@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.
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ CLI_PACKAGE,
4
+ CLI_VERSION,
5
+ invokedName,
6
+ named
7
+ } from "./chunk-FQWV5MIC.js";
2
8
 
3
9
  // src/core/errors.ts
4
10
  var HttpError = class extends Error {
@@ -52,14 +58,6 @@ var CommandError = class extends Error {
52
58
  }
53
59
  };
54
60
 
55
- // src/version.ts
56
- import { readFileSync } from "fs";
57
- var pkg = JSON.parse(
58
- readFileSync(new URL("../package.json", import.meta.url), "utf8")
59
- );
60
- var CLI_VERSION = pkg.version;
61
- var CLI_PACKAGE = pkg.name;
62
-
63
61
  // src/core/expert.ts
64
62
  var EXPERT_TOKEN_PREFIX = "pxc_";
65
63
  var EXPERT_ASSIGNMENTS_PATH = "/api/v1/expert/assignments";
@@ -93,9 +91,15 @@ var DEFAULT_MESSAGES = {
93
91
  404: "Not found",
94
92
  409: "Conflict",
95
93
  422: "Validation error",
96
- 426: "CLI too old for this API \u2014 upgrade: npm i -g @allocator-one/harness-cli@latest",
94
+ 426: `CLI too old for this API \u2014 upgrade: npm i -g ${CLI_PACKAGE}@latest`,
97
95
  429: "Rate limited \u2014 retry later"
98
96
  };
