orizu 0.5.6 → 0.5.8

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.
@@ -36,10 +36,33 @@ import { spawnSync, spawn as nodeSpawn } from 'child_process';
36
36
  import { closeSync, existsSync, openSync, readFileSync } from 'fs';
37
37
  import { tmpdir } from 'os';
38
38
  import { join } from 'path';
39
- import { PREBAKED_MARKER_PATH, SETUP_HOOK_RELATIVE_PATH, parsePrebakedMarker, } from './hosted-runtime-assets.js';
39
+ import { HOSTED_TASK_PREAMBLE, PREBAKED_MARKER_PATH, SETUP_HOOK_RELATIVE_PATH, composeHostedTaskPrompt, parsePrebakedMarker, } from './hosted-runtime-assets.js';
40
+ import { harvestWorkspace } from './hosted-harvest.js';
41
+ import { annotateHeadlessQuestions, withIdleWatchdog } from './hosted-headless.js';
40
42
  import { OPENCODE_PINNED_VERSION, createOpenCodeHarness, spawnOpenCode, } from './hosted-harness-opencode.js';
41
43
  import { createClaudeAgentHarness } from './hosted-harness-claude.js';
42
44
  import { drainHarnessToSink, resumeRunEventSink, } from './hosted-run-event-sink.js';
45
+ /**
46
+ * Environment injected into the spawned OpenCode server so the AGENT's bash sees a
47
+ * PRE-AUTHENTICATED `orizu` CLI (ALI-1044). ORIZU_TOKEN_FILE points at the 0600
48
+ * rotated bearer file and ORIZU_BASE_URL at the Orizu API — the exact vars the
49
+ * CLI's auth resolution reads (resolveEnvBearerToken + resolveBaseUrl). The dummy
50
+ * ANTHROPIC key (model-key brokering, G3) is preserved. No real secret is carried
51
+ * here: the bearer stays on disk and is read fresh per request from the file.
52
+ */
53
+ export function buildOpenCodeSpawnEnv(context) {
54
+ const env = {};
55
+ if (context.anthropicDummyKey) {
56
+ env.ANTHROPIC_API_KEY = context.anthropicDummyKey;
57
+ }
58
+ if (context.bearerFile) {
59
+ env.ORIZU_TOKEN_FILE = context.bearerFile;
60
+ }
61
+ if (context.apiBaseUrl) {
62
+ env.ORIZU_BASE_URL = context.apiBaseUrl;
63
+ }
64
+ return Object.keys(env).length > 0 ? env : undefined;
65
+ }
43
66
  /** Default egress-canary probe host (a known non-allowlisted destination). */
44
67
  export const DEFAULT_EGRESS_CANARY_HOST = 'example.com';
45
68
  /** Bound (ms) on the canary probe — a live firewall resets/times out fast; a
@@ -181,6 +204,25 @@ function defaultRunSetupHook({ workspaceDir }) {
181
204
  const ok = (res.status ?? 1) === 0;
182
205
  return { ran: true, ok, detail: ok ? 'setup.sh ok' : `setup.sh exit ${res.status ?? 'unknown'}` };
183
206
  }
207
+ /** Default idle-watchdog window (ALI-1037): abort a prompt that makes no progress
208
+ * for this long. The timer resets on every harness EVENT, and a legitimate long
209
+ * single tool call (a big `npm install`, a build, an optimization step) emits no
210
+ * intermediate events — so this must comfortably exceed the longest expected
211
+ * quiet tool call (review finding #2). Overridable per-run via `idleTimeoutMs` or
212
+ * the `ORIZU_AGENT_IDLE_TIMEOUT_MS` env var. */
213
+ export const DEFAULT_AGENT_IDLE_TIMEOUT_MS = 25 * 60 * 1000;
214
+ /** Resolve the idle window: explicit option wins, then env, then the default. */
215
+ function resolveIdleTimeoutMs(override) {
216
+ if (typeof override === 'number' && Number.isFinite(override))
217
+ return override;
218
+ const raw = process.env.ORIZU_AGENT_IDLE_TIMEOUT_MS;
219
+ if (raw) {
220
+ const parsed = Number.parseInt(raw, 10);
221
+ if (Number.isFinite(parsed))
222
+ return parsed;
223
+ }
224
+ return DEFAULT_AGENT_IDLE_TIMEOUT_MS;
225
+ }
184
226
  /** Model-key env vars whose value must be scrubbed from every run event. */
