@deftai/directive-runtime 0.129.6 → 0.129.8

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 (149) hide show
  1. package/Taskfile.yml +103 -16
  2. package/commands.md +8 -3
  3. package/dist/session-start.d.ts +8 -2
  4. package/dist/session-start.js +33 -12
  5. package/dist/vbrief-preflight.d.ts +4 -0
  6. package/dist/vbrief-preflight.js +34 -0
  7. package/dist/verify-scope-provenance.d.ts +13 -1
  8. package/dist/verify-scope-provenance.js +44 -1
  9. package/docs/getting-started.md +2 -0
  10. package/docs/scope-provenance.md +15 -1
  11. package/package.json +1 -1
  12. package/packs/skills/skills-pack-0.1.json +1 -1
  13. package/scm/github.md +5 -0
  14. package/skills/deft-directive-review-cycle/SKILL.md +15 -6
  15. package/skills/deft-directive-swarm/references/host-claude-code.md +11 -1
  16. package/skills/deft-directive-swarm/references/host-grok-build.md +3 -2
  17. package/vendor/directive-content/Taskfile.yml +103 -16
  18. package/vendor/directive-content/commands.md +8 -3
  19. package/vendor/directive-content/docs/getting-started.md +2 -0
  20. package/vendor/directive-content/docs/scope-provenance.md +15 -1
  21. package/vendor/directive-content/packs/skills/skills-pack-0.1.json +1 -1
  22. package/vendor/directive-content/scm/github.md +5 -0
  23. package/vendor/directive-content/skills/deft-directive-review-cycle/SKILL.md +15 -6
  24. package/vendor/directive-content/skills/deft-directive-swarm/references/host-claude-code.md +11 -1
  25. package/vendor/directive-content/skills/deft-directive-swarm/references/host-grok-build.md +3 -2
  26. package/vendor/directive-core/dist/.tsbuildinfo +1 -1
  27. package/vendor/directive-core/dist/check/context.d.ts.map +1 -1
  28. package/vendor/directive-core/dist/check/context.js +3 -31
  29. package/vendor/directive-core/dist/check/context.js.map +1 -1
  30. package/vendor/directive-core/dist/check/gate-lists.d.ts.map +1 -1
  31. package/vendor/directive-core/dist/check/gate-lists.js +4 -32
  32. package/vendor/directive-core/dist/check/gate-lists.js.map +1 -1
  33. package/vendor/directive-core/dist/check/walk-test-files.d.ts +3 -0
  34. package/vendor/directive-core/dist/check/walk-test-files.d.ts.map +1 -0
  35. package/vendor/directive-core/dist/check/walk-test-files.js +67 -0
  36. package/vendor/directive-core/dist/check/walk-test-files.js.map +1 -0
  37. package/vendor/directive-core/dist/fs/contained-write.d.ts +8 -2
  38. package/vendor/directive-core/dist/fs/contained-write.d.ts.map +1 -1
  39. package/vendor/directive-core/dist/fs/contained-write.js +22 -6
  40. package/vendor/directive-core/dist/fs/contained-write.js.map +1 -1
  41. package/vendor/directive-core/dist/hooks/dispatcher.d.ts +7 -1
  42. package/vendor/directive-core/dist/hooks/dispatcher.d.ts.map +1 -1
  43. package/vendor/directive-core/dist/hooks/dispatcher.js +180 -10
  44. package/vendor/directive-core/dist/hooks/dispatcher.js.map +1 -1
  45. package/vendor/directive-core/dist/hooks/scope.d.ts +54 -0
  46. package/vendor/directive-core/dist/hooks/scope.d.ts.map +1 -1
  47. package/vendor/directive-core/dist/hooks/scope.js +83 -2
  48. package/vendor/directive-core/dist/hooks/scope.js.map +1 -1
  49. package/vendor/directive-core/dist/init-deposit/agent-hooks.d.ts +26 -1
  50. package/vendor/directive-core/dist/init-deposit/agent-hooks.d.ts.map +1 -1
  51. package/vendor/directive-core/dist/init-deposit/agent-hooks.js +52 -3
  52. package/vendor/directive-core/dist/init-deposit/agent-hooks.js.map +1 -1
  53. package/vendor/directive-core/dist/init-deposit/host-hook-outer.d.ts +43 -0
  54. package/vendor/directive-core/dist/init-deposit/host-hook-outer.d.ts.map +1 -1
  55. package/vendor/directive-core/dist/init-deposit/host-hook-outer.js +148 -0
  56. package/vendor/directive-core/dist/init-deposit/host-hook-outer.js.map +1 -1
  57. package/vendor/directive-core/dist/intake/reconcile-issues.d.ts +33 -1
  58. package/vendor/directive-core/dist/intake/reconcile-issues.d.ts.map +1 -1
  59. package/vendor/directive-core/dist/intake/reconcile-issues.js +288 -12
  60. package/vendor/directive-core/dist/intake/reconcile-issues.js.map +1 -1
  61. package/vendor/directive-core/dist/intake/thin-brief.d.ts +78 -0
  62. package/vendor/directive-core/dist/intake/thin-brief.d.ts.map +1 -0
  63. package/vendor/directive-core/dist/intake/thin-brief.js +224 -0
  64. package/vendor/directive-core/dist/intake/thin-brief.js.map +1 -0
  65. package/vendor/directive-core/dist/policy/ceremony-dial.d.ts.map +1 -1
  66. package/vendor/directive-core/dist/policy/ceremony-dial.js +2 -1
  67. package/vendor/directive-core/dist/policy/ceremony-dial.js.map +1 -1
  68. package/vendor/directive-core/dist/policy/small-change-lane.d.ts +193 -0
  69. package/vendor/directive-core/dist/policy/small-change-lane.d.ts.map +1 -0
  70. package/vendor/directive-core/dist/policy/small-change-lane.js +690 -0
  71. package/vendor/directive-core/dist/policy/small-change-lane.js.map +1 -0
  72. package/vendor/directive-core/dist/pr-merge-readiness/slizard-gate.d.ts +10 -5
  73. package/vendor/directive-core/dist/pr-merge-readiness/slizard-gate.d.ts.map +1 -1
  74. package/vendor/directive-core/dist/pr-merge-readiness/slizard-gate.js +41 -18
  75. package/vendor/directive-core/dist/pr-merge-readiness/slizard-gate.js.map +1 -1
  76. package/vendor/directive-core/dist/preflight/evaluate.d.ts +3 -0
  77. package/vendor/directive-core/dist/preflight/evaluate.d.ts.map +1 -1
  78. package/vendor/directive-core/dist/preflight/evaluate.js +15 -0
  79. package/vendor/directive-core/dist/preflight/evaluate.js.map +1 -1
  80. package/vendor/directive-core/dist/preflight-gh/pre-push.d.ts +3 -2
  81. package/vendor/directive-core/dist/preflight-gh/pre-push.d.ts.map +1 -1
  82. package/vendor/directive-core/dist/preflight-gh/pre-push.js +19 -18
  83. package/vendor/directive-core/dist/preflight-gh/pre-push.js.map +1 -1
  84. package/vendor/directive-core/dist/product-first-done-gate/evaluate.d.ts +6 -0
  85. package/vendor/directive-core/dist/product-first-done-gate/evaluate.d.ts.map +1 -1
  86. package/vendor/directive-core/dist/product-first-done-gate/evaluate.js +26 -0
  87. package/vendor/directive-core/dist/product-first-done-gate/evaluate.js.map +1 -1
  88. package/vendor/directive-core/dist/scope/decomposed-refs.d.ts +7 -0
  89. package/vendor/directive-core/dist/scope/decomposed-refs.d.ts.map +1 -1
  90. package/vendor/directive-core/dist/scope/decomposed-refs.js +27 -10
  91. package/vendor/directive-core/dist/scope/decomposed-refs.js.map +1 -1
  92. package/vendor/directive-core/dist/scope/delivery-evidence.d.ts +218 -19
  93. package/vendor/directive-core/dist/scope/delivery-evidence.d.ts.map +1 -1
  94. package/vendor/directive-core/dist/scope/delivery-evidence.js +973 -107
  95. package/vendor/directive-core/dist/scope/delivery-evidence.js.map +1 -1
  96. package/vendor/directive-core/dist/scope/transition.d.ts +7 -1
  97. package/vendor/directive-core/dist/scope/transition.d.ts.map +1 -1
  98. package/vendor/directive-core/dist/scope/transition.js +45 -5
  99. package/vendor/directive-core/dist/scope/transition.js.map +1 -1
  100. package/vendor/directive-core/dist/scope-provenance/base-fence.d.ts +13 -3
  101. package/vendor/directive-core/dist/scope-provenance/base-fence.d.ts.map +1 -1
  102. package/vendor/directive-core/dist/scope-provenance/base-fence.js +25 -2
  103. package/vendor/directive-core/dist/scope-provenance/base-fence.js.map +1 -1
  104. package/vendor/directive-core/dist/scope-provenance/evaluate.d.ts +12 -0
  105. package/vendor/directive-core/dist/scope-provenance/evaluate.d.ts.map +1 -1
  106. package/vendor/directive-core/dist/scope-provenance/evaluate.js +41 -5
  107. package/vendor/directive-core/dist/scope-provenance/evaluate.js.map +1 -1
  108. package/vendor/directive-core/dist/scope-provenance/forge-approval.d.ts +106 -0
  109. package/vendor/directive-core/dist/scope-provenance/forge-approval.d.ts.map +1 -0
  110. package/vendor/directive-core/dist/scope-provenance/forge-approval.js +382 -0
  111. package/vendor/directive-core/dist/scope-provenance/forge-approval.js.map +1 -0
  112. package/vendor/directive-core/dist/session/orientation-compression.d.ts +18 -2
  113. package/vendor/directive-core/dist/session/orientation-compression.d.ts.map +1 -1
  114. package/vendor/directive-core/dist/session/orientation-compression.js +38 -5
  115. package/vendor/directive-core/dist/session/orientation-compression.js.map +1 -1
  116. package/vendor/directive-core/dist/session/session-start.d.ts +47 -2
  117. package/vendor/directive-core/dist/session/session-start.d.ts.map +1 -1
  118. package/vendor/directive-core/dist/session/session-start.js +149 -1
  119. package/vendor/directive-core/dist/session/session-start.js.map +1 -1
  120. package/vendor/directive-core/dist/swarm/approach1-babysitter.d.ts +18 -4
  121. package/vendor/directive-core/dist/swarm/approach1-babysitter.d.ts.map +1 -1
  122. package/vendor/directive-core/dist/swarm/approach1-babysitter.js +28 -4
  123. package/vendor/directive-core/dist/swarm/approach1-babysitter.js.map +1 -1
  124. package/vendor/directive-core/dist/swarm/finalize-cohort.d.ts +48 -0
  125. package/vendor/directive-core/dist/swarm/finalize-cohort.d.ts.map +1 -1
  126. package/vendor/directive-core/dist/swarm/finalize-cohort.js +175 -19
  127. package/vendor/directive-core/dist/swarm/finalize-cohort.js.map +1 -1
  128. package/vendor/directive-core/dist/swarm/finalize-owed.d.ts +18 -3
  129. package/vendor/directive-core/dist/swarm/finalize-owed.d.ts.map +1 -1
  130. package/vendor/directive-core/dist/swarm/finalize-owed.js +139 -32
  131. package/vendor/directive-core/dist/swarm/finalize-owed.js.map +1 -1
  132. package/vendor/directive-core/dist/swarm/launch.d.ts.map +1 -1
  133. package/vendor/directive-core/dist/swarm/launch.js +4 -8
  134. package/vendor/directive-core/dist/swarm/launch.js.map +1 -1
  135. package/vendor/directive-core/dist/swarm/worker-auth-assignment.d.ts +7 -0
  136. package/vendor/directive-core/dist/swarm/worker-auth-assignment.d.ts.map +1 -1
  137. package/vendor/directive-core/dist/swarm/worker-auth-assignment.js +16 -1
  138. package/vendor/directive-core/dist/swarm/worker-auth-assignment.js.map +1 -1
  139. package/vendor/directive-core/dist/task-surface/index.d.ts.map +1 -1
  140. package/vendor/directive-core/dist/task-surface/index.js +41 -0
  141. package/vendor/directive-core/dist/task-surface/index.js.map +1 -1
  142. package/vendor/directive-core/dist/triage/evaluate/worktrees.d.ts.map +1 -1
  143. package/vendor/directive-core/dist/triage/evaluate/worktrees.js +219 -60
  144. package/vendor/directive-core/dist/triage/evaluate/worktrees.js.map +1 -1
  145. package/vendor/directive-core/dist/vbrief-reconcile/types.d.ts +5 -1
  146. package/vendor/directive-core/dist/vbrief-reconcile/types.d.ts.map +1 -1
  147. package/vendor/directive-core/dist/vbrief-reconcile/umbrellas.d.ts.map +1 -1
  148. package/vendor/directive-core/dist/vbrief-reconcile/umbrellas.js +19 -7
  149. package/vendor/directive-core/dist/vbrief-reconcile/umbrellas.js.map +1 -1