97
+ var API_VERSION_HEADER = "x-harness-api-version";
98
+ var STATS_PATH = "/api/v1/stats";
99
+ var SESSION_PATH = "/api/v1/session";
100
+ function isLoginCredential(token) {
101
+ return !token.startsWith("aone_") && !isExpertToken(token);
102
+ }
99
103
  var Client = class {
100
104
  constructor(config) {
101
105
  this.config = config;
@@ -153,16 +157,32 @@ var Client = class {
153
157
  throw new TransportError(transportMessage(error), transportCode(error));
154
158
  }
155
159
  }
160
+ /**
161
+ * A GET that also hands back the server's API version header, so a probe
162
+ * that verifies a credential reports the version without a second
163
+ * request. Errors map as for `get`.
164
+ */
165
+ async getVersioned(path2) {
166
+ const response = await this.raw("GET", path2, {});
167
+ const apiVersion = response.headers.get(API_VERSION_HEADER);
168
+ return { envelope: await this.json(response), apiVersion };
169
+ }
156
170
  /**
157
171
  * The server's API version, for `version`'s server side. The header rides
158
- * on every response — the versioning plug runs before auth — so even a
159
- * 401 answers. A pxc_ credential probes the one route it reaches, so the
160
- * probe is never a refusal in the server's log.
172
+ * on every response of a route the server has — the versioning plug runs
173
+ * before auth — so even a 401 answers. Each credential probes a route it
174
+ * reaches, so the probe is never a refusal in the server's log: a pxc_
175
+ * credential its assignments, a login credential of either kind (the
176
+ * Operator-only one reaches no tracker read) GET /api/v1/session, and an
177
+ * aone_ token the stats. A server older than GET /api/v1/session answers
178
+ * that route's 404 without the header, so the stats answer then.
161
179
  */
162
180
  async serverVersion() {
163
- const path2 = isExpertToken(this.config.token) ? EXPERT_ASSIGNMENTS_PATH : "/api/v1/stats";
164
- const response = await this.raw("GET", path2, {});
165
- return response.headers.get("x-harness-api-version") ?? "unknown";
181
+ const token = this.config.token;
182
+ const path2 = isExpertToken(token) ? EXPERT_ASSIGNMENTS_PATH : isLoginCredential(token) ? SESSION_PATH : STATS_PATH;
183
+ const version2 = (await this.raw("GET", path2, {})).headers.get(API_VERSION_HEADER);
184
+ if (version2 !== null || path2 !== SESSION_PATH) return version2 ?? "unknown";
185
+ return (await this.raw("GET", STATS_PATH, {})).headers.get(API_VERSION_HEADER) ?? "unknown";
166
186
  }
167
187
  // Only an explicitly configured actor is sent, and never over one the
168
188
  // caller already put in the body.
@@ -311,6 +331,7 @@ function issueDetail(issue, images = []) {
311
331
  ["Priority", issue["priority_label"]],
312
332
  ["Assignee", path(issue, "assignee", "name")],
313
333
  ["Team", path(issue, "team", "key")],
334
+ ["Plan", issuePlanLine(issue["plan"])],
314
335
  ["Labels", labelsLine(issue["labels"])],
315
336
  ["Blocked", blockedLine(issue)],
316
337
  ["Waiting", waitingLine(issue)],
@@ -715,6 +736,34 @@ function roleName(role) {
715
736
  return words.charAt(0).toUpperCase() + words.slice(1);
716
737
  }
717
738
  var LIVE_PLAN_STATUSES = ["released", "running", "needs_attention"];
739
+ function plansTable(plans) {
740
+ const rows = plans.map((plan) => [
741
+ str(plan["root"]) || "-",
742
+ str(plan["status"]) || "-",
743
+ plan["held"] === true ? "yes" : plan["held"] === false ? "no" : "-",
744
+ typeof plan["revision"] === "number" ? String(plan["revision"]) : "-",
745
+ truncate(plan["title"])
746
+ ]);
747
+ return table(["ROOT", "STATUS", "HELD", "REVISION", "TITLE"], rows);
748
+ }
749
+ function issuePlanLine(plan) {
750
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan)) return null;
751
+ const record = plan;
752
+ const root = clean(record["root"]);
753
+ if (root === "") return null;
754
+ const status = record["held"] === true ? "held" : clean(record["status"]);
755
+ const revision = record["revision"];
756
+ const parts = [root];
757
+ if (status !== "") parts.push(status);
758
+ if (typeof revision === "number") {
759
+ parts.push(`revision ${revision}`);
760
+ if (typeof record["in_revision"] === "boolean") parts.push(record["in_revision"] ? "in revision" : "not in revision");
761
+ } else {
762
+ parts.push("never released");
763
+ }
764
+ if (record["executor"] === "operator") parts.push("Operator step");
765
+ return parts.join(" \xB7 ");
766
+ }
718
767
  function planDetail(plan) {
719
768
  const steps = records(plan["steps"]);
720
769
  const fields = [
@@ -843,7 +892,7 @@ function holderText(holder) {
843
892
  function planHeldSection(heldSteps) {
844
893
  if (!Array.isArray(heldSteps) || heldSteps.length === 0) return "";
845
894
  return `
846
- Held by an edit until the plan is released again: ${heldSteps.map((step) => clean(step)).join(", ")}
895
+ Changed since the release: ${heldSteps.map((step) => clean(step)).join(", ")} (only Operator steps are held until the next release takes them in)
847
896
  `;
848
897
  }
849
898
  function indentLines(text) {
@@ -863,11 +912,32 @@ function changesetDetails(details) {
863
912
  function violationDetails(violations) {
864
913
  return records(violations).map((violation) => {
865
914
  const code = clean(violation["code"]);
866
- const message = clean(violation["message"]) || code || "invalid";
915
+ const message = capMessage(violation) ?? (clean(violation["message"]) || code || "invalid");
867
916
  return `
868
917
  ${violationSubject(violation)}: ${message}${code === "" ? "" : ` [${code}]`}`;
869
918
  }).join("");
870
919
  }
920
+ var MAX_PLAN_STEPS_CEILING = 2e3;
921
+ function capMessage(violation) {
922
+ if (violation["code"] !== "too_many_steps") return null;
923
+ const cap = violation["cap"];
924
+ if (typeof cap !== "object" || cap === null || Array.isArray(cap)) return null;
925
+ const { kind, max, count, ceiling: served } = cap;
926
+ if (!Number.isInteger(max) || !Number.isInteger(count)) return null;
927
+ if (kind === "operator_steps") {
928
+ return `the plan has ${count} Operator steps, above the fixed limit of ${max}: Operator's delegated grant caps a plan's agent steps, and no setting raises it \u2014 hand some to people (executor none) or split the plan`;
929
+ }
930
+ if (kind === "plan_steps") {
931
+ const ceiling = Number.isInteger(served) ? served : MAX_PLAN_STEPS_CEILING;
932
+ const over = `the plan has ${count} steps, above this organization's max plan steps (${max})`;
933
+ if (count > ceiling && max < ceiling) {
934
+ return `${over}, and above the ceiling of ${ceiling} that no setting raises past \u2014 split the plan`;
935
+ }
936
+ if (max >= ceiling) return `${over}, which is already at its ceiling of ${ceiling} \u2014 split the plan`;
937
+ return `${over}: a Harness admin can raise "Max plan steps" in Harness settings \u2192 Plans (max_plan_steps, up to ${ceiling})`;
938
+ }
939
+ return null;
940
+ }
871
941
  function violationSubject(violation) {
872
942
  const key = typeof violation["key"] === "string" ? clean(violation["key"]) : null;
873
943
  const index = typeof violation["index"] === "number" ? `steps[${violation["index"]}]` : null;
@@ -966,7 +1036,7 @@ function path(record, ...keys) {
966
1036
  }
967
1037
 
968
1038
  // src/commands/issues.ts
969
- import { openAsBlob, openSync, closeSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
1039
+ import { openAsBlob, openSync, closeSync, readFileSync, statSync as statSync2 } from "fs";
970
1040
  import { basename, extname } from "path";
971
1041
 
972
1042
  // src/core/attachments.ts
@@ -1165,6 +1235,9 @@ var UUID_FORMAT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
1165
1235
  function isUuid(value) {
1166
1236
  return UUID_FORMAT.test(value);
1167
1237
  }
1238
+ function isIdentifier(value) {
1239
+ return IDENTIFIER_FORMAT.test(value);
1240
+ }
1168
1241
  function requireIdentifiers(identifiers) {
1169
1242
  const bad = identifiers.filter((identifier) => !IDENTIFIER_FORMAT.test(identifier));
1170
1243
  if (bad.length > 0) {
@@ -1198,7 +1271,7 @@ function requireReadyClaimArgs(opts) {
1198
1271
  async function claimNext(ctx, opts) {
1199
1272
  requireReadyClaimArgs(opts);
1200
1273
  const team = requiredTeam(ctx, opts.team);
1201
- const check = `check \`harness list --team ${team} --assignee me --status in_progress\``;
1274
+ const check = `check \`${invokedName()} list --team ${team} --assignee me --status in_progress\``;
1202
1275
  const unreadable = (why) => new CommandError(`The claim-next response ${why}, so an issue may have been claimed \u2014 ${check}.`);
1203
1276
  let body;
1204
1277
  try {
@@ -1356,14 +1429,14 @@ async function prime(ctx) {
1356
1429
  const statsBody = await ctx.client.get("/api/v1/stats");
1357
1430
  const ready2 = markAgentSteps(await mostImportantReady(ctx));
1358
1431
  const json2 = { stats: statsBody.data, ready: ready2 };
1359
- const human = "# Harness workflow context\n\n" + stats(statsBody.data) + "\nReady to work:\n" + issuesTable(ready2, { markAgentSteps: true }) + `
1432
+ const human = "# Harness workflow context\n\n" + stats(statsBody.data) + "\nReady to work:\n" + issuesTable(ready2, { markAgentSteps: true }) + named(`
1360
1433
  Workflow: harness ready \u2192 harness update <id> --claim \u2192 work \u2192
1361
1434
  harness close <id> --reason "Implemented in <commit-hash>" (response
1362
1435
  lists newly unblocked issues \u2014 surface them).
1363
1436
  Create tracked work BEFORE coding: harness create --title "\u2026" --type task.
1364
1437
  Dependencies: harness dep add <issue> <blocker> (blocker blocks issue).
1365
1438
  Before reporting done: close the issues you finished.
1366
- `;
1439
+ `);
1367
1440
  return { json: json2, human };
1368
1441
  }
1369
1442
  async function mostImportantReady(ctx) {
@@ -1381,7 +1454,7 @@ async function version(config) {
1381
1454
  } else {
1382
1455
  server = await new Client(config).serverVersion().catch(() => "unreachable");
1383
1456
  }
1384
- return { json: { cli: CLI_VERSION, server }, human: `harness ${CLI_VERSION} (server API ${server})
1457
+ return { json: { cli: CLI_VERSION, server }, human: `${invokedName()} ${CLI_VERSION} (server API ${server})
1385
1458
  ` };
1386
1459
  }
1387
1460
  async function create(ctx, opts) {
@@ -1494,18 +1567,42 @@ ${opts.appendNotes}`;
1494
1567
  return { json: issue, human };
1495
1568
  }
1496
1569
  var CLAIM_REFUSAL_HINTS = {
1497
- // The edit holds a released step, so the plan needs releasing again before
1498
- // the step is claimable.
1499
- agent_step: () => "Operator runs it for its plan; to do it yourself, set its executor to none (harness plan edit <plan-id>) and release the plan again",
1500
- waiting_on_condition: (identifier) => `harness show ${identifier} lists what it waits on; tick a gate with harness gate satisfy ${identifier} <gate>`
1570
+ // A person never claims an Operator step, so releasing the plan is not the
1571
+ // fix: handing the step to a person is, and a handed-over Operator step
1572
+ // stays frozen until the next release.
1573
+ agent_step: (_identifier, plan) => {
1574
+ const runs = plan.kind === "sole" ? `plan ${plan.root}` : plan.kind === "nearest" ? `its plans (several govern it; the nearest is ${plan.root})` : "its plan";
1575
+ const target = plan.kind === "sole" ? plan.root : "<plan>";
1576
+ return `a person can't claim an Operator step: Operator runs it for ${runs}. To do it yourself, set its executor to none (${invokedName()} plan edit ${target}) and release the plan again`;
1577
+ },
1578
+ plan_not_released: (_identifier, plan) => planReleaseHint(plan),
1579
+ not_in_plan_revision: (_identifier, plan) => planReleaseHint(plan),
1580
+ edited_since_release: (_identifier, plan) => planReleaseHint(plan),
1581
+ waiting_on_condition: (identifier) => `${invokedName()} show ${identifier} lists what it waits on; tick a gate with ${invokedName()} gate satisfy ${identifier} <gate>`
1501
1582
  };
1583
+ function planReleaseHint(plan) {
1584
+ if (plan.kind === "sole") return plan.release ? `run ${invokedName()} plan release ${plan.root}` : void 0;
1585
+ if (plan.kind === "nearest") {
1586
+ return `several plans govern it; the nearest is ${plan.root} (${invokedName()} plan show ${plan.root})`;
1587
+ }
1588
+ return void 0;
1589
+ }
1502
1590
  function explainClaimRefusal(error, identifier) {
1503
1591
  if (!(error instanceof HttpError) || error.status !== 409) return error;
1504
1592
  const reason = error.body["reason"];
1505
- const hint = typeof reason === "string" && Object.hasOwn(CLAIM_REFUSAL_HINTS, reason) ? CLAIM_REFUSAL_HINTS[reason](identifier) : void 0;
1593
+ const hint = typeof reason === "string" && Object.hasOwn(CLAIM_REFUSAL_HINTS, reason) ? CLAIM_REFUSAL_HINTS[reason](identifier, refusingPlan(error.body)) : void 0;
1506
1594
  if (hint === void 0) return error;
1507
1595
  return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
1508
1596
  }
1597
+ function refusingPlan(body) {
1598
+ const plan = body["plan"];
1599
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan)) return { kind: "none" };
1600
+ const root = plan["root"];
1601
+ if (typeof root !== "string" || !isIdentifier(root)) return { kind: "none" };
1602
+ const message = typeof body["message"] === "string" ? body["message"] : "";
1603
+ if (!new RegExp(`\\bplan ${root}\\b`).test(message)) return { kind: "nearest", root };
1604
+ return { kind: "sole", root, release: new RegExp(`\\brelease plan ${root}\\b`).test(message) };
1605
+ }
1509
1606
  function requireCloseArgs(identifiers, opts) {
1510
1607
  if (identifiers.length === 0) {
1511
1608
  throw new UsageError(
@@ -1604,9 +1701,9 @@ async function archive(ctx, identifiers, opts) {
1604
1701
  }
1605
1702
  async function comments(ctx, identifier) {
1606
1703
  requireIdentifiers([identifier]);
1607
- const body = await ctx.client.get(`/api/v1/issues/${identifier}/comments`);
1608
- if (body.data.length === 0) return { json: body.data, human: "No comments.\n" };
1609
- const human = body.data.map((comment2) => {
1704
+ const records5 = await completeCommentThread(ctx, identifier);
1705
+ if (records5.length === 0) return { json: records5, human: "No comments.\n" };
1706
+ const human = records5.map((comment2) => {
1610
1707
  const author = clean((comment2["author"] ?? {})["name"] ?? "unknown");
1611
1708
  const id = clean(comment2["id"] ?? "unknown id");
1612
1709
  const text = cleanMultiline(str2(comment2["body"])).split("\n").map((line) => " " + line).join("\n");
@@ -1614,7 +1711,35 @@ async function comments(ctx, identifier) {
1614
1711
  ${text}
1615
1712
  `;
1616
1713
  }).join("\n");
1617
- return { json: body.data, human };
1714
+ return { json: records5, human };
1715
+ }
1716
+ async function completeCommentThread(ctx, identifier) {
1717
+ const pages = [];
1718
+ const seen = /* @__PURE__ */ new Set();
1719
+ let before;
1720
+ for (; ; ) {
1721
+ const page = await ctx.client.get(`/api/v1/issues/${identifier}/comments`, {
1722
+ limit: 200,
1723
+ before
1724
+ });
1725
+ if (!Array.isArray(page.data) || typeof page.meta?.has_more !== "boolean") {
1726
+ throw new CommandError("Invalid comment pagination: expected a comment array and boolean meta.has_more; completeness unknown.");
1727
+ }
1728
+ const records5 = [];
1729
+ for (const record of page.data) {
1730
+ if (record === null || typeof record !== "object" || Array.isArray(record) || typeof record.id !== "string" || record.id.trim() === "" || seen.has(record.id)) {
1731
+ throw new CommandError("Invalid comment pagination: missing or repeated comment id; completeness unknown.");
1732
+ }
1733
+ seen.add(record.id);
1734
+ records5.push(record);
1735
+ }
1736
+ pages.push(records5);
1737
+ if (!page.meta.has_more) return pages.reverse().flat();
1738
+ if (records5.length === 0) {
1739
+ throw new CommandError("Invalid comment pagination: has_more on an empty page; completeness unknown.");
1740
+ }
1741
+ before = records5[0]["id"];
1742
+ }
1618
1743
  }
1619
1744
  async function dep(ctx, verb, issue, blocker) {
1620
1745
  requireIdentifiers([issue, blocker]);
@@ -1764,7 +1889,7 @@ async function attachFile(ctx, path2) {
1764
1889
  }
1765
1890
  async function uploadAttachment(ctx, path2, maxBytes) {
1766
1891
  attachableFile(path2, maxBytes);
1767
- const file = maxBytes === MAX_VIDEO_BYTES ? new File([await openAsBlob(path2)], basename(path2)) : new File([readFileSync2(path2)], basename(path2));
1892
+ const file = maxBytes === MAX_VIDEO_BYTES ? new File([await openAsBlob(path2)], basename(path2)) : new File([readFileSync(path2)], basename(path2));
1768
1893
  requireAttachmentSize(path2, file.size, maxBytes);
1769
1894
  const form = new FormData();
1770
1895
  form.set("file", file);
@@ -1871,13 +1996,13 @@ async function prepareApplicableLabelIds(ctx, teamId, names) {
1871
1996
  }
1872
1997
  function applicableLabel(labels, input, teamId) {
1873
1998
  const normalized = normalizeLabelName(input);
1874
- const named = labels.filter((label) => normalizeLabelName(label["name"]) === normalized);
1875
- const teamMatches = named.filter((label) => label["team_id"] === teamId);
1999
+ const named2 = labels.filter((label) => normalizeLabelName(label["name"]) === normalized);
2000
+ const teamMatches = named2.filter((label) => label["team_id"] === teamId);
1876
2001
  if (teamMatches.length > 1) {
1877
2002
  throw new CommandError(`more than one team label matches "${input}"; use a label id`);
1878
2003
  }
1879
2004
  if (teamMatches.length === 1) return teamMatches[0];
1880
- const workspaceMatches = named.filter(
2005
+ const workspaceMatches = named2.filter(
1881
2006
  (label) => label["team_id"] === null || label["team_id"] === void 0
1882
2007
  );
1883
2008
  if (workspaceMatches.length > 1) {
@@ -2003,8 +2128,8 @@ function listParams(ctx, opts) {
2003
2128
  }
2004
2129
  function optionalPriority(value) {
2005
2130
  if (value === void 0) return null;
2006
- const named = Object.hasOwn(PRIORITY_NAMES, value) ? PRIORITY_NAMES[value] : void 0;
2007
- if (named !== void 0) return named;
2131
+ const named2 = Object.hasOwn(PRIORITY_NAMES, value) ? PRIORITY_NAMES[value] : void 0;
2132
+ if (named2 !== void 0) return named2;
2008
2133
  if (/^\d+$/.test(value)) {
2009
2134
  throw new UsageError(
2010
2135
  `Bare numeric priorities are refused: bd and harness disagree on what
@@ -2518,7 +2643,7 @@ var APPROVAL_NOTE = "Write and send capabilities, and any proposal, need a perso
2518
2643
  var REFUSALS = {
2519
2644
  operator_disabled: "nothing here works until it is switched back on",
2520
2645
  operator_not_permitted: "this account has no admin or member role in the organization the credential is bound to",
2521
- actor_required: "this credential acts as no person: sign in with `harness login`, or use an API token minted for a user",
2646
+ actor_required: `this credential acts as no person: sign in with \`${invokedName()} login\`, or use an API token minted for a user`,
2522
2647
  run_bound_credential: "a run-bound credential can't read Operator's catalog",
2523
2648
  forbidden: "Operator needs the operator:read scope (aone_ API tokens must be minted with it) and an admin or member role"
2524
2649
  };
@@ -2638,7 +2763,7 @@ function requireCapabilityId(id) {
2638
2763
  throw new UsageError(`not a capability id: ${clean(id)} (e.g. crm.party.search)`);
2639
2764
  }
2640
2765
  if (id === "domains") {
2641
- throw new UsageError("domains is not a capability id \u2014 `harness tools domains` lists the domains");
2766
+ throw new UsageError(`domains is not a capability id \u2014 \`${invokedName()} tools domains\` lists the domains`);
2642
2767
  }
2643
2768
  }
2644
2769
  function requireSearchQuery(query) {
@@ -2995,9 +3120,9 @@ function jobPeople(job) {
2995
3120
  const participants = records3(field2(job, "people", "participants"));
2996
3121
  const owner = isRecord2(job["owner"]) ? job["owner"] : null;
2997
3122
  const names = /* @__PURE__ */ new Map();
2998
- for (const named of owner === null ? participants : [...participants, owner]) {
2999
- if (typeof named["user_id"] === "string" && typeof named["name"] === "string" && named["name"] !== "") {
3000
- if (!names.has(named["user_id"])) names.set(named["user_id"], named["name"]);
3123
+ for (const named2 of owner === null ? participants : [...participants, owner]) {
3124
+ if (typeof named2["user_id"] === "string" && typeof named2["name"] === "string" && named2["name"] !== "") {
3125
+ if (!names.has(named2["user_id"])) names.set(named2["user_id"], named2["name"]);
3001
3126
  }
3002
3127
  }
3003
3128
  const person = (userId) => typeof userId === "string" && userId !== "" ? { user_id: userId, name: names.get(userId) ?? null } : null;
@@ -3090,7 +3215,8 @@ async function jobWatch(ctx, id, opts) {
3090
3215
  }
3091
3216
  }
3092
3217
  function resumeLine(id, after) {
3093
- return `harness: stopped watching job ${id}; resume with: harness watch ${id} --after ${after}
3218
+ const name = invokedName();
3219
+ return `${name}: stopped watching job ${id}; resume with: ${name} watch ${id} --after ${after}
3094
3220
  `;
3095
3221
  }
3096
3222
  function watchStopSignal(stdout = process.stdout, signals = process) {
@@ -3179,9 +3305,9 @@ async function eventsPage(ctx, id, after) {
3179
3305
  var REFUSAL_HINTS = {
3180
3306
  operator_disabled: "nothing can be read until Operator is switched back on for this organization",
3181
3307
  operator_not_permitted: "only the organization's admins and members use Operator; guests and people outside it cannot",
3182
- actor_required: "the credential acts as no person: sign in with `harness login`, or use an API token a person created",
3183
- forbidden: "the credential lacks the operator:read scope (`harness login` carries it; an aone_ token must be created with it, bound to this organization), or you are a guest or outside the organization",
3184
- run_bound_credential: "a run-bound credential cannot read Operator jobs; sign in with `harness login`"
3308
+ actor_required: `the credential acts as no person: sign in with \`${invokedName()} login\`, or use an API token a person created`,
3309
+ forbidden: `the credential lacks the operator:read scope (\`${invokedName()} login\` carries it; an aone_ token must be created with it, bound to this organization), or you are a guest or outside the organization`,
3310
+ run_bound_credential: `a run-bound credential cannot read Operator jobs; sign in with \`${invokedName()} login\``
3185
3311
  };
3186
3312
  function missingJob(id) {
3187
3313
  return `no Operator job ${id} you can see: it does not exist, is in another organization, or is not shared with you (or this Harness server has no Operator API yet)`;
@@ -3567,10 +3693,10 @@ async function sendOnce(ctx, path2, body, write2) {
3567
3693
  throw error;
3568
3694
  }
3569
3695
  }
3570
- function explainRefusal(error, notFound2, hints) {
3696
+ function explainRefusal(error, notFound2, hints2) {
3571
3697
  if (!(error instanceof HttpError) || error instanceof UpgradeRequiredError) return error;
3572
3698
  const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
3573
- const own = Object.hasOwn(hints, code) ? hints[code] : void 0;
3699
+ const own = Object.hasOwn(hints2, code) ? hints2[code] : void 0;
3574
3700
  let hint;
3575
3701
  if (error.status >= 500) hint = own;
3576
3702
  else if (error.status === 404) hint = notFound2;
@@ -3578,10 +3704,132 @@ function explainRefusal(error, notFound2, hints) {
3578
3704
  else if (error.status === 403 && Object.hasOwn(ACCESS_HINTS, code)) hint = ACCESS_HINTS[code];
3579
3705
  else if (error.status === 400 && code === "bad_request") hint = BAD_REQUEST_HINT;
3580
3706
  if (hint === void 0) return error;
3581
- const message = error.status >= 500 && typeof error.body["message"] === "string" ? error.body["message"] : error.message;
3707
+ const message = error.status < 500 ? error.message : typeof error.body["message"] === "string" ? error.body["message"] : own !== void 0 ? `The server answered ${error.status}` : error.message;
3582
3708
  return new HttpError(error.status, `${message} \u2014 ${hint}`, error.body, error.retryAfter);
3583
3709
  }
3584
3710
 
3711
+ // src/commands/operator-run.ts
3712
+ var OUTCOME_MAX_CHARACTERS = 1e4;
3713
+ var BUDGET_MAX_DOLLARS = 1e6;
3714
+ var SKILL = /^\/?[a-z0-9]+(-[a-z0-9]+)*$/;
3715
+ var SKILL_MAX_CHARACTERS = 65;
3716
+ var BUDGET = /^\d+(\.\d{1,2})?$/;
3717
+ function prepareRun(outcome, opts = {}, surface = CLI_SURFACE) {
3718
+ const trimmed = outcome.trim();
3719
+ if (trimmed === "") throw new UsageError(`the outcome is empty \u2014 say what the job should achieve${surface.outcomeHint}`);
3720
+ const length = graphemeLength(trimmed);
3721
+ if (length > OUTCOME_MAX_CHARACTERS) {
3722
+ throw new UsageError(
3723
+ `the outcome is ${length} characters once trimmed; a job takes at most ${OUTCOME_MAX_CHARACTERS}`
3724
+ );
3725
+ }
3726
+ const body = { outcome: trimmed };
3727
+ if (opts.budget !== void 0) {
3728
+ const budget = opts.budget.trim();
3729
+ const amount = BUDGET.test(budget) ? Number(budget) : NaN;
3730
+ if (!(amount > 0) || amount > BUDGET_MAX_DOLLARS) {
3731
+ throw new UsageError(
3732
+ `${surface.budget} takes a positive amount in US dollars with at most two decimals, up to ${BUDGET_MAX_DOLLARS}`
3733
+ );
3734
+ }
3735
+ body["budget"] = amount;
3736
+ }
3737
+ if (opts.skill !== void 0) {
3738
+ const skill = opts.skill.trim();
3739
+ if (skill.length > SKILL_MAX_CHARACTERS || !SKILL.test(skill)) {
3740
+ throw new UsageError(
3741
+ `${surface.skill} takes a skill's invocation name, like /summarize-lps (${surface.skills} lists them)`
3742
+ );
3743
+ }
3744
+ body["skill"] = skill;
3745
+ }
3746
+ return { body };
3747
+ }
3748
+ var CLI_SURFACE = {
3749
+ budget: "--budget",
3750
+ skill: "--skill",
3751
+ outcomeHint: ': run "<outcome>"',
3752
+ skills: "`skills`",
3753
+ look: "`jobs --mine` for the new job",
3754
+ again: "run again"
3755
+ };
3756
+ var MCP_SURFACE = {
3757
+ budget: "budget",
3758
+ skill: "skill",
3759
+ outcomeHint: "",
3760
+ skills: "`skills_list`",
3761
+ look: "`jobs_list` with mine for the new job",
3762
+ again: "call again"
3763
+ };
3764
+ async function startRun(ctx, request, surface = CLI_SURFACE) {
3765
+ const write2 = { mayHave: "a job may have been started", check: surface.look };
3766
+ let data;
3767
+ try {
3768
+ data = (await sendOnce(ctx, "/api/v1/operator/jobs", request.body, write2)).data;
3769
+ } catch (error) {
3770
+ throw explainRefusal(error, "this Harness server has no Operator API for starting jobs yet", hints(error, surface));
3771
+ }
3772
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
3773
+ throw outcomeUnknown("the answer named no job", write2);
3774
+ }
3775
+ const record = data;
3776
+ const jobId = record["job_id"];
3777
+ if (typeof jobId !== "string" || jobId === "") throw outcomeUnknown("the answer named no job", write2);
3778
+ const text = (value) => typeof value === "string" ? value : "";
3779
+ return {
3780
+ job_id: jobId,
3781
+ state: text(record["state"]),
3782
+ workbench_url: text(record["workbench_url"]),
3783
+ approval_url: text(record["approval_url"]) === "" ? null : text(record["approval_url"])
3784
+ };
3785
+ }
3786
+ function hints(error, surface) {
3787
+ const retryAfter = error instanceof HttpError ? error.retryAfter : null;
3788
+ return {
3789
+ budget_not_permitted: `nothing was started: only the organization's admins, who manage its Operator budget, can pass ${surface.budget} \u2014 ${surface.again} without it`,
3790
+ budget_exceeded: `nothing was started: the organization's monthly Operator budget has less than ${surface.budget} left this month \u2014 pass a smaller budget, or ask an admin`,
3791
+ concurrent_change: `nothing was started: Operator or your access changed while the job started \u2014 check that you can still use Operator, then ${surface.again}`,
3792
+ invalid_outcome: `nothing was started: pass the outcome as text of 1 to ${OUTCOME_MAX_CHARACTERS} characters once trimmed, without control characters`,
3793
+ invalid_budget: `nothing was started: ${surface.budget} takes a positive amount in US dollars with at most two decimals, up to ${BUDGET_MAX_DOLLARS}`,
3794
+ unknown_skill: `nothing was started: no Operator skill of that name that you may run in the organization (${surface.skills} lists them)`,
3795
+ rate_limited: "nothing was started: you can start 30 jobs an hour this way" + (retryAfter === null ? "" : `; try again in ${clean(retryAfter)} s`),
3796
+ service_unavailable: "nothing was started; try again later"
3797
+ };
3798
+ }
3799
+ function startedText(job) {
3800
+ return `Started Operator job ${clean(job.job_id)}.
3801
+ ` + labelled([
3802
+ ["State", clean(job.state) || null],
3803
+ ["Workbench", clean(job.workbench_url) || null],
3804
+ ["Approve at", job.approval_url === null ? null : clean(job.approval_url)]
3805
+ ]) + (job.approval_url === null ? "" : "\nApproving happens in the app; the CLI cannot approve.\n");
3806
+ }
3807
+ async function run(ctx, request, opts = {}) {
3808
+ const job = await startRun(ctx, request);
3809
+ if (opts.watch !== true) return { json: job, human: startedText(job) };
3810
+ const json2 = opts.json === true;
3811
+ if (!json2) opts.onLine?.(startedText(job) + "\n");
3812
+ let watched;
3813
+ try {
3814
+ watched = await jobWatch(ctx, job.job_id, {
3815
+ throughWaits: true,
3816
+ collectEvents: json2,
3817
+ onLine: json2 ? void 0 : opts.onLine,
3818
+ signal: opts.signal,
3819
+ sleep: opts.sleep
3820
+ });
3821
+ } catch (error) {
3822
+ const follow = ` \u2014 the job was started: follow it with \`watch ${job.job_id}\``;
3823
+ if (error instanceof HttpError) {
3824
+ throw new HttpError(error.status, `${error.message}${follow}`, error.body, error.retryAfter);
3825
+ }
3826
+ if (error instanceof TransportError) throw new TransportError(`${error.message}${follow}`, error.code);
3827
+ if (error instanceof CommandError) throw new CommandError(`${error.message}${follow}`);
3828
+ throw error;
3829
+ }
3830
+ return { ...watched, json: { ...job, watch: watched.json } };
3831
+ }
3832
+
3585
3833
  // src/commands/operator-continue.ts
3586
3834
  async function jobContinue(ctx, id) {
3587
3835
  requireJobId(id);
@@ -3614,7 +3862,7 @@ function refusals(id) {
3614
3862
  }
3615
3863
 
3616
3864
  // src/core/textinput.ts
3617
- import { readFileSync as readFileSync3, statSync as statSync3 } from "fs";
3865
+ import { readFileSync as readFileSync2, statSync as statSync3 } from "fs";
3618
3866
  import { isatty } from "tty";
3619
3867
  var STDIN_PATH = "-";
3620
3868
  var MAX_TEXT_BYTES = 1e6;
@@ -3672,7 +3920,7 @@ function readTextFile(path2, option, limit = TEXT_LIMIT) {
3672
3920
  if (path2 === STDIN_PATH) {
3673
3921
  let piped;
3674
3922
  try {
3675
- piped = readFileSync3(0);
3923
+ piped = readFileSync2(0);
3676
3924
  } catch (error) {
3677
3925
  throw new UsageError(`${option} -: cannot read stdin (${describe(error)})`);
3678
3926
  }
@@ -3684,7 +3932,7 @@ function readTextFile(path2, option, limit = TEXT_LIMIT) {
3684
3932
  const flag = `${option} ${path2}`;
3685
3933
  let bytes;
3686
3934
  try {
3687
- bytes = readFileSync3(path2);
3935
+ bytes = readFileSync2(path2);
3688
3936
  } catch (error) {
3689
3937
  throw new UsageError(`${flag}: ${describe(error)}`);
3690
3938
  }
@@ -3882,14 +4130,14 @@ function requireProcedureName(name) {
3882
4130
  }
3883
4131
  async function procedureSave(ctx, jobId, name) {
3884
4132
  requireJobId(jobId);
3885
- const named = requireProcedureName(name);
4133
+ const named2 = requireProcedureName(name);
3886
4134
  const write2 = {
3887
4135
  mayHave: "a procedure may have been saved",
3888
4136
  check: `the job's procedures with \`job ${jobId}\``
3889
4137
  };
3890
4138
  let data;
3891
4139
  try {
3892
- data = (await sendOnce(ctx, `/api/v1/operator/jobs/${jobId}/procedures`, { name: named }, write2)).data;
4140
+ data = (await sendOnce(ctx, `/api/v1/operator/jobs/${jobId}/procedures`, { name: named2 }, write2)).data;
3893
4141
  } catch (error) {
3894
4142
  throw explainRefusal(error, missingJob(jobId), {
3895
4143
  owner_required: "only the job's owner can save it as a procedure; ask them to save it",
@@ -4102,6 +4350,9 @@ import { parseDocument } from "yaml";
4102
4350
  var PLAN_EXECUTORS = ["none", "operator"];
4103
4351
  var CONDITION_KINDS = ["not_before", "gate", "event"];
4104
4352
  var GATE_OWNER_ROLES = ["gp", "compliance_officer", "valuer", "accountant", "auditor", "counsel"];
4353
+ var PLAN_STATUSES = ["held", "released", "running", "completed", "needs_attention"];
4354
+ var PLAN_LIST_MAX_LIMIT = 100;
4355
+ var PLAN_LIST_DEFAULT_LIMIT = 50;
4105
4356
  function parsePlanDocument(text, source) {
4106
4357
  const parsed = extname2(source).toLowerCase() === ".json" ? parseJson(text, source) : parseYamlDocument(text, source);
4107
4358
  if (!isRecord5(parsed)) {
@@ -4160,17 +4411,48 @@ async function planCreate(ctx, plan, opts) {
4160
4411
  human: `Created plan ${clean(id)} (${clean(created["status"]) || "held"})
4161
4412
 
4162
4413
  ` + planDetail(created) + `
4163
- Nothing runs until it is released: harness plan release ${clean(id)}
4414
+ Nothing runs until it is released: ${invokedName()} plan release ${clean(id)}
4164
4415
  `
4165
4416
  };
4166
4417
  }
4418
+ function requirePlanListArgs(opts) {
4419
+ if (opts.status !== void 0 && !PLAN_STATUSES.includes(opts.status)) {
4420
+ throw new UsageError(`status must be one of: ${PLAN_STATUSES.join(", ")}`);
4421
+ }
4422
+ if (opts.limit !== void 0 && (!Number.isInteger(opts.limit) || opts.limit < 1 || opts.limit > PLAN_LIST_MAX_LIMIT)) {
4423
+ throw new UsageError(`--limit takes 1 to ${PLAN_LIST_MAX_LIMIT}: the server returns at most ${PLAN_LIST_MAX_LIMIT} plans`);
4424
+ }
4425
+ }
4426
+ async function planList(ctx, opts) {
4427
+ requirePlanListArgs(opts);
4428
+ const team = requiredTeam(ctx, opts.team);
4429
+ let body;
4430
+ try {
4431
+ body = await ctx.client.get(`/api/v1/teams/${team}/plans`, { status: opts.status, limit: opts.limit });
4432
+ } catch (error) {
4433
+ throw explainMissing(error, `no team ${team} here, or this Harness server cannot list plans yet`);
4434
+ }
4435
+ if (!Array.isArray(body.data)) throw new CommandError("The plan list response carried no list.");
4436
+ const list3 = body.data.filter(isRecord5);
4437
+ const limit = opts.limit ?? PLAN_LIST_DEFAULT_LIMIT;
4438
+ let notice;
4439
+ if (list3.length >= limit) {
4440
+ notice = limit < PLAN_LIST_MAX_LIMIT ? `harness: showing the newest ${list3.length} plans \u2014 pass --limit (up to ${PLAN_LIST_MAX_LIMIT}) for older ones.
4441
+ ` : `harness: showing the newest ${list3.length} plans, the most one list returns \u2014 narrow it with --status.
4442
+ `;
4443
+ }
4444
+ const what = opts.status === void 0 ? "plans" : `${opts.status} plans`;
4445
+ const human = list3.length === 0 ? `No ${what} in team ${team}.
4446
+ ` : plansTable(list3);
4447
+ return notice === void 0 ? { json: body.data, human } : { json: body.data, human, notice };
4448
+ }
4167
4449
  async function planShow(ctx, id) {
4168
- requirePlanId(id);
4450
+ requirePlanRef(id);
4169
4451
  const plan = await planRequest(id, () => ctx.client.get(`/api/v1/plans/${id}`));
4170
4452
  return { json: plan, human: planDetail(plan) };
4171
4453
  }
4172
4454
  async function planEdit(ctx, id, edits) {
4173
- requirePlanId(id);
4455
+ requirePlanRef(id);
4174
4456
  requireDocument(edits, "a plan edit is one object: steps to change and budgets");
4175
4457
  const plan = await planRequest(id, () => ctx.client.patch(`/api/v1/plans/${id}`, edits));
4176
4458
  const held = Array.isArray(plan["held_steps"]) && plan["held_steps"].length > 0;
@@ -4179,12 +4461,12 @@ async function planEdit(ctx, id, edits) {
4179
4461
  human: `Updated plan ${clean(id)}
4180
4462
 
4181
4463
  ` + planDetail(plan) + (held ? `
4182
- Release it again to run the held steps: harness plan release ${clean(id)}
4464
+ Only Operator steps are held until the next release; release the plan again to take in the changes: ${invokedName()} plan release ${clean(id)}
4183
4465
  ` : "")
4184
4466
  };
4185
4467
  }
4186
4468
  async function planRelease(ctx, id, opts) {
4187
- requirePlanId(id);
4469
+ requirePlanRef(id);
4188
4470
  const body = {};
4189
4471
  if (opts.collaborators !== void 0 && opts.collaborators.length > 0) body["collaborators"] = opts.collaborators;
4190
4472
  if (opts.retryBudget !== void 0) body["retry_budget"] = opts.retryBudget;
@@ -4199,7 +4481,7 @@ async function planRelease(ctx, id, opts) {
4199
4481
  };
4200
4482
  }
4201
4483
  async function planHold(ctx, id) {
4202
- requirePlanId(id);
4484
+ requirePlanRef(id);
4203
4485
  const plan = await planRequest(id, () => ctx.client.post(`/api/v1/plans/${id}/hold`));
4204
4486
  return {
4205
4487
  json: plan,
@@ -4212,25 +4494,41 @@ async function gateSet(ctx, issue, gate, satisfied) {
4212
4494
  const wanted = gate.trim();
4213
4495
  if (wanted === "") throw new UsageError("name the gate by its key, its label or its condition id");
4214
4496
  const expert = isExpertToken(ctx.config.token);
4215
- const list3 = await readConditions(ctx, issue, expert);
4216
- const target = findGate(list3, issue, wanted, expert);
4497
+ let list3 = null;
4498
+ if (!isUuid(wanted)) {
4499
+ try {
4500
+ list3 = await readConditions(ctx, issue, expert);
4501
+ } catch (error) {
4502
+ if (error instanceof HttpError && error.status === 403) {
4503
+ throw new HttpError(
4504
+ error.status,
4505
+ `${error.message} \u2014 find the gate by its condition id; only Harness staff can list an issue's conditions`,
4506
+ error.body,
4507
+ error.retryAfter
4508
+ );
4509
+ }
4510
+ throw error;
4511
+ }
4512
+ }
4513
+ const target = list3 === null ? { id: wanted.toLowerCase(), label: wanted.toLowerCase() } : findGate(list3, issue, wanted, expert);
4217
4514
  const id = str7(target["id"]);
4218
4515
  let body;
4219
4516
  try {
4220
4517
  body = await ctx.client.put(`/api/v1/issues/${issue}/conditions/${id}/satisfaction`, { satisfied });
4221
4518
  } catch (error) {
4222
- throw await explainNotGateOwner(ctx, error, target, issue, satisfied);
4519
+ throw await explainNotGateOwner(ctx, error, target, issue, satisfied, list3 !== null);
4223
4520
  }
4224
- const others = list3.filter((condition) => condition["id"] !== id && condition["satisfied"] !== true).length;
4521
+ const others = list3?.filter((condition) => condition["id"] !== id && condition["satisfied"] !== true).length ?? 0;
4225
4522
  let tail = "";
4226
- if (satisfied && expert) {
4523
+ if (satisfied && list3 !== null && expert) {
4227
4524
  tail = others > 0 ? ` \u2014 ${others} other gate${others === 1 ? "" : "s"} of yours on it ${others === 1 ? "is" : "are"} open` : "";
4228
- } else if (satisfied) {
4525
+ } else if (satisfied && list3 !== null) {
4229
4526
  tail = others > 0 ? ` \u2014 it still waits on ${others} other condition${others === 1 ? "" : "s"}` : " \u2014 no other wait condition holds it";
4230
4527
  }
4528
+ const label = list3 === null && isRecord5(body.data) ? body.data["label"] ?? id : target["label"];
4231
4529
  return {
4232
4530
  json: body.data,
4233
- human: `${satisfied ? "Satisfied" : "Un-satisfied"} gate "${clean(target["label"])}" on ${issue}${tail}
4531
+ human: `${satisfied ? "Satisfied" : "Un-satisfied"} gate "${clean(label)}" on ${issue}${tail}
4234
4532
  `
4235
4533
  };
4236
4534
  }
@@ -4264,8 +4562,10 @@ async function readConditions(ctx, issue, expert) {
4264
4562
  if (!Array.isArray(conditions)) throw new CommandError("The conditions response carried no list.");
4265
4563
  return conditions.filter(isRecord5);
4266
4564
  }
4267
- function requirePlanId(id) {
4268
- if (!isUuid(id)) throw new UsageError(`not a plan id: ${id} (expected the plan's UUID)`);
4565
+ function requirePlanRef(id) {
4566
+ if (!isUuid(id) && !isIdentifier(id)) {
4567
+ throw new UsageError(`not a plan: ${id} (expected the plan's id or its root issue's identifier, e.g. FR-1)`);
4568
+ }
4269
4569
  }
4270
4570
  function requireDocument(value, what) {
4271
4571
  if (!isRecord5(value)) throw new UsageError(what);
@@ -4274,18 +4574,21 @@ async function planRequest(id, send) {
4274
4574
  try {
4275
4575
  return (await send()).data;
4276
4576
  } catch (error) {
4277
- throw explainMissing(error, `no plan ${id} in this organization, or this Harness server has no plan API yet`);
4577
+ throw explainMissing(
4578
+ error,
4579
+ isUuid(id) ? `no plan ${id} in this organization, or this Harness server has no plan API yet` : `${id} roots no plan in this organization (${invokedName()} show ${id} names the plan an issue belongs to, by its root), or this Harness server opens plans by their id only`
4580
+ );
4278
4581
  }
4279
4582
  }
4280
4583
  function explainMissing(error, why) {
4281
4584
  if (!(error instanceof HttpError) || error.status !== 404) return error;
4282
4585
  return new HttpError(404, `${error.message} \u2014 ${why}`, error.body, error.retryAfter);
4283
4586
  }
4284
- async function explainNotGateOwner(ctx, error, gate, issue, satisfied) {
4587
+ async function explainNotGateOwner(ctx, error, gate, issue, satisfied, allowLookup) {
4285
4588
  if (!(error instanceof HttpError) || error.status !== 403 || error.body["reason"] !== "not_gate_owner") return error;
4286
4589
  let current;
4287
4590
  let currentOwner = error.body["owner"];
4288
- if (currentOwner !== null && !isRecord5(currentOwner)) {
4591
+ if (allowLookup && currentOwner !== null && !isRecord5(currentOwner)) {
4289
4592
  try {
4290
4593
  const fresh = (await ctx.client.get(`/api/v1/issues/${issue}/conditions`)).data;
4291
4594
  if (Array.isArray(fresh)) current = fresh.filter(isRecord5).find((condition) => condition["id"] === gate["id"]);
@@ -4295,30 +4598,24 @@ async function explainNotGateOwner(ctx, error, gate, issue, satisfied) {
4295
4598
  }
4296
4599
  const owner = isRecord5(currentOwner) ? ownerText(currentOwner) : "";
4297
4600
  const subject = `its owner${owner === "" ? "" : `, ${owner},`}`;
4298
- const named = `gate "${clean((current ?? gate)["label"])}" on ${issue}`;
4299
- const message = satisfied ? `Cannot satisfy ${named}: only ${subject} can tick it` : `Cannot un-satisfy ${named}: only ${subject} or Harness staff can untick it`;
4601
+ const named2 = `gate "${clean((current ?? gate)["label"])}" on ${issue}`;
4602
+ const message = satisfied ? `Cannot satisfy ${named2}: only ${subject} can tick it` : `Cannot un-satisfy ${named2}: only ${subject} or Harness staff can untick it`;
4300
4603
  return new HttpError(error.status, message, error.body, error.retryAfter);
4301
4604
  }
4302
4605
  var gateLabel = (condition) => str7(condition["label"]).trim();
4303
4606
  var gateKey = (condition) => str7(condition["key"]);
4304
4607
  function exactMatches(conditions, wanted) {
4305
- const id = isUuid(wanted) ? wanted.toLowerCase() : null;
4306
- return conditions.filter((condition) => {
4307
- if (id !== null && str7(condition["id"]).toLowerCase() === id) return true;
4308
- if (condition["kind"] !== "gate") return false;
4309
- const key = gateKey(condition);
4310
- return key !== "" && key === (id ?? wanted) || gateLabel(condition) === wanted;
4311
- });
4608
+ return conditions.filter(
4609
+ (condition) => condition["kind"] === "gate" && (gateKey(condition) !== "" && gateKey(condition) === wanted || gateLabel(condition) === wanted)
4610
+ );
4312
4611
  }
4313
4612
  function ambiguous(conditions, issue, wanted, matches) {
4314
4613
  const listed = matches.map((condition) => {
4315
4614
  const what = condition["kind"] === "gate" ? `gate ${gateName({ label: gateLabel(condition), key: gateKey(condition) })}` : `${clean(condition["kind"])} condition`;
4316
4615
  return `${clean(condition["id"])} (${what})`;
4317
4616
  }).join(", ");
4318
- const unnameable = matches.map((condition) => str7(condition["id"])).filter((id) => exactMatches(conditions, id).length !== 1).map((id) => clean(id));
4319
- const caveat = unnameable.length === 0 ? "" : ` (${unnameable.join(", ")} ${unnameable.length === 1 ? "is" : "are"} also another gate's key or label, so ${unnameable.length === 1 ? "it" : "they"} cannot be named by id)`;
4320
4617
  return new CommandError(
4321
- `several gates on ${issue} match "${wanted}": ${listed} \u2014 pass the condition id of the one you mean${caveat}`
4618
+ `several gates on ${issue} match "${wanted}": ${listed} \u2014 pass the condition id of the one you mean`
4322
4619
  );
4323
4620
  }
4324
4621
  function findGate(conditions, issue, wanted, expert = false) {
@@ -4326,11 +4623,6 @@ function findGate(conditions, issue, wanted, expert = false) {
4326
4623
  if (exact.length > 1) throw ambiguous(conditions, issue, wanted, exact);
4327
4624
  if (exact.length === 1) {
4328
4625
  const only = exact[0];
4329
- if (only["kind"] !== "gate") {
4330
- throw new CommandError(
4331
- `condition ${wanted} on ${issue} is a ${clean(only["kind"])} condition \u2014 only a gate is satisfied by hand`
4332
- );
4333
- }
4334
4626
  return only;
4335
4627
  }
4336
4628
  const gates = conditions.filter((condition) => condition["kind"] === "gate");
@@ -4340,7 +4632,6 @@ function findGate(conditions, issue, wanted, expert = false) {
4340
4632
  );
4341
4633
  if (loose.length > 1) throw ambiguous(conditions, issue, wanted, loose);
4342
4634
  if (loose.length === 1) return loose[0];
4343
- if (isUuid(wanted)) throw new CommandError(`No condition ${wanted} on ${issue}${expert ? " that you own" : ""}.`);
4344
4635
  const none = expert ? `No gate of yours on ${issue}` : `No gate on ${issue}`;
4345
4636
  if (gates.length === 0) {
4346
4637
  throw new CommandError(`${none} matches "${wanted}" \u2014 ${expert ? "you own no gate on it" : "it has no gates"}`);
@@ -4373,7 +4664,8 @@ function isRecord6(value) {
4373
4664
  }
4374
4665
 
4375
4666
  // src/core/credentials.ts
4376
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
4667
+ import { createHash } from "crypto";
4668
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
4377
4669
  import { homedir } from "os";
4378
4670
  import { dirname as dirname2, isAbsolute, join as join2 } from "path";
4379
4671
  function defaultCredentialsPath(env = process.env) {
@@ -4381,9 +4673,18 @@ function defaultCredentialsPath(env = process.env) {
4381
4673
  const base = xdg !== void 0 && xdg !== "" && isAbsolute(xdg) ? xdg : join2(homedir(), ".config");
4382
4674
  return join2(base, "harness", "credentials.json");
4383
4675
  }
4676
+ function orgCredentialsPath(path2, baseUrl, org) {
4677
+ const url = new URL(baseUrl);
4678
+ const host = url.origin + url.pathname.replace(/\/+$/, "");
4679
+ const key = createHash("sha256").update(JSON.stringify([path2, host, org.toLowerCase()])).digest("hex");
4680
+ return join2(dirname2(path2), `credentials-org-${key}.json`);
4681
+ }
4682
+ function validOrgId(org) {
4683
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(org);
4684
+ }
4384
4685
  function readCredentials(path2) {
4385
4686
  try {
4386
- const parsed = JSON.parse(readFileSync4(path2, "utf8"));
4687
+ const parsed = JSON.parse(readFileSync3(path2, "utf8"));
4387
4688
  if (typeof parsed === "object" && parsed !== null) {
4388
4689
  const { url, token } = parsed;
4389
4690
  if (typeof url === "string" && typeof token === "string") return { url, token };
@@ -4410,7 +4711,7 @@ function deleteCredentials(path2) {
4410
4711
  }
4411
4712
 
4412
4713
  // src/core/repo-config.ts
4413
- import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
4714
+ import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
4414
4715
  import { homedir as homedir2 } from "os";
4415
4716
  import { dirname as dirname3, isAbsolute as isAbsolute2, join as join3, parse } from "path";
4416
4717
  var CONFIG_RELATIVE_PATH = join3(".harness-cli", "config.json");
@@ -4429,7 +4730,7 @@ function findRepoConfig(startDir = process.cwd()) {
4429
4730
  function readRepoConfig(path2) {
4430
4731
  let parsed;
4431
4732
  try {
4432
- parsed = JSON.parse(readFileSync5(path2, "utf8"));
4733
+ parsed = JSON.parse(readFileSync4(path2, "utf8"));
4433
4734
  } catch (error) {
4434
4735
  const detail = error instanceof Error ? error.message : String(error);
4435
4736
  return { config: null, warnings: [`harness: ignoring malformed ${path2} (${detail})`] };
@@ -4458,7 +4759,7 @@ function trustedHostsPath(env = process.env) {
4458
4759
  }
4459
4760
  function readTrustedHosts(env) {
4460
4761
  try {
4461
- const parsed = JSON.parse(readFileSync5(trustedHostsPath(env), "utf8"));
4762
+ const parsed = JSON.parse(readFileSync4(trustedHostsPath(env), "utf8"));
4462
4763
  if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed["hosts"])) {
4463
4764
  return parsed["hosts"].filter(
4464
4765
  (host) => typeof host === "string"
@@ -4495,6 +4796,10 @@ function resolveConfig(env = process.env, credentialsPath, repo) {
4495
4796
  const envUrl = blankToNull(env["HARNESS_API_URL"]);
4496
4797
  const envToken = blankToNull(env["HARNESS_API_TOKEN"]);
4497
4798
  const repoUrl = repo?.url?.replace(/\/+$/, "") ?? null;
4799
+ const org = envToken === null ? repo?.org?.toLowerCase() : void 0;
4800
+ if (org !== void 0 && !validOrgId(org)) {
4801
+ return { kind: "error", message: `repo org in ${repo?.path} must be an organization UUID`, warnings };
4802
+ }
4498
4803
  let rawUrl;
4499
4804
  let hostSource;
4500
4805
  if (envUrl !== null) {
@@ -4513,6 +4818,9 @@ function resolveConfig(env = process.env, credentialsPath, repo) {
4513
4818
  } else if (repoUrl !== null) {
4514
4819
  rawUrl = repoUrl;
4515
4820
  hostSource = "repo-config";
4821
+ } else if (org !== void 0) {
4822
+ rawUrl = DEFAULT_BASE_URL;
4823
+ hostSource = "default";
4516
4824
  } else if (stored !== null) {
4517
4825
  rawUrl = stored.url;
4518
4826
  hostSource = "stored-login";
@@ -4523,13 +4831,16 @@ function resolveConfig(env = process.env, credentialsPath, repo) {
4523
4831
  const validated = validateBaseUrl(rawUrl);
4524
4832
  if (!validated.ok) return { kind: "error", message: validated.message, warnings };
4525
4833
  const baseUrl = rawUrl.replace(/\/+$/, "");
4834
+ const selectedPath = org === void 0 ? path2 : orgCredentialsPath(path2, baseUrl, org);
4835
+ const selected = org === void 0 ? stored : readCredentials(selectedPath);
4526
4836
  let token;
4527
4837
  if (envToken !== null) {
4528
4838
  token = envToken;
4529
- } else if (stored !== null && normalize(stored.url) !== null && normalize(stored.url) === normalize(baseUrl)) {
4530
- token = stored.token;
4839
+ } else if (selected !== null && normalize(selected.url) !== null && normalize(selected.url) === normalize(baseUrl)) {
4840
+ token = selected.token;
4531
4841
  } else {
4532
- const result = { kind: "needs-login", baseUrl, hostSource, warnings };
4842
+ const result = { kind: "needs-login", baseUrl, hostSource, credentialsPath: selectedPath, warnings };
4843
+ if (org !== void 0) result.org = org;
4533
4844
  if (hostSource === "repo-config" && repo?.path !== void 0) result.repoConfigPath = repo.path;
4534
4845
  return result;
4535
4846
  }
@@ -4543,7 +4854,8 @@ function resolveConfig(env = process.env, credentialsPath, repo) {
4543
4854
  // the --team flag at the command layer) stays authoritative.
4544
4855
  team: blankToNull(env["HARNESS_TEAM"]) ?? repo?.team ?? null,
4545
4856
  cacheDir: resolveCacheDir(env),
4546
- hostSource
4857
+ hostSource,
4858
+ credentialsPath: selectedPath
4547
4859
  },
4548
4860
  warnings
4549
4861
  };
@@ -4582,21 +4894,22 @@ function validateBaseUrl(baseUrl) {
4582
4894
  }
4583
4895
  if (parsed.protocol === "https:" && parsed.hostname !== "") return { ok: true };
4584
4896
  if (parsed.protocol === "http:") {
4585
- if (["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname)) return { ok: true };
4897
+ const host = parsed.hostname;
4898
+ if (["localhost", "127.0.0.1", "[::1]"].includes(host) || host.endsWith(".localhost")) return { ok: true };
4586
4899
  return {
4587
4900
  ok: false,
4588
- message: `refusing plain-http HARNESS_API_URL (${baseUrl}) \u2014 the API token would travel in cleartext. Use https, or http against localhost only.`
4901
+ message: `refusing plain-http HARNESS_API_URL (${baseUrl}) \u2014 the API token would travel in cleartext. Use https, or http against localhost, *.localhost, 127.0.0.1 or [::1] only.`
4589
4902
  };
4590
4903
  }
4591
4904
  return { ok: false, message: `HARNESS_API_URL must be an http(s) URL with a host, got: ${baseUrl}` };
4592
4905
  }
4593
- function loginHelp(baseUrl) {
4906
+ function loginHelp(baseUrl, name = invokedName()) {
4594
4907
  return `Not signed in to ${baseUrl}.
4595
4908
 
4596
4909
  Interactively, in a terminal (not from inside an agent \u2014 the browser
4597
4910
  tab needs someone to see it):
4598
4911
 
4599
- harness login
4912
+ ${name} login
4600
4913
 
4601
4914
  For agents and CI, mint an API token (admin \u2192 API tokens) with the
4602
4915
  harness:read scope \u2014 plus harness:write only where the workflow
@@ -4605,7 +4918,7 @@ creates or closes \u2014 set an expiry, and export it:
4605
4918
  export HARNESS_API_TOKEN=aone_...
4606
4919
 
4607
4920
  An outside expert uses the pxc_ credential minted in their expert view:
4608
- export it the same way, or paste it into \`harness login --no-browser\`.
4921
+ export it the same way, or paste it into \`${name} login --no-browser\`.
4609
4922
  `;
4610
4923
  }
4611
4924
  function blankToNull(value) {
@@ -4620,12 +4933,12 @@ export {
4620
4933
  UsageError,
4621
4934
  LocalError,
4622
4935
  CommandError,
4623
- CLI_VERSION,
4624
- CLI_PACKAGE,
4625
4936
  EXPERT_ASSIGNMENTS_PATH,
4626
4937
  isExpertToken,
4627
4938
  EXPERT_LAPSED,
4628
4939
  explainExpertRefusal,
4940
+ SESSION_PATH,
4941
+ isLoginCredential,
4629
4942
  Client,
4630
4943
  clean,
4631
4944
  json,
@@ -4719,6 +5032,12 @@ export {
4719
5032
  jobShow,
4720
5033
  jobWatch,
4721
5034
  watchStopSignal,
5035
+ OUTCOME_MAX_CHARACTERS,
5036
+ BUDGET_MAX_DOLLARS,
5037
+ prepareRun,
5038
+ MCP_SURFACE,
5039
+ startRun,
5040
+ run,
4722
5041
  jobContinue,
4723
5042
  resolveTextSources,
4724
5043
  STEER_MAX_CHARACTERS,
@@ -4736,17 +5055,23 @@ export {
4736
5055
  PLAN_EXECUTORS,
4737
5056
  CONDITION_KINDS,
4738
5057
  GATE_OWNER_ROLES,
5058
+ PLAN_STATUSES,
5059
+ PLAN_LIST_MAX_LIMIT,
4739
5060
  readPlanFile,
4740
5061
  planCreate,
5062
+ requirePlanListArgs,
5063
+ planList,
4741
5064
  planShow,
4742
5065
  planEdit,
4743
5066
  planRelease,
4744
5067
  planHold,
4745
5068
  gateSet,
4746
5069
  conditionList,
4747
- requirePlanId,
5070
+ requirePlanRef,
4748
5071
  assignments,
4749
5072
  defaultCredentialsPath,
5073
+ orgCredentialsPath,
5074
+ validOrgId,
4750
5075
  readCredentials,
4751
5076
  writeCredentials,
4752
5077
  deleteCredentials,