whale-igniter 1.5.1 → 1.5.3

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to Whale Igniter are documented here.
4
4
 
5
+ ## 1.5.3 - 2026-07-06
6
+
7
+ ### Added
8
+
9
+ - Added shared target-directory guardrails for commands that write files or scan projects, blocking home/root-level targets and requiring `--force` when no `.git` directory or `package.json` is present.
10
+ - Added grouped validation summaries for CLI and generated docs, plus `--full` for exhaustive validation output and generated context health warnings when validation scope looks misconfigured.
11
+
12
+ ## 1.5.2 - 2026-06-10
13
+
14
+ ### Changed
15
+
16
+ - Decisions and refinements are now stored as individual Markdown files with frontmatter (`intelligence/decisions/ADR-XXXX-*.md`, `intelligence/refinements/REF-XXXX-*.md`) instead of accumulative JSON stores. IDs are sequential and human-readable. `loadDecisions`/`loadRefinements` keep dual-reading legacy JSON, so existing projects lose no data.
17
+ - `llm-wiki/DECISIONS.md` is now a compact index table linking to each decision's source file, instead of dumping full bodies — reducing the context an AI agent consumes.
18
+ - Updated `glob` to its supported current major and refreshed the transitive MCP dependency tree; production audit reports zero vulnerabilities.
19
+
20
+ ### Security
21
+
22
+ - `whale changes` now invokes git via `execFileSync` with argument arrays instead of shell-interpolated strings, removing a command-injection vector through the `--since` flag.
23
+
5
24
  ## 1.5.1 - 2026-06-03
6
25
 
7
26
  ### Added
package/README.md CHANGED
@@ -34,12 +34,12 @@ my-app/
34
34
  ├── CLAUDE.md # agent-readable project brief, auto-synced
35
35
  ├── intelligence/
36
36
  │ ├── components.json # component catalog with variants and states
37
- │ ├── decisions.json # architectural decisions and their rationale
38
- │ └── refinements.json # approved exceptions "this is intentional"
37
+ │ ├── decisions/ # one Markdown ADR per decision
38
+ │ └── refinements/ # one Markdown file per approved exception
39
39
  └── llm-wiki/ # generated markdown, one file per concern
