@tablation/crew 0.83.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 +364 -19
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -34,6 +34,29 @@ function doctorBaseReport(r, base, health) {
34
34
  }
35
35
  }
36
36
 
37
+ // src/periscopes.ts
38
+ import { lookup } from "dns/promises";
39
+ function periscopeRoutes(cfg) {
40
+ return cfg.routes.filter((r) => r.enabled && r.hostPeriscopes).map((r) => r.route);
41
+ }
42
+ function periscopeSummary(cfg) {
43
+ const routes = periscopeRoutes(cfg);
44
+ const limits = `max ${cfg.ship.maxPeriscopes}, idle ${cfg.ship.periscopeIdleMinutes} min`;
45
+ if (routes.length === 0) return `off (no enabled route sets hostPeriscopes: true; ${limits})`;
46
+ return `ON for ${routes.join(", ")} (host ${cfg.ship.previewHost ?? "NOT SET (ship.previewHost)"}, ${limits})`;
47
+ }
48
+ async function previewHostProblem(cfg, resolve6 = (h) => lookup(h)) {
49
+ if (periscopeRoutes(cfg).length === 0) return void 0;
50
+ const host = cfg.ship.previewHost;
51
+ if (!host) return "ship.previewHost is not set \u2014 a route hosts periscopes, so Passengers have no host to be sent to";
52
+ try {
53
+ await resolve6(host);
54
+ } catch {
55
+ return `ship.previewHost "${host}" does not resolve from this machine`;
56
+ }
57
+ return void 0;
58
+ }
59
+
37
60
  // src/config.ts
38
61
  import { readFileSync as readFileSync8, existsSync as existsSync7 } from "fs";
39
62
  import { resolve, dirname as dirname3, isAbsolute, join as join9, basename as basename3 } from "path";
@@ -2034,6 +2057,8 @@ var ROLE_LABEL = {
2034
2057
  };
2035
2058
  var ConfigError = class extends Error {
2036
2059
  };
