pm-github 2026.10.27 → 2026.10.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +52 -39
  2. package/README.md +75 -3
  3. package/dist/closeout.d.ts.map +1 -1
  4. package/dist/closeout.js +71 -30
  5. package/dist/closeout.js.map +1 -1
  6. package/dist/discussion-schema.d.ts +110 -0
  7. package/dist/discussion-schema.d.ts.map +1 -0
  8. package/dist/discussion-schema.js +41 -0
  9. package/dist/discussion-schema.js.map +1 -0
  10. package/dist/discussions.d.ts +26 -0
  11. package/dist/discussions.d.ts.map +1 -0
  12. package/dist/discussions.js +215 -0
  13. package/dist/discussions.js.map +1 -0
  14. package/dist/gate.d.ts.map +1 -1
  15. package/dist/gate.js +10 -4
  16. package/dist/gate.js.map +1 -1
  17. package/dist/identity.d.ts.map +1 -1
  18. package/dist/identity.js +2 -1
  19. package/dist/identity.js.map +1 -1
  20. package/dist/import-fidelity.d.ts +8 -3
  21. package/dist/import-fidelity.d.ts.map +1 -1
  22. package/dist/import-fidelity.js +39 -10
  23. package/dist/import-fidelity.js.map +1 -1
  24. package/dist/import-tags.d.ts +15 -0
  25. package/dist/import-tags.d.ts.map +1 -0
  26. package/dist/import-tags.js +35 -0
  27. package/dist/import-tags.js.map +1 -0
  28. package/dist/index.d.ts +52 -41
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +188 -89
  31. package/dist/index.js.map +1 -1
  32. package/dist/issue-forms.d.ts +1 -1
  33. package/dist/issue-forms.d.ts.map +1 -1
  34. package/dist/issue-forms.js +8 -5
  35. package/dist/issue-forms.js.map +1 -1
  36. package/dist/label-names.d.ts +6 -0
  37. package/dist/label-names.d.ts.map +1 -0
  38. package/dist/label-names.js +15 -0
  39. package/dist/label-names.js.map +1 -0
  40. package/dist/labels.d.ts +161 -0
  41. package/dist/labels.d.ts.map +1 -0
  42. package/dist/labels.js +235 -0
  43. package/dist/labels.js.map +1 -0
  44. package/dist/projects.d.ts +6 -4
  45. package/dist/projects.d.ts.map +1 -1
  46. package/dist/projects.js +25 -24
  47. package/dist/projects.js.map +1 -1
  48. package/dist/refresh.d.ts +2 -1
  49. package/dist/refresh.d.ts.map +1 -1
  50. package/dist/refresh.js +19 -9
  51. package/dist/refresh.js.map +1 -1
  52. package/dist/webhooks.d.ts.map +1 -1
  53. package/dist/webhooks.js +5 -2
  54. package/dist/webhooks.js.map +1 -1
  55. package/dist/workflow-diagnostic.d.ts +9 -0
  56. package/dist/workflow-diagnostic.d.ts.map +1 -0
  57. package/dist/workflow-diagnostic.js +62 -0
  58. package/dist/workflow-diagnostic.js.map +1 -0
  59. package/dist/workspace.d.ts +18 -0
  60. package/dist/workspace.d.ts.map +1 -0
  61. package/dist/workspace.js +39 -0
  62. package/dist/workspace.js.map +1 -0
  63. package/docs/discussion-capture-example.json +14 -0
  64. package/docs/discussion-capture.md +44 -0
  65. package/manifest.json +2 -2
  66. package/package.json +15 -10
package/dist/index.js CHANGED
@@ -1,10 +1,19 @@
1
+ import { readGithubWorkspace, canonicalGithubStatus, githubStatusKind, githubStatusIsTerminal } from "./workspace.js";
1
2
  import { ownValue } from "./own-record.js";
3
+ import { uniqueGithubLabelNames } from "./label-names.js";
4
+ import { githubLabelsToTags, importTagArguments, importTagOptions, importTagValues, nativeTagAdditions } from "./import-tags.js";
5
+ export { githubLabelsToTags } from "./import-tags.js";
2
6
  import { CloseoutUsageError, compactCloseoutReport, runCloseout } from "./closeout.js";
7
+ import { readWorkflowFailure } from "./workflow-diagnostic.js";
3
8
  import { compileIssueForm, completeIssueForm, readRepositoryIssueForm, refuseIssueForm } from "./issue-forms.js";
4
9
  export { compileIssueForm, completeIssueForm, readRepositoryIssueForm } from "./issue-forms.js";
5
- import { AmbiguousCitationError, buildImportFidelity, fetchImportTimeline, importFidelityFields, matchImportedIssue, persistImportFidelity, readImportReleases, selectImportContextIndex, validateImportLifetime, } from "./import-fidelity.js";
10
+ import { AmbiguousCitationError, buildImportFidelity, fetchImportTimeline, importFidelityFields, matchImportedIssue, normalizeImportValue, persistImportFidelity, readImportReleases, selectImportContextIndex, serializeImportTags, validateImportLifetime, } from "./import-fidelity.js";
6
11
  import { refreshChanges, runRefresh } from "./refresh.js";
