@jam-mcp/server 1.0.0 → 1.1.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 (35) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +86 -65
  3. package/dist/adapters/jira-cloud/jira-client.d.ts +10 -1
  4. package/dist/adapters/jira-cloud/jira-client.js +1 -1
  5. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +35 -6
  6. package/dist/adapters/jira-cloud/jira-write.adapter.js +90 -10
  7. package/dist/application/apply-write.d.ts +25 -0
  8. package/dist/application/apply-write.js +151 -0
  9. package/dist/application/plan-write.d.ts +32 -0
  10. package/dist/application/plan-write.js +167 -0
  11. package/dist/application/write-plan-store.d.ts +42 -0
  12. package/dist/application/write-plan-store.js +69 -0
  13. package/dist/bootstrap/boot-health-gate.js +2 -2
  14. package/dist/bootstrap/mcp-config-merger.d.ts +8 -7
  15. package/dist/bootstrap/mcp-config-merger.js +7 -7
  16. package/dist/bootstrap/setup-plan.d.ts +8 -0
  17. package/dist/bootstrap/setup-plan.js +3 -2
  18. package/dist/cli/setup-wizard.js +1 -1
  19. package/dist/cli-entry.js +33 -33
  20. package/dist/deps.d.ts +15 -0
  21. package/dist/deps.js +8 -0
  22. package/dist/domain/errors.d.ts +1 -1
  23. package/dist/domain/errors.js +13 -0
  24. package/dist/domain/write.d.ts +117 -0
  25. package/dist/domain/write.js +33 -0
  26. package/dist/mcp/create-server.d.ts +8 -3
  27. package/dist/mcp/create-server.js +30 -6
  28. package/dist/mcp/tools/jira-write-apply.tool.d.ts +3 -0
  29. package/dist/mcp/tools/jira-write-apply.tool.js +33 -0
  30. package/dist/mcp/tools/jira-write-plan.tool.d.ts +3 -0
  31. package/dist/mcp/tools/jira-write-plan.tool.js +57 -0
  32. package/dist/policy/write-policy.d.ts +60 -0
  33. package/dist/policy/write-policy.js +114 -0
  34. package/dist/ports/jira-write.port.d.ts +18 -4
  35. package/package.json +69 -69
