@tablation/crew 0.57.0 → 0.58.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 (3) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +270 -45
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -239,6 +239,7 @@ crew watch [route] live view of what the crew is doing
239
239
  crew status [route] paused/running state, led by what the operator owes (needs_info assigned to them, needs_planning, ships needing attention)
240
240
  crew doctor [route] [--fix] preflight; --fix enables clean routes, offers crew install
241
241
  crew selftest [route] [--toolchain NAME] [--json] launch each toolchain as a run would and prove it reaches the tracker
242
+ crew sample-ticket [route] [--remove] [--dry-run] [--json] file one ticket on a throwaway repo that walks the dev, QA and release loop
242
243
  crew ports [route] which checkout owns which ports, and what is up
243
244
  crew reap [route] kill servers left behind by removed worktrees
244
245
  crew drop [route] NNN remove a merged ticket's worktree and branch
package/dist/cli.js CHANGED
@@ -5104,7 +5104,7 @@ function doctorSelftestLines(stored, toolchains) {
5104
5104
  }
5105
5105
 
5106
5106
  // src/cli.ts
5107
- import { resolve as resolve5, dirname as dirname14, join as join28, basename as basename9 } from "path";
5107
+ import { resolve as resolve5, dirname as dirname15, join as join29, basename as basename10 } from "path";
5108
5108
  import { fileURLToPath } from "url";
5109
5109
  import { hostname as hostname2, homedir as homedir8 } from "os";
5110
5110
  import { spawnSync as spawnSync6 } from "child_process";
@@ -7201,6 +7201,25 @@ var Tracker = class {
7201
7201
  if (!model) throw new Error("this workspace has no Repos table");
7202
7202
  return this.client.records.create(model, fields);
7203
7203
  }
