@tablation/crew 0.94.0 → 0.96.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
@@ -2602,6 +2602,22 @@ function parseCloneScheme(v) {
2602
2602
  if (v === "ssh" || v === "https") return v;
2603
2603
  throw new ConfigError(`ship.cloneScheme must be "ssh" or "https", got ${JSON.stringify(v)}`);
2604
2604
  }
2605
+ function parseShipEnv(v) {
2606
+ if (v === void 0 || v === null) return void 0;
2607
+ if (typeof v !== "object" || Array.isArray(v)) throw new ConfigError("ship.env must be a map of variable name to value");
2608
+ const out = {};
2609
+ for (const [k, val] of Object.entries(v)) {
2610
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) throw new ConfigError(`ship.env: "${k}" is not a valid variable name`);
2611
+ if (k === "PATH") throw new ConfigError("ship.env: PATH is set from ship.extraPath, not here");
2612
+ if (typeof val !== "string" && typeof val !== "number" && typeof val !== "boolean") {
2613
+ throw new ConfigError(`ship.env.${k} must be a string, number or boolean`);
2614
+ }
2615
+ const text = String(val);
2616
+ if (/[\r\n\0]/.test(text)) throw new ConfigError(`ship.env.${k} must be a single line`);
2617
+ out[k] = text;
2618
+ }
2619
+ return Object.keys(out).length ? out : void 0;
2620
+ }
2605
2621
  function parseShipForges(v) {
2606
2622
  try {
2607
2623
  return parseForges(v);
@@ -2641,7 +2657,8 @@ var SHIP_KEYS = /* @__PURE__ */ new Set([
2641
2657
  "previewHost",
2642
2658
  "maxPeriscopes",
2643
2659
  "periscopeIdleMinutes",
2644
- "secretStore"
2660
+ "secretStore",
2661
+ "env"
2645
2662
  ]);
2646
2663
  var ROUTE_KEYS = /* @__PURE__ */ new Set([
2647
2664
  "route",
@@ -2665,7 +2682,8 @@ var ROUTE_KEYS = /* @__PURE__ */ new Set([
2665
2682
  "promptSet",
2666
2683
  "hostPassengers",
2667
2684
  "hostPeriscopes",
2668
- "toolchain"
2685
+ "toolchain",
2686
+ "maxRunMinutes"
2669
2687
  ]);
2670
2688
  function parseWorktreeName(v, where, file) {
2671
2689
  const problem = typeof v === "string" ? worktreeNameProblem(v) : "must be a string";
@@ -3064,7 +3082,8 @@ Create the first of those:
3064
3082
  previewHost: shipRaw.previewHost ? String(shipRaw.previewHost).trim() || void 0 : void 0,
3065
3083
  maxPeriscopes,
3066
3084
  periscopeIdleMinutes,
3067
- secretStore
3085
+ secretStore,
3086
+ env: parseShipEnv(shipRaw.env)
3068
3087
  },
3069
3088
  routes,
3070
3089
  crewHome,
@@ -3075,7 +3094,21 @@ Create the first of those:
3075
3094
  function expandCommand(cmd, base) {
3076
3095
  return cmd.includes("/") || cmd.startsWith("~") ? expand(cmd, base) : cmd;
3077
3096
  }
3078
- var TOOLCHAIN_KEYS = /* @__PURE__ */ new Set(["harness", "bin", "launcher", "launcherBin", "model", "tiers", "args", "env", "maxThinkingTokens", "auth"]);
3097
+ var TOOLCHAIN_KEYS = /* @__PURE__ */ new Set(["harness", "bin", "launcher", "launcherBin", "model", "tiers", "args", "env", "maxThinkingTokens", "auth", "maxRunMinutes"]);
3098
+ var MAX_RUN_MINUTES_CEILING = Math.floor((2 ** 31 - 1) / 6e4);
3099
+ function parseMaxRunMinutes(raw, where, missing) {
3100
+ if (raw === void 0) return void 0;
3101
+ const n = Number(raw);
3102
+ if (!Number.isInteger(n) || n < 0) {
3103
+ missing.add(`${where} must be a non-negative integer number of minutes, 0 for no limit (got "${String(raw)}")`);
3104
+ return void 0;
3105
+ }
3106
+ if (n > MAX_RUN_MINUTES_CEILING) {
3107
+ missing.add(`${where} is too large (${n}); the most it can be is ${MAX_RUN_MINUTES_CEILING} minutes \u2014 use 0 for no limit`);
3108
+ return void 0;
3109
+ }
3110
+ return n;
3111
+ }
3079
3112
  function parsePair(raw, missing) {
3080
3113
  if (raw === void 0) return void 0;
3081
3114
  if (!Array.isArray(raw)) {
@@ -3132,6 +3165,7 @@ function parseToolchains(raw, base, missing, file) {
3132
3165
  if (!Number.isInteger(maxThinkingTokens) || maxThinkingTokens < 0) {
3133
3166
  missing.add(`${where}.maxThinkingTokens must be a non-negative integer (got "${entryRaw.maxThinkingTokens}")`);
3134
3167
  }
3168
+ const maxRunMinutes = parseMaxRunMinutes(entryRaw.maxRunMinutes, `${where}.maxRunMinutes`, missing);
3135
3169
  let auth;
3136
3170
  if (entryRaw.auth !== void 0) {
3137
3171
  if (entryRaw.auth !== "subscription" && entryRaw.auth !== "inherit") {
@@ -3153,7 +3187,8 @@ function parseToolchains(raw, base, missing, file) {
3153
3187
  tiers,
3154
3188
  args: Array.isArray(args) ? args.map(String) : [],
3155
3189
  env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, String(v)])),
3156
- maxThinkingTokens
3190
+ maxThinkingTokens,
3191
+ ...maxRunMinutes !== void 0 ? { maxRunMinutes } : {}
3157
3192
  });
3158
3193
  }
3159
3194
  return out;
@@ -3161,6 +3196,7 @@ function parseToolchains(raw, base, missing, file) {
3161
3196
  function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, toolchainNames) {
3162
3197
  rejectUnknownKeys(c, ROUTE_KEYS, `${where}`, file);
3163
3198
  const toolchain = c?.toolchain === void 0 ? void 0 : String(c.toolchain);
3199
+ const maxRunMinutes = parseMaxRunMinutes(c?.maxRunMinutes, `${where}.maxRunMinutes`, missing);
3164
3200
  if (toolchain !== void 0 && !toolchainNames.has(toolchain)) {
3165
3201
  missing.add(`${where}.toolchain "${toolchain}" is not defined in ship.toolchains (have: ${[...toolchainNames].join(", ")})`);
3166
3202
  }
@@ -3224,6 +3260,7 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
3224
3260
  repoOverrides,
3225
3261
  worktreePrefix: c?.worktreePrefix ? String(c.worktreePrefix) : void 0,
3226
3262
  ...toolchain !== void 0 ? { toolchain } : {},
3263
+ ...maxRunMinutes !== void 0 ? { maxRunMinutes } : {},
3227
3264
  promptsDir: resolvePromptsDir(c?.promptSet ? String(c.promptSet) : void 0, crewHome, base),
3228
3265
  weight,
3229
3266
  baseUrl: String(c?.baseUrl ?? raw.ship?.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
@@ -3698,7 +3735,7 @@ function recoveredFailureHeadlines(o, route2) {
3698
3735
 
3699
3736
  // src/install.ts
3700
3737
  import { dirname as dirname5, join as join13, extname } from "path";
3701
- import { homedir as homedir7 } from "os";
3738
+ import { homedir as homedir7, userInfo } from "os";
3702
3739
  import { accessSync as accessSync2, constants as constants2, existsSync as existsSync11, mkdirSync as mkdirSync7, readdirSync as readdirSync3, readFileSync as readFileSync12, statSync as statSync4, writeFileSync as writeFileSync8, unlinkSync, realpathSync } from "fs";
3703
3740
  import { spawnSync as spawnSync4 } from "child_process";
3704
3741
  import { createHash as createHash3 } from "crypto";
@@ -3766,6 +3803,30 @@ function dockerPathProblem(ship, shellPath = process.env.PATH ?? "") {
3766
3803
  const inShell = findOnPath("docker", shellPath);
3767
3804
  return inShell ? `\`docker\` is not on the scheduler PATH (${schedulerPath}) but is at ${inShell} in your shell; add ${dirname5(inShell)} to ship.extraPath in crew.yaml and run \`crew install\`` : `\`docker\` is not on the scheduler PATH (${schedulerPath}) or your shell PATH; install Docker, then add its directory to ship.extraPath in crew.yaml and run \`crew install\``;
3768
3805
  }
3806
+ var USER_INSTALL = { root: false, user: "" };
3807
+ function installContext(env = process.env) {
3808
+ const root = typeof process.getuid === "function" && process.getuid() === 0;
3809
+ const user = root && env.SUDO_USER || userInfo().username;
3810
+ return { root, user };
3811
+ }
3812
+ var SYSTEM_UNIT_DIR = "/etc/systemd/system";
3813
+ function systemUnitPathFor(label, suffix = "service") {
3814
+ return join13(SYSTEM_UNIT_DIR, `${label}.${suffix}`);
3815
+ }
3816
+ function unitEnvironment(ship) {
3817
+ return [["PATH", pathFor(ship)], ...Object.entries(ship.env ?? {})];
3818
+ }
3819
+ function systemdEnvLines(ship) {
3820
+ return unitEnvironment(ship).map(([k, v]) => {
3821
+ if (k === "PATH") return `Environment=PATH=${v}`;
3822
+ const escaped = `${k}=${v}`.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%");
3823
+ return `Environment="${escaped}"`;
3824
+ }).join("\n");
3825
+ }
3826
+ function shQuote(v) {
3827
+ if (/^[A-Za-z0-9_/.:,@%+=-]+$/.test(v)) return v;
3828
+ return `'${v.replace(/'/g, `'\\''`)}'`;
3829
+ }
3769
3830
  function xmlEscape(s) {
3770
3831
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3771
3832
  }
@@ -3798,8 +3859,8 @@ function planLaunchd(ship, crewHome, job) {
3798
3859
  </array>
3799
3860
  <key>EnvironmentVariables</key>
3800
3861
  <dict>
3801
- <key>PATH</key>
3802
- <string>${xmlEscape(pathFor(ship))}</string>
3862
+ ${unitEnvironment(ship).map(([k, v]) => ` <key>${xmlEscape(k)}</key>
3863
+ <string>${xmlEscape(v)}</string>`).join("\n")}
3803
3864
  </dict>
3804
3865
  <key>WorkingDirectory</key>
3805
3866
  <string>${xmlEscape(crewHome)}</string>
@@ -3823,9 +3884,17 @@ ${scheduleKeys}
3823
3884
  schedulerLog
3824
3885
  };
3825
3886
  }
3826
- function planSystemd(ship, crewHome, job) {
3887
+ function planSystemd(ship, crewHome, job, ctx) {
3827
3888
  const label = labelFor(crewHome, job);
3828
- const unitDir = join13(homedir7(), ".config", "systemd", "user");
3889
+ const unitDir = ctx.root ? SYSTEM_UNIT_DIR : join13(homedir7(), ".config", "systemd", "user");
3890
+ const ctl = ctx.root ? ["systemctl"] : ["systemctl", "--user"];
3891
+ const userLine = ctx.root && ctx.user && ctx.user !== "root" ? `User=${ctx.user}
3892
+ ` : "";
3893
+ const wantedBy = ctx.root ? "multi-user.target" : "default.target";
3894
+ const linger = ctx.root || !ctx.user ? {} : {
3895
+ lingerCommand: ["loginctl", "enable-linger", ctx.user],
3896
+ lingerHint: `the daemon will stop when ${ctx.user} logs out; run \`sudo loginctl enable-linger ${ctx.user}\` once, or run \`crew install\` as root for a system unit`
3897
+ };
3829
3898
  const servicePath = join13(unitDir, `${label}.service`);
3830
3899
  const crewLog = ship.logFile;
3831
3900
  const schedulerLog = schedulerLogFor(crewLog, job);
@@ -3837,9 +3906,9 @@ Description=${description}
3837
3906
 
3838
3907
  [Service]
3839
3908
  Type=simple
3840
- ExecStart=${[...crewInvocation(crewHome), ...subcommandFor(job, "systemd")].join(" ")}
3909
+ ${userLine}ExecStart=${[...crewInvocation(crewHome), ...subcommandFor(job, "systemd")].join(" ")}
3841
3910
  WorkingDirectory=${crewHome}
3842
- Environment=PATH=${pathFor(ship)}
3911
+ ${systemdEnvLines(ship)}
3843
3912
  StandardOutput=append:${schedulerLog}
3844
3913
  StandardError=append:${schedulerLog}
3845
3914
  Restart=on-failure
@@ -3849,7 +3918,7 @@ RestartSec=5
3849
3918
  KillMode=process
3850
3919
 
3851
3920
  [Install]
3852
- WantedBy=default.target
3921
+ WantedBy=${wantedBy}
3853
3922
  `;
3854
3923
  return {
3855
3924
  mechanism: "systemd",
@@ -3859,11 +3928,12 @@ WantedBy=default.target
3859
3928
  cronMarker: null,
3860
3929
  preLoadCommands: [],
3861
3930
  loadCommands: [
3862
- ["systemctl", "--user", "daemon-reload"],
3863
- ["systemctl", "--user", "enable", "--now", `${label}.service`]
3931
+ [...ctl, "daemon-reload"],
3932
+ [...ctl, "enable", "--now", `${label}.service`]
3864
3933
  ],
3865
3934
  crewLog,
3866
- schedulerLog
3935
+ schedulerLog,
3936
+ ...linger
3867
3937
  };
3868
3938
  }
3869
3939
  const timerPath = join13(unitDir, `${label}.timer`);
@@ -3873,9 +3943,9 @@ Description=${description}
3873
3943
 
3874
3944
  [Service]
3875
3945
  Type=oneshot
3876
- ExecStart=${[...crewInvocation(crewHome), ...subcommandFor(job, "systemd")].join(" ")}
3946
+ ${userLine}ExecStart=${[...crewInvocation(crewHome), ...subcommandFor(job, "systemd")].join(" ")}
3877
3947
  WorkingDirectory=${crewHome}
3878
- Environment=PATH=${pathFor(ship)}
3948
+ ${systemdEnvLines(ship)}
3879
3949
  StandardOutput=append:${schedulerLog}
3880
3950
  StandardError=append:${schedulerLog}
3881
3951
  `;
@@ -3899,11 +3969,12 @@ WantedBy=timers.target
3899
3969
  cronMarker: null,
3900
3970
  preLoadCommands: [],
3901
3971
  loadCommands: [
3902
- ["systemctl", "--user", "daemon-reload"],
3903
- ["systemctl", "--user", "enable", "--now", `${label}.timer`]
3972
+ [...ctl, "daemon-reload"],
3973
+ [...ctl, "enable", "--now", `${label}.timer`]
3904
3974
  ],
3905
3975
  crewLog,
3906
- schedulerLog
3976
+ schedulerLog,
3977
+ ...linger
3907
3978
  };
3908
3979
  }
3909
3980
  function planCron(ship, crewHome, job) {
@@ -3911,7 +3982,7 @@ function planCron(ship, crewHome, job) {
3911
3982
  const crewLog = ship.logFile;
3912
3983
  const schedulerLog = schedulerLogFor(crewLog, job);
3913
3984
  const minutes = Math.max(1, Math.round(INTERVAL_SECONDS / 60));
3914
- const line = `*/${minutes} * * * * cd ${crewHome} && PATH=${pathFor(ship)} ${[...crewInvocation(crewHome), ...subcommandFor(job, "cron")].join(" ")} >> ${schedulerLog} 2>&1`;
3985
+ const line = `*/${minutes} * * * * cd ${crewHome} && ${unitEnvironment(ship).map(([k, v]) => `${k}=${shQuote(v)}`).join(" ")} ${[...crewInvocation(crewHome), ...subcommandFor(job, "cron")].join(" ")} >> ${schedulerLog} 2>&1`;
3915
3986
  return {
3916
3987
  mechanism: "cron",
3917
3988
  unitPaths: [],
@@ -3931,9 +4002,9 @@ function unitAbandonsAgents(plan, read) {
3931
4002
  if (text === null) return null;
3932
4003
  return plan.mechanism === "launchd" ? /<key>AbandonProcessGroup<\/key>\s*<true\/>/.test(text) : /^KillMode=process\s*$/m.test(text);
3933
4004
  }
3934
- function planInstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
4005
+ function planInstall(ship, crewHome, hostShip, hasSystemd, job = "run", ctx = USER_INSTALL) {
3935
4006
  if (hostShip === "macos") return planLaunchd(ship, crewHome, job);
3936
- if (hostShip === "linux" && hasSystemd) return planSystemd(ship, crewHome, job);
4007
+ if (hostShip === "linux" && hasSystemd) return planSystemd(ship, crewHome, job, ctx);
3937
4008
  if (hostShip === "linux") return planCron(ship, crewHome, job);
3938
4009
  throw new Error(`crew install: no scheduler support yet for ${hostShip} (Windows is planned, not built)`);
3939
4010
  }
@@ -3950,20 +4021,22 @@ function planUninstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
3950
4021
  };
3951
4022
  }
3952
4023
  if (hostShip === "linux" && hasSystemd) {
3953
- const unitDir = join13(homedir7(), ".config", "systemd", "user");
4024
+ const system = existsSync11(systemUnitPathFor(label));
4025
+ const unitDir = system ? SYSTEM_UNIT_DIR : join13(homedir7(), ".config", "systemd", "user");
4026
+ const ctl = system ? ["systemctl"] : ["systemctl", "--user"];
3954
4027
  if (job === "run") {
3955
4028
  return {
3956
4029
  mechanism: "systemd",
3957
4030
  unitPaths: [join13(unitDir, `${label}.service`)],
3958
4031
  crontabLine: null,
3959
- unloadCommands: [["systemctl", "--user", "disable", "--now", `${label}.service`]]
4032
+ unloadCommands: [[...ctl, "disable", "--now", `${label}.service`]]
3960
4033
  };
3961
4034
  }
3962
4035
  return {
3963
4036
  mechanism: "systemd",
3964
4037
  unitPaths: [join13(unitDir, `${label}.service`), join13(unitDir, `${label}.timer`)],
3965
4038
  crontabLine: null,
3966
- unloadCommands: [["systemctl", "--user", "disable", "--now", `${label}.timer`]]
4039
+ unloadCommands: [[...ctl, "disable", "--now", `${label}.timer`]]
3967
4040
  };
3968
4041
  }
3969
4042
  if (hostShip === "linux") {
@@ -3989,11 +4062,12 @@ function planDaemonControl(crewHome, hostShip, hasSystemd) {
3989
4062
  }
3990
4063
  if (hostShip === "linux" && hasSystemd) {
3991
4064
  const unit = `${label}.service`;
4065
+ const ctl = existsSync11(systemUnitPathFor(label)) ? ["systemctl"] : ["systemctl", "--user"];
3992
4066
  return {
3993
4067
  mechanism: "systemd",
3994
- startCommand: ["systemctl", "--user", "start", unit],
3995
- stopCommand: ["systemctl", "--user", "stop", unit],
3996
- statusCommand: ["systemctl", "--user", "status", unit]
4068
+ startCommand: [...ctl, "start", unit],
4069
+ stopCommand: [...ctl, "stop", unit],
4070
+ statusCommand: [...ctl, "status", unit]
3997
4071
  };
3998
4072
  }
3999
4073
  if (hostShip === "linux") {
@@ -4014,11 +4088,14 @@ function runAll(commands, cwd, log) {
4014
4088
  return true;
4015
4089
  }
4016
4090
  async function applyInstall(plan, crewHome, dryRun, log) {
4091
+ let changed2 = plan.crontabLine !== null;
4017
4092
  for (const path of plan.unitPaths) {
4018
4093
  if (dryRun) {
4019
4094
  log.emit(`would write ${path}`);
4020
4095
  continue;
4021
4096
  }
4097
+ const before = existsSync11(path) ? readFileSync12(path, "utf8") : null;
4098
+ if (before !== plan.unitContent[path]) changed2 = true;
4022
4099
  mkdirSync7(dirname5(path), { recursive: true });
4023
4100
  writeFileSync8(path, plan.unitContent[path]);
4024
4101
  log.emit(`wrote ${path}`);
@@ -4048,8 +4125,18 @@ async function applyInstall(plan, crewHome, dryRun, log) {
4048
4125
  log.emit(`loaded (${plan.mechanism}), firing every ${INTERVAL_SECONDS}s starting ${INTERVAL_SECONDS}s from now`);
4049
4126
  }
4050
4127
  }
4128
+ if (plan.lingerCommand) {
4129
+ if (dryRun) {
4130
+ log.emit(`would run: ${plan.lingerCommand.join(" ")}`);
4131
+ } else if (runAll([plan.lingerCommand], crewHome, { emit: log.emit.bind(log), warn: () => void 0 })) {
4132
+ log.emit(`lingering enabled for ${plan.lingerCommand[2]}: the unit survives logout`);
4133
+ } else {
4134
+ log.warn(`could not enable lingering \u2014 ${plan.lingerHint ?? "run loginctl enable-linger by hand"}`);
4135
+ }
4136
+ }
4051
4137
  log.emit(`crew's own log: ${plan.crewLog}`);
4052
4138
  log.emit(`scheduler log (only what escapes the crew, e.g. a crash before it starts): ${plan.schedulerLog}`);
4139
+ return { changed: changed2 };
4053
4140
  }
4054
4141
  async function applyUninstall(plan, crewHome, dryRun, log) {
4055
4142
  if (plan.mechanism === "none") {
@@ -7563,6 +7650,28 @@ var Tracker = class {
7563
7650
  if (!auth.commentShip) missing.push(`Comments.${this.contract.comments.ship}`);
7564
7651
  return missing;
7565
7652
  }
7653
+ /**
7654
+ * The runtime-map columns (ISSUE-2791) this workspace's template has not
7655
+ * taken yet, as `table.column` names for `crew doctor`. Each is read as its
7656
+ * default when absent, so this is informational: `Repos.forge_host`
7657
+ * (default `github.com`), `Ships.kind` (`developer_machine`),
7658
+ * `Ships.instance_class` and `Ships.relay_url` (blank). A table the route
7659
+ * does not have is skipped, not reported.
7660
+ */
7661
+ async missingRuntimeColumns() {
7662
+ const wanted = [
7663
+ [this.route.resolved?.reposModelId, "Repos", ["forge_host"]],
7664
+ [this.route.resolved?.shipsModelId, "Ships", ["kind", "instance_class", "relay_url"]]
7665
+ ];
7666
+ const missing = [];
7667
+ for (const [modelId, label, columns] of wanted) {
7668
+ if (!modelId) continue;
7669
+ const have = await this.columnNames(modelId);
7670
+ if (have.size === 0) continue;
7671
+ for (const c of columns) if (!have.has(c)) missing.push(`${label}.${c}`);
7672
+ }
7673
+ return missing;
7674
+ }
7566
7675
  /**
7567
7676
  * The public `/v/<slug>/r/<token>` link for one ticket (ISSUE-928) —
7568
7677
  * fetch-or-create via `POST /views/:viewId/record-links`, so a repeated
@@ -9190,6 +9299,55 @@ function skillMarkerWarnings(promptsDir, personaPrompts) {
9190
9299
  return out;
9191
9300
  }
9192
9301
 
9302
+ // src/run-limit.ts
9303
+ var DEFAULT_MAX_RUN_MINUTES = 240;
9304
+ var KILL_GRACE_MS = 5e3;
9305
+ var WALL_CLOCK_REASON = "wall-clock";
9306
+ function resolveMaxRunMinutes(route2, toolchain) {
9307
+ return route2 ?? toolchain ?? DEFAULT_MAX_RUN_MINUTES;
9308
+ }
9309
+ function deadlineFor(spawnedAt, maxRunMinutes) {
9310
+ if (!maxRunMinutes || maxRunMinutes <= 0) return void 0;
9311
+ const t = Date.parse(spawnedAt);
9312
+ return Number.isNaN(t) ? void 0 : new Date(t + maxRunMinutes * 6e4).toISOString();
9313
+ }
9314
+ function msUntilDeadline(deadline, now = Date.now()) {
9315
+ if (!deadline) return void 0;
9316
+ const t = Date.parse(deadline);
9317
+ return Number.isNaN(t) ? void 0 : t - now;
9318
+ }
9319
+ async function killRunGroup(pid, o = {}) {
9320
+ const alive = o.isAlive ?? ((p) => {
9321
+ try {
9322
+ process.kill(p, 0);
9323
+ return true;
9324
+ } catch (e) {
9325
+ return e.code === "EPERM";
9326
+ }
9327
+ });
9328
+ const signal = (sig) => {
9329
+ try {
9330
+ process.kill(-pid, sig);
9331
+ } catch {
9332
+ try {
9333
+ process.kill(pid, sig);
9334
+ } catch {
9335
+ }
9336
+ }
9337
+ };
9338
+ signal("SIGTERM");
9339
+ const end = Date.now() + (o.graceMs ?? KILL_GRACE_MS);
9340
+ while (alive(pid) && Date.now() < end) await new Promise((r) => setTimeout(r, 50));
9341
+ if (alive(pid)) signal("SIGKILL");
9342
+ }
9343
+ var RUN_KILLED_MARKER = "<!-- crew:run-killed -->";
9344
+ function runKilledComment(b) {
9345
+ 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.";
9346
+ return `${RUN_KILLED_MARKER}
9347
+
9348
+ The agent run on this ticket was stopped at its wall-clock limit of ${b.limitMinutes} minutes before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The worktree is left as it was. The next run on this seat resumes from it rather than starting over.`;
9349
+ }
9350
+
9193
9351
  // src/agent.ts
9194
9352
  var DIGEST_MAX_AGE_SECONDS = 600;
9195
9353
  var LEGACY_TOOLCHAIN_HARNESS = "claude";
@@ -9299,6 +9457,7 @@ function planAgentRun(o) {
9299
9457
  toolchain: toolchain.name,
9300
9458
  harness: toolchain.harness,
9301
9459
  provider: toolchainProvider(toolchain),
9460
+ maxRunMinutes: resolveMaxRunMinutes(o.route.maxRunMinutes, toolchain.maxRunMinutes),
9302
9461
  resolution: { toolchain: seat.toolchainWhy, model: seat.modelWhy },
9303
9462
  warnings: [...seat.warnings ?? [], ...expanded.warnings],
9304
9463
  prompt,
@@ -9388,6 +9547,17 @@ function identityOf(plan, adapterHarness) {
9388
9547
  promptSha: plan.promptSha
9389
9548
  };
9390
9549
  }
9550
+ async function killForDeadline(pid, emit, role, ticket, spawnedAt, limitMinutes) {
9551
+ const elapsedMs = Date.now() - Date.parse(spawnedAt);
9552
+ const limit = limitMinutes ?? Math.round(elapsedMs / 6e4);
9553
+ emit.emit(`run_killed: ${role} run exceeded its wall-clock limit of ${limit} min (ran ${Math.round(elapsedMs / 6e4)} min)`, {
9554
+ role,
9555
+ ...ticket ? { ticket } : {},
9556
+ data: { event: "run_killed", reason: WALL_CLOCK_REASON, limitMinutes: limit, elapsedMs, pid }
9557
+ });
9558
+ await killRunGroup(pid);
9559
+ return { limitMinutes: limit, elapsedMs };
9560
+ }
9391
9561
  var withoutKey = ({ apiKey: _apiKey, ...rest }) => rest;
9392
9562
  function openSink(path) {
9393
9563
  try {
@@ -9747,13 +9917,16 @@ function spawnAgent(plan, emit, hintMoved) {
9747
9917
  detached: true
9748
9918
  });
9749
9919
  closeSync3(streamFd);
9920
+ const spawnedAt = (/* @__PURE__ */ new Date()).toISOString();
9921
+ const deadline = deadlineFor(spawnedAt, plan.maxRunMinutes);
9750
9922
  if (child.pid !== void 0) {
9751
9923
  const sidecar = {
9752
9924
  pid: child.pid,
9753
9925
  ownerPid: process.pid,
9754
- spawnedAt: (/* @__PURE__ */ new Date()).toISOString(),
9926
+ spawnedAt,
9755
9927
  role: plan.role,
9756
9928
  route: plan.route,
9929
+ ...deadline ? { deadline, maxRunMinutes: plan.maxRunMinutes } : {},
9757
9930
  ticket: plan.ticket ?? null,
9758
9931
  ticketRecordId: plan.ticketRecordId ?? null,
9759
9932
  repo: plan.repo ?? null,
@@ -9773,25 +9946,45 @@ function spawnAgent(plan, emit, hintMoved) {
9773
9946
  }
9774
9947
  }
9775
9948
  const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
9776
- const meta = { pid: child.pid ?? null };
9949
+ const meta = { pid: child.pid ?? null, ...deadline ? { deadline } : {} };
9777
9950
  obs.onClaim = () => writeRunMeta(id, startedAt, obs.liveTicket, meta);
9778
9951
  if (child.pid !== void 0) writeRunMeta(id, startedAt, obs.liveTicket, meta);
9779
9952
  const tail3 = new FileTail(plan.streamPath);
9780
9953
  const timer = setInterval(() => obs.feed(tail3.poll()), TAIL_POLL_MS);
9954
+ let killed;
9955
+ const limitTimer = child.pid !== void 0 && deadline ? setTimeout(() => {
9956
+ const pid = child.pid;
9957
+ void killForDeadline(pid, emit, plan.role, obs.liveTicket ?? plan.ticket ?? null, spawnedAt, plan.maxRunMinutes).then((k) => {
9958
+ killed = k;
9959
+ });
9960
+ }, Math.max(0, msUntilDeadline(deadline) ?? 0)) : void 0;
9781
9961
  let spawnFailed = false;
9782
9962
  child.on("error", (err3) => {
9783
9963
  spawnFailed = true;
9784
9964
  clearInterval(timer);
9965
+ clearTimeout(limitTimer);
9785
9966
  reject(new AgentSpawnError(`cannot run ${plan.bin}: ${err3.message}`));
9786
9967
  });
9787
9968
  child.on("close", async (code) => {
9788
9969
  clearInterval(timer);
9970
+ clearTimeout(limitTimer);
9789
9971
  obs.feed(tail3.poll());
9790
9972
  obs.end();
9791
9973
  await endSink(eventsSink);
9792
9974
  if (spawnFailed) return;
9793
- const { ms: ms3, handoffPending } = await finishRun(id, obs, emit, startedAt, { code: code ?? 1 }, plan.agentLog?.apiKey, hintMoved);
9794
- resolve6({ code: code ?? 1, ms: ms3, ...handoffPending ? { handoffPending } : {} });
9975
+ if (limitTimer && !killed && !obs.sawResult && msUntilDeadline(deadline) <= 0) {
9976
+ killed = { limitMinutes: plan.maxRunMinutes ?? 0, elapsedMs: Date.now() - Date.parse(spawnedAt) };
9977
+ }
9978
+ const { ms: ms3, handoffPending } = await finishRun(
9979
+ id,
9980
+ obs,
9981
+ emit,
9982
+ startedAt,
9983
+ { code: code ?? 1, ...killed ? { interrupted: WALL_CLOCK_REASON } : {} },
9984
+ plan.agentLog?.apiKey,
9985
+ hintMoved
9986
+ );
9987
+ resolve6({ code: code ?? 1, ms: ms3, ...handoffPending ? { handoffPending } : {}, ...killed ? { killed } : {} });
9795
9988
  });
9796
9989
  child.stdin?.on("error", (err3) => {
9797
9990
  emit.warn(`agent closed stdin before the prompt was delivered: ${err3.message}`, { step: "agent" });
@@ -9810,18 +10003,23 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
9810
10003
  obs.feed(tail3.poll());
9811
10004
  obs.muted = false;
9812
10005
  const adopted = isAlive(sc);
10006
+ let killed;
9813
10007
  if (adopted) {
9814
10008
  o.onAdopted?.();
9815
10009
  const pollMs = o.pollMs ?? TAIL_POLL_MS;
9816
10010
  while (isAlive(sc)) {
9817
10011
  obs.feed(tail3.poll());
10012
+ if (!killed && (msUntilDeadline(sc.deadline) ?? 1) <= 0) {
10013
+ killed = await killForDeadline(sc.pid, emit, sc.role, obs.liveTicket ?? sc.ticket, sc.spawnedAt, sc.maxRunMinutes);
10014
+ continue;
10015
+ }
9818
10016
  await new Promise((r) => setTimeout(r, pollMs));
9819
10017
  }
9820
10018
  }
9821
10019
  obs.feed(tail3.poll());
9822
10020
  obs.end();
9823
10021
  await endSink(eventsSink);
9824
- const interrupted = obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
10022
+ const interrupted = killed ? WALL_CLOCK_REASON : obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
9825
10023
  const { ms: ms3, ticket } = await finishRun(
9826
10024
  id,
9827
10025
  obs,
@@ -9830,7 +10028,7 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
9830
10028
  { code: null, ...interrupted ? { interrupted } : {} },
9831
10029
  o.agentLogKey
9832
10030
  );
9833
- return { adopted, ...interrupted ? { interrupted } : {}, ms: ms3, ticket };
10031
+ return { adopted, ...interrupted ? { interrupted } : {}, ...killed ? { killed } : {}, ms: ms3, ticket };
9834
10032
  }
9835
10033
 
9836
10034
  // src/seat-keys.ts
@@ -10335,8 +10533,8 @@ function doctorSelftestLines(stored, toolchains) {
10335
10533
  // src/cli.ts
10336
10534
  import { resolve as resolve5, dirname as dirname22, join as join44, basename as basename13 } from "path";
10337
10535
  import { fileURLToPath } from "url";
10338
- import { hostname as hostname2, homedir as homedir10 } from "os";
10339
- import { spawnSync as spawnSync7 } from "child_process";
10536
+ import { hostname as hostname3, homedir as homedir10 } from "os";
10537
+ import { spawnSync as spawnSync8 } from "child_process";
10340
10538
  import { parse as parseYaml2 } from "yaml";
10341
10539
 
10342
10540
  // src/argv.ts
@@ -10348,6 +10546,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
10348
10546
  "clear",
10349
10547
  "dir",
10350
10548
  "extra-path",
10549
+ "forge-host",
10351
10550
  "harness",
10352
10551
  "hook",
10353
10552
  "host",
@@ -12588,134 +12787,719 @@ function flagWizardAnswers(f, env) {
12588
12787
  };
12589
12788
  }
12590
12789
 
12591
- // src/release-run.ts
12592
- import { readFileSync as readFileSync23, writeFileSync as writeFileSync19, existsSync as existsSync25, copyFileSync, mkdirSync as mkdirSync17, mkdtempSync as mkdtempSync2, rmSync as rmSync14 } from "fs";
12593
- import { dirname as dirname13, join as join29 } from "path";
12594
- import { tmpdir as tmpdir3 } from "os";
12790
+ // src/provision.ts
12791
+ import { spawnSync as spawnSync6 } from "child_process";
12792
+ import { existsSync as existsSync25, readFileSync as readFileSync23, writeFileSync as writeFileSync19 } from "fs";
12793
+ import { hostname as hostname2 } from "os";
12794
+ import { parseDocument as parseDocument3 } from "yaml";
12595
12795
 
12596
- // src/release.ts
12597
- function describeUnplaceable(unplaceable, verifiedStatus) {
12598
- const stranded = unplaceable.filter((u) => u.ticket.status === verifiedStatus);
12599
- if (stranded.length === 0) return [];
12600
- const notes = [];
12601
- const keysFor = (r) => stranded.filter((u) => u.reason === r).map((u) => u.ticket.issue_id);
12602
- const noRepo = keysFor("no-repo");
12603
- if (noRepo.length) {
12604
- notes.push({
12605
- level: "warn",
12606
- message: `${noRepo.length} verified ticket(s) name no repository and cannot be released: ` + noRepo.join(", ")
12607
- });
12796
+ // src/doctor-fix.ts
12797
+ import { parseDocument as parseDocument2 } from "yaml";
12798
+
12799
+ // src/repos-cmd.ts
12800
+ import { homedir as homedir9 } from "os";
12801
+ import { basename as basename8, join as join29, resolve as resolve3 } from "path";
12802
+ import { isMap, isSeq, parseDocument, stringify as stringify2 } from "yaml";
12803
+ var ReposError = class extends Error {
12804
+ };
12805
+ function normalizeRemote(url) {
12806
+ if (!url) return void 0;
12807
+ let s = url.trim();
12808
+ if (!s) return void 0;
12809
+ const scp = /^[^\s@/]+@[^\s:/]+:(.+)$/.exec(s);
12810
+ if (scp) s = scp[1];
12811
+ else s = s.replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^/@]+@)?[^/]+\//i, "");
12812
+ s = s.replace(/\/+$/, "").replace(/\.git$/i, "");
12813
+ const parts = s.split("/").filter(Boolean);
12814
+ if (parts.length < 2) return void 0;
12815
+ return parts.slice(-2).join("/").toLowerCase();
12816
+ }
12817
+ function matchRepoName(origin, resolved) {
12818
+ const want = normalizeRemote(origin);
12819
+ if (!want || !resolved?.repoRemotes) return void 0;
12820
+ for (const [id, remote] of Object.entries(resolved.repoRemotes)) {
12821
+ if (normalizeRemote(remote) === want) return resolved.repoNames?.[id];
12608
12822
  }
12609
- const unknown = keysFor("unknown-repo");
12610
- if (unknown.length) {
12611
- notes.push({
12612
- level: "warn",
12613
- message: `${unknown.length} verified ticket(s) name a repository this ship does not recognise \u2014 run \`crew connect\` to refresh its repo list: ${unknown.join(", ")}`
12614
- });
12823
+ return void 0;
12824
+ }
12825
+ function routeNamesOf(text) {
12826
+ const routes = parseDocument(text).get("routes", true);
12827
+ if (!isSeq(routes)) return [];
12828
+ return routes.items.flatMap((i) => isMap(i) && typeof i.get("route") === "string" ? [i.get("route")] : []);
12829
+ }
12830
+ function parseAddArgs(args, routes, isDir) {
12831
+ const rest = [...args];
12832
+ let route2;
12833
+ if (rest.length > 0 && routes.includes(rest[0])) route2 = rest.shift();
12834
+ else if (rest.length > 0 && /^[^/\s]+\/[^/\s]+$/.test(rest[0]) && !isDir(rest[0])) {
12835
+ throw new ReposError(`no route "${rest[0]}" in the config (have: ${routes.join(", ") || "none"})`);
12615
12836
  }
12616
- const elsewhere = stranded.filter((u) => u.reason === "not-served-here");
12617
- if (elsewhere.length) {
12618
- const byRepoName = /* @__PURE__ */ new Map();
12619
- for (const u of elsewhere) {
12620
- const name = u.repo ?? "?";
12621
- byRepoName.set(name, [...byRepoName.get(name) ?? [], u.ticket.issue_id]);
12622
- }
12623
- notes.push({
12624
- level: "info",
12625
- message: `${elsewhere.length} verified ticket(s) belong to a repository this ship has no checkout for \u2014 another ship releases them: ` + [...byRepoName].map(([n, ids]) => `${n} (${ids.join(", ")})`).join("; ")
12626
- });
12837
+ if (!route2) {
12838
+ if (routes.length === 1) route2 = routes[0];
12839
+ else throw new ReposError(routes.length === 0 ? "the config has no routes \u2014 run `crew connect` first" : `several routes are configured; name one: ${routes.join(", ")}`);
12627
12840
  }
12628
- return notes;
12841
+ if (rest.length > 2) throw new ReposError("too many arguments: expected [route] [name] [path]");
12842
+ if (rest.length === 2) return { route: route2, name: rest[0], path: rest[1] };
12843
+ if (rest.length === 1) return isDir(rest[0]) ? { route: route2, path: rest[0] } : { route: route2, name: rest[0], path: "." };
12844
+ return { route: route2, path: "." };
12629
12845
  }
12630
- var unplaceableReported = /* @__PURE__ */ new Set();
12631
- function reportUnplaceable(routeName, unplaceable, verified, emit) {
12632
- if (unplaceableReported.has(routeName)) return;
12633
- unplaceableReported.add(routeName);
12634
- for (const note of describeUnplaceable(unplaceable, verified)) {
12635
- if (note.level === "warn") emit.warn(note.message, { step: "release" });
12636
- else emit.emit(note.message, { step: "release" });
12846
+ function expandDir(p, base) {
12847
+ const home = p === "~" ? homedir9() : p.startsWith("~/") ? `${homedir9()}/${p.slice(2)}` : p;
12848
+ return resolve3(base, home);
12849
+ }
12850
+ function routeMapOf(doc, route2) {
12851
+ const routes = doc.get("routes", true);
12852
+ if (isSeq(routes)) {
12853
+ for (const item of routes.items) {
12854
+ if (isMap(item) && item.get("route") === route2) return item;
12855
+ }
12637
12856
  }
12857
+ const have = isSeq(routes) ? routes.items.map((i) => isMap(i) ? String(i.get("route")) : "?").join(", ") : "none";
12858
+ throw new ReposError(`no route "${route2}" in the config (have: ${have})`);
12638
12859
  }
12639
- function checkGuards(cwd, base = "main") {
12640
- const branch = currentBranch(cwd);
12641
- if (branch !== base) {
12642
- return { kind: "branch", detail: `primary checkout is on '${branch}', not ${base}` };
12860
+ function configuredRepos(text, route2, base) {
12861
+ const doc = parseDocument(text);
12862
+ const m = routeMapOf(doc, route2);
12863
+ const repos = m.get("repos");
12864
+ if (isMap(repos)) {
12865
+ return repos.items.map((p) => {
12866
+ const name = String(p.key.value ?? p.key);
12867
+ const v = p.value;
12868
+ const dir2 = isMap(v) ? String(v.get("dir") ?? "") : String(v?.value ?? "");
12869
+ return { name, dir: expandDir(dir2, base) };
12870
+ });
12643
12871
  }
12644
- const changes = status(cwd);
12645
- if (changes.length === 0) return null;
12646
- const untracked = changes.filter((c) => c.untracked).length;
12647
- const names = changes.slice(0, 3).map((c) => c.path).join(", ");
12648
- const more = changes.length > 3 ? `, +${changes.length - 3} more` : "";
12649
- const untrackedNote = untracked > 0 ? ` (${untracked} untracked)` : "";
12650
- return {
12651
- kind: "dirty",
12652
- detail: `working tree has ${changes.length} change(s)${untrackedNote}: ${names}${more}`
12653
- };
12872
+ const dir = m.get("dir");
12873
+ return dir ? [{ name: basename8(String(dir)), dir: expandDir(String(dir), base) }] : [];
12654
12874
  }
12655
- function heldByBlockers(tickets, verifiedStatus, info) {
12656
- if (!info) return [];
12657
- return tickets.filter((t) => t.status === verifiedStatus && unresolvedBlockers(t, info, { mergeGated: false }).length > 0).map((ticket) => ({ ticket, blockers: describeUnresolved(ticket, info, { mergeGated: false }) }));
12875
+ function cloneBaseOf(text, route2, base, isCheckout) {
12876
+ const m = routeMapOf(parseDocument(text), route2);
12877
+ const repos = m.get("repos", true);
12878
+ if (isMap(repos) && repos.items.length > 0) return null;
12879
+ const dir = m.get("dir");
12880
+ if (!dir) return null;
12881
+ const abs = expandDir(String(dir), base);
12882
+ return isCheckout(abs) ? null : abs;
12658
12883
  }
12659
- var describeHeld = (held) => held.map((h) => `${h.ticket.issue_tag ?? h.ticket.issue_id} held: blocked by ${h.blockers}`);
12660
- var CHANGELOG_LINE = /^Changelog:[ \t]*(.*)$/gm;
12661
- var TICKET_REF_AT_START = /^([A-Z][A-Z0-9]*)-(\d+)\b/;
12662
- function dedupeChangelogEntries(entries, ticket) {
12663
- const own = (ticket.issue_tag ?? ticket.issue_id).match(/^([A-Z][A-Z0-9]*)-(\d+)$/);
12664
- const seen = /* @__PURE__ */ new Set();
12665
- const out = [];
12666
- for (const raw of entries) {
12667
- let line = raw.trim();
12668
- const ref = line.match(TICKET_REF_AT_START);
12669
- let key = line;
12670
- if (ref) {
12671
- const prefix = ref[1];
12672
- const num = ref[2];
12673
- const canonical = own && own[2] === num ? own[1] : prefix;
12674
- line = `${canonical}-${num}${line.slice(ref[0].length)}`;
12675
- key = `${canonical}-${num}`;
12676
- }
12677
- if (seen.has(key)) continue;
12678
- seen.add(key);
12679
- out.push(line);
12680
- }
12681
- return out;
12884
+ function routeSetsHooks(text, route2, name) {
12885
+ const m = routeMapOf(parseDocument(text), route2);
12886
+ const repos = m.get("repos", true);
12887
+ let target = m;
12888
+ if (isMap(repos)) target = repos.get(name, true);
12889
+ if (!isMap(target)) return false;
12890
+ const hooks = target.get("hooks", true);
12891
+ return isMap(hooks) && hooks.items.length > 0;
12682
12892
  }
12683
- var BUMP_LINE = /^Bump:[ \t]*(major|minor|patch)\b/gim;
12684
- function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main", blockerInfo, ref = "HEAD") {
12685
- const held = new Set(heldByBlockers(tickets, contract.statuses.verified, blockerInfo).map((h) => h.ticket.id));
12686
- const verified = tickets.filter((t) => t.status === contract.statuses.verified && !held.has(t.id)).sort((a, b) => Number.parseInt(a.issue_id.replace(/^\D+/, ""), 10) - Number.parseInt(b.issue_id.replace(/^\D+/, ""), 10));
12687
- return verified.map((ticket) => {
12688
- const branch = branchFor(ticket);
12689
- if (!branch) {
12690
- const keys = referenceKeys(ticket);
12691
- const unreleased = lastReleased ? findKeyInRange(cwd, keys, `${lastReleased}..${ref}`) : null;
12692
- if (unreleased) {
12693
- return {
12694
- ticket,
12695
- branch: null,
12696
- entries: [],
12697
- usedFallback: false,
12698
- bump: "patch",
12699
- majorRequested: false,
12700
- skipReason: "already-merged"
12701
- };
12702
- }
12703
- const anywhere = findKeyInRange(cwd, keys, ref);
12704
- if (anywhere) {
12705
- return {
12706
- ticket,
12707
- branch: null,
12708
- entries: [],
12709
- usedFallback: false,
12710
- bump: "patch",
12711
- majorRequested: false,
12712
- skipReason: lastReleased ? "already-released" : "already-merged",
12713
- mergedSha: anywhere
12714
- };
12715
- }
12716
- return {
12717
- ticket,
12718
- branch: null,
12893
+ function listRepoRows(i) {
12894
+ const cloneBase = i.isCheckout ? cloneBaseOf(i.text, i.route, i.base, i.isCheckout) : null;
12895
+ const checkouts = configuredRepos(i.text, i.route, i.base).filter((c) => c.dir !== cloneBase);
12896
+ const claimed = /* @__PURE__ */ new Set();
12897
+ const hooksSourceOf = (c) => i.exists(c.dir) && i.repoHasHooks(c.dir) ? "repo" : routeSetsHooks(i.text, i.route, c.name) ? "route" : "none";
12898
+ const originFor = (c, remote) => {
12899
+ const o = i.originOf(c.dir) ?? null;
12900
+ if (!o) return null;
12901
+ return remote !== null && normalizeRemote(o) === normalizeRemote(remote) ? null : o;
12902
+ };
12903
+ const rows = [];
12904
+ const names = i.resolved?.repoNames ?? {};
12905
+ const remotes = i.resolved?.repoRemotes ?? {};
12906
+ for (const [id, name] of Object.entries(names)) {
12907
+ const remote = remotes[id] ?? null;
12908
+ const want = normalizeRemote(remote);
12909
+ let idx = checkouts.findIndex((c2, n) => !claimed.has(n) && want !== void 0 && normalizeRemote(i.originOf(c2.dir)) === want);
12910
+ if (idx < 0) idx = checkouts.findIndex((c2, n) => !claimed.has(n) && c2.name === name);
12911
+ const c = idx >= 0 ? checkouts[idx] : void 0;
12912
+ if (idx >= 0) claimed.add(idx);
12913
+ rows.push({
12914
+ name,
12915
+ remote,
12916
+ repoRowId: id,
12917
+ attached: c !== void 0,
12918
+ dir: c?.dir ?? null,
12919
+ exists: c ? i.exists(c.dir) : false,
12920
+ origin: c ? originFor(c, remote) : null,
12921
+ matchedRow: c ? name : null,
12922
+ hooksSource: c ? hooksSourceOf(c) : "none"
12923
+ });
12924
+ }
12925
+ checkouts.forEach((c, n) => {
12926
+ if (claimed.has(n)) return;
12927
+ rows.push({
12928
+ name: c.name,
12929
+ remote: null,
12930
+ repoRowId: null,
12931
+ attached: true,
12932
+ dir: c.dir,
12933
+ exists: i.exists(c.dir),
12934
+ origin: originFor(c, null),
12935
+ matchedRow: null,
12936
+ hooksSource: hooksSourceOf(c)
12937
+ });
12938
+ });
12939
+ return rows;
12940
+ }
12941
+ function planRepoAdd(i) {
12942
+ const doc = parseDocument(i.text);
12943
+ if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
12944
+ const m = routeMapOf(doc, i.route);
12945
+ let existing = configuredRepos(i.text, i.route, i.base);
12946
+ const target = expandDir(i.path, i.base);
12947
+ let droppedDir;
12948
+ const placeholder = m.get("dir");
12949
+ const reposNow = m.get("repos", true);
12950
+ if (i.isCheckout && placeholder && !(isMap(reposNow) && reposNow.items.length > 0)) {
12951
+ const placeholderDir = expandDir(String(placeholder), i.base);
12952
+ if (placeholderDir !== target && !i.isCheckout(placeholderDir)) {
12953
+ droppedDir = placeholderDir;
12954
+ m.delete("dir");
12955
+ existing = [];
12956
+ }
12957
+ }
12958
+ if (existing.some((e) => e.dir === target)) {
12959
+ throw new ReposError(`${target} is already configured on route ${i.route}`);
12960
+ }
12961
+ const matched = matchRepoName(i.origin, i.resolved);
12962
+ const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename8(target);
12963
+ let warning;
12964
+ if (!matched) {
12965
+ const known = Object.values(i.resolved?.repoNames ?? {});
12966
+ warning = i.origin ? `the tracker has no Repos row for remote ${normalizeRemote(i.origin) ?? i.origin}${known.length ? ` (known: ${known.join(", ")})` : ""} \u2014 added anyway; create the row so ticket routing can find it` : "this checkout has no origin remote, so it cannot be matched to a Repos row \u2014 added anyway";
12967
+ }
12968
+ const repos = m.get("repos", true);
12969
+ let mode;
12970
+ if (isMap(repos)) {
12971
+ if (repos.has(name)) throw new ReposError(`route ${i.route} already has a repo named "${name}" (${String(repos.get(name))})`);
12972
+ repos.set(name, target);
12973
+ mode = "repos";
12974
+ } else if (m.has("dir") && existing.length > 0) {
12975
+ const old = existing[0];
12976
+ const oldName = i.nameForExisting?.(old.dir) ?? old.name;
12977
+ if (oldName === name) throw new ReposError(`route ${i.route} already has a repo named "${name}" (${old.dir})`);
12978
+ const oldDir = String(m.get("dir"));
12979
+ const pair = m.items.find((p) => p.key.value === "dir");
12980
+ pair.key = doc.createNode("repos");
12981
+ pair.value = doc.createNode({ [oldName]: oldDir, [name]: target });
12982
+ mode = "repos";
12983
+ } else if (droppedDir) {
12984
+ m.set("repos", doc.createNode({ [name]: target }));
12985
+ mode = "repos";
12986
+ } else {
12987
+ m.set("dir", target);
12988
+ mode = "dir";
12989
+ }
12990
+ return { text: String(doc), mode, name, block: renderBlock(m), warning, unmatched: !matched, droppedDir };
12991
+ }
12992
+ function planRepoRemove(text, base, route2, path) {
12993
+ const doc = parseDocument(text);
12994
+ if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
12995
+ const m = routeMapOf(doc, route2);
12996
+ const target = expandDir(path, base);
12997
+ const existing = configuredRepos(text, route2, base);
12998
+ const hit = existing.find((e) => e.dir === target);
12999
+ if (!hit) throw new ReposError(`${target} is not configured on route ${route2}`);
13000
+ const repos = m.get("repos", true);
13001
+ if (isMap(repos)) {
13002
+ repos.delete(hit.name);
13003
+ const left = existing.filter((e) => e !== hit);
13004
+ if (left.length === 1) {
13005
+ const pair = m.items.find((p) => p.key.value === "repos");
13006
+ pair.key = doc.createNode("dir");
13007
+ pair.value = doc.createNode(String(repos.get(left[0].name)));
13008
+ } else if (left.length === 0) m.delete("repos");
13009
+ } else {
13010
+ m.delete("dir");
13011
+ }
13012
+ return { text: String(doc), name: hit.name };
13013
+ }
13014
+ function renderBlock(m) {
13015
+ return stringify2([m.toJSON()]).trimEnd();
13016
+ }
13017
+ var LOCAL_RELEASE_MODES = /* @__PURE__ */ new Set(["integrate", "external"]);
13018
+ function planRepoRow(i) {
13019
+ const remote = normalizeRemote(i.origin);
13020
+ if (!remote) throw new ReposError(`cannot derive an owner/repo from origin "${i.origin}"`);
13021
+ const mode = i.spec?.releaseMode;
13022
+ return {
13023
+ name: i.name,
13024
+ remote,
13025
+ platform: i.spec?.platform ?? "unix",
13026
+ release_mode: mode ? LOCAL_RELEASE_MODES.has(mode) ? "local" : mode : "local",
13027
+ ci_provider: i.spec?.ciProvider ?? "none",
13028
+ enabled: true,
13029
+ project_id: i.projectId
13030
+ };
13031
+ }
13032
+ function resolveRepoProject(projects, opt, routeAreaId) {
13033
+ if (opt) {
13034
+ const want = opt.toLowerCase();
13035
+ const hit = projects.find((p) => p.id === opt || p.name?.toLowerCase() === want);
13036
+ if (!hit) throw new ReposError(`no Projects row "${opt}" (have: ${projects.map((p) => p.name ?? p.id).join(", ") || "none"})`);
13037
+ return hit.id;
13038
+ }
13039
+ if (routeAreaId) return routeAreaId;
13040
+ if (projects.length === 1) return projects[0].id;
13041
+ throw new ReposError(
13042
+ `cannot tell which project the new Repos row belongs to \u2014 pass --project <name> (have: ${projects.map((p) => p.name ?? p.id).join(", ") || "none"}), or --no-create to skip creating it`
13043
+ );
13044
+ }
13045
+ function parseCloneArgs(args, routes) {
13046
+ const rest = [...args];
13047
+ let route2;
13048
+ if (rest.length > 1 && routes.includes(rest[0])) route2 = rest.shift();
13049
+ else if (rest.length > 1) throw new ReposError(`no route "${rest[0]}" in the config (have: ${routes.join(", ") || "none"})`);
13050
+ if (!route2) {
13051
+ if (routes.length === 1) route2 = routes[0];
13052
+ else throw new ReposError(routes.length === 0 ? "the config has no routes \u2014 run `crew connect` first" : `several routes are configured; name one: ${routes.join(", ")}`);
13053
+ }
13054
+ if (rest.length !== 1) throw new ReposError("expected [route] <name> \u2014 the Repos row to clone (see `crew repos list`)");
13055
+ return { route: route2, name: rest[0] };
13056
+ }
13057
+ function planRepoClone(i) {
13058
+ const names = i.resolved?.repoNames ?? {};
13059
+ const id = Object.keys(names).find((k) => names[k].toLowerCase() === i.name.toLowerCase());
13060
+ if (!id) {
13061
+ const known = Object.values(names);
13062
+ throw new ReposError(`the tracker has no Repos row named "${i.name}" for route ${i.route}${known.length ? ` (known: ${known.join(", ")})` : " \u2014 run `crew connect` to refresh them"}`);
13063
+ }
13064
+ const name = names[id];
13065
+ const remote = normalizeRemote(i.resolved?.repoRemotes?.[id]);
13066
+ if (!remote) {
13067
+ throw new ReposError(
13068
+ `the Repos row "${name}" has no remote to clone from \u2014 attach an existing checkout with \`crew repos add ${i.route} ${name} <path>\`, or set the row's remote in the tracker and re-run \`crew connect\``
13069
+ );
13070
+ }
13071
+ const doc = parseDocument(i.text);
13072
+ const m = routeMapOf(doc, i.route);
13073
+ const existing = configuredRepos(i.text, i.route, i.base);
13074
+ if (existing.some((e) => e.name.toLowerCase() === name.toLowerCase())) {
13075
+ throw new ReposError(`route ${i.route} already has a checkout named "${name}" \u2014 nothing to clone`);
13076
+ }
13077
+ const ship = doc.get("ship", true);
13078
+ const basePath = String(m.get("reposBasePath") ?? (isMap(ship) ? ship.get("reposBasePath") : void 0) ?? "~/Crew");
13079
+ const target = i.dir ? expandDir(i.dir, i.base) : join29(expandDir(basePath, i.base), i.route.split("/")[0], name);
13080
+ const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name, isCheckout: i.isCheckout });
13081
+ return { ...add, remote, target };
13082
+ }
13083
+
13084
+ // src/doctor-fix.ts
13085
+ var sound = (r) => r.hasRepos && r.problems.length === 0;
13086
+ function planFix(f) {
13087
+ const actions = [];
13088
+ const notes = [];
13089
+ for (const r of f.routes) {
13090
+ if (!r.enabled && sound(r)) {
13091
+ actions.push({ kind: "enable", route: r.route });
13092
+ const gaps = r.readiness ?? [];
13093
+ if (gaps.length) {
13094
+ notes.push(`${r.route}: ${gaps.length} release-readiness gap${gaps.length === 1 ? "" : "s"} (${gaps[0]}${gaps.length > 1 ? ", ..." : ""}) \u2014 the route will run, but releases stop at the first missing hook until it is added`);
13095
+ }
13096
+ } else if (!sound(r) && !r.enabled) {
13097
+ notes.push(`${r.route}: not enabled \u2014 ${r.hasRepos ? r.problems[0] : "no checkout is configured"}`);
13098
+ }
13099
+ }
13100
+ if (f.schedulerAbsent && f.routes.some(sound)) {
13101
+ if (f.host === "windows") notes.push("scheduler support is not built for Windows yet \u2014 nothing to install; run `crew run` by hand");
13102
+ else actions.push({ kind: "install" });
13103
+ }
13104
+ return { actions, notes };
13105
+ }
13106
+ async function gatherHealth(c, check) {
13107
+ let problems;
13108
+ let readiness = [];
13109
+ try {
13110
+ const r = await check();
13111
+ if (Array.isArray(r)) problems = r;
13112
+ else ({ problems, readiness } = r);
13113
+ } catch (e) {
13114
+ problems = [`checks could not run: ${e instanceof Error ? e.message : String(e)}`];
13115
+ }
13116
+ return { route: c.route, enabled: c.enabled, hasRepos: c.hasRepos, problems, readiness };
13117
+ }
13118
+ function routesInScope(routes, named) {
13119
+ return named ? routes.filter((r) => r.route === named) : routes;
13120
+ }
13121
+ function planEnable(text, route2) {
13122
+ const doc = parseDocument2(text);
13123
+ routeMapOf(doc, route2).set("enabled", true);
13124
+ return String(doc);
13125
+ }
13126
+ function schedulerInstalled(plan, exists, crontab) {
13127
+ if (plan.mechanism === "cron") return plan.cronMarker !== null && crontab.includes(plan.cronMarker);
13128
+ return plan.unitPaths.length > 0 && plan.unitPaths.every(exists);
13129
+ }
13130
+
13131
+ // src/provision.ts
13132
+ var PROVISION_EXIT = { ok: 0, failed: 1, problem: 2, choice: 3 };
13133
+ function parseEvents(stdout) {
13134
+ const out = [];
13135
+ for (const line of stdout.split("\n")) {
13136
+ const t = line.trim();
13137
+ if (!t.startsWith("{")) continue;
13138
+ try {
13139
+ out.push(JSON.parse(t));
13140
+ } catch {
13141
+ }
13142
+ }
13143
+ return out;
13144
+ }
13145
+ async function runProvision(o, deps, write, human) {
13146
+ const summary = { ok: false, state: "failed", changed: false, routes: [], forges: [] };
13147
+ const emit = (event, message, data = {}) => {
13148
+ if (o.json) write(`${JSON.stringify({ ...data, event, message })}
13149
+ `);
13150
+ else human(`${message}
13151
+ `);
13152
+ };
13153
+ const fail = (code, message, data = {}) => {
13154
+ emit("problem", message, data);
13155
+ return { code, summary: { ...summary, ok: false, state: "failed" } };
13156
+ };
13157
+ if (!o.key) return fail(PROVISION_EXIT.problem, "no connect key: pass --key or set CREW_CONNECT_KEY", { stage: "input" });
13158
+ if (o.routes.length === 0) {
13159
+ return fail(PROVISION_EXIT.problem, "no routes to serve: pass --route WORKSPACE[/PROJECT] (repeatable) or set CREW_PROVISION_ROUTES", { stage: "input" });
13160
+ }
13161
+ for (const host of o.forgeHosts) {
13162
+ if (!o.tokens[host]) return fail(PROVISION_EXIT.problem, `no token for forge ${host}: set CREW_FORGE_TOKEN or CREW_FORGE_TOKEN_${envKey(host)}`, { stage: "input" });
13163
+ }
13164
+ const shipName2 = o.shipName ?? deps.defaultShipName();
13165
+ summary.ship = shipName2;
13166
+ const routeNames = [];
13167
+ for (const route2 of o.routes) {
13168
+ const args = [
13169
+ "connect",
13170
+ route2,
13171
+ "--json",
13172
+ "--no-wizard",
13173
+ "--ship-name",
13174
+ shipName2,
13175
+ ...o.agentBin ? ["--agent-bin", o.agentBin] : [],
13176
+ ...o.extraPath ? ["--extra-path", o.extraPath] : [],
13177
+ ...o.baseUrl ? ["--base-url", o.baseUrl] : [],
13178
+ ...o.dryRun ? ["--dry-run"] : [],
13179
+ ...o.choices.flatMap((c) => ["--choose", c])
13180
+ ];
13181
+ const res = deps.crew(args, { env: { CREW_CONNECT_KEY: o.key } });
13182
+ const events = parseEvents(res.stdout);
13183
+ for (const e of events) if (o.json) write(`${JSON.stringify({ ...e, phase: "connect" })}
13184
+ `);
13185
+ if (res.status !== 0) {
13186
+ const problem = events.find((e) => e.event === "problem");
13187
+ const msg = String(problem?.message ?? (res.stderr.trim().split("\n").pop() || `crew connect ${route2} exited ${res.status}`));
13188
+ if (!o.json) human(`crew provision: connect ${route2}: ${msg}
13189
+ `);
13190
+ return { code: res.status === PROVISION_EXIT.choice ? PROVISION_EXIT.choice : res.status, summary: { ...summary, ok: false, state: "failed" } };
13191
+ }
13192
+ const done = events.find((e) => e.event === "done");
13193
+ const name = String(done?.route ?? route2);
13194
+ routeNames.push(name);
13195
+ const shipRow = events.find((e) => e.event === "ship_row");
13196
+ if (shipRow?.action === "created") summary.changed = true;
13197
+ if (done?.wroteConfig === true) summary.changed = true;
13198
+ emit("connect", `connected ${name}${shipRow ? ` (ship row ${String(shipRow.action)})` : ""}`, { route: name });
13199
+ }
13200
+ summary.routes = routeNames;
13201
+ if (o.dryRun) {
13202
+ summary.ok = true;
13203
+ summary.state = "dry-run";
13204
+ emit("done", "dry run: connected nothing past discovery; no forge, route, unit or daemon was touched", { summary });
13205
+ return { code: PROVISION_EXIT.ok, summary };
13206
+ }
13207
+ const configPath = deps.configFile();
13208
+ if (!configPath) return fail(PROVISION_EXIT.failed, "connect finished but no crew.yaml exists", { stage: "connect" });
13209
+ summary.configPath = configPath;
13210
+ for (const host of o.forgeHosts) {
13211
+ const token = o.tokens[host];
13212
+ const stored = deps.storedToken(host);
13213
+ if (stored === token) {
13214
+ summary.forges.push({ host, action: "unchanged" });
13215
+ emit("forge", `forge ${host}: token already stored`, { host, action: "unchanged" });
13216
+ continue;
13217
+ }
13218
+ const sub = stored === null ? "set" : "rotate";
13219
+ const res = deps.crew(["forge", sub, host, "--token-stdin"], { input: `${token}
13220
+ ` });
13221
+ if (res.status !== 0) return fail(PROVISION_EXIT.failed, `forge ${host}: ${res.stderr.trim() || `crew forge ${sub} exited ${res.status}`}`, { stage: "forge", host });
13222
+ const action = sub === "set" ? "stored" : "rotated";
13223
+ summary.forges.push({ host, action });
13224
+ summary.changed = true;
13225
+ emit("forge", `forge ${host}: token ${action}`, { host, action });
13226
+ }
13227
+ for (const route2 of routeNames) {
13228
+ if (deps.enableRoute(route2)) {
13229
+ summary.changed = true;
13230
+ emit("route_enabled", `enabled route ${route2}`, { route: route2 });
13231
+ }
13232
+ }
13233
+ const before = deps.installState();
13234
+ if (before.unitsStale || !before.running || !before.lingering) {
13235
+ const res = deps.crew(["install"]);
13236
+ if (res.status !== 0) {
13237
+ return fail(PROVISION_EXIT.failed, `crew install exited ${res.status}: ${(res.stderr || res.stdout).trim().split("\n").pop() ?? ""}`, { stage: "install" });
13238
+ }
13239
+ summary.changed = true;
13240
+ emit("install", `installed ${before.mechanism} units`, { mechanism: before.mechanism });
13241
+ }
13242
+ const after = deps.installState();
13243
+ summary.daemon = { mechanism: after.mechanism, running: after.running, lingering: after.lingering };
13244
+ if (!after.running) {
13245
+ return fail(PROVISION_EXIT.failed, `the ${after.mechanism} daemon unit is not running after install`, { stage: "daemon" });
13246
+ }
13247
+ emit("daemon", `daemon running (${after.mechanism}${after.lingering ? ", survives logout" : ", will stop at logout: lingering is off"})`, { ...summary.daemon });
13248
+ summary.ok = true;
13249
+ summary.state = summary.changed ? "provisioned" : "already provisioned";
13250
+ emit("done", summary.state, { summary });
13251
+ return { code: PROVISION_EXIT.ok, summary };
13252
+ }
13253
+ function envKey(host) {
13254
+ return host.toUpperCase().replace(/[^A-Z0-9]/g, "_");
13255
+ }
13256
+ function tokensFromEnv(hosts, env) {
13257
+ const out = {};
13258
+ for (const h of hosts) {
13259
+ const t = env[`CREW_FORGE_TOKEN_${envKey(h)}`] ?? env.CREW_FORGE_TOKEN;
13260
+ if (t?.trim()) out[h] = t.trim();
13261
+ }
13262
+ return out;
13263
+ }
13264
+ function repeated(argv2, name) {
13265
+ const out = [];
13266
+ for (let i = 0; i < argv2.length; i++) {
13267
+ const a = argv2[i];
13268
+ if (a === `--${name}`) {
13269
+ const v = argv2[++i];
13270
+ if (v !== void 0) out.push(v);
13271
+ } else if (a.startsWith(`--${name}=`)) out.push(a.slice(name.length + 3));
13272
+ }
13273
+ return out;
13274
+ }
13275
+ function splitList(v) {
13276
+ return (v ?? "").split(/[\s,]+/).filter(Boolean);
13277
+ }
13278
+ function provisionOptionsFrom(argv2, env) {
13279
+ const one = (name) => repeated(argv2, name).at(-1);
13280
+ const routes = repeated(argv2, "route");
13281
+ const forgeFlags = repeated(argv2, "forge-host");
13282
+ const tokenAvailable = !!env.CREW_FORGE_TOKEN?.trim() || Object.keys(env).some((k) => k.startsWith("CREW_FORGE_TOKEN_") && env[k]?.trim());
13283
+ const forgeHosts = (forgeFlags.length ? forgeFlags : tokenAvailable ? splitList(env.CREW_FORGE_HOSTS ?? "github.com") : []).map((h) => h.toLowerCase());
13284
+ return {
13285
+ key: one("key") ?? env.CREW_CONNECT_KEY,
13286
+ routes: routes.length ? routes : splitList(env.CREW_PROVISION_ROUTES),
13287
+ shipName: one("ship-name") ?? env.CREW_SHIP_NAME,
13288
+ agentBin: one("agent-bin"),
13289
+ extraPath: one("extra-path"),
13290
+ baseUrl: one("base-url"),
13291
+ forgeHosts,
13292
+ tokens: tokensFromEnv(forgeHosts, env),
13293
+ json: argv2.includes("--json"),
13294
+ dryRun: argv2.includes("--dry-run"),
13295
+ choices: repeated(argv2, "choose")
13296
+ };
13297
+ }
13298
+ function realProvisionDeps(crewHome) {
13299
+ const spawnCrew = (args, o = {}) => {
13300
+ const [bin, ...pre] = crewInvocation(crewHome);
13301
+ const env = { ...process.env, ...o.env };
13302
+ delete env.CREW_FORGE_TOKEN;
13303
+ const res = spawnSync6(bin, [...pre, ...args], { cwd: crewHome, encoding: "utf8", env, input: o.input, maxBuffer: 64 * 1024 * 1024 });
13304
+ return { status: res.status ?? 1, stdout: res.stdout ?? "", stderr: `${res.stderr ?? ""}${res.error ? String(res.error) : ""}` };
13305
+ };
13306
+ const configFile = () => {
13307
+ const f = findConfigFile(crewHome);
13308
+ return existsSync25(f) ? f : null;
13309
+ };
13310
+ return {
13311
+ crew: spawnCrew,
13312
+ configFile,
13313
+ enableRoute(route2) {
13314
+ const file = configFile();
13315
+ if (!file) return false;
13316
+ const text = readFileSync23(file, "utf8");
13317
+ const doc = parseDocument3(text);
13318
+ const routes = doc.get("routes");
13319
+ const entry = routes?.items?.find((r) => r.get("route") === route2);
13320
+ if (!entry) throw new Error(`route ${route2} is not in ${file}`);
13321
+ if (entry.get("enabled") === true) return false;
13322
+ writeFileSync19(file, planEnable(text, route2));
13323
+ return true;
13324
+ },
13325
+ installState() {
13326
+ const file = configFile();
13327
+ const cfg = loadConfig(crewHome, file ?? void 0, { allowNoRoutes: true });
13328
+ const host = hostPlatform();
13329
+ const ctx = installContext();
13330
+ const systemd = detectSystemd();
13331
+ const jobs = ["run", "release", "passengers"];
13332
+ let unitsStale = false;
13333
+ let mechanism = "none";
13334
+ for (const job of jobs) {
13335
+ const plan = planInstall(cfg.ship, crewHome, host, systemd, job, ctx);
13336
+ mechanism = plan.mechanism;
13337
+ if (plan.mechanism === "cron") {
13338
+ unitsStale = true;
13339
+ continue;
13340
+ }
13341
+ for (const p of plan.unitPaths) {
13342
+ if (!existsSync25(p) || readFileSync23(p, "utf8") !== plan.unitContent[p]) unitsStale = true;
13343
+ }
13344
+ }
13345
+ const label = labelFor(crewHome, "run");
13346
+ let running = false;
13347
+ let lingering = true;
13348
+ if (mechanism === "systemd") {
13349
+ const system = ctx.root || existsSync25(systemUnitPathFor(label));
13350
+ const ctl = system ? ["systemctl"] : ["systemctl", "--user"];
13351
+ running = spawnSync6(ctl[0], [...ctl.slice(1), "is-active", "--quiet", `${label}.service`]).status === 0;
13352
+ if (!system) {
13353
+ const out = spawnSync6("loginctl", ["show-user", ctx.user, "-p", "Linger"], { encoding: "utf8" });
13354
+ lingering = /Linger=yes/.test(out.stdout ?? "");
13355
+ }
13356
+ } else if (mechanism === "cron") {
13357
+ running = true;
13358
+ } else if (mechanism === "launchd") {
13359
+ running = spawnSync6("launchctl", ["list", label]).status === 0;
13360
+ }
13361
+ return { mechanism, unitsStale, running, lingering };
13362
+ },
13363
+ storedToken(host) {
13364
+ const res = spawnCrew(["credential", "get"], { input: `protocol=https
13365
+ host=${host}
13366
+
13367
+ ` });
13368
+ const m = /^password=(.*)$/m.exec(res.stdout);
13369
+ return m ? m[1] : null;
13370
+ },
13371
+ defaultShipName: () => hostname2()
13372
+ };
13373
+ }
13374
+
13375
+ // src/release-run.ts
13376
+ import { readFileSync as readFileSync24, writeFileSync as writeFileSync20, existsSync as existsSync26, copyFileSync, mkdirSync as mkdirSync17, mkdtempSync as mkdtempSync2, rmSync as rmSync14 } from "fs";
13377
+ import { dirname as dirname14, join as join30 } from "path";
13378
+ import { tmpdir as tmpdir3 } from "os";
13379
+
13380
+ // src/release.ts
13381
+ function describeUnplaceable(unplaceable, verifiedStatus) {
13382
+ const stranded = unplaceable.filter((u) => u.ticket.status === verifiedStatus);
13383
+ if (stranded.length === 0) return [];
13384
+ const notes = [];
13385
+ const keysFor = (r) => stranded.filter((u) => u.reason === r).map((u) => u.ticket.issue_id);
13386
+ const noRepo = keysFor("no-repo");
13387
+ if (noRepo.length) {
13388
+ notes.push({
13389
+ level: "warn",
13390
+ message: `${noRepo.length} verified ticket(s) name no repository and cannot be released: ` + noRepo.join(", ")
13391
+ });
13392
+ }
13393
+ const unknown = keysFor("unknown-repo");
13394
+ if (unknown.length) {
13395
+ notes.push({
13396
+ level: "warn",
13397
+ message: `${unknown.length} verified ticket(s) name a repository this ship does not recognise \u2014 run \`crew connect\` to refresh its repo list: ${unknown.join(", ")}`
13398
+ });
13399
+ }
13400
+ const elsewhere = stranded.filter((u) => u.reason === "not-served-here");
13401
+ if (elsewhere.length) {
13402
+ const byRepoName = /* @__PURE__ */ new Map();
13403
+ for (const u of elsewhere) {
13404
+ const name = u.repo ?? "?";
13405
+ byRepoName.set(name, [...byRepoName.get(name) ?? [], u.ticket.issue_id]);
13406
+ }
13407
+ notes.push({
13408
+ level: "info",
13409
+ message: `${elsewhere.length} verified ticket(s) belong to a repository this ship has no checkout for \u2014 another ship releases them: ` + [...byRepoName].map(([n, ids]) => `${n} (${ids.join(", ")})`).join("; ")
13410
+ });
13411
+ }
13412
+ return notes;
13413
+ }
13414
+ var unplaceableReported = /* @__PURE__ */ new Set();
13415
+ function reportUnplaceable(routeName, unplaceable, verified, emit) {
13416
+ if (unplaceableReported.has(routeName)) return;
13417
+ unplaceableReported.add(routeName);
13418
+ for (const note of describeUnplaceable(unplaceable, verified)) {
13419
+ if (note.level === "warn") emit.warn(note.message, { step: "release" });
13420
+ else emit.emit(note.message, { step: "release" });
13421
+ }
13422
+ }
13423
+ function checkGuards(cwd, base = "main") {
13424
+ const branch = currentBranch(cwd);
13425
+ if (branch !== base) {
13426
+ return { kind: "branch", detail: `primary checkout is on '${branch}', not ${base}` };
13427
+ }
13428
+ const changes = status(cwd);
13429
+ if (changes.length === 0) return null;
13430
+ const untracked = changes.filter((c) => c.untracked).length;
13431
+ const names = changes.slice(0, 3).map((c) => c.path).join(", ");
13432
+ const more = changes.length > 3 ? `, +${changes.length - 3} more` : "";
13433
+ const untrackedNote = untracked > 0 ? ` (${untracked} untracked)` : "";
13434
+ return {
13435
+ kind: "dirty",
13436
+ detail: `working tree has ${changes.length} change(s)${untrackedNote}: ${names}${more}`
13437
+ };
13438
+ }
13439
+ function heldByBlockers(tickets, verifiedStatus, info) {
13440
+ if (!info) return [];
13441
+ return tickets.filter((t) => t.status === verifiedStatus && unresolvedBlockers(t, info, { mergeGated: false }).length > 0).map((ticket) => ({ ticket, blockers: describeUnresolved(ticket, info, { mergeGated: false }) }));
13442
+ }
13443
+ var describeHeld = (held) => held.map((h) => `${h.ticket.issue_tag ?? h.ticket.issue_id} held: blocked by ${h.blockers}`);
13444
+ var CHANGELOG_LINE = /^Changelog:[ \t]*(.*)$/gm;
13445
+ var TICKET_REF_AT_START = /^([A-Z][A-Z0-9]*)-(\d+)\b/;
13446
+ function dedupeChangelogEntries(entries, ticket) {
13447
+ const own = (ticket.issue_tag ?? ticket.issue_id).match(/^([A-Z][A-Z0-9]*)-(\d+)$/);
13448
+ const seen = /* @__PURE__ */ new Set();
13449
+ const out = [];
13450
+ for (const raw of entries) {
13451
+ let line = raw.trim();
13452
+ const ref = line.match(TICKET_REF_AT_START);
13453
+ let key = line;
13454
+ if (ref) {
13455
+ const prefix = ref[1];
13456
+ const num = ref[2];
13457
+ const canonical = own && own[2] === num ? own[1] : prefix;
13458
+ line = `${canonical}-${num}${line.slice(ref[0].length)}`;
13459
+ key = `${canonical}-${num}`;
13460
+ }
13461
+ if (seen.has(key)) continue;
13462
+ seen.add(key);
13463
+ out.push(line);
13464
+ }
13465
+ return out;
13466
+ }
13467
+ var BUMP_LINE = /^Bump:[ \t]*(major|minor|patch)\b/gim;
13468
+ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main", blockerInfo, ref = "HEAD") {
13469
+ const held = new Set(heldByBlockers(tickets, contract.statuses.verified, blockerInfo).map((h) => h.ticket.id));
13470
+ const verified = tickets.filter((t) => t.status === contract.statuses.verified && !held.has(t.id)).sort((a, b) => Number.parseInt(a.issue_id.replace(/^\D+/, ""), 10) - Number.parseInt(b.issue_id.replace(/^\D+/, ""), 10));
13471
+ return verified.map((ticket) => {
13472
+ const branch = branchFor(ticket);
13473
+ if (!branch) {
13474
+ const keys = referenceKeys(ticket);
13475
+ const unreleased = lastReleased ? findKeyInRange(cwd, keys, `${lastReleased}..${ref}`) : null;
13476
+ if (unreleased) {
13477
+ return {
13478
+ ticket,
13479
+ branch: null,
13480
+ entries: [],
13481
+ usedFallback: false,
13482
+ bump: "patch",
13483
+ majorRequested: false,
13484
+ skipReason: "already-merged"
13485
+ };
13486
+ }
13487
+ const anywhere = findKeyInRange(cwd, keys, ref);
13488
+ if (anywhere) {
13489
+ return {
13490
+ ticket,
13491
+ branch: null,
13492
+ entries: [],
13493
+ usedFallback: false,
13494
+ bump: "patch",
13495
+ majorRequested: false,
13496
+ skipReason: lastReleased ? "already-released" : "already-merged",
13497
+ mergedSha: anywhere
13498
+ };
13499
+ }
13500
+ return {
13501
+ ticket,
13502
+ branch: null,
12719
13503
  entries: [],
12720
13504
  usedFallback: false,
12721
13505
  bump: "patch",
@@ -13331,13 +14115,13 @@ async function bump(o, size) {
13331
14115
  }
13332
14116
  return printed;
13333
14117
  }
13334
- const files = o.repo.release.versionFiles.map((f) => join29(o.cwd, f));
14118
+ const files = o.repo.release.versionFiles.map((f) => join30(o.cwd, f));
13335
14119
  const first = files[0];
13336
- if (!existsSync25(first)) {
14120
+ if (!existsSync26(first)) {
13337
14121
  o.emit.error(`no ${o.repo.release.versionFiles[0]} to read a version from`);
13338
14122
  return null;
13339
14123
  }
13340
- const current = JSON.parse(readFileSync23(first, "utf8")).version;
14124
+ const current = JSON.parse(readFileSync24(first, "utf8")).version;
13341
14125
  if (!current) {
13342
14126
  o.emit.error(`no version field in ${o.repo.release.versionFiles[0]}`);
13343
14127
  return null;
@@ -13350,27 +14134,27 @@ async function bump(o, size) {
13350
14134
  return version;
13351
14135
  }
13352
14136
  for (const f of files) {
13353
- const src = readFileSync23(f, "utf8");
14137
+ const src = readFileSync24(f, "utf8");
13354
14138
  const out = src.replace(/("version"\s*:\s*")[^"]*(")/, `$1${version}$2`);
13355
14139
  if (out === src) {
13356
14140
  o.emit.error(`no version field in ${f}`);
13357
14141
  return null;
13358
14142
  }
13359
- writeFileSync19(f, out);
14143
+ writeFileSync20(f, out);
13360
14144
  }
13361
14145
  return version;
13362
14146
  }
13363
14147
  function writeChangelog(o, version, entries) {
13364
14148
  const file = o.repo.release.changelog;
13365
14149
  if (!file || entries.length === 0) return;
13366
- const path = join29(o.cwd, file);
14150
+ const path = join30(o.cwd, file);
13367
14151
  const section = renderChangelogSection(version, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), entries);
13368
14152
  if (o.dryRun) {
13369
14153
  o.emit.emit(`would prepend ${entries.length} entr(ies) to ${file}`);
13370
14154
  return;
13371
14155
  }
13372
- const existing = existsSync25(path) ? readFileSync23(path, "utf8") : "# Changelog\n";
13373
- writeFileSync19(path, insertChangelogSection(existing, section));
14156
+ const existing = existsSync26(path) ? readFileSync24(path, "utf8") : "# Changelog\n";
14157
+ writeFileSync20(path, insertChangelogSection(existing, section));
13374
14158
  }
13375
14159
  async function confirm(o, expected) {
13376
14160
  if (!o.repo.hooks.released) return void 0;
@@ -13463,8 +14247,8 @@ function closeReleaseCheckout(cwd, root, dir, branch, run2 = gitOk) {
13463
14247
  function openReleaseCheckout(o) {
13464
14248
  const { remote, base } = o.repo.branch;
13465
14249
  const branch = `crew/release-${Date.now()}`;
13466
- const root = mkdtempSync2(join29(tmpdir3(), "crew-release-"));
13467
- const dir = join29(root, "checkout");
14250
+ const root = mkdtempSync2(join30(tmpdir3(), "crew-release-"));
14251
+ const dir = join30(root, "checkout");
13468
14252
  o.emit.emit(`release checkout: ${dir}`, { step: "release" });
13469
14253
  const close = () => closeReleaseCheckout(o.cwd, root, dir, branch);
13470
14254
  try {
@@ -13474,10 +14258,10 @@ function openReleaseCheckout(o) {
13474
14258
  throw e;
13475
14259
  }
13476
14260
  for (const f of o.repo.worktrees.copy) {
13477
- const src = join29(o.cwd, f);
13478
- if (!existsSync25(src)) continue;
13479
- mkdirSync17(dirname13(join29(dir, f)), { recursive: true });
13480
- copyFileSync(src, join29(dir, f));
14261
+ const src = join30(o.cwd, f);
14262
+ if (!existsSync26(src)) continue;
14263
+ mkdirSync17(dirname14(join30(dir, f)), { recursive: true });
14264
+ copyFileSync(src, join30(dir, f));
13481
14265
  }
13482
14266
  return { dir, branch, close };
13483
14267
  }
@@ -14178,12 +14962,12 @@ Passed: ${passed}.${willClose ? " Closing this alert." : ""}`,
14178
14962
  }
14179
14963
 
14180
14964
  // src/live-runs.ts
14181
- import { existsSync as existsSync27, readdirSync as readdirSync11 } from "fs";
14182
- import { join as join31 } from "path";
14965
+ import { existsSync as existsSync28, readdirSync as readdirSync11 } from "fs";
14966
+ import { join as join32 } from "path";
14183
14967
 
14184
14968
  // src/interrupted.ts
14185
- import { existsSync as existsSync26, readdirSync as readdirSync10, readFileSync as readFileSync24 } from "fs";
14186
- import { join as join30 } from "path";
14969
+ import { existsSync as existsSync27, readdirSync as readdirSync10, readFileSync as readFileSync25 } from "fs";
14970
+ import { join as join31 } from "path";
14187
14971
  var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
14188
14972
  function interruptedComment(b) {
14189
14973
  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.";
@@ -14202,21 +14986,21 @@ var pidAlive = (pid) => {
14202
14986
  };
14203
14987
  function runFinished(streamPath) {
14204
14988
  const path = `${streamPath}.meta.json`;
14205
- if (!existsSync26(path)) return false;
14989
+ if (!existsSync27(path)) return false;
14206
14990
  try {
14207
- return JSON.parse(readFileSync24(path, "utf8")).running !== true;
14991
+ return JSON.parse(readFileSync25(path, "utf8")).running !== true;
14208
14992
  } catch {
14209
14993
  return true;
14210
14994
  }
14211
14995
  }
14212
14996
  function orphanedStreams(o) {
14213
- const dir = join30(o.stateDir, "streams");
14214
- if (!existsSync26(dir)) return [];
14997
+ const dir = join31(o.stateDir, "streams");
14998
+ if (!existsSync27(dir)) return [];
14215
14999
  const ownerAlive = o.isOwnerAlive ?? pidAlive;
14216
15000
  const out = [];
14217
15001
  for (const name of readdirSync10(dir)) {
14218
15002
  if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
14219
- const streamPath = join30(dir, name);
15003
+ const streamPath = join31(dir, name);
14220
15004
  if (runFinished(streamPath)) continue;
14221
15005
  const sidecar = readRunSidecar(streamPath);
14222
15006
  if (!sidecar || sidecar.route !== o.routeLabel) continue;
@@ -14270,12 +15054,12 @@ async function reconcileRuns(o) {
14270
15054
 
14271
15055
  // src/live-runs.ts
14272
15056
  function liveRuns(stateDir, isAlive = runIsAlive) {
14273
- const dir = join31(stateDir, "streams");
14274
- if (!existsSync27(dir)) return [];
15057
+ const dir = join32(stateDir, "streams");
15058
+ if (!existsSync28(dir)) return [];
14275
15059
  const out = [];
14276
15060
  for (const name of readdirSync11(dir)) {
14277
15061
  if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
14278
- const streamPath = join31(dir, name);
15062
+ const streamPath = join32(dir, name);
14279
15063
  if (runFinished(streamPath)) continue;
14280
15064
  const sidecar = readRunSidecar(streamPath);
14281
15065
  if (!sidecar || !isAlive(sidecar)) continue;
@@ -14331,7 +15115,8 @@ function describeRun(run2, route2, seatIds, worktreeOf = (d, n) => {
14331
15115
  worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
14332
15116
  stream: run2.streamPath,
14333
15117
  pid: run2.pid,
14334
- startedAt: run2.spawnedAt
15118
+ startedAt: run2.spawnedAt,
15119
+ deadline: run2.deadline ?? null
14335
15120
  };
14336
15121
  }
14337
15122
 
@@ -14375,7 +15160,7 @@ function formatProjectsReport(report) {
14375
15160
  }
14376
15161
 
14377
15162
  // src/forge-cmd.ts
14378
- import { parseDocument } from "yaml";
15163
+ import { parseDocument as parseDocument4 } from "yaml";
14379
15164
  function runCredential(action, stdin, forges, stateDir, env = process.env) {
14380
15165
  try {
14381
15166
  return { code: 0, out: answerCredential(action ?? "", parseCredentialInput(stdin), forges, stateDir, env), err: "" };
@@ -14416,7 +15201,7 @@ function runForge(a, io) {
14416
15201
  if (!existing) {
14417
15202
  const tok = sourceFromArgs(a);
14418
15203
  if (typeof tok === "string") return fail(tok);
14419
- const doc = parseDocument(io.readFile(io.configFile));
15204
+ const doc = parseDocument4(io.readFile(io.configFile));
14420
15205
  doc.setIn(["ship", "forges", host, "token"], tok);
14421
15206
  io.writeFile(io.configFile, doc.toString());
14422
15207
  io.forges = { ...io.forges ?? {}, [host]: { username: "x-access-token", token: tok } };
@@ -14609,7 +15394,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
14609
15394
  }
14610
15395
 
14611
15396
  // src/watch.ts
14612
- import { watch as watchFile, existsSync as existsSync28, statSync as statSync9, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
15397
+ import { watch as watchFile, existsSync as existsSync29, statSync as statSync9, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
14613
15398
  var E = "\x1B[";
14614
15399
  var dim = (s) => `${E}2m${s}${E}0m`;
14615
15400
  var bold = (s) => `${E}1m${s}${E}0m`;
@@ -14710,7 +15495,7 @@ function renderHeaders(states, now = Date.now()) {
14710
15495
  return states.map((s) => renderHeader(s, now)).join("");
14711
15496
  }
14712
15497
  function readFrom(file, from) {
14713
- if (!existsSync28(file)) return { events: [], offset: from };
15498
+ if (!existsSync29(file)) return { events: [], offset: from };
14714
15499
  const size = statSync9(file).size;
14715
15500
  if (size < from) return readFrom(file, 0);
14716
15501
  if (size === from) return { events: [], offset: from };
@@ -14953,17 +15738,17 @@ function nextRoles(decision, ran) {
14953
15738
  }
14954
15739
 
14955
15740
  // src/ship-name.ts
14956
- import { existsSync as existsSync30, mkdirSync as mkdirSync19, readFileSync as readFileSync26, renameSync as renameSync3, writeFileSync as writeFileSync21 } from "fs";
14957
- import { dirname as dirname15 } from "path";
14958
- import { parseDocument as parseDocument2 } from "yaml";
15741
+ import { existsSync as existsSync31, mkdirSync as mkdirSync19, readFileSync as readFileSync27, renameSync as renameSync3, writeFileSync as writeFileSync22 } from "fs";
15742
+ import { dirname as dirname16 } from "path";
15743
+ import { parseDocument as parseDocument5 } from "yaml";
14959
15744
 
14960
15745
  // src/machine-id.ts
14961
15746
  import { execFileSync as execFileSync5 } from "child_process";
14962
15747
  import { createHash as createHash5 } from "crypto";
14963
- import { existsSync as existsSync29, mkdirSync as mkdirSync18, readFileSync as readFileSync25, writeFileSync as writeFileSync20 } from "fs";
14964
- import { dirname as dirname14, join as join32 } from "path";
15748
+ import { existsSync as existsSync30, mkdirSync as mkdirSync18, readFileSync as readFileSync26, writeFileSync as writeFileSync21 } from "fs";
15749
+ import { dirname as dirname15, join as join33 } from "path";
14965
15750
  function machineIdPathFor(stateDir) {
14966
- return join32(stateDir, "machine-id");
15751
+ return join33(stateDir, "machine-id");
14967
15752
  }
14968
15753
  var sha256 = (s) => createHash5("sha256").update(s).digest("hex");
14969
15754
  function readRawMachineId(platform2 = process.platform) {
@@ -14976,8 +15761,8 @@ function readRawMachineId(platform2 = process.platform) {
14976
15761
  return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1];
14977
15762
  }
14978
15763
  for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
14979
- if (existsSync29(p)) {
14980
- const id = readFileSync25(p, "utf8").trim();
15764
+ if (existsSync30(p)) {
15765
+ const id = readFileSync26(p, "utf8").trim();
14981
15766
  if (id) return id;
14982
15767
  }
14983
15768
  }
@@ -14988,15 +15773,15 @@ function readRawMachineId(platform2 = process.platform) {
14988
15773
  function ensureMachineId(stateDir, opts = {}) {
14989
15774
  if (opts.override?.trim()) return sha256(opts.override.trim());
14990
15775
  const path = machineIdPathFor(stateDir);
14991
- if (existsSync29(path)) {
14992
- const cached = readFileSync25(path, "utf8").trim();
15776
+ if (existsSync30(path)) {
15777
+ const cached = readFileSync26(path, "utf8").trim();
14993
15778
  if (/^[0-9a-f]{64}$/.test(cached)) return cached;
14994
15779
  }
14995
15780
  const raw = (opts.read ?? readRawMachineId)();
14996
15781
  if (!raw) return void 0;
14997
15782
  const hashed = sha256(raw);
14998
- mkdirSync18(dirname14(path), { recursive: true });
14999
- writeFileSync20(path, `${hashed}
15783
+ mkdirSync18(dirname15(path), { recursive: true });
15784
+ writeFileSync21(path, `${hashed}
15000
15785
  `, { mode: 384 });
15001
15786
  return hashed;
15002
15787
  }
@@ -15025,11 +15810,11 @@ function planNameSync(local, row, lastSynced) {
15025
15810
  return { drift: "local", action: "push" };
15026
15811
  }
15027
15812
  function shipNameOfYaml(text) {
15028
- const v = parseDocument2(text).getIn(["ship", "name"]);
15813
+ const v = parseDocument5(text).getIn(["ship", "name"]);
15029
15814
  return typeof v === "string" && v.trim() ? v.trim() : void 0;
15030
15815
  }
15031
15816
  function setShipNameInYaml(text, name) {
15032
- const doc = parseDocument2(text);
15817
+ const doc = parseDocument5(text);
15033
15818
  doc.setIn(["ship", "name"], name);
15034
15819
  return doc.toString();
15035
15820
  }
@@ -15067,34 +15852,34 @@ async function syncRouteName(i) {
15067
15852
  return { status: "ok", ...base, action, name: i.local };
15068
15853
  }
15069
15854
  function readSyncedName(resolvedFile) {
15070
- if (!existsSync30(resolvedFile)) return void 0;
15855
+ if (!existsSync31(resolvedFile)) return void 0;
15071
15856
  try {
15072
- const v = JSON.parse(readFileSync26(resolvedFile, "utf8")).shipName;
15857
+ const v = JSON.parse(readFileSync27(resolvedFile, "utf8")).shipName;
15073
15858
  return typeof v === "string" ? v : void 0;
15074
15859
  } catch {
15075
15860
  return void 0;
15076
15861
  }
15077
15862
  }
15078
15863
  function writeSyncedName(resolvedFile, name) {
15079
- if (!existsSync30(resolvedFile)) return;
15864
+ if (!existsSync31(resolvedFile)) return;
15080
15865
  let raw;
15081
15866
  try {
15082
- raw = JSON.parse(readFileSync26(resolvedFile, "utf8"));
15867
+ raw = JSON.parse(readFileSync27(resolvedFile, "utf8"));
15083
15868
  } catch {
15084
15869
  return;
15085
15870
  }
15086
15871
  if (raw.shipName === name) return;
15087
15872
  raw.shipName = name;
15088
- mkdirSync19(dirname15(resolvedFile), { recursive: true });
15873
+ mkdirSync19(dirname16(resolvedFile), { recursive: true });
15089
15874
  const tmp = `${resolvedFile}.tmp${process.pid}`;
15090
- writeFileSync21(tmp, `${JSON.stringify(raw, null, 2)}
15875
+ writeFileSync22(tmp, `${JSON.stringify(raw, null, 2)}
15091
15876
  `);
15092
15877
  renameSync3(tmp, resolvedFile);
15093
15878
  }
15094
15879
  async function reconcileShipName(i) {
15095
15880
  let local = i.ship.name;
15096
15881
  try {
15097
- local = shipNameOfYaml(readFileSync26(i.configFile, "utf8")) ?? local;
15882
+ local = shipNameOfYaml(readFileSync27(i.configFile, "utf8")) ?? local;
15098
15883
  } catch {
15099
15884
  }
15100
15885
  const resolvedFile = resolvedPathFor(i.stateDir, i.route);
@@ -15118,8 +15903,8 @@ async function reconcileShipName(i) {
15118
15903
  }
15119
15904
  if (result.status !== "ok") return result;
15120
15905
  if (result.action === "adopt" || result.action === "row-wins") {
15121
- const text = readFileSync26(i.configFile, "utf8");
15122
- writeFileSync21(i.configFile, setShipNameInYaml(text, result.name));
15906
+ const text = readFileSync27(i.configFile, "utf8");
15907
+ writeFileSync22(i.configFile, setShipNameInYaml(text, result.name));
15123
15908
  i.ship.name = result.name;
15124
15909
  if (result.action === "row-wins") {
15125
15910
  i.emit?.warn(
@@ -15505,7 +16290,10 @@ async function discover(o) {
15505
16290
  ...hostPeriscopes !== void 0 ? { host_periscopes: hostPeriscopes } : {},
15506
16291
  ...sshPublicKey !== void 0 ? { ssh_public_key: sshPublicKey } : {},
15507
16292
  ...ownerId !== void 0 ? { owner_id: ownerId } : {},
15508
- ...writeMachineId ? { machine_id: machineId } : {}
16293
+ ...writeMachineId ? { machine_id: machineId } : {},
16294
+ // ISSUE-2791: a ship connecting by hand is a developer machine; the
16295
+ // column arrives with a template update, so only written when present.
16296
+ ...shipColumns.has("kind") ? { kind: "developer_machine" } : {}
15509
16297
  });
15510
16298
  shipRowId = created.id;
15511
16299
  out.shipRow = { id: shipRowId, name: o.ship.name, action: "created", matchedBy };
@@ -15640,659 +16428,372 @@ async function discover(o) {
15640
16428
  const mine = people.find(isMine);
15641
16429
  const byEmail = out.meEmail ? candidates2.filter((r) => emailOf(r).some((e) => eq(e, out.meEmail))) : [];
15642
16430
  const unlinked = people.filter((r) => !r.user_id);
15643
- const only = candidates2.length === 1 && unlinked.length === 1 ? candidates2[0] : void 0;
15644
- const pick = mine ?? (byEmail.length === 1 ? byEmail[0] : only);
15645
- if (pick) {
15646
- out.operator = pick.id;
15647
- out.operatorAction = isMine(pick) ? "matched" : "linked";
15648
- if (!isMine(pick) && o.ship) await linkOperatorRow(o, out, pick);
15649
- } else {
15650
- out.operatorCompared = `signed in as ${out.meEmail ?? "unknown email"} (identity ${out.meIdentityId ?? out.meId}); ` + (people.length === 0 ? "no person rows in the Crew table" : people.map((r) => {
15651
- const m = r.user_id ? members.get(r.user_id) : void 0;
15652
- return `"${r.name || "unnamed row"}" <${r.email ?? "no email"}>` + (r.user_id ? ` linked to member ${m?.email ?? r.user_id}${m?.identityId ? ` (identity ${m.identityId})` : ""}` : " unlinked");
15653
- }).join("; "));
15654
- if (o.ship && (!o.interactive || candidates2.length === 0)) await createOperatorRow(o, out);
15655
- }
15656
- }
15657
- }
15658
- return out;
15659
- }
15660
- async function linkOperatorRow(o, found, row) {
15661
- const name = row.name || row.id;
15662
- if (!found.meId || !found.crewModelId) return;
15663
- if (o.dryRun) {
15664
- found.provisioning.push(`would link Crew row "${name}" to your account`);
15665
- return;
15666
- }
15667
- const body = { user_id: found.meId };
15668
- if (!row.email && found.meEmail) body.email = found.meEmail;
15669
- try {
15670
- await patch(o, `/data-models/${found.crewModelId}/records/${row.id}`, body);
15671
- found.provisioning.push(`linked Crew row "${name}" to your account`);
15672
- } catch (e) {
15673
- if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
15674
- found.provisioning.push(
15675
- `could not link your Crew row (no write access) \u2014 ask a workspace admin to set user_id on "${name}"`
15676
- );
15677
- }
15678
- }
15679
- async function createOperatorRow(o, found) {
15680
- if (!found.meId || !found.crewModelId) return;
15681
- const name = found.meName || found.meEmail || "Operator";
15682
- if (o.dryRun) {
15683
- found.provisioning.push(`would create Crew row "${name}" for you`);
15684
- found.operatorAction = "would-create";
15685
- return;
15686
- }
15687
- try {
15688
- const created = await post2(o, `/data-models/${found.crewModelId}/records`, {
15689
- name,
15690
- ...found.meEmail ? { email: found.meEmail } : {},
15691
- user_id: found.meId
15692
- });
15693
- found.operator = created.id;
15694
- found.operatorAction = "created";
15695
- found.holds.push({ id: created.id, name, email: found.meEmail, user_id: found.meId });
15696
- found.candidates.push({ id: created.id, name, email: found.meEmail, user_id: found.meId, detail: found.meEmail });
15697
- found.provisioning.push(`created Crew row "${name}" for you`);
15698
- } catch (e) {
15699
- if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
15700
- found.provisioning.push("could not create your Crew row (no write access) \u2014 ask a workspace admin to add one with your user_id");
15701
- }
15702
- }
15703
- function renderConnection(d, route2, dir, opts = {}) {
15704
- const apiKeyFileLine = opts.keychainBacked ? "" : opts.apiKeyFile ? ` apiKeyFile: "${opts.apiKeyFile}"` : ` apiKeyFile: "REPLACE \u2014 a file holding this workspace's key"`;
15705
- const apiKeyVarLine = opts.keychainBacked ? "" : opts.apiKeyVar ? `
15706
- apiKeyVar: ${opts.apiKeyVar}` : `
15707
- apiKeyVar: REPLACE_KEY_VAR`;
15708
- const baseUrlLine = opts.baseUrl ? opts.baseUrl.replace(/\/+$/, "") === DEFAULT_BASE_URL ? "" : ` baseUrl: "${opts.baseUrl}"
15709
- ` : ' baseUrl: "REPLACE \u2014 the same baseUrl as your other routes"\n';
15710
- const keyLines = [apiKeyFileLine, apiKeyVarLine.replace(/^\n/, "")].filter(Boolean).join("\n");
15711
- return ` - route: ${route2}
15712
- enabled: false # set to true once \`crew doctor\` is green
15713
- ${opts.area ? ` area: "${opts.area}"
15714
- ` : ""}${dir === null ? "" : ` dir: "${dir}"
15715
- `}${baseUrlLine}${opts.hostPassengers ? " hostPassengers: true\n" : ""}${keyLines ? keyLines + "\n" : ""}`;
15716
- }
15717
-
15718
- // src/cli.ts
15719
- import {
15720
- hostFromUrl as hostFromUrl4,
15721
- loginWithDeviceCode,
15722
- DeviceLoginError
15723
- } from "@tablation/client";
15724
-
15725
- // src/capability-check.ts
15726
- var WRITE_LEVELS = /* @__PURE__ */ new Set(["WRITE", "DELETE"]);
15727
- async function canWriteTable(o, modelId) {
15728
- try {
15729
- const { access } = await get(o, `/data-models/${modelId}/access`);
15730
- return access === void 0 ? void 0 : WRITE_LEVELS.has(access);
15731
- } catch {
15732
- return void 0;
15733
- }
15734
- }
15735
- async function projectCapabilities(o, projectId) {
15736
- if (!projectId) return void 0;
15737
- try {
15738
- return await get(o, `/projects/${projectId}/capabilities`);
15739
- } catch {
15740
- return void 0;
15741
- }
15742
- }
15743
- async function checkCapabilities(o, found) {
15744
- const admin = isAdminRole(found.role);
15745
- const table2 = async (key, label, tableName2, modelId, askFor) => {
15746
- if (!modelId) return { key, label, status: "skipped", note: `no ${tableName2} table in this project` };
15747
- const ok = await canWriteTable(o, modelId);
15748
- if (ok === void 0) return { key, label, status: "skipped", note: "could not read access" };
15749
- return ok ? { key, label, status: "ok" } : { key, label, status: "missing", askFor };
15750
- };
15751
- const [crew, ships, repos, caps] = await Promise.all([
15752
- table2("crew-table", "write the Crew table", "Crew", found.crewModelId ?? found.models.crew, "WRITE access on the Crew table"),
15753
- table2("ships-table", "write the Ships table", "Ships", found.shipsModelId, "WRITE access on the Ships table"),
15754
- table2("repos-table", "create Repos rows", "Repos", found.reposModelId, "WRITE access on the Repos table"),
15755
- projectCapabilities(o, found.projectId)
15756
- ]);
15757
- const mint = admin || caps?.MINT_AGENT_KEYS ? { key: "mint-agent-keys", label: "mint agent keys", status: "ok" } : caps ? { key: "mint-agent-keys", label: "mint agent keys", status: "missing", askFor: 'the "Mint agent keys" capability' } : { key: "mint-agent-keys", label: "mint agent keys", status: "skipped", note: "could not read capabilities" };
15758
- const sync = admin ? { key: "sync-personas", label: "sync personas and skills", status: "ok" } : { key: "sync-personas", label: "sync personas and skills", status: "missing", askFor: "the workspace admin role (personas and skills are workspace-level tables)" };
15759
- return [crew, ships, repos, mint, sync];
15760
- }
15761
- function missLine(i) {
15762
- return `${i.label} \u2014 ask a workspace admin for ${i.askFor ?? "access"}`;
15763
- }
15764
- function describeCapability(i) {
15765
- if (i.status === "ok") return `ok ${i.label}`;
15766
- if (i.status === "skipped") return `skipped ${i.label} (${i.note ?? "not checked"})`;
15767
- return `missing ${missLine(i)}`;
15768
- }
15769
-
15770
- // src/agents-sync-run.ts
15771
- import { existsSync as existsSync31, mkdirSync as mkdirSync20, readFileSync as readFileSync27, writeFileSync as writeFileSync22 } from "fs";
15772
- import { dirname as dirname16 } from "path";
15773
- async function runAgentsSync(target, o) {
15774
- const dryRun = o.dryRun ?? false;
15775
- const force = o.force ?? false;
15776
- const keyRole = target.resolved?.role ?? await fetchKeyRole(target, o.userAgent);
15777
- let readOnly = !isAdminRole(keyRole);
15778
- const runPersonas = (ro) => syncPersonas(target, { userAgent: o.userAgent, dryRun, force, readOnly: ro });
15779
- let personaResult;
15780
- try {
15781
- personaResult = await runPersonas(readOnly);
15782
- } catch (e) {
15783
- if (readOnly || !isForbidden(e)) throw e;
15784
- readOnly = true;
15785
- personaResult = await runPersonas(true);
15786
- }
15787
- o.say(`route: ${target.route}`);
15788
- for (const p of personaResult.outcomes) o.say(describeSyncOutcome(p));
15789
- for (const l of personaResult.crewLinks) o.say(describeCrewLink(l));
15790
- if (personaResult.releaseNote) o.say(personaResult.releaseNote);
15791
- const briefs = Object.fromEntries(ROLE_NAMES.map((r) => [r, personaDefaultPrompt(target.promptsDir, r)]));
15792
- for (const w of skillMarkerWarnings(target.promptsDir, briefs)) o.warn(`warning: ${w}`);
15793
- let skillResult;
15794
- if (!readOnly) {
15795
- try {
15796
- skillResult = await syncSkills(target, { userAgent: o.userAgent, dryRun, force });
15797
- } catch (e) {
15798
- if (isForbidden(e)) readOnly = true;
15799
- else throw e;
15800
- }
15801
- }
15802
- for (const s of skillResult?.outcomes ?? []) o.say(describeSkillSyncOutcome(s));
15803
- if (!dryRun) {
15804
- const resolvedPath = resolvedPathFor(o.stateDir, target.route);
15805
- const raw = existsSync31(resolvedPath) ? JSON.parse(readFileSync27(resolvedPath, "utf8")) : {};
15806
- raw.agentPersonas = personaResult.agentPersonas;
15807
- if (skillResult) raw.agentSkills = skillResult.agentSkills;
15808
- if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
15809
- mkdirSync20(dirname16(resolvedPath), { recursive: true });
15810
- writeFileSync22(resolvedPath, `${JSON.stringify(raw, null, 2)}
15811
- `);
15812
- if (target.resolved) {
15813
- target.resolved.agentPersonas = personaResult.agentPersonas;
15814
- if (personaResult.releaseAgentId) target.resolved.releaseAgentId = personaResult.releaseAgentId;
15815
- }
15816
- }
15817
- if (readOnly) {
15818
- o.say("personas and skills: not synced (this key is not a workspace admin; the workspace's rows are used as they are)");
15819
- }
15820
- const diverged = personaResult.outcomes.some((p) => p.action === "diverged") || (skillResult?.outcomes.some((s) => s.action === "diverged") ?? false);
15821
- return { personaResult, skillResult, readOnly, diverged };
15822
- }
15823
-
15824
- // src/logbook.ts
15825
- import { createHash as createHash6 } from "crypto";
15826
- import { TablationClient as TablationClient4 } from "@tablation/client";
15827
- var LogbookError = class extends Error {
15828
- };
15829
- function client(route2, userAgent) {
15830
- return new TablationClient4({
15831
- baseUrl: `${route2.baseUrl}/api`,
15832
- apiKey: resolveApiKey(route2),
15833
- headers: { "User-Agent": userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
15834
- });
15835
- }
15836
- function requireWorkspace(route2) {
15837
- if (!route2.resolved) {
15838
- throw new LogbookError(`route "${route2.route}" has no resolved ids \u2014 run \`crew connect\` first`);
16431
+ const only = candidates2.length === 1 && unlinked.length === 1 ? candidates2[0] : void 0;
16432
+ const pick = mine ?? (byEmail.length === 1 ? byEmail[0] : only);
16433
+ if (pick) {
16434
+ out.operator = pick.id;
16435
+ out.operatorAction = isMine(pick) ? "matched" : "linked";
16436
+ if (!isMine(pick) && o.ship) await linkOperatorRow(o, out, pick);
16437
+ } else {
16438
+ out.operatorCompared = `signed in as ${out.meEmail ?? "unknown email"} (identity ${out.meIdentityId ?? out.meId}); ` + (people.length === 0 ? "no person rows in the Crew table" : people.map((r) => {
16439
+ const m = r.user_id ? members.get(r.user_id) : void 0;
16440
+ return `"${r.name || "unnamed row"}" <${r.email ?? "no email"}>` + (r.user_id ? ` linked to member ${m?.email ?? r.user_id}${m?.identityId ? ` (identity ${m.identityId})` : ""}` : " unlinked");
16441
+ }).join("; "));
16442
+ if (o.ship && (!o.interactive || candidates2.length === 0)) await createOperatorRow(o, out);
16443
+ }
16444
+ }
15839
16445
  }
16446
+ return out;
15840
16447
  }
15841
- async function listLogEntries(route2, opts = {}) {
15842
- requireWorkspace(route2);
15843
- const c = client(route2, opts.userAgent);
15844
- const agentLogModel = await c.dataModels.get("agent_log", route2.resolved.workspaceId);
15845
- const filters = [];
15846
- if (opts.ticket) filters.push({ columnName: "ticket_reference", operator: "EQ", value: opts.ticket });
15847
- if (opts.role) {
15848
- const agentsModel = await c.dataModels.get("agents", route2.resolved.workspaceId);
15849
- const rows = await c.records.list(agentsModel.id, { limit: 200 });
15850
- const match = rows.find((r) => r.name.toLowerCase() === opts.role.toLowerCase());
15851
- if (!match) return [];
15852
- filters.push({ columnName: "agent_id", operator: "EQ", value: match.id });
16448
+ async function linkOperatorRow(o, found, row) {
16449
+ const name = row.name || row.id;
16450
+ if (!found.meId || !found.crewModelId) return;
16451
+ if (o.dryRun) {
16452
+ found.provisioning.push(`would link Crew row "${name}" to your account`);
16453
+ return;
15853
16454
  }
15854
- const entries = await c.records.list(agentLogModel.id, {
15855
- limit: opts.limit ?? 20,
15856
- ...filters.length > 0 ? { filters: JSON.stringify(filters) } : {}
15857
- });
15858
- return [...entries].sort((a, b) => String(b.started_at ?? "").localeCompare(String(a.started_at ?? "")));
15859
- }
15860
- async function showLogEntry(route2, entryId, opts = {}) {
15861
- requireWorkspace(route2);
15862
- const c = client(route2, opts.userAgent);
15863
- const agentLogModel = await c.dataModels.get("agent_log", route2.resolved.workspaceId);
15864
- let entry;
16455
+ const body = { user_id: found.meId };
16456
+ if (!row.email && found.meEmail) body.email = found.meEmail;
15865
16457
  try {
15866
- entry = await c.records.get(agentLogModel.id, entryId);
16458
+ await patch(o, `/data-models/${found.crewModelId}/records/${row.id}`, body);
16459
+ found.provisioning.push(`linked Crew row "${name}" to your account`);
15867
16460
  } catch (e) {
15868
- throw new LogbookError(`no Agent Log entry ${entryId} in this workspace (${e.message})`);
16461
+ if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
16462
+ found.provisioning.push(
16463
+ `could not link your Crew row (no write access) \u2014 ask a workspace admin to set user_id on "${name}"`
16464
+ );
15869
16465
  }
15870
- if (!opts.withPrompt) return { entry, reconstruction: { status: "not_recorded", reason: "not requested" } };
15871
- if (!entry.agent_id || !entry.prompt_version) {
15872
- return {
15873
- entry,
15874
- reconstruction: {
15875
- status: "not_recorded",
15876
- reason: !entry.agent_id ? "this entry has no linked Agents row (predates crew agents sync being run for its role)" : "this entry predates ISSUE-377/529 \u2014 no prompt_version was recorded"
15877
- }
15878
- };
16466
+ }
16467
+ async function createOperatorRow(o, found) {
16468
+ if (!found.meId || !found.crewModelId) return;
16469
+ const name = found.meName || found.meEmail || "Operator";
16470
+ if (o.dryRun) {
16471
+ found.provisioning.push(`would create Crew row "${name}" for you`);
16472
+ found.operatorAction = "would-create";
16473
+ return;
15879
16474
  }
15880
- const agentsModel = await c.dataModels.get("agents", route2.resolved.workspaceId);
15881
- let historyEntries;
15882
16475
  try {
15883
- historyEntries = await fetchHistoryEntries(route2, agentsModel.id, entry.agent_id, opts.userAgent, 200);
15884
- } catch (e) {
15885
- return { entry, reconstruction: { status: "approximate", reason: `history lookup failed: ${e.message}` } };
15886
- }
15887
- const match = historyEntries.find((h) => h.history_id === entry.prompt_version);
15888
- if (!match) {
15889
- return {
15890
- entry,
15891
- reconstruction: {
15892
- status: "approximate",
15893
- reason: `history entry ${entry.prompt_version} no longer exists on the Agents row (purged, or the row itself was recreated)`
15894
- }
15895
- };
15896
- }
15897
- const prompt = appendAdditions(match.prompt ?? "", match.prompt_additions);
15898
- const actualSha = createHash6("sha256").update(prompt).digest("hex");
15899
- const exact = !!entry.prompt_sha && actualSha === entry.prompt_sha;
15900
- return {
15901
- entry,
15902
- reconstruction: {
15903
- status: exact ? "exact" : "approximate",
15904
- historyId: match.history_id,
15905
- changedAt: match.changed_at,
15906
- prompt,
15907
- ...exact ? {} : { reason: entry.prompt_sha ? "stored prompt_sha does not match this history entry's content" : "this entry recorded no prompt_sha to verify against" }
15908
- }
15909
- };
15910
- }
15911
-
15912
- // src/repos-check.ts
15913
- import { spawnSync as spawnSync6 } from "child_process";
15914
- import { readFileSync as readFileSync28 } from "fs";
15915
- import { basename as basename8 } from "path";
15916
- var POSIX_SHELL = /^(?:ba|z|da|k|a|mk)?sh$|^fish$/;
15917
- function checkHookSyntax(script, shell) {
15918
- const spec = resolveShell(shell);
15919
- const name = basename8(spec.bin).replace(/\.exe$/i, "").toLowerCase();
15920
- let run2;
15921
- if (POSIX_SHELL.test(name)) {
15922
- run2 = spawnSync6(spec.bin, ["-n"], { input: script, encoding: "utf8" });
15923
- } else if (/^(pwsh|powershell)$/.test(name)) {
15924
- const parse3 = "$e=$null;[void][System.Management.Automation.Language.Parser]::ParseInput($env:CREW_CHECK_SCRIPT,[ref]$null,[ref]$e);if($e){$e|ForEach-Object{[Console]::Error.WriteLine($_.Message)};exit 1}";
15925
- run2 = spawnSync6(spec.bin, ["-NoProfile", "-NonInteractive", "-Command", parse3], {
15926
- encoding: "utf8",
15927
- env: { ...process.env, CREW_CHECK_SCRIPT: script }
16476
+ const created = await post2(o, `/data-models/${found.crewModelId}/records`, {
16477
+ name,
16478
+ ...found.meEmail ? { email: found.meEmail } : {},
16479
+ user_id: found.meId
15928
16480
  });
15929
- } else {
15930
- return { status: "unchecked", message: `no syntax check for ${spec.bin}` };
16481
+ found.operator = created.id;
16482
+ found.operatorAction = "created";
16483
+ found.holds.push({ id: created.id, name, email: found.meEmail, user_id: found.meId });
16484
+ found.candidates.push({ id: created.id, name, email: found.meEmail, user_id: found.meId, detail: found.meEmail });
16485
+ found.provisioning.push(`created Crew row "${name}" for you`);
16486
+ } catch (e) {
16487
+ if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
16488
+ found.provisioning.push("could not create your Crew row (no write access) \u2014 ask a workspace admin to add one with your user_id");
15931
16489
  }
15932
- if (run2.error) return { status: "unchecked", message: `cannot run ${spec.bin}: ${run2.error.message}` };
15933
- if (run2.status === 0) return { status: "ok" };
15934
- const message = `${run2.stderr ?? ""}${run2.stdout ?? ""}`.trim() || `${spec.bin} exited ${run2.status}`;
15935
- return { status: "error", message };
15936
16490
  }
15937
- function renderSamples(cfg, dir) {
15938
- const checkout = basename8(dir.replace(/[/\\]+$/, ""));
15939
- const tagged = { key: "ISSUE-123", title: "Fix the widget", role: "dev", tag: "TABL-123", prefix: "tabl", lowercase: cfg.branch.lowercase };
15940
- const untagged = { key: "ISSUE-123", title: "Fix the widget", role: "dev", lowercase: cfg.branch.lowercase };
15941
- return [["with a project tag", tagged], ["without a project tag", untagged]].map(([label, ctx]) => {
15942
- const branch = renderBranchName(effectiveBranchTemplate(cfg, ctx.prefix), ctx);
15943
- const worktree = effectiveWorktreeDirName(cfg, dir, branch, "123", (t) => renderWorktreeName(t, { ...ctx, dir: checkout }));
15944
- return { case: label, branch, worktree };
15945
- });
16491
+ function renderConnection(d, route2, dir, opts = {}) {
16492
+ const apiKeyFileLine = opts.keychainBacked ? "" : opts.apiKeyFile ? ` apiKeyFile: "${opts.apiKeyFile}"` : ` apiKeyFile: "REPLACE \u2014 a file holding this workspace's key"`;
16493
+ const apiKeyVarLine = opts.keychainBacked ? "" : opts.apiKeyVar ? `
16494
+ apiKeyVar: ${opts.apiKeyVar}` : `
16495
+ apiKeyVar: REPLACE_KEY_VAR`;
16496
+ const baseUrlLine = opts.baseUrl ? opts.baseUrl.replace(/\/+$/, "") === DEFAULT_BASE_URL ? "" : ` baseUrl: "${opts.baseUrl}"
16497
+ ` : ' baseUrl: "REPLACE \u2014 the same baseUrl as your other routes"\n';
16498
+ const keyLines = [apiKeyFileLine, apiKeyVarLine.replace(/^\n/, "")].filter(Boolean).join("\n");
16499
+ return ` - route: ${route2}
16500
+ enabled: false # set to true once \`crew doctor\` is green
16501
+ ${opts.area ? ` area: "${opts.area}"
16502
+ ` : ""}${dir === null ? "" : ` dir: "${dir}"
16503
+ `}${baseUrlLine}${opts.hostPassengers ? " hostPassengers: true\n" : ""}${keyLines ? keyLines + "\n" : ""}`;
15946
16504
  }
15947
- function checkRepoSpec(dir, opts = {}) {
15948
- const empty = { parsed: null, effective: null, readiness: [], hooks: [], shadowed: [], samples: [] };
15949
- const file = findRepoConfig(dir);
15950
- if (!file) {
15951
- return { ...empty, file: null, error: `no .crew.yaml, .crew.yml or .crew.json in ${dir}`, exitCode: 1 };
15952
- }
15953
- let parsed;
16505
+
16506
+ // src/cli.ts
16507
+ import {
16508
+ hostFromUrl as hostFromUrl4,
16509
+ loginWithDeviceCode,
16510
+ DeviceLoginError
16511
+ } from "@tablation/client";
16512
+
16513
+ // src/capability-check.ts
16514
+ var WRITE_LEVELS = /* @__PURE__ */ new Set(["WRITE", "DELETE"]);
16515
+ async function canWriteTable(o, modelId) {
15954
16516
  try {
15955
- parsed = parseRepoConfig(readFileSync28(file, "utf8"), file);
15956
- } catch (e) {
15957
- if (!(e instanceof RepoConfigError)) throw e;
15958
- return { ...empty, file, error: e.message, exitCode: 1 };
16517
+ const { access } = await get(o, `/data-models/${modelId}/access`);
16518
+ return access === void 0 ? void 0 : WRITE_LEVELS.has(access);
16519
+ } catch {
16520
+ return void 0;
15959
16521
  }
15960
- const effective = resolveRepoConfig(parsed, opts.ship, dir);
15961
- const readiness = validateEffective(effective);
15962
- const hooks = Object.keys(effective.hooks).map((name) => ({
15963
- name,
15964
- label: hookLabel(effective, name),
15965
- syntax: checkHookSyntax(effective.hooks[name], effective.shell)
15966
- }));
15967
- const failed = hooks.some((h) => h.syntax.status === "error");
15968
- return {
15969
- file,
15970
- parsed,
15971
- effective,
15972
- readiness,
15973
- hooks,
15974
- shadowed: effective.shadowed,
15975
- samples: renderSamples(effective, dir),
15976
- error: null,
15977
- exitCode: failed || opts.strict && readiness.length > 0 ? 1 : 0
15978
- };
15979
16522
  }
15980
- function renderRepoCheck(r) {
15981
- if (r.error) return `${r.file ?? "crew repos check"}: ${r.error}
15982
- `;
15983
- const e = r.effective;
15984
- const lines = [
15985
- `${r.file}`,
15986
- ` platform ${e.platform}`,
15987
- ` release.mode ${e.release.mode}`,
15988
- ` release.versioning ${e.release.versioning}`,
15989
- ` branch.base ${e.branch.base}`,
15990
- ` branch.name ${e.branch.name}`,
15991
- ` branch.push ${e.branch.push}`
15992
- ];
15993
- for (const s of r.samples) lines.push(` ${s.case}: branch ${s.branch}, worktree ../${s.worktree}`);
15994
- lines.push(r.hooks.length ? " hooks" : " hooks none");
15995
- for (const h of r.hooks) {
15996
- const named = h.label !== e.hooks[h.name] ? ` (${h.label})` : "";
15997
- const syntax = h.syntax.status === "ok" ? "syntax ok" : h.syntax.status === "unchecked" ? `syntax not checked: ${h.syntax.message}` : "syntax ERROR";
15998
- lines.push(` ${h.name}${named}: ${syntax}`);
15999
- if (h.syntax.status === "error") lines.push(...(h.syntax.message ?? "").split("\n").map((l) => ` ${l}`));
16523
+ async function projectCapabilities(o, projectId) {
16524
+ if (!projectId) return void 0;
16525
+ try {
16526
+ return await get(o, `/projects/${projectId}/capabilities`);
16527
+ } catch {
16528
+ return void 0;
16000
16529
  }
16001
- if (r.shadowed.length) lines.push(` this ship declares ${r.shadowed.join(", ")}, and the file overrides it \u2014 the file wins`);
16002
- for (const p of r.readiness) lines.push(` - release readiness: ${p}`);
16003
- return `${lines.join("\n")}
16004
- `;
16005
16530
  }
16006
-
16007
- // src/repos-hooks.ts
16008
- import { isMap as isMap2, parseDocument as parseDocument4, YAMLMap } from "yaml";
16009
-
16010
- // src/repos-cmd.ts
16011
- import { homedir as homedir9 } from "os";
16012
- import { basename as basename9, join as join33, resolve as resolve3 } from "path";
16013
- import { isMap, isSeq, parseDocument as parseDocument3, stringify as stringify2 } from "yaml";
16014
- var ReposError = class extends Error {
16015
- };
16016
- function normalizeRemote(url) {
16017
- if (!url) return void 0;
16018
- let s = url.trim();
16019
- if (!s) return void 0;
16020
- const scp = /^[^\s@/]+@[^\s:/]+:(.+)$/.exec(s);
16021
- if (scp) s = scp[1];
16022
- else s = s.replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^/@]+@)?[^/]+\//i, "");
16023
- s = s.replace(/\/+$/, "").replace(/\.git$/i, "");
16024
- const parts = s.split("/").filter(Boolean);
16025
- if (parts.length < 2) return void 0;
16026
- return parts.slice(-2).join("/").toLowerCase();
16531
+ async function checkCapabilities(o, found) {
16532
+ const admin = isAdminRole(found.role);
16533
+ const table2 = async (key, label, tableName2, modelId, askFor) => {
16534
+ if (!modelId) return { key, label, status: "skipped", note: `no ${tableName2} table in this project` };
16535
+ const ok = await canWriteTable(o, modelId);
16536
+ if (ok === void 0) return { key, label, status: "skipped", note: "could not read access" };
16537
+ return ok ? { key, label, status: "ok" } : { key, label, status: "missing", askFor };
16538
+ };
16539
+ const [crew, ships, repos, caps] = await Promise.all([
16540
+ table2("crew-table", "write the Crew table", "Crew", found.crewModelId ?? found.models.crew, "WRITE access on the Crew table"),
16541
+ table2("ships-table", "write the Ships table", "Ships", found.shipsModelId, "WRITE access on the Ships table"),
16542
+ table2("repos-table", "create Repos rows", "Repos", found.reposModelId, "WRITE access on the Repos table"),
16543
+ projectCapabilities(o, found.projectId)
16544
+ ]);
16545
+ const mint = admin || caps?.MINT_AGENT_KEYS ? { key: "mint-agent-keys", label: "mint agent keys", status: "ok" } : caps ? { key: "mint-agent-keys", label: "mint agent keys", status: "missing", askFor: 'the "Mint agent keys" capability' } : { key: "mint-agent-keys", label: "mint agent keys", status: "skipped", note: "could not read capabilities" };
16546
+ const sync = admin ? { key: "sync-personas", label: "sync personas and skills", status: "ok" } : { key: "sync-personas", label: "sync personas and skills", status: "missing", askFor: "the workspace admin role (personas and skills are workspace-level tables)" };
16547
+ return [crew, ships, repos, mint, sync];
16027
16548
  }
16028
- function matchRepoName(origin, resolved) {
16029
- const want = normalizeRemote(origin);
16030
- if (!want || !resolved?.repoRemotes) return void 0;
16031
- for (const [id, remote] of Object.entries(resolved.repoRemotes)) {
16032
- if (normalizeRemote(remote) === want) return resolved.repoNames?.[id];
16033
- }
16034
- return void 0;
16549
+ function missLine(i) {
16550
+ return `${i.label} \u2014 ask a workspace admin for ${i.askFor ?? "access"}`;
16035
16551
  }
16036
- function routeNamesOf(text) {
16037
- const routes = parseDocument3(text).get("routes", true);
16038
- if (!isSeq(routes)) return [];
16039
- return routes.items.flatMap((i) => isMap(i) && typeof i.get("route") === "string" ? [i.get("route")] : []);
16552
+ function describeCapability(i) {
16553
+ if (i.status === "ok") return `ok ${i.label}`;
16554
+ if (i.status === "skipped") return `skipped ${i.label} (${i.note ?? "not checked"})`;
16555
+ return `missing ${missLine(i)}`;
16040
16556
  }
16041
- function parseAddArgs(args, routes, isDir) {
16042
- const rest = [...args];
16043
- let route2;
16044
- if (rest.length > 0 && routes.includes(rest[0])) route2 = rest.shift();
16045
- else if (rest.length > 0 && /^[^/\s]+\/[^/\s]+$/.test(rest[0]) && !isDir(rest[0])) {
16046
- throw new ReposError(`no route "${rest[0]}" in the config (have: ${routes.join(", ") || "none"})`);
16557
+
16558
+ // src/agents-sync-run.ts
16559
+ import { existsSync as existsSync32, mkdirSync as mkdirSync20, readFileSync as readFileSync28, writeFileSync as writeFileSync23 } from "fs";
16560
+ import { dirname as dirname17 } from "path";
16561
+ async function runAgentsSync(target, o) {
16562
+ const dryRun = o.dryRun ?? false;
16563
+ const force = o.force ?? false;
16564
+ const keyRole = target.resolved?.role ?? await fetchKeyRole(target, o.userAgent);
16565
+ let readOnly = !isAdminRole(keyRole);
16566
+ const runPersonas = (ro) => syncPersonas(target, { userAgent: o.userAgent, dryRun, force, readOnly: ro });
16567
+ let personaResult;
16568
+ try {
16569
+ personaResult = await runPersonas(readOnly);
16570
+ } catch (e) {
16571
+ if (readOnly || !isForbidden(e)) throw e;
16572
+ readOnly = true;
16573
+ personaResult = await runPersonas(true);
16047
16574
  }
16048
- if (!route2) {
16049
- if (routes.length === 1) route2 = routes[0];
16050
- else throw new ReposError(routes.length === 0 ? "the config has no routes \u2014 run `crew connect` first" : `several routes are configured; name one: ${routes.join(", ")}`);
16575
+ o.say(`route: ${target.route}`);
16576
+ for (const p of personaResult.outcomes) o.say(describeSyncOutcome(p));
16577
+ for (const l of personaResult.crewLinks) o.say(describeCrewLink(l));
16578
+ if (personaResult.releaseNote) o.say(personaResult.releaseNote);
16579
+ const briefs = Object.fromEntries(ROLE_NAMES.map((r) => [r, personaDefaultPrompt(target.promptsDir, r)]));
16580
+ for (const w of skillMarkerWarnings(target.promptsDir, briefs)) o.warn(`warning: ${w}`);
16581
+ let skillResult;
16582
+ if (!readOnly) {
16583
+ try {
16584
+ skillResult = await syncSkills(target, { userAgent: o.userAgent, dryRun, force });
16585
+ } catch (e) {
16586
+ if (isForbidden(e)) readOnly = true;
16587
+ else throw e;
16588
+ }
16051
16589
  }
16052
- if (rest.length > 2) throw new ReposError("too many arguments: expected [route] [name] [path]");
16053
- if (rest.length === 2) return { route: route2, name: rest[0], path: rest[1] };
16054
- if (rest.length === 1) return isDir(rest[0]) ? { route: route2, path: rest[0] } : { route: route2, name: rest[0], path: "." };
16055
- return { route: route2, path: "." };
16056
- }
16057
- function expandDir(p, base) {
16058
- const home = p === "~" ? homedir9() : p.startsWith("~/") ? `${homedir9()}/${p.slice(2)}` : p;
16059
- return resolve3(base, home);
16060
- }
16061
- function routeMapOf(doc, route2) {
16062
- const routes = doc.get("routes", true);
16063
- if (isSeq(routes)) {
16064
- for (const item of routes.items) {
16065
- if (isMap(item) && item.get("route") === route2) return item;
16590
+ for (const s of skillResult?.outcomes ?? []) o.say(describeSkillSyncOutcome(s));
16591
+ if (!dryRun) {
16592
+ const resolvedPath = resolvedPathFor(o.stateDir, target.route);
16593
+ const raw = existsSync32(resolvedPath) ? JSON.parse(readFileSync28(resolvedPath, "utf8")) : {};
16594
+ raw.agentPersonas = personaResult.agentPersonas;
16595
+ if (skillResult) raw.agentSkills = skillResult.agentSkills;
16596
+ if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
16597
+ mkdirSync20(dirname17(resolvedPath), { recursive: true });
16598
+ writeFileSync23(resolvedPath, `${JSON.stringify(raw, null, 2)}
16599
+ `);
16600
+ if (target.resolved) {
16601
+ target.resolved.agentPersonas = personaResult.agentPersonas;
16602
+ if (personaResult.releaseAgentId) target.resolved.releaseAgentId = personaResult.releaseAgentId;
16066
16603
  }
16067
16604
  }
16068
- const have = isSeq(routes) ? routes.items.map((i) => isMap(i) ? String(i.get("route")) : "?").join(", ") : "none";
16069
- throw new ReposError(`no route "${route2}" in the config (have: ${have})`);
16070
- }
16071
- function configuredRepos(text, route2, base) {
16072
- const doc = parseDocument3(text);
16073
- const m = routeMapOf(doc, route2);
16074
- const repos = m.get("repos");
16075
- if (isMap(repos)) {
16076
- return repos.items.map((p) => {
16077
- const name = String(p.key.value ?? p.key);
16078
- const v = p.value;
16079
- const dir2 = isMap(v) ? String(v.get("dir") ?? "") : String(v?.value ?? "");
16080
- return { name, dir: expandDir(dir2, base) };
16081
- });
16605
+ if (readOnly) {
16606
+ o.say("personas and skills: not synced (this key is not a workspace admin; the workspace's rows are used as they are)");
16082
16607
  }
16083
- const dir = m.get("dir");
16084
- return dir ? [{ name: basename9(String(dir)), dir: expandDir(String(dir), base) }] : [];
16608
+ const diverged = personaResult.outcomes.some((p) => p.action === "diverged") || (skillResult?.outcomes.some((s) => s.action === "diverged") ?? false);
16609
+ return { personaResult, skillResult, readOnly, diverged };
16085
16610
  }
16086
- function cloneBaseOf(text, route2, base, isCheckout) {
16087
- const m = routeMapOf(parseDocument3(text), route2);
16088
- const repos = m.get("repos", true);
16089
- if (isMap(repos) && repos.items.length > 0) return null;
16090
- const dir = m.get("dir");
16091
- if (!dir) return null;
16092
- const abs = expandDir(String(dir), base);
16093
- return isCheckout(abs) ? null : abs;
16611
+
16612
+ // src/logbook.ts
16613
+ import { createHash as createHash6 } from "crypto";
16614
+ import { TablationClient as TablationClient4 } from "@tablation/client";
16615
+ var LogbookError = class extends Error {
16616
+ };
16617
+ function client(route2, userAgent) {
16618
+ return new TablationClient4({
16619
+ baseUrl: `${route2.baseUrl}/api`,
16620
+ apiKey: resolveApiKey(route2),
16621
+ headers: { "User-Agent": userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
16622
+ });
16094
16623
  }
16095
- function routeSetsHooks(text, route2, name) {
16096
- const m = routeMapOf(parseDocument3(text), route2);
16097
- const repos = m.get("repos", true);
16098
- let target = m;
16099
- if (isMap(repos)) target = repos.get(name, true);
16100
- if (!isMap(target)) return false;
16101
- const hooks = target.get("hooks", true);
16102
- return isMap(hooks) && hooks.items.length > 0;
16624
+ function requireWorkspace(route2) {
16625
+ if (!route2.resolved) {
16626
+ throw new LogbookError(`route "${route2.route}" has no resolved ids \u2014 run \`crew connect\` first`);
16627
+ }
16103
16628
  }
16104
- function listRepoRows(i) {
16105
- const cloneBase = i.isCheckout ? cloneBaseOf(i.text, i.route, i.base, i.isCheckout) : null;
16106
- const checkouts = configuredRepos(i.text, i.route, i.base).filter((c) => c.dir !== cloneBase);
16107
- const claimed = /* @__PURE__ */ new Set();
16108
- const hooksSourceOf = (c) => i.exists(c.dir) && i.repoHasHooks(c.dir) ? "repo" : routeSetsHooks(i.text, i.route, c.name) ? "route" : "none";
16109
- const originFor = (c, remote) => {
16110
- const o = i.originOf(c.dir) ?? null;
16111
- if (!o) return null;
16112
- return remote !== null && normalizeRemote(o) === normalizeRemote(remote) ? null : o;
16113
- };
16114
- const rows = [];
16115
- const names = i.resolved?.repoNames ?? {};
16116
- const remotes = i.resolved?.repoRemotes ?? {};
16117
- for (const [id, name] of Object.entries(names)) {
16118
- const remote = remotes[id] ?? null;
16119
- const want = normalizeRemote(remote);
16120
- let idx = checkouts.findIndex((c2, n) => !claimed.has(n) && want !== void 0 && normalizeRemote(i.originOf(c2.dir)) === want);
16121
- if (idx < 0) idx = checkouts.findIndex((c2, n) => !claimed.has(n) && c2.name === name);
16122
- const c = idx >= 0 ? checkouts[idx] : void 0;
16123
- if (idx >= 0) claimed.add(idx);
16124
- rows.push({
16125
- name,
16126
- remote,
16127
- repoRowId: id,
16128
- attached: c !== void 0,
16129
- dir: c?.dir ?? null,
16130
- exists: c ? i.exists(c.dir) : false,
16131
- origin: c ? originFor(c, remote) : null,
16132
- matchedRow: c ? name : null,
16133
- hooksSource: c ? hooksSourceOf(c) : "none"
16134
- });
16629
+ async function listLogEntries(route2, opts = {}) {
16630
+ requireWorkspace(route2);
16631
+ const c = client(route2, opts.userAgent);
16632
+ const agentLogModel = await c.dataModels.get("agent_log", route2.resolved.workspaceId);
16633
+ const filters = [];
16634
+ if (opts.ticket) filters.push({ columnName: "ticket_reference", operator: "EQ", value: opts.ticket });
16635
+ if (opts.role) {
16636
+ const agentsModel = await c.dataModels.get("agents", route2.resolved.workspaceId);
16637
+ const rows = await c.records.list(agentsModel.id, { limit: 200 });
16638
+ const match = rows.find((r) => r.name.toLowerCase() === opts.role.toLowerCase());
16639
+ if (!match) return [];
16640
+ filters.push({ columnName: "agent_id", operator: "EQ", value: match.id });
16135
16641
  }
16136
- checkouts.forEach((c, n) => {
16137
- if (claimed.has(n)) return;
16138
- rows.push({
16139
- name: c.name,
16140
- remote: null,
16141
- repoRowId: null,
16142
- attached: true,
16143
- dir: c.dir,
16144
- exists: i.exists(c.dir),
16145
- origin: originFor(c, null),
16146
- matchedRow: null,
16147
- hooksSource: hooksSourceOf(c)
16148
- });
16642
+ const entries = await c.records.list(agentLogModel.id, {
16643
+ limit: opts.limit ?? 20,
16644
+ ...filters.length > 0 ? { filters: JSON.stringify(filters) } : {}
16149
16645
  });
16150
- return rows;
16646
+ return [...entries].sort((a, b) => String(b.started_at ?? "").localeCompare(String(a.started_at ?? "")));
16151
16647
  }
16152
- function planRepoAdd(i) {
16153
- const doc = parseDocument3(i.text);
16154
- if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
16155
- const m = routeMapOf(doc, i.route);
16156
- let existing = configuredRepos(i.text, i.route, i.base);
16157
- const target = expandDir(i.path, i.base);
16158
- let droppedDir;
16159
- const placeholder = m.get("dir");
16160
- const reposNow = m.get("repos", true);
16161
- if (i.isCheckout && placeholder && !(isMap(reposNow) && reposNow.items.length > 0)) {
16162
- const placeholderDir = expandDir(String(placeholder), i.base);
16163
- if (placeholderDir !== target && !i.isCheckout(placeholderDir)) {
16164
- droppedDir = placeholderDir;
16165
- m.delete("dir");
16166
- existing = [];
16167
- }
16168
- }
16169
- if (existing.some((e) => e.dir === target)) {
16170
- throw new ReposError(`${target} is already configured on route ${i.route}`);
16648
+ async function showLogEntry(route2, entryId, opts = {}) {
16649
+ requireWorkspace(route2);
16650
+ const c = client(route2, opts.userAgent);
16651
+ const agentLogModel = await c.dataModels.get("agent_log", route2.resolved.workspaceId);
16652
+ let entry;
16653
+ try {
16654
+ entry = await c.records.get(agentLogModel.id, entryId);
16655
+ } catch (e) {
16656
+ throw new LogbookError(`no Agent Log entry ${entryId} in this workspace (${e.message})`);
16171
16657
  }
16172
- const matched = matchRepoName(i.origin, i.resolved);
16173
- const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename9(target);
16174
- let warning;
16175
- if (!matched) {
16176
- const known = Object.values(i.resolved?.repoNames ?? {});
16177
- warning = i.origin ? `the tracker has no Repos row for remote ${normalizeRemote(i.origin) ?? i.origin}${known.length ? ` (known: ${known.join(", ")})` : ""} \u2014 added anyway; create the row so ticket routing can find it` : "this checkout has no origin remote, so it cannot be matched to a Repos row \u2014 added anyway";
16658
+ if (!opts.withPrompt) return { entry, reconstruction: { status: "not_recorded", reason: "not requested" } };
16659
+ if (!entry.agent_id || !entry.prompt_version) {
16660
+ return {
16661
+ entry,
16662
+ reconstruction: {
16663
+ status: "not_recorded",
16664
+ reason: !entry.agent_id ? "this entry has no linked Agents row (predates crew agents sync being run for its role)" : "this entry predates ISSUE-377/529 \u2014 no prompt_version was recorded"
16665
+ }
16666
+ };
16178
16667
  }
16179
- const repos = m.get("repos", true);
16180
- let mode;
16181
- if (isMap(repos)) {
16182
- if (repos.has(name)) throw new ReposError(`route ${i.route} already has a repo named "${name}" (${String(repos.get(name))})`);
16183
- repos.set(name, target);
16184
- mode = "repos";
16185
- } else if (m.has("dir") && existing.length > 0) {
16186
- const old = existing[0];
16187
- const oldName = i.nameForExisting?.(old.dir) ?? old.name;
16188
- if (oldName === name) throw new ReposError(`route ${i.route} already has a repo named "${name}" (${old.dir})`);
16189
- const oldDir = String(m.get("dir"));
16190
- const pair = m.items.find((p) => p.key.value === "dir");
16191
- pair.key = doc.createNode("repos");
16192
- pair.value = doc.createNode({ [oldName]: oldDir, [name]: target });
16193
- mode = "repos";
16194
- } else if (droppedDir) {
16195
- m.set("repos", doc.createNode({ [name]: target }));
16196
- mode = "repos";
16197
- } else {
16198
- m.set("dir", target);
16199
- mode = "dir";
16668
+ const agentsModel = await c.dataModels.get("agents", route2.resolved.workspaceId);
16669
+ let historyEntries;
16670
+ try {
16671
+ historyEntries = await fetchHistoryEntries(route2, agentsModel.id, entry.agent_id, opts.userAgent, 200);
16672
+ } catch (e) {
16673
+ return { entry, reconstruction: { status: "approximate", reason: `history lookup failed: ${e.message}` } };
16200
16674
  }
16201
- return { text: String(doc), mode, name, block: renderBlock(m), warning, unmatched: !matched, droppedDir };
16202
- }
16203
- function planRepoRemove(text, base, route2, path) {
16204
- const doc = parseDocument3(text);
16205
- if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
16206
- const m = routeMapOf(doc, route2);
16207
- const target = expandDir(path, base);
16208
- const existing = configuredRepos(text, route2, base);
16209
- const hit = existing.find((e) => e.dir === target);
16210
- if (!hit) throw new ReposError(`${target} is not configured on route ${route2}`);
16211
- const repos = m.get("repos", true);
16212
- if (isMap(repos)) {
16213
- repos.delete(hit.name);
16214
- const left = existing.filter((e) => e !== hit);
16215
- if (left.length === 1) {
16216
- const pair = m.items.find((p) => p.key.value === "repos");
16217
- pair.key = doc.createNode("dir");
16218
- pair.value = doc.createNode(String(repos.get(left[0].name)));
16219
- } else if (left.length === 0) m.delete("repos");
16220
- } else {
16221
- m.delete("dir");
16675
+ const match = historyEntries.find((h) => h.history_id === entry.prompt_version);
16676
+ if (!match) {
16677
+ return {
16678
+ entry,
16679
+ reconstruction: {
16680
+ status: "approximate",
16681
+ reason: `history entry ${entry.prompt_version} no longer exists on the Agents row (purged, or the row itself was recreated)`
16682
+ }
16683
+ };
16222
16684
  }
16223
- return { text: String(doc), name: hit.name };
16224
- }
16225
- function renderBlock(m) {
16226
- return stringify2([m.toJSON()]).trimEnd();
16227
- }
16228
- var LOCAL_RELEASE_MODES = /* @__PURE__ */ new Set(["integrate", "external"]);
16229
- function planRepoRow(i) {
16230
- const remote = normalizeRemote(i.origin);
16231
- if (!remote) throw new ReposError(`cannot derive an owner/repo from origin "${i.origin}"`);
16232
- const mode = i.spec?.releaseMode;
16685
+ const prompt = appendAdditions(match.prompt ?? "", match.prompt_additions);
16686
+ const actualSha = createHash6("sha256").update(prompt).digest("hex");
16687
+ const exact = !!entry.prompt_sha && actualSha === entry.prompt_sha;
16233
16688
  return {
16234
- name: i.name,
16235
- remote,
16236
- platform: i.spec?.platform ?? "unix",
16237
- release_mode: mode ? LOCAL_RELEASE_MODES.has(mode) ? "local" : mode : "local",
16238
- ci_provider: i.spec?.ciProvider ?? "none",
16239
- enabled: true,
16240
- project_id: i.projectId
16689
+ entry,
16690
+ reconstruction: {
16691
+ status: exact ? "exact" : "approximate",
16692
+ historyId: match.history_id,
16693
+ changedAt: match.changed_at,
16694
+ prompt,
16695
+ ...exact ? {} : { reason: entry.prompt_sha ? "stored prompt_sha does not match this history entry's content" : "this entry recorded no prompt_sha to verify against" }
16696
+ }
16241
16697
  };
16242
16698
  }
16243
- function resolveRepoProject(projects, opt, routeAreaId) {
16244
- if (opt) {
16245
- const want = opt.toLowerCase();
16246
- const hit = projects.find((p) => p.id === opt || p.name?.toLowerCase() === want);
16247
- if (!hit) throw new ReposError(`no Projects row "${opt}" (have: ${projects.map((p) => p.name ?? p.id).join(", ") || "none"})`);
16248
- return hit.id;
16699
+
16700
+ // src/repos-check.ts
16701
+ import { spawnSync as spawnSync7 } from "child_process";
16702
+ import { readFileSync as readFileSync29 } from "fs";
16703
+ import { basename as basename9 } from "path";
16704
+ var POSIX_SHELL = /^(?:ba|z|da|k|a|mk)?sh$|^fish$/;
16705
+ function checkHookSyntax(script, shell) {
16706
+ const spec = resolveShell(shell);
16707
+ const name = basename9(spec.bin).replace(/\.exe$/i, "").toLowerCase();
16708
+ let run2;
16709
+ if (POSIX_SHELL.test(name)) {
16710
+ run2 = spawnSync7(spec.bin, ["-n"], { input: script, encoding: "utf8" });
16711
+ } else if (/^(pwsh|powershell)$/.test(name)) {
16712
+ const parse3 = "$e=$null;[void][System.Management.Automation.Language.Parser]::ParseInput($env:CREW_CHECK_SCRIPT,[ref]$null,[ref]$e);if($e){$e|ForEach-Object{[Console]::Error.WriteLine($_.Message)};exit 1}";
16713
+ run2 = spawnSync7(spec.bin, ["-NoProfile", "-NonInteractive", "-Command", parse3], {
16714
+ encoding: "utf8",
16715
+ env: { ...process.env, CREW_CHECK_SCRIPT: script }
16716
+ });
16717
+ } else {
16718
+ return { status: "unchecked", message: `no syntax check for ${spec.bin}` };
16249
16719
  }
16250
- if (routeAreaId) return routeAreaId;
16251
- if (projects.length === 1) return projects[0].id;
16252
- throw new ReposError(
16253
- `cannot tell which project the new Repos row belongs to \u2014 pass --project <name> (have: ${projects.map((p) => p.name ?? p.id).join(", ") || "none"}), or --no-create to skip creating it`
16254
- );
16720
+ if (run2.error) return { status: "unchecked", message: `cannot run ${spec.bin}: ${run2.error.message}` };
16721
+ if (run2.status === 0) return { status: "ok" };
16722
+ const message = `${run2.stderr ?? ""}${run2.stdout ?? ""}`.trim() || `${spec.bin} exited ${run2.status}`;
16723
+ return { status: "error", message };
16255
16724
  }
16256
- function parseCloneArgs(args, routes) {
16257
- const rest = [...args];
16258
- let route2;
16259
- if (rest.length > 1 && routes.includes(rest[0])) route2 = rest.shift();
16260
- else if (rest.length > 1) throw new ReposError(`no route "${rest[0]}" in the config (have: ${routes.join(", ") || "none"})`);
16261
- if (!route2) {
16262
- if (routes.length === 1) route2 = routes[0];
16263
- else throw new ReposError(routes.length === 0 ? "the config has no routes \u2014 run `crew connect` first" : `several routes are configured; name one: ${routes.join(", ")}`);
16264
- }
16265
- if (rest.length !== 1) throw new ReposError("expected [route] <name> \u2014 the Repos row to clone (see `crew repos list`)");
16266
- return { route: route2, name: rest[0] };
16725
+ function renderSamples(cfg, dir) {
16726
+ const checkout = basename9(dir.replace(/[/\\]+$/, ""));
16727
+ const tagged = { key: "ISSUE-123", title: "Fix the widget", role: "dev", tag: "TABL-123", prefix: "tabl", lowercase: cfg.branch.lowercase };
16728
+ const untagged = { key: "ISSUE-123", title: "Fix the widget", role: "dev", lowercase: cfg.branch.lowercase };
16729
+ return [["with a project tag", tagged], ["without a project tag", untagged]].map(([label, ctx]) => {
16730
+ const branch = renderBranchName(effectiveBranchTemplate(cfg, ctx.prefix), ctx);
16731
+ const worktree = effectiveWorktreeDirName(cfg, dir, branch, "123", (t) => renderWorktreeName(t, { ...ctx, dir: checkout }));
16732
+ return { case: label, branch, worktree };
16733
+ });
16267
16734
  }
16268
- function planRepoClone(i) {
16269
- const names = i.resolved?.repoNames ?? {};
16270
- const id = Object.keys(names).find((k) => names[k].toLowerCase() === i.name.toLowerCase());
16271
- if (!id) {
16272
- const known = Object.values(names);
16273
- throw new ReposError(`the tracker has no Repos row named "${i.name}" for route ${i.route}${known.length ? ` (known: ${known.join(", ")})` : " \u2014 run `crew connect` to refresh them"}`);
16735
+ function checkRepoSpec(dir, opts = {}) {
16736
+ const empty = { parsed: null, effective: null, readiness: [], hooks: [], shadowed: [], samples: [] };
16737
+ const file = findRepoConfig(dir);
16738
+ if (!file) {
16739
+ return { ...empty, file: null, error: `no .crew.yaml, .crew.yml or .crew.json in ${dir}`, exitCode: 1 };
16274
16740
  }
16275
- const name = names[id];
16276
- const remote = normalizeRemote(i.resolved?.repoRemotes?.[id]);
16277
- if (!remote) {
16278
- throw new ReposError(
16279
- `the Repos row "${name}" has no remote to clone from \u2014 attach an existing checkout with \`crew repos add ${i.route} ${name} <path>\`, or set the row's remote in the tracker and re-run \`crew connect\``
16280
- );
16741
+ let parsed;
16742
+ try {
16743
+ parsed = parseRepoConfig(readFileSync29(file, "utf8"), file);
16744
+ } catch (e) {
16745
+ if (!(e instanceof RepoConfigError)) throw e;
16746
+ return { ...empty, file, error: e.message, exitCode: 1 };
16281
16747
  }
16282
- const doc = parseDocument3(i.text);
16283
- const m = routeMapOf(doc, i.route);
16284
- const existing = configuredRepos(i.text, i.route, i.base);
16285
- if (existing.some((e) => e.name.toLowerCase() === name.toLowerCase())) {
16286
- throw new ReposError(`route ${i.route} already has a checkout named "${name}" \u2014 nothing to clone`);
16748
+ const effective = resolveRepoConfig(parsed, opts.ship, dir);
16749
+ const readiness = validateEffective(effective);
16750
+ const hooks = Object.keys(effective.hooks).map((name) => ({
16751
+ name,
16752
+ label: hookLabel(effective, name),
16753
+ syntax: checkHookSyntax(effective.hooks[name], effective.shell)
16754
+ }));
16755
+ const failed = hooks.some((h) => h.syntax.status === "error");
16756
+ return {
16757
+ file,
16758
+ parsed,
16759
+ effective,
16760
+ readiness,
16761
+ hooks,
16762
+ shadowed: effective.shadowed,
16763
+ samples: renderSamples(effective, dir),
16764
+ error: null,
16765
+ exitCode: failed || opts.strict && readiness.length > 0 ? 1 : 0
16766
+ };
16767
+ }
16768
+ function renderRepoCheck(r) {
16769
+ if (r.error) return `${r.file ?? "crew repos check"}: ${r.error}
16770
+ `;
16771
+ const e = r.effective;
16772
+ const lines = [
16773
+ `${r.file}`,
16774
+ ` platform ${e.platform}`,
16775
+ ` release.mode ${e.release.mode}`,
16776
+ ` release.versioning ${e.release.versioning}`,
16777
+ ` branch.base ${e.branch.base}`,
16778
+ ` branch.name ${e.branch.name}`,
16779
+ ` branch.push ${e.branch.push}`
16780
+ ];
16781
+ for (const s of r.samples) lines.push(` ${s.case}: branch ${s.branch}, worktree ../${s.worktree}`);
16782
+ lines.push(r.hooks.length ? " hooks" : " hooks none");
16783
+ for (const h of r.hooks) {
16784
+ const named = h.label !== e.hooks[h.name] ? ` (${h.label})` : "";
16785
+ const syntax = h.syntax.status === "ok" ? "syntax ok" : h.syntax.status === "unchecked" ? `syntax not checked: ${h.syntax.message}` : "syntax ERROR";
16786
+ lines.push(` ${h.name}${named}: ${syntax}`);
16787
+ if (h.syntax.status === "error") lines.push(...(h.syntax.message ?? "").split("\n").map((l) => ` ${l}`));
16287
16788
  }
16288
- const ship = doc.get("ship", true);
16289
- const basePath = String(m.get("reposBasePath") ?? (isMap(ship) ? ship.get("reposBasePath") : void 0) ?? "~/Crew");
16290
- const target = i.dir ? expandDir(i.dir, i.base) : join33(expandDir(basePath, i.base), i.route.split("/")[0], name);
16291
- const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name, isCheckout: i.isCheckout });
16292
- return { ...add, remote, target };
16789
+ if (r.shadowed.length) lines.push(` this ship declares ${r.shadowed.join(", ")}, and the file overrides it \u2014 the file wins`);
16790
+ for (const p of r.readiness) lines.push(` - release readiness: ${p}`);
16791
+ return `${lines.join("\n")}
16792
+ `;
16293
16793
  }
16294
16794
 
16295
16795
  // src/repos-hooks.ts
16796
+ import { isMap as isMap2, parseDocument as parseDocument6, YAMLMap } from "yaml";
16296
16797
  function packageManager(f) {
16297
16798
  if (f.exists("pnpm-lock.yaml")) return "pnpm";
16298
16799
  if (f.exists("yarn.lock")) return "yarn";
@@ -16433,7 +16934,7 @@ function repoDirOf(text, base, route2, name) {
16433
16934
  return hit.dir;
16434
16935
  }
16435
16936
  function planRouteHooks(text, route2, name, hooks, opts = {}) {
16436
- const doc = parseDocument4(text);
16937
+ const doc = parseDocument6(text);
16437
16938
  if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
16438
16939
  const m = routeMapOf(doc, route2);
16439
16940
  const repos = m.get("repos", true);
@@ -16470,7 +16971,7 @@ function planRouteHooks(text, route2, name, hooks, opts = {}) {
16470
16971
  return { text: String(doc), written, kept, replaced };
16471
16972
  }
16472
16973
  function clearRouteHooks(text, route2, name) {
16473
- const doc = parseDocument4(text);
16974
+ const doc = parseDocument6(text);
16474
16975
  if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
16475
16976
  const m = routeMapOf(doc, route2);
16476
16977
  const repos = m.get("repos", true);
@@ -16753,56 +17254,8 @@ function planSampleTicket(i) {
16753
17254
  };
16754
17255
  }
16755
17256
 
16756
- // src/doctor-fix.ts
16757
- import { parseDocument as parseDocument5 } from "yaml";
16758
- var sound = (r) => r.hasRepos && r.problems.length === 0;
16759
- function planFix(f) {
16760
- const actions = [];
16761
- const notes = [];
16762
- for (const r of f.routes) {
16763
- if (!r.enabled && sound(r)) {
16764
- actions.push({ kind: "enable", route: r.route });
16765
- const gaps = r.readiness ?? [];
16766
- if (gaps.length) {
16767
- notes.push(`${r.route}: ${gaps.length} release-readiness gap${gaps.length === 1 ? "" : "s"} (${gaps[0]}${gaps.length > 1 ? ", ..." : ""}) \u2014 the route will run, but releases stop at the first missing hook until it is added`);
16768
- }
16769
- } else if (!sound(r) && !r.enabled) {
16770
- notes.push(`${r.route}: not enabled \u2014 ${r.hasRepos ? r.problems[0] : "no checkout is configured"}`);
16771
- }
16772
- }
16773
- if (f.schedulerAbsent && f.routes.some(sound)) {
16774
- if (f.host === "windows") notes.push("scheduler support is not built for Windows yet \u2014 nothing to install; run `crew run` by hand");
16775
- else actions.push({ kind: "install" });
16776
- }
16777
- return { actions, notes };
16778
- }
16779
- async function gatherHealth(c, check) {
16780
- let problems;
16781
- let readiness = [];
16782
- try {
16783
- const r = await check();
16784
- if (Array.isArray(r)) problems = r;
16785
- else ({ problems, readiness } = r);
16786
- } catch (e) {
16787
- problems = [`checks could not run: ${e instanceof Error ? e.message : String(e)}`];
16788
- }
16789
- return { route: c.route, enabled: c.enabled, hasRepos: c.hasRepos, problems, readiness };
16790
- }
16791
- function routesInScope(routes, named) {
16792
- return named ? routes.filter((r) => r.route === named) : routes;
16793
- }
16794
- function planEnable(text, route2) {
16795
- const doc = parseDocument5(text);
16796
- routeMapOf(doc, route2).set("enabled", true);
16797
- return String(doc);
16798
- }
16799
- function schedulerInstalled(plan, exists, crontab) {
16800
- if (plan.mechanism === "cron") return plan.cronMarker !== null && crontab.includes(plan.cronMarker);
16801
- return plan.unitPaths.length > 0 && plan.unitPaths.every(exists);
16802
- }
16803
-
16804
17257
  // src/worktree-sweep.ts
16805
- import { existsSync as existsSync32, readFileSync as readFileSync29, readdirSync as readdirSync12 } from "fs";
17258
+ import { existsSync as existsSync33, readFileSync as readFileSync30, readdirSync as readdirSync12 } from "fs";
16806
17259
  import { basename as basename11, dirname as dirname19, join as join35, resolve as resolve4 } from "path";
16807
17260
  function planWorktreeSweep(target, tickets, contract) {
16808
17261
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
@@ -16915,7 +17368,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
16915
17368
  let removed = 0;
16916
17369
  let keptBranches = 0;
16917
17370
  for (const a of actions) {
16918
- if (!dryRun && base && !a.keepBranch && existsSync32(a.path) && isHandBackMerge(a.path, base)) {
17371
+ if (!dryRun && base && !a.keepBranch && existsSync33(a.path) && isHandBackMerge(a.path, base)) {
16919
17372
  if (gitOk(a.path, ["merge", "--abort"]) !== null) {
16920
17373
  log.emit(
16921
17374
  `abandoned the hand-back merge of ${base} in ${a.path}: ${a.ticket.issue_id} deployed without it`,
@@ -16923,7 +17376,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
16923
17376
  );
16924
17377
  }
16925
17378
  }
16926
- if (!dryRun && existsSync32(a.path)) {
17379
+ if (!dryRun && existsSync33(a.path)) {
16927
17380
  let dirty = false;
16928
17381
  try {
16929
17382
  dirty = status(a.path).length > 0;
@@ -16934,7 +17387,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
16934
17387
  continue;
16935
17388
  }
16936
17389
  }
16937
- if (!dryRun && a.requirePushed && a.branch && existsSync32(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
17390
+ if (!dryRun && a.requirePushed && a.branch && existsSync33(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
16938
17391
  refuse(a, "unpushed commits", `${a.branch} has commits that are not on ${remote}`);
16939
17392
  continue;
16940
17393
  }
@@ -16983,7 +17436,7 @@ function isHandBackMerge(path, base) {
16983
17436
  if (!msgPath) return false;
16984
17437
  let first;
16985
17438
  try {
16986
- first = readFileSync29(resolve4(path, msgPath), "utf8").split("\n")[0] ?? "";
17439
+ first = readFileSync30(resolve4(path, msgPath), "utf8").split("\n")[0] ?? "";
16987
17440
  } catch {
16988
17441
  return false;
16989
17442
  }
@@ -17088,12 +17541,12 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
17088
17541
  }
17089
17542
 
17090
17543
  // src/stream-sweep.ts
17091
- import { existsSync as existsSync33, readdirSync as readdirSync13, rmSync as rmSync15, statSync as statSync10 } from "fs";
17544
+ import { existsSync as existsSync34, readdirSync as readdirSync13, rmSync as rmSync15, statSync as statSync10 } from "fs";
17092
17545
  import { join as join36 } from "path";
17093
17546
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
17094
17547
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
17095
17548
  const dir = join36(stateDir, "streams");
17096
- if (!existsSync33(dir)) return [];
17549
+ if (!existsSync34(dir)) return [];
17097
17550
  const cutoff = now - retentionDays * DAY_MS2;
17098
17551
  return readdirSync13(dir).map((name) => join36(dir, name)).filter((path) => {
17099
17552
  try {
@@ -17121,7 +17574,7 @@ function applyStreamSweep(paths, dryRun, log) {
17121
17574
  return removed;
17122
17575
  }
17123
17576
  function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
17124
- if (!existsSync33(stateDir)) return [];
17577
+ if (!existsSync34(stateDir)) return [];
17125
17578
  const cutoff = now - retentionDays * DAY_MS2;
17126
17579
  return readdirSync13(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join36(stateDir, name)).filter((path) => {
17127
17580
  try {
@@ -17133,11 +17586,11 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
17133
17586
  }
17134
17587
 
17135
17588
  // src/cli.ts
17136
- import { realpathSync as realpathSync3, readFileSync as readFileSync35, existsSync as existsSync40, mkdirSync as mkdirSync26, writeFileSync as writeFileSync27, rmSync as rmSync19, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync4, statSync as statSync14, readdirSync as readdirSync18 } from "fs";
17589
+ import { realpathSync as realpathSync3, readFileSync as readFileSync36, existsSync as existsSync41, mkdirSync as mkdirSync26, writeFileSync as writeFileSync28, rmSync as rmSync19, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync4, statSync as statSync14, readdirSync as readdirSync18 } from "fs";
17137
17590
  import { createInterface } from "readline/promises";
17138
17591
 
17139
17592
  // src/periscope-cmd.ts
17140
- import { existsSync as existsSync34, mkdirSync as mkdirSync21, readFileSync as readFileSync30, rmSync as rmSync16, writeFileSync as writeFileSync23 } from "fs";
17593
+ import { existsSync as existsSync35, mkdirSync as mkdirSync21, readFileSync as readFileSync31, rmSync as rmSync16, writeFileSync as writeFileSync24 } from "fs";
17141
17594
  import { join as join37 } from "path";
17142
17595
  var PeriscopeError = class extends Error {
17143
17596
  };
@@ -17149,14 +17602,14 @@ function removePeriscopePrior(stateDir, ticketRecordId) {
17149
17602
  function writePeriscopePrior(stateDir, ticketRecordId, prior) {
17150
17603
  try {
17151
17604
  mkdirSync21(priorDir(stateDir), { recursive: true });
17152
- writeFileSync23(priorPath(stateDir, ticketRecordId), JSON.stringify(prior));
17605
+ writeFileSync24(priorPath(stateDir, ticketRecordId), JSON.stringify(prior));
17153
17606
  } catch {
17154
17607
  }
17155
17608
  }
17156
17609
  function readPeriscopePrior(stateDir, ticketRecordId) {
17157
17610
  try {
17158
17611
  const p = priorPath(stateDir, ticketRecordId);
17159
- return existsSync34(p) ? JSON.parse(readFileSync30(p, "utf8")) : void 0;
17612
+ return existsSync35(p) ? JSON.parse(readFileSync31(p, "utf8")) : void 0;
17160
17613
  } catch {
17161
17614
  return void 0;
17162
17615
  }
@@ -17646,7 +18099,7 @@ import { dirname as dirOf2, resolve as resolvePath } from "path";
17646
18099
 
17647
18100
  // src/ssh-keys.ts
17648
18101
  import { execFileSync as execFileSync8 } from "child_process";
17649
- import { existsSync as existsSync35, mkdirSync as mkdirSync22, readFileSync as readFileSync31, chmodSync as chmodSync3 } from "fs";
18102
+ import { existsSync as existsSync36, mkdirSync as mkdirSync22, readFileSync as readFileSync32, chmodSync as chmodSync3 } from "fs";
17650
18103
  import { dirname as dirname20, join as join39 } from "path";
17651
18104
  var SshKeyError = class extends Error {
17652
18105
  };
@@ -17656,7 +18109,7 @@ function shipSshKeyPathFor(stateDir) {
17656
18109
  function ensureShipSshKeypair(stateDir) {
17657
18110
  const privateKeyPath = shipSshKeyPathFor(stateDir);
17658
18111
  const publicKeyPath = `${privateKeyPath}.pub`;
17659
- if (!existsSync35(privateKeyPath)) {
18112
+ if (!existsSync36(privateKeyPath)) {
17660
18113
  mkdirSync22(dirname20(privateKeyPath), { recursive: true });
17661
18114
  try {
17662
18115
  execFileSync8("ssh-keygen", [
@@ -17677,10 +18130,10 @@ function ensureShipSshKeypair(stateDir) {
17677
18130
  }
17678
18131
  chmodSync3(privateKeyPath, 384);
17679
18132
  }
17680
- if (!existsSync35(publicKeyPath)) {
18133
+ if (!existsSync36(publicKeyPath)) {
17681
18134
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
17682
18135
  }
17683
- const publicKey = readFileSync31(publicKeyPath, "utf8").trim();
18136
+ const publicKey = readFileSync32(publicKeyPath, "utf8").trim();
17684
18137
  return { privateKeyPath, publicKeyPath, publicKey };
17685
18138
  }
17686
18139
  function sshKeygenAvailable() {
@@ -17897,7 +18350,7 @@ function syncAllPassengerCheckouts(cfg) {
17897
18350
 
17898
18351
  // src/tunnel.ts
17899
18352
  import { spawn as spawn5 } from "child_process";
17900
- import { existsSync as existsSync36, mkdirSync as mkdirSync23, readFileSync as readFileSync32, writeFileSync as writeFileSync24, unlinkSync as unlinkSync3, readdirSync as readdirSync14 } from "fs";
18353
+ import { existsSync as existsSync37, mkdirSync as mkdirSync23, readFileSync as readFileSync33, writeFileSync as writeFileSync25, unlinkSync as unlinkSync3, readdirSync as readdirSync14 } from "fs";
17901
18354
  import { randomInt } from "crypto";
17902
18355
  import { join as join40 } from "path";
17903
18356
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
@@ -17970,9 +18423,9 @@ function tunnelStatePath(stateDir, workspaceId) {
17970
18423
  }
17971
18424
  function readPersistedTunnel(stateDir, workspaceId) {
17972
18425
  const path = tunnelStatePath(stateDir, workspaceId);
17973
- if (!existsSync36(path)) return void 0;
18426
+ if (!existsSync37(path)) return void 0;
17974
18427
  try {
17975
- return JSON.parse(readFileSync32(path, "utf8"));
18428
+ return JSON.parse(readFileSync33(path, "utf8"));
17976
18429
  } catch {
17977
18430
  return void 0;
17978
18431
  }
@@ -17980,7 +18433,7 @@ function readPersistedTunnel(stateDir, workspaceId) {
17980
18433
  function writePersistedTunnel(stateDir, state) {
17981
18434
  const path = tunnelStatePath(stateDir, state.workspaceId);
17982
18435
  mkdirSync23(join40(stateDir, "passengers"), { recursive: true });
17983
- writeFileSync24(path, `${JSON.stringify(state, null, 2)}
18436
+ writeFileSync25(path, `${JSON.stringify(state, null, 2)}
17984
18437
  `);
17985
18438
  }
17986
18439
  function updatePersistedTunnel(stateDir, workspaceId, patch2) {
@@ -18078,7 +18531,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
18078
18531
  }
18079
18532
  function readAllPersistedWorkspaceIds(stateDir) {
18080
18533
  const dir = join40(stateDir, "passengers");
18081
- if (!existsSync36(dir)) return [];
18534
+ if (!existsSync37(dir)) return [];
18082
18535
  return readdirSync14(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
18083
18536
  }
18084
18537
 
@@ -18199,7 +18652,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
18199
18652
  import { createServer as createHttpServer } from "http";
18200
18653
  import { spawn as spawn6 } from "child_process";
18201
18654
  import { randomBytes } from "crypto";
18202
- import { existsSync as existsSync37, mkdirSync as mkdirSync24, readFileSync as readFileSync33, writeFileSync as writeFileSync25, unlinkSync as unlinkSync4, readdirSync as readdirSync15 } from "fs";
18655
+ import { existsSync as existsSync38, mkdirSync as mkdirSync24, readFileSync as readFileSync34, writeFileSync as writeFileSync26, unlinkSync as unlinkSync4, readdirSync as readdirSync15 } from "fs";
18203
18656
  import { join as join41 } from "path";
18204
18657
 
18205
18658
  // src/secret-compare.ts
@@ -18293,9 +18746,9 @@ function syncDaemonStatePath(stateDir, workspaceId) {
18293
18746
  }
18294
18747
  function readPersistedSyncDaemon(stateDir, workspaceId) {
18295
18748
  const path = syncDaemonStatePath(stateDir, workspaceId);
18296
- if (!existsSync37(path)) return void 0;
18749
+ if (!existsSync38(path)) return void 0;
18297
18750
  try {
18298
- return JSON.parse(readFileSync33(path, "utf8"));
18751
+ return JSON.parse(readFileSync34(path, "utf8"));
18299
18752
  } catch {
18300
18753
  return void 0;
18301
18754
  }
@@ -18303,7 +18756,7 @@ function readPersistedSyncDaemon(stateDir, workspaceId) {
18303
18756
  function writePersistedSyncDaemon(stateDir, state) {
18304
18757
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
18305
18758
  mkdirSync24(join41(stateDir, "passengers"), { recursive: true });
18306
- writeFileSync25(path, `${JSON.stringify(state, null, 2)}
18759
+ writeFileSync26(path, `${JSON.stringify(state, null, 2)}
18307
18760
  `);
18308
18761
  }
18309
18762
  function removePersistedSyncDaemon(stateDir, workspaceId) {
@@ -18324,7 +18777,7 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
18324
18777
  }
18325
18778
  function readAllPersistedSyncWorkspaceIds(stateDir) {
18326
18779
  const dir = join41(stateDir, "passengers");
18327
- if (!existsSync37(dir)) return [];
18780
+ if (!existsSync38(dir)) return [];
18328
18781
  const suffix = "-sync.json";
18329
18782
  return readdirSync15(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
18330
18783
  }
@@ -18412,7 +18865,7 @@ function describeCause(err3) {
18412
18865
  }
18413
18866
 
18414
18867
  // src/daemon.ts
18415
- import { existsSync as existsSync38, mkdirSync as mkdirSync25, readFileSync as readFileSync34, readdirSync as readdirSync16, rmSync as rmSync17, statSync as statSync12, writeFileSync as writeFileSync26 } from "fs";
18868
+ import { existsSync as existsSync39, mkdirSync as mkdirSync25, readFileSync as readFileSync35, readdirSync as readdirSync16, rmSync as rmSync17, statSync as statSync12, writeFileSync as writeFileSync27 } from "fs";
18416
18869
  import { join as join42 } from "path";
18417
18870
  import { randomBytes as randomBytes2 } from "crypto";
18418
18871
  var DEFAULT_FLOOR_MS = 5e3;
@@ -18493,7 +18946,7 @@ function computeTreeSignature(paths) {
18493
18946
  }
18494
18947
  function makeStaleChecker(paths, opts = {}) {
18495
18948
  const initial = computeTreeSignature(paths);
18496
- return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync38(opts.reloadSentinel);
18949
+ return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync39(opts.reloadSentinel);
18497
18950
  }
18498
18951
  function reloadSentinelFor(stateDir) {
18499
18952
  return join42(stateDir, "reload-requested");
@@ -18504,12 +18957,12 @@ function daemonStampFor(stateDir) {
18504
18957
  function noteDaemonStarted(stateDir, now = Date.now()) {
18505
18958
  mkdirSync25(stateDir, { recursive: true });
18506
18959
  rmSync17(reloadSentinelFor(stateDir), { force: true });
18507
- writeFileSync26(daemonStampFor(stateDir), `${JSON.stringify({ pid: process.pid, startedAt: now })}
18960
+ writeFileSync27(daemonStampFor(stateDir), `${JSON.stringify({ pid: process.pid, startedAt: now })}
18508
18961
  `);
18509
18962
  }
18510
18963
  function readDaemonStarted(stateDir) {
18511
18964
  try {
18512
- const v = JSON.parse(readFileSync34(daemonStampFor(stateDir), "utf8")).startedAt;
18965
+ const v = JSON.parse(readFileSync35(daemonStampFor(stateDir), "utf8")).startedAt;
18513
18966
  return typeof v === "number" ? v : null;
18514
18967
  } catch {
18515
18968
  return null;
@@ -18782,7 +19235,7 @@ async function reconcileForRoute(ctx, o) {
18782
19235
  if (wt) dirty = status(wt.path).length > 0;
18783
19236
  } catch {
18784
19237
  }
18785
- await tracker.postEvent(sc.ticketRecordId, interruptedComment({
19238
+ await tracker.postEvent(sc.ticketRecordId, result.killed ? runKilledComment({ limitMinutes: result.killed.limitMinutes, branch: wt?.branch ?? null, dirty }) : interruptedComment({
18786
19239
  branch: wt?.branch ?? null,
18787
19240
  dirty,
18788
19241
  reason: result.interrupted === "daemon-exit" ? "the crew daemon exited" : "it exited without a result"
@@ -18805,6 +19258,23 @@ async function reconcileForRoute(ctx, o) {
18805
19258
  }
18806
19259
  });
18807
19260
  }
19261
+ async function postRunKilled(route2, tracker, ticketId, memberId, limitMinutes, emit) {
19262
+ if (!ticketId || !memberId) return;
19263
+ try {
19264
+ const t = await tracker.ticket(ticketId);
19265
+ const target = repoTargetFor(route2, t.repo_id);
19266
+ const num = String(t[tracker.contract.columns.key] ?? "").replace(/^\D+/, "");
19267
+ const wt = target && num ? worktreeForNumber(target.dir, num) : null;
19268
+ let dirty = null;
19269
+ try {
19270
+ if (wt) dirty = status(wt.path).length > 0;
19271
+ } catch {
19272
+ }
19273
+ await tracker.postEvent(ticketId, runKilledComment({ limitMinutes, branch: wt?.branch ?? null, dirty }), memberId, "interrupted");
19274
+ } catch (e) {
19275
+ emit.warn(`could not post the wall-clock limit comment: ${e.message}`, { step: "agent" });
19276
+ }
19277
+ }
18808
19278
  async function runRoleAgent(role, decision, ctx, hooks) {
18809
19279
  const held = {};
18810
19280
  try {
@@ -18909,7 +19379,7 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
18909
19379
  const target = repoTargetFor(route2, result.ticket.repo_id);
18910
19380
  ticketRepo = target?.name;
18911
19381
  try {
18912
- if (target && ensureRepoCheckout(target.dir, target.remote)) {
19382
+ if (target && ensureRepoCheckout(target.dir, target.remote, { scheme: ship.cloneScheme })) {
18913
19383
  emit.emit(`cloned ${target.name} into ${target.dir}`, { step: "select", role });
18914
19384
  }
18915
19385
  } catch (e) {
@@ -18967,8 +19437,12 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
18967
19437
  } catch {
18968
19438
  }
18969
19439
  }
18970
- await spawnAgent(plan, emit, hintMoved);
18971
- await guardRunHandoff(route2, ship, tracker, role, workingId, emit, runStart);
19440
+ const result = await spawnAgent(plan, emit, hintMoved);
19441
+ if (result.killed) {
19442
+ await postRunKilled(route2, tracker, workingId, memberId, result.killed.limitMinutes, emit);
19443
+ } else {
19444
+ await guardRunHandoff(route2, ship, tracker, role, workingId, emit, runStart);
19445
+ }
18972
19446
  } finally {
18973
19447
  await settleRunEnd({
18974
19448
  tracker,
@@ -19010,7 +19484,7 @@ import { createHash as createHash9 } from "crypto";
19010
19484
  import {
19011
19485
  chmodSync as chmodSync4,
19012
19486
  closeSync as closeSync5,
19013
- existsSync as existsSync39,
19487
+ existsSync as existsSync40,
19014
19488
  fstatSync,
19015
19489
  openSync as openSync5,
19016
19490
  readSync as readSync3,
@@ -19361,7 +19835,7 @@ function lastLines2(path, n) {
19361
19835
  }
19362
19836
  function readTail(live, opts) {
19363
19837
  const max = opts.maxBytes ?? MAX_TAIL_BYTES;
19364
- if (!existsSync39(live)) {
19838
+ if (!existsSync40(live)) {
19365
19839
  return { lines: [], cursor: opts.after ?? encodeCursor({ dev: 0, ino: 0, off: 0 }), reset: false };
19366
19840
  }
19367
19841
  const cur = opts.after ? decodeCursor(opts.after) : null;
@@ -19518,7 +19992,7 @@ async function route(deps, method, url, req, abort) {
19518
19992
  const run2 = decodeURIComponent(p.slice("/streams/".length));
19519
19993
  if (!RUN_NAME_RE.test(run2) || run2.includes("..") || /\.(pid|meta\.json|events\.jsonl)$/.test(run2)) return err2(400, "not a run name");
19520
19994
  const file = join43(deps.stateDir, "streams", run2.endsWith(".jsonl") ? run2 : `${run2}.jsonl`);
19521
- if (!existsSync39(file)) return err2(404, "no such run");
19995
+ if (!existsSync40(file)) return err2(404, "no such run");
19522
19996
  return tailReply(file, url.searchParams, false, abort);
19523
19997
  }
19524
19998
  if ((p === "/pause" || p === "/resume") && post3) {
@@ -19562,7 +20036,7 @@ function send(res, r) {
19562
20036
  res.end(body);
19563
20037
  }
19564
20038
  async function socketIsStale(path) {
19565
- if (!existsSync39(path)) return false;
20039
+ if (!existsSync40(path)) return false;
19566
20040
  return new Promise((resolve6) => {
19567
20041
  const c = createConnection(path);
19568
20042
  c.once("connect", () => {
@@ -19581,7 +20055,7 @@ async function startControlServer(deps) {
19581
20055
  return null;
19582
20056
  }
19583
20057
  if (await socketIsStale(path)) rmSync18(path, { force: true });
19584
- else if (existsSync39(path)) {
20058
+ else if (existsSync40(path)) {
19585
20059
  log(`control API not started: ${path} is in use by another process`);
19586
20060
  return null;
19587
20061
  }
@@ -19790,6 +20264,9 @@ function usage(problem, requested = false) {
19790
20264
  crew --version [--json] print the crew version on one line (also: crew version); --json adds home and compiled
19791
20265
  crew status [route] [--json] paused/running state; --json for a machine reader
19792
20266
  crew projects [--route R] [--json] the Projects rows each route can read (id, name, prefix, work mode)
20267
+ crew provision --key K --route WS[/PROJECT] [--forge-host H] [--ship-name N] [--json] one non-interactive run: connect, forge tokens,
20268
+ enable routes, install units with lingering, start the daemon; safe to re-run (CREW_CONNECT_KEY,
20269
+ CREW_PROVISION_ROUTES, CREW_SHIP_NAME, CREW_FORGE_TOKEN[_<HOST>] also read)
19793
20270
  crew forge [set|rotate <host>] [--token T | --token-stdin] [--source file|keychain|env] this ship's git-forge tokens (ship.forges)
19794
20271
  crew credential get|store|erase git credential helper answering from ship.forges (git runs it; not for hand use)
19795
20272
  crew whoami [route] [--json] the signed-in identity, key role, operator Crew row and credential source per route
@@ -19890,6 +20367,7 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
19890
20367
  "agents",
19891
20368
  "connect",
19892
20369
  "credential",
20370
+ "provision",
19893
20371
  "forge",
19894
20372
  "daemon",
19895
20373
  "deploy",
@@ -19965,7 +20443,7 @@ function daemonConfigPaths(c) {
19965
20443
  for (const r of c.routes) if (r.apiKeyFile && !files.includes(r.apiKeyFile)) files.push(r.apiKeyFile);
19966
20444
  return files;
19967
20445
  }
19968
- var STATE_WRITING_COMMANDS = /* @__PURE__ */ new Set(["connect", "disconnect", "install", "uninstall", "reload", "release", "merge", "deploy"]);
20446
+ var STATE_WRITING_COMMANDS = /* @__PURE__ */ new Set(["connect", "provision", "disconnect", "install", "uninstall", "reload", "release", "merge", "deploy"]);
19969
20447
  var STATE_WRITING_SUBCOMMANDS = {
19970
20448
  agents: /* @__PURE__ */ new Set(["sync"]),
19971
20449
  skills: /* @__PURE__ */ new Set(["sync"])
@@ -19979,6 +20457,19 @@ if (sessionRole && (STATE_WRITING_COMMANDS.has(command) || STATE_WRITING_SUBCOMM
19979
20457
  );
19980
20458
  process.exit(2);
19981
20459
  }
20460
+ if (command === "provision") {
20461
+ const outcome = await runProvision(
20462
+ provisionOptionsFrom(argv, process.env),
20463
+ realProvisionDeps(CREW_HOME),
20464
+ (l) => {
20465
+ process.stdout.write(l);
20466
+ },
20467
+ (l) => {
20468
+ process.stderr.write(l);
20469
+ }
20470
+ );
20471
+ process.exit(outcome.code);
20472
+ }
19982
20473
  if (command === "passenger-sync-daemon") {
19983
20474
  runSyncDaemonFromEnv();
19984
20475
  } else {
@@ -20056,7 +20547,7 @@ if (command === "passenger-sync-daemon") {
20056
20547
  };
20057
20548
  if (command === "connect" && !flag("sign-out") && usesFlagAnswers(wizardFlags, connectIsTTY) && shouldRunWizard({ isTTY: true, crewHome: CREW_HOME })) {
20058
20549
  const flagged = flagWizardAnswers(wizardFlags, {
20059
- hostname: hostname2(),
20550
+ hostname: hostname3(),
20060
20551
  platform: hostPlatform(),
20061
20552
  pathEnv: process.env.PATH ?? "",
20062
20553
  nodePath: process.execPath,
@@ -20081,7 +20572,7 @@ if (command === "passenger-sync-daemon") {
20081
20572
  let answers;
20082
20573
  try {
20083
20574
  answers = await runWizard(prompter, {
20084
- hostname: hostname2(),
20575
+ hostname: hostname3(),
20085
20576
  platform: hostPlatform(),
20086
20577
  pathEnv: process.env.PATH ?? "",
20087
20578
  nodePath: process.execPath,
@@ -20096,7 +20587,7 @@ if (command === "passenger-sync-daemon") {
20096
20587
  } else {
20097
20588
  const toleratesNoRoutes = command === "credential" || command === "forge" || command === "repos" || command === "ship" || command === "disconnect" && flag("reset") || command === "connect" && flag("sign-out");
20098
20589
  const configless = command === "connect" && flag("sign-out") || command === "disconnect" && flag("reset");
20099
- if (configless && !existsSync40(findConfigFile(CREW_HOME))) {
20590
+ if (configless && !existsSync41(findConfigFile(CREW_HOME))) {
20100
20591
  cfg = loadConfig(CREW_HOME, firstRunConfigPath(), { text: "ship: {}\n", allowNoRoutes: true });
20101
20592
  } else {
20102
20593
  cfg = loadConfig(CREW_HOME, void 0, toleratesNoRoutes ? { allowNoRoutes: true } : {});
@@ -20111,7 +20602,7 @@ if (command === "passenger-sync-daemon") {
20111
20602
  throw e;
20112
20603
  }
20113
20604
  if (command === "credential") {
20114
- const r = runCredential(positional[1], readFileSync35(0, "utf8"), cfg.ship.forges, cfg.ship.stateDir);
20605
+ const r = runCredential(positional[1], readFileSync36(0, "utf8"), cfg.ship.forges, cfg.ship.stateDir);
20115
20606
  process.stdout.write(r.out);
20116
20607
  process.stderr.write(r.err);
20117
20608
  process.exit(r.code);
@@ -20123,9 +20614,9 @@ if (command === "passenger-sync-daemon") {
20123
20614
  forges: cfg.ship.forges,
20124
20615
  stateDir: cfg.ship.stateDir,
20125
20616
  configFile: cfg.configFile,
20126
- readFile: (p) => readFileSync35(p, "utf8"),
20127
- writeFile: (p, t) => writeFileSync27(p, t),
20128
- stdin: () => readFileSync35(0, "utf8"),
20617
+ readFile: (p) => readFileSync36(p, "utf8"),
20618
+ writeFile: (p, t) => writeFileSync28(p, t),
20619
+ stdin: () => readFileSync36(0, "utf8"),
20129
20620
  env: process.env
20130
20621
  }
20131
20622
  );
@@ -20349,7 +20840,7 @@ if (command === "passenger-sync-daemon") {
20349
20840
  }
20350
20841
  const hasSystemd = detectSystemd();
20351
20842
  for (const job of ["run", "release", "passengers"]) {
20352
- const plan = planInstall(cfg.ship, CREW_HOME, host, hasSystemd, job);
20843
+ const plan = planInstall(cfg.ship, CREW_HOME, host, hasSystemd, job, installContext());
20353
20844
  process.stdout.write(`installing ${job} via ${plan.mechanism} for ${host}${dryRun ? " (dry run)" : ""}
20354
20845
  `);
20355
20846
  await applyInstall(plan, CREW_HOME, dryRun, {
@@ -20362,13 +20853,13 @@ if (command === "passenger-sync-daemon") {
20362
20853
  if (host === "linux" && hasSystemd) {
20363
20854
  const label = labelFor(CREW_HOME, "run");
20364
20855
  const staleTimer = join44(homedir10(), ".config", "systemd", "user", `${label}.timer`);
20365
- if (existsSync40(staleTimer)) {
20856
+ if (existsSync41(staleTimer)) {
20366
20857
  process.stdout.write(
20367
20858
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
20368
20859
  `
20369
20860
  );
20370
20861
  if (!dryRun) {
20371
- spawnSync7("systemctl", ["--user", "disable", "--now", `${label}.timer`], { cwd: CREW_HOME });
20862
+ spawnSync8("systemctl", ["--user", "disable", "--now", `${label}.timer`], { cwd: CREW_HOME });
20372
20863
  try {
20373
20864
  unlinkSync5(staleTimer);
20374
20865
  } catch {
@@ -20389,7 +20880,7 @@ if (command === "passenger-sync-daemon") {
20389
20880
  const tracker = new Tracker(c, cfg.ship);
20390
20881
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
20391
20882
  for (const r of await resolvedRepos(c)) {
20392
- if (!existsSync40(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
20883
+ if (!existsSync41(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
20393
20884
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
20394
20885
  readiness.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
20395
20886
  }
@@ -20407,9 +20898,9 @@ if (command === "passenger-sync-daemon") {
20407
20898
  }
20408
20899
  async function doctorFix(o) {
20409
20900
  const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
20410
- const unitExists = (p) => existsSync40(p);
20411
- const crontab = o.host === "linux" ? spawnSync7("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
20412
- const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
20901
+ const unitExists = (p) => existsSync41(p);
20902
+ const crontab = o.host === "linux" ? spawnSync8("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
20903
+ const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run", installContext()), unitExists, crontab) : true;
20413
20904
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
20414
20905
  for (const n of plan.notes) process.stdout.write(`fix: ${n}
20415
20906
  `);
@@ -20424,7 +20915,7 @@ if (command === "passenger-sync-daemon") {
20424
20915
  for (const a of plan.actions) {
20425
20916
  if (a.kind === "enable") {
20426
20917
  if (prompter && !await prompter.confirm(`Route ${a.route} passes every check \u2014 set enabled: true in ${cfg.configFile}?`, false)) continue;
20427
- writeFileSync27(cfg.configFile, planEnable(readFileSync35(cfg.configFile, "utf8"), a.route));
20918
+ writeFileSync28(cfg.configFile, planEnable(readFileSync36(cfg.configFile, "utf8"), a.route));
20428
20919
  enabledSound.add(a.route);
20429
20920
  changed2.push(`enabled route ${a.route} in ${cfg.configFile}`);
20430
20921
  } else {
@@ -21166,7 +21657,7 @@ ${cu.tail.trimEnd()}
21166
21657
  const target = repoTargetFor(w.route, result.ticket.repo_id);
21167
21658
  fleetTicketRepo = target?.name;
21168
21659
  try {
21169
- if (target && ensureRepoCheckout(target.dir, target.remote)) {
21660
+ if (target && ensureRepoCheckout(target.dir, target.remote, { scheme: cfg.ship.cloneScheme })) {
21170
21661
  wemit.emit(`cloned ${target.name} into ${target.dir}`, { step: "select", role: w.role });
21171
21662
  }
21172
21663
  } catch (e) {
@@ -21383,7 +21874,7 @@ ${cu.tail.trimEnd()}
21383
21874
  const target = repoTargetFor(route2, result.ticket.repo_id);
21384
21875
  ticketRepo = target?.name;
21385
21876
  try {
21386
- if (target && ensureRepoCheckout(target.dir, target.remote)) {
21877
+ if (target && ensureRepoCheckout(target.dir, target.remote, { scheme: cfg.ship.cloneScheme })) {
21387
21878
  emit.emit(`cloned ${target.name} into ${target.dir}`, { step: "select", role: current });
21388
21879
  }
21389
21880
  } catch (e) {
@@ -21504,7 +21995,7 @@ ${cu.tail.trimEnd()}
21504
21995
  }
21505
21996
  let failed = false;
21506
21997
  for (const cmd2 of commands) {
21507
- const res2 = spawnSync7(cmd2[0], cmd2.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
21998
+ const res2 = spawnSync8(cmd2[0], cmd2.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
21508
21999
  if (res2.stdout) process.stdout.write(res2.stdout);
21509
22000
  if (res2.stderr) process.stderr.write(res2.stderr);
21510
22001
  if (cmd2 === control.startCommand && (res2.error || res2.status !== 0)) {
@@ -21522,7 +22013,7 @@ ${cu.tail.trimEnd()}
21522
22013
  `);
21523
22014
  break;
21524
22015
  }
21525
- const res = spawnSync7(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
22016
+ const res = spawnSync8(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
21526
22017
  if (res.stdout) process.stdout.write(res.stdout);
21527
22018
  if (res.stderr) process.stderr.write(res.stderr);
21528
22019
  if (daemonSub !== "status" && (res.error || res.status !== 0)) {
@@ -21806,10 +22297,10 @@ ${cu.tail.trimEnd()}
21806
22297
  `);
21807
22298
  if (!dryRun) {
21808
22299
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
21809
- const raw = existsSync40(resolvedPath) ? JSON.parse(readFileSync35(resolvedPath, "utf8")) : {};
22300
+ const raw = existsSync41(resolvedPath) ? JSON.parse(readFileSync36(resolvedPath, "utf8")) : {};
21810
22301
  raw.agentSkills = result.agentSkills;
21811
22302
  mkdirSync26(dirOf2(resolvedPath), { recursive: true });
21812
- writeFileSync27(resolvedPath, `${JSON.stringify(raw, null, 2)}
22303
+ writeFileSync28(resolvedPath, `${JSON.stringify(raw, null, 2)}
21813
22304
  `);
21814
22305
  }
21815
22306
  if (result.outcomes.some((o) => o.action === "diverged")) process.exitCode = 1;
@@ -21832,7 +22323,7 @@ ${cu.tail.trimEnd()}
21832
22323
  throw e;
21833
22324
  }
21834
22325
  const oldName = cfg.ship.name;
21835
- writeFileSync27(cfg.configFile, setShipNameInYaml(readFileSync35(cfg.configFile, "utf8"), newName));
22326
+ writeFileSync28(cfg.configFile, setShipNameInYaml(readFileSync36(cfg.configFile, "utf8"), newName));
21836
22327
  cfg.ship.name = newName;
21837
22328
  let renameMachineId;
21838
22329
  try {
@@ -21879,7 +22370,7 @@ ${cu.tail.trimEnd()}
21879
22370
  process.exit(2);
21880
22371
  };
21881
22372
  if (sub !== "add" && sub !== "list" && sub !== "clone" && sub !== "hooks" && sub !== "check") usage2();
21882
- const configText = readFileSync35(cfg.configFile, "utf8");
22373
+ const configText = readFileSync36(cfg.configFile, "utf8");
21883
22374
  if (sub === "check") {
21884
22375
  try {
21885
22376
  const routeArg = value("route") ?? positional[2];
@@ -21910,8 +22401,8 @@ ${cu.tail.trimEnd()}
21910
22401
  try {
21911
22402
  const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
21912
22403
  const dir = repoDirOf(configText, dirname22(cfg.configFile), a.route, a.name);
21913
- if (!existsSync40(dir)) throw new ReposError(`${dir} does not exist on disk`);
21914
- const files = { exists: (r) => existsSync40(resolvePath(dir, r)), list: (r) => {
22404
+ if (!existsSync41(dir)) throw new ReposError(`${dir} does not exist on disk`);
22405
+ const files = { exists: (r) => existsSync41(resolvePath(dir, r)), list: (r) => {
21915
22406
  try {
21916
22407
  return readdirSync18(resolvePath(dir, r));
21917
22408
  } catch {
@@ -21919,7 +22410,7 @@ ${cu.tail.trimEnd()}
21919
22410
  }
21920
22411
  }, read: (r) => {
21921
22412
  try {
21922
- return readFileSync35(resolvePath(dir, r), "utf8");
22413
+ return readFileSync36(resolvePath(dir, r), "utf8");
21923
22414
  } catch {
21924
22415
  return void 0;
21925
22416
  }
@@ -21961,19 +22452,19 @@ ${cu.tail.trimEnd()}
21961
22452
  let cleared;
21962
22453
  if (clear2) {
21963
22454
  const plan = clearRouteHooks(configText, a.route, a.name);
21964
- if (plan.removed.length > 0) writeFileSync27(cfg.configFile, plan.text);
22455
+ if (plan.removed.length > 0) writeFileSync28(cfg.configFile, plan.text);
21965
22456
  cleared = { file: cfg.configFile, removed: plan.removed };
21966
22457
  }
21967
22458
  if (write && suggestion.source === null && recipeIds.length === 0 && hookSpecs.length === 0) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
21968
22459
  if (write === "route") {
21969
22460
  const plan = planRouteHooks(configText, a.route, a.name, picked.hooks, { replace: flag("replace") });
21970
- writeFileSync27(cfg.configFile, plan.text);
22461
+ writeFileSync28(cfg.configFile, plan.text);
21971
22462
  written = { target: "route", file: cfg.configFile, hooks: plan.written, kept: plan.kept, replaced: plan.replaced };
21972
22463
  } else if (write === "repo") {
21973
22464
  const have = existingRepoConfig(files);
21974
22465
  if (have) throw new ReposError(`${resolvePath(dir, have)} already exists; not overwriting it`);
21975
22466
  const file = resolvePath(dir, ".crew.yaml");
21976
- writeFileSync27(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
22467
+ writeFileSync28(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
21977
22468
  written = { target: "repo", file, hooks: Object.keys(picked.hooks), kept: [], replaced: [] };
21978
22469
  }
21979
22470
  if (flag("json")) {
@@ -22029,7 +22520,7 @@ ${cu.tail.trimEnd()}
22029
22520
  if (sub === "list" && !routeName) usage2();
22030
22521
  if (sub === "add") {
22031
22522
  try {
22032
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync40(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
22523
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync41(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
22033
22524
  routeName = a.route;
22034
22525
  addPath = a.path;
22035
22526
  addName = a.name;
@@ -22058,16 +22549,16 @@ ${cu.tail.trimEnd()}
22058
22549
  }
22059
22550
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
22060
22551
  let resolvedRemotes;
22061
- if (existsSync40(resolvedFile)) {
22552
+ if (existsSync41(resolvedFile)) {
22062
22553
  try {
22063
- resolvedRemotes = JSON.parse(readFileSync35(resolvedFile, "utf8"));
22554
+ resolvedRemotes = JSON.parse(readFileSync36(resolvedFile, "utf8"));
22064
22555
  } catch {
22065
22556
  }
22066
22557
  }
22067
22558
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
22068
22559
  const text = configText;
22069
22560
  const base = dirname22(cfg.configFile);
22070
- const isCheckout = (d) => existsSync40(`${d}/.git`);
22561
+ const isCheckout = (d) => existsSync41(`${d}/.git`);
22071
22562
  try {
22072
22563
  if (sub === "list") {
22073
22564
  if (flag("json")) {
@@ -22077,11 +22568,11 @@ ${cu.tail.trimEnd()}
22077
22568
  base,
22078
22569
  resolved: resolvedRemotes,
22079
22570
  originOf,
22080
- exists: (d) => existsSync40(d),
22571
+ exists: (d) => existsSync41(d),
22081
22572
  isCheckout,
22082
22573
  repoHasHooks: (d) => REPO_CONFIG_FILENAMES.some((f) => {
22083
22574
  try {
22084
- return Object.keys(parseRepoConfig(readFileSync35(resolvePath(d, f), "utf8"), f).hooks).length > 0;
22575
+ return Object.keys(parseRepoConfig(readFileSync36(resolvePath(d, f), "utf8"), f).hooks).length > 0;
22085
22576
  } catch {
22086
22577
  return false;
22087
22578
  }
@@ -22098,7 +22589,7 @@ ${cu.tail.trimEnd()}
22098
22589
  for (const r of repos) {
22099
22590
  const origin2 = originOf(r.dir);
22100
22591
  const row = matchRepoName(origin2, resolvedRemotes);
22101
- const note = !existsSync40(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
22592
+ const note = !existsSync41(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
22102
22593
  process.stdout.write(`${r.name} ${r.dir} ${note}
22103
22594
  `);
22104
22595
  }
@@ -22110,9 +22601,9 @@ ${cu.tail.trimEnd()}
22110
22601
  ` : `${human}
22111
22602
  `);
22112
22603
  const plan2 = planRepoClone({ text, base, route: routeName, name: addName, resolved: resolvedRemotes, dir: value("dir"), isCheckout });
22113
- if (existsSync40(plan2.target) && (existsSync40(`${plan2.target}/.git`) || readdirSync18(plan2.target).length > 0)) {
22604
+ if (existsSync41(plan2.target) && (existsSync41(`${plan2.target}/.git`) || readdirSync18(plan2.target).length > 0)) {
22114
22605
  throw new ReposError(
22115
- existsSync40(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
22606
+ existsSync41(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
22116
22607
  );
22117
22608
  }
22118
22609
  if (dryRun) {
@@ -22130,14 +22621,14 @@ ${cu.tail.trimEnd()}
22130
22621
  throw new ReposError(`${e.message}${authHint}
22131
22622
  crew.yaml was not changed`);
22132
22623
  }
22133
- writeFileSync27(cfg.configFile, plan2.text);
22624
+ writeFileSync28(cfg.configFile, plan2.text);
22134
22625
  if (plan2.droppedDir) process.stderr.write(`crew repos: dropped dir: ${plan2.droppedDir} from route ${routeName} \u2014 it is not a checkout (the clone base); checkouts now live under repos:
22135
22626
  `);
22136
22627
  say("attached", { route: routeName, name: plan2.name, dir: plan2.target, mode: plan2.mode }, `cloned and attached ${plan2.target} to ${routeName} as ${plan2.mode === "dir" ? "dir" : `repos.${plan2.name}`} in ${cfg.configFile}`);
22137
22628
  break;
22138
22629
  }
22139
22630
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir10()));
22140
- const top = existsSync40(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
22631
+ const top = existsSync41(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
22141
22632
  if (!top) throw new ReposError(`${target} is not a git checkout`);
22142
22633
  const origin = originOf(top);
22143
22634
  const plan = planRepoAdd({
@@ -22189,15 +22680,15 @@ ${cu.tail.trimEnd()}
22189
22680
  const created = await rowTracker.createRepoRow(rowPayload);
22190
22681
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
22191
22682
  `);
22192
- if (existsSync40(resolvedFile)) {
22193
- const raw = JSON.parse(readFileSync35(resolvedFile, "utf8"));
22683
+ if (existsSync41(resolvedFile)) {
22684
+ const raw = JSON.parse(readFileSync36(resolvedFile, "utf8"));
22194
22685
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
22195
22686
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
22196
- writeFileSync27(resolvedFile, `${JSON.stringify(raw, null, 2)}
22687
+ writeFileSync28(resolvedFile, `${JSON.stringify(raw, null, 2)}
22197
22688
  `);
22198
22689
  }
22199
22690
  }
22200
- writeFileSync27(cfg.configFile, plan.text);
22691
+ writeFileSync28(cfg.configFile, plan.text);
22201
22692
  process.stdout.write(`added ${top} to ${routeName} as ${plan.mode === "dir" ? "dir" : `repos.${plan.name}`} in ${cfg.configFile}
22202
22693
  `);
22203
22694
  } catch (e) {
@@ -22408,7 +22899,7 @@ ${reconstruction.prompt}
22408
22899
  let result;
22409
22900
  try {
22410
22901
  result = await loginWithDeviceCode(`${baseUrl}/api`, {
22411
- deviceName: `crew on ${hostname2()}`,
22902
+ deviceName: `crew on ${hostname3()}`,
22412
22903
  // Lets the approval page preselect the workspace this connect is for.
22413
22904
  // A bare `crew connect` has none and passes nothing.
22414
22905
  ...workspace ? { workspaceSlug: workspace } : {},
@@ -22564,7 +23055,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
22564
23055
  process.exit(2);
22565
23056
  }
22566
23057
  if (found.shipNameAdopted && !dryRun) {
22567
- writeFileSync27(cfg.configFile, setShipNameInYaml(readFileSync35(cfg.configFile, "utf8"), found.shipNameAdopted));
23058
+ writeFileSync28(cfg.configFile, setShipNameInYaml(readFileSync36(cfg.configFile, "utf8"), found.shipNameAdopted));
22568
23059
  cfg.ship.name = found.shipNameAdopted;
22569
23060
  }
22570
23061
  if (found.shipRow) {
@@ -22607,10 +23098,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
22607
23098
  }
22608
23099
  }
22609
23100
  let previous;
22610
- const hadPreviousFile = existsSync40(resolvedPath);
23101
+ const hadPreviousFile = existsSync41(resolvedPath);
22611
23102
  if (hadPreviousFile) {
22612
23103
  try {
22613
- previous = JSON.parse(readFileSync35(resolvedPath, "utf8"));
23104
+ previous = JSON.parse(readFileSync36(resolvedPath, "utf8"));
22614
23105
  } catch {
22615
23106
  }
22616
23107
  }
@@ -22742,7 +23233,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
22742
23233
  mkdirSync26(dirname22(resolvedPath), { recursive: true });
22743
23234
  if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
22744
23235
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
22745
- writeFileSync27(tmpPath, `${JSON.stringify(resolved, null, 2)}
23236
+ writeFileSync28(tmpPath, `${JSON.stringify(resolved, null, 2)}
22746
23237
  `);
22747
23238
  renameSync4(tmpPath, resolvedPath);
22748
23239
  process.stderr.write(`Resolved ids written to ${resolvedPath}
@@ -22789,7 +23280,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
22789
23280
  `);
22790
23281
  } else {
22791
23282
  mkdirSync26(dirname22(apiKeyFile), { recursive: true });
22792
- writeFileSync27(apiKeyFile, `${apiKeyVar}=${apiKey}
23283
+ writeFileSync28(apiKeyFile, `${apiKeyVar}=${apiKey}
22793
23284
  `, { mode: 384 });
22794
23285
  process.stderr.write(`API key written to ${apiKeyFile}
22795
23286
  `);
@@ -23523,7 +24014,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
23523
24014
  const shellR = hit && resolveShell(hit.repo.config.shell ?? cfg.ship.shell);
23524
24015
  const resumed = await runPeriscopeResume({
23525
24016
  find: async () => hit && { row: hit.row, tag: hit.tag },
23526
- worktreeExists: (p) => existsSync40(p),
24017
+ worktreeExists: (p) => existsSync41(p),
23527
24018
  caps: periscopeCaps(cfg.ship),
23528
24019
  runHook: async (name, cwd, env) => {
23529
24020
  const script = hit?.repo.config.hooks[name];
@@ -23618,7 +24109,7 @@ ${where.join("\n")}`);
23618
24109
  prepare: async (path) => {
23619
24110
  for (const f of repoP.config.worktrees.copy) {
23620
24111
  const src = join44(repoP.dir, f);
23621
- if (!existsSync40(src)) continue;
24112
+ if (!existsSync41(src)) continue;
23622
24113
  mkdirSync26(dirOf2(join44(path, f)), { recursive: true });
23623
24114
  copyFileSync2(src, join44(path, f));
23624
24115
  }
@@ -23706,7 +24197,7 @@ ${where.join("\n")}`);
23706
24197
  process.stdout.write(upOutput(res, terminal));
23707
24198
  const opener = openerFor(process.platform);
23708
24199
  if (opener && shouldOpen({ open: flag("open"), noOpen: flag("no-open"), json: jsonP, terminal })) {
23709
- const r = spawnSync7(opener, [res.url], { stdio: "ignore" });
24200
+ const r = spawnSync8(opener, [res.url], { stdio: "ignore" });
23710
24201
  if (r.error || r.status !== 0) process.stderr.write(`crew periscope: could not open ${res.url} with ${opener}
23711
24202
  `);
23712
24203
  }
@@ -23895,7 +24386,7 @@ ${where.join("\n")}`);
23895
24386
  };
23896
24387
  const droppedRoutes = [];
23897
24388
  try {
23898
- const rawCfg = parseYaml2(readFileSync35(cfg.configFile, "utf8"));
24389
+ const rawCfg = parseYaml2(readFileSync36(cfg.configFile, "utf8"));
23899
24390
  for (const r of rawCfg?.routes ?? []) {
23900
24391
  if (typeof r?.route !== "string" || cfg.routes.some((x) => x.route === r.route)) continue;
23901
24392
  try {
@@ -23931,19 +24422,19 @@ ${where.join("\n")}`);
23931
24422
  }
23932
24423
  const host = hostPlatform();
23933
24424
  const control = host === "windows" ? null : planDaemonControl(CREW_HOME, host, detectSystemd());
23934
- const unitStatus = control?.statusCommand ? spawnSync7(control.statusCommand[0], control.statusCommand.slice(1), { cwd: CREW_HOME, encoding: "utf8" }) : null;
24425
+ const unitStatus = control?.statusCommand ? spawnSync8(control.statusCommand[0], control.statusCommand.slice(1), { cwd: CREW_HOME, encoding: "utf8" }) : null;
23935
24426
  const unitRunning = unitStatus !== null && !unitStatus.error && unitStatus.status === 0;
23936
24427
  if (!unitRunning) {
23937
24428
  process.stdout.write("crew reload: no daemon unit is running \u2014 nothing to reload (the next run reads crew.yaml fresh)\n");
23938
24429
  break;
23939
24430
  }
23940
24431
  mkdirSync26(cfg.ship.stateDir, { recursive: true });
23941
- writeFileSync27(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
24432
+ writeFileSync28(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
23942
24433
  `);
23943
24434
  process.stdout.write("crew reload: requested \u2014 the daemon finishes its in-flight pass, then relaunches with the new config\n");
23944
24435
  if (flag("now") && control?.stopCommand && control.startCommand) {
23945
24436
  for (const cmd of [control.stopCommand, control.startCommand]) {
23946
- const res = spawnSync7(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
24437
+ const res = spawnSync8(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
23947
24438
  if (cmd === control.startCommand && (res.error || res.status !== 0)) {
23948
24439
  process.stderr.write(`crew reload: ${cmd.join(" ")} exited ${res.status ?? "error"}
23949
24440
  `);
@@ -23989,7 +24480,7 @@ ${where.join("\n")}`);
23989
24480
  }
23990
24481
  case "log": {
23991
24482
  try {
23992
- const text = readFileSync35(cfg.ship.logFile, "utf8").split("\n");
24483
+ const text = readFileSync36(cfg.ship.logFile, "utf8").split("\n");
23993
24484
  process.stdout.write(`${text.slice(-(Number(value("tail")) || 40)).join("\n")}
23994
24485
  `);
23995
24486
  } catch {
@@ -24012,7 +24503,7 @@ ${where.join("\n")}`);
24012
24503
  };
24013
24504
  if (!route2.resolved) fail(`route ${route2.route} is not connected \u2014 run \`crew connect\` first`);
24014
24505
  const tracker = new Tracker(route2, cfg.ship);
24015
- const configText = readFileSync35(cfg.configFile, "utf8");
24506
+ const configText = readFileSync36(cfg.configFile, "utf8");
24016
24507
  const cfgBase = dirname22(cfg.configFile);
24017
24508
  const configured2 = configuredRepos(configText, route2.route, cfgBase);
24018
24509
  const dir = configured2.find((r) => r.name === SAMPLE_REPO)?.dir ?? sampleDir(configured2.map((r) => r.dir), route2.reposBasePath, route2.route.split("/")[0]);
@@ -24041,7 +24532,7 @@ ${where.join("\n")}`);
24041
24532
  }
24042
24533
  if (configured2.some((r) => r.dir === dir)) {
24043
24534
  try {
24044
- writeFileSync27(cfg.configFile, planRepoRemove(configText, cfgBase, route2.route, dir).text);
24535
+ writeFileSync28(cfg.configFile, planRepoRemove(configText, cfgBase, route2.route, dir).text);
24045
24536
  } catch (e) {
24046
24537
  if (e instanceof ReposError) fail(e.message);
24047
24538
  throw e;
@@ -24049,31 +24540,31 @@ ${where.join("\n")}`);
24049
24540
  }
24050
24541
  if (row) {
24051
24542
  await tracker.deleteRepoRow(row.id);
24052
- if (existsSync40(resolvedFile)) {
24053
- const raw = JSON.parse(readFileSync35(resolvedFile, "utf8"));
24543
+ if (existsSync41(resolvedFile)) {
24544
+ const raw = JSON.parse(readFileSync36(resolvedFile, "utf8"));
24054
24545
  if (raw.repoNames) delete raw.repoNames[row.id];
24055
24546
  if (raw.repoRemotes) delete raw.repoRemotes[row.id];
24056
- writeFileSync27(resolvedFile, `${JSON.stringify(raw, null, 2)}
24547
+ writeFileSync28(resolvedFile, `${JSON.stringify(raw, null, 2)}
24057
24548
  `);
24058
24549
  }
24059
24550
  }
24060
- if (existsSync40(join44(dir, ".git")) && basename13(dir) === SAMPLE_REPO) rmSync19(dir, { recursive: true, force: true });
24551
+ if (existsSync41(join44(dir, ".git")) && basename13(dir) === SAMPLE_REPO) rmSync19(dir, { recursive: true, force: true });
24061
24552
  say("removed", { dir, closed }, `removed the sample repo${closed.length ? `, closed ${closed.join(", ")}` : ""}`);
24062
24553
  break;
24063
24554
  }
24064
24555
  const projectId = route2.resolved.areaId ?? resolveRepoProject(await tracker.projectRows(), value("project"));
24065
- const fresh = !existsSync40(join44(dir, ".git"));
24066
- if (existsSync40(dir) && fresh && readdirSync18(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
24556
+ const fresh = !existsSync41(join44(dir, ".git"));
24557
+ if (existsSync41(dir) && fresh && readdirSync18(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
24067
24558
  if (dryRun) {
24068
24559
  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`);
24069
24560
  break;
24070
24561
  }
24071
24562
  if (fresh) {
24072
24563
  mkdirSync26(dir, { recursive: true });
24073
- writeFileSync27(join44(dir, "README.md"), SAMPLE_README);
24074
- writeFileSync27(join44(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
24564
+ writeFileSync28(join44(dir, "README.md"), SAMPLE_README);
24565
+ writeFileSync28(join44(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
24075
24566
  const g = (...a) => {
24076
- const r = spawnSync7("git", a, { cwd: dir, encoding: "utf8" });
24567
+ const r = spawnSync8("git", a, { cwd: dir, encoding: "utf8" });
24077
24568
  if (r.status !== 0) fail(`git ${a[0]} failed in ${dir}: ${(r.stderr || r.stdout).trim()}`);
24078
24569
  };
24079
24570
  g("init", "-b", "main");
@@ -24089,17 +24580,17 @@ ${where.join("\n")}`);
24089
24580
  spec: { platform: "unix", releaseMode: "integrate", ciProvider: "none" }
24090
24581
  });
24091
24582
  repoId = (await tracker.createRepoRow(payload)).id;
24092
- if (existsSync40(resolvedFile)) {
24093
- const raw = JSON.parse(readFileSync35(resolvedFile, "utf8"));
24583
+ if (existsSync41(resolvedFile)) {
24584
+ const raw = JSON.parse(readFileSync36(resolvedFile, "utf8"));
24094
24585
  raw.repoNames = { ...raw.repoNames, [repoId]: SAMPLE_REPO };
24095
24586
  raw.repoRemotes = { ...raw.repoRemotes, [repoId]: SAMPLE_REMOTE };
24096
- writeFileSync27(resolvedFile, `${JSON.stringify(raw, null, 2)}
24587
+ writeFileSync28(resolvedFile, `${JSON.stringify(raw, null, 2)}
24097
24588
  `);
24098
24589
  }
24099
24590
  }
24100
24591
  if (!configured2.some((r) => r.dir === dir)) {
24101
24592
  try {
24102
- writeFileSync27(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route2.route, path: dir, name: SAMPLE_REPO }).text);
24593
+ writeFileSync28(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route2.route, path: dir, name: SAMPLE_REPO }).text);
24103
24594
  } catch (e) {
24104
24595
  if (e instanceof ReposError) fail(e.message);
24105
24596
  throw e;
@@ -24403,7 +24894,7 @@ watermark: ${state.watermark()}
24403
24894
  );
24404
24895
  }
24405
24896
  for (const x of runs) {
24406
- process.stdout.write(`running: ${x.role} \u2014 ${x.ticket ?? "(no ticket)"}${x.repo ? ` in ${x.repo}` : ""} (pid ${x.pid}, since ${x.spawnedAt})
24897
+ process.stdout.write(`running: ${x.role} \u2014 ${x.ticket ?? "(no ticket)"}${x.repo ? ` in ${x.repo}` : ""} (pid ${x.pid}, since ${x.spawnedAt}${x.deadline ? `, deadline ${x.deadline}` : ""})
24407
24898
  `);
24408
24899
  }
24409
24900
  if (shipRows.length) {
@@ -24434,7 +24925,7 @@ watermark: ${state.watermark()}
24434
24925
  }
24435
24926
  for (const r of repos) {
24436
24927
  const remote = r.config.branch.remote;
24437
- if (existsSync40(r.dir)) {
24928
+ if (existsSync41(r.dir)) {
24438
24929
  pushAuth.push({
24439
24930
  route: c.route,
24440
24931
  repo: r.name,
@@ -24519,7 +25010,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
24519
25010
  }
24520
25011
  try {
24521
25012
  const evFile = eventFileFor(cfg.ship.stateDir);
24522
- const evSize = existsSync40(evFile) ? statSync14(evFile).size : 0;
25013
+ const evSize = existsSync41(evFile) ? statSync14(evFile).size : 0;
24523
25014
  process.stdout.write(
24524
25015
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
24525
25016
  ` : "events log: ok\n"
@@ -24527,10 +25018,10 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
24527
25018
  } catch {
24528
25019
  }
24529
25020
  if (host !== "windows") {
24530
- const unitPlan = planInstall(cfg.ship, CREW_HOME, host, detectSystemd(), "run");
25021
+ const unitPlan = planInstall(cfg.ship, CREW_HOME, host, detectSystemd(), "run", installContext());
24531
25022
  const abandons = unitAbandonsAgents(unitPlan, (p) => {
24532
25023
  try {
24533
- return readFileSync35(p, "utf8");
25024
+ return readFileSync36(p, "utf8");
24534
25025
  } catch {
24535
25026
  return null;
24536
25027
  }
@@ -24613,6 +25104,11 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
24613
25104
  const missingAuthorship = await tracker.missingAuthorshipColumns();
24614
25105
  process.stdout.write(
24615
25106
  missingAuthorship.length === 0 ? "authorship: filed_by_id, ship_id present\n" : `authorship: missing ${missingAuthorship.join(", ")} \u2014 update the Issues template
25107
+ `
25108
+ );
25109
+ const missingRuntime = await tracker.missingRuntimeColumns();
25110
+ process.stdout.write(
25111
+ missingRuntime.length === 0 ? "runtime columns: forge_host, kind, instance_class, relay_url present\n" : `runtime columns: missing ${missingRuntime.join(", ")} \u2014 read as their defaults; update the Issues template
24616
25112
  `
24617
25113
  );
24618
25114
  process.stdout.write(