@jam-mcp/server 1.1.0 → 1.2.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.
Files changed (42) hide show
  1. package/README.md +2 -2
  2. package/dist/adapters/credentials/windows-user-env.d.ts +3 -1
  3. package/dist/adapters/credentials/windows-user-env.js +20 -1
  4. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.d.ts +27 -0
  5. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.js +81 -0
  6. package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +10 -1
  7. package/dist/adapters/jira-cloud/jira-read.adapter.js +17 -0
  8. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +13 -7
  9. package/dist/adapters/jira-cloud/jira-write.adapter.js +25 -20
  10. package/dist/application/apply-create-issue.d.ts +20 -0
  11. package/dist/application/apply-create-issue.js +187 -0
  12. package/dist/application/apply-write.js +18 -3
  13. package/dist/application/plan-create-issue.d.ts +44 -0
  14. package/dist/application/plan-create-issue.js +188 -0
  15. package/dist/application/plan-write.d.ts +10 -3
  16. package/dist/application/plan-write.js +54 -11
  17. package/dist/application/write-plan-store.d.ts +2 -2
  18. package/dist/application/write-plan-store.js +13 -1
  19. package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
  20. package/dist/cli/auth.d.ts +6 -0
  21. package/dist/cli/auth.js +2 -1
  22. package/dist/deps.d.ts +11 -0
  23. package/dist/deps.js +6 -0
  24. package/dist/domain/adf.d.ts +35 -0
  25. package/dist/domain/adf.js +65 -0
  26. package/dist/domain/errors.d.ts +1 -1
  27. package/dist/domain/errors.js +9 -0
  28. package/dist/domain/write.d.ts +128 -15
  29. package/dist/domain/write.js +33 -1
  30. package/dist/mcp/create-server.d.ts +4 -0
  31. package/dist/mcp/create-server.js +5 -0
  32. package/dist/mcp/tools/jira-write-plan.tool.js +34 -6
  33. package/dist/policy/consistency-policy.d.ts +10 -4
  34. package/dist/policy/create-policy.d.ts +86 -0
  35. package/dist/policy/create-policy.js +182 -0
  36. package/dist/policy/write-policy.d.ts +10 -1
  37. package/dist/policy/write-policy.js +15 -1
  38. package/dist/ports/jira-create-metadata.port.d.ts +25 -0
  39. package/dist/ports/jira-create-metadata.port.js +1 -0
  40. package/dist/ports/jira-read.port.d.ts +23 -0
  41. package/dist/ports/jira-write.port.d.ts +9 -0
  42. package/package.json +2 -2
