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:
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -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`.
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"bugs": []
|
||||||
|
}
|
||||||
@@ -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. -->
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"last_heartbeat": "2026-06-03T13:15:40.431Z",
|
||||||
|
"engine_status": "running",
|
||||||
|
"execution_log": [],
|
||||||
|
"dead_letter_queue": [],
|
||||||
|
"upcoming": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"captured_at": null,
|
||||||
|
"captures": [],
|
||||||
|
"total_size_kb": 0,
|
||||||
|
"estimated_tokens": 0
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"suggestions": [],
|
||||||
|
"generated_at": null
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
@@ -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())
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[project]
|
||||||
|
name = "netgazet"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
dependencies = []
|
||||||
Reference in New Issue
Block a user