@allocator-one/harness-cli 1.14.0 → 1.15.1

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 {
@@ -20,10 +26,12 @@ var UpgradeRequiredError = class extends HttpError {
20
26
  }
21
27
  };
22
28
  var TransportError = class extends Error {
23
- constructor(message) {
29
+ constructor(message, code = null) {
24
30
  super(message);
31
+ this.code = code;
25
32
  this.name = "TransportError";
26
33
  }
34
+ code;
27
35
  };
28
36
  var MalformedResponseError = class extends TransportError {
29
37
  constructor(message) {
@@ -50,12 +58,25 @@ var CommandError = class extends Error {
50
58
  }
51
59
  };
52
60
 
53
- // src/version.ts
54
- import { readFileSync } from "fs";
55
- var pkg = JSON.parse(
56
- readFileSync(new URL("../package.json", import.meta.url), "utf8")
57
- );
58
- var CLI_VERSION = pkg.version;
61
+ // src/core/expert.ts
62
+ var EXPERT_TOKEN_PREFIX = "pxc_";
63
+ var EXPERT_ASSIGNMENTS_PATH = "/api/v1/expert/assignments";
64
+ function isExpertToken(token) {
65
+ return token.startsWith(EXPERT_TOKEN_PREFIX);
66
+ }
67
+ var EXPERT_REACH = "an outside expert's pxc_ credential only lists its own assignments and reads and ticks the gates it owns on them: assignments, conditions <issue> and gate satisfy|unsatisfy (assignment_list, condition_list and gate_set over MCP)";
68
+ var EXPERT_LAPSED = "the pxc_ credential is not valid here: it has expired (it lasts at most 30 days) or was revoked, its seat was revoked or has ended, the account is suspended, its TOTP enrolment changed, or it was minted for another host. If your seat is still open, mint a new one in your expert view";
69
+ var NOT_AN_EXPERT = "this credential is a member's login or an API token, not an outside expert's pxc_ credential";
70
+ function explainExpertRefusal(error, token) {
71
+ if (!(error instanceof HttpError) || error instanceof UpgradeRequiredError) return error;
72
+ const code = error.body["error"];
73
+ let hint;
74
+ if (error.status === 403 && code === "expert_bound_credential") hint = EXPERT_REACH;
75
+ else if (error.status === 403 && code === "expert_credential_required") hint = NOT_AN_EXPERT;
76
+ else if (error.status === 401 && isExpertToken(token)) hint = EXPERT_LAPSED;
77
+ if (hint === void 0) return error;
78
+ return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
79
+ }
59
80
 
60
81
  // src/core/client.ts
61
82
  var RECEIVE_TIMEOUT_MS = 3e4;
@@ -70,9 +91,15 @@ var DEFAULT_MESSAGES = {
70
91
  404: "Not found",
71
92
  409: "Conflict",
72
93
  422: "Validation error",
73
- 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`,
74
95
  429: "Rate limited \u2014 retry later"
75
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
+ }
76
103
  var Client = class {
77
104
  constructor(config) {
78
105
  this.config = config;
@@ -89,10 +116,15 @@ var Client = class {
89
116
  async get(path2, params) {
90
117
  return this.json(await this.raw("GET", path2, { params }));
91
118
  }
119
+ /**
120
+ * `actor: false` keeps a configured actor out of the body, for endpoints
121
+ * whose body is closed: they refuse a field they do not name, and record
122
+ * the credential's own person instead.
123
+ */
92
124
  async post(path2, body = {}, options = {}) {
93
125
  return this.json(
94
126
  await this.raw("POST", path2, {
95
- json: this.withActor(body),
127
+ json: options.actor === false ? body : this.withActor(body),
96
128
  idempotencyKey: options.idempotencyKey
97
129
  })
98
130
  );
@@ -122,17 +154,35 @@ var Client = class {
122
154
  try {
123
155
  return new Uint8Array(await response.arrayBuffer());
124
156
  } catch (error) {
125
- throw new TransportError(transportMessage(error));
157
+ throw new TransportError(transportMessage(error), transportCode(error));
126
158
  }
127
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
+ }
128
170
  /**
129
171
  * The server's API version, for `version`'s server side. The header rides
130
- * on every response — the versioning plug runs before auth — so even a
131
- * 401 answers.
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.
132
179
  */
133
180
  async serverVersion() {
134
- const response = await this.raw("GET", "/api/v1/stats", {});
135
- 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";
136
186
  }
137
187
  // Only an explicitly configured actor is sent, and never over one the
138
188
  // caller already put in the body.
@@ -168,7 +218,7 @@ var Client = class {
168
218
  redirect: "manual"
169
219
  });
170
220
  } catch (error) {
171
- throw new TransportError(transportMessage(error));
221
+ throw new TransportError(transportMessage(error), transportCode(error));
172
222
  }
173
223
  }
174
224
  async json(response) {
@@ -177,7 +227,7 @@ var Client = class {
177
227
  try {
178
228
  text = await response.text();
179
229
  } catch (error) {
180
- throw new TransportError(transportMessage(error));
230
+ throw new TransportError(transportMessage(error), transportCode(error));
181
231
  }
182
232
  if (text === "") return { data: void 0 };
183
233
  let parsed;
@@ -206,6 +256,17 @@ var Client = class {
206
256
  return new HttpError(response.status, message, parsed, response.headers.get("retry-after"));
207
257
  }
208
258
  };
259
+ function transportCode(error) {
260
+ const cause = error instanceof Error ? error.cause : void 0;
261
+ if (typeof cause !== "object" || cause === null) return null;
262
+ const code = cause.code;
263
+ if (typeof code === "string") return code;
264
+ const errors = cause.errors;
265
+ if (!Array.isArray(errors)) return null;
266
+ const codes = new Set(errors.map((inner) => inner?.code));
267
+ const [only] = codes;
268
+ return codes.size === 1 && typeof only === "string" ? only : null;
269
+ }
209
270
  function transportMessage(error) {
210
271
  if (error instanceof Error) {
211
272
  if (error.name === "TimeoutError" || error.name === "AbortError") return "request timed out";
@@ -302,6 +363,10 @@ function activityEntry(entry) {
302
363
  return activityStateEvent(entry);
303
364
  case "assignee_event":
304
365
  return activityAssigneeEvent(entry);
366
+ case "condition_event":
367
+ return activityConditionEvent(entry);
368
+ case "field_event":
369
+ return activityFieldEvent(entry);
305
370
  default:
306
371
  throw new CommandError(`Unknown activity entry kind: ${clean(entry["kind"])}`);
307
372
  }
@@ -314,7 +379,7 @@ function activityComment(entry) {
314
379
  if (typeof deleted !== "boolean") throw new CommandError("Invalid comment deleted flag.");
315
380
  const body = activityCommentBody(comment2["body"], deleted);
316
381
  const text = cleanMultiline(body).split("\n").map((line) => " " + line).join("\n");
317
- return `[${clean(requiredString(entry["occurred_at"], "activity occurrence time"))}] ${clean(author)} (${clean(requiredString(comment2["id"], "comment id"))}):
382
+ return `[${occurredAt(entry)}] ${clean(author)} (${clean(requiredString(comment2["id"], "comment id"))}):
318
383
  ${text}
319
384
  `;
320
385
  }
@@ -324,7 +389,7 @@ function activityCommentBody(value, deleted) {
324
389
  return requiredString(value, "comment body");
325
390
  }
326
391
  function activityStateEvent(entry) {
327
- const actor = activityActor(requiredRecord(entry["actor"], "activity actor"));
392
+ const actor = activityWho(entry);
328
393
  const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
329
394
  let description;
330
395
  switch (entry["action"]) {
@@ -340,11 +405,11 @@ function activityStateEvent(entry) {
340
405
  default:
341
406
  throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
342
407
  }
343
- return `[${clean(requiredString(entry["occurred_at"], "activity occurrence time"))}] ${description} \xB7 ${provenance}
408
+ return `[${occurredAt(entry)}] ${description} \xB7 ${provenance}
344
409
  `;
345
410
  }
346
411
  function activityAssigneeEvent(entry) {
347
- const actor = activityActor(requiredRecord(entry["actor"], "activity actor"));
412
+ const actor = activityWho(entry);
348
413
  const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
349
414
  let description;
350
415
  switch (entry["action"]) {
@@ -360,9 +425,130 @@ function activityAssigneeEvent(entry) {
360
425
  default:
361
426
  throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
362
427
  }
363
- return `[${clean(requiredString(entry["occurred_at"], "activity occurrence time"))}] ${description} - ${provenance}
428
+ return `[${occurredAt(entry)}] ${description} - ${provenance}
429
+ `;
430
+ }
431
+ function activityConditionEvent(entry) {
432
+ const actor = activityWho(entry);
433
+ const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
434
+ const condition = conditionSubject(requiredRecord(entry["condition"], "activity condition"));
435
+ let description;
436
+ switch (entry["action"]) {
437
+ case "added":
438
+ description = `${actor} added ${condition}`;
439
+ break;
440
+ case "changed":
441
+ description = `${actor} changed ${condition}`;
442
+ break;
443
+ case "removed":
444
+ description = `${actor} removed ${condition}`;
445
+ break;
446
+ case "satisfied":
447
+ description = `${actor} satisfied ${condition}`;
448
+ break;
449
+ case "unsatisfied":
450
+ description = `${actor} marked ${condition} unsatisfied`;
451
+ break;
452
+ default:
453
+ throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
454
+ }
455
+ return `[${occurredAt(entry)}] ${description} \xB7 ${provenance}
456
+ `;
457
+ }
458
+ function conditionSubject(condition) {
459
+ const label = clean(condition["label"]);
460
+ switch (condition["kind"]) {
461
+ case "gate":
462
+ return label === "" ? "the gate" : `the gate "${label}"`;
463
+ case "not_before": {
464
+ const at = clean(condition["not_before"]);
465
+ return at === "" ? "the not-before date" : `the not-before date ${at}`;
466
+ }
467
+ case "event":
468
+ return label === "" ? "the wait condition" : `the system event "${label}"`;
469
+ default:
470
+ return "the wait condition";
471
+ }
472
+ }
473
+ function activityFieldEvent(entry) {
474
+ const actor = activityWho(entry);
475
+ const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
476
+ const change = fieldChange(requiredString(entry["field"], "activity field"), entry["from"], entry["to"]);
477
+ return `[${occurredAt(entry)}] ${actor} ${change} \xB7 ${provenance}
364
478
  `;
365
479
  }
480
+ function fieldChange(field3, from, to) {
481
+ switch (field3) {
482
+ case "priority":
483
+ return `changed priority from ${priorityName(from)} to ${priorityName(to)}`;
484
+ case "title":
485
+ return `renamed the issue from \u201C${activityTitle(from)}\u201D to \u201C${activityTitle(to)}\u201D`;
486
+ case "description":
487
+ return "edited the description";
488
+ case "labels":
489
+ return labelsChange(to);
490
+ case "parent":
491
+ return parentChange(from, to);
492
+ case "archived":
493
+ if (typeof to !== "boolean") throw new CommandError("Invalid activity archived value.");
494
+ return to ? "archived the issue" : "unarchived the issue";
495
+ default:
496
+ return `changed the ${clean(field3)} field`;
497
+ }
498
+ }
499
+ var ACTIVITY_PRIORITIES = {
500
+ 0: "No priority",
501
+ 1: "Urgent",
502
+ 2: "High",
503
+ 3: "Medium",
504
+ 4: "Low"
505
+ };
506
+ function priorityName(value) {
507
+ if (typeof value !== "number" || !Number.isInteger(value)) throw new CommandError("Invalid activity priority.");
508
+ return ACTIVITY_PRIORITIES[value] ?? String(value);
509
+ }
510
+ function activityTitle(value) {
511
+ if (typeof value !== "string") throw new CommandError("Invalid activity title.");
512
+ return clean(value);
513
+ }
514
+ function labelsChange(to) {
515
+ const change = requiredRecord(to, "activity labels");
516
+ const added = labelNames(change["added"]);
517
+ const removed = labelNames(change["removed"]);
518
+ const parts = [
519
+ ...added.length > 0 ? [`added ${labelList(added)}`] : [],
520
+ ...removed.length > 0 ? [`removed ${labelList(removed)}`] : []
521
+ ];
522
+ return parts.length === 0 ? "changed the labels" : parts.join(" and ");
523
+ }
524
+ function labelNames(value) {
525
+ if (!Array.isArray(value)) throw new CommandError("Invalid activity labels.");
526
+ return value.map((label) => clean(requiredString(requiredRecord(label, "activity labels")["name"], "activity labels")));
527
+ }
528
+ function labelList(names) {
529
+ return `${names.length === 1 ? "label" : "labels"} ${names.join(", ")}`;
530
+ }
531
+ function parentChange(from, to) {
532
+ if (from === null && to === null) return "changed the parent";
533
+ if (from === null) return `set the parent to ${parentName(to)}`;
534
+ if (to === null) return `removed the parent ${parentName(from)}`;
535
+ return `changed the parent from ${parentName(from)} to ${parentName(to)}`;
536
+ }
537
+ function parentName(value) {
538
+ const parent = requiredRecord(value, "activity parent");
539
+ return optionalLabel(parent["identifier"], "an unknown issue", "activity parent identifier");
540
+ }
541
+ function activityWho(entry) {
542
+ const actor = activityActor(requiredRecord(entry["actor"], "activity actor"));
543
+ const label = entry["actor_label"];
544
+ if (label === void 0 || label === null || label === "") return actor;
545
+ if (typeof label !== "string") throw new CommandError("Invalid activity actor label.");
546
+ const cleaned = clean(label);
547
+ return cleaned === "" ? actor : `${actor} via \u201C${cleaned}\u201D`;
548
+ }
549
+ function occurredAt(entry) {
550
+ return clean(requiredString(entry["occurred_at"], "activity occurrence time"));
551
+ }
366
552
  function assigneeName(value) {
367
553
  const assignee = requiredRecord(value, "activity assignee");
368
554
  if (assignee["kind"] === "unknown") return "Unknown assignee";
@@ -392,24 +578,24 @@ function activityProvenance(provenance) {
392
578
  throw new CommandError(`Unknown activity provenance: ${clean(provenance["kind"])}`);
393
579
  }
394
580
  }
395
- function stateName(value, field2) {
396
- return optionalLabel(requiredRecord(value, field2)["name"], "Unknown state", `${field2} name`);
581
+ function stateName(value, field3) {
582
+ return optionalLabel(requiredRecord(value, field3)["name"], "Unknown state", `${field3} name`);
397
583
  }
398
- function nullableStateName(value, field2) {
399
- return value === null ? "Unknown state" : stateName(value, field2);
584
+ function nullableStateName(value, field3) {
585
+ return value === null ? "Unknown state" : stateName(value, field3);
400
586
  }
401
- function optionalLabel(value, fallback, field2) {
587
+ function optionalLabel(value, fallback, field3) {
402
588
  if (value === null || value === "") return fallback;
403
- return clean(requiredString(value, field2));
589
+ return clean(requiredString(value, field3));
404
590
  }
405
- function requiredRecord(value, field2) {
591
+ function requiredRecord(value, field3) {
406
592
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
407
- throw new CommandError(`Invalid ${field2}.`);
593
+ throw new CommandError(`Invalid ${field3}.`);
408
594
  }
409
595
  return value;
410
596
  }
411
- function requiredString(value, field2) {
412
- if (typeof value !== "string" || value === "") throw new CommandError(`Invalid ${field2}.`);
597
+ function requiredString(value, field3) {
598
+ if (typeof value !== "string" || value === "") throw new CommandError(`Invalid ${field3}.`);
413
599
  return value;
414
600
  }
415
601
  function stats(data) {
@@ -446,18 +632,25 @@ function waitingLine(issue) {
446
632
  return `yes, on ${open} of ${conditions.length} wait condition${conditions.length === 1 ? "" : "s"}`;
447
633
  }
448
634
  function conditionsSection(conditions) {
449
- const list2 = records(conditions);
450
- if (list2.length === 0) return "";
451
- return "\nWait conditions:\n" + list2.map((condition) => ` ${conditionLine(condition)}
635
+ const list3 = records(conditions);
636
+ if (list3.length === 0) return "";
637
+ return "\nWait conditions:\n" + list3.map((condition) => ` ${conditionLine(condition)}
452
638
  `).join("");
453
639
  }
454
- function conditionLine(condition) {
640
+ function conditionLine(condition, options = {}) {
455
641
  const satisfied = condition["satisfied"] === true;
456
642
  let what;
457
643
  switch (condition["kind"]) {
458
- case "gate":
459
- what = `gate "${clean(condition["label"])}"`;
644
+ case "gate": {
645
+ const owner = condition["owner"];
646
+ let ownedBy = "";
647
+ if (typeof owner === "object" && owner !== null) {
648
+ const record = owner;
649
+ ownedBy = options.expertView === true && record["kind"] === "expert" ? "you" : ownerText(record);
650
+ }
651
+ what = `gate ${gateName(condition)}${ownedBy === "" ? "" : ` owned by ${ownedBy}`}`;
460
652
  break;
653
+ }
461
654
  case "not_before":
462
655
  what = `not before ${clean(condition["not_before"])}`;
463
656
  break;
@@ -469,11 +662,78 @@ function conditionLine(condition) {
469
662
  }
470
663
  let when = "";
471
664
  if (satisfied && typeof condition["satisfied_at"] === "string") {
472
- const by = clean(path(condition, "satisfied_by", "name"));
473
- when = ` (satisfied ${clean(condition["satisfied_at"])}${by === "" ? "" : ` by ${by}`})`;
665
+ let by = clean(path(condition, "satisfied_by", "name"));
666
+ if (by === "" && condition["satisfied_by_kind"] === "expert") by = "an outside expert";
667
+ let role = typeof condition["satisfied_as_role"] === "string" ? roleName(condition["satisfied_as_role"]) : "";
668
+ switch (path(condition, "satisfied_by", "kind")) {
669
+ case "you":
670
+ by = "you";
671
+ break;
672
+ case "role": {
673
+ const as = path(condition, "satisfied_by", "role");
674
+ if (typeof as === "string" && as !== "") role = roleName(as);
675
+ break;
676
+ }
677
+ case "expert":
678
+ by = "an outside expert";
679
+ break;
680
+ case "member":
681
+ by = "a member";
682
+ break;
683
+ }
684
+ when = ` (satisfied ${clean(condition["satisfied_at"])}${by === "" ? "" : ` by ${by}`}${role === "" ? "" : ` as ${role}`})`;
474
685
  }
475
686
  return `${satisfied ? "[x]" : "[ ]"} ${what}${when} \xB7 ${clean(condition["id"])}`;
476
687
  }
688
+ function gateName(gate) {
689
+ const key = clean(gate["key"]);
690
+ return `"${clean(gate["label"])}"${key === "" ? "" : ` [${key}]`}`;
691
+ }
692
+ function ownerText(owner) {
693
+ const label = clean(owner["label"]) || (owner["kind"] === "role" && typeof owner["role"] === "string" ? roleName(owner["role"]) : "");
694
+ const ref = clean(owner["ref"]);
695
+ if (owner["kind"] === "expert") {
696
+ const email = label || ref.replace(/^expert:/, "");
697
+ return email === "" ? "" : `${email} (expert)`;
698
+ }
699
+ if (label === "") return ref;
700
+ return ref === "" || ref === label ? label : `${label} (${ref})`;
701
+ }
702
+ function expertSummary(seat) {
703
+ const role = typeof seat.role === "string" && seat.role !== "" ? roleName(seat.role) : "Outside expert";
704
+ const status = clean(seat.plan?.status);
705
+ const plan = seat.plan === null ? ", no plan" : ` on plan ${clean(seat.plan.id)}${status === "" ? "" : ` (${status})`}`;
706
+ const steps = seat.assigned_steps;
707
+ const count = steps === 0 ? "no assigned steps" : `${steps} assigned step${steps === 1 ? "" : "s"}`;
708
+ return `${role}${plan} \xB7 ${count}`;
709
+ }
710
+ function expertAssignments(steps) {
711
+ return steps.map((step) => "\n" + assignedStep(step)).join("");
712
+ }
713
+ function assignedStep(step) {
714
+ const blockers = typeof step["open_blockers"] === "number" ? step["open_blockers"] : 0;
715
+ const fields = [
716
+ ["State", path(step, "state", "name")],
717
+ ["Due", step["due_date"]],
718
+ ["Blocked", blockers > 0 ? `yes, by ${blockers} open issue${blockers === 1 ? "" : "s"}` : null]
719
+ ];
720
+ const fieldLines = fields.filter(([, value]) => value !== null && value !== void 0 && value !== "").map(([label, value]) => ` ${(label + ":").padEnd(12)}${clean(value)}
721
+ `).join("");
722
+ const gates = records(step["gates"]);
723
+ const gateLines = gates.length === 0 ? " Gates: none of yours\n" : " Gates:\n" + gates.map((gate) => ` ${conditionLine(gate, { expertView: true })}
724
+ `).join("");
725
+ const description = step["description"];
726
+ const descriptionLines = typeof description === "string" && description !== "" ? " Description:\n" + indent(indent(description)) + "\n" : "";
727
+ return `${clean(step["identifier"])} \xB7 ${clean(step["title"])}
728
+ ${fieldLines}${gateLines}${descriptionLines}`;
729
+ }
730
+ var ROLE_NAMES = { gp: "GP" };
731
+ function roleName(role) {
732
+ const slug = clean(role);
733
+ if (Object.hasOwn(ROLE_NAMES, slug)) return ROLE_NAMES[slug];
734
+ const words = slug.replace(/_/g, " ");
735
+ return words.charAt(0).toUpperCase() + words.slice(1);
736
+ }
477
737
  var LIVE_PLAN_STATUSES = ["released", "running", "needs_attention"];
478
738
  function planDetail(plan) {
479
739
  const steps = records(plan["steps"]);
@@ -483,10 +743,10 @@ function planDetail(plan) {
483
743
  ["Budgets", planBudgetsLine(plan)],
484
744
  ["Estimate", planEstimateLine(plan["estimate"])]
485
745
  ];
486
- const fieldLines = fields.filter((field2) => field2[1] !== null).map(([label, value]) => ` ${(label + ":").padEnd(12)}${value}
746
+ const fieldLines = fields.filter((field3) => field3[1] !== null).map(([label, value]) => ` ${(label + ":").padEnd(12)}${value}
487
747
  `).join("");
488
748
  return `Plan ${clean(plan["id"])} \xB7 ${clean(plan["status"])}
489
- ` + fieldLines + planStepsSection(steps, str(plan["status"])) + planEdgesSection(plan["edges"]) + planConditionsSection(steps) + planHeldSection(plan["held_steps"]);
749
+ ` + fieldLines + planStepsSection(steps, str(plan["status"])) + planEdgesSection(plan["edges"]) + planConditionsSection(steps) + planWaitingSection(steps) + planHeldSection(plan["held_steps"]);
490
750
  }
491
751
  function planRootLine(root) {
492
752
  if (typeof root !== "object" || root === null) return null;
@@ -560,9 +820,9 @@ function stepStatus(step, planStatus) {
560
820
  return "-";
561
821
  }
562
822
  function planEdgesSection(edges) {
563
- const list2 = records(edges);
564
- if (list2.length === 0) return "";
565
- return "\nEdges (blocker \u2192 blocked):\n" + list2.map((edge) => ` ${clean(edge["from"])} \u2192 ${clean(edge["to"])}
823
+ const list3 = records(edges);
824
+ if (list3.length === 0) return "";
825
+ return "\nEdges (blocker \u2192 blocked):\n" + list3.map((edge) => ` ${clean(edge["from"])} \u2192 ${clean(edge["to"])}
566
826
  `).join("");
567
827
  }
568
828
  function planConditionsSection(steps) {
@@ -574,6 +834,32 @@ function planConditionsSection(steps) {
574
834
  return "\nWait conditions:\n" + lines.map(([identifier, line]) => ` ${padGraphemes(identifier, width)} ${line}
575
835
  `).join("");
576
836
  }
837
+ function planWaitingSection(steps) {
838
+ const lines = steps.flatMap(
839
+ (step) => records(step["waiting_for"]).map((wait) => [clean(step["identifier"]), waitLine(wait)])
840
+ );
841
+ if (lines.length === 0) return "";
842
+ const width = Math.max(...lines.map(([identifier]) => graphemeLength(identifier)));
843
+ return "\nWaiting for:\n" + lines.map(([identifier, line]) => ` ${padGraphemes(identifier, width)} ${line}
844
+ `).join("");
845
+ }
846
+ function waitLine(wait) {
847
+ const name = gateName(wait);
848
+ const owner = wait["owner"];
849
+ if (typeof owner !== "object" || owner === null) return `${name} \xB7 no owner, anyone with standing can tick`;
850
+ const holders = wait["holders"];
851
+ let who;
852
+ if (!Array.isArray(holders)) who = "who can tick is not known";
853
+ else if (holders.length === 0) who = "nobody can tick";
854
+ else who = `can tick: ${records(holders).map(holderText).join(", ")}`;
855
+ return `${name} \xB7 ${ownerText(owner)} \xB7 ${who}`;
856
+ }
857
+ function holderText(holder) {
858
+ const name = clean(holder["name"]);
859
+ if (holder["kind"] !== "expert") return name;
860
+ const organization = clean(holder["organization"]);
861
+ return `${name} (expert${organization === "" ? "" : `, ${organization}`})`;
862
+ }
577
863
  function planHeldSection(heldSteps) {
578
864
  if (!Array.isArray(heldSteps) || heldSteps.length === 0) return "";
579
865
  return `
@@ -588,10 +874,10 @@ function errorDetails(body) {
588
874
  }
589
875
  function changesetDetails(details) {
590
876
  if (typeof details !== "object" || details === null || Array.isArray(details)) return "";
591
- return Object.entries(details).map(([field2, messages]) => {
877
+ return Object.entries(details).map(([field3, messages]) => {
592
878
  const text = Array.isArray(messages) ? messages.map(String).join("; ") : String(messages);
593
879
  return `
594
- ${clean(field2)}: ${clean(text)}`;
880
+ ${clean(field3)}: ${clean(text)}`;
595
881
  }).join("");
596
882
  }
597
883
  function violationDetails(violations) {
@@ -700,7 +986,7 @@ function path(record, ...keys) {
700
986
  }
701
987
 
702
988
  // src/commands/issues.ts
703
- import { openAsBlob, openSync, closeSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
989
+ import { openAsBlob, openSync, closeSync, readFileSync, statSync as statSync2 } from "fs";
704
990
  import { basename, extname } from "path";
705
991
 
706
992
  // src/core/attachments.ts
@@ -716,7 +1002,7 @@ var SOURCES = [
716
1002
  var MAX_IMAGES_PER_SHOW = 20;
717
1003
  var MAX_ATTACHMENT_BYTES = 25e6;
718
1004
  function refs(issue) {
719
- const texts = SOURCES.map(([field2, source]) => [issue[field2], source]);
1005
+ const texts = SOURCES.map(([field3, source]) => [issue[field3], source]);
720
1006
  const comments2 = Array.isArray(issue["comments"]) ? issue["comments"] : [];
721
1007
  for (const comment2 of comments2) texts.push([comment2["body"], "comment"]);
722
1008
  const found = [];
@@ -829,8 +1115,8 @@ function write(path2, bytes) {
829
1115
  }
830
1116
 
831
1117
  // src/core/pagination.ts
832
- function notices(meta, requestedPageSize, records2) {
833
- return capNotice(meta, requestedPageSize) + truncationNotice(meta, records2);
1118
+ function notices(meta, requestedPageSize, records5) {
1119
+ return capNotice(meta, requestedPageSize) + truncationNotice(meta, records5);
834
1120
  }
835
1121
  function capNotice(meta, requestedPageSize) {
836
1122
  const served = meta?.page_size;
@@ -840,12 +1126,12 @@ function capNotice(meta, requestedPageSize) {
840
1126
  }
841
1127
  return "";
842
1128
  }
843
- function truncationNotice(meta, records2) {
1129
+ function truncationNotice(meta, records5) {
844
1130
  const total = meta?.total;
845
1131
  const page = meta?.page;
846
1132
  const pages = meta?.total_pages;
847
1133
  if (typeof total !== "number" || typeof page !== "number" || typeof pages !== "number") return "";
848
- const shown = records2.length;
1134
+ const shown = records5.length;
849
1135
  if (shown < total) {
850
1136
  return `harness: showing ${shown} of ${total} (page ${page} of ${pages}) \u2014 pass --all for the rest, or --page to step through.
851
1137
  `;
@@ -856,20 +1142,20 @@ async function fetchEveryPage(fetchPage) {
856
1142
  const first = await fetchPage(1);
857
1143
  const totalPages = pageCount(first.meta);
858
1144
  if (totalPages === null) return first.data;
859
- let records2 = first.data;
1145
+ let records5 = first.data;
860
1146
  for (let page = 2; page <= totalPages; page++) {
861
1147
  const body = await fetchPage(page);
862
- records2 = dedupe([...records2, ...body.data]);
1148
+ records5 = dedupe([...records5, ...body.data]);
863
1149
  }
864
- return records2;
1150
+ return records5;
865
1151
  }
866
1152
  function pageCount(meta) {
867
1153
  const total = meta?.total_pages;
868
1154
  return typeof total === "number" && total > 0 ? total : null;
869
1155
  }
870
- function dedupe(records2) {
1156
+ function dedupe(records5) {
871
1157
  const seen = /* @__PURE__ */ new Set();
872
- return records2.filter((record) => {
1158
+ return records5.filter((record) => {
873
1159
  const id = record["id"];
874
1160
  const key = id === void 0 || id === null ? `struct:${stableKey(record)}` : `id:${stableKey(id)}`;
875
1161
  if (seen.has(key)) return false;
@@ -908,9 +1194,9 @@ function requireIdentifiers(identifiers) {
908
1194
  async function ready(ctx, opts) {
909
1195
  if (opts.claim === true) return claimNext(ctx, opts);
910
1196
  const params = { ...listParams(ctx, opts), ready: true };
911
- const { records: records2, notice } = await fetchIssues(ctx, params, opts);
1197
+ const { records: records5, notice } = await fetchIssues(ctx, params, opts);
912
1198
  const rank = (issue) => PRIORITY_RANK[issue["priority"]] ?? 4;
913
- const sorted = markAgentSteps([...records2].sort((a, b) => rank(a) - rank(b)));
1199
+ const sorted = markAgentSteps([...records5].sort((a, b) => rank(a) - rank(b)));
914
1200
  return { json: sorted, human: issuesTable(sorted, { markAgentSteps: true }), notice };
915
1201
  }
916
1202
  function markAgentSteps(issues) {
@@ -932,7 +1218,7 @@ function requireReadyClaimArgs(opts) {
932
1218
  async function claimNext(ctx, opts) {
933
1219
  requireReadyClaimArgs(opts);
934
1220
  const team = requiredTeam(ctx, opts.team);
935
- const check = `check \`harness list --team ${team} --assignee me --status in_progress\``;
1221
+ const check = `check \`${invokedName()} list --team ${team} --assignee me --status in_progress\``;
936
1222
  const unreadable = (why) => new CommandError(`The claim-next response ${why}, so an issue may have been claimed \u2014 ${check}.`);
937
1223
  let body;
938
1224
  try {
@@ -965,13 +1251,13 @@ function isIssueRecord(value) {
965
1251
  }
966
1252
  async function blocked(ctx, opts) {
967
1253
  const params = { ...listParams(ctx, opts), blocked: true };
968
- const { records: records2, notice } = await fetchIssues(ctx, params, opts);
969
- return { json: records2, human: issuesTable(records2), notice };
1254
+ const { records: records5, notice } = await fetchIssues(ctx, params, opts);
1255
+ return { json: records5, human: issuesTable(records5), notice };
970
1256
  }
971
1257
  async function list(ctx, opts) {
972
1258
  const params = listParams(ctx, opts);
973
- const { records: records2, notice } = await fetchIssues(ctx, params, opts);
974
- return { json: records2, human: issuesTable(records2), notice };
1259
+ const { records: records5, notice } = await fetchIssues(ctx, params, opts);
1260
+ return { json: records5, human: issuesTable(records5), notice };
975
1261
  }
976
1262
  async function search(ctx, queryWords, opts) {
977
1263
  if (queryWords.length === 0) throw new UsageError("usage: harness search <query>");
@@ -1002,9 +1288,14 @@ async function show(ctx, identifier, opts) {
1002
1288
  return notice === "" ? { json: json2, human } : { json: json2, human, notice };
1003
1289
  }
1004
1290
  var ACTIVITY_MAX_LIMIT = 100;
1291
+ var ACTIVITY_INCLUDE = "condition_events,field_events";
1292
+ var ACTIVITY_INCLUDE_WITHOUT_FIELDS = "condition_events";
1293
+ var ACTIVITY_WITHOUT_FIELDS_NOTICE = "harness: this server doesn't record field changes yet; showing activity without them.\n";
1005
1294
  async function activity(ctx, identifier, opts) {
1006
1295
  requireIdentifiers([identifier]);
1007
- let page = await activityPage(ctx, identifier, opts.limit);
1296
+ const first = await firstActivityPage(ctx, identifier, opts.limit);
1297
+ const include = first.include;
1298
+ let { page, notice } = first;
1008
1299
  let entries = page.entries;
1009
1300
  if (opts.all === true) {
1010
1301
  const pages = [page.entries];
@@ -1015,17 +1306,38 @@ async function activity(ctx, identifier, opts) {
1015
1306
  throw new CommandError("Activity response repeated a cursor.");
1016
1307
  }
1017
1308
  seenCursors.add(cursor);
1018
- page = await activityPage(ctx, identifier, opts.limit, cursor);
1309
+ page = await activityPage(ctx, identifier, include, opts.limit, cursor);
1019
1310
  pages.push(page.entries);
1020
1311
  }
1021
1312
  entries = pages.reverse().flat();
1022
1313
  }
1023
- const notice = opts.all !== true && page.hasMore ? `harness: showing the newest ${entries.length} activity entries \u2014 pass --all for older activity.
1024
- ` : void 0;
1025
- return { json: entries, human: activityEntries(entries), notice };
1314
+ if (opts.all !== true && page.hasMore) {
1315
+ notice += `harness: showing the newest ${entries.length} activity entries \u2014 pass --all for older activity.
1316
+ `;
1317
+ }
1318
+ return { json: entries, human: activityEntries(entries), notice: notice === "" ? void 0 : notice };
1319
+ }
1320
+ async function firstActivityPage(ctx, identifier, limit) {
1321
+ try {
1322
+ const page2 = await activityPage(ctx, identifier, ACTIVITY_INCLUDE, limit);
1323
+ return { page: page2, include: ACTIVITY_INCLUDE, notice: "" };
1324
+ } catch (error) {
1325
+ if (!refusesInclude(error)) throw error;
1326
+ }
1327
+ const page = await activityPage(ctx, identifier, ACTIVITY_INCLUDE_WITHOUT_FIELDS, limit);
1328
+ return { page, include: ACTIVITY_INCLUDE_WITHOUT_FIELDS, notice: ACTIVITY_WITHOUT_FIELDS_NOTICE };
1329
+ }
1330
+ function refusesInclude(error) {
1331
+ if (!(error instanceof HttpError) || error.status !== 400) return false;
1332
+ const message = error.body["message"];
1333
+ return typeof message === "string" && /\binclude\b/.test(message) && /\b(condition_events|field_events)\b|\baccepts only\b/.test(message);
1026
1334
  }
1027
- async function activityPage(ctx, identifier, limit, before) {
1028
- const body = await ctx.client.get(`/api/v1/issues/${identifier}/activity`, { limit, before });
1335
+ async function activityPage(ctx, identifier, include, limit, before) {
1336
+ const body = await ctx.client.get(`/api/v1/issues/${identifier}/activity`, {
1337
+ limit,
1338
+ before,
1339
+ include
1340
+ });
1029
1341
  if (!Array.isArray(body.data)) throw new CommandError("Activity response data is not an array.");
1030
1342
  if (body.data.some((entry) => typeof entry !== "object" || entry === null || Array.isArray(entry))) {
1031
1343
  throw new CommandError("Activity response contains a non-object entry.");
@@ -1064,14 +1376,14 @@ async function prime(ctx) {
1064
1376
  const statsBody = await ctx.client.get("/api/v1/stats");
1065
1377
  const ready2 = markAgentSteps(await mostImportantReady(ctx));
1066
1378
  const json2 = { stats: statsBody.data, ready: ready2 };
1067
- const human = "# Harness workflow context\n\n" + stats(statsBody.data) + "\nReady to work:\n" + issuesTable(ready2, { markAgentSteps: true }) + `
1379
+ const human = "# Harness workflow context\n\n" + stats(statsBody.data) + "\nReady to work:\n" + issuesTable(ready2, { markAgentSteps: true }) + named(`
1068
1380
  Workflow: harness ready \u2192 harness update <id> --claim \u2192 work \u2192
1069
1381
  harness close <id> --reason "Implemented in <commit-hash>" (response
1070
1382
  lists newly unblocked issues \u2014 surface them).
1071
1383
  Create tracked work BEFORE coding: harness create --title "\u2026" --type task.
1072
1384
  Dependencies: harness dep add <issue> <blocker> (blocker blocks issue).
1073
1385
  Before reporting done: close the issues you finished.
1074
- `;
1386
+ `);
1075
1387
  return { json: json2, human };
1076
1388
  }
1077
1389
  async function mostImportantReady(ctx) {
@@ -1089,7 +1401,7 @@ async function version(config) {
1089
1401
  } else {
1090
1402
  server = await new Client(config).serverVersion().catch(() => "unreachable");
1091
1403
  }
1092
- return { json: { cli: CLI_VERSION, server }, human: `harness ${CLI_VERSION} (server API ${server})
1404
+ return { json: { cli: CLI_VERSION, server }, human: `${invokedName()} ${CLI_VERSION} (server API ${server})
1093
1405
  ` };
1094
1406
  }
1095
1407
  async function create(ctx, opts) {
@@ -1204,8 +1516,8 @@ ${opts.appendNotes}`;
1204
1516
  var CLAIM_REFUSAL_HINTS = {
1205
1517
  // The edit holds a released step, so the plan needs releasing again before
1206
1518
  // the step is claimable.
1207
- 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",
1208
- waiting_on_condition: (identifier) => `harness show ${identifier} lists what it waits on; tick a gate with harness gate satisfy ${identifier} <gate>`
1519
+ agent_step: () => `Operator runs it for its plan; to do it yourself, set its executor to none (${invokedName()} plan edit <plan-id>) and release the plan again`,
1520
+ waiting_on_condition: (identifier) => `${invokedName()} show ${identifier} lists what it waits on; tick a gate with ${invokedName()} gate satisfy ${identifier} <gate>`
1209
1521
  };
1210
1522
  function explainClaimRefusal(error, identifier) {
1211
1523
  if (!(error instanceof HttpError) || error.status !== 409) return error;
@@ -1243,7 +1555,7 @@ async function close(ctx, identifiers, opts) {
1243
1555
  results.push({ identifier, ok: true, response });
1244
1556
  } catch (error) {
1245
1557
  if (error instanceof HttpError || error instanceof TransportError) {
1246
- results.push({ identifier, ok: false, error });
1558
+ results.push({ identifier, ok: false, error: explainExpertRefusal(error, ctx.config.token) });
1247
1559
  } else {
1248
1560
  throw error;
1249
1561
  }
@@ -1293,7 +1605,7 @@ async function archive(ctx, identifiers, opts) {
1293
1605
  results.push({ identifier, ok: true, issue: body.data });
1294
1606
  } catch (error) {
1295
1607
  if (error instanceof HttpError || error instanceof TransportError) {
1296
- results.push({ identifier, ok: false, error });
1608
+ results.push({ identifier, ok: false, error: explainExpertRefusal(error, ctx.config.token) });
1297
1609
  } else {
1298
1610
  throw error;
1299
1611
  }
@@ -1312,9 +1624,9 @@ async function archive(ctx, identifiers, opts) {
1312
1624
  }
1313
1625
  async function comments(ctx, identifier) {
1314
1626
  requireIdentifiers([identifier]);
1315
- const body = await ctx.client.get(`/api/v1/issues/${identifier}/comments`);
1316
- if (body.data.length === 0) return { json: body.data, human: "No comments.\n" };
1317
- const human = body.data.map((comment2) => {
1627
+ const records5 = await completeCommentThread(ctx, identifier);
1628
+ if (records5.length === 0) return { json: records5, human: "No comments.\n" };
1629
+ const human = records5.map((comment2) => {
1318
1630
  const author = clean((comment2["author"] ?? {})["name"] ?? "unknown");
1319
1631
  const id = clean(comment2["id"] ?? "unknown id");
1320
1632
  const text = cleanMultiline(str2(comment2["body"])).split("\n").map((line) => " " + line).join("\n");
@@ -1322,7 +1634,35 @@ async function comments(ctx, identifier) {
1322
1634
  ${text}
1323
1635
  `;
1324
1636
  }).join("\n");
1325
- return { json: body.data, human };
1637
+ return { json: records5, human };
1638
+ }
1639
+ async function completeCommentThread(ctx, identifier) {
1640
+ const pages = [];
1641
+ const seen = /* @__PURE__ */ new Set();
1642
+ let before;
1643
+ for (; ; ) {
1644
+ const page = await ctx.client.get(`/api/v1/issues/${identifier}/comments`, {
1645
+ limit: 200,
1646
+ before
1647
+ });
1648
+ if (!Array.isArray(page.data) || typeof page.meta?.has_more !== "boolean") {
1649
+ throw new CommandError("Invalid comment pagination: expected a comment array and boolean meta.has_more; completeness unknown.");
1650
+ }
1651
+ const records5 = [];
1652
+ for (const record of page.data) {
1653
+ if (record === null || typeof record !== "object" || Array.isArray(record) || typeof record.id !== "string" || record.id.trim() === "" || seen.has(record.id)) {
1654
+ throw new CommandError("Invalid comment pagination: missing or repeated comment id; completeness unknown.");
1655
+ }
1656
+ seen.add(record.id);
1657
+ records5.push(record);
1658
+ }
1659
+ pages.push(records5);
1660
+ if (!page.meta.has_more) return pages.reverse().flat();
1661
+ if (records5.length === 0) {
1662
+ throw new CommandError("Invalid comment pagination: has_more on an empty page; completeness unknown.");
1663
+ }
1664
+ before = records5[0]["id"];
1665
+ }
1326
1666
  }
1327
1667
  async function dep(ctx, verb, issue, blocker) {
1328
1668
  requireIdentifiers([issue, blocker]);
@@ -1472,7 +1812,7 @@ async function attachFile(ctx, path2) {
1472
1812
  }
1473
1813
  async function uploadAttachment(ctx, path2, maxBytes) {
1474
1814
  attachableFile(path2, maxBytes);
1475
- const file = maxBytes === MAX_VIDEO_BYTES ? new File([await openAsBlob(path2)], basename(path2)) : new File([readFileSync2(path2)], basename(path2));
1815
+ const file = maxBytes === MAX_VIDEO_BYTES ? new File([await openAsBlob(path2)], basename(path2)) : new File([readFileSync(path2)], basename(path2));
1476
1816
  requireAttachmentSize(path2, file.size, maxBytes);
1477
1817
  const form = new FormData();
1478
1818
  form.set("file", file);
@@ -1579,13 +1919,13 @@ async function prepareApplicableLabelIds(ctx, teamId, names) {
1579
1919
  }
1580
1920
  function applicableLabel(labels, input, teamId) {
1581
1921
  const normalized = normalizeLabelName(input);
1582
- const named = labels.filter((label) => normalizeLabelName(label["name"]) === normalized);
1583
- const teamMatches = named.filter((label) => label["team_id"] === teamId);
1922
+ const named2 = labels.filter((label) => normalizeLabelName(label["name"]) === normalized);
1923
+ const teamMatches = named2.filter((label) => label["team_id"] === teamId);
1584
1924
  if (teamMatches.length > 1) {
1585
1925
  throw new CommandError(`more than one team label matches "${input}"; use a label id`);
1586
1926
  }
1587
1927
  if (teamMatches.length === 1) return teamMatches[0];
1588
- const workspaceMatches = named.filter(
1928
+ const workspaceMatches = named2.filter(
1589
1929
  (label) => label["team_id"] === null || label["team_id"] === void 0
1590
1930
  );
1591
1931
  if (workspaceMatches.length > 1) {
@@ -1674,15 +2014,15 @@ async function fetchIssues(ctx, params, opts) {
1674
2014
  throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
1675
2015
  }
1676
2016
  if (opts.all === true) {
1677
- const records3 = await fetchEveryPage(
2017
+ const records6 = await fetchEveryPage(
1678
2018
  (page) => ctx.client.get("/api/v1/issues", { ...params, page })
1679
2019
  );
1680
- return { records: records3, notice: "" };
2020
+ return { records: records6, notice: "" };
1681
2021
  }
1682
2022
  const body = await ctx.client.get("/api/v1/issues", params);
1683
- const records2 = body.data;
2023
+ const records5 = body.data;
1684
2024
  const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
1685
- return { records: records2, notice: notices(body.meta, requested, records2) };
2025
+ return { records: records5, notice: notices(body.meta, requested, records5) };
1686
2026
  }
1687
2027
  function listParams(ctx, opts) {
1688
2028
  if (opts.type !== void 0 && opts.label !== void 0) {
@@ -1711,8 +2051,8 @@ function listParams(ctx, opts) {
1711
2051
  }
1712
2052
  function optionalPriority(value) {
1713
2053
  if (value === void 0) return null;
1714
- const named = Object.hasOwn(PRIORITY_NAMES, value) ? PRIORITY_NAMES[value] : void 0;
1715
- if (named !== void 0) return named;
2054
+ const named2 = Object.hasOwn(PRIORITY_NAMES, value) ? PRIORITY_NAMES[value] : void 0;
2055
+ if (named2 !== void 0) return named2;
1716
2056
  if (/^\d+$/.test(value)) {
1717
2057
  throw new UsageError(
1718
2058
  `Bare numeric priorities are refused: bd and harness disagree on what
@@ -1755,8 +2095,8 @@ function withTeam(params, ctx, opts) {
1755
2095
  function putParam(params, key, value) {
1756
2096
  return value === void 0 ? params : { ...params, [key]: value };
1757
2097
  }
1758
- function putOpt(body, field2, value) {
1759
- return value === void 0 ? body : { ...body, [field2]: value };
2098
+ function putOpt(body, field3, value) {
2099
+ return value === void 0 ? body : { ...body, [field3]: value };
1760
2100
  }
1761
2101
  function putPresent(body, key, value) {
1762
2102
  if (value === null || value === void 0 || value === false) return body;
@@ -1808,8 +2148,8 @@ async function paginatedRead(ctx, path2, params, opts) {
1808
2148
  throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
1809
2149
  }
1810
2150
  if (opts.all === true) {
1811
- const records2 = await fetchEveryPage((page) => ctx.client.get(path2, { ...params, page }));
1812
- return { records: records2, notice: "" };
2151
+ const records5 = await fetchEveryPage((page) => ctx.client.get(path2, { ...params, page }));
2152
+ return { records: records5, notice: "" };
1813
2153
  }
1814
2154
  const body = await ctx.client.get(path2, params);
1815
2155
  const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
@@ -1819,10 +2159,10 @@ async function projectList(ctx, opts) {
1819
2159
  let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
1820
2160
  if (opts.search !== void 0) params = { ...params, search: opts.search };
1821
2161
  if (opts.state !== void 0) params = { ...params, state: opts.state };
1822
- const { records: records2, notice } = await paginatedRead(ctx, "/api/v1/projects", params, opts);
1823
- const human = records2.length === 0 ? "No projects.\n" : table(
2162
+ const { records: records5, notice } = await paginatedRead(ctx, "/api/v1/projects", params, opts);
2163
+ const human = records5.length === 0 ? "No projects.\n" : table(
1824
2164
  ["ID", "STATE", "LEAD", "TARGET", "NAME"],
1825
- records2.map((project) => [
2165
+ records5.map((project) => [
1826
2166
  str3(project["id"]),
1827
2167
  str3(project["state"]) || "-",
1828
2168
  str3(field(project, "lead", "name")) || "-",
@@ -1830,7 +2170,7 @@ async function projectList(ctx, opts) {
1830
2170
  str3(project["name"])
1831
2171
  ])
1832
2172
  );
1833
- return { json: records2, human, notice };
2173
+ return { json: records5, human, notice };
1834
2174
  }
1835
2175
  async function projectShow(ctx, id) {
1836
2176
  requireSafeId("project", id);
@@ -1921,12 +2261,12 @@ async function milestoneCreate(ctx, opts) {
1921
2261
  async function docList(ctx, opts) {
1922
2262
  let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
1923
2263
  if (opts.search !== void 0) params = { ...params, search: opts.search };
1924
- const { records: records2, notice } = await paginatedRead(ctx, "/api/v1/documents", params, opts);
1925
- const human = records2.length === 0 ? "No documents.\n" : table(
2264
+ const { records: records5, notice } = await paginatedRead(ctx, "/api/v1/documents", params, opts);
2265
+ const human = records5.length === 0 ? "No documents.\n" : table(
1926
2266
  ["ID", "UPDATED", "TITLE"],
1927
- records2.map((doc) => [str3(doc["id"]), str3(doc["updated_at"]) || "-", str3(doc["title"])])
2267
+ records5.map((doc) => [str3(doc["id"]), str3(doc["updated_at"]) || "-", str3(doc["title"])])
1928
2268
  );
1929
- return { json: records2, human, notice };
2269
+ return { json: records5, human, notice };
1930
2270
  }
1931
2271
  async function docShow(ctx, id) {
1932
2272
  requireSafeId("document", id);
@@ -2012,7 +2352,7 @@ async function linkRm(ctx, issue, idOrUrl) {
2012
2352
  return { json: { deleted: link["id"] }, human: `Removed ${clean(link["url"])} from ${issue}
2013
2353
  ` };
2014
2354
  }
2015
- async function labelList(ctx) {
2355
+ async function labelList2(ctx) {
2016
2356
  const body = await ctx.client.get("/api/v1/labels");
2017
2357
  const human = body.data.length === 0 ? "No labels.\n" : table(
2018
2358
  ["NAME", "COLOR", "ISSUES", "ID"],
@@ -2215,167 +2555,1761 @@ function indent2(text) {
2215
2555
  return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
2216
2556
  }
2217
2557
 
2218
- // src/core/textinput.ts
2219
- import { readFileSync as readFileSync3, statSync as statSync3 } from "fs";
2220
- import { isatty } from "tty";
2221
- var STDIN_PATH = "-";
2222
- var MAX_TEXT_BYTES = 1e6;
2223
- var TEXT_LIMIT = { bytes: MAX_TEXT_BYTES, why: "that is not prose; check the path" };
2224
- var DOCUMENT_LIMIT = { bytes: 8e6, why: "larger than the API accepts in one request" };
2225
- function flagOf(name) {
2226
- return name.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`);
2558
+ // src/commands/operator-catalog.ts
2559
+ var SCHEMA_VIEWS = ["input", "result"];
2560
+ var MAX_QUERY_BYTES = 200;
2561
+ var MAX_CATALOG_PAGES = 200;
2562
+ var MAX_SCHEMA_PAGES = 1e3;
2563
+ var SCHEMA_ENCODING = "json_fragments_v1";
2564
+ var CAPABILITY_ID_FORMAT = /^[A-Za-z0-9_.:-]{1,200}$/;
2565
+ var APPROVAL_NOTE = "Write and send capabilities, and any proposal, need a person's approval in the app before they take effect.\n";
2566
+ var REFUSALS = {
2567
+ operator_disabled: "nothing here works until it is switched back on",
2568
+ operator_not_permitted: "this account has no admin or member role in the organization the credential is bound to",
2569
+ actor_required: `this credential acts as no person: sign in with \`${invokedName()} login\`, or use an API token minted for a user`,
2570
+ run_bound_credential: "a run-bound credential can't read Operator's catalog",
2571
+ forbidden: "Operator needs the operator:read scope (aone_ API tokens must be minted with it) and an admin or member role"
2572
+ };
2573
+ async function toolsList(ctx, opts) {
2574
+ requireToolsOpts(opts);
2575
+ const { items, meta } = await capabilityPages(ctx, {}, opts);
2576
+ return {
2577
+ json: items,
2578
+ human: items.length === 0 ? "Operator can use no capabilities here.\n" : capabilitiesTable(items, false),
2579
+ notice: pagingNotice(items, meta, opts, "capabilities")
2580
+ };
2227
2581
  }
2228
- function resolveTextSources(sources) {
2229
- const resolved = {};
2230
- const toRead = [];
2231
- let stdinClaimedBy;
2232
- for (const { name, inline, file } of sources) {
2233
- if (inline !== void 0 && file !== void 0) {
2234
- throw new UsageError(
2235
- `--${flagOf(name)} and --${flagOf(name)}-file both given \u2014 pass one; there is no precedence between them`
2582
+ async function toolsSearch(ctx, query, opts) {
2583
+ requireSearchQuery(query);
2584
+ requireToolsOpts(opts);
2585
+ const { items, meta } = await capabilityPages(ctx, { q: query }, opts);
2586
+ return {
2587
+ json: items,
2588
+ human: items.length === 0 ? `No capability Operator can use matches "${clean(query)}".
2589
+ ` : capabilitiesTable(items, true),
2590
+ notice: pagingNotice(items, meta, opts, "matches")
2591
+ };
2592
+ }
2593
+ async function toolsDomains(ctx) {
2594
+ const body = await operatorRequest(() => ctx.client.get("/api/v1/operator/capabilities/domains"));
2595
+ const domains = records2(body.data, "domains");
2596
+ return {
2597
+ json: domains,
2598
+ human: domains.length === 0 ? "Operator can use no capabilities here.\n" : table(
2599
+ ["DOMAIN", "CAPABILITIES"],
2600
+ domains.map((domain) => [str4(domain["domain"]), str4(domain["count"])])
2601
+ )
2602
+ };
2603
+ }
2604
+ async function toolShow(ctx, id, opts) {
2605
+ requireCapabilityId(id);
2606
+ const view = requireSchemaView(opts.schema);
2607
+ const fragments = [];
2608
+ let capability = {};
2609
+ let fragmentCount = null;
2610
+ for (let page = 1; ; page++) {
2611
+ const body = await operatorRequest(
2612
+ () => ctx.client.get(`/api/v1/operator/capabilities/${id}`, { schema: view, page }),
2613
+ `Operator can't use a capability ${id} here: it doesn't exist, or it is disabled or switched off`
2614
+ );
2615
+ const data = body.data;
2616
+ if (!isRecord(data)) throw new CommandError("The capability response carried no capability.");
2617
+ if (data["id"] !== id || data["schema"] !== view || data["page"] !== page) {
2618
+ throw new CommandError(
2619
+ `The capability response is not page ${page} of ${id}'s ${view} schema (it says page ${clean(data["page"])} of ${clean(data["id"])}'s ${clean(data["schema"])} schema).`
2236
2620
  );
2237
2621
  }
2238
- if (file === void 0) {
2239
- if (inline !== void 0) resolved[name] = inline;
2240
- continue;
2622
+ if (data["schema_encoding"] !== SCHEMA_ENCODING) {
2623
+ throw new CommandError(
2624
+ `The capability's schema comes in an encoding this CLI does not read (${clean(data["schema_encoding"])}) \u2014 upgrade the CLI.`
2625
+ );
2241
2626
  }
2242
- if (file === STDIN_PATH) {
2243
- if (isatty(0)) {
2244
- throw new UsageError(
2245
- `--${flagOf(name)}-file - reads stdin, but stdin is a terminal \u2014 pipe something in or pass a path`
2246
- );
2247
- }
2248
- if (stdinClaimedBy !== void 0) {
2249
- throw new UsageError(
2250
- `--${flagOf(name)}-file and --${flagOf(stdinClaimedBy)}-file both read stdin \u2014 there is only one stream, so only one may be "-"`
2251
- );
2252
- }
2253
- stdinClaimedBy = name;
2627
+ const count = data["fragment_count"];
2628
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 1) {
2629
+ throw new CommandError(`The capability's schema page ${page} carries no valid fragment count.`);
2254
2630
  }
2255
- if (file !== STDIN_PATH) checkReadable(file, `--${flagOf(name)}-file`);
2256
- toRead.push({ name, file });
2257
- }
2258
- for (const { name, file } of [...toRead].sort(byStdinLast)) {
2259
- resolved[name] = stripOneTrailingNewline(readTextFile(file, `--${flagOf(name)}-file`));
2260
- }
2261
- return resolved;
2262
- }
2263
- function readFileOption(flag, path2) {
2264
- if (path2 === STDIN_PATH) {
2265
- if (isatty(0)) {
2266
- throw new UsageError(`${flag} - reads stdin, but stdin is a terminal \u2014 pipe something in or pass a path`);
2631
+ if (fragmentCount !== null && count !== fragmentCount) {
2632
+ throw new CommandError(
2633
+ `The capability's schema pages disagree on the fragment count (${fragmentCount}, then ${count} on page ${page}).`
2634
+ );
2267
2635
  }
2268
- } else {
2269
- checkReadable(path2, flag, DOCUMENT_LIMIT);
2270
- }
2271
- return readTextFile(path2, flag, DOCUMENT_LIMIT);
2272
- }
2273
- function readTextFile(path2, option, limit = TEXT_LIMIT) {
2274
- if (path2 === STDIN_PATH) {
2275
- let piped;
2276
- try {
2277
- piped = readFileSync3(0);
2278
- } catch (error) {
2279
- throw new UsageError(`${option} -: cannot read stdin (${describe(error)})`);
2636
+ fragmentCount = count;
2637
+ const pageFragments = data["fragments"];
2638
+ if (!Array.isArray(pageFragments) || !pageFragments.every((fragment) => typeof fragment === "string")) {
2639
+ throw new CommandError(`The capability's schema page ${page} carried no fragments.`);
2280
2640
  }
2281
- if (piped.length > limit.bytes) {
2282
- throw new UsageError(oversize(`${option} -`, limit));
2641
+ fragments.push(...pageFragments);
2642
+ capability = data;
2643
+ const next = data["next_page"];
2644
+ if (next === null || next === void 0) break;
2645
+ if (next !== page + 1 || next > MAX_SCHEMA_PAGES) {
2646
+ throw new CommandError(`The capability's schema page ${page} names an invalid next page (${clean(next)}).`);
2283
2647
  }
2284
- return decodeUtf8(piped, `${option} -`);
2285
2648
  }
2286
- const flag = `${option} ${path2}`;
2287
- let bytes;
2288
- try {
2289
- bytes = readFileSync3(path2);
2290
- } catch (error) {
2291
- throw new UsageError(`${flag}: ${describe(error)}`);
2649
+ if (fragments.length !== fragmentCount) {
2650
+ throw new CommandError(
2651
+ `The capability's schema came back incomplete: ${fragments.length} of ${fragmentCount} fragments.`
2652
+ );
2292
2653
  }
2293
- if (bytes.length > limit.bytes) throw new UsageError(oversize(flag, limit));
2294
- return decodeUtf8(bytes, flag);
2295
- }
2296
- function decodeUtf8(bytes, flag) {
2654
+ let schema;
2297
2655
  try {
2298
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
2656
+ schema = JSON.parse(fragments.join(""));
2299
2657
  } catch {
2300
- throw new UsageError(`${flag}: not valid UTF-8 \u2014 convert it first`);
2658
+ throw new CommandError("The capability's schema fragments do not join into valid JSON.");
2659
+ }
2660
+ const shown = { ...withoutSchemaPaging(capability), [`${view}_schema`]: schema };
2661
+ return { json: shown, human: capabilityDetail(shown, view, schema) };
2662
+ }
2663
+ async function skillsList(ctx) {
2664
+ const body = await operatorRequest(() => ctx.client.get("/api/v1/operator/skills"));
2665
+ const skills = records2(body.data, "skills").map((skill) => ({
2666
+ id: skill["id"],
2667
+ title: skill["title"],
2668
+ invocation_name: skill["invocation_name"],
2669
+ content_sha256: skill["content_sha256"]
2670
+ }));
2671
+ return {
2672
+ json: skills,
2673
+ human: skills.length === 0 ? "No skills are assigned to Operator in this organization.\n" : table(
2674
+ ["INVOCATION", "TITLE", "SHA-256", "ID"],
2675
+ skills.map((skill) => [
2676
+ str4(skill.invocation_name),
2677
+ truncate(skill.title),
2678
+ str4(skill.content_sha256),
2679
+ str4(skill.id)
2680
+ ])
2681
+ )
2682
+ };
2683
+ }
2684
+ function requireCapabilityId(id) {
2685
+ if (!CAPABILITY_ID_FORMAT.test(id) || /^\.+$/.test(id)) {
2686
+ throw new UsageError(`not a capability id: ${clean(id)} (e.g. crm.party.search)`);
2687
+ }
2688
+ if (id === "domains") {
2689
+ throw new UsageError(`domains is not a capability id \u2014 \`${invokedName()} tools domains\` lists the domains`);
2301
2690
  }
2302
2691
  }
2303
- function oversize(what, limit) {
2304
- return `${what}: over ${limit.bytes / 1e6} MB \u2014 ${limit.why}`;
2692
+ function requireSearchQuery(query) {
2693
+ if (query.trim() === "") throw new UsageError("the search query is empty \u2014 say what to search for");
2694
+ if (Buffer.byteLength(query, "utf8") > MAX_QUERY_BYTES) {
2695
+ throw new UsageError(`the search query is over ${MAX_QUERY_BYTES} bytes \u2014 shorten it`);
2696
+ }
2305
2697
  }
2306
- function checkReadable(path2, option, limit = TEXT_LIMIT) {
2307
- const flag = `${option} ${path2}`;
2308
- let stats3;
2309
- try {
2310
- stats3 = statSync3(path2);
2311
- } catch (error) {
2312
- throw new UsageError(`${flag}: ${describe(error)}`);
2698
+ function requireToolsOpts(opts) {
2699
+ if (opts.domain !== void 0 && opts.domain.trim() === "") {
2700
+ throw new UsageError("the domain is empty \u2014 name one, or leave it out for every domain");
2313
2701
  }
2314
- if (!stats3.isFile()) {
2315
- throw new UsageError(
2316
- `${flag}: ${stats3.isDirectory() ? "is a directory" : "not a regular file"}`
2317
- );
2702
+ if (opts.cursor !== void 0 && opts.cursor.trim() === "") {
2703
+ throw new UsageError("the cursor is empty \u2014 pass a page's cursor, or leave it out for the first page");
2318
2704
  }
2319
- if (stats3.size > limit.bytes) throw new UsageError(oversize(flag, limit));
2320
- }
2321
- function byStdinLast(a, b) {
2322
- return Number(a.file === STDIN_PATH) - Number(b.file === STDIN_PATH);
2323
2705
  }
2324
- function stripOneTrailingNewline(text) {
2325
- if (text.endsWith("\r\n")) return text.slice(0, -2);
2326
- if (text.endsWith("\n")) return text.slice(0, -1);
2327
- return text;
2706
+ function requireSchemaView(view) {
2707
+ if (view === void 0) return "input";
2708
+ if (SCHEMA_VIEWS.includes(view)) return view;
2709
+ throw new UsageError(`--schema must be input or result, not ${clean(view)}`);
2328
2710
  }
2329
- function describe(error) {
2330
- const code = error?.code;
2331
- if (code === "ENOENT") return "no such file";
2332
- if (code === "EISDIR") return "is a directory";
2333
- if (code === "EACCES") return "permission denied";
2334
- return error instanceof Error ? error.message : String(error);
2711
+ async function capabilityPages(ctx, base, opts) {
2712
+ const params = { ...base, domain: opts.domain };
2713
+ let page = await capabilityPage(ctx, { ...params, cursor: opts.cursor }, CALLER_CURSOR);
2714
+ const items = [...page.items];
2715
+ if (opts.all === true) {
2716
+ const seen = new Set(opts.cursor === void 0 ? [] : [opts.cursor]);
2717
+ let pages = 1;
2718
+ while (page.nextCursor !== null) {
2719
+ if (seen.has(page.nextCursor)) throw new CommandError("The capabilities response repeated a cursor.");
2720
+ if (pages >= MAX_CATALOG_PAGES) {
2721
+ throw new CommandError(`The capabilities listing ran past ${MAX_CATALOG_PAGES} pages; stopped there.`);
2722
+ }
2723
+ seen.add(page.nextCursor);
2724
+ page = await capabilityPage(ctx, { ...params, cursor: page.nextCursor }, WALK_CURSOR);
2725
+ pages++;
2726
+ items.push(...page.items);
2727
+ }
2728
+ }
2729
+ return { items: dedupeById(items), meta: page.meta };
2335
2730
  }
2336
-
2337
- // src/commands/plans.ts
2338
- import { extname as extname2 } from "path";
2339
- import { parseDocument } from "yaml";
2340
- var PLAN_EXECUTORS = ["none", "operator"];
2341
- var CONDITION_KINDS = ["not_before", "gate", "event"];
2342
- function parsePlanDocument(text, source) {
2343
- const parsed = extname2(source).toLowerCase() === ".json" ? parseJson(text, source) : parseYamlDocument(text, source);
2344
- if (!isRecord(parsed)) {
2345
- throw new UsageError(`${source}: a plan file holds one object (a mapping), not a list or a scalar`);
2731
+ var CALLER_CURSOR = "start again without the cursor";
2732
+ var WALK_CURSOR = "the listing changed or its cursor expired while following pages; run it again";
2733
+ async function capabilityPage(ctx, params, staleCursor) {
2734
+ const body = await operatorRequest(
2735
+ () => ctx.client.get("/api/v1/operator/capabilities", params),
2736
+ void 0,
2737
+ staleCursor
2738
+ );
2739
+ const items = records2(body.data, "capabilities");
2740
+ const meta = body.meta;
2741
+ if (!isRecord(meta)) throw new CommandError("The capabilities response has no pagination metadata.");
2742
+ const nextCursor = meta.next_cursor;
2743
+ if (nextCursor !== null && (typeof nextCursor !== "string" || nextCursor === "")) {
2744
+ throw new CommandError("The capabilities response has an invalid next_cursor.");
2346
2745
  }
2347
- return parsed;
2746
+ if (nextCursor !== null && items.length === 0) {
2747
+ throw new CommandError("The capabilities response says there are more pages but holds no capabilities.");
2748
+ }
2749
+ return { items, meta, nextCursor };
2348
2750
  }
2349
- function parseJson(text, source) {
2751
+ async function operatorRequest(send, notFound2 = "this Harness server has no Operator API yet", staleCursor = CALLER_CURSOR) {
2350
2752
  try {
2351
- return JSON.parse(text);
2753
+ return await send();
2352
2754
  } catch (error) {
2353
- throw new UsageError(`${source}: not valid JSON (${firstLine(error)})`);
2755
+ throw explain(error, notFound2, staleCursor);
2354
2756
  }
2355
2757
  }
2356
- function parseYamlDocument(text, source) {
2357
- const document = parseDocument(text, { resolveKnownTags: false });
2358
- const problem = document.errors[0] ?? document.warnings[0];
2359
- if (problem !== void 0) throw new UsageError(`${source}: not valid YAML (${firstLine(problem)})`);
2360
- let value;
2361
- try {
2362
- value = document.toJS();
2363
- } catch (error) {
2364
- throw new UsageError(`${source}: not valid YAML (${firstLine(error)})`);
2758
+ function explain(error, notFound2, staleCursor) {
2759
+ if (!(error instanceof HttpError)) return error;
2760
+ const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
2761
+ let hint;
2762
+ if (error.status === 403) hint = Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : void 0;
2763
+ else if (error.status === 404) hint = notFound2;
2764
+ else if (error.status === 400 && code === "invalid_cursor") hint = staleCursor;
2765
+ else if (error.status === 400 && /\bdomain\b/.test(error.message)) {
2766
+ hint = "no such domain; listing without one shows each capability's domain";
2365
2767
  }
2366
- if (!jsonSafe(value)) {
2367
- throw new UsageError(`${source}: holds a value JSON cannot carry (.inf or .nan) \u2014 write a number or a string`);
2768
+ if (hint === void 0) return error;
2769
+ return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
2770
+ }
2771
+ function capabilitiesTable(items, search2) {
2772
+ const headers = ["ID", "KIND", "DOMAIN", "SUMMARY"];
2773
+ const rows = items.map((item) => [
2774
+ str4(item["id"]),
2775
+ kindName(item),
2776
+ str4(item["domain"]),
2777
+ truncate(item["summary"])
2778
+ ]);
2779
+ const table2 = search2 ? table(
2780
+ ["SCORE", ...headers],
2781
+ rows.map((row, index) => [str4(items[index]["score"]), ...row])
2782
+ ) : table(headers, rows);
2783
+ return table2 + (items.some(needsApproval) ? "\n" + APPROVAL_NOTE : "");
2784
+ }
2785
+ function pagingNotice(items, meta, opts, noun) {
2786
+ if (opts.all === true || typeof meta.next_cursor !== "string") return void 0;
2787
+ const of = typeof meta.count === "number" ? ` of ${meta.count}` : "";
2788
+ return `harness: showing ${items.length}${of} ${noun} \u2014 pass --all for every page, or --cursor ${clean(meta.next_cursor)} for the next one.
2789
+ `;
2790
+ }
2791
+ function capabilityDetail(capability, view, schema) {
2792
+ const kind = kindName(capability);
2793
+ const line = (label, value) => ` ${(label + ":").padEnd(10)}${clean(value)}
2794
+ `;
2795
+ const description = cleanMultiline(str4(capability["description"]));
2796
+ return `${clean(capability["id"])}
2797
+ ` + line("Domain", str4(capability["domain"])) + line("Kind", kind === "send" ? "send \u2014 delivered beyond the platform" : kind) + line("Mode", `${str4(capability["mode"])} (effect: ${str4(capability["effect"])})`) + line("Summary", str4(capability["summary"])) + line("Approval", approval(capability)) + (description === "" ? "" : `
2798
+ Description:
2799
+ ${indent3(description)}
2800
+ `) + `
2801
+ ${view === "input" ? "Input" : "Result"} schema:
2802
+ ${indent3(cleanMultiline(JSON.stringify(schema, null, 2)))}
2803
+ `;
2804
+ }
2805
+ function needsApproval(capability) {
2806
+ return capability["kind"] === "write" || capability["kind"] === "send" || capability["mode"] === "proposal";
2807
+ }
2808
+ function approval(capability) {
2809
+ if (needsApproval(capability)) return "a person approves it in the app before it takes effect";
2810
+ if (capability["kind"] === "read") return "not needed for a read";
2811
+ const kind = kindName(capability);
2812
+ return kind === "unknown" ? "unknown: the server named no kind" : `unknown for a capability of kind ${kind}`;
2813
+ }
2814
+ function kindName(capability) {
2815
+ const kind = capability["kind"];
2816
+ return typeof kind === "string" && kind.trim() !== "" ? kind : "unknown";
2817
+ }
2818
+ var SCHEMA_PAGING_FIELDS = /* @__PURE__ */ new Set(["schema", "schema_encoding", "fragments", "fragment_count", "page", "next_page"]);
2819
+ function withoutSchemaPaging(capability) {
2820
+ return Object.fromEntries(Object.entries(capability).filter(([key]) => !SCHEMA_PAGING_FIELDS.has(key)));
2821
+ }
2822
+ function records2(value, what) {
2823
+ if (!Array.isArray(value) || !value.every(isRecord)) {
2824
+ throw new CommandError(`The ${what} response carried no list.`);
2368
2825
  }
2369
2826
  return value;
2370
2827
  }
2371
- function jsonSafe(value) {
2372
- if (typeof value === "number") return Number.isFinite(value);
2373
- if (Array.isArray(value)) return value.every(jsonSafe);
2374
- if (typeof value === "object" && value !== null) return Object.values(value).every(jsonSafe);
2375
- return true;
2828
+ function dedupeById(items) {
2829
+ const seen = /* @__PURE__ */ new Set();
2830
+ return items.filter((item) => {
2831
+ if (seen.has(item["id"])) return false;
2832
+ seen.add(item["id"]);
2833
+ return true;
2834
+ });
2376
2835
  }
2377
- function firstLine(error) {
2378
- return (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
2836
+ function indent3(text) {
2837
+ return text.split("\n").map((line) => " " + line).join("\n");
2838
+ }
2839
+ function isRecord(value) {
2840
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2841
+ }
2842
+ function str4(value) {
2843
+ return value === null || value === void 0 ? "" : String(value);
2844
+ }
2845
+
2846
+ // src/commands/operator-jobs.ts
2847
+ var JOB_STATES = [
2848
+ "draft",
2849
+ "planning",
2850
+ "ready",
2851
+ "running",
2852
+ "waiting_for_approval",
2853
+ "waiting_for_human",
2854
+ "paused",
2855
+ "failed",
2856
+ "needs_reconciliation",
2857
+ "completed",
2858
+ "cancelled"
2859
+ ];
2860
+ var JOB_STATUS_ALIASES = ["open", "waiting", "done"];
2861
+ var JOBS_MAX_LIMIT = 50;
2862
+ var JOBS_MAX_PAGES = 100;
2863
+ var EVENTS_PAGE_LIMIT = 200;
2864
+ var WATCH_INTERVAL_MS = 2e3;
2865
+ var WATCH_WAITING_INTERVAL_MS = 1e4;
2866
+ var WATCH_RETRIES = 3;
2867
+ var RETRY_BASE_MS = 1e3;
2868
+ var RETRY_AFTER_CAP_MS = 3e5;
2869
+ var EXIT_WAITING_FOR_PERSON = 4;
2870
+ var EXIT_JOB_ENDED = 5;
2871
+ var EXIT_INTERRUPTED = 130;
2872
+ var INTERRUPTED = "interrupted";
2873
+ var READER_GONE = "reader-gone";
2874
+ var ENDED_STATES = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
2875
+ var CHANGE_SET_FIELDS = [
2876
+ "manifest_id",
2877
+ "version",
2878
+ "digest",
2879
+ "status",
2880
+ "plan_version",
2881
+ "max_effect_count",
2882
+ "actions"
2883
+ ];
2884
+ var CHANGE_SET_ACTION_FIELDS = [
2885
+ "id",
2886
+ "action_type",
2887
+ "action_version",
2888
+ "display_order",
2889
+ "effect_count",
2890
+ "required_capability_ids",
2891
+ "digest"
2892
+ ];
2893
+ var WITHHELD_FIELDS = /* @__PURE__ */ new Set([
2894
+ "objective",
2895
+ "objective_text",
2896
+ "instruction",
2897
+ "instructions",
2898
+ "description",
2899
+ "result",
2900
+ "results",
2901
+ "outputs",
2902
+ "investigation",
2903
+ "read_material",
2904
+ "public_research",
2905
+ "source_review",
2906
+ "review_actions",
2907
+ "manifest_actions",
2908
+ "recipe",
2909
+ "lua",
2910
+ "lua_source"
2911
+ ]);
2912
+ var CODE = /^[a-z0-9_.:-]{1,64}$/;
2913
+ var TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
2914
+ function requireJobsArgs(opts) {
2915
+ if (opts.status !== void 0) normalizeStatus(opts.status);
2916
+ if (opts.limit !== void 0 && (!Number.isInteger(opts.limit) || opts.limit < 1 || opts.limit > JOBS_MAX_LIMIT)) {
2917
+ throw new UsageError(`--limit takes an integer from 1 to ${JOBS_MAX_LIMIT}`);
2918
+ }
2919
+ }
2920
+ function normalizeStatus(status) {
2921
+ const tokens = status.split(",").map((token) => token.trim().toLowerCase()).filter((token) => token !== "");
2922
+ const known = [...JOB_STATES, ...JOB_STATUS_ALIASES];
2923
+ const unknown = tokens.filter((token) => !known.includes(token));
2924
+ if (tokens.length === 0 || unknown.length > 0) {
2925
+ throw new UsageError(
2926
+ `${tokens.length === 0 ? "--status is empty" : `unknown job status: ${unknown.join(", ")}`} \u2014 pass job states (${JOB_STATES.join(", ")}) or ${JOB_STATUS_ALIASES.join(", ")}, comma-separated`
2927
+ );
2928
+ }
2929
+ return tokens.join(",");
2930
+ }
2931
+ async function jobsList(ctx, opts) {
2932
+ requireJobsArgs(opts);
2933
+ const params = {
2934
+ status: opts.status === void 0 ? void 0 : normalizeStatus(opts.status),
2935
+ mine: opts.mine === true ? true : void 0,
2936
+ // Reading everything: full pages, so the walk takes as few requests as it can.
2937
+ limit: opts.limit ?? (opts.all === true ? JOBS_MAX_LIMIT : void 0)
2938
+ };
2939
+ let page = await jobsPage(ctx, params, void 0);
2940
+ let jobs = page.jobs;
2941
+ if (opts.all !== true) {
2942
+ if (!page.hasMore) return { json: jobs, human: jobsTable(jobs) };
2943
+ if (jobs.length === 0) {
2944
+ return {
2945
+ json: jobs,
2946
+ human: "No Operator jobs you can see on this page.\n",
2947
+ notice: "harness: older jobs may exist past this page \u2014 pass --all to read on.\n"
2948
+ };
2949
+ }
2950
+ return {
2951
+ json: jobs,
2952
+ human: jobsTable(jobs),
2953
+ notice: `harness: showing the newest ${plural(jobs.length, "Operator job")} \u2014 pass --all for older ones.
2954
+ `
2955
+ };
2956
+ }
2957
+ const seen = /* @__PURE__ */ new Set();
2958
+ let pages = 1;
2959
+ while (page.hasMore && pages < JOBS_MAX_PAGES) {
2960
+ const cursor = page.nextCursor;
2961
+ if (seen.has(cursor)) throw new CommandError("The jobs response repeated a cursor.");
2962
+ seen.add(cursor);
2963
+ page = await jobsPage(ctx, params, cursor);
2964
+ pages += 1;
2965
+ jobs = [...jobs, ...page.jobs];
2966
+ }
2967
+ jobs = uniqueById(jobs);
2968
+ if (!page.hasMore) return { json: jobs, human: jobsTable(jobs) };
2969
+ return {
2970
+ json: jobs,
2971
+ human: jobsTable(jobs),
2972
+ notice: `harness: stopped after ${JOBS_MAX_PAGES} pages (${plural(jobs.length, "job")}) without reaching the end \u2014 narrow the list with --status or --mine.
2973
+ `,
2974
+ exitCode: 1
2975
+ };
2976
+ }
2977
+ async function jobsPage(ctx, params, before) {
2978
+ const body = await explained(
2979
+ () => ctx.client.get("/api/v1/operator/jobs", { ...params, before }),
2980
+ { notFound: "this Harness server has no Operator API yet" }
2981
+ );
2982
+ const jobs = objects(body.data, "jobs").map(withheld);
2983
+ const meta = body.meta;
2984
+ if (typeof meta !== "object" || meta === null || typeof meta["has_more"] !== "boolean") {
2985
+ throw new CommandError("The jobs response has no paging metadata.");
2986
+ }
2987
+ if (meta["has_more"] !== true) return { jobs, hasMore: false, nextCursor: null };
2988
+ const cursor = meta["next_cursor"];
2989
+ if (typeof cursor !== "string" || cursor === "") {
2990
+ throw new CommandError("The jobs response says more exist but gives no cursor.");
2991
+ }
2992
+ return { jobs, hasMore: true, nextCursor: cursor };
2993
+ }
2994
+ function requireJobId(id) {
2995
+ if (!isUuid(id)) throw new UsageError(`not an Operator job id: ${id} (expected the job's UUID)`);
2996
+ }
2997
+ async function jobShow(ctx, id, opts = {}) {
2998
+ requireJobId(id);
2999
+ const body = await explained(() => ctx.client.get(`/api/v1/operator/jobs/${id}`), {
3000
+ notFound: missingJob(id)
3001
+ });
3002
+ if (!isRecord2(body.data)) throw new CommandError("The job response carried no job.");
3003
+ const job = withheld(body.data);
3004
+ if (opts.changes !== true && opts.people !== true) return { json: job, human: jobDetail(job) };
3005
+ const document = Object.fromEntries(
3006
+ ["id", "state", "waiting_on", "url", "approval_url"].map((key) => [key, job[key] ?? null])
3007
+ );
3008
+ const sections = [];
3009
+ let notice;
3010
+ if (opts.changes === true) {
3011
+ const changeSet = await jobChangeSet(ctx, id);
3012
+ document["change_set"] = changeSet;
3013
+ sections.push(changesText(id, job, changeSet));
3014
+ notice = changeSetRace(job, changeSet);
3015
+ }
3016
+ if (opts.people === true) {
3017
+ const people = jobPeople(job);
3018
+ document["people"] = people;
3019
+ sections.push(peopleText(id, people));
3020
+ }
3021
+ return { json: document, human: sections.join("\n"), ...notice === void 0 ? {} : { notice } };
3022
+ }
3023
+ function changeSetRace(job, changeSet) {
3024
+ const pending = field2(job, "pending_approval", "manifest_id");
3025
+ if (typeof pending !== "string") return void 0;
3026
+ const read = changeSet === null ? null : changeSet["manifest_id"];
3027
+ if (read === pending) return void 0;
3028
+ return `harness: the job waits for approval of change set ${clean(pending)}, but the change set read next is ${read === null ? "none" : clean(read)}: the job changed between the two reads, so run it again.
3029
+ `;
3030
+ }
3031
+ async function jobChangeSet(ctx, id) {
3032
+ const body = await explained(() => ctx.client.get(`/api/v1/operator/jobs/${id}/changes`), {
3033
+ notFound: missingJob(id)
3034
+ });
3035
+ if (body.data === null) return null;
3036
+ if (!isRecord2(body.data)) throw new CommandError("The change set response carried no change set.");
3037
+ return {
3038
+ ...pick(body.data, CHANGE_SET_FIELDS),
3039
+ actions: records3(body.data["actions"]).map((action) => pick(action, CHANGE_SET_ACTION_FIELDS))
3040
+ };
3041
+ }
3042
+ function jobPeople(job) {
3043
+ const participants = records3(field2(job, "people", "participants"));
3044
+ const owner = isRecord2(job["owner"]) ? job["owner"] : null;
3045
+ const names = /* @__PURE__ */ new Map();
3046
+ for (const named2 of owner === null ? participants : [...participants, owner]) {
3047
+ if (typeof named2["user_id"] === "string" && typeof named2["name"] === "string" && named2["name"] !== "") {
3048
+ if (!names.has(named2["user_id"])) names.set(named2["user_id"], named2["name"]);
3049
+ }
3050
+ }
3051
+ const person = (userId) => typeof userId === "string" && userId !== "" ? { user_id: userId, name: names.get(userId) ?? null } : null;
3052
+ const some = (people) => people.filter((entry) => entry !== null);
3053
+ const approvers = field2(job, "people", "approver_user_ids");
3054
+ return {
3055
+ requester: person(job["requester_user_id"]),
3056
+ execution_principal: person(job["execution_principal_user_id"]),
3057
+ owner: owner === null ? null : person(owner["user_id"]),
3058
+ approvers: some((Array.isArray(approvers) ? approvers : []).map(person)),
3059
+ collaborators: some(participants.filter((entry) => entry["role"] === "collaborator").map((entry) => person(entry["user_id"])))
3060
+ };
3061
+ }
3062
+ async function jobWatch(ctx, id, opts) {
3063
+ requireJobId(id);
3064
+ if (opts.after !== void 0 && (!Number.isInteger(opts.after) || opts.after < 0)) {
3065
+ throw new UsageError("--after takes a non-negative event sequence");
3066
+ }
3067
+ const emit = opts.onLine ?? (() => {
3068
+ });
3069
+ const sleep = opts.sleep ?? abortableSleep;
3070
+ const signal = opts.signal;
3071
+ let after = opts.after ?? 0;
3072
+ const events = [];
3073
+ let job = null;
3074
+ let shown = null;
3075
+ let shownApprovalUrl = null;
3076
+ const document = (outcome, extra = {}) => ({
3077
+ id,
3078
+ outcome,
3079
+ ...extra,
3080
+ job: job === null ? null : jobStateJson(job),
3081
+ ...opts.collectEvents === true ? { events } : {},
3082
+ next_after: after
3083
+ });
3084
+ const stopped = (reason) => reason === READER_GONE ? (
3085
+ // Nobody is reading any more: end quietly.
3086
+ { json: document("stopped"), human: "", exitCode: 0 }
3087
+ ) : { json: document("interrupted"), human: "", notice: resumeLine(id, after), exitCode: EXIT_INTERRUPTED };
3088
+ for (; ; ) {
3089
+ if (aborted(signal)) return stopped(signal?.reason);
3090
+ let page;
3091
+ try {
3092
+ page = await withRetries(() => eventsPage(ctx, id, after), sleep, signal);
3093
+ } catch (error) {
3094
+ if (aborted(signal)) return stopped(signal?.reason);
3095
+ if (!transient(error)) throw error;
3096
+ const message = failureMessage(error);
3097
+ return {
3098
+ json: document("error", {
3099
+ error: { message, status: error instanceof HttpError ? error.status : null }
3100
+ }),
3101
+ human: "",
3102
+ notice: `harness: ${message}
3103
+ ` + resumeLine(id, after),
3104
+ exitCode: error instanceof TransportError ? 3 : 1
3105
+ };
3106
+ }
3107
+ for (const event of page.events) {
3108
+ if (opts.collectEvents === true) events.push(eventJson(event));
3109
+ emit(eventLine(event));
3110
+ }
3111
+ after = page.nextAfter;
3112
+ job = page.job;
3113
+ if (page.hasMore) continue;
3114
+ const state = str5(job["state"]);
3115
+ const waitingOn = typeof job["waiting_on"] === "string" ? job["waiting_on"] : null;
3116
+ if (shown === null || shown.state !== state || shown.waitingOn !== waitingOn) {
3117
+ emit(stateLine(shown?.state ?? null, state, waitingOn));
3118
+ shown = { state, waitingOn };
3119
+ }
3120
+ const approvalUrl = typeof job["approval_url"] === "string" ? job["approval_url"] : null;
3121
+ if (opts.throughWaits === true && approvalUrl !== null && approvalUrl !== shownApprovalUrl) {
3122
+ emit(`approve in the app: ${clean(approvalUrl)}
3123
+ `);
3124
+ }
3125
+ shownApprovalUrl = approvalUrl;
3126
+ let outcome = null;
3127
+ if (ENDED_STATES.has(state)) outcome = state;
3128
+ else if (waitingOn !== null && opts.throughWaits !== true) outcome = "waiting";
3129
+ if (outcome !== null) {
3130
+ return {
3131
+ json: document(outcome),
3132
+ human: outcomeText(id, outcome, job),
3133
+ exitCode: outcome === "completed" ? 0 : outcome === "waiting" ? EXIT_WAITING_FOR_PERSON : EXIT_JOB_ENDED
3134
+ };
3135
+ }
3136
+ if (aborted(signal)) return stopped(signal?.reason);
3137
+ await sleep(waitingOn !== null || state === "paused" ? WATCH_WAITING_INTERVAL_MS : WATCH_INTERVAL_MS, signal);
3138
+ }
3139
+ }
3140
+ function resumeLine(id, after) {
3141
+ const name = invokedName();
3142
+ return `${name}: stopped watching job ${id}; resume with: ${name} watch ${id} --after ${after}
3143
+ `;
3144
+ }
3145
+ function watchStopSignal(stdout = process.stdout, signals = process) {
3146
+ const controller = new AbortController();
3147
+ const readerGone = () => controller.abort(READER_GONE);
3148
+ stdout.once("error", readerGone);
3149
+ stdout.once("close", readerGone);
3150
+ signals.once("SIGINT", () => controller.abort(INTERRUPTED));
3151
+ return controller.signal;
3152
+ }
3153
+ function aborted(signal) {
3154
+ return signal?.aborted === true;
3155
+ }
3156
+ function abortableSleep(ms, signal) {
3157
+ return new Promise((resolve) => {
3158
+ if (aborted(signal)) {
3159
+ resolve();
3160
+ return;
3161
+ }
3162
+ const done = () => {
3163
+ clearTimeout(timer);
3164
+ signal?.removeEventListener("abort", done);
3165
+ resolve();
3166
+ };
3167
+ const timer = setTimeout(done, ms);
3168
+ signal?.addEventListener("abort", done, { once: true });
3169
+ });
3170
+ }
3171
+ function transient(error) {
3172
+ if (error instanceof TransportError) return true;
3173
+ return error instanceof HttpError && !(error instanceof UpgradeRequiredError) && (error.status >= 500 || error.status === 429);
3174
+ }
3175
+ async function withRetries(send, sleep, signal) {
3176
+ for (let attempt = 0; ; attempt++) {
3177
+ try {
3178
+ return await send();
3179
+ } catch (error) {
3180
+ if (attempt >= WATCH_RETRIES || !transient(error) || aborted(signal)) throw error;
3181
+ await sleep(retryDelay(error, attempt), signal);
3182
+ if (aborted(signal)) throw error;
3183
+ }
3184
+ }
3185
+ }
3186
+ function retryDelay(error, attempt) {
3187
+ const backoff = RETRY_BASE_MS * 2 ** attempt;
3188
+ const asked = error instanceof HttpError ? retryAfterMs(error.retryAfter) : null;
3189
+ return asked === null ? backoff : Math.max(backoff, Math.min(asked, RETRY_AFTER_CAP_MS));
3190
+ }
3191
+ function retryAfterMs(header) {
3192
+ if (header === null) return null;
3193
+ const value = header.trim();
3194
+ if (/^\d+$/.test(value)) return Number.parseInt(value, 10) * 1e3;
3195
+ const at = Date.parse(value);
3196
+ return Number.isNaN(at) ? null : Math.max(0, at - Date.now());
3197
+ }
3198
+ function failureMessage(error) {
3199
+ if (error instanceof TransportError) return `cannot reach the API: ${clean(error.message)}`;
3200
+ return `${clean(error.message)} (HTTP ${error.status})${errorDetails(error.body)}`;
3201
+ }
3202
+ async function eventsPage(ctx, id, after) {
3203
+ const body = await explained(
3204
+ () => ctx.client.get(`/api/v1/operator/jobs/${id}/events`, {
3205
+ after,
3206
+ limit: EVENTS_PAGE_LIMIT
3207
+ }),
3208
+ { notFound: missingJob(id) }
3209
+ );
3210
+ const events = objects(body.data, "events");
3211
+ const meta = body.meta;
3212
+ if (typeof meta !== "object" || meta === null) throw new CommandError("The events response has no metadata.");
3213
+ const nextAfter = meta["next_after"];
3214
+ const hasMore = meta["has_more"];
3215
+ const job = meta["job"];
3216
+ if (typeof nextAfter !== "number" || !Number.isInteger(nextAfter) || nextAfter < after) {
3217
+ throw new CommandError("The events response has an invalid next_after.");
3218
+ }
3219
+ if (typeof hasMore !== "boolean") throw new CommandError("The events response has an invalid has_more.");
3220
+ if (!isRecord2(job) || typeof job["state"] !== "string") {
3221
+ throw new CommandError("The events response does not carry the job's state.");
3222
+ }
3223
+ if (hasMore && nextAfter === after) {
3224
+ throw new CommandError("The events response says more exist but did not move its cursor.");
3225
+ }
3226
+ return { events, nextAfter, hasMore, job };
3227
+ }
3228
+ var REFUSAL_HINTS = {
3229
+ operator_disabled: "nothing can be read until Operator is switched back on for this organization",
3230
+ operator_not_permitted: "only the organization's admins and members use Operator; guests and people outside it cannot",
3231
+ actor_required: `the credential acts as no person: sign in with \`${invokedName()} login\`, or use an API token a person created`,
3232
+ 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`,
3233
+ run_bound_credential: `a run-bound credential cannot read Operator jobs; sign in with \`${invokedName()} login\``
3234
+ };
3235
+ function missingJob(id) {
3236
+ 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)`;
3237
+ }
3238
+ async function explained(send, why) {
3239
+ try {
3240
+ return await send();
3241
+ } catch (error) {
3242
+ if (!(error instanceof HttpError)) throw error;
3243
+ const code = error.body["error"];
3244
+ let hint;
3245
+ if (error.status === 403 && typeof code === "string") hint = REFUSAL_HINTS[code];
3246
+ if (error.status === 404) hint = why.notFound;
3247
+ if (error.status === 400 && code === "invalid_cursor") hint = "run the list again from the start";
3248
+ if (hint === void 0) throw error;
3249
+ throw new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
3250
+ }
3251
+ }
3252
+ function jobsTable(jobs) {
3253
+ if (jobs.length === 0) return "No Operator jobs.\n";
3254
+ const rows = jobs.map((job) => [
3255
+ str5(job["id"]),
3256
+ str5(job["state"]) || "-",
3257
+ str5(job["waiting_on"]) || "-",
3258
+ str5(job["objective_kind"]) || "-",
3259
+ personName(job["owner"]),
3260
+ str5(field2(job, "plan_step", "issue")) || "-",
3261
+ str5(job["updated_at"]) || "-"
3262
+ ]);
3263
+ const table2 = table(["ID", "STATE", "WAITING ON", "KIND", "OWNER", "PLAN STEP", "UPDATED"], rows);
3264
+ const approvals = jobs.filter((job) => typeof job["approval_url"] === "string");
3265
+ if (approvals.length === 0) return table2;
3266
+ return table2 + "\nWaiting for approval. Approve in the app; the CLI cannot:\n" + approvals.map((job) => ` ${clean(job["id"])} ${clean(job["approval_url"])}
3267
+ `).join("");
3268
+ }
3269
+ function jobDetail(job) {
3270
+ const waitingOn = str5(job["waiting_on"]);
3271
+ const header = `Operator job ${clean(job["id"])} \xB7 ${clean(job["state"])}` + (waitingOn === "" ? "" : ` \xB7 waiting on ${clean(waitingOn)}`) + "\n";
3272
+ const people = records3(field2(job, "people", "participants"));
3273
+ const fields = [
3274
+ ["Kind", str5(job["objective_kind"]) || "-"],
3275
+ ["Role", str5(job["viewer_role"]) || null],
3276
+ ["Owner", isRecord2(job["owner"]) ? personName(job["owner"]) : null],
3277
+ ["Plan step", planStepLine(job["plan_step"])],
3278
+ ["Plan", planLine(job["plan"])],
3279
+ ["Progress", progressLine(job["progress"])],
3280
+ ["Window", windowLine(job["window"])],
3281
+ ["Spend", spendLine(job["spend"])],
3282
+ ["Verification", str5(field2(job, "verification", "status")) || null],
3283
+ ["Effects", effectsLine(job["effects"])],
3284
+ ["Skill", skillLine(job["skill"])],
3285
+ ["Grant", grantLine(job["grant"], people)],
3286
+ ["Created", str5(job["inserted_at"]) || null],
3287
+ ["Updated", str5(job["updated_at"]) || null],
3288
+ ["Workbench", str5(job["url"]) || null]
3289
+ ];
3290
+ return header + labelled(fields) + approvalSection(job["pending_approval"], people) + listSection("Blockers", records3(job["blockers"]), (item) => `${str5(item["kind"])}: ${str5(item["state"])}`) + listSection("Artifacts", records3(job["artifacts"]), (item) => `${str5(item["id"])} ${str5(item["url"])}`) + peopleSection(people) + listSection(
3291
+ "Procedures",
3292
+ records3(job["procedures"]),
3293
+ (procedure) => `${str5(procedure["name"])} v${str5(procedure["current_version"])} ${str5(procedure["id"])}`
3294
+ ) + controlsLine(job["viewer_can"]) + "\nThe objective's text is not shown: the API never returns it, because it can quote what Operator read. Open the workbench link to read it.\n";
3295
+ }
3296
+ function labelled(fields, width = 15) {
3297
+ return fields.filter((entry) => entry[1] !== null).map(([label, value]) => ` ${(label + ":").padEnd(width)}${clean(value)}
3298
+ `).join("");
3299
+ }
3300
+ function planStepLine(step) {
3301
+ if (!isRecord2(step)) return null;
3302
+ return `${str5(step["issue"])} (plan ${str5(step["plan_id"])})`;
3303
+ }
3304
+ function planLine(plan) {
3305
+ if (!isRecord2(plan)) return "none yet";
3306
+ const count = plan["capability_count"];
3307
+ return `version ${str5(plan["version"])} \xB7 instructions v${str5(plan["instruction_version"])} \xB7 ${str5(count)} capabilit${count === 1 ? "y" : "ies"}`;
3308
+ }
3309
+ function progressLine(progress) {
3310
+ if (!isRecord2(progress)) return null;
3311
+ const parts = [str5(progress["stage"]), str5(progress["situation"])].filter((part) => part !== "");
3312
+ if (progress["needs_viewer"] === true) parts.push("needs you");
3313
+ return parts.length === 0 ? null : parts.join(" \xB7 ");
3314
+ }
3315
+ function windowLine(window) {
3316
+ if (!isRecord2(window)) return null;
3317
+ const left = duration(window["remaining_ms"]);
3318
+ const budget = duration(window["budget_ms"]);
3319
+ const sequence = window["sequence"];
3320
+ return `${left ?? "?"} left${budget === null ? "" : ` of ${budget}`} \xB7 ${str5(window["state"])}` + (typeof sequence === "number" ? ` (window ${sequence})` : "");
3321
+ }
3322
+ function spendLine(spend) {
3323
+ if (!isRecord2(spend)) return null;
3324
+ const spent = `${str5(spend["spent_cents"]) || "0"} cents spent`;
3325
+ if (typeof spend["plan_budget_cents"] !== "number") return spent;
3326
+ const remaining = spend["plan_remaining_cents"];
3327
+ return `${spent} \xB7 plan budget ${spend["plan_budget_cents"]} cents` + (typeof remaining === "number" ? `, ${remaining} cents left` : "");
3328
+ }
3329
+ function effectsLine(effects) {
3330
+ if (!isRecord2(effects)) return null;
3331
+ return ["staged", "succeeded", "failed", "unknown"].map((key) => `${str5(effects[key]) || "0"} ${key}`).join(" \xB7 ");
3332
+ }
3333
+ function skillLine(skill) {
3334
+ if (!isRecord2(skill)) return null;
3335
+ return `${str5(skill["title"])} (${str5(skill["invocation_name"])}) \xB7 sha256 ${str5(skill["content_sha256"])} \xB7 pinned ${str5(skill["pinned_at"])}`;
3336
+ }
3337
+ function grantLine(grant, people) {
3338
+ if (!isRecord2(grant)) return null;
3339
+ const approvedAt = str5(grant["approved_at"]);
3340
+ const expiresAt = str5(grant["expires_at"]);
3341
+ return `${str5(grant["status"])} v${str5(grant["version"])} \xB7 approved by ${nameOf(grant["approver_user_id"], people)}` + (approvedAt === "" ? "" : ` at ${approvedAt}`) + ` \xB7 ${expiresAt === "" ? "no expiry" : `expires ${expiresAt}`}`;
3342
+ }
3343
+ function approvalSection(pending, people) {
3344
+ if (!isRecord2(pending)) return "";
3345
+ const approvers = Array.isArray(pending["approver_user_ids"]) ? pending["approver_user_ids"].map((id) => nameOf(id, people)).join(", ") : "";
3346
+ const canApprove = pending["viewer_can_approve"] === true ? "you can approve" : "you cannot approve it";
3347
+ return "\nWaiting for approval. Approve it in the app; the CLI cannot:\n" + labelled([
3348
+ [
3349
+ "Change set",
3350
+ `${str5(pending["manifest_id"])} v${str5(pending["version"])} \xB7 ${str5(pending["action_count"])} actions \xB7 up to ${str5(pending["max_effect_count"])} effects \xB7 plan version ${str5(pending["plan_version"])}`
3351
+ ],
3352
+ ["Digest", str5(pending["digest"]) || null],
3353
+ ["Approvers", approvers === "" ? canApprove : `${approvers} \xB7 ${canApprove}`],
3354
+ ["Approve at", str5(pending["url"]) || null]
3355
+ ]);
3356
+ }
3357
+ function peopleSection(people) {
3358
+ if (people.length === 0) return "";
3359
+ const rows = people.map((person) => [
3360
+ str5(person["name"]) || "(no name)",
3361
+ str5(person["role"]),
3362
+ str5(person["user_id"])
3363
+ ]);
3364
+ const lines = table(["", "", ""], rows).split("\n").slice(1, -1);
3365
+ return "\nPeople:\n" + lines.map((line) => ` ${line}
3366
+ `).join("");
3367
+ }
3368
+ function changesText(id, job, changeSet) {
3369
+ if (changeSet === null) return `No pending change set: Operator has proposed nothing for job ${id} yet.
3370
+ `;
3371
+ const approvalUrl = str5(job["approval_url"]);
3372
+ const rows = records3(changeSet["actions"]).map((action) => [
3373
+ str5(action["display_order"]) || "-",
3374
+ str5(action["action_type"]),
3375
+ str5(action["action_version"]),
3376
+ str5(action["effect_count"]),
3377
+ (Array.isArray(action["required_capability_ids"]) ? action["required_capability_ids"].map(str5).join(", ") : "") || "-",
3378
+ str5(action["digest"]),
3379
+ str5(action["id"])
3380
+ ]);
3381
+ const actions = rows.length === 0 ? "\nActions: none\n" : "\nActions:\n" + table(["#", "ACTION", "VERSION", "EFFECTS", "CAPABILITIES", "DIGEST", "ID"], rows).split("\n").slice(0, -1).map((line) => ` ${line}
3382
+ `).join("");
3383
+ return `Change set of Operator job ${id} \xB7 ${clean(changeSet["status"])}
3384
+ ` + labelled([
3385
+ [
3386
+ "Change set",
3387
+ `${str5(changeSet["manifest_id"])} v${str5(changeSet["version"])} \xB7 plan version ${str5(changeSet["plan_version"])} \xB7 up to ${str5(changeSet["max_effect_count"])} effects`
3388
+ ],
3389
+ ["Digest", str5(changeSet["digest"]) || null],
3390
+ approvalUrl === "" ? ["Workbench", str5(job["url"]) || null] : ["Approve at", approvalUrl]
3391
+ ]) + actions + "\nValues and targets are not shown: review them in the app at the " + (approvalUrl === "" ? "workbench link.\n" : "approval link. The CLI cannot approve.\n");
3392
+ }
3393
+ function peopleText(id, people) {
3394
+ const one = (entry) => isRecord2(entry) ? str5(entry["name"]) || str5(entry["user_id"]) : "-";
3395
+ const many = (entries) => (Array.isArray(entries) ? entries.map(one).join(", ") : "") || "none";
3396
+ return `People on Operator job ${id}:
3397
+ ` + labelled(
3398
+ [
3399
+ ["Requester", one(people["requester"])],
3400
+ ["Execution principal", one(people["execution_principal"])],
3401
+ ["Owner", one(people["owner"])],
3402
+ ["Approvers", many(people["approvers"])],
3403
+ ["Collaborators", many(people["collaborators"])]
3404
+ ],
3405
+ 22
3406
+ );
3407
+ }
3408
+ function listSection(title, items, line) {
3409
+ if (items.length === 0) return "";
3410
+ return `
3411
+ ${title}:
3412
+ ` + items.map((item) => ` ${clean(line(item))}
3413
+ `).join("");
3414
+ }
3415
+ function controlsLine(viewerCan) {
3416
+ const controls = isRecord2(viewerCan) ? ["steer", "cancel", "continue", "pause", "save_procedure"].filter((key) => viewerCan[key] === true) : [];
3417
+ return `
3418
+ You can, in the workbench: ${controls.length === 0 ? "nothing right now" : controls.join(", ")}
3419
+ `;
3420
+ }
3421
+ function eventLine(event) {
3422
+ const actor = isRecord2(event["actor"]) ? event["actor"] : {};
3423
+ const actorKind = safeValue(actor["kind"]) || "?";
3424
+ const actorId = safeValue(actor["delegated_grant_id"] ?? actor["user_id"]);
3425
+ const data = isRecord2(event["data"]) ? Object.entries(event["data"]).map(([key, value]) => `${clean(key)}=${safeValue(value) || "\u2026"}`) : [];
3426
+ return `#${clean(event["sequence"])} ${clean(event["occurred_at"])} ${clean(event["kind"])} ${actorId === "" ? actorKind : `${actorKind} ${actorId}`}` + (data.length === 0 ? "" : ` ${data.join(" ")}`) + "\n";
3427
+ }
3428
+ function eventJson(event) {
3429
+ const actor = isRecord2(event["actor"]) ? event["actor"] : {};
3430
+ const data = isRecord2(event["data"]) ? event["data"] : {};
3431
+ return {
3432
+ sequence: typeof event["sequence"] === "number" ? event["sequence"] : null,
3433
+ kind: safeValue(event["kind"]) || null,
3434
+ occurred_at: typeof event["occurred_at"] === "string" && TIMESTAMP.test(event["occurred_at"]) ? event["occurred_at"] : null,
3435
+ actor: Object.fromEntries(
3436
+ ["kind", "user_id", "delegated_grant_id"].filter((key) => key in actor && (actor[key] === null || safeValue(actor[key]) !== "")).map((key) => [key, actor[key]])
3437
+ ),
3438
+ data: Object.fromEntries(Object.entries(data).filter(([key, value]) => CODE.test(key) && safeValue(value) !== ""))
3439
+ };
3440
+ }
3441
+ function jobStateJson(job) {
3442
+ return Object.fromEntries(
3443
+ ["state", "waiting_on", "updated_at", "url", "approval_url"].filter((key) => key in job).map((key) => [key, job[key]])
3444
+ );
3445
+ }
3446
+ function stateLine(previous, state, waitingOn) {
3447
+ const now = clean(state) + (waitingOn === null ? "" : `, waiting on ${clean(waitingOn)}`);
3448
+ return previous === null || previous === state ? `state: ${now}
3449
+ ` : `state: ${clean(previous)} \u2192 ${now}
3450
+ `;
3451
+ }
3452
+ function outcomeText(id, outcome, job) {
3453
+ const url = clean(job["url"]);
3454
+ switch (outcome) {
3455
+ case "completed":
3456
+ return `Job ${id} completed.
3457
+ Workbench: ${url}
3458
+ `;
3459
+ case "failed":
3460
+ return `Job ${id} failed.
3461
+ Workbench: ${url}
3462
+ `;
3463
+ case "cancelled":
3464
+ return `Job ${id} was cancelled.
3465
+ Workbench: ${url}
3466
+ `;
3467
+ case "waiting": {
3468
+ const waitingOn = job["waiting_on"];
3469
+ if (waitingOn === "approval") {
3470
+ const link = clean(job["approval_url"]) || url;
3471
+ return `Job ${id} is waiting for approval. Approve it in the app; the CLI cannot:
3472
+ ${link}
3473
+ `;
3474
+ }
3475
+ const what = waitingOn === "person" ? "a person" : clean(waitingOn);
3476
+ return `Job ${id} is waiting on ${what}; a person has to act in the workbench:
3477
+ ${url}
3478
+ `;
3479
+ }
3480
+ }
3481
+ }
3482
+ function withheld(job) {
3483
+ return Object.fromEntries(
3484
+ Object.entries(job).filter(([key]) => !WITHHELD_FIELDS.has(key) && !key.startsWith("knowledge"))
3485
+ );
3486
+ }
3487
+ function pick(record, keys) {
3488
+ return Object.fromEntries(keys.filter((key) => key in record).map((key) => [key, record[key]]));
3489
+ }
3490
+ function objects(value, what) {
3491
+ if (!Array.isArray(value) || !value.every(isRecord2)) {
3492
+ throw new CommandError(`The ${what} response data is not a list of objects.`);
3493
+ }
3494
+ return value;
3495
+ }
3496
+ function plural(count, noun) {
3497
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
3498
+ }
3499
+ function uniqueById(jobs) {
3500
+ const seen = /* @__PURE__ */ new Set();
3501
+ return jobs.filter((job) => {
3502
+ const id = str5(job["id"]);
3503
+ if (seen.has(id)) return false;
3504
+ seen.add(id);
3505
+ return true;
3506
+ });
3507
+ }
3508
+ function personName(person) {
3509
+ if (!isRecord2(person)) return "-";
3510
+ return str5(person["name"]) || str5(person["user_id"]) || "-";
3511
+ }
3512
+ function nameOf(userId, people) {
3513
+ const id = str5(userId);
3514
+ const person = people.find((candidate) => candidate["user_id"] === id);
3515
+ return (person === void 0 ? "" : str5(person["name"])) || id || "?";
3516
+ }
3517
+ function duration(ms) {
3518
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return null;
3519
+ const seconds = Math.max(0, Math.floor(ms / 1e3));
3520
+ const hours = Math.floor(seconds / 3600);
3521
+ const minutes = Math.floor(seconds % 3600 / 60);
3522
+ const rest = seconds % 60;
3523
+ if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`;
3524
+ if (minutes > 0) return `${minutes}m ${String(rest).padStart(2, "0")}s`;
3525
+ return `${rest}s`;
3526
+ }
3527
+ function safeValue(value) {
3528
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
3529
+ if (typeof value === "boolean") return String(value);
3530
+ if (typeof value === "string" && CODE.test(value)) return value;
3531
+ return "";
3532
+ }
3533
+ function field2(record, ...keys) {
3534
+ let current = record;
3535
+ for (const key of keys) {
3536
+ if (!isRecord2(current)) return null;
3537
+ current = current[key];
3538
+ }
3539
+ return current ?? null;
3540
+ }
3541
+ function records3(value) {
3542
+ return Array.isArray(value) ? value.filter(isRecord2) : [];
3543
+ }
3544
+ function isRecord2(value) {
3545
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3546
+ }
3547
+ function str5(value) {
3548
+ return value === null || value === void 0 ? "" : String(value);
3549
+ }
3550
+
3551
+ // src/commands/operator-write.ts
3552
+ var ACCESS_HINTS = {
3553
+ ...REFUSAL_HINTS,
3554
+ operator_disabled: "nothing can be changed until Operator is switched back on for this organization",
3555
+ actor_required: "the credential acts as no person: use a browser login, or an API token a person created",
3556
+ forbidden: "the credential lacks the operator:write scope (a browser login carries it; an aone_ token must be created with it, bound to this organization), or you are a guest or outside the organization",
3557
+ run_bound_credential: "a run-bound credential cannot change Operator jobs; use a browser login"
3558
+ };
3559
+ var BAD_REQUEST_HINT = "nothing changed: the server refused a field this CLI sent, so this CLI and the server disagree about the request \u2014 update the CLI";
3560
+ var NEVER_SENT = /* @__PURE__ */ new Set([
3561
+ "ENOTFOUND",
3562
+ "EAI_AGAIN",
3563
+ "EAI_FAIL",
3564
+ "ECONNREFUSED",
3565
+ "ENETUNREACH",
3566
+ "EHOSTUNREACH",
3567
+ "EHOSTDOWN",
3568
+ "EADDRNOTAVAIL",
3569
+ "UND_ERR_CONNECT_TIMEOUT",
3570
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
3571
+ "SELF_SIGNED_CERT_IN_CHAIN",
3572
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
3573
+ "UNABLE_TO_GET_ISSUER_CERT",
3574
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
3575
+ "CERT_HAS_EXPIRED",
3576
+ "CERT_NOT_YET_VALID",
3577
+ "CERT_REVOKED",
3578
+ "CERT_UNTRUSTED",
3579
+ "CERT_REJECTED",
3580
+ "HOSTNAME_MISMATCH",
3581
+ "ERR_TLS_CERT_ALTNAME_INVALID",
3582
+ "ERR_SSL_WRONG_VERSION_NUMBER"
3583
+ ]);
3584
+ function neverSent(error) {
3585
+ return !(error instanceof MalformedResponseError) && error.code !== null && NEVER_SENT.has(error.code);
3586
+ }
3587
+ function outcomeUnknown(why, write2) {
3588
+ return new CommandError(
3589
+ `Outcome unknown: ${why}, so ${write2.mayHave}. It is not retried; check ${write2.check} before trying again.`
3590
+ );
3591
+ }
3592
+ async function sendOnce(ctx, path2, body, write2) {
3593
+ try {
3594
+ return await ctx.client.post(path2, body, { actor: false });
3595
+ } catch (error) {
3596
+ if (error instanceof TransportError) {
3597
+ if (neverSent(error)) {
3598
+ throw new TransportError(
3599
+ `${error.message} \u2014 the request never reached the server, so nothing changed`,
3600
+ error.code
3601
+ );
3602
+ }
3603
+ throw outcomeUnknown(
3604
+ error instanceof MalformedResponseError ? `the answer could not be read (${error.message})` : `the request failed (${error.message}) after it may have reached the server`,
3605
+ write2
3606
+ );
3607
+ }
3608
+ if (error instanceof HttpError && !(error instanceof UpgradeRequiredError) && error.status >= 500) {
3609
+ throw new HttpError(
3610
+ error.status,
3611
+ `${error.message} \u2014 outcome unknown: ${write2.mayHave}. It is not retried; check ${write2.check} before trying again`,
3612
+ error.body,
3613
+ error.retryAfter
3614
+ );
3615
+ }
3616
+ throw error;
3617
+ }
3618
+ }
3619
+ function explainRefusal(error, notFound2, hints2) {
3620
+ if (!(error instanceof HttpError) || error instanceof UpgradeRequiredError) return error;
3621
+ const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
3622
+ const own = Object.hasOwn(hints2, code) ? hints2[code] : void 0;
3623
+ let hint;
3624
+ if (error.status >= 500) hint = own;
3625
+ else if (error.status === 404) hint = notFound2;
3626
+ else if (own !== void 0) hint = own;
3627
+ else if (error.status === 403 && Object.hasOwn(ACCESS_HINTS, code)) hint = ACCESS_HINTS[code];
3628
+ else if (error.status === 400 && code === "bad_request") hint = BAD_REQUEST_HINT;
3629
+ if (hint === void 0) return error;
3630
+ 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;
3631
+ return new HttpError(error.status, `${message} \u2014 ${hint}`, error.body, error.retryAfter);
3632
+ }
3633
+
3634
+ // src/commands/operator-run.ts
3635
+ var OUTCOME_MAX_CHARACTERS = 1e4;
3636
+ var BUDGET_MAX_DOLLARS = 1e6;
3637
+ var SKILL = /^\/?[a-z0-9]+(-[a-z0-9]+)*$/;
3638
+ var SKILL_MAX_CHARACTERS = 65;
3639
+ var BUDGET = /^\d+(\.\d{1,2})?$/;
3640
+ function prepareRun(outcome, opts = {}, surface = CLI_SURFACE) {
3641
+ const trimmed = outcome.trim();
3642
+ if (trimmed === "") throw new UsageError(`the outcome is empty \u2014 say what the job should achieve${surface.outcomeHint}`);
3643
+ const length = graphemeLength(trimmed);
3644
+ if (length > OUTCOME_MAX_CHARACTERS) {
3645
+ throw new UsageError(
3646
+ `the outcome is ${length} characters once trimmed; a job takes at most ${OUTCOME_MAX_CHARACTERS}`
3647
+ );
3648
+ }
3649
+ const body = { outcome: trimmed };
3650
+ if (opts.budget !== void 0) {
3651
+ const budget = opts.budget.trim();
3652
+ const amount = BUDGET.test(budget) ? Number(budget) : NaN;
3653
+ if (!(amount > 0) || amount > BUDGET_MAX_DOLLARS) {
3654
+ throw new UsageError(
3655
+ `${surface.budget} takes a positive amount in US dollars with at most two decimals, up to ${BUDGET_MAX_DOLLARS}`
3656
+ );
3657
+ }
3658
+ body["budget"] = amount;
3659
+ }
3660
+ if (opts.skill !== void 0) {
3661
+ const skill = opts.skill.trim();
3662
+ if (skill.length > SKILL_MAX_CHARACTERS || !SKILL.test(skill)) {
3663
+ throw new UsageError(
3664
+ `${surface.skill} takes a skill's invocation name, like /summarize-lps (${surface.skills} lists them)`
3665
+ );
3666
+ }
3667
+ body["skill"] = skill;
3668
+ }
3669
+ return { body };
3670
+ }
3671
+ var CLI_SURFACE = {
3672
+ budget: "--budget",
3673
+ skill: "--skill",
3674
+ outcomeHint: ': run "<outcome>"',
3675
+ skills: "`skills`",
3676
+ look: "`jobs --mine` for the new job",
3677
+ again: "run again"
3678
+ };
3679
+ var MCP_SURFACE = {
3680
+ budget: "budget",
3681
+ skill: "skill",
3682
+ outcomeHint: "",
3683
+ skills: "`skills_list`",
3684
+ look: "`jobs_list` with mine for the new job",
3685
+ again: "call again"
3686
+ };
3687
+ async function startRun(ctx, request, surface = CLI_SURFACE) {
3688
+ const write2 = { mayHave: "a job may have been started", check: surface.look };
3689
+ let data;
3690
+ try {
3691
+ data = (await sendOnce(ctx, "/api/v1/operator/jobs", request.body, write2)).data;
3692
+ } catch (error) {
3693
+ throw explainRefusal(error, "this Harness server has no Operator API for starting jobs yet", hints(error, surface));
3694
+ }
3695
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
3696
+ throw outcomeUnknown("the answer named no job", write2);
3697
+ }
3698
+ const record = data;
3699
+ const jobId = record["job_id"];
3700
+ if (typeof jobId !== "string" || jobId === "") throw outcomeUnknown("the answer named no job", write2);
3701
+ const text = (value) => typeof value === "string" ? value : "";
3702
+ return {
3703
+ job_id: jobId,
3704
+ state: text(record["state"]),
3705
+ workbench_url: text(record["workbench_url"]),
3706
+ approval_url: text(record["approval_url"]) === "" ? null : text(record["approval_url"])
3707
+ };
3708
+ }
3709
+ function hints(error, surface) {
3710
+ const retryAfter = error instanceof HttpError ? error.retryAfter : null;
3711
+ return {
3712
+ 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`,
3713
+ 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`,
3714
+ 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}`,
3715
+ invalid_outcome: `nothing was started: pass the outcome as text of 1 to ${OUTCOME_MAX_CHARACTERS} characters once trimmed, without control characters`,
3716
+ invalid_budget: `nothing was started: ${surface.budget} takes a positive amount in US dollars with at most two decimals, up to ${BUDGET_MAX_DOLLARS}`,
3717
+ unknown_skill: `nothing was started: no Operator skill of that name that you may run in the organization (${surface.skills} lists them)`,
3718
+ rate_limited: "nothing was started: you can start 30 jobs an hour this way" + (retryAfter === null ? "" : `; try again in ${clean(retryAfter)} s`),
3719
+ service_unavailable: "nothing was started; try again later"
3720
+ };
3721
+ }
3722
+ function startedText(job) {
3723
+ return `Started Operator job ${clean(job.job_id)}.
3724
+ ` + labelled([
3725
+ ["State", clean(job.state) || null],
3726
+ ["Workbench", clean(job.workbench_url) || null],
3727
+ ["Approve at", job.approval_url === null ? null : clean(job.approval_url)]
3728
+ ]) + (job.approval_url === null ? "" : "\nApproving happens in the app; the CLI cannot approve.\n");
3729
+ }
3730
+ async function run(ctx, request, opts = {}) {
3731
+ const job = await startRun(ctx, request);
3732
+ if (opts.watch !== true) return { json: job, human: startedText(job) };
3733
+ const json2 = opts.json === true;
3734
+ if (!json2) opts.onLine?.(startedText(job) + "\n");
3735
+ let watched;
3736
+ try {
3737
+ watched = await jobWatch(ctx, job.job_id, {
3738
+ throughWaits: true,
3739
+ collectEvents: json2,
3740
+ onLine: json2 ? void 0 : opts.onLine,
3741
+ signal: opts.signal,
3742
+ sleep: opts.sleep
3743
+ });
3744
+ } catch (error) {
3745
+ const follow = ` \u2014 the job was started: follow it with \`watch ${job.job_id}\``;
3746
+ if (error instanceof HttpError) {
3747
+ throw new HttpError(error.status, `${error.message}${follow}`, error.body, error.retryAfter);
3748
+ }
3749
+ if (error instanceof TransportError) throw new TransportError(`${error.message}${follow}`, error.code);
3750
+ if (error instanceof CommandError) throw new CommandError(`${error.message}${follow}`);
3751
+ throw error;
3752
+ }
3753
+ return { ...watched, json: { ...job, watch: watched.json } };
3754
+ }
3755
+
3756
+ // src/commands/operator-continue.ts
3757
+ async function jobContinue(ctx, id) {
3758
+ requireJobId(id);
3759
+ const write2 = { mayHave: "the job may have been continued", check: `\`job ${id}\`` };
3760
+ let data;
3761
+ try {
3762
+ data = (await sendOnce(ctx, `/api/v1/operator/jobs/${id}/continue`, {}, write2)).data;
3763
+ } catch (error) {
3764
+ throw explainRefusal(error, notFound(id), refusals(id));
3765
+ }
3766
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
3767
+ throw outcomeUnknown("the answer carried no job", write2);
3768
+ }
3769
+ const job = withheld(data);
3770
+ return { json: job, human: `Continued Operator job ${id}.
3771
+
3772
+ ` + jobDetail(job) };
3773
+ }
3774
+ function notFound(id) {
3775
+ return `${missingJob(id)}. If you could see the job until now, it may have gone out of sight just as it was continued: check \`job ${id}\` before continuing it again`;
3776
+ }
3777
+ function refusals(id) {
3778
+ return {
3779
+ principal_required: "only the person the job runs as (its execution principal) can continue it",
3780
+ invalid_state: `the job has nothing to continue in its current state; \`job ${id}\` shows where it stands`,
3781
+ reconciliation_required: "reconcile its partial effects in the app first, from the job's workbench link",
3782
+ principal_inactive: "the person the job runs as lost the rights to run it, so it can't continue until those are restored",
3783
+ deliveries_unsettled: `a delivery is still sending, or its outcome is unknown: if \`job ${id}\` shows it waiting on delivery, settle it in the app; otherwise continue it once the delivery has sent`
3784
+ };
3785
+ }
3786
+
3787
+ // src/core/textinput.ts
3788
+ import { readFileSync as readFileSync2, statSync as statSync3 } from "fs";
3789
+ import { isatty } from "tty";
3790
+ var STDIN_PATH = "-";
3791
+ var MAX_TEXT_BYTES = 1e6;
3792
+ var TEXT_LIMIT = { bytes: MAX_TEXT_BYTES, why: "that is not prose; check the path" };
3793
+ var DOCUMENT_LIMIT = { bytes: 8e6, why: "larger than the API accepts in one request" };
3794
+ function flagOf(name) {
3795
+ return name.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`);
3796
+ }
3797
+ function resolveTextSources(sources) {
3798
+ const resolved = {};
3799
+ const toRead = [];
3800
+ let stdinClaimedBy;
3801
+ for (const { name, inline, file } of sources) {
3802
+ if (inline !== void 0 && file !== void 0) {
3803
+ throw new UsageError(
3804
+ `--${flagOf(name)} and --${flagOf(name)}-file both given \u2014 pass one; there is no precedence between them`
3805
+ );
3806
+ }
3807
+ if (file === void 0) {
3808
+ if (inline !== void 0) resolved[name] = inline;
3809
+ continue;
3810
+ }
3811
+ if (file === STDIN_PATH) {
3812
+ if (isatty(0)) {
3813
+ throw new UsageError(
3814
+ `--${flagOf(name)}-file - reads stdin, but stdin is a terminal \u2014 pipe something in or pass a path`
3815
+ );
3816
+ }
3817
+ if (stdinClaimedBy !== void 0) {
3818
+ throw new UsageError(
3819
+ `--${flagOf(name)}-file and --${flagOf(stdinClaimedBy)}-file both read stdin \u2014 there is only one stream, so only one may be "-"`
3820
+ );
3821
+ }
3822
+ stdinClaimedBy = name;
3823
+ }
3824
+ if (file !== STDIN_PATH) checkReadable(file, `--${flagOf(name)}-file`);
3825
+ toRead.push({ name, file });
3826
+ }
3827
+ for (const { name, file } of [...toRead].sort(byStdinLast)) {
3828
+ resolved[name] = stripOneTrailingNewline(readTextFile(file, `--${flagOf(name)}-file`));
3829
+ }
3830
+ return resolved;
3831
+ }
3832
+ function readFileOption(flag, path2) {
3833
+ if (path2 === STDIN_PATH) {
3834
+ if (isatty(0)) {
3835
+ throw new UsageError(`${flag} - reads stdin, but stdin is a terminal \u2014 pipe something in or pass a path`);
3836
+ }
3837
+ } else {
3838
+ checkReadable(path2, flag, DOCUMENT_LIMIT);
3839
+ }
3840
+ return readTextFile(path2, flag, DOCUMENT_LIMIT);
3841
+ }
3842
+ function readTextFile(path2, option, limit = TEXT_LIMIT) {
3843
+ if (path2 === STDIN_PATH) {
3844
+ let piped;
3845
+ try {
3846
+ piped = readFileSync2(0);
3847
+ } catch (error) {
3848
+ throw new UsageError(`${option} -: cannot read stdin (${describe(error)})`);
3849
+ }
3850
+ if (piped.length > limit.bytes) {
3851
+ throw new UsageError(oversize(`${option} -`, limit));
3852
+ }
3853
+ return decodeUtf8(piped, `${option} -`);
3854
+ }
3855
+ const flag = `${option} ${path2}`;
3856
+ let bytes;
3857
+ try {
3858
+ bytes = readFileSync2(path2);
3859
+ } catch (error) {
3860
+ throw new UsageError(`${flag}: ${describe(error)}`);
3861
+ }
3862
+ if (bytes.length > limit.bytes) throw new UsageError(oversize(flag, limit));
3863
+ return decodeUtf8(bytes, flag);
3864
+ }
3865
+ function decodeUtf8(bytes, flag) {
3866
+ try {
3867
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
3868
+ } catch {
3869
+ throw new UsageError(`${flag}: not valid UTF-8 \u2014 convert it first`);
3870
+ }
3871
+ }
3872
+ function oversize(what, limit) {
3873
+ return `${what}: over ${limit.bytes / 1e6} MB \u2014 ${limit.why}`;
3874
+ }
3875
+ function checkReadable(path2, option, limit = TEXT_LIMIT) {
3876
+ const flag = `${option} ${path2}`;
3877
+ let stats3;
3878
+ try {
3879
+ stats3 = statSync3(path2);
3880
+ } catch (error) {
3881
+ throw new UsageError(`${flag}: ${describe(error)}`);
3882
+ }
3883
+ if (!stats3.isFile()) {
3884
+ throw new UsageError(
3885
+ `${flag}: ${stats3.isDirectory() ? "is a directory" : "not a regular file"}`
3886
+ );
3887
+ }
3888
+ if (stats3.size > limit.bytes) throw new UsageError(oversize(flag, limit));
3889
+ }
3890
+ function byStdinLast(a, b) {
3891
+ return Number(a.file === STDIN_PATH) - Number(b.file === STDIN_PATH);
3892
+ }
3893
+ function stripOneTrailingNewline(text) {
3894
+ if (text.endsWith("\r\n")) return text.slice(0, -2);
3895
+ if (text.endsWith("\n")) return text.slice(0, -1);
3896
+ return text;
3897
+ }
3898
+ function describe(error) {
3899
+ const code = error?.code;
3900
+ if (code === "ENOENT") return "no such file";
3901
+ if (code === "EISDIR") return "is a directory";
3902
+ if (code === "EACCES") return "permission denied";
3903
+ return error instanceof Error ? error.message : String(error);
3904
+ }
3905
+
3906
+ // src/commands/operator-controls.ts
3907
+ var STEER_MAX_CHARACTERS = 1e4;
3908
+ var DONE = { steer: "steered", cancel: "cancelled", pause: "paused" };
3909
+ function requireInstruction(instruction) {
3910
+ const trimmed = instruction.trim();
3911
+ if (trimmed === "") throw new UsageError("the instruction is empty \u2014 say what the job should change");
3912
+ const length = graphemeLength(trimmed);
3913
+ if (length > STEER_MAX_CHARACTERS) {
3914
+ throw new UsageError(
3915
+ `the instruction is ${length} characters once trimmed; a job takes at most ${STEER_MAX_CHARACTERS}`
3916
+ );
3917
+ }
3918
+ return trimmed;
3919
+ }
3920
+ function instructionFrom(words, file) {
3921
+ if (file === void 0) return requireInstruction(words.join(" "));
3922
+ if (words.length > 0) {
3923
+ throw new UsageError(
3924
+ "instruction words and --instruction-file both given \u2014 pass one; there is no precedence between them"
3925
+ );
3926
+ }
3927
+ return requireInstruction(resolveTextSources([{ name: "instruction", inline: void 0, file }])["instruction"] ?? "");
3928
+ }
3929
+ async function jobSteer(ctx, id, instruction) {
3930
+ requireJobId(id);
3931
+ const job = await control(ctx, id, "steer", { instruction: requireInstruction(instruction) });
3932
+ return {
3933
+ json: job,
3934
+ human: `Steered Operator job ${id}: the instruction is posted in its room, attributed to you. Work it changes needs a fresh approval (earlier approvals no longer apply), and a run in progress stops to take it.
3935
+
3936
+ ` + jobDetail(job)
3937
+ };
3938
+ }
3939
+ async function jobCancel(ctx, id) {
3940
+ requireJobId(id);
3941
+ const job = await control(ctx, id, "cancel", {});
3942
+ const effects = effectsLine(job["effects"]);
3943
+ return {
3944
+ json: job,
3945
+ human: `Cancelled Operator job ${id}. ` + (effects === null ? "Committed effects stay committed; the answer carried no counts.\n" : `Committed effects stay committed: ${clean(effects)} (staged effects that never ran count as unknown).
3946
+ `) + "\n" + jobDetail(job)
3947
+ };
3948
+ }
3949
+ async function jobPause(ctx, id) {
3950
+ requireJobId(id);
3951
+ const job = await control(ctx, id, "pause", {});
3952
+ return {
3953
+ json: job,
3954
+ human: `Paused Operator job ${id}. Its active-time budget is kept; continue it to run again.
3955
+
3956
+ ` + jobDetail(job)
3957
+ };
3958
+ }
3959
+ async function control(ctx, id, verb, body) {
3960
+ const write2 = { mayHave: `the job may have been ${DONE[verb]}`, check: `\`job ${id}\`` };
3961
+ let data;
3962
+ try {
3963
+ data = (await sendOnce(ctx, `/api/v1/operator/jobs/${id}/${verb}`, body, write2)).data;
3964
+ } catch (error) {
3965
+ throw explained2(error, id, verb);
3966
+ }
3967
+ if (!isRecord3(data)) throw outcomeUnknown("the answer carried no job", write2);
3968
+ return withheld(data);
3969
+ }
3970
+ function explained2(error, id, verb) {
3971
+ if (!(error instanceof HttpError) || error.status >= 500) return error;
3972
+ const hint = refusalHint(error, id, verb);
3973
+ if (hint === void 0) return error;
3974
+ return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
3975
+ }
3976
+ function refusalHint(error, id, verb) {
3977
+ const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
3978
+ const job = `\`job ${id}\``;
3979
+ switch (error.status) {
3980
+ case 400:
3981
+ return code === "bad_request" ? BAD_REQUEST_HINT : void 0;
3982
+ case 403:
3983
+ if (code !== "principal_required") return ACCESS_HINTS[code];
3984
+ return verb === "pause" ? "only the job's execution principal may pause it; as its owner or a collaborator you can steer it instead, which stops its current run" : `only the job's execution principal may ${verb} it; ask them to`;
3985
+ case 404:
3986
+ return missingJob(id);
3987
+ case 409:
3988
+ return conflictHint(code, verb, job);
3989
+ case 422:
3990
+ if (code === "invalid_instruction") {
3991
+ return `pass the instruction as text of 1 to ${STEER_MAX_CHARACTERS} characters once trimmed`;
3992
+ }
3993
+ return code === "validation_error" && verb === "steer" ? "the job's room refused the instruction, so nothing was posted" : void 0;
3994
+ default:
3995
+ return void 0;
3996
+ }
3997
+ }
3998
+ function conflictHint(code, verb, job) {
3999
+ switch (code) {
4000
+ case "invalid_state":
4001
+ return {
4002
+ steer: `the app doesn't offer Steer for the job now: it has finished, failed, waits for approval or needs reconciliation \u2014 ${job} shows where it stands`,
4003
+ cancel: `the job has already ended \u2014 ${job} shows how`,
4004
+ pause: `only a running job can be paused \u2014 ${job} shows where it stands`
4005
+ }[verb];
4006
+ case "reconciliation_required":
4007
+ return `its partial effects need reconciling first: reconcile them in the app (${job} has the workbench link)`;
4008
+ case "deliveries_unsettled":
4009
+ return `a delivery is still sending, or its outcome is unknown: if ${job} shows it waiting on delivery, settle that in the app (the workbench link is there); otherwise ${verb} it again once the delivery has sent`;
4010
+ case "principal_inactive":
4011
+ return "nothing changed: the job's execution principal needs their admin or member role back first";
4012
+ case "cancellation_scope_changed":
4013
+ return `the job was not cancelled; read it with ${job} and decide again`;
4014
+ default:
4015
+ return void 0;
4016
+ }
4017
+ }
4018
+ function isRecord3(value) {
4019
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4020
+ }
4021
+
4022
+ // src/commands/operator-procedures.ts
4023
+ var PROCEDURE_FIELDS = [
4024
+ "id",
4025
+ "name",
4026
+ "job_id",
4027
+ "job_url",
4028
+ "current_version",
4029
+ "parameter_schema",
4030
+ "versions",
4031
+ "inserted_at",
4032
+ "updated_at"
4033
+ ];
4034
+ var PROCEDURE_VERSION_FIELDS = [
4035
+ "version",
4036
+ "recipe_digest",
4037
+ "capability_ids",
4038
+ "action_roster",
4039
+ "inserted_at"
4040
+ ];
4041
+ var RUN_NOTE = "A run is a new job: fresh inputs, your current rights and a change set that needs its own approval. Nothing of the saved job carries over. Approving happens in the app; the CLI cannot approve.\n";
4042
+ function requireProcedureId(id) {
4043
+ if (!isUuid(id)) throw new UsageError(`not an Operator procedure id: ${id} (expected the procedure's UUID)`);
4044
+ }
4045
+ function missingProcedure(id) {
4046
+ return `no Operator procedure ${id} you can see: it does not exist or was deleted, is in another organization, was saved from a job you can't see, or was saved for the whole organization rather than a job (or this Harness server has no procedures API yet)`;
4047
+ }
4048
+ function requireProcedureName(name) {
4049
+ if (name === void 0 || name.trim() === "") {
4050
+ throw new UsageError('a procedure needs a name: procedure save <job-id> --name "\u2026"');
4051
+ }
4052
+ return name;
4053
+ }
4054
+ async function procedureSave(ctx, jobId, name) {
4055
+ requireJobId(jobId);
4056
+ const named2 = requireProcedureName(name);
4057
+ const write2 = {
4058
+ mayHave: "a procedure may have been saved",
4059
+ check: `the job's procedures with \`job ${jobId}\``
4060
+ };
4061
+ let data;
4062
+ try {
4063
+ data = (await sendOnce(ctx, `/api/v1/operator/jobs/${jobId}/procedures`, { name: named2 }, write2)).data;
4064
+ } catch (error) {
4065
+ throw explainRefusal(error, missingJob(jobId), {
4066
+ owner_required: "only the job's owner can save it as a procedure; ask them to save it",
4067
+ not_saveable: "only a job that completed with its work verified, and has work to repeat, can be saved",
4068
+ validation_error: "the name must be plain text, without control characters"
4069
+ });
4070
+ }
4071
+ if (!isRecord4(data) || typeof data["id"] !== "string" || data["id"] === "") {
4072
+ throw outcomeUnknown("the answer named no procedure", write2);
4073
+ }
4074
+ const saved = `Saved job ${jobId} as procedure ${clean(data["id"])}`;
4075
+ if (!Array.isArray(data["versions"])) {
4076
+ return {
4077
+ json: pick2(data, ["id", "url"]),
4078
+ human: `${saved}, but it can't be read back as you.
4079
+ ` + labelled([["Workbench", str6(data["url"]) || null]])
4080
+ };
4081
+ }
4082
+ const procedure = procedureJson(data);
4083
+ return { json: procedure, human: `${saved}.
4084
+
4085
+ ` + procedureDetail(procedure) };
4086
+ }
4087
+ async function procedureShow(ctx, id) {
4088
+ requireProcedureId(id);
4089
+ const body = await explained(() => ctx.client.get(`/api/v1/operator/procedures/${id}`), {
4090
+ notFound: missingProcedure(id)
4091
+ });
4092
+ if (!isRecord4(body.data)) throw new CommandError("The procedure response carried no procedure.");
4093
+ const procedure = procedureJson(body.data);
4094
+ return { json: procedure, human: procedureDetail(procedure) };
4095
+ }
4096
+ function prepareProcedureRun(id, opts) {
4097
+ requireProcedureId(id);
4098
+ const body = {};
4099
+ const version2 = runVersion(opts.version);
4100
+ if (version2 !== void 0) body["version"] = version2;
4101
+ const given = [
4102
+ opts.param !== void 0 && opts.param.length > 0 ? "--param" : null,
4103
+ opts.paramsFile !== void 0 ? "--params-file" : null,
4104
+ opts.parameters !== void 0 ? "parameters" : null
4105
+ ].filter((source) => source !== null);
4106
+ if (given.length > 1) {
4107
+ throw new UsageError(`${given.join(" and ")} both given \u2014 pass the parameters one way; there is no precedence`);
4108
+ }
4109
+ if (opts.parameters !== void 0 && !isRecord4(opts.parameters)) {
4110
+ throw new UsageError("parameters must be one object of names and values");
4111
+ }
4112
+ const fromFlags = opts.param !== void 0 && opts.param.length > 0 ? parseParams(opts.param) : void 0;
4113
+ if (opts.group !== void 0) body["group_id"] = bareId(opts.group, "--group");
4114
+ if (opts.source !== void 0) body["source_resource_id"] = bareId(opts.source, "--source");
4115
+ const parameters = fromFlags ?? opts.parameters ?? (opts.paramsFile === void 0 ? void 0 : readParamsFile(opts.paramsFile));
4116
+ if (parameters !== void 0) body["parameters"] = parameters;
4117
+ return { id, body };
4118
+ }
4119
+ async function startProcedureRun(ctx, request) {
4120
+ const { id } = request;
4121
+ const write2 = { mayHave: "a job may have been started", check: "`jobs` for the new job" };
4122
+ let data;
4123
+ try {
4124
+ data = (await sendOnce(ctx, `/api/v1/operator/procedures/${id}/runs`, request.body, write2)).data;
4125
+ } catch (error) {
4126
+ throw explainRefusal(error, missingProcedure(id), {
4127
+ version_conflict: `the procedure has a newer current version (\`procedure show ${id}\` names it): run that one, or run without a version to take the current one`,
4128
+ concurrent_change: `read the procedure again (\`procedure show ${id}\`) before running it again`,
4129
+ invalid_rerun: `check the parameters against the procedure's parameter schema (\`procedure show ${id}\`), and that your current rights cover the run`,
4130
+ // The server refuses with it before the run starts.
4131
+ service_unavailable: "nothing was started; try again later"
4132
+ });
4133
+ }
4134
+ if (!isRecord4(data) || typeof data["id"] !== "string" || data["id"] === "") {
4135
+ throw outcomeUnknown("the answer named no job", write2);
4136
+ }
4137
+ const started = `Started Operator job ${clean(data["id"])} from procedure ${id}`;
4138
+ if (typeof data["state"] !== "string") {
4139
+ return {
4140
+ json: pick2(data, ["id", "url"]),
4141
+ human: `${started}, but it can't be read back as you.
4142
+ ` + labelled([["Workbench", str6(data["url"]) || null]]) + "\n" + RUN_NOTE
4143
+ };
4144
+ }
4145
+ const job = withheld(data);
4146
+ const waitingOn = str6(job["waiting_on"]);
4147
+ return {
4148
+ json: job,
4149
+ human: `${started}.
4150
+ ` + labelled([
4151
+ ["State", str6(job["state"]) + (waitingOn === "" ? "" : ` \xB7 waiting on ${waitingOn}`)],
4152
+ ["Workbench", str6(job["url"]) || null],
4153
+ ["Approve at", str6(job["approval_url"]) || null]
4154
+ ]) + "\n" + RUN_NOTE
4155
+ };
4156
+ }
4157
+ async function procedureRun(ctx, id, opts) {
4158
+ return startProcedureRun(ctx, prepareProcedureRun(id, opts));
4159
+ }
4160
+ function runVersion(version2) {
4161
+ if (version2 === void 0) return void 0;
4162
+ const text = String(version2).trim();
4163
+ const parsed = typeof version2 === "number" ? version2 : /^\d+$/.test(text) ? Number(text) : Number.NaN;
4164
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
4165
+ throw new UsageError(`--version takes a positive integer, the version expected to be current (got ${text})`);
4166
+ }
4167
+ return parsed;
4168
+ }
4169
+ function parseParams(entries) {
4170
+ const parsed = [];
4171
+ const names = /* @__PURE__ */ new Set();
4172
+ for (const entry of entries) {
4173
+ const equals = entry.indexOf("=");
4174
+ if (equals === -1) throw new UsageError(`--param ${entry}: expected name=value, or name:=<json> for a JSON value`);
4175
+ const typed = equals > 0 && entry[equals - 1] === ":";
4176
+ const name = entry.slice(0, typed ? equals - 1 : equals);
4177
+ const raw = entry.slice(equals + 1);
4178
+ if (name.trim() === "") throw new UsageError(`--param ${entry}: the parameter has no name`);
4179
+ if (name !== name.trim()) {
4180
+ throw new UsageError(`--param ${entry}: the name starts or ends with whitespace \u2014 write it without`);
4181
+ }
4182
+ if (names.has(name)) throw new UsageError(`--param ${name} given twice \u2014 pass each parameter once`);
4183
+ names.add(name);
4184
+ parsed.push([name, typed ? jsonValue(raw, `--param ${entry}`) : raw]);
4185
+ }
4186
+ return Object.fromEntries(parsed);
4187
+ }
4188
+ function jsonValue(text, what) {
4189
+ try {
4190
+ return JSON.parse(text);
4191
+ } catch (error) {
4192
+ throw new UsageError(`${what}: not valid JSON after := (${firstLine(error)}); quote a string as name=value`);
4193
+ }
4194
+ }
4195
+ function readParamsFile(path2) {
4196
+ const text = readFileOption("--params-file", path2);
4197
+ let parsed;
4198
+ try {
4199
+ parsed = JSON.parse(text);
4200
+ } catch (error) {
4201
+ throw new UsageError(`--params-file ${path2}: not valid JSON (${firstLine(error)})`);
4202
+ }
4203
+ if (!isRecord4(parsed)) {
4204
+ throw new UsageError(`--params-file ${path2}: the parameters are one JSON object of names and values`);
4205
+ }
4206
+ return parsed;
4207
+ }
4208
+ function bareId(value, flag) {
4209
+ if (value.trim() === "") throw new UsageError(`${flag} is empty`);
4210
+ if (value !== value.trim()) throw new UsageError(`${flag} starts or ends with whitespace \u2014 pass the id alone`);
4211
+ return value;
4212
+ }
4213
+ function procedureJson(data) {
4214
+ const procedure = pick2(data, PROCEDURE_FIELDS);
4215
+ if ("versions" in procedure) {
4216
+ procedure["versions"] = records4(procedure["versions"]).map((version2) => pick2(version2, PROCEDURE_VERSION_FIELDS));
4217
+ }
4218
+ return procedure;
4219
+ }
4220
+ function procedureDetail(procedure) {
4221
+ const current = procedure["current_version"];
4222
+ const versions = records4(procedure["versions"]).map(
4223
+ (version2) => ` ${clean(`v${str6(version2["version"])}${version2["version"] === current ? " (current)" : ""} ${str6(version2["inserted_at"])} recipe ${str6(version2["recipe_digest"])}`)}
4224
+ ${"Capabilities:".padEnd(15)}${clean(list2(version2["capability_ids"]))}
4225
+ ${"Actions:".padEnd(15)}${clean(list2(version2["action_roster"]))}
4226
+ `
4227
+ );
4228
+ return `Procedure ${clean(procedure["name"])} \xB7 v${clean(current)} \xB7 ${clean(procedure["id"])}
4229
+ ` + labelled([
4230
+ ["Saved from", `job ${str6(procedure["job_id"])}`],
4231
+ ["Workbench", str6(procedure["job_url"]) || null],
4232
+ ["Created", str6(procedure["inserted_at"]) || null],
4233
+ ["Updated", str6(procedure["updated_at"]) || null]
4234
+ ]) + (versions.length === 0 ? "\nVersions: none\n" : "\nVersions:\n" + versions.join("")) + parametersSection(procedure["parameter_schema"]) + "\n" + RUN_NOTE;
4235
+ }
4236
+ function parametersSection(schema) {
4237
+ if (!isRecord4(schema) || Object.keys(schema).length === 0) return "\nParameters a run takes: none\n";
4238
+ const lines = JSON.stringify(schema, null, 2).split("\n").map((line) => ` ${clean(line)}
4239
+ `).join("");
4240
+ const scoped = isRecord4(schema["scoped_inputs"]) ? schema["scoped_inputs"]["required"] : void 0;
4241
+ const scopes = Array.isArray(scoped) ? scoped.map(str6) : [];
4242
+ let needs = "";
4243
+ if (scopes.includes("contact_reconciliation")) {
4244
+ needs = "Its runs also need --group <contact-group-id> and --source <uploaded-file-id> (contact_reconciliation).\n";
4245
+ } else if (scopes.length > 0) {
4246
+ needs = `Its runs also need inputs resolved afresh: ${clean(scopes.join(", "))}.
4247
+ `;
4248
+ }
4249
+ return "\nParameters a run takes (JSON schema):\n" + lines + needs;
4250
+ }
4251
+ function list2(value) {
4252
+ return (Array.isArray(value) ? value.map(str6).join(", ") : "") || "none";
4253
+ }
4254
+ function pick2(record, keys) {
4255
+ return Object.fromEntries(keys.filter((key) => key in record).map((key) => [key, record[key]]));
4256
+ }
4257
+ function records4(value) {
4258
+ return Array.isArray(value) ? value.filter(isRecord4) : [];
4259
+ }
4260
+ function isRecord4(value) {
4261
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4262
+ }
4263
+ function str6(value) {
4264
+ return value === null || value === void 0 ? "" : String(value);
4265
+ }
4266
+ function firstLine(error) {
4267
+ return (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
4268
+ }
4269
+
4270
+ // src/commands/plans.ts
4271
+ import { extname as extname2 } from "path";
4272
+ import { parseDocument } from "yaml";
4273
+ var PLAN_EXECUTORS = ["none", "operator"];
4274
+ var CONDITION_KINDS = ["not_before", "gate", "event"];
4275
+ var GATE_OWNER_ROLES = ["gp", "compliance_officer", "valuer", "accountant", "auditor", "counsel"];
4276
+ function parsePlanDocument(text, source) {
4277
+ const parsed = extname2(source).toLowerCase() === ".json" ? parseJson(text, source) : parseYamlDocument(text, source);
4278
+ if (!isRecord5(parsed)) {
4279
+ throw new UsageError(`${source}: a plan file holds one object (a mapping), not a list or a scalar`);
4280
+ }
4281
+ return parsed;
4282
+ }
4283
+ function parseJson(text, source) {
4284
+ try {
4285
+ return JSON.parse(text);
4286
+ } catch (error) {
4287
+ throw new UsageError(`${source}: not valid JSON (${firstLine2(error)})`);
4288
+ }
4289
+ }
4290
+ function parseYamlDocument(text, source) {
4291
+ const document = parseDocument(text, { resolveKnownTags: false });
4292
+ const problem = document.errors[0] ?? document.warnings[0];
4293
+ if (problem !== void 0) throw new UsageError(`${source}: not valid YAML (${firstLine2(problem)})`);
4294
+ let value;
4295
+ try {
4296
+ value = document.toJS();
4297
+ } catch (error) {
4298
+ throw new UsageError(`${source}: not valid YAML (${firstLine2(error)})`);
4299
+ }
4300
+ if (!jsonSafe(value)) {
4301
+ throw new UsageError(`${source}: holds a value JSON cannot carry (.inf or .nan) \u2014 write a number or a string`);
4302
+ }
4303
+ return value;
4304
+ }
4305
+ function jsonSafe(value) {
4306
+ if (typeof value === "number") return Number.isFinite(value);
4307
+ if (Array.isArray(value)) return value.every(jsonSafe);
4308
+ if (typeof value === "object" && value !== null) return Object.values(value).every(jsonSafe);
4309
+ return true;
4310
+ }
4311
+ function firstLine2(error) {
4312
+ return (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
2379
4313
  }
2380
4314
  function readPlanFile(path2, flag = "--file") {
2381
4315
  return parsePlanDocument(readFileOption(flag, path2), path2);
@@ -2391,13 +4325,13 @@ async function planCreate(ctx, plan, opts) {
2391
4325
  throw explainMissing(error, `no team ${team} here, or this Harness server has no plan API yet`);
2392
4326
  }
2393
4327
  const created = body.data;
2394
- const id = str4(created["id"]);
4328
+ const id = str7(created["id"]);
2395
4329
  return {
2396
4330
  json: created,
2397
4331
  human: `Created plan ${clean(id)} (${clean(created["status"]) || "held"})
2398
4332
 
2399
4333
  ` + planDetail(created) + `
2400
- Nothing runs until it is released: harness plan release ${clean(id)}
4334
+ Nothing runs until it is released: ${invokedName()} plan release ${clean(id)}
2401
4335
  `
2402
4336
  };
2403
4337
  }
@@ -2416,7 +4350,7 @@ async function planEdit(ctx, id, edits) {
2416
4350
  human: `Updated plan ${clean(id)}
2417
4351
 
2418
4352
  ` + planDetail(plan) + (held ? `
2419
- Release it again to run the held steps: harness plan release ${clean(id)}
4353
+ Release it again to run the held steps: ${invokedName()} plan release ${clean(id)}
2420
4354
  ` : "")
2421
4355
  };
2422
4356
  }
@@ -2428,7 +4362,7 @@ async function planRelease(ctx, id, opts) {
2428
4362
  if (opts.spendBudgetCents !== void 0) body["spend_budget_cents"] = opts.spendBudgetCents;
2429
4363
  const plan = await planRequest(id, () => ctx.client.post(`/api/v1/plans/${id}/release`, body));
2430
4364
  const revision = plan["revision"] ?? {};
2431
- const ready2 = Array.isArray(plan["steps"]) ? plan["steps"].filter((step) => isRecord(step) && step["ready"] === true).length : 0;
4365
+ const ready2 = Array.isArray(plan["steps"]) ? plan["steps"].filter((step) => isRecord5(step) && step["ready"] === true).length : 0;
2432
4366
  return {
2433
4367
  json: plan,
2434
4368
  human: `Released plan ${clean(id)} as revision ${clean(revision["number"]) || "?"}: ${ready2} ${ready2 === 1 ? "step" : "steps"} ready now
@@ -2447,23 +4381,22 @@ async function planHold(ctx, id) {
2447
4381
  async function gateSet(ctx, issue, gate, satisfied) {
2448
4382
  requireIdentifiers([issue]);
2449
4383
  const wanted = gate.trim();
2450
- if (wanted === "") throw new UsageError("name the gate by its label or its condition id");
2451
- let conditions;
4384
+ if (wanted === "") throw new UsageError("name the gate by its key, its label or its condition id");
4385
+ const expert = isExpertToken(ctx.config.token);
4386
+ const list3 = await readConditions(ctx, issue, expert);
4387
+ const target = findGate(list3, issue, wanted, expert);
4388
+ const id = str7(target["id"]);
4389
+ let body;
2452
4390
  try {
2453
- conditions = (await ctx.client.get(`/api/v1/issues/${issue}/conditions`)).data;
4391
+ body = await ctx.client.put(`/api/v1/issues/${issue}/conditions/${id}/satisfaction`, { satisfied });
2454
4392
  } catch (error) {
2455
- throw explainMissing(error, `no issue ${issue} here, or this Harness server has no wait conditions yet`);
4393
+ throw await explainNotGateOwner(ctx, error, target, issue, satisfied);
2456
4394
  }
2457
- if (!Array.isArray(conditions)) throw new CommandError("The conditions response carried no list.");
2458
- const list2 = conditions.filter(isRecord);
2459
- const target = findGate(list2, issue, wanted);
2460
- const id = str4(target["id"]);
2461
- const body = await ctx.client.put(`/api/v1/issues/${issue}/conditions/${id}/satisfaction`, {
2462
- satisfied
2463
- });
2464
- const others = list2.filter((condition) => condition["id"] !== id && condition["satisfied"] !== true).length;
4395
+ const others = list3.filter((condition) => condition["id"] !== id && condition["satisfied"] !== true).length;
2465
4396
  let tail = "";
2466
- if (satisfied) {
4397
+ if (satisfied && expert) {
4398
+ tail = others > 0 ? ` \u2014 ${others} other gate${others === 1 ? "" : "s"} of yours on it ${others === 1 ? "is" : "are"} open` : "";
4399
+ } else if (satisfied) {
2467
4400
  tail = others > 0 ? ` \u2014 it still waits on ${others} other condition${others === 1 ? "" : "s"}` : " \u2014 no other wait condition holds it";
2468
4401
  }
2469
4402
  return {
@@ -2472,11 +4405,41 @@ async function gateSet(ctx, issue, gate, satisfied) {
2472
4405
  `
2473
4406
  };
2474
4407
  }
4408
+ async function conditionList(ctx, issue) {
4409
+ requireIdentifiers([issue]);
4410
+ const expert = isExpertToken(ctx.config.token);
4411
+ const list3 = await readConditions(ctx, issue, expert);
4412
+ let human;
4413
+ if (list3.length === 0) {
4414
+ human = expert ? `You own no gate on ${issue}.
4415
+ ` : `No wait conditions on ${issue}.
4416
+ `;
4417
+ } else {
4418
+ human = (expert ? `Your gates on ${issue}:
4419
+ ` : `Wait conditions on ${issue}:
4420
+ `) + list3.map((condition) => ` ${conditionLine(condition, { expertView: expert })}
4421
+ `).join("");
4422
+ }
4423
+ return { json: list3, human };
4424
+ }
4425
+ async function readConditions(ctx, issue, expert) {
4426
+ let conditions;
4427
+ try {
4428
+ conditions = (await ctx.client.get(`/api/v1/issues/${issue}/conditions`)).data;
4429
+ } catch (error) {
4430
+ throw explainMissing(
4431
+ error,
4432
+ expert ? `no step ${issue} assigned to you: a pxc_ credential reads only its own steps` : `no issue ${issue} here, or this Harness server has no wait conditions yet`
4433
+ );
4434
+ }
4435
+ if (!Array.isArray(conditions)) throw new CommandError("The conditions response carried no list.");
4436
+ return conditions.filter(isRecord5);
4437
+ }
2475
4438
  function requirePlanId(id) {
2476
4439
  if (!isUuid(id)) throw new UsageError(`not a plan id: ${id} (expected the plan's UUID)`);
2477
4440
  }
2478
4441
  function requireDocument(value, what) {
2479
- if (!isRecord(value)) throw new UsageError(what);
4442
+ if (!isRecord5(value)) throw new UsageError(what);
2480
4443
  }
2481
4444
  async function planRequest(id, send) {
2482
4445
  try {
@@ -2489,41 +4452,99 @@ function explainMissing(error, why) {
2489
4452
  if (!(error instanceof HttpError) || error.status !== 404) return error;
2490
4453
  return new HttpError(404, `${error.message} \u2014 ${why}`, error.body, error.retryAfter);
2491
4454
  }
2492
- function findGate(conditions, issue, wanted) {
2493
- const gates = conditions.filter((condition) => condition["kind"] === "gate");
2494
- const label = (condition) => str4(condition["label"]).trim();
2495
- if (isUuid(wanted) && !gates.some((condition) => label(condition) === wanted)) {
2496
- const byId = conditions.find((condition) => str4(condition["id"]).toLowerCase() === wanted.toLowerCase());
2497
- if (byId === void 0) throw new CommandError(`No condition ${wanted} on ${issue}.`);
2498
- if (byId["kind"] !== "gate") {
4455
+ async function explainNotGateOwner(ctx, error, gate, issue, satisfied) {
4456
+ if (!(error instanceof HttpError) || error.status !== 403 || error.body["reason"] !== "not_gate_owner") return error;
4457
+ let current;
4458
+ let currentOwner = error.body["owner"];
4459
+ if (currentOwner !== null && !isRecord5(currentOwner)) {
4460
+ try {
4461
+ const fresh = (await ctx.client.get(`/api/v1/issues/${issue}/conditions`)).data;
4462
+ if (Array.isArray(fresh)) current = fresh.filter(isRecord5).find((condition) => condition["id"] === gate["id"]);
4463
+ } catch {
4464
+ }
4465
+ currentOwner = current?.["owner"];
4466
+ }
4467
+ const owner = isRecord5(currentOwner) ? ownerText(currentOwner) : "";
4468
+ const subject = `its owner${owner === "" ? "" : `, ${owner},`}`;
4469
+ const named2 = `gate "${clean((current ?? gate)["label"])}" on ${issue}`;
4470
+ const message = satisfied ? `Cannot satisfy ${named2}: only ${subject} can tick it` : `Cannot un-satisfy ${named2}: only ${subject} or Harness staff can untick it`;
4471
+ return new HttpError(error.status, message, error.body, error.retryAfter);
4472
+ }
4473
+ var gateLabel = (condition) => str7(condition["label"]).trim();
4474
+ var gateKey = (condition) => str7(condition["key"]);
4475
+ function exactMatches(conditions, wanted) {
4476
+ const id = isUuid(wanted) ? wanted.toLowerCase() : null;
4477
+ return conditions.filter((condition) => {
4478
+ if (id !== null && str7(condition["id"]).toLowerCase() === id) return true;
4479
+ if (condition["kind"] !== "gate") return false;
4480
+ const key = gateKey(condition);
4481
+ return key !== "" && key === (id ?? wanted) || gateLabel(condition) === wanted;
4482
+ });
4483
+ }
4484
+ function ambiguous(conditions, issue, wanted, matches) {
4485
+ const listed = matches.map((condition) => {
4486
+ const what = condition["kind"] === "gate" ? `gate ${gateName({ label: gateLabel(condition), key: gateKey(condition) })}` : `${clean(condition["kind"])} condition`;
4487
+ return `${clean(condition["id"])} (${what})`;
4488
+ }).join(", ");
4489
+ const unnameable = matches.map((condition) => str7(condition["id"])).filter((id) => exactMatches(conditions, id).length !== 1).map((id) => clean(id));
4490
+ 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)`;
4491
+ return new CommandError(
4492
+ `several gates on ${issue} match "${wanted}": ${listed} \u2014 pass the condition id of the one you mean${caveat}`
4493
+ );
4494
+ }
4495
+ function findGate(conditions, issue, wanted, expert = false) {
4496
+ const exact = exactMatches(conditions, wanted);
4497
+ if (exact.length > 1) throw ambiguous(conditions, issue, wanted, exact);
4498
+ if (exact.length === 1) {
4499
+ const only = exact[0];
4500
+ if (only["kind"] !== "gate") {
2499
4501
  throw new CommandError(
2500
- `condition ${wanted} on ${issue} is a ${clean(byId["kind"])} condition \u2014 only a gate is satisfied by hand`
4502
+ `condition ${wanted} on ${issue} is a ${clean(only["kind"])} condition \u2014 only a gate is satisfied by hand`
2501
4503
  );
2502
4504
  }
2503
- return byId;
2504
- }
2505
- let matches = gates.filter((condition) => label(condition) === wanted);
2506
- if (matches.length === 0) {
2507
- matches = gates.filter((condition) => label(condition).toLowerCase() === wanted.toLowerCase());
4505
+ return only;
2508
4506
  }
2509
- if (matches.length === 1) return matches[0];
2510
- if (matches.length > 1) {
2511
- const ids = matches.map((condition) => clean(condition["id"])).join(", ");
2512
- throw new CommandError(`several gates on ${issue} match "${wanted}" \u2014 pass the condition id: ${ids}`);
4507
+ const gates = conditions.filter((condition) => condition["kind"] === "gate");
4508
+ const folded = wanted.toLowerCase();
4509
+ const loose = gates.filter(
4510
+ (condition) => gateLabel(condition).toLowerCase() === folded || gateKey(condition) !== "" && gateKey(condition) === folded
4511
+ );
4512
+ if (loose.length > 1) throw ambiguous(conditions, issue, wanted, loose);
4513
+ if (loose.length === 1) return loose[0];
4514
+ if (isUuid(wanted)) throw new CommandError(`No condition ${wanted} on ${issue}${expert ? " that you own" : ""}.`);
4515
+ const none = expert ? `No gate of yours on ${issue}` : `No gate on ${issue}`;
4516
+ if (gates.length === 0) {
4517
+ throw new CommandError(`${none} matches "${wanted}" \u2014 ${expert ? "you own no gate on it" : "it has no gates"}`);
2513
4518
  }
2514
- if (gates.length === 0) throw new CommandError(`No gate on ${issue} matches "${wanted}" \u2014 it has no gates`);
2515
- const labels = gates.map((condition) => `"${clean(label(condition))}"`).join(", ");
2516
- throw new CommandError(`No gate on ${issue} matches "${wanted}". Its gates: ${labels}`);
4519
+ const names = gates.map((condition) => gateName({ label: gateLabel(condition), key: gateKey(condition) })).join(", ");
4520
+ throw new CommandError(`${none} matches "${wanted}". ${expert ? "Your" : "Its"} gates: ${names}`);
2517
4521
  }
2518
- function isRecord(value) {
4522
+ function isRecord5(value) {
2519
4523
  return typeof value === "object" && value !== null && !Array.isArray(value);
2520
4524
  }
2521
- function str4(value) {
4525
+ function str7(value) {
2522
4526
  return value === null || value === void 0 ? "" : String(value);
2523
4527
  }
2524
4528
 
4529
+ // src/commands/expert.ts
4530
+ async function assignments(ctx) {
4531
+ const data = (await ctx.client.get(EXPERT_ASSIGNMENTS_PATH)).data;
4532
+ if (!isRecord6(data) || !Array.isArray(data["assignments"])) {
4533
+ throw new CommandError("The assignments response carried no list.");
4534
+ }
4535
+ const steps = data["assignments"].filter(isRecord6);
4536
+ const expert = isRecord6(data["expert"]) ? data["expert"] : {};
4537
+ const plan = isRecord6(data["plan"]) ? { id: data["plan"]["id"], status: data["plan"]["status"] } : null;
4538
+ const header = expertSummary({ role: expert["role"], plan, assigned_steps: steps.length });
4539
+ return { json: data, human: `${header}
4540
+ ${expertAssignments(steps)}` };
4541
+ }
4542
+ function isRecord6(value) {
4543
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4544
+ }
4545
+
2525
4546
  // src/core/credentials.ts
2526
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
4547
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
2527
4548
  import { homedir } from "os";
2528
4549
  import { dirname as dirname2, isAbsolute, join as join2 } from "path";
2529
4550
  function defaultCredentialsPath(env = process.env) {
@@ -2533,7 +4554,7 @@ function defaultCredentialsPath(env = process.env) {
2533
4554
  }
2534
4555
  function readCredentials(path2) {
2535
4556
  try {
2536
- const parsed = JSON.parse(readFileSync4(path2, "utf8"));
4557
+ const parsed = JSON.parse(readFileSync3(path2, "utf8"));
2537
4558
  if (typeof parsed === "object" && parsed !== null) {
2538
4559
  const { url, token } = parsed;
2539
4560
  if (typeof url === "string" && typeof token === "string") return { url, token };
@@ -2560,7 +4581,7 @@ function deleteCredentials(path2) {
2560
4581
  }
2561
4582
 
2562
4583
  // src/core/repo-config.ts
2563
- import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
4584
+ import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
2564
4585
  import { homedir as homedir2 } from "os";
2565
4586
  import { dirname as dirname3, isAbsolute as isAbsolute2, join as join3, parse } from "path";
2566
4587
  var CONFIG_RELATIVE_PATH = join3(".harness-cli", "config.json");
@@ -2579,7 +4600,7 @@ function findRepoConfig(startDir = process.cwd()) {
2579
4600
  function readRepoConfig(path2) {
2580
4601
  let parsed;
2581
4602
  try {
2582
- parsed = JSON.parse(readFileSync5(path2, "utf8"));
4603
+ parsed = JSON.parse(readFileSync4(path2, "utf8"));
2583
4604
  } catch (error) {
2584
4605
  const detail = error instanceof Error ? error.message : String(error);
2585
4606
  return { config: null, warnings: [`harness: ignoring malformed ${path2} (${detail})`] };
@@ -2590,13 +4611,13 @@ function readRepoConfig(path2) {
2590
4611
  const record = parsed;
2591
4612
  const config = { path: path2 };
2592
4613
  const warnings = [];
2593
- for (const field2 of ["team", "url", "org"]) {
2594
- const value = record[field2];
4614
+ for (const field3 of ["team", "url", "org"]) {
4615
+ const value = record[field3];
2595
4616
  if (value === void 0) continue;
2596
4617
  if (typeof value === "string" && value.trim() !== "") {
2597
- config[field2] = value.trim();
4618
+ config[field3] = value.trim();
2598
4619
  } else {
2599
- warnings.push(`harness: ignoring "${field2}" in ${path2} \u2014 expected a non-empty string`);
4620
+ warnings.push(`harness: ignoring "${field3}" in ${path2} \u2014 expected a non-empty string`);
2600
4621
  }
2601
4622
  }
2602
4623
  return { config, warnings };
@@ -2608,7 +4629,7 @@ function trustedHostsPath(env = process.env) {
2608
4629
  }
2609
4630
  function readTrustedHosts(env) {
2610
4631
  try {
2611
- const parsed = JSON.parse(readFileSync5(trustedHostsPath(env), "utf8"));
4632
+ const parsed = JSON.parse(readFileSync4(trustedHostsPath(env), "utf8"));
2612
4633
  if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed["hosts"])) {
2613
4634
  return parsed["hosts"].filter(
2614
4635
  (host) => typeof host === "string"
@@ -2740,19 +4761,22 @@ function validateBaseUrl(baseUrl) {
2740
4761
  }
2741
4762
  return { ok: false, message: `HARNESS_API_URL must be an http(s) URL with a host, got: ${baseUrl}` };
2742
4763
  }
2743
- function loginHelp(baseUrl) {
4764
+ function loginHelp(baseUrl, name = invokedName()) {
2744
4765
  return `Not signed in to ${baseUrl}.
2745
4766
 
2746
4767
  Interactively, in a terminal (not from inside an agent \u2014 the browser
2747
4768
  tab needs someone to see it):
2748
4769
 
2749
- harness login
4770
+ ${name} login
2750
4771
 
2751
4772
  For agents and CI, mint an API token (admin \u2192 API tokens) with the
2752
4773
  harness:read scope \u2014 plus harness:write only where the workflow
2753
4774
  creates or closes \u2014 set an expiry, and export it:
2754
4775
 
2755
4776
  export HARNESS_API_TOKEN=aone_...
4777
+
4778
+ An outside expert uses the pxc_ credential minted in their expert view:
4779
+ export it the same way, or paste it into \`${name} login --no-browser\`.
2756
4780
  `;
2757
4781
  }
2758
4782
  function blankToNull(value) {
@@ -2767,10 +4791,16 @@ export {
2767
4791
  UsageError,
2768
4792
  LocalError,
2769
4793
  CommandError,
2770
- CLI_VERSION,
4794
+ EXPERT_ASSIGNMENTS_PATH,
4795
+ isExpertToken,
4796
+ EXPERT_LAPSED,
4797
+ explainExpertRefusal,
4798
+ SESSION_PATH,
4799
+ isLoginCredential,
2771
4800
  Client,
2772
4801
  clean,
2773
4802
  json,
4803
+ expertSummary,
2774
4804
  errorDetails,
2775
4805
  MAX_IDEMPOTENCY_KEY_BYTES,
2776
4806
  IDEMPOTENCY_KEY_FORMAT,
@@ -2820,7 +4850,7 @@ export {
2820
4850
  linkAdd,
2821
4851
  linkUpdate,
2822
4852
  linkRm,
2823
- labelList,
4853
+ labelList2 as labelList,
2824
4854
  labelCreate,
2825
4855
  teamCreate,
2826
4856
  teamList,
@@ -2838,9 +4868,51 @@ export {
2838
4868
  memberAdd,
2839
4869
  memberSetRole,
2840
4870
  upload,
4871
+ toolsList,
4872
+ toolsSearch,
4873
+ toolsDomains,
4874
+ toolShow,
4875
+ skillsList,
4876
+ requireCapabilityId,
4877
+ requireSearchQuery,
4878
+ requireToolsOpts,
4879
+ requireSchemaView,
4880
+ JOB_STATES,
4881
+ JOB_STATUS_ALIASES,
4882
+ JOBS_MAX_LIMIT,
4883
+ JOBS_MAX_PAGES,
4884
+ EXIT_WAITING_FOR_PERSON,
4885
+ EXIT_JOB_ENDED,
4886
+ EXIT_INTERRUPTED,
4887
+ requireJobsArgs,
4888
+ jobsList,
4889
+ requireJobId,
4890
+ jobShow,
4891
+ jobWatch,
4892
+ watchStopSignal,
4893
+ OUTCOME_MAX_CHARACTERS,
4894
+ BUDGET_MAX_DOLLARS,
4895
+ prepareRun,
4896
+ MCP_SURFACE,
4897
+ startRun,
4898
+ run,
4899
+ jobContinue,
2841
4900
  resolveTextSources,
4901
+ STEER_MAX_CHARACTERS,
4902
+ instructionFrom,
4903
+ jobSteer,
4904
+ jobCancel,
4905
+ jobPause,
4906
+ requireProcedureId,
4907
+ requireProcedureName,
4908
+ procedureSave,
4909
+ procedureShow,
4910
+ prepareProcedureRun,
4911
+ startProcedureRun,
4912
+ procedureRun,
2842
4913
  PLAN_EXECUTORS,
2843
4914
  CONDITION_KINDS,
4915
+ GATE_OWNER_ROLES,
2844
4916
  readPlanFile,
2845
4917
  planCreate,
2846
4918
  planShow,
@@ -2848,7 +4920,9 @@ export {
2848
4920
  planRelease,
2849
4921
  planHold,
2850
4922
  gateSet,
4923
+ conditionList,
2851
4924
  requirePlanId,
4925
+ assignments,
2852
4926
  defaultCredentialsPath,
2853
4927
  readCredentials,
2854
4928
  writeCredentials,