@tablation/crew 0.84.0 → 0.86.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 +563 -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,59 @@ 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
+ }
6908
+ /**
6909
+ * This ship's Periscopes rows (EPIC-077), newest first, with the ticket each
6910
+ * belongs to. Empty when there is no Periscopes table or no Ships row yet.
6911
+ */
6912
+ async periscopeRows(states) {
6913
+ const modelId = this.route.resolved?.periscopesModelId;
6914
+ const { shipId } = await this.authorshipProbe();
6915
+ if (!modelId || !shipId) return [];
6916
+ const conds = [{ columnName: "ship_id", operator: "EQ", value: shipId }];
6917
+ if (states?.length) conds.push({ columnName: "state", operator: "IN", value: states });
6918
+ const rows = await this.client.records.list(modelId, { filters: encodeFilters(conds), limit: 200 });
6919
+ const refId = (v) => typeof v === "string" ? v : v && typeof v === "object" && typeof v.id === "string" ? v.id : void 0;
6920
+ const ids = [...new Set(rows.map((r) => refId(r.issue_id)).filter((x) => !!x))];
6921
+ const tickets = new Map((await this.ticketsByIds(ids)).map((t) => [t.id, t]));
6922
+ const out = [];
6923
+ for (const row of rows) {
6924
+ const t = tickets.get(refId(row.issue_id) ?? "");
6925
+ if (t) out.push({ row, ticket: t });
6926
+ }
6927
+ return out.sort((a, b) => String(b.row.started_at ?? "").localeCompare(String(a.row.started_at ?? "")));
6928
+ }
6929
+ /** Updates one Periscopes row (EPIC-077). */
6930
+ async updatePeriscope(id, patch2) {
6931
+ const modelId = this.route.resolved?.periscopesModelId;
6932
+ if (!modelId) throw new Error("this workspace has no Periscopes table");
6933
+ await this.client.records.update(modelId, id, patch2);
6934
+ }
6882
6935
  /**
6883
6936
  * The authorship columns this workspace is missing, as `table.column` names
6884
6937
  * for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
@@ -9102,8 +9155,8 @@ function spawnAgent(plan, emit, hintMoved) {
9102
9155
  const meta = { pid: child.pid ?? null };
9103
9156
  obs.onClaim = () => writeRunMeta(id, startedAt, obs.liveTicket, meta);
9104
9157
  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);
9158
+ const tail2 = new FileTail(plan.streamPath);
9159
+ const timer = setInterval(() => obs.feed(tail2.poll()), TAIL_POLL_MS);
9107
9160
  let spawnFailed = false;
9108
9161
  child.on("error", (err) => {
9109
9162
  spawnFailed = true;
@@ -9112,7 +9165,7 @@ function spawnAgent(plan, emit, hintMoved) {
9112
9165
  });
9113
9166
  child.on("close", async (code) => {
9114
9167
  clearInterval(timer);
9115
- obs.feed(tail.poll());
9168
+ obs.feed(tail2.poll());
9116
9169
  obs.end();
9117
9170
  await endSink(eventsSink);
9118
9171
  if (spawnFailed) return;
@@ -9131,20 +9184,20 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
9131
9184
  const id = { ...sc, streamPath };
9132
9185
  const eventsSink = openSink(sc.eventsPath);
9133
9186
  const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
9134
- const tail = new FileTail(streamPath);
9187
+ const tail2 = new FileTail(streamPath);
9135
9188
  obs.muted = true;
9136
- obs.feed(tail.poll());
9189
+ obs.feed(tail2.poll());
9137
9190
  obs.muted = false;
9138
9191
  const adopted = isAlive(sc);
9139
9192
  if (adopted) {
9140
9193
  o.onAdopted?.();
9141
9194
  const pollMs = o.pollMs ?? TAIL_POLL_MS;
9142
9195
  while (isAlive(sc)) {
9143
- obs.feed(tail.poll());
9196
+ obs.feed(tail2.poll());
9144
9197
  await new Promise((r) => setTimeout(r, pollMs));
9145
9198
  }
9146
9199
  }
9147
- obs.feed(tail.poll());
9200
+ obs.feed(tail2.poll());
9148
9201
  obs.end();
9149
9202
  await endSink(eventsSink);
9150
9203
  const interrupted = obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
@@ -9692,7 +9745,9 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
9692
9745
  "toolchain",
9693
9746
  "workspace",
9694
9747
  "workspace-id",
9695
- "write"
9748
+ "write",
9749
+ "topic",
9750
+ "assignee"
9696
9751
  ]);
9697
9752
  function positionalArgs(argv2) {
9698
9753
  const out = [];
@@ -13032,7 +13087,7 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
13032
13087
  }
13033
13088
  if (culprits.length === 0 && truncated) {
13034
13089
  let lo = 2, hi = applied.length;
13035
- let tail = lastLines(output, HOOK_FAILURE_TAIL_LINES);
13090
+ let tail2 = lastLines(output, HOOK_FAILURE_TAIL_LINES);
13036
13091
  while (lo < hi) {
13037
13092
  const mid = Math.floor((lo + hi) / 2);
13038
13093
  gitOk(o.cwd, ["reset", "--hard", preMerge]);
@@ -13047,12 +13102,12 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
13047
13102
  const r = clean ? await hook(o, name) : null;
13048
13103
  if (r && r.code !== 0) {
13049
13104
  hi = mid;
13050
- tail = lastLines(r.output, HOOK_FAILURE_TAIL_LINES);
13105
+ tail2 = lastLines(r.output, HOOK_FAILURE_TAIL_LINES);
13051
13106
  } else lo = mid + 1;
13052
13107
  }
13053
13108
  const owner = applied[hi - 1];
13054
13109
  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 });
13110
+ culprits.push({ c: owner, how: "pair", sibling: applied[hi - 2], tail: tail2 });
13056
13111
  }
13057
13112
  }
13058
13113
  }
@@ -16373,6 +16428,242 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
16373
16428
  // src/cli.ts
16374
16429
  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
16430
  import { createInterface } from "readline/promises";
16431
+
16432
+ // src/periscope-cmd.ts
16433
+ var PeriscopeError = class extends Error {
16434
+ };
16435
+ var DEFAULT_MAX_PERISCOPES2 = 3;
16436
+ var DEFAULT_PERISCOPE_IDLE_MINUTES2 = 120;
16437
+ function parsePeriscopeUpArgs(positional2, flags) {
16438
+ const rest = positional2.slice(2);
16439
+ if (rest.length === 0 || rest.length > 2) {
16440
+ throw new PeriscopeError("usage: crew periscope up [route] REPO [--topic TEXT] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--json]");
16441
+ }
16442
+ const topic = flags.topic?.trim();
16443
+ return {
16444
+ ...rest.length === 2 ? { route: rest[0] } : {},
16445
+ repo: rest[rest.length - 1],
16446
+ ...topic ? { topic } : {},
16447
+ isolate: flags.isolate,
16448
+ ...flags.host ? { host: flags.host } : {},
16449
+ ...flags.assignee ? { assignee: flags.assignee } : {},
16450
+ json: flags.json
16451
+ };
16452
+ }
16453
+ function parseEnvLines(output) {
16454
+ const env = {};
16455
+ for (const line of output.split("\n")) {
16456
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim());
16457
+ if (m) env[m[1]] = m[2].replace(/^"(.*)"$/, "$1");
16458
+ }
16459
+ return env;
16460
+ }
16461
+ function previewUrlFrom(output) {
16462
+ const last = output.split("\n").map((l) => l.trim()).filter(Boolean).pop();
16463
+ return last && /^https?:\/\/\S+$/i.test(last) ? last : void 0;
16464
+ }
16465
+ function periscopeCaps(l) {
16466
+ return {
16467
+ max: l.maxPeriscopes ?? DEFAULT_MAX_PERISCOPES2,
16468
+ idleMinutes: l.periscopeIdleMinutes ?? DEFAULT_PERISCOPE_IDLE_MINUTES2
16469
+ };
16470
+ }
16471
+ var tail = (s, n = 20) => s.trimEnd().split("\n").slice(-n).join("\n");
16472
+ function progressComment(url, handoff) {
16473
+ return [
16474
+ `Periscope is up: ${url}`,
16475
+ ...handoff?.trim() ? ["", handoff.trim()] : []
16476
+ ].join("\n");
16477
+ }
16478
+ async function periscopePreflight(d, repo) {
16479
+ if (!d.previewHook) {
16480
+ 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")`);
16481
+ }
16482
+ if (!d.hasPeriscopesTable) {
16483
+ throw new PeriscopeError("this workspace has no Periscopes table \u2014 update the Issues template and run `crew connect`");
16484
+ }
16485
+ const live = await d.liveCount();
16486
+ if (live >= d.caps.max) {
16487
+ throw new PeriscopeError(`this ship already hosts ${live} live periscope(s) (ship.maxPeriscopes is ${d.caps.max}) \u2014 discard one first`);
16488
+ }
16489
+ }
16490
+ async function runPeriscopeUp(d, args) {
16491
+ await periscopePreflight(d, args.repo);
16492
+ const topic = args.topic ?? (await d.askTopic())?.trim();
16493
+ if (!topic) throw new PeriscopeError("a topic is required: pass --topic TEXT");
16494
+ const unsafe = d.baseProblem();
16495
+ if (unsafe) throw new PeriscopeError(`DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${unsafe}`);
16496
+ const draft = await d.fileDraft(`Periscope: ${topic}`);
16497
+ d.say("filed", `filed draft ${draft.tag}`, { ticket: draft.tag, ticketRecordId: draft.id });
16498
+ let wt;
16499
+ const abandon = async (why) => {
16500
+ if (wt) await d.teardown(wt.path, wt.branch).catch(() => {
16501
+ });
16502
+ await d.closeDraft(draft.id, `Periscope abandoned: ${why.split("\n")[0]}`).catch(() => {
16503
+ });
16504
+ throw new PeriscopeError(why);
16505
+ };
16506
+ try {
16507
+ wt = d.cutWorktree(draft);
16508
+ } catch (e) {
16509
+ return abandon(e.message);
16510
+ }
16511
+ d.say("worktree", `cut ${wt.path} on ${wt.branch}`, { worktree: wt.path, branch: wt.branch });
16512
+ const prepared = await d.prepare(wt.path);
16513
+ if (prepared && prepared.code !== 0) {
16514
+ return abandon(`setup failed (exit ${prepared.code})
16515
+ ${tail(prepared.output)}`);
16516
+ }
16517
+ const env = { CREW_TICKET: draft.tag, CREW_BRANCH: wt.branch };
16518
+ const ports = await d.runHook("ports", wt.path, env);
16519
+ if (ports) Object.assign(env, parseEnvLines(ports.output));
16520
+ if (args.isolate) {
16521
+ const iso = await d.runHook("isolate", wt.path, env);
16522
+ if (iso && iso.code !== 0) return abandon(`isolate failed (exit ${iso.code})
16523
+ ${tail(iso.output)}`);
16524
+ if (iso) Object.assign(env, parseEnvLines(iso.output));
16525
+ }
16526
+ const handoff = await d.runHook("handoff", wt.path, env);
16527
+ if (handoff && handoff.code !== 0) return abandon(`handoff failed (exit ${handoff.code})
16528
+ ${tail(handoff.output)}`);
16529
+ const preview = await d.runHook("preview", wt.path, { ...env, CREW_PREVIEW_HOST: d.host });
16530
+ if (!preview || preview.code !== 0) {
16531
+ return abandon(`preview exited ${preview?.code ?? "without running"}
16532
+ ${tail(preview?.output ?? "")}`);
16533
+ }
16534
+ const url = previewUrlFrom(preview.output);
16535
+ if (!url) return abandon(`preview printed no URL as its last line
16536
+ ${tail(preview.output)}`);
16537
+ d.say("preview", `preview at ${url}`, { url });
16538
+ const now = d.now();
16539
+ let row;
16540
+ try {
16541
+ row = await d.createRow({
16542
+ issue_id: draft.id,
16543
+ url,
16544
+ host: d.host,
16545
+ worktree: wt.path,
16546
+ branch: wt.branch,
16547
+ state: "live",
16548
+ isolated: args.isolate,
16549
+ started_at: now.toISOString(),
16550
+ last_activity_at: now.toISOString(),
16551
+ expires_at: new Date(now.getTime() + d.caps.idleMinutes * 6e4).toISOString()
16552
+ });
16553
+ } catch (e) {
16554
+ return abandon(`recording the Periscopes row failed: ${e.message}`);
16555
+ }
16556
+ d.say("recorded", "recorded the Periscopes row", { periscopeRecordId: row.id });
16557
+ await d.comment(draft.id, progressComment(url, handoff?.output)).catch((e) => {
16558
+ d.say("warning", `progress comment not posted: ${e.message}`);
16559
+ });
16560
+ return { ticket: draft.tag, ticketRecordId: draft.id, worktree: wt.path, branch: wt.branch, url, periscopeRecordId: row.id };
16561
+ }
16562
+ function parsePeriscopeDownArgs(positional2, flags) {
16563
+ const usage2 = "usage: crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]";
16564
+ const rest = positional2.slice(2);
16565
+ if (rest.length === 0 || rest.length > 2) throw new PeriscopeError(usage2);
16566
+ if (flags.keep === flags.discard) throw new PeriscopeError(`pass exactly one of --keep or --discard
16567
+ ${usage2}`);
16568
+ const m = /^(?:[A-Za-z]+-)?(\d+)$/.exec(rest[rest.length - 1]);
16569
+ if (!m) throw new PeriscopeError(`"${rest[rest.length - 1]}" is not a ticket number
16570
+ ${usage2}`);
16571
+ return {
16572
+ ...rest.length === 2 ? { route: rest[0] } : {},
16573
+ number: m[1],
16574
+ mode: flags.keep ? "keep" : "discard",
16575
+ allowEmpty: flags.allowEmpty,
16576
+ json: flags.json
16577
+ };
16578
+ }
16579
+ var PROTOTYPE_HEADING = "## Prototype";
16580
+ function prototypeSection(files) {
16581
+ const added = files.reduce((n, f) => n + f.added, 0);
16582
+ const removed = files.reduce((n, f) => n + f.removed, 0);
16583
+ return [
16584
+ PROTOTYPE_HEADING,
16585
+ "",
16586
+ "This branch began as a Periscope prototype and may be rewritten rather than treated as reviewed work.",
16587
+ "",
16588
+ ...files.length === 0 ? ["No files differ from the base."] : files.map((f) => `- \`${f.path}\` (+${f.added} \u2212${f.removed})`),
16589
+ "",
16590
+ `${files.length} file${files.length === 1 ? "" : "s"} changed, ${added} insertion${added === 1 ? "" : "s"}(+), ${removed} deletion${removed === 1 ? "" : "s"}(\u2212)`
16591
+ ].join("\n");
16592
+ }
16593
+ async function runPeriscopeDown(d, args) {
16594
+ const rows = await d.rowsForTicket(args.number);
16595
+ if (rows.length === 0) throw new PeriscopeError(`no periscope for ticket ${args.number} on this ship`);
16596
+ const row = rows.find((r) => r.state === "live");
16597
+ if (!row) {
16598
+ const done = rows[0];
16599
+ d.say("unchanged", `${done.ticket} is already ${done.state}; nothing to do`);
16600
+ return { ticket: done.ticket, mode: args.mode, outcome: "unchanged", state: done.state };
16601
+ }
16602
+ if (args.mode === "discard") {
16603
+ const killed2 = await d.stopServer(row.worktree, row.number);
16604
+ d.say("server", killed2 ? `stopped ${killed2} process(es)` : "no server was listening");
16605
+ d.removeWorktree(row.worktree, row.branch, true);
16606
+ d.say("worktree", `removed ${row.worktree} and branch ${row.branch}`);
16607
+ await d.updateTicket(row.ticketRecordId, {
16608
+ status: "discarded",
16609
+ fillProjectAndRepo: false,
16610
+ note: "The periscope was discarded; nothing was pushed."
16611
+ });
16612
+ d.say("ticket", `${row.ticket} closed as obsolete`);
16613
+ await d.setRowState(row.id, "discarded");
16614
+ d.say("row", "row is discarded");
16615
+ return { ticket: row.ticket, mode: "discard", outcome: "done", state: "discarded" };
16616
+ }
16617
+ const dirty = d.hasChanges(row.worktree);
16618
+ if (!dirty && !args.allowEmpty) {
16619
+ throw new PeriscopeError(`${row.ticket} has nothing to commit; pass --allow-empty to keep the branch anyway, or --discard`);
16620
+ }
16621
+ if (dirty) {
16622
+ d.commitAll(row.worktree, `${row.ticket} Periscope prototype`);
16623
+ d.say("commit", `committed the worktree on ${row.branch}`);
16624
+ }
16625
+ const pushed = d.push(row.worktree, row.branch);
16626
+ d.say("push", `pushed ${pushed.sha} to ${pushed.remote}/${row.branch}`, { sha: pushed.sha });
16627
+ const t = await d.readTicket(row.ticketRecordId);
16628
+ const section = prototypeSection(d.changedFiles(row.worktree));
16629
+ const description = t.description.includes(PROTOTYPE_HEADING) ? t.description : `${t.description.trimEnd()}
16630
+
16631
+ ${section}`;
16632
+ await d.updateTicket(row.ticketRecordId, {
16633
+ description,
16634
+ fillProjectAndRepo: !t.hasProject || !t.hasRepo,
16635
+ status: "new"
16636
+ });
16637
+ d.say("ticket", `${row.ticket} is new and unassigned with a Prototype section`);
16638
+ await d.postPushed(row.ticketRecordId, pushed.sha, pushed.remote, row.branch);
16639
+ const killed = await d.stopServer(row.worktree, row.number);
16640
+ d.say("server", killed ? `stopped ${killed} process(es)` : "no server was listening");
16641
+ d.removeWorktree(row.worktree, row.branch, false);
16642
+ d.say("worktree", `removed ${row.worktree}; ${row.branch} stays, pushed`);
16643
+ await d.setRowState(row.id, "kept");
16644
+ d.say("row", "row is kept");
16645
+ return { ticket: row.ticket, mode: "keep", outcome: "done", state: "kept", sha: pushed.sha };
16646
+ }
16647
+ function periscopeListEntries(rows, now, isListening) {
16648
+ return rows.filter((r) => r.state === "live").map((r) => {
16649
+ const at = r.lastActivityAt ? new Date(r.lastActivityAt).getTime() : NaN;
16650
+ return { ...r, idleMinutes: Number.isNaN(at) ? null : Math.max(0, Math.floor((now.getTime() - at) / 6e4)), listening: isListening(r) };
16651
+ });
16652
+ }
16653
+ function formatPeriscopeList(entries) {
16654
+ if (entries.length === 0) return "no live periscopes on this ship";
16655
+ return entries.map((e) => [
16656
+ e.ticket,
16657
+ e.repo,
16658
+ e.worktree,
16659
+ e.url,
16660
+ e.state,
16661
+ e.idleMinutes === null ? "idle ?" : `idle ${e.idleMinutes} min`,
16662
+ e.listening ? "listening" : "not listening"
16663
+ ].join(" ")).join("\n");
16664
+ }
16665
+
16666
+ // src/cli.ts
16376
16667
  import { dirname as dirOf, resolve as resolvePath } from "path";
16377
16668
 
16378
16669
  // src/ssh-keys.ts
@@ -17833,6 +18124,11 @@ function usage(problem, requested = false) {
17833
18124
  crew reap [route] kill orphaned servers, drop worktrees for closed tickets
17834
18125
  crew drop [route] NNN remove a merged ticket's worktree and branch
17835
18126
  crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
18127
+ crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]
18128
+ file a draft ticket, cut and prepare its worktree, start the repo's preview hook and record a Periscopes row (alias: start)
18129
+ crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]
18130
+ end a periscope: commit and push its branch (ticket back to new), or throw it away (alias: end)
18131
+ crew periscope list [route] [--json] the live periscopes on this ship, their idle minutes and whether each server is listening
17836
18132
  crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
17837
18133
  crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
17838
18134
  crew sync [route] fast-forward the checkout and its worktrees from the remote
@@ -17925,6 +18221,7 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
17925
18221
  "merge",
17926
18222
  "passengers",
17927
18223
  "pause",
18224
+ "periscope",
17928
18225
  "poll",
17929
18226
  "ports",
17930
18227
  "projects",
@@ -18128,7 +18425,7 @@ if (command === "passenger-sync-daemon") {
18128
18425
  const DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set(["start", "stop", "status", "restart"]);
18129
18426
  const worktreeArgs = command === "worktree" ? splitWorktreeArgs(positional, cfg.routes.map((r) => r.route)) : void 0;
18130
18427
  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;
18428
+ 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
18429
  let route;
18133
18430
  try {
18134
18431
  route = fleetWide ? cfg.routes[0] : findRoute(cfg, named);
@@ -21209,7 +21506,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21209
21506
  const hitsWt = /* @__PURE__ */ new Map();
