pm-github 2026.10.28 → 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 (58) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +37 -0
  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 +1 -1
  25. package/dist/import-tags.d.ts.map +1 -1
  26. package/dist/import-tags.js +8 -6
  27. package/dist/import-tags.js.map +1 -1
  28. package/dist/index.d.ts +48 -40
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +139 -60
  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 +6 -4
  35. package/dist/issue-forms.js.map +1 -1
  36. package/dist/projects.d.ts +6 -4
  37. package/dist/projects.d.ts.map +1 -1
  38. package/dist/projects.js +21 -20
  39. package/dist/projects.js.map +1 -1
  40. package/dist/refresh.d.ts +2 -1
  41. package/dist/refresh.d.ts.map +1 -1
  42. package/dist/refresh.js +17 -8
  43. package/dist/refresh.js.map +1 -1
  44. package/dist/webhooks.d.ts.map +1 -1
  45. package/dist/webhooks.js +5 -2
  46. package/dist/webhooks.js.map +1 -1
  47. package/dist/workflow-diagnostic.d.ts +9 -0
  48. package/dist/workflow-diagnostic.d.ts.map +1 -0
  49. package/dist/workflow-diagnostic.js +62 -0
  50. package/dist/workflow-diagnostic.js.map +1 -0
  51. package/dist/workspace.d.ts +18 -0
  52. package/dist/workspace.d.ts.map +1 -0
  53. package/dist/workspace.js +39 -0
  54. package/dist/workspace.js.map +1 -0
  55. package/docs/discussion-capture-example.json +14 -0
  56. package/docs/discussion-capture.md +44 -0
  57. package/manifest.json +1 -1
  58. package/package.json +8 -4
package/dist/index.js CHANGED
@@ -1,14 +1,18 @@
1
+ import { readGithubWorkspace, canonicalGithubStatus, githubStatusKind, githubStatusIsTerminal } from "./workspace.js";
1
2
  import { ownValue } from "./own-record.js";
2
3
  import { uniqueGithubLabelNames } from "./label-names.js";
3
4
  import { githubLabelsToTags, importTagArguments, importTagOptions, importTagValues, nativeTagAdditions } from "./import-tags.js";
4
5
  export { githubLabelsToTags } from "./import-tags.js";
5
6
  import { CloseoutUsageError, compactCloseoutReport, runCloseout } from "./closeout.js";
7
+ import { readWorkflowFailure } from "./workflow-diagnostic.js";
6
8
  import { compileIssueForm, completeIssueForm, readRepositoryIssueForm, refuseIssueForm } from "./issue-forms.js";
7
9
  export { compileIssueForm, completeIssueForm, readRepositoryIssueForm } from "./issue-forms.js";
8
- 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";
9
11
  import { refreshChanges, runRefresh } from "./refresh.js";
