@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
package/README.md CHANGED
@@ -70,10 +70,10 @@ auth login Store Jira credentials in this user's OS secret store
70
70
  runtime Show or change which JAM build this machine runs
71
71
  ```
72
72
 
73
- Written out, that is `npx --yes @jam-mcp/launcher@1.1.0 doctor`, or just `jam
73
+ Written out, that is `npx --yes @jam-mcp/launcher@1.2.0 doctor`, or just `jam
74
74
  doctor` if you took the launcher's optional global install. Starting from
75
75
  nothing — no install, no runtime chosen yet — use
76
- `npx --yes @jam-mcp/bootstrap@1.1.0 init` instead.
76
+ `npx --yes @jam-mcp/bootstrap@1.2.0 init` instead.
77
77
 
78
78
  Credentials come from the process environment or this user's OS secret store —
79
79
  never from a repository file — and never appear in logs, telemetry, or tool
@@ -15,6 +15,8 @@ export type RegQueryFn = (name: string) => string | undefined;
15
15
  */
16
16
  export declare class WindowsUserEnvCredentialSource implements CredentialValueSource {
17
17
  private readonly queryFn;
18
- constructor(queryFn?: RegQueryFn);
18
+ private readonly env;
19
+ constructor(queryFn?: RegQueryFn, env?: NodeJS.ProcessEnv);
19
20
  read(): RawCredentialValues;
20
21
  }
22
+ export declare function userEnvDisabled(env?: NodeJS.ProcessEnv): boolean;
@@ -15,12 +15,16 @@ import { CREDENTIAL_ENV_KEYS } from "./process-env.js";
15
15
  */
