@tablation/crew 0.36.0 → 0.38.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/README.md CHANGED
@@ -36,13 +36,19 @@ prompt set (the three-lane dev/design/QA policy this repo ships with — see
36
36
  A fifth persona, **pair**, rides `crew agents sync` alongside these four but
37
37
  is not polled: it is the identity a live, interactive session (an IDE
38
38
  conversation, not a scheduled run) uses when it touches the tracker. It has
39
- no Crew-table seat, is never assigned a ticket by the poll loop, and its
40
- brief (`prompts/default/personas/lane-pair.md`) is not prefixed with
41
- `common.md` — none of the polling loop's shared policy describes it. `crew
39
+ no Crew-table seat and is never assigned a ticket by the poll loop. `crew
42
40
  agents prompt pair` prints its current persona text (a workspace admin's
43
41
  live edit if there is one, else the local default) for something like a
44
42
  `SessionStart` hook to feed into a session at start.
45
43
 
44
+ `common.md` — the building lanes' shared policy (worktrees, one stateless
45
+ invocation per cycle, the dev/design/QA hand-off) — is prefixed to the
46
+ **dev**, **design** and **qa** briefs only. **triage** and **pair** are
47
+ self-contained (`SELF_CONTAINED_ROLES` in `src/config.ts`): neither cuts a
48
+ worktree, builds or hands anything to QA, so the policy would only
49
+ misdescribe them. The prompts carry rules, not history; the incidents behind
50
+ each rule are kept in [`docs/prompt-rationale.md`](docs/prompt-rationale.md).
51
+
46
52
  Alongside personas, a prompt set can also ship **skills** —
47
53
  `prompts/<promptSet>/skills/*.md`, each one YAML frontmatter (`name`,
48
54
  `description`) plus a markdown prompt body. A skill isn't a seat: it's not
@@ -55,7 +61,14 @@ diverged sync narrowed to skills alone, for when you've only touched
55
61
  `skills/`. Example use — a Pair session invoked via an Epic's
56
62
  `grill_link` looks up the `Grill-Me` skill through the Tablation MCP tool