@@ -0,0 +1,188 @@
1
+ import { JamError } from "../domain/errors.js";
2
+ import { CREATABLE_FIELDS } from "../domain/write.js";
3
+ import { assertRequiredFieldsSupported, CREATE_FIELD_IDS, resolveAllowedValue, resolveIssueType, } from "../policy/create-policy.js";
4
+ import { PLAN_TTL_MS } from "../policy/write-policy.js";
5
+ import { canonicalizePlainText, textToAdf } from "../domain/adf.js";
6
+ /**
7
+ * Work out whether an issue can be created here, and describe the one JAM
8
+ * would create.
9
+ *
10
+ * Reads only - the create metadata endpoints answer questions about a
11
+ * project's configuration and change nothing. The order is the point:
12
+ *
13
+ * 1. Validate the request against JAM's own contract. Anything refusable
14
+ * without asking Jira is refused before a round trip is spent on it.
15
+ * 2. Ask Jira which issue types this account can create here, and resolve the
16
+ * requested one against that list. An id is never derived from a name.
17
+ * 3. Ask Jira what that issue type's create screen requires, and refuse now if
18
+ * it requires something JAM cannot express. A create JAM knows Jira will
19
+ * reject is not sent.
20
+ * 4. Resolve every constrained value against Jira's own allowed list.
21
+ *
22
+ * What comes out is a plan that records not just the intended issue but the
23
+ * premises it rests on, so apply can check they still hold.
24
+ *
25
+ * The target project is never a parameter. It is the project this workspace is
26
+ * bound to, which is what the user consented to when they set JAM up; taking
27
+ * it from the caller would make the binding advisory.
28
+ */
29
+ export async function planCreateIssue(deps, request) {
30
+ const projectKey = configuredProject(deps);
31
+ const input = validateCreateInput(request.input);
32
+ const issueTypes = await deps.jiraCreateMetadata.getIssueTypes(projectKey);
33
+ const issueType = resolveIssueType(input.issueType, issueTypes);
34
+ const fields = await deps.jiraCreateMetadata.getCreateFields(projectKey, issueType.id);
35
+ const requiredFieldIds = assertRequiredFieldsSupported(fields, input);
36
+ const byId = new Map(fields.map((f) => [f.id, f]));
37
+ const resolvedValues = [];
38
+ // Only the constrained fields go through resolution. Summary and description
39
+ // are free text, and labels are a Jira-wide vocabulary rather than a
40
+ // per-project one, so there is no list to resolve them against.
41
+ let priority;
42
+ if (input.priority !== undefined) {
43
+ const resolved = resolveAllowedValue(byId.get(CREATE_FIELD_IDS.priority), input.priority, "priority");
44
+ priority = resolved.resolved;
45
+ resolvedValues.push({ fieldId: CREATE_FIELD_IDS.priority, ...resolved });
46
+ }
47
+ let components;
48
+ if (input.components !== undefined) {
49
+ const field = byId.get(CREATE_FIELD_IDS.components);
50
+ components = input.components.map((name) => {
51
+ const resolved = resolveAllowedValue(field, name, "component");
52
+ resolvedValues.push({ fieldId: CREATE_FIELD_IDS.components, ...resolved });
53
+ return resolved.resolved;
54
+ });
55
+ }
56
+ const intendedAfter = {
57
+ issueType: issueType.name,
58
+ summary: input.summary,
59
+ // Canonical, not the raw string. `intendedAfter` is also what
60
+ // `verification.expects` promises a direct read will show, and a direct
61
+ // read shows the text as Jira renders it back - so promising the caller's
62
+ // exact bytes would be promising something no read can ever produce.
63
+ ...(input.description !== undefined
64
+ ? { description: canonicalizePlainText(input.description) }
65
+ : {}),
66
+ ...(priority !== undefined ? { priority } : {}),
67
+ ...(input.labels !== undefined ? { labels: input.labels } : {}),
68
+ ...(components !== undefined ? { components } : {}),
69
+ };
70
+ const createdAt = new Date();
71
+ const plan = deps.writePlans.create({
72
+ kind: "create-issue",
73
+ projectKey,
74
+ operation: "issue.create",
75
+ before: { issue: null },
76
+ intendedAfter,
77
+ schemaRequirements: {
78
+ issueTypeId: issueType.id,
79
+ issueTypeName: issueType.name,
80
+ requiredFieldIds,
81
+ resolvedValues,
82
+ },
83
+ createdAt: createdAt.toISOString(),
84
+ expiresAt: new Date(createdAt.getTime() + PLAN_TTL_MS).toISOString(),
85
+ mutation: {
86
+ kind: "create",
87
+ fields: toJiraCreateFields(projectKey, issueType.id, input, { priority, components }),
88
+ },
89
+ });
90
+ return {
91
+ plan,
92
+ receipt: {
93
+ status: "planned",
94
+ planId: plan.planId,
95
+ operation: plan.operation,
96
+ project: plan.projectKey,
97
+ before: plan.before,
98
+ intendedAfter: plan.intendedAfter,
99
+ expiresAt: plan.expiresAt,
100
+ verification: { method: "direct-issue-read", expects: plan.intendedAfter },
101
+ },
102
+ };
103
+ }
104
+ /** The project this workspace is bound to, or a refusal that says so. */
105
+ export function configuredProject(deps) {
106
+ const configured = deps.config.project.key.trim().toUpperCase();
107
+ if (!configured) {
108
+ throw new JamError("JAM_SETUP_REQUIRED", "No Jira project is configured for this workspace, so JAM does not know where an issue would be created.");
109
+ }
110
+ return configured;
111
+ }
112
+ /**
113
+ * Check the request against the create contract, and normalize it.
114
+ *
115
+ * Pure: no Jira, no state. `key` is not accepted here at all - there is no
116
+ * issue to name - and neither is `project`, which comes from the binding.
117
+ * Anything outside CREATABLE_FIELDS is rejected rather than ignored: silently
118
+ * dropping a field an agent asked for would create an issue that is not the
119
+ * one it described.
120
+ */
121
+ export function validateCreateInput(raw) {
122
+ const unknown = Object.keys(raw).filter((key) => raw[key] !== undefined && !CREATABLE_FIELDS.includes(key));
123
+ if (unknown.length > 0) {
124
+ throw new JamError("JAM_WRITE_FIELD_NOT_ALLOWED", `issue.create cannot set ${unknown.join(", ")}. Supported: ${CREATABLE_FIELDS.join(", ")}.`, { rejected: unknown, supported: [...CREATABLE_FIELDS] });
125
+ }
126
+ const issueType = requiredText(raw["issueType"], "issueType");
127
+ const summary = requiredText(raw["summary"], "summary");
128
+ const input = { issueType, summary };
129
+ if (raw["description"] !== undefined) {
130
+ // Plain text, never ADF. A caller-supplied document tree would mean
131
+ // panels, mentions and embeds arriving through a field that reads like
132
+ // prose - the same argument that keeps comment.add on plain text.
133
+ if (typeof raw["description"] !== "string") {
134
+ throw notAllowed("issue.create needs `input.description` to be plain text.");
135
+ }
136
+ // An all-whitespace description is not a description. Accepting one would
137
+ // mean promising to verify text that renders to nothing, which no read can
138
+ // confirm - so it is refused here rather than becoming an unverifiable
139
+ // create later.
140
+ if (canonicalizePlainText(raw["description"]).length === 0) {
141
+ throw notAllowed("issue.create needs `input.description` to be non-empty when it is set.");
142
+ }
143
+ input.description = raw["description"];
144
+ }
145
+ if (raw["priority"] !== undefined) {
146
+ input.priority = requiredText(raw["priority"], "priority");
147
+ }
148
+ if (raw["labels"] !== undefined) {
149
+ input.labels = stringArray(raw["labels"], "labels");
150
+ }
151
+ if (raw["components"] !== undefined) {
152
+ input.components = stringArray(raw["components"], "components");
153
+ }
154
+ return input;
155
+ }
156
+ function requiredText(value, field) {
157
+ if (typeof value !== "string" || value.trim().length === 0) {
158
+ throw notAllowed(`issue.create needs non-empty \`input.${field}\`.`);
159
+ }
160
+ return value.trim();
161
+ }
162
+ function stringArray(value, field) {
163
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
164
+ throw notAllowed(`issue.create needs \`input.${field}\` to be an array of strings.`);
165
+ }
166
+ return value;
167
+ }
168
+ function notAllowed(message) {
169
+ return new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", message, { operation: "issue.create" });
170
+ }
171
+ /** Whitelisted values to the shapes Jira's create API expects. */
172
+ function toJiraCreateFields(projectKey, issueTypeId, input, resolved) {
173
+ const fields = {
174
+ project: { key: projectKey },
175
+ issuetype: { id: issueTypeId },
176
+ summary: input.summary,
177
+ };
178
+ if (input.description !== undefined)
179
+ fields["description"] = textToAdf(input.description);
180
+ if (resolved.priority !== undefined)
181
+ fields["priority"] = { name: resolved.priority };
182
+ if (input.labels !== undefined)
183
+ fields["labels"] = input.labels;
184
+ if (resolved.components !== undefined) {
185
+ fields["components"] = resolved.components.map((name) => ({ name }));
186
+ }
187
+ return fields;
188
+ }
@@ -2,7 +2,8 @@ import type { JamDeps } from "../deps.js";
2
2
  import type { FullIssueContext } from "../domain/context.js";