16
16
  export class WindowsUserEnvCredentialSource {
17
17
  queryFn;
18
- constructor(queryFn = defaultRegQuery) {
18
+ env;
19
+ constructor(queryFn = defaultRegQuery, env = process.env) {
19
20
  this.queryFn = queryFn;
21
+ this.env = env;
20
22
  }
21
23
  read() {
22
24
  if (process.platform !== "win32")
23
25
  return {};
26
+ if (userEnvDisabled(this.env))
27
+ return {};
24
28
  const out = {};
25
29
  for (const key of CREDENTIAL_ENV_KEYS) {
26
30
  const value = this.queryFn(key)?.trim();
@@ -30,6 +34,21 @@ export class WindowsUserEnvCredentialSource {
30
34
  return out;
31
35
  }
32
36
  }
37
+ /**
38
+ * Escape hatch for isolated test sandboxes, matching JAM_DISABLE_SECRET_STORE.
39
+ *
40
+ * HKCU\Environment is per-user, not per-HOME, so repointing HOME does not make
41
+ * a sandbox credential-free on Windows: a developer who ran `setx JIRA_API_TOKEN`
42
+ * once has credentials that every process of theirs can see. Without this, a
43
+ * hermetic test would pass or fail depending on whose machine ran it, and
44
+ * "zero HOME" would be mistaken for "zero credentials".
45
+ *
46
+ * Not a user-facing feature. Production never sets it.
47
+ */
48
+ const DISABLE_ENV = "JAM_DISABLE_USER_ENV";
49
+ export function userEnvDisabled(env = process.env) {
50
+ return Boolean(env[DISABLE_ENV]);
51
+ }
33
52
  const VALUE_LINE = /^\s*\S+\s+REG_(?:SZ|EXPAND_SZ)\s+(.*)$/;
34
53
  // Built from a char code rather than a literal backslash escape - a lone
35
54
  // backslash before a letter isn't a recognized JS escape and silently
@@ -0,0 +1,27 @@
1
+ import type { CreateFieldMetadata, CreateIssueType } from "../../domain/write.js";
2
+ import type { CredentialPort } from "../../ports/credentials.port.js";
3
+ import type { JiraCreateMetadataPort } from "../../ports/jira-create-metadata.port.js";
4
+ /**
5
+ * Jira Cloud REST v3 create metadata, per project and per issue type.
6
+ *
7
+ * The two-endpoint form, not the aggregate `createmeta?expand=` one: the
8
+ * aggregate endpoint is deprecated on Jira Cloud and returns every issue type's
9
+ * every field in one document, which is both larger and less precise than the
10
+ * question being asked. Planning wants one project's issue types, and then one
11
+ * issue type's fields.
12
+ *
13
+ * `retry: false` throughout. These answers decide whether a create is possible
14
+ * and what it will contain, so a retried-and-stale answer is worse than a
15
+ * failure - the same reason `getTransitions` does not retry.
16
+ *
17
+ * Both mappers are defensive about shape. Jira omits fields it considers
18
+ * irrelevant and different deployments populate different ones, so anything
19
+ * unrecognised is dropped rather than guessed at: an entry JAM cannot read is
20
+ * an entry JAM must not claim to have understood.
21
+ */
22
+ export declare class JiraCloudCreateMetadataAdapter implements JiraCreateMetadataPort {
23
+ private readonly client;
24
+ constructor(credentials: CredentialPort, fetchImpl?: typeof fetch);
25
+ getIssueTypes(projectKey: string): Promise<CreateIssueType[]>;
26
+ getCreateFields(projectKey: string, issueTypeId: string): Promise<CreateFieldMetadata[]>;
27
+ }
@@ -0,0 +1,81 @@
1
+ import { JiraClient } from "./jira-client.js";
2
+ /**
3
+ * Jira Cloud REST v3 create metadata, per project and per issue type.
4
+ *
5
+ * The two-endpoint form, not the aggregate `createmeta?expand=` one: the
6
+ * aggregate endpoint is deprecated on Jira Cloud and returns every issue type's
7
+ * every field in one document, which is both larger and less precise than the
8
+ * question being asked. Planning wants one project's issue types, and then one
9
+ * issue type's fields.
10
+ *
11
+ * `retry: false` throughout. These answers decide whether a create is possible
12
+ * and what it will contain, so a retried-and-stale answer is worse than a
13
+ * failure - the same reason `getTransitions` does not retry.
14
+ *
15
+ * Both mappers are defensive about shape. Jira omits fields it considers
16
+ * irrelevant and different deployments populate different ones, so anything
17
+ * unrecognised is dropped rather than guessed at: an entry JAM cannot read is
18
+ * an entry JAM must not claim to have understood.
19
+ */
20
+ export class JiraCloudCreateMetadataAdapter {
21
+ client;
22
+ constructor(credentials, fetchImpl) {
23
+ this.client = fetchImpl ? new JiraClient(credentials, fetchImpl) : new JiraClient(credentials);
24
+ }
25
+ async getIssueTypes(projectKey) {
26
+ const { data } = await this.client.request({
27
+ path: `rest/api/3/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`,
28
+ retry: false,
29
+ });
30
+ return (data.issueTypes ?? [])
31
+ .filter((t) => typeof t.id === "string" && typeof t.name === "string")
32
+ .map((t) => ({ id: t.id, name: t.name, subtask: t.subtask === true }));
33
+ }
34
+ async getCreateFields(projectKey, issueTypeId) {
35
+ const { data } = await this.client.request({
36
+ path: `rest/api/3/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes/${encodeURIComponent(issueTypeId)}`,
37
+ retry: false,
38
+ });
39
+ return (data.fields ?? [])
40
+ .map(toFieldMetadata)
41
+ .filter((f) => f !== undefined);
42
+ }
43
+ }
44
+ function toFieldMetadata(raw) {
45
+ // Jira has called this `fieldId` and `key` in different responses. Without
46
+ // one of them the entry cannot be matched to anything, so it is dropped -
47
+ // and if it was required, the required-field gate will refuse the plan
48
+ // because JAM cannot show it was supplied.
49
+ const id = typeof raw.fieldId === "string" ? raw.fieldId : typeof raw.key === "string" ? raw.key : undefined;
50
+ if (!id)
51
+ return undefined;
52
+ const allowed = mapAllowedValues(raw.allowedValues);
53
+ return {
54
+ id,
55
+ name: typeof raw.name === "string" ? raw.name : id,
56
+ required: raw.required === true,
57
+ hasDefaultValue: raw.hasDefaultValue === true,
58
+ ...(allowed ? { allowedValues: allowed } : {}),
59
+ };
60
+ }
61
+ /**
62
+ * Allowed values, when Jira constrains the field at all.
63
+ *
64
+ * Undefined and empty mean different things and are kept apart: undefined is
65
+ * "Jira did not constrain this", empty is "Jira constrains it and offers
66
+ * nothing". The first permits a free value, the second permits none.
67
+ */
68
+ function mapAllowedValues(raw) {
69
+ if (!Array.isArray(raw))
70
+ return undefined;
71
+ return raw.map((entry) => {
72
+ const id = typeof entry?.id === "string" ? entry.id : undefined;
73
+ // Components and priorities use `name`; some option fields use `value`.
74
+ const name = typeof entry?.name === "string"
75
+ ? entry.name
76
+ : typeof entry?.value === "string"
77
+ ? entry.value
78
+ : undefined;
79
+ return { ...(id ? { id } : {}), ...(name ? { name } : {}) };
80
+ });
81
+ }
@@ -1,11 +1,20 @@
1
1
  import type { ProjectConfig } from "../../config/schema.js";
2
2
  import type { CredentialPort } from "../../ports/credentials.port.js";
3
- import type { CurrentUser, GetCommentsRequest, GetCommentsResult, GetIssuesRequest, GetIssuesResult, JiraReadPort, ListProjectsResult, SearchPageRequest, SearchPageResult } from "../../ports/jira-read.port.js";
3
+ import type { CurrentUser, GetCommentsRequest, GetCommentsResult, GetIssueRequest, GetIssueResult, GetIssuesRequest, GetIssuesResult, JiraReadPort, ListProjectsResult, SearchPageRequest, SearchPageResult } from "../../ports/jira-read.port.js";
4
4
  export declare class JiraCloudReadAdapter implements JiraReadPort {
5
5
  private readonly config;
6
6
  private readonly client;
7
7
  constructor(credentials: CredentialPort, config: ProjectConfig, fetchImpl?: typeof fetch);
8
8
  searchPage(req: SearchPageRequest): Promise<SearchPageResult>;
9
+ /**
10
+ * `GET /rest/api/3/issue/{key}` - the single-issue endpoint, not bulkfetch.
11
+ *
12
+ * This is what ConsistencyPolicy means by a direct issue read, and the write
13
+ * plane is the only caller. A 404 is an answer, not a failure: the issue is
14
+ * not there, or not visible to this account, and the caller decides which of
15
+ * those matters.
16
+ */
17
+ getIssue(req: GetIssueRequest): Promise<GetIssueResult>;
9
18
  getIssues(req: GetIssuesRequest): Promise<GetIssuesResult>;
10
19
  getComments(req: GetCommentsRequest): Promise<GetCommentsResult>;
11
20
  listProjects(): Promise<ListProjectsResult>;
@@ -27,6 +27,23 @@ export class JiraCloudReadAdapter {
27
27
  result.nextPageToken = data.nextPageToken;
28
28
  return result;
29
29
  }
30
+ /**
31
+ * `GET /rest/api/3/issue/{key}` - the single-issue endpoint, not bulkfetch.
32
+ *
33
+ * This is what ConsistencyPolicy means by a direct issue read, and the write
34
+ * plane is the only caller. A 404 is an answer, not a failure: the issue is
35
+ * not there, or not visible to this account, and the caller decides which of
36
+ * those matters.
37
+ */
38
+ async getIssue(req) {
39
+ const { data, bytes } = await this.client.request({
40
+ path: `rest/api/3/issue/${encodeURIComponent(req.key)}`,
41
+ query: { fields: req.fields.join(",") },
42
+ });
43
+ if (!data?.key)
44
+ return { responseBytes: bytes };
45
+ return { issue: mapIssueWithMeta(data, this.config).issue, responseBytes: bytes };
46
+ }
30
47
  async getIssues(req) {
31
48
  const issues = [];
32
49
  const commentTotals = {};
@@ -17,6 +17,19 @@ import type { JiraWritePort } from "../../ports/jira-write.port.js";
17
17
  export declare class JiraCloudWriteAdapter implements JiraWritePort {
18
18
  private readonly client;
19
19
  constructor(credentials: CredentialPort, fetchImpl?: typeof fetch);
20
+ /**
21
+ * Create one issue, once.
22
+ *
23
+ * `retry: false` matters more here than anywhere else behind this port. A
24
+ * retried update converges; a retried create leaves two issues, and the
25
+ * second one has a different key nobody is holding. An ambiguous failure is
26
+ * handed to the application layer as JAM_WRITE_UNCERTAIN and resolved by
27
+ * looking, never by sending it again.
28
+ */
29
+ createIssue(fields: Record<string, unknown>): Promise<{
30
+ id: string;
31
+ key: string;
32
+ }>;
20
33
  updateIssue(key: string, fields: Record<string, unknown>): Promise<void>;
21
34
  /**
22
35
  * Add a comment, converting plain text to ADF here rather than accepting ADF.
@@ -32,10 +45,3 @@ export declare class JiraCloudWriteAdapter implements JiraWritePort {
32
45
  getTransitions(key: string): Promise<JiraTransition[]>;
33
46
  transitionIssue(key: string, transitionId: string): Promise<void>;
34
47
  }
35
- /**
36
- * Plain text to the narrowest ADF that represents it.
37
- *
38
- * Blank lines separate paragraphs; everything else is literal. No markdown is
39
- * interpreted, so a comment containing `*` or `#` says what it says.
40
- */
41
- export declare function textToAdf(text: string): unknown;
@@ -1,3 +1,4 @@
1
+ import { textToAdf } from "../../domain/adf.js";
1
2
  import { JamError } from "../../domain/errors.js";
2
3
  import { JiraClient } from "./jira-client.js";
3
4
  /**
@@ -20,6 +21,30 @@ export class JiraCloudWriteAdapter {
20
21
  ? new JiraClient(credentials, fetchImpl)
21
22
  : new JiraClient(credentials);
22
23
  }
24
+ /**
25
+ * Create one issue, once.
26
+ *
27
+ * `retry: false` matters more here than anywhere else behind this port. A
28
+ * retried update converges; a retried create leaves two issues, and the
29
+ * second one has a different key nobody is holding. An ambiguous failure is
30
+ * handed to the application layer as JAM_WRITE_UNCERTAIN and resolved by
31
+ * looking, never by sending it again.
32
+ */
33
+ async createIssue(fields) {
34
+ const { data } = await this.client.request({
35
+ path: "rest/api/3/issue",
36
+ method: "POST",
37
+ body: { fields },
38
+ retry: false,
39
+ });
40
+ if (!data?.key || !data.id) {
41
+ // Jira took the request and told us nothing identifying, so an issue may
42
+ // now exist that JAM cannot name. That is exactly the uncertain case:
43
+ // report it, do not retry, and let a person look.
44
+ throw new JamError("JAM_WRITE_UNCERTAIN", "Jira accepted a create but returned no issue key, so JAM cannot tell which issue it made - or whether it made one. Look in the project before trying again: retrying could create a second issue.", { project: fields["project"]?.key });
45
+ }
46
+ return { id: data.id, key: data.key };
47
+ }
23
48
  async updateIssue(key, fields) {
24
49
  await this.client.request({
25
50
  path: `rest/api/3/issue/${encodeURIComponent(key)}`,
@@ -75,23 +100,3 @@ export class JiraCloudWriteAdapter {
75
100
  });
76
101
  }
77
102
  }
78
- /**
79
- * Plain text to the narrowest ADF that represents it.
80
- *
81
- * Blank lines separate paragraphs; everything else is literal. No markdown is
82
- * interpreted, so a comment containing `*` or `#` says what it says.
83
- */
84
- export function textToAdf(text) {
85
- const paragraphs = text
86
- .split(/\n{2,}/)
87
- .map((block) => block.trim())
88
- .filter(Boolean);
89
- return {
90
- type: "doc",
91
- version: 1,
92
- content: (paragraphs.length > 0 ? paragraphs : [text]).map((block) => ({
93
- type: "paragraph",
94
- content: [{ type: "text", text: block }],
95
- })),
96
- };
97
- }
@@ -0,0 +1,20 @@
1
+ import type { JamDeps } from "../deps.js";
2
+ import type { CreateIssueWritePlan, WriteApplyReceipt } from "../domain/write.js";
3
+ /**
4
+ * Create the issue a plan describes, then go and look at what was created.
5
+ *
6
+ * The same three-step shape as every other apply, with one substitution.
7
+ * Updating an existing issue re-reads that issue and compares its revision;
8
+ * there is no issue to re-read here, so what gets checked instead is the
9
+ * premise the plan was built on - the project's create schema. That is
10
+ * creation's concurrency boundary.
11
+ *
12
+ * 1. Re-derive the schema and check the plan's premises still hold. If the
13
+ * issue type went away, or a required field JAM cannot fill appeared, or a
14
+ * resolved value is no longer offered, nothing is sent.
15
+ * 2. POST the create exactly once. No retry, ever - see below.
16
+ * 3. Read the new issue by the key Jira returned, and check it says what the
17
+ * plan intended. A 201 with a key is Jira accepting a request, not
18
+ * evidence that the issue exists as described.
19
+ */
20
+ export declare function applyCreateIssue(deps: JamDeps, plan: CreateIssueWritePlan): Promise<WriteApplyReceipt>;
@@ -0,0 +1,187 @@
1
+ import { JamError, toJamError } from "../domain/errors.js";
2
+ import { canonicalizePlainText } from "../domain/adf.js";
3
+ import { assertSchemaUnchanged } from "../policy/create-policy.js";
4
+ import { projectKeyOf } from "../policy/write-policy.js";
5
+ import { readIssue } from "./plan-write.js";
6
+ /**
7
+ * Create the issue a plan describes, then go and look at what was created.
8
+ *
9
+ * The same three-step shape as every other apply, with one substitution.
10
+ * Updating an existing issue re-reads that issue and compares its revision;
11
+ * there is no issue to re-read here, so what gets checked instead is the
12
+ * premise the plan was built on - the project's create schema. That is
13
+ * creation's concurrency boundary.
14
+ *
15
+ * 1. Re-derive the schema and check the plan's premises still hold. If the
16
+ * issue type went away, or a required field JAM cannot fill appeared, or a
17
+ * resolved value is no longer offered, nothing is sent.
18
+ * 2. POST the create exactly once. No retry, ever - see below.
19
+ * 3. Read the new issue by the key Jira returned, and check it says what the
20
+ * plan intended. A 201 with a key is Jira accepting a request, not
21
+ * evidence that the issue exists as described.
22
+ */
23
+ export async function applyCreateIssue(deps, plan) {
24
+ if (plan.mutation.kind !== "create") {
25
+ throw new JamError("CONFIG_INVALID", "A create-issue plan must carry a create mutation.");
26
+ }
27
+ await revalidateSchema(deps, plan);
28
+ const created = await create(deps, plan);
29
+ const after = await verify(deps, plan, created.key);
30
+ deps.writePlans.consume(plan.planId);
31
+ return {
32
+ status: "applied",
33
+ issue: created.key,
34
+ operation: plan.operation,
35
+ before: plan.before,
36
+ after,
37
+ verified: true,
38
+ };
39
+ }
40
+ /**
41
+ * Ask Jira for the create schema again, and check the plan still stands.
42
+ *
43
+ * Deliberately not a comparison of the two metadata documents. The question is
44
+ * not "is the schema identical" - on an active project it rarely is - but "are
45
+ * this plan's premises still true". See assertSchemaUnchanged.
46
+ */
47
+ async function revalidateSchema(deps, plan) {
48
+ const { projectKey, schemaRequirements } = plan;
49
+ const issueTypes = await deps.jiraCreateMetadata.getIssueTypes(projectKey);
50
+ const fields = await deps.jiraCreateMetadata.getCreateFields(projectKey, schemaRequirements.issueTypeId);
51
+ assertSchemaUnchanged(schemaRequirements, issueTypes, fields, intendedInput(plan));
52
+ }
53
+ /**
54
+ * The create request as an input again, for re-running the required-field gate.
55
+ *
56
+ * Derived from `intendedAfter` rather than kept as a second copy of the
57
+ * request: one record of what this plan intends, read two ways.
58
+ */
59
+ function intendedInput(plan) {
60
+ const after = plan.intendedAfter;
61
+ return {
62
+ issueType: plan.schemaRequirements.issueTypeName,
63
+ summary: String(after["summary"] ?? ""),
64
+ ...(after["description"] !== undefined ? { description: String(after["description"]) } : {}),
65
+ ...(after["priority"] !== undefined ? { priority: String(after["priority"]) } : {}),
66
+ ...(Array.isArray(after["labels"]) ? { labels: after["labels"] } : {}),
67
+ ...(Array.isArray(after["components"]) ? { components: after["components"] } : {}),
68
+ };
69
+ }
70
+ /**
71
+ * Send the create, once.
72
+ *
73
+ * There is no retry here and there must not be one. This is the sharpest
74
+ * version of the rule the whole write plane follows: a create that fails
75
+ * ambiguously may already have produced an issue, and resending it produces a
76
+ * second one - on someone's board, in someone's sprint, with a key nobody is
77
+ * holding. So an ambiguous failure becomes JAM_WRITE_UNCERTAIN and says what
78
+ * to do about it: look in the project, do not send it again.
79
+ */
80
+ async function create(deps, plan) {
81
+ if (plan.mutation.kind !== "create") {
82
+ throw new JamError("CONFIG_INVALID", "A create-issue plan must carry a create mutation.");
83
+ }
84
+ try {
85
+ return await deps.jiraWrite.createIssue(plan.mutation.fields);
86
+ }
87
+ catch (err) {
88
+ const jamError = toJamError(err);
89
+ if (!isAmbiguous(jamError))
90
+ throw jamError;
91
+ throw new JamError("JAM_WRITE_UNCERTAIN", `JAM could not tell whether the issue was created in ${plan.projectKey}: ${jamError.message} Look in the project to find out - do not retry this write, which could create a second issue.`, { project: plan.projectKey, operation: plan.operation, cause: jamError.code });
92
+ }
93
+ }
94
+ /**
95
+ * A failure that says nothing about whether Jira acted.
96
+ *
97
+ * A 403 or a 400 is a decision Jira made and did not act on. A dropped
98
+ * connection or a 5xx is not: the request may have been processed before the
99
+ * answer went missing. JAM_WRITE_UNCERTAIN raised inside the adapter - Jira
100
+ * accepted a create but named no issue - is ambiguous by construction and
101
+ * passes through unchanged.
102
+ */
103
+ function isAmbiguous(err) {
104
+ return (err.code === "JIRA_UNAVAILABLE" ||
105
+ err.code === "RATE_LIMITED" ||
106
+ err.code === "JAM_WRITE_UNCERTAIN");
107
+ }
108
+ /**
109
+ * Read the created issue and check it is the one the plan described.
110
+ *
111
+ * Every field in `intendedAfter` is checked, because `intendedAfter` is also
112
+ * what the plan receipt promised `verification.expects` would show. A field
113
+ * the receipt names and the check skips is worse than one it never named: it
114
+ * reports `verified: true` about something nobody looked at.
115
+ *
116
+ * Only the fields the create contract lets a caller ask for are in there.
117
+ * Jira fills in a great deal on its own - reporter, created, status, whatever
118
+ * a project's automation adds - and none of that was requested, so requiring
119
+ * it to match something would be inventing an expectation nobody stated.
120
+ */
121
+ async function verify(deps, plan, issueKey) {
122
+ // Where the issue landed is part of what was intended. The workspace binding
123
+ // is the whole of JAM's write scope, so a key from another project coming
124
+ // back from a create is the one outcome that must never be reported as the
125
+ // create that was planned.
126
+ const createdProject = projectKeyOf(issueKey);
127
+ if (createdProject !== plan.projectKey) {
128
+ throw verificationFailed(plan, issueKey, { project: plan.projectKey }, { project: createdProject ?? issueKey });
129
+ }
130
+ const issue = await readIssue(deps, issueKey);
131
+ const observed = {};
132
+ for (const field of Object.keys(plan.intendedAfter)) {
133
+ observed[field] = observedValue(issue, field);
134
+ }
135
+ for (const [field, expected] of Object.entries(plan.intendedAfter)) {
136
+ if (!sameValue(observed[field], expected)) {
137
+ throw verificationFailed(plan, issueKey, plan.intendedAfter, observed);
138
+ }
139
+ }
140
+ return observed;
141
+ }
142
+ /**
143
+ * The created issue's value for one requested field.
144
+ *
145
+ * The description is canonicalized on the way out, the same way the plan
146
+ * canonicalized what was asked for. Jira stores a document and renders it back
147
+ * as text, so the two are never byte-identical - and a comparison that failed
148
+ * on Jira's own formatting would be reporting a problem that is not there.
149
+ */
150
+ function observedValue(issue, field) {
151
+ switch (field) {
152
+ case "issueType":
153
+ return issue.issueType;
154
+ case "summary":
155
+ return issue.summary;
156
+ case "description":
157
+ return issue.description === undefined
158
+ ? undefined
159
+ : canonicalizePlainText(issue.description);
160
+ case "priority":
161
+ return issue.priority;
162
+ case "labels":
163
+ return issue.labels;
164
+ case "components":
165
+ return issue.components;
166
+ default:
167
+ return undefined;
168
+ }
169
+ }
170
+ /**
171
+ * The issue exists. Say so, and say not to make another one.
172
+ *
173
+ * This is the failure an agent is most likely to answer by trying again, and
174
+ * trying again is the one thing that turns a wrong issue into two wrong
175
+ * issues.
176
+ */
177
+ function verificationFailed(plan, issueKey, expected, observed) {
178
+ return new JamError("JAM_WRITE_VERIFICATION_FAILED", `Jira created ${issueKey}, but a direct read does not show what the plan intended. A workflow rule or a project automation may have altered the issue as it was created. The issue exists - look at it and fix it there, or delete it. Do not create another one.`, { issueKey, operation: plan.operation, expected, observed });
179
+ }
180
+ function sameValue(observed, expected) {
181
+ if (Array.isArray(expected) || Array.isArray(observed)) {
182
+ const a = Array.isArray(observed) ? [...observed].map(String).sort() : [];
183
+ const b = Array.isArray(expected) ? [...expected].map(String).sort() : [];
184
+ return a.length === b.length && a.every((value, i) => value === b[i]);
185
+ }
186
+ return observed === expected;
187
+ }
@@ -1,6 +1,7 @@
1
1
  import { JamError, toJamError } from "../domain/errors.js";
2
2
  import { readModeAfterWrite } from "../policy/consistency-policy.js";
3
3
  import { assertUnchanged } from "../policy/write-policy.js";
4
+ import { applyCreateIssue } from "./apply-create-issue.js";
4
5
  import { readIssue } from "./plan-write.js";
5
6
  /**
6
7
  * Execute a plan JAM made, then go and look at what happened.
@@ -28,6 +29,11 @@ export async function applyWritePlan(deps, request) {
28
29
  if (readModeAfterWrite() !== "direct") {
29
30
  throw new JamError("CONFIG_INVALID", "Write confirmation must use a direct issue read.");
30
31
  }
32
+ // Creation follows the same three steps with a different first one: there is
33
+ // no issue to re-read, so what gets re-checked is the create schema the plan
34
+ // was built on. Both paths still end in a direct read of a real issue.
35
+ if (plan.kind === "create-issue")
36
+ return applyCreateIssue(deps, plan);
31
37
  const current = await readIssue(deps, plan.issueKey);
32
38
  assertUnchanged(plan.issueKey, plan.baseUpdated, current.updated);
33
39
  const outcome = await mutate(deps, plan);
@@ -65,6 +71,11 @@ async function mutate(deps, plan) {
65
71
  case "transition":
66
72
  await deps.jiraWrite.transitionIssue(plan.issueKey, plan.mutation.transitionId);
67
73
  return {};
74
+ case "create":
75
+ // Unreachable: a create plan is routed to applyCreateIssue above. The
76
+ // case exists so adding a mutation kind is a compile error here rather
77
+ // than a silent fall-through that writes nothing and reports success.
78
+ throw new JamError("CONFIG_INVALID", "A create mutation cannot be applied through the existing-issue path.");
68
79
  }
69
80
  }
70
81
  catch (err) {
@@ -94,10 +105,14 @@ function isAmbiguous(err) {
94
105
  async function verify(deps, plan) {
95
106
  const issue = await readIssue(deps, plan.issueKey);
96
107
  if (plan.mutation.kind === "comment") {
97
- const { comments } = await deps.jira.getIssues({
98
- keys: [plan.issueKey],
108
+ // Direct issue GET again, not the bulk endpoint: this is post-write
109
+ // confirmation, and ConsistencyPolicy makes no exception for the read that
110
+ // happens to want the comment field.
111
+ const { issue: withComments } = await deps.jira.getIssue({
112
+ key: plan.issueKey,
99
113
  fields: ["summary", "status", "comment", "updated"],
100
- }).then((r) => ({ comments: r.issues[0]?.comments ?? [] }));
114
+ });
115
+ const comments = withComments?.comments ?? [];
101
116
  const wanted = plan.mutation.text.trim();
102
117
  const found = comments.some((c) => c.body.trim() === wanted);
103
118
  if (!found) {
@@ -0,0 +1,44 @@
1
+ import type { JamDeps } from "../deps.js";
2
+ import type { CreateIssueInput, CreateIssueWritePlan, WritePlanReceipt } from "../domain/write.js";
3
+ export type PlanCreateIssueRequest = {
4
+ input: Record<string, unknown>;
5
+ };
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 declare function planCreateIssue(deps: JamDeps, request: PlanCreateIssueRequest): Promise<{
30
+ plan: CreateIssueWritePlan;
31
+ receipt: WritePlanReceipt;
32
+ }>;
33
+ /** The project this workspace is bound to, or a refusal that says so. */
34
+ export declare function configuredProject(deps: JamDeps): string;
35
+ /**
36
+ * Check the request against the create contract, and normalize it.
37
+ *
38
+ * Pure: no Jira, no state. `key` is not accepted here at all - there is no
39
+ * issue to name - and neither is `project`, which comes from the binding.
40
+ * Anything outside CREATABLE_FIELDS is rejected rather than ignored: silently
41
+ * dropping a field an agent asked for would create an issue that is not the
42
+ * one it described.
43
+ */
44
+ export declare function validateCreateInput(raw: Record<string, unknown>): CreateIssueInput;