@tablation/crew 0.10.2 → 0.11.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.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { resolve as resolve4, dirname as dirname9, join as join19, basename as basename7 } from "path";
4
+ import { resolve as resolve4, dirname as dirname10, join as join19, basename as basename7 } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { hostname as hostname2, homedir as homedir5 } from "os";
7
7
  import { spawnSync as spawnSync3 } from "child_process";
@@ -3991,16 +3991,20 @@ function refreshBaseBranch(cwd, remote, base, dryRun = false) {
3991
3991
  const [behindStr, aheadStr] = counts.split(/\s+/);
3992
3992
  const behind = Number.parseInt(behindStr ?? "0", 10);
3993
3993
  const ahead = Number.parseInt(aheadStr ?? "0", 10);
3994
+ const named = () => {
3995
+ const subjects = (gitOk(cwd, ["log", "--format=%h %s", "-5", `${upstream}..${base}`]) ?? "").split("\n").filter(Boolean);
3996
+ return subjects.length ? `: ${subjects.join("; ")}${ahead > subjects.length ? "; \u2026" : ""}` : "";
3997
+ };
3994
3998
  if (ahead > 0 && behind > 0) {
3995
3999
  return {
3996
4000
  action: "diverged",
3997
4001
  ahead,
3998
4002
  behind,
3999
- detail: `${base} has diverged from ${upstream} (${ahead} ahead, ${behind} behind)`
4003
+ detail: `${base} has diverged from ${upstream} (${ahead} ahead, ${behind} behind)${named()}`
4000
4004
  };
4001
4005
  }
4002
4006
  if (behind === 0 && ahead > 0) {
4003
- return { action: "ahead", ahead, detail: `${base} has ${ahead} commit(s) ${upstream} does not` };
4007
+ return { action: "ahead", ahead, detail: `${base} has ${ahead} commit(s) ${upstream} does not${named()}` };
4004
4008
  }
4005
4009
  if (behind === 0) return { action: "level", detail: `${base} is level with ${upstream}` };
4006
4010
  if (dryRun) {
@@ -4015,8 +4019,22 @@ function refreshBaseBranch(cwd, remote, base, dryRun = false) {
4015
4019
  detail: `fast-forwarded ${base} to ${upstream} (${behind} commit(s) from elsewhere)`
4016
4020
  };
4017
4021
  }
4018
- function pushBranch(cwd, remote, branch) {
4019
- git(cwd, ["push", remote, branch]);
4022
+ function recoverStrandedReleaseCheckouts(cwd) {
4023
+ gitOk(cwd, ["worktree", "prune"]);
4024
+ const out = gitOk(cwd, ["worktree", "list", "--porcelain"]) ?? "";
4025
+ let path = "";
4026
+ for (const line of out.split("\n")) {
4027
+ if (line.startsWith("worktree ")) path = line.slice("worktree ".length);
4028
+ else if (line.startsWith("branch refs/heads/crew/release-") && path) {
4029
+ gitOk(cwd, ["worktree", "remove", "--force", path]);
4030
+ }
4031
+ }
4032
+ const stranded = (gitOk(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/crew/release-*"]) ?? "").split("\n").filter(Boolean);
4033
+ for (const b of stranded) gitOk(cwd, ["branch", "-q", "-D", b]);
4034
+ return stranded;
4035
+ }
4036
+ function pushBranch(cwd, remote, branch, dst = branch) {
4037
+ git(cwd, ["push", remote, `${branch}:${dst}`]);
4020
4038
  }
4021
4039
  function worktrees(cwd) {
4022
4040
  const out = gitOk(cwd, ["worktree", "list", "--porcelain"]) ?? "";
@@ -5453,8 +5471,9 @@ Wrote ${o.configPath}.
5453
5471
  }
5454
5472
 
5455
5473
  // src/release-run.ts
5456
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "fs";
5457
- import { join as join9 } from "path";
5474
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync9, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync2 } from "fs";
5475
+ import { dirname as dirname7, join as join9 } from "path";
5476
+ import { tmpdir as tmpdir2 } from "os";
5458
5477
 
5459
5478
  // src/release.ts
5460
5479
  function describeUnplaceable(unplaceable, verifiedStatus) {
@@ -6029,6 +6048,31 @@ async function confirmLastRelease(o, decision) {
6029
6048
  if (!confirmed) o.emit.warn(`${decision.lastTag} is still not confirmed live \u2014 tickets stay verified`);
6030
6049
  return { confirmed, version };
6031
6050
  }
6051
+ function openReleaseCheckout(o) {
6052
+ const { remote, base } = o.repo.branch;
6053
+ const branch = `crew/release-${Date.now()}`;
6054
+ const root = mkdtempSync(join9(tmpdir2(), "crew-release-"));
6055
+ const dir = join9(root, "checkout");
6056
+ const close = () => {
6057
+ gitOk(o.cwd, ["worktree", "remove", "--force", dir]);
6058
+ rmSync2(root, { recursive: true, force: true });
6059
+ gitOk(o.cwd, ["worktree", "prune"]);
6060
+ gitOk(o.cwd, ["branch", "-D", branch]);
6061
+ };
6062
+ try {
6063
+ git(o.cwd, ["worktree", "add", "-q", "-b", branch, dir, `${remote}/${base}`]);
6064
+ } catch (e) {
6065
+ close();
6066
+ throw e;
6067
+ }
6068
+ for (const f of o.repo.worktrees.copy) {
6069
+ const src = join9(o.cwd, f);
6070
+ if (!existsSync9(src)) continue;
6071
+ mkdirSync7(dirname7(join9(dir, f)), { recursive: true });
6072
+ copyFileSync(src, join9(dir, f));
6073
+ }
6074
+ return { dir, branch, close };
6075
+ }
6032
6076
  async function runRelease(o) {
6033
6077
  const scoped = (msg) => o.scope ? `${o.scope}: ${msg}` : msg;
6034
6078
  const tagPattern = o.repo.release.tagPattern ?? "v*";
@@ -6075,6 +6119,33 @@ async function runRelease(o) {
6075
6119
  const externalClosures = await confirmExternalReleased(o, landed);
6076
6120
  return { merged: [], deployed: false, stopped: "external", decision, externalClosures };
6077
6121
  }
6122
+ const { remote, base } = o.repo.branch;
6123
+ if (o.dryRun || o.mergeOnly || !remoteBranchExists(o.cwd, remote, base)) {
6124
+ return mergeAndRelease(o, decision, fresh);
6125
+ }
6126
+ let checkout;
6127
+ const stranded = recoverStrandedReleaseCheckouts(o.cwd);
6128
+ if (stranded.length > 0) o.emit.warn(scoped(`dropped release checkout(s) stranded by a crash: ${stranded.join(", ")}`));
6129
+ try {
6130
+ checkout = openReleaseCheckout(o);
6131
+ } catch (e) {
6132
+ o.emit.error(scoped(`could not cut a release checkout from ${remote}/${base} \u2014 ${e.message}`));
6133
+ return { merged: [], deployed: false, stopped: "release checkout failed", decision };
6134
+ }
6135
+ try {
6136
+ return await mergeAndRelease({ ...o, cwd: checkout.dir }, decision, fresh, checkout.branch);
6137
+ } finally {
6138
+ checkout.close();
6139
+ const refreshed = refreshBaseBranch(o.cwd, remote, base);
6140
+ if (refreshed.action === "fast-forwarded" && o.repo.hooks.build) {
6141
+ const r = await hook(o, "build");
6142
+ if (r && r.code !== 0) {
6143
+ o.emit.warn(scoped(`rebuilding the primary checkout after the release failed (exit ${r.code}) \u2014 its dist/ is stale until \`${o.repo.hooks.build}\` is run there`));
6144
+ }
6145
+ }
6146
+ }
6147
+ }
6148
+ async function mergeAndRelease(o, decision, fresh, releaseBranch) {
6078
6149
  o.emit.enter("merge");
6079
6150
  const merged = [];
6080
6151
  const conflicts = [];
@@ -6236,6 +6307,16 @@ async function runRelease(o) {
6236
6307
  }
6237
6308
  }
6238
6309
  }
6310
+ if (releaseBranch && !o.dryRun) {
6311
+ const { remote, base } = o.repo.branch;
6312
+ if (fetchRemote(o.cwd, remote) && remoteBranchExists(o.cwd, remote, base)) {
6313
+ const moved = resolve2(o.cwd, `${remote}/${base}`);
6314
+ if (moved && !isAncestor(o.cwd, moved, "HEAD")) {
6315
+ o.emit.warn(`${remote}/${base} moved during the release \u2014 discarding it; the tickets stay verified and the next cycle retries`);
6316
+ return { merged, conflicts, unbuildable, version, deployed: false, decision, stopped: "base branch moved during release" };
6317
+ }
6318
+ }
6319
+ }
6239
6320
  let deployed = false;
6240
6321
  let integrated = false;
6241
6322
  if (o.repo.release.mode === "integrate") {
@@ -6265,7 +6346,7 @@ async function runRelease(o) {
6265
6346
  o.emit.emit(`would push ${o.repo.branch.base} to ${o.repo.branch.remote}`);
6266
6347
  } else {
6267
6348
  try {
6268
- pushBranch(o.cwd, o.repo.branch.remote, o.repo.branch.base);
6349
+ pushBranch(o.cwd, o.repo.branch.remote, releaseBranch ?? o.repo.branch.base, o.repo.branch.base);
6269
6350
  o.emit.emit(`pushed ${o.repo.branch.base} to ${o.repo.branch.remote}`);
6270
6351
  } catch (e) {
6271
6352
  o.emit.error(
@@ -8020,7 +8101,7 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
8020
8101
  }
8021
8102
 
8022
8103
  // src/stream-sweep.ts
8023
- import { existsSync as existsSync15, readdirSync as readdirSync3, rmSync as rmSync2, statSync as statSync6 } from "fs";
8104
+ import { existsSync as existsSync15, readdirSync as readdirSync3, rmSync as rmSync3, statSync as statSync6 } from "fs";
8024
8105
  import { join as join14 } from "path";
8025
8106
  var DAY_MS = 24 * 60 * 60 * 1e3;
8026
8107
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
@@ -8043,7 +8124,7 @@ function applyStreamSweep(paths, dryRun, log) {
8043
8124
  continue;
8044
8125
  }
8045
8126
  try {
8046
- rmSync2(path, { force: true });
8127
+ rmSync3(path, { force: true });
8047
8128
  removed++;
8048
8129
  log.emit(`removed stream artifact ${path}`, { step: "worktree" });
8049
8130
  } catch (e) {
@@ -8054,14 +8135,14 @@ function applyStreamSweep(paths, dryRun, log) {
8054
8135
  }
8055
8136
 
8056
8137
  // src/cli.ts
8057
- import { readFileSync as readFileSync14, existsSync as existsSync19, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync, renameSync } from "fs";
8138
+ import { readFileSync as readFileSync14, existsSync as existsSync19, mkdirSync as mkdirSync11, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync } from "fs";
8058
8139
  import { createInterface } from "readline/promises";
8059
8140
  import { dirname as dirOf, resolve as resolvePath } from "path";
8060
8141
 
8061
8142
  // src/ssh-keys.ts
8062
8143
  import { execFileSync as execFileSync3 } from "child_process";
8063
- import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync11, chmodSync } from "fs";
8064
- import { dirname as dirname8, join as join15 } from "path";
8144
+ import { existsSync as existsSync16, mkdirSync as mkdirSync8, readFileSync as readFileSync11, chmodSync } from "fs";
8145
+ import { dirname as dirname9, join as join15 } from "path";
8065
8146
  var SshKeyError = class extends Error {
8066
8147
  };
8067
8148
  function shipSshKeyPathFor(stateDir) {
@@ -8071,7 +8152,7 @@ function ensureShipSshKeypair(stateDir) {
8071
8152
  const privateKeyPath = shipSshKeyPathFor(stateDir);
8072
8153
  const publicKeyPath = `${privateKeyPath}.pub`;
8073
8154
  if (!existsSync16(privateKeyPath)) {
8074
- mkdirSync7(dirname8(privateKeyPath), { recursive: true });
8155
+ mkdirSync8(dirname9(privateKeyPath), { recursive: true });
8075
8156
  try {
8076
8157
  execFileSync3("ssh-keygen", [
8077
8158
  "-t",
@@ -8308,7 +8389,7 @@ function syncAllPassengerCheckouts(cfg) {
8308
8389
 
8309
8390
  // src/tunnel.ts
8310
8391
  import { spawn as spawn3 } from "child_process";
8311
- import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync12, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync4 } from "fs";
8392
+ import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync12, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync4 } from "fs";
8312
8393
  import { randomInt } from "crypto";
8313
8394
  import { join as join16 } from "path";
8314
8395
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
@@ -8390,7 +8471,7 @@ function readPersistedTunnel(stateDir, workspaceId) {
8390
8471
  }
8391
8472
  function writePersistedTunnel(stateDir, state) {
8392
8473
  const path = tunnelStatePath(stateDir, state.workspaceId);
8393
- mkdirSync8(join16(stateDir, "passengers"), { recursive: true });
8474
+ mkdirSync9(join16(stateDir, "passengers"), { recursive: true });
8394
8475
  writeFileSync7(path, `${JSON.stringify(state, null, 2)}
8395
8476
  `);
8396
8477
  }
@@ -8610,7 +8691,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
8610
8691
  import { createServer as createHttpServer } from "http";
8611
8692
  import { spawn as spawn4 } from "child_process";
8612
8693
  import { randomBytes } from "crypto";
8613
- import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync13, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync5 } from "fs";
8694
+ import { existsSync as existsSync18, mkdirSync as mkdirSync10, readFileSync as readFileSync13, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync5 } from "fs";
8614
8695
  import { join as join17 } from "path";
8615
8696
 
8616
8697
  // src/secret-compare.ts
@@ -8713,7 +8794,7 @@ function readPersistedSyncDaemon(stateDir, workspaceId) {
8713
8794
  }
8714
8795
  function writePersistedSyncDaemon(stateDir, state) {
8715
8796
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
8716
- mkdirSync9(join17(stateDir, "passengers"), { recursive: true });
8797
+ mkdirSync10(join17(stateDir, "passengers"), { recursive: true });
8717
8798
  writeFileSync8(path, `${JSON.stringify(state, null, 2)}
8718
8799
  `);
8719
8800
  }
@@ -9077,7 +9158,7 @@ async function buildEnvironment(route, ship, ticket) {
9077
9158
  }
9078
9159
 
9079
9160
  // src/cli.ts
9080
- var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname9(process.execPath) : resolve4(dirname9(fileURLToPath(import.meta.url)), "..");
9161
+ var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname10(process.execPath) : resolve4(dirname10(fileURLToPath(import.meta.url)), "..");
9081
9162
  var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
9082
9163
  var CYCLE_SECONDS = 120;
9083
9164
  var STALE_CYCLES = 3;
@@ -10529,7 +10610,7 @@ ${changed2.map((c) => ` - ${c}
10529
10610
  const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
10530
10611
  raw.agentPersonas = personaResult.agentPersonas;
10531
10612
  raw.agentSkills = skillResult.agentSkills;
10532
- mkdirSync10(dirOf(resolvedPath), { recursive: true });
10613
+ mkdirSync11(dirOf(resolvedPath), { recursive: true });
10533
10614
  writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
10534
10615
  `);
10535
10616
  }
@@ -10577,7 +10658,7 @@ ${changed2.map((c) => ` - ${c}
10577
10658
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
10578
10659
  const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
10579
10660
  raw.agentSkills = result.agentSkills;
10580
- mkdirSync10(dirOf(resolvedPath), { recursive: true });
10661
+ mkdirSync11(dirOf(resolvedPath), { recursive: true });
10581
10662
  writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
10582
10663
  `);
10583
10664
  }
@@ -10621,7 +10702,7 @@ ${changed2.map((c) => ` - ${c}
10621
10702
  }
10622
10703
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
10623
10704
  const text = configText;
10624
- const base = dirname9(cfg.configFile);
10705
+ const base = dirname10(cfg.configFile);
10625
10706
  try {
10626
10707
  if (sub === "list") {
10627
10708
  const repos = configuredRepos(text, routeName, base);
@@ -10982,8 +11063,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10982
11063
  process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
10983
11064
  `);
10984
11065
  } else {
10985
- mkdirSync10(dirname9(resolvedPath), { recursive: true });
10986
- if (hadPreviousFile) copyFileSync(resolvedPath, `${resolvedPath}.bak`);
11066
+ mkdirSync11(dirname10(resolvedPath), { recursive: true });
11067
+ if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
10987
11068
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
10988
11069
  writeFileSync9(tmpPath, `${JSON.stringify(resolved, null, 2)}
10989
11070
  `);
@@ -10999,7 +11080,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
10999
11080
  process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
11000
11081
  `);
11001
11082
  } else {
11002
- mkdirSync10(dirname9(apiKeyFile), { recursive: true });
11083
+ mkdirSync11(dirname10(apiKeyFile), { recursive: true });
11003
11084
  writeFileSync9(apiKeyFile, `${apiKeyVar}=${apiKey}
11004
11085
  `, { mode: 384 });
11005
11086
  process.stderr.write(`API key written to ${apiKeyFile}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.10.2",
3
+ "version": "0.11.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",
@@ -353,9 +353,12 @@ if `report_type` is either of those.
353
353
  reconcile it yourself. Post a comment on the ticket naming the repo and the
354
354
  counts `crew sync` printed, leave the ticket as you found it, and end the
355
355
  run — the operator reconciles the checkout. Otherwise, from
356
- the primary checkout, `git worktree add` a sibling worktree for this
357
- ticket, on a new branch cut from the base branch's current HEAD. The
358
- digest's `worktree` and `branch` columns give this ticket's own directory
356
+ the primary checkout, `git worktree add <dir> -b <branch> <remote>/<base>`
357
+ a sibling worktree for this ticket, on a new branch cut from the base
358
+ branch's REMOTE-TRACKING ref (`origin/main`, say), never from the primary
359
+ checkout's own local base — that copy is a cache of the remote, and a
360
+ local commit on it would be carried into every ticket cut from it
361
+ (CREW-1383). The digest's `worktree` and `branch` columns give this ticket's own directory
359
362
  and branch name — use them verbatim. The Environment section names the
360
363
  base branch and describes the general pattern, but its own worked example
361
364
  is illustrative only (rendered before any ticket is chosen, so it cannot
@@ -334,8 +334,12 @@ if `report_type` is either of those.
334
334
  reconcile it yourself. Post a comment on the ticket naming the repo and the
335
335
  counts `crew sync` printed, leave the ticket as you found it, and end the
336
336
  run — the operator reconciles the checkout. Otherwise, from
337
- the primary checkout, `git worktree add` a sibling worktree for this
338
- ticket, on a new branch cut from the base branch's current HEAD. The
337
+ the primary checkout, `git worktree add <dir> -b <branch> <remote>/<base>`
338
+ a sibling worktree for this ticket, on a new branch cut from the base
339
+ branch's REMOTE-TRACKING ref (`origin/main`, say), never from the primary
340
+ checkout's own local base — that copy is a cache of the remote, and a
341
+ local commit on it would be carried into every ticket cut from it
342
+ (CREW-1383). The
339
343
  digest's `worktree` and `branch` columns give this ticket's own directory
340
344
  and branch name — use them verbatim. The Environment section names the
341
345
  base branch and describes the general pattern, but its own worked example