gadak · diff

git:20261018.71a690a to git:20261103.ef0ef84

4 added, 4 removed. Audit B to B.

---
name: gadak
description: >
Local issue tracker, and a SQLite mirror of Jira/Confluence when a site
exists. Answer questions from that mirror with SQL instead of the Atlassian
API — it can join, aggregate and read history JQL cannot express. Use when
the user asks what changed, who is working on what, why a ticket was
reopened, what the wiki says, what is stuck or untriaged, how a release is
shaped, about a backlog, or anything that would otherwise mean paging
through Jira search — including when they have no Atlassian account and want
- a tracker on this machine (the built-in tracker). Use it before any write —
- creating an issue, attaching a file, editing a summary, label or priority,
- commenting, transitioning, assigning, writing a wiki page all go through
- gadak, not the API. When the user wants to see issues, do not paste a
+ a tracker on this machine (the built-in tracker). For a tracker a gadak
+ workspace holds, every write (create, edit, comment, transition, assign,
+ attach, wiki page) goes through gadak, not its API; a tracker no workspace
+ holds is out of scope. When the user wants to see issues, do not paste a
markdown table: open them with `gadak views open`. Filter on status_category
and ids, never on display names. Changing gadak itself (theme, sync
interval, projects) is `gadak config`.
---
# Asking the mirror
gadak keeps a local SQLite mirror of Jira (and optionally Confluence) at
`~/.gadak/gadak.db`. The origin is a Jira site, or — with no Atlassian account —
an in-process tracker (`gadak init --local`), or another machine's
`gadak serve` bound with `gadak init --pairing-code-stdin`. Reads never touch
the network, so queries are free and fast: prefer a query over asking the user
to look something up.
If `gadak doctor` reports `skill: stale`, run `gadak skill install` so this
file matches the binary you are running. An upgrade of a previous gadak
install is in-place; a file gadak did not write needs `--force`.
**Show, don't paste.** SQL answers; `gadak views open` presents. Render a
markdown table or other document artifact only when there is no UI to focus,
or when the user explicitly asked for a document. If they asked to *see* the
issues, put them on the app:
```bash
gadak views open --jql 'project = NMA AND statusCategory = "In Progress"'
gadak views open --keys 'NMA-1,NMA-2'
gadak views open NMB-140
gadak sql --no-header "select key from issues_full where status_category != 'done'" | gadak views open --keys -
```
`--keys` accepts comma or whitespace; `--keys -` reads stdin. First-seen order
is kept, so the SQL `ORDER BY` is what the list shows. `gadak sql` prints a
header row first — skip it with `--no-header` (or `tail -n +2`) or that header
becomes a key.
`--keys` cannot be combined with `--jql` or a view name.
`gadak open <KEY>` is the origin escape hatch (system browser to the issue's page on Jira or Linear).
`gadak views open` is the "open in gadak" verb (focus the running app or serve
tab). The names collide; the verbs do not. `gadak issue <KEY> --link` is the
pasteable member of the family: it prints the `gadak://` deep link, plus the
http form when a serve is listening — reach for it when the ask is a link to
hand someone.
First, check it is there and current:
```bash
gadak sync --if-stale 15m # no-op when fresh; one incremental pass if older than 15m or last sync failed
gadak status --json # counts, watermark, last_error, schema_version, custom_fields.mapped
```
`gadak sync --if-stale 15m` is the session preamble on a CLI-only host: it
returns immediately when every source is fresh, and runs one incremental
pass when a source is older than 15m or its last sync failed. A running
`gadak serve` keeps the mirror fresh on its own. `gadak sync --source
jira|linear|confluence` narrows a pass to one source on a workspace that
carries several (default `all`). `--watch` is a foreground loop that never
returns — do not run it.
`gadak sync --concurrency N` (1–8, default 4) widens the Confluence fetch
pool; 1 is the old one-at-a-time pass. Every Jira or Confluence sync pass
ends with one stderr line spending its requests by kind and the wall time
each waited, and `gadak api --headers` prints the status line and every
response header — a slow first sync's cost is visible per endpoint family.
While a first full sync is still filling the mirror, read commands print one
stderr line (`first sync in progress: … — results are partial`) and
`gadak status --json` carries a `first_sync` object (`phase`, `fetched`,
`total`, `wiki_pending`). That is normal, not an error: work with what has
landed so far, say in your answer that results are partial, and do not start
a second sync — the running one owns the mirror until it finishes.
`last_error` means the last sync failed. An old `watermark` on a quiet project
is normal — treat it as "possibly behind", not broken. `watermark` /
`sync_count` / `last_error` are the issue-source row (Jira when it has run,
Linear when Linear is the only issue source that has; `sources.jira` /
`sources.linear` for per-source rows). If `gadak` is missing,
say so rather than guessing at answers. If `gadak` is on PATH but there is no
workspace yet and they asked for a backlog (or have no Jira), create a
workspace on the built-in tracker — do not invent a `TODO.md`
or a GitHub Issue.
After a context compaction, or when resuming someone else's session on an
existing workspace, the first command is `gadak recents`: the keys this
workspace read most recently, newest first. Reads record themselves —
`gadak issue` and `gadak search` append the visit/search rows to `local.db`
as they run, so the list is already there when you need it.
```bash
gadak recents # kind, key, viewed_at — TSV with a header
gadak recents --json --limit 50
```
## How the work went (`gadak retro`)
When the question is how the work itself went — sessions, how long before a
session produced its first write, how old the in-progress tail is, what closed,
whether comments claim work is done on issues that are not — run `gadak retro`
before writing any status prose: it reads the mirror and `local.db`, prints one
ISO-week column per week with a change column, and prints the definition of
every row under the numbers, so a claim can be checked against the exact rule
that produced it. The table also carries wip age max (the oldest in-progress
issue, beside the p85) and cycle p50/p85 (how long the week's closures took,
never-reopened issues only). `gadak retro --json` emits the same numbers for
scripting, `--session-gap 45m` moves the read-gap that splits sessions (5m to
24h; the default gap is the `retro.sessionGap` config key, 30m unset), and
`gadak retro --open closed --week 1` opens the issues behind a cell
in the running app (with `--json`, prints the keys only; `--open cycle`
follows a week's cycle samples).
Under the table `gadak retro` prints three material sections — the whole
in-progress tail oldest first with its p85 line, the bucket's surprises
(reopened work, issues that churned through four or more statuses, and under
sprint columns work added after the sprint started or carried in), and the
closures grouped by issue type — and closes with a one-sentence summary of the
last column. `--explain` adds a paragraph under each section saying what it
is, why it is there and how to read it. `--json` carries all of that plus the
parts the table has no room for: the ordered event list per bucket, closures
by epic, the unplanned subset, one row per cycle sample, and the issues you
opened that nothing touched beside the ones that moved while you never looked.
A host without a shell reaches the same document as the `gadak_retro` MCP tool,
with the flags as its arguments.
Issues labelled `retro-action` come back under `actions`, and one whose
description opens with `metric: <row name>` carries that row's value when the
action was written beside its value now. `--open` follows any of these to the
issues: `aging`, `unplanned`, `surprises`, `seen-not-moved`, `moved-not-seen`.
`gadak retro --by-sprint` cuts the same report by sprint window instead of
by ISO week — one column per sprint, named, with the running one marked — on
any origin that has sprints (Jira Software, Linear cycles, the built-in
tracker). It replaces `--since` rather than joining it, and every definition
under the table names the sprint instead of the week, so the footer still
describes the table on screen. With more than one board carrying sprints it
refuses and names them; pass `--board <id>`.
## Which origin you are talking to
Two questions, not one. Same CLI verbs either way.
**Which tracker is the origin** (`origin_type`):
- `jira` — an Atlassian Cloud site. Needs a stored credential.
- `jira-server` — a self-hosted Jira Server / Data Center site
(`gadak init --site <base-url> --server`; Personal Access Token, no
email). Bodies are wiki markup carried verbatim — what you type is what
the origin stores — and the ADF marker rules taught later in this file
do not apply there.
- `linear` — a Linear workspace, when a `linear.apiKey` is configured.
- `gadak` — the tracker that ships with gadak (`issuetap`). No Atlassian
account. The durable file is `origin/issuetap.db` under the workspace
directory, not `gadak.db` — that one is still a cache you can delete.
**Where that origin runs** (`transport`):
- `local` — in this process. Only a `gadak` origin can be.
- `remote` — across a serve API. Every `jira` and `linear` origin is, and so
is a `gadak` origin on another machine you paired with.
Writes always go to the origin first, then the mirror re-reads.
A paired workspace is the case worth remembering: `gadak, remote`. Its
origin is the built-in tracker one machine away, so writes work with no
Atlassian credential at all.
Detect both before you write:
```bash
gadak doctor --json # workspace.origin_type and workspace.transport
gadak status --json # the same pair, plus freshness and a pairing object when paired
gadak workspaces --json # name, active, configured, site_host, issues, …
```
`kind` is still there — `standalone` or `connected` — and still means what
it used to. It is the older, coarser field: it calls a paired workspace
`connected` even though its origin is the built-in tracker. Prefer
`origin_type` and `transport`; read `kind` only from a gadak too old to
send them.
A `gadak` origin row is `configured: true` with an **empty** `site_host`.
That is not a broken workspace. Do not report "no site" and stop, and do
not ask for an Atlassian token.
## Which mirror you are reading
Someone may have several: one per Jira site, kept apart as **workspaces**. Ask,
rather than assume there is one:
```bash
gadak workspaces --json # name, active, configured, site_host, issues, documents, last_sync_at
```
`active` is the workspace the command you just ran used. Three things can
select it: the command line, the environment, or a stored default somebody set
once with `gadak workspace use <name>`. The first two are visible in what you
ran; the third is not, which is what the rules below are about.
```bash
gadak --workspace work sql "…" # this call only
gadak workspace use work # the default a later bare `gadak` inherits
gadak workspace use --clear # no stored default again
```
A stored default answers `workspace_source: "stored"` — say so when you report
which mirror you read, because nobody in the conversation chose it.
`--profile` is an alias of `--workspace`; existing scripts and MCP installs
that pass `--profile` keep working. `gadak profiles` is the same command as
`gadak workspaces`. To remove a workspace entirely:
`gadak workspaces rm <name> --yes` — one on the built-in tracker additionally
needs `--destroy-origin` (its persist is the only copy of that tracker; the
refusal names the file to copy out first).
`gadak workspace export [--out FILE]` is the built-in tracker's moving-out
form: it writes the persist file's contents as the same seed YAML
`origin/issuetap.yaml` always was (GDK-768). Jira, Server, and Linear
workspaces have nothing to hand over in that format, and a paired client is
refused too — it must not mint a fresh origin on the wrong machine.
Three rules follow, and they are the point of the design:
- **Name the workspace in every command that matters.** Never rely on the
ambient default when a question is about a specific site — a shell that was
configured before you arrived can point anywhere, and your transcript will
not record which mirror answered. The one exception is gadak's own terminal:
it sets `GADAK_WORKSPACE` to the workspace its window shows, so a bare
`gadak` there is on that workspace by construction — but the transcript
still records nothing, so name `--workspace` on writes.
- **Say which mirror you read.** `gadak status --json` carries `workspace`
(the name, `default` for the root) and `workspace_source` (what selected it:
`flag`, `stored`, `default`, or the environment variable that supplied it).
Quote `workspace` when the answer could differ per site, and say
`workspace_source` too when nobody in this conversation chose it. The MCP
`gadak_status` tool answers the same two.
- **Never write to one origin while reading another.** `gadak comment`,
`transition`, `assign` and `api --write` all take the same `--workspace`. If
the question came from data in one mirror, the write goes to that same one.
If a workspace shows `configured: false` it has no credential: a **Jira or Linear**
mirror can still be read if it was ever synced, but it will not be refreshed
and writes will fail. A **built-in-tracker** workspace is `configured: true` with
an empty `site_host` and no token — writes still work; they go to the local
origin, not Atlassian. If `site_host` is empty, read `workspace.kind` from
`gadak doctor --json` (or `kind` from `gadak status --json` when that field
is present) before assuming the workspace is broken. A row that says
`unreadable` is still a broken config — report it. If `site_host` is empty
**and** `configured` is false, there is no workspace yet — go to *A tracker of your own*
below.
## The one mistake that silently returns nothing
Filter on ids and categories, never on display names. Jira translates
`status.name` and `issuetype.name` per account, so a name that works on your
site returns zero rows on someone else's.
```sql
WHERE status = 'In Progress' -- WRONG: empty on a localized account
WHERE status_category = 'inprogress' -- RIGHT: stable everywhere
```
`status_category` is `new`, `inprogress`, or `done`. `status_id` and
`issue_type_id` are stable too.
## The schema in one paragraph
`items` is the source-neutral spine (title, `body_text`, timestamps).
**`issues` is the view to reach for** — the Jira projection joined onto the
spine, so it carries `summary`, `description_text` (`items.body_text`,
flattened), and every projection column in one relation; the intuitive
`SELECT key, summary, status FROM issues` just works (since 0.19.1;
`issues_full` is the same view under its older name, and on an older
mirror it is the one that has `summary`). Sprint is three columns there
(`sprint_id`, `sprint_name`, `sprint_state` — filter on id or state,
never the name) **and** a `sprints` table of its own (id, board_id, name,
goal, state, start_at, end_at, complete_at), with `boards` beside it —
join on `sprint_id = sprints.id`. A sprint holding no issues exists only
in that table, and so do its goal and its dates. `versions` is the project catalog; join it on
`fix_version_ids` (same-order ids next to the name array `fix_versions`).
`pages` is
the Confluence projection. `comments`, `attachments`, `changelog`, `links`,
and `dev_links` (development-panel PRs) hang off `items.id`. `attachments`
holds metadata, not bytes, and its `url` is empty on Jira by design — download
a file with `gadak attach get <KEY> <filename|id> [--out DIR]`. `items_fts` is
one FTS5 index over titles, labels, bodies, and
comment text — issues and wiki pages together. `labels`, `components`, and
`fix_versions` are JSON arrays; reach them with `json_each`. Mapped custom
fields live in `issues.custom` (and `issues_full.custom`) keyed by alias —
only after `gadak fields --apply`. If `gadak status --json` shows
`custom_fields.mapped` of 0, an empty `json_extract(custom, '$.alias')` may
mean mapping has not run, not that the field is blank:
```sql
SELECT key, json_extract(custom, '$.story_points') AS sp
FROM issues_full WHERE json_extract(custom, '$.story_points') IS NOT NULL;
```
Personal state lives in `local.db` beside the mirror (ATTACHed as `local`;
you do not type ATTACH): `local.saved_views` (`gadak views save`),
`local.recipes` (`gadak recipes save`), `local.visits`, `local.searches`.
It survives deleting `gadak.db`.
**"Mine" is already in the database — do not substitute an identity.**
`local.me` holds this workspace's own three keys (`account_id`, `email`,
`actor_slug`), refreshed from the credential every time a command opens the
mirror, and two views join it to the issues so you never have to know who you
are:
```sql
SELECT key, status, priority, summary FROM my_open ORDER BY priority_rank;
SELECT key, status, assignee, summary FROM handed_off;
```
`my_open` is assigned to me and not finished (`status_category`, never a
status name). `handed_off` is the delegation ledger: reported by me, held by
somebody else or by nobody. Both carry every `issues_full` column. The match
is account id first, then email case-insensitively, then the built-in
tracker's actor slug — the same rule the web applies. A workspace with no
credential and no actor has no identity, so both views are empty; that is the
answer, not a failure.
Some columns exist only here, derived from the changelog while syncing:
`carryover_count` (how many times an issue was carried into another sprint
after the first — NULL, not 0, on an origin with no changelog),
`first_sprint_id` / `first_sprint_at`,
`blocked_hours` / `blocked_since` (how long an issue was flagged as blocked,
and since when — NULL when the origin cannot say),
`reopen_count`, `reopened_at`, `reopen_reason`, and `epic_key` (the nearest
level-1 ancestor). Jira cannot answer questions about these at all.
Output modes: `gadak sql "…"` is tab-separated *with a header row*,
`--no-header` omits that row (TSV and `--csv`), `--json` gives one object per
row, `--csv` is header plus CSV.
## Queries that cover most questions
```sql
-- Someone's open work, most urgent first
-- assignee_email can be empty when the site hides emails; prefer assignee_id after looking it up by name.
-- gadak assign accepts that accountId (or the display name) when email is hidden; ambiguous names are refused with the candidates.
SELECT key, status, priority, summary FROM issues_full
WHERE assignee_email = 'dana@example.com' AND status_category != 'done'
ORDER BY priority_rank, updated_at DESC;
-- What regressed (reopens are the highest-signal quality metric here)
SELECT key, summary, reopen_count, reopen_reason FROM issues_full
WHERE reopen_count > 0 ORDER BY reopen_count DESC, reopened_at DESC LIMIT 20;
-- What keeps getting pushed to the next sprint (carryover_count is NULL, not
-- 0, on an origin that supplies no changelog — Linear)
SELECT key, summary, carryover_count, sprint_name FROM issues_full
WHERE carryover_count > 0 ORDER BY carryover_count DESC LIMIT 20;
-- What is stuck, and for how long
-- Same GDK-369 clock caveat on the built-in tracker for julianday('now').
SELECT key, status, ROUND(julianday('now') - julianday(status_changed_at), 1) AS days
FROM issues WHERE status_category = 'inprogress' ORDER BY days DESC LIMIT 20;
-- Has anyone hit this before? (descriptions, comments AND wiki pages, one index)
-- ref is the issue key, or for kind='page' the origin page id (items.key —
-- the id `gadak page get` prints and `gadak page edit` takes; NOT
-- pages.item_id, an internal join id).
SELECT it.kind, COALESCE(i.key, it.key) AS ref, it.title, p.space_key
FROM items_fts f JOIN items it ON it.rowid = f.rowid
LEFT JOIN issues i ON i.item_id = it.id
LEFT JOIN pages p ON p.item_id = it.id
WHERE items_fts MATCH 'webhook AND retry' LIMIT 20;
-- What moved this week, and who moved it
-- On the built-in tracker, the origin clock may not match the wall clock (GDK-369);
-- this window can be empty. Do not rewrite the predicate.
SELECT c.at, c.author, c.field, c.from_value, c.to_value, i.key
FROM changelog c JOIN issues i ON i.item_id = c.item_id
WHERE c.at > datetime('now', '-7 days') ORDER BY c.at DESC LIMIT 50;
-- Untriaged: nobody on it, no priority set
SELECT key, created_at, summary FROM issues_full
WHERE status_category = 'new' AND assignee_id IS NULL AND priority_rank = 0
ORDER BY created_at LIMIT 30;
-- Everything one person wrote, across issues and pages
SELECT it.kind, it.title, substr(c.body_text, 1, 80), c.created_at
FROM comments c JOIN items it ON it.id = c.item_id
WHERE c.author = 'Dana Whitfield' ORDER BY c.created_at DESC LIMIT 20;
-- Work in the active sprint (sprint_id / sprint_state are the stable keys;
-- sprint_name is a localized display name — never filter on it)
SELECT key, summary, sprint_id, sprint_name
FROM issues_full
WHERE sprint_state = 'active' AND status_category != 'done'
ORDER BY priority_rank, updated_at DESC;
-- What blocks one issue (links are stored from both ends: the blocked issue
-- carries the inward row, the blocker the outward one)
SELECT l.target_key AS blocked_by, t.status_category
FROM links l
JOIN issues_full i ON i.item_id = l.item_id
JOIN issues_full t ON t.key = l.target_key
WHERE i.key = 'NMA-24' AND l.type = 'Blocks' AND l.direction = 'inward';
-- Everything under one epic (epic_key is derived: nearest level-1 ancestor)
SELECT key, status_category, priority_rank, summary FROM issues_full
WHERE epic_key = 'NMB-194' ORDER BY status_category, priority_rank, key;
```
Blockers both ways, duplicate networks, open work per component or per epic,
overdue-not-done, comments keyed on `author_id`, and @-mentions read out of
the ADF body are worked queries in `docs/RECIPES.md` ("Blockers and
duplicates", "Epics, components, and due dates").
## Listing work: list, ready, next
`gadak list` is the default open-issues read — run it before writing any
query. Every issue with `status_category != 'done'`, highest
`priority_rank` first, `updated_at` newest first as the tiebreak, 30 rows.
Both `priority` and `priority_rank` are in the output so the ordering can
be checked without a second query. `--limit N`, `--json`, `--csv`,
`--no-header` behave like `gadak sql`; `--all` includes done issues.
`gadak ready` (or `gadak list --ready`) narrows that list to issues no
*recorded* open blocker holds back: an inward blocking link whose target
issue is not done disqualifies. That is the `open_blockers` column (a
mirror read, no origin call) — which link types block resolves through the
mirror's `link_types` catalog, the same vocabulary `gadak link --type`
resolves, never a hardcoded name. Block links are typed far less
consistently than epic links (Relates is the default), so "ready" means
nothing recorded blocks it, not that nothing does. On a mirror whose schema
predates the column, a
stderr notice says so and the plain open list is shown; an empty "nothing
ready" would be a stronger and wronger claim.
```bash
gadak list # open issues, priority rank first
gadak list --limit 5 --json
gadak list --all # include done
gadak ready # open issues nothing open blocks
```
A recipe is a name for a mirror SQL query, stored in `local.recipes`. It is
not a ranking engine — order comes from `priority_rank` / `status_category`.
`gadak next` (alias `gadak pick`) runs the recipe named next when one is
saved; with none saved it runs the `gadak list` default and prints the save
command on stderr. This is a report, not occupancy — claiming still goes
through the origin (`gadak claim`).
```bash
gadak recipes save next "select key, priority_rank, status, round(julianday('now') - julianday(status_changed_at), 1) as age_days, summary from issues_full where status_category != 'done' order by priority_rank, updated_at desc limit 10"
gadak next # saved recipe, or the built-in default
gadak pick # same command, the changelog's name
gadak recipes run next --json # the runner: still an error when unsaved
gadak recipes show next # SQL text; pipeable into save
gadak recipes show next | gadak recipes save next -m -
gadak recipes # name, updated_at, sql preview
gadak recipes rm next
```
### When asked what to do next
Run `gadak ready` (`gadak next` when a recipe is saved); the rows carry
`age_days`, days in the current status. Answer with facts about the issues,
in the order the list gives: key, summary, `age_days` as "in progress for N
days" — "waiting N days" when `status_category` is `new`. For the top pick,
say whether anything blocks it (`gadak ready` already excludes blocked
issues; when it degraded — the stderr notice — say so) and who is waiting
on it: the "What does this issue block?" query in docs/RECIPES.md, keyed on
link direction. Do not prescribe — the subject of every sentence is an
issue, never a person; no ranking of people, no praise. The answer carries
age, blockers, and who waits, and nothing else (docs/project/THEORY.md,
G1/G10).
`gadak search "…"` is the shortcut when a query is overkill; `--json` includes a
`pages` array, and every hit says which field matched. If the user pastes Jira
JQL or a navigator URL, use `gadak search --jql '…'` (or pass the URL as the
query). Clauses the subset cannot express are printed on stderr and must be
repeated to the user — do not pretend the list is what Jira would have shown.
Search covers labels as well as titles, bodies and comments — an issue whose
only signal is a label comes back, and `--json` attributes it to field
`labels`. English stem variants match (`payments` hits `payment`). CJK
queries of two or more runes match inside a compound (`결제` hits
`간편결제`). A Latin word or key written straight against CJK (`NMB-110` in
`追跡issueはNMB-110で`) still matches — a hidden index column re-indexes the
runs the tokenizer would swallow. English middles still miss (`ency` does
not hit a title that is only `idempotency`). JQL `project NOT IN (KEY, …)`
applies; `status NOT IN`
does not (`cannot apply JQL — status not in … (only = and IN)`). Prefer
`status_category` / `status_id` in SQL — never `status = 'In Progress'`.
To put the human on a view, do not describe the filters — set them:
```bash
gadak views # Jira filters (after sync) + saved views
gadak views open "the name" # focuses the running desktop app or serve tab
gadak views save "Night triage" --jql '…' # keep a named view in local.db (survives deleting the mirror)
gadak views save "Triage board" --jql '…' --layout board # board|list (default list); views open takes --layout with --jql/--keys
```
`views open` writes a one-shot hash the UI applies (`ks=` for `--keys`,
`issue=` for a positional key); it also opens a serve tab when one is
listening, and focuses Gadak.app on macOS (the `--workspace` is passed through
so the window and the file match). `--no-open` writes the hash only. `--json`
prints the hash and where it was sent. Confirm you named `--workspace` if the
user has more than one mirror. `gadak open` is the origin (Jira/Linear) escape
hatch; `gadak views open` is open-in-gadak.
## A tracker of your own: a backlog you may create and own
When work needs tracking and no Jira exists — a personal project, this
machine, or your own plan as an agent — create a workspace gadak keeps.
One command, no token, no site:
```bash
gadak init --local --json
gadak sync # first fill; in-process, no network
gadak create "first ticket title" -m "why this exists"
gadak views open <KEY>
```
`init --local` is non-interactive. Do not combine it with `--site`,
`--email`, or a token. It seeds project `STD` and wiki space `LOC`, and
records the default issue type, so a summary-only `gadak create` is enough.
The record is `origin/issuetap.db`, not the mirror — `gadak backup --to <dir>`
copies it to one file while serve runs, and prints the path.
Use a dedicated `--workspace` and name it in every command, so the backlog
never mixes with a Jira mirror:
```bash
gadak --workspace plan init --local --json
gadak --workspace plan create "Ship the uploader" -m "found while refactoring"
gadak --workspace plan views open <KEY>
```
If the active workspace is already a Jira Cloud site and the user asked
for a backlog of this repo (not of that site), do not file there. That write
is visible to their whole team. Init the built-in tracker under a new workspace instead.
Do not invent a `TODO.md`, a markdown checklist, or a GitHub Issue when
`gadak` is on PATH. The window is how the human sees the same backlog.
The durable record is `<workspace-dir>/origin/issuetap.yaml` — that file is
the backup target, not `gadak.db`. `gadak doctor --json` reports it as
`workspace.persist` (and top-level `origin`). Running `init` with a site on
the built-in tracker deletes issues that originated there; warn before
converting.
**Write freely here.** Confirm-before-writing applies to Jira or Linear
workspaces because the team can see the issue. A built-in-tracker write is a file
on this machine: create, edit, comment, and transition without asking, then
report what you filed (keys and one-line summaries).
Everything else in this file works identically: `status_category`,
`issues_full`, `reopen_count`, `views open`.
### Leaving a tracker: migrate a mirror into a workspace gadak keeps
`gadak --workspace <new name> migrate --from <workspace>` exports the
source workspace's mirror — issues, comments, history, links, attachment
bytes, wiki pages — into a brand-new workspace on the built-in tracker and ends with a
source-vs-migrated count table (derived columns like `reopen_count`
included). The source is read-only throughout and keeps working. The
target must not exist yet; migrating never rebinds an existing workspace.
`--projects A,B` / `--spaces X,Y` narrow the set, `--skip-attachments`
skips the byte downloads. Bodies migrate as the origin's ADF, formatting
included; the report counts the code blocks / tables / inline media that
arrived (an inline image resolves by filename against its attachment).
The second destination is Linear: `gadak --workspace <linear workspace>
migrate --from <workspace> --to linear --team <KEY>` writes the issues into
that team through the Linear credential of the workspace the command runs
in (no new workspace; `gadak sync` fills its mirror afterwards).
`status_category` picks the workflow state by type, `priority_rank` becomes
0-4, issue types and labels become team labels, parents and links become
sub-issues and relations, comments carry `author · time` in the body.
Re-running is safe: every created issue ends with a `gadak-migrate: <KEY>`
footer and a match is skipped. Not migrated, and said so in the report:
history (Linear has no changelog write API), wiki pages, attachment bytes
(linked by URL), real authorship. `--dry-run` prints the mapping and counts
with no network call; `--limit N` takes the first N issues.
The third destination is Jira: `gadak --workspace <jira workspace> migrate
--from <workspace> --to jira --project <KEY>` writes the issues into that
project through the Jira credential of the workspace the command runs in —
the way out of the built-in tracker that does not delete anything, since
`init --replace-local` drops locally originated issues instead of carrying
them. `status_category` picks one transition into a status of the same
category (names are never matched), `priority_rank` indexes the target
site's priority catalog, the issue type is matched by name against the
project's own types, and attachment bytes are uploaded. The same
`gadak-migrate: <KEY>` footer makes a re-run idempotent, and `--dry-run`,
`--limit N` and `--skip-attachments` work as they do for Linear. Change
history, wiki pages, real authorship and assignees stay behind; the report
lists them.
### Pointing at another workspace's issue
On the built-in tracker, local or paired, `gadak ref <KEY> <workspace>/<TARGET>`
records a pointer at an issue in a different workspace — a personal note
that names a team ticket, without writing anything to that team's tracker.
`gadak ref <KEY> --list` prints each pointer with the target's **current**
status and assignee, read from that workspace's own mirror on this machine
(no network). A target this machine does not mirror still lists — it just
says so. `--rm <id>` takes one back. The same references appear on the
issue in the app. A Jira Cloud workspace refuses the verb: the pointer
would be visible to that whole site.
`gadak link <KEY> <url> [--title T]` writes the plain-URL half of the same
row — a remote link at a commit, PR, or any page, with a title you choose.
A PR-shaped URL additionally shows in the issue's PR section (`linked_prs`),
on the CLI and in the app, with no integration attached.
## Pairing: the origin is another machine's serve
Home (the built-in tracker, with `gadak serve` running) mints one offer per device.
The remote binds a *fresh* workspace. After that, every verb on that workspace
uses the home serve as origin. `--endpoint` is the URL the remote reaches the
home serve at (the tailnet URL); without it mint takes the serve's own address
and refuses when that is loopback (exit 64) — show that error, it may already
carry the completed command.
```bash
gadak pairing mint --label laptop --endpoint https://<machine>.<tailnet>.ts.net # home: stdout is one offer line
gadak --workspace laptop init --pairing-code-stdin # remote: paste the offer
gadak --workspace laptop status # kind is connected; prints paired with "laptop"
gadak pairing list # home: token table; remote: one status line
gadak pairing revoke laptop # home only
```
Do not combine `--pairing-code-stdin` with `--local` or a site token.
`_home` is this machine's routing token, not a device — `revoke` refuses it;
`gadak pairing mint --label _home` rotates it. If a command fails with a
`pairing:` prefix, show that error to the user. Do not invent a retry.
Version skew shows before any verb fails: `gadak status` and
`gadak doctor` both carry a pairing skew line (`pairing_skew` in `--json`,
GDK-1273) naming the home serve's gadak against this client's. A home serve
older than the client means verbs it predates answer 501 with the upgrade
sentence — read that as skew, not as a route you mistyped.
## Writing as yourself: the actor
On the built-in tracker, local or paired, every write records who made it. Set
your identity before the first write so comments and transitions attribute
to you, not the workspace's default user:
```bash
export GADAK_ACTOR="claude:354bff2b|Claude (build 1)" # slug | display name
gadak status # the actor row confirms recognition (--json: actor.slug, actor.source)
```
Claude Code is detected automatically — no export needed; each session
writes as `claude:<session prefix>`. A slug is a stable identity: pick one
per agent and keep it across sessions. The machine's fallback lives in
`gadak config set actor '{"slug":"grok:aa11","name":"Grok"}'` (the env
value wins over it). On the built-in tracker the actor is the recorded
author of the write. On a Jira or Linear origin — where the write goes out
under a person's credential — agent-authored comments and new issues
instead carry one trailing line, `— via gadak · <actor>`, on by default and
off with `gadak config set actor.trailer false` (`gadak status` shows the
switch on the actor row). Without an actor,
writes attribute to the workspace's default user, exactly as before.
The person at the machine has their own verb — do not use it for agent
sessions: `gadak me set "Your Name"` records a person identity (kind
`person`, slug `person:<name>`) so that person's own writes are authored
as them; `gadak me` shows what is set, `gadak me clear` removes it. It is
refused on a connected Jira/Linear workspace, where the account is the
identity.
The attribution is queryable — "what did the previous session leave" is
one query, not archaeology across four tables. Every write surface carries
`author_id` (`items` for created issues and pages, `comments`, `changelog`,
`page_versions`), so filter each on the actor slug and union the results;
the ready-made timeline query is in `docs/RECIPES.md` under "What a
session left". The current session's slug is `actor.slug` in
`gadak status --json`.
When several agents work one backlog, claiming is a write, not a comment
convention: `gadak claim <KEY>` takes an issue as yours — assignee plus the
in-progress transition in one step — and refuses (exit 75, the holder's
name in the error) while another actor holds it. Use it instead of a
"[claim]" comment; `--take-over` replaces the holder only when the human
says to. On a workflow where two transitions land in progress the bare
claim refuses with both named — pass `--transition <id|name>` to choose
(it must land in progress; anything else is `gadak transition`). `gadak issue KEY` answers "how long has this sat?" with its
`durations` line (wait = created → first in-progress, progress = in-progress
→ done or now), computed from the changelog — never stored.
## One issue, and writes
On the **built-in tracker**, `init` seeds project `STD` and records a default issue type,
so a summary-only create is enough. On **Jira**, use a key and project
that exist on that site (the `NMB-140` lines below are an example, not a
universal project). A paired workspace is `connected` with no default project
or type — `create` will ask for `--project` / `--type` until you set them.
```bash
# built-in tracker (seed project STD)
gadak create "first ticket title" -m "why this exists"
gadak issue STD-1 --json
gadak comment STD-1 -m "Reproduced on staging."
gadak assign STD-1 you@example.com # the one seeded user; example.com emails from the Jira examples do not exist here
gadak claim STD-1 # take it as yours: assignee + in-progress transition; refuses while another actor holds it (exit 75)
gadak dev link STD-1 --pr https://github.com/org/app/pull/7 # opened a PR? record it right here
gadak dev scan # or sweep the repo: keys in PR titles/branches → links
# Jira: a key that exists on that site
gadak issue NMB-140 --json # fields, description, comments, history
gadak issue NMB-140 --editmeta # which configured fields this issue can edit (origin GET; not stored)
gadak issue NMB-140 NMB-141 --json # JSON array of the same documents; omit --json for text with --- KEY --- between them
gadak sql --no-header "select key from issues_full where parent_key='NMB-140'" | gadak issue --keys -
gadak issue NMB-140 --derive # why reopen_count / resolved_at / epic_key are what they are
gadak comment NMB-140 Reproduced on staging. # positional body
gadak comment NMB-140 -m "Reproduced on staging."
gadak comment NMB-140 -m "thanks @Dana" # @Name resolves to a site user; ambiguous names are refused
gadak comment NMB-140 -m - # body from stdin, for anything multi-line
gadak comment NMB-140 -m "done" --visibility role=Administrators
gadak comment NMB-140 -m "done" --internal # JSM internal
gadak comment edit NMB-140 91653 -m "corrected repro" # replaces that comment's body — the id is whatever a read handed you
gadak comment rm NMB-140 jira:91653 --yes # removes it; gadak sql prints jira:91653, gadak issue prints 91653, both accepted
gadak transition NMB-140 # list tokens this credential can fire
gadak transition NMB-140 "In Review"
gadak transition NMB-140 done # status category: new | inprogress | done
# Prefer the name or the category token over a number: on the built-in tracker
# transition ids (1..n) and status ids (3, 10000, …) overlap, so a bare number
# is refused as ambiguous — the refusal names both readings.
# A category token folds destinations you could not have told apart: two
# statuses with the same display name in the same category count as one, and
# the one the project actually uses wins. Two destinations with *different*
# names still refuse and name every reading, including the folded ones —
# `gadak transition NMB-140` lists them before you try to write.
gadak close NMB-140 # same as transition KEY done; already done is a no-op
gadak transition NMB-140 done --resolution "Won't Do" -m "fixed in 1.2"
gadak assign NMB-140 dana@example.com # email, display name, or accountId; `-` unassigns. Ambiguous names are refused with the candidates.
gadak claim NMB-140 # Cloud has no atomic claim: assignee + transition run as two calls (warned on stderr); held issues refuse with exit 75
gadak claim NMB-140 --take-over # replace the current holder
gadak create Batch worker drops the last page --project NMB --type Bug -m "repro on staging" --parent NMB-1
gadak create Severity required --project NMB --type Task --field severity=High
gadak attach NMB-140 screenshot.png trace.log
gadak attach NMB-140 /tmp/bench.html # an .html attachment renders inline on the issue, sandboxed like a dashboard — see "The same wall, attached to one issue"
gadak attach get NMB-140 trace.log # the read half: bytes to ./trace.log. `gadak issue KEY` lists the names; --out takes a directory, a path, or - for stdout. Works on every origin — do not parse issues.raw for an id and hand-build a REST path for `gadak api`.
gadak edit NMB-140 --summary "…" --label +regression --label -needs-triage --priority High --parent none
gadak edit NMB-140 --type Task # name, localized name, or id — same resolver as create --type
gadak edit NMB-140 -m "## Repro\n\n- step one" # markdown replaces the body; a text with no placeholders over a body that has panels/media/mentions refuses without --force-plain
gadak issue NMB-140 # a formatted body prints with placeholders: <!-- adf:1:… panel info --> … <!-- /adf:1 -->, <!-- adf:3:… mention @Dana -->
gadak edit NMB-140 -m - < edited.md # keep the markers: each node goes back where its marker stands; delete a marker to remove that node (stderr says which); a stale marker is refused — Cloud only: on jira-server the body is wiki markup carried verbatim and markers are literal text
gadak issue NMB-140 --json | jq .description_adf > body.json && gadak edit NMB-140 --adf-file body.json # the raw path: the ADF back as it is, no guard (comment --adf-file too)
gadak edit NMB-140 --component +SDK --component -Docs
gadak edit NMB-140 --fix-version +v2.5 --fix-version -10012
gadak edit NMB-140 --field severity=High
gadak link NMB-140 NMB-141 --type blocks # A blocks B; "is blocked by" means A is blocked by B
gadak link NMB-140 https://github.com/org/app/pull/7 --title "Fix login" # remote link (built-in or paired); a PR-shaped URL also appears in the issue's PR section
gadak unlink NMB-140 NMB-141 --type blocks # removes that link (live id lookup; the mirror keeps no link ids)
gadak create Ship the uploader --project NMB --type Task --dry-run # every write verb takes --dry-run: prints the exact request as one JSON line and sends nothing
gadak edit NMB-140 --priority High --dry-run # resolutions still run (ids, not typed names); a would-refuse dry-run refuses
gadak create --batch - # one JSON object per line on stdin (stops at the first failure)
gadak comment --batch - # JSON lines {"key","body"}; tries every line; one envelope row per key
gadak transition --batch - # JSON lines {"key","target"}; --dry-run writes nothing (--json adds transition_id)
gadak assign --batch - # JSON lines {"key","assignee"}; "-" unassigns
gadak edit --batch - # JSON lines {"key"} plus summary, labels (+x/-x), type, priority, due, parent, fields
gadak fields --apply # map in-use custom fields, then edit --field alias=value
gadak project create IDEA --name Ideas # grow a built-in-tracker workspace by a project
gadak transition NMB-140 done --field environment=staging
gadak search NMB-140 --explain # why each hit ranked: key-exact, key-prefix, or fts
```
`--batch -` on comment, transition, assign, and edit reads one JSON object per stdin line (at most 50). A line that fails does not stop the rest; stdout is one envelope row per line (`key`, `ok`, `changed`, `error`; `--json` is JSON lines). `create --batch -` still stops at the first failure.
`--dry-run` (create, edit, comment and comment edit/rm, transition, close, assign, claim, link, unlink) prints the request the write would send — `{"dry_run":true,"verb":…,"key":…,"request":{…}}` on stdout, "nothing was sent" on stderr — and exits 0 without touching the origin. Resolutions run first, so the plan carries ids, not typed names, and a write that would refuse refuses in dry run too. `create --json` carries the new key at the top level (`key` and `created.key`), plus `resolved` saying which default chose each field (`source`: flag, config, sole, alias, parent, catalog, mirror).
Custom-field writes follow this order: `gadak fields --apply` (save aliases) →
`gadak issue KEY --editmeta` (which of those aliases this issue can edit) →
`gadak edit KEY --field alias=value` or `gadak create … --field alias=value`.
`gadak comment` resolves `@Name` to a site user (account id) before sending.
Ambiguous names are refused with the candidates and no comment is posted. A
name that matches nobody stays plain text and is named on stderr; stdout stays
pipeable.
Wiki pages — read from the local mirror (no network), write through the
origin (Confluence, or the built-in tracker's wiki). The seeded space
is `LOC`; Jira: a space key that exists on that site. `gadak wiki` runs
every one of these (`gadak wiki get <ID>` = `gadak page get <ID>`):
```bash
gadak page list # id, space, title, updated_at — newest first; where <ID> comes from
gadak page get <ID> # title, body, comments from the mirror
gadak page get <ID> --json
gadak page create --space LOC --title "Retention notes" -m "first draft"
gadak page edit <ID> --title "Renamed"
gadak page comment <ID> -m "a question"
```
To edit a rich page without losing its formatting (panels, layouts, embeds —
anything markdown cannot carry), round-trip the storage document:
`gadak page get <ID> --storage > body.json`, edit that JSON, then
`gadak page edit <ID> --storage-file body.json` replaces the whole body with it —
pin `--version` from the same read when the page may have moved since.
Writes go to the origin, then the issue (or page) is re-read into the mirror.
On a **Jira** Cloud workspace the origin is Jira — a create, comment,
or transition is visible to their whole team; confirm first.
On the **local built-in tracker** the origin is this machine. File without
asking, then report the keys and one-line summaries.
On a **paired** workspace (`status --json` has `pairing`), writes go to the
home serve, not Atlassian. Its mirror carries the home origin's wiki too
(every team space, personal excluded); if `status --json` says the wiki pass is skipped as
not configured, the workspace was paired before that was the default —
`gadak config set wiki.enabled true` turns it on (`wiki.*` is the alias the
CLI takes for the stored `confluence.*` paths; stderr names the stored one).
Never write to the SQLite file. A row written directly is destroyed by the
next sync, on either kind.
Discover flags from the binary, not from memory: `gadak <verb> --help` lists
what that verb accepts, and `gadak issue KEY --editmeta` lists which configured
custom fields this issue can edit. If a field still is not there, say so rather
than reaching for the REST API — `gadak api` exists for that, but it is an
escape hatch, not the path of least surprise.
## Leaving and finding memory
A page is where a session leaves what the next one should not have to
rederive. Pages and issues share one index — `items_fts` covers both kinds —
so retrieval is the search this file already teaches: the unified query under
*Queries that cover most questions* above, or `gadak search '<keyword>'`,
which returns issues and pages together.
The loop, three commands:
```bash
gadak page create --space LOC --title "retry backoff — findings" -m - <<'EOF'
Base 250ms, factor 2, cap 8s, jitter mandatory. Measured on the upload
path; the flat 1s retry lost large uploads.
EOF
# → 20001 retry backoff — findings (stdout is the page id, then the title)
gadak comment STD-1 -m "Findings: /wiki/spaces/LOC/pages/20001 — backoff measured"
gadak search "backoff" # next session: the issue and the page both return
```
The comment is what ties page to issue: `item_refs` is rebuilt from comment
text on every write, and a comment links a page **only when it carries the
page's URL** — `/wiki/spaces/<KEY>/pages/<ID>` or `pageId=<ID>`, the two
shapes `item_refs` recognizes (the linked page then shows under `ref_pages`
in `gadak issue KEY --json`). A bare page id in a comment does not link —
unlike a bare issue key, which does.
**Agent memory has its own verb.** When the intent is "leave it so the next
session finds it", `gadak memory add '<note>'` is the correct call: it
writes a page into the memory space through the same origin path as
`page create`, derives the title from the note's first line, and reports
`id → title → space`. `gadak memory search '<text>'` scopes the search to
that space alone. The space is the `memory.space` setting: the **built-in tracker**
defaults to the seeded `LOC`; **Jira refuses until it is set**
(`gadak config set memory.space KEY`) rather than guess a team-visible
space — ask the user which space before the first add. To extend an
existing note instead of starting a new page, `gadak page edit <ID>
--append -m '<text>'` grafts paragraphs onto the current body and keeps
whatever formatting is already there.
**Leave a page when the finding outruns the issue**: an investigation whose
result a future session would otherwise redo, a decision together with its
why, a map of something the mirror cannot derive from its own rows. What the
issue itself already records belongs in a comment — and so does a one-line
fact; a page that would hold one sentence is a comment wearing a title.
Confirm-first reaches pages exactly as it reaches issue writes: on a
**Jira** workspace a page is visible to the whole team — confirm before
creating or editing one, and use a space key the user names, never a guess.
On the **built-in tracker** the seed space is `LOC` and writes are free (see
*A tracker of your own* above).
## Workspace settings
An agent configures the workspace through the CLI. Do not hand-edit
`~/.gadak/config.json` and do not drive the Settings dialog.
```bash
gadak config list # every editable path, current value, one-line description
gadak config list --json
gadak config get appearance.theme
gadak config set appearance.theme dark
gadak config set syncIntervalSec 30
gadak config set features.feed true
gadak config set projects '["NMB","NMA"]'
gadak config set devStatus true
```
`--json` on `list` and `get` (and `set`, which prints the stored value).
Unknown paths exit 64 and print the valid list. `set` accepts JSON or a
bare scalar (`dark`, `true`, `30`); arrays and objects need JSON.
Credentials (site, email, token) stay on `gadak init` — they are not
`config set` paths. `gadak config list` says so.
`appearance.theme` is `system` (the default, not persisted), `light`,
`dark`, or a lowercase palette id (`[a-z0-9-]{1,32}`). Palette names
belong to the web; the CLI only checks the shape.
## UI colors (`ui.*`)
The user's color overrides are three `config set` paths. Discover the valid
token names first — the catalog ships with the binary:
```bash
gadak config get ui.tokens.catalog --json # name, cssVar, tier, rules, per-palette values
gadak config set ui.tokens '{"colors":{"accent":"#7a4bd0"}}'
gadak config set ui.tokensByTheme '{"dark":{"colors":{"accent":"#9a6be0"}}}'
gadak config set ui.dataColors '{"label":{"urgent":"#c03030"},"type":{"10007":"#d07020"},"status":{"inprogress":"#7e5904"}}'
```
- **Warnings mean applied: only parsing refuses.** A value that
cannot parse (`"red"`, `"90"` for a length) or a wrong shape refuses;
everything else — locked tiers, contrast/ΔEok/deuteranopia floors,
dimension ranges and relations — **warns on stderr and saves** (exit 0,
value echoed). Do not retry or work around a warning: the user's look is
theirs. Do surface the warning text — it carries the measured number,
the floor, and the fix (contrast warnings name the failing palettes and
the `ui.tokensByTheme.<palette>` scoping fix; type-step warnings list the
four-rung ladder that moves together).
- **Tiers:** `locked` tokens warn and save (palette authoring — the build
may re-derive them in an upgrade); `validated` tokens are judged in every
palette they render in; `free` tokens need hex only. Values are `#rgb` or
`#rrggbb`, nothing else.
- **`dataColors` keys are ids, never display names**: `label.*` is the label
text itself, `type.*` is the Jira issue type id (digits — names localize per
account), `status.*` is the status category `new` / `inprogress` / `done`.
A display name is refused with the correct key kind in the message.
- **Unknown token names warn and are carried**, not refused — a newer gadak's
config still loads. Do not "clean" them out on sight.
- Changes reach an already-open web tab within ~1s (the ui-focus poll carries
`configVersion`); no reload, no server restart.
## Dashboards (agent-authored walls)
You can author a live dashboard yourself: one HTML document plus named
queries, saved like a view and rendered full-tab in the running web UI.
Write the HTML to a temp file, register it with its datasources, open it —
an already-open tab follows within ~1s, and every later `save` under the
same name live-replaces the frame:
```bash
gadak dashboards save triage --html /tmp/triage.html \
--datasource "by_status=sql:select status_category, count(*) as n from issues_full group by 1 order by 1" \
--datasource "mine=jql:assignee = currentUser() AND resolution is EMPTY"
gadak dashboards open triage # focuses the running web tab
gadak dashboards list / show / rm # lifecycle; same name on save = update
```
- **Your HTML never fetches.** It runs sandboxed (opaque origin, CSP closes
the network); the host executes each registered datasource and pushes rows
in as `postMessage` events `{type:'data', name, columns, rows}`. Listen
for those and paint. **`rows` are positional arrays, not objects** —
`columns` is `["label","n"]` and `rows` is `[["api",12], …]`, so read
`row[columns.indexOf('n')]`. Reaching for `row.n` is `undefined` in every
cell and throws nothing; a wall of `undefined`/`NaN` over correct SQL is
always this. You may send back exactly two verbs, both throttled:
`{type:'refresh'}` (re-run datasources) and
`{type:'open', hash:'#/?issue=GDK-1'}` — navigate the app itself to one
of its own hashes (issue detail `#/?issue=KEY`, filtered list
`#/?sc=inprogress`, search `#/?q=…`; recipe table in `docs/DASHBOARDS.md`).
To link off-app, use `<a target="_blank" rel="noopener">` — it opens a
new tab and leaves the wall in place; never a plain external `href`,
which navigates the frame away.
- **SQL datasources are arbitrary read-only SELECTs** over `issues_full` —
CTEs and window functions included. Key on computed columns, never display
names: `status_category` / `priority_rank` / `issue_type_id`.
- **Charts: use the vendored libraries, not a CDN** (CSP refuses external
hosts). `<script src="/api/v1/dashboards/vendor/uPlot.iife.min.js">` (+ its
CSS) — leading slash required. The frame inherits no app styling: set your
own explicit palette.
- **The wall's height is the panel's, not yours.** A terminal strip or a
second column can halve it between one open and the next, and the frame
scrolls with an overlay scrollbar that is invisible at rest — so a list
that runs past the fold ends in a row cut through its glyphs and says
nothing about the rest. Give any list that can grow its own
`overflow-y: auto` region, floor its height to a whole number of rows so
the last one inside it is a full row, and show a `+N more` line while rows
are hidden (and only then). `examples/dashboards/label-ratio.html`
in gadak's source tree is this shape end to end.
**A complete one. Copy this shape — there is no example file to go find.**
Everything the contract requires is here: the listener, the positional read,
and a paint. Nothing is elided.
```html
<!doctype html><meta charset="utf-8">
<style>
:root { color-scheme: light dark }
body { margin:0; padding:24px; font:14px/1.5 ui-sans-serif,system-ui,sans-serif;
background:#12141a; color:#e8eaf0 }
h1 { font-size:15px; font-weight:600; margin:0 0 16px; letter-spacing:.01em }
/* One row's box in one place — the snap arithmetic reads the same value. */
:root { --row-h: 30px }
* { box-sizing:border-box }
html, body { height:100% }
body { display:flex; flex-direction:column; overflow:hidden }
/* min-height:0 lets this shrink below its content, so the overflow lands
in #out's own scroller instead of running off the panel's bottom edge. */
#wrap { flex:1; min-height:0; display:flex; flex-direction:column }
#out { overflow-y:auto; overscroll-behavior:contain }
#more { height:var(--row-h); display:flex; align-items:center;
color:#a8aec0; font-size:12.5px; border-top:1px solid #262a35 }
.row { display:flex; align-items:center; gap:12px; height:var(--row-h) }
.name { width:180px; color:#a8aec0; overflow:hidden; text-overflow:ellipsis;
white-space:nowrap }
.bar { height:18px; background:#5b8cff; border-radius:3px; min-width:2px }
.n { color:#a8aec0; font-variant-numeric:tabular-nums }
[hidden] { display:none !important }
</style>
<h1 id="t">…</h1>
<div id="wrap"><div id="out"></div><div id="more" hidden></div></div>
<script>
addEventListener('message', (e) => {
const m = e.data
if (!m || m.type !== 'data' || m.name !== 'by_label') return
// rows are POSITIONAL arrays keyed by columns — never row.n
const iL = m.columns.indexOf('label'), iN = m.columns.indexOf('n')
const rows = m.rows.map((r) => [String(r[iL]), Number(r[iN])])
const max = Math.max(1, ...rows.map((r) => r[1]))
document.getElementById('t').textContent =
`${rows.length} labels · ${rows.reduce((a, r) => a + r[1], 0)} issues`
document.getElementById('out').replaceChildren(...rows.map(([label, n]) => {
const row = document.createElement('div'); row.className = 'row'
const nm = document.createElement('div'); nm.className = 'name'; nm.textContent = label
const bar = document.createElement('div'); bar.className = 'bar'
bar.style.width = `${(n / max) * 320}px`
const num = document.createElement('div'); num.className = 'n'; num.textContent = n
row.append(nm, bar, num); return row
}))
fit()
})
// The height is the panel's: floor the list to whole rows so the last one
// is never cut through its glyphs, and spend the remainder on "+N more"
// — shown only while rows are actually hidden.
function fit() {
const wrap = document.getElementById('wrap'), out = document.getElementById('out')
const more = document.getElementById('more'), rowH = 30
const n = out.children.length, avail = wrap.clientHeight
if (!avail || !n) return
const fits = Math.floor(avail / rowH) >= n
const shown = Math.max(1, Math.floor((fits ? avail : avail - rowH) / rowH))
out.style.height = `${shown * rowH}px`
const hidden = Math.max(0, n - shown)
more.hidden = hidden === 0
if (hidden) more.textContent = `+${hidden} more · scroll for the rest`
}
addEventListener('resize', fit)
</script>
```
```bash
gadak dashboards save label_ratio --html /tmp/label_ratio.html \
--datasource "by_label=sql:select json_each.value as label, count(*) as n \
from issues_full, json_each(issues_full.labels) group by 1 order by n desc limit 20"
gadak dashboards open label_ratio
```
- For a uPlot line chart and the full `open` recipe table, `docs/DASHBOARDS.md`
lives in gadak's source tree — it is *not* installed beside this file, so
fetch `https://github.com/midagedev/gadak/blob/main/docs/DASHBOARDS.md` if
you want it. Do not search the filesystem for it: there is nothing to find,
and the block above is already a working wall.
### The same wall, attached to one issue
A dashboard is workspace-wide. When the report belongs to one issue — a
benchmark you just ran, a diff you just measured, a failure you just
reproduced — attach the HTML to that issue instead and the detail renders it
inline:
```bash
gadak attach NMB-140 /tmp/bench.html # any .html attachment becomes an artifact
```
The issue detail draws it in a sandboxed frame under the **same policy the
dashboard frame gets**: opaque origin, the network closed, and the vendored
libraries reachable at `/api/v1/dashboards/vendor/…` (leading slash required).
So the charting half of the block above works unchanged. Two differences: there
are no datasources — an artifact has no `postMessage` channel and paints the
numbers you wrote into it — and nothing live-replaces it; a new upload is a new
attachment. The frame inherits no app styling, so set your own palette, and
give any list that can grow its own `overflow-y: auto` region for the same
reason the wall does.
This is the one way an agent leaves a *rendered* result where the work lives.
A comment holds text; this holds the chart.
## Rules that come with the file
- **Never write to the database.** Writes go through the origin (the Jira site on
a Jira workspace, the built-in tracker when local, the home serve when paired);
a row written directly is destroyed by the next sync. No exception for
"just a label". Saved views and visits live in `local.db`, not the mirror.
- **Do not depend on `issues.raw`.** It is shaped by Jira's API, not by gadak's
contract. Use the projected columns.
- **Do not poll in a loop.** `sync_state.version` moves only when something
changed — compare it instead.
- **Read the freshness warning.** `issue`, `search`, `comment`, `transition`,
`assign`, and `fields` print one line to stderr when the last sync failed or
is over an hour old. stdout stays clean and pipeable.
- **Do not quote a restricted or JSM-internal comment in a public channel.**
Filter `visibility_type != '' OR jsd_public = 0` first (`jsd_public` NULL
means the marker was absent, not internal).
- **Before quoting an issue in a public place** (commit message, public
summary, chat), check `security_level_id`. NULL means unrestricted, or a
row the next sync has not rewritten. Key on the id, never on
`security_level` (names localize).
## Development-panel links (`dev_links`)
`dev_links` is the projected development panel (pull-request URL, title,
status). `gadak issue KEY --json` includes it; SQL joins `dev_links` on
`item_id`.
- **built-in tracker:** `gadak dev link KEY --pr <url>` records a PR through the
built-in origin; `gadak dev scan` execs `gh pr list` and links matches
(`cmd/gadak/dev.go`). Both refuse on a Jira workspace.
- **Jira Cloud:** do not run `dev link` / `dev scan`. `gadak config set
devStatus true` to *mirror* Jira's development panel into `dev_links` (read).
- **paired** is kind `connected`: same refusal as Cloud. Write on the
gadak home, then sync.
When you close an issue, end the comment with the **full URL** of what closed
it, one per line and nothing else on those lines — a commit URL, a PR URL, or
`repo@hash` when the repository has no public one. Prose like `main 1693107`
records the same fact in a shape nothing can read: it is not clickable, and
the development-panel surface above cannot see it. A URL is what a future
ingest would parse, so writing one costs nothing now and keeps the record
usable later. This matters most where it is the only record: a built-in
tracker, and a Jira Cloud site without the GitHub app, have no automatic
commit linking at all.
## When the mirror does not model it
Watchers, worklogs, user search, and anything else sync does not
project are reachable through the origin with `gadak api`. Watchers here
means Jira's site-side subscribers on an issue — not mirrored, so the
`watchers` count in the API response is the only copy. gadak's own
**watches** are the other word entirely: your local follows, keys in
`local.db` that `gadak export` carries beside favorites and recents.
They never touch the site. Sprints *are*
projected (`issues.sprint_id`, `sprint_name`, `sprint_state`); filter on
`sprint_id` or `sprint_state='active'`, never on `sprint_name`. The
`sprints` and `boards` tables carry the rest, and `gadak sprint` reads and
writes them:
```bash
gadak sprint list # active first, with issue counts and the sprint goal
gadak sprint show 14 # that sprint's daily burn-up: scope, started, done (a mirror read)
gadak sprint add 12 NMB-140 NMB-141 # into sprint 12
gadak sprint remove NMB-140 # back to the backlog
gadak sprint create 3 "Sprint 14" --goal "ship the uploader"
gadak sprint start 14 --days 7
gadak sprint close 14
```
A host without a shell reaches the burn-up as the `gadak_sprint` MCP tool,
with `id` as its argument — the series is computed from the changelog rather
than stored, so `gadak_query` cannot answer it. An origin that keeps no change
history says so (`has_history: false`) instead of returning a row of zeros.
They work on Jira (Cloud and Server, where Jira Software is installed), on
the built-in tracker, which serves the same Agile surface, and on Linear,
where a cycle is a sprint and a team is a board — there `list`, `add` and
`remove` work, and `create`, `start` and `close` refuse by name: a cycle
begins and ends by its dates, and Linear makes them from the team's cadence
rather than on request. `show` is the one read in the group: it reconstructs
the daily scope from the changelog, so on Linear it prints the sprint's
header and says the burn-up is not available.
```bash
gadak api GET /rest/api/3/issue/NMB-140/watchers
```
Read-only unless `--write` is passed. Prefer the mirror when it can answer.
On a Jira or Linear workspace this is a network round trip against the site's
rate budget. On the local built-in tracker it talks in-process (no network);
unimplemented paths return 501.
## When gadak itself is the problem
You cannot see the window, so a path is your whole diagnostic surface. In
order:
- `gadak doctor` — a redacted summary of this install. Safe to paste into a
report; it never contains a credential.
- `~/.gadak/logs/gadak.log` — the log file. It is local, never uploaded, and
token values are stripped before anything is written; read the tail of it.
`gadak doctor` prints the real path, which is what to trust if the home was
moved.
- The mirror is a disposable cache, so `gadak sync --full` is a safe first
move and losing the mirror costs nothing. Do that before anything drastic.
- `skill: stale` means this file and the binary disagree — `gadak skill
install`.
Four `gadak doctor` fields are diagnostics, not failures — same names in
`--json`. `mirror.wal_bytes` is read before doctor opens the mirror, so the
number is what was on disk, not something doctor's own open caused
(GDK-307). `session_boundary` is where the previous reading session ended
(a read more than 30 minutes after the last starts a new one); visits newer
than it are the current session (GDK-1549). `priority_entropy` (the human
`priority` line) says one priority value is carrying the whole axis
(GDK-1413) — do not build work-ranking answers on priority until it
spreads. `local_schema_skew` says a newer gadak already migrated this
home's `local.db`: upgrade gadak or point at another `--workspace` /
`GADAK_HOME` rather than reporting the workspace broken (GDK-596).
## Report the friction; do not route around it
If gadak made you work harder than the task deserved — a column you had to
parse by hand, an identifier a read gave you that a write refused, an error
that named a problem but not a next move — that is worth reporting, and
reporting it is the expected move rather than a favour. The workaround you
were about to write destroys the evidence: once you have parsed it yourself,
nobody can see that the column was missing.
Report it at `https://github.com/midagedev/gadak/issues`. What makes a report
actionable here:
- the exact command you ran and the exact output you got
- `gadak --version`, and its origin type and transport, or
Linear
- what you expected instead — one sentence
A patch is welcome too, and is often smaller than the report suggests: clone
`https://github.com/midagedev/gadak`, and `AGENTS.md` at the root is the
development contract, including the gate commands to run before you open a
pull request.
## More
`gadak sql "select ..."` against `specs/000-product/data-model.md` covers every
column; `docs/RECIPES.md` in the gadak repository has more worked questions.