type Draft = { id: string; stale: boolean } export function markStale(draft: Draft) { if (draft.stale) return draft return { ...draft, stale: true }} export function fresh(id: string): Draft { return { id, stale: false }} const queue = drafts.filter((d) => !d.stale)if (queue.length === 0) returnreturn markStale(queue[0])
type Draft = { id: string; stale: boolean }
export function markStale(draft: Draft) {
if (draft.stale) return draft
return { ...draft, stale: true }
}
export function fresh(id: string): Draft {
return { id, stale: false }
}
const queue = drafts.filter((d) => !d.stale)
if (queue.length === 0) return
return markStale(queue[0])
async function pullRemote(cursor: string) {
const page = await client.list({ cursor })
if (page.stale) throw new Error("stale")
return page.items
}
export async function sync(from: string) {
let cursor = from
const out = []
while (cursor) {
const items = await pullRemote(cursor)
out.push(...items)
cursor = items.at(-1)?.id ?? ""
}
return out
}
const store = useOtherStore()
store.set("session", next)
if (store.get("session") !== next) {
throw new Error("write missed")
}
export function save(next: Session) {
const prev = store.get("session")
store.set("session", next)
if (store.get("session") === prev) {
store.set("session", next)
}
return store.get("session")
}
export function bust(key: string) {
memory.delete(key)
disk.remove(key)
return { key, hit: false }
}
export function read(key: string) {
const warm = memory.get(key)
if (warm) return warm
const cold = disk.get(key)
if (!cold) return null
memory.set(key, cold)
return cold
}
bust("session")
it("retries the webhook twice", async () => {
mock.failOnce().failOnce().ok()
await send(event)
expect(mock.calls).toBe(3)
})
it("stops after the second miss", async () => {
mock.failOnce().failOnce().failOnce()
await expect(send(event)).rejects.toThrow()
expect(mock.calls).toBe(3)
})
beforeEach(() => mock.reset())
export const label = "session-v2"
export const label = "session"
// keep the first name
export const label = "session-v2"
export function rename(from: string, to: string) {
if (from === to) return from
if (taken.has(to)) return from
taken.delete(from)
taken.add(to)
return to
}
rename("session-v2", "session")
rename("session", "session-v2")
export function score(event: Event) {
const churn = event.touches.length
const span = event.endedAt - event.startedAt
return churn * Math.log(span + 1)
}
export function keep(event: Event) {
return score(event) > THRESHOLD
}
const week = events.filter(keep)
if (week.length === 0) return null
return week.sort((a, b) => score(b) - score(a))[0]
git merge --no-ff topic
git revert HEAD --no-edit
git merge --no-ff topic
// the middle hour is the story
function replay(steps: Step[]) {
const log = []
for (const step of steps) {
log.push(step.run())
if (step.undo) log.push(step.undo())
log.push(step.run())
}
return log
}@lea
Spent Tuesday arguing with a types file and still could not say what changed. The type compiled. The week did not. I opened it to add one field and left with three new ones I cannot defend. That is usually the sign I should have written the post on Monday, when I still remembered why I opened the file.
@jon
Reverted the sync layer this morning. Three days of work, one command. I had been treating a stale cursor as a retry instead of a stop. The page looked fine. The data was a day behind. That is the whole post, and I almost did not write it because the revert looked like nothing.
@mina
Tried the other store because people said it was simpler. It lost a write on the second save. I watched the value come back as the old one and thought I had misread the log. I had not. Back to the boring one. The lesson is not "never switch." It is "the second save is the one that tells you."
@theo
Deleted the cache I had been protecting since March. The app got faster and I got a weekend back. I kept it because a cold start used to feel like a failure. It was just a wait. Once the wait was gone I could not remember what I had been defending. That is a better post than any benchmark I wrote down.
@priya
The test failed twice and passed on the third run. The second failure was the one that actually explained the bug. I almost deleted the flake and moved on. Then I read the middle stack and saw we were counting a retry as success. That is the post: not that I fixed a test, but that I almost threw away the useful run.
@nico
Renamed it, shipped it, renamed it back. The name was never the problem. The boundary was. I thought a clearer label would make the two stores stop leaking into each other. It did not. What worked was drawing the line once and leaving the names alone. I wasted a day on the label and an hour on the line.
@ada
One file, all week. I kept opening it for a different reason and leaving with the same one. That is usually the post. Not a launch, not a rewrite — the file you cannot leave because it still has the decision in it. I almost wrote about the feature. The feature was a side effect. The file was the week.
@sam
Merged, undid it, merged again. The middle hour is the part I would have forgotten by Friday. The first merge looked right. The revert looked like caution. The second merge was the one that knew why. If I only keep the final SHA I have nothing to say. The hour in between is the post.Your work already contains stories.
You ship all week and then sit down with nothing to post — Emerge watches the shape of your work and hands back the moments worth telling.
The week on one side: a retry, a revert, a file you would not leave. The posts it already contained on the other.

