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 +19 -0
- package/README.md +3 -3
- package/dist/commands/adopt.js +11 -5
- package/dist/commands/changes.js +53 -39
- package/dist/commands/decision.js +1 -1
- package/dist/commands/friendly.js +11 -5
- package/dist/commands/ignite.js +6 -1
- package/dist/commands/init.js +7 -2
- package/dist/commands/insights.js +7 -2
- package/dist/commands/refine.js +1 -8
- package/dist/commands/selene.js +0 -3
- package/dist/commands/sync.js +7 -3
- package/dist/commands/validate.js +33 -2
- package/dist/commands/watch.js +1 -1
- package/dist/commands/wiki.js +5 -1
- package/dist/generators/markdownGenerator.js +16 -8
- package/dist/generators/reportGenerator.js +2 -28
- package/dist/generators/wikiGenerator.js +11 -3
- package/dist/index.js +20 -8
- package/dist/mcp/server.js +3 -9
- package/dist/selene/promptBuilder.js +2 -2
- package/dist/templates/claude.js +14 -6
- package/dist/templates/conventions.js +4 -2
- package/dist/templates/decisions.js +17 -16
- package/dist/templates/project.js +6 -4
- package/dist/utils/decisions.js +93 -13
- package/dist/utils/refinements.js +86 -13
- package/dist/utils/targetGuard.js +77 -0
- package/dist/utils/validationSummary.js +103 -0
- package/dist/validators/cssValidator.js +8 -5
- package/dist/version.js +1 -1
- package/package.json +3 -2
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
|
|
38
|
-
│ └── refinements
|
|
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
|
-
`
|
|
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
|
|
package/dist/commands/adopt.js
CHANGED
|
@@ -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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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);
|
package/dist/commands/changes.js
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
53
|
-
|
|
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.
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/ignite.js
CHANGED
|
@@ -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
|
|
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 ------------
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
});
|
package/dist/commands/refine.js
CHANGED
|
@@ -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
|
-
|
|
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 = [];
|
package/dist/commands/selene.js
CHANGED
|
@@ -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."));
|
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
-
*
|
|
14
|
+
* project configuration and intelligence stores (the source of truth).
|
|
14
15
|
*
|
|
15
16
|
* Run this:
|
|
16
|
-
* - after manually editing intelligence
|
|
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
|
-
|
|
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 },
|
package/dist/commands/watch.js
CHANGED
|
@@ -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
|
|
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/commands/wiki.js
CHANGED
|
@@ -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
|
-
|
|
31
|
+
"## Open Issues",
|
|
31
32
|
""
|
|
32
33
|
];
|
|
33
|
-
|
|
34
|
-
lines.push(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|