@tablation/crew 0.85.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 +261 -1
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -6905,6 +6905,33 @@ var Tracker = class {
6905
6905
  const { shipId } = await this.authorshipProbe();
6906
6906
  return this.client.records.create(modelId, { ...fields, ...shipId ? { ship_id: shipId } : {} });
6907
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
+ }
6908
6935
  /**
6909
6936
  * The authorship columns this workspace is missing, as `table.column` names
6910
6937
  * for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
@@ -16532,6 +16559,109 @@ ${tail(preview.output)}`);
16532
16559
  });
16533
16560
  return { ticket: draft.tag, ticketRecordId: draft.id, worktree: wt.path, branch: wt.branch, url, periscopeRecordId: row.id };
16534
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
+ }
16535
16665
 
16536
16666
  // src/cli.ts
16537
16667
  import { dirname as dirOf, resolve as resolvePath } from "path";
@@ -17996,6 +18126,9 @@ function usage(problem, requested = false) {
17996
18126
  crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
17997
18127
  crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]
17998
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
17999
18132
  crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
18000
18133
  crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
18001
18134
  crew sync [route] fast-forward the checkout and its worktrees from the remote
@@ -21422,14 +21555,141 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21422
21555
  }
21423
21556
  case "periscope": {
21424
21557
  const subP = positional[1];
21425
- if (subP !== "up" && subP !== "start") {
21558
+ if (subP !== "up" && subP !== "start" && subP !== "down" && subP !== "end" && subP !== "list") {
21426
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");
21427
21562
  process.exit(2);
21428
21563
  }
21429
21564
  const jsonP = flag("json");
21430
21565
  const say = (event, message, data = {}) => process.stderr.write(jsonP ? `${JSON.stringify({ ...data, event, message })}
21431
21566
  ` : `${message}
21432
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
+ }
21433
21693
  try {
21434
21694
  const a = parsePeriscopeUpArgs(positional, { topic: value("topic"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
21435
21695
  const candidatesP = a.route ? [findRoute(cfg, a.route)] : cfg.routes.filter((r) => r.enabled);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.85.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",