← back to Lifestyle Asset Intel
snapshot: 6 file(s) changed, ~6 modified
06d377fac03d1b223cb35ee9757d621cd3e0ff1f · 2026-05-13 08:57:56 -0700 · Steve
Files touched
M .yolo/run-tick.shM .yolo/tick-prompt.mdM lib/openapi.jsM lib/valuation.jsM routes/api.jsM tests/smoke.test.js
Diff
commit 06d377fac03d1b223cb35ee9757d621cd3e0ff1f
Author: Steve <steve@designerwallcoverings.com>
Date: Wed May 13 08:57:56 2026 -0700
snapshot: 6 file(s) changed, ~6 modified
---
.yolo/run-tick.sh | 7 +++++--
.yolo/tick-prompt.md | 26 ++++++++++++++++++++++----
lib/openapi.js | 32 ++++++++++++++++++++++++++++++++
lib/valuation.js | 41 ++++++++++++++++++++++++++++++++++++++++-
routes/api.js | 9 ++++++++-
tests/smoke.test.js | 16 ++++++++++++++++
6 files changed, 123 insertions(+), 8 deletions(-)
diff --git a/.yolo/run-tick.sh b/.yolo/run-tick.sh
index 40407e4..dbdc016 100755
--- a/.yolo/run-tick.sh
+++ b/.yolo/run-tick.sh
@@ -33,8 +33,11 @@ trap 'rmdir "$LOCK_DIR" 2>/dev/null' EXIT
START=$(date -u +%FT%TZ)
echo "[$START] tick start" >> "$LOG"
-# Pipe the prompt to claude CLI, --print so it exits, --max-turns 12 to cap cost
-/Users/stevestudio2/.local/bin/claude --print --dangerously-skip-permissions --max-turns 12 < "$PROMPT" \
+# Pipe the prompt to claude CLI, --print so it exits, --max-turns 25.
+# Bumped from 12 → 25 after the 2026-05-10 08:14 fire blew its budget
+# mid-implementation. 25 leaves room for (read+plan) + (do work) +
+# (test+restart+commit) + (1-2 extra) without uncapped runaway.
+/Users/stevestudio2/.local/bin/claude --print --dangerously-skip-permissions --max-turns 25 < "$PROMPT" \
>> "$LOG" 2>&1
EXIT=$?
diff --git a/.yolo/tick-prompt.md b/.yolo/tick-prompt.md
index 668eb0b..ed80a80 100644
--- a/.yolo/tick-prompt.md
+++ b/.yolo/tick-prompt.md
@@ -43,11 +43,29 @@ TESTS: `PGDATABASE=lifestyle_asset_intel npm test` (currently 44/44 green)
- Add an /admin/audit HTML view over /api/audit/recent
- Methodology version bump trigger when breakdown weights change
+## Hard turn budget — BUDGET YOUR EFFORT
+
+You have 25 turns total. Burn-down plan:
+
+ turns 1-3 : read git log, ROADMAP, decide ONE small target
+ turns 4-15 : implement (read files, edit, write)
+ turns 16-19 : run npm test, restart pm2 if needed, fix small breakage
+ turns 20-22 : git add + git commit
+ turns 23-24 : log status, exit
+ turn 25 : EMERGENCY ONLY — already over budget, abort cleanly
+
+If you reach turn 18 without a working diff, REVERT and pick a smaller
+target. Do not keep digging.
+
+If tests are failing on entry to turn 16, revert your edits with
+`git checkout -- <files-you-edited>` (NEVER `git clean -fd` on tracked
+files). Better to ship nothing than to ship red. Other yolo ticks will
+pick the slack.
+
## Exit conditions
-- Tests fail and you can't fix in ≤6 turns → revert, log, exit.
-- Project in broken state → fix-first, no new features.
-- ≥8 turns elapsed → wrap up, commit if green, exit.
-- Done with the tick → exit cleanly so launchd can fire again next interval.
+- Tests fail after your changes and < 5 turns left → revert, log, exit.
+- Project in broken state on entry → fix-first only, no new features.
+- Done with one logical commit → exit cleanly so launchd can fire next.
KEEP IT SMALL. Many small ticks > one large stuck tick.
diff --git a/lib/openapi.js b/lib/openapi.js
index d66b7e5..1eb7f8a 100644
--- a/lib/openapi.js
+++ b/lib/openapi.js
@@ -334,6 +334,38 @@ function buildSpec({ version, gitSha }) {
}
}
},
+ '/api/family': {
+ get: {
+ summary: 'List every model family with cohort summary',
+ description:
+ 'Returns one row per model family: brand identity, asset_count, ' +
+ 'total_comps_24m (transactions in trailing 24 months), and avg_q50 ' +
+ '(unweighted mean of latest snapshot Q50 across the cohort). ' +
+ 'Ordered by brand name, then family name.',
+ tags: ['catalog'],
+ responses: {
+ 200: { description: 'OK', content: { 'application/json': { schema: {
+ type: 'object',
+ properties: {
+ count: { type: 'integer' },
+ families: { type: 'array', items: {
+ type: 'object',
+ properties: {
+ slug: { type: 'string' }, name: { type: 'string' },
+ brand: {
+ type: 'object',
+ properties: { slug: { type: 'string' }, name: { type: 'string' } }
+ },
+ asset_count: { type: 'integer' },
+ total_comps_24m: { type: 'integer' },
+ avg_q50: { type: 'number', nullable: true }
+ }
+ } }
+ }
+ } } } }
+ }
+ }
+ },
'/api/family/{slug}': {
get: {
summary: 'Cohort detail for a model family',
diff --git a/lib/valuation.js b/lib/valuation.js
index bee13f8..f9a4fc9 100644
--- a/lib/valuation.js
+++ b/lib/valuation.js
@@ -413,6 +413,45 @@ function computeExpectedDts(liquidity) {
return Math.round(clamp(60 - l * 50, 7, 120));
}
+// listFamilies() — index of every model family with a one-row cohort
+// summary (asset_count, total comps in trailing 24m, avg Q50). Powers
+// the /api/family (no slug) endpoint and any "browse by family" UI.
+// Single SQL pass — does NOT call listAssets() per family, so this
+// stays cheap as the catalog grows.
+async function listFamilies() {
+ const rows = await many(
+ `SELECT mf.slug, mf.name,
+ b.slug AS brand_slug, b.name AS brand_name,
+ COUNT(DISTINCT ca.id)::int AS asset_count,
+ COALESCE(SUM(stats.n_comps), 0)::int AS total_comps_24m,
+ AVG(vs.q50)::numeric AS avg_q50
+ FROM model_families mf
+ JOIN brands b ON b.id = mf.brand_id
+ LEFT JOIN canonical_assets ca ON ca.model_family_id = mf.id
+ LEFT JOIN LATERAL (
+ SELECT q50 FROM valuation_snapshots vs
+ WHERE vs.canonical_asset_id = ca.id
+ ORDER BY vs.as_of DESC LIMIT 1
+ ) vs ON true
+ LEFT JOIN LATERAL (
+ SELECT COUNT(*)::int AS n_comps
+ FROM transactions t
+ WHERE t.canonical_asset_id = ca.id
+ AND t.transacted_at > now() - interval '24 months'
+ ) stats ON true
+ GROUP BY mf.slug, mf.name, b.slug, b.name
+ ORDER BY b.name, mf.name`
+ );
+ return rows.map((r) => ({
+ slug: r.slug,
+ name: r.name,
+ brand: { slug: r.brand_slug, name: r.brand_name },
+ asset_count: r.asset_count,
+ total_comps_24m: r.total_comps_24m,
+ avg_q50: r.avg_q50 != null ? +parseFloat(r.avg_q50).toFixed(0) : null
+ }));
+}
+
// getFamily(slug) — cohort detail. Returns the family + brand + the
// list of canonical assets (with their live snapshot values) plus a
// summary block (count, weighted-avg Q50, total comps in trailing 24m,
@@ -521,6 +560,6 @@ function quantile(sorted, q) {
}
module.exports = {
- valueAsset, listAssets, getIndex, getFamily,
+ valueAsset, listAssets, getIndex, getFamily, listFamilies,
computeBreakdown, computeLiquidity, computeExpectedDts, quantile
};
diff --git a/routes/api.js b/routes/api.js
index b2deee0..21dfb8a 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -3,7 +3,7 @@ const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { pool } = require('../lib/db');
-const { valueAsset, listAssets, getIndex, getFamily } = require('../lib/valuation');
+const { valueAsset, listAssets, getIndex, getFamily, listFamilies } = require('../lib/valuation');
const {
isValidEmail,
listHoldings,
@@ -145,6 +145,13 @@ router.get('/valuation/:slug', setCache(NO_STORE), async (req, res, next) => {
} catch (e) { next(e); }
});
+router.get('/family', setCache(PUB_300), async (req, res, next) => {
+ try {
+ const families = await listFamilies();
+ res.json({ count: families.length, families });
+ } catch (e) { next(e); }
+});
+
router.get('/family/:slug', setCache(PUB_300), async (req, res, next) => {
try {
const out = await getFamily(req.params.slug);
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index 1324e36..ba68505 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -179,6 +179,22 @@ test('GET /api/family/birkin returns cohort summary + assets', async () => {
assert.ok(Array.isArray(r.json.indices));
});
+test('GET /api/family lists every model family with cohort summary', async () => {
+ const r = await get('/api/family');
+ assert.equal(r.status, 200);
+ assert.ok(typeof r.json.count === 'number');
+ assert.ok(Array.isArray(r.json.families));
+ assert.ok(r.json.families.length >= 2, 'at least Birkin + Kelly seeded');
+ const slugs = r.json.families.map((f) => f.slug);
+ assert.ok(slugs.includes('birkin'), 'birkin family present');
+ assert.ok(slugs.includes('kelly'), 'kelly family present');
+ const birkin = r.json.families.find((f) => f.slug === 'birkin');
+ assert.equal(birkin.brand.slug, 'hermes');
+ assert.equal(birkin.brand.name, 'Hermès');
+ assert.ok(birkin.asset_count >= 6);
+ assert.ok(typeof birkin.total_comps_24m === 'number');
+});
+
test('GET /api/family/<unknown> returns 404', async () => {
const r = await get('/api/family/nonexistent');
assert.equal(r.status, 404);
← 447537f yolo tick #16: exotic / special-edition badges + audit-test
·
back to Lifestyle Asset Intel
·
yolo tick #17: /api/assets sort param (value/liquidity/comps 1df0cc4 →