10
12
  import { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.js";
11
13
  import { planGithubLabelMigration, readLabelMigrationJson } from "./labels.js";
14
+ import { captureGithubDiscussion, refuseDiscussion } from "./discussions.js";
15
+ export { captureGithubDiscussion, readGithubDiscussionComment, validateDiscussionCaptureReport } from "./discussions.js";
12
16
  export { planGithubLabelMigration, readGithubLabelCatalog, validateGithubLabelMigrationReport } from "./labels.js";
13
17
  export { exploreGithubIdentities, planGithubIdentityRepair, applyGithubIdentityRepair } from "./identity.js";
14
18
  import { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.js";
@@ -329,7 +333,7 @@ async function request(method, url, token, payload, maxRetries = 4) {
329
333
  attempt++;
330
334
  continue;
331
335
  }
332
- 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}`);
333
337
  }
334
338
  }
335
339
  /** Fetch a single GitHub REST endpoint via GET and return the decoded
@@ -958,10 +962,11 @@ export function deriveAtomicItemId(repo, issueNumber, idPrefix, normalizeItemId)
958
962
  return normalizeItemId(`github-${repoToken}-${issueNumber}`, idPrefix);
959
963
  }
960
964
  /** Map one rendered import entry to its reversible SDK mutation sequence. */
961
- export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, updateOnly = false) {
965
+ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, updateOnly = false, statuses) {
962
966
  if (updateOnly && !entry.match?.id)
963
967
  throw new CommandError("Refresh refuses an unlinked target; no item may be created.");
964
- 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;
965
970
  const audit = updateOnly ? { comment: [`Refreshed linked GitHub issue ${repo.toLowerCase()}#${entry.issueNumber}`] } : {};
966
971
  if (entry.linkOnly && entry.match?.id) {
967
972
  return { itemId: entry.match.id, mutations: [{ op: "update", id: entry.match.id, options: { ...(!changed || changed.has("tags") ? importTagOptions(entry) : {}), ...audit } }] };
@@ -974,13 +979,13 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
974
979
  ...importTagOptions(entry),
975
980
  ...(entry.assignee ? { assignee: entry.assignee } : {}),
976
981
  ...(entry.milestone ? { sprint: entry.milestone } : {}),
977
- ...(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` } : {}),
978
983
  };
979
984
  // Explicit terminal reconciliation also handles canceled -> completed. The
980
985
  // supported force option permits that source-authoritative repeat closure;
981
986
  // finalization restores the real dates before the transaction commits.
982
- const statusOptions = entry.status === "closed"
983
- ? 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 } : {}
984
989
  : { status: entry.status };
985
990
  const managedItemId = deriveAtomicItemId(repo, entry.issueNumber, idPrefix, normalizeItemId);
986
991
  // A missing match and a match at our deterministic external-key id use the
@@ -990,7 +995,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
990
995
  // create step treats an existing stable id as already applied; update then
991
996
  // makes later content-bearing transactions refresh the item normally.
992
997
  if (!updateOnly && (!entry.match?.id || entry.match.id === managedItemId)) {
993
- const createStatus = entry.status === "closed" ? "open" : entry.status;
998
+ const createStatus = closing ? "open" : entry.status;
994
999
  const mutations = [{
995
1000
  op: "create",
996
1001
  id: managedItemId,
@@ -1003,7 +1008,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
1003
1008
  ...statusOptions,
1004
1009
  },
1005
1010
  }];
1006
- if (entry.status === "closed" && !entry.fidelity) {
1011
+ if (closing && !entry.fidelity) {
1007
1012
  mutations.push({
1008
1013
  op: "close",
1009
1014
  id: managedItemId,
@@ -1030,7 +1035,7 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
1030
1035
  id: itemId,
1031
1036
  options: updateOptions,
1032
1037
  }];
1033
- if (entry.status === "closed" && !entry.fidelity) {
1038
+ if (closing && !entry.fidelity) {
1034
1039
  mutations.push({
1035
1040
  op: "close",
1036
1041
  id: itemId,
@@ -1046,13 +1051,13 @@ export function buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemI
1046
1051
  * The importer's desired-state and compensation writes leave retries stable;
1047
1052
  * local edits and later state cycles invalidate completed journals.
1048
1053
  */
1049
- async function importContextVersions(pmRoot, entries) {
1054
+ async function importContextVersions(pmRoot, entries, statuses) {
1050
1055
  const versions = [];
1051
1056
  for (const entry of entries) {
1052
1057
  if (!entry.fidelity || !entry.match?.id)
1053
1058
  continue;
1054
1059
  const snapshot = await sdkReadHistorySnapshot(sdkGetHistoryPath(pmRoot, entry.match.id), entry.match.id);
1055
- const context = snapshot.entries[selectImportContextIndex(snapshot.entries, entry.status)];
1060
+ const context = snapshot.entries[selectImportContextIndex(snapshot.entries, entry.status, statuses)];
1056
1061
  if (context)
1057
1062
  versions.push(`${entry.match.id}:${crypto.createHash("sha256").update(JSON.stringify(context)).digest("hex")}`);
1058
1063
  }
@@ -1197,6 +1202,7 @@ async function commitGithubImportTransaction(config) {
1197
1202
  export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1198
1203
  pmRoot = sdkResolvePmRoot(process.cwd(), pmRoot);
1199
1204
  const { commitItemMutations: commit, normalizeItemId, readSettings, } = await resolveAtomicSdkFunctions(opts);
1205
+ const workspace = await readGithubWorkspace(pmRoot);
1200
1206
  let idPrefix = "pm-";
1201
1207
  try {
1202
1208
  const settings = await readSettings(pmRoot);
@@ -1213,11 +1219,11 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1213
1219
  // by the stable GitHub issue number so a retry whose API page/order changed
1214
1220
  // supplies the exact same plan as well as the same transaction id.
1215
1221
  for (const entry of [...entries].sort((a, b) => a.issueNumber - b.issueNumber)) {
1216
- const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, opts.updateOnly);
1222
+ const planned = buildAtomicImportMutations(repo, entry, idPrefix, normalizeItemId, opts.updateOnly, workspace.statuses);
1217
1223
  itemIds.set(entry.issueNumber, planned.itemId);
1218
1224
  mutations.push(...planned.mutations);
1219
1225
  }
1220
- const transactionId = deriveAtomicTransactionId(repo, entries, mutations, await importContextVersions(pmRoot, entries));
1226
+ const transactionId = deriveAtomicTransactionId(repo, entries, mutations, await importContextVersions(pmRoot, entries, workspace.statuses));
1221
1227
  // Update-only transactions derive their steps from the CURRENT item state,
1222
1228
  // so a retry after a partial apply would fingerprint a different plan and
1223
1229
  // orphan the journal behind a fresh transaction id. Persist the exact plan
@@ -2405,7 +2411,7 @@ export async function prepareGithubImport(issue, repo, opts, token, match, fetch
2405
2411
  const timeline = await fidelityContext.timeline(issue, repo, token);
2406
2412
  const fidelity = buildImportFidelity(issue, fidelityContext.releases, timeline);
2407
2413
  const targetStatus = mapState(issue.state, issue.state_reason);
2408
- 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")
2409
2415
  ? { local: match.status, github: issue.state, rule: identity.linkOnly ? "preserve-provider-owner" : "github-source-wins" } : undefined;
2410
2416
  const kind = issue.pull_request ? "PR" : "issue";
2411
2417
  const labels = issue.labels.map((label) => label.name).filter(Boolean);
@@ -2451,6 +2457,16 @@ export async function prepareGithubImport(issue, repo, opts, token, match, fetch
2451
2457
  match,
2452
2458
  };
2453
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
+ }
2454
2470
  /**
2455
2471
  * Verify that a gated import's plan accounts for every fetched issue exactly once.
2456
2472
  *
@@ -2622,11 +2638,11 @@ function gateImportWrites(pmRoot) {
2622
2638
  * throws {@link CommandError} (with a semantic exit code) on failure.
2623
2639
  *
2624
2640
  * @param repoArg - The `owner/repo` to import from.
2625
- * @param pmRoot - Workspace root or pm data dir.
2641
+ * @param pmPath - Workspace root or pm data dir.
2626
2642
  * @param opts - Normalized import options.
2627
2643
  * @param deps - Injectable collaborators (token resolver, fetchers, etc.) for tests.
2628
2644
  */
2629
- export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2645
+ export async function runImport(repoArg, pmPath, opts, dependencies = {}) {
2630
2646
  if (!repoArg || !repoArg.includes("/")) {
2631
2647
  throw new CommandError("Usage: pm github import <owner/repo> [--all|--state open|closed|all] " +
2632
2648
  "[--labels bug,enhancement] [--since <iso>] [--assignee <login>] " +
@@ -2668,7 +2684,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2668
2684
  console.error("No issues found.");
2669
2685
  // Even a no-op gated import must prove the workspace is gateable (a Git work
2670
2686
  // tree the scanner can read), so the empty-change gate scan runs here too.
2671
- const emptyGateReceipt = opts.gate && !opts.dryRun ? gateImportWrites(pmRoot) : undefined;
2687
+ const emptyGateReceipt = opts.gate && !opts.dryRun ? gateImportWrites(pmPath) : undefined;
2672
2688
  if (opts.dryRun && (opts.atomic || missingIssues.length > 0)) {
2673
2689
  return {
2674
2690
  dryRun: true,
@@ -2688,6 +2704,8 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2688
2704
  // reported "would import N, skip 0" where the real run performs updates for
2689
2705
  // already-linked issues. A preview that overstates creates reads as "this will
2690
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;
2691
2709
  /** Read the complete raw corpus in-process, retaining all provider metadata and bodies. */
2692
2710
  const readImportItems = async () => {
2693
2711
  if (dependencies.readItems)
@@ -2697,7 +2715,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2697
2715
  };
2698
2716
  const existingItems = await readImportItems();
2699
2717
  const existing = indexByProvenance(existingItems);
2700
- const fidelityContext = { items: existingItems, releases: readImportReleases(pmRoot),
2718
+ const fidelityContext = { statuses: workspace.statuses, items: existingItems, releases: readImportReleases(pmRoot),
2701
2719
  timeline: dependencies.fetchIssueTimeline ?? (dependencies.fetchIssues ? async () => [] : fetchImportTimeline) };
2702
2720
  let imported = 0;
2703
2721
  let updated = 0;
@@ -2841,7 +2859,14 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2841
2859
  ...fetchFields,
2842
2860
  };
2843
2861
  }
2844
- 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);
2845
2870
  if (opts.gate)
2846
2871
  verifyImportedProvenance(await readImportItems(), prepared, repo);
2847
2872
  // Planned native comments are part of the verified plan: a contended lock,
@@ -2861,7 +2886,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2861
2886
  }
2862
2887
  }
2863
2888
  failClosedOnPlanDivergence(opts.gate, skipped, skipped, commentWriteFailures);
2864
- if (result.recovered) {
2889
+ if (unchanged)
2890
+ console.error("Atomic import unchanged; no item transaction was needed.");
2891
+ else if (result.recovered) {
2865
2892
  console.error(`Atomic import recovered transaction ${result.transactionId} covering ${result.recoveredItems ?? prepared.length} item(s).`);
2866
2893
  }
2867
2894
  else {
@@ -2878,6 +2905,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2878
2905
  const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined;
2879
2906
  return {
2880
2907
  transactionId: result.transactionId,
2908
+ ...(unchanged ? { unchanged: true } : {}),
2881
2909
  recovered: result.recovered,
2882
2910
  imported: result.imported,
2883
2911
  updated: result.updated - (result.recovered ? 0 : adoptionCount),
@@ -2962,22 +2990,22 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2962
2990
  continue;
2963
2991
  }
2964
2992
  // Reconcile status separately.
2965
- 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)) {
2966
2994
  const cancel = pmRun(["--path", pmRoot, "update", match.id, "--status", "canceled", "--close-reason", prepared.fidelity.github_close_reason]);
2967
2995
  if (!cancel.ok)
2968
2996
  throw new CommandError(`Could not cancel GitHub issue #${issue.number}.`);
2969
2997
  }
2970
- 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)) {
2971
2999
  // Close through `pm close` so the reason is real provenance; pass the
2972
3000
  // source completion time as --completed-at when GitHub recorded one.
2973
- const close = pmRun([...githubCloseArgs(match.id), ...(match.status === "canceled" ? ["--force"] : [])]);
3001
+ const close = pmRun([...githubCloseArgs(match.id), ...(githubStatusIsTerminal(match.status, workspace.statuses) ? ["--force"] : [])]);
2974
3002
  if (!close.ok) {
2975
3003
  console.error(`#${issue.number}: close reconciliation failed — ${close.stderr}`);
2976
3004
  skipped++;
2977
3005
  continue;
2978
3006
  }
2979
3007
  }
