@clipboard-health/groundcrew 4.4.0 → 4.6.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.
@@ -14,6 +14,7 @@ import { resolveWorkspaceKind } from "../lib/workspaces.js";
14
14
  // Tokenization stops after this many non-flag tokens. Two is enough to
15
15
  // catch wrapper + wrapped CLI commands like `safehouse claude --foo`.
16
16
  const MAX_TOKENS_PER_CMD = 2;
17
+ const SHIPPED_DEFAULT_MODEL_NAMES = ["claude", "codex"];
17
18
  async function checkCmd(cmd, required, hint) {
18
19
  const path = await which(cmd);
19
20
  const resolvedHint = path ?? hint;
@@ -103,14 +104,29 @@ function commandTokensToCheck(cmd) {
103
104
  }
104
105
  return result;
105
106
  }
106
- function gatherToolTokens(config) {
107
- const all = new Set();
108
- for (const definition of Object.values(config.models.definitions)) {
107
+ function gatherToolTargets(config) {
108
+ const all = new Map();
109
+ for (const [modelName, definition] of Object.entries(config.models.definitions)) {
109
110
  for (const token of commandTokensToCheck(definition.cmd)) {
110
- all.add(token);
111
+ const hint = modelCliHint(modelName, token);
112
+ if (!all.has(token) || all.get(token) === undefined) {
113
+ all.set(token, hint);
114
+ }
111
115
  }
112
116
  }
113
- return [...all];
117
+ return [...all].map(([token, hint]) => (hint === undefined ? { token } : { token, hint }));
118
+ }
119
+ function modelCliHint(modelName, token) {
120
+ if (token !== modelName) {
121
+ return undefined;
122
+ }
123
+ if (!isShippedDefaultModelName(modelName)) {
124
+ return undefined;
125
+ }
126
+ return `install ${token} or disable it in crew.config.ts: \`models.definitions.${modelName} = { disabled: true }\``;
127
+ }
128
+ function isShippedDefaultModelName(value) {
129
+ return value === "claude" || value === "codex";
114
130
  }
115
131
  function format(check) {
116
132
  let tag;
@@ -163,11 +179,11 @@ export async function doctor() {
163
179
  checkDir(config.workspace.projectDir, "workspace.projectDir"),
164
180
  localCapability,
165
181
  ];
166
- const toolTokens = gatherToolTokens(config);
167
- for (const token of toolTokens) {
182
+ const toolTargets = gatherToolTargets(config);
183
+ for (const { token, hint } of toolTargets) {
168
184
  const required = localCapability.ok;
169
185
  // oxlint-disable-next-line no-await-in-loop -- doctor reports tools in deterministic order
170
- const check = await checkCmd(token, required, required ? undefined : "required for local runs");
186
+ const check = await checkCmd(token, required, required ? hint : "required for local runs");
171
187
  checks.push(check);
172
188
  }
173
189
  const usageGatedModels = gatedModels(config);
@@ -4,7 +4,10 @@
4
4
  * the shipped `crew.config.example.ts` so a fresh install skips the manual
5
5
  * `cp` dance documented in the README.
6
6
  */
7
+ import { type LocalRunnerSetting } from "../lib/config.ts";
8
+ declare const INIT_MODELS: readonly ["claude", "codex"];
7
9
  type InitConfigScope = "global" | "local";
10
+ type InitModel = (typeof INIT_MODELS)[number];
8
11
  interface InitConfigOptions {
9
12
  /** Where to write the config. Defaults to "local" (cwd). */
10
13
  scope?: InitConfigScope;
@@ -14,6 +17,16 @@ interface InitConfigOptions {
14
17
  dryRun?: boolean;
15
18
  /** Override for the working directory; defaults to `process.cwd()`. */
16
19
  cwd?: string;
20
+ /** Pre-fill workspace.projectDir in the generated config. */
21
+ projectDir?: string;
22
+ /** Pre-fill workspace.knownRepositories in the generated config. */
23
+ repositories?: string[];
24
+ /** Pre-fill local.runner in the generated config. */
25
+ runner?: LocalRunnerSetting;
26
+ /** Keep one shipped default model enabled and disable the other. */
27
+ model?: InitModel;
28
+ /** Override the source template path. */
29
+ examplePath?: string;
17
30
  }
18
31
  type InitConfigOutcome = "dry-run-would-write" | "exists" | "wrote";
19
32
  interface InitConfigResult {
@@ -1 +1 @@
1
- {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAWH,KAAK,eAAe,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE1C,UAAU,iBAAiB;IACzB,4DAA4D;IAC5D,KAAK,CAAC,EAAE,eAAe,CAAC;IACxB,yCAAyC;IACzC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iEAAiE;IACjE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,uEAAuE;IACvE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,KAAK,iBAAiB,GAAG,qBAAqB,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEpE,UAAU,gBAAgB;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,iBAAiB,CAAC;CAC5B;AAED,wBAAgB,UAAU,CAAC,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAoB5E;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBjE"}
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,EAAyB,KAAK,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAUlF,QAAA,MAAM,WAAW,YAAI,QAAQ,EAAE,OAAO,CAAU,CAAC;AAEjD,KAAK,eAAe,GAAG,QAAQ,GAAG,OAAO,CAAC;AAC1C,KAAK,SAAS,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAE9C,UAAU,iBAAiB;IACzB,4DAA4D;IAC5D,KAAK,CAAC,EAAE,eAAe,CAAC;IACxB,yCAAyC;IACzC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iEAAiE;IACjE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,uEAAuE;IACvE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,oEAAoE;IACpE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,qDAAqD;IACrD,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,oEAAoE;IACpE,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,yCAAyC;IACzC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,KAAK,iBAAiB,GAAG,qBAAqB,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEpE,UAAU,gBAAgB;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,iBAAiB,CAAC;CAC5B;AAED,wBAAgB,UAAU,CAAC,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAoB5E;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAWjE"}
@@ -4,16 +4,21 @@
4
4
  * the shipped `crew.config.example.ts` so a fresh install skips the manual
5
5
  * `cp` dance documented in the README.
6
6
  */
7
- import { copyFileSync, existsSync, mkdirSync } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
8
  import { dirname, resolve } from "node:path";
9
+ import { LOCAL_RUNNER_SETTINGS } from "../lib/config.js";
10
+ import { shellSingleQuote } from "../lib/shell.js";
9
11
  import { log, writeOutput } from "../lib/util.js";
10
12
  import { xdgConfigPath } from "../lib/xdg.js";
11
13
  const CONFIG_FILE_NAME = "crew.config.ts";
12
14
  const EXAMPLE_FILE_NAME = "crew.config.example.ts";
15
+ const DEFAULT_EXAMPLE_PROJECT_DIR = "~/dev/groundcrew";
16
+ const INIT_USAGE = "Usage: crew init [--global | --local] [--force] [--dry-run] [--project-dir <dir>] [--repo <owner/repo>]... [--runner <auto|safehouse|sdx|none>] [--model <claude|codex>]";
17
+ const INIT_MODELS = ["claude", "codex"];
13
18
  export function initConfig(options = {}) {
14
19
  const scope = options.scope ?? "local";
15
20
  const cwd = options.cwd ?? process.cwd();
16
- const source = resolveExamplePath();
21
+ const source = options.examplePath ?? resolveExamplePath();
17
22
  const destination = destinationFor({ scope, cwd });
18
23
  if (existsSync(destination) && options.force !== true) {
19
24
  log(`[exists] ${destination} — pass --force to overwrite`);
@@ -24,7 +29,7 @@ export function initConfig(options = {}) {
24
29
  return { destination, outcome: "dry-run-would-write" };
25
30
  }
26
31
  mkdirSync(dirname(destination), { recursive: true });
27
- copyFileSync(source, destination);
32
+ writeFileSync(destination, renderConfig(source, options));
28
33
  log(`[wrote] ${destination}`);
29
34
  return { destination, outcome: "wrote" };
30
35
  }
@@ -36,24 +41,27 @@ export async function initConfigCli(argv) {
36
41
  return;
37
42
  }
38
43
  if (result.outcome === "wrote") {
39
- writeOutput("");
40
- writeOutput("Next steps:");
41
- writeOutput(` - Edit ${result.destination}`);
42
- writeOutput(" - Set workspace.projectDir, workspace.knownRepositories");
43
- writeOutput(" - Export GROUNDCREW_LINEAR_API_KEY (or LINEAR_API_KEY)");
44
- writeOutput(" - Assign Linear tickets to yourself and add an agent-* label to opt them in");
45
- writeOutput(" - Verify with `crew doctor`");
44
+ writeInitGuidance(result.destination, options);
46
45
  }
47
46
  }
48
47
  function parseArguments(argv) {
49
48
  let scope;
50
49
  let force = false;
51
50
  let dryRun = false;
52
- for (const argument of argv) {
51
+ let projectDir;
52
+ const repositories = [];
53
+ let runner;
54
+ let model;
55
+ for (let index = 0; index < argv.length; index += 1) {
56
+ const argument = argv[index];
57
+ /* v8 ignore next 3 @preserve -- loop bounds keep argv[index] defined */
58
+ if (argument === undefined) {
59
+ continue;
60
+ }
53
61
  if (argument === "--global" || argument === "--local") {
54
62
  const next = argument === "--global" ? "global" : "local";
55
63
  if (scope !== undefined && scope !== next) {
56
- throw new Error("crew init: --global and --local are mutually exclusive.\nUsage: crew init [--global | --local] [--force] [--dry-run]");
64
+ throw new Error(`crew init: --global and --local are mutually exclusive.\n${INIT_USAGE}`);
57
65
  }
58
66
  scope = next;
59
67
  continue;
@@ -66,9 +74,44 @@ function parseArguments(argv) {
66
74
  dryRun = true;
67
75
  continue;
68
76
  }
69
- throw new Error(`Unknown option: ${argument}\nUsage: crew init [--global | --local] [--force] [--dry-run]`);
77
+ if (argument === "--project-dir") {
78
+ projectDir = readOptionValue(argv, index, argument);
79
+ index += 1;
80
+ continue;
81
+ }
82
+ if (argument === "--repo") {
83
+ repositories.push(readOptionValue(argv, index, argument));
84
+ index += 1;
85
+ continue;
86
+ }
87
+ if (argument === "--runner") {
88
+ runner = parseRunner(readOptionValue(argv, index, argument));
89
+ index += 1;
90
+ continue;
91
+ }
92
+ if (argument === "--model") {
93
+ model = parseModel(readOptionValue(argv, index, argument));
94
+ index += 1;
95
+ continue;
96
+ }
97
+ throw new Error(`Unknown option: ${argument}\n${INIT_USAGE}`);
98
+ }
99
+ const parsed = {
100
+ scope: scope ?? "local",
101
+ force,
102
+ dryRun,
103
+ repositories,
104
+ };
105
+ if (projectDir !== undefined) {
106
+ parsed.projectDir = projectDir;
70
107
  }
71
- return { scope: scope ?? "local", force, dryRun };
108
+ if (runner !== undefined) {
109
+ parsed.runner = runner;
110
+ }
111
+ if (model !== undefined) {
112
+ parsed.model = model;
113
+ }
114
+ return parsed;
72
115
  }
73
116
  function destinationFor(args) {
74
117
  if (args.scope === "global") {
@@ -81,3 +124,127 @@ function resolveExamplePath() {
81
124
  // after build; the example ships at the package root in both cases.
82
125
  return resolve(import.meta.dirname, "..", "..", EXAMPLE_FILE_NAME);
83
126
  }
127
+ function readOptionValue(argv, index, flag) {
128
+ const value = argv[index + 1];
129
+ if (value === undefined || value.length === 0 || value.startsWith("-")) {
130
+ throw new Error(`crew init ${flag}: value is required\n${INIT_USAGE}`);
131
+ }
132
+ return value;
133
+ }
134
+ function parseRunner(value) {
135
+ if (isLocalRunnerSetting(value)) {
136
+ return value;
137
+ }
138
+ throw new Error(`crew init --runner must be one of ${LOCAL_RUNNER_SETTINGS.join(", ")}`);
139
+ }
140
+ function parseModel(value) {
141
+ if (isInitModel(value)) {
142
+ return value;
143
+ }
144
+ throw new Error(`crew init --model must be one of ${INIT_MODELS.join(", ")}`);
145
+ }
146
+ function isLocalRunnerSetting(value) {
147
+ return value === "auto" || value === "safehouse" || value === "sdx" || value === "none";
148
+ }
149
+ function isInitModel(value) {
150
+ return value === "claude" || value === "codex";
151
+ }
152
+ function tsString(value) {
153
+ return JSON.stringify(value);
154
+ }
155
+ function renderConfig(source, options) {
156
+ let contents = readFileSync(source, "utf8");
157
+ if (options.projectDir !== undefined) {
158
+ contents = replaceRequired(contents, `projectDir: ${tsString(DEFAULT_EXAMPLE_PROJECT_DIR)}`, `projectDir: ${tsString(options.projectDir)}`, "--project-dir");
159
+ }
160
+ if (options.repositories !== undefined && options.repositories.length > 0) {
161
+ contents = replaceRequired(contents, 'knownRepositories: ["your-org/your-repo"]', `knownRepositories: [${options.repositories.map(tsString).join(", ")}]`, "--repo");
162
+ }
163
+ if (options.runner !== undefined) {
164
+ contents = replaceRequired(contents, ` // local: { runner: "auto" },`, ` local: { runner: ${tsString(options.runner)} },`, "--runner");
165
+ }
166
+ if (options.model !== undefined) {
167
+ contents = replaceRequired(contents, " // prompts: {", `${modelBlock(options.model)}\n // prompts: {`, "--model");
168
+ }
169
+ return contents;
170
+ }
171
+ function replaceRequired(contents, search, replacement, flag) {
172
+ if (!contents.includes(search)) {
173
+ throw new Error(`crew init ${flag}: template anchor not found in ${EXAMPLE_FILE_NAME}`);
174
+ }
175
+ return contents.replace(search, replacement);
176
+ }
177
+ function modelBlock(model) {
178
+ const disabled = model === "claude" ? "codex" : "claude";
179
+ return [
180
+ " models: {",
181
+ ` default: ${tsString(model)},`,
182
+ " definitions: {",
183
+ ` ${disabled}: { disabled: true },`,
184
+ " },",
185
+ " },",
186
+ "",
187
+ ].join("\n");
188
+ }
189
+ function writeInitGuidance(destination, options) {
190
+ writeOutput("");
191
+ writeOutput("Next steps:");
192
+ writeOutput(` - Review ${destination}`);
193
+ if (options.projectDir === undefined ||
194
+ options.repositories === undefined ||
195
+ options.repositories.length === 0) {
196
+ writeOutput(" - Set workspace.projectDir and workspace.knownRepositories");
197
+ }
198
+ writeCloneGuidance(options);
199
+ writeOutput(" - If using Linear, export your API key:");
200
+ writeOutput(' export GROUNDCREW_LINEAR_API_KEY="lin_api_..."');
201
+ writeOutput(" - In Linear, assign tickets to yourself and add an agent-* label to opt them in");
202
+ writeOutput(" - Validate and start:");
203
+ writeOutput(" crew doctor");
204
+ writeOutput(" crew run --watch");
205
+ }
206
+ function writeCloneGuidance(options) {
207
+ if (options.repositories === undefined || options.repositories.length === 0) {
208
+ return;
209
+ }
210
+ writeOutput(" - Clone configured repositories:");
211
+ writeOutput(` ${projectDirAssignment(options.projectDir ?? DEFAULT_EXAMPLE_PROJECT_DIR)}`);
212
+ for (const repository of options.repositories) {
213
+ for (const command of cloneCommands(repository)) {
214
+ writeOutput(` ${command}`);
215
+ }
216
+ }
217
+ }
218
+ function projectDirAssignment(projectDir) {
219
+ if (projectDir === "~") {
220
+ return 'PROJECT_DIR="$HOME"';
221
+ }
222
+ if (projectDir.startsWith("~/")) {
223
+ return `PROJECT_DIR="$HOME/${escapeDoubleQuotedShellValue(projectDir.slice(2))}"`;
224
+ }
225
+ return `PROJECT_DIR=${shellSingleQuote(projectDir)}`;
226
+ }
227
+ function cloneCommands(repository) {
228
+ const parts = repository.split("/");
229
+ const [owner, name, extra] = parts;
230
+ if (owner !== undefined && name !== undefined && extra === undefined) {
231
+ return [
232
+ `mkdir -p "$PROJECT_DIR/${owner}"`,
233
+ `git clone git@github.com:${owner}/${name}.git "$PROJECT_DIR/${owner}/${name}"`,
234
+ ];
235
+ }
236
+ return [
237
+ 'mkdir -p "$PROJECT_DIR"',
238
+ `git clone <REMOTE_URL_FOR_${repository}> "$PROJECT_DIR/${repository}"`,
239
+ ];
240
+ }
241
+ function escapeDoubleQuotedShellValue(value) {
242
+ let escaped = "";
243
+ for (const character of value) {
244
+ escaped +=
245
+ character === '"' || character === "\\" || character === "$" || character === "`"
246
+ ? `\\${character}`
247
+ : character;
248
+ }
249
+ return escaped;
250
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"AAIA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAanE,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA6gBD,wBAAsB,MAAM,CAAC,MAAM,EAAE,cAAc,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/F;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAI7D"}
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"AAIA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAanE,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAkjBD,wBAAsB,MAAM,CAAC,MAAM,EAAE,cAAc,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/F;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAI7D"}
@@ -68,8 +68,14 @@ function ticketWorkspaceText(probe, ticket) {
68
68
  if (probe.kind === "unavailable") {
69
69
  return workspaceProbeUnavailableLine(probe);
70
70
  }
71
+ if (isWorkspaceExited(probe, ticket)) {
72
+ return "exited";
73
+ }
71
74
  return probe.names.has(ticket) ? "live" : "not live";
72
75
  }
76
+ function isWorkspaceExited(probe, ticket) {
77
+ return probe.kind === "ok" && probe.exitedNames?.has(ticket) === true;
78
+ }
73
79
  function formatRunState(state) {
74
80
  if (state === undefined) {
75
81
  return "(none)";
@@ -116,6 +122,17 @@ function writeRecentLogs(config, ticket) {
116
122
  writeSection("Recent logs");
117
123
  writeOutput(logLines.join("\n"));
118
124
  }
125
+ async function exitedWorkspaceAccessHint(config, probe, ticket) {
126
+ if (!isWorkspaceExited(probe, ticket)) {
127
+ return undefined;
128
+ }
129
+ try {
130
+ return await withLogOutputSuppressed(async () => await workspaces.accessHint(config, ticket));
131
+ }
132
+ catch {
133
+ return undefined;
134
+ }
135
+ }
119
136
  function formatTicketLine(ticket, runState, sourceStatus) {
120
137
  const parts = [`ticket: ${ticket}`];
121
138
  if (sourceStatus.kind === "found") {
@@ -154,10 +171,14 @@ async function writeTicketStatus(config, rawTicket) {
154
171
  withLogOutputSuppressed(async () => await workspaces.probe(config)),
155
172
  readTicketSourceStatus(config, ticket),
156
173
  ]);
174
+ const accessHint = await exitedWorkspaceAccessHint(config, workspaceProbe, ticket);
157
175
  writeOutput(formatTicketLine(ticket, runState, sourceStatus));
158
176
  writeTicketTitle(runState, sourceStatus);
159
177
  writeOutput(`run: ${formatRunState(runState)}`);
160
178
  writeOutput(`workspace: ${ticketWorkspaceText(workspaceProbe, ticket)}`);
179
+ if (accessHint !== undefined) {
180
+ writeOutput(`attach: ${accessHint.command}`);
181
+ }
161
182
  await writeTicketWorktrees(config, ticket);
162
183
  writeRecentLogs(config, ticket);
163
184
  }
@@ -202,22 +223,26 @@ function formatDuration(ms) {
202
223
  /**
203
224
  * Combined human-readable state for the inventory row. Surfaces RunState
204
225
  * lifecycle and flags the two interesting disagreements with the workspace
205
- * probe `(session dead)` when we recorded a running dispatch but no
206
- * session is alive, and `(stray session)` when a session is alive without
207
- * any recorded dispatch. `probe.kind === "unavailable"` is treated as
208
- * "we don't know" and never produces a suffix. When the row is actively
209
- * running, appends the elapsed wall-clock time since dispatch.
226
+ * probe. A recorded running dispatch can have a missing or exited session;
227
+ * an idle row can have a stray live or exited session. `probe.kind ===
228
+ * "unavailable"` is treated as "we don't know" and never produces a suffix.
229
+ * When the row is actively running, appends the elapsed wall-clock time since
230
+ * dispatch.
210
231
  */
211
232
  function inventoryStateText(runState, probe, ticket, now) {
212
233
  const lifecycle = runState?.state ?? "idle";
213
234
  const duration = runStateDurationMs(runState, now);
214
235
  const flags = [];
215
236
  if (probe.kind === "ok") {
216
- const sessionLive = probe.names.has(ticket);
217
- if (lifecycle === "idle" && sessionLive) {
218
- flags.push("stray session");
237
+ const sessionPresent = probe.names.has(ticket);
238
+ const sessionExited = isWorkspaceExited(probe, ticket);
239
+ if (lifecycle === "idle" && sessionPresent) {
240
+ flags.push(sessionExited ? "stray exited session" : "stray session");
241
+ }
242
+ if ((lifecycle === "running" || lifecycle === "resumed") && sessionExited) {
243
+ flags.push("session exited");
219
244
  }
220
- if ((lifecycle === "running" || lifecycle === "resumed") && !sessionLive) {
245
+ else if ((lifecycle === "running" || lifecycle === "resumed") && !sessionPresent) {
221
246
  flags.push("session dead");
222
247
  }
223
248
  }
@@ -231,9 +256,11 @@ function inventoryStateText(runState, probe, ticket, now) {
231
256
  * probe disagree. Returned commands are safe defaults; the user is free to
232
257
  * ignore them and use `attach:` + `pr:` to investigate first.
233
258
  *
234
- * - Stray session (live session, no run-state record) `crew cleanup` to
235
- * tear down the orphaned worktree + close the session.
236
- * - Session dead (run-state says running/resumed, no live session)
259
+ * - Stray session (session present, no run-state record) -> `crew cleanup`
260
+ * to tear down the orphaned worktree and close the session.
261
+ * - Session exited (run-state says running/resumed, kept dead tmux window)
262
+ * -> attach first so the failed command remains available for inspection.
263
+ * - Session dead (run-state says running/resumed, no session present) ->
237
264
  * `crew resume` to bring the agent back; the worktree is preserved.
238
265
  *
239
266
  * No hint when the probe is unavailable (we genuinely don't know whether
@@ -244,11 +271,17 @@ function inventoryHint(runState, probe, ticket) {
244
271
  return undefined;
245
272
  }
246
273
  const lifecycle = runState?.state ?? "idle";
247
- const sessionLive = probe.names.has(ticket);
248
- if (lifecycle === "idle" && sessionLive) {
249
- return `run 'crew cleanup ${ticket}' to clear this stray session`;
274
+ const sessionPresent = probe.names.has(ticket);
275
+ const sessionExited = isWorkspaceExited(probe, ticket);
276
+ if (lifecycle === "idle" && sessionPresent) {
277
+ return sessionExited
278
+ ? `run 'crew cleanup ${ticket}' to clear this stray exited session`
279
+ : `run 'crew cleanup ${ticket}' to clear this stray session`;
280
+ }
281
+ if ((lifecycle === "running" || lifecycle === "resumed") && sessionExited) {
282
+ return `attach to inspect scrollback, then run 'crew resume ${ticket}'`;
250
283
  }
251
- if ((lifecycle === "running" || lifecycle === "resumed") && !sessionLive) {
284
+ if ((lifecycle === "running" || lifecycle === "resumed") && !sessionPresent) {
252
285
  return `run 'crew resume ${ticket}' to bring the session back`;
253
286
  }
254
287
  return undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"tmuxAdapter.d.ts","sourceRoot":"","sources":["../../src/lib/tmuxAdapter.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,KAAK,OAAO,EAIb,MAAM,uBAAuB,CAAC;AAY/B,eAAO,MAAM,WAAW,EAAE,OAgEzB,CAAC"}
1
+ {"version":3,"file":"tmuxAdapter.d.ts","sourceRoot":"","sources":["../../src/lib/tmuxAdapter.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,KAAK,OAAO,EAIb,MAAM,uBAAuB,CAAC;AAY/B,eAAO,MAAM,WAAW,EAAE,OA+DzB,CAAC"}
@@ -17,8 +17,7 @@ export const tmuxAdapter = {
17
17
  async open(spec, signal) {
18
18
  await ensureTmuxSession(signal);
19
19
  const target = tmuxTarget(spec.name);
20
- const keepDeadWindowsEnv = readEnvironmentVariable("GROUNDCREW_KEEP_DEAD_WINDOWS");
21
- const keepDeadWindows = keepDeadWindowsEnv !== undefined && keepDeadWindowsEnv.length > 0;
20
+ const keepDeadWindows = shouldKeepDeadWindows();
22
21
  await runWorkspaceCommand("tmux", [
23
22
  "new-window",
24
23
  "-d",
@@ -54,7 +53,7 @@ export const tmuxAdapter = {
54
53
  // oxlint-disable-next-line unicorn/no-useless-undefined -- undefined marks the workspace backend as unavailable.
55
54
  return undefined;
56
55
  }
57
- return parseTmuxWindows(probe.output);
56
+ return parseTmuxWindows(probe.output, { includeExited: shouldKeepDeadWindows() });
58
57
  },
59
58
  async close(name, signal) {
60
59
  try {
@@ -78,6 +77,10 @@ export const tmuxAdapter = {
78
77
  function tmuxTarget(name) {
79
78
  return `${TMUX_SESSION}:${name}`;
80
79
  }
80
+ function shouldKeepDeadWindows() {
81
+ const keepDeadWindowsEnv = readEnvironmentVariable("GROUNDCREW_KEEP_DEAD_WINDOWS");
82
+ return keepDeadWindowsEnv === "1";
83
+ }
81
84
  function isTmuxNotFoundError(error) {
82
85
  // runCommand surfaces the child's stderr in error.message, so the "no
83
86
  // server" / "missing session" / "can't find window" signatures are visible
@@ -130,7 +133,7 @@ async function ensureTmuxSession(signal) {
130
133
  }
131
134
  }
132
135
  }
133
- function parseTmuxWindows(output) {
136
+ function parseTmuxWindows(output, options = {}) {
134
137
  const items = [];
135
138
  for (const line of output.split("\n")) {
136
139
  if (line.length === 0) {
@@ -147,10 +150,11 @@ function parseTmuxWindows(output) {
147
150
  // pane_dead != 0 means the command exited and the window is a zombie
148
151
  // (only happens when remain-on-exit is on; defense in depth in case a
149
152
  // user-globally-set value beats our per-window override).
150
- if (deadFlag !== undefined && deadFlag !== "0") {
153
+ const isExited = deadFlag !== undefined && deadFlag !== "0";
154
+ if (isExited && options.includeExited !== true) {
151
155
  continue;
152
156
  }
153
- items.push({ name });
157
+ items.push(isExited ? { name, state: "exited" } : { name });
154
158
  }
155
159
  return items;
156
160
  }
@@ -10,6 +10,8 @@ export type WorkspaceKind = "cmux" | "tmux";
10
10
  export interface Workspace {
11
11
  /** Ticket id; the join key callers use. */
12
12
  name: string;
13
+ /** Omitted means live, for backends that do not expose an exited state. */
14
+ state?: "exited";
13
15
  }
14
16
  export interface WorkspaceStatus {
15
17
  text: string;
@@ -37,6 +39,7 @@ export interface OpenSpec {
37
39
  export type WorkspaceProbe = {
38
40
  kind: "ok";
39
41
  names: Set<string>;
42
+ exitedNames?: Set<string>;
40
43
  } | {
41
44
  kind: "unavailable";
42
45
  error?: unknown;
@@ -60,7 +63,7 @@ export type WorkspaceCloseResult = {
60
63
  export interface Adapter {
61
64
  open(spec: OpenSpec, signal?: AbortSignal): Promise<void>;
62
65
  /**
63
- * Live workspaces only. Returns:
66
+ * Known workspaces. Returns:
64
67
  * - `Workspace[]` when the adapter probe succeeded (may be empty).
65
68
  * - `undefined` when the adapter binary failed in a way that doesn't
66
69
  * distinguish "no live workspaces" from "couldn't ask".
@@ -1 +1 @@
1
- {"version":3,"file":"workspaceAdapter.d.ts","sourceRoot":"","sources":["../../src/lib/workspaceAdapter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,CAAC;AAE5C,MAAM,WAAW,SAAS;IACxB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,eAAe,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,MAAM,wBAAwB,GAChC;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,GACvB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,MAAM,oBAAoB,GAC5B;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAClB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,WAAW,OAAO;IACtB,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D;;;;;OAKG;IACH,IAAI,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,EAAE,GAAG,SAAS,CAAC,CAAC;IAC7D,0DAA0D;IAC1D,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACzE;;;OAGG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS,CAAC;CAC3D;AAED,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,SAAS,MAAM,EAAE,EAC7B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,MAAM,CAAC,CAIjB;AAED,wBAAgB,eAAe,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAE7D"}
1
+ {"version":3,"file":"workspaceAdapter.d.ts","sourceRoot":"","sources":["../../src/lib/workspaceAdapter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,MAAM,CAAC;AAE5C,MAAM,WAAW,SAAS;IACxB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,2EAA2E;IAC3E,KAAK,CAAC,EAAE,QAAQ,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,eAAe,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACvB,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAAC,WAAW,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAE,GAC7D;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,MAAM,wBAAwB,GAChC;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,GACvB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,MAAM,oBAAoB,GAC5B;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAClB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE7C,MAAM,WAAW,OAAO;IACtB,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D;;;;;OAKG;IACH,IAAI,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,EAAE,GAAG,SAAS,CAAC,CAAC;IAC7D,0DAA0D;IAC1D,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACzE;;;OAGG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS,CAAC;CAC3D;AAED,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,SAAS,MAAM,EAAE,EAC7B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,MAAM,CAAC,CAIjB;AAED,wBAAgB,eAAe,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAE7D"}
@@ -1 +1 @@
1
- {"version":3,"file":"workspaces.d.ts","sourceRoot":"","sources":["../../src/lib/workspaces.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACxE,OAAO,EAA0B,KAAK,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAE1E,OAAO,EAGL,KAAK,QAAQ,EACb,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC7B,KAAK,aAAa,EAClB,KAAK,cAAc,EACpB,MAAM,uBAAuB,CAAC;AAE/B,YAAY,EACV,QAAQ,EACR,SAAS,EACT,mBAAmB,EACnB,oBAAoB,EACpB,wBAAwB,EACxB,aAAa,EACb,cAAc,EACd,eAAe,GAChB,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,oBAAoB,CAAC;IAChC,QAAQ,EAAE,aAAa,CAAC;IACxB,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,gBAAgB;IACxB,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,gBAAgB,CAAC;CACxB;AAED,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,gBAAgB,GAAG,mBAAmB,CAUtF;AAsDD,iBAAe,eAAe,CAC5B,MAAM,EAAE,cAAc,EACtB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,cAAc,CAAC,CAezB;AAED,iBAAe,sBAAsB,CACnC,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAAC,CAG1C;AAED,iBAAe,kBAAkB,CAC/B,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,wBAAwB,CAAC,CAenC;AAED,eAAO,MAAM,UAAU;IACf,IAAI,SAAS,cAAc,QAAQ,QAAQ,WAAW,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvF,KAAK;IACC,KAAK,SACD,cAAc,QAChB,MAAM,WACH,WAAW,GACnB,OAAO,CAAC,oBAAoB,CAAC;IAIhC,SAAS;IACT,UAAU;CACX,CAAC"}
1
+ {"version":3,"file":"workspaces.d.ts","sourceRoot":"","sources":["../../src/lib/workspaces.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACxE,OAAO,EAA0B,KAAK,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAE1E,OAAO,EAGL,KAAK,QAAQ,EACb,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC7B,KAAK,aAAa,EAClB,KAAK,cAAc,EACpB,MAAM,uBAAuB,CAAC;AAE/B,YAAY,EACV,QAAQ,EACR,SAAS,EACT,mBAAmB,EACnB,oBAAoB,EACpB,wBAAwB,EACxB,aAAa,EACb,cAAc,EACd,eAAe,GAChB,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,oBAAoB,CAAC;IAChC,QAAQ,EAAE,aAAa,CAAC;IACxB,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,gBAAgB;IACxB,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,gBAAgB,CAAC;CACxB;AAED,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,gBAAgB,GAAG,mBAAmB,CAUtF;AAsDD,iBAAe,eAAe,CAC5B,MAAM,EAAE,cAAc,EACtB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,cAAc,CAAC,CAiBzB;AAED,iBAAe,sBAAsB,CACnC,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAAC,CAG1C;AAED,iBAAe,kBAAkB,CAC/B,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,wBAAwB,CAAC,CAenC;AAED,eAAO,MAAM,UAAU;IACf,IAAI,SAAS,cAAc,QAAQ,QAAQ,WAAW,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvF,KAAK;IACC,KAAK,SACD,cAAc,QAChB,MAAM,WACH,WAAW,GACnB,OAAO,CAAC,oBAAoB,CAAC;IAIhC,SAAS;IACT,UAAU;CACX,CAAC"}
@@ -74,7 +74,9 @@ async function probeWorkspaces(config, signal) {
74
74
  if (raw === undefined) {
75
75
  return { kind: "unavailable" };
76
76
  }
77
- return { kind: "ok", names: new Set(raw.map((ws) => ws.name)) };
77
+ const names = new Set(raw.map((ws) => ws.name));
78
+ const exitedNames = new Set(raw.filter((ws) => ws.state === "exited").map((ws) => ws.name));
79
+ return exitedNames.size === 0 ? { kind: "ok", names } : { kind: "ok", names, exitedNames };
78
80
  }
79
81
  async function accessHintForWorkspace(config, name, signal) {
80
82
  const adapter = await adapterFor(config, signal);
@@ -0,0 +1,17 @@
1
+ # Groundcrew uses sandboxes but does not provision them
2
+
3
+ **Status:** Accepted and implemented. Implementation is tracked under STAFF-1033 and its slices.
4
+
5
+ Groundcrew launches agent processes _inside_ an isolation backend (safehouse on macOS, sdx/Docker Sandboxes elsewhere, or `none`), but it no longer manages the lifecycle of those sandboxes. We removed `crew sandbox` (ensure/regenerate/auth/rm/list), the auth-recipe machinery, and `lib/dockerSandbox.ts` because they duplicated functionality `sbx` already provides — wrapping someone else's CLI to be marginally more ergonomic cost ~1100 LOC and pulled sandbox-provisioning concepts (templates, kits, auth recipes, git defaults) into groundcrew's config surface for no proportional benefit.
6
+
7
+ ## Considered Options
8
+
9
+ - **Keep the sdx lifecycle commands** — rejected: they reimplement `sbx run`/`sbx exec` setup flows, and every concept they expose (`authRecipes`, `template`, `kits`, `gitDefaults`) is a sandbox concern, not an orchestration concern.
10
+ - **Generalize the launch wrap to a user-supplied template string** — rejected for now: the build-time-secrets and `.groundcrew/setup.sh` plumbing inside the sdx wrap is awkward to express in a user template. Kept a small `safehouse | sdx | none` WRAP enum in core instead.
11
+
12
+ ## Consequences
13
+
14
+ - The launch **WRAP** stays in core (`launchCommand.ts`): given an agent command + worktree + secrets + sandbox name, produce the shell string that runs the agent under the chosen backend.
15
+ - First-time sandbox setup is now a manual `sbx` workflow the user runs themselves; the README points to it. Groundcrew assumes the sandbox already exists at launch.
16
+ - Linux/WSL users are unaffected at launch time — they keep the sdx WRAP — but no longer get groundcrew-driven provisioning.
17
+ - Removed config keys (`sandbox.authRecipes`, `sandbox.gitDefaults`, sdx lifecycle fields like `template`/`kits`) hard-fail with an actionable message, matching the existing `config.ts` precedent for removed shapes.
@@ -0,0 +1,17 @@
1
+ # One ticket-source path; Linear is just an adapter
2
+
3
+ **Status:** Accepted; not yet implemented. Implementation is tracked under STAFF-1033 / STAFF-1034 (lands PR #89). The past/present tense below describes the decided end state, not the current code — at time of writing `orchestrator.ts` still calls `boardSource.fetch()` and `boardSource.ts` still exists.
4
+
5
+ There is a single path from board state to dispatch: `Source[] → Board → Dispatcher`. Linear is a `TicketSource` adapter like any other; the dispatcher and eligibility code never import Linear-specific logic. We deleted the legacy `boardSource.ts → dispatcher` path (which made Linear the only source that could actually start agents) and moved the live Linear logic into `src/lib/adapters/linear/`, because the half-wired parallel architecture meant declared shell sources validated at startup but contributed zero tickets to dispatch — defeating the entire pluggable-source point.
6
+
7
+ ## Considered Options
8
+
9
+ - **Keep `boardSource.ts` as a Linear fast-path and wire extras alongside it** — rejected: two paths from board to dispatch is exactly the coupling that let Linear concepts leak into the dispatcher. The whole value is symmetry — every source, including Linear, reaches dispatch the same way.
10
+
11
+ ## Consequences
12
+
13
+ - The canonical seam is the `Issue` contract: a source emits `model` and `repository`, or the ticket is ignored (`isGroundcrewIssue` keys off exactly that). Consumers branch on the canonical `CanonicalStatus` enum, never on a source's native status names.
14
+ - **Linear-specific** concepts live in the adapter: `agent-*` label parsing, `agent-any` routing, sub-issue/parent detection, assigned-to-viewer + label selection policy.
15
+ - **Canonical** concepts stay in eligibility so every source benefits: blocker classification (sources populate `blockers[]`) and exhausted-model gating (sources pick a `model`).
16
+ - This was a pure internal refactor with no user-visible change — Linear keeps working identically — so it carried no migration cost and landed before the breaking v5 cuts.
17
+ - Changing the Linear selection mechanism (assigned + labeled) is now an adapter-local change that does not touch the engine.