pm-github 2026.9.26 → 2026.10.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  //
3
3
  // Capabilities (see manifest.json):
4
4
  // commands — `pm gh-issues import` (legacy) + `pm github sync` +
5
+ // `pm github gate` (fail-closed pre-push privacy gate) +
5
6
  // `pm github project list|fields|import|sync` (Projects v2)
6
7
  // importers — `pm github import <owner/repo>` (idempotent native import)
7
8
  // exporters — `pm github export` (render pm items as a GitHub-issues payload)
@@ -20,7 +21,9 @@ import path from "node:path";
20
21
  import { spawnSync } from "node:child_process";
21
22
  import { comments as pmCommentsFn, commitItemMutations as sdkCommitItemMutations, listAllItemMetadata as sdkListAllItemMetadata, normalizeItemId as sdkNormalizeItemId, readSettings as sdkReadSettings, } from "@unbrained/pm-cli/sdk";
22
23
  import { collectNewOrderingCycleWarnings as sdkCollectNewOrderingCycleWarnings } from "@unbrained/pm-cli/sdk/graph";
23
- import { buildProjectImportPlan, buildProjectPullPlan, buildProjectPushPlan, parseProjectItemTag, parseProjectRef, parseStatusMap, projectItemTag, } from "./projects.js";
24
+ import { GateInputError, formatGateReport, runTrackerGate, resolvePmDataDir, } from "./gate.js";
25
+ export { resolvePmDataDir } from "./gate.js";
26
+ import { buildProjectImportPlan, buildProjectPullPlan, buildProjectPushPlan, parseProjectItemTag, parseProjectRef, parseStatusMap, parseAssignmentMap, projectItemTag, } from "./projects.js";
24
27
  const COMMENTS_MODES = ["body", "annotations", "both"];
25
28
  // Resolve a GitHub token so the importer is not stuck on the 60 req/hr
26
29
  // unauthenticated quota and can read private repos. Order: explicit env vars,
