@jam-mcp/server 1.0.1 → 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 (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +86 -72
  3. package/dist/adapters/credentials/windows-user-env.d.ts +3 -1
  4. package/dist/adapters/credentials/windows-user-env.js +20 -1
  5. package/dist/adapters/jira-cloud/jira-client.d.ts +10 -1
  6. package/dist/adapters/jira-cloud/jira-client.js +1 -1
  7. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.d.ts +27 -0
  8. package/dist/adapters/jira-cloud/jira-create-metadata.adapter.js +81 -0
  9. package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +10 -1
  10. package/dist/adapters/jira-cloud/jira-read.adapter.js +17 -0
  11. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +41 -6
  12. package/dist/adapters/jira-cloud/jira-write.adapter.js +96 -11
  13. package/dist/application/apply-create-issue.d.ts +20 -0
  14. package/dist/application/apply-create-issue.js +187 -0
  15. package/dist/application/apply-write.d.ts +25 -0
  16. package/dist/application/apply-write.js +166 -0
  17. package/dist/application/plan-create-issue.d.ts +44 -0
  18. package/dist/application/plan-create-issue.js +188 -0
  19. package/dist/application/plan-write.d.ts +39 -0
  20. package/dist/application/plan-write.js +210 -0
  21. package/dist/application/write-plan-store.d.ts +42 -0
  22. package/dist/application/write-plan-store.js +81 -0
  23. package/dist/bootstrap/boot-health-gate.js +2 -2
  24. package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
  25. package/dist/cli/auth.d.ts +6 -0
  26. package/dist/cli/auth.js +2 -1
  27. package/dist/cli-entry.js +33 -33
  28. package/dist/deps.d.ts +26 -0
  29. package/dist/deps.js +14 -0
  30. package/dist/domain/adf.d.ts +35 -0
  31. package/dist/domain/adf.js +65 -0
  32. package/dist/domain/errors.d.ts +1 -1
  33. package/dist/domain/errors.js +22 -0
  34. package/dist/domain/write.d.ts +230 -0
  35. package/dist/domain/write.js +65 -0
  36. package/dist/mcp/create-server.d.ts +12 -3
  37. package/dist/mcp/create-server.js +35 -6
  38. package/dist/mcp/tools/jira-write-apply.tool.d.ts +3 -0
  39. package/dist/mcp/tools/jira-write-apply.tool.js +33 -0
  40. package/dist/mcp/tools/jira-write-plan.tool.d.ts +3 -0
  41. package/dist/mcp/tools/jira-write-plan.tool.js +85 -0
  42. package/dist/policy/consistency-policy.d.ts +10 -4
  43. package/dist/policy/create-policy.d.ts +86 -0
  44. package/dist/policy/create-policy.js +182 -0
  45. package/dist/policy/write-policy.d.ts +69 -0
  46. package/dist/policy/write-policy.js +128 -0
  47. package/dist/ports/jira-create-metadata.port.d.ts +25 -0
  48. package/dist/ports/jira-create-metadata.port.js +1 -0
  49. package/dist/ports/jira-read.port.d.ts +23 -0
  50. package/dist/ports/jira-write.port.d.ts +27 -4
  51. package/package.json +69 -69
@@ -0,0 +1,210 @@
1
+ import { JamError } from "../domain/errors.js";
2
+ import { assertExistingIssueOperation, assertFieldsAllowed, assertOperationAllowed, assertWriteScope, PLAN_TTL_MS, resolveTransition, } from "../policy/write-policy.js";
3
+ import { planCreateIssue } from "./plan-create-issue.js";
4
+ /**
5
+ * Work out whether a requested change is currently possible, and describe it.
6
+ *
7
+ * Reads only. Nothing here mutates Jira, and that is the whole point of the
8
+ * step: the agent gets to see what the issue looks like now, what JAM would
9
+ * do to it, and what a direct read will have to show before JAM will call it
10
+ * done - all before anything has happened.
11
+ *
12
+ * The order matters. Scope and operation are checked before any Jira call, so
13
+ * an out-of-scope key costs nothing and comes back as a JAM refusal rather
14
+ * than a 404. Everything after that is derived from the issue as Jira reports
15
+ * it right now, never from what the caller asserted about it.
16
+ */
17
+ export async function planWrite(deps, request) {
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);
26
+ const projectKey = assertWriteScope(issueKey, deps.config.project.key);
27
+ const operation = assertExistingIssueOperation(request.operation);
28
+ // Everything that can be refused from the request alone is refused here,
29
+ // before a Jira call is spent on it. An agent asking to write a field JAM
30
+ // does not write should get that answer, not a round trip and then that
31
+ // answer.
32
+ const input = validateInput(operation, request.input);
33
+ const issue = await readIssue(deps, issueKey);
34
+ const { before, intendedAfter, mutation, transition } = await describe(deps, operation, issueKey, issue, input);
35
+ const createdAt = new Date();
36
+ const plan = deps.writePlans.create({
37
+ kind: "existing-issue",
38
+ issueKey,
39
+ projectKey,
40
+ operation,
41
+ before,
42
+ intendedAfter,
43
+ baseUpdated: issue.updated,
44
+ createdAt: createdAt.toISOString(),
45
+ expiresAt: new Date(createdAt.getTime() + PLAN_TTL_MS).toISOString(),
46
+ ...(transition ? { transition } : {}),
47
+ mutation,
48
+ });
49
+ return {
50
+ plan,
51
+ receipt: {
52
+ status: "planned",
53
+ planId: plan.planId,
54
+ issue: plan.issueKey,
55
+ operation: plan.operation,
56
+ before: plan.before,
57
+ intendedAfter: plan.intendedAfter,
58
+ expiresAt: plan.expiresAt,
59
+ verification: { method: "direct-issue-read", expects: plan.intendedAfter },
60
+ },
61
+ };
62
+ }
63
+ /**
64
+ * The issue as Jira has it, read directly by key.
65
+ *
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.
74
+ */
75
+ export async function readIssue(deps, issueKey) {
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
+ ],
93
+ });
94
+ if (!found) {
95
+ throw new JamError("ISSUE_NOT_FOUND", `Jira has no issue ${issueKey}, or it is not visible to this account.`, { issueKey });
96
+ }
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();
113
+ }
114
+ /**
115
+ * Check the request against the contract, and normalize it.
116
+ *
117
+ * Pure: no Jira, no state. Whether an operation is supported, whether a field
118
+ * is writable and whether the input is even the right shape are all knowable
119
+ * without asking Jira anything, so they are answered first.
120
+ */
121
+ function validateInput(operation, raw) {
122
+ switch (operation) {
123
+ case "comment.add": {
124
+ const text = raw.text;
125
+ if (typeof text !== "string" || text.trim().length === 0) {
126
+ throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "comment.add needs non-empty `input.text`.", { operation });
127
+ }
128
+ return { text: text.trim() };
129
+ }
130
+ case "field.update":
131
+ return assertFieldsAllowed(raw);
132
+ case "status.transition": {
133
+ const status = raw.status;
134
+ if (typeof status !== "string" || status.trim().length === 0) {
135
+ throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "status.transition needs non-empty `input.status`.", { operation });
136
+ }
137
+ return { status: status.trim() };
138
+ }
139
+ }
140
+ }
141
+ async function describe(deps, operation, issueKey, issue, input) {
142
+ switch (operation) {
143
+ case "comment.add": {
144
+ const { text } = input;
145
+ // Comments accumulate rather than replace, so `before` says what is
146
+ // there now by count - the plan is not claiming to know the thread.
147
+ return {
148
+ before: { comments: issue.comments.length },
149
+ intendedAfter: { commentAdded: text },
150
+ mutation: { kind: "comment", text },
151
+ };
152
+ }
153
+ case "field.update": {
154
+ const fields = input;
155
+ const before = {};
156
+ const after = {};
157
+ for (const [field, value] of Object.entries(fields)) {
158
+ before[field] = currentValue(issue, field);
159
+ after[field] = value;
160
+ }
161
+ return {
162
+ before,
163
+ intendedAfter: after,
164
+ mutation: { kind: "fields", fields: toJiraFields(fields) },
165
+ };
166
+ }
167
+ case "status.transition": {
168
+ const { status: target } = input;
169
+ // Ask Jira what is reachable rather than deriving an id from a name:
170
+ // transition ids are per-workflow, and a guessed one either fails or
171
+ // moves the issue somewhere nobody asked for.
172
+ const available = await deps.jiraWrite.getTransitions(issueKey);
173
+ const transition = resolveTransition(target, available);
174
+ return {
175
+ before: { status: issue.status },
176
+ intendedAfter: { status: transition.to },
177
+ mutation: { kind: "transition", transitionId: transition.id },
178
+ transition,
179
+ };
180
+ }
181
+ }
182
+ }
183
+ function currentValue(issue, field) {
184
+ switch (field) {
185
+ case "summary":
186
+ return issue.summary;
187
+ case "priority":
188
+ return issue.priority;
189
+ case "labels":
190
+ return issue.labels;
191
+ case "components":
192
+ return issue.components;
193
+ default:
194
+ return undefined;
195
+ }
196
+ }
197
+ /** Whitelisted values to the shapes Jira's field API expects. */
198
+ function toJiraFields(input) {
199
+ const fields = {};
200
+ if (input.summary !== undefined)
201
+ fields["summary"] = input.summary;
202
+ if (input.priority !== undefined)
203
+ fields["priority"] = { name: input.priority };
204
+ if (input.labels !== undefined)
205
+ fields["labels"] = input.labels;
206
+ if (input.components !== undefined) {
207
+ fields["components"] = input.components.map((name) => ({ name }));
208
+ }
209
+ return fields;
210
+ }
@@ -0,0 +1,42 @@
1
+ import type { NewWritePlan, WritePlan } from "../domain/write.js";
2
+ /**
3
+ * Where a plan lives between `jira_write_plan` and `jira_write_apply`.
4
+ *
5
+ * In this process, and nowhere else. The alternative considered was a signed
6
+ * self-contained token, and it is worse here on both counts that matter: the
7
+ * signing key would have to come from somewhere (a new secret on disk, or a
8
+ * per-process key that gives the token exactly this lifetime anyway), and the
9
+ * mutation would have to travel through the agent to come back. Keeping the
10
+ * mutation in memory makes forgery impossible rather than merely hard - a
11
+ * `planId` is an opaque handle, and what it names never leaves this process.
12
+ *
13
+ * The cost is that plans do not survive a restart. That is acceptable: a plan
14
+ * is only valid while the issue has not moved, so a stale one was going to be
15
+ * rejected on its own terms, and re-planning is a single read.
16
+ *
17
+ * See docs/decisions/adr-jira-write-plane.md.
18
+ */
19
+ export declare class WritePlanStore {
20
+ private readonly now;
21
+ private readonly plans;
22
+ /** Injected by tests so expiry does not depend on wall-clock timing. */
23
+ constructor(now?: () => Date);
24
+ create(plan: NewWritePlan): WritePlan;
25
+ /**
26
+ * Resolve a plan for applying.
27
+ *
28
+ * An expired plan is reported as expired rather than as missing: those are
29
+ * different situations, and telling them apart is the difference between
30
+ * "re-plan" and "you are calling this wrong".
31
+ */
32
+ take(planId: string): WritePlan;
33
+ /**
34
+ * Retire a plan once it has been applied.
35
+ *
36
+ * Single use, so a receipt cannot be turned into a second mutation by
37
+ * calling apply again with the same id - which for `comment.add` would mean
38
+ * two comments.
39
+ */
40
+ consume(planId: string): void;
41
+ private evictExpired;
42
+ }
@@ -0,0 +1,81 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { JamError } from "../domain/errors.js";
3
+ import { planExpired } from "../policy/write-policy.js";
4
+ /**
5
+ * Where a plan lives between `jira_write_plan` and `jira_write_apply`.
6
+ *
7
+ * In this process, and nowhere else. The alternative considered was a signed
8
+ * self-contained token, and it is worse here on both counts that matter: the
9
+ * signing key would have to come from somewhere (a new secret on disk, or a
10
+ * per-process key that gives the token exactly this lifetime anyway), and the
11
+ * mutation would have to travel through the agent to come back. Keeping the
12
+ * mutation in memory makes forgery impossible rather than merely hard - a
13
+ * `planId` is an opaque handle, and what it names never leaves this process.
14
+ *
15
+ * The cost is that plans do not survive a restart. That is acceptable: a plan
16
+ * is only valid while the issue has not moved, so a stale one was going to be
17
+ * rejected on its own terms, and re-planning is a single read.
18
+ *
19
+ * See docs/decisions/adr-jira-write-plane.md.
20
+ */
21
+ export class WritePlanStore {
22
+ now;
23
+ plans = new Map();
24
+ /** Injected by tests so expiry does not depend on wall-clock timing. */
25
+ constructor(now = () => new Date()) {
26
+ this.now = now;
27
+ }
28
+ create(plan) {
29
+ this.evictExpired();
30
+ const stored = { ...plan, planId: randomUUID() };
31
+ this.plans.set(stored.planId, stored);
32
+ return stored;
33
+ }
34
+ /**
35
+ * Resolve a plan for applying.
36
+ *
37
+ * An expired plan is reported as expired rather than as missing: those are
38
+ * different situations, and telling them apart is the difference between
39
+ * "re-plan" and "you are calling this wrong".
40
+ */
41
+ take(planId) {
42
+ const plan = this.plans.get(planId);
43
+ if (!plan) {
44
+ throw new JamError("JAM_WRITE_PLAN_NOT_FOUND", "No such write plan. Plans live in the running JAM server and do not survive a restart - call jira_write_plan again.", { planId });
45
+ }
46
+ if (planExpired(plan.expiresAt, this.now())) {
47
+ this.plans.delete(planId);
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
+ });
61
+ }
62
+ return plan;
63
+ }
64
+ /**
65
+ * Retire a plan once it has been applied.
66
+ *
67
+ * Single use, so a receipt cannot be turned into a second mutation by
68
+ * calling apply again with the same id - which for `comment.add` would mean
69
+ * two comments.
70
+ */
71
+ consume(planId) {
72
+ this.plans.delete(planId);
73
+ }
74
+ evictExpired() {
75
+ const now = this.now();
76
+ for (const [id, plan] of this.plans) {
77
+ if (planExpired(plan.expiresAt, now))
78
+ this.plans.delete(id);
79
+ }
80
+ }
81
+ }
@@ -1,5 +1,5 @@
1
1
  import { toJamError } from "../domain/errors.js";
