pm-github 2026.10.24 → 2026.10.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/closeout.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { ownValue } from "./own-record.js";
1
2
  /** Close delivered tracker items using merged pull requests and head-check evidence. */
2
- import { close, listAllComplete, certifyCompleteListResult, PmCliError } from "@unbrained/pm-cli/sdk";
3
+ import { close, hashDocument, locateItem, readLocatedItem, readSettings, resolveItemTypeRegistry, runWithActiveExtensions, setActiveExtensionHooks, listAllComplete, certifyCompleteListResult, getHistoryPath, readHistoryEntries, verifyHistoryEntries, PmCliError, EXIT_CODE } from "@unbrained/pm-cli/sdk";
3
4
  /** Reject non-object API payloads before reading safety-critical fields. */
4
5
  function record(value) {
5
6
  if (value === null || typeof value !== "object" || Array.isArray(value))
@@ -23,9 +24,10 @@ function isoDate(value) {
23
24
  }
24
25
  /** Normalize repeatable flags without silently dropping malformed members. */
25
26
  function repeated(options, key) {
26
- if (options[key] === undefined)
27
+ const value = ownValue(options, key);
28
+ if (value === undefined)
27
29
  return [];
28
- const values = Array.isArray(options[key]) ? options[key] : [options[key]];
30
+ const values = Array.isArray(value) ? value : [value];
29
31
  if (values.length === 0 || values.some(value => typeof value !== "string" || value.trim() === ""))
30
32
  throw new Error(`--${key} requires a value.`);
31
33
  return [...new Set(values.map(value => value.trim()))];
@@ -82,23 +84,40 @@ function pullRequest(value, repo, number) {
82
84
  const raw = record(value);
83
85
  const base = record(raw.base);
84
86
  const baseRepo = record(base.repo);
85
- const head = record(raw.head);
87
+ const head = raw.head === null || raw.head === undefined ? {} : record(raw.head);
86
88
  if (raw.number !== number || text(baseRepo.full_name).toLowerCase() !== repo.toLowerCase()
87
89
  || text(raw.html_url).toLowerCase() !== `https://github.com/${repo}/pull/${number}`.toLowerCase()
88
90
  || (raw.body !== null && typeof raw.body !== "string"))
89
91
  throw new Error("Pull-request identity or body is unverifiable.");
90
92
  return {
91
- number, body: raw.body, merged: raw.merged === true,
93
+ number, body: raw.body, merged: typeof raw.merged === "boolean" ? raw.merged : null,
94
+ commits: Number.isSafeInteger(raw.commits) && Number(raw.commits) >= 0 ? Number(raw.commits) : null,
92
95
  merged_at: raw.merged_at, merge_commit_sha: raw.merge_commit_sha,
93
- html_url: raw.html_url, head: { sha: text(head.sha) },
96
+ html_url: raw.html_url, head: { sha: typeof head.sha === "string" ? head.sha : "" },
94
97
  base: { ref: text(base.ref), repo: { full_name: baseRepo.full_name } },
95
98
  };
96
99
  }
100
+ /** Read a validated merge time; missing or malformed chronology remains unknown. */
101
+ function mergeTime(value) {
102
+ try {
103
+ return Date.parse(isoDate(text(value)));
104
+ }
105
+ catch {
106
+ return Number.NaN;
107
+ }
108
+ }
109
+ /** Name each missing merge fact without copying untrusted API values. */
110
+ function mergeEvidenceGaps(pr) {
111
+ return [
112
+ ...(pr.merged === true ? [] : ["merged"]),
113
+ ...(Number.isFinite(mergeTime(pr.merged_at)) ? [] : ["merged_at"]),
114
+ ...(typeof pr.merge_commit_sha === "string" && /^[a-f0-9]{40}$/.test(pr.merge_commit_sha) ? [] : ["merge_commit_sha"]),
115
+ ...(/^[a-f0-9]{40}$/.test(pr.head.sha) ? [] : ["head.sha"]),
116
+ ];
117
+ }
97
118
  /** Require complete, well-formed merge facts before any check queries or writes. */
98
119
  function mergeEvidence(pr) {
99
- return pr.merged && typeof pr.merged_at === "string" && isoDate(pr.merged_at).length > 0
100
- && typeof pr.merge_commit_sha === "string" && /^[a-f0-9]{40}$/.test(pr.merge_commit_sha)
101
- && /^[a-f0-9]{40}$/.test(pr.head.sha);
120
+ return mergeEvidenceGaps(pr).length === 0;
102
121
  }
103
122
  /** Treat only permission and not-found responses as protection metadata gaps. */
104
123
  async function readProtectionSource(read) {
@@ -313,6 +332,54 @@ export function extractCloseoutItems(body) {
313
332
  return [entry];
314
333
  }).map(({ repo, item, declared }) => ({ repo, item, declared }));
315
334
  }
335
+ /** Read the last criteria edit from immutable SDK history; unavailable chronology cannot select delivery. */
336
+ async function acceptanceChangedAt(pmRoot, id, document) {
337
+ try {
338
+ const history = await readHistoryEntries(getHistoryPath(pmRoot, id), id);
339
+ if (history.length === 0 || !verifyHistoryEntries(history, document).ok)
340
+ return Number.NaN;
341
+ let changedAt = Date.parse(document.metadata.created_at);
342
+ for (const event of history) {
343
+ if (event.patch.some(patch => ["", "/metadata", "/metadata/acceptance_criteria"].includes(patch.path))) {
344
+ changedAt = Math.max(changedAt, Date.parse(event.ts));
345
+ }
346
+ }
347
+ return changedAt;
348
+ }
349
+ catch {
350
+ return Number.NaN;
351
+ }
352
+ }
353
+ /** Extract exact item declarations from descriptions or commit messages using the same grammar. */
354
+ function deliveryReferences(body, repo, ids) {
355
+ const refs = extractCloseoutItems(body);
356
+ const declarations = new Set();
357
+ for (const match of body.matchAll(/^(?:[-*][ \t]+)?([a-z]+|pm-github:|PM item:)[ \t]+(\S+)[ \t]*$/gim)) {
358
+ const keyword = match[1].toLowerCase();
359
+ if (!CLOSING_KEYWORDS.has(keyword) && keyword !== "pm-github:" && keyword !== "pm item:")
360
+ continue;
361
+ const id = declarationKey(match[2]);
362
+ declarations.add(id);
363
+ if (/[.!]$/.test(id))
364
+ declarations.add(id.slice(0, -1));
365
+ }
366
+ for (const id of ids) {
367
+ if (declarations.has(declarationKey(id))) {
368
+ const ref = refs.find(ref => ref.item === id && ref.repo.toLowerCase() === repo.toLowerCase());
369
+ if (ref)
370
+ ref.declared = true;
371
+ else
372
+ refs.push({ item: id, repo, declared: true });
373
+ }
374
+ }
375
+ return refs;
376
+ }
377
+ /** Classify delivery once: only claims are candidates; unreadable claims remain unresolved. */
378
+ function deliveryRole(declared, tagged, commitsReadable) {
379
+ if (declared || tagged)
380
+ return "candidate";
381
+ return commitsReadable ? "context" : "unresolved";
382
+ }
316
383
  /** Render only the allowed GitHub facts; never include body, title or comment text. */
317
384
  function closeReason(pr, passed) {
318
385
  return JSON.stringify({ pr: pr.number, url: pr.html_url, merge_commit_sha: pr.merge_commit_sha,
@@ -357,8 +424,29 @@ export function checkEvidence(gate) {
357
424
  const successes = gate.passed.length > 0 ? `successful checks: ${gate.passed.join(", ")}` : "no check reported success";
358
425
  return `no readable required checks, so every completed head check run had to succeed, be neutral or be skipped; ${successes}`;
359
426
  }
360
- /** Plan all selected PRs before closing eligible items through the real SDK. */
361
- export async function runCloseout(pmRoot, repo, options, api) {
427
+ /** Project one item decision onto all its candidates, including incomplete competitors. */
428
+ function reportItemDecision(rows, decision) {
429
+ const candidates = rows.map(row => row.pr).sort((a, b) => a - b);
430
+ for (const row of rows) {
431
+ if (decision.kind === "chosen") {
432
+ if (row === decision.row)
433
+ continue;
434
+ Object.assign(row, { action: "skip", code: "duplicate_selection",
435
+ reason: "Another merged PR uniquely supplies the delivery evidence for this item." });
436
+ }
437
+ else {
438
+ Object.assign(row, { action: "needs_review", code: "needs_review", reason: decision.reason, candidates });
439
+ if (decision.missing.length > 0)
440
+ row.missing_evidence = decision.missing;
441
+ }
442
+ }
443
+ }
444
+ /** Plan selected PRs and apply only decisions whose exact item snapshot still matches. */
445
+ export function runCloseout(pmRoot, repo, options, api, observer) {
446
+ return closeoutAttempt(pmRoot, repo, options, api, true, observer);
447
+ }
448
+ /** Re-select a conflicted item once; each attempt installs a lock-scoped SDK precondition. */
449
+ async function closeoutAttempt(pmRoot, repo, options, api, retry, observer, onlyItem) {
362
450
  if (!repo || !/^(?!\.{1,2}\/)[A-Za-z0-9_.-]+\/(?!\.{1,2}$)[A-Za-z0-9_.-]+$/.test(repo))
363
451
  throw new CloseoutUsageError("closeout requires --repo owner/repo or PM_GITHUB_REPO.");
364
452
  const numbers = usage(() => repeated(options, "pr")).map(value => {
@@ -369,9 +457,9 @@ export async function runCloseout(pmRoot, repo, options, api) {
369
457
  const since = options.since === undefined ? undefined : usage(() => isoDate(text(options.since)));
370
458
  if ((numbers.length === 0) === (since === undefined))
371
459
  throw new CloseoutUsageError("Select either repeatable --pr <n> or --since <ISO date>.");
372
- const declared = usage(() => requiredContexts(options["required-checks"] ?? options.requiredChecks));
460
+ const declared = usage(() => requiredContexts(ownValue(options, "required-checks") ?? options.requiredChecks));
373
461
  const skip = new Set(usage(() => repeated(options, "skip")));
374
- const apply = options.apply === true && options["dry-run"] !== true && options.dryRun !== true;
462
+ const apply = options.apply === true && ownValue(options, "dry-run") !== true && options.dryRun !== true;
375
463
  if (since !== undefined) {
376
464
  // Rows arrive newest-updated first and a merge never postdates its PR's update, so a row updated
377
465
  // before the cutoff proves no later row can have merged at or after it.
@@ -379,7 +467,8 @@ export async function runCloseout(pmRoot, repo, options, api) {
379
467
  const older = (row) => typeof row === "object" && row !== null && Date.parse(String(row.updated_at)) < cutoff;
380
468
  for (const value of await pages(api, `/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`, false, older)) {
381
469
  const pr = record(value);
382
- if (pr.merged_at !== null && Date.parse(isoDate(text(pr.merged_at))) >= Date.parse(since)) {
470
+ // A missing or malformed merge time is uncertainty, never proof of an unmerged PR.
471
+ if (!Number.isFinite(mergeTime(pr.merged_at)) || mergeTime(pr.merged_at) >= cutoff) {
383
472
  if (!Number.isSafeInteger(pr.number) || Number(pr.number) < 1)
384
473
  throw new Error("Invalid selected pull-request number.");
385
474
  numbers.push(Number(pr.number));
@@ -387,10 +476,26 @@ export async function runCloseout(pmRoot, repo, options, api) {
387
476
  }
388
477
  }
389
478
  const corpus = certifyCompleteListResult(await listAllComplete({}, { pmRoot, noExtensions: true }));
479
+ await observer?.("listed");
480
+ const settings = await readSettings(pmRoot);
481
+ const types = resolveItemTypeRegistry(settings).type_to_folder;
482
+ const snapshots = new Map();
483
+ const documents = new Map();
390
484
  const items = new Map(corpus.items.map(item => [item.id, item]));
485
+ // Selection and its guard must use the same authoritative document, including collections and body.
486
+ for (const id of items.keys()) {
487
+ const located = await locateItem(pmRoot, id, settings.id_prefix, settings.item_format, types);
488
+ if (!located)
489
+ throw new Error("Closeout item snapshot unavailable.");
490
+ const { document } = await readLocatedItem(located, { schema: settings.schema });
491
+ snapshots.set(id, hashDocument(document));
492
+ documents.set(id, document);
493
+ items.set(id, document.metadata);
494
+ }
495
+ await observer?.("snapshot");
391
496
  // Containers close when their children are done, never because a pull request mentions them.
392
497
  const activeChildren = new Map();
393
- for (const item of corpus.items) {
498
+ for (const item of items.values()) {
394
499
  const parent = item.parent;
395
500
  if (typeof parent === "string" && !TERMINAL_STATUSES.includes(item.status))
396
501
  activeChildren.set(parent, (activeChildren.get(parent) ?? 0) + 1);
@@ -398,35 +503,51 @@ export async function runCloseout(pmRoot, repo, options, api) {
398
503
  const plan = [];
399
504
  const checks = [];
400
505
  const closures = [];
506
+ const candidates = new Map();
507
+ const unreadable = [];
401
508
  for (const number of [...new Set(numbers)].sort((a, b) => a - b)) {
402
- const pr = pullRequest(await payload(api, `/repos/${repo}/pulls/${number}`), repo, number);
403
- let gate = { pr: number, code: "incomplete_merge", rule: "unavailable", ok: false, passed: [], reason: "Pull request is not merged or merge evidence is incomplete." };
509
+ let evidence;
510
+ try {
511
+ evidence = await payload(api, `/repos/${repo}/pulls/${number}`);
512
+ }
513
+ catch {
514
+ unreadable.push(number);
515
+ continue;
516
+ }
517
+ const pr = pullRequest(evidence, repo, number);
518
+ let gate = { pr: number, code: "incomplete_merge", rule: "unavailable", ok: false, passed: [], reason: `Missing or invalid merge evidence: ${mergeEvidenceGaps(pr).join(", ")}.` };
404
519
  if (mergeEvidence(pr))
405
520
  gate = await checkCloseoutHead(api, repo, pr, declared);
406
521
  checks.push(gate);
407
- const refs = extractCloseoutItems(pr.body ?? "");
408
- // Scan declarations once per PR, retaining case-insensitive IDs and optional punctuation.
409
- const declarations = new Set();
410
- for (const match of (pr.body ?? "").matchAll(/^(?:[-*][ \t]+)?([a-z]+|pm-github:|PM item:)[ \t]+(\S+)[ \t]*$/gim)) {
411
- const keyword = match[1].toLowerCase();
412
- if (!CLOSING_KEYWORDS.has(keyword) && keyword !== "pm-github:" && keyword !== "pm item:")
413
- continue;
414
- const id = declarationKey(match[2]);
415
- declarations.add(id);
416
- if (/[.!]$/.test(id))
417
- declarations.add(id.slice(0, -1));
522
+ const refs = deliveryReferences(pr.body ?? "", repo, items.keys());
523
+ // Commit messages can independently claim delivery. Missing commit evidence never upgrades a mention.
524
+ let messages = [];
525
+ let commitsReadable = false;
526
+ try {
527
+ if (pr.merged !== false) {
528
+ const commits = await pages(api, `/repos/${repo}/pulls/${number}/commits?per_page=100`);
529
+ if (pr.commits === null || commits.length !== pr.commits)
530
+ throw new Error("Incomplete commit evidence.");
531
+ messages = commits.map(value => text(record(record(value).commit).message));
532
+ commitsReadable = true;
533
+ }
534
+ }
535
+ catch { /* Retain description references; unresolved commit evidence is held during attribution. */ }
536
+ for (const ref of deliveryReferences(messages.join("\n"), repo, items.keys())) {
537
+ const existing = refs.find(entry => entry.item === ref.item && entry.repo.toLowerCase() === ref.repo.toLowerCase());
538
+ if (existing)
539
+ existing.declared ||= ref.declared;
540
+ else
541
+ refs.push(ref);
418
542
  }
543
+ // An unreadable commit set cannot prove that an item has no commit-only delivery link.
419
544
  for (const item of items.values()) {
420
- // Delivery must be declared in this PR body, never inferred from tracker metadata.
421
- if (declarations.has(declarationKey(item.id))) {
422
- const ref = refs.find(ref => ref.item === item.id && ref.repo.toLowerCase() === repo.toLowerCase());
423
- if (ref)
424
- ref.declared = true;
425
- else
426
- refs.push({ item: item.id, repo, declared: true });
545
+ if ((item.tags.includes(`delivery-pr:${pr.html_url}`) || (!commitsReadable && pr.merged !== false)) && !refs.some(ref => ref.item === item.id && ref.repo.toLowerCase() === repo.toLowerCase())) {
546
+ refs.push({ item: item.id, repo, declared: false });
427
547
  }
428
548
  }
429
549
  for (const ref of refs) {
550
+ const role = deliveryRole(ref.declared, items.get(ref.item)?.tags.includes(`delivery-pr:${pr.html_url}`) === true, commitsReadable);
430
551
  let reason;
431
552
  let code;
432
553
  if (ref.repo.toLowerCase() !== repo.toLowerCase()) {
@@ -445,9 +566,9 @@ export async function runCloseout(pmRoot, repo, options, api) {
445
566
  code = "already_terminal";
446
567
  reason = "Item is already closed or canceled.";
447
568
  }
448
- else if (!gate.ok) {
449
- code = gate.code;
450
- reason = gate.reason;
569
+ else if (pr.merged === false) {
570
+ code = "not_merged";
571
+ reason = "Pull request is confirmed unmerged and cannot deliver this item.";
451
572
  }
452
573
  else if (items.get(ref.item).type === "Epic") {
453
574
  code = "epic";
@@ -457,9 +578,17 @@ export async function runCloseout(pmRoot, repo, options, api) {
457
578
  code = "active_children";
458
579
  reason = `Item has ${activeChildren.get(ref.item)} non-terminal child item(s).`;
459
580
  }
460
- else if (!ref.declared) {
581
+ else if (role === "context") {
461
582
  code = "not_delivery";
462
- reason = "Mentioned, not delivered. A mention is not delivery. Declare Closes, Fixes, Resolves or Delivers, or use a structured PM item: or pm-github: declaration.";
583
+ reason = "Mentioned, not delivered. A mention is not delivery. This fully inspected PR has no delivery claim and is context only.";
584
+ }
585
+ else if (!gate.ok) {
586
+ code = gate.code;
587
+ reason = gate.reason;
588
+ }
589
+ else if (role === "unresolved") {
590
+ code = "head_evidence_unavailable";
591
+ reason = "Commit delivery evidence unavailable or incomplete.";
463
592
  }
464
593
  else {
465
594
  reason = gate.reason;
@@ -472,60 +601,157 @@ export async function runCloseout(pmRoot, repo, options, api) {
472
601
  actualResult: `Merged at ${pr.merged_at}; ${checkEvidence(gate)}`,
473
602
  });
474
603
  plan.push(row);
604
+ const group = candidates.get(ref.item) ?? [];
605
+ group.push({ row, pr, declared: ref.declared, commitsReadable });
606
+ candidates.set(ref.item, group);
475
607
  continue;
476
608
  }
477
- plan.push({ item: ref.item, pr: number, action: "skip", code, reason });
609
+ const row = { item: ref.item, pr: number, action: "skip", code, reason };
610
+ plan.push(row);
611
+ if (role !== "context" && pr.merged !== false && items.has(ref.item) && ref.repo.toLowerCase() === repo.toLowerCase()
612
+ && !skip.has(ref.item) && !TERMINAL_STATUSES.includes(items.get(ref.item).status)
613
+ && items.get(ref.item).type !== "Epic" && !activeChildren.has(ref.item)) {
614
+ const group = candidates.get(ref.item) ?? [];
615
+ group.push({ row, pr, declared: ref.declared, commitsReadable });
616
+ candidates.set(ref.item, group);
617
+ }
478
618
  }
479
619
  }
480
- // Choose one complete, passing delivery before any SDK write. Merge chronology, not PR number,
481
- // determines the winner; an equal timestamp uses the higher PR number for stable selection.
482
- const deliveries = new Map();
483
- for (const entry of closures) {
484
- const previous = deliveries.get(entry.row.item);
485
- if (!previous || Date.parse(entry.at) >= Date.parse(previous.at)) {
486
- deliveries.set(entry.row.item, entry);
620
+ // A failed PR read can hide linkage and a competing claim. Retain its number for
621
+ // every active eligible item; without the PR evidence no item can be proved unrelated.
622
+ for (const number of unreadable) {
623
+ const url = `https://github.com/${repo}/pull/${number}`;
624
+ for (const item of items.values()) {
625
+ if (skip.has(item.id) || TERMINAL_STATUSES.includes(item.status) || item.type === "Epic" || activeChildren.has(item.id))
626
+ continue;
627
+ const row = { item: item.id, pr: number, action: "skip", code: "head_evidence_unavailable", reason: "Pull-request fetch failed; merge, linkage and delivery evidence unavailable." };
628
+ plan.push(row);
629
+ const group = candidates.get(item.id) ?? [];
630
+ group.push({ row, pr: { number, html_url: url, body: null, merged: true, merged_at: null, merge_commit_sha: null, commits: null,
631
+ head: { sha: "" }, base: { ref: "", repo: { full_name: repo } } }, declared: false, commitsReadable: false });
632
+ candidates.set(item.id, group);
487
633
  }
488
634
  }
489
- for (const entry of closures) {
490
- if (deliveries.get(entry.row.item) !== entry) {
491
- entry.row.action = "skip";
492
- entry.row.code = "duplicate_selection";
493
- entry.row.reason = "A later qualifying merged PR declares delivery for this item.";
635
+ // Resolve attribution before any SDK mutation; passing checks never break a delivery tie.
636
+ for (const [id, group] of candidates) {
637
+ const item = items.get(id);
638
+ const explicit = item.tags.filter(tag => tag.startsWith("delivery-pr:"));
639
+ const cutoff = await acceptanceChangedAt(pmRoot, id, documents.get(id));
640
+ await observer?.("history");
641
+ const plausible = group.filter(entry => !Number.isFinite(mergeTime(entry.pr.merged_at)) || mergeTime(entry.pr.merged_at) > cutoff);
642
+ const eligible = explicit.length > 0
643
+ ? group.filter(entry => explicit.length === 1 && explicit[0] === `delivery-pr:${entry.pr.html_url}`)
644
+ : plausible.filter(entry => entry.declared);
645
+ const uncertain = explicit.length === 1 && eligible.length === 1 ? eligible : plausible;
646
+ const missing = uncertain.flatMap(entry => {
647
+ const gaps = ["incomplete_merge", "head_evidence_unavailable", "required_checks_unverified", "head_checks_failed"].includes(entry.row.code)
648
+ ? [{ pr: entry.pr.number, code: entry.row.code, reason: entry.row.reason }] : [];
649
+ if (!entry.commitsReadable)
650
+ gaps.push({ pr: entry.pr.number, code: "head_evidence_unavailable", reason: "Commit delivery evidence unavailable or incomplete." });
651
+ return gaps;
652
+ });
653
+ // One item decision owns every candidate status. Evidence codes describe PR facts,
654
+ // but an unchosen PR cannot remain a fatal refusal after another PR wins.
655
+ const chosen = eligible.length === 1 && missing.length === 0 ? eligible[0].row : undefined;
656
+ if (chosen)
657
+ reportItemDecision(group.map(entry => entry.row), { kind: "chosen", row: chosen });
658
+ else if (group.length > 1 || explicit.length > 0 || missing.length > 0 || group[0].row.code !== "not_delivery") {
659
+ reportItemDecision(group.map(entry => entry.row), { kind: "blocking_hold",
660
+ reason: "Delivery attribution or chosen PR evidence is unresolved; set one delivery-pr:<canonical PR URL> tag or verify a unique delivery claim after the last acceptance-criteria change."
661
+ + missing.map(gap => ` PR #${gap.pr}: ${gap.reason}`).join(""), missing });
494
662
  }
495
663
  }
664
+ await observer?.("selection");
496
665
  let closed = 0;
497
666
  let failed = 0;
667
+ const handled = new Set();
668
+ const dependentConflicts = new Map();
669
+ /** Replace stale decisions with one fresh selection, retaining a reason when linkage disappeared. */
670
+ async function reselect(id, pr) {
671
+ handled.add(id);
672
+ const fresh = await closeoutAttempt(pmRoot, repo, options, api, false, observer, id);
673
+ checks.splice(0, checks.length, ...fresh.checks);
674
+ const replacement = fresh.plan;
675
+ if (!replacement.some(row => row.item === id))
676
+ replacement.push({ item: id, pr, action: "needs_review", code: "needs_review",
677
+ candidates: [pr], reason: "Item revision changed; fresh selection found no delivery evidence. Hold for review." });
678
+ const replaced = new Set(replacement.map(row => row.item));
679
+ const retained = plan.filter(row => !replaced.has(row.item));
680
+ plan.splice(0, plan.length, ...retained, ...replacement);
681
+ closed += fresh.closed;
682
+ failed += fresh.failed;
683
+ }
498
684
  if (apply) {
499
685
  for (const entry of closures) {
500
- if (entry.row.action !== "close")
686
+ if (entry.row.action !== "close" || (onlyItem !== undefined && entry.row.item !== onlyItem))
501
687
  continue;
502
688
  try {
503
- await close(entry.row.item, entry.reason, { author: "pm-github", completedAt: entry.at,
504
- resolution: entry.resolution, expectedResult: entry.expectedResult, actualResult: entry.actualResult,
505
- }, { pmRoot, noExtensions: true });
689
+ await observer?.("before_close");
690
+ // The pinned SDK has no public close(expectedRevision). Its transactional hook runs
691
+ // inside the same per-item lock as lifecycle validation, persistence and history.
692
+ await runWithActiveExtensions({ path: pmRoot, noExtensions: true }, async () => {
693
+ setActiveExtensionHooks({ beforeCommand: [], afterCommand: [], onRead: [], onWrite: [], onIndex: [],
694
+ beforeMutation: [{ layer: "project", name: "closeout-revision", run: async (context) => {
695
+ await observer?.("locked");
696
+ // SDK close may also auto-unblock dependents. Every resulting write needs its own snapshot.
697
+ const before = context.before;
698
+ const id = before.metadata.id;
699
+ if (hashDocument(before) !== snapshots.get(id)) {
700
+ if (id !== entry.row.item)
701
+ dependentConflicts.set(id, entry.row.pr);
702
+ throw new PmCliError("Closeout item revision changed.", 1, { code: "closeout_revision_changed" });
703
+ }
704
+ } }],
705
+ });
706
+ await close(entry.row.item, entry.reason, { author: "pm-github", completedAt: entry.at,
707
+ resolution: entry.resolution, expectedResult: entry.expectedResult, actualResult: entry.actualResult,
708
+ }, { pmRoot, noExtensions: true });
709
+ });
506
710
  closed++;
711
+ handled.add(entry.row.item);
507
712
  }
508
713
  catch (error) {
714
+ if (error instanceof PmCliError && (error.code === "closeout_revision_changed"
715
+ || (error.code === undefined && error.exitCode === EXIT_CODE.CONFLICT))) {
716
+ if (retry) {
717
+ await reselect(entry.row.item, entry.row.pr);
718
+ continue;
719
+ }
720
+ reportItemDecision(plan.filter(row => row.item === entry.row.item && row.code !== "not_delivery"), { kind: "blocking_hold",
721
+ reason: "Item revision changed again after one delivery re-selection; hold for review.", missing: [] });
722
+ continue;
723
+ }
509
724
  failed++;
510
- entry.row.action = "skip";
511
- entry.row.code = "close_failed";
512
- entry.row.reason = closeoutFailureReason(error);
725
+ const reason = closeoutFailureReason(error);
726
+ reportItemDecision(plan.filter(row => row.item === entry.row.item && row.code !== "not_delivery"), { kind: "blocking_hold", reason,
727
+ missing: [{ pr: entry.row.pr, code: "close_failed", reason }] });
513
728
  }
514
729
  }
730
+ for (const [id, pr] of dependentConflicts) {
731
+ if (handled.has(id))
732
+ continue;
733
+ if (retry)
734
+ await reselect(id, pr);
735
+ else
736
+ plan.push({ item: id, pr, action: "needs_review", code: "needs_review", candidates: [pr],
737
+ reason: "Dependent item revision changed during re-selection; automatic unblock held for review." });
738
+ }
515
739
  }
516
- return { command: "github closeout", repo, dry_run: !apply, plan, checks, closed, failed };
740
+ return { command: "github closeout", repo, dry_run: !apply,
741
+ plan: onlyItem === undefined ? plan : plan.filter(row => row.item === onlyItem || dependentConflicts.has(row.item)), checks, closed, failed };
517
742
  }
518
743
  /** Keep actionable decisions and their checks while counting routine skips by stable code. */
519
744
  export function compactCloseoutReport(report) {
520
- const skipped = {};
745
+ const skipped = new Map();
521
746
  const plan = report.plan.flatMap(row => {
522
- if (["already_terminal", "other_repository", "duplicate_selection", "explicit_skip"].includes(row.code)) {
523
- skipped[row.code] = (skipped[row.code] ?? 0) + 1;
747
+ if (["already_terminal", "other_repository", "duplicate_selection", "explicit_skip", "not_merged"].includes(row.code)) {
748
+ skipped.set(row.code, (skipped.get(row.code) ?? 0) + 1);
524
749
  return [];
525
750
  }
526
- return [{ ...row, action: row.action === "close" ? "close" : "refuse" }];
751
+ return [{ ...row, action: row.action === "skip" && row.code !== "not_delivery" ? "refuse" : row.action }];
527
752
  });
528
753
  const prs = new Set(plan.map(row => row.pr));
529
- return { ...report, plan, skipped, checks: report.checks.filter(gate => prs.has(gate.pr)) };
754
+ const counts = Object.fromEntries(skipped);
755
+ return { ...report, plan, skipped: counts, checks: report.checks.filter(gate => prs.has(gate.pr)) };
530
756
  }
531
757
  //# sourceMappingURL=closeout.js.map