whale-igniter 1.5.3 → 1.6.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/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to Whale Igniter are documented here.
4
4
 
5
+ ## 1.6.0 - 2026-07-14
6
+
7
+ ### Added
8
+
9
+ - Added GitHub Actions CI for builds, tests, production dependency audits, security scanning, package validation, and committed `dist/` freshness.
10
+ - Added npm Trusted Publishing through GitHub Actions OIDC, restricted to version tags in the protected `npm` environment and published with provenance.
11
+ - Added migration coverage for Markdown-backed decisions and refinements, including legacy JSON compatibility, duplicate handling, and malformed legacy stores.
12
+ - Added launch metadata to generated landings: description, Open Graph and Twitter cards, canonical/social image support, and a themed inline favicon.
13
+ - Added configurable contact form actions plus native required, email, and minimum-length validation.
14
+
15
+ ### Changed
16
+
17
+ - Updated the release workflow to Node.js 24 and current GitHub Actions, without long-lived npm publishing tokens or release-build dependency caches.
18
+ - Rebuilt and reconciled committed `dist/` output with `src/`; CI now rejects stale or untracked build artifacts.
19
+ - Removed internal planning documents from the public repository and documented the verified release process.
20
+
21
+ ### Release history note
22
+
23
+ - An older source commit was titled `Release 1.6.0`, but no `v1.6.0` tag, GitHub Release, or npm version was created from it. Version `1.6.0` in this entry is the first real published release with that number.
24
+
25
+ ## 1.5.4 - 2026-07-08
26
+
27
+ ### Security
28
+
29
+ - Added automatic secret redaction before Selene prompts are copied, written, cached, or sent to Anthropic/OpenAI.
30
+ - Added `npm run security:scan` to detect likely committed secrets in source and compiled output.
31
+ - Added the security scan to `prepublishOnly`, after build and before tests, so npm releases fail before publishing if high-confidence secrets are found.
32
+ - Replaced the `gray-matter`/`js-yaml` dependency path with a local frontmatter parser, restoring a zero-vulnerability production audit.
33
+
5
34
  ## 1.5.3 - 2026-07-06
6
35
 
7
36
  ### Added
package/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  <p align="center">
6
6
  <a href="https://www.npmjs.com/package/whale-igniter"><img alt="npm" src="https://img.shields.io/npm/v/whale-igniter?color=%230034D3&labelColor=%23F2ECE1&style=flat-square"></a>
7
+ <a href="https://github.com/luisviol/whale-igniter/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/luisviol/whale-igniter/ci.yml?branch=main&label=CI&style=flat-square"></a>
7
8
  <a href="./LICENSE"><img alt="MIT" src="https://img.shields.io/badge/license-MIT-%230034D3?labelColor=%23F2ECE1&style=flat-square"></a>
8
9
  <img alt="node" src="https://img.shields.io/badge/node-%E2%89%A520-%230034D3?labelColor=%23F2ECE1&style=flat-square">
9
10
  <img alt="local first" src="https://img.shields.io/badge/local--first-agent%20memory-%230034D3?labelColor=%23F2ECE1&style=flat-square">
@@ -224,6 +225,24 @@ Whale Igniter has no telemetry or analytics.
224
225
 
225
226
  ---
226
227
 
228
+ ## Development and releases
229
+
230
+ Start from a clean checkout and use the locked dependency graph:
231
+
232
+ ```bash
233
+ npm ci
234
+ npm run build
235
+ npm test
236
+ npm audit --omit=dev --audit-level=high
237
+ npm run security:scan
238
+ ```
239
+
240
+ `dist/` is versioned because the CLI and MCP smoke tests execute it directly. Every source change that affects compiled output must include the matching `dist/` change. CI rebuilds the package and fails if `dist/` is modified or if the build creates an untracked distribution file.
241
+
242
+ npm releases are published only by `.github/workflows/publish.yml`, triggered from a GitHub Release whose tag matches `v<package.json version>`. The GitHub `npm` environment must provide the `NPM_TOKEN` secret. The workflow installs from `package-lock.json`, audits production dependencies, runs `prepublishOnly`, and publishes with npm provenance. Do not run `npm publish` from a development checkout.
243
+
244
+ ---
245
+
227
246
  ## Credits