@@ -29,20 +32,19 @@ export function resolveGitHubToken() {
29
32
  const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
30
33
  if (envToken && envToken.trim())
31
34
  return envToken.trim();
32
- try {
33
- const result = spawnSync("gh", ["auth", "token"], { encoding: "utf-8" });
34
- if (result.status === 0) {
35
- const token = result.stdout.trim();
36
- if (token)
37
- return token;
38
- }
39
- }
40
- catch {
41
- // gh not installed — fall back to unauthenticated requests.
35
+ // spawnSync never throws for a missing executable: it reports ENOENT through
36
+ // `result.error`/`status: null` (verified against the Node subprocess
37
+ // contract), and the arguments here are fixed and valid, so there is no
38
+ // throwable failure left — the status check below is the whole boundary.
39
+ const result = spawnSync("gh", ["auth", "token"], { encoding: "utf-8", env: { ...process.env } });
40
+ if (result.status === 0) {
41
+ const token = result.stdout.trim();
42
+ if (token)
43
+ return token;
42
44
  }
43
45
  return undefined;
44
46
  }
45
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
47
+ const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
46
48
  /**
47
49
  * Decide whether the Authorization token may be forwarded across a redirect.
48
50
  *
@@ -117,6 +119,7 @@ export function githubApiBase() {
117
119
  * token only to same-origin targets via {@link sameOrigin}.
118
120
  */
119
121
  function requestOnce(method, url, token, payload, redirectsLeft = 5) {
122
+ let deadline;
120
123
  return new Promise((resolve, reject) => {
121
124
  const headers = {
122
125
  "User-Agent": "pm-github",
@@ -135,7 +138,7 @@ function requestOnce(method, url, token, payload, redirectsLeft = 5) {
135
138
  const target = new URL(url);
136
139
  const transport = target.protocol === "http:" ? http : https;
137
140
  const req = transport.request(target, { method, headers }, (res) => {
138
- const status = res.statusCode ?? 0;
141
+ const status = res.statusCode; // Node guarantees an Integer statusCode on every ClientResponse.
139
142
  if (status >= 300 && status < 400 && res.headers.location) {
140
143
  // Drain the redirect response so the socket is returned to the pool.
141
144
  res.resume();
@@ -173,13 +176,19 @@ function requestOnce(method, url, token, payload, redirectsLeft = 5) {
173
176
  });
174
177
  });
175
178
  req.on("error", reject);
176
- req.setTimeout(30000, () => {
177
- req.destroy(new Error("request timed out after 30s"));
178
- });
179
+ // A wall-clock deadline also bounds redirects and stalled response bodies.
180
+ // Bun's ClientRequest socket timeout can fail to fire on a silent server.
181
+ deadline = setTimeout(() => {
182
+ const error = new Error("request timed out after 30s");
183
+ // Reject before destroy: Bun can synchronously emit response end during
184
+ // destruction, which must never turn a truncated timed-out body into success.
185
+ reject(error);
186
+ req.destroy(error);
187
+ }, 30000);
179
188
  if (payload)
180
189
  req.write(payload);
181
190
  req.end();
182
- });
191
+ }).finally(() => clearTimeout(deadline));
183
192
  }
184
193
  /**
185
194
  * Compute how long (ms) to wait before retrying a rate-limited or transient
@@ -269,7 +278,22 @@ export function formatRateLimit(info) {
269
278
  }
270
279
  // Decide whether a failed HTTP response is worth retrying: 429, any 5xx, or a
271
280
  // 403 that is actually a primary/secondary rate-limit wall (remaining=0).
272
- function isRetryableStatus(status, headers) {
281
+ //
282
+ /**
283
+ * Decide whether a failed HTTP response is worth retrying.
284
+ *
285
+ * Retries 429, any 5xx, and a 403 that is actually a primary/secondary
286
+ * rate-limit wall (remaining=0 or a Retry-After). Mixed-case header keys are
287
+ * honored defensively: Node's HTTP client lowercases response names, but the
288
+ * shared headers record also serves boundary tests that pass other shapes.
289
+ *
290
+ * @internal Exported only so the HTTP-boundary tests can drive the real
291
+ * decision. `stripInternal` keeps it out of the published `.d.ts`.
292
+ * @param status - The HTTP status code of the failed response.
293
+ * @param headers - The response headers to read the rate-limit hints from.
294
+ * @returns True when the request should be retried with backoff.
295
+ */
296
+ export function isRetryableStatus(status, headers) {
273
297
  if (status === 429)
274
298
  return true;
275
299
  if (status >= 500)
@@ -355,7 +379,7 @@ export function optionString(options, ...keys) {
355
379
  }
356
380
  // Whether an option key was explicitly provided (even if empty/falsey).
357
381
  export function optionProvided(options, ...keys) {
358
- return keys.some((k) => Object.prototype.hasOwnProperty.call(options, k));
382
+ return keys.some((k) => Object.hasOwn(options, k));
359
383
  }
360
384
  // Parse a `--since` value into an ISO timestamp the GitHub `since` query param
361
385
  // accepts. Accepts either an ISO 8601 timestamp (passed through, invalid date
@@ -394,20 +418,7 @@ export function parseSince(value, nowMs = Date.now()) {
394
418
  export function parseLabelMap(options, ...keys) {
395
419
  const lookup = keys.length > 0 ? keys : ["label-map", "labelMap"];
396
420
  const raw = optionCsv(options, ...lookup);
397
- if (raw.length === 0)
398
- return undefined;
399
- const map = new Map();
400
- for (const entry of raw) {
401
- const eq = entry.indexOf("=");
402
- if (eq <= 0)
403
- continue; // need a non-empty "from" before the '='
404
- const from = entry.slice(0, eq).trim();
405
- const to = entry.slice(eq + 1).trim();
406
- if (!from || !to)
407
- continue;
408
- map.set(from, to);
409
- }
410
- return map.size > 0 ? map : undefined;
421
+ return parseAssignmentMap(raw);
411
422
  }
412
423
  // Apply a label translation table to a list of labels. Labels with a mapping
413
424
  // are replaced; unmapped labels pass through unchanged. Two source labels that
@@ -761,7 +772,7 @@ export function readPmItems(pmRoot, platform = process.platform, pmPackageRoot =
761
772
  command = process.execPath;
762
773
  args = [cliEntry, ...listArgs];
763
774
  }
764
- const result = spawnSync(command, args, { encoding: "utf-8", maxBuffer });
775
+ const result = spawnSync(command, args, { encoding: "utf-8", maxBuffer, env: { ...process.env } });
765
776
  // A buffer overrun kills the child with status null and no stderr, so name the
766
777
  // real cause instead of reporting an unexplained failure.
767
778
  if (result.error) {
@@ -846,6 +857,10 @@ export function indexByProvenance(items) {
846
857
  }
847
858
  return index;
848
859
  }
860
+ /** Render thrown values consistently across subprocess, SDK, and HTTP boundaries. */
861
+ function errorMessage(error) {
862
+ return error instanceof Error ? error.message : String(error);
863
+ }
849
864
  // ---------------------------------------------------------------------------
850
865
  // Atomic GitHub issue import (pm-cli >= 2026.7.20 commitItemMutations)
851
866
  // ---------------------------------------------------------------------------
@@ -889,11 +904,15 @@ export async function resolveCommitItemMutations(importSdk) {
889
904
  return cachedCommitItemMutations;
890
905
  }
891
906
  async function resolveAtomicSdkFunctions(opts) {
892
- const needsSdk = !opts.commitItemMutations || !opts.normalizeItemId || !opts.readSettings;
893
907
  return {
894
- commitItemMutations: opts.commitItemMutations ?? assertSdkFunction(needsSdk ? sdkCommitItemMutations : undefined, "commitItemMutations"),
895
- normalizeItemId: opts.normalizeItemId ?? assertSdkFunction(needsSdk ? sdkNormalizeItemId : undefined, "normalizeItemId"),
896
- readSettings: opts.readSettings ?? assertSdkFunction(needsSdk ? sdkReadSettings : undefined, "readSettings"),
908
+ // Each `??` fallback runs only when the injected option is absent, so the
909
+ // SDK function is always the value handed onward; a separate `needsSdk ?`
910
+ // branch inside the fallback can never select anything else (when every
911
+ // option is injected the fallbacks do not run at all), so the SDK binding
912
+ // is passed directly.
913
+ commitItemMutations: opts.commitItemMutations ?? assertSdkFunction(sdkCommitItemMutations, "commitItemMutations"),
914
+ normalizeItemId: opts.normalizeItemId ?? assertSdkFunction(sdkNormalizeItemId, "normalizeItemId"),
915
+ readSettings: opts.readSettings ?? assertSdkFunction(sdkReadSettings, "readSettings"),
897
916
  };
898
917
  }
899
918
  /**
@@ -1058,7 +1077,7 @@ export async function importGithubAtomic(pmRoot, repo, entries, opts = {}) {
1058
1077
  };
1059
1078
  }
1060
1079
  catch (err) {
1061
- const msg = err instanceof Error ? err.message : String(err);
1080
+ const msg = errorMessage(err);
1062
1081
  if (err instanceof AggregateError || /compensation failed/i.test(msg)) {
1063
1082
  throw new CommandError(`Atomic GitHub import failed and compensation was incomplete. The tracker may contain partially applied state; retry the same import to resume transaction ${transactionId}, then inspect its durable journal if recovery still fails. Underlying error: ${msg}`, EXIT_CODE.GENERIC_FAILURE);
1064
1083
  }
@@ -1132,10 +1151,10 @@ export async function fetchComments(issue, repo, token) {
1132
1151
  page = JSON.parse(body);
1133
1152
  }
1134
1153
  catch {
1135
- break;
1154
+ throw new CommandError("GitHub comments response is not valid JSON.");
1136
1155
  }
1137
1156
  if (!Array.isArray(page))
1138
- break;
1157
+ throw new CommandError("GitHub comments response must be an array.");
1139
1158
  comments.push(...page);
1140
1159
  nextUrl = parseNextLink(linkHeader);
1141
1160
  }
@@ -1204,28 +1223,6 @@ export function composeBody(issue, comments) {
1204
1223
  export const IMPORT_LOCK_TTL_MS_DEFAULT = 5 * 60_000;
1205
1224
  /** Default total budget a caller waits to acquire a contended comment-sync lock before giving up and skipping that item's sync. */
1206
1225
  export const IMPORT_LOCK_WAIT_MS_DEFAULT = 30_000;
1207
- /**
1208
- * Resolve the pm data dir from the `pmRoot` a command handler receives.
1209
- *
1210
- * The host may hand either the workspace root (the dir containing `.agents/pm`)
1211
- * or the data dir itself — the pm CLI accepts both for `--path`. This returns
1212
- * the dir that actually holds `settings.json` and `locks/`, defaulting to
1213
- * `pmRoot` unchanged when no nested `.agents/pm` exists.
1214
- *
1215
- * @param pmRoot - The path supplied by the extension host.
1216
- * @returns The resolved pm data directory.
1217
- */
1218
- export function resolvePmDataDir(pmRoot) {
1219
- const nested = path.join(pmRoot, ".agents", "pm");
1220
- try {
1221
- if (fs.statSync(nested).isDirectory())
1222
- return nested;
1223
- }
1224
- catch {
1225
- // Not the workspace-root form — assume pmRoot already is the data dir.
1226
- }
1227
- return pmRoot;
1228
- }
1229
1226
  /**
1230
1227
  * Absolute lock file path for one item's comment-sync critical section.
1231
1228
  *
@@ -1274,13 +1271,17 @@ function readImportLockPayload(lockPath) {
1274
1271
  *
1275
1272
  * Signals the PID with signal 0 (no effect); `EPERM` means the process exists
1276
1273
  * but belongs to another user (still alive), while `ESRCH` means it is gone.
1277
- * Non-numeric or non-positive PIDs are treated as not-alive.
1274
+ * Non-positive PIDs are treated as not-alive. The sole production caller already
1275
+ * rejects non-numeric PIDs before calling, so this function does not re-check the type.
1278
1276
  *
1279
- * @param pid - The recorded owner PID (untyped at the call site).
1277
+ * @internal Exported so the lock tests can drive the PID boundary directly.
1278
+ * `stripInternal` keeps it out of the published declarations.
1279
+ * @param pid - The recorded owner PID. Callers pass a positive integer; non-positive
1280
+ * values are rejected here so a direct call cannot signal the process group.
1280
1281
  * @returns True when the PID names a live process.
1281
1282
  */
1282
- function isLockOwnerAlive(pid) {
1283
- if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
1283
+ export function isLockOwnerAlive(pid) {
1284
+ if (pid <= 0)
1284
1285
  return false;
1285
1286
  try {
1286
1287
  process.kill(pid, 0);
@@ -1444,7 +1445,7 @@ export async function acquireImportLock(pmRoot, itemId, opts = {}) {
1444
1445
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
1445
1446
  }
1446
1447
  catch (err) {
1447
- const msg = err instanceof Error ? err.message : String(err);
1448
+ const msg = errorMessage(err);
1448
1449
  console.error(`pm-github: comment-sync lock unavailable (${msg}) — proceeding without cross-process serialization`);
1449
1450
  return { status: "degraded" };
1450
1451
  }
@@ -1513,11 +1514,13 @@ export async function acquireImportLock(pmRoot, itemId, opts = {}) {
1513
1514
  }
1514
1515
  const code = err?.code;
1515
1516
  if (code !== "EEXIST") {
1516
- const msg = err instanceof Error ? err.message : String(err);
1517
+ const msg = errorMessage(err);
1517
1518
  console.error(`pm-github: comment-sync lock failed (${msg}) — proceeding without cross-process serialization`);
1518
1519
  return { status: "degraded" };
1519
1520
  }
1520
- // The lock exists. If it vanished before we could stat it, retry at once.
1521
+ // The lock exists for open(O_EXCL) but cannot be stat'd (it vanished, or
1522
+ // it is a broken symlink). Retry, but honor the wait budget: a persistent
1523
+ // stat failure must not busy-spin the process until an external killer.
1521
1524
  let existing;
1522
1525
  let mtimeMs = Number.NaN;
1523
1526
  try {
@@ -1525,6 +1528,10 @@ export async function acquireImportLock(pmRoot, itemId, opts = {}) {
1525
1528
  existing = readImportLockPayload(lockPath);
1526
1529
  }
1527
1530
  catch {
1531
+ const vanishedForMs = Date.now() - startedAt;
1532
+ if (vanishedForMs >= waitMs)
1533
+ return { status: "contended" };
1534
+ await sleep(Math.min(backoffMs, Math.max(1, waitMs - vanishedForMs)));
1528
1535
  continue;
1529
1536
  }
1530
1537
  const staleReason = importLockStaleReason(lockPath, existing, mtimeMs, ttlMs);
@@ -1661,24 +1668,26 @@ export function parseCreatedItemId(stdout) {
1661
1668
  *
1662
1669
  * Idempotent: comments already present (matched by their marker id) are skipped,
1663
1670
  * so re-running import never duplicates. Each GitHub comment becomes one pm
1664
- * comment authored by the GitHub login. Failures are logged and never abort the
1665
- * import; a contended or unavailable lock degrades gracefully (see
1666
- * {@link acquireImportLock}).
1671
+ * comment authored by the GitHub login. Failures are reported in the returned
1672
+ * `failed` count and logged, never thrown: a contended or unavailable lock, an
1673
+ * unreadable existing-comment collection, and each refused add all count every
1674
+ * comment that did not land (see {@link acquireImportLock}). Gated callers turn
1675
+ * a non-zero `failed` count into a fail-closed refusal; ungated ones continue.
1667
1676
  *
1668
1677
  * @param itemId - The pm item to append comments to.
1669
1678
  * @param comments - The GitHub comments to sync.
1670
1679
  * @param pmRoot - Workspace root or pm data dir.
1671
1680
  * @param issueNumber - The source issue number (for log prefixes).
1672
- * @returns How many comments were added and how many skipped as duplicates.
1681
+ * @returns How many comments were added, skipped as duplicates, and not written.
1673
1682
  */
1674
1683
  export async function syncGithubCommentsToAnnotations(itemId, comments, pmRoot, issueNumber) {
1675
1684
  if (comments.length === 0)
1676
- return { added: 0, skipped: 0 };
1685
+ return { added: 0, skipped: 0, failed: 0 };
1677
1686
  const acquisition = await acquireImportLock(pmRoot, itemId);
1678
1687
  if (acquisition.status === "contended") {
1679
1688
  console.error(`#${issueNumber}: comment sync for ${itemId} skipped — another import holds the ` +
1680
1689
  `comment-sync lock; re-run import to pick up the comments`);
1681
- return { added: 0, skipped: 0 };
1690
+ return { added: 0, skipped: 0, failed: comments.length };
1682
1691
  }
1683
1692
  const release = acquisition.status === "acquired" ? () => acquisition.lock.release() : () => { };
1684
1693
  try {
@@ -1688,13 +1697,14 @@ export async function syncGithubCommentsToAnnotations(itemId, comments, pmRoot,
1688
1697
  existing = list.comments;
1689
1698
  }
1690
1699
  catch (err) {
1691
- const msg = err instanceof Error ? err.message : String(err);
1700
+ const msg = errorMessage(err);
1692
1701
  console.error(`#${issueNumber}: could not read existing comments for ${itemId} — ${msg}`);
1693
- return { added: 0, skipped: 0 };
1702
+ return { added: 0, skipped: 0, failed: comments.length };
1694
1703
  }
1695
1704
  const synced = extractSyncedCommentIds(existing);
1696
1705
  let added = 0;
1697
1706
  let skipped = 0;
1707
+ let failed = 0;
1698
1708
  for (const c of comments) {
1699
1709
  if (synced.has(c.id)) {
1700
1710
  skipped++;
@@ -1706,11 +1716,12 @@ export async function syncGithubCommentsToAnnotations(itemId, comments, pmRoot,
1706
1716
  added++;
1707
1717
  }
1708
1718
  catch (err) {
1709
- const msg = err instanceof Error ? err.message : String(err);
1719
+ const msg = errorMessage(err);
1710
1720
  console.error(`#${issueNumber}: comment ${c.id} sync failed — ${msg}`);
1721
+ failed++;
1711
1722
  }
1712
1723
  }
1713
- return { added, skipped };
1724
+ return { added, skipped, failed };
1714
1725
  }
1715
1726
  finally {
1716
1727
  release();
@@ -1808,6 +1819,7 @@ export function parseImportOptions(options) {
1808
1819
  dryRun: optionEnabled(options, "dry-run", "dryRun"),
1809
1820
  atomic: optionEnabled(options, "atomic"),
1810
1821
  linkDeps: optionEnabled(options, "link-deps", "linkDeps"),
1822
+ gate: optionEnabled(options, "gate"),
1811
1823
  };
1812
1824
  }
1813
1825
  // Spawn the `pm` CLI with the given argv and return a normalized ok/stdout/stderr
@@ -1815,7 +1827,7 @@ export function parseImportOptions(options) {
1815
1827
  // error-handling shape.
1816
1828
  function pmRun(args) {
1817
1829
  const maxBuffer = pmJsonMaxBuffer();
1818
- const result = spawnSync("pm", args, { encoding: "utf-8", maxBuffer });
1830
+ const result = spawnSync("pm", args, { encoding: "utf-8", maxBuffer, env: { ...process.env } });
1819
1831
  return { ok: result.status === 0, stderr: result.stderr || "", stdout: result.stdout || "" };
1820
1832
  }
1821
1833
  // Strip fenced (```…```) and inline (`…`) code so `#123` mentions inside code
@@ -2037,7 +2049,7 @@ export async function linkImportedDependencies(repo, issues, pmRoot, deps = {})
2037
2049
  linked: 0,
2038
2050
  unresolved: 0,
2039
2051
  orderingCycleWarnings: [],
2040
- failures: [`dependency linking skipped: ${err instanceof Error ? err.message : String(err)}`],
2052
+ failures: [`dependency linking skipped: ${errorMessage(err)}`],
2041
2053
  };
2042
2054
  }
2043
2055
  const provenance = buildProvenanceIndexFromMetadata(before);
@@ -2070,7 +2082,7 @@ export async function linkImportedDependencies(repo, issues, pmRoot, deps = {})
2070
2082
  }
2071
2083
  }
2072
2084
  catch (err) {
2073
- failures.push(`ordering-cycle advisory skipped: ${err instanceof Error ? err.message : String(err)}`);
2085
+ failures.push(`ordering-cycle advisory skipped: ${errorMessage(err)}`);
2074
2086
  }
2075
2087
  }
2076
2088
  return { linked, unresolved, orderingCycleWarnings: [...warnings], failures };
@@ -2108,7 +2120,10 @@ async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueCo
2108
2120
  comments = await fetchIssueComments(issue, repo, token);
2109
2121
  }
2110
2122
  catch (err) {
2111
- const msg = err instanceof Error ? err.message : String(err);
2123
+ const msg = errorMessage(err);
2124
+ if (opts.gate)
2125
+ throw new CommandError(`pm github gate: comments for issue #${issue.number} could not be read.`);
2126
+ // The gated case throws above, so this diagnostic always names the plain path.
2112
2127
  console.error(`#${issue.number}: failed to fetch comments — ${msg}`);
2113
2128
  }
2114
2129
  }
@@ -2132,14 +2147,172 @@ async function prepareGithubImport(issue, repo, opts, token, match, fetchIssueCo
2132
2147
  match,
2133
2148
  };
2134
2149
  }
2150
+ /**
2151
+ * Verify that a gated import's plan accounts for every fetched issue exactly once.
2152
+ *
2153
+ * An import that silently drops an issue (or plans one twice) would leave the
2154
+ * tracker out of sync with GitHub while reporting success, and the sync PR
2155
+ * would then publish an incomplete state. The check is fail-closed: every
2156
+ * filtered issue number must appear in the prepared entries or the explicit
2157
+ * skip list exactly once, nothing outside the fetched set may appear, and the
2158
+ * counts must reconcile. A violation throws {@link CommandError} before any
2159
+ * mutation happens.
2160
+ *
2161
+ * @param issues - The filtered issues the fetch delivered.
2162
+ * @param prepared - The plan entries built from them.
2163
+ * @param skippedNumbers - Issue numbers the plan explicitly skips.
2164
+ * @returns The reconciled counts on success.
2165
+ */
2166
+ export function verifyImportPlanCompleteness(issues, prepared, skippedNumbers) {
2167
+ const fetched = new Set(issues.map((issue) => issue.number));
2168
+ const planned = new Set();
2169
+ let duplicates = 0;
2170
+ for (const entry of prepared) {
2171
+ if (planned.has(entry.issueNumber))
2172
+ duplicates++;
2173
+ planned.add(entry.issueNumber);
2174
+ }
2175
+ const skippedSet = new Set(skippedNumbers);
2176
+ const unaccounted = [...fetched].filter((number) => !planned.has(number) && !skippedSet.has(number));
2177
+ const foreign = [...planned, ...skippedNumbers].filter((number) => !fetched.has(number));
2178
+ const countsReconcile = prepared.length + skippedNumbers.length === issues.length;
2179
+ const overlapping = skippedNumbers.some((number) => planned.has(number));
2180
+ const duplicateFetch = fetched.size !== issues.length;
2181
+ const duplicateSkip = skippedSet.size !== skippedNumbers.length;
2182
+ if (overlapping || duplicateFetch || duplicateSkip || duplicates > 0 || unaccounted.length > 0 || foreign.length > 0 || !countsReconcile) {
2183
+ throw new CommandError(`pm github gate: import plan is incomplete — fetched ${issues.length}, planned ${prepared.length}, ` +
2184
+ `skipped ${skippedNumbers.length}; duplicates ${duplicates}, unaccounted [${unaccounted.join(", ")}], ` +
2185
+ `out-of-plan [${foreign.join(", ")}]. No item was written.`, EXIT_CODE.GENERIC_FAILURE);
2186
+ }
2187
+ return { fetched: issues.length, planned: prepared.length, skipped: skippedNumbers.length };
2188
+ }
2189
+ /**
2190
+ * Verify that a gated import's plan keeps a second run a no-op via provenance tags.
2191
+ *
2192
+ * Idempotency is only real when every re-import lands on exactly one item: a
2193
+ * corpus with two items sharing a `gh:owner/repo#N` tag updates one and lets the
2194
+ * other drift, and a plan entry targeting a second entry's item would write the
2195
+ * same item twice. The check fails closed on duplicate provenance tags in the
2196
+ * existing corpus, on plan entries that would create a new item without its
2197
+ * provenance tag, and on two plan entries resolving to the same existing item.
2198
+ *
2199
+ * @param existing - The complete existing pm item corpus.
2200
+ * @param prepared - The plan entries about to be written.
2201
+ * @param repo - The `owner/repo` being imported from.
2202
+ * @returns The provenance idempotency receipts on success.
2203
+ */
2204
+ export function verifyImportIdempotency(existing, prepared, repo) {
2205
+ const tagOwners = new Map();
2206
+ const duplicateTags = new Set();
2207
+ for (const item of existing) {
2208
+ if (!item.id)
2209
+ continue;
2210
+ for (const tag of item.tags ?? []) {
2211
+ const parsed = parseProvenanceTag(tag);
2212
+ if (!parsed)
2213
+ continue;
2214
+ const key = `${parsed.repo}#${parsed.number}`;
2215
+ const prior = tagOwners.get(key);
2216
+ if (prior && prior !== item.id)
2217
+ duplicateTags.add(key);
2218
+ else
2219
+ tagOwners.set(key, item.id);
2220
+ }
2221
+ }
2222
+ const matchedIds = [];
2223
+ let untaggedNewEntries = 0;
2224
+ for (const entry of prepared) {
2225
+ if (entry.match?.id) {
2226
+ matchedIds.push(entry.match.id);
2227
+ continue;
2228
+ }
2229
+ if (!entry.tags.includes(provenanceTag(repo, entry.issueNumber)))
2230
+ untaggedNewEntries++;
2231
+ }
2232
+ const conflictingTargets = matchedIds.length - new Set(matchedIds).size;
2233
+ if (duplicateTags.size > 0 || untaggedNewEntries > 0 || conflictingTargets > 0) {
2234
+ throw new CommandError(`pm github gate: import plan is not idempotent — ${duplicateTags.size} duplicate provenance tag(s) ` +
2235
+ `in the existing corpus, ${untaggedNewEntries} new entr(y/ies) without a provenance tag, ` +
2236
+ `${conflictingTargets} conflicting target(s). No item was written.`, EXIT_CODE.GENERIC_FAILURE);
2237
+ }
2238
+ return {
2239
+ provenance_indexed: tagOwners.size,
2240
+ matched_by_provenance: matchedIds.length,
2241
+ new_entries: prepared.length - matchedIds.length,
2242
+ };
2243
+ }
2244
+ /**
2245
+ * Verify the persisted corpus actually contains every issue in the written plan.
2246
+ *
2247
+ * A recovered SDK journal alone is insufficient evidence after local files are
2248
+ * reset or removed. Require unique durable provenance before a caller may push.
2249
+ *
2250
+ * @param existing - Complete post-write item corpus.
2251
+ * @param prepared - Every entry the verified plan required.
2252
+ * @param repo - Source repository whose provenance must be present.
2253
+ * @returns Number of durable issue identities verified.
2254
+ */
2255
+ export function verifyImportedProvenance(existing, prepared, repo) {
2256
+ const index = indexByProvenance(existing);
2257
+ const persisted = prepared.map(entry => ({ ...entry, match: index.get(`${repo.toLowerCase()}#${entry.issueNumber}`) }));
2258
+ if (persisted.some(entry => !entry.match?.id))
2259
+ throw new CommandError("pm github gate: persisted corpus does not account for the complete import plan. Nothing may be pushed.");
2260
+ verifyImportIdempotency(existing, persisted, repo);
2261
+ return persisted.length;
2262
+ }
2263
+ /**
2264
+ * Execute the real tracker gate with the shared fail-closed command error boundary.
2265
+ *
2266
+ * @internal Exported to verify collaborator failures against real Git fixtures.
2267
+ * @param input - Tracker root and optional gate input dependencies.
2268
+ * @returns A completed gate report; unexpected errors are redacted.
2269
+ */
2270
+ export function runCommandTrackerGate(input) {
2271
+ try {
2272
+ return runTrackerGate(input);
2273
+ }
2274
+ catch (error) {
2275
+ throw new CommandError(error instanceof GateInputError ? error.message : "pm github gate: scanner error; scan did not complete.", EXIT_CODE.GENERIC_FAILURE);
2276
+ }
2277
+ }
2278
+ /**
2279
+ * Run the post-write tracker privacy gate and turn its outcome into a receipt.
2280
+ *
2281
+ * Runs the real gate over the proposed tracker change the import just wrote,
2282
+ * emits the human-readable report to stderr, and throws {@link CommandError}
2283
+ * with the findings (rule + item + field + hash — never the matched content)
2284
+ * when the verdict is fail, so a caller higher in the pipeline (a sync workflow)
2285
+ * never reaches its commit/push step on a gated failure. Gate input failures
2286
+ * (not a Git work tree, unreadable change, malformed allowlist) fail identically.
2287
+ *
2288
+ * @param pmRoot - Workspace root or pm data dir the import wrote to.
2289
+ * @returns The pass receipt, embedded into the import result.
2290
+ */
2291
+ function gateImportWrites(pmRoot) {
2292
+ const report = runCommandTrackerGate({ pmRoot });
2293
+ const lines = formatGateReport(report);
2294
+ console.error(lines.join("\n"));
2295
+ if (report.verdict === "fail") {
2296
+ throw new CommandError(JSON.stringify({ ...report, error: "pm github gate: FAIL" }), EXIT_CODE.GENERIC_FAILURE);
2297
+ }
2298
+ return {
2299
+ verdict: report.verdict,
2300
+ scanned_files: report.scanned_files,
2301
+ findings: report.findings.length,
2302
+ allowlisted: report.allowlisted,
2303
+ };
2304
+ }
2135
2305
  /**
2136
2306
  * Run the full GitHub issue import flow.
2137
2307
  *
2138
2308
  * Idempotent: items already linked (by provenance tag) to a fetched issue are
2139
2309
  * UPDATEd; new issues are created. Honors `--atomic` (one crash-resumable
2140
2310
  * transaction) versus the per-item `pm` mutation path, optional `--link-deps`,
2141
- * and `--dry-run`. Returns a structured result and throws {@link CommandError}
2142
- * (with a semantic exit code) on failure.
2311
+ * and `--dry-run`. With `--gate` the plan is verified (completeness + provenance
2312
+ * idempotency) BEFORE any mutation, and the proposed tracker change is scanned
2313
+ * by the fail-closed privacy gate AFTER it — a gated failure exits non-zero so
2314
+ * a caller never commits or pushes the change. Returns a structured result and
2315
+ * throws {@link CommandError} (with a semantic exit code) on failure.
2143
2316
  *
2144
2317
  * @param repoArg - The `owner/repo` to import from.
2145
2318
  * @param pmRoot - Workspace root or pm data dir.
@@ -2161,7 +2334,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2161
2334
  fetched = await (dependencies.fetchIssues ?? fetchAllIssues)(repo, opts, token);
2162
2335
  }
2163
2336
  catch (err) {
2164
- const msg = err instanceof Error ? err.message : String(err);
2337
+ const msg = errorMessage(err);
2165
2338
  const hint = !token && /HTTP 403/.test(msg)
2166
2339
  ? " — set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` to raise the rate limit (60→5000/hr) and reach private repos"
2167
2340
  : "";
@@ -2171,6 +2344,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2171
2344
  const filtered = applyClientFilters(fetched, opts);
2172
2345
  if (filtered.length === 0) {
2173
2346
  console.error("No issues found.");
2347
+ // Even a no-op gated import must prove the workspace is gateable (a Git work
2348
+ // tree the scanner can read), so the empty-change gate scan runs here too.
2349
+ const emptyGateReceipt = opts.gate && !opts.dryRun ? gateImportWrites(pmRoot) : undefined;
2174
2350
  if (opts.atomic && opts.dryRun) {
2175
2351
  return {
2176
2352
  dryRun: true,
@@ -2180,7 +2356,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2180
2356
  atomic: true,
2181
2357
  };
2182
2358
  }
2183
- return { imported: 0, updated: 0, skipped: 0 };
2359
+ return { imported: 0, updated: 0, skipped: 0, ...(emptyGateReceipt ? { gate: emptyGateReceipt } : {}) };
2184
2360
  }
2185
2361
  console.error(`Found ${filtered.length} issue(s).`);
2186
2362
  // Build the idempotency index once up-front — including for a dry run. Skipping
@@ -2188,20 +2364,64 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2188
2364
  // reported "would import N, skip 0" where the real run performs updates for
2189
2365
  // already-linked issues. A preview that overstates creates reads as "this will
2190
2366
  // duplicate my whole tracker" and is the one thing --dry-run exists to rule out.
2191
- const existing = indexByProvenance((dependencies.readItems ?? readPmItems)(pmRoot));
2367
+ const existingItems = (dependencies.readItems ?? readPmItems)(pmRoot);
2368
+ const existing = indexByProvenance(existingItems);
2192
2369
  let imported = 0;
2193
2370
  let updated = 0;
2194
2371
  let skipped = 0;
2195
- if (opts.atomic) {
2196
- const prepared = [];
2372
+ // Planned native comments the write phase could not land (contended lock,
2373
+ // refused add, unparsable created id). Ungated runs warn and continue; gated
2374
+ // runs add this to the plan-divergence refusal at the end.
2375
+ let commentWriteFailures = 0;
2376
+ // Gated pipeline, phase 1 — plan BEFORE any mutation. The same prepare step
2377
+ // the write paths use builds every plan entry up front, so completeness
2378
+ // (every fetched issue accounted for exactly once) and idempotency (every
2379
+ // re-import lands on exactly one provenance-tagged item) are proven while
2380
+ // the tracker is still untouched. Both checks throw before any write.
2381
+ let gatePlan;
2382
+ let gateCompleteness;
2383
+ let gateIdempotency;
2384
+ let gatePlanByNumber;
2385
+ if (opts.gate || opts.atomic) {
2386
+ const planned = [];
2387
+ const skippedNumbers = [];
2197
2388
  for (const issue of filtered) {
2198
2389
  const entry = await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments);
2199
2390
  if (!entry) {
2200
- skipped++;
2391
+ skippedNumbers.push(issue.number);
2201
2392
  continue;
2202
2393
  }
2203
- prepared.push(entry);
2394
+ planned.push(entry);
2395
+ }
2396
+ if (opts.gate) {
2397
+ gateCompleteness = verifyImportPlanCompleteness(filtered, planned, skippedNumbers);
2398
+ gateIdempotency = verifyImportIdempotency(existingItems, planned, repo);
2204
2399
  }
2400
+ gatePlan = planned;
2401
+ gatePlanByNumber = new Map(planned.map((entry) => [entry.issueNumber, entry]));
2402
+ skipped = skippedNumbers.length;
2403
+ if (opts.gate && !opts.dryRun) {
2404
+ const preview = runCommandTrackerGate({ pmRoot, plannedItems: planned.map(entry => ({
2405
+ itemId: entry.match?.id ?? `github-issue-${entry.issueNumber}`,
2406
+ fields: { title: entry.title, description: entry.description, body: entry.body, tags: entry.tags,
2407
+ assignee: entry.assignee, sprint: entry.milestone,
2408
+ comments: entry.syncAnnotations ? entry.comments.map(comment => ({
2409
+ author: comment.user?.login, text: comment.body, created_at: comment.created_at,
2410
+ })) : [] },
2411
+ })) });
2412
+ if (preview.verdict === "fail")
2413
+ throw new CommandError(JSON.stringify({ ...preview, error: "pm github gate: FAIL before write" }));
2414
+ }
2415
+ if (opts.gate)
2416
+ console.error(`pm github gate: plan verified — ${gateCompleteness.planned} planned, ${gateCompleteness.skipped} skipped, ` +
2417
+ `${gateIdempotency.matched_by_provenance} matched by provenance, ${gateIdempotency.new_entries} new.`);
2418
+ }
2419
+ const gatePlanFields = opts.gate ? { gate: { completeness: gateCompleteness, idempotency: gateIdempotency, scan: "post-write" } } : {};
2420
+ if (opts.atomic) {
2421
+ // The gated pipeline already prepared every entry (and counted its skips)
2422
+ // during the pre-mutation plan phase; reuse that plan verbatim so the write
2423
+ // phase cannot diverge from the verified plan.
2424
+ const prepared = [...gatePlan];
2205
2425
  if (prepared.length === 0) {
2206
2426
  if (opts.dryRun) {
2207
2427
  console.error(`[dry-run] Atomic plan would import 0, update 0, skip ${skipped}.`);
@@ -2220,7 +2440,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2220
2440
  updated = prepared.length - imported;
2221
2441
  for (const entry of prepared) {
2222
2442
  const action = entry.match?.id ? "update" : "import";
2223
- console.error(` [dry-run][atomic] #${entry.issueNumber} ${action}: ${entry.title} (${entry.status})`);
2443
+ console.error(` [dry-run][atomic] #${entry.issueNumber} ${action}: ${opts.gate ? "(gated title)" : entry.title} (${entry.status})`);
2224
2444
  }
2225
2445
  console.error(`[dry-run] Atomic plan would import ${imported}, update ${updated}, skip ${skipped}.`);
2226
2446
  if (opts.linkDeps) {
@@ -2234,17 +2454,29 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2234
2454
  wouldSkip: skipped,
2235
2455
  atomic: true,
2236
2456
  ...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
2457
+ ...gatePlanFields,
2237
2458
  };
2238
2459
  }
2239
2460
  const result = await (dependencies.commitAtomic ?? importGithubAtomic)(pmRoot, repo, prepared);
2461
+ if (opts.gate)
2462
+ verifyImportedProvenance((dependencies.readItems ?? readPmItems)(pmRoot), prepared, repo);
2463
+ // Planned native comments are part of the verified plan: a contended lock,
2464
+ // a refused add, or a committed entry whose created id is missing from the
2465
+ // post-commit routing map all leave planned comments unwritten, so they are
2466
+ // counted and gated runs fail closed below instead of exiting clean.
2467
+ let commentWriteFailures = 0;
2240
2468
  for (const entry of prepared) {
2241
2469
  if (!entry.syncAnnotations)
2242
2470
  continue;
2243
2471
  const itemId = result.itemIds.get(entry.issueNumber);
2244
2472
  if (itemId) {
2245
- await syncGithubCommentsToAnnotations(itemId, entry.comments, pmRoot, entry.issueNumber);
2473
+ commentWriteFailures += (await syncGithubCommentsToAnnotations(itemId, entry.comments, pmRoot, entry.issueNumber)).failed;
2474
+ }
2475
+ else {
2476
+ commentWriteFailures += entry.comments.length;
2246
2477
  }
2247
2478
  }
2479
+ failClosedOnPlanDivergence(opts.gate, skipped, skipped, commentWriteFailures);
2248
2480
  if (result.recovered) {
2249
2481
  console.error(`Atomic import recovered transaction ${result.transactionId} covering ${result.recoveredItems ?? prepared.length} item(s).`);
2250
2482
  }
@@ -2257,6 +2489,9 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2257
2489
  reportDepLink(atomicDepLink);
2258
2490
  // itemIds is an internal post-commit routing map for native comments. Maps
2259
2491
  // serialize as `{}` in JSON, so keep it out of the public command result.
2492
+ // Gated pipeline, phase 2 — the writes are on disk but nothing is committed
2493
+ // or pushed; scan the proposed tracker change now and fail closed.
2494
+ const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined;
2260
2495
  return {
2261
2496
  transactionId: result.transactionId,
2262
2497
  recovered: result.recovered,
@@ -2266,12 +2501,21 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2266
2501
  skipped,
2267
2502
  atomic: true,
2268
2503
  ...depLinkResultFields(atomicDepLink),
2504
+ ...(gateReceipt ? { gate: gateReceipt } : {}),
2269
2505
  };
2270
2506
  }
2271
2507
  for (const issue of filtered) {
2272
- const prepared = await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments);
2508
+ // With --gate, the pre-mutation plan phase already prepared (or explicitly
2509
+ // skipped) every issue; reuse the verified plan instead of re-preparing so
2510
+ // the write phase cannot diverge from it.
2511
+ const prepared = gatePlanByNumber
2512
+ ? gatePlanByNumber.get(issue.number)
2513
+ : await prepareGithubImport(issue, repo, opts, token, existing.get(`${repo.toLowerCase()}#${issue.number}`), dependencies.fetchIssueComments);
2273
2514
  if (!prepared) {
2274
- skipped++;
2515
+ // A gated run counted its skips during the verified plan phase; only the
2516
+ // ungated path counts them here.
2517
+ if (!gatePlanByNumber)
2518
+ skipped++;
2275
2519
  continue;
2276
2520
  }
2277
2521
  const labels = issue.labels.map((l) => l.name).filter(Boolean);
@@ -2295,8 +2539,8 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2295
2539
  };
2296
2540
  if (opts.dryRun) {
2297
2541
  const action = match?.id ? "update" : "import";
2298
- const metadata = labels.length > 0 ? `${status}, ${labels.join(",")}` : status;
2299
- console.error(` [dry-run] #${issue.number} ${action}: ${title} (${metadata})`);
2542
+ const metadata = !opts.gate && labels.length > 0 ? `${status}, ${labels.join(",")}` : status;
2543
+ console.error(` [dry-run] #${issue.number} ${action}: ${opts.gate ? "(gated title)" : title} (${metadata})`);
2300
2544
  if (match?.id)
2301
2545
  updated++;
2302
2546
  else
@@ -2344,7 +2588,7 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2344
2588
  }
2345
2589
  }
2346
2590
  if (syncAnnotations) {
2347
- await syncGithubCommentsToAnnotations(match.id, comments, pmRoot, issue.number);
2591
+ commentWriteFailures += (await syncGithubCommentsToAnnotations(match.id, comments, pmRoot, issue.number)).failed;
2348
2592
  }
2349
2593
  updated++;
2350
2594
  continue;
@@ -2401,10 +2645,14 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2401
2645
  }
2402
2646
  if (syncAnnotations) {
2403
2647
  if (createdId) {
2404
- await syncGithubCommentsToAnnotations(createdId, comments, pmRoot, issue.number);
2648
+ commentWriteFailures += (await syncGithubCommentsToAnnotations(createdId, comments, pmRoot, issue.number)).failed;
2405
2649
  }
2406
2650
  else {
2651
+ // Without the created id the planned comments cannot be written; the
2652
+ // gated plan-divergence refusal below turns that into a fail-closed exit
2653
+ // while ungated runs keep today's warn-and-continue behaviour.
2407
2654
  console.error(`#${issue.number}: could not parse created item id — comments not synced`);
2655
+ commentWriteFailures += comments.length;
2408
2656
  }
2409
2657
  }
2410
2658
  imported++;
@@ -2420,19 +2668,30 @@ export async function runImport(repoArg, pmRoot, opts, dependencies = {}) {
2420
2668
  wouldImport: imported,
2421
2669
  wouldUpdate: updated,
2422
2670
  wouldSkip: skipped,
2423
- ...(opts.atomic ? { atomic: true } : {}),
2424
2671
  ...(opts.linkDeps ? { wouldLinkDependencyCandidates: countDependencyRefCandidates(repo, filtered) } : {}),
2672
+ ...gatePlanFields,
2425
2673
  };
2426
2674
  }
2427
2675
  console.error(`Imported ${imported} new, updated ${updated} existing, skipped ${skipped}.`);
2428
2676
  if (imported === 0 && updated === 0 && skipped > 0) {
2429
2677
  throw new CommandError(`Imported 0 issue(s); ${skipped} failed.`);
2430
2678
  }
2679
+ // A gated import is all-or-nothing in effect: a planned write that failed
2680
+ // (an update, close or reopen reconciliation) or a planned native comment
2681
+ // that was not written leaves the tracker different from the verified plan,
2682
+ // and provenance alone cannot see that, because the item still carries its
2683
+ // tag. Fail closed before anything can be pushed.
2684
+ failClosedOnPlanDivergence(opts.gate, gateCompleteness?.skipped ?? skipped, skipped, commentWriteFailures);
2431
2685
  const depLink = opts.linkDeps
2432
2686
  ? await linkImportedDependencies(repo, filtered, pmRoot, dependencies)
2433
2687
  : undefined;
2434
2688
  reportDepLink(depLink);
2435
- return { imported, updated, skipped, ...depLinkResultFields(depLink) };
2689
+ // Gated pipeline, phase 2 — the writes are on disk but nothing is committed or
2690
+ // pushed; scan the proposed tracker change now and fail closed.
2691
+ if (opts.gate)
2692
+ verifyImportedProvenance((dependencies.readItems ?? readPmItems)(pmRoot), gatePlan, repo);
2693
+ const gateReceipt = opts.gate ? gateImportWrites(pmRoot) : undefined;
2694
+ return { imported, updated, skipped, ...depLinkResultFields(depLink), ...(gateReceipt ? { gate: gateReceipt } : {}) };
2436
2695
  }
2437
2696
  /**
2438
2697
  * Emit the human-readable `--link-deps` summary to stderr.
@@ -2469,12 +2728,50 @@ function depLinkResultFields(result) {
2469
2728
  ...(result.failures.length > 0 ? { dependencyLinkFailures: result.failures } : {}),
2470
2729
  };
2471
2730
  }
2731
+ /**
2732
+ * Fail a gated import when the writes diverged from the verified plan.
2733
+ *
2734
+ * A gated import is all-or-nothing in effect: a planned item write that failed
2735
+ * (an update, close or reopen reconciliation), a planned native comment that
2736
+ * was not written, or a comment collection left unreachable by an unparsable
2737
+ * created id all leave the tracker different from the verified plan — and
2738
+ * provenance alone cannot see that, because the item still carries its tag and
2739
+ * missing content cannot leak anything into the post-write privacy scan. Both
2740
+ * the non-atomic and the atomic import path funnel their divergence here and
2741
+ * fail closed before anything can be pushed; ungated imports already warned.
2742
+ *
2743
+ * @param gate - Whether the import runs gated; ungated runs never throw here.
2744
+ * @param plannedSkipped - Issues the verified plan already accounted as skipped.
2745
+ * @param actualSkipped - Issues the write phase actually skipped.
2746
+ * @param commentFailures - Planned native comments that were not written.
2747
+ * @throws CommandError when any planned write or comment failed on a gated run.
2748
+ */
2749
+ function failClosedOnPlanDivergence(gate, plannedSkipped, actualSkipped, commentFailures) {
2750
+ const failed = actualSkipped - plannedSkipped + commentFailures;
2751
+ if (!gate || failed <= 0)
2752
+ return;
2753
+ throw new CommandError(`pm github gate: ${failed} planned write(s) failed; the tracker does not match the verified plan. Nothing may be pushed.`);
2754
+ }
2472
2755
  // ---------------------------------------------------------------------------
2473
2756
  // Sync core — push pm status changes back to GitHub (close / reopen)
2474
2757
  // ---------------------------------------------------------------------------
2475
2758
  function pmStatusToGithubState(status) {
2476
2759
  return status === "closed" || status === "canceled" ? "closed" : "open";
2477
2760
  }
2761
+ /**
2762
+ * Require every requested item before a remote sync or export can begin.
2763
+ *
2764
+ * @param items - Complete local corpus.
2765
+ * @param requested - Selected ids, or an empty list for the complete corpus.
2766
+ * @returns Matched items in corpus order.
2767
+ * @throws CommandError when any requested identity is absent.
2768
+ */
2769
+ function requireRequestedItems(items, requested) {
2770
+ const scoped = scopeItemsByIds(items, requested.length > 0 ? requested : undefined);
2771
+ if (scoped.missing.length > 0)
2772
+ throw new CommandError(`--ids included unknown pm item id(s): ${scoped.missing.join(", ")}`, EXIT_CODE.NOT_FOUND);
2773
+ return scoped.selected;
2774
+ }
2478
2775
  // Build the pm → GitHub issue sync plan: for each pm item linked to `repo`, emit
2479
2776
  // a create-or-update entry keyed by the issue's provenance tag.
2480
2777
  export function planSync(items, repo) {
@@ -2504,8 +2801,8 @@ export function planSync(items, repo) {
2504
2801
  // Command handler for `pm github sync`: preview or apply the pm → GitHub issue
2505
2802
  // sync plan, scoped by --ids and honoring --dry-run / --apply.
2506
2803
  async function runSync(ctx) {
2507
- const options = ctx.options || {};
2508
- const repo = optionString(options, "repo") || ctx.args?.[0];
2804
+ const options = ctx.options;
2805
+ const repo = optionString(options, "repo") || ctx.args[0];
2509
2806
  const dryRun = optionEnabled(options, "dry-run", "dryRun");
2510
2807
  const idsProvided = optionProvided(options, "ids");
2511
2808
  const scopedIds = optionCsv(options, "ids");
@@ -2521,12 +2818,8 @@ async function runSync(ctx) {
2521
2818
  throw new CommandError("pm github sync needs a GitHub token to mutate issues " +
2522
2819
  "(set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`). Use --dry-run to preview without a token.", EXIT_CODE.USAGE);
2523
2820
  }
2524
- const allItems = readPmItems(ctx.pm_root);
2525
- const scoped = scopeItemsByIds(allItems, scopedIds.length > 0 ? scopedIds : undefined);
2526
- if (scoped.missing.length > 0) {
2527
- throw new CommandError(`--ids included unknown pm item id(s): ${scoped.missing.join(", ")}`, EXIT_CODE.NOT_FOUND);
2528
- }
2529
- const plan = planSync(scoped.selected, repo);
2821
+ const selected = requireRequestedItems(readPmItems(ctx.pm_root), scopedIds);
2822
+ const plan = planSync(selected, repo);
2530
2823
  if (plan.length === 0) {
2531
2824
  const scopeNote = scopedIds.length > 0 ? ` from --ids (${scopedIds.join(", ")})` : "";
2532
2825
  console.error(`No pm items${scopeNote} linked to ${repo} ` +
@@ -2548,7 +2841,7 @@ async function runSync(ctx) {
2548
2841
  current = JSON.parse(body);
2549
2842
  }
2550
2843
  catch (err) {
2551
- const msg = err instanceof Error ? err.message : String(err);
2844
+ const msg = errorMessage(err);
2552
2845
  console.error(`#${entry.number}: could not read upstream state — ${msg}`);
2553
2846
  skipped++;
2554
2847
  continue;
@@ -2568,7 +2861,7 @@ async function runSync(ctx) {
2568
2861
  synced++;
2569
2862
  }
2570
2863
  catch (err) {
2571
- const msg = err instanceof Error ? err.message : String(err);
2864
+ const msg = errorMessage(err);
2572
2865
  console.error(`#${entry.number}: PATCH failed — ${msg}`);
2573
2866
  skipped++;
2574
2867
  }
@@ -2675,7 +2968,7 @@ export async function applyExportPlan(plan, repo, token, requestFn) {
2675
2968
  catch (err) {
2676
2969
  // Isolate the failure: record it and keep going so one bad item never
2677
2970
  // abandons the items that follow it (and that may already be writable).
2678
- const msg = err instanceof Error ? err.message : String(err);
2971
+ const msg = errorMessage(err);
2679
2972
  const label = entry.action === "update" && entry.number !== undefined
2680
2973
  ? `#${entry.number}`
2681
2974
  : entry.id ?? `"${p.title}"`;
@@ -2746,10 +3039,10 @@ export function applyOutcomeError(plan, result, repo) {
2746
3039
  * duplicated; `--label-map` translates pm tags to GitHub labels.
2747
3040
  */
2748
3041
  async function runExport(ctx) {
2749
- const options = ctx.options || {};
2750
- const jsonMode = ctx.global?.json === true;
3042
+ const options = ctx.options;
3043
+ const jsonMode = ctx.global.json === true;
2751
3044
  const format = optionString(options, "format") || "json";
2752
- const repo = optionString(options, "repo") || ctx.args?.[0];
3045
+ const repo = optionString(options, "repo") || ctx.args[0];
2753
3046
  const apply = exportWillApply(options);
2754
3047
  const idsProvided = optionProvided(options, "ids");
2755
3048
  const scopedIds = optionCsv(options, "ids");
@@ -2757,12 +3050,8 @@ async function runExport(ctx) {
2757
3050
  if (idsProvided && scopedIds.length === 0) {
2758
3051
  throw new CommandError("--ids requires at least one pm item id (comma-separated), e.g. --ids pm-123,pm-456.", EXIT_CODE.USAGE);
2759
3052
  }
2760
- const allItems = readPmItems(ctx.pm_root);
2761
- const scoped = scopeItemsByIds(allItems, scopedIds.length > 0 ? scopedIds : undefined);
2762
- if (scoped.missing.length > 0) {
2763
- throw new CommandError(`--ids included unknown pm item id(s): ${scoped.missing.join(", ")}`, EXIT_CODE.NOT_FOUND);
2764
- }
2765
- const plan = buildExportPlan(scoped.selected, repo, labelMap);
3053
+ const selected = requireRequestedItems(readPmItems(ctx.pm_root), scopedIds);
3054
+ const plan = buildExportPlan(selected, repo, labelMap);
2766
3055
  const creates = plan.filter((e) => e.action === "create").length;
2767
3056
  const updates = plan.filter((e) => e.action === "update").length;
2768
3057
  if (!apply) {
@@ -2787,7 +3076,7 @@ async function runExport(ctx) {
2787
3076
  console.error(JSON.stringify(plan, null, 2));
2788
3077
  }
2789
3078
  const scopeNote = scopedIds.length > 0
2790
- ? ` Scoped to ${scoped.selected.length} item(s) via --ids.`
3079
+ ? ` Scoped to ${selected.length} item(s) via --ids.`
2791
3080
  : "";
2792
3081
  const labelNote = labelMap && labelMap.size > 0
2793
3082
  ? ` Label map applied (${labelMap.size} mapping(s)).`
@@ -2898,7 +3187,7 @@ export function mapSearchHits(matchedNumbers, repo, itemsByProvenance) {
2898
3187
  // (0, 1] so hits clear pm's default score threshold.
2899
3188
  hits.push({
2900
3189
  id: item.id,
2901
- score: matchedNumbers.length > 0 ? rank / matchedNumbers.length : 1,
3190
+ score: rank / matchedNumbers.length,
2902
3191
  matched_fields: [`github:${repoLc}#${number}`],
2903
3192
  });
2904
3193
  rank--;
@@ -2925,13 +3214,8 @@ export function resolveSearchRepo(options) {
2925
3214
  }
2926
3215
  // Detect whether the `gh` CLI is installed and runnable on PATH.
2927
3216
  function detectGhCli() {
2928
- try {
2929
- const r = spawnSync("gh", ["--version"], { encoding: "utf-8" });
2930
- return r.status === 0;
2931
- }
2932
- catch {
2933
- return false;
2934
- }
3217
+ const r = spawnSync("gh", ["--version"], { encoding: "utf-8", env: { ...process.env } });
3218
+ return r.status === 0;
2935
3219
  }
2936
3220
  // Report which token source is active: `env` (GITHUB_TOKEN/GH_TOKEN), `gh` (gh
2937
3221
  // auth), or `none`.
@@ -2944,8 +3228,8 @@ function detectTokenSource() {
2944
3228
  // Command handler for `pm github validate`: checks token source, gh CLI, rate
2945
3229
  // limit, and the reachable issue counts for the configured repo.
2946
3230
  async function runValidate(ctx) {
2947
- const options = ctx.options || {};
2948
- const repo = optionString(options, "repo") || ctx.args?.[0];
3231
+ const options = ctx.options;
3232
+ const repo = optionString(options, "repo") || ctx.args[0];
2949
3233
  const gh_cli = detectGhCli();
2950
3234
  const token_source = detectTokenSource();
2951
3235
  const token = resolveGitHubToken();
@@ -2974,7 +3258,7 @@ async function runValidate(ctx) {
2974
3258
  report.repo = repo;
2975
3259
  try {
2976
3260
  const res = await fetchJSON(`${githubApiBase()}/repos/${repo}`, token);
2977
- report.repo_accessible = res.status >= 200 && res.status < 300;
3261
+ report.repo_accessible = true;
2978
3262
  report.repo_status = res.status;
2979
3263
  const rate = parseRateLimit(res.headers);
2980
3264
  if (rate.remaining !== undefined)
@@ -2992,18 +3276,14 @@ async function runValidate(ctx) {
2992
3276
  (token ? "" : " — set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` to raise it (60→5000/hr)") +
2993
3277
  ".");
2994
3278
  }
2995
- if (report.repo_accessible) {
2996
- report.messages.push(`Repo ${repo} is accessible (HTTP ${res.status}).`);
2997
- }
2998
- else {
2999
- report.ok = false;
3000
- report.messages.push(`Repo ${repo} returned HTTP ${res.status}.`);
3001
- }
3279
+ // fetchJSON only resolves for 2xx; every other status throws into the catch
3280
+ // below, so a returned response is accessible by construction.
3281
+ report.messages.push(`Repo ${repo} is accessible (HTTP ${res.status}).`);
3002
3282
  }
3003
3283
  catch (err) {
3004
3284
  report.ok = false;
3005
3285
  report.repo_accessible = false;
3006
- report.messages.push(`Repo ${repo} check failed: ${err instanceof Error ? err.message : String(err)}`);
3286
+ report.messages.push(`Repo ${repo} check failed: ${errorMessage(err)}`);
3007
3287
  }
3008
3288
  }
3009
3289
  }
@@ -3199,7 +3479,7 @@ async function fetchProjectItems(projectId, token) {
3199
3479
  */
3200
3480
  async function gqlAddDraft(projectId, title, body, token) {
3201
3481
  const q = `mutation($p:ID!,$t:String!,$b:String){ addProjectV2DraftIssue(input:{projectId:$p,title:$t,body:$b}){ projectItem{ id } } }`;
3202
- const d = await githubGraphQL(token, q, { p: projectId, t: title, b: body ?? "" });
3482
+ const d = await githubGraphQL(token, q, { p: projectId, t: title, b: body });
3203
3483
  const id = d.addProjectV2DraftIssue?.projectItem?.id;
3204
3484
  if (!id)
3205
3485
  throw new CommandError("addProjectV2DraftIssue returned no item id.");
@@ -3335,8 +3615,8 @@ export async function listOwnerProjectsV2Nodes(owner, graphQL) {
3335
3615
  * @param ctx - The command-handler context.
3336
3616
  */
3337
3617
  async function runProjectList(ctx) {
3338
- const options = ctx.options || {};
3339
- const owner = optionString(options, "owner") || ctx.args?.[0];
3618
+ const options = ctx.options;
3619
+ const owner = optionString(options, "owner") || ctx.args[0];
3340
3620
  if (!owner) {
3341
3621
  throw new CommandError("Usage: pm github project list <owner> (a GitHub user or org login)", EXIT_CODE.USAGE);
3342
3622
  }
@@ -3349,7 +3629,7 @@ async function runProjectList(ctx) {
3349
3629
  closed: !!n.closed,
3350
3630
  description: n.shortDescription ?? undefined,
3351
3631
  }));
3352
- if (ctx.global?.json !== true) {
3632
+ if (ctx.global.json !== true) {
3353
3633
  if (projects.length === 0) {
3354
3634
  console.error(`No Projects v2 found for ${owner} (or none accessible with the resolved token).`);
3355
3635
  }
@@ -3362,6 +3642,22 @@ async function runProjectList(ctx) {
3362
3642
  }
3363
3643
  return { owner, projects };
3364
3644
  }
3645
+ /**
3646
+ * Require a valid owner/number board reference before a project command can run.
3647
+ *
3648
+ * Resolves positional and --project forms identically for fields, import, and
3649
+ * sync; a malformed reference exits before any GraphQL request or mutation.
3650
+ *
3651
+ * @param ctx - The host command context.
3652
+ * @param usage - Command-specific recovery instruction.
3653
+ * @returns The parsed project identity.
3654
+ */
3655
+ function requireProjectRef(ctx, usage) {
3656
+ const ref = parseProjectRef(optionString(ctx.options, "project") || ctx.args[0]);
3657
+ if (!ref)
3658
+ throw new CommandError(usage, EXIT_CODE.USAGE);
3659
+ return ref;
3660
+ }
3365
3661
  // --- project fields --------------------------------------------------------
3366
3662
  /**
3367
3663
  * Command handler for `pm github project fields`: show a board's fields.
@@ -3372,11 +3668,8 @@ async function runProjectList(ctx) {
3372
3668
  * @param ctx - The command-handler context.
3373
3669
  */
3374
3670
  async function runProjectFields(ctx) {
3375
- const options = ctx.options || {};
3376
- const ref = parseProjectRef(optionString(options, "project") || ctx.args?.[0]);
3377
- if (!ref) {
3378
- throw new CommandError("Usage: pm github project fields <owner/number> (e.g. pm github project fields unbraind/5)", EXIT_CODE.USAGE);
3379
- }
3671
+ const options = ctx.options;
3672
+ const ref = requireProjectRef(ctx, "Usage: pm github project fields <owner/number> (e.g. pm github project fields unbraind/5)");
3380
3673
  const token = resolveGitHubToken();
3381
3674
  const meta = await resolveProject(ref, token);
3382
3675
  const q = `
@@ -3393,7 +3686,7 @@ async function runProjectFields(ctx) {
3393
3686
  type: f.dataType ?? f.__typename,
3394
3687
  options: Array.isArray(f.options) ? f.options.map((o) => o.name) : undefined,
3395
3688
  }));
3396
- if (ctx.global?.json !== true) {
3689
+ if (ctx.global.json !== true) {
3397
3690
  console.error(`Project ${ref.owner}/${ref.number} — ${meta.title} (${meta.ownerType})`);
3398
3691
  console.error(` ${meta.url}`);
3399
3692
  console.error(` Status field: ${meta.statusField ? meta.statusField.options.map((o) => o.name).join(" | ") : "(none — pushes cannot set status)"}`);
@@ -3415,11 +3708,8 @@ async function runProjectFields(ctx) {
3415
3708
  * @param ctx - The command-handler context.
3416
3709
  */
3417
3710
  async function runProjectImport(ctx) {
3418
- const options = ctx.options || {};
3419
- const ref = parseProjectRef(optionString(options, "project") || ctx.args?.[0]);
3420
- if (!ref) {
3421
- throw new CommandError("Usage: pm github project import <owner/number> [--dry-run] [--status-map pm=Option,...] [--type <type>]", EXIT_CODE.USAGE);
3422
- }
3711
+ const options = ctx.options;
3712
+ const ref = requireProjectRef(ctx, "Usage: pm github project import <owner/number> [--dry-run] [--status-map pm=Option,...] [--type <type>]");
3423
3713
  const dryRun = optionEnabled(options, "dry-run", "dryRun");
3424
3714
  const itemType = optionString(options, "type") || "Task";
3425
3715
  const statusMap = parseStatusMap(optionCsv(options, "status-map", "statusMap"));
@@ -3554,19 +3844,16 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
3554
3844
  let itemId = entry.itemId;
3555
3845
  if (entry.action === "add-draft") {
3556
3846
  const pm = pmById.get(entry.pmId);
3557
- itemId = await gqlAddDraft(meta.id, entry.title, pm?.body || pm?.description, token);
3847
+ itemId = await gqlAddDraft(meta.id, entry.title, pm.body || pm.description, token);
3558
3848
  }
3559
3849
  else if (entry.action === "add-issue") {
3560
- if (!entry.issueRepo || typeof entry.issueNumber !== "number") {
3561
- return { changed: false, error: "add-issue entry missing issue coordinates" };
3562
- }
3850
+ // The private executor receives only buildProjectPushPlan entries. Its
3851
+ // add-issue arm always sets both coordinates from a parsed provenance tag.
3563
3852
  const contentId = await gqlResolveIssueNodeId(entry.issueRepo, entry.issueNumber, token);
3564
3853
  if (!contentId)
3565
3854
  return { changed: false, error: `could not resolve node id for ${entry.issueRepo}#${entry.issueNumber}` };
3566
3855
  itemId = await gqlAddIssue(meta.id, contentId, token);
3567
3856
  }
3568
- if (!itemId)
3569
- return { changed: false, error: "no project item id to act on" };
3570
3857
  if (entry.targetOptionId && meta.statusField) {
3571
3858
  await gqlSetStatus(meta.id, itemId, meta.statusField.id, entry.targetOptionId, token);
3572
3859
  }
@@ -3575,7 +3862,7 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
3575
3862
  const pm = pmById.get(entry.pmId);
3576
3863
  if (pm?.id) {
3577
3864
  const tag = projectItemTag(ref, itemId);
3578
- const existingTags = pm.tags ?? [];
3865
+ const existingTags = pm.tags;
3579
3866
  if (!existingTags.includes(tag)) {
3580
3867
  const upd = pmRun([
3581
3868
  "--path", pmRoot, "update", pm.id,
@@ -3589,7 +3876,7 @@ async function applyPushEntry(entry, meta, ref, pmById, pmRoot, token) {
3589
3876
  return { changed: true };
3590
3877
  }
3591
3878
  catch (err) {
3592
- return { changed: false, error: err instanceof Error ? err.message : String(err) };
3879
+ return { changed: false, error: errorMessage(err) };
3593
3880
  }
3594
3881
  }
3595
3882
  // Build the pm CLI argv that applies one pull (project → pm) status transition.
@@ -3659,11 +3946,8 @@ function applyPullEntry(entry, pmRoot) {
3659
3946
  * @param ctx - The command-handler context.
3660
3947
  */
3661
3948
  async function runProjectSync(ctx) {
3662
- const options = ctx.options || {};
3663
- const ref = parseProjectRef(optionString(options, "project") || ctx.args?.[0]);
3664
- if (!ref) {
3665
- throw new CommandError("Usage: pm github project sync <owner/number> [--push|--pull] [--apply] [--ids pm-1,..] [--status-map pm=Option,..] [--no-add-missing] [--prefer pm|github]", EXIT_CODE.USAGE);
3666
- }
3949
+ const options = ctx.options;
3950
+ const ref = requireProjectRef(ctx, "Usage: pm github project sync <owner/number> [--push|--pull] [--apply] [--ids pm-1,..] [--status-map pm=Option,..] [--no-add-missing] [--prefer pm|github]");
3667
3951
  const wantPush = optionEnabled(options, "push");
3668
3952
  const wantPull = optionEnabled(options, "pull");
3669
3953
  const dryRunFlag = optionEnabled(options, "dry-run", "dryRun");
@@ -3732,7 +4016,7 @@ async function runProjectSync(ctx) {
3732
4016
  for (const e of pullActionable)
3733
4017
  console.error(` ${e.pmId} "${e.title}": ${e.fromStatus} → ${e.toStatus}`);
3734
4018
  for (const s of pullPlan.statusSkipped) {
3735
- console.error(` [skip] item ${s.itemId}: board status "${s.optionName ?? "(none)"}" maps to no pm status`);
4019
+ console.error(` [skip] item ${s.itemId}: board status "${s.optionName}" maps to no pm status`);
3736
4020
  }
3737
4021
  if (pullActionable.length === 0)
3738
4022
  console.error(" (nothing to pull)");
@@ -3749,7 +4033,7 @@ async function runProjectSync(ctx) {
3749
4033
  let pushFailed = 0;
3750
4034
  if (applyPush && pushPlan) {
3751
4035
  for (const e of pushActionable) {
3752
- if (prefer === "github" && e.action === "set-status" && pullItemIds.has(e.itemId ?? "")) {
4036
+ if (prefer === "github" && e.action === "set-status" && pullItemIds.has(e.itemId)) {
3753
4037
  continue; // pull wins for this linked item
3754
4038
  }
3755
4039
  const r = await applyPushEntry(e, meta, ref, pmById, ctx.pm_root, token);
@@ -3832,6 +4116,7 @@ const IMPORT_FLAGS = [
3832
4116
  { long: "--comments-mode", value_name: "body|annotations|both", description: "How to persist fetched GitHub comments: `body` (default, embed in item body), `annotations` (sync to the pm item's native comments collection), or `both`. `annotations`/`both` are idempotent on re-import (dedupe by GitHub comment id)" },
3833
4117
  { long: "--atomic", description: "Commit the complete import as one workspace-writer-locked, crash-resumable transaction (pm-cli >=2026.7.20); compensate applied mutations on failure and report incomplete compensation" },
3834
4118
  { long: "--link-deps", description: "After import, map dependency references in issue bodies (`Blocked by #N`, `Depends on owner/repo#N`, `Blocks #N`) to pm dependency edges between the linked items. Idempotent; skips self- and unresolved references; ordering cycles are reported (via the SDK ordering-cycle advisory), not rejected" },
4119
+ { long: "--gate", description: "Fail-closed privacy gate: verify plan completeness and provenance-tag idempotency before the write, then scan the proposed tracker change for credentials, personal data, and host paths after it; any finding (or unreadable scan input) fails the import so nothing is ever pushed" },
3835
4120
  { long: "--dry-run", description: "Preview without writing" },
3836
4121
  { long: "--type", value_name: "type", description: "Override pm item type (default: Issue)" },
3837
4122
  ];
@@ -3850,6 +4135,10 @@ const SYNC_FLAGS = [
3850
4135
  { long: "--ids", value_name: "pm-1,pm-2", description: "Only sync these pm item IDs (comma-separated)" },
3851
4136
  { long: "--dry-run", description: "Preview the close/reopen plan without mutating GitHub" },
3852
4137
  ];
4138
+ const GATE_FLAGS = [
4139
+ { long: "--diff", value_name: "file", description: "Scan an explicit unified diff file instead of the staged/working tracker change (fail closed when unreadable)" },
4140
+ { long: "--allowlist", value_name: "file", description: "Explicit content-hash allowlist of reviewed false positives (default: `<repo root>/.pm-github-gate-allowlist.json`; a missing EXPLICIT file fails, a missing default is an empty allowlist)" },
4141
+ ];
3853
4142
  const VALIDATE_FLAGS = [
3854
4143
  { long: "--repo", value_name: "owner/repo", description: "Also check this repo is accessible with the resolved token" },
3855
4144
  ];
@@ -3887,7 +4176,7 @@ const PROJECT_SYNC_FLAGS = [
3887
4176
  const defineExtension = (module) => module;
3888
4177
  export default defineExtension({
3889
4178
  name: "pm-github",
3890
- version: "2026.9.26",
4179
+ version: "2026.10.8",
3891
4180
  activate(api) {
3892
4181
  // -----------------------------------------------------------------------
3893
4182
  // schema — declare the GitHub provenance fields so the workspace knows them
@@ -3923,7 +4212,7 @@ export default defineExtension({
3923
4212
  "github project sync",
3924
4213
  ],
3925
4214
  run: (ctx) => {
3926
- if (isMutatingGithubCommand(ctx.command, ctx.options || {})) {
4215
+ if (isMutatingGithubCommand(ctx.command, ctx.options)) {
3927
4216
  if (!resolveGitHubToken()) {
3928
4217
  console.error("[pm-github preflight] this github command mutates remote state but no GitHub " +
3929
4218
  "token is resolvable (GITHUB_TOKEN/GH_TOKEN or `gh auth login`). It will fail.");
@@ -3936,7 +4225,7 @@ export default defineExtension({
3936
4225
  // importer — `pm github import <owner/repo>` (idempotent native pipeline)
3937
4226
  // -----------------------------------------------------------------------
3938
4227
  api.registerImporter("github", async (ctx) => {
3939
- return runImport(ctx.args?.[0], ctx.pm_root, parseImportOptions(ctx.options || {}));
4228
+ return runImport(ctx.args[0], ctx.pm_root, parseImportOptions(ctx.options));
3940
4229
  }, {
3941
4230
  description: "Fetch GitHub issues from a repo and create/update pm items (idempotent " +
3942
4231
  "on re-import via the `gh:owner/repo#N` provenance tag). Skips pull " +
@@ -4003,7 +4292,7 @@ export default defineExtension({
4003
4292
  api.registerSearchProvider({
4004
4293
  name: "github",
4005
4294
  async query(qctx) {
4006
- const repo = resolveSearchRepo(qctx.options || {});
4295
+ const repo = resolveSearchRepo(qctx.options);
4007
4296
  if (!repo)
4008
4297
  return [];
4009
4298
  const token = resolveGitHubToken();
@@ -4042,10 +4331,10 @@ export default defineExtension({
4042
4331
  if (ctx.command !== "close" && ctx.command !== "update")
4043
4332
  return;
4044
4333
  // Only nudge for items that are actually linked to GitHub.
4045
- const id = ctx.args?.[0];
4334
+ const id = ctx.args[0];
4046
4335
  if (!id || !ctx.pm_root)
4047
4336
  return;
4048
- const res = spawnSync("pm", ["--path", ctx.pm_root, "--json", "show", id], { encoding: "utf-8", maxBuffer: pmJsonMaxBuffer() });
4337
+ const res = spawnSync("pm", ["--path", ctx.pm_root, "--json", "show", id], { encoding: "utf-8", maxBuffer: pmJsonMaxBuffer(), env: { ...process.env } });
4049
4338
  if (res.status !== 0)
4050
4339
  return;
4051
4340
  let repo;
@@ -4131,6 +4420,48 @@ export default defineExtension({
4131
4420
  },
4132
4421
  });
4133
4422
  // -----------------------------------------------------------------------
4423
+ // command — `pm github gate` (fail-closed privacy gate over the proposed
4424
+ // tracker change). Runs BEFORE any push in a sync pipeline; a fail verdict
4425
+ // or an unreadable scan input exits non-zero so nothing downstream commits
4426
+ // or pushes. Findings name rule + item + field + content hash — never the
4427
+ // matched content.
4428
+ // -----------------------------------------------------------------------
4429
+ api.registerCommand({
4430
+ name: "github gate",
4431
+ description: "Fail-closed privacy gate over the proposed tracker change (the staged/working/untracked diff under the resolved pm tracker path, or an explicit --diff file). Fails on credentials (GitHub/npm/AWS/Slack/OpenAI/Anthropic tokens, generic bearers, private-key blocks, high-entropy secret assignments), personal data (non-noreply emails, phone numbers), and host paths (absolute local filesystem paths, home-directory usernames). Reviewed false positives are allowlisted by content hash. Unreadable input or a scanner error fails. Read-only; never mutates anything. Use --json for machine output.",
4432
+ intent: "verify a proposed tracker change before any public branch push",
4433
+ examples: [
4434
+ "pm github gate",
4435
+ "pm github gate --json",
4436
+ "pm github gate --diff pm-sync.patch",
4437
+ "pm github gate --allowlist .pm-github-gate-allowlist.json",
4438
+ ],
4439
+ flags: GATE_FLAGS,
4440
+ failure_hints: [
4441
+ "Run inside the repository holding the pm tracker, or pass --diff <file> for an explicit unified diff.",
4442
+ "A finding names the rule, item, field, and the sha256 of the matched content; allowlist that hash in .pm-github-gate-allowlist.json with a written reason after review.",
4443
+ "The gate scans only added lines — removed content cannot publish anything new.",
4444
+ "pm github import --gate composes this gate into an import: plan completeness and provenance idempotency are verified before the write, the scan runs after it.",
4445
+ ],
4446
+ async run(ctx) {
4447
+ const diffFile = optionString(ctx.options, "diff");
4448
+ const allowlistFile = optionString(ctx.options, "allowlist");
4449
+ const report = runCommandTrackerGate({
4450
+ pmRoot: ctx.pm_root,
4451
+ ...(diffFile ? { diffFile } : {}),
4452
+ ...(allowlistFile ? { allowlistFile } : {}),
4453
+ });
4454
+ if (ctx.global.json !== true) {
4455
+ for (const line of formatGateReport(report))
4456
+ console.error(line);
4457
+ }
4458
+ if (report.verdict === "fail") {
4459
+ throw new CommandError(JSON.stringify({ ...report, error: "pm github gate: FAIL" }), EXIT_CODE.GENERIC_FAILURE);
4460
+ }
4461
+ return report;
4462
+ },
4463
+ });
4464
+ // -----------------------------------------------------------------------
4134
4465
  // command — `pm github validate` (diagnostics: gh/token/repo reachability)
4135
4466
  // -----------------------------------------------------------------------
4136
4467
  api.registerCommand({
@@ -4152,7 +4483,7 @@ export default defineExtension({
4152
4483
  ],
4153
4484
  async run(ctx) {
4154
4485
  const report = await runValidate(ctx);
4155
- const jsonMode = ctx.global?.json === true;
4486
+ const jsonMode = ctx.global.json === true;
4156
4487
  if (!jsonMode) {
4157
4488
  for (const line of report.messages)
4158
4489
  console.error(line);