@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.
package/dist/cli.js CHANGED
@@ -30,7 +30,9 @@ import {
30
30
  docShow,
31
31
  docUpdate,
32
32
  editComment,
33
+ errorDetails,
33
34
  findRepoConfig,
35
+ gateSet,
34
36
  isHostTrusted,
35
37
  json,
36
38
  labelCreate,
@@ -48,21 +50,32 @@ import {
48
50
  memberSetRole,
49
51
  milestoneCreate,
50
52
  milestoneList,
53
+ planCreate,
54
+ planEdit,
55
+ planHold,
56
+ planRelease,
57
+ planShow,
51
58
  prime,
52
59
  projectCreate,
53
60
  projectList,
54
61
  projectShow,
55
62
  projectUpdate,
56
63
  readCredentials,
64
+ readPlanFile,
57
65
  ready,
58
66
  relationAdd,
59
67
  relationList,
60
68
  relationRm,
61
69
  relationUpdate,
70
+ requireCloseArgs,
62
71
  requireCommentId,
63
72
  requireIdentifiers,
73
+ requirePlanId,
74
+ requireReadyClaimArgs,
75
+ requiredTeam,
64
76
  resolveCacheHome,
65
77
  resolveConfig,
78
+ resolveTextSources,
66
79
  search,
67
80
  show,
68
81
  stateAdd,
@@ -79,10 +92,11 @@ import {
79
92
  upload,
80
93
  userList,
81
94
  validateBaseUrl,
95
+ validateIdempotencyKey,
82
96
  version,
83
97
  workflowStates,
84
98
  writeCredentials
85
- } from "./chunk-VOH6BBJD.js";
99
+ } from "./chunk-F2YVTB2A.js";
86
100
 
87
101
  // src/cli.ts
88
102
  import { createInterface as createInterface2 } from "readline";
@@ -808,113 +822,6 @@ function defaultOpenBrowser(url) {
808
822
  }
809
823
  }
810
824
 
811
- // src/core/textinput.ts
812
- import { readFileSync, statSync } from "fs";
813
- import { isatty } from "tty";
814
- var STDIN_PATH = "-";
815
- var MAX_TEXT_BYTES = 1e6;
816
- function flagOf(name) {
817
- return name.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`);
818
- }
819
- function resolveTextSources(sources) {
820
- const resolved = {};
821
- const toRead = [];
822
- let stdinClaimedBy;
823
- for (const { name, inline, file } of sources) {
824
- if (inline !== void 0 && file !== void 0) {
825
- throw new UsageError(
826
- `--${flagOf(name)} and --${flagOf(name)}-file both given \u2014 pass one; there is no precedence between them`
827
- );
828
- }
829
- if (file === void 0) {
830
- if (inline !== void 0) resolved[name] = inline;
831
- continue;
832
- }
833
- if (file === STDIN_PATH) {
834
- if (isatty(0)) {
835
- throw new UsageError(
836
- `--${flagOf(name)}-file - reads stdin, but stdin is a terminal \u2014 pipe something in or pass a path`
837
- );
838
- }
839
- if (stdinClaimedBy !== void 0) {
840
- throw new UsageError(
841
- `--${flagOf(name)}-file and --${flagOf(stdinClaimedBy)}-file both read stdin \u2014 there is only one stream, so only one may be "-"`
842
- );
843
- }
844
- stdinClaimedBy = name;
845
- }
846
- if (file !== STDIN_PATH) checkReadable(file, name);
847
- toRead.push({ name, file });
848
- }
849
- for (const { name, file } of [...toRead].sort(byStdinLast)) {
850
- resolved[name] = stripOneTrailingNewline(readTextFile(file, name));
851
- }
852
- return resolved;
853
- }
854
- function readTextFile(path, name) {
855
- if (path === STDIN_PATH) {
856
- let piped;
857
- try {
858
- piped = readFileSync(0);
859
- } catch (error) {
860
- throw new UsageError(`--${flagOf(name)}-file -: cannot read stdin (${describe(error)})`);
861
- }
862
- if (piped.length > MAX_TEXT_BYTES) {
863
- throw new UsageError(oversize(`--${flagOf(name)}-file -`));
864
- }
865
- return decodeUtf8(piped, `--${flagOf(name)}-file -`);
866
- }
867
- const flag = `--${flagOf(name)}-file ${path}`;
868
- let bytes;
869
- try {
870
- bytes = readFileSync(path);
871
- } catch (error) {
872
- throw new UsageError(`${flag}: ${describe(error)}`);
873
- }
874
- if (bytes.length > MAX_TEXT_BYTES) throw new UsageError(oversize(flag));
875
- return decodeUtf8(bytes, flag);
876
- }
877
- function decodeUtf8(bytes, flag) {
878
- try {
879
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
880
- } catch {
881
- throw new UsageError(`${flag}: not valid UTF-8 \u2014 convert it first`);
882
- }
883
- }
884
- function oversize(what) {
885
- return `${what}: over ${MAX_TEXT_BYTES / 1e6} MB \u2014 that is not prose; check the path`;
886
- }
887
- function checkReadable(path, name) {
888
- const flag = `--${flagOf(name)}-file ${path}`;
889
- let stats2;
890
- try {
891
- stats2 = statSync(path);
892
- } catch (error) {
893
- throw new UsageError(`${flag}: ${describe(error)}`);
894
- }
895
- if (!stats2.isFile()) {
896
- throw new UsageError(
897
- `${flag}: ${stats2.isDirectory() ? "is a directory" : "not a regular file"}`
898
- );
899
- }
900
- if (stats2.size > MAX_TEXT_BYTES) throw new UsageError(oversize(flag));
901
- }
902
- function byStdinLast(a, b) {
903
- return Number(a.file === STDIN_PATH) - Number(b.file === STDIN_PATH);
904
- }
905
- function stripOneTrailingNewline(text) {
906
- if (text.endsWith("\r\n")) return text.slice(0, -2);
907
- if (text.endsWith("\n")) return text.slice(0, -1);
908
- return text;
909
- }
910
- function describe(error) {
911
- const code = error?.code;
912
- if (code === "ENOENT") return "no such file";
913
- if (code === "EISDIR") return "is a directory";
914
- if (code === "EACCES") return "permission denied";
915
- return error instanceof Error ? error.message : String(error);
916
- }
917
-
918
825
  // src/core/stdio.ts
919
826
  var BROKEN_PIPE_CODES = /* @__PURE__ */ new Set(["EPIPE", "ERR_STREAM_DESTROYED"]);
920
827
  function guardBrokenPipes(streams = [process.stdout, process.stderr]) {
@@ -933,11 +840,11 @@ import {
933
840
  existsSync,
934
841
  linkSync,
935
842
  mkdirSync,
936
- readFileSync as readFileSync2,
843
+ readFileSync,
937
844
  realpathSync,
938
845
  renameSync,
939
846
  rmSync,
940
- statSync as statSync2,
847
+ statSync,
941
848
  writeFileSync
942
849
  } from "fs";
943
850
  import { basename, dirname, join as join2, parse, sep } from "path";
@@ -980,7 +887,7 @@ function findPackageRoot(from) {
980
887
  }
981
888
  function packageNameAt(path) {
982
889
  try {
983
- const parsed = JSON.parse(readFileSync2(path, "utf8"));
890
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
984
891
  if (typeof parsed !== "object" || parsed === null) return null;
985
892
  const name = parsed["name"];
986
893
  return typeof name === "string" ? name : null;
@@ -991,7 +898,7 @@ function packageNameAt(path) {
991
898
  function versionAt(packageRoot) {
992
899
  try {
993
900
  const parsed = JSON.parse(
994
- readFileSync2(join2(packageRoot, "package.json"), "utf8")
901
+ readFileSync(join2(packageRoot, "package.json"), "utf8")
995
902
  );
996
903
  if (typeof parsed !== "object" || parsed === null) return null;
997
904
  const version2 = parsed["version"];
@@ -1056,7 +963,7 @@ function stampPath(env = process.env) {
1056
963
  }
1057
964
  function readStamp(env = process.env) {
1058
965
  try {
1059
- const parsed = JSON.parse(readFileSync2(stampPath(env), "utf8"));
966
+ const parsed = JSON.parse(readFileSync(stampPath(env), "utf8"));
1060
967
  if (typeof parsed !== "object" || parsed === null) return null;
1061
968
  const record = parsed;
1062
969
  const checkedAt = record["checkedAt"];
@@ -1361,8 +1268,8 @@ function releaseLock(lock) {
1361
1268
  }
1362
1269
  function readHolder(path) {
1363
1270
  try {
1364
- const mtimeMs = statSync2(path).mtimeMs;
1365
- const pid = Number.parseInt(readFileSync2(path, "utf8").trim(), 10);
1271
+ const mtimeMs = statSync(path).mtimeMs;
1272
+ const pid = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
1366
1273
  return { pid: Number.isInteger(pid) && pid > 0 ? pid : null, mtimeMs };
1367
1274
  } catch {
1368
1275
  return null;
@@ -1556,17 +1463,11 @@ async function fail(error, hostNotice) {
1556
1463
  }
1557
1464
  throw error;
1558
1465
  }
1559
- function errorDetails(body) {
1560
- const details = body["details"];
1561
- if (typeof details !== "object" || details === null || Array.isArray(details))
1562
- return "";
1563
- return Object.entries(details).map(([field, messages]) => {
1564
- const text = Array.isArray(messages) ? messages.map(String).join("; ") : String(messages);
1565
- return `
1566
- ${clean(field)}: ${clean(text)}`;
1567
- }).join("");
1568
- }
1569
- program.command("ready").description("issues ready to work, highest priority first").option("--claim", "atomically claim the first claimable issue").option("--team <key>", "team filter").option("--limit <n>", "page size", integer).option("--page <n>", "page number", integer).option("--all", "follow every page").option("--json", "emit JSON").action(async (opts) => {
1466
+ program.command("ready").description("issues ready to work, highest priority first").option(
1467
+ "--claim",
1468
+ "claim the team's next ready, unassigned issue in one server-side request (team from --team, HARNESS_TEAM or the repo config; no paging flags)"
1469
+ ).option("--team <key>", "team filter; with --claim, the team to claim from").option("--limit <n>", "page size", integer).option("--page <n>", "page number", integer).option("--all", "follow every page").option("--json", "emit JSON").action(async (opts) => {
1470
+ if (opts.claim === true) requireReadyClaimArgs(opts);
1570
1471
  await withConfig(
1571
1472
  opts.json === true,
1572
1473
  opts.claim === true,
@@ -1676,7 +1577,7 @@ function requireNonBlankTitle(text, titleFile) {
1676
1577
  program.command("create").description("create an issue atomically after resolving requested labels").option("--title <text>", "issue title (required)").option("--description <text>", "problem statement").option("--design <text>", "design notes").option("--notes <text>", "working notes").option("--acceptance <text>", "acceptance criteria").option("--title-file <path>", 'read the title from a file ("-" for stdin)').option("--description-file <path>", 'read the description from a file ("-" for stdin)').option("--design-file <path>", 'read the design notes from a file ("-" for stdin)').option("--notes-file <path>", 'read the notes from a file ("-" for stdin)').option("--acceptance-file <path>", 'read the acceptance criteria from a file ("-" for stdin)').option(
1677
1578
  "--type <type>",
1678
1579
  "task | bug | feature | epic (applied as a type/ label)"
1679
- ).option("--priority <name>", "urgent | high | medium | low | none").option("--due-date <date>", "YYYY-MM-DD").option("--parent <id>", "parent epic").option("--assignee <who>", "me, a user id, an email, or a full name").option("--team <key>", "team key (or HARNESS_TEAM)").option(
1580
+ ).option("--priority <name>", "urgent | high | medium | low | none").option("--due-date <date>", "YYYY-MM-DD").option("--parent <id>", "parent epic").option("--assignee <who>", "me, a user id, an email, or a full name").option("--team <key>", "team key (or HARNESS_TEAM)").option("--idempotency-key <key>", "reuse the same key when deliberately retrying this create").option(
1680
1581
  "--label <name>",
1681
1582
  "resolve team-first, then workspace; create missing workspace labels before the issue (repeatable)",
1682
1583
  collect,
@@ -1726,15 +1627,15 @@ program.command("update").description("update an issue; --claim takes it atomica
1726
1627
  });
1727
1628
  }
1728
1629
  );
1729
- program.command("close").description("close issues; the response lists newly unblocked issues").argument("[ids...]").option("--reason <text>", "why it is done (e.g. the commit hash)").option("--reason-file <path>", 'read the reason from a file ("-" for stdin)').option("--cancel", "close as canceled instead of completed").option("--json", "emit JSON").action(
1630
+ program.command("close").description("close issues; the response lists newly unblocked issues").argument("[ids...]").option("--reason <text>", "why it is done (e.g. the commit hash)").option("--reason-file <path>", 'read the reason from a file ("-" for stdin)').option("--cancel", "close as canceled instead of completed").option("--duplicate-of <id>", "close one issue as a duplicate of the canonical issue").option("--json", "emit JSON").action(
1730
1631
  async (ids, opts) => {
1632
+ if (ids.length === 0) {
1633
+ throw new UsageError(
1634
+ "usage: harness close <id> [<id>\u2026] [--reason <text> | --reason-file <path>] [--cancel | --duplicate-of <id>]"
1635
+ );
1636
+ }
1637
+ requireCloseArgs(ids, opts);
1731
1638
  await withConfig(opts.json === true, true, (ctx) => {
1732
- if (ids.length === 0) {
1733
- throw new UsageError(
1734
- "usage: harness close <id> [<id>\u2026] [--reason <text> | --reason-file <path>] [--cancel]"
1735
- );
1736
- }
1737
- requireIdentifiers2(ids);
1738
1639
  const text = resolveTextSources([
1739
1640
  { name: "reason", inline: opts.reason, file: opts.reasonFile }
1740
1641
  ]);
@@ -1813,6 +1714,60 @@ relation.command("rm").argument("[issue]").argument("[id-or-issue]").option("--j
1813
1714
  return relationRm(ctx, issue, idOrIssue);
1814
1715
  });
1815
1716
  });
1717
+ var plan = program.command("plan").description("plans: a whole graph of issues created in one request, then released and held");
1718
+ plan.command("create").description("create a plan from a JSON or YAML file (the API's request body); it starts held").option("--file <path>", 'the plan: .json is JSON, anything else YAML ("-" for stdin)').option("--team <key>", "team of the root and of people's steps (or HARNESS_TEAM)").option("--idempotency-key <key>", "reuse the same key when deliberately retrying this create").option("--json", "emit JSON").action(async (opts) => {
1719
+ await withConfig(opts.json === true, true, (ctx) => {
1720
+ if (opts.file === void 0) {
1721
+ throw new UsageError("usage: harness plan create --file <plan.json|plan.yaml> [--team <key>]");
1722
+ }
1723
+ const team2 = requiredTeam(ctx, opts.team);
1724
+ validateIdempotencyKey(opts.idempotencyKey);
1725
+ return planCreate(ctx, readPlanFile(opts.file), { ...opts, team: team2 });
1726
+ });
1727
+ });
1728
+ plan.command("show").description("a plan's status, budgets, revision, steps, edges and wait conditions").argument("[id]").option("--json", "emit JSON").action(async (id, opts) => {
1729
+ await withConfig(opts.json === true, false, (ctx) => {
1730
+ if (id === void 0) throw new UsageError("usage: harness plan show <plan-id>");
1731
+ return planShow(ctx, id);
1732
+ });
1733
+ });
1734
+ plan.command("edit").description("change step executors and keys, and budgets, from a JSON or YAML file").argument("[id]").option("--file <path>", 'the edit: .json is JSON, anything else YAML ("-" for stdin)').option("--json", "emit JSON").action(async (id, opts) => {
1735
+ await withConfig(opts.json === true, true, (ctx) => {
1736
+ if (id === void 0 || opts.file === void 0) {
1737
+ throw new UsageError("usage: harness plan edit <plan-id> --file <edits.json|edits.yaml>");
1738
+ }
1739
+ requirePlanId(id);
1740
+ return planEdit(ctx, id, readPlanFile(opts.file));
1741
+ });
1742
+ });
1743
+ plan.command("release").description("release a plan as yourself: freeze its next revision and let its ready steps run").argument("[id]").option("--collaborator <who>", "a person of this organization (id or email) for the plan room (repeatable)", collect, []).option("--retry-budget <n>", "retries per failed Operator step", integer).option("--spend-budget-cents <n>", "the plan's spend budget; needed to release Operator steps", integer).option("--json", "emit JSON").action(
1744
+ async (id, opts) => {
1745
+ await withConfig(opts.json === true, true, (ctx) => {
1746
+ if (id === void 0) throw new UsageError("usage: harness plan release <plan-id>");
1747
+ return planRelease(ctx, id, { ...opts, collaborators: opts.collaborator });
1748
+ });
1749
+ }
1750
+ );
1751
+ plan.command("hold").description("pause every step of a plan until it is released again").argument("[id]").option("--json", "emit JSON").action(async (id, opts) => {
1752
+ await withConfig(opts.json === true, true, (ctx) => {
1753
+ if (id === void 0) throw new UsageError("usage: harness plan hold <plan-id>");
1754
+ return planHold(ctx, id);
1755
+ });
1756
+ });
1757
+ var gate = program.command("gate").description("manual wait conditions: gate satisfy|unsatisfy <issue> <gate>");
1758
+ for (const [verb, satisfied] of [
1759
+ ["satisfy", true],
1760
+ ["unsatisfy", false]
1761
+ ]) {
1762
+ gate.command(verb).description(`${satisfied ? "tick" : "untick"} a gate on an issue, by its label or condition id`).argument("[issue]").argument("[gate]").option("--json", "emit JSON").action(async (issue, gateName, opts) => {
1763
+ await withConfig(opts.json === true, true, (ctx) => {
1764
+ if (issue === void 0 || gateName === void 0) {
1765
+ throw new UsageError(`usage: harness gate ${verb} <issue> <gate-label-or-id>`);
1766
+ }
1767
+ return gateSet(ctx, issue, gateName, satisfied);
1768
+ });
1769
+ });
1770
+ }
1816
1771
  var commentCommand = program.command("comment").description("comment on an issue").argument("[id]").argument("[text...]").option("--body-file <path>", 'read the comment body from a file ("-" for stdin)').option("--mention <name>", "notify a person (repeatable)", collect, []).option("--json", "emit JSON").action(
1817
1772
  async (id, text, opts) => {
1818
1773
  await withConfig(opts.json === true, true, (ctx) => {
@@ -2093,7 +2048,7 @@ program.command("upload").description("upload an image (25 MB) or H.264 MP4 vide
2093
2048
  });
2094
2049
  });
2095
2050
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
2096
- const { startMcpServer } = await import("./server-FNNMISOZ.js");
2051
+ const { startMcpServer } = await import("./server-75QJASTY.js");
2097
2052
  await startMcpServer();
2098
2053
  });
2099
2054
  var keys = program.command("keys").description("the org's provider API keys, handed to tooling without printing them").enablePositionalOptions();
@@ -2,8 +2,12 @@
2
2
  import {
3
3
  ACTIVITY_MAX_LIMIT,
4
4
  CLI_VERSION,
5
+ CONDITION_KINDS,
5
6
  Client,
6
7
  HttpError,
8
+ IDEMPOTENCY_KEY_FORMAT,
9
+ MAX_IDEMPOTENCY_KEY_BYTES,
10
+ PLAN_EXECUTORS,
7
11
  RELATION_CREATE_TYPES,
8
12
  RELATION_UPDATE_TYPES,
9
13
  TransportError,
@@ -22,6 +26,8 @@ import {
22
26
  docShow,
23
27
  docUpdate,
24
28
  editComment,
29
+ errorDetails,
30
+ gateSet,
25
31
  labelCreate,
26
32
  labelDelete,
27
33
  labelList,
@@ -36,6 +42,11 @@ import {
36
42
  memberSetRole,
37
43
  milestoneCreate,
38
44
  milestoneList,
45
+ planCreate,
46
+ planEdit,
47
+ planHold,
48
+ planRelease,
49
+ planShow,
39
50
  projectCreate,
40
51
  projectList,
41
52
  projectShow,
@@ -61,7 +72,7 @@ import {
61
72
  upload,
62
73
  userList,
63
74
  workflowStates
64
- } from "./chunk-VOH6BBJD.js";
75
+ } from "./chunk-F2YVTB2A.js";
65
76
 
66
77
  // src/mcp/server.ts
67
78
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -97,7 +108,9 @@ async function run(work) {
97
108
  return { content };
98
109
  } catch (error) {
99
110
  if (error instanceof NeedsLoginError) return toolError(error.message);
100
- if (error instanceof HttpError) return toolError(`${error.message} (HTTP ${error.status})`);
111
+ if (error instanceof HttpError) {
112
+ return toolError(`${error.message} (HTTP ${error.status})${errorDetails(error.body)}`);
113
+ }
101
114
  if (error instanceof TransportError) return toolError(`cannot reach the API: ${error.message}`);
102
115
  if (error instanceof Error) return toolError(error.message);
103
116
  throw error;
@@ -111,11 +124,55 @@ var page = {
111
124
  var identifier = z.string().describe("issue identifier, e.g. IO-123");
112
125
  var commentId = z.string().uuid().describe("comment UUID from issue_show or comment_list");
113
126
  var dateField = z.string().optional().describe('YYYY-MM-DD; "" clears the date');
127
+ var planCondition = z.object({
128
+ kind: z.enum(CONDITION_KINDS),
129
+ not_before: z.string().optional().describe("not_before: an ISO 8601 UTC instant"),
130
+ label: z.string().optional().describe("gate: what a person ticks, e.g. Finance sign-off"),
131
+ event: z.string().optional().describe("event: deal_stage_changed, opportunity_stage_changed, form_submitted or investment_executed"),
132
+ subject_id: z.string().optional().describe("event: the record the event is about (one you can read)"),
133
+ target_id: z.string().optional().describe("event: the stage or status a stage change must reach")
134
+ }).passthrough();
135
+ var planIssueFields = {
136
+ title: z.string(),
137
+ description: z.string().optional().describe("an Operator step's description is its instruction (required for one)"),
138
+ design: z.string().optional(),
139
+ notes: z.string().optional(),
140
+ acceptance_criteria: z.string().optional(),
141
+ priority: z.number().int().optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low"),
142
+ estimate: z.number().optional(),
143
+ due_date: z.string().optional().describe("YYYY-MM-DD"),
144
+ assignee: z.string().optional().describe("email or user id; never on an Operator step"),
145
+ label_ids: z.array(z.string()).optional()
146
+ };
147
+ var planStep = z.object({
148
+ ...planIssueFields,
149
+ key: z.string().describe("unique in the plan; other steps refer to this step by it"),
150
+ parent: z.string().optional().describe("key of the step that contains this one; the root when absent"),
151
+ blocked_by: z.array(z.string()).optional().describe("keys of the steps this one waits for"),
152
+ executor: z.enum(PLAN_EXECUTORS).optional().describe("none (a person, the default) or operator"),
153
+ team: z.string().optional().describe("team key for a person's step; Operator steps go to the Operator team"),
154
+ conditions: z.array(planCondition).optional().describe("wait conditions: all must hold before the step is ready")
155
+ }).passthrough();
156
+ var planDocument = z.object({
157
+ ...planIssueFields,
158
+ parent: z.string().optional().describe("an existing issue to file the root under, e.g. IO-12"),
159
+ retry_budget: z.number().int().min(0).optional().describe("retries per failed Operator step (default 2)"),
160
+ spend_budget_cents: z.number().int().min(0).nullable().optional().describe("needed to release a plan with Operator steps"),
161
+ steps: z.array(planStep).min(1).describe("at most 99 steps besides the root")
162
+ }).passthrough();
163
+ var planId = z.string().uuid().describe("plan UUID, from plan_create");
164
+ var planStepEdit = z.object({
165
+ step: z.string().optional().describe("the step's key; give this or issue"),
166
+ issue: z.string().optional().describe("the step's issue identifier; give this or step"),
167
+ executor: z.enum(PLAN_EXECUTORS).optional().describe("none hands the step to a person"),
168
+ key: z.string().nullable().optional().describe("the step's new key")
169
+ }).passthrough();
170
+ var idempotencyKey = z.string().min(1).max(MAX_IDEMPOTENCY_KEY_BYTES).regex(IDEMPOTENCY_KEY_FORMAT, "must use printable ASCII without leading or trailing spaces");
114
171
  var TOOLS = [
115
172
  // ── Issues ──
116
173
  {
117
174
  name: "issue_list",
118
- description: "List issues with filters. ready=true \u2192 unblocked open issues; blocked=true \u2192 issues waiting on a blocker.",
175
+ description: "List issues with filters. ready=true \u2192 unblocked open issues, each with agent_step (true when it is filed in the Operator team, where plan steps Operator runs live: only the plan driver claims those, and a claim refusal is the authority); blocked=true \u2192 issues waiting on a blocker.",
119
176
  readOnly: true,
120
177
  shape: {
121
178
  ...page,
@@ -158,7 +215,7 @@ var TOOLS = [
158
215
  },
159
216
  {
160
217
  name: "issue_show",
161
- description: "Full issue detail: fields, relations, children, comments. images=true downloads attached images to the local cache and includes their paths as local_images.",
218
+ description: "Full issue detail: fields, relations, children, comments. blockers gives each unresolved blocker's state_type and archived flag: only a completed blocker resolves, so a cancelled or archived one still blocks. waiting_on_condition and conditions say which wait conditions (not_before, gate, event) still hold it; tick a gate with gate_set. images=true downloads attached images to the local cache and includes their paths as local_images.",
162
219
  readOnly: true,
163
220
  shape: { id: identifier, images: z.boolean().optional() },
164
221
  handler: (ctx, args) => show(ctx, args["id"], { images: args["images"] === true })
@@ -190,13 +247,15 @@ var TOOLS = [
190
247
  due_date: dateField,
191
248
  parent: z.string().optional(),
192
249
  assignee: z.string().optional(),
193
- labels: z.array(z.string()).optional()
250
+ labels: z.array(z.string()).optional(),
251
+ idempotency_key: idempotencyKey.optional().describe("reuse the same printable-ASCII key when deliberately retrying this create")
194
252
  },
195
253
  handler: (ctx, args) => create(ctx, {
196
254
  ...args,
197
255
  acceptance: args["acceptance_criteria"],
198
256
  dueDate: args["due_date"],
199
- label: args["labels"]
257
+ label: args["labels"],
258
+ idempotencyKey: args["idempotency_key"]
200
259
  })
201
260
  },
202
261
  {
@@ -230,21 +289,26 @@ var TOOLS = [
230
289
  },
231
290
  {
232
291
  name: "issue_claim",
233
- description: "Atomically claim an issue: assigns it and moves it to the team's first started state. A conflict means someone else holds it.",
292
+ description: "Atomically claim an issue: assigns it and moves it to the team's first started state. A conflict names its reason: someone else holds it, or it is not claimable (e.g. agent_step, waiting_on_condition).",
234
293
  readOnly: false,
235
294
  shape: { id: identifier },
236
295
  handler: (ctx, args) => update(ctx, args["id"], { claim: true })
237
296
  },
238
297
  {
239
298
  name: "issue_close",
240
- description: "Close issues. The result lists newly unblocked issues \u2014 surface them.",
299
+ description: "Close issues. Set duplicate_of for one source issue to close it as a duplicate; it cannot be combined with cancel. The result lists newly unblocked issues \u2014 surface them. still_blocking lists the dependents a cancelled or duplicate-closed issue still blocks: only completing releases them.",
241
300
  readOnly: false,
242
301
  shape: {
243
302
  ids: z.array(identifier).min(1),
244
303
  reason: z.string().optional().describe("why it is done, e.g. the commit hash"),
245
- cancel: z.boolean().optional().describe("close as canceled instead of completed")
304
+ cancel: z.boolean().optional().describe("close as canceled instead of completed; incompatible with duplicate_of"),
305
+ duplicate_of: identifier.optional().describe("canonical issue; requires exactly one source id and is incompatible with cancel")
246
306
  },
247
- handler: (ctx, args) => close(ctx, args["ids"], args)
307
+ handler: (ctx, args) => close(ctx, args["ids"], {
308
+ reason: args["reason"],
309
+ cancel: args["cancel"],
310
+ duplicateOf: args["duplicate_of"]
311
+ })
248
312
  },
249
313
  {
250
314
  name: "issue_comment",
@@ -656,6 +720,74 @@ var TOOLS = [
656
720
  shape: {},
657
721
  handler: (ctx) => stats(ctx)
658
722
  },
723
+ // ── Plans and gates ──
724
+ {
725
+ name: "plan_create",
726
+ description: "Create a plan: a root issue and up to 99 steps, created in one transaction or not at all, and held until a person releases it (plan_release). Steps refer to each other by key: parent (containment) and blocked_by (dependencies). executor operator files a step in the organization's Operator team, where only the plan driver claims it; a person's step goes to its team (or the plan's). conditions are wait conditions (not_before, gate, event). An invalid plan writes nothing and the error lists every violation by step key. Reuse idempotency_key when retrying.",
727
+ readOnly: false,
728
+ shape: {
729
+ team: z.string().optional().describe("team key for the root and people's steps; defaults to HARNESS_TEAM"),
730
+ plan: planDocument.describe("the root's fields plus steps"),
731
+ idempotency_key: idempotencyKey.optional().describe("reuse the same printable-ASCII key when deliberately retrying this create")
732
+ },
733
+ handler: (ctx, args) => planCreate(ctx, args["plan"], { team: args["team"], idempotencyKey: args["idempotency_key"] })
734
+ },
735
+ {
736
+ name: "plan_show",
737
+ description: "A plan's status, budgets, current revision and spend estimate, and its whole tree root first: each step's key, executor, parent, state, ready flag, held flag (edited since release), in_revision and wait conditions, plus the blocks edges between steps.",
738
+ readOnly: true,
739
+ shape: { id: planId },
740
+ handler: (ctx, args) => planShow(ctx, args["id"])
741
+ },
742
+ {
743
+ name: "plan_edit",
744
+ description: "Change step executors and keys (each step named by its key or issue identifier) and the plan's budgets. Editing a released step holds it until the plan is released again; a budget edit needs the plan held. Descriptions, parents and edges are edited with issue_update and relation_add/relation_rm; conditions are set at plan_create, and gate_set ticks a gate.",
745
+ readOnly: false,
746
+ shape: {
747
+ id: planId,
748
+ steps: z.array(planStepEdit).optional(),
749
+ retry_budget: z.number().int().min(0).optional(),
750
+ spend_budget_cents: z.number().int().min(0).nullable().optional().describe("null clears the budget")
751
+ },
752
+ handler: (ctx, args) => {
753
+ const { id, ...edits } = args;
754
+ return planEdit(ctx, id, edits);
755
+ }
756
+ },
757
+ {
758
+ name: "plan_release",
759
+ description: "Release a held plan (or one that needs attention) as the caller: freezes its next revision and lets its ready steps run. A plan with Operator steps needs a spend budget and the caller's right to run Operator; collaborators join its plan room.",
760
+ readOnly: false,
761
+ shape: {
762
+ id: planId,
763
+ collaborators: z.array(z.string()).max(20).optional().describe("people of this organization, by id or email"),
764
+ retry_budget: z.number().int().min(0).optional(),
765
+ spend_budget_cents: z.number().int().min(0).nullable().optional().describe("null clears the budget")
766
+ },
767
+ handler: (ctx, args) => planRelease(ctx, args["id"], {
768
+ collaborators: args["collaborators"],
769
+ retryBudget: args["retry_budget"],
770
+ spendBudgetCents: args["spend_budget_cents"]
771
+ })
772
+ },
773
+ {
774
+ name: "plan_hold",
775
+ description: "Pause every step of a plan: none is ready until it is released again.",
776
+ readOnly: false,
777
+ shape: { id: planId },
778
+ handler: (ctx, args) => planHold(ctx, args["id"])
779
+ },
780
+ {
781
+ name: "gate_set",
782
+ description: "Satisfy (satisfied true) or un-satisfy (false) a gate wait condition on an issue, found by its label or condition id (issue_show lists conditions). Needs staff standing in the organization.",
783
+ readOnly: false,
784
+ shape: {
785
+ issue: identifier,
786
+ gate: z.string().describe("the gate's label, or its condition id"),
787
+ satisfied: z.boolean()
788
+ },
789
+ handler: (ctx, args) => gateSet(ctx, args["issue"], args["gate"], args["satisfied"])
790
+ },
659
791
  // ── Uploads ──
660
792
  {
661
793
  name: "upload",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allocator-one/harness-cli",
3
- "version": "1.12.1",
3
+ "version": "1.14.0",
4
4
  "description": "CLI and MCP server for Harness, allocator.one's issue tracker",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,6 +33,7 @@
33
33
  "dependencies": {
34
34
  "@modelcontextprotocol/sdk": "^1.30.0",
35
35
  "commander": "^14.0.0",
36
+ "yaml": "^2.9.1",
36
37
  "zod": "^3.25.76"
37
38
  },
38
39
  "devDependencies": {