@deftai/directive-runtime 0.129.6 → 0.129.7

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 (85) hide show
  1. package/Taskfile.yml +94 -13
  2. package/commands.md +5 -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/skills/deft-directive-review-cycle/SKILL.md +15 -6
  14. package/skills/deft-directive-swarm/references/host-grok-build.md +3 -2
  15. package/vendor/directive-content/Taskfile.yml +94 -13
  16. package/vendor/directive-content/commands.md +5 -3
  17. package/vendor/directive-content/docs/getting-started.md +2 -0
  18. package/vendor/directive-content/docs/scope-provenance.md +15 -1
  19. package/vendor/directive-content/packs/skills/skills-pack-0.1.json +1 -1
  20. package/vendor/directive-content/skills/deft-directive-review-cycle/SKILL.md +15 -6
  21. package/vendor/directive-content/skills/deft-directive-swarm/references/host-grok-build.md +3 -2
  22. package/vendor/directive-core/dist/.tsbuildinfo +1 -1
  23. package/vendor/directive-core/dist/fs/contained-write.d.ts +8 -2
  24. package/vendor/directive-core/dist/fs/contained-write.d.ts.map +1 -1
  25. package/vendor/directive-core/dist/fs/contained-write.js +22 -6
  26. package/vendor/directive-core/dist/fs/contained-write.js.map +1 -1
  27. package/vendor/directive-core/dist/hooks/dispatcher.d.ts +1 -1
  28. package/vendor/directive-core/dist/hooks/dispatcher.d.ts.map +1 -1
  29. package/vendor/directive-core/dist/hooks/dispatcher.js +116 -4
  30. package/vendor/directive-core/dist/hooks/dispatcher.js.map +1 -1
  31. package/vendor/directive-core/dist/init-deposit/agent-hooks.d.ts +26 -1
  32. package/vendor/directive-core/dist/init-deposit/agent-hooks.d.ts.map +1 -1
  33. package/vendor/directive-core/dist/init-deposit/agent-hooks.js +52 -3
  34. package/vendor/directive-core/dist/init-deposit/agent-hooks.js.map +1 -1
  35. package/vendor/directive-core/dist/init-deposit/host-hook-outer.d.ts +43 -0
  36. package/vendor/directive-core/dist/init-deposit/host-hook-outer.d.ts.map +1 -1
  37. package/vendor/directive-core/dist/init-deposit/host-hook-outer.js +148 -0
  38. package/vendor/directive-core/dist/init-deposit/host-hook-outer.js.map +1 -1
  39. package/vendor/directive-core/dist/pr-merge-readiness/slizard-gate.d.ts +10 -5
  40. package/vendor/directive-core/dist/pr-merge-readiness/slizard-gate.d.ts.map +1 -1
  41. package/vendor/directive-core/dist/pr-merge-readiness/slizard-gate.js +41 -18
  42. package/vendor/directive-core/dist/pr-merge-readiness/slizard-gate.js.map +1 -1
  43. package/vendor/directive-core/dist/preflight-gh/pre-push.d.ts +3 -2
  44. package/vendor/directive-core/dist/preflight-gh/pre-push.d.ts.map +1 -1
  45. package/vendor/directive-core/dist/preflight-gh/pre-push.js +19 -18
  46. package/vendor/directive-core/dist/preflight-gh/pre-push.js.map +1 -1
  47. package/vendor/directive-core/dist/scope/delivery-evidence.d.ts +202 -19
  48. package/vendor/directive-core/dist/scope/delivery-evidence.d.ts.map +1 -1
  49. package/vendor/directive-core/dist/scope/delivery-evidence.js +948 -107
  50. package/vendor/directive-core/dist/scope/delivery-evidence.js.map +1 -1
  51. package/vendor/directive-core/dist/scope/transition.d.ts +4 -1
  52. package/vendor/directive-core/dist/scope/transition.d.ts.map +1 -1
  53. package/vendor/directive-core/dist/scope/transition.js +2 -2
  54. package/vendor/directive-core/dist/scope/transition.js.map +1 -1
  55. package/vendor/directive-core/dist/scope-provenance/base-fence.d.ts +11 -2
  56. package/vendor/directive-core/dist/scope-provenance/base-fence.d.ts.map +1 -1
  57. package/vendor/directive-core/dist/scope-provenance/base-fence.js +16 -1
  58. package/vendor/directive-core/dist/scope-provenance/base-fence.js.map +1 -1
  59. package/vendor/directive-core/dist/scope-provenance/evaluate.d.ts +12 -0
  60. package/vendor/directive-core/dist/scope-provenance/evaluate.d.ts.map +1 -1
  61. package/vendor/directive-core/dist/scope-provenance/evaluate.js +41 -5
  62. package/vendor/directive-core/dist/scope-provenance/evaluate.js.map +1 -1
  63. package/vendor/directive-core/dist/scope-provenance/forge-approval.d.ts +106 -0
  64. package/vendor/directive-core/dist/scope-provenance/forge-approval.d.ts.map +1 -0
  65. package/vendor/directive-core/dist/scope-provenance/forge-approval.js +382 -0
  66. package/vendor/directive-core/dist/scope-provenance/forge-approval.js.map +1 -0
  67. package/vendor/directive-core/dist/session/orientation-compression.d.ts +18 -2
  68. package/vendor/directive-core/dist/session/orientation-compression.d.ts.map +1 -1
  69. package/vendor/directive-core/dist/session/orientation-compression.js +38 -5
  70. package/vendor/directive-core/dist/session/orientation-compression.js.map +1 -1
  71. package/vendor/directive-core/dist/session/session-start.d.ts +47 -2
  72. package/vendor/directive-core/dist/session/session-start.d.ts.map +1 -1
  73. package/vendor/directive-core/dist/session/session-start.js +149 -1
  74. package/vendor/directive-core/dist/session/session-start.js.map +1 -1
  75. package/vendor/directive-core/dist/swarm/approach1-babysitter.d.ts +18 -4
  76. package/vendor/directive-core/dist/swarm/approach1-babysitter.d.ts.map +1 -1
  77. package/vendor/directive-core/dist/swarm/approach1-babysitter.js +28 -4
  78. package/vendor/directive-core/dist/swarm/approach1-babysitter.js.map +1 -1
  79. package/vendor/directive-core/dist/swarm/finalize-cohort.d.ts +29 -0
  80. package/vendor/directive-core/dist/swarm/finalize-cohort.d.ts.map +1 -1
  81. package/vendor/directive-core/dist/swarm/finalize-cohort.js +122 -11
  82. package/vendor/directive-core/dist/swarm/finalize-cohort.js.map +1 -1
  83. package/vendor/directive-core/dist/triage/evaluate/worktrees.d.ts.map +1 -1
  84. package/vendor/directive-core/dist/triage/evaluate/worktrees.js +219 -60
  85. package/vendor/directive-core/dist/triage/evaluate/worktrees.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,618 @@ 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
