pm-github 2026.10.16 → 2026.10.23

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/index.js CHANGED
@@ -14,13 +14,19 @@
14
14
  // Issues use the REST API; Projects v2 is GraphQL-only (see the Projects v2
15
15
  // section below and the pure plan/mapping logic in ./projects.ts).
16
16
  import { CloseoutUsageError, compactCloseoutReport, runCloseout } from "./closeout.js";
17
+ import { AmbiguousCitationError, buildImportFidelity, fetchImportTimeline, importFidelityFields, matchImportedIssue, persistImportFidelity, readImportReleases, selectImportContextIndex, validateImportLifetime, } from "./import-fidelity.js";
18
+ import { refreshChanges, runRefresh } from "./refresh.js";
19
+ import { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.js";
20
+ export { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.js";
21
+ import { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.js";
22
+ export { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.js";
17
23
  import http from "node:http";
18
24
  import https from "node:https";
19
25
  import crypto from "node:crypto";
20
26
  import fs from "node:fs";
21
27
  import path from "node:path";
22
28
  import { spawnSync } from "node:child_process";
23
- import { comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, isExternalDependencyReference, listAllComplete, certifyCompleteListResult, listAllItemMetadata as sdkListAllItemMetadata, normalizeItemId as sdkNormalizeItemId, readSettings as sdkReadSettings, } from "@unbrained/pm-cli/sdk";
29
+ import { createPmCliExpectedError, comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, isExternalDependencyReference, listAllComplete, certifyCompleteListResult, listAllItemMetadata as sdkListAllItemMetadata, normalizeItemId as sdkNormalizeItemId, readSettings as sdkReadSettings, resolvePmRoot as sdkResolvePmRoot, writeFileAtomic, getHistoryPath as sdkGetHistoryPath, readHistorySnapshot as sdkReadHistorySnapshot, } from "@unbrained/pm-cli/sdk";
24
30
  import { collectNewOrderingCycleWarnings as sdkCollectNewOrderingCycleWarnings } from "@unbrained/pm-cli/sdk/graph";
25
31
  import { GateInputError, formatGateReport, runTrackerGate, resolvePmDataDir, } from "./gate.js";
26
32
  export { resolvePmDataDir } from "./gate.js";
@@ -109,7 +115,10 @@ export function githubApiBase() {
109
115
  "Requests carry the GitHub token, so a plaintext non-loopback base would leak it.");
110
116
  }
111
117
  // Strip a trailing slash so `${base}/repos/...` cannot produce `//repos/...`.
112
- return raw.replace(/\/+$/, "");
118
+ let end = raw.length;
119
+ while (raw[end - 1] === "/")
120
+ end--;
121
+ return raw.slice(0, end);
113
122
  }
114
123
  /**
115
124
  * One low-level HTTP request, with no retry or backoff.
@@ -356,7 +365,7 @@ export function parseNextLink(linkHeader) {
356
365
  // Map a GitHub issue/PR state (+ optional stateReason) onto a pm status,
357
366
  // preserving `not_planned` closures as `canceled` rather than `closed`.
358
367
  export function mapState(state, stateReason) {
359
- if (state === "closed" && stateReason === "not_planned")
368
+ if (state === "closed" && (stateReason === "not_planned" || stateReason === "duplicate"))
360
369
  return "canceled";
361
370
  return state === "closed" ? "closed" : "open";
362
371
  }
@@ -718,6 +727,7 @@ export function decodeCompletePmItems(parsed) {
718
727
  }
719
728
  ids.add(item.id);
720
729
  items.push({
730
+ ...item,
721
731
  id: item.id,
722
732
  title,
723
733
  status,
@@ -924,8 +934,9 @@ async function resolveAtomicSdkFunctions(opts) {
924
934
  * Derive an order-independent transaction id from the desired import state and
925
935
  * exact ordered mutation plan. Content or target changes produce a fresh
926
936
  * transaction; a reordered retry of the same plan resumes the durable journal.
937
+ * External history revisions distinguish a local edit from a retry of import-owned writes.
927
938
  */
928
- export function deriveAtomicTransactionId(repo, entries, mutations) {
939
+ export function deriveAtomicTransactionId(repo, entries, mutations, contextVersions = []) {
929
940
  const canonical = [...entries]
930
941
  .sort((a, b) => a.issueNumber - b.issueNumber)
931
942
  .map((entry) => ({
@@ -938,8 +949,10 @@ export function deriveAtomicTransactionId(repo, entries, mutations) {
938
949
  tags: [...entry.tags].sort(),
939
950
  assignee: entry.assignee ?? null,
940
951
  milestone: entry.milestone ?? null,
952
+ fidelity: entry.fidelity ?? null,
953
+ linkOnly: entry.linkOnly ?? false,
941
954
  }));
942
- const digest = crypto
955
+ const payload = crypto
943
956
  .createHash("sha256")
944
957
  .update(repo.toLowerCase())
945
958
  .update("\x1f")
@@ -948,10 +961,10 @@ export function deriveAtomicTransactionId(repo, entries, mutations) {
948
961
  // Recovery requires the exact ordered step plan. Include targets and
949
962
  // options so a changed id_prefix or provenance match gets a fresh journal
950
963
  // instead of colliding with an incompatible prior attempt.
951
- .update(JSON.stringify(mutations))
952
- .digest("hex")
953
- .slice(0, 16);
954
- return `${ATOMIC_IMPORT_PREFIX}${digest}`;
964
+ .update(JSON.stringify(mutations));
965
+ if (contextVersions.length > 0)
966
+ payload.update("\x1f").update(JSON.stringify([...contextVersions].sort()));
967
+ return `${ATOMIC_IMPORT_PREFIX}${payload.digest("hex").slice(0, 16)}`;
955
968
  }
956
969
  /** Stable create id keyed by the external GitHub issue, never by fetch order. */
957
970
  export function deriveAtomicItemId(repo, issueNumber, idPrefix, normalizeItemId) {
@@ -963,7 +976,14 @@ export function deriveAtomicItemId(repo, issueNumber, idPrefix, normalizeItemId)
963
976
  return normalizeItemId(`github-${repoToken}-${issueNumber}`, idPrefix);
964
977
  }
965
978
  /** Map one rendered import entry to its reversible SDK mutation sequence. */
966
- export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId) {
979
+ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, updateOnly = false) {
980
+ if (updateOnly && !entry.match?.id)
981
+ throw new CommandError("Refresh refuses an unlinked target; no item may be created.");
982
+ const changed = updateOnly ? new Set(refreshChanges(entry).map(change => change.field)) : undefined;
983
+ const audit = updateOnly ? { comment: [`Refreshed linked GitHub issue ${repo.toLowerCase()}#${entry.issueNumber}`] } : {};
984
+ if (entry.linkOnly && entry.match?.id) {
985
+ return { itemId: entry.match.id, mutations: [{ op: "update", id: entry.match.id, options: { ...(!changed || changed.has("tags") ? { addTags: entry.tags } : {}), ...audit } }] };
986
+ }
967
987
  const sharedOptions = {
968
988
  title: entry.title,
969
989
  type: entry.itemType,
@@ -972,7 +992,14 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
972
992
  tags: entry.tags.join(","),
973
993
  ...(entry.assignee ? { assignee: entry.assignee } : {}),
974
994
  ...(entry.milestone ? { sprint: entry.milestone } : {}),
995
+ ...(entry.status === "canceled" ? { closeReason: entry.fidelity?.github_close_reason ?? `GitHub issue #${entry.issueNumber} closed` } : {}),
975
996
  };
997
+ // Explicit terminal reconciliation also handles canceled -> completed. The
998
+ // supported force option permits that source-authoritative repeat closure;
999
+ // finalization restores the real dates before the transaction commits.
1000
+ const statusOptions = entry.status === "closed"
1001
+ ? entry.fidelity ? { status: "closed", closeReason: entry.fidelity.github_close_reason, force: true } : {}
1002
+ : { status: entry.status };
976
1003
  const managedItemId = deriveAtomicItemId(repo, entry.issueNumber, idPrefix, normalizeItemId);
977
1004
  // A missing match and a match at our deterministic external-key id use the
978
1005
  // SAME create+update upsert plan. This is essential for crash recovery: if a
@@ -980,26 +1007,26 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
980
1007
  // item, but commitItemMutations must still receive the original plan. The
981
1008
  // create step treats an existing stable id as already applied; update then
982
1009
  // makes later content-bearing transactions refresh the item normally.
983
- if (!entry.match?.id || entry.match.id === managedItemId) {
1010
+ if (!updateOnly && (!entry.match?.id || entry.match.id === managedItemId)) {
984
1011
  const createStatus = entry.status === "closed" ? "open" : entry.status;
985
1012
  const mutations = [{
986
1013
  op: "create",
987
1014
  id: managedItemId,
988
- options: { ...sharedOptions, status: createStatus },
1015
+ options: { ...sharedOptions, status: createStatus, message: `pm-github import create ${repo.toLowerCase()}#${entry.issueNumber}` },
989
1016
  }, {
990
1017
  op: "update",
991
1018
  id: managedItemId,
992
1019
  options: {
993
1020
  ...sharedOptions,
994
- ...(entry.status !== "closed" ? { status: entry.status } : {}),
1021
+ ...statusOptions,
995
1022
  },
996
1023
  }];
997
- if (entry.status === "closed") {
1024
+ if (entry.status === "closed" && !entry.fidelity) {
998
1025
  mutations.push({
999
1026
  op: "close",
1000
1027
  id: managedItemId,
1001
1028
  reason: `GitHub issue #${entry.issueNumber} closed`,
1002
- // Preserve the source's real completion time instead of the import time.
1029
+ // Legacy callers can supply completion directly; full plans finalize both lifecycle dates.
1003
1030
  ...(entry.closedAt ? { options: { completedAt: entry.closedAt } } : {}),
1004
1031
  });
1005
1032
  }
@@ -1009,52 +1036,125 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
1009
1036
  };
1010
1037
  }
1011
1038
  const itemId = entry.match.id;
1012
- const updateOptions = { ...sharedOptions };
1013
- // close has a dedicated mutation so its reason is preserved. Every other
1014
- // transition (open/reopen/canceled) is safely reversible as part of update.
1015
- if (entry.status !== "closed") {
1016
- updateOptions.status = entry.status;
1017
- }
1039
+ const updateOptions = {
1040
+ ...(updateOnly ? {
1041
+ ...(changed.has("title") ? { title: entry.title } : {}),
1042
+ ...(changed.has("tags") ? { tags: entry.tags.join(",") } : {}),
1043
+ ...(changed.has("status") && sharedOptions.closeReason !== undefined ? { closeReason: sharedOptions.closeReason } : {}),
1044
+ } : sharedOptions), ...(!changed || changed.has("status") ? statusOptions : {}), ...audit,
1045
+ };
1018
1046
  const mutations = [{
1019
1047
  op: "update",
1020
1048
  id: itemId,
1021
1049
  options: updateOptions,
1022
1050
  }];
1023
- if (entry.status === "closed") {
1051
+ if (entry.status === "closed" && !entry.fidelity) {
1024
1052
  mutations.push({
1025
1053
  op: "close",
1026
1054
  id: itemId,
1027
1055
  reason: `GitHub issue #${entry.issueNumber} closed`,
1028
- // Preserve the source's real completion time instead of the import time.
1056
+ // Legacy callers can supply completion directly; full plans finalize both lifecycle dates.
1029
1057
  ...(entry.closedAt ? { options: { completedAt: entry.closedAt } } : {}),
1030
1058
  });
1031
1059
  }
1032
1060
  return { itemId, mutations };
1033
1061
  }
1034
- /** Commit a complete issue-import batch under one crash-resumable transaction. */
1035
- export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1036
- const { commitItemMutations: commit, normalizeItemId, readSettings, } = await resolveAtomicSdkFunctions(opts);
1037
- let idPrefix = "pm-";
1062
+ /**
1063
+ * Bind atomic plans to the latest external edit or different lifecycle generation.
1064
+ * The importer's desired-state and compensation writes leave retries stable;
1065
+ * local edits and later state cycles invalidate completed journals.
1066
+ */
1067
+ async function importContextVersions(pmRoot, entries) {
1068
+ const versions = [];
1069
+ for (const entry of entries) {
1070
+ if (!entry.fidelity || !entry.match?.id)
1071
+ continue;
1072
+ const snapshot = await sdkReadHistorySnapshot(sdkGetHistoryPath(pmRoot, entry.match.id), entry.match.id);
1073
+ const context = snapshot.entries[selectImportContextIndex(snapshot.entries, entry.status)];
1074
+ if (context)
1075
+ versions.push(`${entry.match.id}:${crypto.createHash("sha256").update(JSON.stringify(context)).digest("hex")}`);
1076
+ }
1077
+ return versions;
1078
+ }
1079
+ /** Durable SDK journals live per tracker under `transactions/sdk`. */
1080
+ function sdkJournalDir(pmRoot) {
1081
+ return path.join(pmRoot, "transactions", "sdk");
1082
+ }
1083
+ /** Persisted import plans live beside the SDK journals, under `transactions/pm-github`. */
1084
+ function importPlanDir(pmRoot) {
1085
+ return path.join(pmRoot, "transactions", "pm-github");
1086
+ }
1087
+ /** Extract the mutation target ids from journal step ids like `1-update-<item>-<hash>`. @internal */
1088
+ export function journalStepItemIds(stepIds) {
1089
+ const ids = new Set();
1090
+ for (const step of stepIds) {
1091
+ const id = typeof step === "string" ? /^\d+-(?:create|update|close|release)-(.+)-[0-9a-f]{12}$/.exec(step)?.[1] : undefined;
1092
+ if (id !== undefined)
1093
+ ids.add(id);
1094
+ }
1095
+ return [...ids];
1096
+ }
1097
+ /** List pending github-import journals without changing plans or tracker bytes. */
1098
+ export async function listUnfinishedGithubImports(pmRoot) {
1099
+ pmRoot = sdkResolvePmRoot(process.cwd(), pmRoot);
1100
+ const unfinished = [];
1101
+ const journalNames = await fs.promises.readdir(sdkJournalDir(pmRoot)).catch((error) => {
1102
+ if (error.code === "ENOENT")
1103
+ return [];
1104
+ throw new CommandError("Refresh cannot inspect GitHub transaction journals.");
1105
+ });
1106
+ for (const name of journalNames) {
1107
+ if (!name.startsWith(ATOMIC_IMPORT_PREFIX) || !name.endsWith(".json"))
1108
+ continue;
1109
+ const transactionId = name.slice(0, -".json".length);
1110
+ let journal;
1111
+ try {
1112
+ journal = JSON.parse(await fs.promises.readFile(path.join(sdkJournalDir(pmRoot), name), "utf8"));
1113
+ if (!journal || typeof journal !== "object")
1114
+ throw new Error("Invalid journal");
1115
+ }
1116
+ catch {
1117
+ throw new CommandError(`Refresh cannot inspect GitHub transaction ${transactionId}: its durable journal is unreadable.`);
1118
+ }
1119
+ if (journal.status === "applying" || journal.status === "compensating") {
1120
+ unfinished.push({ transactionId, status: journal.status,
1121
+ itemIds: journalStepItemIds(Array.isArray(journal.stepIds) ? journal.stepIds : []) });
1122
+ }
1123
+ }
1124
+ return unfinished.sort((left, right) => left.transactionId.localeCompare(right.transactionId));
1125
+ }
1126
+ /** Persist the exact pre-commit plan so an interrupted journal can be replayed verbatim. */
1127
+ async function persistGithubImportPlan(pmRoot, record) {
1128
+ await fs.promises.mkdir(importPlanDir(pmRoot), { recursive: true });
1129
+ await writeFileAtomic(path.join(importPlanDir(pmRoot), `${record.transactionId}.json`), `${JSON.stringify(record, null, 2)}\n`);
1130
+ }
1131
+ /** Read one persisted plan; null when the tracker holds no plan for that transaction. */
1132
+ export async function readGithubImportPlan(pmRoot, transactionId) {
1133
+ if (!/^[a-zA-Z0-9._-]+$/.test(transactionId))
1134
+ return null;
1135
+ const file = path.join(importPlanDir(sdkResolvePmRoot(process.cwd(), pmRoot)), `${transactionId}.json`);
1038
1136
  try {
1039
- const settings = await readSettings(pmRoot);
1040
- if (settings?.id_prefix)
1041
- idPrefix = String(settings.id_prefix);
1137
+ return JSON.parse(await fs.promises.readFile(file, "utf8"));
1042
1138
  }
1043
1139
  catch {
1044
- // Match normal import resilience: an unreadable optional setting falls
1045
- // back to the canonical prefix; the mutation still validates the tracker.
1140
+ return null;
1046
1141
  }
1047
- const mutations = [];
1048
- const itemIds = new Map();
1049
- // The transaction journal fingerprints the ordered step plan. Canonicalize
1050
- // by the stable GitHub issue number so a retry whose API page/order changed
1051
- // supplies the exact same plan as well as the same transaction id.
1052
- for (const entry of [...entries].sort((a, b) => a.issueNumber - b.issueNumber)) {
1053
- const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId);
1054
- itemIds.set(entry.issueNumber, planned.itemId);
1055
- mutations.push(...planned.mutations);
1056
- }
1057
- const transactionId = deriveAtomicTransactionId(repo, entries, mutations);
1142
+ }
1143
+ /** Remove a persisted plan once its journal reached a durable terminal state. */
1144
+ async function removeGithubImportPlan(pmRoot, transactionId) {
1145
+ await fs.promises.rm(path.join(importPlanDir(pmRoot), `${transactionId}.json`), { force: true });
1146
+ }
1147
+ /** Finalize source metadata and commit one github-import transaction through the SDK. */
1148
+ async function commitGithubImportTransaction(config) {
1149
+ const { pmRoot, entries, mutations, transactionId, itemIds, opts, commit } = config;
1150
+ /** Finalize before the SDK commits, and repair source metadata on recovered journals. */
1151
+ const finalize = async () => {
1152
+ for (const entry of entries) {
1153
+ if (entry.fidelity)
1154
+ await persistImportFidelity(pmRoot, itemIds.get(entry.issueNumber), entry.fidelity, Boolean(entry.linkOnly), opts.updateOnly);
1155
+ }
1156
+ };
1157
+ let committed = false;
1058
1158
  try {
1059
1159
  const result = await commit({
1060
1160
  pmRoot,
@@ -1066,7 +1166,14 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1066
1166
  // restores every UPDATE and CLOSE step (covered by the mixed rollback
1067
1167
  // integration test below this implementation).
1068
1168
  createCompensation: "delete",
1169
+ onTransition: async (context) => {
1170
+ if (context.transition === "committing")
1171
+ await finalize();
1172
+ await opts.onTransition?.(context);
1173
+ },
1069
1174
  });
1175
+ committed = true;
1176
+ await finalize();
1070
1177
  // A recovered journal may include work applied by the interrupted process
1071
1178
  // as well as steps resumed now. The SDK intentionally returns the durable
1072
1179
  // final results, not a per-invocation delta, so create/update counts cannot
@@ -1082,15 +1189,144 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1082
1189
  };
1083
1190
  }
1084
1191
  catch (err) {
1192
+ // Retain sensitive replay data only while the durable journal is resumable.
1193
+ // Apply this to fresh commits and recovery attempts, preserving the original
1194
+ // failure when journal inspection or cleanup itself is unavailable.
1195
+ if (opts.updateOnly) {
1196
+ const pending = await listUnfinishedGithubImports(pmRoot).catch(() => null);
1197
+ if (pending && !pending.some(journal => journal.transactionId === transactionId)) {
1198
+ await removeGithubImportPlan(pmRoot, transactionId).catch(() => { });
1199
+ }
1200
+ }
1085
1201
  const msg = errorMessage(err);
1202
+ const label = `${opts.updateOnly ? "Refresh" : "Atomic GitHub import"} transaction ${transactionId}`;
1203
+ if (committed)
1204
+ throw new CommandError(`${label} has a committed journal but source metadata could not be finalized. Retry transaction ${transactionId}; nothing may be pushed. Underlying error: ${msg}`);
1086
1205
  if (err instanceof AggregateError || /compensation failed/i.test(msg)) {
1087
- throw new CommandError(`Atomic GitHub import failed and compensation was incomplete. The tracker may contain partially applied state; retry the same import to resume transaction ${transactionId}, then inspect its durable journal if recovery still fails. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
1206
+ throw new CommandError(`${label} failed and compensation was incomplete. The tracker may contain partially applied state; retry to resume transaction ${transactionId}, then inspect its durable journal if recovery still fails. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
1088
1207
  }
1089
1208
  if (err instanceof Error && err.name === "WorkspaceTransactionInterruptedError") {
1090
- throw new CommandError(`Atomic GitHub import was interrupted. Its durable journal is resumable; retry the same import to continue transaction ${transactionId}. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
1209
+ throw new CommandError(`${label} was interrupted. Its durable journal is resumable; retry to continue transaction ${transactionId}. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
1210
+ }
1211
+ throw new CommandError(`${label} failed after the SDK completed its normal compensation path; no new partial committed state is expected. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
1212
+ }
1213
+ }
1214
+ /** Commit a complete issue-import batch under one crash-resumable transaction. */
1215
+ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1216
+ pmRoot = sdkResolvePmRoot(process.cwd(), pmRoot);
1217
+ const { commitItemMutations: commit, normalizeItemId, readSettings, } = await resolveAtomicSdkFunctions(opts);
1218
+ let idPrefix = "pm-";
1219
+ try {
1220
+ const settings = await readSettings(pmRoot);
1221
+ if (settings?.id_prefix)
1222
+ idPrefix = String(settings.id_prefix);
1223
+ }
1224
+ catch {
1225
+ // Match normal import resilience: an unreadable optional setting falls
1226
+ // back to the canonical prefix; the mutation still validates the tracker.
1227
+ }
1228
+ const mutations = [];
1229
+ const itemIds = new Map();
1230
+ // The transaction journal fingerprints the ordered step plan. Canonicalize
1231
+ // by the stable GitHub issue number so a retry whose API page/order changed
1232
+ // supplies the exact same plan as well as the same transaction id.
1233
+ for (const entry of [...entries].sort((a, b) => a.issueNumber - b.issueNumber)) {
1234
+ const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, opts.updateOnly);
1235
+ itemIds.set(entry.issueNumber, planned.itemId);
1236
+ mutations.push(...planned.mutations);
1237
+ }
1238
+ const transactionId = deriveAtomicTransactionId(repo, entries, mutations, await importContextVersions(pmRoot, entries));
1239
+ // Update-only transactions derive their steps from the CURRENT item state,
1240
+ // so a retry after a partial apply would fingerprint a different plan and
1241
+ // orphan the journal behind a fresh transaction id. Persist the exact plan
1242
+ // before the SDK touches the tracker so an interrupted refresh can replay it.
1243
+ if (opts.updateOnly)
1244
+ for (const entry of entries)
1245
+ validateGithubImportFidelity(entry, repo, `GitHub transaction ${transactionId}`);
1246
+ const plan = opts.updateOnly ? { transactionId, repo: repo.toLowerCase(), entries, mutations } : undefined;
1247
+ if (plan)
1248
+ await persistGithubImportPlan(pmRoot, plan);
1249
+ const result = await commitGithubImportTransaction({ pmRoot, repo, entries, mutations, transactionId, itemIds, opts, commit });
1250
+ if (plan)
1251
+ await removeGithubImportPlan(pmRoot, transactionId);
1252
+ return result;
1253
+ }
1254
+ /** Validate every source-finalization input against its effective native lifecycle before writes. */
1255
+ export function validateGithubImportFidelity(entry, repo, context, items) {
1256
+ // Finalization reads source metadata outside the SDK mutation preview.
1257
+ // Validate its entire projection before ANY selected journal can commit.
1258
+ const fidelity = entry.fidelity;
1259
+ if (fidelity !== undefined) {
1260
+ const fields = ["github_url", "github_number", "github_state", "github_state_reason", "github_created_at",
1261
+ "github_updated_at", "github_closed_at", "github_close_reason", "github_release"];
1262
+ if (!fidelity || typeof fidelity !== "object" || Array.isArray(fidelity) ||
1263
+ Object.keys(fidelity).some(field => !fields.includes(field)) ||
1264
+ typeof fidelity.github_url !== "string" ||
1265
+ fidelity.github_url.toLowerCase() !== `https://github.com/${repo.toLowerCase()}/issues/${entry.issueNumber}` ||
1266
+ fidelity.github_number !== entry.issueNumber ||
1267
+ !["open", "closed"].includes(fidelity.github_state) ||
1268
+ typeof fidelity.github_created_at !== "string" || typeof fidelity.github_updated_at !== "string" ||
1269
+ [fidelity.github_closed_at, fidelity.github_close_reason, fidelity.github_release, fidelity.github_state_reason]
1270
+ .some(value => value !== null && typeof value !== "string")) {
1271
+ throw new CommandError(`${context}: its persisted fidelity is invalid.`);
1272
+ }
1273
+ try {
1274
+ validateImportLifetime({ number: entry.issueNumber, created_at: fidelity.github_created_at,
1275
+ updated_at: fidelity.github_updated_at, closed_at: fidelity.github_closed_at });
1276
+ }
1277
+ catch {
1278
+ throw new CommandError(`${context}: its persisted fidelity has an invalid source lifetime.`);
1279
+ }
1280
+ // Missing source closure dates preserve native completion metadata.
1281
+ // Check the effective lifecycle from the current read-only snapshot too.
1282
+ const current = items?.find(item => item.id === entry.match.id) ?? entry.match;
1283
+ const projected = { ...current, ...importFidelityFields(fidelity, Boolean(entry.linkOnly)) };
1284
+ if (projected.closed_at && Date.parse(String(projected.closed_at)) < Date.parse(String(projected.created_at))) {
1285
+ throw new CommandError(`${context}: its persisted fidelity conflicts with the native closure date.`);
1286
+ }
1287
+ }
1288
+ if (entry.linkOnly !== undefined && typeof entry.linkOnly !== "boolean") {
1289
+ throw new CommandError(`${context}: its persisted ownership is invalid.`);
1290
+ }
1291
+ }
1292
+ /** Validate a persisted refresh plan without writing or opening an SDK transaction. */
1293
+ export function validateGithubImportPlan(record, items) {
1294
+ if (typeof record?.transactionId !== "string" || !/^[a-zA-Z0-9._-]+$/.test(record.transactionId) ||
1295
+ typeof record.repo !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(record.repo) ||
1296
+ !Array.isArray(record.entries) || !record.entries.length || !Array.isArray(record.mutations) || !record.mutations.length) {
1297
+ throw new CommandError(`Refresh cannot replay unfinished GitHub transaction ${typeof record?.transactionId === "string" ? record.transactionId : "<unknown>"}: its persisted plan is invalid.`, EXIT_CODE.GENERIC_FAILURE);
1298
+ }
1299
+ const itemIds = new Map();
1300
+ for (const entry of record.entries) {
1301
+ if (typeof entry?.issueNumber !== "number" || typeof entry?.match?.id !== "string") {
1302
+ throw new CommandError(`Refresh cannot replay unfinished GitHub transaction ${record.transactionId}: its persisted plan has an unlinked target.`, EXIT_CODE.GENERIC_FAILURE);
1091
1303
  }
1092
- throw new CommandError(`Atomic GitHub import failed after the SDK completed its normal compensation path; no new partial committed state is expected. Transaction id: ${transactionId}. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
1304
+ validateGithubImportFidelity(entry, record.repo, `Refresh cannot replay unfinished GitHub transaction ${record.transactionId}`, items);
1305
+ itemIds.set(entry.issueNumber, entry.match.id);
1093
1306
  }
1307
+ if (record.mutations.some(mutation => mutation?.op !== "update" || ![...itemIds.values()].includes(mutation.id))) {
1308
+ throw new CommandError(`Refresh cannot replay unfinished GitHub transaction ${record.transactionId}: its persisted mutation targets are invalid.`);
1309
+ }
1310
+ return itemIds;
1311
+ }
1312
+ /**
1313
+ * Resume one interrupted github-import journal by replaying its persisted
1314
+ * original plan. The plan — never a re-derivation from current item state —
1315
+ * supplies the exact ordered mutations and the transaction id, because a
1316
+ * partial apply changes both the derived step set and the history context the
1317
+ * id is fingerprinted from. When the recorded plan cannot be trusted, the
1318
+ * caller fails closed instead of leaving the journal silently unfinished.
1319
+ */
1320
+ export async function resumeGithubImport(pmRoot, record, opts = {}) {
1321
+ pmRoot = sdkResolvePmRoot(process.cwd(), pmRoot);
1322
+ const itemIds = validateGithubImportPlan(record);
1323
+ const { commitItemMutations: commit } = await resolveAtomicSdkFunctions(opts);
1324
+ const result = await commitGithubImportTransaction({
1325
+ pmRoot, repo: record.repo, entries: record.entries, mutations: record.mutations,
1326
+ transactionId: record.transactionId, itemIds, opts: { ...opts, updateOnly: true }, commit,
1327
+ });
1328
+ await removeGithubImportPlan(pmRoot, record.transactionId);
1329
+ return result;
1094
1330
  }
1095
1331
  // ---------------------------------------------------------------------------
1096
1332
  // Shared import core (used by both the legacy command and the importer)
@@ -1895,6 +2131,7 @@ export function parseImportOptions(options) {
1895
2131
  atomic: optionEnabled(options, "atomic"),
1896
2132
  linkDeps: optionEnabled(options, "link-deps", "linkDeps"),
1897
2133
  gate: optionEnabled(options, "gate"),
2134
+ adoptCited: optionEnabled(options, "adopt-cited", "adoptCited"),
1898
2135
  };
1899
2136
  }
1900
2137
  // Spawn the `pm` CLI with the given argv and return a normalized ok/stdout/stderr
@@ -2177,10 +2414,17 @@ export async function linkImportedDependencies(repo, issues, pmRoot, deps = {})
2177
2414
  * @param fetchIssueComments - Injectable comment fetcher (defaults to {@link fetchComments}).
2178
2415
  * @returns The prepared import entry, or `undefined` when the issue has no title.
2179
2416
  */
2180
- async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueComments = fetchComments) {
2417
+ export async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueComments = fetchComments, fidelityContext) {
2181
2418
  const title = issue.title.trim();
2182
2419
  if (!title)
2183
2420
  return undefined;
2421
+ const identity = matchImportedIssue(fidelityContext.items, issue, match, opts.adoptCited === true);
2422
+ match = identity.item;
2423
+ const timeline = await fidelityContext.timeline(issue, repo, token);
2424
+ const fidelity = buildImportFidelity(issue, fidelityContext.releases, timeline);
2425
+ const targetStatus = mapState(issue.state, issue.state_reason);
2426
+ const conflict = match?.status && match.status !== targetStatus && (["closed", "canceled"].includes(match.status) || issue.state === "closed")
2427
+ ? { local: match.status, github: issue.state, rule: identity.linkOnly ? "preserve-provider-owner" : "github-source-wins" } : undefined;
2184
2428
  const kind = issue.pull_request ? "PR" : "issue";
2185
2429
  const labels = issue.labels.map((label) => label.name).filter(Boolean);
2186
2430
  const tag = provenanceTag(repo, issue.number);
@@ -2206,19 +2450,22 @@ async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueCo
2206
2450
  issueNumber: issue.number,
2207
2451
  title,
2208
2452
  itemType: opts.itemType,
2209
- status: mapState(issue.state, issue.state_reason),
2453
+ status: identity.linkOnly ? match.status : targetStatus,
2210
2454
  description: `GH ${kind} #${issue.number}: ${issue.html_url}` +
2211
2455
  (author ? ` · author @${author}` : "") +
2212
2456
  (issue.state_reason ? ` · state reason ${issue.state_reason}` : "") +
2213
- (issue.created_at ? ` · created ${issue.created_at}` : "") +
2214
- (issue.updated_at ? ` · updated ${issue.updated_at}` : ""),
2457
+ ` · created ${issue.created_at} · updated ${issue.updated_at}`,
2215
2458
  body: writeCommentsToBody ? composeBody(issue, comments) : (issue.body || ""),
2216
- tags: [...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])],
2459
+ tags: [...new Set([...(identity.linkOnly || identity.via === "citation" ? match.tags ?? [] : []), ...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])])],
2217
2460
  assignee: issue.assignee?.login,
2218
2461
  milestone: issue.milestone?.title,
2219
2462
  comments,
2220
2463
  syncAnnotations,
2221
2464
  closedAt: issue.closed_at ?? undefined,
2465
+ fidelity,
2466
+ linkOnly: identity.linkOnly,
2467
+ identity: { via: identity.via, ...(conflict ? { conflict } : {}) },
2468
+ ...(identity.citation_candidates ? { citation_candidates: identity.citation_candidates } : {}),
2222
2469
  match,
2223
2470
  };
2224
2471
  }
@@ -2299,6 +2546,8 @@ export function verifyImportIdempotency(existing, prepared, repo) {
2299
2546
  for (const entry of prepared) {
2300
2547
  if (entry.match?.id) {
2301
2548
  matchedIds.push(entry.match.id);
2549
+ if (entry.identity?.via === "citation" && !entry.tags.includes(provenanceTag(repo, entry.issueNumber)))
2550
+ untaggedNewEntries++;
2302
2551
  continue;
2303
2552
  }
2304
2553
  if (!entry.tags.includes(provenanceTag(repo, entry.issueNumber)))
@@ -2311,8 +2560,9 @@ export function verifyImportIdempotency(existing, prepared, repo) {
2311
2560
  `${conflictingTargets} conflicting target(s). No item was written.`, EXIT_CODE.GENERIC_FAILURE);
2312
2561
  }
2313
2562
  return {
2563
+ adopted: prepared.filter(entry => entry.identity?.via === "citation").length,
2314
2564
  provenance_indexed: tagOwners.size,
2315
- matched_by_provenance: matchedIds.length,
2565
+ matched_by_provenance: prepared.filter(entry => entry.match?.id && entry.identity?.via !== "citation").length,
2316
2566
  new_entries: prepared.length - matchedIds.length,
2317
2567
  };
2318
2568
  }
@@ -2442,12 +2692,13 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2442
2692
  dryRun: true,
2443
2693
  wouldImport: 0,
2444
2694
  wouldUpdate: 0,
2695
+ wouldAdopt: 0,
2445
2696
  wouldSkip: 0,
2446
2697
  ...(opts.atomic ? { atomic: true } : {}),
2447
2698
  ...fetchFields,
2448
2699
  };
2449
2700
  }
2450
- return { imported: 0, updated: 0, skipped: 0, ...fetchFields, ...(emptyGateReceipt ? { gate: emptyGateReceipt } : {}) };
2701
+ return { imported: 0, updated: 0, adopted: 0, skipped: 0, ...fetchFields, ...(emptyGateReceipt ? { gate: emptyGateReceipt } : {}) };
2451
2702
  }