57
63
  `agent_skills_controller_get_skill` and follows it. `prompts/default/skills/
58
- grill-me.md` is the shipped example.
64
+ grill-me.md` is one shipped example; the rest are the building lanes'
65
+ situational procedures (`Worktree Setup`, `Running the Suite`, `Question and
66
+ Investigation Tickets`, `Filing a Ticket`, `Ticket Attachments`, `Workspace
67
+ Change Authorization`, `Design Pass`), which `common.md` names at the step
68
+ that needs each one — every run keeps the rule and the minimum inline and
69
+ fetches the full procedure only when it applies, so a dev run that never
70
+ files a ticket never pays for the filing procedure. `test/prompts.test.ts`
71
+ checks that every skill a persona names ships in the same preset.
59
72
 
60
73
  One role runs per cycle, whichever holds the most urgent actionable ticket
61
74
  (`src/priority.ts` decides, and the same module sorts the digest that agent is
@@ -100,10 +113,12 @@ edit its prose, and point `promptSet` at the copy (`~/my-crew-policy` or a
100
113
  relative path) — no fork of this repo required. A prompt set is a complete,
101
114
  self-contained fork of `personas/common.md` + one `personas/lane-<role>.md`
102
115
  per seat, plus whatever `skills/*.md` it wants, not a diff against another
103
- one; each persona file is read as plain prose concatenated ahead of the
104
- per-run roster/environment/queue sections
116
+ one; each persona file is read as plain prose (common.md ahead of a building
117
+ lane's brief, a self-contained brief alone) and concatenated after the
118
+ per-run roster/environment sections and before the queue digest
105
119
  (`src/agent.ts`'s `assemblePrompt`), so there's no templating layer to
106
- learn. `prompts/dev-qa/` is a worked example of trimming a lane out of
120
+ learn. Keep the prose generic: `test/prompts.test.ts` fails a preset that
121
+ names a path, a command, an id, a ticket reference or a date. `prompts/dev-qa/` is a worked example of trimming a lane out of
107
122
  `default` cleanly, including the unused `lane-design.md` stub every
108
123
  preset still needs today — see the next paragraph for why.
109
124
 
package/dist/cli.js CHANGED
@@ -1806,6 +1806,7 @@ var DEFAULT_BASE_URL = "https://app.tablation.com";
1806
1806
  var DEFAULT_MAX_THINKING_TOKENS = 4096;
1807
1807
  var DEFAULT_REPOS_BASE_PATH = "~/Crew";
1808
1808
  var ROLE_NAMES = ["dev", "design", "qa", "triage", "pair"];
1809
+ var SELF_CONTAINED_ROLES = /* @__PURE__ */ new Set(["pair", "triage"]);
1809
1810
  var ROLE_LABEL = {
1810
1811
  dev: "Dev",
1811
1812
  design: "Design",
@@ -7011,7 +7012,8 @@ function planAgentRun(o) {
7011
7012
  throw new AgentError(`no brief at ${briefPath} \u2014 refusing to run an unscoped session`);
7012
7013
  }
7013
7014
  const commonPath = join10(o.route.promptsDir, "personas", "common.md");
7014
- if (!existsSync9(commonPath)) throw new AgentError(`no shared policy at ${commonPath}`);
7015
+ const usesCommon = !SELF_CONTAINED_ROLES.has(o.role);
7016
+ if (usesCommon && !existsSync9(commonPath)) throw new AgentError(`no shared policy at ${commonPath}`);
7015
7017
  const now = o.now ?? (() => Date.now());
7016
7018
  const routeLabel = routeSlug(o.route.route);
7017
7019
  const digestPath2 = join10(o.stateDir, `digest-${routeLabel}-${o.role}.md`);
@@ -7021,7 +7023,7 @@ function planAgentRun(o) {
7021
7023
  ageSeconds = Math.floor((now() - statSync5(digestPath2).mtimeMs) / 1e3);
7022
7024
  if (ageSeconds <= DIGEST_MAX_AGE_SECONDS) digest = readFileSync8(digestPath2, "utf8");
7023
7025
  }
7024
- const common = readFileSync8(commonPath, "utf8");
7026
+ const common = usesCommon ? readFileSync8(commonPath, "utf8") : "";
7025
7027
  const brief = readFileSync8(briefPath, "utf8");
7026
7028
  const effectiveCommon = o.divergedPrompt ? "" : common;
7027
7029
  const effectiveBrief = o.divergedPrompt ?? brief;
@@ -9385,7 +9387,7 @@ var AgentsSyncError = class extends Error {
9385
9387
  function personaDefaultPrompt(promptsDir, role) {
9386
9388
  const briefPath = join15(promptsDir, "personas", `lane-${role}.md`);
9387
9389
  if (!existsSync15(briefPath)) throw new AgentsSyncError(`no brief at ${briefPath}`);
9388
- if (role === "pair") return readFileSync10(briefPath, "utf8");
9390
+ if (SELF_CONTAINED_ROLES.has(role)) return readFileSync10(briefPath, "utf8");
9389
9391
  const commonPath = join15(promptsDir, "personas", "common.md");
9390
9392
  if (!existsSync15(commonPath)) throw new AgentsSyncError(`no shared policy at ${commonPath}`);
9391
9393
  return `${readFileSync10(commonPath, "utf8")}${readFileSync10(briefPath, "utf8")}`;
@@ -10616,7 +10618,7 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
10616
10618
  }
10617
10619
 
10618
10620
  // src/cli.ts
10619
- import { readFileSync as readFileSync15, existsSync as existsSync22, mkdirSync as mkdirSync12, writeFileSync as writeFileSync12, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9 } from "fs";
10621
+ import { readFileSync as readFileSync16, existsSync as existsSync23, mkdirSync as mkdirSync13, writeFileSync as writeFileSync13, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9 } from "fs";
10620
10622
  import { createInterface } from "readline/promises";
10621
10623
  import { dirname as dirOf, resolve as resolvePath } from "path";
10622
10624
 
@@ -11354,7 +11356,7 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
11354
11356
  }
11355
11357
 
11356
11358
  // src/daemon.ts
11357
- import { readdirSync as readdirSync6, statSync as statSync8 } from "fs";
11359
+ import { existsSync as existsSync22, mkdirSync as mkdirSync12, readFileSync as readFileSync15, readdirSync as readdirSync6, rmSync as rmSync8, statSync as statSync8, writeFileSync as writeFileSync12 } from "fs";
11358
11360
  import { join as join21 } from "path";
11359
11361
  var DEFAULT_FLOOR_MS = 5e3;
11360
11362
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
@@ -11401,9 +11403,38 @@ function latestMtime(path) {
11401
11403
  function computeTreeSignature(paths) {
11402
11404
  return paths.map((p) => String(latestMtime(p))).join(":");
11403
11405
  }
11404
- function makeStaleChecker(paths) {
11406
+ function makeStaleChecker(paths, opts = {}) {
11405
11407
  const initial = computeTreeSignature(paths);
11406
- return () => computeTreeSignature(paths) !== initial;
11408
+ return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync22(opts.reloadSentinel);
11409
+ }
11410
+ function reloadSentinelFor(stateDir) {
11411
+ return join21(stateDir, "reload-requested");
11412
+ }
11413
+ function daemonStampFor(stateDir) {
11414
+ return join21(stateDir, "daemon.started");
11415
+ }
11416
+ function noteDaemonStarted(stateDir, now = Date.now()) {
11417
+ mkdirSync12(stateDir, { recursive: true });
11418
+ rmSync8(reloadSentinelFor(stateDir), { force: true });
11419
+ writeFileSync12(daemonStampFor(stateDir), `${JSON.stringify({ pid: process.pid, startedAt: now })}
11420
+ `);
11421
+ }
11422
+ function readDaemonStarted(stateDir) {
11423
+ try {
11424
+ const v = JSON.parse(readFileSync15(daemonStampFor(stateDir), "utf8")).startedAt;
11425
+ return typeof v === "number" ? v : null;
11426
+ } catch {
11427
+ return null;
11428
+ }
11429
+ }
11430
+ function configChangedAfter(files, startedAt) {
11431
+ for (const f of files) {
11432
+ try {
11433
+ if (statSync8(f).mtimeMs > startedAt) return f;
11434
+ } catch {
11435
+ }
11436
+ }
11437
+ return null;
11407
11438
  }
11408
11439
  var SPAWN_PARK_AFTER = 3;
11409
11440
  var SpawnBreaker = class {
@@ -11804,6 +11835,7 @@ function usage() {
11804
11835
  crew watch [route] live view of what the crew is doing
11805
11836
  crew status [route] [--json] paused/running state; --json for a machine reader
11806
11837
  crew projects [--route R] [--json] the Projects rows each route can read (id, name, prefix, work mode)
11838
+ crew reload make the running daemon re-read crew.yaml (--now: restart it immediately)
11807
11839
  crew doctor [route] [--fix] preflight; --fix enables clean routes and offers crew install
11808
11840
  crew ports [route] which checkout owns which ports, and what is up
11809
11841
  crew reap [route] kill orphaned servers, drop worktrees for closed tickets
@@ -11858,7 +11890,12 @@ var value = (name) => {
11858
11890
  var positional = argv.filter((a, i) => !a.startsWith("--") && !argv[i - 1]?.startsWith("--"));
11859
11891
  var command = positional[0];
11860
11892
  if (!command) usage();
11861
- var STATE_WRITING_COMMANDS = /* @__PURE__ */ new Set(["connect", "disconnect", "install", "uninstall", "release", "merge", "deploy"]);
11893
+ function daemonConfigPaths(c) {
11894
+ const files = [c.configFile];
11895
+ for (const r of c.routes) if (r.apiKeyFile && !files.includes(r.apiKeyFile)) files.push(r.apiKeyFile);
11896
+ return files;
11897
+ }
11898
+ var STATE_WRITING_COMMANDS = /* @__PURE__ */ new Set(["connect", "disconnect", "install", "uninstall", "reload", "release", "merge", "deploy"]);
11862
11899
  var STATE_WRITING_SUBCOMMANDS = {
11863
11900
  agents: /* @__PURE__ */ new Set(["sync"]),
11864
11901
  skills: /* @__PURE__ */ new Set(["sync"])
@@ -11968,7 +12005,7 @@ if (command === "passenger-sync-daemon") {
11968
12005
  const releaseFleetWide = ["merge", "deploy", "release"].includes(command) && flag("fleet");
11969
12006
  const DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set(["start", "stop", "status", "restart"]);
11970
12007
  const named = command === "daemon" && DAEMON_SUBCOMMANDS.has(positional[1] ?? "") ? positional[2] : positional[1];
11971
- const fleetWide = command === "inbox" || command === "connect" || command === "disconnect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || command === "projects" || releaseFleetWide || command === "pause" || command === "resume" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1;
12008
+ const fleetWide = command === "inbox" || command === "connect" || command === "disconnect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || command === "projects" || releaseFleetWide || command === "pause" || command === "resume" || command === "reload" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1;
11972
12009
  let route;
11973
12010
  try {
11974
12011
  route = fleetWide ? cfg.routes[0] : findRoute(cfg, named);
@@ -12175,7 +12212,7 @@ if (command === "passenger-sync-daemon") {
12175
12212
  if (host === "linux" && hasSystemd) {
12176
12213
  const label = labelFor(CREW_HOME, "run");
12177
12214
  const staleTimer = join22(homedir7(), ".config", "systemd", "user", `${label}.timer`);
12178
- if (existsSync22(staleTimer)) {
12215
+ if (existsSync23(staleTimer)) {
12179
12216
  process.stdout.write(
12180
12217
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
12181
12218
  `
@@ -12201,7 +12238,7 @@ if (command === "passenger-sync-daemon") {
12201
12238
  const tracker = new Tracker(c, cfg.ship);
12202
12239
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
12203
12240
  for (const r of await resolvedRepos(c)) {
12204
- if (!existsSync22(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
12241
+ if (!existsSync23(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
12205
12242
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
12206
12243
  problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
12207
12244
  }
@@ -12219,7 +12256,7 @@ if (command === "passenger-sync-daemon") {
12219
12256
  }
12220
12257
  async function doctorFix(o) {
12221
12258
  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))));
12222
- const unitExists = (p) => existsSync22(p);
12259
+ const unitExists = (p) => existsSync23(p);
12223
12260
  const crontab = o.host === "linux" ? spawnSync5("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
12224
12261
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
12225
12262
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -12236,7 +12273,7 @@ if (command === "passenger-sync-daemon") {
12236
12273
  for (const a of plan.actions) {
12237
12274
  if (a.kind === "enable") {
12238
12275
  if (prompter && !await prompter.confirm(`Route ${a.route} passes every check \u2014 set enabled: true in ${cfg.configFile}?`, false)) continue;
12239
- writeFileSync12(cfg.configFile, planEnable(readFileSync15(cfg.configFile, "utf8"), a.route));
12276
+ writeFileSync13(cfg.configFile, planEnable(readFileSync16(cfg.configFile, "utf8"), a.route));
12240
12277
  enabledSound.add(a.route);
12241
12278
  changed2.push(`enabled route ${a.route} in ${cfg.configFile}`);
12242
12279
  } else {
@@ -13141,6 +13178,9 @@ ${changed2.map((c) => ` - ${c}
13141
13178
  }
13142
13179
  requireArmed("run the daemon loop");
13143
13180
  const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join22(CREW_HOME, "src"), join22(CREW_HOME, "dist"), join22(CREW_HOME, "bin")];
13181
+ const configPaths = daemonConfigPaths(cfg);
13182
+ watchPaths.push(...configPaths);
13183
+ noteDaemonStarted(cfg.ship.stateDir);
13144
13184
  const spawnBreaker = new SpawnBreaker();
13145
13185
  const stopReason = await runDaemonLoop({
13146
13186
  runPass: () => runOnePass({
@@ -13158,14 +13198,16 @@ ${changed2.map((c) => ` - ${c}
13158
13198
  `)
13159
13199
  })
13160
13200
  }),
13161
- checkStale: makeStaleChecker(watchPaths),
13201
+ checkStale: makeStaleChecker(watchPaths, { reloadSentinel: reloadSentinelFor(cfg.ship.stateDir) }),
13162
13202
  onPass: (result) => {
13163
13203
  if (result.started.length) emit.emit(`daemon pass started: ${result.started.join(", ")}`);
13164
13204
  for (const s of result.skipped) emit.emit(`daemon pass skipped ${s.role} \u2014 ${s.reason}`);
13165
13205
  }
13166
13206
  });
13167
13207
  if (stopReason === "stale") {
13168
- emit.emit("crew daemon: installed tree changed on disk \u2014 exiting cleanly so the OS-level restart relaunches with the update");
13208
+ emit.emit(
13209
+ "crew daemon: installed tree, crew.yaml or a reload request changed on disk \u2014 exiting cleanly so the OS-level restart relaunches with the update"
13210
+ );
13169
13211
  process.exit(0);
13170
13212
  }
13171
13213
  break;
@@ -13289,11 +13331,11 @@ ${changed2.map((c) => ` - ${c}
13289
13331
  `);
13290
13332
  if (!dryRun) {
13291
13333
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
13292
- const raw = existsSync22(resolvedPath) ? JSON.parse(readFileSync15(resolvedPath, "utf8")) : {};
13334
+ const raw = existsSync23(resolvedPath) ? JSON.parse(readFileSync16(resolvedPath, "utf8")) : {};
13293
13335
  raw.agentPersonas = personaResult.agentPersonas;
13294
13336
  raw.agentSkills = skillResult.agentSkills;
13295
- mkdirSync12(dirOf(resolvedPath), { recursive: true });
13296
- writeFileSync12(resolvedPath, `${JSON.stringify(raw, null, 2)}
13337
+ mkdirSync13(dirOf(resolvedPath), { recursive: true });
13338
+ writeFileSync13(resolvedPath, `${JSON.stringify(raw, null, 2)}
13297
13339
  `);
13298
13340
  }
13299
13341
  if (personaResult.outcomes.some((o) => o.action === "diverged") || skillResult.outcomes.some((o) => o.action === "diverged")) {
@@ -13338,10 +13380,10 @@ ${changed2.map((c) => ` - ${c}
13338
13380
  `);
13339
13381
  if (!dryRun) {
13340
13382
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
13341
- const raw = existsSync22(resolvedPath) ? JSON.parse(readFileSync15(resolvedPath, "utf8")) : {};
13383
+ const raw = existsSync23(resolvedPath) ? JSON.parse(readFileSync16(resolvedPath, "utf8")) : {};
13342
13384
  raw.agentSkills = result.agentSkills;
13343
- mkdirSync12(dirOf(resolvedPath), { recursive: true });
13344
- writeFileSync12(resolvedPath, `${JSON.stringify(raw, null, 2)}
13385
+ mkdirSync13(dirOf(resolvedPath), { recursive: true });
13386
+ writeFileSync13(resolvedPath, `${JSON.stringify(raw, null, 2)}
13345
13387
  `);
13346
13388
  }
13347
13389
  if (result.outcomes.some((o) => o.action === "diverged")) process.exitCode = 1;
@@ -13354,14 +13396,14 @@ ${changed2.map((c) => ` - ${c}
13354
13396
  process.exit(2);
13355
13397
  };
13356
13398
  if (sub !== "add" && sub !== "list") usage2();
13357
- const configText = readFileSync15(cfg.configFile, "utf8");
13399
+ const configText = readFileSync16(cfg.configFile, "utf8");
13358
13400
  let routeName = positional[2];
13359
13401
  let addPath = ".";
13360
13402
  let addName;
13361
13403
  if (sub === "list" && !routeName) usage2();
13362
13404
  if (sub === "add") {
13363
13405
  try {
13364
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync22(resolvePath(p.replace(/^~(?=\/|$)/, homedir7()))));
13406
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync23(resolvePath(p.replace(/^~(?=\/|$)/, homedir7()))));
13365
13407
  routeName = a.route;
13366
13408
  addPath = a.path;
13367
13409
  addName = a.name;
@@ -13376,9 +13418,9 @@ ${changed2.map((c) => ` - ${c}
13376
13418
  }
13377
13419
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
13378
13420
  let resolvedRemotes;
13379
- if (existsSync22(resolvedFile)) {
13421
+ if (existsSync23(resolvedFile)) {
13380
13422
  try {
13381
- resolvedRemotes = JSON.parse(readFileSync15(resolvedFile, "utf8"));
13423
+ resolvedRemotes = JSON.parse(readFileSync16(resolvedFile, "utf8"));
13382
13424
  } catch {
13383
13425
  }
13384
13426
  }
@@ -13393,14 +13435,14 @@ ${changed2.map((c) => ` - ${c}
13393
13435
  for (const r of repos) {
13394
13436
  const origin2 = originOf(r.dir);
13395
13437
  const row = matchRepoName(origin2, resolvedRemotes);
13396
- const note = !existsSync22(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
13438
+ const note = !existsSync23(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
13397
13439
  process.stdout.write(`${r.name} ${r.dir} ${note}
13398
13440
  `);
13399
13441
  }
13400
13442
  break;
13401
13443
  }
13402
13444
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir7()));
13403
- const top = existsSync22(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
13445
+ const top = existsSync23(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
13404
13446
  if (!top) throw new ReposError(`${target} is not a git checkout`);
13405
13447
  const origin = originOf(top);
13406
13448
  const plan = planRepoAdd({
@@ -13449,15 +13491,15 @@ ${changed2.map((c) => ` - ${c}
13449
13491
  const created = await rowTracker.createRepoRow(rowPayload);
13450
13492
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
13451
13493
  `);
13452
- if (existsSync22(resolvedFile)) {
13453
- const raw = JSON.parse(readFileSync15(resolvedFile, "utf8"));
13494
+ if (existsSync23(resolvedFile)) {
13495
+ const raw = JSON.parse(readFileSync16(resolvedFile, "utf8"));
13454
13496
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
13455
13497
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
13456
- writeFileSync12(resolvedFile, `${JSON.stringify(raw, null, 2)}
13498
+ writeFileSync13(resolvedFile, `${JSON.stringify(raw, null, 2)}
13457
13499
  `);
13458
13500
  }
13459
13501
  }
13460
- writeFileSync12(cfg.configFile, plan.text);
13502
+ writeFileSync13(cfg.configFile, plan.text);
13461
13503
  process.stdout.write(`added ${top} to ${routeName} as ${plan.mode === "dir" ? "dir" : `repos.${plan.name}`} in ${cfg.configFile}
13462
13504
  `);
13463
13505
  } catch (e) {
@@ -13696,10 +13738,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
13696
13738
  operator = await pickOperator(found.holds);
13697
13739
  }
13698
13740
  let previous;
13699
- const hadPreviousFile = existsSync22(resolvedPath);
13741
+ const hadPreviousFile = existsSync23(resolvedPath);
13700
13742
  if (hadPreviousFile) {
13701
13743
  try {
13702
- previous = JSON.parse(readFileSync15(resolvedPath, "utf8"));
13744
+ previous = JSON.parse(readFileSync16(resolvedPath, "utf8"));
13703
13745
  } catch {
13704
13746
  }
13705
13747
  }
@@ -13760,10 +13802,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
13760
13802
  process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
13761
13803
  `);
13762
13804
  } else {
13763
- mkdirSync12(dirname12(resolvedPath), { recursive: true });
13805
+ mkdirSync13(dirname12(resolvedPath), { recursive: true });
13764
13806
  if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
13765
13807
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
13766
- writeFileSync12(tmpPath, `${JSON.stringify(resolved, null, 2)}
13808
+ writeFileSync13(tmpPath, `${JSON.stringify(resolved, null, 2)}
13767
13809
  `);
13768
13810
  renameSync2(tmpPath, resolvedPath);
13769
13811
  process.stderr.write(`Resolved ids written to ${resolvedPath}
@@ -13777,8 +13819,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
13777
13819
  process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
13778
13820
  `);
13779
13821
  } else {
13780
- mkdirSync12(dirname12(apiKeyFile), { recursive: true });
13781
- writeFileSync12(apiKeyFile, `${apiKeyVar}=${apiKey}
13822
+ mkdirSync13(dirname12(apiKeyFile), { recursive: true });
13823
+ writeFileSync13(apiKeyFile, `${apiKeyVar}=${apiKey}
13782
13824
  `, { mode: 384 });
13783
13825
  process.stderr.write(`API key written to ${apiKeyFile}
13784
13826
  `);
@@ -14285,6 +14327,38 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
14285
14327
  if (!await installScheduler({ replace: flag("replace") })) process.exit(2);
14286
14328
  break;
14287
14329
  }
14330
+ case "reload": {
14331
+ const sentinel = reloadSentinelFor(cfg.ship.stateDir);
14332
+ if (dryRun) {
14333
+ process.stdout.write(`would write ${sentinel}
14334
+ `);
14335
+ break;
14336
+ }
14337
+ const host = hostPlatform();
14338
+ const control = host === "windows" ? null : planDaemonControl(CREW_HOME, host, detectSystemd());
14339
+ const unitStatus = control?.statusCommand ? spawnSync5(control.statusCommand[0], control.statusCommand.slice(1), { cwd: CREW_HOME, encoding: "utf8" }) : null;
14340
+ const unitRunning = unitStatus !== null && !unitStatus.error && unitStatus.status === 0;
14341
+ if (!unitRunning) {
14342
+ process.stdout.write("crew reload: no daemon unit is running \u2014 nothing to reload (the next run reads crew.yaml fresh)\n");
14343
+ break;
14344
+ }
14345
+ mkdirSync13(cfg.ship.stateDir, { recursive: true });
14346
+ writeFileSync13(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
14347
+ `);
14348
+ process.stdout.write("crew reload: requested \u2014 the daemon finishes its in-flight pass, then relaunches with the new config\n");
14349
+ if (flag("now") && control?.stopCommand && control.startCommand) {
14350
+ for (const cmd of [control.stopCommand, control.startCommand]) {
14351
+ const res = spawnSync5(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
14352
+ if (cmd === control.startCommand && (res.error || res.status !== 0)) {
14353
+ process.stderr.write(`crew reload: ${cmd.join(" ")} exited ${res.status ?? "error"}
14354
+ `);
14355
+ process.exitCode = 1;
14356
+ }
14357
+ }
14358
+ process.stdout.write("crew reload: restarted the daemon unit now\n");
14359
+ }
14360
+ break;
14361
+ }
14288
14362
  case "uninstall": {
14289
14363
  const host = hostPlatform();
14290
14364
  if (host === "windows") {
@@ -14320,7 +14394,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
14320
14394
  }
14321
14395
  case "log": {
14322
14396
  try {
14323
- const text = readFileSync15(cfg.ship.logFile, "utf8").split("\n");
14397
+ const text = readFileSync16(cfg.ship.logFile, "utf8").split("\n");
14324
14398
  process.stdout.write(`${text.slice(-(Number(value("tail")) || 40)).join("\n")}
14325
14399
  `);
14326
14400
  } catch {
@@ -14530,13 +14604,23 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
14530
14604
  );
14531
14605
  try {
14532
14606
  const evFile = eventFileFor(cfg.ship.stateDir);
14533
- const evSize = existsSync22(evFile) ? statSync9(evFile).size : 0;
14607
+ const evSize = existsSync23(evFile) ? statSync9(evFile).size : 0;
14534
14608
  process.stdout.write(
14535
14609
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
14536
14610
  ` : "events log: ok\n"
14537
14611
  );
14538
14612
  } catch {
14539
14613
  }
14614
+ const daemonStartedAt = readDaemonStarted(cfg.ship.stateDir);
14615
+ if (daemonStartedAt !== null) {
14616
+ const changed2 = configChangedAfter(daemonConfigPaths(cfg), daemonStartedAt);
14617
+ if (changed2) {
14618
+ process.stdout.write(
14619
+ `daemon config: WARN ${changed2} changed at ${new Date(statSync9(changed2).mtimeMs).toISOString()} but the daemon started at ${new Date(daemonStartedAt).toISOString()} \u2014 run \`crew reload\`
14620
+ `
14621
+ );
14622
+ }
14623
+ }
14540
14624
  const tracker = new Tracker(route, cfg.ship);
14541
14625
  const contractProblems = validateContract(tracker.contract);
14542
14626
  process.stdout.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",