228
247
 
229
248
  Brand assets reference JetBrains Mono (SIL Open Font License 1.1) and Special Elite (Apache License 2.0). No font binaries are shipped.
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import matter from "gray-matter";
3
2
  import { resolveTarget } from "../utils/paths.js";
4
3
  import { loadComponents } from "../utils/components.js";
4
+ import { parseFrontmatter } from "../utils/frontmatter.js";
5
5
  import { ui } from "../ui/index.js";
6
6
  const DECISIONS_DIR = "intelligence/decisions";
7
7
  const REFINEMENTS_DIR = "intelligence/refinements";
@@ -42,10 +42,10 @@ function mdLabelAtRef(target, ref, file, label) {
42
42
  }
43
43
  }
44
44
  const decisionLabel = (raw) => {
45
- const title = matter(raw).data?.title;
45
+ const title = parseFrontmatter(raw).data.title;
46
46
  return title ? String(title) : "";
47
47
  };
48
- const refinementLabel = (raw) => matter(raw).content.trim().split("\n")[0] ?? "";
48
+ const refinementLabel = (raw) => parseFrontmatter(raw).content.trim().split("\n")[0] ?? "";
49
49
  function gitDirChanges(target, since, dir, label) {
50
50
  const result = { added: [], removed: [], modified: [] };
51
51
  let raw = "";
@@ -63,7 +63,7 @@ export async function createLandingCommand(options = {}) {
63
63
  await fs.ensureDir(path.dirname(contentAbs));
64
64
  await fs.writeJson(contentAbs, content, { spaces: 2 });
65
65
  }
66
- await fs.writeFile(htmlAbs, renderHtml(content, sections), "utf8");
66
+ await fs.writeFile(htmlAbs, renderHtml(content, sections, theme), "utf8");
67
67
  await fs.writeFile(cssAbs, renderCss(theme, {
68
68
  includeProof: sections.includes("proof"),
69
69
  includeContact: sections.includes("contact")
@@ -300,7 +300,7 @@ function validateLandingContent(value) {
300
300
  if (!isRecord(value))
301
301
  return { ok: false, error: "expected a JSON object" };
302
302
  const input = value;
303
- for (const key of ["brand", "eyebrow", "title", "subtitle"]) {
303
+ for (const key of ["brand", "eyebrow", "title", "subtitle", "description", "canonicalUrl", "socialImage"]) {
304
304
  if (input[key] !== undefined && typeof input[key] !== "string") {
305
305
  return { ok: false, error: `${key} must be a string` };
306
306
  }
@@ -354,6 +354,7 @@ function defaultLandingContent(title, sections) {
354
354
  eyebrow: "Launch-ready landing",
355
355
  title: `${title} turns interest into qualified conversations.`,
356
356
  subtitle: "A focused page with clear benefits, trust signals and a path that is easy to scan.",
357
+ description: `${title} helps visitors understand the offer, evaluate proof, and take the next step.`,
357
358
  cta: {
358
359
  label: "Start a conversation",
359
360
  href: ctaTarget
@@ -382,7 +383,8 @@ function defaultLandingContent(title, sections) {
382
383
  eyebrow: "Contact",
383
384
  title: "Tell us what you want to launch.",
384
385
  body: "Share a few details and we will follow up with next steps.",
385
- submitLabel: "Send message"
386
+ submitLabel: "Send message",
387
+ action: "#"
386
388
  }
387
389
  };
388
390
  }
@@ -392,6 +394,7 @@ function sampleLandingContent() {
392
394
  eyebrow: "Nutrition planning",
393
395
  title: "Meal planning for athletes who train hard.",
394
396
  subtitle: "Build training-aware menus, grocery lists, and coach-ready exports without rebuilding spreadsheets.",
397
+ description: "Plan athlete meals, grocery lists, and coach-ready exports around each training block.",
395
398
  cta: {
396
399
  label: "Request early access",
397
400
  href: "#contact"
@@ -427,11 +430,12 @@ function sampleLandingContent() {
427
430
  function mergeLandingContent(base, next) {
428
431
  return {
429
432
  ...base,
430
- ...definedPick(next, ["brand", "eyebrow", "title", "subtitle"]),
433
+ ...definedPick(next, ["brand", "eyebrow", "title", "subtitle", "description", "canonicalUrl", "socialImage"]),
434
+ description: next.description ?? next.subtitle ?? base.description,
431
435
  cta: { ...base.cta, ...definedPick(next.cta ?? {}, ["label", "href"]) },
432
436
  features: next.features ?? base.features,
433
437
  proof: { ...base.proof, ...definedPick(next.proof ?? {}, ["eyebrow", "title", "quote", "byline"]) },
434
- contact: { ...base.contact, ...definedPick(next.contact ?? {}, ["eyebrow", "title", "body", "submitLabel"]) }
438
+ contact: { ...base.contact, ...definedPick(next.contact ?? {}, ["eyebrow", "title", "body", "submitLabel", "action"]) }
435
439
  };
436
440
  }
437
441
  function definedPick(source, keys) {
@@ -442,7 +446,7 @@ function definedPick(source, keys) {
442
446
  }
443
447
  return out;
444
448
  }
445
- function renderHtml(content, sections) {
449
+ function renderHtml(content, sections, theme) {
446
450
  const header = sections.includes("header") ? `${indent(renderSection("header", content, sections), 4)}\n` : "";
447
451
  const footer = sections.includes("footer") ? `\n${indent(renderSection("footer", content, sections), 4)}` : "";
448
452
  const mainBlocks = sections
@@ -455,6 +459,14 @@ function renderHtml(content, sections) {
455
459
  <meta charset="utf-8" />
456
460
  <meta name="viewport" content="width=device-width, initial-scale=1" />
457
461
  <title>${escapeHtml(content.brand)}</title>
462
+ <meta name="description" content="${escapeAttr(content.description)}" />
463
+ <meta property="og:type" content="website" />
464
+ <meta property="og:title" content="${escapeAttr(content.title)}" />
465
+ <meta property="og:description" content="${escapeAttr(content.description)}" />
466
+ <meta name="twitter:card" content="${content.socialImage ? "summary_large_image" : "summary"}" />
467
+ <meta name="twitter:title" content="${escapeAttr(content.title)}" />
468
+ <meta name="twitter:description" content="${escapeAttr(content.description)}" />
469
+ ${content.canonicalUrl ? ` <link rel="canonical" href="${escapeAttr(content.canonicalUrl)}" />\n <meta property="og:url" content="${escapeAttr(content.canonicalUrl)}" />\n` : ""}${content.socialImage ? ` <meta property="og:image" content="${escapeAttr(content.socialImage)}" />\n <meta name="twitter:image" content="${escapeAttr(content.socialImage)}" />\n` : ""} <link rel="icon" href="${renderFaviconData(theme)}" type="image/svg+xml" />
458
470
  <link rel="stylesheet" href="./styles.css" />
459
471
  </head>
460
472
  <body>
@@ -527,18 +539,19 @@ ${indent(content.features.map(renderFeature).join("\n"), 6)}
527
539
  <h2 id="contact-title">${escapeHtml(content.contact.title)}</h2>
528
540
  <p class="lede">${escapeHtml(content.contact.body)}</p>
529
541
  </div>
530
- <form class="contact-form">
542
+ <form class="contact-form" action="${escapeAttr(content.contact.action)}" method="post">
543
+ <p class="form-help" id="contact-form-help">All fields are required.</p>
531
544
  <label>
532
545
  Name
533
- <input name="name" type="text" autocomplete="name" required />
546
+ <input name="name" type="text" autocomplete="name" placeholder="Ada Lovelace" aria-describedby="contact-form-help" required />
534
547
  </label>
535
548
  <label>
536
549
  Email
537
- <input name="email" type="email" autocomplete="email" required />
550
+ <input name="email" type="email" autocomplete="email" placeholder="ada@example.com" aria-describedby="contact-form-help" required />
538
551
  </label>
539
552
  <label>
540
553
  Message
541
- <textarea name="message" rows="4" required></textarea>
554
+ <textarea name="message" rows="4" minlength="10" placeholder="Tell us what you want to launch." aria-describedby="contact-form-help" required></textarea>
542
555
  </label>
543
556
  <button class="button" type="submit">${escapeHtml(content.contact.submitLabel)}</button>
544
557
  </form>
@@ -583,6 +596,10 @@ function escapeHtml(value) {
583
596
  function escapeAttr(value) {
584
597
  return escapeHtml(value).replace(/'/g, "&#39;");
585
598
  }
599
+ function renderFaviconData(theme) {
600
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="${theme.identity.accent}"/><path d="M14 18l9 28 9-18 9 18 9-28" fill="none" stroke="${theme.identity.onAccent}" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
601
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
602
+ }
586
603
  function printLandingHints(args) {
587
604
  console.log();
588
605
  console.log(ui.section("Next edits"));
@@ -594,7 +611,7 @@ function printLandingHints(args) {
594
611
  `${ui.kv("copy", contentHint, { keyWidth: 8 })}`,
595
612
  `${ui.kv("design", "try --theme atlas | harbor | aurora", { keyWidth: 8 })}`,
596
613
  `${ui.kv("scope", `trim sections with --sections ${args.sections.join(",")}`, { keyWidth: 8 })}`,
597
- `${ui.kv("dev", "wire the form action in src/index.html", { keyWidth: 8 })}`
614
+ `${ui.kv("launch", "set contact.action, canonicalUrl, and a 1200x630 socialImage", { keyWidth: 8 })}`
598
615
  ];
599
616
  console.log(ui.indent(lines.join("\n")));
600
617
  }
@@ -750,6 +767,10 @@ blockquote {
750
767
  }
751
768
 
752
769
  .eyebrow {
770
+ display: inline-flex;
771
+ width: fit-content;
772
+ max-width: 100%;
773
+ align-self: flex-start;
753
774
  margin: 0 0 calc(var(--space-grid) * 2);
754
775
  color: var(--color-accent-strong);
755
776
  font-size: 0.875rem;
@@ -982,6 +1003,12 @@ label {
982
1003
  font-weight: 700;
983
1004
  }
984
1005
 
1006
+ .form-help {
1007
+ margin: 0;
1008
+ color: var(--color-muted);
1009
+ font-size: 0.875rem;
1010
+ }
1011
+
985
1012
  input,
986
1013
  textarea {
987
1014
  width: 100%;
@@ -14,6 +14,7 @@ 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 { redactSecrets } from "../selene/redaction.js";
17
18
  import { ui } from "../ui/index.js";
18
19
  const PENDING_DIR = ".whale/selene";
19
20
  // ---------------------------------------------------------------------------
@@ -83,6 +84,10 @@ async function emitPrompt(target, prompt, kind, options) {
83
84
  * prompt mode, etc. Centralising keeps the policy consistent.
84
85
  */
85
86
  async function runSelene(target, kind, prompt, options) {
87
+ const safePrompt = redactSecrets(prompt);
88
+ if (safePrompt !== prompt) {
89
+ console.log(ui.warning("Potential secrets were redacted before Selene output/API use."));
90
+ }
86
91
  const config = await loadConfig(target);
87
92
  const sel = config.selene ?? {};
88
93
  // Step 1: figure out the mode.
@@ -103,7 +108,7 @@ async function runSelene(target, kind, prompt, options) {
103
108
  process.exitCode = 1;
104
109
  return { rawResponse: null };
105
110
  }
106
- await emitPrompt(target, prompt, kind, options);
111
+ await emitPrompt(target, safePrompt, kind, options);
107
112
  return { rawResponse: null };
108
113
  }
109
114
  // ---- API mode --------------------------------------------------------------
@@ -111,7 +116,7 @@ async function runSelene(target, kind, prompt, options) {
111
116
  // Cache lookup, unless the user opted out.
112
117
  const cacheEnabled = !options.noCache && !sel.noCache;
113
118
  if (cacheEnabled) {
114
- const cached = await readCache(target, resolved.model, prompt);
119
+ const cached = await readCache(target, resolved.model, safePrompt);
115
120
  if (cached) {
116
121
  console.log(ui.muted(" cache hit — no API call needed"));
117
122
  // Still stash the response so `apply` from disk works if the user wants.
@@ -122,7 +127,7 @@ async function runSelene(target, kind, prompt, options) {
122
127
  // Cost confirmation.
123
128
  const confirm = options.confirmCost || sel.confirmCost;
124
129
  if (confirm) {
125
- const inputTokens = estimateTokens(prompt);
130
+ const inputTokens = estimateTokens(safePrompt);
126
131
  const expectedOutput = sel.maxTokens ?? 1500;
127
132
  const cost = estimateCostUsd(resolved.model, inputTokens, expectedOutput);
128
133
  const costStr = cost === null ? "unknown" : `≈ $${cost.toFixed(4)}`;
@@ -135,13 +140,13 @@ async function runSelene(target, kind, prompt, options) {
135
140
  });
136
141
  if (!ok) {
137
142
  console.log(ui.warning("Aborted — falling back to prompt mode."));
138
- await emitPrompt(target, prompt, kind, options);
143
+ await emitPrompt(target, safePrompt, kind, options);
139
144
  return { rawResponse: null };
140
145
  }
141
146
  }
142
147
  const spin = ora({ text: "Calling provider", stream: process.stderr }).start();
143
148
  const result = await callProvider(resolved, {
144
- prompt,
149
+ prompt: safePrompt,
145
150
  maxTokens: sel.maxTokens ?? 1500,
146
151
  temperature: sel.temperature ?? 0.2
147
152
  });
@@ -152,7 +157,7 @@ async function runSelene(target, kind, prompt, options) {
152
157
  }
153
158
  // Graceful fallback: still emit the prompt so the user isn't stuck.
154
159
  console.log(ui.warning("\nFalling back to prompt mode so you can run this manually:"));
155
- await emitPrompt(target, prompt, kind, options);
160
+ await emitPrompt(target, safePrompt, kind, options);
156
161
  return { rawResponse: null };
157
162
  }
158
163
  const usage = result.inputTokens !== null && result.outputTokens !== null
@@ -160,7 +165,7 @@ async function runSelene(target, kind, prompt, options) {
160
165
  : "";
161
166
  spin.succeed(`Response received${usage}`);
162
167
  if (cacheEnabled) {
163
- await writeCache(target, resolved.model, prompt, result.text);
168
+ await writeCache(target, resolved.model, safePrompt, result.text);
164
169
  }
165
170
  await stashResponse(target, kind, result.text);
166
171
  return { rawResponse: result.text };
@@ -0,0 +1,45 @@
1
+ const REDACTED = "[REDACTED_SECRET]";
2
+ const SECRET_PATTERNS = [
3
+ {
4
+ name: "private-key",
5
+ pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g
6
+ },
7
+ { name: "openai-key", pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
8
+ { name: "github-token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g },
9
+ { name: "slack-token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
10
+ { name: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
11
+ { name: "google-api-key", pattern: /\bAIza[0-9A-Za-z_-]{30,}\b/g },
12
+ { name: "anthropic-key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g }
13
+ ];
14
+ const ASSIGNMENT_PATTERN = /\b(api[_-]?key|token|secret|password|passwd|client[_-]?secret|private[_-]?key)\b(\s*[:=]\s*)(["']?)([^"'\s,;)}\]]{16,})(\3)/gi;
15
+ function redactAssignment(match, key, separator, quote, value) {
16
+ if (isLikelyPlaceholder(value))
17
+ return match;
18
+ return `${key}${separator}${quote}${REDACTED}${quote}`;
19
+ }
20
+ function isLikelyPlaceholder(value) {
21
+ const normalized = value.toLowerCase();
22
+ return [
23
+ "example",
24
+ "placeholder",
25
+ "changeme",
26
+ "dummy",
27
+ "fake",
28
+ "mock",
29
+ "sample",
30
+ "test",
31
+ "xxxx",
32
+ "..."
33
+ ].some((needle) => normalized.includes(needle));
34
+ }
35
+ export function redactSecrets(input) {
36
+ let output = input;
37
+ for (const { pattern } of SECRET_PATTERNS) {
38
+ output = output.replace(pattern, REDACTED);
39
+ }
40
+ output = output.replace(ASSIGNMENT_PATTERN, redactAssignment);
41
+ return output;
42
+ }
43
+ export function containsRedactedSecret(input) {
44
+ return redactSecrets(input) !== input;
45
+ }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
- import matter from "gray-matter";
3
+ import { parseFrontmatter, stringifyFrontmatter } from "./frontmatter.js";
4
4
  const DIR = "intelligence/decisions";
5
5
  const LEGACY_STORE = "intelligence/decisions.json";
6
6
  function slugify(title) {
@@ -17,7 +17,7 @@ function section(body, heading) {
17
17
  return text && text.length > 0 ? text : undefined;
18
18
  }
19
19
  function parseFile(raw) {
20
- const { data, content } = matter(raw);
20
+ const { data, content } = parseFrontmatter(raw);
21
21
  const decision = section(content, "Decision");
22
22
  if (!data.id || !data.title || !decision)
23
23
  return null;
@@ -34,7 +34,7 @@ function parseFile(raw) {
34
34
  };
35
35
  }
36
36
  function serialize(d) {
37
- const fm = matter.stringify([
37
+ return stringifyFrontmatter([
38
38
  "## Context",
39
39
  d.context ?? "",
40
40
  "",
@@ -51,7 +51,6 @@ function serialize(d) {
51
51
  createdAt: d.timestamp,
52
52
  supersedes: d.supersedes ?? null
53
53
  });
54
- return fm;
55
54
  }
56
55
  async function loadLegacy(target) {
57
56
  const file = path.join(target, LEGACY_STORE);
@@ -0,0 +1,62 @@
1
+ function parseValue(raw) {
2
+ const value = raw.trim();
3
+ if (value === "null")
4
+ return null;
5
+ if (value === "true")
6
+ return true;
7
+ if (value === "false")
8
+ return false;
9
+ if (/^-?\d+(?:\.\d+)?$/.test(value))
10
+ return Number(value);
11
+ if ((value.startsWith('"') && value.endsWith('"')) ||
12
+ (value.startsWith("'") && value.endsWith("'"))) {
13
+ try {
14
+ return JSON.parse(value);
15
+ }
16
+ catch {
17
+ return value.slice(1, -1);
18
+ }
19
+ }
20
+ return value;
21
+ }
22
+ function stringifyValue(value) {
23
+ if (value === null || value === undefined)
24
+ return "null";
25
+ if (typeof value === "boolean" || typeof value === "number")
26
+ return String(value);
27
+ const text = String(value);
28
+ if (text === "" || /[:#\n\r]|^\s|\s$/.test(text))
29
+ return JSON.stringify(text);
30
+ return text;
31
+ }
32
+ export function parseFrontmatter(raw) {
33
+ if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) {
34
+ return { data: {}, content: raw };
35
+ }
36
+ const lineEnding = raw.startsWith("---\r\n") ? "\r\n" : "\n";
37
+ const startLength = 3 + lineEnding.length;
38
+ const endMarker = `${lineEnding}---${lineEnding}`;
39
+ const end = raw.indexOf(endMarker, startLength);
40
+ if (end === -1)
41
+ return { data: {}, content: raw };
42
+ const block = raw.slice(startLength, end);
43
+ const content = raw.slice(end + endMarker.length);
44
+ const data = {};
45
+ for (const line of block.split(/\r?\n/)) {
46
+ if (!line.trim())
47
+ continue;
48
+ const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
49
+ if (!match)
50
+ continue;
51
+ data[match[1]] = parseValue(match[2]);
52
+ }
53
+ return { data, content };
54
+ }
55
+ export function stringifyFrontmatter(content, data) {
56
+ const lines = ["---"];
57
+ for (const [key, value] of Object.entries(data)) {
58
+ lines.push(`${key}: ${stringifyValue(value)}`);
59
+ }
60
+ lines.push("---", content);
61
+ return lines.join("\n");
62
+ }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
- import matter from "gray-matter";
3
+ import { parseFrontmatter, stringifyFrontmatter } from "./frontmatter.js";
4
4
  const DIR = "intelligence/refinements";
5
5
  const LEGACY_STORE = "intelligence/refinements.json";
6
6
  function slugify(text) {
@@ -11,7 +11,7 @@ function slugify(text) {
11
11
  .slice(0, 60);
12
12
  }
13
13
  function parseFile(raw) {
14
- const { data, content } = matter(raw);
14
+ const { data, content } = parseFrontmatter(raw);
15
15
  const note = content.trim();
16
16
  if (!data.id || !note)
17
17
  return null;
@@ -40,7 +40,7 @@ function serialize(r) {
40
40
  fm.selector = r.scope.selector;
41
41
  if (r.scope?.file)
42
42
  fm.file = r.scope.file;
43
- return matter.stringify(r.note, fm);
43
+ return stringifyFrontmatter(r.note, fm);
44
44
  }
45
45
  async function loadLegacy(target) {
46
46
  const file = path.join(target, LEGACY_STORE);
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = "1.5.3";
1
+ export const PACKAGE_VERSION = "1.6.0";
package/docs/ROADMAP.md CHANGED
@@ -184,23 +184,24 @@ These are listed explicitly to defend focus over time.
184
184
 
185
185
  ---
186
186
 
187
- ## Operational milestones (not engineering work)
187
+ ## Operational milestones
188
188
 
189
189
  These are tasks that depend on the project owner, not on the codebase:
190
190
 
191
- - **Publish to npm.** Code is ready. Requires an npm account and
192
- `npm publish` from a clean checkout.
193
- - **Public repo.** Move from local zips to a versioned GitHub repo
194
- with releases.
191
+ - **npm releases.** Publishing is performed by the GitHub Release workflow,
192
+ with a matching `v<version>` tag, the protected `npm` environment, and
193
+ provenance. Local `npm publish` is not part of the release process.
194
+ - **Public repo.** Keep CI, release tags, changelog, package version, source,
195
+ and committed `dist/` synchronized.
195
196
  - **Demo video.** Record the 5-minute "adopt a real repo and watch
196
197
  Claude Code work with full context" walkthrough referenced in the
197
198
  done criteria.
198
199
  - **Field test.** Adopt the tool in three real projects (own or
199
200
  partner teams) and feed observations into v1.1.
200
201
 
201
- Until those happen, v1.0 is "feature-complete and ready to ship" but
202
- not "released and validated in the wild."
202
+ Release readiness is decided from the CI run attached to the release commit,
203
+ not from a development checkout.
203
204
 
204
205
  ---
205
206
 
206
- _Last updated: v1.1 release._
207
+ _Last updated: release hardening after v1.5.4._
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whale-igniter",
3
- "version": "1.5.3",
3
+ "version": "1.6.0",
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",
@@ -23,8 +23,9 @@
23
23
  "build": "tsc",
24
24
  "postbuild": "node -e \"require('node:fs').chmodSync('dist/index.js', 0o755)\"",
25
25
  "start": "node dist/index.js",
26
+ "security:scan": "tsx scripts/security-scan.ts",
26
27
  "test": "tsx tests/run.ts && tsx tests/mcp-smoke.ts && tsx tests/extractors.ts && tsx tests/driftAnalyzers.ts",
27
- "prepublishOnly": "npm run build && npm test"
28
+ "prepublishOnly": "npm run build && npm run security:scan && npm test"
28
29
  },
29
30
  "engines": {
30
31
  "node": ">=20.0.0"
@@ -66,7 +67,6 @@
66
67
  "commander": "^12.1.0",
67
68
  "fs-extra": "^11.2.0",
68
69
  "glob": "^13.0.6",
69
- "gray-matter": "^4.0.3",
70
70
  "ora": "^8.1.0",
71
71
  "postcss": "^8.4.47",
72
72
  "prompts": "^2.4.2",