@cat-factory/executor-harness 1.34.4 → 1.34.10

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.
@@ -1,6 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
- import type { HarnessAuthFields, RepoSpec } from './job.js'
3
+ import type { AgentJob, AgentResult, HarnessAuthFields, PeerRepoSpec, RepoSpec } from './job.js'
4
4
  import {
5
5
  branchAheadOfBase,
6
6
  branchHasCommitsSince,
@@ -11,6 +11,7 @@ import {
11
11
  excludeFromGit,
12
12
  headCommit,
13
13
  listUntrackedFiles,
14
+ openPullRequest,
14
15
  prepareExistingCheckout,
15
16
  pushBranch,
16
17
  refreshFromBaseIfClean,
@@ -23,6 +24,7 @@ import {
23
24
  agentNeverActed,
24
25
  agentOutputTail,
25
26
  runAgentInWorkspace,
27
+ withWorkspace,
26
28
  } from './pi-workspace.js'
27
29
  import type { ProgressGuardLimits } from './pi.js'
28
30
  import type { RunOptions } from './runner.js'
@@ -399,6 +401,292 @@ export async function runCodingAgent(
399
401
  )
400
402
  }
401
403
 
404
+ /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
405
+ export function safeDirSegment(value: string): string {
406
+ return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_'
407
+ }
408
+
409
+ /**
410
+ * A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
411
+ * repo under the workspace root. Deterministic (`owner__name`) and collision-free by construction
412
+ * — the checkout set is deduped by `owner/name` upstream and GitHub owners contain no `_`, so the
413
+ * `owner__name` join is unique per repo without a stateful collision dance. Kept as a factory so
414
+ * the coding + read-only explore fan-outs share ONE scheme, and it MUST stay byte-identical to the
415
+ * backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in `@cat-factory/server`
416
+ * (jobBody.ts), which names this exact directory in the agent's prompt — the two are computed
417
+ * independently, so a divergent rule would point the agent at a directory that does not exist.
418
+ */
419
+ export function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string {
420
+ return (repo) => `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}`
421
+ }
422
+
423
+ /** One repository participating in a multi-repo run: where to clone it + what to do after. */
424
+ interface RepoLeg {
425
+ repo: RepoSpec
426
+ /** Sibling directory name under the workspace root. */
427
+ dirName: string
428
+ /** Absolute checkout directory (filled during the clone phase). */
429
+ dir: string
430
+ /** Branch to clone (the repo's base). */
431
+ cloneBranch: string
432
+ /** Branch to create off the clone and push the work to (the shared `cat-factory/<block>`). */
433
+ workBranch: string
434
+ ghToken: string
435
+ pr?: { title: string; body: string }
436
+ frameId?: string
437
+ primary: boolean
438
+ /** The branch tip before the run — work iff the branch advances past it. */
439
+ baseSha: string
440
+ /** Whether an existing remote work branch was resumed (already carries prior work). */
441
+ resumed: boolean
442
+ }
443
+
444
+ /**
445
+ * Multi-repo coding (service-connections phase 3): clone the primary repo AND every connected
446
+ * peer repo as SIBLING checkouts under one workspace root, run the agent ONCE with its cwd at
447
+ * that root (so it makes the cross-service change coherently across all of them), then commit +
448
+ * push each repo that actually changed and open one PR per dirty repo. The task's own-service PR
449
+ * is reported as `prUrl`/`branch`; the peer PRs as `peerPullRequests`.
450
+ *
451
+ * Deliberately simpler than the single-repo {@link runCodingAgent} for the first cut: NO mid-run
452
+ * checkpoint pushes (an evicted multi-repo run re-clones on retry — the deterministic work branch
453
+ * still lets it resume any commits it managed to push at the end), NO warm-pool persistent
454
+ * checkout (always ephemeral), and NO follow-up sentinel streaming. It reuses the SAME dir-scoped
455
+ * git helpers, so the per-repo clone/commit/push/PR mechanics match the single-repo path exactly.
456
+ */
457
+ export async function runMultiRepoCoding(
458
+ job: AgentJob,
459
+ opts: RunOptions = {},
460
+ ): Promise<AgentResult> {
461
+ const { signal } = opts
462
+ const logger = (opts.log ?? log).child({ kind: 'multi-repo', jobId: job.jobId })
463
+ const peers: PeerRepoSpec[] = job.peerRepos ?? []
464
+ const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch
465
+
466
+ // Assign the sibling directory per repo via the shared deterministic allocator (`owner__name`,
467
+ // matching the backend prompt's `siblingCheckoutDir`), shared with the read-only explore fan-out.
468
+ const claimDir = makeDirClaimer()
469
+ const legs: RepoLeg[] = [
470
+ {
471
+ repo: job.repo,
472
+ dirName: claimDir(job.repo),
473
+ dir: '',
474
+ cloneBranch: job.branch,
475
+ workBranch: primaryWorkBranch,
476
+ ghToken: job.ghToken,
477
+ ...(job.pr ? { pr: job.pr } : {}),
478
+ primary: true,
479
+ baseSha: '',
480
+ resumed: false,
481
+ },
482
+ ...peers.map(
483
+ (peer): RepoLeg => ({
484
+ repo: peer.repo,
485
+ dirName: claimDir(peer.repo),
486
+ dir: '',
487
+ cloneBranch: peer.repo.baseBranch,
488
+ // Coding peers always carry `newBranch` (the backend sets the shared work branch);
489
+ // fall back to the primary's for the type (read-only peers never reach this path).
490
+ workBranch: peer.newBranch ?? primaryWorkBranch,
491
+ ghToken: peer.ghToken ?? job.ghToken,
492
+ ...(peer.pr ? { pr: peer.pr } : {}),
493
+ ...(peer.frameId ? { frameId: peer.frameId } : {}),
494
+ primary: false,
495
+ baseSha: '',
496
+ resumed: false,
497
+ }),
498
+ ),
499
+ ]
500
+
501
+ return withWorkspace('multi', async (root) => {
502
+ // Clone phase: every repo into its sibling dir under the workspace root. Resume an
503
+ // existing remote work branch (an evicted retry) rather than branching off base again.
504
+ opts.onPhase?.('clone')
505
+ for (const leg of legs) {
506
+ const dir = join(root, leg.dirName)
507
+ await mkdir(dir, { recursive: true })
508
+ leg.resumed = await remoteBranchExists(leg.repo.cloneUrl, leg.workBranch, leg.ghToken, signal)
509
+ if (leg.resumed) {
510
+ logger.info('multi-repo: resuming existing branch', {
511
+ repo: leg.dirName,
512
+ branch: leg.workBranch,
513
+ })
514
+ await cloneExistingBranch({
515
+ cloneUrl: leg.repo.cloneUrl,
516
+ branch: leg.workBranch,
517
+ ghToken: leg.ghToken,
518
+ dir,
519
+ signal,
520
+ })
521
+ } else {
522
+ logger.info('multi-repo: cloning', { repo: leg.dirName, cloneBranch: leg.cloneBranch })
523
+ await cloneRepo({
524
+ repo: { ...leg.repo, baseBranch: leg.cloneBranch },
525
+ ghToken: leg.ghToken,
526
+ dir,
527
+ signal,
528
+ })
529
+ await createBranch(dir, leg.workBranch, signal)
530
+ }
531
+ leg.dir = dir
532
+ // The branch tip before the agent runs. Captured BEFORE the resume base refresh below so
533
+ // that refresh's merge commit counts as advancement and is pushed (as in the single-repo
534
+ // path). A fresh leg produced work iff its branch advances past this; a resumed leg already
535
+ // carries prior work.
536
+ leg.baseSha = await headCommit(dir, signal)
537
+ // A resumed branch was cut from an OLDER base; merge the latest base in when the two merge
538
+ // cleanly so the agent works against current base and the peer/own PRs stay current. On a
539
+ // conflict this is a best-effort no-op (the merge gate handles a conflicting PR downstream),
540
+ // mirroring the single-repo {@link runCodingAgent} resume refresh.
541
+ if (leg.resumed) {
542
+ const refreshed = await refreshFromBaseIfClean(
543
+ dir,
544
+ leg.cloneBranch,
545
+ leg.ghToken,
546
+ signal,
547
+ ).catch(() => false)
548
+ if (!refreshed) {
549
+ logger.info('multi-repo: resume base refresh skipped (conflict or error)', {
550
+ repo: leg.dirName,
551
+ base: leg.cloneBranch,
552
+ })
553
+ }
554
+ }
555
+ }
556
+
557
+ // Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
558
+ // and can change them coherently. No monorepo/service-directory scoping — the multi-repo
559
+ // note + the backend system-prompt section explain the layout.
560
+ opts.onPhase?.('agent')
561
+ logger.info('multi-repo: running agent', { repos: legs.map((l) => l.dirName) })
562
+ const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace(
563
+ {
564
+ dir: root,
565
+ systemPrompt: job.systemPrompt,
566
+ userPrompt: job.userPrompt,
567
+ model: job.model,
568
+ harness: job.harness,
569
+ subscriptionToken: job.subscriptionToken,
570
+ subscriptionBaseUrl: job.subscriptionBaseUrl,
571
+ ambientAuth: job.ambientAuth,
572
+ proxyBaseUrl: job.proxyBaseUrl,
573
+ sessionToken: job.sessionToken,
574
+ webToolsGuidance: job.webToolsGuidance,
575
+ webSearchProxy: job.webSearch,
576
+ guardLimits: job.guardLimits,
577
+ ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
578
+ multiRepo: true,
579
+ },
580
+ opts,
581
+ )
582
+
583
+ // Push phase: commit forgotten tracked edits, then push + open a PR for each repo the run
584
+ // actually changed. A repo the agent left untouched is skipped (no branch, no PR).
585
+ opts.onPhase?.('push')
586
+ let primaryPushed = false
587
+ let primaryPrUrl: string | undefined
588
+ const peerPullRequests: NonNullable<AgentResult['peerPullRequests']> = []
589
+ for (const leg of legs) {
590
+ await commitTrackedEdits(
591
+ leg.dir,
592
+ job.commitMessage ?? leg.pr?.title ?? 'Agent changes',
593
+ signal,
594
+ )
595
+ const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
596
+ let hasWork = advanced || leg.resumed
597
+ if (leg.resumed && !advanced) {
598
+ const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal)
599
+ if (ahead === false) hasWork = false
600
+ }
601
+ const leftover = await listUntrackedFiles(leg.dir, signal)
602
+ if (leftover.length > 0) {
603
+ logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
604
+ repo: leg.dirName,
605
+ count: leftover.length,
606
+ files: leftover.slice(0, 20),
607
+ })
608
+ }
609
+ if (!hasWork) {
610
+ logger.info('multi-repo: no changes for repo', { repo: leg.dirName })
611
+ continue
612
+ }
613
+ await pushBranch(leg.dir, leg.workBranch, leg.ghToken, signal)
614
+ let prUrl: string | null = null
615
+ if (leg.pr) {
616
+ prUrl = await openPullRequest({
617
+ owner: leg.repo.owner,
618
+ name: leg.repo.name,
619
+ ghToken: leg.ghToken,
620
+ head: leg.workBranch,
621
+ base: leg.repo.baseBranch,
622
+ pr: leg.pr,
623
+ apiBase: job.githubApiBase,
624
+ cloneUrl: leg.repo.cloneUrl,
625
+ ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
626
+ signal,
627
+ })
628
+ }
629
+ if (leg.primary) {
630
+ primaryPushed = true
631
+ if (prUrl) primaryPrUrl = prUrl
632
+ } else if (prUrl) {
633
+ peerPullRequests.push({
634
+ repo: `${leg.repo.owner}/${leg.repo.name}`,
635
+ ...(leg.frameId ? { frameId: leg.frameId } : {}),
636
+ prUrl,
637
+ branch: leg.workBranch,
638
+ })
639
+ }
640
+ }
641
+
642
+ const anyWork = primaryPushed || peerPullRequests.length > 0
643
+ if (!anyWork) {
644
+ // Nothing changed in ANY repo. For the implementer this is a failure (as in the
645
+ // single-repo path); a caller that tolerates a no-op (never the implementer today)
646
+ // gets a clean non-event.
647
+ if (job.noChangesIsError === false) {
648
+ return {
649
+ pushed: false,
650
+ branch: primaryWorkBranch,
651
+ summary,
652
+ stats,
653
+ ...(usage ? { usage } : {}),
654
+ ...(callMetrics ? { callMetrics } : {}),
655
+ }
656
+ }
657
+ return {
658
+ pushed: false,
659
+ branch: primaryWorkBranch,
660
+ summary,
661
+ stats,
662
+ error: noChangesReason(
663
+ 'the agent produced no file changes in any repository',
664
+ stats,
665
+ stderrTail,
666
+ ),
667
+ failureCause: 'no-changes',
668
+ ...(usage ? { usage } : {}),
669
+ ...(callMetrics ? { callMetrics } : {}),
670
+ }
671
+ }
672
+ logger.info('multi-repo: complete', {
673
+ primaryPushed,
674
+ primaryPrUrl: primaryPrUrl ?? null,
675
+ peers: peerPullRequests.length,
676
+ })
677
+ return {
678
+ pushed: primaryPushed,
679
+ ...(primaryPrUrl ? { prUrl: primaryPrUrl } : {}),
680
+ branch: primaryWorkBranch,
681
+ ...(peerPullRequests.length ? { peerPullRequests } : {}),
682
+ summary,
683
+ stats,
684
+ ...(usage ? { usage } : {}),
685
+ ...(callMetrics ? { callMetrics } : {}),
686
+ }
687
+ })
688
+ }
689
+
402
690
  /**
403
691
  * The "no changes" reason both coding agents report: a caller-supplied lead phrase
404
692
  * plus the shared "never acted" cause and a credential-scrubbed tail of Pi's stderr.
package/src/job.ts CHANGED
@@ -61,6 +61,29 @@ export interface PrSpec {
61
61
  body: string
62
62
  }
63
63
 
64
+ /**
65
+ * A connected service's repo to check out as a SIBLING alongside the primary during a
66
+ * multi-repo coding run (service-connections phase 3). The agent clones every peer repo
67
+ * into a sibling directory under the workspace root, makes the coherent cross-service
68
+ * change, and the harness opens ONE PR per peer repo it actually changed. The clone URL is
69
+ * host-allowlisted exactly like the primary `repo.cloneUrl`.
70
+ */
71
+ export interface PeerRepoSpec {
72
+ repo: RepoSpec
73
+ /** The involved service frame this repo resolved from, echoed back on the peer PR. */
74
+ frameId?: string
75
+ /**
76
+ * The work branch to create off the peer's base and push (the shared `cat-factory/<block>`).
77
+ * Present for a COING fan-out (coder / ci-fixer). Absent for a READ-ONLY explore fan-out
78
+ * (the bug-investigator), which only clones the peer to read it and never pushes.
79
+ */
80
+ newBranch?: string
81
+ /** Open a PR/MR in this peer when set AND the run changed the peer (skipped for a clean repo). */
82
+ pr?: PrSpec
83
+ /** Per-repo GitHub token; defaults to the job's `ghToken` (one installation per workspace today). */
84
+ ghToken?: string
85
+ }
86
+
64
87
  function str(value: unknown, path: string): string {
65
88
  if (typeof value !== 'string' || value.length === 0) {
66
89
  throw new Error(`Invalid job: '${path}' must be a non-empty string`)
@@ -179,6 +202,38 @@ function parseRepoSpec(repo: Record<string, unknown>): RepoSpec {
179
202
  return spec
180
203
  }
181
204
 
205
+ /**
206
+ * Parse the optional multi-repo peer list (service-connections phase 3). Each entry carries a
207
+ * full {@link RepoSpec} (validated + sanitised like the primary), the work branch to push, and
208
+ * an optional PR + per-repo token. A malformed list throws; an absent one yields `[]`.
209
+ */
210
+ function parsePeerRepos(value: unknown): PeerRepoSpec[] {
211
+ if (value === undefined || value === null) return []
212
+ if (!Array.isArray(value)) throw new Error("Invalid job: 'peerRepos' must be an array")
213
+ return value.map((entry, i) => {
214
+ if (typeof entry !== 'object' || entry === null) {
215
+ throw new Error(`Invalid job: 'peerRepos[${i}]' must be an object`)
216
+ }
217
+ const e = entry as Record<string, unknown>
218
+ const spec: PeerRepoSpec = {
219
+ repo: parseRepoSpec((e.repo ?? {}) as Record<string, unknown>),
220
+ }
221
+ // `newBranch` is required for a coding fan-out (it pushes to it) but ABSENT for a
222
+ // read-only explore fan-out (bug-investigator) — validate it only when present.
223
+ if (e.newBranch !== undefined) spec.newBranch = str(e.newBranch, `peerRepos[${i}].newBranch`)
224
+ if (typeof e.frameId === 'string' && e.frameId) spec.frameId = e.frameId
225
+ if (typeof e.ghToken === 'string' && e.ghToken) spec.ghToken = e.ghToken
226
+ if (typeof e.pr === 'object' && e.pr !== null) {
227
+ const p = e.pr as Record<string, unknown>
228
+ spec.pr = {
229
+ title: str(p.title, `peerRepos[${i}].pr.title`),
230
+ body: typeof p.body === 'string' ? p.body : '',
231
+ }
232
+ }
233
+ return spec
234
+ })
235
+ }
236
+
182
237
  /** Parse the optional `repo.provider` discriminator (defaults to undefined ⇒ host inference). */
183
238
  function parseVcsProvider(value: unknown): 'github' | 'gitlab' | undefined {
184
239
  if (value === undefined || value === null) return undefined
@@ -529,6 +584,13 @@ export interface AgentJob extends HarnessAuthFields {
529
584
  commitMessage?: string
530
585
  /** Coding mode: open this PR when the run pushed changes. Absent ⇒ push only, no PR. */
531
586
  pr?: PrSpec
587
+ /**
588
+ * Coding mode (implementer): connected services' repos to clone as SIBLINGS for a MULTI-REPO
589
+ * change (service-connections phase 3). When present, the agent works with its cwd at the
590
+ * workspace ROOT (all repos are sibling checkouts under it), and the harness opens one PR per
591
+ * peer repo it actually changed — in addition to the primary. Absent ⇒ single-repo run.
592
+ */
593
+ peerRepos?: PeerRepoSpec[]
532
594
  /**
533
595
  * Coding mode: whether a no-op run (nothing changed) is a failure. The implementer
534
596
  * fails on a no-op; the in-place fixers (ci-fix / fix-tests) treat it as a non-fatal
@@ -617,6 +679,13 @@ export interface AgentResult {
617
679
  pushed?: boolean
618
680
  prUrl?: string
619
681
  branch?: string
682
+ /**
683
+ * Coding mode (multi-repo): the PRs opened in the connected services' PEER repos, one per
684
+ * repo the run actually changed (service-connections phase 3). Beside the own-service
685
+ * `prUrl`/`branch`; the backend lifts these onto the block's `peerPullRequests`. Absent for
686
+ * a single-repo run.
687
+ */
688
+ peerPullRequests?: { repo: string; frameId?: string; prUrl: string; branch: string }[]
620
689
  /** Coding mode (bootstrap): the default branch the bootstrapped contents were pushed to. */
621
690
  defaultBranch?: string
622
691
  error?: string
@@ -856,6 +925,7 @@ export function parseAgentJob(input: unknown): AgentJob {
856
925
  })()
857
926
  : undefined
858
927
  const infra = parseAgentInfraSpec(o.infra)
928
+ const peerRepos = parsePeerRepos(o.peerRepos)
859
929
  const bootstrap = parseAgentBootstrapSpec(o.bootstrap)
860
930
  const contextFiles = parseContextFiles(o.contextFiles)
861
931
  const packageRegistries = parsePackageRegistries(o.packageRegistries)
@@ -886,6 +956,7 @@ export function parseAgentJob(input: unknown): AgentJob {
886
956
  ? { commitMessage: o.commitMessage }
887
957
  : {}),
888
958
  ...(pr ? { pr } : {}),
959
+ ...(peerRepos.length ? { peerRepos } : {}),
889
960
  ...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
890
961
  ...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
891
962
  ...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
@@ -896,5 +967,11 @@ export function parseAgentJob(input: unknown): AgentJob {
896
967
  // Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
897
968
  // allowed GitHub host too (the installation token is sent to it on the force-push).
898
969
  if (job.bootstrap) assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl')
970
+ // Each peer repo's clone URL receives the installation token on clone/push, so it must be
971
+ // an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
972
+ // exfiltrate the token exactly like a rogue primary clone URL.
973
+ for (const [i, peer] of (job.peerRepos ?? []).entries()) {
974
+ assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`)
975
+ }
899
976
  return job
900
977
  }
@@ -209,6 +209,12 @@ export interface AgentRunSpec {
209
209
  * is present directly in the container env (the self-hosted runner-pool path).
210
210
  */
211
211
  webSearchProxy?: boolean
212
+ /**
213
+ * Multi-repo run (service-connections phase 3): the cwd (`dir`) is the WORKSPACE ROOT with
214
+ * every involved repo checked out as a sibling under it. Suppresses the single-repo monorepo
215
+ * note in AGENTS.md and adds the multi-repo mechanics note instead. Absent ⇒ single-repo.
216
+ */
217
+ multiRepo?: boolean
212
218
  }
213
219
 
214
220
  /**
@@ -274,6 +280,7 @@ export async function runAgentInWorkspace(
274
280
  guidance: spec.webToolsGuidance,
275
281
  serviceDirectory: spec.serviceDirectory,
276
282
  contextFiles,
283
+ ...(spec.multiRepo ? { multiRepo: true } : {}),
277
284
  })
278
285
  await writePiModelsConfig({ model: spec.model, proxyBaseUrl })
279
286
  const { signal, onActivity, onProgress, onSpan } = opts
package/src/pi.ts CHANGED
@@ -141,6 +141,7 @@ export async function writeAgentsContext(
141
141
  guidance?: string
142
142
  serviceDirectory?: string
143
143
  contextFiles?: ContextFileInfo[]
144
+ multiRepo?: boolean
144
145
  } = {},
145
146
  ): Promise<void> {
146
147
  const dir = join(homedir(), '.pi', 'agent')
@@ -152,18 +153,44 @@ export async function writeAgentsContext(
152
153
  const webTools = opts.webSearch ? (opts.guidance ?? WEB_TOOLS_GUIDANCE) : ''
153
154
  // Tell the agent it's in a monorepo and which subtree is its service, so it scopes
154
155
  // its work (and its build/test commands) there. Only present when the dispatcher
155
- // resolved a monorepo service directory; the agent's cwd already points at it.
156
- const monorepo = opts.serviceDirectory ? monorepoGuidance(opts.serviceDirectory) : ''
156
+ // resolved a monorepo service directory; the agent's cwd already points at it. A
157
+ // MULTI-REPO run runs at the workspace root (cwd spans sibling checkouts), so the
158
+ // monorepo note is suppressed there — the multi-repo mechanics note replaces it.
159
+ const monorepo =
160
+ opts.serviceDirectory && !opts.multiRepo ? monorepoGuidance(opts.serviceDirectory) : ''
161
+ // Multi-repo mechanics note (service-connections phase 3): the concrete repo→role mapping
162
+ // is in the backend-composed system prompt above; this explains the shared MECHANICS (cwd
163
+ // is the workspace root, repos are sibling checkouts, one PR per dirty repo).
164
+ const multiRepo = opts.multiRepo ? MULTI_REPO_GUIDANCE : ''
157
165
  // Point the agent at any linked context the backend materialised into the checkout
158
166
  // (requirements / RFCs / PRDs / tracker issues) so it reads them on demand.
159
167
  const context = contextGuidance(opts.contextFiles ?? [])
160
168
  await writeFile(
161
169
  join(dir, 'AGENTS.md'),
162
- `${systemPrompt}${BLUEPRINT_GUIDANCE}${SPEC_GUIDANCE}${TODO_GUIDANCE}${monorepo}${webTools}${context}`,
170
+ `${systemPrompt}${BLUEPRINT_GUIDANCE}${SPEC_GUIDANCE}${TODO_GUIDANCE}${monorepo}${multiRepo}${webTools}${context}`,
163
171
  'utf8',
164
172
  )
165
173
  }
166
174
 
175
+ /** The MULTI-REPO mechanics note appended to AGENTS.md when a run spans sibling checkouts. */
176
+ const MULTI_REPO_GUIDANCE = `
177
+
178
+ ## Multi-repo workspace (work across sibling checkouts)
179
+
180
+ This task spans MORE THAN ONE repository. Your working directory is the WORKSPACE ROOT, and
181
+ each involved repository is checked out as a sibling directory directly under it. The workspace
182
+ root itself is NOT a git repository — run git INSIDE each repository's directory. The system
183
+ prompt above lists which repository is which and each one's role. Make the cross-service
184
+ change coherently across every repository the task requires — a provider's API and its
185
+ consumer's call site belong in the SAME piece of work. Run each repository's own build/test
186
+ commands inside that repository's directory.
187
+
188
+ Commit your own work inside each repository you change (\`cd\` into it, stage the files that
189
+ belong — INCLUDING any new files you added — and commit). The platform will NOT add untracked
190
+ files for you, so anything you leave uncommitted and untracked is lost. Each repository you
191
+ change is opened as a SEPARATE pull request; leave a repository untouched if the task does not
192
+ require changing it.`
193
+
167
194
  /** Directory in the checkout where linked-context files are materialised (see CONTEXT_DIR in agents). */
168
195
  export const CONTEXT_DIR = '.cat-context'
169
196