21210
21507
  for (const cand of candidatesWt) {
21211
21508
  const tr = new Tracker(cand, cfg.ship);
21212
- const hit = (await tr.openTickets()).find((t) => t[tr.contract.columns.key] === `ISSUE-${nWt}`);
21509
+ 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
21510
  if (hit) hitsWt.set(cand.route, hit);
21214
21511
  lookedWt.push({
21215
21512
  route: cand.route,
@@ -21256,6 +21553,258 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21256
21553
  if (!planIsUsable(planWt)) process.exitCode = 1;
21257
21554
  break;
21258
21555
  }
21556
+ case "periscope": {
21557
+ const subP = positional[1];
21558
+ if (subP !== "up" && subP !== "start" && subP !== "down" && subP !== "end" && subP !== "list") {
21559
+ process.stderr.write("crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]\n");
21560
+ process.stderr.write("crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]\n");
21561
+ process.stderr.write("crew periscope list [route] [--json]\n");
21562
+ process.exit(2);
21563
+ }
21564
+ const jsonP = flag("json");
21565
+ const say = (event, message, data = {}) => process.stderr.write(jsonP ? `${JSON.stringify({ ...data, event, message })}
21566
+ ` : `${message}
21567
+ `);
21568
+ if (subP === "down" || subP === "end" || subP === "list") {
21569
+ try {
21570
+ const downArgs = subP === "list" ? void 0 : parsePeriscopeDownArgs(positional, { keep: flag("keep"), discard: flag("discard"), allowEmpty: flag("allow-empty"), json: jsonP });
21571
+ const routeName = subP === "list" ? positional[2] : downArgs.route;
21572
+ const routesP = (routeName ? [findRoute(cfg, routeName)] : cfg.routes.filter((r) => r.enabled)).filter((r) => r.resolved?.periscopesModelId);
21573
+ if (routesP.length === 0) throw new PeriscopeError("no connected route has a Periscopes table");
21574
+ const gather = async (rt) => {
21575
+ const tr = new Tracker(rt, cfg.ship);
21576
+ const repos = await resolvedRepos(rt);
21577
+ return (await tr.periscopeRows()).map(({ row, ticket }) => {
21578
+ const repo = repos.find((r) => r.id && r.id === ticket[tr.contract.columns.repo]) ?? (repos.length === 1 ? repos[0] : void 0);
21579
+ const str = (v) => typeof v === "string" ? v : "";
21580
+ return {
21581
+ id: row.id,
21582
+ ticketRecordId: ticket.id,
21583
+ ticket: ticket.issue_tag ?? ticket.issue_id,
21584
+ number: /(\d+)$/.exec(ticket.issue_id)?.[1] ?? "",
21585
+ repo: repo?.name ?? "?",
21586
+ state: str(row.state),
21587
+ url: str(row.url),
21588
+ worktree: str(row.worktree),
21589
+ branch: str(row.branch),
21590
+ lastActivityAt: str(row.last_activity_at) || null
21591
+ };
21592
+ });
21593
+ };
21594
+ if (subP === "list") {
21595
+ const entries = periscopeListEntries(
21596
+ (await Promise.all(routesP.map(gather))).flat(),
21597
+ /* @__PURE__ */ new Date(),
21598
+ (r) => pidsInWorktree(r.worktree, Number(r.number)).length > 0
21599
+ );
21600
+ process.stdout.write(jsonP ? `${JSON.stringify(entries)}
21601
+ ` : `${formatPeriscopeList(entries)}
21602
+ `);
21603
+ break;
21604
+ }
21605
+ const a = downArgs;
21606
+ const hits = [];
21607
+ for (const rt of routesP) {
21608
+ const rows = (await gather(rt)).filter((r) => r.number === a.number);
21609
+ if (rows.length) hits.push({ rt, rows });
21610
+ }
21611
+ if (hits.length !== 1) {
21612
+ throw new PeriscopeError(hits.length === 0 ? `no periscope for ticket ${a.number} on this ship` : `ticket ${a.number} has periscopes on more than one route (${hits.map((h) => h.rt.route).join(", ")}) \u2014 name the route`);
21613
+ }
21614
+ const { rt: routeD, rows: rowsD } = hits[0];
21615
+ const trD = new Tracker(routeD, cfg.ship);
21616
+ const cD = trD.contract;
21617
+ const reposD = await resolvedRepos(routeD);
21618
+ const repoD = reposD.find((r) => r.name === rowsD[0].repo);
21619
+ if (!repoD) throw new PeriscopeError(`cannot tell which repository ${rowsD[0].ticket} belongs to`);
21620
+ const { remote, base } = repoD.config.branch;
21621
+ const memberD = routeD.resolved.seats.pair ?? routeD.resolved.operator;
21622
+ const closedD = cD.statuses.resolved.includes("closed_obsolete") ? "closed_obsolete" : cD.statuses.wontFix;
21623
+ const depsD = {
21624
+ rowsForTicket: async () => rowsD,
21625
+ hasChanges: (wt) => status(wt).length > 0,
21626
+ commitAll: (wt, msg) => {
21627
+ git(wt, ["add", "-A"]);
21628
+ git(wt, ["commit", "-m", msg]);
21629
+ },
21630
+ push: (wt, branch) => {
21631
+ pushBranchUpstream(wt, remote, branch);
21632
+ return { sha: headSha(wt), remote };
21633
+ },
21634
+ changedFiles: (wt) => {
21635
+ const out = gitOk(wt, ["diff", "--numstat", `${remote}/${base}...HEAD`]) ?? "";
21636
+ return out.split("\n").filter(Boolean).map((l) => {
21637
+ const [add, rem, ...p] = l.split(" ");
21638
+ return { path: p.join(" "), added: Number(add) || 0, removed: Number(rem) || 0 };
21639
+ });
21640
+ },
21641
+ readTicket: async (id) => {
21642
+ const t = await trD.ticket(id);
21643
+ return { description: String(t.description ?? ""), hasProject: !!t[cD.columns.slice], hasRepo: !!t[cD.columns.repo] };
21644
+ },
21645
+ updateTicket: async (id, p) => {
21646
+ const patch2 = {};
21647
+ if (p.status === "new") {
21648
+ patch2[cD.columns.status] = "new";
21649
+ patch2[cD.columns.assignee] = null;
21650
+ if (p.description !== void 0) patch2.description = p.description;
21651
+ if (p.fillProjectAndRepo) {
21652
+ if (routeD.resolved.areaId) patch2[cD.columns.slice] = routeD.resolved.areaId;
21653
+ if (repoD.id) patch2[cD.columns.repo] = repoD.id;
21654
+ }
21655
+ } else {
21656
+ patch2[cD.columns.status] = closedD;
21657
+ patch2[cD.columns.resolutionNote] = p.note;
21658
+ }
21659
+ await trD.updateTicket(id, patch2);
21660
+ },
21661
+ postPushed: (id, sha, rem, branch) => trD.postEvent(id, pushedEventBody(sha, rem, branch), memberD, "pushed"),
21662
+ stopServer: async (wt, n) => {
21663
+ const pids = pidsInWorktree(wt, Number(n));
21664
+ if (pids.length) await killGently(pids);
21665
+ return pids.length;
21666
+ },
21667
+ removeWorktree: (wt, branch, deleteBranch) => {
21668
+ gitOk(repoD.dir, ["worktree", "remove", "--force", wt]);
21669
+ if (deleteBranch) gitOk(repoD.dir, ["branch", "-D", branch]);
21670
+ },
21671
+ setRowState: (id, state2) => trD.updatePeriscope(id, { state: state2 }),
21672
+ say
21673
+ };
21674
+ if (dryRun) {
21675
+ say("dry-run", `would end the periscope for ${rowsD[0].ticket} (${a.mode})`);
21676
+ break;
21677
+ }
21678
+ const res = await runPeriscopeDown(depsD, a);
21679
+ if (jsonP) process.stdout.write(`${JSON.stringify({ event: "done", ...res })}
21680
+ `);
21681
+ else process.stdout.write(`${res.ticket} ${res.state}
21682
+ `);
21683
+ } catch (e) {
21684
+ if (e instanceof PeriscopeError || e instanceof ConfigError) {
21685
+ process.stderr.write(`crew periscope: ${e.message}
21686
+ `);
21687
+ process.exit(1);
21688
+ }
21689
+ throw e;
21690
+ }
21691
+ break;
21692
+ }
21693
+ try {
21694
+ const a = parsePeriscopeUpArgs(positional, { topic: value("topic"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
21695
+ const candidatesP = a.route ? [findRoute(cfg, a.route)] : cfg.routes.filter((r) => r.enabled);
21696
+ const servedP = [];
21697
+ for (const cand of candidatesP) {
21698
+ const hit = (await resolvedRepos(cand)).find((r) => r.name === a.repo);
21699
+ if (hit) servedP.push({ route: cand, repo: hit });
21700
+ }
21701
+ if (servedP.length !== 1) {
21702
+ 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`);
21703
+ }
21704
+ const { route: routeP, repo: repoP } = servedP[0];
21705
+ if (!routeP.resolved) throw new PeriscopeError(`route ${routeP.route} is not connected \u2014 run \`crew connect\` first`);
21706
+ const trackerP = new Tracker(routeP, cfg.ship);
21707
+ const cP = trackerP.contract;
21708
+ const periscopes = await trackerP.periscopesStatus();
21709
+ const caps = periscopeCaps(cfg.ship);
21710
+ const shellP = resolveShell(repoP.config.shell ?? cfg.ship.shell);
21711
+ const pairId = a.assignee ?? routeP.resolved.seats.pair;
21712
+ if (!pairId) throw new PeriscopeError(`route ${routeP.route} has no Pair seat \u2014 pass --assignee CREW_ROW_ID`);
21713
+ const crewP = await trackerP.crewRows();
21714
+ const operatorName = crewP.find((r) => r.id === routeP.resolved.operator)?.name ?? "Pair";
21715
+ const memberId = routeP.resolved.seats.pair ?? routeP.resolved.operator;
21716
+ const deps = {
21717
+ previewHook: repoP.config.hooks.preview,
21718
+ hasPeriscopesTable: periscopes.present,
21719
+ liveCount: () => trackerP.livePeriscopeCount(),
21720
+ caps,
21721
+ baseProblem: () => {
21722
+ const h = baseHealth(repoP);
21723
+ return h.unsafe ? h.detail : void 0;
21724
+ },
21725
+ askTopic: async () => {
21726
+ if (!process.stdin.isTTY || jsonP) return void 0;
21727
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
21728
+ try {
21729
+ return await rl.question("Periscope topic: ");
21730
+ } finally {
21731
+ rl.close();
21732
+ }
21733
+ },
21734
+ fileDraft: async (title) => {
21735
+ const t = await trackerP.fileTicket({
21736
+ title,
21737
+ description: `A periscope: a throwaway preview of ${a.repo} for a viewer, opened by \`crew periscope up\`.`,
21738
+ report_type: "feature",
21739
+ reporter_name: operatorName,
21740
+ [cP.columns.status]: cP.statuses.draft,
21741
+ [cP.columns.assignee]: pairId,
21742
+ ...routeP.resolved.areaId ? { [cP.columns.slice]: routeP.resolved.areaId } : {},
21743
+ ...repoP.id ? { [cP.columns.repo]: repoP.id } : {}
21744
+ }, memberId);
21745
+ return { id: t.id, tag: t.issue_tag ?? t.issue_id, issueId: t.issue_id, prefix: t.project_issue_prefix };
21746
+ },
21747
+ cutWorktree: (draft) => {
21748
+ const plan = planRunWorktree(repoP.dir, repoP.config, { issue_id: draft.issueId, issue_tag: draft.tag, project_issue_prefix: draft.prefix }, void 0);
21749
+ if (!planIsUsable(plan)) throw new PeriscopeError(`cannot use the worktree: ${describeRunWorktreePlan(plan)}`);
21750
+ const path = applyRunWorktree(repoP.dir, repoP.config, plan);
21751
+ if (!path) throw new PeriscopeError("the worktree could not be cut");
21752
+ return { path, branch: plan.branch ?? "" };
21753
+ },
21754
+ prepare: async (path) => {
21755
+ for (const f of repoP.config.worktrees.copy) {
21756
+ const src = join38(repoP.dir, f);
21757
+ if (!existsSync35(src)) continue;
21758
+ mkdirSync22(dirOf(join38(path, f)), { recursive: true });
21759
+ copyFileSync2(src, join38(path, f));
21760
+ }
21761
+ const setup = repoP.config.hooks.setup;
21762
+ return setup ? runScript(setup, { cwd: path, shell: shellP, onLine: (l) => {
21763
+ if (l.trim()) say("setup", l.trim());
21764
+ } }) : null;
21765
+ },
21766
+ runHook: async (name, cwd, env) => {
21767
+ const script = repoP.config.hooks[name];
21768
+ return script ? runScript(script, { cwd, env, shell: shellP, timeoutMs: name === "preview" ? 18e4 : void 0 }) : null;
21769
+ },
21770
+ createRow: (fields) => trackerP.createPeriscope(fields),
21771
+ comment: (ticketId, body) => trackerP.postComment(ticketId, body, memberId),
21772
+ teardown: async (path, branch) => {
21773
+ const n = Number(path.match(/(\d+)\/?$/)?.[1]);
21774
+ if (Number.isFinite(n)) await killGently(pidsInWorktree(path, n));
21775
+ gitOk(repoP.dir, ["worktree", "remove", "--force", path]);
21776
+ gitOk(repoP.dir, ["branch", "-D", branch]);
21777
+ },
21778
+ closeDraft: async (ticketId, note) => {
21779
+ const closed = cP.statuses.resolved.includes("closed_obsolete") ? "closed_obsolete" : cP.statuses.wontFix;
21780
+ await trackerP.updateTicket(ticketId, { [cP.columns.status]: closed, [cP.columns.resolutionNote]: note });
21781
+ },
21782
+ now: () => /* @__PURE__ */ new Date(),
21783
+ host: a.host ?? "localhost",
21784
+ say
21785
+ };
21786
+ if (dryRun) {
21787
+ await periscopePreflight(deps, a.repo);
21788
+ 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)`);
21789
+ break;
21790
+ }
21791
+ const res = await runPeriscopeUp(deps, a);
21792
+ if (jsonP) process.stdout.write(`${JSON.stringify({ event: "done", ...res })}
21793
+ `);
21794
+ else process.stdout.write(`${res.ticket}
21795
+ ${res.worktree}
21796
+ ${res.url}
21797
+ `);
21798
+ } catch (e) {
21799
+ if (e instanceof PeriscopeError || e instanceof ConfigError) {
21800
+ process.stderr.write(`crew periscope: ${e.message}
21801
+ `);
21802
+ process.exit(1);
21803
+ }
21804
+ throw e;
21805
+ }
21806
+ break;
21807
+ }
21259
21808
  case "unassign": {
21260
21809
  emit.enter("select");
21261
21810
  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.86.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",