@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/README.md CHANGED
@@ -175,8 +175,8 @@ your Claude subscription, not a separate API key.
175
175
  ## No code on your computer?
176
176
 
177
177
  If you can open the repo on GitHub but don't keep the code on this computer, the companion can
178
- keep its own copy for you. You need Git installed (https://git-scm.com/downloads) and, for
179
- `--backend cli`, Claude Code. Then run one command from any folder:
178
+ access it temporarily when work needs it. You need Git installed (https://git-scm.com/downloads)
179
+ and, for `--backend cli`, Claude Code. Then run one command from any folder:
180
180
 
181
181
  ```bash
182
182
  truing connect \
@@ -190,24 +190,26 @@ truing connect \
190
190
  `--branch <name>` to follow a branch other than the repo's main one.
191
191
 
192
192
  - **GitHub access.** If the repo was connected with **Connect GitHub** in the Console, the
193
- companion uses that connection. It asks for a fresh, short-lived access pass just before each
194
- download and never saves it on your computer. Otherwise, a browser window asks you to sign in
195
- to GitHub on this computer and the companion uses that sign-in.
196
- - **You never touch the copy.** It lives in a hidden folder
197
- (`~/.product-console-companion/repos/`). The companion brings it up to date before every check.
198
- You don't need to open it, edit it or pull it.
199
- - **If GitHub stops accepting the sign-in**, `status` and the companion's log say "GitHub sign-in
200
- needed". Fix it with:
193
+ companion asks for a fresh access pass for each operation and never saves it on your computer.
194
+ The connection is validated before it is saved.
195
+ - **No permanent code copy.** When the Console enables temporary repository access, each task gets
196
+ an isolated copy that is removed when the task ends. `status` shows the repository identity,
197
+ not a folder. A Console that has not enabled the rollout continues using the earlier managed-copy
198
+ behavior until it is upgraded.
199
+ - **If GitHub access stops working**, reconnect GitHub in the Console, then check it with:
201
200
 
202
201
  ```bash
203
202
  truing signin
204
203
  ```
205
204
 
206
- This asks GitHub for the latest code with the sign-in window allowed, so you can sign in again.
207
- The running companion picks it up on its own; no restart needed. Add `--repo owner/name` to
208
- do just one repo.
209
- - **Disconnecting deletes the copy.** `disconnect` removes the companion's copy of every repo it
210
- connected this way. Folders you made yourself are never deleted.
205
+ For a temporary connection this performs a bounded access check and stores no source. Add
206
+ `--repo owner/name` to check just one repo. Legacy managed copies retain their existing sign-in
207
+ flow while the rollout is off.
208
+ - **Disconnecting removes the pairing.** Temporary connections have no code folder to remove.
209
+ When the rollout is enabled, an older companion-managed copy is removed after its repository
210
+ identity and ownership records are verified. Developer folders, foreign folders, linked paths,
211
+ and copies still referenced by a legacy pairing are never removed. Locked copies stay marked
212
+ `cleanup_pending` and are retried when the companion restarts.
211
213
 
212
214
  ## ChatGPT subscription through Codex CLI
213
215
 
@@ -215,7 +217,7 @@ truing connect \
215
217
  the companion's own Codex home. This is separate from your normal Codex login:
216
218
 
217
219
  ```powershell
218
- New-Item -ItemType Directory -Force "$HOME\.product-console-companion\codex-home" | Out-Null; $env:CODEX_HOME="$HOME\.product-console-companion\codex-home"; codex login; Remove-Item Env:CODEX_HOME
220
+ New-Item -ItemType Directory -Force "$HOME\.product-console-companion\codex-home" | Out-Null; $env:CODEX_HOME="$HOME\.product-console-companion\codex-home"; codex.cmd login; Remove-Item Env:CODEX_HOME
219
221
  ```
220
222
 
221
223
  ```bash
@@ -321,13 +323,10 @@ The package's `publishConfig` makes this a public scoped-package publish. Then,
321
323
  GitHub Actions** for this repo (`fynchconsulting/product-console`) and the exact workflow filename
322
324
  `publish-companion.yml`, with no environment. No npm token is generated, pasted, or stored.
323
325
 
324
- After that, releases use `.github/workflows/publish-companion.yml` as before: bump the version in
325
- `package.json` in a normal PR, merge it, then either
326
-
327
- ```bash
328
- git tag companion-v<the new version> && git push --tags
329
- ```
330
-
331
- or trigger the workflow manually (Actions tab, or `gh workflow run publish-companion.yml`). Either
332
- way it publishes whatever's currently in `package.json` at that point — there's no separate
333
- "which version" input to get wrong.
326
+ After that, releases are automatic (TRUING.md §9): any PR that changes `companion/` bumps the
327
+ version in `companion/package.json` (`npm version patch --no-git-tag-version` in `companion/`, or
328
+ `minor`), and CI fails the PR if it doesn't. Merging it to main runs
329
+ `.github/workflows/publish-companion.yml`, which publishes that version to npm and tags the commit
330
+ `companion-v<version>`. If the version is already on npm the publish is skipped. The workflow can
331
+ also be run by hand (Actions tab, or `gh workflow run publish-companion.yml`) to recover from a
332
+ failed run; it publishes whatever is in `package.json` at that point.
@@ -0,0 +1,41 @@
1
+ const REJECTED_ANTHROPIC_KEYWORDS = new Set([
2
+ 'minimum',
3
+ 'maximum',
4
+ 'exclusiveMinimum',
5
+ 'exclusiveMaximum',
6
+ 'multipleOf',
7
+ 'minLength',
8
+ 'maxLength',
9
+ 'minItems',
10
+ 'maxItems',
11
+ 'pattern',
12
+ 'format',
13
+ ]);
14
+ const SCHEMA_MAP_KEYWORDS = new Set(['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']);
15
+ function converted(value, preserveKeys = false) {
16
+ if (Array.isArray(value))
17
+ return value.map((item) => converted(item));
18
+ if (value === null || typeof value !== 'object')
19
+ return value;
20
+ const convertedNode = Object.fromEntries(Object.entries(value)
21
+ .filter(([key]) => preserveKeys || !REJECTED_ANTHROPIC_KEYWORDS.has(key))
22
+ .map(([key, child]) => [key, converted(child, SCHEMA_MAP_KEYWORDS.has(key))]));
23
+ const type = value.type;
24
+ const enumValues = value.enum;
25
+ if (Array.isArray(type) && type.length === 2 && Array.isArray(enumValues) && enumValues.includes(null)) {
26
+ const nonNullType = type.find((item) => item !== 'null');
27
+ if (typeof nonNullType === 'string' && type.includes('null')) {
28
+ const annotations = Object.fromEntries(Object.entries(convertedNode)
29
+ .filter(([key]) => key !== 'type' && key !== 'enum'));
30
+ const nonNullEnum = enumValues.filter((item) => item !== null).map((item) => converted(item));
31
+ return nonNullEnum.length === 0
32
+ ? { ...annotations, type: 'null' }
33
+ : { ...annotations, anyOf: [{ type: nonNullType, enum: nonNullEnum }, { type: 'null' }] };
34
+ }
35
+ }
36
+ return convertedNode;
37
+ }
38
+ /** Keep this provider-boundary copy in sync with server/anthropic-output-schema.ts. */
39
+ export function toAnthropicOutputSchema(schema) {
40
+ return converted(schema);
41
+ }
@@ -11,18 +11,25 @@
11
11
  * two in sync by hand if either changes.
12
12
  */
13
13
  import Anthropic from '@anthropic-ai/sdk';
14
+ import { toAnthropicOutputSchema } from './anthropic-output-schema.js';
14
15
  import { readFile } from 'node:fs/promises';
15
16
  import { join } from 'node:path';
16
17
  import { buildLocalDesignContext } from './design-context.js';
17
18
  import { buildLocalRepoContext, listRepoFiles } from './local-scan.js';
18
19
  import { appendDiffToVolatile, prepareShipmentAnalysis } from './shipment.js';
19
- import { replyText, streamWithFallback } from './text-model.js';
20
+ import { anthropicTextModel, replyText, streamWithFallback, } from './text-model.js';
20
21
  const MAX_CANDIDATE_FILES = 8;
21
22
  /** A skip threshold, not a truncation — a file over this is left OUT of what's read entirely
22
23
  * (with a note in the log), rather than read and cut off partway through. The whole point of
23
24
  * this backend is that what IS read is never truncated; this only guards against an
24
25
  * accidentally-selected generated/binary/data file blowing up the prompt. */
25
26
  const MAX_FILE_BYTES = 512 * 1024;
27
+ /** Dedicated whole-read entry point; the optional safe-mode contract cannot be omitted here. */
28
+ export async function runWholeReadModelCall(client, request) {
29
+ if (!request.wholeRead)
30
+ throw new TypeError('whole_read_context_required');
31
+ return anthropicTextModel(client)(request);
32
+ }
26
33
  /** Structured model-only call used by the investigation reader. The reader, not this call and
27
34
  * not the model, owns every repository read and evidence id. */
28
35
  export async function runStructuredModelCall(client, request) {
@@ -32,7 +39,7 @@ export async function runStructuredModelCall(client, request) {
32
39
  thinking: { type: 'adaptive' },
33
40
  output_config: {
34
41
  effort: request.effort,
35
- format: { type: 'json_schema', schema: request.schema },
42
+ format: { type: 'json_schema', schema: toAnthropicOutputSchema(request.schema) },
36
43
  },
37
44
  system: request.system,
38
45
  messages: [{ role: 'user', content: request.prompt }],
@@ -20,7 +20,8 @@ import { assertSingleLineArguments } from './cli-arguments.js';
20
20
  import { spawnClaude } from './claude-binary.js';
21
21
  import { gitErrorMessage } from './sandbox.js';
22
22
  import { appendDiffToVolatile, prepareShipmentAnalysis } from './shipment.js';
23
- import { ModelRefusalError } from './text-model.js';
23
+ import { ModelRefusalError, WholeReadTransportError, } from './text-model.js';
24
+ import { isUsageLimitError, parseUsageLimitReset } from './usage-limit.js';
24
25
  /** Independent of --max-budget-usd — a dollar cap doesn't prevent a network-stall hang. No
25
26
  * automatic retry on top of this (see cli.ts): auto-retry would risk double-spending the
26
27
  * customer's own subscription/API quota. */
@@ -69,6 +70,51 @@ function promptWriteFailure(error) {
69
70
  wrapped.syscall = error.syscall;
70
71
  return wrapped;
71
72
  }
73
+ function numericUsage(value) {
74
+ if (!value || typeof value !== 'object' || Array.isArray(value))
75
+ return null;
76
+ const result = {};
77
+ for (const [key, candidate] of Object.entries(value)) {
78
+ if (typeof candidate === 'number' && Number.isFinite(candidate))
79
+ result[key] = candidate;
80
+ }
81
+ return Object.keys(result).length ? result : null;
82
+ }
83
+ function claudeMetadata(envelope, fallbackStopReason) {
84
+ const reportedModels = envelope.modelUsage ? Object.keys(envelope.modelUsage) : [];
85
+ const model = typeof envelope.model === 'string'
86
+ ? envelope.model
87
+ : reportedModels.length === 1 ? reportedModels[0] : null;
88
+ const usage = numericUsage(envelope.usage)
89
+ ?? (model && envelope.modelUsage ? numericUsage(envelope.modelUsage[model]) : null);
90
+ return { usage, metadata: {
91
+ finishReason: envelope.stop_reason ?? fallbackStopReason,
92
+ model,
93
+ usageAvailable: usage !== null,
94
+ } };
95
+ }
96
+ function safeClaudeFailure(envelope) {
97
+ const raw = `${envelope.subtype ?? ''} ${envelope.terminal_reason ?? ''} ${envelope.errors?.join(' ') ?? ''} ${envelope.result}`;
98
+ if (isUsageLimitError(raw) || envelope.terminal_reason === 'budget_exhausted'
99
+ || envelope.subtype === 'error_max_budget_usd') {
100
+ const reset = parseUsageLimitReset(raw);
101
+ return new WholeReadTransportError('usage_limit', 'claude_cli', reset?.toISOString());
102
+ }
103
+ if (/billing|credit|payment required/i.test(raw)) {
104
+ return new WholeReadTransportError('billing_exhausted', 'claude_cli');
105
+ }
106
+ if (/rate_limit_error|http 429|too many requests|overloaded/i.test(raw)) {
107
+ return new WholeReadTransportError('provider_rate_limit', 'claude_cli');
108
+ }
109
+ return new WholeReadTransportError('capability_failure', 'claude_cli');
110
+ }
111
+ async function terminateAndWait(child) {
112
+ if (child.exitCode !== null || child.signalCode !== null)
113
+ return;
114
+ const closed = new Promise((resolve) => child.once('close', () => resolve()));
115
+ child.kill();
116
+ await closed;
117
+ }
72
118
  /** The model sometimes emits an absolute path matching the sandbox root, sometimes a
73
119
  * POSIX-flavoured path as if --add-dir were mounted at "/" — normalize both down to a
74
120
  * repo-relative path, matching api-key-caller.ts's own filesRead convention. */
@@ -81,7 +127,7 @@ function normalizeToRepoRelative(filePath, root) {
81
127
  * sandbox as `repoPath`; connection-less calls grant no tools and need no sandbox.
82
128
  * Returns whatever structured_output the model produced plus every file it actually read via
83
129
  * the Read tool. Every exported per-kind function below is a thin wrapper over this. */
84
- async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
130
+ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false, wholeRead) {
85
131
  if (spec.stableDocumentBase64) {
86
132
  throw new Error("This task includes a PDF PRD, which the cli backend can't read yet. Pair with " +
87
133
  '--backend api-key instead, or re-upload the PRD as .md/.txt/.docx.');
@@ -89,7 +135,7 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
89
135
  const sandboxPath = connectionless ? tmpdir() : repoPath;
90
136
  let systemPromptDir = null;
91
137
  try {
92
- systemPromptDir = await mkdtemp(join(tmpdir(), 'product-console-claude-system-'));
138
+ systemPromptDir = await mkdtemp(join(wholeRead?.attemptDirectory ?? tmpdir(), 'product-console-claude-system-'));
93
139
  const systemPromptPath = join(systemPromptDir, 'prompt.txt');
94
140
  await writeFile(systemPromptPath, spec.system, { encoding: 'utf8', mode: 0o600 });
95
141
  const emptyMcpConfigPath = await getEmptyMcpConfigPath();
@@ -140,7 +186,7 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
140
186
  // default here, so both are always real streams in practice; the ! just satisfies the type.
141
187
  const stdoutDecoder = new StringDecoder('utf8');
142
188
  const recordStdout = (text) => {
143
- if (!text)
189
+ if (!text || wholeRead)
144
190
  return;
145
191
  if (stdoutFirst.length < STDOUT_SAMPLE_CHARS) {
146
192
  stdoutFirst += text.slice(0, STDOUT_SAMPLE_CHARS - stdoutFirst.length);
@@ -157,7 +203,8 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
157
203
  child.kill();
158
204
  });
159
205
  child.stderr.on('data', (chunk) => {
160
- stderrTail = (stderrTail + chunk.toString()).slice(-2000);
206
+ if (!wholeRead)
207
+ stderrTail = (stderrTail + chunk.toString()).slice(-2000);
161
208
  });
162
209
  const rl = createInterface({ input: child.stdout });
163
210
  const timer = setTimeout(() => {
@@ -172,7 +219,7 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
172
219
  }
173
220
  catch {
174
221
  unparseableLines++;
175
- if (firstUnparseableLineLength === null) {
222
+ if (!wholeRead && firstUnparseableLineLength === null) {
176
223
  firstUnparseableLineLength = line.length;
177
224
  firstUnparseableLineFirst = line.slice(0, UNPARSEABLE_LINE_SAMPLE_CHARS);
178
225
  firstUnparseableLineLast = line.slice(-UNPARSEABLE_LINE_SAMPLE_CHARS);
@@ -204,6 +251,12 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
204
251
  child.on('error', reject); // e.g. ENOENT — claude isn't installed / not on PATH
205
252
  child.on('close', (code) => resolvePromise(code ?? 1));
206
253
  });
254
+ const unregisterReader = wholeRead?.registerReader({ cancel: () => terminateAndWait(child) });
255
+ const abort = () => { void terminateAndWait(child); };
256
+ if (wholeRead?.signal.aborted)
257
+ abort();
258
+ else
259
+ wholeRead?.signal.addEventListener('abort', abort, { once: true });
207
260
  try {
208
261
  if (spec.stableText)
209
262
  child.stdin.write(spec.stableText + '\n\n');
@@ -214,13 +267,24 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
214
267
  stdinError ??= error;
215
268
  child.kill();
216
269
  }
217
- const exitCode = await exitPromise;
218
- clearTimeout(timer);
270
+ let exitCode;
271
+ try {
272
+ exitCode = await exitPromise;
273
+ }
274
+ finally {
275
+ clearTimeout(timer);
276
+ wholeRead?.signal.removeEventListener('abort', abort);
277
+ unregisterReader?.();
278
+ }
279
+ if (wholeRead?.signal.aborted)
280
+ throw new WholeReadTransportError('cancelled', 'claude_cli');
219
281
  if (!resultEnvelope && timedOut) {
220
282
  throw new Error(`claude CLI took longer than ${WALL_CLOCK_TIMEOUT_MS / 60_000} minutes and was killed.`);
221
283
  }
222
284
  const capturedStdinError = stdinError;
223
285
  if (!resultEnvelope) {
286
+ if (wholeRead)
287
+ throw new WholeReadTransportError('capability_failure', 'claude_cli');
224
288
  if (capturedStdinError) {
225
289
  throw promptWriteFailure(capturedStdinError);
226
290
  }
@@ -244,6 +308,25 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
244
308
  `types: ${types}; raw stdout: ${stdoutSample}${unparseableSample}`);
245
309
  }
246
310
  const envelope = resultEnvelope;
311
+ if (wholeRead) {
312
+ if (envelope.is_error || envelope.terminal_reason === 'budget_exhausted'
313
+ || envelope.subtype === 'error_max_budget_usd')
314
+ throw safeClaudeFailure(envelope);
315
+ const metadata = claudeMetadata(envelope, lastStopReason);
316
+ if (metadata.metadata.finishReason === 'max_tokens') {
317
+ throw new WholeReadTransportError('output_truncated', 'claude_cli');
318
+ }
319
+ if (metadata.metadata.finishReason === 'refusal') {
320
+ throw new WholeReadTransportError('model_declined', 'claude_cli');
321
+ }
322
+ if (capturedStdinError)
323
+ throw new WholeReadTransportError('capability_failure', 'claude_cli');
324
+ const text = envelope.structured_output === undefined
325
+ ? envelope.result
326
+ : JSON.stringify(envelope.structured_output);
327
+ return { result: envelope.structured_output ?? envelope.result, filesRead: [],
328
+ reply: { text, usage: metadata.usage, metadata: metadata.metadata } };
329
+ }
247
330
  if (envelope.terminal_reason === 'budget_exhausted' || envelope.subtype === 'error_max_budget_usd') {
248
331
  throw new Error(`Exceeded the $${maxBudgetUsd.toFixed(2)} budget for this task (spent $${envelope.total_cost_usd.toFixed(4)} before stopping).`);
249
332
  }
@@ -271,6 +354,22 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
271
354
  await rm(systemPromptDir, { recursive: true, force: true });
272
355
  }
273
356
  }
357
+ /** Whole-read model-only entry point: no tools, no raw child diagnostics, attempt-owned files. */
358
+ export async function runWholeReadModelCallViaCli(request) {
359
+ if (!request.wholeRead)
360
+ throw new TypeError('whole_read_context_required');
361
+ const outcome = await runViaCli({
362
+ kind: 'whole-read',
363
+ model: request.model,
364
+ effort: request.effort,
365
+ maxTokens: request.maxTokens,
366
+ system: request.system,
367
+ stableText: request.stableText,
368
+ volatileText: request.text,
369
+ schema: request.schema,
370
+ }, INVESTIGATION_CALL_BUDGET_USD, request.wholeRead.attemptDirectory, true, request.wholeRead);
371
+ return outcome.reply;
372
+ }
274
373
  /** The CLI is only a structured-output model transport for investigations. It gets no tools and
275
374
  * no repository directory; all evidence reads happen in investigation-reader.ts first. */
276
375
  export async function runStructuredModelCallViaCli(request) {