40
40
  ```
41
41
 
42
- `intelligence/*.json` is the source of truth. Markdown is generated from it. When you run `whale sync`, everything regenerates — CLAUDE.md, the wiki, the context your agent reads on the next conversation.
42
+ `whale.config.json` and `intelligence/` are the source of truth. Decisions and refinements use individual Markdown files with frontmatter; component metadata remains structured JSON. When you run `whale sync`, everything regenerates — CLAUDE.md, the wiki, and the context your agent reads on the next conversation.
43
43
 
44
44
  The agent doesn't guess anymore. It reads.
45
45
 
@@ -3,6 +3,7 @@ import fs from "fs-extra";
3
3
  import ora from "ora";
4
4
  import prompts from "prompts";
5
5
  import { resolveTarget } from "../utils/paths.js";
6
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
6
7
  import { scanComponents } from "../scanner/componentScanner.js";
7
8
  import { aggregateTailwind, detectTailwindConfig } from "../scanner/tailwindScanner.js";
8
9
  import { inferFoundations } from "../scanner/foundationInferrer.js";
@@ -13,6 +14,9 @@ import { ui } from "../ui/index.js";
13
14
  const SCANNER_VERSION = "1.1.0";
14
15
  export async function adoptCommand(targetArg, options = {}) {
15
16
  const target = resolveTarget(targetArg);
17
+ if (!(await guardTargetDirectory(target, { force: options.force, command: "adopt" }))) {
18
+ return;
19
+ }
16
20
  console.log();
17
21
  console.log(ui.header("Whale Igniter", `adopt • ${path.relative(process.cwd(), target) || "."}`));
18
22
  console.log();
@@ -136,11 +140,13 @@ export async function adoptCommand(targetArg, options = {}) {
136
140
  console.log(ui.note("Updated stack to 'tailwind' in whale.config.json"));
137
141
  }
138
142
  }
139
- // ---- Step 6: ensure other intelligence files exist ------------------------
140
- for (const file of ["refinements.json", "decisions.json", "components.json"]) {
141
- const p = path.join(target, "intelligence", file);
142
- if (!(await fs.pathExists(p)))
143
- await fs.writeJson(p, [], { spaces: 2 });
143
+ // ---- Step 6: ensure other intelligence stores exist -----------------------
144
+ const intelligenceDir = path.join(target, "intelligence");
145
+ await fs.ensureDir(path.join(intelligenceDir, "decisions"));
146
+ await fs.ensureDir(path.join(intelligenceDir, "refinements"));
147
+ const componentsPath = path.join(intelligenceDir, "components.json");
148
+ if (!(await fs.pathExists(componentsPath))) {
149
+ await fs.writeJson(componentsPath, [], { spaces: 2 });
144
150
  }
145
151
  // ---- Step 7: optional Selene enrichment -----------------------------------
146
152
  const ai = await getAiAvailability(target);
@@ -1,21 +1,22 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
+ import matter from "gray-matter";
2
3
  import { resolveTarget } from "../utils/paths.js";
3
- import { loadDecisions } from "../utils/decisions.js";
4
4
  import { loadComponents } from "../utils/components.js";
5
- import { loadRefinements } from "../utils/refinements.js";
6
5
  import { ui } from "../ui/index.js";
6
+ const DECISIONS_DIR = "intelligence/decisions";
7
+ const REFINEMENTS_DIR = "intelligence/refinements";
7
8
  const INTELLIGENCE_FILES = [
8
- "intelligence/decisions.json",
9
+ DECISIONS_DIR,
10
+ REFINEMENTS_DIR,
9
11
  "intelligence/components.json",
10
- "intelligence/refinements.json",
11
12
  "whale.config.json"
12
13
  ];
14
+ function git(target, args) {
15
+ return execFileSync("git", args, { cwd: target, encoding: "utf8" });
16
+ }
13
17
  function gitDiffFiles(target, since) {
14
18
  try {
15
- const result = execSync(`git diff --name-only ${since} HEAD -- ${INTELLIGENCE_FILES.join(" ")}`, {
16
- cwd: target,
17
- encoding: "utf8"
18
- });
19
+ const result = git(target, ["diff", "--name-only", since, "HEAD", "--", ...INTELLIGENCE_FILES]);
19
20
  return result.trim().split("\n").filter(Boolean);
20
21
  }
21
22
  catch {
@@ -24,13 +25,49 @@ function gitDiffFiles(target, since) {
24
25
  }
25
26
  function gitFileAtRef(target, ref, file) {
26
27
  try {
27
- const raw = execSync(`git show ${ref}:${file}`, { cwd: target, encoding: "utf8" });
28
+ const raw = git(target, ["show", `${ref}:${file}`]);
28
29
  return JSON.parse(raw);
29
30
  }
30
31
  catch {
31
32
  return [];
32
33
  }
33
34
  }
35
+ function mdLabelAtRef(target, ref, file, label) {
36
+ try {
37
+ const raw = git(target, ["show", `${ref}:${file}`]);
38
+ return label(raw) || file;
39
+ }
40
+ catch {
41
+ return file;
42
+ }
43
+ }
44
+ const decisionLabel = (raw) => {
45
+ const title = matter(raw).data?.title;
46
+ return title ? String(title) : "";
47
+ };
48
+ const refinementLabel = (raw) => matter(raw).content.trim().split("\n")[0] ?? "";
49
+ function gitDirChanges(target, since, dir, label) {
50
+ const result = { added: [], removed: [], modified: [] };
51
+ let raw = "";
52
+ try {
53
+ raw = git(target, ["diff", "--name-status", since, "HEAD", "--", dir]);
54
+ }
55
+ catch {
56
+ return result;
57
+ }
58
+ for (const line of raw.trim().split("\n").filter(Boolean)) {
59
+ const [status, file] = line.split("\t");
60
+ if (!file || !file.endsWith(".md"))
61
+ continue;
62
+ if (status.startsWith("A"))
63
+ result.added.push(mdLabelAtRef(target, "HEAD", file, label));
64
+ else if (status.startsWith("D"))
65
+ result.removed.push(mdLabelAtRef(target, since, file, label));
66
+ else if (status.startsWith("M") || status.startsWith("R"))
67
+ result.modified.push(mdLabelAtRef(target, "HEAD", file, label));
68
+ }
69
+ return result;
70
+ }
34
71
  export async function changesCommand(opts) {
35
72
  const target = resolveTarget(opts.target);
36
73
  const since = opts.since ?? "HEAD~1";
@@ -49,21 +86,8 @@ export async function changesCommand(opts) {
49
86
  if (changedFiles.includes("whale.config.json")) {
50
87
  summary.configChanged = true;
51
88
  }
52
- if (changedFiles.includes("intelligence/decisions.json")) {
53
- const before = gitFileAtRef(target, since, "intelligence/decisions.json");
54
- const after = await loadDecisions(target);
55
- const beforeIds = new Map(before.map((d) => [d.id, d.title]));
56
- const afterIds = new Map(after.map((d) => [d.id, d.title]));
57
- for (const [id, title] of afterIds) {
58
- if (!beforeIds.has(id))
59
- summary.decisions.added.push(title);
60
- }
61
- for (const [id, title] of beforeIds) {
62
- if (!afterIds.has(id))
63
- summary.decisions.removed.push(title);
64
- else if (afterIds.get(id) !== title)
65
- summary.decisions.modified.push(title);
66
- }
89
+ if (changedFiles.some((f) => f.startsWith(`${DECISIONS_DIR}/`))) {
90
+ summary.decisions = gitDirChanges(target, since, DECISIONS_DIR, decisionLabel);
67
91
  }
68
92
  if (changedFiles.includes("intelligence/components.json")) {
69
93
  const before = gitFileAtRef(target, since, "intelligence/components.json");
@@ -79,20 +103,10 @@ export async function changesCommand(opts) {
79
103
  summary.components.removed.push(name);
80
104
  }
81
105
  }
82
- if (changedFiles.includes("intelligence/refinements.json")) {
83
- const before = gitFileAtRef(target, since, "intelligence/refinements.json");
84
- const after = await loadRefinements(target);
85
- const beforeIds = new Set(before.map((r) => r.id));
86
- const afterIds = new Set(after.map((r) => r.id));
87
- const beforeNotes = new Map(before.map((r) => [r.id, r.note]));
88
- for (const r of after) {
89
- if (!beforeIds.has(r.id))
90
- summary.refinements.added.push(r.note);
91
- }
92
- for (const [id, note] of beforeNotes) {
93
- if (!afterIds.has(id))
94
- summary.refinements.removed.push(note);
95
- }
106
+ if (changedFiles.some((f) => f.startsWith(`${REFINEMENTS_DIR}/`))) {
107
+ const changes = gitDirChanges(target, since, REFINEMENTS_DIR, refinementLabel);
108
+ summary.refinements.added = changes.added;
109
+ summary.refinements.removed = changes.removed;
96
110
  }
97
111
  // ---- Render ----------------------------------------------------------------
98
112
  if (summary.configChanged) {
@@ -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) {
@@ -5,20 +5,26 @@ import { syncCommand } from "./sync.js";
5
5
  import { validateCommand } from "./validate.js";
6
6
  import { wikiCommand } from "./wiki.js";
7
7
  import { ui } from "../ui/index.js";
8
- export async function rememberCommand(target) {
8
+ import { resolveTarget } from "../utils/paths.js";
9
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
10
+ export async function rememberCommand(target, options = {}) {
9
11
  console.log();
10
12
  console.log(ui.header("Whale Igniter", "remember"));
11
13
  console.log(ui.muted("Updating project memory for AI assistants."));
12
- await syncCommand(target);
14
+ await syncCommand(target, options);
13
15
  }
14
- export async function checkCommand(target) {
16
+ export async function checkCommand(target, options = {}) {
15
17
  console.log();
16
18
  console.log(ui.header("Whale Igniter", "check"));
17
19
  console.log(ui.muted("Checking project health, then looking for useful recommendations."));
18
20
  console.log();
19
- await validateCommand(target);
21
+ const resolvedTarget = resolveTarget(target);
22
+ if (!(await guardTargetDirectory(resolvedTarget, { force: options.force, command: "check" }))) {
23
+ return;
24
+ }
25
+ await validateCommand(target, { ...options, skipTargetGuard: true });
20
26
  const validationExit = process.exitCode;
21
- await insightsCommand(target, {});
27
+ await insightsCommand(target, { ...options, skipTargetGuard: true });
22
28
  if (validationExit !== undefined) {
23
29
  process.exitCode = validationExit;
24
30
  }
@@ -7,6 +7,7 @@ import { validateCss } from "../validators/cssValidator.js";
7
7
  import { generateWiki } from "../generators/wikiGenerator.js";
8
8
  import { appendDecision } from "../utils/decisions.js";
9
9
  import { getAiAvailability } from "../utils/aiAvailability.js";
10
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
10
11
  import { mapWizardAnswers, suggestUiCategories } from "../utils/wizardMapping.js";
11
12
  import { ui } from "../ui/index.js";
12
13
  import { PACKAGE_VERSION } from "../version.js";
@@ -49,7 +50,11 @@ export async function igniteCommand(projectName = "whale-project", options = {})
49
50
  }
50
51
  // ---- Step 1: scaffold workspace -------------------------------------------
51
52
  const resolvedProjectName = config.projectName ?? projectName;
52
- const target = await initCommand(resolvedProjectName, { config, silent: true });
53
+ const plannedTarget = path.resolve(process.cwd(), resolvedProjectName);
54
+ if (!(await guardTargetDirectory(plannedTarget, { force: options.force, command: "ignite" }))) {
55
+ return;
56
+ }
57
+ const target = await initCommand(resolvedProjectName, { config, silent: true, skipTargetGuard: true });
53
58
  const targetRel = path.relative(process.cwd(), target) || ".";
54
59
  console.log(ui.ok(`Workspace created at ${ui.path(targetRel)}`));
55
60
  // ---- Step 1b: record free-text project intent as first decision ------------
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import ora from "ora";
4
4
  import { DEFAULT_CONFIG, saveConfig } from "../utils/config.js";
5
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
5
6
  import { ui } from "../ui/index.js";
6
7
  /**
7
8
  * Folders Whale always expects to exist. Created empty so subsequent
@@ -27,8 +28,6 @@ async function ensureIntelligenceFiles(target) {
27
28
  const intelligenceDir = path.join(target, "intelligence");
28
29
  await fs.ensureDir(intelligenceDir);
29
30
  const seeds = {
30
- "refinements.json": [],
31
- "decisions.json": [],
32
31
  "components.json": []
33
32
  };
34
33
  for (const [name, content] of Object.entries(seeds)) {
@@ -37,9 +36,15 @@ async function ensureIntelligenceFiles(target) {
37
36
  await fs.writeJson(file, content, { spaces: 2 });
38
37
  }
39
38
  }
39
+ // Decisions and refinements are stored as individual Markdown files.
40
+ await fs.ensureDir(path.join(intelligenceDir, "decisions"));
41
+ await fs.ensureDir(path.join(intelligenceDir, "refinements"));
40
42
  }
41
43
  export async function initCommand(projectName = "whale-project", options = {}) {
42
44
  const target = path.resolve(process.cwd(), projectName);
45
+ if (!options.skipTargetGuard && !(await guardTargetDirectory(target, { force: options.force, command: "init" }))) {
46
+ return target;
47
+ }
43
48
  const spinner = options.silent
44
49
  ? null
45
50
  : ora(`Scaffolding ${projectName}`).start();
@@ -3,6 +3,7 @@ import fs from "fs-extra";
3
3
  import ora from "ora";
4
4
  import prompts from "prompts";
5
5
  import { resolveTarget } from "../utils/paths.js";
6
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
6
7
  import { loadConfig } from "../utils/config.js";
7
8
  import { loadRefinements, appendRefinement } from "../utils/refinements.js";
8
9
  import { loadDecisions } from "../utils/decisions.js";
@@ -21,6 +22,9 @@ import { randomUUID } from "node:crypto";
21
22
  const SEVERITY_RANK = { info: 0, warning: 1, critical: 2 };
22
23
  export async function insightsCommand(targetArg, options = {}) {
23
24
  const target = resolveTarget(targetArg);
25
+ if (!options.skipTargetGuard && !(await guardTargetDirectory(target, { force: options.force, command: "insights" }))) {
26
+ return;
27
+ }
24
28
  if (!(await fs.pathExists(path.join(target, "whale.config.json")))) {
25
29
  console.log();
26
30
  console.log(ui.warn("No whale.config.json found at this target. Run `whale ignite` or `whale adopt` first."));
@@ -99,6 +103,9 @@ export async function insightsDriftReviewCommand(category, opts) {
99
103
  }
100
104
  const cat = category;
101
105
  const target = resolveTarget(opts.target);
106
+ if (!(await guardTargetDirectory(target, { force: opts.force, command: "insights drift" }))) {
107
+ return;
108
+ }
102
109
  console.log();
103
110
  console.log(ui.header("Whale Igniter", `insights drift ${cat}`));
104
111
  console.log();
@@ -168,8 +175,6 @@ export async function insightsDriftReviewCommand(category, opts) {
168
175
  break;
169
176
  if (action === "refine") {
170
177
  await appendRefinement(target, {
171
- id: randomUUID(),
172
- timestamp: new Date().toISOString(),
173
178
  note: ins.title,
174
179
  scope: { issueType: cat }
175
180
  });
@@ -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 = [];
@@ -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."));
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import ora from "ora";
3
3
  import { resolveTarget } from "../utils/paths.js";
4
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
4
5
  import { generateWiki } from "../generators/wikiGenerator.js";
5
6
  import { loadConfig } from "../utils/config.js";
6
7
  import { loadDecisions } from "../utils/decisions.js";
@@ -10,15 +11,18 @@ import { getAiAvailability } from "../utils/aiAvailability.js";
10
11
  import { ui } from "../ui/index.js";
11
12
  /**
12
13
  * `whale sync` regenerates everything an AI agent reads, from the
13
- * intelligence JSON files (which are the source of truth).
14
+ * project configuration and intelligence stores (the source of truth).
14
15
  *
15
16
  * Run this:
16
- * - after manually editing intelligence/*.json
17
+ * - after manually editing whale.config.json or intelligence stores
17
18
  * - before handing the project to an AI agent
18
19
  * - in CI to ensure CLAUDE.md stays in sync
19
20
  */
