@jam-mcp/server 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +90 -86
  3. package/dist/adapters/jira-cloud/jira-assignee-resolution.adapter.d.ts +39 -0
  4. package/dist/adapters/jira-cloud/jira-assignee-resolution.adapter.js +94 -0
  5. package/dist/adapters/jira-cloud/jira-edit-metadata.adapter.d.ts +25 -0
  6. package/dist/adapters/jira-cloud/jira-edit-metadata.adapter.js +84 -0
  7. package/dist/adapters/jira-cloud/jira-read.adapter.js +20 -1
  8. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +9 -0
  9. package/dist/adapters/jira-cloud/jira-write.adapter.js +16 -0
  10. package/dist/application/apply-create-issue.js +1 -1
  11. package/dist/application/apply-write.js +86 -3
  12. package/dist/application/plan-write.d.ts +20 -2
  13. package/dist/application/plan-write.js +166 -18
  14. package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
  15. package/dist/bootstrap/setup-plan.d.ts +11 -0
  16. package/dist/bootstrap/setup-plan.js +10 -1
  17. package/dist/cli-entry.js +33 -33
  18. package/dist/config/schema.d.ts +1 -0
  19. package/dist/config/schema.js +35 -1
  20. package/dist/deps.d.ts +18 -0
  21. package/dist/deps.js +12 -0
  22. package/dist/domain/errors.d.ts +1 -1
  23. package/dist/domain/errors.js +14 -0
  24. package/dist/domain/write.d.ts +136 -3
  25. package/dist/domain/write.js +12 -0
  26. package/dist/index.js +0 -0
  27. package/dist/mcp/tools/jira-write-apply.tool.js +14 -14
  28. package/dist/mcp/tools/jira-write-plan.tool.js +40 -20
  29. package/dist/policy/assignee-policy.d.ts +60 -0
  30. package/dist/policy/assignee-policy.js +103 -0
  31. package/dist/policy/custom-field-policy.d.ts +93 -0
  32. package/dist/policy/custom-field-policy.js +230 -0
  33. package/dist/ports/jira-assignee-resolution.port.d.ts +51 -0
  34. package/dist/ports/jira-assignee-resolution.port.js +1 -0
  35. package/dist/ports/jira-edit-metadata.port.d.ts +22 -0
  36. package/dist/ports/jira-edit-metadata.port.js +1 -0
  37. package/dist/ports/jira-read.port.d.ts +21 -0
  38. package/dist/ports/jira-write.port.d.ts +8 -0
  39. package/package.json +69 -69
package/dist/deps.js CHANGED
@@ -38,6 +38,16 @@ export async function buildDeps(options = {}) {
38
38
  const { JiraCloudCreateMetadataAdapter } = await import("./adapters/jira-cloud/jira-create-metadata.adapter.js");
39
39
  jiraCreateMetadata = new JiraCloudCreateMetadataAdapter(credentials);
40
40
  }