@@ -8,12 +8,13 @@
8
8
  *
9
9
  * Deploy / UAT are separate axes and MUST NOT be inferred from Git alone.
10
10
  */
11
- import { closerSetFromIssueIds } from "../one-pr-unit/closer-set.js";
12
11
  import { productPullRequestFromPlan } from "../orphan-active/running-briefs.js";
13
12
  import { assertSafeBranchName, FIELD_DELIVERY_BRANCH, isSafeBranchName, resolveDeliveryBranch, trackingFetchArgv, } from "../policy/delivery-branch.js";
13
+ import { parseAllDeftStoryMarks } from "../pr-closing-keywords/main.js";
14
14
  import { defaultRunGh } from "../pr-protected-issues/gh.js";
15
15
  import { defaultGitRunner, gitIsAncestor } from "../session/git.js";
16
16
  import { readRitualState } from "../session/ritual-sentinel.js";
17
+ import { parseGitHubRepoSlug } from "../text/redos-safe.js";
17
18
  import { LIFECYCLE_FOLDERS } from "./constants.js";
18
19
  /** Prefer-A #5545: refuse resolver error when branch is empty or unsafe. */
19
20
  function refuseUnsafeResolvedDelivery(resolved) {
@@ -379,51 +380,643 @@ export function prDiffAddsBrief(files, briefBasename) {
379
380
  const touchesCode = files.some((f) => lifecycleBriefBasename(f.filename) === null);
380
381
  return addsBrief && touchesCode;
381
382
  }
383
+ /** Recut-13 uniqueness-scan budget: units and caps (#5710). */
384
+ export const MERGE_TIME_SCAN_WALL_CLOCK_MS = 20 * 60 * 1000;
385
+ export const MERGE_TIME_SCAN_MIN_GRAPHQL_REMAINING = 200;
386
+ export const MERGE_TIME_SCAN_MIN_CORE_REMAINING = 100;
387
+ /** GitHub `pulls/{n}/files` lists at most 3000 files. */
388
+ export const PR_FILES_LISTING_CAP = 3000;
389
+ /** The three accepted merge-time bases, named in every stranded-shape refusal (#5710). */
390
+ export const MERGE_TIME_ASSOCIATION_BASES = "(a) a unique sole `deft-story: N` mark in the PR body as of merged_at, " +
391
+ "(b) a closing keyword (close/fix/resolve forms) naming the issue in the PR body as of merged_at, " +
392
+ "(c) a Connected issue link in effect at merged_at";
393
+ /** Start the Recut-13 scan budget: wall clock from now, live rate limits per call. */
394
+ export function makeScanBudget(forge) {
395
+ const startMs = forge.nowMs();
396
+ const wallClock = () => forge.nowMs() - startMs > MERGE_TIME_SCAN_WALL_CLOCK_MS
397
+ ? `scan budget exhausted: wall-clock cap ${MERGE_TIME_SCAN_WALL_CLOCK_MS / 60000} minutes`
398
+ : null;
399
+ const remaining = (bucket, floor, label) => {
400
+ const clock = wallClock();
401
+ if (clock !== null)
402
+ return clock;
403
+ const rate = forge.probeRateLimit();
404
+ if (rate === null)
405
+ return "scan budget unknown: live rate-limit probe failed";
406
+ return rate[bucket] < floor
407
+ ? `scan budget exhausted: ${label} remaining ${rate[bucket]} < ${floor}`
408
+ : null;
409
+ };
410
+ return {
411
+ beforeGraphql: () => remaining("graphqlRemaining", MERGE_TIME_SCAN_MIN_GRAPHQL_REMAINING, "graphql"),
412
+ beforeRest: () => remaining("coreRemaining", MERGE_TIME_SCAN_MIN_CORE_REMAINING, "REST core"),
413
+ };
414
+ }
415
+ /** Next cursor of a page with more nodes, or null when the cursor is missing or stuck. */
416
+ function nextCursor(endCursor, after) {
417
+ const next = endCursor?.trim() ?? "";
418
+ return next.length === 0 || next === after ? null : next;
419
+ }
420
+ /** Walk every page of a connection; any gap, guard refusal or stuck cursor fails closed. */
421
+ function collectAllPages(fetchPage, guard, label) {
422
+ const nodes = [];
423
+ let after = null;
424
+ for (;;) {
425
+ const refused = guard();
426
+ if (refused !== null) {
427
+ return { ok: false, error: `${label}: ${refused}` };
428
+ }
429
+ const page = fetchPage(after);
430
+ if (!page.ok) {
431
+ return { ok: false, error: `${label}: ${page.error}` };
432
+ }
433
+ nodes.push(...page.nodes);
434
+ if (!page.hasNextPage) {
435
+ return { ok: true, nodes };
436
+ }
437
+ const next = nextCursor(page.endCursor, after);
438
+ if (next === null) {
439
+ return {
440
+ ok: false,
441
+ error: `${label}: incomplete page (hasNextPage without a new endCursor)`,
442
+ };
443
+ }
444
+ after = next;
445
+ }
446
+ }
382
447
  /**
383
- * Prefer-A story→PR→merge identity join before delivery liveness (#3675).
384
- *
385
- * Uses plan-derived expected repo/issue, authoritative closingIssuesReferences
386
- * with per-issue repository preserved (not reassigned to the plan repo), and
387
- * merge_commit_sha identity equality. Does not require implementationCommit
388
- * ancestry (squash-safe).
448
+ * Merge-time body (#5710): the latest `userContentEdits` revision with
449
+ * editedAt ≤ merged_at, or the current body when the PR has no edit history.
450
+ * A hidden revision or no revision at or before merged_at is unknown (fail closed).
389
451
  */
390
- export function verifyStoryPrMergeIdentity(input) {
391
- const expected = resolvePlanGithubIssueRef(input.plan);
392
- if (expected === null) {
452
+ export function selectMergeTimeBody(revisions, currentBody, mergedAt) {
453
+ const mergedMs = Date.parse(mergedAt);
454
+ if (Number.isNaN(mergedMs)) {
455
+ return { ok: false, error: `unreadable merged_at ${JSON.stringify(mergedAt)}` };
456
+ }
457
+ if (revisions.length === 0) {
458
+ return { ok: true, body: currentBody ?? "" };
459
+ }
460
+ let chosen = null;
461
+ let chosenMs = Number.NEGATIVE_INFINITY;
462
+ for (const revision of revisions) {
463
+ const editedMs = Date.parse(revision.editedAt);
464
+ if (Number.isNaN(editedMs)) {
465
+ return {
466
+ ok: false,
467
+ error: `unreadable revision editedAt ${JSON.stringify(revision.editedAt)}`,
468
+ };
469
+ }
470
+ if (editedMs <= mergedMs && editedMs >= chosenMs) {
471
+ chosen = revision;
472
+ chosenMs = editedMs;
473
+ }
474
+ }
475
+ if (chosen === null) {
476
+ return { ok: false, error: "no body revision at or before merged_at" };
477
+ }
478
+ if (chosen.body === null) {
479
+ return { ok: false, error: "merge-time body revision is unreadable" };
480
+ }
481
+ return { ok: true, body: chosen.body };
482
+ }
483
+ const CLOSING_KEYWORD_REF_RE = /\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved):?[ \t]+(?:#(\d+)|([\w.-]+)\/([\w.-]+)#(\d+)|https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+))(?![\w-])/gi;
484
+ /**
485
+ * True when the body has a GitHub closing keyword naming the issue as `#N`,
486
+ * `owner/repo#N` or the issue URL (#5710 basis b). `#N` means the PR's repository.
487
+ */
488
+ export function bodyHasClosingKeywordFor(body, repository, issueNumber) {
489
+ const repoKey = repository.trim().toLowerCase();
490
+ const re = new RegExp(CLOSING_KEYWORD_REF_RE.source, CLOSING_KEYWORD_REF_RE.flags);
491
+ for (let match = re.exec(body); match !== null; match = re.exec(body)) {
492
+ const [, bare, owner, name, qualified, urlOwner, urlName, urlNumber] = match;
493
+ if (bare !== undefined) {
494
+ if (Number(bare) === issueNumber)
495
+ return true;
496
+ continue;
497
+ }
498
+ const refRepo = `${owner ?? urlOwner ?? ""}/${name ?? urlName ?? ""}`.toLowerCase();
499
+ if (refRepo === repoKey && Number(qualified ?? urlNumber) === issueNumber) {
500
+ return true;
501
+ }
502
+ }
503
+ return false;
504
+ }
505
+ /**
506
+ * Replay Connected / Disconnected events up to merged_at (#5710 basis c).
507
+ * Events after merged_at do not count.
508
+ */
509
+ export function connectedAtMergedAt(events, repository, issueNumber, mergedAt) {
510
+ const mergedMs = Date.parse(mergedAt);
511
+ if (Number.isNaN(mergedMs)) {
512
+ return { ok: false, error: `unreadable merged_at ${JSON.stringify(mergedAt)}` };
513
+ }
514
+ const repoKey = repository.trim().toLowerCase();
515
+ const relevant = [];
516
+ for (const event of events) {
517
+ const names = event.issues.some((ref) => ref.repository.trim().toLowerCase() === repoKey && ref.issueNumber === issueNumber);
518
+ if (!names)
519
+ continue;
520
+ const ms = Date.parse(event.createdAt);
521
+ if (Number.isNaN(ms)) {
522
+ return { ok: false, error: `unreadable event createdAt ${JSON.stringify(event.createdAt)}` };
523
+ }
524
+ if (ms <= mergedMs) {
525
+ relevant.push({ ms, kind: event.kind });
526
+ }
527
+ }
528
+ relevant.sort((a, b) => a.ms - b.ms);
529
+ const last = relevant[relevant.length - 1];
530
+ return { ok: true, connected: last?.kind === "connected" };
531
+ }
532
+ /** Ledger that scope completion writes beside the brief moves (#6231 / #680 P5). */
533
+ export const SPEC_DRIFT_LEDGER_REL = "xbrief/.audit/spec-drift-ledger.json";
534
+ /**
535
+ * Delivery-brief-land path family (#5710 / #680 F1): a brief under an
536
+ * `xbrief|vbrief/<lifecycle>/` folder, root `CHANGELOG.md`, or the exact
537
+ * spec-drift ledger. One helper for the delivery gate, the CHANGELOG
538
+ * exemption, membership and finalize's lifecycle-shaped PR check, so the
539
+ * family cannot drift between them.
540
+ */
541
+ export function isDeliveryBriefLandPath(path) {
542
+ const p = path.replace(/\\/g, "/");
543
+ if (p === "CHANGELOG.md" || p === SPEC_DRIFT_LEDGER_REL) {
544
+ return true;
545
+ }
546
+ const parts = p.split("/");
547
+ return (parts.length >= 3 &&
548
+ (parts[0] === "xbrief" || parts[0] === "vbrief") &&
549
+ LIFECYCLE_FOLDER_SET.has(parts[1] ?? "") &&
550
+ (parts[parts.length - 1] ?? "").length > 0);
551
+ }
552
+ /**
553
+ * Completion-move shape (#680 P5): a non-empty change set of delivery-brief-land
554
+ * paths with at least one brief under `completed/`. Membership admits the
555
+ * ledger only under this shape.
556
+ */
557
+ export function isCompletionMoveShaped(paths) {
558
+ return (paths.length > 0 &&
559
+ paths.every((p) => isDeliveryBriefLandPath(p)) &&
560
+ paths.some((p) => /^(?:xbrief|vbrief)\/completed\/./.test(p.replace(/\\/g, "/"))));
561
+ }
562
+ /**
563
+ * Delivery-brief-land exclusion (#5710): every changed path, rename sources
564
+ * included, is in the delivery-brief-land family (`isDeliveryBriefLandPath`),
565
+ * with at least one such path for a positive
566
+ * exclusion. CHANGELOG-only, completed-folder renames and deletion-only diffs
567
+ * on those paths are excluded. A zero-file diff is not a positive exclusion:
568
+ * as a competitor it still counts (fail closed), and as a candidate it is
569
+ * refused separately because it carries no delivery. Not identical to
570
+ * activation `isBriefLandShapedDiff`, which denies completed/ and needs a
571
+ * non-removed nonterminal brief.
572
+ */
573
+ export function isDeliveryBriefLandExcluded(files) {
574
+ return (files.length > 0 &&
575
+ files.every((file) => isDeliveryBriefLandPath(file.filename) &&
576
+ ((file.previousFilename ?? "").length === 0 ||
577
+ isDeliveryBriefLandPath(file.previousFilename ?? ""))));
578
+ }
579
+ /** The single distinct `deft-story` mark in a body, or null when zero or several (#5710). */
580
+ export function soleDeftStoryMark(body) {
581
+ const marks = parseAllDeftStoryMarks(body);
582
+ return marks.length === 1 ? (marks[0] ?? null) : null;
583
+ }
584
+ function reconstructMergeTimeBody(forge, budget, target, currentBody, mergedAt) {
585
+ const { repository, prNumber } = target;
586
+ const revisions = collectAllPages((after) => forge.fetchBodyRevisionsPage(repository, prNumber, after), budget.beforeGraphql, `PR #${prNumber} body revisions`);
587
+ if (!revisions.ok) {
588
+ return revisions;
589
+ }
590
+ const selected = selectMergeTimeBody(revisions.nodes, currentBody, mergedAt);
591
+ return selected.ok
592
+ ? selected
593
+ : { ok: false, error: `PR #${prNumber} merge-time body: ${selected.error}` };
594
+ }
595
+ function classifyDeliveryExclusion(forge, budget, repository, prNumber) {
596
+ // REST budget is checked before every page, not once per PR (#5710).
597
+ const listed = collectAllPages((after) => forge.fetchPrFilesPage(repository, prNumber, after), budget.beforeRest, `PR #${prNumber} changed files`);
598
+ if (!listed.ok) {
599
+ return listed;
600
+ }
601
+ // The forge stops listing at its cap, so reaching it means the list may be truncated.
602
+ if (listed.nodes.length >= PR_FILES_LISTING_CAP) {
393
603
  return {
394
604
  ok: false,
395
- message: "Delivery identity join failed: plan has no x-xbrief/github-issue reference " +
396
- "to derive expected repository/issue (#3675).",
605
+ error: `PR #${prNumber} changed files: listing truncated at the ${PR_FILES_LISTING_CAP}-file cap`,
397
606
  };
398
607
  }
399
- const evidenceRepo = typeof input.evidence.repository === "string" && input.evidence.repository.trim().length > 0
400
- ? input.evidence.repository.trim()
401
- : null;
402
- if (evidenceRepo !== null && evidenceRepo !== expected.repository) {
608
+ return {
609
+ ok: true,
610
+ excluded: isDeliveryBriefLandExcluded(listed.nodes),
611
+ fileCount: listed.nodes.length,
612
+ };
613
+ }
614
+ /** Merge-time body of one listed PR; edits only replayed when one landed after merge. */
615
+ function listedMergeTimeBody(forge, budget, repository, node) {
616
+ const mergedMs = Date.parse(node.mergedAt);
617
+ const lastEditedMs = node.lastEditedAt === null ? null : Date.parse(node.lastEditedAt);
618
+ if (Number.isNaN(mergedMs) || (lastEditedMs !== null && Number.isNaN(lastEditedMs))) {
619
+ return { ok: false, error: `PR #${node.number} has unreadable mergedAt / lastEditedAt` };
620
+ }
621
+ // No edit after merge: the latest revision at or before merged_at is the
622
+ // latest revision, which is the current body.
623
+ if (lastEditedMs === null || lastEditedMs <= mergedMs) {
624
+ return { ok: true, body: node.body };
625
+ }
626
+ return reconstructMergeTimeBody(forge, budget, { repository, prNumber: node.number }, node.body, node.mergedAt);
627
+ }
628
+ /**
629
+ * Bounded exhaustive uniqueness scan (#5710 basis a). Enumerates every merged
630
+ * PR, reconstructs each merge-time body, and classifies sole-mark hits for
631
+ * `issueNumber`. Returns the non-excluded sole-mark PRs other than the
632
+ * candidate. Any incomplete page, unreadable revision or competitor, truncated
633
+ * listing, or exhausted budget fails closed. No recent-window shortcut.
634
+ */
635
+ export function scanSoleMarkCompetitors(input) {
636
+ const { repository, issueNumber, candidatePr, forge } = input;
637
+ const budget = input.budget ?? makeScanBudget(forge);
638
+ const competitors = [];
639
+ let after = null;
640
+ for (;;) {
641
+ const refused = budget.beforeGraphql();
642
+ if (refused !== null) {
643
+ return { ok: false, error: `merged-PR scan: ${refused}` };
644
+ }
645
+ const page = forge.listMergedPrsPage(repository, after);
646
+ if (!page.ok) {
647
+ return { ok: false, error: `merged-PR scan: ${page.error}` };
648
+ }
649
+ for (const node of page.nodes) {
650
+ if (node.number === candidatePr)
651
+ continue;
652
+ const body = listedMergeTimeBody(forge, budget, repository, node);
653
+ if (!body.ok) {
654
+ return { ok: false, error: `merged-PR scan: ${body.error}` };
655
+ }
656
+ if (soleDeftStoryMark(body.body) !== issueNumber)
657
+ continue;
658
+ const exclusion = classifyDeliveryExclusion(forge, budget, repository, node.number);
659
+ if (!exclusion.ok) {
660
+ return { ok: false, error: `merged-PR scan: possible competitor ${exclusion.error}` };
661
+ }
662
+ if (!exclusion.excluded) {
663
+ competitors.push(node.number);
664
+ }
665
+ }
666
+ if (!page.hasNextPage) {
667
+ return { ok: true, competitors };
668
+ }
669
+ const next = nextCursor(page.endCursor, after);
670
+ if (next === null) {
671
+ return {
672
+ ok: false,
673
+ error: "merged-PR scan: incomplete page (hasNextPage without a new endCursor)",
674
+ };
675
+ }
676
+ after = next;
677
+ }
678
+ }
679
+ /** Basis a: unique sole mark among merged non-excluded PRs (#5710). */
680
+ function proveSoleMarkBasis(input) {
681
+ const { forge, budget, repository, issueNumber, prNumber } = input;
682
+ if (soleDeftStoryMark(input.mergeTimeBody) !== issueNumber) {
403
683
  return {
404
684
  ok: false,
405
- message: `Delivery identity join failed: evidence repository '${evidenceRepo}' does not match ` +
406
- `plan-derived expected repository '${expected.repository}' (#3675).`,
685
+ reason: `(a) PR #${prNumber} body as of merged_at has no sole \`deft-story: ${issueNumber}\` mark`,
407
686
  };
408
687
  }
409
- const prNumber = typeof input.evidence.prNumber === "number" &&
410
- Number.isInteger(input.evidence.prNumber) &&
411
- input.evidence.prNumber > 0
412
- ? input.evidence.prNumber
413
- : null;
688
+ const exclusion = classifyDeliveryExclusion(forge, budget, repository, prNumber);
689
+ if (!exclusion.ok) {
690
+ return { ok: false, reason: `(a) ${exclusion.error}` };
691
+ }
692
+ if (exclusion.fileCount === 0) {
693
+ return {
694
+ ok: false,
695
+ reason: `(a) PR #${prNumber} changes no files (a zero-file PR carries no delivery)`,
696
+ };
697
+ }
698
+ if (exclusion.excluded) {
699
+ return {
700
+ ok: false,
701
+ reason: `(a) PR #${prNumber} changes only briefs and CHANGELOG.md ` +
702
+ `(delivery-brief-land excluded; not delivery)`,
703
+ };
704
+ }
705
+ const scan = scanSoleMarkCompetitors({
706
+ repository,
707
+ issueNumber,
708
+ candidatePr: prNumber,
709
+ forge,
710
+ budget,
711
+ });
712
+ if (!scan.ok) {
713
+ return { ok: false, reason: `(a) uniqueness unknown, refusing: ${scan.error}` };
714
+ }
715
+ if (scan.competitors.length > 0) {
716
+ const list = scan.competitors.map((n) => `#${n}`).join(", ");
717
+ return {
718
+ ok: false,
719
+ reason: `(a) mark is not unique: merged non-excluded PR(s) ${list} also carry a sole ` +
720
+ `\`deft-story: ${issueNumber}\` mark`,
721
+ };
722
+ }
723
+ return { ok: true };
724
+ }
725
+ /** One GraphQL call through gh; returns `data` or an error (any `errors` entry fails). */
726
+ function runGhGraphql(runGh, query, vars) {
727
+ const cmd = ["gh", "api", "graphql", "-f", `query=${query}`];
728
+ for (const [key, value] of Object.entries(vars)) {
729
+ if (value !== null) {
730
+ cmd.push("-F", `${key}=${String(value)}`);
731
+ }
732
+ }
733
+ let result;
734
+ try {
735
+ result = runGh(cmd);
736
+ }
737
+ catch (exc) {
738
+ return { ok: false, error: exc instanceof Error ? exc.message : String(exc) };
739
+ }
740
+ if (result.returncode !== 0) {
741
+ return { ok: false, error: result.stderr.trim() || "gh api graphql non-zero" };
742
+ }
743
+ let payload;
744
+ try {
745
+ payload = JSON.parse(result.stdout);
746
+ }
747
+ catch {
748
+ return { ok: false, error: "unparseable GraphQL JSON" };
749
+ }
750
+ const rec = asRecord(payload);
751
+ if (rec === null || (Array.isArray(rec.errors) && rec.errors.length > 0)) {
752
+ return { ok: false, error: "GraphQL response carried errors" };
753
+ }
754
+ const data = asRecord(rec.data);
755
+ return data === null ? { ok: false, error: "GraphQL response missing data" } : { ok: true, data };
756
+ }
757
+ function readConnection(value) {
758
+ const conn = asRecord(value);
759
+ const pageInfo = asRecord(conn?.pageInfo);
760
+ if (conn === null || pageInfo === null || !Array.isArray(conn.nodes))
761
+ return null;
762
+ if (typeof pageInfo.hasNextPage !== "boolean")
763
+ return null;
764
+ const endCursor = typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : null;
765
+ return { nodes: conn.nodes, hasNextPage: pageInfo.hasNextPage, endCursor };
766
+ }
767
+ const MERGED_PRS_GRAPHQL = "query($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){" +
768
+ "pullRequests(states:MERGED,first:100,after:$after,orderBy:{field:CREATED_AT,direction:ASC}){" +
769
+ "pageInfo{hasNextPage endCursor} nodes{number mergedAt body lastEditedAt}}}}";
770
+ const BODY_REVISIONS_GRAPHQL = "query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){" +
771
+ "pullRequest(number:$number){userContentEdits(first:100,after:$after){" +
772
+ "pageInfo{hasNextPage endCursor} nodes{editedAt deletedAt diff}}}}}";
773
+ const SUBJECT_FIELDS = "__typename ... on Issue{number repository{nameWithOwner}}";
774
+ const CONNECTION_EVENTS_GRAPHQL = "query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){" +
775
+ "pullRequest(number:$number){timelineItems(first:100,after:$after," +
776
+ "itemTypes:[CONNECTED_EVENT,DISCONNECTED_EVENT]){pageInfo{hasNextPage endCursor} nodes{__typename " +
777
+ `... on ConnectedEvent{createdAt source{${SUBJECT_FIELDS}} subject{${SUBJECT_FIELDS}}} ` +
778
+ `... on DisconnectedEvent{createdAt source{${SUBJECT_FIELDS}} subject{${SUBJECT_FIELDS}}}}}}}}`;
779
+ function parseEventIssue(value) {
780
+ const rec = asRecord(value);
781
+ if (rec === null || rec.__typename !== "Issue")
782
+ return null;
783
+ const repo = asRecord(rec.repository)?.nameWithOwner;
784
+ if (typeof rec.number !== "number" || typeof repo !== "string")
785
+ return null;
786
+ return { repository: repo, issueNumber: rec.number };
787
+ }
788
+ function parseMergedPrNode(value) {
789
+ const rec = asRecord(value);
790
+ if (rec === null || typeof rec.number !== "number" || typeof rec.mergedAt !== "string") {
791
+ return null;
792
+ }
793
+ if (rec.body !== null && typeof rec.body !== "string")
794
+ return null;
795
+ if (rec.lastEditedAt !== null && typeof rec.lastEditedAt !== "string")
796
+ return null;
797
+ return {
798
+ number: rec.number,
799
+ mergedAt: rec.mergedAt,
800
+ body: typeof rec.body === "string" ? rec.body : "",
801
+ lastEditedAt: typeof rec.lastEditedAt === "string" ? rec.lastEditedAt : null,
802
+ };
803
+ }
804
+ function parseRevisionNode(value) {
805
+ const rec = asRecord(value);
806
+ if (rec === null || typeof rec.editedAt !== "string")
807
+ return null;
808
+ const hidden = rec.deletedAt !== null && rec.deletedAt !== undefined;
809
+ return {
810
+ editedAt: rec.editedAt,
811
+ body: !hidden && typeof rec.diff === "string" ? rec.diff : null,
812
+ };
813
+ }
814
+ function parseConnectionEventNode(value) {
815
+ const rec = asRecord(value);
816
+ if (rec === null || typeof rec.createdAt !== "string")
817
+ return null;
818
+ let kind;
819
+ if (rec.__typename === "ConnectedEvent") {
820
+ kind = "connected";
821
+ }
822
+ else if (rec.__typename === "DisconnectedEvent") {
823
+ kind = "disconnected";
824
+ }
825
+ else {
826
+ return null;
827
+ }
828
+ const issues = [parseEventIssue(rec.source), parseEventIssue(rec.subject)].filter((ref) => ref !== null);
829
+ return { kind, createdAt: rec.createdAt, issues };
830
+ }
831
+ function graphqlPage(runGh, request, pick, parseNode) {
832
+ const response = runGhGraphql(runGh, request.query, request.vars);
833
+ if (!response.ok)
834
+ return response;
835
+ const conn = readConnection(pick(response.data));
836
+ if (conn === null) {
837
+ return { ok: false, error: "incomplete page (missing connection or pageInfo)" };
838
+ }
839
+ const nodes = [];
840
+ for (const raw of conn.nodes) {
841
+ const parsed = parseNode(raw);
842
+ if (parsed === null)
843
+ return { ok: false, error: "unreadable node in page" };
844
+ nodes.push(parsed);
845
+ }
846
+ return { ok: true, nodes, hasNextPage: conn.hasNextPage, endCursor: conn.endCursor };
847
+ }
848
+ function parsePrFileEntry(value) {
849
+ const rec = asRecord(value);
850
+ if (rec === null || typeof rec.filename !== "string" || rec.filename.length === 0)
851
+ return null;
852
+ if (typeof rec.status !== "string")
853
+ return null;
854
+ const previous = typeof rec.previous_filename === "string" ? rec.previous_filename : "";
855
+ return previous.length > 0
856
+ ? { status: rec.status, filename: rec.filename, previousFilename: previous }
857
+ : { status: rec.status, filename: rec.filename };
858
+ }
859
+ /**
860
+ * One REST page of `pulls/{n}/files` with rename sources, read uncached. The
861
+ * cursor is the next page number; paging ends at the first empty page.
862
+ */
863
+ function fetchPrFilesPageLive(runGh, repository, prNumber, after) {
864
+ const parsed = parseOwnerRepo(repository);
865
+ if (parsed === null)
866
+ return { ok: false, error: `invalid repository ${repository}` };
867
+ const page = after === null ? 1 : Number(after);
868
+ if (!Number.isInteger(page) || page < 1)
869
+ return { ok: false, error: `invalid page ${after}` };
870
+ const path = `repos/${parsed.owner}/${parsed.name}/pulls/${prNumber}/files?per_page=100&page=${page}`;
871
+ let result;
872
+ try {
873
+ result = liveRunGh(runGh)(["gh", "api", path]);
874
+ }
875
+ catch (exc) {
876
+ return { ok: false, error: exc instanceof Error ? exc.message : String(exc) };
877
+ }
878
+ if (result.returncode !== 0) {
879
+ return { ok: false, error: result.stderr.trim() || "gh api files non-zero" };
880
+ }
881
+ let payload;
882
+ try {
883
+ payload = JSON.parse(result.stdout);
884
+ }
885
+ catch {
886
+ return { ok: false, error: "unparseable files page" };
887
+ }
888
+ if (!Array.isArray(payload))
889
+ return { ok: false, error: "files page is not an array" };
890
+ const nodes = [];
891
+ for (const raw of payload) {
892
+ const entry = parsePrFileEntry(raw);
893
+ if (entry === null)
894
+ return { ok: false, error: "unreadable files entry" };
895
+ nodes.push(entry);
896
+ }
897
+ const hasNextPage = nodes.length > 0;
898
+ return { ok: true, nodes, hasNextPage, endCursor: hasNextPage ? String(page + 1) : null };
899
+ }
900
+ /**
901
+ * Pin plain `gh` for single-path GETs the default runner would send to the
902
+ * `ghx` cache proxy. A cached read can be stale: a PR cached before merge
903
+ * reads as unmerged, a rate bucket reads as fuller than it is (#954 / #5710).
904
+ * Injected runners are used as given.
905
+ */
906
+ export function liveRunGh(runGh) {
907
+ return runGh === defaultRunGh ? (cmd) => defaultRunGh(cmd, "gh") : runGh;
908
+ }
909
+ function probeLiveRateLimit(runGh) {
910
+ const cmd = ["gh", "api", "rate_limit"];
911
+ let result;
912
+ try {
913
+ result = liveRunGh(runGh)(cmd);
914
+ }
915
+ catch {
916
+ return null;
917
+ }
918
+ if (result.returncode !== 0)
919
+ return null;
920
+ try {
921
+ const resources = asRecord(asRecord(JSON.parse(result.stdout))?.resources);
922
+ const graphql = asRecord(resources?.graphql)?.remaining;
923
+ const core = asRecord(resources?.core)?.remaining;
924
+ if (typeof graphql !== "number" || typeof core !== "number")
925
+ return null;
926
+ return { graphqlRemaining: graphql, coreRemaining: core };
927
+ }
928
+ catch {
929
+ return null;
930
+ }
931
+ }
932
+ /**
933
+ * Live forge reads for merge-time association (#5710): GraphQL pages, REST
934
+ * changed files with rename sources, and the uncached `gh api rate_limit`.
935
+ */
936
+ export function defaultMergeTimeForge(runGh = defaultRunGh) {
937
+ const prVars = (repository, prNumber, after) => {
938
+ const parsed = parseOwnerRepo(repository);
939
+ return parsed === null
940
+ ? null
941
+ : { owner: parsed.owner, name: parsed.name, number: prNumber, after };
942
+ };
943
+ const pullRequestOf = (data) => asRecord(asRecord(data.repository)?.pullRequest);
944
+ const invalid = (repository) => ({
945
+ ok: false,
946
+ error: `invalid repository ${repository}`,
947
+ });
948
+ return {
949
+ listMergedPrsPage: (repository, after) => {
950
+ const parsed = parseOwnerRepo(repository);
951
+ if (parsed === null)
952
+ return invalid(repository);
953
+ return graphqlPage(runGh, { query: MERGED_PRS_GRAPHQL, vars: { owner: parsed.owner, name: parsed.name, after } }, (data) => asRecord(data.repository)?.pullRequests, parseMergedPrNode);
954
+ },
955
+ fetchBodyRevisionsPage: (repository, prNumber, after) => {
956
+ const vars = prVars(repository, prNumber, after);
957
+ if (vars === null)
958
+ return invalid(repository);
959
+ return graphqlPage(runGh, { query: BODY_REVISIONS_GRAPHQL, vars }, (data) => pullRequestOf(data)?.userContentEdits, parseRevisionNode);
960
+ },
961
+ fetchConnectionEventsPage: (repository, prNumber, after) => {
962
+ const vars = prVars(repository, prNumber, after);
963
+ if (vars === null)
964
+ return invalid(repository);
965
+ return graphqlPage(runGh, { query: CONNECTION_EVENTS_GRAPHQL, vars }, (data) => pullRequestOf(data)?.timelineItems, parseConnectionEventNode);
966
+ },
967
+ fetchPrFilesPage: (repository, prNumber, after) => fetchPrFilesPageLive(runGh, repository, prNumber, after),
968
+ probeRateLimit: () => probeLiveRateLimit(runGh),
969
+ nowMs: () => Date.now(),
970
+ };
971
+ }
972
+ /**
973
+ * Canonical project repository (#5710 P1): OWNER/NAME of the `origin` remote.
974
+ * `--repo` is a matching assertion only and is never the source of it.
975
+ */
976
+ export function resolveCanonicalProjectRepository(projectRoot, runGit = defaultGitRunner) {
977
+ let result;
978
+ try {
979
+ result = runGit(projectRoot, ["remote", "get-url", "origin"]);
980
+ }
981
+ catch {
982
+ return null;
983
+ }
984
+ if (result.code !== 0)
985
+ return null;
986
+ return parseGitHubRepoSlug(result.stdout.trim());
987
+ }
988
+ /**
989
+ * PR-only delivery identity (#5710 P1): only when the plan has no GitHub issue
990
+ * reference (today's resolver). Identity is (canonical remote, stamped
991
+ * productPullRequest). `x-xbrief/github-pr` references are never identity.
992
+ */
993
+ export function resolvePrOnlyDeliveryIdentity(plan, resolveCanonicalRepository) {
994
+ const prNumber = productPullRequestFromPlan(plan);
414
995
  if (prNumber === null) {
415
996
  return {
416
997
  ok: false,
417
- message: "Delivery identity join failed: missing story-associated PR number. " +
418
- "Pass --pr <n> for the PR that forge-closes this story; --pr alone is not identity (#3675).",
998
+ message: "Delivery identity join failed: plan has no x-xbrief/github-issue reference and no " +
999
+ "plan.metadata.productPullRequest stamp. Stamp productPullRequest (issue-less briefs only) " +
1000
+ "or add an issue origin. An x-xbrief/github-pr reference is not a delivery identity (#5710).",
1001
+ };
1002
+ }
1003
+ const repository = resolveCanonicalRepository();
1004
+ if (repository === null) {
1005
+ return {
1006
+ ok: false,
1007
+ message: `Delivery identity join failed: plan has productPullRequest=${prNumber} but the ` +
1008
+ `canonical project remote (git remote get-url origin) did not resolve to OWNER/NAME (#5710).`,
419
1009
  };
420
1010
  }
421
- const payload = input.fetchPrPayload(prNumber, expected.repository);
1011
+ return { ok: true, repository, prNumber };
1012
+ }
1013
+ function fetchMergedPayload(fetchPrPayload, prNumber, repository) {
1014
+ const payload = fetchPrPayload(prNumber, repository);
422
1015
  if (payload === null) {
423
1016
  return {
424
1017
  ok: false,
425
1018
  message: `Delivery identity join failed: could not fetch PR #${prNumber} in ` +
426
- `${expected.repository} (lookup failure) (#3675).`,
1019
+ `${repository} (lookup failure) (#3675).`,
427
1020
  };
428
1021
  }
429
1022
  const mergedAt = typeof payload.merged_at === "string" && payload.merged_at.trim().length > 0
@@ -435,86 +1028,282 @@ export function verifyStoryPrMergeIdentity(input) {
435
1028
  if (mergedAt === null || mergeCommitSha === null) {
436
1029
  return {
437
1030
  ok: false,
438
- message: `Delivery identity join failed: PR #${prNumber} in ${expected.repository} ` +
1031
+ message: `Delivery identity join failed: PR #${prNumber} in ${repository} ` +
439
1032
  `is not merged (missing merged_at / merge_commit_sha) (#3675).`,
440
1033
  };
441
1034
  }
442
- const linked = input.fetchClosingIssueIds(prNumber, expected.repository);
443
- if (linked === null) {
1035
+ return { ok: true, payload, mergedAt, mergeCommitSha };
1036
+ }
1037
+ function payloadHeadSha(payload) {
1038
+ const head = asRecord(payload.head);
1039
+ if (typeof head?.sha === "string" && head.sha.trim().length > 0) {
1040
+ return head.sha.trim();
1041
+ }
1042
+ if (typeof payload.head_sha === "string" && payload.head_sha.trim().length > 0) {
1043
+ return payload.head_sha.trim();
1044
+ }
1045
+ return null;
1046
+ }
1047
+ function mergeCommitMismatch(mergeCommit, merged, prNumber) {
1048
+ if (shasEqual(mergeCommit, merged.mergeCommitSha)) {
1049
+ return null;
1050
+ }
1051
+ return (`Delivery identity join failed: supplied merge commit ${mergeCommit} does not ` +
1052
+ `equal PR #${prNumber} merge_commit_sha ${merged.mergeCommitSha} (identity, not reachability) (#3675).`);
1053
+ }
1054
+ function checkRecordedImplementation(evidence, payload, prNumber) {
1055
+ const recordedImpl = typeof evidence.implementationCommit === "string" &&
1056
+ evidence.implementationCommit.trim().length > 0
1057
+ ? evidence.implementationCommit.trim()
1058
+ : null;
1059
+ if (recordedImpl === null) {
1060
+ return null;
1061
+ }
1062
+ const headSha = payloadHeadSha(payload);
1063
+ if (headSha !== null && shasEqual(recordedImpl, headSha)) {
1064
+ return null;
1065
+ }
1066
+ return (`Delivery identity join failed: recorded implementationCommit ${recordedImpl} ` +
1067
+ `does not match PR #${prNumber} head at merge time` +
1068
+ `${headSha !== null ? ` (${headSha})` : ""} (#3675).`);
1069
+ }
1070
+ /** Shared tail: head check and the success result. */
1071
+ function finishIdentityJoin(input) {
1072
+ const { evidence, merged, prNumber } = input;
1073
+ const headCheck = checkRecordedImplementation(evidence, merged.payload, prNumber);
1074
+ if (headCheck !== null) {
1075
+ return { ok: false, message: headCheck };
1076
+ }
1077
+ const base = asRecord(merged.payload.base);
1078
+ return {
1079
+ ok: true,
1080
+ message: `story→PR→merge identity joined: ${input.subject} via PR #${prNumber} ` +
1081
+ `(${input.identityBasis})`,
1082
+ mergeCommitSha: merged.mergeCommitSha,
1083
+ mergedAt: merged.mergedAt,
1084
+ prBase: typeof base?.ref === "string" ? base.ref : null,
1085
+ headSha: payloadHeadSha(merged.payload),
1086
+ identityBasis: input.identityBasis,
1087
+ repository: input.repository,
1088
+ };
1089
+ }
1090
+ function evidencePrNumber(evidence) {
1091
+ return typeof evidence.prNumber === "number" &&
1092
+ Number.isInteger(evidence.prNumber) &&
1093
+ evidence.prNumber > 0
1094
+ ? evidence.prNumber
1095
+ : null;
1096
+ }
1097
+ function evidenceRepository(evidence) {
1098
+ return typeof evidence.repository === "string" && evidence.repository.trim().length > 0
1099
+ ? evidence.repository.trim()
1100
+ : null;
1101
+ }
1102
+ /** PR-only join (#5710 P1): stamp + canonical remote; no issue closer, no merge-time standard. */
1103
+ function verifyPrOnlyIdentity(input) {
1104
+ const resolved = resolvePrOnlyDeliveryIdentity(input.plan, input.resolveCanonicalRepository ?? (() => null));
1105
+ if (!resolved.ok) {
1106
+ return { ok: false, message: resolved.message };
1107
+ }
1108
+ const { repository, prNumber: stamped } = resolved;
1109
+ const assertedRepo = evidenceRepository(input.evidence);
1110
+ if (assertedRepo !== null && assertedRepo.toLowerCase() !== repository.toLowerCase()) {
444
1111
  return {
445
1112
  ok: false,
446
- message: `Delivery identity join failed: could not fetch closingIssuesReferences for ` +
447
- `PR #${prNumber} in ${expected.repository} (#3675).`,
1113
+ message: `Delivery identity join failed: --repo '${assertedRepo}' does not match the canonical ` +
1114
+ `project remote '${repository}' (matching assertion only) (#5710).`,
448
1115
  };
449
1116
  }
450
- const closerSet = linked.flatMap((ref) => closerSetFromIssueIds(ref.repository, [ref.issueNumber]));
451
- const expectedRepoKey = expected.repository.trim().toLowerCase();
452
- const closesStory = closerSet.some((origin) => origin.repo === expectedRepoKey && origin.issueId === expected.issueNumber);
453
- let identityBasis = "forge-closer";
454
- if (!closesStory) {
455
- const closerMiss = `Delivery identity join failed: PR #${prNumber} forge closer set does not include ` +
456
- `${expected.repository}#${expected.issueNumber} (wrong story / absent association) (#3675).`;
457
- const briefBasename = input.briefBasename?.trim() ?? "";
458
- if (briefBasename.length === 0 || input.fetchPrChangedFiles === undefined) {
459
- return { ok: false, message: closerMiss };
460
- }
461
- const files = input.fetchPrChangedFiles(prNumber, expected.repository);
462
- if (files === null) {
463
- return {
464
- ok: false,
465
- message: `${closerMiss} Brief-in-PR-diff fallback could not read PR #${prNumber} changed files ` +
466
- `(lookup failure).`,
467
- };
468
- }
469
- if (!prDiffAddsBrief(files, briefBasename)) {
470
- return {
471
- ok: false,
472
- message: `${closerMiss} Brief-in-PR-diff fallback also failed: PR #${prNumber} did not add ` +
473
- `${briefBasename} alongside code changes.`,
474
- };
475
- }
476
- identityBasis = "brief-in-pr-diff";
1117
+ const prNumber = evidencePrNumber(input.evidence);
1118
+ if (prNumber === null) {
1119
+ return {
1120
+ ok: false,
1121
+ message: `Delivery identity join failed: PR-only brief needs --pr ${stamped} ` +
1122
+ `(plan.metadata.productPullRequest) (#5710).`,
1123
+ };
477
1124
  }
478
- if (!shasEqual(input.mergeCommit, mergeCommitSha)) {
1125
+ if (prNumber !== stamped) {
479
1126
  return {
480
1127
  ok: false,
481
- message: `Delivery identity join failed: supplied merge commit ${input.mergeCommit} does not ` +
482
- `equal PR #${prNumber} merge_commit_sha ${mergeCommitSha} (identity, not reachability) (#3675).`,
1128
+ message: `Delivery identity join failed: --pr ${prNumber} is not plan.metadata.productPullRequest ` +
1129
+ `${stamped}. On a PR-only brief the stamp is the identity; an x-xbrief/github-pr ` +
1130
+ `reference does not override it (#5710).`,
483
1131
  };
484
1132
  }
485
- const base = asRecord(payload.base);
486
- const head = asRecord(payload.head);
487
- const prBase = typeof base?.ref === "string" ? base.ref : null;
488
- const headSha = typeof head?.sha === "string" && head.sha.trim().length > 0
489
- ? head.sha.trim()
490
- : typeof payload.head_sha === "string" && payload.head_sha.trim().length > 0
491
- ? payload.head_sha.trim()
492
- : null;
493
- const recordedImpl = typeof input.evidence.implementationCommit === "string" &&
494
- input.evidence.implementationCommit.trim().length > 0
495
- ? input.evidence.implementationCommit.trim()
496
- : null;
497
- if (recordedImpl !== null) {
498
- if (headSha === null || !shasEqual(recordedImpl, headSha)) {
499
- return {
500
- ok: false,
501
- message: `Delivery identity join failed: recorded implementationCommit ${recordedImpl} ` +
502
- `does not match PR #${prNumber} head at merge time` +
503
- `${headSha !== null ? ` (${headSha})` : ""} (#3675).`,
504
- };
1133
+ const merged = fetchMergedPayload(input.fetchPrPayload, prNumber, repository);
1134
+ if (!merged.ok) {
1135
+ return { ok: false, message: merged.message };
1136
+ }
1137
+ const mismatch = mergeCommitMismatch(input.mergeCommit, merged, prNumber);
1138
+ if (mismatch !== null) {
1139
+ return { ok: false, message: mismatch };
1140
+ }
1141
+ return finishIdentityJoin({
1142
+ evidence: input.evidence,
1143
+ merged,
1144
+ prNumber,
1145
+ repository,
1146
+ identityBasis: "pr-only",
1147
+ subject: `${repository} productPullRequest`,
1148
+ });
1149
+ }
1150
+ /** Brief-in-PR-diff basis (#3675, unchanged). Null = proven; "" = not attempted; else reason. */
1151
+ function proveBriefInPrDiff(input, repository, prNumber) {
1152
+ const briefBasename = input.briefBasename?.trim() ?? "";
1153
+ if (briefBasename.length === 0 || input.fetchPrChangedFiles === undefined) {
1154
+ return "";
1155
+ }
1156
+ const files = input.fetchPrChangedFiles(prNumber, repository);
1157
+ if (files === null) {
1158
+ return `brief-in-PR-diff: could not read PR #${prNumber} changed files (lookup failure)`;
1159
+ }
1160
+ if (!prDiffAddsBrief(files, briefBasename)) {
1161
+ return `brief-in-PR-diff: PR #${prNumber} did not add ${briefBasename} alongside code changes`;
1162
+ }
1163
+ return null;
1164
+ }
1165
+ /** Bases b and c from forge reads; returns the proven basis or appends reasons. */
1166
+ function proveKeywordOrConnected(forge, budget, target, reasons) {
1167
+ const { repository, issueNumber, prNumber, mergedAt } = target;
1168
+ let mergeTimeBody = null;
1169
+ const body = reconstructMergeTimeBody(forge, budget, { repository, prNumber }, target.currentBody, mergedAt);
1170
+ if (body.ok) {
1171
+ mergeTimeBody = body.body;
1172
+ if (bodyHasClosingKeywordFor(body.body, repository, issueNumber)) {
1173
+ return { basis: "merge-time-keyword", mergeTimeBody };
1174
+ }
1175
+ reasons.push(`(b) PR #${prNumber} body as of merged_at has no closing keyword for #${issueNumber}`);
1176
+ }
1177
+ else {
1178
+ reasons.push(`(a)/(b) ${body.error}`);
1179
+ }
1180
+ const events = collectAllPages((after) => forge.fetchConnectionEventsPage(repository, prNumber, after), budget.beforeGraphql, `PR #${prNumber} connection events`);
1181
+ if (!events.ok) {
1182
+ reasons.push(`(c) ${events.error}`);
1183
+ return { basis: null, mergeTimeBody };
1184
+ }
1185
+ const state = connectedAtMergedAt(events.nodes, repository, issueNumber, mergedAt);
1186
+ if (state.ok && state.connected) {
1187
+ return { basis: "merge-time-connected", mergeTimeBody };
1188
+ }
1189
+ reasons.push(state.ok
1190
+ ? `(c) no Connected link to #${issueNumber} in effect at merged_at`
1191
+ : `(c) ${state.error}`);
1192
+ return { basis: null, mergeTimeBody };
1193
+ }
1194
+ /**
1195
+ * Issue-anchored association as of merged_at (#5710 P3). Cheap bases first
1196
+ * (b, c, brief-in-PR-diff), then the exhaustive sole-mark scan (a). A failed
1197
+ * lookup only removes a basis; success always needs a positive proof.
1198
+ */
1199
+ function proveIssueAnchoredAssociation(input, target) {
1200
+ const { repository, issueNumber, prNumber } = target;
1201
+ const reasons = [];
1202
+ const forge = input.mergeTimeForge;
1203
+ const budget = forge === undefined ? null : makeScanBudget(forge);
1204
+ let mergeTimeBody = null;
1205
+ if (forge !== undefined && budget !== null) {
1206
+ const cheap = proveKeywordOrConnected(forge, budget, target, reasons);
1207
+ if (cheap.basis !== null) {
1208
+ return { ok: true, basis: cheap.basis };
505
1209
  }
1210
+ mergeTimeBody = cheap.mergeTimeBody;
1211
+ }
1212
+ else {
1213
+ reasons.push("merge-time association lookups are unavailable");
1214
+ }
1215
+ const briefDiff = proveBriefInPrDiff(input, repository, prNumber);
1216
+ if (briefDiff === null) {
1217
+ return { ok: true, basis: "brief-in-pr-diff" };
1218
+ }
1219
+ if (briefDiff.length > 0) {
1220
+ reasons.push(briefDiff);
1221
+ }
1222
+ if (forge !== undefined && budget !== null && mergeTimeBody !== null) {
1223
+ const mark = proveSoleMarkBasis({
1224
+ forge,
1225
+ budget,
1226
+ repository,
1227
+ issueNumber,
1228
+ prNumber,
1229
+ mergeTimeBody,
1230
+ });
1231
+ if (mark.ok) {
1232
+ return { ok: true, basis: "merge-time-mark" };
1233
+ }
1234
+ reasons.unshift(mark.reason);
506
1235
  }
507
1236
  return {
508
- ok: true,
509
- message: `story→PR→merge identity joined: ${expected.repository}#${expected.issueNumber} via PR #${prNumber}` +
510
- (identityBasis === "brief-in-pr-diff" ? " (brief-in-PR-diff)" : ""),
511
- mergeCommitSha,
512
- mergedAt,
513
- prBase,
514
- headSha,
515
- identityBasis,
1237
+ ok: false,
1238
+ message: `Delivery identity join failed: PR #${prNumber} has no merge-time association with ` +
1239
+ `${repository}#${issueNumber}. Accepted bases: ${MERGE_TIME_ASSOCIATION_BASES}. ` +
1240
+ `A mark, keyword or link added after merged_at does not count, and current ` +
1241
+ `closingIssuesReferences alone is insufficient. Findings: ${reasons.join("; ")}. ` +
1242
+ `Already-merged work with no merge-time basis stays refused until the parked ` +
1243
+ `identity-migration ships (#5710).`,
516
1244
  };
517
1245
  }
1246
+ /**
1247
+ * Prefer-A story→PR→merge identity join before delivery liveness (#3675 / #5710).
1248
+ *
1249
+ * Issue-anchored briefs (today's `resolvePlanGithubIssueRef`): the association
1250
+ * must hold as of merged_at by (a) a unique sole `deft-story` mark, (b) a
1251
+ * closing keyword in the merge-time body, or (c) a Connected link at merged_at,
1252
+ * or by the unchanged brief-in-PR-diff basis. Current-state
1253
+ * closingIssuesReferences alone is insufficient, and `productPullRequest === --pr`
1254
+ * is not a proof here. A failed issue-anchored check never falls back to the stamp.
1255
+ *
1256
+ * Issue-less briefs use the PR-only identity (#5710 P1). merge_commit_sha must
1257
+ * equal the supplied merge commit on both paths (squash-safe).
1258
+ */
1259
+ export function verifyStoryPrMergeIdentity(input) {
1260
+ const expected = resolvePlanGithubIssueRef(input.plan);
1261
+ if (expected === null) {
1262
+ return verifyPrOnlyIdentity(input);
1263
+ }
1264
+ const evidenceRepo = evidenceRepository(input.evidence);
1265
+ if (evidenceRepo !== null && evidenceRepo !== expected.repository) {
1266
+ return {
1267
+ ok: false,
1268
+ message: `Delivery identity join failed: evidence repository '${evidenceRepo}' does not match ` +
1269
+ `plan-derived expected repository '${expected.repository}' (#3675).`,
1270
+ };
1271
+ }
1272
+ const prNumber = evidencePrNumber(input.evidence);
1273
+ if (prNumber === null) {
1274
+ return {
1275
+ ok: false,
1276
+ message: "Delivery identity join failed: missing story-associated PR number. " +
1277
+ "Pass --pr <n> for the PR that delivered this story; --pr alone is not identity (#3675).",
1278
+ };
1279
+ }
1280
+ const merged = fetchMergedPayload(input.fetchPrPayload, prNumber, expected.repository);
1281
+ if (!merged.ok) {
1282
+ return { ok: false, message: merged.message };
1283
+ }
1284
+ const mismatch = mergeCommitMismatch(input.mergeCommit, merged, prNumber);
1285
+ if (mismatch !== null) {
1286
+ return { ok: false, message: mismatch };
1287
+ }
1288
+ const association = proveIssueAnchoredAssociation(input, {
1289
+ repository: expected.repository,
1290
+ issueNumber: expected.issueNumber,
1291
+ prNumber,
1292
+ mergedAt: merged.mergedAt,
1293
+ currentBody: typeof merged.payload.body === "string" ? merged.payload.body : null,
1294
+ });
1295
+ if (!association.ok) {
1296
+ return { ok: false, message: association.message };
1297
+ }
1298
+ return finishIdentityJoin({
1299
+ evidence: input.evidence,
1300
+ merged,
1301
+ prNumber,
1302
+ repository: expected.repository,
1303
+ identityBasis: association.basis,
1304
+ subject: `${expected.repository}#${expected.issueNumber}`,
1305
+ });
1306
+ }
518
1307
  /**
519
1308
  * Classify stored completion provenance for read paths.
520
1309
  * Legacy completed records without provenance → unknown/unverified (not delivered).
@@ -777,6 +1566,60 @@ function deliveryEvidenceRemediation(deliveryBranch) {
777
1566
  `When ${deliveryBranch} is delivery, wait until the merge commit is an ancestor of ` +
778
1567
  `origin/${deliveryBranch}, then complete. Do not use --non-delivery for work that shipped.`);
779
1568
  }
1569
+ /**
1570
+ * #5710 P4: refuse a non-delivery disposition when the stamped productPullRequest
1571
+ * is merged in the authoritative repository (the issue repository, else the
1572
+ * canonical remote). Merged to an integration branch counts as merged in v1.
1573
+ * Returns null when no stamp exists or the stamped PR is confirmed unmerged.
1574
+ * Any failure to establish merge state fails closed with a retry diagnostic.
1575
+ *
1576
+ * Policy (Recut-13 lean, #5710 comment 6817282070):
1577
+ * - Guarantee: a code-bearing brief (`isCodeBearingScope`) cannot take a
1578
+ * non-delivery disposition while its stamped PR is merged or unreadable.
1579
+ * - Non-goal in v1: briefs that are not code-bearing (`docs-only` /
1580
+ * `process-only` tags, `metadata.delivery.required: false`, docs/process/
1581
+ * research kinds). They return before this check. The lean parks "Moving the
1582
+ * non-delivery refuse ahead of the !codeBearing early return"; widening it
1583
+ * needs a new contract, not a review fix.
1584
+ */
1585
+ function refuseNonDeliveryForMergedStamp(input) {
1586
+ const productPr = productPullRequestFromPlan(input.plan);
1587
+ if (productPr === null) {
1588
+ return null;
1589
+ }
1590
+ const head = `Refusing scope:complete --non-delivery ${input.disposition}:`;
1591
+ const noWrite = "No completion or provenance was recorded (#5710).";
1592
+ const repository = resolvePlanGithubIssueRef(input.plan)?.repository ?? input.resolveCanonicalRepository();
1593
+ if (repository === null) {
1594
+ return (`${head} cannot resolve the repository of stamped productPullRequest #${productPr} ` +
1595
+ `(no issue origin and no canonical origin remote). Repair the remote or the stamp, ` +
1596
+ `then retry. ${noWrite}`);
1597
+ }
1598
+ let payload;
1599
+ try {
1600
+ payload = input.fetchPrPayload(productPr, repository);
1601
+ }
1602
+ catch {
1603
+ payload = null;
1604
+ }
1605
+ const mergedAt = payload?.merged_at;
1606
+ const unknown = payload === null ||
1607
+ payload === undefined ||
1608
+ !("merged_at" in payload) ||
1609
+ (mergedAt !== null && (typeof mergedAt !== "string" || mergedAt.trim().length === 0));
1610
+ if (unknown) {
1611
+ return (`${head} could not establish whether stamped productPullRequest #${productPr} in ` +
1612
+ `${repository} is merged (lookup failed or malformed response). Retry after fixing gh ` +
1613
+ `auth, rate limit or network, or repair plan.metadata.productPullRequest. ${noWrite}`);
1614
+ }
1615
+ if (mergedAt === null) {
1616
+ return null;
1617
+ }
1618
+ return (`${head} stamped productPullRequest #${productPr} in ${repository} is merged ` +
1619
+ `(merged_at ${String(mergedAt)}). Shipped work completes as delivered: ` +
1620
+ `scope:complete -- --pr ${productPr} --merge-commit <sha>. A merge to an integration ` +
1621
+ `branch also counts as merged here. ${noWrite}`);
1622
+ }
780
1623
  /**
781
1624
  * Gate delivered completion for a code-bearing scope (#3041 / #3380 / #3675).
782
1625
  *
@@ -793,14 +1636,19 @@ export function evaluateDeliveryGate(options) {
793
1636
  const runGh = options.runGh ?? defaultRunGh;
794
1637
  const fetchPrPayload = options.fetchPrPayload ??
795
1638
  ((prNumber, repository) => defaultFetchPrPayload(prNumber, repository, runGh));
796
- const fetchClosingIssueIds = options.fetchClosingIssueIds ??
797
- ((prNumber, repository) => defaultFetchClosingIssueIds(prNumber, repository, runGh));
798
- // Default the changed-files fetcher only when closer refs are not injected, so
799
- // injected test seams never fall through to a live gh call.
1639
+ // Live defaults only when the PR payload is not injected, so injected test
1640
+ // seams never fall through to a live gh call.
1641
+ const liveForge = options.fetchPrPayload === undefined;
800
1642
  const fetchPrChangedFiles = options.fetchPrChangedFiles ??
801
- (options.fetchClosingIssueIds === undefined
1643
+ (liveForge
802
1644
  ? (prNumber, repository) => defaultFetchPrChangedFiles(prNumber, repository, runGh)
803
1645
  : undefined);
1646
+ const mergeTimeForge = options.mergeTimeForge ?? (liveForge ? defaultMergeTimeForge(runGh) : undefined);
1647
+ const resolveCanonicalRepository = () => resolveCanonicalProjectRepository(options.projectRoot, runGit);
1648
+ // #5710 P4: the merged-stamp check needs an uncached read; a stale
1649
+ // "unmerged" would let shipped work record as non-delivery.
1650
+ const fetchStampedPrLive = options.fetchPrPayload ??
1651
+ ((prNumber, repository) => defaultFetchPrPayload(prNumber, repository, liveRunGh(runGh)));
804
1652
  const verifier = options.verifier ?? "scope:complete";
805
1653
  const codeBearing = isCodeBearingScope(options.plan);
806
1654
  // #5693: experiment_archived Findings check runs before the non-code early
@@ -835,6 +1683,16 @@ export function evaluateDeliveryGate(options) {
835
1683
  codeBearing: true,
836
1684
  };
837
1685
  }
1686
+ // #5710 P4: resolve a stamped productPullRequest before any non-delivery success.
1687
+ const shipped = refuseNonDeliveryForMergedStamp({
1688
+ plan: options.plan,
1689
+ disposition: options.nonDeliveryDisposition,
1690
+ fetchPrPayload: fetchStampedPrLive,
1691
+ resolveCanonicalRepository,
1692
+ });
1693
+ if (shipped !== null) {
1694
+ return { ok: false, message: shipped, provenance: null, codeBearing: true };
1695
+ }
838
1696
  const branchResult = resolveDeliveryBranch(options.projectRoot, runGit);
839
1697
  const refused = refuseUnsafeResolvedDelivery(branchResult);
840
1698
  if (refused !== null) {
@@ -931,9 +1789,10 @@ export function evaluateDeliveryGate(options) {
931
1789
  evidence,
932
1790
  mergeCommit,
933
1791
  fetchPrPayload,
934
- fetchClosingIssueIds,
1792
+ mergeTimeForge,
935
1793
  briefBasename: options.briefBasename,
936
1794
  fetchPrChangedFiles,
1795
+ resolveCanonicalRepository,
937
1796
  });
938
1797
  if (!identity.ok) {
939
1798
  return {
@@ -945,7 +1804,7 @@ export function evaluateDeliveryGate(options) {
945
1804
  }
946
1805
  const boundEvidence = {
947
1806
  ...evidence,
948
- repository: resolvePlanGithubIssueRef(options.plan)?.repository ?? evidence.repository ?? null,
1807
+ repository: identity.repository ?? evidence.repository ?? null,
949
1808
  prNumber: evidence.prNumber,
950
1809
  prBase: identity.prBase ?? prBase,
951
1810
  mergeCommit: identity.mergeCommitSha ?? mergeCommit,
@@ -1372,20 +2231,27 @@ export function discoverDeliveryEvidenceForComplete(options) {
1372
2231
  const expected = resolvePlanGithubIssueRef(options.plan);
1373
2232
  const productPr = productPullRequestFromPlan(options.plan);
1374
2233
  if (productPr !== null) {
1375
- if (expected === null) {
1376
- return {
1377
- ok: false,
1378
- message: `Delivery auto-discovery failed: plan has productPullRequest=${productPr} but no ` +
1379
- `x-xbrief/github-issue reference to derive repository (#3583). ` +
1380
- deliveryEvidenceRemediation(deliveryBranch),
1381
- };
2234
+ // #5710 P1: an issue-less brief uses the same PR-only resolver as the identity join.
2235
+ let repository;
2236
+ if (expected !== null) {
2237
+ repository = expected.repository;
2238
+ }
2239
+ else {
2240
+ const prOnly = resolvePrOnlyDeliveryIdentity(options.plan, () => resolveCanonicalProjectRepository(options.projectRoot, runGit));
2241
+ if (!prOnly.ok) {
2242
+ return {
2243
+ ok: false,
2244
+ message: `Delivery auto-discovery failed: ${prOnly.message} ${deliveryEvidenceRemediation(deliveryBranch)}`,
2245
+ };
2246
+ }
2247
+ repository = prOnly.repository;
1382
2248
  }
1383
- const payload = fetchPrPayload(productPr, expected.repository);
2249
+ const payload = fetchPrPayload(productPr, repository);
1384
2250
  if (payload === null) {
1385
2251
  return {
1386
2252
  ok: false,
1387
2253
  message: `Delivery auto-discovery failed: could not fetch productPullRequest #${productPr} ` +
1388
- `in ${expected.repository} (#3583). ${deliveryEvidenceRemediation(deliveryBranch)}`,
2254
+ `in ${repository} (#3583). ${deliveryEvidenceRemediation(deliveryBranch)}`,
1389
2255
  };
1390
2256
  }
1391
2257
  if (!candidatePassesDeliveryBaseFilter(payload, deliveryBranch)) {
@@ -1396,7 +2262,7 @@ export function discoverDeliveryEvidenceForComplete(options) {
1396
2262
  `territory in v1) (#3583). ${deliveryEvidenceRemediation(deliveryBranch)}`,
1397
2263
  };
1398
2264
  }
1399
- const discovered = evidenceFromPrPayload(payload, productPr, expected.repository, deliveryBranch, verifier);
2265
+ const discovered = evidenceFromPrPayload(payload, productPr, repository, deliveryBranch, verifier);
1400
2266
  return {
1401
2267
  ok: true,
1402
2268
  evidence: mergePreservingExplicitFacts(existing, discovered),