3
3
  import type { WritePlan, WritePlanReceipt } from "../domain/write.js";
4
4
  export type PlanWriteRequest = {
5
- key: string;
5
+ /** Absent for `issue.create`, which names a project rather than an issue. */
6
+ key?: string;
6
7
  operation: string;
7
8
  input: Record<string, unknown>;
8
9
  };
@@ -26,7 +27,13 @@ export declare function planWrite(deps: JamDeps, request: PlanWriteRequest): Pro
26
27
  /**
27
28
  * The issue as Jira has it, read directly by key.
28
29
  *
29
- * A direct read, never a search: ConsistencyPolicy requires it for anything
30
- * that decides a write, and a JQL result can lag behind the issue it describes.
30
+ * `getIssue`, not `getIssues`: ConsistencyPolicy requires a direct issue GET
31
+ * for anything that decides or confirms a write, and the bulk endpoint is not
32
+ * one. A JQL result can lag behind the issue it describes; a bulk fetch is
33
+ * free to answer from a different path than the single-issue endpoint. Neither
34
+ * difference matters for ordinary reads, and both matter here.
35
+ *
36
+ * The one read every write goes through - the pre-write conflict check, the
37
+ * post-write confirmation, and the post-create confirmation.
31
38
  */
32
39
  export declare function readIssue(deps: JamDeps, issueKey: string): Promise<FullIssueContext>;
@@ -1,5 +1,6 @@
1
1
  import { JamError } from "../domain/errors.js";
2
- import { assertFieldsAllowed, assertOperationAllowed, assertWriteScope, PLAN_TTL_MS, resolveTransition, } from "../policy/write-policy.js";
2
+ import { assertExistingIssueOperation, assertFieldsAllowed, assertOperationAllowed, assertWriteScope, PLAN_TTL_MS, resolveTransition, } from "../policy/write-policy.js";
3
+ import { planCreateIssue } from "./plan-create-issue.js";
3
4
  /**
4
5
  * Work out whether a requested change is currently possible, and describe it.
5
6
  *
@@ -14,9 +15,16 @@ import { assertFieldsAllowed, assertOperationAllowed, assertWriteScope, PLAN_TTL
14
15
  * it right now, never from what the caller asserted about it.
15
16
  */
16
17
  export async function planWrite(deps, request) {
17
- const issueKey = request.key.trim().toUpperCase();
18
+ // Creation branches before anything else touches `key`, because it has none.
19
+ // Routing on the operation rather than on whether a key happened to be
20
+ // supplied keeps the two request shapes genuinely separate instead of one
21
+ // shape with holes in it.
22
+ if (assertOperationAllowed(request.operation) === "issue.create") {
23
+ return planCreateIssue(deps, { input: request.input });
24
+ }
25
+ const issueKey = requireIssueKey(request);
18
26
  const projectKey = assertWriteScope(issueKey, deps.config.project.key);
19
- const operation = assertOperationAllowed(request.operation);
27
+ const operation = assertExistingIssueOperation(request.operation);
20
28
  // Everything that can be refused from the request alone is refused here,
21
29
  // before a Jira call is spent on it. An agent asking to write a field JAM
22
30
  // does not write should get that answer, not a round trip and then that
@@ -26,6 +34,7 @@ export async function planWrite(deps, request) {
26
34
  const { before, intendedAfter, mutation, transition } = await describe(deps, operation, issueKey, issue, input);
27
35
  const createdAt = new Date();
28
36
  const plan = deps.writePlans.create({
37
+ kind: "existing-issue",
29
38
  issueKey,
30
39
  projectKey,
31
40
  operation,
@@ -54,19 +63,53 @@ export async function planWrite(deps, request) {
54
63
  /**
55
64
  * The issue as Jira has it, read directly by key.
56
65
  *
57
- * A direct read, never a search: ConsistencyPolicy requires it for anything
58
- * that decides a write, and a JQL result can lag behind the issue it describes.
66
+ * `getIssue`, not `getIssues`: ConsistencyPolicy requires a direct issue GET
67
+ * for anything that decides or confirms a write, and the bulk endpoint is not
68
+ * one. A JQL result can lag behind the issue it describes; a bulk fetch is
69
+ * free to answer from a different path than the single-issue endpoint. Neither
70
+ * difference matters for ordinary reads, and both matter here.
71
+ *
72
+ * The one read every write goes through - the pre-write conflict check, the
73
+ * post-write confirmation, and the post-create confirmation.
59
74
  */
60
75
  export async function readIssue(deps, issueKey) {
61
- const { issues } = await deps.jira.getIssues({
62
- keys: [issueKey],
63
- fields: ["summary", "status", "priority", "labels", "components", "updated"],
76
+ const { issue: found } = await deps.jira.getIssue({
77
+ key: issueKey,
78
+ // `issuetype` and `description` are here for creation's verification step,
79
+ // which has to confirm the issue Jira made is the one that was asked for.
80
+ // A field a plan promises to check has to be a field this read returns -
81
+ // otherwise the check silently passes on `undefined`. They cost nothing on
82
+ // the other operations, which do not compare them.
83
+ fields: [
84
+ "summary",
85
+ "status",
86
+ "issuetype",
87
+ "description",
88
+ "priority",
89
+ "labels",
90
+ "components",
91
+ "updated",
92
+ ],
64
93
  });
65
- const issue = issues[0];
66
- if (!issue) {
94
+ if (!found) {
67
95
  throw new JamError("ISSUE_NOT_FOUND", `Jira has no issue ${issueKey}, or it is not visible to this account.`, { issueKey });
68
96
  }
69
- return issue;
97
+ return found;
98
+ }
99
+ /**
100
+ * The issue an existing-issue operation names, or a refusal that says why.
101
+ *
102
+ * `key` is optional on the request only because `issue.create` has no issue.
103
+ * Reaching here means the operation does have one, so its absence is the
104
+ * caller using the wrong shape - which is worth saying, rather than reading as
105
+ * an empty key and failing further in.
106
+ */
107
+ function requireIssueKey(request) {
108
+ const key = request.key?.trim();
109
+ if (!key) {
110
+ throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", `${request.operation} changes an issue that already exists, so it needs \`key\`.`, { operation: request.operation });
111
+ }
112
+ return key.toUpperCase();
70
113
  }
71
114
  /**
72
115
  * Check the request against the contract, and normalize it.
@@ -1,4 +1,4 @@
1
- import type { WritePlan } from "../domain/write.js";
1
+ import type { NewWritePlan, WritePlan } from "../domain/write.js";
2
2
  /**
3
3
  * Where a plan lives between `jira_write_plan` and `jira_write_apply`.
4
4
  *
@@ -21,7 +21,7 @@ export declare class WritePlanStore {
21
21
  private readonly plans;
22
22
  /** Injected by tests so expiry does not depend on wall-clock timing. */
23
23
  constructor(now?: () => Date);
24
- create(plan: Omit<WritePlan, "planId">): WritePlan;
24
+ create(plan: NewWritePlan): WritePlan;
25
25
  /**
26
26
  * Resolve a plan for applying.
27
27
  *
@@ -45,7 +45,19 @@ export class WritePlanStore {
45
45
  }
46
46
  if (planExpired(plan.expiresAt, this.now())) {
47
47
  this.plans.delete(planId);
48
- throw new JamError("JAM_WRITE_PLAN_EXPIRED", `This write plan expired at ${plan.expiresAt}. Re-plan against the current state of ${plan.issueKey}.`, { planId, issueKey: plan.issueKey, expiresAt: plan.expiresAt });
48
+ // What to re-plan against differs by plan: an existing issue has a
49
+ // current state, a create has only the project's current create schema.
50
+ // Naming an issue key here for a create would name an issue that has
51
+ // never existed.
52
+ throw new JamError("JAM_WRITE_PLAN_EXPIRED", plan.kind === "create-issue"
53
+ ? `This write plan expired at ${plan.expiresAt}. Nothing was created - re-plan against the current create schema for project ${plan.projectKey}.`
54
+ : `This write plan expired at ${plan.expiresAt}. Re-plan against the current state of ${plan.issueKey}.`, {
55
+ planId,
56
+ expiresAt: plan.expiresAt,
57
+ ...(plan.kind === "create-issue"
58
+ ? { project: plan.projectKey }
59
+ : { issueKey: plan.issueKey }),
60
+ });
49
61
  }
50
62
  return plan;
51
63
  }
@@ -13,7 +13,7 @@ import { LAUNCHER_PACKAGE_SPEC } from "@jam-mcp/launcher";
13
13
  export { LAUNCHER_PACKAGE_SPEC };
14
14
  export declare const JAM_MCP_ENTRY: {
15
15
  readonly command: "npx";
16
- readonly args: readonly ["--yes", "@jam-mcp/launcher@1.1.0", "serve"];
16
+ readonly args: readonly ["--yes", "@jam-mcp/launcher@1.2.0", "serve"];
17
17
  };
18
18
  /**
19
19
  * Recognise wiring from before the launcher existed: a hard-coded path to one
@@ -24,6 +24,12 @@ export type AuthOptions = {
24
24
  * it) is about the ordering, not about the network.
25
25
  */
26
26
  verify?: (values: StoredCredentials) => Promise<string | undefined>;
27
+ /**
28
+ * Injected by tests. The suite runs with JAM_DISABLE_SECRET_STORE set so it
29
+ * cannot reach a real keychain, which would otherwise make every "no store
30
+ * on this system" case report the disabled one instead.
31
+ */
32
+ env?: NodeJS.ProcessEnv;
27
33
  };
28
34
  export declare function authLoginCommand(options?: AuthOptions): Promise<number>;
29
35
  export declare function authLogoutCommand(options?: AuthOptions): number;
package/dist/cli/auth.js CHANGED
@@ -48,11 +48,12 @@ export async function authLoginCommand(options = {}) {
48
48
  const ui = options.ui ?? new Ui();
49
49
  const store = "store" in options ? options.store : resolveSecretStore();
50
50
  const readBack = options.readBack ?? freshPort;
51
+ const env = options.env ?? process.env;
51
52
  ui.section("Authentication");
52
53
  if (!store) {
53
54
  // Disabled and absent are different problems with different fixes, and
54
55
  // saying "no store" when one was switched off sends the user hunting.
55
- if (secretStoreDisabled()) {
56
+ if (secretStoreDisabled(env)) {
56
57
  ui.failure("Secret store disabled by JAM_DISABLE_SECRET_STORE");
57
58
  ui.line(" That variable is for isolated test sandboxes. Unset it and run this again.");
58
59
  ui.next(ENV_HINT);
package/dist/deps.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { ProjectConfig } from "./config/schema.js";
4
4
  import type { CachePort } from "./ports/cache.port.js";
5
5
  import type { CredentialPort } from "./ports/credentials.port.js";
6
6
  import type { JiraReadPort } from "./ports/jira-read.port.js";
7
+ import type { JiraCreateMetadataPort } from "./ports/jira-create-metadata.port.js";
7
8
  import type { JiraWritePort } from "./ports/jira-write.port.js";
8
9
  import { WritePlanStore } from "./application/write-plan-store.js";
9
10
  import type { TelemetryPort } from "./ports/telemetry.port.js";
@@ -20,6 +21,14 @@ export type JamDeps = {
20
21
  * that did both would blur them.
21
22
  */
22
23
  jiraWrite: JiraWritePort;
24
+ /**
25
+ * What Jira will accept when creating an issue here. A third port rather
26
+ * than a method on either of the others: it mutates nothing, so it does not
27
+ * belong behind the write port's no-retry contract, and it answers a
28
+ * question about a project rather than about an issue, so the read port's
29
+ * completeness semantics would mean nothing for it.
30
+ */
31
+ jiraCreateMetadata: JiraCreateMetadataPort;
23
32
  /**
24
33
  * Plans awaiting apply. Lives for the life of this server process - see
25
34
  * WritePlanStore for why it is not persisted.
@@ -35,6 +44,8 @@ export type BuildDepsOptions = {
35
44
  jira?: JiraReadPort;
36
45
  /** Injected by tests so no test can reach a real Jira write endpoint. */
37
46
  jiraWrite?: JiraWritePort;
47
+ /** Injected by tests so create metadata comes from a fixture, not a site. */
48
+ jiraCreateMetadata?: JiraCreateMetadataPort;
38
49
  /** Injected by tests to bypass the real process/registry credential lookup. */
39
50
  credentials?: CredentialPort;
40
51
  /**
package/dist/deps.js CHANGED
@@ -33,12 +33,18 @@ export async function buildDeps(options = {}) {
33
33
  const { JiraCloudWriteAdapter } = await import("./adapters/jira-cloud/jira-write.adapter.js");
34
34
  jiraWrite = new JiraCloudWriteAdapter(credentials);
35
35
  }
36
+ let jiraCreateMetadata = options.jiraCreateMetadata;
37
+ if (!jiraCreateMetadata) {
38
+ const { JiraCloudCreateMetadataAdapter } = await import("./adapters/jira-cloud/jira-create-metadata.adapter.js");
39
+ jiraCreateMetadata = new JiraCloudCreateMetadataAdapter(credentials);
40
+ }
36
41
  return {
37
42
  config: resolved.config,
38
43
  configPath: resolved.configPath,
39
44
  keySource: resolved.keySource,
40
45
  jira,
41
46
  jiraWrite,
47
+ jiraCreateMetadata,
42
48
  writePlans: new WritePlanStore(),
43
49
  cache: new NoopCache(),
44
50
  telemetry,
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Plain text to the narrowest Atlassian Document Format that represents it.
3
+ *
4
+ * Jira's rich-text fields - a comment body, an issue description - are document
5
+ * trees. Accepting one from a caller would mean accepting arbitrary structure
6
+ * through a field that reads like "text": panels, mentions, embedded content,
7
+ * links to anywhere. So JAM's contract is plain text in both places, and this
8
+ * is the single conversion that produces the document.
9
+ *
10
+ * Shared rather than duplicated per field on purpose. Two copies of this would
11
+ * be two answers to "what can an agent put in a Jira document", and the second
12
+ * one would drift.
13
+ *
14
+ * Blank lines separate paragraphs; everything else is literal. No markdown is
15
+ * interpreted, so text containing `*` or `#` says what it says.
16
+ */
17
+ export declare function textToAdf(text: string): unknown;
18
+ /**
19
+ * The text as it will exist once Jira has it.
20
+ *
21
+ * A write is only verified if what a direct read shows can be compared to what
22
+ * was asked for - and for a rich-text field those two are never byte-identical.
23
+ * The caller's string becomes a document, Jira stores the document, and reading
24
+ * it back renders a document into text again. Blank-line runs collapse, block
25
+ * edges lose their whitespace, a trailing newline disappears.
26
+ *
27
+ * None of that changes what the description says, so comparing raw strings
28
+ * would fail every time. Comparing canonical forms fails only when the text
29
+ * actually differs.
30
+ *
31
+ * Deliberately the same normalization `textToAdf` performs, from the same
32
+ * place: "what JAM sends" and "what JAM will accept as proof it arrived" must
33
+ * not be able to drift into two answers.
34
+ */
35
+ export declare function canonicalizePlainText(text: string): string;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Plain text to the narrowest Atlassian Document Format that represents it.
3
+ *
4
+ * Jira's rich-text fields - a comment body, an issue description - are document
5
+ * trees. Accepting one from a caller would mean accepting arbitrary structure
6
+ * through a field that reads like "text": panels, mentions, embedded content,
7
+ * links to anywhere. So JAM's contract is plain text in both places, and this
8
+ * is the single conversion that produces the document.
9
+ *
10
+ * Shared rather than duplicated per field on purpose. Two copies of this would
11
+ * be two answers to "what can an agent put in a Jira document", and the second
12
+ * one would drift.
13
+ *
14
+ * Blank lines separate paragraphs; everything else is literal. No markdown is
15
+ * interpreted, so text containing `*` or `#` says what it says.
16
+ */
17
+ export function textToAdf(text) {
18
+ const paragraphs = toParagraphs(text);
19
+ return {
20
+ type: "doc",
21
+ version: 1,
22
+ content: (paragraphs.length > 0 ? paragraphs : [text]).map((block) => ({
23
+ type: "paragraph",
24
+ content: [{ type: "text", text: block }],
25
+ })),
26
+ };
27
+ }
28
+ /**
29
+ * The text as it will exist once Jira has it.
30
+ *
31
+ * A write is only verified if what a direct read shows can be compared to what
32
+ * was asked for - and for a rich-text field those two are never byte-identical.
33
+ * The caller's string becomes a document, Jira stores the document, and reading
34
+ * it back renders a document into text again. Blank-line runs collapse, block
35
+ * edges lose their whitespace, a trailing newline disappears.
36
+ *
37
+ * None of that changes what the description says, so comparing raw strings
38
+ * would fail every time. Comparing canonical forms fails only when the text
39
+ * actually differs.
40
+ *
41
+ * Deliberately the same normalization `textToAdf` performs, from the same
42
+ * place: "what JAM sends" and "what JAM will accept as proof it arrived" must
43
+ * not be able to drift into two answers.
44
+ */
45
+ export function canonicalizePlainText(text) {
46
+ return toParagraphs(text).join("\n\n");
47
+ }
48
+ /**
49
+ * Blank lines separate paragraphs; everything else is literal.
50
+ *
51
+ * A single newline inside a block survives - it is a line break the author
52
+ * wrote, and ADF round-trips it - so only the edges of each block are trimmed.
53
+ */
54
+ function toParagraphs(text) {
55
+ return (text
56
+ // Line endings first. A CRLF document would otherwise carry a stray \r
57
+ // at the end of every block into ADF, and Jira renders it back as LF -
58
+ // so a create that was correct would fail verification, on nothing more
59
+ // than which editor the caller used. Worse, `\r\n\r\n` and `\n\n` would
60
+ // split into paragraphs differently.
61
+ .replace(/\r\n?/g, "\n")
62
+ .split(/\n{2,}/)
63
+ .map((block) => block.trim())
64
+ .filter(Boolean));
65
+ }
@@ -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_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_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: {
@@ -25,6 +25,15 @@ export const JAM_ERROR_CODES = [
25
25
  "JAM_WRITE_OPERATION_NOT_ALLOWED",
26
26
  "JAM_WRITE_FIELD_NOT_ALLOWED",
27
27
  "JAM_WRITE_TRANSITION_NOT_AVAILABLE",
28
+ // Creation. Each is a refusal JAM makes before Jira is asked to act, or a
29
+ // premise that stopped holding between planning and applying - never a raw
30
+ // Jira 400 passed along. "That type is not on offer", "this project needs a
31
+ // field JAM cannot fill" and "the schema moved under the plan" are three
32
+ // different next steps for whoever is holding the agent.
33
+ "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE",
34
+ "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED",
35
+ "JAM_WRITE_VALUE_NOT_ALLOWED",
36
+ "JAM_WRITE_SCHEMA_CHANGED",
28
37
  "JAM_WRITE_PLAN_NOT_FOUND",
29
38
  "JAM_WRITE_PLAN_EXPIRED",
30
39
  "JAM_WRITE_CONFLICT",