whale-igniter 1.5.1 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +3 -3
- package/dist/commands/adopt.js +7 -5
- package/dist/commands/changes.js +53 -39
- package/dist/commands/decision.js +1 -1
- package/dist/commands/init.js +3 -2
- package/dist/commands/insights.js +0 -2
- package/dist/commands/refine.js +1 -8
- package/dist/commands/selene.js +0 -3
- package/dist/commands/sync.js +2 -2
- package/dist/commands/watch.js +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp/server.js +3 -9
- package/dist/selene/promptBuilder.js +2 -2
- package/dist/templates/claude.js +4 -4
- package/dist/templates/conventions.js +4 -2
- package/dist/templates/decisions.js +17 -16
- package/dist/utils/decisions.js +93 -13
- package/dist/utils/refinements.js +86 -13
- package/dist/version.js +1 -1
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Whale Igniter are documented here.
|
|
4
4
|
|
|
5
|
+
## 1.5.2 - 2026-06-10
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- `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.
|
|
11
|
+
- Updated `glob` to its supported current major and refreshed the transitive MCP dependency tree; production audit reports zero vulnerabilities.
|
|
12
|
+
|
|
13
|
+
### Security
|
|
14
|
+
|
|
15
|
+
- `whale changes` now invokes git via `execFileSync` with argument arrays instead of shell-interpolated strings, removing a command-injection vector through the `--since` flag.
|
|
16
|
+
|
|
5
17
|
## 1.5.1 - 2026-06-03
|
|
6
18
|
|
|
7
19
|
### 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
|
@@ -136,11 +136,13 @@ export async function adoptCommand(targetArg, options = {}) {
|
|
|
136
136
|
console.log(ui.note("Updated stack to 'tailwind' in whale.config.json"));
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
|
-
// ---- Step 6: ensure other intelligence
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
// ---- Step 6: ensure other intelligence stores exist -----------------------
|
|
140
|
+
const intelligenceDir = path.join(target, "intelligence");
|
|
141
|
+
await fs.ensureDir(path.join(intelligenceDir, "decisions"));
|
|
142
|
+
await fs.ensureDir(path.join(intelligenceDir, "refinements"));
|
|
143
|
+
const componentsPath = path.join(intelligenceDir, "components.json");
|
|
144
|
+
if (!(await fs.pathExists(componentsPath))) {
|
|
145
|
+
await fs.writeJson(componentsPath, [], { spaces: 2 });
|
|
144
146
|
}
|
|
145
147
|
// ---- Step 7: optional Selene enrichment -----------------------------------
|
|
146
148
|
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) {
|
package/dist/commands/init.js
CHANGED
|
@@ -27,8 +27,6 @@ async function ensureIntelligenceFiles(target) {
|
|
|
27
27
|
const intelligenceDir = path.join(target, "intelligence");
|
|
28
28
|
await fs.ensureDir(intelligenceDir);
|
|
29
29
|
const seeds = {
|
|
30
|
-
"refinements.json": [],
|
|
31
|
-
"decisions.json": [],
|
|
32
30
|
"components.json": []
|
|
33
31
|
};
|
|
34
32
|
for (const [name, content] of Object.entries(seeds)) {
|
|
@@ -37,6 +35,9 @@ async function ensureIntelligenceFiles(target) {
|
|
|
37
35
|
await fs.writeJson(file, content, { spaces: 2 });
|
|
38
36
|
}
|
|
39
37
|
}
|
|
38
|
+
// Decisions and refinements are stored as individual Markdown files.
|
|
39
|
+
await fs.ensureDir(path.join(intelligenceDir, "decisions"));
|
|
40
|
+
await fs.ensureDir(path.join(intelligenceDir, "refinements"));
|
|
40
41
|
}
|
|
41
42
|
export async function initCommand(projectName = "whale-project", options = {}) {
|
|
42
43
|
const target = path.resolve(process.cwd(), projectName);
|
|
@@ -168,8 +168,6 @@ export async function insightsDriftReviewCommand(category, opts) {
|
|
|
168
168
|
break;
|
|
169
169
|
if (action === "refine") {
|
|
170
170
|
await appendRefinement(target, {
|
|
171
|
-
id: randomUUID(),
|
|
172
|
-
timestamp: new Date().toISOString(),
|
|
173
171
|
note: ins.title,
|
|
174
172
|
scope: { issueType: cat }
|
|
175
173
|
});
|
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
|
@@ -10,10 +10,10 @@ import { getAiAvailability } from "../utils/aiAvailability.js";
|
|
|
10
10
|
import { ui } from "../ui/index.js";
|
|
11
11
|
/**
|
|
12
12
|
* `whale sync` regenerates everything an AI agent reads, from the
|
|
13
|
-
*
|
|
13
|
+
* project configuration and intelligence stores (the source of truth).
|
|
14
14
|
*
|
|
15
15
|
* Run this:
|
|
16
|
-
* - after manually editing intelligence
|
|
16
|
+
* - after manually editing whale.config.json or intelligence stores
|
|
17
17
|
* - before handing the project to an AI agent
|
|
18
18
|
* - in CI to ensure CLAUDE.md stays in sync
|
|
19
19
|
*/
|
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/index.js
CHANGED
|
@@ -48,7 +48,7 @@ program
|
|
|
48
48
|
});
|
|
49
49
|
program
|
|
50
50
|
.command("sync [target]")
|
|
51
|
-
.description("Regenerate CLAUDE.md and the LLM Wiki from intelligence
|
|
51
|
+
.description("Regenerate CLAUDE.md and the LLM Wiki from project intelligence.")
|
|
52
52
|
.action(syncCommand);
|
|
53
53
|
program
|
|
54
54
|
.command("remember [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
|
@@ -53,12 +53,12 @@ the design system, conventions and decisions without re-explaining them every se
|
|
|
53
53
|
|
|
54
54
|
Whale stores structured context in two locations:
|
|
55
55
|
|
|
56
|
-
1. **\`intelligence/\`** — source of truth
|
|
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
|
|
@@ -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");
|
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
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const PACKAGE_VERSION = "1.5.
|
|
1
|
+
export const PACKAGE_VERSION = "1.5.2";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whale-igniter",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"description": "CLI-first operational intelligence. Bootstraps and adopts AI-readable project context so agents like Claude Code, Codex and Cursor understand your design system from the first commit. Includes an MCP server, a file watcher, deterministic insights, and an opt-in AI bridge (Selene).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -65,7 +65,8 @@
|
|
|
65
65
|
"chalk": "^5.3.0",
|
|
66
66
|
"commander": "^12.1.0",
|
|
67
67
|
"fs-extra": "^11.2.0",
|
|
68
|
-
"glob": "^
|
|
68
|
+
"glob": "^13.0.6",
|
|
69
|
+
"gray-matter": "^4.0.3",
|
|
69
70
|
"ora": "^8.1.0",
|
|
70
71
|
"postcss": "^8.4.47",
|
|
71
72
|
"prompts": "^2.4.2",
|