@clipboard-health/groundcrew 4.7.3 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +5 -5
  2. package/crew.config.example.ts +31 -25
  3. package/dist/commands/doctor.d.ts.map +1 -1
  4. package/dist/commands/doctor.js +4 -4
  5. package/dist/commands/init.d.ts +1 -1
  6. package/dist/commands/init.d.ts.map +1 -1
  7. package/dist/commands/init.js +24 -13
  8. package/dist/commands/setupWorkspace.d.ts.map +1 -1
  9. package/dist/commands/setupWorkspace.js +7 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/lib/adapters/linear/fetch.d.ts +1 -1
  13. package/dist/lib/adapters/linear/fetch.d.ts.map +1 -1
  14. package/dist/lib/adapters/linear/fetch.js +7 -7
  15. package/dist/lib/adapters/linear/parsing.d.ts +1 -1
  16. package/dist/lib/adapters/linear/parsing.d.ts.map +1 -1
  17. package/dist/lib/adapters/linear/parsing.js +9 -9
  18. package/dist/lib/buildSecrets.d.ts +2 -2
  19. package/dist/lib/buildSecrets.js +2 -2
  20. package/dist/lib/config.d.ts +23 -26
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/config.js +79 -49
  23. package/dist/lib/launchCommand.d.ts +7 -6
  24. package/dist/lib/launchCommand.d.ts.map +1 -1
  25. package/dist/lib/launchCommand.js +38 -30
  26. package/dist/lib/repositoryHooks.d.ts +8 -0
  27. package/dist/lib/repositoryHooks.d.ts.map +1 -0
  28. package/dist/lib/repositoryHooks.js +71 -0
  29. package/docs/adr/0001-groundcrew-uses-but-does-not-provision-sandboxes.md +1 -1
  30. package/docs/configuration.md +66 -36
  31. package/docs/credentials.md +6 -6
  32. package/docs/setup-hook-agent-prompt.md +46 -49
  33. package/docs/setup-hooks.md +64 -29
  34. package/docs/troubleshooting.md +3 -3
  35. package/package.json +7 -7
@@ -35,7 +35,7 @@ const DEFAULT_ORCHESTRATOR = {
35
35
  pollIntervalMilliseconds: 120_000,
36
36
  sessionLimitPercentage: 85,
37
37
  };
