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 +29 -0
- package/README.md +19 -0
- package/dist/commands/changes.js +3 -3
- package/dist/commands/createLanding.js +38 -11
- package/dist/commands/selene.js +12 -7
- package/dist/selene/redaction.js +45 -0
- package/dist/utils/decisions.js +3 -4
- package/dist/utils/frontmatter.js +62 -0
- package/dist/utils/refinements.js +3 -3
- package/dist/version.js +1 -1
- package/docs/ROADMAP.md +9 -8
- package/package.json +3 -3
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.
|
package/dist/commands/changes.js
CHANGED
|
@@ -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 =
|
|
45
|
+
const title = parseFrontmatter(raw).data.title;
|
|
46
46
|
return title ? String(title) : "";
|
|
47
47
|
};
|
|
48
|
-
const refinementLabel = (raw) =>
|
|
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, "'");
|
|
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("
|
|
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%;
|
package/dist/commands/selene.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
+
}
|
package/dist/utils/decisions.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
|
-
import
|
|
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 } =
|
|
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
|
-
|
|
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
|
|
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 } =
|
|
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
|
|
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.
|
|
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
|
|
187
|
+
## Operational milestones
|
|
188
188
|
|
|
189
189
|
These are tasks that depend on the project owner, not on the codebase:
|
|
190
190
|
|
|
191
|
-
- **
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
202
|
-
not
|
|
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.
|
|
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.
|
|
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",
|