sidecarsync 2.1.0 → 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 +464 -51
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -216,12 +216,15 @@ function utcTimestamp() {
216
216
  function nowIso() {
217
217
  return new Date().toISOString();
218
218
  }
219
- var SidecarError;
219
+ var DIRTY_CHECKOUT_EXIT = 75, SidecarError;
220
220
  var init_util = __esm(() => {
221
221
  SidecarError = class SidecarError extends Error {
222
- constructor(message) {
222
+ reason;
223
+ stage;
224
+ constructor(message, options = {}) {
223
225
  super(message);
224
226
  this.name = "SidecarError";
227
+ this.reason = options.reason;
225
228
  }
226
229
  };
227
230
  });
@@ -1402,7 +1405,7 @@ function isDirty(repo) {
1402
1405
  }
1403
1406
  function ensureClean(repo) {
1404
1407
  if (isDirty(repo))
1405
- throw new SidecarError("sidecar checkout has uncommitted changes");
1408
+ throw new SidecarError("sidecar checkout has uncommitted changes", { reason: "dirty-checkout" });
1406
1409
  }
1407
1410
  function sidecarIdentity() {
1408
1411
  return { name: currentUser(), email: `${slug(currentUser())}@${slug(currentHost())}.local` };
@@ -1446,15 +1449,142 @@ var init_git = __esm(() => {
1446
1449
  });
1447
1450
 
1448
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
+ }
1449
1569
  function redactText(input, mode = DEFAULT_REDACTION_MODE) {
1450
1570
  if (mode === "none")
1451
1571
  return input;
1452
- 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) => {
1453
- const { keyQuote = "", key, separator, valueQuote } = args.at(-1);
1454
- 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))
1455
1579
  return match;
1456
1580
  return `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${placeholderForKey(key)}${valueQuote}`;
1457
- }).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
+ });
1458
1588
  for (const [pattern, replacement] of TOKEN_PATTERNS) {
1459
1589
  output = output.replace(pattern, replacement);
1460
1590
  }
@@ -1462,6 +1592,12 @@ function redactText(input, mode = DEFAULT_REDACTION_MODE) {
1462
1592
  return output;
1463
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);
1464
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
+ }
1465
1601
  function countRedactionPlaceholders(text) {
1466
1602
  return text.match(PLACEHOLDER_REGEX)?.length ?? 0;
1467
1603
  }
@@ -1515,12 +1651,20 @@ function isLikelyCreditCard(value) {
1515
1651
  }
1516
1652
  return sum % 10 === 0;
1517
1653
  }
