@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.
- package/README.md +43 -482
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +24 -8
- package/dist/commands/init.d.ts +13 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +181 -14
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +49 -16
- package/dist/lib/tmuxAdapter.d.ts.map +1 -1
- package/dist/lib/tmuxAdapter.js +10 -6
- package/dist/lib/workspaceAdapter.d.ts +4 -1
- package/dist/lib/workspaceAdapter.d.ts.map +1 -1
- package/dist/lib/workspaces.d.ts.map +1 -1
- package/dist/lib/workspaces.js +3 -1
- package/docs/adr/0001-groundcrew-uses-but-does-not-provision-sandboxes.md +17 -0
- package/docs/adr/0002-one-ticket-source-path-linear-is-an-adapter.md +17 -0
- package/docs/commands.md +68 -0
- package/docs/configuration.md +121 -0
- package/docs/credentials.md +81 -0
- package/docs/runners.md +45 -0
- package/docs/setup-hook-agent-prompt.md +62 -0
- package/docs/setup-hooks.md +46 -0
- package/docs/ticket-sources.md +45 -0
- package/docs/troubleshooting.md +48 -0
- package/package.json +2 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
|
107
|
-
const all = new
|
|
108
|
-
for (const definition of Object.
|
|
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
|
-
|
|
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
|
|
167
|
-
for (const token of
|
|
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 ?
|
|
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);
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/commands/init.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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"}
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
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") && !
|
|
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 (
|
|
235
|
-
* tear down the orphaned worktree
|
|
236
|
-
* - 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
|
|
248
|
-
|
|
249
|
-
|
|
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") && !
|
|
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,
|
|
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"}
|
package/dist/lib/tmuxAdapter.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
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;
|
|
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,
|
|
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"}
|
package/dist/lib/workspaces.js
CHANGED
|
@@ -74,7 +74,9 @@ async function probeWorkspaces(config, signal) {
|
|
|
74
74
|
if (raw === undefined) {
|
|
75
75
|
return { kind: "unavailable" };
|
|
76
76
|
}
|
|
77
|
-
|
|
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.
|