185
227
  const MODEL_KEY_ENV_VARS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'];
186
228
  function redactionListFromEnv() {
@@ -365,6 +407,49 @@ export async function runHostedLoop(opts) {
365
407
  }
366
408
  }
367
409
  }
410
+ // End-of-run auto-harvest (ALI-1036): commit + push anything the agent left
411
+ // uncommitted/unpushed so a run NEVER loses work — on BOTH the success and
412
+ // failure paths. Runs at most once (guarded), records exactly one of
413
+ // work_persisted / work_none / work_persist_failed, and NEVER throws or changes
414
+ // the run's terminal status: a harvest failure is recorded and the run proceeds.
415
+ let harvested = false;
416
+ const runAutoHarvest = async () => {
417
+ if (harvested)
418
+ return;
419
+ harvested = true;
420
+ let outcome;
421
+ try {
422
+ outcome = harvestWorkspace({
423
+ workspaceDir: context.workspaceDir,
424
+ runId: context.runId,
425
+ author: context.author,
426
+ exec: opts.harvestExec,
427
+ // Real harvest only inside a genuine hosted sandbox (prebaked marker
428
+ // present) — never on a host/test working tree (review finding #1).
429
+ enabled: existsSync(PREBAKED_MARKER_PATH),
430
+ });
431
+ }
432
+ catch (error) {
433
+ outcome = { kind: 'work_persist_failed', error: error instanceof Error ? error.message : String(error) };
434
+ }
435
+ if (sink.sealed)
436
+ return;
437
+ try {
438
+ if (outcome.kind === 'work_persisted') {
439
+ await sink.append({ kind: 'work_persisted', payload: { sha: outcome.sha, files: outcome.files } });
440
+ }
441
+ else if (outcome.kind === 'work_none') {
442
+ await sink.append({ kind: 'work_none', payload: {} });
443
+ }
444
+ else {
445
+ await sink.append({ kind: 'work_persist_failed', payload: { error: outcome.error } });
446
+ }
447
+ }
448
+ catch {
449
+ // Recording harvest is best-effort — never fail the run because the harvest
450
+ // event append failed.
451
+ }
452
+ };
368
453
  let spawned = null;
369
454
  let agentSessionId = null;
370
455
  let installOk = false;
@@ -417,7 +502,7 @@ export async function runHostedLoop(opts) {
417
502
  model: context.model,
418
503
  cwd: context.workspaceDir,
419
504
  port: context.opencodePort,
420
- env: context.anthropicDummyKey ? { ANTHROPIC_API_KEY: context.anthropicDummyKey } : undefined,
505
+ env: buildOpenCodeSpawnEnv(context),
421
506
  spawn: nodeChildSpawner,
422
507
  logPath: join(tmpdir(), `opencode-serve-${context.runId}.log`),
423
508
  signal,
@@ -437,14 +522,41 @@ export async function runHostedLoop(opts) {
437
522
  resumeAgentSessionId: context.resumeAgentSessionId,
438
523
  });
439
524
  agentSessionId = started.agentSessionId;
525
+ // Prompt scaffolding (ALI-1036): wrap the user task with the standing
526
+ // headless preamble, keeping the task verbatim beneath a delimiter.
440
527
  const prompt = {
441
528
  runId: context.runId,
442
529
  messageId: context.messageId,
443
- content: taskPrompt,
530
+ content: composeHostedTaskPrompt(taskPrompt, opts.taskPreamble ?? HOSTED_TASK_PREAMBLE),
444
531
  author: context.author,
445
532
  };