2
- import { createServer } from "../mcp/create-server.js";
2
+ import { createServer, TOOL_COUNT } from "../mcp/create-server.js";
3
3
  /**
4
4
  * One health-check core shared by `jam doctor`, `jam setup` and `jam serve`.
5
5
  *
@@ -60,7 +60,7 @@ export async function runHealthGate(deps, mode) {
60
60
  });
61
61
  try {
62
62
  createServer(deps);
63
- add({ name: "MCP server startup", ok: true, fatal: true, detail: "3 tools registered" });
63
+ add({ name: "MCP server startup", ok: true, fatal: true, detail: `${TOOL_COUNT} tools registered` });
64
64
  }
65
65
  catch (err) {
66
66
  add({ name: "MCP server startup", ok: false, fatal: true, detail: toJamError(err).message });
@@ -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.0.1", "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/cli-entry.js CHANGED
@@ -11,39 +11,39 @@ import { authStatusCommand, doctorJsonCommand, setupAgentCommand, setupApplyComm
11
11
  * points (notably @jam-mcp/bootstrap) can forward to exactly these commands
12
12
  * instead of reimplementing them.
13
13
  */
14
- export const USAGE = `jam - Jira Agent MCP
15
-
16
- Usage:
17
- jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)
18
- jam doctor Diagnose config, credentials and Jira connectivity
19
- jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]
20
- Wire up this project and run doctor. Binds it to you
21
- alone, writing nothing to the repository; --shared
22
- adopts JAM for the team (project.yaml, .mcp.json)
23
- jam runtime Show which JAM build this machine runs
24
- jam runtime use package | development <path>
25
- Change it (writes ~/.jam/config.yaml only, never a project)
26
- jam auth login Store Jira credentials in this user's OS secret store
27
- jam auth logout Remove them again
28
-
29
- For coding agents and scripts (stdout is JSON only, never prompts):
30
- jam setup --agent One shot: detect, plan, apply what is safe, verify
31
- jam setup plan --json Report what setup would change, changing nothing
32
- jam setup apply --non-interactive --json
33
- Execute the plan
34
- jam doctor --json Health check as structured output
35
- jam auth status --json Whether Jira credentials are configured (never their value)
36
-
37
- Environment:
38
- JIRA_BASE_URL https://your-site.atlassian.net
39
- JIRA_EMAIL Atlassian account email
40
- JIRA_API_TOKEN Atlassian API token
41
- JAM_PROJECT_KEY Jira project key, used by \`jam setup\`/\`jam serve\` when no
42
- .jira-agent/project.yaml exists yet
43
-
44
- Credentials and JAM_PROJECT_KEY are read from the current shell's environment
45
- first, then (on Windows) from the User environment - so a value set with
46
- \`setx\` works without opening a new terminal.
14
+ export const USAGE = `jam - Jira Agent MCP
15
+
16
+ Usage:
17
+ jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)
18
+ jam doctor Diagnose config, credentials and Jira connectivity
19
+ jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]
20
+ Wire up this project and run doctor. Binds it to you
21
+ alone, writing nothing to the repository; --shared
22
+ adopts JAM for the team (project.yaml, .mcp.json)
23
+ jam runtime Show which JAM build this machine runs
24
+ jam runtime use package | development <path>
25
+ Change it (writes ~/.jam/config.yaml only, never a project)
26
+ jam auth login Store Jira credentials in this user's OS secret store
27
+ jam auth logout Remove them again
28
+
29
+ For coding agents and scripts (stdout is JSON only, never prompts):
30
+ jam setup --agent One shot: detect, plan, apply what is safe, verify
31
+ jam setup plan --json Report what setup would change, changing nothing
32
+ jam setup apply --non-interactive --json
33
+ Execute the plan
34
+ jam doctor --json Health check as structured output
35
+ jam auth status --json Whether Jira credentials are configured (never their value)
36
+
37
+ Environment:
38
+ JIRA_BASE_URL https://your-site.atlassian.net
39
+ JIRA_EMAIL Atlassian account email
40
+ JIRA_API_TOKEN Atlassian API token
41
+ JAM_PROJECT_KEY Jira project key, used by \`jam setup\`/\`jam serve\` when no
42
+ .jira-agent/project.yaml exists yet
43
+
44
+ Credentials and JAM_PROJECT_KEY are read from the current shell's environment
45
+ first, then (on Windows) from the User environment - so a value set with
46
+ \`setx\` works without opening a new terminal.
47
47
  `;