2452
2703
  console.error(`Found ${filtered.length} issue(s).`);
2453
2704
  // Build the idempotency index once up-front — including for a dry run. Skipping
@@ -2455,17 +2706,27 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2455
2706
  // reported "would import N, skip 0" where the real run performs updates for
2456
2707
  // already-linked issues. A preview that overstates creates reads as "this will
2457
2708
  // duplicate my whole tracker" and is the one thing --dry-run exists to rule out.
2458
- const existingItems = (dependencies.readItems ?? readPmItems)(pmRoot);
2709
+ /** Read the complete raw corpus in-process, retaining all provider metadata and bodies. */
2710
+ const readImportItems = async () => {
2711
+ if (dependencies.readItems)
2712
+ return dependencies.readItems(pmRoot);
2713
+ const corpus = certifyCompleteListResult(await listAllComplete({ includeBody: true }, { pmRoot, cwd: pmRoot, noExtensions: true }));
2714
+ return decodeCompletePmItems(corpus);
2715
+ };
2716
+ const existingItems = await readImportItems();
2459
2717
  const existing = indexByProvenance(existingItems);
2718
+ const fidelityContext = { items: existingItems, releases: readImportReleases(pmRoot),
2719
+ timeline: dependencies.fetchIssueTimeline ?? (dependencies.fetchIssues ? async () => [] : fetchImportTimeline) };
2460
2720
  let imported = 0;
