@allocator-one/harness-cli 1.13.0 → 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,12 +50,18 @@ 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,
@@ -62,8 +70,12 @@ import {
62
70
  requireCloseArgs,
63
71
  requireCommentId,
64
72
  requireIdentifiers,
73
+ requirePlanId,
74
+ requireReadyClaimArgs,
75
+ requiredTeam,
65
76
  resolveCacheHome,
66
77
  resolveConfig,
78
+ resolveTextSources,
67
79
  search,
68
80
  show,
69
81
  stateAdd,
@@ -80,10 +92,11 @@ import {
80
92
  upload,
81
93
  userList,
82
94
  validateBaseUrl,
95
+ validateIdempotencyKey,
83
96
  version,
84
97
  workflowStates,
85
98
  writeCredentials
86
- } from "./chunk-XFMECLOU.js";
99
+ } from "./chunk-F2YVTB2A.js";
87
100
 
88
101
  // src/cli.ts
89
102
  import { createInterface as createInterface2 } from "readline";
@@ -809,113 +822,6 @@ function defaultOpenBrowser(url) {
809
822
  }
810
823
  }
811
824
 
812
- // src/core/textinput.ts
813
- import { readFileSync, statSync } from "fs";
814
- import { isatty } from "tty";
815
- var STDIN_PATH = "-";
816
- var MAX_TEXT_BYTES = 1e6;
817
- function flagOf(name) {
818
- return name.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`);
819
- }
820
- function resolveTextSources(sources) {
821
- const resolved = {};
822
- const toRead = [];
823
- let stdinClaimedBy;
824
- for (const { name, inline, file } of sources) {
825
- if (inline !== void 0 && file !== void 0) {
826
- throw new UsageError(
827
- `--${flagOf(name)} and --${flagOf(name)}-file both given \u2014 pass one; there is no precedence between them`
828
- );
829
- }
830
- if (file === void 0) {
831
- if (inline !== void 0) resolved[name] = inline;
832
- continue;
833
- }
834
- if (file === STDIN_PATH) {
835
- if (isatty(0)) {
836
- throw new UsageError(
837
- `--${flagOf(name)}-file - reads stdin, but stdin is a terminal \u2014 pipe something in or pass a path`
838
- );
839
- }
840
- if (stdinClaimedBy !== void 0) {
841
- throw new UsageError(
842
- `--${flagOf(name)}-file and --${flagOf(stdinClaimedBy)}-file both read stdin \u2014 there is only one stream, so only one may be "-"`
843
- );
844
- }
845
- stdinClaimedBy = name;
846
- }
847
- if (file !== STDIN_PATH) checkReadable(file, name);
848
- toRead.push({ name, file });
849
- }
850
- for (const { name, file } of [...toRead].sort(byStdinLast)) {
851
- resolved[name] = stripOneTrailingNewline(readTextFile(file, name));
852
- }
853
- return resolved;
854
- }
855
- function readTextFile(path, name) {
856
- if (path === STDIN_PATH) {
857
- let piped;
858
- try {
859
- piped = readFileSync(0);
860
- } catch (error) {
861
- throw new UsageError(`--${flagOf(name)}-file -: cannot read stdin (${describe(error)})`);
862
- }
863
- if (piped.length > MAX_TEXT_BYTES) {
864
- throw new UsageError(oversize(`--${flagOf(name)}-file -`));
865
- }
866
- return decodeUtf8(piped, `--${flagOf(name)}-file -`);
867
- }
868
- const flag = `--${flagOf(name)}-file ${path}`;
869
- let bytes;
870
- try {
871
- bytes = readFileSync(path);
872
- } catch (error) {
873
- throw new UsageError(`${flag}: ${describe(error)}`);
874
- }
875
- if (bytes.length > MAX_TEXT_BYTES) throw new UsageError(oversize(flag));
876
- return decodeUtf8(bytes, flag);
877
- }
878
- function decodeUtf8(bytes, flag) {
879
- try {
880
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
881
- } catch {
882
- throw new UsageError(`${flag}: not valid UTF-8 \u2014 convert it first`);
883
- }
884
- }
885
- function oversize(what) {
886
- return `${what}: over ${MAX_TEXT_BYTES / 1e6} MB \u2014 that is not prose; check the path`;
887
- }
888
- function checkReadable(path, name) {
889
- const flag = `--${flagOf(name)}-file ${path}`;
890
- let stats2;
891
- try {
892
- stats2 = statSync(path);
893
- } catch (error) {
894
- throw new UsageError(`${flag}: ${describe(error)}`);
895
- }
896
- if (!stats2.isFile()) {
897
- throw new UsageError(
898
- `${flag}: ${stats2.isDirectory() ? "is a directory" : "not a regular file"}`
899
- );
900
- }
901
- if (stats2.size > MAX_TEXT_BYTES) throw new UsageError(oversize(flag));
902
- }
903
- function byStdinLast(a, b) {
904
- return Number(a.file === STDIN_PATH) - Number(b.file === STDIN_PATH);
905
- }
906
- function stripOneTrailingNewline(text) {
907
- if (text.endsWith("\r\n")) return text.slice(0, -2);
908
- if (text.endsWith("\n")) return text.slice(0, -1);
909
- return text;
910
- }
911
- function describe(error) {
912
- const code = error?.code;
913
- if (code === "ENOENT") return "no such file";
914
- if (code === "EISDIR") return "is a directory";
915
- if (code === "EACCES") return "permission denied";
916
- return error instanceof Error ? error.message : String(error);
917
- }
918
-
919
825
  // src/core/stdio.ts
920
826
  var BROKEN_PIPE_CODES = /* @__PURE__ */ new Set(["EPIPE", "ERR_STREAM_DESTROYED"]);
921
827
  function guardBrokenPipes(streams = [process.stdout, process.stderr]) {
@@ -934,11 +840,11 @@ import {
934
840
  existsSync,
935
841
  linkSync,
936
842
  mkdirSync,
937
- readFileSync as readFileSync2,
843
+ readFileSync,
938
844
  realpathSync,
939
845
  renameSync,
940
846
  rmSync,
941
- statSync as statSync2,
847
+ statSync,
942
848
  writeFileSync
943
849
  } from "fs";
944
850
  import { basename, dirname, join as join2, parse, sep } from "path";
@@ -981,7 +887,7 @@ function findPackageRoot(from) {
981
887
  }
982
888
  function packageNameAt(path) {
983
889
  try {
984
- const parsed = JSON.parse(readFileSync2(path, "utf8"));
890
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
985
891
  if (typeof parsed !== "object" || parsed === null) return null;
986
892
  const name = parsed["name"];
987
893
  return typeof name === "string" ? name : null;
@@ -992,7 +898,7 @@ function packageNameAt(path) {
992
898
  function versionAt(packageRoot) {
993
899
  try {
994
900
  const parsed = JSON.parse(
995
- readFileSync2(join2(packageRoot, "package.json"), "utf8")
901
+ readFileSync(join2(packageRoot, "package.json"), "utf8")
996
902
  );
997
903
  if (typeof parsed !== "object" || parsed === null) return null;
998
904
  const version2 = parsed["version"];
@@ -1057,7 +963,7 @@ function stampPath(env = process.env) {
1057
963
  }
1058
964
  function readStamp(env = process.env) {
1059
965
  try {
1060
- const parsed = JSON.parse(readFileSync2(stampPath(env), "utf8"));
966
+ const parsed = JSON.parse(readFileSync(stampPath(env), "utf8"));
1061
967
  if (typeof parsed !== "object" || parsed === null) return null;
1062
968
  const record = parsed;
1063
969
  const checkedAt = record["checkedAt"];
@@ -1362,8 +1268,8 @@ function releaseLock(lock) {
1362
1268
  }
1363
1269
  function readHolder(path) {
1364
1270
  try {
1365
- const mtimeMs = statSync2(path).mtimeMs;
1366
- 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);
1367
1273
  return { pid: Number.isInteger(pid) && pid > 0 ? pid : null, mtimeMs };
1368
1274
  } catch {
1369
1275
  return null;
@@ -1557,17 +1463,11 @@ async function fail(error, hostNotice) {
1557
1463
  }
1558
1464
  throw error;
1559
1465
  }
1560
- function errorDetails(body) {
1561
- const details = body["details"];
1562
- if (typeof details !== "object" || details === null || Array.isArray(details))
1563
- return "";
1564
- return Object.entries(details).map(([field, messages]) => {
1565
- const text = Array.isArray(messages) ? messages.map(String).join("; ") : String(messages);
1566
- return `
1567
- ${clean(field)}: ${clean(text)}`;
1568
- }).join("");
1569
- }
1570
- 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);
1571
1471
  await withConfig(
1572
1472
  opts.json === true,
1573
1473
  opts.claim === true,
@@ -1814,6 +1714,60 @@ relation.command("rm").argument("[issue]").argument("[id-or-issue]").option("--j
1814
1714
  return relationRm(ctx, issue, idOrIssue);
1815
1715
  });
1816
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
+ }
1817
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(
1818
1772
  async (id, text, opts) => {
1819
1773
  await withConfig(opts.json === true, true, (ctx) => {
@@ -2094,7 +2048,7 @@ program.command("upload").description("upload an image (25 MB) or H.264 MP4 vide
2094
2048
  });
2095
2049
  });
2096
2050
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
2097
- const { startMcpServer } = await import("./server-CYBKYG5E.js");
2051
+ const { startMcpServer } = await import("./server-75QJASTY.js");
2098
2052
  await startMcpServer();
2099
2053
  });
2100
2054
  var keys = program.command("keys").description("the org's provider API keys, handed to tooling without printing them").enablePositionalOptions();
@@ -2,10 +2,12 @@
2
2
  import {
3
3
  ACTIVITY_MAX_LIMIT,
4
4
  CLI_VERSION,
5
+ CONDITION_KINDS,
5
6
  Client,
6
7
  HttpError,
7
8
  IDEMPOTENCY_KEY_FORMAT,
8
9
  MAX_IDEMPOTENCY_KEY_BYTES,
10
+ PLAN_EXECUTORS,
9
11
  RELATION_CREATE_TYPES,
10
12
  RELATION_UPDATE_TYPES,
11
13
  TransportError,
@@ -24,6 +26,8 @@ import {
24
26
  docShow,
25
27
  docUpdate,
26
28
  editComment,
29
+ errorDetails,
30
+ gateSet,
27
31
  labelCreate,
28
32
  labelDelete,
29
33
  labelList,
@@ -38,6 +42,11 @@ import {
38
42
  memberSetRole,
39
43
  milestoneCreate,
40
44
  milestoneList,
45
+ planCreate,
46
+ planEdit,
47
+ planHold,
48
+ planRelease,
49
+ planShow,
41
50
  projectCreate,
42
51
  projectList,
43
52
  projectShow,
@@ -63,7 +72,7 @@ import {
63
72
  upload,
64
73
  userList,
65
74
  workflowStates
66
- } from "./chunk-XFMECLOU.js";
75
+ } from "./chunk-F2YVTB2A.js";
67
76
 
68
77
  // src/mcp/server.ts
69
78
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -99,7 +108,9 @@ async function run(work) {
99
108
  return { content };
100
109
  } catch (error) {
101
110
  if (error instanceof NeedsLoginError) return toolError(error.message);
102
- 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
+ }
103
114
  if (error instanceof TransportError) return toolError(`cannot reach the API: ${error.message}`);
104
115
  if (error instanceof Error) return toolError(error.message);
105
116
  throw error;
@@ -113,11 +124,55 @@ var page = {
113
124
  var identifier = z.string().describe("issue identifier, e.g. IO-123");
114
125
  var commentId = z.string().uuid().describe("comment UUID from issue_show or comment_list");
115
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");
116
171
  var TOOLS = [
117
172
  // ── Issues ──
118
173
  {
119
174
  name: "issue_list",
120
- 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.",
121
176
  readOnly: true,
122
177
  shape: {
123
178
  ...page,
@@ -160,7 +215,7 @@ var TOOLS = [
160
215
  },
161
216
  {
162
217
  name: "issue_show",
163
- 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.",
164
219
  readOnly: true,
165
220
  shape: { id: identifier, images: z.boolean().optional() },
166
221
  handler: (ctx, args) => show(ctx, args["id"], { images: args["images"] === true })
@@ -193,7 +248,7 @@ var TOOLS = [
193
248
  parent: z.string().optional(),
194
249
  assignee: z.string().optional(),
195
250
  labels: z.array(z.string()).optional(),
196
- idempotency_key: z.string().min(1).max(MAX_IDEMPOTENCY_KEY_BYTES).regex(IDEMPOTENCY_KEY_FORMAT, "must use printable ASCII without leading or trailing spaces").optional().describe("reuse the same printable-ASCII key when deliberately retrying this create")
251
+ idempotency_key: idempotencyKey.optional().describe("reuse the same printable-ASCII key when deliberately retrying this create")
197
252
  },
198
253
  handler: (ctx, args) => create(ctx, {
199
254
  ...args,
@@ -234,14 +289,14 @@ var TOOLS = [
234
289
  },
235
290
  {
236
291
  name: "issue_claim",
237
- 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).",
238
293
  readOnly: false,
239
294
  shape: { id: identifier },
240
295
  handler: (ctx, args) => update(ctx, args["id"], { claim: true })
241
296
  },
242
297
  {
243
298
  name: "issue_close",
244
- 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.",
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.",
245
300
  readOnly: false,
246
301
  shape: {
247
302
  ids: z.array(identifier).min(1),
@@ -665,6 +720,74 @@ var TOOLS = [
665
720
  shape: {},
666
721
  handler: (ctx) => stats(ctx)
667
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
+ },
668
791
  // ── Uploads ──
669
792
  {
670
793
  name: "upload",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allocator-one/harness-cli",
3
- "version": "1.13.0",
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": {