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 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.json # architectural decisions and their rationale
38
- │ └── refinements.json # approved exceptions "this is intentional"
37
+ │ ├── decisions/ # one Markdown ADR per decision
38
+ │ └── refinements/ # one Markdown file per approved exception
39
39
  └── llm-wiki/ # generated markdown, one file per concern
40
40
  ```
41
41
 
42
- `intelligence/*.json` is the source of truth. Markdown is generated from it. When you run `whale sync`, everything regenerates — CLAUDE.md, the wiki, the context your agent reads on the next conversation.
42
+ `whale.config.json` and `intelligence/` are the source of truth. Decisions and refinements use individual Markdown files with frontmatter; component metadata remains structured JSON. When you run `whale sync`, everything regenerates — CLAUDE.md, the wiki, and the context your agent reads on the next conversation.
43
43
 
44
44
  The agent doesn't guess anymore. It reads.
45
45
 
@@ -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 files exist ------------------------
140
- for (const file of ["refinements.json", "decisions.json", "components.json"]) {
141
- const p = path.join(target, "intelligence", file);
142
- if (!(await fs.pathExists(p)))
143
- await fs.writeJson(p, [], { spaces: 2 });
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);
@@ -1,21 +1,22 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
+ import matter from "gray-matter";
2
3
  import { resolveTarget } from "../utils/paths.js";
3
- import { loadDecisions } from "../utils/decisions.js";
4
4
  import { loadComponents } from "../utils/components.js";
5
- import { loadRefinements } from "../utils/refinements.js";
6
5
  import { ui } from "../ui/index.js";
6
+ const DECISIONS_DIR = "intelligence/decisions";
7
+ const REFINEMENTS_DIR = "intelligence/refinements";
7
8
  const INTELLIGENCE_FILES = [
8
- "intelligence/decisions.json",
9
+ DECISIONS_DIR,
10
+ REFINEMENTS_DIR,
9
11
  "intelligence/components.json",
10
- "intelligence/refinements.json",
11
12
  "whale.config.json"
12
13
  ];
14
+ function git(target, args) {
15
+ return execFileSync("git", args, { cwd: target, encoding: "utf8" });
16
+ }
13
17
  function gitDiffFiles(target, since) {
14
18
  try {
15
- const result = execSync(`git diff --name-only ${since} HEAD -- ${INTELLIGENCE_FILES.join(" ")}`, {
16
- cwd: target,
17
- encoding: "utf8"
18
- });
19
+ const result = git(target, ["diff", "--name-only", since, "HEAD", "--", ...INTELLIGENCE_FILES]);
19
20
  return result.trim().split("\n").filter(Boolean);
20
21
  }
21
22
  catch {
@@ -24,13 +25,49 @@ function gitDiffFiles(target, since) {
24
25
  }
25
26
  function gitFileAtRef(target, ref, file) {
26
27
  try {
27
- const raw = execSync(`git show ${ref}:${file}`, { cwd: target, encoding: "utf8" });
28
+ const raw = git(target, ["show", `${ref}:${file}`]);
28
29
  return JSON.parse(raw);
29
30
  }
30
31
  catch {
31
32
  return [];
32
33
  }
33
34
  }
35
+ function mdLabelAtRef(target, ref, file, label) {
36
+ try {
37
+ const raw = git(target, ["show", `${ref}:${file}`]);
38
+ return label(raw) || file;
39
+ }
40
+ catch {
41
+ return file;
42
+ }
43
+ }
44
+ const decisionLabel = (raw) => {
45
+ const title = matter(raw).data?.title;
46
+ return title ? String(title) : "";
47
+ };
48
+ const refinementLabel = (raw) => matter(raw).content.trim().split("\n")[0] ?? "";
49
+ function gitDirChanges(target, since, dir, label) {
50
+ const result = { added: [], removed: [], modified: [] };
51
+ let raw = "";
52
+ try {
53
+ raw = git(target, ["diff", "--name-status", since, "HEAD", "--", dir]);
54
+ }
55
+ catch {
56
+ return result;
57
+ }
58
+ for (const line of raw.trim().split("\n").filter(Boolean)) {
59
+ const [status, file] = line.split("\t");
60
+ if (!file || !file.endsWith(".md"))
61
+ continue;
62
+ if (status.startsWith("A"))
63
+ result.added.push(mdLabelAtRef(target, "HEAD", file, label));
64
+ else if (status.startsWith("D"))
65
+ result.removed.push(mdLabelAtRef(target, since, file, label));
66
+ else if (status.startsWith("M") || status.startsWith("R"))
67
+ result.modified.push(mdLabelAtRef(target, "HEAD", file, label));
68
+ }
69
+ return result;
70
+ }
34
71
  export async function changesCommand(opts) {
35
72
  const target = resolveTarget(opts.target);
36
73
  const since = opts.since ?? "HEAD~1";
@@ -49,21 +86,8 @@ export async function changesCommand(opts) {
49
86
  if (changedFiles.includes("whale.config.json")) {
50
87
  summary.configChanged = true;
51
88
  }
52
- if (changedFiles.includes("intelligence/decisions.json")) {
53
- const before = gitFileAtRef(target, since, "intelligence/decisions.json");
54
- const after = await loadDecisions(target);
55
- const beforeIds = new Map(before.map((d) => [d.id, d.title]));
56
- const afterIds = new Map(after.map((d) => [d.id, d.title]));
57
- for (const [id, title] of afterIds) {
58
- if (!beforeIds.has(id))
59
- summary.decisions.added.push(title);
60
- }
61
- for (const [id, title] of beforeIds) {
62
- if (!afterIds.has(id))
63
- summary.decisions.removed.push(title);
64
- else if (afterIds.get(id) !== title)
65
- summary.decisions.modified.push(title);
66
- }
89
+ if (changedFiles.some((f) => f.startsWith(`${DECISIONS_DIR}/`))) {
90
+ summary.decisions = gitDirChanges(target, since, DECISIONS_DIR, decisionLabel);
67
91
  }
68
92
  if (changedFiles.includes("intelligence/components.json")) {
69
93
  const before = gitFileAtRef(target, since, "intelligence/components.json");
@@ -79,20 +103,10 @@ export async function changesCommand(opts) {
79
103
  summary.components.removed.push(name);
80
104
  }
81
105
  }
82
- if (changedFiles.includes("intelligence/refinements.json")) {
83
- const before = gitFileAtRef(target, since, "intelligence/refinements.json");
84
- const after = await loadRefinements(target);
85
- const beforeIds = new Set(before.map((r) => r.id));
86
- const afterIds = new Set(after.map((r) => r.id));
87
- const beforeNotes = new Map(before.map((r) => [r.id, r.note]));
88
- for (const r of after) {
89
- if (!beforeIds.has(r.id))
90
- summary.refinements.added.push(r.note);
91
- }
92
- for (const [id, note] of beforeNotes) {
93
- if (!afterIds.has(id))
94
- summary.refinements.removed.push(note);
95
- }
106
+ if (changedFiles.some((f) => f.startsWith(`${REFINEMENTS_DIR}/`))) {
107
+ const changes = gitDirChanges(target, since, REFINEMENTS_DIR, refinementLabel);
108
+ summary.refinements.added = changes.added;
109
+ summary.refinements.removed = changes.removed;
96
110
  }
97
111
  // ---- Render ----------------------------------------------------------------
98
112
  if (summary.configChanged) {
@@ -86,7 +86,7 @@ export async function decisionCommand(options = {}) {
86
86
  consequences: consequences?.trim() || undefined
87
87
  });
88
88
  console.log();
89
- console.log(ui.ok(`Decision recorded ${ui.muted("— " + created.id.slice(0, 8))}`));
89
+ console.log(ui.ok(`Decision recorded ${ui.muted("— " + created.id)}`));
90
90
  console.log(` ${ui.accent(created.title)}`);
91
91
  console.log(` ${ui.kv("category", created.category, { keyWidth: 8 })}`);
92
92
  if (options.sync !== false) {
@@ -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
  });
@@ -1,4 +1,3 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { resolveTarget } from "../utils/paths.js";
3
2
  import { appendRefinement, inferScope } from "../utils/refinements.js";
4
3
  import { generateWiki } from "../generators/wikiGenerator.js";
@@ -6,13 +5,7 @@ import { ui } from "../ui/index.js";
6
5
  export async function refineCommand(note, options = {}) {
7
6
  const target = resolveTarget();
8
7
  const scope = inferScope(note);
9
- const refinement = {
10
- id: randomUUID(),
11
- timestamp: new Date().toISOString(),
12
- note,
13
- scope
14
- };
15
- await appendRefinement(target, refinement);
8
+ await appendRefinement(target, { note, scope });
16
9
  console.log(ui.ok("Refinement recorded"));
17
10
  if (scope) {
18
11
  const parts = [];
@@ -14,7 +14,6 @@ import { copyToClipboard } from "../selene/clipboard.js";
14
14
  import { resolveProvider, describeProviderState } from "../selene/providers.js";
15
15
  import { callProvider, estimateTokens, estimateCostUsd } from "../selene/apiClient.js";
16
16
  import { readCache, writeCache, clearCache } from "../selene/cache.js";
17
- import { randomUUID } from "node:crypto";
18
17
  import { ui } from "../ui/index.js";
19
18
  const PENDING_DIR = ".whale/selene";
20
19
  // ---------------------------------------------------------------------------
@@ -421,8 +420,6 @@ async function applySuggest(target, raw, options) {
421
420
  }
422
421
  else if (s.kind === "refinement") {
423
422
  await appendRefinement(target, {
424
- id: randomUUID(),
425
- timestamp: new Date().toISOString(),
426
423
  note: s.proposed_text
427
424
  });
428
425
  console.log(ui.success(" ✓ Recorded as refinement."));
@@ -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
- * intelligence JSON files (which are the source of truth).
13
+ * project configuration and intelligence stores (the source of truth).
14
14
  *
15
15
  * Run this:
16
- * - after manually editing intelligence/*.json
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
  */
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Why this exists. Whale's value depends on CLAUDE.md being current.
5
5
  * Every write command (refine, decision, component add, adopt review) auto-syncs,
