@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 +2 -2
- package/dist/application/apply-create-issue.js +48 -1
- package/dist/application/apply-write.js +82 -1
- package/dist/application/plan-create-issue.d.ts +11 -0
- package/dist/application/plan-create-issue.js +8 -1
- package/dist/application/plan-write.d.ts +12 -1
- package/dist/application/plan-write.js +4 -2
- package/dist/application/write-plan-store.d.ts +28 -22
- package/dist/application/write-plan-store.js +102 -26
- package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
- package/dist/cli/jira-write.d.ts +44 -0
- package/dist/cli/jira-write.js +146 -0
- package/dist/cli/lifecycle.d.ts +66 -0
- package/dist/cli/lifecycle.js +233 -0
- package/dist/cli-entry.d.ts +1 -1
- package/dist/cli-entry.js +53 -17
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +5 -0
- package/dist/domain/write.d.ts +12 -0
- package/package.json +2 -2
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
22
|
-
|
|
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
|
|
8
|
+
* Where a plan lives between planning and applying.
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
77
|
-
if (
|
|
78
|
-
|
|
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.
|
|
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
|
+
}
|
package/dist/cli-entry.d.ts
CHANGED
|
@@ -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\
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
132
|
-
// tools
|
|
133
|
-
|
|
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":
|
package/dist/domain/errors.d.ts
CHANGED
|
@@ -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: {
|
package/dist/domain/errors.js
CHANGED
|
@@ -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",
|
package/dist/domain/write.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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"
|