@tablation/crew 0.88.0 → 0.89.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 (2) hide show
  1. package/dist/cli.js +275 -130
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -9803,7 +9803,7 @@ function doctorSelftestLines(stored, toolchains) {
9803
9803
  }
9804
9804
 
9805
9805
  // src/cli.ts
9806
- import { resolve as resolve5, dirname as dirname20, join as join40, basename as basename12 } from "path";
9806
+ import { resolve as resolve5, dirname as dirname20, join as join41, basename as basename12 } from "path";
9807
9807
  import { fileURLToPath } from "url";
9808
9808
  import { hostname as hostname2, homedir as homedir10 } from "os";
9809
9809
  import { spawnSync as spawnSync7 } from "child_process";
@@ -9843,6 +9843,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
9843
9843
  "write",
9844
9844
  "topic",
9845
9845
  "assignee",
9846
+ "issue",
9846
9847
  "resume"
9847
9848
  ]);
9848
9849
  function positionalArgs(argv2) {
@@ -16532,24 +16533,55 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
16532
16533
  }
16533
16534
 
16534
16535
  // src/cli.ts
16535
- import { realpathSync as realpathSync3, readFileSync as readFileSync32, existsSync as existsSync36, mkdirSync as mkdirSync23, writeFileSync as writeFileSync24, rmSync as rmSync16, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync3, statSync as statSync11, readdirSync as readdirSync17 } from "fs";
16536
+ import { realpathSync as realpathSync3, readFileSync as readFileSync33, existsSync as existsSync37, mkdirSync as mkdirSync24, writeFileSync as writeFileSync25, rmSync as rmSync17, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync3, statSync as statSync11, readdirSync as readdirSync17 } from "fs";
16536
16537
  import { createInterface } from "readline/promises";
16537
16538
 
16538
16539
  // src/periscope-cmd.ts
16540
+ import { existsSync as existsSync32, mkdirSync as mkdirSync19, readFileSync as readFileSync28, rmSync as rmSync15, writeFileSync as writeFileSync21 } from "fs";
16541
+ import { join as join35 } from "path";
16539
16542
  var PeriscopeError = class extends Error {
16540
16543
  };
16544
+ var priorDir = (stateDir) => join35(stateDir, "periscope-priors");
16545
+ var priorPath = (stateDir, ticketRecordId) => join35(priorDir(stateDir), `${ticketRecordId}.json`);
16546
+ function removePeriscopePrior(stateDir, ticketRecordId) {
16547
+ rmSync15(priorPath(stateDir, ticketRecordId), { force: true });
16548
+ }
16549
+ function writePeriscopePrior(stateDir, ticketRecordId, prior) {
16550
+ try {
16551
+ mkdirSync19(priorDir(stateDir), { recursive: true });
16552
+ writeFileSync21(priorPath(stateDir, ticketRecordId), JSON.stringify(prior));
16553
+ } catch {
16554
+ }
16555
+ }
16556
+ function readPeriscopePrior(stateDir, ticketRecordId) {
16557
+ try {
16558
+ const p = priorPath(stateDir, ticketRecordId);
16559
+ return existsSync32(p) ? JSON.parse(readFileSync28(p, "utf8")) : void 0;
16560
+ } catch {
16561
+ return void 0;
16562
+ }
16563
+ }
16541
16564
  var DEFAULT_MAX_PERISCOPES2 = 3;
16542
16565
  var DEFAULT_PERISCOPE_IDLE_MINUTES2 = 120;
