whale-igniter 1.1.0
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/LICENSE +21 -0
- package/README.md +275 -0
- package/dist/analyzer/imports.js +88 -0
- package/dist/analyzer/insights.js +276 -0
- package/dist/commands/add.js +36 -0
- package/dist/commands/adopt.js +180 -0
- package/dist/commands/adoptReview.js +267 -0
- package/dist/commands/component.js +93 -0
- package/dist/commands/createComponent.js +207 -0
- package/dist/commands/decision.js +98 -0
- package/dist/commands/docs.js +34 -0
- package/dist/commands/ignite.js +212 -0
- package/dist/commands/init.js +66 -0
- package/dist/commands/insights.js +123 -0
- package/dist/commands/mcp.js +106 -0
- package/dist/commands/refine.js +36 -0
- package/dist/commands/selene.js +516 -0
- package/dist/commands/sync.js +43 -0
- package/dist/commands/validate.js +48 -0
- package/dist/commands/watch.js +150 -0
- package/dist/commands/wiki.js +21 -0
- package/dist/generators/markdownGenerator.js +112 -0
- package/dist/generators/reportGenerator.js +50 -0
- package/dist/generators/wikiGenerator.js +365 -0
- package/dist/index.js +213 -0
- package/dist/mcp/server.js +404 -0
- package/dist/scanner/componentScanner.js +522 -0
- package/dist/scanner/foundationInferrer.js +174 -0
- package/dist/scanner/tailwindMapper.js +58 -0
- package/dist/scanner/tailwindScanner.js +186 -0
- package/dist/selene/apiClient.js +168 -0
- package/dist/selene/cache.js +68 -0
- package/dist/selene/clipboard.js +56 -0
- package/dist/selene/promptBuilder.js +229 -0
- package/dist/selene/providers.js +67 -0
- package/dist/selene/responseParser.js +149 -0
- package/dist/ui/atoms.js +30 -0
- package/dist/ui/blocks.js +208 -0
- package/dist/ui/capabilities.js +64 -0
- package/dist/ui/index.js +13 -0
- package/dist/ui/symbols.js +41 -0
- package/dist/ui/theme.js +78 -0
- package/dist/utils/components.js +40 -0
- package/dist/utils/config.js +31 -0
- package/dist/utils/decisions.js +32 -0
- package/dist/utils/paths.js +4 -0
- package/dist/utils/proposals.js +61 -0
- package/dist/utils/refinements.js +81 -0
- package/dist/utils/registry.js +45 -0
- package/dist/utils/writeJson.js +6 -0
- package/dist/validators/cssValidator.js +204 -0
- package/dist/version.js +1 -0
- package/docs/ROADMAP.md +206 -0
- package/package.json +76 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "fs-extra";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import prompts from "prompts";
|
|
5
|
+
import { resolveTarget } from "../utils/paths.js";
|
|
6
|
+
import { loadConfig } from "../utils/config.js";
|
|
7
|
+
import { loadDecisions, appendDecision } from "../utils/decisions.js";
|
|
8
|
+
import { loadRefinements, appendRefinement } from "../utils/refinements.js";
|
|
9
|
+
import { loadComponents, upsertComponent } from "../utils/components.js";
|
|
10
|
+
import { generateWiki } from "../generators/wikiGenerator.js";
|
|
11
|
+
import { buildDescribePrompt, buildAuditPrompt, buildSuggestPrompt } from "../selene/promptBuilder.js";
|
|
12
|
+
import { parseJsonResponse, isDescribeResponse, isAuditResponse, isSuggestResponse } from "../selene/responseParser.js";
|
|
13
|
+
import { copyToClipboard } from "../selene/clipboard.js";
|
|
14
|
+
import { resolveProvider, describeProviderState } from "../selene/providers.js";
|
|
15
|
+
import { callProvider, estimateTokens, estimateCostUsd } from "../selene/apiClient.js";
|
|
16
|
+
import { readCache, writeCache, clearCache } from "../selene/cache.js";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { ui } from "../ui/index.js";
|
|
19
|
+
const PENDING_DIR = ".whale/selene";
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Shared helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
async function loadContext(target) {
|
|
24
|
+
const [config, decisions, refinements, components] = await Promise.all([
|
|
25
|
+
loadConfig(target),
|
|
26
|
+
loadDecisions(target),
|
|
27
|
+
loadRefinements(target),
|
|
28
|
+
loadComponents(target)
|
|
29
|
+
]);
|
|
30
|
+
return { config, decisions, refinements, components };
|
|
31
|
+
}
|
|
32
|
+
async function emitPrompt(target, prompt, kind, options) {
|
|
33
|
+
if (options.stdout) {
|
|
34
|
+
process.stdout.write(prompt);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (options.out) {
|
|
38
|
+
const outPath = path.resolve(target, options.out);
|
|
39
|
+
await fs.ensureDir(path.dirname(outPath));
|
|
40
|
+
await fs.writeFile(outPath, prompt, "utf8");
|
|
41
|
+
console.log(ui.ok(`Prompt written to ${path.relative(target, outPath)}`));
|
|
42
|
+
console.log(ui.muted(" Paste it into your AI agent of choice."));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// Default: copy to clipboard + save a copy in .whale/selene for `apply` round-trip.
|
|
46
|
+
const stashDir = path.join(target, PENDING_DIR);
|
|
47
|
+
await fs.ensureDir(stashDir);
|
|
48
|
+
const stashPath = path.join(stashDir, `${kind}-${Date.now()}.prompt.md`);
|
|
49
|
+
await fs.writeFile(stashPath, prompt, "utf8");
|
|
50
|
+
const copied = await copyToClipboard(prompt);
|
|
51
|
+
if (copied) {
|
|
52
|
+
console.log(ui.ok("Prompt copied to clipboard"));
|
|
53
|
+
console.log(ui.muted(` Length: ${prompt.length} chars`));
|
|
54
|
+
console.log(ui.muted(` Stash: ${path.relative(target, stashPath)}`));
|
|
55
|
+
console.log();
|
|
56
|
+
console.log(ui.accent("Next:"));
|
|
57
|
+
console.log(ui.muted(" 1. Paste the prompt into Claude / ChatGPT / Cursor."));
|
|
58
|
+
console.log(ui.muted(" 2. Copy the response."));
|
|
59
|
+
console.log(ui.muted(` 3. Run: whale selene apply ${kind} (paste, then Ctrl+D)`));
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
console.log(ui.warn("Could not access the clipboard — printing prompt below."));
|
|
63
|
+
console.log(ui.muted(` Stash: ${path.relative(target, stashPath)}`));
|
|
64
|
+
console.log();
|
|
65
|
+
console.log(ui.rule());
|
|
66
|
+
console.log(prompt);
|
|
67
|
+
console.log(ui.rule());
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The orchestration shared by describe/audit/suggest.
|
|
72
|
+
*
|
|
73
|
+
* Decides between API mode and prompt mode based on:
|
|
74
|
+
* 1. --prompt-only forces prompt mode.
|
|
75
|
+
* 2. --api-only forces API mode (errors if no key).
|
|
76
|
+
* 3. Otherwise: API if a key is resolvable, prompt otherwise.
|
|
77
|
+
* 4. config.selene.autoCall = false flips the default to prompt mode.
|
|
78
|
+
*
|
|
79
|
+
* Returns the raw response text on API success, or null on prompt mode
|
|
80
|
+
* (the caller stops there — the user runs `whale selene apply` later).
|
|
81
|
+
*
|
|
82
|
+
* Why a single helper instead of inline logic per command: the three
|
|
83
|
+
* commands all need cache lookup, cost confirmation, error fallback to
|
|
84
|
+
* prompt mode, etc. Centralising keeps the policy consistent.
|
|
85
|
+
*/
|
|
86
|
+
async function runSelene(target, kind, prompt, options) {
|
|
87
|
+
const config = await loadConfig(target);
|
|
88
|
+
const sel = config.selene ?? {};
|
|
89
|
+
// Step 1: figure out the mode.
|
|
90
|
+
let wantApi = !options.promptOnly;
|
|
91
|
+
if (options.apiOnly)
|
|
92
|
+
wantApi = true;
|
|
93
|
+
if (sel.autoCall === false && !options.apiOnly)
|
|
94
|
+
wantApi = false;
|
|
95
|
+
// Step 2: try to resolve a provider if we want API.
|
|
96
|
+
const force = options.provider ?? undefined;
|
|
97
|
+
const resolved = wantApi
|
|
98
|
+
? resolveProvider(config, { force, modelOverride: options.model })
|
|
99
|
+
: null;
|
|
100
|
+
if (!resolved) {
|
|
101
|
+
if (options.apiOnly) {
|
|
102
|
+
console.log(ui.fail("--api-only was set but no API key is configured."));
|
|
103
|
+
console.log(ui.muted(" Set ANTHROPIC_API_KEY or OPENAI_API_KEY in your environment."));
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return { rawResponse: null };
|
|
106
|
+
}
|
|
107
|
+
await emitPrompt(target, prompt, kind, options);
|
|
108
|
+
return { rawResponse: null };
|
|
109
|
+
}
|
|
110
|
+
// ---- API mode --------------------------------------------------------------
|
|
111
|
+
console.log(ui.accent(`Selene • ${resolved.provider} • ${resolved.model}`) + ui.muted(` (key from ${resolved.source})`));
|
|
112
|
+
// Cache lookup, unless the user opted out.
|
|
113
|
+
const cacheEnabled = !options.noCache && !sel.noCache;
|
|
114
|
+
if (cacheEnabled) {
|
|
115
|
+
const cached = await readCache(target, resolved.model, prompt);
|
|
116
|
+
if (cached) {
|
|
117
|
+
console.log(ui.muted(" cache hit — no API call needed"));
|
|
118
|
+
// Still stash the response so `apply` from disk works if the user wants.
|
|
119
|
+
await stashResponse(target, kind, cached);
|
|
120
|
+
return { rawResponse: cached };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Cost confirmation.
|
|
124
|
+
const confirm = options.confirmCost || sel.confirmCost;
|
|
125
|
+
if (confirm) {
|
|
126
|
+
const inputTokens = estimateTokens(prompt);
|
|
127
|
+
const expectedOutput = sel.maxTokens ?? 1500;
|
|
128
|
+
const cost = estimateCostUsd(resolved.model, inputTokens, expectedOutput);
|
|
129
|
+
const costStr = cost === null ? "unknown" : `≈ $${cost.toFixed(4)}`;
|
|
130
|
+
console.log(ui.muted(` estimated cost: ${costStr} (${inputTokens} input + up to ${expectedOutput} output tokens)`));
|
|
131
|
+
const { ok } = await prompts({
|
|
132
|
+
type: "confirm",
|
|
133
|
+
name: "ok",
|
|
134
|
+
message: "Proceed with the call?",
|
|
135
|
+
initial: true
|
|
136
|
+
});
|
|
137
|
+
if (!ok) {
|
|
138
|
+
console.log(ui.warning("Aborted — falling back to prompt mode."));
|
|
139
|
+
await emitPrompt(target, prompt, kind, options);
|
|
140
|
+
return { rawResponse: null };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const spin = ora({ text: "Calling provider", stream: process.stderr }).start();
|
|
144
|
+
const result = await callProvider(resolved, {
|
|
145
|
+
prompt,
|
|
146
|
+
maxTokens: sel.maxTokens ?? 1500,
|
|
147
|
+
temperature: sel.temperature ?? 0.2
|
|
148
|
+
});
|
|
149
|
+
if (!result.ok) {
|
|
150
|
+
spin.fail(`API call failed: ${result.error}`);
|
|
151
|
+
if (result.retryable) {
|
|
152
|
+
console.log(ui.muted(" (retryable — try again later or pass --prompt-only)"));
|
|
153
|
+
}
|
|
154
|
+
// Graceful fallback: still emit the prompt so the user isn't stuck.
|
|
155
|
+
console.log(ui.warning("\nFalling back to prompt mode so you can run this manually:"));
|
|
156
|
+
await emitPrompt(target, prompt, kind, options);
|
|
157
|
+
return { rawResponse: null };
|
|
158
|
+
}
|
|
159
|
+
const usage = result.inputTokens !== null && result.outputTokens !== null
|
|
160
|
+
? ` ${ui.muted(`(${result.inputTokens} in / ${result.outputTokens} out)`)}`
|
|
161
|
+
: "";
|
|
162
|
+
spin.succeed(`Response received${usage}`);
|
|
163
|
+
if (cacheEnabled) {
|
|
164
|
+
await writeCache(target, resolved.model, prompt, result.text);
|
|
165
|
+
}
|
|
166
|
+
await stashResponse(target, kind, result.text);
|
|
167
|
+
return { rawResponse: result.text };
|
|
168
|
+
}
|
|
169
|
+
async function stashResponse(target, kind, text) {
|
|
170
|
+
const stashDir = path.join(target, PENDING_DIR);
|
|
171
|
+
await fs.ensureDir(stashDir);
|
|
172
|
+
const stashPath = path.join(stashDir, `${kind}-${Date.now()}.response.md`);
|
|
173
|
+
await fs.writeFile(stashPath, text, "utf8");
|
|
174
|
+
}
|
|
175
|
+
async function readResponse(target, options) {
|
|
176
|
+
if (options.from) {
|
|
177
|
+
const p = path.resolve(target, options.from);
|
|
178
|
+
return await fs.readFile(p, "utf8");
|
|
179
|
+
}
|
|
180
|
+
// Read from stdin until EOF.
|
|
181
|
+
return await new Promise((resolve, reject) => {
|
|
182
|
+
if (process.stdin.isTTY) {
|
|
183
|
+
console.log(ui.muted("Paste the LLM response, then press Ctrl+D (or Ctrl+Z, Enter on Windows):"));
|
|
184
|
+
}
|
|
185
|
+
let buf = "";
|
|
186
|
+
process.stdin.setEncoding("utf8");
|
|
187
|
+
process.stdin.on("data", (chunk) => {
|
|
188
|
+
buf += chunk;
|
|
189
|
+
});
|
|
190
|
+
process.stdin.on("end", () => resolve(buf));
|
|
191
|
+
process.stdin.on("error", (err) => reject(err));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
export async function seleneDescribeCommand(componentName, options = {}) {
|
|
195
|
+
const target = resolveTarget();
|
|
196
|
+
const ctx = await loadContext(target);
|
|
197
|
+
// Find the component file. Priority: --file flag, then catalog entry.
|
|
198
|
+
let filePath = options.file;
|
|
199
|
+
let existingEntry = ctx.components.find((c) => c.name === componentName);
|
|
200
|
+
if (!filePath && existingEntry?.files && existingEntry.files.length > 0) {
|
|
201
|
+
filePath = existingEntry.files[0];
|
|
202
|
+
}
|
|
203
|
+
if (!filePath) {
|
|
204
|
+
console.log(ui.fail(`Component "${componentName}" not found in the catalog and no --file given.`));
|
|
205
|
+
console.log(ui.muted(" Pass --file <path> or register the component first."));
|
|
206
|
+
process.exitCode = 1;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const absFile = path.resolve(target, filePath);
|
|
210
|
+
if (!(await fs.pathExists(absFile))) {
|
|
211
|
+
console.log(ui.fail(`File not found: ${filePath}`));
|
|
212
|
+
process.exitCode = 1;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const fileContents = await fs.readFile(absFile, "utf8");
|
|
216
|
+
const prompt = buildDescribePrompt(ctx, {
|
|
217
|
+
componentName,
|
|
218
|
+
filePath,
|
|
219
|
+
fileContents,
|
|
220
|
+
existingEntry
|
|
221
|
+
});
|
|
222
|
+
const { rawResponse } = await runSelene(target, "describe", prompt, options);
|
|
223
|
+
if (!rawResponse)
|
|
224
|
+
return; // Prompt-mode path — user will run `apply` later.
|
|
225
|
+
// API-mode path: auto-apply (with confirmation unless --yes is passed).
|
|
226
|
+
if (options.autoApply === false) {
|
|
227
|
+
console.log(ui.muted("\nResponse received but --no-auto-apply was set. Run `whale selene apply describe`."));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
await applyDescribe(target, rawResponse, {});
|
|
231
|
+
}
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// audit
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
export async function seleneAuditCommand(filePath, options = {}) {
|
|
236
|
+
const target = resolveTarget();
|
|
237
|
+
const ctx = await loadContext(target);
|
|
238
|
+
const absFile = path.resolve(target, filePath);
|
|
239
|
+
if (!(await fs.pathExists(absFile))) {
|
|
240
|
+
console.log(ui.fail(`File not found: ${filePath}`));
|
|
241
|
+
process.exitCode = 1;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const fileContents = await fs.readFile(absFile, "utf8");
|
|
245
|
+
const prompt = buildAuditPrompt(ctx, { filePath, fileContents });
|
|
246
|
+
const { rawResponse } = await runSelene(target, "audit", prompt, options);
|
|
247
|
+
if (!rawResponse)
|
|
248
|
+
return;
|
|
249
|
+
if (options.autoApply === false) {
|
|
250
|
+
console.log(ui.muted("\nResponse received but --no-auto-apply was set. Run `whale selene apply audit`."));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
await applyAudit(target, rawResponse, {});
|
|
254
|
+
}
|
|
255
|
+
export async function seleneSuggestCommand(options = {}) {
|
|
256
|
+
const target = resolveTarget();
|
|
257
|
+
const ctx = await loadContext(target);
|
|
258
|
+
const validFocus = ["decisions", "refinements", "components", "all"];
|
|
259
|
+
const focus = options.focus && validFocus.includes(options.focus)
|
|
260
|
+
? options.focus
|
|
261
|
+
: undefined;
|
|
262
|
+
const prompt = buildSuggestPrompt(ctx, { focus });
|
|
263
|
+
const { rawResponse } = await runSelene(target, "suggest", prompt, options);
|
|
264
|
+
if (!rawResponse)
|
|
265
|
+
return;
|
|
266
|
+
if (options.autoApply === false) {
|
|
267
|
+
console.log(ui.muted("\nResponse received but --no-auto-apply was set. Run `whale selene apply suggest`."));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
await applySuggest(target, rawResponse, { yes: options.yes });
|
|
271
|
+
}
|
|
272
|
+
export async function seleneApplyCommand(kind, options = {}) {
|
|
273
|
+
const target = resolveTarget();
|
|
274
|
+
const raw = await readResponse(target, options);
|
|
275
|
+
if (!raw.trim()) {
|
|
276
|
+
console.log(ui.fail("Empty input. Nothing to apply."));
|
|
277
|
+
process.exitCode = 1;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (kind === "describe")
|
|
281
|
+
return applyDescribe(target, raw, options);
|
|
282
|
+
if (kind === "audit")
|
|
283
|
+
return applyAudit(target, raw, options);
|
|
284
|
+
if (kind === "suggest")
|
|
285
|
+
return applySuggest(target, raw, options);
|
|
286
|
+
console.log(ui.fail(`Unknown apply kind: ${kind}`));
|
|
287
|
+
process.exitCode = 1;
|
|
288
|
+
}
|
|
289
|
+
async function applyDescribe(target, raw, options) {
|
|
290
|
+
const parsed = parseJsonResponse(raw, isDescribeResponse);
|
|
291
|
+
if (!parsed.ok) {
|
|
292
|
+
console.log(ui.fail(`${parsed.reason}`));
|
|
293
|
+
console.log(ui.muted(" Expected a fenced JSON block with `name` and `description`."));
|
|
294
|
+
process.exitCode = 1;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const v = parsed.value;
|
|
298
|
+
console.log(ui.emphasis(`\nProposed catalog update for ${v.name}\n`));
|
|
299
|
+
console.log(` description: ${ui.accent(v.description)}`);
|
|
300
|
+
if (v.category)
|
|
301
|
+
console.log(` category: ${v.category}`);
|
|
302
|
+
if (v.variants?.length)
|
|
303
|
+
console.log(` variants: ${v.variants.join(", ")}`);
|
|
304
|
+
if (v.states?.length)
|
|
305
|
+
console.log(` states: ${v.states.join(", ")}`);
|
|
306
|
+
if (v.tokens?.length)
|
|
307
|
+
console.log(` tokens: ${v.tokens.slice(0, 8).join(", ")}`);
|
|
308
|
+
if (!options.yes) {
|
|
309
|
+
const { ok } = await prompts({
|
|
310
|
+
type: "confirm",
|
|
311
|
+
name: "ok",
|
|
312
|
+
message: "Apply to intelligence/components.json?",
|
|
313
|
+
initial: true
|
|
314
|
+
});
|
|
315
|
+
if (!ok) {
|
|
316
|
+
console.log(ui.warning("Aborted."));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
await upsertComponent(target, {
|
|
321
|
+
name: v.name,
|
|
322
|
+
description: v.description,
|
|
323
|
+
category: v.category,
|
|
324
|
+
variants: v.variants,
|
|
325
|
+
states: v.states,
|
|
326
|
+
tokens: v.tokens
|
|
327
|
+
});
|
|
328
|
+
await generateWiki(target);
|
|
329
|
+
console.log(ui.ok("Catalog updated and AI context regenerated."));
|
|
330
|
+
}
|
|
331
|
+
async function applyAudit(target, raw, options) {
|
|
332
|
+
const parsed = parseJsonResponse(raw, isAuditResponse);
|
|
333
|
+
if (!parsed.ok) {
|
|
334
|
+
console.log(ui.fail(`${parsed.reason}`));
|
|
335
|
+
process.exitCode = 1;
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const v = parsed.value;
|
|
339
|
+
if (v.issues.length === 0) {
|
|
340
|
+
console.log(ui.ok(`${v.file} — no issues reported.`));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// Audit results are surfaced, not auto-applied. We write a markdown
|
|
344
|
+
// report into docs/selene/ that the user (or a CI step) can act on.
|
|
345
|
+
const reportDir = path.join(target, "docs", "selene");
|
|
346
|
+
await fs.ensureDir(reportDir);
|
|
347
|
+
const slug = v.file.replace(/[\\/]/g, "_").replace(/\.[^.]+$/, "");
|
|
348
|
+
const reportPath = path.join(reportDir, `audit-${slug}-${Date.now()}.md`);
|
|
349
|
+
const lines = [`# Selene audit — ${v.file}`, "", `Generated: ${new Date().toISOString()}`, ""];
|
|
350
|
+
for (const i of v.issues) {
|
|
351
|
+
lines.push(`## ${i.severity.toUpperCase()} — ${i.rule}${i.line ? ` (line ${i.line})` : ""}`);
|
|
352
|
+
lines.push("");
|
|
353
|
+
lines.push(i.message);
|
|
354
|
+
if (i.suggestion) {
|
|
355
|
+
lines.push("");
|
|
356
|
+
lines.push(`**Suggestion:** ${i.suggestion}`);
|
|
357
|
+
}
|
|
358
|
+
lines.push("");
|
|
359
|
+
}
|
|
360
|
+
await fs.writeFile(reportPath, lines.join("\n"), "utf8");
|
|
361
|
+
// Console summary.
|
|
362
|
+
console.log(ui.emphasis(`\nSelene audit — ${v.file}`));
|
|
363
|
+
console.log(ui.muted(`${v.issues.length} issue(s) reported. Full report:`));
|
|
364
|
+
console.log(ui.accent(` ${path.relative(target, reportPath)}`));
|
|
365
|
+
console.log();
|
|
366
|
+
for (const i of v.issues) {
|
|
367
|
+
const sev = i.severity === "critical" ? ui.emphasis(ui.danger("CRITICAL"))
|
|
368
|
+
: i.severity === "warning" ? ui.emphasis(ui.warning("WARNING"))
|
|
369
|
+
: ui.emphasis(ui.accent("INFO"));
|
|
370
|
+
console.log(` ${sev} ${ui.muted(`[${i.rule}${i.line ? `:${i.line}` : ""}]`)} ${i.message}`);
|
|
371
|
+
if (i.suggestion)
|
|
372
|
+
console.log(ui.muted(` → ${i.suggestion}`));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async function applySuggest(target, raw, options) {
|
|
376
|
+
const parsed = parseJsonResponse(raw, isSuggestResponse);
|
|
377
|
+
if (!parsed.ok) {
|
|
378
|
+
console.log(ui.fail(`${parsed.reason}`));
|
|
379
|
+
process.exitCode = 1;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const v = parsed.value;
|
|
383
|
+
if (v.suggestions.length === 0) {
|
|
384
|
+
console.log(ui.muted("No suggestions in the response."));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
console.log(ui.emphasis(`\n${v.suggestions.length} suggestion(s) from Selene\n`));
|
|
388
|
+
let applied = 0;
|
|
389
|
+
let skipped = 0;
|
|
390
|
+
for (const s of v.suggestions) {
|
|
391
|
+
console.log(ui.emphasis(s.title));
|
|
392
|
+
console.log(ui.muted(` kind: ${s.kind} (${s.category})`));
|
|
393
|
+
console.log(ui.muted(` ${s.rationale}`));
|
|
394
|
+
console.log();
|
|
395
|
+
console.log(" Proposed:");
|
|
396
|
+
console.log(" " + ui.accent(s.proposed_text.split("\n").join("\n ")));
|
|
397
|
+
console.log();
|
|
398
|
+
let action = "accept";
|
|
399
|
+
if (!options.yes) {
|
|
400
|
+
const { choice } = await prompts({
|
|
401
|
+
type: "select",
|
|
402
|
+
name: "choice",
|
|
403
|
+
message: "Action:",
|
|
404
|
+
choices: [
|
|
405
|
+
{ title: "Accept (record it)", value: "accept" },
|
|
406
|
+
{ title: "Skip", value: "skip" },
|
|
407
|
+
{ title: "Reject permanently", value: "reject" }
|
|
408
|
+
]
|
|
409
|
+
});
|
|
410
|
+
action = choice ?? "skip";
|
|
411
|
+
}
|
|
412
|
+
if (action === "accept") {
|
|
413
|
+
if (s.kind === "decision" || s.kind === "foundation") {
|
|
414
|
+
await appendDecision(target, {
|
|
415
|
+
title: s.title,
|
|
416
|
+
category: s.category,
|
|
417
|
+
context: s.rationale,
|
|
418
|
+
decision: s.proposed_text
|
|
419
|
+
});
|
|
420
|
+
console.log(ui.success(" ✓ Recorded as decision."));
|
|
421
|
+
}
|
|
422
|
+
else if (s.kind === "refinement") {
|
|
423
|
+
await appendRefinement(target, {
|
|
424
|
+
id: randomUUID(),
|
|
425
|
+
timestamp: new Date().toISOString(),
|
|
426
|
+
note: s.proposed_text
|
|
427
|
+
});
|
|
428
|
+
console.log(ui.success(" ✓ Recorded as refinement."));
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
// cleanup — surfaced but not auto-applied.
|
|
432
|
+
console.log(ui.muted(" (cleanup suggestion — surfaced, not auto-applied)"));
|
|
433
|
+
}
|
|
434
|
+
applied += 1;
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
skipped += 1;
|
|
438
|
+
}
|
|
439
|
+
console.log();
|
|
440
|
+
}
|
|
441
|
+
if (applied > 0) {
|
|
442
|
+
await generateWiki(target);
|
|
443
|
+
console.log(ui.muted("✓ AI context regenerated."));
|
|
444
|
+
}
|
|
445
|
+
console.log();
|
|
446
|
+
console.log(`Applied ${applied}, skipped ${skipped}.`);
|
|
447
|
+
}
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Utility commands: status and cache
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
export async function seleneStatusCommand() {
|
|
452
|
+
const target = resolveTarget();
|
|
453
|
+
const config = await loadConfig(target);
|
|
454
|
+
const sel = config.selene ?? {};
|
|
455
|
+
console.log();
|
|
456
|
+
console.log(ui.header("Whale Igniter", "selene status"));
|
|
457
|
+
console.log();
|
|
458
|
+
console.log(ui.section("Providers"));
|
|
459
|
+
for (const p of describeProviderState()) {
|
|
460
|
+
const mark = p.available ? ui.glyph.check : ui.muted("·");
|
|
461
|
+
const envNote = p.available ? ui.muted(`(${p.envName} set)`) : ui.muted(`(${p.envName} not set)`);
|
|
462
|
+
console.log(ui.indent(`${mark} ${p.provider} ${envNote}`));
|
|
463
|
+
}
|
|
464
|
+
console.log();
|
|
465
|
+
console.log(ui.section("Configuration"));
|
|
466
|
+
if (Object.keys(sel).length === 0) {
|
|
467
|
+
console.log(ui.indent(ui.muted("(empty — using defaults)")));
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
const cfgLines = [];
|
|
471
|
+
if (sel.provider)
|
|
472
|
+
cfgLines.push(ui.kv("provider", sel.provider, { keyWidth: 12 }));
|
|
473
|
+
if (sel.model)
|
|
474
|
+
cfgLines.push(ui.kv("model", ui.code(sel.model), { keyWidth: 12 }));
|
|
475
|
+
if (sel.temperature !== undefined)
|
|
476
|
+
cfgLines.push(ui.kv("temperature", String(sel.temperature), { keyWidth: 12 }));
|
|
477
|
+
if (sel.maxTokens !== undefined)
|
|
478
|
+
cfgLines.push(ui.kv("maxTokens", String(sel.maxTokens), { keyWidth: 12 }));
|
|
479
|
+
if (sel.autoCall !== undefined)
|
|
480
|
+
cfgLines.push(ui.kv("autoCall", String(sel.autoCall), { keyWidth: 12 }));
|
|
481
|
+
if (sel.confirmCost !== undefined)
|
|
482
|
+
cfgLines.push(ui.kv("confirmCost", String(sel.confirmCost), { keyWidth: 12 }));
|
|
483
|
+
if (sel.noCache !== undefined)
|
|
484
|
+
cfgLines.push(ui.kv("noCache", String(sel.noCache), { keyWidth: 12 }));
|
|
485
|
+
console.log(ui.indent(cfgLines.join("\n")));
|
|
486
|
+
}
|
|
487
|
+
// What would actually happen on a Selene call right now?
|
|
488
|
+
const resolved = resolveProvider(config);
|
|
489
|
+
console.log();
|
|
490
|
+
console.log(ui.section("Active mode"));
|
|
491
|
+
if (resolved) {
|
|
492
|
+
console.log(ui.indent(`${ui.glyph.check} ${ui.success("API")} ${ui.muted(`(${resolved.provider} • ${resolved.model})`)}`));
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
console.log(ui.indent(`${ui.glyph.info} ${ui.warning("prompt-only")} ${ui.muted("(no API key found)")}`));
|
|
496
|
+
console.log(ui.indent(ui.muted("Set ANTHROPIC_API_KEY or OPENAI_API_KEY to enable API mode.")));
|
|
497
|
+
}
|
|
498
|
+
console.log();
|
|
499
|
+
console.log(ui.section("Cache"));
|
|
500
|
+
const cacheDir = path.join(target, ".whale/selene/cache");
|
|
501
|
+
if (await fs.pathExists(cacheDir)) {
|
|
502
|
+
const entries = (await fs.readdir(cacheDir)).filter((f) => f.endsWith(".json")).length;
|
|
503
|
+
console.log(ui.indent(`${entries} ${ui.muted("entry/entries at .whale/selene/cache")}`));
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
console.log(ui.indent(ui.muted("empty")));
|
|
507
|
+
}
|
|
508
|
+
console.log();
|
|
509
|
+
}
|
|
510
|
+
export async function seleneCacheClearCommand() {
|
|
511
|
+
const target = resolveTarget();
|
|
512
|
+
const removed = await clearCache(target);
|
|
513
|
+
console.log();
|
|
514
|
+
console.log(ui.ok(`Cleared ${removed} cache entry/entries.`));
|
|
515
|
+
console.log();
|
|
516
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { resolveTarget } from "../utils/paths.js";
|
|
4
|
+
import { generateWiki } from "../generators/wikiGenerator.js";
|
|
5
|
+
import { loadConfig } from "../utils/config.js";
|
|
6
|
+
import { loadDecisions } from "../utils/decisions.js";
|
|
7
|
+
import { loadComponents } from "../utils/components.js";
|
|
8
|
+
import { loadRefinements } from "../utils/refinements.js";
|
|
9
|
+
import { ui } from "../ui/index.js";
|
|
10
|
+
/**
|
|
11
|
+
* `whale sync` regenerates everything an AI agent reads, from the
|
|
12
|
+
* intelligence JSON files (which are the source of truth).
|
|
13
|
+
*
|
|
14
|
+
* Run this:
|
|
15
|
+
* - after manually editing intelligence/*.json
|
|
16
|
+
* - before handing the project to an AI agent
|
|
17
|
+
* - in CI to ensure CLAUDE.md stays in sync
|
|
18
|
+
*/
|
|
19
|
+
export async function syncCommand(targetArg) {
|
|
20
|
+
const target = resolveTarget(targetArg);
|
|
21
|
+
const spinner = ora("Reading intelligence stores").start();
|
|
22
|
+
const config = await loadConfig(target);
|
|
23
|
+
const decisions = await loadDecisions(target);
|
|
24
|
+
const components = await loadComponents(target);
|
|
25
|
+
const refinements = await loadRefinements(target);
|
|
26
|
+
spinner.succeed(`Loaded: ${decisions.length} decision(s), ${components.length} component(s), ${refinements.length} refinement(s)`);
|
|
27
|
+
const spin2 = ora("Regenerating AI context").start();
|
|
28
|
+
const { rootFiles, wikiFiles } = await generateWiki(target);
|
|
29
|
+
spin2.succeed("AI context regenerated");
|
|
30
|
+
console.log();
|
|
31
|
+
console.log(ui.section("Root entry points"));
|
|
32
|
+
for (const file of rootFiles) {
|
|
33
|
+
console.log(` ${ui.ok(ui.path(path.relative(target, file)))}`);
|
|
34
|
+
}
|
|
35
|
+
console.log();
|
|
36
|
+
console.log(ui.section("Wiki"));
|
|
37
|
+
for (const file of wikiFiles) {
|
|
38
|
+
console.log(` ${ui.ok(ui.path(path.relative(target, file)))}`);
|
|
39
|
+
}
|
|
40
|
+
console.log();
|
|
41
|
+
console.log(ui.accent("AI agents will now see the latest project state."));
|
|
42
|
+
console.log(ui.muted(`Targets configured: ${(config.aiTargets ?? []).join(", ") || "(none)"}`));
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import ora from "ora";
|
|
2
|
+
import { resolveTarget } from "../utils/paths.js";
|
|
3
|
+
import { validateCss, hasErrors } from "../validators/cssValidator.js";
|
|
4
|
+
import { ui } from "../ui/index.js";
|
|
5
|
+
export async function validateCommand(targetArg) {
|
|
6
|
+
const target = resolveTarget(targetArg);
|
|
7
|
+
const spinner = ora("Scanning workspace").start();
|
|
8
|
+
let issues;
|
|
9
|
+
try {
|
|
10
|
+
issues = await validateCss(target);
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
spinner.fail(`Validation failed: ${err.message}`);
|
|
14
|
+
process.exitCode = 2;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
spinner.succeed("Workspace scanned");
|
|
18
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
19
|
+
const warningCount = issues.filter((i) => i.severity === "warning").length;
|
|
20
|
+
if (issues.length === 0) {
|
|
21
|
+
console.log();
|
|
22
|
+
console.log(ui.ok("No issues found."));
|
|
23
|
+
process.exitCode = 0;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// Header with the counts as badges.
|
|
27
|
+
console.log();
|
|
28
|
+
console.log(ui.summary([
|
|
29
|
+
{ label: "issues", value: issues.length },
|
|
30
|
+
{ label: "errors", value: errorCount, tone: errorCount > 0 ? "danger" : "muted" },
|
|
31
|
+
{ label: "warnings", value: warningCount, tone: warningCount > 0 ? "warn" : "muted" }
|
|
32
|
+
]));
|
|
33
|
+
console.log();
|
|
34
|
+
for (const issue of issues) {
|
|
35
|
+
const head = issue.severity === "error"
|
|
36
|
+
? `${ui.glyph.cross} ${ui.danger(issue.type)}`
|
|
37
|
+
: `${ui.glyph.warn} ${ui.warning(issue.type)}`;
|
|
38
|
+
console.log(`${head} ${ui.muted(`— ${issue.file}:${issue.line}:${issue.column}`)}`);
|
|
39
|
+
console.log(` ${issue.message}`);
|
|
40
|
+
console.log(` ${ui.next(issue.suggestion)}`);
|
|
41
|
+
if (issue.selector) {
|
|
42
|
+
console.log(` ${ui.muted("selector: " + issue.selector)}`);
|
|
43
|
+
}
|
|
44
|
+
console.log();
|
|
45
|
+
}
|
|
46
|
+
// Exit code: errors fail the run, warnings don't.
|
|
47
|
+
process.exitCode = hasErrors(issues) ? 1 : 0;
|
|
48
|
+
}
|