@withtruing/companion 0.13.2 → 0.14.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 (57) hide show
  1. package/README.md +25 -26
  2. package/dist/anthropic-output-schema.js +41 -0
  3. package/dist/api-key-caller.js +9 -2
  4. package/dist/claude-cli-caller.js +107 -8
  5. package/dist/cli.js +837 -207
  6. package/dist/codex-binary.js +96 -0
  7. package/dist/codex-cli-caller.js +90 -16
  8. package/dist/config.js +177 -2
  9. package/dist/git-range.js +88 -46
  10. package/dist/http-client.js +95 -10
  11. package/dist/investigation-reader.js +12 -6
  12. package/dist/managed-repo.js +132 -0
  13. package/dist/observed-sources.js +132 -6
  14. package/dist/reader-result-contract.js +296 -2
  15. package/dist/repository-provider.js +608 -0
  16. package/dist/schema-entities.js +18 -18
  17. package/dist/service.js +7 -2
  18. package/dist/surface-mapper.js +53 -11
  19. package/dist/text-model.js +91 -8
  20. package/dist/whole-read/archive.js +196 -0
  21. package/dist/whole-read/checks.js +297 -0
  22. package/dist/whole-read/claims.js +196 -0
  23. package/dist/whole-read/coverage-ledger.js +51 -0
  24. package/dist/whole-read/decode.js +234 -0
  25. package/dist/whole-read/drafting.js +502 -0
  26. package/dist/whole-read/generated-rules.js +68 -0
  27. package/dist/whole-read/go-proof.js +255 -0
  28. package/dist/whole-read/grouping-prompt.js +19 -0
  29. package/dist/whole-read/grouping.js +247 -0
  30. package/dist/whole-read/identity.js +103 -0
  31. package/dist/whole-read/import-graph.js +293 -0
  32. package/dist/whole-read/inventory-collector.js +55 -0
  33. package/dist/whole-read/inventory-rules.js +141 -0
  34. package/dist/whole-read/lockfiles.js +947 -0
  35. package/dist/whole-read/notes.js +216 -0
  36. package/dist/whole-read/npm-proof.js +272 -0
  37. package/dist/whole-read/owner-decision-detectors.js +270 -0
  38. package/dist/whole-read/packing.js +612 -0
  39. package/dist/whole-read/pages.js +429 -0
  40. package/dist/whole-read/phase-policy.js +54 -0
  41. package/dist/whole-read/prd-build.js +279 -0
  42. package/dist/whole-read/prd-fidelity.js +14 -0
  43. package/dist/whole-read/python-proof.js +489 -0
  44. package/dist/whole-read/read-prompt.js +67 -0
  45. package/dist/whole-read/redaction.js +411 -0
  46. package/dist/whole-read/relationship-mapping.js +98 -0
  47. package/dist/whole-read/sanitize.js +266 -0
  48. package/dist/whole-read/secret-rules.js +26 -0
  49. package/dist/whole-read/structure.js +300 -0
  50. package/dist/whole-read/types.js +2 -0
  51. package/dist/whole-read-claims.js +12 -0
  52. package/dist/whole-read-git.js +340 -0
  53. package/dist/whole-read-pool.js +80 -0
  54. package/dist/whole-read-runner.js +945 -0
  55. package/dist/whole-read-shared-source.js +194 -0
  56. package/dist/whole-read-temp.js +479 -0
  57. package/package.json +8 -2
package/dist/git-range.js CHANGED
@@ -28,8 +28,10 @@ export function gitRangeEnvironment(parent = process.env) {
28
28
  delete environment.GIT_CONFIG_NOSYSTEM;
29
29
  return environment;
30
30
  }
