Links a worktree to a Jira Cloud issue, records the work done on it, and posts a reviewed daily update comment.

A Claude Code plugin that keeps Jira Cloud up to date with the work you do in Claude Code, a second one, git-push, that pushes your commits from a button, and a third, worktree-hooks, that starts each worktree from the latest remote branch, and usage-sync, that adds up your Claude Code usage across machines.
/jira update suggests the next status (In Review once a pull request is up, Done once it is merged) for you to confirm with one press.Built on Claude Code's function hooks (early access); the API can change between Claude Code releases.
/plugin marketplace add 9akashnp8/claude-code-auto-jira-log
/plugin install jira-log@auto-jira-log
/jira setup, fill in your site URL (https://your-team.atlassian.net), email and token, then press Save and test connection. Add a project key too if you want to create issues from Claude Code.The token is encrypted with Windows DPAPI before it is stored, so it opens only for your Windows user on that machine. Token storage needs Windows PowerShell.
| Command | What it does |
|---|---|
/jira | Shows which issue this worktree is linked to |
/jira setup | Opens the connection form |
/jira link [KEY] | Links this worktree to an issue; without a key, opens the picker |
/jira new [what for] | Drafts a ticket (a title and a short description) from the conversation, focused on what you type after new if anything: pick its type and sprint, revise it, then create and link it |
/jira new --manual | Opens a blank ticket form (title, description, type, sprint) and never asks the model; the band's Write it myself button does the same. Every drafted ticket can be edited in the same form |
/jira update | Drafts today's comment in the Jira pane: revise it, post it or discard it |
/jira status | Moves the linked issue to any status its workflow allows, such as Done |
/jira unlink | Removes the link |
The description is a text area: click it to type, Enter starts a new line and Esc leaves it. It grows with the text up to 12 rows, then scrolls. Each line becomes a paragraph in Jira. In VS Code, which draws no such area, it is a one-line field.
A new issue goes into the project's current sprint unless you pick a future sprint or Backlog in the form. The sprints are the active and future ones on the project's scrum boards. When the site has no Jira Software, or the project has no scrum board, the form shows no sprint and the issue goes to the backlog.
Running /jira update again on the same day replaces that day's comment, unless someone edited it in Jira since; then a new comment is added.
A day whose work never reached Jira is not lost: the next /jira update, within 14 days, drafts that work into its comment along with the day's own, and posting it marks the earlier day as covered.
Linking a To Do issue looks for a transition into a status named In Progress (any case); when the workflow has none, a toast says so and the issue stays where it is.
/jira update only suggests moves the issue's workflow allows from its current status, and nothing moves until you press the button. Its evidence is the conversation and the day's record, which notes pull and merge requests opened from Claude Code by gh pr create, glab mr create, az repos pr create, or an MCP tool such as create_pull_request. A pull request opened in the browser counts once you mention it in the conversation.
A second plugin in this marketplace: push the branch and open its pull request from buttons above the prompt, then follow the pull request there until it is merged.
/plugin install git-push@auto-jira-log
When the branch has commits that are not on origin, the line above the prompt says so, with a Push button:
feature/export · not on origin yet · 2 commits: the branch was never pushed. Push runs git push --set-upstream origin HEAD, so origin/feature/export is created and tracked.feature/export · 1 commit not pushed: the branch tracks origin/feature/export and is ahead of it.A toast says whether the push worked, or the line from git that says why not. The plugin checks git after each turn and every 5 seconds, so commits made with the app's Commit button or in a terminal show up too. The push runs with your own git credentials and never waits on a terminal prompt. Claude Code runs git for plugins with the repository's hooks off, so a pre-push hook does not run on this push.
When origin is an Azure DevOps repository (Azure DevOps Server, such as https://server/tfs/Collection/Project/_git/Repo, or Azure DevOps Services) and the branch is on it with nothing left to push, the line shows Create PR:
origin and picks the branch to merge into: the branch yours was most likely cut from. That is the origin branch missing the fewest of your branch's commits, then the one with the fewest commits of its own, with the repository's default branch winning a tie. A branch cut from dev merges into dev. With git older than 2.41, which cannot measure this, it uses the default branch.jira-log's next update know it is up.From then on the line follows the pull request, checking every 30 seconds: PR #12 · Active · 1 of 2 approved · checks running. Conflicts, a rejection or a failed check turn it red, changes requested yellow, and a merged pull request shows Merged in the merge purple. PR #12 opens it in the browser. A pull request opened in the browser shows up too.
When a build validation policy's build fails, the line shows Fix build (once Claude is not busy with a turn):
context.buildId), its timeline, and for the first five failed steps their error lines and the last 150 lines of its log, timestamps dropped. A job or stage counts only when it has errors of its own, such as no agent picking it up.fix(ci): . When the turn ends, the plugin pushes the commit, and the push queues the build again.RETRY: <why>. The plugin queues the policy's build again, as the pull request's Re-queue does.ESCALATE: <what to do>. The line shows Needs you: … and you get a notification. The line clears once the build runs again.A fix that is interrupted or commits nothing brings the button back. The build output is put to Claude as data, with an instruction not to follow anything written in it.
When Azure DevOps reports that the pull request has conflicts with its target branch (mergeStatus is conflicts), the line shows Fix conflicts:
origin and merges the target into your branch here: git merge --no-edit origin/<target>. It uses a merge, not a rebase, because the branch is already published and under review. With uncommitted changes to tracked files it stops before merging and asks you to commit or stash them.git commit --no-edit. When the turn ends, the plugin pushes the merge commit.ESCALATE: <the decision needed>. The line shows Needs you: … and you get a notification.Fix conflicts is not offered again for the same conflicts until Azure DevOps tries the merge again, which it does after a push to either branch. If Claude leaves the merge unfinished, pressing Fix conflicts again picks up the merge in progress rather than starting a new one. The list of conflicted files comes from the local merge, which is what Claude resolves, so the plugin does not need Azure DevOps's preview conflicts API.
Signing in needs no setup. The plugin asks git for the credential it uses for origin (git credential fill), then sends requests through Windows PowerShell: first as Basic sign-in (a personal access token), then as Windows sign-in with the same credential (a domain password), and with no stored credential as your Windows account. The credential stays in memory and goes to PowerShell on its standard input, never on a command line. Requests use REST API version 6.0, which Azure DevOps Server 2020 and later answer. Pull requests need Windows.
When both plugins are installed, their lines stack in the band above the prompt.
A third plugin in this marketplace: start every worktree from the latest commit of the branch it is made from, not from whatever copy of that branch this machine fetched last.
/plugin install worktree-hooks@auto-jira-log
It registers WorktreeCreate, WorktreeRemove and SessionStart command hooks. claude --worktree always branches from the default branch. A WorktreeCreate hook replaces Claude Code's own worktree creation, so worktree-create.sh does all of it:
origin/HEAD (main when that is not set).git fetch origin <branch>. When the fetch fails, for example offline, it carries on from the cached origin/<branch>.git worktree add for .claude/worktrees/<name> on a new branch, worktree-<name>, from origin/<branch>.worktree-remove.sh removes the worktree and deletes its worktree-* branch, because Claude Code never deletes the branch of a worktree a hook created.
The desktop app's Code tab makes its worktrees itself and does not run WorktreeCreate. It branches from the branch picked in the branch selector, as this machine last fetched it, which can be behind. For those, session-start.sh catches up when a new session starts in a worktree:
sourceBranch in its session file under %APPDATA%Claudeclaude-code-sessions. That file belongs to the app and is not documented, so when it is missing or changes shape, the hook falls back to the one branch, other than the worktree's own, that points at the worktree's commit. When several do, it cannot tell them apart and leaves the worktree alone.origin and fast-forwards the worktree's branch to it, then tells Claude in one line.It leaves the worktree alone when the session is resumed, cleared or compacted rather than new, when there are uncommitted changes, when the branch has commits of its own, or when the branch is not on origin. Set WORKTREE_HOOKS_SYNC=0 to turn this off and only log.
Things to know:
.worktreeinclude is not processed when a hook creates the worktree, so files such as .env are not copied across..claude/worktrees/ to the repository's .gitignore.bash directly, because on Windows that name can resolve to WSL's launcher.WorktreeCreate fires for claude --worktree, isolation: "worktree" subagents and background sessions, not for the desktop app's Code tab.CLAUDE_PROJECT_DIR and a hook's working directory are the main checkout. Only the cwd in the hook's input is the worktree.Every hook appends to ~/.claude/logs/worktree-hooks.log (set WORKTREE_HOOKS_LOG to move it): its input, the Claude Code entry point (CLAUDE_CODE_ENTRYPOINT, such as cli or claude-desktop), which bash ran it, each step, and the line a failure stopped at. SessionStart also logs the session's folder, branch and whether it is a linked worktree, the branch it was made from and how that was found, then either the fast-forward or the reason it skipped one (no sync: …).
To find out whether a way of starting a session runs WorktreeCreate, start one and read the log:
SessionStart entry for the new worktree with no WorktreeCreate entry before it: the worktree was made without the hook. Its branch is not named worktree-<name>.SessionStart entry either: the plugin's hooks did not load in that session. Check /hooks and the Errors tab of /plugin.WorktreeCreate entry ending in failed:: the hook ran and stopped at the logged line.For Claude Code's own record of a hook, run it from the terminal with a debug log, then search the file for Hook :
claude --worktree hook-check --debug-file ./claude-debug.txt
To run a script by hand, pipe it the JSON Claude Code would send:
echo '{"name":"hook-check"}' | CLAUDE_PROJECT_DIR="$PWD" bash plugins/worktree-hooks/scripts/worktree-create.sh
A debugging plugin: it logs every hook event Claude Code fires, so you can see which events fire, when, and with what input. It changes nothing.
/plugin install hook-logger@auto-jira-log
Each event appends two lines to ~/.claude/logs/hook-events.log (set HOOK_LOGGER_LOG to move it): the time, event name, Claude Code entry point (cli or claude-desktop) and project folder, then the full JSON input.
It registers all 33 events in the hooks reference, with no matcher, so each fires on every occurrence. Two of them cannot just log: registering WorktreeCreate or WorktreeRemove replaces Claude Code's own worktree creation or removal. So after logging, the plugin does that work itself, and adds a result= line saying what it did:
WorktreeCreate makes .claude/worktrees/<name> on a new branch, worktree-<name>, from the project's current HEAD, and prints its path. Claude Code's own default starts from origin/<default branch> instead. .worktreeinclude is not processed.WorktreeRemove removes the worktree and its worktree-* branch.Don't enable it alongside worktree-hooks: both would answer WorktreeCreate.
FileChanged only fires for files named in its matcher, so with none it may never fire. Every hook starts Git Bash on Windows, which adds a little time to each tool call: disable the plugin when you are done.
Another plugin in this marketplace: add up your Claude Code usage across machines. Claude Code only reports usage for the machine it runs on, and an individual plan has no API to ask. This plugin reads each machine's transcripts into daily token counts and commits them to a GitHub repository of yours, one file per machine, for a dashboard to merge.
/plugin install usage-sync@auto-jira-log
It needs Node.js 18 or later on the PATH, since its hooks run node.
claude-usage-data, with a README so its branch has a commit. Make it public if a static site reads it without a token./plugin:| Option | What it is |
|---|---|
| Data repository | owner/name, or the repository's URL |
| GitHub token | The token from step 2, kept in the system's credential store |
| Branch | main unless you use another |
| Folder | Where the device files go, devices by default |
| Device name | This machine's file name, <device>.json; empty uses the host name |
| Device label | The name a dashboard shows, such as Work laptop |
| Time zone | The IANA zone days and hours are counted in, such as Asia/Kolkata. Set the same one on every machine; empty uses this machine's |
| Sync interval | Minutes a starting session waits after the last sync before it syncs again, 60 by default |
Install it on every machine you use Claude Code on, with a different device name on each.
Two command hooks run scripts/usage-sync.mjs. SessionEnd syncs every time a session ends; SessionStart syncs when the last sync is older than the interval, which catches a session that was killed before its end hook ran. Each hook starts the sync in a background process and returns at once, so neither starting nor quitting Claude Code waits on the network.
A sync reads the transcripts under ~/.claude/projects (subagents' included), counts each day's sessions, messages and per-model tokens (input, output, cache read, cache write), reads the device's file from the repository, merges, and commits it if anything changed. The first sync reads every transcript Claude Code still keeps, so the history goes back as far as they do. Later syncs read only the transcripts that changed, plus the others that touch the same days, so a day is always counted whole. Days whose transcripts Claude Code has since deleted keep their last count, and for each day the fuller of the repository's copy and the machine's wins, so the record never shrinks.
The upload is the device's file alone, devices/<device>.json:
{
"version": 2,
"device": "macbook",
"label": "MacBook Pro",
"timeZone": "Asia/Kolkata",
"updatedAt": "2026-10-12T09:30:00.000Z",
"days": {
"2026-10-12": {
"sessions": 3,
"messages": 412,
"hours": [0, 0, 0, 0, 0, 0, 0, 0, 0, 37, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"models": {
"claude-opus-5-5": { "input": 180, "output": 91662, "cacheRead": 19465751, "cacheWrite": 518138, "requests": 90 }
}
}
}
}
The counts follow Claude Code's own stats, so they match what it shows on the machine:
messages counts the main conversation's prompts, tool results and responses. A subagent's entries are not messages, but its tokens are counted.sessions counts each session on the day it started, and hours counts sessions started per hour of the day, which is what a peak hour comes from.The file carries a version. A file written under older rules (no version) is replaced day by day on the first sync after an update, keeping only days this machine no longer has. No prompt, file path, project name or session id leaves the machine.
/usage-sync:status shows the device, where it uploads and how the last sync went. Every sync appends a line to ~/.claude/usage-sync/sync.log, the first place to look when nothing arrives.
To sync by hand from a terminal, give the options as USAGE_SYNC_<KEY> variables:
USAGE_SYNC_REPO=me/claude-usage-data USAGE_SYNC_TOKEN=... node <plugin folder>/scripts/usage-sync.mjs sync
Things to know:
cleanupPeriodDays (30 by default), so install the plugin soon to keep the most history.~/.claude/usage-sync/, survives an uninstall; the repository holds the record either way.Tests: node --test plugins/usage-sync/tests/*.test.mjs.
claude plugin validate plugins/jira-log
claude plugin test plugins/jira-log
claude --plugin-dir plugins/jira-log
The same commands work for plugins/git-push, plugins/worktree-hooks, plugins/hook-logger and plugins/usage-sync.
A plugin's prompts live in its hooks/prompts.ts. prompt-evals/ runs them against fixture commits, activity records and conversations with real model calls, and compares versions side by side:
npm install
npm run eval -- pull-request.draft --baseline main
npm run eval:view
The calls run through headless Claude Code on your Claude subscription; add --api to use the Claude API with ANTHROPIC_API_KEY instead.
hooks/register.tsx 1122 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { JiraIssue, JiraNewIssue, JiraTransition, JiraUpdate, JiraView } from '../types'
5import {
6 addArtifact,
7 addOnce,
8 cappedSummary,
9 COMMIT,
10 currentSprint,
11 dayOf,
12 earlierDaysIn,
13 emptyIssue,
14 EMPTY_ACTIVITY,
15 IN_PROGRESS,
16 isIssueReady,
17 isReviewTool,
18 isSameStatus,
19 ISSUE_SHAPE,
20 newIssuePrompt,
21 OPEN,
22 parseNewIssue,
23 parseSuggestion,
24 parseUpdate,
25 POWERSHELL,
26 progressOf,
27 publishedArtifactOf,
28 reasonOf,
29 relativeTo,
30 reviewRequestOf,
31 reviewUrlIn,
32 SEAL,
33 sprintOf,
34 sprintOptions,
35 sprintValue,
36 TEST_RUN,
37 toDocument,
38 toIssue,
39 toIssueDocument,
40 toMarkdown,
41 toSprints,
42 toTransition,
43 UPDATE_AND_MOVE_SHAPE,
44 UPDATE_RULES,
45 UPDATE_SHAPE,
46 type CoveredDay,
47 type DailyComment,
48 type DayActivity,
49 type JiraConfig,
50 type RawComment,
51 type RawIssue,
52 type RawSprint,
53 type RawTransition,
54 type UnpostedDay,
55} from './format'
56import type { DescriptionProps } from './description'
57
58type Engine = EngineInterface
59
60const PANE = 'jira'
61const DESCRIPTION_PLACEHOLDER = 'the outcome wanted, why, and how to tell it is done'
62const USAGE =
63 'Usage: /jira [setup | link [KEY] | new [--manual | what the ticket is for] | update | status | unlink]'
64
65const view = atom({ plugin: 'jira-log', key: 'view' } as const, 'setup')
66const isConfigured = atom({ plugin: 'jira-log', key: 'isConfigured' } as const, false)
67const isSkipped = atom({ plugin: 'jira-log', key: 'isSkipped' } as const, false)
68const isProgressHidden = atom({ plugin: 'jira-log', key: 'isProgressHidden' } as const, false)
69const progress = atom({ plugin: 'jira-log', key: 'progress' } as const, null)
70const link = atom({ plugin: 'jira-log', key: 'link' } as const, null)
71const issues = atom({ plugin: 'jira-log', key: 'issues' } as const, [])
72const draft = atom({ plugin: 'jira-log', key: 'draft' } as const, null)
73const suggestion = atom({ plugin: 'jira-log', key: 'suggestion' } as const, null)
74const newIssue = atom({ plugin: 'jira-log', key: 'newIssue' } as const, null)
75const issueTypes = atom({ plugin: 'jira-log', key: 'issueTypes' } as const, [])
76const sprints = atom({ plugin: 'jira-log', key: 'sprints' } as const, [])
77const transitions = atom({ plugin: 'jira-log', key: 'transitions' } as const, [])
78const busy = atom({ plugin: 'jira-log', key: 'busy' } as const, null)
79const notice = atom({ plugin: 'jira-log', key: 'notice' } as const, null)
80
81type SetupField = 'site' | 'email' | 'token' | 'project'
82const SETUP_LABELS: Record<SetupField, string> = {
83 site: 'site URL',
84 email: 'email',
85 token: 'API token',
86 project: 'project key',
87}
88
89// Typed into the setup form but not yet saved; kept out of $.state so the token never lands there.
90const typed: Partial<Record<SetupField, string>> = {}
91let opened: { blob: string; token: string } | undefined
92// The store is read-modify-write, and parallel tool calls finish together.
93let recording: Promise<void> = Promise.resolve()
94// Actions recorded when the open draft was made: work done while reviewing it is not in the comment.
95let drafted: { actions: number | undefined; earlier: { day: string; actions: number }[] } | undefined
96// Earlier days change only when a comment is posted, so their unposted count is kept per issue and day.
97let earlierCount: { issue: string; day: string; actions: number } | undefined
98
99async function powershell($: Engine, script: string, stdin: string, what: string) {
100 const { exitCode, stdout, stderr } = await $.process.run([...POWERSHELL, script], { stdin })
101 if (exitCode !== 0) {
102 throw new Error(`could not ${what} the API token: ${stderr.trim().split('\n')[0]}`)
103 }
104
105 return stdout.trim()
106}
107
108async function jira<T>($: Engine, config: JiraConfig, method: string, path: string, body?: unknown) {
109 if (opened?.blob !== config.tokenBlob) {
110 opened = { blob: config.tokenBlob, token: await powershell($, OPEN, config.tokenBlob, 'decrypt') }
111 }
112 const response = await $.http.fetch(`${config.site}${path}`, {
113 method,
114 headers: {
115 Authorization: `Basic ${btoa(`${config.email}:${opened.token}`)}`,
116 Accept: 'application/json',
117 'Content-Type': 'application/json',
118 },
119 body: body === undefined ? undefined : JSON.stringify(body),
120 })
121 if (!response.ok) {
122 throw new Error(`Jira answered ${response.status} to ${method} ${path}: ${reasonOf(response.text)}`)
123 }
124
125 return (response.text === '' ? undefined : JSON.parse(response.text)) as T
126}
127
128async function whoAmI($: Engine, config: JiraConfig) {
129 return jira<{ displayName: string; accountId: string }>($, config, 'GET', '/rest/api/3/myself')
130}
131
132async function myOpenIssues($: Engine, config: JiraConfig) {
133 const found = await jira<{ issues: RawIssue[] }>($, config, 'POST', '/rest/api/3/search/jql', {
134 jql: 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC',
135 fields: ['summary', 'status'],
136 maxResults: 30,
137 })
138
139 return found.issues.map(toIssue)
140}
141
142async function getIssue($: Engine, config: JiraConfig, key: string) {
143 const path = `/rest/api/3/issue/${encodeURIComponent(key)}?fields=summary,status`
144
145 return toIssue(await jira<RawIssue>($, config, 'GET', path))
146}
147
148async function transitionsOf($: Engine, config: JiraConfig, key: string) {
149 const path = `/rest/api/3/issue/${encodeURIComponent(key)}/transitions`
150
151 return (await jira<{ transitions: RawTransition[] }>($, config, 'GET', path)).transitions.map(toTransition)
152}
153
154async function applyTransition($: Engine, config: JiraConfig, key: string, id: string) {
155 const path = `/rest/api/3/issue/${encodeURIComponent(key)}/transitions`
156 await jira($, config, 'POST', path, { transition: { id } })
157 const moved = await getIssue($, config, key)
158 if ((await read($, link))?.key === key) await setLink($, moved)
159
160 return moved
161}
162
163type RawIssueType = { name: string; subtask: boolean }
164
165async function creatableTypes($: Engine, config: JiraConfig, project: string) {
166 const path = `/rest/api/3/issue/createmeta/${encodeURIComponent(project)}/issuetypes`
167 const found = await jira<{ issueTypes?: RawIssueType[]; values?: RawIssueType[] }>($, config, 'GET', path)
168
169 return (found.issueTypes ?? found.values ?? []).filter(type => !type.subtask).map(type => type.name)
170}
171
172// Sprints live on the project's scrum boards, in Jira Software's own API; kanban boards have none.
173async function openSprints($: Engine, config: JiraConfig, project: string) {
174 const path = `/rest/agile/1.0/board?projectKeyOrId=${encodeURIComponent(project)}&type=scrum`
175 const boards = (await jira<{ values: { id: number }[] }>($, config, 'GET', path)).values
176 const lists = await Promise.all(
177 boards.map(async board => {
178 const sprintsPath = `/rest/agile/1.0/board/${board.id}/sprint?state=active,future`
179
180 return (await jira<{ values: RawSprint[] }>($, config, 'GET', sprintsPath)).values
181 }),
182 )
183
184 return toSprints(lists)
185}
186
187async function addToSprint($: Engine, config: JiraConfig, sprint: number, key: string) {
188 await jira($, config, 'POST', `/rest/agile/1.0/sprint/${sprint}/issue`, { issues: [key] })
189}
190
191// One comment per issue per day: today's earlier comment is replaced, unless
192// someone changed it in Jira since it was posted, in which case a new one is added.
193async function saveDailyComment(
194 $: Engine,
195 config: JiraConfig,
196 key: string,
197 day: string,
198 update: JiraUpdate,
199 previous: DailyComment | undefined,
200): Promise<DailyComment> {
201 const comments = `/rest/api/3/issue/${encodeURIComponent(key)}/comment`
202 const body = { body: toDocument(day, update) }
203 if (previous !== undefined) {
204 const current = await jira<RawComment>($, config, 'GET', `${comments}/${previous.id}`).catch(
205 () => undefined,
206 )
207 if (current?.updated === previous.updated) {
208 const saved = await jira<RawComment>($, config, 'PUT', `${comments}/${previous.id}`, body)
209
210 return { id: saved.id, updated: saved.updated, update }
211 }
212 }
213 const saved = await jira<RawComment>($, config, 'POST', comments, body)
214
215 return { id: saved.id, updated: saved.updated, update }
216}
217
218const activityKey = (issue: string, day: string) => `activity:${issue}:${day}`
219
220async function readActivity($: Engine, issue: string, day: string) {
221 return ((await $.store.get(activityKey(issue, day))) as DayActivity | undefined) ?? EMPTY_ACTIVITY
222}
223
224function record($: Engine, issue: string, day: string, change: (activity: DayActivity) => DayActivity) {
225 recording = recording
226 .then(async () => {
227 const activity = await readActivity($, issue, day)
228 await $.store.set(activityKey(issue, day), { ...change(activity), actions: (activity.actions ?? 0) + 1 })
229 if ((await read($, link))?.key === issue) await refreshProgress($, issue)
230 })
231 .catch(error => $.ui.log(`jira-log: could not record activity: ${error}`, { to: 'debug' }))
232
233 return recording
234}
235
236// A comment without a count predates counting and covered its whole day.
237async function postedActionsOn($: Engine, issue: string, day: string, actions: number) {
238 const posted = (await $.store.get(commentKey(issue, day))) as DailyComment | undefined
239 const covered = (await $.store.get(coveredKey(issue, day))) as CoveredDay | undefined
240
241 return Math.max(posted === undefined ? 0 : (posted.actions ?? actions), covered?.actions ?? 0)
242}
243
244async function unpostedDays($: Engine, issue: string, day: string) {
245 const found: UnpostedDay[] = []
246 for (const earlier of earlierDaysIn(await $.store.keys(), issue, day)) {
247 const activity = await readActivity($, issue, earlier)
248 const actions = activity.actions ?? 0
249 const unposted = actions - (await postedActionsOn($, issue, earlier, actions))
250 if (unposted > 0) found.push({ day: earlier, activity, actions, unposted })
251 }
252
253 return found
254}
255
256async function earlierUnposted($: Engine, issue: string, day: string) {
257 if (earlierCount?.issue !== issue || earlierCount.day !== day) {
258 const days = await unpostedDays($, issue, day)
259 earlierCount = { issue, day, actions: days.reduce((sum, one) => sum + one.unposted, 0) }
260 }
261
262 return earlierCount.actions
263}
264
265async function refreshProgress($: Engine, issue: string) {
266 const day = await today($)
267 const { actions = 0 } = await readActivity($, issue, day)
268 const posted = (await $.store.get(commentKey(issue, day))) as DailyComment | undefined
269 const current = {
270 day,
271 actions,
272 postedActions: posted === undefined ? 0 : (posted.actions ?? actions),
273 postedAt: posted === undefined ? null : (posted.postedAt ?? (Date.parse(posted.updated) || null)),
274 earlier: await earlierUnposted($, issue, day),
275 }
276 await update($, progress, () => current)
277 $.ui.status(`Jira ${issue}${progressOf(current, day).short ? ` · ${progressOf(current, day).short}` : ''}`)
278}
279
280async function recordCommand($: Engine, issue: string, command: string, isPassing: boolean, output: string) {
281 const day = await today($)
282 if (COMMIT.test(command)) {
283 if (!isPassing) return
284 const { exitCode, stdout } = await $.process.run(['git', 'log', '-1', '--format=%s'])
285 const subject = stdout.trim()
286 if (exitCode !== 0 || subject === '') return
287
288 return record($, issue, day, activity => ({ ...activity, commits: addOnce(activity.commits, subject, 50) }))
289 }
290 const pullRequest = isPassing ? reviewRequestOf(command, output) : undefined
291 if (pullRequest !== undefined) return recordReviewRequest($, issue, pullRequest)
292 if (TEST_RUN.test(command)) {
293 const test = { command: command.slice(0, 120), isPassing }
294
295 return record($, issue, day, activity => ({ ...activity, tests: [...activity.tests, test].slice(-20) }))
296 }
297}
298
299async function recordReviewRequest($: Engine, issue: string, pullRequest: string) {
300 return record($, issue, await today($), activity => ({
301 ...activity,
302 pullRequests: addOnce(activity.pullRequests, pullRequest, 20),
303 }))
304}
305
306async function recordArtifact($: Engine, issue: string, artifact: { url: string; title: string }) {
307 return record($, issue, await today($), activity => ({
308 ...activity,
309 artifacts: addArtifact(activity.artifacts, artifact),
310 }))
311}
312
313async function recordEdit($: Engine, issue: string, path: string) {
314 const file = relativeTo(await $.session.root(), path)
315
316 return record($, issue, await today($), activity => ({ ...activity, files: addOnce(activity.files, file, 200) }))
317}
318
319const loadConfig = async ($: Engine) =>
320 ((await $.store.get('config')) ?? {}) as Partial<JiraConfig>
321
322const isComplete = (config: Partial<JiraConfig>): config is JiraConfig =>
323 Boolean(config.site && config.email && config.tokenBlob)
324
325async function requireConfig($: Engine) {
326 const config = await loadConfig($)
327 if (isComplete(config)) return config
328 const missing = [
329 !config.site && 'site URL',
330 !config.email && 'email',
331 !config.tokenBlob && 'API token',
332 ].filter(Boolean)
333 throw new Error(`Jira is not set up yet, missing ${missing.join(', ')}: run /jira setup.`)
334}
335
336async function requireProject($: Engine) {
337 const config = await requireConfig($)
338 if (!config.project) throw new Error('creating issues needs a project key: add one in /jira setup.')
339
340 return { config, project: config.project }
341}
342
343const loadLinks = async ($: Engine) =>
344 ((await $.store.get('links')) ?? {}) as Record<string, JiraIssue>
345
346const commentKey = (issue: string, day: string) => `comment:${issue}:${day}`
347
348const coveredKey = (issue: string, day: string) => `covered:${issue}:${day}`
349
350const today = async ($: Engine) => dayOf(await $.clock.now())
351
352async function showStatus($: Engine, issue: JiraIssue | null) {
353 if (issue !== null) return refreshProgress($, issue.key)
354 await update($, progress, () => null)
355 $.ui.status(undefined)
356}
357
358const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
359
360const fail = ($: Engine) => (error: unknown) => $.ui.toast(`Jira: ${message(error)}`)
361
362async function attempt($: Engine, label: string, work: () => Promise<void>) {
363 await update($, busy, () => label)
364 await update($, notice, () => null)
365 try {
366 await work()
367 } catch (error) {
368 await update($, notice, () => message(error))
369 $.ui.toast(`Jira: ${message(error)}`)
370 } finally {
371 await update($, busy, () => null)
372 }
373}
374
375async function openPane($: Engine, next: JiraView) {
376 await update($, view, () => next)
377 await $.ui.open({ id: PANE, title: 'Jira', focus: true })
378}
379
380async function refresh($: Engine) {
381 const config = await loadConfig($)
382 await update($, isConfigured, () => isComplete(config))
383 const linked = (await loadLinks($))[await $.session.root()] ?? null
384 await update($, link, () => linked)
385 await showStatus($, linked)
386}
387
388async function setLink($: Engine, issue: JiraIssue | null) {
389 const root = await $.session.root()
390 const { [root]: _, ...others } = await loadLinks($)
391 await $.store.set('links', issue === null ? others : { ...others, [root]: issue })
392 await update($, link, () => issue)
393 await showStatus($, issue)
394}
395
396async function openPicker($: Engine) {
397 if (!isComplete(await loadConfig($))) return openPane($, 'setup')
398 await openPane($, 'pick')
399 await attempt($, 'Loading your open issues...', async () => {
400 const found = await myOpenIssues($, await requireConfig($))
401 await update($, issues, () => found)
402 })
403}
404
405async function linkTo($: Engine, issue: JiraIssue) {
406 await setLink($, issue)
407 await update($, isSkipped, () => false)
408 $.ui.toast(`Linked this worktree to ${issue.key}`)
409 await $.ui.close({ id: PANE })
410 if (issue.statusCategory === 'new') await advance($, issue.key, IN_PROGRESS, 'work started')
411}
412
413// Automatic moves never interrupt the work: a missing transition or an error is a toast.
414async function advance($: Engine, key: string, target: string, reason: string) {
415 try {
416 const config = await requireConfig($)
417 const current = await getIssue($, config, key)
418 if (isSameStatus(current.status, target) || current.statusCategory === 'done') return
419 const transition = (await transitionsOf($, config, key)).find(one => isSameStatus(one.to, target))
420 if (transition === undefined) {
421 $.ui.toast(`${key}: no transition from ${current.status} to ${target}; left as is`)
422 return
423 }
424 await applyTransition($, config, key, transition.id)
425 $.ui.toast(`Moved ${key} to ${target} (${reason})`)
426 } catch (error) {
427 $.ui.toast(`Jira: could not move ${key} to ${target}: ${message(error)}`)
428 }
429}
430
431async function openMoves($: Engine) {
432 const issue = await read($, link)
433 if (issue === null) return 'No Jira issue is linked to this worktree: run /jira link first.'
434 await openPane($, 'move')
435 await attempt($, `Loading the moves for ${issue.key}...`, async () => {
436 const found = await transitionsOf($, await requireConfig($), issue.key)
437 await update($, transitions, () => found)
438 })
439
440 return `Pick where ${issue.key} goes in the Jira pane.`
441}
442
443async function move($: Engine, id: string) {
444 const issue = await read($, link)
445 if (issue === null) return
446 await attempt($, `Moving ${issue.key}...`, async () => {
447 const moved = await applyTransition($, await requireConfig($), issue.key, id)
448 $.ui.toast(`Moved ${moved.key} to ${moved.status}`)
449 await $.ui.close({ id: PANE })
450 })
451}
452
453async function linkByKey($: Engine, key: string) {
454 const issue = await getIssue($, await requireConfig($), key.trim().toUpperCase())
455 await linkTo($, issue)
456
457 return `Linked this worktree to ${issue.key}: ${issue.summary} [${issue.status}].`
458}
459
460async function storeSetting($: Engine, field: SetupField, value: string) {
461 const trimmed = value.trim()
462 if (trimmed === '') return
463 let saved: Partial<JiraConfig>
464 if (field === 'token') {
465 saved = { ...(await loadConfig($)), tokenBlob: await powershell($, SEAL, trimmed, 'encrypt') }
466 } else if (field === 'project') {
467 saved = { ...(await loadConfig($)), project: trimmed.toUpperCase() }
468 } else if (field === 'site') {
469 const site = /^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`
470 if (!site.startsWith('https://')) throw new Error('the site URL must use https.')
471 saved = { ...(await loadConfig($)), site: site.replace(/\/+$/, '') }
472 } else {
473 saved = { ...(await loadConfig($)), email: trimmed }
474 }
475 await $.store.set('config', saved)
476 delete typed[field]
477 await update($, isConfigured, () => isComplete(saved))
478 $.ui.invalidate('ui.render')
479}
480
481async function saveSetting($: Engine, field: SetupField, value: string) {
482 await attempt($, 'Saving...', async () => {
483 await storeSetting($, field, value)
484 await update($, notice, () => `Saved the ${SETUP_LABELS[field]}.`)
485 })
486}
487
488async function testConnection($: Engine) {
489 await attempt($, 'Connecting...', async () => {
490 for (const [field, value] of Object.entries(typed) as [SetupField, string][]) {
491 await storeSetting($, field, value)
492 }
493 const { displayName: name } = await whoAmI($, await requireConfig($))
494 $.ui.toast(`Connected to Jira as ${name}`)
495 if ((await read($, link)) === null) await openPicker($)
496 else await update($, notice, () => `Connected as ${name}.`)
497 })
498}
499
500// The fork reads this session's transcript; before the first reply there is none.
501async function ask($: Engine, prompt: string) {
502 const forked = await $.model.fork({ prompt })
503 const reply =
504 !forked.isAnswered && forked.reason === 'nothing-to-fork'
505 ? await $.model.complete({ model: await $.session.model(), prompt })
506 : forked
507 if (!reply.isAnswered) throw new Error(`the model gave no draft (${reply.reason})`)
508
509 return reply.text
510}
511
512async function draftPrompt(
513 $: Engine,
514 issue: JiraIssue,
515 day: string,
516 moves: readonly JiraTransition[],
517 unposted: readonly UnpostedDay[],
518) {
519 const activity = await readActivity($, issue.key, day)
520 const posted = (await $.store.get(commentKey(issue.key, day))) as DailyComment | undefined
521 const earlier = posted
522 ? `Already posted today; your draft replaces it, so keep what still holds:\n${JSON.stringify(posted.update)}`
523 : 'Nothing has been posted today yet.'
524 const statuses = moves.map(one => `"${one.to}"`).join(', ')
525 const days = unposted.map(one => one.day)
526 const byDay = Object.fromEntries(unposted.map(one => [one.day, one.activity]))
527
528 return [
529 `Draft today's progress comment for Jira issue ${issue.key} "${issue.summary}" (${day}).` +
530 (days.length === 0 ? '' : ` It also covers ${days.join(', ')}, whose work never reached Jira.`),
531 `Work recorded on this issue today across every Claude Code session:\n${JSON.stringify(activity, null, 2)}`,
532 ...(days.length === 0
533 ? []
534 : [`Work recorded on those earlier days, by day; include it too:\n${JSON.stringify(byDay, null, 2)}`]),
535 earlier,
536 'Use that record and this conversation.',
537 UPDATE_RULES,
538 `The issue is in "${issue.status}". Its workflow can move it to: ${statuses || 'nothing from here'}. ` +
539 'Suggest a move only when this conversation or the record shows the work has reached one of those ' +
540 'statuses: a pull or merge request opened (the record lists it, or the person says they raised one) ' +
541 'points to a review status; merged or finished points to a done status. Then set move.to to that exact ' +
542 'name and move.reason to one short sentence citing the evidence. Otherwise leave move.to empty; work ' +
543 'having happened is no reason to move.',
544 UPDATE_AND_MOVE_SHAPE,
545 ].join('\n\n')
546}
547
548async function startDraft($: Engine) {
549 const issue = await read($, link)
550 if (issue === null) return 'No Jira issue is linked to this worktree: run /jira link first.'
551 const config = await requireConfig($)
552 await update($, draft, () => null)
553 await update($, suggestion, () => null)
554 await openPane($, 'draft')
555 await attempt($, `Drafting today's update for ${issue.key}...`, async () => {
556 const day = await today($)
557 const unposted = await unpostedDays($, issue.key, day)
558 drafted = {
559 actions: (await readActivity($, issue.key, day)).actions,
560 earlier: unposted.map(one => ({ day: one.day, actions: one.actions })),
561 }
562 const current = await getIssue($, config, issue.key)
563 const moves = await transitionsOf($, config, issue.key).catch(() => [])
564 const reply = await ask($, await draftPrompt($, current, day, moves, unposted))
565 await update($, draft, () => parseUpdate(reply))
566 await update($, suggestion, () => parseSuggestion(reply, moves, current.status))
567 })
568
569 return `Drafted today's update for ${issue.key}: review it in the Jira pane.`
570}
571
572async function revise($: Engine, instruction: string) {
573 const current = await read($, draft)
574 if (current === null || instruction.trim() === '') return
575 await attempt($, 'Revising...', async () => {
576 const prompt =
577 `This is the current draft of today's Jira comment:\n${JSON.stringify(current)}\n\n` +
578 `Revise it as follows: ${instruction.trim()}\n\n${UPDATE_RULES}\n\n${UPDATE_SHAPE}`
579 const revised = parseUpdate(await ask($, prompt))
580 await update($, draft, () => revised)
581 })
582}
583
584async function post($: Engine) {
585 const [issue, current] = [await read($, link), await read($, draft)]
586 if (issue === null || current === null) return
587 await attempt($, `Posting to ${issue.key}...`, async () => {
588 const day = await today($)
589 const key = commentKey(issue.key, day)
590 const previous = (await $.store.get(key)) as DailyComment | undefined
591 const saved = await saveDailyComment($, await requireConfig($), issue.key, day, current, previous)
592 const snapshot = drafted ?? { actions: (await readActivity($, issue.key, day)).actions, earlier: [] }
593 await $.store.set(key, { ...saved, actions: snapshot.actions, postedAt: await $.clock.now() })
594 for (const one of snapshot.earlier) {
595 await $.store.set(coveredKey(issue.key, one.day), { actions: one.actions, in: day } satisfies CoveredDay)
596 }
597 earlierCount = undefined
598 await refreshProgress($, issue.key)
599 await update($, draft, () => null)
600 $.ui.toast(`${saved.id === previous?.id ? 'Updated' : 'Posted'} today's comment on ${issue.key}`)
601 if ((await read($, suggestion)) === null) await $.ui.close({ id: PANE })
602 })
603}
604
605async function acceptSuggestion($: Engine) {
606 const [issue, suggested] = [await read($, link), await read($, suggestion)]
607 if (issue === null || suggested === null) return
608 await attempt($, `Moving ${issue.key} to ${suggested.to}...`, async () => {
609 const moved = await applyTransition($, await requireConfig($), issue.key, suggested.id)
610 await update($, suggestion, () => null)
611 $.ui.toast(`Moved ${moved.key} to ${moved.status}`)
612 if ((await read($, draft)) === null) await $.ui.close({ id: PANE })
613 })
614}
615
616async function declineSuggestion($: Engine) {
617 await update($, suggestion, () => null)
618 if ((await read($, draft)) === null) await $.ui.close({ id: PANE })
619}
620
621async function discard($: Engine) {
622 await update($, draft, () => null)
623 await update($, suggestion, () => null)
624 await update($, newIssue, () => null)
625 await $.ui.close({ id: PANE })
626}
627
628// With `isManual` the model is not asked: the person fills in a blank ticket in the pane.
629async function startCreate($: Engine, focus = '', isManual = false) {
630 const { config, project } = await requireProject($)
631 await update($, newIssue, () => null)
632 await openPane($, 'create')
633 await attempt($, isManual ? `Opening a new ${project} issue...` : `Drafting a new ${project} issue...`, async () => {
634 // Without Jira Software, or a scrum board, there are no sprints and the issue goes to the backlog.
635 const [types, open] = await Promise.all([
636 creatableTypes($, config, project),
637 openSprints($, config, project).catch(error => {
638 $.ui.log(`jira-log: could not load the sprints of ${project}: ${message(error)}`, { to: 'debug' })
639 return []
640 }),
641 ])
642 await update($, issueTypes, () => types)
643 await update($, sprints, () => open)
644 const fallback = types.find(type => isSameStatus(type, 'Task')) ?? types[0] ?? 'Task'
645 const blank = emptyIssue(fallback, currentSprint(open))
646 const started = isManual ? blank : { ...blank, ...parseNewIssue(await ask($, newIssuePrompt(focus.trim()))) }
647 await update($, newIssue, () => started)
648 })
649
650 return isManual
651 ? `Opened a blank ${project} issue: write it in the Jira pane.`
652 : `Drafted a new ${project} issue: review it in the Jira pane.`
653}
654
655const editIssue = ($: Engine, change: Partial<JiraNewIssue>) =>
656 update($, newIssue, one => (one === null ? one : { ...one, ...change }))
657
658async function reviseIssue($: Engine, instruction: string) {
659 const current = await read($, newIssue)
660 if (current === null || instruction.trim() === '') return
661 await attempt($, 'Revising...', async () => {
662 const { summary, description } = current
663 const prompt =
664 `This is the current draft of a new Jira ticket:\n${JSON.stringify({ summary, description })}\n\n` +
665 `Revise it as follows: ${instruction.trim()}\n\n` +
666 'Keep it a description of work to be done, under 120 words.\n\n' +
667 ISSUE_SHAPE
668 const revised = parseNewIssue(await ask($, prompt))
669 await update($, newIssue, one => (one === null ? one : { ...one, ...revised }))
670 })
671}
672
673async function createIssue($: Engine) {
674 const current = await read($, newIssue)
675 if (current === null || !isIssueReady(current)) return
676 await attempt($, 'Creating the issue...', async () => {
677 const { config, project } = await requireProject($)
678 const created = await jira<{ key: string }>($, config, 'POST', '/rest/api/3/issue', {
679 fields: {
680 project: { key: project },
681 summary: current.summary.trim(),
682 issuetype: { name: current.issueType },
683 description: toIssueDocument(current),
684 },
685 })
686 if (current.sprint !== null) {
687 const name = (await read($, sprints)).find(one => one.id === current.sprint)?.name ?? 'the sprint'
688 await addToSprint($, config, current.sprint, created.key).catch(error =>
689 $.ui.toast(`Created ${created.key} but could not add it to ${name}: ${message(error)}`),
690 )
691 }
692 const { accountId } = await whoAmI($, config)
693 const assignee = `/rest/api/3/issue/${encodeURIComponent(created.key)}/assignee`
694 await jira($, config, 'PUT', assignee, { accountId }).catch(error =>
695 $.ui.toast(`Created ${created.key} but could not assign it to you: ${message(error)}`),
696 )
697 await update($, newIssue, () => null)
698 await linkTo($, await getIssue($, config, created.key))
699 })
700}
701
702export const register: Register = on => {
703 on('session.start', async ($, e, next) => {
704 await $.command.register({
705 name: 'jira',
706 description: "Link this worktree to a Jira issue and post today's update",
707 argumentHint: '[setup | link [KEY] | new [--manual | what for] | update | status | unlink]',
708 })
709 await refresh($)
710
711 return next(e)
712 })
713
714 on('command.run', { command: 'jira' }, async ($, e) => {
715 const [verb = '', argument = ''] = e.args.trim().split(/\s+/)
716 try {
717 switch (verb.toLowerCase()) {
718 case 'setup':
719 await openPane($, 'setup')
720 return { text: 'Jira setup is open in the Jira pane.' }
721 case 'link':
722 if (argument !== '') return { text: await linkByKey($, argument) }
723 await openPicker($)
724 return { text: 'Pick an issue in the Jira pane.' }
725 case 'unlink':
726 await setLink($, null)
727 return { text: 'This worktree is no longer linked to a Jira issue.' }
728 case 'update':
729 return { text: await startDraft($) }
730 case 'new':
731 if (argument.toLowerCase() === '--manual') return { text: await startCreate($, '', true) }
732 return { text: await startCreate($, e.args.trim().slice(verb.length)) }
733 case 'status':
734 return { text: await openMoves($) }
735 case '': {
736 const issue = await read($, link)
737 if (!isComplete(await loadConfig($))) return { text: 'Jira is not set up yet: run /jira setup.' }
738 return {
739 text:
740 issue === null
741 ? 'No Jira issue is linked to this worktree: run /jira link.'
742 : `Linked to ${issue.key}: ${issue.summary} [${issue.status}]. Run /jira update to draft today's comment.`,
743 }
744 }
745 default:
746 return { text: USAGE }
747 }
748 } catch (error) {
749 return { text: `Jira: ${message(error)}` }
750 }
751 })
752
753 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
754 const ran = await next(e)
755 const issue = await read($, link)
756 if (issue !== null && ran.deny === undefined && ran.isError !== true) {
757 void recordEdit($, issue.key, e.file_path)
758 }
759
760 return ran
761 })
762
763 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
764 const ran = await next(e)
765 const issue = await read($, link)
766 if (issue !== null && ran.deny === undefined && ran.isError !== true) {
767 void recordEdit($, issue.key, e.file_path)
768 }
769
770 return ran
771 })
772
773 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
774 const ran = await next(e)
775 const issue = await read($, link)
776 if (issue !== null && ran.deny === undefined) {
777 void recordCommand($, issue.key, e.command, ran.isError !== true, ran.text ?? '')
778 }
779
780 return ran
781 })
782
783 on('tool.call', { tool: 'Artifact' }, async ($, e, next) => {
784 const ran = await next(e)
785 const issue = await read($, link)
786 const artifact = publishedArtifactOf(e, ran.result, ran.text ?? '')
787 if (issue !== null && artifact !== undefined && ran.deny === undefined && ran.isError !== true) {
788 void recordArtifact($, issue.key, artifact)
789 }
790
791 return ran
792 })
793
794 on('tool.call', async ($, e, next) => {
795 const ran = await next(e)
796 if (!isReviewTool(e.tool) || ran.deny !== undefined || ran.isError === true) return ran
797 const issue = await read($, link)
798 if (issue !== null) void recordReviewRequest($, issue.key, reviewUrlIn(ran.text ?? '') ?? e.tool)
799
800 return ran
801 })
802
803 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
804 if (e.props.hasSurvey) return next(e)
805 const { Box, Button, Text } = $.ui.resolve(e)
806 // The band holds one tree: stack what the plugins beneath draw (git-push's line) under ours,
807 // with a rule between. Nothing beneath leaves the engine's own band, which draws no line.
808 const stacked = async (row: RenderElement) => {
809 const below = await next(e)
810
811 return (
812 <Box flexDirection="column">
813 {row}
814 {below.type !== 'engine' && <Text dimColor wrap="truncate">{'─'.repeat(e.props.bodyColumns)}</Text>}
815 {below}
816 </Box>
817 )
818 }
819 const linked = await read($, link)
820 if (linked !== null) {
821 if (await read($, isProgressHidden)) return next(e)
822 const { tone, text } = progressOf(await read($, progress), await today($))
823
824 return stacked(
825 <Box flexDirection="row" gap={1}>
826 <Text dimColor>
827 {linked.key} · {linked.status} ·
828 </Text>
829 <Text dimColor={tone === 'quiet'} color={tone === 'due' ? 'warning' : tone === 'done' ? 'success' : undefined}>
830 {text}
831 </Text>
832 {tone === 'due' && (
833 <Button
834 key="draft-update"
835 label={text.startsWith('posted') ? 'Update' : 'Draft update'}
836 variant="primary"
837 onPress={() => void startDraft($).catch(fail($))}
838 />
839 )}
840 <Button key="hide-progress" label="Hide" onPress={() => void update($, isProgressHidden, () => true)} />
841 </Box>
842 )
843 }
844 if (await read($, isSkipped)) return next(e)
845 const isReady = await read($, isConfigured)
846
847 return stacked(
848 <Box flexDirection="row" gap={1}>
849 <Text dimColor>{isReady ? 'Jira: no issue linked to this worktree' : 'Jira log is not set up'}</Text>
850 <Button
851 key="link"
852 label={isReady ? 'Pick issue' : 'Set up'}
853 variant="primary"
854 onPress={() => void (isReady ? openPicker($) : openPane($, 'setup'))}
855 />
856 {isReady && <Button key="new" label="Create new" onPress={() => void startCreate($).catch(fail($))} />}
857 {isReady && (
858 <Button key="manual" label="Write it myself" onPress={() => void startCreate($, '', true).catch(fail($))} />
859 )}
860 <Button key="skip" label="Skip" onPress={() => void update($, isSkipped, () => true)} />
861 </Box>
862 )
863 })
864
865 // The description area posts its whole text on each change.
866 on('ui.message', async ($, e, next) => {
867 const { description } = (e.data ?? {}) as { description?: unknown }
868 if (e.requestId !== PANE || e.element !== 'description' || typeof description !== 'string') return next(e)
869 await editIssue($, { description })
870
871 return {}
872 })
873
874 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
875 if (e.surface === 'mobile') {
876 const { Text } = $.ui.resolve(e)
877
878 return <Text dimColor>The Jira pane needs the terminal or the desktop app.</Text>
879 }
880 const { Box, Button, Input, Markdown, Select, Text } = $.ui.resolve(e)
881 // VS Code draws no Client, so there the description is a one-line field.
882 const { Client } = e.surface === 'vscode' ? { Client: undefined } : $.ui.resolve(e)
883 const working = await read($, busy)
884 const said = await read($, notice)
885 const footer = [
886 working !== null && <Text dimColor>{working}</Text>,
887 said !== null && <Text color="yellow">{said}</Text>,
888 ]
889 const current = await read($, view)
890
891 if (current === 'setup') {
892 const config = await loadConfig($)
893
894 return (
895 <Box flexDirection="column" gap={1}>
896 <Text bold>Jira Cloud connection</Text>
897 <Input
898 key="site"
899 label="Site URL "
900 placeholder="https://your-team.atlassian.net"
901 value={typed.site ?? config.site}
902 onInput={value => void (typed.site = value)}
903 onSubmit={value => void saveSetting($, 'site', value)}
904 />
905 <Input
906 key="email"
907 label="Email "
908 placeholder="the email you sign in to Jira with"
909 value={typed.email ?? config.email}
910 onInput={value => void (typed.email = value)}
911 onSubmit={value => void saveSetting($, 'email', value)}
912 />
913 <Input
914 key="token"
915 label="API token "
916 placeholder={
917 config.tokenBlob
918 ? 'saved and encrypted; paste a new one to replace it'
919 : 'create one at id.atlassian.com > Security > API tokens'
920 }
921 onInput={value => void (typed.token = value)}
922 onSubmit={value => void saveSetting($, 'token', value)}
923 />
924 <Input
925 key="project"
926 label="Project "
927 placeholder="key new issues go to, e.g. CPC (optional)"
928 value={typed.project ?? config.project}
929 onInput={value => void (typed.project = value)}
930 onSubmit={value => void saveSetting($, 'project', value)}
931 />
932 <Button
933 key="test"
934 label="Save and test connection"
935 variant="primary"
936 onPress={() => void testConnection($)}
937 />
938 {footer}
939 </Box>
940 )
941 }
942
943 if (current === 'pick') {
944 const list = await read($, issues)
945
946 return (
947 <Box flexDirection="column" gap={1}>
948 <Text bold>Link this worktree to one of your open issues</Text>
949 {list.length > 0 && (
950 <Select
951 key="issue"
952 options={list.map(issue => ({
953 value: issue.key,
954 label: `${issue.key} ${issue.summary} [${issue.status}]`,
955 }))}
956 onSelect={key => {
957 const issue = list.find(one => one.key === key)
958 if (issue !== undefined) void linkTo($, issue)
959 }}
960 />
961 )}
962 {working === null && list.length === 0 && <Text dimColor>No open issues are assigned to you.</Text>}
963 <Input
964 key="by-key"
965 label="Or a key "
966 placeholder="PROJ-123"
967 submitLabel="link"
968 onSubmit={value => void attempt($, `Looking up ${value}...`, () => linkByKey($, value).then(() => {}))}
969 />
970 <Button key="cancel" label="Cancel" role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
971 {footer}
972 </Box>
973 )
974 }
975
976 if (current === 'create') {
977 const drafted = await read($, newIssue)
978 const types = await read($, issueTypes)
979 const open = await read($, sprints)
980
981 return (
982 <Box flexDirection="column" gap={1}>
983 <Text bold>New issue</Text>
984 {drafted !== null && working !== null && <Text bold>{drafted.summary}</Text>}
985 {drafted !== null && working !== null && drafted.description !== '' && <Text>{drafted.description}</Text>}
986 {drafted !== null && working === null && (
987 <Box flexDirection="column" gap={1}>
988 <Input
989 key="summary"
990 label="Title "
991 placeholder="what needs doing, as a short imperative title"
992 value={drafted.summary}
993 onInput={value => void editIssue($, { summary: cappedSummary(value) })}
994 onSubmit={value => void editIssue($, { summary: cappedSummary(value) })}
995 />
996 {Client === undefined ? (
997 <Input
998 key="description"
999 label="Description "
1000 placeholder={DESCRIPTION_PLACEHOLDER}
1001 value={drafted.description}
1002 onInput={value => void editIssue($, { description: value })}
1003 onSubmit={value => void editIssue($, { description: value })}
1004 />
1005 ) : (
1006 <Box flexDirection="column">
1007 <Box flexDirection="row" gap={1}>
1008 <Text>Description</Text>
1009 <Text dimColor>click to write · Enter adds a line · Esc leaves the box</Text>
1010 </Box>
1011 <Client
1012 key="description"
1013 module="./description.tsx"
1014 width="100%"
1015 props={{ text: drafted.description, placeholder: DESCRIPTION_PLACEHOLDER } satisfies DescriptionProps}
1016 />
1017 </Box>
1018 )}
1019 {types.length > 0 && (
1020 <Select
1021 key="type"
1022 label="Type "
1023 value={drafted.issueType}
1024 options={types.map(type => ({ value: type, label: type }))}
1025 onSelect={type => void editIssue($, { issueType: type })}
1026 />
1027 )}
1028 {open.length > 0 && (
1029 <Select
1030 key="sprint"
1031 label="Sprint "
1032 value={sprintValue(drafted.sprint)}
1033 options={sprintOptions(open)}
1034 onSelect={value => void editIssue($, { sprint: sprintOf(value) })}
1035 />
1036 )}
1037 {isIssueReady(drafted) && (
1038 <Input
1039 key="revise-issue"
1040 label="Revise "
1041 placeholder="e.g. make it a bug fix and mention the login page"
1042 submitLabel="revise"
1043 onSubmit={value => void reviseIssue($, value)}
1044 />
1045 )}
1046 <Box flexDirection="row" gap={2}>
1047 {isIssueReady(drafted) && (
1048 <Button key="create" label="Create and link" variant="primary" onPress={() => void createIssue($)} />
1049 )}
1050 <Button key="discard" label="Discard" role="dismiss" onPress={() => void discard($)} />
1051 </Box>
1052 </Box>
1053 )}
1054 {footer}
1055 </Box>
1056 )
1057 }
1058
1059 if (current === 'move') {
1060 const [issue, moves] = [await read($, link), await read($, transitions)]
1061
1062 return (
1063 <Box flexDirection="column" gap={1}>
1064 <Text bold>{issue === null ? 'No linked issue' : `Move ${issue.key} (now ${issue.status})`}</Text>
1065 {moves.length > 0 && (
1066 <Select
1067 key="move"
1068 options={moves.map(one => ({
1069 value: one.id,
1070 label: isSameStatus(one.name, one.to) ? one.to : `${one.name} → ${one.to}`,
1071 }))}
1072 onSelect={id => void move($, id)}
1073 />
1074 )}
1075 {working === null && moves.length === 0 && <Text dimColor>No moves are available from here.</Text>}
1076 <Button key="cancel" label="Cancel" role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
1077 {footer}
1078 </Box>
1079 )
1080 }
1081
1082 const issue = await read($, link)
1083 const drafted = await read($, draft)
1084 const suggested = await read($, suggestion)
1085
1086 return (
1087 <Box flexDirection="column" gap={1}>
1088 <Text bold>{issue === null ? 'No linked issue' : `${issue.key}: today's update`}</Text>
1089 {suggested !== null && issue !== null && working === null && (
1090 <Box flexDirection="column">
1091 <Text>
1092 Suggested: move {issue.key} from {issue.status} to {suggested.to}
1093 </Text>
1094 {suggested.reason !== '' && <Text dimColor>{suggested.reason}</Text>}
1095 <Box flexDirection="row" gap={2}>
1096 <Button key="accept-move" label={`Move to ${suggested.to}`} onPress={() => void acceptSuggestion($)} />
1097 <Button key="decline-move" label="Not now" onPress={() => void declineSuggestion($)} />
1098 </Box>
1099 </Box>
1100 )}
1101 {drafted !== null && <Markdown text={toMarkdown(drafted)} />}
1102 {drafted !== null && working === null && (
1103 <Box flexDirection="column" gap={1}>
1104 <Input
1105 key="revise"
1106 label="Revise "
1107 placeholder="e.g. mention we are waiting on staging access"
1108 submitLabel="revise"
1109 onSubmit={value => void revise($, value)}
1110 />
1111 <Box flexDirection="row" gap={2}>
1112 <Button key="post" label="Post to Jira" variant="primary" onPress={() => void post($)} />
1113 <Button key="discard" label="Discard" role="dismiss" onPress={() => void discard($)} />
1114 </Box>
1115 </Box>
1116 )}
1117 {footer}
1118 </Box>
1119 )
1120 })
1121}
1122hooks/format.ts 338 lines1import type { JiraIssue, JiraNewIssue, JiraProgress, JiraSprint, JiraSuggestion, JiraTransition, JiraUpdate } from '../types'
2
3export type JiraConfig = { site: string; email: string; tokenBlob: string; project?: string }
4
5// Older comments carry no `actions` or `postedAt`, and their `update` holds completed, pending, blockers
6// and achievements lists in place of `notes`.
7export type DailyComment = { id: string; updated: string; update: JiraUpdate; actions?: number; postedAt?: number }
8
9export type DayActivity = {
10 files: string[]
11 commits: string[]
12 tests: { command: string; isPassing: boolean }[]
13 // A pull or merge request's URL, or the command or tool that opened it when no URL was printed.
14 pullRequests: string[]
15 // Published Artifacts by URL; the title is empty when no publish named one. Absent on older days.
16 artifacts?: { url: string; title: string }[]
17 // Every action recorded today, counted even when the lists above dedupe or roll it off; absent on older days.
18 actions?: number
19}
20
21// An earlier day posted under a later day's comment rather than one of its own.
22export type CoveredDay = { actions: number; in: string }
23
24export type UnpostedDay = { day: string; activity: DayActivity; actions: number; unposted: number }
25
26export type RawIssue = {
27 key: string
28 fields: { summary: string; status: { name: string; statusCategory: { key: string } } }
29}
30
31export type RawComment = { id: string; updated: string }
32
33export type RawTransition = { id: string; name: string; to: { name: string } }
34
35export type RawSprint = { id: number; name: string; state: string }
36
37export const IN_PROGRESS = 'In Progress'
38
39export const EMPTY_ACTIVITY: DayActivity = {
40 files: [],
41 commits: [],
42 tests: [],
43 pullRequests: [],
44 artifacts: [],
45 actions: 0,
46}
47
48// How far back unposted work is still offered to the draft.
49export const LOOKBACK_DAYS = 14
50
51const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`
52
53const clockOf = (at: number) => {
54 const date = new Date(at)
55
56 return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
57}
58
59// What the band and status line say about the linked issue's work that is not in Jira yet.
60export function progressOf(progress: JiraProgress | null, day: string) {
61 const current = progress?.day === day ? progress : null
62 const actions = current?.actions ?? 0
63 const earlier = current?.earlier ?? 0
64 const unposted = Math.max(0, actions - (current?.postedActions ?? 0)) + earlier
65 if (current?.postedAt == null) {
66 if (unposted === 0) return { tone: 'quiet', text: 'nothing recorded today', short: '' }
67 const text =
68 earlier === 0
69 ? `${plural(actions, 'action')} today, not in Jira yet`
70 : actions === 0
71 ? `${plural(earlier, 'action')} from earlier days, not in Jira yet`
72 : `${plural(actions, 'action')} today and ${earlier} earlier, not in Jira yet`
73
74 return { tone: 'due', text, short: `${unposted} unposted` }
75 }
76 if (unposted === 0) {
77 return { tone: 'done', text: `✓ today's update posted ${clockOf(current.postedAt)}`, short: '✓' }
78 }
79
80 return {
81 tone: 'due',
82 text: `posted ${clockOf(current.postedAt)}, ${plural(unposted, 'action')} ${earlier === 0 ? 'since' : 'not in Jira yet'}`,
83 short: `${unposted} unposted`,
84 }
85}
86
87export const TEST_RUN =
88 /\b(pytest|vitest|jest|mocha|unittest|go test|cargo test|(?:npm|pnpm|yarn|bun)(?: run)? test)\b/
89export const COMMIT = /\bgit\s+commit\b/
90const REVIEW_COMMAND = /\b(gh\s+pr\s+create|glab\s+mr\s+create|az\s+repos\s+pr\s+create)\b/
91const REVIEW_TOOL = /create_?(?:pull_?request|merge_?request)/i
92// GitHub /pull/7, GitLab /merge_requests/7, Azure DevOps /pullrequest/7.
93const REVIEW_URL = /https?:\/\/[^\s"'<>]+\/(?:pull|merge_requests|pullrequest)\/\d+/
94const ARTIFACT_URL = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[\w-]+/
95
96// DPAPI: a sealed blob opens only for this Windows user on this machine.
97export const POWERSHELL = ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command']
98export const SEAL =
99 '$t = [Console]::In.ReadToEnd().Trim(); ' +
100 'ConvertTo-SecureString -String $t -AsPlainText -Force | ConvertFrom-SecureString'
101export const OPEN =
102 '$b = [Console]::In.ReadToEnd().Trim(); $s = ConvertTo-SecureString -String $b; ' +
103 '[Runtime.InteropServices.Marshal]::PtrToStringBSTR(' +
104 '[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))'
105
106export const UPDATE_RULES = [
107 'Rules: these are plain notes, not sections. A weekly report is built from them later and sorts the work ' +
108 'into its own sections, so never group or label the notes.',
109 '- 2 to 6 notes, in the order the work happened, each one line of at most 25 words.',
110 '- Each note says what was done and what it achieved. When something went wrong, say what and how it was ' +
111 'resolved, or that it is still open and who or what it waits on.',
112 '- Mention a next step only when it was decided today.',
113 '- Never narrate the session, list files, names or terms, or mention housekeeping such as committing or pushing.',
114 '- Plain words a teammate outside the project understands. No code, diffs, file paths, secrets or internal ' +
115 'hostnames. Never invent anything.',
116].join('\n')
117
118export const UPDATE_SHAPE = 'Answer with only this JSON object and nothing else:\n{"notes": []}'
119
120export const UPDATE_AND_MOVE_SHAPE =
121 'Answer with only this JSON object and nothing else:\n{"notes": [], "move": {"to": "", "reason": ""}}'
122
123export const ISSUE_SHAPE =
124 'Answer with only this JSON object and nothing else:\n{"summary": "", "description": ""}'
125
126export function newIssuePrompt(focus: string) {
127 const subject = focus
128 ? `The person asked for: "${focus}". That decides what the ticket is about. Use this conversation ` +
129 'only for details that serve it, and leave out everything else the conversation covered.'
130 : 'Base it on the main piece of work in this conversation.'
131
132 return [
133 'Draft a Jira ticket: a description of work to be done, not a record of what this conversation did.',
134 subject,
135 [
136 'Rules:',
137 '- Write it as work ahead, for a teammate who has not seen the conversation. Never narrate what was ' +
138 'discussed, tried or found, or what exists so far.',
139 '- summary: an imperative title under 80 characters.',
140 '- description: 2 to 4 plain sentences: the outcome wanted, why, and how to tell it is done. No ' +
141 'headings or lists.',
142 '- Under 120 words in all. No code, file paths, diffs, secrets or internal hostnames.',
143 ].join('\n'),
144 ISSUE_SHAPE,
145 ].join('\n\n')
146}
147
148export function reviewRequestOf(command: string, output: string) {
149 const opened = command.match(REVIEW_COMMAND)?.[1]
150
151 return opened === undefined ? undefined : (output.match(REVIEW_URL)?.[0] ?? opened.replace(/\s+/g, ' '))
152}
153
154export const isReviewTool = (tool: string) => tool.startsWith('mcp__') && REVIEW_TOOL.test(tool)
155
156export const reviewUrlIn = (output: string) => output.match(REVIEW_URL)?.[0]
157
158type ArtifactCall = { action?: string; asset?: boolean; url?: string; title?: string }
159
160// A publish of a page or its files, not an asset upload. A create names its URL only in what it answered,
161// and an update names its page's title only there.
162export function publishedArtifactOf(input: ArtifactCall, result: unknown, output: string) {
163 if ((input.action ?? 'publish') !== 'publish' || input.asset === true) return undefined
164 const answered = (typeof result === 'object' && result !== null ? result : {}) as { url?: unknown; title?: unknown }
165 const url = [input.url, answered.url, output]
166 .map(one => (typeof one === 'string' ? one.match(ARTIFACT_URL)?.[0] : undefined))
167 .find(one => one !== undefined)
168 const title = [input.title, answered.title].find(one => typeof one === 'string' && one.trim() !== '')
169
170 return url === undefined ? undefined : { url, title: typeof title === 'string' ? title.trim() : '' }
171}
172
173export function addArtifact(list: DayActivity['artifacts'] = [], artifact: { url: string; title: string }) {
174 const known = list.find(one => one.url === artifact.url)
175 if (known === undefined) return [...list, artifact].slice(-50)
176 if (known.title !== '' || artifact.title === '') return list
177
178 return list.map(one => (one === known ? artifact : one))
179}
180
181export const toTransition = ({ id, name, to }: RawTransition): JiraTransition => ({ id, name, to: to.name })
182
183export const isSameStatus = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
184
185export const toIssue = ({ key, fields }: RawIssue): JiraIssue => ({
186 key,
187 summary: fields.summary,
188 status: fields.status.name,
189 statusCategory: fields.status.statusCategory.key,
190})
191
192export function reasonOf(text: string) {
193 try {
194 const body = JSON.parse(text) as { errorMessages?: string[]; errors?: Record<string, string> }
195 const reasons = [...(body.errorMessages ?? []), ...Object.values(body.errors ?? {})]
196 if (reasons.length > 0) return reasons.join('; ')
197 } catch {}
198
199 return text.slice(0, 200)
200}
201
202export function dayOf(now: number) {
203 const date = new Date(now)
204 const pad = (n: number) => String(n).padStart(2, '0')
205
206 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
207}
208
209export function dayBefore(day: string, count: number) {
210 const [year = 0, month = 1, date = 1] = day.split('-').map(Number)
211
212 return dayOf(new Date(year, month - 1, date - count).getTime())
213}
214
215// The earlier days within the lookback, oldest first, that the store holds activity for on this issue.
216export function earlierDaysIn(keys: readonly string[], issue: string, day: string) {
217 const prefix = `activity:${issue}:`
218 const oldest = dayBefore(day, LOOKBACK_DAYS)
219
220 return keys
221 .filter(key => key.startsWith(prefix))
222 .map(key => key.slice(prefix.length))
223 .filter(one => one >= oldest && one < day)
224 .sort()
225}
226
227export function relativeTo(root: string, path: string) {
228 const [file, base] = [path.replaceAll('\\', '/'), `${root.replaceAll('\\', '/')}/`]
229
230 return file.toLowerCase().startsWith(base.toLowerCase()) ? file.slice(base.length) : file
231}
232
233export const addOnce = (list: string[], item: string, limit: number) =>
234 list.includes(item) ? list : [...list, item].slice(-limit)
235
236const objectIn = (reply: string) => JSON.parse(reply.slice(reply.indexOf('{'), reply.lastIndexOf('}') + 1))
237
238const listOf = (value: unknown) =>
239 Array.isArray(value)
240 ? value.filter((item): item is string => typeof item === 'string' && item.trim() !== '').map(item => item.trim())
241 : []
242
243// The ticket's text only: its type and sprint are picked in the form, not by the model.
244export function parseNewIssue(reply: string): Pick<JiraNewIssue, 'summary' | 'description'> {
245 const parsed = objectIn(reply) as { summary?: unknown; description?: unknown }
246 const summary = typeof parsed.summary === 'string' ? cappedSummary(parsed.summary.trim()) : ''
247 if (summary === '') throw new Error('the model drafted no summary')
248
249 return { summary, description: typeof parsed.description === 'string' ? parsed.description.trim() : '' }
250}
251
252// Only a move the workflow allows from here is suggested; anything else the model names is dropped.
253export function parseSuggestion(
254 reply: string,
255 moves: readonly JiraTransition[],
256 status: string,
257): JiraSuggestion | null {
258 const { move } = objectIn(reply) as { move?: { to?: unknown; reason?: unknown } }
259 const to = typeof move?.to === 'string' ? move.to : ''
260 const transition = moves.find(one => isSameStatus(one.to, to))
261 if (to === '' || transition === undefined || isSameStatus(to, status)) return null
262
263 return { id: transition.id, to: transition.to, reason: typeof move?.reason === 'string' ? move.reason.trim() : '' }
264}
265
266export function parseUpdate(reply: string): JiraUpdate {
267 return { notes: listOf((objectIn(reply) as { notes?: unknown }).notes) }
268}
269
270export const toMarkdown = (update: JiraUpdate) =>
271 update.notes.map(note => `- ${note}`).join('\n') || '_Nothing to report._'
272
273const SUMMARY_LIMIT = 255
274
275// A ticket the person writes themselves starts blank; only the summary is required.
276export const emptyIssue = (issueType: string, sprint: number | null = null): JiraNewIssue => ({
277 summary: '',
278 description: '',
279 issueType,
280 sprint,
281})
282
283export const isIssueReady = (issue: JiraNewIssue) => issue.summary.trim() !== ''
284
285export const cappedSummary = (summary: string) => summary.slice(0, SUMMARY_LIMIT)
286
287// The open sprints of the project's boards, each once: the active ones first, then the future ones.
288export function toSprints(lists: readonly (readonly RawSprint[])[]): JiraSprint[] {
289 const open = new Map<number, JiraSprint>()
290 for (const { id, name, state } of lists.flat()) {
291 if (!open.has(id) && (state === 'active' || state === 'future')) open.set(id, { id, name, state })
292 }
293 const sprints = [...open.values()]
294
295 return [...sprints.filter(one => one.state === 'active'), ...sprints.filter(one => one.state === 'future')]
296}
297
298// A new issue goes into the current sprint unless the person picks another one, or the backlog.
299export const currentSprint = (sprints: readonly JiraSprint[]) =>
300 sprints.find(one => one.state === 'active')?.id ?? null
301
302const BACKLOG = 'backlog'
303
304export const sprintOptions = (sprints: readonly JiraSprint[]) => [
305 ...sprints.map(one => ({ value: String(one.id), label: one.state === 'active' ? `${one.name} (current)` : one.name })),
306 { value: BACKLOG, label: 'Backlog' },
307]
308
309export const sprintValue = (sprint: number | null) => (sprint === null ? BACKLOG : String(sprint))
310
311export const sprintOf = (value: string) => (value === BACKLOG ? null : Number(value))
312
313const text = (value: string, isStrong = false) =>
314 isStrong ? { type: 'text', text: value, marks: [{ type: 'strong' }] } : { type: 'text', text: value }
315
316const bulletList = (items: readonly string[]) => ({
317 type: 'bulletList',
318 content: items.map(item => ({ type: 'listItem', content: [{ type: 'paragraph', content: [text(item)] }] })),
319})
320
321// Comment and description bodies in the v3 API are Atlassian Document Format, not markdown.
322export function toDocument(day: string, update: JiraUpdate) {
323 const notes = update.notes.length > 0 ? [bulletList(update.notes)] : []
324
325 return {
326 type: 'doc',
327 version: 1,
328 content: [{ type: 'paragraph', content: [text(`Daily update ${day}`, true)] }, ...notes],
329 }
330}
331
332// Each line of the description is a paragraph of its own.
333export function toIssueDocument(issue: JiraNewIssue) {
334 const lines = issue.description.split('\n').map(line => line.trim()).filter(Boolean)
335
336 return { type: 'doc', version: 1, content: lines.map(line => ({ type: 'paragraph', content: [text(line)] })) }
337}
338hooks/description.tsx 67 lines1import type { ClientModule } from 'claude-code'
2
3import { editorOf, rowAt, rowsOf, viewOf, withKey, withPress, type Editor } from './editor'
4
5export type DescriptionProps = { text: string; placeholder: string }
6
7// The round border and one column of padding each side, and a column for the cursor past a line's end.
8const FRAME_COLUMNS = 5
9
10// The ticket's description as a text area: it starts from the text drawn with it, grows with what is typed
11// up to MAX_ROWS rows and then scrolls, and posts each change to the hooks module.
12const Description: ClientModule<DescriptionProps, Editor> = (props, surface) => {
13 const { Box, Text } = surface.elements
14 // The region is 0 wide until it is first laid out.
15 const widthNow = () => Math.max(10, (surface.columns || 64) - FRAME_COLUMNS)
16 const width = widthNow()
17 const editor = surface.state ?? editorOf(props.text)
18 if (surface.state === undefined) {
19 const apply = (next: Editor) => {
20 const before = surface.state ?? editor
21 surface.setState(next)
22 if (next.text !== before.text) surface.post({ description: next.text })
23 }
24 surface.onKey(key => apply(withKey(surface.state ?? editor, key, widthNow())))
25 surface.onPointer(event => {
26 if (event.type === 'down') apply(withPress(surface.state ?? editor, event.x - 2, event.y - 1, widthNow()))
27 })
28 surface.setState(editor)
29 }
30 const rows = rowsOf(editor.text, width)
31 const { height, top } = viewOf(editor, rows)
32 const cursorRow = rowAt(rows, editor.cursor)
33 const lines = Array.from({ length: height }, (_, index) => {
34 const row = rows[top + index]
35 const text = row === undefined ? '' : editor.text.slice(row.start, row.end)
36 if (row === undefined || !editor.isActive || top + index !== cursorRow) {
37 return <Text key={`row-${index}`}>{text || ' '}</Text>
38 }
39 const at = editor.cursor - row.start
40
41 // One run of text with the cursor cell nested in it, so the spaces beside the cursor stay where they are.
42 return (
43 <Text key={`row-${index}`}>
44 {text.slice(0, at)}
45 <Text inverse>{text.charAt(at) || ' '}</Text>
46 {text.slice(at + 1)}
47 </Text>
48 )
49 })
50 const isBlank = editor.text === '' && !editor.isActive
51
52 return (
53 <Box flexDirection="column" width="100%" borderStyle="round" borderDimColor paddingX={1}>
54 {isBlank ? (
55 <Box flexDirection="column">
56 <Text dimColor>{props.placeholder}</Text>
57 {lines.slice(1)}
58 </Box>
59 ) : (
60 lines
61 )}
62 </Box>
63 )
64}
65
66export default Description
67hooks/editor.ts 144 lines1// A multi-line text box for the surfaces whose Input is one line: the text, where the cursor is in it,
2// and the first visual row shown. Lines wrap at word boundaries to the box's width.
3export type Editor = { text: string; cursor: number; top: number; isActive: boolean }
4
5// A visual row: the text from `start` up to `end`, one line or the part of one that fits the width.
6export type Row = { start: number; end: number }
7
8export type EditorKey = { key: string; ctrl?: true; meta?: true }
9
10// Rows the box shows before it scrolls, and the fewest it draws so it reads as a text area.
11export const MIN_ROWS = 4
12export const MAX_ROWS = 12
13// Jira refuses a description over 32,767 characters.
14const TEXT_LIMIT = 32_000
15
16// The terminal names the keys it maps (`up`, `return`, `backspace`, ...) and sends a space as `space`; a
17// browser-style surface would name them as the DOM does (`ArrowUp`, `Enter`, ` `). Both become one name here.
18const NAMED: Record<string, string> = {
19 up: 'up', arrowup: 'up', down: 'down', arrowdown: 'down', left: 'left', arrowleft: 'left', right: 'right',
20 arrowright: 'right', return: 'return', enter: 'return', backspace: 'backspace', delete: 'delete', del: 'delete',
21 home: 'home', end: 'end', space: 'space', spacebar: 'space',
22}
23
24// Keys that type nothing here.
25const IGNORED = new Set([
26 'tab', 'pageup', 'pagedown', 'escape', 'esc', 'insert', 'clear', 'center', 'undefined', 'unidentified', 'mouse',
27 'wheelup', 'wheeldown', 'shift', 'control', 'ctrl', 'alt', 'meta', 'os', 'capslock', 'numlock', 'scrolllock',
28 'pause', 'printscreen', 'contextmenu', 'dead', 'process', ...Array.from({ length: 24 }, (_, n) => `f${n + 1}`),
29])
30
31const nameOf = (key: string) => (key.length > 1 ? NAMED[key.toLowerCase()] : undefined)
32
33const isIgnored = (key: string) => key === '' || (key.length > 1 && IGNORED.has(key.toLowerCase()))
34
35export const editorOf = (text: string): Editor => ({ text, cursor: text.length, top: 0, isActive: false })
36
37export function rowsOf(text: string, width: number): Row[] {
38 const rows: Row[] = []
39 let start = 0
40 for (const line of text.split('\n')) {
41 const end = start + line.length
42 let from = start
43 while (end - from > width) {
44 const space = text.lastIndexOf(' ', from + width - 1)
45 const cut = space > from ? space + 1 : from + width
46 rows.push({ start: from, end: cut })
47 from = cut
48 }
49 rows.push({ start: from, end })
50 start = end + 1
51 }
52
53 return rows
54}
55
56// Where a wrapped row ends, the next row begins: a cursor there is drawn on the next row.
57export function rowAt(rows: readonly Row[], cursor: number) {
58 let at = 0
59 rows.forEach((row, index) => {
60 if (row.start <= cursor) at = index
61 })
62
63 return at
64}
65
66const lastIn = (rows: readonly Row[], index: number) => {
67 const row = rows[index]
68 if (row === undefined) return 0
69
70 return rows[index + 1]?.start === row.end ? row.end - 1 : row.end
71}
72
73const cursorIn = (rows: readonly Row[], index: number, column: number) =>
74 Math.min((rows[index]?.start ?? 0) + Math.max(0, column), lastIn(rows, index))
75
76const insert = (editor: Editor, typed: string) => {
77 const text = typed.replace(/\r\n?/g, '\n')
78 const room = TEXT_LIMIT - editor.text.length
79 if (room <= 0) return editor
80 const added = text.slice(0, room)
81
82 return {
83 ...editor,
84 text: editor.text.slice(0, editor.cursor) + added + editor.text.slice(editor.cursor),
85 cursor: editor.cursor + added.length,
86 }
87}
88
89// One key from the box: printable text (a paste comes as one string) is typed, Enter starts a new line.
90function keyed(editor: Editor, { key, ctrl, meta }: EditorKey, width: number): Editor {
91 const active = { ...editor, isActive: true }
92 if (ctrl || meta) return active
93 const { text, cursor } = active
94 const rows = rowsOf(text, width)
95 const row = rowAt(rows, cursor)
96 const column = cursor - (rows[row]?.start ?? 0)
97 if (isIgnored(key)) return active
98 switch (nameOf(key)) {
99 case 'space':
100 return insert(active, ' ')
101 case 'return':
102 return insert(active, '\n')
103 case 'backspace':
104 return cursor === 0 ? active : { ...active, text: text.slice(0, cursor - 1) + text.slice(cursor), cursor: cursor - 1 }
105 case 'delete':
106 return { ...active, text: text.slice(0, cursor) + text.slice(cursor + 1) }
107 case 'left':
108 return { ...active, cursor: Math.max(0, cursor - 1) }
109 case 'right':
110 return { ...active, cursor: Math.min(text.length, cursor + 1) }
111 case 'up':
112 return { ...active, cursor: row === 0 ? 0 : cursorIn(rows, row - 1, column) }
113 case 'down':
114 return { ...active, cursor: row === rows.length - 1 ? text.length : cursorIn(rows, row + 1, column) }
115 case 'home':
116 return { ...active, cursor: rows[row]?.start ?? 0 }
117 case 'end':
118 return { ...active, cursor: lastIn(rows, row) }
119 default:
120 return insert(active, key)
121 }
122}
123
124// How many rows the box shows, and the first one, so that the cursor's row is in view.
125export function viewOf(editor: Editor, rows: readonly Row[]) {
126 const height = Math.min(MAX_ROWS, Math.max(MIN_ROWS, rows.length))
127 const row = rowAt(rows, editor.cursor)
128 const top = row < editor.top ? row : row >= editor.top + height ? row - height + 1 : editor.top
129
130 return { height, top: Math.max(0, Math.min(top, rows.length - height)) }
131}
132
133const scrolled = (editor: Editor, width: number) => ({ ...editor, top: viewOf(editor, rowsOf(editor.text, width)).top })
134
135export const withKey = (editor: Editor, key: EditorKey, width: number) => scrolled(keyed(editor, key, width), width)
136
137// A press puts the cursor where it landed, in the box's text cells.
138export function withPress(editor: Editor, column: number, line: number, width: number): Editor {
139 const rows = rowsOf(editor.text, width)
140 const row = Math.min(rows.length - 1, Math.max(0, editor.top + line))
141
142 return scrolled({ ...editor, isActive: true, cursor: cursorIn(rows, row, column) }, width)
143}
144types/index.d.ts 51 lines1export type JiraIssue = { key: string; summary: string; status: string; statusCategory: string }
2
3export type JiraUpdate = { notes: string[] }
4
5// `sprint` is the id of the sprint the issue goes into once created; null leaves it in the backlog.
6export type JiraNewIssue = {
7 summary: string
8 description: string
9 issueType: string
10 sprint: number | null
11}
12
13export type JiraSprint = { id: number; name: string; state: 'active' | 'future' }
14
15export type JiraTransition = { id: string; name: string; to: string }
16
17export type JiraSuggestion = { id: string; to: string; reason: string }
18
19// `earlier` counts unposted actions on earlier days; progress saved before it existed has none.
20export type JiraProgress = {
21 day: string
22 actions: number
23 postedActions: number
24 postedAt: number | null
25 earlier?: number
26}
27
28export type JiraView = 'setup' | 'pick' | 'draft' | 'create' | 'move'
29
30declare module 'claude-code' {
31 interface PluginState {
32 'jira-log': {
33 view: JiraView
34 isConfigured: boolean
35 isSkipped: boolean
36 isProgressHidden: boolean
37 progress: JiraProgress | null
38 link: JiraIssue | null
39 issues: JiraIssue[]
40 draft: JiraUpdate | null
41 suggestion: JiraSuggestion | null
42 newIssue: JiraNewIssue | null
43 issueTypes: string[]
44 sprints: JiraSprint[]
45 transitions: JiraTransition[]
46 busy: string | null
47 notice: string | null
48 }
49 }
50}
51