+ const DELIVERY_BRIEF_LAND_RE = /^(?:xbrief|vbrief)\/(?:proposed|pending|active|completed)\//;
533
+ function isDeliveryBriefLandPath(path) {
534
+ const p = path.replace(/\\/g, "/");
535
+ return p === "CHANGELOG.md" || DELIVERY_BRIEF_LAND_RE.test(p);
536
+ }
537
+ /**
538
+ * Delivery-brief-land exclusion (#5710): every changed path, rename sources
539
+ * included, is a brief under `xbrief|vbrief/(proposed|pending|active|completed)/`
540
+ * or root `CHANGELOG.md`, with at least one such path for a positive
541
+ * exclusion. CHANGELOG-only, completed-folder renames and deletion-only diffs
542
+ * on those paths are excluded. A zero-file diff is not a positive exclusion:
543
+ * as a competitor it still counts (fail closed), and as a candidate it is
544
+ * refused separately because it carries no delivery. Not identical to
545
+ * activation `isBriefLandShapedDiff`, which denies completed/ and needs a
546
+ * non-removed nonterminal brief.
547
+ */
548
+ export function isDeliveryBriefLandExcluded(files) {
549
+ return (files.length > 0 &&
550
+ files.every((file) => isDeliveryBriefLandPath(file.filename) &&
551
+ ((file.previousFilename ?? "").length === 0 ||
552
+ isDeliveryBriefLandPath(file.previousFilename ?? ""))));
553
+ }
554
+ /** The single distinct `deft-story` mark in a body, or null when zero or several (#5710). */
555
+ export function soleDeftStoryMark(body) {
556
+ const marks = parseAllDeftStoryMarks(body);
557
+ return marks.length === 1 ? (marks[0] ?? null) : null;
558
+ }
559
+ function reconstructMergeTimeBody(forge, budget, target, currentBody, mergedAt) {
560
+ const { repository, prNumber } = target;
561
+ const revisions = collectAllPages((after) => forge.fetchBodyRevisionsPage(repository, prNumber, after), budget.beforeGraphql, `PR #${prNumber} body revisions`);
562
+ if (!revisions.ok) {
563
+ return revisions;
564
+ }
565
+ const selected = selectMergeTimeBody(revisions.nodes, currentBody, mergedAt);
566
+ return selected.ok
567
+ ? selected
568
+ : { ok: false, error: `PR #${prNumber} merge-time body: ${selected.error}` };
569
+ }
570
+ function classifyDeliveryExclusion(forge, budget, repository, prNumber) {
571
+ // REST budget is checked before every page, not once per PR (#5710).
572
+ const listed = collectAllPages((after) => forge.fetchPrFilesPage(repository, prNumber, after), budget.beforeRest, `PR #${prNumber} changed files`);
573
+ if (!listed.ok) {
574
+ return listed;
575
+ }
576
+ // The forge stops listing at its cap, so reaching it means the list may be truncated.
577
+ if (listed.nodes.length >= PR_FILES_LISTING_CAP) {
393
578
  return {
394
579
  ok: false,
395
- message: "Delivery identity join failed: plan has no x-xbrief/github-issue reference " +
396
- "to derive expected repository/issue (#3675).",
580
+ error: `PR #${prNumber} changed files: listing truncated at the ${PR_FILES_LISTING_CAP}-file cap`,
397
581
  };
398
582
  }
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) {
583
+ return {
584
+ ok: true,
585
+ excluded: isDeliveryBriefLandExcluded(listed.nodes),
586
+ fileCount: listed.nodes.length,
587
+ };
588
+ }
589
+ /** Merge-time body of one listed PR; edits only replayed when one landed after merge. */
590
+ function listedMergeTimeBody(forge, budget, repository, node) {
591
+ const mergedMs = Date.parse(node.mergedAt);
592
+ const lastEditedMs = node.lastEditedAt === null ? null : Date.parse(node.lastEditedAt);
593
+ if (Number.isNaN(mergedMs) || (lastEditedMs !== null && Number.isNaN(lastEditedMs))) {
594
+ return { ok: false, error: `PR #${node.number} has unreadable mergedAt / lastEditedAt` };
595
+ }
596
+ // No edit after merge: the latest revision at or before merged_at is the
597
+ // latest revision, which is the current body.
598
+ if (lastEditedMs === null || lastEditedMs <= mergedMs) {
599
+ return { ok: true, body: node.body };
600
+ }
601
+ return reconstructMergeTimeBody(forge, budget, { repository, prNumber: node.number }, node.body, node.mergedAt);
602
+ }
603
+ /**
604
+ * Bounded exhaustive uniqueness scan (#5710 basis a). Enumerates every merged
605
+ * PR, reconstructs each merge-time body, and classifies sole-mark hits for
606
+ * `issueNumber`. Returns the non-excluded sole-mark PRs other than the
607
+ * candidate. Any incomplete page, unreadable revision or competitor, truncated
608
+ * listing, or exhausted budget fails closed. No recent-window shortcut.
609
+ */
610
+ export function scanSoleMarkCompetitors(input) {
611
+ const { repository, issueNumber, candidatePr, forge } = input;
612
+ const budget = input.budget ?? makeScanBudget(forge);
613
+ const competitors = [];
614
+ let after = null;
615
+ for (;;) {
616
+ const refused = budget.beforeGraphql();
617
+ if (refused !== null) {
618
+ return { ok: false, error: `merged-PR scan: ${refused}` };
619
+ }
620
+ const page = forge.listMergedPrsPage(repository, after);
621
+ if (!page.ok) {
622
+ return { ok: false, error: `merged-PR scan: ${page.error}` };
623
+ }
624
+ for (const node of page.nodes) {
625
+ if (node.number === candidatePr)
626
+ continue;
627
+ const body = listedMergeTimeBody(forge, budget, repository, node);
628
+ if (!body.ok) {
629
+ return { ok: false, error: `merged-PR scan: ${body.error}` };
630
+ }
631
+ if (soleDeftStoryMark(body.body) !== issueNumber)
632
+ continue;
633
+ const exclusion = classifyDeliveryExclusion(forge, budget, repository, node.number);
634
+ if (!exclusion.ok) {
635
+ return { ok: false, error: `merged-PR scan: possible competitor ${exclusion.error}` };
636
+ }
637
+ if (!exclusion.excluded) {
638
+ competitors.push(node.number);
639
+ }
640
+ }
641
+ if (!page.hasNextPage) {
642
+ return { ok: true, competitors };
643
+ }
644
+ const next = nextCursor(page.endCursor, after);
645
+ if (next === null) {
646
+ return {
647
+ ok: false,
648
+ error: "merged-PR scan: incomplete page (hasNextPage without a new endCursor)",
649
+ };
650
+ }
651
+ after = next;
652
+ }
653
+ }
654
+ /** Basis a: unique sole mark among merged non-excluded PRs (#5710). */
655
+ function proveSoleMarkBasis(input) {
656
+ const { forge, budget, repository, issueNumber, prNumber } = input;
657
+ if (soleDeftStoryMark(input.mergeTimeBody) !== issueNumber) {
403
658
  return {
404
659
  ok: false,
405
- message: `Delivery identity join failed: evidence repository '${evidenceRepo}' does not match ` +
406
- `plan-derived expected repository '${expected.repository}' (#3675).`,
660
+ reason: `(a) PR #${prNumber} body as of merged_at has no sole \`deft-story: ${issueNumber}\` mark`,
407
661
  };
408
662
  }
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;
663
+ const exclusion = classifyDeliveryExclusion(forge, budget, repository, prNumber);
664
+ if (!exclusion.ok) {
665
+ return { ok: false, reason: `(a) ${exclusion.error}` };
666
+ }
667
+ if (exclusion.fileCount === 0) {
668
+ return {
669
+ ok: false,
670
+ reason: `(a) PR #${prNumber} changes no files (a zero-file PR carries no delivery)`,
671
+ };
672
+ }
673
+ if (exclusion.excluded) {
674
+ return {
675
+ ok: false,
676
+ reason: `(a) PR #${prNumber} changes only briefs and CHANGELOG.md ` +
677
+ `(delivery-brief-land excluded; not delivery)`,
678
+ };
679
+ }
680
+ const scan = scanSoleMarkCompetitors({
681
+ repository,
682
+ issueNumber,
683
+ candidatePr: prNumber,
684
+ forge,
685
+ budget,
686
+ });
687
+ if (!scan.ok) {
688
+ return { ok: false, reason: `(a) uniqueness unknown, refusing: ${scan.error}` };
689
+ }
690
+ if (scan.competitors.length > 0) {
691
+ const list = scan.competitors.map((n) => `#${n}`).join(", ");
692
+ return {
693
+ ok: false,
694
+ reason: `(a) mark is not unique: merged non-excluded PR(s) ${list} also carry a sole ` +
695
+ `\`deft-story: ${issueNumber}\` mark`,
696
+ };
697
+ }
698
+ return { ok: true };
699
+ }
700
+ /** One GraphQL call through gh; returns `data` or an error (any `errors` entry fails). */
701
+ function runGhGraphql(runGh, query, vars) {
702
+ const cmd = ["gh", "api", "graphql", "-f", `query=${query}`];
703
+ for (const [key, value] of Object.entries(vars)) {
704
+ if (value !== null) {
705
+ cmd.push("-F", `${key}=${String(value)}`);
706
+ }
707
+ }
708
+ let result;
709
+ try {
710
+ result = runGh(cmd);
711
+ }
712
+ catch (exc) {
713
+ return { ok: false, error: exc instanceof Error ? exc.message : String(exc) };
714
+ }
715
+ if (result.returncode !== 0) {
716
+ return { ok: false, error: result.stderr.trim() || "gh api graphql non-zero" };
717
+ }
718
+ let payload;
719
+ try {
720
+ payload = JSON.parse(result.stdout);
721
+ }
722
+ catch {
723
+ return { ok: false, error: "unparseable GraphQL JSON" };
724
+ }
725
+ const rec = asRecord(payload);
726
+ if (rec === null || (Array.isArray(rec.errors) && rec.errors.length > 0)) {
727
+ return { ok: false, error: "GraphQL response carried errors" };
728
+ }
729
+ const data = asRecord(rec.data);
730
+ return data === null ? { ok: false, error: "GraphQL response missing data" } : { ok: true, data };
731
+ }
732
+ function readConnection(value) {
733
+ const conn = asRecord(value);
734
+ const pageInfo = asRecord(conn?.pageInfo);
735
+ if (conn === null || pageInfo === null || !Array.isArray(conn.nodes))
736
+ return null;
737
+ if (typeof pageInfo.hasNextPage !== "boolean")
738
+ return null;
739
+ const endCursor = typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : null;
740
+ return { nodes: conn.nodes, hasNextPage: pageInfo.hasNextPage, endCursor };
741
+ }
742
+ const MERGED_PRS_GRAPHQL = "query($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){" +
743
+ "pullRequests(states:MERGED,first:100,after:$after,orderBy:{field:CREATED_AT,direction:ASC}){" +
744
+ "pageInfo{hasNextPage endCursor} nodes{number mergedAt body lastEditedAt}}}}";
745
+ const BODY_REVISIONS_GRAPHQL = "query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){" +
746
+ "pullRequest(number:$number){userContentEdits(first:100,after:$after){" +
747
+ "pageInfo{hasNextPage endCursor} nodes{editedAt deletedAt diff}}}}}";
748
+ const SUBJECT_FIELDS = "__typename ... on Issue{number repository{nameWithOwner}}";
749
+ const CONNECTION_EVENTS_GRAPHQL = "query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){" +
750
+ "pullRequest(number:$number){timelineItems(first:100,after:$after," +
751
+ "itemTypes:[CONNECTED_EVENT,DISCONNECTED_EVENT]){pageInfo{hasNextPage endCursor} nodes{__typename " +
752
+ `... on ConnectedEvent{createdAt source{${SUBJECT_FIELDS}} subject{${SUBJECT_FIELDS}}} ` +
753
+ `... on DisconnectedEvent{createdAt source{${SUBJECT_FIELDS}} subject{${SUBJECT_FIELDS}}}}}}}}`;
754
+ function parseEventIssue(value) {
755
+ const rec = asRecord(value);
756
+ if (rec === null || rec.__typename !== "Issue")
757
+ return null;
758
+ const repo = asRecord(rec.repository)?.nameWithOwner;
759
+ if (typeof rec.number !== "number" || typeof repo !== "string")
760
+ return null;
761
+ return { repository: repo, issueNumber: rec.number };
762
+ }
763
+ function parseMergedPrNode(value) {
764
+ const rec = asRecord(value);
765
+ if (rec === null || typeof rec.number !== "number" || typeof rec.mergedAt !== "string") {
766
+ return null;
767
+ }
768
+ if (rec.body !== null && typeof rec.body !== "string")
769
+ return null;
770
+ if (rec.lastEditedAt !== null && typeof rec.lastEditedAt !== "string")
771
+ return null;
772
+ return {
773
+ number: rec.number,
774
+ mergedAt: rec.mergedAt,
775
+ body: typeof rec.body === "string" ? rec.body : "",
776
+ lastEditedAt: typeof rec.lastEditedAt === "string" ? rec.lastEditedAt : null,
777
+ };
778
+ }
779
+ function parseRevisionNode(value) {
780
+ const rec = asRecord(value);
781
+ if (rec === null || typeof rec.editedAt !== "string")
782
+ return null;
783
+ const hidden = rec.deletedAt !== null && rec.deletedAt !== undefined;
784
+ return {
785
+ editedAt: rec.editedAt,
786
+ body: !hidden && typeof rec.diff === "string" ? rec.diff : null,
787
+ };
788
+ }
789
+ function parseConnectionEventNode(value) {
790
+ const rec = asRecord(value);
791
+ if (rec === null || typeof rec.createdAt !== "string")
792
+ return null;
793
+ let kind;
794
+ if (rec.__typename === "ConnectedEvent") {
795
+ kind = "connected";
796
+ }
797
+ else if (rec.__typename === "DisconnectedEvent") {
798
+ kind = "disconnected";
799
+ }
800
+ else {
801
+ return null;
802
+ }
803
+ const issues = [parseEventIssue(rec.source), parseEventIssue(rec.subject)].filter((ref) => ref !== null);
804
+ return { kind, createdAt: rec.createdAt, issues };
805
+ }
806
+ function graphqlPage(runGh, request, pick, parseNode) {
807
+ const response = runGhGraphql(runGh, request.query, request.vars);
808
+ if (!response.ok)
809
+ return response;
810
+ const conn = readConnection(pick(response.data));
811
+ if (conn === null) {
812
+ return { ok: false, error: "incomplete page (missing connection or pageInfo)" };
813
+ }
814
+ const nodes = [];
815
+ for (const raw of conn.nodes) {
816
+ const parsed = parseNode(raw);
817
+ if (parsed === null)
818
+ return { ok: false, error: "unreadable node in page" };
819
+ nodes.push(parsed);
820
+ }
821
+ return { ok: true, nodes, hasNextPage: conn.hasNextPage, endCursor: conn.endCursor };
822
+ }
823
+ function parsePrFileEntry(value) {
824
+ const rec = asRecord(value);
825
+ if (rec === null || typeof rec.filename !== "string" || rec.filename.length === 0)
826
+ return null;
827
+ if (typeof rec.status !== "string")
828
+ return null;
829
+ const previous = typeof rec.previous_filename === "string" ? rec.previous_filename : "";
830
+ return previous.length > 0
831
+ ? { status: rec.status, filename: rec.filename, previousFilename: previous }
832
+ : { status: rec.status, filename: rec.filename };
833
+ }
834
+ /**
835
+ * One REST page of `pulls/{n}/files` with rename sources, read uncached. The
836
+ * cursor is the next page number; paging ends at the first empty page.
837
+ */
838
+ function fetchPrFilesPageLive(runGh, repository, prNumber, after) {
839
+ const parsed = parseOwnerRepo(repository);
840
+ if (parsed === null)
841
+ return { ok: false, error: `invalid repository ${repository}` };
842
+ const page = after === null ? 1 : Number(after);
843
+ if (!Number.isInteger(page) || page < 1)
844
+ return { ok: false, error: `invalid page ${after}` };
845
+ const path = `repos/${parsed.owner}/${parsed.name}/pulls/${prNumber}/files?per_page=100&page=${page}`;
846
+ let result;
847
+ try {
848
+ result = liveRunGh(runGh)(["gh", "api", path]);
849
+ }
850
+ catch (exc) {
851
+ return { ok: false, error: exc instanceof Error ? exc.message : String(exc) };
852
+ }
853
+ if (result.returncode !== 0) {
854
+ return { ok: false, error: result.stderr.trim() || "gh api files non-zero" };
855
+ }
856
+ let payload;
857
+ try {
858
+ payload = JSON.parse(result.stdout);
859
+ }
860
+ catch {
861
+ return { ok: false, error: "unparseable files page" };
862
+ }
863
+ if (!Array.isArray(payload))
864
+ return { ok: false, error: "files page is not an array" };
865
+ const nodes = [];
866
+ for (const raw of payload) {
867
+ const entry = parsePrFileEntry(raw);
868
+ if (entry === null)
869
+ return { ok: false, error: "unreadable files entry" };
870
+ nodes.push(entry);
871
+ }
872
+ const hasNextPage = nodes.length > 0;
873
+ return { ok: true, nodes, hasNextPage, endCursor: hasNextPage ? String(page + 1) : null };
874
+ }
875
+ /**
876
+ * Pin plain `gh` for single-path GETs the default runner would send to the
877
+ * `ghx` cache proxy. A cached read can be stale: a PR cached before merge
878
+ * reads as unmerged, a rate bucket reads as fuller than it is (#954 / #5710).
879
+ * Injected runners are used as given.
880
+ */
881
+ export function liveRunGh(runGh) {
882
+ return runGh === defaultRunGh ? (cmd) => defaultRunGh(cmd, "gh") : runGh;
883
+ }
884
+ function probeLiveRateLimit(runGh) {
885
+ const cmd = ["gh", "api", "rate_limit"];
886
+ let result;
887
+ try {
888
+ result = liveRunGh(runGh)(cmd);
889
+ }
890
+ catch {
891
+ return null;
892
+ }
893
+ if (result.returncode !== 0)
894
+ return null;
895
+ try {
896
+ const resources = asRecord(asRecord(JSON.parse(result.stdout))?.resources);
897
+ const graphql = asRecord(resources?.graphql)?.remaining;
898
+ const core = asRecord(resources?.core)?.remaining;
899
+ if (typeof graphql !== "number" || typeof core !== "number")
900
+ return null;
901
+ return { graphqlRemaining: graphql, coreRemaining: core };
902
+ }
903
+ catch {
904
+ return null;
905
+ }
906
+ }
907
+ /**
908
+ * Live forge reads for merge-time association (#5710): GraphQL pages, REST
909
+ * changed files with rename sources, and the uncached `gh api rate_limit`.
910
+ */
911
+ export function defaultMergeTimeForge(runGh = defaultRunGh) {
912
+ const prVars = (repository, prNumber, after) => {
913
+ const parsed = parseOwnerRepo(repository);
914
+ return parsed === null
915
+ ? null
916
+ : { owner: parsed.owner, name: parsed.name, number: prNumber, after };
917
+ };
918
+ const pullRequestOf = (data) => asRecord(asRecord(data.repository)?.pullRequest);
919
+ const invalid = (repository) => ({
920
+ ok: false,
921
+ error: `invalid repository ${repository}`,
922
+ });
923
+ return {
924
+ listMergedPrsPage: (repository, after) => {
925
+ const parsed = parseOwnerRepo(repository);
926
+ if (parsed === null)
927
+ return invalid(repository);
928
+ return graphqlPage(runGh, { query: MERGED_PRS_GRAPHQL, vars: { owner: parsed.owner, name: parsed.name, after } }, (data) => asRecord(data.repository)?.pullRequests, parseMergedPrNode);
929
+ },
930
+ fetchBodyRevisionsPage: (repository, prNumber, after) => {
931
+ const vars = prVars(repository, prNumber, after);
932
+ if (vars === null)
933
+ return invalid(repository);
934
+ return graphqlPage(runGh, { query: BODY_REVISIONS_GRAPHQL, vars }, (data) => pullRequestOf(data)?.userContentEdits, parseRevisionNode);
935
+ },
936
+ fetchConnectionEventsPage: (repository, prNumber, after) => {
937
+ const vars = prVars(repository, prNumber, after);
938
+ if (vars === null)
939
+ return invalid(repository);
940
+ return graphqlPage(runGh, { query: CONNECTION_EVENTS_GRAPHQL, vars }, (data) => pullRequestOf(data)?.timelineItems, parseConnectionEventNode);
941
+ },
942
+ fetchPrFilesPage: (repository, prNumber, after) => fetchPrFilesPageLive(runGh, repository, prNumber, after),
943
+ probeRateLimit: () => probeLiveRateLimit(runGh),
944
+ nowMs: () => Date.now(),
945
+ };
946
+ }
947
+ /**
948
+ * Canonical project repository (#5710 P1): OWNER/NAME of the `origin` remote.
949
+ * `--repo` is a matching assertion only and is never the source of it.
950
+ */
951
+ export function resolveCanonicalProjectRepository(projectRoot, runGit = defaultGitRunner) {
952
+ let result;
953
+ try {
954
+ result = runGit(projectRoot, ["remote", "get-url", "origin"]);
955
+ }
956
+ catch {
957
+ return null;
958
+ }
959
+ if (result.code !== 0)
960
+ return null;
961
+ return parseGitHubRepoSlug(result.stdout.trim());
962
+ }
963
+ /**
964
+ * PR-only delivery identity (#5710 P1): only when the plan has no GitHub issue
965
+ * reference (today's resolver). Identity is (canonical remote, stamped
966
+ * productPullRequest). `x-xbrief/github-pr` references are never identity.
967
+ */
968
+ export function resolvePrOnlyDeliveryIdentity(plan, resolveCanonicalRepository) {
969
+ const prNumber = productPullRequestFromPlan(plan);
414
970
  if (prNumber === null) {
415
971
  return {
416
972
  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).",
973
+ message: "Delivery identity join failed: plan has no x-xbrief/github-issue reference and no " +
974
+ "plan.metadata.productPullRequest stamp. Stamp productPullRequest (issue-less briefs only) " +
975
+ "or add an issue origin. An x-xbrief/github-pr reference is not a delivery identity (#5710).",
976
+ };
977
+ }
978
+ const repository = resolveCanonicalRepository();
979
+ if (repository === null) {
980
+ return {
981
+ ok: false,
982
+ message: `Delivery identity join failed: plan has productPullRequest=${prNumber} but the ` +
983
+ `canonical project remote (git remote get-url origin) did not resolve to OWNER/NAME (#5710).`,
419
984
  };
420
985
  }
421
- const payload = input.fetchPrPayload(prNumber, expected.repository);
986
+ return { ok: true, repository, prNumber };
987
+ }
988
+ function fetchMergedPayload(fetchPrPayload, prNumber, repository) {
989
+ const payload = fetchPrPayload(prNumber, repository);
422
990
  if (payload === null) {
423
991
  return {
424
992
  ok: false,
425
993
  message: `Delivery identity join failed: could not fetch PR #${prNumber} in ` +
426
- `${expected.repository} (lookup failure) (#3675).`,
994
+ `${repository} (lookup failure) (#3675).`,
427
995
  };
428
996
  }
429
997
  const mergedAt = typeof payload.merged_at === "string" && payload.merged_at.trim().length > 0
@@ -435,86 +1003,282 @@ export function verifyStoryPrMergeIdentity(input) {
435
1003
  if (mergedAt === null || mergeCommitSha === null) {
436
1004
  return {
437
1005
  ok: false,
438
- message: `Delivery identity join failed: PR #${prNumber} in ${expected.repository} ` +
1006
+ message: `Delivery identity join failed: PR #${prNumber} in ${repository} ` +
439
1007
  `is not merged (missing merged_at / merge_commit_sha) (#3675).`,
440
1008
  };
441
1009
  }
442
- const linked = input.fetchClosingIssueIds(prNumber, expected.repository);
443
- if (linked === null) {
1010
+ return { ok: true, payload, mergedAt, mergeCommitSha };
1011
+ }
1012
+ function payloadHeadSha(payload) {
1013
+ const head = asRecord(payload.head);
1014
+ if (typeof head?.sha === "string" && head.sha.trim().length > 0) {
1015
+ return head.sha.trim();
1016
+ }
1017
+ if (typeof payload.head_sha === "string" && payload.head_sha.trim().length > 0) {
1018
+ return payload.head_sha.trim();
1019
+ }
1020
+ return null;
1021
+ }
1022
+ function mergeCommitMismatch(mergeCommit, merged, prNumber) {
1023
+ if (shasEqual(mergeCommit, merged.mergeCommitSha)) {
1024
+ return null;
1025
+ }
1026
+ return (`Delivery identity join failed: supplied merge commit ${mergeCommit} does not ` +
1027
+ `equal PR #${prNumber} merge_commit_sha ${merged.mergeCommitSha} (identity, not reachability) (#3675).`);
1028
+ }
1029
+ function checkRecordedImplementation(evidence, payload, prNumber) {
1030
+ const recordedImpl = typeof evidence.implementationCommit === "string" &&
1031
+ evidence.implementationCommit.trim().length > 0
1032
+ ? evidence.implementationCommit.trim()
1033
+ : null;
1034
+ if (recordedImpl === null) {
1035
+ return null;
1036
+ }
1037
+ const headSha = payloadHeadSha(payload);
1038
+ if (headSha !== null && shasEqual(recordedImpl, headSha)) {
1039
+ return null;
1040
+ }
1041
+ return (`Delivery identity join failed: recorded implementationCommit ${recordedImpl} ` +
1042
+ `does not match PR #${prNumber} head at merge time` +
1043
+ `${headSha !== null ? ` (${headSha})` : ""} (#3675).`);
1044
+ }
1045
+ /** Shared tail: head check and the success result. */
1046
+ function finishIdentityJoin(input) {
1047
+ const { evidence, merged, prNumber } = input;
1048
+ const headCheck = checkRecordedImplementation(evidence, merged.payload, prNumber);
1049
+ if (headCheck !== null) {
1050
+ return { ok: false, message: headCheck };
1051
+ }
1052
+ const base = asRecord(merged.payload.base);
1053
+ return {
1054
+ ok: true,
1055
+ message: `story→PR→merge identity joined: ${input.subject} via PR #${prNumber} ` +
1056
+ `(${input.identityBasis})`,
1057
+ mergeCommitSha: merged.mergeCommitSha,
1058
+ mergedAt: merged.mergedAt,
1059
+ prBase: typeof base?.ref === "string" ? base.ref : null,
1060
+ headSha: payloadHeadSha(merged.payload),
1061
+ identityBasis: input.identityBasis,
1062
+ repository: input.repository,
1063
+ };
1064
+ }
1065
+ function evidencePrNumber(evidence) {
1066
+ return typeof evidence.prNumber === "number" &&
1067
+ Number.isInteger(evidence.prNumber) &&
1068
+ evidence.prNumber > 0
1069
+ ? evidence.prNumber
1070
+ : null;
1071
+ }
1072
+ function evidenceRepository(evidence) {
1073
+ return typeof evidence.repository === "string" && evidence.repository.trim().length > 0
1074
+ ? evidence.repository.trim()
1075
+ : null;
1076
+ }
1077
+ /** PR-only join (#5710 P1): stamp + canonical remote; no issue closer, no merge-time standard. */
1078
+ function verifyPrOnlyIdentity(input) {
1079
+ const resolved = resolvePrOnlyDeliveryIdentity(input.plan, input.resolveCanonicalRepository ?? (() => null));
1080
+ if (!resolved.ok) {
1081
+ return { ok: false, message: resolved.message };
1082
+ }
1083
+ const { repository, prNumber: stamped } = resolved;
1084
+ const assertedRepo = evidenceRepository(input.evidence);
1085
+ if (assertedRepo !== null && assertedRepo.toLowerCase() !== repository.toLowerCase()) {
444
1086
  return {
445
1087
  ok: false,
446
- message: `Delivery identity join failed: could not fetch closingIssuesReferences for ` +
447
- `PR #${prNumber} in ${expected.repository} (#3675).`,
1088
+ message: `Delivery identity join failed: --repo '${assertedRepo}' does not match the canonical ` +
1089
+ `project remote '${repository}' (matching assertion only) (#5710).`,
448
1090
  };
449
1091
  }
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";
1092
+ const prNumber = evidencePrNumber(input.evidence);
1093
+ if (prNumber === null) {
1094
+ return {
1095
+ ok: false,
1096
+ message: `Delivery identity join failed: PR-only brief needs --pr ${stamped} ` +
1097
+ `(plan.metadata.productPullRequest) (#5710).`,
1098
+ };
477
1099
  }
478
- if (!shasEqual(input.mergeCommit, mergeCommitSha)) {
1100
+ if (prNumber !== stamped) {
479
1101
  return {
480
1102
  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).`,
1103
+ message: `Delivery identity join failed: --pr ${prNumber} is not plan.metadata.productPullRequest ` +
1104
+ `${stamped}. On a PR-only brief the stamp is the identity; an x-xbrief/github-pr ` +
1105
+ `reference does not override it (#5710).`,
483
1106
  };
484
1107
  }
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
- };
1108
+ const merged = fetchMergedPayload(input.fetchPrPayload, prNumber, repository);
1109
+ if (!merged.ok) {
1110
+ return { ok: false, message: merged.message };
1111
+ }
1112
+ const mismatch = mergeCommitMismatch(input.mergeCommit, merged, prNumber);
1113
+ if (mismatch !== null) {
1114
+ return { ok: false, message: mismatch };
1115
+ }
1116
+ return finishIdentityJoin({
1117
+ evidence: input.evidence,
1118
+ merged,
1119
+ prNumber,
1120
+ repository,
1121
+ identityBasis: "pr-only",
1122
+ subject: `${repository} productPullRequest`,
1123
+ });
1124
+ }
1125
+ /** Brief-in-PR-diff basis (#3675, unchanged). Null = proven; "" = not attempted; else reason. */
1126
+ function proveBriefInPrDiff(input, repository, prNumber) {
1127
+ const briefBasename = input.briefBasename?.trim() ?? "";
1128
+ if (briefBasename.length === 0 || input.fetchPrChangedFiles === undefined) {
1129
+ return "";
1130
+ }
1131
+ const files = input.fetchPrChangedFiles(prNumber, repository);
1132
+ if (files === null) {
1133
+ return `brief-in-PR-diff: could not read PR #${prNumber} changed files (lookup failure)`;
1134
+ }
1135
+ if (!prDiffAddsBrief(files, briefBasename)) {
1136
+ return `brief-in-PR-diff: PR #${prNumber} did not add ${briefBasename} alongside code changes`;
1137
+ }
1138
+ return null;
1139
+ }
1140
+ /** Bases b and c from forge reads; returns the proven basis or appends reasons. */
1141
+ function proveKeywordOrConnected(forge, budget, target, reasons) {
1142
+ const { repository, issueNumber, prNumber, mergedAt } = target;
1143
+ let mergeTimeBody = null;
1144
+ const body = reconstructMergeTimeBody(forge, budget, { repository, prNumber }, target.currentBody, mergedAt);
1145
+ if (body.ok) {
1146
+ mergeTimeBody = body.body;
1147
+ if (bodyHasClosingKeywordFor(body.body, repository, issueNumber)) {
1148
+ return { basis: "merge-time-keyword", mergeTimeBody };
505
1149
  }
1150
+ reasons.push(`(b) PR #${prNumber} body as of merged_at has no closing keyword for #${issueNumber}`);
1151
+ }
1152
+ else {
1153
+ reasons.push(`(a)/(b) ${body.error}`);
1154
+ }
1155
+ const events = collectAllPages((after) => forge.fetchConnectionEventsPage(repository, prNumber, after), budget.beforeGraphql, `PR #${prNumber} connection events`);
1156
+ if (!events.ok) {
1157
+ reasons.push(`(c) ${events.error}`);
1158
+ return { basis: null, mergeTimeBody };
1159
+ }
1160
+ const state = connectedAtMergedAt(events.nodes, repository, issueNumber, mergedAt);
1161
+ if (state.ok && state.connected) {
1162
+ return { basis: "merge-time-connected", mergeTimeBody };
1163
+ }
1164
+ reasons.push(state.ok
1165
+ ? `(c) no Connected link to #${issueNumber} in effect at merged_at`
1166
+ : `(c) ${state.error}`);
1167
+ return { basis: null, mergeTimeBody };
1168
+ }
1169
+ /**
1170
+ * Issue-anchored association as of merged_at (#5710 P3). Cheap bases first
1171
+ * (b, c, brief-in-PR-diff), then the exhaustive sole-mark scan (a). A failed
1172
+ * lookup only removes a basis; success always needs a positive proof.
1173
+ */
1174
+ function proveIssueAnchoredAssociation(input, target) {
1175
+ const { repository, issueNumber, prNumber } = target;
1176
+ const reasons = [];
1177
+ const forge = input.mergeTimeForge;
1178
+ const budget = forge === undefined ? null : makeScanBudget(forge);
1179
+ let mergeTimeBody = null;
1180
+ if (forge !== undefined && budget !== null) {
1181
+ const cheap = proveKeywordOrConnected(forge, budget, target, reasons);
1182
+ if (cheap.basis !== null) {
1183
+ return { ok: true, basis: cheap.basis };
1184
+ }
1185
+ mergeTimeBody = cheap.mergeTimeBody;
1186
+ }
1187
+ else {
1188
+ reasons.push("merge-time association lookups are unavailable");
1189
+ }
1190
+ const briefDiff = proveBriefInPrDiff(input, repository, prNumber);
1191
+ if (briefDiff === null) {
1192
+ return { ok: true, basis: "brief-in-pr-diff" };
1193
+ }
1194
+ if (briefDiff.length > 0) {
1195
+ reasons.push(briefDiff);
1196
+ }
1197
+ if (forge !== undefined && budget !== null && mergeTimeBody !== null) {
1198
+ const mark = proveSoleMarkBasis({
1199
+ forge,
1200
+ budget,
1201
+ repository,
1202
+ issueNumber,
1203
+ prNumber,
1204
+ mergeTimeBody,
1205
+ });
1206
+ if (mark.ok) {
1207
+ return { ok: true, basis: "merge-time-mark" };
1208
+ }
1209
+ reasons.unshift(mark.reason);
506
1210
  }
507
1211
  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,
1212
+ ok: false,
1213
+ message: `Delivery identity join failed: PR #${prNumber} has no merge-time association with ` +
1214
+ `${repository}#${issueNumber}. Accepted bases: ${MERGE_TIME_ASSOCIATION_BASES}. ` +
1215
+ `A mark, keyword or link added after merged_at does not count, and current ` +
1216
+ `closingIssuesReferences alone is insufficient. Findings: ${reasons.join("; ")}. ` +
1217
+ `Already-merged work with no merge-time basis stays refused until the parked ` +
1218
+ `identity-migration ships (#5710).`,
516
1219
  };
517
1220
  }
1221
+ /**
1222
+ * Prefer-A story→PR→merge identity join before delivery liveness (#3675 / #5710).
1223
+ *
1224
+ * Issue-anchored briefs (today's `resolvePlanGithubIssueRef`): the association
1225
+ * must hold as of merged_at by (a) a unique sole `deft-story` mark, (b) a
1226
+ * closing keyword in the merge-time body, or (c) a Connected link at merged_at,
1227
+ * or by the unchanged brief-in-PR-diff basis. Current-state
1228
+ * closingIssuesReferences alone is insufficient, and `productPullRequest === --pr`
1229
+ * is not a proof here. A failed issue-anchored check never falls back to the stamp.
1230
+ *
1231
+ * Issue-less briefs use the PR-only identity (#5710 P1). merge_commit_sha must
1232
+ * equal the supplied merge commit on both paths (squash-safe).
1233
+ */
1234
+ export function verifyStoryPrMergeIdentity(input) {
1235
+ const expected = resolvePlanGithubIssueRef(input.plan);
1236
+ if (expected === null) {
1237
+ return verifyPrOnlyIdentity(input);
1238
+ }
1239
+ const evidenceRepo = evidenceRepository(input.evidence);
1240
+ if (evidenceRepo !== null && evidenceRepo !== expected.repository) {
1241
+ return {
1242
+ ok: false,
1243
+ message: `Delivery identity join failed: evidence repository '${evidenceRepo}' does not match ` +
1244
+ `plan-derived expected repository '${expected.repository}' (#3675).`,
1245
+ };
1246
+ }
1247
+ const prNumber = evidencePrNumber(input.evidence);
1248
+ if (prNumber === null) {
1249
+ return {
1250
+ ok: false,
1251
+ message: "Delivery identity join failed: missing story-associated PR number. " +
1252
+ "Pass --pr <n> for the PR that delivered this story; --pr alone is not identity (#3675).",
1253
+ };
1254
+ }
1255
+ const merged = fetchMergedPayload(input.fetchPrPayload, prNumber, expected.repository);
1256
+ if (!merged.ok) {
1257
+ return { ok: false, message: merged.message };
1258
+ }
1259
+ const mismatch = mergeCommitMismatch(input.mergeCommit, merged, prNumber);
1260
+ if (mismatch !== null) {
1261
+ return { ok: false, message: mismatch };
1262
+ }
1263
+ const association = proveIssueAnchoredAssociation(input, {
1264
+ repository: expected.repository,
1265
+ issueNumber: expected.issueNumber,
1266
+ prNumber,
1267
+ mergedAt: merged.mergedAt,
1268
+ currentBody: typeof merged.payload.body === "string" ? merged.payload.body : null,
1269
+ });
1270
+ if (!association.ok) {
1271
+ return { ok: false, message: association.message };
1272
+ }
1273
+ return finishIdentityJoin({
1274
+ evidence: input.evidence,
1275
+ merged,
1276
+ prNumber,
1277
+ repository: expected.repository,
1278
+ identityBasis: association.basis,
1279
+ subject: `${expected.repository}#${expected.issueNumber}`,
1280
+ });
1281
+ }
518
1282
  /**
519
1283
  * Classify stored completion provenance for read paths.
520
1284
  * Legacy completed records without provenance → unknown/unverified (not delivered).
@@ -777,6 +1541,60 @@ function deliveryEvidenceRemediation(deliveryBranch) {
777
1541
  `When ${deliveryBranch} is delivery, wait until the merge commit is an ancestor of ` +
778
1542
  `origin/${deliveryBranch}, then complete. Do not use --non-delivery for work that shipped.`);
779
1543
  }
1544
+ /**
1545
+ * #5710 P4: refuse a non-delivery disposition when the stamped productPullRequest
1546
+ * is merged in the authoritative repository (the issue repository, else the
1547
+ * canonical remote). Merged to an integration branch counts as merged in v1.
1548
+ * Returns null when no stamp exists or the stamped PR is confirmed unmerged.
1549
+ * Any failure to establish merge state fails closed with a retry diagnostic.
1550
+ *
1551
+ * Policy (Recut-13 lean, #5710 comment 6817282070):
1552
+ * - Guarantee: a code-bearing brief (`isCodeBearingScope`) cannot take a
1553
+ * non-delivery disposition while its stamped PR is merged or unreadable.
1554
+ * - Non-goal in v1: briefs that are not code-bearing (`docs-only` /
1555
+ * `process-only` tags, `metadata.delivery.required: false`, docs/process/
1556
+ * research kinds). They return before this check. The lean parks "Moving the
1557
+ * non-delivery refuse ahead of the !codeBearing early return"; widening it
1558
+ * needs a new contract, not a review fix.
1559
+ */
1560
+ function refuseNonDeliveryForMergedStamp(input) {
1561
+ const productPr = productPullRequestFromPlan(input.plan);
1562
+ if (productPr === null) {
1563
+ return null;
1564
+ }
1565
+ const head = `Refusing scope:complete --non-delivery ${input.disposition}:`;
1566
+ const noWrite = "No completion or provenance was recorded (#5710).";
1567
+ const repository = resolvePlanGithubIssueRef(input.plan)?.repository ?? input.resolveCanonicalRepository();
1568
+ if (repository === null) {
1569
+ return (`${head} cannot resolve the repository of stamped productPullRequest #${productPr} ` +
1570
+ `(no issue origin and no canonical origin remote). Repair the remote or the stamp, ` +
1571
+ `then retry. ${noWrite}`);
1572
+ }
1573
+ let payload;
1574
+ try {
1575
+ payload = input.fetchPrPayload(productPr, repository);
1576
+ }
1577
+ catch {
1578
+ payload = null;
1579
+ }
1580
+ const mergedAt = payload?.merged_at;
1581
+ const unknown = payload === null ||
1582
+ payload === undefined ||
1583
+ !("merged_at" in payload) ||
1584
+ (mergedAt !== null && (typeof mergedAt !== "string" || mergedAt.trim().length === 0));
1585
+ if (unknown) {
1586
+ return (`${head} could not establish whether stamped productPullRequest #${productPr} in ` +
1587
+ `${repository} is merged (lookup failed or malformed response). Retry after fixing gh ` +
1588
+ `auth, rate limit or network, or repair plan.metadata.productPullRequest. ${noWrite}`);
1589
+ }
1590
+ if (mergedAt === null) {
1591
+ return null;
1592
+ }
1593
+ return (`${head} stamped productPullRequest #${productPr} in ${repository} is merged ` +
1594
+ `(merged_at ${String(mergedAt)}). Shipped work completes as delivered: ` +
1595
+ `scope:complete -- --pr ${productPr} --merge-commit <sha>. A merge to an integration ` +
1596
+ `branch also counts as merged here. ${noWrite}`);
1597
+ }
780
1598
  /**
781
1599
  * Gate delivered completion for a code-bearing scope (#3041 / #3380 / #3675).
782
1600
  *
@@ -793,14 +1611,19 @@ export function evaluateDeliveryGate(options) {
793
1611
  const runGh = options.runGh ?? defaultRunGh;
794
1612
  const fetchPrPayload = options.fetchPrPayload ??
795
1613
  ((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.
1614
+ // Live defaults only when the PR payload is not injected, so injected test
1615
+ // seams never fall through to a live gh call.
1616
+ const liveForge = options.fetchPrPayload === undefined;
800
1617
  const fetchPrChangedFiles = options.fetchPrChangedFiles ??
801
- (options.fetchClosingIssueIds === undefined
1618
+ (liveForge
802
1619
  ? (prNumber, repository) => defaultFetchPrChangedFiles(prNumber, repository, runGh)
803
1620
  : undefined);
1621
+ const mergeTimeForge = options.mergeTimeForge ?? (liveForge ? defaultMergeTimeForge(runGh) : undefined);
1622
+ const resolveCanonicalRepository = () => resolveCanonicalProjectRepository(options.projectRoot, runGit);
1623
+ // #5710 P4: the merged-stamp check needs an uncached read; a stale
1624
+ // "unmerged" would let shipped work record as non-delivery.
1625
+ const fetchStampedPrLive = options.fetchPrPayload ??
1626
+ ((prNumber, repository) => defaultFetchPrPayload(prNumber, repository, liveRunGh(runGh)));
804
1627
  const verifier = options.verifier ?? "scope:complete";
805
1628
  const codeBearing = isCodeBearingScope(options.plan);
806
1629
  // #5693: experiment_archived Findings check runs before the non-code early
@@ -835,6 +1658,16 @@ export function evaluateDeliveryGate(options) {
835
1658
  codeBearing: true,
836
1659
  };
837
1660
  }
1661
+ // #5710 P4: resolve a stamped productPullRequest before any non-delivery success.
1662
+ const shipped = refuseNonDeliveryForMergedStamp({
1663
+ plan: options.plan,
1664
+ disposition: options.nonDeliveryDisposition,
1665
+ fetchPrPayload: fetchStampedPrLive,
1666
+ resolveCanonicalRepository,
1667
+ });
1668
+ if (shipped !== null) {
1669
+ return { ok: false, message: shipped, provenance: null, codeBearing: true };
1670
+ }
838
1671
  const branchResult = resolveDeliveryBranch(options.projectRoot, runGit);
839
1672
  const refused = refuseUnsafeResolvedDelivery(branchResult);
840
1673
  if (refused !== null) {
@@ -931,9 +1764,10 @@ export function evaluateDeliveryGate(options) {
931
1764
  evidence,
932
1765
  mergeCommit,
933
1766
  fetchPrPayload,
934
- fetchClosingIssueIds,
1767
+ mergeTimeForge,
935
1768
  briefBasename: options.briefBasename,
936
1769
  fetchPrChangedFiles,
1770
+ resolveCanonicalRepository,
937
1771
  });
938
1772
  if (!identity.ok) {
939
1773
  return {
@@ -945,7 +1779,7 @@ export function evaluateDeliveryGate(options) {
945
1779
  }
946
1780
  const boundEvidence = {
947
1781
  ...evidence,
948
- repository: resolvePlanGithubIssueRef(options.plan)?.repository ?? evidence.repository ?? null,
1782
+ repository: identity.repository ?? evidence.repository ?? null,
949
1783
  prNumber: evidence.prNumber,
950
1784
  prBase: identity.prBase ?? prBase,
951
1785
  mergeCommit: identity.mergeCommitSha ?? mergeCommit,
@@ -1372,20 +2206,27 @@ export function discoverDeliveryEvidenceForComplete(options) {
1372
2206
  const expected = resolvePlanGithubIssueRef(options.plan);
1373
2207
  const productPr = productPullRequestFromPlan(options.plan);
1374
2208
  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
- };
2209
+ // #5710 P1: an issue-less brief uses the same PR-only resolver as the identity join.
2210
+ let repository;
2211
+ if (expected !== null) {
2212
+ repository = expected.repository;
2213
+ }
2214
+ else {
2215
+ const prOnly = resolvePrOnlyDeliveryIdentity(options.plan, () => resolveCanonicalProjectRepository(options.projectRoot, runGit));
2216
+ if (!prOnly.ok) {
2217
+ return {
2218
+ ok: false,
2219
+ message: `Delivery auto-discovery failed: ${prOnly.message} ${deliveryEvidenceRemediation(deliveryBranch)}`,
2220
+ };
2221
+ }
2222
+ repository = prOnly.repository;
1382
2223
  }
1383
- const payload = fetchPrPayload(productPr, expected.repository);
2224
+ const payload = fetchPrPayload(productPr, repository);
1384
2225
  if (payload === null) {
1385
2226
  return {
1386
2227
  ok: false,
1387
2228
  message: `Delivery auto-discovery failed: could not fetch productPullRequest #${productPr} ` +
1388
- `in ${expected.repository} (#3583). ${deliveryEvidenceRemediation(deliveryBranch)}`,
2229
+ `in ${repository} (#3583). ${deliveryEvidenceRemediation(deliveryBranch)}`,
1389
2230
  };
1390
2231
  }
1391
2232
  if (!candidatePassesDeliveryBaseFilter(payload, deliveryBranch)) {
@@ -1396,7 +2237,7 @@ export function discoverDeliveryEvidenceForComplete(options) {
1396
2237
  `territory in v1) (#3583). ${deliveryEvidenceRemediation(deliveryBranch)}`,
1397
2238
  };
1398
2239
  }
1399
- const discovered = evidenceFromPrPayload(payload, productPr, expected.repository, deliveryBranch, verifier);
2240
+ const discovered = evidenceFromPrPayload(payload, productPr, repository, deliveryBranch, verifier);
1400
2241
  return {
1401
2242
  ok: true,
1402
2243
  evidence: mergePreservingExplicitFacts(existing, discovered),