48
48
  function findFlagValue(argv, flag) {
49
49
  const index = argv.indexOf(flag);
package/dist/deps.d.ts CHANGED
@@ -4,6 +4,9 @@ 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";
8
+ import type { JiraWritePort } from "./ports/jira-write.port.js";
9
+ import { WritePlanStore } from "./application/write-plan-store.js";
7
10
  import type { TelemetryPort } from "./ports/telemetry.port.js";
8
11
  /** Everything the application layer is allowed to reach for. */
9
12
  export type JamDeps = {
@@ -12,6 +15,25 @@ export type JamDeps = {
12
15
  /** Where the project key came from when no config file supplied one. */
13
16
  keySource?: BootstrapSource;
14
17
  jira: JiraReadPort;
18
+ /**
19
+ * The mutating half. Separate from `jira` on purpose: reading and writing
20
+ * have different retry rules and different confirmation rules, and one port
21
+ * that did both would blur them.
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;
32
+ /**
33
+ * Plans awaiting apply. Lives for the life of this server process - see
34
+ * WritePlanStore for why it is not persisted.
35
+ */
36
+ writePlans: WritePlanStore;
15
37
  cache: CachePort;
16
38
  telemetry: TelemetryPort;
17
39
  credentials: CredentialPort;
@@ -20,6 +42,10 @@ export type BuildDepsOptions = {
20
42
  cwd?: string;
21
43
  /** Injected by tests to bypass the real REST adapter. */
22
44
  jira?: JiraReadPort;
45
+ /** Injected by tests so no test can reach a real Jira write endpoint. */
46
+ jiraWrite?: JiraWritePort;
47
+ /** Injected by tests so create metadata comes from a fixture, not a site. */
48
+ jiraCreateMetadata?: JiraCreateMetadataPort;
23
49
  /** Injected by tests to bypass the real process/registry credential lookup. */
24
50
  credentials?: CredentialPort;
25
51
  /**
package/dist/deps.js CHANGED
@@ -2,6 +2,7 @@ import { NoopCache } from "./adapters/cache/noop-cache.js";
2
2
  import { CompositeCredentialProvider } from "./adapters/credentials/composite.js";
3
3
  import { ConsoleTelemetry } from "./adapters/telemetry/console-telemetry.js";
4
4
  import { resolveProjectConfig } from "./bootstrap/project-config-resolver.js";
5
+ import { WritePlanStore } from "./application/write-plan-store.js";
5
6
  /**
6
7
  * Single composition root. `jam serve`, `jam doctor` and `jam setup` all wire
7
8
  * through here, so a doctor pass actually proves the server's configuration.
@@ -27,11 +28,24 @@ export async function buildDeps(options = {}) {
27
28
  const { JiraCloudReadAdapter } = await import("./adapters/jira-cloud/jira-read.adapter.js");
28
29
  jira = new JiraCloudReadAdapter(credentials, resolved.config);
29
30
  }
31
+ let jiraWrite = options.jiraWrite;
32
+ if (!jiraWrite) {
33
+ const { JiraCloudWriteAdapter } = await import("./adapters/jira-cloud/jira-write.adapter.js");
34
+ jiraWrite = new JiraCloudWriteAdapter(credentials);
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
+ }
30
41
  return {
31
42
  config: resolved.config,
32
43
  configPath: resolved.configPath,
33
44
  keySource: resolved.keySource,
34
45
  jira,
46
+ jiraWrite,
47
+ jiraCreateMetadata,
48
+ writePlans: new WritePlanStore(),
35
49
  cache: new NoopCache(),
36
50
  telemetry,
37
51
  credentials,
@@ -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"];
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: {