@lea
Spent Tuesday arguing with a types file and still could not say what changed. The type compiled. The week did not. I opened it to add one field and left with three new ones I cannot defend. That is usually the sign I should have written the post on Monday, when I still remembered why I opened the file.
async function pullRemote(cursor: string) { const page = await client.list({ cursor }) if (page.stale) throw new Error("stale") return page.items} export async function sync(from: string) { let cursor = from const out = [] while (cursor) { const items = await pullRemote(cursor) out.push(...items) cursor = items.at(-1)?.id ?? "" } return out}

@jon
Reverted the sync layer this morning. Three days of work, one command. I had been treating a stale cursor as a retry instead of a stop. The page looked fine. The data was a day behind. That is the whole post, and I almost did not write it because the revert looked like nothing.
const store = useOtherStore() store.set("session", next)if (store.get("session") !== next) { throw new Error("write missed")} export function save(next: Session) { const prev = store.get("session") store.set("session", next) if (store.get("session") === prev) { store.set("session", next) } return store.get("session")}

@mina
Tried the other store because people said it was simpler. It lost a write on the second save. I watched the value come back as the old one and thought I had misread the log. I had not. Back to the boring one. The lesson is not "never switch." It is "the second save is the one that tells you."
export function bust(key: string) { memory.delete(key) disk.remove(key) return { key, hit: false }} export function read(key: string) { const warm = memory.get(key) if (warm) return warm const cold = disk.get(key) if (!cold) return null memory.set(key, cold) return cold} bust("session")

@theo
Deleted the cache I had been protecting since March. The app got faster and I got a weekend back. I kept it because a cold start used to feel like a failure. It was just a wait. Once the wait was gone I could not remember what I had been defending. That is a better post than any benchmark I wrote down.
it("retries the webhook twice", async () => { mock.failOnce().failOnce().ok() await send(event) expect(mock.calls).toBe(3)}) it("stops after the second miss", async () => { mock.failOnce().failOnce().failOnce() await expect(send(event)).rejects.toThrow() expect(mock.calls).toBe(3)}) beforeEach(() => mock.reset())

@priya
The test failed twice and passed on the third run. The second failure was the one that actually explained the bug. I almost deleted the flake and moved on. Then I read the middle stack and saw we were counting a retry as success. That is the post: not that I fixed a test, but that I almost threw away the useful run.
export const label = "session-v2"export const label = "session" // keep the first nameexport const label = "session-v2" export function rename(from: string, to: string) { if (from === to) return from if (taken.has(to)) return from taken.delete(from) taken.add(to) return to} rename("session-v2", "session")rename("session", "session-v2")

@nico
Renamed it, shipped it, renamed it back. The name was never the problem. The boundary was. I thought a clearer label would make the two stores stop leaking into each other. It did not. What worked was drawing the line once and leaving the names alone. I wasted a day on the label and an hour on the line.
export function score(event: Event) { const churn = event.touches.length const span = event.endedAt - event.startedAt return churn * Math.log(span + 1)} export function keep(event: Event) { return score(event) > THRESHOLD} const week = events.filter(keep)if (week.length === 0) return nullreturn week.sort((a, b) => score(b) - score(a))[0]

@ada
One file, all week. I kept opening it for a different reason and leaving with the same one. That is usually the post. Not a launch, not a rewrite — the file you cannot leave because it still has the decision in it. I almost wrote about the feature. The feature was a side effect. The file was the week.
git merge --no-ff topicgit revert HEAD --no-editgit merge --no-ff topic // the middle hour is the story function replay(steps: Step[]) { const log = [] for (const step of steps) { log.push(step.run()) if (step.undo) log.push(step.undo()) log.push(step.run()) } return log}

@sam
Merged, undid it, merged again. The middle hour is the part I would have forgotten by Friday. The first merge looked right. The revert looked like caution. The second merge was the one that knew why. If I only keep the final SHA I have nothing to say. The hour in between is the post.
export async function waitFor(id: string) { const started = Date.now() while (Date.now() - started < LIMIT) { const row = await lock.get(id) if (row?.free) return row await sleep(40) } throw new Error("timeout")} export function release(id: string) { return lock.set(id, { free: true })} await waitFor("deploy")

