sidecarsync 2.1.1 → 2.1.2

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 (2) hide show
  1. package/dist/cli.js +262 -15
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1449,15 +1449,142 @@ var init_git = __esm(() => {
1449
1449
  });
1450
1450
 
1451
1451
  // src/redaction.ts
1452
+ function redactShellDefaults(text) {
1453
+ let output = "";
1454
+ let copied = 0;
1455
+ for (const match of text.matchAll(SHELL_DEFAULT_REGEX)) {
1456
+ const [whole, open, name, operator] = match;
1457
+ if (match.index < copied)
1458
+ continue;
1459
+ if (SHELL_DIRECTORY_NAMES.has(name) || !isSensitiveKey(name))
1460
+ continue;
1461
+ const wordStart = match.index + whole.length;
1462
+ const end = shellWordEnd(text, wordStart, "}");
1463
+ const wordEnd = end === -1 ? lineEnd(text, wordStart) : end;
1464
+ const word = text.slice(wordStart, wordEnd);
1465
+ output += text.slice(copied, wordStart);
1466
+ output += !word || isPlaceholder(word) || isShellReference(word) ? word : placeholderForKey(name);
1467
+ copied = wordEnd;
1468
+ }
1469
+ return output + text.slice(copied);
1470
+ }
1471
+ function shellWordEnd(text, start, close, depth = 0) {
1472
+ if (depth > SHELL_NESTING_LIMIT)
1473
+ return -1;
1474
+ let parens = 0;
1475
+ for (let index = start;index < text.length; index += 1) {
1476
+ const char = text[index];
1477
+ if (char === `
1478
+ ` || char === "\r")
1479
+ return -1;
1480
+ if (char === "\\") {
1481
+ index += 1;
1482
+ } else if (char === "'" || char === '"' || char === "`") {
1483
+ index = closingQuote(text, index + 1, char, depth + 1);
1484
+ if (index === -1)
1485
+ return -1;
1486
+ } else if (char === "$" && (text[index + 1] === "{" || text[index + 1] === "(")) {
1487
+ index = shellWordEnd(text, index + 2, text[index + 1] === "{" ? "}" : ")", depth + 1);
1488
+ if (index === -1)
1489
+ return -1;
1490
+ } else if (close === ")" && char === "(") {
1491
+ parens += 1;
1492
+ } else if (close === ")" && char === ")" && parens > 0) {
1493
+ parens -= 1;
1494
+ } else if (char === close) {
1495
+ return index;
1496
+ }
1497
+ }
1498
+ return -1;
1499
+ }
1500
+ function closingQuote(text, start, quote, depth) {
1501
+ if (depth > SHELL_NESTING_LIMIT)
1502
+ return -1;
1503
+ for (let index = start;index < text.length; index += 1) {
1504
+ const char = text[index];
1505
+ if (char === `
1506
+ ` || char === "\r")
1507
+ return -1;
1508
+ if (char === quote)
1509
+ return index;
1510
+ if (quote === "'")
1511
+ continue;
1512
+ if (char === "\\") {
1513
+ index += 1;
1514
+ } else if (quote === '"' && char === "$" && (text[index + 1] === "{" || text[index + 1] === "(")) {
1515
+ index = shellWordEnd(text, index + 2, text[index + 1] === "{" ? "}" : ")", depth + 1);
1516
+ if (index === -1)
1517
+ return -1;
1518
+ } else if (quote === '"' && char === "`") {
1519
+ index = closingQuote(text, index + 1, "`", depth + 1);
1520
+ if (index === -1)
1521
+ return -1;
1522
+ }
1523
+ }
1524
+ return -1;
1525
+ }
1526
+ function lineEnd(text, start) {
1527
+ const match = /[\r\n]/.exec(text.slice(start));
1528
+ return match ? start + match.index : text.length;
1529
+ }
1530
+ function isShellReadOfKey(text, offset, separator) {
1531
+ if (SHELL_EXPANSION_KEY_PREFIX.test(text.slice(Math.max(0, offset - 64), offset)))
1532
+ return true;
1533
+ return text[offset - 1] === "$" && separator.trim().startsWith(":");
1534
+ }
1535
+ function isShellReference(value, depth = 0) {
1536
+ if (depth > SHELL_NESTING_LIMIT)
1537
+ return false;
1538
+ const unquoted = value.length > 1 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
1539
+ if (SHELL_VARIABLE_REFERENCE.test(unquoted))
1540
+ return true;
1541
+ if (!unquoted.startsWith("${") || shellWordEnd(unquoted, 2, "}") !== unquoted.length - 1)
1542
+ return false;
1543
+ const rest = SHELL_EXPANSION_INNER.exec(unquoted.slice(2, -1))?.groups?.rest;
1544
+ if (rest === undefined)
1545
+ return false;
1546
+ if (rest === "" || /^:?\?/.test(rest) || /^(?::[a-zA-Z0-9]+)+$/.test(rest))
1547
+ return true;
1548
+ const word = /^:?[-=+](?<word>.*)$/s.exec(rest)?.groups?.word;
1549
+ return word !== undefined && (word === "" || isPlaceholder(word) || isShellReference(word, depth + 1));
1550
+ }
1551
+ function startsWithShellReference(text, start, value) {
1552
+ if (SHELL_COMMAND_NAME_VALUE.test(value))
1553
+ return true;
1554
+ if (text[start] !== "$")
1555
+ return false;
1556
+ let end;
1557
+ if (text[start + 1] === "{") {
1558
+ end = shellWordEnd(text, start + 2, "}");
1559
+ if (end === -1)
1560
+ return false;
1561
+ end += 1;
1562
+ } else {
1563
+ end = start + 1 + (/^[A-Za-z0-9_]*/.exec(text.slice(start + 1))?.[0].length ?? 0);
1564
+ }
1565
+ if (end < text.length && !/[\s;&|)]/.test(text[end]))
1566
+ return false;
1567
+ return isShellReference(text.slice(start, end));
1568
+ }
1452
1569
  function redactText(input, mode = DEFAULT_REDACTION_MODE) {
1453
1570
  if (mode === "none")
1454
1571
  return input;
1455
- let output = input.replace(PEM_PRIVATE_KEY_REGEX, "<PRIVATEKEY>").replace(AUTHORIZATION_HEADER_REGEX, (_match, prefix) => `${prefix}<TOKEN>`).replace(BARE_BEARER_TOKEN_REGEX, (_match, prefix) => `${prefix}<TOKEN>`).replace(URL_CREDENTIALS_REGEX, (_match, prefix) => `${prefix}:<SECRET>@`).replace(QUOTED_SECRET_REGEX, (match, ...args) => {
1456
- const { keyQuote = "", key, separator, valueQuote } = args.at(-1);
1457
- if (!isSensitiveKey(key))
1572
+ let output = redactShellDefaults(input).replace(PEM_PRIVATE_KEY_REGEX, "<PRIVATEKEY>").replace(AUTHORIZATION_HEADER_REGEX, (_match, prefix) => `${prefix}<TOKEN>`).replace(BARE_BEARER_TOKEN_REGEX, (_match, prefix) => `${prefix}<TOKEN>`).replace(URL_CREDENTIALS_REGEX, (_match, prefix) => `${prefix}:<SECRET>@`).replace(QUOTED_SECRET_REGEX, (match, ...args) => {
1573
+ const { keyQuote = "", key, separator, valueQuote, value } = args.at(-1);
1574
+ const offset = args.at(-3);
1575
+ const text = args.at(-2);
1576
+ if (!isSensitiveKey(key) || isShellReadOfKey(text, offset + keyQuote.length, separator))
1577
+ return match;
1578
+ if (valueQuote === '"' && isShellReference(value))
1458
1579
  return match;
1459
1580
  return `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${placeholderForKey(key)}${valueQuote}`;
1460
- }).replace(BARE_ASSIGNMENT_SECRET_REGEX, (match, key, separator) => isSensitiveKey(key) ? `${key}${separator}${placeholderForKey(key)}` : match);
1581
+ }).replace(BARE_ASSIGNMENT_SECRET_REGEX, (match, key, separator, value, offset, text) => {
1582
+ if (!isSensitiveKey(key) || isShellReadOfKey(text, offset, separator))
1583
+ return match;
1584
+ if (startsWithShellReference(text, offset + key.length + separator.length, value))
1585
+ return match;
1586
+ return `${key}${separator}${placeholderForKey(key)}`;
1587
+ });
1461
1588
  for (const [pattern, replacement] of TOKEN_PATTERNS) {
1462
1589
  output = output.replace(pattern, replacement);
1463
1590
  }
@@ -1465,6 +1592,12 @@ function redactText(input, mode = DEFAULT_REDACTION_MODE) {
1465
1592
  return output;
1466
1593
  return output.replace(EMAIL_REGEX, "<EMAIL>").replace(PHONE_REGEX, "<PHONENUMBER>").replace(SSN_REGEX, "<SSN>").replace(CREDIT_CARD_CANDIDATE_REGEX, (candidate) => isLikelyCreditCard(candidate) ? "<CREDITCARD>" : candidate);
1467
1594
  }
1595
+ function isPlaceholder(text) {
1596
+ return /^<(?:API_KEY|TOKEN|SECRET|PRIVATEKEY|EMAIL|PHONENUMBER|SSN|CREDITCARD)>$/.test(text);
1597
+ }
1598
+ function hasRedactionPlaceholder(text) {
1599
+ return countRedactionPlaceholders(text) > 0;
1600
+ }
1468
1601
  function countRedactionPlaceholders(text) {
1469
1602
  return text.match(PLACEHOLDER_REGEX)?.length ?? 0;
1470
1603
  }
@@ -1518,12 +1651,20 @@ function isLikelyCreditCard(value) {
1518
1651
  }
1519
1652
  return sum % 10 === 0;
1520
1653
  }
