diff --git a/src/lib/aiAtlas/adapter.ts b/src/lib/aiAtlas/adapter.ts index daff8dbc..edeacea2 100644 --- a/src/lib/aiAtlas/adapter.ts +++ b/src/lib/aiAtlas/adapter.ts @@ -6,11 +6,72 @@ export const copy: any = { linesValue: (n: number) => String(n), introInhabitantsTpl: () => '', }; -/* Wolf's prose for a card, or the guide's own text when he has none. */ -const describe = (id: string, fallback: string[]) => - features[id] || fallback.filter(Boolean); +/* Page words the Terminal may replace with a push, beyond copy.json. */ +export const PAGE_TEXT_DEFAULTS: Record = { + brandTitle: 'Wolf’s Terminal', + apexLabel: 'WOLF', + apexSub: 'direction', + orderLabel: 'The Order', + resourceAgents: 'Colleagues', + resourceMemory: 'Memory', + resourceTools: 'Tools', + resourceModels: 'Models', + ringOrderLabel: 'I · Ownership', + ringDevEnvLabel: 'II · Resources', + ringProjectsLabel: 'III · Task lifecycle', + ringTerritoriesLabel: 'IV · Mechanisms', + ringOrderTitle: 'Ownership', + ringDevEnvTitle: 'Resources', + ringDevEnvDesc: + 'What every stage of a task draws on: colleagues, memory, tools, models.', + ringProjectsTitle: 'Task lifecycle', + ringProjectsDesc: + 'The rings are the stages of one task, clockwise from Project to Result.', + ringTerritoriesTitle: 'Mechanisms', + ringTerritoriesDesc: 'Every topic opens its own card.', + metaLabel: 'TERMINAL DOCUMENTATION', + topicsPlaceholder: 'The Atlas', + stagesAria: 'Project to Result, clockwise', +}; + +/* Keys of copy.json a push may replace: the plain strings. */ +export const PAGE_TEXT_KEYS = new Set([ + ...Object.keys(base).filter(key => typeof (base as any)[key] === 'string'), + ...Object.keys(PAGE_TEXT_DEFAULTS), +]); + +/* Labels drawn inside map nodes and rings: short, or they spill over the + map. A push keeps them to SHORT_LABEL_MAX characters. */ +export const SHORT_LABEL_KEYS = new Set([ + 'brandTitle', + 'apexLabel', + 'apexSub', + 'orderLabel', + 'resourceAgents', + 'resourceMemory', + 'resourceTools', + 'resourceModels', + 'ringOrderLabel', + 'ringDevEnvLabel', + 'ringProjectsLabel', + 'ringTerritoriesLabel', + 'metaLabel', + 'topicsPlaceholder', +]); +export const SHORT_LABEL_MAX = 40; + +/* How many tiles one stage can carry before its arc runs into the next. */ +export const MAX_TILES_PER_STAGE = 3; export function adaptGuide(guide: any) { + /* Words pushed with the guide as `copy` win over the built-in ones. */ + const text = (key: string) => + guide.copy?.[key] ?? PAGE_TEXT_DEFAULTS[key] ?? (copy as any)[key]; + /* Wolf's prose for a card: pushed with the guide as `cards` when the + Terminal sends it, else the copy in features.ts, else the guide's own + text. */ + const describe = (id: string, fallback: string[]) => + guide.cards?.[id] || features[id] || fallback.filter(Boolean); const dossiers: any = {}; const entries = new Map(guide.entries.map((entry: any) => [entry.id, entry])); for (const entry of [...guide.entries, ...guide.system.nodes]) { @@ -36,7 +97,16 @@ export function adaptGuide(guide: any) { voice; they are not drawn. */ const systemRef = (id: string) => dossiers['system-' + id] ? 'system-' + id : id; - const labels = ['Project', 'Task', 'Dispatch', 'Prepare', 'Work', 'Result']; + /* A push may set each stage's label and the tiles drawn on it as + `stages`; otherwise the map keeps the selection below. */ + const labels = [ + 'Project', + 'Task', + 'Dispatch', + 'Prepare', + 'Work', + 'Result', + ].map((label, i) => guide.stages?.[i]?.label ?? label); const angles = [210, 270, 330, 30, 90, 150]; const chosen = [ ['keys', 'backlog'], @@ -45,7 +115,7 @@ export function adaptGuide(guide: any) { ['global', 'local', 'session-resume'], ['work-checks', 'sendto', 'human-collab'], ['history', 'decisions', 'discipline'], - ]; + ].map((tiles, i) => guide.stages?.[i]?.tiles ?? tiles); const topicToStage: any = {}; const support = [ ['order'], @@ -139,6 +209,8 @@ export function adaptGuide(guide: any) { ]; const t: any = { ...copy, + ...PAGE_TEXT_DEFAULTS, + ...(guide.copy || {}), securityLayers: layerIds.map((id, index) => ({ n: index + 1, side: index % 2 ? 'right' : 'left', @@ -164,13 +236,13 @@ export function adaptGuide(guide: any) { ), }; dossiers['ring:order'] = { - title: 'Ownership', + title: text('ringOrderTitle'), desc: nodeDesc('order'), rows: [], }; dossiers['ring:devEnv'] = { - title: 'Resources', - desc: 'What every stage of a task draws on: colleagues, memory, tools, models.', + title: text('ringDevEnvTitle'), + desc: text('ringDevEnvDesc'), rows: ['agents', 'memory', 'tools', 'models'].map(id => ({ k: 'resource', v: node(id).title, @@ -178,13 +250,13 @@ export function adaptGuide(guide: any) { })), }; dossiers['ring:projects'] = { - title: 'Task lifecycle', - desc: 'The rings are the stages of one task, clockwise from Project to Result.', + title: text('ringProjectsTitle'), + desc: text('ringProjectsDesc'), rows: projects.map((p: any) => ({ k: 'stage', v: p.label, ref: p.id })), }; dossiers['ring:territories'] = { - title: 'Mechanisms', - desc: 'Every topic opens its own card.', + title: text('ringTerritoriesTitle'), + desc: text('ringTerritoriesDesc'), rows: guide.entries.map((e: any) => ({ k: 'mechanism', v: e.title, @@ -195,31 +267,40 @@ export function adaptGuide(guide: any) { topicToStage, relations, copy: t, - brand: { title: 'Wolf’s Terminal', kanji: '天' }, + brand: { title: text('brandTitle'), kanji: '天' }, ringLabels: { - order: { label: 'I · Ownership', theta: 270, offset: 0.09 }, - devEnv: { label: 'II · Resources', theta: 270 }, - projects: { label: 'III · Task lifecycle', theta: 270, offset: 0.08 }, - territories: { label: 'IV · Mechanisms', theta: 270 }, + order: { label: text('ringOrderLabel'), theta: 270, offset: 0.09 }, + devEnv: { label: text('ringDevEnvLabel'), theta: 270 }, + projects: { + label: text('ringProjectsLabel'), + theta: 270, + offset: 0.08, + }, + territories: { label: text('ringTerritoriesLabel'), theta: 270 }, }, apex: { id: 'wolf', - label: 'WOLF', + label: text('apexLabel'), cjk: '天', - sub: 'direction', + sub: text('apexSub'), diamond: 'gold', }, order: { r: 0.2, - member: { id: 'order', label: 'The Order', diamond: 'blue', theta: 270 }, + member: { + id: 'order', + label: text('orderLabel'), + diamond: 'blue', + theta: 270, + }, }, devEnv: { r: 0.39, members: [ - ['agents', 'Colleagues'], - ['memory', 'Memory'], - ['tools', 'Tools'], - ['models', 'Models'], + ['agents', text('resourceAgents')], + ['memory', text('resourceMemory')], + ['tools', text('resourceTools')], + ['models', text('resourceModels')], ].map(([id, label], i) => ({ id: dossiers['system-' + id] ? 'system-' + id : id, label, diff --git a/src/lib/aiAtlas/features.ts b/src/lib/aiAtlas/features.ts index 05af2e78..f83f8e9d 100644 --- a/src/lib/aiAtlas/features.ts +++ b/src/lib/aiAtlas/features.ts @@ -91,7 +91,7 @@ const features: Record = { /* ---------- dispatch ---------- */ // card: Engine project: [ - 'Every project tile runs on Claude or on OpenAI’s Codex. I choose the engine per project, and Terminal runs the agent inside that project’s folder with that engine.', + 'Every project tile runs on Claude or on OpenAI’s Codex. I choose the engine per project, and Terminal runs the agent inside that project’s folder with that engine. Grok is the third one. xAI’s model runs on the same tiles, on one login, and Terminal keeps every Grok turn in its own archive, so nothing that lane produces can be lost.', ], // card: Engine switch 'engine-switch': [ @@ -258,7 +258,7 @@ const features: Record = { /* ---------- the result ---------- */ // card: Delivered work 'delivered-work': [ - 'The agent reports what changed and what it checked. The work itself stays in the project as files, commits and deployments. I read the report against that evidence, not on its own.', + 'The agent reports what changed and what it checked. The work itself stays in the project as files, commits and deployments. I read the report against that evidence, not on its own. Done is not the agent’s word either. Before the report reaches me, a reviewer on a different vendor’s model reads the task in my own words and a snapshot of the project. It has none of the author’s context, so it cannot share the author’s blind spots. GPT does that review, Grok steps in when GPT is down, and it is never a Claude checking a Claude.', ], // card: Review result 'review-result': [ @@ -328,7 +328,7 @@ const features: Record = { ], // card: Models (resource ring) models: [ - 'The thinking happens at the provider. Today that is Claude and OpenAI’s Codex, several subscriptions of each, and the tools stay on my server whichever one is thinking.', + 'The thinking happens at the provider. Today that is Claude and OpenAI’s Codex, several subscriptions of each, and the tools stay on my server whichever one is thinking. xAI’s Grok is the third provider on the same terms.', 'The switch between them is my own code, so a third engine is a slot on it and not a rewrite. The groundwork for local models is in place: the box is chosen, the model is picked and the plan is written. It is not running yet, and the atlas says so.', ], // card: Terminal diff --git a/src/lib/aiAtlas/stripGuide.ts b/src/lib/aiAtlas/stripGuide.ts index 19ea673d..d6fc826c 100644 --- a/src/lib/aiAtlas/stripGuide.ts +++ b/src/lib/aiAtlas/stripGuide.ts @@ -5,7 +5,13 @@ of it is kept. The same cut as scripts/ai-atlas/strip-guide.mjs, which refreshes the bundled fallback; the two keep the same field lists. */ -import { adaptGuide } from './adapter'; +import { + adaptGuide, + MAX_TILES_PER_STAGE, + PAGE_TEXT_KEYS, + SHORT_LABEL_KEYS, + SHORT_LABEL_MAX, +} from './adapter'; const pick = (obj: any, keys: string[]) => Object.fromEntries( @@ -19,6 +25,64 @@ const isRecordList = (value: unknown) => item && typeof item === 'object' && typeof (item as any).id === 'string', ); +/* Wolf's card texts, pushed by the Terminal: card id to paragraphs. Only + plain strings pass; a card listed here replaces its copy in features.ts. */ +const isCards = (value: unknown) => + value === undefined || + (!!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.entries(value).every( + ([id, paragraphs]) => + /^[a-z0-9-]+$/.test(id) && + Array.isArray(paragraphs) && + paragraphs.length > 0 && + paragraphs.every( + p => typeof p === 'string' && p.length > 0 && p.length <= 4000, + ), + )); + +/* Page words, pushed by the Terminal: a copy key to its new text. Only + keys the page already has pass. */ +const isCopy = (value: unknown) => + value === undefined || + (!!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.entries(value).every( + ([key, words]) => + PAGE_TEXT_KEYS.has(key) && + typeof words === 'string' && + words.length > 0 && + words.length <= (SHORT_LABEL_KEYS.has(key) ? SHORT_LABEL_MAX : 4000), + )); + +/* The map, pushed by the Terminal: per stage, in step order, an optional + label and the tiles drawn on it. Every tile must be an entry. */ +const stagesError = (value: unknown, steps: number, ids: Set) => { + if (value === undefined) return null; + if (!Array.isArray(value) || value.length !== steps) + return `stages must list all ${steps} stages in step order`; + for (const stage of value) { + if (!stage || typeof stage !== 'object') return 'a stage is not an object'; + if ( + stage.label !== undefined && + (typeof stage.label !== 'string' || stage.label.length > 40) + ) + return 'a stage label must be text up to 40 characters'; + if (stage.tiles === undefined) continue; + if ( + !Array.isArray(stage.tiles) || + stage.tiles.length === 0 || + stage.tiles.length > MAX_TILES_PER_STAGE + ) + return `a stage draws 1 to ${MAX_TILES_PER_STAGE} tiles`; + const missing = stage.tiles.find((id: unknown) => !ids.has(id as string)); + if (missing !== undefined) return `tile ${missing} is not an entry`; + } + return null; +}; + export type StripResult = { guide: any } | { error: string }; export function stripGuide(input: any): StripResult { @@ -34,8 +98,20 @@ export function stripGuide(input: any): StripResult { return { error: 'system.nodes missing or malformed' }; if (input.steps.some((s: any) => !Array.isArray(s.children))) return { error: 'a step has no children list' }; + if (!isCards(input.cards)) + return { error: 'cards must map a card id to a list of paragraphs' }; + if (!isCopy(input.copy)) + return { + error: `copy must map a known page text key to non-empty text, labels up to ${SHORT_LABEL_MAX} characters`, + }; + const stages = stagesError( + input.stages, + input.steps.length, + new Set(input.entries.map((e: any) => e.id)), + ); + if (stages) return { error: stages }; - const guide = { + const guide: any = { generatedAt: input.generatedAt, steps: input.steps.map((s: any) => pick(s, ['id', 'title', 'location', 'text', 'children']), @@ -49,6 +125,10 @@ export function stripGuide(input: any): StripResult { ), }, }; + if (input.cards) guide.cards = input.cards; + if (input.copy) guide.copy = input.copy; + if (input.stages) + guide.stages = input.stages.map((s: any) => pick(s, ['label', 'tiles'])); /* The page must be able to draw what is stored; a guide it cannot draw is refused and the page keeps the last one that worked. */ try { diff --git a/src/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index 90422336..f0882679 100644 --- a/src/pages/ai-atlas.tsx +++ b/src/pages/ai-atlas.tsx @@ -7,7 +7,7 @@ import React, { useState, } from 'react'; -import { adaptGuide, copy } from '@lib/aiAtlas/adapter'; +import { adaptGuide, copy, PAGE_TEXT_DEFAULTS } from '@lib/aiAtlas/adapter'; import bundledGuide from '@lib/aiAtlas/guide.json'; import { securityPassage, securityRadii } from '@lib/aiAtlas/securityPassage'; @@ -1560,7 +1560,7 @@ export function AiAtlasApp({ {t.welcomeBanner}
- TERMINAL DOCUMENTATION + {t.metaLabel}
@@ -1662,10 +1662,7 @@ export function AiAtlasApp({ - + {data.projects.members .slice(0, -1) .map((p: any, i: number) => { @@ -2073,7 +2070,7 @@ export function AiAtlasApp({ setFocusedNode(e.target.value || null); }} > - + {Object.entries(data.dossiers).map(([id, d]: any) => (