@@ -0,0 +1,167 @@
1
+ import { JamError } from "../domain/errors.js";
2
+ import { assertFieldsAllowed, assertOperationAllowed, assertWriteScope, PLAN_TTL_MS, resolveTransition, } from "../policy/write-policy.js";
3
+ /**
4
+ * Work out whether a requested change is currently possible, and describe it.
5
+ *
6
+ * Reads only. Nothing here mutates Jira, and that is the whole point of the
7
+ * step: the agent gets to see what the issue looks like now, what JAM would
8
+ * do to it, and what a direct read will have to show before JAM will call it
9
+ * done - all before anything has happened.
10
+ *
11
+ * The order matters. Scope and operation are checked before any Jira call, so
12
+ * an out-of-scope key costs nothing and comes back as a JAM refusal rather
13
+ * than a 404. Everything after that is derived from the issue as Jira reports
14
+ * it right now, never from what the caller asserted about it.
15
+ */
16
+ export async function planWrite(deps, request) {
17
+ const issueKey = request.key.trim().toUpperCase();
18
+ const projectKey = assertWriteScope(issueKey, deps.config.project.key);
19
+ const operation = assertOperationAllowed(request.operation);
20
+ // Everything that can be refused from the request alone is refused here,
21
+ // before a Jira call is spent on it. An agent asking to write a field JAM
22
+ // does not write should get that answer, not a round trip and then that
23
+ // answer.
24
+ const input = validateInput(operation, request.input);
25
+ const issue = await readIssue(deps, issueKey);
26
+ const { before, intendedAfter, mutation, transition } = await describe(deps, operation, issueKey, issue, input);
27
+ const createdAt = new Date();
28
+ const plan = deps.writePlans.create({
29
+ issueKey,
30
+ projectKey,
31
+ operation,
32
+ before,
33
+ intendedAfter,
34
+ baseUpdated: issue.updated,
35
+ createdAt: createdAt.toISOString(),
36
+ expiresAt: new Date(createdAt.getTime() + PLAN_TTL_MS).toISOString(),
37
+ ...(transition ? { transition } : {}),
38
+ mutation,
39
+ });
40
+ return {
41
+ plan,
42
+ receipt: {
43
+ status: "planned",
44
+ planId: plan.planId,
45
+ issue: plan.issueKey,
46
+ operation: plan.operation,
47
+ before: plan.before,
48
+ intendedAfter: plan.intendedAfter,
49
+ expiresAt: plan.expiresAt,
50
+ verification: { method: "direct-issue-read", expects: plan.intendedAfter },
51
+ },
52
+ };
53
+ }
54
+ /**
55
+ * The issue as Jira has it, read directly by key.
56
+ *
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.
59
+ */
60
+ export async function readIssue(deps, issueKey) {
61
+ const { issues } = await deps.jira.getIssues({
62
+ keys: [issueKey],
63
+ fields: ["summary", "status", "priority", "labels", "components", "updated"],
64
+ });
65
+ const issue = issues[0];
66
+ if (!issue) {
67
+ throw new JamError("ISSUE_NOT_FOUND", `Jira has no issue ${issueKey}, or it is not visible to this account.`, { issueKey });
68
+ }
69
+ return issue;
70
+ }
71
+ /**
72
+ * Check the request against the contract, and normalize it.
73
+ *
74
+ * Pure: no Jira, no state. Whether an operation is supported, whether a field
75
+ * is writable and whether the input is even the right shape are all knowable
76
+ * without asking Jira anything, so they are answered first.
77
+ */
78
+ function validateInput(operation, raw) {
79
+ switch (operation) {
80
+ case "comment.add": {
81
+ const text = raw.text;
82
+ if (typeof text !== "string" || text.trim().length === 0) {
83
+ throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "comment.add needs non-empty `input.text`.", { operation });
84
+ }
85
+ return { text: text.trim() };
86
+ }
87
+ case "field.update":
88
+ return assertFieldsAllowed(raw);
89
+ case "status.transition": {
90
+ const status = raw.status;
91
+ if (typeof status !== "string" || status.trim().length === 0) {
92
+ throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "status.transition needs non-empty `input.status`.", { operation });
93
+ }
94
+ return { status: status.trim() };
95
+ }
96
+ }
97
+ }
98
+ async function describe(deps, operation, issueKey, issue, input) {
99
+ switch (operation) {
100
+ case "comment.add": {
101
+ const { text } = input;
102
+ // Comments accumulate rather than replace, so `before` says what is
103
+ // there now by count - the plan is not claiming to know the thread.
104
+ return {
105
+ before: { comments: issue.comments.length },
106
+ intendedAfter: { commentAdded: text },
107
+ mutation: { kind: "comment", text },
108
+ };
109
+ }
110
+ case "field.update": {
111
+ const fields = input;
112
+ const before = {};
113
+ const after = {};
114
+ for (const [field, value] of Object.entries(fields)) {
115
+ before[field] = currentValue(issue, field);
116
+ after[field] = value;
117
+ }
118
+ return {
119
+ before,
120
+ intendedAfter: after,
121
+ mutation: { kind: "fields", fields: toJiraFields(fields) },
122
+ };
123
+ }
124
+ case "status.transition": {
125
+ const { status: target } = input;
126
+ // Ask Jira what is reachable rather than deriving an id from a name:
127
+ // transition ids are per-workflow, and a guessed one either fails or
128
+ // moves the issue somewhere nobody asked for.
129
+ const available = await deps.jiraWrite.getTransitions(issueKey);
130
+ const transition = resolveTransition(target, available);
131
+ return {
132
+ before: { status: issue.status },
133
+ intendedAfter: { status: transition.to },
134
+ mutation: { kind: "transition", transitionId: transition.id },
135
+ transition,
136
+ };
137
+ }
138
+ }
139
+ }
140
+ function currentValue(issue, field) {
141
+ switch (field) {
142
+ case "summary":
143
+ return issue.summary;
144
+ case "priority":
145
+ return issue.priority;
146
+ case "labels":
147
+ return issue.labels;
148
+ case "components":
149
+ return issue.components;
150
+ default:
151
+ return undefined;
152
+ }
153
+ }
154
+ /** Whitelisted values to the shapes Jira's field API expects. */
155
+ function toJiraFields(input) {
156
+ const fields = {};
157
+ if (input.summary !== undefined)
158
+ fields["summary"] = input.summary;
159
+ if (input.priority !== undefined)
160
+ fields["priority"] = { name: input.priority };
161
+ if (input.labels !== undefined)
162
+ fields["labels"] = input.labels;
163
+ if (input.components !== undefined) {
164
+ fields["components"] = input.components.map((name) => ({ name }));
165
+ }
166
+ return fields;
167
+ }
@@ -0,0 +1,42 @@
1
+ import type { 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: Omit<WritePlan, "planId">): 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,69 @@
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
+ 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 });
49
+ }
50
+ return plan;
51
+ }
52
+ /**
53
+ * Retire a plan once it has been applied.
54
+ *
55
+ * Single use, so a receipt cannot be turned into a second mutation by
56
+ * calling apply again with the same id - which for `comment.add` would mean
57
+ * two comments.
58
+ */
59
+ consume(planId) {
60
+ this.plans.delete(planId);
61
+ }
62
+ evictExpired() {
63
+ const now = this.now();
64
+ for (const [id, plan] of this.plans) {
65
+ if (planExpired(plan.expiresAt, now))
66
+ this.plans.delete(id);
67
+ }
68
+ }
69
+ }
@@ -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 });
@@ -1,3 +1,4 @@
1
+ import { LAUNCHER_PACKAGE_SPEC } from "@jam-mcp/launcher";
1
2
  /**
2
3
  * The canonical jam entry for a project's `.mcp.json`.
3
4
  *
@@ -9,10 +10,10 @@
9
10
  * Pinned to an exact version. A floating tag would silently change what a
10
11
  * teammate's editor launches.
11
12
  */
