Compare commits
9
Commits
9b06e9e24b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933c197060 | ||
|
|
de3591a25b | ||
|
|
88032490c0 | ||
|
|
fd24b69039 | ||
|
|
dfc009e422 | ||
|
|
1843c0495a | ||
|
|
43ae5bb53b | ||
|
|
05054afdc9 | ||
|
|
9022847971 |
@@ -38,14 +38,34 @@ if [[ -f /etc/udev/rules.d/99-panama-gpu.rules ]]; then
|
||||
fi
|
||||
|
||||
# The power-key drop-in lands via the same copy; logind reads its config only
|
||||
# at start, so without this the button keeps hard-powering-off until the next
|
||||
# boot. Restarting logind is what Fedora's own updates do and current systemd
|
||||
# preserves sessions across it.
|
||||
# at start. Restarting logind under a running Wayland or X11 session drops that
|
||||
# session and takes the desktop down with it, so the restart happens only when
|
||||
# logind is not already honouring the drop-in AND no graphical session is live.
|
||||
# Otherwise the change waits for the next boot, which is harmless: the answer
|
||||
# is usually already "ignore" from an earlier run.
|
||||
graphical_session_active() {
|
||||
local id
|
||||
for id in $(loginctl list-sessions --no-legend 2>/dev/null | awk '{ print $1 }'); do
|
||||
case "$(loginctl show-session "$id" -p Type --value 2>/dev/null)" in
|
||||
wayland|x11) return 0 ;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ -f /etc/systemd/logind.conf.d/panama-power-key.conf ]]; then
|
||||
power_key="$(busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \
|
||||
org.freedesktop.login1.Manager HandlePowerKey 2>/dev/null)" || power_key=""
|
||||
if [[ "$power_key" == 's "ignore"' ]]; then
|
||||
log "logind already leaves the power button to the power menu"
|
||||
elif graphical_session_active; then
|
||||
log "A desktop session is running; the power-button change applies at next boot"
|
||||
else
|
||||
log "Reloading logind so the power button opens the menu instead of powering off"
|
||||
sudo systemctl try-restart systemd-logind 2>/dev/null \
|
||||
|| log "Could not restart logind; the power-button change applies at next boot"
|
||||
fi
|
||||
fi
|
||||
|
||||
# The document-portal drop-in lands via the same copy. A user unit's drop-ins
|
||||
# are read at daemon-reload, so without this the guard applies from the next
|
||||
|
||||
@@ -105,6 +105,13 @@ before_system_flatpak="$(snapshot_file_state /var/lib/flatpak/repo/config)"
|
||||
before_system_flathub_key="$(snapshot_file_state /var/lib/flatpak/repo/flathub.trustedkeys.gpg)"
|
||||
before_user_flatpak="$(snapshot_file_state "$HOME/.local/share/flatpak/repo/config")"
|
||||
before_bashrc="$(snapshot_file_state "$HOME/.bashrc")"
|
||||
# A runtime case that escapes its case_root writes a fixture node into the real
|
||||
# nvm tree, and the real installer then refuses that unreceipted directory on
|
||||
# every later update. Snapshot the listing so the escape fails here instead.
|
||||
snapshot_nvm_node() {
|
||||
find "${NVM_DIR:-$HOME/.nvm}/versions/node" -printf '%P %s %T@\n' 2>/dev/null | sort
|
||||
}
|
||||
before_nvm_node="$(snapshot_nvm_node)"
|
||||
|
||||
# Runtime and agent installs must consume the reviewed provenance table. Keep
|
||||
# this scan at the public script boundary because a command hidden elsewhere in
|
||||
@@ -2064,5 +2071,7 @@ STUB_DNF_FAIL_MATCH=terra-release expect_failure run_installer_function terra-dn
|
||||
|| fail 'repository cases changed the user Flatpak remote'
|
||||
[[ "$before_bashrc" == "$(snapshot_file_state "$HOME/.bashrc")" ]] \
|
||||
|| fail 'repository cases changed the protected bashrc'
|
||||
[[ "$before_nvm_node" == "$(snapshot_nvm_node)" ]] \
|
||||
|| fail 'runtime cases wrote into the real nvm node tree'
|
||||
|
||||
printf 'package provenance contract: PASS\n'
|
||||
|
||||
@@ -36,7 +36,7 @@ note() { findings+=("$1"); }
|
||||
tmp="$(mktemp -d -t panama-update-contract.XXXXXX)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings
|
||||
STAGE_NAMES=(install-packages link-dotfiles link-skills link-user link-mcp change-settings
|
||||
link-vicinae-scripts setup-server link-server setup-identity
|
||||
install-hardware)
|
||||
PACKAGE_BEHAVIOR_INPUTS=(
|
||||
@@ -191,7 +191,7 @@ fi
|
||||
|
||||
# ── 2. Exactly the answer-free stages ────────────────────────────────────────
|
||||
|
||||
for stage in install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts; do
|
||||
for stage in install-packages link-dotfiles link-skills link-user link-mcp change-settings link-vicinae-scripts; do
|
||||
grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage"
|
||||
done
|
||||
for stage in setup-identity install-hardware; do
|
||||
@@ -364,7 +364,7 @@ grep -qx 'link-dotfiles' "$tmp/c/ran" \
|
||||
# reason to abandon the machine either. It suppresses the stages that open DNF
|
||||
# and the migrations, which are free to run a transaction of their own. Every
|
||||
# stage that only links configuration still runs, and the status stays 78.
|
||||
SAFE_STAGES=(link-dotfiles link-skills link-user link-vicinae-scripts)
|
||||
SAFE_STAGES=(link-dotfiles link-skills link-user link-mcp link-vicinae-scripts)
|
||||
DNF_SUPPRESSED=(install-packages change-settings install-hardware)
|
||||
|
||||
assert_trust_refusal() {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# The ticket page
|
||||
|
||||
Every ticket this skill works has a **ticket page**, and every epic has an **epic index**. The
|
||||
page is where Gib follows the work: the ticket as Jira has it, the plan, live progress, proof
|
||||
beside the mocks, and the review. It updates while you work, so a write you skip is a gap he
|
||||
sees. It is a personal working file, exempt from the prose bar like the rest of `.claude/docs/`.
|
||||
|
||||
```
|
||||
.claude/docs/epics/
|
||||
_site/site.css, site.js # shared renderer, refreshed from templates/site/ on every write
|
||||
<EPIC-KEY>/
|
||||
index.html, epic.data.js # the epic index, in build order
|
||||
<TICKET-KEY>/
|
||||
index.html, ticket.data.js # the ticket page
|
||||
resources/ proof/ deliverables/ mr.md
|
||||
tickets/<TICKET-KEY>/ # same shape, for a ticket with no epic, and no index
|
||||
```
|
||||
|
||||
## Write through `ticket-page`, always
|
||||
|
||||
`~/.agents/skills/ticket/scripts/ticket-page` is the only writer of the data files. It validates
|
||||
before every write and refuses a broken one, writes atomically so the open page never reads half
|
||||
a file, appends each change to the page's live log, and keeps the ticket's row on the epic index
|
||||
current. Editing a `.data.js` file by hand skips all four. `ticket-page help` lists every
|
||||
command and its flags.
|
||||
|
||||
It finds the ticket by key under the current repo's `.claude/docs/epics/`, so run it from inside
|
||||
the repo.
|
||||
|
||||
## What to record, and when
|
||||
|
||||
| Moment | Command |
|
||||
|---|---|
|
||||
| Issue fetched to a scratch path | `init <KEY> --issue <issue.raw.json>`. Prints the ticket folder, then any populated Jira fields the page does not show. |
|
||||
| Attachments downloaded, a video transcribed, frames extracted | `scan <KEY>` |
|
||||
| Ticket branch resolved, ticket classified | `meta <KEY> --branch <name> --classified bounded` |
|
||||
| Research notes worth keeping: what a video says, what a spreadsheet holds, a check against prod | `note <KEY> "<Title>" --file notes.html` |
|
||||
| Plan written | `plan <KEY> --file plan.json`, then one `mock` per artboard |
|
||||
| Plan approved, Phase 2 begins | `approve <KEY>` |
|
||||
| Starting a step | `step <KEY> <n> running` |
|
||||
| A step's commit exists | `step <KEY> <n> done --commit <sha>`, one `--commit` per commit |
|
||||
| A criterion is met | `criterion <KEY> <ID> done --evidence "<what shows it>"` |
|
||||
| Proof captured, or dropped from `proof/` | `proof <KEY> --file proof/<name> --proves AC1,AC3 --caption "<test case>" [--mock <id>]`, `proof <KEY> --test "<name>" ...`, `unproof <KEY> <file>` |
|
||||
| Reviewer finding, and its resolution | `finding <KEY> add "<text>" --severity CONFIRMED --where <file:line>`, then `finding <KEY> F1 fixed --commit <sha>` or `rejected --reason "<why>"` |
|
||||
| pre-mr-review audit read | `verdict <KEY> <verdict text> --recommendation <R> --head <sha>` |
|
||||
| A Jira field PUT and verified | `jira <KEY> "<field name>"` |
|
||||
| `mr.md` written | `mr <KEY>` |
|
||||
| Anything else Gib would want to see happen: suite green, a surprise, a blocker | `event <KEY> "<text>"` |
|
||||
|
||||
A step is done when its commit exists and `ticket-page show <KEY>` lists that commit beside it.
|
||||
`step done` refuses without `--commit`. A step with nothing to commit, such as a verification
|
||||
pass, takes `--no-commit`.
|
||||
|
||||
## `plan.json`
|
||||
|
||||
The plan is one JSON file, written with the Write tool and loaded with `ticket-page plan`. Prose
|
||||
fields are HTML fragments: `<p>`, `<b>`, `<code>`, `<ul><li>`. `plan` replaces any earlier plan
|
||||
and clears its progress and proof, because a rewritten plan is a restart. To change a plan
|
||||
mid-build (a new step, a reworded criterion), print it with `plan-json <KEY> > plan.json`, edit
|
||||
that, and load it with `plan --amend`: steps whose number and text are unchanged, and
|
||||
criteria whose id remains, keep their progress, and proof keeps the criteria that still exist.
|
||||
|
||||
```json
|
||||
{
|
||||
"criteria": [
|
||||
{ "id": "AC1", "text": "Every company can carry a five character canonical ID.", "steps": [1, 2] },
|
||||
{ "id": "AC2", "text": "A duplicate canonical ID is rejected with a readable message.", "steps": [3] }
|
||||
],
|
||||
"questions": [],
|
||||
"approach": "<p><b>Company.canonicalId</b> is the only new state. The engagement ID is derived by <code>formatEngagementCanonicalId</code>, so the two cannot drift.</p>",
|
||||
"decisions": [["Engagement canonical ID", "Derived at read time", "Matches the spreadsheet formula, with no second column to backfill."]],
|
||||
"steps": [
|
||||
{ "n": 1, "text": "Add the canonicalId module, tests first.", "tdd": true },
|
||||
{ "n": 2, "text": "Schema, migration and backfill." },
|
||||
{ "n": 3, "text": "Map the unique violation to CONFLICT.", "tdd": true },
|
||||
{ "n": 4, "text": "Canonical ID field on the company form.", "kind": "mock" }
|
||||
],
|
||||
"risks": [
|
||||
{ "risk": "Two companies given the same code", "handling": "The unique index blocks it and step 3 returns a readable CONFLICT.", "criteria": ["AC2"], "fromTicket": true }
|
||||
],
|
||||
"tests": [
|
||||
{ "kind": "integration", "text": "companies.update with a taken code throws CONFLICT.", "criteria": ["AC2"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- Steps are numbered 1 to N in order. `kind` is `code` (the default), `mock`, or `deliverable`.
|
||||
- Every criterion names the steps that satisfy it. Risks and tests name the criteria they cover.
|
||||
- A risk or test that comes from the ticket's own table carries `"fromTicket": true`. Your own
|
||||
additions leave it off, so the page shows which rows the ticket asked for.
|
||||
- `questions` holds only what genuinely needs Gib's answer, each as a direct question. Empty
|
||||
means none.
|
||||
|
||||
## Mocks
|
||||
|
||||
When a step changes what a user sees (a new screen, a changed layout, a new control, copy a
|
||||
user reads), the plan carries mock artboards:
|
||||
|
||||
```
|
||||
ticket-page mock <KEY> <id> --title "<screen>" --criteria AC3,AC8 --note "<what the build will not do>" --file artboard.html
|
||||
```
|
||||
|
||||
- An artboard is a complete small HTML document with its own `<style>`, rendered in a sandboxed
|
||||
frame beside the proof. Draw the screen state the plan introduces, with the app's real
|
||||
navigation and density around it rather than a wireframe of boxes.
|
||||
- One artboard per new screen state. When the direction is Gib's call, draw each candidate as
|
||||
its own artboard (`option-a`, `option-b`) and ask the choice in `questions`, naming the
|
||||
artboards. This is how the house rule of several static mocks before touching real components
|
||||
is met inside the ticket flow.
|
||||
- `--criteria` names what the artboard illustrates. The Progress tab shows it under those
|
||||
criteria, and replaces the empty half with the proof once a `proof ... --mock <id>` lands.
|
||||
- `--note` states what the artboard shows that the implementation will not do.
|
||||
- A spike deliverable's polished mocks (see **Mocks and datamodel changes**) can start from the
|
||||
approved artboard. The built screens are still screenshotted for real.
|
||||
|
||||
## The epic index
|
||||
|
||||
The index lists the epic's stories in build order: the order they should be completed in, which
|
||||
is not key order and not always the Jira link graph. Each worked ticket keeps its own row current.
|
||||
The rest of the index is yours to seed and keep true.
|
||||
|
||||
- **Seed it** the first time a ticket under the epic is worked, and refresh it on later runs.
|
||||
Search the epic's children (`parent = <EPIC> ORDER BY rank`). For each, record the title, the
|
||||
estimate as `--hours`, the Story Risk Level as `--risk`, the stories it is blocked by as
|
||||
`--after`, and `--status done` if Jira says Done: `ticket-page epic <EPIC> story <KEY> ...`.
|
||||
Closed spikes go in with `epic <EPIC> closed <KEY> --title T`.
|
||||
- **Set the order** with `ticket-page epic <EPIC> order <KEY> <KEY> ...`, listing every story. If
|
||||
the epic already records a build order (a `summary.md` or plan in its folder), that order wins.
|
||||
Otherwise order by the blocked-by links, then Jira rank. Write the reasons the order is what it
|
||||
is with `epic <EPIC> why --file reasons.txt`, one reason per line.
|
||||
- **Gib reorders by asking.** When he does, rerun `order` and update `why`.
|
||||
- A story is marked done only by `--status done`, once it has merged or Jira says Done. The index
|
||||
never guesses that.
|
||||
|
||||
## Reading it as an agent
|
||||
|
||||
`ticket-page md <KEY>` prints the ticket and the plan as markdown. Use it when you resume a
|
||||
ticket, and hand it to the fresh reviewer as the ticket and plan in its reading order.
|
||||
|
||||
## Tickets from before the page
|
||||
|
||||
Tickets planned before the page existed have `resources/ticket.md` and `plan.md` and no
|
||||
`ticket.data.js`. Leave those files alone. When Phase 0 resumes such a ticket, convert it
|
||||
first: `init` from `resources/issue.raw.json`, `plan` from the plan's content, then `approve` and
|
||||
`step ... done` for each step `git log` shows committed. From then on the page is the record.
|
||||
+184
-188
@@ -1,14 +1,14 @@
|
||||
---
|
||||
name: ticket
|
||||
description: End-to-end Jira ticket workflow — fetch a ticket into .claude/docs/epics/, write a plan, implement it with clean commits, drive the pre-mr-review audit to convergence (the user runs it themselves from their work Claude account), and write the MR doc. Use when the user gives you a Jira ticket key (e.g. KACP-11111) to work, or asks to plan/implement/wrap up a ticket.
|
||||
description: End-to-end Jira ticket workflow — fetch a ticket into a live ticket page under .claude/docs/epics/, plan it, implement it with clean commits, drive the pre-mr-review audit to convergence (the user runs it themselves from their work Claude account), and write the MR doc. Use when the user gives you a Jira ticket key (e.g. KACP-11111) to work, or asks to plan/implement/wrap up a ticket.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Ticket Workflow
|
||||
|
||||
Turns a Jira ticket key into: a saved ticket record, a reviewed plan, implemented and
|
||||
committed code, a converged `pre-mr-review` audit, and an MR doc ready to paste into
|
||||
GitLab/GitHub. This skill is user-level (`~/.agents/skills/ticket/`) and works the same
|
||||
Turns a Jira ticket key into: a live ticket page carrying the ticket, a reviewed plan,
|
||||
progress, and proof, implemented and committed code, a converged `pre-mr-review` audit, and
|
||||
an MR doc ready to paste into GitLab/GitHub. This skill is user-level (`~/.agents/skills/ticket/`) and works the same
|
||||
way in any repo that has a `.claude/docs/` directory and a `pre-mr-review` skill.
|
||||
|
||||
Invoked as `/ticket KACP-11111`. The ticket key is passed as the skill's arguments. Called
|
||||
@@ -19,19 +19,33 @@ Jira story, the estimate, risk level, and testing tables, as part of authoring a
|
||||
before anyone builds it. It has nothing to do with reviewing code. The code review in
|
||||
this skill is Phase 2 step 5 and is performed by a dispatched agent, not by a skill.
|
||||
|
||||
## The ticket page
|
||||
|
||||
Every ticket gets a **ticket page**, a web page Gib keeps open while you work: the ticket as
|
||||
Jira has it, the plan with its mocks, live progress, proof beside the mocks, and the review.
|
||||
Every epic gets an **epic index** listing its stories in build order. Both render from data
|
||||
files that only `ticket-page` writes, and both update themselves as it writes.
|
||||
|
||||
**Read [SITE.md](SITE.md) before the first write of every run.** It holds the command for each
|
||||
moment the phases below name, the plan's JSON shape, how to draw mocks, and how the epic order
|
||||
is seeded. Record each moment when it happens, not in a batch at the end, because Gib is
|
||||
watching the page change.
|
||||
|
||||
## Directory layout this skill maintains
|
||||
|
||||
```
|
||||
.claude/docs/epics/
|
||||
_site/ # the shared renderer, refreshed by ticket-page
|
||||
<EPIC-KEY>/
|
||||
epic.md # brief epic context, written once, best-effort
|
||||
index.html, epic.data.js # the epic index
|
||||
<TICKET-KEY>/
|
||||
index.html, ticket.data.js # the ticket page
|
||||
resources/
|
||||
ticket.md # title, description, dev review instructions
|
||||
issue.raw.json # the fetched issue, kept for re-imports
|
||||
<attachments...> # images, videos, other files as downloaded
|
||||
<video>.transcript.txt # only for videos that got transcribed
|
||||
<video>.transcript.srt
|
||||
plan.md
|
||||
<video>.frames/
|
||||
deliverables/ # only for tickets whose output is documents, not code
|
||||
<deliverable-slug>/
|
||||
<deliverable-slug>.md
|
||||
@@ -40,15 +54,16 @@ this skill is Phase 2 step 5 and is performed by a dispatched agent, not by a sk
|
||||
proof/ # captured working feature proof, uploaded to Jira by the user
|
||||
mr.md # the MR description only, written once pre-mr-review says Ready to Open MR
|
||||
tickets/
|
||||
<TICKET-KEY>/ # same shape, for tickets with no epic
|
||||
resources/...
|
||||
plan.md
|
||||
deliverables/...
|
||||
mr.md
|
||||
<TICKET-KEY>/ # same shape, for tickets with no epic, and no index
|
||||
```
|
||||
|
||||
## Scripts this skill uses
|
||||
|
||||
- `~/.agents/skills/ticket/scripts/ticket-page <command> <KEY> ...` — the only writer of the
|
||||
ticket page and epic index. See [SITE.md](SITE.md), or `ticket-page help`. Runs under bun.
|
||||
- `~/.agents/skills/ticket/scripts/capture-proof` — sharp Playwright screenshots of the
|
||||
running app for proof and spike screenshots. `capture-proof help` lists its options. The
|
||||
first run installs a pinned Playwright into `~/.cache/ticket-page/`.
|
||||
- `~/.agents/skills/ticket/scripts/jira-fetch-issue.sh <KEY> <OUT.json>` — fetches one
|
||||
issue (`fields=*all`, rendered HTML description, field-name map) via `JIRA_BASE_URL` /
|
||||
`JIRA_CREDENTIALS`.
|
||||
@@ -99,8 +114,8 @@ rerun it, and verify the device log instead of accepting the fallback transcript
|
||||
## House style (applies to everything this skill writes or commits)
|
||||
|
||||
- No code comments unless the user explicitly asks for one in that spot.
|
||||
- No em dashes, en dashes, semicolons, or arrow glyphs in commit messages, `ticket.md`,
|
||||
`plan.md`, or `mr.md`. Plain punctuation only.
|
||||
- No em dashes, en dashes, semicolons, or arrow glyphs in commit messages, the ticket page,
|
||||
or `mr.md`. Plain punctuation only.
|
||||
- Commits are `git commit -m "<short imperative message>"` only — no body, no
|
||||
co-author line, no `Claude` / `Codex` / `AI` trailer of any kind.
|
||||
- Never `git push`. Never open an MR/PR. This skill prepares everything needed to open
|
||||
@@ -159,18 +174,18 @@ the document.** Being aligned first is the point, and the writing should read as
|
||||
you always were.
|
||||
|
||||
This governs `mr.md`, `customfield_10260` and `10261`, every other Jira field, and PM
|
||||
deliverables. It does not govern files only he reads. `plan.md`, `bugs.md`, audits and
|
||||
deliverables. It does not govern files only he reads. The ticket page, `bugs.md`, audits and
|
||||
working notes can name him and record who decided what, because that history is useful.
|
||||
|
||||
The House style punctuation rule above is the stricter one where the two overlap. Keep
|
||||
it. Personal working files (`plan.md`, `ticket.md`) are exempt, nobody else reads them.
|
||||
it. The ticket page is a personal working file and exempt, nobody else reads it.
|
||||
|
||||
## Verification tools available
|
||||
|
||||
The goal isn't "a plausible-sounding plan" or "code that compiles" — it's a plan and
|
||||
an implementation that are actually correct, checked against the real system rather
|
||||
than assumed from the ticket text. Use whichever of these actually verify the thing
|
||||
in question, before writing `plan.md` (Phase 1) and again whenever Phase 2's
|
||||
in question, before writing the plan (Phase 1) and again whenever Phase 2's
|
||||
implementation makes a claim that's checkable:
|
||||
|
||||
- **The codebase itself.** Don't take the ticket's description of "how things work
|
||||
@@ -196,7 +211,7 @@ implementation makes a claim that's checkable:
|
||||
about explicitly rather than assume the file's DB credential is fair game by default.
|
||||
|
||||
If something would need access you don't have (a different repo, a system you can't
|
||||
reach), say so explicitly in `plan.md`'s Open questions instead of silently skipping
|
||||
reach), say so explicitly in the plan's open questions instead of silently skipping
|
||||
the check.
|
||||
|
||||
**How to write what you found, in a deliverable.** State it as a plain confirmed
|
||||
@@ -210,7 +225,7 @@ codebase equally.
|
||||
|
||||
Some tickets (spikes, research, migration proposals, the KACP-22764 kind of ticket)
|
||||
don't produce code, they produce documents for a PM or another engineer to read. When
|
||||
a `plan.md` Step produces a document rather than a code change, it becomes its own
|
||||
a plan step produces a document rather than a code change, it becomes its own
|
||||
subfolder under `<target-dir>/deliverables/<deliverable-slug>/` with three files:
|
||||
|
||||
- `<slug>.md` — the source of truth for content. Write this first, get the content
|
||||
@@ -225,11 +240,10 @@ subfolder under `<target-dir>/deliverables/<deliverable-slug>/` with three files
|
||||
- `<slug>.pdf` — compiled from the `.typ` file.
|
||||
|
||||
**Write for the actual audience, not for yourself.** The reader is a PM or another
|
||||
engineer who has never seen `.claude/docs/`, doesn't know what `plan.md` or
|
||||
`ticket.md` are, and doesn't have this repo (or any other local repo) checked out.
|
||||
Never reference `.claude/docs/` or any local file path in a deliverable's content.
|
||||
Never say "see plan.md" or "see ticket.md", those are personal working files, not
|
||||
things a reader can open. Refer to "this ticket" or a plain Jira link, not a local
|
||||
engineer who has never seen `.claude/docs/` or the ticket page, and doesn't have this
|
||||
repo (or any other local repo) checked out. Never reference `.claude/docs/` or any local
|
||||
file path in a deliverable's content. Never point the reader at the ticket page or the
|
||||
plan, those are personal working files, not things a reader can open. Refer to "this ticket" or a plain Jira link, not a local
|
||||
path. If a fact came from another repo on this machine, state the fact, don't cite
|
||||
the local checkout path it came from.
|
||||
|
||||
@@ -315,7 +329,7 @@ describe it only as a quick UI mockup.
|
||||
|
||||
When a ticket calls for mocks or datamodel changes (a spike proposing new screens or a
|
||||
new schema, for instance), don't stop at wireframes or a schema proposal document.
|
||||
Build it for real, on the ticket's branch. Mark these steps `(mock)` in `plan.md`.
|
||||
Build it for real, on the ticket's branch. Give these steps `"kind": "mock"` in the plan.
|
||||
|
||||
A mock deliverable is **two artifacts**, not one, and they answer different questions.
|
||||
The code proves the thing is buildable and surfaces the problems a picture hides. The
|
||||
@@ -348,11 +362,11 @@ what was quick to wire up. Ship both, and don't let either stand in for the othe
|
||||
feature. Don't chase every edge case and don't fix unrelated pre-existing bugs you
|
||||
happen to notice along the way, just implement enough of the real thing to be
|
||||
screenshotted and evaluated. If doing it for real would take significantly longer
|
||||
than a static mock would, say so in `plan.md` rather than silently scoping it down
|
||||
than a static mock would, say so in the plan's approach rather than silently scoping it down
|
||||
without mentioning the tradeoff.
|
||||
- **Screenshot the real thing.** Once it's built and running, use the `run` skill to
|
||||
get the app up and a browser automation tool (e.g. `claude-in-chrome`) to capture
|
||||
the actual screens, instead of drawing a wireframe. Save screenshots under
|
||||
get the app up and capture the actual screens with `capture-proof`, as the proof step
|
||||
in Phase 2 describes, instead of drawing a wireframe. Save screenshots under
|
||||
`deliverables/<slug>/screenshots/` and the visual mocks under
|
||||
`deliverables/<slug>/mocks/`, and embed both in `<slug>.typ` via `image()` when
|
||||
building the mockups deliverable. Label which is which. A reader who can't tell a
|
||||
@@ -412,7 +426,7 @@ through, and the branch needs to already exist when that happens.
|
||||
|
||||
Once this resolves you're on the correct branch, and Phase 1/Phase 2 work happens
|
||||
there. `.claude/docs/` itself is gitignored in this repo, so which branch you're on
|
||||
doesn't affect `ticket.md`/`plan.md`/`mr.md` directly — this is about making sure any
|
||||
doesn't affect the ticket page or `mr.md` directly — this is about making sure any
|
||||
actual code (mockup prototypes, spike code, real implementation) lands in the right
|
||||
place from the start.
|
||||
|
||||
@@ -422,20 +436,23 @@ place from the start.
|
||||
correct key.
|
||||
2. Look for an existing directory for this ticket without hitting the network:
|
||||
`find .claude/docs/epics -mindepth 2 -maxdepth 2 -type d -name "<KEY>"`
|
||||
(this matches both `epics/<EPIC>/<KEY>` and `epics/tickets/<KEY>`).
|
||||
(this matches both `epics/<EPIC>/<KEY>` and `epics/tickets/<KEY>`). If it holds a
|
||||
`plan.md` but no `ticket.data.js`, it predates the ticket page: convert it first, as
|
||||
[SITE.md](SITE.md) describes under **Tickets from before the page**, then continue.
|
||||
`ticket-page show <KEY>` says where the ticket stands.
|
||||
3. Branch on what you find:
|
||||
- **Nothing found** — this is a fresh ticket. Go to **Phase 1**.
|
||||
- **Directory exists, no `plan.md`** — a previous run was interrupted before writing
|
||||
a plan. Go to **Phase 1** and regenerate from scratch (re-fetch, overwrite
|
||||
`ticket.md`, re-check attachments); it's idempotent and cheap.
|
||||
- **`plan.md` exists, no `mr.md`** — ask the user (AskUserQuestion) what they want:
|
||||
- **A page with no plan** — a previous run was interrupted before writing a plan. Go
|
||||
to **Phase 1** and regenerate from scratch (re-fetch, re-import, re-check
|
||||
attachments); it's idempotent and cheap.
|
||||
- **A plan, no `mr.md`** — ask the user (AskUserQuestion) what they want:
|
||||
- Re-fetch the ticket and rewrite the plan from scratch (they want to start over)
|
||||
- Proceed to implementing the existing `plan.md` as-is (they reviewed and approved it)
|
||||
- Resume implementation (some plan steps are already checked off / some commits
|
||||
already exist on the ticket branch — pick up from the first unchecked step)
|
||||
- Proceed to implementing the existing plan as-is (they reviewed and approved it)
|
||||
- Resume implementation (some steps are already done on the page / some commits
|
||||
already exist on the ticket branch — pick up from the first open step)
|
||||
- Move to the pre-mr-review stage (implementation looks done, just need the audit + MR doc; the user runs the audit from their work account, see Phase 2 step 7)
|
||||
Route to **Phase 1** or **Phase 2** accordingly.
|
||||
- **Both `plan.md` and `mr.md` exist** — this ticket looks finished. Tell the user
|
||||
- **A plan and `mr.md`** — this ticket looks finished. Tell the user
|
||||
`mr.md` already exists at its path and ask whether they want you to refresh it
|
||||
(e.g. they made more changes since) or leave it alone. Only re-enter Phase 2's
|
||||
verification/pre-mr-review loop if they say the code changed since `mr.md` was
|
||||
@@ -446,83 +463,39 @@ place from the start.
|
||||
## Phase 1 — fetch, scaffold, and plan
|
||||
|
||||
1. Do **Branch setup** above first, unconditionally.
|
||||
2. Run `jira-fetch-issue.sh <KEY> <tmp-path>` (a scratch path is fine here — you don't
|
||||
yet know the final directory). Read the resulting JSON.
|
||||
3. Determine the epic: `fields.parent.key`, if present. If absent, this ticket has no
|
||||
epic.
|
||||
4. Resolve the target directory:
|
||||
- With epic: `.claude/docs/epics/<EPIC-KEY>/<KEY>/`
|
||||
- Without epic: `.claude/docs/epics/tickets/<KEY>/`
|
||||
Create it and its `resources/` subdirectory.
|
||||
5. Move/copy the fetched JSON to `<target-dir>/resources/issue.raw.json` for your own
|
||||
reference while writing `ticket.md` — this raw file is scratch, not part of the
|
||||
deliverable; feel free to leave it (it's harmless context for later) or delete it
|
||||
once `ticket.md` is written, your call.
|
||||
6. If there's an epic and `.claude/docs/epics/<EPIC-KEY>/epic.md` doesn't already exist:
|
||||
best-effort fetch the epic issue too (`jira-fetch-issue.sh <EPIC-KEY> <tmp>`) and
|
||||
write a short `epic.md` (title + description, converted to markdown, a couple
|
||||
paragraphs at most). If this fetch fails for any reason, skip it and continue — it's
|
||||
context, not a blocker.
|
||||
7. Write `resources/ticket.md` by reading the fetched issue JSON yourself:
|
||||
- Title, type, status, priority, assignee, epic key (or "None"), and a link
|
||||
(`<JIRA_BASE_URL>/browse/<KEY>`).
|
||||
- Description: convert `renderedFields.description` (HTML) to clean markdown. If
|
||||
empty, say so.
|
||||
- Developer review instructions: the field name varies by project and isn't a fixed
|
||||
custom field ID. Look at the `names` map in the response for any field whose name
|
||||
matches something like "dev review instructions" / "review instructions"
|
||||
(case-insensitive substring match), then render that field's value the same way as
|
||||
the description. If nothing matches, write "None provided" — don't guess a field.
|
||||
- Test cases: same approach for any field whose name matches something like
|
||||
"test cases" / "test table" / "working feature proof" / "qa" (case-insensitive
|
||||
substring match), e.g. KACP's "Test Cases & Working Feature Proof" field. This
|
||||
is part of the ticket's testing section and MUST be captured into `ticket.md`
|
||||
whenever it holds anything beyond an empty template. Often it's just the bare
|
||||
instruction panel and empty table, note that and move on. But when it has real
|
||||
content, that content is part of the implementation and has to be known from
|
||||
the start: render every row verbatim as a markdown table under its own
|
||||
`## Test cases and working feature proof` heading, keeping the ticket's own
|
||||
column headers, and carry any prose or instructions in the field alongside the
|
||||
table. These are the scenarios the developer must prove, so Phase 1's plan must
|
||||
already say how each row gets proven, Phase 2's verification must actually
|
||||
prove each row, and Phase 2 step 9 fills the proof column back into the Jira
|
||||
field itself. If nothing matches, write "None provided".
|
||||
- Risk mitigation: same approach for any field whose name matches something like
|
||||
"risk mitigation" / "risk management" / "risks" (case-insensitive substring
|
||||
match), e.g. KACP's "Risk Mitigation" field. Same rule as test cases: an empty
|
||||
template gets noted, real content gets captured in full because it shapes the
|
||||
implementation from the start. Render it as a markdown table under its own
|
||||
`## Risk mitigation` heading, keeping the ticket's own column headers and
|
||||
every row verbatim. The PM and Lead Developer write the risks and mitigation
|
||||
strategies; implementing those strategies and proving each one is the developer's
|
||||
job, so every row needs a real mitigation in the code and its proof filled into
|
||||
the Jira field in Phase 2 step 9. If nothing matches, write "None provided".
|
||||
- Developer fill-in tables, in general: any ticket table with a column the developer
|
||||
is meant to complete (Working Feature Proof, Mitigation Proof, and the like) is
|
||||
copied into `ticket.md` in full, with that column left showing where its answer
|
||||
goes rather than dropped. `ticket.md` is the record of what the ticket actually
|
||||
asks for. Never substitute your own invented test cases or risks for the ticket's,
|
||||
in `ticket.md` or in the Jira fields. Your own additional cases are welcome, but
|
||||
they go alongside the ticket's rows, clearly marked as additional, never in place
|
||||
of them.
|
||||
- Sweep for anything else populated: list every key in the `names` map whose field
|
||||
actually has a non-null, non-empty value on this issue (jq over `.fields` joined
|
||||
with `.names`), and skim any populated field not already captured above. Capture
|
||||
the ones relevant to implementing or verifying the ticket; ignore workflow
|
||||
plumbing (ranks, sprints boards, reviewer assignments, rich-field duplicates).
|
||||
This exists because real content sometimes hides in per-project custom fields
|
||||
with unpredictable names — a name-pattern miss must not silently drop content.
|
||||
- Any other fields on the ticket that look clearly relevant to implementing it
|
||||
(acceptance criteria field, story points, labels, components) are worth a short
|
||||
line each; don't dump every custom field verbatim.
|
||||
8. Run `jira-download-attachments.sh <issue.raw.json> <target-dir>/resources/`. For each
|
||||
2. Run `jira-fetch-issue.sh <KEY> <tmp-path>` (a scratch path is fine here — the next
|
||||
step knows the final directory). Read the resulting JSON.
|
||||
3. Import it: `ticket-page init <KEY> --issue <tmp-path>`. This files the ticket under its
|
||||
epic (or `tickets/` without one), imports every rich-text field verbatim as sanitized
|
||||
HTML, writes the page, and prints the ticket folder. Copy the fetched JSON to
|
||||
`<target-dir>/resources/issue.raw.json`, which later re-imports and the attachment
|
||||
script read. Record the branch from **Branch setup** with `ticket-page meta`, then open
|
||||
the page with `ticket-page open <KEY>` so Gib can follow from here on.
|
||||
4. `init` ends by listing populated Jira fields the page does not show. Read each one and
|
||||
add any that bear on implementing or verifying the ticket as a note. This sweep exists
|
||||
because real content sometimes hides in per-project custom fields with unpredictable
|
||||
names, and a name-pattern miss must not silently drop it.
|
||||
5. Read what the Ticket tab now holds, and treat its tables as the ticket's contract.
|
||||
- **Test cases** (KACP's "Test Cases & Working Feature Proof") and **risk mitigation**
|
||||
are imported with every row verbatim. Often a field is just the bare instruction
|
||||
panel and an empty table, which needs no further thought. When it has real rows,
|
||||
those are the scenarios the developer must prove and the mitigations the developer
|
||||
must implement: the plan names each one (`"fromTicket": true`), Phase 2 proves each
|
||||
one, and Phase 2 step 9 fills its proof column back into the Jira field. The PM and
|
||||
Lead write the risks. Implementing and proving each is the developer's job.
|
||||
- Never substitute your own invented test cases or risks for the ticket's, in the plan
|
||||
or in the Jira fields. Your own additional cases are welcome alongside the ticket's
|
||||
rows, never in place of them.
|
||||
6. If the ticket has an epic, seed or refresh its epic index as [SITE.md](SITE.md)
|
||||
describes under **The epic index**. This is best-effort context: if the epic search
|
||||
fails, say so and continue.
|
||||
7. Run `jira-download-attachments.sh <issue.raw.json> <target-dir>/resources/`. For each
|
||||
attachment with a `video/*` mime type, check whether another attachment already looks
|
||||
like its transcript (same base filename with `.txt`/`.srt`/`.vtt`, or a filename
|
||||
containing "transcript"). Collect any videos with no matching transcript.
|
||||
9. If there are untranscribed videos, ask the user (AskUserQuestion, one question,
|
||||
8. If there are untranscribed videos, ask the user (AskUserQuestion, one question,
|
||||
multiSelect if more than one video) whether to transcribe them now. For each they
|
||||
approve, run `transcribe.sh <video-path> <target-dir>/resources/`. Note in `ticket.md`
|
||||
under Attachments which videos have a transcript and which were skipped.
|
||||
approve, run `transcribe.sh <video-path> <target-dir>/resources/`.
|
||||
For every video that gets transcribed, also extract frames — ticket videos are
|
||||
almost always screen recordings, and the transcript alone misses what was on
|
||||
screen (the UI being pointed at, the annotation, the row that's wrong). Extract to
|
||||
@@ -535,6 +508,10 @@ place from the start.
|
||||
and treat what's visible on screen as part of the ticket's content the same way
|
||||
the transcript is. Skip frame extraction only when the video is confirmed
|
||||
audio-only or the user says the visuals don't matter.
|
||||
9. Run `ticket-page scan <KEY>` so the page carries the attachments, each video with its
|
||||
transcript and frames. When a video, spreadsheet, or linked document holds the
|
||||
ticket's real requirements, write what it says as a note (`ticket-page note`), the
|
||||
way "What the video says" summarizes a recording point by point.
|
||||
10. Now actually understand the ticket in the context of this codebase: read whatever
|
||||
source files, tests, or docs are relevant to what's being asked. Use Explore/grep
|
||||
as needed — this is normal engineering research, not scripted. Use the
|
||||
@@ -549,54 +526,33 @@ place from the start.
|
||||
**architectural** is a new subsystem, a schema change other stories depend on, or
|
||||
anything that alters an interface neighbouring work relies on. Bounded is the common
|
||||
case and goes straight to step 11. For architectural, call the Skill tool with
|
||||
"grilling" and resolve its frontier with Gib BEFORE writing `plan.md` — those
|
||||
"grilling" and resolve its frontier with Gib BEFORE writing the plan — those
|
||||
questions get answered in conversation, not deferred into the plan's Open questions
|
||||
section, because an architectural plan built on a wrong assumption is the expensive
|
||||
one to discover in Phase 2. Bounded measures the repo, not your familiarity: if the
|
||||
flow being changed isn't already there to read, it isn't bounded. In doubt, take the
|
||||
heavier path.
|
||||
11. Write `plan.md` in `<target-dir>/plan.md` (a sibling of `resources/`, not inside
|
||||
it). Structure:
|
||||
```
|
||||
# Plan: <KEY> — <short title>
|
||||
heavier path. Either way, record the classification with `ticket-page meta`.
|
||||
11. Write the plan as `plan.json` in a scratch path, in the shape [SITE.md](SITE.md) gives,
|
||||
and load it with `ticket-page plan <KEY> --file plan.json`. Its parts: acceptance
|
||||
criteria restated precisely from the ticket, each naming the steps that satisfy it;
|
||||
open questions that genuinely need Gib's answer, empty if there really aren't any
|
||||
(don't manufacture questions to look thorough); the approach, meaning what you're
|
||||
going to do and why, which files, modules and functions are involved, and how it fits
|
||||
the existing patterns; decisions already settled with Gib; steps, each roughly one
|
||||
commit's worth of work, with `"kind": "deliverable"` for a step that produces a
|
||||
document via **Deliverable documents** and `"kind": "mock"` for a real schema or UI
|
||||
change built to demonstrate a mock (see **Mocks and datamodel changes**); risks and
|
||||
edge cases, each with how the plan handles it; and the test plan, meaning how each
|
||||
criterion gets verified, the existing suites to run, and any new tests.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] <criterion from the ticket, restated precisely>
|
||||
...
|
||||
|
||||
## Open questions
|
||||
<Anything genuinely blocking or ambiguous that needs the user's answer before or
|
||||
during implementation. "None" if there really aren't any — don't manufacture
|
||||
questions to look thorough.>
|
||||
|
||||
## Approach
|
||||
<Narrative: what you're going to do and why, which files/modules/functions are
|
||||
involved, how it fits the existing patterns in this codebase.>
|
||||
|
||||
## Steps
|
||||
- [ ] <step 1, roughly one commit's worth of work — mark it `(deliverable)` if it
|
||||
produces a document via the Deliverable documents process instead of code, or
|
||||
`(mock)` if it's a real schema/UI change built to demonstrate a mock rather than
|
||||
ship a finished feature, see Mocks and datamodel changes>
|
||||
- [ ] <step 2>
|
||||
...
|
||||
|
||||
## Risks and edge cases considered
|
||||
- <edge case> — <how the plan handles it>
|
||||
...
|
||||
|
||||
## Test plan
|
||||
- <how you'll verify each acceptance criterion, including existing test suites to
|
||||
run and any new tests to add>
|
||||
```
|
||||
Be thorough: address every acceptance criterion, resolve as many open questions as
|
||||
you reasonably can by reading the code first, and don't leave logic gaps. Steps
|
||||
should be concrete enough that Phase 2 can execute them without re-deriving the
|
||||
approach. The Test plan must name every test case `ticket.md` captured and say how
|
||||
that exact case gets proven, and the Risks section must name every risk
|
||||
`ticket.md` captured and say which code enforces its mitigation. Read the notes
|
||||
column of a ticket test case as part of the case, not as commentary: a note like
|
||||
"confirm the certificate matches the emailed version" is its own thing to prove.
|
||||
approach. The test plan must name every test case on the Ticket tab and say how
|
||||
that exact case gets proven, and the risks must name every risk on the Ticket tab
|
||||
and say which code enforces its mitigation. Read the notes column of a ticket test
|
||||
case as part of the case, not as commentary: a note like "confirm the certificate
|
||||
matches the emailed version" is its own thing to prove.
|
||||
|
||||
**Where the shape is in question, borrow the vocabulary.** When the plan has to
|
||||
decide how deep a module should be, where a seam belongs, or what an interface
|
||||
@@ -606,7 +562,7 @@ place from the start.
|
||||
written for real.
|
||||
|
||||
**The Test plan is a design decision, not a formality.** Beyond the cases
|
||||
`ticket.md` already captured, call the Skill tool with "tdd" to decide which
|
||||
the ticket already carries, call the Skill tool with "tdd" to decide which
|
||||
additional tests are worth writing, where the seam under test goes, and whether a
|
||||
mock is warranted. That judgment is what keeps this section focused instead of
|
||||
padded: a test plan listing one case per changed file is slop, and three tests that
|
||||
@@ -623,10 +579,14 @@ place from the start.
|
||||
step stays green because the old form still exists until the last one. Say in the
|
||||
Approach that this is what you're doing and why, since the step count looks inflated
|
||||
otherwise.
|
||||
12. Stop here. Tell the user `plan.md` is ready at its path, summarize the approach in
|
||||
a couple of sentences, and mention any open questions that need their input before
|
||||
you'd implement it. Do not start implementing in this same run — wait for them to
|
||||
review the plan (editing it directly if they want) and invoke `/ticket <KEY>` again.
|
||||
12. When a step changes what a user sees, draw its mock artboards with `ticket-page mock`,
|
||||
as [SITE.md](SITE.md) describes under **Mocks**.
|
||||
13. Stop here. Tell the user the plan is on the ticket page, give its path from
|
||||
`ticket-page path <KEY>`, summarize the approach in a couple of sentences, and mention
|
||||
any open questions that need their input before you'd implement it. Do not start
|
||||
implementing in this same run — wait for them to review the plan and invoke
|
||||
`/ticket <KEY>` again. They change the plan by asking, and you rewrite it with
|
||||
`ticket-page plan`.
|
||||
|
||||
## Phase 2 — implement, verify, and hand off
|
||||
|
||||
@@ -635,14 +595,16 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
1. Do **Branch setup** above first, unconditionally — even if Phase 1 already did this
|
||||
for the same ticket earlier, confirm you're still actually on that branch now (the
|
||||
user may have switched branches between runs).
|
||||
2. Read `plan.md` fresh — the user may have hand-edited it.
|
||||
3. Work through `plan.md`'s Steps checklist in order. For each step: implement it (a
|
||||
step marked `(deliverable)` follows the **Deliverable documents** process above
|
||||
instead of writing code; a step marked `(mock)` follows **Mocks and datamodel
|
||||
changes** — real schema/UI work, not a static wireframe), then check it off
|
||||
(`- [x]`) in `plan.md`, then make one commit for it (or a few, if the step
|
||||
naturally splits into independent units). Commit messages are short and imperative,
|
||||
describing what changed, following House style above.
|
||||
2. Read the plan fresh with `ticket-page md <KEY>`, then mark it approved with
|
||||
`ticket-page approve <KEY>`.
|
||||
3. Work through the plan's steps in order. For each step: implement it (a
|
||||
`deliverable` step follows the **Deliverable documents** process above instead of
|
||||
writing code; a `mock` step follows **Mocks and datamodel changes** — real schema/UI work, not a static wireframe), and make one commit for it (or
|
||||
a few, if the step naturally splits into independent units). Commit messages are short
|
||||
and imperative, describing what changed, following House style above. Mark the step
|
||||
running on the page when you start it and done with its commits when they exist, and
|
||||
mark each acceptance criterion met the moment it is (SITE.md has the commands). A step
|
||||
is done when `ticket-page show <KEY>` lists its commit.
|
||||
|
||||
**Write the test first for steps that carry real logic**, meaning a router
|
||||
procedure, a derivation, a permission or authorization check, a migration, or a
|
||||
@@ -654,11 +616,13 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
House style rejects. When unsure whether a step qualifies, ask whether the test could
|
||||
ever fail for a reason worth knowing about. If it couldn't, skip it.
|
||||
|
||||
If a step fails in a way `plan.md` didn't predict, and the cause isn't obvious within
|
||||
If a step fails in a way the plan didn't predict, and the cause isn't obvious within
|
||||
a couple of minutes, call the Skill tool with "diagnosing-bugs" rather than trying
|
||||
fixes to see what sticks. Note anything it turns up that changes the plan.
|
||||
fixes to see what sticks. Note anything it turns up that changes the plan as an event on
|
||||
the page. When it changes the steps, amend the plan as [SITE.md](SITE.md) describes, which
|
||||
keeps the progress already made.
|
||||
4. Do not run the full verification suite after every commit (checks-at-the-end mode).
|
||||
Once every step is implemented and checked off, find and run this project's standard
|
||||
Once every step is implemented and marked done, find and run this project's standard
|
||||
verification (check `package.json` scripts for something like `ci:check`,
|
||||
`typecheck`, `lint`, `test`, or fall back to whatever the repo's README documents).
|
||||
Fix anything broken, committing fixes as their own commits, until it's clean. If you
|
||||
@@ -694,8 +658,9 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
change cannot provide it.
|
||||
- **Give the reviewer its reading order explicitly**, or it produces noise about
|
||||
conventions the project already settled: the epic summary if there is one
|
||||
(`.claude/docs/epics/<EPIC-KEY>/summary.md`), then this ticket's `resources/` and
|
||||
`plan.md`, then `AGENTS.md`, and only then the diff. Give it the exact commit range.
|
||||
(`.claude/docs/epics/<EPIC-KEY>/summary.md`), then the ticket and plan as
|
||||
`ticket-page md <KEY>` prints them, then this ticket's `resources/`, then
|
||||
`AGENTS.md`, and only then the diff. Give it the exact commit range.
|
||||
- **Tell it to verify rather than suspect**, to try to refute its own findings before
|
||||
reporting them, to mark each finding CONFIRMED or PLAUSIBLE, and to give exact
|
||||
`file:line` plus a concrete failure scenario. Tell it explicitly not to write,
|
||||
@@ -706,12 +671,13 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
from checking.
|
||||
- **Ask the spec questions explicitly**, or the review only finds bugs in what you
|
||||
did write and never notices what you didn't. Three questions, each answered against
|
||||
`ticket.md` and `plan.md` rather than the diff alone: what the ticket asked for that
|
||||
the ticket and the plan rather than the diff alone: what the ticket asked for that
|
||||
is missing or only partly done; what behavior is in the diff that nothing asked for;
|
||||
and what looks implemented but is implemented wrongly. Have it quote the AC ID or
|
||||
the `plan.md` line each finding traces to.
|
||||
the plan step each finding traces to.
|
||||
|
||||
Then respond to the review, in writing:
|
||||
Then respond to the review, in writing, and put each finding on the page as you judge
|
||||
it: added, then fixed with its commit or rejected with the reason.
|
||||
|
||||
- **Findings are not automatically true.** Verify each one against the codebase
|
||||
before acting on it. Reviewers do produce confident wrong answers. Say plainly
|
||||
@@ -743,7 +709,7 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
skill prints the exact paths). Check the `last_reviewed_head` in the audit
|
||||
header matches the current HEAD; if the branch moved after their run, say so
|
||||
and ask them to rerun before acting on a stale audit.
|
||||
3. Read the verdict.
|
||||
3. Read the verdict, and record it on the page with `ticket-page verdict`.
|
||||
- **Ready to Open MR**: continue to step 8.
|
||||
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix
|
||||
as its own commit), then ask the user to rerun the review from the work
|
||||
@@ -769,8 +735,8 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
Make each one count:
|
||||
- **Feed it context on the first run.** It reviews the diff, and left alone it
|
||||
re-derives intent from the branch name and re-raises decisions that are
|
||||
already settled. Ask the user to point it at the ticket's
|
||||
`resources/ticket.md` and `plan.md`. This is the cheapest thing that reduces
|
||||
already settled. Ask the user to point it at the ticket's `ticket.data.js`, or give
|
||||
them the output of `ticket-page md <KEY>` to paste in. This is the cheapest thing that reduces
|
||||
repeat findings.
|
||||
- **Answer its decisions explicitly in the rerun.** When it parks a finding
|
||||
under "Decide before opening", write the decision and its reasoning into the
|
||||
@@ -794,7 +760,7 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
- New ENVs: list any env vars actually introduced; "None" if none.
|
||||
- Additional Notes: a bulleted checklist of the changes you actually made, not a
|
||||
dumping ground. Between Summary and this section, every acceptance criterion from
|
||||
`plan.md` (or, for a bug ticket, the bug itself) needs to be directly addressed,
|
||||
the plan (or, for a bug ticket, the bug itself) needs to be directly addressed,
|
||||
not just gestured at. If there's genuinely unrelated work worth mentioning (e.g.
|
||||
an incidental fix along the way), it goes after everything ticket-relevant, still
|
||||
as short bullets. Five bullet points max total, ticket-relevant and incidental
|
||||
@@ -834,7 +800,8 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
(`customfield_10260`, "For The Engineer: Patch Notes"). Step 9 writes it.
|
||||
Scattered coverage across notes does not count as having answered a named
|
||||
requirement, and neither does burying it in the MR description.
|
||||
Save this as `<target-dir>/mr.md` (next to `plan.md`, not under `resources/`).
|
||||
Save this as `<target-dir>/mr.md` (next to `index.html`, not under `resources/`), and
|
||||
record it with `ticket-page mr <KEY>`.
|
||||
|
||||
**`mr.md` is the template plus the pasted handoff, and NOTHING ELSE.** This is a
|
||||
hard shape, not a starting point. Its only headings are the template's own numbered
|
||||
@@ -855,7 +822,8 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
9. Fill the Jira ticket fields directly, by issue type. Rich text fields are ADF:
|
||||
render markdown with `python3 ~/.agents/skills/review-ticket/scripts/review2adf.py
|
||||
render <file.md>` and PUT via `{"fields": {...}}` to `/rest/api/3/issue/<KEY>`.
|
||||
After every PUT, re-fetch and verify the field landed (compare extracted text,
|
||||
After every PUT, re-fetch and verify the field landed, then record it with
|
||||
`ticket-page jira <KEY> "<field name>"` (compare extracted text,
|
||||
treating empty `attrs` objects and `localId`/`colwidth`/`width` as noise). The
|
||||
field-to-type map below is verified against the project's edit screens, don't PUT
|
||||
a field to a type that doesn't carry it.
|
||||
@@ -866,9 +834,35 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
|
||||
**Proof first.** Before filling any proof column, capture working feature proof
|
||||
yourself wherever possible: run the app (`run` skill) and screenshot the real
|
||||
feature with browser automation, or capture test output for behavior with no UI.
|
||||
feature with `capture-proof`, or capture test output for behavior with no UI.
|
||||
|
||||
**Screenshots come from Playwright, never from a browser-extension screen grab.**
|
||||
Extension captures come out soft and hard to read, and a proof screenshot exists to be
|
||||
read. `capture-proof shot` renders at twice the pixel density with animations frozen,
|
||||
fonts loaded, and dev overlays hidden:
|
||||
|
||||
```
|
||||
capture-proof shot http://localhost:3000/companies --wait-for "text=Canonical ID" --out <target-dir>/proof/companies-grid-canonical-ids.png
|
||||
```
|
||||
|
||||
- **Sign-in.** The first `shot` against an app that needs a session stops with a sign-in
|
||||
hint. Ask Gib to run `capture-proof login <origin>` himself: it opens a visible
|
||||
browser, he signs in and closes the window, and every later `shot` against that
|
||||
origin reuses the session. It is saved under `~/.cache/ticket-page/auth/`, never in a
|
||||
repo. The hint only catches a redirect to a sign-in path, so look at every capture
|
||||
before recording it.
|
||||
- **Frame the evidence.** Pass `--wait-for` with the thing the proof is about, so the
|
||||
capture never catches a loading spinner. Crop to the part that matters with
|
||||
`--selector`, or take the whole page with `--full`.
|
||||
- **Interaction first.** For state that takes clicks or typing (an open dialog, a
|
||||
form showing its warning), write a small script,
|
||||
`export default async (page) => { await page.getByLabel("Canonical ID").fill("NIKON"); }`,
|
||||
and pass it with `--script`. Keep scripts in a scratch path, not in `proof/`.
|
||||
- Read every capture with the Read tool before recording it. A crisp picture of the
|
||||
wrong screen is still wrong.
|
||||
Save artifacts under `<target-dir>/proof/` with names that match the test-case
|
||||
rows they prove. A proof cell references its artifact by filename plus "attached"
|
||||
rows they prove, and put each on the page with `ticket-page proof`, naming the
|
||||
criteria it proves and the mock it answers. A proof cell references its artifact by filename plus "attached"
|
||||
(Gib uploads every attachment, this skill NEVER uploads files to Jira) or names
|
||||
the passing test. When proof genuinely can't be captured here (needs prod, a real
|
||||
inbox, a flag flip), the cell says exactly what's needed and step 10 lists it for
|
||||
@@ -878,7 +872,8 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
**Cap the proof at five files, and aim for two or three.** Gib uploads every
|
||||
attachment by hand, so each file has a real cost and a wall of near-duplicate
|
||||
screenshots buries the two that matter. Before filling the proof column, pick the
|
||||
smallest set that actually carries the evidence and delete the rest from `proof/`.
|
||||
smallest set that actually carries the evidence, delete the rest from `proof/`, and
|
||||
drop them from the page with `ticket-page unproof`.
|
||||
|
||||
- One screenshot can prove several rows at once. A company page that shows the
|
||||
navigation, the tabs, and the absent section proves three criteria in one image,
|
||||
@@ -934,12 +929,13 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
ticket, Drive, or both, per the ticket's instructions). Fill `customfield_10260`
|
||||
only if the spike actually shipped code.
|
||||
|
||||
Before moving on, re-read `ticket.md` row by row: every test case and every risk
|
||||
the ticket carries now has its developer column filled in Jira. Leave none
|
||||
Before moving on, re-read the Ticket tab's test case and risk tables row by row: every
|
||||
test case and every risk the ticket carries now has its developer column filled in
|
||||
Jira. Leave none
|
||||
unanswered.
|
||||
10. Tell the user: implementation is committed on `<branch>`, `pre-mr-review` verdict is
|
||||
Ready to Open MR, the Jira fields are filled (name which), and `mr.md` is ready at
|
||||
its path. Then the manual list, which should only ever be:
|
||||
Ready to Open MR, the Jira fields are filled (name which), `mr.md` is ready at its
|
||||
path, and the ticket page shows the finished work beside its mocks. Then the manual list, which should only ever be:
|
||||
- push the branch, open the MR, and paste `mr.md` in as the description
|
||||
- upload the named proof or deliverable files (from `proof/` or `deliverables/`)
|
||||
to the ticket, plus anything flagged as proof only Gib can capture
|
||||
@@ -947,8 +943,8 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
|
||||
## Resuming mid-implementation
|
||||
|
||||
If Phase 0 routes here because some steps in `plan.md` are already checked off: check
|
||||
`git log` on the current branch against the plan's steps to sanity-check they actually
|
||||
If Phase 0 routes here because some steps are already done on the page: check
|
||||
`git log` on the current branch against the page's step commits to sanity-check they actually
|
||||
match reality (a checked-off step should have a corresponding commit), then continue
|
||||
from the first unchecked step. If the plan and the git history disagree, stop and ask
|
||||
from the first open step. If the plan and the git history disagree, stop and ask
|
||||
the user rather than guessing which one is right.
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Screenshots a running app for a ticket's proof with Playwright. See: capture-proof help
|
||||
# The first run installs a pinned Playwright and its Chromium into ~/.cache/ticket-page.
|
||||
set -euo pipefail
|
||||
version=1.60.0
|
||||
here="$(dirname "$(readlink -f "$0")")"
|
||||
pw="${XDG_CACHE_HOME:-$HOME/.cache}/ticket-page/playwright"
|
||||
command -v node >/dev/null 2>&1 || { echo "capture-proof: node is not on PATH" >&2; exit 127; }
|
||||
if [[ "$(node -p "try{require('$pw/node_modules/playwright/package.json').version}catch{''}" 2>/dev/null)" != "$version" ]]; then
|
||||
echo "capture-proof: installing Playwright $version into $pw" >&2
|
||||
mkdir -p "$pw"
|
||||
[[ -f "$pw/package.json" ]] || echo '{"private":true}' > "$pw/package.json"
|
||||
(cd "$pw" && npm install --silent --no-audit --no-fund "playwright@$version") >&2
|
||||
node "$pw/node_modules/playwright/cli.js" install chromium >&2
|
||||
fi
|
||||
exec node "$here/capture-proof.ts" "$@"
|
||||
@@ -0,0 +1,128 @@
|
||||
// capture-proof: sharp, repeatable screenshots of a running app for a ticket's proof/,
|
||||
// taken with Playwright instead of a screen-capture tool. Run through the `capture-proof`
|
||||
// wrapper, which installs a pinned Playwright into a cache on first use.
|
||||
//
|
||||
// capture-proof login <url> sign in once in a visible browser, save the session
|
||||
// capture-proof shot <url> --out <file.png> take a screenshot with the saved session
|
||||
//
|
||||
// Sessions live under ~/.cache/ticket-page/auth/, one per origin, readable only by you.
|
||||
// They are never written inside a repository.
|
||||
|
||||
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { Browser, BrowserContextOptions, Page } from "playwright";
|
||||
|
||||
const cache = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "ticket-page");
|
||||
const { chromium } = createRequire(join(cache, "playwright", "package.json"))("playwright") as typeof import("playwright");
|
||||
|
||||
// The Next.js dev badge and toast stacks sit over the content in dev builds.
|
||||
const ALWAYS_HIDE = ["nextjs-portal", "[data-nextjs-toast]", "[data-sonner-toaster]"];
|
||||
|
||||
class UsageError extends Error {}
|
||||
function fail(msg: string): never { throw new UsageError(msg); }
|
||||
|
||||
function authFile(url: string): string {
|
||||
const u = new URL(url);
|
||||
return join(cache, "auth", `${u.hostname}_${u.port || (u.protocol === "https:" ? "443" : "80")}.json`);
|
||||
}
|
||||
|
||||
function parse(argv: string[]) {
|
||||
const pos: string[] = [];
|
||||
const opt = new Map<string, string>();
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (!a.startsWith("--")) { pos.push(a); continue; }
|
||||
const next = argv[i + 1];
|
||||
opt.set(a.slice(2), next === undefined || next.startsWith("--") ? "true" : (i++, next));
|
||||
}
|
||||
return { pos, opt };
|
||||
}
|
||||
|
||||
/** Opens a visible browser at the app. Gib signs in and closes the window, and the session
|
||||
* is saved for every later `shot` against the same origin. */
|
||||
async function login(url: string) {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({ viewport: null });
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
console.log("Sign in in the browser window, then close it. The session is saved when the window closes.");
|
||||
await page.waitForEvent("close", { timeout: 0 });
|
||||
const file = authFile(url);
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
await context.storageState({ path: file });
|
||||
chmodSync(file, 0o600);
|
||||
await browser.close();
|
||||
console.log(`Saved the session for ${new URL(url).origin}`);
|
||||
}
|
||||
|
||||
type Actions = (page: Page) => Promise<void>;
|
||||
|
||||
async function shot(url: string, opt: Map<string, string>) {
|
||||
const out = resolve(opt.get("out") ?? fail("shot needs --out <file.png>"));
|
||||
const file = authFile(url);
|
||||
const context: BrowserContextOptions = {
|
||||
viewport: { width: Number(opt.get("width") ?? 1440), height: Number(opt.get("height") ?? 900) },
|
||||
deviceScaleFactor: Number(opt.get("scale") ?? 2),
|
||||
colorScheme: opt.has("dark") ? "dark" : "light",
|
||||
reducedMotion: "reduce",
|
||||
...(existsSync(file) && !opt.has("anonymous") ? { storageState: file } : {}),
|
||||
};
|
||||
let browser: Browser | undefined;
|
||||
try {
|
||||
browser = await chromium.launch();
|
||||
const page = await (await browser.newContext(context)).newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
if (!opt.has("anonymous") && /sign-?in|login|auth\//i.test(new URL(page.url()).pathname)) {
|
||||
fail(`${url} landed on ${page.url()}, a sign-in page. Run: capture-proof login ${new URL(url).origin}`);
|
||||
}
|
||||
const script = opt.get("script");
|
||||
if (script) {
|
||||
const mod: { default: Actions } = await import(pathToFileURL(resolve(script)).href);
|
||||
await mod.default(page);
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
// Network idle is not enough for content rendered on the client, which can still show
|
||||
// a spinner. Wait for the thing the proof is about when it is named, then settle.
|
||||
const waitFor = opt.get("wait-for");
|
||||
if (waitFor) await page.locator(waitFor).first().waitFor({ state: "visible", timeout: 20_000 });
|
||||
await page.waitForTimeout(Number(opt.get("settle") ?? 500));
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const hide = [...ALWAYS_HIDE, ...(opt.get("hide")?.split(",") ?? [])].join(", ");
|
||||
await page.addStyleTag({ content: `${hide} { visibility: hidden !important; }` });
|
||||
const selector = opt.get("selector");
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
const settings = { path: out, animations: "disabled", caret: "hide" } as const;
|
||||
if (selector) await page.locator(selector).first().screenshot(settings);
|
||||
else await page.screenshot({ ...settings, fullPage: opt.has("full") });
|
||||
console.log(out);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
}
|
||||
}
|
||||
|
||||
const HELP = `capture-proof login <url>
|
||||
Opens a visible browser at <url>. Sign in, then close the window to save the session.
|
||||
capture-proof shot <url> --out <file.png> [options]
|
||||
--selector <css> capture one element instead of the viewport
|
||||
--full capture the whole scrolling page
|
||||
--wait-for <loc> wait until this is visible first: a CSS selector, or text=Some label
|
||||
--settle <ms> pause after loading, 500 by default
|
||||
--script <file.mjs> run \`export default async (page) => {...}\` first: clicks, typing, opening a dialog
|
||||
--hide <css,css> hide more overlays, beyond the Next.js dev badge and toasts
|
||||
--width 1440 --height 900 --scale 2 viewport and pixel density
|
||||
--dark dark color scheme
|
||||
--anonymous ignore the saved session, as a signed-out user`;
|
||||
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
const { pos, opt } = parse(rest);
|
||||
try {
|
||||
if (cmd === "login") await login(pos[0] ?? fail("login needs a url"));
|
||||
else if (cmd === "shot") await shot(pos[0] ?? fail("shot needs a url"), opt);
|
||||
else { console.log(HELP); process.exit(cmd && cmd !== "help" ? 2 : 0); }
|
||||
} catch (err) {
|
||||
console.error(`capture-proof ${cmd}: ${err instanceof Error ? err.message : err}`);
|
||||
process.exit(err instanceof UsageError ? 2 : 1);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Turns a fetched Jira issue (jira-fetch-issue.sh output) and the ticket's resources/
|
||||
// directory into the source half of a Ticket: its metadata, every rich-text field as
|
||||
// sanitized HTML, and its attachments with transcripts and frames.
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
import type { Attachment, Section, Segment, Ticket } from "./ticket-data";
|
||||
import { now } from "./ticket-data";
|
||||
|
||||
type Named = { name?: string; value?: string; displayName?: string };
|
||||
interface JiraLink { type: { inward: string; outward: string }; inwardIssue?: LinkedIssue; outwardIssue?: LinkedIssue }
|
||||
interface LinkedIssue { key: string; fields?: { summary?: string } }
|
||||
|
||||
/** The parts of jira-fetch-issue.sh's response this importer reads. Every other field
|
||||
* stays `unknown` and only ever passes through `plain()`. */
|
||||
export interface JiraIssue {
|
||||
key: string;
|
||||
fields: {
|
||||
summary: string;
|
||||
issuetype?: Named; status?: Named; priority?: Named; assignee?: Named; reporter?: Named;
|
||||
timeoriginalestimate?: number | null;
|
||||
parent?: LinkedIssue;
|
||||
issuelinks?: JiraLink[];
|
||||
attachment?: { id: string | number; filename: string }[];
|
||||
[field: string]: unknown;
|
||||
};
|
||||
renderedFields: Record<string, unknown>;
|
||||
names: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Rich-text fields in the order a reader wants them. Anything populated that matches
|
||||
* none of these still gets imported, after them, so a field with an unexpected name
|
||||
* is never dropped. */
|
||||
const FIELD_ORDER = [/^description$/i, /acceptance criteria/i, /review instructions/i, /test cases|working feature proof/i, /risk mitigation$|^risk mitigation/i];
|
||||
|
||||
/** Strips anything executable and rewrites attachment links to the downloaded copies,
|
||||
* so the page renders Jira's HTML offline without running any of it. */
|
||||
export function sanitize(html: string, attachments: Map<string, string>): string {
|
||||
return html
|
||||
.replace(/<(script|style|iframe|object|embed)\b[\s\S]*?<\/\1\s*>/gi, "")
|
||||
.replace(/<(script|style|iframe|object|embed|link|meta)\b[^>]*\/?>/gi, "")
|
||||
.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "")
|
||||
.replace(/(href|src)\s*=\s*(["'])\s*javascript:[^"']*\2/gi, '$1="#"')
|
||||
.replace(/<img\b[^>]*class="icon"[^>]*>/gi, "")
|
||||
.replace(/https?:\/\/[^"'\s]+\/(?:rest\/api\/\d\/attachment\/content|secure\/attachment)\/(\d+)(?:\/[^"'\s]*)?/g,
|
||||
(url, id: string) => attachments.get(id) ?? url)
|
||||
.replace(/<a\s+(?=[^>]*href="https?:)/gi, '<a target="_blank" rel="noreferrer" ');
|
||||
}
|
||||
|
||||
/** Workflow bookkeeping, matched against both field ids and display names. */
|
||||
const PLUMBING = /^(created|updated|last ?viewed|status ?category|watches|votes|progress|aggregate|work ?ratio|creator|project|time ?tracking|worklog|comment|subtasks|security|environment|resolution|due ?date|rank|epic link|development|children count|remaining estimate|time spent|sprint|flagged)/i;
|
||||
|
||||
const hours = (seconds?: number | null) => (seconds ? `${+(seconds / 3600).toFixed(1)}h` : undefined);
|
||||
|
||||
/** A plain value for the details table, or null when the field is empty or plumbing. */
|
||||
function plain(value: unknown): string | null {
|
||||
if (value == null || value === "" || (Array.isArray(value) && !value.length)) return null;
|
||||
if (typeof value === "string" || typeof value === "number") return String(value);
|
||||
if (Array.isArray(value)) return value.map(plain).filter(Boolean).join(", ") || null;
|
||||
if (typeof value === "object") { const v = value as Named; return v.displayName ?? v.value ?? v.name ?? null; }
|
||||
return null;
|
||||
}
|
||||
|
||||
export function importIssue(issue: JiraIssue, baseUrl: string): Pick<Ticket, "key" | "title" | "type" | "status" | "priority" | "estimate" | "assignee" | "epic" | "jira" | "links" | "source"> & { unplaced: string[] } {
|
||||
const f = issue.fields;
|
||||
const files = new Map((f.attachment ?? []).map(a => [String(a.id), `resources/${a.filename}`]));
|
||||
const rich = Object.entries(issue.renderedFields)
|
||||
.filter((e): e is [string, string] => typeof e[1] === "string" && /<[a-z]/i.test(e[1]))
|
||||
.map(([id, html]) => ({ id, title: issue.names[id] ?? id, html }));
|
||||
const rank = (title: string) => { const i = FIELD_ORDER.findIndex(r => r.test(title)); return i < 0 ? FIELD_ORDER.length : i; };
|
||||
rich.sort((a, b) => rank(a.title) - rank(b.title));
|
||||
const sections: Section[] = rich.map(r => ({ id: r.id, title: r.title, html: sanitize(r.html, files), origin: "jira" }));
|
||||
|
||||
const shown: [string, string | undefined | null][] = [
|
||||
["Type", f.issuetype?.name], ["Status", f.status?.name], ["Priority", f.priority?.name],
|
||||
["Assignee", f.assignee?.displayName], ["Reporter", f.reporter?.displayName],
|
||||
["Estimate", hours(f.timeoriginalestimate)], ["Labels", plain(f.labels)], ["Components", plain(f.components)],
|
||||
["Fix versions", plain(f.fixVersions)], ["Story points", plain(f.customfield_10016)],
|
||||
];
|
||||
const fields = shown.filter((x): x is [string, string] => !!x[1]);
|
||||
|
||||
// Populated plain fields the page does not show, named for the agent's sweep.
|
||||
const known = new Set(["summary", "issuetype", "status", "priority", "assignee", "reporter", "timeoriginalestimate", "labels",
|
||||
"components", "fixVersions", "customfield_10016", "parent", "issuelinks", "attachment", "description", ...rich.map(r => r.id)]);
|
||||
const unplaced = Object.entries(f)
|
||||
.filter(([k, v]) => !known.has(k) && plain(v) && !PLUMBING.test(k) && !PLUMBING.test(issue.names[k] ?? ""))
|
||||
.map(([k, v]) => `${issue.names[k] ?? k}: ${String(plain(v)).slice(0, 80)}`);
|
||||
|
||||
const links = (f.issuelinks ?? []).flatMap(l => {
|
||||
const other = l.outwardIssue ?? l.inwardIssue;
|
||||
return other ? [{ type: l.outwardIssue ? l.type.outward : l.type.inward, key: other.key, title: other.fields?.summary ?? "" }] : [];
|
||||
});
|
||||
|
||||
return {
|
||||
key: issue.key,
|
||||
title: f.summary,
|
||||
type: f.issuetype?.name ?? "Issue",
|
||||
status: f.status?.name ?? "",
|
||||
priority: f.priority?.name ?? "",
|
||||
estimate: hours(f.timeoriginalestimate),
|
||||
assignee: f.assignee?.displayName,
|
||||
epic: f.parent ? { key: f.parent.key, title: f.parent.fields?.summary ?? "" } : null,
|
||||
jira: `${baseUrl.replace(/\/$/, "")}/browse/${issue.key}`,
|
||||
links,
|
||||
source: { importedAt: now(), fields, sections },
|
||||
unplaced,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSrt(srt: string): Segment[] {
|
||||
const secs = (t: string) => { const [h, m, s] = t.replace(",", ".").split(":"); return Math.round((+h * 3600 + +m * 60 + +s) * 10) / 10; };
|
||||
return srt.trim().split(/\r?\n\s*\r?\n/).flatMap(block => {
|
||||
const lines = block.trim().split(/\r?\n/);
|
||||
const time = lines.find(l => l.includes("-->"));
|
||||
if (!time) return [];
|
||||
const text = lines.slice(lines.indexOf(time) + 1).join(" ").trim();
|
||||
return text.length > 1 ? [{ t: secs(time.split("-->")[0].trim()), text }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
const KIND: Record<string, Attachment["kind"]> = {
|
||||
".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "image",
|
||||
".mp4": "video", ".webm": "video", ".mov": "video", ".mkv": "video",
|
||||
".mp3": "audio", ".m4a": "audio", ".wav": "audio", ".ogg": "audio",
|
||||
};
|
||||
|
||||
/** Every file in resources/, with each video's transcript and frames attached to it. */
|
||||
export function scanResources(ticketDir: string): Attachment[] {
|
||||
const dir = join(ticketDir, "resources");
|
||||
let names: string[];
|
||||
try { names = readdirSync(dir); } catch { return []; }
|
||||
const skip = (n: string) => n === "issue.raw.json" || /\.transcript\.(txt|srt|vtt)$/.test(n) || n.endsWith(".frames") || n.startsWith(".");
|
||||
return names.filter(n => !skip(n) && statSync(join(dir, n)).isFile()).sort().map(name => {
|
||||
const kind = KIND[extname(name).toLowerCase()] ?? "file";
|
||||
const base = name.slice(0, -extname(name).length);
|
||||
const att: Attachment = { name, path: `resources/${name}`, kind, bytes: statSync(join(dir, name)).size };
|
||||
if (kind === "video" || kind === "audio") {
|
||||
try { att.transcript = parseSrt(readFileSync(join(dir, `${base}.transcript.srt`), "utf8")); } catch {}
|
||||
try { att.frames = readdirSync(join(dir, `${base}.frames`)).filter(n => /\.(png|jpe?g)$/i.test(n)).sort().map(n => `resources/${base}.frames/${n}`); } catch {}
|
||||
}
|
||||
return att;
|
||||
});
|
||||
}
|
||||
|
||||
/** Readable text from Jira-ish HTML, for the markdown export an agent reads. */
|
||||
export function htmlToText(html: string): string {
|
||||
return html
|
||||
.replace(/<(br|\/p|\/div|\/h\d|\/li|\/tr)\b[^>]*>/gi, "\n")
|
||||
.replace(/<\/t[dh]>/gi, " | ")
|
||||
.replace(/<li\b[^>]*>/gi, "- ")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// The ticket page's data model: one `ticket.data.js` per ticket and one `epic.data.js`
|
||||
// per epic, each a single `window.X = <strict JSON>;` assignment so a page opened from
|
||||
// file:// can load it with a <script> tag. Everything that reads or writes those files
|
||||
// goes through this module, so the shape is defined once and validated on every write.
|
||||
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export type StepStatus = "todo" | "running" | "done";
|
||||
export type StepKind = "code" | "mock" | "deliverable";
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
title: string;
|
||||
html: string;
|
||||
/** "jira" is imported verbatim from the ticket; "note" is written by the agent. */
|
||||
origin: "jira" | "note";
|
||||
}
|
||||
|
||||
export interface Segment { t: number; text: string }
|
||||
|
||||
export interface Attachment {
|
||||
name: string;
|
||||
/** Relative to the ticket directory. */
|
||||
path: string;
|
||||
kind: "image" | "video" | "audio" | "file";
|
||||
bytes: number;
|
||||
transcript?: Segment[];
|
||||
frames?: string[];
|
||||
}
|
||||
|
||||
export interface Criterion {
|
||||
id: string;
|
||||
text: string;
|
||||
steps: number[];
|
||||
status: "todo" | "done";
|
||||
doneAt?: string;
|
||||
evidence?: string;
|
||||
}
|
||||
|
||||
export interface Step {
|
||||
n: number;
|
||||
text: string;
|
||||
kind: StepKind;
|
||||
tdd?: boolean;
|
||||
status: StepStatus;
|
||||
commits: string[];
|
||||
doneAt?: string;
|
||||
}
|
||||
|
||||
export interface Risk { risk: string; handling: string; criteria?: string[]; fromTicket?: boolean }
|
||||
export interface Test { text: string; kind: string; criteria?: string[]; fromTicket?: boolean }
|
||||
/** A design artboard drawn at plan time. `criteria` names what it illustrates, so the
|
||||
* Progress tab can show it beside the proof for those criteria. */
|
||||
export interface Mock { id: string; title: string; note: string; html: string; criteria: string[] }
|
||||
|
||||
export interface Plan {
|
||||
writtenAt: string;
|
||||
approvedAt?: string;
|
||||
criteria: Criterion[];
|
||||
/** HTML fragments. Empty means none. */
|
||||
questions: string[];
|
||||
/** HTML. */
|
||||
approach: string;
|
||||
decisions: [question: string, choice: string, why: string][];
|
||||
steps: Step[];
|
||||
risks: Risk[];
|
||||
tests: Test[];
|
||||
mocks: Mock[];
|
||||
}
|
||||
|
||||
export interface Proof {
|
||||
kind: "image" | "text" | "test";
|
||||
/** Relative to the ticket directory, for image and text proof. */
|
||||
file?: string;
|
||||
/** Embedded content of a text proof, because file:// pages cannot fetch it. */
|
||||
text?: string;
|
||||
/** The passing test's name, for test proof. */
|
||||
test?: string;
|
||||
proves: string[];
|
||||
caption: string;
|
||||
mock?: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
id: string;
|
||||
severity: "CONFIRMED" | "PLAUSIBLE";
|
||||
text: string;
|
||||
where?: string;
|
||||
status: "open" | "fixed" | "rejected";
|
||||
commit?: string;
|
||||
reason?: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface Event { at: string; kind: string; text: string; commit?: string }
|
||||
|
||||
export interface Ticket {
|
||||
schema: 1;
|
||||
key: string;
|
||||
title: string;
|
||||
type: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
estimate?: string;
|
||||
assignee?: string;
|
||||
epic: { key: string; title: string } | null;
|
||||
jira: string;
|
||||
branch?: string;
|
||||
classified?: "bounded" | "architectural";
|
||||
links: { type: string; key: string; title: string }[];
|
||||
source: { importedAt: string; fields: [string, string][]; sections: Section[] };
|
||||
attachments: Attachment[];
|
||||
plan: Plan | null;
|
||||
proof: Proof[];
|
||||
review: {
|
||||
findings: Finding[];
|
||||
preMr: { verdict: string; recommendation?: string; head?: string; runs: number; at: string } | null;
|
||||
};
|
||||
jiraFields: { name: string; at: string }[];
|
||||
mr: { at: string } | null;
|
||||
events: Event[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type StoryStatus = "todo" | "planned" | "building" | "verifying" | "ready" | "done";
|
||||
|
||||
export interface Story {
|
||||
key: string;
|
||||
title: string;
|
||||
hours?: number;
|
||||
risk?: string;
|
||||
after: string[];
|
||||
status: StoryStatus;
|
||||
/** Set once a ticket page exists for the story, so the index can link to it. */
|
||||
page?: boolean;
|
||||
progress?: { criteria: [number, number]; steps: [number, number] };
|
||||
}
|
||||
|
||||
export interface Epic {
|
||||
schema: 1;
|
||||
key: string;
|
||||
title: string;
|
||||
jira: string;
|
||||
/** Why the build order is what it is, one reason per entry. */
|
||||
why: string[];
|
||||
/** In build order. */
|
||||
stories: Story[];
|
||||
closed: { key: string; title: string }[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const now = () => new Date().toISOString();
|
||||
|
||||
const GLOBAL = { ticket: "TICKET", epic: "EPIC" } as const;
|
||||
|
||||
export function readData(file: string, kind: "ticket"): Ticket;
|
||||
export function readData(file: string, kind: "epic"): Epic;
|
||||
export function readData(file: string, kind: keyof typeof GLOBAL): Ticket | Epic {
|
||||
const raw = readFileSync(file, "utf8");
|
||||
const prefix = `window.${GLOBAL[kind]} = `;
|
||||
if (!raw.startsWith(prefix)) throw new Error(`${file} does not start with ${prefix.trim()}`);
|
||||
return JSON.parse(raw.slice(prefix.length).trim().replace(/;$/, ""));
|
||||
}
|
||||
|
||||
/** Validates, then writes through a temp file and a rename so a page reloading
|
||||
* the file mid-write never sees half of it. */
|
||||
export function writeData(file: string, kind: "ticket", data: Ticket): void;
|
||||
export function writeData(file: string, kind: "epic", data: Epic): void;
|
||||
export function writeData(file: string, kind: keyof typeof GLOBAL, data: Ticket | Epic) {
|
||||
const problems = kind === "ticket" ? validateTicket(data as Ticket) : validateEpic(data as Epic);
|
||||
if (problems.length) throw new Error(`refusing to write ${file}:\n - ${problems.join("\n - ")}`);
|
||||
data.updatedAt = now();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
const tmp = join(dirname(file), `.${Date.now()}.${process.pid}.tmp`);
|
||||
writeFileSync(tmp, `window.${GLOBAL[kind]} = ${JSON.stringify(data, null, 1)};\n`);
|
||||
renameSync(tmp, file);
|
||||
}
|
||||
|
||||
|
||||
const isStr = (x: unknown): x is string => typeof x === "string";
|
||||
|
||||
export function validateTicket(t: Ticket): string[] {
|
||||
const p: string[] = [];
|
||||
if (t.schema !== 1) p.push("schema must be 1");
|
||||
for (const k of ["key", "title", "type", "status", "jira"] as const) if (!isStr(t[k]) || !t[k]) p.push(`${k} is required`);
|
||||
if (!Array.isArray(t.events)) p.push("events must be a list");
|
||||
if (!t.plan) return p;
|
||||
const plan = t.plan;
|
||||
const ids = new Set<string>();
|
||||
plan.criteria.forEach((c, i) => {
|
||||
if (!c.id || !c.text) p.push(`criterion ${i + 1} needs an id and text`);
|
||||
if (ids.has(c.id)) p.push(`criterion id ${c.id} is used twice`);
|
||||
ids.add(c.id);
|
||||
for (const n of c.steps) if (!plan.steps.some(s => s.n === n)) p.push(`${c.id} names step ${n}, which does not exist`);
|
||||
});
|
||||
plan.steps.forEach((s, i) => {
|
||||
if (s.n !== i + 1) p.push(`steps must be numbered 1..${plan.steps.length} in order, found ${s.n} at position ${i + 1}`);
|
||||
if (!["code", "mock", "deliverable"].includes(s.kind)) p.push(`step ${s.n} kind must be code, mock or deliverable`);
|
||||
if (!["todo", "running", "done"].includes(s.status)) p.push(`step ${s.n} status must be todo, running or done`);
|
||||
});
|
||||
const mockIds = new Set(plan.mocks.map(m => m.id));
|
||||
for (const m of plan.mocks) for (const c of m.criteria) if (!ids.has(c)) p.push(`mock ${m.id} names unknown criterion ${c}`);
|
||||
for (const r of plan.risks) for (const c of r.criteria ?? []) if (!ids.has(c)) p.push(`risk "${r.risk}" names unknown criterion ${c}`);
|
||||
for (const x of plan.tests) for (const c of x.criteria ?? []) if (!ids.has(c)) p.push(`test "${x.text}" names unknown criterion ${c}`);
|
||||
for (const pr of t.proof) {
|
||||
for (const c of pr.proves) if (!ids.has(c)) p.push(`proof ${pr.file ?? pr.test} names unknown criterion ${c}`);
|
||||
if (pr.mock && !mockIds.has(pr.mock)) p.push(`proof ${pr.file} names unknown mock ${pr.mock}`);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export function validateEpic(e: Epic): string[] {
|
||||
const p: string[] = [];
|
||||
if (e.schema !== 1) p.push("schema must be 1");
|
||||
if (!e.key || !e.title) p.push("key and title are required");
|
||||
const keys = new Set<string>();
|
||||
for (const s of e.stories) {
|
||||
if (keys.has(s.key)) p.push(`${s.key} appears twice in the order`);
|
||||
keys.add(s.key);
|
||||
}
|
||||
for (const s of e.stories) for (const a of s.after) if (!keys.has(a)) p.push(`${s.key} waits on ${a}, which is not in the epic`);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Where a ticket stands, derived from its data rather than stored, so the page,
|
||||
* the epic index and `show` can never disagree. */
|
||||
export function storyStatus(t: Ticket): StoryStatus {
|
||||
if (!t.plan) return "todo";
|
||||
if (t.review.preMr?.verdict === "Ready to Open MR") return "ready";
|
||||
if (!t.plan.approvedAt) return "planned";
|
||||
return t.plan.steps.every(s => s.status === "done") ? "verifying" : "building";
|
||||
}
|
||||
|
||||
export function progress(t: Ticket): Story["progress"] {
|
||||
const pl = t.plan;
|
||||
if (!pl) return { criteria: [0, 0], steps: [0, 0] };
|
||||
return {
|
||||
criteria: [pl.criteria.filter(c => c.status === "done").length, pl.criteria.length],
|
||||
steps: [pl.steps.filter(s => s.status === "done").length, pl.steps.length],
|
||||
};
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the ticket page CLI under bun. See ../SITE.md, or: ticket-page help
|
||||
set -euo pipefail
|
||||
here="$(dirname "$(readlink -f "$0")")"
|
||||
command -v bun >/dev/null 2>&1 || { echo "ticket-page: bun is not on PATH" >&2; exit 127; }
|
||||
exec bun "$here/ticket-page.ts" "$@"
|
||||
@@ -0,0 +1,116 @@
|
||||
// Run with: bun test (from this directory)
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { importIssue, type JiraIssue, parseSrt, sanitize } from "./jira-import";
|
||||
import { readData, type Ticket, writeData } from "./ticket-data";
|
||||
|
||||
const issue = (over: Partial<JiraIssue["fields"]> = {}, rendered: Record<string, string> = {}): JiraIssue => ({
|
||||
key: "KACP-1",
|
||||
fields: { summary: "Add canonical IDs", issuetype: { name: "Story" }, status: { name: "To Do" }, priority: { name: "Medium" },
|
||||
parent: { key: "KACP-9", fields: { summary: "Engagement epic" } }, attachment: [{ id: "42830", filename: "shot.png" }], ...over },
|
||||
renderedFields: { description: "<p>Do the thing</p>", ...rendered },
|
||||
names: { description: "Description", customfield_1: "Risk Mitigation", customfield_2: "Some Oddly Named Field", customfield_3: "Developer Review Instructions" },
|
||||
});
|
||||
|
||||
describe("sanitize", () => {
|
||||
test("drops anything executable and points attachments at the downloaded copy", () => {
|
||||
const out = sanitize(
|
||||
`<p onclick="x()">hi</p><script>alert(1)</script><a href="javascript:evil()">x</a>` +
|
||||
`<img src="https://ksense-tech.atlassian.net/rest/api/3/attachment/content/42830" alt="shot.png">`,
|
||||
new Map([["42830", "resources/shot.png"]]));
|
||||
expect(out).not.toMatch(/onclick|<script|javascript:/i);
|
||||
expect(out).toContain('src="resources/shot.png"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("importIssue", () => {
|
||||
test("keeps every rich field, reader order first and unexpected names after", () => {
|
||||
const t = importIssue(issue({}, { customfield_2: "<p>odd</p>", customfield_1: "<table><tr><td>r</td></tr></table>", customfield_3: "<p>dev</p>" }), "https://j");
|
||||
expect(t.source.sections.map(s => s.title)).toEqual(["Description", "Developer Review Instructions", "Risk Mitigation", "Some Oddly Named Field"]);
|
||||
expect(t.epic).toEqual({ key: "KACP-9", title: "Engagement epic" });
|
||||
});
|
||||
});
|
||||
|
||||
test("parseSrt skips cues with no words", () => {
|
||||
const segs = parseSrt("1\n00:00:01,500 --> 00:00:03,000\n Hello there\n\n2\n00:00:03,000 --> 00:00:04,000\n I\n\n3\n00:01:02,000 --> 00:01:05,000\nBye");
|
||||
expect(segs).toEqual([{ t: 1.5, text: "Hello there" }, { t: 62, text: "Bye" }]);
|
||||
});
|
||||
|
||||
test("writeData refuses a plan that names a missing step and leaves the file as it was", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tp-"));
|
||||
const file = join(dir, "ticket.data.js");
|
||||
const base = { schema: 1, key: "KACP-1", title: "t", type: "Story", status: "To Do", priority: "", epic: null, jira: "j", links: [],
|
||||
source: { importedAt: "", fields: [], sections: [] }, attachments: [], plan: null, proof: [], review: { findings: [], preMr: null },
|
||||
jiraFields: [], mr: null, events: [], updatedAt: "" } satisfies Ticket;
|
||||
writeData(file, "ticket", base);
|
||||
const before = readFileSync(file, "utf8");
|
||||
const broken: Ticket = { ...base, plan: { writtenAt: "", criteria: [{ id: "AC1", text: "x", steps: [2], status: "todo" }], questions: [], approach: "",
|
||||
decisions: [], steps: [{ n: 1, text: "s", kind: "code", status: "todo", commits: [] }], risks: [], tests: [], mocks: [] } };
|
||||
expect(() => writeData(file, "ticket", broken)).toThrow(/AC1 names step 2/);
|
||||
expect(readFileSync(file, "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
describe("the CLI, end to end", () => {
|
||||
const docs = mkdtempSync(join(tmpdir(), "tp-docs-"));
|
||||
const cli = (...args: string[]) => spawnSync("bun", [join(import.meta.dir, "ticket-page.ts"), ...args], { encoding: "utf8", env: { ...process.env, TICKET_DOCS: docs } });
|
||||
const scratch = mkdtempSync(join(tmpdir(), "tp-in-"));
|
||||
const issuePath = join(scratch, "issue.json");
|
||||
writeFileSync(issuePath, JSON.stringify(issue()));
|
||||
const dir = join(docs, "KACP-9", "KACP-1");
|
||||
const ticket = () => readData(join(dir, "ticket.data.js"), "ticket");
|
||||
const epic = () => readData(join(docs, "KACP-9", "epic.data.js"), "epic");
|
||||
|
||||
test("init files the ticket under its epic and writes both pages", () => {
|
||||
expect(cli("init", "KACP-1", "--issue", issuePath).status).toBe(0);
|
||||
for (const f of ["index.html", "ticket.data.js"]) expect(existsSync(join(dir, f))).toBe(true);
|
||||
expect(existsSync(join(docs, "KACP-9", "index.html"))).toBe(true);
|
||||
expect(readFileSync(join(dir, "index.html"), "utf8")).toMatch(/href="..\/..\/_site\/site.css\?v=[0-9a-f]{10}"/);
|
||||
expect(epic().stories.map(s => [s.key, s.status])).toEqual([["KACP-1", "todo"]]);
|
||||
});
|
||||
|
||||
test("a step is done only once its commit exists, and the epic row follows", () => {
|
||||
const plan = join(scratch, "plan.json");
|
||||
writeFileSync(plan, JSON.stringify({ approach: "<p>a</p>", criteria: [{ id: "AC1", text: "c", steps: [1] }], steps: [{ n: 1, text: "s" }, { n: 2, text: "t" }] }));
|
||||
expect(cli("plan", "KACP-1", "--file", plan).status).toBe(0);
|
||||
expect(epic().stories[0].status).toBe("planned");
|
||||
cli("approve", "KACP-1");
|
||||
const refused = cli("step", "KACP-1", "1", "done");
|
||||
expect(refused.status).toBe(2);
|
||||
expect(refused.stderr).toMatch(/--commit/);
|
||||
expect(cli("step", "KACP-1", "1", "done", "--commit", "abc1234").status).toBe(0);
|
||||
expect(ticket().plan!.steps[0]).toMatchObject({ status: "done", commits: ["abc1234"] });
|
||||
expect(epic().stories[0]).toMatchObject({ status: "building", progress: { steps: [1, 2] } });
|
||||
});
|
||||
|
||||
test("text proof is embedded, because a file:// page cannot fetch it", () => {
|
||||
mkdirSync(join(dir, "proof"), { recursive: true });
|
||||
writeFileSync(join(dir, "proof", "counts.txt"), "70 of 70 codes stored");
|
||||
expect(cli("proof", "KACP-1", "--file", "proof/counts.txt", "--proves", "AC1", "--caption", "Counts").status).toBe(0);
|
||||
expect(ticket().proof[0]).toMatchObject({ kind: "text", text: "70 of 70 codes stored" });
|
||||
expect(cli("proof", "KACP-1", "--file", "proof/counts.txt", "--proves", "AC7", "--caption", "x").status).toBe(1);
|
||||
});
|
||||
|
||||
test("--amend keeps progress for the steps that remain, a rewrite starts over", () => {
|
||||
const plan = join(scratch, "plan2.json");
|
||||
const exported = JSON.parse(cli("plan-json", "KACP-1").stdout);
|
||||
exported.steps.push({ n: 3, text: "a review fix" });
|
||||
writeFileSync(plan, JSON.stringify(exported));
|
||||
expect(cli("plan", "KACP-1", "--file", plan, "--amend").status).toBe(0);
|
||||
expect(ticket().plan!.steps.map(s => s.status)).toEqual(["done", "todo", "todo"]);
|
||||
expect(ticket().plan!.approvedAt).toBeDefined();
|
||||
expect(ticket().proof).toHaveLength(1);
|
||||
expect(cli("plan", "KACP-1", "--file", plan).status).toBe(0);
|
||||
expect(ticket().plan!.steps.every(s => s.status === "todo")).toBe(true);
|
||||
expect(ticket().proof).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("re-importing keeps the plan unless --reset", () => {
|
||||
cli("init", "KACP-1", "--issue", issuePath);
|
||||
expect(ticket().plan).not.toBeNull();
|
||||
cli("init", "KACP-1", "--issue", issuePath, "--reset");
|
||||
expect(ticket().plan).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
// ticket-page: the only writer of a ticket's page data. See ../SITE.md for when the
|
||||
// ticket skill calls each command. Run `ticket-page help` for the command list.
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import";
|
||||
import {
|
||||
type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step,
|
||||
storyStatus, type Ticket, writeData,
|
||||
} from "./ticket-data";
|
||||
|
||||
const SITE_TEMPLATES = resolve(import.meta.dir, "../templates/site");
|
||||
|
||||
class UsageError extends Error {}
|
||||
function fail(msg: string): never { throw new UsageError(msg); }
|
||||
|
||||
/** --flag value pairs and bare --switches after the positional arguments. */
|
||||
function parse(argv: string[]) {
|
||||
const pos: string[] = [];
|
||||
const opt = new Map<string, string[]>();
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (!a.startsWith("--")) { pos.push(a); continue; }
|
||||
const name = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
const value = next === undefined || next.startsWith("--") ? "true" : (i++, next);
|
||||
opt.set(name, [...(opt.get(name) ?? []), value]);
|
||||
}
|
||||
return { pos, one: (k: string) => opt.get(k)?.at(-1), all: (k: string) => opt.get(k) ?? [], has: (k: string) => opt.has(k) };
|
||||
}
|
||||
const list = (s?: string) => (s ? s.split(",").map(x => x.trim()).filter(Boolean) : []);
|
||||
|
||||
// ── Where things live ────────────────────────────────────────────────────────
|
||||
|
||||
export function docsRoot(): string {
|
||||
if (process.env.TICKET_DOCS) return resolve(process.env.TICKET_DOCS);
|
||||
const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
||||
if (git.status !== 0) fail("not inside a git repository, and TICKET_DOCS is not set");
|
||||
return join(git.stdout.trim(), ".claude/docs/epics");
|
||||
}
|
||||
|
||||
function ticketDir(key: string): string {
|
||||
const root = docsRoot();
|
||||
const hit = existsSync(root) && readdirSync(root).map(d => join(root, d, key)).find(d => existsSync(join(d, "ticket.data.js")));
|
||||
return hit || fail(`no ticket page for ${key} under ${root}. Run: ticket-page init ${key} --issue <issue.raw.json>`);
|
||||
}
|
||||
const ticketFile = (dir: string) => join(dir, "ticket.data.js");
|
||||
const epicFile = (epicKey: string) => join(docsRoot(), epicKey, "epic.data.js");
|
||||
|
||||
/** Copies the shared renderer into _site/ when the skill's copy changed, and writes the
|
||||
* page shell. Run on every write so an old ticket picks up the current renderer. */
|
||||
function refreshSite(pageDir: string, kind: "ticket" | "epic", title: string) {
|
||||
const site = join(docsRoot(), "_site");
|
||||
mkdirSync(site, { recursive: true });
|
||||
const version = createHash("sha1");
|
||||
for (const f of ["site.css", "site.js"]) {
|
||||
const from = join(SITE_TEMPLATES, f), to = join(site, f);
|
||||
const body = readFileSync(from, "utf8");
|
||||
version.update(body);
|
||||
if (!existsSync(to) || readFileSync(to, "utf8") !== body) copyFileSync(from, to);
|
||||
}
|
||||
// The shell names the renderer by content hash, so a browser that cached an older
|
||||
// renderer loads the new one the next time the page opens.
|
||||
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8")
|
||||
.replaceAll("{{SITE}}", relative(pageDir, site) || ".")
|
||||
.replaceAll("{{VERSION}}", version.digest("hex").slice(0, 10))
|
||||
.replaceAll("{{TITLE}}", title.replace(/[<&]/g, c => (c === "<" ? "<" : "&")));
|
||||
const page = join(pageDir, "index.html");
|
||||
if (!existsSync(page) || readFileSync(page, "utf8") !== shell) writeFileSync(page, shell);
|
||||
}
|
||||
|
||||
// ── Ticket reads and writes ──────────────────────────────────────────────────
|
||||
|
||||
function load(key: string) {
|
||||
const dir = ticketDir(key);
|
||||
return { dir, t: readData(ticketFile(dir), "ticket") };
|
||||
}
|
||||
|
||||
function save(dir: string, t: Ticket) {
|
||||
writeData(ticketFile(dir), "ticket", t);
|
||||
refreshSite(dir, "ticket", `${t.key}: ${t.title}`);
|
||||
if (t.epic) syncStory(t);
|
||||
}
|
||||
|
||||
function log(t: Ticket, kind: string, text: string, commit?: string) {
|
||||
t.events.push({ at: now(), kind, text, ...(commit ? { commit } : {}) });
|
||||
}
|
||||
|
||||
/** Keeps this ticket's row on its epic index current. Creates the epic page if the
|
||||
* ticket is the first one worked under it. A story marked done stays done. */
|
||||
function syncStory(t: Ticket) {
|
||||
const epic = t.epic!;
|
||||
const file = epicFile(epic.key);
|
||||
const e: Epic = existsSync(file) ? readData(file, "epic")
|
||||
: { schema: 1, key: epic.key, title: epic.title, jira: t.jira.replace(t.key, epic.key), why: [], stories: [], closed: [], updatedAt: now() };
|
||||
let s = e.stories.find(x => x.key === t.key);
|
||||
if (!s) { s = { key: t.key, title: t.title, after: [], status: "todo" }; e.stories.push(s); }
|
||||
s.title = t.title;
|
||||
s.page = true;
|
||||
s.progress = progress(t);
|
||||
if (s.status !== "done") s.status = storyStatus(t);
|
||||
writeData(file, "epic", e);
|
||||
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
|
||||
}
|
||||
|
||||
// ── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Args = ReturnType<typeof parse>;
|
||||
const commands: Record<string, { usage: string; run: (a: Args) => void }> = {};
|
||||
const command = (name: string, usage: string, run: (a: Args) => void) => { commands[name] = { usage, run }; };
|
||||
|
||||
command("init", "init <KEY> --issue <issue.raw.json> [--reset] import the ticket, keeping any plan unless --reset", a => {
|
||||
const [key] = a.pos;
|
||||
const issuePath = a.one("issue") ?? fail("init needs --issue <issue.raw.json>");
|
||||
const issue: JiraIssue = JSON.parse(readFileSync(issuePath, "utf8"));
|
||||
if (issue.key !== key) fail(`${issuePath} holds ${issue.key}, not ${key}`);
|
||||
const imported = importIssue(issue, process.env.JIRA_BASE_URL ?? "https://ksense-tech.atlassian.net");
|
||||
const { unplaced, ...meta } = imported;
|
||||
const dir = join(docsRoot(), meta.epic?.key ?? "tickets", key);
|
||||
mkdirSync(join(dir, "resources"), { recursive: true });
|
||||
const prior = existsSync(ticketFile(dir)) ? readData(ticketFile(dir), "ticket") : null;
|
||||
const keep = prior && !a.has("reset") ? prior : null;
|
||||
const t: Ticket = {
|
||||
schema: 1, ...meta,
|
||||
branch: keep?.branch, classified: keep?.classified,
|
||||
// Notes the agent wrote survive a re-import. Jira's own sections are replaced.
|
||||
source: { ...meta.source, sections: [...meta.source.sections, ...(keep?.source.sections.filter(s => s.origin === "note") ?? [])] },
|
||||
attachments: scanResources(dir),
|
||||
plan: keep?.plan ?? null, proof: keep?.proof ?? [], review: keep?.review ?? { findings: [], preMr: null },
|
||||
jiraFields: keep?.jiraFields ?? [], mr: keep?.mr ?? null, events: keep?.events ?? [], updatedAt: now(),
|
||||
};
|
||||
log(t, "fetch", prior ? (keep ? "Re-imported the ticket from Jira, plan kept" : "Re-imported the ticket from Jira, plan cleared") : "Imported the ticket from Jira");
|
||||
save(dir, t);
|
||||
console.log(dir);
|
||||
if (unplaced.length) console.log(`\nPopulated fields not on the page. Add any that matter with \`ticket-page note\`:\n ${unplaced.join("\n ")}`);
|
||||
});
|
||||
|
||||
command("scan", "scan <KEY> re-read resources/ after downloads, transcripts or frames change", a => {
|
||||
const { dir, t } = load(a.pos[0]);
|
||||
t.attachments = scanResources(dir);
|
||||
const withTranscript = t.attachments.filter(x => x.transcript?.length).length;
|
||||
log(t, "fetch", `Scanned ${t.attachments.length} attachments, ${withTranscript} with a transcript`);
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("meta", "meta <KEY> [--branch B] [--classified bounded|architectural]", a => {
|
||||
const { dir, t } = load(a.pos[0]);
|
||||
if (a.has("branch")) t.branch = a.one("branch");
|
||||
const c = a.one("classified");
|
||||
if (c) t.classified = c === "bounded" || c === "architectural" ? c : fail("--classified is bounded or architectural");
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("note", "note <KEY> <title> --file <notes.html> add or replace an agent-written section on the Ticket tab", a => {
|
||||
const [key, title] = a.pos;
|
||||
if (!title) fail("note needs a title");
|
||||
const { dir, t } = load(key);
|
||||
const html = readFileSync(a.one("file") ?? fail("note needs --file"), "utf8");
|
||||
const id = "note-" + title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||
t.source.sections = [...t.source.sections.filter(s => s.id !== id), { id, title, html, origin: "note" }];
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("plan", "plan <KEY> --file <plan.json> [--amend] write the plan. A rewrite resets progress and proof; --amend keeps both for the steps and criteria that remain", a => {
|
||||
const { dir, t } = load(a.pos[0]);
|
||||
type StepIn = Omit<Step, "status" | "commits" | "doneAt"> & Partial<Pick<Step, "kind">>;
|
||||
type CritIn = Omit<Criterion, "status" | "doneAt" | "evidence">;
|
||||
const input = JSON.parse(readFileSync(a.one("file") ?? fail("plan needs --file"), "utf8")) as
|
||||
Partial<Omit<Plan, "steps" | "criteria">> & { steps: StepIn[]; criteria: CritIn[] };
|
||||
const prior = t.plan;
|
||||
const amend = a.has("amend");
|
||||
if (amend && !prior) fail("--amend needs an existing plan");
|
||||
const was = <T,>(list: T[] | undefined, match: (x: T) => boolean) => (amend ? list?.find(match) : undefined);
|
||||
const plan: Plan = {
|
||||
writtenAt: now(),
|
||||
...(amend && prior?.approvedAt ? { approvedAt: prior.approvedAt } : {}),
|
||||
criteria: (input.criteria ?? fail("plan.json needs criteria")).map(c => {
|
||||
const old = was(prior?.criteria, x => x.id === c.id);
|
||||
return { ...c, steps: c.steps ?? [], status: old?.status ?? "todo", ...(old?.doneAt ? { doneAt: old.doneAt } : {}), ...(old?.evidence ? { evidence: old.evidence } : {}) };
|
||||
}),
|
||||
questions: input.questions ?? [],
|
||||
approach: input.approach ?? "",
|
||||
decisions: input.decisions ?? [],
|
||||
steps: (input.steps ?? fail("plan.json needs steps")).map(s => {
|
||||
const old = was(prior?.steps, x => x.n === s.n && x.text === s.text);
|
||||
return { ...s, kind: s.kind ?? "code", status: old?.status ?? "todo", commits: old?.commits ?? [], ...(old?.doneAt ? { doneAt: old.doneAt } : {}) };
|
||||
}),
|
||||
risks: input.risks ?? [],
|
||||
tests: input.tests ?? [],
|
||||
mocks: (input.mocks ?? prior?.mocks ?? []).map(m => ({ ...m, criteria: m.criteria ?? [] })),
|
||||
};
|
||||
t.plan = plan;
|
||||
const ids = new Set(plan.criteria.map(c => c.id));
|
||||
t.proof = amend ? t.proof.map(p => ({ ...p, proves: p.proves.filter(c => ids.has(c)) })).filter(p => p.proves.length) : [];
|
||||
log(t, "plan", amend ? `Amended the plan: ${plan.criteria.length} criteria, ${plan.steps.length} steps`
|
||||
: `Wrote the plan: ${plan.criteria.length} criteria, ${plan.steps.length} steps${t.classified ? `, classified ${t.classified}` : ""}`);
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("plan-json", "plan-json <KEY> print the current plan as plan.json, to edit and reload with plan --amend", a => {
|
||||
const pl = load(a.pos[0]).t.plan ?? fail("there is no plan yet");
|
||||
console.log(JSON.stringify({
|
||||
criteria: pl.criteria.map(({ id, text, steps }) => ({ id, text, steps })),
|
||||
questions: pl.questions, approach: pl.approach, decisions: pl.decisions,
|
||||
steps: pl.steps.map(({ n, text, kind, tdd }) => ({ n, text, kind, ...(tdd ? { tdd } : {}) })),
|
||||
risks: pl.risks, tests: pl.tests,
|
||||
}, null, 2));
|
||||
});
|
||||
|
||||
command("mock", "mock <KEY> <id> --title T --criteria AC1,AC2 --file <artboard.html> [--note N] add or replace a mock artboard", a => {
|
||||
const [key, id] = a.pos;
|
||||
const { dir, t } = load(key);
|
||||
if (!t.plan) fail("write the plan before its mocks");
|
||||
const m: Mock = { id: id ?? fail("mock needs an id"), title: a.one("title") ?? fail("mock needs --title"), note: a.one("note") ?? "", html: readFileSync(a.one("file") ?? fail("mock needs --file"), "utf8"),
|
||||
criteria: list(a.one("criteria")).length ? list(a.one("criteria")) : fail("mock needs --criteria, the criteria it illustrates") };
|
||||
t.plan!.mocks = [...t.plan!.mocks.filter(x => x.id !== id), m];
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("approve", "approve <KEY> mark the plan approved; Phase 2 starts", a => {
|
||||
const { dir, t } = load(a.pos[0]);
|
||||
if (!t.plan) fail("there is no plan to approve");
|
||||
t.plan!.approvedAt = now();
|
||||
log(t, "approve", "Plan approved");
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("step", "step <KEY> <n> running|done|todo [--commit SHA]... done needs at least one commit, or --no-commit for a step with none", a => {
|
||||
const [key, n, status] = a.pos;
|
||||
const { dir, t } = load(key);
|
||||
const step = t.plan?.steps.find(s => s.n === Number(n)) ?? fail(`no step ${n}`);
|
||||
if (status !== "running" && status !== "done" && status !== "todo") fail("status is running, done or todo");
|
||||
const commits = a.all("commit");
|
||||
if (status === "done" && !commits.length && !step.commits.length && !a.has("no-commit")) fail(`step ${n} is done only once its commit exists: pass --commit <sha>`);
|
||||
if (status === "running") t.plan!.steps.forEach(s => { if (s.status === "running") s.status = "todo"; });
|
||||
step.status = status;
|
||||
step.commits = [...new Set([...step.commits, ...commits])];
|
||||
step.doneAt = status === "done" ? now() : undefined;
|
||||
if (status === "running") log(t, "start", `Started step ${n}: ${step.text}`);
|
||||
if (status === "done") log(t, "commit", `Finished step ${n}`, commits.at(-1));
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("criterion", "criterion <KEY> <id> done|todo [--evidence TEXT]", a => {
|
||||
const [key, id, status] = a.pos;
|
||||
const { dir, t } = load(key);
|
||||
const c = t.plan?.criteria.find(x => x.id === id) ?? fail(`no criterion ${id}`);
|
||||
if (status !== "done" && status !== "todo") fail("status is done or todo");
|
||||
c.status = status;
|
||||
c.doneAt = status === "done" ? now() : undefined;
|
||||
if (a.has("evidence")) c.evidence = a.one("evidence");
|
||||
if (status === "done") log(t, "criterion", `Met ${id}${c.evidence ? `: ${c.evidence}` : ""}`);
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("proof", "proof <KEY> (--file proof/<name> | --test NAME) --proves AC1,AC2 --caption TEXT [--mock ID]", a => {
|
||||
const { dir, t } = load(a.pos[0]);
|
||||
const file = a.one("file"), test = a.one("test");
|
||||
if (!file === !test) fail("proof takes exactly one of --file or --test");
|
||||
const proves = list(a.one("proves"));
|
||||
if (!proves.length) fail("proof needs --proves");
|
||||
const caption = a.one("caption") ?? fail("proof needs --caption");
|
||||
if (file && !existsSync(join(dir, file))) fail(`${file} does not exist in ${dir}`);
|
||||
const isText = !!file && /\.(txt|log|md|json|csv|diff)$/i.test(file);
|
||||
t.proof = t.proof.filter(p => (file ? p.file !== file : p.test !== test));
|
||||
t.proof.push({
|
||||
kind: test ? "test" : isText ? "text" : "image", ...(file ? { file } : { test }),
|
||||
...(isText ? { text: readFileSync(join(dir, file!), "utf8") } : {}),
|
||||
proves, caption, ...(a.one("mock") ? { mock: a.one("mock") } : {}), at: now(),
|
||||
});
|
||||
log(t, "proof", `Captured proof for ${proves.join(", ")}: ${caption}`);
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("unproof", "unproof <KEY> <file-or-test> drop a proof entry, for files cut from proof/", a => {
|
||||
const [key, what] = a.pos;
|
||||
const { dir, t } = load(key);
|
||||
t.proof = t.proof.filter(p => p.file !== what && p.test !== what);
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("finding", "finding <KEY> add TEXT --severity CONFIRMED|PLAUSIBLE [--where file:line] | finding <KEY> <id> fixed --commit SHA | rejected --reason TEXT", a => {
|
||||
const [key, idOrAdd, arg] = a.pos;
|
||||
const { dir, t } = load(key);
|
||||
if (idOrAdd === "add") {
|
||||
const severity = a.one("severity");
|
||||
if (severity !== "CONFIRMED" && severity !== "PLAUSIBLE") fail("--severity is CONFIRMED or PLAUSIBLE");
|
||||
const f: Finding = { id: `F${t.review.findings.length + 1}`, severity, text: arg ?? fail("finding add needs its text"), status: "open", at: now(), ...(a.one("where") ? { where: a.one("where") } : {}) };
|
||||
t.review.findings.push(f);
|
||||
log(t, "review", `Review finding ${f.id}: ${f.text}`);
|
||||
console.log(f.id);
|
||||
} else {
|
||||
const f = t.review.findings.find(x => x.id === idOrAdd) ?? fail(`no finding ${idOrAdd}`);
|
||||
if (arg === "fixed") { f.status = "fixed"; f.commit = a.one("commit") ?? fail("fixed needs --commit"); log(t, "fix", `Fixed ${f.id}: ${f.text}`, f.commit); }
|
||||
else if (arg === "rejected") { f.status = "rejected"; f.reason = a.one("reason") ?? fail("rejected needs --reason"); log(t, "review", `Rejected ${f.id}: ${f.reason}`); }
|
||||
else fail("finding <id> takes fixed or rejected");
|
||||
f.at = now();
|
||||
}
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("verdict", "verdict <KEY> <verdict> [--recommendation R] [--head SHA] record a pre-mr-review run", a => {
|
||||
const [key, ...words] = a.pos;
|
||||
const { dir, t } = load(key);
|
||||
const verdict = words.join(" ") || fail("verdict needs the verdict text");
|
||||
t.review.preMr = { verdict, recommendation: a.one("recommendation"), head: a.one("head"), runs: (t.review.preMr?.runs ?? 0) + 1, at: now() };
|
||||
log(t, "verdict", `pre-mr-review: ${verdict}`);
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("jira", "jira <KEY> <field name> record a Jira field as filled", a => {
|
||||
const [key, ...name] = a.pos;
|
||||
const { dir, t } = load(key);
|
||||
const field = name.join(" ") || fail("jira needs the field name");
|
||||
t.jiraFields = [...t.jiraFields.filter(f => f.name !== field), { name: field, at: now() }];
|
||||
log(t, "jira", `Filled ${field} in Jira`);
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("mr", "mr <KEY> record that mr.md is written", a => {
|
||||
const { dir, t } = load(a.pos[0]);
|
||||
t.mr = { at: now() };
|
||||
log(t, "mr", "Wrote mr.md");
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("event", "event <KEY> TEXT [--kind K] [--commit SHA] add a line to the live log", a => {
|
||||
const [key, ...text] = a.pos;
|
||||
const { dir, t } = load(key);
|
||||
log(t, a.one("kind") ?? "note", text.join(" ") || fail("event needs text"), a.one("commit"));
|
||||
save(dir, t);
|
||||
});
|
||||
|
||||
command("show", "show <KEY> print where the ticket stands", a => {
|
||||
const { dir, t } = load(a.pos[0]);
|
||||
const p = progress(t)!;
|
||||
console.log(`${t.key} ${storyStatus(t)} criteria ${p.criteria.join("/")} steps ${p.steps.join("/")} proof ${t.proof.length}\n${join(dir, "index.html")}`);
|
||||
for (const s of t.plan?.steps ?? []) console.log(` ${s.status === "done" ? "x" : s.status === "running" ? ">" : " "} ${s.n}. ${s.text}${s.commits.length ? ` ${s.commits.join(" ")}` : ""}`);
|
||||
});
|
||||
|
||||
command("path", "path <KEY> print the page path", a => console.log(join(ticketDir(a.pos[0]), "index.html")));
|
||||
|
||||
command("open", "open <KEY> open the page in the browser", a => {
|
||||
spawnSync("xdg-open", [join(ticketDir(a.pos[0]), "index.html")], { stdio: "ignore" });
|
||||
});
|
||||
|
||||
command("md", "md <KEY> print the ticket and plan as markdown, for an agent's reading order", a => {
|
||||
const { t } = load(a.pos[0]);
|
||||
const out: string[] = [`# ${t.key}: ${t.title}`, "", `${t.type}, ${t.status}. ${t.jira}`, ""];
|
||||
for (const [k, v] of t.source.fields) out.push(`- ${k}: ${v}`);
|
||||
for (const s of t.source.sections) out.push("", `## ${s.title}${s.origin === "note" ? " (notes)" : ""}`, "", htmlToText(s.html));
|
||||
const pl = t.plan;
|
||||
if (pl) {
|
||||
out.push("", "# Plan", "", "## Acceptance criteria");
|
||||
for (const c of pl.criteria) out.push(`- [${c.status === "done" ? "x" : " "}] ${c.id}: ${c.text} (steps ${c.steps.join(", ")})`);
|
||||
out.push("", "## Open questions", ...(pl.questions.length ? pl.questions.map(q => `- ${htmlToText(q)}`) : ["None"]));
|
||||
out.push("", "## Approach", "", htmlToText(pl.approach));
|
||||
if (pl.decisions.length) out.push("", "## Decisions", ...pl.decisions.map(([q, c, w]) => `- ${q}: ${c}. ${w}`));
|
||||
out.push("", "## Steps", ...pl.steps.map(s => `- [${s.status === "done" ? "x" : " "}] ${s.n}. ${s.text}${s.kind !== "code" ? ` (${s.kind})` : ""}${s.commits.length ? ` ${s.commits.join(" ")}` : ""}`));
|
||||
out.push("", "## Risks", ...pl.risks.map(r => `- ${r.risk}: ${r.handling}${r.fromTicket ? " (from the ticket)" : ""}`));
|
||||
out.push("", "## Test plan", ...pl.tests.map(x => `- ${x.kind}: ${x.text}${x.fromTicket ? " (from the ticket)" : ""}`));
|
||||
}
|
||||
if (t.proof.length) out.push("", "# Proof", ...t.proof.map(p => `- ${p.file ?? p.test}: ${p.caption} (proves ${p.proves.join(", ")})`));
|
||||
console.log(out.join("\n"));
|
||||
});
|
||||
|
||||
command("check", "check <KEY> validate the data file", a => {
|
||||
const { t } = load(a.pos[0]);
|
||||
console.log(`${t.key} is valid`);
|
||||
});
|
||||
|
||||
// Epic commands work on the epic's own data file.
|
||||
function withEpic(key: string, fn: (e: Epic) => void) {
|
||||
const file = epicFile(key);
|
||||
const e = existsSync(file) ? readData(file, "epic") : fail(`no epic page for ${key}. Run: ticket-page epic ${key} init --title T`);
|
||||
fn(e);
|
||||
writeData(file, "epic", e);
|
||||
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
|
||||
}
|
||||
|
||||
command("epic", "epic <EPIC> init --title T [--jira URL] | story <KEY> [--title T --hours H --risk R --after K1,K2 --status S] | order <KEY>... | why --file <reasons.txt> | closed <KEY> --title T", a => {
|
||||
const [key, sub, ...rest] = a.pos;
|
||||
if (sub === "init") {
|
||||
const file = epicFile(key);
|
||||
const e: Epic = existsSync(file) ? readData(file, "epic") : { schema: 1, key, title: "", jira: "", why: [], stories: [], closed: [], updatedAt: now() };
|
||||
e.title = a.one("title") ?? (e.title || fail("epic init needs --title"));
|
||||
e.jira = a.one("jira") ?? (e.jira || `${process.env.JIRA_BASE_URL ?? "https://ksense-tech.atlassian.net"}/browse/${key}`);
|
||||
writeData(file, "epic", e);
|
||||
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
|
||||
return console.log(join(dirname(file), "index.html"));
|
||||
}
|
||||
withEpic(key, e => {
|
||||
if (sub === "story") {
|
||||
const [storyKey] = rest;
|
||||
let s = e.stories.find(x => x.key === storyKey);
|
||||
if (!s) { s = { key: storyKey ?? fail("story needs a key"), title: "", after: [], status: "todo" }; e.stories.push(s); }
|
||||
if (a.has("title")) s.title = a.one("title")!;
|
||||
if (a.has("hours")) s.hours = Number(a.one("hours"));
|
||||
if (a.has("risk")) s.risk = a.one("risk");
|
||||
if (a.has("after")) s.after = list(a.one("after"));
|
||||
const st = a.one("status");
|
||||
if (st) s.status = (["todo", "planned", "building", "verifying", "ready", "done"] as const).find(x => x === st) ?? fail("unknown status");
|
||||
if (!s.title) fail(`${s.key} needs --title`);
|
||||
} else if (sub === "order") {
|
||||
const missing = e.stories.filter(s => !rest.includes(s.key)).map(s => s.key);
|
||||
if (missing.length || rest.length !== e.stories.length) fail(`order must list every story exactly once. Missing: ${missing.join(", ") || "none"}`);
|
||||
e.stories = rest.map(k => e.stories.find(s => s.key === k) ?? fail(`${k} is not in the epic`));
|
||||
} else if (sub === "why") {
|
||||
e.why = readFileSync(a.one("file") ?? fail("why needs --file"), "utf8").split("\n").map(x => x.trim()).filter(Boolean);
|
||||
} else if (sub === "closed") {
|
||||
const [k] = rest;
|
||||
e.closed = [...e.closed.filter(c => c.key !== k), { key: k ?? fail("closed needs a key"), title: a.one("title") ?? fail("closed needs --title") }];
|
||||
e.stories = e.stories.filter(s => s.key !== k);
|
||||
} else fail("epic takes init, story, order, why or closed");
|
||||
});
|
||||
});
|
||||
|
||||
function help() {
|
||||
console.log("ticket-page <command> ...\n");
|
||||
for (const c of Object.values(commands)) console.log(" " + c.usage);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const [name, ...rest] = process.argv.slice(2);
|
||||
if (!name || name === "help" || !commands[name]) { help(); process.exit(name && name !== "help" ? 2 : 0); }
|
||||
try {
|
||||
const args = parse(rest);
|
||||
if (name !== "epic" && !/^[A-Z][A-Z0-9]+-\d+$/.test(args.pos[0] ?? "")) fail(`${name} needs a ticket key like KACP-12345 first`);
|
||||
commands[name].run(args);
|
||||
} catch (err) {
|
||||
console.error(`ticket-page ${name}: ${err instanceof Error ? err.message : err}`);
|
||||
process.exit(err instanceof UsageError ? 2 : 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{TITLE}}</title>
|
||||
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}">
|
||||
</head>
|
||||
<body data-page="epic" data-src="epic.data.js">
|
||||
<div id="app"><p class="loading">Loading epic.data.js…</p></div>
|
||||
<script src="{{SITE}}/site.js?v={{VERSION}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
/* Shared stylesheet for ticket and epic pages. Copied into .claude/docs/epics/_site/ by ticket-page. */
|
||||
:root {
|
||||
--bg: #f6f6f3; --surface: #ffffff; --sunk: #efefea; --ink: #1c1c1a; --muted: #6b6b66;
|
||||
--line: #e2e2dd; --accent: #2f5fd0; --accent-ink: #fff; --accent-soft: #e8eeff;
|
||||
--ok: #1f7a3d; --ok-soft: #e4f4e8; --warn: #8a6100; --warn-soft: #fff4d6;
|
||||
--run: #2f5fd0; --run-soft: #e8eeff; --todo: #9b9a93;
|
||||
--mono: ui-monospace, "JetBrains Mono", "Cascadia Mono", Menlo, monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Inter", sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #141513; --surface: #1d1e1b; --sunk: #181917; --ink: #ecebe6; --muted: #9b9a93;
|
||||
--line: #2d2e2a; --accent: #86a4f3; --accent-ink: #0f1420; --accent-soft: #1f2940;
|
||||
--ok: #6fcf8e; --ok-soft: #173222; --warn: #e6c36b; --warn-soft: #2e2713;
|
||||
--run: #86a4f3; --run-soft: #1f2940; --todo: #6b6b66;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #141513; --surface: #1d1e1b; --sunk: #181917; --ink: #ecebe6; --muted: #9b9a93;
|
||||
--line: #2d2e2a; --accent: #86a4f3; --accent-ink: #0f1420; --accent-soft: #1f2940;
|
||||
--ok: #6fcf8e; --ok-soft: #173222; --warn: #e6c36b; --warn-soft: #2e2713;
|
||||
--run: #86a4f3; --run-soft: #1f2940; --todo: #6b6b66;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.55 var(--sans); }
|
||||
a { color: var(--accent); }
|
||||
code, .mono { font-family: var(--mono); font-size: .9em; }
|
||||
h1, h2, h3 { line-height: 1.25; }
|
||||
h2 { font-size: 1.1em; margin: 0 0 12px; }
|
||||
h3 { font-size: .95em; margin: 18px 0 8px; }
|
||||
p { margin: 0 0 10px; max-width: 74ch; }
|
||||
ul { margin: 0; padding-left: 20px; }
|
||||
li { margin: 0 0 6px; max-width: 74ch; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: .92em; }
|
||||
th, td { text-align: left; vertical-align: top; padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
||||
th { font-size: .78em; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 600; }
|
||||
.muted { color: var(--muted); }
|
||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px; }
|
||||
.stack > * + * { margin-top: 14px; }
|
||||
|
||||
/* Status marks: done by the agent, running, not started. */
|
||||
.mark { display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 50%;
|
||||
flex: none; font-size: 11px; font-weight: 700; border: 1.5px solid var(--todo); color: transparent; }
|
||||
.mark.done { background: var(--ok); border-color: var(--ok); color: var(--surface); }
|
||||
.mark.run { border-color: var(--run); background: var(--run-soft); }
|
||||
.mark.done::after { content: "✓"; }
|
||||
.pill { display: inline-block; font-size: .75em; font-weight: 600; padding: 2px 9px; border-radius: 99px;
|
||||
background: var(--sunk); color: var(--muted); white-space: nowrap; }
|
||||
.pill.ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.pill.run { background: var(--run-soft); color: var(--run); }
|
||||
.pill.warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.bar { height: 6px; border-radius: 99px; background: var(--sunk); overflow: hidden; }
|
||||
.bar > i { display: block; height: 100%; background: var(--ok); border-radius: 99px; transition: width .3s; }
|
||||
.sha { font-family: var(--mono); font-size: .82em; color: var(--muted); }
|
||||
|
||||
/* The review tick is the viewer's own, separate from the agent's done mark. */
|
||||
.seen { font-size: .78em; color: var(--muted); display: inline-flex; gap: 5px; align-items: center; cursor: pointer; user-select: none; }
|
||||
.seen input { margin: 0; }
|
||||
|
||||
|
||||
/* Video with a transcript that seeks the player. */
|
||||
.video { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 14px; }
|
||||
.video video { width: 100%; border-radius: 8px; background: #000; display: block; }
|
||||
.transcript { max-height: 340px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); font-size: .88em; }
|
||||
.transcript button { display: flex; gap: 10px; width: 100%; text-align: left; font: inherit; color: inherit; background: none; border: 0;
|
||||
border-bottom: 1px solid var(--line); padding: 6px 10px; cursor: pointer; }
|
||||
.transcript button:hover { background: var(--sunk); }
|
||||
.transcript button.now { background: var(--accent-soft); }
|
||||
.transcript .t { font-family: var(--mono); color: var(--muted); flex: none; }
|
||||
.frames { display: flex; gap: 8px; margin-top: 10px; overflow-x: auto; }
|
||||
.frames img { height: 64px; border-radius: 5px; border: 1px solid var(--line); cursor: zoom-in; }
|
||||
@media (max-width: 860px) { .video { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Mock artboard and proof, side by side. */
|
||||
.compare { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.compare figure { margin: 0; }
|
||||
.compare figcaption { font-size: .78em; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 6px; }
|
||||
.artboard { width: 100%; height: 300px; border: 1px solid var(--line); border-radius: 8px; background: #fff; display: block; }
|
||||
.shot { width: 100%; border: 1px solid var(--line); border-radius: 8px; display: block; background: var(--sunk); }
|
||||
.placeholder { height: 300px; border: 1.5px dashed var(--line); border-radius: 8px; display: grid; place-items: center; color: var(--muted); font-size: .9em; text-align: center; padding: 20px; }
|
||||
pre.textproof { margin: 0; max-height: 300px; overflow: auto; font-family: var(--mono); font-size: .8em; background: var(--surface);
|
||||
border: 1px solid var(--line); border-radius: 8px; padding: 12px; }
|
||||
@media (max-width: 860px) { .compare { grid-template-columns: 1fr; } }
|
||||
|
||||
.timeline { list-style: none; padding: 0; margin: 0; position: relative; }
|
||||
.timeline li { display: grid; grid-template-columns: 74px 18px 1fr; gap: 10px; align-items: start; margin: 0 0 10px; max-width: none; }
|
||||
.timeline time { font-family: var(--mono); font-size: .8em; color: var(--muted); padding-top: 1px; white-space: nowrap; }
|
||||
.timeline .dot { width: 9px; height: 9px; border-radius: 50%; margin: 6px auto 0; background: var(--todo); }
|
||||
.timeline .dot.commit { background: var(--ok); } .timeline .dot.fix { background: var(--warn); }
|
||||
.timeline .dot.verdict, .timeline .dot.approve { background: var(--accent); }
|
||||
|
||||
/* Page chrome */
|
||||
.loading, .error { max-width: 720px; margin: 60px auto; padding: 0 16px; color: var(--muted); }
|
||||
.error { color: var(--warn); }
|
||||
.top { background: var(--surface); border-bottom: 1px solid var(--line); }
|
||||
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px; }
|
||||
.head { padding: 22px 0 14px; display: grid; grid-template-columns: 1fr 300px; gap: 24px; align-items: end; }
|
||||
.head h1 { margin: 4px 0 8px; font-size: 1.55em; }
|
||||
.crumbs { font-size: .85em; color: var(--muted); }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: .86em; color: var(--muted); align-items: center; }
|
||||
.live { font-size: .8em; color: var(--muted); }
|
||||
.live.stale { color: var(--warn); }
|
||||
.meters { display: grid; gap: 10px; font-size: .84em; }
|
||||
.meters .row { display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; }
|
||||
.meters .bar { grid-column: 1 / -1; }
|
||||
.tabs { display: flex; gap: 2px; overflow-x: auto; }
|
||||
.tabs a { padding: 10px 14px; text-decoration: none; color: var(--muted); border-bottom: 2px solid transparent; white-space: nowrap; font-size: .93em; }
|
||||
.tabs a[aria-current] { color: var(--ink); border-color: var(--accent); font-weight: 600; }
|
||||
.tabs .n { margin-left: 6px; font-size: .78em; }
|
||||
main.wrap { padding-top: 24px; padding-bottom: 80px; }
|
||||
.grid2 { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
.check { display: flex; gap: 10px; align-items: flex-start; padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||
.check:last-child { border-bottom: 0; }
|
||||
.check .body { flex: 1; min-width: 0; }
|
||||
.check .sub { font-size: .82em; color: var(--muted); margin-top: 2px; }
|
||||
.callout { background: var(--warn-soft); border: 1px solid var(--warn); border-radius: 8px; padding: 10px 14px; }
|
||||
.empty { color: var(--muted); padding: 30px 0; text-align: center; }
|
||||
.kv td:first-child { color: var(--muted); width: 140px; }
|
||||
|
||||
/* Jira's own HTML, sanitized at import. */
|
||||
.jira { overflow-x: auto; }
|
||||
.jira img { max-width: 100%; height: auto; border-radius: 6px; }
|
||||
.jira table { margin: 8px 0; }
|
||||
.jira td, .jira th { border: 1px solid var(--line); }
|
||||
/* Jira hard-codes light panel colors inline, which would be unreadable in dark mode. */
|
||||
.jira *:not(img) { background-color: transparent !important; color: inherit !important; border-color: var(--line) !important; }
|
||||
.jira .panel, .jira .panelHeader, .jira .panelContent { background-color: var(--sunk) !important; border-radius: 6px; }
|
||||
.jira .panel { border: 1px solid var(--line); margin: 8px 0; }
|
||||
.jira .panelHeader { font-weight: 600; padding: 6px 10px; }
|
||||
.jira .panelContent { padding: 6px 10px; }
|
||||
.note-tag { font-size: .72em; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--accent); margin-left: 8px; }
|
||||
.images { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }
|
||||
.images a { display: block; } .images img { width: 100%; border-radius: 6px; border: 1px solid var(--line); }
|
||||
|
||||
/* Progress: criteria down the left, everything that traces to the picked one on the right. */
|
||||
.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 16px; }
|
||||
.tile { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }
|
||||
.tile .n { font-size: 1.35em; font-weight: 700; }
|
||||
.tile .l { font-size: .8em; color: var(--muted); }
|
||||
.split { display: grid; grid-template-columns: 380px minmax(0, 1fr); gap: 16px; align-items: start; margin-bottom: 16px; }
|
||||
.spine { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; position: sticky; top: 16px; }
|
||||
.spine button { display: flex; gap: 10px; width: 100%; text-align: left; font: inherit; font-size: .9em; color: inherit; background: none; border: 0;
|
||||
border-bottom: 1px solid var(--line); padding: 10px 14px; cursor: pointer; align-items: flex-start; }
|
||||
.spine button:last-child { border-bottom: 0; }
|
||||
.spine button[aria-current] { background: var(--accent-soft); }
|
||||
.spine .id { font-family: var(--mono); font-size: .82em; color: var(--muted); width: 40px; flex: none; }
|
||||
.spine .dots { display: flex; gap: 3px; margin-top: 5px; }
|
||||
.spine .dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--line); }
|
||||
.spine .dots i.on { background: var(--ok); } .spine .dots i.run { background: var(--run); }
|
||||
.trace { display: grid; grid-template-columns: 110px 1fr; gap: 8px 14px; font-size: .92em; margin: 12px 0 18px; }
|
||||
.trace dt { color: var(--muted); font-size: .85em; padding-top: 2px; }
|
||||
.trace dd { margin: 0; }
|
||||
|
||||
/* Epic index */
|
||||
.epic h1 { margin: 2px 0 6px; font-size: 1.6em; }
|
||||
.epic main.wrap { max-width: 1040px; }
|
||||
.sum { display: flex; flex-wrap: wrap; gap: 8px 22px; color: var(--muted); font-size: .9em; margin: 4px 0 18px; align-items: center; }
|
||||
.sum .bar { width: 220px; }
|
||||
.why { margin-bottom: 18px; } .why ol { margin: 6px 0 0; padding-left: 20px; }
|
||||
.seq { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; }
|
||||
.seq .row { display: grid; grid-template-columns: 44px 1fr 170px 120px; gap: 14px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
||||
.seq .row:last-child { border-bottom: 0; }
|
||||
.seq .n { font-family: var(--mono); font-weight: 700; color: var(--muted); font-size: 1.05em; text-align: right; }
|
||||
.seq .row.done .n { color: var(--ok); }
|
||||
.seq .row.next { background: var(--accent-soft); } .seq .row.next .n { color: var(--accent); }
|
||||
.seq .t a { color: inherit; text-decoration: none; font-weight: 600; } .seq .t a[href]:hover { color: var(--accent); }
|
||||
.seq .k { font-family: var(--mono); font-size: .8em; color: var(--muted); }
|
||||
.seq .after { font-size: .8em; color: var(--muted); margin-top: 2px; }
|
||||
.seq .prog { font-size: .78em; color: var(--muted); display: grid; gap: 4px; }
|
||||
.seq .meta { text-align: right; font-size: .82em; color: var(--muted); display: grid; gap: 4px; justify-items: end; }
|
||||
.risk-HIGH { color: #c2410c; font-weight: 700; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.head, .grid2, .split { grid-template-columns: 1fr; }
|
||||
.tiles { grid-template-columns: 1fr 1fr; }
|
||||
.spine { position: static; }
|
||||
.seq .row { grid-template-columns: 34px 1fr; }
|
||||
.seq .prog, .seq .meta { grid-column: 2; text-align: left; justify-items: start; }
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// Renderer for ticket and epic pages. Copied into .claude/docs/epics/_site/ by ticket-page.
|
||||
// A page shell names its data file in <body data-src>. This script loads that file with a
|
||||
// <script> tag (file:// pages cannot fetch), re-loads it every two seconds, and redraws
|
||||
// only when the data changed. The data shape is defined in scripts/ticket-data.ts.
|
||||
// @ts-check
|
||||
/** @typedef {import('../../scripts/ticket-data').Ticket} Ticket */
|
||||
/** @typedef {import('../../scripts/ticket-data').Epic} Epic */
|
||||
(() => {
|
||||
const PAGE = document.body.dataset.page;
|
||||
const SRC = document.body.dataset.src || '';
|
||||
const GLOBAL = PAGE === 'epic' ? 'EPIC' : 'TICKET';
|
||||
|
||||
// ── DOM helpers ──────────────────────────────────────────────────────────
|
||||
/** h('div.card#id', {attrs}, ...children). Strings become text; html() parses markup. */
|
||||
function h(sel, attrs, ...kids) {
|
||||
const [, tag, rest] = /** @type {RegExpMatchArray} */ (sel.match(/^([a-z0-9]*)(.*)$/i));
|
||||
const node = document.createElement(tag || 'div');
|
||||
(rest.match(/[.#][^.#]+/g) || []).forEach(p => p[0] === '.' ? node.classList.add(p.slice(1)) : (node.id = p.slice(1)));
|
||||
if (attrs && (typeof attrs !== 'object' || attrs instanceof Node || Array.isArray(attrs))) { kids.unshift(attrs); attrs = null; }
|
||||
for (const [k, v] of Object.entries(attrs || {})) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k.startsWith('on')) node.addEventListener(k.slice(2), v); else node.setAttribute(k, v === true ? '' : String(v));
|
||||
}
|
||||
kids.flat(Infinity).forEach(k => k != null && k !== false && node.append(k instanceof Node ? k : document.createTextNode(String(k))));
|
||||
return node;
|
||||
}
|
||||
/** Markup from the data file: sanitized Jira HTML at import, or the agent's own notes. */
|
||||
const html = s => { const t = document.createElement('template'); t.innerHTML = s || ''; return t.content; };
|
||||
// Nodes that must survive a redraw (a playing video, a loaded iframe) are built once per key.
|
||||
const kept = new Map();
|
||||
const keep = (key, make) => { if (!kept.has(key)) kept.set(key, make()); return kept.get(key); };
|
||||
|
||||
const clock = iso => iso ? new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
const day = iso => iso ? new Date(iso).toLocaleDateString([], { month: 'short', day: 'numeric' }) : '';
|
||||
const mmss = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
|
||||
const size = b => b > 1e6 ? `${(b / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(b / 1e3))} KB`;
|
||||
const pct = ([a, b]) => b ? Math.round(100 * a / b) : 0;
|
||||
const bar = pair => h('div.bar', h('i', { style: `width:${pct(pair)}%` }));
|
||||
const mark = st => h('span.mark' + (st === 'done' ? '.done' : st === 'running' ? '.run' : ''), { title: st });
|
||||
const pill = (text, tone) => h('span.pill' + (tone ? '.' + tone : ''), text);
|
||||
|
||||
/** The viewer's own review tick, kept in this browser only and separate from the agent's done mark. */
|
||||
function seen(scope, id) {
|
||||
const key = `seen:${scope}:${id}`;
|
||||
let on = false; try { on = localStorage.getItem(key) === '1'; } catch {}
|
||||
return h('label.seen', { title: 'Your own review tick, stored in this browser' },
|
||||
h('input', { type: 'checkbox', checked: on || null, onchange: e => { try { localStorage.setItem(key, e.target.checked ? '1' : '0'); } catch {} } }), 'reviewed');
|
||||
}
|
||||
|
||||
// ── Loading and live reload ──────────────────────────────────────────────
|
||||
let last = '', loadedAt = 0, failed = false;
|
||||
function load() {
|
||||
const s = document.createElement('script');
|
||||
s.src = `${SRC}?t=${Date.now()}`;
|
||||
s.onload = () => {
|
||||
s.remove(); failed = false; loadedAt = Date.now();
|
||||
const next = JSON.stringify(window[GLOBAL]);
|
||||
if (next !== last) { last = next; draw(); } else liveNote();
|
||||
};
|
||||
s.onerror = () => { s.remove(); failed = true; if (!last) fatal(`Could not load ${SRC}. Run ticket-page check for this ticket.`); else liveNote(); };
|
||||
document.head.append(s);
|
||||
}
|
||||
function fatal(msg) { document.getElementById('app')?.replaceChildren(h('p.error', msg)); }
|
||||
function liveNote() {
|
||||
const el = document.querySelector('.live');
|
||||
if (!el) return;
|
||||
el.classList.toggle('stale', failed);
|
||||
el.textContent = failed ? 'Live: last reload failed, showing the previous version' : `Live, checked ${clock(new Date(loadedAt).toISOString())}`;
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const data = window[GLOBAL];
|
||||
const y = scrollY;
|
||||
try {
|
||||
document.getElementById('app')?.replaceChildren(PAGE === 'epic' ? epicPage(data) : ticketPage(data));
|
||||
} catch (err) { fatal('The page could not render this data: ' + err.message); console.error(err); }
|
||||
liveNote();
|
||||
scrollTo(0, y);
|
||||
}
|
||||
addEventListener('hashchange', () => last && draw());
|
||||
|
||||
// ── Ticket page ──────────────────────────────────────────────────────────
|
||||
const TABS = ['Ticket', 'Plan', 'Progress', 'Proof', 'Review'];
|
||||
const route = () => { const [tab, pick] = location.hash.slice(1).split('/'); return { tab: TABS.includes(tab) ? tab : null, pick }; };
|
||||
|
||||
/** @param {Ticket} t */
|
||||
function phase(t) {
|
||||
const pl = t.plan;
|
||||
if (t.review.preMr?.verdict === 'Ready to Open MR') return ['Ready to Open MR', 'ok'];
|
||||
if (!pl) return ['Not planned', ''];
|
||||
if (!pl.approvedAt) return ['Plan ready for review', 'warn'];
|
||||
const run = pl.steps.find(s => s.status === 'running');
|
||||
if (run) return [`Building step ${run.n}`, 'run'];
|
||||
if (pl.steps.every(s => s.status === 'done')) return [t.review.preMr ? `pre-mr-review: ${t.review.preMr.verdict}` : 'Verifying', 'run'];
|
||||
return ['Building', 'run'];
|
||||
}
|
||||
|
||||
/** @param {Ticket} t */
|
||||
function ticketPage(t) {
|
||||
const r = route();
|
||||
const tab = r.tab || (t.plan ? (t.plan.approvedAt ? 'Progress' : 'Plan') : 'Ticket');
|
||||
const pl = t.plan;
|
||||
const count = xs => [xs.filter(x => x.status === 'done').length, xs.length];
|
||||
const crit = pl ? count(pl.criteria) : [0, 0], steps = pl ? count(pl.steps) : [0, 0];
|
||||
const [phaseText, tone] = phase(t);
|
||||
const openFindings = t.review.findings.filter(f => f.status === 'open').length;
|
||||
const badge = { Plan: pl && !pl.approvedAt ? 'review' : null, Progress: pl ? `${steps[0]}/${steps[1]}` : null, Proof: t.proof.length || null,
|
||||
Review: t.review.preMr?.verdict === 'Ready to Open MR' ? 'Ready' : openFindings || null };
|
||||
const header = h('div.top', h('div.wrap',
|
||||
h('div.head',
|
||||
h('div',
|
||||
h('div.crumbs', t.epic ? [h('a', { href: '../index.html' }, `${t.epic.key} ${t.epic.title}`), ' / '] : null, t.key),
|
||||
h('h1', t.title),
|
||||
h('div.meta', pill(phaseText, tone), h('span', [t.type, t.estimate].filter(Boolean).join(', ')),
|
||||
t.branch ? h('span', 'Branch ', h('code', t.branch)) : null, h('a', { href: t.jira, target: '_blank', rel: 'noreferrer' }, 'Open in Jira'), h('span.live'))),
|
||||
pl ? h('div.meters',
|
||||
h('div.row', h('span', 'Acceptance criteria'), h('b', `${crit[0]} of ${crit[1]}`), bar(crit)),
|
||||
h('div.row', h('span', 'Steps'), h('b', `${steps[0]} of ${steps[1]}`), bar(steps))) : h('div')),
|
||||
h('nav.tabs', TABS.map(x => h('a', { href: '#' + x, 'aria-current': x === tab ? 'page' : null }, x, badge[x] ? h('span.pill.n', badge[x]) : null)))));
|
||||
return h('div', header, h('main.wrap', TICKET_TABS[tab](t, r.pick)));
|
||||
}
|
||||
|
||||
function videoBlock(a) {
|
||||
return keep('video:' + a.path, () => {
|
||||
const player = /** @type {HTMLVideoElement} */ (h('video', { src: a.path, poster: a.frames?.[0], controls: true, preload: 'metadata' }));
|
||||
const segs = a.transcript || [];
|
||||
const rows = segs.map(seg => h('button', { onclick: () => { player.currentTime = seg.t; player.play().catch(() => {}); } }, h('span.t', mmss(seg.t)), h('span', seg.text)));
|
||||
const follow = () => {
|
||||
let i = segs.findIndex(s => s.t > player.currentTime) - 1;
|
||||
if (i < -1) i = segs.length - 1;
|
||||
rows.forEach((row, j) => row.classList.toggle('now', j === i));
|
||||
};
|
||||
player.addEventListener('timeupdate', follow);
|
||||
player.addEventListener('seeked', follow);
|
||||
const frames = a.frames?.length ? h('div.frames', a.frames.map(f => h('a', { href: f, target: '_blank' }, h('img', { src: f, alt: 'Frame', loading: 'lazy' })))) : null;
|
||||
return h('div.card', h('h2', a.name),
|
||||
h('div.video', h('div', player, frames), segs.length ? h('div.transcript', rows) : h('p.muted', 'No transcript. Transcribe it and run ticket-page scan.')),
|
||||
segs.length ? h('p.muted', { style: 'margin-top:8px;font-size:.85em' }, 'Click a transcript line to jump there.') : null);
|
||||
});
|
||||
}
|
||||
|
||||
function artboard(m) {
|
||||
return keep('mock:' + m.id + ':' + m.html.length + ':' + m.html.slice(0, 64), () => {
|
||||
const f = /** @type {HTMLIFrameElement} */ (h('iframe.artboard', { title: m.title, sandbox: '' }));
|
||||
f.srcdoc = m.html;
|
||||
return f;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {Ticket} t @param {import('../../scripts/ticket-data').Proof} p */
|
||||
function compare(t, p) {
|
||||
const m = p.mock && t.plan?.mocks.find(x => x.id === p.mock);
|
||||
const evidence = p.kind === 'text' ? h('pre.textproof', p.text || '') : p.kind === 'test' ? h('pre.textproof', 'Passing test: ' + p.test) : h('a', { href: p.file, target: '_blank' }, h('img.shot', { src: p.file, alt: p.file }));
|
||||
return h('div.compare',
|
||||
h('figure', h('figcaption', 'Mock, drawn at plan time'), m ? artboard(m) : h('div.placeholder', 'No mock for this one.')),
|
||||
h('figure', h('figcaption', 'Proof, captured from the build'), evidence));
|
||||
}
|
||||
const pending = m => h('div.compare',
|
||||
h('figure', h('figcaption', 'Mock, drawn at plan time'), artboard(m)),
|
||||
h('figure', h('figcaption', 'Proof, captured from the build'), h('div.placeholder', 'Not captured yet. It appears here when the proof is recorded.')));
|
||||
|
||||
function timeline(events) {
|
||||
if (!events.length) return h('p.muted', 'Nothing yet.');
|
||||
let lastDay = '';
|
||||
return h('ul.timeline', events.slice().reverse().map(e => {
|
||||
const d = day(e.at), label = d !== lastDay ? d : ''; lastDay = d;
|
||||
return h('li', h('time', { title: new Date(e.at).toLocaleString() }, label ? h('div', label) : null, clock(e.at)), h('span.dot.' + e.kind), h('span', e.text, e.commit ? [' ', h('span.sha', e.commit)] : null));
|
||||
}));
|
||||
}
|
||||
|
||||
const TICKET_TABS = {
|
||||
/** @param {Ticket} t */
|
||||
Ticket: t => {
|
||||
const media = t.attachments.filter(a => a.kind === 'video' || a.kind === 'audio');
|
||||
const images = t.attachments.filter(a => a.kind === 'image');
|
||||
const files = t.attachments.filter(a => a.kind === 'file');
|
||||
return h('div.stack',
|
||||
h('div.grid2',
|
||||
h('div.stack', t.source.sections.length ? t.source.sections.map(s => h('div.card', h('h2', s.title, s.origin === 'note' ? h('span.note-tag', 'notes') : null), h('div.jira', html(s.html))))
|
||||
: h('div.card', h('p.muted', 'The ticket has no written content.'))),
|
||||
h('div.stack',
|
||||
h('div.card', h('h2', 'Details'), h('table.kv', t.source.fields.map(([k, v]) => h('tr', h('td', k), h('td', v))))),
|
||||
t.links.length ? h('div.card', h('h2', 'Linked issues'), h('ul', t.links.map(l => h('li', l.type, ' ', h('code', l.key), ' ', l.title)))) : null,
|
||||
files.length ? h('div.card', h('h2', 'Files'), h('ul', files.map(f => h('li', h('a', { href: f.path }, f.name), h('span.muted', ' ' + size(f.bytes)))))) : null)),
|
||||
media.map(videoBlock),
|
||||
images.length ? h('div.card', h('h2', 'Images'), h('div.images', images.map(i => h('a', { href: i.path, target: '_blank' }, h('img', { src: i.path, alt: i.name, loading: 'lazy' }))))) : null);
|
||||
},
|
||||
|
||||
/** @param {Ticket} t */
|
||||
Plan: t => {
|
||||
const pl = t.plan;
|
||||
if (!pl) return h('p.empty', 'No plan yet.');
|
||||
return h('div.grid2',
|
||||
h('div.stack',
|
||||
h('div.card', h('h2', 'Acceptance criteria'), pl.criteria.map(c => h('div.check', mark(c.status),
|
||||
h('div.body', h('div', h('b', c.id + ' '), c.text), h('div.sub', c.steps.length ? 'Steps ' + c.steps.join(', ') : 'No step named')), seen(t.key, c.id)))),
|
||||
h('div.card', h('h2', 'Approach'), h('div.jira', html(pl.approach))),
|
||||
pl.mocks.length ? h('div.card', h('h2', 'Mocks'), pl.mocks.map(m => h('div', h('h3', m.title), artboard(m), m.note ? h('p.muted', { style: 'font-size:.85em;margin-top:6px' }, m.note) : null))) : null),
|
||||
h('div.stack',
|
||||
h('div.card', h('h2', 'Open questions'), pl.questions.length ? h('div.callout', h('ul', pl.questions.map(q => h('li', html(q))))) : h('p.muted', 'None.')),
|
||||
pl.decisions.length ? h('div.card', h('h2', 'Decisions'), h('table', pl.decisions.map(([q, c, why]) => h('tr', h('td', h('b', q), h('div.muted', { style: 'font-size:.9em' }, why)), h('td', c))))) : null,
|
||||
h('div.card', h('h2', 'Steps'), h('ol', { style: 'padding-left:22px;margin:0' }, pl.steps.map(s => h('li', s.text, s.kind !== 'code' ? [' ', pill(s.kind)] : null, s.tdd ? [' ', pill('test first')] : null)))),
|
||||
h('div.card', h('h2', 'Risks and edge cases'), pl.risks.length ? h('ul', pl.risks.map(r => h('li', h('b', r.risk + '. '), r.handling, r.fromTicket ? [' ', pill('ticket')] : null))) : h('p.muted', 'None listed.')),
|
||||
h('div.card', h('h2', 'Test plan'), pl.tests.length ? h('ul', pl.tests.map(x => h('li', pill(x.kind), ' ', x.text, x.fromTicket ? [' ', pill('ticket')] : null))) : h('p.muted', 'None listed.'))));
|
||||
},
|
||||
|
||||
/** @param {Ticket} t @param {string} pick */
|
||||
Progress: (t, pick) => {
|
||||
const pl = t.plan;
|
||||
if (!pl) return h('p.empty', 'No plan yet.');
|
||||
const count = xs => [xs.filter(x => x.status === 'done').length, xs.length];
|
||||
const [cd, ct] = count(pl.criteria), [sd, st] = count(pl.steps);
|
||||
const open = t.review.findings.filter(f => f.status === 'open').length;
|
||||
const review = t.review.preMr ? t.review.preMr.verdict : t.review.findings.length ? `${open} open` : 'Not yet';
|
||||
const tiles = h('div.tiles', [[`${cd}/${ct}`, 'Criteria met'], [`${sd}/${st}`, 'Steps committed'], [String(t.proof.length), 'Proof captured'], [review, 'Review']]
|
||||
.map(([n, l]) => h('div.tile', h('div.n', n), h('div.l', l))));
|
||||
const step = n => pl.steps.find(s => s.n === n);
|
||||
const c = pl.criteria.find(x => x.id === pick) || pl.criteria.find(x => x.status !== 'done') || pl.criteria[0];
|
||||
const spine = h('div.spine', pl.criteria.map(x => h('button', { 'aria-current': x === c ? 'true' : null, onclick: () => { location.hash = `Progress/${x.id}`; } },
|
||||
mark(x.status), h('span.id', x.id),
|
||||
h('span', x.text, h('div.dots', { title: 'Steps behind this criterion' }, x.steps.map(n => h('i' + (step(n)?.status === 'done' ? '.on' : step(n)?.status === 'running' ? '.run' : ''))))))));
|
||||
let detail = h('div.card', h('p.muted', 'The plan has no acceptance criteria.'));
|
||||
if (c) {
|
||||
const proofs = t.proof.filter(p => p.proves.includes(c.id));
|
||||
const risks = pl.risks.filter(r => r.criteria?.includes(c.id));
|
||||
const tests = pl.tests.filter(x => x.criteria?.includes(c.id));
|
||||
const mocks = pl.mocks.filter(m => m.criteria.includes(c.id) && !proofs.some(p => p.mock === m.id));
|
||||
detail = h('div.card',
|
||||
h('div', { style: 'display:flex;justify-content:space-between;gap:12px;align-items:baseline' }, h('h2', c.id, ' ', c.status === 'done' ? pill('met ' + clock(c.doneAt), 'ok') : pill('open')), seen(t.key, c.id)),
|
||||
h('p', c.text), c.evidence ? h('p.muted', c.evidence) : null,
|
||||
h('dl.trace',
|
||||
h('dt', 'Steps'), h('dd', c.steps.length ? c.steps.map(n => { const s = step(n); return s && h('div', { style: 'display:flex;gap:8px;margin-bottom:4px' }, mark(s.status), h('span', `${s.n}. ${s.text} `, s.commits.map(x => h('span.sha', x + ' ')))); }) : h('span.muted', 'None named')),
|
||||
h('dt', 'Tests'), h('dd', tests.length ? tests.map(x => h('div', pill(x.kind), ' ', x.text)) : h('span.muted', 'None tied to this one')),
|
||||
h('dt', 'Risks'), h('dd', risks.length ? risks.map(r => h('div', h('b', r.risk + '. '), r.handling)) : h('span.muted', 'None tied to this one'))),
|
||||
proofs.map(p => h('div', { style: 'margin-bottom:14px' }, h('h3', p.caption), compare(t, p))),
|
||||
mocks.map(m => h('div', h('h3', m.title), pending(m))),
|
||||
!proofs.length && !mocks.length ? h('p.muted', 'No proof recorded for this criterion yet.') : null);
|
||||
}
|
||||
const steps = h('div.card', h('h2', 'Steps'), pl.steps.map(s => h('div.check', mark(s.status),
|
||||
h('div.body', h('div', h('b', s.n + '. '), s.text, s.kind !== 'code' ? [' ', pill(s.kind)] : null),
|
||||
h('div.sub', s.status === 'done' ? [s.commits.map(x => h('span.sha', x + ' ')), 'at ', clock(s.doneAt)] : s.status === 'running' ? 'In progress' : 'Not started')))));
|
||||
return h('div', tiles, h('div.split', spine, detail), h('div.grid2', steps, h('div.card', h('h2', 'Live log'), timeline(t.events))));
|
||||
},
|
||||
|
||||
/** @param {Ticket} t */
|
||||
Proof: t => {
|
||||
const mocks = t.plan?.mocks || [];
|
||||
const waiting = mocks.filter(m => !t.proof.some(p => p.mock === m.id));
|
||||
if (!t.proof.length && !waiting.length) return h('p.empty', 'No proof yet.');
|
||||
return h('div.stack',
|
||||
t.proof.map(p => h('div.card', h('h2', p.caption),
|
||||
h('p.muted', 'Proves ', p.proves.join(', '), '. ', p.file ? ['File ', h('code', p.file), ', for you to attach in Jira.'] : 'Named test.'), compare(t, p))),
|
||||
waiting.map(m => h('div.card', h('h2', m.title), pending(m))));
|
||||
},
|
||||
|
||||
/** @param {Ticket} t */
|
||||
Review: t => {
|
||||
const R = t.review;
|
||||
return h('div.grid2',
|
||||
h('div.card', h('h2', 'Code review'), R.findings.length ? R.findings.map(f => h('div.check', mark(f.status === 'open' ? 'running' : 'done'),
|
||||
h('div.body', h('div', pill(f.severity, f.severity === 'CONFIRMED' ? 'warn' : ''), ' ', h('b', f.id + ' '), f.text),
|
||||
h('div.sub', f.where ? [h('code', f.where), ' '] : null, f.status === 'fixed' ? ['Fixed in ', h('span.sha', f.commit)] : f.status === 'rejected' ? 'Rejected: ' + f.reason : 'Open'))))
|
||||
: h('p.muted', 'No reviewer has run yet.')),
|
||||
h('div.stack',
|
||||
h('div.card', h('h2', 'pre-mr-review'), R.preMr ? [h('p', pill(R.preMr.verdict, R.preMr.verdict === 'Ready to Open MR' ? 'ok' : 'warn')),
|
||||
h('p', R.preMr.recommendation ? ['Recommendation: ', h('b', R.preMr.recommendation), '. '] : null, `Run ${R.preMr.runs} time${R.preMr.runs === 1 ? '' : 's'}`, R.preMr.head ? [' at ', h('span.sha', R.preMr.head)] : null, '.')]
|
||||
: h('p.muted', 'Not run yet. You run it from your work account.')),
|
||||
h('div.card', h('h2', 'Jira fields'), t.jiraFields.length ? h('ul', t.jiraFields.map(f => h('li', f.name, h('span.muted', ' ' + clock(f.at))))) : h('p.muted', 'Filled after the verdict.')),
|
||||
h('div.card', h('h2', 'MR description'), t.mr ? h('p', h('code', 'mr.md'), ' written ', day(t.mr.at), ' ', clock(t.mr.at), '.') : h('p.muted', 'Written once the verdict is Ready to Open MR.'))));
|
||||
},
|
||||
};
|
||||
|
||||
// ── Epic page ────────────────────────────────────────────────────────────
|
||||
const STATUS = { todo: ['To do', ''], planned: ['Planned', 'warn'], building: ['Building', 'run'], verifying: ['Verifying', 'run'], ready: ['Ready for MR', 'ok'], done: ['Done', 'ok'] };
|
||||
|
||||
/** @param {Epic} e */
|
||||
function epicPage(e) {
|
||||
document.body.classList.add('epic');
|
||||
const byKey = Object.fromEntries(e.stories.map((s, i) => [s.key, { ...s, n: i + 1 }]));
|
||||
const done = e.stories.filter(s => s.status === 'done');
|
||||
const hours = e.stories.reduce((a, s) => a + (s.hours || 0), 0), doneH = done.reduce((a, s) => a + (s.hours || 0), 0);
|
||||
const ready = s => s.status === 'todo' && s.after.every(k => byKey[k]?.status === 'done');
|
||||
const next = e.stories.find(s => ['planned', 'building', 'verifying', 'ready'].includes(s.status)) || e.stories.find(ready);
|
||||
return h('div',
|
||||
h('div.top', h('div.wrap', h('div.head', h('div',
|
||||
h('div.crumbs', e.key), h('h1', e.title),
|
||||
h('div.meta', h('span', `${done.length} of ${e.stories.length} stories done`), hours ? h('span', `${doneH} of ${hours}h`) : null,
|
||||
h('a', { href: e.jira, target: '_blank', rel: 'noreferrer' }, 'Open in Jira'), h('span.live'))),
|
||||
h('div.meters', h('div.row', h('span', 'Stories done'), h('b', `${done.length} of ${e.stories.length}`), bar([done.length, e.stories.length])))))),
|
||||
h('main.wrap',
|
||||
e.why.length ? h('div.card.why', h('b', 'Build order, and why it is this order'), h('ol', e.why.map(w => h('li', w)))) : null,
|
||||
e.stories.length ? h('div.seq', e.stories.map((s, i) => {
|
||||
const [label, tone] = STATUS[s.status] || [s.status, ''];
|
||||
const p = s.progress;
|
||||
return h('div.row.' + s.status + (s === next ? '.next' : ''),
|
||||
h('div.n', i + 1),
|
||||
h('div.t', h('a', { href: s.page ? `${s.key}/index.html` : null, title: s.page ? 'Open the ticket page' : 'No ticket page yet' }, s.title), ' ', h('span.k', s.key),
|
||||
s.after.length ? h('div.after', 'After ', s.after.map(k => '#' + (byKey[k]?.n ?? '?')).join(', ')) : null),
|
||||
h('div.prog', s.status === 'todo' || !p ? h('span', ready(s) ? 'Unblocked, ready to start' : s.after.length ? 'Waiting on earlier stories' : '') : [h('span', `Criteria ${p.criteria.join('/')} · Steps ${p.steps.join('/')}`), bar(p.steps)]),
|
||||
h('div.meta', pill(s === next && s.status === 'todo' ? 'Up next' : label, s === next && s.status === 'todo' ? 'run' : tone),
|
||||
h('span', [s.hours ? s.hours + 'h' : '', s.risk ? h('span.risk-' + s.risk, s.risk) : ''].filter(Boolean).flatMap((x, j) => j ? [' · ', x] : [x]))));
|
||||
})) : h('p.empty', 'No stories yet.'),
|
||||
e.closed.length ? h('p.muted', { style: 'margin-top:18px;font-size:.9em' }, 'Closed: ', e.closed.map(c => `${c.key} ${c.title}`).join(', '), '.') : null));
|
||||
}
|
||||
|
||||
load();
|
||||
setInterval(load, 2000);
|
||||
})();
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{TITLE}}</title>
|
||||
<link rel="stylesheet" href="{{SITE}}/site.css?v={{VERSION}}">
|
||||
</head>
|
||||
<body data-page="ticket" data-src="ticket.data.js">
|
||||
<div id="app"><p class="loading">Loading ticket.data.js…</p></div>
|
||||
<script src="{{SITE}}/site.js?v={{VERSION}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user