Skip to content

feat(cli): write Jira from the shell when the tools are unreachable - #39

Merged
colosair merged 1 commit into
mainfrom
feat/cli-write-surface
Sep 8, 2026
Merged

colosair merged 1 commit into
mainfrom
feat/cli-write-surface

Conversation

@colosair

@colosair colosair commented Sep 8, 2026

Copy link
Copy Markdown
Owner

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.

MCP stale / unavailable  →  JAM CLI  →  the existing application write path  →  Jira
jam jira write-plan --operation <op> [--key KEY] --input '<json>'
jam jira write-apply <planId>

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 (looseObject exists 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. PlanWriteRequest and ApplyWriteRequest were already the transport-neutral contract; no new layer was extracted.

One thing did block it: plans could not cross a process. write-plan and write-apply are two invocations, so an in-memory plan was gone before apply could see it.

before   WritePlanStore holds plans in the server process
after    ~/.jam/write-plans/<planId>.json, shared by MCP and CLI

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. take claims a plan by renaming it (.json → .claimed), because two processes can now reach one and rename is the only atomic operation here. Read-then-delete would leave a window, and for comment.add that 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 with 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, which is what jira_write_plan is. Nothing is gained by forging a plan obtainable by requesting one.

Two details worth reviewing:

  • 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 that belong 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. A moved issue is a conflict, not tampering, and reporting them in that order keeps the two from being read as each other.

No shortcuts. There is deliberately no jam jira comment or jam jira close. write-apply takes a planId and has no flag through which a payload could arrive — a test asserts the source contains no --payload / --field / --override. issue.create goes through the same write-plan rather than getting a command of its own. write-apply never 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:

If plans ever need to outlive a process — a remote JAM, or a queued approval step — this is the decision to revisit.

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:

The cost is that a mutation is now serialized where the machine's user can read and edit it. That is a real reduction from "forgery is impossible" to "forgery gains nothing", and it is the price of a write path that survives its transport.

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 across manifests, code and docs

New 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.

write-plan                             planned, no mutation
write-apply from a second process      applied · verified: true
same planId again                      JAM_WRITE_PLAN_NOT_FOUND, directory empty
plan, move the issue, then apply       JAM_WRITE_CONFLICT
edit the stored mutation, leave input  JAM_WRITE_PLAN_TAMPERED, nothing reached Jira
read → plan → apply, no MCP at all     applied · verified: true

The last line is what this change exists for.

Not measured

JAM_WRITE_UNCERTAIN was 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: --input on PowerShell

Hit while running acceptance on Windows.

PowerShell   --input '{\"text\":\"...\"}'   →  "--input must be a JSON object."
Git Bash     --input '{"text":"..."}'        →  fine

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-file or 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

version bump / release       deliberately not in this PR
new write policy             the operation set and whitelist are unchanged
approval system              write-apply does not prompt and must not start
launcher / bootstrap         untouched

Merge after review.

🤖 Generated with Claude Code

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
colosair force-pushed the feat/cli-write-surface branch from bb19ab3 to 44e6536 Compare September 8, 2026 03:32
@colosair
colosair merged commit 0cb9090 into main Sep 8, 2026
5 checks passed
@colosair
colosair deleted the feat/cli-write-surface branch September 8, 2026 03:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant