style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)

Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-04 15:58:06 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent a5154ee884
commit ec27228a8e
1588 changed files with 28388 additions and 10890 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'No active company' }, { status: 400 })
}
// Defense in depth alongside RLS — confirm membership before composing.
// Defense in depth alongside RLS: confirm membership before composing.
const { data: membership } = await supabase
.from('company_members')
.select('role')
+2 -2
View File
@@ -38,7 +38,7 @@ export async function GET(
if (convErr) return NextResponse.json({ error: convErr.message }, { status: 500 })
if (!conv) return NextResponse.json({ error: 'Conversation not found' }, { status: 404 })
// Defense in depth alongside RLS — verify caller is a member of the
// Defense in depth alongside RLS: verify caller is a member of the
// conversation's company AND owns the conversation row. Conversations are
// user-scoped within a company; one team member should not see another's.
if (conv.user_id !== user.id) {
@@ -91,7 +91,7 @@ export async function PATCH(
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
}
// Defense in depth — verify ownership before update so a 404 is returned
// Defense in depth: verify ownership before update so a 404 is returned
// (instead of relying solely on RLS, which would silently 0-row).
const { data: existing } = await supabase
.from('agent_conversations')
+1 -1
View File
@@ -7,7 +7,7 @@ import { getActiveCompanyId } from '@/lib/company/context'
// Query params:
// archived: 'true' | 'false' (default 'false')
// pinned: 'true' filters to pinned only
// intent: 'general.help' (or any intent id) — filter
// intent: 'general.help' (or any intent id), filter
// q: case-insensitive substring match on title/context_ref
// limit: 1..200, default 50
//
+8 -8
View File
@@ -10,7 +10,7 @@ import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
// Make sure extensions are loaded — the chat loop dispatches against the
// Make sure extensions are loaded: the chat loop dispatches against the
// agent tool registry which is populated by the mcp-server extension at load.
ensureInitialized()
@@ -62,7 +62,7 @@ const BodySchema = z.object({
// POST /api/agent/invoke
//
// Streams NDJSON events from the chat loop. Each line is a JSON object whose
// `kind` identifies the event type — see lib/agent/chat/run-turn.ts StreamEvent.
// `kind` identifies the event type: see lib/agent/chat/run-turn.ts StreamEvent.
//
// Auth: the user must be a member of the resolved company.
//
@@ -72,7 +72,7 @@ export async function POST(request: Request) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Generous per-user rate limit — bounds runaway Bedrock spend (loop-firing
// Generous per-user rate limit: bounds runaway Bedrock spend (loop-firing
// sessions). Fails open on infra error.
const rate = await checkAgentRateLimit(supabase, user.id)
if (!rate.ok) {
@@ -108,7 +108,7 @@ export async function POST(request: Request) {
.maybeSingle()
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
// No Anthropic Bedrock calls in the sandbox — the demo runs entirely on
// No Anthropic Bedrock calls in the sandbox: the demo runs entirely on
// seed data and the assistant is gated to a "look, don't touch" preview.
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
@@ -116,12 +116,12 @@ export async function POST(request: Request) {
const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.ai)
if (capBlocked) return capBlocked
// onboarding.intake completion signal — once the user has actually
// onboarding.intake completion signal: once the user has actually
// engaged (typed a real reply, not the auto-fired greeting prompt that
// mounts the chat), stamp intake_completed_at on the profile so re-entry
// logic and opportunistic follow-up logic in other intents can tell the
// intake happened. Idempotent: the IS NULL guard ensures we never
// overwrite the first engagement timestamp. Best-effort — failure here
// overwrite the first engagement timestamp. Best-effort: failure here
// doesn't break the chat; the next user turn retries.
if (
body.intent_id === 'onboarding.intake' &&
@@ -136,7 +136,7 @@ export async function POST(request: Request) {
.eq('company_id', companyId)
.is('intake_completed_at', null)
} catch {
// ignored — see comment above
// ignored: see comment above
}
}
@@ -210,7 +210,7 @@ export async function POST(request: Request) {
}
}
// Stream — NDJSON events from the chat loop.
// Stream: NDJSON events from the chat loop.
const encoder = new TextEncoder()
// Conversation id is set above; capture into a non-null local for the
// streaming closure's first emission.
+3 -3
View File
@@ -8,13 +8,13 @@ import { requireWritePermission } from '@/lib/auth/require-write'
// Mutate a single memory entry. Powers the list/edit/pin/dismiss affordances
// on /settings/agent-memory (plan §11).
//
// content — edit the durable text (kind never changes; that would
// content: edit the durable text (kind never changes; that would
// muddle the audit lineage). Append-only is preserved by
// superseded_by chains when an upstream caller wants it; the
// transparency UI is allowed to overwrite in place because
// the row's `updated_at` already documents the edit.
// is_pinned — boost into the top-N prompt block regardless of score.
// is_active — false = dismiss (removes from ranking pool); true = restore.
// is_pinned: boost into the top-N prompt block regardless of score.
// is_active: false = dismiss (removes from ranking pool); true = restore.
//
// RLS scopes to user_company_ids(); we ALSO re-verify membership for the
// row's company_id as defense in depth before mutating.
+1 -1
View File
@@ -55,7 +55,7 @@ describe('GET /api/agent/memory', () => {
{
id: 'mem-1',
kind: 'fact',
content: 'Räkenskapsår jan–dec',
content: 'Räkenskapsår jan-dec',
source: 'composer',
source_ref: null,
relevance_score: 0.5,
+15 -15
View File
@@ -20,7 +20,7 @@ const BodySchema = z.object({
company_id: z.string().uuid().optional(),
})
// 10s — the user is on a wait-screen with visible progress, so we can afford
// 10s: the user is on a wait-screen with visible progress, so we can afford
// the longer budget. The prior 5s clipped legitimate fetches (TIC fans out to
// ~13 Lens calls upstream) into the fallback bucket while still burning the
// in-flight upstream calls against quota. See actions.ts:182-189 for the
@@ -58,17 +58,17 @@ interface ProfilePayload {
// POST /api/agent/onboarding/stream
//
// Streams real-timed progress for the agent build sequence (plan §7 Phase A).
// Each step runs on its actual latency — no artificial delays. On timeout or
// Each step runs on its actual latency: no artificial delays. On timeout or
// failure, the step emits `fallback` and the pipeline continues with a
// deterministic default so the user always reaches Phase B.
//
// Response: application/x-ndjson — one JSON event per line.
// Response: application/x-ndjson, one JSON event per line.
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Generous per-user rate limit — bounds reload-spam of the onboarding build
// Generous per-user rate limit: bounds reload-spam of the onboarding build
// (each run fires 2 LLM calls). Fails open on infra error.
const rate = await checkAgentRateLimit(supabase, user.id)
if (!rate.ok) {
@@ -103,7 +103,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
}
// No live composer run for sandbox companies — they ship with a pre-built
// No live composer run for sandbox companies: they ship with a pre-built
// verified agent_profile so the chrome is visible without burning Bedrock.
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
@@ -119,12 +119,12 @@ export async function POST(request: Request) {
controller.enqueue(encoder.encode(JSON.stringify(event) + '\n'))
} catch {
// Stream was cancelled (user navigated away). Subsequent enqueues
// would throw — we just stop emitting.
// would throw: we just stop emitting.
}
}
try {
// Step 1 — TIC: read companies.tic_snapshot; if missing or stale,
// Step 1: TIC: read companies.tic_snapshot; if missing or stale,
// live-fetch from the TIC extension (cookies forwarded from the
// incoming request) and persist. Falls through gracefully when TIC is
// disabled, the company has no org_number, or the request times out.
@@ -146,7 +146,7 @@ export async function POST(request: Request) {
// bounded to companies actively creating an agent, so the TIC
// budget stays safe even when upgrading pre-v2 snapshots.
// timeoutMs: lift the internal fetch signal to match the outer
// budget — otherwise the 5s default fires first and we get the
// budget: otherwise the 5s default fires first and we get the
// pre-fix behavior even with the longer outer budget.
ensureTicSnapshot({
supabase,
@@ -163,10 +163,10 @@ export async function POST(request: Request) {
status: ticResult.snapshot ? 'success' : 'fallback',
})
// Gather inputs once — used by select + narrative + persistence.
// Gather inputs once: used by select + narrative + persistence.
const inputs = await gatherComposerInputs(supabase, companyId)
// Step 2 — Opus atom selection with timeout + deterministic fallback.
// Step 2: Opus atom selection with timeout + deterministic fallback.
send({ step: 'select', status: 'in_progress' })
let selection: AtomSelection
try {
@@ -181,14 +181,14 @@ export async function POST(request: Request) {
// path produced the selection. fallbackAtomSelection generates a
// generic template that doesn't know about KÄNDA FAKTA; the Opus
// path is also re-filtered in case the model strayed. Cheap belt-
// and-braces — same function used for both.
// and-braces: same function used for both.
selection.verification_questions = filterRedundantQuestions(
selection.verification_questions,
inputs,
selection.modifier_atoms,
)
// Step 3 — Sonnet narrative with timeout + plain fallback.
// Step 3: Sonnet narrative with timeout + plain fallback.
send({ step: 'narrative', status: 'in_progress' })
let narrative: string
try {
@@ -199,7 +199,7 @@ export async function POST(request: Request) {
send({ step: 'narrative', status: 'fallback', narrative })
}
// Step 4 — Persist the profile so Phase B has a row to edit.
// Step 4: Persist the profile so Phase B has a row to edit.
const composedAt = new Date().toISOString()
const sourceSignals = inputsToSourceSignals(inputs)
const { error: upsertErr } = await supabase
@@ -243,7 +243,7 @@ export async function POST(request: Request) {
},
})
// Step 5 — fire-and-forget cache pre-warm. The client renders the
// Step 5: fire-and-forget cache pre-warm. The client renders the
// review card already; pre-warm just buys a faster first chat turn.
send({ step: 'prewarm', status: 'in_progress' })
const allIds = [
@@ -288,7 +288,7 @@ export async function POST(request: Request) {
}
// Run a promise against a wall-clock budget. The underlying work continues to
// completion on the server when the budget elapses — we just stop waiting for
// completion on the server when the budget elapses: we just stop waiting for
// it. For Anthropic calls that's fine: a slow Opus turn finishing later still
// warms its own cache.
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
+4 -4
View File
@@ -11,7 +11,7 @@ import { getActiveCompanyId } from '@/lib/company/context'
//
// PATCH updates field_overrides (timestamped, merged with existing) and
// optionally rewrites the atom arrays from the review UI. Does not touch
// verified_at — that flows through /verify.
// verified_at: that flows through /verify.
const AtomArrays = z.object({
horizontal_atoms: z.array(z.string()).optional(),
@@ -24,7 +24,7 @@ const PatchBody = z.object({
field_overrides: z.record(z.string(), z.unknown()).optional(),
atoms: AtomArrays.optional(),
profile_summary: z.string().min(1).max(2000).optional(),
// Agent personalization — name shown on the FAB and chat headers, and
// Agent personalization: name shown on the FAB and chat headers, and
// avatar key into the static AVATAR_OPTIONS registry. Both nullable so
// the user can clear them back to defaults.
display_name: z.string().min(1).max(60).nullable().optional(),
@@ -41,7 +41,7 @@ export async function GET(request: Request) {
url.searchParams.get('company_id') ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
// Defense in depth alongside RLS — confirm membership before reading.
// Defense in depth alongside RLS: confirm membership before reading.
const { data: membership } = await supabase
.from('company_members')
.select('role')
@@ -81,7 +81,7 @@ export async function PATCH(request: Request) {
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
// RLS guards reads/updates by company_id; defense in depth — confirm membership.
// RLS guards reads/updates by company_id; defense in depth: confirm membership.
const { data: membership } = await supabase
.from('company_members')
.select('role')
+2 -2
View File
@@ -6,7 +6,7 @@ import { getActiveCompanyId } from '@/lib/company/context'
// POST /api/agent/profile/verify
//
// Stamps verified_at + verified_by_user_id on agent_profiles when the user
// clicks "Det här ser rätt ut — kör" in Phase B. Idempotent: re-verifying
// clicks "Det här ser rätt ut, kör" in Phase B. Idempotent: re-verifying
// updates the timestamp; this is desirable for "Bygg om" rebuild flows.
const BodySchema = z.object({
@@ -31,7 +31,7 @@ export async function POST(request: Request) {
const companyId = body.company_id ?? (await getActiveCompanyId(supabase, user.id))
if (!companyId) return NextResponse.json({ error: 'No active company' }, { status: 400 })
// Defense in depth alongside RLS — confirm membership for the target
// Defense in depth alongside RLS: confirm membership for the target
// company; a non-viewer role is required to stamp verified_at.
const { data: membership } = await supabase
.from('company_members')
+3 -3
View File
@@ -5,10 +5,10 @@ import { getActiveCompanyId } from '@/lib/company/context'
// GET /api/agent/skills
//
// Read-only transparency surface for the in-app bookkeeping assistant's domain
// knowledge ("atoms"). Powers /settings/agent-skills — the companion to
// knowledge ("atoms"). Powers /settings/agent-skills: the companion to
// /settings/agent-memory. Memory is what the assistant *learned* about this
// company (user-editable); skills are the Swedish-accounting expertise it
// *ships* with — authored in .claude/skills/**/SKILL.md, seeded into
// *ships* with: authored in .claude/skills/**/SKILL.md, seeded into
// agent_atom_registry, read-only for users, curated via mcp_exposed.
//
// Two shapes off one route:
@@ -19,7 +19,7 @@ import { getActiveCompanyId } from '@/lib/company/context'
//
// "Active for this company": horizontal atoms are regulatory and shared by
// every Swedish company, so always active. Vertical/modifier atoms are active
// only when the composer selected them into this company's agent_profile —
// only when the composer selected them into this company's agent_profile:
// others are shown dormant so the user sees both the full library and what's
// tuned for them. The profile arrays store full ids ("vertical/konsult-it"),
// matched directly against agent_atom_registry.id (see lib/agent/chat/system-prompt.ts).