@jam-mcp/server 1.5.0 → 1.7.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.
package/README.md CHANGED
@@ -74,10 +74,10 @@ auth login Store Jira credentials in this user's OS secret store
74
74
  runtime Show or change which JAM build this machine runs
75
75
  ```
76
76
 
77
- Written out, that is `npx --yes @jam-mcp/launcher@1.5.0 doctor`, or just `jam
77
+ Written out, that is `npx --yes @jam-mcp/launcher@1.7.0 doctor`, or just `jam
78
78
  doctor` if you took the launcher's optional global install. Starting from
79
79
  nothing — no install, no runtime chosen yet — use
80
- `npx --yes @jam-mcp/bootstrap@1.5.0 init` instead.
80
+ `npx --yes @jam-mcp/bootstrap@1.7.0 init` instead.
81
81
 
82
82
  Credentials come from the process environment or this user's OS secret store —
83
83
  never from a repository file — and never appear in logs, telemetry, or tool
@@ -1,7 +1,8 @@
1
1
  import { JamError, toJamError } from "../domain/errors.js";
2
2
  import { canonicalizePlainText } from "../domain/adf.js";
3
- import { assertSchemaUnchanged } from "../policy/create-policy.js";
3
+ import { assertSchemaUnchanged, CREATE_FIELD_IDS } from "../policy/create-policy.js";
4
4
  import { projectKeyOf } from "../policy/write-policy.js";
5
+ import { toJiraCreateFields, validateCreateInput } from "./plan-create-issue.js";
5
6
  import { readIssue } from "./plan-write.js";
6
7
  /**
7
8
  * Create the issue a plan describes, then go and look at what was created.
@@ -24,6 +25,11 @@ export async function applyCreateIssue(deps, plan) {
24
25
  if (plan.mutation.kind !== "create") {
25
26
  throw new JamError("CONFIG_INVALID", "A create-issue plan must carry a create mutation.");
26
27
  }
28
+ // Plans come off disk now, so the fields being sent are checked against what
29
+ // this plan's own input produces before anything is created. Editing the
30
+ // stored mutation without editing the input is caught here; editing both is
31
+ // asking JAM to plan that create, which is the supported way to get one.
32
+ assertCreateMutationMatchesInput(plan);
27
33
  await revalidateSchema(deps, plan);
28
34
  const created = await create(deps, plan);
29
35
  const { observed: after, issueId } = await verify(deps, plan, created);
@@ -45,6 +51,47 @@ export async function applyCreateIssue(deps, plan) {
45
51
  * not "is the schema identical" - on an active project it rarely is - but "are
46
52
  * this plan's premises still true". See assertSchemaUnchanged.
47
53
  */
54
+ /**
55
+ * Check the stored create fields are the ones this plan's input produces.
56
+ *
57
+ * The existing-issue path does the same thing in apply-write.ts, and for the
58
+ * same reason: a plan is a file, and re-deriving is what makes editing it
59
+ * pointless. Creation can do it without touching Jira - `toJiraCreateFields`
60
+ * is pure once the issue type and resolved values are known, and those are in
61
+ * `schemaRequirements`, which `revalidateSchema` checks against Jira right
62
+ * after this.
63
+ *
64
+ * Nothing has been created when this refuses.
65
+ */
66
+ function assertCreateMutationMatchesInput(plan) {
67
+ const { schemaRequirements: schema } = plan;
68
+ // One entry per requested value, so a component list resolves item by item -
69
+ // matching on fieldId alone would give every component the first one's answer.
70
+ const resolvedFor = (fieldId, requested) => schema.resolvedValues.find((entry) => entry.fieldId === fieldId && entry.requested === requested)
71
+ ?.resolved;
72
+ let derived;
73
+ try {
74
+ const input = validateCreateInput(plan.input);
75
+ const priority = input.priority !== undefined
76
+ ? resolvedFor(CREATE_FIELD_IDS.priority, input.priority)
77
+ : undefined;
78
+ derived = toJiraCreateFields(plan.projectKey, schema.issueTypeId, input, {
79
+ ...(priority !== undefined ? { priority } : {}),
80
+ ...(input.components !== undefined
81
+ ? {
82
+ components: input.components.map((name) => resolvedFor(CREATE_FIELD_IDS.components, name) ?? name),
83
+ }
84
+ : {}),
85
+ });
86
+ }
87
+ catch (err) {
88
+ throw new JamError("JAM_WRITE_PLAN_TAMPERED", `This create plan does not describe an issue JAM would create in ${plan.projectKey}. Nothing was created - call jira_write_plan again.`, { planId: plan.planId, project: plan.projectKey, reason: toJamError(err).code });
89
+ }
90
+ const recorded = plan.mutation.kind === "create" ? plan.mutation.fields : undefined;
91
+ if (JSON.stringify(derived) !== JSON.stringify(recorded)) {
92
+ throw new JamError("JAM_WRITE_PLAN_TAMPERED", `This create plan's recorded fields do not match what its input produces. Nothing was created - call jira_write_plan again.`, { planId: plan.planId, project: plan.projectKey });
93
+ }
94
+ }
48
95
  async function revalidateSchema(deps, plan) {
49
96
  const { projectKey, schemaRequirements } = plan;
50
97
  const issueTypes = await deps.jiraCreateMetadata.getIssueTypes(projectKey);
@@ -3,7 +3,7 @@ import { readModeAfterWrite } from "../policy/consistency-policy.js";
3
3
  import { assertAssignable } from "../policy/assignee-policy.js";
4
4
  import { assertSameIssue, assertUnchanged } from "../policy/write-policy.js";
5
5
  import { applyCreateIssue } from "./apply-create-issue.js";
6
- import { readIssue } from "./plan-write.js";
6
+ import { readIssue, toJiraFields, validateInput } from "./plan-write.js";
7
7
  /**
8
8
  * Execute a plan JAM made, then go and look at what happened.
9
9
  *
@@ -41,6 +41,11 @@ export async function applyWritePlan(deps, request) {
41
41
  // and comparing it would be answering the wrong question.
42
42
  assertSameIssue(plan.issueKey, plan.issueId, current.issueId);
43
43
  assertUnchanged(plan.issueKey, plan.baseUpdated, current.issue.updated);
44
+ // The plan came off disk, so derive its mutation again and check it is the
45
+ // one stored. This is after the revision check on purpose: a moved issue is
46
+ // a conflict, not tampering, and saying so in that order keeps the two
47
+ // situations from being reported as each other.
48
+ assertMutationMatchesInput(plan);
44
49
  // Whatever the plan depends on that the revision check cannot see, checked
45
50
  // again here. For an assignment that is the target's permission to hold this
46
51
  // issue: it can be revoked between planning and applying, and a plan that
@@ -60,6 +65,82 @@ export async function applyWritePlan(deps, request) {
60
65
  ...(outcome.commentId ? { commentId: outcome.commentId } : {}),
61
66
  };
62
67
  }
68
+ /**
69
+ * Check the stored mutation is the one this plan's input produces.
70
+ *
71
+ * Plans used to live in the process that made them, so what apply sent could
72
+ * only have come from planning. They live in a file now - shared so that a
73
+ * session whose MCP channel died can still apply one from the shell - and a
74
+ * file can be edited.
75
+ *
76
+ * What replaces the in-process guarantee is this: run the same derivation
77
+ * planning ran, on the plan's own input, and refuse if the answer differs.
78
+ * Editing `mutation` alone is caught here. Editing `input` to match means
79
+ * asking JAM to derive that mutation, which is what `jira_write_plan` is - so
80
+ * there is nothing to gain by forging a plan that could not be obtained by
81
+ * asking for one.
82
+ *
83
+ * Nothing has been sent to Jira when this refuses.
84
+ */
85
+ function assertMutationMatchesInput(plan) {
86
+ let derived;
87
+ try {
88
+ // Through validateInput first: the whitelist is part of the derivation, so
89
+ // a plan carrying a field JAM does not write is refused here rather than
90
+ // sent. Re-deriving without it would let an edited input past the check
91
+ // planning applied to it.
92
+ const input = validateInput(plan.operation, plan.input);
93
+ derived = deriveMutation(plan, input);
94
+ }
95
+ catch (err) {
96
+ // A plan whose input no longer derives anything is not a plan. Say that,
97
+ // rather than letting the original refusal read as a fresh request being
98
+ // rejected.
99
+ throw new JamError("JAM_WRITE_PLAN_TAMPERED", `This write plan does not describe a change JAM would make for ${plan.issueKey}. Nothing was written - call jira_write_plan again.`, { planId: plan.planId, issueKey: plan.issueKey, reason: toJamError(err).code });
100
+ }
101
+ if (JSON.stringify(derived) !== JSON.stringify(plan.mutation)) {
102
+ throw new JamError("JAM_WRITE_PLAN_TAMPERED", `This write plan's recorded change does not match what its input produces for ${plan.issueKey}. Nothing was written - call jira_write_plan again.`, { planId: plan.planId, issueKey: plan.issueKey });
103
+ }
104
+ }
105
+ /**
106
+ * The mutation this plan's input produces, without asking Jira anything.
107
+ *
108
+ * Planning calls Jira for two of these - the transition list, the user
109
+ * directory - and records what it settled on. Re-deriving here reads those
110
+ * recorded answers rather than fetching them again, for two reasons: apply
111
+ * would otherwise pay a round trip it does not need, and planning's lookups
112
+ * carry refusals that belong to planning (`already assigned`, `not
113
+ * assignable`). Running those a second time would report a check on the
114
+ * current state as if the plan were malformed.
115
+ *
116
+ * What is being checked is narrower and enough: the mutation must be the one
117
+ * this plan's own parts describe. An edit to `mutation` alone is caught. An
118
+ * edit that also rewrites the input and the recorded resolution is a different
119
+ * plan, obtainable by asking for one - and Jira still has to accept it.
120
+ */
121
+ function deriveMutation(plan, input) {
122
+ switch (plan.operation) {
123
+ case "comment.add":
124
+ return { kind: "comment", text: input.text };
125
+ case "field.update":
126
+ return { kind: "fields", fields: toJiraFields(input) };
127
+ case "status.transition": {
128
+ // The id came from Jira at plan time and is recorded; what is verified is
129
+ // that the plan still points at its own resolution.
130
+ const transition = plan.transition;
131
+ if (!transition)
132
+ throw new JamError("CONFIG_INVALID", "A transition plan must record its transition.");
133
+ return { kind: "transition", transitionId: transition.id };
134
+ }
135
+ case "assignee.update": {
136
+ const target = plan.intendedAfter["assignee"];
137
+ if (!target?.accountId) {
138
+ throw new JamError("CONFIG_INVALID", "An assignment plan must record who it resolved to.");
139
+ }
140
+ return { kind: "assignee", accountId: target.accountId };
141
+ }
142
+ }
143
+ }
63
144
  /**
64
145
  * Re-derive the premises the revision check does not cover.
65
146
  *
@@ -42,3 +42,14 @@ export declare function configuredProject(deps: JamDeps): string;
42
42
  * one it described.
43
43
  */
44
44
  export declare function validateCreateInput(raw: Record<string, unknown>): CreateIssueInput;
45
+ /** Whitelisted values to the shapes Jira's create API expects. */
46
+ /**
47
+ * Exported because apply derives these again and compares - see
48
+ * `assertCreateMutationMatchesInput` in apply-create-issue.ts. Planning and
49
+ * applying must produce the same fields from the same input, so they call one
50
+ * function rather than two that are supposed to agree.
51
+ */
52
+ export declare function toJiraCreateFields(projectKey: string, issueTypeId: string, input: CreateIssueInput, resolved: {
53
+ priority?: string;
54
+ components?: string[];
55
+ }): Record<string, unknown>;
@@ -86,6 +86,7 @@ export async function planCreateIssue(deps, request) {
86
86
  kind: "create",
87
87
  fields: toJiraCreateFields(projectKey, issueType.id, input, { priority, components }),
88
88
  },
89
+ input: request.input,
89
90
  });