2461
2721
  let updated = 0;
2722
+ let adopted = 0;
2462
2723
  let skipped = 0;
2463
2724
  // Planned native comments the write phase could not land (contended lock,
2464
2725
  // refused add, unparsable created id). Ungated runs warn and continue; gated
2465
2726
  // runs add this to the plan-divergence refusal at the end.
2466
2727
  let commentWriteFailures = 0;
2467
- // Gated pipeline, phase 1 — plan BEFORE any mutation. The same prepare step
2468
- // the write paths use builds every plan entry up front, so completeness
2728
+ // Plan every import BEFORE any mutation, including ungated citation refusals.
2729
+ // The same prepare step builds every entry up front, so gated completeness
2469
2730
  // (every fetched issue accounted for exactly once) and idempotency (every
2470
2731
  // re-import lands on exactly one provenance-tagged item) are proven while
2471
2732
  // the tracker is still untouched. Both checks throw before any write.
@@ -2473,19 +2734,48 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2473
2734
  let gateCompleteness;
2474
2735
  let gateIdempotency;
2475
2736
  let gatePlanByNumber;
2476
- if (opts.gate || opts.atomic) {
2737
+ const citationCandidates = [];
2738
+ let citationReceipt = {};
2739
+ {
2477
2740
  const planned = [];
2478
2741
  const skippedNumbers = [];
2742
+ const ambiguous = [];
2479
2743
  for (const issue of filtered) {
2480
- const entry = await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments);
2744
+ let entry;
2745
+ try {
2746
+ entry = await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments, fidelityContext);
2747
+ }
2748
+ catch (error) {
2749
+ if (!(error instanceof AmbiguousCitationError))
2750
+ throw error;
2751
+ ambiguous.push(error.entry);
2752
+ for (const candidate of error.entry.citation_candidates ?? [])
2753
+ citationCandidates.push({ issue: issue.number, ...candidate });
2754
+ continue;
2755
+ }
2481
2756
  if (!entry) {
2482
2757
  skippedNumbers.push(issue.number);
2483
2758
  continue;
2484
2759
  }
2485
2760
  planned.push(entry);
2761
+ for (const candidate of entry.citation_candidates ?? [])
2762
+ citationCandidates.push({ issue: issue.number, ...candidate });
2763
+ }
2764
+ if (citationCandidates.length > 0)
2765
+ citationReceipt = { citation_candidates: citationCandidates };
2766
+ if (ambiguous.length > 0) {
2767
+ const refusal = { code: "ambiguous_citation", blocked: true, plan: ambiguous, ...citationReceipt };
2768
+ if (!opts.dryRun)
2769
+ throw new CommandError(JSON.stringify(refusal));
2770
+ for (const entry of ambiguous)
2771
+ console.error(`[dry-run] #${entry.issue} ambiguous_citation: ${entry.candidates.join(", ")}. ${entry.remedy}`);
2772
+ return { dryRun: true, wouldImport: 0, wouldUpdate: 0, wouldAdopt: 0, wouldSkip: skippedNumbers.length,
2773
+ ...(opts.atomic ? { atomic: true } : {}), ...refusal, ...fetchFields };
2486
2774
  }