31
- function runGit(repoPath, args, outputLimit, input, environment) {
31
+ function runGit(repoPath, args, outputLimit, input, environment, execution) {
32
32
  return new Promise((resolve, reject) => {
33
+ if (execution?.signal.aborted)
34
+ return reject(new GitCommandFailure('resource'));
33
35
  const child = spawn('git', ['--no-replace-objects', ...args], {
34
36
  cwd: repoPath,
35
37
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -41,19 +43,35 @@ function runGit(repoPath, args, outputLimit, input, environment) {
41
43
  let stdoutBytes = 0;
42
44
  let stderrBytes = 0;
43
45
  let settled = false;
46
+ let closeProcess;
47
+ const processClosed = new Promise((done) => { closeProcess = done; });
48
+ const unregister = execution?.registerReader({
49
+ cancel: async () => {
50
+ if (child.exitCode === null && child.signalCode === null)
51
+ child.kill('SIGKILL');
52
+ await processClosed;
53
+ },
54
+ }) ?? (() => undefined);
55
+ const abort = () => {
56
+ if (child.exitCode === null && child.signalCode === null)
57
+ child.kill('SIGKILL');
58
+ };
59
+ execution?.signal.addEventListener('abort', abort, { once: true });
44
60
  const finish = (error, value) => {
45
61
  if (settled)
46
62
  return;
47
63
  settled = true;
48
64
  clearTimeout(timer);
65
+ execution?.signal.removeEventListener('abort', abort);
66
+ unregister();
49
67
  if (error)
50
68
  reject(error);
51
69
  else
52
70
  resolve(value);
53
71
  };
54
72
  const overflow = () => {
55
- child.kill('SIGKILL');
56
- finish(new GitCommandFailure('resource'));
73
+ if (child.exitCode === null && child.signalCode === null)
74
+ child.kill('SIGKILL');
57
75
  };
58
76
  child.stdout?.on('data', (chunk) => {
59
77
  stdoutBytes += chunk.length;
@@ -68,15 +86,19 @@ function runGit(repoPath, args, outputLimit, input, environment) {
68
86
  if (stdoutBytes + stderrBytes > outputLimit)
69
87
  overflow();
70
88
  });
71
- child.on('error', () => finish(new GitCommandFailure('resource')));
89
+ child.on('error', () => {
90
+ closeProcess();
91
+ finish(new GitCommandFailure('resource'));
92
+ });
72
93
  child.on('close', (code, signal) => {
94
+ closeProcess();
73
95
  if (signal || code === null)
74
96
  return finish(new GitCommandFailure('resource'));
75
97
  finish(undefined, { stdout: Buffer.concat(stdout), exitCode: code, stderr: Buffer.concat(stderr).toString('utf8') });
76
98
  });
77
99
  const timer = setTimeout(() => {
78
- child.kill('SIGKILL');
79
- finish(new GitCommandFailure('resource'));
100
+ if (child.exitCode === null && child.signalCode === null)
101
+ child.kill('SIGKILL');
80
102
  }, GIT_RANGE_COMMAND_TIMEOUT_MS);
81
103
  if (input)
82
104
  child.stdin?.end(input);
@@ -166,7 +188,7 @@ export function fitsGitRangeTransport(result) {
166
188
  return Buffer.byteLength(JSON.stringify({ result, filesRead: [] }), 'utf8') <= GIT_RANGE_MAX_HTTP_BYTES;
167
189
  }
168
190
  const failure = (code) => ({ ok: false, code });
169
- export async function runGitRangeTask(task, repoPath, connection) {
191
+ export async function runGitRangeTask(task, repoPath, connection, acquired) {
170
192
  const payload = task.payload;
171
193
  const branch = payload.branch;
172
194
  const base = payload.base;
@@ -178,40 +200,54 @@ export async function runGitRangeTask(task, repoPath, connection) {
178
200
  || (payload.schemaProbe !== undefined && !validSchemaProbe(payload.schemaProbe))) {
179
201
  return { result: failure('history_failed'), filesRead: [] };
180
202
  }
181
- const branchCheck = await runGit(repoPath, ['check-ref-format', '--branch', branch], GIT_RANGE_METADATA_OUTPUT_BYTES);
182
- const remoteList = await runGit(repoPath, ['remote'], GIT_RANGE_METADATA_OUTPUT_BYTES);
203
+ if (acquired && (acquired.base !== base || !SHA.test(acquired.head))) {
204
+ return { result: failure('history_failed'), filesRead: [] };
205
+ }
206
+ const executeGit = (args, outputLimit, input, environment = acquired?.environment) => runGit(repoPath, args, outputLimit, input, environment, acquired);
207
+ const branchCheck = await executeGit(['check-ref-format', '--branch', branch], GIT_RANGE_METADATA_OUTPUT_BYTES);
208
+ const remoteList = acquired ? null : await executeGit(['remote'], GIT_RANGE_METADATA_OUTPUT_BYTES);
183
209
  if (branchCheck.exitCode !== 0)
184
210
  return { result: failure('branch_missing'), filesRead: [] };
185
- const remotes = exactLine(Buffer.concat([remoteList.stdout, remoteList.stdout.length === 0 ? Buffer.from('\n') : Buffer.alloc(0)]));
186
- if (remoteList.exitCode !== 0 || !remotes.split('\n').includes(remote))
187
- return { result: failure('branch_missing'), filesRead: [] };
188
- destination = `refs/product-console/git-range/${task.id}/${randomUUID()}/head`;
189
- const destinationCheck = await runGit(repoPath, ['check-ref-format', destination], GIT_RANGE_METADATA_OUTPUT_BYTES);
190
- const sourceCheck = await runGit(repoPath, ['check-ref-format', `refs/heads/${branch}`], GIT_RANGE_METADATA_OUTPUT_BYTES);
191
- if (destinationCheck.exitCode !== 0 || sourceCheck.exitCode !== 0)
192
- return { result: failure('branch_missing'), filesRead: [] };
193
- const fetchEnvironment = await managedFetchEnvironment(repoPath);
194
- delete fetchEnvironment.GIT_CONFIG_NOSYSTEM;
195
- const fetched = await runGit(repoPath, [
196
- 'fetch', '--no-tags', '--refmap=', '--no-write-fetch-head', '--no-recurse-submodules', '--',
197
- remote, `+refs/heads/${branch}:${destination}`,
198
- ], GIT_RANGE_METADATA_OUTPUT_BYTES, undefined, fetchEnvironment);
199
- reportBackgroundFetch(repoPath, { ok: fetched.exitCode === 0, stderr: fetched.stderr });
200
- if (fetched.exitCode !== 0)
201
- return { result: failure('fetch_failed'), filesRead: [] };
202
- const resolved = await runGit(repoPath, ['rev-parse', '--verify', `${destination}^{commit}`], GIT_RANGE_METADATA_OUTPUT_BYTES);
203
- if (resolved.exitCode !== 0)
204
- return { result: failure('history_failed'), filesRead: [] };
205
- const head = exactLine(resolved.stdout);
206
- if (!SHA.test(head))
207
- return { result: failure('history_failed'), filesRead: [] };
208
- const shallowOutput = await runGit(repoPath, ['rev-parse', '--is-shallow-repository'], GIT_RANGE_METADATA_OUTPUT_BYTES);
209
- if (shallowOutput.exitCode !== 0)
210
- return { result: failure('history_failed'), filesRead: [] };
211
- const shallowText = exactLine(shallowOutput.stdout);
212
- if (shallowText !== 'true' && shallowText !== 'false')
213
- return { result: failure('history_failed'), filesRead: [] };
214
- const historyShallow = shallowText === 'true';
211
+ let head;
212
+ let historyShallow;
213
+ if (acquired) {
214
+ head = acquired.head;
215
+ historyShallow = acquired.historyShallow;
216
+ }
217
+ else {
218
+ const remotes = exactLine(Buffer.concat([remoteList.stdout,
219
+ remoteList.stdout.length === 0 ? Buffer.from('\n') : Buffer.alloc(0)]));
220
+ if (remoteList.exitCode !== 0 || !remotes.split('\n').includes(remote)) {
221
+ return { result: failure('branch_missing'), filesRead: [] };
222
+ }
223
+ destination = `refs/product-console/git-range/${task.id}/${randomUUID()}/head`;
224
+ const destinationCheck = await executeGit(['check-ref-format', destination], GIT_RANGE_METADATA_OUTPUT_BYTES);
225
+ const sourceCheck = await executeGit(['check-ref-format', `refs/heads/${branch}`], GIT_RANGE_METADATA_OUTPUT_BYTES);
226
+ if (destinationCheck.exitCode !== 0 || sourceCheck.exitCode !== 0)
227
+ return { result: failure('branch_missing'), filesRead: [] };
228
+ const fetchEnvironment = await managedFetchEnvironment(repoPath);
229
+ delete fetchEnvironment.GIT_CONFIG_NOSYSTEM;
230
+ const fetched = await executeGit([
231
+ 'fetch', '--no-tags', '--refmap=', '--no-write-fetch-head', '--no-recurse-submodules', '--',
232
+ remote, `+refs/heads/${branch}:${destination}`,
233
+ ], GIT_RANGE_METADATA_OUTPUT_BYTES, undefined, fetchEnvironment);
234
+ reportBackgroundFetch(repoPath, { ok: fetched.exitCode === 0, stderr: fetched.stderr });
235
+ if (fetched.exitCode !== 0)
236
+ return { result: failure('fetch_failed'), filesRead: [] };
237
+ const resolved = await executeGit(['rev-parse', '--verify', `${destination}^{commit}`], GIT_RANGE_METADATA_OUTPUT_BYTES);
238
+ if (resolved.exitCode !== 0)
239
+ return { result: failure('history_failed'), filesRead: [] };
240
+ head = exactLine(resolved.stdout);
241
+ if (!SHA.test(head))
242
+ return { result: failure('history_failed'), filesRead: [] };
243
+ const shallowOutput = await executeGit(['rev-parse', '--is-shallow-repository'], GIT_RANGE_METADATA_OUTPUT_BYTES);
244
+ if (shallowOutput.exitCode !== 0)
245
+ return { result: failure('history_failed'), filesRead: [] };
246
+ const shallowText = exactLine(shallowOutput.stdout);
247
+ if (shallowText !== 'true' && shallowText !== 'false')
248
+ return { result: failure('history_failed'), filesRead: [] };
249
+ historyShallow = shallowText === 'true';
250
+ }
215
251
  if (base === null) {
216
252
  const result = { ok: true, mode: 'head_only', head, base, commits: [], changedPaths: [], totalCommits: null, baseUnavailable: false, truncatedFiles: false, historyShallow };
217
253
  return { result: fitsGitRangeTransport(result) ? result : failure('history_failed'), filesRead: [] };
@@ -220,7 +256,12 @@ export async function runGitRangeTask(task, repoPath, connection) {
220
256
  const result = { ok: true, mode: 'range', head, base, commits: [], changedPaths: [], totalCommits: 0, baseUnavailable: false, truncatedFiles: false, historyShallow };
221
257
  return { result: fitsGitRangeTransport(result) ? result : failure('history_failed'), filesRead: [] };
222
258
  }
223
- const baseCheck = await runGit(repoPath, ['cat-file', '--batch-check'], GIT_RANGE_METADATA_OUTPUT_BYTES, Buffer.from(`${base}\n`));
259
+ if (acquired?.baseUnavailableReason) {
260
+ return { result: { ok: true, mode: 'base_unavailable', head, base, commits: [], changedPaths: [],
261
+ totalCommits: null, baseUnavailable: true,
262
+ baseUnavailableReason: acquired.baseUnavailableReason, truncatedFiles: false, historyShallow }, filesRead: [] };
263
+ }
264
+ const baseCheck = await executeGit(['cat-file', '--batch-check'], GIT_RANGE_METADATA_OUTPUT_BYTES, Buffer.from(`${base}\n`));
224
265
  if (baseCheck.exitCode !== 0)
225
266
  return { result: failure('history_failed'), filesRead: [] };
226
267
  const baseDescription = exactLine(baseCheck.stdout);
@@ -229,19 +270,19 @@ export async function runGitRangeTask(task, repoPath, connection) {
229
270
  }
230
271
  if (!new RegExp(`^${base} commit [0-9]+$`).test(baseDescription))
231
272
  return { result: failure('history_failed'), filesRead: [] };
232
- if (historyShallow) {
273
+ if (historyShallow && !acquired) {
233
274
  return { result: { ok: true, mode: 'base_unavailable', head, base, commits: [], changedPaths: [], totalCommits: null, baseUnavailable: true, baseUnavailableReason: 'shallow_history', truncatedFiles: false, historyShallow }, filesRead: [] };
234
275
  }
235
- const ancestor = await runGit(repoPath, ['merge-base', '--is-ancestor', base, head], GIT_RANGE_METADATA_OUTPUT_BYTES);
276
+ const ancestor = await executeGit(['merge-base', '--is-ancestor', base, head], GIT_RANGE_METADATA_OUTPUT_BYTES);
236
277
  if (ancestor.exitCode === 1) {
237
278
  return { result: { ok: true, mode: 'base_unavailable', head, base, commits: [], changedPaths: [], totalCommits: null, baseUnavailable: true, baseUnavailableReason: 'not_ancestor', truncatedFiles: false, historyShallow }, filesRead: [] };
238
279
  }
239
280
  if (ancestor.exitCode !== 0)
240
281
  return { result: failure('history_failed'), filesRead: [] };
241
282
  const [enumerated, counted, diff] = await Promise.all([
242
- runGit(repoPath, ['rev-list', '--topo-order', '--reverse', `${base}..${head}`, '--'], GIT_RANGE_ENUMERATION_OUTPUT_BYTES),
243
- runGit(repoPath, ['rev-list', '--count', `${base}..${head}`, '--'], GIT_RANGE_METADATA_OUTPUT_BYTES),
244
- runGit(repoPath, ['diff', '--no-color', '--name-status', '-z', '-M', base, head], GIT_RANGE_PATH_OUTPUT_BYTES),
283
+ executeGit(['rev-list', '--topo-order', '--reverse', `${base}..${head}`, '--'], GIT_RANGE_ENUMERATION_OUTPUT_BYTES),
284
+ executeGit(['rev-list', '--count', `${base}..${head}`, '--'], GIT_RANGE_METADATA_OUTPUT_BYTES),
285
+ executeGit(['diff', '--no-color', '--name-status', '-z', '-M', base, head], GIT_RANGE_PATH_OUTPUT_BYTES),
245
286
  ]);
246
287
  if (enumerated.exitCode !== 0 || counted.exitCode !== 0 || diff.exitCode !== 0)
247
288
  return { result: failure('history_failed'), filesRead: [] };
@@ -259,7 +300,7 @@ export async function runGitRangeTask(task, repoPath, connection) {
259
300
  return { result: failure('history_failed'), filesRead: [] };
260
301
  }
261
302
  const selected = oids.slice(0, GIT_RANGE_MAX_COMMITS);
262
- const objects = await runGit(repoPath, ['cat-file', '--batch'], GIT_RANGE_OBJECT_OUTPUT_BYTES, Buffer.from(`${selected.join('\n')}\n`));
303
+ const objects = await executeGit(['cat-file', '--batch'], GIT_RANGE_OBJECT_OUTPUT_BYTES, Buffer.from(`${selected.join('\n')}\n`));
263
304
  if (objects.exitCode !== 0)
264
305
  return { result: failure('history_failed'), filesRead: [] };
265
306
  const commits = parseCommitObjects(objects.stdout, selected);
@@ -272,6 +313,7 @@ export async function runGitRangeTask(task, repoPath, connection) {
272
313
  head,
273
314
  changedPaths: paths.changedPaths,
274
315
  probe: payload.schemaProbe,
316
+ environment: acquired?.environment,
275
317
  });
276
318
  const result = {
277
319
  ok: true, mode: 'range', head, base, commits, changedPaths: paths.changedPaths,
@@ -2,6 +2,7 @@
2
2
  * fetch (Node 20+, no dependency needed) — see package.json's engines field. */
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { manifestHash } from './surface-mapper.js';
5
+ import { WHOLE_READ_COMPANION_CAPABILITIES } from './whole-read-claims.js';
5
6
  const WHOAMI_TIMEOUT_MS = 15_000;
6
7
  const CLAIM_TIMEOUT_MS = 10_000;
7
8
  const HEARTBEAT_TIMEOUT_MS = 10_000;
@@ -9,6 +10,7 @@ const RESULT_TIMEOUT_MS = 60_000;
9
10
  const ERROR_REPORT_TIMEOUT_MS = 30_000;
10
11
  const PAIRING_TIMEOUT_MS = 15_000;
11
12
  const READINESS_TIMEOUT_MS = 10_000;
13
+ const BUILD_RESULT_TIMEOUT_MS = 60_000;
12
14
  const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
13
15
  if (typeof packageVersion !== 'string' || !packageVersion) {
14
16
  throw new Error('companion/package.json does not contain a version');
@@ -23,9 +25,15 @@ export const COMPANION_CAPABILITIES = [
23
25
  'schema-entities@1',
24
26
  'verify-automatic-surface@1',
25
27
  'map-module-expansion@1',
28
+ ...WHOLE_READ_COMPANION_CAPABILITIES,
26
29
  ];
27
- const capabilityHeaders = () => ({
28
- 'X-Companion-Capabilities': COMPANION_CAPABILITIES.join(','),
30
+ export const CLAUDE_CLI_FAST_MODEL = 'claude-haiku-5-5';
31
+ export const CODEX_FAST_MODEL = 'gpt-5.6-luna';
32
+ export const WHOLE_READ_FAST_MODEL_CAPABILITY = 'whole-read-fast-model@';
33
+ const capabilityHeaders = (fastModel) => ({
34
+ 'X-Companion-Capabilities': [...COMPANION_CAPABILITIES,
35
+ ...(fastModel && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(fastModel)
36
+ ? [`${WHOLE_READ_FAST_MODEL_CAPABILITY}${fastModel}`] : [])].join(','),
29
37
  });
30
38
  export class CompanionHttpError extends Error {
31
39
  status;
@@ -52,9 +60,14 @@ export class PairingHttpError extends Error {
52
60
  export class CompanionClient {
53
61
  url;
54
62
  token;
55
- constructor(url, token) {
63
+ fastModelsByConnection;
64
+ constructor(url, token, fastModelsByConnection = {}) {
56
65
  this.url = url;
57
66
  this.token = token;
67
+ this.fastModelsByConnection = fastModelsByConnection;
68
+ }
69
+ capabilityHeaders(connectionId) {
70
+ return capabilityHeaders(connectionId ? this.fastModelsByConnection[connectionId] : undefined);
58
71
  }
59
72
  request(path, timeoutMs, init) {
60
73
  return fetch(`${this.url}${path}`, {
@@ -82,6 +95,68 @@ export class CompanionClient {
82
95
  whoami() {
83
96
  return this.call('/api/companion/whoami', WHOAMI_TIMEOUT_MS);
84
97
  }
98
+ listBuildAssignments() {
99
+ return this.call('/api/companion/builds/assignments', CLAIM_TIMEOUT_MS, {
100
+ headers: this.capabilityHeaders(),
101
+ });
102
+ }
103
+ acceptBuildAssignment(assignment, report) {
104
+ return this.call(`/api/companion/builds/assignments/${encodeURIComponent(assignment.id)}/accept`, BUILD_RESULT_TIMEOUT_MS, {
105
+ method: 'POST',
106
+ headers: this.capabilityHeaders(assignment.connectionId),
107
+ body: JSON.stringify({
108
+ assignmentRevision: assignment.assignmentRevision,
109
+ canonicalRepository: assignment.canonicalRepository,
110
+ snapshotSha: assignment.snapshotSha,
111
+ ...report,
112
+ }),
113
+ });
114
+ }
115
+ claimBuildUnit(assignment, input) {
116
+ return this.call(`/api/companion/builds/${encodeURIComponent(assignment.buildId)}/claim`, CLAIM_TIMEOUT_MS, {
117
+ method: 'POST',
118
+ headers: this.capabilityHeaders(assignment.connectionId),
119
+ body: JSON.stringify({ assignmentId: assignment.id,
120
+ assignmentRevision: assignment.assignmentRevision, ...input }),
121
+ });
122
+ }
123
+ buildAttemptPath(claim) {
124
+ return `/api/companion/builds/${encodeURIComponent(claim.buildId)}`
125
+ + `/assignments/${encodeURIComponent(claim.assignmentId)}`
126
+ + `/units/${encodeURIComponent(claim.unitId)}/attempts/${encodeURIComponent(claim.attemptId)}`;
127
+ }
128
+ putBuildResultPage(claim, page) {
129
+ return this.call(`${this.buildAttemptPath(claim)}/pages`, BUILD_RESULT_TIMEOUT_MS, {
130
+ method: 'POST', headers: capabilityHeaders(), body: JSON.stringify({
131
+ claimToken: claim.claimToken, controlRevision: claim.controlRevision,
132
+ assignmentRevision: claim.assignmentRevision, page,
133
+ }),
134
+ });
135
+ }
136
+ sealBuildResult(claim, value) {
137
+ return this.call(`${this.buildAttemptPath(claim)}/seal`, BUILD_RESULT_TIMEOUT_MS, {
138
+ method: 'POST', headers: capabilityHeaders(), body: JSON.stringify({
139
+ claimToken: claim.claimToken, controlRevision: claim.controlRevision,
140
+ assignmentRevision: claim.assignmentRevision, ...value,
141
+ }),
142
+ });
143
+ }
144
+ acceptBuildResult(claim, value) {
145
+ return this.call(`${this.buildAttemptPath(claim)}/result`, BUILD_RESULT_TIMEOUT_MS, {
146
+ method: 'POST', headers: capabilityHeaders(), body: JSON.stringify({
147
+ claimToken: claim.claimToken, controlRevision: claim.controlRevision,
148
+ assignmentRevision: claim.assignmentRevision, ...value,
149
+ }),
150
+ });
151
+ }
152
+ reportBuildError(claim, failure, usage) {
153
+ return this.call(`${this.buildAttemptPath(claim)}/error`, ERROR_REPORT_TIMEOUT_MS, {
154
+ method: 'POST', headers: capabilityHeaders(), body: JSON.stringify({
155
+ claimToken: claim.claimToken, controlRevision: claim.controlRevision,
156
+ assignmentRevision: claim.assignmentRevision, failure, ...(usage === undefined ? {} : { usage }),
157
+ }),
158
+ });
159
+ }
85
160
  async githubToken(repo) {
86
161
  try {
87
162
  const response = await this.call('/api/companion/github-token', CLAIM_TIMEOUT_MS, { method: 'POST', body: JSON.stringify({ repo }) });
@@ -101,7 +176,7 @@ export class CompanionClient {
101
176
  claimNext(connectionId) {
102
177
  return this.call(connectionId
103
178
  ? `/api/companion/tasks/next?connectionId=${encodeURIComponent(connectionId)}`
104
- : '/api/companion/tasks/next', CLAIM_TIMEOUT_MS, { headers: capabilityHeaders() });
179
+ : '/api/companion/tasks/next', CLAIM_TIMEOUT_MS, { headers: this.capabilityHeaders(connectionId) });
105
180
  }
106
181
  async claimNextInvestigation() {
107
182
  const response = await this.request('/api/companion/investigations/next', CLAIM_TIMEOUT_MS, {
@@ -182,14 +257,24 @@ export class CompanionClient {
182
257
  }),
183
258
  });
184
259
  }
185
- heartbeat(taskId) {
186
- return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/heartbeat`, HEARTBEAT_TIMEOUT_MS, { method: 'POST' });
260
+ heartbeatSurfaceMapping(job) {
261
+ return this.call(`/api/companion/surface-mappings/${encodeURIComponent(job.jobId)}/heartbeat`, HEARTBEAT_TIMEOUT_MS, {
262
+ method: 'POST',
263
+ headers: {
264
+ 'X-Companion-Version': COMPANION_VERSION,
265
+ ...capabilityHeaders(),
266
+ },
267
+ body: JSON.stringify({ attempt: job.attempt, claimToken: job.claimToken }),
268
+ });
269
+ }
270
+ heartbeat(taskId, claimToken) {
271
+ return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/heartbeat`, HEARTBEAT_TIMEOUT_MS, { method: 'POST', ...(claimToken ? { body: JSON.stringify({ claimToken }) } : {}) });
187
272
  }
188
- reportResult(taskId, result, filesRead, snapshot) {
273
+ reportResult(taskId, result, filesRead, snapshot, claimToken) {
189
274
  return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/result`, RESULT_TIMEOUT_MS, {
190
275
  method: 'POST',
191
276
  headers: capabilityHeaders(),
192
- body: JSON.stringify({ result, filesRead, ...(snapshot ? { snapshot } : {}) }),
277
+ body: JSON.stringify({ result, filesRead, ...(snapshot ? { snapshot } : {}), ...(claimToken ? { claimToken } : {}) }),
193
278
  });
194
279
  }
195
280
  reportGitRangeResult(taskId, result) {
@@ -199,8 +284,8 @@ export class CompanionClient {
199
284
  }
200
285
  return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/result`, RESULT_TIMEOUT_MS, { method: 'POST', headers: capabilityHeaders(), body });
201
286
  }
202
- reportError(taskId, error) {
203
- return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/error`, ERROR_REPORT_TIMEOUT_MS, { method: 'POST', headers: capabilityHeaders(), body: JSON.stringify({ error }) });
287
+ reportError(taskId, error, claimToken) {
288
+ return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/error`, ERROR_REPORT_TIMEOUT_MS, { method: 'POST', headers: capabilityHeaders(), body: JSON.stringify({ error, ...(claimToken ? { claimToken } : {}) }) });
204
289
  }
205
290
  reportInvestigationResult(investigationId, result) {
206
291
  return this.call(`/api/companion/investigations/${encodeURIComponent(investigationId)}/result`, RESULT_TIMEOUT_MS, { method: 'POST', headers: capabilityHeaders(), body: JSON.stringify(result) });
@@ -4,7 +4,7 @@ import { readFile, lstat, stat } from 'node:fs/promises';
4
4
  import { resolve, sep } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { listRepoFiles } from './local-scan.js';
7
- import { obtainRequestedSources } from './observed-sources.js';
7
+ import { obtainRequestedSources, } from './observed-sources.js';
8
8
  import { scopeCovers, validateReaderResult } from './reader-result-contract.js';
9
9
  import { gitErrorMessage } from './sandbox.js';
10
10
  import { isUsageLimitError } from './usage-limit.js';
@@ -1152,7 +1152,7 @@ async function enumerateTrees(request, obtained) {
1152
1152
  }
1153
1153
  export async function runInvestigation(request, connections, callModel, reportDiagnostic = (diagnostic) => {
1154
1154
  console.error(formatInvestigationReaderDiagnostic(diagnostic));
1155
- }, execution) {
1155
+ }, execution, sourceAcquisition) {
1156
1156
  if (request.policyVersion === 'policy-4' && !execution) {
1157
1157
  throw new MissingPolicy4ExecutionError();
1158
1158
  }
@@ -1160,7 +1160,7 @@ export async function runInvestigation(request, connections, callModel, reportDi
1160
1160
  let observedSources = [];
1161
1161
  let evidence = [];
1162
1162
  try {
1163
- obtained = await obtainRequestedSources(request, connections);
1163
+ obtained = await obtainRequestedSources(request, connections, sourceAcquisition);
1164
1164
  observedSources = obtained.observedSources;
1165
1165
  if (obtained.unavailable) {
1166
1166
  return honestResult(request, observedSources, evidence, 'source_identity_unavailable', execution);
@@ -1173,17 +1173,20 @@ export async function runInvestigation(request, connections, callModel, reportDi
1173
1173
  if (!sandbox)
1174
1174
  return honestResult(request, observedSources, evidence, 'files_unreadable', execution);
1175
1175
  const reads = await readSurfaceEvidence(request, sandbox);
1176
+ obtained.assertAvailable();
1176
1177
  evidence = reads.map((read) => read.evidence);
1177
1178
  const retrievalReason = surfaceRetrievalReason(evidence);
1178
1179
  if (retrievalReason)
1179
1180
  return honestResult(request, observedSources, evidence, retrievalReason, execution);
1181
+ const assessments = await assessCompleteSurface(request, observedSources, reads, callModel, reportDiagnostic, execution);
1182
+ obtained.assertAvailable();
1180
1183
  const result = {
1181
1184
  investigationId: request.investigationId,
1182
1185
  attempt: request.attempt,
1183
1186
  resultKey: resultKeyFor(request),
1184
1187
  observedSources,
1185
1188
  evidence,
1186
- assessments: ensureAutomaticClaimShapes(request, await assessCompleteSurface(request, observedSources, reads, callModel, reportDiagnostic, execution)),
1189
+ assessments: ensureAutomaticClaimShapes(request, assessments),
1187
1190
  execution: { ...execution },
1188
1191
  };
1189
1192
  try {
@@ -1214,6 +1217,7 @@ export async function runInvestigation(request, connections, callModel, reportDi
1214
1217
  schema: selectionSchema,
1215
1218
  });
1216
1219
  selected = filterSelectedPaths(selection, trees);
1220
+ obtained.assertAvailable();
1217
1221
  }
1218
1222
  catch (error) {
1219
1223
  const treeFileCount = trees.reduce((count, tree) => count + tree.paths.length, 0);
@@ -1223,6 +1227,7 @@ export async function runInvestigation(request, connections, callModel, reportDi
1223
1227
  return honestResult(request, observedSources, evidence, 'no_plausible_path');
1224
1228
  }
1225
1229
  const reads = await readSelectedEvidence(request, selected, obtained.sandboxes);
1230
+ obtained.assertAvailable();
1226
1231
  const budgeted = buildBudgetedAssessmentPrompt(request, reads);
1227
1232
  evidence = budgeted.reads.map((read) => read.evidence);
1228
1233
  if (budgeted.prompt === null) {
@@ -1257,6 +1262,7 @@ export async function runInvestigation(request, connections, callModel, reportDi
1257
1262
  prompt: assessmentPrompt,
1258
1263
  schema: assessmentResponseSchema,
1259
1264
  });
1265
+ obtained.assertAvailable();
1260
1266
  }
1261
1267
  catch (error) {
1262
1268
  return honestResult(request, observedSources, evidence, reasonForModelFailure(error, 'assessment', assessmentPrompt, budgeted.reads.length, reportDiagnostic));
@@ -1293,11 +1299,11 @@ export async function runInvestigation(request, connections, callModel, reportDi
1293
1299
  await obtained?.cleanup();
1294
1300
  }
1295
1301
  }
1296
- export async function runAndReportInvestigation(request, connections, callModel, report, reportDiagnostic, execution) {
1302
+ export async function runAndReportInvestigation(request, connections, callModel, report, reportDiagnostic, execution, sourceAcquisition) {
1297
1303
  if (request.policyVersion === 'policy-4' && !execution) {
1298
1304
  throw new MissingPolicy4ExecutionError();
1299
1305
  }
1300
- const result = await runInvestigation(request, connections, callModel, reportDiagnostic, execution);
1306
+ const result = await runInvestigation(request, connections, callModel, reportDiagnostic, execution, sourceAcquisition);
1301
1307
  await report(result);
1302
1308
  return result;
1303
1309
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { spawn } from 'node:child_process';
10
10
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { lstat, realpath, rm } from 'node:fs/promises';
11
12
  import { basename, isAbsolute, join, relative, resolve } from 'node:path';
12
13
  import { parseRemoteUrl } from './local-scan.js';
13
14
  export class ManagedRepoError extends Error {
@@ -84,6 +85,20 @@ export function labelFromCopyPath(repoPath) {
84
85
  const match = /^github\.com__(.+?)__(.+)$/.exec(basename(repoPath));
85
86
  return match ? `${match[1]}/${match[2]}` : basename(repoPath);
86
87
  }
88
+ /** Reads a managed repository's durable identity, with the old label/path convention retained
89
+ * only as a compatibility fallback for configs that have not entered the B38 migration yet. */
90
+ export function configuredManagedRepository(connection) {
91
+ if (connection.repository) {
92
+ if (connection.repository.host !== 'github.com' || connection.repository.port !== null) {
93
+ throw new ManagedRepoError(NOT_GITHUB_MESSAGE);
94
+ }
95
+ return parseGitHubRepo(connection.repository.path);
96
+ }
97
+ return parseGitHubRepo(connection.label ?? labelFromCopyPath(connection.repoPath));
98
+ }
99
+ export function usesTemporaryManagedStorage(connection) {
100
+ return connection.managed === true && connection.repositoryStorage === 'temporary';
101
+ }
87
102
  const MAX_CAPTURED_STDERR = 16_384;
88
103
  /** `stdio: 'inherit'` lets Git (and Git Credential Manager) talk to the person directly; stderr
89
104
  * is still teed to the terminal and captured so a failure can be explained in one line. */
@@ -201,6 +216,10 @@ export function watchManagedCopy(repoPath, label, log, getToken) {
201
216
  export function forgetManagedCopies() {
202
217
  watchedCopies.clear();
203
218
  }
219
+ /** Stops background reporting for one copy before its retirement starts. */
220
+ export function forgetManagedCopy(repoPath) {
221
+ watchedCopies.delete(watchKey(repoPath));
222
+ }
204
223
  async function tokenFrom(getToken) {
205
224
  if (!getToken)
206
225
  return null;
@@ -389,6 +408,119 @@ export async function removeManagedCopy(repoPath, reposDir) {
389
408
  rmSync(managedStatePath(target), { force: true });
390
409
  return true;
391
410
  }
411
+ export class ManagedCopyRetirementError extends Error {
412
+ code;
413
+ constructor(code) {
414
+ super(code);
415
+ this.code = code;
416
+ this.name = 'ManagedCopyRetirementError';
417
+ }
418
+ }
419
+ async function pathMissing(path) {
420
+ try {
421
+ await lstat(path);
422
+ return false;
423
+ }
424
+ catch (error) {
425
+ if (error.code === 'ENOENT')
426
+ return true;
427
+ throw error;
428
+ }
429
+ }
430
+ function hasManagedCopyMarker(repoPath) {
431
+ try {
432
+ const marker = JSON.parse(readFileSync(managedStatePath(repoPath), 'utf8'));
433
+ return marker !== null
434
+ && typeof marker === 'object'
435
+ && !Array.isArray(marker)
436
+ && typeof marker.signInNeeded === 'boolean'
437
+ && typeof marker.lastUpdatedAt === 'string'
438
+ && Number.isFinite(Date.parse(marker.lastUpdatedAt));
439
+ }
440
+ catch {
441
+ return false;
442
+ }
443
+ }
444
+ function normalizedPath(path) {
445
+ const absolute = resolve(path);
446
+ return process.platform === 'win32' ? absolute.toLowerCase() : absolute;
447
+ }
448
+ async function validateManagedCopyTargetPath(repoPath, reposDir, repo) {
449
+ const root = resolve(reposDir);
450
+ const target = resolve(repoPath);
451
+ if (normalizedPath(target) !== normalizedPath(managedCopyPath(root, repo))) {
452
+ throw new ManagedCopyRetirementError('cleanup_path_outside_root');
453
+ }
454
+ if (await pathMissing(root))
455
+ return 'missing';
456
+ const rootStats = await lstat(root);
457
+ if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) {
458
+ throw new ManagedCopyRetirementError('cleanup_unsafe_link');
459
+ }
460
+ if (await pathMissing(target))
461
+ return 'missing';
462
+ const targetStats = await lstat(target);
463
+ if (!targetStats.isDirectory() || targetStats.isSymbolicLink()) {
464
+ throw new ManagedCopyRetirementError('cleanup_unsafe_link');
465
+ }
466
+ const [canonicalRoot, canonicalTarget] = await Promise.all([realpath(root), realpath(target)]);
467
+ if (normalizedPath(join(canonicalRoot, basename(target))) !== normalizedPath(canonicalTarget)) {
468
+ throw new ManagedCopyRetirementError('cleanup_path_outside_root');
469
+ }
470
+ return 'present';
471
+ }
472
+ /** Validates the old clone from independent companion records before deletion. The target must be
473
+ * the one deterministic child for this identity, have the state marker written on clone, and
474
+ * still be the top of the expected Git repository. */
475
+ async function validateManagedCopyRetirement(repoPath, reposDir, repo) {
476
+ const target = resolve(repoPath);
477
+ const presence = await validateManagedCopyTargetPath(repoPath, reposDir, repo);
478
+ if (presence === 'missing')
479
+ return presence;
480
+ if (!hasManagedCopyMarker(target)) {
481
+ throw new ManagedCopyRetirementError('cleanup_marker_missing');
482
+ }
483
+ if (!(await isCopyOf(target, repo))) {
484
+ throw new ManagedCopyRetirementError('cleanup_identity_mismatch');
485
+ }
486
+ return 'present';
487
+ }
488
+ /** Retires one validated historical managed clone. `onValidated` durably records cleanup_pending
489
+ * before the first delete. Validation is repeated afterwards to close the write/delete race. */
490
+ export async function retireManagedCopy(options, dependencies = {}) {
491
+ const wait = dependencies.wait
492
+ ?? ((delayMs) => new Promise((resolveWait) => setTimeout(resolveWait, delayMs)));
493
+ const retryDelaysMs = dependencies.retryDelaysMs ?? [25, 50, 100, 200];
494
+ const removeTree = dependencies.removeTree ?? ((path) => rm(path, { recursive: true, force: false }));
495
+ const initial = await validateManagedCopyRetirement(options.repoPath, options.reposDir, options.repo);
496
+ if (initial === 'missing') {
497
+ rmSync(managedStatePath(options.repoPath), { force: true });
498
+ return { cleanupAttempts: 0 };
499
+ }
500
+ await options.onValidated();
501
+ await validateManagedCopyRetirement(options.repoPath, options.reposDir, options.repo);
502
+ forgetManagedCopy(options.repoPath);
503
+ await runGit(options.repoPath, ['worktree', 'prune']);
504
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
505
+ try {
506
+ const presence = await validateManagedCopyTargetPath(options.repoPath, options.reposDir, options.repo);
507
+ if (presence === 'present')
508
+ await removeTree(options.repoPath);
509
+ if (!(await pathMissing(options.repoPath)))
510
+ throw new Error('cleanup_delete_failed');
511
+ rmSync(managedStatePath(options.repoPath), { force: true });
512
+ return { cleanupAttempts: attempt };
513
+ }
514
+ catch (error) {
515
+ if (error instanceof ManagedCopyRetirementError)
516
+ throw error;
517
+ if (attempt < 5) {
518
+ await wait(retryDelaysMs[Math.min(attempt - 1, retryDelaysMs.length - 1)] ?? 0);
519
+ }
520
+ }
521
+ }
522
+ throw new ManagedCopyRetirementError('cleanup_delete_failed');
523
+ }
392
524
  export function relativeTime(iso, now = new Date()) {
393
525
  if (!iso)
394
526
  return 'not yet';