12
- export declare const LAUNCHER_PACKAGE_SPEC = "@jam-mcp/launcher@1.0.0";
13
+ export { LAUNCHER_PACKAGE_SPEC };
13
14
  export declare const JAM_MCP_ENTRY: {
14
15
  readonly command: "npx";
15
- readonly args: readonly ["--yes", "@jam-mcp/launcher@1.0.0", "serve"];
16
+ readonly args: readonly ["--yes", "@jam-mcp/launcher@1.1.0", "serve"];
16
17
  };
17
18
  /**
18
19
  * Recognise wiring from before the launcher existed: a hard-coded path to one
@@ -48,11 +49,11 @@ export declare function inspectMcpConfig(root: string): McpInspection;
48
49
  */
49
50
  export declare function writeJamMcpEntry(root: string, entry?: unknown): string;
50
51
  /**
51
- * Merge a PATH-based JAM entry into `.mcp.json`, preserving everything else.
52
+ * Merge the launcher entry into `.mcp.json`, preserving everything else.
52
53
  *
53
- * Deliberately does NOT record an absolute path to this JAM checkout - that
54
- * would break the moment a teammate clones to a different location. `command:
55
- * "jam"` relies on `jam` being on PATH (see `jam setup`'s PATH check), which is
56
- * what keeps this file safe to commit and share.
54
+ * Deliberately records neither an absolute path to a JAM checkout nor a bare
55
+ * `jam` - the first breaks the moment a teammate clones somewhere else, and
56
+ * the second assumes an optional global install every teammate would have to
57
+ * have made. `npx` at an exact launcher version assumes only npm.
57
58
  */