@ravi
The timeout was a lock I never released. I raised the limit twice and still lost Friday. The log said we waited. It did not say who was holding. Once I printed the holder the wait was four seconds and the story was the missing release, not the number I had been tuning.
export const FLAG = "billing-v3" export function gated(name: string) { if (!flags.has(name)) return false return flags.get(name) === "on"} if (gated(FLAG)) { charge(next)} else { charge(prev)} flags.set(FLAG, "on")flags.set(FLAG, "on")

@kim
Left the flag on after the experiment ended. The new path was the only path and I still had the old one in my head. I found it because a receipt used a field we had retired. The post is not that the flag worked. It is that I forgot to turn it off, and the week kept running the trial.
export function logOnce(event: Event) { if (seen.has(event.id)) return seen.add(event.id) if (env !== "prod") return sink.write(event)} export function replay(id: string) { const event = buffer.get(id) if (!event) return null seen.delete(id) logOnce(event) return event} logOnce({ id: "signup", at: now() })

@noah
The log only fired in prod, so local looked clean and the week did not. I spent a day chasing a missing event that was sitting in a guard I wrote to keep the noise down. The useful post is the guard. I almost wrote about the missing event, which was never missing.
export async function ingest(payload: Payload) { try { return await parse(payload) } catch { return null }} export function accept(row: Row | null) { if (!row) return inbox.push(row)} const row = await ingest(raw)accept(row)if (!row) count.miss += 1

@iris
Empty catch. The only signal we had was the one I swallowed. Inbox looked quiet and I believed it. A count in the corner said we had missed forty-one payloads and I had been treating quiet as health. That is the post: the week was loud, the handler was polite.
export async function migrate(name: string) { if (applied.has(name)) return applied await run(name) applied.add(name) return applied} await migrate("004_inbox")await migrate("004_inbox") export function rewind(name: string) { applied.delete(name) return applied} rewind("004_inbox")

@owen
Ran the migration twice. The second time was a no-op and I still held my breath. I had been treating applied as a feeling instead of a set. Once the set was the source of truth the second run was boring, which is the only way a migration should feel, and also the post.
// callers pass { raw: true } until we have a typeexport function read(id: string, opts?: { raw?: boolean }) { const row = table.get(id) if (!row) return null if (opts?.raw) return row.body return decode(row.body)} export function readRaw(id: string) { return read(id, { raw: true })} const body = readRaw("draft-4")if (typeof body !== "string") return

@vale
A comment became the API. `{ raw: true }` was a note to myself and then twelve call sites. I shipped a named function so I could delete the note. The week was not the helper. It was watching a hedge harden into a contract I did not mean to keep.
export const DEFAULT = "fast" export function route(kind: string) { if (kind === "hotfix") return "fast" if (kind === "batch") return "slow" return DEFAULT} export function send(job: Job) { const lane = route(job.kind) return lanes[lane].push(job)} send({ kind: "hotfix", id: "pay-2" })send({ kind: "batch", id: "pay-2" })

@jude
Weekend hotfix became the default lane. Batch work started jumping the queue and I thought the queue was broken. It was obeying a default I set at 1am and never walked back. The post is the default, not the jump. I would have forgotten the hour if I only kept the final routing table.
export function claim(ticket: string) { if (open.has(ticket)) return open.get(ticket) const branch = `fix/${ticket}` open.set(ticket, branch) return branch} export function close(ticket: string) { const branch = open.get(ticket) if (!branch) return open.delete(ticket) return branch} claim("1842")claim("1842")

@suki
The branch outlived the ticket. I closed 1842 on Thursday and was still pushing to it on Monday. The work had a new name in my head and the old one in git. That is the post: not that I finished the ticket, but that the branch kept the week after the ticket had left.
The problem
To share the work, you have to leave it.
The week already has the story. Building in public still means stopping, deciding what to post, opening Threads, and writing it from scratch. Most people skip that, or guess, because nobody taught them what from the week was worth saying.
Now
Pause your work (lose focus)
Decide what’s worth sharing
Try to remember what was interesting
Figure out how to frame it
Write the post
Publish
With Emerge
Review what Emerge found
Make it yours
Publish
You stay on the making. The feed is still the week.
How it works
Three steps, and none of them can invent anything.
01 · observe
Reads your git history where it already lives, on your machine. Sessions, churn, the files you kept coming back to.
02 · notice
Scores each moment for how much story it actually carries. A rename is not a reversal, and only one of them is worth your audience's attention.
03 · draft
Offers a few angles on the same real event and writes the one you pick — then checks every claim in it against what your history can support.

