@cat-factory/orchestration 0.296.0 → 0.298.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.
@@ -1,26 +1,41 @@
1
- import { getErrorMessage } from '@cat-factory/kernel';
1
+ import { MAX_ADOPTION_READ_PATH, MAX_ADOPTION_READS } from '@cat-factory/contracts';
2
+ import { getErrorMessage, redactSecrets } from '@cat-factory/kernel';
2
3
  // ---------------------------------------------------------------------------
3
- // The DETERMINISTIC half of a monorepo bootstrap's survey: what the two sides actually
4
- // contain, read through the checkout-free `RepoFiles` port.
4
+ // A monorepo bootstrap's SURVEY: what the two sides actually contain, read through the
5
+ // checkout-free `RepoFiles` port.
5
6
  //
6
- // The platform computes this and the model only JUDGES it. That split is the whole reason the
7
- // suggestion is worth reviewing: a recommendation is checkable against a named file a human can
8
- // open, and a claim about a convention that cites nothing the survey read is dropped upstream
9
- // (`parseAdoptionDecisions`) rather than shown as if the platform had verified it.
7
+ // The read is in two halves, and the split is the whole design. The platform SEEDS an opening
8
+ // context (each side's root listing and the convention files it really holds, whichever CI
9
+ // declaration its provider uses, and the listing of every sibling that looks like a service),
10
+ // because there is no reason to spend model calls rediscovering `package.json` and it keeps the
11
+ // cheap case cheap. The MODEL then widens it: it asks for the CI workflow that will actually gate
12
+ // the pull request, follows a dependency into the shared package that says what adopting it
13
+ // entails, and opens a second and a third sibling when the first two disagree. None of that is
14
+ // enumerable in advance, which is why the previous declared list decided what the survey could
15
+ // not see before it looked.
10
16
  //
11
- // Two rules shape the reads. Everything probed is a BOUNDED, declared list (no crawl, no
12
- // recursive walk), so the cost of a survey does not scale with the size of the monorepo it is
13
- // landing in. And every read that FAILS is recorded as unreadable rather than skipped, because a
14
- // plan built without the monorepo's CI is materially weaker than one built with it, and only the
15
- // survey can say which of the two a reviewer is looking at.
17
+ // What the platform keeps is the BOOKKEEPING. Every read, seeded or model-chosen, is budgeted,
18
+ // scrubbed and appended to ONE transcript, and that transcript is what the plan carries, so a
19
+ // recommendation stays checkable against a record the model could not write
20
+ // (`parseAdoptionDecisions` drops a citation naming anything the transcript does not hold as
21
+ // READ). The bound is a call ceiling plus a character ceiling rather than a declared path list,
22
+ // so the cost of a survey still does not scale with the size of the monorepo it lands in.
23
+ //
24
+ // Every read that FAILS is recorded as unreadable rather than skipped, and one the platform
25
+ // declines is recorded as refused: a plan built without the monorepo's CI is materially weaker
26
+ // than one built with it, and only the transcript can say which of the two a reviewer has.
16
27
  // ---------------------------------------------------------------------------
17
28
  /**
18
29
  * The root-level files that carry a repository's conventions, in priority order.
19
30
  *
20
31
  * Cross-ecosystem on purpose: the flow is not JS-specific, and a Go or JVM monorepo whose
21
- * conventions this list cannot see would produce a survey that silently found "nothing", the
32
+ * conventions this list cannot see would produce a seed that silently found "nothing", the
22
33
  * failure mode this whole module exists to avoid. Probed by INTERSECTION with a directory
23
34
  * listing, so naming a file no repository has costs nothing.
35
+ *
36
+ * Still a declared list, and deliberately so: it is the OPENING context and the test a candidate
37
+ * directory has to pass to count as a service, not the boundary of what the survey can see.
38
+ * Anything it misses, the model can go and read.
24
39
  */