2980
- 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)) {
2981
3009
  const reopen = pmRun(["--path", pmRoot, "update", match.id, "--status", "open", "--message", `GitHub issue #${issue.number} reopened`]);
2982
3010
  if (!reopen.ok) {
2983
3011
  console.error(`#${issue.number}: reopen reconciliation failed — ${reopen.stderr}`);
@@ -2999,8 +3027,8 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2999
3027
  // `require_close_reason` rejects `pm create --status closed`. Create the
3000
3028
  // item open, then close it through `pm close` with the source's real
3001
3029
  // completion timestamp (`closed_at`) as --completed-at provenance.
3002
- const createStatus = status === "closed" ? "open" : status;
3003
- 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);
3004
3032
  const createArgs = [
3005
3033
  "--path", pmRoot, "create",
3006
3034
  "--title", title,
@@ -3011,7 +3039,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
3011
3039
  "--tags", importTagOptions(prepared).tags,
3012
3040
  "--message", `Imported from GitHub #${issue.number}`,
3013
3041
  ];
3014
- if (status === "canceled")
3042
+ if (canonicalGithubStatus(status, workspace.statuses) === canonicalGithubStatus("canceled", workspace.statuses))
3015
3043
  createArgs.push("--close-reason", prepared.fidelity.github_close_reason);
3016
3044
  if (assignee)
3017
3045
  createArgs.push("--assignee", assignee);
@@ -3163,8 +3191,8 @@ function failClosedOnPlanDivergence(gate, plannedSkipped, actualSkipped, comment
3163
3191
  // ---------------------------------------------------------------------------
3164
3192
  // Sync core — push pm status changes back to GitHub (close / reopen)
3165
3193
  // ---------------------------------------------------------------------------
3166
- function pmStatusToGithubState(status) {
3167
- return status === "closed" || status === "canceled" ? "closed" : "open";
3194
+ function pmStatusToGithubState(status, statuses) {
3195
+ return githubStatusIsTerminal(status, statuses) ? "closed" : "open";
3168
3196
  }
3169
3197
  /**
3170
3198
  * Require every requested item before a remote sync or export can begin.
@@ -3182,7 +3210,7 @@ function requireRequestedItems(items, requested) {
3182
3210
  }
3183
3211
  // Build the pm → GitHub issue sync plan: for each pm item linked to `repo`, emit
3184
3212
  // a create-or-update entry keyed by the issue's provenance tag.
3185
- export function planSync(items, repo) {
3213
+ export function planSync(items, repo, statuses) {
3186
3214
  const plan = [];
3187
3215
  const repoLc = repo.toLowerCase();
3188
3216
  for (const item of items) {
@@ -3192,7 +3220,7 @@ export function planSync(items, repo) {
3192
3220
  const p = parseProvenanceTag(tag);
3193
3221
  if (!p || p.repo !== repoLc)
3194
3222
  continue;
3195
- const desired = pmStatusToGithubState(item.status);
3223
+ const desired = pmStatusToGithubState(item.status, statuses);
3196
3224
  plan.push({
3197
3225
  id: item.id,
3198
3226
  number: p.number,
@@ -3227,7 +3255,8 @@ async function runSync(ctx) {
3227
3255
  "(set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`). Use --dry-run to preview without a token.", EXIT_CODE.USAGE);
3228
3256
  }
3229
3257
  const selected = requireRequestedItems(readPmItems(ctx.pm_root), scopedIds);
3230
- const plan = planSync(selected, repo);
3258
+ const workspace = await readGithubWorkspace(ctx.pm_root);
3259
+ const plan = planSync(selected, repo, workspace.statuses);
3231
3260
  if (plan.length === 0) {
3232
3261
  const scopeNote = scopedIds.length > 0 ? ` from --ids (${scopedIds.join(", ")})` : "";
3233
3262
  console.error(`No pm items${scopeNote} linked to ${repo} ` +
@@ -3289,7 +3318,7 @@ async function runSync(ctx) {
3289
3318
  }
3290
3319
  // Convert a pm item into the GitHub issue create/update payload, dropping
3291
3320
  // internal provenance tags from labels and applying the optional label map.
3292
- function itemToGithubPayload(item, labelMap) {
3321
+ function itemToGithubPayload(item, labelMap, statuses) {
3293
3322
  // Drop our internal provenance tags from exported labels, then apply any
3294
3323
  // user-supplied label mapping (pm tag → GitHub label). Both issue provenance
3295
3324
  // (`gh:owner/repo#N`) and project provenance (`gh-project:owner/number#itemId`)
@@ -3300,18 +3329,18 @@ function itemToGithubPayload(item, labelMap) {
3300
3329
  title: item.title ?? "(untitled)",
3301
3330
  body: item.body || item.description || "",
3302
3331
  labels: applyLabelMap(labels, labelMap),
3303
- state: item.status === "closed" || item.status === "canceled" ? "closed" : "open",
3332
+ state: pmStatusToGithubState(item.status, statuses),
3304
3333
  };
3305
3334
  }
3306
3335
  // Build the create/update plan. `repo` (lowercased) decides which provenance
3307
3336
  // tags count as an "already exported to THIS repo" link → update; everything
3308
3337
  // else is a create. Pure + side-effect free so it can be unit-tested and
3309
3338
  // printed verbatim in --dry-run.
3310
- export function buildExportPlan(items, repo, labelMap) {
3339
+ export function buildExportPlan(items, repo, labelMap, statuses) {
3311
3340
  const repoLc = repo?.toLowerCase();
3312
3341
  const plan = [];
3313
3342
  for (const item of items) {
3314
- const payload = itemToGithubPayload(item, labelMap);
3343
+ const payload = itemToGithubPayload(item, labelMap, statuses);
3315
3344
  let number;
3316
3345
  if (repoLc) {
3317
3346
  for (const tag of item.tags ?? []) {
@@ -3473,7 +3502,8 @@ async function runExport(ctx) {
3473
3502
  else if (["form-ref", "formRef", "form-answers", "formAnswers", "form-bindings", "formBindings"].some(flag => optionProvided(options, flag))) {
3474
3503
  refuseIssueForm("invalid_form_input", [{ field: "form", reason: "selection", remedy: "Pass --form with --repo and --form-ref to enforce an issue-form contract." }]);
3475
3504
  }
3476
- const plan = buildExportPlan(selected, repo, labelMap);
3505
+ const workspace = await readGithubWorkspace(ctx.pm_root);
3506
+ const plan = buildExportPlan(selected, repo, labelMap, workspace.statuses);
3477
3507
  for (const [index, completion] of completions.entries()) {
3478
3508
  plan[index].payload.body = completion.body;
3479
3509
  plan[index].payload.labels = uniqueGithubLabelNames([...plan[index].payload.labels, ...completion.report.contract.labels]);
@@ -3736,11 +3766,11 @@ function isGithubBlockerDependency(target) {
3736
3766
  * @param items - The complete runtime-validated item corpus.
3737
3767
  * @returns The candidate items, in corpus order.
3738
3768
  */
3739
- export function selectBlockerCandidates(items) {
3769
+ export function selectBlockerCandidates(items, statuses) {
3740
3770
  return items.filter((item) => {
3741
- if (item.status === "blocked")
3771
+ if (githubStatusKind(item.status, statuses) === "blocked")
3742
3772
  return true;
3743
- if (BLOCKER_TERMINAL_STATUSES.has(item.status))
3773
+ if (githubStatusIsTerminal(item.status, statuses))
3744
3774
  return false;
3745
3775
  return (item.dependencies.some((dep) => dep.kind === "blocked_by" && isGithubBlockerDependency(dep.id))
3746
3776
  || (item.blockedBy !== undefined && isGithubBlockerDependency(item.blockedBy)));
@@ -4199,7 +4229,8 @@ async function runBlockers(ctx) {
4199
4229
  }
4200
4230
  const corpus = await readBlockerPmItems(ctx.pm_root);
4201
4231
  const selected = requireRequestedItems(corpus, scopedIds);
4202
- const candidates = selectBlockerCandidates(selected);
4232
+ const workspace = await readGithubWorkspace(ctx.pm_root);
4233
+ const candidates = selectBlockerCandidates(selected, workspace.statuses);
4203
4234
  const perItem = candidates.map((item) => ({ item, references: collectBlockerReferences(item, fallbackRepo) }));
4204
4235
  const unique = new Map();
4205
4236
  for (const entry of perItem) {
@@ -4845,7 +4876,8 @@ async function runProjectImport(ctx) {
4845
4876
  // Reading the local store is non-mutating and keeps dry-run faithful: linked
4846
4877
  // project items must preview as updates, not misleading duplicate creates.
4847
4878
  const pmItems = readPmItems(ctx.pm_root);
4848
- 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);
4849
4881
  let imported = 0;
4850
4882
  let updated = 0;
4851
4883
  let skipped = 0;
@@ -4878,9 +4910,12 @@ async function runProjectImport(ctx) {
4878
4910
  // A `closed` mapping cannot go through `pm update --status closed`
4879
4911
  // (governance require_close_reason rejects it since pm-cli 2026.8.3);
4880
4912
  // the update omits --status and a separate `pm close` records the reason.
4881
- const closeAfterUpdate = entry.mappedStatus === "closed";
4882
- if (entry.mappedStatus && !closeAfterUpdate)
4913
+ const closeAfterUpdate = entry.mappedStatus !== undefined && canonicalGithubStatus(entry.mappedStatus, workspace.statuses) === workspace.statuses.close_status;
4914
+ if (entry.mappedStatus && !closeAfterUpdate) {
4883
4915
  updArgs.push("--status", entry.mappedStatus);
4916
+ if (githubStatusIsTerminal(entry.mappedStatus, workspace.statuses))
4917
+ updArgs.push("--close-reason", projectImportCloseReason(ref, entry.content));
4918
+ }
4884
4919
  const upd = pmRun(updArgs);
4885
4920
  if (!upd.ok) {
4886
4921
  console.error(` ${entry.title}: update failed — ${upd.stderr}`);
@@ -4902,19 +4937,21 @@ async function runProjectImport(ctx) {
4902
4937
  // `require_close_reason` rejects `pm create --status closed` (pm-cli
4903
4938
  // 2026.8.3+). Create open, then close through `pm close` with factual
4904
4939
  // provenance. `--json` is needed to read the assigned id for the close.
4905
- const createStatus = entry.status === "closed" ? "open" : entry.status;
4906
- 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;
4907
4942
  const createArgs = [
4908
4943
  "--path", ctx.pm_root, "create",
4909
4944
  "--title", entry.title,
4910
4945
  "--type", itemType,
4911
4946
  "--status", createStatus,
4912
4947
  "--description", description,
4913
- "--tags", entry.tags.join(","),
4948
+ "--tags", serializeImportTags(entry.tags),
4914
4949
  "--message", `Imported from GitHub project ${ref.owner}/${ref.number}`,
4915
4950
  ];
4916
4951
  if (entry.body)
4917
4952
  createArgs.push("--body", entry.body);
4953
+ if (!mustClose && githubStatusIsTerminal(entry.status, workspace.statuses))
4954
+ createArgs.push("--close-reason", projectImportCloseReason(ref, entry.content));
4918
4955
  if (mustClose)
4919
4956
  createArgs.push("--json");
4920
4957
  const created = pmRun(createArgs);
@@ -5035,22 +5072,22 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
5035
5072
  * @param pmRoot - Workspace root for the `pm` invocation.
5036
5073
  * @returns The argv array.
5037
5074
  */
5038
- export function buildPullEntryArgs(entry, pmRoot) {
5075
+ export function buildPullEntryArgs(entry, pmRoot, statuses) {
5039
5076
  const reason = `GitHub project status → ${entry.toStatus}`;
5040
- if (entry.toStatus === "closed") {
5077
+ if (canonicalGithubStatus(entry.toStatus, statuses) === (statuses?.close_status ?? "closed")) {
5041
5078
  return ["--path", pmRoot, "close", entry.pmId, "--reason", reason];
5042
5079
  }
5043
- if (entry.toStatus === "canceled") {
5080
+ if (githubStatusIsTerminal(entry.toStatus, statuses)) {
5044
5081
  return [
5045
5082
  "--path", pmRoot, "update", entry.pmId,
5046
- "--status", "canceled",
5083
+ "--status", canonicalGithubStatus(entry.toStatus, statuses),
5047
5084
  "--close-reason", reason,
5048
5085
  "--message", reason,
5049
5086
  ];
5050
5087
  }
5051
5088
  return [
5052
5089
  "--path", pmRoot, "update", entry.pmId,
5053
- "--status", entry.toStatus,
5090
+ "--status", canonicalGithubStatus(entry.toStatus, statuses),
5054
5091
  "--message", reason,
5055
5092
  ];
5056
5093
  }
@@ -5058,8 +5095,8 @@ export function buildPullEntryArgs(entry, pmRoot) {
5058
5095
  // (records closed_at + close_reason); `canceled` keeps its distinct terminal
5059
5096
  // state via `pm update` while recording `--close-reason`; everything else is a
5060
5097
  // plain `pm update --status`.
5061
- function applyPullEntry(entry, pmRoot) {
5062
- const res = pmRun(buildPullEntryArgs(entry, pmRoot));
5098
+ function applyPullEntry(entry, pmRoot, statuses) {
5099
+ const res = pmRun(buildPullEntryArgs(entry, pmRoot, statuses));
5063
5100
  return res.ok ? { changed: true } : { changed: false, error: res.stderr };
5064
5101
  }
5065
5102
  /**
@@ -5108,11 +5145,12 @@ async function runProjectSync(ctx) {
5108
5145
  for (const it of scoped.selected)
5109
5146
  if (it.id)
5110
5147
  pmById.set(it.id, it);
5148
+ const workspace = await readGithubWorkspace(ctx.pm_root);
5111
5149
  const pushPlan = doPush
5112
- ? buildProjectPushPlan(scoped.selected, ref, projectItems, meta.statusField, { addMissing, statusMap })
5150
+ ? buildProjectPushPlan(scoped.selected, ref, projectItems, meta.statusField, { addMissing, statusMap, statuses: workspace.statuses })
5113
5151
  : undefined;
5114
5152
  const pullPlan = doPull
5115
- ? buildProjectPullPlan(scoped.selected, ref, projectItems, statusMap)
5153
+ ? buildProjectPullPlan(scoped.selected, ref, projectItems, statusMap, workspace.statuses)
5116
5154
  : undefined;
5117
5155
  // Conflict resolution when applying BOTH directions: a linked item can appear
5118
5156
  // in both plans. `--prefer pm` (default) lets push win (skip its pull entry);
@@ -5179,7 +5217,7 @@ async function runProjectSync(ctx) {
5179
5217
  for (const e of pullActionable) {
5180
5218
  if (prefer === "pm" && pushItemIds.has(e.itemId))
5181
5219
  continue; // push wins
5182
- const r = applyPullEntry(e, ctx.pm_root);
5220
+ const r = applyPullEntry(e, ctx.pm_root, workspace.statuses);
5183
5221
  if (!r.changed) {
5184
5222
  console.error(` pull ${e.pmId} "${e.title}": ${r.error}`);
5185
5223
  pullFailed++;
@@ -5315,7 +5353,7 @@ const PROJECT_SYNC_FLAGS = [
5315
5353
  const defineExtension = (module) => module;
5316
5354
  export default defineExtension({
5317
5355
  name: "pm-github",
5318
- version: "2026.10.28",
5356
+ version: "2026.10.29",
5319
5357
  activate(api) {
5320
5358
  // -----------------------------------------------------------------------
5321
5359
  // schema — declare the GitHub provenance fields so the workspace knows them
@@ -5575,6 +5613,30 @@ export default defineExtension({
5575
5613
  });
5576
5614
  },
5577
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
+ });
5578
5640
  api.registerCommand({
5579
5641
  name: "github labels plan",
5580
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.",
@@ -5763,6 +5825,19 @@ export default defineExtension({
5763
5825
  return report;
5764
5826
  },
5765
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
+ });
5766
5841
  // Closeout uses the existing repository resolution and REST transport.
5767
5842
  api.registerCommand({
5768
5843
  name: "github closeout",
@@ -5785,8 +5860,12 @@ export default defineExtension({
5785
5860
  const report = await runCloseout(ctx.pm_root, resolveSearchRepo(ctx.options), ctx.options, {
5786
5861
  base: githubApiBase(), get: url => fetchJSON(url, token), next: parseNextLink,
5787
5862
  });
5788
- 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`);
5789
5867
  throw new CommandError(JSON.stringify({ ...report, error: "pm github closeout: some closes failed" }), EXIT_CODE.GENERIC_FAILURE);
5868
+ }
5790
5869
  return ctx.global.json === true || ctx.options.full === true ? report : compactCloseoutReport(report);
5791
5870
  }
5792
5871
  catch (error) {