orizu 0.5.14 → 0.5.16

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 (51) hide show
  1. package/dist/artifact-archive.js +20 -4
  2. package/dist/egress-allowlist-cli.js +72 -10
  3. package/dist/github-setup.js +60 -6
  4. package/dist/help.js +26 -7
  5. package/dist/hosted-runtime-assets.js +8 -1
  6. package/dist/hosted-session-cli.js +197 -22
  7. package/dist/index.js +108 -68
  8. package/dist/kill-switch-cli.js +10 -0
  9. package/dist/merge-job.js +26 -3
  10. package/dist/optimizations-list-cli.js +85 -0
  11. package/dist/option-validation.js +54 -0
  12. package/dist/preview-runtime.js +93 -0
  13. package/dist/prompt-draft-push.js +3 -2
  14. package/dist/registration-tag-warning.js +49 -0
  15. package/dist/run-event-digest.js +319 -0
  16. package/dist/runner-dir-verify.js +848 -0
  17. package/dist/scorer-draft-push.js +3 -2
  18. package/dist/scores-accept-cli.js +87 -0
  19. package/dist/workbench-cli.js +35 -7
  20. package/dist/zip-draft-push.js +3 -2
  21. package/package.json +1 -1
  22. package/src/artifact-archive.ts +29 -4
  23. package/src/egress-allowlist-cli.ts +87 -13
  24. package/src/github-setup.ts +66 -7
  25. package/src/help.ts +26 -7
  26. package/src/hosted-runtime-assets.ts +14 -1
  27. package/src/hosted-session-cli.ts +215 -22
  28. package/src/index.ts +114 -80
  29. package/src/kill-switch-cli.ts +10 -0
  30. package/src/merge-job.ts +25 -6
  31. package/src/optimizations-list-cli.ts +117 -0
  32. package/src/option-validation.ts +63 -0
  33. package/src/preview-runtime.ts +94 -0
  34. package/src/prompt-draft-push.ts +5 -2
  35. package/src/registration-tag-warning.ts +58 -0
  36. package/src/run-event-digest.ts +333 -0
  37. package/src/runner-dir-verify.ts +1029 -0
  38. package/src/scorer-draft-push.ts +5 -2
  39. package/src/scores-accept-cli.ts +124 -0
  40. package/src/workbench-cli.ts +38 -7
  41. package/src/zip-draft-push.ts +5 -2
  42. package/vendor/orizu-gepa-python/src/orizu_gepa/cli.py +80 -3
  43. package/vendor/orizu-gepa-python/src/orizu_gepa/optimizer.py +288 -15
  44. package/vendor/orizu-gepa-python/src/orizu_gepa/runner.py +133 -8
  45. package/vendor/skills/orizu-cli/SKILL.md +4 -2
  46. package/vendor/skills/orizu-cli/references/building-apps.md +131 -1
  47. package/vendor/skills/orizu-cli/references/building-judges.md +18 -0
  48. package/vendor/skills/orizu-cli/references/cli-reference.md +2 -1
  49. package/vendor/skills/orizu-cli/references/optimization-with-gepa.md +3 -1
  50. package/vendor/skills/orizu-cli/references/prompt-control-plane.md +75 -0
  51. package/vendor/skills/orizu-cli/scripts/test-app.mjs +3 -0