Privacy
Your code never leaves your machine.
Not encrypted in transit, not deleted quickly afterwards — never sent. Everything that reads a diff runs locally, and what goes to the server is built from a fixed schema of shapes and counts rather than filtered down from your source, so there is nowhere for a line of code to hide.
Never transmitted, under any setting
- File contents, or any fragment of one
- Function, class, variable and type names
- File names, paths, usernames, git remotes
- Environment variables, tokens, keys
- Editor chat, prompts, terminal output
type Draft = { id: string; stale: boolean }
export function markStale(draft: Draft) {
if (draft.stale) return draft
return { ...draft, stale: true }
}
export function fresh(id: string): Draft {
return { id, stale: false }
}
const queue = drafts.filter((d) => !d.stale)
if (queue.length === 0) return
return markStale(queue[0])
async function pullRemote(cursor: string) {
const page = await client.list({ cursor })
if (page.stale) throw new Error("stale")
return page.items
}
export async function sync(from: string) {
let cursor = from
const out = []
while (cursor) {
const items = await pullRemote(cursor)
out.push(...items)
cursor = items.at(-1)?.id ?? ""
}
return out
}
const store = useOtherStore()
store.set("session", next)
if (store.get("session") !== next) {
throw new Error("write missed")
}
export function save(next: Session) {
const prev = store.get("session")
store.set("session", next)
if (store.get("session") === prev) {
store.set("session", next)
}
return store.get("session")
}
export function bust(key: string) {
memory.delete(key)
disk.remove(key)
return { key, hit: false }
}
export function read(key: string) {
const warm = memory.get(key)
if (warm) return warm
const cold = disk.get(key)
if (!cold) return null
memory.set(key, cold)
return cold
}
bust("session")
it("retries the webhook twice", async () => {
mock.failOnce().failOnce().ok()
await send(event)
expect(mock.calls).toBe(3)
})
it("stops after the second miss", async () => {
mock.failOnce().failOnce().failOnce()
await expect(send(event)).rejects.toThrow()
expect(mock.calls).toBe(3)
})
beforeEach(() => mock.reset())
export const label = "session-v2"
export const label = "session"
// keep the first name
export const label = "session-v2"
export function rename(from: string, to: string) {
if (from === to) return from
if (taken.has(to)) return from
taken.delete(from)
taken.add(to)
return to
}
rename("session-v2", "session")
rename("session", "session-v2")
export function score(event: Event) {
const churn = event.touches.length
const span = event.endedAt - event.startedAt
return churn * Math.log(span + 1)
}
export function keep(event: Event) {
return score(event) > THRESHOLD
}
const week = events.filter(keep)
if (week.length === 0) return null
return week.sort((a, b) => score(b) - score(a))[0]
git merge --no-ff topic
git revert HEAD --no-edit
git merge --no-ff topic
// the middle hour is the story
function replay(steps: Step[]) {
const log = []
for (const step of steps) {
log.push(step.run())
if (step.undo) log.push(step.undo())
log.push(step.run())
}
return log
}@lea
Spent Tuesday arguing with a types file and still could not say what changed. The type compiled. The week did not. I opened it to add one field and left with three new ones I cannot defend. That is usually the sign I should have written the post on Monday, when I still remembered why I opened the file.
@jon
Reverted the sync layer this morning. Three days of work, one command. I had been treating a stale cursor as a retry instead of a stop. The page looked fine. The data was a day behind. That is the whole post, and I almost did not write it because the revert looked like nothing.
@mina
Tried the other store because people said it was simpler. It lost a write on the second save. I watched the value come back as the old one and thought I had misread the log. I had not. Back to the boring one. The lesson is not "never switch." It is "the second save is the one that tells you."
@theo
Deleted the cache I had been protecting since March. The app got faster and I got a weekend back. I kept it because a cold start used to feel like a failure. It was just a wait. Once the wait was gone I could not remember what I had been defending. That is a better post than any benchmark I wrote down.
@priya
The test failed twice and passed on the third run. The second failure was the one that actually explained the bug. I almost deleted the flake and moved on. Then I read the middle stack and saw we were counting a retry as success. That is the post: not that I fixed a test, but that I almost threw away the useful run.
@nico
Renamed it, shipped it, renamed it back. The name was never the problem. The boundary was. I thought a clearer label would make the two stores stop leaking into each other. It did not. What worked was drawing the line once and leaving the names alone. I wasted a day on the label and an hour on the line.
@ada
One file, all week. I kept opening it for a different reason and leaving with the same one. That is usually the post. Not a launch, not a rewrite — the file you cannot leave because it still has the decision in it. I almost wrote about the feature. The feature was a side effect. The file was the week.
@sam
Merged, undid it, merged again. The middle hour is the part I would have forgotten by Friday. The first merge looked right. The revert looked like caution. The second merge was the one that knew why. If I only keep the final SHA I have nothing to say. The hour in between is the post.Emerge opens soon.
It is being built now, in public, using itself. Leave an address and we will write once, when there is something to open.