@cat-factory/server 0.311.3 → 0.314.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 (31) hide show
  1. package/dist/agents/ContainerRepoBootstrapper.d.ts +120 -9
  2. package/dist/agents/ContainerRepoBootstrapper.d.ts.map +1 -1
  3. package/dist/agents/ContainerRepoBootstrapper.js +448 -177
  4. package/dist/agents/ContainerRepoBootstrapper.js.map +1 -1
  5. package/dist/agents/agentContextRecord.d.ts +25 -0
  6. package/dist/agents/agentContextRecord.d.ts.map +1 -1
  7. package/dist/agents/agentContextRecord.js +57 -0
  8. package/dist/agents/agentContextRecord.js.map +1 -1
  9. package/dist/app.d.ts.map +1 -1
  10. package/dist/app.js +2 -0
  11. package/dist/app.js.map +1 -1
  12. package/dist/modules/bugFishing/BugFishingController.d.ts +14 -0
  13. package/dist/modules/bugFishing/BugFishingController.d.ts.map +1 -0
  14. package/dist/modules/bugFishing/BugFishingController.js +56 -0
  15. package/dist/modules/bugFishing/BugFishingController.js.map +1 -0
  16. package/dist/modules/execution/ExecutionController.js +5 -0
  17. package/dist/modules/execution/ExecutionController.js.map +1 -1
  18. package/dist/modules/github/GitHubController.js +7 -0
  19. package/dist/modules/github/GitHubController.js.map +1 -1
  20. package/dist/modules/publicApi/PublicProvisioningController.d.ts.map +1 -1
  21. package/dist/modules/publicApi/PublicProvisioningController.js +1 -0
  22. package/dist/modules/publicApi/PublicProvisioningController.js.map +1 -1
  23. package/dist/modules/publicApi/openapiDocument.generated.d.ts +1 -1
  24. package/dist/modules/publicApi/openapiDocument.generated.d.ts.map +1 -1
  25. package/dist/modules/publicApi/openapiDocument.generated.js +1 -1
  26. package/dist/modules/publicApi/openapiDocument.generated.js.map +1 -1
  27. package/dist/persistence/mappers.d.ts +2 -0
  28. package/dist/persistence/mappers.d.ts.map +1 -1
  29. package/dist/persistence/mappers.js +5 -0
  30. package/dist/persistence/mappers.js.map +1 -1
  31. package/package.json +8 -8
@@ -1,5 +1,8 @@
1
- import { failureKindFromHarnessCause } from '@cat-factory/kernel';
1
+ import { failureKindFromHarnessCause, runBestEffort } from '@cat-factory/kernel';
2
2
  import { isProxyableProvider } from '@cat-factory/agents';
3
+ import { bootstrapStepIds, REPO_BOOTSTRAP_AGENT_KIND } from '@cat-factory/contracts';
4
+ import { recordBootstrapContextSnapshot } from './agentContextRecord.js';
5
+ import { drainToolCalls } from './toolTrajectory.js';
3
6
  import { RunnerJobClient } from './RunnerJobClient.js';
4
7
  import { logger } from '../observability/logger.js';
5
8
  /** The role prompt when adapting a cloned reference architecture. */
@@ -10,31 +13,89 @@ const ADAPT_SYSTEM_PROMPT = 'You are a repository bootstrapper. You have a fresh
10
13
  'focused, idiomatic changes that match the existing structure. Do not invent ' +
11
14
  'unrelated features.';
12
15
  /**
13
- * The role prompt when writing a new service INTO an existing monorepo.
16
+ * The role prompt when writing a new service INTO an existing monorepo, told where its work
17
+ * lands.
14
18
  *
15
- * Distinct from the two below in the thing it keeps saying: the checkout is not the new
16
- * service's to reshape. The agent has the monorepo (writable, at a work branch) and the
17
- * reference template beside it as a read-only sibling, and its whole job is confined to one
18
- * new subdirectory plus the minimum registration the monorepo's own tooling needs. Every
19
- * cross-cutting choice it might otherwise make has already been made by a human and is stated
20
- * in the brief, so the prompt's job is to stop it re-deciding them.
19
+ * Distinct from the ones below in the thing it keeps saying: the checkout is not the new
20
+ * service's to reshape. The agent has the monorepo (writable) and the reference template beside
21
+ * it as a read-only sibling, and its whole job is confined to one new subdirectory plus the
22
+ * minimum registration the monorepo's own tooling needs. Every cross-cutting choice it might
23
+ * otherwise make has already been made by a human and is stated in the brief, so the prompt's
24
+ * job is to stop it re-deciding them.
25
+ *
26
+ * Two of its sentences are delivery-specific, and both are the kind that changes what the agent
27
+ * does. WHICH BRANCH it is on is the licence to commit loosely or not: under `direct_push` the
28
+ * harness checkpoints every commit straight to the branch every other service is built from, and
29
+ * an agent told it is on a "fresh work branch" is being told the opposite of that. And WHERE a
30
+ * deviation goes has to be somewhere that exists: routing it to a pull request description on a
31
+ * run that opens no pull request is how a caveat about a settled decision is silently lost.
32
+ */
33
+ function monorepoSystemPrompt(delivery) {
34
+ const checkout = delivery === 'pull_request'
35
+ ? 'Your working directory is the monorepo checkout, already on a fresh work branch. '
36
+ : "Your working directory is the monorepo checkout, on the monorepo's OWN DEFAULT BRANCH: " +
37
+ 'every commit you make is pushed to the branch every other service in it is built from, ' +
38
+ 'as you make it. So commit only work you would be willing to merge, keep the tree ' +
39
+ 'building at every commit, and never rewrite, revert or force-push history that was ' +
40
+ 'there before you. ';
41
+ const deviation = delivery === 'pull_request'
42
+ ? 'say so in the pull request description'
43
+ : 'say so in the commit message that makes the change';
44
+ return ('You are adding a NEW service to an existing monorepo. ' +
45
+ checkout +
46
+ 'A reference template repository is ' +
47
+ 'checked out READ-ONLY as a sibling directory beside it: read from it freely, copy from it ' +
48
+ 'where the brief says to, and never write to it. ' +
49
+ 'Create the new service in the subdirectory the brief names, and touch NOTHING else in the ' +
50
+ 'monorepo except the minimum registration its own tooling requires (a workspace list, a ' +
51
+ 'build-graph entry, a CI matrix entry). Modifying an existing service is out of scope, and ' +
52
+ 'so is reformatting, upgrading or "tidying" anything you did not add. ' +
53
+ 'The brief carries adoption decisions a human has already reviewed and settled: for each ' +
54
+ 'area they say whether the new service follows the monorepo or the template. Follow them ' +
55
+ 'exactly. Do not substitute your own preference for one of them, and if a decision cannot ' +
56
+ `be honoured as written, do the closest thing that respects it and ${deviation} rather than ` +
57
+ 'quietly taking the other side. ' +
58
+ 'Match the surrounding monorepo in everything the brief does NOT settle: its naming, its ' +
59
+ 'file layout, its dependency versions, its lint and test conventions. Leave the new service ' +
60
+ 'building and its tests passing.');
61
+ }
62
+ /**
63
+ * The role prompt when filling a NEW repository from a reference template, delivered as a pull
64
+ * request.
65
+ *
66
+ * Distinct from {@link ADAPT_SYSTEM_PROMPT} in what the checkout IS: there the agent works in a
67
+ * clone OF the template and reshapes it, here it works in the new repository (which holds only
68
+ * its initial README) with the template read-only beside it. Saying "adapt this in place" to an
69
+ * agent whose cwd is the empty repository is how a run ends with the template untouched and the
70
+ * repository still empty.
71
+ */
72
+ const PR_ADAPT_SYSTEM_PROMPT = 'You are a repository bootstrapper. Your working directory is a BRAND-NEW repository, ' +
73
+ 'holding nothing but the initial README/.gitignore/license it was created with, already on a ' +
74
+ 'fresh work branch. A reference architecture (a base/golden-template repository) is checked ' +
75
+ 'out READ-ONLY as a sibling directory beside it: read from it freely, copy what the new ' +
76
+ 'service needs into your working directory, and never write to it. ' +
77
+ 'Adapt what you copy into the new service per the instructions: rename packages/modules, ' +
78
+ 'leave out pieces that do not apply, write the README and metadata for THIS service, and ' +
79
+ 'leave the project building. Make focused, idiomatic changes that match the structure the ' +
80
+ 'template establishes. Do not invent unrelated features. ' +
81
+ 'Your work is delivered as a pull request a person reviews, so it must stand on its own: no ' +
82
+ 'placeholder files you meant to fill in later, and no references to paths that only exist in ' +
83
+ 'the template.';
84
+ /**
85
+ * The role prompt when scaffolding a new repository from scratch, delivered as a pull request.
86
+ *
87
+ * Same job as {@link SCAFFOLD_SYSTEM_PROMPT}, said to an agent that is NOT in an empty
88
+ * directory: the repository already carries its initial commit, which is what the pull request
89
+ * is opened against.
21
90
  */