7
12
  import { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.js";
13
+ import { planGithubLabelMigration, readLabelMigrationJson } from "./labels.js";
14
+ import { captureGithubDiscussion, refuseDiscussion } from "./discussions.js";
15
+ export { captureGithubDiscussion, readGithubDiscussionComment, validateDiscussionCaptureReport } from "./discussions.js";
16
+ export { planGithubLabelMigration, readGithubLabelCatalog, validateGithubLabelMigrationReport } from "./labels.js";
8
17
  export { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.js";
9
18
  import { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.js";
10
19
  export { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.js";
@@ -14,7 +23,7 @@ import crypto from "node:crypto";
14
23
  import fs from "node:fs";
15
24
  import path from "node:path";
16
25
  import { spawnSync } from "node:child_process";
17
- 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";
26
+ import { createPmCliExpectedError, comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, isExternalDependencyReference, listAllComplete, certifyCompleteListResult, listAllItemMetadata as sdkListAllItemMetadata, normalizeItemId as sdkNormalizeItemId, readSettings as sdkReadSettings, resolvePmRoot as sdkResolvePmRoot, toImportTags, writeFileAtomic, getHistoryPath as sdkGetHistoryPath, readHistorySnapshot as sdkReadHistorySnapshot, } from "@unbrained/pm-cli/sdk";
18
27
  import { collectNewOrderingCycleWarnings as sdkCollectNewOrderingCycleWarnings } from "@unbrained/pm-cli/sdk/graph";
19
28
  import { GateInputError, formatGateReport, runTrackerGate, resolvePmDataDir, } from "./gate.js";
20
29
  export { resolvePmDataDir } from "./gate.js";
@@ -324,7 +333,7 @@ async function request(method, url, token, payload, maxRetries = 4) {
324
333
  attempt++;
325
334
  continue;
326
335
  }
327
- throw new Error(`GitHub API returned HTTP ${res.status}`);
336
+ throw new Error(`GitHub API returned HTTP ${res.status} for ${method} ${new URL(url).pathname}`);
328
337
  }
329
338
  }
330
339
  /** Fetch a single GitHub REST endpoint via GET and return the decoded
@@ -423,18 +432,7 @@ export function parseLabelMap(options, ...keys) {
423
432
  // map to the same GitHub label are collapsed (GitHub rejects duplicate labels
424
433
  // on an issue with a 422), preserving first-seen order.
425
434
  export function applyLabelMap(labels, labelMap) {
426
- if (!labelMap || labelMap.size === 0)
427
- return labels;
428
- const out = [];
429
- const seen = new Set();
430
- for (const label of labels) {
431
- const mapped = labelMap.get(label) ?? label;
432
- if (seen.has(mapped))
433
- continue;
434
- seen.add(mapped);
435
- out.push(mapped);
436
- }
437
- return out;
435
+ return uniqueGithubLabelNames(labels.map(label => labelMap?.get(label) ?? label));
438
436
  }
439
437
  // Parse one or more CSV-like option values into a deduplicated string list.
440
438
  // Accepts a single string ("a,b") or repeated values (["a,b", "c"]).
@@ -964,29 +962,30 @@ export function deriveAtomicItemId(repo, issueNumber, idPrefix, normalizeItemId)
964
962
  return normalizeItemId(`github-${repoToken}-${issueNumber}`, idPrefix);
965
963
  }
966
964
  /** Map one rendered import entry to its reversible SDK mutation sequence. */
967
- export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, updateOnly = false) {
965
+ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, updateOnly = false, statuses) {
968
966
  if (updateOnly && !entry.match?.id)
969
967
  throw new CommandError("Refresh refuses an unlinked target; no item may be created.");
970
- const changed = updateOnly ? new Set(refreshChanges(entry).map(change => change.field)) : undefined;
968
+ const closing = canonicalGithubStatus(entry.status, statuses) === canonicalGithubStatus("closed", statuses);
969
+ const changed = updateOnly ? new Set(refreshChanges(entry, statuses).map(change => change.field)) : undefined;
971
970
  const audit = updateOnly ? { comment: [`Refreshed linked GitHub issue ${repo.toLowerCase()}#${entry.issueNumber}`] } : {};
972
971
  if (entry.linkOnly && entry.match?.id) {
973
- return { itemId: entry.match.id, mutations: [{ op: "update", id: entry.match.id, options: { ...(!changed || changed.has("tags") ? { addTags: entry.tags } : {}), ...audit } }] };
972
+ return { itemId: entry.match.id, mutations: [{ op: "update", id: entry.match.id, options: { ...(!changed || changed.has("tags") ? importTagOptions(entry) : {}), ...audit } }] };
974
973
  }
975
974
  const sharedOptions = {
976
975
  title: entry.title,
977
976
  type: entry.itemType,
978
977
  description: entry.description,
979
978
  body: entry.body,
980
- tags: entry.tags.join(","),
979
+ ...importTagOptions(entry),
981
980
  ...(entry.assignee ? { assignee: entry.assignee } : {}),
982
981
  ...(entry.milestone ? { sprint: entry.milestone } : {}),
983
- ...(entry.status === "canceled" ? { closeReason: entry.fidelity?.github_close_reason ?? `GitHub issue #${entry.issueNumber} closed` } : {}),
982
+ ...((!closing && githubStatusIsTerminal(entry.status, statuses)) ? { closeReason: entry.fidelity?.github_close_reason ?? `GitHub issue #${entry.issueNumber} closed` } : {}),
984
983
  };
985
984
  // Explicit terminal reconciliation also handles canceled -> completed. The
986
985
  // supported force option permits that source-authoritative repeat closure;
987
986
  // finalization restores the real dates before the transaction commits.
988
- const statusOptions = entry.status === "closed"
989
- ? entry.fidelity ? { status: "closed", closeReason: entry.fidelity.github_close_reason, force: true } : {}
987
+ const statusOptions = closing
988
+ ? entry.fidelity ? { status: entry.status, closeReason: entry.fidelity.github_close_reason, force: true } : {}
990
989
  : { status: entry.status };
991
990
  const managedItemId = deriveAtomicItemId(repo, entry.issueNumber, idPrefix, normalizeItemId);
992
991
  // A missing match and a match at our deterministic external-key id use the
@@ -996,7 +995,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
996
995
  // create step treats an existing stable id as already applied; update then
997
996
  // makes later content-bearing transactions refresh the item normally.
998
997
  if (!updateOnly && (!entry.match?.id || entry.match.id === managedItemId)) {
999
- const createStatus = entry.status === "closed" ? "open" : entry.status;
998
+ const createStatus = closing ? "open" : entry.status;
1000
999
  const mutations = [{
1001
1000
  op: "create",
1002
1001
  id: managedItemId,
@@ -1009,7 +1008,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
1009
1008
  ...statusOptions,
1010
1009
  },
1011
1010
  }];
1012
- if (entry.status === "closed" && !entry.fidelity) {
1011
+ if (closing && !entry.fidelity) {
1013
1012
  mutations.push({
1014
1013
  op: "close",
1015
1014
  id: managedItemId,
@@ -1027,7 +1026,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
1027
1026
  const updateOptions = {
1028
1027
  ...(updateOnly ? {
1029
1028
  ...(changed.has("title") ? { title: entry.title } : {}),
1030
- ...(changed.has("tags") ? { tags: entry.tags.join(",") } : {}),
1029
+ ...(changed.has("tags") ? importTagOptions(entry) : {}),
1031
1030
  ...(changed.has("status") && sharedOptions.closeReason !== undefined ? { closeReason: sharedOptions.closeReason } : {}),
1032
1031
  } : sharedOptions), ...(!changed || changed.has("status") ? statusOptions : {}), ...audit,
1033
1032
  };
@@ -1036,7 +1035,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
1036
1035
  id: itemId,
1037
1036
  options: updateOptions,
1038
1037
  }];
1039
- if (entry.status === "closed" && !entry.fidelity) {
1038
+ if (closing && !entry.fidelity) {
1040
1039
  mutations.push({
1041
1040
  op: "close",
1042
1041
  id: itemId,
@@ -1052,13 +1051,13 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
1052
1051
  * The importer's desired-state and compensation writes leave retries stable;
1053
1052
  * local edits and later state cycles invalidate completed journals.
1054
1053
  */
1055
- async function importContextVersions(pmRoot, entries) {
1054
+ async function importContextVersions(pmRoot, entries, statuses) {
1056
1055
  const versions = [];
1057
1056
  for (const entry of entries) {
1058
1057
  if (!entry.fidelity || !entry.match?.id)
1059
1058
  continue;
1060
1059
  const snapshot = await sdkReadHistorySnapshot(sdkGetHistoryPath(pmRoot, entry.match.id), entry.match.id);
1061
- const context = snapshot.entries[selectImportContextIndex(snapshot.entries, entry.status)];
1060
+ const context = snapshot.entries[selectImportContextIndex(snapshot.entries, entry.status, statuses)];
1062
1061
  if (context)
1063
1062
  versions.push(`${entry.match.id}:${crypto.createHash("sha256").update(JSON.stringify(context)).digest("hex")}`);
1064
1063
  }
@@ -1203,6 +1202,7 @@ async function commitGithubImportTransaction(config) {
1203
1202
  export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1204
1203
  pmRoot = sdkResolvePmRoot(process.cwd(), pmRoot);
1205
1204
  const { commitItemMutations: commit, normalizeItemId, readSettings, } = await resolveAtomicSdkFunctions(opts);
1205
+ const workspace = await readGithubWorkspace(pmRoot);
1206
1206
  let idPrefix = "pm-";
1207
1207
  try {
1208
1208
  const settings = await readSettings(pmRoot);
@@ -1219,11 +1219,11 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1219
1219
  // by the stable GitHub issue number so a retry whose API page/order changed
1220
1220
  // supplies the exact same plan as well as the same transaction id.
1221
1221
  for (const entry of [...entries].sort((a, b) => a.issueNumber - b.issueNumber)) {
1222
- const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, opts.updateOnly);
1222
+ const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, opts.updateOnly, workspace.statuses);
1223
1223
  itemIds.set(entry.issueNumber, planned.itemId);
1224
1224
  mutations.push(...planned.mutations);
1225
1225
  }
1226
- const transactionId = deriveAtomicTransactionId(repo, entries, mutations, await importContextVersions(pmRoot, entries));
1226
+ const transactionId = deriveAtomicTransactionId(repo, entries, mutations, await importContextVersions(pmRoot, entries, workspace.statuses));
1227
1227
  // Update-only transactions derive their steps from the CURRENT item state,
1228
1228
  // so a retry after a partial apply would fingerprint a different plan and
1229
1229
  // orphan the journal behind a fresh transaction id. Persist the exact plan
@@ -2411,7 +2411,7 @@ export async function prepareGithubImport(issue, repo, opts, token, match, fetch
2411
2411
  const timeline = await fidelityContext.timeline(issue, repo, token);
2412
2412
  const fidelity = buildImportFidelity(issue, fidelityContext.releases, timeline);
2413
2413
  const targetStatus = mapState(issue.state, issue.state_reason);
2414
- const conflict = match?.status && match.status !== targetStatus && (["closed", "canceled"].includes(match.status) || issue.state === "closed")
2414
+ const conflict = match?.status && canonicalGithubStatus(match.status, fidelityContext.statuses) !== canonicalGithubStatus(targetStatus, fidelityContext.statuses) && (githubStatusIsTerminal(match.status, fidelityContext.statuses) || issue.state === "closed")
2415
2415
  ? { local: match.status, github: issue.state, rule: identity.linkOnly ? "preserve-provider-owner" : "github-source-wins" } : undefined;
2416
2416
  const kind = issue.pull_request ? "PR" : "issue";
2417
2417
  const labels = issue.labels.map((label) => label.name).filter(Boolean);
@@ -2444,7 +2444,7 @@ export async function prepareGithubImport(issue, repo, opts, token, match, fetch
2444
2444
  (issue.state_reason ? ` · state reason ${issue.state_reason}` : "") +
2445
2445
  ` · created ${issue.created_at} · updated ${issue.updated_at}`,
2446
2446
  body: writeCommentsToBody ? composeBody(issue, comments) : (issue.body || ""),
2447
- tags: [...new Set([...(identity.linkOnly || identity.via === "citation" ? match.tags ?? [] : []), ...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])])],
2447
+ tags: toImportTags([...(identity.linkOnly || identity.via === "citation" ? match.tags ?? [] : []), ...githubLabelsToTags([...labels, tag, ...(ghAuthorTag ? [ghAuthorTag] : [])])]),
2448
2448
  assignee: issue.assignee?.login,
2449
2449
  milestone: issue.milestone?.title,
2450
2450
  comments,
@@ -2457,6 +2457,16 @@ export async function prepareGithubImport(issue, repo, opts, token, match, fetch
2457
2457
  match,
2458
2458
  };
2459
2459
  }
2460
+ /** Recognize a complete source projection without treating the SDK mutation clock as content. */
2461
+ export function importEntryUnchanged(entry, types, statuses) {
2462
+ if (!entry.match?.id || !entry.fidelity || refreshChanges(entry, statuses).length !== 0)
2463
+ return false;
2464
+ if (entry.linkOnly)
2465
+ return true;
2466
+ const fields = { type: entry.itemType, description: entry.description, body: entry.body,
2467
+ ...(entry.assignee ? { assignee: entry.assignee } : {}), ...(entry.milestone ? { sprint: entry.milestone } : {}) };
2468
+ return Object.entries(fields).every(([field, value]) => normalizeImportValue(field, ownValue(entry.match, field) ?? "", types, statuses) === normalizeImportValue(field, value, types, statuses));
2469
+ }
2460
2470
  /**
2461
2471
  * Verify that a gated import's plan accounts for every fetched issue exactly once.
2462
2472
  *
@@ -2534,11 +2544,11 @@ export function verifyImportIdempotency(existing, prepared, repo) {
2534
2544
  for (const entry of prepared) {
2535
2545
  if (entry.match?.id) {
2536
2546
  matchedIds.push(entry.match.id);
2537
- if (entry.identity?.via === "citation" && !entry.tags.includes(provenanceTag(repo, entry.issueNumber)))
2547
+ if (entry.identity?.via === "citation" && !importTagValues(entry).includes(provenanceTag(repo, entry.issueNumber)))
2538
2548
  untaggedNewEntries++;
2539
2549
  continue;
2540
2550
  }
2541
- if (!entry.tags.includes(provenanceTag(repo, entry.issueNumber)))
2551
+ if (!importTagValues(entry).includes(provenanceTag(repo, entry.issueNumber)))
2542
2552
  untaggedNewEntries++;
2543
2553
  }
2544
2554
  const conflictingTargets = matchedIds.length - new Set(matchedIds).size;
@@ -2628,11 +2638,11 @@ function gateImportWrites(pmRoot) {
2628
2638
  * throws {@link CommandError} (with a semantic exit code) on failure.
2629
2639
  *
2630
2640
  * @param repoArg - The `owner/repo` to import from.
2631
- * @param pmRoot - Workspace root or pm data dir.
2641
+ * @param pmPath - Workspace root or pm data dir.
2632
2642
  * @param opts - Normalized import options.
2633
2643
  * @param deps - Injectable collaborators (token resolver, fetchers, etc.) for tests.
2634
2644
  */
2635
- export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2645
+ export async function runImport(repoArg, pmPath, opts, dependencies = {}) {
2636
2646
  if (!repoArg || !repoArg.includes("/")) {
2637
2647
  throw new CommandError("Usage: pm github import <owner/repo> [--all|--state open|closed|all] " +
2638
2648
  "[--labels bug,enhancement] [--since <iso>] [--assignee <login>] " +
@@ -2674,7 +2684,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2674
2684
  console.error("No issues found.");
2675
2685
  // Even a no-op gated import must prove the workspace is gateable (a Git work
2676
2686
  // tree the scanner can read), so the empty-change gate scan runs here too.
2677
- const emptyGateReceipt = opts.gate && !opts.dryRun ? gateImportWrites(pmRoot) : undefined;
2687
+ const emptyGateReceipt = opts.gate && !opts.dryRun ? gateImportWrites(pmPath) : undefined;
2678
2688
  if (opts.dryRun && (opts.atomic || missingIssues.length > 0)) {
2679
2689
  return {
2680
2690
  dryRun: true,
@@ -2694,6 +2704,8 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2694
2704
  // reported "would import N, skip 0" where the real run performs updates for
2695
2705
  // already-linked issues. A preview that overstates creates reads as "this will
2696
2706
  // duplicate my whole tracker" and is the one thing --dry-run exists to rule out.
2707
+ const workspace = await readGithubWorkspace(pmPath);
2708
+ const pmRoot = workspace.pmRoot;
2697
2709
  /** Read the complete raw corpus in-process, retaining all provider metadata and bodies. */
2698
2710
  const readImportItems = async () => {
2699
2711
  if (dependencies.readItems)
@@ -2703,7 +2715,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2703
2715
  };
2704
2716
  const existingItems = await readImportItems();
2705
2717
  const existing = indexByProvenance(existingItems);
2706
- const fidelityContext = { items: existingItems, releases: readImportReleases(pmRoot),
2718
+ const fidelityContext = { statuses: workspace.statuses, items: existingItems, releases: readImportReleases(pmRoot),
2707
2719
  timeline: dependencies.fetchIssueTimeline ?? (dependencies.fetchIssues ? async () => [] : fetchImportTimeline) };
2708
2720
  let imported = 0;
2709
2721
  let updated = 0;
@@ -2847,7 +2859,14 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2847
2859
  ...fetchFields,
2848
2860
  };
2849
2861
  }
2850
- const result = await (dependencies.commitAtomic ?? importGithubAtomic)(pmRoot, repo, prepared);
2862
+ // Mixed create/update batches can select a fresh journal after the first
2863
+ // apply. Replaying an otherwise identical SDK UPDATE advances updated_at.
2864
+ // Never hide an interrupted journal behind the unchanged-state shortcut.
2865
+ const typeNames = new Map(Object.entries(workspace.types.alias_to_type));
2866
+ const unchanged = prepared.every(entry => importEntryUnchanged(entry, typeNames, workspace.statuses)) && (await listUnfinishedGithubImports(pmRoot)).length === 0;
2867
+ const result = unchanged ? { transactionId: undefined, recovered: false, recoveredItems: undefined, imported: 0, updated: 0,
2868
+ itemIds: new Map(prepared.map(entry => [entry.issueNumber, entry.match.id])) }
2869
+ : await (dependencies.commitAtomic ?? importGithubAtomic)(pmRoot, repo, prepared);
2851
2870
  if (opts.gate)
2852
2871
  verifyImportedProvenance(await readImportItems(), prepared, repo);
2853
2872
  // Planned native comments are part of the verified plan: a contended lock,
@@ -2867,7 +2886,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2867
2886
  }
2868
2887
  }
2869
2888
  failClosedOnPlanDivergence(opts.gate, skipped, skipped, commentWriteFailures);
2870
- if (result.recovered) {
2889
+ if (unchanged)
2890
+ console.error("Atomic import unchanged; no item transaction was needed.");
2891
+ else if (result.recovered) {
2871
2892
  console.error(`Atomic import recovered transaction ${result.transactionId} covering ${result.recoveredItems ?? prepared.length} item(s).`);
2872
2893
  }
2873
2894
  else {
@@ -2884,6 +2905,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2884
2905
  const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined;
2885
2906
  return {
2886
2907
  transactionId: result.transactionId,
2908
+ ...(unchanged ? { unchanged: true } : {}),
2887
2909
  recovered: result.recovered,
2888
2910
  imported: result.imported,
2889
2911
  updated: result.updated - (result.recovered ? 0 : adoptionCount),
@@ -2904,7 +2926,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2904
2926
  continue;
2905
2927
  }
2906
2928
  const labels = issue.labels.map((l) => l.name).filter(Boolean);
2907
- const { title, status, assignee, milestone, match, description, body, tags, comments, syncAnnotations, } = prepared;
2929
+ const { title, status, assignee, milestone, match, description, body, comments, syncAnnotations, } = prepared;
2908
2930
  /**
2909
2931
  * Build the `pm close` argv for this issue's pm item.
2910
2932
  *
@@ -2933,7 +2955,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2933
2955
  }
2934
2956
  if (prepared.linkOnly) {
2935
2957
  const ownerId = match.id;
2936
- const linked = pmRun(["--path", pmRoot, "update", ownerId, "--add-tags", tags.join(",")]);
2958
+ const nativeTags = importTagArguments(prepared);
2959
+ const linked = nativeTags.length
2960
+ ? pmRun(["--path", pmRoot, "update", ownerId, ...nativeTags]) : { ok: true };
2937
2961
  if (!linked.ok)
2938
2962
  throw new CommandError(`Could not link GitHub issue #${issue.number} to its existing owner.`);
2939
2963
  await persistImportFidelity(pmRoot, ownerId, prepared.fidelity, true);
@@ -2946,12 +2970,13 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2946
2970
  if (match?.id) {
2947
2971
  // Idempotent update — never duplicate. Status transitions go through the
2948
2972
  // proper command (close requires a reason; reopen via update --status).
2973
+ const nativeTags = importTagArguments(prepared);
2949
2974
  const updArgs = [
2950
2975
  "--path", pmRoot, "update", match.id,
2951
2976
  "--title", title,
2952
2977
  "--description", description,
2953
2978
  "--body", body,
2954
- "--tags", tags.join(","),
2979
+ ...nativeTags,
2955
2980
  "--message", `Re-imported from GitHub #${issue.number}`,
2956
2981
  ];
2957
2982
  if (assignee)
@@ -2965,22 +2990,22 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2965
2990
  continue;
2966
2991
  }
2967
2992
  // Reconcile status separately.
2968
- if (status === "canceled" && match.status !== "canceled") {
2993
+ if (canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("canceled", workspace.statuses) && canonicalGithubStatus(match.status, workspace.statuses) !== canonicalGithubStatus("canceled", workspace.statuses)) {
2969
2994
  const cancel = pmRun(["--path", pmRoot, "update", match.id, "--status", "canceled", "--close-reason", prepared.fidelity.github_close_reason]);
2970
2995
  if (!cancel.ok)
2971
2996
  throw new CommandError(`Could not cancel GitHub issue #${issue.number}.`);
2972
2997
  }
2973
- if (status === "closed" && match.status !== "closed") {
2998
+ if (canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("closed", workspace.statuses) && canonicalGithubStatus(match.status, workspace.statuses) !== canonicalGithubStatus("closed", workspace.statuses)) {
2974
2999
  // Close through `pm close` so the reason is real provenance; pass the
2975
3000
  // source completion time as --completed-at when GitHub recorded one.
2976
- const close = pmRun([...githubCloseArgs(match.id), ...(match.status === "canceled" ? ["--force"] : [])]);
3001
+ const close = pmRun([...githubCloseArgs(match.id), ...(githubStatusIsTerminal(match.status, workspace.statuses) ? ["--force"] : [])]);
2977
3002
  if (!close.ok) {
2978
3003
  console.error(`#${issue.number}: close reconciliation failed — ${close.stderr}`);
2979
3004
  skipped++;
2980
3005
  continue;
2981
3006
  }
2982
3007
  }
2983
- else if (status === "open" && (match.status === "closed" || match.status === "canceled")) {
3008
+ else if (canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("open", workspace.statuses) && githubStatusIsTerminal(match.status, workspace.statuses)) {
2984
3009
  const reopen = pmRun(["--path", pmRoot, "update", match.id, "--status", "open", "--message", `GitHub issue #${issue.number} reopened`]);
2985
3010
  if (!reopen.ok) {
2986
3011
  console.error(`#${issue.number}: reopen reconciliation failed — ${reopen.stderr}`);
@@ -3002,8 +3027,8 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
3002
3027
  // `require_close_reason` rejects `pm create --status closed`. Create the
3003
3028
  // item open, then close it through `pm close` with the source's real
3004
3029
  // completion timestamp (`closed_at`) as --completed-at provenance.
3005
- const createStatus = status === "closed" ? "open" : status;
3006
- const mustClose = status === "closed";
3030
+ const createStatus = canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("closed", workspace.statuses) ? "open" : status;
3031
+ const mustClose = canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("closed", workspace.statuses);
3007
3032
  const createArgs = [
3008
3033
  "--path", pmRoot, "create",
3009
3034
  "--title", title,
@@ -3011,10 +3036,10 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
3011
3036
  "--status", createStatus,
3012
3037
  "--description", description,
3013
3038
  "--body", body,
3014
- "--tags", tags.join(","),
3039
+ "--tags", importTagOptions(prepared).tags,
3015
3040
  "--message", `Imported from GitHub #${issue.number}`,
3016
3041
  ];
3017
- if (status === "canceled")
3042
+ if (canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("canceled", workspace.statuses))
3018
3043
  createArgs.push("--close-reason", prepared.fidelity.github_close_reason);
3019
3044
  if (assignee)
3020
3045
  createArgs.push("--assignee", assignee);
@@ -3166,8 +3191,8 @@ function failClosedOnPlanDivergence(gate, plannedSkipped, actualSkipped, comment
3166
3191
  // ---------------------------------------------------------------------------
3167
3192
  // Sync core — push pm status changes back to GitHub (close / reopen)
3168
3193
  // ---------------------------------------------------------------------------
3169
- function pmStatusToGithubState(status) {
3170
- return status === "closed" || status === "canceled" ? "closed" : "open";
3194
+ function pmStatusToGithubState(status, statuses) {
3195
+ return githubStatusIsTerminal(status, statuses) ? "closed" : "open";
3171
3196
  }
3172
3197
  /**
3173
3198
  * Require every requested item before a remote sync or export can begin.
@@ -3185,7 +3210,7 @@ function requireRequestedItems(items, requested) {
3185
3210
  }
3186
3211
  // Build the pm → GitHub issue sync plan: for each pm item linked to `repo`, emit
3187
3212
  // a create-or-update entry keyed by the issue's provenance tag.
3188
- export function planSync(items, repo) {
3213
+ export function planSync(items, repo, statuses) {
3189
3214
  const plan = [];
3190
3215
  const repoLc = repo.toLowerCase();
3191
3216
  for (const item of items) {
@@ -3195,7 +3220,7 @@ export function planSync(items, repo) {
3195
3220
  const p = parseProvenanceTag(tag);
3196
3221
  if (!p || p.repo !== repoLc)
3197
3222
  continue;
3198
- const desired = pmStatusToGithubState(item.status);
3223
+ const desired = pmStatusToGithubState(item.status, statuses);
3199
3224
  plan.push({
3200
3225
  id: item.id,
3201
3226
  number: p.number,
@@ -3230,7 +3255,8 @@ async function runSync(ctx) {
3230
3255
  "(set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`). Use --dry-run to preview without a token.", EXIT_CODE.USAGE);
3231
3256
  }
3232
3257
  const selected = requireRequestedItems(readPmItems(ctx.pm_root), scopedIds);
3233
- const plan = planSync(selected, repo);
3258
+ const workspace = await readGithubWorkspace(ctx.pm_root);
3259
+ const plan = planSync(selected, repo, workspace.statuses);
3234
3260
  if (plan.length === 0) {
3235
3261
  const scopeNote = scopedIds.length > 0 ? ` from --ids (${scopedIds.join(", ")})` : "";
3236
3262
  console.error(`No pm items${scopeNote} linked to ${repo} ` +
@@ -3292,7 +3318,7 @@ async function runSync(ctx) {
3292
3318
  }
3293
3319
  // Convert a pm item into the GitHub issue create/update payload, dropping
3294
3320
  // internal provenance tags from labels and applying the optional label map.
3295
- function itemToGithubPayload(item, labelMap) {
3321
+ function itemToGithubPayload(item, labelMap, statuses) {
3296
3322
  // Drop our internal provenance tags from exported labels, then apply any
3297
3323
  // user-supplied label mapping (pm tag → GitHub label). Both issue provenance
3298
3324
  // (`gh:owner/repo#N`) and project provenance (`gh-project:owner/number#itemId`)
@@ -3303,18 +3329,18 @@ function itemToGithubPayload(item, labelMap) {
3303
3329
  title: item.title ?? "(untitled)",
3304
3330
  body: item.body || item.description || "",
3305
3331
  labels: applyLabelMap(labels, labelMap),
3306
- state: item.status === "closed" || item.status === "canceled" ? "closed" : "open",
3332
+ state: pmStatusToGithubState(item.status, statuses),
3307
3333
  };
3308
3334
  }
3309
3335
  // Build the create/update plan. `repo` (lowercased) decides which provenance
3310
3336
  // tags count as an "already exported to THIS repo" link → update; everything
3311
3337
  // else is a create. Pure + side-effect free so it can be unit-tested and
3312
3338
  // printed verbatim in --dry-run.
3313
- export function buildExportPlan(items, repo, labelMap) {
3339
+ export function buildExportPlan(items, repo, labelMap, statuses) {
3314
3340
  const repoLc = repo?.toLowerCase();
3315
3341
  const plan = [];
3316
3342
  for (const item of items) {
3317
- const payload = itemToGithubPayload(item, labelMap);
3343
+ const payload = itemToGithubPayload(item, labelMap, statuses);
3318
3344
  let number;
3319
3345
  if (repoLc) {
3320
3346
  for (const tag of item.tags ?? []) {
@@ -3368,11 +3394,11 @@ export async function applyExportPlan(plan, repo, token, requestFn) {
3368
3394
  throw new Error("update entry is missing its GitHub issue number");
3369
3395
  }
3370
3396
  if (entry.action === "update" && entry.number !== undefined) {
3371
- await requestFn("PATCH", `${githubApiBase()}/repos/${repo}/issues/${entry.number}`, token, JSON.stringify({ title: p.title, body: p.body, labels: p.labels, state: p.state }));
3397
+ await requestFn("PATCH", `${githubApiBase()}/repos/${repo}/issues/${entry.number}`, token, JSON.stringify({ title: p.title, body: p.body, labels: uniqueGithubLabelNames(p.labels), state: p.state }));
3372
3398
  updated++;
3373
3399
  }
3374
3400
  else {
3375
- await requestFn("POST", `${githubApiBase()}/repos/${repo}/issues`, token, JSON.stringify({ title: p.title, body: p.body, labels: p.labels }));
3401
+ await requestFn("POST", `${githubApiBase()}/repos/${repo}/issues`, token, JSON.stringify({ title: p.title, body: p.body, labels: uniqueGithubLabelNames(p.labels) }));
3376
3402
  created++;
3377
3403
  }
3378
3404
  }
@@ -3476,10 +3502,11 @@ async function runExport(ctx) {
3476
3502
  else if (["form-ref", "formRef", "form-answers", "formAnswers", "form-bindings", "formBindings"].some(flag => optionProvided(options, flag))) {
3477
3503
  refuseIssueForm("invalid_form_input", [{ field: "form", reason: "selection", remedy: "Pass --form with --repo and --form-ref to enforce an issue-form contract." }]);
3478
3504
  }
3479
- const plan = buildExportPlan(selected, repo, labelMap);
3505
+ const workspace = await readGithubWorkspace(ctx.pm_root);
3506
+ const plan = buildExportPlan(selected, repo, labelMap, workspace.statuses);
3480
3507
  for (const [index, completion] of completions.entries()) {
3481
3508
  plan[index].payload.body = completion.body;
3482
- plan[index].payload.labels = [...new Set([...plan[index].payload.labels, ...completion.report.contract.labels])];
3509
+ plan[index].payload.labels = uniqueGithubLabelNames([...plan[index].payload.labels, ...completion.report.contract.labels]);
3483
3510
  }
3484
3511
  const creates = plan.filter((e) => e.action === "create").length;
3485
3512
  const updates = plan.filter((e) => e.action === "update").length;
@@ -3739,11 +3766,11 @@ function isGithubBlockerDependency(target) {
3739
3766
  * @param items - The complete runtime-validated item corpus.
3740
3767
  * @returns The candidate items, in corpus order.
3741
3768
  */
3742
- export function selectBlockerCandidates(items) {
3769
+ export function selectBlockerCandidates(items, statuses) {
3743
3770
  return items.filter((item) => {
3744
- if (item.status === "blocked")
3771
+ if (githubStatusKind(item.status, statuses) === "blocked")
3745
3772
  return true;
3746
- if (BLOCKER_TERMINAL_STATUSES.has(item.status))
3773
+ if (githubStatusIsTerminal(item.status, statuses))
3747
3774
  return false;
3748
3775
  return (item.dependencies.some((dep) => dep.kind === "blocked_by" && isGithubBlockerDependency(dep.id))
3749
3776
  || (item.blockedBy !== undefined && isGithubBlockerDependency(item.blockedBy)));
@@ -4202,7 +4229,8 @@ async function runBlockers(ctx) {
4202
4229
  }
4203
4230
  const corpus = await readBlockerPmItems(ctx.pm_root);
4204
4231
  const selected = requireRequestedItems(corpus, scopedIds);
4205
- const candidates = selectBlockerCandidates(selected);
4232
+ const workspace = await readGithubWorkspace(ctx.pm_root);
4233
+ const candidates = selectBlockerCandidates(selected, workspace.statuses);
4206
4234
  const perItem = candidates.map((item) => ({ item, references: collectBlockerReferences(item, fallbackRepo) }));
4207
4235
  const unique = new Map();
4208
4236
  for (const entry of perItem) {
@@ -4848,7 +4876,8 @@ async function runProjectImport(ctx) {
4848
4876
  // Reading the local store is non-mutating and keeps dry-run faithful: linked
4849
4877
  // project items must preview as updates, not misleading duplicate creates.
4850
4878
  const pmItems = readPmItems(ctx.pm_root);
4851
- const plan = buildProjectImportPlan(projectItems, ref, pmItems, statusMap);
4879
+ const workspace = await readGithubWorkspace(ctx.pm_root);
4880
+ const plan = buildProjectImportPlan(projectItems, ref, pmItems, statusMap, workspace.statuses);
4852
4881
  let imported = 0;
4853
4882
  let updated = 0;
4854
4883
  let skipped = 0;
@@ -4863,11 +4892,12 @@ async function runProjectImport(ctx) {
4863
4892
  continue;
4864
4893
  }
4865
4894
  if (entry.action === "update" && entry.pmId) {
4895
+ const addedTags = nativeTagAdditions(pmItems.find(item => item.id === entry.pmId).tags, entry.tags);
4866
4896
  const updArgs = [
4867
4897
  "--path", ctx.pm_root, "update", entry.pmId,
4868
4898
  "--title", entry.title,
4869
4899
  "--description", description,
4870
- "--tags", entry.tags.join(","),
4900
+ ...(addedTags.length ? ["--add-tags", addedTags.join(",")] : []),
4871
4901
  "--message", `Re-imported from GitHub project ${ref.owner}/${ref.number}`,
4872
4902
  ];
4873
4903
  if (entry.body)
@@ -4880,9 +4910,12 @@ async function runProjectImport(ctx) {
4880
4910
  // A `closed` mapping cannot go through `pm update --status closed`
4881
4911
  // (governance require_close_reason rejects it since pm-cli 2026.8.3);
4882
4912
  // the update omits --status and a separate `pm close` records the reason.
4883
- const closeAfterUpdate = entry.mappedStatus === "closed";
4884
- if (entry.mappedStatus && !closeAfterUpdate)
4913
+ const closeAfterUpdate = entry.mappedStatus !== undefined && canonicalGithubStatus(entry.mappedStatus, workspace.statuses) === workspace.statuses.close_status;
4914
+ if (entry.mappedStatus && !closeAfterUpdate) {
4885
4915
  updArgs.push("--status", entry.mappedStatus);
4916
+ if (githubStatusIsTerminal(entry.mappedStatus, workspace.statuses))
4917
+ updArgs.push("--close-reason", projectImportCloseReason(ref, entry.content));
4918
+ }
4886
4919
  const upd = pmRun(updArgs);
4887
4920
  if (!upd.ok) {
4888
4921
  console.error(` ${entry.title}: update failed — ${upd.stderr}`);
@@ -4904,19 +4937,21 @@ async function runProjectImport(ctx) {
4904
4937
  // `require_close_reason` rejects `pm create --status closed` (pm-cli
4905
4938
  // 2026.8.3+). Create open, then close through `pm close` with factual
4906
4939
  // provenance. `--json` is needed to read the assigned id for the close.
4907
- const createStatus = entry.status === "closed" ? "open" : entry.status;
4908
- const mustClose = entry.status === "closed";
4940
+ const mustClose = canonicalGithubStatus(entry.status, workspace.statuses) === workspace.statuses.close_status;
4941
+ const createStatus = mustClose ? workspace.statuses.open_status : entry.status;
4909
4942
  const createArgs = [
4910
4943
  "--path", ctx.pm_root, "create",
4911
4944
  "--title", entry.title,
4912
4945
  "--type", itemType,
4913
4946
  "--status", createStatus,
4914
4947
  "--description", description,
4915
- "--tags", entry.tags.join(","),
4948
+ "--tags", serializeImportTags(entry.tags),
4916
4949
  "--message", `Imported from GitHub project ${ref.owner}/${ref.number}`,
4917
4950
  ];
4918
4951
  if (entry.body)
4919
4952
  createArgs.push("--body", entry.body);
4953
+ if (!mustClose && githubStatusIsTerminal(entry.status, workspace.statuses))
4954
+ createArgs.push("--close-reason", projectImportCloseReason(ref, entry.content));
4920
4955
  if (mustClose)
4921
4956
  createArgs.push("--json");
4922
4957
  const created = pmRun(createArgs);
@@ -4991,10 +5026,10 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
4991
5026
  if (pm?.id) {
4992
5027
  const tag = projectItemTag(ref, itemId);
4993
5028
  const existingTags = pm.tags;
4994
- if (!existingTags.includes(tag)) {
5029
+ if (nativeTagAdditions(existingTags, [tag]).length) {
4995
5030
  const upd = pmRun([
4996
5031
  "--path", pmRoot, "update", pm.id,
4997
- "--tags", [...existingTags, tag].join(","),
5032
+ "--add-tags", tag,
4998
5033
  "--message", `Linked to GitHub project ${ref.owner}/${ref.number}`,
4999
5034
  ]);
5000
5035
  if (!upd.ok)
@@ -5037,22 +5072,22 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
5037
5072
  * @param pmRoot - Workspace root for the `pm` invocation.
5038
5073
  * @returns The argv array.
5039
5074
  */
5040
- export function buildPullEntryArgs(entry, pmRoot) {
5075
+ export function buildPullEntryArgs(entry, pmRoot, statuses) {
5041
5076
  const reason = `GitHub project status → ${entry.toStatus}`;
5042
- if (entry.toStatus === "closed") {
5077
+ if (canonicalGithubStatus(entry.toStatus, statuses) === (statuses?.close_status ?? "closed")) {
5043
5078
  return ["--path", pmRoot, "close", entry.pmId, "--reason", reason];
5044
5079
  }
5045
- if (entry.toStatus === "canceled") {
5080
+ if (githubStatusIsTerminal(entry.toStatus, statuses)) {
5046
5081
  return [
5047
5082
  "--path", pmRoot, "update", entry.pmId,
5048
- "--status", "canceled",
5083
+ "--status", canonicalGithubStatus(entry.toStatus, statuses),
5049
5084
  "--close-reason", reason,
5050
5085
  "--message", reason,
5051
5086
  ];
5052
5087
  }
5053
5088
  return [
5054
5089
  "--path", pmRoot, "update", entry.pmId,
5055
- "--status", entry.toStatus,
5090
+ "--status", canonicalGithubStatus(entry.toStatus, statuses),
5056
5091
  "--message", reason,
5057
5092
  ];
5058
5093
  }
@@ -5060,8 +5095,8 @@ export function buildPullEntryArgs(entry, pmRoot) {
5060
5095
  // (records closed_at + close_reason); `canceled` keeps its distinct terminal
5061
5096
  // state via `pm update` while recording `--close-reason`; everything else is a
5062
5097
  // plain `pm update --status`.
5063
- function applyPullEntry(entry, pmRoot) {
5064
- const res = pmRun(buildPullEntryArgs(entry, pmRoot));
5098
+ function applyPullEntry(entry, pmRoot, statuses) {
5099
+ const res = pmRun(buildPullEntryArgs(entry, pmRoot, statuses));
5065
5100
  return res.ok ? { changed: true } : { changed: false, error: res.stderr };
5066
5101
  }
5067
5102
  /**
@@ -5110,11 +5145,12 @@ async function runProjectSync(ctx) {
5110
5145
  for (const it of scoped.selected)
5111
5146
  if (it.id)
5112
5147
  pmById.set(it.id, it);
5148
+ const workspace = await readGithubWorkspace(ctx.pm_root);
5113
5149
  const pushPlan = doPush
5114
- ? buildProjectPushPlan(scoped.selected, ref, projectItems, meta.statusField, { addMissing, statusMap })
5150
+ ? buildProjectPushPlan(scoped.selected, ref, projectItems, meta.statusField, { addMissing, statusMap, statuses: workspace.statuses })
5115
5151
  : undefined;
5116
5152
  const pullPlan = doPull
5117
- ? buildProjectPullPlan(scoped.selected, ref, projectItems, statusMap)
5153
+ ? buildProjectPullPlan(scoped.selected, ref, projectItems, statusMap, workspace.statuses)
5118
5154
  : undefined;
5119
5155
  // Conflict resolution when applying BOTH directions: a linked item can appear
5120
5156
  // in both plans. `--prefer pm` (default) lets push win (skip its pull entry);
@@ -5181,7 +5217,7 @@ async function runProjectSync(ctx) {
5181
5217
  for (const e of pullActionable) {
5182
5218
  if (prefer === "pm" && pushItemIds.has(e.itemId))
5183
5219
  continue; // push wins
5184
- const r = applyPullEntry(e, ctx.pm_root);
5220
+ const r = applyPullEntry(e, ctx.pm_root, workspace.statuses);
5185
5221
  if (!r.changed) {
5186
5222
  console.error(` pull ${e.pmId} "${e.title}": ${r.error}`);
5187
5223
  pullFailed++;
@@ -5317,7 +5353,7 @@ const PROJECT_SYNC_FLAGS = [
5317
5353
  const defineExtension = (module) => module;
5318
5354
  export default defineExtension({
5319
5355
  name: "pm-github",
5320
- version: "2026.10.27",
5356
+ version: "2026.10.29",
5321
5357
  activate(api) {
5322
5358
  // -----------------------------------------------------------------------
5323
5359
  // schema — declare the GitHub provenance fields so the workspace knows them
@@ -5577,6 +5613,52 @@ export default defineExtension({
5577
5613
  });
5578
5614
  },
5579
5615
  });
5616
+ api.registerCommand({
5617
+ name: "github discussion capture",
5618
+ description: "Preview explicitly marked issue-comment spans as native Decisions. Apply only the reviewed digest; source instructions remain quoted data.",
5619
+ flags: [
5620
+ { long: "--input", value_name: "file", description: "Strict JSON selection: repo, issue, item, selections with comment/start/end/quote/marked:true" },
5621
+ { long: "--approve", value_name: "digest", description: "Apply the exact reviewed preview, re-reading every source and native target" },
5622
+ { long: "--max-output-bytes", value_name: "N", description: "Complete JSON receipt budget, integer 1024..131072 (default 65536)" },
5623
+ ],
5624
+ async run(ctx) {
5625
+ const { input, approve, maxOutputBytes } = ctx.options;
5626
+ if (typeof input !== "string" || approve !== undefined && typeof approve !== "string")
5627
+ refuseDiscussion("invalid_selection", "Supply --input with a strict JSON selection document and an optional full --approve digest.");
5628
+ let selection;
5629
+ try {
5630
+ selection = readLabelMigrationJson(input);
5631
+ }
5632
+ catch {
5633
+ refuseDiscussion("invalid_selection", "Supply a readable bounded regular JSON selection file; see pm github discussion capture --help.");
5634
+ }
5635
+ return captureGithubDiscussion(ctx.pm_root, selection, ctx.sdk, {
5636
+ approval: approve, maxOutputBytes: maxOutputBytes === undefined ? undefined : Number(maxOutputBytes),
5637
+ });
5638
+ },
5639
+ });
5640
+ api.registerCommand({
5641
+ name: "github labels plan",
5642
+ description: "Preview exact provider label rename/consolidation impacts on stored tags and declared filters/mappings. Read-only; --approve revalidates a saved digest against current state.",
5643
+ flags: [
5644
+ { long: "--input", value_name: "file", description: "Strict JSON request with repo, source IDs/names, change, filters, mappings and choices" },
5645
+ { long: "--catalog", value_name: "file", description: "Complete offline catalog JSON; omit to fetch all current GitHub labels" },
5646
+ { long: "--approve", value_name: "digest", description: "Check the exact reviewed plan against current labels, policy and SDK revisions; writes nothing" },
5647
+ { long: "--limit", value_name: "N", description: "Affected rows per page, 1..100 (default 20); counts always cover the complete corpus" },
5648
+ { long: "--offset", value_name: "N", description: "Nonnegative affected-row offset" },
5649
+ ],
5650
+ async run(ctx) {
5651
+ const { input, catalog, approve, limit, offset } = ctx.options;
5652
+ if (typeof input !== "string" || catalog !== undefined && typeof catalog !== "string" || approve !== undefined && typeof approve !== "string") {
5653
+ throw createPmCliExpectedError("Supply --input <JSON file>, optional --catalog <JSON file> and a full --approve digest.", { exitCode: 2, context: { code: "invalid_input", required: "Use pm github labels plan --help and the documented JSON example." } });
5654
+ }
5655
+ return planGithubLabelMigration(ctx.pm_root, readLabelMigrationJson(input), {
5656
+ page: { limit: limit === undefined ? undefined : Number(limit), offset: offset === undefined ? undefined : Number(offset) },
5657
+ approval: approve,
5658
+ readCatalog: catalog === undefined ? undefined : async () => readLabelMigrationJson(catalog),
5659
+ });
5660
+ },
5661
+ });
5580
5662
  api.registerCommand({
5581
5663
  name: "github refresh",
5582
5664
  description: "Refresh existing linked items from GitHub without importing new issues.",
@@ -5743,6 +5825,19 @@ export default defineExtension({
5743
5825
  return report;
5744
5826
  },
5745
5827
  });
5828
+ // Receipts stay private until the workflow scans the selected diagnostic.
5829
+ api.registerCommand({
5830
+ name: "github workflow-diagnostic",
5831
+ description: "Select one actionable refusal from private workflow receipts. Privacy-scan before publishing the result.",
5832
+ flags: [
5833
+ { long: "--report", value_name: "file", description: "Command JSON stdout receipt" },
5834
+ { long: "--error-file", value_name: "file", description: "Command stderr receipt" },
5835
+ { long: "--diff", value_name: "file", description: "Repeat-import tracker diff" },
5836
+ ],
5837
+ async run(ctx) {
5838
+ return readWorkflowFailure(optionString(ctx.options, "report"), optionString(ctx.options, "error-file", "errorFile"), optionString(ctx.options, "diff"));
5839
+ },
5840
+ });
5746
5841
  // Closeout uses the existing repository resolution and REST transport.
5747
5842
  api.registerCommand({
5748
5843
  name: "github closeout",
@@ -5765,8 +5860,12 @@ export default defineExtension({
5765
5860
  const report = await runCloseout(ctx.pm_root, resolveSearchRepo(ctx.options), ctx.options, {
5766
5861
  base: githubApiBase(), get: url => fetchJSON(url, token), next: parseNextLink,
5767
5862
  });
5768
- if (report.failed > 0)
5863
+ if (report.failed > 0) {
5864
+ // Preserve the full private receipt before the host formats or budgets its error message.
5865
+ if (ctx.global.json === true)
5866
+ process.stdout.write(`${JSON.stringify(report)}\n`);
5769
5867
  throw new CommandError(JSON.stringify({ ...report, error: "pm github closeout: some closes failed" }), EXIT_CODE.GENERIC_FAILURE);
5868
+ }
5770
5869
  return ctx.global.json === true || ctx.options.full === true ? report : compactCloseoutReport(report);
5771
5870
  }
5772
5871
  catch (error) {