38
- const DEFAULT_MODEL_DEFINITIONS = {
38
+ const BUILT_IN_MODEL_DEFINITIONS = {
39
39
  claude: {
40
40
  cmd: "claude --permission-mode auto",
41
41
  color: "#C15F3C",
@@ -47,6 +47,30 @@ const DEFAULT_MODEL_DEFINITIONS = {
47
47
  usage: { codexbar: { provider: "codex" } },
48
48
  },
49
49
  };
50
+ const MODEL_DEFINITIONS_MIGRATION_MESSAGE = [
51
+ "configuration migration required: models are no longer enabled by default.",
52
+ "",
53
+ "Add the models you want to use:",
54
+ "",
55
+ "models: {",
56
+ ' default: "claude",',
57
+ " definitions: {",
58
+ " claude: {},",
59
+ " },",
60
+ "},",
61
+ "",
62
+ "To keep the previous claude+codex behavior:",
63
+ "",
64
+ "models: {",
65
+ ' default: "claude",',
66
+ " definitions: {",
67
+ " claude: {},",
68
+ " codex: {},",
69
+ " },",
70
+ "},",
71
+ "",
72
+ "`disabled: true` is no longer supported; remove disabled model entries instead.",
73
+ ].join("\n");
50
74
  const DEFAULT_PROMPT_INITIAL = [
51
75
  "You are working on Linear ticket {{ticket}} ({{title}}) in the {{worktree}} worktree subdirectory.",
52
76
  "",
@@ -128,6 +152,31 @@ function normalizeOptionalString(value, configKey) {
128
152
  }
129
153
  return value.trim();
130
154
  }
155
+ function normalizeHookCommands(value, configKey) {
156
+ if (value === undefined) {
157
+ return {};
158
+ }
159
+ if (!isPlainObject(value)) {
160
+ fail(`${configKey} must be an object`);
161
+ }
162
+ const hooks = {};
163
+ const prepareWorktree = normalizeOptionalString(value["prepareWorktree"], `${configKey}.prepareWorktree`);
164
+ if (prepareWorktree !== undefined) {
165
+ hooks.prepareWorktree = prepareWorktree;
166
+ }
167
+ return hooks;
168
+ }
169
+ function normalizeDefaults(value) {
170
+ if (value === undefined) {
171
+ return { hooks: {} };
172
+ }
173
+ if (!isPlainObject(value)) {
174
+ fail("defaults must be an object");
175
+ }
176
+ return {
177
+ hooks: normalizeHookCommands(value["hooks"], "defaults.hooks"),
178
+ };
179
+ }
131
180
  const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
132
181
  function validatePreLaunchEnv(modelName, value) {
133
182
  const configPath = `models.definitions.${modelName}.preLaunchEnv`;
@@ -139,7 +188,7 @@ function validatePreLaunchEnv(modelName, value) {
139
188
  fail(`${configPath}[${index}] must be a POSIX env var name matching ${ENV_VAR_NAME_PATTERN.source} (got ${JSON.stringify(entry)})`);
140
189
  }
141
190
  // Build secrets are sourced into the host launch shell, forwarded only to
142
- // the Safehouse *setup* wrap, and `unset` on the host before the agent
191
+ // the Safehouse *prepareWorktree* wrap, and `unset` on the host before the agent
143
192
  // wrap is exec'd. Listing one here would silently never reach the agent —
144
193
  // fail loudly so the operator picks a different name (or removes the
145
194
  // entry) instead of debugging a missing env var at runtime.
@@ -196,22 +245,20 @@ function normalizeSandbox(value, configKey) {
196
245
  if (Object.hasOwn(value, "kits")) {
197
246
  failRemovedConfigKey(`${configKey}.kits`, "Groundcrew no longer creates sdx sandboxes or applies sandbox kits.");
198
247
  }
199
- const { agent, setupCommand } = value;
248
+ if (Object.hasOwn(value, "setupCommand")) {
249
+ fail(`${configKey}.setupCommand is no longer supported: use repo-local \`.groundcrew/config.json\` \`hooks.prepareWorktree\`, or \`defaults.hooks.prepareWorktree\` in crew.config.ts when you need a fallback for repos without their own hook.`);
250
+ }
251
+ const { agent } = value;
200
252
  requireString(agent, `${configKey}.agent`);
201
253
  const trimmedAgent = agent.trim();
202
254
  if (trimmedAgent.length === 0) {
203
255
  fail(`${configKey}.agent must be a non-empty string (got ${JSON.stringify(agent)})`);
204
256
  }
205
- const sandbox = { agent: trimmedAgent };
206
- const normalizedSetup = normalizeOptionalString(setupCommand, `${configKey}.setupCommand`);
207
- if (normalizedSetup !== undefined) {
208
- sandbox.setupCommand = normalizedSetup;
209
- }
210
- return sandbox;
257
+ return { agent: trimmedAgent };
211
258
  }
212
259
  function failRemovedConfigKey(configKey, reason) {
213
260
  fail(`${configKey} is no longer supported: ${reason} ` +
214
- "Provision and manage the sandbox yourself with `sbx` (for example `sbx create --name groundcrew-<agent> <agent> <projectDir>`), then keep only `models.definitions.<model>.sandbox.agent` plus optional `setupCommand` in crew.config.ts.");
261
+ "Provision and manage the sandbox yourself with `sbx` (for example `sbx create --name groundcrew-<agent> <agent> <projectDir>`), then keep only `models.definitions.<model>.sandbox.agent` in crew.config.ts.");
215
262
  }
216
263
  function failIfLegacyModelKeys(name, override) {
217
264
  if (!isPlainObject(override)) {
@@ -221,23 +268,16 @@ function failIfLegacyModelKeys(name, override) {
221
268
  fail(`models.definitions.${name}.isolation is no longer supported: per-model isolation is no longer supported`);
222
269
  }
223
270
  if (Object.hasOwn(override, "disabled")) {
224
- if (override["disabled"] !== true) {
225
- fail(`models.definitions.${name}.disabled must be exactly \`true\` when set (got ${JSON.stringify(override["disabled"])})`);
226
- }
227
- const conflicting = ["cmd", "color", "usage", "sandbox", "preLaunch", "preLaunchEnv"].filter((key) => Object.hasOwn(override, key));
228
- if (conflicting.length > 0) {
229
- fail(`models.definitions.${name}: cannot combine \`disabled: true\` with other fields (${conflicting.join(", ")}). Either disable the model or override its fields, not both.`);
230
- }
271
+ fail(MODEL_DEFINITIONS_MIGRATION_MESSAGE);
231
272
  }
232
273
  }
233
274
  /**
234
- * True when `name` is a shipped default the user removed via `disabled: true`.
235
- * Derived from absence in `definitions` that's the only path that removes a
236
- * shipped default, codified in `failIfLegacyModelKeys` + `mergeDefinitions`.
237
- * Consumers needing to distinguish disabled-by-user from unknown-label use this.
275
+ * True when `name` is a built-in preset but not present in the enabled
276
+ * definitions. Consumers use this to distinguish `agent-codex` when codex is
277
+ * not enabled from an arbitrary unknown label like `agent-typo`.
238
278
  */
239
- export function isShippedDefaultDisabled(config, name) {
240
- return (Object.hasOwn(DEFAULT_MODEL_DEFINITIONS, name) &&
279
+ export function isBuiltInModelNotEnabled(config, name) {
280
+ return (Object.hasOwn(BUILT_IN_MODEL_DEFINITIONS, name) &&
241
281
  !Object.hasOwn(config.models.definitions, name));
242
282
  }
243
283
  function isUsageDisableSentinel(usage) {
@@ -274,27 +314,17 @@ function buildOverrideCandidate(name, override, existing) {
274
314
  return candidate;
275
315
  }
276
316
  function mergeDefinitions(user) {
277
- if (user !== undefined && !isPlainObject(user)) {
317
+ if (user === undefined) {
318
+ fail(MODEL_DEFINITIONS_MIGRATION_MESSAGE);
319
+ }
320
+ if (!isPlainObject(user)) {
278
321
  fail("models.definitions must be an object");
279
322
  }
280
- const merged = Object.fromEntries(Object.entries(DEFAULT_MODEL_DEFINITIONS).map(([name, definition]) => [
281
- name,
282
- cloneModelDefinition(definition),
283
- ]));
284
- for (const [name, override] of Object.entries(user ?? {})) {
323
+ const merged = {};
324
+ for (const [name, override] of Object.entries(user)) {
285
325
  failIfLegacyModelKeys(name, override);
286
- if (override.disabled === true) {
287
- if (!Object.hasOwn(DEFAULT_MODEL_DEFINITIONS, name)) {
288
- fail(`models.definitions.${name}: \`disabled: true\` is only valid for shipped defaults (${Object.keys(DEFAULT_MODEL_DEFINITIONS).join(", ")}). Remove the entry instead.`);
289
- }
290
- // Drop the key so downstream iterators (doctor, eligibility, usage) ignore
291
- // the model automatically; `isShippedDefaultDisabled` lets the few consumers
292
- // that need to distinguish disabled from unknown re-derive the set.
293
- // oxlint-disable-next-line typescript/no-dynamic-delete -- `merged` is a fresh function-local clone of DEFAULT_MODEL_DEFINITIONS; no V8 dictionary-mode/pollution concerns
294
- delete merged[name];
295
- continue;
296
- }
297
- const candidate = buildOverrideCandidate(name, override, merged[name]);
326
+ const builtIn = BUILT_IN_MODEL_DEFINITIONS[name];
327
+ const candidate = buildOverrideCandidate(name, override, builtIn);
298
328
  const { cmd, color, usage, sandbox, preLaunch, preLaunchEnv } = candidate;
299
329
  if (typeof cmd !== "string" || cmd.length === 0) {
300
330
  fail(`models.definitions.${name}.cmd must be a non-empty string`);
@@ -416,6 +446,7 @@ function applyDefaults(user) {
416
446
  projectDir: expandHome(user.workspace.projectDir),
417
447
  knownRepositories: user.workspace.knownRepositories,
418
448
  },
449
+ defaults: normalizeDefaults(user.defaults),
419
450
  orchestrator: { ...DEFAULT_ORCHESTRATOR, ...user.orchestrator },
420
451
  models: {
421
452
  default: user.models?.default ?? "claude",
@@ -456,7 +487,6 @@ function validate(config) {
456
487
  requirePositiveInt(config.orchestrator.pollIntervalMilliseconds, "orchestrator.pollIntervalMilliseconds");
457
488
  requirePercent(config.orchestrator.sessionLimitPercentage, "orchestrator.sessionLimitPercentage");
458
489
  const { definitions } = config.models;
459
- /* v8 ignore next 3 @preserve -- mergeDefinitions seeds claude+codex defaults, so an empty map is unreachable */
460
490
  if (Object.keys(definitions).length === 0) {
461
491
  fail("models.definitions must contain at least one model");
462
492
  }
@@ -468,12 +498,12 @@ function validate(config) {
468
498
  requireString(definition.color, `models.definitions.${name}.color`);
469
499
  if (definition.usage !== undefined) {
470
500
  const usagePath = `models.definitions.${name}.usage`;
471
- /* v8 ignore next 3 @preserve -- mergeDefinitions only assigns usage from validated overrides or shipped defaults; reaching this guard requires hand-mutating the resolved config */
501
+ /* v8 ignore next 3 @preserve -- mergeDefinitions only assigns usage from validated overrides or built-in presets; reaching this guard requires hand-mutating the resolved config */
472
502
  if (typeof definition.usage !== "object" || definition.usage === null) {
473
503
  fail(`${usagePath} must be an object`);
474
504
  }
475
505
  const { codexbar } = definition.usage;
476
- /* v8 ignore next 3 @preserve -- mergeDefinitions only assigns usage from validated overrides or shipped defaults; reaching this guard requires hand-mutating the resolved config */
506
+ /* v8 ignore next 3 @preserve -- mergeDefinitions only assigns usage from validated overrides or built-in presets; reaching this guard requires hand-mutating the resolved config */
477
507
  if (typeof codexbar !== "object" || codexbar === null) {
478
508
  fail(`${usagePath}.codexbar must be an object`);
479
509
  }
@@ -496,11 +526,11 @@ function validate(config) {
496
526
  if (!LOCAL_RUNNER_SETTINGS.includes(config.local.runner)) {
497
527
  fail(`local.runner must be one of ${LOCAL_RUNNER_SETTINGS.join(", ")} (got ${JSON.stringify(config.local.runner)})`);
498
528
  }
499
- // Disabled-default check must run before the generic "not a key" check so
500
- // the user gets the specific "is disabled" message instead of a stale-list
501
- // message they can't act on without realizing they need to re-enable.
502
- if (isShippedDefaultDisabled(config, config.models.default)) {
503
- fail(`models.default ("${config.models.default}") is disabled. Either re-enable it or set models.default to an enabled model.`);
529
+ // Built-in-not-enabled check must run before the generic "not a key" check
530
+ // so the user gets the specific migration-oriented message for `codex`
531
+ // instead of a stale-list message.
532
+ if (isBuiltInModelNotEnabled(config, config.models.default)) {
533
+ fail(`models.default ("${config.models.default}") is not enabled. Add \`models.definitions.${config.models.default}: {}\` or set models.default to an enabled model.`);
504
534
  }
505
535
  if (!(config.models.default in definitions)) {
506
536
  fail(`models.default ("${config.models.default}") is not a key in models.definitions (have: ${Object.keys(definitions).join(", ")})`);
@@ -11,11 +11,6 @@ export { shellSingleQuote } from "./shell.ts";
11
11
  * exercise the catch branch.
12
12
  */
13
13
  export declare function resolveSafehouseClearancePath(baseUrl?: string): string;
14
- /**
15
- * Per-repo setup hook: if `.groundcrew/setup.sh` exists, run it with
16
- * `--deps-only`; otherwise no-op.
17
- */
18
- export declare const SETUP_COMMAND = "if [ -f .groundcrew/setup.sh ]; then bash .groundcrew/setup.sh --deps-only; fi";
19
14
  interface LaunchCommandArguments {
20
15
  definition: ModelDefinition;
21
16
  promptFile: string;
@@ -23,11 +18,17 @@ interface LaunchCommandArguments {
23
18
  /**
24
19
  * Optional path to a `KEY='value'` env file containing build-time
25
20
  * secrets (see `BUILD_SECRET_NAMES`). Sourced on the host shell before
26
- * setup; for the sdx runner the names are propagated into the sandbox
21
+ * prepareWorktree; for the sdx runner the names are propagated into the sandbox
27
22
  * via `sbx exec -e KEY`. Always unset before exec'ing the agent so the
28
23
  * agent process never inherits them.
29
24
  */
30
25
  secretsFile?: string | undefined;
26
+ /**
27
+ * Optional repo-preparation hook resolved by the caller from the freshly
28
+ * created worktree's `.groundcrew/config.json`, falling back to
29
+ * `defaults.hooks.prepareWorktree` from crew.config.ts.
30
+ */
31
+ prepareWorktreeCommand?: string | undefined;
31
32
  /**
32
33
  * Concrete local isolation backend chosen for this launch. Resolved
33
34
  * from `config.local.runner` via `resolveLocalRunner` before this
@@ -1 +1 @@
1
- {"version":3,"file":"launchCommand.d.ts","sourceRoot":"","sources":["../../src/lib/launchCommand.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,eAAe,EACrB,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;;;;GASG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,GAAE,MAAwB,GAAG,MAAM,CAcvF;AAID;;;GAGG;AACH,eAAO,MAAM,aAAa,mFACwD,CAAC;AAyKnF,UAAU,sBAAsB;IAC9B,UAAU,EAAE,eAAe,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC;;;;OAIG;IACH,MAAM,EAAE,WAAW,CAAC;IACpB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,sBAAsB,GAAG,MAAM,CA0B7E"}
1
+ {"version":3,"file":"launchCommand.d.ts","sourceRoot":"","sources":["../../src/lib/launchCommand.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,eAAe,EACrB,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;;;;GASG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,GAAE,MAAwB,GAAG,MAAM,CAcvF;AA2KD,UAAU,sBAAsB;IAC9B,UAAU,EAAE,eAAe,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5C;;;;OAIG;IACH,MAAM,EAAE,WAAW,CAAC;IACpB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,sBAAsB,GAAG,MAAM,CA0B7E"}
@@ -25,11 +25,6 @@ export function resolveSafehouseClearancePath(baseUrl = import.meta.url) {
25
25
  return path.resolve(path.dirname(clearancePackageJson), "safehouse", "safehouse-clearance");
26
26
  }
27
27
  const SAFEHOUSE_CLEARANCE_WRAPPER_PATH = resolveSafehouseClearancePath();
28
- /**
29
- * Per-repo setup hook: if `.groundcrew/setup.sh` exists, run it with
30
- * `--deps-only`; otherwise no-op.
31
- */
32
- export const SETUP_COMMAND = "if [ -f .groundcrew/setup.sh ]; then bash .groundcrew/setup.sh --deps-only; fi";
33
28
  function renderAgentCommand(arguments_) {
34
29
  return arguments_.agentCmd
35
30
  .replaceAll("{{worktree}}", shellSingleQuote(arguments_.worktreeDir))
@@ -38,17 +33,17 @@ function renderAgentCommand(arguments_) {
38
33
  function renderPreLaunch(preLaunch, worktreeDir) {
39
34
  return preLaunch.replaceAll("{{worktree}}", shellSingleQuote(worktreeDir));
40
35
  }
41
- function setupWithStatusReporting(setupCommand) {
36
+ function prepareWorktreeWithStatusReporting(prepareWorktreeCommand) {
42
37
  return [
43
- setupCommand,
44
- "setup_status=$?",
45
- 'if [ "$setup_status" -ne 0 ]; then echo "groundcrew setup command exited with status $setup_status; continuing to agent." >&2; fi',
38
+ `(${prepareWorktreeCommand})`,
39
+ "prepare_status=$?",
40
+ 'if [ "$prepare_status" -ne 0 ]; then echo "groundcrew prepareWorktree hook exited with status $prepare_status; continuing to agent." >&2; fi',
46
41
  ].join("; ");
47
42
  }
48
43
  /**
49
44
  * Source a `KEY='value'` file with auto-export so build-time secrets land
50
- * in the shell env before setup runs. The `-f` guard keeps it a no-op if
51
- * the file disappeared between staging and launch.
45
+ * in the shell env before prepareWorktree runs. The `-f` guard keeps it a
46
+ * no-op if the file disappeared between staging and launch.
52
47
  */
53
48
  function sourceSecretsLine(secretsFile) {
54
49
  return `if [ -f ${shellSingleQuote(secretsFile)} ]; then set -a && . ${shellSingleQuote(secretsFile)} && set +a; fi`;
@@ -196,8 +191,9 @@ export function buildLaunchCommand(arguments_) {
196
191
  /**
197
192
  * The Safehouse wrap applies only when `runner === "safehouse"` and `cmd` does
198
193
  * not already invoke `safehouse` itself. A `safehouse …` cmd owns its own
199
- * sandbox flags, and we can't splice setup into a command we don't control, so
200
- * those (and the `none` runner) fall through to the unwrapped host path.
194
+ * sandbox flags, and we can't splice prepareWorktree into a command we don't
195
+ * control, so those (and the `none` runner) fall through to the unwrapped host
196
+ * path.
201
197
  */
202
198
  function shouldWrapWithSafehouse(arguments_) {
203
199
  if (arguments_.runner !== "safehouse") {
@@ -207,8 +203,9 @@ function shouldWrapWithSafehouse(arguments_) {
207
203
  }
208
204
  /**
209
205
  * Unsandboxed host launch (`runner === "none"`, or a `safehouse …` cmd that
210
- * brings its own wrap). Setup, secret sourcing, and the agent all run on the
211
- * host shell because there is no groundcrew-managed sandbox to run them inside.
206
+ * brings its own wrap). prepareWorktree, secret sourcing, and the agent all run
207
+ * on the host shell because there is no groundcrew-managed sandbox to run them
208
+ * inside.
212
209
  */
213
210
  function buildUnwrappedHostLaunchCommand(arguments_) {
214
211
  const promptDir = path.dirname(arguments_.promptFile);
@@ -220,8 +217,10 @@ function buildUnwrappedHostLaunchCommand(arguments_) {
220
217
  const lines = [
221
218
  ...hostTrapAndCd({ worktreeDir: arguments_.worktreeDir, promptDir }),
222
219
  ...hostSourceSecrets(arguments_.secretsFile),
223
- setupWithStatusReporting(SETUP_COMMAND),
224
220
  ];
221
+ if (arguments_.prepareWorktreeCommand !== undefined) {
222
+ lines.push(prepareWorktreeWithStatusReporting(arguments_.prepareWorktreeCommand));
223
+ }
225
224
  if (arguments_.secretsFile !== undefined) {
226
225
  lines.push(unsetSecretsLine());
227
226
  }
@@ -237,9 +236,11 @@ function buildUnwrappedHostLaunchCommand(arguments_) {
237
236
  /**
238
237
  * Safehouse launch. Two Safehouse wraps, by design:
239
238
  *
240
- * 1. **Setup wrap**: plain `safehouse-clearance ... sh -c '<setup>'`. Runs
241
- * `.groundcrew/setup.sh --deps-only` filesystem-isolated and
242
- * egress-restricted, **without** inheriting agent-profile grants.
239
+ * 1. **prepareWorktree wrap**: plain
240
+ * `safehouse-clearance ... sh -c '<prepareWorktree>'`. Runs the repo
241
+ * preparation hook filesystem-isolated and egress-restricted,
242
+ * **without** inheriting agent-profile grants. Omitted entirely when no
243
+ * hook command is configured.
243
244
  * 2. **Agent wrap**: `safehouse-clearance "$shim" -c '<exec agent>' sh "$_p"`
244
245
  * where `$shim` is a `mktemp`-d symlink to `/bin/sh` named after the
245
246
  * agent (e.g. `claude`). Safehouse selects the matching agent profile
@@ -253,14 +254,14 @@ function buildUnwrappedHostLaunchCommand(arguments_) {
253
254
  * from which `stageBuildSecrets` reads them) nor file-sourced values — and keeps
254
255
  * stale same-named ambient credentials from being forwarded. `secrets.env` is
255
256
  * then sourced into the host launch shell so Safehouse can forward build secrets
256
- * into the **setup wrap** via `--env-pass=` (Safehouse's `--env=FILE` mode strips
257
- * them otherwise). After setup returns, `BUILD_SECRET_NAMES` are `unset` again
257
+ * into the **prepareWorktree wrap** via `--env-pass=` (Safehouse's `--env=FILE` mode strips
258
+ * them otherwise). After prepareWorktree returns, `BUILD_SECRET_NAMES` are `unset` again
258
259
  * on the host so they cannot reach the agent wrap.
259
260
  *
260
261
  * `--env-pass` composition is split per wrap (deliberate, post PR #128):
261
- * - Setup wrap forwards build secrets only.
262
+ * - prepareWorktree wrap forwards build secrets only.
262
263
  * - Agent wrap forwards `preLaunchEnv` names only. preLaunch credentials never
263
- * reach the profile-neutral setup phase.
264
+ * reach the profile-neutral prepare phase.
264
265
  */
265
266
  function buildSafehouseLaunchCommand(arguments_) {
266
267
  const promptDir = path.dirname(arguments_.promptFile);
@@ -270,15 +271,17 @@ function buildSafehouseLaunchCommand(arguments_) {
270
271
  worktreeDir: arguments_.worktreeDir,
271
272
  sandboxName: "",
272
273
  });
273
- const setupCommand = setupWithStatusReporting(SETUP_COMMAND);
274
+ const prepareWorktreeCommand = arguments_.prepareWorktreeCommand === undefined
275
+ ? undefined
276
+ : prepareWorktreeWithStatusReporting(arguments_.prepareWorktreeCommand);
274
277
  const agentCommand = `exec ${agentCmd} "$@"`;
275
- // Split --env-pass per wrap: the setup wrap only needs build secrets (so
278
+ // Split --env-pass per wrap: the prepareWorktree wrap only needs build secrets (so
276
279
  // `npm install` etc. can authenticate); the agent wrap only needs the
277
280
  // user's preLaunchEnv (build secrets are `unset` on the host between the
278
281
  // two wraps, so forwarding them here would silently no-op). Keeps preLaunch
279
- // credentials out of the profile-neutral setup phase — see PR #128.
282
+ // credentials out of the profile-neutral prepare phase — see PR #128.
280
283
  // Trailing space keeps each flag separated from the next argv token.
281
- const setupEnvPassFlag = arguments_.secretsFile === undefined ? "" : `--env-pass=${BUILD_SECRET_NAMES.join(",")} `;
284
+ const prepareWorktreeEnvPassFlag = arguments_.secretsFile === undefined ? "" : `--env-pass=${BUILD_SECRET_NAMES.join(",")} `;
282
285
  const preLaunchEnvNames = arguments_.definition.preLaunchEnv ?? [];
283
286
  const agentEnvPassFlag = preLaunchEnvNames.length === 0 ? "" : `--env-pass=${preLaunchEnvNames.join(",")} `;
284
287
  const safehouseWrapper = shellSingleQuote(SAFEHOUSE_CLEARANCE_WRAPPER_PATH);
@@ -298,7 +301,10 @@ function buildSafehouseLaunchCommand(arguments_) {
298
301
  lines.push(unsetEnvironmentLine([...BUILD_SECRET_NAMES, ...preLaunchEnvNames]));
299
302
  lines.push(renderPreLaunch(arguments_.definition.preLaunch, arguments_.worktreeDir));
300
303
  }
301
- lines.push(...hostSourceSecrets(arguments_.secretsFile), `_p=$(cat ${shellSingleQuote(arguments_.promptFile)})`, `rm -rf ${shellSingleQuote(promptDir)}`, `${safehouseWrapper} ${setupEnvPassFlag}sh -c ${shellSingleQuote(setupCommand)}`);
304
+ lines.push(...hostSourceSecrets(arguments_.secretsFile), `_p=$(cat ${shellSingleQuote(arguments_.promptFile)})`, `rm -rf ${shellSingleQuote(promptDir)}`);
305
+ if (prepareWorktreeCommand !== undefined) {
306
+ lines.push(`${safehouseWrapper} ${prepareWorktreeEnvPassFlag}sh -c ${shellSingleQuote(prepareWorktreeCommand)}`);
307
+ }
302
308
  if (arguments_.secretsFile !== undefined) {
303
309
  lines.push(unsetSecretsLine());
304
310
  }
@@ -321,8 +327,10 @@ function buildSdxLaunchCommand(arguments_) {
321
327
  worktreeDir: arguments_.worktreeDir,
322
328
  sandboxName: arguments_.sandboxName,
323
329
  });
324
- const setupCommand = arguments_.definition.sandbox.setupCommand ?? SETUP_COMMAND;
325
- const innerParts = [setupWithStatusReporting(setupCommand)];
330
+ const innerParts = [];
331
+ if (arguments_.prepareWorktreeCommand !== undefined) {
332
+ innerParts.push(prepareWorktreeWithStatusReporting(arguments_.prepareWorktreeCommand));
333
+ }
326
334
  if (arguments_.secretsFile !== undefined) {
327
335
  innerParts.push(unsetSecretsLine());
328
336
  }
@@ -0,0 +1,8 @@
1
+ import type { HookCommands } from "./config.ts";
2
+ interface ResolvePrepareWorktreeCommandArguments {
3
+ worktreeDir: string;
4
+ defaultHooks: HookCommands;
5
+ }
6
+ export declare function resolvePrepareWorktreeCommand(arguments_: ResolvePrepareWorktreeCommandArguments): string | undefined;
7
+ export {};
8
+ //# sourceMappingURL=repositoryHooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repositoryHooks.d.ts","sourceRoot":"","sources":["../../src/lib/repositoryHooks.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAIhD,UAAU,sCAAsC;IAC9C,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,YAAY,CAAC;CAC5B;AAED,wBAAgB,6BAA6B,CAC3C,UAAU,EAAE,sCAAsC,GACjD,MAAM,GAAG,SAAS,CAGpB"}
@@ -0,0 +1,71 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ const REPOSITORY_CONFIG_RELATIVE_PATH = ".groundcrew/config.json";
4
+ export function resolvePrepareWorktreeCommand(arguments_) {
5
+ const repositoryConfig = readRepositoryConfig(arguments_.worktreeDir);
6
+ return repositoryConfig?.hooks.prepareWorktree ?? arguments_.defaultHooks.prepareWorktree;
7
+ }
8
+ function readRepositoryConfig(worktreeDir) {
9
+ const configPath = path.join(worktreeDir, REPOSITORY_CONFIG_RELATIVE_PATH);
10
+ let contents;
11
+ try {
12
+ contents = readFileSync(configPath, "utf8");
13
+ }
14
+ catch (error) {
15
+ if (isFileNotFoundError(error)) {
16
+ return undefined;
17
+ }
18
+ throw new Error(`Could not read ${REPOSITORY_CONFIG_RELATIVE_PATH}.`, { cause: error });
19
+ }
20
+ let parsed;
21
+ try {
22
+ parsed = JSON.parse(contents);
23
+ }
24
+ catch (error) {
25
+ throw new Error(`${REPOSITORY_CONFIG_RELATIVE_PATH}: expected valid JSON.`, { cause: error });
26
+ }
27
+ return normalizeRepositoryConfig(parsed);
28
+ }
29
+ function normalizeRepositoryConfig(value) {
30
+ if (!isPlainObject(value)) {
31
+ fail("must be a JSON object");
32
+ }
33
+ if (value["version"] !== 1) {
34
+ fail("version must be 1");
35
+ }
36
+ return {
37
+ hooks: normalizeHookCommands(value["hooks"]),
38
+ };
39
+ }
40
+ function normalizeHookCommands(value) {
41
+ if (value === undefined) {
42
+ return {};
43
+ }
44
+ if (!isPlainObject(value)) {
45
+ fail("hooks must be an object");
46
+ }
47
+ const hooks = {};
48
+ const prepareWorktree = normalizeOptionalHookCommand(value["prepareWorktree"], "hooks.prepareWorktree");
49
+ if (prepareWorktree !== undefined) {
50
+ hooks.prepareWorktree = prepareWorktree;
51
+ }
52
+ return hooks;
53
+ }
54
+ function normalizeOptionalHookCommand(value, configKey) {
55
+ if (value === undefined) {
56
+ return undefined;
57
+ }
58
+ if (typeof value !== "string" || value.trim().length === 0) {
59
+ fail(`${configKey} must be a non-empty string`);
60
+ }
61
+ return value.trim();
62
+ }
63
+ function fail(message) {
64
+ throw new Error(`${REPOSITORY_CONFIG_RELATIVE_PATH}: ${message}`);
65
+ }
66
+ function isFileNotFoundError(error) {
67
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
68
+ }
69
+ function isPlainObject(value) {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value);
71
+ }
@@ -7,7 +7,7 @@ Groundcrew launches agent processes _inside_ an isolation backend (safehouse on
7
7
  ## Considered Options
8
8
 
9
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.
10
+ - **Generalize the launch wrap to a user-supplied template string** — rejected for now: the build-time-secrets and `prepareWorktree` 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
11
 
12
12
  ## Consequences
13
13