16543
16566
  function parsePeriscopeUpArgs(positional2, flags) {
16567
+ const usage2 = "usage: crew periscope up [route] REPO [--topic TEXT | --issue NNN] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--json]";
16544
16568
  const rest = positional2.slice(2);
16545
- if (rest.length === 0 || rest.length > 2) {
16546
- throw new PeriscopeError("usage: crew periscope up [route] REPO [--topic TEXT] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--json]");
16547
- }
16569
+ if (rest.length === 0 || rest.length > 2) throw new PeriscopeError(usage2);
16548
16570
  const topic = flags.topic?.trim();
16571
+ let issue;
16572
+ if (flags.issue !== void 0) {
16573
+ if (topic) throw new PeriscopeError(`--issue and --topic cannot be combined: --issue periscopes a ticket that already exists
16574
+ ${usage2}`);
16575
+ const m = /^(?:[A-Za-z]+-)?(\d+)$/.exec(flags.issue.trim());
16576
+ if (!m) throw new PeriscopeError(`"${flags.issue}" is not a ticket number
16577
+ ${usage2}`);
16578
+ issue = String(Number.parseInt(m[1], 10));
16579
+ }
16549
16580
  return {
16550
16581
  ...rest.length === 2 ? { route: rest[0] } : {},
16551
16582
  repo: rest[rest.length - 1],
16552
16583
  ...topic ? { topic } : {},
16584
+ ...issue ? { issue } : {},
16553
16585
  isolate: flags.isolate,
16554
16586
  ...flags.host ? { host: flags.host } : {},
16555
16587
  ...flags.assignee ? { assignee: flags.assignee } : {},
@@ -16574,6 +16606,17 @@ function periscopeCaps(l) {
16574
16606
  idleMinutes: l.periscopeIdleMinutes ?? DEFAULT_PERISCOPE_IDLE_MINUTES2
16575
16607
  };
16576
16608
  }
16609
+ async function existingRefusal(e, t) {
16610
+ if (t.closed) return `${t.tag} is ${t.status}; a closed ticket cannot be periscoped`;
16611
+ if (e.repoId && t.repoId !== e.repoId) return `${t.tag} belongs to a different repository than ${e.repoName}`;
16612
+ if (t.assignee && t.assignee !== e.pairId) {
16613
+ const agent = await e.agentHolder(t.assignee);
16614
+ if (agent) return `${t.tag} is held by ${agent}`;
16615
+ }
16616
+ if (t.heldByShipId && e.shipId && t.heldByShipId !== e.shipId) return `${t.tag} is held by another ship (${t.heldByShipId})`;
16617
+ if (e.hasLiveRun(t.id)) return `${t.tag} has a run in flight on this ship`;
16618
+ return void 0;
16619
+ }
16577
16620
  var tail = (s, n = 20) => s.trimEnd().split("\n").slice(-n).join("\n");
16578
16621
  function progressComment(url, handoff) {
16579
16622
  return [
@@ -16595,17 +16638,36 @@ async function periscopePreflight(d, repo) {
16595
16638
  }
16596
16639
  async function runPeriscopeUp(d, args) {
16597
16640
  await periscopePreflight(d, args.repo);
16598
- const topic = args.topic ?? (await d.askTopic())?.trim();
16599
- if (!topic) throw new PeriscopeError("a topic is required: pass --topic TEXT");
16600
- const unsafe = d.baseProblem();
16601
- if (unsafe) throw new PeriscopeError(`DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${unsafe}`);
16602
- const draft = await d.fileDraft(`Periscope: ${topic}`);
16603
- d.say("filed", `filed draft ${draft.tag}`, { ticket: draft.tag, ticketRecordId: draft.id });
16641
+ const ex = d.existing;
16642
+ let draft;
16643
+ let prior;
16644
+ if (args.issue) {
16645
+ if (!ex) throw new PeriscopeError("--issue is not supported here");
16646
+ prior = await ex.load(args.issue);
16647
+ if (!prior) throw new PeriscopeError(`no ticket numbered ${args.issue} on this route`);
16648
+ const refused = await existingRefusal(ex, prior);
16649
+ if (refused) throw new PeriscopeError(refused);
16650
+ const unsafe = d.baseProblem();
16651
+ if (unsafe) throw new PeriscopeError(`DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${unsafe}`);
16652
+ await ex.hold(prior.id);
16653
+ ex.remember(prior.id, { assignee: prior.assignee, status: prior.status });
16654
+ draft = prior;
16655
+ d.say("held", `holding ${prior.tag} (status ${prior.status} stays)`, { ticket: prior.tag, ticketRecordId: prior.id });
16656
+ } else {
16657
+ const topic = args.topic ?? (await d.askTopic())?.trim();
16658
+ if (!topic) throw new PeriscopeError("a topic is required: pass --topic TEXT");
16659
+ const unsafe = d.baseProblem();
16660
+ if (unsafe) throw new PeriscopeError(`DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${unsafe}`);
16661
+ draft = await d.fileDraft(`Periscope: ${topic}`);
16662
+ d.say("filed", `filed draft ${draft.tag}`, { ticket: draft.tag, ticketRecordId: draft.id });
16663
+ }
16604
16664
  let wt;
16605
16665
  const abandon = async (why) => {
16606
16666
  if (wt) await d.teardown(wt.path, wt.branch).catch(() => {
16607
16667
  });
16608
- await d.closeDraft(draft.id, `Periscope abandoned: ${why.split("\n")[0]}`).catch(() => {
16668
+ if (prior) await d.existing.restore(prior.id, prior.assignee).catch(() => {
16669
+ });
16670
+ else await d.closeDraft(draft.id, `Periscope abandoned: ${why.split("\n")[0]}`).catch(() => {
16609
16671
  });
16610
16672
  throw new PeriscopeError(why);
16611
16673
  };
@@ -16682,7 +16744,15 @@ ${tail(preview.output)}`);
16682
16744
  await d.comment(draft.id, progressComment(url, handoff?.output)).catch((e) => {
16683
16745
  d.say("warning", `progress comment not posted: ${e.message}`);
16684
16746
  });
16685
- return { ticket: draft.tag, ticketRecordId: draft.id, worktree: wt.path, branch: wt.branch, url, periscopeRecordId: row.id };
16747
+ return {
16748
+ ticket: draft.tag,
16749
+ ticketRecordId: draft.id,
16750
+ worktree: wt.path,
16751
+ branch: wt.branch,
16752
+ url,
16753
+ periscopeRecordId: row.id,
16754
+ ...prior ? { existing: true, priorStatus: prior.status } : {}
16755
+ };
16686
16756
  }
16687
16757
  function parsePeriscopeDownArgs(positional2, flags) {
16688
16758
  const usage2 = "usage: crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]";
@@ -16731,6 +16801,21 @@ async function runPeriscopeDown(d, args) {
16731
16801
  d.say("unchanged", `${done.ticket} is already ${done.state}; nothing to do`);
16732
16802
  return { ticket: done.ticket, mode: args.mode, outcome: "unchanged", state: done.state };
16733
16803
  }
16804
+ const existing = (await d.readTicket(row.ticketRecordId)).isDraft === false;
16805
+ const prior = existing ? d.priorHold?.(row.ticketRecordId) : void 0;
16806
+ if (existing && args.mode === "discard") {
16807
+ const killed2 = await d.stopServer(row.worktree, row.number);
16808
+ d.say("server", killed2 ? `stopped ${killed2} process(es)` : "no server was listening");
16809
+ const onRemote = d.remoteHasBranch?.(row.worktree, row.branch) ?? true;
16810
+ d.removeWorktree(row.worktree, row.branch, !onRemote);
16811
+ d.say("worktree", onRemote ? `removed ${row.worktree}; ${row.branch} stays, it is on the remote` : `removed ${row.worktree} and branch ${row.branch}`);
16812
+ await d.updateTicket(row.ticketRecordId, { status: "released", fillProjectAndRepo: false });
16813
+ d.say("ticket", `${row.ticket} hold cleared; status unchanged${prior ? ` (assignee before the periscope: ${prior.assignee ?? "none"})` : ""}`);
16814
+ await d.setRowState(row.id, "discarded");
16815
+ d.say("row", "row is discarded");
16816
+ forgetLocal(d, row.ticketRecordId);
16817
+ return { ticket: row.ticket, mode: "discard", outcome: "done", state: "discarded", existing: true, ...prior ? { status: prior.status } : {} };
16818
+ }
16734
16819
  if (args.mode === "discard") {
16735
16820
  const killed2 = await d.stopServer(row.worktree, row.number);
16736
16821
  d.say("server", killed2 ? `stopped ${killed2} process(es)` : "no server was listening");
@@ -16762,12 +16847,17 @@ async function runPeriscopeDown(d, args) {
16762
16847
  const description = t.description.includes(PROTOTYPE_HEADING) ? t.description : `${t.description.trimEnd()}
16763
16848
 
16764
16849
  ${section}`;
16765
- await d.updateTicket(row.ticketRecordId, {
16766
- description,
16767
- fillProjectAndRepo: !t.hasProject || !t.hasRepo,
16768
- status: "new"
16769
- });
16770
- d.say("ticket", `${row.ticket} is new and unassigned with a Prototype section`);
16850
+ if (existing) {
16851
+ await d.updateTicket(row.ticketRecordId, { description, fillProjectAndRepo: false, status: "released" });
16852
+ d.say("ticket", `${row.ticket} has a Prototype section and no assignee; status unchanged${prior ? ` (assignee before the periscope: ${prior.assignee ?? "none"})` : ""}`);
16853
+ } else {
16854
+ await d.updateTicket(row.ticketRecordId, {
16855
+ description,
16856
+ fillProjectAndRepo: !t.hasProject || !t.hasRepo,
16857
+ status: "new"
16858
+ });
16859
+ d.say("ticket", `${row.ticket} is new and unassigned with a Prototype section`);
16860
+ }
16771
16861
  await d.postPushed(row.ticketRecordId, pushed.sha, pushed.remote, row.branch);
16772
16862
  const killed = await d.stopServer(row.worktree, row.number);
16773
16863
  d.say("server", killed ? `stopped ${killed} process(es)` : "no server was listening");
@@ -16776,7 +16866,7 @@ ${section}`;
16776
16866
  await d.setRowState(row.id, "kept");
16777
16867
  d.say("row", "row is kept");
16778
16868
  forgetLocal(d, row.ticketRecordId);
16779
- return { ticket: row.ticket, mode: "keep", outcome: "done", state: "kept", sha: pushed.sha };
16869
+ return { ticket: row.ticket, mode: "keep", outcome: "done", state: "kept", sha: pushed.sha, ...existing ? { existing: true, ...prior ? { status: prior.status } : {} } : {} };
16780
16870
  }
16781
16871
  function periscopeListEntries(rows, now, isListening) {
16782
16872
  return rows.filter((r) => r.state === "live").map((r) => {
@@ -16838,7 +16928,7 @@ ${tail2(preview.output)}`);
16838
16928
  // src/periscope-idle.ts
16839
16929
  import { execFileSync as execFileSync6 } from "child_process";
16840
16930
  import { statSync as statSync9 } from "fs";
16841
- import { join as join35 } from "path";
16931
+ import { join as join36 } from "path";
16842
16932
  var MAX_SCANNED_FILES = 2e4;
16843
16933
  function newestWorktreeMtime(root) {
16844
16934
  let listing;
@@ -16858,7 +16948,7 @@ function newestWorktreeMtime(root) {
16858
16948
  if (!rel) continue;
16859
16949
  if (++scanned > MAX_SCANNED_FILES) break;
16860
16950
  try {
16861
- const m = statSync9(join35(root, rel)).mtimeMs;
16951
+ const m = statSync9(join36(root, rel)).mtimeMs;
16862
16952
  if (newest === null || m > newest) newest = m;
16863
16953
  } catch {
16864
16954
  }
@@ -16934,18 +17024,18 @@ import { dirname as dirOf2, resolve as resolvePath } from "path";
16934
17024
 
16935
17025
  // src/ssh-keys.ts
16936
17026
  import { execFileSync as execFileSync7 } from "child_process";
16937
- import { existsSync as existsSync32, mkdirSync as mkdirSync19, readFileSync as readFileSync28, chmodSync } from "fs";
16938
- import { dirname as dirname19, join as join36 } from "path";
17027
+ import { existsSync as existsSync33, mkdirSync as mkdirSync20, readFileSync as readFileSync29, chmodSync } from "fs";
17028
+ import { dirname as dirname19, join as join37 } from "path";
16939
17029
  var SshKeyError = class extends Error {
16940
17030
  };
16941
17031
  function shipSshKeyPathFor(stateDir) {
16942
- return join36(stateDir, "ssh", "id_ed25519");
17032
+ return join37(stateDir, "ssh", "id_ed25519");
16943
17033
  }
16944
17034
  function ensureShipSshKeypair(stateDir) {
16945
17035
  const privateKeyPath = shipSshKeyPathFor(stateDir);
16946
17036
  const publicKeyPath = `${privateKeyPath}.pub`;
16947
- if (!existsSync32(privateKeyPath)) {
16948
- mkdirSync19(dirname19(privateKeyPath), { recursive: true });
17037
+ if (!existsSync33(privateKeyPath)) {
17038
+ mkdirSync20(dirname19(privateKeyPath), { recursive: true });
16949
17039
  try {
16950
17040
  execFileSync7("ssh-keygen", [
16951
17041
  "-t",
@@ -16965,10 +17055,10 @@ function ensureShipSshKeypair(stateDir) {
16965
17055
  }
16966
17056
  chmodSync(privateKeyPath, 384);
16967
17057
  }
16968
- if (!existsSync32(publicKeyPath)) {
17058
+ if (!existsSync33(publicKeyPath)) {
16969
17059
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
16970
17060
  }
16971
- const publicKey = readFileSync28(publicKeyPath, "utf8").trim();
17061
+ const publicKey = readFileSync29(publicKeyPath, "utf8").trim();
16972
17062
  return { privateKeyPath, publicKeyPath, publicKey };
16973
17063
  }
16974
17064
  function sshKeygenAvailable() {
@@ -17185,9 +17275,9 @@ function syncAllPassengerCheckouts(cfg) {
17185
17275
 
17186
17276
  // src/tunnel.ts
17187
17277
  import { spawn as spawn5 } from "child_process";
17188
- import { existsSync as existsSync33, mkdirSync as mkdirSync20, readFileSync as readFileSync29, writeFileSync as writeFileSync21, unlinkSync as unlinkSync3, readdirSync as readdirSync14 } from "fs";
17278
+ import { existsSync as existsSync34, mkdirSync as mkdirSync21, readFileSync as readFileSync30, writeFileSync as writeFileSync22, unlinkSync as unlinkSync3, readdirSync as readdirSync14 } from "fs";
17189
17279
  import { randomInt } from "crypto";
17190
- import { join as join37 } from "path";
17280
+ import { join as join38 } from "path";
17191
17281
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
17192
17282
  var defaultSpawn = (cmd, args) => spawn5(cmd, args, DEFAULT_SPAWN_OPTIONS);
17193
17283
  var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
@@ -17254,21 +17344,21 @@ function startTunnel(opts) {
17254
17344
  };
17255
17345
  }
17256
17346
  function tunnelStatePath(stateDir, workspaceId) {
17257
- return join37(stateDir, "passengers", `${workspaceId}.json`);
17347
+ return join38(stateDir, "passengers", `${workspaceId}.json`);
17258
17348
  }
17259
17349
  function readPersistedTunnel(stateDir, workspaceId) {
17260
17350
  const path = tunnelStatePath(stateDir, workspaceId);
17261
- if (!existsSync33(path)) return void 0;
17351
+ if (!existsSync34(path)) return void 0;
17262
17352
  try {
17263
- return JSON.parse(readFileSync29(path, "utf8"));
17353
+ return JSON.parse(readFileSync30(path, "utf8"));
17264
17354
  } catch {
17265
17355
  return void 0;
17266
17356
  }
17267
17357
  }
17268
17358
  function writePersistedTunnel(stateDir, state) {
17269
17359
  const path = tunnelStatePath(stateDir, state.workspaceId);
17270
- mkdirSync20(join37(stateDir, "passengers"), { recursive: true });
17271
- writeFileSync21(path, `${JSON.stringify(state, null, 2)}
17360
+ mkdirSync21(join38(stateDir, "passengers"), { recursive: true });
17361
+ writeFileSync22(path, `${JSON.stringify(state, null, 2)}
17272
17362
  `);
17273
17363
  }
17274
17364
  function updatePersistedTunnel(stateDir, workspaceId, patch2) {
@@ -17365,8 +17455,8 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
17365
17455
  }
17366
17456
  }
17367
17457
  function readAllPersistedWorkspaceIds(stateDir) {
17368
- const dir = join37(stateDir, "passengers");
17369
- if (!existsSync33(dir)) return [];
17458
+ const dir = join38(stateDir, "passengers");
17459
+ if (!existsSync34(dir)) return [];
17370
17460
  return readdirSync14(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
17371
17461
  }
17372
17462
 
@@ -17487,8 +17577,8 @@ async function unregisterEndpoint(workspaceId, info, d) {
17487
17577
  import { createServer as createHttpServer } from "http";
17488
17578
  import { spawn as spawn6 } from "child_process";
17489
17579
  import { randomBytes } from "crypto";
17490
- import { existsSync as existsSync34, mkdirSync as mkdirSync21, readFileSync as readFileSync30, writeFileSync as writeFileSync22, unlinkSync as unlinkSync4, readdirSync as readdirSync15 } from "fs";
17491
- import { join as join38 } from "path";
17580
+ import { existsSync as existsSync35, mkdirSync as mkdirSync22, readFileSync as readFileSync31, writeFileSync as writeFileSync23, unlinkSync as unlinkSync4, readdirSync as readdirSync15 } from "fs";
17581
+ import { join as join39 } from "path";
17492
17582
 
17493
17583
  // src/secret-compare.ts
17494
17584
  import { timingSafeEqual } from "crypto";
@@ -17577,21 +17667,21 @@ var defaultSpawn2 = (cmd, args, env) => {
17577
17667
  return child;
17578
17668
  };
17579
17669
  function syncDaemonStatePath(stateDir, workspaceId) {
17580
- return join38(stateDir, "passengers", `${workspaceId}-sync.json`);
17670
+ return join39(stateDir, "passengers", `${workspaceId}-sync.json`);
17581
17671
  }
17582
17672
  function readPersistedSyncDaemon(stateDir, workspaceId) {
17583
17673
  const path = syncDaemonStatePath(stateDir, workspaceId);
17584
- if (!existsSync34(path)) return void 0;
17674
+ if (!existsSync35(path)) return void 0;
17585
17675
  try {
17586
- return JSON.parse(readFileSync30(path, "utf8"));
17676
+ return JSON.parse(readFileSync31(path, "utf8"));
17587
17677
  } catch {
17588
17678
  return void 0;
17589
17679
  }
17590
17680
  }
17591
17681
  function writePersistedSyncDaemon(stateDir, state) {
17592
17682
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
17593
- mkdirSync21(join38(stateDir, "passengers"), { recursive: true });
17594
- writeFileSync22(path, `${JSON.stringify(state, null, 2)}
17683
+ mkdirSync22(join39(stateDir, "passengers"), { recursive: true });
17684
+ writeFileSync23(path, `${JSON.stringify(state, null, 2)}
17595
17685
  `);
17596
17686
  }
17597
17687
  function removePersistedSyncDaemon(stateDir, workspaceId) {
@@ -17611,8 +17701,8 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
17611
17701
  }
17612
17702
  }
17613
17703
  function readAllPersistedSyncWorkspaceIds(stateDir) {
17614
- const dir = join38(stateDir, "passengers");
17615
- if (!existsSync34(dir)) return [];
17704
+ const dir = join39(stateDir, "passengers");
17705
+ if (!existsSync35(dir)) return [];
17616
17706
  const suffix = "-sync.json";
17617
17707
  return readdirSync15(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
17618
17708
  }
@@ -17700,8 +17790,8 @@ function describeCause(err) {
17700
17790
  }
17701
17791
 
17702
17792
  // src/daemon.ts
17703
- import { existsSync as existsSync35, mkdirSync as mkdirSync22, readFileSync as readFileSync31, readdirSync as readdirSync16, rmSync as rmSync15, statSync as statSync10, writeFileSync as writeFileSync23 } from "fs";
17704
- import { join as join39 } from "path";
17793
+ import { existsSync as existsSync36, mkdirSync as mkdirSync23, readFileSync as readFileSync32, readdirSync as readdirSync16, rmSync as rmSync16, statSync as statSync10, writeFileSync as writeFileSync24 } from "fs";
17794
+ import { join as join40 } from "path";
17705
17795
  import { randomBytes as randomBytes2 } from "crypto";
17706
17796
  var DEFAULT_FLOOR_MS = 5e3;
17707
17797
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
@@ -17772,7 +17862,7 @@ function latestMtime(path) {
17772
17862
  if (!stat.isDirectory()) return 0;
17773
17863
  let latest = stat.mtimeMs;
17774
17864
  for (const entry of readdirSync16(path, { withFileTypes: true })) {
17775
- latest = Math.max(latest, latestMtime(join39(path, entry.name)));
17865
+ latest = Math.max(latest, latestMtime(join40(path, entry.name)));
17776
17866
  }
17777
17867
  return latest;
17778
17868
  }
@@ -17781,23 +17871,23 @@ function computeTreeSignature(paths) {
17781
17871
  }
17782
17872
  function makeStaleChecker(paths, opts = {}) {
17783
17873
  const initial = computeTreeSignature(paths);
17784
- return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync35(opts.reloadSentinel);
17874
+ return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync36(opts.reloadSentinel);
17785
17875
  }
17786
17876
  function reloadSentinelFor(stateDir) {
17787
- return join39(stateDir, "reload-requested");
17877
+ return join40(stateDir, "reload-requested");
17788
17878
  }
17789
17879
  function daemonStampFor(stateDir) {
17790
- return join39(stateDir, "daemon.started");
17880
+ return join40(stateDir, "daemon.started");
17791
17881
  }
17792
17882
  function noteDaemonStarted(stateDir, now = Date.now()) {
17793
- mkdirSync22(stateDir, { recursive: true });
17794
- rmSync15(reloadSentinelFor(stateDir), { force: true });
17795
- writeFileSync23(daemonStampFor(stateDir), `${JSON.stringify({ pid: process.pid, startedAt: now })}
17883
+ mkdirSync23(stateDir, { recursive: true });
17884
+ rmSync16(reloadSentinelFor(stateDir), { force: true });
17885
+ writeFileSync24(daemonStampFor(stateDir), `${JSON.stringify({ pid: process.pid, startedAt: now })}
17796
17886
  `);
17797
17887
  }
17798
17888
  function readDaemonStarted(stateDir) {
17799
17889
  try {
17800
- const v = JSON.parse(readFileSync31(daemonStampFor(stateDir), "utf8")).startedAt;
17890
+ const v = JSON.parse(readFileSync32(daemonStampFor(stateDir), "utf8")).startedAt;
17801
17891
  return typeof v === "number" ? v : null;
17802
17892
  } catch {
17803
17893
  return null;
@@ -18419,7 +18509,7 @@ function usage(problem, requested = false) {
18419
18509
  crew reap [route] kill orphaned servers, drop worktrees for closed tickets
18420
18510
  crew drop [route] NNN remove a merged ticket's worktree and branch
18421
18511
  crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
18422
- crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]
18512
+ crew periscope up [route] REPO --topic TEXT|--issue NNN [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]
18423
18513
  crew periscope up --resume NNN restart the preview an idle periscope's server was stopped for
18424
18514
  file a draft ticket, cut and prepare its worktree, start the repo's preview hook and record a Periscopes row (alias: start)
18425
18515
  crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]
@@ -18699,7 +18789,7 @@ if (command === "passenger-sync-daemon") {
18699
18789
  } else {
18700
18790
  const toleratesNoRoutes = command === "repos" || command === "ship" || command === "disconnect" && flag("reset") || command === "connect" && flag("sign-out");
18701
18791
  const configless = command === "connect" && flag("sign-out") || command === "disconnect" && flag("reset");
18702
- if (configless && !existsSync36(findConfigFile(CREW_HOME))) {
18792
+ if (configless && !existsSync37(findConfigFile(CREW_HOME))) {
18703
18793
  cfg = loadConfig(CREW_HOME, firstRunConfigPath(), { text: "ship: {}\n", allowNoRoutes: true });
18704
18794
  } else {
18705
18795
  cfg = loadConfig(CREW_HOME, void 0, toleratesNoRoutes ? { allowNoRoutes: true } : {});
@@ -18937,8 +19027,8 @@ if (command === "passenger-sync-daemon") {
18937
19027
  }
18938
19028
  if (host === "linux" && hasSystemd) {
18939
19029
  const label = labelFor(CREW_HOME, "run");
18940
- const staleTimer = join40(homedir10(), ".config", "systemd", "user", `${label}.timer`);
18941
- if (existsSync36(staleTimer)) {
19030
+ const staleTimer = join41(homedir10(), ".config", "systemd", "user", `${label}.timer`);
19031
+ if (existsSync37(staleTimer)) {
18942
19032
  process.stdout.write(
18943
19033
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
18944
19034
  `
@@ -18965,7 +19055,7 @@ if (command === "passenger-sync-daemon") {
18965
19055
  const tracker = new Tracker(c, cfg.ship);
18966
19056
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
18967
19057
  for (const r of await resolvedRepos(c)) {
18968
- if (!existsSync36(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
19058
+ if (!existsSync37(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
18969
19059
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
18970
19060
  readiness.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
18971
19061
  }
@@ -18983,7 +19073,7 @@ if (command === "passenger-sync-daemon") {
18983
19073
  }
18984
19074
  async function doctorFix(o) {
18985
19075
  const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
18986
- const unitExists = (p) => existsSync36(p);
19076
+ const unitExists = (p) => existsSync37(p);
18987
19077
  const crontab = o.host === "linux" ? spawnSync7("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
18988
19078
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
18989
19079
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -19000,7 +19090,7 @@ if (command === "passenger-sync-daemon") {
19000
19090
  for (const a of plan.actions) {
19001
19091
  if (a.kind === "enable") {
19002
19092
  if (prompter && !await prompter.confirm(`Route ${a.route} passes every check \u2014 set enabled: true in ${cfg.configFile}?`, false)) continue;
19003
- writeFileSync24(cfg.configFile, planEnable(readFileSync32(cfg.configFile, "utf8"), a.route));
19093
+ writeFileSync25(cfg.configFile, planEnable(readFileSync33(cfg.configFile, "utf8"), a.route));
19004
19094
  enabledSound.add(a.route);
19005
19095
  changed2.push(`enabled route ${a.route} in ${cfg.configFile}`);
19006
19096
  } else {
@@ -20104,7 +20194,7 @@ ${cu.tail.trimEnd()}
20104
20194
  process.exit(1);
20105
20195
  }
20106
20196
  requireArmed("run the daemon loop");
20107
- const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join40(CREW_HOME, "src"), join40(CREW_HOME, "dist"), join40(CREW_HOME, "bin")];
20197
+ const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join41(CREW_HOME, "src"), join41(CREW_HOME, "dist"), join41(CREW_HOME, "bin")];
20108
20198
  const configPaths = daemonConfigPaths(cfg);
20109
20199
  watchPaths.push(...configPaths);
20110
20200
  noteDaemonStarted(cfg.ship.stateDir);
@@ -20333,10 +20423,10 @@ ${cu.tail.trimEnd()}
20333
20423
  `);
20334
20424
  if (!dryRun) {
20335
20425
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
20336
- const raw = existsSync36(resolvedPath) ? JSON.parse(readFileSync32(resolvedPath, "utf8")) : {};
20426
+ const raw = existsSync37(resolvedPath) ? JSON.parse(readFileSync33(resolvedPath, "utf8")) : {};
20337
20427
  raw.agentSkills = result.agentSkills;
20338
- mkdirSync23(dirOf2(resolvedPath), { recursive: true });
20339
- writeFileSync24(resolvedPath, `${JSON.stringify(raw, null, 2)}
20428
+ mkdirSync24(dirOf2(resolvedPath), { recursive: true });
20429
+ writeFileSync25(resolvedPath, `${JSON.stringify(raw, null, 2)}
20340
20430
  `);
20341
20431
  }
20342
20432
  if (result.outcomes.some((o) => o.action === "diverged")) process.exitCode = 1;
@@ -20359,7 +20449,7 @@ ${cu.tail.trimEnd()}
20359
20449
  throw e;
20360
20450
  }
20361
20451
  const oldName = cfg.ship.name;
20362
- writeFileSync24(cfg.configFile, setShipNameInYaml(readFileSync32(cfg.configFile, "utf8"), newName));
20452
+ writeFileSync25(cfg.configFile, setShipNameInYaml(readFileSync33(cfg.configFile, "utf8"), newName));
20363
20453
  cfg.ship.name = newName;
20364
20454
  let renameMachineId;
20365
20455
  try {
@@ -20406,7 +20496,7 @@ ${cu.tail.trimEnd()}
20406
20496
  process.exit(2);
20407
20497
  };
20408
20498
  if (sub !== "add" && sub !== "list" && sub !== "clone" && sub !== "hooks" && sub !== "check") usage2();
20409
- const configText = readFileSync32(cfg.configFile, "utf8");
20499
+ const configText = readFileSync33(cfg.configFile, "utf8");
20410
20500
  if (sub === "check") {
20411
20501
  try {
20412
20502
  const routeArg = value("route") ?? positional[2];
@@ -20437,8 +20527,8 @@ ${cu.tail.trimEnd()}
20437
20527
  try {
20438
20528
  const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
20439
20529
  const dir = repoDirOf(configText, dirname20(cfg.configFile), a.route, a.name);
20440
- if (!existsSync36(dir)) throw new ReposError(`${dir} does not exist on disk`);
20441
- const files = { exists: (r) => existsSync36(resolvePath(dir, r)), list: (r) => {
20530
+ if (!existsSync37(dir)) throw new ReposError(`${dir} does not exist on disk`);
20531
+ const files = { exists: (r) => existsSync37(resolvePath(dir, r)), list: (r) => {
20442
20532
  try {
20443
20533
  return readdirSync17(resolvePath(dir, r));
20444
20534
  } catch {
@@ -20446,7 +20536,7 @@ ${cu.tail.trimEnd()}
20446
20536
  }
20447
20537
  }, read: (r) => {
20448
20538
  try {
20449
- return readFileSync32(resolvePath(dir, r), "utf8");
20539
+ return readFileSync33(resolvePath(dir, r), "utf8");
20450
20540
  } catch {
20451
20541
  return void 0;
20452
20542
  }
@@ -20488,19 +20578,19 @@ ${cu.tail.trimEnd()}
20488
20578
  let cleared;
20489
20579
  if (clear2) {
20490
20580
  const plan = clearRouteHooks(configText, a.route, a.name);
20491
- if (plan.removed.length > 0) writeFileSync24(cfg.configFile, plan.text);
20581
+ if (plan.removed.length > 0) writeFileSync25(cfg.configFile, plan.text);
20492
20582
  cleared = { file: cfg.configFile, removed: plan.removed };
20493
20583
  }
20494
20584
  if (write && suggestion.source === null && recipeIds.length === 0 && hookSpecs.length === 0) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
20495
20585
  if (write === "route") {
20496
20586
  const plan = planRouteHooks(configText, a.route, a.name, picked.hooks, { replace: flag("replace") });
20497
- writeFileSync24(cfg.configFile, plan.text);
20587
+ writeFileSync25(cfg.configFile, plan.text);
20498
20588
  written = { target: "route", file: cfg.configFile, hooks: plan.written, kept: plan.kept, replaced: plan.replaced };
20499
20589
  } else if (write === "repo") {
20500
20590
  const have = existingRepoConfig(files);
20501
20591
  if (have) throw new ReposError(`${resolvePath(dir, have)} already exists; not overwriting it`);
20502
20592
  const file = resolvePath(dir, ".crew.yaml");
20503
- writeFileSync24(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
20593
+ writeFileSync25(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
20504
20594
  written = { target: "repo", file, hooks: Object.keys(picked.hooks), kept: [], replaced: [] };
20505
20595
  }
20506
20596
  if (flag("json")) {
@@ -20556,7 +20646,7 @@ ${cu.tail.trimEnd()}
20556
20646
  if (sub === "list" && !routeName) usage2();
20557
20647
  if (sub === "add") {
20558
20648
  try {
20559
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync36(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
20649
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync37(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
20560
20650
  routeName = a.route;
20561
20651
  addPath = a.path;
20562
20652
  addName = a.name;
@@ -20585,16 +20675,16 @@ ${cu.tail.trimEnd()}
20585
20675
  }
20586
20676
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
20587
20677
  let resolvedRemotes;
20588
- if (existsSync36(resolvedFile)) {
20678
+ if (existsSync37(resolvedFile)) {
20589
20679
  try {
20590
- resolvedRemotes = JSON.parse(readFileSync32(resolvedFile, "utf8"));
20680
+ resolvedRemotes = JSON.parse(readFileSync33(resolvedFile, "utf8"));
20591
20681
  } catch {
20592
20682
  }
20593
20683
  }
20594
20684
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
20595
20685
  const text = configText;
20596
20686
  const base = dirname20(cfg.configFile);
20597
- const isCheckout = (d) => existsSync36(`${d}/.git`);
20687
+ const isCheckout = (d) => existsSync37(`${d}/.git`);
20598
20688
  try {
20599
20689
  if (sub === "list") {
20600
20690
  if (flag("json")) {
@@ -20604,11 +20694,11 @@ ${cu.tail.trimEnd()}
20604
20694
  base,
20605
20695
  resolved: resolvedRemotes,
20606
20696
  originOf,
20607
- exists: (d) => existsSync36(d),
20697
+ exists: (d) => existsSync37(d),
20608
20698
  isCheckout,
20609
20699
  repoHasHooks: (d) => REPO_CONFIG_FILENAMES.some((f) => {
20610
20700
  try {
20611
- return Object.keys(parseRepoConfig(readFileSync32(resolvePath(d, f), "utf8"), f).hooks).length > 0;
20701
+ return Object.keys(parseRepoConfig(readFileSync33(resolvePath(d, f), "utf8"), f).hooks).length > 0;
20612
20702
  } catch {
20613
20703
  return false;
20614
20704
  }
@@ -20625,7 +20715,7 @@ ${cu.tail.trimEnd()}
20625
20715
  for (const r of repos) {
20626
20716
  const origin2 = originOf(r.dir);
20627
20717
  const row = matchRepoName(origin2, resolvedRemotes);
20628
- const note = !existsSync36(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
20718
+ const note = !existsSync37(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
20629
20719
  process.stdout.write(`${r.name} ${r.dir} ${note}
20630
20720
  `);
20631
20721
  }
@@ -20637,9 +20727,9 @@ ${cu.tail.trimEnd()}
20637
20727
  ` : `${human}
20638
20728
  `);
20639
20729
  const plan2 = planRepoClone({ text, base, route: routeName, name: addName, resolved: resolvedRemotes, dir: value("dir"), isCheckout });
20640
- if (existsSync36(plan2.target) && (existsSync36(`${plan2.target}/.git`) || readdirSync17(plan2.target).length > 0)) {
20730
+ if (existsSync37(plan2.target) && (existsSync37(`${plan2.target}/.git`) || readdirSync17(plan2.target).length > 0)) {
20641
20731
  throw new ReposError(
20642
- existsSync36(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
20732
+ existsSync37(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
20643
20733
  );
20644
20734
  }
20645
20735
  if (dryRun) {
@@ -20657,14 +20747,14 @@ ${cu.tail.trimEnd()}
20657
20747
  throw new ReposError(`${e.message}${authHint}
20658
20748
  crew.yaml was not changed`);
20659
20749
  }
20660
- writeFileSync24(cfg.configFile, plan2.text);
20750
+ writeFileSync25(cfg.configFile, plan2.text);
20661
20751
  if (plan2.droppedDir) process.stderr.write(`crew repos: dropped dir: ${plan2.droppedDir} from route ${routeName} \u2014 it is not a checkout (the clone base); checkouts now live under repos:
20662
20752
  `);
20663
20753
  say("attached", { route: routeName, name: plan2.name, dir: plan2.target, mode: plan2.mode }, `cloned and attached ${plan2.target} to ${routeName} as ${plan2.mode === "dir" ? "dir" : `repos.${plan2.name}`} in ${cfg.configFile}`);
20664
20754
  break;
20665
20755
  }
20666
20756
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir10()));
20667
- const top = existsSync36(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
20757
+ const top = existsSync37(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
20668
20758
  if (!top) throw new ReposError(`${target} is not a git checkout`);
20669
20759
  const origin = originOf(top);
20670
20760
  const plan = planRepoAdd({
@@ -20716,15 +20806,15 @@ ${cu.tail.trimEnd()}
20716
20806
  const created = await rowTracker.createRepoRow(rowPayload);
20717
20807
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
20718
20808
  `);
20719
- if (existsSync36(resolvedFile)) {
20720
- const raw = JSON.parse(readFileSync32(resolvedFile, "utf8"));
20809
+ if (existsSync37(resolvedFile)) {
20810
+ const raw = JSON.parse(readFileSync33(resolvedFile, "utf8"));
20721
20811
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
20722
20812
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
20723
- writeFileSync24(resolvedFile, `${JSON.stringify(raw, null, 2)}
20813
+ writeFileSync25(resolvedFile, `${JSON.stringify(raw, null, 2)}
20724
20814
  `);
20725
20815
  }
20726
20816
  }
20727
- writeFileSync24(cfg.configFile, plan.text);
20817
+ writeFileSync25(cfg.configFile, plan.text);
20728
20818
  process.stdout.write(`added ${top} to ${routeName} as ${plan.mode === "dir" ? "dir" : `repos.${plan.name}`} in ${cfg.configFile}
20729
20819
  `);
20730
20820
  } catch (e) {
@@ -21091,7 +21181,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21091
21181
  process.exit(2);
21092
21182
  }
21093
21183
  if (found.shipNameAdopted && !dryRun) {
21094
- writeFileSync24(cfg.configFile, setShipNameInYaml(readFileSync32(cfg.configFile, "utf8"), found.shipNameAdopted));
21184
+ writeFileSync25(cfg.configFile, setShipNameInYaml(readFileSync33(cfg.configFile, "utf8"), found.shipNameAdopted));
21095
21185
  cfg.ship.name = found.shipNameAdopted;
21096
21186
  }
21097
21187
  if (found.shipRow) {
@@ -21134,10 +21224,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21134
21224
  }
21135
21225
  }
21136
21226
  let previous;
21137
- const hadPreviousFile = existsSync36(resolvedPath);
21227
+ const hadPreviousFile = existsSync37(resolvedPath);
21138
21228
  if (hadPreviousFile) {
21139
21229
  try {
21140
- previous = JSON.parse(readFileSync32(resolvedPath, "utf8"));
21230
+ previous = JSON.parse(readFileSync33(resolvedPath, "utf8"));
21141
21231
  } catch {
21142
21232
  }
21143
21233
  }
@@ -21266,10 +21356,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21266
21356
  process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
21267
21357
  `);
21268
21358
  } else {
21269
- mkdirSync23(dirname20(resolvedPath), { recursive: true });
21359
+ mkdirSync24(dirname20(resolvedPath), { recursive: true });
21270
21360
  if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
21271
21361
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
21272
- writeFileSync24(tmpPath, `${JSON.stringify(resolved, null, 2)}
21362
+ writeFileSync25(tmpPath, `${JSON.stringify(resolved, null, 2)}
21273
21363
  `);
21274
21364
  renameSync3(tmpPath, resolvedPath);
21275
21365
  process.stderr.write(`Resolved ids written to ${resolvedPath}
@@ -21315,8 +21405,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21315
21405
  process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
21316
21406
  `);
21317
21407
  } else {
21318
- mkdirSync23(dirname20(apiKeyFile), { recursive: true });
21319
- writeFileSync24(apiKeyFile, `${apiKeyVar}=${apiKey}
21408
+ mkdirSync24(dirname20(apiKeyFile), { recursive: true });
21409
+ writeFileSync25(apiKeyFile, `${apiKeyVar}=${apiKey}
21320
21410
  `, { mode: 384 });
21321
21411
  process.stderr.write(`API key written to ${apiKeyFile}
21322
21412
  `);
@@ -21866,7 +21956,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21866
21956
  case "periscope": {
21867
21957
  const subP = positional[1];
21868
21958
  if (subP !== "up" && subP !== "start" && subP !== "down" && subP !== "end" && subP !== "list") {
21869
- process.stderr.write("crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]\n");
21959
+ process.stderr.write("crew periscope up [route] REPO --topic TEXT|--issue NNN [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]\n");
21870
21960
  process.stderr.write("crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]\n");
21871
21961
  process.stderr.write("crew periscope list [route] [--json]\n");
21872
21962
  process.exit(2);
@@ -21950,11 +22040,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21950
22040
  },
21951
22041
  readTicket: async (id) => {
21952
22042
  const t = await trD.ticket(id);
21953
- return { description: String(t.description ?? ""), hasProject: !!t[cD.columns.slice], hasRepo: !!t[cD.columns.repo] };
22043
+ return { description: String(t.description ?? ""), hasProject: !!t[cD.columns.slice], hasRepo: !!t[cD.columns.repo], isDraft: t.status === cD.statuses.draft };
21954
22044
  },
22045
+ priorHold: (id) => readPeriscopePrior(cfg.ship.stateDir, id),
22046
+ remoteHasBranch: (_wt, branch) => remoteBranchExists(repoD.dir, remote, branch),
21955
22047
  updateTicket: async (id, p) => {
21956
22048
  const patch2 = {};
21957
- if (p.status === "new") {
22049
+ if (p.status === "released") {
22050
+ patch2[cD.columns.assignee] = null;
22051
+ if ((await trD.authorshipProbe()).issueHeld) patch2[cD.columns.heldBy] = null;
22052
+ if (p.description !== void 0) patch2.description = p.description;
22053
+ } else if (p.status === "new") {
21958
22054
  patch2[cD.columns.status] = "new";
21959
22055
  patch2[cD.columns.assignee] = null;
21960
22056
  if (p.description !== void 0) patch2.description = p.description;
@@ -21979,7 +22075,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21979
22075
  if (deleteBranch) gitOk(repoD.dir, ["branch", "-D", branch]);
21980
22076
  },
21981
22077
  setRowState: (id, state2) => trD.updatePeriscope(id, { state: state2 }),
21982
- forgetLocal: (id) => removePeriscopeSidecar(cfg.ship.stateDir, id),
22078
+ forgetLocal: (id) => {
22079
+ removePeriscopeSidecar(cfg.ship.stateDir, id);
22080
+ removePeriscopePrior(cfg.ship.stateDir, id);
22081
+ },
21983
22082
  say
21984
22083
  };
21985
22084
  if (dryRun) {
@@ -22024,7 +22123,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
22024
22123
  const shellR = hit && resolveShell(hit.repo.config.shell ?? cfg.ship.shell);
22025
22124
  const resumed = await runPeriscopeResume({
22026
22125
  find: async () => hit && { row: hit.row, tag: hit.tag },
22027
- worktreeExists: (p) => existsSync36(p),
22126
+ worktreeExists: (p) => existsSync37(p),
22028
22127
  caps: periscopeCaps(cfg.ship),
22029
22128
  runHook: async (name, cwd, env) => {
22030
22129
  const script = hit?.repo.config.hooks[name];
@@ -22043,7 +22142,7 @@ ${resumed.url}
22043
22142
  `);
22044
22143
  break;
22045
22144
  }
22046
- const a = parsePeriscopeUpArgs(positional, { topic: value("topic"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
22145
+ const a = parsePeriscopeUpArgs(positional, { topic: value("topic"), issue: value("issue"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
22047
22146
  const candidatesP = a.route ? [findRoute(cfg, a.route)] : cfg.routes.filter((r) => r.enabled);
22048
22147
  const servedP = [];
22049
22148
  for (const cand of candidatesP) {
@@ -22105,10 +22204,10 @@ ${resumed.url}
22105
22204
  },
22106
22205
  prepare: async (path) => {
22107
22206
  for (const f of repoP.config.worktrees.copy) {
22108
- const src = join40(repoP.dir, f);
22109
- if (!existsSync36(src)) continue;
22110
- mkdirSync23(dirOf2(join40(path, f)), { recursive: true });
22111
- copyFileSync2(src, join40(path, f));
22207
+ const src = join41(repoP.dir, f);
22208
+ if (!existsSync37(src)) continue;
22209
+ mkdirSync24(dirOf2(join41(path, f)), { recursive: true });
22210
+ copyFileSync2(src, join41(path, f));
22112
22211
  }
22113
22212
  const setup = repoP.config.hooks.setup;
22114
22213
  return setup ? runScript(setup, { cwd: path, shell: shellP, onLine: (l) => {
@@ -22132,12 +22231,58 @@ ${resumed.url}
22132
22231
  const closed = cP.statuses.resolved.includes("closed_obsolete") ? "closed_obsolete" : cP.statuses.wontFix;
22133
22232
  await trackerP.updateTicket(ticketId, { [cP.columns.status]: closed, [cP.columns.resolutionNote]: note });
22134
22233
  },
22234
+ existing: {
22235
+ load: async (number) => {
22236
+ const t = (await trackerP.ticketsByNumbers([number])).find((x) => String(x[cP.columns.key]).replace(/^\D+/, "").replace(/^0+(?=\d)/, "") === number);
22237
+ if (!t) return void 0;
22238
+ return {
22239
+ id: t.id,
22240
+ tag: t.issue_tag ?? t.issue_id,
22241
+ issueId: t.issue_id,
22242
+ prefix: t.project_issue_prefix,
22243
+ status: t.status,
22244
+ closed: cP.statuses.resolved.includes(t.status) || t.status === cP.statuses.wontFix,
22245
+ repoId: t[cP.columns.repo] ?? null,
22246
+ assignee: t[cP.columns.assignee] ?? null,
22247
+ heldByShipId: t[cP.columns.heldBy] ?? null
22248
+ };
22249
+ },
22250
+ repoId: repoP.id ?? null,
22251
+ repoName: a.repo,
22252
+ pairId,
22253
+ shipId: (await trackerP.authorshipProbe()).shipId,
22254
+ agentHolder: async (assigneeId) => {
22255
+ const row = crewP.find((r) => r.id === assigneeId);
22256
+ return row?.agent_id ? row.name ?? assigneeId : void 0;
22257
+ },
22258
+ hasLiveRun: (ticketRecordId) => liveRuns(cfg.ship.stateDir).some((r) => r.ticketRecordId === ticketRecordId),
22259
+ hold: async (id) => {
22260
+ const auth = await trackerP.authorshipProbe();
22261
+ await trackerP.updateTicket(id, {
22262
+ [cP.columns.assignee]: pairId,
22263
+ ...auth.issueHeld && auth.shipId ? { [cP.columns.heldBy]: auth.shipId } : {}
22264
+ });
22265
+ },
22266
+ restore: async (id, assignee) => {
22267
+ const auth = await trackerP.authorshipProbe();
22268
+ await trackerP.updateTicket(id, { [cP.columns.assignee]: assignee, ...auth.issueHeld ? { [cP.columns.heldBy]: null } : {} });
22269
+ },
22270
+ remember: (id, prior) => writePeriscopePrior(cfg.ship.stateDir, id, prior)
22271
+ },
22135
22272
  now: () => /* @__PURE__ */ new Date(),
22136
22273
  host: a.host ?? "localhost",
22137
22274
  say
22138
22275
  };
22139
22276
  if (dryRun) {
22140
22277
  await periscopePreflight(deps, a.repo);
22278
+ if (a.issue) {
22279
+ const t = await deps.existing.load(a.issue);
22280
+ if (!t) throw new PeriscopeError(`no ticket numbered ${a.issue} on this route`);
22281
+ const refused = await existingRefusal(deps.existing, t);
22282
+ if (refused) throw new PeriscopeError(refused);
22283
+ say("dry-run", `would hold ${t.tag} (status ${t.status} stays) for ${pairId}, cut or reuse its worktree for ${a.repo}, run setup${a.isolate ? ", isolate" : ""}, ports, handoff and preview on ${deps.host}, and record a Periscopes row`);
22284
+ break;
22285
+ }
22141
22286
  say("dry-run", `would file "Periscope: ${a.topic ?? "<topic>"}" as ${cP.statuses.draft} for ${pairId}, cut a worktree for ${a.repo}, run setup${a.isolate ? ", isolate" : ""}, ports, handoff and preview on ${deps.host}, and record a Periscopes row (expires after ${caps.idleMinutes} minutes idle)`);
22142
22287
  break;
22143
22288
  }
@@ -22332,7 +22477,7 @@ ${res.url}
22332
22477
  };
22333
22478
  const droppedRoutes = [];
22334
22479
  try {
22335
- const rawCfg = parseYaml2(readFileSync32(cfg.configFile, "utf8"));
22480
+ const rawCfg = parseYaml2(readFileSync33(cfg.configFile, "utf8"));
22336
22481
  for (const r of rawCfg?.routes ?? []) {
22337
22482
  if (typeof r?.route !== "string" || cfg.routes.some((x) => x.route === r.route)) continue;
22338
22483
  try {
@@ -22374,8 +22519,8 @@ ${res.url}
22374
22519
  process.stdout.write("crew reload: no daemon unit is running \u2014 nothing to reload (the next run reads crew.yaml fresh)\n");
22375
22520
  break;
22376
22521
  }
22377
- mkdirSync23(cfg.ship.stateDir, { recursive: true });
22378
- writeFileSync24(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
22522
+ mkdirSync24(cfg.ship.stateDir, { recursive: true });
22523
+ writeFileSync25(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
22379
22524
  `);
22380
22525
  process.stdout.write("crew reload: requested \u2014 the daemon finishes its in-flight pass, then relaunches with the new config\n");
22381
22526
  if (flag("now") && control?.stopCommand && control.startCommand) {
@@ -22426,7 +22571,7 @@ ${res.url}
22426
22571
  }
22427
22572
  case "log": {
22428
22573
  try {
22429
- const text = readFileSync32(cfg.ship.logFile, "utf8").split("\n");
22574
+ const text = readFileSync33(cfg.ship.logFile, "utf8").split("\n");
22430
22575
  process.stdout.write(`${text.slice(-(Number(value("tail")) || 40)).join("\n")}
22431
22576
  `);
22432
22577
  } catch {
@@ -22449,7 +22594,7 @@ ${res.url}
22449
22594
  };
22450
22595
  if (!route.resolved) fail(`route ${route.route} is not connected \u2014 run \`crew connect\` first`);
22451
22596
  const tracker = new Tracker(route, cfg.ship);
22452
- const configText = readFileSync32(cfg.configFile, "utf8");
22597
+ const configText = readFileSync33(cfg.configFile, "utf8");
22453
22598
  const cfgBase = dirname20(cfg.configFile);
22454
22599
  const configured = configuredRepos(configText, route.route, cfgBase);
22455
22600
  const dir = configured.find((r) => r.name === SAMPLE_REPO)?.dir ?? sampleDir(configured.map((r) => r.dir), route.reposBasePath, route.route.split("/")[0]);
@@ -22478,7 +22623,7 @@ ${res.url}
22478
22623
  }
22479
22624
  if (configured.some((r) => r.dir === dir)) {
22480
22625
  try {
22481
- writeFileSync24(cfg.configFile, planRepoRemove(configText, cfgBase, route.route, dir).text);
22626
+ writeFileSync25(cfg.configFile, planRepoRemove(configText, cfgBase, route.route, dir).text);
22482
22627
  } catch (e) {
22483
22628
  if (e instanceof ReposError) fail(e.message);
22484
22629
  throw e;
@@ -22486,29 +22631,29 @@ ${res.url}
22486
22631
  }
22487
22632
  if (row) {
22488
22633
  await tracker.deleteRepoRow(row.id);
22489
- if (existsSync36(resolvedFile)) {
22490
- const raw = JSON.parse(readFileSync32(resolvedFile, "utf8"));
22634
+ if (existsSync37(resolvedFile)) {
22635
+ const raw = JSON.parse(readFileSync33(resolvedFile, "utf8"));
22491
22636
  if (raw.repoNames) delete raw.repoNames[row.id];
22492
22637
  if (raw.repoRemotes) delete raw.repoRemotes[row.id];
22493
- writeFileSync24(resolvedFile, `${JSON.stringify(raw, null, 2)}
22638
+ writeFileSync25(resolvedFile, `${JSON.stringify(raw, null, 2)}
22494
22639
  `);
22495
22640
  }
22496
22641
  }
22497
- if (existsSync36(join40(dir, ".git")) && basename12(dir) === SAMPLE_REPO) rmSync16(dir, { recursive: true, force: true });
22642
+ if (existsSync37(join41(dir, ".git")) && basename12(dir) === SAMPLE_REPO) rmSync17(dir, { recursive: true, force: true });
22498
22643
  say("removed", { dir, closed }, `removed the sample repo${closed.length ? `, closed ${closed.join(", ")}` : ""}`);
22499
22644
  break;
22500
22645
  }
22501
22646
  const projectId = route.resolved.areaId ?? resolveRepoProject(await tracker.projectRows(), value("project"));
22502
- const fresh = !existsSync36(join40(dir, ".git"));
22503
- if (existsSync36(dir) && fresh && readdirSync17(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
22647
+ const fresh = !existsSync37(join41(dir, ".git"));
22648
+ if (existsSync37(dir) && fresh && readdirSync17(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
22504
22649
  if (dryRun) {
22505
22650
  say("dry-run", { dir, newCheckout: fresh, repoRow: row?.id ?? null }, `would ${fresh ? "create" : "reuse"} ${dir}${row ? "" : ", create the Repos row"}, and file one ${tracker.contract.statuses.approved} ticket`);
22506
22651
  break;
22507
22652
  }
22508
22653
  if (fresh) {
22509
- mkdirSync23(dir, { recursive: true });
22510
- writeFileSync24(join40(dir, "README.md"), SAMPLE_README);
22511
- writeFileSync24(join40(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
22654
+ mkdirSync24(dir, { recursive: true });
22655
+ writeFileSync25(join41(dir, "README.md"), SAMPLE_README);
22656
+ writeFileSync25(join41(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
22512
22657
  const g = (...a) => {
22513
22658
  const r = spawnSync7("git", a, { cwd: dir, encoding: "utf8" });
22514
22659
  if (r.status !== 0) fail(`git ${a[0]} failed in ${dir}: ${(r.stderr || r.stdout).trim()}`);
@@ -22526,17 +22671,17 @@ ${res.url}
22526
22671
  spec: { platform: "unix", releaseMode: "integrate", ciProvider: "none" }
22527
22672
  });
22528
22673
  repoId = (await tracker.createRepoRow(payload)).id;
22529
- if (existsSync36(resolvedFile)) {
22530
- const raw = JSON.parse(readFileSync32(resolvedFile, "utf8"));
22674
+ if (existsSync37(resolvedFile)) {
22675
+ const raw = JSON.parse(readFileSync33(resolvedFile, "utf8"));
22531
22676
  raw.repoNames = { ...raw.repoNames, [repoId]: SAMPLE_REPO };
22532
22677
  raw.repoRemotes = { ...raw.repoRemotes, [repoId]: SAMPLE_REMOTE };
22533
- writeFileSync24(resolvedFile, `${JSON.stringify(raw, null, 2)}
22678
+ writeFileSync25(resolvedFile, `${JSON.stringify(raw, null, 2)}
22534
22679
  `);
22535
22680
  }
22536
22681
  }
22537
22682
  if (!configured.some((r) => r.dir === dir)) {
22538
22683
  try {
22539
- writeFileSync24(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route.route, path: dir, name: SAMPLE_REPO }).text);
22684
+ writeFileSync25(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route.route, path: dir, name: SAMPLE_REPO }).text);
22540
22685
  } catch (e) {
22541
22686
  if (e instanceof ReposError) fail(e.message);
22542
22687
  throw e;
@@ -22871,7 +23016,7 @@ watermark: ${state.watermark()}
22871
23016
  }
22872
23017
  for (const r of repos) {
22873
23018
  const remote = r.config.branch.remote;
22874
- if (existsSync36(r.dir)) {
23019
+ if (existsSync37(r.dir)) {
22875
23020
  pushAuth.push({
22876
23021
  route: c.route,
22877
23022
  repo: r.name,
@@ -22948,7 +23093,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
22948
23093
  }
22949
23094
  try {
22950
23095
  const evFile = eventFileFor(cfg.ship.stateDir);
22951
- const evSize = existsSync36(evFile) ? statSync11(evFile).size : 0;
23096
+ const evSize = existsSync37(evFile) ? statSync11(evFile).size : 0;
22952
23097
  process.stdout.write(
22953
23098
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
22954
23099
  ` : "events log: ok\n"
@@ -22959,7 +23104,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
22959
23104
  const unitPlan = planInstall(cfg.ship, CREW_HOME, host, detectSystemd(), "run");
22960
23105
  const abandons = unitAbandonsAgents(unitPlan, (p) => {
22961
23106
  try {
22962
- return readFileSync32(p, "utf8");
23107
+ return readFileSync33(p, "utf8");
22963
23108
  } catch {
22964
23109
  return null;
22965
23110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.88.0",
3
+ "version": "0.89.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",