whale-igniter 1.4.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -86,7 +86,7 @@ export async function decisionCommand(options = {}) {
86
86
  consequences: consequences?.trim() || undefined
87
87
  });
88
88
  console.log();
89
- console.log(ui.ok(`Decision recorded ${ui.muted("— " + created.id.slice(0, 8))}`));
89
+ console.log(ui.ok(`Decision recorded ${ui.muted("— " + created.id)}`));
90
90
  console.log(` ${ui.accent(created.title)}`);
91
91
  console.log(` ${ui.kv("category", created.category, { keyWidth: 8 })}`);
92
92
  if (options.sync !== false) {
@@ -0,0 +1,159 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ import { resolveTarget } from "../utils/paths.js";
4
+ import { loadConfig } from "../utils/config.js";
5
+ import { loadComponents } from "../utils/components.js";
6
+ import { ui } from "../ui/index.js";
7
+ export async function forgeCommand(componentName, options = {}) {
8
+ const target = resolveTarget();
9
+ const config = await loadConfig(target);
10
+ const components = await loadComponents(target);
11
+ const component = components.find((c) => c.name === componentName);
12
+ if (!component) {
13
+ console.log(ui.fail(`Component "${componentName}" not found in intelligence/components.json.`));
14
+ console.log(ui.muted(` Run \`whale create component ${componentName}\` first, or \`whale component add ${componentName}\`.`));
15
+ process.exitCode = 1;
16
+ return;
17
+ }
18
+ const grid = config.foundations?.grid ?? 8;
19
+ const controlRadius = config.foundations?.radius?.control ?? 2;
20
+ const containerRadius = config.foundations?.radius?.container ?? 4;
21
+ const accent = config.branding?.accent ?? "blue";
22
+ const isControl = isControlCategory(component.category);
23
+ const radius = isControl ? controlRadius : containerRadius;
24
+ const px = grid * 2;
25
+ const py = grid;
26
+ const outDir = options.out ?? "docs/forge";
27
+ const fileRel = path.join(outDir, `${componentName}.md`).replace(/\\/g, "/");
28
+ const fileAbs = path.join(target, fileRel);
29
+ if (await fs.pathExists(fileAbs) && !options.force) {
30
+ console.log(ui.fail(`${ui.path(fileRel)} already exists. Use --force to overwrite.`));
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ await fs.ensureDir(path.dirname(fileAbs));
35
+ const content = renderForgeContract({
36
+ component,
37
+ grid,
38
+ radius,
39
+ px,
40
+ py,
41
+ accent,
42
+ isControl,
43
+ });
44
+ await fs.writeFile(fileAbs, content, "utf8");
45
+ console.log(ui.ok(`Forge contract → ${ui.path(fileRel)}`));
46
+ console.log(` ${ui.kv("component", componentName, { keyWidth: 10 })}`);
47
+ console.log(` ${ui.kv("template", isControl ? "control" : "container", { keyWidth: 10 })}`);
48
+ console.log(` ${ui.kv("grid", `${grid}px`, { keyWidth: 10 })}`);
49
+ console.log(` ${ui.kv("radius", `${radius}px`, { keyWidth: 10 })}`);
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // Renderer
53
+ // ---------------------------------------------------------------------------
54
+ function renderForgeContract(args) {
55
+ const { component, grid, radius, px, py, accent, isControl } = args;
56
+ const { name, category, variants = [], states = [], files = [], tokens = [] } = component;
57
+ const paddingNote = isControl
58
+ ? `${px}px horizontal / ${py}px vertical (${grid}px grid × 2 / × 1)`
59
+ : `${py * 3}px all sides (${grid}px grid × 3)`;
60
+ const derivedTokens = deriveTokens(variants, accent, tokens);
61
+ const checklist = buildChecklist(isControl, variants, states);
62
+ const lines = [
63
+ `# Forge — ${name}`,
64
+ "",
65
+ `> Auto-generated implementation contract. Keep in sync with \`intelligence/components.json\`.`,
66
+ "",
67
+ "## Component spec",
68
+ "",
69
+ `| Property | Value |`,
70
+ `|-----------|-------|`,
71
+ `| Template | ${isControl ? "control" : "container"} |`,
72
+ `| Category | ${category ?? "unspecified"} |`,
73
+ `| Grid | ${grid}px |`,
74
+ `| Radius | ${radius}px |`,
75
+ `| Padding | ${paddingNote} |`,
76
+ ];
77
+ if (variants.length > 0) {
78
+ lines.push(`| Variants | ${variants.join(", ")} |`);
79
+ }
80
+ if (states.length > 0) {
81
+ lines.push(`| States | ${states.join(", ")} |`);
82
+ }
83
+ lines.push("", "## Design tokens", "");
84
+ if (derivedTokens.length > 0) {
85
+ for (const t of derivedTokens) {
86
+ lines.push(`- \`${t.token}\` — ${t.usage}`);
87
+ }
88
+ }
89
+ else {
90
+ lines.push("- No tokens registered. Run `whale selene describe` to infer them.");
91
+ }
92
+ lines.push("", "## Implementation checklist", "");
93
+ for (const item of checklist) {
94
+ lines.push(`- [ ] ${item}`);
95
+ }
96
+ if (files.length > 0) {
97
+ lines.push("", "## Files", "");
98
+ for (const f of files) {
99
+ lines.push(`- \`${f}\``);
100
+ }
101
+ }
102
+ lines.push("", "---", `_Generated by Whale Igniter · \`whale forge ${name}\`_`, "");
103
+ return lines.join("\n");
104
+ }
105
+ function deriveTokens(variants, accent, existing) {
106
+ if (existing.length > 0) {
107
+ return existing.map((t) => ({ token: t, usage: "registered token" }));
108
+ }
109
+ const result = [];
110
+ for (const v of variants) {
111
+ switch (v) {
112
+ case "primary":
113
+ result.push({ token: `--color-${accent}-600`, usage: "primary background" });
114
+ result.push({ token: `--color-${accent}-700`, usage: "primary hover" });
115
+ break;
116
+ case "secondary":
117
+ result.push({ token: `--color-gray-100`, usage: "secondary background" });
118
+ result.push({ token: `--color-gray-200`, usage: "secondary hover" });
119
+ break;
120
+ case "ghost":
121
+ result.push({ token: `--color-gray-100`, usage: "ghost hover background" });
122
+ break;
123
+ case "destructive":
124
+ result.push({ token: `--color-red-600`, usage: "destructive background" });
125
+ break;
126
+ }
127
+ }
128
+ return result;
129
+ }
130
+ function buildChecklist(isControl, variants, states) {
131
+ const items = [];
132
+ if (isControl) {
133
+ items.push("forwardRef implemented with correct HTMLElement type");
134
+ items.push("baseClasses use grid-aligned padding");
135
+ items.push(`radius matches control radius from whale.config.json`);
136
+ }
137
+ else {
138
+ items.push("Container uses grid-aligned padding (grid × 3)");
139
+ items.push("radius matches container radius from whale.config.json");
140
+ }
141
+ if (states.includes("focus") || states.includes("focus-visible")) {
142
+ items.push(":focus-visible ring is visible at 2px offset");
143
+ }
144
+ if (states.includes("disabled")) {
145
+ items.push("disabled state reduces opacity and removes pointer-events");
146
+ }
147
+ if (states.includes("hover")) {
148
+ items.push("hover state uses a darker/lighter token variant, not raw hex");
149
+ }
150
+ for (const v of variants) {
151
+ items.push(`Variant "${v}" visually tested at all states`);
152
+ }
153
+ items.push("Color values come from tokens (var(--color-*)), not raw hex");
154
+ items.push("Component registered in intelligence/components.json");
155
+ return items;
156
+ }
157
+ function isControlCategory(category) {
158
+ return ["form", "navigation"].includes(category ?? "");
159
+ }
@@ -27,8 +27,6 @@ async function ensureIntelligenceFiles(target) {
27
27
  const intelligenceDir = path.join(target, "intelligence");
28
28
  await fs.ensureDir(intelligenceDir);
29
29
  const seeds = {
30
- "refinements.json": [],
31
- "decisions.json": [],
32
30
  "components.json": []
33
31
  };
34
32
  for (const [name, content] of Object.entries(seeds)) {
@@ -37,6 +35,9 @@ async function ensureIntelligenceFiles(target) {
37
35
  await fs.writeJson(file, content, { spaces: 2 });
38
36
  }
39
37
  }
38
+ // Decisions and refinements are stored as individual Markdown files.
39
+ await fs.ensureDir(path.join(intelligenceDir, "decisions"));
40
+ await fs.ensureDir(path.join(intelligenceDir, "refinements"));
40
41
  }
41
42
  export async function initCommand(projectName = "whale-project", options = {}) {
42
43
  const target = path.resolve(process.cwd(), projectName);
@@ -168,8 +168,6 @@ export async function insightsDriftReviewCommand(category, opts) {
168
168
  break;
169
169
  if (action === "refine") {
170
170
  await appendRefinement(target, {
171
- id: randomUUID(),
172
- timestamp: new Date().toISOString(),
173
171
  note: ins.title,
174
172
  scope: { issueType: cat }
175
173
  });
@@ -1,4 +1,3 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { resolveTarget } from "../utils/paths.js";
3
2
  import { appendRefinement, inferScope } from "../utils/refinements.js";
4
3
  import { generateWiki } from "../generators/wikiGenerator.js";
@@ -6,13 +5,7 @@ import { ui } from "../ui/index.js";
6
5
  export async function refineCommand(note, options = {}) {
7
6
  const target = resolveTarget();
8
7
  const scope = inferScope(note);
9
- const refinement = {
10
- id: randomUUID(),
11
- timestamp: new Date().toISOString(),
12
- note,
13
- scope
14
- };
15
- await appendRefinement(target, refinement);
8
+ await appendRefinement(target, { note, scope });
16
9
  console.log(ui.ok("Refinement recorded"));
17
10
  if (scope) {
18
11
  const parts = [];
@@ -0,0 +1,189 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ import ora from "ora";
4
+ import { resolveTarget } from "../utils/paths.js";
5
+ import { loadConfig } from "../utils/config.js";
6
+ import { loadComponents } from "../utils/components.js";
7
+ import { loadDecisions } from "../utils/decisions.js";
8
+ import { loadRefinements } from "../utils/refinements.js";
9
+ import { resolveProvider } from "../selene/providers.js";
10
+ import { callProvider } from "../selene/apiClient.js";
11
+ import { ui } from "../ui/index.js";
12
+ // ---------------------------------------------------------------------------
13
+ // whale scribe readme <ComponentName>
14
+ // ---------------------------------------------------------------------------
15
+ export async function scribeReadmeCommand(componentName, options = {}) {
16
+ const target = resolveTarget();
17
+ const config = await loadConfig(target);
18
+ const components = await loadComponents(target);
19
+ const component = components.find((c) => c.name === componentName);
20
+ if (!component) {
21
+ console.log(ui.fail(`Component "${componentName}" not found in intelligence/components.json.`));
22
+ console.log(ui.muted(` Run \`whale create component ${componentName}\` first.`));
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+ const outDir = options.out ?? "docs/scribe";
27
+ const fileRel = path.join(outDir, `${componentName}.md`).replace(/\\/g, "/");
28
+ const fileAbs = path.join(target, fileRel);
29
+ if (await fs.pathExists(fileAbs) && !options.force) {
30
+ console.log(ui.fail(`${ui.path(fileRel)} already exists. Use --force to overwrite.`));
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ const grid = config.foundations?.grid ?? 8;
35
+ const controlRadius = config.foundations?.radius?.control ?? 2;
36
+ const containerRadius = config.foundations?.radius?.container ?? 4;
37
+ const accent = config.branding?.accent ?? "blue";
38
+ const projectName = config.projectName ?? "this project";
39
+ const prompt = buildReadmePrompt({
40
+ componentName,
41
+ component,
42
+ grid,
43
+ controlRadius,
44
+ containerRadius,
45
+ accent,
46
+ projectName,
47
+ });
48
+ if (options.promptOnly) {
49
+ console.log(prompt);
50
+ return;
51
+ }
52
+ const resolved = resolveProvider(config);
53
+ if (!resolved) {
54
+ console.log(ui.warn("No API key found — printing prompt instead."));
55
+ console.log(ui.muted(" Set ANTHROPIC_API_KEY or OPENAI_API_KEY to use AI generation."));
56
+ console.log("\n" + prompt);
57
+ return;
58
+ }
59
+ const spinner = ora(`Scribe · generating README for ${componentName}`).start();
60
+ const result = await callProvider(resolved, { prompt, maxTokens: 1200, temperature: 0.4 });
61
+ if (!result.ok) {
62
+ spinner.fail(`API error: ${result.error}`);
63
+ process.exitCode = 1;
64
+ return;
65
+ }
66
+ spinner.succeed(`README generated`);
67
+ const content = result.text.trim();
68
+ await fs.ensureDir(path.dirname(fileAbs));
69
+ await fs.writeFile(fileAbs, content + "\n", "utf8");
70
+ console.log(ui.ok(`Scribe → ${ui.path(fileRel)}`));
71
+ console.log(ui.muted(` provider: ${resolved.provider} · ${resolved.model}`));
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // whale scribe overview
75
+ // ---------------------------------------------------------------------------
76
+ export async function scribeOverviewCommand(options = {}) {
77
+ const target = resolveTarget();
78
+ const config = await loadConfig(target);
79
+ const components = await loadComponents(target);
80
+ const decisions = await loadDecisions(target);
81
+ const refinements = await loadRefinements(target);
82
+ const outDir = options.out ?? "docs/scribe";
83
+ const fileRel = path.join(outDir, "overview.md").replace(/\\/g, "/");
84
+ const fileAbs = path.join(target, fileRel);
85
+ if (await fs.pathExists(fileAbs) && !options.force) {
86
+ console.log(ui.fail(`${ui.path(fileRel)} already exists. Use --force to overwrite.`));
87
+ process.exitCode = 1;
88
+ return;
89
+ }
90
+ const prompt = buildOverviewPrompt({ config, components, decisions, refinements });
91
+ if (options.promptOnly) {
92
+ console.log(prompt);
93
+ return;
94
+ }
95
+ const resolved = resolveProvider(config);
96
+ if (!resolved) {
97
+ console.log(ui.warn("No API key found — printing prompt instead."));
98
+ console.log(ui.muted(" Set ANTHROPIC_API_KEY or OPENAI_API_KEY to use AI generation."));
99
+ console.log("\n" + prompt);
100
+ return;
101
+ }
102
+ const spinner = ora("Scribe · generating project overview").start();
103
+ const result = await callProvider(resolved, { prompt, maxTokens: 1600, temperature: 0.4 });
104
+ if (!result.ok) {
105
+ spinner.fail(`API error: ${result.error}`);
106
+ process.exitCode = 1;
107
+ return;
108
+ }
109
+ spinner.succeed("Overview generated");
110
+ const content = result.text.trim();
111
+ await fs.ensureDir(path.dirname(fileAbs));
112
+ await fs.writeFile(fileAbs, content + "\n", "utf8");
113
+ console.log(ui.ok(`Scribe → ${ui.path(fileRel)}`));
114
+ console.log(ui.muted(` provider: ${resolved.provider} · ${resolved.model}`));
115
+ }
116
+ // ---------------------------------------------------------------------------
117
+ // Prompt builders
118
+ // ---------------------------------------------------------------------------
119
+ function buildReadmePrompt(args) {
120
+ const { componentName, component, grid, controlRadius, containerRadius, accent, projectName } = args;
121
+ const { category, description, variants = [], states = [], files = [], tokens = [] } = component;
122
+ return [
123
+ `# Scribe task — README for component: ${componentName}`,
124
+ "",
125
+ "You are a technical writer generating a component README for a design system.",
126
+ `The project is called "${projectName}" and uses an ${grid}px grid, ${controlRadius}px control radius, ${containerRadius}px container radius, and "${accent}" as accent color.`,
127
+ "",
128
+ "## Component metadata",
129
+ `- Name: ${componentName}`,
130
+ `- Category: ${category ?? "unspecified"}`,
131
+ `- Description: ${description ?? "(none)"}`,
132
+ `- Variants: ${variants.length > 0 ? variants.join(", ") : "none"}`,
133
+ `- States: ${states.length > 0 ? states.join(", ") : "none"}`,
134
+ `- Files: ${files.length > 0 ? files.join(", ") : "not specified"}`,
135
+ `- Tokens: ${tokens.length > 0 ? tokens.join(", ") : "not specified"}`,
136
+ "",
137
+ "## Task",
138
+ `Write a concise, developer-focused README.md for the ${componentName} component.`,
139
+ "Include:",
140
+ "1. A one-paragraph description of what it does and when to use it",
141
+ "2. A usage example (JSX/TSX code block)",
142
+ "3. A props table (name | type | default | description)",
143
+ "4. A section on accessibility (focus, ARIA if relevant)",
144
+ "5. A brief design notes section mentioning the grid and token constraints",
145
+ "",
146
+ "Write in plain Markdown. Be concise — target 150-250 lines max. No preamble or explanation.",
147
+ ].join("\n");
148
+ }
149
+ function buildOverviewPrompt(args) {
150
+ const { config, components, decisions, refinements } = args;
151
+ const componentList = components
152
+ .map((c) => `- ${c.name}${c.description ? `: ${c.description}` : ""}`)
153
+ .join("\n") || " (none registered)";
154
+ const decisionList = decisions
155
+ .map((d) => `- ${d.title}: ${d.decision}`)
156
+ .join("\n") || " (none)";
157
+ return [
158
+ "# Scribe task — project overview README",
159
+ "",
160
+ "You are a technical writer generating a project overview README.",
161
+ "",
162
+ "## Project config",
163
+ `- Name: ${config.projectName ?? "unnamed"}`,
164
+ `- Type: ${config.projectType ?? "unspecified"}`,
165
+ `- Stack: ${config.stack ?? "unspecified"}`,
166
+ `- Grid: ${config.foundations?.grid ?? 8}px`,
167
+ `- Control radius: ${config.foundations?.radius?.control ?? 2}px`,
168
+ `- Container radius: ${config.foundations?.radius?.container ?? 4}px`,
169
+ `- Accent: ${config.branding?.accent ?? "unspecified"}`,
170
+ `- Active packs: ${(config.packs ?? []).join(", ") || "none"}`,
171
+ "",
172
+ "## Registered components",
173
+ componentList,
174
+ "",
175
+ "## Architectural decisions",
176
+ decisionList,
177
+ `- Refinements recorded: ${refinements.length}`,
178
+ "",
179
+ "## Task",
180
+ "Write a project overview README.md that a new developer would read to understand:",
181
+ "1. What this project is and who it's for",
182
+ "2. The design system foundations (grid, radius, tokens)",
183
+ "3. The component catalog (what exists, categories)",
184
+ "4. Key architectural decisions",
185
+ "5. How to get started (whale commands to run)",
186
+ "",
187
+ "Write in plain Markdown. Be concise and direct. No preamble or explanation.",
188
+ ].join("\n");
189
+ }
@@ -14,7 +14,6 @@ import { copyToClipboard } from "../selene/clipboard.js";
14
14
  import { resolveProvider, describeProviderState } from "../selene/providers.js";
15
15
  import { callProvider, estimateTokens, estimateCostUsd } from "../selene/apiClient.js";
16
16
  import { readCache, writeCache, clearCache } from "../selene/cache.js";
17
- import { randomUUID } from "node:crypto";
18
17
  import { ui } from "../ui/index.js";
19
18
  const PENDING_DIR = ".whale/selene";
20
19
  // ---------------------------------------------------------------------------
@@ -421,8 +420,6 @@ async function applySuggest(target, raw, options) {
421
420
  }
422
421
  else if (s.kind === "refinement") {
423
422
  await appendRefinement(target, {
424
- id: randomUUID(),
425
- timestamp: new Date().toISOString(),
426
423
  note: s.proposed_text
427
424
  });
428
425
  console.log(ui.success(" ✓ Recorded as refinement."));
@@ -10,10 +10,10 @@ import { getAiAvailability } from "../utils/aiAvailability.js";
10
10
  import { ui } from "../ui/index.js";
11
11
  /**
12
12
  * `whale sync` regenerates everything an AI agent reads, from the
13
- * intelligence JSON files (which are the source of truth).
13
+ * project configuration and intelligence stores (the source of truth).
14
14
  *
15
15
  * Run this:
16
- * - after manually editing intelligence/*.json
16
+ * - after manually editing whale.config.json or intelligence stores
17
17
  * - before handing the project to an AI agent
18
18
  * - in CI to ensure CLAUDE.md stays in sync
19
19
  */
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Why this exists. Whale's value depends on CLAUDE.md being current.
5
5
  * Every write command (refine, decision, component add, adopt review) auto-syncs,
6
- * but humans also edit `intelligence/*.json` directly, change `whale.config.json`,
6
+ * but humans also edit `intelligence/` directly, change `whale.config.json`,
7
7
  * or commit catalog files from another tool. Without a watcher the gap between
8
8
  * "what's true" and "what the AI sees" silently widens. Watcher closes that gap.
9
9
  *
package/dist/index.js CHANGED
@@ -15,6 +15,9 @@ import { adoptReviewCommand, adoptStatusCommand } from "./commands/adoptReview.j
15
15
  import { insightsCommand, insightsDriftReviewCommand } from "./commands/insights.js";
16
16
  import { createComponentCommand } from "./commands/createComponent.js";
17
17
  import { createLandingCommand } from "./commands/createLanding.js";
18
+ import { forgeCommand } from "./commands/forge.js";
19
+ import { scribeReadmeCommand, scribeOverviewCommand } from "./commands/scribe.js";
20
+ import { atlasHealthCommand, atlasDecisionsCommand } from "./commands/atlas.js";
18
21
  import { themesListCommand } from "./commands/themes.js";
19
22
  import { updateTokensCommand } from "./commands/updateTokens.js";
20
23
  import { seleneDescribeCommand, seleneAuditCommand, seleneSuggestCommand, seleneApplyCommand, seleneStatusCommand, seleneCacheClearCommand } from "./commands/selene.js";
@@ -45,7 +48,7 @@ program
45
48
  });
46
49
  program
47
50
  .command("sync [target]")
48
- .description("Regenerate CLAUDE.md and the LLM Wiki from intelligence/*.json.")
51
+ .description("Regenerate CLAUDE.md and the LLM Wiki from project intelligence.")
49
52
  .action(syncCommand);
50
53
  program
51
54
  .command("remember [target]")
@@ -178,13 +181,60 @@ create
178
181
  create
179
182
  .command("landing")
180
183
  .description("Generate a static HTML/CSS landing page using current foundations.")
181
- .option("--sections <list>", "Comma-separated sections (hero,features,proof,contact)")
184
+ .option("--sections <list>", "Comma-separated sections (header,hero,features,proof,contact,footer)")
182
185
  .option("--out-dir <path>", "Output directory relative to project root (default: src)")
183
186
  .option("--force", "Overwrite generated files if they already exist")
184
187
  .option("--no-register", "Don't add generated sections to intelligence/components.json")
185
188
  .option("--no-sync", "Don't regenerate CLAUDE.md / wiki after creating")
186
189
  .option("--theme <name>", "Visual theme for the generated UI (see `whale themes list`)")
190
+ .option("--content <path>", "Read landing copy from a local JSON file")
191
+ .option("--sample-content", "Create an editable landing.json sample and use it")
192
+ .option("--brief", "Ask a short local brief and generate landing.json")
193
+ .option("--title <text>", "Override the hero headline")
194
+ .option("--eyebrow <text>", "Override the hero eyebrow")
195
+ .option("--tagline <text>", "Override the hero supporting copy")
196
+ .option("--cta <text>", "Override the primary CTA label")
197
+ .option("--cta-href <href>", "Override the primary CTA href")
187
198
  .action((opts) => createLandingCommand(opts));
199
+ program
200
+ .command("forge <component>")
201
+ .description("Generate an implementation contract (Markdown) for a registered component.")
202
+ .option("--out <dir>", "Output directory relative to project root (default: docs/forge)")
203
+ .option("--force", "Overwrite the contract if it already exists")
204
+ .action((component, opts) => forgeCommand(component, opts));
205
+ const atlas = program
206
+ .command("atlas")
207
+ .description("Project health and decision intelligence for PMs and tech leads.");
208
+ atlas
209
+ .command("health")
210
+ .description("Generate an AI-powered project health report.")
211
+ .option("--out <dir>", "Output directory (default: docs/atlas)")
212
+ .option("--force", "Overwrite if already exists")
213
+ .option("--prompt-only", "Print the prompt without calling the API")
214
+ .action((opts) => atlasHealthCommand(opts));
215
+ atlas
216
+ .command("decisions")
217
+ .description("List active architectural decisions, optionally filtered.")
218
+ .option("--category <name>", "Filter by category (architecture, design-system, product, tooling, convention)")
219
+ .option("--status <status>", "Filter by status (active, superseded, deprecated) — default: active")
220
+ .action((opts) => atlasDecisionsCommand(opts));
221
+ const scribe = program
222
+ .command("scribe")
223
+ .description("AI-generated documentation for components and projects.");
224
+ scribe
225
+ .command("readme <component>")
226
+ .description("Generate a developer README for a registered component using AI.")
227
+ .option("--out <dir>", "Output directory (default: docs/scribe)")
228
+ .option("--force", "Overwrite if already exists")
229
+ .option("--prompt-only", "Print the prompt without calling the API")
230
+ .action((component, opts) => scribeReadmeCommand(component, opts));
231
+ scribe
232
+ .command("overview")
233
+ .description("Generate a project overview README using AI.")
234
+ .option("--out <dir>", "Output directory (default: docs/scribe)")
235
+ .option("--force", "Overwrite if already exists")
236
+ .option("--prompt-only", "Print the prompt without calling the API")
237
+ .action((opts) => scribeOverviewCommand(opts));
188
238
  const adopt = program
189
239
  .command("adopt [target]")
190
240
  .description("Scan an existing project and propose components, foundations and decisions.")
@@ -38,7 +38,6 @@ import { collectReferencedFiles, normalizePath } from "../analyzer/imports.js";
38
38
  import { scanComponents } from "../scanner/componentScanner.js";
39
39
  import { aggregateTailwind } from "../scanner/tailwindScanner.js";
40
40
  import { validateCss } from "../validators/cssValidator.js";
41
- import { randomUUID } from "node:crypto";
42
41
  import { PACKAGE_VERSION } from "../version.js";
43
42
  function ok(text) {
44
43
  return { content: [{ type: "text", text }] };
@@ -354,12 +353,7 @@ export function buildMcpServer() {
354
353
  if (!check.ok)
355
354
  return err(check.reason);
356
355
  const scope = inferScope(note);
357
- await appendRefinement(ws, {
358
- id: randomUUID(),
359
- timestamp: new Date().toISOString(),
360
- note,
361
- scope
362
- });
356
+ await appendRefinement(ws, { note, scope });
363
357
  await generateWiki(ws);
364
358
  const scopeDesc = scope
365
359
  ? `Scope inferred: ${Object.entries(scope).map(([k, v]) => `${k}=${v}`).join(", ")}.`
@@ -368,8 +362,8 @@ export function buildMcpServer() {
368
362
  });
369
363
  server.registerTool("whale_sync", {
370
364
  title: "Regenerate CLAUDE.md and the LLM wiki",
371
- description: "Regenerates the AI-readable context files from intelligence/*.json. Call " +
372
- "this if you've manually edited the JSON stores and want the wiki to catch " +
365
+ description: "Regenerates the AI-readable context files from project intelligence. Call " +
366
+ "this if you've manually edited the source stores and want the wiki to catch " +
373
367
  "up. Whale auto-syncs on every recorded change, so manual calls are rarely needed.",
374
368
  inputSchema: { target: z.string().optional() }
375
369
  }, async ({ target }) => {
@@ -42,7 +42,7 @@ function renderProjectContext(ctx) {
42
42
  lines.push(`- Control radius: ${c.foundations?.radius?.control ?? 4}px (buttons, inputs)`);
43
43
  lines.push(`- Container radius: ${c.foundations?.radius?.container ?? 8}px (cards, modals)`);
44
44
  // Active decisions — show titles only to keep tokens down. Full text
45
- // is available in intelligence/decisions.json if the model needs it.
45
+ // is available in the linked intelligence/decisions Markdown files.
46
46
  const active = ctx.decisions.filter((d) => d.status === "active");
47
47
  if (active.length > 0) {
48
48
  lines.push("");
@@ -52,7 +52,7 @@ function renderProjectContext(ctx) {
52
52
  lines.push(`- [${d.category}] ${d.title}`);
53
53
  }
54
54
  if (active.length > recent.length) {
55
- lines.push(`- … and ${active.length - recent.length} more in intelligence/decisions.json`);
55
+ lines.push(`- … and ${active.length - recent.length} more in llm-wiki/DECISIONS.md`);
56
56
  }
57
57
  }
58
58
  if (ctx.refinements.length > 0) {
@@ -53,12 +53,12 @@ the design system, conventions and decisions without re-explaining them every se
53
53
 
54
54
  Whale stores structured context in two locations:
55
55
 
56
- 1. **\`intelligence/\`** — source of truth (JSON). Prefer these for precise data:
57
- - \`intelligence/refinements.json\` — approved validator overrides
58
- - \`intelligence/decisions.json\` — architectural and product decisions
56
+ 1. **\`intelligence/\`** — source of truth. Prefer these for precise data:
57
+ - \`intelligence/refinements/\` — approved validator overrides as Markdown
58
+ - \`intelligence/decisions/\` — architectural and product decisions as ADRs
59
59
  - \`intelligence/components.json\` — component catalog
60
60
 
61
- 2. **\`llm-wiki/\`** — human + AI readable markdown rendered from the JSON:
61
+ 2. **\`llm-wiki/\`** — compact human + AI readable markdown indexes:
62
62
  - \`llm-wiki/FOUNDATIONS.md\` — design tokens and grid rules
63
63
  - \`llm-wiki/CONVENTIONS.md\` — coding and design conventions
64
64
  - \`llm-wiki/DECISIONS.md\` — decision log in narrative form
@@ -28,7 +28,8 @@ export function renderConventions(config, refinements) {
28
28
  if (r.scope?.file)
29
29
  scopeParts.push(`file=\`${r.scope.file}\``);
30
30
  const scope = scopeParts.length ? ` _(${scopeParts.join(", ")})_` : "";
31
- lines.push(`- **${r.timestamp.slice(0, 10)}** ${r.note}${scope}`);
31
+ const ref = r.sourcePath ? `[${r.id}](../${r.sourcePath})` : r.id;
32
+ lines.push(`- **${ref}** · ${r.timestamp.slice(0, 10)} — ${r.note}${scope}`);
32
33
  }
33
34
  lines.push("");
34
35
  }
@@ -36,7 +37,8 @@ export function renderConventions(config, refinements) {
36
37
  lines.push("## Open questions", "");
37
38
  lines.push("These notes do not yet have a scope — the validator cannot act on them.", "Review and convert to scoped refinements or decisions.", "");
38
39
  for (const r of scopeless) {
39
- lines.push(`> ⚠️ Unresolved — **${r.timestamp.slice(0, 10)}**: ${r.note}`);
40
+ const ref = r.sourcePath ? `[${r.id}](../${r.sourcePath})` : r.id;
41
+ lines.push(`> ⚠️ Unresolved — **${ref}** · ${r.timestamp.slice(0, 10)}: ${r.note}`);
40
42
  }
41
43
  lines.push("");
42
44
  }