@tablation/crew 0.84.0 → 0.85.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 +303 -14
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -5606,7 +5606,7 @@ function validateContract(c) {
5606
5606
  }
5607
5607
  var EVENT_LOG_MISSING = "the Issues table has no event log \u2014 crew writes its audit trail there (CREW-1574). In the app, open the Issues table's Data & lifecycle tab and turn on Event log, then rerun.";
5608
5608
  var PERISCOPE_COLUMNS = [
5609
- "ticket_id",
5609
+ "issue_id",
5610
5610
  "ship_id",
5611
5611
  "url",
5612
5612
  "host",
@@ -6879,6 +6879,32 @@ var Tracker = class {
6879
6879
  const have = await this.columnNames(modelId);
6880
6880
  return { present: true, modelId, missing: PERISCOPE_COLUMNS.filter((c) => !have.has(c)) };
6881
6881
  }
6882
+ /**
6883
+ * How many `live` Periscopes rows this ship holds (EPIC-077). Zero when the
6884
+ * workspace has no Periscopes table or this ship has no Ships row yet (the
6885
+ * row's `ship_id` could not name it), so the cap only ever counts rows it
6886
+ * can attribute.
6887
+ */
6888
+ async livePeriscopeCount() {
6889
+ const modelId = this.route.resolved?.periscopesModelId;
6890
+ const { shipId } = await this.authorshipProbe();
6891
+ if (!modelId || !shipId) return 0;
6892
+ const rows = await this.client.records.list(modelId, {
6893
+ filters: encodeFilters([
6894
+ { columnName: "ship_id", operator: "EQ", value: shipId },
6895
+ { columnName: "state", operator: "EQ", value: "live" }
6896
+ ]),
6897
+ limit: 200
6898
+ });
6899
+ return rows.length;
6900
+ }
6901
+ /** Records a Periscopes row for this ship (EPIC-077); `ship_id` is stamped here, never by the caller. */
6902
+ async createPeriscope(fields) {
6903
+ const modelId = this.route.resolved?.periscopesModelId;
6904
+ if (!modelId) throw new Error("this workspace has no Periscopes table");
6905
+ const { shipId } = await this.authorshipProbe();
6906
+ return this.client.records.create(modelId, { ...fields, ...shipId ? { ship_id: shipId } : {} });
6907
+ }
6882
6908
  /**
6883
6909
  * The authorship columns this workspace is missing, as `table.column` names
6884
6910
  * for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
@@ -9102,8 +9128,8 @@ function spawnAgent(plan, emit, hintMoved) {
9102
9128
  const meta = { pid: child.pid ?? null };
9103
9129
  obs.onClaim = () => writeRunMeta(id, startedAt, obs.liveTicket, meta);
9104
9130
  if (child.pid !== void 0) writeRunMeta(id, startedAt, obs.liveTicket, meta);
9105
- const tail = new FileTail(plan.streamPath);
9106
- const timer = setInterval(() => obs.feed(tail.poll()), TAIL_POLL_MS);
9131
+ const tail2 = new FileTail(plan.streamPath);
9132
+ const timer = setInterval(() => obs.feed(tail2.poll()), TAIL_POLL_MS);
9107
9133
  let spawnFailed = false;
9108
9134
  child.on("error", (err) => {
9109
9135
  spawnFailed = true;
@@ -9112,7 +9138,7 @@ function spawnAgent(plan, emit, hintMoved) {
9112
9138
  });
9113
9139
  child.on("close", async (code) => {
9114
9140
  clearInterval(timer);
9115
- obs.feed(tail.poll());
9141
+ obs.feed(tail2.poll());
9116
9142
  obs.end();
9117
9143
  await endSink(eventsSink);
9118
9144
  if (spawnFailed) return;
@@ -9131,20 +9157,20 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
9131
9157
  const id = { ...sc, streamPath };
9132
9158
  const eventsSink = openSink(sc.eventsPath);
9133
9159
  const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
9134
- const tail = new FileTail(streamPath);
9160
+ const tail2 = new FileTail(streamPath);
9135
9161
  obs.muted = true;
9136
- obs.feed(tail.poll());
9162
+ obs.feed(tail2.poll());
9137
9163
  obs.muted = false;
9138
9164
  const adopted = isAlive(sc);
9139
9165
  if (adopted) {
9140
9166
  o.onAdopted?.();
9141
9167
  const pollMs = o.pollMs ?? TAIL_POLL_MS;
9142
9168
  while (isAlive(sc)) {
9143
- obs.feed(tail.poll());
9169
+ obs.feed(tail2.poll());
9144
9170
  await new Promise((r) => setTimeout(r, pollMs));
9145
9171
  }
9146
9172
  }
9147
- obs.feed(tail.poll());
9173
+ obs.feed(tail2.poll());
9148
9174
  obs.end();
9149
9175
  await endSink(eventsSink);
9150
9176
  const interrupted = obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
@@ -9692,7 +9718,9 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
9692
9718
  "toolchain",
9693
9719
  "workspace",
9694
9720
  "workspace-id",
9695
- "write"
9721
+ "write",
9722
+ "topic",
9723
+ "assignee"
9696
9724
  ]);
9697
9725
  function positionalArgs(argv2) {
9698
9726
  const out = [];
@@ -13032,7 +13060,7 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
13032
13060
  }
13033
13061
  if (culprits.length === 0 && truncated) {
13034
13062
  let lo = 2, hi = applied.length;
13035
- let tail = lastLines(output, HOOK_FAILURE_TAIL_LINES);
13063
+ let tail2 = lastLines(output, HOOK_FAILURE_TAIL_LINES);
13036
13064
  while (lo < hi) {
13037
13065
  const mid = Math.floor((lo + hi) / 2);
13038
13066
  gitOk(o.cwd, ["reset", "--hard", preMerge]);
@@ -13047,12 +13075,12 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
13047
13075
  const r = clean ? await hook(o, name) : null;
13048
13076
  if (r && r.code !== 0) {
13049
13077
  hi = mid;
13050
- tail = lastLines(r.output, HOOK_FAILURE_TAIL_LINES);
13078
+ tail2 = lastLines(r.output, HOOK_FAILURE_TAIL_LINES);
13051
13079
  } else lo = mid + 1;
13052
13080
  }
13053
13081
  const owner = applied[hi - 1];
13054
13082
  o.emit.warn(`${owner.branch} is the first branch whose prefix fails the ${name} hook (bisected over ${applied.length})`, { ticket: owner.ticket.issue_id });
13055
- culprits.push({ c: owner, how: "pair", sibling: applied[hi - 2], tail });
13083
+ culprits.push({ c: owner, how: "pair", sibling: applied[hi - 2], tail: tail2 });
13056
13084
  }
13057
13085
  }
13058
13086
  }
@@ -16373,6 +16401,139 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
16373
16401
  // src/cli.ts
16374
16402
  import { realpathSync as realpathSync3, readFileSync as readFileSync31, existsSync as existsSync35, mkdirSync as mkdirSync22, writeFileSync as writeFileSync23, rmSync as rmSync15, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync3, statSync as statSync10, readdirSync as readdirSync16 } from "fs";
16375
16403
  import { createInterface } from "readline/promises";
16404
+
16405
+ // src/periscope-cmd.ts
16406
+ var PeriscopeError = class extends Error {
16407
+ };
16408
+ var DEFAULT_MAX_PERISCOPES2 = 3;
16409
+ var DEFAULT_PERISCOPE_IDLE_MINUTES2 = 120;
16410
+ function parsePeriscopeUpArgs(positional2, flags) {
16411
+ const rest = positional2.slice(2);
16412
+ if (rest.length === 0 || rest.length > 2) {
16413
+ throw new PeriscopeError("usage: crew periscope up [route] REPO [--topic TEXT] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--json]");
16414
+ }
16415
+ const topic = flags.topic?.trim();
16416
+ return {
16417
+ ...rest.length === 2 ? { route: rest[0] } : {},
16418
+ repo: rest[rest.length - 1],
16419
+ ...topic ? { topic } : {},
16420
+ isolate: flags.isolate,
16421
+ ...flags.host ? { host: flags.host } : {},
16422
+ ...flags.assignee ? { assignee: flags.assignee } : {},
16423
+ json: flags.json
16424
+ };
16425
+ }
16426
+ function parseEnvLines(output) {
16427
+ const env = {};
16428
+ for (const line of output.split("\n")) {
16429
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim());
16430
+ if (m) env[m[1]] = m[2].replace(/^"(.*)"$/, "$1");
16431
+ }
16432
+ return env;
16433
+ }
16434
+ function previewUrlFrom(output) {
16435
+ const last = output.split("\n").map((l) => l.trim()).filter(Boolean).pop();
16436
+ return last && /^https?:\/\/\S+$/i.test(last) ? last : void 0;
16437
+ }
16438
+ function periscopeCaps(l) {
16439
+ return {
16440
+ max: l.maxPeriscopes ?? DEFAULT_MAX_PERISCOPES2,
16441
+ idleMinutes: l.periscopeIdleMinutes ?? DEFAULT_PERISCOPE_IDLE_MINUTES2
16442
+ };
16443
+ }
16444
+ var tail = (s, n = 20) => s.trimEnd().split("\n").slice(-n).join("\n");
16445
+ function progressComment(url, handoff) {
16446
+ return [
16447
+ `Periscope is up: ${url}`,
16448
+ ...handoff?.trim() ? ["", handoff.trim()] : []
16449
+ ].join("\n");
16450
+ }
16451
+ async function periscopePreflight(d, repo) {
16452
+ if (!d.previewHook) {
16453
+ throw new PeriscopeError(`${repo} declares no \`preview\` hook, so it cannot host a periscope \u2014 add one to its .crew.yaml (see the repository spec page, "hooks.preview")`);
16454
+ }
16455
+ if (!d.hasPeriscopesTable) {
16456
+ throw new PeriscopeError("this workspace has no Periscopes table \u2014 update the Issues template and run `crew connect`");
16457
+ }
16458
+ const live = await d.liveCount();
16459
+ if (live >= d.caps.max) {
16460
+ throw new PeriscopeError(`this ship already hosts ${live} live periscope(s) (ship.maxPeriscopes is ${d.caps.max}) \u2014 discard one first`);
16461
+ }
16462
+ }
16463
+ async function runPeriscopeUp(d, args) {
16464
+ await periscopePreflight(d, args.repo);
16465
+ const topic = args.topic ?? (await d.askTopic())?.trim();
16466
+ if (!topic) throw new PeriscopeError("a topic is required: pass --topic TEXT");
16467
+ const unsafe = d.baseProblem();
16468
+ if (unsafe) throw new PeriscopeError(`DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${unsafe}`);
16469
+ const draft = await d.fileDraft(`Periscope: ${topic}`);
16470
+ d.say("filed", `filed draft ${draft.tag}`, { ticket: draft.tag, ticketRecordId: draft.id });
16471
+ let wt;
16472
+ const abandon = async (why) => {
16473
+ if (wt) await d.teardown(wt.path, wt.branch).catch(() => {
16474
+ });
16475
+ await d.closeDraft(draft.id, `Periscope abandoned: ${why.split("\n")[0]}`).catch(() => {
16476
+ });
16477
+ throw new PeriscopeError(why);
16478
+ };
16479
+ try {
16480
+ wt = d.cutWorktree(draft);
16481
+ } catch (e) {
16482
+ return abandon(e.message);
16483
+ }
16484
+ d.say("worktree", `cut ${wt.path} on ${wt.branch}`, { worktree: wt.path, branch: wt.branch });
16485
+ const prepared = await d.prepare(wt.path);
16486
+ if (prepared && prepared.code !== 0) {
16487
+ return abandon(`setup failed (exit ${prepared.code})
16488
+ ${tail(prepared.output)}`);
16489
+ }
16490
+ const env = { CREW_TICKET: draft.tag, CREW_BRANCH: wt.branch };
16491
+ const ports = await d.runHook("ports", wt.path, env);
16492
+ if (ports) Object.assign(env, parseEnvLines(ports.output));
16493
+ if (args.isolate) {
16494
+ const iso = await d.runHook("isolate", wt.path, env);
16495
+ if (iso && iso.code !== 0) return abandon(`isolate failed (exit ${iso.code})
16496
+ ${tail(iso.output)}`);
16497
+ if (iso) Object.assign(env, parseEnvLines(iso.output));
16498
+ }
16499
+ const handoff = await d.runHook("handoff", wt.path, env);
16500
+ if (handoff && handoff.code !== 0) return abandon(`handoff failed (exit ${handoff.code})
16501
+ ${tail(handoff.output)}`);
16502
+ const preview = await d.runHook("preview", wt.path, { ...env, CREW_PREVIEW_HOST: d.host });
16503
+ if (!preview || preview.code !== 0) {
16504
+ return abandon(`preview exited ${preview?.code ?? "without running"}
16505
+ ${tail(preview?.output ?? "")}`);
16506
+ }
16507
+ const url = previewUrlFrom(preview.output);
16508
+ if (!url) return abandon(`preview printed no URL as its last line
16509
+ ${tail(preview.output)}`);
16510
+ d.say("preview", `preview at ${url}`, { url });
16511
+ const now = d.now();
16512
+ let row;
16513
+ try {
16514
+ row = await d.createRow({
16515
+ issue_id: draft.id,
16516
+ url,
16517
+ host: d.host,
16518
+ worktree: wt.path,
16519
+ branch: wt.branch,
16520
+ state: "live",
16521
+ isolated: args.isolate,
16522
+ started_at: now.toISOString(),
16523
+ last_activity_at: now.toISOString(),
16524
+ expires_at: new Date(now.getTime() + d.caps.idleMinutes * 6e4).toISOString()
16525
+ });
16526
+ } catch (e) {
16527
+ return abandon(`recording the Periscopes row failed: ${e.message}`);
16528
+ }
16529
+ d.say("recorded", "recorded the Periscopes row", { periscopeRecordId: row.id });
16530
+ await d.comment(draft.id, progressComment(url, handoff?.output)).catch((e) => {
16531
+ d.say("warning", `progress comment not posted: ${e.message}`);
16532
+ });
16533
+ return { ticket: draft.tag, ticketRecordId: draft.id, worktree: wt.path, branch: wt.branch, url, periscopeRecordId: row.id };
16534
+ }
16535
+
16536
+ // src/cli.ts
16376
16537
  import { dirname as dirOf, resolve as resolvePath } from "path";
16377
16538
 
16378
16539
  // src/ssh-keys.ts
@@ -17833,6 +17994,8 @@ function usage(problem, requested = false) {
17833
17994
  crew reap [route] kill orphaned servers, drop worktrees for closed tickets
17834
17995
  crew drop [route] NNN remove a merged ticket's worktree and branch
17835
17996
  crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
17997
+ crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]
17998
+ file a draft ticket, cut and prepare its worktree, start the repo's preview hook and record a Periscopes row (alias: start)
17836
17999
  crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
17837
18000
  crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
17838
18001
  crew sync [route] fast-forward the checkout and its worktrees from the remote
@@ -17925,6 +18088,7 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
17925
18088
  "merge",
17926
18089
  "passengers",
17927
18090
  "pause",
18091
+ "periscope",
17928
18092
  "poll",
17929
18093
  "ports",
17930
18094
  "projects",
@@ -18128,7 +18292,7 @@ if (command === "passenger-sync-daemon") {
18128
18292
  const DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set(["start", "stop", "status", "restart"]);
18129
18293
  const worktreeArgs = command === "worktree" ? splitWorktreeArgs(positional, cfg.routes.map((r) => r.route)) : void 0;
18130
18294
  const named = worktreeArgs ? worktreeArgs.route : command === "daemon" && DAEMON_SUBCOMMANDS.has(positional[1] ?? "") ? positional[2] : positional[1];
18131
- const fleetWide = command === "inbox" || command === "connect" || command === "disconnect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || command === "ship" || command === "projects" || command === "whoami" || releaseFleetWide || command === "pause" || command === "resume" || command === "reload" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1 || worktreeArgs !== void 0 && !named && cfg.routes.length > 1;
18295
+ const fleetWide = command === "inbox" || command === "connect" || command === "disconnect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || command === "ship" || command === "projects" || command === "periscope" || command === "whoami" || releaseFleetWide || command === "pause" || command === "resume" || command === "reload" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1 || worktreeArgs !== void 0 && !named && cfg.routes.length > 1;
18132
18296
  let route;
18133
18297
  try {
18134
18298
  route = fleetWide ? cfg.routes[0] : findRoute(cfg, named);
@@ -21209,7 +21373,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21209
21373
  const hitsWt = /* @__PURE__ */ new Map();
21210
21374
  for (const cand of candidatesWt) {
21211
21375
  const tr = new Tracker(cand, cfg.ship);
21212
- const hit = (await tr.openTickets()).find((t) => t[tr.contract.columns.key] === `ISSUE-${nWt}`);
21376
+ const hit = (await tr.openTickets()).find((t) => t[tr.contract.columns.key] === `ISSUE-${nWt}`) ?? (await tr.ticketsByNumbers([nWt])).find((t) => t[tr.contract.columns.key] === `ISSUE-${nWt}` && t[tr.contract.columns.status] === tr.contract.statuses.draft);
21213
21377
  if (hit) hitsWt.set(cand.route, hit);
21214
21378
  lookedWt.push({
21215
21379
  route: cand.route,
@@ -21256,6 +21420,131 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21256
21420
  if (!planIsUsable(planWt)) process.exitCode = 1;
21257
21421
  break;
21258
21422
  }
21423
+ case "periscope": {
21424
+ const subP = positional[1];
21425
+ if (subP !== "up" && subP !== "start") {
21426
+ process.stderr.write("crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]\n");
21427
+ process.exit(2);
21428
+ }
21429
+ const jsonP = flag("json");
21430
+ const say = (event, message, data = {}) => process.stderr.write(jsonP ? `${JSON.stringify({ ...data, event, message })}
21431
+ ` : `${message}
21432
+ `);
21433
+ try {
21434
+ const a = parsePeriscopeUpArgs(positional, { topic: value("topic"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
21435
+ const candidatesP = a.route ? [findRoute(cfg, a.route)] : cfg.routes.filter((r) => r.enabled);
21436
+ const servedP = [];
21437
+ for (const cand of candidatesP) {
21438
+ const hit = (await resolvedRepos(cand)).find((r) => r.name === a.repo);
21439
+ if (hit) servedP.push({ route: cand, repo: hit });
21440
+ }
21441
+ if (servedP.length !== 1) {
21442
+ throw new PeriscopeError(servedP.length === 0 ? `no route serves a repository named "${a.repo}"` : `"${a.repo}" is served by more than one route (${servedP.map((x) => x.route.route).join(", ")}) \u2014 name the route`);
21443
+ }
21444
+ const { route: routeP, repo: repoP } = servedP[0];
21445
+ if (!routeP.resolved) throw new PeriscopeError(`route ${routeP.route} is not connected \u2014 run \`crew connect\` first`);
21446
+ const trackerP = new Tracker(routeP, cfg.ship);
21447
+ const cP = trackerP.contract;
21448
+ const periscopes = await trackerP.periscopesStatus();
21449
+ const caps = periscopeCaps(cfg.ship);
21450
+ const shellP = resolveShell(repoP.config.shell ?? cfg.ship.shell);
21451
+ const pairId = a.assignee ?? routeP.resolved.seats.pair;
21452
+ if (!pairId) throw new PeriscopeError(`route ${routeP.route} has no Pair seat \u2014 pass --assignee CREW_ROW_ID`);
21453
+ const crewP = await trackerP.crewRows();
21454
+ const operatorName = crewP.find((r) => r.id === routeP.resolved.operator)?.name ?? "Pair";
21455
+ const memberId = routeP.resolved.seats.pair ?? routeP.resolved.operator;
21456
+ const deps = {
21457
+ previewHook: repoP.config.hooks.preview,
21458
+ hasPeriscopesTable: periscopes.present,
21459
+ liveCount: () => trackerP.livePeriscopeCount(),
21460
+ caps,
21461
+ baseProblem: () => {
21462
+ const h = baseHealth(repoP);
21463
+ return h.unsafe ? h.detail : void 0;
21464
+ },
21465
+ askTopic: async () => {
21466
+ if (!process.stdin.isTTY || jsonP) return void 0;
21467
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
21468
+ try {
21469
+ return await rl.question("Periscope topic: ");
21470
+ } finally {
21471
+ rl.close();
21472
+ }
21473
+ },
21474
+ fileDraft: async (title) => {
21475
+ const t = await trackerP.fileTicket({
21476
+ title,
21477
+ description: `A periscope: a throwaway preview of ${a.repo} for a viewer, opened by \`crew periscope up\`.`,
21478
+ report_type: "feature",
21479
+ reporter_name: operatorName,
21480
+ [cP.columns.status]: cP.statuses.draft,
21481
+ [cP.columns.assignee]: pairId,
21482
+ ...routeP.resolved.areaId ? { [cP.columns.slice]: routeP.resolved.areaId } : {},
21483
+ ...repoP.id ? { [cP.columns.repo]: repoP.id } : {}
21484
+ }, memberId);
21485
+ return { id: t.id, tag: t.issue_tag ?? t.issue_id, issueId: t.issue_id, prefix: t.project_issue_prefix };
21486
+ },
21487
+ cutWorktree: (draft) => {
21488
+ const plan = planRunWorktree(repoP.dir, repoP.config, { issue_id: draft.issueId, issue_tag: draft.tag, project_issue_prefix: draft.prefix }, void 0);
21489
+ if (!planIsUsable(plan)) throw new PeriscopeError(`cannot use the worktree: ${describeRunWorktreePlan(plan)}`);
21490
+ const path = applyRunWorktree(repoP.dir, repoP.config, plan);
21491
+ if (!path) throw new PeriscopeError("the worktree could not be cut");
21492
+ return { path, branch: plan.branch ?? "" };
21493
+ },
21494
+ prepare: async (path) => {
21495
+ for (const f of repoP.config.worktrees.copy) {
21496
+ const src = join38(repoP.dir, f);
21497
+ if (!existsSync35(src)) continue;
21498
+ mkdirSync22(dirOf(join38(path, f)), { recursive: true });
21499
+ copyFileSync2(src, join38(path, f));
21500
+ }
21501
+ const setup = repoP.config.hooks.setup;
21502
+ return setup ? runScript(setup, { cwd: path, shell: shellP, onLine: (l) => {
21503
+ if (l.trim()) say("setup", l.trim());
21504
+ } }) : null;
21505
+ },
21506
+ runHook: async (name, cwd, env) => {
21507
+ const script = repoP.config.hooks[name];
21508
+ return script ? runScript(script, { cwd, env, shell: shellP, timeoutMs: name === "preview" ? 18e4 : void 0 }) : null;
21509
+ },
21510
+ createRow: (fields) => trackerP.createPeriscope(fields),
21511
+ comment: (ticketId, body) => trackerP.postComment(ticketId, body, memberId),
21512
+ teardown: async (path, branch) => {
21513
+ const n = Number(path.match(/(\d+)\/?$/)?.[1]);
21514
+ if (Number.isFinite(n)) await killGently(pidsInWorktree(path, n));
21515
+ gitOk(repoP.dir, ["worktree", "remove", "--force", path]);
21516
+ gitOk(repoP.dir, ["branch", "-D", branch]);
21517
+ },
21518
+ closeDraft: async (ticketId, note) => {
21519
+ const closed = cP.statuses.resolved.includes("closed_obsolete") ? "closed_obsolete" : cP.statuses.wontFix;
21520
+ await trackerP.updateTicket(ticketId, { [cP.columns.status]: closed, [cP.columns.resolutionNote]: note });
21521
+ },
21522
+ now: () => /* @__PURE__ */ new Date(),
21523
+ host: a.host ?? "localhost",
21524
+ say
21525
+ };
21526
+ if (dryRun) {
21527
+ await periscopePreflight(deps, a.repo);
21528
+ 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)`);
21529
+ break;
21530
+ }
21531
+ const res = await runPeriscopeUp(deps, a);
21532
+ if (jsonP) process.stdout.write(`${JSON.stringify({ event: "done", ...res })}
21533
+ `);
21534
+ else process.stdout.write(`${res.ticket}
21535
+ ${res.worktree}
21536
+ ${res.url}
21537
+ `);
21538
+ } catch (e) {
21539
+ if (e instanceof PeriscopeError || e instanceof ConfigError) {
21540
+ process.stderr.write(`crew periscope: ${e.message}
21541
+ `);
21542
+ process.exit(1);
21543
+ }
21544
+ throw e;
21545
+ }
21546
+ break;
21547
+ }
21259
21548
  case "unassign": {
21260
21549
  emit.enter("select");
21261
21550
  const rawUnassign = positional[2] ?? positional[1];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.84.0",
3
+ "version": "0.85.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",