2060
+ var DEFAULT_MAX_PERISCOPES = 3;
2061
+ var DEFAULT_PERISCOPE_IDLE_MINUTES = 120;
2037
2062
  function parseCloneScheme(v) {
2038
2063
  if (v === void 0 || v === null || v === "") return void 0;
2039
2064
  if (v === "ssh" || v === "https") return v;
@@ -2065,7 +2090,10 @@ var SHIP_KEYS = /* @__PURE__ */ new Set([
2065
2090
  "relayHttpPort",
2066
2091
  "relayPublicDomain",
2067
2092
  "passengerImage",
2068
- "cloneScheme"
2093
+ "cloneScheme",
2094
+ "previewHost",
2095
+ "maxPeriscopes",
2096
+ "periscopeIdleMinutes"
2069
2097
  ]);
2070
2098
  var ROUTE_KEYS = /* @__PURE__ */ new Set([
2071
2099
  "route",
@@ -2088,6 +2116,7 @@ var ROUTE_KEYS = /* @__PURE__ */ new Set([
2088
2116
  "resolved",
2089
2117
  "promptSet",
2090
2118
  "hostPassengers",
2119
+ "hostPeriscopes",
2091
2120
  "toolchain"
2092
2121
  ]);
2093
2122
  function parseWorktreeName(v, where, file) {
@@ -2399,6 +2428,14 @@ Create the first of those:
2399
2428
  if (!Number.isInteger(streamRetentionDays) || streamRetentionDays < 1) {
2400
2429
  missing.add(`ship.streamRetentionDays must be a positive integer (got "${shipRaw.streamRetentionDays}")`);
2401
2430
  }
2431
+ const maxPeriscopes = shipRaw.maxPeriscopes === void 0 ? DEFAULT_MAX_PERISCOPES : Number(shipRaw.maxPeriscopes);
2432
+ if (!Number.isInteger(maxPeriscopes) || maxPeriscopes < 1) {
2433
+ missing.add(`ship.maxPeriscopes must be a positive integer (got "${shipRaw.maxPeriscopes}")`);
2434
+ }
2435
+ const periscopeIdleMinutes = shipRaw.periscopeIdleMinutes === void 0 ? DEFAULT_PERISCOPE_IDLE_MINUTES : Number(shipRaw.periscopeIdleMinutes);
2436
+ if (!Number.isInteger(periscopeIdleMinutes) || periscopeIdleMinutes < 1) {
2437
+ missing.add(`ship.periscopeIdleMinutes must be a positive integer (got "${shipRaw.periscopeIdleMinutes}")`);
2438
+ }
2402
2439
  const eventsRotateBytes = shipRaw.eventsRotateBytes === void 0 ? DEFAULT_EVENTS_ROTATE_BYTES : Number(shipRaw.eventsRotateBytes);
2403
2440
  if (!Number.isInteger(eventsRotateBytes) || eventsRotateBytes < 1) {
2404
2441
  missing.add(`ship.eventsRotateBytes must be a positive integer (got "${shipRaw.eventsRotateBytes}")`);
@@ -2468,7 +2505,10 @@ Create the first of those:
2468
2505
  relayHttpPort: shipRaw.relayHttpPort !== void 0 ? Number(shipRaw.relayHttpPort) : 443,
2469
2506
  relayPublicDomain: shipRaw.relayPublicDomain ? String(shipRaw.relayPublicDomain) : void 0,
2470
2507
  passengerImage: shipRaw.passengerImage ? String(shipRaw.passengerImage) : void 0,
2471
- cloneScheme: parseCloneScheme(shipRaw.cloneScheme)
2508
+ cloneScheme: parseCloneScheme(shipRaw.cloneScheme),
2509
+ previewHost: shipRaw.previewHost ? String(shipRaw.previewHost).trim() || void 0 : void 0,
2510
+ maxPeriscopes,
2511
+ periscopeIdleMinutes
2472
2512
  },
2473
2513
  routes,
2474
2514
  crewHome,
@@ -2569,6 +2609,9 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
2569
2609
  missing.add(`${where}.toolchain "${toolchain}" is not defined in ship.toolchains (have: ${[...toolchainNames].join(", ")})`);
2570
2610
  }
2571
2611
  const route = missing.req(c?.route, `${where}.route`);
2612
+ if (c?.hostPeriscopes === true && c?.hostPassengers !== true) {
2613
+ missing.add(`${where} (route "${route}") sets hostPeriscopes: true without hostPassengers: true \u2014 a Passenger must be able to board before they can open a periscope`);
2614
+ }
2572
2615
  const routeParts = route ? route.split("/") : [];
2573
2616
  const routeWellFormed = routeParts.length === 2 && routeParts.every(Boolean);
2574
2617
  if (route && !routeWellFormed) {
@@ -2643,7 +2686,8 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
2643
2686
  },
2644
2687
  release: parseReleaseOverride(c?.release, where, file),
2645
2688
  branch: parseBranchOverride(c?.branch, where, file),
2646
- hostPassengers: c?.hostPassengers === true
2689
+ hostPassengers: c?.hostPassengers === true,
2690
+ hostPeriscopes: c?.hostPeriscopes === true
2647
2691
  };
2648
2692
  const resolvedFile = routeWellFormed ? readResolvedFile(stateDir, route) : void 0;
2649
2693
  result.resolved = parseResolved(resolvedFile ?? c?.resolved, missing, where);
@@ -5562,7 +5606,7 @@ function validateContract(c) {
5562
5606
  }
5563
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.";
5564
5608
  var PERISCOPE_COLUMNS = [
5565
- "ticket_id",
5609
+ "issue_id",
5566
5610
  "ship_id",
5567
5611
  "url",
5568
5612
  "host",
@@ -6835,6 +6879,32 @@ var Tracker = class {
6835
6879
  const have = await this.columnNames(modelId);
6836
6880
  return { present: true, modelId, missing: PERISCOPE_COLUMNS.filter((c) => !have.has(c)) };
6837
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
+ }
6838
6908
  /**
6839
6909
  * The authorship columns this workspace is missing, as `table.column` names
6840
6910
  * for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
@@ -7762,7 +7832,7 @@ function skillMarkerNames(text) {
7762
7832
  }
7763
7833
  return names;
7764
7834
  }
7765
- function expandSkillMarkers(text, lookup, opts = {}) {
7835
+ function expandSkillMarkers(text, lookup2, opts = {}) {
7766
7836
  const maxDepth = opts.maxDepth ?? MAX_INCLUDE_DEPTH;
7767
7837
  const maxBytes = opts.maxBytes ?? MAX_INCLUDED_BYTES;
7768
7838
  const warnings = [];
@@ -7778,7 +7848,7 @@ function expandSkillMarkers(text, lookup, opts = {}) {
7778
7848
  warnings.push(`skill include "${name}" left literal: nested deeper than ${maxDepth} levels`);
7779
7849
  return literal;
7780
7850
  }
7781
- const body = lookup(name);
7851
+ const body = lookup2(name);
7782
7852
  if (body === void 0) throw new SkillIncludeError(`no skill named "${name}" for the include ${literal}`);
7783
7853
  const trimmed = body.replace(/^\n+/, "").replace(/\n+$/, "");
7784
7854
  const bytes = Buffer.byteLength(trimmed, "utf8");
@@ -9058,8 +9128,8 @@ function spawnAgent(plan, emit, hintMoved) {
9058
9128
  const meta = { pid: child.pid ?? null };
9059
9129
  obs.onClaim = () => writeRunMeta(id, startedAt, obs.liveTicket, meta);
9060
9130
  if (child.pid !== void 0) writeRunMeta(id, startedAt, obs.liveTicket, meta);
9061
- const tail = new FileTail(plan.streamPath);
9062
- 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);
9063
9133
  let spawnFailed = false;
9064
9134
  child.on("error", (err) => {
9065
9135
  spawnFailed = true;
@@ -9068,7 +9138,7 @@ function spawnAgent(plan, emit, hintMoved) {
9068
9138
  });
9069
9139
  child.on("close", async (code) => {
9070
9140
  clearInterval(timer);
9071
- obs.feed(tail.poll());
9141
+ obs.feed(tail2.poll());
9072
9142
  obs.end();
9073
9143
  await endSink(eventsSink);
9074
9144
  if (spawnFailed) return;
@@ -9087,20 +9157,20 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
9087
9157
  const id = { ...sc, streamPath };
9088
9158
  const eventsSink = openSink(sc.eventsPath);
9089
9159
  const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
9090
- const tail = new FileTail(streamPath);
9160
+ const tail2 = new FileTail(streamPath);
9091
9161
  obs.muted = true;
9092
- obs.feed(tail.poll());
9162
+ obs.feed(tail2.poll());
9093
9163
  obs.muted = false;
9094
9164
  const adopted = isAlive(sc);
9095
9165
  if (adopted) {
9096
9166
  o.onAdopted?.();
9097
9167
  const pollMs = o.pollMs ?? TAIL_POLL_MS;
9098
9168
  while (isAlive(sc)) {
9099
- obs.feed(tail.poll());
9169
+ obs.feed(tail2.poll());
9100
9170
  await new Promise((r) => setTimeout(r, pollMs));
9101
9171
  }
9102
9172
  }
9103
- obs.feed(tail.poll());
9173
+ obs.feed(tail2.poll());
9104
9174
  obs.end();
9105
9175
  await endSink(eventsSink);
9106
9176
  const interrupted = obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
@@ -9648,7 +9718,9 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
9648
9718
  "toolchain",
9649
9719
  "workspace",
9650
9720
  "workspace-id",
9651
- "write"
9721
+ "write",
9722
+ "topic",
9723
+ "assignee"
9652
9724
  ]);
9653
9725
  function positionalArgs(argv2) {
9654
9726
  const out = [];
@@ -12988,7 +13060,7 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
12988
13060
  }
12989
13061
  if (culprits.length === 0 && truncated) {
12990
13062
  let lo = 2, hi = applied.length;
12991
- let tail = lastLines(output, HOOK_FAILURE_TAIL_LINES);
13063
+ let tail2 = lastLines(output, HOOK_FAILURE_TAIL_LINES);
12992
13064
  while (lo < hi) {
12993
13065
  const mid = Math.floor((lo + hi) / 2);
12994
13066
  gitOk(o.cwd, ["reset", "--hard", preMerge]);
@@ -13003,12 +13075,12 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
13003
13075
  const r = clean ? await hook(o, name) : null;
13004
13076
  if (r && r.code !== 0) {
13005
13077
  hi = mid;
13006
- tail = lastLines(r.output, HOOK_FAILURE_TAIL_LINES);
13078
+ tail2 = lastLines(r.output, HOOK_FAILURE_TAIL_LINES);
13007
13079
  } else lo = mid + 1;
13008
13080
  }
13009
13081
  const owner = applied[hi - 1];
13010
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 });
13011
- culprits.push({ c: owner, how: "pair", sibling: applied[hi - 2], tail });
13083
+ culprits.push({ c: owner, how: "pair", sibling: applied[hi - 2], tail: tail2 });
13012
13084
  }
13013
13085
  }
13014
13086
  }
@@ -16329,6 +16401,139 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
16329
16401
  // src/cli.ts
16330
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";
16331
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
16332
16537
  import { dirname as dirOf, resolve as resolvePath } from "path";
16333
16538
 
16334
16539
  // src/ssh-keys.ts
@@ -17789,6 +17994,8 @@ function usage(problem, requested = false) {
17789
17994
  crew reap [route] kill orphaned servers, drop worktrees for closed tickets
17790
17995
  crew drop [route] NNN remove a merged ticket's worktree and branch
17791
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)
17792
17999
  crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
17793
18000
  crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
17794
18001
  crew sync [route] fast-forward the checkout and its worktrees from the remote
@@ -17881,6 +18088,7 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
17881
18088
  "merge",
17882
18089
  "passengers",
17883
18090
  "pause",
18091
+ "periscope",
17884
18092
  "poll",
17885
18093
  "ports",
17886
18094
  "projects",
@@ -18084,7 +18292,7 @@ if (command === "passenger-sync-daemon") {
18084
18292
  const DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set(["start", "stop", "status", "restart"]);
18085
18293
  const worktreeArgs = command === "worktree" ? splitWorktreeArgs(positional, cfg.routes.map((r) => r.route)) : void 0;
18086
18294
  const named = worktreeArgs ? worktreeArgs.route : command === "daemon" && DAEMON_SUBCOMMANDS.has(positional[1] ?? "") ? positional[2] : positional[1];
18087
- 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;
18088
18296
  let route;
18089
18297
  try {
18090
18298
  route = fleetWide ? cfg.routes[0] : findRoute(cfg, named);
@@ -20385,6 +20593,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
20385
20593
  platform: cfg.ship.platform,
20386
20594
  machineId,
20387
20595
  hostPassengers: firstRun?.answers.hostPassengers ?? existingRoute?.hostPassengers === true,
20596
+ hostPeriscopes: existingRoute?.hostPeriscopes === true,
20388
20597
  sshPublicKey,
20389
20598
  lastSyncedName: (r) => readSyncedName(resolvedPathFor(cfg.ship.stateDir, r))
20390
20599
  };
@@ -21164,7 +21373,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21164
21373
  const hitsWt = /* @__PURE__ */ new Map();
21165
21374
  for (const cand of candidatesWt) {
21166
21375
  const tr = new Tracker(cand, cfg.ship);
21167
- 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);
21168
21377
  if (hit) hitsWt.set(cand.route, hit);
21169
21378
  lookedWt.push({
21170
21379
  route: cand.route,
@@ -21211,6 +21420,131 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21211
21420
  if (!planIsUsable(planWt)) process.exitCode = 1;
21212
21421
  break;
21213
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
+ }
21214
21548
  case "unassign": {
21215
21549
  emit.enter("select");
21216
21550
  const rawUnassign = positional[2] ?? positional[1];
@@ -21848,6 +22182,7 @@ ${owed.map((l) => ` ${l}
21848
22182
  `ship: ${cfg.ship.name} (${cfg.ship.platform})
21849
22183
  route: ${route.route} -> ${route.dir}
21850
22184
  enabled: ${route.enabled}
22185
+ periscopes: ${periscopeSummary(cfg)}
21851
22186
  crew: ${state.isPaused() ? "paused" : "active"}
21852
22187
  release: ${state.releaseSkipReason() ? "paused" : "active"}
21853
22188
  watermark: ${state.watermark()}
@@ -21965,6 +22300,8 @@ watermark: ${state.watermark()}
21965
22300
  }
21966
22301
  }
21967
22302
  for (const message of roleCapWarnings(cfg.ship)) problems.push({ severity: "note", kind: "role_cap_above_ship_cap", message });
22303
+ const previewProblem2 = await previewHostProblem(cfg);
22304
+ if (previewProblem2) problems.push({ severity: "blocker", kind: "preview_host", message: previewProblem2 });
21968
22305
  problems.push(...attentionProblems(openShipAttention(state), problems));
21969
22306
  if (problems.some((p) => p.severity === "blocker")) process.exitCode = 1;
21970
22307
  process.stdout.write(`${JSON.stringify({ offline: flag("offline"), pushAuth, tools: toolRows, problems, passed }, null, 2)}
@@ -21978,6 +22315,14 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
21978
22315
  );
21979
22316
  for (const w of roleCapWarnings(cfg.ship)) process.stdout.write(`role concurrency: WARN ${w}
21980
22317
  `);
22318
+ process.stdout.write(`periscopes: ${periscopeSummary(cfg)}
22319
+ `);
22320
+ const previewProblem = await previewHostProblem(cfg);
22321
+ if (previewProblem) {
22322
+ process.stdout.write(` BLOCKER ${previewProblem}
22323
+ `);
22324
+ process.exitCode = 1;
22325
+ }
21981
22326
  try {
21982
22327
  const evFile = eventFileFor(cfg.ship.stateDir);
21983
22328
  const evSize = existsSync35(evFile) ? statSync10(evFile).size : 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.83.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",