22
- const MONOREPO_SYSTEM_PROMPT = 'You are adding a NEW service to an existing monorepo. Your working directory is the ' +
23
- 'monorepo checkout, already on a fresh work branch. A reference template repository is ' +
24
- 'checked out READ-ONLY as a sibling directory beside it: read from it freely, copy from it ' +
25
- 'where the brief says to, and never write to it. ' +
26
- 'Create the new service in the subdirectory the brief names, and touch NOTHING else in the ' +
27
- 'monorepo except the minimum registration its own tooling requires (a workspace list, a ' +
28
- 'build-graph entry, a CI matrix entry). Modifying an existing service is out of scope, and ' +
29
- 'so is reformatting, upgrading or "tidying" anything you did not add. ' +
30
- 'The brief carries adoption decisions a human has already reviewed and settled: for each ' +
31
- 'area they say whether the new service follows the monorepo or the template. Follow them ' +
32
- 'exactly. Do not substitute your own preference for one of them, and if a decision cannot be ' +
33
- 'honoured as written, do the closest thing that respects it and say so in the pull request ' +
34
- 'description rather than quietly taking the other side. ' +
35
- 'Match the surrounding monorepo in everything the brief does NOT settle: its naming, its ' +
36
- 'file layout, its dependency versions, its lint and test conventions. Leave the new service ' +
37
- 'building and its tests passing.';
91
+ const PR_SCAFFOLD_SYSTEM_PROMPT = 'You are a repository bootstrapper. Your working directory is a BRAND-NEW repository, ' +
92
+ 'holding nothing but the initial README/.gitignore/license it was created with, already on a ' +
93
+ 'fresh work branch. Scaffold the service described in the instructions into it: create a ' +
94
+ 'sensible, idiomatic project layout with source files, a README, and the metadata and ' +
95
+ 'build/config files appropriate for the stack, leaving the project building. Keep the scope ' +
96
+ 'to what the instructions describe; do not invent unrelated features. ' +
97
+ 'Your work is delivered as a pull request a person reviews, so it must stand on its own: no ' +
98
+ 'placeholder files you meant to fill in later.';
38
99
  /** The role prompt when scaffolding a brand-new repository from scratch. */
39
100
  const SCAFFOLD_SYSTEM_PROMPT = 'You are a repository bootstrapper. You are working in an empty directory and ' +
40
101
  'must scaffold a brand-new repository from scratch per the instructions. Create ' +
