@event4u/agent-config 14.13.0 → 14.14.1

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 (138) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +138 -376
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/analyze/inbox.md +36 -0
  7. package/dist/agent-src/commands/pr/create.md +16 -1
  8. package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
  9. package/dist/agent-src/rules/code-comment-discipline.md +6 -0
  10. package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
  11. package/dist/agent-src/rules/token-budget-discipline.md +2 -2
  12. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  13. package/dist/agent-src/scripts/guarded_baseline.ts +22 -7
  14. package/dist/agent-src/scripts/stubs_due.ts +118 -2
  15. package/dist/agent-src/scripts/update_roadmap_progress.ts +8 -8
  16. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  17. package/dist/cli/registry.js +1 -0
  18. package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
  19. package/dist/cli-delegate/cmd_conformance.js +39 -2
  20. package/dist/cli-delegate/cmd_doctor.js +1 -1
  21. package/dist/discovery/deprecation-report.md +1 -1
  22. package/dist/discovery/discovery-manifest.json +7 -7
  23. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  24. package/dist/discovery/discovery-manifest.summary.md +1 -1
  25. package/dist/discovery/dormancy-report.md +1 -1
  26. package/dist/discovery/orphan-report.md +1 -1
  27. package/dist/discovery/packs.json +2 -2
  28. package/dist/discovery/trust-report.md +1 -1
  29. package/dist/discovery/workspaces.json +2 -2
  30. package/dist/hooks/dispatch.js +1696 -812
  31. package/dist/mcp/registry-manifest.json +2 -2
  32. package/dist/mcp/server.json +2 -2
  33. package/dist/mcp/server.mjs +297 -115
  34. package/docs/guidelines/agent-infra/guarded-baseline.md +1 -1
  35. package/docs/guidelines/code-clarity.md +18 -0
  36. package/package.json +1 -1
  37. package/src/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  38. package/src/agent-src/scripts/guarded_baseline.ts +22 -7
  39. package/src/agent-src/scripts/stubs_due.ts +118 -2
  40. package/src/agent-src/scripts/update_roadmap_progress.ts +8 -8
  41. package/src/config/assurance-capability-registry.json +1 -1
  42. package/src/config/estate-count-budget.json +20 -1
  43. package/src/config/evaluator-budgets.json +4 -4
  44. package/src/config/gate-coverage.yml +211 -1
  45. package/src/config/gate-reachability-exemptions.json +1 -0
  46. package/src/config/gate-violation-baselines.json +9 -3
  47. package/src/config/preamble-payload-budget.json +19 -11
  48. package/src/config/rule-stub-ceilings.json +14 -7
  49. package/src/config/web-launch-readiness.json +4 -2
  50. package/src/scripts/_cli/_doctor_emit.ts +71 -0
  51. package/src/scripts/_cli/_doctor_strict.ts +163 -0
  52. package/src/scripts/_cli/cmd_doctor.ts +16 -37
  53. package/src/scripts/_dispatch.bash +11 -4
  54. package/src/scripts/_lib/activation_ladder.ts +47 -3
  55. package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
  56. package/src/scripts/_lib/adr_frontmatter.ts +26 -0
  57. package/src/scripts/_lib/branch_convergence.ts +186 -0
  58. package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
  59. package/src/scripts/_lib/carrier_status.ts +118 -0
  60. package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
  61. package/src/scripts/_lib/context_observation.ts +132 -0
  62. package/src/scripts/_lib/continuation_ladder.ts +188 -0
  63. package/src/scripts/_lib/corpus_manifest.ts +726 -0
  64. package/src/scripts/_lib/delivery_arm_experiment.ts +18 -2
  65. package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
  66. package/src/scripts/_lib/evaluation_cascade.ts +452 -0
  67. package/src/scripts/_lib/evolution_roi.ts +562 -0
  68. package/src/scripts/_lib/experiment_binding.ts +95 -0
  69. package/src/scripts/_lib/harness_evolution_guards.ts +10 -0
  70. package/src/scripts/_lib/ignored_blocker.ts +8 -1
  71. package/src/scripts/_lib/lexical_shortlist.ts +215 -0
  72. package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
  73. package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
  74. package/src/scripts/_lib/outcome_envelope.ts +6 -1
  75. package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
  76. package/src/scripts/_lib/pathology_archive.ts +393 -0
  77. package/src/scripts/_lib/promotion_capability.ts +315 -0
  78. package/src/scripts/_lib/promotion_evidence.ts +414 -0
  79. package/src/scripts/_lib/promotion_review.ts +294 -0
  80. package/src/scripts/_lib/prompt_shape.ts +92 -0
  81. package/src/scripts/_lib/release_highlights.ts +79 -1
  82. package/src/scripts/_lib/release_material.ts +125 -2
  83. package/src/scripts/_lib/repeated_failure.ts +19 -2
  84. package/src/scripts/_lib/routing_corpus.ts +307 -0
  85. package/src/scripts/_lib/routing_index_input.ts +104 -0
  86. package/src/scripts/_lib/rule_injection.ts +22 -1
  87. package/src/scripts/_lib/runtime_journal.ts +11 -4
  88. package/src/scripts/_lib/semantic_noop.ts +167 -0
  89. package/src/scripts/activation_receipt.ts +258 -0
  90. package/src/scripts/ai_council/cross_exam.ts +128 -0
  91. package/src/scripts/ai_council/explain_route.ts +157 -0
  92. package/src/scripts/ai_council/information_gain.ts +293 -0
  93. package/src/scripts/ai_council/leakage_corpus.ts +385 -0
  94. package/src/scripts/ai_council/leakage_patterns.ts +453 -0
  95. package/src/scripts/ai_council/minority_retention.ts +171 -0
  96. package/src/scripts/ai_council/recouncil_savings.ts +299 -0
  97. package/src/scripts/ai_council/replay_route.ts +223 -0
  98. package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
  99. package/src/scripts/ai_council/routing_training_row.ts +197 -0
  100. package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
  101. package/src/scripts/check_adr_frontmatter.ts +4 -9
  102. package/src/scripts/check_agent_artifact_location.ts +337 -0
  103. package/src/scripts/check_artefact_count_messaging.ts +74 -8
  104. package/src/scripts/check_enforcement_coverage.ts +40 -1
  105. package/src/scripts/check_estate_count.ts +205 -24
  106. package/src/scripts/check_release_highlights.ts +113 -14
  107. package/src/scripts/check_roadmap_trackable.ts +17 -2
  108. package/src/scripts/check_web_launch_readiness.ts +310 -20
  109. package/src/scripts/corpus_manifest.ts +235 -0
  110. package/src/scripts/evolution_lab.ts +234 -9
  111. package/src/scripts/generate_capability_matrix.ts +16 -9
  112. package/src/scripts/grade_target_readiness.ts +221 -2
  113. package/src/scripts/hook_effect_doctor.ts +47 -4
  114. package/src/scripts/hook_manifest.json +1 -1
  115. package/src/scripts/hook_manifest.yaml +28 -6
  116. package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
  117. package/src/scripts/hooks/concern_registry.ts +2 -0
  118. package/src/scripts/hooks/run_continuation_hook.ts +78 -88
  119. package/src/scripts/hooks/session_eol_hook.ts +12 -1
  120. package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
  121. package/src/scripts/install-hooks.sh +18 -0
  122. package/src/scripts/language_mirror_hook.ts +14 -2
  123. package/src/scripts/lint_carrier_integrity.ts +820 -0
  124. package/src/scripts/lint_code_comments.ts +748 -0
  125. package/src/scripts/lint_plan_risk_register.ts +39 -19
  126. package/src/scripts/lint_promotion_paths.ts +779 -0
  127. package/src/scripts/llm_propose.ts +256 -0
  128. package/src/scripts/measure_delivery_sets.ts +361 -0
  129. package/src/scripts/measure_routing_signal.ts +307 -0
  130. package/src/scripts/model_rule_injection.ts +19 -3
  131. package/src/scripts/probe_council_retention.ts +495 -0
  132. package/src/scripts/release.ts +21 -37
  133. package/src/scripts/release_drill.ts +199 -0
  134. package/src/scripts/release_env.ts +35 -0
  135. package/src/scripts/release_publication.ts +142 -0
  136. package/src/scripts/roadmap_context.ts +15 -0
  137. package/src/scripts/skill_scout.ts +633 -0
  138. package/src/scripts/sync_pr_branch.ts +521 -42