446
- const status = await drainHarnessToSink(harness.runPrompt(prompt, signal), sink, {
533
+ // Drive the prompt through (a) question auto-handling annotation and (b) the
534
+ // idle watchdog (ALI-1037): no harness event for `idleTimeoutMs` aborts the
535
+ // prompt and throws `AgentStalledError` → the catch below harvests, then
536
+ // finishes the run `failed`. The prompt runs under a CHILD AbortController
537
+ // linked to the loop signal so the watchdog can abort the in-flight prompt
538
+ // (unblocking a signal-respecting harness the same way a cancel would), then
539
+ // best-effort `harness.stop()` the driver.
540
+ const idleTimeoutMs = resolveIdleTimeoutMs(opts.idleTimeoutMs);
541
+ const promptController = new AbortController();
542
+ if (signal.aborted)
543
+ promptController.abort();
544
+ else
545
+ signal.addEventListener('abort', () => promptController.abort(), { once: true });
546
+ const annotated = annotateHeadlessQuestions(harness.runPrompt(prompt, promptController.signal));
547
+ const drivenStream = idleTimeoutMs > 0
548
+ ? withIdleWatchdog(annotated, {
549
+ timeoutMs: idleTimeoutMs,
550
+ onTimeout: async () => {
551
+ promptController.abort();
552
+ await harness.stop();
553
+ },
554
+ })
555
+ : annotated;
556
+ const status = await drainHarnessToSink(drivenStream, sink, {
447
557
  summary: { agentSessionId },
558
+ // Auto-harvest runs on EVERY terminal path, before the sink seals.
559
+ beforeFinish: runAutoHarvest,
448
560
  });
449
561
  await harness.shutdown();
450
562
  return { status, agentSessionId, installOk, error: null };
@@ -452,7 +564,9 @@ export async function runHostedLoop(opts) {
452
564
  catch (error) {
453
565
  const message = error instanceof Error ? error.message : String(error);
454
566
  // Fail closed: never leave the run non-terminal. finish() is idempotent and
455
- // no-ops when the drain already sealed the sink.
567
+ // no-ops when the drain already sealed the sink. Harvest FIRST (partial work
568
+ // is valuable, incl. an idle-watchdog abort) — before the terminal seals.
569
+ await runAutoHarvest();
456
570
  if (!sink.sealed) {
457
571
  try {
458
572
  await sink.finish('failed', { summary: { error: message } });
@@ -71,6 +71,13 @@ const EVENT_TYPE_BY_KIND = {
71
71
  // through this SAME sink (single writer). See EGRESS_*_EVENT_TYPE below.
72
72
  egress_blocked: EGRESS_BLOCKED_EVENT_TYPE,
73
73
  egress_allowed: EGRESS_ALLOWED_EVENT_TYPE,
74
+ // Durability + headless (ALI-1036 / ALI-1037), emitted by the in-sandbox loop
75
+ // through this SAME sink (single writer): auto-harvest results + headless
76
+ // question auto-handling record.
77
+ work_persisted: 'work_persisted',
78
+ work_none: 'work_none',
79
+ work_persist_failed: 'work_persist_failed',
80
+ question_auto_answered: 'agent_question_auto_answered',
74
81
  };
75
82
  /** The RunAPI event type a harness kind maps to, or null if it is not appendable
76
83
  * (terminal kinds route through the PATCH; deferred kinds have no mapping yet). */
@@ -457,6 +464,16 @@ export async function resumeRunEventSink(options) {
457
464
  * run's final status.
458
465
  */
459
466
  export async function drainHarnessToSink(events, sink, opts = {}) {
467
+ // Run the pre-terminal hook (auto-harvest) at most once, right before the first
468
+ // finish() on any terminal path — while the sink is still writable.
469
+ let beforeFinishRan = false;
470
+ const runBeforeFinish = async () => {
471
+ if (beforeFinishRan)
472
+ return;
473
+ beforeFinishRan = true;
474
+ if (opts.beforeFinish)
475
+ await opts.beforeFinish();
476
+ };
460
477
  for await (const event of events) {
461
478
  if (event.kind === 'execution_complete') {
462
479
  // A prompt the caller/agent stopped is a cancellation, not a failure: the
@@ -464,6 +481,7 @@ export async function drainHarnessToSink(events, sink, opts = {}) {
464
481
  // and stop()-initiated paths. A genuine failure (success:false, no abort
465
482
  // flag) still lands 'failed'.
466
483
  if (event.payload.aborted === true) {
484
+ await runBeforeFinish();
467
485
  await sink.finish('cancelled', {
468
486
  summary: opts.summary,
469
487
  evidence: opts.evidence,
@@ -476,10 +494,12 @@ export async function drainHarnessToSink(events, sink, opts = {}) {
476
494
  const summary = success
477
495
  ? opts.summary
478
496
  : { ...(opts.summary ?? {}), error: event.payload.error ?? 'execution failed' };
497
+ await runBeforeFinish();
479
498
  await sink.finish(status, { summary, evidence: opts.evidence, transcript: opts.transcript });
480
499
  return status;
481
500
  }
482
501
  if (event.kind === 'error') {
502
+ await runBeforeFinish();
483
503
  await sink.finish('failed', {
484
504
  summary: { ...(opts.summary ?? {}), error: event.payload.error ?? 'agent error' },
485
505
  evidence: opts.evidence,
@@ -491,6 +511,7 @@ export async function drainHarnessToSink(events, sink, opts = {}) {
491
511
  }
492
512
  // The harness contract guarantees a terminal event; reaching here means the
493
513
  // stream ended abnormally. Fail closed so the run never dangles non-terminal.
514
+ await runBeforeFinish();
494
515
  await sink.finish('failed', {
495
516
  summary: { ...(opts.summary ?? {}), error: 'harness stream ended without a terminal event' },
496
517
  transcript: opts.transcript,
@@ -39,6 +39,33 @@ export const SETUP_HOOK_RELATIVE_PATH = '.orizu/setup.sh';
39
39
  * same identity for run-event attribution; keep them identical.
40
40
  */
41
41
  export const AGENT_GIT_IDENTITY = { name: 'Orizu Agent', email: 'agent@orizu.ai' };
42
+ // -- Hosted task prompt scaffolding (ALI-1036) -------------------------------
43
+ /**
44
+ * Standing preamble wrapped around every hosted task prompt so a NATURAL task
45
+ * ("review X and update the readme") runs unattended with zero magic words. The
46
+ * agent is told to work autonomously (never block on the human), commit as it
47
+ * goes, and treat Orizu — not the repo — as the home for production pointers and
48
+ * run metadata. An end-of-run auto-harvest (ALI-1036, `harvestWorkspace`) is the
49
+ * safety net for anything left uncommitted; the preamble names it so the agent
50
+ * knows partial work is preserved.
51
+ */
52
+ export const HOSTED_TASK_PREAMBLE = 'You are running unattended in a hosted sandbox. Work autonomously: never ask the user ' +
53
+ 'questions — when uncertain, choose the most reasonable/reversible option and note the ' +
54
+ 'decision. Commit your work to the current branch in logical units as you go (an automatic ' +
55
+ 'checkpoint will also save anything uncommitted at the end). Never switch branches. ' +
56
+ 'Production/default pointers and run metadata live in Orizu, not this repo.';
57
+ /** Delimiter separating the standing preamble from the verbatim user task. */
58
+ export const HOSTED_TASK_DELIMITER = '--- YOUR TASK ---';
59
+ /**
60
+ * Compose the full hosted prompt: the standing preamble, a clear delimiter, then
61
+ * the user's task VERBATIM beneath it. Passing an empty preamble yields the task
62
+ * unchanged (an explicit opt-out for tests / callers that scaffold elsewhere).
63
+ */
64
+ export function composeHostedTaskPrompt(task, preamble = HOSTED_TASK_PREAMBLE) {
65
+ if (!preamble)
66
+ return task;
67
+ return `${preamble}\n\n${HOSTED_TASK_DELIMITER}\n${task}`;
68
+ }
42
69
  // -- Pre-baked runtime marker (ALI-1017) -------------------------------------
43
70
  /**
44
71
  * Absolute path of the marker the pre-baked hosted-runtime image writes at build
@@ -198,6 +198,12 @@ async function defaultResolveRepo(fetcher, workspaceId, sessionId) {
198
198
  return { repoFullName, cloneUrl: `https://github.com/${repoFullName}.git` };
199
199
  }
200
200
  export async function startHostedSession(opts) {
201
+ // ALI-1035 fail-fast: a hosted run cannot reach the model provider without a key
202
+ // (the firewall brokers the real key onto egress). Refuse BEFORE creating any
203
+ // session, run row, or sandbox — no orphaned rows, no paid box, instant error.
204
+ if (!opts.modelApiKey) {
205
+ throw new Error('hosted session needs a model API key: set ANTHROPIC_API_KEY (or pass --model-key)');
206
+ }
201
207
  const rawFetch = opts.rawFetch ?? globalThis.fetch;
202
208
  const log = opts.logLine ?? (() => { });
203
209
  const model = opts.model ?? DEFAULT_HOSTED_MODEL;
@@ -418,6 +424,32 @@ export async function startHostedSession(opts) {
418
424
  let sandboxBudgetMs = durationMs;
419
425
  let tokenExpiresAtMs = now() + ttlMinutes * 60 * 1000;
420
426
  let stopped = false;
427
+ // ALI-1037 host-side idle gate: only KEEP a sandbox alive while its run is
428
+ // making progress. Before each keepalive extension we cheaply check whether any
429
+ // new run event landed since the last check; a run with NO new events since the
430
+ // previous rotation cycle is not extended (the in-sandbox watchdog is the
431
+ // primary stall killer — this stops the HOST from paying to keep a dead box
432
+ // alive). "Can't tell" (non-2xx / transport error) never starves a healthy run.
433
+ let lastEventCursor = 0;
434
+ const hasEventProgress = async () => {
435
+ try {
436
+ const res = await opts.fetcher(`/api/cli/workbench-runs/${encodeURIComponent(runId)}/events?after=${lastEventCursor}&limit=500`);
437
+ if (!res.ok)
438
+ return true;
439
+ const body = (await res.json().catch(() => ({})));
440
+ const events = Array.isArray(body.events) ? body.events : [];
441
+ if (events.length === 0)
442
+ return false;
443
+ lastEventCursor = events.reduce((max, event) => {
444
+ const seq = typeof event.sequence === 'number' ? event.sequence : 0;
445
+ return seq > max ? seq : max;
446
+ }, lastEventCursor);
447
+ return true;
448
+ }
449
+ catch {
450
+ return true;
451
+ }
452
+ };
421
453
  let cancelSleep = null;
422
454
  const stop = () => {
423
455
  stopped = true;
@@ -485,15 +517,23 @@ export async function startHostedSession(opts) {
485
517
  log('rotated agent bearer (file overwritten)');
486
518
  // Keepalive: extend the sandbox by one rotation interval each rotation
487
519
  // while attached (SDK supports extendTimeout(ms)), bounded by the 24h cap
488
- // so a long attached session outlives the initial --duration window.
520
+ // so a long attached session outlives the initial --duration window — BUT
521
+ // only while the run is making progress (ALI-1037). A run with no new
522
+ // events since the last rotation is not extended: the box is left to wind
523
+ // down to its current budget instead of the host paying for a stalled run.
489
524
  if (sandbox.extendTimeout && sandboxBudgetMs + rotationIntervalMs <= sandboxCapMs) {
490
- try {
491
- await sandbox.extendTimeout(rotationIntervalMs);
492
- sandboxBudgetMs += rotationIntervalMs;
493
- log(`extended sandbox timeout (+${Math.round(rotationIntervalMs / 1000)}s, budget ${Math.round(sandboxBudgetMs / 60000)}m)`);
525
+ if (!(await hasEventProgress())) {
526
+ log('sandbox timeout NOT extended: no run events since the last rotation (agent stalled) — letting the box wind down');
494
527
  }
495
- catch (error) {
496
- log(`sandbox extendTimeout failed: ${error instanceof Error ? error.message : String(error)}`);
528
+ else {
529
+ try {
530
+ await sandbox.extendTimeout(rotationIntervalMs);
531
+ sandboxBudgetMs += rotationIntervalMs;
532
+ log(`extended sandbox timeout (+${Math.round(rotationIntervalMs / 1000)}s, budget ${Math.round(sandboxBudgetMs / 60000)}m)`);
533
+ }
534
+ catch (error) {
535
+ log(`sandbox extendTimeout failed: ${error instanceof Error ? error.message : String(error)}`);
536
+ }
497
537
  }
498
538
  }
499
539
  }
@@ -562,7 +602,7 @@ const HOSTED_USAGE = 'Usage: orizu session start --hosted --task "<prompt>" [--d
562
602
  '[--model <provider/model>] [--project <team/project>] [--provider vercel|local-sim] ' +
563
603
  '[--image <orizu-hosted-runtime:tag> (pre-baked VCR image; default env ORIZU_HOSTED_IMAGE)] ' +
564
604
  '[--snapshot <snapshot-id> (pre-baked Vercel snapshot, zero-Docker; default env ORIZU_HOSTED_SNAPSHOT; ' +
565
- 'mutually exclusive with --image)] [--tail]\n' +
605
+ 'mutually exclusive with --image)] [--model-key <key> (else ANTHROPIC_API_KEY; required)] [--tail]\n' +
566
606
  'Note (v0): if you disconnect, the agent token expires within its TTL (<=60m) and the run finishes ' +
567
607
  'cleanly; reconnect with `orizu run tail --run <id>`. The sandbox timeout is independent (up to 24h).';
568
608
  /**
@@ -618,7 +658,9 @@ export async function hostedCommand(args, io) {
618
658
  hostedImage: argVal(args, '--image') ?? process.env.ORIZU_HOSTED_IMAGE ?? undefined,
619
659
  hostedSnapshot: argVal(args, '--snapshot') ?? process.env.ORIZU_HOSTED_SNAPSHOT ?? undefined,
620
660
  tail,
621
- modelApiKey: process.env.ANTHROPIC_API_KEY || undefined,
661
+ // Model key: explicit --model-key wins, else ANTHROPIC_API_KEY. Falsy → the
662
+ // fail-fast in startHostedSession throws before any session/sandbox is made.
663
+ modelApiKey: argVal(args, '--model-key') ?? (process.env.ANTHROPIC_API_KEY || undefined),
622
664
  logLine: line => io.print(line),
623
665
  onTailEvent: event => io.print(io.json ? JSON.stringify(event) : `#${String(event.sequence ?? '?')} ${String(event.eventType ?? '')}`),
624
666
  tailImpl: tail
package/dist/http.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getActiveBaseUrl, getServerCredentials, updateServerCredentials } from './credentials.js';
1
+ import { getActiveBaseUrl, getServerCredentials, resolveEnvBearerToken, updateServerCredentials, } from './credentials.js';
2
2
  import { getFlagBaseUrl, normalizeBaseUrl } from './global-flags.js';
3
3
  let runtimeFlags = { local: false, server: null };
4
4
  export function setGlobalFlags(flags) {
@@ -94,6 +94,21 @@ async function refreshCredentials(baseUrl, credentials) {
94
94
  export async function authedFetch(path, init = {}) {
95
95
  const baseUrl = resolveBaseUrl();
96
96
  assertSecureTokenTransport(baseUrl);
97
+ // In-sandbox pre-auth (ALI-1044): a bearer supplied via ORIZU_TOKEN /
98
+ // ORIZU_TOKEN_FILE takes precedence over credentials.json. It is externally
99
+ // managed (the hosted loop rotates the token file), so it is read FRESH here on
100
+ // every request and NEVER refreshed by this client — on a 401 the loop rotates
101
+ // the file and the next request naturally picks up the new bearer.
102
+ const envBearer = resolveEnvBearerToken();
103
+ if (envBearer) {
104
+ return await fetch(`${baseUrl}${path}`, {
105
+ ...init,
106
+ headers: {
107
+ ...(init.headers || {}),
108
+ Authorization: `Bearer ${envBearer}`,
109
+ },
110
+ });
111
+ }
97
112
  const credentials = getServerCredentials(baseUrl);
98
113
  if (!credentials) {
99
114
  throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`);
package/dist/index.js CHANGED
@@ -36,6 +36,7 @@ import { hostedCommand } from './hosted-session-cli.js';
36
36
  import { workspaceSyncCommand } from './workspace-sync.js';
37
37
  import { runGitCredential } from './git-credential.js';
38
38
  import { runGithubLink, runInteractiveHostedSetup } from './github-setup.js';
39
+ import { reportCommentsCommand, throwDeprecatedPromptCommentsCommand, } from './report-comments-cli.js';
39
40
  function getErrorMessage(error) {
40
41
  return error instanceof Error ? error.message : String(error);
41
42
  }
@@ -609,63 +610,6 @@ function printTaskStatusSummary(data) {
609
610
  });
610
611
  }
611
612
  }
612
- function compactTerminalText(value, maxLength = 160) {
613
- const compact = sanitizeTerminalText(value).replace(/\s+/g, ' ').trim();
614
- if (compact.length <= maxLength) {
615
- return compact;
616
- }
617
- return `${compact.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
618
- }
619
- function printIndentedBody(value, indent) {
620
- const lines = sanitizeTerminalText(value || '').split(/\r?\n/);
621
- for (const line of lines) {
622
- printLine(`${indent}${line}`);
623
- }
624
- }
625
- function formatPromptCommentAnchor(anchor) {
626
- if (!anchor) {
627
- return null;
628
- }
629
- const lineLabel = anchor.startLine && anchor.endLine
630
- ? anchor.startLine === anchor.endLine
631
- ? `line ${anchor.startLine}`
632
- : `lines ${anchor.startLine}-${anchor.endLine}`
633
- : null;
634
- const selectedText = anchor.text ? `"${compactTerminalText(anchor.text, 180)}"` : null;
635
- if (lineLabel && selectedText) {
636
- return `${lineLabel}: ${selectedText}`;
637
- }
638
- return lineLabel || selectedText;
639
- }
640
- function printPromptComments(data) {
641
- const versionLabel = data.version.versionLabel || (data.version.versionNumber ? `v${data.version.versionNumber}` : data.version.id);
642
- printLine(`Prompt: ${sanitizeTerminalText(data.prompt.name)} (${sanitizeTerminalText(data.prompt.id)})`);
643
- printLine(`Version: ${sanitizeTerminalText(String(versionLabel))} (${sanitizeTerminalText(data.version.id)})`);
644
- printLine(`Comments: ${data.summary.threadCount} thread${data.summary.threadCount === 1 ? '' : 's'} ` +
645
- `(${data.summary.openThreadCount} open, ${data.summary.resolvedThreadCount} resolved, ${data.summary.replyCount} replies)`);
646
- if (data.comments.length === 0) {
647
- printLine('\nNo comments found for this prompt version.');
648
- return;
649
- }
650
- for (const [index, thread] of data.comments.entries()) {
651
- const authorName = thread.author?.name || 'Unknown';
652
- const anchor = formatPromptCommentAnchor(thread.anchor);
653
- printLine('');
654
- printLine(`${index + 1}. [${thread.status}] ${sanitizeTerminalText(authorName)} · ${sanitizeTerminalText(thread.createdAt)}`);
655
- if (anchor) {
656
- printLine(` Selection: ${anchor}`);
657
- }
658
- printIndentedBody(thread.body, ' ');
659
- if (thread.replies.length > 0) {
660
- printLine(` Replies (${thread.replies.length})`);
661
- for (const reply of thread.replies) {
662
- const replyAuthor = reply.author?.name || 'Unknown';
663
- printLine(` - ${sanitizeTerminalText(replyAuthor)} · ${sanitizeTerminalText(reply.createdAt)}`);
664
- printIndentedBody(reply.body, ' ');
665
- }
666
- }
667
- }
668
- }
669
613
  const DEFAULT_AUTH_CALLBACK_PORT = 43123;
670
614
  function resolveAuthCallbackPort() {
671
615
  const envPort = process.env.ORIZU_AUTH_PORT;
@@ -1029,33 +973,6 @@ async function listJudges() {
1029
973
  }
1030
974
  printPromptSummaries(data.judges, 'No judges found.');
1031
975
  }
1032
- async function listPromptComments() {
1033
- const promptRef = getPositionalArg(2);
1034
- const project = getArg('--project') || await resolveProjectSlug(null);
1035
- const label = getArg('--label');
1036
- const version = getArg('--version');
1037
- if (!promptRef) {
1038
- throw new Error('Usage: orizu prompts comments <prompt-id-or-name> --project <team/project> [--label <label> | --version <version-id>] [--json]');
1039
- }
1040
- if (label && version) {
1041
- throw new Error('Use either --label or --version, not both');
1042
- }
1043
- const params = new URLSearchParams({ project });
1044
- if (label)
1045
- params.set('label', label);
1046
- if (version)
1047
- params.set('version', version);
1048
- const response = await authedFetch(`/api/cli/prompts/${encodeURIComponent(promptRef)}/comments?${params.toString()}`);
1049
- if (!response.ok) {
1050
- throw new Error(`Failed to fetch prompt comments: ${await response.text()}`);
1051
- }
1052
- const data = await parseJsonResponse(response, 'Prompt comments');
1053
- if (hasJsonFlag()) {
1054
- printJson(data);
1055
- return;
1056
- }
1057
- printPromptComments(data);
1058
- }
1059
976
  async function listScorers() {
1060
977
  const project = getArg('--project') || await resolveProjectSlug(null);
1061
978
  const response = await authedFetch(`/api/cli/scorers?project=${encodeURIComponent(project)}`);
@@ -2144,6 +2061,21 @@ function hasJsonFlag() {
2144
2061
  function printJson(value) {
2145
2062
  printLine(JSON.stringify(value));
2146
2063
  }
2064
+ function createReportCommentsCliContext() {
2065
+ return {
2066
+ getArg,
2067
+ getPositionalArg,
2068
+ rejectDashPrefixedOptionValue,
2069
+ resolveProjectSlug,
2070
+ readSourceFile,
2071
+ authedFetch,
2072
+ parseJsonResponse,
2073
+ hasJsonFlag,
2074
+ printJson,
2075
+ printLine,
2076
+ sanitizeTerminalText,
2077
+ };
2078
+ }
2147
2079
  function printCapabilities() {
2148
2080
  const capabilities = getCapabilities(getCliVersion());
2149
2081
  if (hasJsonFlag()) {
@@ -3859,6 +3791,35 @@ async function setTaskReport() {
3859
3791
  printLine(`Uploaded task report for ${sanitizeTerminalText(data.task.id)} ` +
3860
3792
  `[${sanitizeTerminalText(data.task.status)}]`);
3861
3793
  }
3794
+ async function getTaskReport() {
3795
+ const taskId = getArg('--task');
3796
+ rejectDashPrefixedOptionValue('--task', taskId);
3797
+ if (!taskId) {
3798
+ throw new Error('Usage: orizu tasks report get --task <taskId> [--json]');
3799
+ }
3800
+ const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/report`);
3801
+ if (!response.ok) {
3802
+ throw new Error(`Failed to read task report: ${await response.text()}`);
3803
+ }
3804
+ const data = await parseJsonResponse(response, 'Task report get');
3805
+ if (hasJsonFlag()) {
3806
+ printJson(data);
3807
+ return;
3808
+ }
3809
+ const title = data.task.title ? `${data.task.title} ` : '';
3810
+ printLine(`Task report: ${sanitizeTerminalText(title)}(${sanitizeTerminalText(data.task.id)})`);
3811
+ if (data.task.status) {
3812
+ printLine(`Status: ${sanitizeTerminalText(data.task.status)}`);
3813
+ }
3814
+ if (data.task.report.sourceName) {
3815
+ printLine(`Source: ${sanitizeTerminalText(data.task.report.sourceName)}`);
3816
+ }
3817
+ if (data.task.report.createdAt) {
3818
+ printLine(`Created: ${sanitizeTerminalText(data.task.report.createdAt)}`);
3819
+ }
3820
+ printLine('');
3821
+ printLine(sanitizeTerminalText(data.task.report.markdown));
3822
+ }
3862
3823
  function defaultAppExportFilename(appName, versionNum) {
3863
3824
  const safeName = appName
3864
3825
  .trim()
@@ -4896,6 +4857,10 @@ export async function main(rawArgs = process.argv.slice(2)) {
4896
4857
  await logOptimizationEvent();
4897
4858
  return;
4898
4859
  }
4860
+ if (command === 'comments') {
4861
+ await reportCommentsCommand(subcommand, createReportCommentsCliContext());
4862
+ return;
4863
+ }
4899
4864
  if (command === 'teams' && subcommand === 'list') {
4900
4865
  await listTeams();
4901
4866
  return;
@@ -4934,8 +4899,7 @@ export async function main(rawArgs = process.argv.slice(2)) {
4934
4899
  return;
4935
4900
  }
4936
4901
  if (command === 'prompts' && subcommand === 'comments') {
4937
- await listPromptComments();
4938
- return;
4902
+ throwDeprecatedPromptCommentsCommand();
4939
4903
  }
4940
4904
  if (command === 'prompts' && subcommand === 'pull') {
4941
4905
  await pullPromptArtifact('prompt');
@@ -5100,6 +5064,10 @@ export async function main(rawArgs = process.argv.slice(2)) {
5100
5064
  await setTaskReport();
5101
5065
  return;
5102
5066
  }
5067
+ if (command === 'tasks' && subcommand === 'report' && cliArgs[2] === 'get') {
5068
+ await getTaskReport();
5069
+ return;
5070
+ }
5103
5071
  if (command === 'tasks' && subcommand === 'pause') {
5104
5072
  await updateTaskStatus('paused');
5105
5073
  return;