@@ -100,6 +161,12 @@ export class ContainerRepoBootstrapper {
100
161
  * Pre-flight the target repo and dispatch the bootstrap container as a
101
162
  * background job (returns once accepted, like `/run`). Throws on a pre-flight
102
163
  * failure so the run fails fast before a board frame is created.
164
+ *
165
+ * THREE dispatch shapes, chosen by the target and the run's delivery. A new repository
166
+ * delivered by `direct_push` is the original one (adapt a clone, reinitialise, force-push);
167
+ * everything else is the ordinary coding shape (clone the writable target, template beside it
168
+ * read-only, work branch, pull request), because the target already holds a history nobody may
169
+ * reset: the monorepo's, or the initial commit a pull request has to be opened against.
103
170
  */
104
171
  async startBootstrap(request) {
105
172
  const log = logger.child({ jobId: request.jobId, workspaceId: request.workspaceId });
@@ -112,90 +179,37 @@ export class ContainerRepoBootstrapper {
112
179
  `(Workers AI, or a direct OpenAI-compatible provider); ` +
113
180
  `'${this.deps.model.provider}' is not supported.`);
114
181
  }
115
- // A monorepo run has a completely different pre-flight and a completely different push, so
116
- // it branches before the new-repo checks below: there is no repository to create, nothing to
117
- // be empty, and force-pushing a fresh history would destroy the target.
182
+ // A monorepo run has a completely different pre-flight, so it branches before the new-repo
183
+ // checks below: there is no repository to create and nothing to be empty.
118
184
  if (request.monorepo) {
119
185
  return await this.startMonorepoBootstrap(request, request.monorepo, installation);
120
186
  }
121
- // The target repo is created up front — by the user via GitHub's new-repo page,
122
- // or, for privileged-tier orgs (ADR 0005), programmatically via the create-repo
123
- // endpoint behind the modal's "Create repository" button. Resolve it under the
124
- // installation account to confirm it exists, is reachable by the App, and is
125
- // empty — the run pushes the bootstrapped contents as the initial commit.
187
+ const target = await this.preflightNewRepoTarget(request, installation, log);
188
+ if (request.delivery.mode === 'pull_request') {
189
+ return await this.startNewRepoPullRequest(request, target, installation, log);
190
+ }
126
191
  const owner = installation.accountLogin;
127
192
  const repoName = request.target.name;
128
- const ref = { owner, repo: repoName };
129
- log.info('bootstrap: pre-flighting target repo', { target: `${owner}/${repoName}` });
130
- let target;
131
- try {
132
- target = await this.deps.githubClient.getRepo(installation.installationId, ref);
133
- }
134
- catch {
135
- throw new Error(`Repository ${owner}/${repoName} was not found or is not accessible to the GitHub App. ` +
136
- `Create a repository named "${repoName}" under ${owner} (an initial README, .gitignore ` +
137
- `or license is fine), make sure the App is installed on it, then run bootstrap again.`);
138
- }
139
- // The repo being *readable* is not enough: bootstrapping ends in a force-push, so
140
- // the installation must have write access. A public repo the App can read but is
141
- // not granted (not in the App's selected-repos list, or the App lacks
142
- // contents:write) reads fine above but 403s on the container's push — pre-flight
143
- // it here so that case fails fast with an actionable message instead of failing
144
- // deep inside the run after a board frame has been created.
145
- if (!(await this.deps.githubClient.canPush(installation.installationId, ref))) {
146
- throw new Error(`The GitHub App can see ${owner}/${repoName} but does not have write access to it, so the ` +
147
- `bootstrapped commit cannot be pushed. Grant the App write access to this repository ` +
148
- `(GitHub → Settings → Applications → the cat-factory App → Configure → Repository access — ` +
149
- `add "${repoName}" or allow all repositories), or, in local mode, use a GitHub PAT that ` +
150
- `can push to it. Then run bootstrap again.`);
151
- }
152
- // The run replaces the repo's contents with a fresh single-commit history, so
153
- // the target must be empty — except that GitHub's create-repo page often
154
- // prepopulates a README, .gitignore and/or license. Those are throwaway
155
- // boilerplate, so tolerate a repo that holds *only* them (the push force-
156
- // overwrites them); reject anything with real content to avoid clobbering work.
157
- const rootEntries = await this.deps.githubClient.listRootEntries(installation.installationId, ref);
158
- const realContent = rootEntries.filter((entry) => !isBootstrapBoilerplate(entry));
159
- if (realContent.length > 0) {
160
- const sample = realContent
161
- .map((entry) => entry.path)
162
- .slice(0, 5)
163
- .join(', ');
164
- throw new Error(`Repository ${owner}/${repoName} already has content (${sample}). Bootstrapping replaces ` +
165
- `the repository's contents, so it needs an empty repository — or one prepopulated only ` +
166
- `with a README, .gitignore, license and/or AGENTS.md.`);
167
- }
168
- // Scoped to the one repo being bootstrapped: the run clones and force-pushes exactly this
169
- // target and touches nothing else, so there is no leg to widen for. `target` came from the
170
- // pre-flight `getRepo` above, which is why this costs no extra read.
193
+ // With a reference architecture the container clones + adapts it; without one
194
+ // it scaffolds an empty repo from the freeform instructions alone.
195
+ const reference = await this.resolveReferenceTemplate(request, installation.installationId, log);
196
+ // Scoped to the repos this run touches: the target it force-pushes, and the template it
197
+ // CLONES. The template belongs on the scope even though nothing pushes to it, because the
198
+ // clone runs on this same token: leaving it off works only for a public reference
199
+ // architecture and 404s on a private one, with nothing in the failure naming the cause.
200
+ // `target` came from the pre-flight `getRepo` above, which is why it costs no extra read.
171
201
  const ghToken = await this.deps.mintInstallationToken(installation.installationId, {
172
- executionId: request.containerJobId,
202
+ executionId: request.jobId,
173
203
  workspaceId: request.workspaceId,
174
- repoIds: [String(target.githubId)],
204
+ repoIds: [String(target.githubId), ...(reference?.githubId ? [reference.githubId] : [])],
175
205
  });
176
206
  // Private-registry auth for the scaffolder's installs, exactly as the
177
207
  // implementation executor forwards it.
178
208
  const packageRegistries = (await this.deps.resolvePackageRegistries?.(request.workspaceId)) ?? [];
179
- const sessionToken = await this.deps.sessionService.mint({
180
- workspaceId: request.workspaceId,
181
- executionId: request.containerJobId,
182
- agentKind: 'architect',
183
- provider: this.deps.model.provider,
184
- model: this.deps.model.model,
185
- });
186
- const webBase = (this.deps.webBaseUrl ?? 'https://github.com').replace(/\/+$/, '');
209
+ const sessionToken = await this.mintSessionToken(request);
210
+ const webBase = this.webBase();
187
211
  const targetCloneUrl = `${webBase}/${owner}/${repoName}.git`;
188
- const defaultBranch = target.defaultBranch ?? 'main';
189
- // With a reference architecture the container clones + adapts it; without one
190
- // it scaffolds an empty repo from the freeform instructions alone.
191
- const reference = request.referenceRepo
192
- ? {
193
- owner: request.referenceRepo.owner,
194
- name: request.referenceRepo.name,
195
- cloneUrl: `${webBase}/${request.referenceRepo.owner}/${request.referenceRepo.name}.git`,
196
- baseBranch: 'main',
197
- }
198
- : undefined;
212
+ const defaultBranch = defaultBranchOf(target);
199
213
  const targetSpec = { owner, name: repoName, cloneUrl: targetCloneUrl, defaultBranch };
200
214
  // The generic agent `repo` is the clone source: the reference when adapting one, or the
201
215
  // (uncloned) target placeholder when scaffolding from scratch. The real push destination
@@ -209,18 +223,18 @@ export class ContainerRepoBootstrapper {
209
223
  }
210
224
  : { owner, name: repoName, baseBranch: defaultBranch, cloneUrl: targetCloneUrl };
211
225
  // Bootstrap dispatches the generic, manifest-driven `agent` kind in `coding` mode with a
212
- // `bootstrap` spec (the divergent force-push to a separate target repo) — the SAME path
226
+ // `bootstrap` spec (the divergent force-push to a separate target repo): the SAME path
213
227
  // every other built-in coding agent takes, with NO bespoke `/bootstrap` harness handler.
214
228
  const body = {
215
229
  jobId: request.containerJobId,
216
230
  // The run's correlation ids, so the container's own lines join to this bootstrap in the
217
- // backend's logs — the same fields `buildCommonBody` puts on an execution job. A bootstrap
218
- // is a first-class agent run (one `agent_runs` table, one retry surface), so it must not be
219
- // the one agent-kind dispatch whose container logs cannot be joined to anything. Its run id
220
- // IS its job id: a bootstrap has no separate execution row, which is exactly what
221
- // `sessionService.mint` above is told.
231
+ // backend's logs: the same fields `buildCommonBody` puts on an execution job. A bootstrap
232
+ // is a first-class agent run (one `agent_runs` table, one retry surface, one observability
233
+ // panel), so it must not be the one agent-kind dispatch whose container logs cannot be
234
+ // joined to anything. The id is the RUN's, matching the session token above: a bootstrap
235
+ // has no separate execution row, so its run id is what every run-scoped read is keyed by.
222
236
  workspaceId: request.workspaceId,
223
- executionId: request.containerJobId,
237
+ executionId: request.jobId,
224
238
  mode: 'coding',
225
239
  systemPrompt: reference ? ADAPT_SYSTEM_PROMPT : SCAFFOLD_SYSTEM_PROMPT,
226
240
  userPrompt: request.instructions ||
@@ -232,7 +246,7 @@ export class ContainerRepoBootstrapper {
232
246
  // select a subscription harness. The job schema tolerates `harness` (shared
233
247
  // HarnessAuthFields), but bootstrap is the one container flow that always uses
234
248
  // the deployment's proxyable model rather than a workspace's pooled subscription
235
- // token — there is no per-block model selection on a not-yet-existing repo.
249
+ // token: there is no per-block model selection on a not-yet-existing repo.
236
250
  proxyBaseUrl: this.deps.proxyBaseUrl,
237
251
  // This backend serves the phase-tagged completions route (see `ContainerAgentExecutor`),
238
252
  // so a bootstrap's calls are attributed rather than landing in the unattributed slice.
@@ -242,9 +256,9 @@ export class ContainerRepoBootstrapper {
242
256
  ...(packageRegistries.length ? { packageRegistries } : {}),
243
257
  repo: repoSpec,
244
258
  branch: repoSpec.baseBranch,
245
- // Bootstrap always resets history to a single commit and force-pushes (the fresh
246
- // history shares no ancestor with the target repo's boilerplate); that is implicit
247
- // in the bootstrap flow, so no per-job flags are needed.
259
+ // This delivery always resets history to a single commit and force-pushes (the fresh
260
+ // history shares no ancestor with the target repo's boilerplate); that is what the
261
+ // `bootstrap` spec MEANS to the harness, so no per-job flags are needed.
248
262
  bootstrap: {
249
263
  target: targetSpec,
250
264
  ...(reference ? {} : { fromScratch: true }),
@@ -254,7 +268,7 @@ export class ContainerRepoBootstrapper {
254
268
  // Dispatch through the shared transport (keyed by job id), exactly like the
255
269
  // implementation executor: it hits the harness `POST /jobs` (kind `agent`), starts the
256
270
  // background job and returns once accepted; we then poll via the same transport.
257
- // Idempotent per job id — a replayed dispatch re-attaches rather than duplicating.
271
+ // Idempotent per job id: a replayed dispatch re-attaches rather than duplicating.
258
272
  log.info('bootstrap: dispatching container', {
259
273
  reference: reference ? `${reference.owner}/${reference.name}` : null,
260
274
  });
@@ -262,29 +276,129 @@ export class ContainerRepoBootstrapper {
262
276
  // equals the run id (no per-step fan-out into a shared container, and no second phase).
263
277
  await this.jobs.dispatch(request.workspaceId, { runId: request.jobId, jobId: request.containerJobId }, body, 'agent');
264
278
  log.info('bootstrap: container accepted job');
279
+ await this.recordDispatchContext(request, body, log);
265
280
  return {
266
281
  workspaceId: request.workspaceId,
267
282
  jobId: request.jobId,
268
283
  containerJobId: request.containerJobId,
269
284
  };
270
285
  }
286
+ /**
287
+ * Pre-flight the pre-created target repository of a NEW-REPO run: it exists, the App can
288
+ * write to it, and it holds no real content.
289
+ *
290
+ * The target repo is created up front: by the user via the host's new-repo page, or, for
291
+ * privileged-tier orgs (ADR 0005), programmatically via the create-repo endpoint behind the
292
+ * modal's "Create repository" button.
293
+ *
294
+ * The emptiness rule binds under both deliveries, because both WRITE a whole service into the
295
+ * repository: a force-push would clobber real content and a pull request would propose
296
+ * deleting it. What differs is the FLOOR: a pull request needs an initial commit to branch
297
+ * from, so that delivery also refuses a repository with none.
298
+ */
299
+ async preflightNewRepoTarget(request, installation, log) {
300
+ const owner = installation.accountLogin;
301
+ const repoName = request.target.name;
302
+ const ref = { owner, repo: repoName };
303
+ log.info('bootstrap: pre-flighting target repo', { target: `${owner}/${repoName}` });
304
+ let target;
305
+ try {
306
+ target = await this.deps.githubClient.getRepo(installation.installationId, ref);
307
+ }
308
+ catch {
309
+ throw new Error(`Repository ${owner}/${repoName} was not found or is not accessible to the GitHub App. ` +
310
+ `Create a repository named "${repoName}" under ${owner} (an initial README, .gitignore ` +
311
+ `or license is fine), make sure the App is installed on it, then run bootstrap again.`);
312
+ }
313
+ // The repo being *readable* is not enough: bootstrapping ends in a push, so the
314
+ // installation must have write access. A public repo the App can read but is
315
+ // not granted (not in the App's selected-repos list, or the App lacks
316
+ // contents:write) reads fine above but 403s on the container's push, so pre-flight
317
+ // it here: that case then fails fast with an actionable message instead of failing
318
+ // deep inside the run after a board frame has been created.
319
+ if (!(await this.deps.githubClient.canPush(installation.installationId, ref))) {
320
+ throw new Error(`The GitHub App can see ${owner}/${repoName} but does not have write access to it, so the ` +
321
+ `bootstrapped commit cannot be pushed. Grant the App write access to this repository ` +
322
+ `(GitHub → Settings → Applications → the cat-factory App → Configure → Repository access: ` +
323
+ `add "${repoName}" or allow all repositories), or, in local mode, use a GitHub PAT that ` +
324
+ `can push to it. Then run bootstrap again.`);
325
+ }
326
+ // The run writes a whole repository's worth of content, so the target must be
327
+ // empty, except that GitHub's create-repo page often prepopulates a README,
328
+ // .gitignore and/or license. Those are throwaway boilerplate, so tolerate a repo
329
+ // that holds *only* them; reject anything with real content to avoid clobbering work.
330
+ const rootEntries = await this.deps.githubClient.listRootEntries(installation.installationId, ref);
331
+ const realContent = rootEntries.filter((entry) => !isBootstrapBoilerplate(entry));
332
+ if (realContent.length > 0) {
333
+ const sample = realContent
334
+ .map((entry) => entry.path)
335
+ .slice(0, 5)
336
+ .join(', ');
337
+ throw new Error(`Repository ${owner}/${repoName} already has content (${sample}). Bootstrapping replaces ` +
338
+ `the repository's contents, so it needs an empty repository, or one prepopulated only ` +
339
+ `with a README, .gitignore, license and/or AGENTS.md.`);
340
+ }
341
+ // A pull request is opened BETWEEN two commits, so a repository holding none cannot take
342
+ // one: there is no default branch to clone, to branch from, or to target.
343
+ // `listRootEntries` answers `[]` for exactly that repository (the contents endpoint 404s
344
+ // where there is no commit), so an empty listing is the tell. Refused here, naming both
345
+ // ways out, rather than surfacing later as a clone failure that reads like an outage.
346
+ if (request.delivery.mode === 'pull_request' && rootEntries.length === 0) {
347
+ throw new Error(`Repository ${owner}/${repoName} has no commits yet, so there is no branch to open a ` +
348
+ `pull request against. Either create it with an initial commit (a README is enough), ` +
349
+ `or bootstrap it with "push directly", which writes the repository's first commit.`);
350
+ }
351
+ return target;
352
+ }
353
+ /**
354
+ * Dispatch a NEW-REPO run delivered as a pull request: the ordinary coding shape against the
355
+ * (already-initialised) target repository, with the reference template beside it as a
356
+ * READ-ONLY sibling checkout.
357
+ *
358
+ * Deliberately NOT the `bootstrap` spec, which is the whole reason this path exists
359
+ * separately: that spec reinitialises history and force-pushes, and a branch whose history
360
+ * shares no ancestor with the default branch is not something a pull request can be opened
361
+ * from. Here the target's own initial commit is the base, so the diff a reviewer reads is the
362
+ * service being added.
363
+ */
364
+ async startNewRepoPullRequest(request, target, installation, log) {
365
+ const owner = installation.accountLogin;
366
+ const repoName = request.target.name;
367
+ log.info('bootstrap(new-repo pr): dispatching container', { target: `${owner}/${repoName}` });
368
+ return await this.dispatchCodingShape(request, installation, log, {
369
+ repo: {
370
+ owner,
371
+ name: repoName,
372
+ baseBranch: defaultBranchOf(target),
373
+ cloneUrl: `${this.webBase()}/${owner}/${repoName}.git`,
374
+ },
375
+ repoGithubId: target.githubId,
376
+ systemPrompt: request.referenceRepo ? PR_ADAPT_SYSTEM_PROMPT : PR_SCAFFOLD_SYSTEM_PROMPT,
377
+ userPrompt: request.instructions ||
378
+ (request.referenceRepo
379
+ ? 'Adapt the reference architecture for the new service.'
380
+ : 'Scaffold a new repository for the service.'),
381
+ });
382
+ }
271
383
  /**
272
384
  * Dispatch a monorepo bootstrap's APPLY phase: an ordinary coding job on the monorepo, with
273
385
  * the reference template alongside it as a READ-ONLY sibling checkout.
274
386
  *
275
387
  * Deliberately the plain coding shape rather than a `bootstrap` spec, and that is the design:
276
- * the harness already knows how to clone a writable primary at a work branch, clone
277
- * `referenceRepos` beside it without ever branching or pushing them, and open one pull request
278
- * for the primary. A bespoke bootstrap mode here would be a second implementation of that with
279
- * one extra way to get the push wrong, against a repository that holds other people's code.
388
+ * the harness already knows how to clone a writable primary (at a work branch, or at the
389
+ * default branch it commits onto, per the run's delivery), clone `referenceRepos` beside it
390
+ * without ever branching or pushing them, and open one pull request for the primary when it
391
+ * was given one to open. A bespoke bootstrap mode here would be a second implementation of
392
+ * that with one extra way to get the push wrong, against a repository that holds other
393
+ * people's code.
280
394
  *
281
395
  * `repo.serviceDirectory` is what scopes the agent to the new subdirectory: the same field
282
396
  * every monorepo-service run rides, so the working-directory rule is stated in one place.
283
397
  *
284
398
  * Two pre-flights, both about the monorepo rather than about a new repo: the App must be able
285
399
  * to WRITE to it (a read-only grant reads fine and 403s on the push, after a board frame
286
- * exists), and the target directory must still be absent at dispatch time, because the orchestration
287
- * pre-flighted it before the survey, and a review can be settled days later.
400
+ * exists), and the target directory must still be absent at dispatch time, because the
401
+ * orchestration pre-flighted it before the survey and a review can be settled days later.
288
402
  */
289
403
  async startMonorepoBootstrap(request, monorepo, installation) {
290
404
  const log = logger.child({ jobId: request.jobId, workspaceId: request.workspaceId });
@@ -300,93 +414,171 @@ export class ContainerRepoBootstrapper {
300
414
  `to it, so the new service cannot be pushed. Grant the App write access to this ` +
301
415
  `repository, then retry.`);
302
416
  }
303
- const existing = await this.deps.githubClient.listDirectory(installation.installationId, ref, monorepo.directory, defaultBranchOf(target));
417
+ const existing = await this.deps.githubClient.listDirectory(installation.installationId, ref, monorepo.directory, defaultBranch);
304
418
  if (existing.length > 0) {
305
419
  throw new Error(`\`${monorepo.directory}\` already exists in ${monorepo.owner}/${monorepo.name}. It was ` +
306
420
  `empty when this bootstrap started; something has since created it. Pick a different ` +
307
421
  `directory and start a new bootstrap rather than writing over it.`);
308
422
  }
309
- const webBase = (this.deps.webBaseUrl ?? 'https://github.com').replace(/\/+$/, '');
310
- // Scoped to the repos this run touches: the monorepo it pushes to, and the template it
311
- // reads. A template outside the installation simply is not cloneable, which the harness
312
- // reports as a clone failure rather than silently running without it.
313
- const repoIds = [String(target.githubId)];
314
- const reference = request.referenceRepo;
315
- let referenceRepos = [];
316
- if (reference) {
317
- const templateRepo = await this.deps.githubClient
318
- .getRepo(installation.installationId, { owner: reference.owner, repo: reference.name })
319
- .catch(() => null);
320
- if (templateRepo)
321
- repoIds.push(String(templateRepo.githubId));
322
- referenceRepos = [
423
+ log.info('bootstrap(monorepo): dispatching container', {
424
+ directory: monorepo.directory,
425
+ delivery: request.delivery.mode,
426
+ });
427
+ return await this.dispatchCodingShape(request, installation, log, {
428
+ repo: {
429
+ owner: monorepo.owner,
430
+ name: monorepo.name,
431
+ baseBranch: defaultBranch,
432
+ cloneUrl: `${this.webBase()}/${monorepo.owner}/${monorepo.name}.git`,
433
+ serviceDirectory: monorepo.directory,
434
+ },
435
+ repoGithubId: target.githubId,
436
+ systemPrompt: monorepoSystemPrompt(request.delivery.mode),
437
+ userPrompt: request.instructions,
438
+ });
439
+ }
440
+ /**
441
+ * The dispatch both non-force-push shapes share: clone the writable target, fetch the
442
+ * reference template beside it read-only, and either open a work branch plus a pull request
443
+ * or commit onto the target's own default branch.
444
+ *
445
+ * ONE builder rather than one per target, because the delivery rule is the interesting part
446
+ * and a second copy of it is a second place for `newBranch` and `pr` to disagree: a body
447
+ * carrying a branch but no PR pushes work onto a branch nobody is told about.
448
+ */
449
+ async dispatchCodingShape(request, installation, log, spec) {
450
+ // Scoped to the repos this run touches: the one it pushes to, and the template it reads. A
451
+ // template outside the installation simply is not cloneable, which the harness reports as a
452
+ // clone failure rather than silently running without it.
453
+ const reference = await this.resolveReferenceTemplate(request, installation.installationId, log);
454
+ const repoIds = [
455
+ String(spec.repoGithubId),
456
+ ...(reference?.githubId ? [reference.githubId] : []),
457
+ ];
458
+ const referenceRepos = reference
459
+ ? [
323
460
  {
324
461
  repo: {
325
462
  owner: reference.owner,
326
463
  name: reference.name,
327
- baseBranch: templateRepo?.defaultBranch ?? 'main',
328
- cloneUrl: `${webBase}/${reference.owner}/${reference.name}.git`,
464
+ baseBranch: reference.baseBranch,
465
+ cloneUrl: reference.cloneUrl,
329
466
  },
330
467
  },
331
- ];
332
- }
468
+ ]
469
+ : [];
333
470
  const ghToken = await this.deps.mintInstallationToken(installation.installationId, {
334
- executionId: request.containerJobId,
471
+ executionId: request.jobId,
335
472
  workspaceId: request.workspaceId,
336
473
  repoIds,
337
474
  });
338
475
  const packageRegistries = (await this.deps.resolvePackageRegistries?.(request.workspaceId)) ?? [];
339
- const sessionToken = await this.deps.sessionService.mint({
340
- workspaceId: request.workspaceId,
341
- executionId: request.containerJobId,
342
- agentKind: 'architect',
343
- provider: this.deps.model.provider,
344
- model: this.deps.model.model,
345
- });
476
+ const sessionToken = await this.mintSessionToken(request);
477
+ // The delivery decides the two fields TOGETHER: with `newBranch` and `pr` the harness pushes
478
+ // a work branch and opens one pull request; with neither it commits onto `branch`, the
479
+ // target's own default. Omitting only one of the pair is the bug this single site prevents.
480
+ const delivery = request.delivery.mode === 'pull_request'
481
+ ? { newBranch: request.delivery.branch, pr: request.delivery.pr }
482
+ : {};
346
483
  const body = {
347
484
  jobId: request.containerJobId,
348
485
  workspaceId: request.workspaceId,
349
- executionId: request.containerJobId,
486
+ // The RUN, not this phase's drive id: see the session mint above.
487
+ executionId: request.jobId,
350
488
  mode: 'coding',
351
- systemPrompt: MONOREPO_SYSTEM_PROMPT,
352
- userPrompt: request.instructions,
489
+ systemPrompt: spec.systemPrompt,
490
+ userPrompt: spec.userPrompt,
353
491
  model: this.deps.model.model,
354
492
  proxyBaseUrl: this.deps.proxyBaseUrl,
355
493
  proxyPhasePath: true,
356
494
  sessionToken,
357
495
  ghToken,
358
496
  ...(packageRegistries.length ? { packageRegistries } : {}),
359
- repo: {
360
- owner: monorepo.owner,
361
- name: monorepo.name,
362
- baseBranch: defaultBranch,
363
- cloneUrl: `${webBase}/${monorepo.owner}/${monorepo.name}.git`,
364
- serviceDirectory: monorepo.directory,
365
- },
366
- branch: defaultBranch,
367
- newBranch: monorepo.branch,
368
- pr: monorepo.pr,
497
+ repo: spec.repo,
498
+ branch: spec.repo.baseBranch,
499
+ ...delivery,
369
500
  ...(referenceRepos.length ? { referenceRepos } : {}),
370
501
  ...(this.deps.githubApiBase ? { githubApiBase: this.deps.githubApiBase } : {}),
371
502
  };
372
- log.info('bootstrap(monorepo): dispatching container', {
373
- branch: monorepo.branch,
503
+ await this.jobs.dispatch(request.workspaceId, { runId: request.jobId, jobId: request.containerJobId }, body, 'agent');
504
+ // The BRANCH is the field an operator needs first when a run reports done with no pull
505
+ // request (the orchestration then fails it as delivered nowhere) or faults mid-way under
506
+ // `direct_push`: it names where the commits are. Nothing else in the backend's logs does.
507
+ log.info('bootstrap: container accepted job', {
508
+ delivery: request.delivery.mode,
509
+ branch: request.delivery.mode === 'pull_request' ? request.delivery.branch : spec.repo.baseBranch,
374
510
  reference: reference ? `${reference.owner}/${reference.name}` : null,
375
511
  });
376
- await this.jobs.dispatch(request.workspaceId, { runId: request.jobId, jobId: request.containerJobId }, body, 'agent');
377
- log.info('bootstrap(monorepo): container accepted job');
512
+ await this.recordDispatchContext(request, body, log);
378
513
  return {
379
514
  workspaceId: request.workspaceId,
380
515
  jobId: request.jobId,
381
516
  containerJobId: request.containerJobId,
382
517
  };
383
518
  }
519
+ /**
520
+ * Resolve the reference template a run reads from: its clone spec, plus the numeric id the
521
+ * run's installation token has to be scoped to.
522
+ *
523
+ * ONE resolution for both dispatch shapes, because both CLONE the template and both mint the
524
+ * token that clone runs on. Two copies is what left the force-push path granting only its push
525
+ * target while cloning the template with that same token, so a PRIVATE reference architecture
526
+ * 404s on clone under one delivery and works under the other, and what left it assuming the
527
+ * template's base branch was `main`.
528
+ *
529
+ * A template the installation cannot read resolves to no id and the conventional branch rather
530
+ * than refusing the run: the clone then fails inside the harness naming the repository, which
531
+ * is a better report than a pre-flight throw here, and the warning says which read failed.
532
+ */
533
+ async resolveReferenceTemplate(request, installationId, log) {
534
+ const reference = request.referenceRepo;
535
+ if (!reference)
536
+ return undefined;
537
+ const template = await runBestEffort(log, 'bootstrap: read reference template repo', () => this.deps.githubClient.getRepo(installationId, {
538
+ owner: reference.owner,
539
+ repo: reference.name,
540
+ }), { reference: `${reference.owner}/${reference.name}` });
541
+ return {
542
+ owner: reference.owner,
543
+ name: reference.name,
544
+ cloneUrl: `${this.webBase()}/${reference.owner}/${reference.name}.git`,
545
+ baseBranch: template ? defaultBranchOf(template) : 'main',
546
+ githubId: template ? String(template.githubId) : null,
547
+ };
548
+ }
549
+ /**
550
+ * The model-locked LLM-proxy session token a bootstrap container runs under.
551
+ *
552
+ * Keyed on the RUN, never on the drive: a monorepo run's apply phase is dispatched under its
553
+ * own container job id, so minting on that id files the apply's model calls under a key no
554
+ * run-scoped read asks for, and the run reports only what its survey spent.
555
+ */
556
+ mintSessionToken(request) {
557
+ return this.deps.sessionService.mint({
558
+ workspaceId: request.workspaceId,
559
+ executionId: request.jobId,
560
+ agentKind: REPO_BOOTSTRAP_AGENT_KIND,
561
+ provider: this.deps.model.provider,
562
+ model: this.deps.model.model,
563
+ });
564
+ }
565
+ /** The host's web base, trailing slashes stripped, for building clone URLs. */
566
+ webBase() {
567
+ return (this.deps.webBaseUrl ?? 'https://github.com').replace(/\/+$/, '');
568
+ }
384
569
  /** Poll a dispatched bootstrap job, mapping the runner job view into an update. */
385
570
  async pollBootstrap(handle) {
386
571
  const view = await this.jobs.poll(handle.workspaceId, {
387
572
  runId: handle.jobId,
388
573
  jobId: handle.containerJobId,
389
574
  });
575
+ // The tool calls the harness drained on this poll, to the same two destinations an
576
+ // execution step's go to. Filed under the RUN (`runId`) and grouped by the container job,
577
+ // so a monorepo run's apply trajectory reads under the run a person opened. Isolated +
578
+ // best-effort inside `drainToolCalls`: it can never affect this poll's verdict.
579
+ // Correlated by the same two ids every other line about this run carries, so a drain that
580
+ // warns (an image too old to number its calls) names the run it was about.
581
+ await drainToolCalls(this.deps, this.jobHandle(handle), view.spans, logger.child({ jobId: handle.jobId, workspaceId: handle.workspaceId }));
390
582
  if (view.state === 'running') {
391
583
  return view.progress ? { state: 'running', subtasks: view.progress } : { state: 'running' };
392
584
  }
@@ -420,16 +612,62 @@ export class ContainerRepoBootstrapper {
420
612
  detail: view.detail ?? result.error,
421
613
  };
422
614
  }
423
- // A MONOREPO run's product is the pull request, and NOTHING else it could report stands in
424
- // for it: there is no repository it created, so building a `repoUrl` here would name one
425
- // that does not exist. A `prUrl` is the shape's own tell (the new-repo flow force-pushes a
426
- // default branch and never opens one), so it is reported alone, and a completed apply that
427
- // opened none reports neither, leaving the ORCHESTRATION to say what that means (it fails
428
- // the run: the service was delivered nowhere).
429
- if (result.prUrl)
430
- return { state: 'done', prUrl: result.prUrl };
431
- const outcome = await this.buildOutcome(handle, result.defaultBranch);
432
- return { state: 'done', outcome };
615
+ // What a completed run can NAME is decided by its target, never by whether a `prUrl` came
616
+ // back. A monorepo run created no repository, so building a `repoUrl` here would name one
617
+ // that does not exist; a new-repo run created one under either delivery, and under
618
+ // `pull_request` it has BOTH a repository and a pull request to report. Reading the record
619
+ // is what tells them apart: `prUrl`'s presence cannot, now that a new-repo run may carry
620
+ // one. A run that promised a pull request and reports none is left to the ORCHESTRATION to
621
+ // fail, which is where "delivered nowhere" is a statement about the run rather than about
622
+ // this poll.
623
+ const record = await this.requireRecord(handle);
624
+ const pr = result.prUrl ? { prUrl: result.prUrl } : {};
625
+ if (record.monorepo)
626
+ return { state: 'done', ...pr };
627
+ const outcome = await this.buildOutcome(handle, record.repoName, result.defaultBranch);
628
+ return { state: 'done', outcome, ...pr };
629
+ }
630
+ /**
631
+ * The dispatched container job as an {@link AgentJobHandle}, the shape the shared trajectory
632
+ * drain speaks. `runId` is the bootstrap RUN and `jobId` the container job it dispatched,
633
+ * which is the same split every execution step's handle carries.
634
+ */
635
+ jobHandle(handle) {
636
+ return {
637
+ jobId: handle.containerJobId,
638
+ runId: handle.jobId,
639
+ workspaceId: handle.workspaceId,
640
+ agentKind: REPO_BOOTSTRAP_AGENT_KIND,
641
+ model: this.resolvedModel(),
642
+ provider: this.deps.model.provider,
643
+ };
644
+ }
645
+ /**
646
+ * The resolved model as every OTHER producer writes it: `provider:model`, which is the format
647
+ * `AgentJobHandle.model` and `agentContextSnapshotSchema.model` both document. A bare model id
648
+ * renders beside prefixed ones on the same panel and gives nothing to a reader that splits the
649
+ * field to recover the provider.
650
+ */
651
+ resolvedModel() {
652
+ return `${this.deps.model.provider}:${this.deps.model.model}`;
653
+ }
654
+ /**
655
+ * File what this dispatch handed the agent, so a bootstrap's Provided-context tab answers the
656
+ * same question every other agent run's does.
657
+ *
658
+ * AWAITED for the reason `recordAgentContextSnapshot` states: it runs after the container has
659
+ * already been accepted, so it delays nothing but the handle's return, and an un-awaited insert
660
+ * is dropped outright on the Worker, where this runs inside a Workflow step.
661
+ */
662
+ async recordDispatchContext(request, body, log) {
663
+ await recordBootstrapContextSnapshot(this.deps.agentContextObservability, log, {
664
+ body,
665
+ model: this.resolvedModel(),
666
+ agentKind: REPO_BOOTSTRAP_AGENT_KIND,
667
+ workspaceId: request.workspaceId,
668
+ executionId: request.jobId,
669
+ stepIndex: dispatchStepIndex(request),
670
+ });
433
671
  }
434
672
  /**
435
673
  * Best-effort: reclaim the per-run container for a job. Releases through the same
@@ -474,23 +712,56 @@ export class ContainerRepoBootstrapper {
474
712
  });
475
713
  return { installationId: installation.installationId, githubId: repo.githubId };
476
714
  }
477
- /** Construct the success outcome from the installation + the recorded job's repo name. */
478
- async buildOutcome(handle, resultDefaultBranch) {
715
+ /**
716
+ * Construct the success outcome from the installation + the recorded job's repo name.
717
+ *
718
+ * `resultDefaultBranch` is what the HARNESS reported, and only the `bootstrap` spec reports one
719
+ * (it echoes back the branch it force-pushed, which it also created). The plain coding shape a
720
+ * `pull_request` run takes reports none, so the branch is READ off the target repository rather
721
+ * than defaulted to `main`: this field states which branch the work lands on, and a repository
722
+ * whose default is `master`, `trunk` or an org-wide choice would otherwise be recorded as a ref
723
+ * that does not exist. One read, on the terminal poll only. A failed read keeps the
724
+ * conventional fallback and says so in a warning, rather than failing a delivered run over the
725
+ * one field nothing reads back.
726
+ */
727
+ async buildOutcome(handle, repoName, resultDefaultBranch) {
479
728
  const installation = await this.deps.installationRepository.getByWorkspace(handle.workspaceId);
480
729
  if (!installation)
481
730
  throw new Error(`Workspace '${handle.workspaceId}' is not connected to GitHub`);
482
- const record = await this.deps.bootstrapJobRepository.get(handle.workspaceId, handle.jobId);
483
- if (!record)
484
- throw new Error(`Bootstrap job '${handle.jobId}' not found`);
485
731
  const owner = installation.accountLogin;
486
- const webBase = (this.deps.webBaseUrl ?? 'https://github.com').replace(/\/+$/, '');
732
+ const log = logger.child({ jobId: handle.jobId, workspaceId: handle.workspaceId });
733
+ const target = resultDefaultBranch
734
+ ? null
735
+ : await runBestEffort(log, 'bootstrap: read the target repo default branch', () => this.deps.githubClient.getRepo(installation.installationId, {
736
+ owner,
737
+ repo: repoName,
738
+ }), { repo: `${owner}/${repoName}` });
487
739
  return {
488
- repoUrl: `${webBase}/${owner}/${record.repoName}`,
740
+ repoUrl: `${this.webBase()}/${owner}/${repoName}`,
489
741
  owner,
490
- name: record.repoName,
491
- defaultBranch: resultDefaultBranch ?? 'main',
742
+ name: repoName,
743
+ defaultBranch: resultDefaultBranch ?? (target ? defaultBranchOf(target) : 'main'),
492
744
  };
493
745
  }
746
+ /** The run's stored row, which a poll addresses only by id. */
747
+ async requireRecord(handle) {
748
+ const record = await this.deps.bootstrapJobRepository.get(handle.workspaceId, handle.jobId);
749
+ if (!record)
750
+ throw new Error(`Bootstrap job '${handle.jobId}' not found`);
751
+ return record;
752
+ }
753
+ }
754
+ /**
755
+ * Which of the RUN's own steps this dispatch is, numbered exactly as the board numbers them.
756
+ *
757
+ * The container dispatch is always the run's LAST move: a new-repo run is only `scaffold`, and a
758
+ * monorepo run's apply follows the survey and the human review. So it is read off the end of the
759
+ * shared `bootstrapStepIds` list rather than by searching it for a step NAME, which cannot
760
+ * answer `-1`. A snapshot filed at a step the run never had is a row every step-scoped read
761
+ * silently drops.
762
+ */
763
+ function dispatchStepIndex(request) {
764
+ return bootstrapStepIds({ monorepo: request.monorepo ?? null }).length - 1;
494
765
  }
495
766
  /** A repo's default branch, or the conventional fallback when the provider reported none. */
496
767
  function defaultBranchOf(repo) {