diff --git a/docs/COTTAGE_FEED.md b/docs/COTTAGE_FEED.md index cb22058..051a490 100644 --- a/docs/COTTAGE_FEED.md +++ b/docs/COTTAGE_FEED.md @@ -276,6 +276,9 @@ Old `{ "number", "url", "title", "state" }` objects continue to work. Omitting ` | `reviewedHeadSha` | string | Head covered by recorded review/finalization evidence. | | `source` | string | Evidence source, such as `github`, `finalization`, or the feed's own adapter name. | | `checkedAt` | number \| null | Time the evidence was actually checked. Refresh it only after a successful check. | +| `openedAt` | number \| string \| null | When the PR opened (epoch ms or ISO). Used for decorative parcel-age scenery; the bundled GitHub adapter maps `createdAt` here. Without it, a live cottage falls back to other clocks and may stay visually fresh. | +| `mergedAt` | number \| string \| null | Optional merge time when known. | +| `closedAt` | number \| string \| null | Optional close time when known. | | `stale` | boolean | Evidence is out of date or its source failed. | | `reason` | string | Short explanation of uncertainty or failure. | | `isDraft` | boolean | A draft cannot be ready to merge. | diff --git a/docs/superpowers/specs/2026-09-15-village-folklore-design.md b/docs/superpowers/specs/2026-09-15-village-folklore-design.md new file mode 100644 index 0000000..9941334 --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-village-folklore-design.md @@ -0,0 +1,38 @@ +# CottageCode: village folklore gags + +Approved for implementation 2026-09-15. Real fleet state, read as village scenery. Decorative only: never change occupancy, feed status, or hide Talk / review desk / PR access (same rule as bedtime). + +## Chosen gags + +1. **Haunted cottages** — settled (and especially long-offline) houses go feral. +2. **Branch lanes** — yard dirt and weeds on the existing spur; not a second path system and never town↔town roads. +3. **Nature reclaims the queue** — neglected parcels and letters attract moss, nests, shrines, and crows; failing checks get a pocket storm. + +## Classifiers (`src/folklore.mjs`) + +Pure functions over observed feed fields. Missing data stays none / unknown. + +| Helper | Stages / values | Signal | +|---|---|---| +| `hauntStage(agent, now)` | `none` → `cobweb` → `ivy` → `ruins` | `occupancy === "settled"` immediately cobwebs; `status === "offline"` and age ≥ 6h → ivy; age ≥ 24h → ruins. Age from `endedAt` or `updatedAt`. | +| `branchLane(agent)` | `{ kind: "unknown"\|"default"\|"feature", weeds: boolean }` | `defaultBranch` or common defaults (`main`/`master`/`trunk`/`develop`) → default lane; other non-empty branch → feature. Weeds when feature and settled. | +| `parcelReclaim(agent, now)` | `none` → `fresh` → `moss` → `nest` → `shrine` | Only when `hasOutstandingPr`. Wait age from earliest trustworthy clock: `pr.openedAt`, else `endedAt`, else `updatedAt`. Thresholds: fresh < 2h, moss < 12h, nest < 48h, else shrine. Unknown / missing PR → `none` (lawn stays clean). Stale GitHub evidence must not look merged. | +| `letterNeglect(agent, now)` | `none` → `pile` → `crows` | Letter cottages only (`occupancy !== "settled"` and blocked task or blocked PR). Age ≥ 2h → crows. | +| `checkWeather(agent)` | `clear` → `storm` → `unknown` | `prCi` failing → storm; unavailable → unknown; else clear. | + +## Painting + +- **House:** cobwebs in corners; ivy + slightly crooked chimney at ivy+; ruins deepen ivy and dim further. Pale window ghost only at night when haunt ≥ cobweb and status is not working — never a warm working pane. +- **Interior:** light dust / cobweb accents in `paintRoomAtmosphere` when haunt ≥ cobweb; request board and review desk stay reachable. +- **Branch lane:** tiny weeds along the door-to-lane path for feature branches; denser when `weeds`. No regrouping of cottages by ref. Relationship paths unchanged. +- **Parcel stand:** moss tint, nest beside post, then tiny shrine; stand remains the same PR hit target. +- **Mailbox / Jack stoop:** overflow stack already exists; crows perch when any letter is at `crows`. Letter counts unchanged. +- **Storm:** small cloud over the roof when checks are failing only. + +## Demo fixtures + +Demo seed includes at least: one long-offline settled cottage (ruins-ready), one feature-branch settled cottage with weeds, one long-outstanding open PR parcel (moss+), one long-blocked letter (crows), and one failing-checks cottage (storm). Toggle settled to tour haunted houses. + +## Verification + +Unit tests for every classifier edge (missing clocks, unknown PR, default branch, non-letter). Browser smoke: `/?demo=1`, show settled, confirm scenery without blocked Talk/PR. \ No newline at end of file diff --git a/src/atmosphere.mjs b/src/atmosphere.mjs index f0c93d8..1130cb9 100644 --- a/src/atmosphere.mjs +++ b/src/atmosphere.mjs @@ -1,4 +1,6 @@ /** Decorative light only: no task data, status, collision, or geometry changes. */ +import { hauntStage, paintHauntGhost, paintRoomDust } from './folklore.mjs'; + const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value)); const finite = value => typeof value === 'number' && Number.isFinite(value); const mix = (a, b, t) => a + (b - a) * t; @@ -191,6 +193,7 @@ function windowLight(p, x, y, agent, opacity, state, style, small = false) { p(x + side, y + 1, side ? 4 : 3, 1, color([73, 88, 113], style.nightness * .8)); p(x + side, y + 5, side ? 4 : 3, 1, color([22, 32, 51], style.nightness)); } + if (hauntStage(agent) !== 'none') paintHauntGhost(p, x, y, style.nightness); } return working && strength > .001; } @@ -259,7 +262,9 @@ export function paintTownAtmosphere(ctx, world = {}, timeState = villageTime(12) export function paintRoomAtmosphere(ctx, room, timeState = villageTime(12), { time = 0, reduce = false, agent = null } = {}) { const state = stateOf(timeState), result = { phase: state.phase, windows: 0 }; if (!ctx || !room || !finite(room.width) || !finite(room.height)) return result; - if (state.roomDarkness <= .0001 && state.windowShade <= .001 && state.windowGlow <= .001) return result; + const haunt = hauntStage(agent); + const hasLight = state.roomDarkness > .0001 || state.windowShade > .001 || state.windowGlow > .001; + if (!hasLight && haunt === 'none') return result; const review = room.objects?.find(object => object.id === 'review'); const protectedRects = review ? [ { x: review.x + 2, y: review.y - 12, w: review.w - 4, h: 14 }, @@ -275,48 +280,51 @@ export function paintRoomAtmosphere(ctx, room, timeState = villageTime(12), { ti // unknown remains gray, and no warm ambient light changes those meanings. clipped(ctx, room.width, room.height, protectedRects); const p = painter(ctx); - palette(ctx, p, room.width, room.height, style, state); - const win = room.window; - if (validRect(win) && state.windowShade > .001) { - ctx.save(); - try { - // drawRoom's curtains and crossbars stay visible around the glass. - ctx.beginPath(); - ctx.rect(win.x + 3, win.y, 7, 11); ctx.rect(win.x + 12, win.y, 8, 11); - ctx.rect(win.x + 3, win.y + 13, 7, 10); ctx.rect(win.x + 12, win.y + 13, 8, 10); - ctx.clip(); - p(win.x, win.y, win.w, win.h, color(state.sky, state.windowShade)); - p(win.x, win.y + 17, win.w, 6, color([48, 73, 61], state.windowShade * .85)); - p(win.x + 7, win.y + 14, 9, 8, color([55, 77, 65], state.windowShade * .85)); - const moon = style.nightness; - p(win.x + 14, win.y + 4, 4, 4, color([246, 227, 172], state.windowShade * .75)); - p(win.x + 16, win.y + 3, 3, 4, color(state.sky, state.windowShade * moon)); - if (state.fireflies > .2) p(win.x + 6, win.y + 5, 1, 1, color([223, 230, 218], state.fireflies * .6)); - result.windows = 1; - } finally { ctx.restore(); } - // A small moonlit projection follows the existing authored window shaft. - for (let i = 0; i < 4; i++) p(win.x + 4 + i * 3, 54 + i * 7, 21, 6, - color(style.nightness > .3 ? [112, 145, 195] : [253, 203, 138], state.windowShade * .08)); - } - const hearth = room.objects?.find(object => object.id === 'hearth'); - if (validRect(hearth) && working && state.windowGlow > .001) { - const flicker = .9 + Math.sin(frame * 1.7 + Number(room.seed || 0) % 17) * .1; - glow(p, hearth.x + 3, hearth.y + hearth.h - 10, hearth.w - 6, 8, state.windowGlow * flicker); - p(hearth.x - 5, hearth.y + hearth.h - 2, hearth.w + 10, 6, color([255, 190, 105], state.windowGlow * flicker * .15)); - } else if (validRect(hearth) && style.nightness > .001) { - // Bedtime hearths settle to embers; the renderer's original bright flames - // are covered rather than being left shining underneath a dark overlay. - p(hearth.x + 7, hearth.y + hearth.h - 15, hearth.w - 14, 12, color([29, 34, 43], style.nightness)); - p(hearth.x + 11, hearth.y + hearth.h - 5, 2, 1, color([128, 78, 55], style.nightness)); - p(hearth.x + 17, hearth.y + hearth.h - 4, 2, 1, color([103, 68, 53], style.nightness)); - } - const desk = room.objects?.find(object => object.id === 'workbench'); - if (working && validRect(desk) && state.windowGlow > .001) { - ctx.globalCompositeOperation = 'screen'; - glow(p, desk.x + 3, desk.y + 2, desk.w - 6, 16, state.windowGlow * .75); - p(desk.x + 4, desk.y + 4, desk.w - 8, 12, color([242, 175, 89], state.windowGlow * .12)); - ctx.globalCompositeOperation = 'source-over'; + if (hasLight) { + palette(ctx, p, room.width, room.height, style, state); + const win = room.window; + if (validRect(win) && state.windowShade > .001) { + ctx.save(); + try { + // drawRoom's curtains and crossbars stay visible around the glass. + ctx.beginPath(); + ctx.rect(win.x + 3, win.y, 7, 11); ctx.rect(win.x + 12, win.y, 8, 11); + ctx.rect(win.x + 3, win.y + 13, 7, 10); ctx.rect(win.x + 12, win.y + 13, 8, 10); + ctx.clip(); + p(win.x, win.y, win.w, win.h, color(state.sky, state.windowShade)); + p(win.x, win.y + 17, win.w, 6, color([48, 73, 61], state.windowShade * .85)); + p(win.x + 7, win.y + 14, 9, 8, color([55, 77, 65], state.windowShade * .85)); + const moon = style.nightness; + p(win.x + 14, win.y + 4, 4, 4, color([246, 227, 172], state.windowShade * .75)); + p(win.x + 16, win.y + 3, 3, 4, color(state.sky, state.windowShade * moon)); + if (state.fireflies > .2) p(win.x + 6, win.y + 5, 1, 1, color([223, 230, 218], state.fireflies * .6)); + result.windows = 1; + } finally { ctx.restore(); } + // A small moonlit projection follows the existing authored window shaft. + for (let i = 0; i < 4; i++) p(win.x + 4 + i * 3, 54 + i * 7, 21, 6, + color(style.nightness > .3 ? [112, 145, 195] : [253, 203, 138], state.windowShade * .08)); + } + const hearth = room.objects?.find(object => object.id === 'hearth'); + if (validRect(hearth) && working && state.windowGlow > .001) { + const flicker = .9 + Math.sin(frame * 1.7 + Number(room.seed || 0) % 17) * .1; + glow(p, hearth.x + 3, hearth.y + hearth.h - 10, hearth.w - 6, 8, state.windowGlow * flicker); + p(hearth.x - 5, hearth.y + hearth.h - 2, hearth.w + 10, 6, color([255, 190, 105], state.windowGlow * flicker * .15)); + } else if (validRect(hearth) && style.nightness > .001) { + // Bedtime hearths settle to embers; the renderer's original bright flames + // are covered rather than being left shining underneath a dark overlay. + p(hearth.x + 7, hearth.y + hearth.h - 15, hearth.w - 14, 12, color([29, 34, 43], style.nightness)); + p(hearth.x + 11, hearth.y + hearth.h - 5, 2, 1, color([128, 78, 55], style.nightness)); + p(hearth.x + 17, hearth.y + hearth.h - 4, 2, 1, color([103, 68, 53], style.nightness)); + } + const desk = room.objects?.find(object => object.id === 'workbench'); + if (working && validRect(desk) && state.windowGlow > .001) { + ctx.globalCompositeOperation = 'screen'; + glow(p, desk.x + 3, desk.y + 2, desk.w - 6, 16, state.windowGlow * .75); + p(desk.x + 4, desk.y + 4, desk.w - 8, 12, color([242, 175, 89], state.windowGlow * .12)); + ctx.globalCompositeOperation = 'source-over'; + } } + paintRoomDust(p, room, haunt); } finally { ctx.restore(); } return result; } diff --git a/src/folklore.mjs b/src/folklore.mjs new file mode 100644 index 0000000..0393c87 --- /dev/null +++ b/src/folklore.mjs @@ -0,0 +1,211 @@ +/** + * Village folklore: decorative readings of occupancy, branches, PRs, and letters. + * Never changes feed status, occupancy, or input/PR access. + */ + +import { normalizePr, prCi, prStage } from "./pr.mjs"; + +export const HAUNT_IVY_MS = 6 * 60 * 60 * 1000; +export const HAUNT_RUINS_MS = 24 * 60 * 60 * 1000; +export const PARCEL_FRESH_MS = 2 * 60 * 60 * 1000; +export const PARCEL_MOSS_MS = 2 * 60 * 60 * 1000; +export const PARCEL_NEST_MS = 12 * 60 * 60 * 1000; +export const PARCEL_SHRINE_MS = 48 * 60 * 60 * 1000; +export const LETTER_CROWS_MS = 2 * 60 * 60 * 1000; + +const DEFAULT_BRANCHES = new Set([ + "main", + "master", + "trunk", + "develop", + "development", +]); + +function clock(...values) { + for (const value of values) { + const n = typeof value === "number" ? value : Date.parse(value); + if (Number.isFinite(n) && n > 0) return n; + } + return null; +} + +function ageMs(agent, now, ...prefer) { + const t = clock(...prefer, agent?.endedAt, agent?.updatedAt); + return t == null ? null : Math.max(0, now - t); +} + +/** Settled cottages cobweb; long-offline ones grow ivy and ruins. */ +export function hauntStage(agent, now = Date.now()) { + if (!agent || agent.occupancy !== "settled") return "none"; + if (agent.status !== "offline") return "cobweb"; + const age = ageMs(agent, now); + if (age == null) return "cobweb"; + if (age >= HAUNT_RUINS_MS) return "ruins"; + if (age >= HAUNT_IVY_MS) return "ivy"; + return "cobweb"; +} + +/** Intra-town spur scenery. Never invents town-to-town roads. */ +export function branchLane(agent = {}) { + const branch = typeof agent.branch === "string" ? agent.branch.trim() : ""; + if (!branch) return { kind: "unknown", weeds: false }; + const def = + typeof agent.defaultBranch === "string" ? agent.defaultBranch.trim() : ""; + const kind = + (def && branch === def) || DEFAULT_BRANCHES.has(branch) + ? "default" + : "feature"; + return { kind, weeds: kind === "feature" && agent.occupancy === "settled" }; +} + +/** + * Verified open PRs only. Unknown identity stays none so the lawn does not + * invent moss for an unverified outstanding pull request. + */ +export function parcelReclaim(agent, now = Date.now()) { + const pr = normalizePr(agent?.pr, now); + if (pr.state !== "open") return "none"; + const age = ageMs(agent, now, pr.openedAt, agent?.pr?.openedAt); + if (age == null) return "fresh"; + if (age >= PARCEL_SHRINE_MS) return "shrine"; + if (age >= PARCEL_NEST_MS) return "nest"; + if (age >= PARCEL_MOSS_MS) return "moss"; + return "fresh"; +} + +/** Blocked live letters only. Settled ghosts are not mail. */ +export function letterNeglect(agent, now = Date.now()) { + if (!agent || agent.occupancy === "settled") return "none"; + const blocked = + agent.status === "blocked" || prStage(agent.pr, now) === "blocked"; + if (!blocked) return "none"; + const age = ageMs( + agent, + now, + agent?.inputRequest?.updatedAt, + agent?.inputRequest?.createdAt, + ); + if (age != null && age >= LETTER_CROWS_MS) return "crows"; + return "pile"; +} + +export function checkWeather(agent) { + const ci = prCi(agent?.pr); + if (ci.state === "failing") return "storm"; + if (ci.state === "unavailable") return "unknown"; + return "clear"; +} + +/** Pixel accents for a house already drawn by town.mjs. */ +export function paintHauntExtras(px, x, y, houseW, houseH, stage) { + if (!px || stage === "none") return; + const ink = "#2b2118", + web = "#d8d2c4", + ivy = "#3f6b45", + ivyDk = "#2d4f34"; + // Cobwebs in the eaves and a corner. + px(x + 8, y + 34, 1, 1, web); + px(x + 9, y + 35, 2, 1, web); + px(x + 11, y + 36, 1, 1, web); + px(x + houseW - 12, y + 34, 1, 1, web); + px(x + houseW - 13, y + 35, 2, 1, web); + if (stage === "cobweb") return; + + // Ivy climbing the left wall; ruins thicken it. + const climb = stage === "ruins" ? 18 : 12; + for (let i = 0; i < climb; i++) { + px(x + 5 + (i % 3), y + houseH - 6 - i, 2, 1, i % 2 ? ivyDk : ivy); + if (i % 4 === 0) px(x + 7, y + houseH - 6 - i, 1, 1, ivy); + } + // Crooked chimney lean. + px(x + 44, y + 6, 1, 10, ink); + px(x + 45, y + 5, 1, 4, "#5a636b"); + if (stage === "ruins") { + px(x + 14, y + 40, 3, 1, "#6a737a"); + px(x + 30, y + 42, 4, 1, "#6a737a"); + px(x + 18, y + 22, 2, 2, ivyDk); + } +} + +/** Pale night ghost behind dark glass — never a warm working pane. */ +export function paintHauntGhost(px, wx, wy, nightness) { + if (!px || !(nightness > 0.2)) return; + const a = Math.min(0.55, nightness * 0.45); + const ghost = (r, g, b) => `rgba(${r},${g},${b},${a})`; + px(wx + 2, wy + 2, 2, 4, ghost(210, 220, 230)); + px(wx + 1, wy + 3, 4, 2, ghost(190, 205, 220)); + px(wx + 2, wy + 1, 2, 1, ghost(230, 235, 240)); +} + +/** Weeds along the door-to-lane yard path for feature branches. */ +export function paintBranchWeeds(px, x, y, houseW, houseH, lane) { + if (!px || !lane || lane.kind !== "feature") return; + const tip = lane.weeds ? "#355c32" : "#4a7a42"; + const blade = lane.weeds ? 7 : 3; + const pathX = x + Math.floor(houseW / 2) - 1; + for (let i = 0; i < blade; i++) { + const gy = y + houseH + 2 + i * 2; + px(pathX - 3 + (i % 3), gy, 1, 2, tip); + px(pathX + 2 + ((i + 1) % 3), gy + 1, 1, 2, tip); + } + if (lane.weeds) { + px(pathX - 5, y + houseH + 8, 2, 1, "#2f4f2c"); + px(pathX + 4, y + houseH + 10, 2, 1, "#2f4f2c"); + } +} + +/** Moss / nest / shrine beside an existing parcel dispatch stand. */ +export function paintParcelReclaim(px, x, y, stage) { + if (!px || !stage || stage === "none" || stage === "fresh") return; + if (stage === "moss" || stage === "nest" || stage === "shrine") { + px(x - 1, y + 12, 14, 2, "#3f6b45"); + px(x + 2, y + 11, 8, 1, "#2d4f34"); + } + if (stage === "nest" || stage === "shrine") { + px(x + 14, y + 8, 6, 4, "#6b4a2e"); + px(x + 15, y + 7, 4, 2, "#8a6238"); + px(x + 16, y + 9, 2, 1, "#c9a066"); + } + if (stage === "shrine") { + px(x - 4, y - 2, 3, 8, "#8d959b"); + px(x - 5, y - 3, 5, 2, "#aab2b8"); + px(x - 3, y - 5, 1, 2, "#e8c15a"); + } +} + +/** Crow accents for neglected mail. */ +export function paintLetterCrows(px, x, y, stage) { + if (!px || stage !== "crows") return; + px(x + 20, y - 2, 3, 2, "#1a1f24"); + px(x + 22, y - 3, 2, 1, "#1a1f24"); + px(x + 19, y, 1, 1, "#1a1f24"); + px(x + 26, y + 1, 3, 2, "#1a1f24"); + px(x + 28, y, 2, 1, "#1a1f24"); +} + +/** Pocket storm over a roof when CI is failing. */ +export function paintCheckStorm(px, x, y, weather) { + if (!px || weather !== "storm") return; + px(x + 10, y - 6, 18, 4, "#4a5560"); + px(x + 14, y - 8, 12, 3, "#5c6772"); + px(x + 18, y - 3, 1, 4, "#ffd166"); + px(x + 22, y - 2, 1, 3, "#ffd166"); +} + +/** Soft dust and cobwebs inside a settled / haunted room. */ +export function paintRoomDust(px, room, stage) { + if (!px || !room || stage === "none") return; + const web = "#d8d2c488"; + px(18, 14, 1, 1, web); + px(19, 15, 2, 1, web); + px(210, 16, 1, 1, web); + px(208, 17, 3, 1, web); + if (stage === "cobweb") return; + px(30, 70, 4, 1, "#c4b89a55"); + px(120, 110, 5, 1, "#c4b89a44"); + px(180, 80, 3, 1, "#c4b89a55"); + if (stage === "ruins") { + px(40, 50, 6, 1, "#6a737a66"); + px(160, 130, 8, 1, "#6a737a55"); + } +} diff --git a/src/github.mjs b/src/github.mjs index e030e80..53e9e94 100644 --- a/src/github.mjs +++ b/src/github.mjs @@ -4,7 +4,7 @@ import { promisify } from "node:util"; import { normalizePr, parsePrUrl, prKey } from "./pr.mjs"; const execFileAsync = promisify(execFile); -const FIELDS = "number,url,title,state,labels,headRefOid,headRefName,isCrossRepository,isDraft,mergedAt,closedAt,statusCheckRollup"; +const FIELDS = "number,url,title,state,labels,headRefOid,headRefName,isCrossRepository,isDraft,mergedAt,closedAt,createdAt,statusCheckRollup"; const REPO_RE = /^[\w.-]+\/[\w.-]+$/; const DEFAULT_NAMES = new Set(["main", "master", "trunk"]); @@ -100,6 +100,7 @@ export function createGithubEnricher({ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid_pr_response"); const value = { ...raw, repo: target.repo, headSha: raw.headSha || raw.headRefOid || "", + openedAt: raw.openedAt || raw.createdAt || undefined, source: "github", checkedAt: now(), stale: false, reviewUncertain: false, // A new GitHub observation supersedes a derived review state. reviewState: undefined, @@ -169,6 +170,9 @@ export function createGithubEnricher({ ...entry.value, ...(samePr && initial.finalization ? { finalization: initial.finalization } : {}), ...(samePr && initial.reviewedHeadSha ? { reviewedHeadSha: initial.reviewedHeadSha } : {}), + // Keep a feed-supplied open clock when GitHub did not return createdAt. + ...(samePr && initial.openedAt && !(entry.value.openedAt || entry.value.createdAt) + ? { openedAt: initial.openedAt } : {}), stale: Boolean(entry.error), reason: entry.error || entry.value.reason || "", }; diff --git a/src/observatory.mjs b/src/observatory.mjs index d3375b3..febbb6e 100644 --- a/src/observatory.mjs +++ b/src/observatory.mjs @@ -1,5 +1,6 @@ import {createInterior,isWalkable,renderInterior,renderResident} from './interiors.mjs'; import {normalizePr,prCi,prStage,prCounts} from './pr.mjs'; +import {parcelReclaim,paintParcelReclaim} from './folklore.mjs'; import {movePoint,inside,normalizeRelationships,normalizedHandoffs} from './world.mjs'; import {createHistory} from './history.mjs'; import {createSound} from './sound.mjs'; @@ -636,6 +637,7 @@ export function createObservatory(api){ function drawDispatch(ctx,p,time){ const stage=prStage(p.agent.pr),s=STAGES[stage],x=p.x-15,y=p.y+55; const label=extras.lightAt(time).darkness>.1?'#d0def0':'#253729'; + const px=(rx,ry,w,h,c)=>{ctx.fillStyle=c;ctx.fillRect(rx|0,ry|0,w|0,h|0);}; ctx.textBaseline='top'; ctx.fillStyle='#354231';ctx.fillRect(x-2,y+5,17,3);ctx.fillRect(x,y+8,2,9);ctx.fillRect(x+10,y+8,2,9); if(stage!=='none'){ @@ -643,6 +645,7 @@ export function createObservatory(api){ if(stage==='ready'){ctx.fillStyle='#fff4b8';ctx.fillRect(x+5,y-5,2,13);ctx.fillRect(x-1,y,14,2);} if(stage==='merged'){ctx.clearRect(x+3,y-3,6,3);ctx.fillStyle='#c4e2a4';ctx.fillRect(x-1,y-6,5,3);ctx.fillRect(x+8,y-6,5,3);} } + paintParcelReclaim(px,x,y,parcelReclaim(p.agent)); ctx.font='8px "Silkscreen",monospace';ctx.fillStyle=stage==='blocked'?'#ffe8d2':label; const sym={none:'—',open:'+',active:'*','waiting-codex':'?','waiting-ci':':',blocked:'!',ready:'*',merged:'✓',closed:'×',unknown:'?'}[stage]; if(stage==='blocked'){ctx.fillStyle='#d95540';ctx.fillRect(x+2,y-18,9,11);ctx.fillStyle='#fff2df';} diff --git a/src/pr.mjs b/src/pr.mjs index fa61a12..0ba158e 100644 --- a/src/pr.mjs +++ b/src/pr.mjs @@ -191,7 +191,7 @@ export function normalizePr(value, now = Date.now()) { labels, checks, headSha, reviewedHeadSha, source, checkedAt, stale, reason, stage, reviewUncertain: uncertain, }; - for (const key of ["repo", "host", "observedReadyHeadSha", "isDraft", "mergedAt", "closedAt"]) + for (const key of ["repo", "host", "observedReadyHeadSha", "isDraft", "mergedAt", "closedAt", "openedAt"]) if (p[key] !== undefined) result[key] = p[key]; if (receipt) result.finalization = { ...receipt }; return result; diff --git a/src/town.mjs b/src/town.mjs index 080024b..7447105 100644 --- a/src/town.mjs +++ b/src/town.mjs @@ -5,6 +5,16 @@ import { createObservatory, apprenticeResidentTarget } from "./observatory.mjs"; import { normalizeCottage } from "./feed-client.mjs"; import { blankFeedState, createLatestRefresh, readCurrentFeed, snapshotForEndpoint } from "./live-feed.mjs"; import { lettersOf } from "./occupancy.mjs"; +import { + hauntStage, + branchLane, + letterNeglect, + checkWeather, + paintHauntExtras, + paintBranchWeeds, + paintLetterCrows, + paintCheckStorm, +} from "./folklore.mjs"; import { PUBLIC_DEMO } from "./runtime.mjs"; import { createBedtimeRoutine, paintCoop, routineForAgent, statusBubbleAnchor, villageLifeLabel, visibleBedtimeKids } from "./bedtime.mjs"; import { residentTargets } from "./interaction.mjs"; @@ -187,12 +197,13 @@ const SIM = (() => { ["Otto","HubTown","idle"],["Vera","HubTown","done"],["Gus","HubTown","working"],["Ida","HubTown","idle"], ["Juno","MemTown","working"],["Pim","MemTown","working"],["Wren","MemTown","idle"], ["Sable","MemTown","offline"], + ["Moss","MemTown","offline"], ["Hollis","FusionTown","working"],["Dov","FusionTown","idle"],["Tess","FusionTown","blocked"],["Nils","FusionTown","working"], ["Odie","AppTown","working"],["Ruth","AppTown","done"],["Cass","AppTown","idle"], ["Nyx","VaultTown","working"],["Reed","VaultTown","idle"], ["Bolt-1","HubTown","working",0],["Bolt-2","HubTown","blocked",0], ["Juno-1","MemTown","working",7],["Juno-2","MemTown","done",7],["Juno-3","MemTown","working",7], - ["Hollis-1","FusionTown","idle",11] + ["Hollis-1","FusionTown","idle",12] ]; const now = Date.now(); const agents = seed.map((s,i)=>({ @@ -245,11 +256,84 @@ const SIM = (() => { a.branch = mum ? mum.branch : "main"; } }); + const FOLKLORE_PINNED = new Set(["Moss", "Sable", "Vera", "Kip", "Gus"]); + const stampFolkloreDemo = (stamp = Date.now()) => { + const moss = agents.find(a => a.name === "Moss"); + if (moss) { + moss.status = "offline"; + moss.endedAt = stamp - 30 * 60 * 60 * 1000; + moss.updatedAt = moss.endedAt; + moss.occupancy = "settled"; + moss.branch = "feat/forgotten-index"; + moss.defaultBranch = "main"; + moss.pr = { state: "none", source: "demo", checkedAt: stamp }; + moss.inputRequest = null; + moss.attention = ""; + moss.task = "Claude Code session ended."; + moss.lastLine = pick(LINES.offline); + moss.activity = pick(ACTIVITY.offline); + } + const sable = agents.find(a => a.name === "Sable"); + if (sable) { + sable.status = "offline"; + sable.endedAt = stamp - 8 * 60 * 60 * 1000; + sable.updatedAt = sable.endedAt; + sable.occupancy = "settled"; + sable.branch = "feat/old-memory"; + sable.defaultBranch = "main"; + sable.pr = { state: "none", source: "demo", checkedAt: stamp }; + sable.inputRequest = null; + sable.attention = ""; + sable.task = "Claude Code session ended."; + sable.lastLine = pick(LINES.offline); + sable.activity = pick(ACTIVITY.offline); + } + const vera = agents.find(a => a.name === "Vera"); + if (vera) { + vera.status = "done"; + vera.occupancy = "live"; + vera.endedAt = stamp - 14 * 60 * 60 * 1000; + vera.updatedAt = stamp; + vera.pr = { + number: 501, repo: "demo/HubTown", url: "https://github.com/demo/HubTown/pull/501", + title: "stale parcel", state: "open", reviewState: "open", headSha: "demo-vera", + source: "demo", checkedAt: stamp, openedAt: stamp - 14 * 60 * 60 * 1000, + }; + } + const kip = agents.find(a => a.name === "Kip"); + if (kip) { + // Pin once so the practice Talk form keeps a stable request id; after a + // successful reply (inputRequest cleared), do not force blocked again. + if (!kip._folkloreLetter) { + kip.status = "blocked"; + kip.occupancy = "live"; + kip.updatedAt = stamp - 3 * 60 * 60 * 1000; + kip.inputRequest = demoInput(kip, stamp - 3 * 60 * 60 * 1000); + kip.attention = kip.inputRequest.prompt; + kip.activity = "Waiting for your choice about the scope of this task."; + kip._folkloreLetter = true; + } else if (kip.inputRequest) { + kip.status = "blocked"; + kip.occupancy = "live"; + } + } + const gus = agents.find(a => a.name === "Gus"); + if (gus) { + gus.pr = { + number: 502, repo: "demo/HubTown", url: "https://github.com/demo/HubTown/pull/502", + title: "stormy checks", state: "open", reviewState: "waiting-ci", + labels: ["babysit:waiting-ci"], headSha: "demo-gus", source: "demo", checkedAt: stamp, + checks: [{ name: "ci", status: "COMPLETED", conclusion: "FAILURE" }], + }; + } + }; + stampFolkloreDemo(now); let live = true; let demoBeat=0; setInterval(()=>{ if(!live) return; demoBeat++; + stampFolkloreDemo(); if(demoBeat===5){ const parent=agents[0],stamp=Date.now(),id="demo-apprentice"; agents.push({...parent,id,name:"Pip",parent:parent.id,status:"working",occupancy:"live",endedAt:0,inputRequest:null,attention:'',taskId:"demo-task-pip",task:"Check the webhook edge cases",originalAsk:"Please test the webhook edge cases while Bolt finishes the queue changes.",taskStartedAt:stamp,startedAt:stamp,sessionStartedAt:stamp,updatedAt:stamp,tokens:0,cost:0,events:[{id:id+":arrival",timestamp:stamp,kind:"request",text:"Please test the webhook edge cases while Bolt finishes the queue changes."}]}); @@ -260,6 +344,7 @@ const SIM = (() => { DEMO_META.handoffs.push(event);DEMO_META.handoffs=DEMO_META.handoffs.slice(-20);agents[0].events.push(event); } agents.forEach(a=>{ + if(FOLKLORE_PINNED.has(a.name)) return; if(a.status==="working"){ const d = Math.floor(Math.random()*900); a.tokens += d; a.cost += d * RATE; @@ -1018,6 +1103,8 @@ function drawHouse(x, y, ag){ px(dx+5, dy+9, 1, 2, "#ffd166"); px(dx-3, bodyY+bodyH, 14, 3, C.stone); px(dx-3, bodyY+bodyH+2, 14, 1, C.stoneDk); + paintHauntExtras(px, x, y, HOUSE_W, HOUSE_H, hauntStage(ag)); + paintCheckStorm(px, x, y, checkWeather(ag)); } function drawBranchPost(x, y, branch, dim){ @@ -1187,6 +1274,8 @@ function drawJackStoop(plot, n, on, over){ const bob = reduce ? 0 : Math.round(Math.sin(t*2)*0.5); if(!observatory) drawJack(x+22, y+12+bob, 0); drawMailbox(x+38, y+8, n); + const crowed = letters().some(ag => letterNeglect(ag) === "crows"); + if (crowed) paintLetterCrows(px, x + 38, y + 8, "crows"); if(on || over){ ctx.strokeStyle = on ? "#ffd166" : "#ffffff"; ctx.lineWidth = 1; @@ -1522,6 +1611,7 @@ function draw(){ drawHouse(p.x, p.y, ag); drawBranchPost(p.x, p.y+HOUSE_H+SIGN_Y, ag.branch || ag.worktree, ag.status==="offline"); + paintBranchWeeds(px, p.x, p.y, HOUSE_W, HOUSE_H, branchLane(ag)); if(ag.status === "working"){ const phase = reduce ? 0.4 : (t*0.35 + p.x*0.07) % 1; drawSmoke(p.x+40, p.y+2, phase); diff --git a/test/folklore.test.mjs b/test/folklore.test.mjs new file mode 100644 index 0000000..1425542 --- /dev/null +++ b/test/folklore.test.mjs @@ -0,0 +1,219 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + HAUNT_IVY_MS, + HAUNT_RUINS_MS, + PARCEL_MOSS_MS, + PARCEL_NEST_MS, + PARCEL_SHRINE_MS, + LETTER_CROWS_MS, + hauntStage, + branchLane, + parcelReclaim, + letterNeglect, + checkWeather, +} from "../src/folklore.mjs"; + +const NOW = 1_700_000_000_000; + +test("hauntStage stays none for live and recent cottages", () => { + assert.equal( + hauntStage({ occupancy: "live", status: "working" }, NOW), + "none", + ); + assert.equal( + hauntStage( + { occupancy: "recent", status: "done", endedAt: NOW - 60_000 }, + NOW, + ), + "none", + ); +}); + +test("hauntStage cobwebs as soon as a cottage settles", () => { + assert.equal( + hauntStage( + { occupancy: "settled", status: "done", endedAt: NOW - 60_000 }, + NOW, + ), + "cobweb", + ); +}); + +test("hauntStage grows ivy then ruins for long-offline settled cottages", () => { + assert.equal( + hauntStage( + { occupancy: "settled", status: "offline", endedAt: NOW - HAUNT_IVY_MS }, + NOW, + ), + "ivy", + ); + assert.equal( + hauntStage( + { + occupancy: "settled", + status: "offline", + endedAt: NOW - HAUNT_RUINS_MS, + }, + NOW, + ), + "ruins", + ); +}); + +test("hauntStage does not invent age when clocks are missing", () => { + assert.equal( + hauntStage({ occupancy: "settled", status: "offline" }, NOW), + "cobweb", + ); +}); + +test("branchLane treats default and common defaults as the village green", () => { + assert.deepEqual(branchLane({ branch: "main" }), { + kind: "default", + weeds: false, + }); + assert.deepEqual( + branchLane({ branch: "feat/thing", defaultBranch: "feat/thing" }), + { + kind: "default", + weeds: false, + }, + ); + assert.deepEqual(branchLane({}), { kind: "unknown", weeds: false }); +}); + +test("branchLane weeds only on settled feature branches", () => { + assert.deepEqual(branchLane({ branch: "feat/thing", occupancy: "live" }), { + kind: "feature", + weeds: false, + }); + assert.deepEqual(branchLane({ branch: "feat/thing", occupancy: "settled" }), { + kind: "feature", + weeds: true, + }); +}); + +test("parcelReclaim stays none without a verified open PR", () => { + assert.equal(parcelReclaim({ pr: { state: "none" } }, NOW), "none"); + assert.equal( + parcelReclaim({ pr: { state: "unknown", source: "unavailable" } }, NOW), + "none", + ); + assert.equal( + parcelReclaim({ pr: { state: "merged", number: 1 } }, NOW), + "none", + ); + assert.equal( + parcelReclaim( + { + pr: { + state: "unknown", + number: 9, + url: "https://github.com/demo/repo/pull/9", + }, + endedAt: NOW - PARCEL_SHRINE_MS, + }, + NOW, + ), + "none", + ); +}); + +test("parcelReclaim preserves openedAt through normalizePr for age", async () => { + const { normalizePr } = await import("../src/pr.mjs"); + const openedAt = NOW - PARCEL_NEST_MS; + const pr = normalizePr( + { + state: "open", + number: 12, + url: "https://github.com/demo/repo/pull/12", + openedAt, + checkedAt: NOW, + headSha: "abc", + }, + NOW, + ); + assert.equal(pr.openedAt, openedAt); + assert.equal( + parcelReclaim({ pr, occupancy: "live", updatedAt: NOW }, NOW), + "nest", + ); +}); + +test("parcelReclaim escalates by wait age for outstanding PRs", () => { + const open = { + state: "open", + number: 12, + url: "https://github.com/demo/repo/pull/12", + }; + assert.equal( + parcelReclaim( + { pr: { ...open, openedAt: NOW - 60_000 }, occupancy: "live" }, + NOW, + ), + "fresh", + ); + assert.equal( + parcelReclaim( + { pr: { ...open, openedAt: NOW - PARCEL_MOSS_MS }, endedAt: NOW }, + NOW, + ), + "moss", + ); + assert.equal( + parcelReclaim({ pr: open, endedAt: NOW - PARCEL_NEST_MS }, NOW), + "nest", + ); + assert.equal( + parcelReclaim({ pr: open, updatedAt: NOW - PARCEL_SHRINE_MS }, NOW), + "shrine", + ); +}); + +test("letterNeglect ignores settled ghosts and escalates blocked letters", () => { + assert.equal( + letterNeglect({ occupancy: "settled", status: "blocked" }, NOW), + "none", + ); + assert.equal( + letterNeglect( + { occupancy: "live", status: "blocked", updatedAt: NOW - 30_000 }, + NOW, + ), + "pile", + ); + assert.equal( + letterNeglect( + { + occupancy: "live", + status: "blocked", + updatedAt: NOW - LETTER_CROWS_MS, + }, + NOW, + ), + "crows", + ); +}); + +test("checkWeather reports storm only for failing checks", () => { + assert.equal(checkWeather({ pr: { state: "open", checks: [] } }), "unknown"); + assert.equal( + checkWeather({ + pr: { + state: "open", + checks: [{ name: "ci", status: "COMPLETED", conclusion: "SUCCESS" }], + }, + }), + "clear", + ); + assert.equal( + checkWeather({ + pr: { + state: "open", + checks: [{ name: "ci", status: "COMPLETED", conclusion: "FAILURE" }], + }, + }), + "storm", + ); +}); diff --git a/test/github.test.mjs b/test/github.test.mjs index f9e47a3..9d06081 100644 --- a/test/github.test.mjs +++ b/test/github.test.mjs @@ -113,6 +113,29 @@ test("GitHub PR queries request the status check rollup", async () => { assert.equal(command.file, "gh"); assert.equal(command.args[0], "pr"); assert.match(command.args.at(-1), /statusCheckRollup/); + assert.match(command.args.at(-1), /createdAt/); +}); + +test("GitHub enrichment maps createdAt to openedAt and keeps a feed open clock", async () => { + const enrich = createGithubEnricher({ + now: () => start, + query: async () => ({ ...rawPr, createdAt: "2026-09-01T00:00:00Z" }), + }); + await enrich([agent]); + await enrich.flush(); + const withGithubClock = (await enrich([agent]))[0].pr; + assert.equal(withGithubClock.openedAt, "2026-09-01T00:00:00Z"); + + const feedOpened = Date.parse("2026-08-01T00:00:00Z"); + const enrichKeep = createGithubEnricher({ + now: () => start, + query: async () => ({ ...rawPr }), + }); + const seeded = { ...agent, pr: { ...agent.pr, openedAt: feedOpened } }; + await enrichKeep([seeded]); + await enrichKeep.flush(); + const kept = (await enrichKeep([seeded]))[0].pr; + assert.equal(kept.openedAt, feedOpened); }); test("branch discovery checks the actual default branch and accepts only an unambiguous exact match", async () => {