25
40
  const CONVENTION_FILES = [
26
41
  'package.json',
@@ -58,41 +73,156 @@ const CONVENTION_FILES = [
58
73
  'CONTRIBUTING.md',
59
74
  'README.md',
60
75
  ];
61
- /** Directory listings probed on the monorepo root for its CI wiring. */
62
- const CI_DIRECTORIES = ['.github/workflows', '.gitlab-ci.yml', '.circleci'];
63
76
  /**
64
- * How many candidate siblings are examined before one is chosen as the worked example.
77
+ * The CI directories the seed LISTS (never reads), each gated on the root entry that holds it.
78
+ *
79
+ * Listing rather than reading is the correction the tool loop makes possible: a monorepo with
80
+ * reusable workflows plus thirty per-service ones used to contribute an arbitrary two, and what
81
+ * a new directory is actually REQUIRED to satisfy (a path filter, a required check) was likely
82
+ * in one of the twenty-eight nobody read. The listing is the menu; the model picks off it.
83
+ *
84
+ * Every provider the platform pushes to is named, for the same reason {@link CONVENTION_FILES}
85
+ * is cross-ecosystem: a GitLab-hosted monorepo has no `.github` at all, so a seed that knows
86
+ * only GitHub hands the model an opening context with NO CI in it and leaves the `ci` area with
87
+ * nothing citable on exactly the deployments this platform supports as first-class. Probed by
88
+ * intersection with the root listing, so naming a provider no repository here uses costs nothing.
89
+ */
90
+ const CI_DIRECTORIES = [
91
+ { rootEntry: '.github', path: '.github/workflows' },
92
+ { rootEntry: '.circleci', path: '.circleci' },
93
+ ];
94
+ /**
95
+ * CI declarations that are a single root FILE, so there is no directory to offer as a menu.
96
+ *
97
+ * Read outside the {@link MAX_ROOT_FILES} convention cap deliberately: this is the whole of what
98
+ * its provider says about CI, and losing it to fourteen manifests would leave the `ci` area
99
+ * unevidenced on a repository that states its pipeline perfectly clearly.
100
+ */
101
+ const CI_FILES = ['.gitlab-ci.yml'];
102
+ /**
103
+ * How many sibling directories are probed as candidate worked examples.
65
104
  *
66
- * Bounded like every other read here, and >1 because the FIRST entry of a services directory is
67
- * not a service in any repository that also keeps tooling there: an alphabetical pick lands on
68
- * `.github`, `.changeset` or a `docs/` folder and then reports CI config to the reviewer as what
69
- * a service in this monorepo looks like. Dot-entries are excluded outright (below) and the rest
70
- * are probed in order until one holds a convention file of its own.
105
+ * Higher than the old pick-one probe because the seed no longer reads their files: a candidate
106
+ * costs one listing, and offering several is what makes a monorepo whose services DISAGREE
107
+ * representable at all. Dot-entries are excluded outright, and a candidate qualifies only by
108
+ * holding a convention file of its own.
109
+ */
110
+ const MAX_SIBLING_CANDIDATES = 6;
111
+ /**
112
+ * Per-BODY content cap, on a directory listing as much as on a file.
113
+ *
114
+ * A convention is legible from its opening and a lockfile-sized read is not, and the same bound
115
+ * has to reach a listing: a generated or vendored directory with five thousand entries renders a
116
+ * body two budgets wide, which the exploration charge can only answer by refusing, latching
117
+ * `exhausted` and reporting a survey that spent almost nothing as one that ran out of content.
71
118
  */
72
- const MAX_SIBLING_CANDIDATES = 4;
73
- /** Per-file content cap. A convention is legible from its opening; a lockfile-sized read is not. */
74
119
  const MAX_FILE_CHARS = 6_000;
75
- /** How many root files one side contributes, most-conventional first. */
120
+ /** How many root files one side contributes to the opening context, most-conventional first. */
76
121
  const MAX_ROOT_FILES = 14;
77
- /** How many files the worked-example sibling service contributes. */
78
- const MAX_SIBLING_FILES = 10;
79
- /** How many CI workflow files are read (they repeat heavily past the first couple). */
80
- const MAX_CI_FILES = 2;
122
+ /**
123
+ * The opening context's TOTAL character budget, split into an equal reservation per side.
124
+ *
125
+ * Spent in key order it would not be a bound at all but a handover to whichever side sorts
126
+ * first, and `monorepo:` sorts before `template:` for every key: a large monorepo would spend
127
+ * the whole allowance and the template would land entirely refused, which is exactly the
128
+ * crowding-out this exists to prevent. So a run with no template reserves all of it for the
129
+ * monorepo and a run with one gives each side half, spent in its own priority order; whatever a
130
+ * side leaves unspent carries to the next, which makes the reservation a floor rather than a cap.
131
+ */
132
+ const MAX_SEED_CHARS = 36_000;
133
+ /** How many reads the MODEL may ask for. The loop's hard ceiling; see {@link AdoptionExploration}. */
134
+ const MAX_EXPLORATION_CALLS = 24;
135
+ /** How many characters the model's own reads may spend, on top of the seed's reservation. */
136
+ const MAX_EXPLORATION_CHARS = 54_000;
137
+ const DEFAULT_LIMITS = {
138
+ maxExplorationCalls: MAX_EXPLORATION_CALLS,
139
+ maxExplorationChars: MAX_EXPLORATION_CHARS,
140
+ maxSeedChars: MAX_SEED_CHARS,
141
+ maxFileChars: MAX_FILE_CHARS,
142
+ };
81
143
  /** The parent directory a new service's siblings live in, or `''` for a root-level service. */
82
144
  export function parentDirectoryOf(directory) {
83
145
  const segments = directory.split('/').filter(Boolean);
84
146
  return segments.slice(0, -1).join('/');
85
147
  }
148
+ /**
149
+ * The longest raw path a model may ask for.
150
+ *
151
+ * Derived from the contract rather than restated: what the transcript records is the PREFIXED
152
+ * key, so the longest side name and a listing's trailing slash both have to fit inside
153
+ * {@link MAX_ADOPTION_READ_PATH} beside the path itself. Restating 400 here emitted a row the
154
+ * schema calls too long for any path over 390.
155
+ */
156
+ const MAX_SURVEY_PATH = MAX_ADOPTION_READ_PATH - 'template:'.length - 1;
157
+ /**
158
+ * A repository-relative path the platform is willing to fetch, or the reason it will not.
159
+ *
160
+ * The path is MODEL-AUTHORED and is interpolated into the VCS contents API's URL, so it is
161
+ * validated for magic rather than only for traversal, and both halves of that bite:
162
+ *
163
+ * - a control character or a backslash means the model is guessing at a shell or a Windows
164
+ * path, and answering "not found" would tell it the repository lacks a file it never actually
165
+ * asked for;
166
+ * - `?`, `#` and `%` are the URL's own syntax, and the caller appends its `?ref=` AFTER this
167
+ * path. A `#` truncates the request to a DIFFERENT file while the transcript records the whole
168
+ * string as read, so a citation lands on the plan pointing at a path no reviewer can open; a
169
+ * `?ref=` of the model's own is honoured over the branch the survey believes it is reading;
170
+ * and a percent escape puts the traversal check below on the wrong side of the decoding the
171
+ * host, not this process, performs.
172
+ *
173
+ * A refusal is REPORTED (it lands on the transcript and the model is told), never a silent
174
+ * shortening.
175
+ */
176
+ export function normalizeSurveyPath(raw) {
177
+ const trimmed = raw.trim().replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
178
+ if (trimmed.length > MAX_SURVEY_PATH) {
179
+ return { refused: 'the path is too long to be a repository path' };
180
+ }
181
+ if (trimmed.includes('\\') || [...trimmed].some((ch) => ch.charCodeAt(0) < 0x20)) {
182
+ return { refused: 'the path contains characters that are not part of a repository path' };
183
+ }
184
+ if (/[?#%]/.test(trimmed)) {
185
+ return {
186
+ refused: 'the path contains URL syntax (? # or %), which is not part of a repository path',
187
+ };
188
+ }
189
+ const segments = trimmed.split('/');
190
+ if (segments.some((segment) => segment === '..')) {
191
+ return { refused: 'the path leaves the repository; give a path relative to its root' };
192
+ }
193
+ return { path: segments.filter((segment) => segment !== '.').join('/') };
194
+ }
195
+ /** The citable key one read produces: `<side>:<path>`, with a trailing `/` marking a listing. */
196
+ function keyFor(side, kind, path) {
197
+ if (kind === 'read')
198
+ return `${side}:${path}`;
199
+ // `./` for the root, so a listing always reads as one and no key ends in a bare colon.
200
+ return `${side}:${path ? `${path}/` : './'}`;
201
+ }
86
202
  /** Clip one file's content to the per-file cap, stating the clip rather than hiding it. */
87
- function clip(content) {
88
- if (content.length <= MAX_FILE_CHARS)
203
+ function clip(content, max) {
204
+ if (content.length <= max)
89
205
  return content;
90
- return `${content.slice(0, MAX_FILE_CHARS)}\n…[truncated: ${content.length - MAX_FILE_CHARS} more characters not shown]`;
206
+ return `${content.slice(0, max)}\n…[truncated: ${content.length - max} more characters not shown]`;
207
+ }
208
+ /**
209
+ * Render a directory's entries as the body a decision can cite for layout, under the same
210
+ * per-body cap a file read answers to.
211
+ *
212
+ * Uncapped, one listing of a generated directory could be wider than the whole exploration
213
+ * budget, and the only answer a charge has to a body it cannot fit is to refuse it and latch
214
+ * `exhausted`, reporting a content budget that ran out when nothing had been spent.
215
+ */
216
+ function renderListing(entries, max) {
217
+ return clip(entries
218
+ .map((entry) => `${entry.name}${entry.type === 'dir' ? '/' : ''}`)
219
+ .sort()
220
+ .join('\n'), max);
91
221
  }
92
222
  /**
93
- * One side's reader, accumulating what it read and what it could not.
223
+ * One side's raw reader: performs the IO, hands bodies and failures to the ledger.
94
224
  *
95
- * `unreadable` is the load-bearing half. A `getFile` that THROWS is a provider failure (a
225
+ * `unreadable` is the load-bearing distinction. A `getFile` that THROWS is a provider failure (a
96
226
  * revoked token, a rate limit, an outage), and it is not the same fact as the file being absent
97
227
  * (which `getFile` reports as `null`). Collapsing the two would let a survey blinded by an
98
228
  * expired installation token present itself as a monorepo with no conventions.
@@ -100,190 +230,513 @@ function clip(content) {
100
230
  class SideReader {
101
231
  side;
102
232
  prefix;
103
- logger;
104
- read = [];
105
- unreadable = [];
106
- contents = {};
107
- constructor(side, prefix, logger) {
233
+ constructor(side, prefix) {
108
234
  this.side = side;
109
235
  this.prefix = prefix;
110
- this.logger = logger;
111
236
  }
112
- /** Entry names of a directory, or null when it could not be listed (recorded as unreadable). */
237
+ /** Entry names of a directory, or the failure that stopped the listing. */
113
238
  async list(path) {
114
239
  try {
115
- return await this.side.files.listDirectory(path, this.side.gitRef);
240
+ return { entries: await this.side.files.listDirectory(path, this.side.gitRef) };
116
241
  }
117
242
  catch (error) {
118
- this.unreadable.push(`${path || '.'}/`);
119
- this.logger?.warn('monorepo survey: directory listing failed', {
120
- side: this.prefix,
121
- path,
122
- err: getErrorMessage(error),
123
- });
124
- return null;
243
+ return { failed: getErrorMessage(error) };
125
244
  }
126
245
  }
127
- /** Read one file into the survey; absent is silent, unreadable is recorded. */
128
- async take(path) {
246
+ /** One file's content, `null` when it is simply absent, or the failure that stopped the read. */
247
+ async read(path) {
129
248
  try {
130
249
  const file = await this.side.files.getFile(path, this.side.gitRef);
131
- if (!file)
132
- return;
133
- this.read.push(path);
134
- this.contents[`${this.prefix}:${path}`] = clip(file.content);
250
+ return { content: file ? file.content : null };
135
251
  }
136
252
  catch (error) {
137
- this.unreadable.push(path);
138
- this.logger?.warn('monorepo survey: file read failed', {
139
- side: this.prefix,
140
- path,
141
- err: getErrorMessage(error),
253
+ return { failed: getErrorMessage(error) };
254
+ }
255
+ }
256
+ }
257
+ /**
258
+ * A monorepo survey in progress: the transcript, the budgets, and the reader the model widens it
259
+ * through.
260
+ *
261
+ * Created before the model is asked anything, seeded once, then handed to the advisor. The
262
+ * caller re-reads {@link survey} AFTER the advisor returns, because the transcript is what the
263
+ * model actually fetched rather than what the platform predicted it would need.
264
+ */
265
+ export class MonorepoSurveySession {
266
+ request;
267
+ limits;
268
+ readers;
269
+ log;
270
+ reads = [];
271
+ bodies = {};
272
+ /**
273
+ * Seed bodies that were FETCHED but did not fit the opening context's reservation.
274
+ *
275
+ * Held rather than dropped: the prompt names them and tells the model to ask, so throwing the
276
+ * bytes away buys a second contents-API round trip for content this process is already
277
+ * holding. Kept OUT of {@link bodies} so nothing is citable before it has actually been served,
278
+ * and out of {@link seedKeys} so it stays out of the opening prompt.
279
+ */
280
+ withheld = {};
281
+ seedKeys = new Set();
282
+ siblings = [];
283
+ calls = 0;
284
+ explorationChars = 0;
285
+ recordsDropped = 0;
286
+ exhausted = null;
287
+ constructor(request) {
288
+ this.request = request;
289
+ this.limits = { ...DEFAULT_LIMITS, ...request.limits };
290
+ this.log = request.logger;
291
+ this.readers = {
292
+ monorepo: new SideReader(request.monorepo, 'monorepo'),
293
+ ...(request.template ? { template: new SideReader(request.template, 'template') } : {}),
294
+ };
295
+ }
296
+ get sides() {
297
+ return Object.keys(this.readers);
298
+ }
299
+ /** The transcript, the siblings offered, and what the exploration spent, as of right now. */
300
+ survey() {
301
+ return {
302
+ reads: [...this.reads],
303
+ siblingServices: [...this.siblings],
304
+ exploration: {
305
+ calls: this.calls,
306
+ maxCalls: this.limits.maxExplorationCalls,
307
+ chars: this.explorationChars,
308
+ maxChars: this.limits.maxExplorationChars,
309
+ exhausted: this.exhausted,
310
+ recordsDropped: this.recordsDropped,
311
+ },
312
+ };
313
+ }
314
+ /**
315
+ * The bodies rendered into the model's OPENING prompt.
316
+ *
317
+ * Only the seeded ones: everything the model fetches afterwards reaches it as that tool call's
318
+ * own result, and folding those into the prompt too would send each body twice.
319
+ */
320
+ seedFiles() {
321
+ const seeded = {};
322
+ for (const key of this.seedKeys) {
323
+ const body = this.bodies[key];
324
+ if (body !== undefined)
325
+ seeded[key] = body;
326
+ }
327
+ return seeded;
328
+ }
329
+ /**
330
+ * Record something the platform could not even attempt, so it does not read as an absence.
331
+ *
332
+ * The one caller is a reference template the workspace has not LINKED: unreadable from here
333
+ * even though the apply phase's container can still clone it. "The template ships nothing for
334
+ * this area" and "nobody looked at the template" lead a reviewer to opposite conclusions.
335
+ */
336
+ noteUnavailable(side, path, note) {
337
+ this.record({ path: `${side}:${path}`, origin: 'seed', outcome: 'unreadable', chars: 0, note });
338
+ }
339
+ /**
340
+ * Read the opening context: each side's root, the CI directory, and every sibling that looks
341
+ * like a service.
342
+ *
343
+ * The sibling probe is the read no root file can stand in for, and it is a LIST rather than a
344
+ * pick. One sibling is a sample of size one, so a monorepo with a six-year-old Java service
345
+ * beside three new TypeScript ones has no single house convention, and naming whichever
346
+ * directory sorted first reports a disagreement as though it were the answer. Dot-entries are
347
+ * excluded (`.github` sorts below every letter, so an alphabetical pick landed on a workflows
348
+ * folder for any root-level target) and a candidate qualifies only by holding a convention file
349
+ * of its own.
350
+ */
351
+ async seed() {
352
+ const harvests = [];
353
+ const mono = this.readers.monorepo;
354
+ if (mono)
355
+ harvests.push({ reader: mono, harvest: await this.seedMonorepo(mono) });
356
+ const template = this.readers.template;
357
+ if (template)
358
+ harvests.push({ reader: template, harvest: await this.seedTemplate(template) });
359
+ this.commitSeed(harvests.map((entry) => entry.harvest));
360
+ }
361
+ async seedMonorepo(reader) {
362
+ const candidates = [];
363
+ const root = await this.seedRoot(reader, candidates);
364
+ const parent = parentDirectoryOf(this.request.directory);
365
+ // Re-uses the root listing for a root-level target rather than asking for it twice. The
366
+ // parent's own listing contributes no citable entry (the empty candidate sink): it is the
367
+ // MENU the sibling probe reads, and each qualifying sibling's listing carries the layout
368
+ // evidence. A failure to list it is still recorded, because "no siblings" and "could not see
369
+ // whether there are siblings" are opposite facts.
370
+ const siblingEntries = parent === '' ? root : await this.listInto(reader, parent, []);
371
+ const siblings = await this.probeSiblings(reader, siblingEntries, candidates);
372
+ return { candidates, siblings };
373
+ }
374
+ async seedTemplate(reader) {
375
+ const candidates = [];
376
+ await this.seedRoot(reader, candidates);
377
+ return { candidates, siblings: [] };
378
+ }
379
+ /**
380
+ * What BOTH sides contribute: the root listing, the conventions it holds, and its CI.
381
+ *
382
+ * Shared rather than the monorepo's alone, because `ci` is a decision BETWEEN the two sides and
383
+ * a seed that evidences only one of them biases it in the direction the prompt spends a
384
+ * paragraph forbidding: an area nothing was read about is not an area the other side wins.
385
+ */
386
+ async seedRoot(reader, candidates) {
387
+ const root = await this.listInto(reader, '', candidates);
388
+ if (!root)
389
+ return null;
390
+ await this.takeRootFiles(reader, root, candidates);
391
+ for (const ci of CI_DIRECTORIES) {
392
+ if (root.some((entry) => entry.name === ci.rootEntry)) {
393
+ await this.listInto(reader, ci.path, candidates);
394
+ }
395
+ }
396
+ return root;
397
+ }
398
+ /**
399
+ * List a directory, recording the listing as a citable seed entry.
400
+ *
401
+ * The listing costs no extra request (it is the one the seed already needed) and it is the only
402
+ * evidence either side offers about source layout and module structure: no root manifest states
403
+ * where a service puts its code, its tests or its entry point. Without it, a `source-layout`
404
+ * recommendation cites nothing and is dropped upstream as invention, so `template` was the only
405
+ * answer the model could legitimately give for that area on every monorepo.
406
+ */
407
+ async listInto(reader, path, candidates) {
408
+ const key = keyFor(reader.prefix, 'list', path);
409
+ const result = await reader.list(path);
410
+ if ('failed' in result) {
411
+ this.noteListingFailure(reader, path, result.failed);
412
+ return null;
413
+ }
414
+ if (result.entries.length === 0) {
415
+ this.record({
416
+ path: key,
417
+ origin: 'seed',
418
+ outcome: 'absent',
419
+ chars: 0,
420
+ note: 'the directory is empty or does not exist',
142
421
  });
422
+ return result.entries;
143
423
  }
424
+ candidates.push({ key, body: renderListing(result.entries, this.limits.maxFileChars) });
425
+ return result.entries;
144
426
  }
145
427
  /**
146
- * Record a directory's SHAPE as a citable survey entry.
428
+ * A seed listing that FAILED: warned and recorded as unreadable, never skipped.
147
429
  *
148
- * The listing is already in hand (nothing is re-read), and it is the only evidence the survey
149
- * has for `source-layout` and module structure: no root manifest states where a service puts
150
- * its code, its tests or its entry point, and the sibling's own config files do not either. A
151
- * recommendation about layout that cites nothing is dropped upstream as invention, so without
152
- * this the model can only ever answer `template` for one of the twelve areas it is asked
153
- * about. Recorded under the directory path with a trailing slash, so the key a decision cites
154
- * is visibly a listing rather than a file.
430
+ * Shared with the sibling probe, which is the read where skipping costs the most. "No sibling
431
+ * service" is the strongest claim the opening context makes about a monorepo (it tells the
432
+ * model there is no worked example and it tells the reviewer the survey saw root conventions
433
+ * only), so a probe blinded by a revoked token or a rate limit has to say so rather than
434
+ * produce the sentence a genuinely flat repository produces.
155
435
  */
156
- noteLayout(dir, entries) {
157
- if (entries.length === 0)
158
- return;
159
- // `./` for the root, matching how `list` names an unreadable root, so no key is a bare slash.
160
- const path = dir ? `${dir}/` : './';
161
- this.read.push(path);
162
- this.contents[`${this.prefix}:${path}`] = entries
163
- .map((entry) => `${entry.name}${entry.type === 'dir' ? '/' : ''}`)
164
- .sort()
165
- .join('\n');
436
+ noteListingFailure(reader, path, cause) {
437
+ this.log?.warn('monorepo survey: directory listing failed', {
438
+ side: reader.prefix,
439
+ path,
440
+ err: cause,
441
+ });
442
+ this.record({
443
+ path: keyFor(reader.prefix, 'list', path),
444
+ origin: 'seed',
445
+ outcome: 'unreadable',
446
+ chars: 0,
447
+ note: cause,
448
+ });
166
449
  }
167
450
  /**
168
- * Read the convention files a listed directory actually holds, in the declared priority order
169
- * and capped. Reads run concurrently: the set is bounded and declared, so this is one fixed
170
- * fan-out rather than a loop that grows with the repository.
451
+ * The root files one side contributes: its conventions, capped in priority order, plus any
452
+ * single-file CI declaration.
453
+ *
454
+ * The two lists are read together but capped apart. A CI file competing for the convention cap
455
+ * would be crowded out by fourteen manifests on exactly the repositories whose CI is a single
456
+ * file, which is the `ci` area losing its only evidence to a tie-break nobody chose.
171
457
  */
172
- async takeConventionFiles(dir, entries, limit) {
458
+ async takeRootFiles(reader, entries, candidates) {
173
459
  const present = new Set(entries.filter((entry) => entry.type === 'file').map((entry) => entry.name));
174
- const wanted = CONVENTION_FILES.filter((name) => present.has(name)).slice(0, limit);
175
- await Promise.all(wanted.map((name) => this.take(dir ? `${dir}/${name}` : name)));
460
+ await this.takeFiles(reader, '', [
461
+ ...CONVENTION_FILES.filter((name) => present.has(name)).slice(0, MAX_ROOT_FILES),
462
+ ...CI_FILES.filter((name) => present.has(name)),
463
+ ], candidates);
176
464
  }
177
- }
178
- /**
179
- * Entries of `parent` that could plausibly be a sibling SERVICE, in probe order.
180
- *
181
- * Excludes the target itself, every dot-entry, and the CI directories the survey has already
182
- * read: `.github` sorts below every letter, so an alphabetical pick over a raw listing returns
183
- * it for any root-level target, and the reviewer is then told a workflows folder is "the best
184
- * available statement of what a service in this monorepo looks like".
185
- */
186
- function siblingCandidates(entries, directory) {
187
- const excluded = new Set(CI_DIRECTORIES);
188
- return entries
189
- .filter((entry) => entry.type === 'dir' &&
190
- entry.path !== directory &&
191
- !entry.name.startsWith('.') &&
192
- !excluded.has(entry.path))
193
- .map((entry) => entry.path)
194
- .sort()
195
- .slice(0, MAX_SIBLING_CANDIDATES);
196
- }
197
- /**
198
- * Choose one existing sibling as the monorepo's worked example, read it, and return its path.
199
- *
200
- * A candidate has to actually look like a service: a directory holding no convention file of its
201
- * own says nothing about how a service here is built, and presenting it as the example is worse
202
- * than presenting none, because "no sibling" is a fact the plan REPORTS while a bad sibling is
203
- * one it asserts. Returns null when nothing qualifies, which the survey carries through as
204
- * `siblingService: null` and the prompt states outright.
205
- */
206
- async function pickSiblingService(mono, entries, directory) {
207
- if (!entries)
208
- return null;
209
- for (const candidate of siblingCandidates(entries, directory)) {
210
- const own = await mono.list(candidate);
211
- if (!own)
212
- continue;
213
- const names = new Set(own.filter((entry) => entry.type === 'file').map((entry) => entry.name));
214
- if (!CONVENTION_FILES.some((name) => names.has(name)))
215
- continue;
216
- // The SHAPE first (it is the only evidence for layout), then the files themselves.
217
- mono.noteLayout(candidate, own);
218
- await mono.takeConventionFiles(candidate, own, MAX_SIBLING_FILES);
219
- return candidate;
465
+ /** Read a named set of files a directory actually holds, in the order they were named. */
466
+ async takeFiles(reader, dir, wanted, candidates) {
467
+ // One fixed fan-out: the set is bounded and declared, so this never grows with the repository.
468
+ const results = await Promise.all(wanted.map(async (name) => {
469
+ const path = dir ? `${dir}/${name}` : name;
470
+ return { path, result: await reader.read(path) };
471
+ }));
472
+ for (const { path, result } of results) {
473
+ const key = keyFor(reader.prefix, 'read', path);
474
+ if ('failed' in result) {
475
+ this.log?.warn('monorepo survey: file read failed', {
476
+ side: reader.prefix,
477
+ path,
478
+ err: result.failed,
479
+ });
480
+ this.record({
481
+ path: key,
482
+ origin: 'seed',
483
+ outcome: 'unreadable',
484
+ chars: 0,
485
+ note: result.failed,
486
+ });
487
+ continue;
488
+ }
489
+ // Absent is silent here: the set was intersected with a real listing, so a null means the
490
+ // entry vanished between the two calls, which says nothing a reviewer needs.
491
+ if (result.content === null)
492
+ continue;
493
+ candidates.push({ key, body: clip(this.scrub(result.content), this.limits.maxFileChars) });
494
+ }
495
+ }
496
+ /**
497
+ * List every plausible sibling service, keeping the ones that hold a convention file.
498
+ *
499
+ * Excludes the target itself and every dot-entry (which is also what keeps the CI folder out,
500
+ * since a listing's entries are one level deep): a directory that says nothing about how a
501
+ * service here is built is worse than no example, because "no sibling" is a fact the plan
502
+ * REPORTS while a bad sibling is one it asserts.
503
+ *
504
+ * One bounded fan-out rather than a loop of awaits, the shape {@link takeFiles} already uses:
505
+ * the candidate set is capped above, so six sequential round trips to the VCS host sat in the
506
+ * opening context's critical path for nothing.
507
+ */
508
+ async probeSiblings(reader, entries, candidates) {
509
+ if (!entries)
510
+ return [];
511
+ const target = this.request.directory;
512
+ const probes = entries
513
+ .filter((entry) => entry.type === 'dir' && entry.path !== target && !entry.name.startsWith('.'))
514
+ .map((entry) => entry.path)
515
+ .sort()
516
+ .slice(0, MAX_SIBLING_CANDIDATES);
517
+ const listings = await Promise.all(probes.map(async (path) => ({ path, result: await reader.list(path) })));
518
+ const qualifying = [];
519
+ for (const { path, result } of listings) {
520
+ if ('failed' in result) {
521
+ this.noteListingFailure(reader, path, result.failed);
522
+ continue;
523
+ }
524
+ const names = new Set(result.entries.filter((e) => e.type === 'file').map((e) => e.name));
525
+ if (!CONVENTION_FILES.some((name) => names.has(name)))
526
+ continue;
527
+ qualifying.push(path);
528
+ candidates.push({
529
+ key: keyFor(reader.prefix, 'list', path),
530
+ body: renderListing(result.entries, this.limits.maxFileChars),
531
+ });
532
+ }
533
+ return qualifying;
534
+ }
535
+ /**
536
+ * Apply the per-side reservation to everything the seed fetched, and record the result.
537
+ *
538
+ * A body that does not fit is recorded `refused` rather than dropped, for the same reason the
539
+ * survey reports what it could not read: the model must not treat a file it was never shown as
540
+ * a file that does not exist, and the reviewer sees the same list. The BYTES are kept even so
541
+ * (see {@link withheld}), because the note invites the model to ask for them and re-fetching
542
+ * what is already in memory is a round trip for nothing. Whatever an earlier side leaves
543
+ * unspent carries forward, so the reservation is a floor rather than a ceiling.
544
+ */
545
+ commitSeed(harvests) {
546
+ const share = Math.floor(this.limits.maxSeedChars / Math.max(1, harvests.length));
547
+ let spare = this.limits.maxSeedChars - share * harvests.length;
548
+ for (const harvest of harvests) {
549
+ this.siblings = [...this.siblings, ...harvest.siblings];
550
+ let budget = share + spare;
551
+ for (const candidate of harvest.candidates) {
552
+ if (candidate.body.length > budget) {
553
+ this.withheld[candidate.key] = candidate.body;
554
+ this.record({
555
+ path: candidate.key,
556
+ origin: 'seed',
557
+ outcome: 'refused',
558
+ chars: 0,
559
+ note: 'read, but it did not fit the opening context; ask for it if you need it',
560
+ });
561
+ continue;
562
+ }
563
+ budget -= candidate.body.length;
564
+ this.bodies[candidate.key] = candidate.body;
565
+ this.seedKeys.add(candidate.key);
566
+ this.record({
567
+ path: candidate.key,
568
+ origin: 'seed',
569
+ outcome: 'read',
570
+ chars: candidate.body.length,
571
+ note: null,
572
+ });
573
+ }
574
+ spare = budget;
575
+ }
576
+ }
577
+ /**
578
+ * One model-chosen read, charged against the exploration budget.
579
+ *
580
+ * The call is counted BEFORE anything else, refusals included: a model emitting nonsense paths
581
+ * would otherwise loop until the step cap fired, having read nothing. Budget exhaustion is
582
+ * answered rather than thrown, so the model is told what it has left and can produce a plan
583
+ * that says which areas it ran short on, instead of the loop ending with no reply at all.
584
+ */
585
+ async explore(request) {
586
+ this.calls += 1;
587
+ const reader = this.readers[request.side];
588
+ if (!reader) {
589
+ return this.refuse(request, `there is no ${request.side} repository in this run to read`);
590
+ }
591
+ if (this.calls > this.limits.maxExplorationCalls) {
592
+ this.exhausted = 'calls';
593
+ return this.refuse(request, `the exploration budget is spent (${this.limits.maxExplorationCalls} reads). Answer from ` +
594
+ `what you have already seen, and say in the rationale which areas you could not check`);
595
+ }
596
+ const normalized = normalizeSurveyPath(request.path);
597
+ if ('refused' in normalized)
598
+ return this.refuse(request, normalized.refused);
599
+ const path = normalized.path;
600
+ if (request.kind === 'read' && path === '') {
601
+ return this.refuse(request, 'name the file to read, relative to the repository root');
602
+ }
603
+ // The new service does not exist yet, but a RETRY surveying after a partial run would find
604
+ // whatever the previous attempt left there. Reading it back as the monorepo's established
605
+ // convention is the platform citing its own draft to itself.
606
+ if (request.side === 'monorepo' && this.isInsideTarget(path)) {
607
+ return this.refuse(request, `${this.request.directory} is the service being created, not an existing one; read a sibling instead`);
608
+ }
609
+ const key = keyFor(request.side, request.kind, path);
610
+ const cached = this.bodies[key];
611
+ // Already fetched (seeded, or asked for twice): answer from what was read rather than
612
+ // spending a second read of the same bytes on a bounded budget. No new transcript row, since
613
+ // it names a read already recorded; the CALL is still counted, so a model re-requesting the
614
+ // same file cannot buy itself an unbounded loop.
615
+ if (cached !== undefined) {
616
+ return { outcome: 'read', body: cached, note: null, key };
617
+ }
618
+ // Fetched during the seed but never shown, so the model taking the prompt's advice and asking
619
+ // for it costs no round trip. CHARGED all the same: the bytes enter the model's context now,
620
+ // which is what the exploration budget bounds, and it takes a `read` row of its own beside the
621
+ // seed's `refused` one, because the citation check upstream keys on the OUTCOME.
622
+ const withheld = this.withheld[key];
623
+ if (withheld !== undefined) {
624
+ const answer = this.charge(key, withheld);
625
+ // Dropped only once it is SERVED: a charge refused for an exhausted budget must leave the
626
+ // body where it is, so a later call with room can still answer it without a second fetch.
627
+ if (answer.outcome === 'read')
628
+ delete this.withheld[key];
629
+ return answer;
630
+ }
631
+ return request.kind === 'list'
632
+ ? await this.exploreList(reader, path, key)
633
+ : await this.exploreRead(reader, path, key);
634
+ }
635
+ async exploreList(reader, path, key) {
636
+ const result = await reader.list(path);
637
+ if ('failed' in result)
638
+ return this.fail(key, result.failed);
639
+ if (result.entries.length === 0) {
640
+ this.record({
641
+ path: key,
642
+ origin: 'model',
643
+ outcome: 'absent',
644
+ chars: 0,
645
+ note: 'no such directory, or it is empty',
646
+ });
647
+ return { outcome: 'absent', body: '', note: 'no such directory, or it is empty', key: null };
648
+ }
649
+ return this.charge(key, renderListing(result.entries, this.limits.maxFileChars));
650
+ }
651
+ async exploreRead(reader, path, key) {
652
+ const result = await reader.read(path);
653
+ if ('failed' in result)
654
+ return this.fail(key, result.failed);
655
+ if (result.content === null) {
656
+ this.record({ path: key, origin: 'model', outcome: 'absent', chars: 0, note: 'no such file' });
657
+ return { outcome: 'absent', body: '', note: 'no such file in this repository', key: null };
658
+ }
659
+ return this.charge(key, clip(this.scrub(result.content), this.limits.maxFileChars));
660
+ }
661
+ /** Spend a model read's characters, or refuse it because the content budget is gone. */
662
+ charge(key, body) {
663
+ const remaining = this.limits.maxExplorationChars - this.explorationChars;
664
+ if (body.length > remaining) {
665
+ this.exhausted = 'chars';
666
+ const note = `the exploration content budget is spent (${this.limits.maxExplorationChars} characters). ` +
667
+ `Answer from what you have already seen, and say in the rationale which areas you could ` +
668
+ `not check`;
669
+ this.record({ path: key, origin: 'model', outcome: 'refused', chars: 0, note });
670
+ return { outcome: 'refused', body: '', note, key: null };
671
+ }
672
+ this.explorationChars += body.length;
673
+ this.bodies[key] = body;
674
+ this.record({ path: key, origin: 'model', outcome: 'read', chars: body.length, note: null });
675
+ return { outcome: 'read', body, note: null, key };
676
+ }
677
+ /** A provider failure on a model read: recorded, and stated to the model as UNKNOWN. */
678
+ fail(key, cause) {
679
+ this.log?.warn('monorepo survey: model-requested read failed', { key, err: cause });
680
+ this.record({ path: key, origin: 'model', outcome: 'unreadable', chars: 0, note: cause });
681
+ return {
682
+ outcome: 'unreadable',
683
+ body: '',
684
+ note: `that read failed (${cause}); treat what it would have said as UNKNOWN, not as absent`,
685
+ key: null,
686
+ };
687
+ }
688
+ /** A read the platform declines: recorded on the transcript, and the reason given to the model. */
689
+ refuse(request, note) {
690
+ this.record({
691
+ path: keyFor(request.side, request.kind, request.path.slice(0, 200) || '.'),
692
+ origin: 'model',
693
+ outcome: 'refused',
694
+ chars: 0,
695
+ note,
696
+ });
697
+ return { outcome: 'refused', body: '', note, key: null };
698
+ }
699
+ /** Whether a path is the new service's own directory or something under it. */
700
+ isInsideTarget(path) {
701
+ const target = this.request.directory.replace(/\/+$/, '');
702
+ return target !== '' && (path === target || path.startsWith(`${target}/`));
703
+ }
704
+ /**
705
+ * Scrub at READ time, so every body is scrubbed once and nothing can reach a model through a
706
+ * path that forgot to. A prompt built from an unscrubbed body is strictly more exposed than the
707
+ * transcript, and the exploration half has no compose step a caller could scrub at.
708
+ */
709
+ scrub(body) {
710
+ return redactSecrets(body) ?? '';
711
+ }
712
+ /**
713
+ * Append to the transcript, up to the cap.
714
+ *
715
+ * Past {@link MAX_ADOPTION_READS} the entry is COUNTED and not recorded: one model turn can emit
716
+ * any number of tool calls, so the array needs a bound the call budget does not give it, and
717
+ * that count is what states the truncation to the reviewer. The gap between `calls` and the
718
+ * array's length cannot: the seed adds rows without adding calls, and a call answered from what
719
+ * was already read adds a call without a row.
720
+ */
721
+ record(read) {
722
+ if (this.reads.length >= MAX_ADOPTION_READS) {
723
+ this.recordsDropped += 1;
724
+ return;
725
+ }
726
+ this.reads.push(read);
220
727
  }
221
- return null;
222
728
  }
223
729
  /**
224
- * Survey both sides of a monorepo bootstrap: the house conventions the new service is landing
225
- * among, and what the reference template ships for the same areas.
730
+ * Open a survey of both sides of a monorepo bootstrap and read its opening context.
226
731
  *
227
- * Three reads make up the monorepo half, and the third is the one that matters most: the root
228
- * config says what the repository declares, the CI workflows say what it enforces, and the
229
- * nearest EXISTING SIBLING service says what a service in this repository actually looks like,
230
- * which is the thing a new service has to match and the thing no root file states. When nothing
231
- * beside the target qualifies as a sibling service, that is reported (`siblingService: null`)
232
- * rather than filled with the first directory found, which is a claim the survey cannot support.
233
- *
234
- * Each side also contributes its own SHAPE (`noteLayout`), because the shape is the only evidence
235
- * either side offers about source layout and module structure. Both are read off listings the
236
- * survey already needed, so neither costs a request.
732
+ * Two reads make up the monorepo half and the second is the one that matters most: the root
733
+ * config says what the repository declares, and the sibling services beside the target say what a
734
+ * service in this repository actually looks like, which is the thing a new service has to match
735
+ * and the thing no root file states. Everything past that is the model's to ask for.
237
736
  */
238
737
  export async function surveyMonorepo(request) {
239
- const { directory, logger } = request;
240
- const mono = new SideReader(request.monorepo, 'monorepo', logger);
241
- const template = request.template
242
- ? new SideReader(request.template, 'template', logger)
243
- : undefined;
244
- // ---- the monorepo's root conventions -------------------------------------
245
- const rootEntries = await mono.list('');
246
- if (rootEntries)
247
- await mono.takeConventionFiles('', rootEntries, MAX_ROOT_FILES);
248
- // ---- what it enforces in CI ---------------------------------------------
249
- for (const ciPath of CI_DIRECTORIES) {
250
- if (ciPath.endsWith('.yml')) {
251
- await mono.take(ciPath);
252
- continue;
253
- }
254
- const entries = await mono.list(ciPath);
255
- if (!entries)
256
- continue;
257
- const workflows = entries
258
- .filter((entry) => entry.type === 'file')
259
- .slice(0, MAX_CI_FILES)
260
- .map((entry) => entry.path);
261
- await Promise.all(workflows.map((path) => mono.take(path)));
262
- }
263
- // ---- the nearest existing sibling service (the worked example) -----------
264
- const parent = parentDirectoryOf(directory);
265
- // Re-uses the root listing for a root-level target rather than asking for it twice.
266
- const siblingEntries = parent === '' ? rootEntries : await mono.list(parent);
267
- const sibling = await pickSiblingService(mono, siblingEntries, directory);
268
- // ---- the reference template ---------------------------------------------
269
- // Its SHAPE is recorded beside its files for the same reason the sibling's is: a layout
270
- // recommendation needs evidence on both sides, or the one side that has any wins by default.
271
- if (template) {
272
- const entries = await template.list('');
273
- if (entries) {
274
- template.noteLayout('', entries);
275
- await template.takeConventionFiles('', entries, MAX_ROOT_FILES);
276
- }
277
- }
278
- const survey = {
279
- monorepoPaths: mono.read,
280
- templatePaths: template?.read ?? [],
281
- unreadablePaths: [
282
- ...mono.unreadable.map((path) => `monorepo:${path}`),
283
- ...(template?.unreadable ?? []).map((path) => `template:${path}`),
284
- ],
285
- siblingService: sibling,
286
- };
287
- return { survey, files: { ...mono.contents, ...template?.contents } };
738
+ const session = new MonorepoSurveySession(request);
739
+ await session.seed();
740
+ return session;
288
741
  }
289
742
  //# sourceMappingURL=monorepoSurvey.js.map