2487
2775
  if (opts.gate) {
2488
2776
  gateCompleteness = verifyImportPlanCompleteness(filtered, planned, skippedNumbers);
2777
+ }
2778
+ if (opts.gate || planned.some(entry => entry.identity?.via === "citation")) {
2489
2779
  gateIdempotency = verifyImportIdempotency(existingItems, planned, repo);
2490
2780
  }
2491
2781
  gatePlan = planned;
@@ -2494,7 +2784,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2494
2784
  if (opts.gate && !opts.dryRun) {
2495
2785
  const preview = runCommandTrackerGate({ pmRoot, plannedItems: planned.map(entry => ({
2496
2786
  itemId: entry.match?.id ?? `github-issue-${entry.issueNumber}`,
2497
- fields: { title: entry.title, description: entry.description, body: entry.body, tags: entry.tags,
2787
+ fields: { ...entry.fidelity, title: entry.title, description: entry.description, body: entry.body, tags: entry.tags,
2498
2788
  assignee: entry.assignee, sprint: entry.milestone,
2499
2789
  comments: entry.syncAnnotations ? entry.comments.map(comment => ({
2500
2790
  author: comment.user?.login, text: comment.body, created_at: comment.created_at,
@@ -2505,9 +2795,26 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2505
2795
  }
2506
2796
  if (opts.gate)
2507
2797
  console.error(`pm github gate: plan verified — ${gateCompleteness.planned} planned, ${gateCompleteness.skipped} skipped, ` +
2508
- `${gateIdempotency.matched_by_provenance} matched by provenance, ${gateIdempotency.new_entries} new.`);
2509
- }
2510
- const gatePlanFields = opts.gate ? { gate: { completeness: gateCompleteness, idempotency: gateIdempotency, scan: "post-write" } } : {};
2798
+ `${gateIdempotency.matched_by_provenance} matched by provenance, ${gateIdempotency.adopted} adopted, ${gateIdempotency.new_entries} new.`);
2799
+ }
2800
+ // Preview output omits unscanned PR-title quotes and private provider close text.
2801
+ const fidelityPlan = () => gatePlan.map(entry => {
2802
+ const source = entry.fidelity;
2803
+ const reason = source.github_close_reason.split(" (", 1)[0];
2804
+ return { issue: entry.issueNumber, item: entry.match?.id,
2805
+ action: entry.identity?.via === "citation" ? "adopt" : entry.linkOnly ? "link" : entry.match?.id ? "update" : "create", status: entry.status,
2806
+ ...source, github_close_reason: reason,
2807
+ created_at: entry.linkOnly ? entry.match.created_at : source.github_created_at,
2808
+ updated_at_policy: "sdk-mutation-clock; source in github_updated_at",
2809
+ closed_at: entry.linkOnly ? entry.match.closed_at : source.github_closed_at,
2810
+ close_reason: entry.linkOnly ? "preserved-provider-owner" : reason,
2811
+ release: entry.linkOnly ? "preserved-provider-owner" : source.github_release,
2812
+ identity: entry.identity,
2813
+ ...(entry.citation_candidates ? { citation_candidates: entry.citation_candidates } : {}) };
2814
+ });
2815
+ const adoptionCount = gatePlan.filter(entry => entry.identity?.via === "citation").length;
2816
+ const planFields = { ...(opts.gate || adoptionCount > 0 || citationCandidates.length > 0 ? { plan: fidelityPlan() } : {}), ...citationReceipt };
2817
+ const gatePlanFields = { ...planFields, ...(opts.gate ? { gate: { completeness: gateCompleteness, idempotency: gateIdempotency, scan: "post-write" } } : {}) };
2511
2818
  if (opts.atomic) {
2512
2819
  // The gated pipeline already prepared every entry (and counted its skips)
2513
2820
  // during the pre-mutation plan phase; reuse that plan verbatim so the write
@@ -2520,6 +2827,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2520
2827
  dryRun: true,
2521
2828
  wouldImport: 0,
2522
2829
  wouldUpdate: 0,
2830
+ wouldAdopt: 0,
2523
2831
  wouldSkip: skipped,
2524
2832
  atomic: true,
2525
2833
  ...fetchFields,
@@ -2529,12 +2837,12 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2529
2837
  }
2530
2838
  if (opts.dryRun) {
2531
2839
  imported = prepared.filter((entry) => !entry.match?.id).length;
2532
- updated = prepared.length - imported;
2840
+ updated = prepared.length - imported - adoptionCount;
2533
2841
  for (const entry of prepared) {
2534
- const action = entry.match?.id ? "update" : "import";
2842
+ const action = entry.identity?.via === "citation" ? "adopt" : entry.match?.id ? "update" : "import";
2535
2843
  console.error(` [dry-run][atomic] #${entry.issueNumber} ${action}: ${opts.gate ? "(gated title)" : entry.title} (${entry.status})`);
2536
2844
  }
2537
- console.error(`[dry-run] Atomic plan would import ${imported}, update ${updated}, skip ${skipped}.`);
2845
+ console.error(`[dry-run] Atomic plan would import ${imported}, update ${updated}, skip ${skipped}. Adopt ${adoptionCount}.`);
2538
2846
  if (opts.linkDeps) {
2539
2847
  console.error(`[dry-run] --link-deps: ${countDependencyRefCandidates(repo, filtered)} candidate reference(s) parsed; ` +
2540
2848
  `resolution + edge writes run only on a real import.`);
@@ -2543,6 +2851,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2543
2851
  dryRun: true,
2544
2852
  wouldImport: imported,
2545
2853
  wouldUpdate: updated,
2854
+ wouldAdopt: adoptionCount,
2546
2855
  wouldSkip: skipped,
2547
2856
  atomic: true,
2548
2857
  ...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
@@ -2552,7 +2861,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2552
2861
  }
2553
2862
  const result = await (dependencies.commitAtomic ?? importGithubAtomic)(pmRoot, repo, prepared);
2554
2863
  if (opts.gate)
2555
- verifyImportedProvenance((dependencies.readItems ?? readPmItems)(pmRoot), prepared, repo);
2864
+ verifyImportedProvenance(await readImportItems(), prepared, repo);
2556
2865
  // Planned native comments are part of the verified plan: a contended lock,
2557
2866
  // a refused add, or a committed entry whose created id is missing from the
2558
2867
  // post-commit routing map all leave planned comments unwritten, so they are
@@ -2589,58 +2898,63 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2589
2898
  transactionId: result.transactionId,
2590
2899
  recovered: result.recovered,
2591
2900
  imported: result.imported,
2592
- updated: result.updated,
2901
+ updated: result.updated - (result.recovered ? 0 : adoptionCount),
2902
+ adopted: result.recovered ? 0 : adoptionCount,
2593
2903
  ...(result.recoveredItems !== undefined ? { recoveredItems: result.recoveredItems } : {}),
2594
2904
  skipped,
2595
2905
  atomic: true,
2596
2906
  ...depLinkResultFields(atomicDepLink),
2597
2907
  ...fetchFields,
2908
+ ...planFields,
2598
2909
  ...(gateReceipt ? { gate: gateReceipt } : {}),
2599
2910
  };
2600
2911
  }
2601
2912
  for (const issue of filtered) {
2602
- // With --gate, the pre-mutation plan phase already prepared (or explicitly
2603
- // skipped) every issue; reuse the verified plan instead of re-preparing so
2604
- // the write phase cannot diverge from it.
2605
- const prepared = gatePlanByNumber
2606
- ? gatePlanByNumber.get(issue.number)
2607
- : await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments);
2913
+ // Every import reuses the pre-mutation identity plan, including ungated runs.
2914
+ const prepared = gatePlanByNumber.get(issue.number);
2608
2915
  if (!prepared) {
2609
- // A gated run counted its skips during the verified plan phase; only the
2610
- // ungated path counts them here.
2611
- if (!gatePlanByNumber)
2612
- skipped++;
2613
2916
  continue;
2614
2917
  }
2615
2918
  const labels = issue.labels.map((l) => l.name).filter(Boolean);
2616
- const { title, status, assignee, milestone, match, description, body, tags, comments, syncAnnotations, closedAt, } = prepared;
2919
+ const { title, status, assignee, milestone, match, description, body, tags, comments, syncAnnotations, } = prepared;
2617
2920
  /**
2618
2921
  * Build the `pm close` argv for this issue's pm item.
2619
2922
  *
2620
2923
  * Two paths below close an item for the same reason — reconciling an
2621
2924
  * already-matched item whose upstream issue is now closed, and closing an
2622
2925
  * item that was just created `open` because pm-cli 2026.8.3 refuses a
2623
- * terminal `create --status closed`. Both must carry identical provenance
2624
- * (the reason) and identical completion evidence (GitHub's own `closed_at`,
2625
- * when it recorded one), so the argv is built once here rather than
2626
- * duplicated at each site where the two could silently drift apart.
2926
+ * terminal `create --status closed`. Both carry the same source reason.
2927
+ * Source completion is applied with source creation during finalization,
2928
+ * avoiding an intermediate completion date before the SDK creation date.
2627
2929
  */
2628
2930
  const githubCloseArgs = (id) => {
2629
- const args = ["--path", pmRoot, "close", id, "--reason", `GitHub issue #${issue.number} closed`];
2630
- if (closedAt)
2631
- args.push("--completed-at", closedAt);
2931
+ const args = ["--path", pmRoot, "close", id, "--reason", prepared.fidelity.github_close_reason];
2632
2932
  return args;
2633
2933
  };
2634
2934
  if (opts.dryRun) {
2635
- const action = match?.id ? "update" : "import";
2935
+ const action = prepared.identity?.via === "citation" ? "adopt" : match?.id ? "update" : "import";
2636
2936
  const metadata = !opts.gate && labels.length > 0 ? `${status}, ${labels.join(",")}` : status;
2637
2937
  console.error(` [dry-run] #${issue.number} ${action}: ${opts.gate ? "(gated title)" : title} (${metadata})`);
2638
- if (match?.id)
2938
+ if (prepared.identity?.via === "citation")
2939
+ adopted++;
2940
+ else if (match?.id)
2639
2941
  updated++;
2640
2942
  else
2641
2943
  imported++;
2642
2944
  continue;
2643
2945
  }
2946
+ if (prepared.linkOnly) {
2947
+ const ownerId = match.id;
2948
+ const linked = pmRun(["--path", pmRoot, "update", ownerId, "--add-tags", tags.join(",")]);
2949
+ if (!linked.ok)
2950
+ throw new CommandError(`Could not link GitHub issue #${issue.number} to its existing owner.`);
2951
+ await persistImportFidelity(pmRoot, ownerId, prepared.fidelity, true);
2952
+ if (prepared.identity?.via === "citation")
2953
+ adopted++;
2954
+ else
2955
+ updated++;
2956
+ continue;
2957
+ }
2644
2958
  if (match?.id) {
2645
2959
  // Idempotent update — never duplicate. Status transitions go through the
2646
2960
  // proper command (close requires a reason; reopen via update --status).
@@ -2663,17 +2977,22 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2663
2977
  continue;
2664
2978
  }
2665
2979
  // Reconcile status separately.
2980
+ if (status === "canceled" && match.status !== "canceled") {
2981
+ const cancel = pmRun(["--path", pmRoot, "update", match.id, "--status", "canceled", "--close-reason", prepared.fidelity.github_close_reason]);
2982
+ if (!cancel.ok)
2983
+ throw new CommandError(`Could not cancel GitHub issue #${issue.number}.`);
2984
+ }
2666
2985
  if (status === "closed" && match.status !== "closed") {
2667
2986
  // Close through `pm close` so the reason is real provenance; pass the
2668
2987
  // source completion time as --completed-at when GitHub recorded one.
2669
- const close = pmRun(githubCloseArgs(match.id));
2988
+ const close = pmRun([...githubCloseArgs(match.id), ...(match.status === "canceled" ? ["--force"] : [])]);
2670
2989
  if (!close.ok) {
2671
2990
  console.error(`#${issue.number}: close reconciliation failed — ${close.stderr}`);
2672
2991
  skipped++;
2673
2992
  continue;
2674
2993
  }
2675
2994
  }
2676
- else if (status === "open" && match.status === "closed") {
2995
+ else if (status === "open" && (match.status === "closed" || match.status === "canceled")) {
2677
2996
  const reopen = pmRun(["--path", pmRoot, "update", match.id, "--status", "open", "--message", `GitHub issue #${issue.number} reopened`]);
2678
2997
  if (!reopen.ok) {
2679
2998
  console.error(`#${issue.number}: reopen reconciliation failed — ${reopen.stderr}`);
@@ -2684,7 +3003,11 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2684
3003
  if (syncAnnotations) {
2685
3004
  commentWriteFailures += (await syncGithubCommentsToAnnotations(match.id, comments, pmRoot, issue.number)).failed;
2686
3005
  }
2687
- updated++;
3006
+ await persistImportFidelity(pmRoot, match.id, prepared.fidelity, false);
3007
+ if (prepared.identity?.via === "citation")
3008
+ adopted++;
3009
+ else
3010
+ updated++;
2688
3011
  continue;
2689
3012
  }
2690
3013
  // A closed upstream issue cannot be born closed: governance
@@ -2703,21 +3026,22 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2703
3026
  "--tags", tags.join(","),
2704
3027
  "--message", `Imported from GitHub #${issue.number}`,
2705
3028
  ];
3029
+ if (status === "canceled")
3030
+ createArgs.push("--close-reason", prepared.fidelity.github_close_reason);
2706
3031
  if (assignee)
2707
3032
  createArgs.push("--assignee", assignee);
2708
3033
  if (milestone)
2709
3034
  createArgs.push("--sprint", milestone);
2710
3035
  // --json lets a following close (or annotations sync) address the freshly
2711
3036
  // created item by id without re-scanning the workspace.
2712
- if (mustClose || syncAnnotations)
2713
- createArgs.push("--json");
3037
+ createArgs.push("--json");
2714
3038
  const created = pmRun(createArgs);
2715
3039
  if (!created.ok) {
2716
3040
  console.error(`#${issue.number}: create failed — ${created.stderr}`);
2717
3041
  skipped++;
2718
3042
  continue;
2719
3043
  }
2720
- const createdId = (mustClose || syncAnnotations) ? parseCreatedItemId(created.stdout) : undefined;
3044
+ const createdId = parseCreatedItemId(created.stdout);
2721
3045
  if (mustClose) {
2722
3046
  if (createdId) {
2723
3047
  const close = pmRun(githubCloseArgs(createdId));
@@ -2749,10 +3073,13 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2749
3073
  commentWriteFailures += comments.length;
2750
3074
  }
2751
3075
  }
3076
+ if (!createdId)
3077
+ throw new CommandError(`Could not finalize GitHub issue #${issue.number}: created id missing.`);
3078
+ await persistImportFidelity(pmRoot, createdId, prepared.fidelity, false);
2752
3079
  imported++;
2753
3080
  }
2754
3081
  if (opts.dryRun) {
2755
- console.error(`[dry-run] Would import ${imported}, update ${updated}, skip ${skipped}.`);
3082
+ console.error(`[dry-run] Would import ${imported}, update ${updated}, skip ${skipped}. Adopt ${adopted}.`);
2756
3083
  if (opts.linkDeps) {
2757
3084
  console.error(`[dry-run] --link-deps: ${countDependencyRefCandidates(repo, filtered)} candidate reference(s) parsed; ` +
2758
3085
  `resolution + edge writes run only on a real import.`);
@@ -2761,14 +3088,15 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2761
3088
  dryRun: true,
2762
3089
  wouldImport: imported,
2763
3090
  wouldUpdate: updated,
3091
+ wouldAdopt: adopted,
2764
3092
  wouldSkip: skipped,
2765
3093
  ...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
2766
3094
  ...gatePlanFields,
2767
3095
  ...fetchFields,
2768
3096
  };
2769
3097
  }
2770
- console.error(`Imported ${imported} new, updated ${updated} existing, skipped ${skipped}.`);
2771
- if (imported === 0 && updated === 0 && skipped > 0) {
3098
+ console.error(`Imported ${imported} new, updated ${updated} existing, adopted ${adopted}, skipped ${skipped}.`);
3099
+ if (imported === 0 && updated === 0 && adopted === 0 && skipped > 0) {
2772
3100
  throw new CommandError(`Imported 0 issue(s); ${skipped} failed.`);
2773
3101
  }
2774
3102
  // A gated import is all-or-nothing in effect: a planned write that failed
@@ -2784,9 +3112,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2784
3112
  // Gated pipeline, phase 2 — the writes are on disk but nothing is committed or
2785
3113
  // pushed; scan the proposed tracker change now and fail closed.
2786
3114
  if (opts.gate)
2787
- verifyImportedProvenance((dependencies.readItems ?? readPmItems)(pmRoot), gatePlan, repo);
3115
+ verifyImportedProvenance(await readImportItems(), gatePlan, repo);
2788
3116
  const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined;
2789
- return { imported, updated, skipped, ...fetchFields, ...depLinkResultFields(depLink), ...(gateReceipt ? { gate: gateReceipt } : {}) };
3117
+ return { imported, updated, adopted, skipped, ...fetchFields, ...depLinkResultFields(depLink), ...planFields, ...(gateReceipt ? { gate: gateReceipt } : {}) };
2790
3118
  }
2791
3119
  /**
2792
3120
  * Emit the human-readable `--link-deps` summary to stderr.
@@ -4102,11 +4430,12 @@ function graphqlUrl() {
4102
4430
  // `errors` array, so we surface those explicitly. The combined user+org queries
4103
4431
  // below intentionally return a partial error for the wrong owner type while the
4104
4432
  // right one still resolves; we only throw when there is NO usable data.
4105
- async function githubGraphQL(token, query, variables) {
4433
+ /** Execute a typed GraphQL request for internal import closure evidence. @internal */
4434
+ export async function githubGraphQL(token, query, variables) {
4106
4435
  return (await githubGraphQLResponse(token, query, variables)).data;
4107
4436
  }
4108
4437
  /** Shared GraphQL client retaining partial errors for reference-level classification. */
4109
- async function githubGraphQLResponse(token, query, variables) {
4438
+ export async function githubGraphQLResponse(token, query, variables) {
4110
4439
  if (!token) {
4111
4440
  throw new CommandError("GitHub GraphQL requires a token (set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`).", EXIT_CODE.USAGE);
4112
4441
  }
@@ -4876,7 +5205,7 @@ async function runProjectSync(ctx) {
4876
5205
  export function isMutatingGithubCommand(command, options) {
4877
5206
  const cmd = (command || "").toLowerCase();
4878
5207
  const dryRun = optionEnabled(options, "dry-run", "dryRun");
4879
- if (cmd === "github sync")
5208
+ if (cmd === "github sync" || cmd === "github refresh")
4880
5209
  return !dryRun;
4881
5210
  // Export is dry-run by default; it only mutates with an explicit apply
4882
5211
  // (--apply / --no-dry-run / legacy --push) AND no --dry-run override.
@@ -4896,6 +5225,7 @@ export function isMutatingGithubCommand(command, options) {
4896
5225
  // Extension
4897
5226
  // ---------------------------------------------------------------------------
4898
5227
  const IMPORT_FLAGS = [
5228
+ { long: "--adopt-cited", description: "Opt in to normalized, anchored citation adoption (default: off). Tag items gh:owner/repo#N when creating them; default imports only report citation candidates" },
4899
5229
  { long: "--issue", value_name: "N[,N...]", description: "Fetch exactly these issue numbers, #N, or same-repo owner/repo#N references; conflicts with --state/--all/--labels/--since/--assignee/--milestone/--limit. Missing numbers fail before writes (reported by --dry-run); PRs require --include-prs" },
4900
5230
  { long: "--limit", value_name: "n", description: "Positive list-mode issue cap, newest-updated first; reports limit and truncated when more exist (one extra matching issue is fetched to check). Invalid with --issue" },
4901
5231
  { long: "--all", description: "Include closed issues (shorthand for --state all)" },
@@ -4977,7 +5307,7 @@ const PROJECT_SYNC_FLAGS = [
4977
5307
  const defineExtension = (module) => module;
4978
5308
  export default defineExtension({
4979
5309
  name: "pm-github",
4980
- version: "2026.10.16",
5310
+ version: "2026.10.23",
4981
5311
  activate(api) {
4982
5312
  // -----------------------------------------------------------------------
4983
5313
  // schema — declare the GitHub provenance fields so the workspace knows them
@@ -4989,6 +5319,8 @@ export default defineExtension({
4989
5319
  { name: "github_author", type: "string", optional: true },
4990
5320
  { name: "github_created_at", type: "string", optional: true },
4991
5321
  { name: "github_updated_at", type: "string", optional: true },
5322
+ { name: "github_link_only", type: "boolean", optional: true },
5323
+ ...["github_closed_at", "github_state_reason", "github_close_reason", "github_release"].map(name => ({ name, type: "string", optional: true })),
4992
5324
  ]);
4993
5325
  // -----------------------------------------------------------------------
4994
5326
  // preflight — safe, local guard for mutating github commands. Scoped to
@@ -5006,6 +5338,7 @@ export default defineExtension({
5006
5338
  api.registerPreflight({
5007
5339
  commands: [
5008
5340
  "github sync",
5341
+ "github refresh",
5009
5342
  "github export",
5010
5343
  "github import",
5011
5344
  "gh-issues import",
@@ -5187,6 +5520,88 @@ export default defineExtension({
5187
5520
  },
5188
5521
  });
5189
5522
  // -----------------------------------------------------------------------
5523
+ // Refresh uses the import planner and its guarded update-only transaction path.
5524
+ api.registerCommand({
5525
+ name: "github identity",
5526
+ description: "Explore exact identity collisions and copied mentions offline. Defaults to a bounded read-only graph; --owner and --detach preview one repair, --approve applies its exact digest.",
5527
+ flags: [
5528
+ { long: "--identity", value_name: "ref", description: "Exact gh:owner/repo#N or external_ref to inspect" },
5529
+ { long: "--limit", value_name: "N", description: "Identity groups per page, 1..100 (default 20)" },
5530
+ { long: "--offset", value_name: "N", description: "Zero-based identity page offset" },
5531
+ { long: "--owner", value_name: "id", description: "Independently declared rightful owner" },
5532
+ { long: "--detach", value_name: "id", description: "One duplicate whose GitHub tag may be removed" },
5533
+ { long: "--approve", value_name: "digest", description: "Apply the exact reviewed preview digest" },
5534
+ ],
5535
+ async run(ctx) {
5536
+ const options = ctx.options;
5537
+ if (options.owner !== undefined || options.detach !== undefined || options.approve !== undefined) {
5538
+ if (typeof options.identity !== "string" || typeof options.owner !== "string" || typeof options.detach !== "string" ||
5539
+ (options.approve !== undefined && typeof options.approve !== "string")) {
5540
+ throw createPmCliExpectedError("Supply --identity, --owner and --detach together; --approve requires the preview digest.", { context: { code: "invalid_options" } });
5541
+ }
5542
+ return options.approve === undefined
5543
+ ? planGithubIdentityRepair(ctx.pm_root, options.identity, options.owner, options.detach)
5544
+ : applyGithubIdentityRepair(ctx.pm_root, { identity: options.identity, owner: options.owner, detach: options.detach, approval: options.approve, author: process.env.PM_AUTHOR ?? "pm-github" });
5545
+ }
5546
+ return exploreGithubIdentities(ctx.pm_root, {
5547
+ limit: options.limit === undefined ? undefined : Number(options.limit),
5548
+ offset: options.offset === undefined ? undefined : Number(options.offset),
5549
+ identity: typeof options.identity === "string" ? options.identity : undefined,
5550
+ });
5551
+ },
5552
+ });
5553
+ api.registerCommand({
5554
+ name: "github refresh",
5555
+ description: "Refresh existing linked items from GitHub without importing new issues.",
5556
+ intent: "keep a selected GitHub mirror current",
5557
+ arguments: [{ name: "owner/repo", required: true, description: "Linked GitHub repository" }],
5558
+ flags: [
5559
+ { long: "--dry-run", description: "Preview linked-item field changes without writing the tracker" },
5560
+ { long: "--since", value_name: "date|relative", description: "Only apply issues updated since this date or relative duration" },
5561
+ { long: "--limit", value_name: "N", description: "Select at most N linked issue numbers, in ascending order, before fetching" },
5562
+ { long: "--atomic", description: "Commit all changed linked items in one workspace writer transaction; an interrupted transaction is replayed from its persisted plan on the next refresh before unchanged items are skipped" },
5563
+ ],
5564
+ async run(ctx) {
5565
+ return runRefresh(ctx.args[0], ctx.pm_root, ctx.options);
5566
+ },
5567
+ });
5568
+ api.registerCommand({
5569
+ name: "github webhook receive",
5570
+ description: "Verify and durably deduplicate an explicitly supplied issues webhook. Reads PM_GITHUB_WEBHOOK_SECRET; never stores payloads or changes items.",
5571
+ arguments: [{ name: "owner/repo", required: true, description: "Expected repository boundary" }],
5572
+ flags: [
5573
+ { long: "--payload", value_name: "file", description: "File containing the exact original request body bytes" },
5574
+ { long: "--delivery", value_name: "id", description: "Original X-GitHub-Delivery header" },
5575
+ { long: "--event", value_name: "type", description: "Original X-GitHub-Event header (issues)" },
5576
+ { long: "--signature", value_name: "sha256=...", description: "Original X-Hub-Signature-256 header" },
5577
+ ],
5578
+ async run(ctx) {
5579
+ const { payload, delivery, event, signature } = ctx.options;
5580
+ if (!ctx.args[0] || typeof payload !== "string" || typeof delivery !== "string" || typeof event !== "string" || typeof signature !== "string") {
5581
+ throw new WebhookRefusal("invalid_options", "Supply owner/repo and --payload, --delivery, --event, --signature; set PM_GITHUB_WEBHOOK_SECRET securely.");
5582
+ }
5583
+ let bytes;
5584
+ try {
5585
+ if (fs.statSync(payload).size > 25 * 1024 * 1024)
5586
+ throw new Error("oversized");
5587
+ bytes = fs.readFileSync(payload);
5588
+ }
5589
+ catch {
5590
+ throw new WebhookRefusal("invalid_event", "Supply a readable original payload file of at most 25 MiB.");
5591
+ }
5592
+ return acceptGithubWebhook(ctx.pm_root, { repo: ctx.args[0], delivery, event, signature,
5593
+ payload: bytes, secret: process.env.PM_GITHUB_WEBHOOK_SECRET ?? "" });
5594
+ },
5595
+ });
5596
+ api.registerCommand({
5597
+ name: "github webhook reconcile",
5598
+ description: "Reconcile pending deliveries against current GitHub state into compact proposals, without changing items. Poll with github refresh for apply or recovery.",
5599
+ arguments: [{ name: "owner/repo", required: true, description: "Expected repository boundary" }],
5600
+ flags: [{ long: "--limit", value_name: "N", description: "Maximum deliveries per batch, 1 to 100 (default 20)" }],
5601
+ async run(ctx) {
5602
+ return reconcileGithubWebhooks(ctx.pm_root, ctx.args[0] ?? "", ctx.options.limit === undefined ? 20 : Number(ctx.options.limit));
5603
+ },
5604
+ });
5190
5605
  // command — legacy `pm gh-issues import <owner/repo>` (delegates to core)
5191
5606
  // -----------------------------------------------------------------------
5192
5607
  api.registerCommand({
@@ -5311,6 +5726,7 @@ export default defineExtension({
5311
5726
  { long: "--repo", value_name: "owner/repo", description: "Repository (or PM_GITHUB_REPO)" },
5312
5727
  { long: "--pr", value_name: "n", repeatable: true, description: "Select a PR number (repeatable; conflicts with --since)" },
5313
5728
  { long: "--since", value_name: "ISO date", description: "Select PRs merged at or after this ISO date/time" },
5729
+ { long: "--required-checks", value_name: "contexts", description: "One required context per line when protection metadata is unavailable (spaces preserved; blank lines ignored)" },
5314
5730
  { long: "--skip", value_name: "id", repeatable: true, description: "Never close this item (repeatable)" },
5315
5731
  { long: "--apply", description: "Close eligible items (default: dry-run)" },
5316
5732
  { long: "--dry-run", description: "Preview only; overrides --apply" },