sidecarsync 2.0.1 → 2.1.0

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 (3) hide show
  1. package/README.md +5 -0
  2. package/dist/cli.js +1661 -497
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -196,6 +196,9 @@ function currentUser() {
196
196
  function currentHost() {
197
197
  return os.hostname().split(".", 1)[0] || "unknown";
198
198
  }
199
+ function currentMachine() {
200
+ return `${currentUser()}@${currentHost()}`;
201
+ }
199
202
  function parseDuration(value) {
200
203
  if (typeof value === "number")
201
204
  return Number.isFinite(value) && value >= 0 ? value : undefined;
@@ -229,6 +232,10 @@ import os2 from "node:os";
229
232
  import path2 from "node:path";
230
233
  import { spawnSync } from "node:child_process";
231
234
  import { fileURLToPath } from "node:url";
235
+ function sidecarCliPath() {
236
+ const self = fileURLToPath(import.meta.url);
237
+ return self.endsWith(".ts") ? path2.join(path2.dirname(self), "..", "dist", "cli.js") : self;
238
+ }
232
239
  function packageVersion() {
233
240
  let current = path2.dirname(fileURLToPath(import.meta.url));
234
241
  while (true) {
@@ -324,147 +331,6 @@ var init_install = __esm(() => {
324
331
  INSTALL_SOURCES = new Set(["npm", "bun", "curl"]);
325
332
  });
326
333
 
327
- // src/git.ts
328
- import fs3 from "node:fs";
329
- import path3 from "node:path";
330
- import { spawnSync as spawnSync2 } from "node:child_process";
331
- function git(repo, args, options = {}) {
332
- return gitRaw(["-C", repo, ...args], options);
333
- }
334
- function gitBytes(repo, args, options = {}) {
335
- const check = options.check ?? true;
336
- const result = spawnSync2("git", ["-C", repo, ...args], {
337
- encoding: "buffer",
338
- maxBuffer: 100 * 1024 * 1024
339
- });
340
- const status = result.status ?? 1;
341
- const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? "");
342
- const stderr = Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.from(result.stderr ?? "");
343
- if (check && status !== 0) {
344
- throw new SidecarError(stderr.toString("utf8").trim() || stdout.toString("utf8").trim());
345
- }
346
- return { status, stdout, stderr };
347
- }
348
- function gitRaw(args, options = {}) {
349
- const check = options.check ?? true;
350
- const result = spawnSync2("git", args, {
351
- encoding: "utf8",
352
- input: options.input,
353
- maxBuffer: 100 * 1024 * 1024
354
- });
355
- const status = result.status ?? 1;
356
- const stdout = result.stdout ?? "";
357
- const stderr = result.stderr ?? "";
358
- if (check && status !== 0) {
359
- throw new SidecarError(stderr.trim() || stdout.trim());
360
- }
361
- return { status, stdout, stderr };
362
- }
363
- function fetch(repo, quiet, check = true) {
364
- const args = ["fetch", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*"];
365
- if (quiet)
366
- args.splice(1, 0, "--quiet");
367
- git(repo, args, { check });
368
- }
369
- function hasAnyCommit(repo) {
370
- return git(repo, ["rev-parse", "--verify", "HEAD"], { check: false }).status === 0;
371
- }
372
- function branchExists(repo, branch) {
373
- return git(repo, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { check: false }).status === 0;
374
- }
375
- function remoteRefExists(repo, branch) {
376
- return git(repo, ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`], {
377
- check: false
378
- }).status === 0;
379
- }
380
- function isAncestor(repo, maybeAncestor, descendant) {
381
- return git(repo, ["merge-base", "--is-ancestor", maybeAncestor, descendant], { check: false }).status === 0;
382
- }
383
- function gitToplevel(cwd) {
384
- const root = gitToplevelOptional(cwd);
385
- if (!root)
386
- throw new SidecarError("not inside a Git repository");
387
- return root;
388
- }
389
- function gitToplevelOptional(cwd) {
390
- const result = gitRaw(["-C", cwd, "rev-parse", "--show-toplevel"], { check: false });
391
- if (result.status !== 0)
392
- return;
393
- return result.stdout.trim();
394
- }
395
- function gitCommonDir(root) {
396
- const commonDir = gitCommonDirOptional(root);
397
- if (!commonDir)
398
- throw new SidecarError("not inside a Git repository");
399
- return commonDir;
400
- }
401
- function gitExcludePath(root) {
402
- const commonDir = gitCommonDirOptional(root);
403
- return commonDir ? path3.join(commonDir, "info", "exclude") : undefined;
404
- }
405
- function isGitIgnored(root, relativePath) {
406
- return git(root, ["check-ignore", "-q", "--", relativePath], { check: false }).status === 0;
407
- }
408
- function isGitTracked(root, relativePath) {
409
- return git(root, ["ls-files", "--error-unmatch", "--", relativePath], { check: false }).status === 0;
410
- }
411
- function gitCommonDirOptional(root) {
412
- const result = gitRaw(["-C", root, "rev-parse", "--git-common-dir"], { check: false });
413
- if (result.status !== 0)
414
- return;
415
- return path3.resolve(root, result.stdout.trim());
416
- }
417
- function gitDir(repo) {
418
- const result = git(repo, ["rev-parse", "--git-dir"]).stdout.trim();
419
- return path3.isAbsolute(result) ? result : path3.resolve(repo, result);
420
- }
421
- function hasGitMetadata(repo) {
422
- return fs3.existsSync(path3.join(repo, ".git"));
423
- }
424
- function isDirty(repo) {
425
- return Boolean(git(repo, ["status", "--porcelain"]).stdout.trim());
426
- }
427
- function ensureClean(repo) {
428
- if (isDirty(repo))
429
- throw new SidecarError("sidecar checkout has uncommitted changes");
430
- }
431
- function ensureCommitIdentity(repo) {
432
- if (git(repo, ["config", "user.name"], { check: false }).status !== 0) {
433
- git(repo, ["config", "user.name", currentUser()]);
434
- }
435
- if (git(repo, ["config", "user.email"], { check: false }).status !== 0) {
436
- git(repo, ["config", "user.email", `${slug(currentUser())}@${slug(currentHost())}.local`]);
437
- }
438
- }
439
- function familyPrimaryRoot(root) {
440
- const primary = jjDefaultWorkspace(root) ?? gitMainWorktree(root);
441
- if (!primary)
442
- return;
443
- return realpathOr(primary) === realpathOr(root) ? undefined : primary;
444
- }
445
- function jjDefaultWorkspace(root) {
446
- const pointer = path3.join(root, ".jj", "repo");
447
- try {
448
- if (!fs3.statSync(pointer).isFile())
449
- return;
450
- const repo = path3.resolve(path3.dirname(pointer), fs3.readFileSync(pointer, "utf8").trim());
451
- const workspace = path3.dirname(path3.dirname(repo));
452
- return fs3.existsSync(path3.join(workspace, ".jj")) ? workspace : undefined;
453
- } catch {
454
- return;
455
- }
456
- }
457
- function gitMainWorktree(root) {
458
- const result = git(root, ["worktree", "list", "--porcelain"], { check: false });
459
- if (result.status !== 0)
460
- return;
461
- const entry = result.stdout.split(/\r?\n/).find((line) => line.startsWith("worktree "));
462
- return entry?.slice("worktree ".length).trim() || undefined;
463
- }
464
- var init_git = __esm(() => {
465
- init_util();
466
- });
467
-
468
334
  // ../../node_modules/.bun/smol-toml@1.7.0/node_modules/smol-toml/dist/date.js
469
335
  var DATE_TIME_RE, TomlDate;
470
336
  var init_date = __esm(() => {
@@ -1428,6 +1294,157 @@ var init_dist = __esm(() => {
1428
1294
  */
1429
1295
  });
1430
1296
 
1297
+ // src/git.ts
1298
+ import fs3 from "node:fs";
1299
+ import path3 from "node:path";
1300
+ import { spawnSync as spawnSync2 } from "node:child_process";
1301
+ function git(repo, args, options = {}) {
1302
+ return gitRaw(["-C", repo, ...args], options);
1303
+ }
1304
+ function gitBytes(repo, args, options = {}) {
1305
+ const check = options.check ?? true;
1306
+ const result = spawnSync2("git", ["-C", repo, ...args], {
1307
+ encoding: "buffer",
1308
+ maxBuffer: 100 * 1024 * 1024
1309
+ });
1310
+ const status = result.status ?? 1;
1311
+ const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? "");
1312
+ const stderr = Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.from(result.stderr ?? "");
1313
+ if (check && status !== 0) {
1314
+ throw new SidecarError(stderr.toString("utf8").trim() || stdout.toString("utf8").trim());
1315
+ }
1316
+ return { status, stdout, stderr };
1317
+ }
1318
+ function gitRaw(args, options = {}) {
1319
+ const check = options.check ?? true;
1320
+ const result = spawnSync2("git", args, {
1321
+ encoding: "utf8",
1322
+ input: options.input,
1323
+ maxBuffer: 100 * 1024 * 1024
1324
+ });
1325
+ const status = result.status ?? 1;
1326
+ const stdout = result.stdout ?? "";
1327
+ const stderr = result.stderr ?? "";
1328
+ if (check && status !== 0) {
1329
+ throw new SidecarError(stderr.trim() || stdout.trim());
1330
+ }
1331
+ return { status, stdout, stderr };
1332
+ }
1333
+ function fetch(repo, quiet, check = true) {
1334
+ const args = ["fetch", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*"];
1335
+ if (quiet)
1336
+ args.splice(1, 0, "--quiet");
1337
+ git(repo, args, { check });
1338
+ }
1339
+ function hasAnyCommit(repo) {
1340
+ return git(repo, ["rev-parse", "--verify", "HEAD"], { check: false }).status === 0;
1341
+ }
1342
+ function branchExists(repo, branch) {
1343
+ return git(repo, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { check: false }).status === 0;
1344
+ }
1345
+ function remoteRefExists(repo, branch) {
1346
+ return git(repo, ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`], {
1347
+ check: false
1348
+ }).status === 0;
1349
+ }
1350
+ function isAncestor(repo, maybeAncestor, descendant) {
1351
+ return git(repo, ["merge-base", "--is-ancestor", maybeAncestor, descendant], { check: false }).status === 0;
1352
+ }
1353
+ function treeOf(repo, ref) {
1354
+ return git(repo, ["rev-parse", `${ref}^{tree}`]).stdout.trim();
1355
+ }
1356
+ function emptyTree(repo) {
1357
+ return git(repo, ["mktree"], { input: "" }).stdout.trim();
1358
+ }
1359
+ function gitToplevel(cwd) {
1360
+ const root = gitToplevelOptional(cwd);
1361
+ if (!root)
1362
+ throw new SidecarError("not inside a Git repository");
1363
+ return root;
1364
+ }
1365
+ function gitToplevelOptional(cwd) {
1366
+ const result = gitRaw(["-C", cwd, "rev-parse", "--show-toplevel"], { check: false });
1367
+ if (result.status !== 0)
1368
+ return;
1369
+ return result.stdout.trim();
1370
+ }
1371
+ function gitCommonDir(root) {
1372
+ const commonDir = gitCommonDirOptional(root);
1373
+ if (!commonDir)
1374
+ throw new SidecarError("not inside a Git repository");
1375
+ return commonDir;
1376
+ }
1377
+ function gitExcludePath(root) {
1378
+ const commonDir = gitCommonDirOptional(root);
1379
+ return commonDir ? path3.join(commonDir, "info", "exclude") : undefined;
1380
+ }
1381
+ function isGitIgnored(root, relativePath) {
1382
+ return git(root, ["check-ignore", "-q", "--", relativePath], { check: false }).status === 0;
1383
+ }
1384
+ function isGitTracked(root, relativePath) {
1385
+ return git(root, ["ls-files", "--error-unmatch", "--", relativePath], { check: false }).status === 0;
1386
+ }
1387
+ function gitCommonDirOptional(root) {
1388
+ const result = gitRaw(["-C", root, "rev-parse", "--git-common-dir"], { check: false });
1389
+ if (result.status !== 0)
1390
+ return;
1391
+ return path3.resolve(root, result.stdout.trim());
1392
+ }
1393
+ function gitDir(repo) {
1394
+ const result = git(repo, ["rev-parse", "--git-dir"]).stdout.trim();
1395
+ return path3.isAbsolute(result) ? result : path3.resolve(repo, result);
1396
+ }
1397
+ function hasGitMetadata(repo) {
1398
+ return fs3.existsSync(path3.join(repo, ".git"));
1399
+ }
1400
+ function isDirty(repo) {
1401
+ return Boolean(git(repo, ["status", "--porcelain"]).stdout.trim());
1402
+ }
1403
+ function ensureClean(repo) {
1404
+ if (isDirty(repo))
1405
+ throw new SidecarError("sidecar checkout has uncommitted changes");
1406
+ }
1407
+ function sidecarIdentity() {
1408
+ return { name: currentUser(), email: `${slug(currentUser())}@${slug(currentHost())}.local` };
1409
+ }
1410
+ function ensureCommitIdentity(repo) {
1411
+ const { name, email } = sidecarIdentity();
1412
+ if (git(repo, ["config", "user.name"], { check: false }).status !== 0) {
1413
+ git(repo, ["config", "user.name", name]);
1414
+ }
1415
+ if (git(repo, ["config", "user.email"], { check: false }).status !== 0) {
1416
+ git(repo, ["config", "user.email", email]);
1417
+ }
1418
+ }
1419
+ function familyPrimaryRoot(root) {
1420
+ const primary = jjDefaultWorkspace(root) ?? gitMainWorktree(root);
1421
+ if (!primary)
1422
+ return;
1423
+ return realpathOr(primary) === realpathOr(root) ? undefined : primary;
1424
+ }
1425
+ function jjDefaultWorkspace(root) {
1426
+ const pointer = path3.join(root, ".jj", "repo");
1427
+ try {
1428
+ if (!fs3.statSync(pointer).isFile())
1429
+ return;
1430
+ const repo = path3.resolve(path3.dirname(pointer), fs3.readFileSync(pointer, "utf8").trim());
1431
+ const workspace = path3.dirname(path3.dirname(repo));
1432
+ return fs3.existsSync(path3.join(workspace, ".jj")) ? workspace : undefined;
1433
+ } catch {
1434
+ return;
1435
+ }
1436
+ }
1437
+ function gitMainWorktree(root) {
1438
+ const result = git(root, ["worktree", "list", "--porcelain"], { check: false });
1439
+ if (result.status !== 0)
1440
+ return;
1441
+ const entry = result.stdout.split(/\r?\n/).find((line) => line.startsWith("worktree "));
1442
+ return entry?.slice("worktree ".length).trim() || undefined;
1443
+ }
1444
+ var init_git = __esm(() => {
1445
+ init_util();
1446
+ });
1447
+
1431
1448
  // src/redaction.ts
1432
1449
  function redactText(input, mode = DEFAULT_REDACTION_MODE) {
1433
1450
  if (mode === "none")
@@ -1582,7 +1599,8 @@ function parseHealthRecord(text) {
1582
1599
  lastFailureAt: typeof record.lastFailureAt === "string" ? record.lastFailureAt : undefined,
1583
1600
  consecutiveFailures: typeof record.consecutiveFailures === "number" && Number.isFinite(record.consecutiveFailures) ? record.consecutiveFailures : 0,
1584
1601
  stage: typeof record.stage === "string" ? record.stage : undefined,
1585
- message: typeof record.message === "string" ? record.message : undefined
1602
+ message: typeof record.message === "string" ? record.message : undefined,
1603
+ review: record.review === "pending" || record.review === "attention" ? record.review : undefined
1586
1604
  };
1587
1605
  }
1588
1606
  function serializeHealthRecord(record) {
@@ -1595,24 +1613,27 @@ function nextHealthRecord(previous, identity, outcome, now) {
1595
1613
  ...identity,
1596
1614
  status: outcome.status,
1597
1615
  updatedAt: now,
1598
- lastSuccessAt: previous?.lastSuccessAt,
1599
- lastFailureAt: previous?.lastFailureAt,
1616
+ lastSuccessAt: isoTimestamp(previous?.lastSuccessAt),
1617
+ lastFailureAt: isoTimestamp(previous?.lastFailureAt),
1600
1618
  consecutiveFailures: 0
1601
1619
  };
1602
1620
  if (outcome.status === "ok")
1603
- return { ...base, lastSuccessAt: now };
1621
+ return { ...base, lastSuccessAt: now, ...outcome.review ? { review: outcome.review } : {} };
1604
1622
  return {
1605
1623
  ...base,
1606
1624
  lastFailureAt: now,
1607
- consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1,
1625
+ consecutiveFailures: Math.max(0, Math.floor(previous?.consecutiveFailures ?? 0)) + 1,
1608
1626
  stage: outcome.stage,
1609
1627
  message: redactText(outcome.message).trim().slice(-MESSAGE_LIMIT)
1610
1628
  };
1611
1629
  }
1630
+ function isoTimestamp(value) {
1631
+ return value && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(value) ? value : undefined;
1632
+ }
1612
1633
  function shouldPublishHealth(previous, next, now = Date.now()) {
1613
1634
  if (!previous)
1614
1635
  return true;
1615
- if (next.status === "failed" || previous.status !== next.status)
1636
+ if (next.status === "failed" || previous.status !== next.status || previous.review !== next.review)
1616
1637
  return true;
1617
1638
  const last = Date.parse(previous.updatedAt);
1618
1639
  if (!Number.isFinite(last))
@@ -3538,6 +3559,22 @@ function ensureDistinctCheckouts(peers) {
3538
3559
  function sameRemoteKey(remote) {
3539
3560
  return remote.trim().replace(/\/+$/, "").replace(/\.git$/, "");
3540
3561
  }
3562
+ function reviewRequired(config) {
3563
+ return config.review === "required";
3564
+ }
3565
+ function privateFailures() {
3566
+ return failuresArePrivate;
3567
+ }
3568
+ function keepFailuresPrivate() {
3569
+ failuresArePrivate = true;
3570
+ }
3571
+ function notePeerPolicy(config) {
3572
+ if (reviewRequired(config))
3573
+ keepFailuresPrivate();
3574
+ }
3575
+ function reviewTimeoutSeconds(config) {
3576
+ return config.reviewTimeout ?? DEFAULT_REVIEW_TIMEOUT_SECONDS;
3577
+ }
3541
3578
  function findConfigRoot(start) {
3542
3579
  const root = findConfigRootOptional(start);
3543
3580
  if (root)
@@ -3556,7 +3593,11 @@ function findConfigRootOptional(start) {
3556
3593
  }
3557
3594
  }
3558
3595
  function writeConfig(configPath, config) {
3559
- const text = [
3596
+ notePeerPolicy(config);
3597
+ fs5.writeFileSync(configPath, configText(config), "utf8");
3598
+ }
3599
+ function configText(config) {
3600
+ return [
3560
3601
  `version = ${config.version}`,
3561
3602
  `remote = ${JSON.stringify(config.remote)}`,
3562
3603
  `path = ${JSON.stringify(config.path)}`,
@@ -3566,10 +3607,11 @@ function writeConfig(configPath, config) {
3566
3607
  `resolve = ${JSON.stringify(config.resolve ?? DEFAULT_RESOLVE)}`,
3567
3608
  ...config.debounce === undefined ? [] : [`debounce = ${config.debounce}`],
3568
3609
  ...config.interval === undefined ? [] : [`interval = ${config.interval}`],
3610
+ ...reviewRequired(config) ? [`review = "required"`] : [],
3611
+ ...config.reviewTimeout === undefined ? [] : [`review-timeout = ${config.reviewTimeout}`],
3569
3612
  ""
3570
3613
  ].join(`
3571
3614
  `);
3572
- fs5.writeFileSync(configPath, text, "utf8");
3573
3615
  }
3574
3616
  function readConfig(configPath, options = {}) {
3575
3617
  let values;
@@ -3580,10 +3622,22 @@ function readConfig(configPath, options = {}) {
3580
3622
  }
3581
3623
  values = parsed;
3582
3624
  } catch (error) {
3625
+ if (error.code !== "ENOENT")
3626
+ keepFailuresPrivate();
3583
3627
  if (error instanceof SidecarError)
3584
3628
  throw error;
3585
3629
  throw new SidecarError(`${configPath} is not valid TOML: ${error instanceof Error ? error.message : String(error)}`);
3586
3630
  }
3631
+ if (values.review !== undefined && values.review !== "off")
3632
+ keepFailuresPrivate();
3633
+ const unknown = Object.keys(values).filter((key) => !CONFIG_KEYS.has(key));
3634
+ if (unknown.length) {
3635
+ throw new SidecarError(`${configPath}: unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((key) => JSON.stringify(key)).join(", ")}; this sidecar may be older than the config — update it with \`sidecar update\``);
3636
+ }
3637
+ const version = numberConfigValue(configPath, values, "version", CONFIG_VERSION);
3638
+ if (version !== CONFIG_VERSION) {
3639
+ throw new SidecarError(`${configPath}: config version ${version} is not supported by this sidecar (expected ${CONFIG_VERSION}); update it with \`sidecar update\``);
3640
+ }
3587
3641
  const remote = optionalStringConfigValue(configPath, values, "remote");
3588
3642
  if (!remote)
3589
3643
  throw new SidecarError(`${configPath} is missing remote`);
@@ -3592,7 +3646,7 @@ function readConfig(configPath, options = {}) {
3592
3646
  const config = {
3593
3647
  peer,
3594
3648
  remote,
3595
- version: numberConfigValue(configPath, values, "version", 1),
3649
+ version,
3596
3650
  path: stringConfigValue(configPath, values, "path", DEFAULT_PATH),
3597
3651
  branch: stringConfigValue(configPath, values, "branch", DEFAULT_BRANCH),
3598
3652
  inbox: stringConfigValue(configPath, values, "inbox", DEFAULT_INBOX),
@@ -3600,6 +3654,8 @@ function readConfig(configPath, options = {}) {
3600
3654
  resolve: resolveModeConfigValue(stringConfigValue(configPath, values, "resolve", DEFAULT_RESOLVE), configPath),
3601
3655
  debounce: durationConfigValue(values.debounce, `${configPath} debounce`),
3602
3656
  interval: durationConfigValue(values.interval, `${configPath} interval`),
3657
+ review: reviewModeConfigValue(stringConfigValue(configPath, values, "review", "off"), configPath),
3658
+ reviewTimeout: durationConfigValue(values["review-timeout"], `${configPath} review-timeout`),
3603
3659
  rules: options.loadRules === false ? undefined : readRules(rulesPath),
3604
3660
  rulesPath
3605
3661
  };
@@ -3616,6 +3672,11 @@ function redactionModeConfigValue(value, source) {
3616
3672
  return value;
3617
3673
  throw new SidecarError(`${source}: invalid redaction mode ${JSON.stringify(value)}; expected one of ${REDACTION_MODES.join(", ")}`);
3618
3674
  }
3675
+ function reviewModeConfigValue(value, source) {
3676
+ if (REVIEW_MODES.includes(value))
3677
+ return value;
3678
+ throw new SidecarError(`${source}: invalid review mode ${JSON.stringify(value)}; expected one of ${REVIEW_MODES.join(", ")}`);
3679
+ }
3619
3680
  function durationConfigValue(value, source) {
3620
3681
  if (value === undefined)
3621
3682
  return;
@@ -3736,6 +3797,8 @@ function inboxPrefix(config) {
3736
3797
  return inboxBranchPrefix(config.inbox);
3737
3798
  }
3738
3799
  function remoteBranchName(remoteBranch) {
3800
+ if (remoteBranch.startsWith(PUBLIC_REFS))
3801
+ return remoteBranch.slice(PUBLIC_REFS.length);
3739
3802
  return remoteBranch.startsWith("origin/") ? remoteBranch.slice("origin/".length) : remoteBranch;
3740
3803
  }
3741
3804
  function matchesInboxPrefix(prefix, branch) {
@@ -3749,7 +3812,7 @@ function inboxBranchPrefix(template) {
3749
3812
  const slashIndex = staticPrefix.lastIndexOf("/");
3750
3813
  return slashIndex === -1 ? staticPrefix : staticPrefix.slice(0, slashIndex + 1);
3751
3814
  }
3752
- var DEFAULT_PATH = "sidecar", DEFAULT_BRANCH = "main", DEFAULT_INBOX = "sidecar-inbox/{user}/{random}", branchValidity, DEFAULT_PEER = "default", PEER_ENV = "SIDECAR_PEER", PEER_NAME, RESERVED_PEER_SUFFIXES, RESOLVE_MODES, DEFAULT_RESOLVE = "fork";
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/";
3753
3816
  var init_config = __esm(() => {
3754
3817
  init_dist();
3755
3818
  init_util();
@@ -3761,6 +3824,21 @@ var init_config = __esm(() => {
3761
3824
  PEER_NAME = /^[a-z0-9][a-z0-9-]*$/;
3762
3825
  RESERVED_PEER_SUFFIXES = new Set(["swp", "swo", "swx", "bak", "orig", "rej", "tmp", "old", "example", "sample", "lock"]);
3763
3826
  RESOLVE_MODES = ["fork", "lww"];
3827
+ REVIEW_MODES = ["off", "required"];
3828
+ DEFAULT_REVIEW_TIMEOUT_SECONDS = 60 * 60;
3829
+ CONFIG_KEYS = new Set([
3830
+ "version",
3831
+ "remote",
3832
+ "path",
3833
+ "branch",
3834
+ "inbox",
3835
+ "redaction",
3836
+ "resolve",
3837
+ "debounce",
3838
+ "interval",
3839
+ "review",
3840
+ "review-timeout"
3841
+ ]);
3764
3842
  });
3765
3843
 
3766
3844
  // src/state.ts
@@ -3803,7 +3881,8 @@ function readSettings() {
3803
3881
  daemonEnabled: typeof record.daemonEnabled === "boolean" ? record.daemonEnabled : true,
3804
3882
  autoUpdate: typeof record.autoUpdate === "boolean" ? record.autoUpdate : true,
3805
3883
  lastUpdateCheckAt: typeof record.lastUpdateCheckAt === "string" ? record.lastUpdateCheckAt : undefined,
3806
- installSource: INSTALL_SOURCES.has(record.installSource) ? record.installSource : undefined
3884
+ installSource: INSTALL_SOURCES.has(record.installSource) ? record.installSource : undefined,
3885
+ reviewer: typeof record.reviewer === "string" && record.reviewer.trim() ? record.reviewer : undefined
3807
3886
  };
3808
3887
  } catch (error) {
3809
3888
  logSidecarEvent("failure", {
@@ -3823,6 +3902,8 @@ function writeSettings(settings) {
3823
3902
  record.lastUpdateCheckAt = settings.lastUpdateCheckAt;
3824
3903
  if (settings.installSource)
3825
3904
  record.installSource = settings.installSource;
3905
+ if (settings.reviewer)
3906
+ record.reviewer = settings.reviewer;
3826
3907
  fs6.writeFileSync(settingsPath(), `${JSON.stringify(record, null, 2)}
3827
3908
  `, "utf8");
3828
3909
  }
@@ -4073,8 +4154,21 @@ function acquireSyncLockOrThrow(root, peer) {
4073
4154
  return release;
4074
4155
  throw new SidecarError("another sidecar sync is already running; try again once it finishes");
4075
4156
  }
4157
+ function acquireSyncLockWaiting(root, peer) {
4158
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
4159
+ const deadline = Date.now() + SYNC_LOCK_WAIT_MS;
4160
+ while (true) {
4161
+ const release = acquireSyncLock(root, peer);
4162
+ if (release)
4163
+ return release;
4164
+ if (Date.now() >= deadline) {
4165
+ throw new SidecarError("another sidecar sync is still running; try again once it finishes");
4166
+ }
4167
+ Atomics.wait(sleeper, 0, 0, 250);
4168
+ }
4169
+ }
4076
4170
  function withSyncLock(root, peer, onBusy, fn) {
4077
- const releaseLock = onBusy === "skip" ? acquireSyncLock(root, peer) : acquireSyncLockOrThrow(root, peer);
4171
+ const releaseLock = onBusy === "skip" ? acquireSyncLock(root, peer) : onBusy === "wait" ? acquireSyncLockWaiting(root, peer) : acquireSyncLockOrThrow(root, peer);
4078
4172
  if (!releaseLock) {
4079
4173
  console.log("another sidecar sync is already running; skipping this soft sync");
4080
4174
  return false;
@@ -4106,7 +4200,7 @@ function syncLockIsStale(lockDir) {
4106
4200
  return error.code !== "EPERM";
4107
4201
  }
4108
4202
  }
4109
- var STATE_DIR_ENV = "SIDECAR_STATE_DIR", DEFAULT_SETTINGS, LOG_ROTATE_BYTES;
4203
+ var STATE_DIR_ENV = "SIDECAR_STATE_DIR", DEFAULT_SETTINGS, LOG_ROTATE_BYTES, SYNC_LOCK_WAIT_MS;
4110
4204
  var init_state = __esm(() => {
4111
4205
  init_util();
4112
4206
  init_git();
@@ -4115,6 +4209,7 @@ var init_state = __esm(() => {
4115
4209
  init_redaction();
4116
4210
  DEFAULT_SETTINGS = { daemonEnabled: true, autoUpdate: true };
4117
4211
  LOG_ROTATE_BYTES = 5 * 1024 * 1024;
4212
+ SYNC_LOCK_WAIT_MS = 10 * 60 * 1000;
4118
4213
  });
4119
4214
 
4120
4215
  // src/service.ts
@@ -4405,22 +4500,405 @@ ${plistValue(item, indent + 2)}`).join("")}${pad}</dict>
4405
4500
  return `${pad}<string></string>
4406
4501
  `;
4407
4502
  }
4408
- function escapeXml(value) {
4409
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
4503
+ function escapeXml(value) {
4504
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
4505
+ }
4506
+ var SKIP_SERVICE_ENV = "SIDECAR_SKIP_SERVICE", DAEMON_LABEL = "com.anteprojector.sidecar";
4507
+ var init_service = __esm(() => {
4508
+ init_util();
4509
+ init_install();
4510
+ init_state();
4511
+ });
4512
+
4513
+ // src/review.ts
4514
+ import crypto4 from "node:crypto";
4515
+ import fs8 from "node:fs";
4516
+ import path8 from "node:path";
4517
+ import { spawn as spawn2, spawnSync as spawnSync4 } from "node:child_process";
4518
+ function newId() {
4519
+ return crypto4.randomBytes(4).toString("hex");
4520
+ }
4521
+ function reviewsDir() {
4522
+ return path8.join(sidecarStateDir(), "reviews");
4523
+ }
4524
+ function reviewDir(id) {
4525
+ return path8.join(reviewsDir(), id);
4526
+ }
4527
+ function writePrivateFile(filePath, text) {
4528
+ const temporary = `${filePath}.${crypto4.randomUUID()}.tmp`;
4529
+ try {
4530
+ fs8.writeFileSync(temporary, text, { encoding: "utf8", mode: 384 });
4531
+ fs8.renameSync(temporary, filePath);
4532
+ } finally {
4533
+ fs8.rmSync(temporary, { force: true });
4534
+ }
4535
+ }
4536
+ function writePrivateJson(filePath, value) {
4537
+ writePrivateFile(filePath, `${JSON.stringify(value, null, 2)}
4538
+ `);
4539
+ }
4540
+ function readJson(filePath) {
4541
+ try {
4542
+ return JSON.parse(fs8.readFileSync(filePath, "utf8"));
4543
+ } catch {
4544
+ return;
4545
+ }
4546
+ }
4547
+ function readReview(id) {
4548
+ keepFailuresPrivate();
4549
+ if (!ID_PATTERN.test(id))
4550
+ return;
4551
+ const dir = reviewDir(id);
4552
+ const record = readJson(path8.join(dir, "review.json"));
4553
+ if (!record || record.id !== id)
4554
+ return;
4555
+ const verdict = readJson(path8.join(dir, "verdict.json"));
4556
+ const run = readJson(runPath(dir));
4557
+ return { record, verdict, run, state: reviewState(record, verdict, run), dir };
4558
+ }
4559
+ function runPath(dir) {
4560
+ return path8.join(dir, "run.json");
4561
+ }
4562
+ function reviewFamily(sidecarPath) {
4563
+ return realpathOr(gitCommonDir(sidecarPath));
4564
+ }
4565
+ function listReviews(sidecarPath) {
4566
+ let ids;
4567
+ try {
4568
+ ids = fs8.readdirSync(reviewsDir());
4569
+ } catch {
4570
+ return [];
4571
+ }
4572
+ const family = sidecarPath === undefined ? undefined : reviewFamily(sidecarPath);
4573
+ return ids.map(readReview).filter((review) => review !== undefined).filter((review) => family === undefined || review.record.family === family).sort((left, right) => right.record.createdAt.localeCompare(left.record.createdAt) || right.record.id.localeCompare(left.record.id));
4574
+ }
4575
+ function findReview(idOrPrefix) {
4576
+ const exact = readReview(idOrPrefix);
4577
+ if (exact)
4578
+ return exact;
4579
+ const matches = listReviews().filter((review) => review.record.id.startsWith(idOrPrefix));
4580
+ if (matches.length === 1)
4581
+ return matches[0];
4582
+ if (matches.length > 1)
4583
+ throw new SidecarError(`review id ${JSON.stringify(idOrPrefix)} is ambiguous; use more of it`);
4584
+ throw new SidecarError(`no review ${JSON.stringify(idOrPrefix)} on this machine`);
4585
+ }
4586
+ function isOpen(review) {
4587
+ return review.state !== "published" && review.state !== "superseded";
4588
+ }
4589
+ function assertOpen(review) {
4590
+ if (!isOpen(review))
4591
+ throw new SidecarError(`review ${review.record.id} is already ${review.state}`);
4592
+ }
4593
+ function openReview(sidecarPath) {
4594
+ return listReviews(sidecarPath).find(isOpen);
4595
+ }
4596
+ function reviewState(record, verdict, run) {
4597
+ if (record.publishedAt)
4598
+ return "published";
4599
+ if (record.supersededAt)
4600
+ return "superseded";
4601
+ if (verdict)
4602
+ return verdict.verdict;
4603
+ if (run?.finishedAt && run.exitCode !== 0)
4604
+ return "failed";
4605
+ if (run && !run.finishedAt && !pidAlive(run.pid))
4606
+ return "failed";
4607
+ if (!run && record.launchedAt && (Date.now() - Date.parse(record.launchedAt)) / 1000 > LAUNCH_GRACE_SECONDS)
4608
+ return "failed";
4609
+ return "pending";
4610
+ }
4611
+ function pidAlive(pid) {
4612
+ if (!Number.isInteger(pid) || pid <= 0)
4613
+ return false;
4614
+ try {
4615
+ process.kill(pid, 0);
4616
+ return true;
4617
+ } catch (error) {
4618
+ return error.code === "EPERM";
4619
+ }
4620
+ }
4621
+ function reviewAttention(review, now = Date.now()) {
4622
+ if (review.state === "rejected")
4623
+ return "rejected";
4624
+ if (review.state === "failed") {
4625
+ const run = review.run;
4626
+ if (!run)
4627
+ return "reviewer never started";
4628
+ if (run.finishedAt) {
4629
+ return run.signal ? `reviewer stopped by ${run.signal} without a verdict` : `reviewer exited ${run.exitCode} without a verdict`;
4630
+ }
4631
+ return "reviewer stopped without a verdict";
4632
+ }
4633
+ if (review.state !== "pending")
4634
+ return;
4635
+ if (!review.record.launchedAt && !readSettings().reviewer)
4636
+ return "no reviewer is configured on this machine";
4637
+ const age = (now - Date.parse(review.record.createdAt)) / 1000;
4638
+ if (age > review.record.timeoutSeconds)
4639
+ return `undecided for over ${formatDuration(review.record.timeoutSeconds)}`;
4640
+ return;
4641
+ }
4642
+ function formatDuration(seconds) {
4643
+ if (seconds >= 3600 && seconds % 3600 === 0)
4644
+ return `${seconds / 3600}h`;
4645
+ if (seconds >= 60 && seconds % 60 === 0)
4646
+ return `${seconds / 60}m`;
4647
+ return `${Math.round(seconds)}s`;
4648
+ }
4649
+ function peerReviewGuidelinesFileName(name) {
4650
+ return name === DEFAULT_PEER ? ".sidecar-review" : `.sidecar-review.${name}`;
4651
+ }
4652
+ function peerReviewGuidelinesPath(root, name) {
4653
+ return path8.join(root, peerReviewGuidelinesFileName(name));
4654
+ }
4655
+ function readGuidelines(root, peer) {
4656
+ try {
4657
+ const text = fs8.readFileSync(peerReviewGuidelinesPath(root, peer), "utf8").trim();
4658
+ return text || undefined;
4659
+ } catch (error) {
4660
+ if (error.code === "ENOENT")
4661
+ return;
4662
+ throw new SidecarError(`could not read ${peerReviewGuidelinesPath(root, peer)}: ${error instanceof Error ? error.message : String(error)}`);
4663
+ }
4664
+ }
4665
+ function createReview(options) {
4666
+ const { root, config, sidecarPath, base, head } = options;
4667
+ const guidelines = readGuidelines(root, config.peer);
4668
+ const id = newId();
4669
+ const dir = reviewDir(id);
4670
+ fs8.mkdirSync(reviewsDir(), { recursive: true, mode: 448 });
4671
+ fs8.mkdirSync(dir, { mode: 448 });
4672
+ const fields = git(sidecarPath, ["diff-tree", ...PACKET_DIFF_OPTIONS, "--name-status", "-z", base, head]).stdout.split("\x00").filter(Boolean);
4673
+ const files = [];
4674
+ for (let index = 0;index + 1 < fields.length; index += 2)
4675
+ files.push({ status: fields[index], path: fields[index + 1] });
4676
+ writeOpaqueArtifacts(sidecarPath, dir, base, head, files);
4677
+ const diff = git(sidecarPath, ["diff-tree", ...PACKET_DIFF_OPTIONS, "-p", "--no-color", "--no-ext-diff", "--no-textconv", base, head]).stdout;
4678
+ const record = {
4679
+ schema: REVIEW_SCHEMA,
4680
+ id,
4681
+ family: reviewFamily(sidecarPath),
4682
+ root,
4683
+ peer: config.peer,
4684
+ base,
4685
+ head,
4686
+ tree: treeOf(sidecarPath, head),
4687
+ files,
4688
+ createdAt: nowIso(),
4689
+ timeoutSeconds: reviewTimeoutSeconds(config)
4690
+ };
4691
+ writePrivateFile(path8.join(dir, "diff.patch"), diff);
4692
+ if (guidelines)
4693
+ writePrivateFile(path8.join(dir, "guidelines.md"), `${guidelines}
4694
+ `);
4695
+ writePrivateFile(path8.join(dir, "instructions.md"), reviewInstructions(record, config, guidelines));
4696
+ writePrivateJson(path8.join(dir, "review.json"), record);
4697
+ logSidecarEvent("review-open", { id, root, peer: config.peer, files: files.length });
4698
+ pruneClosedReviews(sidecarPath);
4699
+ return { record, state: "pending", dir };
4700
+ }
4701
+ function writeOpaqueArtifacts(repo, dir, from, head, files) {
4702
+ const binary = new Set(git(repo, ["diff-tree", ...PACKET_DIFF_OPTIONS, "--numstat", "-z", from, head]).stdout.split("\x00").filter((entry) => entry.startsWith("-\t-\t")).map((entry) => entry.slice("-\t-\t".length)));
4703
+ const blobs = new Set(git(repo, ["ls-tree", "-r", "-z", "--full-tree", head]).stdout.split("\x00").filter((entry) => entry.split(" ")[1] === "blob").map((entry) => entry.slice(entry.indexOf("\t") + 1)));
4704
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
4705
+ const isText = (blob) => {
4706
+ try {
4707
+ utf8.decode(blob);
4708
+ return true;
4709
+ } catch {
4710
+ return false;
4711
+ }
4712
+ };
4713
+ let count = 0;
4714
+ for (const file of files) {
4715
+ if (file.status === "D" || !blobs.has(file.path))
4716
+ continue;
4717
+ const blob = gitBytes(repo, ["cat-file", "blob", `${head}:${file.path}`]).stdout;
4718
+ if (!binary.has(file.path) && isText(blob))
4719
+ continue;
4720
+ count += 1;
4721
+ file.artifact = path8.join("opaque", String(count).padStart(4, "0"));
4722
+ fs8.mkdirSync(path8.join(dir, "opaque"), { recursive: true, mode: 448 });
4723
+ fs8.writeFileSync(path8.join(dir, file.artifact), blob, { mode: 384 });
4724
+ }
4725
+ }
4726
+ function updateRecord(review, change) {
4727
+ const record = { ...review.record, ...change };
4728
+ writePrivateJson(path8.join(review.dir, "review.json"), record);
4729
+ review.record = record;
4730
+ }
4731
+ function markPublishing(review, inbox, commit) {
4732
+ updateRecord(review, { publishing: { inbox, commit } });
4733
+ }
4734
+ function markPublished(review, commit) {
4735
+ updateRecord(review, { publishedAt: nowIso(), publishedCommit: commit });
4736
+ logSidecarEvent("review-published", { id: review.record.id, commit });
4737
+ }
4738
+ function markSuperseded(review) {
4739
+ updateRecord(review, { supersededAt: nowIso() });
4740
+ logSidecarEvent("review-superseded", { id: review.record.id });
4741
+ }
4742
+ function pruneClosedReviews(sidecarPath) {
4743
+ const closed = listReviews(sidecarPath).filter((review) => !isOpen(review));
4744
+ for (const review of closed.slice(KEEP_CLOSED_REVIEWS)) {
4745
+ fs8.rmSync(review.dir, { recursive: true, force: true });
4746
+ }
4747
+ }
4748
+ function pruneOrphanedReviews() {
4749
+ for (const review of listReviews()) {
4750
+ if (!fs8.existsSync(review.record.family))
4751
+ fs8.rmSync(review.dir, { recursive: true, force: true });
4752
+ }
4753
+ }
4754
+ function opaqueSection(record) {
4755
+ const opaque = record.files.filter((file) => file.artifact);
4756
+ if (!opaque.length)
4757
+ return "";
4758
+ return `
4759
+ These files' content does not appear faithfully in \`diff.patch\` (binary, not
4760
+ UTF-8, or diffs turned off). Their complete new content, as it would be
4761
+ published, is in the packet.
4762
+ Inspect each one; if you cannot, do not approve:
4763
+
4764
+ ${opaque.map((file) => `- \`${file.artifact}\` — ${file.path}`).join(`
4765
+ `)}
4766
+ `;
4767
+ }
4768
+ function reviewInstructions(record, config, guidelines) {
4769
+ return `# Sidecar publish review ${record.id}
4770
+
4771
+ A sidecar is a scratchpad repository that syncs automatically to a shared Git
4772
+ remote (${redactText(config.remote)}). This machine wants to publish the change in
4773
+ \`diff.patch\`. Everyone with access to that remote can read what you approve,
4774
+ and it stays in the remote's history.
4775
+
4776
+ Decide whether anything in the change should not leave this machine. This is
4777
+ not a code review: ignore quality, style, and correctness.
4778
+
4779
+ Secret redaction has already run. Values it caught appear as placeholders such
4780
+ as \`<API_KEY>\`, \`<TOKEN>\`, or \`<SECRET>\`; they are not leaks. Look for what
4781
+ pattern matching misses:
4782
+
4783
+ - credentials in unusual formats, or ones split across lines
4784
+ - personal data about real people: contact details, health, finances, private conversations
4785
+ - confidential material: customer names and data, unreleased plans, internal-only details
4786
+ - anything the repository's guidelines below rule out
4787
+
4788
+ ## Files
4789
+
4790
+ All in this directory (\`$SIDECAR_REVIEW_DIR\`):
4791
+
4792
+ - \`diff.patch\` — the change exactly as it would be published
4793
+ - \`review.json\` — the repository, peer, and changed paths
4794
+ ${guidelines ? "- `guidelines.md` — this repository's guidelines, repeated below\n" : ""}${opaqueSection(record)}
4795
+ ## Guidelines
4796
+
4797
+ ${guidelines ?? "This repository has no review guidelines (`.sidecar-review` beside its `.sidecar`)."}
4798
+
4799
+ ## Decide
4800
+
4801
+ Approve, and the change is published on the next sync:
4802
+
4803
+ sidecar review approve ${record.id}
4804
+
4805
+ Reject, saying which file and what kind of content. Do not repeat the
4806
+ sensitive value itself:
4807
+
4808
+ sidecar review reject ${record.id} --reason "<file: what kind of content>"
4809
+
4810
+ If you are unsure, or a person should weigh in, do neither: leave the review
4811
+ open and raise it with them. Nothing is published until someone approves, and
4812
+ they decide with the same two commands.
4813
+ `;
4814
+ }
4815
+ function configuredReviewer() {
4816
+ const command = readSettings().reviewer;
4817
+ if (!command)
4818
+ throw new SidecarError("no reviewer is configured; set one with `sidecar review reviewer <command>`");
4819
+ return command;
4820
+ }
4821
+ function launchReviewer(review) {
4822
+ updateRecord(review, { launchedAt: nowIso() });
4823
+ const child = spawn2(process.execPath, [sidecarCliPath(), "review", "run", review.record.id], {
4824
+ cwd: review.dir,
4825
+ detached: true,
4826
+ stdio: "ignore",
4827
+ env: { ...process.env, [SKIP_LOCAL_EXEC_ENV]: "1" }
4828
+ });
4829
+ child.unref();
4830
+ logSidecarEvent("review-launch", { id: review.record.id });
4831
+ }
4832
+ function runReviewer(idOrPrefix) {
4833
+ const review = findReview(idOrPrefix);
4834
+ const { id } = review.record;
4835
+ assertOpen(review);
4836
+ if (review.run)
4837
+ throw new SidecarError(`review ${id}'s reviewer already ran; \`sidecar review retry ${id}\` opens a new review of the same change`);
4838
+ const command = configuredReviewer();
4839
+ const file = runPath(review.dir);
4840
+ const run = { pid: process.pid, command, startedAt: nowIso() };
4841
+ writePrivateJson(file, run);
4842
+ const log = fs8.openSync(path8.join(review.dir, "reviewer.log"), "a", 384);
4843
+ let result;
4844
+ try {
4845
+ result = spawnSync4("/bin/sh", ["-c", command], {
4846
+ cwd: review.dir,
4847
+ stdio: ["ignore", log, log],
4848
+ env: reviewerEnv(review)
4849
+ });
4850
+ } finally {
4851
+ fs8.closeSync(log);
4852
+ }
4853
+ const finished = { ...run, finishedAt: nowIso(), exitCode: result.status, signal: result.signal };
4854
+ writePrivateJson(file, finished);
4855
+ logSidecarEvent("review-run", { id, exitCode: result.status, signal: result.signal });
4856
+ return 0;
4857
+ }
4858
+ function reviewerEnv(review) {
4859
+ const env = { ...process.env };
4860
+ for (const name of [PEER_ENV, SOFT_SYNC_ENV, LOCAL_SYNC_ENV, SKIP_LOCAL_EXEC_ENV, GLOBAL_EXEC_ENV])
4861
+ delete env[name];
4862
+ const { record } = review;
4863
+ return {
4864
+ ...env,
4865
+ SIDECAR_REVIEW_ID: record.id,
4866
+ SIDECAR_REVIEW_DIR: review.dir,
4867
+ SIDECAR_REVIEW_PROMPT: path8.join(review.dir, "instructions.md"),
4868
+ SIDECAR_REVIEW_DIFF: path8.join(review.dir, "diff.patch"),
4869
+ SIDECAR_REVIEW_ROOT: record.root,
4870
+ SIDECAR_REVIEW_PEER: record.peer,
4871
+ SIDECAR_REVIEW_CONFIG: peerConfigPath(record.root, record.peer)
4872
+ };
4410
4873
  }
4411
- var SKIP_SERVICE_ENV = "SIDECAR_SKIP_SERVICE", DAEMON_LABEL = "com.anteprojector.sidecar";
4412
- var init_service = __esm(() => {
4874
+ function decideReview(review, verdict, reason) {
4875
+ assertOpen(review);
4876
+ if (review.record.publishing && verdict === "rejected") {
4877
+ throw new SidecarError(`review ${review.record.id} is already being published; it can no longer be rejected`);
4878
+ }
4879
+ const record = { verdict, ...reason ? { reason } : {}, decidedAt: nowIso() };
4880
+ writePrivateJson(path8.join(review.dir, "verdict.json"), record);
4881
+ review.verdict = record;
4882
+ review.state = verdict;
4883
+ logSidecarEvent("review-decision", { id: review.record.id, verdict });
4884
+ }
4885
+ var REVIEW_SCHEMA = 1, KEEP_CLOSED_REVIEWS = 10, ID_PATTERN, LAUNCH_GRACE_SECONDS = 60, PACKET_DIFF_OPTIONS;
4886
+ var init_review = __esm(() => {
4413
4887
  init_util();
4888
+ init_git();
4414
4889
  init_install();
4890
+ init_config();
4415
4891
  init_state();
4892
+ init_redaction();
4893
+ ID_PATTERN = /^[0-9a-f]+$/;
4894
+ PACKET_DIFF_OPTIONS = ["-r", "--no-renames", "--ignore-submodules=none"];
4416
4895
  });
4417
4896
 
4418
4897
  // src/sync.ts
4419
- import crypto4 from "node:crypto";
4420
- import fs8 from "node:fs";
4898
+ import crypto5 from "node:crypto";
4899
+ import fs9 from "node:fs";
4421
4900
  import os5 from "node:os";
4422
- import path8 from "node:path";
4423
- import { fileURLToPath as fileURLToPath2 } from "node:url";
4901
+ import path9 from "node:path";
4424
4902
  function syncProject(root, config, options) {
4425
4903
  const stage = (name) => options.onStage?.(name);
4426
4904
  stage("checkout");
@@ -4428,6 +4906,7 @@ function syncProject(root, config, options) {
4428
4906
  if (!options.remote && !hasAnyCommit(sidecarPath)) {
4429
4907
  throw new SidecarError(`local sync requires an initialized sidecar checkout at ${sidecarPath}; run \`sidecar sync\` first`);
4430
4908
  }
4909
+ bindReviewPolicy(sidecarPath, config);
4431
4910
  const inbox = expandInbox(config, sidecarPath);
4432
4911
  ensureCommitIdentity(sidecarPath);
4433
4912
  ensureRedactionFilter(sidecarPath, config.redaction, config);
@@ -4444,7 +4923,9 @@ function syncProject(root, config, options) {
4444
4923
  settleCheckouts(sidecarPath, config, inbox, siblings);
4445
4924
  }
4446
4925
  if (!options.remote)
4447
- return;
4926
+ return {};
4927
+ if (reviewRequired(config))
4928
+ return publishReviewed(root, sidecarPath, config, inbox, siblings, stage);
4448
4929
  stage("push-inbox");
4449
4930
  syncBranchBeforePush(sidecarPath, inbox, config);
4450
4931
  pushBranch(sidecarPath, inbox);
@@ -4452,6 +4933,288 @@ function syncProject(root, config, options) {
4452
4933
  mergeInboxBranches(sidecarPath, config, { forkFiles: true, push: true, remote: true });
4453
4934
  stage("settle-remote");
4454
4935
  settleCheckouts(sidecarPath, config, inbox, siblings);
4936
+ return {};
4937
+ }
4938
+ function withPublicGit(repo, fn) {
4939
+ assertRedactionFilterIntact(repo);
4940
+ const { name, email } = sidecarIdentity();
4941
+ const overrides = [
4942
+ ["core.hooksPath", os5.devNull],
4943
+ ["core.attributesFile", os5.devNull],
4944
+ ["core.autocrlf", "false"],
4945
+ ["commit.gpgSign", "false"],
4946
+ ["tag.gpgSign", "false"],
4947
+ ["i18n.commitEncoding", "UTF-8"],
4948
+ ["rerere.enabled", "false"],
4949
+ ["rerere.autoUpdate", "false"],
4950
+ ["merge.renormalize", "false"],
4951
+ ["merge.default", "text"],
4952
+ ["push.pushOption", ""],
4953
+ ["push.followTags", "false"],
4954
+ ...neutralizedDrivers(repo)
4955
+ ];
4956
+ const env = {
4957
+ GIT_AUTHOR_NAME: name,
4958
+ GIT_AUTHOR_EMAIL: email,
4959
+ GIT_COMMITTER_NAME: name,
4960
+ GIT_COMMITTER_EMAIL: email,
4961
+ GIT_NO_REPLACE_OBJECTS: "1",
4962
+ GIT_ATTR_SOURCE: emptyTree(repo),
4963
+ GIT_CONFIG_PARAMETERS: undefined,
4964
+ GIT_CONFIG_COUNT: String(overrides.length)
4965
+ };
4966
+ overrides.forEach(([key, value], index) => {
4967
+ env[`GIT_CONFIG_KEY_${index}`] = key;
4968
+ env[`GIT_CONFIG_VALUE_${index}`] = value;
4969
+ });
4970
+ const saved = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]));
4971
+ const apply = (values) => {
4972
+ for (const [key, value] of Object.entries(values)) {
4973
+ if (value === undefined)
4974
+ delete process.env[key];
4975
+ else
4976
+ process.env[key] = value;
4977
+ }
4978
+ };
4979
+ apply(env);
4980
+ try {
4981
+ return fn();
4982
+ } finally {
4983
+ apply(saved);
4984
+ }
4985
+ }
4986
+ function neutralizedDrivers(repo) {
4987
+ const keys = git(repo, ["config", "--name-only", "--get-regexp", "^(merge|filter)\\..+\\.(driver|clean|smudge|process)$"], { check: false }).stdout.split(`
4988
+ `).filter(Boolean);
4989
+ return keys.flatMap((key) => {
4990
+ const [section, ...rest] = key.split(".");
4991
+ const driver = rest.slice(0, -1).join(".");
4992
+ if (section === "merge")
4993
+ return [[`merge.${driver}.driver`, "git merge-file --marker-size=%L %A %O %B"]];
4994
+ if (driver === REDACTION_FILTER_NAME)
4995
+ return [];
4996
+ return [[`filter.${driver}.clean`, "cat"], [`filter.${driver}.smudge`, "cat"], [`filter.${driver}.process`, ""], [`filter.${driver}.required`, "false"]];
4997
+ });
4998
+ }
4999
+ function assertRedactionFilterIntact(repo) {
5000
+ if (git(repo, ["config", "--get-all", `filter.${REDACTION_FILTER_NAME}.process`], { check: false }).status === 0) {
5001
+ throw new SidecarError(`filter.${REDACTION_FILTER_NAME}.process is configured; remove it — a review-gated peer publishes only through sidecar's own redaction filter`);
5002
+ }
5003
+ }
5004
+ function assertPublishDestination(repo) {
5005
+ const pushUrl = git(repo, ["config", "--get-all", "remote.origin.pushurl"], { check: false }).stdout.trim();
5006
+ if (!pushUrl)
5007
+ return;
5008
+ const url = git(repo, ["config", "--get", "remote.origin.url"], { check: false }).stdout.trim();
5009
+ if (pushUrl !== url) {
5010
+ throw new SidecarError("remote.origin.pushurl sends this sidecar's pushes somewhere other than its remote; a review-gated peer publishes only to its remote — unset it");
5011
+ }
5012
+ }
5013
+ function fetchPublic(repo) {
5014
+ git(repo, ["fetch", "--quiet", "--prune", "--no-tags", "origin", "+refs/heads/*:refs/remotes/origin/*", `+refs/heads/*:${PUBLIC_REFS}*`]);
5015
+ }
5016
+ function publicRef(branch) {
5017
+ return `${PUBLIC_REFS}${branch}`;
5018
+ }
5019
+ function refreshPublicRefs(repo) {
5020
+ withPublicGit(repo, () => fetchPublic(repo));
5021
+ }
5022
+ function publicTip(repo, branch) {
5023
+ return git(repo, ["rev-parse", "--verify", "--quiet", publicRef(branch)], { check: false }).stdout.trim() || undefined;
5024
+ }
5025
+ function isPublic(repo, commit) {
5026
+ return Boolean(git(repo, ["for-each-ref", "--contains", commit, "--format=%(refname)", PUBLIC_REFS]).stdout.trim());
5027
+ }
5028
+ function publishReviewed(root, sidecarPath, config, inbox, siblings, stage) {
5029
+ stage("fetch");
5030
+ withPublicGit(sidecarPath, () => {
5031
+ fetchPublic(sidecarPath);
5032
+ if (!publicTip(sidecarPath, config.branch))
5033
+ bootstrapReviewedMain(sidecarPath, config);
5034
+ stage("publish");
5035
+ const open = openReview(sidecarPath);
5036
+ if (open?.state === "approved")
5037
+ publishApprovedReview(sidecarPath, config, inbox, open);
5038
+ });
5039
+ stage("merge");
5040
+ withPublicGit(sidecarPath, () => mergeFetchedInboxes(sidecarPath, config, { forkFiles: true }));
5041
+ stage("merge-local");
5042
+ mergeInboxBranches(sidecarPath, config, { forkFiles: true, push: false, remote: false });
5043
+ stage("settle-remote");
5044
+ settleCheckouts(sidecarPath, config, inbox, siblings);
5045
+ stage("review");
5046
+ const gate = withPublicGit(sidecarPath, () => gateUnpublished(root, sidecarPath, config));
5047
+ if (gate?.launch)
5048
+ launchReviewer(gate.review);
5049
+ return gate ? { held: { review: gate.review, attention: reviewAttention(gate.review) } } : {};
5050
+ }
5051
+ function gateUnpublished(root, sidecarPath, config) {
5052
+ const head = git(sidecarPath, ["rev-parse", `refs/heads/${config.branch}`]).stdout.trim();
5053
+ const headTree = treeOf(sidecarPath, head);
5054
+ const publicMain = publicTip(sidecarPath, config.branch);
5055
+ const base = publicMain && git(sidecarPath, ["merge-base", head, publicMain], { check: false }).stdout.trim();
5056
+ if (!base)
5057
+ throw new SidecarError(`local ${config.branch} shares no history with the remote's; nothing is reviewed or published until it does`);
5058
+ const open = openReview(sidecarPath);
5059
+ const hasReviewer = Boolean(readSettings().reviewer);
5060
+ if (treeOf(sidecarPath, base) === headTree) {
5061
+ if (open && open.state !== "approved")
5062
+ markSuperseded(open);
5063
+ return;
5064
+ }
5065
+ if (open?.state === "pending" || open?.state === "approved") {
5066
+ return { review: open, launch: open.state === "pending" && !open.record.launchedAt && hasReviewer };
5067
+ }
5068
+ if (open) {
5069
+ if (open.record.tree === headTree)
5070
+ return { review: open, launch: false };
5071
+ markSuperseded(open);
5072
+ }
5073
+ const review = createReview({ root, config, sidecarPath, base, head });
5074
+ console.log(`opened review ${paint("brand", review.record.id)} for ${review.record.files.length} changed file(s)`);
5075
+ return { review, launch: hasReviewer };
5076
+ }
5077
+ function publishApprovedReview(sidecarPath, config, inbox, review) {
5078
+ const { record } = review;
5079
+ const target = record.publishing ?? { inbox, commit: cutPublishCommit(sidecarPath, config, inbox, record) };
5080
+ const protectRef = publishingRef(record.id);
5081
+ if (!isPublic(sidecarPath, target.commit)) {
5082
+ const parents = git(sidecarPath, ["rev-list", "--parents", "-n", "1", target.commit]).stdout.trim().split(" ").slice(1);
5083
+ if (!parents.every((parent) => isPublic(sidecarPath, parent))) {
5084
+ markSuperseded(review);
5085
+ git(sidecarPath, ["update-ref", "-d", protectRef], { check: false });
5086
+ console.log(`review ${paint("brand", record.id)} was measured from a commit the remote does not have; reviewing again`);
5087
+ return;
5088
+ }
5089
+ assertPublishDestination(sidecarPath);
5090
+ if (!record.publishing) {
5091
+ git(sidecarPath, ["update-ref", protectRef, target.commit]);
5092
+ markPublishing(review, target.inbox, target.commit);
5093
+ }
5094
+ const push = git(sidecarPath, ["push", "--no-follow-tags", "origin", `${target.commit}:refs/heads/${target.inbox}`], { check: false });
5095
+ if (push.status !== 0) {
5096
+ throw new SidecarError(push.stderr.trim() || `could not publish review ${record.id} to ${target.inbox}`);
5097
+ }
5098
+ fetchPublic(sidecarPath);
5099
+ console.log(`published review ${paint("brand", record.id)} to ${paint("brand", target.inbox)}`);
5100
+ }
5101
+ linkPublished(sidecarPath, config, review, target.commit);
5102
+ markPublished(review, target.commit);
5103
+ git(sidecarPath, ["update-ref", "-d", protectRef], { check: false });
5104
+ }
5105
+ function publishingRef(id) {
5106
+ return `refs/sidecar-publishing/${id}`;
5107
+ }
5108
+ function retryReview(review) {
5109
+ const { record } = review;
5110
+ if (!isOpen(review))
5111
+ throw new SidecarError(`review ${record.id} is already ${review.state}`);
5112
+ if (record.publishing)
5113
+ throw new SidecarError(`review ${record.id} is already being published`);
5114
+ if (review.state === "approved")
5115
+ throw new SidecarError(`review ${record.id} is approved and publishes on the next sync`);
5116
+ const config = loadPeer(record.root, record.peer).config;
5117
+ const sidecarPath = requireSidecarCheckout(record.root, config);
5118
+ markSuperseded(review);
5119
+ const fresh = withPublicGit(sidecarPath, () => createReview({ root: record.root, config, sidecarPath, base: record.base, head: record.head }));
5120
+ launchReviewer(fresh);
5121
+ return fresh;
5122
+ }
5123
+ function cutPublishCommit(sidecarPath, config, inbox, record) {
5124
+ const previous = publicTip(sidecarPath, inbox);
5125
+ const parents = [record.base, ...previous && !isAncestor(sidecarPath, previous, record.base) ? [previous] : []];
5126
+ const body = [
5127
+ "sidecar publish",
5128
+ "",
5129
+ `source: ${currentMachine()}`,
5130
+ `inbox: ${inbox}`,
5131
+ `review: ${record.id}`,
5132
+ ...publishedWriteTrailers(sidecarPath, config, record)
5133
+ ];
5134
+ return git(sidecarPath, [
5135
+ "commit-tree",
5136
+ record.tree,
5137
+ ...parents.flatMap((parent) => ["-p", parent]),
5138
+ "-m",
5139
+ body.join(`
5140
+ `)
5141
+ ]).stdout.trim();
5142
+ }
5143
+ function publishedWriteTrailers(repo, config, record) {
5144
+ const defaults = { resolve: config.resolve, redaction: config.redaction };
5145
+ const paths = record.files.map((file) => file.path).filter((filePath) => resolveFileRules(config.rules, filePath, defaults).resolve === "lww");
5146
+ if (paths.length > WRITTEN_TRAILER_LIMIT)
5147
+ return [];
5148
+ const now = Math.floor(Date.now() / 1000);
5149
+ return paths.map((filePath) => {
5150
+ const { time } = lastWriteEvent(repo, record.head, filePath);
5151
+ return lwwWrittenTrailer(filePath, { time: Math.min(Math.max(0, time), now), source: `review-${record.id}` });
5152
+ });
5153
+ }
5154
+ function linkPublished(sidecarPath, config, review, published) {
5155
+ const main = `refs/heads/${config.branch}`;
5156
+ if (isAncestor(sidecarPath, published, main))
5157
+ return;
5158
+ if (!isAncestor(sidecarPath, review.record.head, main)) {
5159
+ logSidecarEvent("review-link-skip", { sidecarPath, id: review.record.id });
5160
+ return;
5161
+ }
5162
+ withScratchWorktree(sidecarPath, config, "HEAD", (worktree) => {
5163
+ git(worktree, ["switch", config.branch]);
5164
+ git(worktree, ["merge", "--no-ff", "-s", "ours", "-m", `sidecar: link published review ${review.record.id}`, published]);
5165
+ });
5166
+ }
5167
+ function mergePublishedInboxes(sidecarPath, config, options) {
5168
+ return withPublicGit(sidecarPath, () => {
5169
+ fetchPublic(sidecarPath);
5170
+ return mergeFetchedInboxes(sidecarPath, config, options);
5171
+ });
5172
+ }
5173
+ function mergeFetchedInboxes(sidecarPath, config, options) {
5174
+ if (!publicTip(sidecarPath, config.branch))
5175
+ return 0;
5176
+ const publicMain = publicRef(config.branch);
5177
+ if (publishedInboxBranches(sidecarPath, config).every((ref) => isAncestor(sidecarPath, ref, publicMain))) {
5178
+ console.log("no published inbox branches to merge");
5179
+ return 0;
5180
+ }
5181
+ assertPublishDestination(sidecarPath);
5182
+ return withScratchWorktree(sidecarPath, config, publicMain, (worktree) => {
5183
+ assertRedactionFilterIntact(worktree);
5184
+ const maxAttempts = 3;
5185
+ for (let attempt = 1;; attempt += 1) {
5186
+ if (attempt > 1) {
5187
+ fetchPublic(sidecarPath);
5188
+ git(worktree, ["reset", "--hard", publicMain]);
5189
+ }
5190
+ const pending = publishedInboxBranches(worktree, config).filter((ref) => !isAncestor(worktree, ref, "HEAD"));
5191
+ if (!pending.length) {
5192
+ console.log("no published inbox branches to merge");
5193
+ return 0;
5194
+ }
5195
+ const merged = [];
5196
+ for (const ref of pending) {
5197
+ console.log(`merging ${paint("brand", remoteBranchName(ref))}`);
5198
+ if (mergeInboxBranch(worktree, config, ref, options))
5199
+ merged.push(ref);
5200
+ }
5201
+ const push = git(worktree, ["push", "--no-follow-tags", "origin", `HEAD:refs/heads/${config.branch}`], { check: false });
5202
+ if (push.status !== 0) {
5203
+ if (attempt >= maxAttempts)
5204
+ throw new SidecarError(push.stderr.trim() || `could not push ${config.branch}`);
5205
+ console.log(`push of ${config.branch} was rejected; refetching and retrying`);
5206
+ continue;
5207
+ }
5208
+ fetchPublic(sidecarPath);
5209
+ console.log(`pushed ${config.branch}`);
5210
+ console.log(`merged ${merged.length} inbox branch(es)`);
5211
+ return merged.length;
5212
+ }
5213
+ });
5214
+ }
5215
+ function publishedInboxBranches(repo, config) {
5216
+ const prefix = inboxPrefix(config);
5217
+ return git(repo, ["for-each-ref", "--format=%(refname)", PUBLIC_REFS]).stdout.split(/\r?\n/).map((ref) => ref.trim()).filter((ref) => ref && matchesInboxPrefix(prefix, ref.slice(PUBLIC_REFS.length))).sort();
4455
5218
  }
4456
5219
  function settleCheckouts(sidecarPath, config, inbox, siblings) {
4457
5220
  refreshInboxFromMain(sidecarPath, config, inbox);
@@ -4465,7 +5228,7 @@ function settleCheckouts(sidecarPath, config, inbox, siblings) {
4465
5228
  } catch (error) {
4466
5229
  logSidecarEvent("settle-skip", {
4467
5230
  sidecarPath: sibling,
4468
- message: error instanceof Error ? error.message : String(error)
5231
+ ...reviewRequired(config) ? {} : { message: error instanceof Error ? error.message : String(error) }
4469
5232
  });
4470
5233
  }
4471
5234
  }
@@ -4500,33 +5263,44 @@ function reportSyncHealth(root, config, outcome) {
4500
5263
  const sidecarPath = resolveSidecarPath(root, config);
4501
5264
  if (!hasGitMetadata(sidecarPath))
4502
5265
  return;
4503
- const branch = healthBranchFor(sidecarPath);
4504
- const previous = readHealthRecordAt(sidecarPath, `origin/${branch}`);
4505
- const identity = {
4506
- machine: `${currentUser()}@${currentHost()}`,
4507
- root,
4508
- peer: config.peer === DEFAULT_PEER ? undefined : config.peer,
4509
- inbox: expandInbox(config, sidecarPath),
4510
- version: packageVersion()
4511
- };
4512
- const record = nextHealthRecord(previous, identity, outcome, nowIso());
4513
- if (!shouldPublishHealth(previous, record))
4514
- return;
4515
- publishHealthRecord(sidecarPath, branch, record);
4516
- logSidecarEvent("health", {
4517
- branch,
4518
- status: record.status,
4519
- stage: record.stage,
4520
- consecutiveFailures: record.consecutiveFailures
4521
- });
5266
+ withPublicGit(sidecarPath, () => publishHealth(root, config, sidecarPath, outcome));
4522
5267
  } catch (error) {
4523
5268
  logSidecarEvent("failure", {
4524
5269
  command: "health",
4525
5270
  root,
4526
- message: `could not publish health: ${error instanceof Error ? error.message : String(error)}`
5271
+ message: reviewRequired(config) ? "could not publish health" : `could not publish health: ${error instanceof Error ? error.message : String(error)}`
4527
5272
  });
4528
5273
  }
4529
5274
  }
5275
+ function publishHealth(root, config, sidecarPath, outcome) {
5276
+ const branch = healthBranchFor(sidecarPath);
5277
+ const gated = reviewRequired(config);
5278
+ if (gated && outcome.status === "failed") {
5279
+ try {
5280
+ fetchPublic(sidecarPath);
5281
+ } catch {}
5282
+ }
5283
+ const previous = readHealthRecordAt(sidecarPath, gated ? publicRef(branch) : `origin/${branch}`);
5284
+ const identity = {
5285
+ machine: currentMachine(),
5286
+ root,
5287
+ peer: config.peer === DEFAULT_PEER ? undefined : config.peer,
5288
+ inbox: expandInbox(config, sidecarPath),
5289
+ version: packageVersion()
5290
+ };
5291
+ const record = nextHealthRecord(previous, identity, outcome, nowIso());
5292
+ if (!shouldPublishHealth(previous, record))
5293
+ return;
5294
+ if (gated)
5295
+ assertPublishDestination(sidecarPath);
5296
+ publishHealthRecord(sidecarPath, branch, record);
5297
+ logSidecarEvent("health", {
5298
+ branch,
5299
+ status: record.status,
5300
+ stage: record.stage,
5301
+ consecutiveFailures: record.consecutiveFailures
5302
+ });
5303
+ }
4530
5304
  function publishHealthRecord(sidecarPath, branch, record) {
4531
5305
  const blob = git(sidecarPath, ["hash-object", "-w", "--stdin"], {
4532
5306
  input: serializeHealthRecord(record)
@@ -4535,17 +5309,8 @@ function publishHealthRecord(sidecarPath, branch, record) {
4535
5309
  input: `100644 blob ${blob} ${HEALTH_FILE}
4536
5310
  `
4537
5311
  }).stdout.trim();
4538
- const commit = git(sidecarPath, [
4539
- "-c",
4540
- `user.name=${currentUser()}`,
4541
- "-c",
4542
- `user.email=${slug(currentUser())}@${slug(currentHost())}.local`,
4543
- "commit-tree",
4544
- tree,
4545
- "-m",
4546
- `health: ${record.status} — ${record.machine}`
4547
- ]).stdout.trim();
4548
- git(sidecarPath, ["push", "--force", "origin", `${commit}:refs/heads/${branch}`]);
5312
+ const commit = git(sidecarPath, ["commit-tree", tree, "-m", `health: ${record.status} — ${record.machine}`]).stdout.trim();
5313
+ git(sidecarPath, ["push", "--no-follow-tags", "--force", "origin", `${commit}:refs/heads/${branch}`]);
4549
5314
  }
4550
5315
  function readHealthRecordAt(sidecarPath, ref) {
4551
5316
  const result = git(sidecarPath, ["show", `${ref}:${HEALTH_FILE}`], { check: false });
@@ -4572,9 +5337,9 @@ function mergeInboxBranches(sidecarPath, config, options) {
4572
5337
  ensureCommitIdentity(sidecarPath);
4573
5338
  if (options.remote)
4574
5339
  fetch(sidecarPath, false);
4575
- if (!hasPendingInboxWork(sidecarPath, config)) {
5340
+ if (!hasPendingInboxWork(sidecarPath, config) && mainContainsRemote(sidecarPath, config)) {
4576
5341
  if (options.push && !mainMatchesRemote(sidecarPath, config)) {
4577
- const push = git(sidecarPath, ["push", "origin", `refs/heads/${config.branch}:refs/heads/${config.branch}`], {
5342
+ const push = git(sidecarPath, ["push", "--no-follow-tags", "origin", `refs/heads/${config.branch}:refs/heads/${config.branch}`], {
4578
5343
  check: false
4579
5344
  });
4580
5345
  if (push.status === 0) {
@@ -4593,18 +5358,21 @@ function mergeInboxBranches(sidecarPath, config, options) {
4593
5358
  if (git(sidecarPath, ["branch", "--show-current"]).stdout.trim() === config.branch) {
4594
5359
  ensureInboxBranch(sidecarPath, config, expandInbox(config, sidecarPath));
4595
5360
  }
4596
- const scratch = path8.join(os5.tmpdir(), `sidecar-merge-${crypto4.createHash("sha1").update(sidecarPath).digest("hex").slice(0, 12)}`);
4597
- const worktree = path8.join(scratch, "checkout");
5361
+ return withScratchWorktree(sidecarPath, config, "HEAD", (worktree) => mergeInboxBranchesAt(worktree, config, options));
5362
+ }
5363
+ function withScratchWorktree(sidecarPath, config, start, fn) {
5364
+ const scratch = path9.join(os5.tmpdir(), `sidecar-merge-${crypto5.createHash("sha1").update(sidecarPath).digest("hex").slice(0, 12)}`);
5365
+ const worktree = path9.join(scratch, "checkout");
4598
5366
  git(sidecarPath, ["worktree", "remove", "--force", worktree], { check: false });
4599
- fs8.rmSync(scratch, { recursive: true, force: true });
5367
+ fs9.rmSync(scratch, { recursive: true, force: true });
4600
5368
  git(sidecarPath, ["worktree", "prune", "--expire", "now"], { check: false });
4601
5369
  try {
4602
- git(sidecarPath, ["worktree", "add", "--detach", worktree]);
5370
+ git(sidecarPath, ["worktree", "add", "--detach", worktree, start]);
4603
5371
  ensureRedactionFilter(worktree, config.redaction, config);
4604
- return mergeInboxBranchesAt(worktree, config, options);
5372
+ return fn(worktree);
4605
5373
  } finally {
4606
5374
  git(sidecarPath, ["worktree", "remove", "--force", worktree], { check: false });
4607
- fs8.rmSync(scratch, { recursive: true, force: true });
5375
+ fs9.rmSync(scratch, { recursive: true, force: true });
4608
5376
  }
4609
5377
  }
4610
5378
  function mainMatchesRemote(repo, config) {
@@ -4615,6 +5383,11 @@ function mainMatchesRemote(repo, config) {
4615
5383
  const remote = refs.get(remoteRef);
4616
5384
  return local !== undefined && local === remote;
4617
5385
  }
5386
+ function mainContainsRemote(repo, config) {
5387
+ if (!remoteRefExists(repo, config.branch) || !branchExists(repo, config.branch))
5388
+ return true;
5389
+ return isAncestor(repo, `origin/${config.branch}`, `refs/heads/${config.branch}`);
5390
+ }
4618
5391
  function hasPendingInboxWork(repo, config) {
4619
5392
  const main = `refs/heads/${config.branch}`;
4620
5393
  return pendingInboxBranches(repo, config).some((branch) => !isAncestor(repo, branch, main));
@@ -4638,7 +5411,7 @@ function mergeInboxBranchesAt(sidecarPath, config, options) {
4638
5411
  merged.push(remoteBranch);
4639
5412
  }
4640
5413
  if (options.push) {
4641
- const push = git(sidecarPath, ["push", "-u", "origin", `HEAD:refs/heads/${config.branch}`], { check: false });
5414
+ const push = git(sidecarPath, ["push", "--no-follow-tags", "-u", "origin", `HEAD:refs/heads/${config.branch}`], { check: false });
4642
5415
  if (push.status !== 0) {
4643
5416
  if (attempt >= maxAttempts) {
4644
5417
  throw new SidecarError(push.stderr.trim() || `could not push ${config.branch}`);
@@ -4709,10 +5482,10 @@ function familySidecarCheckout(root, config) {
4709
5482
  } catch {
4710
5483
  return;
4711
5484
  }
4712
- if (primaryConfig.remote !== config.remote)
5485
+ if (!sharesFamilyCheckout(primaryConfig, config))
4713
5486
  return;
4714
5487
  const primaryPath = resolveSidecarPath(primary, primaryConfig);
4715
- if (path8.resolve(primaryPath) === path8.resolve(primary))
5488
+ if (path9.resolve(primaryPath) === path9.resolve(primary))
4716
5489
  return;
4717
5490
  if (!hasGitMetadata(primaryPath))
4718
5491
  cloneOrUpdate(primary, primaryConfig, true);
@@ -4732,7 +5505,7 @@ function checkoutIsUnlinkedFromFamily(root, config, sidecarPath) {
4732
5505
  if (isStandalone(config))
4733
5506
  return false;
4734
5507
  try {
4735
- if (!fs8.statSync(path8.join(sidecarPath, ".git")).isDirectory())
5508
+ if (!fs9.statSync(path9.join(sidecarPath, ".git")).isDirectory())
4736
5509
  return false;
4737
5510
  } catch {
4738
5511
  return false;
@@ -4741,21 +5514,34 @@ function checkoutIsUnlinkedFromFamily(root, config, sidecarPath) {
4741
5514
  if (!primary)
4742
5515
  return false;
4743
5516
  try {
4744
- const primaryConfig = readConfig(peerConfigPath(primary, config.peer));
4745
- return primaryConfig.remote === config.remote;
5517
+ return sharesFamilyCheckout(readConfig(peerConfigPath(primary, config.peer)), config);
4746
5518
  } catch {
4747
5519
  return false;
4748
5520
  }
4749
5521
  }
5522
+ function sharesFamilyCheckout(primaryConfig, config) {
5523
+ return primaryConfig.remote === config.remote && reviewRequired(primaryConfig) === reviewRequired(config);
5524
+ }
5525
+ function bindReviewPolicy(sidecarPath, config) {
5526
+ const bound = git(sidecarPath, ["config", "--local", "--get", REVIEW_POLICY_KEY], { check: false }).stdout.trim();
5527
+ if (reviewRequired(config)) {
5528
+ if (bound !== "required")
5529
+ git(sidecarPath, ["config", "--local", REVIEW_POLICY_KEY, "required"]);
5530
+ return;
5531
+ }
5532
+ if (bound === "required") {
5533
+ throw new SidecarError(`${sidecarPath} holds work from a peer that requires review, and this peer would publish it unreviewed; set review = "required" in this repo's ${peerFileName(config.peer)} too — or, to publish that work without review, \`git -C ${sidecarPath} config --unset ${REVIEW_POLICY_KEY}\``);
5534
+ }
5535
+ }
4750
5536
  function cloneOrUpdate(root, config, bootstrapMain, options) {
4751
5537
  const sidecarPath = resolveSidecarPath(root, config);
4752
- if (fs8.existsSync(sidecarPath) && !hasGitMetadata(sidecarPath)) {
4753
- if (fs8.readdirSync(sidecarPath).length) {
5538
+ if (fs9.existsSync(sidecarPath) && !hasGitMetadata(sidecarPath)) {
5539
+ if (fs9.readdirSync(sidecarPath).length) {
4754
5540
  throw new SidecarError(`${sidecarPath} exists and is not an empty Git repo`);
4755
5541
  }
4756
- fs8.rmdirSync(sidecarPath);
5542
+ fs9.rmdirSync(sidecarPath);
4757
5543
  }
4758
- if (!fs8.existsSync(sidecarPath)) {
5544
+ if (!fs9.existsSync(sidecarPath)) {
4759
5545
  const primaryPath = familySidecarCheckout(root, config);
4760
5546
  if (primaryPath)
4761
5547
  git(primaryPath, ["worktree", "add", "--detach", sidecarPath]);
@@ -4776,7 +5562,7 @@ function cloneOrUpdate(root, config, bootstrapMain, options) {
4776
5562
  throw new SidecarError(`${sidecarPath} is not usable as a sidecar checkout`);
4777
5563
  }
4778
5564
  if (options?.checkoutId) {
4779
- fs8.writeFileSync(path8.join(gitDir(sidecarPath), "sidecar-id"), `${options.checkoutId}
5565
+ fs9.writeFileSync(path9.join(gitDir(sidecarPath), "sidecar-id"), `${options.checkoutId}
4780
5566
  `, {
4781
5567
  encoding: "utf8",
4782
5568
  mode: 384
@@ -4784,6 +5570,7 @@ function cloneOrUpdate(root, config, bootstrapMain, options) {
4784
5570
  }
4785
5571
  ensureCommitIdentity(sidecarPath);
4786
5572
  ensureRedactionFilter(sidecarPath, config.redaction, config);
5573
+ bindReviewPolicy(sidecarPath, config);
4787
5574
  if (bootstrapMain)
4788
5575
  bootstrapMainBranch(sidecarPath, config);
4789
5576
  const inbox = expandInbox(config, sidecarPath);
@@ -4793,6 +5580,10 @@ function cloneOrUpdate(root, config, bootstrapMain, options) {
4793
5580
  function bootstrapMainBranch(repo, config) {
4794
5581
  if (remoteRefExists(repo, config.branch))
4795
5582
  return;
5583
+ if (reviewRequired(config)) {
5584
+ withPublicGit(repo, () => bootstrapReviewedMain(repo, config));
5585
+ return;
5586
+ }
4796
5587
  if (hasAnyCommit(repo)) {
4797
5588
  const current = git(repo, ["branch", "--show-current"]).stdout.trim();
4798
5589
  if (current !== config.branch) {
@@ -4811,7 +5602,7 @@ function bootstrapMainBranch(repo, config) {
4811
5602
  pushBranch(repo, config.branch);
4812
5603
  return;
4813
5604
  }
4814
- fs8.writeFileSync(path8.join(repo, "README.md"), `# Sidecar
5605
+ fs9.writeFileSync(path9.join(repo, "README.md"), `# Sidecar
4815
5606
 
4816
5607
  Scratch space for a code repository: plans, notes, and agent context.
4817
5608
  This is a plain git repo you own — read it, edit it, clone it anywhere.
@@ -4821,6 +5612,21 @@ Kept in sync by [sidecar](https://github.com/endograph/sidecar).
4821
5612
  git(repo, ["commit", "-m", "Initialize sidecar"]);
4822
5613
  pushBranch(repo, config.branch);
4823
5614
  }
5615
+ function bootstrapReviewedMain(repo, config) {
5616
+ assertPublishDestination(repo);
5617
+ const root = git(repo, ["commit-tree", emptyTree(repo), "-m", "Initialize sidecar"]).stdout.trim();
5618
+ git(repo, ["push", "--no-follow-tags", "origin", `${root}:refs/heads/${config.branch}`]);
5619
+ fetchPublic(repo);
5620
+ console.log(`started ${config.branch} on the remote empty; this checkout's content is published through review`);
5621
+ const ref = `refs/heads/${config.branch}`;
5622
+ if (!hasAnyCommit(repo)) {
5623
+ git(repo, ["update-ref", ref, root]);
5624
+ return;
5625
+ }
5626
+ const local = git(repo, ["rev-parse", "--verify", "--quiet", ref], { check: false }).stdout.trim() || git(repo, ["rev-parse", "HEAD"]).stdout.trim();
5627
+ const link = git(repo, ["commit-tree", treeOf(repo, local), "-p", local, "-p", root, "-m", "sidecar: link the remote's initial main"]).stdout.trim();
5628
+ git(repo, ["update-ref", ref, link]);
5629
+ }
4824
5630
  function ensureMainBranch(repo, config) {
4825
5631
  if (branchExists(repo, config.branch)) {
4826
5632
  git(repo, ["switch", config.branch]);
@@ -4842,6 +5648,16 @@ function ensureMainBranch(repo, config) {
4842
5648
  git(repo, ["merge", "--ff-only", remoteBranch]);
4843
5649
  return;
4844
5650
  }
5651
+ if (reviewRequired(config)) {
5652
+ if (git(repo, ["merge-base", "HEAD", remoteBranch], { check: false }).status !== 0) {
5653
+ const link = git(repo, ["commit-tree", treeOf(repo, "HEAD"), "-p", "HEAD", "-p", remoteBranch, "-m", `sidecar: link the remote's new ${config.branch}`]).stdout.trim();
5654
+ git(repo, ["merge", "--ff-only", link]);
5655
+ console.log(`${remoteBranch} was started over; local ${config.branch} is joined to it and reviewed from there`);
5656
+ return;
5657
+ }
5658
+ mergeInboxBranch(repo, config, remoteBranch, { forkFiles: true });
5659
+ return;
5660
+ }
4845
5661
  const tip = git(repo, ["rev-parse", "--short", "HEAD"]).stdout.trim();
4846
5662
  const discarded = `refs/sidecar-discarded/${config.branch}/${utcTimestamp()}-${tip}`;
4847
5663
  git(repo, ["update-ref", discarded, "HEAD"], { check: false });
@@ -4889,29 +5705,29 @@ function ensureSidecarCheckout(root, config) {
4889
5705
  return requireSidecarCheckout(root, config);
4890
5706
  }
4891
5707
  function snapshot(repo, mainRoot, inbox, message = "sidecar snapshot", redactionMode = DEFAULT_REDACTION_MODE, policy) {
4892
- if (fs8.existsSync(path8.join(gitDir(repo), "MERGE_HEAD"))) {
5708
+ if (fs9.existsSync(path9.join(gitDir(repo), "MERGE_HEAD"))) {
4893
5709
  throw new SidecarError("cannot snapshot an unfinished merge; resolve or abort it before syncing");
4894
5710
  }
4895
5711
  const rewired = ensureRedactionFilter(repo, redactionMode, policy);
4896
- const revisionPath = path8.join(gitDir(repo), "sidecar-redaction-revision");
5712
+ const revisionPath = path9.join(gitDir(repo), "sidecar-redaction-revision");
4897
5713
  const revision = `${REDACTION_FILTER_REVISION}:${redactionMode}:${rulesFingerprint(policy?.rules)}`;
4898
5714
  let appliedRevision = "";
4899
5715
  try {
4900
- appliedRevision = fs8.readFileSync(revisionPath, "utf8");
5716
+ appliedRevision = fs9.readFileSync(revisionPath, "utf8");
4901
5717
  } catch {}
4902
5718
  git(repo, ["add", "-A"]);
4903
5719
  if ((rewired || appliedRevision !== revision) && hasAnyCommit(repo)) {
4904
5720
  git(repo, ["add", "--renormalize", "."]);
4905
5721
  }
4906
5722
  if (git(repo, ["diff", "--cached", "--quiet"], { check: false }).status === 0) {
4907
- fs8.writeFileSync(revisionPath, revision, "utf8");
5723
+ fs9.writeFileSync(revisionPath, revision, "utf8");
4908
5724
  console.log("no sidecar changes to snapshot");
4909
5725
  return false;
4910
5726
  }
4911
5727
  const staged = git(repo, ["diff", "--cached", "--name-only", "-z", "--diff-filter=d"]).stdout.split("\x00").filter(Boolean);
4912
- const source = `${currentUser()}@${currentHost()}`;
5728
+ const source = currentMachine();
4913
5729
  const body = [message, "", `source: ${source}`];
4914
- if (path8.resolve(repo) !== path8.resolve(mainRoot)) {
5730
+ if (path9.resolve(repo) !== path9.resolve(mainRoot)) {
4915
5731
  const mainHead = git(mainRoot, ["rev-parse", "--short", "HEAD"], { check: false });
4916
5732
  body.push(`main-head: ${mainHead.status === 0 ? mainHead.stdout.trim() : "unborn"}`);
4917
5733
  }
@@ -4919,7 +5735,7 @@ function snapshot(repo, mainRoot, inbox, message = "sidecar snapshot", redaction
4919
5735
  body.push(...writtenTrailers(repo, staged));
4920
5736
  git(repo, ["commit", "-m", body.join(`
4921
5737
  `)]);
4922
- fs8.writeFileSync(revisionPath, revision, "utf8");
5738
+ fs9.writeFileSync(revisionPath, revision, "utf8");
4923
5739
  console.log(`committed sidecar snapshot to ${paint("brand", inbox)}`);
4924
5740
  reportRedactions(repo, staged, redactionMode, policy?.rules);
4925
5741
  return true;
@@ -4933,7 +5749,7 @@ function writtenTrailers(repo, staged) {
4933
5749
  `))
4934
5750
  continue;
4935
5751
  try {
4936
- const seconds = Math.floor(fs8.lstatSync(path8.join(repo, relPath)).mtimeMs / 1000);
5752
+ const seconds = Math.floor(fs9.lstatSync(path9.join(repo, relPath)).mtimeMs / 1000);
4937
5753
  if (seconds > 0)
4938
5754
  trailers.push(`${WRITTEN_TRAILER} ${seconds} ${relPath}`);
4939
5755
  } catch {}
@@ -4944,7 +5760,7 @@ function reportRedactions(repo, staged, mode, rules) {
4944
5760
  let files = 0;
4945
5761
  let items = 0;
4946
5762
  for (const relPath of staged) {
4947
- const delta = fileRedactionDelta(path8.join(repo, relPath), mode, { rules, relativePath: relPath });
5763
+ const delta = fileRedactionDelta(path9.join(repo, relPath), mode, { rules, relativePath: relPath });
4948
5764
  if (!delta)
4949
5765
  continue;
4950
5766
  files += 1;
@@ -4958,7 +5774,7 @@ function reportRedactions(repo, staged, mode, rules) {
4958
5774
  function fileRedactionDelta(filePath, mode, policy) {
4959
5775
  let data;
4960
5776
  try {
4961
- data = fs8.readFileSync(filePath);
5777
+ data = fs9.readFileSync(filePath);
4962
5778
  } catch {
4963
5779
  return;
4964
5780
  }
@@ -4973,14 +5789,14 @@ function fileRedactionDelta(filePath, mode, policy) {
4973
5789
  return { text, redacted, items };
4974
5790
  }
4975
5791
  function checkoutRedactionPolicy(repo) {
4976
- const policyPath = path8.join(gitDir(repo), REDACTION_POLICY_FILE);
5792
+ const policyPath = path9.join(gitDir(repo), REDACTION_POLICY_FILE);
4977
5793
  let bound;
4978
5794
  try {
4979
- bound = JSON.parse(fs8.readFileSync(policyPath, "utf8"));
5795
+ bound = JSON.parse(fs9.readFileSync(policyPath, "utf8"));
4980
5796
  } catch {
4981
5797
  throw new SidecarError("missing or invalid checkout redaction policy; run `sidecar sync`");
4982
5798
  }
4983
- if (!bound || typeof bound !== "object" || typeof bound.fingerprint !== "string" || bound.rulesPath !== undefined && (typeof bound.rulesPath !== "string" || !path8.isAbsolute(bound.rulesPath))) {
5799
+ if (!bound || typeof bound !== "object" || typeof bound.fingerprint !== "string" || bound.rulesPath !== undefined && (typeof bound.rulesPath !== "string" || !path9.isAbsolute(bound.rulesPath))) {
4984
5800
  throw new SidecarError("invalid checkout redaction policy; run `sidecar sync`");
4985
5801
  }
4986
5802
  const mode = redactionModeConfigValue(bound.mode, "checkout redaction mode");
@@ -4995,27 +5811,28 @@ function ensureRedactionFilter(repo, mode = DEFAULT_REDACTION_MODE, policy) {
4995
5811
  if (policy?.rules?.length && !policy.rulesPath) {
4996
5812
  throw new SidecarError("path redaction rules require an explicit host rules file");
4997
5813
  }
4998
- const rulesPath = policy?.rulesPath ? path8.resolve(policy.rulesPath) : undefined;
5814
+ const rulesPath = policy?.rulesPath ? path9.resolve(policy.rulesPath) : undefined;
4999
5815
  if (rulesPath && rulesFingerprint(readRules(rulesPath)) !== fingerprint) {
5000
5816
  throw new SidecarError("sidecar rules changed during this operation; run `sidecar sync` to apply them");
5001
5817
  }
5002
- const policyPath = path8.join(gitDir(repo), REDACTION_POLICY_FILE);
5818
+ const policyPath = path9.join(gitDir(repo), REDACTION_POLICY_FILE);
5003
5819
  const bound = JSON.stringify({ mode, rulesPath, fingerprint });
5004
5820
  let previous = "";
5005
5821
  try {
5006
- previous = fs8.readFileSync(policyPath, "utf8");
5822
+ previous = fs9.readFileSync(policyPath, "utf8");
5007
5823
  } catch {}
5008
5824
  const policyChanged = previous !== bound;
5009
5825
  if (policyChanged)
5010
- fs8.writeFileSync(policyPath, bound, { encoding: "utf8", mode: 384 });
5011
- const command = `${filterCommandQuote(process.execPath)} ${filterCommandQuote(redactCliPath())} redact --checkout-policy --path %f`;
5826
+ fs9.writeFileSync(policyPath, bound, { encoding: "utf8", mode: 384 });
5827
+ const command = `${filterCommandQuote(process.execPath)} ${filterCommandQuote(sidecarCliPath())} redact --checkout-policy --path %f`;
5012
5828
  const wanted = [
5013
5829
  [`filter.${REDACTION_FILTER_NAME}.clean`, command],
5014
5830
  [`filter.${REDACTION_FILTER_NAME}.revision`, REDACTION_FILTER_REVISION],
5015
5831
  [`filter.${REDACTION_FILTER_NAME}.smudge`, "cat"],
5016
5832
  [`filter.${REDACTION_FILTER_NAME}.required`, "true"]
5017
5833
  ];
5018
- const attributesPath = path8.join(gitCommonDir(repo), "info", "attributes");
5834
+ const processKey = `filter.${REDACTION_FILTER_NAME}.process`;
5835
+ const attributesPath = path9.join(gitCommonDir(repo), "info", "attributes");
5019
5836
  const line = `* filter=${REDACTION_FILTER_NAME}`;
5020
5837
  const configured = git(repo, ["config", "--get-regexp", `^filter\\.${REDACTION_FILTER_NAME}\\.`], {
5021
5838
  check: false
@@ -5025,10 +5842,10 @@ function ensureRedactionFilter(repo, mode = DEFAULT_REDACTION_MODE, policy) {
5025
5842
  const space = entry.indexOf(" ");
5026
5843
  return [entry.slice(0, space), entry.slice(space + 1)];
5027
5844
  }));
5028
- const configOk = wanted.every(([key, value]) => current.get(key) === value);
5845
+ const configOk = wanted.every(([key, value]) => current.get(key) === value) && !current.has(processKey);
5029
5846
  let attributes = "";
5030
5847
  try {
5031
- attributes = fs8.readFileSync(attributesPath, "utf8");
5848
+ attributes = fs9.readFileSync(attributesPath, "utf8");
5032
5849
  } catch {}
5033
5850
  const attributesOk = attributes.split(/\r?\n/).includes(line);
5034
5851
  if (configOk && attributesOk)
@@ -5036,9 +5853,10 @@ function ensureRedactionFilter(repo, mode = DEFAULT_REDACTION_MODE, policy) {
5036
5853
  for (const [key, value] of wanted) {
5037
5854
  git(repo, ["config", key, value]);
5038
5855
  }
5856
+ git(repo, ["config", "--unset-all", processKey], { check: false });
5039
5857
  if (!attributesOk) {
5040
- fs8.mkdirSync(path8.dirname(attributesPath), { recursive: true });
5041
- fs8.appendFileSync(attributesPath, attributes && !attributes.endsWith(`
5858
+ fs9.mkdirSync(path9.dirname(attributesPath), { recursive: true });
5859
+ fs9.appendFileSync(attributesPath, attributes && !attributes.endsWith(`
5042
5860
  `) ? `
5043
5861
  ${line}
5044
5862
  ` : `${line}
@@ -5048,13 +5866,13 @@ ${line}
5048
5866
  }
5049
5867
  function removeRedactionFilter(repo) {
5050
5868
  git(repo, ["config", "--remove-section", `filter.${REDACTION_FILTER_NAME}`], { check: false });
5051
- fs8.rmSync(path8.join(gitDir(repo), "sidecar-redaction-revision"), { force: true });
5052
- fs8.rmSync(path8.join(gitDir(repo), REDACTION_POLICY_FILE), { force: true });
5053
- const attributesPath = path8.join(gitCommonDir(repo), "info", "attributes");
5869
+ fs9.rmSync(path9.join(gitDir(repo), "sidecar-redaction-revision"), { force: true });
5870
+ fs9.rmSync(path9.join(gitDir(repo), REDACTION_POLICY_FILE), { force: true });
5871
+ const attributesPath = path9.join(gitCommonDir(repo), "info", "attributes");
5054
5872
  const line = `* filter=${REDACTION_FILTER_NAME}`;
5055
5873
  let contents;
5056
5874
  try {
5057
- contents = fs8.readFileSync(attributesPath, "utf8");
5875
+ contents = fs9.readFileSync(attributesPath, "utf8");
5058
5876
  } catch {
5059
5877
  return;
5060
5878
  }
@@ -5063,17 +5881,13 @@ function removeRedactionFilter(repo) {
5063
5881
  if (kept.length === lines.length)
5064
5882
  return;
5065
5883
  if (kept.every((entry) => !entry.trim())) {
5066
- fs8.rmSync(attributesPath, { force: true });
5884
+ fs9.rmSync(attributesPath, { force: true });
5067
5885
  } else {
5068
- fs8.writeFileSync(attributesPath, `${kept.join(`
5886
+ fs9.writeFileSync(attributesPath, `${kept.join(`
5069
5887
  `).replace(/\s+$/g, "")}
5070
5888
  `, "utf8");
5071
5889
  }
5072
5890
  }
5073
- function redactCliPath() {
5074
- const self = fileURLToPath2(import.meta.url);
5075
- return self.endsWith(".ts") ? path8.join(path8.dirname(self), "..", "dist", "cli.js") : self;
5076
- }
5077
5891
  function filterCommandQuote(value) {
5078
5892
  return `"${value.replace(/([\\"$`])/g, "\\$1")}"`;
5079
5893
  }
@@ -5120,7 +5934,7 @@ function refreshInboxFromMain(repo, config, inbox) {
5120
5934
  }
5121
5935
  }
5122
5936
  function pushBranch(repo, branch) {
5123
- git(repo, ["push", "-u", "origin", `HEAD:refs/heads/${branch}`]);
5937
+ git(repo, ["push", "--no-follow-tags", "-u", "origin", `HEAD:refs/heads/${branch}`]);
5124
5938
  console.log(`pushed ${paint("brand", branch)}`);
5125
5939
  }
5126
5940
  function forkConflicts(repo, remoteBranch, selectedPaths) {
@@ -5163,14 +5977,14 @@ function forkConflicts(repo, remoteBranch, selectedPaths) {
5163
5977
  continue;
5164
5978
  const { oid, mode } = stages[stage];
5165
5979
  const outPath = forkPath(conflictPath, label, oid);
5166
- const fullOut = path8.join(repo, outPath);
5167
- fs8.mkdirSync(path8.dirname(fullOut), { recursive: true });
5168
- fs8.rmSync(fullOut, { force: true });
5980
+ const fullOut = path9.join(repo, outPath);
5981
+ fs9.mkdirSync(path9.dirname(fullOut), { recursive: true });
5982
+ fs9.rmSync(fullOut, { force: true });
5169
5983
  if (mode === "120000")
5170
- fs8.symlinkSync(blob.toString("utf8"), fullOut);
5984
+ fs9.symlinkSync(blob.toString("utf8"), fullOut);
5171
5985
  else {
5172
- fs8.writeFileSync(fullOut, blob);
5173
- fs8.chmodSync(fullOut, mode === "100755" ? 493 : 420);
5986
+ fs9.writeFileSync(fullOut, blob);
5987
+ fs9.chmodSync(fullOut, mode === "100755" ? 493 : 420);
5174
5988
  }
5175
5989
  git(repo, ["add", "--", `:(literal)${outPath}`]);
5176
5990
  versions.push({
@@ -5178,18 +5992,18 @@ function forkConflicts(repo, remoteBranch, selectedPaths) {
5178
5992
  label,
5179
5993
  oid,
5180
5994
  path: outPath,
5181
- sha256: crypto4.createHash("sha256").update(blob).digest("hex")
5995
+ sha256: crypto5.createHash("sha256").update(blob).digest("hex")
5182
5996
  });
5183
5997
  }
5184
5998
  git(repo, ["rm", "-f", "--", `:(literal)${conflictPath}`]);
5185
5999
  manifest.paths.push({ path: conflictPath, versions });
5186
6000
  }
5187
- const manifestDir = path8.join(repo, ".sidecar-conflicts");
5188
- fs8.mkdirSync(manifestDir, { recursive: true });
5189
- const manifestPath = path8.join(manifestDir, `${timestamp}-${manifestLabel}-fork-files.json`);
5190
- fs8.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
6001
+ const manifestDir = path9.join(repo, ".sidecar-conflicts");
6002
+ fs9.mkdirSync(manifestDir, { recursive: true });
6003
+ const manifestPath = path9.join(manifestDir, `${timestamp}-${manifestLabel}-fork-files.json`);
6004
+ fs9.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
5191
6005
  `, "utf8");
5192
- git(repo, ["add", "--", `:(literal)${path8.relative(repo, manifestPath)}`]);
6006
+ git(repo, ["add", "--", `:(literal)${path9.relative(repo, manifestPath)}`]);
5193
6007
  if (Object.keys(selectConflictPaths(unmergedPaths(repo), selectedPaths)).length) {
5194
6008
  throw new SidecarError("fork-files did not clear all unmerged paths");
5195
6009
  }
@@ -5219,7 +6033,7 @@ function resolveLastWriterWins(repo, canonicalBranch, remoteBranch, selectedPath
5219
6033
  ...selections.filter((entry) => entry.winner).map((entry) => entry.filePath)
5220
6034
  ]);
5221
6035
  for (const filePath of present) {
5222
- for (let parent = path8.posix.dirname(filePath);parent !== "."; parent = path8.posix.dirname(parent)) {
6036
+ for (let parent = path9.posix.dirname(filePath);parent !== "."; parent = path9.posix.dirname(parent)) {
5223
6037
  if (present.has(parent))
5224
6038
  throw new SidecarError(`last-writer-wins selected incompatible file and directory paths: ${parent}, ${filePath}`);
5225
6039
  }
@@ -5246,12 +6060,12 @@ function resolveLastWriterWins(repo, canonicalBranch, remoteBranch, selectedPath
5246
6060
  written.push(lwwWrittenTrailer(filePath, write));
5247
6061
  }
5248
6062
  if (manifest.paths.length) {
5249
- const manifestDir = path8.join(repo, ".sidecar-conflicts");
5250
- fs8.mkdirSync(manifestDir, { recursive: true });
5251
- const manifestPath = path8.join(manifestDir, `${timestamp}-${fileLabel(branch)}-lww.json`);
5252
- fs8.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
6063
+ const manifestDir = path9.join(repo, ".sidecar-conflicts");
6064
+ fs9.mkdirSync(manifestDir, { recursive: true });
6065
+ const manifestPath = path9.join(manifestDir, `${timestamp}-${fileLabel(branch)}-lww.json`);
6066
+ fs9.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
5253
6067
  `, "utf8");
5254
- git(repo, ["add", "--", `:(literal)${path8.relative(repo, manifestPath)}`]);
6068
+ git(repo, ["add", "--", `:(literal)${path9.relative(repo, manifestPath)}`]);
5255
6069
  }
5256
6070
  if (Object.keys(selectConflictPaths(unmergedPaths(repo), selectedPaths)).length) {
5257
6071
  throw new SidecarError("last-writer-wins did not clear all unmerged paths");
@@ -5279,7 +6093,7 @@ function treeEntry(repo, ref, filePath) {
5279
6093
  return;
5280
6094
  }
5281
6095
  function lwwTrailerPrefix(filePath) {
5282
- return `sidecar-lww-${crypto4.createHash("sha256").update(filePath).digest("hex")}:`;
6096
+ return `sidecar-lww-${crypto5.createHash("sha256").update(filePath).digest("hex")}:`;
5283
6097
  }
5284
6098
  function lwwWrittenTrailer(filePath, write) {
5285
6099
  return `${lwwTrailerPrefix(filePath)} ${write.time} ${write.source || "-"}`;
@@ -5326,11 +6140,11 @@ function recordedLwwEvent(body, prefix) {
5326
6140
  return Number.isInteger(time) && time >= 0 && source ? { time, source: source === "-" ? "" : source } : undefined;
5327
6141
  }
5328
6142
  function forkPath(conflictPath, label, oid) {
5329
- const parsed = path8.parse(conflictPath);
6143
+ const parsed = path9.parse(conflictPath);
5330
6144
  const shortOid = oid ? oid.slice(0, 7) : "missing";
5331
6145
  const safeLabel = fileLabel(label);
5332
6146
  const forkName = parsed.ext ? `${parsed.name}.conflict.${safeLabel}.${shortOid}${parsed.ext}` : `${parsed.name}.conflict.${safeLabel}.${shortOid}`;
5333
- return path8.join(parsed.dir, forkName);
6147
+ return path9.join(parsed.dir, forkName);
5334
6148
  }
5335
6149
  function fileLabel(value) {
5336
6150
  return slug(value).replaceAll("/", "-");
@@ -5389,7 +6203,7 @@ function pendingInboxBranches(repo, config) {
5389
6203
  });
5390
6204
  return [...local, ...remote].sort();
5391
6205
  }
5392
- var SOFT_SYNC_ENV = "SIDECAR_SYNC_SOFT", LOCAL_SYNC_ENV = "SIDECAR_SYNC_LOCAL", WRITTEN_TRAILER = "written:", WRITTEN_TRAILER_LIMIT = 500, REDACTION_FILTER_NAME = "sidecar-redact", REDACTION_FILTER_REVISION = "2", REDACTION_POLICY_FILE = "sidecar-redaction-policy";
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";
5393
6207
  var init_sync = __esm(() => {
5394
6208
  init_color();
5395
6209
  init_util();
@@ -5397,13 +6211,14 @@ var init_sync = __esm(() => {
5397
6211
  init_install();
5398
6212
  init_config();
5399
6213
  init_state();
6214
+ init_review();
5400
6215
  init_rules();
5401
6216
  init_health();
5402
6217
  init_redaction();
5403
6218
  });
5404
6219
 
5405
6220
  // src/ui.ts
5406
- import fs9 from "node:fs";
6221
+ import fs10 from "node:fs";
5407
6222
  function announcePeer(peer, peers) {
5408
6223
  const index = peers.indexOf(peer);
5409
6224
  if (index > 0)
@@ -5465,13 +6280,13 @@ function promptYesNoDefaultNo(question) {
5465
6280
  return answer === "y" || answer === "yes";
5466
6281
  }
5467
6282
  function promptLine(prompt) {
5468
- fs9.writeSync(1, prompt);
5469
- const fd = fs9.openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r");
6283
+ fs10.writeSync(1, prompt);
6284
+ const fd = fs10.openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r");
5470
6285
  try {
5471
6286
  const chunks = [];
5472
6287
  const buffer = Buffer.alloc(1);
5473
6288
  while (true) {
5474
- const bytesRead = fs9.readSync(fd, buffer, 0, 1, null);
6289
+ const bytesRead = fs10.readSync(fd, buffer, 0, 1, null);
5475
6290
  if (bytesRead === 0)
5476
6291
  break;
5477
6292
  const char = buffer.toString("utf8", 0, bytesRead);
@@ -5482,7 +6297,7 @@ function promptLine(prompt) {
5482
6297
  }
5483
6298
  return chunks.join("").trim();
5484
6299
  } finally {
5485
- fs9.closeSync(fd);
6300
+ fs10.closeSync(fd);
5486
6301
  }
5487
6302
  }
5488
6303
  var init_ui = __esm(() => {
@@ -5491,9 +6306,9 @@ var init_ui = __esm(() => {
5491
6306
  });
5492
6307
 
5493
6308
  // src/cmd-init.ts
5494
- import fs10 from "node:fs";
5495
- import path9 from "node:path";
5496
- import { spawnSync as spawnSync4 } from "node:child_process";
6309
+ import fs11 from "node:fs";
6310
+ import path10 from "node:path";
6311
+ import { spawnSync as spawnSync5 } from "node:child_process";
5497
6312
  function cmdDeinit(args) {
5498
6313
  const parsed = parseOptions(args, { boolean: new Set(["--yes", "-y"]), value: new Set(["--peer"]) });
5499
6314
  if (parsed.positional.length)
@@ -5515,7 +6330,7 @@ function cmdDeinit(args) {
5515
6330
  const configPath = peerConfigPath(root, name);
5516
6331
  const leftovers = [];
5517
6332
  let config;
5518
- if (fs10.existsSync(configPath)) {
6333
+ if (fs11.existsSync(configPath)) {
5519
6334
  try {
5520
6335
  config = readConfig(configPath);
5521
6336
  } catch {
@@ -5548,12 +6363,14 @@ function cmdDeinit(args) {
5548
6363
  removeCheckout(checkoutPath);
5549
6364
  const ignoreEntry = ignoreEntryForSidecarPath(root, config.path);
5550
6365
  if (ignoreEntry) {
5551
- removeIgnoreEntry(path9.join(root, ".gitignore"), ignoreEntry);
6366
+ removeIgnoreEntry(path10.join(root, ".gitignore"), ignoreEntry);
5552
6367
  removeZedInclusion(root, ignoreEntry);
5553
6368
  }
5554
6369
  }
5555
- fs10.rmSync(configPath, { force: true });
5556
- fs10.rmSync(peerRulesPath(root, name), { force: true });
6370
+ fs11.rmSync(configPath, { force: true });
6371
+ fs11.rmSync(peerRulesPath(root, name), { force: true });
6372
+ fs11.rmSync(peerReviewGuidelinesPath(root, name), { force: true });
6373
+ pruneOrphanedReviews();
5557
6374
  unregisterInstance(configPath);
5558
6375
  });
5559
6376
  console.log(`removed sidecar from ${paint("repo", root)}${name === DEFAULT_PEER ? "" : ` (peer ${paint("brand", name)})`}`);
@@ -5568,9 +6385,9 @@ function cmdDeinit(args) {
5568
6385
  function assertDeinitTarget(root, checkoutPath) {
5569
6386
  const target = realpathOr(checkoutPath);
5570
6387
  const host = realpathOr(root);
5571
- const hostFromTarget = path9.relative(target, host);
5572
- const parts = path9.relative(host, target).split(path9.sep);
5573
- const containsHost = !path9.isAbsolute(hostFromTarget) && hostFromTarget.split(path9.sep)[0] !== "..";
6388
+ const hostFromTarget = path10.relative(target, host);
6389
+ const parts = path10.relative(host, target).split(path10.sep);
6390
+ const containsHost = !path10.isAbsolute(hostFromTarget) && hostFromTarget.split(path10.sep)[0] !== "..";
5574
6391
  if (containsHost || parts.includes(".git") || parts.includes(".jj")) {
5575
6392
  throw new SidecarError(`refusing to delete ${checkoutPath}, which contains the host repo or its VCS metadata`);
5576
6393
  }
@@ -5591,19 +6408,19 @@ function releaseStandaloneCheckout(root, config) {
5591
6408
  }
5592
6409
  function removeCheckout(checkoutPath) {
5593
6410
  try {
5594
- if (fs10.statSync(path9.join(checkoutPath, ".git")).isFile()) {
6411
+ if (fs11.statSync(path10.join(checkoutPath, ".git")).isFile()) {
5595
6412
  git(checkoutPath, ["worktree", "remove", "--force", checkoutPath], { check: false });
5596
6413
  }
5597
6414
  } catch {}
5598
- fs10.rmSync(checkoutPath, { recursive: true, force: true });
6415
+ fs11.rmSync(checkoutPath, { recursive: true, force: true });
5599
6416
  }
5600
6417
  function cmdInit(args) {
5601
6418
  const parsed = parseOptions(args, {
5602
6419
  boolean: new Set(["--no-clone", "--no-bootstrap-main", "--local-install", "--ignored"]),
5603
- value: new Set(["--path", "--branch", "--inbox", "--redaction", "--resolve", "--debounce", "--interval", "--peer"])
6420
+ value: new Set(["--path", "--branch", "--inbox", "--redaction", "--resolve", "--review", "--review-timeout", "--debounce", "--interval", "--peer"])
5604
6421
  });
5605
6422
  if (parsed.positional.length > 1) {
5606
- throw new SidecarError("usage: sidecar init [remote] [--peer name] [--path sidecar] [--branch main] [--inbox template] [--redaction mode] [--resolve fork|lww] [--debounce 10m] [--interval 1h] [--ignored]");
6423
+ throw new SidecarError("usage: sidecar init [remote] [--peer name] [--path sidecar] [--branch main] [--inbox template] [--redaction mode] [--resolve fork|lww] [--review off|required] [--review-timeout 1h] [--debounce 10m] [--interval 1h] [--ignored]");
5607
6424
  }
5608
6425
  const remote = parsed.positional[0];
5609
6426
  const requestedPeer = parsed.values.get("--peer");
@@ -5643,13 +6460,15 @@ function cmdInit(args) {
5643
6460
  }
5644
6461
  function configurePeer(root, name, remote, parsed) {
5645
6462
  const configPath = peerConfigPath(root, name);
5646
- let reuse = !remote && fs10.existsSync(configPath);
5647
- if (remote && fs10.existsSync(configPath)) {
6463
+ let reuse = !remote && fs11.existsSync(configPath);
6464
+ let built;
6465
+ if (remote && fs11.existsSync(configPath)) {
5648
6466
  const existing = readConfig(configPath);
5649
- const unchanged = existing.remote === remote && existing.path === getValue(parsed, "--path", existing.path) && existing.branch === getValue(parsed, "--branch", existing.branch) && existing.inbox === getValue(parsed, "--inbox", existing.inbox) && existing.redaction === getValue(parsed, "--redaction", existing.redaction) && existing.resolve === getValue(parsed, "--resolve", existing.resolve) && existing.debounce === durationConfigValue(parsed.values.get("--debounce") ?? existing.debounce, "--debounce") && existing.interval === durationConfigValue(parsed.values.get("--interval") ?? existing.interval, "--interval");
5650
- reuse = unchanged || !promptOverwriteConfig(configPath, existing.remote, remote);
6467
+ built = buildInitConfig(root, name, remote, parsed, existing);
6468
+ reuse = configText(built) === configText(existing) || !promptOverwriteConfig(configPath, existing.remote, remote);
5651
6469
  }
5652
- const config = reuse ? readConfig(configPath) : buildInitConfig(root, name, remote, parsed);
6470
+ const config = reuse ? readConfig(configPath) : built ?? buildInitConfig(root, name, remote, parsed);
6471
+ notePeerPolicy(config);
5653
6472
  if (!reuse) {
5654
6473
  config.rulesPath = peerRulesPath(root, name);
5655
6474
  config.rules = readRules(config.rulesPath);
@@ -5658,7 +6477,7 @@ function configurePeer(root, name, remote, parsed) {
5658
6477
  if (isStandalone(config)) {
5659
6478
  throw new SidecarError("--ignored cannot apply to a standalone sidecar: its .sidecar is part of the tree it syncs");
5660
6479
  }
5661
- for (const file of [peerFileName(name), peerRulesFileName(name)]) {
6480
+ for (const file of [peerFileName(name), peerRulesFileName(name), peerReviewGuidelinesFileName(name)]) {
5662
6481
  if (isGitTracked(root, file)) {
5663
6482
  throw new SidecarError(`${file} is tracked by git; \`git rm --cached ${file}\` before ignoring it`);
5664
6483
  }
@@ -5691,9 +6510,11 @@ function excludePeerFile(root, name) {
5691
6510
  throw new SidecarError(`--ignored needs a git exclude file, and ${root} has none; ignore ${configFile} in your own VCS config`);
5692
6511
  }
5693
6512
  const rulesFile = peerRulesFileName(name);
6513
+ const guidelinesFile = peerReviewGuidelinesFileName(name);
5694
6514
  ensureIgnoreLine(exclude, `/${configFile}`);
5695
6515
  ensureIgnoreLine(exclude, `/${rulesFile}`);
5696
- console.log(`ignored ${configFile} and ${rulesFile} via .git/info/exclude`);
6516
+ ensureIgnoreLine(exclude, `/${guidelinesFile}`);
6517
+ console.log(`ignored ${configFile}, ${rulesFile}, and ${guidelinesFile} via .git/info/exclude`);
5697
6518
  }
5698
6519
  function initRoot(remote, parsed) {
5699
6520
  const cwd = process.cwd();
@@ -5708,9 +6529,9 @@ function initRoot(remote, parsed) {
5708
6529
  console.log(`initialized ${paint("repo", cwd)} as a Git repository`);
5709
6530
  return cwd;
5710
6531
  }
5711
- function buildInitConfig(root, name, remote, parsed) {
6532
+ function buildInitConfig(root, name, remote, parsed, existing) {
5712
6533
  const defaultPath = name === DEFAULT_PEER ? DEFAULT_PATH : name;
5713
- const rawPath = parsed.values.has("--path") ? getValue(parsed, "--path", defaultPath) : promptSidecarPath(root, name, defaultPath);
6534
+ const rawPath = parsed.values.has("--path") ? getValue(parsed, "--path", defaultPath) : existing?.path ?? promptSidecarPath(root, name, defaultPath);
5714
6535
  const sidecarPath = pathIsRepoRoot(root, rawPath) ? "." : rawPath;
5715
6536
  const standalone = isStandalonePath(sidecarPath);
5716
6537
  if (standalone && name !== DEFAULT_PEER) {
@@ -5721,12 +6542,14 @@ function buildInitConfig(root, name, remote, parsed) {
5721
6542
  remote: remote ?? (standalone ? standaloneRemote(root) : promptRemote(root)),
5722
6543
  version: 1,
5723
6544
  path: sidecarPath,
5724
- branch: getValue(parsed, "--branch", DEFAULT_BRANCH),
5725
- inbox: getValue(parsed, "--inbox", DEFAULT_INBOX),
5726
- redaction: parsed.values.has("--redaction") ? redactionModeConfigValue(getValue(parsed, "--redaction", DEFAULT_REDACTION_MODE), "--redaction") : promptRedactionMode(),
5727
- resolve: parsed.values.has("--resolve") ? resolveModeConfigValue(getValue(parsed, "--resolve", DEFAULT_RESOLVE), "--resolve") : DEFAULT_RESOLVE,
5728
- debounce: durationConfigValue(parsed.values.get("--debounce"), "--debounce"),
5729
- interval: durationConfigValue(parsed.values.get("--interval"), "--interval")
6545
+ branch: getValue(parsed, "--branch", existing?.branch ?? DEFAULT_BRANCH),
6546
+ inbox: getValue(parsed, "--inbox", existing?.inbox ?? DEFAULT_INBOX),
6547
+ redaction: parsed.values.has("--redaction") ? redactionModeConfigValue(getValue(parsed, "--redaction", DEFAULT_REDACTION_MODE), "--redaction") : existing?.redaction ?? promptRedactionMode(),
6548
+ resolve: parsed.values.has("--resolve") ? resolveModeConfigValue(getValue(parsed, "--resolve", DEFAULT_RESOLVE), "--resolve") : existing?.resolve ?? DEFAULT_RESOLVE,
6549
+ debounce: durationConfigValue(parsed.values.get("--debounce"), "--debounce") ?? existing?.debounce,
6550
+ interval: durationConfigValue(parsed.values.get("--interval"), "--interval") ?? existing?.interval,
6551
+ review: reviewModeConfigValue(getValue(parsed, "--review", existing?.review ?? "off"), "--review"),
6552
+ reviewTimeout: durationConfigValue(parsed.values.get("--review-timeout"), "--review-timeout") ?? existing?.reviewTimeout
5730
6553
  };
5731
6554
  }
5732
6555
  function printCheckoutVisibility(root, config) {
@@ -5743,7 +6566,7 @@ function printCheckoutVisibility(root, config) {
5743
6566
  console.log(`sidecar path outside repo; not updating .gitignore`);
5744
6567
  return;
5745
6568
  }
5746
- ensureIgnoreEntry(exclude ?? path9.join(root, ".gitignore"), ignoreEntry);
6569
+ ensureIgnoreEntry(exclude ?? path10.join(root, ".gitignore"), ignoreEntry);
5747
6570
  const name = ignoreEntry.replace(/\/+$/, "");
5748
6571
  console.log(`ignored ${name}/ via ${exclude ? ".git/info/exclude" : ".gitignore"}`);
5749
6572
  if (hasZedInclusion(root, ignoreEntry)) {
@@ -5757,8 +6580,8 @@ function printCheckoutVisibility(root, config) {
5757
6580
  }
5758
6581
  }
5759
6582
  function offerLocalInstall(root, standalone, forced) {
5760
- const manifestPath = path9.join(root, "package.json");
5761
- if (!fs10.existsSync(manifestPath)) {
6583
+ const manifestPath = path10.join(root, "package.json");
6584
+ if (!fs11.existsSync(manifestPath)) {
5762
6585
  if (forced)
5763
6586
  throw new SidecarError("--local-install requires a package.json");
5764
6587
  return;
@@ -5768,7 +6591,7 @@ function offerLocalInstall(root, standalone, forced) {
5768
6591
  let source;
5769
6592
  let manifest;
5770
6593
  try {
5771
- source = fs10.readFileSync(manifestPath, "utf8");
6594
+ source = fs11.readFileSync(manifestPath, "utf8");
5772
6595
  const parsed = JSON.parse(source);
5773
6596
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
5774
6597
  throw new Error("not an object");
@@ -5794,7 +6617,7 @@ function offerLocalInstall(root, standalone, forced) {
5794
6617
  manifest.pnpm = pnpm;
5795
6618
  }
5796
6619
  const indent = /^([ \t]+)"/m.exec(source)?.[1] ?? " ";
5797
- fs10.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, indent)}
6620
+ fs11.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, indent)}
5798
6621
  `);
5799
6622
  console.log(`added ${paint("brand", PACKAGE_NAME)} to devDependencies; run your package manager's install to pin it`);
5800
6623
  if (managers.has("bun")) {
@@ -5822,7 +6645,7 @@ function detectPackageManagers(root) {
5822
6645
  ["package-lock.json", "npm"],
5823
6646
  ["yarn.lock", "yarn"]
5824
6647
  ];
5825
- return new Set(lockfiles.filter(([file]) => fs10.existsSync(path9.join(root, file))).map(([, manager]) => manager));
6648
+ return new Set(lockfiles.filter(([file]) => fs11.existsSync(path10.join(root, file))).map(([, manager]) => manager));
5826
6649
  }
5827
6650
  function ensureDaemonSetup(globalSidecar) {
5828
6651
  if (process.env[SKIP_SERVICE_ENV] === "1")
@@ -5832,7 +6655,7 @@ function ensureDaemonSetup(globalSidecar) {
5832
6655
  const service = daemonServiceStatus();
5833
6656
  if (!service.available || service.installed && service.running)
5834
6657
  return;
5835
- const result = spawnSync4(globalSidecar, ["daemon", "enable"], {
6658
+ const result = spawnSync5(globalSidecar, ["daemon", "enable"], {
5836
6659
  encoding: "utf8",
5837
6660
  env: {
5838
6661
  ...process.env,
@@ -5884,7 +6707,7 @@ function warnIfGlobalPredatesPeers(executable, peers) {
5884
6707
  console.error(`sidecar: warning: the global sidecar (${version ? `v${version}` : "unknown version"}) predates peers; its daemon syncs only .sidecar until it is updated`);
5885
6708
  }
5886
6709
  function registerInstallWithGlobalSidecar(executable, root) {
5887
- const result = spawnSync4(executable, ["register-install"], {
6710
+ const result = spawnSync5(executable, ["register-install"], {
5888
6711
  cwd: root,
5889
6712
  encoding: "utf8",
5890
6713
  env: {
@@ -5901,7 +6724,7 @@ function installGlobalSidecar() {
5901
6724
  const bun = findExecutableOnPath(process.platform === "win32" ? "bun.exe" : "bun");
5902
6725
  const command = bun ? [bun, "add", "-g", PACKAGE_SPEC] : ["npm", "install", "-g", PACKAGE_SPEC];
5903
6726
  console.log(`running ${command.join(" ")}`);
5904
- const result = spawnSync4(command[0], command.slice(1), { stdio: "inherit" });
6727
+ const result = spawnSync5(command[0], command.slice(1), { stdio: "inherit" });
5905
6728
  if (result.status !== 0) {
5906
6729
  throw new SidecarError(`global sidecar install failed; run \`${command.join(" ")}\` manually`);
5907
6730
  }
@@ -5920,7 +6743,7 @@ function cmdClone(args) {
5920
6743
  const { root, config } = peer;
5921
6744
  if (parsed.flags.has("--if-missing")) {
5922
6745
  const sidecarPath = resolveSidecarPath(root, config);
5923
- if (fs10.existsSync(sidecarPath) && hasGitMetadata(sidecarPath))
6746
+ if (fs11.existsSync(sidecarPath) && hasGitMetadata(sidecarPath))
5924
6747
  continue;
5925
6748
  }
5926
6749
  cloneOrUpdate(root, config, !parsed.flags.has("--no-bootstrap-main"));
@@ -6008,7 +6831,7 @@ function createRemoteWithGh(root) {
6008
6831
  const origin = git(root, ["remote", "get-url", "origin"], { check: false }).stdout.trim() || undefined;
6009
6832
  const parsedOrigin = origin ? parseGitHubRemote(origin) : undefined;
6010
6833
  const owner = parsedOrigin?.owner ?? ghLogin(gh);
6011
- const baseName = parsedOrigin?.repo ?? path9.basename(root);
6834
+ const baseName = parsedOrigin?.repo ?? path10.basename(root);
6012
6835
  const suggested = owner ? `${owner}/${baseName}-sidecar` : `${baseName}-sidecar`;
6013
6836
  const answer = promptLine(`repository to create ${paint("quiet", `[${suggested}]`)}: `) || suggested;
6014
6837
  const fullName = answer.includes("/") ? answer : owner ? `${owner}/${answer}` : undefined;
@@ -6016,7 +6839,7 @@ function createRemoteWithGh(root) {
6016
6839
  throw new SidecarError("could not determine the repository owner; enter it as owner/name");
6017
6840
  }
6018
6841
  console.log(`running gh repo create ${fullName} --private`);
6019
- const create = spawnSync4(gh, ["repo", "create", fullName, "--private"], { stdio: "inherit" });
6842
+ const create = spawnSync5(gh, ["repo", "create", fullName, "--private"], { stdio: "inherit" });
6020
6843
  if (create.status !== 0) {
6021
6844
  throw new SidecarError("gh repo create failed; create the repo yourself and rerun `sidecar init <remote>`");
6022
6845
  }
@@ -6030,14 +6853,14 @@ function parseGitHubRemote(url) {
6030
6853
  return { owner: match[1], repo: match[2] };
6031
6854
  }
6032
6855
  function ghLogin(gh) {
6033
- const result = spawnSync4(gh, ["api", "user", "-q", ".login"], { encoding: "utf8" });
6856
+ const result = spawnSync5(gh, ["api", "user", "-q", ".login"], { encoding: "utf8" });
6034
6857
  if (result.status !== 0)
6035
6858
  return;
6036
6859
  const login = result.stdout.trim();
6037
6860
  return login || undefined;
6038
6861
  }
6039
6862
  function ghGitProtocol(gh) {
6040
- const result = spawnSync4(gh, ["config", "get", "git_protocol"], { encoding: "utf8" });
6863
+ const result = spawnSync5(gh, ["config", "get", "git_protocol"], { encoding: "utf8" });
6041
6864
  if (result.status !== 0)
6042
6865
  return "https";
6043
6866
  return result.stdout.trim() || "https";
@@ -6057,38 +6880,38 @@ function removeIgnoreEntry(ignorePath, sidecarPath) {
6057
6880
  removeIgnoreLine(ignorePath, `/${sidecarPath.replace(/^\/+|\/+$/g, "")}/`);
6058
6881
  }
6059
6882
  function ensureIgnoreLine(ignorePath, entry) {
6060
- const lines = fs10.existsSync(ignorePath) ? fs10.readFileSync(ignorePath, "utf8").split(/\r?\n/) : [];
6883
+ const lines = fs11.existsSync(ignorePath) ? fs11.readFileSync(ignorePath, "utf8").split(/\r?\n/) : [];
6061
6884
  if (lines.includes(entry))
6062
6885
  return;
6063
6886
  while (lines.length && lines[lines.length - 1] === "")
6064
6887
  lines.pop();
6065
6888
  lines.push(entry);
6066
- fs10.mkdirSync(path9.dirname(ignorePath), { recursive: true });
6067
- fs10.writeFileSync(ignorePath, `${lines.join(`
6889
+ fs11.mkdirSync(path10.dirname(ignorePath), { recursive: true });
6890
+ fs11.writeFileSync(ignorePath, `${lines.join(`
6068
6891
  `).replace(/\s+$/g, "")}
6069
6892
  `, "utf8");
6070
6893
  }
6071
6894
  function removeIgnoreLine(ignorePath, entry) {
6072
- if (!fs10.existsSync(ignorePath))
6895
+ if (!fs11.existsSync(ignorePath))
6073
6896
  return;
6074
- const lines = fs10.readFileSync(ignorePath, "utf8").split(/\r?\n/);
6897
+ const lines = fs11.readFileSync(ignorePath, "utf8").split(/\r?\n/);
6075
6898
  const kept = lines.filter((line) => line !== entry);
6076
6899
  if (kept.length === lines.length)
6077
6900
  return;
6078
6901
  if (kept.every((line) => !line.trim())) {
6079
- fs10.rmSync(ignorePath);
6902
+ fs11.rmSync(ignorePath);
6080
6903
  } else {
6081
- fs10.writeFileSync(ignorePath, `${kept.join(`
6904
+ fs11.writeFileSync(ignorePath, `${kept.join(`
6082
6905
  `).replace(/\s+$/g, "")}
6083
6906
  `, "utf8");
6084
6907
  }
6085
6908
  }
6086
6909
  function hasZedInclusion(root, sidecarPath) {
6087
- const settingsPath2 = path9.join(root, ".zed", "settings.json");
6088
- if (!fs10.existsSync(settingsPath2))
6910
+ const settingsPath2 = path10.join(root, ".zed", "settings.json");
6911
+ if (!fs11.existsSync(settingsPath2))
6089
6912
  return false;
6090
6913
  try {
6091
- const parsed = JSON.parse(fs10.readFileSync(settingsPath2, "utf8"));
6914
+ const parsed = JSON.parse(fs11.readFileSync(settingsPath2, "utf8"));
6092
6915
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
6093
6916
  return false;
6094
6917
  const inclusions = parsed.file_scan_inclusions;
@@ -6102,11 +6925,11 @@ function zedInclusionGlob(sidecarPath) {
6102
6925
  }
6103
6926
  function ensureZedInclusion(root, sidecarPath) {
6104
6927
  const glob = zedInclusionGlob(sidecarPath);
6105
- const settingsPath2 = path9.join(root, ".zed", "settings.json");
6928
+ const settingsPath2 = path10.join(root, ".zed", "settings.json");
6106
6929
  let settings = {};
6107
- if (fs10.existsSync(settingsPath2)) {
6930
+ if (fs11.existsSync(settingsPath2)) {
6108
6931
  try {
6109
- const parsed = JSON.parse(fs10.readFileSync(settingsPath2, "utf8"));
6932
+ const parsed = JSON.parse(fs11.readFileSync(settingsPath2, "utf8"));
6110
6933
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
6111
6934
  return false;
6112
6935
  settings = parsed;
@@ -6118,18 +6941,18 @@ function ensureZedInclusion(root, sidecarPath) {
6118
6941
  if (!inclusions.includes(glob)) {
6119
6942
  inclusions.push(glob);
6120
6943
  settings.file_scan_inclusions = inclusions;
6121
- fs10.mkdirSync(path9.dirname(settingsPath2), { recursive: true });
6122
- fs10.writeFileSync(settingsPath2, `${JSON.stringify(settings, null, 2)}
6944
+ fs11.mkdirSync(path10.dirname(settingsPath2), { recursive: true });
6945
+ fs11.writeFileSync(settingsPath2, `${JSON.stringify(settings, null, 2)}
6123
6946
  `, "utf8");
6124
6947
  }
6125
6948
  return true;
6126
6949
  }
6127
6950
  function removeZedInclusion(root, sidecarPath) {
6128
- const settingsPath2 = path9.join(root, ".zed", "settings.json");
6129
- if (!fs10.existsSync(settingsPath2))
6951
+ const settingsPath2 = path10.join(root, ".zed", "settings.json");
6952
+ if (!fs11.existsSync(settingsPath2))
6130
6953
  return;
6131
6954
  try {
6132
- const settings = JSON.parse(fs10.readFileSync(settingsPath2, "utf8"));
6955
+ const settings = JSON.parse(fs11.readFileSync(settingsPath2, "utf8"));
6133
6956
  if (!settings || typeof settings !== "object" || Array.isArray(settings))
6134
6957
  return;
6135
6958
  const inclusions = settings.file_scan_inclusions;
@@ -6144,17 +6967,17 @@ function removeZedInclusion(root, sidecarPath) {
6144
6967
  } else {
6145
6968
  delete settings.file_scan_inclusions;
6146
6969
  }
6147
- fs10.writeFileSync(settingsPath2, `${JSON.stringify(settings, null, 2)}
6970
+ fs11.writeFileSync(settingsPath2, `${JSON.stringify(settings, null, 2)}
6148
6971
  `, "utf8");
6149
6972
  } catch {
6150
6973
  console.error(`sidecar: warning: could not safely remove the Zed inclusion from ${settingsPath2}`);
6151
6974
  }
6152
6975
  }
6153
6976
  function ignoreEntryForSidecarPath(root, sidecarPath) {
6154
- const resolvedRoot = path9.resolve(root);
6155
- const resolvedSidecarPath = path9.resolve(root, sidecarPath);
6156
- const relative = path9.relative(resolvedRoot, resolvedSidecarPath);
6157
- if (!relative || relative.startsWith("..") || path9.isAbsolute(relative))
6977
+ const resolvedRoot = path10.resolve(root);
6978
+ const resolvedSidecarPath = path10.resolve(root, sidecarPath);
6979
+ const relative = path10.relative(resolvedRoot, resolvedSidecarPath);
6980
+ if (!relative || relative.startsWith("..") || path10.isAbsolute(relative))
6158
6981
  return;
6159
6982
  return relative;
6160
6983
  }
@@ -6170,11 +6993,12 @@ var init_cmd_init = __esm(() => {
6170
6993
  init_ui();
6171
6994
  init_redaction();
6172
6995
  init_rules();
6996
+ init_review();
6173
6997
  });
6174
6998
 
6175
6999
  // src/cmd-refresh.ts
6176
- import fs11 from "node:fs";
6177
- import path10 from "node:path";
7000
+ import fs12 from "node:fs";
7001
+ import path11 from "node:path";
6178
7002
  function worktreeHoldingBranch(repo, branch) {
6179
7003
  const result = git(repo, ["worktree", "list", "--porcelain"], { check: false });
6180
7004
  if (result.status !== 0)
@@ -6202,13 +7026,53 @@ function unpushedCommits(sidecarPath) {
6202
7026
  });
6203
7027
  return counted.status === 0 ? Number(counted.stdout.trim()) || 0 : 0;
6204
7028
  }
6205
- function dependentWorktrees(sidecarPath) {
7029
+ function heldWork(sidecarPath, config, inbox) {
7030
+ const gated = reviewRequired(config);
7031
+ const unpushed = gated ? 0 : unpushedCommits(sidecarPath);
7032
+ const unpublished = gated && !gatedWorkIsPublished(sidecarPath, config, inbox);
7033
+ const dirtyFiles = git(sidecarPath, ["status", "--porcelain"], { check: false }).stdout.split(`
7034
+ `).filter(Boolean).length;
7035
+ return [
7036
+ unpushed ? `${unpushed} commit(s) the remote has not seen` : "",
7037
+ unpublished ? "changes not yet published through review" : "",
7038
+ dirtyFiles ? `${dirtyFiles} uncommitted file(s)` : ""
7039
+ ].filter(Boolean);
7040
+ }
7041
+ function gatedWorkIsPublished(sidecarPath, config, inbox) {
7042
+ if (!hasAnyCommit(sidecarPath))
7043
+ return true;
7044
+ const tree = (ref) => {
7045
+ const result = git(sidecarPath, ["rev-parse", "--verify", "--quiet", `${ref}^{tree}`], { check: false });
7046
+ return result.status === 0 ? result.stdout.trim() : undefined;
7047
+ };
7048
+ const main = `refs/heads/${config.branch}`;
7049
+ const publicMain = tree(`${PUBLIC_REFS}${config.branch}`);
7050
+ const mainTree = tree(main);
7051
+ const mainPublished = mainTree === undefined || mainTree === publicMain;
7052
+ const published = (ref, branch) => {
7053
+ const own = tree(ref);
7054
+ return own === publicMain || own === tree(`${PUBLIC_REFS}${branch}`) || mainPublished && isAncestor(sidecarPath, ref, main);
7055
+ };
7056
+ if (!published("HEAD", inbox))
7057
+ return false;
7058
+ if (!ownsStore(sidecarPath))
7059
+ return true;
7060
+ if (!mainPublished)
7061
+ return false;
7062
+ const prefix = inboxPrefix(config);
7063
+ return git(sidecarPath, ["for-each-ref", "--format=%(refname)", "refs/heads/"]).stdout.split(`
7064
+ `).filter((ref) => ref && matchesInboxPrefix(prefix, ref.slice("refs/heads/".length))).every((ref) => published(ref, ref.slice("refs/heads/".length)));
7065
+ }
7066
+ function ownsStore(sidecarPath) {
6206
7067
  try {
6207
- if (!fs11.statSync(path10.join(sidecarPath, ".git")).isDirectory())
6208
- return [];
7068
+ return fs12.statSync(path11.join(sidecarPath, ".git")).isDirectory();
6209
7069
  } catch {
6210
- return [];
7070
+ return false;
6211
7071
  }
7072
+ }
7073
+ function dependentWorktrees(sidecarPath) {
7074
+ if (!ownsStore(sidecarPath))
7075
+ return [];
6212
7076
  const result = git(sidecarPath, ["worktree", "list", "--porcelain"], { check: false });
6213
7077
  if (result.status !== 0)
6214
7078
  return [];
@@ -6219,11 +7083,11 @@ function existingCheckoutId(sidecarPath) {
6219
7083
  const candidates = [];
6220
7084
  const reported = git(sidecarPath, ["rev-parse", "--git-dir"], { check: false });
6221
7085
  if (reported.status === 0)
6222
- candidates.push(path10.resolve(sidecarPath, reported.stdout.trim()));
6223
- candidates.push(path10.join(sidecarPath, ".git"));
7086
+ candidates.push(path11.resolve(sidecarPath, reported.stdout.trim()));
7087
+ candidates.push(path11.join(sidecarPath, ".git"));
6224
7088
  for (const candidate of candidates) {
6225
7089
  try {
6226
- const id = slug(fs11.readFileSync(path10.join(candidate, "sidecar-id"), "utf8"));
7090
+ const id = slug(fs12.readFileSync(path11.join(candidate, "sidecar-id"), "utf8"));
6227
7091
  if (id)
6228
7092
  return id;
6229
7093
  } catch {}
@@ -6235,12 +7099,20 @@ function refreshCheckout(root, config) {
6235
7099
  if (isStandalone(config)) {
6236
7100
  throw new SidecarError("refusing to delete a standalone sidecar, which is the repo itself");
6237
7101
  }
6238
- const relative = path10.relative(root, sidecarPath);
6239
- if (!relative || relative.startsWith("..") || path10.isAbsolute(relative)) {
7102
+ const relative = path11.relative(root, sidecarPath);
7103
+ if (!relative || relative.startsWith("..") || path11.isAbsolute(relative)) {
6240
7104
  throw new SidecarError(`refusing to delete ${sidecarPath}, which is not inside ${root}`);
6241
7105
  }
6242
7106
  const checkoutId = existingCheckoutId(sidecarPath);
6243
- fs11.rmSync(sidecarPath, { recursive: true, force: true });
7107
+ if (ownsStore(sidecarPath)) {
7108
+ let open;
7109
+ try {
7110
+ open = openReview(sidecarPath);
7111
+ } catch {}
7112
+ if (open)
7113
+ markSuperseded(open);
7114
+ }
7115
+ fs12.rmSync(sidecarPath, { recursive: true, force: true });
6244
7116
  const family = familySidecarCheckout(root, config);
6245
7117
  if (family) {
6246
7118
  git(family, ["worktree", "prune", "--expire", "now"], { check: false });
@@ -6301,16 +7173,17 @@ function refreshPeer({ root, config, name }, parsed) {
6301
7173
  let inbox;
6302
7174
  if (readable) {
6303
7175
  inbox = expandInbox(config, sidecarPath);
6304
- fetch(sidecarPath, true, false);
6305
- const unpushed = unpushedCommits(sidecarPath);
6306
- const dirtyFiles = git(sidecarPath, ["status", "--porcelain"], { check: false }).stdout.split(`
6307
- `).filter(Boolean).length;
6308
- if ((unpushed || dirtyFiles) && !force) {
6309
- const held = [
6310
- unpushed ? `${unpushed} commit(s) the remote has not seen` : "",
6311
- dirtyFiles ? `${dirtyFiles} uncommitted file(s)` : ""
6312
- ].filter(Boolean);
6313
- throw new SidecarError(`this checkout still holds ${held.join(" and ")}; run \`sidecar sync\` to push them, then refresh — or \`sidecar refresh --force\` to discard them`);
7176
+ if (reviewRequired(config)) {
7177
+ try {
7178
+ refreshPublicRefs(sidecarPath);
7179
+ } catch {}
7180
+ } else {
7181
+ fetch(sidecarPath, true, false);
7182
+ }
7183
+ const held = heldWork(sidecarPath, config, inbox);
7184
+ if (held.length && !force) {
7185
+ const publish = reviewRequired(config) ? "approve its review (`sidecar review`) and sync to publish them" : "run `sidecar sync` to push them";
7186
+ throw new SidecarError(`this checkout still holds ${held.join(" and ")}; ${publish}, then refresh — or \`sidecar refresh --force\` to discard them`);
6314
7187
  }
6315
7188
  }
6316
7189
  if (standalone) {
@@ -6337,7 +7210,7 @@ function refreshPeer({ root, config, name }, parsed) {
6337
7210
  }
6338
7211
  let declined;
6339
7212
  withSyncLock(root, name, "throw", () => {
6340
- if (readable && !force && isDirty(sidecarPath)) {
7213
+ if (readable && !force && inbox && heldWork(sidecarPath, config, inbox).length) {
6341
7214
  throw new SidecarError("the sidecar checkout changed while waiting for confirmation; rerun refresh");
6342
7215
  }
6343
7216
  if (standalone)
@@ -6359,10 +7232,11 @@ var init_cmd_refresh = __esm(() => {
6359
7232
  init_sync();
6360
7233
  init_ui();
6361
7234
  init_rules();
7235
+ init_review();
6362
7236
  });
6363
7237
 
6364
7238
  // src/cmd-status.ts
6365
- import fs12 from "node:fs";
7239
+ import fs13 from "node:fs";
6366
7240
  function statusLine(label, value, role) {
6367
7241
  labelLine(STATUS_LABEL_WIDTH, label, value, role);
6368
7242
  }
@@ -6395,6 +7269,8 @@ function printPeerStatus({ root, config, configPath }) {
6395
7269
  statusLine("main branch", config.branch);
6396
7270
  statusLine("inbox branch", inbox);
6397
7271
  statusLine("conflicts", config.resolve === "lww" ? "last writer wins" : "fork files");
7272
+ if (reviewRequired(config))
7273
+ printReviewLine(sidecarPath);
6398
7274
  if (!checkoutPresent) {
6399
7275
  statusLine("checkout", "missing — run `sidecar init`", "bad");
6400
7276
  printDaemonLine();
@@ -6425,6 +7301,36 @@ function printPeerStatus({ root, config, configPath }) {
6425
7301
  statusLine("pending inbox", "none", "quiet");
6426
7302
  }
6427
7303
  }
7304
+ function printReviewLine(sidecarPath) {
7305
+ const review = hasGitMetadata(sidecarPath) ? openReview(sidecarPath) : undefined;
7306
+ const reviewer = readSettings().reviewer;
7307
+ if (!review) {
7308
+ if (reviewer)
7309
+ statusLine("review", `required — nothing waiting (reviewer: ${reviewer})`, "quiet");
7310
+ else
7311
+ statusLine("review", "required — no reviewer configured; reviews wait for `sidecar review approve`", "attn");
7312
+ return;
7313
+ }
7314
+ const attention = reviewAttention(review);
7315
+ const age = formatRelativeTime(review.record.createdAt) ?? review.record.createdAt;
7316
+ const summary = `${review.state} ${review.record.id}, opened ${age}, ${review.record.files.length} file(s)`;
7317
+ statusLine("review", attention ? `${summary} — needs attention: ${attention}` : summary, attention ? "attn" : undefined);
7318
+ if (review.verdict?.reason)
7319
+ console.log(` ${paint("quiet", review.verdict.reason)}`);
7320
+ console.log(` ${paint("quiet", `sidecar review show ${review.record.id}`)}`);
7321
+ }
7322
+ function openReviewPayload(sidecarPath) {
7323
+ const review = openReview(sidecarPath);
7324
+ if (!review)
7325
+ return;
7326
+ return {
7327
+ id: review.record.id,
7328
+ state: review.state,
7329
+ attention: reviewAttention(review),
7330
+ createdAt: review.record.createdAt,
7331
+ files: review.record.files.length
7332
+ };
7333
+ }
6428
7334
  function statusPayload({ root, name, config, configPath }) {
6429
7335
  const sidecarPath = resolveSidecarPath(root, config);
6430
7336
  const checkoutPresent = hasGitMetadata(sidecarPath);
@@ -6445,13 +7351,15 @@ function statusPayload({ root, name, config, configPath }) {
6445
7351
  familyLinked: checkoutPresent ? !checkoutIsUnlinkedFromFamily(root, config, sidecarPath) : undefined,
6446
7352
  daemon: daemonHealth().text,
6447
7353
  lastSyncAt: readInstances().find((instance) => instance.configPath === configPath)?.lastSyncAt,
6448
- pendingInbox: checkoutPresent ? pendingStatusInboxBranches(sidecarPath, config) : undefined
7354
+ pendingInbox: checkoutPresent ? pendingStatusInboxBranches(sidecarPath, config) : undefined,
7355
+ review: reviewRequired(config) ? { required: true, open: checkoutPresent ? openReviewPayload(sidecarPath) : undefined } : undefined
6449
7356
  };
6450
7357
  }
6451
7358
  function pendingStatusInboxBranches(sidecarPath, config) {
6452
7359
  fetch(sidecarPath, true, false);
6453
7360
  const base = remoteRefExists(sidecarPath, config.branch) ? `origin/${config.branch}` : branchExists(sidecarPath, config.branch) ? config.branch : "HEAD";
6454
- return pendingInboxBranches(sidecarPath, config).filter((remoteBranch) => !isAncestor(sidecarPath, remoteBranch, base));
7361
+ const candidates = reviewRequired(config) ? pendingInboxBranches(sidecarPath, config).filter((branch) => branch.startsWith("origin/")) : pendingInboxBranches(sidecarPath, config);
7362
+ return candidates.filter((remoteBranch) => !isAncestor(sidecarPath, remoteBranch, base));
6455
7363
  }
6456
7364
  function daemonHealth() {
6457
7365
  if (!shouldUseGlobalRegistry()) {
@@ -6553,6 +7461,10 @@ function healthStatusLine(state, record) {
6553
7461
  const age = formatRelativeTime(record.updatedAt) ?? record.updatedAt;
6554
7462
  return { text: `stale — last reported ${age}`, role: "attn" };
6555
7463
  }
7464
+ if (record.review === "attention")
7465
+ return { text: "ok — publishing held, review needs attention", role: "attn" };
7466
+ if (record.review === "pending")
7467
+ return { text: "ok — publishing held for review", role: "ok" };
6556
7468
  return { text: "ok", role: "ok" };
6557
7469
  }
6558
7470
  function cmdInstances(args) {
@@ -6606,16 +7518,16 @@ function cmdTail(args) {
6606
7518
  throw new SidecarError("--lines requires a positive integer");
6607
7519
  }
6608
7520
  const filePath = sidecarLogPath();
6609
- if (!fs12.existsSync(filePath)) {
7521
+ if (!fs13.existsSync(filePath)) {
6610
7522
  if (parsed.flags.has("-f") || parsed.flags.has("--follow")) {
6611
7523
  followLog(filePath, 0);
6612
7524
  return 0;
6613
7525
  }
6614
7526
  return 0;
6615
7527
  }
6616
- const stat = fs12.statSync(filePath);
7528
+ const stat = fs13.statSync(filePath);
6617
7529
  if (stat.size > 0) {
6618
- process.stdout.write(lastLines(fs12.readFileSync(filePath, "utf8"), lines));
7530
+ process.stdout.write(lastLines(fs13.readFileSync(filePath, "utf8"), lines));
6619
7531
  }
6620
7532
  if (parsed.flags.has("-f") || parsed.flags.has("--follow")) {
6621
7533
  followLog(filePath, stat.size);
@@ -6638,7 +7550,7 @@ function followLog(filePath, startOffset) {
6638
7550
  sleep(1000);
6639
7551
  let stat;
6640
7552
  try {
6641
- stat = fs12.statSync(filePath);
7553
+ stat = fs13.statSync(filePath);
6642
7554
  } catch {
6643
7555
  offset = 0;
6644
7556
  continue;
@@ -6647,17 +7559,17 @@ function followLog(filePath, startOffset) {
6647
7559
  offset = 0;
6648
7560
  if (stat.size <= offset)
6649
7561
  continue;
6650
- const fd = fs12.openSync(filePath, "r");
7562
+ const fd = fs13.openSync(filePath, "r");
6651
7563
  try {
6652
7564
  const length = stat.size - offset;
6653
7565
  const buffer = Buffer.alloc(length);
6654
- const bytesRead = fs12.readSync(fd, buffer, 0, length, offset);
7566
+ const bytesRead = fs13.readSync(fd, buffer, 0, length, offset);
6655
7567
  if (bytesRead > 0) {
6656
7568
  process.stdout.write(buffer.subarray(0, bytesRead).toString("utf8"));
6657
7569
  offset += bytesRead;
6658
7570
  }
6659
7571
  } finally {
6660
- fs12.closeSync(fd);
7572
+ fs13.closeSync(fd);
6661
7573
  }
6662
7574
  }
6663
7575
  }
@@ -6676,6 +7588,7 @@ var init_cmd_status = __esm(() => {
6676
7588
  init_sync();
6677
7589
  init_ui();
6678
7590
  init_health();
7591
+ init_review();
6679
7592
  STATUS_LABEL_WIDTH = "pending inbox:".length;
6680
7593
  });
6681
7594
 
@@ -7064,20 +7977,20 @@ var require_ignore = __commonJS((exports, module) => {
7064
7977
  const index = body.indexOf(SLASH);
7065
7978
  return index < 0 || index === body.length - 1;
7066
7979
  };
7067
- var basenameOf = (path11) => {
7068
- const end = path11.length - 1;
7069
- const index = path11.lastIndexOf(SLASH, path11[end] === SLASH ? end - 1 : end);
7070
- return index < 0 ? path11 : path11.slice(index + 1);
7980
+ var basenameOf = (path12) => {
7981
+ const end = path12.length - 1;
7982
+ const index = path12.lastIndexOf(SLASH, path12[end] === SLASH ? end - 1 : end);
7983
+ return index < 0 ? path12 : path12.slice(index + 1);
7071
7984
  };
7072
- var parentOf = (path11) => {
7073
- if (path11.charCodeAt(0) === SLASH_CODE || path11.indexOf(DOUBLE_SLASH) >= 0) {
7074
- const slices = path11.split(SLASH).filter(Boolean);
7985
+ var parentOf = (path12) => {
7986
+ if (path12.charCodeAt(0) === SLASH_CODE || path12.indexOf(DOUBLE_SLASH) >= 0) {
7987
+ const slices = path12.split(SLASH).filter(Boolean);
7075
7988
  slices.pop();
7076
7989
  return slices.length ? slices.join(SLASH) + SLASH : EMPTY;
7077
7990
  }
7078
- const end = path11.length - 1;
7079
- const cut = path11.lastIndexOf(SLASH, path11.charCodeAt(end) === SLASH_CODE ? end - 1 : end);
7080
- return cut < 0 ? EMPTY : path11.slice(0, cut + 1);
7991
+ const end = path12.length - 1;
7992
+ const cut = path12.lastIndexOf(SLASH, path12.charCodeAt(end) === SLASH_CODE ? end - 1 : end);
7993
+ return cut < 0 ? EMPTY : path12.slice(0, cut + 1);
7081
7994
  };
7082
7995
  var isString = (subject) => typeof subject === "string";
7083
7996
  var checkPattern = (pattern) => pattern && isString(pattern) && !REGEX_TEST_BLANK_LINE.test(pattern) && !REGEX_INVALID_TRAILING_BACKSLASH.test(pattern) && pattern.indexOf("#") !== 0;
@@ -7162,19 +8075,19 @@ var require_ignore = __commonJS((exports, module) => {
7162
8075
  makeArray(isString(pattern) ? splitPattern(pattern) : pattern).forEach(this._add, this);
7163
8076
  return this._added;
7164
8077
  }
7165
- test(path11, checkUnignored, mode) {
8078
+ test(path12, checkUnignored, mode) {
7166
8079
  let ignored = false;
7167
8080
  let unignored = false;
7168
8081
  let matchedRule;
7169
8082
  const rules = this._rules;
7170
8083
  const { length } = rules;
7171
8084
  const shortcut = this._basenameCount * 2 >= length;
7172
- const basename = shortcut ? basenameOf(path11) : path11;
8085
+ const basename = shortcut ? basenameOf(path12) : path12;
7173
8086
  for (let index = 0;index < length; index++) {
7174
8087
  const rule = rules[index];
7175
8088
  const { negative } = rule;
7176
8089
  const skip = unignored === negative && ignored !== unignored || negative && !ignored && !unignored && !checkUnignored;
7177
- if (!skip && rule[mode].test(shortcut && rule._basenameOnly ? basename : path11)) {
8090
+ if (!skip && rule[mode].test(shortcut && rule._basenameOnly ? basename : path12)) {
7178
8091
  ignored = !negative;
7179
8092
  unignored = negative;
7180
8093
  matchedRule = negative ? UNDEFINED : rule;
@@ -7193,38 +8106,38 @@ var require_ignore = __commonJS((exports, module) => {
7193
8106
  var throwError = (message, Ctor) => {
7194
8107
  throw new Ctor(message);
7195
8108
  };
7196
- var checkPath = (path11, originalPath, doThrow) => {
7197
- if (!isString(path11)) {
8109
+ var checkPath = (path12, originalPath, doThrow) => {
8110
+ if (!isString(path12)) {
7198
8111
  return doThrow(`path must be a string, but got \`${originalPath}\``, TypeError);
7199
8112
  }
7200
- if (!path11) {
8113
+ if (!path12) {
7201
8114
  return doThrow(`path must not be empty`, TypeError);
7202
8115
  }
7203
- if (checkPath.isNotRelative(path11)) {
8116
+ if (checkPath.isNotRelative(path12)) {
7204
8117
  const r = "`path.relative()`d";
7205
8118
  return doThrow(`path should be a ${r} string, but got "${originalPath}"`, RangeError);
7206
8119
  }
7207
8120
  return true;
7208
8121
  };
7209
- var isNotRelative = (path11) => {
7210
- const first = path11.charCodeAt(0);
8122
+ var isNotRelative = (path12) => {
8123
+ const first = path12.charCodeAt(0);
7211
8124
  if (first === SLASH_CODE) {
7212
8125
  return true;
7213
8126
  }
7214
8127
  if (first !== DOT_CODE) {
7215
8128
  return false;
7216
8129
  }
7217
- if (path11.length === 1) {
8130
+ if (path12.length === 1) {
7218
8131
  return true;
7219
8132
  }
7220
- const second = path11.charCodeAt(1);
8133
+ const second = path12.charCodeAt(1);
7221
8134
  if (second === SLASH_CODE) {
7222
8135
  return true;
7223
8136
  }
7224
8137
  if (second !== DOT_CODE) {
7225
8138
  return false;
7226
8139
  }
7227
- return path11.length === 2 || path11.charCodeAt(2) === SLASH_CODE;
8140
+ return path12.length === 2 || path12.charCodeAt(2) === SLASH_CODE;
7228
8141
  };
7229
8142
  checkPath.isNotRelative = isNotRelative;
7230
8143
  checkPath.convert = (p) => p;
@@ -7254,51 +8167,51 @@ var require_ignore = __commonJS((exports, module) => {
7254
8167
  return this.add(pattern);
7255
8168
  }
7256
8169
  _test(originalPath, cache, checkUnignored) {
7257
- const path11 = originalPath && checkPath.convert(originalPath);
7258
- checkPath(path11, originalPath, this._strictPathCheck ? throwError : RETURN_FALSE);
7259
- return this._t(path11, cache, checkUnignored);
8170
+ const path12 = originalPath && checkPath.convert(originalPath);
8171
+ checkPath(path12, originalPath, this._strictPathCheck ? throwError : RETURN_FALSE);
8172
+ return this._t(path12, cache, checkUnignored);
7260
8173
  }
7261
- checkIgnore(path11) {
7262
- if (path11.charCodeAt(path11.length - 1) !== SLASH_CODE) {
7263
- return this.test(path11);
8174
+ checkIgnore(path12) {
8175
+ if (path12.charCodeAt(path12.length - 1) !== SLASH_CODE) {
8176
+ return this.test(path12);
7264
8177
  }
7265
- const parentPath = parentOf(path11);
8178
+ const parentPath = parentOf(path12);
7266
8179
  if (parentPath) {
7267
8180
  const parent = this._t(parentPath, this._testCache, true);
7268
8181
  if (parent.ignored) {
7269
8182
  return parent;
7270
8183
  }
7271
8184
  }
7272
- return this._rules.test(path11, false, MODE_CHECK_IGNORE);
8185
+ return this._rules.test(path12, false, MODE_CHECK_IGNORE);
7273
8186
  }
7274
- _t(path11, cache, checkUnignored) {
7275
- if (path11 in cache) {
7276
- return cache[path11];
8187
+ _t(path12, cache, checkUnignored) {
8188
+ if (path12 in cache) {
8189
+ return cache[path12];
7277
8190
  }
7278
- const parentPath = parentOf(path11);
8191
+ const parentPath = parentOf(path12);
7279
8192
  const parent = parentPath ? this._t(parentPath, cache, checkUnignored) : UNDEFINED;
7280
- return cache[path11] = parent && parent.ignored ? parent : this._rules.test(path11, checkUnignored, MODE_IGNORE);
8193
+ return cache[path12] = parent && parent.ignored ? parent : this._rules.test(path12, checkUnignored, MODE_IGNORE);
7281
8194
  }
7282
- ignores(path11) {
7283
- return this._test(path11, this._ignoreCache, false).ignored;
8195
+ ignores(path12) {
8196
+ return this._test(path12, this._ignoreCache, false).ignored;
7284
8197
  }
7285
8198
  createFilter() {
7286
- return (path11) => !this.ignores(path11);
8199
+ return (path12) => !this.ignores(path12);
7287
8200
  }
7288
8201
  filter(paths) {
7289
8202
  return makeArray(paths).filter(this.createFilter());
7290
8203
  }
7291
- test(path11) {
7292
- return this._test(path11, this._testCache, true);
8204
+ test(path12) {
8205
+ return this._test(path12, this._testCache, true);
7293
8206
  }
7294
8207
  }
7295
8208
  var factory = (options) => new Ignore(options);
7296
- var isPathValid = (path11) => checkPath(path11 && checkPath.convert(path11), path11, RETURN_FALSE);
8209
+ var isPathValid = (path12) => checkPath(path12 && checkPath.convert(path12), path12, RETURN_FALSE);
7297
8210
  var setupWindows = () => {
7298
8211
  const makePosix = (str) => /^\\\\\?\\/.test(str) || /["<>|\u0000-\u001F]+/u.test(str) ? str : str.replace(/\\/g, "/");
7299
8212
  checkPath.convert = makePosix;
7300
8213
  const REGEX_TEST_WINDOWS_PATH_ABSOLUTE = /^[a-z]:\//i;
7301
- checkPath.isNotRelative = (path11) => REGEX_TEST_WINDOWS_PATH_ABSOLUTE.test(path11) || isNotRelative(path11);
8214
+ checkPath.isNotRelative = (path12) => REGEX_TEST_WINDOWS_PATH_ABSOLUTE.test(path12) || isNotRelative(path12);
7302
8215
  };
7303
8216
  if (typeof process !== "undefined" && process.platform === "win32") {
7304
8217
  setupWindows();
@@ -7321,15 +8234,22 @@ __export(exports_daemon, {
7321
8234
  checkAndInstallUpdate: () => checkAndInstallUpdate,
7322
8235
  WATCH_LIMIT: () => WATCH_LIMIT
7323
8236
  });
7324
- import fs13 from "node:fs";
7325
- import path11 from "node:path";
7326
- import { spawn as spawn2 } from "node:child_process";
7327
- import { fileURLToPath as fileURLToPath3 } from "node:url";
8237
+ import fs14 from "node:fs";
8238
+ import path12 from "node:path";
8239
+ import { spawn as spawn3 } from "node:child_process";
8240
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
8241
+ function outputIsPrivate(key) {
8242
+ try {
8243
+ return reviewRequired(readConfig(key, { loadRules: false }));
8244
+ } catch {
8245
+ return true;
8246
+ }
8247
+ }
7328
8248
  function peerRoot(key) {
7329
- return path11.dirname(key);
8249
+ return path12.dirname(key);
7330
8250
  }
7331
8251
  function peerName(key) {
7332
- return peerNameOf(path11.basename(key)) ?? DEFAULT_PEER;
8252
+ return peerNameOf(path12.basename(key)) ?? DEFAULT_PEER;
7333
8253
  }
7334
8254
  function peerFields(key) {
7335
8255
  const peer = peerName(key);
@@ -7417,7 +8337,7 @@ function maybeAdoptNewerInstall(state, bootVersion) {
7417
8337
  executable: onPath,
7418
8338
  reason: "new-install"
7419
8339
  });
7420
- const child = spawn2(onPath, ["daemon", "restart"], {
8340
+ const child = spawn3(onPath, ["daemon", "restart"], {
7421
8341
  detached: true,
7422
8342
  stdio: "ignore",
7423
8343
  windowsHide: true,
@@ -7437,7 +8357,7 @@ async function runCycle(state) {
7437
8357
  let skipped = 0;
7438
8358
  for (const instance of readInstances()) {
7439
8359
  const key = instance.configPath;
7440
- if (!fs13.existsSync(instance.configPath)) {
8360
+ if (!fs14.existsSync(instance.configPath)) {
7441
8361
  const misses = (state.misses.get(key) ?? 0) + 1;
7442
8362
  state.misses.set(key, misses);
7443
8363
  if (misses >= PRUNE_AFTER_MISSES) {
@@ -7524,7 +8444,7 @@ async function syncInstance(state, key, trigger, options = {}) {
7524
8444
  command: "daemon",
7525
8445
  ...peerFields(key),
7526
8446
  trigger,
7527
- message: result.timedOut ? "sync timed out" : result.output.trim().slice(-500) || `sync exited ${result.status}`
8447
+ message: result.timedOut ? "sync timed out" : !outputIsPrivate(key) && result.output.trim().slice(-500) || `sync exited ${result.status}`
7528
8448
  });
7529
8449
  }
7530
8450
  } finally {
@@ -7551,7 +8471,7 @@ async function syncIfDirty(state, key, trigger) {
7551
8471
  }
7552
8472
  async function checkoutIsDirty(key) {
7553
8473
  const sidecarPath = readInstances().find((instance) => instance.configPath === key)?.sidecarPath;
7554
- if (!sidecarPath || !fs13.existsSync(sidecarPath))
8474
+ if (!sidecarPath || !fs14.existsSync(sidecarPath))
7555
8475
  return false;
7556
8476
  const result = await runChild("git", ["-C", sidecarPath, "status", "--porcelain"], { timeoutMs: 30000 });
7557
8477
  return result.status === 0 && Boolean(result.stdout.trim());
@@ -7559,7 +8479,7 @@ async function checkoutIsDirty(key) {
7559
8479
  function localSidecarCliPath(root) {
7560
8480
  if (!projectDependsOnSidecar(root))
7561
8481
  return;
7562
- const candidate = path11.join(root, "node_modules", PACKAGE_NAME, "dist", "cli.js");
8482
+ const candidate = path12.join(root, "node_modules", PACKAGE_NAME, "dist", "cli.js");
7563
8483
  if (!isFile(candidate))
7564
8484
  return;
7565
8485
  const localVersion = installedPackageVersion(root);
@@ -7568,10 +8488,10 @@ function localSidecarCliPath(root) {
7568
8488
  return candidate;
7569
8489
  }
7570
8490
  function currentCliPath() {
7571
- return process.argv[1] || fileURLToPath3(import.meta.url);
8491
+ return process.argv[1] || fileURLToPath2(import.meta.url);
7572
8492
  }
7573
8493
  function selectWatchTargets(instances, limit = WATCH_LIMIT) {
7574
- return [...instances].filter((instance) => fs13.existsSync(instance.configPath) && fs13.existsSync(instance.sidecarPath)).sort((left, right) => instanceRecency(right) - instanceRecency(left)).slice(0, limit);
8494
+ return [...instances].filter((instance) => fs14.existsSync(instance.configPath) && fs14.existsSync(instance.sidecarPath)).sort((left, right) => instanceRecency(right) - instanceRecency(left)).slice(0, limit);
7575
8495
  }
7576
8496
  function instanceRecency(instance) {
7577
8497
  const time = Date.parse(instance.lastSyncAt ?? instance.updatedAt ?? instance.registeredAt);
@@ -7622,8 +8542,8 @@ async function refreshWatchers(state) {
7622
8542
  });
7623
8543
  watcher.on("all", (...args) => {
7624
8544
  scheduleWatchSync(state, key);
7625
- const changedPath = typeof args[1] === "string" ? path11.resolve(args[1]) : "";
7626
- if (changedPath !== path11.join(path11.resolve(sidecarPath), ".gitignore"))
8545
+ const changedPath = typeof args[1] === "string" ? path12.resolve(args[1]) : "";
8546
+ if (changedPath !== path12.join(path12.resolve(sidecarPath), ".gitignore"))
7627
8547
  return;
7628
8548
  if (state.watchers.get(key) !== watcher)
7629
8549
  return;
@@ -7636,7 +8556,7 @@ async function refreshWatchers(state) {
7636
8556
  logSidecarEvent("failure", {
7637
8557
  command: "daemon",
7638
8558
  ...peerFields(key),
7639
- message: `watcher error: ${error instanceof Error ? error.message : String(error)}`
8559
+ message: outputIsPrivate(key) ? "watcher error" : `watcher error: ${error instanceof Error ? error.message : String(error)}`
7640
8560
  });
7641
8561
  });
7642
8562
  state.watchers.set(key, watcher);
@@ -7644,7 +8564,7 @@ async function refreshWatchers(state) {
7644
8564
  logSidecarEvent("failure", {
7645
8565
  command: "daemon",
7646
8566
  ...peerFields(key),
7647
- message: `could not watch ${sidecarPath}: ${error instanceof Error ? error.message : String(error)}`
8567
+ message: outputIsPrivate(key) ? `could not watch ${sidecarPath}` : `could not watch ${sidecarPath}: ${error instanceof Error ? error.message : String(error)}`
7648
8568
  });
7649
8569
  }
7650
8570
  }
@@ -7717,7 +8637,7 @@ async function watchRegistry(state) {
7717
8637
  const watcher = chokidar.watch(sidecarStateDir(), { ignoreInitial: true, depth: 0 });
7718
8638
  watcher.on("all", (...args) => {
7719
8639
  const filePath = typeof args[1] === "string" ? args[1] : "";
7720
- if (path11.basename(filePath) !== "instances.json")
8640
+ if (path12.basename(filePath) !== "instances.json")
7721
8641
  return;
7722
8642
  if (state.registryTimer)
7723
8643
  return;
@@ -7736,7 +8656,7 @@ async function watchRegistry(state) {
7736
8656
  function compileGitignoreMatcher(lines) {
7737
8657
  const rules = import_ignore.default({ ignorecase: false }).add(lines);
7738
8658
  return (relativePath) => {
7739
- const normalized = relativePath.split(path11.sep).join("/");
8659
+ const normalized = relativePath.split(path12.sep).join("/");
7740
8660
  if (!import_ignore.default.isPathValid(normalized))
7741
8661
  return false;
7742
8662
  return rules.ignores(normalized);
@@ -7745,19 +8665,19 @@ function compileGitignoreMatcher(lines) {
7745
8665
  function watchIgnoreMatcher(sidecarPath) {
7746
8666
  let gitignore;
7747
8667
  try {
7748
- const ignoreFile = path11.join(sidecarPath, ".gitignore");
7749
- if (fs13.existsSync(ignoreFile)) {
7750
- gitignore = compileGitignoreMatcher(fs13.readFileSync(ignoreFile, "utf8").split(`
8668
+ const ignoreFile = path12.join(sidecarPath, ".gitignore");
8669
+ if (fs14.existsSync(ignoreFile)) {
8670
+ gitignore = compileGitignoreMatcher(fs14.readFileSync(ignoreFile, "utf8").split(`
7751
8671
  `));
7752
8672
  }
7753
8673
  } catch {}
7754
- const root = path11.resolve(sidecarPath);
8674
+ const root = path12.resolve(sidecarPath);
7755
8675
  return (candidate, stats) => {
7756
- const relative = path11.relative(root, candidate);
8676
+ const relative = path12.relative(root, candidate);
7757
8677
  if (!relative)
7758
8678
  return false;
7759
- const normalized = relative.split(path11.sep).join("/");
7760
- if (normalized === ".." || normalized.startsWith("../") || path11.isAbsolute(relative))
8679
+ const normalized = relative.split(path12.sep).join("/");
8680
+ if (normalized === ".." || normalized.startsWith("../") || path12.isAbsolute(relative))
7761
8681
  return true;
7762
8682
  if (normalized === ".git" || normalized.startsWith(".git/"))
7763
8683
  return true;
@@ -7836,10 +8756,10 @@ function restartAfterUpdate() {
7836
8756
  }
7837
8757
  async function acquireDaemonPid() {
7838
8758
  const pidPath = daemonPidPath();
7839
- fs13.mkdirSync(path11.dirname(pidPath), { recursive: true });
8759
+ fs14.mkdirSync(path12.dirname(pidPath), { recursive: true });
7840
8760
  while (true) {
7841
8761
  try {
7842
- fs13.writeFileSync(pidPath, `${process.pid}
8762
+ fs14.writeFileSync(pidPath, `${process.pid}
7843
8763
  `, { encoding: "utf8", flag: "wx" });
7844
8764
  return;
7845
8765
  } catch (error) {
@@ -7855,7 +8775,7 @@ async function acquireDaemonPid() {
7855
8775
  continue;
7856
8776
  }
7857
8777
  logSidecarEvent("daemon-pid-heal", { holder: holder ?? null });
7858
- fs13.rmSync(pidPath, { force: true });
8778
+ fs14.rmSync(pidPath, { force: true });
7859
8779
  }
7860
8780
  }
7861
8781
  function installShutdownHandlers() {
@@ -7870,12 +8790,12 @@ function installShutdownHandlers() {
7870
8790
  function removeOwnPidFile() {
7871
8791
  try {
7872
8792
  if (readPid(daemonPidPath()) === process.pid)
7873
- fs13.rmSync(daemonPidPath(), { force: true });
8793
+ fs14.rmSync(daemonPidPath(), { force: true });
7874
8794
  } catch {}
7875
8795
  }
7876
8796
  function readPid(pidPath) {
7877
8797
  try {
7878
- const pid = Number(fs13.readFileSync(pidPath, "utf8").trim());
8798
+ const pid = Number(fs14.readFileSync(pidPath, "utf8").trim());
7879
8799
  return Number.isInteger(pid) && pid > 0 ? pid : undefined;
7880
8800
  } catch {
7881
8801
  return;
@@ -7883,7 +8803,7 @@ function readPid(pidPath) {
7883
8803
  }
7884
8804
  function runChild(command, args, options) {
7885
8805
  return new Promise((resolve) => {
7886
- const child = spawn2(command, args, {
8806
+ const child = spawn3(command, args, {
7887
8807
  cwd: options.cwd,
7888
8808
  env: options.env ?? process.env,
7889
8809
  stdio: ["ignore", "pipe", "pipe"],
@@ -7916,21 +8836,21 @@ function runChild(command, args, options) {
7916
8836
  }
7917
8837
  function isFile(filePath) {
7918
8838
  try {
7919
- return fs13.statSync(filePath).isFile();
8839
+ return fs14.statSync(filePath).isFile();
7920
8840
  } catch {
7921
8841
  return false;
7922
8842
  }
7923
8843
  }
7924
8844
  function realpathOr2(filePath) {
7925
8845
  try {
7926
- return fs13.realpathSync(filePath);
8846
+ return fs14.realpathSync(filePath);
7927
8847
  } catch {
7928
- return path11.resolve(filePath);
8848
+ return path12.resolve(filePath);
7929
8849
  }
7930
8850
  }
7931
8851
  function isInsidePath2(child, parent) {
7932
- const relative = path11.relative(parent, child);
7933
- return Boolean(relative) && !relative.startsWith("..") && !path11.isAbsolute(relative);
8852
+ const relative = path12.relative(parent, child);
8853
+ return Boolean(relative) && !relative.startsWith("..") && !path12.isAbsolute(relative);
7934
8854
  }
7935
8855
  function delay(ms) {
7936
8856
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -8132,9 +9052,9 @@ var init_cmd_daemon = __esm(() => {
8132
9052
  });
8133
9053
 
8134
9054
  // src/cmd-sync.ts
8135
- import fs14 from "node:fs";
9055
+ import fs15 from "node:fs";
8136
9056
  import os6 from "node:os";
8137
- import path12 from "node:path";
9057
+ import path13 from "node:path";
8138
9058
  function cmdSnapshot(args) {
8139
9059
  const parsed = parseOptions(args, {
8140
9060
  boolean: new Set(["--push"]),
@@ -8143,17 +9063,23 @@ function cmdSnapshot(args) {
8143
9063
  if (parsed.positional.length)
8144
9064
  throw new SidecarError("usage: sidecar snapshot [--push] [-m message] [--peer name]");
8145
9065
  const peers = loadPeers(selectedPeer(parsed));
9066
+ const push = parsed.flags.has("--push");
9067
+ const gated = push ? peers.filter((peer) => reviewRequired(peer.config)) : [];
9068
+ if (gated.length) {
9069
+ throw new SidecarError(`${peers.length === 1 ? "this peer requires" : `${gated.map((peer) => peer.name).join(", ")} require`} review before anything is published; snapshot without --push, and \`sidecar sync\` opens or advances the review`);
9070
+ }
8146
9071
  for (const peer of peers) {
8147
9072
  announcePeer(peer, peers);
8148
9073
  const { root, config } = peer;
8149
9074
  const sidecarPath = requireSidecarCheckout(root, config);
8150
9075
  withSyncLock(root, peer.name, "throw", () => {
9076
+ bindReviewPolicy(sidecarPath, config);
8151
9077
  const inbox = expandInbox(config, sidecarPath);
8152
9078
  ensureCommitIdentity(sidecarPath);
8153
9079
  ensureRedactionFilter(sidecarPath, config.redaction, config);
8154
9080
  ensureInboxBranch(sidecarPath, config, inbox);
8155
9081
  const committed = snapshot(sidecarPath, root, inbox, getValue(parsed, "--message", getValue(parsed, "-m", "")) || undefined, config.redaction, config);
8156
- if (committed && parsed.flags.has("--push")) {
9082
+ if (committed && push) {
8157
9083
  syncBranchBeforePush(sidecarPath, inbox, config);
8158
9084
  pushBranch(sidecarPath, inbox);
8159
9085
  }
@@ -8171,12 +9097,19 @@ function cmdSync(args) {
8171
9097
  }
8172
9098
  const peers = loadPeers(selectedPeer(parsed), { loadRules: false });
8173
9099
  const failed = [];
9100
+ const soft = parsed.flags.has("--soft") || process.env[SOFT_SYNC_ENV] === "1";
9101
+ const options = {
9102
+ lock: soft ? "skip" : "throw",
9103
+ remote: !parsed.flags.has("--local") && process.env[LOCAL_SYNC_ENV] !== "1",
9104
+ snapshot: !parsed.flags.has("--no-snapshot"),
9105
+ message: getValue(parsed, "--message", getValue(parsed, "-m", "")) || undefined
9106
+ };
8174
9107
  for (const peer of peers) {
8175
9108
  announcePeer(peer, peers);
8176
9109
  try {
8177
9110
  if (peer.config.rulesPath)
8178
9111
  peer.config.rules = readRules(peer.config.rulesPath);
8179
- syncPeer(peer, parsed);
9112
+ syncPeer(peer, options);
8180
9113
  } catch (error) {
8181
9114
  if (peers.length === 1)
8182
9115
  throw error;
@@ -8189,18 +9122,19 @@ function cmdSync(args) {
8189
9122
  }
8190
9123
  return 0;
8191
9124
  }
8192
- function syncPeer(peer, parsed) {
9125
+ function syncPeer(peer, options) {
8193
9126
  const { root, config } = peer;
8194
- const soft = parsed.flags.has("--soft") || process.env[SOFT_SYNC_ENV] === "1";
8195
- const remote = !parsed.flags.has("--local") && process.env[LOCAL_SYNC_ENV] !== "1";
9127
+ const { remote } = options;
9128
+ const soft = options.lock === "skip";
8196
9129
  let stage = "start";
9130
+ let outcome = {};
8197
9131
  let synced;
8198
9132
  try {
8199
- synced = withSyncLock(root, peer.name, soft ? "skip" : "throw", () => {
8200
- syncProject(root, config, {
8201
- snapshot: !parsed.flags.has("--no-snapshot"),
9133
+ synced = withSyncLock(root, peer.name, options.lock, () => {
9134
+ outcome = syncProject(root, config, {
9135
+ snapshot: options.snapshot,
8202
9136
  remote,
8203
- message: getValue(parsed, "--message", getValue(parsed, "-m", "")) || undefined,
9137
+ message: options.message,
8204
9138
  onStage: (name) => {
8205
9139
  stage = name;
8206
9140
  }
@@ -8212,13 +9146,18 @@ function syncPeer(peer, parsed) {
8212
9146
  reportSyncHealth(root, config, {
8213
9147
  status: "failed",
8214
9148
  stage,
8215
- message: error instanceof Error ? error.message : String(error)
9149
+ message: reviewRequired(config) ? "details are shown only by a sync run by hand on that machine" : error instanceof Error ? error.message : String(error)
8216
9150
  });
8217
9151
  throw error;
8218
9152
  }
8219
9153
  if (synced) {
9154
+ const held = outcome.held;
9155
+ if (held) {
9156
+ const { id } = held.review.record;
9157
+ 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
+ }
8220
9159
  if (remote)
8221
- reportSyncHealth(root, config, { status: "ok" });
9160
+ reportSyncHealth(root, config, held ? { status: "ok", review: held.attention ? "attention" : "pending" } : { status: "ok" });
8222
9161
  if (!soft) {
8223
9162
  const sidecarPath = resolveSidecarPath(root, config);
8224
9163
  if (checkoutIsUnlinkedFromFamily(root, config, sidecarPath)) {
@@ -8243,12 +9182,16 @@ function cmdMerge(args) {
8243
9182
  const { root, config } = peer;
8244
9183
  const sidecarPath = requireSidecarCheckout(root, config);
8245
9184
  withSyncLock(root, peer.name, "throw", () => {
9185
+ bindReviewPolicy(sidecarPath, config);
8246
9186
  ensureRedactionFilter(sidecarPath, config.redaction, config);
8247
- mergeInboxBranches(sidecarPath, config, {
8248
- forkFiles: parsed.flags.has("--fork-files"),
8249
- push: !parsed.flags.has("--no-push"),
8250
- remote: true
8251
- });
9187
+ const forkFiles = parsed.flags.has("--fork-files");
9188
+ const push = !parsed.flags.has("--no-push");
9189
+ if (reviewRequired(config) && push) {
9190
+ mergePublishedInboxes(sidecarPath, config, { forkFiles });
9191
+ mergeInboxBranches(sidecarPath, config, { forkFiles, push: false, remote: false });
9192
+ return;
9193
+ }
9194
+ mergeInboxBranches(sidecarPath, config, { forkFiles, push, remote: true });
8252
9195
  });
8253
9196
  }
8254
9197
  return 0;
@@ -8272,7 +9215,7 @@ function printPeerRedactions({ root, config }) {
8272
9215
  let shown = 0;
8273
9216
  let items = 0;
8274
9217
  for (const relPath of files) {
8275
- const delta = fileRedactionDelta(path12.join(sidecarPath, relPath), config.redaction, { rules: config.rules, relativePath: relPath });
9218
+ const delta = fileRedactionDelta(path13.join(sidecarPath, relPath), config.redaction, { rules: config.rules, relativePath: relPath });
8276
9219
  if (!delta)
8277
9220
  continue;
8278
9221
  if (shown)
@@ -8291,12 +9234,12 @@ ${items} redaction(s) in ${shown} file(s) will be pushed this way (default: ${co
8291
9234
  console.log("local files are untouched; redaction is controlled by the peer's configuration and rules");
8292
9235
  }
8293
9236
  function printRedactionDiff(original, redacted) {
8294
- const scratch = fs14.mkdtempSync(path12.join(os6.tmpdir(), "sidecar-redactions-"));
9237
+ const scratch = fs15.mkdtempSync(path13.join(os6.tmpdir(), "sidecar-redactions-"));
8295
9238
  try {
8296
- const localPath = path12.join(scratch, "local");
8297
- const pushedPath = path12.join(scratch, "pushed");
8298
- fs14.writeFileSync(localPath, original, "utf8");
8299
- fs14.writeFileSync(pushedPath, redacted, "utf8");
9239
+ const localPath = path13.join(scratch, "local");
9240
+ const pushedPath = path13.join(scratch, "pushed");
9241
+ fs15.writeFileSync(localPath, original, "utf8");
9242
+ fs15.writeFileSync(pushedPath, redacted, "utf8");
8300
9243
  const color = colorLevel() > 0 ? ["--color"] : [];
8301
9244
  const diff = gitRaw(["diff", "--no-index", ...color, "--", localPath, pushedPath], { check: false });
8302
9245
  const lines = diff.stdout.split(`
@@ -8307,7 +9250,7 @@ function printRedactionDiff(original, redacted) {
8307
9250
  if (body)
8308
9251
  console.log(body);
8309
9252
  } finally {
8310
- fs14.rmSync(scratch, { recursive: true, force: true });
9253
+ fs15.rmSync(scratch, { recursive: true, force: true });
8311
9254
  }
8312
9255
  }
8313
9256
  function cmdRedact(args) {
@@ -8323,15 +9266,15 @@ function cmdRedact(args) {
8323
9266
  }
8324
9267
  const mode = redactionModeConfigValue(getValue(parsed, "--mode", DEFAULT_REDACTION_MODE), "--mode");
8325
9268
  const rulesPath = parsed.values.get("--rules");
8326
- const effective = checkoutPolicy ? checkoutRedactionPolicy(process.cwd()) : { mode, rules: rulesPath ? readRules(path12.resolve(rulesPath)) : [] };
9269
+ const effective = checkoutPolicy ? checkoutRedactionPolicy(process.cwd()) : { mode, rules: rulesPath ? readRules(path13.resolve(rulesPath)) : [] };
8327
9270
  const relativePath = parsed.values.get("--path");
8328
9271
  if ((checkoutPolicy || rulesPath) && !relativePath)
8329
9272
  throw new SidecarError("redaction rules require --path");
8330
9273
  const effectiveMode = relativePath ? resolveFileRules(effective.rules, relativePath, { resolve: "fork", redaction: effective.mode }).redaction : effective.mode;
8331
- const output = redactBuffer(fs14.readFileSync(0), effectiveMode);
9274
+ const output = redactBuffer(fs15.readFileSync(0), effectiveMode);
8332
9275
  let offset = 0;
8333
9276
  while (offset < output.length) {
8334
- offset += fs14.writeSync(1, output, offset, output.length - offset);
9277
+ offset += fs15.writeSync(1, output, offset, output.length - offset);
8335
9278
  }
8336
9279
  return 0;
8337
9280
  }
@@ -8347,6 +9290,215 @@ var init_cmd_sync = __esm(() => {
8347
9290
  init_ui();
8348
9291
  });
8349
9292
 
9293
+ // src/cmd-review.ts
9294
+ import fs16 from "node:fs";
9295
+ import path14 from "node:path";
9296
+ function cmdReview(args) {
9297
+ const [subcommand = "list", ...rest] = args[0]?.startsWith("-") ? ["list", ...args] : args;
9298
+ switch (subcommand) {
9299
+ case "list":
9300
+ return listCommand(rest);
9301
+ case "show":
9302
+ return showCommand(rest);
9303
+ case "approve":
9304
+ case "reject":
9305
+ return decideCommand(subcommand, rest);
9306
+ case "retry":
9307
+ return retryCommand(rest);
9308
+ case "reviewer":
9309
+ return reviewerCommand(rest);
9310
+ case "run":
9311
+ return runCommand(rest);
9312
+ default:
9313
+ throw new SidecarError(`unknown review command ${JSON.stringify(subcommand)}; ${USAGE}`);
9314
+ }
9315
+ }
9316
+ function listCommand(args) {
9317
+ const parsed = parseOptions(args, { boolean: new Set(["--json"]), value: new Set(["--peer"]) });
9318
+ if (parsed.positional.length)
9319
+ throw new SidecarError(USAGE);
9320
+ const peers = loadPeers(selectedPeer(parsed));
9321
+ if (parsed.flags.has("--json")) {
9322
+ console.log(JSON.stringify(peers.map((peer) => ({
9323
+ peer: peer.name,
9324
+ required: reviewRequired(peer.config),
9325
+ reviews: peerReviews(peer).map(reviewPayload)
9326
+ })), null, 2));
9327
+ return 0;
9328
+ }
9329
+ for (const peer of peers) {
9330
+ announcePeer(peer, peers);
9331
+ if (!reviewRequired(peer.config)) {
9332
+ console.log(paint("quiet", 'review is off for this peer; turn it on with review = "required" in its config'));
9333
+ continue;
9334
+ }
9335
+ const reviews = peerReviews(peer);
9336
+ if (!reviews.length) {
9337
+ console.log(paint("quiet", "no reviews yet; the next sync with unpublished changes opens one"));
9338
+ continue;
9339
+ }
9340
+ for (const review of reviews)
9341
+ printReviewRow(review);
9342
+ }
9343
+ return 0;
9344
+ }
9345
+ function peerReviews(peer) {
9346
+ const sidecarPath = resolveSidecarPath(peer.root, peer.config);
9347
+ return hasGitMetadata(sidecarPath) ? listReviews(sidecarPath) : [];
9348
+ }
9349
+ function printReviewRow(review) {
9350
+ const attention = reviewAttention(review);
9351
+ const age = formatRelativeTime(review.record.createdAt) ?? review.record.createdAt;
9352
+ const text = `${review.record.id} ${review.state.padEnd(10)} ${age}, ${review.record.files.length} file(s)`;
9353
+ if (attention)
9354
+ console.log(`${paint("attn", text)} needs attention: ${attention}`);
9355
+ else
9356
+ console.log(isOpen(review) ? text : paint("quiet", text));
9357
+ }
9358
+ function reviewPayload(review) {
9359
+ return {
9360
+ id: review.record.id,
9361
+ state: review.state,
9362
+ attention: reviewAttention(review),
9363
+ createdAt: review.record.createdAt,
9364
+ files: review.record.files,
9365
+ reason: review.verdict?.reason,
9366
+ decidedAt: review.verdict?.decidedAt,
9367
+ publishedAt: review.record.publishedAt,
9368
+ publishedCommit: review.record.publishedCommit,
9369
+ dir: review.dir
9370
+ };
9371
+ }
9372
+ function showCommand(args) {
9373
+ const parsed = parseOptions(args, { boolean: new Set(["--json"]), value: new Set(["--peer"]) });
9374
+ if (parsed.positional.length > 1)
9375
+ throw new SidecarError(USAGE);
9376
+ const review = selectReview(parsed.positional[0], selectedPeer(parsed));
9377
+ if (parsed.flags.has("--json")) {
9378
+ console.log(JSON.stringify(reviewPayload(review), null, 2));
9379
+ return 0;
9380
+ }
9381
+ const { record } = review;
9382
+ const line = (label, value, role) => labelLine(LABEL_WIDTH, label, value, role);
9383
+ const attention = reviewAttention(review);
9384
+ line("review", record.id, "brand");
9385
+ line("state", attention ? `${review.state} — needs attention: ${attention}` : review.state, attention ? "attn" : undefined);
9386
+ line("repo", record.root, "repo");
9387
+ if (record.peer !== "default")
9388
+ line("peer", record.peer);
9389
+ line("opened", formatRelativeTime(record.createdAt) ?? record.createdAt);
9390
+ if (review.verdict)
9391
+ line("decided", `${review.verdict.verdict} ${formatRelativeTime(review.verdict.decidedAt) ?? review.verdict.decidedAt}`);
9392
+ if (review.verdict?.reason)
9393
+ line("reason", review.verdict.reason);
9394
+ if (review.run)
9395
+ line("reviewer", review.run.command, "quiet");
9396
+ line("packet", review.dir, "quiet");
9397
+ console.log("");
9398
+ for (const file of record.files) {
9399
+ console.log(` ${file.status} ${file.path}${file.artifact ? paint("quiet", ` (not in the diff; full content: ${path14.join(review.dir, file.artifact)})`) : ""}`);
9400
+ }
9401
+ console.log("");
9402
+ process.stdout.write(fs16.readFileSync(path14.join(review.dir, "diff.patch"), "utf8"));
9403
+ if (isOpen(review)) {
9404
+ console.log("");
9405
+ console.log(paint("quiet", `decide with \`sidecar review approve ${record.id}\` or \`sidecar review reject ${record.id} --reason "..."\``));
9406
+ }
9407
+ return 0;
9408
+ }
9409
+ function decideCommand(verdict, args) {
9410
+ const parsed = parseOptions(args, { boolean: new Set(["--no-sync"]), value: new Set(["--reason", "--peer"]) });
9411
+ if (parsed.positional.length > 1)
9412
+ throw new SidecarError(USAGE);
9413
+ const selected = selectReview(parsed.positional[0], selectedPeer(parsed));
9414
+ const reason = parsed.values.get("--reason")?.trim() || undefined;
9415
+ const { id } = selected.record;
9416
+ withLockedReview(selected, (review) => decideReview(review, verdict === "approve" ? "approved" : "rejected", reason));
9417
+ if (verdict === "reject") {
9418
+ console.log(`rejected review ${paint("brand", id)}; nothing it covers is published`);
9419
+ console.log("change the files it flags and the next sync opens a new review, or approve it to override");
9420
+ return 0;
9421
+ }
9422
+ console.log(`approved review ${paint("brand", id)}`);
9423
+ if (parsed.flags.has("--no-sync")) {
9424
+ console.log("it publishes on the next sync");
9425
+ return 0;
9426
+ }
9427
+ const peer = loadPeer(selected.record.root, selected.record.peer);
9428
+ syncPeer(peer, { lock: "wait", remote: true, snapshot: true });
9429
+ return 0;
9430
+ }
9431
+ function retryCommand(args) {
9432
+ const parsed = parseOptions(args, { boolean: new Set, value: new Set(["--peer"]) });
9433
+ if (parsed.positional.length > 1)
9434
+ throw new SidecarError(USAGE);
9435
+ configuredReviewer();
9436
+ const selected = selectReview(parsed.positional[0], selectedPeer(parsed));
9437
+ const fresh = withLockedReview(selected, retryReview);
9438
+ console.log(`retired review ${paint("brand", selected.record.id)}; review ${paint("brand", fresh.record.id)} covers the same change, and its reviewer is running`);
9439
+ return 0;
9440
+ }
9441
+ function withLockedReview(selected, fn) {
9442
+ const { id, root, peer } = selected.record;
9443
+ let result;
9444
+ withSyncLock(root, peer, "wait", () => {
9445
+ const review = readReview(id);
9446
+ if (!review)
9447
+ throw new SidecarError(`review ${id} is gone`);
9448
+ result = fn(review);
9449
+ });
9450
+ return result;
9451
+ }
9452
+ function reviewerCommand(args) {
9453
+ const settings = readSettings();
9454
+ if (!args.length) {
9455
+ console.log(settings.reviewer ?? paint("quiet", "no reviewer configured"));
9456
+ return 0;
9457
+ }
9458
+ if (args.length === 1 && args[0] === "--unset") {
9459
+ writeSettings({ ...settings, reviewer: undefined });
9460
+ console.log("reviewer unset; reviews now wait for `sidecar review approve`");
9461
+ return 0;
9462
+ }
9463
+ if (args.length !== 1) {
9464
+ throw new SidecarError('usage: sidecar review reviewer ["<command>"|--unset]; quote the command as one argument');
9465
+ }
9466
+ const command = args[0].trim();
9467
+ if (!command)
9468
+ throw new SidecarError("the reviewer command is empty");
9469
+ writeSettings({ ...settings, reviewer: command });
9470
+ console.log(`reviewer set to ${paint("brand", command)}`);
9471
+ return 0;
9472
+ }
9473
+ function runCommand(args) {
9474
+ if (args.length !== 1)
9475
+ throw new SidecarError("usage: sidecar review run <id>");
9476
+ return runReviewer(args[0]);
9477
+ }
9478
+ function selectReview(id, peer) {
9479
+ if (id)
9480
+ return findReview(id);
9481
+ const open = loadPeers(peer).map((candidate) => resolveSidecarPath(candidate.root, candidate.config)).filter(hasGitMetadata).map(openReview).filter((review) => review !== undefined);
9482
+ if (open.length === 1)
9483
+ return open[0];
9484
+ if (!open.length)
9485
+ throw new SidecarError("no open review here; pass a review id (`sidecar review` lists them)");
9486
+ throw new SidecarError(`more than one open review here (${open.map((review) => review.record.id).join(", ")}); pass the id`);
9487
+ }
9488
+ var USAGE = "usage: sidecar review [list|show|approve|reject|retry|reviewer] [id] [--reason text] [--no-sync] [--json] [--peer name]", LABEL_WIDTH;
9489
+ var init_cmd_review = __esm(() => {
9490
+ init_color();
9491
+ init_util();
9492
+ init_git();
9493
+ init_config();
9494
+ init_state();
9495
+ init_review();
9496
+ init_sync();
9497
+ init_cmd_sync();
9498
+ init_ui();
9499
+ LABEL_WIDTH = "reviewer:".length;
9500
+ });
9501
+
8350
9502
  // src/commands.ts
8351
9503
  function run(argv) {
8352
9504
  const [command, ...rest] = argv;
@@ -8420,12 +9572,13 @@ var init_commands = __esm(() => {
8420
9572
  init_cmd_status();
8421
9573
  init_cmd_daemon();
8422
9574
  init_cmd_sync();
9575
+ init_cmd_review();
8423
9576
  COMMANDS = [
8424
9577
  {
8425
9578
  name: "init",
8426
9579
  run: cmdInit,
8427
9580
  section: "common",
8428
- usage: "init [remote] [--peer name] [--path sidecar|.] [--branch main] [--inbox template] [--redaction none|secrets|secrets+pii] [--resolve fork|lww] [--debounce 10m] [--interval 1h] [--ignored] [--local-install]",
9581
+ usage: "init [remote] [--peer name] [--path sidecar|.] [--branch main] [--inbox template] [--redaction none|secrets|secrets+pii] [--resolve fork|lww] [--review off|required] [--review-timeout 1h] [--debounce 10m] [--interval 1h] [--ignored] [--local-install]",
8429
9582
  notes: [
8430
9583
  "--path . makes this repo itself the sidecar (standalone)",
8431
9584
  "--peer name adds a second sidecar as .sidecar.name; --ignored keeps it out of the tree",
@@ -8447,6 +9600,16 @@ var init_commands = __esm(() => {
8447
9600
  usage: "redactions [--peer name]",
8448
9601
  summary: "preview what redaction rewrites before content is pushed"
8449
9602
  },
9603
+ {
9604
+ name: "review",
9605
+ run: cmdReview,
9606
+ section: "common",
9607
+ usage: "review [show|approve|reject|retry] [id] [--reason text] [--no-sync] [--json] [--peer name]",
9608
+ notes: [
9609
+ 'the reviews that gate a peer with review = "required"; approve publishes at once',
9610
+ "review reviewer [<command>|--unset] sets who reviews on this machine"
9611
+ ]
9612
+ },
8450
9613
  {
8451
9614
  name: "sync",
8452
9615
  run: cmdSync,
@@ -8517,7 +9680,7 @@ async function main(argv = process.argv.slice(2)) {
8517
9680
  if (command !== "redact" && command !== "deinit" && shouldUseGlobalRegistry()) {
8518
9681
  logSidecarEvent("failure", {
8519
9682
  command,
8520
- message: error instanceof Error ? error.message : String(error)
9683
+ ...privateFailures() ? {} : { message: error instanceof Error ? error.message : String(error) }
8521
9684
  });
8522
9685
  }
8523
9686
  if (error instanceof SidecarError) {
@@ -8535,6 +9698,7 @@ var init_cli = __esm(() => {
8535
9698
  init_color();
8536
9699
  init_util();
8537
9700
  init_install();
9701
+ init_config();
8538
9702
  init_state();
8539
9703
  init_commands();
8540
9704
  init_util();
@@ -8556,20 +9720,20 @@ var init_cli = __esm(() => {
8556
9720
 
8557
9721
  // src/bin.ts
8558
9722
  init_cli();
8559
- import fs15 from "node:fs";
8560
- import path13 from "node:path";
8561
- import { spawnSync as spawnSync5 } from "node:child_process";
8562
- import { fileURLToPath as fileURLToPath4 } from "node:url";
9723
+ import fs17 from "node:fs";
9724
+ import path15 from "node:path";
9725
+ import { spawnSync as spawnSync6 } from "node:child_process";
9726
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
8563
9727
  var SKIP_LOCAL_EXEC_ENV3 = "SIDECAR_SKIP_LOCAL_EXEC";
8564
9728
  var GLOBAL_EXEC_ENV3 = "SIDECAR_GLOBAL_EXEC";
8565
9729
  var PACKAGE_NAME2 = "sidecarsync";
8566
9730
  var GLOBAL_ONLY_COMMANDS = new Set(["daemon", "deinit", "register-install", "set-install-source", "update"]);
8567
9731
  if (process.argv[2] !== "redact" && !process.env[SKIP_LOCAL_EXEC_ENV3]) {
8568
- const local = findLocalInstall(process.cwd(), fileURLToPath4(import.meta.url));
9732
+ const local = findLocalInstall(process.cwd(), fileURLToPath3(import.meta.url));
8569
9733
  if (local) {
8570
9734
  process.env[GLOBAL_EXEC_ENV3] = "1";
8571
9735
  if (local.newer && !GLOBAL_ONLY_COMMANDS.has(process.argv[2])) {
8572
- const result = spawnSync5(process.execPath, [local.executable, ...process.argv.slice(2)], {
9736
+ const result = spawnSync6(process.execPath, [local.executable, ...process.argv.slice(2)], {
8573
9737
  stdio: "inherit",
8574
9738
  env: {
8575
9739
  ...process.env,
@@ -8586,15 +9750,15 @@ if (process.argv[2] !== "redact" && !process.env[SKIP_LOCAL_EXEC_ENV3]) {
8586
9750
  }
8587
9751
  process.exit(await main());
8588
9752
  function findLocalInstall(start, self) {
8589
- let current = path13.resolve(start);
9753
+ let current = path15.resolve(start);
8590
9754
  while (true) {
8591
9755
  if (projectDependsOnSidecar2(current)) {
8592
- const candidate = path13.join(current, "node_modules", PACKAGE_NAME2, "dist", "cli.js");
9756
+ const candidate = path15.join(current, "node_modules", PACKAGE_NAME2, "dist", "cli.js");
8593
9757
  if (isFile2(candidate) && !sameFile(candidate, self)) {
8594
9758
  return { executable: candidate, newer: localIsNewer(current) };
8595
9759
  }
8596
9760
  }
8597
- const parent = path13.dirname(current);
9761
+ const parent = path15.dirname(current);
8598
9762
  if (parent === current)
8599
9763
  return;
8600
9764
  current = parent;
@@ -8605,11 +9769,11 @@ function localIsNewer(projectRoot) {
8605
9769
  return localVersion !== undefined && compareVersions(localVersion, packageVersion()) > 0;
8606
9770
  }
8607
9771
  function projectDependsOnSidecar2(projectRoot) {
8608
- const manifestPath = path13.join(projectRoot, "package.json");
9772
+ const manifestPath = path15.join(projectRoot, "package.json");
8609
9773
  if (!isFile2(manifestPath))
8610
9774
  return false;
8611
9775
  try {
8612
- const manifest = JSON.parse(fs15.readFileSync(manifestPath, "utf8"));
9776
+ const manifest = JSON.parse(fs17.readFileSync(manifestPath, "utf8"));
8613
9777
  return Boolean(manifest.dependencies?.[PACKAGE_NAME2] || manifest.devDependencies?.[PACKAGE_NAME2] || manifest.optionalDependencies?.[PACKAGE_NAME2] || manifest.peerDependencies?.[PACKAGE_NAME2]);
8614
9778
  } catch {
8615
9779
  return false;
@@ -8617,14 +9781,14 @@ function projectDependsOnSidecar2(projectRoot) {
8617
9781
  }
8618
9782
  function isFile2(filePath) {
8619
9783
  try {
8620
- return fs15.statSync(filePath).isFile();
9784
+ return fs17.statSync(filePath).isFile();
8621
9785
  } catch {
8622
9786
  return false;
8623
9787
  }
8624
9788
  }
8625
9789
  function sameFile(first, second) {
8626
9790
  try {
8627
- return fs15.realpathSync(first) === fs15.realpathSync(second);
9791
+ return fs17.realpathSync(first) === fs17.realpathSync(second);
8628
9792
  } catch {
8629
9793
  return false;
8630
9794
  }