Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .claude/references/compact-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Compact style for issues and PR bodies

Shared reference. Pointed at by `.claude/skills/jira-issue/SKILL.md` and
`.claude/skills/pr-pitch/SKILL.md`. Adapted from the MIT-licensed
`i-have-adhd` skill (https://github.com/ayghri/i-have-adhd), which shapes chat
turns; these rules shape written artifacts instead.

## What changes when the reader is not in the conversation

A chat reader has just asked you something. A Jira reader is scanning a queue
of forty tickets, and a PR reader has eleven other reviews open. Both arrive
cold, months after the work, with none of your context and no way to ask a
follow-up. Four consequences:

1. The first line is the only line guaranteed to be read. It carries the
decision, not the setup.
2. Nothing can be held in the reader's head from a previous paragraph.
3. There is no "let me know if you need anything else". Whatever is unanswered
stays unanswered.
4. Length is not a cost the reader chooses to pay. Jira Data Center has no
collapsible sections, so every word in a description is on the screen.

## The rules

### 1. Lead with what the reader must decide

Not context, not history, not the investigation. For a triager: what breaks,
who it hits, how bad. For a reviewer: what the change does and what it risks.

Bad: `h3. The underlying problem` / "While investigating LT-22710 we noticed"
Good: "Deleting a feature-based natural class empties every rule that used it."

### 2. Number every multi-step sequence

Repro steps, verification steps, migration steps. One bounded action per step.
**No step contains "and then" twice.** If a step needs a sub-list, it is two
steps.

### 3. End with one named next action

Every description ends with a single `*Next:*` line naming who does what.
Tickets and PRs that end without one stall, because nobody is named.

Good: `*Next:* reporter to confirm the FLEx version (see comment).`
Good: `Next: approve, or tell me to split the installer change out.`

### 4. One artifact, one problem

A ticket describing four problems is four tickets, linked. A PR doing three
unrelated things is three PRs, stacked. Splitting is cheap at filing time and
expensive at review time.

### 5. State cause, not concern

No "seems to", no "there may be an issue with", no apology. Name the symptom
and, if known, the mechanism. If the mechanism is inferred rather than
observed, say "inferred" -- that is information, whereas hedging is noise.

### 6. Cost shape, not hours

Hours on a ticket read as a commitment nobody made. Size the work by what it
touches.

Bad: "This will take some work." / "About two days."
Good: "One branch in one file." / "Needs a liblcm release and a package bump."

### 7. Cap any list at five

Past five, split into "must" and "nice to have", or accept that the artifact
is really an epic. Five ranked beats ten unranked.

### 8. No preamble, no recap, no closer

Banned openers: "This issue describes", "This PR refactors", "While
investigating", "As a note", "I have been looking into". Banned closers:
"Please let me know", "Hope this helps", "Happy to provide more detail".

Start with the answer. Stop when the answer is done.

### 9. Say what is not known

An explicit `*Not known:*` line is worth more than a confident guess. It tells
the next reader where to dig and stops a fabrication becoming folklore.

### 10. Never assert what was not verified

Everything above the fold is either something a reporter said or something we
observed. Analysis, inference and reconstruction go in a comment or an
accordion, labelled as such. A screenshot from a headless test is not a
screenshot of the product; say which it is.

## Pre-send check

Delete before publishing:

1. The first sentence, if it announces what the artifact is about to do.
2. The last sentence, if it recaps or asks for further questions.
3. Any "by the way" sidebar. It is a separate ticket.
4. Hedging adverbs carrying no information. Keep a hedge that carries real
uncertainty; deleting that one manufactures confidence.
5. Any idiom. Replace with the literal action.

Then verify: **reading only the title and the last line, does the reader know
what is wrong and what happens next?** If yes, publish.
133 changes: 133 additions & 0 deletions .claude/references/evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Screenshots and visual evidence

Shared reference. Pointed at by `.claude/skills/pr-pitch/SKILL.md`,
`.claude/skills/jira-issue/SKILL.md` and
`.claude/skills/fieldworks-avalonia-ui/SKILL.md`.

Three stages, and skipping the middle one is the usual failure:
**capture, curate, publish.**

## The rule that outranks the rest

**The test is the evidence. The screenshot is the courtesy.**

A headless capture proves nothing a reviewer can re-run. The assertion does.
So a PR carries the test name *and* the picture, and the picture never carries
a claim the test does not.

The corollary: **a control-level headless capture is not a screenshot of the
product.** If nothing writes the operation at runtime yet, the image shows a
renderer, not a feature. Say which it is, in the caption, every time. That
distinction evaporates the moment an image is pasted without one.

## Capture

| Surface | Who captures | How |
| --- | --- | --- |
| Avalonia | The agent, automated | Headless Skia. See `.claude/skills/fieldworks-avalonia-ui/references/visual-snapshot-testing.md` |
| WinForms, live project | The developer | Real scenarios need real data, and real data needs permission |
| WinForms, throwaway project | Either | `fieldworks-winapp/navigation/screenshot-evidence.md`, MCP-driven |

Captures land in `Output/ManualEvidence/<TICKET>/NN-name.png`. That directory
is gitignored, which is correct -- captures are working output, not artifacts.

## Curate

An uncaptured curation step is why evidence reads as decoration. Three things:

**Trim to content.** Renderer captures are mostly background. A 520x180 capture
with content in the top quarter reads as an empty box at thumbnail size, which
is the size it is first seen at in both GitHub and Jira. Crop to the content
bounds plus about 8 pixels.

**Caption every image.** What to look at, not what it is. "Before" is not a
caption; "Before -- the Lexeme field lists both seh and pt" is.

**Label provenance in the caption.** One of: headless control-level capture,
live FLEx desktop, or mockup. Never leave it to be inferred.

Name files `NN-state-subject.png` so they sort into reading order:
`01-before-writing-systems.png`, `02-after-writing-systems.png`.

## Publish -- GitHub

Try these in order and say which one was used.

**1. `gh --attach`, once it ships.** Native upload on six commands (issue and
PR create, edit, comment), tracked by `github/roadmap#1324`. It uses the
ordinary `gh` token, so no cookie and no committed file. Constraints: write
access required, **Actions tokens excluded** so CI cannot use it, nine file
types, images under 10 MB. Detect it rather than assuming a version:

```powershell
if ((gh pr comment --help 2>&1 | Out-String) -match '--attach') { "native upload available" }
```

**2. `gh image`** (`drogers0/gh-image`, MIT). Drives the web UI's own upload
flow and returns a real `user-attachments` URL. It needs a GitHub **session
cookie**, not the `gh` token: `--token`, `GH_SESSION_TOKEN`, or extraction
from a browser cookie store. Chrome 127 and later encrypt cookies in a way
that defeats extraction on Windows, so a Chrome-only machine will report
`session token is empty`.

- Check availability with `gh image check-token`, which prints a username.
- **Never run `gh image extract-token` in an agent session.** It prints a
full-account credential to stdout, and stdout becomes conversation context.
- A `user_session` cookie grants complete account access and bypasses 2FA. If
a developer chooses this route, they set `GH_SESSION_TOKEN` in their own
shell before starting the session -- never pasted into a prompt.

**3. Ask the author to drag it in.** When neither route above is available --
no `--attach` yet, no session cookie, or a CI run, where the official flag
excludes Actions tokens anyway -- say so and hand the file over. Name the exact
path to drop into the comment box, then splice the returned URL into the body.

That is a real answer, not a failure. An agent that cannot upload should say
which route it tried and stop, rather than inventing somewhere to put the file.

**Do not commit images to the repository** to work around this, and do not
create a side branch to host them. Both put binaries in history permanently to
solve a problem that lasts one review.

## Publish -- Jira

Jira takes native attachments, which is better than a URL there because they
outlive any branch:

```powershell
python -c @'
import sys; sys.path.insert(0, ".claude/skills/atlassian-skills/scripts")
from jira_attachments import jira_add_attachment
print(jira_add_attachment("LT-22715", ["01-before.png", "02-after.png"]))
'@
```

Then reference them from the description or comment with `!01-before.png!`,
or `!01-before.png|thumbnail!` to keep a long description scannable. Images
belong in the analysis comment unless the picture *is* the bug report.

## Permission

**Hard stop, every time, before anything leaves the machine:**

> Do you have permission to post this?

A screenshot of a live project is a data disclosure exactly as a sample
project is: vernacular text, speaker names, unpublished lexical data,
community-owned material. Jira attachments are visible to everyone with
project access, and a GitHub attachment on a public repo is public.

- Never publish a capture the agent found on disk without being told to.
- Agent-captured WinForms evidence comes from a throwaway test project only.
- If permission is unclear, describe the image instead and say in the ticket
that a capture exists but was not attached, so nobody re-asks.

## Checklist

- [ ] The claim the image supports is also pinned by a test, or the image is
labelled as the only evidence.
- [ ] Trimmed to content.
- [ ] Captioned with what to look at.
- [ ] Provenance named: headless, live, or mockup.
- [ ] Permission asked and answered before upload.
- [ ] The publish route used is stated, including when it was you.
9 changes: 9 additions & 0 deletions .claude/skills/atlassian-skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,15 @@ from scripts.jira_issues import (
jira_add_comment # Add comment to issue
)

# Attachments live in their own module because they need a multipart POST
from scripts.jira_attachments import (
jira_add_attachment # Upload one or more files to an issue
)

# Attachments are visible to everyone who can see the issue. Confirm
# permission before uploading user data -- see .claude/references/evidence.md
jira_add_attachment("LT-22715", ["01-before.png", "02-after.png"])

# Create issue with full options
jira_create_issue(
project_key="PROJ",
Expand Down
139 changes: 139 additions & 0 deletions .claude/skills/atlassian-skills/scripts/jira_attachments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Jira attachment tools.

Tools:
- jira_add_attachment: Upload one or more files to an issue

Attachments need a multipart POST, which AtlassianClient.post cannot do --
it only sends JSON. This module therefore drives client.session directly,
reusing the client's base URL, auth, SSL setting and error handling.
"""

import mimetypes
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))

from typing import Any, Dict, List, Optional, Union

from _common import (
AtlassianCredentials,
get_jira_client,
format_json_response,
format_error_response,
ConfigurationError,
AuthenticationError,
ValidationError,
NotFoundError,
APIError,
NetworkError,
)

# Jira Data Center's default ceiling. A larger file fails server-side with a
# message that does not name the limit, so check it here where we can say so.
DEFAULT_MAX_BYTES = 10 * 1024 * 1024


def jira_add_attachment(
issue_key: str,
file_paths: Union[str, List[str]],
credentials: Optional[AtlassianCredentials] = None,
max_bytes: int = DEFAULT_MAX_BYTES
) -> str:
"""Attach one or more files to a Jira issue.

Args:
issue_key: Issue key (e.g., 'LT-22715')
file_paths: A path, or a list of paths, to upload
credentials: Optional AtlassianCredentials for Agent environments.
If not provided, uses environment variables.
max_bytes: Reject any file larger than this before uploading

Returns:
JSON string with one entry per attachment, each carrying id,
filename, size and the content URL, or error information

Note:
Attachments are visible to everyone who can see the issue. Confirm
permission to publish before calling this with user data --
FieldWorks projects, screenshots of live data, and logs frequently
contain unpublished language material.
"""
handles = []
try:
client = get_jira_client(credentials)

if not issue_key:
raise ValidationError('issue_key is required')
if not file_paths:
raise ValidationError('at least one file path is required')

if isinstance(file_paths, str):
file_paths = [file_paths]

for path in file_paths:
if not os.path.isfile(path):
raise ValidationError(f'file not found: {path}')
size = os.path.getsize(path)
if size == 0:
raise ValidationError(f'file is empty: {path}')
if size > max_bytes:
raise ValidationError(
f'file is {size} bytes, over the {max_bytes} byte limit: {path}'
)

files = []
for path in file_paths:
name = os.path.basename(path)
mime = mimetypes.guess_type(name)[0] or 'application/octet-stream'
handle = open(path, 'rb')
handles.append(handle)
files.append(('file', (name, handle, mime)))

url = f"{client.config.url}{client.api_path(f'issue/{issue_key}/attachments')}"

# X-Atlassian-Token defeats Jira's XSRF check, which otherwise rejects
# the upload. Content-Type must be cleared so requests can set the
# multipart boundary; the session sets application/json for every
# other call, and a None value here removes it for this one.
response = client.session.post(
url,
files=files,
headers={'X-Atlassian-Token': 'no-check', 'Content-Type': None},
timeout=120,
verify=client.ssl_verify
)
client._handle_error(response)

uploaded: List[Dict[str, Any]] = []
for item in (response.json() if response.content else []):
uploaded.append({
'id': item.get('id', ''),
'filename': item.get('filename', ''),
'size': item.get('size', 0),
'mimeType': item.get('mimeType', ''),
'content': item.get('content', ''),
'thumbnail': item.get('thumbnail', '')
})

return format_json_response({
'issue_key': issue_key,
'count': len(uploaded),
'attachments': uploaded
})

except ConfigurationError as e:
return format_error_response('ConfigurationError', str(e))
except AuthenticationError as e:
return format_error_response('AuthenticationError', str(e))
except ValidationError as e:
return format_error_response('ValidationError', str(e))
except NotFoundError as e:
return format_error_response('NotFoundError', str(e))
except (APIError, NetworkError) as e:
return format_error_response(type(e).__name__, str(e))
except Exception as e:
return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}')
finally:
for handle in handles:
handle.close()
Loading
Loading