41
+ let jiraAssignees = options.jiraAssignees;
42
+ if (!jiraAssignees) {
43
+ const { JiraCloudAssigneeResolutionAdapter } = await import("./adapters/jira-cloud/jira-assignee-resolution.adapter.js");
44
+ jiraAssignees = new JiraCloudAssigneeResolutionAdapter(credentials);
45
+ }
46
+ let jiraEditMetadata = options.jiraEditMetadata;
47
+ if (!jiraEditMetadata) {
48
+ const { JiraCloudEditMetadataAdapter } = await import("./adapters/jira-cloud/jira-edit-metadata.adapter.js");
49
+ jiraEditMetadata = new JiraCloudEditMetadataAdapter(credentials);
50
+ }
41
51
  return {
42
52
  config: resolved.config,
43
53
  configPath: resolved.configPath,
@@ -45,6 +55,8 @@ export async function buildDeps(options = {}) {
45
55
  jira,
46
56
  jiraWrite,
47
57
  jiraCreateMetadata,
58
+ jiraAssignees,
59
+ jiraEditMetadata,
48
60
  writePlans: new WritePlanStore(),
49
61
  cache: new NoopCache(),
50
62
  telemetry,
@@ -5,7 +5,7 @@
5
5
  * is mapped onto one of these codes so the agent (and `jam doctor`) can reason
6
6
  * about failures without parsing vendor-specific payloads.
7
7
  */
8
- export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", "JAM_WRITE_VALUE_NOT_ALLOWED", "JAM_WRITE_SCHEMA_CHANGED", "JAM_WRITE_PLAN_NOT_FOUND", "JAM_WRITE_PLAN_EXPIRED", "JAM_WRITE_CONFLICT", "JAM_WRITE_VERIFICATION_FAILED", "JAM_WRITE_UNCERTAIN"];
8
+ export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", "JAM_WRITE_VALUE_NOT_ALLOWED", "JAM_WRITE_SCHEMA_CHANGED", "JAM_WRITE_ASSIGNEE_NOT_FOUND", "JAM_WRITE_ASSIGNEE_AMBIGUOUS", "JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", "JAM_WRITE_ASSIGNEE_ALREADY_SET", "JAM_WRITE_CUSTOM_FIELD_NOT_EDITABLE", "JAM_WRITE_CUSTOM_FIELD_TYPE_UNSUPPORTED", "JAM_WRITE_PLAN_NOT_FOUND", "JAM_WRITE_PLAN_EXPIRED", "JAM_WRITE_CONFLICT", "JAM_WRITE_VERIFICATION_FAILED", "JAM_WRITE_UNCERTAIN"];
9
9
  export type JamErrorCode = (typeof JAM_ERROR_CODES)[number];
10
10
  export type JamErrorPayload = {
11
11
  error: {
@@ -34,6 +34,20 @@ export const JAM_ERROR_CODES = [
34
34
  "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED",
35
35
  "JAM_WRITE_VALUE_NOT_ALLOWED",
36
36
  "JAM_WRITE_SCHEMA_CHANGED",
37
+ // Assignment. A name is not an identity, and Jira decides who may hold an
38
+ // issue - so "nobody by that name", "several people by that name", "that
39
+ // person may not hold this issue" and "they already do" are four different
40
+ // things for a caller to do next, and none of them is "try again".
41
+ "JAM_WRITE_ASSIGNEE_NOT_FOUND",
42
+ "JAM_WRITE_ASSIGNEE_AMBIGUOUS",
43
+ "JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE",
44
+ "JAM_WRITE_ASSIGNEE_ALREADY_SET",
45
+ // Custom fields. Three permissions have to line up and none implies another,
46
+ // so a refusal says which one is missing: the team never opted this field in,
47
+ // Jira will not let it be set on this issue, or JAM does not know the type
48
+ // well enough to write it. Each points somewhere different.
49
+ "JAM_WRITE_CUSTOM_FIELD_NOT_EDITABLE",
50
+ "JAM_WRITE_CUSTOM_FIELD_TYPE_UNSUPPORTED",
37
51
  "JAM_WRITE_PLAN_NOT_FOUND",
38
52
  "JAM_WRITE_PLAN_EXPIRED",
39
53
  "JAM_WRITE_CONFLICT",
@@ -20,9 +20,9 @@
20
20
  * layer: these name an issue, creation names a project; these compare a
21
21
  * revision to detect a conflict, creation has no revision to compare.
22
22
  */
23
- export declare const EXISTING_ISSUE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition"];
23
+ export declare const EXISTING_ISSUE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition", "assignee.update", "custom-field.update"];
24
24
  /** The operations the public MCP surface accepts. Nothing else is reachable. */
25
- export declare const WRITE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition", "issue.create"];
25
+ export declare const WRITE_OPERATIONS: readonly ["comment.add", "field.update", "status.transition", "assignee.update", "custom-field.update", "issue.create"];
26
26
  export type ExistingIssueOperation = (typeof EXISTING_ISSUE_OPERATIONS)[number];
27
27
  export type WriteOperation = (typeof WRITE_OPERATIONS)[number];
28
28
  /**
@@ -48,6 +48,32 @@ export type FieldUpdateInput = {
48
48
  export type StatusTransitionInput = {
49
49
  status: string;
50
50
  };
51
+ /**
52
+ * Who to assign an issue to, as a person would say it.
53
+ *
54
+ * A display name, or an accountId if the caller already has one. Either way it
55
+ * is a selector, not an identifier: nothing here is ever sent to Jira. It is
56
+ * resolved against Jira's own user directory first, and what gets written is
57
+ * the accountId that resolution produced.
58
+ */
59
+ export type AssigneeUpdateInput = {
60
+ assignee: string;
61
+ };
62
+ /**
63
+ * A Jira user as JAM identifies them.
64
+ *
65
+ * `accountId` is the identity; `displayName` is for the human reading the
66
+ * receipt. They are not interchangeable - two people can share a display name,
67
+ * which is precisely why an assignment is verified on the accountId.
68
+ */
69
+ export type AssigneeRef = {
70
+ accountId: string;
71
+ displayName: string;
72
+ };
73
+ /** A user Jira offered in answer to a search. */
74
+ export type AssigneeCandidate = AssigneeRef & {
75
+ active: boolean;
76
+ };
51
77
  /**
52
78
  * Fields `issue.create` may set.
53
79
  *
@@ -67,7 +93,7 @@ export type CreateIssueInput = {
67
93
  labels?: string[];
68
94
  components?: string[];
69
95
  };
70
- export type WriteInput = CommentAddInput | FieldUpdateInput | StatusTransitionInput | CreateIssueInput;
96
+ export type WriteInput = CommentAddInput | FieldUpdateInput | StatusTransitionInput | AssigneeUpdateInput | CustomFieldUpdateInput | CreateIssueInput;
71
97
  /** An issue type as Jira offers it for one project, right now. */
72
98
  export type CreateIssueType = {
73
99
  id: string;
@@ -117,6 +143,93 @@ export type CreateSchemaRequirements = {
117
143
  resolved: string;
118
144
  }[];
119
145
  };
146
+ /**
147
+ * One field on an issue's edit screen, as Jira describes it.
148
+ *
149
+ * Normalized at the adapter: `operations` and `schema` come straight from
150
+ * Jira's own vocabulary because they are the vocabulary the decision is made
151
+ * in, but nothing else of the raw document travels.
152
+ */
153
+ export type EditFieldMetadata = {
154
+ /** Jira's field id, e.g. `customfield_10021`. */
155
+ id: string;
156
+ name: string;
157
+ required: boolean;
158
+ /** What Jira says can be done to this field: `set`, `add`, `remove`, ... */
159
+ operations: string[];
160
+ schema: {
161
+ type: string;
162
+ /** Element type, for `type: "array"`. */
163
+ items?: string;
164
+ /** The custom field's implementation key, when it is a custom field. */
165
+ custom?: string;
166
+ customId?: number;
167
+ };
168
+ /** Present only where Jira constrains the value. Absent is not empty. */
169
+ allowedValues?: EditFieldOption[];
170
+ };
171
+ /**
172
+ * One option Jira offers for a constrained field.
173
+ *
174
+ * `id` is the identity and `label` is what a person reads - the same split as
175
+ * a user's accountId and display name, and for the same reason: an option can
176
+ * be renamed without becoming a different option, and two options could carry
177
+ * the same label.
178
+ */
179
+ export type EditFieldOption = {
180
+ id: string;
181
+ label: string;
182
+ };
183
+ /**
184
+ * The custom field value families JAM can write.
185
+ *
186
+ * Narrow on purpose. Each of these has an unambiguous wire shape that JAM can
187
+ * produce from a plain caller value and compare after the fact. Everything
188
+ * else - dates needing a timezone policy, rich text needing ADF, user and
189
+ * group pickers needing identity resolution, app-owned fields with private
190
+ * semantics - is refused rather than guessed at.
191
+ */
192
+ export declare const CUSTOM_FIELD_KINDS: readonly ["text", "number", "single-option", "multi-option"];
193
+ export type CustomFieldKind = (typeof CUSTOM_FIELD_KINDS)[number];
194
+ export type CustomFieldUpdateInput = {
195
+ /** A configured field id, or a configured writable field name. */
196
+ field: string;
197
+ value: string | number | string[];
198
+ };
199
+ /**
200
+ * What a custom-field plan depends on, recorded so apply can check it again.
201
+ *
202
+ * The issue's revision does not cover any of this: a field can be taken off a
203
+ * screen, lose its `set` operation, change type, or have an option renamed
204
+ * without the issue itself being touched. So these premises are frozen
205
+ * alongside `baseUpdated`, and re-derived before the write.
206
+ */
207
+ export type CustomFieldRequirements = {
208
+ fieldId: string;
209
+ fieldName: string;
210
+ kind: CustomFieldKind;
211
+ schema: {
212
+ type: string;
213
+ items?: string;
214
+ custom?: string;
215
+ };
216
+ /** Options resolved from Jira's allowed list, for the option kinds. */
217
+ resolvedOptions?: EditFieldOption[];
218
+ };
219
+ /** A custom field value as a receipt shows it - reviewable, not a Jira payload. */
220
+ export type CustomFieldValueView = {
221
+ id: string;
222
+ name: string;
223
+ value: string | number | null;
224
+ } | {
225
+ id: string;
226
+ name: string;
227
+ value: EditFieldOption | null;
228
+ } | {
229
+ id: string;
230
+ name: string;
231
+ value: EditFieldOption[];
232
+ };
120
233
  /** A transition as Jira currently offers it for one issue. */
121
234
  export type JiraTransition = {
122
235
  id: string;
@@ -154,6 +267,19 @@ export type ExistingIssueWritePlan = WritePlanCommon & {
154
267
  * from a status name.
155
268
  */
156
269
  transition?: JiraTransition;
270
+ /**
271
+ * Who the issue was assigned to when the plan was made, by identity.
272
+ *
273
+ * Present only for `assignee.update`, and separate from `before` because
274
+ * `before` is what a receipt shows a human while this is what apply compares.
275
+ * `undefined` means the issue was unassigned.
276
+ */
277
+ baseAssigneeAccountId?: string;
278
+ /**
279
+ * What this plan assumed about a custom field's configuration. Present only
280
+ * for `custom-field.update`; apply re-derives each premise before writing.
281
+ */
282
+ customFieldRequirements?: CustomFieldRequirements;
157
283
  };
158
284
  /**
159
285
  * A plan to create an issue that does not exist yet.
@@ -185,6 +311,13 @@ export type WriteMutation = {
185
311
  } | {
186
312
  kind: "transition";
187
313
  transitionId: string;
314
+ } | {
315
+ kind: "assignee";
316
+ accountId: string;
317
+ } | {
318
+ kind: "custom-field";
319
+ fieldId: string;
320
+ value: unknown;
188
321
  } | {
189
322
  kind: "create";
190
323
  fields: Record<string, unknown>;
@@ -24,6 +24,8 @@ export const EXISTING_ISSUE_OPERATIONS = [
24
24
  "comment.add",
25
25
  "field.update",
26
26
  "status.transition",
27
+ "assignee.update",
28
+ "custom-field.update",
27
29
  ];
28
30
  /** The operations the public MCP surface accepts. Nothing else is reachable. */
29
31
  export const WRITE_OPERATIONS = [...EXISTING_ISSUE_OPERATIONS, "issue.create"];
@@ -54,6 +56,16 @@ export const CREATABLE_FIELDS = [
54
56
  "labels",
55
57
  "components",
56
58
  ];
59
+ /**
60
+ * The custom field value families JAM can write.
61
+ *
62
+ * Narrow on purpose. Each of these has an unambiguous wire shape that JAM can
63
+ * produce from a plain caller value and compare after the fact. Everything
64
+ * else - dates needing a timezone policy, rich text needing ADF, user and
65
+ * group pickers needing identity resolution, app-owned fields with private
66
+ * semantics - is refused rather than guessed at.
67
+ */
68
+ export const CUSTOM_FIELD_KINDS = ["text", "number", "single-option", "multi-option"];
57
69
  export function isWriteOperation(value) {
58
70
  return WRITE_OPERATIONS.includes(value);
59
71
  }
package/dist/index.js CHANGED
File without changes
@@ -1,20 +1,20 @@
1
1
  import { z } from "zod";
2
2
  import { applyWritePlan } from "../../application/apply-write.js";
3
3
  import { runTool } from "../tool-result.js";
4
- const DESCRIPTION = `Apply a plan from jira_write_plan. This changes Jira.
5
-
6
- Takes a planId and nothing else. The change was decided when the plan was made, so there is no field, payload or override to pass here - that is deliberate, and it is what stops a write happening without the state check that planning did.
7
-
8
- Before writing, JAM re-reads the issue and compares it to what the plan saw. If it moved, you get JAM_WRITE_CONFLICT and no write happens: call jira_write_plan again against the new state rather than treating the conflict as a transient failure.
9
-
10
- After writing, JAM reads the issue back and checks the intended result is actually there. Only then does it return "applied". Jira accepting a request is not the same as the issue having changed.
11
-
12
- Failures worth handling differently:
13
- - JAM_WRITE_CONFLICT the issue moved; re-plan
14
- - JAM_WRITE_PLAN_EXPIRED the plan aged out; re-plan
15
- - JAM_WRITE_VERIFICATION_FAILED Jira accepted it but the issue does not show it; read the issue and tell the user
16
- - JAM_WRITE_UNCERTAIN JAM does not know whether it landed; read the issue. Do NOT call this tool again - the write may already have been applied, and applying it twice is a second comment or a second transition.
17
-
4
+ const DESCRIPTION = `Apply a plan from jira_write_plan. This changes Jira.
5
+
6
+ Takes a planId and nothing else. The change was decided when the plan was made, so there is no field, payload or override to pass here - that is deliberate, and it is what stops a write happening without the state check that planning did.
7
+
8
+ Before writing, JAM re-reads the issue and compares it to what the plan saw. If it moved, you get JAM_WRITE_CONFLICT and no write happens: call jira_write_plan again against the new state rather than treating the conflict as a transient failure.
9
+
10
+ After writing, JAM reads the issue back and checks the intended result is actually there. Only then does it return "applied". Jira accepting a request is not the same as the issue having changed.
11
+
12
+ Failures worth handling differently:
13
+ - JAM_WRITE_CONFLICT the issue moved; re-plan
14
+ - JAM_WRITE_PLAN_EXPIRED the plan aged out; re-plan
15
+ - JAM_WRITE_VERIFICATION_FAILED Jira accepted it but the issue does not show it; read the issue and tell the user
16
+ - JAM_WRITE_UNCERTAIN JAM does not know whether it landed; read the issue. Do NOT call this tool again - the write may already have been applied, and applying it twice is a second comment or a second transition.
17
+
18
18
  Never report an uncertain or unverified write as done.`;
19
19
  export function registerJiraWriteApply(server, deps) {
20
20
  server.registerTool("jira_write_apply", {
@@ -2,24 +2,30 @@ import { z } from "zod";
2
2
  import { planWrite } from "../../application/plan-write.js";
3
3
  import { CREATABLE_FIELDS, WRITABLE_FIELDS, WRITE_OPERATIONS } from "../../domain/write.js";
4
4
  import { runTool } from "../tool-result.js";
5
- const DESCRIPTION = `Work out how to change one Jira issue, and get back a plan. Changes nothing.
6
-
7
- This is the first half of every write. Call it, read what it says the issue looks like now and what it would become, then pass the returned planId to jira_write_apply. There is no way to write to Jira without a plan, and a plan cannot be assembled by hand - only jira_write_plan issues one.
8
-
9
- Operations on an issue that already exists - these need \`key\`:
10
- - comment.add input: { "text": "..." } plain text; JAM converts it, do not send ADF
11
- - field.update input: { "summary"?, "priority"?, "labels"?, "components"? }
12
- - status.transition input: { "status": "Done" } JAM asks Jira which transitions exist and matches yours
13
-
14
- Creating an issue - no \`key\`, because there is no issue yet:
15
- - issue.create input: { "issueType": "Task", "summary": "...", "description"?, "priority"?, "labels"?, "components"? }
16
-
17
- issue.create goes into the project this workspace is bound to; the project is not a parameter. Planning reads Jira's create schema for that project first, so an issue type Jira does not offer, a priority or component outside its allowed values, and a project whose create screen requires a field JAM cannot set are all refused here rather than attempted. \`description\` is plain text, like a comment. Not settable in this version: assignee, reporter, parent, custom fields, attachments.
18
-
19
- Writes are limited to the Jira project this workspace is bound to; a key from another project is refused rather than attempted.
20
-
21
- The plan records what the issue looked like when it was made, and expires. If the issue changes in the meantime, jira_write_apply refuses with JAM_WRITE_CONFLICT - re-plan against the new state rather than forcing the old one through.
22
-
5
+ const DESCRIPTION = `Work out how to change one Jira issue, and get back a plan. Changes nothing.
6
+
7
+ This is the first half of every write. Call it, read what it says the issue looks like now and what it would become, then pass the returned planId to jira_write_apply. There is no way to write to Jira without a plan, and a plan cannot be assembled by hand - only jira_write_plan issues one.
8
+
9
+ Operations on an issue that already exists - these need \`key\`:
10
+ - comment.add input: { "text": "..." } plain text; JAM converts it, do not send ADF
11
+ - field.update input: { "summary"?, "priority"?, "labels"?, "components"? }
12
+ - status.transition input: { "status": "Done" } JAM asks Jira which transitions exist and matches yours
13
+ - assignee.update input: { "assignee": "..." } a display name or an accountId; JAM resolves it against Jira's own directory
14
+ - custom-field.update input: { "field": "...", "value": ... } one custom field the project opted in
15
+
16
+ Creating an issue - no \`key\`, because there is no issue yet:
17
+ - issue.create input: { "issueType": "Task", "summary": "...", "description"?, "priority"?, "labels"?, "components"? }
18
+
19
+ issue.create goes into the project this workspace is bound to; the project is not a parameter. Planning reads Jira's create schema for that project first, so an issue type Jira does not offer, a priority or component outside its allowed values, and a project whose create screen requires a field JAM cannot set are all refused here rather than attempted. \`description\` is plain text, like a comment. Not settable in this version: assignee, reporter, parent, custom fields, attachments.
20
+
21
+ assignee.update never sends the name you pass. JAM searches Jira's user directory, and assigns only when exactly one user matches your string exactly - an exact display name (case-insensitive) or an accountId. A partial match is Jira reporting a similarity, not identifying a person, so several matches or none come back as a refusal with the candidates attached: name one exactly, or pass their accountId. JAM also checks Jira offers that person as an assignee for this issue, before planning and again before writing, and confirms the result by accountId rather than by name. Unassigning, and setting an assignee while creating, are not in this version.
22
+
23
+ custom-field.update changes one custom field, and only one a team has opted in: the field's exact id must carry "writable: true" in the project's .jira-agent/project.yaml. Being readable does not make a field writable. "field" is that id or its configured name, matched exactly - no partial matches. JAM then asks Jira's edit metadata whether the field is settable on this issue for this account, and what type it is. Supported types are single-line text (string), number, single-select (string naming an option) and multi-select (array of strings). Anything else - dates, rich text, user or group pickers, app-owned fields - is refused rather than attempted. Types are not converted: "5" is not 5. Options are matched exactly against what Jira offers and written by option id. Clearing a field is not supported, so an empty string or an empty array is refused.
24
+
25
+ Writes are limited to the Jira project this workspace is bound to; a key from another project is refused rather than attempted.
26
+
27
+ The plan records what the issue looked like when it was made, and expires. If the issue changes in the meantime, jira_write_apply refuses with JAM_WRITE_CONFLICT - re-plan against the new state rather than forcing the old one through.
28
+
23
29
  A plan is a statement about what is possible right now, not a promise that it will happen. Nothing is written until jira_write_apply runs.`;
24
30
  export function registerJiraWritePlan(server, deps) {
25
31
  server.registerTool("jira_write_plan", {
@@ -34,7 +40,7 @@ export function registerJiraWritePlan(server, deps) {
34
40
  .string()
35
41
  .min(1)
36
42
  .optional()
37
- .describe('Issue key, e.g. "PROJECT-123". Required for comment.add, field.update and status.transition; omit for issue.create, which has no issue yet. Must be in the configured project.'),
43
+ .describe('Issue key, e.g. "PROJECT-123". Required for every operation that changes an existing issue; omit for issue.create, which has no issue yet. Must be in the configured project.'),
38
44
  operation: z
39
45
  .enum(WRITE_OPERATIONS)
40
46
  .describe(`What to do: ${WRITE_OPERATIONS.join(", ")}.`),
@@ -52,6 +58,20 @@ export function registerJiraWritePlan(server, deps) {
52
58
  .min(1)
53
59
  .optional()
54
60
  .describe("status.transition: the status to move to, e.g. \"Done\"."),
61
+ assignee: z
62
+ .string()
63
+ .min(1)
64
+ .optional()
65
+ .describe("assignee.update: who to assign, as an exact display name or an accountId. Not settable through field.update."),
66
+ field: z
67
+ .string()
68
+ .min(1)
69
+ .optional()
70
+ .describe("custom-field.update: the custom field, as its Jira id (customfield_10016) or its configured name. Must be writable in this project's config."),
71
+ value: z
72
+ .union([z.string(), z.number(), z.array(z.string())])
73
+ .optional()
74
+ .describe("custom-field.update: the value. A string for text, a number for numeric, a string naming an option for single-select, an array of strings for multi-select."),
55
75
  issueType: z
56
76
  .string()
57
77
  .min(1)
@@ -69,7 +89,7 @@ export function registerJiraWritePlan(server, deps) {
69
89
  .optional()
70
90
  .describe("Component names. Replaces the whole component set."),
71
91
  })
72
- .describe(`Operation input. field.update accepts only ${WRITABLE_FIELDS.join(", ")}; issue.create accepts only ${CREATABLE_FIELDS.join(", ")}. Custom fields and assignee are not writable by either.`),
92
+ .describe(`Operation input. field.update accepts only ${WRITABLE_FIELDS.join(", ")}; issue.create accepts only ${CREATABLE_FIELDS.join(", ")}. Custom fields are not writable by either, and the assignee is changed through assignee.update rather than through field.update.`),
73
93
  },
74
94
  // Planning reads Jira and decides; it never mutates. Hosts are free to
75
95
  // run it without asking, which is what keeps the two-step shape cheap.
@@ -0,0 +1,60 @@
1
+ import type { AssigneeCandidate, AssigneeRef } from "../domain/write.js";
2
+ /**
3
+ * Turning a name into a person, without ever guessing which person.
4
+ *
5
+ * Jira's user search is a substring search: "min" finds Min Kim and Minho
6
+ * Park, and it returns them in whatever order it likes. An agent handing JAM a
7
+ * name is describing an intent, not identifying an account - so the search is
8
+ * how candidates are found, and never how one of them is chosen.
9
+ *
10
+ * The rule is that JAM assigns only when the answer is unambiguous on its own
11
+ * terms: one exact identity. Everything else comes back as a refusal carrying
12
+ * the candidates, so the next move is to name one of them precisely rather
13
+ * than to hope the same query resolves differently.
14
+ *
15
+ * This costs an agent a round trip on ambiguity. The alternative costs someone
16
+ * an issue assigned to the wrong colleague, discovered later.
17
+ */
18
+ /**
19
+ * Which candidate the caller meant, if exactly one is certain.
20
+ *
21
+ * In order:
22
+ *
23
+ * 1. An exact accountId. The caller already had an identity; nothing to guess.
24
+ * 2. Exactly one candidate whose display name matches exactly, ignoring case
25
+ * and surrounding space. "task" and "Task" are the same intent, and an
26
+ * agent cannot learn a directory's casing before asking.
27
+ *
28
+ * Nothing else resolves. A single substring hit is still a substring hit: it
29
+ * is Jira saying "this contains what you typed", not "this is who you meant".
30
+ */
31
+ export declare function resolveAssignee(requested: string, candidates: AssigneeCandidate[]): AssigneeRef;
32
+ /**
33
+ * Refuse an assignment Jira would not permit, before asking it to.
34
+ *
35
+ * Assignability is a permission question with a per-project answer, and JAM
36
+ * does not model Jira's permission scheme - it asks. Called at plan time so a
37
+ * refusal is a JAM decision rather than a 400, and again immediately before
38
+ * the write, because a permission that held when the plan was made is not the
39
+ * same as one that still holds.
40
+ */
41
+ export declare function assertAssignable(issueKey: string, target: AssigneeRef, assignable: boolean): void;
42
+ /**
43
+ * Refuse an assignment that would change nothing.
44
+ *
45
+ * Not an error in Jira's eyes, and not harmful - but a write JAM reports as
46
+ * applied should be a write that happened. Saying so plainly is more useful
47
+ * than a receipt claiming to have changed something that already was.
48
+ */
49
+ export declare function assertNotAlreadyAssigned(issueKey: string, current: string | undefined, target: AssigneeRef): void;
50
+ /**
51
+ * The candidates this string identifies exactly, if any.
52
+ *
53
+ * Exported so the caller can tell "the search settled it" from "the search did
54
+ * not" without reimplementing the rule - a second copy of what counts as exact
55
+ * is a second answer waiting to disagree with this one.
56
+ *
57
+ * An accountId match wins outright: it is an identity, and a display name that
58
+ * happens to equal somebody's account id is not a reason to consider them.
59
+ */
60
+ export declare function exactMatches(requested: string, candidates: AssigneeCandidate[]): AssigneeCandidate[];
@@ -0,0 +1,103 @@
1
+ import { JamError } from "../domain/errors.js";
2
+ /**
3
+ * Turning a name into a person, without ever guessing which person.
4
+ *
5
+ * Jira's user search is a substring search: "min" finds Min Kim and Minho
6
+ * Park, and it returns them in whatever order it likes. An agent handing JAM a
7
+ * name is describing an intent, not identifying an account - so the search is
8
+ * how candidates are found, and never how one of them is chosen.
9
+ *
10
+ * The rule is that JAM assigns only when the answer is unambiguous on its own
11
+ * terms: one exact identity. Everything else comes back as a refusal carrying
12
+ * the candidates, so the next move is to name one of them precisely rather
13
+ * than to hope the same query resolves differently.
14
+ *
15
+ * This costs an agent a round trip on ambiguity. The alternative costs someone
16
+ * an issue assigned to the wrong colleague, discovered later.
17
+ */
18
+ /**
19
+ * Which candidate the caller meant, if exactly one is certain.
20
+ *
21
+ * In order:
22
+ *
23
+ * 1. An exact accountId. The caller already had an identity; nothing to guess.
24
+ * 2. Exactly one candidate whose display name matches exactly, ignoring case
25
+ * and surrounding space. "task" and "Task" are the same intent, and an
26
+ * agent cannot learn a directory's casing before asking.
27
+ *
28
+ * Nothing else resolves. A single substring hit is still a substring hit: it
29
+ * is Jira saying "this contains what you typed", not "this is who you meant".
30
+ */
31
+ export function resolveAssignee(requested, candidates) {
32
+ const wanted = requested.trim();
33
+ if (wanted.length === 0) {
34
+ throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "assignee.update needs a non-empty `input.assignee`.", { operation: "assignee.update" });
35
+ }
36
+ const exact = exactMatches(wanted, candidates);
37
+ if (exact.length === 0) {
38
+ throw new JamError("JAM_WRITE_ASSIGNEE_NOT_FOUND", candidates.length === 0
39
+ ? `Jira has no user matching "${requested}".`
40
+ : `No Jira user is exactly "${requested}". JAM assigns only on an exact display name or an accountId, because a partial match is Jira reporting a similarity rather than identifying a person. Name one of the candidates exactly, or pass their accountId.`, { requested, candidates: describe(candidates) });
41
+ }
42
+ if (exact.length > 1) {
43
+ // Two people really can share a display name. Picking either would be a
44
+ // coin toss whose result is somebody's issue.
45
+ throw new JamError("JAM_WRITE_ASSIGNEE_AMBIGUOUS", `"${requested}" matches ${exact.length} Jira users exactly. Pass the accountId of the one you mean.`, { requested, candidates: describe(exact) });
46
+ }
47
+ const match = exact[0];
48
+ if (!match.active) {
49
+ throw new JamError("JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", `${match.displayName} is a deactivated Jira account, so this issue cannot be assigned to them.`, { requested, accountId: match.accountId, reason: "INACTIVE" });
50
+ }
51
+ return { accountId: match.accountId, displayName: match.displayName };
52
+ }
53
+ /**
54
+ * Refuse an assignment Jira would not permit, before asking it to.
55
+ *
56
+ * Assignability is a permission question with a per-project answer, and JAM
57
+ * does not model Jira's permission scheme - it asks. Called at plan time so a
58
+ * refusal is a JAM decision rather than a 400, and again immediately before
59
+ * the write, because a permission that held when the plan was made is not the
60
+ * same as one that still holds.
61
+ */
62
+ export function assertAssignable(issueKey, target, assignable) {
63
+ if (assignable)
64
+ return;
65
+ throw new JamError("JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", `Jira does not offer ${target.displayName} as an assignee for ${issueKey}. They may lack the assignable-user permission in this project, or have lost it since this plan was made.`, { issueKey, accountId: target.accountId, displayName: target.displayName });
66
+ }
67
+ /**
68
+ * Refuse an assignment that would change nothing.
69
+ *
70
+ * Not an error in Jira's eyes, and not harmful - but a write JAM reports as
71
+ * applied should be a write that happened. Saying so plainly is more useful
72
+ * than a receipt claiming to have changed something that already was.
73
+ */
74
+ export function assertNotAlreadyAssigned(issueKey, current, target) {
75
+ if (current !== target.accountId)
76
+ return;
77
+ throw new JamError("JAM_WRITE_ASSIGNEE_ALREADY_SET", `${issueKey} is already assigned to ${target.displayName}. Nothing to change.`, { issueKey, accountId: target.accountId, displayName: target.displayName });
78
+ }
79
+ /**
80
+ * The candidates this string identifies exactly, if any.
81
+ *
82
+ * Exported so the caller can tell "the search settled it" from "the search did
83
+ * not" without reimplementing the rule - a second copy of what counts as exact
84
+ * is a second answer waiting to disagree with this one.
85
+ *
86
+ * An accountId match wins outright: it is an identity, and a display name that
87
+ * happens to equal somebody's account id is not a reason to consider them.
88
+ */
89
+ export function exactMatches(requested, candidates) {
90
+ const wanted = requested.trim();
91
+ const byAccountId = candidates.filter((c) => c.accountId === wanted);
92
+ if (byAccountId.length > 0)
93
+ return byAccountId;
94
+ return candidates.filter((c) => c.displayName.trim().toLowerCase() === wanted.toLowerCase());
95
+ }
96
+ /** Candidates as an agent can act on them: a name to repeat, and an id to be sure. */
97
+ function describe(candidates) {
98
+ return candidates.map((c) => ({
99
+ accountId: c.accountId,
100
+ displayName: c.displayName,
101
+ active: c.active,
102
+ }));
103
+ }
@@ -0,0 +1,93 @@
1
+ import type { ProjectConfig } from "../config/schema.js";
2
+ import type { CustomFieldKind, CustomFieldRequirements, CustomFieldUpdateInput, CustomFieldValueView, EditFieldMetadata, EditFieldOption } from "../domain/write.js";
3
+ /**
4
+ * What JAM will write to a custom field, and everything that has to be true
5
+ * first.
6
+ *
7
+ * Three separate permissions have to line up, and none of them implies
8
+ * another:
9
+ *
10
+ * 1. **The team said so.** The field's exact id is in the project's whitelist
11
+ * with `writable: true`. Being readable is not being writable - reading a
12
+ * field and letting an agent change it are different decisions, and a
13
+ * config written before JAM could write must not start granting writes
14
+ * because JAM learned how.
15
+ * 2. **Jira allows it here and now.** The field is on this issue's edit
16
+ * screen for this account, and Jira lists `set` among its operations.
17
+ * Asked, never modelled: applicability depends on project, issue type,
18
+ * field contexts, screens and permissions, and JAM does not carry a copy
19
+ * of any of that.
20
+ * 3. **JAM knows the shape.** The field's type is one of four families whose
21
+ * wire form JAM can produce from a plain value and compare afterwards.
22
+ * Anything else is refused rather than posted to find out.
23
+ */
24
+ type WritableField = {
25
+ id: string;
26
+ name: string;
27
+ };
28
+ /**
29
+ * Which configured field this selector names.
30
+ *
31
+ * The id is the identity; the name is an alias for people. Resolution is exact
32
+ * on either - no substring, no fuzz - because the alternative is an agent's
33
+ * approximate word choosing which field on somebody's board gets rewritten.
34
+ *
35
+ * Only `writable: true` entries are candidates, including for the refusal
36
+ * message: naming a read-only field as an alternative would suggest it is one
37
+ * selector away from being written.
38
+ */
39
+ export declare function resolveWritableField(config: ProjectConfig, requested: string): WritableField;
40
+ /**
41
+ * The field as Jira currently offers it on this issue, or a refusal.
42
+ *
43
+ * Absent from the edit metadata and present-but-not-settable are different
44
+ * situations with the same answer for the caller, so they share a code and
45
+ * differ in the detail: one means the field is not on this screen, the other
46
+ * that Jira will not let this account set it.
47
+ */
48
+ export declare function assertEditable(issueKey: string, field: WritableField, metadata: EditFieldMetadata[]): EditFieldMetadata;
49
+ /**
50
+ * Which of the four families this field belongs to, if any.
51
+ *
52
+ * Classified from Jira's own `schema`, which is the vocabulary Jira answers
53
+ * in. The implementation key (`schema.custom`) deliberately does not decide
54
+ * it: there are hundreds of them, they are app-specific, and a field's wire
55
+ * shape follows its type rather than its plugin.
56
+ *
57
+ * Anything unclassified is refused. Posting an unknown type to see what
58
+ * happens would use a Jira 400 as schema discovery, and on the occasions it
59
+ * did not 400 it would write something nobody described.
60
+ */
61
+ export declare function classifyKind(field: EditFieldMetadata): CustomFieldKind;
62
+ /**
63
+ * The value, checked against the family and turned into what Jira expects.
64
+ *
65
+ * Types are never coerced. `"5"` is not `5`: a caller that meant a number can
66
+ * say so, and silently converting would make JAM's idea of the value differ
67
+ * from the caller's in exactly the cases where it matters.
68
+ *
69
+ * Nothing here clears a field. Empty strings, empty arrays and null are
70
+ * refused rather than treated as "unset" - removing a value is a different
71
+ * intent from setting one, and it is not in this version.
72
+ */
73
+ export declare function resolveCustomFieldValue(field: EditFieldMetadata, kind: CustomFieldKind, input: CustomFieldUpdateInput): {
74
+ jiraValue: unknown;
75
+ view: CustomFieldValueView;
76
+ resolvedOptions?: EditFieldOption[];
77
+ };
78
+ /**
79
+ * Do this plan's premises still hold?
80
+ *
81
+ * Semantic, like the create schema check and for the same reason: comparing
82
+ * whole metadata documents would invalidate every outstanding plan whenever an
83
+ * unrelated field appeared on the screen. What is compared is what the plan
84
+ * actually rested on - the field is still settable, still the same family,
85
+ * still the same schema, and every option it chose is still offered under the
86
+ * same label.
87
+ *
88
+ * A renamed option is treated as a changed one. The id is the identity, but a
89
+ * label is what the plan showed a human before they agreed to it, and "Backend"
90
+ * becoming "Platform" is a different statement about the issue.
91
+ */
92
+ export declare function assertCustomFieldUnchanged(issueKey: string, requirements: CustomFieldRequirements, metadata: EditFieldMetadata[]): void;
93
+ export {};