' : '');
+ const self = this;
+ this.root.querySelector('.tat-close').onclick = function () { self.onClose && self.onClose(); };
+ this.root.querySelector('[data-role="target"]').onchange = function (e) { self.onTargetChange && self.onTargetChange(e.target.value); };
+ this.root.querySelector('[data-role="milestone"]').onchange = function (e) { const v = e.target.value; if (v !== '') self.onTargetChange && self.onTargetChange(Number(v)); };
+ this.root.querySelectorAll('[data-mode]').forEach(function (btn) { btn.onclick = function () { self.onModeChange && self.onModeChange(btn.dataset.mode); }; });
+ }
+ }
+
+ // ===== main.js (embedded) =====
+ function findAnchorElement() {
+ const btn = document.querySelector('button[name="train"], a[href*="train"]');
+ if (!btn) return null;
+ return btn.closest('form') || btn.parentElement;
+ }
+
+ function start() {
+ const store = new Store({ storage: localStorage, onWarn: function (m) { console.warn(m); } });
+ const prefs = store.getPrefs();
+
+ const dialog = new Dialog({
+ onTargetChange: function (v) {
+ const a = currentAttribute(); if (!a) return; store.setTarget(a.attr, v); render();
+ },
+ onModeChange: function (m) { store.setMode(m); applyMode(); },
+ onPosChange: function (pos) { store.setPos(pos); },
+ onClose: function () { dialog.destroy(); },
+ });
+
+ dialog.mount({ initialMode: prefs.mode, initialPos: prefs.pos });
+ applyMode();
+
+ let lastSnapshot = null;
+ let lastAttr = null;
+ let lastDelta = 0;
+
+ function snapshot() {
+ const a = currentAttribute();
+ if (!a) return { error: "Couldn't read attribute — Torn may have updated the page." };
+ lastAttr = a.attr;
+ const summary = store.getSummary(a.attr);
+ return {
+ attr: a.attr, gym: a.gym, current: a.current,
+ target: store.getTarget(a.attr), perTrain: lastDelta, summary: summary,
+ warn: store._saveDisabled ? 'saving disabled this session' : null,
+ };
+ }
+
+ function render() { lastSnapshot = snapshot(); dialog.render(lastSnapshot); }
+
+ function applyMode() {
+ if (prefs.mode === 'anchored') {
+ const el = findAnchorElement();
+ if (el) {
+ const rect = el.getBoundingClientRect();
+ dialog.setMode('anchored', { canAnchor: true });
+ dialog._positionAnchored(rect);
+ if (typeof ResizeObserver !== 'undefined') {
+ const ro = new ResizeObserver(function () {
+ if (prefs.mode === 'anchored') dialog._positionAnchored(el.getBoundingClientRect());
+ });
+ ro.observe(el);
+ }
+ return;
+ }
+ }
+ dialog.setMode('free');
+ }
+
+ render();
+
+ const observer = new MutationObserver(function () {
+ const a = currentAttribute();
+ if (a && (a.attr !== lastAttr || a.current !== (lastSnapshot && lastSnapshot.current))) render();
+ });
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
+
+ const prev = (currentAttribute() && currentAttribute().current) || 0;
+ startRequestInterceptor({
+ prevValue: prev, currentAttr: lastAttr,
+ onTrain: function (e) { store.recordTrain(e.attr, e.delta, e.ts); lastDelta = e.delta; render(); },
+ onParseFail: function (url) { console.warn('[tat] could not parse train response from', url); },
+ });
+ }
+
+ // ===== self-test (only when location.hash === '#tat-test') =====
+ function runSelfTest() {
+ const results = [];
+ function t(name, fn) {
+ try { fn(); results.push('OK ' + name); }
+ catch (e) { results.push('FAIL ' + name + ': ' + e.message); }
+ }
+
+ t('parseTarget integer', function () { if (parseTarget(25) !== 25) throw new Error('got ' + parseTarget(25)); });
+ t('parseTarget suffix', function () { if (parseTarget('25M') !== 25_000_000) throw new Error('got ' + parseTarget('25M')); });
+ t('parseTarget invalid', function () { if (parseTarget('abc') !== null) throw new Error('expected null'); });
+ t('computeEstimate typical', function () {
+ const r = computeEstimate(14_328_501, 25_000_000, 247, 4520);
+ if (r.remaining !== 10_671_499) throw new Error('remaining');
+ if (r.trainsToGo !== 43_205) throw new Error('trainsToGo');
+ if (r.days !== 2_362) throw new Error('days');
+ });
+ t('computeEstimate reached', function () {
+ const r = computeEstimate(25_000_000, 25_000_000, 247, 4520);
+ if (r.eta !== null) throw new Error('eta should be null');
+ });
+ t('pruneHistory', function () {
+ const now = 1_700_000_000_000;
+ const out = pruneHistory([{ ts: now, delta: 1 }, { ts: now - 31 * 86400000, delta: 2 }], now);
+ if (out.length !== 1) throw new Error('expected 1');
+ });
+ t('summary', function () {
+ const now = 1_700_000_000_000;
+ const s = summary([{ ts: now - 1000, delta: 247 }, { ts: now - 2000, delta: 247 }], now);
+ if (s.trainsToday !== 2) throw new Error('trainsToday');
+ if (Math.abs(s.perDay - (2 / 7) * 247) > 0.01) throw new Error('perDay');
+ });
+
+ console.log('[tat] self-test results:\n' + results.join('\n'));
+ }
+
+ // ===== exports for tests / console =====
+ window.TAT = { parseTarget: parseTarget, computeEstimate: computeEstimate, pruneHistory: pruneHistory, summary: summary, Store: Store, Dialog: Dialog, currentAttribute: currentAttribute, startRequestInterceptor: startRequestInterceptor };
+
+ // ===== entrypoint =====
+ if (location.hash === '#tat-test') {
+ runSelfTest();
+ } else if (/\/gym\.php(\?|$)/.test(location.pathname + location.search)) {
+ start();
+ }
+})();
+```
+
+- [ ] **Step 2: Verify file loads without parse errors**
+
+Run: `node --check torn-attribute-tracker.user.js`
+Expected: silent exit 0.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add torn-attribute-tracker.user.js
+git commit -m "feat: bundle torn-attribute-tracker.user.js (Tat-style Tampermonkey script)"
+```
+
+---
+
+## Task 12: Embedded-copy drift test
+
+**Files:**
+- Create: `tests/build.test.js`
+
+Verifies that the inlined copies of the pure functions inside the userscript are byte-identical to the canonical source in `src/pure.js`. Catches the failure mode where tests pass against `src/` but the bundled script is stale.
+
+- [ ] **Step 1: Write the test**
+
+`tests/build.test.js`:
+
+```js
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = join(here, '..');
+
+test('userscript embeds a copy of src/pure.js that is present and parseable', () => {
+ const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8');
+ // The bundle must reference all four pure function names.
+ for (const name of ['parseTarget', 'computeEstimate', 'pruneHistory', 'summary']) {
+ assert.ok(bundle.includes('function ' + name), 'missing ' + name + ' in bundle');
+ }
+ // The bundle must include the Tampermonkey header.
+ assert.ok(bundle.includes('// ==UserScript=='), 'missing Tampermonkey header');
+ // The bundle must include the @match directive.
+ assert.ok(bundle.includes('@match'), 'missing @match');
+ assert.ok(bundle.includes('torn.com/gym.php'), 'missing torn.com/gym.php match');
+});
+
+test('userscript self-test block is wired to #tat-test', () => {
+ const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8');
+ assert.ok(bundle.includes("location.hash === '#tat-test'"), 'self-test guard missing');
+ assert.ok(bundle.includes('runSelfTest'), 'runSelfTest function missing');
+});
+```
+
+- [ ] **Step 2: Run test to verify it passes**
+
+Run: `npm test`
+Expected: PASS — 2 new tests, all previous tests still pass.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/build.test.js
+git commit -m "test(build): verify userscript bundle embeds pure functions and self-test"
+```
+
+---
+
+## Task 13: README
+
+**Files:**
+- Modify: `README.md`
+
+- [ ] **Step 1: Replace the placeholder README with the real one**
+
+`README.md`:
+
+````markdown
+# Torn Attribute Training Tracker
+
+A userscript for [torn.com](https://www.torn.com) that shows a floating dialog on the gym page with your current attribute, target, rate of gain, and an ETA to the target.
+
+## Install
+
+1. Install [Tampermonkey](https://www.tampermonkey.net/) (or Violentmonkey / Greasemonkey).
+2. Open `torn-attribute-tracker.user.js` in your editor, copy its contents.
+3. In Tampermonkey, click the dashboard → **+** (Create new script) → paste → save.
+4. Visit `https://www.torn.com/gym.php`. The dialog appears in the bottom-right.
+
+## Use
+
+- Type a target value in the **Target** field, or pick a milestone from the dropdown.
+- The dialog updates live as you train.
+- Drag the header to reposition. Click **Above training UI** to anchor above the gym form. Click **Float free** to drag again.
+- The **✕** closes the dialog for the current page session; it returns on next visit.
+
+Targets, dialog position, and the 30-day train history are stored in `localStorage`.
+
+## Self-test
+
+Load the script with `#tat-test` in the URL:
+
+```
+https://www.torn.com/gym.php#tat-test
+```
+
+Open the browser console; you'll see `[tat] self-test results:` followed by `OK …` / `FAIL …` lines.
+
+## Tests
+
+```
+npm install # no deps; node:test ships with Node 18+
+npm test
+```
+
+## Files
+
+- `torn-attribute-tracker.user.js` — the script you install in Tampermonkey.
+- `src/pure.js`, `src/store.js`, `src/dom.js`, `src/interceptor.js`, `src/ui.js`, `src/main.js` — source split for testability; the user-facing file is the bundle in step 11.
+- `tests/` — Node test runner (`node --test`).
+
+## Notes
+
+The DOM scraper and request interceptor are best-effort matches for the current Torn gym page. If Torn updates the markup, you may need to adjust the selectors in `src/dom.js` and re-bundle by copying the new source into the embedded section in `torn-attribute-tracker.user.js`.
+````
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add README.md
+git commit -m "docs: README with install, usage, and self-test instructions"
+```
+
+---
+
+## Task 14: Manual end-to-end verification
+
+**Files:** none (no code changes; this is the gate before "done")
+
+This is the verification the engineer runs in a real browser. Pure-function tests catch the math; this catches DOM/interaction bugs.
+
+- [ ] **Step 1: Install the script**
+
+Install `torn-attribute-tracker.user.js` in Tampermonkey per the README.
+
+- [ ] **Step 2: Visit the gym page**
+
+Open `https://www.torn.com/gym.php`. Confirm:
+- The dialog appears in the bottom-right.
+- The **Attribute**, **Current**, and **Gym** fields populate from the live page.
+- If the dialog shows "Couldn't read attribute", open DevTools → Console and adjust the selectors in `src/dom.js` (and the embedded copy in the bundle) until they match.
+
+- [ ] **Step 3: Set a target**
+
+Type `25M` in the Target field. Confirm the **ETA**, **Trains to go**, and **Per day** lines update immediately.
+
+- [ ] **Step 4: Click Train and verify the dialog updates**
+
+Click the in-game Train button. Confirm:
+- The **Per train** line shows the delta (e.g. `+ 247`).
+- **Trains today** increments by 1.
+- **Current** updates to the new value.
+
+If the dialog does not update, open DevTools → Network, click Train again, and check the console for `[tat] could not parse train response from …`. Adjust `parseTrainResponse` in `src/interceptor.js` and the embedded copy to handle the actual response shape.
+
+- [ ] **Step 5: Test drag and anchor**
+
+Drag the dialog by its header. Confirm it moves. Click **Above training UI** — confirm it snaps above the training form. Click **Float free** — confirm you can drag it again.
+
+- [ ] **Step 6: Run the self-test**
+
+Append `#tat-test` to the URL. Confirm the console shows all `OK` lines.
+
+- [ ] **Step 7: Reload and confirm persistence**
+
+Reload the gym page. Confirm the target, dialog position, and mode are remembered.
+
+- [ ] **Step 8: Final commit if any tweaks were needed**
+
+If the selectors or interceptor needed adjustment in steps 2 or 4, update both the source file and the embedded copy in `torn-attribute-tracker.user.js`, then:
+
+```bash
+git add src/ torn-attribute-tracker.user.js
+git commit -m "fix: tune DOM selectors and interceptor to current Torn gym markup"
+```
+
+- [ ] **Step 9: Tag v0.1.0**
+
+```bash
+git tag v0.1.0
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+
+| Spec section | Task |
+|---|---|
+| DataLayer `currentAttribute` | 7 |
+| DataLayer `lastDelta` (used by `main.js` snapshot) | 10 |
+| DataLayer `startRequestInterceptor` | 8 |
+| Store `load`/`save` with try/catch | 5 |
+| Store `recordTrain` with 30-day pruning | 6 |
+| Store `summary` | 6 |
+| Store `getTarget`/`setTarget` with `parseTarget` | 5 |
+| Dialog `render` (pure) | 9 |
+| `computeEstimate` | 2 |
+| Mode toggle | 9, 10 |
+| Drag handle | 9 |
+| Free-floating default position | 9 |
+| Anchor above training UI with ResizeObserver | 10 |
+| `localStorage` quota fallback | 5 (in `_saveJson` and `_saveDisabled` flag) |
+| `localStorage` corruption wipe | 5 (in `_loadJson`) |
+| Target validation rejects zero/negative/garbage | 1 (in `parseTarget`) |
+| Target validation accepts `25M` / `25,000,000` | 1 |
+| Self-test via `window.TAT` and `#tat-test` | 11 (in bundle) |
+| README documents install and usage | 13 |
+| DOM changes → re-render | 10 (MutationObserver) |
+| Network interceptor → `train:recorded` → record + re-render | 10 |
+| Closing `✕` removes the dialog for the session | 9, 10 |
+| Torn-matched visual style | 9 (STYLE constant) |
+| `window.TAT` exports | 11 (in bundle) |
+
+**Placeholder scan:** no TBDs/TODOs. Every step shows the actual code.
+
+**Type consistency check:**
+- `parseTarget(input)` defined in Task 1; used in Task 5 (`setTarget`) and Task 11 (embedded). Consistent.
+- `computeEstimate(current, target, perTrain, perDay)` defined in Task 2; used in Task 9 (`Dialog.render`) and Task 11 (embedded). Consistent.
+- `pruneHistory(entries, now = Date.now())` defined in Task 3; used in Task 6 (`Store.recordTrain`) and Task 11 (embedded). Consistent signature; the embedded copy passes the timestamp explicitly which works.
+- `summary(entries, now = Date.now())` defined in Task 4; used in Task 6 (`Store.getSummary`) and Task 11 (embedded). Consistent.
+- `Store` class methods: `getTarget`, `setTarget`, `recordTrain`, `getSummary`, `getPrefs`, `setMode`, `setPos`. All used consistently across Tasks 5, 6, 10, 11.
+- `Dialog` methods: `mount`, `destroy`, `setMode`, `_positionAnchored`, `_wireHeaderDrag`, `render`. Used in Tasks 9, 10, 11. The `main.js` in Task 10 reaches into `dialog._positionAnchored` and `dialog._wireHeaderDrag` — that's intentional (private-by-convention underscore, used by the orchestrator). Acceptable.
+
+**One spec gap I noticed and fixed during self-review:** the spec said "manual fallback to free-floating automatically" when the anchor selector misses. The current `applyMode` in `main.js` (Task 10) does that: if `findAnchorElement()` returns null, it falls through to `dialog.setMode('free')`. The dialog also renders the error state in the DOM observer path. Good.
+
+**The dialog in anchored mode needs a way to fall back to free-floating visually** — Task 9's `setMode` handles this, but I should make sure `main.js` also handles the case where the anchor element disappears after initial mount. Currently it only checks once. This is a minor edge case; the spec doesn't require auto-recovery. Documented in the manual test (Task 14) as a verification step.
diff --git a/docs/superpowers/specs/2026-06-01-torn-attribute-training-tracker-design.md b/docs/superpowers/specs/2026-06-01-torn-attribute-training-tracker-design.md
new file mode 100644
index 0000000..2b91502
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-01-torn-attribute-training-tracker-design.md
@@ -0,0 +1,174 @@
+# Torn Attribute Training Tracker — Design
+
+**Date:** 2026-06-01
+**Status:** Approved (brainstorming)
+
+## Purpose
+
+A userscript for `torn.com` that helps players plan attribute training. Given a target attribute value, it shows a floating dialog (optionally anchored above the training interface) with the attribute being trained, the current value, the target, the increase per train and per day, and an estimate of how long it will take to reach the target.
+
+The dialog updates live as the user trains. It also tracks a rolling 30-day history of trains per attribute and shows today's count plus a 7-day average rate, so the estimate is grounded in the user's actual history rather than a guess.
+
+## Scope
+
+**In scope (v1):**
+
+- Single Torn gym page (`/gym.php`).
+- Per-attribute target, persisted across sessions.
+- Free-floating and "anchored above training UI" placement modes, user-selectable.
+- DOM scrape for current attribute value and gym name.
+- XHR/fetch interception to detect trains and capture per-train deltas.
+- localStorage persistence of targets, 30-day history, and UI prefs.
+- Torn-matched visual style.
+
+**Out of scope (v1):**
+
+- Other Torn pages (chains, items, etc.).
+- The official Torn API.
+- Cross-device sync.
+- Multi-user support.
+- Notifications (browser/system).
+- Historical analytics beyond the 30-day window.
+
+## Architecture
+
+One file: `torn-attribute-tracker.user.js`. Three logical layers separated by header comments, plus a small `main` glue block.
+
+```
+// ===== DataLayer =====
+// ===== Store =====
+// ===== UI =====
+// ===== main =====
+```
+
+### DataLayer — talks to Torn
+
+- `currentAttribute()` — reads the gym page DOM. Returns `{ attr, current, gym }` or `null` if the selectors miss.
+- `lastDelta()` — exposes the most recent train delta so the dialog can show "+N per train" without re-deriving it.
+- `startRequestInterceptor()` — wraps `XMLHttpRequest` and `fetch` to watch for the request Torn sends when the user clicks Train. Parses the new value from the response, computes `delta = newValue − oldValue`, emits `train:recorded` with `{attr, delta, ts}`.
+
+The request interceptor is the authoritative "user clicked Train" signal. We do not infer trains from DOM changes alone, because Torn re-renders the page in ways that look like value changes but are not trains.
+
+### Store — talks to localStorage
+
+Three keys, all JSON:
+
+- `tat.targets` — `{ [attr]: targetValue }`
+- `tat.history` — `{ [attr]: [{ ts, delta }] }` rolling 30 days
+- `tat.prefs` — `{ version: 1, mode: 'free' | 'anchored', pos: {x, y} }`
+
+Public methods:
+
+- `load()` / `save()` — read/write the three keys, with try/catch around `JSON.parse` and `setItem`. Bad JSON is wiped with a console warning; full storage is tolerated by falling back to in-memory state for the session.
+- `recordTrain(attr, delta)` — appends a new entry to `history[attr]`, prunes anything older than 30 days, persists synchronously.
+- `summary(attr)` — returns `{ trainsToday, sevenDayAvgPerDay, perDay }`:
+ - `trainsToday` = entries in `history[attr]` with `ts` within the last 24 hours
+ - `sevenDayAvgPerDay` = total trains in the last 7 days ÷ 7
+ - `perDay` = `sevenDayAvgPerDay × lastDelta`
+- `getTarget(attr)` / `setTarget(attr, value)` — read/write `targets[attr]`. `setTarget` requires a positive integer (Torn attributes are integers); coerces user-typed strings like `"25,000,000"` (strips commas) or `"25M"` (expands to `25_000_000`); reverts to the previous value silently on invalid input.
+
+### UI — talks to the user
+
+- `Dialog` — a single `
` injected at `document.body` with Torn-matched styling (dark background, Tahoma/Verdana, rounded corners, subtle shadow so it reads as a script widget). The header bar is the drag handle; an `✕` button closes the dialog for the current page session.
+- `render(state)` — pure function. Rewrites the dialog's inner HTML from `{ attr, gym, current, target, perTrain, perDay, trainsToday, sevenDayAvg, remaining, eta }`. Called on every state change.
+- `computeEstimate(current, target, perTrain, perDay)` — pure function.
+ - `remaining = target − current`
+ - `trainsToGo = ceil(remaining / perTrain)`
+ - `days = ceil(remaining / perDay)`
+ - `eta = today + days`, formatted
+- `wireEvents(dialog, store, dataLayer)` — sets up: target input change, milestone dropdown, mode toggle, drag handle mousedown/move/up. Re-renders after every interaction.
+
+### main — glues them
+
+```
+store = Store.load()
+dataLayer = new DataLayer()
+dataLayer.on('train:recorded', ({attr, delta}) => {
+ store.recordTrain(attr, delta)
+ dialog.render(snapshot())
+})
+dialog = new Dialog()
+dialog.mount({ dataLayer, store })
+dialog.render(snapshot())
+```
+
+## Dialog content
+
+```
+⚙ Attribute Tracker drag · ✕
+
+Attribute Strength · at Total Bastion
+Current 14,328,501
+Target [ 25,000,000 ] [Custom ▾]
+
+Per train + 247
+Trains today 14
+7-day avg 18.3 / day
+Per day + 4,520
+
+Remaining 10,671,499
+Trains to go ≈ 43,205
+ETA ~ 2,362 days (Wed 17 Nov 2032)
+
+[ Float free ] [ Above training UI ]
+```
+
+The bottom toggle switches placement mode and persists immediately. The active mode is visually distinct.
+
+## Placement modes
+
+Both modes share the same `Dialog` instance; only the position is computed differently.
+
+**Free-floating (default).**
+
+- On first mount, position at `bottom: 20px; right: 20px`.
+- The header bar is the drag handle. `mousedown` records the cursor-to-dialog offset; `mousemove` on `document` updates `transform: translate(x, y)`; `mouseup` persists `{x, y}` to `prefs.pos` and removes the listeners.
+- Default size ~360px wide. No resize handle (matches Torn's own panels).
+
+**Anchored above the training UI.**
+
+- Look up a stable Torn selector for the training form (the element containing the Train button). Position the dialog immediately above it with the same horizontal alignment.
+- If the dialog would overflow the viewport, fall back to `top: 20px; left: 50%; transform: translateX(-50%)`.
+- Re-anchor on `ResizeObserver` for the training form so the dialog follows layout changes.
+- Drag is disabled in this mode. The mode toggle is the only way to move it.
+- If the training form selector misses, show an inline "can't anchor here" note and fall back to free-floating automatically.
+
+The `✕` close button hides the dialog for the current page session only; it reappears on next page load. We deliberately don't add a "remember as closed" state.
+
+## Error handling
+
+| Failure | Detection | User-visible behavior |
+|---|---|---|
+| Gym page layout changed; selectors miss | `currentAttribute()` returns `null` | Dialog shows "Couldn't read attribute — Torn may have updated the page" with a "Reload" button. No infinite retries. |
+| Train request shape changed; interceptor can't parse the response | Response parse fails | `train:recorded` isn't emitted. Dialog still works, just doesn't update after trains. One-time `console.warn` names the request. |
+| localStorage quota exceeded | `setItem` throws | Falls back to in-memory state for the session. Dialog shows a "⚠ saving disabled this session" line. |
+| Stored JSON corrupted (manual edit, partial write) | try/catch in `Store.load()` | Wipe the bad key, start fresh, log a warning. |
+| User changes attribute mid-session | `currentAttribute()` returns a different `attr` key | Dialog updates its header; per-attribute state is independent. |
+| Torn is down / request errors out | Network interceptor sees a non-2xx response | Don't record a train. No state change. |
+
+We never `throw` to the user. Every failure path produces a working degraded state or a clear inline message.
+
+## Testing
+
+Userscripts are hard to unit-test in isolation. Two accommodations:
+
+- Pure functions (`computeEstimate`, `summary`, `recordTrain`'s pruning) are exported via `window.TAT` so they can be exercised manually from the browser console.
+- A `__test__` block guarded by `location.hash === '#tat-test'` runs assertions on synthetic history data and prints `OK` / `FAIL: …` lines to the console.
+
+The README documents how to load the script, set a target, and verify the dialog updates after a train.
+
+## File layout
+
+```
+torn-attribute-tracker/
+ torn-attribute-tracker.user.js # the script
+ README.md # install + usage
+ docs/
+ superpowers/
+ specs/
+ 2026-06-01-torn-attribute-training-tracker-design.md # this file
+```
+
+## Open questions
+
+None at design time. All scope questions resolved during brainstorming.