7204
+ /** Every `Repos` row as `{ id, name }`, for finding one by name. */
7205
+ async repoRows() {
7206
+ const model = this.route.resolved?.reposModelId;
7207
+ if (!model) return [];
7208
+ return this.client.records.list(model, { limit: 200 });
7209
+ }
7210
+ /** Delete a `Repos` row (`crew sample-ticket --remove`). */
7211
+ async deleteRepoRow(id) {
7212
+ const model = this.route.resolved?.reposModelId;
7213
+ if (!model) throw new Error("this workspace has no Repos table");
7214
+ await this.client.records.remove(model, id);
7215
+ }
7216
+ /** Every ticket filed against one `Repos` row, any status. */
7217
+ async ticketsForRepo(repoId) {
7218
+ return this.client.records.list(this.models.issues, {
7219
+ filters: encodeFilters([{ columnName: this.contract.columns.repo, operator: "EQ", value: repoId }]),
7220
+ limit: 200
7221
+ });
7222
+ }
7204
7223
  /**
7205
7224
  * The Ships rows of this workspace, if it has a Ships table.
7206
7225
  *
@@ -12508,9 +12527,77 @@ async function showLogEntry(route, entryId, opts = {}) {
12508
12527
  };
12509
12528
  }
12510
12529
 
12530
+ // src/sample-ticket.ts
12531
+ import { basename as basename7, dirname as dirname11, join as join20 } from "path";
12532
+ var SAMPLE_REPO = "crew-sample";
12533
+ var SAMPLE_REMOTE = `local/${SAMPLE_REPO}`;
12534
+ var SAMPLE_MARKER = "sample: true";
12535
+ var SAMPLE_CREW_YAML = `version: 1
12536
+ platform: unix
12537
+ branch:
12538
+ base: main
12539
+ hooks:
12540
+ test: "true"
12541
+ build: "true"
12542
+ release:
12543
+ mode: integrate
12544
+ versioning: none
12545
+ changelog: false
12546
+ `;
12547
+ var SAMPLE_README = `# crew-sample
12548
+
12549
+ A throwaway repository created by \`crew sample-ticket\`. It exists so the crew
12550
+ has something safe to work on: its test and build hooks always pass, it has no
12551
+ remote, and \`crew sample-ticket --remove\` deletes it.
12552
+ `;
12553
+ function sampleDir(existing, reposBase, workspace) {
12554
+ if (existing.length > 0) return join20(dirname11(existing[0]), SAMPLE_REPO);
12555
+ return join20(reposBase, workspace, SAMPLE_REPO);
12556
+ }
12557
+ var SAMPLE_TITLE = "Sample: add a documented `greet` function with a test";
12558
+ function sampleDescription() {
12559
+ return [
12560
+ SAMPLE_MARKER,
12561
+ "",
12562
+ "> This is a sample ticket filed by `crew sample-ticket` on a throwaway repository. Nothing here is real work.",
12563
+ "",
12564
+ "## What",
12565
+ "",
12566
+ "Add `greet.js` exporting `greet(name)`, which returns `Hello, <name>!` (and `Hello, world!` for no name),",
12567
+ "with a JSDoc comment, and `greet.test.js` covering both cases using `node:test`.",
12568
+ "",
12569
+ "## Acceptance",
12570
+ "",
12571
+ '- `greet("Ada")` returns `Hello, Ada!`; `greet()` returns `Hello, world!`.',
12572
+ "- The test file runs with `node --test` and passes.",
12573
+ "- Add a line to the README saying the function exists.",
12574
+ "",
12575
+ "## Notes",
12576
+ "",
12577
+ "The repo has no remote: leave the branch local. The hooks in `.crew.yaml` always pass, so QA is checking the",
12578
+ "diff against the acceptance list above."
12579
+ ].join("\n");
12580
+ }
12581
+ function isSampleTicket(description) {
12582
+ return (description ?? "").trimStart().startsWith(SAMPLE_MARKER);
12583
+ }
12584
+ function planSampleTicket(i) {
12585
+ return {
12586
+ title: SAMPLE_TITLE,
12587
+ description: sampleDescription(),
12588
+ report_type: "feature",
12589
+ severity: "s4",
12590
+ reporter_name: "crew sample-ticket",
12591
+ status: i.approvedStatus,
12592
+ project_id: i.projectId,
12593
+ repo_id: i.repoId,
12594
+ ...i.filedBy ? { filed_by_id: i.filedBy } : {}
12595
+ };
12596
+ }
12597
+
12511
12598
  // src/repos-cmd.ts
12512
12599
  import { homedir as homedir7 } from "os";
12513
- import { basename as basename7, join as join20, resolve as resolve3 } from "path";
12600
+ import { basename as basename8, join as join21, resolve as resolve3 } from "path";
12514
12601
  import { isMap, isSeq, parseDocument, stringify as stringify2 } from "yaml";
12515
12602
  var ReposError = class extends Error {
12516
12603
  };
@@ -12582,7 +12669,7 @@ function configuredRepos(text, route, base) {
12582
12669
  });
12583
12670
  }
12584
12671
  const dir = m.get("dir");
12585
- return dir ? [{ name: basename7(String(dir)), dir: expandDir(String(dir), base) }] : [];
12672
+ return dir ? [{ name: basename8(String(dir)), dir: expandDir(String(dir), base) }] : [];
12586
12673
  }
12587
12674
  function planRepoAdd(i) {
12588
12675
  const doc = parseDocument(i.text);
@@ -12594,7 +12681,7 @@ function planRepoAdd(i) {
12594
12681
  throw new ReposError(`${target} is already configured on route ${i.route}`);
12595
12682
  }
12596
12683
  const matched = matchRepoName(i.origin, i.resolved);
12597
- const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename7(target);
12684
+ const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename8(target);
12598
12685
  let warning;
12599
12686
  if (!matched) {
12600
12687
  const known = Object.values(i.resolved?.repoNames ?? {});
@@ -12621,6 +12708,28 @@ function planRepoAdd(i) {
12621
12708
  }
12622
12709
  return { text: String(doc), mode, name, block: renderBlock(m), warning, unmatched: !matched };
12623
12710
  }
12711
+ function planRepoRemove(text, base, route, path) {
12712
+ const doc = parseDocument(text);
12713
+ if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
12714
+ const m = routeMapOf(doc, route);
12715
+ const target = expandDir(path, base);
12716
+ const existing = configuredRepos(text, route, base);
12717
+ const hit = existing.find((e) => e.dir === target);
12718
+ if (!hit) throw new ReposError(`${target} is not configured on route ${route}`);
12719
+ const repos = m.get("repos", true);
12720
+ if (isMap(repos)) {
12721
+ repos.delete(hit.name);
12722
+ const left = existing.filter((e) => e !== hit);
12723
+ if (left.length === 1) {
12724
+ const pair = m.items.find((p) => p.key.value === "repos");
12725
+ pair.key = doc.createNode("dir");
12726
+ pair.value = doc.createNode(String(repos.get(left[0].name)));
12727
+ } else if (left.length === 0) m.delete("repos");
12728
+ } else {
12729
+ m.delete("dir");
12730
+ }
12731
+ return { text: String(doc), name: hit.name };
12732
+ }
12624
12733
  function renderBlock(m) {
12625
12734
  return stringify2([m.toJSON()]).trimEnd();
12626
12735
  }
@@ -12686,7 +12795,7 @@ function planRepoClone(i) {
12686
12795
  }
12687
12796
  const ship = doc.get("ship", true);
12688
12797
  const basePath = String(m.get("reposBasePath") ?? (isMap(ship) ? ship.get("reposBasePath") : void 0) ?? "~/Crew");
12689
- const target = i.dir ? expandDir(i.dir, i.base) : join20(expandDir(basePath, i.base), i.route.split("/")[0], name);
12798
+ const target = i.dir ? expandDir(i.dir, i.base) : join21(expandDir(basePath, i.base), i.route.split("/")[0], name);
12690
12799
  const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name });
12691
12800
  return { ...add, remote, target };
12692
12801
  }
@@ -12730,7 +12839,7 @@ function schedulerInstalled(plan, exists, crontab) {
12730
12839
 
12731
12840
  // src/worktree-sweep.ts
12732
12841
  import { existsSync as existsSync21, readFileSync as readFileSync15, readdirSync as readdirSync5 } from "fs";
12733
- import { basename as basename8, dirname as dirname12, join as join21, resolve as resolve4 } from "path";
12842
+ import { basename as basename9, dirname as dirname13, join as join22, resolve as resolve4 } from "path";
12734
12843
  function planWorktreeSweep(target, tickets, contract) {
12735
12844
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
12736
12845
  const verified = contract.statuses.verified;
@@ -12761,7 +12870,7 @@ function ticketForWorktree(w, byNumber, contract) {
12761
12870
  const t = num ? byNumber.get(num) : void 0;
12762
12871
  if (t && ticketStems(t).has(stem)) return t;
12763
12872
  }
12764
- const dir = basename8(w.path);
12873
+ const dir = basename9(w.path);
12765
12874
  const m = DIR_NUMBER.exec(dir);
12766
12875
  if (m && byNumber.has(m[1])) return byNumber.get(m[1]);
12767
12876
  if (!w.branch) {
@@ -12777,15 +12886,15 @@ function worktreeTicketNumbers(target) {
12777
12886
  const n = /-(\d+)$/.exec(BRANCH_STEM.exec(w.branch)?.[1] ?? "")?.[1];
12778
12887
  if (n) nums.add(n);
12779
12888
  }
12780
- const m = DIR_NUMBER_SUFFIXED.exec(basename8(w.path));
12889
+ const m = DIR_NUMBER_SUFFIXED.exec(basename9(w.path));
12781
12890
  if (m) nums.add(m[1]);
12782
12891
  }
12783
12892
  for (const d of siblingWorktreeDirs(target)) nums.add(d.number);
12784
12893
  return [...nums];
12785
12894
  }
12786
12895
  function siblingWorktreeDirs(target) {
12787
- const parent = dirname12(target.dir.replace(/[/\\]+$/, ""));
12788
- const stem = `${basename8(target.dir.replace(/[/\\]+$/, ""))}-`;
12896
+ const parent = dirname13(target.dir.replace(/[/\\]+$/, ""));
12897
+ const stem = `${basename9(target.dir.replace(/[/\\]+$/, ""))}-`;
12789
12898
  const registered = new Set(worktrees(target.dir).map((w) => resolve4(w.path)));
12790
12899
  let names;
12791
12900
  try {
@@ -12797,7 +12906,7 @@ function siblingWorktreeDirs(target) {
12797
12906
  for (const name of names) {
12798
12907
  if (!name.startsWith(stem)) continue;
12799
12908
  const m = DIR_NUMBER_SUFFIXED.exec(name);
12800
- const path = join21(parent, name);
12909
+ const path = join22(parent, name);
12801
12910
  if (m && !registered.has(resolve4(path))) out.push({ path, number: m[1] });
12802
12911
  }
12803
12912
  return out;
@@ -12810,7 +12919,7 @@ function orphanedWorktreeDirs(target, tickets, contract) {
12810
12919
  }
12811
12920
  function warnOrphanedWorktreeDirs(paths, log, held) {
12812
12921
  for (const p of paths) {
12813
- const number = DIR_NUMBER_SUFFIXED.exec(basename8(p))?.[1];
12922
+ const number = DIR_NUMBER_SUFFIXED.exec(basename9(p))?.[1];
12814
12923
  const when = held && number ? noteHeldWorktree(held.state, { repo: held.repo, path: p, ticket: number, status: "deployed", reason: "orphaned directory" }) : "first";
12815
12924
  if (when) {
12816
12925
  log.warn(`orphaned directory ${p}: its ticket is deployed but it is not a registered worktree; the sweep will not delete it \u2014 remove it by hand`, { step: "worktree" });
@@ -12954,7 +13063,7 @@ function isTicketBranch(name, base, stems) {
12954
13063
  }
12955
13064
  function sweepCandidatesExist(cwd, base) {
12956
13065
  const linked = worktrees(cwd).slice(1);
12957
- if (linked.some((w) => DIR_NUMBER_SUFFIXED.test(basename8(w.path)) || w.branch && BRANCH_STEM.test(w.branch))) return true;
13066
+ if (linked.some((w) => DIR_NUMBER_SUFFIXED.test(basename9(w.path)) || w.branch && BRANCH_STEM.test(w.branch))) return true;
12958
13067
  if (siblingWorktreeDirs({ name: "", dir: cwd }).length) return true;
12959
13068
  return localBranches(cwd).some((b) => b !== base && /-\d+(?:-.*)?$/.test(b));
12960
13069
  }
@@ -13006,13 +13115,13 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
13006
13115
 
13007
13116
  // src/stream-sweep.ts
13008
13117
  import { existsSync as existsSync22, readdirSync as readdirSync6, rmSync as rmSync9, statSync as statSync7 } from "fs";
13009
- import { join as join22 } from "path";
13118
+ import { join as join23 } from "path";
13010
13119
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
13011
13120
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
13012
- const dir = join22(stateDir, "streams");
13121
+ const dir = join23(stateDir, "streams");
13013
13122
  if (!existsSync22(dir)) return [];
13014
13123
  const cutoff = now - retentionDays * DAY_MS2;
13015
- return readdirSync6(dir).map((name) => join22(dir, name)).filter((path) => {
13124
+ return readdirSync6(dir).map((name) => join23(dir, name)).filter((path) => {
13016
13125
  try {
13017
13126
  return statSync7(path).mtimeMs < cutoff;
13018
13127
  } catch {
@@ -13040,7 +13149,7 @@ function applyStreamSweep(paths, dryRun, log) {
13040
13149
  function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
13041
13150
  if (!existsSync22(stateDir)) return [];
13042
13151
  const cutoff = now - retentionDays * DAY_MS2;
13043
- return readdirSync6(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join22(stateDir, name)).filter((path) => {
13152
+ return readdirSync6(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join23(stateDir, name)).filter((path) => {
13044
13153
  try {
13045
13154
  return statSync7(path).mtimeMs < cutoff;
13046
13155
  } catch {
@@ -13050,24 +13159,24 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
13050
13159
  }
13051
13160
 
13052
13161
  // src/cli.ts
13053
- import { readFileSync as readFileSync21, existsSync as existsSync28, mkdirSync as mkdirSync14, writeFileSync as writeFileSync15, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9, readdirSync as readdirSync11 } from "fs";
13162
+ import { readFileSync as readFileSync21, existsSync as existsSync28, mkdirSync as mkdirSync14, writeFileSync as writeFileSync15, rmSync as rmSync11, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9, readdirSync as readdirSync11 } from "fs";
13054
13163
  import { createInterface } from "readline/promises";
13055
13164
  import { dirname as dirOf, resolve as resolvePath } from "path";
13056
13165
 
13057
13166
  // src/ssh-keys.ts
13058
13167
  import { execFileSync as execFileSync3 } from "child_process";
13059
13168
  import { existsSync as existsSync23, mkdirSync as mkdirSync10, readFileSync as readFileSync16, chmodSync } from "fs";
13060
- import { dirname as dirname13, join as join23 } from "path";
13169
+ import { dirname as dirname14, join as join24 } from "path";
13061
13170
  var SshKeyError = class extends Error {
13062
13171
  };
13063
13172
  function shipSshKeyPathFor(stateDir) {
13064
- return join23(stateDir, "ssh", "id_ed25519");
13173
+ return join24(stateDir, "ssh", "id_ed25519");
13065
13174
  }
13066
13175
  function ensureShipSshKeypair(stateDir) {
13067
13176
  const privateKeyPath = shipSshKeyPathFor(stateDir);
13068
13177
  const publicKeyPath = `${privateKeyPath}.pub`;
13069
13178
  if (!existsSync23(privateKeyPath)) {
13070
- mkdirSync10(dirname13(privateKeyPath), { recursive: true });
13179
+ mkdirSync10(dirname14(privateKeyPath), { recursive: true });
13071
13180
  try {
13072
13181
  execFileSync3("ssh-keygen", [
13073
13182
  "-t",
@@ -13308,7 +13417,7 @@ function syncAllPassengerCheckouts(cfg) {
13308
13417
  import { spawn as spawn4 } from "child_process";
13309
13418
  import { existsSync as existsSync24, mkdirSync as mkdirSync11, readFileSync as readFileSync17, writeFileSync as writeFileSync12, unlinkSync as unlinkSync3, readdirSync as readdirSync7 } from "fs";
13310
13419
  import { randomInt } from "crypto";
13311
- import { join as join24 } from "path";
13420
+ import { join as join25 } from "path";
13312
13421
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
13313
13422
  var defaultSpawn = (cmd, args) => spawn4(cmd, args, DEFAULT_SPAWN_OPTIONS);
13314
13423
  var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
@@ -13375,7 +13484,7 @@ function startTunnel(opts) {
13375
13484
  };
13376
13485
  }
13377
13486
  function tunnelStatePath(stateDir, workspaceId) {
13378
- return join24(stateDir, "passengers", `${workspaceId}.json`);
13487
+ return join25(stateDir, "passengers", `${workspaceId}.json`);
13379
13488
  }
13380
13489
  function readPersistedTunnel(stateDir, workspaceId) {
13381
13490
  const path = tunnelStatePath(stateDir, workspaceId);
@@ -13388,7 +13497,7 @@ function readPersistedTunnel(stateDir, workspaceId) {
13388
13497
  }
13389
13498
  function writePersistedTunnel(stateDir, state) {
13390
13499
  const path = tunnelStatePath(stateDir, state.workspaceId);
13391
- mkdirSync11(join24(stateDir, "passengers"), { recursive: true });
13500
+ mkdirSync11(join25(stateDir, "passengers"), { recursive: true });
13392
13501
  writeFileSync12(path, `${JSON.stringify(state, null, 2)}
13393
13502
  `);
13394
13503
  }
@@ -13486,7 +13595,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
13486
13595
  }
13487
13596
  }
13488
13597
  function readAllPersistedWorkspaceIds(stateDir) {
13489
- const dir = join24(stateDir, "passengers");
13598
+ const dir = join25(stateDir, "passengers");
13490
13599
  if (!existsSync24(dir)) return [];
13491
13600
  return readdirSync7(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
13492
13601
  }
@@ -13609,7 +13718,7 @@ import { createServer as createHttpServer } from "http";
13609
13718
  import { spawn as spawn5 } from "child_process";
13610
13719
  import { randomBytes } from "crypto";
13611
13720
  import { existsSync as existsSync25, mkdirSync as mkdirSync12, readFileSync as readFileSync18, writeFileSync as writeFileSync13, unlinkSync as unlinkSync4, readdirSync as readdirSync8 } from "fs";
13612
- import { join as join25 } from "path";
13721
+ import { join as join26 } from "path";
13613
13722
 
13614
13723
  // src/secret-compare.ts
13615
13724
  import { timingSafeEqual } from "crypto";
@@ -13698,7 +13807,7 @@ var defaultSpawn2 = (cmd, args, env) => {
13698
13807
  return child;
13699
13808
  };
13700
13809
  function syncDaemonStatePath(stateDir, workspaceId) {
13701
- return join25(stateDir, "passengers", `${workspaceId}-sync.json`);
13810
+ return join26(stateDir, "passengers", `${workspaceId}-sync.json`);
13702
13811
  }
13703
13812
  function readPersistedSyncDaemon(stateDir, workspaceId) {
13704
13813
  const path = syncDaemonStatePath(stateDir, workspaceId);
@@ -13711,7 +13820,7 @@ function readPersistedSyncDaemon(stateDir, workspaceId) {
13711
13820
  }
13712
13821
  function writePersistedSyncDaemon(stateDir, state) {
13713
13822
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
13714
- mkdirSync12(join25(stateDir, "passengers"), { recursive: true });
13823
+ mkdirSync12(join26(stateDir, "passengers"), { recursive: true });
13715
13824
  writeFileSync13(path, `${JSON.stringify(state, null, 2)}
13716
13825
  `);
13717
13826
  }
@@ -13732,7 +13841,7 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
13732
13841
  }
13733
13842
  }
13734
13843
  function readAllPersistedSyncWorkspaceIds(stateDir) {
13735
- const dir = join25(stateDir, "passengers");
13844
+ const dir = join26(stateDir, "passengers");
13736
13845
  if (!existsSync25(dir)) return [];
13737
13846
  const suffix = "-sync.json";
13738
13847
  return readdirSync8(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
@@ -13791,11 +13900,11 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
13791
13900
 
13792
13901
  // src/daemon.ts
13793
13902
  import { existsSync as existsSync27, mkdirSync as mkdirSync13, readFileSync as readFileSync20, readdirSync as readdirSync10, rmSync as rmSync10, statSync as statSync8, writeFileSync as writeFileSync14 } from "fs";
13794
- import { join as join27 } from "path";
13903
+ import { join as join28 } from "path";
13795
13904
 
13796
13905
  // src/interrupted.ts
13797
13906
  import { existsSync as existsSync26, readdirSync as readdirSync9, readFileSync as readFileSync19 } from "fs";
13798
- import { join as join26 } from "path";
13907
+ import { join as join27 } from "path";
13799
13908
  var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
13800
13909
  function interruptedComment(b) {
13801
13910
  const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
@@ -13822,13 +13931,13 @@ function runFinished(streamPath) {
13822
13931
  }
13823
13932
  }
13824
13933
  function orphanedStreams(o) {
13825
- const dir = join26(o.stateDir, "streams");
13934
+ const dir = join27(o.stateDir, "streams");
13826
13935
  if (!existsSync26(dir)) return [];
13827
13936
  const ownerAlive = o.isOwnerAlive ?? pidAlive;
13828
13937
  const out = [];
13829
13938
  for (const name of readdirSync9(dir)) {
13830
13939
  if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
13831
- const streamPath = join26(dir, name);
13940
+ const streamPath = join27(dir, name);
13832
13941
  if (runFinished(streamPath)) continue;
13833
13942
  const sidecar = readRunSidecar(streamPath);
13834
13943
  if (!sidecar || sidecar.route !== o.routeLabel) continue;
@@ -13928,7 +14037,7 @@ function latestMtime(path) {
13928
14037
  if (!stat.isDirectory()) return 0;
13929
14038
  let latest = stat.mtimeMs;
13930
14039
  for (const entry of readdirSync10(path, { withFileTypes: true })) {
13931
- latest = Math.max(latest, latestMtime(join27(path, entry.name)));
14040
+ latest = Math.max(latest, latestMtime(join28(path, entry.name)));
13932
14041
  }
13933
14042
  return latest;
13934
14043
  }
@@ -13940,10 +14049,10 @@ function makeStaleChecker(paths, opts = {}) {
13940
14049
  return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync27(opts.reloadSentinel);
13941
14050
  }
13942
14051
  function reloadSentinelFor(stateDir) {
13943
- return join27(stateDir, "reload-requested");
14052
+ return join28(stateDir, "reload-requested");
13944
14053
  }
13945
14054
  function daemonStampFor(stateDir) {
13946
- return join27(stateDir, "daemon.started");
14055
+ return join28(stateDir, "daemon.started");
13947
14056
  }
13948
14057
  function noteDaemonStarted(stateDir, now = Date.now()) {
13949
14058
  mkdirSync13(stateDir, { recursive: true });
@@ -14309,7 +14418,7 @@ async function buildEnvironment(route, ship, ticket) {
14309
14418
  }
14310
14419
 
14311
14420
  // src/cli.ts
14312
- var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname14(process.execPath) : resolve5(dirname14(fileURLToPath(import.meta.url)), "..");
14421
+ var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname15(process.execPath) : resolve5(dirname15(fileURLToPath(import.meta.url)), "..");
14313
14422
  var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
14314
14423
  var CYCLE_SECONDS2 = 120;
14315
14424
  var STALE_CYCLES = 3;
@@ -14442,6 +14551,7 @@ function usage() {
14442
14551
  crew reload make the running daemon re-read crew.yaml (--now: restart it immediately)
14443
14552
  crew doctor [route] [--fix] preflight; --fix enables clean routes and offers crew install
14444
14553
  crew selftest [route] [--toolchain NAME] [--json] launch each toolchain as a run would and prove it reaches the tracker
14554
+ crew sample-ticket [route] [--remove] [--dry-run] [--json] file one ticket on a throwaway repo that walks the dev, QA and release loop
14445
14555
  crew ports [route] which checkout owns which ports, and what is up
14446
14556
  crew reap [route] kill orphaned servers, drop worktrees for closed tickets
14447
14557
  crew drop [route] NNN remove a merged ticket's worktree and branch
@@ -14865,7 +14975,7 @@ if (command === "passenger-sync-daemon") {
14865
14975
  }
14866
14976
  if (host === "linux" && hasSystemd) {
14867
14977
  const label = labelFor(CREW_HOME, "run");
14868
- const staleTimer = join28(homedir8(), ".config", "systemd", "user", `${label}.timer`);
14978
+ const staleTimer = join29(homedir8(), ".config", "systemd", "user", `${label}.timer`);
14869
14979
  if (existsSync28(staleTimer)) {
14870
14980
  process.stdout.write(
14871
14981
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
@@ -15923,7 +16033,7 @@ ${changed2.map((c) => ` - ${c}
15923
16033
  process.exit(1);
15924
16034
  }
15925
16035
  requireArmed("run the daemon loop");
15926
- const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join28(CREW_HOME, "src"), join28(CREW_HOME, "dist"), join28(CREW_HOME, "bin")];
16036
+ const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join29(CREW_HOME, "src"), join29(CREW_HOME, "dist"), join29(CREW_HOME, "bin")];
15927
16037
  const configPaths = daemonConfigPaths(cfg);
15928
16038
  watchPaths.push(...configPaths);
15929
16039
  noteDaemonStarted(cfg.ship.stateDir);
@@ -16228,7 +16338,7 @@ ${changed2.map((c) => ` - ${c}
16228
16338
  }
16229
16339
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
16230
16340
  const text = configText;
16231
- const base = dirname14(cfg.configFile);
16341
+ const base = dirname15(cfg.configFile);
16232
16342
  try {
16233
16343
  if (sub === "list") {
16234
16344
  const repos = configuredRepos(text, routeName, base);
@@ -16752,7 +16862,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
16752
16862
  process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
16753
16863
  `);
16754
16864
  } else {
16755
- mkdirSync14(dirname14(resolvedPath), { recursive: true });
16865
+ mkdirSync14(dirname15(resolvedPath), { recursive: true });
16756
16866
  if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
16757
16867
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
16758
16868
  writeFileSync15(tmpPath, `${JSON.stringify(resolved, null, 2)}
@@ -16790,7 +16900,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
16790
16900
  process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
16791
16901
  `);
16792
16902
  } else {
16793
- mkdirSync14(dirname14(apiKeyFile), { recursive: true });
16903
+ mkdirSync14(dirname15(apiKeyFile), { recursive: true });
16794
16904
  writeFileSync15(apiKeyFile, `${apiKeyVar}=${apiKey}
16795
16905
  `, { mode: 384 });
16796
16906
  process.stderr.write(`API key written to ${apiKeyFile}
@@ -16800,7 +16910,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
16800
16910
  const keyPersisted = keychainBacked || apiKeyFile !== void 0;
16801
16911
  const wizard = firstRun;
16802
16912
  const writeFirstRun = wizard !== void 0 && !dryRun && keyPersisted;
16803
- const routeDir = value("dir") ?? (writeFirstRun ? join28(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
16913
+ const routeDir = value("dir") ?? (writeFirstRun ? join29(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
16804
16914
  const routeBlock = renderConnection(
16805
16915
  found,
16806
16916
  route2,
@@ -17431,6 +17541,121 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
17431
17541
  }
17432
17542
  break;
17433
17543
  }
17544
+ case "sample-ticket": {
17545
+ const json = flag("json");
17546
+ const say = (event, extra, human) => process.stdout.write(json ? `${JSON.stringify({ event, ...extra })}
17547
+ ` : `${human}
17548
+ `);
17549
+ const fail = (msg) => {
17550
+ if (json) process.stdout.write(`${JSON.stringify({ event: "error", message: msg })}
17551
+ `);
17552
+ else process.stderr.write(`crew sample-ticket: ${msg}
17553
+ `);
17554
+ return process.exit(2);
17555
+ };
17556
+ if (!route.resolved) fail(`route ${route.route} is not connected \u2014 run \`crew connect\` first`);
17557
+ const tracker = new Tracker(route, cfg.ship);
17558
+ const configText = readFileSync21(cfg.configFile, "utf8");
17559
+ const cfgBase = dirname15(cfg.configFile);
17560
+ const configured = configuredRepos(configText, route.route, cfgBase);
17561
+ const dir = configured.find((r) => r.name === SAMPLE_REPO)?.dir ?? sampleDir(configured.map((r) => r.dir), route.reposBasePath, route.route.split("/")[0]);
17562
+ const resolvedFile = resolvedPathFor(cfg.ship.stateDir, route.route);
17563
+ const rows = await tracker.repoRows();
17564
+ const row = rows.find((r) => r.name === SAMPLE_REPO);
17565
+ if (flag("remove")) {
17566
+ const closed = [];
17567
+ if (row) {
17568
+ const wontFix = tracker.contract.statuses.wontFix;
17569
+ const resolved = new Set(tracker.contract.statuses.resolved);
17570
+ for (const t of await tracker.ticketsForRepo(row.id)) {
17571
+ if (resolved.has(t.status) || !isSampleTicket(t.description)) continue;
17572
+ if (!dryRun) {
17573
+ await tracker.updateTicket(t.id, {
17574
+ [tracker.contract.columns.status]: wontFix,
17575
+ [tracker.contract.columns.resolutionNote]: "Sample ticket removed by `crew sample-ticket --remove`; obsolete."
17576
+ });
17577
+ }
17578
+ closed.push(t.issue_tag ?? t.issue_id);
17579
+ }
17580
+ }
17581
+ if (dryRun) {
17582
+ say("dry-run", { dir, row: row?.id ?? null, closed }, `would close ${closed.join(", ") || "no tickets"}, delete the Repos row, detach and delete ${dir}`);
17583
+ break;
17584
+ }
17585
+ if (configured.some((r) => r.dir === dir)) {
17586
+ try {
17587
+ writeFileSync15(cfg.configFile, planRepoRemove(configText, cfgBase, route.route, dir).text);
17588
+ } catch (e) {
17589
+ if (e instanceof ReposError) fail(e.message);
17590
+ throw e;
17591
+ }
17592
+ }
17593
+ if (row) {
17594
+ await tracker.deleteRepoRow(row.id);
17595
+ if (existsSync28(resolvedFile)) {
17596
+ const raw = JSON.parse(readFileSync21(resolvedFile, "utf8"));
17597
+ if (raw.repoNames) delete raw.repoNames[row.id];
17598
+ if (raw.repoRemotes) delete raw.repoRemotes[row.id];
17599
+ writeFileSync15(resolvedFile, `${JSON.stringify(raw, null, 2)}
17600
+ `);
17601
+ }
17602
+ }
17603
+ if (existsSync28(join29(dir, ".git")) && basename10(dir) === SAMPLE_REPO) rmSync11(dir, { recursive: true, force: true });
17604
+ say("removed", { dir, closed }, `removed the sample repo${closed.length ? `, closed ${closed.join(", ")}` : ""}`);
17605
+ break;
17606
+ }
17607
+ const projectId = route.resolved.areaId ?? resolveRepoProject(await tracker.projectRows(), value("project"));
17608
+ const fresh = !existsSync28(join29(dir, ".git"));
17609
+ if (existsSync28(dir) && fresh && readdirSync11(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
17610
+ if (dryRun) {
17611
+ say("dry-run", { dir, newCheckout: fresh, repoRow: row?.id ?? null }, `would ${fresh ? "create" : "reuse"} ${dir}${row ? "" : ", create the Repos row"}, and file one ${tracker.contract.statuses.approved} ticket`);
17612
+ break;
17613
+ }
17614
+ if (fresh) {
17615
+ mkdirSync14(dir, { recursive: true });
17616
+ writeFileSync15(join29(dir, "README.md"), SAMPLE_README);
17617
+ writeFileSync15(join29(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
17618
+ const g = (...a) => {
17619
+ const r = spawnSync6("git", a, { cwd: dir, encoding: "utf8" });
17620
+ if (r.status !== 0) fail(`git ${a[0]} failed in ${dir}: ${(r.stderr || r.stdout).trim()}`);
17621
+ };
17622
+ g("init", "-b", "main");
17623
+ g("add", ".");
17624
+ g("-c", "user.name=crew", "-c", "user.email=crew@localhost", "commit", "-m", "Initial commit");
17625
+ }
17626
+ let repoId = row?.id;
17627
+ if (!repoId) {
17628
+ const payload = planRepoRow({
17629
+ name: SAMPLE_REPO,
17630
+ origin: SAMPLE_REMOTE,
17631
+ projectId,
17632
+ spec: { platform: "unix", releaseMode: "integrate", ciProvider: "none" }
17633
+ });
17634
+ repoId = (await tracker.createRepoRow(payload)).id;
17635
+ if (existsSync28(resolvedFile)) {
17636
+ const raw = JSON.parse(readFileSync21(resolvedFile, "utf8"));
17637
+ raw.repoNames = { ...raw.repoNames, [repoId]: SAMPLE_REPO };
17638
+ raw.repoRemotes = { ...raw.repoRemotes, [repoId]: SAMPLE_REMOTE };
17639
+ writeFileSync15(resolvedFile, `${JSON.stringify(raw, null, 2)}
17640
+ `);
17641
+ }
17642
+ }
17643
+ if (!configured.some((r) => r.dir === dir)) {
17644
+ try {
17645
+ writeFileSync15(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route.route, path: dir, name: SAMPLE_REPO }).text);
17646
+ } catch (e) {
17647
+ if (e instanceof ReposError) fail(e.message);
17648
+ throw e;
17649
+ }
17650
+ }
17651
+ const ticket = await tracker.fileTicket(
17652
+ planSampleTicket({ projectId, repoId, approvedStatus: tracker.contract.statuses.approved }),
17653
+ route.resolved.seats.dev ?? ""
17654
+ );
17655
+ const tag = ticket.issue_tag ?? ticket.issue_id;
17656
+ say("filed", { ticket: tag, id: ticket.id, dir, repoId }, `filed ${tag} against ${dir}; follow it with \`crew watch\``);
17657
+ break;
17658
+ }
17434
17659
  case "selftest": {
17435
17660
  const wanted = value("toolchain");
17436
17661
  const all = shipToolchains(cfg.ship);
@@ -17609,7 +17834,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
17609
17834
  // `synthesis-tabl-123`. `worktreePrefix` above is kept for
17610
17835
  // config-provenance display; this is the field that actually
17611
17836
  // describes where a worktree lands by default.
17612
- worktreeDirPattern: r.config.worktrees.name ?? (r.config.provenance["worktrees.prefix"] !== "default" ? `${r.config.worktrees.prefix}<number>` : `${basename9(r.dir.replace(/[/\\]+$/, ""))}-<branch>`),
17837
+ worktreeDirPattern: r.config.worktrees.name ?? (r.config.provenance["worktrees.prefix"] !== "default" ? `${r.config.worktrees.prefix}<number>` : `${basename10(r.dir.replace(/[/\\]+$/, ""))}-<branch>`),
17613
17838
  worktreeParent: resolvePath(r.dir, "..")
17614
17839
  })),
17615
17840
  waiting: waiting ? { ticket: waiting.ticket, since: waiting.since, streak: fairness.streak() } : null
@@ -17742,7 +17967,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
17742
17967
  );
17743
17968
  for (const r of await resolvedRepos(route)) {
17744
17969
  const { config } = r;
17745
- const worktreePattern = config.worktrees.name ?? (config.provenance["worktrees.prefix"] !== "default" ? `${config.worktrees.prefix}<number>` : `${basename9(r.dir.replace(/[/\\]+$/, ""))}-${effectiveBranchTemplate(config, void 0)}`);
17970
+ const worktreePattern = config.worktrees.name ?? (config.provenance["worktrees.prefix"] !== "default" ? `${config.worktrees.prefix}<number>` : `${basename10(r.dir.replace(/[/\\]+$/, ""))}-${effectiveBranchTemplate(config, void 0)}`);
17746
17971
  process.stdout.write(
17747
17972
  `${`repo ${r.name}:`.padEnd(19)}${r.dir}
17748
17973
  worktrees at ../${worktreePattern} (${config.provenance["worktrees.name"] ?? config.provenance["worktrees.prefix"] ?? "default"}), branch ${config.branch.name}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.57.0",
3
+ "version": "0.58.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",