6
- * but humans also edit `intelligence/*.json` directly, change `whale.config.json`,
6
+ * but humans also edit `intelligence/` directly, change `whale.config.json`,
7
7
  * or commit catalog files from another tool. Without a watcher the gap between
8
8
  * "what's true" and "what the AI sees" silently widens. Watcher closes that gap.
9
9
  *
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/*.json.")
51
+ .description("Regenerate CLAUDE.md and the LLM Wiki from project intelligence.")
52
52
  .action(syncCommand);
53
53
  program
54
54
  .command("remember [target]")
@@ -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/*.json. Call " +
372
- "this if you've manually edited the JSON stores and want the wiki to catch " +
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.json if the model needs it.
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 intelligence/decisions.json`);
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) {
@@ -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 (JSON). Prefer these for precise data:
57
- - \`intelligence/refinements.json\` — approved validator overrides
58
- - \`intelligence/decisions.json\` — architectural and product 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 rendered from the JSON:
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
- lines.push(`- **${r.timestamp.slice(0, 10)}** ${r.note}${scope}`);
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
- lines.push(`> ⚠️ Unresolved — **${r.timestamp.slice(0, 10)}**: ${r.note}`);
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 MAX_DETAIL = 500;
2
- function truncate(text) {
3
- return text.length > MAX_DETAIL ? text.slice(0, MAX_DETAIL) + "…" : text;
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
- "Architectural, product and tooling decisions, most recent first.",
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(`## ${d.title}`);
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,9 +1,60 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
- import { randomUUID } from "node:crypto";
4
- const STORE = "intelligence/decisions.json";
5
- export async function loadDecisions(target) {
6
- const file = path.join(target, STORE);
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 saveDecisions(target, decisions) {
17
- const file = path.join(target, STORE);
18
- await fs.ensureDir(path.dirname(file));
19
- await fs.writeJson(file, decisions, { spaces: 2 });
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 decisions = await loadDecisions(target);
100
+ const existing = await loadDecisions(target);
23
101
  const decision = {
24
- id: randomUUID(),
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
- decisions.push(decision);
30
- await saveDecisions(target, decisions);
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
- const STORE_FILENAME = "refinements.json";
4
- const STORE_DIR = "intelligence";
5
- export async function loadRefinements(target) {
6
- const file = path.join(target, STORE_DIR, STORE_FILENAME);
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 saveRefinements(target, refinements) {
17
- const dir = path.join(target, STORE_DIR);
18
- await fs.ensureDir(dir);
19
- const file = path.join(dir, STORE_FILENAME);
20
- await fs.writeJson(file, refinements, { spaces: 2 });
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
- export async function appendRefinement(target, refinement) {
23
- const current = await loadRefinements(target);
24
- current.push(refinement);
25
- await saveRefinements(target, current);
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";
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.1",
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": "^10.4.5",
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",