Initial commit: PDF→static HTML/CSS site generator

main.py converts every PDF in examples/ into a browseable, mobile-responsive
HTML archive under output/ using poppler-utils. Includes the two NETgazet
sample PDFs, project metadata, OpenWolf scaffolding, and README covering
usage, a watch-folder script, and WordPress iframe embedding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:00:07 +02:00
co-authored by Claude Opus 4.7
commit 93174e1e09
33 changed files with 3652 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
---
description: OpenWolf protocol enforcement — active on all files
globs: **/*
---
- Check .wolf/anatomy.md before reading any project file
- Check .wolf/cerebrum.md Do-Not-Repeat list before generating code
- After writing or editing files, update .wolf/anatomy.md and append to .wolf/memory.md
- After receiving a user correction, update .wolf/cerebrum.md immediately (Preferences, Learnings, or Do-Not-Repeat)
- LEARN from every interaction: if you discover a convention, user preference, or project pattern, add it to .wolf/cerebrum.md. Low threshold — when in doubt, log it.
- BEFORE fixing any bug or error: read .wolf/buglog.json for known fixes
- AFTER fixing any bug, error, failed test, failed build, or user-reported problem: ALWAYS log to .wolf/buglog.json with error_message, root_cause, fix, and tags
- If you edit a file more than twice in a session, that likely indicates a bug — log it to .wolf/buglog.json
- When the user asks to check/evaluate UI design: run `openwolf designqc` to capture screenshots, then read them from .wolf/designqc-captures/
- When the user asks to change/pick/migrate UI framework: read .wolf/reframe-frameworks.md, ask decision questions, recommend a framework, then execute with the framework's prompt
+72
View File
@@ -0,0 +1,72 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/session-start.js\"",
"timeout": 5
}
]
}
],
"PreToolUse": [
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-read.js\"",
"timeout": 5
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-write.js\"",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/post-read.js\"",
"timeout": 5
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js\"",
"timeout": 10
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/stop.js\"",
"timeout": 10
}
]
}
]
}
}
+22
View File
@@ -0,0 +1,22 @@
# Generated site
output/
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# OpenWolf runtime
.wolf/daemon.log
.wolf/designqc-captures/
# OS
.DS_Store
Thumbs.db
# Editors
.vscode/
.idea/
*.swp
+1
View File
@@ -0,0 +1 @@
3.14
+135
View File
@@ -0,0 +1,135 @@
# OpenWolf Operating Protocol
You are working in an OpenWolf-managed project. These rules apply every turn.
## File Navigation
1. Check `.wolf/anatomy.md` BEFORE reading any file. It has a 2-3 line description and token estimate for every file in the project.
2. If the description in anatomy.md is sufficient for your task, do NOT read the full file.
3. If a file is not in anatomy.md, search with Grep/Glob, then update anatomy.md with the new entry.
## Code Generation
1. Before generating code, read `.wolf/cerebrum.md` and respect every entry.
2. Check the `## Do-Not-Repeat` section — these are past mistakes that must not recur.
3. Follow all conventions in `## Key Learnings` and `## User Preferences`.
## After Actions
1. After every significant action, append a one-line entry to `.wolf/memory.md`:
`| HH:MM | description | file(s) | outcome | ~tokens |`
2. After creating, deleting, or renaming files: update `.wolf/anatomy.md`.
## Cerebrum Learning (MANDATORY — every session)
OpenWolf's value comes from learning across sessions. You MUST update `.wolf/cerebrum.md` whenever you learn something useful. This is not optional.
**Update `## User Preferences` when the user:**
- Corrects your approach ("no, do it this way instead")
- Expresses a style preference (naming, structure, formatting)
- Shows a preferred workflow or tool choice
- Rejects a suggestion — record what they preferred instead
- Asks for more/less detail, verbosity, explanation
**Update `## Key Learnings` when you discover:**
- A project convention not obvious from the code (e.g., "tests go in __tests__/ not test/")
- A framework-specific pattern this project uses
- An API behavior that surprised you
- A dependency quirk or version constraint
- How modules connect or data flows through the system
**Update `## Do-Not-Repeat` (with date) when:**
- The user corrects a mistake you made
- You try something that fails and find the right approach
- You discover a gotcha that would trip up a fresh session
**Update `## Decision Log` when:**
- A significant architectural or technical choice is made
- The user explains why they chose approach A over B
- A trade-off is explicitly discussed
**The bar is LOW.** If in doubt, add it. A cerebrum entry that's slightly redundant costs nothing. A missing entry means the next session repeats the same discovery process.
## Bug Logging (MANDATORY)
**Log a bug to `.wolf/buglog.json` whenever ANY of these happen:**
- The user reports an error, bug, or problem
- A test fails or a command produces an error
- You fix something that was broken
- You edit a file more than twice to get it right
- An import, module, or dependency is missing or wrong
- A runtime error, type error, or syntax error occurs
- A build or lint command fails
- A feature doesn't work as expected
- You change error handling, try/catch blocks, or validation logic
- The user says something "doesn't work", "is broken", or "shows wrong X"
**Before fixing:** Read `.wolf/buglog.json` first — the fix may already be known.
**After fixing:** ALWAYS append to `.wolf/buglog.json` with this structure:
```json
{
"id": "bug-NNN",
"timestamp": "ISO date",
"error_message": "exact error or user complaint",
"file": "file that was fixed",
"root_cause": "why it broke",
"fix": "what you changed to fix it",
"tags": ["relevant", "keywords"],
"related_bugs": [],
"occurrences": 1,
"last_seen": "ISO date"
}
```
**The threshold is LOW.** When in doubt, log it. A false positive in the bug log costs nothing. A missed bug means repeating the same mistake later.
## Token Discipline
- Never re-read a file already read this session unless it was modified since.
- Prefer anatomy.md descriptions over full file reads when possible.
- Prefer targeted Grep over full file reads when searching for specific code.
- If appending to a file, do not read the entire file first.
## Design QC
When the user asks you to check, evaluate, or improve the design/UI of their app:
1. Run `openwolf designqc` via Bash to capture screenshots.
- The command auto-detects a running dev server, or starts one from package.json if needed
- Use `--url <url>` only if auto-detection fails
- The command saves compressed JPEG screenshots to `.wolf/designqc-captures/`
- Full pages are captured as sectioned viewport-height images (top, section2, ..., bottom)
2. Read the captured screenshot images from `.wolf/designqc-captures/` using the Read tool.
3. Evaluate the design against modern standards (Shadcn UI, Tailwind, clean React patterns):
- Spacing and whitespace consistency
- Typography hierarchy and readability
- Color contrast and accessibility (WCAG)
- Visual hierarchy and focal points
- Component consistency
- Whether the design looks "dull" or "white-coded" (generic, no personality)
4. Provide specific, actionable feedback with fix suggestions.
5. If the user approves, implement the fixes directly in their code.
6. After fixes, re-run `openwolf designqc` to capture new screenshots and verify improvement.
**Token awareness:** Each screenshot costs ~2500 tokens. The command compresses images (JPEG quality 70, max width 1200px) to minimize cost. For large apps, use `--routes / /specific-page` to limit captures.
## Reframe — UI Framework Selection
When the user asks to change, pick, migrate, or "reframe" their project's UI framework:
1. Read `.wolf/reframe-frameworks.md` for the full framework knowledge base.
2. Ask the user the decision questions from the file (current stack, priority, Tailwind usage, theme preference, app type). Stop early once the choice narrows to 1-2 options.
3. Present a recommendation with reasoning based on the comparison matrix.
4. Once the user confirms, use the selected framework's prompt from the file — **adapted to the actual project** using `.wolf/anatomy.md` for real file paths, routes, and components.
5. Execute the migration: install dependencies, update config, refactor components.
6. After migration, run `openwolf designqc` to verify the new look.
**Do NOT read the entire reframe-frameworks.md into context upfront.** Read the decision questions and comparison matrix first (~50 lines). Only read the specific framework's prompt section after the user chooses.
## Session End
Before ending or when asked to wrap up:
1. Write a session summary to `.wolf/memory.md`.
2. Review the session: did you learn anything? Did the user correct you? Did you fix a bug? If yes, update `.wolf/cerebrum.md` and/or `.wolf/buglog.json`.
+25
View File
@@ -0,0 +1,25 @@
# anatomy.md
> Auto-maintained by OpenWolf. Last scanned: 2026-06-03T13:57:12.941Z
> Files: 9 tracked | Anatomy hits: 0 | Misses: 0
## ../../.claude/plans/
- `in-the-example-folder-immutable-rossum.md` — Plan: PDF → static HTML/CSS site (poppler-utils) (~2113 tok)
## ./
- `.gitignore` — Git ignore rules (~55 tok)
- `.python-version` (~2 tok)
- `CLAUDE.md` — CLAUDE.md (~550 tok)
- `main.py` — Convert every PDF in examples/ into a static HTML/CSS site under output/. (~2847 tok)
- `pyproject.toml` — Python project configuration (~44 tok)
- `README.md` — Project documentation (~909 tok)
## .claude/
- `settings.json` (~441 tok)
## .claude/rules/
- `openwolf.md` (~313 tok)
+4
View File
@@ -0,0 +1,4 @@
{
"version": 1,
"bugs": []
}
+25
View File
@@ -0,0 +1,25 @@
# Cerebrum
> OpenWolf's learning memory. Updated automatically as the AI learns from interactions.
> Do not edit manually unless correcting an error.
> Last updated: 2026-06-03
## User Preferences
<!-- How the user likes things done. Code style, tools, patterns, communication. -->
## Key Learnings
- **Project:** netgazet — converts NETgazet print PDFs (InDesign-laid-out, ~A3, 16pp) into a static HTML/CSS site under `output/`. Entry point is `main.py`; runs via `python main.py` or `uv run python main.py`. Inputs live in `examples/`.
- **Conversion approach:** page-as-JPEG via `pdftocairo -jpeg -singlefile -r 150` + responsive `img { max-width: 100% }`. `pdftohtml` was deliberately rejected because its complex-mode output is fixed-pixel and not mobile-responsive. Selectable text is provided as a `<details>` block per page from `pdftotext -layout`, split on form-feed (`\f`).
- **`pdftocairo` filename quirk:** without `-singlefile` it appends `-<page>.jpg` to the output stem; with `-singlefile` it appends just `.jpg`. The script uses `-singlefile` per page so file paths are predictable.
- **No third-party Python deps.** Conversion shells out via `subprocess.run([...], check=True)`; `pyproject.toml` keeps `dependencies = []`.
## Do-Not-Repeat
<!-- Mistakes made and corrected. Each entry prevents the same mistake recurring. -->
<!-- Format: [YYYY-MM-DD] Description of what went wrong and what to do instead. -->
## Decision Log
<!-- Significant technical decisions with rationale. Why X was chosen over Y. -->
+73
View File
@@ -0,0 +1,73 @@
{
"version": 1,
"openwolf": {
"enabled": true,
"anatomy": {
"auto_scan_on_init": true,
"rescan_interval_hours": 6,
"max_description_length": 100,
"max_files": 500,
"exclude_patterns": [
"node_modules",
".git",
"dist",
"build",
".wolf",
".next",
".nuxt",
"coverage",
"__pycache__",
".cache",
"target",
".vscode",
".idea",
".turbo",
".vercel",
".netlify",
".output",
"*.min.js",
"*.min.css"
]
},
"token_audit": {
"enabled": true,
"report_frequency": "weekly",
"waste_threshold_percent": 15,
"chars_per_token_code": 3.5,
"chars_per_token_prose": 4.0
},
"cron": {
"enabled": true,
"max_retry_attempts": 3,
"dead_letter_enabled": true,
"heartbeat_interval_minutes": 30,
"use_claude_p": true,
"api_key_env": null
},
"memory": {
"consolidation_after_days": 7,
"max_entries_before_consolidation": 200
},
"cerebrum": {
"max_tokens": 2000,
"reflection_frequency": "weekly"
},
"daemon": {
"port": 18790,
"log_level": "info"
},
"dashboard": {
"enabled": true,
"port": 18791
},
"designqc": {
"enabled": true,
"viewports": [
{ "name": "desktop", "width": 1440, "height": 900 },
{ "name": "mobile", "width": 375, "height": 812 }
],
"max_screenshots": 6,
"chrome_path": null
}
}
}
+97
View File
@@ -0,0 +1,97 @@
{
"version": 1,
"tasks": [
{
"id": "anatomy-rescan",
"name": "Full anatomy rescan",
"schedule": "0 */6 * * *",
"description": "Re-scans project filesystem and reconciles anatomy.md",
"action": { "type": "scan_project" },
"retry": {
"max_attempts": 3,
"backoff": "exponential",
"base_delay_seconds": 30
},
"failsafe": {
"on_failure": "log_and_continue",
"alert_after_consecutive_failures": 2,
"dead_letter": true
},
"enabled": true
},
{
"id": "memory-consolidation",
"name": "Consolidate old memory",
"schedule": "0 2 * * *",
"description": "Compress memory.md entries older than 7 days",
"action": {
"type": "consolidate_memory",
"params": { "older_than_days": 7 }
},
"retry": {
"max_attempts": 2,
"backoff": "exponential",
"base_delay_seconds": 60
},
"failsafe": {
"on_failure": "skip_and_retry_next_cycle",
"dead_letter": false
},
"enabled": true
},
{
"id": "token-audit",
"name": "Token audit report",
"schedule": "0 0 * * 1",
"description": "Weekly waste pattern detection",
"action": { "type": "generate_token_report" },
"retry": {
"max_attempts": 2,
"backoff": "linear",
"base_delay_seconds": 60
},
"failsafe": { "on_failure": "log_and_continue", "dead_letter": true },
"enabled": true
},
{
"id": "cerebrum-reflection",
"name": "Cerebrum reflection",
"schedule": "0 3 * * 0",
"description": "Weekly AI review of cerebrum.md — prune stale entries, consolidate duplicates",
"action": {
"type": "ai_task",
"params": {
"prompt": "Review this cerebrum.md. Remove duplicate preferences (keep newer). Remove Do-Not-Repeat entries older than 90 days if no longer relevant. Consolidate Key Learnings that overlap. Keep the file under 2000 tokens. Return the cleaned file content only.",
"context_files": [".wolf/cerebrum.md"]
}
},
"retry": {
"max_attempts": 1,
"backoff": "none",
"base_delay_seconds": 0
},
"failsafe": { "on_failure": "log_and_continue", "dead_letter": false },
"enabled": true
},
{
"id": "project-suggestions",
"name": "AI suggestions",
"schedule": "0 4 * * 1",
"description": "Weekly AI analysis with project improvement suggestions",
"action": {
"type": "ai_task",
"params": {
"prompt": "Based on the recent memory entries and current project structure, provide: 1) Key achievements this week, 2) Code improvements to consider, 3) Logical next tasks, 4) Technical debt or risks. Be specific and actionable. Return as JSON: {achievements:[], improvements:[], next_tasks:[], risks:[]}",
"context_files": [".wolf/memory.md", ".wolf/anatomy.md"]
}
},
"retry": {
"max_attempts": 1,
"backoff": "none",
"base_delay_seconds": 0
},
"failsafe": { "on_failure": "log_and_continue", "dead_letter": false },
"enabled": true
}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"last_heartbeat": "2026-06-03T13:15:40.431Z",
"engine_status": "running",
"execution_log": [],
"dead_letter_queue": [],
"upcoming": []
}
+6
View File
@@ -0,0 +1,6 @@
{
"captured_at": null,
"captures": [],
"total_size_kb": 0,
"estimated_tokens": 0
}
+39
View File
@@ -0,0 +1,39 @@
{
"session_id": "session-2026-06-03-1555",
"started": "2026-06-03T13:55:38.248Z",
"files_read": {
"/home/seppe4/vscode_projects/netgazet/output/index.html": {
"count": 1,
"tokens": 0,
"first_read": "2026-06-03T13:56:13.931Z"
},
"/home/seppe4/vscode_projects/netgazet/.gitignore": {
"count": 1,
"tokens": 0,
"first_read": "2026-06-03T13:57:08.826Z"
}
},
"files_written": [
{
"file": "/home/seppe4/vscode_projects/netgazet/README.md",
"action": "create",
"tokens": 1039,
"at": "2026-06-03T13:57:02.392Z"
},
{
"file": "/home/seppe4/vscode_projects/netgazet/.gitignore",
"action": "create",
"tokens": 59,
"at": "2026-06-03T13:57:12.946Z"
}
],
"edit_counts": {
"README.md": 1,
".gitignore": 1
},
"anatomy_hits": 0,
"anatomy_misses": 2,
"repeated_reads_warned": 0,
"cerebrum_warnings": 0,
"stop_count": 1
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+69
View File
@@ -0,0 +1,69 @@
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, estimateTokens, readStdin, normalizePath } from "./shared.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
const hooksDir = path.join(wolfDir, "hooks");
const sessionFile = path.join(hooksDir, "_session.json");
const raw = await readStdin();
let input;
try {
input = JSON.parse(raw);
}
catch {
process.exit(0);
return;
}
const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
const content = input.tool_output?.content ?? "";
if (!filePath) {
process.exit(0);
return;
}
const normalizedFile = normalizePath(filePath);
// Skip tracking for .wolf/ internal files — consistent with pre-read
const projectDir = normalizePath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
const relToProject = normalizedFile.startsWith(projectDir)
? normalizedFile.slice(projectDir.length).replace(/^\//, "")
: "";
if (relToProject.startsWith(".wolf/") || relToProject.startsWith(".wolf\\")) {
process.exit(0);
return;
}
const ext = path.extname(filePath).toLowerCase();
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".rs", ".go", ".java", ".c", ".cpp", ".css", ".json", ".yaml", ".yml"]);
const proseExts = new Set([".md", ".txt", ".rst"]);
const type = codeExts.has(ext) ? "code" : proseExts.has(ext) ? "prose" : "mixed";
let tokens = content ? estimateTokens(content, type) : 0;
// Fallback: if tool_output had no content, use anatomy token estimate
if (tokens === 0) {
const anatomyContent = readMarkdown(path.join(wolfDir, "anatomy.md"));
const sections = parseAnatomy(anatomyContent);
for (const [sectionKey, entries] of sections) {
for (const entry of entries) {
const entryRelPath = normalizePath(path.join(sectionKey, entry.file));
if (normalizedFile.endsWith(entryRelPath) || normalizedFile.endsWith("/" + entryRelPath)) {
tokens = entry.tokens;
break;
}
}
if (tokens > 0)
break;
}
}
const session = readJSON(sessionFile, { files_read: {} });
if (session.files_read[normalizedFile]) {
session.files_read[normalizedFile].tokens = tokens;
}
else {
session.files_read[normalizedFile] = {
count: 1,
tokens,
first_read: new Date().toISOString(),
};
}
writeJSON(sessionFile, session);
process.exit(0);
}
main().catch(() => process.exit(0));
//# sourceMappingURL=post-read.js.map
+503
View File
@@ -0,0 +1,503 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as crypto from "node:crypto";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, parseAnatomy, serializeAnatomy, extractDescription, estimateTokens, appendMarkdown, timeShort, readStdin, normalizePath } from "./shared.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
const hooksDir = path.join(wolfDir, "hooks");
const sessionFile = path.join(hooksDir, "_session.json");
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const raw = await readStdin();
let input;
try {
input = JSON.parse(raw);
}
catch {
process.exit(0);
return;
}
const toolName = input.tool_name ?? "Write";
const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
if (!filePath) {
process.exit(0);
return;
}
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath);
// Skip processing for .wolf/ internal files to avoid slow self-referential updates
const relPath = normalizePath(path.relative(projectRoot, absolutePath));
if (relPath.startsWith(".wolf/")) {
process.exit(0);
return;
}
// Never track .env files in anatomy — they contain secrets
const baseName = path.basename(absolutePath);
if (baseName === ".env" || baseName.startsWith(".env.")) {
process.exit(0);
return;
}
const oldStr = input.tool_input?.old_string ?? "";
const newStr = input.tool_input?.new_string ?? "";
// 1. Update anatomy.md
try {
const anatomyPath = path.join(wolfDir, "anatomy.md");
let anatomyContent;
try {
anatomyContent = fs.readFileSync(anatomyPath, "utf-8");
}
catch {
anatomyContent = "# anatomy.md\n\n> Auto-maintained by OpenWolf.\n";
}
const sections = parseAnatomy(anatomyContent);
const relPathLocal = normalizePath(path.relative(projectRoot, absolutePath));
const dir = path.dirname(relPathLocal);
const fileName = path.basename(relPathLocal);
const sectionKey = dir === "." ? "./" : dir + "/";
let fileContent = "";
try {
fileContent = fs.readFileSync(absolutePath, "utf-8");
}
catch {
fileContent = input.tool_input?.content ?? "";
}
const desc = extractDescription(absolutePath).slice(0, 100);
const ext = path.extname(absolutePath).toLowerCase();
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".json", ".yaml", ".yml", ".css"]);
const proseExts = new Set([".md", ".txt", ".rst"]);
const type = codeExts.has(ext) ? "code" : proseExts.has(ext) ? "prose" : "mixed";
const tokens = estimateTokens(fileContent, type);
if (!sections.has(sectionKey))
sections.set(sectionKey, []);
const entries = sections.get(sectionKey);
const idx = entries.findIndex((e) => e.file === fileName);
if (idx !== -1) {
entries[idx] = { file: fileName, description: desc, tokens };
}
else {
entries.push({ file: fileName, description: desc, tokens });
}
let fileCount = 0;
for (const [, list] of sections)
fileCount += list.length;
const serialized = serializeAnatomy(sections, {
lastScanned: new Date().toISOString(),
fileCount,
hits: 0,
misses: 0,
});
const tmp = anatomyPath + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
try {
fs.writeFileSync(tmp, serialized, "utf-8");
fs.renameSync(tmp, anatomyPath);
}
catch {
try {
fs.writeFileSync(anatomyPath, serialized, "utf-8");
}
catch { }
try {
fs.unlinkSync(tmp);
}
catch { }
}
}
catch { }
// 2. Append richer entry to memory.md
try {
const action = toolName === "Write" ? "Created" : toolName === "MultiEdit" ? "Multi-edited" : "Edited";
const relFile = normalizePath(path.relative(projectRoot, absolutePath));
const fileContent = input.tool_input?.content ?? "";
const ext = path.extname(absolutePath).toLowerCase();
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".json", ".yaml", ".yml", ".css"]);
const type = codeExts.has(ext) ? "code" : "mixed";
const writeTokens = estimateTokens(fileContent || newStr, type);
let changeDesc = "";
if (oldStr && newStr) {
changeDesc = summarizeEdit(oldStr, newStr, baseName);
}
const memoryPath = path.join(wolfDir, "memory.md");
const outcome = changeDesc || "—";
appendMarkdown(memoryPath, `| ${timeShort()} | ${action} ${relFile} | ${outcome} | ~${writeTokens} |\n`);
}
catch { }
// 3. Record in session tracker + track edit counts
try {
const session = readJSON(sessionFile, { files_written: [], edit_counts: {} });
if (!session.edit_counts)
session.edit_counts = {};
const normalizedFile = normalizePath(filePath);
const action = toolName === "Write" ? "create" : "edit";
const fileContent = input.tool_input?.content ?? "";
const tokens = estimateTokens(fileContent || newStr, "code");
session.files_written.push({
file: normalizedFile,
action,
tokens,
at: new Date().toISOString(),
});
const editKey = normalizePath(path.relative(projectRoot, absolutePath));
session.edit_counts[editKey] = (session.edit_counts[editKey] || 0) + 1;
writeJSON(sessionFile, session);
if (session.edit_counts[editKey] >= 3) {
process.stderr.write(`⚠️ OpenWolf: ${baseName} has been edited ${session.edit_counts[editKey]} times this session. If you're fixing a bug, remember to log it to .wolf/buglog.json.\n`);
}
}
catch { }
// 4. Auto-detect bug-fix patterns and log them
try {
if (oldStr && newStr) {
autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr);
}
}
catch { }
process.exit(0);
}
// ─── Edit Summarizer ─────────────────────────────────────────────
function summarizeEdit(oldStr, newStr, filename) {
const oldLines = oldStr.split("\n");
const newLines = newStr.split("\n");
const oldCount = oldLines.length;
const newCount = newLines.length;
const ext = path.extname(filename).toLowerCase();
// --- Structural fixes ---
if (newStr.includes("try") && newStr.includes("catch") && !oldStr.includes("catch")) {
return "added error handling";
}
if (newStr.includes("?.") && !oldStr.includes("?."))
return "added optional chaining";
if (newStr.includes("?? ") && !oldStr.includes("?? "))
return "added nullish coalescing";
// --- Deleted code ---
if (!newStr.trim() || newStr.trim().length < oldStr.trim().length * 0.2) {
return `removed ${oldCount} lines`;
}
// --- Import changes ---
const oldImports = oldLines.filter(l => /^\s*(import|require|use |from )/.test(l)).length;
const newImports = newLines.filter(l => /^\s*(import|require|use |from )/.test(l)).length;
if (newImports > oldImports && Math.abs(newCount - oldCount) <= newImports - oldImports + 1) {
return `added ${newImports - oldImports} import(s)`;
}
// --- Value/string replacement (common bug fix: wrong value) ---
if (oldCount === 1 && newCount === 1) {
const o = oldStr.trim();
const n = newStr.trim();
// String literal change
const oStr = o.match(/['"`]([^'"`]+)['"`]/);
const nStr = n.match(/['"`]([^'"`]+)['"`]/);
if (oStr && nStr && oStr[1] !== nStr[1]) {
return `"${oStr[1].slice(0, 25)}" → "${nStr[1].slice(0, 25)}"`;
}
// Number change
const oNum = o.match(/\b(\d+\.?\d*)\b/);
const nNum = n.match(/\b(\d+\.?\d*)\b/);
if (oNum && nNum && oNum[1] !== nNum[1] && o.replace(oNum[1], "") === n.replace(nNum[1], "")) {
return `${oNum[1]}${nNum[1]}`;
}
return "inline fix";
}
// --- Method/function call changes ---
const oldCalls = extractCalls(oldStr);
const newCalls = extractCalls(newStr);
const addedCalls = newCalls.filter(c => !oldCalls.includes(c));
const removedCalls = oldCalls.filter(c => !newCalls.includes(c));
if (removedCalls.length === 1 && addedCalls.length === 1) {
return `${removedCalls[0]}() → ${addedCalls[0]}()`;
}
// --- CSS/style changes ---
if (ext === ".css" || ext === ".scss" || ext === ".vue" || ext === ".tsx" || ext === ".jsx") {
const oldProps = (oldStr.match(/[\w-]+\s*:/g) || []).map(p => p.replace(/\s*:/, ""));
const newProps = (newStr.match(/[\w-]+\s*:/g) || []).map(p => p.replace(/\s*:/, ""));
const changed = newProps.filter(p => !oldProps.includes(p));
if (changed.length > 0 && changed.length <= 3) {
return `CSS: ${changed.join(", ")}`;
}
}
// --- Condition changes ---
const oldConds = (oldStr.match(/if\s*\(([^)]+)\)/g) || []);
const newConds = (newStr.match(/if\s*\(([^)]+)\)/g) || []);
if (newConds.length > oldConds.length) {
return `added ${newConds.length - oldConds.length} condition(s)`;
}
// --- Function modified ---
const fnMatch = newStr.match(/(?:function|def|fn|func|async\s+function)\s+(\w+)/);
if (fnMatch) {
return `modified ${fnMatch[1]}()`;
}
// --- Class/method context ---
const methodMatch = newStr.match(/(?:public|private|protected)?\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*[:{]/);
if (methodMatch) {
return `modified ${methodMatch[1]}()`;
}
// --- Size-based fallback ---
if (newCount > oldCount + 5)
return `expanded (+${newCount - oldCount} lines)`;
if (oldCount > newCount + 5)
return `reduced (-${oldCount - newCount} lines)`;
return `${oldCount}${newCount} lines`;
}
function extractCalls(code) {
return [...new Set((code.match(/(\w+)\s*\(/g) || [])
.map(m => m.match(/(\w+)/)?.[1] || "")
.filter(n => n.length > 2 && !["if", "for", "while", "switch", "catch", "function", "return", "new", "typeof", "instanceof", "const", "let", "var"].includes(n)))];
}
// ─── Auto Bug Detection ──────────────────────────────────────────
function autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr) {
const bugLogPath = path.join(wolfDir, "buglog.json");
const bugLog = readJSON(bugLogPath, { version: 1, bugs: [] });
const relFile = normalizePath(path.relative(projectRoot, absolutePath));
const basename = path.basename(absolutePath);
const ext = path.extname(basename).toLowerCase();
// Detect what kind of fix this is
const detection = detectFixPattern(oldStr, newStr, ext);
if (!detection)
return;
// Check for recent duplicate (same file + same category within 5 min)
const recentDupe = bugLog.bugs.find(b => {
if (path.basename(b.file) !== basename)
return false;
if (!b.tags.includes("auto-detected"))
return false;
if (!b.tags.includes(detection.category))
return false;
const bugTime = new Date(b.last_seen).getTime();
return (Date.now() - bugTime) < 5 * 60 * 1000;
});
if (recentDupe) {
recentDupe.occurrences++;
recentDupe.last_seen = new Date().toISOString();
// Append additional context
if (detection.context && !recentDupe.fix.includes(detection.context)) {
recentDupe.fix += ` | Also: ${detection.context}`;
}
writeJSON(bugLogPath, bugLog);
return;
}
const nextId = `bug-${String(bugLog.bugs.length + 1).padStart(3, "0")}`;
bugLog.bugs.push({
id: nextId,
timestamp: new Date().toISOString(),
error_message: detection.summary,
file: relFile,
root_cause: detection.rootCause,
fix: detection.fix,
tags: ["auto-detected", detection.category, ext.replace(".", "") || "unknown"],
related_bugs: [],
occurrences: 1,
last_seen: new Date().toISOString(),
});
writeJSON(bugLogPath, bugLog);
}
function detectFixPattern(oldStr, newStr, ext) {
const oldLines = oldStr.split("\n");
const newLines = newStr.split("\n");
// --- Error handling added ---
if (newStr.includes("catch") && !oldStr.includes("catch")) {
const fn = newStr.match(/(?:function|def|async)\s+(\w+)/)?.[1] || "unknown";
return {
category: "error-handling",
summary: `Missing error handling in ${path.basename(fn)}`,
rootCause: "Code path had no error handling — exceptions would propagate uncaught",
fix: `Added try/catch block`,
context: extractChangedLines(oldStr, newStr),
};
}
// --- Null/undefined safety ---
if ((newStr.includes("?.") && !oldStr.includes("?.")) ||
(newStr.includes("?? ") && !oldStr.includes("?? ")) ||
(/!==?\s*(null|undefined)/.test(newStr) && !/!==?\s*(null|undefined)/.test(oldStr))) {
return {
category: "null-safety",
summary: `Null/undefined access in ${path.basename(path.basename(""))}`,
rootCause: "Property access on potentially null/undefined value",
fix: `Added null safety (optional chaining or null check)`,
context: extractChangedLines(oldStr, newStr),
};
}
// --- Guard clause / early return added ---
if (/if\s*\([^)]*\)\s*(return|throw|continue|break)/.test(newStr) &&
!/if\s*\([^)]*\)\s*(return|throw|continue|break)/.test(oldStr)) {
const condition = newStr.match(/if\s*\(([^)]+)\)/)?.[1]?.trim().slice(0, 60) || "condition";
return {
category: "guard-clause",
summary: `Missing guard clause`,
rootCause: `No early return/throw for edge case: ${condition}`,
fix: `Added guard clause: if (${condition.slice(0, 40)})`,
};
}
// --- Wrong value / string fix (very common bug) ---
if (oldLines.length <= 3 && newLines.length <= 3) {
const oldJoined = oldStr.trim();
const newJoined = newStr.trim();
// String literal changed
const oStrs = oldJoined.match(/['"`]([^'"`]{2,})['"`]/g) || [];
const nStrs = newJoined.match(/['"`]([^'"`]{2,})['"`]/g) || [];
if (oStrs.length > 0 && nStrs.length > 0) {
for (let i = 0; i < Math.min(oStrs.length, nStrs.length); i++) {
if (oStrs[i] !== nStrs[i]) {
return {
category: "wrong-value",
summary: `Incorrect value in code`,
rootCause: `Had ${oStrs[i].slice(0, 50)}`,
fix: `Changed to ${nStrs[i].slice(0, 50)}`,
};
}
}
}
// Variable name / method call changed
const oldTokens = tokenizeCode(oldJoined);
const newTokens = tokenizeCode(newJoined);
const changed = [];
for (let i = 0; i < Math.min(oldTokens.length, newTokens.length); i++) {
if (oldTokens[i] !== newTokens[i]) {
changed.push([oldTokens[i], newTokens[i]]);
}
}
if (changed.length === 1 && changed[0][0].length > 2) {
return {
category: "wrong-reference",
summary: `Wrong reference: ${changed[0][0]} should be ${changed[0][1]}`,
rootCause: `Used "${changed[0][0]}" instead of "${changed[0][1]}"`,
fix: `Changed ${changed[0][0]}${changed[0][1]}`,
};
}
}
// --- Logic fix (condition changed) ---
const oldCond = oldStr.match(/if\s*\(([^)]+)\)/)?.[1];
const newCond = newStr.match(/if\s*\(([^)]+)\)/)?.[1];
if (oldCond && newCond && oldCond !== newCond && oldLines.length <= 5) {
return {
category: "logic-fix",
summary: `Wrong condition in logic`,
rootCause: `Condition was: if (${oldCond.slice(0, 50)})`,
fix: `Changed to: if (${newCond.slice(0, 50)})`,
};
}
// --- Operator fix (=== vs ==, > vs >=, etc.) ---
const opChange = findOperatorChange(oldStr, newStr);
if (opChange) {
return {
category: "operator-fix",
summary: `Wrong operator: ${opChange.old} should be ${opChange.new}`,
rootCause: `Used "${opChange.old}" instead of "${opChange.new}"`,
fix: `Changed operator ${opChange.old}${opChange.new}`,
};
}
// --- Missing import/require ---
const oldImports = new Set((oldStr.match(/(?:import|require)\s*\(?['"]([^'"]+)['"]\)?/g) || []).map(m => m));
const newImports = (newStr.match(/(?:import|require)\s*\(?['"]([^'"]+)['"]\)?/g) || []);
const addedImports = newImports.filter(i => !oldImports.has(i));
if (addedImports.length > 0 && newLines.length - oldLines.length <= addedImports.length + 2) {
const modules = addedImports.map(i => i.match(/['"]([^'"]+)['"]/)?.[1] || "").filter(Boolean);
return {
category: "missing-import",
summary: `Missing import: ${modules.join(", ")}`,
rootCause: `Module(s) not imported: ${modules.join(", ")}`,
fix: `Added import(s) for ${modules.join(", ")}`,
};
}
// --- Return value fix ---
const oldReturn = oldStr.match(/return\s+(.+)/)?.[1]?.trim();
const newReturn = newStr.match(/return\s+(.+)/)?.[1]?.trim();
if (oldReturn && newReturn && oldReturn !== newReturn && oldLines.length <= 5) {
return {
category: "return-value",
summary: `Wrong return value`,
rootCause: `Was returning: ${oldReturn.slice(0, 50)}`,
fix: `Now returns: ${newReturn.slice(0, 50)}`,
};
}
// --- Async/await fix ---
if (newStr.includes("await ") && !oldStr.includes("await ")) {
return {
category: "async-fix",
summary: `Missing await`,
rootCause: `Async call without await — returned Promise instead of value`,
fix: `Added await to async call`,
context: extractChangedLines(oldStr, newStr),
};
}
if (newStr.includes("async ") && !oldStr.includes("async ")) {
return {
category: "async-fix",
summary: `Function not marked async`,
rootCause: `Function uses await but wasn't declared async`,
fix: `Added async modifier`,
};
}
// --- Type annotation/cast fix ---
if (ext === ".ts" || ext === ".tsx") {
if ((newStr.includes(" as ") && !oldStr.includes(" as ")) ||
(newStr.includes(": ") && !oldStr.includes(": ") && oldLines.length <= 3)) {
return {
category: "type-fix",
summary: `Type error`,
rootCause: `Missing or incorrect type annotation`,
fix: `Added type assertion/annotation`,
context: extractChangedLines(oldStr, newStr),
};
}
}
// --- CSS/style fix ---
if (ext === ".css" || ext === ".scss" || ext === ".vue" || ext === ".tsx" || ext === ".jsx") {
const oldProps = extractCSSProps(oldStr);
const newProps = extractCSSProps(newStr);
const changedProps = [...newProps.entries()].filter(([k, v]) => oldProps.get(k) !== v && oldProps.has(k));
if (changedProps.length > 0 && changedProps.length <= 3) {
const desc = changedProps.map(([k, v]) => `${k}: ${oldProps.get(k)}${v}`).join("; ");
return {
category: "style-fix",
summary: `CSS fix: ${changedProps.map(([k]) => k).join(", ")}`,
rootCause: desc,
fix: `Changed ${desc}`,
};
}
}
// --- Significant diff (catch-all for substantial edits) ---
const diffRatio = Math.abs(newStr.length - oldStr.length) / Math.max(oldStr.length, 1);
if (diffRatio > 0.3 && oldLines.length >= 3 && newLines.length >= 3) {
// Only log if there's meaningful structural change, not just additions
const removedLines = oldLines.filter(l => l.trim() && !newLines.some(nl => nl.trim() === l.trim()));
if (removedLines.length >= 2) {
return {
category: "refactor",
summary: `Significant refactor of ${path.basename("")}`,
rootCause: `${removedLines.length} lines replaced/restructured`,
fix: `Rewrote ${oldLines.length}${newLines.length} lines (${removedLines.length} removed)`,
context: removedLines.slice(0, 2).map(l => l.trim().slice(0, 50)).join("; "),
};
}
}
return null;
}
function extractChangedLines(oldStr, newStr) {
const oldLines = new Set(oldStr.split("\n").map(l => l.trim()).filter(Boolean));
const newLines = newStr.split("\n").map(l => l.trim()).filter(Boolean);
const added = newLines.filter(l => !oldLines.has(l));
return added.slice(0, 2).map(l => l.slice(0, 60)).join("; ");
}
function tokenizeCode(code) {
return code.replace(/[^\w$]/g, " ").split(/\s+/).filter(t => t.length > 0);
}
function findOperatorChange(oldStr, newStr) {
const operators = ["===", "!==", "==", "!=", ">=", "<=", ">>", "<<", "&&", "||", "??"];
for (const op of operators) {
if (oldStr.includes(op) && !newStr.includes(op)) {
for (const op2 of operators) {
if (op2 !== op && newStr.includes(op2) && !oldStr.includes(op2)) {
return { old: op, new: op2 };
}
}
}
}
return null;
}
function extractCSSProps(code) {
const props = new Map();
const matches = code.matchAll(/([\w-]+)\s*:\s*([^;}\n]+)/g);
for (const m of matches) {
props.set(m[1].trim(), m[2].trim());
}
return props;
}
main().catch(() => process.exit(0));
//# sourceMappingURL=post-write.js.map
+80
View File
@@ -0,0 +1,80 @@
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, readStdin, normalizePath } from "./shared.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
const hooksDir = path.join(wolfDir, "hooks");
const sessionFile = path.join(hooksDir, "_session.json");
const raw = await readStdin();
let input;
try {
input = JSON.parse(raw);
}
catch {
process.exit(0);
return;
}
const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
if (!filePath) {
process.exit(0);
return;
}
const normalizedFile = normalizePath(filePath);
// Skip tracking for .wolf/ internal files — they're infrastructure, not project files.
// Counting them inflates anatomy miss rates since .wolf/ is excluded from anatomy scanning.
const projectDir = normalizePath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
const relToProject = normalizedFile.startsWith(projectDir)
? normalizedFile.slice(projectDir.length).replace(/^\//, "")
: "";
if (relToProject.startsWith(".wolf/") || relToProject.startsWith(".wolf\\")) {
process.exit(0);
return;
}
const session = readJSON(sessionFile, {
session_id: "", files_read: {}, anatomy_hits: 0, anatomy_misses: 0,
repeated_reads_warned: 0,
});
// Check if already read this session
if (session.files_read[normalizedFile]) {
const prev = session.files_read[normalizedFile];
process.stderr.write(`⚡ OpenWolf: ${path.basename(normalizedFile)} was already read this session (~${prev.tokens} tokens). Consider using your existing knowledge of this file.\n`);
session.files_read[normalizedFile].count++;
session.repeated_reads_warned++;
writeJSON(sessionFile, session);
process.exit(0);
return;
}
// Check anatomy.md for this file
const anatomyContent = readMarkdown(path.join(wolfDir, "anatomy.md"));
const sections = parseAnatomy(anatomyContent);
let found = false;
for (const [sectionKey, entries] of sections) {
for (const entry of entries) {
// Build the full relative path from the section key + filename for accurate matching
const entryRelPath = normalizePath(path.join(sectionKey, entry.file));
if (normalizedFile.endsWith(entryRelPath) || normalizedFile.endsWith("/" + entryRelPath)) {
process.stderr.write(`📋 OpenWolf anatomy: ${entry.file}${entry.description} (~${entry.tokens} tok)\n`);
found = true;
break;
}
}
if (found)
break;
}
if (found) {
session.anatomy_hits++;
}
else {
session.anatomy_misses++;
}
// Record initial read entry (tokens will be updated in post-read)
session.files_read[normalizedFile] = {
count: 1,
tokens: 0,
first_read: new Date().toISOString(),
};
writeJSON(sessionFile, session);
process.exit(0);
}
main().catch(() => process.exit(0));
//# sourceMappingURL=pre-read.js.map
+121
View File
@@ -0,0 +1,121 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, readJSON, readMarkdown, readStdin } from "./shared.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
const raw = await readStdin();
let input;
try {
input = JSON.parse(raw);
}
catch {
process.exit(0);
return;
}
// For Edit tool, the meaningful content is old_string + new_string
const content = input.tool_input?.content ?? "";
const oldStr = input.tool_input?.old_string ?? "";
const newStr = input.tool_input?.new_string ?? "";
const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
const allContent = [content, oldStr, newStr].join("\n");
if (!allContent.trim()) {
process.exit(0);
return;
}
// 1. Cerebrum Do-Not-Repeat check
checkCerebrum(wolfDir, allContent);
// 2. Bug log: search for similar past bugs when editing code
// This fires when Claude is about to edit a file — if the edit looks like a fix
// (changing error handling, modifying catch blocks, etc.), check the bug log
if (filePath && (oldStr || content)) {
checkBugLog(wolfDir, filePath, oldStr, newStr, content);
}
process.exit(0);
}
function checkCerebrum(wolfDir, content) {
const cerebrumContent = readMarkdown(path.join(wolfDir, "cerebrum.md"));
const doNotRepeatSection = cerebrumContent.split("## Do-Not-Repeat")[1];
if (!doNotRepeatSection)
return;
const entries = doNotRepeatSection.split("## ")[0];
const lines = entries.split("\n").filter((l) => l.trim().startsWith("[") || l.trim().startsWith("-"));
for (const line of lines) {
const trimmed = line.trim().replace(/^[-*]\s*/, "").replace(/^\[[\d-]+\]\s*/, "");
if (!trimmed)
continue;
const patterns = [];
const quotedMatches = trimmed.match(/"([^"]+)"/g) || trimmed.match(/'([^']+)'/g) || trimmed.match(/`([^`]+)`/g);
if (quotedMatches) {
for (const qm of quotedMatches) {
patterns.push(qm.replace(/["'`]/g, ""));
}
}
const neverMatch = trimmed.match(/(?:never use|avoid|don't use|do not use)\s+(\w+)/i);
if (neverMatch)
patterns.push(neverMatch[1]);
for (const pattern of patterns) {
try {
const regex = new RegExp(`\\b${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
if (regex.test(content)) {
process.stderr.write(`⚠️ OpenWolf cerebrum warning: "${trimmed}" — check your code before proceeding.\n`);
}
}
catch { }
}
}
}
// Common words that appear in most code — must be excluded from similarity matching
const STOP_WORDS = new Set([
"error", "function", "return", "const", "this", "that", "with", "from",
"import", "export", "class", "interface", "type", "undefined", "null",
"true", "false", "string", "number", "object", "array", "value",
"file", "path", "name", "data", "response", "request", "result",
"should", "must", "does", "have", "been", "will", "would", "could",
"when", "then", "else", "each", "some", "every", "only",
]);
function checkBugLog(wolfDir, filePath, oldStr, newStr, content) {
const bugLogPath = path.join(wolfDir, "buglog.json");
if (!fs.existsSync(bugLogPath))
return;
const bugLog = readJSON(bugLogPath, { version: 1, bugs: [] });
if (bugLog.bugs.length === 0)
return;
const basename = path.basename(filePath);
// ONLY surface bugs that match the SAME file being edited.
// Cross-file matching is too noisy and risks misdirecting Claude.
const fileMatches = bugLog.bugs.filter(b => {
const bugBasename = path.basename(b.file);
return bugBasename === basename;
});
if (fileMatches.length === 0)
return;
// Further filter: require tag or error_message overlap with the edit content
const editText = (oldStr + " " + newStr + " " + content).toLowerCase();
const editTokens = tokenize(editText);
const relevant = fileMatches.filter(bug => {
// Check if any bug tag appears in the edit content
const tagHit = bug.tags.some(t => editText.includes(t.toLowerCase()));
if (tagHit)
return true;
// Check meaningful word overlap (excluding stop words)
const bugTokens = tokenize(bug.error_message + " " + bug.root_cause);
const overlap = [...editTokens].filter(t => bugTokens.has(t));
// Require at least 3 meaningful overlapping words
return overlap.length >= 3;
});
if (relevant.length === 0)
return;
// Surface as a FYI, not a directive — Claude should evaluate, not blindly apply
process.stderr.write(`📋 OpenWolf buglog: ${relevant.length} past bug(s) found for ${basename} — review for context, do NOT apply blindly:\n`);
for (const bug of relevant.slice(0, 2)) {
process.stderr.write(` [${bug.id}] "${bug.error_message.slice(0, 70)}"\n Cause: ${bug.root_cause.slice(0, 80)}\n Fix: ${bug.fix.slice(0, 80)}\n`);
}
}
function tokenize(text) {
return new Set(text.replace(/[^\w\s]/g, " ").split(/\s+/)
.filter(w => w.length > 3 && !STOP_WORDS.has(w.toLowerCase()))
.map(w => w.toLowerCase()));
}
main().catch(() => process.exit(0));
//# sourceMappingURL=pre-write.js.map
+77
View File
@@ -0,0 +1,77 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, timestamp, timeShort } from "./shared.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
// Clean up stale .tmp files left from failed atomic writes
try {
const files = fs.readdirSync(wolfDir);
for (const f of files) {
if (f.endsWith(".tmp")) {
try {
fs.unlinkSync(path.join(wolfDir, f));
}
catch { }
}
}
}
catch { }
const hooksDir = path.join(wolfDir, "hooks");
const sessionFile = path.join(hooksDir, "_session.json");
const now = new Date();
const sessionId = `session-${now.toISOString().slice(0, 10)}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
// Create fresh session state
writeJSON(sessionFile, {
session_id: sessionId,
started: timestamp(),
files_read: {},
files_written: [],
edit_counts: {},
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_warned: 0,
cerebrum_warnings: 0,
stop_count: 0,
});
// Append session header to memory.md
const memoryPath = path.join(wolfDir, "memory.md");
const header = `\n## Session: ${now.toISOString().slice(0, 10)} ${timeShort()}\n\n| Time | Action | File(s) | Outcome | ~Tokens |\n|------|--------|---------|---------|--------|\n`;
appendMarkdown(memoryPath, header);
// Check cerebrum freshness — remind Claude to learn
try {
const cerebrumPath = path.join(wolfDir, "cerebrum.md");
const cerebrumContent = fs.readFileSync(cerebrumPath, "utf-8");
const stat = fs.statSync(cerebrumPath);
const daysSinceUpdate = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24);
// Count actual entries (non-comment, non-empty lines in content sections)
const entryLines = cerebrumContent.split("\n").filter(l => {
const t = l.trim();
return t.startsWith("- ") || t.startsWith("* ") || (t.startsWith("[") && t.includes("]"));
});
if (entryLines.length < 3) {
process.stderr.write(`💡 OpenWolf: cerebrum.md has only ${entryLines.length} entries. Learn from this session — record user preferences, project conventions, and mistakes to .wolf/cerebrum.md.\n`);
}
else if (daysSinceUpdate > 3) {
process.stderr.write(`💡 OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(daysSinceUpdate)} days. Look for opportunities to add learnings this session.\n`);
}
}
catch { }
// Check buglog — remind if empty
try {
const buglogPath = path.join(wolfDir, "buglog.json");
const buglog = readJSON(buglogPath, { bugs: [] });
if (buglog.bugs.length === 0) {
process.stderr.write(`📋 OpenWolf: buglog.json is empty. If you encounter or fix any bugs, errors, or failed tests this session, log them to .wolf/buglog.json.\n`);
}
}
catch { }
// Increment total_sessions in token-ledger
const ledgerPath = path.join(wolfDir, "token-ledger.json");
const ledger = readJSON(ledgerPath, { version: 1, lifetime: { total_sessions: 0 } });
ledger.lifetime.total_sessions++;
writeJSON(ledgerPath, ledger);
process.exit(0);
}
main().catch(() => process.exit(0));
//# sourceMappingURL=session-start.js.map
+614
View File
@@ -0,0 +1,614 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as crypto from "node:crypto";
export function getWolfDir() {
// Prefer CLAUDE_PROJECT_DIR so hooks work even if CWD changes during a session
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
return path.join(projectDir, ".wolf");
}
/**
* Bail out silently if .wolf/ directory doesn't exist in the current project.
* Call this at the top of every hook to avoid crashes in non-OpenWolf projects.
*/
export function ensureWolfDir() {
const wolfDir = getWolfDir();
if (!fs.existsSync(wolfDir)) {
process.exit(0);
}
}
export function readJSON(filePath, fallback) {
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
catch {
return fallback;
}
}
export function writeJSON(filePath, data) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir))
fs.mkdirSync(dir, { recursive: true });
const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
try {
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
fs.renameSync(tmp, filePath);
}
catch {
// On Windows, rename can fail if another process holds a handle.
// Fall back to direct write and clean up the tmp file.
try {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
}
catch { }
try {
fs.unlinkSync(tmp);
}
catch { }
}
}
export function readMarkdown(filePath) {
try {
return fs.readFileSync(filePath, "utf-8");
}
catch {
return "";
}
}
export function appendMarkdown(filePath, line) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir))
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(filePath, line, "utf-8");
}
export function parseAnatomy(content) {
const sections = new Map();
let currentSection = "";
for (const line of content.split("\n")) {
const sm = line.match(/^## (.+)/);
if (sm) {
currentSection = sm[1].trim();
if (!sections.has(currentSection))
sections.set(currentSection, []);
continue;
}
if (!currentSection)
continue;
const em = line.match(/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/);
if (em) {
sections.get(currentSection).push({
file: em[1],
description: em[2] || "",
tokens: parseInt(em[3], 10),
});
}
}
return sections;
}
export function serializeAnatomy(sections, metadata) {
const lines = [
"# anatomy.md",
"",
`> Auto-maintained by OpenWolf. Last scanned: ${metadata.lastScanned}`,
`> Files: ${metadata.fileCount} tracked | Anatomy hits: ${metadata.hits} | Misses: ${metadata.misses}`,
"",
];
const keys = [...sections.keys()].sort();
for (const key of keys) {
lines.push(`## ${key}`);
lines.push("");
const entries = sections.get(key).sort((a, b) => a.file.localeCompare(b.file));
for (const e of entries) {
const desc = e.description ? `${e.description}` : "";
lines.push(`- \`${e.file}\`${desc} (~${e.tokens} tok)`);
}
lines.push("");
}
return lines.join("\n");
}
export function extractDescription(filePath) {
const MAX_DESC = 150;
const basename = path.basename(filePath);
const ext = path.extname(basename).toLowerCase();
const known = {
"package.json": "Node.js package manifest",
"tsconfig.json": "TypeScript configuration",
".gitignore": "Git ignore rules",
"README.md": "Project documentation",
"composer.json": "PHP package manifest",
"requirements.txt": "Python dependencies",
"schema.sql": "Database schema",
"Dockerfile": "Docker container definition",
"docker-compose.yml": "Docker Compose services",
"Cargo.toml": "Rust package manifest",
"go.mod": "Go module definition",
"Gemfile": "Ruby dependencies",
"pubspec.yaml": "Dart/Flutter package manifest",
};
if (known[basename])
return known[basename];
let content;
try {
const fd = fs.openSync(filePath, "r");
const buf = Buffer.alloc(12288); // 12KB
const n = fs.readSync(fd, buf, 0, 12288, 0);
fs.closeSync(fd);
content = buf.subarray(0, n).toString("utf-8");
}
catch {
return "";
}
if (!content.trim())
return "";
const cap = (s) => s.length <= MAX_DESC ? s : s.slice(0, MAX_DESC - 3) + "...";
// Markdown heading
if (ext === ".md" || ext === ".mdx") {
const m = content.match(/^#{1,2}\s+(.+)$/m);
if (m)
return cap(m[1].trim());
}
// HTML title
if (ext === ".html" || ext === ".htm") {
const m = content.match(/<title[^>]*>([^<]+)<\/title>/i);
if (m)
return cap(m[1].trim());
}
// JSDoc / PHPDoc / Javadoc — first meaningful line
const jm = content.match(/\/\*\*\s*\n?\s*\*?\s*(.+)/);
if (jm) {
const l = jm[1].replace(/\*\/$/, "").trim();
if (l && !l.startsWith("@") && l.length > 5)
return cap(l);
}
// Python docstring
if (ext === ".py") {
const dm = content.match(/^(?:#[^\n]*\n)*\s*(?:"""(.+?)"""|'''(.+?)''')/s);
if (dm) {
const first = (dm[1] || dm[2]).split("\n")[0].trim();
if (first && first.length > 3)
return cap(first);
}
}
// Rust doc comments
if (ext === ".rs") {
const lines = content.split("\n");
for (const line of lines.slice(0, 20)) {
const m = line.match(/^\s*(?:\/\/\/|\/\/!)\s*(.+)/);
if (m && m[1].length > 5)
return cap(m[1].trim());
}
}
// Go package comment
if (ext === ".go") {
const m = content.match(/\/\/\s*Package\s+\w+\s+(.*)/);
if (m)
return cap(m[1].trim());
}
// C# XML doc
if (ext === ".cs") {
const m = content.match(/<summary>\s*([\s\S]*?)\s*<\/summary>/);
if (m) {
const text = m[1].replace(/\/\/\/\s*/g, "").replace(/\s+/g, " ").trim();
if (text.length > 5)
return cap(text);
}
}
// Elixir @moduledoc
if (ext === ".ex" || ext === ".exs") {
const m = content.match(/@moduledoc\s+"""\s*\n\s*(.*)/);
if (m)
return cap(m[1].trim());
}
// Header comment (skip generic ones)
const hdrLines = content.split("\n");
for (const line of hdrLines.slice(0, 15)) {
const t = line.trim();
if (!t || t === "<?php" || t.startsWith("#!") || t.startsWith("namespace") || t.startsWith("use ") || t.startsWith("import ") || t.startsWith("from ") || t.startsWith("require") || t.startsWith("module "))
continue;
const cm = t.match(/^(?:\/\/|#|--)\s*(.+)/);
if (cm) {
const text = cm[1].trim();
const lower = text.toLowerCase();
if (text.length > 5 && !lower.startsWith("copyright") && !lower.startsWith("license") && !lower.startsWith("@") && !lower.startsWith("strict") && !lower.startsWith("generated") && !lower.startsWith("eslint-") && !lower.startsWith("nolint")) {
return cap(text);
}
}
if (!t.startsWith("//") && !t.startsWith("#") && !t.startsWith("/*") && !t.startsWith("*") && !t.startsWith("--"))
break;
}
// ─── PHP / Laravel ───────────────────────────────────────
if (ext === ".php") {
if (basename.endsWith(".blade.php")) {
const ext2 = content.match(/@extends\(\s*['"]([^'"]+)['"]\s*\)/);
const sections = (content.match(/@section\(\s*['"](\w+)['"]/g) || []).map(s => s.match(/['"](\w+)['"]/)?.[1]).filter(Boolean);
const parts = [];
if (ext2)
parts.push(`extends ${ext2[1]}`);
if (sections.length)
parts.push(`sections: ${sections.join(", ")}`);
return cap(parts.length ? `Blade: ${parts.join(", ")}` : "Blade template");
}
const classM = content.match(/class\s+(\w+)(?:\s+extends\s+(\w+))?/);
const className = classM?.[1] || "";
const parent = classM?.[2] || "";
const pubMethods = (content.match(/public\s+function\s+(\w+)/g) || [])
.map(m => m.match(/public\s+function\s+(\w+)/)?.[1])
.filter(n => n && n !== "__construct" && n !== "middleware");
if (basename.endsWith("Controller.php") || parent === "Controller") {
if (pubMethods.length > 0) {
const display = pubMethods.slice(0, 5).join(", ");
return cap(pubMethods.length > 5 ? `${display} + ${pubMethods.length - 5} more` : display);
}
}
if (parent === "Model" || parent === "Authenticatable") {
const parts = [];
const tbl = content.match(/\$table\s*=\s*['"]([^'"]+)['"]/);
if (tbl)
parts.push(`table: ${tbl[1]}`);
const fill = content.match(/\$fillable\s*=\s*\[([^\]]*)\]/s);
if (fill) {
const c = (fill[1].match(/['"]/g) || []).length / 2;
parts.push(`${Math.floor(c)} fields`);
}
const rels = (content.match(/\$this->(hasMany|hasOne|belongsTo|belongsToMany|morphMany|morphTo)\(/g) || []).length;
if (rels)
parts.push(`${rels} rels`);
return cap(parts.length ? `Model — ${parts.join(", ")}` : `Model: ${className}`);
}
if (basename.match(/^\d{4}_\d{2}_\d{2}/)) {
const create = content.match(/Schema::create\(\s*['"]([^'"]+)['"]/);
if (create)
return `Migration: create ${create[1]} table`;
const alter = content.match(/Schema::table\(\s*['"]([^'"]+)['"]/);
if (alter)
return `Migration: alter ${alter[1]} table`;
return "Database migration";
}
if (className && pubMethods.length > 0) {
const display = pubMethods.slice(0, 4).join(", ");
return cap(pubMethods.length > 4 ? `${className}: ${display} + ${pubMethods.length - 4} more` : `${className}: ${display}`);
}
}
// ─── TS/JS/React/Next.js ─────────────────────────────────
if (ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx" || ext === ".mjs" || ext === ".cjs") {
// React component
if (ext === ".tsx" || ext === ".jsx") {
const comp = content.match(/(?:export\s+(?:default\s+)?)?(?:function|const)\s+(\w+)/);
const parts = [];
if (comp)
parts.push(comp[1]);
const renders = [];
if (/<(?:form|Form)/i.test(content))
renders.push("form");
if (/<(?:table|Table|DataTable)/i.test(content))
renders.push("table");
if (/<(?:dialog|Dialog|Modal|Drawer)/i.test(content))
renders.push("modal");
if (renders.length)
parts.push(`renders ${renders.join(", ")}`);
if (parts.length)
return cap(parts.join(" — "));
}
// Next.js conventions
if (basename === "page.tsx" || basename === "page.js")
return "Next.js page component";
if (basename === "layout.tsx" || basename === "layout.js")
return "Next.js layout";
if (basename === "route.ts" || basename === "route.js") {
const methods = [...new Set((content.match(/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)/g) || [])
.map(m => m.match(/(GET|POST|PUT|PATCH|DELETE)/)?.[1]))].filter(Boolean);
return methods.length ? `Next.js API route: ${methods.join(", ")}` : "Next.js API route";
}
// Express/Fastify routes
const routeHits = content.match(/\.(get|post|put|patch|delete)\s*\(\s*['"`]/g);
if (routeHits && routeHits.length > 0) {
const methods = [...new Set(routeHits.map(r => r.match(/\.(get|post|put|patch|delete)/)?.[1]?.toUpperCase()))];
return cap(`API routes: ${methods.join(", ")} (${routeHits.length} endpoints)`);
}
// tRPC router
if (content.includes("createTRPCRouter") || content.includes("publicProcedure")) {
const procs = (content.match(/\.(query|mutation|subscription)\s*\(/g) || []).length;
return procs ? `tRPC router: ${procs} procedures` : "tRPC router";
}
// Zod schemas
if (content.includes("z.object") || content.includes("z.string")) {
const schemas = (content.match(/(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*z\./g) || [])
.map(s => s.match(/(?:const|let)\s+(\w+)/)?.[1]).filter(Boolean);
if (schemas.length)
return cap(`Zod schemas: ${schemas.slice(0, 4).join(", ")}${schemas.length > 4 ? ` + ${schemas.length - 4} more` : ""}`);
}
// Exports summary
const exports = (content.match(/export\s+(?:async\s+)?(?:function|class|const|interface|type|enum)\s+(\w+)/g) || [])
.map(e => e.match(/(\w+)$/)?.[1]).filter(Boolean);
if (exports.length > 0 && exports.length <= 5)
return `Exports ${exports.join(", ")}`;
if (exports.length > 5)
return cap(`Exports ${exports.slice(0, 4).join(", ")} + ${exports.length - 4} more`);
}
// ─── Python / Django / FastAPI / Flask ────────────────────
if (ext === ".py") {
// Django model
if (content.includes("models.Model")) {
const cls = content.match(/class\s+(\w+)\(.*models\.Model\)/);
const fields = (content.match(/^\s+\w+\s*=\s*models\.\w+/gm) || []).length;
return cap(`Model: ${cls?.[1] || "unknown"}, ${fields} fields`);
}
// FastAPI/Flask routes
if (content.includes("@router.") || content.includes("@app.")) {
const routes = (content.match(/@(?:router|app)\.(get|post|put|patch|delete)\s*\(/g) || []);
return cap(routes.length ? `API: ${routes.length} endpoints` : "API router");
}
// Pydantic
if (content.includes("BaseModel") && content.includes("Field(")) {
const cls = content.match(/class\s+(\w+)\(.*BaseModel\)/);
return cls ? `Pydantic: ${cls[1]}` : "Pydantic model";
}
// Celery
if (content.includes("@shared_task") || content.includes("@app.task")) {
const tasks = (content.match(/def\s+(\w+)/g) || []).map(m => m.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_"));
return cap(tasks.length ? `Celery tasks: ${tasks.join(", ")}` : "Celery task");
}
// Generic
const pyClass = content.match(/class\s+(\w+)/);
const funcs = (content.match(/def\s+(\w+)/g) || []).map(f => f.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_"));
if (pyClass && funcs.length > 0)
return cap(funcs.length > 4 ? `${pyClass[1]}: ${funcs.slice(0, 4).join(", ")} + ${funcs.length - 4} more` : `${pyClass[1]}: ${funcs.join(", ")}`);
if (funcs.length > 0)
return cap(funcs.slice(0, 4).join(", "));
}
// ─── Go ──────────────────────────────────────────────────
if (ext === ".go") {
const handlers = (content.match(/func\s+(\w+)\s*\(\s*\w+\s+http\.ResponseWriter/g) || [])
.map(m => m.match(/func\s+(\w+)/)?.[1]).filter(Boolean);
if (handlers.length)
return cap(`HTTP handlers: ${handlers.slice(0, 5).join(", ")}`);
const iface = content.match(/type\s+(\w+)\s+interface\s*\{/);
if (iface)
return `Interface: ${iface[1]}`;
const structM = content.match(/type\s+(\w+)\s+struct\s*\{/);
if (structM)
return `Struct: ${structM[1]}`;
const funcs = (content.match(/^func\s+(\w+)/gm) || []).map(m => m.match(/func\s+(\w+)/)?.[1]).filter(n => n && n[0] === n[0].toUpperCase());
if (funcs.length)
return cap(funcs.slice(0, 5).join(", "));
}
// ─── Rust ────────────────────────────────────────────────
if (ext === ".rs") {
const structM = content.match(/pub\s+struct\s+(\w+)/);
if (structM) {
const methods = (content.match(/pub\s+(?:async\s+)?fn\s+(\w+)/g) || []).map(m => m.match(/fn\s+(\w+)/)?.[1]).filter(Boolean);
return cap(methods.length ? `${structM[1]}: ${methods.slice(0, 4).join(", ")}` : `Struct: ${structM[1]}`);
}
const traitM = content.match(/pub\s+trait\s+(\w+)/);
if (traitM)
return `Trait: ${traitM[1]}`;
const enumM = content.match(/pub\s+enum\s+(\w+)/);
if (enumM)
return `Enum: ${enumM[1]}`;
const fns = (content.match(/pub\s+(?:async\s+)?fn\s+(\w+)/g) || []).map(m => m.match(/fn\s+(\w+)/)?.[1]).filter(Boolean);
if (fns.length)
return cap(fns.slice(0, 5).join(", "));
}
// ─── Java / Spring ───────────────────────────────────────
if (ext === ".java") {
const cls = content.match(/(?:public\s+)?class\s+(\w+)/);
const className = cls?.[1] || basename.replace(".java", "");
const annotations = (content.match(/@(RestController|Controller|Service|Repository|Component|Entity|Configuration)/g) || []).map(a => a.slice(1));
const mappings = (content.match(/@(?:Get|Post|Put|Patch|Delete|Request)Mapping/g) || []).length;
if (mappings)
return cap(`${annotations[0] || "Spring"}: ${className} (${mappings} endpoints)`);
if (annotations.length)
return `${annotations[0]}: ${className}`;
if (content.includes("@Entity"))
return `Entity: ${className}`;
const methods = (content.match(/public\s+(?:static\s+)?(?:\w+(?:<[\w,\s]+>)?)\s+(\w+)\s*\(/g) || [])
.map(m => m.match(/(\w+)\s*\(/)?.[1]).filter(n => n && n !== className);
if (methods.length)
return cap(`${className}: ${methods.slice(0, 4).join(", ")}`);
return className ? `Class: ${className}` : "";
}
// ─── Kotlin ──────────────────────────────────────────────
if (ext === ".kt" || ext === ".kts") {
const cls = content.match(/(?:data\s+)?class\s+(\w+)/);
if (content.match(/data\s+class/))
return `Data class: ${cls?.[1] || basename.replace(/\.kts?$/, "")}`;
if (content.includes("routing {"))
return "Ktor routing";
const fns = (content.match(/fun\s+(\w+)/g) || []).map(m => m.match(/fun\s+(\w+)/)?.[1]).filter(Boolean);
if (cls && fns.length)
return cap(`${cls[1]}: ${fns.slice(0, 4).join(", ")}`);
if (fns.length)
return cap(fns.slice(0, 5).join(", "));
}
// ─── C# / .NET ───────────────────────────────────────────
if (ext === ".cs") {
const cls = content.match(/(?:public\s+)?(?:partial\s+)?class\s+(\w+)(?:\s*:\s*(\w+))?/);
const className = cls?.[1] || basename.replace(".cs", "");
const parent = cls?.[2] || "";
if (parent === "Controller" || parent === "ControllerBase" || content.includes("[ApiController]")) {
const actions = (content.match(/\[Http(Get|Post|Put|Patch|Delete)\]/g) || []).map(a => a.match(/Http(\w+)/)?.[1]).filter(Boolean);
return cap(actions.length ? `API Controller: ${className} (${[...new Set(actions)].join(", ")})` : `Controller: ${className}`);
}
if (parent === "DbContext" || content.includes("DbSet<")) {
const sets = (content.match(/DbSet<(\w+)>/g) || []).map(s => s.match(/<(\w+)>/)?.[1]).filter(Boolean);
return cap(sets.length ? `DbContext: ${sets.join(", ")}` : `DbContext: ${className}`);
}
return className ? `Class: ${className}` : "";
}
// ─── Ruby / Rails ────────────────────────────────────────
if (ext === ".rb") {
const cls = content.match(/class\s+(\w+)(?:\s*<\s*(\w+(?:::\w+)?))?/);
const className = cls?.[1] || "";
const parent = cls?.[2] || "";
if (parent?.includes("Controller")) {
const actions = (content.match(/def\s+(index|show|new|create|edit|update|destroy|\w+)/g) || [])
.map(m => m.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_"));
return cap(actions.length ? `Controller: ${actions.join(", ")}` : `Controller: ${className}`);
}
if (parent === "ApplicationRecord" || parent === "ActiveRecord::Base")
return `Model: ${className}`;
if (basename.match(/^\d{14}_/)) {
const create = content.match(/create_table\s+:(\w+)/);
return create ? `Migration: create ${create[1]}` : "Database migration";
}
const methods = (content.match(/def\s+(\w+)/g) || []).map(m => m.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_"));
if (cls && methods.length)
return cap(`${className}: ${methods.slice(0, 4).join(", ")}`);
}
// ─── Swift ───────────────────────────────────────────────
if (ext === ".swift") {
if (content.includes(": View") || content.includes("some View")) {
const name = content.match(/struct\s+(\w+)\s*:\s*View/);
return name ? `SwiftUI view: ${name[1]}` : "SwiftUI view";
}
const proto = content.match(/protocol\s+(\w+)/);
if (proto)
return `Protocol: ${proto[1]}`;
const struct = content.match(/(?:public\s+)?struct\s+(\w+)/);
const cls = content.match(/(?:public\s+)?class\s+(\w+)/);
const name = struct?.[1] || cls?.[1] || "";
if (name)
return `${struct ? "Struct" : "Class"}: ${name}`;
}
// ─── Dart / Flutter ──────────────────────────────────────
if (ext === ".dart") {
if (content.includes("StatefulWidget") || content.includes("StatelessWidget")) {
const name = content.match(/class\s+(\w+)\s+extends\s+(?:Stateful|Stateless)Widget/);
return name ? `${content.includes("StatefulWidget") ? "Stateful" : "Stateless"} widget: ${name[1]}` : "Flutter widget";
}
const cls = content.match(/class\s+(\w+)/);
if (cls)
return `Class: ${cls[1]}`;
}
// ─── Vue / Svelte / Astro ────────────────────────────────
if (ext === ".vue") {
const name = content.match(/name:\s*['"]([^'"]+)['"]/);
const setup = content.includes("<script setup");
const parts = [];
if (name)
parts.push(name[1]);
if (setup)
parts.push("setup");
return cap(parts.length ? `Vue: ${parts.join(", ")}` : "Vue component");
}
if (ext === ".svelte")
return `Svelte: ${basename.replace(".svelte", "")}`;
if (ext === ".astro")
return `Astro: ${basename.replace(".astro", "")}`;
// ─── CSS / SCSS / Less ───────────────────────────────────
if (ext === ".css" || ext === ".scss" || ext === ".less") {
const rules = (content.match(/^[.#@][^\n{]+/gm) || []).length;
const vars = (content.match(/--[\w-]+\s*:/g) || []).length;
const parts = [];
if (rules)
parts.push(`${rules} rules`);
if (vars)
parts.push(`${vars} vars`);
return cap(parts.length ? `Styles: ${parts.join(", ")}` : "Stylesheet");
}
// ─── SQL ─────────────────────────────────────────────────
if (ext === ".sql") {
const creates = (content.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"']?(\w+)/gi) || [])
.map(m => m.match(/(?:TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?)([`"']?\w+)/i)?.[1]?.replace(/[`"']/g, "")).filter(Boolean);
if (creates.length)
return cap(`SQL: tables: ${creates.slice(0, 4).join(", ")}`);
}
// ─── Proto / GraphQL ─────────────────────────────────────
if (ext === ".proto") {
const msgs = (content.match(/message\s+(\w+)/g) || []).map(m => m.match(/message\s+(\w+)/)?.[1]).filter(Boolean);
const services = (content.match(/service\s+(\w+)/g) || []).map(m => m.match(/service\s+(\w+)/)?.[1]).filter(Boolean);
const parts = [];
if (msgs.length)
parts.push(`messages: ${msgs.slice(0, 3).join(", ")}`);
if (services.length)
parts.push(`services: ${services.join(", ")}`);
return cap(parts.length ? `Proto: ${parts.join(", ")}` : "");
}
if (ext === ".graphql" || ext === ".gql") {
const types = (content.match(/type\s+(\w+)/g) || []).map(m => m.match(/type\s+(\w+)/)?.[1]).filter(Boolean);
return cap(types.length ? `GraphQL: types: ${types.slice(0, 4).join(", ")}` : "GraphQL schema");
}
// ─── YAML ────────────────────────────────────────────────
if (ext === ".yaml" || ext === ".yml") {
if (content.includes("runs-on:")) {
const name = content.match(/^name:\s*(.+)$/m);
return cap(name ? `CI: ${name[1].trim()}` : "GitHub Actions workflow");
}
if (content.includes("apiVersion:") && content.includes("kind:")) {
const kind = content.match(/kind:\s*(\w+)/);
return cap(kind ? `K8s ${kind[1]}` : "Kubernetes manifest");
}
if (content.includes("services:") && (basename.includes("docker") || basename.includes("compose"))) {
const services = (content.match(/^\s{2}\w+:/gm) || []).length;
return `Docker Compose: ${services} services`;
}
}
// ─── TOML ────────────────────────────────────────────────
if (ext === ".toml") {
const desc = content.match(/^description\s*=\s*"([^"]+)"/m);
if (desc)
return cap(desc[1]);
}
// ─── Elixir ──────────────────────────────────────────────
if (ext === ".ex" || ext === ".exs") {
const mod = content.match(/defmodule\s+([\w.]+)/);
if (content.includes("Phoenix.LiveView"))
return cap(mod ? `LiveView: ${mod[1]}` : "Phoenix LiveView");
if (content.includes("Controller"))
return cap(mod ? `Phoenix controller: ${mod[1]}` : "Phoenix controller");
const fns = (content.match(/def\s+(\w+)/g) || []).map(m => m.match(/def\s+(\w+)/)?.[1]).filter(Boolean);
if (mod && fns.length)
return cap(`${mod[1]}: ${fns.slice(0, 4).join(", ")}`);
if (mod)
return mod[1];
}
// ─── Lua ─────────────────────────────────────────────────
if (ext === ".lua") {
const fns = (content.match(/function\s+(?:\w+[.:])?(\w+)/g) || []).map(m => m.match(/(\w+)\s*$/)?.[1]).filter(Boolean);
if (fns.length)
return cap(fns.slice(0, 5).join(", "));
}
// ─── Zig ─────────────────────────────────────────────────
if (ext === ".zig") {
const fns = (content.match(/pub\s+fn\s+(\w+)/g) || []).map(m => m.match(/fn\s+(\w+)/)?.[1]).filter(Boolean);
if (fns.length)
return cap(fns.slice(0, 5).join(", "));
}
// Last resort
const declM = content.match(/(?:function|class|const|interface|type|enum)\s+(\w+)/);
if (declM) {
const name = declM[1];
const methods = (content.match(/(?:public\s+)?(?:async\s+)?(?:function\s+|(?:get|set)\s+)(\w+)\s*\(/g) || [])
.map(m => m.match(/(\w+)\s*\(/)?.[1]).filter(n => n && n !== name && n !== "__construct" && n !== "constructor");
if (methods.length > 0 && methods.length <= 5)
return cap(`${name}: ${methods.join(", ")}`);
if (methods.length > 5)
return cap(`${name}: ${methods.slice(0, 3).join(", ")} + ${methods.length - 3} more`);
return `Declares ${name}`;
}
return "";
}
export function estimateTokens(text, type = "mixed") {
const ratio = type === "code" ? 3.5 : type === "prose" ? 4.0 : 3.75;
return Math.ceil(text.length / ratio);
}
export function timestamp() {
return new Date().toISOString();
}
export function timeShort() {
const d = new Date();
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
export function readStdin() {
return new Promise((resolve) => {
const chunks = [];
process.stdin.on("data", (chunk) => chunks.push(chunk));
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
// If no stdin data after 4s, resolve with whatever we have so far.
// On Windows, stdin delivery from Claude Code hooks can be slow.
setTimeout(() => resolve(chunks.length ? Buffer.concat(chunks).toString("utf-8") : "{}"), 4000);
});
}
export function normalizePath(p) {
return p.replace(/\\/g, "/");
}
//# sourceMappingURL=shared.js.map
+147
View File
@@ -0,0 +1,147 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, appendMarkdown, timeShort } from "./shared.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
const hooksDir = path.join(wolfDir, "hooks");
const sessionFile = path.join(hooksDir, "_session.json");
const session = readJSON(sessionFile, {
session_id: "",
started: "",
files_read: {},
files_written: [],
edit_counts: {},
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_warned: 0,
cerebrum_warnings: 0,
stop_count: 0,
});
session.stop_count++;
// Only write to ledger if there's been activity
const readCount = Object.keys(session.files_read).length;
const writeCount = session.files_written.length;
if (readCount === 0 && writeCount === 0) {
writeJSON(sessionFile, session);
process.exit(0);
return;
}
// Check for files edited many times without a buglog entry
checkForMissingBugLogs(wolfDir, session);
// Check if cerebrum was updated this session (it should be if there were edits)
checkCerebrumFreshness(wolfDir, session);
// Build session entry for ledger
const reads = Object.entries(session.files_read).map(([file, data]) => ({
file,
tokens_estimated: data.tokens,
was_repeated: data.count > 1,
anatomy_had_description: false, // simplified
}));
const writes = session.files_written.map((w) => ({
file: w.file,
tokens_estimated: w.tokens,
action: w.action,
}));
const inputTokens = reads.reduce((sum, r) => sum + r.tokens_estimated, 0);
const outputTokens = writes.reduce((sum, w) => sum + w.tokens_estimated, 0);
const sessionEntry = {
id: session.session_id,
started: session.started,
ended: new Date().toISOString(),
reads,
writes,
totals: {
input_tokens_estimated: inputTokens,
output_tokens_estimated: outputTokens,
reads_count: readCount,
writes_count: writeCount,
repeated_reads_blocked: session.repeated_reads_warned,
anatomy_lookups: session.anatomy_hits,
},
};
// Update token-ledger.json
const ledgerPath = path.join(wolfDir, "token-ledger.json");
const ledger = readJSON(ledgerPath, {
version: 1,
created_at: "",
lifetime: {
total_tokens_estimated: 0,
total_reads: 0,
total_writes: 0,
total_sessions: 0,
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_blocked: 0,
estimated_savings_vs_bare_cli: 0,
},
sessions: [],
daemon_usage: [],
waste_flags: [],
optimization_report: { last_generated: null, patterns: [] },
});
ledger.sessions.push(sessionEntry);
ledger.lifetime.total_reads += readCount;
ledger.lifetime.total_writes += writeCount;
ledger.lifetime.total_tokens_estimated += inputTokens + outputTokens;
ledger.lifetime.anatomy_hits += session.anatomy_hits;
ledger.lifetime.anatomy_misses += session.anatomy_misses;
ledger.lifetime.repeated_reads_blocked += session.repeated_reads_warned;
// Estimate savings: anatomy hits save ~200 tokens each, repeated reads blocked save their token count
const savedFromAnatomy = session.anatomy_hits * 200;
const savedFromRepeats = Object.values(session.files_read)
.filter((r) => r.count > 1)
.reduce((sum, r) => sum + r.tokens * (r.count - 1), 0);
ledger.lifetime.estimated_savings_vs_bare_cli += savedFromAnatomy + savedFromRepeats;
writeJSON(ledgerPath, ledger);
// Write a session summary line to memory.md if there was meaningful activity
if (writeCount > 0) {
try {
const uniqueFiles = new Set(session.files_written.map(w => path.basename(w.file)));
const fileList = [...uniqueFiles].slice(0, 5).join(", ");
const memoryPath = path.join(wolfDir, "memory.md");
appendMarkdown(memoryPath, `| ${timeShort()} | Session end: ${writeCount} writes across ${uniqueFiles.size} files (${fileList}) | ${readCount} reads | ~${inputTokens + outputTokens} tok |\n`);
}
catch { }
}
writeJSON(sessionFile, session);
process.exit(0);
}
/**
* Check if files were edited multiple times but buglog.json wasn't updated.
* Emit a stderr reminder so Claude sees it in the next turn.
*/
function checkForMissingBugLogs(wolfDir, session) {
if (!session.edit_counts)
return;
const multiEditFiles = Object.entries(session.edit_counts)
.filter(([, count]) => count >= 3)
.map(([file]) => path.basename(file));
if (multiEditFiles.length === 0)
return;
// Check if buglog was written to this session
const buglogWritten = session.files_written.some(w => w.file.includes("buglog.json"));
if (!buglogWritten) {
process.stderr.write(`⚠️ OpenWolf: Files edited 3+ times this session (${multiEditFiles.join(", ")}) but buglog.json was not updated. If you fixed bugs, please log them.\n`);
}
}
/**
* Check if cerebrum.md was updated recently. If it hasn't been updated in
* a while and there was significant activity, emit a gentle reminder.
*/
function checkCerebrumFreshness(wolfDir, session) {
const cerebrumPath = path.join(wolfDir, "cerebrum.md");
try {
const stat = fs.statSync(cerebrumPath);
const hoursSinceUpdate = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60);
// If cerebrum hasn't been updated in 24h+ and there were significant writes
if (hoursSinceUpdate > 24 && session.files_written.length >= 3) {
process.stderr.write(`💡 OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(hoursSinceUpdate)}h. Did you learn any user preferences, conventions, or gotchas this session? Consider updating .wolf/cerebrum.md.\n`);
}
}
catch {
// cerebrum.md doesn't exist, that's ok
}
}
main().catch(() => process.exit(0));
//# sourceMappingURL=stop.js.map
+9
View File
@@ -0,0 +1,9 @@
# Identity
- **Name:** netgazet
- **Role:** AI development assistant for netgazet
- **Tone:** Direct, concise, technically precise
- **Constraints:**
- Never modify .env or secret files without explicit user confirmation
- Never delete files without explicit user confirmation
- Always explain why before making architectural changes
+36
View File
@@ -0,0 +1,36 @@
# Memory
> Chronological action log. Hooks and AI append to this file automatically.
> Old sessions are consolidated by the daemon weekly.
## Session: 2026-06-03 15:15
| Time | Action | File(s) | Outcome | ~Tokens |
|------|--------|---------|---------|--------|
## Session: 2026-06-03 15:16
| Time | Action | File(s) | Outcome | ~Tokens |
|------|--------|---------|---------|--------|
| 15:16 | Created CLAUDE.md | — | ~587 |
| 15:16 | Session end: 1 writes across 1 files (CLAUDE.md) | 0 reads | ~628 tok |
| 15:17 | Session end: 1 writes across 1 files (CLAUDE.md) | 1 reads | ~1178 tok |
| 15:25 | Created ../../.claude/plans/in-the-example-folder-immutable-rossum.md | — | ~2254 |
| 15:29 | Created main.py | — | ~2847 |
| 15:30 | Implemented PDF→HTML converter (poppler-utils, stdlib only) | main.py | 32 pages rendered, output/ ≈ 20MB | ~3000 |
| 15:30 | Generated output/ site | output/index.html, output/styles.css, output/NETgazet-07_okt2024/*, output/NETgazet-08_apr2025/* | 2 issues × 16 pages | ~500 |
| 15:30 | Session end: 3 writes across 3 files (CLAUDE.md, in-the-example-folder-immutable-rossum.md, main.py) | 4 reads | ~6439 tok |
| 15:34 | Session end: 3 writes across 3 files (CLAUDE.md, in-the-example-folder-immutable-rossum.md, main.py) | 4 reads | ~6439 tok |
| 15:35 | Session end: 3 writes across 3 files (CLAUDE.md, in-the-example-folder-immutable-rossum.md, main.py) | 4 reads | ~6439 tok |
## Session: 2026-06-03 15:52
| Time | Action | File(s) | Outcome | ~Tokens |
|------|--------|---------|---------|--------|
## Session: 2026-06-03 15:55
| Time | Action | File(s) | Outcome | ~Tokens |
|------|--------|---------|---------|--------|
| 15:57 | Created README.md | — | ~970 |
| 15:57 | Created .gitignore | — | ~55 |
+597
View File
@@ -0,0 +1,597 @@
# OpenWolf Reframe — UI Framework Knowledge Base
When the user asks to change, pick, migrate, or "reframe" their UI framework, use this file to guide the conversation and generate the right migration prompt.
## Decision Questions
Ask these in order. Stop early if the answer narrows to 1-2 frameworks.
1. **What framework/library does your project currently use?** (React, Vue, Svelte, plain HTML, etc.)
2. **What's your priority?** (a) Stunning animations, (b) Fast setup / ship quickly, (c) Full design control, (d) Accessibility-first, (e) Business/enterprise look
3. **Do you use Tailwind CSS already?** (Yes → most options work. No → Chakra UI or DaisyUI are easier without it)
4. **Light mode, dark mode, or both?**
5. **Is this a landing page, a dashboard/app, or both?**
## Quick Selection Guide
| Priority | Recommended |
|----------|-------------|
| Fastest setup | DaisyUI, Preline UI |
| Stunning animations | Aceternity UI, Magic UI |
| Maximum control | shadcn/ui, Headless UI |
| Most free components | Origin UI (400+) |
| Multi-framework (React + Vue + Svelte) | Park UI, DaisyUI, Flowbite |
| AI product aesthetic | Cult UI |
| Polished defaults + accessibility | HeroUI, Chakra UI |
| Business/enterprise | Flowbite, shadcn/ui |
## Comparison Matrix
| Framework | Styling | Animation | Setup | Best For | Cost |
|-----------|---------|-----------|-------|----------|------|
| shadcn/ui | Tailwind + Radix | Minimal | Medium | Full apps, dashboards | Free |
| Aceternity UI | Tailwind + Framer Motion | Heavy, cinematic | Easy (copy-paste) | Animated landing pages | Free / Pro |
| Magic UI | Tailwind + Motion | Purposeful, polished | Easy (shadcn CLI) | SaaS marketing | Free / Pro |
| DaisyUI | Tailwind plugin | CSS transitions | Very easy | Rapid prototyping | Free |
| HeroUI | Tailwind + React Aria | Smooth built-in | Easy | React apps, accessible | Free |
| Chakra UI | CSS-in-JS (Emotion) | Built-in transitions | Easy | Themed React apps | Free |
| Flowbite | Tailwind plugin | CSS transitions | Very easy | Multi-framework, business | Free / Pro |
| Preline UI | Tailwind plugin | CSS + minimal JS | Very easy | Speed-focused builds | Free / Pro |
| Park UI | Tailwind + Ark UI | Minimal | Medium | Multi-framework, design systems | Free |
| Origin UI | Tailwind + shadcn | Minimal | Easy (copy-paste) | Max component variety | Free |
| Headless UI | Custom Tailwind | Custom (Transition) | Medium-Hard | Full design control | Free |
| Cult UI | Tailwind + shadcn | Modern, subtle | Medium | AI apps, full-stack | Free |
---
## Framework Prompts
After the user selects a framework, use the corresponding prompt below. **Adapt it to the user's actual project** — replace generic references with their real file structure, existing routes, and components from `.wolf/anatomy.md`.
---
### shadcn/ui
**Stack:** React, Tailwind CSS, Radix UI
**Install:** `npx shadcn@latest init`
**Site:** ui.shadcn.com
```
Build a modern, minimalist landing page using Next.js 14+ (App Router), TypeScript, Tailwind CSS, and shadcn/ui components.
ARCHITECTURE:
- Use Next.js App Router with server components by default, client components only when interactivity is needed
- Organize: /app for routes, /components/ui for shadcn primitives, /components/sections for page sections, /lib for utilities
- Use CSS variables for theming via shadcn's built-in system
DESIGN PRINCIPLES:
- Minimalist with generous whitespace (py-24 to py-32 section spacing)
- Max-width container (max-w-6xl mx-auto) with responsive padding
- Typography hierarchy: text-5xl/6xl hero → text-3xl section titles → text-lg body
- Muted color palette with ONE bold accent color for CTAs
- Subtle micro-interactions on hover (scale, opacity transitions)
- Dark mode support via shadcn's theme provider
SECTIONS TO BUILD:
1. Navbar — sticky, transparent-to-solid on scroll, logo left, nav center, CTA button right
2. Hero — large headline (max 8 words), subtext (max 2 lines), primary CTA button + secondary ghost button, optional hero image or abstract illustration
3. Social proof — logo cloud of 5-6 partner/client logos in grayscale
4. Features — 3-column bento grid with icon, title, description per card using shadcn Card component
5. How it works — 3-step numbered process with connecting line
6. Testimonials — carousel or grid of 3 testimonial cards with avatar, quote, name, role
7. Pricing — 3-tier pricing table using shadcn Card, highlight the recommended plan with ring/border accent
8. FAQ — accordion using shadcn Accordion component
9. CTA — full-width section with headline, subtext, and primary action button
10. Footer — 4-column grid (brand, product links, company links, legal), copyright at bottom
COMPONENT USAGE:
- Button (primary, secondary, ghost, outline variants)
- Card, CardHeader, CardContent, CardFooter
- Accordion, AccordionItem, AccordionTrigger, AccordionContent
- Badge for labels/tags
- Separator for dividers
- NavigationMenu for navbar
CODE QUALITY:
- Fully responsive (mobile-first)
- Semantic HTML (section, nav, main, footer)
- Accessible (proper aria labels, keyboard navigation)
- Performant (lazy load images, optimize fonts via next/font)
- Clean component separation (each section is its own component)
```
---
### Aceternity UI
**Stack:** React, Next.js, Tailwind CSS, Framer Motion
**Install:** Copy-paste or `npx shadcn@latest add [component]`
**Site:** ui.aceternity.com
```
Build a visually stunning, animation-rich landing page using Next.js 14+, TypeScript, Tailwind CSS, and Aceternity UI components with Framer Motion.
ARCHITECTURE:
- Next.js App Router with client components for animated sections
- Organize: /app, /components/aceternity (copied components), /components/sections, /lib
- Install Framer Motion for animation engine
DESIGN PRINCIPLES:
- Dark theme as primary (bg-black/bg-slate-950 backgrounds)
- Dramatic contrast with glowing accents (cyan, purple, or emerald)
- Layered depth via gradients, glows, and glassmorphism
- Cinematic scroll-triggered animations with staggered reveals
- Generous padding (py-32+), large typography (text-6xl/7xl hero)
- Noise/grain texture overlays for visual richness
SECTIONS TO BUILD WITH ACETERNITY COMPONENTS:
1. Navbar — floating navbar with backdrop blur, animated on scroll
2. Hero — use Aceternity's "Spotlight" or "Lamp" effect as background, massive headline with TextGenerateEffect or TypewriterEffect, gradient text on key words, two CTAs with HoverBorderGradient buttons
3. Logo cloud — use InfiniteMovingCards for auto-scrolling partner logos
4. Features — BentoGrid layout with animated cards, each card has BackgroundGradient or CardSpotlight hover effect
5. Product showcase — use Aceternity's 3DCard or ParallaxScroll for product screenshots/mockups
6. Testimonials — AnimatedTestimonials or InfiniteMovingCards with quote, avatar, name
7. How it works — use TracingBeam component for a connected step-by-step flow
8. Pricing — dark cards with GlowingStarsBackground, highlighted tier has animated border (MovingBorder)
9. CTA — full-width with BackgroundBeams or Meteors effect behind headline and button
10. Footer — clean 4-column layout with subtle separator
ANIMATION GUIDELINES:
- Page load: stagger hero elements (title → subtitle → buttons) with 0.15s delay each
- Scroll: use Framer Motion's whileInView for reveal animations (fade up + slight scale)
- Hover: cards lift (translateY -4px) with glow intensification
- Keep animations performant (use transform/opacity only, will-change where needed)
- Respect prefers-reduced-motion
CODE QUALITY:
- Mobile-first responsive design
- Lazy load heavy animation components
- Each section is a standalone component file
- Accessible despite heavy animation (aria labels, focus states)
```
---
### Magic UI
**Stack:** React, TypeScript, Tailwind CSS, Motion (Framer Motion)
**Install:** `npx shadcn@latest add [component]` (shadcn CLI compatible)
**Site:** magicui.design
```
Build a polished, trend-forward SaaS landing page using Next.js 14+, TypeScript, Tailwind CSS, and Magic UI animated components.
ARCHITECTURE:
- Next.js App Router, server components where possible
- Magic UI components installed via shadcn CLI into /components/magicui
- Section components in /components/sections
- Shared config in /lib (cn utility, site config)
DESIGN PRINCIPLES:
- Clean and modern, inspired by Linear/Vercel aesthetic
- Light or dark mode with smooth toggle transition
- Restrained animation — purposeful motion that guides attention, not distracts
- High contrast text, muted backgrounds (slate-50 light / slate-950 dark)
- Monospace or geometric sans-serif for headings, clean sans for body
- ONE signature color (blue-500, violet-500, or emerald-500) for accents
SECTIONS TO BUILD WITH MAGIC UI COMPONENTS:
1. Navbar — clean horizontal nav, use MagicUI ShimmerButton for primary CTA
2. Hero — large headline with AnimatedGradientText on key phrase, NumberTicker for a live stat ("10,000+ users"), DotPattern or GridPattern as subtle background, two buttons (ShimmerButton primary + outline secondary)
3. Social proof — Marquee component for auto-scrolling logos horizontally
4. Features — use MagicCard components in a 3-column grid, each with icon + title + description, subtle hover glow effect
5. Bento showcase — BentoGrid layout showing product capabilities, mix of large and small tiles with different content types (text, mini-demos, stats)
6. Metrics — AnimatedList showing live activity feed OR NumberTicker showing key stats (users, uptime, speed)
7. Testimonials — Marquee of testimonial cards (vertical or horizontal scroll)
8. Pricing — 3 tiers with clean card design, NeonGradientCard for featured plan, BorderBeam on hover
9. CTA — centered layout with AnimatedShinyText headline, Particles or Meteors background effect
10. Footer — minimal 3-4 column grid, muted text, social icons
ANIMATION STRATEGY:
- Use BlurIn / FadeIn for section entrance animations
- NumberTicker for any statistics or metrics
- Marquee for any horizontally scrolling content
- Keep transitions under 600ms, use ease-out curves
- Mobile: reduce or disable particle/meteor effects for performance
CODE QUALITY:
- Fully responsive with mobile breakpoints
- Semantic HTML throughout
- next/font for optimized typography
- Image optimization via next/image
- Component-per-section architecture
```
---
### DaisyUI
**Stack:** Tailwind CSS plugin (framework-agnostic)
**Install:** `npm i -D daisyui` + add to tailwind.config
**Site:** daisyui.com
```
Build a clean, professional landing page using Tailwind CSS and DaisyUI component classes.
ARCHITECTURE:
- Framework of choice with Tailwind CSS + DaisyUI plugin
- Use DaisyUI's semantic class system (btn, card, hero, navbar, etc.)
- Select a DaisyUI theme (or create custom) in tailwind.config.js
- Organize by sections in /components/sections
DESIGN PRINCIPLES:
- Pick ONE DaisyUI theme as base (e.g., "winter" for clean light, "night" for dark, "corporate" for professional)
- Customize theme colors in tailwind.config to match brand
- Minimalist layout with clean spacing (p-8 to p-16 sections)
- Let DaisyUI's built-in design system handle consistency
- Typography via DaisyUI's prose class for content sections
SECTIONS TO BUILD WITH DAISYUI CLASSES:
1. Navbar — "navbar" component with "dropdown" for mobile menu, "btn btn-primary" for CTA
2. Hero — "hero" component with "hero-content", large headline, subtext, "btn btn-primary" + "btn btn-ghost"
3. Social proof — logo row using "flex" with "opacity-50 grayscale" on images
4. Features — "card" components in a responsive grid, each with "card-body", uses "bg-base-200" for contrast
5. Stats — "stats" component with "stat" items showing key metrics
6. Steps — "steps" component (horizontal on desktop, vertical on mobile)
7. Testimonials — "chat" component styled as testimonials OR "card" grid with "avatar"
8. Pricing — "card" components with "badge badge-primary" for recommended plan
9. FAQ — "collapse" or "collapse-plus" components for expandable Q&A
10. Footer — "footer" component with 3-4 columns, "footer-title" for headings
CODE QUALITY:
- Fully responsive using Tailwind breakpoints
- Zero custom CSS needed (DaisyUI handles it)
- Accessible by default (DaisyUI follows ARIA patterns)
- Fast load times (CSS-only, no JS shipped)
- Works with any framework or plain HTML
```
---
### HeroUI (formerly NextUI)
**Stack:** React, Tailwind CSS, React Aria
**Install:** `npm i @heroui/react`
**Site:** heroui.com
```
Build an elegant, accessible landing page using Next.js 14+, TypeScript, Tailwind CSS, and HeroUI components.
ARCHITECTURE:
- Next.js App Router with HeroUI Provider wrapping the app
- Import only needed components from @heroui/react (tree-shakeable)
- Organize: /app, /components/sections, /lib
- Use HeroUI's built-in theme system for consistent styling
DESIGN PRINCIPLES:
- Soft, modern aesthetic with rounded corners and smooth surfaces
- Light mode primary with dark mode support via HeroUI's theme toggle
- Subtle shadows and blur effects (HeroUI's built-in elevation system)
- Clean sans-serif typography, generous line heights
- Smooth transitions on all interactive elements (HeroUI provides these by default)
SECTIONS TO BUILD WITH HEROUI COMPONENTS:
1. Navbar — HeroUI Navbar with NavbarBrand, NavbarContent, NavbarItem, NavbarMenuToggle for mobile
2. Hero — large headline, descriptive subtext, Button (color="primary" size="lg") + Button (variant="bordered")
3. Social proof — Avatar group for client logos or Chip components for trust badges
4. Features — Card components in grid, each with CardHeader, CardBody, use Divider between sections
5. Product demo — Tabs component to show different product views/features interactively
6. Testimonials — Card grid with User component (avatar + name + role) inside each card
7. Pricing — Card components with Chip for "Popular" badge, Listbox for feature lists
8. FAQ — Accordion component with AccordionItem for each question
9. CTA — centered section with Input + Button combo for email capture
10. Footer — clean grid with Link components, Divider above copyright
CODE QUALITY:
- Fully responsive with HeroUI's responsive props
- Accessible by default (React Aria foundation)
- Server-side rendering compatible
- Optimized bundle (import individual components)
- TypeScript strict mode
```
---
### Chakra UI
**Stack:** React, Emotion (CSS-in-JS)
**Install:** `npm i @chakra-ui/react`
**Site:** chakra-ui.com
```
Build a clean, accessible landing page using Next.js 14+, TypeScript, and Chakra UI v3.
ARCHITECTURE:
- Next.js App Router with ChakraProvider at root
- Use Chakra's design tokens and theme extension for brand customization
- Organize: /app, /components/sections, /theme (custom theme config)
DESIGN PRINCIPLES:
- Clean and warm aesthetic using Chakra's polished defaults
- Consistent spacing using Chakra's space scale (4, 8, 12, 16, 20, 24)
- Typography: use Chakra's Heading and Text components
- Color mode support (light/dark) via useColorMode hook
- Accessible-first: every component follows WAI-ARIA by default
SECTIONS TO BUILD WITH CHAKRA COMPONENTS:
1. Navbar — Flex with HStack, IconButton for mobile menu, Button for CTA
2. Hero — VStack centered, Heading size="4xl", Text, HStack with two Buttons
3. Social proof — HStack/Wrap of grayscale Image components
4. Features — SimpleGrid columns={{base:1, md:3}} of VStack cards with Icon, Heading, Text
5. Stats — Stat component group with StatLabel, StatNumber, StatHelpText
6. Testimonials — SimpleGrid of Card with Avatar, Text (quote), Heading (name)
7. Pricing — SimpleGrid of Card, Badge for "Popular", List for features
8. FAQ — Accordion with AccordionItem, AccordionButton, AccordionPanel
9. CTA — Box with bg gradient, VStack centered, Heading, Text, Button
10. Footer — SimpleGrid columns={{base:2, md:4}} with VStack, Heading, Link
THEME EXTENSION:
const theme = extendTheme({
colors: { brand: { 50: '...', 500: '...', 900: '...' } },
fonts: { heading: 'Your Font', body: 'Your Font' },
components: { Button: { defaultProps: { colorScheme: 'brand' } } }
})
CODE QUALITY:
- Fully responsive using Chakra's responsive array/object syntax
- Dark mode ready with useColorModeValue
- Accessible by default
- TypeScript throughout with proper prop types
```
---
### Flowbite
**Stack:** Tailwind CSS (React, Vue, Svelte, Angular)
**Install:** `npm i flowbite flowbite-react`
**Site:** flowbite.com
```
Build a professional, conversion-optimized landing page using Next.js 14+, Tailwind CSS, and Flowbite React components.
ARCHITECTURE:
- Next.js App Router with Flowbite React components
- Import from flowbite-react (Button, Card, Navbar, Footer, etc.)
- Flowbite Tailwind plugin in tailwind.config for interactive styles
DESIGN PRINCIPLES:
- Business-professional aesthetic (clean, trustworthy, conversion-focused)
- Cool neutral palette (slate/gray) with strong brand accent
- Focus on readability and clear information hierarchy
SECTIONS TO BUILD WITH FLOWBITE COMPONENTS:
1. Navbar — Flowbite Navbar with Navbar.Brand, Navbar.Toggle, Navbar.Collapse
2. Hero — Jumbotron-style, large Heading, Button group
3. Social proof — row of client logos
4. Features — Card components in responsive grid with icon badge, title, description
5. Content — alternating image + text rows
6. Testimonials — Blockquote or Card with Rating component for stars
7. Pricing — Card with List for features, Badge for highlighted plan
8. FAQ — Accordion component
9. Newsletter CTA — TextInput + Button in a styled Banner
10. Footer — Flowbite Footer with Footer.LinkGroup, Footer.Brand, Footer.Copyright
CODE QUALITY:
- Fully responsive (components are responsive by default)
- Accessible (follows WAI-ARIA)
- Works server-side with Next.js
- Production-ready component patterns
```
---
### Preline UI
**Stack:** Tailwind CSS (HTML-first, React/Vue plugins)
**Install:** `npm i preline` + add plugin to tailwind.config
**Site:** preline.co
```
Build a sleek, modern landing page using Next.js 14+, Tailwind CSS, and Preline UI components.
ARCHITECTURE:
- Next.js App Router with Preline's Tailwind plugin
- Use Preline's HTML component patterns (class-based, minimal JS)
- Import preline JS for interactive components (dropdowns, modals, accordions)
DESIGN PRINCIPLES:
- Modern SaaS aesthetic (clean lines, airy spacing, professional)
- Neutral base (white/slate) with vibrant accent for CTAs
- Large section padding (py-16 to py-24)
SECTIONS TO BUILD WITH PRELINE PATTERNS:
1. Navbar — header component with mega menu support, sticky on scroll
2. Hero — centered or split layout, large headline with gradient text, two CTA buttons
3. Social proof — logo ticker or static row with grayscale filter
4. Features — icon + text cards in 3-column grid
5. Tabs showcase — tab component showing different product features per tab
6. Testimonials — grid or slider of testimonial cards
7. Pricing — pricing table pattern with toggle for monthly/annual
8. Stats — counter section with large numbers + labels
9. FAQ — accordion component with smooth expand/collapse
10. CTA + Footer — email input + button CTA above multi-column footer
CODE QUALITY:
- Fully responsive with Tailwind breakpoints
- Accessible (follows ARIA standards)
- Lightweight (CSS + minimal JS)
- Works with any framework via HTML classes
```
---
### Park UI
**Stack:** React/Vue/Solid, Ark UI (headless), Tailwind CSS
**Install:** Copy-paste components (shadcn-style)
**Site:** park-ui.com
```
Build a refined, design-system-driven landing page using Next.js 14+, TypeScript, Tailwind CSS, and Park UI components.
ARCHITECTURE:
- Next.js App Router with Park UI components (Ark UI + Tailwind CSS)
- Copy-paste component installation (like shadcn)
- Organize: /app, /components/ui (Park UI primitives), /components/sections
DESIGN PRINCIPLES:
- Polished and systematic (design-system quality)
- Neutral, sophisticated palette with deliberate accent usage
- Consistent spacing and sizing via Park UI's token scale
- Light/dark mode via Park UI's built-in theme tokens
SECTIONS TO BUILD WITH PARK UI COMPONENTS:
1. Navbar — navigation components, clean horizontal layout, mobile drawer
2. Hero — Heading + Text with proper token sizing, Button pair (solid + outline)
3. Social proof — subtle logo row with muted styling
4. Features — Card components in grid with Icon, Heading, Text
5. Tabs showcase — Tabs component for interactive product feature display
6. Testimonials — Card-based layout with Avatar + quote
7. Pricing — Card grid with Badge for recommended tier
8. FAQ — Accordion component (Ark UI primitive, styled via Park UI)
9. CTA — centered section with Input + Button
10. Footer — grid layout with Link groups and Separator
CODE QUALITY:
- Fully responsive and accessible
- Ark UI headless primitives for rock-solid accessibility
- TypeScript strict mode
- Component-per-section architecture
```
---
### Origin UI
**Stack:** React, Tailwind CSS, shadcn/ui foundation
**Install:** Copy-paste (400+ free components)
**Site:** originui.com
```
Build a polished, component-rich landing page using Next.js 14+, TypeScript, Tailwind CSS, and Origin UI components (built on shadcn/ui).
ARCHITECTURE:
- Next.js App Router with shadcn/ui base + Origin UI component variants
- Copy-paste Origin UI components into /components/ui
- Origin UI extends shadcn patterns with 400+ variant options
DESIGN PRINCIPLES:
- Professional and versatile (many style variants per component)
- Pick a cohesive subset of variants that match your brand
- Consistent spacing and typography aligned with shadcn's token system
- Focus on component quality and consistency over flashy animation
SECTIONS TO BUILD:
1. Navbar — choose from Origin UI's multiple navbar variants
2. Hero — select a hero variant (centered, split, with background pattern)
3. Social proof — logo cloud variant
4. Features — pick feature section variant (grid, list, bento, with images)
5. Content — alternating image/text sections
6. Testimonials — choose testimonial variant (cards, quotes, carousel)
7. Pricing — select pricing table variant (simple, comparison, with toggle)
8. FAQ — accordion variant
9. CTA — pick CTA variant (centered, with input, split layout)
10. Footer — choose footer variant (simple, multi-column, with newsletter)
CODE QUALITY:
- Fully responsive (all components are mobile-first)
- Accessible (shadcn/ui + Radix UI foundation)
- TypeScript throughout
- Minimal dependencies
```
---
### Headless UI
**Stack:** React or Vue, Tailwind CSS (by Tailwind Labs)
**Install:** `npm i @headlessui/react`
**Site:** headlessui.com
```
Build a custom-designed, fully accessible landing page using Next.js 14+, TypeScript, Tailwind CSS, and Headless UI for interactive components.
ARCHITECTURE:
- Next.js App Router with Tailwind CSS for all styling
- Headless UI only for interactive behavior (menus, dialogs, tabs, disclosure, transitions)
- All visual design is custom Tailwind — Headless UI provides zero styling
- Organize: /app, /components/ui (custom-styled wrappers), /components/sections
DESIGN PRINCIPLES:
- Completely custom aesthetic — you control every pixel
- Minimalist, editorial-quality design (think Stripe, Linear, Vercel)
- Monochrome base with ONE signature accent color
- Large whitespace, precise typography, meticulous alignment
- Every interactive element is keyboard-navigable and screen-reader friendly
SECTIONS TO BUILD:
1. Navbar — custom with Headless UI Popover for dropdowns, Disclosure for mobile menu
2. Hero — pure Tailwind, dramatic typography, clean CTAs
3. Social proof — pure Tailwind logo row
4. Features — custom card grid, optional Tab component for tabbed showcase
5. Product showcase — TabGroup for interactive product views
6. Testimonials — pure Tailwind card grid
7. Pricing — RadioGroup for plan selection, Switch for monthly/annual toggle
8. FAQ — Disclosure component for accessible expand/collapse
9. CTA — pure Tailwind centered section
10. Footer — pure Tailwind grid layout
HEADLESS UI COMPONENTS TO LEVERAGE:
- Popover — navbar dropdowns
- Disclosure — mobile menu, FAQ items
- Dialog — modals/overlays
- Transition — smooth enter/leave animations
- RadioGroup — plan selection
- Switch — toggles
- TabGroup — tabbed content
CODE QUALITY:
- 100% accessible (Headless UI's core guarantee)
- Fully responsive custom Tailwind design
- Keyboard navigation on all interactive elements
- TypeScript with proper generics
- Zero design dependencies — total creative control
```
---
### Cult UI
**Stack:** React, Next.js, Tailwind CSS, shadcn/ui foundation
**Install:** Copy-paste or CLI
**Site:** cult-ui.com
```
Build a modern, AI-forward landing page using Next.js 14+, TypeScript, Tailwind CSS, and Cult UI components.
ARCHITECTURE:
- Next.js App Router with Cult UI components (extends shadcn/ui)
- Full-stack ready (Cult UI includes backend-aware patterns)
- Use Cult UI's AI-specific blocks if building an AI product
DESIGN PRINCIPLES:
- Tech-forward, developer-centric aesthetic
- Dark mode primary with high-contrast accents
- Modern gradients and subtle glow effects
- Clean code-like typography (mono fonts for accents)
- Purposeful animation that communicates AI/tech sophistication
SECTIONS TO BUILD:
1. Navbar — modern floating nav with glass effect
2. Hero — bold headline with animated text effect, live demo or interactive element, gradient mesh background
3. Features — AI blocks for feature cards, animated icons or micro-interactions
4. Live demo — interactive component showing product in action
5. Code showcase — syntax-highlighted code examples using Cult UI's code block components
6. Testimonials — modern card layout with subtle animation
7. Pricing — clean tier comparison with feature matrix
8. Open source / community — GitHub stars counter, contributor avatars
9. CTA — compelling action section with email input or waitlist signup
10. Footer — developer-friendly footer with docs links, API reference, status page
CODE QUALITY:
- Full-stack patterns (API routes + client components)
- TypeScript strict mode throughout
- Accessible interactive components
- Production-ready architecture patterns
```
+4
View File
@@ -0,0 +1,4 @@
{
"suggestions": [],
"generated_at": null
}
+261
View File
@@ -0,0 +1,261 @@
{
"version": 1,
"created_at": "2026-06-03T13:15:37.485Z",
"lifetime": {
"total_tokens_estimated": 21123,
"total_reads": 14,
"total_writes": 11,
"total_sessions": 4,
"anatomy_hits": 4,
"anatomy_misses": 10,
"repeated_reads_blocked": 0,
"estimated_savings_vs_bare_cli": 800
},
"sessions": [
{
"id": "session-2026-06-03-1516",
"started": "2026-06-03T13:16:36.725Z",
"ended": "2026-06-03T13:16:48.722Z",
"reads": [],
"writes": [
{
"file": "/home/seppe4/vscode_projects/netgazet/CLAUDE.md",
"tokens_estimated": 628,
"action": "create"
}
],
"totals": {
"input_tokens_estimated": 0,
"output_tokens_estimated": 628,
"reads_count": 0,
"writes_count": 1,
"repeated_reads_blocked": 0,
"anatomy_lookups": 0
}
},
{
"id": "session-2026-06-03-1516",
"started": "2026-06-03T13:16:36.725Z",
"ended": "2026-06-03T13:17:38.452Z",
"reads": [
{
"file": "/home/seppe4/vscode_projects/netgazet/CLAUDE.md",
"tokens_estimated": 550,
"was_repeated": false,
"anatomy_had_description": false
}
],
"writes": [
{
"file": "/home/seppe4/vscode_projects/netgazet/CLAUDE.md",
"tokens_estimated": 628,
"action": "create"
}
],
"totals": {
"input_tokens_estimated": 550,
"output_tokens_estimated": 628,
"reads_count": 1,
"writes_count": 1,
"repeated_reads_blocked": 0,
"anatomy_lookups": 1
}
},
{
"id": "session-2026-06-03-1516",
"started": "2026-06-03T13:16:36.725Z",
"ended": "2026-06-03T13:30:44.458Z",
"reads": [
{
"file": "/home/seppe4/vscode_projects/netgazet/CLAUDE.md",
"tokens_estimated": 550,
"was_repeated": false,
"anatomy_had_description": false
},
{
"file": "/home/seppe4/vscode_projects/netgazet/output/index.html",
"tokens_estimated": 0,
"was_repeated": false,
"anatomy_had_description": false
},
{
"file": "/home/seppe4/vscode_projects/netgazet/output/NETgazet-07_okt2024/page-01.html",
"tokens_estimated": 0,
"was_repeated": false,
"anatomy_had_description": false
},
{
"file": "/home/seppe4/vscode_projects/netgazet/output/NETgazet-07_okt2024/index.html",
"tokens_estimated": 0,
"was_repeated": false,
"anatomy_had_description": false
}
],
"writes": [
{
"file": "/home/seppe4/vscode_projects/netgazet/CLAUDE.md",
"tokens_estimated": 628,
"action": "create"
},
{
"file": "/home/seppe4/.claude/plans/in-the-example-folder-immutable-rossum.md",
"tokens_estimated": 2414,
"action": "create"
},
{
"file": "/home/seppe4/vscode_projects/netgazet/main.py",
"tokens_estimated": 2847,
"action": "create"
}
],
"totals": {
"input_tokens_estimated": 550,
"output_tokens_estimated": 5889,
"reads_count": 4,
"writes_count": 3,
"repeated_reads_blocked": 0,
"anatomy_lookups": 1
}
},
{
"id": "session-2026-06-03-1516",
"started": "2026-06-03T13:16:36.725Z",
"ended": "2026-06-03T13:34:03.911Z",
"reads": [
{
"file": "/home/seppe4/vscode_projects/netgazet/CLAUDE.md",
"tokens_estimated": 550,
"was_repeated": false,
"anatomy_had_description": false
},
{
"file": "/home/seppe4/vscode_projects/netgazet/output/index.html",
"tokens_estimated": 0,
"was_repeated": false,
"anatomy_had_description": false
},
{
"file": "/home/seppe4/vscode_projects/netgazet/output/NETgazet-07_okt2024/page-01.html",
"tokens_estimated": 0,
"was_repeated": false,
"anatomy_had_description": false
},
{
"file": "/home/seppe4/vscode_projects/netgazet/output/NETgazet-07_okt2024/index.html",
"tokens_estimated": 0,
"was_repeated": false,
"anatomy_had_description": false
}
],
"writes": [
{
"file": "/home/seppe4/vscode_projects/netgazet/CLAUDE.md",
"tokens_estimated": 628,
"action": "create"
},
{
"file": "/home/seppe4/.claude/plans/in-the-example-folder-immutable-rossum.md",
"tokens_estimated": 2414,
"action": "create"
},
{
"file": "/home/seppe4/vscode_projects/netgazet/main.py",
"tokens_estimated": 2847,
"action": "create"
}
],
"totals": {
"input_tokens_estimated": 550,
"output_tokens_estimated": 5889,
"reads_count": 4,
"writes_count": 3,
"repeated_reads_blocked": 0,
"anatomy_lookups": 1
}
},
{
"id": "session-2026-06-03-1516",
"started": "2026-06-03T13:16:36.725Z",
"ended": "2026-06-03T13:35:12.255Z",
"reads": [
{
"file": "/home/seppe4/vscode_projects/netgazet/CLAUDE.md",
"tokens_estimated": 550,
"was_repeated": false,
"anatomy_had_description": false
},
{
"file": "/home/seppe4/vscode_projects/netgazet/output/index.html",
"tokens_estimated": 0,
"was_repeated": false,
"anatomy_had_description": false
},
{
"file": "/home/seppe4/vscode_projects/netgazet/output/NETgazet-07_okt2024/page-01.html",
"tokens_estimated": 0,
"was_repeated": false,
"anatomy_had_description": false
},
{
"file": "/home/seppe4/vscode_projects/netgazet/output/NETgazet-07_okt2024/index.html",
"tokens_estimated": 0,
"was_repeated": false,
"anatomy_had_description": false
}
],
"writes": [
{
"file": "/home/seppe4/vscode_projects/netgazet/CLAUDE.md",
"tokens_estimated": 628,
"action": "create"
},
{
"file": "/home/seppe4/.claude/plans/in-the-example-folder-immutable-rossum.md",
"tokens_estimated": 2414,
"action": "create"
},
{
"file": "/home/seppe4/vscode_projects/netgazet/main.py",
"tokens_estimated": 2847,
"action": "create"
}
],
"totals": {
"input_tokens_estimated": 550,
"output_tokens_estimated": 5889,
"reads_count": 4,
"writes_count": 3,
"repeated_reads_blocked": 0,
"anatomy_lookups": 1
}
},
{
"id": "session-2026-06-03-1555",
"started": "2026-06-03T13:55:38.248Z",
"ended": "2026-06-03T13:56:28.840Z",
"reads": [
{
"file": "/home/seppe4/vscode_projects/netgazet/output/index.html",
"tokens_estimated": 0,
"was_repeated": false,
"anatomy_had_description": false
}
],
"writes": [],
"totals": {
"input_tokens_estimated": 0,
"output_tokens_estimated": 0,
"reads_count": 1,
"writes_count": 0,
"repeated_reads_blocked": 0,
"anatomy_lookups": 0
}
}
],
"daemon_usage": [],
"waste_flags": [],
"optimization_report": {
"last_generated": null,
"patterns": []
}
}
+75
View File
@@ -0,0 +1,75 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project status
Greenfield Python 3.14 project. As of this writing the codebase is a stub: `main.py` prints a hello message, `pyproject.toml` declares no dependencies, and `README.md` is empty. There is no application architecture yet — design decisions are still open.
## BASIC RULES
### Rule 1 — Think Before Coding
State assumptions explicitly. If uncertain, ask rather than guess.
Push back when a simpler approach exists. Stop when confused.
### Rule 2 — Simplicity First
Minimum code that solves the problem. Nothing speculative.
No features beyond what was asked. No abstractions for single-use code.
### Rule 3 — Surgical Changes
Touch only what you must. Clean up only your own mess.
Don't "improve" adjacent code, comments, or formatting. Match existing style.
### Rule 4 — Goal-Driven Execution
Define success criteria. Loop until verified.
Don't follow steps. Define success and iterate independently.
### Rule 5 — Token budgets are not advisory
Per-task: 4,000 tokens. Per-session: 30,000 tokens.
If approaching budget, summarize and start fresh. Surface the breach.
### Rule 6 — Read before you write
Before adding code, read exports, immediate callers, shared utilities.
If unsure why code is structured a certain way, ask.
### Rule 7 — Checkpoint after every significant step
Summarize what was done, what's verified, what's left.
Don't continue from a state you can't describe back. Stop and restate.
### Rule 8 — Fail loud
"Completed" is wrong if anything was skipped silently.
"Tests pass" is wrong if any were skipped.
Default to surfacing uncertainty, not hiding it.
## Tooling
- Python version pinned by `.python-version` (3.14). Project metadata in `pyproject.toml`.
- `uv` is the package manager (presence of `pyproject.toml` + `.python-version` without `requirements.txt`/`Pipfile`).
- Install/sync deps: `uv sync`
- Add a dependency: `uv add <pkg>`
- Run the entrypoint: `uv run python main.py`
- No test runner, linter, or build step is configured yet — set them up the first time they're needed rather than assuming a default.
## OpenWolf protocol (mandatory)
This project uses OpenWolf for cross-session context management. The full operating protocol is in `.wolf/OPENWOLF.md` and applies every turn:
- **Before reading any file:** check `.wolf/anatomy.md` — it has a description + token estimate for every tracked file. Prefer the description over a full read when sufficient.
- **Before generating code:** read `.wolf/cerebrum.md`, especially the `## Do-Not-Repeat` section.
- **Before fixing a bug:** check `.wolf/buglog.json` for known fixes; after fixing, append a new entry (error, root cause, fix, tags).
- **After significant actions:** append a one-line entry to `.wolf/memory.md`; update `.wolf/anatomy.md` when files are created/deleted/renamed.
- **Learn aggressively:** update `.wolf/cerebrum.md` whenever the user corrects you, expresses a preference, or you discover a non-obvious convention. The threshold is intentionally low.
`.wolf/reframe-frameworks.md` is the UI framework knowledge base — only read it when the user explicitly asks to pick or migrate a UI framework, and read only the sections needed.
## Design QC
When asked to evaluate UI/design: run `openwolf designqc` to capture screenshots into `.wolf/designqc-captures/`, then read those images. Each screenshot ≈ 2500 tokens — use `--routes` to limit scope on large apps.
+154
View File
@@ -0,0 +1,154 @@
# netgazet
Tiny static-site generator for print-format PDFs (newsletters, magazines).
Drop PDFs into `examples/`, run `main.py`, and get a browseable, mobile-friendly
HTML/CSS archive in `output/`.
## What it does
For every `*.pdf` in `examples/` it renders each page as a JPEG with
poppler-utils (`pdftocairo`) and writes:
- `output/<pdf-stem>/page-NN.html` — one wrapper per page with prev/next
navigation and a collapsed text-extract panel (from `pdftotext -layout`).
- `output/<pdf-stem>/index.html` — thumbnail grid for the issue.
- `output/index.html` — root listing of all issues.
- `output/styles.css` — single responsive stylesheet, no JavaScript.
Layout fidelity (faithful to the print PDF) over text reflow; mobile
responsiveness is via `img { max-width: 100% }` and CSS grid auto-fill.
## Requirements
- Python ≥ 3.14 (see `.python-version`)
- `poppler-utils` on `$PATH` (`pdftocairo`, `pdftotext`, `pdfinfo`):
```bash
sudo apt install poppler-utils # Debian / Ubuntu
brew install poppler # macOS
```
No third-party Python dependencies — `pyproject.toml` keeps
`dependencies = []`.
## Usage
```bash
python main.py
# or:
uv run python main.py
```
Then open `output/index.html` in a browser. Each run wipes and rebuilds
`output/<stem>/` for every PDF in `examples/`, so it's idempotent.
## Auto-convert: rebuild when a PDF is dropped in `examples/`
Watch the folder with `inotifywait` (Linux; install `inotify-tools`):
```bash
sudo apt install inotify-tools
```
Save next to `main.py` as `watch.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
WATCH_DIR="${1:-examples}"
echo "[watcher] watching $WATCH_DIR for new PDFs (Ctrl-C to stop)"
inotifywait -m -q -e close_write,moved_to --format '%f' "$WATCH_DIR" \
| while read -r filename; do
case "$filename" in
*.pdf|*.PDF)
echo "[watcher] $(date '+%H:%M:%S') new PDF: $filename — rebuilding"
python main.py
# Optional: push to your server right after rebuild
# rsync -avz --delete output/ user@server:/var/www/netgazet/
;;
esac
done
```
Make executable and run:
```bash
chmod +x watch.sh
./watch.sh
```
The script rebuilds the **whole** site on every new PDF (~30 s for two
16-page issues). Fine for a handful of newsletters; for dozens, extend
`main.py` to accept a single filename and only rebuild that issue plus
the root index.
On macOS, swap `inotifywait` for `fswatch -o "$WATCH_DIR" | while read; do ...; done`.
## Embedding in a WordPress page (iframe)
Once `output/` is hosted somewhere (a vhost on your server, Cloudflare Pages,
Netlify, etc.), embed it in any WordPress page or post:
1. In the block editor, add a **Custom HTML** block.
2. Paste:
```html
<iframe
src="https://netgazet.yourdomain.tld/"
style="width:100%; height:85vh; border:0;"
loading="lazy"
title="NETgazet archive">
</iframe>
```
3. Publish.
Notes:
- Use HTTPS for `src` if your WordPress site is on HTTPS — browsers block
mixed content.
- All navigation (issue → page → next page) stays inside the iframe, so the
WordPress page doesn't reload. Don't add `target="_top"` to links in the
generated HTML — that would break out of the frame.
- `height: 85vh` works on desktop and most phones. For a fixed value use
`height: 800px;` (or similar).
- Cross-domain iframe embeds need no special config, **unless** the static
host sends `X-Frame-Options: DENY` or
`Content-Security-Policy: frame-ancestors 'none'`. Nginx/Caddy don't add
these by default — only an issue if you explicitly enabled them.
### Subdomain vs subfolder (and auto-height)
The iframe `src` can point at any reachable URL, including a **subdomain** such
as `https://gazet.netwerk-antwerpen.be/`:
```html
<iframe
src="https://gazet.netwerk-antwerpen.be/"
style="width:100%; height:90vh; border:0;"
loading="lazy"
title="NETgazet archive">
</iframe>
```
That works out of the box (it's your own static files, so nothing blocks
framing). Two things to know:
- **HTTPS must match.** If WordPress is on `https://`, the subdomain must serve
`https://` too, or the browser blocks it as mixed content. Static hosts and
most shared hosting provide free Let's Encrypt certs — just enable it.
- **A subdomain is a *different origin*** from the WordPress domain. That has one
practical consequence: **cross-origin iframes can't auto-resize to their
content height.** The browser won't let the parent page read the iframe's
height, so the `postMessage` auto-height trick doesn't work. You get one of:
| Hosting | URL example | Auto-height |
|---|---|---|
| Subdomain (different origin) | `gazet.netwerk-antwerpen.be` | ❌ — use a fixed height (`90vh` / `1200px`), accept inner scrollbars |
| Subfolder (same origin) | `netwerk-antwerpen.be/gazet/` | ✅ — same-origin, `postMessage` auto-height works |
Pick a **subfolder** only if seamless auto-resizing matters more than the
cleaner subdomain URL. Otherwise a subdomain with a fixed `height` is the
simplest setup. (The *Advanced iFrame Pro* plugin can do cross-domain
auto-height too, but it needs a helper file dropped on the subdomain.)
Binary file not shown.
Binary file not shown.
+366
View File
@@ -0,0 +1,366 @@
"""Convert every PDF in examples/ into a static HTML/CSS site under output/.
Shells out to poppler-utils (pdftocairo, pdftotext, pdfinfo). No third-party deps.
Usage: python main.py (or: uv run python main.py)
"""
from __future__ import annotations
import html
import re
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).parent
EXAMPLES_DIR = ROOT / "examples"
OUTPUT_DIR = ROOT / "output"
DPI_FULL = 150
DPI_THUMB = 30
JPEG_QUALITY_FULL = 85
JPEG_QUALITY_THUMB = 80
REQUIRED_BINARIES = ("pdftocairo", "pdftotext", "pdfinfo")
def main() -> int:
missing = [b for b in REQUIRED_BINARIES if shutil.which(b) is None]
if missing:
print(f"error: missing poppler-utils binaries: {', '.join(missing)}", file=sys.stderr)
print("install with: sudo apt install poppler-utils", file=sys.stderr)
return 1
if not EXAMPLES_DIR.is_dir():
print(f"error: {EXAMPLES_DIR} does not exist", file=sys.stderr)
return 1
pdfs = sorted(EXAMPLES_DIR.glob("*.pdf"))
if not pdfs:
print(f"error: no PDFs found in {EXAMPLES_DIR}", file=sys.stderr)
return 1
OUTPUT_DIR.mkdir(exist_ok=True)
write_shared_css()
issues = []
for pdf in pdfs:
print(f"converting {pdf.name} ...")
issues.append(convert_pdf(pdf))
write_root_index(issues)
print(f"\ndone. open {OUTPUT_DIR / 'index.html'} in a browser.")
return 0
def convert_pdf(pdf_path: Path) -> dict:
stem = pdf_path.stem
out = OUTPUT_DIR / stem
reset_dir(out)
page_count = pdf_page_count(pdf_path)
page_texts = extract_text_per_page(pdf_path, page_count)
for n in range(1, page_count + 1):
render_jpeg(pdf_path, out / f"page-{n:02d}", n, DPI_FULL, JPEG_QUALITY_FULL)
render_jpeg(pdf_path, out / f"thumb-{n:02d}", n, DPI_THUMB, JPEG_QUALITY_THUMB)
write_page_html(out, stem, n, page_count, page_texts[n - 1])
write_issue_index(out, stem, page_count)
return {"stem": stem, "title": stem, "pages": page_count}
def reset_dir(path: Path) -> None:
if path.exists():
shutil.rmtree(path)
path.mkdir(parents=True)
def pdf_page_count(pdf_path: Path) -> int:
out = subprocess.run(
["pdfinfo", str(pdf_path)], check=True, capture_output=True, text=True
).stdout
m = re.search(r"^Pages:\s+(\d+)", out, re.MULTILINE)
if not m:
raise RuntimeError(f"could not parse page count from pdfinfo for {pdf_path}")
return int(m.group(1))
def extract_text_per_page(pdf_path: Path, page_count: int) -> list[str]:
"""Return a list of page texts. pdftotext separates pages with form-feed (\\f)."""
out = subprocess.run(
["pdftotext", "-layout", str(pdf_path), "-"],
check=True,
capture_output=True,
text=True,
).stdout
pages = out.split("\f")
# pdftotext appends a trailing form-feed → one extra empty element; trim it.
if pages and pages[-1] == "":
pages.pop()
# Pad/truncate so the list matches page_count exactly (defensive).
while len(pages) < page_count:
pages.append("")
return pages[:page_count]
def render_jpeg(pdf_path: Path, out_stem: Path, page: int, dpi: int, quality: int) -> None:
"""pdftocairo always appends -<page>.jpg unless -singlefile is used.
We render one page at a time with -singlefile so the output filename is exact.
"""
subprocess.run(
[
"pdftocairo",
"-jpeg",
"-jpegopt",
f"quality={quality}",
"-r",
str(dpi),
"-f",
str(page),
"-l",
str(page),
"-singlefile",
str(pdf_path),
str(out_stem), # pdftocairo appends .jpg automatically
],
check=True,
capture_output=True,
)
# ---------- HTML emission ----------
def write_shared_css() -> None:
(OUTPUT_DIR / "styles.css").write_text(SHARED_CSS, encoding="utf-8")
def write_root_index(issues: list[dict]) -> None:
cards = "\n".join(
f""" <a class="card" href="{html.escape(i['stem'])}/index.html">
<img src="{html.escape(i['stem'])}/thumb-01.jpg" alt="Cover of {html.escape(i['title'])}">
<h2>{html.escape(i['title'])}</h2>
<p>{i['pages']} pages</p>
</a>"""
for i in issues
)
page = PAGE_SHELL.format(
title="NETgazet archive",
css_href="styles.css",
body=f""" <header>
<h1>NETgazet archive</h1>
<p>Static HTML rendering of the available editions.</p>
</header>
<main class="card-grid">
{cards}
</main>""",
)
(OUTPUT_DIR / "index.html").write_text(page, encoding="utf-8")
def write_issue_index(out: Path, stem: str, page_count: int) -> None:
thumbs = "\n".join(
f""" <a class="thumb" href="page-{n:02d}.html">
<img src="thumb-{n:02d}.jpg" alt="Page {n}" loading="lazy">
<span>Page {n}</span>
</a>"""
for n in range(1, page_count + 1)
)
page = PAGE_SHELL.format(
title=html.escape(stem),
css_href="../styles.css",
body=f""" <header>
<p><a href="../index.html">← all editions</a></p>
<h1>{html.escape(stem)}</h1>
<p>{page_count} pages — tap a thumbnail to read.</p>
</header>
<main class="thumb-grid">
{thumbs}
</main>""",
)
(out / "index.html").write_text(page, encoding="utf-8")
def write_page_html(out: Path, stem: str, n: int, total: int, text: str) -> None:
prev_link = (
f'<a href="page-{n - 1:02d}.html" rel="prev">← Page {n - 1}</a>'
if n > 1
else '<span class="nav-disabled">← Page</span>'
)
next_link = (
f'<a href="page-{n + 1:02d}.html" rel="next">Page {n + 1} →</a>'
if n < total
else '<span class="nav-disabled">Page →</span>'
)
text_block = (
f""" <details class="page-text">
<summary>Show page text</summary>
<pre>{html.escape(text)}</pre>
</details>"""
if text.strip()
else ""
)
body = f""" <header>
<p><a href="index.html">← {html.escape(stem)}</a> · <a href="../index.html">all editions</a></p>
<h1>Page {n} <span class="of">of {total}</span></h1>
</header>
<nav class="pager">
{prev_link}
<a href="index.html" class="nav-up">Index</a>
{next_link}
</nav>
<main>
<figure class="page">
<img src="page-{n:02d}.jpg" alt="Page {n} of {html.escape(stem)}">
</figure>
{text_block}
</main>
<nav class="pager pager-bottom">
{prev_link}
<a href="index.html" class="nav-up">Index</a>
{next_link}
</nav>"""
page = PAGE_SHELL.format(
title=f"{html.escape(stem)} — page {n}",
css_href="../styles.css",
body=body,
)
(out / f"page-{n:02d}.html").write_text(page, encoding="utf-8")
# ---------- Templates ----------
PAGE_SHELL = """<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<link rel="stylesheet" href="{css_href}">
</head>
<body>
{body}
</body>
</html>
"""
SHARED_CSS = """:root {
--fg: #1a1a1a;
--muted: #666;
--bg: #fafafa;
--card: #fff;
--border: #e2e2e2;
--accent: #1a4480;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--fg);
background: var(--bg);
line-height: 1.45;
max-width: 1000px;
margin: 0 auto;
padding: 1rem;
}
img { max-width: 100%; height: auto; display: block; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
header { margin-bottom: 1.5rem; }
header h1 { margin: 0.25rem 0; font-size: 1.5rem; }
header p { margin: 0.25rem 0; color: var(--muted); }
.of { color: var(--muted); font-weight: normal; font-size: 0.85em; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.25rem;
}
.card {
display: block;
background: var(--card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem;
color: inherit;
}
.card:hover { border-color: var(--accent); text-decoration: none; }
.card img { border: 1px solid var(--border); margin-bottom: 0.5rem; }
.card h2 { margin: 0.25rem 0; font-size: 1.05rem; }
.card p { margin: 0; color: var(--muted); font-size: 0.9rem; }
.thumb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
}
.thumb {
display: block;
text-align: center;
color: inherit;
}
.thumb img {
border: 1px solid var(--border);
border-radius: 3px;
margin: 0 auto 0.25rem;
}
.thumb:hover img { border-color: var(--accent); }
.thumb span { font-size: 0.85rem; color: var(--muted); }
.pager {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: space-between;
align-items: center;
margin: 1rem 0;
padding: 0.5rem 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
font-size: 0.95rem;
}
.pager .nav-up { font-weight: 600; }
.pager .nav-disabled { color: #bbb; }
.pager-bottom { margin-top: 1rem; }
.page { margin: 0; }
.page img {
border: 1px solid var(--border);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
margin: 0 auto;
}
.page-text {
margin-top: 1rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.5rem 0.75rem;
}
.page-text summary {
cursor: pointer;
color: var(--accent);
font-size: 0.95rem;
}
.page-text pre {
white-space: pre-wrap;
word-wrap: break-word;
font-size: 0.85rem;
line-height: 1.4;
margin: 0.75rem 0 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
@media (max-width: 480px) {
body { padding: 0.5rem; }
header h1 { font-size: 1.25rem; }
.pager { font-size: 0.85rem; }
}
"""
if __name__ == "__main__":
sys.exit(main())
+7
View File
@@ -0,0 +1,7 @@
[project]
name = "netgazet"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = []
Generated
+8
View File
@@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "netgazet"
version = "0.1.0"
source = { virtual = "." }