@event4u/agent-config 14.14.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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/pr/create.md +16 -1
- package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
- package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +5 -5
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +528 -371
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +185 -98
- package/package.json +1 -1
- package/src/config/assurance-capability-registry.json +1 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +62 -2
- package/src/config/gate-reachability-exemptions.json +1 -0
- package/src/config/gate-violation-baselines.json +4 -3
- package/src/config/web-launch-readiness.json +4 -2
- package/src/scripts/_cli/_doctor_emit.ts +71 -0
- package/src/scripts/_cli/_doctor_strict.ts +163 -0
- package/src/scripts/_cli/cmd_doctor.ts +16 -37
- package/src/scripts/_dispatch.bash +11 -0
- package/src/scripts/_lib/branch_convergence.ts +186 -0
- package/src/scripts/_lib/context_observation.ts +132 -0
- package/src/scripts/_lib/continuation_ladder.ts +188 -0
- package/src/scripts/_lib/experiment_binding.ts +95 -0
- package/src/scripts/_lib/ignored_blocker.ts +8 -1
- package/src/scripts/_lib/outcome_envelope.ts +6 -1
- package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
- package/src/scripts/_lib/release_highlights.ts +12 -3
- package/src/scripts/_lib/repeated_failure.ts +19 -2
- package/src/scripts/_lib/runtime_journal.ts +11 -4
- package/src/scripts/check_agent_artifact_location.ts +337 -0
- package/src/scripts/check_artefact_count_messaging.ts +74 -8
- package/src/scripts/check_enforcement_coverage.ts +40 -1
- package/src/scripts/check_release_highlights.ts +17 -1
- package/src/scripts/check_web_launch_readiness.ts +310 -20
- package/src/scripts/evolution_lab.ts +53 -1
- package/src/scripts/generate_capability_matrix.ts +16 -9
- package/src/scripts/grade_target_readiness.ts +221 -2
- package/src/scripts/hook_effect_doctor.ts +47 -4
- package/src/scripts/hooks/run_continuation_hook.ts +78 -88
- package/src/scripts/hooks/session_eol_hook.ts +12 -1
- package/src/scripts/install-hooks.sh +18 -0
- package/src/scripts/lint_code_comments.ts +106 -1
- package/src/scripts/release_drill.ts +57 -1
- package/src/scripts/release_publication.ts +58 -0
- package/src/scripts/roadmap_context.ts +15 -0
- package/src/scripts/skill_scout.ts +633 -0
- 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
|
-
/**
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
307
|
-
return { base: `origin/${b}`, how: `the open PR base (${b})` };
|
|
308
|
-
}
|
|
370
|
+
return typeof b === 'string' && b !== '' ? b : null;
|
|
309
371
|
} catch {
|
|
310
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
342
|
-
|
|
343
|
-
|
|
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: `${
|
|
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:
|
|
787
|
+
scanned: order.length,
|
|
353
788
|
};
|
|
354
789
|
}
|
|
355
790
|
|
|
356
|
-
const
|
|
357
|
-
|
|
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
|
-
|
|
362
|
-
'
|
|
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:
|
|
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
|
|
371
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|