Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 105 additions & 24 deletions src/lib/aiAtlas/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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',
]);
Comment on lines +45 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix for the map-node overflow risk, but brandTitle is missing from this list. It renders in the same .doc-header .header-left row as metaLabel (src/pages/ai-atlas.tsx:1546-1559), and that row is white-space: nowrap with no overflow handling (src/styles/ai-atlas.css:116-133). brandTitle is also run through .split(' ').map(w => w.toUpperCase().split('').join(' ')).join(' · ') before rendering (ai-atlas.tsx:1549-1552), which inserts a space between every letter — so a pushed value near the 4000-char general cap would blow out to several times its length and overflow the header, the same failure mode metaLabel/apexLabel/etc. were just capped to prevent.

Since brandTitle sits in the identical nowrap row as metaLabel, it should get the same SHORT_LABEL_MAX treatment.

[Fix this →](https://claude.ai/code?q=In%20src%2Flib%2FaiAtlas%2Fadapter.ts%2C%20add%20%27brandTitle%27%20to%20the%20SHORT_LABEL_KEYS%20Set%20(around%20line%2045-59).%20It%20renders%20in%20the%20same%20.doc-header%20.header-left%20row%20as%20metaLabel%20(src%2Fpages%2Fai-atlas.tsx%20around%20line%201546-1559)%2C%20which%20is%20white-space%3A%20nowrap%20with%20no%20overflow%20handling%20(src%2Fstyles%2Fai-atlas.css%20line%20116-133)%2C%20and%20brandTitle%20is%20additionally%20expanded%20by%20a%20per-letter-spacing%20transform%20before%20render%2C%20amplifying%20any%20overflow.%20It%20should%20get%20the%20same%20SHORT_LABEL_MAX%20cap%20as%20metaLabel%20since%20a%20long%20pushed%20value%20would%20overflow%20the%20header%20the%20same%20way%20the%20map%20node%20labels%20did%20before%20this%20fix.&repo=keepsimpleio/KeepSimpleOSS

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]) {
Expand All @@ -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'],
Expand All @@ -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'],
Expand Down Expand Up @@ -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',
Expand All @@ -164,27 +236,27 @@ 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,
ref: dossiers['system-' + id] ? 'system-' + id : id,
})),
};
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,
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/lib/aiAtlas/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const features: Record<string, string[]> = {
/* ---------- 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': [
Expand Down Expand Up @@ -258,7 +258,7 @@ const features: Record<string, string[]> = {
/* ---------- 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': [
Expand Down Expand Up @@ -328,7 +328,7 @@ const features: Record<string, string[]> = {
],
// 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
Expand Down
84 changes: 82 additions & 2 deletions src/lib/aiAtlas/stripGuide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<string>) => {
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 {
Expand All @@ -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']),
Expand All @@ -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 {
Expand Down
Loading
Loading