@allocator-one/harness-cli 1.12.1 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,12 @@ var TransportError = class extends Error {
25
25
  this.name = "TransportError";
26
26
  }
27
27
  };
28
+ var MalformedResponseError = class extends TransportError {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = "MalformedResponseError";
32
+ }
33
+ };
28
34
  var UsageError = class extends Error {
29
35
  constructor(message) {
30
36
  super(message);
@@ -83,8 +89,13 @@ var Client = class {
83
89
  async get(path2, params) {
84
90
  return this.json(await this.raw("GET", path2, { params }));
85
91
  }
86
- async post(path2, body = {}) {
87
- return this.json(await this.raw("POST", path2, { json: this.withActor(body) }));
92
+ async post(path2, body = {}, options = {}) {
93
+ return this.json(
94
+ await this.raw("POST", path2, {
95
+ json: this.withActor(body),
96
+ idempotencyKey: options.idempotencyKey
97
+ })
98
+ );
88
99
  }
89
100
  async patch(path2, body) {
90
101
  return this.json(await this.raw("PATCH", path2, { json: this.withActor(body) }));
@@ -138,6 +149,9 @@ var Client = class {
138
149
  authorization: `Bearer ${this.config.token}`,
139
150
  "x-harness-cli-version": CLI_VERSION
140
151
  };
152
+ if (options.idempotencyKey !== void 0) {
153
+ headers["idempotency-key"] = options.idempotencyKey;
154
+ }
141
155
  let body;
142
156
  if (options.json !== void 0) {
143
157
  headers["content-type"] = "application/json";
@@ -170,10 +184,10 @@ var Client = class {
170
184
  try {
171
185
  parsed = JSON.parse(text);
172
186
  } catch {
173
- throw new TransportError(`the API answered with a non-JSON body (HTTP ${response.status})`);
187
+ throw new MalformedResponseError(`the API answered with a non-JSON body (HTTP ${response.status})`);
174
188
  }
175
189
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
176
- throw new TransportError(`the API answered with an invalid JSON envelope (HTTP ${response.status})`);
190
+ throw new MalformedResponseError(`the API answered with an invalid JSON envelope (HTTP ${response.status})`);
177
191
  }
178
192
  return parsed;
179
193
  }
@@ -222,16 +236,31 @@ function clean(value) {
222
236
  function json(term) {
223
237
  return JSON.stringify(term) + "\n";
224
238
  }
225
- function issuesTable(issues) {
239
+ var OPERATOR_TEAM_KEY = /^OPERATOR\d*$/;
240
+ var OPERATOR_TEAM_NAME = "Operator";
241
+ var AGENT_MARK = "[agent]";
242
+ var AGENT_LEGEND = `
243
+ ${AGENT_MARK} Operator runs this step for its plan; only the plan driver claims it.
244
+ `;
245
+ function isAgentStep(issue) {
246
+ return OPERATOR_TEAM_KEY.test(str(path(issue, "team", "key"))) && str(path(issue, "team", "name")) === OPERATOR_TEAM_NAME;
247
+ }
248
+ function issuesTable(issues, options = {}) {
226
249
  if (issues.length === 0) return "No issues.\n";
227
- const rows = issues.map((issue) => [
228
- str(issue["identifier"]),
229
- str(path(issue, "state", "name")) || "-",
230
- str(issue["priority_label"]) || "-",
231
- str(path(issue, "assignee", "name")) || "-",
232
- truncate(issue["title"])
233
- ]);
234
- return table(["ID", "STATE", "PRIORITY", "ASSIGNEE", "TITLE"], rows);
250
+ let marked = false;
251
+ const rows = issues.map((issue) => {
252
+ const agentStep = options.markAgentSteps === true && isAgentStep(issue);
253
+ if (agentStep) marked = true;
254
+ return [
255
+ str(issue["identifier"]),
256
+ str(path(issue, "state", "name")) || "-",
257
+ str(issue["priority_label"]) || "-",
258
+ str(path(issue, "assignee", "name")) || "-",
259
+ agentStep ? truncate(`${AGENT_MARK} ${clean(issue["title"])}`) : truncate(issue["title"])
260
+ ];
261
+ });
262
+ const rendered = table(["ID", "STATE", "PRIORITY", "ASSIGNEE", "TITLE"], rows);
263
+ return marked ? rendered + AGENT_LEGEND : rendered;
235
264
  }
236
265
  function issueDetail(issue, images = []) {
237
266
  const header = `${str(issue["identifier"])} \xB7 ${clean(issue["title"])}
@@ -243,6 +272,7 @@ function issueDetail(issue, images = []) {
243
272
  ["Team", path(issue, "team", "key")],
244
273
  ["Labels", labelsLine(issue["labels"])],
245
274
  ["Blocked", blockedLine(issue)],
275
+ ["Waiting", waitingLine(issue)],
246
276
  ["Due", issue["due_date"]],
247
277
  ["Updated", issue["updated_at"]]
248
278
  ];
@@ -258,7 +288,7 @@ function issueDetail(issue, images = []) {
258
288
  ${title}:
259
289
  ${indent(String(text))}
260
290
  `).join("");
261
- return header + fieldLines + sectionText + relationsSection(issue["relations"]) + childrenSection(issue["children"]) + commentsSection(issue["comments"]) + imagesSection(images);
291
+ return header + fieldLines + sectionText + conditionsSection(issue["conditions"]) + relationsSection(issue["relations"]) + childrenSection(issue["children"]) + commentsSection(issue["comments"]) + imagesSection(images);
262
292
  }
263
293
  function activityEntries(entries) {
264
294
  if (entries.length === 0) return "No activity.\n";
@@ -403,6 +433,185 @@ function table(headers, rows) {
403
433
  (row) => row.map((cell, column) => padGraphemes(cell, widths[column] ?? 0)).join(" ").replace(/\s+$/, "") + "\n"
404
434
  ).join("");
405
435
  }
436
+ function records(value) {
437
+ if (!Array.isArray(value)) return [];
438
+ return value.filter(
439
+ (entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry)
440
+ );
441
+ }
442
+ function waitingLine(issue) {
443
+ if (issue["waiting_on_condition"] !== true) return null;
444
+ const conditions = records(issue["conditions"]);
445
+ const open = conditions.filter((condition) => condition["satisfied"] !== true).length;
446
+ return `yes, on ${open} of ${conditions.length} wait condition${conditions.length === 1 ? "" : "s"}`;
447
+ }
448
+ function conditionsSection(conditions) {
449
+ const list2 = records(conditions);
450
+ if (list2.length === 0) return "";
451
+ return "\nWait conditions:\n" + list2.map((condition) => ` ${conditionLine(condition)}
452
+ `).join("");
453
+ }
454
+ function conditionLine(condition) {
455
+ const satisfied = condition["satisfied"] === true;
456
+ let what;
457
+ switch (condition["kind"]) {
458
+ case "gate":
459
+ what = `gate "${clean(condition["label"])}"`;
460
+ break;
461
+ case "not_before":
462
+ what = `not before ${clean(condition["not_before"])}`;
463
+ break;
464
+ case "event":
465
+ what = "system event";
466
+ break;
467
+ default:
468
+ what = clean(condition["kind"]) || "condition";
469
+ }
470
+ let when = "";
471
+ 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}`})`;
474
+ }
475
+ return `${satisfied ? "[x]" : "[ ]"} ${what}${when} \xB7 ${clean(condition["id"])}`;
476
+ }
477
+ var LIVE_PLAN_STATUSES = ["released", "running", "needs_attention"];
478
+ function planDetail(plan) {
479
+ const steps = records(plan["steps"]);
480
+ const fields = [
481
+ ["Root", planRootLine(plan["root"])],
482
+ ["Revision", planRevisionLine(plan)],
483
+ ["Budgets", planBudgetsLine(plan)],
484
+ ["Estimate", planEstimateLine(plan["estimate"])]
485
+ ];
486
+ const fieldLines = fields.filter((field2) => field2[1] !== null).map(([label, value]) => ` ${(label + ":").padEnd(12)}${value}
487
+ `).join("");
488
+ 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"]);
490
+ }
491
+ function planRootLine(root) {
492
+ if (typeof root !== "object" || root === null) return null;
493
+ const record = root;
494
+ return `${clean(record["identifier"])} \xB7 ${clean(record["title"])}`;
495
+ }
496
+ function planRevisionLine(plan) {
497
+ const revision = plan["revision"];
498
+ if (typeof revision !== "object" || revision === null) return "none yet (never released)";
499
+ const number = clean(revision["number"]);
500
+ const by = clean(path(plan, "released_by", "name"));
501
+ const at = clean(plan["released_at"]);
502
+ if (by === "" && at === "") return number;
503
+ return `${number} \xB7 released${by === "" ? "" : ` by ${by}`}${at === "" ? "" : ` at ${at}`}`;
504
+ }
505
+ function planBudgetsLine(plan) {
506
+ const retries = plan["retry_budget"];
507
+ const retryText = typeof retries === "number" ? `${retries} ${retries === 1 ? "retry" : "retries"} per failed Operator step` : null;
508
+ const spend = plan["spend_budget_cents"];
509
+ const spendText = typeof spend === "number" ? `spend budget ${spend} cents` : "no spend budget";
510
+ return [retryText, spendText].filter((part) => part !== null).join(" \xB7 ");
511
+ }
512
+ function planEstimateLine(estimate) {
513
+ if (typeof estimate !== "object" || estimate === null) return null;
514
+ const record = estimate;
515
+ const ceiling = record["ceiling_cents"];
516
+ if (typeof ceiling !== "number") return null;
517
+ const minimum = record["minimum_budget_cents"];
518
+ return `up to ${ceiling} cents${typeof minimum === "number" ? ` (minimum budget ${minimum} cents)` : ""}`;
519
+ }
520
+ function planStepsSection(steps, planStatus) {
521
+ if (steps.length === 0) return "";
522
+ const depths = stepDepths(steps);
523
+ const rows = steps.map((step) => [
524
+ " ".repeat(depths.get(str(step["identifier"])) ?? 0) + str(step["identifier"]),
525
+ str(step["key"]) || "-",
526
+ str(step["executor"]) || "-",
527
+ str(path(step, "state", "name")) || "-",
528
+ stepStatus(step, planStatus),
529
+ truncate(step["title"])
530
+ ]);
531
+ const rendered = table(["STEP", "KEY", "EXECUTOR", "STATE", "STATUS", "TITLE"], rows);
532
+ return "\nSteps:\n" + indentLines(rendered);
533
+ }
534
+ function stepDepths(steps) {
535
+ const parents = new Map(steps.map((step) => [str(step["identifier"]), str(step["parent"])]));
536
+ const depths = /* @__PURE__ */ new Map();
537
+ for (const identifier of parents.keys()) {
538
+ let depth = 0;
539
+ let parent = parents.get(identifier) ?? "";
540
+ const seen = /* @__PURE__ */ new Set([identifier]);
541
+ while (parent !== "" && parents.has(parent) && !seen.has(parent)) {
542
+ seen.add(parent);
543
+ depth += 1;
544
+ parent = parents.get(parent) ?? "";
545
+ }
546
+ depths.set(identifier, depth);
547
+ }
548
+ return depths;
549
+ }
550
+ function stepStatus(step, planStatus) {
551
+ const stateType = path(step, "state", "type");
552
+ if (step["archived"] === true) return "archived";
553
+ if (stateType === "completed") return "done";
554
+ if (stateType === "canceled") return "cancelled";
555
+ if (step["container"] === true) return "container";
556
+ if (step["ready"] === true) return "ready";
557
+ if (step["held"] === true) return "held";
558
+ if (step["in_revision"] === false && LIVE_PLAN_STATUSES.includes(planStatus)) return "not in revision";
559
+ if (records(step["conditions"]).some((condition) => condition["satisfied"] !== true)) return "waiting";
560
+ return "-";
561
+ }
562
+ 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"])}
566
+ `).join("");
567
+ }
568
+ function planConditionsSection(steps) {
569
+ const lines = steps.flatMap(
570
+ (step) => records(step["conditions"]).map((condition) => [clean(step["identifier"]), conditionLine(condition)])
571
+ );
572
+ if (lines.length === 0) return "";
573
+ const width = Math.max(...lines.map(([identifier]) => graphemeLength(identifier)));
574
+ return "\nWait conditions:\n" + lines.map(([identifier, line]) => ` ${padGraphemes(identifier, width)} ${line}
575
+ `).join("");
576
+ }
577
+ function planHeldSection(heldSteps) {
578
+ if (!Array.isArray(heldSteps) || heldSteps.length === 0) return "";
579
+ return `
580
+ Held by an edit until the plan is released again: ${heldSteps.map((step) => clean(step)).join(", ")}
581
+ `;
582
+ }
583
+ function indentLines(text) {
584
+ return text.split("\n").map((line) => line === "" ? line : " " + line).join("\n");
585
+ }
586
+ function errorDetails(body) {
587
+ return changesetDetails(body["details"]) + violationDetails(body["violations"]);
588
+ }
589
+ function changesetDetails(details) {
590
+ if (typeof details !== "object" || details === null || Array.isArray(details)) return "";
591
+ return Object.entries(details).map(([field2, messages]) => {
592
+ const text = Array.isArray(messages) ? messages.map(String).join("; ") : String(messages);
593
+ return `
594
+ ${clean(field2)}: ${clean(text)}`;
595
+ }).join("");
596
+ }
597
+ function violationDetails(violations) {
598
+ return records(violations).map((violation) => {
599
+ const code = clean(violation["code"]);
600
+ const message = clean(violation["message"]) || code || "invalid";
601
+ return `
602
+ ${violationSubject(violation)}: ${message}${code === "" ? "" : ` [${code}]`}`;
603
+ }).join("");
604
+ }
605
+ function violationSubject(violation) {
606
+ const key = typeof violation["key"] === "string" ? clean(violation["key"]) : null;
607
+ const index = typeof violation["index"] === "number" ? `steps[${violation["index"]}]` : null;
608
+ const issue = typeof violation["issue"] === "string" ? clean(violation["issue"]) : null;
609
+ if (key !== null) {
610
+ const where = index ?? issue;
611
+ return where === null ? `step "${key}"` : `step "${key}" (${where})`;
612
+ }
613
+ return issue ?? index ?? "plan";
614
+ }
406
615
  function relationsSection(relations) {
407
616
  if (!Array.isArray(relations) || relations.length === 0) return "";
408
617
  const lines = relations.map((relation) => ` ${str(relation["direction"]).padEnd(14)}${str(relation["other_issue"])}
@@ -439,10 +648,30 @@ function labelsLine(labels) {
439
648
  }
440
649
  function blockedLine(issue) {
441
650
  if (issue["blocked"] === true && Array.isArray(issue["blocked_by"])) {
442
- return `yes, by ${issue["blocked_by"].map(String).join(", ")}`;
651
+ const notes = blockerNotes(issue["blockers"]);
652
+ const blockers = issue["blocked_by"].map((blocker) => {
653
+ const identifier = String(blocker);
654
+ const note = notes.get(identifier);
655
+ return note === void 0 ? identifier : `${identifier} (${note})`;
656
+ });
657
+ return `yes, by ${blockers.join(", ")}`;
443
658
  }
444
659
  return null;
445
660
  }
661
+ function blockerNotes(blockers) {
662
+ const notes = /* @__PURE__ */ new Map();
663
+ if (!Array.isArray(blockers)) return notes;
664
+ for (const blocker of blockers) {
665
+ if (typeof blocker !== "object" || blocker === null) continue;
666
+ const record = blocker;
667
+ if (typeof record["identifier"] !== "string") continue;
668
+ const reasons = [];
669
+ if (record["state_type"] === "canceled") reasons.push("cancelled");
670
+ if (record["archived"] === true) reasons.push("archived");
671
+ if (reasons.length > 0) notes.set(record["identifier"], reasons.join(", "));
672
+ }
673
+ return notes;
674
+ }
446
675
  function indent(text) {
447
676
  return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
448
677
  }
@@ -600,8 +829,8 @@ function write(path2, bytes) {
600
829
  }
601
830
 
602
831
  // src/core/pagination.ts
603
- function notices(meta, requestedPageSize, records) {
604
- return capNotice(meta, requestedPageSize) + truncationNotice(meta, records);
832
+ function notices(meta, requestedPageSize, records2) {
833
+ return capNotice(meta, requestedPageSize) + truncationNotice(meta, records2);
605
834
  }
606
835
  function capNotice(meta, requestedPageSize) {
607
836
  const served = meta?.page_size;
@@ -611,12 +840,12 @@ function capNotice(meta, requestedPageSize) {
611
840
  }
612
841
  return "";
613
842
  }
614
- function truncationNotice(meta, records) {
843
+ function truncationNotice(meta, records2) {
615
844
  const total = meta?.total;
616
845
  const page = meta?.page;
617
846
  const pages = meta?.total_pages;
618
847
  if (typeof total !== "number" || typeof page !== "number" || typeof pages !== "number") return "";
619
- const shown = records.length;
848
+ const shown = records2.length;
620
849
  if (shown < total) {
621
850
  return `harness: showing ${shown} of ${total} (page ${page} of ${pages}) \u2014 pass --all for the rest, or --page to step through.
622
851
  `;
@@ -627,20 +856,20 @@ async function fetchEveryPage(fetchPage) {
627
856
  const first = await fetchPage(1);
628
857
  const totalPages = pageCount(first.meta);
629
858
  if (totalPages === null) return first.data;
630
- let records = first.data;
859
+ let records2 = first.data;
631
860
  for (let page = 2; page <= totalPages; page++) {
632
861
  const body = await fetchPage(page);
633
- records = dedupe([...records, ...body.data]);
862
+ records2 = dedupe([...records2, ...body.data]);
634
863
  }
635
- return records;
864
+ return records2;
636
865
  }
637
866
  function pageCount(meta) {
638
867
  const total = meta?.total_pages;
639
868
  return typeof total === "number" && total > 0 ? total : null;
640
869
  }
641
- function dedupe(records) {
870
+ function dedupe(records2) {
642
871
  const seen = /* @__PURE__ */ new Set();
643
- return records.filter((record) => {
872
+ return records2.filter((record) => {
644
873
  const id = record["id"];
645
874
  const key = id === void 0 || id === null ? `struct:${stableKey(record)}` : `id:${stableKey(id)}`;
646
875
  if (seen.has(key)) return false;
@@ -663,7 +892,8 @@ var STATUS_STATE_TYPES = {
663
892
  in_progress: "started",
664
893
  closed: "completed,canceled"
665
894
  };
666
- var READY_CLAIM_ATTEMPTS = 10;
895
+ var MAX_IDEMPOTENCY_KEY_BYTES = 200;
896
+ var IDEMPOTENCY_KEY_FORMAT = /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?(?![\s\S])/;
667
897
  var IDENTIFIER_FORMAT = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
668
898
  var UUID_FORMAT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
669
899
  function isUuid(value) {
@@ -676,22 +906,72 @@ function requireIdentifiers(identifiers) {
676
906
  }
677
907
  }
678
908
  async function ready(ctx, opts) {
909
+ if (opts.claim === true) return claimNext(ctx, opts);
679
910
  const params = { ...listParams(ctx, opts), ready: true };
680
- const { records, notice } = await fetchIssues(ctx, params, opts);
911
+ const { records: records2, notice } = await fetchIssues(ctx, params, opts);
681
912
  const rank = (issue) => PRIORITY_RANK[issue["priority"]] ?? 4;
682
- const sorted = [...records].sort((a, b) => rank(a) - rank(b));
683
- if (opts.claim === true) return claimFirst(ctx, sorted);
684
- return { json: sorted, human: issuesTable(sorted), notice };
913
+ const sorted = markAgentSteps([...records2].sort((a, b) => rank(a) - rank(b)));
914
+ return { json: sorted, human: issuesTable(sorted, { markAgentSteps: true }), notice };
915
+ }
916
+ function markAgentSteps(issues) {
917
+ return issues.map((issue) => ({ ...issue, agent_step: isAgentStep(issue) }));
918
+ }
919
+ function requireReadyClaimArgs(opts) {
920
+ const paging = [
921
+ opts.all === true ? "--all" : null,
922
+ opts.limit !== void 0 ? "--limit" : null,
923
+ opts.page !== void 0 ? "--page" : null
924
+ ].filter((flag) => flag !== null);
925
+ if (paging.length === 0) return;
926
+ const last = paging.pop();
927
+ const flags = paging.length === 0 ? last : `${paging.join(", ")} or ${last}`;
928
+ throw new UsageError(
929
+ `--claim cannot be combined with ${flags}: the server picks from the team's whole ready queue, so there are no pages to choose from`
930
+ );
931
+ }
932
+ async function claimNext(ctx, opts) {
933
+ requireReadyClaimArgs(opts);
934
+ const team = requiredTeam(ctx, opts.team);
935
+ const check = `check \`harness list --team ${team} --assignee me --status in_progress\``;
936
+ const unreadable = (why) => new CommandError(`The claim-next response ${why}, so an issue may have been claimed \u2014 ${check}.`);
937
+ let body;
938
+ try {
939
+ body = await ctx.client.post(`/api/v1/teams/${team}/issues/claim-next`);
940
+ } catch (error) {
941
+ if (error instanceof MalformedResponseError) throw unreadable(`could not be read (${error.message})`);
942
+ if (error instanceof TransportError) {
943
+ throw new TransportError(
944
+ `${error.message} \u2014 if the request reached the server, an issue may have been claimed; ${check} before claiming again`
945
+ );
946
+ }
947
+ throw error;
948
+ }
949
+ if (body.data === null) {
950
+ return { json: null, human: `Nothing to claim: team ${team} has no unassigned ready issues.
951
+ ` };
952
+ }
953
+ const claimed = body.data;
954
+ if (!isIssueRecord(claimed)) throw unreadable("carried neither an issue nor null");
955
+ return {
956
+ json: claimed,
957
+ human: `Claimed ${str2(claimed["identifier"])}: ${clean(claimed["title"])}
958
+ `
959
+ };
960
+ }
961
+ function isIssueRecord(value) {
962
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
963
+ const identifier = value["identifier"];
964
+ return typeof identifier === "string" && identifier !== "";
685
965
  }
686
966
  async function blocked(ctx, opts) {
687
967
  const params = { ...listParams(ctx, opts), blocked: true };
688
- const { records, notice } = await fetchIssues(ctx, params, opts);
689
- return { json: records, human: issuesTable(records), notice };
968
+ const { records: records2, notice } = await fetchIssues(ctx, params, opts);
969
+ return { json: records2, human: issuesTable(records2), notice };
690
970
  }
691
971
  async function list(ctx, opts) {
692
972
  const params = listParams(ctx, opts);
693
- const { records, notice } = await fetchIssues(ctx, params, opts);
694
- return { json: records, human: issuesTable(records), notice };
973
+ const { records: records2, notice } = await fetchIssues(ctx, params, opts);
974
+ return { json: records2, human: issuesTable(records2), notice };
695
975
  }
696
976
  async function search(ctx, queryWords, opts) {
697
977
  if (queryWords.length === 0) throw new UsageError("usage: harness search <query>");
@@ -782,9 +1062,9 @@ async function stats2(ctx) {
782
1062
  }
783
1063
  async function prime(ctx) {
784
1064
  const statsBody = await ctx.client.get("/api/v1/stats");
785
- const readyBody = await ctx.client.get("/api/v1/issues", { ready: true, page_size: 10 });
786
- const json2 = { stats: statsBody.data, ready: readyBody.data };
787
- const human = "# Harness workflow context\n\n" + stats(statsBody.data) + "\nReady to work:\n" + issuesTable(readyBody.data) + `
1065
+ const ready2 = markAgentSteps(await mostImportantReady(ctx));
1066
+ 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 }) + `
788
1068
  Workflow: harness ready \u2192 harness update <id> --claim \u2192 work \u2192
789
1069
  harness close <id> --reason "Implemented in <commit-hash>" (response
790
1070
  lists newly unblocked issues \u2014 surface them).
@@ -794,6 +1074,14 @@ Before reporting done: close the issues you finished.
794
1074
  `;
795
1075
  return { json: json2, human };
796
1076
  }
1077
+ async function mostImportantReady(ctx) {
1078
+ try {
1079
+ return (await ctx.client.get("/api/v1/issues/next", { limit: 10 })).data;
1080
+ } catch (error) {
1081
+ if (!(error instanceof HttpError) || error.status !== 404) throw error;
1082
+ return (await ctx.client.get("/api/v1/issues", { ready: true, page_size: 10 })).data;
1083
+ }
1084
+ }
797
1085
  async function version(config) {
798
1086
  let server;
799
1087
  if (config === null || config.baseUrl === "") {
@@ -810,6 +1098,7 @@ async function create(ctx, opts) {
810
1098
  }
811
1099
  validateType(opts.type);
812
1100
  const team = requiredTeam(ctx, opts.team);
1101
+ validateIdempotencyKey(opts.idempotencyKey);
813
1102
  const priority = optionalPriority(opts.priority);
814
1103
  const dueDate = optionalDueDate(opts.dueDate);
815
1104
  const labels = requestedLabelNames(wantedLabels(opts));
@@ -824,7 +1113,9 @@ async function create(ctx, opts) {
824
1113
  body = putDueDate(body, dueDate);
825
1114
  body = putAssignee(body, opts.assignee);
826
1115
  if (labelIds.length > 0) body["label_ids"] = labelIds;
827
- const created = await ctx.client.post(`/api/v1/teams/${team}/issues`, body);
1116
+ const created = await ctx.client.post(`/api/v1/teams/${team}/issues`, body, {
1117
+ idempotencyKey: opts.idempotencyKey
1118
+ });
828
1119
  const issue = created.data;
829
1120
  return {
830
1121
  json: issue,
@@ -832,6 +1123,16 @@ async function create(ctx, opts) {
832
1123
  `
833
1124
  };
834
1125
  }
1126
+ function validateIdempotencyKey(key) {
1127
+ if (key === void 0) return;
1128
+ if (key.trim() === "") throw new UsageError("idempotency key must not be blank");
1129
+ if (Buffer.byteLength(key, "utf8") > MAX_IDEMPOTENCY_KEY_BYTES) {
1130
+ throw new UsageError(`idempotency key must not exceed ${MAX_IDEMPOTENCY_KEY_BYTES} UTF-8 bytes`);
1131
+ }
1132
+ if (!IDEMPOTENCY_KEY_FORMAT.test(key)) {
1133
+ throw new UsageError("idempotency key must use printable ASCII and cannot start or end with a space");
1134
+ }
1135
+ }
835
1136
  async function update(ctx, identifier, opts) {
836
1137
  requireIdentifiers([identifier]);
837
1138
  if (opts.notes !== void 0 && opts.appendNotes !== void 0) {
@@ -850,8 +1151,12 @@ async function update(ctx, identifier, opts) {
850
1151
  let issue = labelPlan?.issue ?? null;
851
1152
  let wrote = false;
852
1153
  if (opts.claim === true) {
853
- const body = await ctx.client.post(`/api/v1/issues/${identifier}/claim`);
854
- issue = body.data;
1154
+ try {
1155
+ const body = await ctx.client.post(`/api/v1/issues/${identifier}/claim`);
1156
+ issue = body.data;
1157
+ } catch (error) {
1158
+ throw explainClaimRefusal(error, identifier);
1159
+ }
855
1160
  wrote = true;
856
1161
  }
857
1162
  let patch = {};
@@ -896,14 +1201,41 @@ ${opts.appendNotes}`;
896
1201
  `;
897
1202
  return { json: issue, human };
898
1203
  }
899
- async function close(ctx, identifiers, opts) {
1204
+ var CLAIM_REFUSAL_HINTS = {
1205
+ // The edit holds a released step, so the plan needs releasing again before
1206
+ // 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>`
1209
+ };
1210
+ function explainClaimRefusal(error, identifier) {
1211
+ if (!(error instanceof HttpError) || error.status !== 409) return error;
1212
+ const reason = error.body["reason"];
1213
+ const hint = typeof reason === "string" && Object.hasOwn(CLAIM_REFUSAL_HINTS, reason) ? CLAIM_REFUSAL_HINTS[reason](identifier) : void 0;
1214
+ if (hint === void 0) return error;
1215
+ return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
1216
+ }
1217
+ function requireCloseArgs(identifiers, opts) {
900
1218
  if (identifiers.length === 0) {
901
- throw new UsageError("usage: harness close <id> [<id>\u2026] [--reason <text>] [--cancel]");
1219
+ throw new UsageError(
1220
+ "usage: harness close <id> [<id>\u2026] [--reason <text>] [--cancel | --duplicate-of <id>]"
1221
+ );
902
1222
  }
903
1223
  requireIdentifiers(identifiers);
1224
+ if (opts.duplicateOf === void 0) return;
1225
+ requireIdentifiers([opts.duplicateOf]);
1226
+ if (opts.cancel === true) {
1227
+ throw new UsageError("--duplicate-of and --cancel are mutually exclusive");
1228
+ }
1229
+ if (identifiers.length !== 1) {
1230
+ throw new UsageError("--duplicate-of accepts exactly one source issue");
1231
+ }
1232
+ }
1233
+ async function close(ctx, identifiers, opts) {
1234
+ requireCloseArgs(identifiers, opts);
904
1235
  let body = {};
905
1236
  body = putPresent(body, "reason", opts.reason);
906
1237
  body = putPresent(body, "cancel", opts.cancel);
1238
+ body = putPresent(body, "duplicate_of", opts.duplicateOf);
907
1239
  const results = [];
908
1240
  for (const identifier of identifiers) {
909
1241
  try {
@@ -917,22 +1249,34 @@ async function close(ctx, identifiers, opts) {
917
1249
  }
918
1250
  }
919
1251
  }
920
- const human = results.map((result) => closeLine(result)).join("");
1252
+ const human = results.map((result) => closeLine(result, opts.duplicateOf)).join("");
921
1253
  const json2 = results.map(
922
- (result) => result.ok ? { identifier: result.identifier, closed: true, unblocked: result.response["unblocked"] ?? null } : { identifier: result.identifier, closed: false, error: errorText(result.error) }
1254
+ (result) => result.ok ? {
1255
+ identifier: result.identifier,
1256
+ closed: true,
1257
+ ...opts.duplicateOf === void 0 ? {} : { duplicate_of: opts.duplicateOf },
1258
+ unblocked: result.response["unblocked"] ?? null,
1259
+ // Passed through as sent: older servers omit it, and so does this.
1260
+ ..."still_blocking" in result.response ? { still_blocking: result.response["still_blocking"] } : {}
1261
+ } : { identifier: result.identifier, closed: false, error: errorText(result.error) }
923
1262
  );
924
1263
  const partial = results.some((result) => !result.ok);
925
1264
  return partial ? { json: json2, human, partial: true } : { json: json2, human };
926
1265
  }
927
- function closeLine(result) {
1266
+ function closeLine(result, duplicateOf) {
928
1267
  if (!result.ok) return `FAILED ${result.identifier}: ${errorText(result.error)}
929
1268
  `;
1269
+ const closure = duplicateOf === void 0 ? `Closed ${result.identifier}` : `Closed ${result.identifier} as duplicate of ${duplicateOf}`;
930
1270
  const unblocked = result.response["unblocked"];
931
- if (Array.isArray(unblocked) && unblocked.length > 0) {
932
- return `Closed ${result.identifier} \u2014 unblocked: ${unblocked.map(String).join(", ")}
1271
+ const line = Array.isArray(unblocked) && unblocked.length > 0 ? `${closure} \u2014 unblocked: ${unblocked.map((identifier) => clean(identifier)).join(", ")}
1272
+ ` : `${closure}
933
1273
  `;
934
- }
935
- return `Closed ${result.identifier}
1274
+ return line + stillBlockingLine(result.response["still_blocking"]);
1275
+ }
1276
+ function stillBlockingLine(stillBlocking) {
1277
+ if (!Array.isArray(stillBlocking) || stillBlocking.length === 0) return "";
1278
+ const identifiers = stillBlocking.map((identifier) => clean(identifier)).join(", ");
1279
+ return ` Still blocking: ${identifiers} (a cancelled issue still blocks its dependents)
936
1280
  `;
937
1281
  }
938
1282
  async function archive(ctx, identifiers, opts) {
@@ -1167,23 +1511,6 @@ function posixDetail(error) {
1167
1511
  if (code === "EACCES") return "permission denied";
1168
1512
  return error instanceof Error ? error.message : String(error);
1169
1513
  }
1170
- async function claimFirst(ctx, issues) {
1171
- for (const issue of issues.slice(0, READY_CLAIM_ATTEMPTS)) {
1172
- try {
1173
- const body = await ctx.client.post(`/api/v1/issues/${str2(issue["identifier"])}/claim`);
1174
- const claimed = body.data;
1175
- return {
1176
- json: claimed,
1177
- human: `Claimed ${str2(claimed["identifier"])}: ${clean(claimed["title"])}
1178
- `
1179
- };
1180
- } catch (error) {
1181
- if (error instanceof HttpError && error.status === 409) continue;
1182
- throw error;
1183
- }
1184
- }
1185
- throw new CommandError("No claimable ready issues.");
1186
- }
1187
1514
  async function setState(ctx, identifier, stateName2) {
1188
1515
  const showBody = await ctx.client.get(`/api/v1/issues/${identifier}`);
1189
1516
  const teamKey = str2((showBody.data["team"] ?? {})["key"]);
@@ -1347,15 +1674,15 @@ async function fetchIssues(ctx, params, opts) {
1347
1674
  throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
1348
1675
  }
1349
1676
  if (opts.all === true) {
1350
- const records2 = await fetchEveryPage(
1677
+ const records3 = await fetchEveryPage(
1351
1678
  (page) => ctx.client.get("/api/v1/issues", { ...params, page })
1352
1679
  );
1353
- return { records: records2, notice: "" };
1680
+ return { records: records3, notice: "" };
1354
1681
  }
1355
1682
  const body = await ctx.client.get("/api/v1/issues", params);
1356
- const records = body.data;
1683
+ const records2 = body.data;
1357
1684
  const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
1358
- return { records, notice: notices(body.meta, requested, records) };
1685
+ return { records: records2, notice: notices(body.meta, requested, records2) };
1359
1686
  }
1360
1687
  function listParams(ctx, opts) {
1361
1688
  if (opts.type !== void 0 && opts.label !== void 0) {
@@ -1481,8 +1808,8 @@ async function paginatedRead(ctx, path2, params, opts) {
1481
1808
  throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
1482
1809
  }
1483
1810
  if (opts.all === true) {
1484
- const records = await fetchEveryPage((page) => ctx.client.get(path2, { ...params, page }));
1485
- return { records, notice: "" };
1811
+ const records2 = await fetchEveryPage((page) => ctx.client.get(path2, { ...params, page }));
1812
+ return { records: records2, notice: "" };
1486
1813
  }
1487
1814
  const body = await ctx.client.get(path2, params);
1488
1815
  const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
@@ -1492,10 +1819,10 @@ async function projectList(ctx, opts) {
1492
1819
  let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
1493
1820
  if (opts.search !== void 0) params = { ...params, search: opts.search };
1494
1821
  if (opts.state !== void 0) params = { ...params, state: opts.state };
1495
- const { records, notice } = await paginatedRead(ctx, "/api/v1/projects", params, opts);
1496
- const human = records.length === 0 ? "No projects.\n" : table(
1822
+ const { records: records2, notice } = await paginatedRead(ctx, "/api/v1/projects", params, opts);
1823
+ const human = records2.length === 0 ? "No projects.\n" : table(
1497
1824
  ["ID", "STATE", "LEAD", "TARGET", "NAME"],
1498
- records.map((project) => [
1825
+ records2.map((project) => [
1499
1826
  str3(project["id"]),
1500
1827
  str3(project["state"]) || "-",
1501
1828
  str3(field(project, "lead", "name")) || "-",
@@ -1503,7 +1830,7 @@ async function projectList(ctx, opts) {
1503
1830
  str3(project["name"])
1504
1831
  ])
1505
1832
  );
1506
- return { json: records, human, notice };
1833
+ return { json: records2, human, notice };
1507
1834
  }
1508
1835
  async function projectShow(ctx, id) {
1509
1836
  requireSafeId("project", id);
@@ -1594,12 +1921,12 @@ async function milestoneCreate(ctx, opts) {
1594
1921
  async function docList(ctx, opts) {
1595
1922
  let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
1596
1923
  if (opts.search !== void 0) params = { ...params, search: opts.search };
1597
- const { records, notice } = await paginatedRead(ctx, "/api/v1/documents", params, opts);
1598
- const human = records.length === 0 ? "No documents.\n" : table(
1924
+ const { records: records2, notice } = await paginatedRead(ctx, "/api/v1/documents", params, opts);
1925
+ const human = records2.length === 0 ? "No documents.\n" : table(
1599
1926
  ["ID", "UPDATED", "TITLE"],
1600
- records.map((doc) => [str3(doc["id"]), str3(doc["updated_at"]) || "-", str3(doc["title"])])
1927
+ records2.map((doc) => [str3(doc["id"]), str3(doc["updated_at"]) || "-", str3(doc["title"])])
1601
1928
  );
1602
- return { json: records, human, notice };
1929
+ return { json: records2, human, notice };
1603
1930
  }
1604
1931
  async function docShow(ctx, id) {
1605
1932
  requireSafeId("document", id);
@@ -1888,8 +2215,315 @@ function indent2(text) {
1888
2215
  return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
1889
2216
  }
1890
2217
 
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()}`);
2227
+ }
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`
2236
+ );
2237
+ }
2238
+ if (file === void 0) {
2239
+ if (inline !== void 0) resolved[name] = inline;
2240
+ continue;
2241
+ }
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;
2254
+ }
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`);
2267
+ }
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)})`);
2280
+ }
2281
+ if (piped.length > limit.bytes) {
2282
+ throw new UsageError(oversize(`${option} -`, limit));
2283
+ }
2284
+ return decodeUtf8(piped, `${option} -`);
2285
+ }
2286
+ const flag = `${option} ${path2}`;
2287
+ let bytes;
2288
+ try {
2289
+ bytes = readFileSync3(path2);
2290
+ } catch (error) {
2291
+ throw new UsageError(`${flag}: ${describe(error)}`);
2292
+ }
2293
+ if (bytes.length > limit.bytes) throw new UsageError(oversize(flag, limit));
2294
+ return decodeUtf8(bytes, flag);
2295
+ }
2296
+ function decodeUtf8(bytes, flag) {
2297
+ try {
2298
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
2299
+ } catch {
2300
+ throw new UsageError(`${flag}: not valid UTF-8 \u2014 convert it first`);
2301
+ }
2302
+ }
2303
+ function oversize(what, limit) {
2304
+ return `${what}: over ${limit.bytes / 1e6} MB \u2014 ${limit.why}`;
2305
+ }
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)}`);
2313
+ }
2314
+ if (!stats3.isFile()) {
2315
+ throw new UsageError(
2316
+ `${flag}: ${stats3.isDirectory() ? "is a directory" : "not a regular file"}`
2317
+ );
2318
+ }
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
+ }
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;
2328
+ }
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);
2335
+ }
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`);
2346
+ }
2347
+ return parsed;
2348
+ }
2349
+ function parseJson(text, source) {
2350
+ try {
2351
+ return JSON.parse(text);
2352
+ } catch (error) {
2353
+ throw new UsageError(`${source}: not valid JSON (${firstLine(error)})`);
2354
+ }
2355
+ }
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)})`);
2365
+ }
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`);
2368
+ }
2369
+ return value;
2370
+ }
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;
2376
+ }
2377
+ function firstLine(error) {
2378
+ return (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
2379
+ }
2380
+ function readPlanFile(path2, flag = "--file") {
2381
+ return parsePlanDocument(readFileOption(flag, path2), path2);
2382
+ }
2383
+ async function planCreate(ctx, plan, opts) {
2384
+ requireDocument(plan, "a plan is one object: the root's fields and its steps");
2385
+ const team = requiredTeam(ctx, opts.team);
2386
+ validateIdempotencyKey(opts.idempotencyKey);
2387
+ let body;
2388
+ try {
2389
+ body = await ctx.client.post(`/api/v1/teams/${team}/plans`, plan, { idempotencyKey: opts.idempotencyKey });
2390
+ } catch (error) {
2391
+ throw explainMissing(error, `no team ${team} here, or this Harness server has no plan API yet`);
2392
+ }
2393
+ const created = body.data;
2394
+ const id = str4(created["id"]);
2395
+ return {
2396
+ json: created,
2397
+ human: `Created plan ${clean(id)} (${clean(created["status"]) || "held"})
2398
+
2399
+ ` + planDetail(created) + `
2400
+ Nothing runs until it is released: harness plan release ${clean(id)}
2401
+ `
2402
+ };
2403
+ }
2404
+ async function planShow(ctx, id) {
2405
+ requirePlanId(id);
2406
+ const plan = await planRequest(id, () => ctx.client.get(`/api/v1/plans/${id}`));
2407
+ return { json: plan, human: planDetail(plan) };
2408
+ }
2409
+ async function planEdit(ctx, id, edits) {
2410
+ requirePlanId(id);
2411
+ requireDocument(edits, "a plan edit is one object: steps to change and budgets");
2412
+ const plan = await planRequest(id, () => ctx.client.patch(`/api/v1/plans/${id}`, edits));
2413
+ const held = Array.isArray(plan["held_steps"]) && plan["held_steps"].length > 0;
2414
+ return {
2415
+ json: plan,
2416
+ human: `Updated plan ${clean(id)}
2417
+
2418
+ ` + planDetail(plan) + (held ? `
2419
+ Release it again to run the held steps: harness plan release ${clean(id)}
2420
+ ` : "")
2421
+ };
2422
+ }
2423
+ async function planRelease(ctx, id, opts) {
2424
+ requirePlanId(id);
2425
+ const body = {};
2426
+ if (opts.collaborators !== void 0 && opts.collaborators.length > 0) body["collaborators"] = opts.collaborators;
2427
+ if (opts.retryBudget !== void 0) body["retry_budget"] = opts.retryBudget;
2428
+ if (opts.spendBudgetCents !== void 0) body["spend_budget_cents"] = opts.spendBudgetCents;
2429
+ const plan = await planRequest(id, () => ctx.client.post(`/api/v1/plans/${id}/release`, body));
2430
+ const revision = plan["revision"] ?? {};
2431
+ const ready2 = Array.isArray(plan["steps"]) ? plan["steps"].filter((step) => isRecord(step) && step["ready"] === true).length : 0;
2432
+ return {
2433
+ json: plan,
2434
+ human: `Released plan ${clean(id)} as revision ${clean(revision["number"]) || "?"}: ${ready2} ${ready2 === 1 ? "step" : "steps"} ready now
2435
+ `
2436
+ };
2437
+ }
2438
+ async function planHold(ctx, id) {
2439
+ requirePlanId(id);
2440
+ const plan = await planRequest(id, () => ctx.client.post(`/api/v1/plans/${id}/hold`));
2441
+ return {
2442
+ json: plan,
2443
+ human: `Held plan ${clean(id)}: none of its steps is ready until it is released again
2444
+ `
2445
+ };
2446
+ }
2447
+ async function gateSet(ctx, issue, gate, satisfied) {
2448
+ requireIdentifiers([issue]);
2449
+ const wanted = gate.trim();
2450
+ if (wanted === "") throw new UsageError("name the gate by its label or its condition id");
2451
+ let conditions;
2452
+ try {
2453
+ conditions = (await ctx.client.get(`/api/v1/issues/${issue}/conditions`)).data;
2454
+ } catch (error) {
2455
+ throw explainMissing(error, `no issue ${issue} here, or this Harness server has no wait conditions yet`);
2456
+ }
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;
2465
+ let tail = "";
2466
+ if (satisfied) {
2467
+ tail = others > 0 ? ` \u2014 it still waits on ${others} other condition${others === 1 ? "" : "s"}` : " \u2014 no other wait condition holds it";
2468
+ }
2469
+ return {
2470
+ json: body.data,
2471
+ human: `${satisfied ? "Satisfied" : "Un-satisfied"} gate "${clean(target["label"])}" on ${issue}${tail}
2472
+ `
2473
+ };
2474
+ }
2475
+ function requirePlanId(id) {
2476
+ if (!isUuid(id)) throw new UsageError(`not a plan id: ${id} (expected the plan's UUID)`);
2477
+ }
2478
+ function requireDocument(value, what) {
2479
+ if (!isRecord(value)) throw new UsageError(what);
2480
+ }
2481
+ async function planRequest(id, send) {
2482
+ try {
2483
+ return (await send()).data;
2484
+ } catch (error) {
2485
+ throw explainMissing(error, `no plan ${id} in this organization, or this Harness server has no plan API yet`);
2486
+ }
2487
+ }
2488
+ function explainMissing(error, why) {
2489
+ if (!(error instanceof HttpError) || error.status !== 404) return error;
2490
+ return new HttpError(404, `${error.message} \u2014 ${why}`, error.body, error.retryAfter);
2491
+ }
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") {
2499
+ throw new CommandError(
2500
+ `condition ${wanted} on ${issue} is a ${clean(byId["kind"])} condition \u2014 only a gate is satisfied by hand`
2501
+ );
2502
+ }
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());
2508
+ }
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}`);
2513
+ }
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}`);
2517
+ }
2518
+ function isRecord(value) {
2519
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2520
+ }
2521
+ function str4(value) {
2522
+ return value === null || value === void 0 ? "" : String(value);
2523
+ }
2524
+
1891
2525
  // src/core/credentials.ts
1892
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
2526
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
1893
2527
  import { homedir } from "os";
1894
2528
  import { dirname as dirname2, isAbsolute, join as join2 } from "path";
1895
2529
  function defaultCredentialsPath(env = process.env) {
@@ -1899,7 +2533,7 @@ function defaultCredentialsPath(env = process.env) {
1899
2533
  }
1900
2534
  function readCredentials(path2) {
1901
2535
  try {
1902
- const parsed = JSON.parse(readFileSync3(path2, "utf8"));
2536
+ const parsed = JSON.parse(readFileSync4(path2, "utf8"));
1903
2537
  if (typeof parsed === "object" && parsed !== null) {
1904
2538
  const { url, token } = parsed;
1905
2539
  if (typeof url === "string" && typeof token === "string") return { url, token };
@@ -1926,7 +2560,7 @@ function deleteCredentials(path2) {
1926
2560
  }
1927
2561
 
1928
2562
  // src/core/repo-config.ts
1929
- import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
2563
+ import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
1930
2564
  import { homedir as homedir2 } from "os";
1931
2565
  import { dirname as dirname3, isAbsolute as isAbsolute2, join as join3, parse } from "path";
1932
2566
  var CONFIG_RELATIVE_PATH = join3(".harness-cli", "config.json");
@@ -1945,7 +2579,7 @@ function findRepoConfig(startDir = process.cwd()) {
1945
2579
  function readRepoConfig(path2) {
1946
2580
  let parsed;
1947
2581
  try {
1948
- parsed = JSON.parse(readFileSync4(path2, "utf8"));
2582
+ parsed = JSON.parse(readFileSync5(path2, "utf8"));
1949
2583
  } catch (error) {
1950
2584
  const detail = error instanceof Error ? error.message : String(error);
1951
2585
  return { config: null, warnings: [`harness: ignoring malformed ${path2} (${detail})`] };
@@ -1974,7 +2608,7 @@ function trustedHostsPath(env = process.env) {
1974
2608
  }
1975
2609
  function readTrustedHosts(env) {
1976
2610
  try {
1977
- const parsed = JSON.parse(readFileSync4(trustedHostsPath(env), "utf8"));
2611
+ const parsed = JSON.parse(readFileSync5(trustedHostsPath(env), "utf8"));
1978
2612
  if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed["hosts"])) {
1979
2613
  return parsed["hosts"].filter(
1980
2614
  (host) => typeof host === "string"
@@ -2137,8 +2771,12 @@ export {
2137
2771
  Client,
2138
2772
  clean,
2139
2773
  json,
2774
+ errorDetails,
2775
+ MAX_IDEMPOTENCY_KEY_BYTES,
2776
+ IDEMPOTENCY_KEY_FORMAT,
2140
2777
  requireIdentifiers,
2141
2778
  ready,
2779
+ requireReadyClaimArgs,
2142
2780
  blocked,
2143
2781
  list,
2144
2782
  search,
@@ -2149,7 +2787,9 @@ export {
2149
2787
  prime,
2150
2788
  version,
2151
2789
  create,
2790
+ validateIdempotencyKey,
2152
2791
  update,
2792
+ requireCloseArgs,
2153
2793
  close,
2154
2794
  archive,
2155
2795
  comments,
@@ -2165,6 +2805,7 @@ export {
2165
2805
  deleteComment,
2166
2806
  requireCommentId,
2167
2807
  attachImage,
2808
+ requiredTeam,
2168
2809
  projectList,
2169
2810
  projectShow,
2170
2811
  projectCreate,
@@ -2197,6 +2838,17 @@ export {
2197
2838
  memberAdd,
2198
2839
  memberSetRole,
2199
2840
  upload,
2841
+ resolveTextSources,
2842
+ PLAN_EXECUTORS,
2843
+ CONDITION_KINDS,
2844
+ readPlanFile,
2845
+ planCreate,
2846
+ planShow,
2847
+ planEdit,
2848
+ planRelease,
2849
+ planHold,
2850
+ gateSet,
2851
+ requirePlanId,
2200
2852
  defaultCredentialsPath,
2201
2853
  readCredentials,
2202
2854
  writeCredentials,