90
91
  return {
91
92
  plan,
@@ -169,7 +170,13 @@ function notAllowed(message) {
169
170
  return new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", message, { operation: "issue.create" });
170
171
  }
171
172
  /** Whitelisted values to the shapes Jira's create API expects. */
172
- function toJiraCreateFields(projectKey, issueTypeId, input, resolved) {
173
+ /**
174
+ * Exported because apply derives these again and compares - see
175
+ * `assertCreateMutationMatchesInput` in apply-create-issue.ts. Planning and
176
+ * applying must produce the same fields from the same input, so they call one
177
+ * function rather than two that are supposed to agree.
178
+ */
179
+ export function toJiraCreateFields(projectKey, issueTypeId, input, resolved) {
173
180
  const fields = {
174
181
  project: { key: projectKey },
175
182
  issuetype: { id: issueTypeId },
@@ -1,6 +1,6 @@
1
1
  import type { JamDeps } from "../deps.js";
2
2
  import type { FullIssueContext } from "../domain/context.js";
3
- import type { WritePlan, WritePlanReceipt } from "../domain/write.js";
3
+ import type { ExistingIssueOperation, FieldUpdateInput, WriteInput, WritePlan, WritePlanReceipt } from "../domain/write.js";
4
4
  export type PlanWriteRequest = {
5
5
  /** Absent for `issue.create`, which names a project rather than an issue. */
6
6
  key?: string;
@@ -52,3 +52,14 @@ export type IssueSnapshot = {
52
52
  assigneeAccountId?: string;
53
53
  };
54
54
  export declare function readIssue(deps: JamDeps, issueKey: string): Promise<IssueSnapshot>;
55
+ /**
56
+ * Check the request against the contract, and normalize it.
57
+ *
58
+ * Pure: no Jira, no state. Whether an operation is supported, whether a field
59
+ * is writable and whether the input is even the right shape are all knowable
60
+ * without asking Jira anything, so they are answered first.
61
+ */
62
+ export declare function validateInput(operation: ExistingIssueOperation, raw: Record<string, unknown>): WriteInput;
63
+ /** Whitelisted values to the shapes Jira's field API expects. */
64
+ /** Exported so apply can derive the same fields again and compare. */
65
+ export declare function toJiraFields(input: FieldUpdateInput): Record<string, unknown>;
@@ -49,6 +49,7 @@ export async function planWrite(deps, request) {
49
49
  ...(transition ? { transition } : {}),
50
50
  ...(baseAssigneeAccountId ? { baseAssigneeAccountId } : {}),
51
51
  mutation,
52
+ input: input,
52
53
  });
53
54
  return {
54
55
  plan,
@@ -125,7 +126,7 @@ function requireIssueKey(request) {
125
126
  * is writable and whether the input is even the right shape are all knowable
126
127
  * without asking Jira anything, so they are answered first.
127
128
  */
128
- function validateInput(operation, raw) {
129
+ export function validateInput(operation, raw) {
129
130
  switch (operation) {
130
131
  case "comment.add": {
131
132
  const text = raw.text;
@@ -256,7 +257,8 @@ function currentValue(issue, field) {
256
257
  }
257
258
  }
258
259
  /** Whitelisted values to the shapes Jira's field API expects. */
259
- function toJiraFields(input) {
260
+ /** Exported so apply can derive the same fields again and compare. */
261
+ export function toJiraFields(input) {
260
262
  const fields = {};
261
263
  if (input.summary !== undefined)
262
264
  fields["summary"] = input.summary;
@@ -1,29 +1,25 @@
1
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
- */
2
+ export type WritePlanStoreOptions = {
3
+ /** Injected by tests so expiry does not depend on wall-clock timing. */
4
+ now?: () => Date;
5
+ /** Injected by tests so no suite writes into the real `~/.jam`. */
6
+ root?: string;
7
+ };
19
8
  export declare class WritePlanStore {
20
9
  private readonly now;
21
- private readonly plans;
22
- /** Injected by tests so expiry does not depend on wall-clock timing. */
23
- constructor(now?: () => Date);
10
+ private readonly root;
11
+ constructor(options?: WritePlanStoreOptions | (() => Date));
24
12
  create(plan: NewWritePlan): WritePlan;
25
13
  /**
26
- * Resolve a plan for applying.
14
+ * Resolve a plan for applying, and claim it in the same step.
15
+ *
16
+ * The claim is a rename, because two processes can now reach the same plan
17
+ * and `rename` is the only thing here that is atomic. Whoever renames it owns
18
+ * the apply; everyone else sees the file already gone and is told there is no
19
+ * such plan - which is the truth, for them.
20
+ *
21
+ * Reading and then deleting would leave a window between the two, and for
22
+ * `comment.add` that window is a second comment.
27
23
  *
28
24
  * An expired plan is reported as expired rather than as missing: those are
29
25
  * different situations, and telling them apart is the difference between
@@ -35,8 +31,18 @@ export declare class WritePlanStore {
35
31
  *
36
32
  * Single use, so a receipt cannot be turned into a second mutation by
37
33
  * calling apply again with the same id - which for `comment.add` would mean
38
- * two comments.
34
+ * two comments. `take` already made that true by claiming the file; this
35
+ * removes the claim so it does not sit until expiry.
39
36
  */
40
37
  consume(planId: string): void;
38
+ private notFound;
39
+ /** `<planId><suffix>`, with the id checked so it can only name a file here. */
40
+ private pathFor;
41
+ private ensureRoot;
42
+ /**
43
+ * Drop what has aged out, including claims abandoned by a process that died
44
+ * mid-apply. Not retrying such a claim is deliberate: apply may already have
45
+ * reached Jira, and that is `JAM_WRITE_UNCERTAIN`'s whole posture.
46
+ */
41
47
  private evictExpired;
42
48
  }
@@ -1,50 +1,92 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { JamError } from "../domain/errors.js";
3
6
  import { planExpired } from "../policy/write-policy.js";
4
7
  /**
5
- * Where a plan lives between `jira_write_plan` and `jira_write_apply`.
8
+ * Where a plan lives between planning and applying.
6
9
  *
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.
10
+ * It used to live in the server process and nowhere else, and the ADR said why:
11
+ * a signed token needs a signing key from somewhere, and keeping the mutation
12
+ * in memory made forgery impossible rather than merely hard.
14
13
  *
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.
14
+ * The same ADR named the condition for revisiting that - "if plans ever need to
15
+ * outlive a process". They do now. `jam jira write-plan` and `jam jira
16
+ * write-apply` are two processes, so an in-memory plan is gone before apply can
17
+ * see it, and a session whose MCP registry is stale has no way to write at all.
18
+ *
19
+ * So a plan is a file under `~/.jam/write-plans/`, shared by both transports.
20
+ * MCP and CLI read the same store, which means a plan made through the tools
21
+ * can be applied from the shell when the tools stop answering - that is the
22
+ * whole point of the change.
23
+ *
24
+ * What replaces the in-process guarantee is in apply, not here: it re-derives
25
+ * the mutation from the plan's own inputs and refuses if the stored mutation
26
+ * disagrees. Editing the file to smuggle a different write requires editing the
27
+ * inputs to match, which is just calling plan again. See apply-write.ts.
18
28
  *
19
29
  * See docs/decisions/adr-jira-write-plane.md.
20
30
  */
31
+ /** One plan, one file. The suffix is the claim: see `take`. */
32
+ const PENDING = ".json";
33
+ const CLAIMED = ".claimed";
21
34
  export class WritePlanStore {
22
35
  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;
36
+ root;
37
+ constructor(options = {}) {
38
+ // The old signature took `now` positionally and tests still use it.
39
+ const opts = typeof options === "function" ? { now: options } : options;
40
+ this.now = opts.now ?? (() => new Date());
41
+ this.root = opts.root ?? join(homedir(), ".jam", "write-plans");
27
42
  }
28
43
  create(plan) {
29
44
  this.evictExpired();
30
45
  const stored = { ...plan, planId: randomUUID() };
31
- this.plans.set(stored.planId, stored);
46
+ this.ensureRoot();
47
+ const path = this.pathFor(stored.planId, PENDING);
48
+ writeFileSync(path, JSON.stringify(stored), "utf8");
49
+ // Owner-only. A plan names an issue and what would be written to it, and
50
+ // nothing else on the machine needs to read that.
51
+ chmodSync(path, 0o600);
32
52
  return stored;
33
53
  }
34
54
  /**
35
- * Resolve a plan for applying.
55
+ * Resolve a plan for applying, and claim it in the same step.
56
+ *
57
+ * The claim is a rename, because two processes can now reach the same plan
58
+ * and `rename` is the only thing here that is atomic. Whoever renames it owns
59
+ * the apply; everyone else sees the file already gone and is told there is no
60
+ * such plan - which is the truth, for them.
61
+ *
62
+ * Reading and then deleting would leave a window between the two, and for
63
+ * `comment.add` that window is a second comment.
36
64
  *
37
65
  * An expired plan is reported as expired rather than as missing: those are
38
66
  * different situations, and telling them apart is the difference between
39
67
  * "re-plan" and "you are calling this wrong".
40
68
  */
41
69
  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 });
70
+ const pending = this.pathFor(planId, PENDING);
71
+ const claimed = this.pathFor(planId, CLAIMED);
72
+ try {
73
+ renameSync(pending, claimed);
74
+ }
75
+ catch {
76
+ throw this.notFound(planId);
77
+ }
78
+ let plan;
79
+ try {
80
+ plan = JSON.parse(readFileSync(claimed, "utf8"));
81
+ }
82
+ catch {
83
+ // Unreadable is not "someone else has it" - the file is ours now and it
84
+ // is unusable, so retire it rather than leaving it to expire.
85
+ rmSync(claimed, { force: true });
86
+ throw this.notFound(planId);
45
87
  }
46
88
  if (planExpired(plan.expiresAt, this.now())) {
47
- this.plans.delete(planId);
89
+ rmSync(claimed, { force: true });
48
90
  // What to re-plan against differs by plan: an existing issue has a
49
91
  // current state, a create has only the project's current create schema.
50
92
  // Naming an issue key here for a create would name an issue that has
@@ -66,16 +108,50 @@ export class WritePlanStore {
66
108
  *
67
109
  * Single use, so a receipt cannot be turned into a second mutation by
68
110
  * calling apply again with the same id - which for `comment.add` would mean
69
- * two comments.
111
+ * two comments. `take` already made that true by claiming the file; this
112
+ * removes the claim so it does not sit until expiry.
70
113
  */
71
114
  consume(planId) {
72
- this.plans.delete(planId);
115
+ rmSync(this.pathFor(planId, CLAIMED), { force: true });
73
116
  }
117
+ notFound(planId) {
118
+ return new JamError("JAM_WRITE_PLAN_NOT_FOUND", "No such write plan. A plan is single-use and expires - call jira_write_plan (or `jam jira write-plan`) again.", { planId });
119
+ }
120
+ /** `<planId><suffix>`, with the id checked so it can only name a file here. */
121
+ pathFor(planId, suffix) {
122
+ if (!/^[0-9a-fA-F-]{1,64}$/.test(planId)) {
123
+ throw this.notFound(planId);
124
+ }
125
+ return join(this.root, `${planId}${suffix}`);
126
+ }
127
+ ensureRoot() {
128
+ if (!existsSync(this.root))
129
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
130
+ }
131
+ /**
132
+ * Drop what has aged out, including claims abandoned by a process that died
133
+ * mid-apply. Not retrying such a claim is deliberate: apply may already have
134
+ * reached Jira, and that is `JAM_WRITE_UNCERTAIN`'s whole posture.
135
+ */
74
136
  evictExpired() {
137
+ if (!existsSync(this.root))
138
+ return;
75
139
  const now = this.now();
76
- for (const [id, plan] of this.plans) {
77
- if (planExpired(plan.expiresAt, now))
78
- this.plans.delete(id);
140
+ for (const name of readdirSync(this.root)) {
141
+ if (!name.endsWith(PENDING) && !name.endsWith(CLAIMED))
142
+ continue;
143
+ const path = join(this.root, name);
144
+ try {
145
+ const plan = JSON.parse(readFileSync(path, "utf8"));
146
+ if (planExpired(plan.expiresAt, now))
147
+ rmSync(path, { force: true });
148
+ }
149
+ catch {
150
+ // Unparseable leftovers are not plans. Removing them keeps the
151
+ // directory from growing without bound, and nothing is lost: a plan
152
+ // that cannot be read cannot be applied either.
153
+ rmSync(path, { force: true });
154
+ }
79
155
  }
80
156
  }
81
157
  }
@@ -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.5.0", "serve"];
16
+ readonly args: readonly ["--yes", "@jam-mcp/launcher@1.7.0", "serve"];
17
17
  };
18
18
  /**
19
19
  * Recognise wiring from before the launcher existed: a hard-coded `node` path
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Writing Jira without the MCP channel.
3
+ *
4
+ * The companion to cli/jira-read.ts, for the same reason and with the same
5
+ * shape. A session whose MCP registry came up in a failed state cannot be shown
6
+ * the tools - Claude Code has no reload surface for a running session - so JAM,
7
+ * its credentials and its Jira access can all be healthy while `jira_write_plan`
8
+ * is simply unreachable. Reads were addressed to the shell first; this is the
9
+ * other half.
10
+ *
11
+ * Nothing here is a second write path. `write-plan` and `write-apply` call the
12
+ * same `planWrite` / `applyWritePlan` the tools call, with the same deps, the
13
+ * same project binding, the same whitelist and the same plan store - so a plan
14
+ * made through MCP can be applied here, which is the point when MCP is what
15
+ * broke.
16
+ *
17
+ * The two-call contract is not relaxed for the shell:
18
+ *
19
+ * write-plan reads Jira, decides what is possible, writes nothing
20
+ * write-apply takes a planId and nothing else, then reads the result back
21
+ *
22
+ * There is deliberately no `jam jira comment` or `jam jira close`. A shortcut
23
+ * that skips planning is exactly what the write plane exists to prevent, and
24
+ * being on a terminal does not change that.
25
+ *
26
+ * Contract, enforced by tests:
27
+ * stdout - one JSON document and nothing else, no ANSI, no prompts
28
+ * stderr - diagnostics only
29
+ *
30
+ * `write-apply` never asks for confirmation. What to write was settled when the
31
+ * plan was made; a prompt here would be a second approval step, which is a
32
+ * different feature and not this one.
33
+ */
34
+ import { type BuildDepsOptions, type JamDeps } from "../deps.js";
35
+ export declare const JIRA_WRITE_USAGE: string;
36
+ export type JiraWriteOptions = BuildDepsOptions & {
37
+ /** Injected by tests so no test reaches a real Jira. */
38
+ deps?: JamDeps;
39
+ /** Where the JSON document goes. Defaults to stdout. */
40
+ write?: (text: string) => void;
41
+ /** Where diagnostics go. Defaults to stderr. */
42
+ warn?: (text: string) => void;
43
+ };
44
+ export declare function runJiraWrite(argv: readonly string[], options?: JiraWriteOptions): Promise<number>;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Writing Jira without the MCP channel.
3
+ *
4
+ * The companion to cli/jira-read.ts, for the same reason and with the same
5
+ * shape. A session whose MCP registry came up in a failed state cannot be shown
6
+ * the tools - Claude Code has no reload surface for a running session - so JAM,
7
+ * its credentials and its Jira access can all be healthy while `jira_write_plan`
8
+ * is simply unreachable. Reads were addressed to the shell first; this is the
9
+ * other half.
10
+ *
11
+ * Nothing here is a second write path. `write-plan` and `write-apply` call the
12
+ * same `planWrite` / `applyWritePlan` the tools call, with the same deps, the
13
+ * same project binding, the same whitelist and the same plan store - so a plan
14
+ * made through MCP can be applied here, which is the point when MCP is what
15
+ * broke.
16
+ *
17
+ * The two-call contract is not relaxed for the shell:
18
+ *
19
+ * write-plan reads Jira, decides what is possible, writes nothing
20
+ * write-apply takes a planId and nothing else, then reads the result back
21
+ *
22
+ * There is deliberately no `jam jira comment` or `jam jira close`. A shortcut
23
+ * that skips planning is exactly what the write plane exists to prevent, and
24
+ * being on a terminal does not change that.
25
+ *
26
+ * Contract, enforced by tests:
27
+ * stdout - one JSON document and nothing else, no ANSI, no prompts
28
+ * stderr - diagnostics only
29
+ *
30
+ * `write-apply` never asks for confirmation. What to write was settled when the
31
+ * plan was made; a prompt here would be a second approval step, which is a
32
+ * different feature and not this one.
33
+ */
34
+ import { applyWritePlan } from "../application/apply-write.js";
35
+ import { planWrite } from "../application/plan-write.js";
36
+ import { buildDeps } from "../deps.js";
37
+ import { toJamError } from "../domain/errors.js";
38
+ import { WRITE_OPERATIONS } from "../domain/write.js";
39
+ export const JIRA_WRITE_USAGE = `Usage:
40
+ jam jira write-plan --operation <op> [--key KEY] --input '<json>'
41
+ jam jira write-apply <planId>
42
+
43
+ Operations: ${WRITE_OPERATIONS.join(", ")}
44
+ issue.create takes no --key; every other operation needs one.
45
+
46
+ Two calls, always. write-plan changes nothing and returns a planId; write-apply
47
+ takes that planId and nothing else. Output is one JSON document on stdout - the
48
+ same receipt the MCP tools return, for a session that cannot see them.
49
+ `;
50
+ /** `--flag value` / `--flag=value`, and nothing invented when absent. */
51
+ function flagValue(argv, flag) {
52
+ const index = argv.indexOf(flag);
53
+ if (index >= 0)
54
+ return argv[index + 1];
55
+ const inline = argv.find((arg) => arg.startsWith(`${flag}=`));
56
+ return inline ? inline.slice(flag.length + 1) : undefined;
57
+ }
58
+ const FLAGS_WITH_VALUES = ["--operation", "--key", "--input"];
59
+ const positional = (argv) => {
60
+ const out = [];
61
+ for (let i = 0; i < argv.length; i += 1) {
62
+ const arg = argv[i];
63
+ if (FLAGS_WITH_VALUES.includes(arg)) {
64
+ i += 1;
65
+ continue;
66
+ }
67
+ if (arg.startsWith("--"))
68
+ continue;
69
+ out.push(arg);
70
+ }
71
+ return out;
72
+ };
73
+ export async function runJiraWrite(argv, options = {}) {
74
+ const write = options.write ?? ((text) => process.stdout.write(text));
75
+ const warn = options.warn ?? ((text) => process.stderr.write(text));
76
+ const [subcommand, ...rest] = argv;
77
+ if (subcommand !== "write-plan" && subcommand !== "write-apply") {
78
+ warn(`Unknown jira command: ${subcommand ?? "(none)"}\n\n${JIRA_WRITE_USAGE}`);
79
+ return 1;
80
+ }
81
+ // Argument shape is checked here; what the values mean is checked by the
82
+ // application, which is the same judgement the tools get. This file does not
83
+ // decide what a valid operation or input is.
84
+ let request;
85
+ if (subcommand === "write-plan") {
86
+ const operation = flagValue(rest, "--operation");
87
+ if (!operation) {
88
+ warn(`jam jira write-plan needs --operation.\n\n${JIRA_WRITE_USAGE}`);
89
+ return 1;
90
+ }
91
+ const raw = flagValue(rest, "--input");
92
+ if (raw === undefined) {
93
+ warn(`jam jira write-plan needs --input '<json>'.\n\n${JIRA_WRITE_USAGE}`);
94
+ return 1;
95
+ }
96
+ let input;
97
+ try {
98
+ input = JSON.parse(raw);
99
+ }
100
+ catch {
101
+ warn("--input must be a JSON object.\n");
102
+ return 1;
103
+ }
104
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
105
+ warn("--input must be a JSON object.\n");
106
+ return 1;
107
+ }
108
+ const key = flagValue(rest, "--key");
109
+ request = {
110
+ kind: "plan",
111
+ ...(key ? { key } : {}),
112
+ operation,
113
+ input: input,
114
+ };
115
+ }
116
+ else {
117
+ // Exactly one positional and no way to pass anything else. There is no
118
+ // payload flag here, and adding one would be adding a way to write
119
+ // something the plan did not describe.
120
+ const [planId, ...extra] = positional(rest);
121
+ if (!planId || extra.length > 0) {
122
+ warn(`jam jira write-apply takes exactly one planId.\n\n${JIRA_WRITE_USAGE}`);
123
+ return 1;
124
+ }
125
+ request = { kind: "apply", planId };
126
+ }
127
+ try {
128
+ const { deps: injected, write: _w, warn: _n, ...depsOptions } = options;
129
+ const deps = injected ?? (await buildDeps(depsOptions));
130
+ const result = request.kind === "plan"
131
+ ? (await planWrite(deps, {
132
+ ...(request.key !== undefined ? { key: request.key } : {}),
133
+ operation: request.operation,
134
+ input: request.input,
135
+ })).receipt
136
+ : await applyWritePlan(deps, { planId: request.planId });
137
+ write(`${JSON.stringify(result)}\n`);
138
+ return 0;
139
+ }
140
+ catch (err) {
141
+ // The same normalized codes the tools produce - an agent reads one contract,
142
+ // not two.
143
+ write(`${JSON.stringify(toJamError(err).toPayload())}\n`);
144
+ return 1;
145
+ }
146
+ }
@@ -0,0 +1,66 @@
1
+ import { type HostId, type HostRunner, type HostState } from "../bootstrap/host-mcp.js";
2
+ export type LifecycleOptions = {
3
+ json?: boolean;
4
+ /** Injected by tests. Nothing here may reach a real host CLI or npm unasked. */
5
+ run?: HostRunner;
6
+ hosts?: () => HostState[];
7
+ };
8
+ export type RefreshHostPlan = {
9
+ id: HostId;
10
+ from?: string;
11
+ bare: boolean;
12
+ action: "repin" | "none";
13
+ };
14
+ export type RefreshPlan = {
15
+ /** The version this build is. `refresh` never moves off it - that is `update`. */
16
+ version: string;
17
+ hosts: RefreshHostPlan[];
18
+ steps: readonly string[];
19
+ };
20
+ /**
21
+ * What `refresh` would converge. Pure: it runs nothing and writes nothing.
22
+ *
23
+ * The scope is deliberately small. A registration that points at a different
24
+ * launcher than this build is what goes stale here. The project binding, the
25
+ * credentials, `~/.jam/config.yaml` and every byte of Jira data are outside
26
+ * it - re-deciding those is `setup`, and calling it "refresh" is how a person
27
+ * loses a binding they never asked to change.
28
+ */
29
+ export declare function planRefresh(hosts: readonly HostState[], version?: string): RefreshPlan;
30
+ export declare function refreshLine(plan: RefreshPlan): string;
31
+ /**
32
+ * `jam refresh` - keep the version, make the registration match it again.
33
+ *
34
+ * Nothing is removed before the replacement is known to work, which is the
35
+ * same order `update` uses and for the same reason: a failed refresh leaves
36
+ * the machine running what it was running.
37
+ */
38
+ export declare function jamRefreshCommand(command: string | undefined, options?: LifecycleOptions): Promise<number>;
39
+ export type UninstallPlan = {
40
+ /** Registrations that would be removed. */
41
+ hosts: HostId[];
42
+ /** Whether a global launcher install would be removed with it. */
43
+ runtime: string | null;
44
+ preserve: string[];
45
+ };
46
+ /**
47
+ * `jam uninstall` - remove what JAM installed, keep what is the person's.
48
+ *
49
+ * Removed: the MCP registrations JAM wrote, and the global launcher when one
50
+ * is installed. Kept: `~/.jam` in full - the project bindings, the runtime
51
+ * choice and the credentials in the OS secret store. There is no purge here
52
+ * on purpose: nothing yet needs a command that destroys those, and an
53
+ * irreversible one that nobody asked for is worse than a missing one.
54
+ */
55
+ export declare function planUninstall(hosts: readonly HostState[], installed: string | null): UninstallPlan;
56
+ export declare function jamUninstallCommand(command: string | undefined, options?: LifecycleOptions): Promise<number>;
57
+ /**
58
+ * `jam status` - the first place a person asks what is going on.
59
+ *
60
+ * This is `doctor`'s user-facing role under the name both products use. The
61
+ * judgement is not reimplemented: the same health gate and the same per-axis
62
+ * verdicts answer here, so the two commands can never disagree.
63
+ */
64
+ export declare function jamStatusCommand(options?: {
65
+ json?: boolean;
66
+ }): Promise<number>;
@@ -0,0 +1,233 @@
1
+ import { LAUNCHER_PACKAGE, SERVER_VERSION } from "@jam-mcp/launcher";
2
+ import { spawnSync } from "node:child_process";
3
+ import { doctorJsonCommand } from "./agent-api.js";
4
+ import { doctor } from "./doctor.js";
5
+ import { detectHosts, hostRegistration, hostUnregistration, } from "../bootstrap/host-mcp.js";
6
+ /**
7
+ * The lifecycle words JAM shares with ASC: `refresh` and `uninstall`.
8
+ *
9
+ * The two products answer to the same vocabulary because a person should not
10
+ * have to remember which one uses which verb:
11
+ *
12
+ * setup make it usable for the first time
13
+ * status what is configured, what works, what is blocked
14
+ * update move to a newer published release
15
+ * refresh keep the version, re-converge what this build registered
16
+ * uninstall remove the product; the person's state stays
17
+ * runtime which build this machine actually runs
18
+ *
19
+ * What is different is ownership, and that stays different. JAM is the Jira
20
+ * access layer: it has no execution mode, no approval path and no session
21
+ * model. A Jira write still travels ASC's decision path and lands through
22
+ * JAM's own write plan/apply - this file does not change that.
23
+ */
24
+ /** A runner with room for npm. The host runner's 20s is not enough for an install. */
25
+ const defaultRunner = ({ command, args }) => {
26
+ const result = spawnSync(command, args, {
27
+ encoding: "utf8",
28
+ timeout: 180_000,
29
+ shell: process.platform === "win32",
30
+ });
31
+ if (result.error)
32
+ return { status: null, failed: true, stdout: "" };
33
+ return { status: result.status, failed: false, stdout: result.stdout ?? "" };
34
+ };
35
+ /**
36
+ * What `refresh` would converge. Pure: it runs nothing and writes nothing.
37
+ *
38
+ * The scope is deliberately small. A registration that points at a different
39
+ * launcher than this build is what goes stale here. The project binding, the
40
+ * credentials, `~/.jam/config.yaml` and every byte of Jira data are outside
41
+ * it - re-deciding those is `setup`, and calling it "refresh" is how a person
42
+ * loses a binding they never asked to change.
43
+ */
44
+ export function planRefresh(hosts, version = SERVER_VERSION) {
45
+ const registered = hosts.filter((host) => host.cliAvailable && host.hasJamEntry);
46
+ const plans = registered.map((host) => ({
47
+ id: host.id,
48
+ ...(host.entryVersion ? { from: host.entryVersion } : {}),
49
+ bare: host.entryBare === true,
50
+ // A bare entry runs the global executable, so its line is already whatever
51
+ // that executable is. Only a pinned line can point somewhere else.
52
+ action: host.entryBare === true || host.entryVersion === version ? "none" : "repin",
53
+ }));
54
+ const moving = plans.filter((host) => host.action === "repin");
55
+ return {
56
+ version,
57
+ hosts: plans,
58
+ steps: moving.length === 0 ? [] : ["switch-registration", "verify"],
59
+ };
60
+ }
61
+ export function refreshLine(plan) {
62
+ const moving = plan.hosts.filter((host) => host.action === "repin");
63
+ if (plan.hosts.length === 0)
64
+ return "No host has a JAM registration - `jam setup` is what adds one.";
65
+ return moving.length === 0
66
+ ? `Registration is current - JAM ${plan.version}. Version unchanged.`
67
+ : `Would re-register: ${moving.map((host) => `${host.id} runs ${host.from ?? "?"}`).join(", ")}`;
68
+ }
69
+ /**
70
+ * `jam refresh` - keep the version, make the registration match it again.
71
+ *
72
+ * Nothing is removed before the replacement is known to work, which is the
73
+ * same order `update` uses and for the same reason: a failed refresh leaves
74
+ * the machine running what it was running.
75
+ */
76
+ export async function jamRefreshCommand(command, options = {}) {
77
+ if (command !== undefined && command !== "check" && command !== "plan") {
78
+ process.stderr.write(`Unknown refresh command: ${command}\nUsage: jam refresh [check|plan] [--json]\n`);
79
+ return 1;
80
+ }
81
+ const run = options.run ?? defaultRunner;
82
+ const hosts = (options.hosts ?? (() => detectHosts(run)))();
83
+ const plan = planRefresh(hosts);
84
+ if (command === "check" || command === "plan") {
85
+ if (options.json)
86
+ process.stdout.write(`${JSON.stringify({ package: LAUNCHER_PACKAGE, ...plan }, null, 2)}\n`);
87
+ else
88
+ process.stdout.write(`${refreshLine(plan)}\n`);
89
+ return 0;
90
+ }
91
+ if (plan.steps.length === 0) {
92
+ if (options.json)
93
+ process.stdout.write(`${JSON.stringify({ package: LAUNCHER_PACKAGE, ...plan, changed: [] }, null, 2)}\n`);
94
+ else
95
+ process.stdout.write(`${refreshLine(plan)}\n`);
96
+ return 0;
97
+ }
98
+ const changed = [];
99
+ for (const host of plan.hosts.filter((h) => h.action === "repin")) {
100
+ // `mcp add` over an existing entry changes nothing on Claude Code - it
101
+ // answers "already exists". The removal is what makes the re-pin land.
102
+ const remove = hostUnregistration(host.id);
103
+ if (remove)
104
+ run(remove);
105
+ const register = hostRegistration(host.id, { version: plan.version });
106
+ if (!register)
107
+ continue;
108
+ const result = run(register);
109
+ if (result.failed || result.status !== 0) {
110
+ process.stderr.write(`refresh failed on ${host.id} - re-register with \`jam setup --agent\`.\n`);
111
+ return 1;
112
+ }
113
+ changed.push(host.id);
114
+ }
115
+ // Read it back. A registration JAM could not verify is never reported as done.
116
+ const after = (options.hosts ?? (() => detectHosts(run)))();
117
+ const stale = after.filter((host) => changed.includes(host.id) && host.entryBare !== true && host.entryVersion !== plan.version);
118
+ if (stale.length > 0) {
119
+ process.stderr.write(`health: ${stale.map((host) => `${host.id} still runs ${host.entryVersion ?? "?"}`).join(", ")}\n`);
120
+ return 1;
121
+ }
122
+ if (options.json) {
123
+ process.stdout.write(`${JSON.stringify({ package: LAUNCHER_PACKAGE, ...plan, changed }, null, 2)}\n`);
124
+ }
125
+ else {
126
+ for (const id of changed)
127
+ process.stdout.write(`registered: ${id} -> ${plan.version}\n`);
128
+ process.stdout.write(`JAM ${plan.version} is registered. Version unchanged.\n`);
129
+ }
130
+ return 0;
131
+ }
132
+ /**
133
+ * `jam uninstall` - remove what JAM installed, keep what is the person's.
134
+ *
135
+ * Removed: the MCP registrations JAM wrote, and the global launcher when one
136
+ * is installed. Kept: `~/.jam` in full - the project bindings, the runtime
137
+ * choice and the credentials in the OS secret store. There is no purge here
138
+ * on purpose: nothing yet needs a command that destroys those, and an
139
+ * irreversible one that nobody asked for is worse than a missing one.
140
+ */
141
+ export function planUninstall(hosts, installed) {
142
+ return {
143
+ hosts: hosts.filter((host) => host.cliAvailable && host.hasJamEntry).map((host) => host.id),
144
+ runtime: installed,
145
+ preserve: [
146
+ "~/.jam/projects.yaml - which project each checkout is bound to",
147
+ "~/.jam/config.yaml - the runtime this machine chose",
148
+ "Jira credentials in the OS secret store",
149
+ "every .jira-agent/project.yaml a repository carries",
150
+ ],
151
+ };
152
+ }
153
+ export async function jamUninstallCommand(command, options = {}) {
154
+ if (command !== undefined && command !== "plan") {
155
+ process.stderr.write(`Unknown uninstall command: ${command}\nUsage: jam uninstall [plan] [--json]\n`);
156
+ return 1;
157
+ }
158
+ const run = options.run ?? defaultRunner;
159
+ const hosts = (options.hosts ?? (() => detectHosts(run)))();
160
+ const installed = globalLauncher(run);
161
+ const plan = planUninstall(hosts, installed);
162
+ if (command === "plan") {
163
+ if (options.json)
164
+ process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
165
+ else {
166
+ process.stdout.write(plan.hosts.length === 0
167
+ ? "No host registration to remove.\n"
168
+ : `Would remove the JAM registration from: ${plan.hosts.join(", ")}\n`);
169
+ if (plan.runtime)
170
+ process.stdout.write(`Would remove ${LAUNCHER_PACKAGE}@${plan.runtime}\n`);
171
+ for (const kept of plan.preserve)
172
+ process.stdout.write(`Would keep: ${kept}\n`);
173
+ }
174
+ return 0;
175
+ }
176
+ let worst = 0;
177
+ for (const id of plan.hosts) {
178
+ const remove = hostUnregistration(id);
179
+ if (!remove)
180
+ continue;
181
+ const result = run(remove);
182
+ if (result.failed || result.status !== 0) {
183
+ process.stderr.write(`could not remove the registration from ${id}\n`);
184
+ worst = 1;
185
+ continue;
186
+ }
187
+ process.stdout.write(`removed: ${id} registration\n`);
188
+ }
189
+ if (plan.runtime) {
190
+ const removal = run({ command: "npm", args: ["uninstall", "-g", LAUNCHER_PACKAGE] });
191
+ if (removal.failed || removal.status !== 0) {
192
+ process.stderr.write(`could not remove ${LAUNCHER_PACKAGE} - run: npm uninstall -g ${LAUNCHER_PACKAGE}\n`);
193
+ worst = 1;
194
+ }
195
+ else {
196
+ process.stdout.write(`removed: ${LAUNCHER_PACKAGE}@${plan.runtime}\n`);
197
+ }
198
+ }
199
+ process.stdout.write("\nYour state stays:\n");
200
+ for (const kept of plan.preserve)
201
+ process.stdout.write(` ${kept}\n`);
202
+ return worst;
203
+ }
204
+ /** The globally installed launcher version, or null when there is none. */
205
+ function globalLauncher(run) {
206
+ const result = run({ command: "npm", args: ["ls", "-g", "--depth=0", "--json", LAUNCHER_PACKAGE] });
207
+ if (result.failed)
208
+ return null;
209
+ try {
210
+ const parsed = JSON.parse(result.stdout);
211
+ return parsed.dependencies?.[LAUNCHER_PACKAGE]?.version ?? null;
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ }
217
+ /**
218
+ * `jam status` - the first place a person asks what is going on.
219
+ *
220
+ * This is `doctor`'s user-facing role under the name both products use. The
221
+ * judgement is not reimplemented: the same health gate and the same per-axis
222
+ * verdicts answer here, so the two commands can never disagree.
223
+ */
224
+ export async function jamStatusCommand(options = {}) {
225
+ if (options.json)
226
+ return doctorJsonCommand();
227
+ process.stdout.write(`jam ${SERVER_VERSION}\n`);
228
+ const code = await doctor();
229
+ process.stdout.write(code === 0
230
+ ? "\nNext: nothing - reading Jira is ready. `jam update` when a newer release is out.\n"
231
+ : "\nNext: `jam setup` binds this project and stores what is missing. `jam refresh` only re-registers.\n");
232
+ return code;
233
+ }
@@ -3,5 +3,5 @@
3
3
  * points (notably @jam-mcp/bootstrap) can forward to exactly these commands
4
4
  * instead of reimplementing them.
5
5
  */
6
- export declare const USAGE = "jam - Jira Agent MCP\n\nUsage:\n jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)\n jam doctor Diagnose config, credentials and Jira connectivity\n jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]\n Wire up this project and run doctor. Binds it to you\n alone, writing nothing to the repository; --shared\n adopts JAM for the team (project.yaml, .mcp.json)\n jam update Move this machine's registration to the published release\n jam update check What is registered, what is published (changes nothing)\n jam runtime Show which JAM build this machine runs\n jam runtime use package | development <path>\n Change it (writes ~/.jam/config.yaml only, never a project)\n jam auth login Store Jira credentials in this user's OS secret store\n jam auth logout Remove them again\n\nFor coding agents and scripts (stdout is JSON only, never prompts):\n jam setup --agent One shot: detect, plan, apply what is safe, verify\n jam setup plan --json Report what setup would change, changing nothing\n jam setup apply --non-interactive --json\n Execute the plan\n jam doctor --json Health check as structured output\n jam auth status --json Whether Jira credentials are configured (never their value)\n jam jira search <jql> [--scope preview|complete]\n jam jira context <KEY> [KEY...]\n jam jira full <KEY> [KEY...]\n Read Jira from the shell - the same reads the MCP\n tools do, for a session that cannot see them yet\n\nEnvironment:\n JIRA_BASE_URL https://your-site.atlassian.net\n JIRA_EMAIL Atlassian account email\n JIRA_API_TOKEN Atlassian API token\n JAM_PROJECT_KEY Jira project key, used by `jam setup`/`jam serve` when no\n .jira-agent/project.yaml exists yet\n\nCredentials and JAM_PROJECT_KEY are read from the current shell's environment\nfirst, then (on Windows) from the User environment - so a value set with\n`setx` works without opening a new terminal.\n";
6
+ export declare const USAGE = "jam - Jira Agent MCP\n\nLifecycle (the same words ASC uses)\n jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]\n Wire up this project and verify it. Binds it to you\n alone, writing nothing to the repository; --shared\n adopts JAM for the team (project.yaml, .mcp.json)\n jam status What is configured, what works, what is blocked\n jam update Move this machine's registration to the published release\n jam refresh Keep the version; re-register what this build owns\n jam uninstall Remove JAM's registrations; your bindings and credentials stay\n jam runtime Show which JAM build this machine runs\n jam runtime use package | development <path>\n Change it (writes ~/.jam/config.yaml only, never a project)\n\nJira\n jam jira search <jql> [--scope preview|complete]\n jam jira context <KEY> [KEY...]\n jam jira full <KEY> [KEY...]\n Read Jira from the shell - the same reads the MCP\n tools do, for a session that cannot see them yet\n jam jira write-plan --operation <op> [--key KEY] --input '<json>'\n jam jira write-apply <planId>\n Write the same way the tools do: plan first, then\n apply that plan by id. Nothing is written until\n write-apply, and it takes no payload of its own\n\nAuthentication\n jam auth status [--json] Whether Jira credentials are configured (never their value)\n jam auth login Store them in this user's OS secret store\n jam auth logout Remove them again\n\nFor coding agents and scripts (stdout is JSON only, never prompts):\n jam setup --agent One shot: detect, plan, apply what is safe, verify\n jam setup plan --json Report what setup would change, changing nothing\n jam setup apply --non-interactive --json\n Execute the plan\n jam status --json Health check as structured output\n jam update check|plan [--json]\n jam refresh check|plan [--json]\n jam uninstall plan [--json]\n\nHost runtime\n jam serve Run the MCP server over stdio - this is what Claude\n Code and Codex launch. Not a command a person types.\n\nEnvironment:\n JIRA_BASE_URL https://your-site.atlassian.net\n JIRA_EMAIL Atlassian account email\n JIRA_API_TOKEN Atlassian API token\n JAM_PROJECT_KEY Jira project key, used by `jam setup`/`jam serve` when no\n .jira-agent/project.yaml exists yet\n\nCredentials and JAM_PROJECT_KEY are read from the current shell's environment\nfirst, then (on Windows) from the User environment - so a value set with\n`setx` works without opening a new terminal.\n";
7
7
  export declare function runJamCommand(argv: string[]): Promise<number>;
package/dist/cli-entry.js CHANGED
@@ -4,10 +4,12 @@ import { showRuntime, useRuntime } from "./cli/runtime.js";
4
4
  import { serve } from "./cli/serve.js";
5
5
  import { setup } from "./cli/setup.js";
6
6
  import { jamUpdateCommand } from "./cli/update.js";
7
+ import { jamRefreshCommand, jamStatusCommand, jamUninstallCommand } from "./cli/lifecycle.js";
7
8
  import { runSetupWizard } from "./cli/setup-wizard.js";
8
9
  import { reportPromptError, Ui } from "./cli/ui.js";
9
10
  import { authStatusCommand, doctorJsonCommand, setupAgentCommand, setupApplyCommand, setupPlanCommand, } from "./cli/agent-api.js";
10
11
  import { runJiraRead } from "./cli/jira-read.js";
12
+ import { runJiraWrite } from "./cli/jira-write.js";
11
13
  /**
12
14
  * Command dispatch for the JAM CLI, separated from the bin so other entry
13
15
  * points (notably @jam-mcp/bootstrap) can forward to exactly these commands
@@ -15,33 +17,49 @@ import { runJiraRead } from "./cli/jira-read.js";
15
17
  */
16
18
  export const USAGE = `jam - Jira Agent MCP
17
19
 
18
- Usage:
19
- jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)
20
- jam doctor Diagnose config, credentials and Jira connectivity
20
+ Lifecycle (the same words ASC uses)
21
21
  jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]
22
- Wire up this project and run doctor. Binds it to you
22
+ Wire up this project and verify it. Binds it to you
23
23
  alone, writing nothing to the repository; --shared
24
24
  adopts JAM for the team (project.yaml, .mcp.json)
25
+ jam status What is configured, what works, what is blocked
25
26
  jam update Move this machine's registration to the published release
26
- jam update check What is registered, what is published (changes nothing)
27
+ jam refresh Keep the version; re-register what this build owns
28
+ jam uninstall Remove JAM's registrations; your bindings and credentials stay
27
29
  jam runtime Show which JAM build this machine runs
28
30
  jam runtime use package | development <path>
29
31
  Change it (writes ~/.jam/config.yaml only, never a project)
30
- jam auth login Store Jira credentials in this user's OS secret store
31
- jam auth logout Remove them again
32
32
 
33
- For coding agents and scripts (stdout is JSON only, never prompts):
34
- jam setup --agent One shot: detect, plan, apply what is safe, verify
35
- jam setup plan --json Report what setup would change, changing nothing
36
- jam setup apply --non-interactive --json
37
- Execute the plan
38
- jam doctor --json Health check as structured output
39
- jam auth status --json Whether Jira credentials are configured (never their value)
33
+ Jira
40
34
  jam jira search <jql> [--scope preview|complete]
41
35
  jam jira context <KEY> [KEY...]
42
36
  jam jira full <KEY> [KEY...]
43
37
  Read Jira from the shell - the same reads the MCP
44
38
  tools do, for a session that cannot see them yet
39
+ jam jira write-plan --operation <op> [--key KEY] --input '<json>'
40
+ jam jira write-apply <planId>
41
+ Write the same way the tools do: plan first, then
42
+ apply that plan by id. Nothing is written until
43
+ write-apply, and it takes no payload of its own
44
+
45
+ Authentication
46
+ jam auth status [--json] Whether Jira credentials are configured (never their value)
47
+ jam auth login Store them in this user's OS secret store
48
+ jam auth logout Remove them again
49
+
50
+ For coding agents and scripts (stdout is JSON only, never prompts):
51
+ jam setup --agent One shot: detect, plan, apply what is safe, verify
52
+ jam setup plan --json Report what setup would change, changing nothing
53
+ jam setup apply --non-interactive --json
54
+ Execute the plan
55
+ jam status --json Health check as structured output
56
+ jam update check|plan [--json]
57
+ jam refresh check|plan [--json]
58
+ jam uninstall plan [--json]
59
+
60
+ Host runtime
61
+ jam serve Run the MCP server over stdio - this is what Claude
62
+ Code and Codex launch. Not a command a person types.
45
63
 
46
64
  Environment:
47
65
  JIRA_BASE_URL https://your-site.atlassian.net
@@ -80,7 +98,12 @@ export async function runJamCommand(argv) {
80
98
  switch (command ?? "serve") {
81
99
  case "serve":
82
100
  return serve();
101
+ case "status":
102
+ return jamStatusCommand({ json: rest.includes("--json") });
103
+ // The old name for the same question. It keeps working for two minor
104
+ // releases; `status` is the word both products answer to.
83
105
  case "doctor":
106
+ process.stderr.write("Deprecated. Use `jam status`.\n");
84
107
  return rest.includes("--json") ? doctorJsonCommand() : doctor();
85
108
  case "setup": {
86
109
  const explicitKey = findFlagValue(rest, "--project");
@@ -108,6 +131,16 @@ export async function runJamCommand(argv) {
108
131
  return jamUpdateCommand(rest[0] === "--json" ? undefined : rest[0], {
109
132
  json: rest.includes("--json"),
110
133
  });
134
+ case "refresh":
135
+ // Not `update`: the version does not move here. Only the registration
136
+ // this build owns is brought back to it.
137
+ return jamRefreshCommand(rest[0]?.startsWith("--") ? undefined : rest[0], {
138
+ json: rest.includes("--json"),
139
+ });
140
+ case "uninstall":
141
+ return jamUninstallCommand(rest[0]?.startsWith("--") ? undefined : rest[0], {
142
+ json: rest.includes("--json"),
143
+ });
111
144
  case "runtime": {
112
145
  const json = rest.includes("--json");
113
146
  if (rest[0] === "use")
@@ -128,9 +161,12 @@ export async function runJamCommand(argv) {
128
161
  return 1;
129
162
  }
130
163
  case "jira":
131
- // Reads addressed to the shell, for a session that cannot see the MCP
132
- // tools yet. Same application path as the tools - see cli/jira-read.ts.
133
- return runJiraRead(rest);
164
+ // Jira addressed to the shell, for a session that cannot see the MCP
165
+ // tools. Same application path as the tools, reads and writes alike -
166
+ // see cli/jira-read.ts and cli/jira-write.ts.
167
+ return rest[0] === "write-plan" || rest[0] === "write-apply"
168
+ ? runJiraWrite(rest)
169
+ : runJiraRead(rest);
134
170
  case "help":
135
171
  case "--help":
136
172
  case "-h":
@@ -5,7 +5,7 @@
5
5
  * is mapped onto one of these codes so the agent (and `jam doctor`) can reason
6
6
  * about failures without parsing vendor-specific payloads.
7
7
  */
8
- export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", "JAM_WRITE_VALUE_NOT_ALLOWED", "JAM_WRITE_SCHEMA_CHANGED", "JAM_WRITE_ASSIGNEE_NOT_FOUND", "JAM_WRITE_ASSIGNEE_AMBIGUOUS", "JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", "JAM_WRITE_ASSIGNEE_ALREADY_SET", "JAM_WRITE_PLAN_NOT_FOUND", "JAM_WRITE_PLAN_EXPIRED", "JAM_WRITE_CONFLICT", "JAM_WRITE_VERIFICATION_FAILED", "JAM_WRITE_UNCERTAIN"];
8
+ export declare const JAM_ERROR_CODES: readonly ["JIRA_AUTH_FAILED", "JIRA_PERMISSION_DENIED", "JQL_INVALID", "ISSUE_NOT_FOUND", "RATE_LIMITED", "CONTEXT_TOO_LARGE", "PARTIAL_RESULT", "CONFIG_INVALID", "JIRA_UNAVAILABLE", "JAM_SETUP_REQUIRED", "JAM_BINDINGS_UNREADABLE", "JAM_WRITE_SCOPE_VIOLATION", "JAM_WRITE_OPERATION_NOT_ALLOWED", "JAM_WRITE_FIELD_NOT_ALLOWED", "JAM_WRITE_TRANSITION_NOT_AVAILABLE", "JAM_WRITE_ISSUE_TYPE_NOT_AVAILABLE", "JAM_WRITE_REQUIRED_FIELD_UNSUPPORTED", "JAM_WRITE_VALUE_NOT_ALLOWED", "JAM_WRITE_SCHEMA_CHANGED", "JAM_WRITE_ASSIGNEE_NOT_FOUND", "JAM_WRITE_ASSIGNEE_AMBIGUOUS", "JAM_WRITE_ASSIGNEE_NOT_ASSIGNABLE", "JAM_WRITE_ASSIGNEE_ALREADY_SET", "JAM_WRITE_PLAN_NOT_FOUND", "JAM_WRITE_PLAN_EXPIRED", "JAM_WRITE_PLAN_TAMPERED", "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: {
@@ -44,6 +44,11 @@ export const JAM_ERROR_CODES = [
44
44
  "JAM_WRITE_ASSIGNEE_ALREADY_SET",
45
45
  "JAM_WRITE_PLAN_NOT_FOUND",
46
46
  "JAM_WRITE_PLAN_EXPIRED",
47
+ // A stored plan that does not describe a change JAM would make. Plans live in
48
+ // a file so both transports can reach one, and apply re-derives the mutation
49
+ // from the plan's own input rather than trusting what the file says. Distinct
50
+ // from CONFLICT: nothing moved, the plan itself does not hold up.
51
+ "JAM_WRITE_PLAN_TAMPERED",
47
52
  "JAM_WRITE_CONFLICT",
48
53
  "JAM_WRITE_VERIFICATION_FAILED",
49
54
  "JAM_WRITE_UNCERTAIN",
@@ -161,6 +161,18 @@ type WritePlanCommon = {
161
161
  expiresAt: string;
162
162
  /** Normalized payload the apply step will send. Never supplied by a caller. */
163
163
  mutation: WriteMutation;
164
+ /**
165
+ * The validated input the mutation was derived from.
166
+ *
167
+ * Kept so apply can derive the mutation again and compare. Plans live in a
168
+ * file now, and a file can be edited - re-deriving is what makes that
169
+ * pointless: changing the mutation without changing the input is caught, and
170
+ * changing both is just calling plan again.
171
+ *
172
+ * Validated, not raw: what a caller sent has already been checked against the
173
+ * operation's whitelist by the time it lands here.
174
+ */
175
+ input: Record<string, unknown>;
164
176
  };
165
177
  /**
166
178
  * A plan against an issue that already exists.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jam-mcp/server",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "JAM (Jira Agent MCP) - agent-facing Jira access layer: MCP server, setup core, and CLI",
5
5
  "keywords": [
6
6
  "jira",
@@ -41,7 +41,7 @@
41
41
  "test:watch": "vitest"
42
42
  },
43
43
  "dependencies": {
44
- "@jam-mcp/launcher": "1.5.0",
44
+ "@jam-mcp/launcher": "1.7.0",
45
45
  "@modelcontextprotocol/sdk": "^1.30.0",
46
46
  "yaml": "^2.9.0",
47
47
  "zod": "^4.4.3"