@allocator-one/harness-cli 1.15.0 → 1.16.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.
package/dist/cli.js CHANGED
@@ -1,8 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  ACTIVITY_MAX_LIMIT,
4
- CLI_PACKAGE,
5
- CLI_VERSION,
6
4
  Client,
7
5
  CommandError,
8
6
  DEFAULT_BASE_URL,
@@ -16,8 +14,11 @@ import {
16
14
  JOBS_MAX_PAGES,
17
15
  JOB_STATES,
18
16
  LocalError,
17
+ PLAN_LIST_MAX_LIMIT,
18
+ PLAN_STATUSES,
19
19
  RELATION_CREATE_TYPES,
20
20
  RELATION_UPDATE_TYPES,
21
+ SESSION_PATH,
21
22
  STEER_MAX_CHARACTERS,
22
23
  TransportError,
23
24
  UpgradeRequiredError,
@@ -50,6 +51,7 @@ import {
50
51
  instructionFrom,
51
52
  isExpertToken,
52
53
  isHostTrusted,
54
+ isLoginCredential,
53
55
  jobCancel,
54
56
  jobContinue,
55
57
  jobPause,
@@ -73,12 +75,15 @@ import {
73
75
  memberSetRole,
74
76
  milestoneCreate,
75
77
  milestoneList,
78
+ orgCredentialsPath,
76
79
  planCreate,
77
80
  planEdit,
78
81
  planHold,
82
+ planList,
79
83
  planRelease,
80
84
  planShow,
81
85
  prepareProcedureRun,
86
+ prepareRun,
82
87
  prime,
83
88
  procedureSave,
84
89
  procedureShow,
@@ -99,7 +104,8 @@ import {
99
104
  requireIdentifiers,
100
105
  requireJobId,
101
106
  requireJobsArgs,
102
- requirePlanId,
107
+ requirePlanListArgs,
108
+ requirePlanRef,
103
109
  requireProcedureId,
104
110
  requireProcedureName,
105
111
  requireReadyClaimArgs,
@@ -110,6 +116,7 @@ import {
110
116
  resolveCacheHome,
111
117
  resolveConfig,
112
118
  resolveTextSources,
119
+ run,
113
120
  search,
114
121
  show,
115
122
  skillsList,
@@ -131,13 +138,21 @@ import {
131
138
  update,
132
139
  upload,
133
140
  userList,
141
+ validOrgId,
134
142
  validateBaseUrl,
135
143
  validateIdempotencyKey,
136
144
  version,
137
145
  watchStopSignal,
138
146
  workflowStates,
139
147
  writeCredentials
140
- } from "./chunk-I2S2JVCQ.js";
148
+ } from "./chunk-GVTZKAXC.js";
149
+ import {
150
+ CLI_VERSION,
151
+ OPERATOR_PACKAGE,
152
+ invokedName,
153
+ layoutOperatorHelp,
154
+ named
155
+ } from "./chunk-FQWV5MIC.js";
141
156
 
142
157
  // src/cli.ts
143
158
  import { createInterface as createInterface2 } from "readline";
@@ -267,8 +282,10 @@ async function exec(ctx, opts) {
267
282
  const missing = unstoredSlugs(requested, stored);
268
283
  if (missing.length > 0) {
269
284
  process.stderr.write(
270
- `harness: no key stored for ${missing.join(", ")} \u2014 the command gets your shell's own variable for those, if any
285
+ named(
286
+ `harness: no key stored for ${missing.join(", ")} \u2014 the command gets your shell's own variable for those, if any
271
287
  `
288
+ )
272
289
  );
273
290
  }
274
291
  }
@@ -325,6 +342,172 @@ async function exec(ctx, opts) {
325
342
  return { json: { exit_code: exitCode }, human: "", exitCode };
326
343
  }
327
344
 
345
+ // src/commands/review.ts
346
+ import { execFileSync } from "child_process";
347
+ function isRecord2(value) {
348
+ return typeof value === "object" && value !== null && !Array.isArray(value);
349
+ }
350
+ var SLUG = /^([\w.-]+)\/([\w.-]+)$/;
351
+ function githubSlug(remote) {
352
+ const match = /^(?:(?:https?|ssh|git):\/\/(?:[^@/\s]+@)?|[\w.-]+@)github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(remote.trim());
353
+ return match ? `${match[1]}/${match[2]}` : void 0;
354
+ }
355
+ function originRemote() {
356
+ try {
357
+ const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
358
+ return githubSlug(url);
359
+ } catch {
360
+ return void 0;
361
+ }
362
+ }
363
+ function prNumber(digits, input) {
364
+ const number = Number(digits);
365
+ if (!Number.isSafeInteger(number) || number < 1) throw new UsageError(`not a pull request number: ${input}`);
366
+ return number;
367
+ }
368
+ var NAME = /^[\w.-]+$/;
369
+ function fromUrl(input) {
370
+ let url;
371
+ try {
372
+ url = new URL(input);
373
+ } catch {
374
+ return null;
375
+ }
376
+ if (!["https:", "http:"].includes(url.protocol) || !/^(www\.)?github\.com$/i.test(url.hostname)) return null;
377
+ let segments;
378
+ try {
379
+ segments = url.pathname.split("/").filter((segment) => segment !== "").map(decodeURIComponent);
380
+ } catch {
381
+ return null;
382
+ }
383
+ const [owner, repo, kind, number] = segments;
384
+ if (kind !== "pull" || !owner || !repo || !number || !NAME.test(owner) || !NAME.test(repo) || !/^\d+$/.test(number)) {
385
+ return null;
386
+ }
387
+ return [owner, repo, number];
388
+ }
389
+ function parsePullRequest(input, repo, origin = originRemote) {
390
+ const trimmed = input.trim();
391
+ const url = fromUrl(trimmed);
392
+ if (url) return { owner: url[0], repo: url[1], number: prNumber(url[2], input) };
393
+ const named2 = /^([\w.-]+)\/([\w.-]+)#(\d+)$/.exec(trimmed);
394
+ if (named2) return { owner: named2[1], repo: named2[2], number: prNumber(named2[3], input) };
395
+ const bare = /^#?(\d+)$/.exec(trimmed);
396
+ if (!bare) throw new UsageError(`not a pull request: ${input} \u2014 use owner/repo#<pr>, its URL, or <pr> with --repo`);
397
+ const slug = repo ?? origin();
398
+ const parts = slug === void 0 ? null : SLUG.exec(slug.trim());
399
+ if (!parts) throw new UsageError("name the repository: owner/repo#<pr>, the pull request URL, or --repo owner/repo");
400
+ return { owner: parts[1], repo: parts[2], number: prNumber(bare[1], input) };
401
+ }
402
+ var label = (pr) => `${pr.owner}/${pr.repo}#${pr.number}`;
403
+ var gatePath = (pr) => `/api/v1/reviews/prs/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/${pr.number}`;
404
+ async function fetchGate(ctx, pr) {
405
+ const body = await ctx.client.get(gatePath(pr));
406
+ if (!isRecord2(body.data)) throw new CommandError(`unexpected gate status for ${label(pr)}: no data`);
407
+ return body.data;
408
+ }
409
+ function latestAttempt(lifecycle) {
410
+ const attempts = Array.isArray(lifecycle["attempts"]) ? lifecycle["attempts"].filter(isRecord2) : [];
411
+ return attempts.reduce(
412
+ (latest, attempt) => latest === void 0 || Number(attempt["number"]) > Number(latest["number"]) ? attempt : latest,
413
+ void 0
414
+ );
415
+ }
416
+ function recovery(lifecycle) {
417
+ const latest = latestAttempt(lifecycle);
418
+ const sent = isRecord2(lifecycle["recovery"]) ? lifecycle["recovery"] : void 0;
419
+ const state2 = String(sent?.["state"] ?? latest?.["state"] ?? "");
420
+ if (state2 !== "failed" && state2 !== "timed_out") return void 0;
421
+ const reason = sent?.["terminal_reason"] ?? latest?.["terminal_reason"];
422
+ return {
423
+ number: latest?.["number"],
424
+ state: state2 === "timed_out" ? "timed out" : state2,
425
+ reason: typeof reason === "string" ? reason : null,
426
+ evidence: sent ? sent["evidence_recorded"] === true : latest?.["review_run_id"] != null,
427
+ next: sent?.["next"] === "automatic_retry" ? "automatic_retry" : "retry"
428
+ };
429
+ }
430
+ function describe(r) {
431
+ const why = [r.reason, r.evidence ? null : "no evidence recorded"].filter((part) => part !== null).join(", ");
432
+ return `attempt ${clean(String(r.number))} ${r.state}${why === "" ? "" : ` (${clean(why)})`}`;
433
+ }
434
+ function projection(gate2, key) {
435
+ const value = gate2[key];
436
+ return isRecord2(value) && typeof value["status"] === "string" ? clean(value["status"]) : "unknown";
437
+ }
438
+ async function status(ctx, pr) {
439
+ const gate2 = await fetchGate(ctx, pr);
440
+ const lifecycle = isRecord2(gate2["lifecycle"]) ? gate2["lifecycle"] : void 0;
441
+ const head = isRecord2(gate2["head"]) && typeof gate2["head"]["sha"] === "string" ? gate2["head"]["sha"].slice(0, 10) : "?";
442
+ const lines = [
443
+ `${label(pr)} at ${clean(head)}`,
444
+ `enforced: ${projection(gate2, "enforced")} advisory: ${projection(gate2, "advisory")}`
445
+ ];
446
+ if (lifecycle) {
447
+ lines.push(`gate check: ${clean(String(lifecycle["state"] ?? "unknown")).replaceAll("_", " ")}`);
448
+ const r = recovery(lifecycle);
449
+ if (r) {
450
+ lines.push(`latest ${describe(r)}`);
451
+ lines.push(
452
+ r.next === "automatic_retry" ? "Harness retries it automatically; check again shortly." : named(`Retry it with: harness review retry ${label(pr)} --reason "\u2026"`)
453
+ );
454
+ }
455
+ }
456
+ return { json: gate2, human: lines.join("\n") + "\n" };
457
+ }
458
+ var REFUSALS = {
459
+ execution_not_terminal: "the latest attempt is still running; wait for it to finish",
460
+ attempt_changed: "another attempt was created meanwhile; read the status and run this again",
461
+ check_changed: "the head's gate check changed meanwhile; run this again",
462
+ head_changed: "the pull request's head moved; the new head gets its own check",
463
+ advisory_not_converged: "the head's advisory review has not converged; converge it first",
464
+ retry_conflict: "this attempt was already retried with a different reason",
465
+ merged: "the pull request is merged"
466
+ };
467
+ async function retry(ctx, pr, reason) {
468
+ const gate2 = await fetchGate(ctx, pr);
469
+ const lifecycle = isRecord2(gate2["lifecycle"]) ? gate2["lifecycle"] : void 0;
470
+ if (!lifecycle || typeof lifecycle["check_id"] !== "string" || typeof lifecycle["head_sha"] !== "string") {
471
+ throw new CommandError(`${label(pr)} has no gate check at its current head: nothing to retry`);
472
+ }
473
+ const r = recovery(lifecycle);
474
+ if (r?.next === "automatic_retry") {
475
+ throw new CommandError(named(`Harness retries attempt ${clean(String(r.number))} itself; run harness review status ${label(pr)} shortly`));
476
+ }
477
+ const previous = latestAttempt(lifecycle);
478
+ const previousId = typeof previous?.["id"] === "string" ? previous["id"] : null;
479
+ const body = {
480
+ head_sha: lifecycle["head_sha"],
481
+ check_id: lifecycle["check_id"],
482
+ previous_attempt_id: previousId,
483
+ // One key per attempt retried: running this again for the same attempt returns the attempt already
484
+ // queued instead of paying for another review run.
485
+ request_key: `harness-cli:${previousId ?? lifecycle["check_id"]}`,
486
+ reason: reason?.trim() || "Retry requested with harness review retry"
487
+ };
488
+ let response;
489
+ try {
490
+ response = await ctx.client.post(`${gatePath(pr)}/retry`, body, { actor: false });
491
+ } catch (error) {
492
+ if (error instanceof HttpError && error.status === 409) {
493
+ const code = error.message.trim().split(/[\s:]+/).pop() ?? "";
494
+ throw new CommandError(`could not retry ${label(pr)}: ${REFUSALS[code] ?? clean(error.message)}`);
495
+ }
496
+ if (error instanceof HttpError && error.status === 403) {
497
+ throw new CommandError(`${error.message} \u2014 a retry needs a login with reviews:write`);
498
+ }
499
+ throw error;
500
+ }
501
+ const attempt = isRecord2(response.data) ? response.data : {};
502
+ const outcome = response["outcome"];
503
+ const verb = outcome === "existing" ? "Already queued" : "Queued";
504
+ return {
505
+ json: { attempt, outcome },
506
+ human: `${verb} attempt ${clean(String(attempt["number"]))} (${clean(String(attempt["id"]))}) for ${label(pr)}
507
+ `
508
+ };
509
+ }
510
+
328
511
  // src/core/auth.ts
329
512
  import { createHash, randomBytes } from "crypto";
330
513
  import { execFile } from "child_process";
@@ -338,9 +521,10 @@ var RESPONSE_FLUSH_TIMEOUT_MS = 2e3;
338
521
  var MAX_PAGE_RESPONSES = 4;
339
522
  var loginPageImport;
340
523
  function loadLoginPage() {
341
- loginPageImport ??= import("./login-page-UVUKFFTT.js");
524
+ loginPageImport ??= import("./login-page-KGEJFFXR.js");
342
525
  return loginPageImport;
343
526
  }
527
+ var NO_SESSION = { kind: null, expires_at: null, scopes: null, user: null, org: null };
344
528
  function generateState() {
345
529
  return randomBytes(24).toString("base64url");
346
530
  }
@@ -353,6 +537,9 @@ function challengeFor(verifier) {
353
537
  async function login(baseUrl, options = {}) {
354
538
  const validated = validateBaseUrl(baseUrl);
355
539
  if (!validated.ok) return { ok: false, message: validated.message };
540
+ if (options.org !== void 0 && !validOrgId(options.org)) {
541
+ return { ok: false, message: "org must be an organization UUID" };
542
+ }
356
543
  const base = baseUrl.replace(/\/+$/, "");
357
544
  if (options.noBrowser === true || !browserAvailable(options)) {
358
545
  return manualLogin(base, options);
@@ -425,6 +612,7 @@ async function loopbackLogin(baseUrl, options) {
425
612
  const detail = error instanceof Error ? error.message : String(error);
426
613
  return { ok: false, message: `could not open a browser: ${detail}` };
427
614
  }
615
+ if (options.org !== void 0) notify(`Choose organization ${options.org} on the login page; another organization's token will be refused.`);
428
616
  notify(`Opening ${url}`);
429
617
  notify("Waiting for the browser authorization (Ctrl-C to abort)\u2026");
430
618
  const callback = await callbackPromise;
@@ -439,7 +627,7 @@ async function loopbackLogin(baseUrl, options) {
439
627
  );
440
628
  return token;
441
629
  }
442
- const result = await verifyAndStore(baseUrl, token.token, options);
630
+ const result = await verifyAndStore(baseUrl, token.token, options, { minted: true, kind: token.kind });
443
631
  if (result.ok) {
444
632
  await callback.respond(200, "You can close this browser and return to the terminal.", "success");
445
633
  } else {
@@ -492,7 +680,7 @@ function awaitCallback(server, state2, timeoutMs) {
492
680
  if (phase !== "waiting") return;
493
681
  const timeoutResponse = {
494
682
  status: 410,
495
- message: "This login attempt has ended \u2014 run harness login again if needed.",
683
+ message: `This login attempt has ended \u2014 run ${invokedName()} login again if needed.`,
496
684
  pageState: "error"
497
685
  };
498
686
  phase = "terminal";
@@ -511,15 +699,15 @@ function awaitCallback(server, state2, timeoutMs) {
511
699
  });
512
700
  server.on("request", (request, response) => {
513
701
  const respond = callbackResponder(response);
514
- const respondWithPage = (status, message, pageState) => {
702
+ const respondWithPage = (status2, message, pageState) => {
515
703
  if (callbackPageResponses >= MAX_PAGE_RESPONSES) {
516
704
  respondEmpty(response, 429);
517
705
  return Promise.resolve();
518
706
  }
519
707
  callbackPageResponses += 1;
520
- return respond(status, message, pageState);
708
+ return respond(status2, message, pageState);
521
709
  };
522
- const respondAtRoot = (status, message, pageState) => {
710
+ const respondAtRoot = (status2, message, pageState) => {
523
711
  const rootPhase = phase === "terminal" ? "terminal" : "waiting";
524
712
  const responses = rootPageResponses.get(rootPhase) ?? 0;
525
713
  if (responses >= MAX_PAGE_RESPONSES) {
@@ -527,7 +715,7 @@ function awaitCallback(server, state2, timeoutMs) {
527
715
  return Promise.resolve();
528
716
  }
529
717
  rootPageResponses.set(rootPhase, responses + 1);
530
- return respond(status, message, pageState);
718
+ return respond(status2, message, pageState);
531
719
  };
532
720
  let url;
533
721
  try {
@@ -564,7 +752,7 @@ function awaitCallback(server, state2, timeoutMs) {
564
752
  }
565
753
  const result = finalResponse ?? {
566
754
  status: 410,
567
- message: "This login attempt has ended \u2014 run harness login again if needed.",
755
+ message: `This login attempt has ended \u2014 run ${invokedName()} login again if needed.`,
568
756
  pageState: "error"
569
757
  };
570
758
  void trackResponse(respondWithPage(result.status, result.message, result.pageState));
@@ -611,12 +799,12 @@ function awaitCallback(server, state2, timeoutMs) {
611
799
  resolve({
612
800
  ok: true,
613
801
  code,
614
- respond: async (status, message, pageState) => {
615
- finalResponse = { status, message, pageState };
802
+ respond: async (status2, message, pageState) => {
803
+ finalResponse = { status: status2, message, pageState };
616
804
  phase = "terminal";
617
805
  const responders = acceptedResponders.splice(0);
618
806
  await Promise.all(
619
- responders.map((acceptedRespond) => trackResponse(acceptedRespond(status, message, pageState)))
807
+ responders.map((acceptedRespond) => trackResponse(acceptedRespond(status2, message, pageState)))
620
808
  );
621
809
  await drainTrackedResponses();
622
810
  }
@@ -628,7 +816,7 @@ function awaitCallback(server, state2, timeoutMs) {
628
816
  });
629
817
  }
630
818
  function callbackResponder(response) {
631
- return async (status, message, state2) => {
819
+ return async (status2, message, state2) => {
632
820
  if (response.writableEnded || response.destroyed) return;
633
821
  return new Promise((resolve) => {
634
822
  let flushTimer;
@@ -659,7 +847,7 @@ function callbackResponder(response) {
659
847
  }
660
848
  try {
661
849
  const html = loginPage(message, state2);
662
- response.writeHead(status, {
850
+ response.writeHead(status2, {
663
851
  "cache-control": "no-store",
664
852
  connection: "close",
665
853
  "content-length": String(Buffer.byteLength(html)),
@@ -679,10 +867,10 @@ function callbackResponder(response) {
679
867
  });
680
868
  };
681
869
  }
682
- function respondEmpty(response, status) {
870
+ function respondEmpty(response, status2) {
683
871
  response.once("error", () => void 0);
684
872
  try {
685
- response.writeHead(status, {
873
+ response.writeHead(status2, {
686
874
  "cache-control": "no-store",
687
875
  connection: "close",
688
876
  "x-content-type-options": "nosniff"
@@ -696,7 +884,8 @@ async function exchangeCode(baseUrl, code, verifier, options) {
696
884
  try {
697
885
  const body = await bareClient(baseUrl, "", options).post("/api/v1/cli/token-exchange", { code, code_verifier: verifier });
698
886
  const token = body.data?.token;
699
- if (typeof token === "string" && token !== "") return { ok: true, token };
887
+ const kind = typeof body.data?.kind === "string" ? body.data.kind : null;
888
+ if (typeof token === "string" && token !== "") return { ok: true, token, kind };
700
889
  return {
701
890
  ok: false,
702
891
  message: "the login-code exchange returned no token \u2014 aborting"
@@ -720,25 +909,40 @@ async function exchangeCode(baseUrl, code, verifier, options) {
720
909
  async function manualLogin(baseUrl, options) {
721
910
  const notify = options.notify ?? defaultNotify;
722
911
  const prompt = options.prompt ?? defaultPrompt;
912
+ if (options.org !== void 0) notify(`Choose organization ${options.org} on the login page; another organization's token will be refused.`);
723
913
  notify(
724
914
  `Open ${baseUrl}/cli/login in a browser, authorize, and paste the token below (an outside expert pastes the pxc_ credential minted in their expert view instead).`
725
915
  );
726
916
  const token = (await prompt()).trim();
727
917
  if (token === "") return { ok: false, message: "no token entered" };
728
- return verifyAndStore(baseUrl, token, options);
918
+ return verifyAndStore(baseUrl, token, options, { minted: false, kind: null });
729
919
  }
730
- async function verifyAndStore(baseUrl, token, options) {
731
- const path = options.credentialsPath ?? defaultCredentialsPath(options.env ?? process.env);
920
+ async function verifyAndStore(baseUrl, token, options, received) {
921
+ const defaultPath = options.credentialsPath ?? defaultCredentialsPath(options.env ?? process.env);
922
+ const path = options.org === void 0 ? defaultPath : orgCredentialsPath(defaultPath, baseUrl, options.org);
732
923
  const client = bareClient(baseUrl, token, options);
733
924
  const expert = isExpertToken(token);
925
+ let kind = received.kind;
734
926
  try {
735
- await client.get(expert ? EXPERT_ASSIGNMENTS_PATH : "/api/v1/teams");
927
+ if (expert) {
928
+ if (options.org !== void 0) return { ok: false, message: "cannot verify an outside expert credential against a repo org \u2014 nothing was stored" };
929
+ await client.get(EXPERT_ASSIGNMENTS_PATH);
930
+ } else {
931
+ const described = isLoginCredential(token) ? await describeSession(client) : null;
932
+ if (described !== null) kind = described.session.kind ?? kind;
933
+ if (options.org !== void 0 && (typeof described?.session.org?.["id"] !== "string" || described.session.org["id"].toLowerCase() !== options.org.toLowerCase())) {
934
+ const revoked = received.minted && await revokeSession(client, token);
935
+ return { ok: false, message: `the received token does not verify organization ${options.org} \u2014 nothing was stored` + (revoked ? ", and the token was revoked" : "") };
936
+ }
937
+ if (described === null) await client.get("/api/v1/teams");
938
+ }
736
939
  } catch (error) {
737
940
  if (error instanceof HttpError) {
738
941
  const lapsed = expert && error.status === 401 ? ` \u2014 ${EXPERT_LAPSED}` : "";
942
+ const revoked = received.minted && await revokeSession(client, token);
739
943
  return {
740
944
  ok: false,
741
- message: `the received token failed verification (${error.message}, HTTP ${error.status}) \u2014 nothing was stored${lapsed}`
945
+ message: `the received token failed verification (${error.message}, HTTP ${error.status}) \u2014 nothing was stored${revoked ? ", and the token was revoked" : ""}${lapsed}`
742
946
  };
743
947
  }
744
948
  if (error instanceof TransportError) {
@@ -754,11 +958,11 @@ async function verifyAndStore(baseUrl, token, options) {
754
958
  try {
755
959
  writeCredentials(path, baseUrl, token);
756
960
  } catch (error) {
757
- await revokeSession(client, token);
961
+ if (received.minted) await revokeSession(client, token);
758
962
  const detail = error instanceof Error ? error.message : String(error);
759
963
  return {
760
964
  ok: false,
761
- message: `could not store credentials at ${path}: ${detail}` + (expert ? " (the pxc_ credential is still valid: revoke it in your expert view)" : "")
965
+ message: `could not store credentials at ${path}: ${detail}` + (expert ? " (the pxc_ credential is still valid: revoke it in your expert view)" : received.minted ? "" : " (the pasted token was not revoked and stays valid)")
762
966
  };
763
967
  }
764
968
  let supersededNote = "";
@@ -768,12 +972,40 @@ async function verifyAndStore(baseUrl, token, options) {
768
972
  supersededNote = "The pxc_ credential stored before stays valid until it expires: revoke it in your expert view.\n";
769
973
  }
770
974
  }
975
+ const name = invokedName();
771
976
  return {
772
977
  ok: true,
773
- json: { url: baseUrl, credentials: path },
774
- human: (expert ? `Stored an outside expert's pxc_ credential for ${baseUrl} at ${path} (it lasts at most 30 days; revoke it in your expert view \u2014 harness logout only forgets it). Commands now act as the expert: only assignments, conditions and gate work until you log in again.
775
- ` : `Logged in to ${baseUrl} \u2014 login token stored at ${path} (valid ~30 days; harness logout revokes it).
776
- `) + supersededNote
978
+ json: { url: baseUrl, credentials: path, kind },
979
+ human: (expert ? `Stored an outside expert's pxc_ credential for ${baseUrl} at ${path} (it lasts at most 30 days; revoke it in your expert view \u2014 ${name} logout only forgets it). Commands now act as the expert: only assignments, conditions and gate work until you log in again.
980
+ ` : `Logged in to ${baseUrl} \u2014 login token stored at ${path} (valid ~30 days; ${name} logout revokes it).
981
+ `) + kindNote(kind) + supersededNote
982
+ };
983
+ }
984
+ function kindNote(kind) {
985
+ if (kind === "operator-cli") {
986
+ return "This credential is Operator-only (operator-cli): Operator commands work; issue, plan and gate commands are refused.\n";
987
+ }
988
+ if (kind === "harness-cli") return "This credential has full access (harness-cli).\n";
989
+ return "";
990
+ }
991
+ async function describeSession(client) {
992
+ try {
993
+ const { envelope, apiVersion } = await client.getVersioned(SESSION_PATH);
994
+ return { session: sessionOf(envelope.data), apiVersion };
995
+ } catch (error) {
996
+ if (error instanceof HttpError && (error.status === 404 || error.status === 405)) return null;
997
+ throw error;
998
+ }
999
+ }
1000
+ function sessionOf(data) {
1001
+ if (!isRecord3(data)) return NO_SESSION;
1002
+ const text = (value) => typeof value === "string" ? value : null;
1003
+ return {
1004
+ kind: text(data["kind"]),
1005
+ expires_at: text(data["expires_at"]),
1006
+ scopes: Array.isArray(data["scopes"]) ? data["scopes"].filter((scope) => typeof scope === "string") : null,
1007
+ user: isRecord3(data["user"]) ? data["user"] : null,
1008
+ org: isRecord3(data["org"]) ? data["org"] : null
777
1009
  };
778
1010
  }
779
1011
  async function revokeSession(client, token) {
@@ -814,8 +1046,18 @@ async function whoami(config, credentialSource) {
814
1046
  const client = new Client(config);
815
1047
  const expert = isExpertToken(config.token);
816
1048
  let probe;
1049
+ let session = null;
1050
+ let serverVersion;
817
1051
  try {
818
- probe = (await client.get(expert ? EXPERT_ASSIGNMENTS_PATH : "/api/v1/stats")).data;
1052
+ const described2 = isLoginCredential(config.token) ? await describeSession(client) : null;
1053
+ if (described2 !== null) {
1054
+ session = described2.session;
1055
+ serverVersion = described2.apiVersion;
1056
+ } else {
1057
+ const answered = await client.getVersioned(expert ? EXPERT_ASSIGNMENTS_PATH : "/api/v1/stats");
1058
+ probe = answered.envelope.data;
1059
+ serverVersion = answered.apiVersion;
1060
+ }
819
1061
  } catch (error) {
820
1062
  if (error instanceof HttpError) {
821
1063
  return {
@@ -835,7 +1077,7 @@ async function whoami(config, credentialSource) {
835
1077
  };
836
1078
  }
837
1079
  }
838
- const serverVersion = await client.serverVersion().catch(() => "unknown");
1080
+ serverVersion ??= "unknown";
839
1081
  const hostSourceLabels = {
840
1082
  env: "HARNESS_API_URL",
841
1083
  "repo-config": ".harness-cli/config.json",
@@ -846,6 +1088,7 @@ async function whoami(config, credentialSource) {
846
1088
  const expertLines = seat === null ? "" : `Expert: ${expertSummary(seat)}
847
1089
  Reaches: its own assignments and the gates it owns on them (assignments, conditions, gate)
848
1090
  `;
1091
+ const described = session ?? NO_SESSION;
849
1092
  return {
850
1093
  ok: true,
851
1094
  json: {
@@ -854,21 +1097,55 @@ Reaches: its own assignments and the gates it owns on them (assignments, con
854
1097
  verified: true,
855
1098
  server_version: serverVersion,
856
1099
  host_source: config.hostSource,
1100
+ kind: described.kind,
1101
+ expires_at: described.expires_at,
1102
+ scopes: described.scopes,
1103
+ user: described.user,
1104
+ org: described.org,
857
1105
  expert: seat
858
1106
  },
859
1107
  human: `Host: ${config.baseUrl}${via}
860
- Credential: ${credentialSource}${seat === null ? "" : ", an outside expert's pxc_ credential"}
861
- ` + expertLines + `Verified: ok (server API ${serverVersion})
1108
+ Credential: ${credentialSource}${seat === null ? "" : ", an outside expert's pxc_ credential"}${kindLabel(described.kind)}
1109
+ ` + sessionLines(described) + expertLines + `Verified: ok (server API ${serverVersion})
862
1110
  `
863
1111
  };
864
1112
  }
1113
+ function kindLabel(kind) {
1114
+ if (kind === null) return "";
1115
+ if (kind === "harness-cli") return ", harness-cli (full access)";
1116
+ if (kind === "operator-cli") return ", operator-cli (Operator only)";
1117
+ return `, ${clean(kind)}`;
1118
+ }
1119
+ function sessionLines(session) {
1120
+ let lines = "";
1121
+ const user2 = session.user;
1122
+ if (user2 !== null) {
1123
+ const name = clean(user2["name"]);
1124
+ const email = clean(user2["email"]);
1125
+ lines += `User: ${name === "" ? email : email === "" ? name : `${name} <${email}>`}
1126
+ `;
1127
+ }
1128
+ const org = session.org;
1129
+ if (org !== null) {
1130
+ const name = clean(org["name"]);
1131
+ const slug = clean(org["slug"]);
1132
+ lines += `Org: ${slug === "" || slug === name ? name : `${name} (${slug})`}
1133
+ `;
1134
+ }
1135
+ if (session.expires_at !== null) lines += `Expires: ${clean(session.expires_at)}
1136
+ `;
1137
+ if (session.kind === "operator-cli") {
1138
+ lines += "Reaches: Operator commands only; issue, plan and gate commands are refused\n";
1139
+ }
1140
+ return lines;
1141
+ }
865
1142
  function expertSeat(data) {
866
- if (!isRecord2(data) || !isRecord2(data["expert"])) return null;
867
- const plan2 = isRecord2(data["plan"]) ? { id: data["plan"]["id"], status: data["plan"]["status"] } : null;
868
- const steps = Array.isArray(data["assignments"]) ? data["assignments"].filter(isRecord2).length : 0;
1143
+ if (!isRecord3(data) || !isRecord3(data["expert"])) return null;
1144
+ const plan2 = isRecord3(data["plan"]) ? { id: data["plan"]["id"], status: data["plan"]["status"] } : null;
1145
+ const steps = Array.isArray(data["assignments"]) ? data["assignments"].filter(isRecord3).length : 0;
869
1146
  return { id: data["expert"]["id"], role: data["expert"]["role"] ?? null, plan: plan2, assigned_steps: steps };
870
1147
  }
871
- function isRecord2(value) {
1148
+ function isRecord3(value) {
872
1149
  return typeof value === "object" && value !== null && !Array.isArray(value);
873
1150
  }
874
1151
  function bareClient(baseUrl, token, options) {
@@ -883,7 +1160,7 @@ function bareClient(baseUrl, token, options) {
883
1160
  return new Client(config);
884
1161
  }
885
1162
  function defaultNotify(line) {
886
- process.stderr.write(line + "\n");
1163
+ process.stderr.write(named(line + "\n"));
887
1164
  }
888
1165
  function defaultPrompt() {
889
1166
  const readline = createInterface({
@@ -914,57 +1191,6 @@ function guardBrokenPipes(streams = [process.stdout, process.stderr]) {
914
1191
  }
915
1192
  }
916
1193
 
917
- // src/invocation.ts
918
- import { basename } from "path";
919
- var OPERATOR_PACKAGE = "@allocator-one/operator";
920
- function invokedName(argv1 = process.argv[1], packageName = CLI_PACKAGE) {
921
- const typed = basename(argv1 ?? "").replace(/\.[cm]?js$/i, "").toLowerCase();
922
- if (typed === "operator" || typed === "harness") return typed;
923
- return packageName === OPERATOR_PACKAGE ? "operator" : "harness";
924
- }
925
- var OPERATOR_COMMANDS = [
926
- "run",
927
- "jobs",
928
- "job",
929
- "watch",
930
- "steer",
931
- "cancel",
932
- "pause",
933
- "continue",
934
- "procedure",
935
- "plan",
936
- "gate",
937
- "conditions",
938
- "assignments",
939
- "tools",
940
- "tool",
941
- "skills",
942
- "mcp"
943
- ];
944
- var GENERAL_COMMANDS = [
945
- "login",
946
- "logout",
947
- "whoami",
948
- "version",
949
- "keys"
950
- ];
951
- var OPERATOR_HEADING = "Operator:";
952
- var ISSUES_HEADING = "Issues:";
953
- var GENERAL_HEADING = "General:";
954
- function layoutOperatorHelp(program2) {
955
- const rank = (name) => {
956
- const operator = OPERATOR_COMMANDS.indexOf(name);
957
- if (operator !== -1) return operator;
958
- return GENERAL_COMMANDS.includes(name) ? 2 * OPERATOR_COMMANDS.length : OPERATOR_COMMANDS.length;
959
- };
960
- const heading = (name) => OPERATOR_COMMANDS.includes(name) ? OPERATOR_HEADING : GENERAL_COMMANDS.includes(name) ? GENERAL_HEADING : ISSUES_HEADING;
961
- for (const command of program2.commands) command.helpGroup(heading(command.name()));
962
- program2.commands.sort(
963
- (left, right) => rank(left.name()) - rank(right.name())
964
- );
965
- program2.commandsGroup(GENERAL_HEADING).helpCommand(true);
966
- }
967
-
968
1194
  // src/core/upgrade.ts
969
1195
  import { spawn as spawn2, spawnSync } from "child_process";
970
1196
  import {
@@ -980,7 +1206,7 @@ import {
980
1206
  statSync,
981
1207
  writeFileSync
982
1208
  } from "fs";
983
- import { basename as basename2, dirname, join as join2, parse, sep } from "path";
1209
+ import { basename, dirname, join as join2, parse, sep } from "path";
984
1210
  import { fileURLToPath } from "url";
985
1211
  var PACKAGE_NAME = "@allocator-one/harness-cli";
986
1212
  var PACKAGE_NAMES = [PACKAGE_NAME, OPERATOR_PACKAGE];
@@ -1053,7 +1279,7 @@ function enclosingNodeModules(packageRoot) {
1053
1279
  let dir = dirname(packageRoot);
1054
1280
  const { root } = parse(dir);
1055
1281
  for (; ; ) {
1056
- if (basename2(dir) === "node_modules") return dir;
1282
+ if (basename(dir) === "node_modules") return dir;
1057
1283
  if (dir === root) return null;
1058
1284
  dir = dirname(dir);
1059
1285
  }
@@ -1299,8 +1525,8 @@ function npmInstallGlobal(install, stillHeld) {
1299
1525
  };
1300
1526
  }
1301
1527
  process.stderr.write(
1302
- `harness: upgrading ${install.packageName ?? PACKAGE_NAME} \u2026
1303
- `
1528
+ named(`harness: upgrading ${install.packageName ?? PACKAGE_NAME} \u2026
1529
+ `)
1304
1530
  );
1305
1531
  const args = [
1306
1532
  "install",
@@ -1368,8 +1594,8 @@ function reexecSelf(env, argv2) {
1368
1594
  });
1369
1595
  if (result.error !== void 0) {
1370
1596
  process.stderr.write(
1371
- `harness: upgraded, but re-running failed: ${result.error.message}
1372
- `
1597
+ named(`harness: upgraded, but re-running failed: ${result.error.message}
1598
+ `)
1373
1599
  );
1374
1600
  return 1;
1375
1601
  }
@@ -1473,11 +1699,14 @@ function exitWith(code) {
1473
1699
  guardBrokenPipes();
1474
1700
  var program = new Command();
1475
1701
  var invokedAs = invokedName();
1702
+ function writeStderr(text) {
1703
+ return process.stderr.write(named(text, invokedAs));
1704
+ }
1476
1705
  program.name(invokedAs).description(
1477
1706
  invokedAs === "operator" ? "drive Operator from the terminal and any agent, with Harness issue tracking alongside (harness.infra.one)" : "issue tracking for humans and agents (harness.infra.one)"
1478
1707
  ).exitOverride().configureOutput({
1479
- writeOut: (text) => process.stdout.write(text),
1480
- writeErr: (text) => process.stderr.write(text)
1708
+ writeOut: (text) => process.stdout.write(named(text, invokedAs)),
1709
+ writeErr: (text) => writeStderr(text)
1481
1710
  });
1482
1711
  function integer(value) {
1483
1712
  if (!/^\d+$/.test(value))
@@ -1518,59 +1747,67 @@ function interactive() {
1518
1747
  const ci = process.env["CI"];
1519
1748
  return process.stdout.isTTY === true && (ci === void 0 || ci === "");
1520
1749
  }
1521
- async function withConfig(jsonFlag, mutating, run) {
1750
+ function canConfirmRepoLogin() {
1751
+ return process.stdin.isTTY === true && process.stderr.isTTY === true;
1752
+ }
1753
+ async function confirmRepoLogin(baseUrl, source, path) {
1754
+ if (source !== "repo-config" || isHostTrusted(baseUrl)) return true;
1755
+ const answer = await promptLine(
1756
+ `This repo's config (${path ?? ".harness-cli/config.json"}) targets ${baseUrl} \u2014 log in there? [y/N] `
1757
+ );
1758
+ if (answer.trim().toLowerCase() !== "y") {
1759
+ writeStderr(`harness: not logging in to ${baseUrl} \u2014 set HARNESS_API_URL to override the repo config
1760
+ `);
1761
+ return false;
1762
+ }
1763
+ trustHost(baseUrl);
1764
+ return true;
1765
+ }
1766
+ async function withConfig(jsonFlag, mutating, run2) {
1522
1767
  const resolved = resolveConfig();
1523
- for (const warning of resolved.warnings) process.stderr.write(warning + "\n");
1768
+ for (const warning of resolved.warnings) writeStderr(warning + "\n");
1524
1769
  if (resolved.kind === "error") {
1525
- process.stderr.write(`harness: ${resolved.message}
1770
+ writeStderr(`harness: ${resolved.message}
1526
1771
  `);
1527
1772
  return exitWith(2);
1528
1773
  }
1529
1774
  if (resolved.kind === "needs-login") {
1530
1775
  if (!interactive()) {
1531
- process.stderr.write(loginHelp(resolved.baseUrl));
1776
+ writeStderr(loginHelp(resolved.baseUrl));
1777
+ if (resolved.org !== void 0) writeStderr(`Choose organization ${resolved.org} when logging in.
1778
+ `);
1532
1779
  return exitWith(2);
1533
1780
  }
1534
- if (resolved.hostSource === "repo-config" && !isHostTrusted(resolved.baseUrl)) {
1535
- const answer = await promptLine(
1536
- `This repo's config (${resolved.repoConfigPath ?? ".harness-cli/config.json"}) targets ${resolved.baseUrl} \u2014 log in there? [y/N] `
1537
- );
1538
- if (answer.trim().toLowerCase() !== "y") {
1539
- process.stderr.write(
1540
- `harness: not logging in to ${resolved.baseUrl} \u2014 set HARNESS_API_URL to override the repo config
1541
- `
1542
- );
1543
- return exitWith(2);
1544
- }
1545
- trustHost(resolved.baseUrl);
1781
+ if (!await confirmRepoLogin(resolved.baseUrl, resolved.hostSource, resolved.repoConfigPath)) {
1782
+ return exitWith(2);
1546
1783
  }
1547
- const loginResult = await login(resolved.baseUrl, {});
1784
+ const loginResult = await login(resolved.baseUrl, resolved.org === void 0 ? {} : { org: resolved.org });
1548
1785
  if (!loginResult.ok) {
1549
- process.stderr.write(`harness: ${clean(loginResult.message)}
1786
+ writeStderr(`harness: ${clean(loginResult.message)}
1550
1787
  `);
1551
1788
  return exitWith(1);
1552
1789
  }
1553
- process.stderr.write(loginResult.human);
1790
+ writeStderr(loginResult.human);
1554
1791
  const again = resolveConfig();
1555
1792
  if (again.kind !== "ok") {
1556
- process.stderr.write(
1793
+ writeStderr(
1557
1794
  `harness: still not signed in to ${resolved.baseUrl} after logging in
1558
1795
  `
1559
1796
  );
1560
1797
  return exitWith(1);
1561
1798
  }
1562
- return execute(again.config, jsonFlag, mutating, run);
1799
+ return execute(again.config, jsonFlag, mutating, run2);
1563
1800
  }
1564
- return execute(resolved.config, jsonFlag, mutating, run);
1801
+ return execute(resolved.config, jsonFlag, mutating, run2);
1565
1802
  }
1566
- async function execute(config, jsonFlag, mutating, run) {
1803
+ async function execute(config, jsonFlag, mutating, run2) {
1567
1804
  const client = new Client(config);
1568
1805
  const hostNotice = mutating ? `\u2192 ${config.baseUrl}
1569
1806
  ` : "";
1570
1807
  try {
1571
- const output = await run({ client, config });
1572
- process.stderr.write(hostNotice);
1573
- if (output.notice !== void 0) process.stderr.write(output.notice);
1808
+ const output = await run2({ client, config });
1809
+ writeStderr(hostNotice);
1810
+ if (output.notice !== void 0) writeStderr(output.notice);
1574
1811
  process.stdout.write(jsonFlag ? json(output.json) : output.human);
1575
1812
  return exitWith(output.exitCode ?? (output.partial === true ? 1 : 0));
1576
1813
  } catch (error) {
@@ -1579,16 +1816,16 @@ async function execute(config, jsonFlag, mutating, run) {
1579
1816
  }
1580
1817
  async function fail(error, hostNotice) {
1581
1818
  if (error instanceof UsageError) {
1582
- process.stderr.write(error.message + "\n");
1819
+ writeStderr(error.message + "\n");
1583
1820
  return exitWith(2);
1584
1821
  }
1585
1822
  if (error instanceof LocalError) {
1586
- process.stderr.write(`harness: ${clean(error.message)}
1823
+ writeStderr(`harness: ${clean(error.message)}
1587
1824
  `);
1588
1825
  return exitWith(1);
1589
1826
  }
1590
1827
  if (error instanceof TransportError) {
1591
- process.stderr.write(
1828
+ writeStderr(
1592
1829
  hostNotice + `harness: cannot reach the API: ${clean(error.message)}
1593
1830
  `
1594
1831
  );
@@ -1598,21 +1835,21 @@ async function fail(error, hostNotice) {
1598
1835
  const healed = selfHeal();
1599
1836
  if (healed.kind === "upgraded") return exitWith(healed.code);
1600
1837
  const why = healed.kind === "failed" ? ` (tried to upgrade: ${clean(healed.reason)})` : "";
1601
- process.stderr.write(
1838
+ writeStderr(
1602
1839
  hostNotice + `harness: ${clean(error.message)} (HTTP 426)${why}
1603
1840
  `
1604
1841
  );
1605
1842
  return exitWith(1);
1606
1843
  }
1607
1844
  if (error instanceof HttpError) {
1608
- process.stderr.write(
1845
+ writeStderr(
1609
1846
  hostNotice + `harness: ${clean(error.message)} (HTTP ${error.status})${errorDetails(error.body)}
1610
1847
  `
1611
1848
  );
1612
1849
  return exitWith(1);
1613
1850
  }
1614
1851
  if (error instanceof CommandError) {
1615
- process.stderr.write(hostNotice + `harness: ${clean(error.message)}
1852
+ writeStderr(hostNotice + `harness: ${clean(error.message)}
1616
1853
  `);
1617
1854
  return exitWith(1);
1618
1855
  }
@@ -1880,32 +2117,37 @@ plan.command("create").description("create a plan from a JSON or YAML file (the
1880
2117
  return planCreate(ctx, readPlanFile(opts.file), { ...opts, team: team2 });
1881
2118
  });
1882
2119
  });
1883
- plan.command("show").description("a plan's status, budgets, revision, steps, edges and wait conditions").argument("[id]").option("--json", "emit JSON").action(async (id, opts) => {
2120
+ plan.command("list").description("a team's plans, newest first: root, status, held, revision and title").option("--team <key>", "the team whose issues root the plans (or HARNESS_TEAM)").option("--status <status>", PLAN_STATUSES.join(" | ")).option("--limit <n>", `maximum plans (default 50, at most ${PLAN_LIST_MAX_LIMIT}; there are no pages)`, integer).option("--json", "emit JSON").action(async (opts) => {
2121
+ requirePlanListArgs(opts);
2122
+ await withConfig(opts.json === true, false, (ctx) => planList(ctx, opts));
2123
+ });
2124
+ var PLAN_ARGUMENT = "the plan's id, or its root issue's identifier (e.g. FR-1)";
2125
+ plan.command("show").description("a plan's status, budgets, revision, steps, edges and wait conditions").argument("[plan]", PLAN_ARGUMENT).option("--json", "emit JSON").action(async (id, opts) => {
1884
2126
  await withConfig(opts.json === true, false, (ctx) => {
1885
- if (id === void 0) throw new UsageError("usage: harness plan show <plan-id>");
2127
+ if (id === void 0) throw new UsageError("usage: harness plan show <plan-id-or-root>");
1886
2128
  return planShow(ctx, id);
1887
2129
  });
1888
2130
  });
1889
- plan.command("edit").description("change step executors and keys, and budgets, from a JSON or YAML file").argument("[id]").option("--file <path>", 'the edit: .json is JSON, anything else YAML ("-" for stdin)').option("--json", "emit JSON").action(async (id, opts) => {
2131
+ plan.command("edit").description("change step executors and keys, and budgets, from a JSON or YAML file").argument("[plan]", PLAN_ARGUMENT).option("--file <path>", 'the edit: .json is JSON, anything else YAML ("-" for stdin)').option("--json", "emit JSON").action(async (id, opts) => {
1890
2132
  await withConfig(opts.json === true, true, (ctx) => {
1891
2133
  if (id === void 0 || opts.file === void 0) {
1892
- throw new UsageError("usage: harness plan edit <plan-id> --file <edits.json|edits.yaml>");
2134
+ throw new UsageError("usage: harness plan edit <plan-id-or-root> --file <edits.json|edits.yaml>");
1893
2135
  }
1894
- requirePlanId(id);
2136
+ requirePlanRef(id);
1895
2137
  return planEdit(ctx, id, readPlanFile(opts.file));
1896
2138
  });
1897
2139
  });
1898
- plan.command("release").description("release a plan as yourself: freeze its next revision and let its ready steps run").argument("[id]").option("--collaborator <who>", "a person of this organization (id or email) for the plan room (repeatable)", collect, []).option("--retry-budget <n>", "retries per failed Operator step", integer).option("--spend-budget-cents <n>", "the plan's spend budget; needed to release Operator steps", integer).option("--json", "emit JSON").action(
2140
+ plan.command("release").description("release a plan as yourself: freeze its next revision and let its ready steps run").argument("[plan]", PLAN_ARGUMENT).option("--collaborator <who>", "a person of this organization (id or email) for the plan room (repeatable)", collect, []).option("--retry-budget <n>", "retries per failed Operator step", integer).option("--spend-budget-cents <n>", "the plan's spend budget; needed to release Operator steps", integer).option("--json", "emit JSON").action(
1899
2141
  async (id, opts) => {
1900
2142
  await withConfig(opts.json === true, true, (ctx) => {
1901
- if (id === void 0) throw new UsageError("usage: harness plan release <plan-id>");
2143
+ if (id === void 0) throw new UsageError("usage: harness plan release <plan-id-or-root>");
1902
2144
  return planRelease(ctx, id, { ...opts, collaborators: opts.collaborator });
1903
2145
  });
1904
2146
  }
1905
2147
  );
1906
- plan.command("hold").description("pause every step of a plan until it is released again").argument("[id]").option("--json", "emit JSON").action(async (id, opts) => {
2148
+ plan.command("hold").description("pause every step of a plan until it is released again").argument("[plan]", PLAN_ARGUMENT).option("--json", "emit JSON").action(async (id, opts) => {
1907
2149
  await withConfig(opts.json === true, true, (ctx) => {
1908
- if (id === void 0) throw new UsageError("usage: harness plan hold <plan-id>");
2150
+ if (id === void 0) throw new UsageError("usage: harness plan hold <plan-id-or-root>");
1909
2151
  return planHold(ctx, id);
1910
2152
  });
1911
2153
  });
@@ -2040,6 +2282,32 @@ ${NOT_RETRIED}`
2040
2282
  await withConfig(opts.json === true, true, (ctx) => jobPause(ctx, id));
2041
2283
  });
2042
2284
  var NOT_IDEMPOTENT = "Not idempotent: it is sent once and never retried, not after a timeout or a 5xx either. Exit 3 means the request never reached the server, so nothing changed. When it may have, the outcome is unknown: it exits 1 and says so. Then check";
2285
+ program.command("run").description("start an Operator job from an outcome, as Run as job does in the app; --watch follows it").argument("[outcome...]").option("--budget <usd>", "US dollars the organization's monthly Operator budget must still have (admins only)", once("--budget")).option("--skill <name>", "run one of the organization's Operator skills, by invocation name (/name)", once("--skill")).option("--watch", "follow the job like `watch --through-waits`; it never approves").option("--json", "emit JSON").addHelpText(
2286
+ "after",
2287
+ `
2288
+ Starts a job for you in a new org chat of yours, under your own current rights.
2289
+ Prints the job id, its state and its workbench link, and the approval link
2290
+ while a change set waits for approval. Approving happens in the app; the CLI
2291
+ never approves. You can start 30 jobs an hour. ${NOT_IDEMPOTENT} \`jobs --mine\` for the new job before running it again.
2292
+
2293
+ With --watch the job is followed until it completes (exit 0) or fails or is
2294
+ cancelled (exit ${EXIT_JOB_ENDED}). When it waits for a person, the approval link is printed and the
2295
+ watch keeps waiting; Ctrl-C stops it (exit ${EXIT_INTERRUPTED}) and stderr says how to resume.
2296
+ `
2297
+ ).action(async (words, opts) => {
2298
+ const request = prepareRun(words.join(" "), { budget: opts.budget, skill: opts.skill });
2299
+ const json2 = opts.json === true;
2300
+ await withConfig(
2301
+ json2,
2302
+ true,
2303
+ (ctx) => run(ctx, request, {
2304
+ watch: opts.watch === true,
2305
+ json: json2,
2306
+ onLine: (line) => process.stdout.write(line),
2307
+ signal: opts.watch === true ? watchStopSignal() : void 0
2308
+ })
2309
+ );
2310
+ });
2043
2311
  function once(flag) {
2044
2312
  return (value, previous) => {
2045
2313
  if (previous !== void 0) throw new InvalidArgumentError(`${flag} given twice \u2014 pass it once`);
@@ -2275,15 +2543,15 @@ link.command("rm").argument("[issue]").argument("[id-or-url]").option("--json",
2275
2543
  });
2276
2544
  }
2277
2545
  );
2278
- var label = program.command("label").description("labels");
2279
- label.command("list").option("--json", "emit JSON").action(async (opts) => {
2546
+ var label2 = program.command("label").description("labels");
2547
+ label2.command("list").option("--json", "emit JSON").action(async (opts) => {
2280
2548
  await withConfig(
2281
2549
  opts.json === true,
2282
2550
  false,
2283
2551
  (ctx) => labelList(ctx)
2284
2552
  );
2285
2553
  });
2286
- label.command("create").argument("[name]").option("--color <color>", "label color").option("--json", "emit JSON").action(
2554
+ label2.command("create").argument("[name]").option("--color <color>", "label color").option("--json", "emit JSON").action(
2287
2555
  async (name, opts) => {
2288
2556
  await withConfig(
2289
2557
  opts.json === true,
@@ -2292,10 +2560,10 @@ label.command("create").argument("[name]").option("--color <color>", "label colo
2292
2560
  );
2293
2561
  }
2294
2562
  );
2295
- label.command("update").description("rename or recolor a label").argument("[id]").option("--name <name>", "new name").option("--color <color>", "swatch name (red, blue, \u2026) or a hex code").option("--json", "emit JSON").action(async (id, opts) => {
2563
+ label2.command("update").description("rename or recolor a label").argument("[id]").option("--name <name>", "new name").option("--color <color>", "swatch name (red, blue, \u2026) or a hex code").option("--json", "emit JSON").action(async (id, opts) => {
2296
2564
  await withConfig(opts.json === true, true, (ctx) => labelUpdate(ctx, id, opts));
2297
2565
  });
2298
- label.command("delete").description("delete a label (refused while issues carry it)").argument("[id]").option("--yes", "confirm the deletion").option("--json", "emit JSON").action(async (id, opts) => {
2566
+ label2.command("delete").description("delete a label (refused while issues carry it)").argument("[id]").option("--yes", "confirm the deletion").option("--json", "emit JSON").action(async (id, opts) => {
2299
2567
  await withConfig(opts.json === true, true, (ctx) => labelDelete(ctx, id, opts));
2300
2568
  });
2301
2569
  var team = program.command("team").description("teams");
@@ -2406,7 +2674,7 @@ program.command("skills").description("the organization's skills assigned to Ope
2406
2674
  await withConfig(opts.json === true, false, (ctx) => skillsList(ctx));
2407
2675
  });
2408
2676
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
2409
- const { startMcpServer } = await import("./server-O7CX2ZCC.js");
2677
+ const { startMcpServer } = await import("./server-F6YRKNC2.js");
2410
2678
  await startMcpServer(invokedAs);
2411
2679
  });
2412
2680
  var keys = program.command("keys").description("the org's provider API keys, handed to tooling without printing them").enablePositionalOptions();
@@ -2428,6 +2696,17 @@ keys.command("exec").description(
2428
2696
  (ctx) => exec(ctx, { command, override: opts.override === true, only })
2429
2697
  );
2430
2698
  });
2699
+ var review = program.command("review").description("a pull request's enforced Review Council gate, and a retry of its latest attempt");
2700
+ review.command("status").description("the gate's enforced and advisory status, and a failed latest attempt with what comes next").argument("<pr>", "owner/repo#<pr>, the pull request URL, or <pr> with --repo or this checkout's origin").option("--repo <owner/repo>", "the repository a bare pull request number belongs to").option("--json", "emit JSON").action(async (pr, opts) => {
2701
+ const ref = parsePullRequest(pr, opts.repo);
2702
+ await withConfig(opts.json === true, false, (ctx) => status(ctx, ref));
2703
+ });
2704
+ review.command("retry").description(
2705
+ "retry the latest gate attempt of the pull request's current head (needs reviews:write); the head, check and attempt are read from the gate status"
2706
+ ).argument("<pr>", "owner/repo#<pr>, the pull request URL, or <pr> with --repo or this checkout's origin").option("--repo <owner/repo>", "the repository a bare pull request number belongs to").option("--reason <text>", "why the attempt is retried, recorded with it").option("--json", "emit JSON").action(async (pr, opts) => {
2707
+ const ref = parsePullRequest(pr, opts.repo);
2708
+ await withConfig(opts.json === true, true, (ctx) => retry(ctx, ref, opts.reason));
2709
+ });
2431
2710
  program.command("login").description("sign in via the browser (infra.one session)").option("--url <url>", "host to sign in to").option("--no-browser", "print the URL and prompt for a pasted token instead").option(
2432
2711
  "--timeout <ms>",
2433
2712
  "how long to wait for the browser callback",
@@ -2436,34 +2715,52 @@ program.command("login").description("sign in via the browser (infra.one session
2436
2715
  async (opts) => {
2437
2716
  const repoLookup = findRepoConfig();
2438
2717
  for (const warning of repoLookup.warnings)
2439
- process.stderr.write(warning + "\n");
2718
+ writeStderr(warning + "\n");
2440
2719
  const repoUrl = repoLookup.config?.url;
2441
- const url = opts.url ?? blankToUndefined(process.env["HARNESS_API_URL"]) ?? repoUrl ?? storedLoginUrl() ?? DEFAULT_BASE_URL;
2720
+ const url = opts.url ?? blankToUndefined(process.env["HARNESS_API_URL"]) ?? repoUrl ?? (repoLookup.config?.org === void 0 ? storedLoginUrl() : void 0) ?? DEFAULT_BASE_URL;
2442
2721
  if (url === repoUrl && opts.url === void 0 && blankToUndefined(process.env["HARNESS_API_URL"]) === void 0) {
2443
- process.stderr.write(
2722
+ writeStderr(
2444
2723
  `harness: host ${url} comes from ${repoLookup.config?.path}
2445
2724
  `
2446
2725
  );
2447
2726
  }
2727
+ const repoSelected = opts.url === void 0 && blankToUndefined(process.env["HARNESS_API_URL"]) === void 0 && repoUrl !== void 0;
2728
+ if (repoSelected && !isHostTrusted(url)) {
2729
+ if (!canConfirmRepoLogin()) {
2730
+ writeStderr("harness: cannot confirm a repo-selected host without a terminal \u2014 pass --url explicitly\n");
2731
+ return exitWith(2);
2732
+ }
2733
+ if (!await confirmRepoLogin(url, "repo-config", repoLookup.config?.path)) return exitWith(2);
2734
+ }
2448
2735
  const options = { noBrowser: !opts.browser };
2736
+ if (repoLookup.config?.org !== void 0) options.org = repoLookup.config.org;
2449
2737
  if (opts.timeout !== void 0) options.timeoutMs = opts.timeout;
2450
2738
  await finishAuth(await login(url, options), opts.json === true);
2451
2739
  }
2452
2740
  );
2453
2741
  program.command("logout").description("revoke and forget the stored login").option("--json", "emit JSON").action(async (opts) => {
2454
- await finishAuth(await logout(), opts.json === true);
2742
+ const selected = resolveConfig({ ...process.env, HARNESS_API_TOKEN: void 0 });
2743
+ if (selected.kind === "error") {
2744
+ writeStderr(`harness: ${selected.message}
2745
+ `);
2746
+ return exitWith(2);
2747
+ }
2748
+ const path = selected.kind === "ok" ? selected.config.credentialsPath : selected.credentialsPath;
2749
+ await finishAuth(await logout(path === void 0 ? {} : { credentialsPath: path }), opts.json === true);
2455
2750
  });
2456
2751
  program.command("whoami").description("show the resolved host and credential source").option("--json", "emit JSON").action(async (opts) => {
2457
2752
  const resolved = resolveConfig();
2458
2753
  for (const warning of resolved.warnings)
2459
- process.stderr.write(warning + "\n");
2754
+ writeStderr(warning + "\n");
2460
2755
  if (resolved.kind === "error") {
2461
- process.stderr.write(`harness: ${resolved.message}
2756
+ writeStderr(`harness: ${resolved.message}
2462
2757
  `);
2463
2758
  return exitWith(2);
2464
2759
  }
2465
2760
  if (resolved.kind === "needs-login") {
2466
- process.stderr.write(loginHelp(resolved.baseUrl));
2761
+ writeStderr(loginHelp(resolved.baseUrl));
2762
+ if (resolved.org !== void 0) writeStderr(`Choose organization ${resolved.org} when logging in.
2763
+ `);
2467
2764
  return exitWith(2);
2468
2765
  }
2469
2766
  const source = blankToUndefined(process.env["HARNESS_API_TOKEN"]) !== void 0 ? "environment" : "stored login";
@@ -2472,7 +2769,7 @@ program.command("whoami").description("show the resolved host and credential sou
2472
2769
  result = await whoami(resolved.config, source);
2473
2770
  } catch (error) {
2474
2771
  if (!(error instanceof TransportError)) throw error;
2475
- process.stderr.write(`harness: cannot reach the API: ${clean(error.message)}
2772
+ writeStderr(`harness: cannot reach the API: ${clean(error.message)}
2476
2773
  `);
2477
2774
  return exitWith(3);
2478
2775
  }
@@ -2489,7 +2786,7 @@ async function finishAuth(result, json2) {
2489
2786
  process.stdout.write(json2 ? json(result.json) : result.human);
2490
2787
  return exitWith(0);
2491
2788
  }
2492
- process.stderr.write(`harness: ${clean(result.message)}
2789
+ writeStderr(`harness: ${clean(result.message)}
2493
2790
  `);
2494
2791
  return exitWith(1);
2495
2792
  }
@@ -2501,7 +2798,7 @@ if (argv[2] === UPDATE_CHECK_COMMAND) {
2501
2798
  }
2502
2799
  try {
2503
2800
  const notice = updateCheck();
2504
- if (notice !== null) process.stderr.write(notice);
2801
+ if (notice !== null) writeStderr(notice);
2505
2802
  } catch {
2506
2803
  }
2507
2804
  if (invokedAs === "operator") layoutOperatorHelp(program);
@@ -2513,7 +2810,7 @@ try {
2513
2810
  await program.parseAsync(argv);
2514
2811
  } catch (error) {
2515
2812
  if (error instanceof UsageError) {
2516
- process.stderr.write(error.message + "\n");
2813
+ writeStderr(error.message + "\n");
2517
2814
  await exitWith(2);
2518
2815
  }
2519
2816
  if (error instanceof CommanderError) {