@@ -11,7 +11,9 @@ const ARTIFACT_EXCLUDED_PATH_NAMES = new Set([
11
11
  function isNodeError(error) {
12
12
  return error instanceof Error;
13
13
  }
14
- function expandHomePath(path) {
14
+ /** Exported for runner-dir-verify (ALI-1159): the pre-exec scan must resolve
15
+ * the same directory this module hashes and the runner later executes. */
16
+ export function expandHomePath(path) {
15
17
  if (path.startsWith('~/')) {
16
18
  const home = process.env.HOME || '';
17
19
  return `${home}/${path.slice(2)}`;
@@ -123,7 +125,13 @@ export function createStoredZip(entries) {
123
125
  endOfCentralDirectory.writeUInt16LE(0, 20);
124
126
  return Buffer.concat([...localParts, centralDirectory, endOfCentralDirectory]);
125
127
  }
126
- export function zipDirectoryToBase64(dirArg) {
128
+ /**
129
+ * Read the exact entry set the deterministic archive covers (sorted relative
130
+ * paths + bytes). Exported for runner-dir-verify (ALI-1159): the verified
131
+ * snapshot must be materialized from the SAME in-memory bytes that were
132
+ * hashed, so a post-hash mutation of the directory cannot change what runs.
133
+ */
134
+ export function readArtifactEntries(dirArg) {
127
135
  const sourceDir = expandHomePath(dirArg);
128
136
  try {
129
137
  const stats = statSync(sourceDir);
@@ -141,12 +149,20 @@ export function zipDirectoryToBase64(dirArg) {
141
149
  if (files.length === 0) {
142
150
  throw new Error(`Directory contains no artifact files: ${sourceDir}`);
143
151
  }
144
- const bytes = createStoredZip(files.map(relativePath => ({
152
+ return files.map(relativePath => ({
145
153
  path: relativePath,
146
154
  data: readFileSync(join(sourceDir, relativePath)),
147
- })));
155
+ mode: statSync(join(sourceDir, relativePath)).mode,
156
+ }));
157
+ }
158
+ /** Deterministic archive of an already-read entry set. */
159
+ export function archiveArtifactEntries(entries) {
160
+ const bytes = createStoredZip(entries);
148
161
  return {
149
162
  zipBase64: bytes.toString('base64'),
150
163
  contentSha256: createHash('sha256').update(bytes).digest('hex'),
151
164
  };
152
165
  }
166
+ export function zipDirectoryToBase64(dirArg) {
167
+ return archiveArtifactEntries(readArtifactEntries(dirArg));
168
+ }
@@ -13,6 +13,12 @@
13
13
  * with an injected fetcher.
14
14
  */
15
15
  import { authedFetch } from './http.js';
16
+ /** Mirrors MAX_TEAM_EGRESS_DOMAINS in the shared read-side validator
17
+ * (workers/session-coordinator/src/egress-domains.ts). The coordinator only
18
+ * applies the first this-many UNIQUE valid domains at policy compose; the
19
+ * CLI is a standalone package and cannot import the worker module, so the
20
+ * cap constant is duplicated here (kept in sync via that file's tests). */
21
+ const MAX_ADDITIVE_EGRESS_DOMAINS = 100;
16
22
  function fetcherFrom(io) {
17
23
  return io.fetcher ?? authedFetch;
18
24
  }
@@ -55,22 +61,72 @@ function splitDomains(value) {
55
61
  .map(d => d.trim())
56
62
  .filter(d => d.length > 0);
57
63
  }
64
+ function stringArray(value) {
65
+ return Array.isArray(value) ? value.filter((d) => typeof d === 'string') : [];
66
+ }
67
+ /**
68
+ * Re-derive the "beyond the 100-domain cap" entries from a stored set the
69
+ * SAME way the GET route does (positional over UNIQUE VALID domains): the
70
+ * coordinator applies only the first MAX_ADDITIVE_EGRESS_DOMAINS valid
71
+ * domains, so every valid entry past that index is configured-but-inert.
72
+ * Stored entries are already canonical + deduped by the write RPC, and the
73
+ * invalid (grandfathered) ones — flagged by the server — are not "valid"
74
+ * and so never consume a cap slot. Used for the PUT response, where a
75
+ * shrink-by-one on an oversized legacy row leaves the returned set still
76
+ * over-cap and the server does not send cappedDomains back.
77
+ */
78
+ function deriveCapped(stored, invalid) {
79
+ const invalidSet = new Set(invalid);
80
+ const capped = [];
81
+ let validCount = 0;
82
+ for (const d of stored) {
83
+ if (invalidSet.has(d))
84
+ continue;
85
+ validCount += 1;
86
+ if (validCount > MAX_ADDITIVE_EGRESS_DOMAINS)
87
+ capped.push(d);
88
+ }
89
+ return capped;
90
+ }
58
91
  async function fetchCurrent(fetcher, path) {
59
92
  const response = await fetcher(path, { method: 'GET' });
60
93
  if (!response.ok) {
61
94
  throw new Error(`show failed (${response.status}): ${await responseMessage(response)}`);
62
95
  }
63
96
  const data = (await response.json());
64
- return Array.isArray(data.extraDomains) ? data.extraDomains.filter((d) => typeof d === 'string') : [];
97
+ return {
98
+ domains: stringArray(data.extraDomains),
99
+ invalid: stringArray(data.invalidDomains),
100
+ capped: stringArray(data.cappedDomains),
101
+ };
65
102
  }
66
- function emit(io, teamSlug, domains) {
103
+ function emit(io, teamSlug, current) {
67
104
  if (io.json) {
68
- io.print(JSON.stringify({ teamSlug, extraDomains: domains }));
105
+ io.print(JSON.stringify({
106
+ teamSlug,
107
+ extraDomains: current.domains,
108
+ invalidDomains: current.invalid,
109
+ cappedDomains: current.capped,
110
+ }));
69
111
  return;
70
112
  }
71
- io.print(domains.length === 0
113
+ const invalid = new Set(current.invalid);
114
+ const capped = new Set(current.capped);
115
+ const lines = current.domains.map((d) => {
116
+ // Round 4 (P3): re-adding a non-grandfathered invalid entry is
117
+ // guaranteed to fail the write RPC's tightened validator (grandfathering
118
+ // is retention-only — see set_team_egress_allowlist), so "remove and
119
+ // re-add" was actively misleading. The only viable remediation is
120
+ // removal.
121
+ if (invalid.has(d))
122
+ return `${d} (invalid — will not take effect; remove it (this value is no longer valid and cannot be re-added))`;
123
+ if (capped.has(d))
124
+ return `${d} (beyond the 100-domain cap — will not take effect)`;
125
+ return d;
126
+ });
127
+ io.print(lines.length === 0
72
128
  ? `${teamSlug}: no additive egress domains (base allowlist only)`
73
- : `${teamSlug} additive egress domains:\n ${domains.join('\n ')}`);
129
+ : `${teamSlug} additive egress domains:\n ${lines.join('\n ')}`);
74
130
  }
75
131
  export async function egressAllowlistCommand(args, io) {
76
132
  const teamSlug = firstPositional(args);
@@ -100,7 +156,7 @@ export async function egressAllowlistCommand(args, io) {
100
156
  }
101
157
  else {
102
158
  const current = await fetchCurrent(fetcher, path);
103
- const set = new Set(current);
159
+ const set = new Set(current.domains);
104
160
  if (addValue)
105
161
  for (const d of splitDomains(addValue))
106
162
  set.add(d);
@@ -119,10 +175,16 @@ export async function egressAllowlistCommand(args, io) {
119
175
  return 1;
120
176
  }
121
177
  const data = (await response.json());
122
- const stored = Array.isArray(data.extraDomains)
123
- ? data.extraDomains.filter((d) => typeof d === 'string')
124
- : next;
125
- emit(io, teamSlug, stored);
178
+ const stored = Array.isArray(data.extraDomains) ? stringArray(data.extraDomains) : next;
179
+ const invalid = stringArray(data.invalidDomains);
180
+ // Grandfathered retention: a successful PUT may still STORE legacy
181
+ // invalid entries the admin retained — the server flags them and we show
182
+ // it. Round 5 (P2): a >100-entry legacy row can only shrink one entry per
183
+ // PUT (the write RPC's GREATEST(100, stored-1) bound), so the returned
184
+ // set can still be OVER the cap. Re-derive the cap annotations from the
185
+ // returned set — same positional logic GET uses — instead of assuming
186
+ // the RPC left it at/under 100.
187
+ emit(io, teamSlug, { domains: stored, invalid, capped: deriveCapped(stored, invalid) });
126
188
  return 0;
127
189
  }
128
190
  catch (error) {
@@ -17,7 +17,7 @@ import { spawnSync } from 'child_process';
17
17
  import { existsSync, mkdirSync } from 'fs';
18
18
  import { join } from 'path';
19
19
  import { authedFetch } from './http.js';
20
- import { assertWorkspaceDirUsable } from './workspace.js';
20
+ import { assertWorkspaceDirUsable, workspaceExists } from './workspace.js';
21
21
  /**
22
22
  * Strip terminal control characters from server-supplied strings before they
23
23
  * are printed (mirrors index.ts `sanitizeTerminalText`), so a hostile or
@@ -28,7 +28,36 @@ function sanitizeServerText(value) {
28
28
  return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
29
29
  }
30
30
  const DEFAULT_POLL_INTERVAL_MS = 2000;
31
- const DEFAULT_POLL_TIMEOUT_MS = 180000;
31
+ // ALI-1141: long enough for a slow org-picker + repo-selection walk, but the
32
+ // wait MUST be finite — when the App is already installed in the target org,
33
+ // GitHub only offers "Configure" and never redirects to our setup callback, so
34
+ // an unbounded poll would spin forever. (Still under the server's 15-minute
35
+ // nonce TTL, so the poll times out before the server would report `expired`.)
36
+ const DEFAULT_POLL_TIMEOUT_MS = 600000;
37
+ // ALI-1141: after this long with no callback, the likeliest cause is the
38
+ // already-installed org above — surface it instead of heartbeating silently.
39
+ const ALREADY_INSTALLED_HINT_MS = 90000;
40
+ /**
41
+ * Why the poll can never succeed when the App is already installed in the org
42
+ * (ALI-1141): GitHub's `installations/new?state=` page then only offers
43
+ * "Configure", and completing configuration does NOT redirect to the App's
44
+ * Setup URL with our state nonce — the callback that flips the row out of
45
+ * `pending` never fires. Printed as a mid-poll hint and folded into the
46
+ * timeout error so the user is never left with an unexplained hang.
47
+ */
48
+ function alreadyInstalledHintLines(teamSlug) {
49
+ return [
50
+ ' ⚠ GitHub has not called back yet. The most likely cause: the Orizu GitHub',
51
+ ' App is ALREADY INSTALLED in that org — GitHub then only offers',
52
+ ' "Configure", and finishing configuration never notifies Orizu, so this',
53
+ ' wait cannot succeed. Remedies:',
54
+ ' • Install the App into a DIFFERENT GitHub org for this team (each org',
55
+ ' installation can be linked to exactly one Orizu team).',
56
+ ' • If the org is not linked to any Orizu team, uninstall the Orizu App',
57
+ ' from the org (GitHub → org Settings → GitHub Apps) and re-run',
58
+ ` \`orizu github link --team ${teamSlug}\` to do a fresh install.`,
59
+ ];
60
+ }
32
61
  function defaultSleep(ms) {
33
62
  return new Promise(resolve => setTimeout(resolve, ms));
34
63
  }
@@ -83,10 +112,12 @@ export async function runGithubLink(teamSlug, io) {
83
112
  const sleep = io.sleep ?? defaultSleep;
84
113
  const interval = io.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
85
114
  const timeout = io.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
86
- const deadline = now() + timeout;
115
+ const started = now();
116
+ const deadline = started + timeout;
87
117
  // Periodic heartbeat so a long install does not look hung; never echoes state.
88
118
  const HEARTBEAT_MS = 15000;
89
- let lastHeartbeat = now();
119
+ let lastHeartbeat = started;
120
+ let printedAlreadyInstalledHint = false;
90
121
  for (;;) {
91
122
  const poll = await fetcher(`/api/cli/github/link?state=${encodeURIComponent(state)}`);
92
123
  if (poll.ok) {
@@ -107,9 +138,20 @@ export async function runGithubLink(teamSlug, io) {
107
138
  // status 'pending' → keep waiting.
108
139
  }
109
140
  if (now() >= deadline) {
110
- throw new Error('Timed out waiting for the GitHub App install to complete. ' +
141
+ // Same actionable message as the mid-poll hint: an already-installed org
142
+ // is the one known way to reach this timeout with nothing else wrong.
143
+ throw new Error(`Timed out waiting for the GitHub App install to complete after ${Math.round(timeout / 60000)} minutes. ` +
144
+ 'If the Orizu GitHub App is already installed in that org, GitHub never notifies Orizu when you finish the "Configure" flow — ' +
145
+ 'install the App into a DIFFERENT GitHub org for this team (or uninstall it from the org first if no other Orizu team uses it). ' +
111
146
  `Resume with \`orizu github link --team ${teamSlug}\` after finishing the install.`);
112
147
  }
148
+ if (!printedAlreadyInstalledHint && now() - started >= ALREADY_INSTALLED_HINT_MS) {
149
+ for (const line of alreadyInstalledHintLines(teamSlug))
150
+ io.print(line);
151
+ io.print(` Still waiting (Ctrl+C to abort; resume later with \`orizu github link --team ${teamSlug}\`)…`);
152
+ printedAlreadyInstalledHint = true;
153
+ lastHeartbeat = now();
154
+ }
113
155
  if (now() - lastHeartbeat >= HEARTBEAT_MS) {
114
156
  io.print(' …still waiting for the GitHub install to complete.');
115
157
  lastHeartbeat = now();
@@ -216,7 +258,7 @@ export async function runHostedAttach(options, io) {
216
258
  io.print(` Cloning ${repoFullName}...`);
217
259
  // Clone with the helper inline + the workspace id in the env so the token is
218
260
  // brokered on demand and never materializes in the URL or on disk.
219
- gitOrThrow(io.git, [
261
+ const clone = io.git([
220
262
  '-c',
221
263
  `credential.helper=${CREDENTIAL_HELPER}`,
222
264
  '-c',
@@ -225,6 +267,18 @@ export async function runHostedAttach(options, io) {
225
267
  cleanUrl,
226
268
  options.targetDir,
227
269
  ], { env: { ORIZU_WORKSPACE_ID: options.workspaceId } });
270
+ if (clone.status !== 0) {
271
+ const detail = clone.stderr || `exit ${clone.status}`;
272
+ // A local-only workspace (`orizu setup --local`) has orizu.team.json but no
273
+ // `.git`, so it passes `assertWorkspaceDirUsable` yet fails the clone —
274
+ // `git clone` refuses the non-empty dir up front and writes nothing, so
275
+ // every byte in there is the user's workspace, not an incomplete clone.
276
+ // Never advise deleting it.
277
+ if (workspaceExists(options.targetDir)) {
278
+ throw new Error(`git clone failed: ${detail} ${options.targetDir} already holds a local Orizu workspace (orizu.team.json) without a hosted clone, so the workbench repo can't be cloned into it. Don't delete it — move it aside or re-run \`orizu setup\` with \`--workspace <empty-path>\` (or keep it local with \`--local\`).`);
279
+ }
280
+ throw new Error(`git clone failed: ${detail} Remove or empty the incomplete clone directory ${options.targetDir}, then re-run \`orizu setup\`.`);
281
+ }
228
282
  // Persist the helper repo-locally for future pull/push (cwd-resolved).
229
283
  gitOrThrow(io.git, ['config', 'credential.helper', CREDENTIAL_HELPER], { cwd: options.targetDir });
230
284
  gitOrThrow(io.git, ['config', 'credential.useHttpPath', 'true'], { cwd: options.targetDir });
package/dist/help.js CHANGED
@@ -394,6 +394,12 @@ export const COMMAND_DOCS = [
394
394
  summary: 'Submit score results for a scorer against a prompt version or optimization candidate.',
395
395
  group: 'Scorers and runners',
396
396
  },
397
+ {
398
+ path: ['scores', 'accept'],
399
+ usage: 'orizu scores accept <score-run-id> [--project <team/project>] [--json]',
400
+ summary: 'Record a human curator acceptance of an agent-reported score as trusted evidence (human-only).',
401
+ group: 'Scorers and runners',
402
+ },
397
403
  {
398
404
  path: ['runners', 'push'],
399
405
  usage: 'orizu runners push <dir> [--project <team/project>] [--name <name>] [--label <label>] [--json]',
@@ -415,7 +421,7 @@ export const COMMAND_DOCS = [
415
421
  {
416
422
  path: ['runners', 'exec'],
417
423
  usage: 'orizu runners exec (--prompt <prompt-version-id> | --prompt-version <id> --runner-version <id> | --scorer-version <id>) --dataset-version <id> --split-set <id-or-name> --split <name> [--runner-dir <dir>] --out <results.jsonl|results.jsonl.gz>',
418
- summary: 'Execute a runner locally against a dataset split and write row results.',
424
+ summary: 'Execute a runner locally against a dataset split and write row results. --runner-dir bytes must match the registered runner version (ADR-007) — register with `orizu runners push` first.',
419
425
  group: 'Scorers and runners',
420
426
  },
421
427
  {
@@ -450,8 +456,8 @@ export const COMMAND_DOCS = [
450
456
  },
451
457
  {
452
458
  path: ['optimizations', 'run-gepa'],
453
- usage: 'orizu optimizations run-gepa --project <team/project> --optimizer-version-id <id> --candidate-version-id <id> --runner-version-id <id> --candidate-runner-dir <dir> --scorer-version-id <id> --scorer-runner-version-id <id> --scorer-runner-dir <dir> --dataset-version-id <id> --split-set-id <id> [--train-split train] [--val-split validation] [--budget auto|light|medium|heavy | --max-metric-calls <n> | --max-full-evals <n> | --max-iterations <n>] [--num-threads auto|N] [--reflection-retry-attempts N] [--reflection-http-timeout-seconds N] [--log-dir logs]',
454
- summary: 'Run the bundled GEPA-style optimizer locally and stream events.',
459
+ usage: 'orizu optimizations run-gepa --project <team/project> --optimizer-version-id <id> --candidate-version-id <id> --runner-version-id <id> --candidate-runner-dir <dir> --scorer-version-id <id> --scorer-runner-version-id <id> --scorer-runner-dir <dir> [--scorer-input-contract gepa|flat_row] [--scorer-candidate-field <row-field>] [--allow-degenerate-seed] --dataset-version-id <id> --split-set-id <id> [--train-split train] [--val-split validation] [--budget auto|light|medium|heavy | --max-metric-calls <n> | --max-full-evals <n> | --max-iterations <n>] [--num-threads auto|N] [--reflection-retry-attempts N] [--reflection-http-timeout-seconds N] [--log-dir logs]',
460
+ summary: 'Run the bundled GEPA-style optimizer locally and stream events. Validates the scorer contract on the seed before iterating; judge runners built for flat-row score runs need --scorer-input-contract flat_row (ALI-1158).',
455
461
  group: 'Optimizations',
456
462
  },
457
463
  {
@@ -747,15 +753,28 @@ export const COMMAND_DOCS = [
747
753
  },
748
754
  {
749
755
  path: ['session', 'start'],
750
- usage: 'orizu session start [--project <team/project>] [--workspace <dir>] [--json]',
751
- summary: 'Start a durable workspace session and print the session id. When run inside the workbench clone (or with --workspace), the remote session branch is fetched and checked out locally, and the exact next git commands are printed.',
756
+ usage: 'orizu session start [--project <team/project>] [--workspace <dir>] [--json] | orizu session start --hosted (--task <prompt> | --task-file <path>) [--duration <min>] [--project <team/project>] [--tail [--quiet]]',
757
+ summary: 'Start a durable workspace session, or add --hosted with a task prompt to start a coordinator-managed hosted agent. Plain sessions check out their remote session branch locally when run inside the workbench clone (or with --workspace).',
752
758
  group: 'Sessions',
753
759
  options: [
754
760
  { name: '--project <team/project>', help: 'Optionally scope the session to a project in the attached workspace.' },
755
761
  { name: '--workspace <dir>', help: 'Workbench clone directory when not running from inside it.' },
762
+ { name: '--hosted', help: 'Start a coordinator-managed hosted agent session instead of a plain durable session.' },
763
+ { name: '--task <prompt>', help: 'Hosted agent task prompt. Mutually exclusive with --task-file.' },
764
+ { name: '--task-file <path>', help: 'Read the hosted agent task prompt from a UTF-8 file. Mutually exclusive with --task.' },
765
+ { name: '--duration <min>', help: 'Hosted sandbox duration in minutes (default 60, maximum 1440).' },
766
+ { name: '--model <provider/model>', help: 'Optional hosted model override.' },
767
+ { name: '--reasoning-effort <level>', help: 'Optional hosted model reasoning-effort override.' },
768
+ { name: '--title <title>', help: 'Optional hosted run title.' },
769
+ { name: '--tail', help: 'Follow the hosted run until it reaches a terminal state, streaming compact per-event digests (agent text, tool calls with args, results).' },
770
+ { name: '--quiet', help: 'With --tail: stream event types only (the pre-digest output).' },
756
771
  { name: '--json', help: 'Emit the machine-readable session payload.' },
757
772
  ],
758
- examples: ['orizu session start', 'orizu session start --project highlight/hip --json'],
773
+ examples: [
774
+ 'orizu session start',
775
+ 'orizu session start --project highlight/hip --json',
776
+ 'orizu session start --hosted --task-file ./task.md --project highlight/hip --duration 90',
777
+ ],
759
778
  },
760
779
  {
761
780
  path: ['session', 'status'],
@@ -823,7 +842,7 @@ export const COMMAND_DOCS = [
823
842
  {
824
843
  path: ['run', 'tail'],
825
844
  usage: 'orizu run tail --run <id> [--after <seq>] [--interval <seconds>] [--once] [--json]',
826
- summary: 'Cursor-poll a workbench run event log; JSON mode emits one event object per line.',
845
+ summary: 'Cursor-poll a workbench run event log as compact per-event digest lines; JSON mode emits one full event object per line.',
827
846
  group: 'Workbench runs',
828
847
  options: [
829
848
  { name: '--run <id>', help: 'Workbench run id.', required: true },
@@ -101,7 +101,14 @@ export function parsePrebakedMarker(raw) {
101
101
  typeof claudeSdkVersion !== 'string') {
102
102
  return null;
103
103
  }
104
- return { cliVersion, opencodeVersion, claudeSdkVersion, builtFor: 'vercel-sandbox' };
104
+ const marker = { cliVersion, opencodeVersion, claudeSdkVersion, builtFor: 'vercel-sandbox' };
105
+ // Optional Braintrust pins (ALI-1048): carried through when present as strings;
106
+ // absent/odd-typed values are simply omitted (never invalidate the marker).
107
+ if (typeof rec.braintrustPyVersion === 'string')
108
+ marker.braintrustPyVersion = rec.braintrustPyVersion;
109
+ if (typeof rec.braintrustNpmVersion === 'string')
110
+ marker.braintrustNpmVersion = rec.braintrustNpmVersion;
111
+ return marker;
105
112
  }
106
113
  export function serializeBootContext(ctx) {
107
114
  return `${JSON.stringify(ctx, null, 2)}\n`;