20
- export async function syncCommand(targetArg) {
21
+ export async function syncCommand(targetArg, options = {}) {
21
22
  const target = resolveTarget(targetArg);
23
+ if (!(await guardTargetDirectory(target, { force: options.force, command: "sync" }))) {
24
+ return;
25
+ }
22
26
  const spinner = ora("Reading intelligence stores").start();
23
27
  const config = await loadConfig(target);
24
28
  const decisions = await loadDecisions(target);
@@ -1,9 +1,14 @@
1
1
  import ora from "ora";
2
2
  import { resolveTarget } from "../utils/paths.js";
3
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
3
4
  import { validateCss, hasErrors } from "../validators/cssValidator.js";
5
+ import { formatIssueLocation, summarizeValidationIssues } from "../utils/validationSummary.js";
4
6
  import { ui } from "../ui/index.js";
5
- export async function validateCommand(targetArg) {
7
+ export async function validateCommand(targetArg, options = {}) {
6
8
  const target = resolveTarget(targetArg);
9
+ if (!options.skipTargetGuard && !(await guardTargetDirectory(target, { force: options.force, command: "validate" }))) {
10
+ return;
11
+ }
7
12
  const spinner = ora("Scanning workspace").start();
8
13
  let issues;
9
14
  try {
@@ -23,7 +28,33 @@ export async function validateCommand(targetArg) {
23
28
  process.exitCode = 0;
24
29
  return;
25
30
  }
26
- // Header with the counts as badges.
31
+ if (!options.full) {
32
+ const summary = summarizeValidationIssues(issues);
33
+ console.log();
34
+ console.log(ui.section("Top issue groups"));
35
+ console.log();
36
+ for (const group of summary.shownGroups) {
37
+ const head = group.severity === "error"
38
+ ? `${ui.glyph.cross} ${ui.danger(group.type)}`
39
+ : `${ui.glyph.warn} ${ui.warning(group.type)}`;
40
+ console.log(`${head} ${ui.muted(`- ${group.count} issue(s)`)}`);
41
+ console.log(` example: ${ui.path(formatIssueLocation(group.example))}`);
42
+ console.log(` ${group.message}`);
43
+ console.log(` ${ui.next(group.example.suggestion)}`);
44
+ if (group.example.selector) {
45
+ console.log(` ${ui.muted("selector: " + group.example.selector)}`);
46
+ }
47
+ console.log();
48
+ }
49
+ if (summary.hiddenIssueCount > 0) {
50
+ console.log(ui.muted(`+ ${summary.hiddenIssueCount} more across ${summary.hiddenFileCount} files - run \`whale check --full\` for everything`));
51
+ console.log();
52
+ }
53
+ process.exitCode = hasErrors(issues) ? 1 : 0;
54
+ return;
55
+ }
56
+ // Header with the counts as badges. Kept for --full, which preserves the
57
+ // older exhaustive output for users who explicitly ask for everything.
27
58
  console.log();
28
59
  console.log(ui.summary([
29
60
  { label: "issues", value: issues.length },
@@ -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
  *
@@ -1,10 +1,14 @@
1
1
  import path from "node:path";
2
2
  import ora from "ora";
3
3
  import { resolveTarget } from "../utils/paths.js";
4
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
4
5
  import { generateWiki } from "../generators/wikiGenerator.js";
5
6
  import { ui } from "../ui/index.js";
6
- export async function wikiCommand(targetArg) {
7
+ export async function wikiCommand(targetArg, options = {}) {
7
8
  const target = resolveTarget(targetArg);
9
+ if (!(await guardTargetDirectory(target, { force: options.force, command: "wiki" }))) {
10
+ return;
11
+ }
8
12
  const spinner = ora("Generating AI context").start();
9
13
  const { rootFiles, wikiFiles } = await generateWiki(target);
10
14
  spinner.succeed("AI context generated");
@@ -3,6 +3,7 @@ import fs from "fs-extra";
3
3
  import { loadConfig } from "../utils/config.js";
4
4
  import { loadRefinements } from "../utils/refinements.js";
5
5
  import { validateCss } from "../validators/cssValidator.js";
6
+ import { renderValidationSummaryMarkdown, summarizeValidationIssues } from "../utils/validationSummary.js";
6
7
  async function buildContext(target) {
7
8
  const config = await loadConfig(target);
8
9
  const refinements = await loadRefinements(target);
@@ -27,11 +28,20 @@ function renderForge(ctx) {
27
28
  "- All interactive elements MUST have a `:focus-visible` style.",
28
29
  "- Color values MUST come from semantic tokens (`var(--color-*)`).",
29
30
  "",
30
- `## Open Issues (${issues.length})`,
31
+ "## Open Issues",
31
32
  ""
32
33
  ];
33
- for (const issue of issues) {
34
- lines.push(`- \`${issue.file}:${issue.line}\` ${issue.type}: ${issue.message}`);
34
+ if (issues.length === 0) {
35
+ lines.push("No validation issues found.");
36
+ }
37
+ else {
38
+ const summary = summarizeValidationIssues(issues);
39
+ for (const group of summary.shownGroups) {
40
+ lines.push(`- **${group.type}** (${group.severity.toUpperCase()}) - ${group.count} issue(s); example \`${group.example.file}:${group.example.line}\` - ${group.message}`);
41
+ }
42
+ if (summary.hiddenIssueCount > 0) {
43
+ lines.push(`- + ${summary.hiddenIssueCount} more across ${summary.hiddenFileCount} files - run \`whale check --full\` for everything.`);
44
+ }
35
45
  }
36
46
  return lines.join("\n") + "\n";
37
47
  }
@@ -59,13 +69,11 @@ function renderScribe(ctx) {
59
69
  lines.push("");
60
70
  lines.push(`## Current Validation Status`);
61
71
  lines.push("");
62
- lines.push(`${issues.length} issue(s) open. Run \`whale validate\` for details.`);
72
+ lines.push(renderValidationSummaryMarkdown(issues));
63
73
  return lines.join("\n") + "\n";
64
74
  }
65
75
  function renderAtlas(ctx) {
66
76
  const { config, refinements, issues } = ctx;
67
- const errors = issues.filter((i) => i.severity === "error").length;
68
- const warnings = issues.filter((i) => i.severity === "warning").length;
69
77
  const lines = [
70
78
  "# Atlas — Product Strategy",
71
79
  "",
@@ -77,10 +85,10 @@ function renderAtlas(ctx) {
77
85
  `- Accent: ${config.branding?.accent ?? "unspecified"}`,
78
86
  "",
79
87
  "## Operational Health",
80
- `- Errors: ${errors}`,
81
- `- Warnings: ${warnings}`,
82
88
  `- Decisions logged: ${refinements.length}`,
83
89
  "",
90
+ renderValidationSummaryMarkdown(issues),
91
+ "",
84
92
  "## Decision Log",
85
93
  ""
86
94
  ];
@@ -1,11 +1,10 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import { validateCss } from "../validators/cssValidator.js";
4
+ import { renderValidationSummaryMarkdown } from "../utils/validationSummary.js";
4
5
  export async function generateValidationReport(target) {
5
6
  const issues = await validateCss(target);
6
7
  const reportPath = path.join(target, "docs", "validation-report.md");
7
- const errors = issues.filter((i) => i.severity === "error");
8
- const warnings = issues.filter((i) => i.severity === "warning");
9
8
  const lines = [
10
9
  "# Whale Validation Report",
11
10
  "",
@@ -13,37 +12,12 @@ export async function generateValidationReport(target) {
13
12
  "",
14
13
  "## Summary",
15
14
  "",
16
- `- Total issues: **${issues.length}**`,
17
- `- Errors: **${errors.length}**`,
18
- `- Warnings: **${warnings.length}**`,
15
+ renderValidationSummaryMarkdown(issues),
19
16
  ""
20
17
  ];
21
18
  if (issues.length === 0) {
22
19
  lines.push("✓ Clean run.");
23
20
  }
24
- else {
25
- // Group by file so the report is actionable.
26
- const byFile = new Map();
27
- for (const issue of issues) {
28
- const arr = byFile.get(issue.file) ?? [];
29
- arr.push(issue);
30
- byFile.set(issue.file, arr);
31
- }
32
- for (const [file, fileIssues] of byFile) {
33
- lines.push(`## ${file}`);
34
- lines.push("");
35
- for (const issue of fileIssues) {
36
- const tag = issue.severity === "error" ? "ERROR" : "WARNING";
37
- lines.push(`- **${tag}** (${issue.type}) at line ${issue.line}`);
38
- lines.push(` - ${issue.message}`);
39
- lines.push(` - Fix: ${issue.suggestion}`);
40
- if (issue.selector) {
41
- lines.push(` - Selector: \`${issue.selector}\``);
42
- }
43
- }
44
- lines.push("");
45
- }
46
- }
47
21
  await fs.ensureDir(path.dirname(reportPath));
48
22
  await fs.writeFile(reportPath, lines.join("\n"));
49
23
  return reportPath;
@@ -4,7 +4,8 @@ import { loadConfig } from "../utils/config.js";
4
4
  import { loadRefinements } from "../utils/refinements.js";
5
5
  import { loadDecisions } from "../utils/decisions.js";
6
6
  import { loadComponents } from "../utils/components.js";
7
- import { validateCss } from "../validators/cssValidator.js";
7
+ import { findCssValidationFiles, validateCss } from "../validators/cssValidator.js";
8
+ import { evaluateValidationHealth, renderValidationHealthWarningMarkdown, renderValidationSummaryMarkdown } from "../utils/validationSummary.js";
8
9
  import { renderFoundations } from "../templates/foundations.js";
9
10
  import { renderConventions } from "../templates/conventions.js";
10
11
  import { renderDecisions } from "../templates/decisions.js";
@@ -16,14 +17,19 @@ export async function generateWiki(target) {
16
17
  const refinements = await loadRefinements(target);
17
18
  const decisions = await loadDecisions(target);
18
19
  const components = await loadComponents(target);
19
- const issues = await validateCss(target);
20
+ const [issues, scannedFiles] = await Promise.all([
21
+ validateCss(target),
22
+ findCssValidationFiles(target)
23
+ ]);
20
24
  const errors = issues.filter((i) => i.severity === "error").length;
21
25
  const warnings = issues.filter((i) => i.severity === "warning").length;
26
+ const validationSummary = renderValidationSummaryMarkdown(issues);
27
+ const healthWarning = renderValidationHealthWarningMarkdown(evaluateValidationHealth(issues, scannedFiles.length, config));
22
28
  const wikiDir = path.join(target, "llm-wiki");
23
29
  await fs.ensureDir(wikiDir);
24
30
  const themedFiles = {
25
31
  "README.md": renderWikiReadme(config),
26
- "PROJECT.md": renderProject(config, errors, warnings, refinements.length, decisions.length),
32
+ "PROJECT.md": renderProject(config, validationSummary, healthWarning, refinements.length, decisions.length),
27
33
  "FOUNDATIONS.md": renderFoundations(config, decisions),
28
34
  "CONVENTIONS.md": renderConventions(config, refinements),
29
35
  "DECISIONS.md": renderDecisions(decisions),
@@ -39,6 +45,8 @@ export async function generateWiki(target) {
39
45
  const rootIndex = renderRootIndex(config, {
40
46
  errors,
41
47
  warnings,
48
+ validationSummary,
49
+ healthWarning,
42
50
  refinementCount: refinements.length,
43
51
  decisionCount: decisions.length,
44
52
  componentCount: components.length