1518
- 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;
1519
1655
  var init_redaction = __esm(() => {
1520
1656
  REDACTION_MODES = ["none", "secrets", "secrets+pii"];
1521
1657
  KEY_NAME_PATTERN = String.raw`[A-Za-z0-9_][A-Za-z0-9_-]*`;
1522
- 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");
1523
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)>)\)?$/;
1524
1668
  AUTHORIZATION_HEADER_REGEX = /\b(authorization\s*[:=]\s*(?:bearer|basic|token)\s+)([^\s"',;`]+)/gi;
1525
1669
  PEM_PRIVATE_KEY_REGEX = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g;
1526
1670
  URL_CREDENTIALS_REGEX = /(\/\/[^\s/:@"'`]+):([^\s/@"'`]+)@/g;
@@ -3812,7 +3956,7 @@ function inboxBranchPrefix(template) {
3812
3956
  const slashIndex = staticPrefix.lastIndexOf("/");
3813
3957
  return slashIndex === -1 ? staticPrefix : staticPrefix.slice(0, slashIndex + 1);
3814
3958
  }
3815
- var DEFAULT_PATH = "sidecar", DEFAULT_BRANCH = "main", DEFAULT_INBOX = "sidecar-inbox/{user}/{random}", branchValidity, DEFAULT_PEER = "default", PEER_ENV = "SIDECAR_PEER", SOFT_SYNC_ENV = "SIDECAR_SYNC_SOFT", LOCAL_SYNC_ENV = "SIDECAR_SYNC_LOCAL", PEER_NAME, RESERVED_PEER_SUFFIXES, RESOLVE_MODES, DEFAULT_RESOLVE = "fork", REVIEW_MODES, DEFAULT_REVIEW_TIMEOUT_SECONDS, CONFIG_VERSION = 1, CONFIG_KEYS, failuresArePrivate = false, PUBLIC_REFS = "refs/sidecar-public/";
3959
+ var DEFAULT_PATH = "sidecar", DEFAULT_BRANCH = "main", DEFAULT_INBOX = "sidecar-inbox/{user}/{random}", branchValidity, DEFAULT_PEER = "default", PEER_ENV = "SIDECAR_PEER", SOFT_SYNC_ENV = "SIDECAR_SYNC_SOFT", LOCAL_SYNC_ENV = "SIDECAR_SYNC_LOCAL", DEFAULT_DEBOUNCE_SECONDS = 60, PEER_NAME, RESERVED_PEER_SUFFIXES, RESOLVE_MODES, DEFAULT_RESOLVE = "fork", REVIEW_MODES, DEFAULT_REVIEW_TIMEOUT_SECONDS, CONFIG_VERSION = 1, CONFIG_KEYS, failuresArePrivate = false, PUBLIC_REFS = "refs/sidecar-public/";
3816
3960
  var init_config = __esm(() => {
3817
3961
  init_dist();
3818
3962
  init_util();
@@ -4200,6 +4344,37 @@ function syncLockIsStale(lockDir) {
4200
4344
  return error.code !== "EPERM";
4201
4345
  }
4202
4346
  }
4347
+ function reviewDueDir() {
4348
+ return path6.join(sidecarStateDir(), "review-due");
4349
+ }
4350
+ function reviewDuePath(configPath) {
4351
+ const key = `${realpathOr(path6.dirname(configPath))}\x00${path6.basename(configPath)}`;
4352
+ return path6.join(reviewDueDir(), `${crypto3.createHash("sha256").update(key).digest("hex").slice(0, 16)}.json`);
4353
+ }
4354
+ function writeReviewDue(configPath, dueAt) {
4355
+ const filePath = reviewDuePath(configPath);
4356
+ fs6.mkdirSync(path6.dirname(filePath), { recursive: true });
4357
+ const temporary = `${filePath}.${crypto3.randomUUID()}.tmp`;
4358
+ try {
4359
+ fs6.writeFileSync(temporary, `${JSON.stringify({ configPath, dueAt: new Date(dueAt).toISOString() })}
4360
+ `, "utf8");
4361
+ fs6.renameSync(temporary, filePath);
4362
+ } finally {
4363
+ fs6.rmSync(temporary, { force: true });
4364
+ }
4365
+ }
4366
+ function clearReviewDue(configPath) {
4367
+ fs6.rmSync(reviewDuePath(configPath), { force: true });
4368
+ }
4369
+ function readReviewDue(configPath) {
4370
+ try {
4371
+ const { dueAt } = JSON.parse(fs6.readFileSync(reviewDuePath(configPath), "utf8"));
4372
+ const time = typeof dueAt === "string" ? Date.parse(dueAt) : Number.NaN;
4373
+ return Number.isFinite(time) ? time : undefined;
4374
+ } catch {
4375
+ return;
4376
+ }
4377
+ }
4203
4378
  var STATE_DIR_ENV = "SIDECAR_STATE_DIR", DEFAULT_SETTINGS, LOG_ROTATE_BYTES, SYNC_LOCK_WAIT_MS;
4204
4379
  var init_state = __esm(() => {
4205
4380
  init_util();
@@ -4692,7 +4867,7 @@ function createReview(options) {
4692
4867
  if (guidelines)
4693
4868
  writePrivateFile(path8.join(dir, "guidelines.md"), `${guidelines}
4694
4869
  `);
4695
- writePrivateFile(path8.join(dir, "instructions.md"), reviewInstructions(record, config, guidelines));
4870
+ writePrivateFile(path8.join(dir, "instructions.md"), reviewInstructions(record, config, guidelines, addedPlaceholderLines(diff)));
4696
4871
  writePrivateJson(path8.join(dir, "review.json"), record);
4697
4872
  logSidecarEvent("review-open", { id, root, peer: config.peer, files: files.length });
4698
4873
  pruneClosedReviews(sidecarPath);
@@ -4751,6 +4926,102 @@ function pruneOrphanedReviews() {
4751
4926
  fs8.rmSync(review.dir, { recursive: true, force: true });
4752
4927
  }
4753
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
+ }
4754
5025
  function opaqueSection(record) {
4755
5026
  const opaque = record.files.filter((file) => file.artifact);
4756
5027
  if (!opaque.length)
@@ -4765,7 +5036,7 @@ ${opaque.map((file) => `- \`${file.artifact}\` — ${file.path}`).join(`
4765
5036
  `)}
4766
5037
  `;
4767
5038
  }
4768
- function reviewInstructions(record, config, guidelines) {
5039
+ function reviewInstructions(record, config, guidelines, placeholders) {
4769
5040
  return `# Sidecar publish review ${record.id}
4770
5041
 
4771
5042
  A sidecar is a scratchpad repository that syncs automatically to a shared Git
@@ -4773,8 +5044,9 @@ remote (${redactText(config.remote)}). This machine wants to publish the change
4773
5044
  \`diff.patch\`. Everyone with access to that remote can read what you approve,
4774
5045
  and it stays in the remote's history.
4775
5046
 
4776
- Decide whether anything in the change should not leave this machine. This is
4777
- 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.
4778
5050
 
4779
5051
  Secret redaction has already run. Values it caught appear as placeholders such
4780
5052
  as \`<API_KEY>\`, \`<TOKEN>\`, or \`<SECRET>\`; they are not leaks. Look for what
@@ -4785,6 +5057,11 @@ pattern matching misses:
4785
5057
  - confidential material: customer names and data, unreleased plans, internal-only details
4786
5058
  - anything the repository's guidelines below rule out
4787
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)}
4788
5065
  ## Files
4789
5066
 
4790
5067
  All in this directory (\`$SIDECAR_REVIEW_DIR\`):
@@ -4882,7 +5159,7 @@ function decideReview(review, verdict, reason) {
4882
5159
  review.state = verdict;
4883
5160
  logSidecarEvent("review-decision", { id: review.record.id, verdict });
4884
5161
  }
4885
- 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;
4886
5163
  var init_review = __esm(() => {
4887
5164
  init_util();
4888
5165
  init_git();
@@ -4892,6 +5169,7 @@ var init_review = __esm(() => {
4892
5169
  init_redaction();
4893
5170
  ID_PATTERN = /^[0-9a-f]+$/;
4894
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 };
4895
5173
  });
4896
5174
 
4897
5175
  // src/sync.ts
@@ -4924,8 +5202,9 @@ function syncProject(root, config, options) {
4924
5202
  }
4925
5203
  if (!options.remote)
4926
5204
  return {};
4927
- if (reviewRequired(config))
4928
- return publishReviewed(root, sidecarPath, config, inbox, siblings, stage);
5205
+ if (reviewRequired(config)) {
5206
+ return publishReviewed(root, sidecarPath, config, inbox, siblings, stage, options.reviewFloor ?? false);
5207
+ }
4929
5208
  stage("push-inbox");
4930
5209
  syncBranchBeforePush(sidecarPath, inbox, config);
4931
5210
  pushBranch(sidecarPath, inbox);
@@ -5025,7 +5304,7 @@ function publicTip(repo, branch) {
5025
5304
  function isPublic(repo, commit) {
5026
5305
  return Boolean(git(repo, ["for-each-ref", "--contains", commit, "--format=%(refname)", PUBLIC_REFS]).stdout.trim());
5027
5306
  }
5028
- function publishReviewed(root, sidecarPath, config, inbox, siblings, stage) {
5307
+ function publishReviewed(root, sidecarPath, config, inbox, siblings, stage, reviewFloor) {
5029
5308
  stage("fetch");
5030
5309
  withPublicGit(sidecarPath, () => {
5031
5310
  fetchPublic(sidecarPath);
@@ -5043,12 +5322,25 @@ function publishReviewed(root, sidecarPath, config, inbox, siblings, stage) {
5043
5322
  stage("settle-remote");
5044
5323
  settleCheckouts(sidecarPath, config, inbox, siblings);
5045
5324
  stage("review");
5046
- const gate = withPublicGit(sidecarPath, () => gateUnpublished(root, sidecarPath, config));
5047
- if (gate?.launch)
5325
+ const gate = withPublicGit(sidecarPath, () => gateUnpublished(root, sidecarPath, config, reviewFloor));
5326
+ if (!gate)
5327
+ return {};
5328
+ if ("due" in gate)
5329
+ return { reviewDue: gate.due };
5330
+ if (gate.launch)
5048
5331
  launchReviewer(gate.review);
5049
- return gate ? { held: { review: gate.review, attention: reviewAttention(gate.review) } } : {};
5332
+ return { held: { review: gate.review, attention: reviewAttention(gate.review) } };
5333
+ }
5334
+ function gateUnpublished(root, sidecarPath, config, floor) {
5335
+ const configPath = peerConfigPath(root, config.peer);
5336
+ const result = gateReview(root, sidecarPath, config, floor);
5337
+ if (result && "due" in result)
5338
+ writeReviewDue(configPath, result.due);
5339
+ else
5340
+ clearReviewDue(configPath);
5341
+ return result;
5050
5342
  }
5051
- function gateUnpublished(root, sidecarPath, config) {
5343
+ function gateReview(root, sidecarPath, config, floor) {
5052
5344
  const head = git(sidecarPath, ["rev-parse", `refs/heads/${config.branch}`]).stdout.trim();
5053
5345
  const headTree = treeOf(sidecarPath, head);
5054
5346
  const publicMain = publicTip(sidecarPath, config.branch);
@@ -5070,10 +5362,25 @@ function gateUnpublished(root, sidecarPath, config) {
5070
5362
  return { review: open, launch: false };
5071
5363
  markSuperseded(open);
5072
5364
  }
5365
+ if (floor) {
5366
+ const due = reviewFloorEndsAt(sidecarPath, config);
5367
+ if (due > Date.now()) {
5368
+ console.log(`the next review opens in ${Math.ceil((due - Date.now()) / 1000)}s; reviews open at most once per debounce`);
5369
+ return { due };
5370
+ }
5371
+ }
5073
5372
  const review = createReview({ root, config, sidecarPath, base, head });
5074
5373
  console.log(`opened review ${paint("brand", review.record.id)} for ${review.record.files.length} changed file(s)`);
5075
5374
  return { review, launch: hasReviewer };
5076
5375
  }
5376
+ function reviewFloorEndsAt(sidecarPath, config) {
5377
+ const previous = listReviews(sidecarPath)[0];
5378
+ if (!previous)
5379
+ return 0;
5380
+ const { createdAt, launchedAt } = previous.record;
5381
+ const started = Math.max(Date.parse(createdAt), launchedAt ? Date.parse(launchedAt) : 0);
5382
+ return started + (config.debounce ?? DEFAULT_DEBOUNCE_SECONDS) * 1000;
5383
+ }
5077
5384
  function publishApprovedReview(sidecarPath, config, inbox, review) {
5078
5385
  const { record } = review;
5079
5386
  const target = record.publishing ?? { inbox, commit: cutPublishCommit(sidecarPath, config, inbox, record) };
@@ -5333,7 +5640,6 @@ function readFleetHealth(sidecarPath) {
5333
5640
  return entries.sort((left, right) => rank[left.state] - rank[right.state] || left.record.machine.localeCompare(right.record.machine) || left.branch.localeCompare(right.branch));
5334
5641
  }
5335
5642
  function mergeInboxBranches(sidecarPath, config, options) {
5336
- ensureClean(sidecarPath);
5337
5643
  ensureCommitIdentity(sidecarPath);
5338
5644
  if (options.remote)
5339
5645
  fetch(sidecarPath, false);
@@ -5353,9 +5659,11 @@ function mergeInboxBranches(sidecarPath, config, options) {
5353
5659
  }
5354
5660
  }
5355
5661
  if (!hasAnyCommit(sidecarPath)) {
5662
+ ensureClean(sidecarPath);
5356
5663
  return mergeInboxBranchesAt(sidecarPath, config, options);
5357
5664
  }
5358
5665
  if (git(sidecarPath, ["branch", "--show-current"]).stdout.trim() === config.branch) {
5666
+ ensureClean(sidecarPath);
5359
5667
  ensureInboxBranch(sidecarPath, config, expandInbox(config, sidecarPath));
5360
5668
  }
5361
5669
  return withScratchWorktree(sidecarPath, config, "HEAD", (worktree) => mergeInboxBranchesAt(worktree, config, options));
@@ -5915,7 +6223,7 @@ function syncBranchBeforePush(repo, branch, config) {
5915
6223
  if (isAncestor(repo, remoteBranch, "HEAD"))
5916
6224
  return;
5917
6225
  if (isDirty(repo)) {
5918
- throw new SidecarError(`${remoteBranch} has commits not in local ${branch}, and the sidecar checkout has uncommitted changes`);
6226
+ throw new SidecarError(`${remoteBranch} has commits not in local ${branch}, and the sidecar checkout has uncommitted changes`, { reason: "dirty-checkout" });
5919
6227
  }
5920
6228
  if (isAncestor(repo, "HEAD", remoteBranch)) {
5921
6229
  git(repo, ["merge", "--ff-only", remoteBranch]);
@@ -5926,12 +6234,19 @@ function syncBranchBeforePush(repo, branch, config) {
5926
6234
  function refreshInboxFromMain(repo, config, inbox) {
5927
6235
  if (!branchExists(repo, inbox) || !branchExists(repo, config.branch))
5928
6236
  return;
5929
- ensureClean(repo);
6237
+ if (isDirty(repo)) {
6238
+ logSidecarEvent("settle-deferred", { sidecarPath: repo });
6239
+ return;
6240
+ }
5930
6241
  git(repo, ["switch", inbox]);
5931
6242
  const result = git(repo, ["merge", "--ff-only", config.branch], { check: false });
5932
- if (result.status !== 0) {
5933
- throw new SidecarError(result.stderr.trim() || `could not fast-forward ${inbox} to ${config.branch}`);
6243
+ if (result.status === 0)
6244
+ return;
6245
+ if (isDirty(repo)) {
6246
+ logSidecarEvent("settle-deferred", { sidecarPath: repo });
6247
+ return;
5934
6248
  }
6249
+ throw new SidecarError(result.stderr.trim() || `could not fast-forward ${inbox} to ${config.branch}`);
5935
6250
  }
5936
6251
  function pushBranch(repo, branch) {
5937
6252
  git(repo, ["push", "--no-follow-tags", "-u", "origin", `HEAD:refs/heads/${branch}`]);
@@ -6203,7 +6518,7 @@ function pendingInboxBranches(repo, config) {
6203
6518
  });
6204
6519
  return [...local, ...remote].sort();
6205
6520
  }
6206
- 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";
6207
6522
  var init_sync = __esm(() => {
6208
6523
  init_color();
6209
6524
  init_util();
@@ -8229,6 +8544,7 @@ __export(exports_daemon, {
8229
8544
  selectWatchTargets: () => selectWatchTargets,
8230
8545
  scheduleFor: () => scheduleFor,
8231
8546
  runDaemonLoop: () => runDaemonLoop,
8547
+ reviewDueAt: () => reviewDueAt,
8232
8548
  refreshWatchers: () => refreshWatchers,
8233
8549
  compileGitignoreMatcher: () => compileGitignoreMatcher,
8234
8550
  checkAndInstallUpdate: () => checkAndInstallUpdate,
@@ -8299,6 +8615,7 @@ async function runDaemonLoop(options) {
8299
8615
  await acquireDaemonPid();
8300
8616
  installShutdownHandlers();
8301
8617
  await watchRegistry(state);
8618
+ await watchReviewDue(state);
8302
8619
  const bootVersion = packageVersion();
8303
8620
  while (true) {
8304
8621
  maybeAdoptNewerInstall(state, bootVersion);
@@ -8411,10 +8728,11 @@ async function syncInstance(state, key, trigger, options = {}) {
8411
8728
  state.syncingFamilies.add(family);
8412
8729
  if (!options.localOnly) {
8413
8730
  state.lastRemoteSyncAt.set(key, Date.now());
8414
- clearTimeout(state.remoteTimers.get(key));
8731
+ clearTimeout(state.remoteTimers.get(key)?.timer);
8415
8732
  state.remoteTimers.delete(key);
8416
8733
  }
8417
8734
  let succeeded = false;
8735
+ let dirtyCheckout = false;
8418
8736
  try {
8419
8737
  const localCli = localSidecarCliPath(root);
8420
8738
  const cli = localCli ?? currentCliPath();
@@ -8431,19 +8749,27 @@ async function syncInstance(state, key, trigger, options = {}) {
8431
8749
  },
8432
8750
  timeoutMs: SYNC_TIMEOUT_MS
8433
8751
  });
8752
+ dirtyCheckout = !result.timedOut && result.status === DIRTY_CHECKOUT_EXIT;
8434
8753
  if (result.status === 0) {
8435
- state.failures.delete(key);
8436
- state.skipUntilCycle.delete(key);
8754
+ if (!options.localOnly) {
8755
+ state.failures.delete(key);
8756
+ state.skipUntilCycle.delete(key);
8757
+ }
8437
8758
  logSidecarEvent("daemon-sync", { ...peerFields(key), trigger, local: Boolean(localCli) });
8438
8759
  succeeded = true;
8439
8760
  } else {
8440
- const failures = (state.failures.get(key) ?? 0) + 1;
8441
- state.failures.set(key, failures);
8442
- state.skipUntilCycle.set(key, state.cycleCount + Math.min(2 ** (failures - 1), MAX_BACKOFF_CYCLES));
8761
+ if (!dirtyCheckout) {
8762
+ const failures = (state.failures.get(key) ?? 0) + 1;
8763
+ state.failures.set(key, failures);
8764
+ state.skipUntilCycle.set(key, state.cycleCount + Math.min(2 ** (failures - 1), MAX_BACKOFF_CYCLES));
8765
+ }
8766
+ if (!options.localOnly)
8767
+ state.lastRemoteSyncAt.set(key, Date.now());
8443
8768
  logSidecarEvent("failure", {
8444
8769
  command: "daemon",
8445
8770
  ...peerFields(key),
8446
8771
  trigger,
8772
+ reason: result.timedOut ? "timeout" : dirtyCheckout ? "dirty-checkout" : `exit-${result.status}`,
8447
8773
  message: result.timedOut ? "sync timed out" : !outputIsPrivate(key) && result.output.trim().slice(-500) || `sync exited ${result.status}`
8448
8774
  });
8449
8775
  }
@@ -8453,12 +8779,24 @@ async function syncInstance(state, key, trigger, options = {}) {
8453
8779
  if (options.localOnly)
8454
8780
  armRemoteSync(state, key);
8455
8781
  }
8456
- if (succeeded)
8782
+ if (succeeded) {
8783
+ if (!options.localOnly)
8784
+ armReviewDue(state, key, "after-sync");
8457
8785
  await followUpTrailingSync(state, key);
8458
- else
8459
- state.trailingPending.delete(key);
8786
+ } else {
8787
+ followUpFailedSync(state, key, { dirtyCheckout, remote: !options.localOnly });
8788
+ }
8460
8789
  return succeeded;
8461
8790
  }
8791
+ function followUpFailedSync(state, key, failure) {
8792
+ if (failure.dirtyCheckout) {
8793
+ state.trailingPending.add(key);
8794
+ if (failure.remote)
8795
+ armRemoteSync(state, key);
8796
+ }
8797
+ if (state.trailingPending.has(key) && !state.pendingTimers.has(key))
8798
+ openTrailingWindow(state, key, SETTLE_WINDOW_MS);
8799
+ }
8462
8800
  async function followUpTrailingSync(state, key) {
8463
8801
  if (!state.trailingPending.delete(key))
8464
8802
  return;
@@ -8474,7 +8812,9 @@ async function checkoutIsDirty(key) {
8474
8812
  if (!sidecarPath || !fs14.existsSync(sidecarPath))
8475
8813
  return false;
8476
8814
  const result = await runChild("git", ["-C", sidecarPath, "status", "--porcelain"], { timeoutMs: 30000 });
8477
- return result.status === 0 && Boolean(result.stdout.trim());
8815
+ if (result.status !== 0)
8816
+ return true;
8817
+ return Boolean(result.stdout.trim());
8478
8818
  }
8479
8819
  function localSidecarCliPath(root) {
8480
8820
  if (!projectDependsOnSidecar(root))
@@ -8602,14 +8942,38 @@ async function beginWatchSync(state, key) {
8602
8942
  state.trailingPending.add(key);
8603
8943
  }
8604
8944
  function armRemoteSync(state, key) {
8605
- if (state.remoteTimers.has(key))
8606
- return;
8607
8945
  const elapsed = Date.now() - (state.lastRemoteSyncAt.get(key) ?? 0);
8946
+ armRemoteAt(state, key, Date.now() + Math.max(SETTLE_WINDOW_MS, scheduleFor(key, state.options).debounceSeconds * 1000 - elapsed), "remote-due");
8947
+ }
8948
+ function armRemoteAt(state, key, at, trigger) {
8949
+ const booked = state.remoteTimers.get(key);
8950
+ if (booked && booked.at <= at)
8951
+ return;
8952
+ clearTimeout(booked?.timer);
8608
8953
  const timer = setTimeout(() => {
8609
8954
  state.remoteTimers.delete(key);
8610
- syncInstance(state, key, "remote-due");
8611
- }, Math.max(SETTLE_WINDOW_MS, scheduleFor(key, state.options).debounceSeconds * 1000 - elapsed));
8612
- state.remoteTimers.set(key, timer);
8955
+ syncInstance(state, key, trigger);
8956
+ }, Math.max(0, at - Date.now()));
8957
+ state.remoteTimers.set(key, { timer, at });
8958
+ }
8959
+ function armReviewDue(state, key, when) {
8960
+ const at = reviewDueAt(readReviewDue(key), when, Date.now(), scheduleFor(key, state.options).debounceSeconds * 1000, comingBack(state, key));
8961
+ if (at !== undefined)
8962
+ armRemoteAt(state, key, at, "review-due");
8963
+ }
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) {
8968
+ if (due === undefined)
8969
+ return;
8970
+ if (due > now)
8971
+ return due;
8972
+ if (when === "start")
8973
+ return now;
8974
+ if (when === "after-sync")
8975
+ return now + Math.max(SETTLE_WINDOW_MS, debounceMs);
8976
+ return comingBack2 ? undefined : now;
8613
8977
  }
8614
8978
  function remoteIsDue(state, key) {
8615
8979
  const last = state.lastRemoteSyncAt.get(key) ?? 0;
@@ -8653,6 +9017,34 @@ async function watchRegistry(state) {
8653
9017
  });
8654
9018
  }
8655
9019
  }
9020
+ async function watchReviewDue(state) {
9021
+ const scan = (when) => {
9022
+ for (const instance of readInstances())
9023
+ armReviewDue(state, instance.configPath, when);
9024
+ };
9025
+ scan("start");
9026
+ const chokidar = await loadChokidar();
9027
+ if (!chokidar)
9028
+ return;
9029
+ try {
9030
+ fs14.mkdirSync(reviewDueDir(), { recursive: true });
9031
+ const watcher = chokidar.watch(reviewDueDir(), { ignoreInitial: true, depth: 0 });
9032
+ let timer;
9033
+ watcher.on("all", () => {
9034
+ if (timer)
9035
+ return;
9036
+ timer = setTimeout(() => {
9037
+ timer = undefined;
9038
+ scan("change");
9039
+ }, 500);
9040
+ });
9041
+ } catch (error) {
9042
+ logSidecarEvent("failure", {
9043
+ command: "daemon",
9044
+ message: `could not watch review due times: ${error instanceof Error ? error.message : String(error)}`
9045
+ });
9046
+ }
9047
+ }
8656
9048
  function compileGitignoreMatcher(lines) {
8657
9049
  const rules = import_ignore.default({ ignorecase: false }).add(lines);
8658
9050
  return (relativePath) => {
@@ -8978,7 +9370,7 @@ async function cmdDaemonRun(args) {
8978
9370
  if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
8979
9371
  throw new SidecarError("--interval must be > 0");
8980
9372
  }
8981
- const debounceSeconds = Number(getValue(parsed, "--debounce", "60"));
9373
+ const debounceSeconds = Number(getValue(parsed, "--debounce", String(DEFAULT_DEBOUNCE_SECONDS)));
8982
9374
  if (!Number.isFinite(debounceSeconds) || debounceSeconds < 0) {
8983
9375
  throw new SidecarError("--debounce must be >= 0");
8984
9376
  }
@@ -9102,7 +9494,8 @@ function cmdSync(args) {
9102
9494
  lock: soft ? "skip" : "throw",
9103
9495
  remote: !parsed.flags.has("--local") && process.env[LOCAL_SYNC_ENV] !== "1",
9104
9496
  snapshot: !parsed.flags.has("--no-snapshot"),
9105
- message: getValue(parsed, "--message", getValue(parsed, "-m", "")) || undefined
9497
+ message: getValue(parsed, "--message", getValue(parsed, "-m", "")) || undefined,
9498
+ reviewFloor: soft
9106
9499
  };
9107
9500
  for (const peer of peers) {
9108
9501
  announcePeer(peer, peers);
@@ -9113,12 +9506,13 @@ function cmdSync(args) {
9113
9506
  } catch (error) {
9114
9507
  if (peers.length === 1)
9115
9508
  throw error;
9116
- failed.push(peer.name);
9509
+ failed.push({ name: peer.name, error });
9117
9510
  console.error(`sidecar: ${peer.name}: ${error instanceof Error ? error.message : String(error)}`);
9118
9511
  }
9119
9512
  }
9120
9513
  if (failed.length) {
9121
- throw new SidecarError(`${failed.length} of ${peers.length} peers failed to sync: ${failed.join(", ")}`);
9514
+ const dirty = failed.every(({ error }) => error instanceof SidecarError && error.reason === "dirty-checkout");
9515
+ throw new SidecarError(`${failed.length} of ${peers.length} peers failed to sync: ${failed.map(({ name }) => name).join(", ")}`, dirty ? { reason: "dirty-checkout" } : {});
9122
9516
  }
9123
9517
  return 0;
9124
9518
  }
@@ -9135,6 +9529,7 @@ function syncPeer(peer, options) {
9135
9529
  snapshot: options.snapshot,
9136
9530
  remote,
9137
9531
  message: options.message,
9532
+ reviewFloor: options.reviewFloor,
9138
9533
  onStage: (name) => {
9139
9534
  stage = name;
9140
9535
  }
@@ -9142,6 +9537,8 @@ function syncPeer(peer, options) {
9142
9537
  registerCurrentInstance(root, config, remote ? { event: "sync", lastSyncAt: nowIso() } : { event: "sync-local" });
9143
9538
  });
9144
9539
  } catch (error) {
9540
+ if (error instanceof SidecarError)
9541
+ error.stage ??= stage;
9145
9542
  if (remote)
9146
9543
  reportSyncHealth(root, config, {
9147
9544
  status: "failed",
@@ -9156,8 +9553,9 @@ function syncPeer(peer, options) {
9156
9553
  const { id } = held.review.record;
9157
9554
  console.log(held.attention ? `sidecar: publishing is held — review ${id} needs attention: ${held.attention}; \`sidecar review show ${id}\`` : `sidecar: publishing is held for review ${id}; \`sidecar review show ${id}\``);
9158
9555
  }
9159
- if (remote)
9160
- reportSyncHealth(root, config, held ? { status: "ok", review: held.attention ? "attention" : "pending" } : { status: "ok" });
9556
+ if (remote) {
9557
+ reportSyncHealth(root, config, held ? { status: "ok", review: held.attention ? "attention" : "pending" } : outcome.reviewDue ? { status: "ok", review: "pending" } : { status: "ok" });
9558
+ }
9161
9559
  if (!soft) {
9162
9560
  const sidecarPath = resolveSidecarPath(root, config);
9163
9561
  if (checkoutIsUnlinkedFromFamily(root, config, sidecarPath)) {
@@ -9425,7 +9823,20 @@ function decideCommand(verdict, args) {
9425
9823
  return 0;
9426
9824
  }
9427
9825
  const peer = loadPeer(selected.record.root, selected.record.peer);
9428
- syncPeer(peer, { lock: "wait", remote: true, snapshot: true });
9826
+ try {
9827
+ syncPeer(peer, { lock: "wait", remote: true, snapshot: true, reviewFloor: true });
9828
+ } catch (error) {
9829
+ if (readReview(id)?.state !== "published") {
9830
+ console.error(`sidecar: review ${id} is approved, but this sync could not confirm it published; the next sync finishes publishing it`);
9831
+ throw error;
9832
+ }
9833
+ logSidecarEvent("review-followup-failure", {
9834
+ id,
9835
+ ...error instanceof SidecarError && error.reason ? { reason: error.reason } : {},
9836
+ ...error instanceof SidecarError && error.stage ? { stage: error.stage } : {}
9837
+ });
9838
+ console.error(`sidecar: published review ${id}; the sync after it stopped early and the next sync finishes it: ${error instanceof Error ? error.message : String(error)}`);
9839
+ }
9429
9840
  return 0;
9430
9841
  }
9431
9842
  function retryCommand(args) {
@@ -9680,12 +10091,14 @@ async function main(argv = process.argv.slice(2)) {
9680
10091
  if (command !== "redact" && command !== "deinit" && shouldUseGlobalRegistry()) {
9681
10092
  logSidecarEvent("failure", {
9682
10093
  command,
10094
+ ...error instanceof SidecarError && error.reason ? { reason: error.reason } : {},
10095
+ ...error instanceof SidecarError && error.stage ? { stage: error.stage } : {},
9683
10096
  ...privateFailures() ? {} : { message: error instanceof Error ? error.message : String(error) }
9684
10097
  });
9685
10098
  }
9686
10099
  if (error instanceof SidecarError) {
9687
10100
  console.error(`${paint("bad", "sidecar:", colorLevel(process.stderr))} ${error.message}`);
9688
- return 1;
10101
+ return error.reason === "dirty-checkout" ? DIRTY_CHECKOUT_EXIT : 1;
9689
10102
  }
9690
10103
  if (error instanceof Error && error.name === "AbortError") {
9691
10104
  console.error("sidecar: stopped");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidecarsync",
3
- "version": "2.1.0",
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": {