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/dist/index.js
CHANGED
|
@@ -39,25 +39,31 @@ program
|
|
|
39
39
|
.description("Bootstrap a Whale workspace with AI-readable context (opinionated by default).")
|
|
40
40
|
.option("-i, --interactive", "Run an interactive wizard to configure stack, packs, AI targets")
|
|
41
41
|
.option("-m, --minimal", "Minimal scaffold: folders + config + one validator pack")
|
|
42
|
+
.option("--force", "Proceed even when the target does not look like a project directory")
|
|
42
43
|
.action((projectName, opts) => igniteCommand(projectName, opts));
|
|
43
44
|
program
|
|
44
45
|
.command("init [projectName]")
|
|
45
46
|
.description("Create an empty Whale workspace (no packs, no AI context).")
|
|
46
|
-
.
|
|
47
|
-
|
|
47
|
+
.option("--force", "Proceed even when the target does not look like a project directory")
|
|
48
|
+
.action(async (projectName, opts) => {
|
|
49
|
+
await initCommand(projectName, opts);
|
|
48
50
|
});
|
|
49
51
|
program
|
|
50
52
|
.command("sync [target]")
|
|
51
|
-
.description("Regenerate CLAUDE.md and the LLM Wiki from intelligence
|
|
52
|
-
.
|
|
53
|
+
.description("Regenerate CLAUDE.md and the LLM Wiki from project intelligence.")
|
|
54
|
+
.option("--force", "Proceed even when the target does not look like a project directory")
|
|
55
|
+
.action((target, opts) => syncCommand(target, opts));
|
|
53
56
|
program
|
|
54
57
|
.command("remember [target]")
|
|
55
58
|
.description("Update project memory for AI assistants (friendly alias for sync).")
|
|
56
|
-
.
|
|
59
|
+
.option("--force", "Proceed even when the target does not look like a project directory")
|
|
60
|
+
.action((target, opts) => rememberCommand(target, opts));
|
|
57
61
|
program
|
|
58
62
|
.command("check [target]")
|
|
59
63
|
.description("Check project health and recommendations (validate + insights).")
|
|
60
|
-
.
|
|
64
|
+
.option("--force", "Proceed even when the target does not look like a project directory")
|
|
65
|
+
.option("--full", "Show every validation issue instead of grouped summaries")
|
|
66
|
+
.action((target, opts) => checkCommand(target, opts));
|
|
61
67
|
program
|
|
62
68
|
.command("improve")
|
|
63
69
|
.description("Ask Selene for project-wide improvement suggestions.")
|
|
@@ -73,7 +79,9 @@ program
|
|
|
73
79
|
program
|
|
74
80
|
.command("validate [target]")
|
|
75
81
|
.description("Run operational validations (exits non-zero on errors).")
|
|
76
|
-
.
|
|
82
|
+
.option("--force", "Proceed even when the target does not look like a project directory")
|
|
83
|
+
.option("--full", "Show every validation issue instead of grouped summaries")
|
|
84
|
+
.action((target, opts) => validateCommand(target, opts));
|
|
77
85
|
program
|
|
78
86
|
.command("docs [target]")
|
|
79
87
|
.description("Generate validation report and run installed generator packs.")
|
|
@@ -81,7 +89,8 @@ program
|
|
|
81
89
|
program
|
|
82
90
|
.command("wiki [target]")
|
|
83
91
|
.description("Regenerate AI context (alias of `sync`).")
|
|
84
|
-
.
|
|
92
|
+
.option("--force", "Proceed even when the target does not look like a project directory")
|
|
93
|
+
.action((target, opts) => wikiCommand(target, opts));
|
|
85
94
|
program
|
|
86
95
|
.command("refine <note>")
|
|
87
96
|
.description("Record an operational refinement. Mention an issue type to suppress matching issues.")
|
|
@@ -157,12 +166,14 @@ const insightsCmd = program
|
|
|
157
166
|
.option("--category <name>", "Filter by category: refinements | decisions | components | foundations | tokens | coverage")
|
|
158
167
|
.option("--min-severity <level>", "Show only insights at this severity or higher (info | warning | critical)")
|
|
159
168
|
.option("--skip-scan", "Skip the source scan (faster, but disables orphan/drift insights)")
|
|
169
|
+
.option("--force", "Proceed even when the target does not look like a project directory")
|
|
160
170
|
.action((target, opts) => insightsCommand(target, opts));
|
|
161
171
|
insightsCmd
|
|
162
172
|
.command("drift <category>")
|
|
163
173
|
.description("Show or review drift in spacing | color | radii.")
|
|
164
174
|
.option("--review", "Enter interactive review loop: accept as refinement, flag for refactor, or skip")
|
|
165
175
|
.option("--target <path>", "Workspace path (default: cwd)")
|
|
176
|
+
.option("--force", "Proceed even when the target does not look like a project directory")
|
|
166
177
|
.action((category, opts) => insightsDriftReviewCommand(category, opts));
|
|
167
178
|
const create = program
|
|
168
179
|
.command("create")
|
|
@@ -240,6 +251,7 @@ const adopt = program
|
|
|
240
251
|
.description("Scan an existing project and propose components, foundations and decisions.")
|
|
241
252
|
.option("--pattern <glob>", "Glob pattern for source files (default: src/**/*.{tsx,jsx})")
|
|
242
253
|
.option("--dry-run", "Run the scanner but don't write proposals to disk")
|
|
254
|
+
.option("--force", "Proceed even when the target does not look like a project directory")
|
|
243
255
|
.action((target, opts) => adoptCommand(target, opts));
|
|
244
256
|
adopt
|
|
245
257
|
.command("review [target]")
|
package/dist/mcp/server.js
CHANGED
|
@@ -38,7 +38,6 @@ import { collectReferencedFiles, normalizePath } from "../analyzer/imports.js";
|
|
|
38
38
|
import { scanComponents } from "../scanner/componentScanner.js";
|
|
39
39
|
import { aggregateTailwind } from "../scanner/tailwindScanner.js";
|
|
40
40
|
import { validateCss } from "../validators/cssValidator.js";
|
|
41
|
-
import { randomUUID } from "node:crypto";
|
|
42
41
|
import { PACKAGE_VERSION } from "../version.js";
|
|
43
42
|
function ok(text) {
|
|
44
43
|
return { content: [{ type: "text", text }] };
|
|
@@ -354,12 +353,7 @@ export function buildMcpServer() {
|
|
|
354
353
|
if (!check.ok)
|
|
355
354
|
return err(check.reason);
|
|
356
355
|
const scope = inferScope(note);
|
|
357
|
-
await appendRefinement(ws, {
|
|
358
|
-
id: randomUUID(),
|
|
359
|
-
timestamp: new Date().toISOString(),
|
|
360
|
-
note,
|
|
361
|
-
scope
|
|
362
|
-
});
|
|
356
|
+
await appendRefinement(ws, { note, scope });
|
|
363
357
|
await generateWiki(ws);
|
|
364
358
|
const scopeDesc = scope
|
|
365
359
|
? `Scope inferred: ${Object.entries(scope).map(([k, v]) => `${k}=${v}`).join(", ")}.`
|
|
@@ -368,8 +362,8 @@ export function buildMcpServer() {
|
|
|
368
362
|
});
|
|
369
363
|
server.registerTool("whale_sync", {
|
|
370
364
|
title: "Regenerate CLAUDE.md and the LLM wiki",
|
|
371
|
-
description: "Regenerates the AI-readable context files from intelligence
|
|
372
|
-
"this if you've manually edited the
|
|
365
|
+
description: "Regenerates the AI-readable context files from project intelligence. Call " +
|
|
366
|
+
"this if you've manually edited the source stores and want the wiki to catch " +
|
|
373
367
|
"up. Whale auto-syncs on every recorded change, so manual calls are rarely needed.",
|
|
374
368
|
inputSchema: { target: z.string().optional() }
|
|
375
369
|
}, async ({ target }) => {
|
|
@@ -42,7 +42,7 @@ function renderProjectContext(ctx) {
|
|
|
42
42
|
lines.push(`- Control radius: ${c.foundations?.radius?.control ?? 4}px (buttons, inputs)`);
|
|
43
43
|
lines.push(`- Container radius: ${c.foundations?.radius?.container ?? 8}px (cards, modals)`);
|
|
44
44
|
// Active decisions — show titles only to keep tokens down. Full text
|
|
45
|
-
// is available in intelligence/decisions
|
|
45
|
+
// is available in the linked intelligence/decisions Markdown files.
|
|
46
46
|
const active = ctx.decisions.filter((d) => d.status === "active");
|
|
47
47
|
if (active.length > 0) {
|
|
48
48
|
lines.push("");
|
|
@@ -52,7 +52,7 @@ function renderProjectContext(ctx) {
|
|
|
52
52
|
lines.push(`- [${d.category}] ${d.title}`);
|
|
53
53
|
}
|
|
54
54
|
if (active.length > recent.length) {
|
|
55
|
-
lines.push(`- … and ${active.length - recent.length} more in
|
|
55
|
+
lines.push(`- … and ${active.length - recent.length} more in llm-wiki/DECISIONS.md`);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
if (ctx.refinements.length > 0) {
|
package/dist/templates/claude.js
CHANGED
|
@@ -33,7 +33,7 @@ export function renderRootIndex(config, stats) {
|
|
|
33
33
|
const stack = config.stack ?? "css";
|
|
34
34
|
const packs = (config.packs ?? []).join(", ") || "(none)";
|
|
35
35
|
const refsSection = renderReferencesSection(config.uiReferences ?? []);
|
|
36
|
-
return
|
|
36
|
+
return `${stats.healthWarning ?? ""}# AI Agent Context — ${name}
|
|
37
37
|
|
|
38
38
|
> This file is auto-generated by [Whale Igniter](https://github.com/whale-igniter).
|
|
39
39
|
> Do not edit by hand. Run \`whale sync\` after changes to regenerate.
|
|
@@ -53,19 +53,27 @@ the design system, conventions and decisions without re-explaining them every se
|
|
|
53
53
|
|
|
54
54
|
Whale stores structured context in two locations:
|
|
55
55
|
|
|
56
|
-
1. **\`intelligence/\`** — source of truth
|
|
57
|
-
- \`intelligence/refinements
|
|
58
|
-
- \`intelligence/decisions
|
|
56
|
+
1. **\`intelligence/\`** — source of truth. Prefer these for precise data:
|
|
57
|
+
- \`intelligence/refinements/\` — approved validator overrides as Markdown
|
|
58
|
+
- \`intelligence/decisions/\` — architectural and product decisions as ADRs
|
|
59
59
|
- \`intelligence/components.json\` — component catalog
|
|
60
60
|
|
|
61
|
-
2. **\`llm-wiki/\`** — human + AI readable markdown
|
|
61
|
+
2. **\`llm-wiki/\`** — compact human + AI readable markdown indexes:
|
|
62
62
|
- \`llm-wiki/FOUNDATIONS.md\` — design tokens and grid rules
|
|
63
63
|
- \`llm-wiki/CONVENTIONS.md\` — coding and design conventions
|
|
64
64
|
- \`llm-wiki/DECISIONS.md\` — decision log in narrative form
|
|
65
65
|
- \`llm-wiki/COMPONENTS.md\` — component catalog
|
|
66
66
|
- \`llm-wiki/WORKFLOWS.md\` — how the team uses Whale day to day
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
## Current status
|
|
69
|
+
|
|
70
|
+
- Decisions: ${stats.decisionCount}
|
|
71
|
+
- Active refinements: ${stats.refinementCount}
|
|
72
|
+
- Components: ${stats.componentCount}
|
|
73
|
+
|
|
74
|
+
### Validation
|
|
75
|
+
|
|
76
|
+
${stats.validationSummary}
|
|
69
77
|
|
|
70
78
|
${refsSection}## How to work here
|
|
71
79
|
|
|
@@ -28,7 +28,8 @@ export function renderConventions(config, refinements) {
|
|
|
28
28
|
if (r.scope?.file)
|
|
29
29
|
scopeParts.push(`file=\`${r.scope.file}\``);
|
|
30
30
|
const scope = scopeParts.length ? ` _(${scopeParts.join(", ")})_` : "";
|
|
31
|
-
|
|
31
|
+
const ref = r.sourcePath ? `[${r.id}](../${r.sourcePath})` : r.id;
|
|
32
|
+
lines.push(`- **${ref}** · ${r.timestamp.slice(0, 10)} — ${r.note}${scope}`);
|
|
32
33
|
}
|
|
33
34
|
lines.push("");
|
|
34
35
|
}
|
|
@@ -36,7 +37,8 @@ export function renderConventions(config, refinements) {
|
|
|
36
37
|
lines.push("## Open questions", "");
|
|
37
38
|
lines.push("These notes do not yet have a scope — the validator cannot act on them.", "Review and convert to scoped refinements or decisions.", "");
|
|
38
39
|
for (const r of scopeless) {
|
|
39
|
-
|
|
40
|
+
const ref = r.sourcePath ? `[${r.id}](../${r.sourcePath})` : r.id;
|
|
41
|
+
lines.push(`> ⚠️ Unresolved — **${ref}** · ${r.timestamp.slice(0, 10)}: ${r.note}`);
|
|
40
42
|
}
|
|
41
43
|
lines.push("");
|
|
42
44
|
}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
const
|
|
2
|
-
function
|
|
3
|
-
|
|
1
|
+
const MAX_SUMMARY = 120;
|
|
2
|
+
function summarize(d) {
|
|
3
|
+
const text = (d.context ?? d.decision).replace(/\s+/g, " ").trim();
|
|
4
|
+
const oneLine = text.length > MAX_SUMMARY ? text.slice(0, MAX_SUMMARY) + "…" : text;
|
|
5
|
+
return oneLine.replace(/\|/g, "\\|");
|
|
6
|
+
}
|
|
7
|
+
function linkFor(d) {
|
|
8
|
+
// Wiki lives in llm-wiki/; decisions live in intelligence/decisions/.
|
|
9
|
+
if (!d.sourcePath)
|
|
10
|
+
return d.title;
|
|
11
|
+
return `[${d.title}](../${d.sourcePath})`;
|
|
4
12
|
}
|
|
5
13
|
export function renderDecisions(decisions) {
|
|
6
14
|
const lines = [
|
|
7
15
|
"# Decision Log",
|
|
8
16
|
"",
|
|
9
|
-
"
|
|
17
|
+
"Compact index of architectural, product and tooling decisions, most recent first.",
|
|
18
|
+
"Open the linked file for full Context / Decision / Consequences.",
|
|
10
19
|
""
|
|
11
20
|
];
|
|
12
21
|
if (decisions.length === 0) {
|
|
@@ -14,20 +23,12 @@ export function renderDecisions(decisions) {
|
|
|
14
23
|
}
|
|
15
24
|
else {
|
|
16
25
|
const sorted = [...decisions].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
26
|
+
lines.push("| ID | Decision | Category | Status | Summary |");
|
|
27
|
+
lines.push("| --- | --- | --- | --- | --- |");
|
|
17
28
|
for (const d of sorted) {
|
|
18
|
-
lines.push(
|
|
19
|
-
lines.push("");
|
|
20
|
-
lines.push(`_${d.timestamp.slice(0, 10)} · ${d.category} · ${d.status}_`);
|
|
21
|
-
lines.push("");
|
|
22
|
-
if (d.context) {
|
|
23
|
-
lines.push("**Context**", "", truncate(d.context), "");
|
|
24
|
-
}
|
|
25
|
-
lines.push("**Decision**", "", truncate(d.decision));
|
|
26
|
-
if (d.consequences) {
|
|
27
|
-
lines.push("", "**Consequences**", "", truncate(d.consequences));
|
|
28
|
-
}
|
|
29
|
-
lines.push("", "---", "");
|
|
29
|
+
lines.push(`| ${d.id} | ${linkFor(d)} | ${d.category} | ${d.status} | ${summarize(d)} |`);
|
|
30
30
|
}
|
|
31
|
+
lines.push("");
|
|
31
32
|
}
|
|
32
33
|
lines.push("_Generated by Whale Igniter._");
|
|
33
34
|
return lines.join("\n");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export function renderProject(config,
|
|
2
|
-
return
|
|
1
|
+
export function renderProject(config, validationSummary, healthWarning, refinementCount, decisionCount) {
|
|
2
|
+
return `${healthWarning ?? ""}# Project Context
|
|
3
3
|
|
|
4
4
|
- **Project name:** ${config.projectName ?? "(unset)"}
|
|
5
5
|
- **Project type:** ${config.projectType ?? "unspecified"}
|
|
@@ -10,11 +10,13 @@ export function renderProject(config, errors, warnings, refinementCount, decisio
|
|
|
10
10
|
|
|
11
11
|
## Current status
|
|
12
12
|
|
|
13
|
-
- Validation errors: ${errors}
|
|
14
|
-
- Validation warnings: ${warnings}
|
|
15
13
|
- Decisions logged: ${decisionCount}
|
|
16
14
|
- Active refinements: ${refinementCount}
|
|
17
15
|
|
|
16
|
+
## Validation
|
|
17
|
+
|
|
18
|
+
${validationSummary}
|
|
19
|
+
|
|
18
20
|
_Generated by Whale Igniter._
|
|
19
21
|
`;
|
|
20
22
|
}
|
package/dist/utils/decisions.js
CHANGED
|
@@ -1,9 +1,60 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
|
-
import
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
const DIR = "intelligence/decisions";
|
|
5
|
+
const LEGACY_STORE = "intelligence/decisions.json";
|
|
6
|
+
function slugify(title) {
|
|
7
|
+
return title
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
10
|
+
.replace(/^-+|-+$/g, "")
|
|
11
|
+
.slice(0, 60);
|
|
12
|
+
}
|
|
13
|
+
function section(body, heading) {
|
|
14
|
+
const re = new RegExp(`^##\\s+${heading}\\s*$([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, "im");
|
|
15
|
+
const match = body.match(re);
|
|
16
|
+
const text = match?.[1]?.trim();
|
|
17
|
+
return text && text.length > 0 ? text : undefined;
|
|
18
|
+
}
|
|
19
|
+
function parseFile(raw) {
|
|
20
|
+
const { data, content } = matter(raw);
|
|
21
|
+
const decision = section(content, "Decision");
|
|
22
|
+
if (!data.id || !data.title || !decision)
|
|
23
|
+
return null;
|
|
24
|
+
return {
|
|
25
|
+
id: String(data.id),
|
|
26
|
+
timestamp: String(data.createdAt ?? data.timestamp ?? ""),
|
|
27
|
+
title: String(data.title),
|
|
28
|
+
category: (data.category ?? "architecture"),
|
|
29
|
+
context: section(content, "Context"),
|
|
30
|
+
decision,
|
|
31
|
+
consequences: section(content, "Consequences"),
|
|
32
|
+
status: (data.status ?? "active"),
|
|
33
|
+
supersedes: data.supersedes ? String(data.supersedes) : undefined
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function serialize(d) {
|
|
37
|
+
const fm = matter.stringify([
|
|
38
|
+
"## Context",
|
|
39
|
+
d.context ?? "",
|
|
40
|
+
"",
|
|
41
|
+
"## Decision",
|
|
42
|
+
d.decision,
|
|
43
|
+
"",
|
|
44
|
+
"## Consequences",
|
|
45
|
+
d.consequences ?? ""
|
|
46
|
+
].join("\n"), {
|
|
47
|
+
id: d.id,
|
|
48
|
+
title: d.title,
|
|
49
|
+
category: d.category,
|
|
50
|
+
status: d.status,
|
|
51
|
+
createdAt: d.timestamp,
|
|
52
|
+
supersedes: d.supersedes ?? null
|
|
53
|
+
});
|
|
54
|
+
return fm;
|
|
55
|
+
}
|
|
56
|
+
async function loadLegacy(target) {
|
|
57
|
+
const file = path.join(target, LEGACY_STORE);
|
|
7
58
|
if (!(await fs.pathExists(file)))
|
|
8
59
|
return [];
|
|
9
60
|
try {
|
|
@@ -13,20 +64,49 @@ export async function loadDecisions(target) {
|
|
|
13
64
|
return [];
|
|
14
65
|
}
|
|
15
66
|
}
|
|
16
|
-
export async function
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
await fs.
|
|
67
|
+
export async function loadDecisions(target) {
|
|
68
|
+
const dir = path.join(target, DIR);
|
|
69
|
+
const fromFiles = [];
|
|
70
|
+
if (await fs.pathExists(dir)) {
|
|
71
|
+
const entries = (await fs.readdir(dir)).filter((f) => f.endsWith(".md")).sort();
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
const raw = await fs.readFile(path.join(dir, entry), "utf8");
|
|
74
|
+
const parsed = parseFile(raw);
|
|
75
|
+
if (parsed) {
|
|
76
|
+
parsed.sourcePath = `${DIR}/${entry}`;
|
|
77
|
+
fromFiles.push(parsed);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Dual read: surface legacy JSON entries that haven't been migrated yet.
|
|
82
|
+
const legacy = await loadLegacy(target);
|
|
83
|
+
const seen = new Set(fromFiles.map((d) => d.id));
|
|
84
|
+
for (const d of legacy) {
|
|
85
|
+
if (!seen.has(d.id))
|
|
86
|
+
fromFiles.push(d);
|
|
87
|
+
}
|
|
88
|
+
return fromFiles;
|
|
89
|
+
}
|
|
90
|
+
function nextId(existing) {
|
|
91
|
+
let max = 0;
|
|
92
|
+
for (const d of existing) {
|
|
93
|
+
const m = /^ADR-(\d+)$/.exec(d.id);
|
|
94
|
+
if (m)
|
|
95
|
+
max = Math.max(max, Number(m[1]));
|
|
96
|
+
}
|
|
97
|
+
return `ADR-${String(max + 1).padStart(4, "0")}`;
|
|
20
98
|
}
|
|
21
99
|
export async function appendDecision(target, input) {
|
|
22
|
-
const
|
|
100
|
+
const existing = await loadDecisions(target);
|
|
23
101
|
const decision = {
|
|
24
|
-
id:
|
|
25
|
-
timestamp: new Date().toISOString(),
|
|
102
|
+
id: nextId(existing),
|
|
103
|
+
timestamp: new Date().toISOString().slice(0, 10),
|
|
26
104
|
status: "active",
|
|
27
105
|
...input
|
|
28
106
|
};
|
|
29
|
-
|
|
30
|
-
await
|
|
107
|
+
const dir = path.join(target, DIR);
|
|
108
|
+
await fs.ensureDir(dir);
|
|
109
|
+
const file = path.join(dir, `${decision.id}-${slugify(decision.title)}.md`);
|
|
110
|
+
await fs.writeFile(file, serialize(decision));
|
|
31
111
|
return decision;
|
|
32
112
|
}
|
|
@@ -1,9 +1,49 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
const DIR = "intelligence/refinements";
|
|
5
|
+
const LEGACY_STORE = "intelligence/refinements.json";
|
|
6
|
+
function slugify(text) {
|
|
7
|
+
return text
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
10
|
+
.replace(/^-+|-+$/g, "")
|
|
11
|
+
.slice(0, 60);
|
|
12
|
+
}
|
|
13
|
+
function parseFile(raw) {
|
|
14
|
+
const { data, content } = matter(raw);
|
|
15
|
+
const note = content.trim();
|
|
16
|
+
if (!data.id || !note)
|
|
17
|
+
return null;
|
|
18
|
+
const scope = {};
|
|
19
|
+
if (data.issueType)
|
|
20
|
+
scope.issueType = String(data.issueType);
|
|
21
|
+
if (data.selector)
|
|
22
|
+
scope.selector = String(data.selector);
|
|
23
|
+
if (data.file)
|
|
24
|
+
scope.file = String(data.file);
|
|
25
|
+
return {
|
|
26
|
+
id: String(data.id),
|
|
27
|
+
timestamp: String(data.createdAt ?? data.timestamp ?? ""),
|
|
28
|
+
note,
|
|
29
|
+
scope: Object.keys(scope).length > 0 ? scope : undefined
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function serialize(r) {
|
|
33
|
+
const fm = {
|
|
34
|
+
id: r.id,
|
|
35
|
+
createdAt: r.timestamp
|
|
36
|
+
};
|
|
37
|
+
if (r.scope?.issueType)
|
|
38
|
+
fm.issueType = r.scope.issueType;
|
|
39
|
+
if (r.scope?.selector)
|
|
40
|
+
fm.selector = r.scope.selector;
|
|
41
|
+
if (r.scope?.file)
|
|
42
|
+
fm.file = r.scope.file;
|
|
43
|
+
return matter.stringify(r.note, fm);
|
|
44
|
+
}
|
|
45
|
+
async function loadLegacy(target) {
|
|
46
|
+
const file = path.join(target, LEGACY_STORE);
|
|
7
47
|
if (!(await fs.pathExists(file)))
|
|
8
48
|
return [];
|
|
9
49
|
try {
|
|
@@ -13,16 +53,49 @@ export async function loadRefinements(target) {
|
|
|
13
53
|
return [];
|
|
14
54
|
}
|
|
15
55
|
}
|
|
16
|
-
export async function
|
|
17
|
-
const dir = path.join(target,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
56
|
+
export async function loadRefinements(target) {
|
|
57
|
+
const dir = path.join(target, DIR);
|
|
58
|
+
const fromFiles = [];
|
|
59
|
+
if (await fs.pathExists(dir)) {
|
|
60
|
+
const entries = (await fs.readdir(dir)).filter((f) => f.endsWith(".md")).sort();
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const raw = await fs.readFile(path.join(dir, entry), "utf8");
|
|
63
|
+
const parsed = parseFile(raw);
|
|
64
|
+
if (parsed)
|
|
65
|
+
fromFiles.push({ ...parsed, sourcePath: `${DIR}/${entry}` });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Dual read: surface legacy JSON entries that haven't been migrated yet.
|
|
69
|
+
const legacy = await loadLegacy(target);
|
|
70
|
+
const seen = new Set(fromFiles.map((r) => r.id));
|
|
71
|
+
for (const r of legacy) {
|
|
72
|
+
if (!seen.has(r.id))
|
|
73
|
+
fromFiles.push(r);
|
|
74
|
+
}
|
|
75
|
+
return fromFiles;
|
|
21
76
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
77
|
+
function nextId(existing) {
|
|
78
|
+
let max = 0;
|
|
79
|
+
for (const r of existing) {
|
|
80
|
+
const m = /^REF-(\d+)$/.exec(r.id);
|
|
81
|
+
if (m)
|
|
82
|
+
max = Math.max(max, Number(m[1]));
|
|
83
|
+
}
|
|
84
|
+
return `REF-${String(max + 1).padStart(4, "0")}`;
|
|
85
|
+
}
|
|
86
|
+
export async function appendRefinement(target, input) {
|
|
87
|
+
const existing = await loadRefinements(target);
|
|
88
|
+
const refinement = {
|
|
89
|
+
id: nextId(existing),
|
|
90
|
+
timestamp: new Date().toISOString().slice(0, 10),
|
|
91
|
+
note: input.note,
|
|
92
|
+
scope: input.scope
|
|
93
|
+
};
|
|
94
|
+
const dir = path.join(target, DIR);
|
|
95
|
+
await fs.ensureDir(dir);
|
|
96
|
+
const file = path.join(dir, `${refinement.id}-${slugify(refinement.note)}.md`);
|
|
97
|
+
await fs.writeFile(file, serialize(refinement));
|
|
98
|
+
return refinement;
|
|
26
99
|
}
|
|
27
100
|
// Lightweight natural-language extractor. Recognises hints like:
|
|
28
101
|
// "Button radius should remain 2px for controls" -> issueType: radius-consistency
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "fs-extra";
|
|
4
|
+
import { ui } from "../ui/index.js";
|
|
5
|
+
function isSamePath(a, b) {
|
|
6
|
+
return path.resolve(a) === path.resolve(b);
|
|
7
|
+
}
|
|
8
|
+
function isAncestorOf(ancestor, child) {
|
|
9
|
+
const relative = path.relative(ancestor, child);
|
|
10
|
+
return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
11
|
+
}
|
|
12
|
+
function projectMarkerMessage(target, command) {
|
|
13
|
+
const commandHint = command ? `whale ${command}` : "this command";
|
|
14
|
+
return [
|
|
15
|
+
`This does not look like a project directory: ${target}`,
|
|
16
|
+
"I could not find a .git directory or package.json there.",
|
|
17
|
+
`Run ${ui.code(commandHint)} from inside your project, pass an explicit project path, or add ${ui.code("--force")} if this really is the target you want.`
|
|
18
|
+
].join("\n");
|
|
19
|
+
}
|
|
20
|
+
function protectedDirectoryMessage(target, command) {
|
|
21
|
+
const commandHint = command ? `whale ${command} path/to/project` : "pass an explicit project path";
|
|
22
|
+
return [
|
|
23
|
+
`I will not run in ${target}.`,
|
|
24
|
+
"That directory is your home folder, the filesystem root, or a parent of your home folder.",
|
|
25
|
+
`Please cd into a project directory first, or run ${ui.code(commandHint)}.`
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
28
|
+
export async function checkTargetDirectory(target, options = {}) {
|
|
29
|
+
const resolvedTarget = path.resolve(target);
|
|
30
|
+
const home = path.resolve(os.homedir());
|
|
31
|
+
const root = path.parse(resolvedTarget).root;
|
|
32
|
+
if (isSamePath(resolvedTarget, home) ||
|
|
33
|
+
isSamePath(resolvedTarget, root) ||
|
|
34
|
+
isAncestorOf(resolvedTarget, home)) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
target: resolvedTarget,
|
|
38
|
+
reason: "protected-directory",
|
|
39
|
+
message: protectedDirectoryMessage(resolvedTarget, options.command)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const hasGit = await fs.pathExists(path.join(resolvedTarget, ".git"));
|
|
43
|
+
const hasPackageJson = await fs.pathExists(path.join(resolvedTarget, "package.json"));
|
|
44
|
+
if (!hasGit && !hasPackageJson) {
|
|
45
|
+
const message = projectMarkerMessage(resolvedTarget, options.command);
|
|
46
|
+
if (!options.force) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
target: resolvedTarget,
|
|
50
|
+
reason: "missing-project-marker",
|
|
51
|
+
message
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
target: resolvedTarget,
|
|
57
|
+
warning: message
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, target: resolvedTarget };
|
|
61
|
+
}
|
|
62
|
+
export async function guardTargetDirectory(target, options = {}) {
|
|
63
|
+
const result = await checkTargetDirectory(target, options);
|
|
64
|
+
if (!result.ok) {
|
|
65
|
+
console.log();
|
|
66
|
+
console.log(ui.fail(result.message));
|
|
67
|
+
console.log();
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (result.warning) {
|
|
72
|
+
console.log();
|
|
73
|
+
console.log(ui.warn(result.warning));
|
|
74
|
+
console.log();
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|