58
59
  export declare function mergeMcpConfig(root: string): McpMergeResult;
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { SERVER_VERSION } from "@jam-mcp/launcher";
3
+ import { LAUNCHER_PACKAGE_SPEC } from "@jam-mcp/launcher";
4
4
  /**
5
5
  * The canonical jam entry for a project's `.mcp.json`.
6
6
  *
@@ -12,7 +12,7 @@ import { SERVER_VERSION } from "@jam-mcp/launcher";
12
12
  * Pinned to an exact version. A floating tag would silently change what a
13
13
  * teammate's editor launches.
14
14
  */
15
- export const LAUNCHER_PACKAGE_SPEC = `@jam-mcp/launcher@${SERVER_VERSION}`;
15
+ export { LAUNCHER_PACKAGE_SPEC };
16
16
  export const JAM_MCP_ENTRY = {
17
17
  command: "npx",
18
18
  args: ["--yes", LAUNCHER_PACKAGE_SPEC, "serve"],
@@ -86,12 +86,12 @@ export function writeJamMcpEntry(root, entry = JAM_MCP_ENTRY) {
86
86
  return path;
87
87
  }
88
88
  /**
89
- * Merge a PATH-based JAM entry into `.mcp.json`, preserving everything else.
89
+ * Merge the launcher entry into `.mcp.json`, preserving everything else.
90
90
  *
91
- * Deliberately does NOT record an absolute path to this JAM checkout - that
92
- * would break the moment a teammate clones to a different location. `command:
93
- * "jam"` relies on `jam` being on PATH (see `jam setup`'s PATH check), which is
94
- * what keeps this file safe to commit and share.
91
+ * Deliberately records neither an absolute path to a JAM checkout nor a bare
92
+ * `jam` - the first breaks the moment a teammate clones somewhere else, and
93
+ * the second assumes an optional global install every teammate would have to
94
+ * have made. `npx` at an exact launcher version assumes only npm.
95
95
  */
96
96
  export function mergeMcpConfig(root) {
97
97
  const path = join(root, ".mcp.json");
@@ -57,6 +57,14 @@ export type SetupPlan = {
57
57
  }[];
58
58
  /** Why a requested migration was refused, when status is JAM_MIGRATION_TARGET_UNAVAILABLE. */
59
59
  migrationTarget?: MigrationTarget;
60
+ /**
61
+ * What a person or an agent has to do next.
62
+ *
63
+ * `command` is executable on a machine with nothing installed and no runtime
64
+ * configured - so it is an `npx` bootstrap invocation, never a bare `jam`.
65
+ * A human interface is free to render the short form; this field is the one
66
+ * a script runs, and a script has no PATH to rely on.
67
+ */
60
68
  nextAction?: {
61
69
  type: "authenticate" | "select_project" | "configure_runtime";
62
70
  command?: string;
@@ -3,6 +3,7 @@ import { CONFIG_RELATIVE_PATH } from "../config/load-config.js";
3
3
  import { hostRegistration } from "./host-mcp.js";
4
4
  import { projectBindingsPath } from "./project-bindings.js";
5
5
  import { decideProjectKey } from "./project-config-bootstrapper.js";
6
+ import { portableBootstrapCommand } from "@jam-mcp/launcher";
6
7
  /**
7
8
  * Decide what setup would do, changing nothing.
8
9
  *
@@ -47,7 +48,7 @@ export function computeSetupPlan(state, options = {}) {
47
48
  code: "JAM_PROJECT_SELECTION_REQUIRED",
48
49
  changes: [],
49
50
  requiresUserAction: true,
50
- nextAction: { type: "select_project", command: "jam setup --project <KEY>" },
51
+ nextAction: { type: "select_project", command: portableBootstrapCommand("setup --project <KEY>") },
51
52
  project: { root: state.project.root },
52
53
  };
53
54
  }
@@ -122,7 +123,7 @@ function finish(changes, state, project) {
122
123
  code: "JAM_RUNTIME_CONFIG_MISSING",
123
124
  changes,
124
125
  requiresUserAction: true,
125
- nextAction: { type: "configure_runtime", command: "jam runtime use package" },
126
+ nextAction: { type: "configure_runtime", command: portableBootstrapCommand("runtime use package") },
126
127
  project,
127
128
  };
128
129
  }
@@ -133,7 +133,7 @@ async function chooseRuntime(ui, options) {
133
133
  {
134
134
  value: "package",
135
135
  label: "Use JAM",
136
- hint: "Run the project-pinned package. Recommended for most users.",
136
+ hint: "Run the published JAM release. Recommended for most users.",
137
137
  },
138
138
  { value: "development", label: "Develop JAM", hint: "Run a local source checkout." },
139
139
  ], "Run: jam runtime use package");
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,8 @@ 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 { JiraWritePort } from "./ports/jira-write.port.js";
8
+ import { WritePlanStore } from "./application/write-plan-store.js";
7
9
  import type { TelemetryPort } from "./ports/telemetry.port.js";
8
10
  /** Everything the application layer is allowed to reach for. */
9
11
  export type JamDeps = {
@@ -12,6 +14,17 @@ export type JamDeps = {
12
14
  /** Where the project key came from when no config file supplied one. */
13
15
  keySource?: BootstrapSource;
14
16
  jira: JiraReadPort;
17
+ /**
18
+ * The mutating half. Separate from `jira` on purpose: reading and writing
19
+ * have different retry rules and different confirmation rules, and one port
20
+ * that did both would blur them.
21
+ */
22
+ jiraWrite: JiraWritePort;
23
+ /**
24
+ * Plans awaiting apply. Lives for the life of this server process - see
25
+ * WritePlanStore for why it is not persisted.
26
+ */
27
+ writePlans: WritePlanStore;
15
28
  cache: CachePort;
16
29
  telemetry: TelemetryPort;
17
30
  credentials: CredentialPort;
@@ -20,6 +33,8 @@ export type BuildDepsOptions = {
20
33
  cwd?: string;
21
34
  /** Injected by tests to bypass the real REST adapter. */
22
35
  jira?: JiraReadPort;
36
+ /** Injected by tests so no test can reach a real Jira write endpoint. */
37
+ jiraWrite?: JiraWritePort;
23
38
  /** Injected by tests to bypass the real process/registry credential lookup. */
24
39
  credentials?: CredentialPort;
25
40
  /**
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,18 @@ 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
+ }
30
36
  return {
31
37
  config: resolved.config,
32
38
  configPath: resolved.configPath,
33
39
  keySource: resolved.keySource,
34
40
  jira,
41
+ jiraWrite,
42
+ writePlans: new WritePlanStore(),
35
43
  cache: new NoopCache(),
36
44
  telemetry,
37
45
  credentials,
@@ -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_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: {
@@ -17,6 +17,19 @@ export const JAM_ERROR_CODES = [
17
17
  "JIRA_UNAVAILABLE",
18
18
  "JAM_SETUP_REQUIRED",
19
19
  "JAM_BINDINGS_UNREADABLE",
20
+ // Write plane. Each names a distinct decision an agent has to make, which is
21
+ // why none of them collapse into CONFIG_INVALID: "you may not write there",
22
+ // "someone else moved it", and "we could not confirm it happened" call for
23
+ // three different next steps.
24
+ "JAM_WRITE_SCOPE_VIOLATION",
25
+ "JAM_WRITE_OPERATION_NOT_ALLOWED",
26
+ "JAM_WRITE_FIELD_NOT_ALLOWED",
27
+ "JAM_WRITE_TRANSITION_NOT_AVAILABLE",
28
+ "JAM_WRITE_PLAN_NOT_FOUND",
29
+ "JAM_WRITE_PLAN_EXPIRED",
30
+ "JAM_WRITE_CONFLICT",
31
+ "JAM_WRITE_VERIFICATION_FAILED",
32
+ "JAM_WRITE_UNCERTAIN",
20
33
  ];
21
34
  export class JamError extends Error {
22
35
  code;