Compare commits
13
Commits
130137485e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933c197060 | ||
|
|
de3591a25b | ||
|
|
88032490c0 | ||
|
|
fd24b69039 | ||
|
|
dfc009e422 | ||
|
|
1843c0495a | ||
|
|
43ae5bb53b | ||
|
|
05054afdc9 | ||
|
|
9022847971 | ||
|
|
9b06e9e24b | ||
|
|
59da2bb2fc | ||
|
|
5ebecd59bb | ||
|
|
501f7306ed |
@@ -38,14 +38,34 @@ if [[ -f /etc/udev/rules.d/99-panama-gpu.rules ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# The power-key drop-in lands via the same copy; logind reads its config only
|
# 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
|
# at start. Restarting logind under a running Wayland or X11 session drops that
|
||||||
# boot. Restarting logind is what Fedora's own updates do and current systemd
|
# session and takes the desktop down with it, so the restart happens only when
|
||||||
# preserves sessions across it.
|
# 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
|
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"
|
log "Reloading logind so the power button opens the menu instead of powering off"
|
||||||
sudo systemctl try-restart systemd-logind 2>/dev/null \
|
sudo systemctl try-restart systemd-logind 2>/dev/null \
|
||||||
|| log "Could not restart logind; the power-button change applies at next boot"
|
|| log "Could not restart logind; the power-button change applies at next boot"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# The document-portal drop-in lands via the same copy. A user unit's drop-ins
|
# 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
|
# 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_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_user_flatpak="$(snapshot_file_state "$HOME/.local/share/flatpak/repo/config")"
|
||||||
before_bashrc="$(snapshot_file_state "$HOME/.bashrc")"
|
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
|
# Runtime and agent installs must consume the reviewed provenance table. Keep
|
||||||
# this scan at the public script boundary because a command hidden elsewhere in
|
# 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'
|
|| fail 'repository cases changed the user Flatpak remote'
|
||||||
[[ "$before_bashrc" == "$(snapshot_file_state "$HOME/.bashrc")" ]] \
|
[[ "$before_bashrc" == "$(snapshot_file_state "$HOME/.bashrc")" ]] \
|
||||||
|| fail 'repository cases changed the protected 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'
|
printf 'package provenance contract: PASS\n'
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ note() { findings+=("$1"); }
|
|||||||
tmp="$(mktemp -d -t panama-update-contract.XXXXXX)"
|
tmp="$(mktemp -d -t panama-update-contract.XXXXXX)"
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
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
|
link-vicinae-scripts setup-server link-server setup-identity
|
||||||
install-hardware)
|
install-hardware)
|
||||||
PACKAGE_BEHAVIOR_INPUTS=(
|
PACKAGE_BEHAVIOR_INPUTS=(
|
||||||
@@ -191,7 +191,7 @@ fi
|
|||||||
|
|
||||||
# ── 2. Exactly the answer-free stages ────────────────────────────────────────
|
# ── 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"
|
grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage"
|
||||||
done
|
done
|
||||||
for stage in setup-identity install-hardware; do
|
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
|
# 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
|
# 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.
|
# 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)
|
DNF_SUPPRESSED=(install-packages change-settings install-hardware)
|
||||||
|
|
||||||
assert_trust_refusal() {
|
assert_trust_refusal() {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ I'm Gabriel. You're my agent. We will be working together a lot, so I thought I
|
|||||||
|
|
||||||
I'm a software developer at Ksense Technology Group. At Ksense, we basically sell our services as developers to clients to create web applications or web servers. Basically whatever the client wants, but for the most part, we create web applications with Next.js. I am the primary developer for internal tools & web applications. I mainly work on an application called Command Center, which serves as a portal for our project managers to interact with our clients.
|
I'm a software developer at Ksense Technology Group. At Ksense, we basically sell our services as developers to clients to create web applications or web servers. Basically whatever the client wants, but for the most part, we create web applications with Next.js. I am the primary developer for internal tools & web applications. I mainly work on an application called Command Center, which serves as a portal for our project managers to interact with our clients.
|
||||||
|
|
||||||
|
My Ksense work lives in ~/Documents/Code/Ksense. The Ksense-specific sections below (Merge Requests, Coworkers) mostly matter when I am working inside that folder. Anywhere else, assume a personal project.
|
||||||
|
|
||||||
I love to build. I focus on building complex things as simple as possible. I love to find ways to reduce complexity when solving problems.
|
I love to build. I focus on building complex things as simple as possible. I love to find ways to reduce complexity when solving problems.
|
||||||
|
|
||||||
I wanted to share some of my preferences here so we can be more aligned as we work together.
|
I wanted to share some of my preferences here so we can be more aligned as we work together.
|
||||||
|
|||||||
@@ -19,3 +19,7 @@ nanokvm-mac http https://macnano.gbrown.org/api/mcp NANOKVM_MAC_TOKEN
|
|||||||
# Bill Tracker's hosted MCP server. No token: it is an OAuth server of its own
|
# Bill Tracker's hosted MCP server. No token: it is an OAuth server of its own
|
||||||
# and Claude Code signs in through Gib's Auth on first use.
|
# and Claude Code signs in through Gib's Auth on first use.
|
||||||
bill-tracker http https://mcp.billtracker.gbrown.org/mcp
|
bill-tracker http https://mcp.billtracker.gbrown.org/mcp
|
||||||
|
|
||||||
|
# Atlassian's hosted MCP server for Jira and Confluence. No token: it is an
|
||||||
|
# OAuth server and Claude Code signs in through /mcp on first use.
|
||||||
|
atlassian http https://mcp.atlassian.com/v1/mcp
|
||||||
|
|||||||
@@ -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
|
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
|
disable-model-invocation: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Ticket Workflow
|
# Ticket Workflow
|
||||||
|
|
||||||
Turns a Jira ticket key into: a saved ticket record, a reviewed plan, implemented and
|
Turns a Jira ticket key into: a live ticket page carrying the ticket, a reviewed plan,
|
||||||
committed code, a converged `pre-mr-review` audit, and an MR doc ready to paste into
|
progress, and proof, implemented and committed code, a converged `pre-mr-review` audit, and
|
||||||
GitLab/GitHub. This skill is user-level (`~/.agents/skills/ticket/`) and works the same
|
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.
|
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
|
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
|
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.
|
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
|
## Directory layout this skill maintains
|
||||||
|
|
||||||
```
|
```
|
||||||
.claude/docs/epics/
|
.claude/docs/epics/
|
||||||
|
_site/ # the shared renderer, refreshed by ticket-page
|
||||||
<EPIC-KEY>/
|
<EPIC-KEY>/
|
||||||
epic.md # brief epic context, written once, best-effort
|
index.html, epic.data.js # the epic index
|
||||||
<TICKET-KEY>/
|
<TICKET-KEY>/
|
||||||
|
index.html, ticket.data.js # the ticket page
|
||||||
resources/
|
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
|
<attachments...> # images, videos, other files as downloaded
|
||||||
<video>.transcript.txt # only for videos that got transcribed
|
<video>.transcript.txt # only for videos that got transcribed
|
||||||
<video>.transcript.srt
|
<video>.transcript.srt
|
||||||
plan.md
|
<video>.frames/
|
||||||
deliverables/ # only for tickets whose output is documents, not code
|
deliverables/ # only for tickets whose output is documents, not code
|
||||||
<deliverable-slug>/
|
<deliverable-slug>/
|
||||||
<deliverable-slug>.md
|
<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
|
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
|
mr.md # the MR description only, written once pre-mr-review says Ready to Open MR
|
||||||
tickets/
|
tickets/
|
||||||
<TICKET-KEY>/ # same shape, for tickets with no epic
|
<TICKET-KEY>/ # same shape, for tickets with no epic, and no index
|
||||||
resources/...
|
|
||||||
plan.md
|
|
||||||
deliverables/...
|
|
||||||
mr.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Scripts this skill uses
|
## 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
|
- `~/.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` /
|
issue (`fields=*all`, rendered HTML description, field-name map) via `JIRA_BASE_URL` /
|
||||||
`JIRA_CREDENTIALS`.
|
`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)
|
## House style (applies to everything this skill writes or commits)
|
||||||
|
|
||||||
- No code comments unless the user explicitly asks for one in that spot.
|
- 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`,
|
- No em dashes, en dashes, semicolons, or arrow glyphs in commit messages, the ticket page,
|
||||||
`plan.md`, or `mr.md`. Plain punctuation only.
|
or `mr.md`. Plain punctuation only.
|
||||||
- Commits are `git commit -m "<short imperative message>"` only — no body, no
|
- Commits are `git commit -m "<short imperative message>"` only — no body, no
|
||||||
co-author line, no `Claude` / `Codex` / `AI` trailer of any kind.
|
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
|
- 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.
|
you always were.
|
||||||
|
|
||||||
This governs `mr.md`, `customfield_10260` and `10261`, every other Jira field, and PM
|
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.
|
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
|
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
|
## Verification tools available
|
||||||
|
|
||||||
The goal isn't "a plausible-sounding plan" or "code that compiles" — it's a plan and
|
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
|
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
|
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:
|
implementation makes a claim that's checkable:
|
||||||
|
|
||||||
- **The codebase itself.** Don't take the ticket's description of "how things work
|
- **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.
|
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
|
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.
|
the check.
|
||||||
|
|
||||||
**How to write what you found, in a deliverable.** State it as a plain confirmed
|
**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)
|
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
|
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:
|
subfolder under `<target-dir>/deliverables/<deliverable-slug>/` with three files:
|
||||||
|
|
||||||
- `<slug>.md` — the source of truth for content. Write this first, get the content
|
- `<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.
|
- `<slug>.pdf` — compiled from the `.typ` file.
|
||||||
|
|
||||||
**Write for the actual audience, not for yourself.** The reader is a PM or another
|
**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
|
engineer who has never seen `.claude/docs/` or the ticket page, and doesn't have this
|
||||||
`ticket.md` are, and doesn't have this repo (or any other local repo) checked out.
|
repo (or any other local repo) checked out. Never reference `.claude/docs/` or any local
|
||||||
Never reference `.claude/docs/` or any local file path in a deliverable's content.
|
file path in a deliverable's content. Never point the reader at the ticket page or the
|
||||||
Never say "see plan.md" or "see ticket.md", those are personal working files, not
|
plan, those are personal working files, not things a reader can open. Refer to "this ticket" or a plain Jira link, not a local
|
||||||
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
|
path. If a fact came from another repo on this machine, state the fact, don't cite
|
||||||
the local checkout path it came from.
|
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
|
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.
|
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.
|
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
|
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
|
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
|
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
|
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.
|
without mentioning the tradeoff.
|
||||||
- **Screenshot the real thing.** Once it's built and running, use the `run` skill to
|
- **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
|
get the app up and capture the actual screens with `capture-proof`, as the proof step
|
||||||
the actual screens, instead of drawing a wireframe. Save screenshots under
|
in Phase 2 describes, instead of drawing a wireframe. Save screenshots under
|
||||||
`deliverables/<slug>/screenshots/` and the visual mocks under
|
`deliverables/<slug>/screenshots/` and the visual mocks under
|
||||||
`deliverables/<slug>/mocks/`, and embed both in `<slug>.typ` via `image()` when
|
`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
|
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
|
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
|
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
|
actual code (mockup prototypes, spike code, real implementation) lands in the right
|
||||||
place from the start.
|
place from the start.
|
||||||
|
|
||||||
@@ -422,20 +436,23 @@ place from the start.
|
|||||||
correct key.
|
correct key.
|
||||||
2. Look for an existing directory for this ticket without hitting the network:
|
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>"`
|
`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:
|
3. Branch on what you find:
|
||||||
- **Nothing found** — this is a fresh ticket. Go to **Phase 1**.
|
- **Nothing found** — this is a fresh ticket. Go to **Phase 1**.
|
||||||
- **Directory exists, no `plan.md`** — a previous run was interrupted before writing
|
- **A page with no plan** — a previous run was interrupted before writing a plan. Go
|
||||||
a plan. Go to **Phase 1** and regenerate from scratch (re-fetch, overwrite
|
to **Phase 1** and regenerate from scratch (re-fetch, re-import, re-check
|
||||||
`ticket.md`, re-check attachments); it's idempotent and cheap.
|
attachments); it's idempotent and cheap.
|
||||||
- **`plan.md` exists, no `mr.md`** — ask the user (AskUserQuestion) what they want:
|
- **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)
|
- 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)
|
- Proceed to implementing the existing plan as-is (they reviewed and approved it)
|
||||||
- Resume implementation (some plan steps are already checked off / some commits
|
- Resume implementation (some steps are already done on the page / some commits
|
||||||
already exist on the ticket branch — pick up from the first unchecked step)
|
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)
|
- 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.
|
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
|
`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
|
(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
|
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
|
## Phase 1 — fetch, scaffold, and plan
|
||||||
|
|
||||||
1. Do **Branch setup** above first, unconditionally.
|
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
|
2. Run `jira-fetch-issue.sh <KEY> <tmp-path>` (a scratch path is fine here — the next
|
||||||
yet know the final directory). Read the resulting JSON.
|
step knows the final directory). Read the resulting JSON.
|
||||||
3. Determine the epic: `fields.parent.key`, if present. If absent, this ticket has no
|
3. Import it: `ticket-page init <KEY> --issue <tmp-path>`. This files the ticket under its
|
||||||
epic.
|
epic (or `tickets/` without one), imports every rich-text field verbatim as sanitized
|
||||||
4. Resolve the target directory:
|
HTML, writes the page, and prints the ticket folder. Copy the fetched JSON to
|
||||||
- With epic: `.claude/docs/epics/<EPIC-KEY>/<KEY>/`
|
`<target-dir>/resources/issue.raw.json`, which later re-imports and the attachment
|
||||||
- Without epic: `.claude/docs/epics/tickets/<KEY>/`
|
script read. Record the branch from **Branch setup** with `ticket-page meta`, then open
|
||||||
Create it and its `resources/` subdirectory.
|
the page with `ticket-page open <KEY>` so Gib can follow from here on.
|
||||||
5. Move/copy the fetched JSON to `<target-dir>/resources/issue.raw.json` for your own
|
4. `init` ends by listing populated Jira fields the page does not show. Read each one and
|
||||||
reference while writing `ticket.md` — this raw file is scratch, not part of the
|
add any that bear on implementing or verifying the ticket as a note. This sweep exists
|
||||||
deliverable; feel free to leave it (it's harmless context for later) or delete it
|
because real content sometimes hides in per-project custom fields with unpredictable
|
||||||
once `ticket.md` is written, your call.
|
names, and a name-pattern miss must not silently drop it.
|
||||||
6. If there's an epic and `.claude/docs/epics/<EPIC-KEY>/epic.md` doesn't already exist:
|
5. Read what the Ticket tab now holds, and treat its tables as the ticket's contract.
|
||||||
best-effort fetch the epic issue too (`jira-fetch-issue.sh <EPIC-KEY> <tmp>`) and
|
- **Test cases** (KACP's "Test Cases & Working Feature Proof") and **risk mitigation**
|
||||||
write a short `epic.md` (title + description, converted to markdown, a couple
|
are imported with every row verbatim. Often a field is just the bare instruction
|
||||||
paragraphs at most). If this fetch fails for any reason, skip it and continue — it's
|
panel and an empty table, which needs no further thought. When it has real rows,
|
||||||
context, not a blocker.
|
those are the scenarios the developer must prove and the mitigations the developer
|
||||||
7. Write `resources/ticket.md` by reading the fetched issue JSON yourself:
|
must implement: the plan names each one (`"fromTicket": true`), Phase 2 proves each
|
||||||
- Title, type, status, priority, assignee, epic key (or "None"), and a link
|
one, and Phase 2 step 9 fills its proof column back into the Jira field. The PM and
|
||||||
(`<JIRA_BASE_URL>/browse/<KEY>`).
|
Lead write the risks. Implementing and proving each is the developer's job.
|
||||||
- Description: convert `renderedFields.description` (HTML) to clean markdown. If
|
- Never substitute your own invented test cases or risks for the ticket's, in the plan
|
||||||
empty, say so.
|
or in the Jira fields. Your own additional cases are welcome alongside the ticket's
|
||||||
- Developer review instructions: the field name varies by project and isn't a fixed
|
rows, never in place of them.
|
||||||
custom field ID. Look at the `names` map in the response for any field whose name
|
6. If the ticket has an epic, seed or refresh its epic index as [SITE.md](SITE.md)
|
||||||
matches something like "dev review instructions" / "review instructions"
|
describes under **The epic index**. This is best-effort context: if the epic search
|
||||||
(case-insensitive substring match), then render that field's value the same way as
|
fails, say so and continue.
|
||||||
the description. If nothing matches, write "None provided" — don't guess a field.
|
7. Run `jira-download-attachments.sh <issue.raw.json> <target-dir>/resources/`. For each
|
||||||
- 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
|
|
||||||
attachment with a `video/*` mime type, check whether another attachment already looks
|
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
|
like its transcript (same base filename with `.txt`/`.srt`/`.vtt`, or a filename
|
||||||
containing "transcript"). Collect any videos with no matching transcript.
|
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
|
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`
|
approve, run `transcribe.sh <video-path> <target-dir>/resources/`.
|
||||||
under Attachments which videos have a transcript and which were skipped.
|
|
||||||
For every video that gets transcribed, also extract frames — ticket videos are
|
For every video that gets transcribed, also extract frames — ticket videos are
|
||||||
almost always screen recordings, and the transcript alone misses what was on
|
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
|
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
|
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
|
the transcript is. Skip frame extraction only when the video is confirmed
|
||||||
audio-only or the user says the visuals don't matter.
|
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
|
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
|
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
|
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
|
**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
|
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
|
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
|
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
|
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
|
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
|
flow being changed isn't already there to read, it isn't bounded. In doubt, take the
|
||||||
heavier path.
|
heavier path. Either way, record the classification with `ticket-page meta`.
|
||||||
11. Write `plan.md` in `<target-dir>/plan.md` (a sibling of `resources/`, not inside
|
11. Write the plan as `plan.json` in a scratch path, in the shape [SITE.md](SITE.md) gives,
|
||||||
it). Structure:
|
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;
|
||||||
# Plan: <KEY> — <short title>
|
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
|
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
|
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
|
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
|
approach. The test plan must name every test case on the Ticket tab and say how
|
||||||
that exact case gets proven, and the Risks section must name every risk
|
that exact case gets proven, and the risks must name every risk on the Ticket tab
|
||||||
`ticket.md` captured and say which code enforces its mitigation. Read the notes
|
and say which code enforces its mitigation. Read the notes column of a ticket test
|
||||||
column of a ticket test case as part of the case, not as commentary: a note like
|
case as part of the case, not as commentary: a note like "confirm the certificate
|
||||||
"confirm the certificate matches the emailed version" is its own thing to prove.
|
matches the emailed version" is its own thing to prove.
|
||||||
|
|
||||||
**Where the shape is in question, borrow the vocabulary.** When the plan has to
|
**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
|
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.
|
written for real.
|
||||||
|
|
||||||
**The Test plan is a design decision, not a formality.** Beyond the cases
|
**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
|
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
|
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
|
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
|
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
|
Approach that this is what you're doing and why, since the step count looks inflated
|
||||||
otherwise.
|
otherwise.
|
||||||
12. Stop here. Tell the user `plan.md` is ready at its path, summarize the approach in
|
12. When a step changes what a user sees, draw its mock artboards with `ticket-page mock`,
|
||||||
a couple of sentences, and mention any open questions that need their input before
|
as [SITE.md](SITE.md) describes under **Mocks**.
|
||||||
you'd implement it. Do not start implementing in this same run — wait for them to
|
13. Stop here. Tell the user the plan is on the ticket page, give its path from
|
||||||
review the plan (editing it directly if they want) and invoke `/ticket <KEY>` again.
|
`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
|
## 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
|
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
|
for the same ticket earlier, confirm you're still actually on that branch now (the
|
||||||
user may have switched branches between runs).
|
user may have switched branches between runs).
|
||||||
2. Read `plan.md` fresh — the user may have hand-edited it.
|
2. Read the plan fresh with `ticket-page md <KEY>`, then mark it approved with
|
||||||
3. Work through `plan.md`'s Steps checklist in order. For each step: implement it (a
|
`ticket-page approve <KEY>`.
|
||||||
step marked `(deliverable)` follows the **Deliverable documents** process above
|
3. Work through the plan's steps in order. For each step: implement it (a
|
||||||
instead of writing code; a step marked `(mock)` follows **Mocks and datamodel
|
`deliverable` step follows the **Deliverable documents** process above instead of
|
||||||
changes** — real schema/UI work, not a static wireframe), then check it off
|
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
|
||||||
(`- [x]`) in `plan.md`, then make one commit for it (or a few, if the step
|
a few, if the step naturally splits into independent units). Commit messages are short
|
||||||
naturally splits into independent units). Commit messages are short and imperative,
|
and imperative, describing what changed, following House style above. Mark the step
|
||||||
describing what changed, following House style above.
|
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
|
**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
|
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
|
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.
|
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
|
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).
|
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`,
|
verification (check `package.json` scripts for something like `ci:check`,
|
||||||
`typecheck`, `lint`, `test`, or fall back to whatever the repo's README documents).
|
`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
|
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.
|
change cannot provide it.
|
||||||
- **Give the reviewer its reading order explicitly**, or it produces noise about
|
- **Give the reviewer its reading order explicitly**, or it produces noise about
|
||||||
conventions the project already settled: the epic summary if there is one
|
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
|
(`.claude/docs/epics/<EPIC-KEY>/summary.md`), then the ticket and plan as
|
||||||
`plan.md`, then `AGENTS.md`, and only then the diff. Give it the exact commit range.
|
`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
|
- **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
|
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,
|
`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.
|
from checking.
|
||||||
- **Ask the spec questions explicitly**, or the review only finds bugs in what you
|
- **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
|
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;
|
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
|
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
|
- **Findings are not automatically true.** Verify each one against the codebase
|
||||||
before acting on it. Reviewers do produce confident wrong answers. Say plainly
|
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
|
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
|
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.
|
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.
|
- **Ready to Open MR**: continue to step 8.
|
||||||
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix
|
- **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
|
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:
|
Make each one count:
|
||||||
- **Feed it context on the first run.** It reviews the diff, and left alone it
|
- **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
|
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
|
already settled. Ask the user to point it at the ticket's `ticket.data.js`, or give
|
||||||
`resources/ticket.md` and `plan.md`. This is the cheapest thing that reduces
|
them the output of `ticket-page md <KEY>` to paste in. This is the cheapest thing that reduces
|
||||||
repeat findings.
|
repeat findings.
|
||||||
- **Answer its decisions explicitly in the rerun.** When it parks a finding
|
- **Answer its decisions explicitly in the rerun.** When it parks a finding
|
||||||
under "Decide before opening", write the decision and its reasoning into the
|
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.
|
- New ENVs: list any env vars actually introduced; "None" if none.
|
||||||
- Additional Notes: a bulleted checklist of the changes you actually made, not a
|
- Additional Notes: a bulleted checklist of the changes you actually made, not a
|
||||||
dumping ground. Between Summary and this section, every acceptance criterion from
|
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.
|
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
|
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
|
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.
|
(`customfield_10260`, "For The Engineer: Patch Notes"). Step 9 writes it.
|
||||||
Scattered coverage across notes does not count as having answered a named
|
Scattered coverage across notes does not count as having answered a named
|
||||||
requirement, and neither does burying it in the MR description.
|
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
|
**`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
|
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:
|
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 markdown with `python3 ~/.agents/skills/review-ticket/scripts/review2adf.py
|
||||||
render <file.md>` and PUT via `{"fields": {...}}` to `/rest/api/3/issue/<KEY>`.
|
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
|
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
|
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.
|
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
|
**Proof first.** Before filling any proof column, capture working feature proof
|
||||||
yourself wherever possible: run the app (`run` skill) and screenshot the real
|
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
|
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
|
(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
|
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
|
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
|
**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
|
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
|
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
|
- 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,
|
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`
|
ticket, Drive, or both, per the ticket's instructions). Fill `customfield_10260`
|
||||||
only if the spike actually shipped code.
|
only if the spike actually shipped code.
|
||||||
|
|
||||||
Before moving on, re-read `ticket.md` row by row: every test case and every risk
|
Before moving on, re-read the Ticket tab's test case and risk tables row by row: every
|
||||||
the ticket carries now has its developer column filled in Jira. Leave none
|
test case and every risk the ticket carries now has its developer column filled in
|
||||||
|
Jira. Leave none
|
||||||
unanswered.
|
unanswered.
|
||||||
10. Tell the user: implementation is committed on `<branch>`, `pre-mr-review` verdict is
|
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
|
Ready to Open MR, the Jira fields are filled (name which), `mr.md` is ready at its
|
||||||
its path. Then the manual list, which should only ever be:
|
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
|
- 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/`)
|
- upload the named proof or deliverable files (from `proof/` or `deliverables/`)
|
||||||
to the ticket, plus anything flagged as proof only Gib can capture
|
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
|
## Resuming mid-implementation
|
||||||
|
|
||||||
If Phase 0 routes here because some steps in `plan.md` are already checked off: check
|
If Phase 0 routes here because some steps are already done on the page: check
|
||||||
`git log` on the current branch against the plan's steps to sanity-check they actually
|
`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
|
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.
|
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>
|
||||||
@@ -55,26 +55,30 @@ Removing patterns is half the job. Sterile, voiceless writing is just as obvious
|
|||||||
18. **Decorative emojis.** Remove from headings and bullets.
|
18. **Decorative emojis.** Remove from headings and bullets.
|
||||||
19. **Curly quotes.** Replace with straight quotes.
|
19. **Curly quotes.** Replace with straight quotes.
|
||||||
|
|
||||||
|
### Spelling
|
||||||
|
|
||||||
|
20. **British spelling.** The author is American. Catch -our (behaviour, flavour, colour, favour, honour), -ise and -ised (recognise, organise, authorise, prioritise, summarise, initialise), -yse (analyse), -re (centre, metre), -logue (catalogue, dialogue), doubled l (travelling, labelled, modelling), and the odd ones (licence as a noun, judgement, programme, grey, whilst). Fix them to the American form. Two exceptions: a word that is the project's own established vocabulary, spelled that way in the client's documents, the ticket titles and the codebase alike (on APSCA, "enrolment" and "cancelled"), and an existing code identifier, enum value, event type or template key, which is never renamed to fix spelling. Check the codebase once before deciding a word is project vocabulary, and do not flip it back and forth.
|
||||||
|
|
||||||
### Communication artifacts
|
### Communication artifacts
|
||||||
|
|
||||||
20. **Chatbot phrases.** "I hope this helps!", "Let me know if...", "Of course!", "Certainly!", "Found the smoking gun!" Remove.
|
21. **Chatbot phrases.** "I hope this helps!", "Let me know if...", "Of course!", "Certainly!", "Found the smoking gun!" Remove.
|
||||||
21. **Cutoff disclaimers.** "While specific details are limited..." Find sources or remove.
|
22. **Cutoff disclaimers.** "While specific details are limited..." Find sources or remove.
|
||||||
22. **Sycophantic tone.** "Great question! You're absolutely right!" Respond directly.
|
23. **Sycophantic tone.** "Great question! You're absolutely right!" Respond directly.
|
||||||
|
|
||||||
### Filler
|
### Filler
|
||||||
|
|
||||||
23. **Filler phrases.** "In order to" becomes "To". "Due to the fact that" becomes "Because". "It is important to note that" gets deleted.
|
24. **Filler phrases.** "In order to" becomes "To". "Due to the fact that" becomes "Because". "It is important to note that" gets deleted.
|
||||||
24. **Excessive hedging.** "could potentially possibly be argued that it might" becomes "may".
|
25. **Excessive hedging.** "could potentially possibly be argued that it might" becomes "may".
|
||||||
25. **Generic conclusions.** "The future looks bright." State specific plans or facts.
|
26. **Generic conclusions.** "The future looks bright." State specific plans or facts.
|
||||||
|
|
||||||
### Jargon
|
### Jargon
|
||||||
|
|
||||||
26. **Abstract metaphor nouns.** Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's real name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word.
|
27. **Abstract metaphor nouns.** Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's real name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word.
|
||||||
|
|
||||||
### Plain speech
|
### Plain speech
|
||||||
|
|
||||||
27. **Say what it does, not how it feels.** "the database stays close at hand", "SQL you can read", "types that follow your schema" name a feeling. The fix names the mechanism or a number: "`.toSQL()` returns the exact string sent to the database", "a column rename fails the build". Ask what the sentence tells the reader to do or know, then write that. If you can't restate it as a concrete instruction, fact, or number, cut it. One more check: if the sentence could appear unchanged in another project's docs, it says nothing about this one. Cut it.
|
28. **Say what it does, not how it feels.** "the database stays close at hand", "SQL you can read", "types that follow your schema" name a feeling. The fix names the mechanism or a number: "`.toSQL()` returns the exact string sent to the database", "a column rename fails the build". Ask what the sentence tells the reader to do or know, then write that. If you can't restate it as a concrete instruction, fact, or number, cut it. One more check: if the sentence could appear unchanged in another project's docs, it says nothing about this one. Cut it.
|
||||||
28. **Shorten or split dense sentences.** If the reader has to backtrack to parse a sentence, break it in two or drop clauses. One idea per sentence.
|
29. **Shorten or split dense sentences.** If the reader has to backtrack to parse a sentence, break it in two or drop clauses. One idea per sentence.
|
||||||
29. **Active voice.** Prefer it. Catch "is/are/was/were + past participle" and name the actor: "queries are validated" becomes "the compiler validates queries", "the file is parsed by the loader" becomes "the loader parses the file". Passive is fine only when the actor is unknown or genuinely doesn't matter.
|
30. **Active voice.** Prefer it. Catch "is/are/was/were + past participle" and name the actor: "queries are validated" becomes "the compiler validates queries", "the file is parsed by the loader" becomes "the loader parses the file". Passive is fine only when the actor is unknown or genuinely doesn't matter.
|
||||||
30. **Cut adverbs, or use a stronger verb.** "runs quickly" becomes "is fast" or the number. "significantly improves" becomes the measured delta. An adverb propping up a weak verb means the verb is wrong.
|
31. **Cut adverbs, or use a stronger verb.** "runs quickly" becomes "is fast" or the number. "significantly improves" becomes the measured delta. An adverb propping up a weak verb means the verb is wrong.
|
||||||
31. **Prefer the plain word.** "utilize" becomes "use", "leverage" becomes "use", "facilitate" becomes "help", "numerous" becomes "many", "in the event that" becomes "if". The fancier synonym is rarely clearer.
|
32. **Prefer the plain word.** "utilize" becomes "use", "leverage" becomes "use", "facilitate" becomes "help", "numerous" becomes "many", "in the event that" becomes "if". The fancier synonym is rarely clearer.
|
||||||
|
|||||||
@@ -14,3 +14,10 @@ Host git.gbrown.org
|
|||||||
User git
|
User git
|
||||||
IdentityFile ~/.ssh/id_ed25519
|
IdentityFile ~/.ssh/id_ed25519
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
|
|
||||||
|
# Agent harnesses (Claude Code, Codex) start their shells without
|
||||||
|
# SSH_AUTH_SOCK, and id_ed25519 has a passphrase, so batch ssh from them fails
|
||||||
|
# with "Permission denied (publickey)" even though the key is right. The
|
||||||
|
# desktop keyring agent already holds the unlocked key; point every host at it.
|
||||||
|
Host *
|
||||||
|
IdentityAgent ${XDG_RUNTIME_DIR}/keyring/ssh
|
||||||
|
|||||||
Reference in New Issue
Block a user