@@ -34,6 +34,12 @@ import * as path from 'node:path';
34
34
  import { spawnSync } from 'node:child_process';
35
35
  import { fileURLToPath, pathToFileURL } from 'node:url';
36
36
 
37
+ import {
38
+ MissingBranchConvergencePolicy,
39
+ UnresolvableTargetSha,
40
+ loadPolicyAtSha,
41
+ type ShaFileReader,
42
+ } from './_lib/branch_convergence.js';
37
43
  import { reportScanned } from './_lib/scan_scope.js';
38
44
 
39
45
  const NETWORK_TIMEOUT_MS = 8_000;
@@ -288,41 +294,462 @@ export function renderConflictReport(plan: Plan): string {
288
294
  return out.join('');
289
295
  }
290
296
 
291
- /** Resolve the base this branch will actually merge into. */
292
- export function resolveBase(repo: string, override: string | null): { base: string | null; how: string } {
293
- if (override !== null && override.trim() !== '') {
294
- return { base: override.trim(), how: 'given by --base' };
295
- }
296
- const branch = sh('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repo).out.trim();
297
- if (branch !== '' && branch !== 'HEAD') {
298
- // The forge knows the REAL base, which matters for a stacked or
299
- // release-line PR: measuring against the repo default would compare
300
- // against a branch this PR never merges into.
301
- const pr = sh('gh', ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'baseRefName', '--limit', '1'], repo);
302
- if (pr.ok) {
297
+ /**
298
+ * Why the base is a SET now, and what each entry means.
299
+ *
300
+ * The old resolution was an EXCLUSIVE chain — `--base`, then the open PR's
301
+ * base, then `origin/HEAD` — so a PR targeting a release line or a stacked
302
+ * parent was kept current with its target and arbitrarily stale against the
303
+ * default branch. Whether the default belongs in the set is a policy question
304
+ * decided per target and read from the TARGET's own commit; see
305
+ * `_lib/branch_convergence.ts` for the decision and the trust boundary.
306
+ */
307
+ export type BaseReason =
308
+ | 'pull-request-target'
309
+ | 'explicit-base-override'
310
+ | 'repository-default-branch'
311
+ | 'branch-convergence-policy:include-default';
312
+
313
+ export interface BaseEntry {
314
+ readonly ref: string;
315
+ readonly reason: BaseReason;
316
+ }
317
+
318
+ /**
319
+ * One stable structured type for every outcome.
320
+ *
321
+ * Deliberately NOT "an array normally, an object when the policy is off": a
322
+ * shape that varies by outcome couples every consumer to ad-hoc type
323
+ * discrimination, which the council flagged as a real architectural objection.
324
+ */
325
+ export interface ResolveBaseResult {
326
+ readonly entries: readonly BaseEntry[];
327
+ readonly policyStatus: 'applied' | 'not-required' | 'disabled';
328
+ }
329
+
330
+ /** Neither an open PR nor a default branch answered. Distinct from a missing policy. */
331
+ export class UnresolvableBase extends Error {
332
+ constructor(detail: string) {
333
+ super(`unresolvable — ${detail}`);
334
+ this.name = 'UnresolvableBase';
335
+ }
336
+ }
337
+
338
+ /**
339
+ * The git questions base resolution asks, as data.
340
+ *
341
+ * Injected rather than called inline so the twelve council fixtures can be
342
+ * exercised without a network, a forge, or a real release line — this
343
+ * repository has none, so a live fixture could only ever cover the
344
+ * default-target path.
345
+ */
346
+ export interface BaseDeps {
347
+ readonly currentBranch: () => string;
348
+ /** The open PR's `baseRefName`, bare (`release/1.x`), or null. */
349
+ readonly prBase: (branch: string) => string | null;
350
+ /** The default branch as a remote-tracking ref (`origin/main`), or null. */
351
+ readonly defaultBranch: () => string | null;
352
+ /** The SHA the server reports for a ref right now, or null. */
353
+ readonly remoteSha: (ref: string) => string | null;
354
+ readonly readAtSha: ShaFileReader;
355
+ }
356
+
357
+ export function makeGitDeps(repo: string): BaseDeps {
358
+ return {
359
+ currentBranch: (): string => sh('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repo).out.trim(),
360
+ prBase: (branch: string): string | null => {
361
+ if (branch === '' || branch === 'HEAD') return null;
362
+ // The forge knows the REAL base, which matters for a stacked or
363
+ // release-line PR: measuring against the repo default would compare
364
+ // against a branch this PR never merges into.
365
+ const pr = sh('gh', ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'baseRefName', '--limit', '1'], repo);
366
+ if (!pr.ok) return null;
303
367
  try {
304
368
  const rows = JSON.parse(pr.out || '[]') as Array<{ baseRefName?: string }>;
305
369
  const b = rows[0]?.baseRefName;
306
- if (typeof b === 'string' && b !== '') {
307
- return { base: `origin/${b}`, how: `the open PR base (${b})` };
308
- }
370
+ return typeof b === 'string' && b !== '' ? b : null;
309
371
  } catch {
310
- /* fall through to the default-branch probe */
372
+ return null;
311
373
  }
374
+ },
375
+ // ASK THE SERVER first, and only then fall back to the local ref.
376
+ //
377
+ // The local-only form was this module's whole answer until the default
378
+ // branch became load-bearing here: the policy's `include` needs a ref to
379
+ // add, and the target-is-default identity check needs one to compare
380
+ // against. Measured in this worktree on 2026-09-03 — `refs/remotes/
381
+ // origin/HEAD` is not set, so the local form returned null and
382
+ // `sync_pr_branch` reported "no open PR and no origin/HEAD" while
383
+ // `check_branch_freshness`, standing three lines away in the same
384
+ // sequence, resolved `origin/main` from the server symref and passed.
385
+ // Two resolvers disagreeing about the base is the defect this roadmap
386
+ // exists to close, one layer down. Mirrors
387
+ // `check_branch_freshness.ts:223` `serverDefaultBase`.
388
+ defaultBranch: (): string | null => {
389
+ const remote = sh('git', ['ls-remote', '--symref', 'origin', 'HEAD'], repo);
390
+ const fromServer = remote.ok ? parseSymrefDefault(remote.out) : null;
391
+ if (fromServer !== null) return fromServer;
392
+ const head = sh('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], repo);
393
+ return head.ok && head.out.trim() !== '' ? head.out.trim() : null;
394
+ },
395
+ remoteSha: (ref: string): string | null => {
396
+ const bare = ref.replace(/^origin\//, '');
397
+ const out = sh('git', ['ls-remote', '--heads', 'origin', bare], repo);
398
+ if (!out.ok) return null;
399
+ const sha = out.out.trim().split(/\s+/)[0];
400
+ return sha !== undefined && /^[0-9a-f]{40}$/.test(sha) ? sha : null;
401
+ },
402
+ // `git show <sha>:<path>` and nothing else. There is no filesystem read
403
+ // here by construction: a policy the PR head carries must not be able to
404
+ // change the criteria the PR is judged against.
405
+ readAtSha: (sha: string, rel: string): string | null => {
406
+ const out = sh('git', ['show', `${sha}:${rel}`], repo);
407
+ return out.ok ? out.out : null;
408
+ },
409
+ };
410
+ }
411
+
412
+ /**
413
+ * The default branch the SERVER reports, from `git ls-remote --symref origin HEAD`.
414
+ *
415
+ * Pure and exported so the parse is testable without a network round trip, and
416
+ * because it closes a measured defect rather than a hypothetical one. Until
417
+ * 2026-09-03 this module read the default branch from `refs/remotes/origin/HEAD`
418
+ * alone — a clone-time ref that is simply absent in some checkouts. Measured in
419
+ * a worktree of this repository the same day: the local form returned null, so
420
+ * `sync_pr_branch` refused with "no open PR and no origin/HEAD" while
421
+ * `check_branch_freshness`, three lines later in the same documented sequence,
422
+ * resolved `origin/main` from the server symref and passed. Two resolvers
423
+ * disagreeing about the base is this roadmap's own subject one layer down, and
424
+ * the default branch stopped being cosmetic here the moment the policy's
425
+ * `include` needed a ref to add. Mirrors `check_branch_freshness.ts:223`.
426
+ */
427
+ export function parseSymrefDefault(lsRemoteOut: string): string | null {
428
+ for (const line of lsRemoteOut.split('\n')) {
429
+ const t = line.trim();
430
+ if (!t.startsWith('ref:')) continue;
431
+ const name = t.slice('ref:'.length).trim().split(/\s+/)[0] ?? '';
432
+ if (!name.startsWith('refs/heads/')) continue;
433
+ const short = name.slice('refs/heads/'.length).trim();
434
+ if (short !== '') return `origin/${short}`;
435
+ }
436
+ return null;
437
+ }
438
+
439
+ /** Strip the remote prefix so a policy key is the branch name a human writes. */
440
+ function bareName(ref: string): string {
441
+ return ref.replace(/^origin\//, '');
442
+ }
443
+
444
+ /**
445
+ * Resolve the base SET this branch has to be current with.
446
+ *
447
+ * Throws rather than returning a partial set: `MissingBranchConvergencePolicy`
448
+ * when a non-default target names no entry, `UnresolvableTargetSha` when the
449
+ * target names no commit, `UnresolvableBase` when nothing answered at all.
450
+ * Neither inclusion nor exclusion is universally safe, so an absent entry must
451
+ * not manufacture repository intent.
452
+ */
453
+ export function resolveBase(repo: string, override: string | null, deps: BaseDeps = makeGitDeps(repo)): ResolveBaseResult {
454
+ const defaultRef = deps.defaultBranch();
455
+
456
+ let target: BaseEntry;
457
+ if (override !== null && override.trim() !== '') {
458
+ target = { ref: override.trim(), reason: 'explicit-base-override' };
459
+ } else {
460
+ const pr = deps.prBase(deps.currentBranch());
461
+ if (pr !== null) {
462
+ target = { ref: `origin/${pr}`, reason: 'pull-request-target' };
463
+ } else if (defaultRef !== null) {
464
+ target = { ref: defaultRef, reason: 'repository-default-branch' };
465
+ } else {
466
+ throw new UnresolvableBase('no open PR and no origin/HEAD');
312
467
  }
313
468
  }
314
- const head = sh('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], repo);
315
- if (head.ok && head.out.trim() !== '') {
316
- return { base: head.out.trim(), how: 'the repo default branch' };
469
+
470
+ // A PR targeting the default branch needs no entry — identity by ref name,
471
+ // or by the SHA both names resolve to.
472
+ const sameName = defaultRef !== null && bareName(target.ref) === bareName(defaultRef);
473
+ const targetSha = deps.remoteSha(target.ref);
474
+ const defaultSha = defaultRef === null ? null : deps.remoteSha(defaultRef);
475
+ const sameSha = targetSha !== null && defaultSha !== null && targetSha === defaultSha;
476
+ if (sameName || sameSha) {
477
+ return { entries: [target], policyStatus: 'not-required' };
478
+ }
479
+
480
+ if (targetSha === null) {
481
+ throw new UnresolvableTargetSha(bareName(target.ref));
482
+ }
483
+ const policy = loadPolicyAtSha(targetSha, deps.readAtSha);
484
+ if (policy === null) {
485
+ throw new MissingBranchConvergencePolicy(bareName(target.ref));
486
+ }
487
+ if (!policy.enabled) {
488
+ // The kill switch. Surfaced as BYPASSED by `renderBaseSummary`, never as
489
+ // a pass — a caller that discards stderr would otherwise read a bypass
490
+ // as a clean run.
491
+ return { entries: [target], policyStatus: 'disabled' };
492
+ }
493
+ const entry = policy.targets[bareName(target.ref)];
494
+ if (entry === undefined) {
495
+ throw new MissingBranchConvergencePolicy(bareName(target.ref));
317
496
  }
318
- return { base: null, how: 'unresolvable — no open PR and no origin/HEAD' };
497
+ if (entry.defaultBranch === 'exclude') {
498
+ return { entries: [target], policyStatus: 'applied' };
499
+ }
500
+ if (defaultRef === null) {
501
+ throw new UnresolvableBase('policy says include the default branch, but origin/HEAD names none');
502
+ }
503
+ return {
504
+ entries: [target, { ref: defaultRef, reason: 'branch-convergence-policy:include-default' }],
505
+ policyStatus: 'applied',
506
+ };
319
507
  }
320
508
 
321
- export function sync(repo: string, baseOverride: string | null, dryRun: boolean): Plan {
322
- const { base, how } = resolveBase(repo, baseOverride);
323
- if (base === null) {
324
- return { exit: 1, message: `cannot resolve a base to update against — ${how}.`, generated: [], remeasured: [], authored: [], scanned: 0 };
509
+ /**
510
+ * The order the refs are MERGED in — default first, then the target.
511
+ *
512
+ * Deliberately not the order `entries` carries. The result type is target-first
513
+ * because the target is the ref the PR actually merges into and the stable head
514
+ * of the contract (council § 4, fixture 4); integration is default-first so a
515
+ * conflict surfaces against the BROADER base before the narrower one, where it
516
+ * is cheapest to abandon (roadmap step 1.3). Both orders are stated here rather
517
+ * than one being inferred from the other.
518
+ */
519
+ export function integrationOrder(r: ResolveBaseResult): string[] {
520
+ const policyAdded = r.entries.filter((e) => e.reason === 'branch-convergence-policy:include-default');
521
+ const rest = r.entries.filter((e) => e.reason !== 'branch-convergence-policy:include-default');
522
+ return [...policyAdded, ...rest].map((e) => e.ref);
523
+ }
524
+
525
+ /** Human-readable provenance per entry — the `how` string, one per ref. */
526
+ export function describeReason(reason: BaseReason): string {
527
+ switch (reason) {
528
+ case 'explicit-base-override':
529
+ return 'given by --base';
530
+ case 'pull-request-target':
531
+ return 'the open PR base';
532
+ case 'repository-default-branch':
533
+ return 'the repo default branch';
534
+ case 'branch-convergence-policy:include-default':
535
+ return 'the default branch, added by the branch-convergence policy';
325
536
  }
537
+ }
538
+
539
+ /**
540
+ * The verdict line for a resolved set, in INTEGRATION order.
541
+ *
542
+ * `disabled` renders as BYPASSED and carries no success marker. A stderr
543
+ * warning is not enough — callers discard stderr, and a bypass reported only
544
+ * there is indistinguishable from a pass.
545
+ */
546
+ export function renderBaseSummary(r: ResolveBaseResult): string {
547
+ const byRef = new Map(r.entries.map((e) => [e.ref, e.reason]));
548
+ const parts = integrationOrder(r).map((ref) => `${ref} (${describeReason(byRef.get(ref) as BaseReason)})`);
549
+ const head = r.policyStatus === 'disabled'
550
+ ? 'BYPASSED — branch-convergence policy disabled at the target commit; the default branch was NOT considered'
551
+ : r.policyStatus === 'applied'
552
+ ? 'branch-convergence policy applied'
553
+ : 'no branch-convergence policy required (target is the default branch)';
554
+ return `${head}; integrating in order: ${parts.join(' → ')}`;
555
+ }
556
+
557
+ /** The ceiling on base-moved retries. A bound, never a queue (roadmap 4.2). */
558
+ export const MAX_BASE_ATTEMPTS = 3;
559
+
560
+ /** One integration attempt against one ref, with the OIDs that bracket it. */
561
+ export interface IntegrationAttempt {
562
+ readonly attempt: number;
563
+ readonly ref: string;
564
+ /** The OID the merge was planned against. */
565
+ readonly before: string | null;
566
+ /** The OID the server reported after the merge finished. */
567
+ readonly after: string | null;
568
+ }
569
+
570
+ export interface IntegrationOutcome {
571
+ readonly ok: boolean;
572
+ readonly attempts: readonly IntegrationAttempt[];
573
+ readonly conflicted: readonly string[];
574
+ readonly message: string;
575
+ }
576
+
577
+ /** The git operations the retry loop needs, injected so a moving base is testable. */
578
+ export interface IntegrateOps {
579
+ readonly remoteSha: (ref: string) => string | null;
580
+ readonly merge: (ref: string) => { ok: boolean; conflicted: string[] };
581
+ }
582
+
583
+ function renderAttempts(attempts: readonly IntegrationAttempt[]): string {
584
+ return attempts
585
+ .map((a) => ` attempt ${String(a.attempt)}: ${a.ref} ${a.before ?? '?'} → ${a.after ?? '?'}`)
586
+ .join('\n');
587
+ }
588
+
589
+ /**
590
+ * Integrate every ref in the set, pinning each base OID and re-checking it.
591
+ *
592
+ * Gap C is measured, not hypothetical: `sync_pr_branch.ts:10` records PR #1391,
593
+ * where the base moved three times during one run and the push was rejected.
594
+ * The bound is three attempts and then a STOP carrying the observed OIDs —
595
+ * reporting the evidence is what makes a genuinely moving base distinguishable
596
+ * from a slow run, which is the whole point of the ceiling. No state outside the
597
+ * run: no queue, no rerere, no persisted attempt log (roadmap 4.2, AC-5).
598
+ */
599
+ export function integrateWithPinnedBase(refs: readonly string[], ops: IntegrateOps): IntegrationOutcome {
600
+ const attempts: IntegrationAttempt[] = [];
601
+ for (let n = 1; n <= MAX_BASE_ATTEMPTS; n++) {
602
+ const pinned = refs.map((ref) => ({ ref, before: ops.remoteSha(ref) }));
603
+ let conflicted: string[] = [];
604
+ let clean = true;
605
+ for (const q of pinned) {
606
+ const m = ops.merge(q.ref);
607
+ if (!m.ok) {
608
+ clean = false;
609
+ conflicted = m.conflicted;
610
+ break;
611
+ }
612
+ }
613
+ const moved: string[] = [];
614
+ for (const q of pinned) {
615
+ const after = ops.remoteSha(q.ref);
616
+ attempts.push({ attempt: n, ref: q.ref, before: q.before, after });
617
+ if (after !== q.before) moved.push(q.ref);
618
+ }
619
+ if (!clean) {
620
+ return {
621
+ ok: false,
622
+ attempts,
623
+ conflicted,
624
+ message: `merge hit ${String(conflicted.length)} conflict(s) on attempt ${String(n)}.`,
625
+ };
626
+ }
627
+ if (moved.length === 0) {
628
+ return { ok: true, attempts, conflicted: [], message: `integrated ${refs.join(', ')} on attempt ${String(n)}.` };
629
+ }
630
+ }
631
+ return {
632
+ ok: false,
633
+ attempts,
634
+ conflicted: [],
635
+ message:
636
+ `the base moved under every one of ${String(MAX_BASE_ATTEMPTS)} attempts — stopping rather than looping.\n` +
637
+ `${renderAttempts(attempts)}\n` +
638
+ ' → a base that moves this fast is a finding about landing speed, not a transient race.',
639
+ };
640
+ }
641
+
642
+ /** The regeneration operations Phase 3 needs, injected so no real conflict is required. */
643
+ export interface RegenOps {
644
+ readonly regenerate: () => { ok: boolean; err: string };
645
+ /** Paths that still differ after a regeneration — the byte-identity probe. */
646
+ readonly dirty: (paths: readonly string[]) => string[];
647
+ readonly stage: (paths: readonly string[]) => boolean;
648
+ }
649
+
650
+ export interface GeneratedResolution {
651
+ readonly resolved: boolean;
652
+ readonly message: string;
653
+ }
654
+
655
+ /**
656
+ * Auto-resolve a conflict set that is GENERATED and nothing else.
657
+ *
658
+ * For a path whose only correct resolution is "run the generator", refusing is
659
+ * ceremony — the classification at `classifyConflicts` already knows which
660
+ * paths those are. Two hard limits, and both are the point rather than caution:
661
+ *
662
+ * - A single REMEASURED or AUTHORED path in the set refuses the WHOLE set. A
663
+ * measured baseline and a hand-written file have no single correct
664
+ * resolution, and resolving their neighbours first would hand the human a
665
+ * half-resolved tree to reason about.
666
+ * - Byte-identity is ASSERTED, not assumed: the generator runs, the outputs are
667
+ * staged, and the generator runs again. A path that is partly hand-edited does
668
+ * not reproduce, so the second run leaves it dirty and the resolution is
669
+ * refused instead of silently overwriting the hand edit (risk-register row 2).
670
+ */
671
+ export function autoResolveGenerated(
672
+ split: Pick<Plan, 'generated' | 'remeasured' | 'authored'>,
673
+ ops: RegenOps,
674
+ ): GeneratedResolution {
675
+ if (split.remeasured.length > 0 || split.authored.length > 0) {
676
+ return {
677
+ resolved: false,
678
+ message:
679
+ 'NOT auto-resolved: the conflict set contains ' +
680
+ `${String(split.remeasured.length)} remeasured and ${String(split.authored.length)} authored path(s), ` +
681
+ 'which have no single correct resolution.',
682
+ };
683
+ }
684
+ if (split.generated.length === 0) {
685
+ return { resolved: false, message: 'nothing to auto-resolve.' };
686
+ }
687
+ const first = ops.regenerate();
688
+ if (!first.ok) {
689
+ return { resolved: false, message: `NOT auto-resolved: regeneration failed — ${first.err.split('\n')[0] ?? '?'}` };
690
+ }
691
+ if (!ops.stage(split.generated)) {
692
+ return { resolved: false, message: 'NOT auto-resolved: could not stage the regenerated paths.' };
693
+ }
694
+ const second = ops.regenerate();
695
+ if (!second.ok) {
696
+ return { resolved: false, message: `NOT auto-resolved: the byte-identity re-run failed — ${second.err.split('\n')[0] ?? '?'}` };
697
+ }
698
+ const drift = ops.dirty(split.generated);
699
+ if (drift.length > 0) {
700
+ return {
701
+ resolved: false,
702
+ message:
703
+ 'NOT auto-resolved: these paths are not byte-identical to a clean regeneration, ' +
704
+ `so at least one is partly hand-edited — ${drift.join(', ')}`,
705
+ };
706
+ }
707
+ return {
708
+ resolved: true,
709
+ message: `auto-resolved ${String(split.generated.length)} generated conflict(s) by regeneration, byte-identity asserted.`,
710
+ };
711
+ }
712
+
713
+ function gitRegenOps(repo: string): RegenOps {
714
+ return {
715
+ // The repository's own consistency target — the exact CI mirror the
716
+ // pre-push hook already runs. Named here rather than open-coded so the
717
+ // generator this trusts is the one the gate trusts.
718
+ regenerate: (): { ok: boolean; err: string } => {
719
+ const r = sh('task', ['consistency'], repo);
720
+ return { ok: r.ok, err: r.err };
721
+ },
722
+ dirty: (paths: readonly string[]): string[] => {
723
+ const r = sh('git', ['status', '--porcelain', '--', ...paths], repo);
724
+ if (!r.ok) return [...paths];
725
+ return r.out
726
+ .split('\n')
727
+ .map((l) => l.slice(3).trim())
728
+ .filter((l) => l !== '');
729
+ },
730
+ stage: (paths: readonly string[]): boolean => sh('git', ['add', '--', ...paths], repo).ok,
731
+ };
732
+ }
733
+
734
+ export function sync(repo: string, baseOverride: string | null, dryRun: boolean, autoResolve = false): Plan {
735
+ let resolved: ResolveBaseResult;
736
+ try {
737
+ resolved = resolveBase(repo, baseOverride);
738
+ } catch (exc) {
739
+ // A missing policy, an unresolvable target SHA and an unresolvable base
740
+ // are all REFUSALS carrying their own typed message. None of them
741
+ // degrades to "check the default branch instead".
742
+ return {
743
+ exit: 1,
744
+ message: `cannot resolve a base set to update against — ${exc instanceof Error ? exc.message : String(exc)}`,
745
+ generated: [],
746
+ remeasured: [],
747
+ authored: [],
748
+ scanned: 0,
749
+ };
750
+ }
751
+ const summary = renderBaseSummary(resolved);
752
+ const order = integrationOrder(resolved);
326
753
 
327
754
  const fetched = sh('git', ['fetch', 'origin', '--prune'], repo);
328
755
  if (!fetched.ok) {
@@ -338,44 +765,83 @@ export function sync(repo: string, baseOverride: string | null, dryRun: boolean)
338
765
  };
339
766
  }
340
767
 
341
- const behind = sh('git', ['rev-list', '--count', `HEAD..${base}`], repo).out.trim();
342
- if (behind === '' || behind === '0') {
343
- return { exit: 0, message: `already current with ${base} (${how}).`, generated: [], remeasured: [], authored: [], scanned: 1 };
768
+ const behindEach = order.map((ref) => ({
769
+ ref,
770
+ behind: Number(sh('git', ['rev-list', '--count', `HEAD..${ref}`], repo).out.trim() || '0'),
771
+ }));
772
+ // De-duplicated by ancestry: a ref already contained in HEAD is 0 behind and
773
+ // is not merged again, which is what keeps a target that already contains
774
+ // the default from being merged twice.
775
+ const stale = behindEach.filter((b) => b.behind > 0);
776
+ if (stale.length === 0) {
777
+ return { exit: 0, message: `already current with every base ref — ${summary}.`, generated: [], remeasured: [], authored: [], scanned: order.length };
344
778
  }
345
779
  if (dryRun) {
780
+ const detail = stale.map((b) => `${b.ref} (${String(b.behind)} behind)`).join(', ');
346
781
  return {
347
782
  exit: 0,
348
- message: `${behind} commit(s) behind ${base} (${how}) — would merge it in. Dry run, nothing changed.`,
783
+ message: `${summary}. Behind: ${detail} — would merge in that order. Dry run, nothing changed.`,
349
784
  generated: [],
350
785
  remeasured: [],
351
786
  authored: [],
352
- scanned: 1,
787
+ scanned: order.length,
353
788
  };
354
789
  }
355
790
 
356
- const merge = sh('git', ['merge', base, '--no-edit'], repo);
357
- if (merge.ok) {
791
+ const outcome = integrateWithPinnedBase(
792
+ stale.map((b) => b.ref),
793
+ {
794
+ remoteSha: (ref: string): string | null => makeGitDeps(repo).remoteSha(ref),
795
+ merge: (ref: string): { ok: boolean; conflicted: string[] } => {
796
+ const m = sh('git', ['merge', ref, '--no-edit'], repo);
797
+ if (m.ok) return { ok: true, conflicted: [] };
798
+ return {
799
+ ok: false,
800
+ conflicted: sh('git', ['diff', '--name-only', '--diff-filter=U'], repo).out.split('\n'),
801
+ };
802
+ },
803
+ },
804
+ );
805
+
806
+ if (outcome.ok) {
358
807
  return {
359
808
  exit: 0,
360
809
  message:
361
- `merged ${base} in (${how}, was ${behind} behind). REGENERATE derived files now — a clean ` +
362
- 'auto-merge of a generated file is still wrong.',
810
+ `${summary}. ${outcome.message} REGENERATE derived files now — a clean auto-merge of a ` +
811
+ 'generated file is still wrong.',
363
812
  generated: [],
364
813
  remeasured: [],
365
814
  authored: [],
366
- scanned: 1,
815
+ scanned: order.length,
367
816
  };
368
817
  }
818
+ if (outcome.conflicted.length === 0) {
819
+ // The base kept moving. No conflict to classify; the evidence IS the
820
+ // per-attempt OID list already in the message.
821
+ return { exit: 1, message: `${summary}. ${outcome.message}`, generated: [], remeasured: [], authored: [], scanned: order.length };
822
+ }
369
823
 
370
- const conflicted = sh('git', ['diff', '--name-only', '--diff-filter=U'], repo).out.split('\n');
371
- const split = classifyConflicts(conflicted);
824
+ const split = classifyConflicts(outcome.conflicted);
825
+ if (autoResolve) {
826
+ const attempt = autoResolveGenerated(split, gitRegenOps(repo));
827
+ if (attempt.resolved) {
828
+ return { exit: 0, message: `${summary}. ${attempt.message}`, generated: [], remeasured: [], authored: [], scanned: order.length };
829
+ }
830
+ return {
831
+ exit: 1,
832
+ message: `${summary}. ${outcome.message} ${attempt.message}`,
833
+ ...split,
834
+ scanned: order.length,
835
+ };
836
+ }
372
837
  return {
373
838
  exit: 1,
374
839
  message:
375
- `merge of ${base} (${how}) hit ${String(split.generated.length + split.remeasured.length + split.authored.length)} conflict(s). ` +
376
- 'NOT auto-resolved: a content conflict is where a parallel session\'s work disappears.',
840
+ `${summary}. ${outcome.message} ` +
841
+ 'NOT auto-resolved: a content conflict is where a parallel session\'s work disappears. ' +
842
+ '(--auto-resolve-generated resolves a set that is GENERATED and nothing else.)',
377
843
  ...split,
378
- scanned: 1,
844
+ scanned: order.length,
379
845
  };
380
846
  }
381
847
 
@@ -385,6 +851,7 @@ export function main(argv?: readonly string[]): number {
385
851
  let base: string | null = null;
386
852
  let dryRun = false;
387
853
  let quiet = false;
854
+ let autoResolve = false;
388
855
  for (let i = 0; i < args.length; i++) {
389
856
  const a = args[i] as string;
390
857
  const val = (): string | null => {
@@ -409,16 +876,23 @@ export function main(argv?: readonly string[]): number {
409
876
  base = v;
410
877
  } else if (a === '--dry-run') {
411
878
  dryRun = true;
879
+ } else if (a === '--auto-resolve-generated') {
880
+ autoResolve = true;
412
881
  } else if (a === '--quiet') {
413
882
  quiet = true;
414
883
  } else if (a === '-h' || a === '--help') {
415
884
  process.stdout.write(
416
- 'usage: sync_pr_branch [--repo PATH] [--base REF] [--dry-run] [--quiet]\n' +
885
+ 'usage: sync_pr_branch [--repo PATH] [--base REF] [--dry-run] [--auto-resolve-generated] [--quiet]\n' +
417
886
  ' Merges the PR base into the current branch so the PR does not go stale.\n' +
418
887
  ' Resolves the base from the open PR when there is one. A conflict is\n' +
419
888
  ' reported and never auto-resolved; generated and authored conflicts are\n' +
420
889
  ' listed separately because only the first has one correct resolution;\n' +
421
- ' measured ratchet baselines are a third class, re-measured not merged.\n',
890
+ ' measured ratchet baselines are a third class, re-measured not merged.\n' +
891
+ ' The base is a SET: a non-default target may also carry the default branch,\n' +
892
+ ' per the branch-convergence policy read at the TARGET commit. Integration\n' +
893
+ ' runs the default first so the broad conflict surfaces first.\n' +
894
+ ' --auto-resolve-generated resolves a conflict set that is GENERATED and\n' +
895
+ ' nothing else, by regenerating and asserting byte-identity.\n',
422
896
  );
423
897
  reportScanned({ gate: 'sync_pr_branch', scanned: 0, units: 'base ref(s)', roots: ['origin'], allowEmpty: 'help output' });
424
898
  return 0;
@@ -431,7 +905,7 @@ export function main(argv?: readonly string[]): number {
431
905
 
432
906
  let plan: Plan;
433
907
  try {
434
- plan = sync(repo, base, dryRun);
908
+ plan = sync(repo, base, dryRun, autoResolve);
435
909
  } catch (exc) {
436
910
  reportScanned({ gate: 'sync_pr_branch', scanned: 0, units: 'base ref(s)', roots: ['origin'], allowEmpty: 'internal error' });
437
911
  process.stderr.write(`❌ sync_pr_branch: internal error: ${exc instanceof Error ? exc.message : String(exc)}\n`);
@@ -440,6 +914,11 @@ export function main(argv?: readonly string[]): number {
440
914
 
441
915
  if (plan.exit === 1) {
442
916
  process.stdout.write(renderConflictReport(plan));
917
+ } else if (plan.message.includes('BYPASSED')) {
918
+ // The kill switch is a BYPASS, never a pass. Loud even under --quiet and
919
+ // never behind a success marker: a caller that reads only stdout must
920
+ // not be able to mistake a disabled policy for a clean run.
921
+ process.stdout.write(`⚠️ sync_pr_branch: ${plan.message}\n`);
443
922
  } else if (plan.message.startsWith('unverified')) {
444
923
  // Loud even under --quiet: unverified reported silently is
445
924
  // indistinguishable from verified, which is the defect being closed.