Repository navigation
feat(cli): write Jira from the shell when the tools are unreachable - #39
Merged
Merged
Conversation
Reads got a shell address in 1.6.0 and writes did not. The gap shows up in
exactly the situation the reads were opened for: a session whose MCP registry
came up in a failed state cannot be shown the tools - Claude Code has no
reload surface for a running session - while JAM, its credentials and its Jira
access are all fine. That session could read Jira and could not change it.
jam jira write-plan --operation <op> [--key KEY] --input '<json>'
jam jira write-apply <planId>
Nothing here is a second write path. Both call the same planWrite /
applyWritePlan the tools call, with the same deps, project binding, whitelist
and normalized error codes. There is deliberately no `jam jira comment` or
`jam jira close`: a shortcut past planning is what the write plane exists to
prevent, and a terminal does not change that. `write-apply` takes a planId and
has no flag through which a payload could arrive.
Plans now live in a file
They lived in the server process, and the ADR said why: a signed token needs
a signing key from somewhere, and keeping the mutation in memory made forgery
impossible rather than merely hard. The same section named the condition for
revisiting - "if plans ever need to outlive a process" - and `write-plan` and
`write-apply` are two processes.
So `~/.jam/write-plans/`, shared by both transports. That sharing is the
point rather than a side effect: a plan made through MCP can be applied from
the shell after the tools stop answering, which is the whole case.
Single use is now the filesystem's job. `take` claims a plan by renaming it,
so two concurrent applies cannot both hold one - for comment.add the
alternative is two comments.
What replaces the in-process guarantee
Not a signature. Apply re-derives the mutation from the plan's own recorded
input and refuses (JAM_WRITE_PLAN_TAMPERED) when the stored mutation is not
what that input produces. Editing the file to smuggle a different write means
editing the input to match, which is asking JAM to plan that write - there is
nothing to gain by forging a plan obtainable by requesting one.
Re-derivation reads the plan's recorded resolutions rather than calling Jira
again. Planning asked for a transition list and a user directory and wrote
down what it settled on; re-running those would cost a round trip and would
re-raise refusals belonging to planning ("already assigned", "not
assignable"), reporting a fact about the current state as though the plan
were malformed. The check is narrower and sufficient: the mutation must be
the one this plan's own parts describe. The whitelist runs again too, so an
edited input cannot reach a field planning refuses.
It runs after the revision check, on purpose. A moved issue is a conflict,
not tampering, and reporting them in that order keeps the two from being read
as each other.
The cost is real and is written into the ADR: a mutation is now serialized
where the machine's user can read and edit it. That is a reduction from
"forgery is impossible" to "forgery gains nothing", and it is what a write
path that survives its transport costs.
Verification
npx tsc --noEmit green
npm test 538 pass / 0 fail / 4 skipped (542)
npm run build green
npm run release:check "Release 1.6.0 is consistent"
Real Jira (a health-check ticket in the bound project - no team ticket
touched):
write-plan planned, no mutation
write-apply from a second
process applied, verified: true, commentId 224893
same planId again JAM_WRITE_PLAN_NOT_FOUND, directory empty
plan, move the issue, apply JAM_WRITE_CONFLICT
edit the stored mutation,
leave the input JAM_WRITE_PLAN_TAMPERED, nothing reached Jira
read → plan → apply with no
MCP channel at all applied, verified: true
The last one is what this change exists for.
Not measured: JAM_WRITE_UNCERTAIN. Its trigger is an ambiguous transport
failure, which cannot be produced against real Jira on demand - only its code
path was read. Saying so rather than implying the state was exercised.
Tests use their own throwaway store directory: the default now writes to the
user's home, and a suite that used it would leave plans there.
Co-Authored-By: Claude Opus 5 <[email protected]>
colosair
force-pushed
the
feat/cli-write-surface
branch
from
September 8, 2026 03:32
bb19ab3 to
44e6536
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Reads got a shell address in 1.6.0 and writes did not. The gap shows up in exactly the situation the reads were opened for: a session whose MCP registry came up in a failed state cannot be shown the tools — Claude Code has no reload surface for a running session — while JAM, its credentials and its Jira access are all fine. That session could read Jira and could not change it.
No release, no version bump. This is the source change only.
Jira
No Jira project corresponds to this repository. An existing binding from another project was not used as issue tracking for this one.
Design changes
Nothing new implements a write. The MCP write tools turned out to be transport shells — the zod schema is the protocol's input declaration and every refusal comes from
planWrite/applyWritePlan, which the tool comments say explicitly (looseObjectexists so unknown keys reach JAM to be refused by name rather than being stripped). So the CLI calls the same two functions with the same deps, project binding, whitelist and normalized error codes.PlanWriteRequestandApplyWriteRequestwere already the transport-neutral contract; no new layer was extracted.One thing did block it: plans could not cross a process.
write-planandwrite-applyare two invocations, so an in-memory plan was gone before apply could see it.Sharing the store is the point rather than a side effect: a plan made through the tools can be applied from the shell after the tools stop answering, which is the case the whole change exists for.
Single use moved to the filesystem.
takeclaims a plan by renaming it (.json→.claimed), because two processes can now reach one andrenameis the only atomic operation here. Read-then-delete would leave a window, and forcomment.addthat window is a second comment.Integrity is re-derivation, not a signature. Apply reads the plan's own recorded
input, derives the mutation from it again, and refuses withJAM_WRITE_PLAN_TAMPEREDwhen the stored mutation is not what that input produces. Editing the file to smuggle a different write means editing the input to match — which is asking JAM to plan that write, which is whatjira_write_planis. Nothing is gained by forging a plan obtainable by requesting one.Two details worth reviewing:
already assigned,not assignable), reporting a fact about the current state as though the plan were malformed. The check is narrower and sufficient: the mutation must be the one this plan's own parts describe. The whitelist runs again too, so an edited input cannot reach a field planning refuses.No shortcuts. There is deliberately no
jam jira commentorjam jira close.write-applytakes a planId and has no flag through which a payload could arrive — a test asserts the source contains no--payload/--field/--override.issue.creategoes through the samewrite-planrather than getting a command of its own.write-applynever prompts: what to write was settled when the plan was made, and a confirmation here would be a second approval step, which is a different feature.ADR change
docs/decisions/adr-jira-write-plane.md§"Where plans live" is rewritten rather than quietly contradicted. That section had said in-process, in memory — and named the condition for revisiting:They do now. The revision records what was gained, why a signed token still was not the shape (the signing key has the same problem it had before; the OS secret store's contract is the Jira credential triple and widening it is a larger change), and what was lost:
Verification
npx tsc --noEmit— greennpm test— 538 pass / 0 fail / 4 skipped (542)npm run build— greennpm run release:check—Release 1.6.0 is consistent across manifests, code and docsNew tests cover the store crossing a process, atomic claim, expiry, path safety, the CLI transport contract, and three integrity cases (edited mutation, edited input outside the whitelist, a wholly forged plan file) — each asserting the write port was not called.
Existing suites were pointed at a throwaway store directory. The default now writes to the user's home, and a suite using it would leave test plans there.
Real acceptance
Against real Jira, on a health-check ticket in the bound project. No team ticket was touched.
The last line is what this change exists for.
Not measured
JAM_WRITE_UNCERTAINwas not exercised. Its trigger is an ambiguous transport failure — a request that may or may not have landed — and that cannot be produced against real Jira on demand. Only its code path was read. Recording this rather than implying the state was tested: it is the one failure whose handling matters most, since retrying it is what turns one comment into two.Known friction:
--inputon PowerShellHit while running acceptance on Windows.
PowerShell's quoting does not deliver the JSON intact. Not a defect in this change — the same would happen to any JSON-argument CLI — but it is a real obstacle for the environment this feature is most needed in, since the sessions that lose their MCP tools are often on Windows.
Left as-is here rather than solved in passing. If it should be addressed, the options are a
--input-fileor reading the object from stdin, and either is a small follow-up. Flagging it rather than letting the next person discover it.Out of scope
Merge after review.
🤖 Generated with Claude Code