1521
- var DEFAULT_REDACTION_MODE = "secrets", REDACTION_MODES, KEY_NAME_PATTERN, QUOTED_SECRET_REGEX, BARE_ASSIGNMENT_SECRET_REGEX, AUTHORIZATION_HEADER_REGEX, PEM_PRIVATE_KEY_REGEX, URL_CREDENTIALS_REGEX, BARE_BEARER_TOKEN_REGEX, TOKEN_PATTERNS, EMAIL_REGEX, PHONE_REGEX, SSN_REGEX, CREDIT_CARD_CANDIDATE_REGEX, PLACEHOLDER_REGEX, COMPACT_SENSITIVE_KEYS;
1654
+ var DEFAULT_REDACTION_MODE = "secrets", REDACTION_MODES, KEY_NAME_PATTERN, QUOTED_SECRET_REGEX, BARE_ASSIGNMENT_SECRET_REGEX, SHELL_NAME_PATTERN, SHELL_EXPANSION_OPEN, SHELL_DEFAULT_REGEX, SHELL_EXPANSION_KEY_PREFIX, SHELL_EXPANSION_INNER, SHELL_DIRECTORY_NAMES, SHELL_VARIABLE_REFERENCE, SHELL_NESTING_LIMIT = 16, SHELL_COMMAND_NAME_VALUE, AUTHORIZATION_HEADER_REGEX, PEM_PRIVATE_KEY_REGEX, URL_CREDENTIALS_REGEX, BARE_BEARER_TOKEN_REGEX, TOKEN_PATTERNS, EMAIL_REGEX, PHONE_REGEX, SSN_REGEX, CREDIT_CARD_CANDIDATE_REGEX, PLACEHOLDER_REGEX, COMPACT_SENSITIVE_KEYS;
1522
1655
  var init_redaction = __esm(() => {
1523
1656
  REDACTION_MODES = ["none", "secrets", "secrets+pii"];
1524
1657
  KEY_NAME_PATTERN = String.raw`[A-Za-z0-9_][A-Za-z0-9_-]*`;
1525
- QUOTED_SECRET_REGEX = new RegExp(String.raw`(?:(?<keyQuote>["'])|\b)(?<key>${KEY_NAME_PATTERN})\k<keyQuote>` + String.raw`(?<separator>\s*[:=]\s*)(?<valueQuote>["'])(?:\\[^\r\n]|(?!\k<valueQuote>)[^\\\r\n])+\k<valueQuote>`, "g");
1658
+ QUOTED_SECRET_REGEX = new RegExp(String.raw`(?:(?<keyQuote>["'])|\b)(?<key>${KEY_NAME_PATTERN})\k<keyQuote>` + String.raw`(?<separator>\s*[:=]\s*)(?<valueQuote>["'])(?<value>(?:\\[^\r\n]|(?!\k<valueQuote>)[^\\\r\n])+)\k<valueQuote>`, "g");
1526
1659
  BARE_ASSIGNMENT_SECRET_REGEX = new RegExp(String.raw`\b(${KEY_NAME_PATTERN})(\s*[:=]\s*)([^\s"',;` + "`" + String.raw`]+)`, "g");
1660
+ SHELL_NAME_PATTERN = String.raw`[A-Za-z_][A-Za-z0-9_]*`;
1661
+ SHELL_EXPANSION_OPEN = String.raw`\$\{(?:\([^()\s]*\))?[#!^=~]*`;
1662
+ SHELL_DEFAULT_REGEX = new RegExp(String.raw`(${SHELL_EXPANSION_OPEN})(${SHELL_NAME_PATTERN})(:?[-=+])`, "g");
1663
+ SHELL_EXPANSION_KEY_PREFIX = new RegExp(`${SHELL_EXPANSION_OPEN}$`);
1664
+ SHELL_EXPANSION_INNER = new RegExp(String.raw`^(?:\([^()\s]*\))?[#!^=~]*${SHELL_NAME_PATTERN}(?<rest>.*)$`, "s");
1665
+ SHELL_DIRECTORY_NAMES = new Set(["PWD", "OLDPWD"]);
1666
+ SHELL_VARIABLE_REFERENCE = new RegExp(String.raw`^\$(?:[A-Z_][A-Z0-9_]*)$`);
1667
+ SHELL_COMMAND_NAME_VALUE = /^\$\((?:[A-Za-z0-9_./+-]+|<(?:API_KEY|TOKEN|SECRET|PRIVATEKEY|EMAIL|PHONENUMBER|SSN|CREDITCARD)>)\)?$/;
1527
1668
  AUTHORIZATION_HEADER_REGEX = /\b(authorization\s*[:=]\s*(?:bearer|basic|token)\s+)([^\s"',;`]+)/gi;
1528
1669
  PEM_PRIVATE_KEY_REGEX = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g;
1529
1670
  URL_CREDENTIALS_REGEX = /(\/\/[^\s/:@"'`]+):([^\s/@"'`]+)@/g;
@@ -4726,7 +4867,7 @@ function createReview(options) {
4726
4867
  if (guidelines)
4727
4868
  writePrivateFile(path8.join(dir, "guidelines.md"), `${guidelines}
4728
4869
  `);
4729
- writePrivateFile(path8.join(dir, "instructions.md"), reviewInstructions(record, config, guidelines));
4870
+ writePrivateFile(path8.join(dir, "instructions.md"), reviewInstructions(record, config, guidelines, addedPlaceholderLines(diff)));
4730
4871
  writePrivateJson(path8.join(dir, "review.json"), record);
4731
4872
  logSidecarEvent("review-open", { id, root, peer: config.peer, files: files.length });
4732
4873
  pruneClosedReviews(sidecarPath);
@@ -4785,6 +4926,102 @@ function pruneOrphanedReviews() {
4785
4926
  fs8.rmSync(review.dir, { recursive: true, force: true });
4786
4927
  }
4787
4928
  }
4929
+ function addedPlaceholderLines(patch) {
4930
+ const found = [];
4931
+ let file;
4932
+ let oldLeft = 0;
4933
+ let newLeft = 0;
4934
+ let newLine = 0;
4935
+ for (const raw of patch.split(`
4936
+ `)) {
4937
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
4938
+ if (oldLeft > 0 || newLeft > 0) {
4939
+ if (line.startsWith("+")) {
4940
+ if (file && hasRedactionPlaceholder(line))
4941
+ found.push({ file, line: newLine, text: line.slice(1) });
4942
+ newLeft -= 1;
4943
+ newLine += 1;
4944
+ } else if (line.startsWith("-")) {
4945
+ oldLeft -= 1;
4946
+ } else if (!line.startsWith("\\")) {
4947
+ oldLeft -= 1;
4948
+ newLeft -= 1;
4949
+ newLine += 1;
4950
+ }
4951
+ continue;
4952
+ }
4953
+ if (line.startsWith("diff --git ")) {
4954
+ file = undefined;
4955
+ } else if (line.startsWith("+++ ")) {
4956
+ file = patchHeaderPath(line.slice(4));
4957
+ } else {
4958
+ const hunk = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
4959
+ if (hunk) {
4960
+ oldLeft = Number(hunk[1] ?? 1);
4961
+ newLine = Number(hunk[2]);
4962
+ newLeft = Number(hunk[3] ?? 1);
4963
+ }
4964
+ }
4965
+ }
4966
+ return found;
4967
+ }
4968
+ function patchHeaderPath(header) {
4969
+ const target = header.endsWith("\t") ? header.slice(0, -1) : header;
4970
+ if (target === "/dev/null")
4971
+ return;
4972
+ const name = target.length > 1 && target.startsWith('"') && target.endsWith('"') ? unquoteGitPath(target.slice(1, -1)) : target;
4973
+ return name.replace(/^b\//, "");
4974
+ }
4975
+ function unquoteGitPath(quoted) {
4976
+ const parts = [];
4977
+ let literal = "";
4978
+ for (let index = 0;index < quoted.length; index += 1) {
4979
+ if (quoted[index] !== "\\" || index + 1 === quoted.length) {
4980
+ literal += quoted[index];
4981
+ continue;
4982
+ }
4983
+ parts.push(Buffer.from(literal, "utf8"));
4984
+ literal = "";
4985
+ const octal = /^[0-3][0-7]{2}/.exec(quoted.slice(index + 1))?.[0];
4986
+ if (octal) {
4987
+ parts.push(Buffer.from([Number.parseInt(octal, 8)]));
4988
+ index += 3;
4989
+ } else {
4990
+ const next = quoted[index + 1];
4991
+ parts.push(GIT_PATH_ESCAPES[next] === undefined ? Buffer.from(next, "utf8") : Buffer.from([GIT_PATH_ESCAPES[next]]));
4992
+ index += 1;
4993
+ }
4994
+ }
4995
+ parts.push(Buffer.from(literal, "utf8"));
4996
+ return Buffer.concat(parts).toString("utf8");
4997
+ }
4998
+ function placeholderSection(lines) {
4999
+ if (!lines.length)
5000
+ return "";
5001
+ const shown = lines.slice(0, PLACEHOLDER_LINES_SHOWN);
5002
+ const more = lines.length - shown.length;
5003
+ return `
5004
+ ## Redaction placeholders
5005
+
5006
+ These lines in the change carry a placeholder (numbered as in the new file).
5007
+ For each one, check that the placeholder stands where a credential's value
5008
+ was, and only there. Reject, naming the line, if it replaced something that is
5009
+ not a credential — part of a shell expansion such as \`\${PWD:t}\`, a path, a
5010
+ command, ordinary words — or if the text around it no longer holds together
5011
+ (a script that no longer parses, a broken config file). That holds even when
5012
+ it did replace a real credential: the other machines still receive a broken
5013
+ file, and the credential belongs outside the synced file. Some placeholders
5014
+ may have been typed by hand or come from content already published; judge
5015
+ them the same way.
5016
+
5017
+ ${shown.map((entry) => `- ${entry.file}:${entry.line}
5018
+
5019
+ ${entry.text}
5020
+ `).join(`
5021
+ `)}${more ? `
5022
+ …and ${more} more; find them in \`diff.patch\`.
5023
+ ` : ""}`;
5024
+ }
4788
5025
  function opaqueSection(record) {
4789
5026
  const opaque = record.files.filter((file) => file.artifact);
4790
5027
  if (!opaque.length)
@@ -4799,7 +5036,7 @@ ${opaque.map((file) => `- \`${file.artifact}\` — ${file.path}`).join(`
4799
5036
  `)}
4800
5037
  `;
4801
5038
  }
4802
- function reviewInstructions(record, config, guidelines) {
5039
+ function reviewInstructions(record, config, guidelines, placeholders) {
4803
5040
  return `# Sidecar publish review ${record.id}
4804
5041
 
4805
5042
  A sidecar is a scratchpad repository that syncs automatically to a shared Git
@@ -4807,8 +5044,9 @@ remote (${redactText(config.remote)}). This machine wants to publish the change
4807
5044
  \`diff.patch\`. Everyone with access to that remote can read what you approve,
4808
5045
  and it stays in the remote's history.
4809
5046
 
4810
- Decide whether anything in the change should not leave this machine. This is
4811
- not a code review: ignore quality, style, and correctness.
5047
+ Decide whether anything in the change should not leave this machine, and
5048
+ whether redaction damaged it. This is not a code review: ignore the quality,
5049
+ style, and correctness of what the author wrote.
4812
5050
 
4813
5051
  Secret redaction has already run. Values it caught appear as placeholders such
4814
5052
  as \`<API_KEY>\`, \`<TOKEN>\`, or \`<SECRET>\`; they are not leaks. Look for what
@@ -4819,6 +5057,11 @@ pattern matching misses:
4819
5057
  - confidential material: customer names and data, unreleased plans, internal-only details
4820
5058
  - anything the repository's guidelines below rule out
4821
5059
 
5060
+ Pattern matching also has false positives, and they are not harmless. A
5061
+ placeholder replaces the original text in the published file; every other
5062
+ machine that syncs this repository gets the placeholder in its own copy, while
5063
+ this machine keeps the original, so the author never sees the damage.
5064
+ ${placeholderSection(placeholders)}
4822
5065
  ## Files
4823
5066
 
4824
5067
  All in this directory (\`$SIDECAR_REVIEW_DIR\`):
@@ -4916,7 +5159,7 @@ function decideReview(review, verdict, reason) {
4916
5159
  review.state = verdict;
4917
5160
  logSidecarEvent("review-decision", { id: review.record.id, verdict });
4918
5161
  }
4919
- var REVIEW_SCHEMA = 1, KEEP_CLOSED_REVIEWS = 10, ID_PATTERN, LAUNCH_GRACE_SECONDS = 60, PACKET_DIFF_OPTIONS;
5162
+ var REVIEW_SCHEMA = 1, KEEP_CLOSED_REVIEWS = 10, ID_PATTERN, LAUNCH_GRACE_SECONDS = 60, PACKET_DIFF_OPTIONS, GIT_PATH_ESCAPES, PLACEHOLDER_LINES_SHOWN = 200;
4920
5163
  var init_review = __esm(() => {
4921
5164
  init_util();
4922
5165
  init_git();
@@ -4926,6 +5169,7 @@ var init_review = __esm(() => {
4926
5169
  init_redaction();
4927
5170
  ID_PATTERN = /^[0-9a-f]+$/;
4928
5171
  PACKET_DIFF_OPTIONS = ["-r", "--no-renames", "--ignore-submodules=none"];
5172
+ GIT_PATH_ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, "\\": 92 };
4929
5173
  });
4930
5174
 
4931
5175
  // src/sync.ts
@@ -6274,7 +6518,7 @@ function pendingInboxBranches(repo, config) {
6274
6518
  });
6275
6519
  return [...local, ...remote].sort();
6276
6520
  }
6277
- var REVIEW_POLICY_KEY = "sidecar.review", WRITTEN_TRAILER = "written:", WRITTEN_TRAILER_LIMIT = 500, REDACTION_FILTER_NAME = "sidecar-redact", REDACTION_FILTER_REVISION = "2", REDACTION_POLICY_FILE = "sidecar-redaction-policy";
6521
+ var REVIEW_POLICY_KEY = "sidecar.review", WRITTEN_TRAILER = "written:", WRITTEN_TRAILER_LIMIT = 500, REDACTION_FILTER_NAME = "sidecar-redact", REDACTION_FILTER_REVISION = "3", REDACTION_POLICY_FILE = "sidecar-redaction-policy";
6278
6522
  var init_sync = __esm(() => {
6279
6523
  init_color();
6280
6524
  init_util();
@@ -8713,11 +8957,14 @@ function armRemoteAt(state, key, at, trigger) {
8713
8957
  state.remoteTimers.set(key, { timer, at });
8714
8958
  }
8715
8959
  function armReviewDue(state, key, when) {
8716
- const at = reviewDueAt(readReviewDue(key), when, Date.now(), scheduleFor(key, state.options).debounceSeconds * 1000);
8960
+ const at = reviewDueAt(readReviewDue(key), when, Date.now(), scheduleFor(key, state.options).debounceSeconds * 1000, comingBack(state, key));
8717
8961
  if (at !== undefined)
8718
8962
  armRemoteAt(state, key, at, "review-due");
8719
8963
  }
8720
- function reviewDueAt(due, when, now, debounceMs) {
8964
+ function comingBack(state, key) {
8965
+ return state.syncing.has(key) || state.remoteTimers.has(key) || state.cycleCount < (state.skipUntilCycle.get(key) ?? 0);
8966
+ }
8967
+ function reviewDueAt(due, when, now, debounceMs, comingBack2) {
8721
8968
  if (due === undefined)
8722
8969
  return;
8723
8970
  if (due > now)
@@ -8726,7 +8973,7 @@ function reviewDueAt(due, when, now, debounceMs) {
8726
8973
  return now;
8727
8974
  if (when === "after-sync")
8728
8975
  return now + Math.max(SETTLE_WINDOW_MS, debounceMs);
8729
- return;
8976
+ return comingBack2 ? undefined : now;
8730
8977
  }
8731
8978
  function remoteIsDue(state, key) {
8732
8979
  const last = state.lastRemoteSyncAt.get(key) ?? 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidecarsync",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "A Git-backed sidecar inbox for repo-local development metadata.",
5
5
  "license": "MIT",
6
6
  "repository": {