From d45ae65174f38a58481c29c52316be3135a048fd Mon Sep 17 00:00:00 2001 From: Erick Bourgeois Date: Sat, 3 Oct 2026 17:09:34 -0400 Subject: [PATCH] fix(generate-sbom): spec-version and extra-args inputs; resolve package via --manifest-path Signed-off-by: Erick Bourgeois --- .claude/commands/designqc.md | 26 ++ .claude/commands/handoff.md | 16 + .claude/commands/reframe.md | 41 ++ .claude/commands/security-audit.md | 40 ++ .claude/rules/no-real-infrastructure.md | 94 ++++ .claude/rules/openwolf.md | 10 + .claude/settings.json | 140 ++++++ .claude/skills/openwolf/SKILL.md | 47 ++ .gitignore | 4 + .opencode/command/designqc.md | 26 ++ .opencode/command/handoff.md | 16 + .opencode/command/reframe.md | 41 ++ .opencode/command/security-audit.md | 40 ++ .opencode/plugin/openwolf.ts | 4 + .opencode/plugin/openwolf/anatomy.ts | 424 ++++++++++++++++++ .opencode/plugin/openwolf/fs.ts | 165 +++++++ .opencode/plugin/openwolf/index.ts | 125 ++++++ .opencode/plugin/openwolf/post-read.ts | 48 ++ .opencode/plugin/openwolf/post-write.ts | 315 +++++++++++++ .opencode/plugin/openwolf/pre-read.ts | 97 ++++ .opencode/plugin/openwolf/pre-write.ts | 105 +++++ .opencode/plugin/openwolf/session.ts | 100 +++++ .opencode/plugin/openwolf/stop.ts | 266 +++++++++++ .opencode/plugin/openwolf/types.ts | 46 ++ .wolf/.gitignore | 15 + .wolf/OPENWOLF.md | 44 ++ .wolf/STATUS.md | 68 +++ .wolf/anatomy-index.json | 433 ++++++++++++++++++ .wolf/anatomy.md | 117 +++++ .wolf/buglog.json | 4 + .wolf/cerebrum.md | 28 ++ .wolf/config.json | 116 +++++ .wolf/cron-manifest.json | 66 +++ .wolf/memory.md | 13 + AGENTS.md | 558 ++++++++++++++++++++++++ CHANGELOG.md | 11 + CLAUDE.md | 7 + rust/generate-sbom/README.md | 17 +- rust/generate-sbom/action.yaml | 131 +++--- 39 files changed, 3800 insertions(+), 64 deletions(-) create mode 100644 .claude/commands/designqc.md create mode 100644 .claude/commands/handoff.md create mode 100644 .claude/commands/reframe.md create mode 100644 .claude/commands/security-audit.md create mode 100644 .claude/rules/no-real-infrastructure.md create mode 100644 .claude/rules/openwolf.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/openwolf/SKILL.md create mode 100644 .opencode/command/designqc.md create mode 100644 .opencode/command/handoff.md create mode 100644 .opencode/command/reframe.md create mode 100644 .opencode/command/security-audit.md create mode 100644 .opencode/plugin/openwolf.ts create mode 100644 .opencode/plugin/openwolf/anatomy.ts create mode 100644 .opencode/plugin/openwolf/fs.ts create mode 100644 .opencode/plugin/openwolf/index.ts create mode 100644 .opencode/plugin/openwolf/post-read.ts create mode 100644 .opencode/plugin/openwolf/post-write.ts create mode 100644 .opencode/plugin/openwolf/pre-read.ts create mode 100644 .opencode/plugin/openwolf/pre-write.ts create mode 100644 .opencode/plugin/openwolf/session.ts create mode 100644 .opencode/plugin/openwolf/stop.ts create mode 100644 .opencode/plugin/openwolf/types.ts create mode 100644 .wolf/.gitignore create mode 100644 .wolf/OPENWOLF.md create mode 100644 .wolf/STATUS.md create mode 100644 .wolf/anatomy-index.json create mode 100644 .wolf/anatomy.md create mode 100755 .wolf/buglog.json create mode 100644 .wolf/cerebrum.md create mode 100644 .wolf/config.json create mode 100755 .wolf/cron-manifest.json create mode 100755 .wolf/memory.md create mode 100644 AGENTS.md diff --git a/.claude/commands/designqc.md b/.claude/commands/designqc.md new file mode 100644 index 0000000..74b6b63 --- /dev/null +++ b/.claude/commands/designqc.md @@ -0,0 +1,26 @@ +--- +description: Screenshot-based design review of the running app via openwolf designqc +argument-hint: [--url ] [--routes ] +--- + +Arguments: $ARGUMENTS + +Evaluate and improve the design/UI of this app: + +1. Run `openwolf designqc` via Bash to capture screenshots (pass through any arguments given above). + - The command auto-detects a running dev server, or starts one from package.json if needed. + - Use `--url ` only if auto-detection fails. + - Compressed JPEG screenshots land in `.wolf/designqc-captures/`; full pages are captured as sectioned viewport-height images (top, section2, ..., bottom). +2. Read the captured screenshots from `.wolf/designqc-captures/` with the Read tool. +3. Evaluate 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 generic ("white-coded", no personality) +4. Provide specific, actionable feedback with fix suggestions. +5. If the user approves, implement the fixes directly in their code. +6. Re-run `openwolf designqc` to verify the improvement. + +Token awareness: each screenshot costs about 2,500 tokens. For large apps, use `--routes / /specific-page` to limit captures. diff --git a/.claude/commands/handoff.md b/.claude/commands/handoff.md new file mode 100644 index 0000000..ab1414c --- /dev/null +++ b/.claude/commands/handoff.md @@ -0,0 +1,16 @@ +Regenerate `.wolf/STATUS.md` as a session handoff document. $ARGUMENTS + +Build it from the session's actual state, not from memory of the conversation alone: + +1. Read the current `.wolf/STATUS.md` to preserve its structure and any still-relevant open items. +2. Run `git status --short` and `git log --oneline -8` to see what actually changed. +3. Skim the latest session block of `.wolf/memory.md` for the action log. + +Then rewrite `.wolf/STATUS.md` with: + +- `## βœ… Done` : what this session completed, one line each, concrete (files, features, fixes). Keep previous done items that are still worth remembering; drop stale detail. +- `## πŸš€ Next quest` : the single next objective, the files involved, acceptance criteria, and any open decisions the user still needs to make. +- `## Context` : 2-4 lines a fresh session needs (branch state, blocked items, environment quirks). +- Bump the date. + +Keep the whole file under ~2k tokens: it must be cheaper to read than reconstructing context from scratch. Do not pad it; a short honest handoff beats a complete-looking one. diff --git a/.claude/commands/reframe.md b/.claude/commands/reframe.md new file mode 100644 index 0000000..49d4b14 --- /dev/null +++ b/.claude/commands/reframe.md @@ -0,0 +1,41 @@ +--- +description: OpenWolf's design brain β€” pick/migrate UI frameworks or audit/fix UI against the anti-generic design principles +argument-hint: [migrate [framework] | audit [target] | fix [target]] +--- + +Arguments: $ARGUMENTS + +Read `.wolf/reframe-frameworks.md` first β€” it contains the **Design Principles +(anti-generic mandate)**, the framework knowledge base, and the migration prompts. +Use `.wolf/anatomy.md` to locate UI files instead of scanning. + +Pick the mode from the arguments (default: `migrate` if a framework is named or the +user is choosing one; otherwise ask which mode they want): + +## Mode: migrate [framework] +Framework selection and migration. +1. If no framework is named, ask the Decision Questions from the knowledge file + (stop early once the answer narrows to 1–2 options) and recommend one. +2. Use that framework's prompt from the knowledge file, adapted to this project's + real structure via `.wolf/anatomy.md`. +3. The Design Principles override anything generic in the prompt: no template + heroβ†’featuresβ†’CTA structures, no stock palettes β€” distinctive by default. + +## Mode: audit [target] +Walk the target (default: the whole UI) and flag every match against the AI-tell +blocklist in the Design Principles: purple gradient heroes, glassmorphism-everything, +emoji headings, generic 3-column feature grids, stock Tailwind palette, Inter for +every role, template SaaS structure, filler microcopy. Produce a findings table β€” +component, tell matched, severity, specific replacement direction β€” and end with the +3 changes that would most increase distinctiveness. + +## Mode: fix [target] +Run the audit, then fix findings in severity order. Fixes must move toward, not merely +away: typography chosen with intent, a palette derived from the product's actual +brand/domain, asymmetry where it serves hierarchy, copy specific to what the product +does, density appropriate to the audience. Preserve the existing framework and +component APIs β€” this is a design pass, not a rewrite. After each fix, state what +changed and why it reads as designed-on-purpose. + +Acceptance criterion for every mode: **if the result could be swapped onto any other +product without anyone noticing, it fails.** diff --git a/.claude/commands/security-audit.md b/.claude/commands/security-audit.md new file mode 100644 index 0000000..2ba9685 --- /dev/null +++ b/.claude/commands/security-audit.md @@ -0,0 +1,40 @@ +--- +description: Layered security audit of the current project (dependencies β†’ secrets β†’ injection β†’ authz β†’ report) +argument-hint: [path or scope, e.g. src/api β€” omit for whole project] +--- + +Perform a layered security audit of: $ARGUMENTS (if empty: the whole project). + +Use `.wolf/anatomy.md` to target files instead of scanning blindly, and check +`.wolf/buglog.json` for previously found security issues before re-reporting them. + +Work through the layers in order. For each, report findings before moving on: + +## Layer 1 β€” Dependencies +Run the ecosystem's audit tool (`npm audit` / `pnpm audit` / `pip-audit` / `cargo audit` …). +Flag known-vulnerable versions and unmaintained packages that handle untrusted input. + +## Layer 2 β€” Secrets +Search for hardcoded credentials: API keys, tokens, passwords, connection strings, +private keys. Check committed env files, config files, and test fixtures. Verify +`.gitignore` covers secret-bearing files (.env*, *.pem, *.key, credentials*). + +## Layer 3 β€” Injection surfaces +Find every place external input reaches an interpreter: shell commands built by string +interpolation (exec/execSync with template strings), SQL string concatenation, HTML +injection/XSS sinks, path traversal (user input joined into fs paths), deserialization +of untrusted data, SSRF (user-controlled URLs fetched server-side). + +## Layer 4 β€” AuthN / AuthZ +Map endpoints and privileged operations. Check: missing auth middleware, IDOR (object +IDs without ownership checks), privilege escalation paths, session handling, CORS and +CSRF posture, servers bound to 0.0.0.0 without auth. + +## Layer 5 β€” Report +Produce a severity-ranked table (Critical/High/Medium/Low): finding, file:line, attack +scenario, concrete fix. Log confirmed vulnerabilities to `.wolf/buglog.json` with tag +"security". Offer to fix Critical and High items immediately. + +Rules: verify each finding against the actual code before reporting (no +pattern-match-only findings); prefer minimal, targeted fixes; never weaken existing +security to silence a warning. diff --git a/.claude/rules/no-real-infrastructure.md b/.claude/rules/no-real-infrastructure.md new file mode 100644 index 0000000..572e89e --- /dev/null +++ b/.claude/rules/no-real-infrastructure.md @@ -0,0 +1,94 @@ +# Never Commit Real Infrastructure Identifiers or Personal Data + +> **This is a public OSS repository.** Anything committed here is published, +> indexed, and permanent: a later commit that removes it does not un-publish +> it. Real hostnames, addresses, account identifiers, and personal data from +> the maintainer's own environment MUST NOT appear in tracked files. + +This is not a style preference. A real hostname in a public repo is a free +reconnaissance gift: it names a host, implies what runs on it, and often +reveals the naming scheme for every other host beside it. A home directory +path names a user account on a real machine. + +## The rule + +**Never write a real hostname, IP address, username, home directory path, +email address, or account identifier belonging to the maintainer's +environment into any tracked file.** This applies to action code, workflows, +tests, docs, examples, scripts, comments, commit messages, and changelog +entries, everywhere, with no exceptions for "it's just a doc comment." + +If you need a concrete value to make an example readable, use a placeholder +from the table below. + +## Placeholders to use + +| Kind | Use | Never use | +| --- | --- | --- | +| Hostname / domain | `bar.foo.io`, `baz.foo.io`, `example.com` | any real host the maintainer operates | +| Documentation IPv4 | `192.0.2.x`, `198.51.100.x`, `203.0.113.x` (RFC 5737) | any real routable address | +| Documentation IPv6 | `2001:db8::/32` (RFC 3849) | any real routable address | +| Private IPv4 | `10.0.0.x`, `192.168.x.x` (RFC 1918), only when the example is *semantically* a private network | a real private address that is actually in use | +| Username | `admin`, `runner`, `svc-example` | a real login | +| Home / project directory | `$CLAUDE_PROJECT_DIR`, `$HOME`, `~`, a repo-relative path | `/Users//...`, `/home//...` | +| Registry | `ghcr.io/firestoned/`, `registry.internal:5000` | a real private registry host | + +The RFC 5737 ranges are the right answer for "make up an IP": they are +reserved for documentation and are guaranteed never routable. A *genuinely* +random IP is worse than a reserved one: it probably belongs to somebody. + +## The one legitimate exception + +The `Copyright (c) 2025 Erick Bourgeois, firestoned` SPDX headers are an +author identity the maintainer chose to publish. Leave them alone. The +distinction is whether the string names *a host you could connect to* or *an +account on a real machine*. + +## OpenWolf, opencode, and Claude Code files + +The OpenWolf and opencode tooling (`.wolf/`, `.opencode/`, `.claude/`) is +generated on the maintainer's machine and tends to bake in absolute paths. +Every tracked file in those directories is held to the same rule: + +- **Hook commands use `$CLAUDE_PROJECT_DIR`**, never an absolute path. Claude + Code sets it to the project root for every hook: + + ```json + // βœ… GOOD + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/session-start.js\"" + + // ❌ BAD: names a real user account on a real machine + "command": "node \"/Users//dev/github-actions/.wolf/hooks/session-start.js\"" + ``` + + `openwolf init` (and its upgrades) rewrite `.claude/settings.json` with + absolute paths. After running either, put `$CLAUDE_PROJECT_DIR` back before + committing. +- **`.wolf/anatomy.md` and `.wolf/anatomy-index.json`** accumulate entries for + any file a session touched, including scratchpads and plan directories + outside the repo. Run `openwolf scan` (a full rescan from the tree alone) + before committing a change to either. +- **`.wolf/memory.md`, `.wolf/STATUS.md`, `.wolf/cerebrum.md`, and + `.wolf/buglog.json`** are written from session activity and can quote + command lines, error messages, and paths verbatim. Read the diff before + committing them. +- `.claude/settings.local.json` is machine-local and must never be committed. + +## Getting a real value in without committing it + +Take the value from the environment at runtime and document it with a +placeholder. In a workflow, that means a secret or a variable +(`${{ vars.REGISTRY_HOST }}`), never a literal. In a shell script, default to +empty and require the caller to supply it, or derive it at runtime. + +## Before finishing any task + +Grep your own diff. It costs one command: + +```sh +# Real-infrastructure and personal-data sweep over staged files +git diff --cached -U0 | rg -i '/Users/|/home/[a-z]|jeb\.ca|gmail\.com|\b(?:\d{1,3}\.){3}\d{1,3}\b' +``` + +Flag anything that is not in the placeholder table above. If you are unsure +whether a value is real, assume it is and replace it. diff --git a/.claude/rules/openwolf.md b/.claude/rules/openwolf.md new file mode 100644 index 0000000..7ee74e6 --- /dev/null +++ b/.claude/rules/openwolf.md @@ -0,0 +1,10 @@ +--- +description: OpenWolf protocol enforcement, active on all files +globs: **/* +--- + +- To locate a symbol or file, run `openwolf find ` first (ranked shortlist, under 1k tokens). For one file's description and symbol ranges: `openwolf find --file `. Never read .wolf/anatomy.md whole; it is an index. +- Check .wolf/cerebrum.md Do-Not-Repeat list before generating code (grep "## Do-Not-Repeat"); after a user correction, update cerebrum.md immediately. +- Do NOT manually update .wolf/anatomy.md or .wolf/memory.md; the OpenWolf hooks maintain them. +- BEFORE fixing any bug: run `openwolf bug search ""` or grep .wolf/buglog.json. AFTER fixing one: log it there (error_message, root_cause, fix, tags). +- When resuming a session, read .wolf/STATUS.md first; regenerate it with /handoff when a quest finishes. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..28bb098 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,140 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/session-start.js\"", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/user-prompt-submit.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 + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-bash.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 + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/post-bash.js\"", + "timeout": 10 + } + ] + } + ], + "PostToolBatch": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/post-batch.js\"", + "timeout": 5 + } + ] + } + ], + "PreCompact": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/precompact.js\"", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/stop.js\"", + "timeout": 10 + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/session-end.js\"", + "timeout": 10 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.claude/skills/openwolf/SKILL.md b/.claude/skills/openwolf/SKILL.md new file mode 100644 index 0000000..eaeacf2 --- /dev/null +++ b/.claude/skills/openwolf/SKILL.md @@ -0,0 +1,47 @@ +--- +name: openwolf +description: OpenWolf operating protocol for this project. Load when starting multi-file work, resuming a session, wrapping up a session, or when unsure how to use .wolf/ state files (anatomy, cerebrum, memory, buglog, STATUS). +--- + +# OpenWolf Operating Protocol + +You are working in an OpenWolf-managed project. The hooks handle bookkeeping automatically: they maintain `.wolf/anatomy.md` and `.wolf/memory.md` after writes, track reads, and surface anatomy hints when you read files. Do not update those two files manually unless your agent has no OpenWolf hooks installed. + +## Session resume + +`.wolf/STATUS.md` is the handoff document. Read it first when resuming; it replaces re-reading memory, plans, and code to reconstruct context. Regenerate it on demand with `/handoff` when a quest finishes or before suggesting `/clear`. + +## File navigation + +1. To locate a symbol or file by name, run `openwolf find ` first: a ranked shortlist from the index (~1k tokens max), cheaper than grepping the world. For one file's description, size, and symbol line ranges: `openwolf find --file `. +2. If the description answers your question, skip the full read. For large files, prefer Read with offset/limit; the pre-read hook surfaces the largest sections with line ranges, and `openwolf map` prints a token-budgeted overview of the most important files. +3. Never read `.wolf/anatomy.md` whole; it is an index. Grep it only for a single path's line when `find` is unavailable. +4. If a file is not indexed, search with Grep/Glob. Regenerate the index with `openwolf scan`. + +## Code generation and learning + +1. Before generating code, check `.wolf/cerebrum.md`: respect `## Do-Not-Repeat` (past mistakes), `## Key Learnings`, and `## User Preferences`. +2. Update cerebrum.md whenever you learn something: a user correction or preference, a project convention not obvious from code, an API surprise, a gotcha that would trip a fresh session. The bar is LOW; a missing entry repeats the discovery next session. + +## Bug logging + +Before fixing any bug: grep `.wolf/buglog.json` for the error message or filename; the fix may already be known. + +After fixing any bug, failed test, failed build, or user-reported problem: append an entry with `id`, `timestamp`, `error_message`, `file`, `root_cause`, `fix`, `tags`, `occurrences`, `last_seen`. Also log when you edit a file more than twice to get it right. + +## Token discipline + +- Never re-read a file already read this session unless it changed since. +- Prefer anatomy descriptions and targeted Grep over full file reads. +- If appending to a file, do not read the entire file first. + +## Session end + +Before wrapping up: run `/handoff` (or update `.wolf/STATUS.md` by hand), write a one-line session summary to `.wolf/memory.md` (`| HH:MM | description | file(s) | outcome | ~tokens |`), and record any learnings or bugs in cerebrum.md / buglog.json. + +## On-demand skills + +- `/handoff`: regenerate .wolf/STATUS.md from the session's actual state. +- `/designqc`: screenshot-based design review of the running app. +- `/reframe`: UI framework selection, migration, and anti-generic design audits. +- `/security-audit`: security review of the project. diff --git a/.gitignore b/.gitignore index 3336df3..a36c288 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ Cargo.lock *.dll *.so *.dylib + +# Claude Code machine-local settings (may hold absolute paths; see +# .claude/rules/no-real-infrastructure.md) +.claude/settings.local.json diff --git a/.opencode/command/designqc.md b/.opencode/command/designqc.md new file mode 100644 index 0000000..74b6b63 --- /dev/null +++ b/.opencode/command/designqc.md @@ -0,0 +1,26 @@ +--- +description: Screenshot-based design review of the running app via openwolf designqc +argument-hint: [--url ] [--routes ] +--- + +Arguments: $ARGUMENTS + +Evaluate and improve the design/UI of this app: + +1. Run `openwolf designqc` via Bash to capture screenshots (pass through any arguments given above). + - The command auto-detects a running dev server, or starts one from package.json if needed. + - Use `--url ` only if auto-detection fails. + - Compressed JPEG screenshots land in `.wolf/designqc-captures/`; full pages are captured as sectioned viewport-height images (top, section2, ..., bottom). +2. Read the captured screenshots from `.wolf/designqc-captures/` with the Read tool. +3. Evaluate 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 generic ("white-coded", no personality) +4. Provide specific, actionable feedback with fix suggestions. +5. If the user approves, implement the fixes directly in their code. +6. Re-run `openwolf designqc` to verify the improvement. + +Token awareness: each screenshot costs about 2,500 tokens. For large apps, use `--routes / /specific-page` to limit captures. diff --git a/.opencode/command/handoff.md b/.opencode/command/handoff.md new file mode 100644 index 0000000..ab1414c --- /dev/null +++ b/.opencode/command/handoff.md @@ -0,0 +1,16 @@ +Regenerate `.wolf/STATUS.md` as a session handoff document. $ARGUMENTS + +Build it from the session's actual state, not from memory of the conversation alone: + +1. Read the current `.wolf/STATUS.md` to preserve its structure and any still-relevant open items. +2. Run `git status --short` and `git log --oneline -8` to see what actually changed. +3. Skim the latest session block of `.wolf/memory.md` for the action log. + +Then rewrite `.wolf/STATUS.md` with: + +- `## βœ… Done` : what this session completed, one line each, concrete (files, features, fixes). Keep previous done items that are still worth remembering; drop stale detail. +- `## πŸš€ Next quest` : the single next objective, the files involved, acceptance criteria, and any open decisions the user still needs to make. +- `## Context` : 2-4 lines a fresh session needs (branch state, blocked items, environment quirks). +- Bump the date. + +Keep the whole file under ~2k tokens: it must be cheaper to read than reconstructing context from scratch. Do not pad it; a short honest handoff beats a complete-looking one. diff --git a/.opencode/command/reframe.md b/.opencode/command/reframe.md new file mode 100644 index 0000000..49d4b14 --- /dev/null +++ b/.opencode/command/reframe.md @@ -0,0 +1,41 @@ +--- +description: OpenWolf's design brain β€” pick/migrate UI frameworks or audit/fix UI against the anti-generic design principles +argument-hint: [migrate [framework] | audit [target] | fix [target]] +--- + +Arguments: $ARGUMENTS + +Read `.wolf/reframe-frameworks.md` first β€” it contains the **Design Principles +(anti-generic mandate)**, the framework knowledge base, and the migration prompts. +Use `.wolf/anatomy.md` to locate UI files instead of scanning. + +Pick the mode from the arguments (default: `migrate` if a framework is named or the +user is choosing one; otherwise ask which mode they want): + +## Mode: migrate [framework] +Framework selection and migration. +1. If no framework is named, ask the Decision Questions from the knowledge file + (stop early once the answer narrows to 1–2 options) and recommend one. +2. Use that framework's prompt from the knowledge file, adapted to this project's + real structure via `.wolf/anatomy.md`. +3. The Design Principles override anything generic in the prompt: no template + heroβ†’featuresβ†’CTA structures, no stock palettes β€” distinctive by default. + +## Mode: audit [target] +Walk the target (default: the whole UI) and flag every match against the AI-tell +blocklist in the Design Principles: purple gradient heroes, glassmorphism-everything, +emoji headings, generic 3-column feature grids, stock Tailwind palette, Inter for +every role, template SaaS structure, filler microcopy. Produce a findings table β€” +component, tell matched, severity, specific replacement direction β€” and end with the +3 changes that would most increase distinctiveness. + +## Mode: fix [target] +Run the audit, then fix findings in severity order. Fixes must move toward, not merely +away: typography chosen with intent, a palette derived from the product's actual +brand/domain, asymmetry where it serves hierarchy, copy specific to what the product +does, density appropriate to the audience. Preserve the existing framework and +component APIs β€” this is a design pass, not a rewrite. After each fix, state what +changed and why it reads as designed-on-purpose. + +Acceptance criterion for every mode: **if the result could be swapped onto any other +product without anyone noticing, it fails.** diff --git a/.opencode/command/security-audit.md b/.opencode/command/security-audit.md new file mode 100644 index 0000000..2ba9685 --- /dev/null +++ b/.opencode/command/security-audit.md @@ -0,0 +1,40 @@ +--- +description: Layered security audit of the current project (dependencies β†’ secrets β†’ injection β†’ authz β†’ report) +argument-hint: [path or scope, e.g. src/api β€” omit for whole project] +--- + +Perform a layered security audit of: $ARGUMENTS (if empty: the whole project). + +Use `.wolf/anatomy.md` to target files instead of scanning blindly, and check +`.wolf/buglog.json` for previously found security issues before re-reporting them. + +Work through the layers in order. For each, report findings before moving on: + +## Layer 1 β€” Dependencies +Run the ecosystem's audit tool (`npm audit` / `pnpm audit` / `pip-audit` / `cargo audit` …). +Flag known-vulnerable versions and unmaintained packages that handle untrusted input. + +## Layer 2 β€” Secrets +Search for hardcoded credentials: API keys, tokens, passwords, connection strings, +private keys. Check committed env files, config files, and test fixtures. Verify +`.gitignore` covers secret-bearing files (.env*, *.pem, *.key, credentials*). + +## Layer 3 β€” Injection surfaces +Find every place external input reaches an interpreter: shell commands built by string +interpolation (exec/execSync with template strings), SQL string concatenation, HTML +injection/XSS sinks, path traversal (user input joined into fs paths), deserialization +of untrusted data, SSRF (user-controlled URLs fetched server-side). + +## Layer 4 β€” AuthN / AuthZ +Map endpoints and privileged operations. Check: missing auth middleware, IDOR (object +IDs without ownership checks), privilege escalation paths, session handling, CORS and +CSRF posture, servers bound to 0.0.0.0 without auth. + +## Layer 5 β€” Report +Produce a severity-ranked table (Critical/High/Medium/Low): finding, file:line, attack +scenario, concrete fix. Log confirmed vulnerabilities to `.wolf/buglog.json` with tag +"security". Offer to fix Critical and High items immediately. + +Rules: verify each finding against the actual code before reporting (no +pattern-match-only findings); prefer minimal, targeted fixes; never weaken existing +security to silence a warning. diff --git a/.opencode/plugin/openwolf.ts b/.opencode/plugin/openwolf.ts new file mode 100644 index 0000000..64ed76e --- /dev/null +++ b/.opencode/plugin/openwolf.ts @@ -0,0 +1,4 @@ +// OpenWolf plugin entry β€” installed by `openwolf init --agent opencode`. +// Implementation lives in ./openwolf/ so it can stay multi-file; this entry +// is the only module OpenCode's plugin loader instantiates. +export { OpenWolf } from "./openwolf/index.js" diff --git a/.opencode/plugin/openwolf/anatomy.ts b/.opencode/plugin/openwolf/anatomy.ts new file mode 100644 index 0000000..7350535 --- /dev/null +++ b/.opencode/plugin/openwolf/anatomy.ts @@ -0,0 +1,424 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import type { AnatomyEntry } from "./types.js" + +// ── Markdown format (canonical β€” mirrors src/hooks/anatomy-store.ts) ──────── + +// The auto-generated header block: never captured as preamble, or every +// importβ†’render cycle would duplicate it. +const AUTO_HEADER = /^(?:# anatomy\.md\s*$|> Auto-maintained by OpenWolf\.|> Files:\s*\d+\s*tracked)/ +// Legacy symbol sub-bullets (2.0.0–2.0.3 renders): mechanical output, dropped +// rather than preserved. Any OTHER indented bullet is hand-written and kept. +const SYMBOL_SUBBULLET = /^ {2}- (?:fn|class|method|section) `[^`]+` L\d+-\d+ \(~\d+ tok\)$/ + +/** Strip leading/trailing blank lines so preserved blocks don't grow by two lines per write. */ +function trimBlankEdges(lines: string[]): string[] { + let start = 0 + let end = lines.length + while (start < end && lines[start].trim() === "") start++ + while (end > start && lines[end - 1].trim() === "") end-- + return lines.slice(start, end) +} + +export function parseAnatomyWithRaw(content: string): { + sections: Map + rawLines: Map + preamble: string[] +} { + const sections = new Map() + const rawLines = new Map() + const preamble: string[] = [] + let currentSection = "" + for (const raw of content.split("\n")) { + const line = raw.replace(/\r$/, "") + const sm = line.match(/^## (.+)/) + if (sm) { + currentSection = sm[1].trim() + if (!sections.has(currentSection)) sections.set(currentSection, []) + continue + } + if (!currentSection) { + // Above the first section heading: preserve everything hand-written + // (issue #61), skipping only our own generated header block. + if (!AUTO_HEADER.test(line)) preamble.push(line) + 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), + }) + continue + } + if (line.trim() === "") continue + if (SYMBOL_SUBBULLET.test(line)) continue + if (!rawLines.has(currentSection)) rawLines.set(currentSection, []) + rawLines.get(currentSection)!.push(line) + } + return { sections, rawLines, preamble: trimBlankEdges(preamble) } +} + +export function parseAnatomy(content: string): Map { + return parseAnatomyWithRaw(content).sections +} + +// serializeAnatomy was removed: it could not carry rawLines or preamble, and +// every writer must go through renderStore so preserved content survives +// (issue #61). + +export function extractDescription(filePath: string): string { + const MAX_DESC = 150 + const basename = path.basename(filePath) + const ext = path.extname(basename).toLowerCase() + const known: Record = { + "package.json": "Node.js package manifest", + "tsconfig.json": "TypeScript configuration", + ".gitignore": "Git ignore rules", + "README.md": "Project documentation", + } + if (known[basename]) return known[basename] + + let content: string + try { + const fd = fs.openSync(filePath, "r") + const buf = Buffer.alloc(12288) + 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: string) => s.length <= MAX_DESC ? s : s.slice(0, MAX_DESC - 3) + "..." + + if (ext === ".md" || ext === ".mdx") { + const m = content.match(/^#{1,2}\s+(.+)$/m) + if (m) return cap(m[1].trim()) + } + + if (ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx") { + if (basename === "page.tsx" || basename === "page.js") return "Next.js page component" + if (basename === "layout.tsx" || basename === "layout.js") return "Next.js layout" + 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) as string[] + 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`) + } + + const declM = content.match(/(?:function|class|const|interface|type|enum)\s+(\w+)/) + if (declM) return `Declares ${declM[1]}` + return "" +} + +// ── Durable store + lock (mirrors src/hooks/anatomy-store.ts, F2b) ────────── +import * as crypto from "node:crypto" +import * as os from "node:os" + +export const STORE_FILE = "anatomy-index.json" +const LOCK_STALE_MS = 10_000 +export const LOCK_BUDGET_MS = 2_000 + +export function sha256(text: string): string { + return crypto.createHash("sha256").update(text).digest("hex") +} + +export interface SymbolEntry { + name: string + kind: "fn" | "class" | "method" | "section" + startLine: number + endLine: number + tokens: number +} + +export interface StoreFileEntry { + description: string + tokens: number + /** sha256 (first 16 hex chars) of file content when last indexed. */ + hash?: string + size?: number + mtimeMs?: number + updatedAt: string + source: "hook" | "scan" | "md-import" + symbols?: SymbolEntry[] +} + +export interface AnatomyStoreData { + version: 1 + meta: { + lastScanned: string + fileCount: number + hits: number + misses: number + /** sha256 of the markdown this store last rendered β€” skew detection key. */ + renderedHash: string + storeUpdatedAt: string + } + /** Keyed by full normalized relative path, e.g. "src/hooks/shared.ts". */ + files: Record + /** Non-conforming anatomy.md lines preserved verbatim, keyed by section. */ + rawLines?: Record + /** + * Hand-written content above the first `## ` heading (issue #61). A typed + * field rather than a reserved rawLines key: old compiled hooks still + * installed in projects ignore an unknown field and round-trip it safely, + * whereas a magic "" section key would render as a stray `## ` heading. + */ + preamble?: string[] +} + +export function newStore(): AnatomyStoreData { + const now = new Date().toISOString() + return { version: 1, meta: { lastScanned: now, fileCount: 0, hits: 0, misses: 0, renderedHash: "", storeUpdatedAt: now }, files: {} } +} + +export function loadStore(wolfDir: string): AnatomyStoreData | null { + try { + const parsed = JSON.parse(fs.readFileSync(path.join(wolfDir, STORE_FILE), "utf-8")) + if (parsed && parsed.version === 1 && parsed.files && parsed.meta) return parsed as AnatomyStoreData + return null + } catch { + return null + } +} + +export function saveStore(wolfDir: string, store: AnatomyStoreData): void { + store.meta.fileCount = Object.keys(store.files).length + store.meta.storeUpdatedAt = new Date().toISOString() + const filePath = path.join(wolfDir, STORE_FILE) + const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp" + const body = JSON.stringify(store, null, 2) + try { + fs.writeFileSync(tmp, body, "utf-8") + fs.renameSync(tmp, filePath) + } catch { + try { fs.writeFileSync(filePath, body, "utf-8") } catch {} + try { fs.unlinkSync(tmp) } catch {} + } +} + +/** Section key for a relpath: "src/hooks/" or "./" for root files. */ +export function sectionKeyOf(relPath: string): string { + const dir = path.dirname(relPath).split(path.sep).join("/") + return dir === "." ? "./" : dir + "/" +} + +/** + * Render the store to markdown, byte-identical to the legacy format. Symbols + * deliberately do NOT render: they live in anatomy-index.json and reach the + * model through the per-file pre-read hint. Rendering them roughly 2.4x'd + * anatomy.md, and agents instructed to consult anatomy.md paid that bill + * wholesale every session. + */ +export function renderStore(store: AnatomyStoreData): string { + const bySection = new Map>() + for (const [relPath, entry] of Object.entries(store.files)) { + const key = sectionKeyOf(relPath) + if (!bySection.has(key)) bySection.set(key, []) + bySection.get(key)!.push({ file: relPath.slice(relPath.lastIndexOf("/") + 1), entry }) + } + + const lines: string[] = [ + "# anatomy.md", + "", + `> Auto-maintained by OpenWolf. Last scanned: ${store.meta.lastScanned}`, + `> Files: ${Object.keys(store.files).length} tracked | Anatomy hits: ${store.meta.hits} | Misses: ${store.meta.misses}`, + "", + ] + const preamble = trimBlankEdges(store.preamble ?? []) + if (preamble.length > 0) { + lines.push(...preamble, "") + } + const rawBySection = store.rawLines ?? {} + const keys = [...new Set([...bySection.keys(), ...Object.keys(rawBySection)])].sort() + for (const key of keys) { + lines.push(`## ${key}`) + lines.push("") + const raws = rawBySection[key] ?? [] + for (const raw of raws) { + lines.push(raw) + } + const entries = (bySection.get(key) ?? []).sort((a, b) => a.file.localeCompare(b.file)) + if (raws.length > 0 && entries.length > 0) lines.push("") + for (const { file, entry } of entries) { + const desc = entry.description ? ` β€” ${entry.description}` : "" + lines.push(`- \`${file}\`${desc} (~${entry.tokens} tok)`) + } + lines.push("") + } + return lines.join("\n") +} + +/** Write the rendered markdown atomically and pin its hash in the store. */ +export function renderToFile(wolfDir: string, store: AnatomyStoreData): void { + const content = renderStore(store) + store.meta.renderedHash = sha256(content) + const anatomyPath = path.join(wolfDir, "anatomy.md") + const tmp = anatomyPath + "." + crypto.randomBytes(4).toString("hex") + ".tmp" + try { + fs.writeFileSync(tmp, content, "utf-8") + fs.renameSync(tmp, anatomyPath) + } catch { + try { fs.writeFileSync(anatomyPath, content, "utf-8") } catch {} + try { fs.unlinkSync(tmp) } catch {} + } +} + +/** + * Absorb out-of-band edits to anatomy.md (old compiled hooks, agent/human + * hand-edits) into the store. ADDITIVE-ONLY: + * - md entry differs β†’ md wins description/tokens (newer intent) + * - md entry absent from store β†’ added (source "md-import") + * - store entry absent from md β†’ KEPT unless the file is gone from disk + * (deletions are exclusively the full scanner's job) + * Symbols always survive (they only flow store β†’ render). + */ +export function importFromMarkdown(store: AnatomyStoreData, mdContent: string, projectRoot: string): void { + // Corruption guard: an empty/unreadable anatomy.md must never wipe the + // preserved hand-written content in the store. + if (!mdContent.trim()) return + + const { sections, rawLines, preamble } = parseAnatomyWithRaw(mdContent) + if (rawLines.size > 0) { + store.rawLines = Object.fromEntries(rawLines) + } else { + delete store.rawLines + } + if (preamble.length > 0) { + store.preamble = preamble + } else { + delete store.preamble + } + const seen = new Set() + for (const [sectionKey, entries] of sections) { + const dir = sectionKey === "./" ? "" : sectionKey + for (const e of entries) { + const relPath = (dir + e.file).split("\\").join("/") + seen.add(relPath) + const existing = store.files[relPath] + if (!existing) { + store.files[relPath] = { description: e.description, tokens: e.tokens, updatedAt: new Date().toISOString(), source: "md-import" } + } else if (existing.description !== e.description || existing.tokens !== e.tokens) { + existing.description = e.description + existing.tokens = e.tokens + existing.updatedAt = new Date().toISOString() + existing.source = "md-import" + } + } + } + // Entries the md no longer lists: keep unless the file is really gone. + for (const relPath of Object.keys(store.files)) { + if (seen.has(relPath)) continue + if (!fs.existsSync(path.join(projectRoot, relPath))) delete store.files[relPath] + } +} + +/** + * Read-side lookup: resolve a file to its anatomy entry. Store-first with an + * O(1) relpath key; falls back to a suffix scan (paths outside the root) and + * finally to parsing anatomy.md for projects that predate the store. + * `normalizedFile` and `projectDir` use forward slashes. + */ +export function lookupEntry( + wolfDir: string, + projectDir: string, + normalizedFile: string +): { file: string; description: string; tokens: number; symbols?: SymbolEntry[]; size?: number; mtimeMs?: number } | null { + const rel = normalizedFile.startsWith(projectDir + "/") + ? normalizedFile.slice(projectDir.length + 1) + : normalizedFile.startsWith("/") ? null : normalizedFile + + const store = loadStore(wolfDir) + if (store) { + const toResult = (rp: string, e: StoreFileEntry) => ({ + file: rp.slice(rp.lastIndexOf("/") + 1), + description: e.description, + tokens: e.tokens, + symbols: e.symbols, + size: e.size, + mtimeMs: e.mtimeMs, + }) + const hit = rel ? store.files[rel] : undefined + if (hit) return toResult(rel!, hit) + for (const [rp, e] of Object.entries(store.files)) { + if (normalizedFile === rp || normalizedFile.endsWith("/" + rp)) { + return toResult(rp, e) + } + } + return null + } + + // Pre-store project: legacy markdown scan. + let md: string + try { + md = fs.readFileSync(path.join(wolfDir, "anatomy.md"), "utf-8") + } catch { + return null + } + for (const [sectionKey, entries] of parseAnatomy(md)) { + const dir = sectionKey === "./" ? "" : sectionKey + for (const entry of entries) { + const entryRelPath = (dir + entry.file).split("\\").join("/") + if (normalizedFile === entryRelPath || normalizedFile.endsWith("/" + entryRelPath)) { + return entry + } + } + } + return null +} + +/** + * Standard writer entry point: load the store (bootstrapping from anatomy.md + * on first contact), and absorb any md-side divergence before the caller + * mutates. Call ONLY while holding the anatomy lock. + */ +export function loadStoreReconciled(wolfDir: string, projectRoot: string): AnatomyStoreData { + let store = loadStore(wolfDir) + let md: string | null = null + try { md = fs.readFileSync(path.join(wolfDir, "anatomy.md"), "utf-8") } catch {} + if (!store) { + store = newStore() + if (md) importFromMarkdown(store, md, projectRoot) + return store + } + if (md !== null && sha256(md) !== store.meta.renderedHash) importFromMarkdown(store, md, projectRoot) + return store +} + +function lockSleep(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +} + +export function withAnatomyLock(wolfDir: string, budgetMs: number, fn: () => T): T | null { + const lockPath = path.join(wolfDir, "anatomy-index.lock") + const deadline = Date.now() + budgetMs + while (true) { + try { + fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, hostname: os.hostname(), acquiredAt: Date.now() }), { flag: "wx" }) + break + } catch {} + let stale = false + try { + const body = JSON.parse(fs.readFileSync(lockPath, "utf-8")) + stale = typeof body.acquiredAt !== "number" || Date.now() - body.acquiredAt > LOCK_STALE_MS + if (!stale && body.hostname === os.hostname() && typeof body.pid === "number") { + try { process.kill(body.pid, 0) } catch (err) { stale = (err as NodeJS.ErrnoException).code === "ESRCH" } + } + } catch { + try { stale = Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS } catch {} + } + if (stale) { + const graveyard = lockPath + "." + crypto.randomBytes(4).toString("hex") + ".stale" + try { fs.renameSync(lockPath, graveyard); try { fs.unlinkSync(graveyard) } catch {} } catch {} + } + if (Date.now() >= deadline) return null + lockSleep(25 + Math.floor(Math.random() * 25)) + } + try { + return fn() + } finally { + try { fs.unlinkSync(lockPath) } catch {} + } +} diff --git a/.opencode/plugin/openwolf/fs.ts b/.opencode/plugin/openwolf/fs.ts new file mode 100644 index 0000000..e88367b --- /dev/null +++ b/.opencode/plugin/openwolf/fs.ts @@ -0,0 +1,165 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import * as crypto from "node:crypto" + +export function getWolfDir(directory: string): string { + return path.join(directory, ".wolf") +} + +export function wolfDirExists(directory: string): boolean { + return fs.existsSync(getWolfDir(directory)) +} + +function isPlainObject(v: unknown): v is Record { + return ( + typeof v === "object" && + v !== null && + !Array.isArray(v) && + Object.getPrototypeOf(v) === Object.prototype + ) +} + +/** + * Recursively fills missing keys in `loaded` from `defaults`. + * Loaded values always win; defaults only fill gaps. Arrays and scalars + * are replaced wholesale (not merged). + */ +function deepMergeDefaults(defaults: T, loaded: T): T { + if (!isPlainObject(defaults) || !isPlainObject(loaded)) return loaded + const result: Record = { ...(defaults as Record) } + for (const key of Object.keys(loaded as Record)) { + const lv = (loaded as Record)[key] + const dv = (defaults as Record)[key] + if (isPlainObject(lv) && isPlainObject(dv)) { + result[key] = deepMergeDefaults(dv, lv) + } else { + result[key] = lv + } + } + return result as T +} + +/** + * Reads JSON from `filePath`. If the file exists and parses, its values are + * deep-merged over `fallback` so that missing nested keys fall back to the + * provided defaults (loaded values always win). If the file is missing or + * unparseable, `fallback` is returned as-is. + * + * This prevents `TypeError: Cannot read properties of undefined` when a + * user's file predates a section a newer release reads (e.g. a pre-2.0 + * token-ledger.json without `lifetime`). + */ +export function readJSON(filePath: string, fallback: T): T { + try { + const raw = fs.readFileSync(filePath, "utf-8") + const parsed = JSON.parse(raw) as T + return deepMergeDefaults(fallback, parsed) + } catch { + return fallback + } +} + +export function writeJSON(filePath: string, data: unknown): void { + 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 { + try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8") } catch {} + try { fs.unlinkSync(tmp) } catch {} + } +} + +/** + * Per-session state path, mirroring getSessionFilePath() in src/hooks/shared.ts + * (same id validation, same layout). + * + * Issue #89, reported with PR #113 by @davdittrich, whose session-file GC is + * adopted below. + * + * Every OpenCode handler receives a sessionId but they all used to persist to + * one shared hooks/_session.json, so starting a second session in the same + * project overwrote the first one's state and each later read or write from + * either session mutated the survivor (#89). The legacy shared file is used + * only when there is no usable id, so existing single-session installs keep + * working. + */ +export function sessionFilePath(hooksDir: string, sessionId: string | undefined): string { + if (typeof sessionId === "string" && /^[\w.-]{4,128}$/.test(sessionId)) { + return path.join(hooksDir, "sessions", `${sessionId}.json`) + } + return path.join(hooksDir, "_session.json") +} + +/** + * Delete per-session state files older than maxAgeDays. + * + * Mirrors gcSessionFiles() in src/hooks/shared.ts. Splitting state per session + * fixes cross-session contamination but creates one file per session forever, + * so the directory needs the same bound the main hooks already apply. + */ +export function gcSessionFiles(hooksDir: string, maxAgeDays = 7): void { + const dir = path.join(hooksDir, "sessions") + const cutoff = Date.now() - maxAgeDays * 24 * 3600 * 1000 + try { + for (const f of fs.readdirSync(dir)) { + if (!f.endsWith(".json")) continue + try { + if (fs.statSync(path.join(dir, f)).mtimeMs < cutoff) fs.unlinkSync(path.join(dir, f)) + } catch {} + } + } catch {} +} + +export function readMarkdown(filePath: string): string { + try { + return fs.readFileSync(filePath, "utf-8") + } catch { + return "" + } +} + +export function appendMarkdown(filePath: string, line: string): void { + const dir = path.dirname(filePath) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + fs.appendFileSync(filePath, line, "utf-8") +} + +export function timeShort(): string { + const d = new Date() + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` +} + +export function timestamp(): string { + return new Date().toISOString() +} + +export function normalizePath(p: string): string { + return p.replace(/\\/g, "/") +} + +export function estimateTokens(text: string, type: "code" | "prose" | "mixed" = "mixed"): number { + const ratio = type === "code" ? 3.5 : type === "prose" ? 4.0 : 3.75 + return Math.ceil(text.length / ratio) +} + +// Files whose contents (or content-derived descriptions) must never reach +// anatomy.md / memory.md because they hold secrets (issue #54). Mirrors +// isSensitiveFile in src/hooks/shared.ts. +const SENSITIVE_EXTENSIONS = new Set([ + ".pem", ".key", ".p8", ".p12", ".pfx", ".keystore", ".jks", ".ppk", ".kdbx", ".tfstate", +]) +const SENSITIVE_BASENAMES = new Set([".npmrc", ".netrc", ".htpasswd", ".pgpass"]) + +export function isSensitiveFile(basename: string): boolean { + const lower = basename.toLowerCase() + if (lower === ".env" || lower.startsWith(".env.")) return true + if (SENSITIVE_BASENAMES.has(lower)) return true + const dot = lower.lastIndexOf(".") + if (dot >= 0 && SENSITIVE_EXTENSIONS.has(lower.slice(dot))) return true + if (/^id_(rsa|dsa|ecdsa|ed25519)/.test(lower)) return true + if (lower.includes("credential") || /^secrets\.(json|ya?ml|toml)$/.test(lower)) return true + return false +} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/index.ts b/.opencode/plugin/openwolf/index.ts new file mode 100644 index 0000000..5445736 --- /dev/null +++ b/.opencode/plugin/openwolf/index.ts @@ -0,0 +1,125 @@ +import type { Plugin } from "@opencode-ai/plugin" +import * as fs from "node:fs" +import * as path from "node:path" + +import { wolfDirExists, getWolfDir } from "./fs.js" +import { handleSessionStart, deleteSession } from "./session.js" +import { handlePreRead } from "./pre-read.js" +import { handlePreWrite } from "./pre-write.js" +import { handlePostRead } from "./post-read.js" +import { handlePostWrite } from "./post-write.js" +import { handleStop } from "./stop.js" + +/** + * OpenCode event payloads have carried the session id in different places + * across versions: top-level `session_id`/`sessionID` in older builds, and + * nested under `properties` (`properties.info.id` for session.created, + * `properties.sessionID` elsewhere) in newer ones. Accept all shapes. + */ +function extractSessionId(source: unknown): string { + if (!source || typeof source !== "object") return "" + const obj = source as { + session_id?: unknown + sessionID?: unknown + properties?: { info?: { id?: unknown }; sessionID?: unknown } + } + const candidates = [ + obj.session_id, + obj.sessionID, + obj.properties?.info?.id, + obj.properties?.sessionID, + ] + for (const c of candidates) { + if (typeof c === "string" && c) return c + } + return "" +} + +export const OpenWolf: Plugin = async ({ directory }: { directory: string }) => { + return { + event: async ({ event }: { event: { type: string; [key: string]: unknown } }) => { + if (event.type === "session.created" && !wolfDirExists(directory)) return + + const sessionId = extractSessionId(event) + if (!sessionId) return + + if (event.type === "session.created") { + handleSessionStart(directory, sessionId) + } + + if (event.type === "session.deleted") { + deleteSession(sessionId) + } + }, + + "tool.execute.before": async (input: { tool: string; sessionID: string }, output: { args: Record }) => { + if (!wolfDirExists(directory)) return + + const sessionId = extractSessionId(input) + if (!sessionId) return + + const args: Record = output.args || {} + const tool = input.tool.toLowerCase() + + if (tool === "read") { + const filePath = String(args.filePath || args.file_path || "") + const isRangedRead = args.offset !== undefined || args.limit !== undefined + if (filePath) handlePreRead(directory, sessionId, filePath, isRangedRead) + } + + if (tool === "write" || tool === "edit") { + const filePath = String(args.filePath || args.file_path || "") + const content = String(args.content || "") + const oldStr = String(args.old_string || args.oldString || "") + const newStr = String(args.new_string || args.newString || "") + if (filePath) handlePreWrite(directory, sessionId, filePath, content, oldStr, newStr) + } + }, + + "tool.execute.after": async (input: { tool: string; sessionID: string; args: Record }, output: Record) => { + if (!wolfDirExists(directory)) return + + const sessionId = extractSessionId(input) + if (!sessionId) return + + const tool = input.tool.toLowerCase() + const args = input.args || {} + + if (tool === "read") { + const filePath = String(args.filePath || args.file_path || "") + const content = String((output as any).output || "") + if (filePath) handlePostRead(directory, sessionId, filePath, content) + } + + if (tool === "write" || tool === "edit") { + const filePath = String(args.filePath || args.file_path || "") + const content = String(args.content || "") + const oldStr = String(args.old_string || args.oldString || "") + const newStr = String(args.new_string || args.newString || "") + if (filePath) handlePostWrite(directory, sessionId, input.tool, filePath, content, oldStr, newStr) + } + }, + + stop: async (input: Record) => { + if (!wolfDirExists(directory)) return + + const sessionId = extractSessionId(input) + if (!sessionId) return + + handleStop(directory, sessionId) + }, + + "experimental.chat.system.transform": async (_input: Record, output: { system: string[] }) => { + if (!wolfDirExists(directory)) return + + const wolfDir = getWolfDir(directory) + const openwolfPath = path.join(wolfDir, "OPENWOLF.md") + if (fs.existsSync(openwolfPath)) { + try { + const openwolfContent = fs.readFileSync(openwolfPath, "utf-8") + output.system.push(`\n\n${openwolfContent}\n`) + } catch {} + } + }, + } +} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/post-read.ts b/.opencode/plugin/openwolf/post-read.ts new file mode 100644 index 0000000..0fae02e --- /dev/null +++ b/.opencode/plugin/openwolf/post-read.ts @@ -0,0 +1,48 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { getWolfDir, writeJSON, readJSON, normalizePath, estimateTokens, sessionFilePath } from "./fs.js" +import { lookupEntry } from "./anatomy.js" +import type { PartialSessionState } from "./types.js" + +export function handlePostRead(directory: string, sessionId: string, filePath: string, content: string): void { + const wolfDir = getWolfDir(directory) + if (!fs.existsSync(wolfDir)) return + + const hooksDir = path.join(wolfDir, "hooks") + const sessionFile = sessionFilePath(hooksDir, sessionId) + const normalizedFile = normalizePath(filePath) + + const projectDir = normalizePath(directory) + const relToProject = normalizedFile.startsWith(projectDir) + ? normalizedFile.slice(projectDir.length).replace(/^\//, "") + : "" + if (relToProject.startsWith(".wolf/") || relToProject.startsWith(".wolf\\")) 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 as "code" | "prose" | "mixed") : 0 + + // Fallback: if the tool output had no content, use the anatomy token estimate + if (tokens === 0) { + const entry = lookupEntry(wolfDir, projectDir, normalizedFile) + if (entry) tokens = entry.tokens + } + + const session = readJSON(sessionFile, { files_read: {} }) + if (!session.files_read) session.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) +} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/post-write.ts b/.opencode/plugin/openwolf/post-write.ts new file mode 100644 index 0000000..a83e9da --- /dev/null +++ b/.opencode/plugin/openwolf/post-write.ts @@ -0,0 +1,315 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import * as crypto from "node:crypto" +import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort, normalizePath, estimateTokens, isSensitiveFile, sessionFilePath } from "./fs.js" +import { extractDescription, withAnatomyLock, loadStoreReconciled, saveStore, renderToFile, sha256, LOCK_BUDGET_MS } from "./anatomy.js" +import type { PartialSessionState, FixDetection } from "./types.js" + +// File types where a value/string change is normal content editing, not a bug +// fix β€” auto bug detection never runs on these (see autoDetectBugFix). Without +// this, a version bump in a README or a key change in a JSON/YAML config is +// logged as a "wrong-value" bug, since the detector matches quoted spans +// (including markdown backticks) regardless of file type. +const NON_CODE_EXTS = new Set([ + ".md", ".mdx", ".markdown", ".txt", ".rst", ".adoc", + ".json", ".jsonc", ".yaml", ".yml", ".toml", ".ini", ".env", + ".lock", ".csv", ".tsv", +]) + +export function handlePostWrite( + directory: string, + sessionId: string, + toolName: string, + filePath: string, + content: string, + oldStr: string, + newStr: string +): void { + const wolfDir = getWolfDir(directory) + if (!fs.existsSync(wolfDir)) return + + const hooksDir = path.join(wolfDir, "hooks") + const sessionFile = sessionFilePath(hooksDir, sessionId) + const projectRoot = directory + + const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath) + const relPath = normalizePath(path.relative(projectRoot, absolutePath)) + if (relPath.startsWith(".wolf/")) return + + // Never track files outside the project root (e.g. a scratchpad under + // /private/tmp). path.relative() yields ../.. section keys that pollute + // anatomy.md and are wiped again by every full scan, so the index churns + // instead of converging. + if (relPath.startsWith("..") || path.isAbsolute(relPath)) return + + // Never track secret-bearing files in anatomy/memory (issue #54): .env is + // not the only file whose description would leak sensitive content. + const baseName = path.basename(absolutePath) + if (isSensitiveFile(baseName)) return + + updateAnatomy(wolfDir, absolutePath, projectRoot, content) + appendToMemory(wolfDir, toolName, absolutePath, projectRoot, content, newStr) + trackSession(sessionFile, filePath, toolName, content, newStr, baseName, projectRoot, absolutePath) + + if (oldStr && newStr) { + autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr) + } +} + +function updateAnatomy(wolfDir: string, absolutePath: string, projectRoot: string, content: string): void { + try { + const relPathLocal = normalizePath(path.relative(projectRoot, absolutePath)) + + let fileContent = "" + try { + fileContent = fs.readFileSync(absolutePath, "utf-8") + } catch { + fileContent = 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 as "code" | "prose" | "mixed") + + let size: number | undefined + let mtimeMs: number | undefined + try { + const st = fs.statSync(absolutePath) + size = st.size + mtimeMs = st.mtimeMs + } catch {} + + withAnatomyLock(wolfDir, LOCK_BUDGET_MS, () => { + const store = loadStoreReconciled(wolfDir, projectRoot) + store.files[relPathLocal] = { + description: desc, + tokens, + hash: sha256(fileContent).slice(0, 16), + size, + mtimeMs, + updatedAt: new Date().toISOString(), + source: "hook", + // The plugin cannot recompute symbols; drop them so stale line + // ranges never misdirect a slice read (the next scan restores them). + symbols: undefined, + } + store.meta.lastScanned = new Date().toISOString() + renderToFile(wolfDir, store) + saveStore(wolfDir, store) + }) + } catch {} +} + +function appendToMemory( + wolfDir: string, + toolName: string, + absolutePath: string, + projectRoot: string, + content: string, + newStr: string +): void { + try { + const action = toolName === "Write" ? "Created" : toolName === "MultiEdit" ? "Multi-edited" : "Edited" + const relFile = normalizePath(path.relative(projectRoot, absolutePath)) + const fileContent = 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 as "code" | "prose" | "mixed") + + let changeDesc = "" + if (content && newStr) { + changeDesc = summarizeEdit(content, newStr, path.basename(absolutePath)) + } + + const memoryPath = path.join(wolfDir, "memory.md") + const outcome = changeDesc || "β€”" + appendMarkdown(memoryPath, `| ${timeShort()} | ${action} ${relFile} | ${outcome} | ~${writeTokens} |\n`) + } catch {} +} + +function trackSession( + sessionFile: string, + filePath: string, + toolName: string, + content: string, + newStr: string, + baseName: string, + projectRoot: string, + absolutePath: string +): void { + 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 = 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 + + // A write invalidates the read record: the next read of this file is + // legitimate, not a duplicate. + if (session.files_read && session.files_read[normalizedFile]) { + delete session.files_read[normalizedFile] + } + + writeJSON(sessionFile, session) + + if (session.edit_counts![editKey] >= 3) { + console.warn(`⚠️ 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.`) + } + } catch {} +} + +export function summarizeEdit(oldStr: string, newStr: string, filename: string): string { + const oldLines = oldStr.split("\n") + const newLines = newStr.split("\n") + const oldCount = oldLines.length + const newCount = newLines.length + + 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" + + if (!newStr.trim() || newStr.trim().length < oldStr.trim().length * 0.2) return `removed ${oldCount} lines` + + 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)` + + if (oldCount === 1 && newCount === 1) { + const o = oldStr.trim() + const n = newStr.trim() + 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)}"` + return "inline fix" + } + + const fnMatch = newStr.match(/(?:function|def|fn|func|async\s+function)\s+(\w+)/) + if (fnMatch) return `modified ${fnMatch[1]}()` + + if (newCount > oldCount + 5) return `expanded (+${newCount - oldCount} lines)` + if (oldCount > newCount + 5) return `reduced (-${oldCount - newCount} lines)` + + return `${oldCount}β†’${newCount} lines` +} + +function bugAutoDetectEnabled(wolfDir: string): boolean { + try { + const cfg = readJSON<{ openwolf?: { buglog?: { auto_detect?: boolean } } }>( + path.join(wolfDir, "config.json"), + {} + ) + // Default on; only an explicit `false` disables auto bug detection. + return cfg.openwolf?.buglog?.auto_detect !== false + } catch { + return true + } +} + +export function autoDetectBugFix(wolfDir: string, absolutePath: string, projectRoot: string, oldStr: string, newStr: string): void { + const basename = path.basename(absolutePath) + const ext = path.extname(basename).toLowerCase() + + // Bug-fix detection is a code concept β€” never fire on prose/docs/data files. + if (NON_CODE_EXTS.has(ext)) return + // Respect an explicit opt-out in .wolf/config.json (default: enabled). + if (!bugAutoDetectEnabled(wolfDir)) return + + const bugLogPath = path.join(wolfDir, "buglog.json") + const bugLog = readJSON<{ version: number; bugs: Array<{ id: string; timestamp: string; error_message: string; file: string; root_cause: string; fix: string; tags: string[]; related_bugs: string[]; occurrences: number; last_seen: string }> }>(bugLogPath, { version: 1, bugs: [] }) + const relFile = normalizePath(path.relative(projectRoot, absolutePath)) + + const detection = detectFixPattern(oldStr, newStr, ext, basename) + if (!detection) return + + 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() + 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) +} + +export function detectFixPattern(oldStr: string, newStr: string, ext: string, basename: string): FixDetection | null { + const oldLines = oldStr.split("\n") + const newLines = newStr.split("\n") + + 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 ${fn}`, rootCause: "Code path had no error handling", fix: "Added try/catch block", context: extractChangedLines(oldStr, newStr) } + } + + if ((newStr.includes("?.") && !oldStr.includes("?.")) || (newStr.includes("?? ") && !oldStr.includes("?? "))) { + return { category: "null-safety", summary: `Null/undefined access in ${basename}`, rootCause: "Property access on potentially null/undefined value", fix: "Added null safety", context: extractChangedLines(oldStr, newStr) } + } + + 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 for: ${condition}`, fix: `Added guard clause: if (${condition.slice(0, 40)})` } + } + + if (oldLines.length <= 3 && newLines.length <= 3) { + const oStrs = oldStr.trim().match(/['"`]([^'"`]{2,})['"`]/g) || [] + const nStrs = newStr.trim().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)}` } + } + } + } + } + + if (newStr.includes("await ") && !oldStr.includes("await ")) { + return { category: "async-fix", summary: "Missing await", rootCause: "Async call without await", fix: "Added await to async call", context: extractChangedLines(oldStr, newStr) } + } + + return null +} + +function extractChangedLines(oldStr: string, newStr: string): string { + const oldLines = new Set(oldStr.split("\n").map(l => l.trim()).filter(Boolean)) + const added = newStr.split("\n").map(l => l.trim()).filter(l => l && !oldLines.has(l)) + return added.slice(0, 2).map(l => l.slice(0, 60)).join("; ") +} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/pre-read.ts b/.opencode/plugin/openwolf/pre-read.ts new file mode 100644 index 0000000..2e88283 --- /dev/null +++ b/.opencode/plugin/openwolf/pre-read.ts @@ -0,0 +1,97 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { getWolfDir, writeJSON, readJSON, normalizePath, sessionFilePath } from "./fs.js" +import { lookupEntry } from "./anatomy.js" +import type { PartialSessionState } from "./types.js" + +export function handlePreRead(directory: string, sessionId: string, filePath: string, isRangedRead = false): void { + const wolfDir = getWolfDir(directory) + if (!fs.existsSync(wolfDir)) return + + const hooksDir = path.join(wolfDir, "hooks") + const sessionFile = sessionFilePath(hooksDir, sessionId) + const normalizedFile = normalizePath(filePath) + + const projectDir = normalizePath(directory) + const relToProject = normalizedFile.startsWith(projectDir) + ? normalizedFile.slice(projectDir.length).replace(/^\//, "") + : "" + if (relToProject.startsWith(".wolf/") || relToProject.startsWith(".wolf\\")) return + + // Ranged reads (offset/limit) are exactly what the symbol hints steer the + // model toward β€” never warn about them or record them as full reads (a + // ranged first contact must not make a later legitimate full read look like + // a duplicate). + if (isRangedRead) return + + const session = readJSON(sessionFile, { + session_id: "", files_read: {}, anatomy_hits: 0, anatomy_misses: 0, + repeated_reads_warned: 0, + }) + + if (!session.files_read) session.files_read = {} + + // Repeat detection is mtime-gated (issue #41): only warn while the file is + // unchanged since the recorded read. A modified file is a legitimate + // re-read β€” drop the stale record and track it fresh below. + if (session.files_read[normalizedFile]) { + const prev = session.files_read[normalizedFile] + let modifiedSinceRead = true + try { + const mtime = fs.statSync(filePath).mtimeMs + modifiedSinceRead = prev.read_mtime === undefined || mtime > prev.read_mtime + } catch {} + if (!modifiedSinceRead) { + prev.count++ + session.repeated_reads_warned = (session.repeated_reads_warned || 0) + 1 + console.warn(`OpenWolf: ${path.basename(normalizedFile)} was already read this session (~${prev.tokens} tok), unchanged since. If you only need the gist, your earlier read may suffice; for exact text (edit anchors, line numbers), the re-read is fine.`) + writeJSON(sessionFile, session) + return + } + delete session.files_read[normalizedFile] + } + + // Anatomy lookup: O(1) against the durable store, legacy md scan fallback. + const entry = lookupEntry(wolfDir, projectDir, normalizedFile) + const found = entry !== null + if (entry) { + if (entry.description) { + console.warn(`OpenWolf anatomy: ${entry.file}: ${entry.description} (~${entry.tokens} tok)`) + } + + // Symbol hint: point at slices of big files. Suppressed if the on-disk + // file no longer matches what was indexed β€” a stale line range that + // misdirects an offset read is worse than no hint at all. + if (entry.symbols && entry.symbols.length > 0) { + let fresh = false + try { + const st = fs.statSync(filePath) + fresh = (entry.size === undefined || st.size === entry.size) && + (entry.mtimeMs === undefined || Math.abs(st.mtimeMs - entry.mtimeMs) < 1) + } catch {} + if (fresh) { + const top = [...entry.symbols].sort((a, b) => b.tokens - a.tokens).slice(0, 5) + const list = top.map((s) => `${s.kind} ${s.name} L${s.startLine}-${s.endLine} ~${s.tokens} tok`).join("; ") + console.warn(`Largest sections: ${list}. Read with offset/limit to fetch just the part you need.`) + } + } + } + + session.anatomy_hits = (session.anatomy_hits || 0) + (found ? 1 : 0) + session.anatomy_misses = (session.anatomy_misses || 0) + (found ? 0 : 1) + + // Record initial read entry (tokens will be updated in post-read) + let readMtime: number | undefined + try { + readMtime = fs.statSync(filePath).mtimeMs + } catch {} + session.files_read[normalizedFile] = { + count: 1, + tokens: 0, + first_read: new Date().toISOString(), + read_mtime: readMtime, + anatomy_hit: found, + } + + writeJSON(sessionFile, session) +} diff --git a/.opencode/plugin/openwolf/pre-write.ts b/.opencode/plugin/openwolf/pre-write.ts new file mode 100644 index 0000000..3824094 --- /dev/null +++ b/.opencode/plugin/openwolf/pre-write.ts @@ -0,0 +1,105 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { getWolfDir, readMarkdown, normalizePath, readJSON } from "./fs.js" + +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 tokenize(text: string): Set { + return new Set( + text.replace(/[^\w\s]/g, " ").split(/\s+/) + .filter(w => w.length > 3 && !STOP_WORDS.has(w.toLowerCase())) + .map(w => w.toLowerCase()) + ) +} + +export function handlePreWrite(directory: string, sessionId: string, filePath: string, content: string, oldStr: string, newStr: string): void { + const wolfDir = getWolfDir(directory) + if (!fs.existsSync(wolfDir)) return + + const allContent = [content, oldStr, newStr].join("\n") + if (!allContent.trim()) return + + checkCerebrum(wolfDir, allContent) + + if (filePath && (oldStr || content)) { + checkBugLog(wolfDir, filePath, oldStr, newStr, content) + } +} + +function checkCerebrum(wolfDir: string, content: string): void { + 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: string[] = [] + 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)) { + console.warn(`⚠️ OpenWolf cerebrum warning: "${trimmed}" β€” check your code before proceeding.`) + } + } catch {} + } + } +} + +interface BugEntry { + id: string + error_message: string + root_cause: string + fix: string + file: string + tags: string[] +} + +function checkBugLog(wolfDir: string, filePath: string, oldStr: string, newStr: string, content: string): void { + const bugLogPath = path.join(wolfDir, "buglog.json") + if (!fs.existsSync(bugLogPath)) return + + const bugLog = readJSON<{ version: number; bugs: BugEntry[] }>(bugLogPath, { version: 1, bugs: [] }) + if (bugLog.bugs.length === 0) return + + const basename = path.basename(filePath) + const fileMatches = bugLog.bugs.filter((b: BugEntry) => path.basename(b.file) === basename) + if (fileMatches.length === 0) return + + const editText = (oldStr + " " + newStr + " " + content).toLowerCase() + const editTokens = tokenize(editText) + + const relevant = fileMatches.filter((bug: BugEntry) => { + const tagHit = bug.tags.some((t: string) => editText.includes(t.toLowerCase())) + if (tagHit) return true + const bugTokens = tokenize(bug.error_message + " " + bug.root_cause) + const overlap = [...editTokens].filter(t => bugTokens.has(t)) + return overlap.length >= 3 + }) + + if (relevant.length === 0) return + + console.warn(`πŸ“‹ OpenWolf buglog: ${relevant.length} past bug(s) found for ${basename} β€” review for context, do NOT apply blindly:`) + for (const bug of relevant.slice(0, 2)) { + console.warn(` [${bug.id}] "${bug.error_message.slice(0, 70)}"`) + console.warn(` Cause: ${bug.root_cause.slice(0, 80)}`) + console.warn(` Fix: ${bug.fix.slice(0, 80)}`) + } +} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/session.ts b/.opencode/plugin/openwolf/session.ts new file mode 100644 index 0000000..ce09960 --- /dev/null +++ b/.opencode/plugin/openwolf/session.ts @@ -0,0 +1,100 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort, timestamp, readMarkdown, sessionFilePath, gcSessionFiles } from "./fs.js" +import type { SessionState } from "./types.js" + +const sessions = new Map() + +export function getSessionState(sessionId: string): SessionState | undefined { + return sessions.get(sessionId) +} + +export function setSessionState(sessionId: string, state: SessionState): void { + sessions.set(sessionId, state) +} + +export function deleteSession(sessionId: string): void { + sessions.delete(sessionId) +} + +export function handleSessionStart(directory: string, sessionId: string): void { + const wolfDir = getWolfDir(directory) + if (!fs.existsSync(wolfDir)) return + + const hooksDir = path.join(wolfDir, "hooks") + fs.mkdirSync(hooksDir, { recursive: true }) + + try { + const files = fs.readdirSync(wolfDir) + for (const f of files) { + if (f.endsWith(".tmp")) { + try { fs.unlinkSync(path.join(wolfDir, f)) } catch {} + } + } + } catch {} + + gcSessionFiles(hooksDir) + + const sessionFile = sessionFilePath(hooksDir, sessionId) + const state: SessionState = { + 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, + } + sessions.set(sessionId, state) + writeJSON(sessionFile, state) + + const memoryPath = path.join(wolfDir, "memory.md") + const now = new Date() + const header = `\n## Session: ${now.toISOString().slice(0, 10)} ${timeShort()}\n\n| Time | Action | File(s) | Outcome | ~Tokens |\n|------|--------|---------|---------|--------|\n` + appendMarkdown(memoryPath, header) + + 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) + const entryLines = cerebrumContent.split("\n").filter(l => { + const t = l.trim() + return t.startsWith("- ") || t.startsWith("* ") || (t.startsWith("[") && t.includes("]")) + }) + if (entryLines.length < 3) { + console.warn(`πŸ’‘ OpenWolf: cerebrum.md has only ${entryLines.length} entries. Learn from this session β€” record user preferences, project conventions, and mistakes to .wolf/cerebrum.md.`) + } else if (daysSinceUpdate > 3) { + console.warn(`πŸ’‘ OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(daysSinceUpdate)} days. Look for opportunities to add learnings this session.`) + } + } catch {} + + try { + const buglogPath = path.join(wolfDir, "buglog.json") + const buglog = readJSON<{ bugs: unknown[] }>(buglogPath, { bugs: [] }) + if (buglog.bugs.length === 0) { + console.warn(`πŸ“‹ OpenWolf: buglog.json is empty. If you encounter or fix any bugs, errors, or failed tests this session, log them to .wolf/buglog.json.`) + } + } catch {} + + // Count the new session in the lifetime totals. readJSON deep-merges the + // fallback, so a pre-2.0 ledger without `lifetime` still gets the default; + // the guard below covers a ledger where `lifetime` is not an object at all. + const ledgerPath = path.join(wolfDir, "token-ledger.json") + const ledger = readJSON>(ledgerPath, { version: 1, lifetime: { total_sessions: 0 } }) as { + version: number + lifetime: { total_sessions: number } + [key: string]: unknown + } + if (!ledger.lifetime || typeof ledger.lifetime !== "object") { + ledger.lifetime = { total_sessions: 0 } + } + if (typeof ledger.lifetime.total_sessions !== "number" || !isFinite(ledger.lifetime.total_sessions)) { + ledger.lifetime.total_sessions = 0 + } + ledger.lifetime.total_sessions++ + writeJSON(ledgerPath, ledger) +} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/stop.ts b/.opencode/plugin/openwolf/stop.ts new file mode 100644 index 0000000..a83f810 --- /dev/null +++ b/.opencode/plugin/openwolf/stop.ts @@ -0,0 +1,266 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort, sessionFilePath } from "./fs.js" +import type { SessionState } from "./types.js" + +// ───────────────────────────────────────────────────────────────────────────── +// Token-ledger writer (mirrors src/hooks/ledger.ts). +// +// Stop fires at the end of EVERY turn, so the ledger write must be idempotent: +// the session entry is UPSERTED by session id (replaced, never appended), and +// lifetime totals are derived β€” archived baseline + fold over the retained +// sessions β€” rather than incremented. The old increment-on-every-Stop scheme +// re-added turns 1..N on turn N, quadratically inflating every lifetime metric +// and duplicating session entries. +// ───────────────────────────────────────────────────────────────────────────── + +export const MAX_LEDGER_SESSIONS = 200 + +interface SessionEntry { + id: string + agent: string + started: string + ended: string + reads: Array<{ + file: string + tokens_estimated: number + was_repeated: boolean + anatomy_had_description: boolean + }> + writes: Array<{ file: string; tokens_estimated: number; action: string }> + totals: { + input_tokens_estimated: number + output_tokens_estimated: number + reads_count: number + writes_count: number + repeated_reads_blocked: number + repeated_reads_warned?: number + anatomy_lookups: number + anatomy_misses?: number + savings_estimated?: number + injection_tokens_estimated?: number + } +} + +interface LifetimeTotals { + total_tokens_estimated: number + total_reads: number + total_writes: number + total_sessions: number + anatomy_hits: number + anatomy_misses: number + repeated_reads_blocked: number + repeated_reads_warned: number + estimated_savings_vs_bare_cli: number + injection_tokens_estimated: number + [key: string]: number +} + +interface LedgerData { + version: number + created_at: string + lifetime: LifetimeTotals + /** Totals folded out of sessions that were rolled off the retained window. */ + lifetime_baseline?: Partial + sessions: SessionEntry[] + [key: string]: unknown +} + +function emptyLedger(): LedgerData { + return { + 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, + repeated_reads_warned: 0, + estimated_savings_vs_bare_cli: 0, + injection_tokens_estimated: 0, + }, + sessions: [], + daemon_usage: [], + waste_flags: [], + optimization_report: { last_generated: null, patterns: [] }, + } +} + +/** Build the ledger entry for the current session state (idempotent). */ +function buildSessionEntry(session: SessionState): SessionEntry { + const reads = Object.entries(session.files_read).map(([file, data]) => ({ + file, + tokens_estimated: data.tokens, + was_repeated: data.count > 1, + anatomy_had_description: data.anatomy_hit === true, + })) + + const writes = session.files_written.map((w) => ({ + file: w.file, + tokens_estimated: w.tokens, + action: w.action, + })) + + return { + id: session.session_id, + agent: "opencode", + started: session.started, + ended: new Date().toISOString(), + reads, + writes, + totals: { + input_tokens_estimated: reads.reduce((sum, r) => sum + r.tokens_estimated, 0), + output_tokens_estimated: writes.reduce((sum, w) => sum + w.tokens_estimated, 0), + reads_count: reads.length, + writes_count: writes.length, + // Honest accounting: only reads the hook actually denied count as + // blocked (warnings do not prevent the read from happening). + repeated_reads_blocked: session.reads_denied ?? 0, + repeated_reads_warned: session.repeated_reads_warned ?? 0, + anatomy_lookups: session.anatomy_hits, + anatomy_misses: session.anatomy_misses, + // Honest savings: tokens of reads that were denied, nothing else. + savings_estimated: session.denied_tokens_saved ?? 0, + injection_tokens_estimated: session.injected_tokens_estimated ?? 0, + }, + } +} + +function addInto(target: Record, key: string, value: number | undefined): void { + if (typeof value !== "number" || !isFinite(value)) return + target[key] = (target[key] ?? 0) + value +} + +/** Copy only real numeric fields out of a possibly-partial totals object. */ +function numericFields(source: Record | undefined): Record { + const out: Record = {} + for (const [k, v] of Object.entries(source ?? {})) { + if (typeof v === "number" && isFinite(v)) out[k] = v + } + return out +} + +function foldEntry(acc: Record, e: SessionEntry): void { + addInto(acc, "total_tokens_estimated", e.totals.input_tokens_estimated + e.totals.output_tokens_estimated) + addInto(acc, "total_reads", e.totals.reads_count) + addInto(acc, "total_writes", e.totals.writes_count) + addInto(acc, "anatomy_hits", e.totals.anatomy_lookups) + addInto(acc, "anatomy_misses", e.totals.anatomy_misses) + addInto(acc, "repeated_reads_blocked", e.totals.repeated_reads_blocked) + addInto(acc, "repeated_reads_warned", e.totals.repeated_reads_warned) + addInto(acc, "estimated_savings_vs_bare_cli", e.totals.savings_estimated) + addInto(acc, "injection_tokens_estimated", e.totals.injection_tokens_estimated) +} + +/** + * Derive lifetime = baseline + fold(sessions). total_sessions is intentionally + * NOT derived here β€” session start counts it once per new session. + */ +function recomputeLifetime(ledger: LedgerData): void { + const acc = numericFields(ledger.lifetime_baseline) + delete acc.total_sessions + for (const e of ledger.sessions) foldEntry(acc, e) + const totalSessions = ledger.lifetime?.total_sessions ?? 0 + ledger.lifetime = { + total_tokens_estimated: 0, + total_reads: 0, + total_writes: 0, + anatomy_hits: 0, + anatomy_misses: 0, + repeated_reads_blocked: 0, + repeated_reads_warned: 0, + estimated_savings_vs_bare_cli: 0, + injection_tokens_estimated: 0, + ...acc, + total_sessions: totalSessions, + } as LifetimeTotals +} + +/** Upsert the entry, roll old sessions into the baseline, derive lifetime. */ +function flushSessionToLedger(wolfDir: string, entry: SessionEntry): void { + if (!entry.id) return + const ledgerPath = path.join(wolfDir, "token-ledger.json") + const ledger = readJSON(ledgerPath, emptyLedger()) + if (!Array.isArray(ledger.sessions)) ledger.sessions = [] + + const idx = ledger.sessions.findIndex((s) => s && s.id === entry.id) + if (idx >= 0) ledger.sessions[idx] = entry + else ledger.sessions.push(entry) + + while (ledger.sessions.length > MAX_LEDGER_SESSIONS) { + const oldest = ledger.sessions.shift()! + const baseline = numericFields(ledger.lifetime_baseline) + foldEntry(baseline, oldest) + ledger.lifetime_baseline = baseline + } + + recomputeLifetime(ledger) + writeJSON(ledgerPath, ledger) +} + +export function handleStop(directory: string, sessionId: string): void { + const wolfDir = getWolfDir(directory) + if (!fs.existsSync(wolfDir)) return + + const hooksDir = path.join(wolfDir, "hooks") + const sessionFile = sessionFilePath(hooksDir, sessionId) + + 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++ + + const readCount = Object.keys(session.files_read).length + const writeCount = session.files_written.length + + if (readCount === 0 && writeCount === 0) { + writeJSON(sessionFile, session) + return + } + + checkForMissingBugLogs(session) + + // Idempotent ledger write: the entry for this session id is REPLACED, not + // appended β€” Stop fires every turn, and appending per turn is what used to + // duplicate sessions and quadratically inflate lifetime totals. + const entry = buildSessionEntry(session) + flushSessionToLedger(wolfDir, entry) + + appendSessionSummary(wolfDir, session, readCount, writeCount) + + writeJSON(sessionFile, session) +} + +function checkForMissingBugLogs(session: SessionState): void { + 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) { + const buglogWritten = session.files_written.some(w => w.file.includes("buglog.json")) + if (!buglogWritten) { + console.warn(`⚠️ OpenWolf: Files edited 3+ times this session (${multiEditFiles.join(", ")}) but buglog.json was not updated. If you fixed bugs, please log them.`) + } + } +} + +function appendSessionSummary(wolfDir: string, session: SessionState, readCount: number, writeCount: number): void { + if (writeCount > 0) { + try { + const inputTokens = Object.values(session.files_read).reduce((sum, r) => sum + r.tokens, 0) + const outputTokens = session.files_written.reduce((sum, w) => sum + w.tokens, 0) + 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 {} + } +} diff --git a/.opencode/plugin/openwolf/types.ts b/.opencode/plugin/openwolf/types.ts new file mode 100644 index 0000000..da3c862 --- /dev/null +++ b/.opencode/plugin/openwolf/types.ts @@ -0,0 +1,46 @@ +export interface FileRead { + count: number + tokens: number + first_read: string + /** mtime of the file when it was read β€” repeat warnings only fire while unchanged (issue #41). */ + read_mtime?: number + anatomy_hit?: boolean +} + +export interface FileWrite { + file: string + action: string + tokens: number + at: string +} + +export interface SessionState { + session_id: string + started: string + files_read: Record + files_written: FileWrite[] + edit_counts: Record + anatomy_hits: number + anatomy_misses: number + repeated_reads_warned: number + reads_denied?: number + denied_tokens_saved?: number + cerebrum_warnings: number + stop_count: number +} + +export type PartialSessionState = Partial + +export interface FixDetection { + category: string + summary: string + rootCause: string + fix: string + context?: string +} + +export interface AnatomyEntry { + file: string + description: string + tokens: number +} \ No newline at end of file diff --git a/.wolf/.gitignore b/.wolf/.gitignore new file mode 100644 index 0000000..c2eb64f --- /dev/null +++ b/.wolf/.gitignore @@ -0,0 +1,15 @@ +# OpenWolf: machine-local state (committed state: cerebrum.md, STATUS.md, +# memory.md, buglog.json, anatomy.md, anatomy-index.json, config.json, OPENWOLF.md) +hooks/ +backups/ +cache/ +daemon.log +daemon.pid +dashboard-token +token-ledger.json +suggestions.json +cron-state.json +_scan-state.json +_*.json +*.lock +*.tmp diff --git a/.wolf/OPENWOLF.md b/.wolf/OPENWOLF.md new file mode 100644 index 0000000..9f81c47 --- /dev/null +++ b/.wolf/OPENWOLF.md @@ -0,0 +1,44 @@ +# OpenWolf Operating Protocol + +You are working in an OpenWolf-managed project. These rules apply every turn. + +OpenWolf's hooks handle the bookkeeping: they maintain `.wolf/anatomy.md` and `.wolf/memory.md` after writes, track reads, and surface anatomy hints when you read files. Do not update those two files manually unless your agent has no OpenWolf hooks installed (Gemini CLI, Cursor). + +## STATUS.md: read first, keep fresh + +`.wolf/STATUS.md` is the handoff document. Read it FIRST when resuming a session; it replaces re-reading memory, plans, and code to reconstruct context. + +Keep it fresh: when the user signals a quest is done ("done", "ship it", "next phase", "/clear", "wrap up"), move finished items to the done section, write the next quest (objective, files, decisions), and bump the date. Do this before responding "done" on any multi-file task and before suggesting `/clear`. A stale STATUS.md wastes the next session. + +## File navigation + +1. Before reading an unfamiliar file, grep `.wolf/anatomy.md` for its path to get a one-line description and token estimate. Do NOT read anatomy.md whole; it is an index, not a document. +2. If the description answers your question, skip the full read. For large files, prefer Read with offset/limit over whole-file reads. +3. If a file is not in anatomy.md, search with Grep/Glob. Regenerate the index with `openwolf scan`. + +## Code generation + +1. Before generating code, check `.wolf/cerebrum.md`: respect `## Do-Not-Repeat` (past mistakes), `## Key Learnings`, and `## User Preferences`. +2. Update cerebrum.md whenever you learn something: a user correction or preference, a project convention not obvious from code, an API surprise, a gotcha that would trip a fresh session, a significant decision and its why. The bar is LOW; a redundant entry costs nothing, a missing one repeats the discovery next session. + +## Bug logging + +Before fixing any bug: grep `.wolf/buglog.json` for the error message or filename; the fix may already be known. + +After fixing any bug, failed test, failed build, or user-reported problem: append an entry with `id`, `timestamp`, `error_message`, `file`, `root_cause`, `fix`, `tags`, `occurrences`, `last_seen`. Also log when you edit a file more than twice to get it right. The threshold is LOW. + +## Token discipline + +- Never re-read a file already read this session unless it changed since. +- Prefer anatomy descriptions and targeted Grep over full file reads. +- If appending to a file, do not read the entire file first. + +## Session end + +Before wrapping up: update `.wolf/STATUS.md`, write a one-line session summary to `.wolf/memory.md` (`| HH:MM | description | file(s) | outcome | ~tokens |`), and record any learnings or bugs from the session in cerebrum.md / buglog.json. + +## On-demand skills + +- `/designqc`: screenshot-based design review of the running app (uses `openwolf designqc`). +- `/reframe`: UI framework selection, migration, and anti-generic design audits. +- `/security-audit`: security review of the project. diff --git a/.wolf/STATUS.md b/.wolf/STATUS.md new file mode 100644 index 0000000..f44a370 --- /dev/null +++ b/.wolf/STATUS.md @@ -0,0 +1,68 @@ +--- +description: session handoff, regenerate with /handoff when a quest finishes +budget_tokens: 1000 +--- +# STATUS β€” github-actions + +> Single source of truth for resuming work. Read this FIRST when starting a session. +> Update this file at the end of every work phase so the next `/clear` resumes in 1 read. +> Last updated: 2026-10-03 + +--- + +## βœ… Done + + + +- (nothing yet β€” fill in as work completes) + +--- + +## πŸš€ Next phase + +**Goal:** __ + +### Acceptance criteria +1. __ +2. _<...>_ + +### Files to create / edit +| Type | File | Content | +|---|---|---| +| new | `path/to/file.ts` | _what it does_ | + +### Closed decisions +- __ + +### Open decisions +- __ + +--- + +## πŸ“ Active architecture + +- **Stack:** __ +- **Key tables / modules:** __ +- **Patterns:** __ + +--- + +## ⚠️ External blockers (don't block coding) + +- __ + +--- + +## πŸ”§ Useful commands + +```bash +# add the most-used commands here so the next session has them ready +``` + +--- + +## πŸ“š References (read IF needed) + +- `.wolf/cerebrum.md` β€” User Preferences + Do-Not-Repeat + Decision Log +- `.wolf/anatomy.md` β€” token-efficient file index +- `.wolf/buglog.json` β€” known bugs + fixes diff --git a/.wolf/anatomy-index.json b/.wolf/anatomy-index.json new file mode 100644 index 0000000..aaf6712 --- /dev/null +++ b/.wolf/anatomy-index.json @@ -0,0 +1,433 @@ +{ + "version": 1, + "meta": { + "lastScanned": "2026-10-03T20:28:16.516Z", + "fileCount": 46, + "hits": 0, + "misses": 0, + "renderedHash": "2c2016e3335a7e10ebf2bed6e1a7aef4df92d1a6eb684377d2c7edb6515b7860", + "storeUpdatedAt": "2026-10-03T20:28:16.522Z", + "rootHash": "e6c97c0cc0b4827b" + }, + "files": { + ".github/dependabot.yml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 553, + "hash": "9d6ade8a4092bb53", + "size": 1938, + "mtimeMs": 1782948155728.5264, + "updatedAt": "2026-10-03T20:25:02.281Z", + "source": "scan" + }, + ".github/workflows/pr.yml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 9908, + "hash": "c3ff262722c129dc", + "size": 34736, + "mtimeMs": 1782948155728.7117, + "updatedAt": "2026-10-03T20:25:02.281Z", + "source": "scan" + }, + ".github/workflows/release.yml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 3830, + "hash": "df6d2e61ef61750a", + "size": 13444, + "mtimeMs": 1782948155728.8574, + "updatedAt": "2026-10-03T20:25:02.282Z", + "source": "scan" + }, + ".gitignore": { + "description": "Git ignore rules", + "tokens": 147, + "hash": "a254d2643baf66ae", + "size": 549, + "mtimeMs": 1766149647115.7715, + "updatedAt": "2026-10-03T20:25:02.282Z", + "source": "scan" + }, + "AGENTS.md": { + "description": "Firestoned GitHub Actions - Development Guidelines", + "tokens": 3931, + "hash": "877098a728626aca", + "size": 15923, + "mtimeMs": 1791059102272.5442, + "updatedAt": "2026-10-03T20:25:02.282Z", + "source": "scan" + }, + "CHANGELOG.md": { + "description": "Change log", + "tokens": 3035, + "hash": "b46a060dc05bd54f", + "size": 12158, + "mtimeMs": 1782948155729.0234, + "updatedAt": "2026-10-03T20:25:02.282Z", + "source": "scan" + }, + "CLAUDE.md": { + "description": "OpenWolf", + "tokens": 3955, + "hash": "4ad27424ff6d7a55", + "size": 16022, + "mtimeMs": 1791059102258.64, + "updatedAt": "2026-10-03T20:25:02.282Z", + "source": "scan" + }, + "CONTRIBUTING.md": { + "description": "Contributing to Firestoned GitHub Actions", + "tokens": 3013, + "hash": "c98272a90b41b8e5", + "size": 12072, + "mtimeMs": 1766149615365.6882, + "updatedAt": "2026-10-03T20:25:02.282Z", + "source": "scan" + }, + "docker/setup-docker/action.yaml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 229, + "hash": "897539a98c56983f", + "size": 799, + "mtimeMs": 1782948155729.16, + "updatedAt": "2026-10-03T20:25:02.282Z", + "source": "scan" + }, + "docker/setup-docker/README.md": { + "description": "Project documentation", + "tokens": 3631, + "hash": "93a44471ab7f369f", + "size": 14527, + "mtimeMs": 1766119151529.0273, + "updatedAt": "2026-10-03T20:25:02.283Z", + "source": "scan" + }, + "examples/README.md": { + "description": "Project documentation", + "tokens": 1311, + "hash": "6ccaae2727d40ae3", + "size": 5259, + "mtimeMs": 1766197135011.6296, + "updatedAt": "2026-10-03T20:25:02.283Z", + "source": "scan" + }, + "examples/rust-library-ci.yml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 1932, + "hash": "0fecadc5bbf14ee4", + "size": 6763, + "mtimeMs": 1766197135011.9111, + "updatedAt": "2026-10-03T20:25:02.283Z", + "source": "scan" + }, + "LICENSE": { + "description": "Project license", + "tokens": 290, + "hash": "67f13431e6092532", + "size": 1084, + "mtimeMs": 1766118366889.6013, + "updatedAt": "2026-10-03T20:25:02.283Z", + "source": "scan" + }, + "README_SAMPLES.md": { + "description": "README Samples - Quality Demonstration", + "tokens": 2011, + "hash": "f6490635810fbe93", + "size": 8048, + "mtimeMs": 1766149446565.0881, + "updatedAt": "2026-10-03T20:25:02.283Z", + "source": "scan" + }, + "README.md": { + "description": "Project documentation", + "tokens": 3645, + "hash": "370f50afae7517d9", + "size": 14659, + "mtimeMs": 1766330796258.824, + "updatedAt": "2026-10-03T20:25:02.283Z", + "source": "scan" + }, + "rust/build-binary/action.yaml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 1306, + "hash": "e7f75991e909dbe7", + "size": 4573, + "mtimeMs": 1782948155729.43, + "updatedAt": "2026-10-03T20:25:02.284Z", + "source": "scan" + }, + "rust/build-binary/README.md": { + "description": "Project documentation", + "tokens": 3856, + "hash": "b8a2a2bc0e7a26ce", + "size": 15421, + "mtimeMs": 1782948155729.307, + "updatedAt": "2026-10-03T20:25:02.284Z", + "source": "scan" + }, + "rust/build-library/action.yml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 1751, + "hash": "d247b20b272f07aa", + "size": 6133, + "mtimeMs": 1766330796259.519, + "updatedAt": "2026-10-03T20:25:02.284Z", + "source": "scan" + }, + "rust/build-library/README.md": { + "description": "Project documentation", + "tokens": 3136, + "hash": "05d3274a83c35cb0", + "size": 12546, + "mtimeMs": 1766197135012.2014, + "updatedAt": "2026-10-03T20:25:02.284Z", + "source": "scan" + }, + "rust/cache-cargo/action.yml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 339, + "hash": "7c2e5271694185f2", + "size": 1185, + "mtimeMs": 1782948155729.694, + "updatedAt": "2026-10-03T20:25:02.284Z", + "source": "scan" + }, + "rust/cache-cargo/README.md": { + "description": "Project documentation", + "tokens": 2344, + "hash": "c5adb60d8e6b466e", + "size": 9430, + "mtimeMs": 1782948155729.591, + "updatedAt": "2026-10-03T20:25:02.285Z", + "source": "scan" + }, + "rust/generate-sbom/action.yaml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 1433, + "hash": "327c71fe4634a2c8", + "size": 5013, + "mtimeMs": 1782948155729.827, + "updatedAt": "2026-10-03T20:25:02.285Z", + "source": "scan" + }, + "rust/generate-sbom/README.md": { + "description": "Project documentation", + "tokens": 4028, + "hash": "92e5fc242956d061", + "size": 16154, + "mtimeMs": 1766586562294.5886, + "updatedAt": "2026-10-03T20:25:02.285Z", + "source": "scan" + }, + "rust/lint/action.yml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 1609, + "hash": "bdbcbc3a67fb33a9", + "size": 5646, + "mtimeMs": 1766330796260.2625, + "updatedAt": "2026-10-03T20:25:02.285Z", + "source": "scan" + }, + "rust/lint/README.md": { + "description": "Project documentation", + "tokens": 3145, + "hash": "5b1890aece87e0e5", + "size": 12582, + "mtimeMs": 1766330796259.976, + "updatedAt": "2026-10-03T20:25:02.285Z", + "source": "scan" + }, + "rust/package-crate/action.yaml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 1084, + "hash": "1c61e7fe3eece90b", + "size": 3796, + "mtimeMs": 1782948155730.1328, + "updatedAt": "2026-10-03T20:25:02.285Z", + "source": "scan" + }, + "rust/package-crate/README.md": { + "description": "Project documentation", + "tokens": 2709, + "hash": "1b709d89f66d0242", + "size": 10863, + "mtimeMs": 1782948155730.0051, + "updatedAt": "2026-10-03T20:25:02.286Z", + "source": "scan" + }, + "rust/publish-crate/action.yaml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 1223, + "hash": "39d847a3364ecef4", + "size": 4287, + "mtimeMs": 1782948155730.397, + "updatedAt": "2026-10-03T20:25:02.286Z", + "source": "scan" + }, + "rust/publish-crate/README.md": { + "description": "Project documentation", + "tokens": 2816, + "hash": "bd54d0987d37baf9", + "size": 11305, + "mtimeMs": 1782948155730.2805, + "updatedAt": "2026-10-03T20:25:02.286Z", + "source": "scan" + }, + "rust/security-scan/action.yaml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 640, + "hash": "759635965cc8b23d", + "size": 2239, + "mtimeMs": 1782948155730.5122, + "updatedAt": "2026-10-03T20:25:02.286Z", + "source": "scan" + }, + "rust/security-scan/README.md": { + "description": "Project documentation", + "tokens": 2523, + "hash": "d37ada2896aac369", + "size": 10092, + "mtimeMs": 1766586562295.3625, + "updatedAt": "2026-10-03T20:25:02.286Z", + "source": "scan" + }, + "rust/setup-rust-build/action.yaml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 682, + "hash": "c2dacd1b86c5f1a9", + "size": 2387, + "mtimeMs": 1782948155730.6267, + "updatedAt": "2026-10-03T20:25:02.286Z", + "source": "scan" + }, + "rust/setup-rust-build/README.md": { + "description": "Project documentation", + "tokens": 2512, + "hash": "8ad244705da3275c", + "size": 10048, + "mtimeMs": 1766962108871.64, + "updatedAt": "2026-10-03T20:25:02.287Z", + "source": "scan" + }, + "rust/verify-toolchain/action.yml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 1404, + "hash": "8658d6c51ae360bb", + "size": 4932, + "mtimeMs": 1766330796261.5955, + "updatedAt": "2026-10-03T20:25:02.287Z", + "source": "scan" + }, + "rust/verify-toolchain/README.md": { + "description": "Project documentation", + "tokens": 2774, + "hash": "ff9a3ccadf122b64", + "size": 11110, + "mtimeMs": 1766330796261.2056, + "updatedAt": "2026-10-03T20:25:02.287Z", + "source": "scan" + }, + "security/cosign-sign/action.yaml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 1122, + "hash": "f43f508d58097271", + "size": 3935, + "mtimeMs": 1782948155730.7405, + "updatedAt": "2026-10-03T20:25:02.287Z", + "source": "scan" + }, + "security/cosign-sign/README.md": { + "description": "Project documentation", + "tokens": 3449, + "hash": "3c858a2b5568cc48", + "size": 13798, + "mtimeMs": 1766118968792.6514, + "updatedAt": "2026-10-03T20:25:02.287Z", + "source": "scan" + }, + "security/license-check/action.yml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 1848, + "hash": "b302c613e1c360f7", + "size": 6962, + "mtimeMs": 1766119566167.3772, + "updatedAt": "2026-10-03T20:25:02.287Z", + "source": "scan" + }, + "security/license-check/README.md": { + "description": "Project documentation", + "tokens": 4315, + "hash": "1d408963f137cbe9", + "size": 17273, + "mtimeMs": 1766119724547.5344, + "updatedAt": "2026-10-03T20:25:02.288Z", + "source": "scan" + }, + "security/trivy-scan/action.yaml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 933, + "hash": "88500e843d18116c", + "size": 3263, + "mtimeMs": 1782948155730.9927, + "updatedAt": "2026-10-03T20:25:02.288Z", + "source": "scan" + }, + "security/trivy-scan/README.md": { + "description": "Project documentation", + "tokens": 3287, + "hash": "a2b2379976b8bb71", + "size": 13145, + "mtimeMs": 1782948155730.8743, + "updatedAt": "2026-10-03T20:25:02.288Z", + "source": "scan" + }, + "security/verify-signed-commits/action.yaml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 1052, + "hash": "a5e81a400289d39b", + "size": 3688, + "mtimeMs": 1766118998939.8027, + "updatedAt": "2026-10-03T20:25:02.288Z", + "source": "scan" + }, + "security/verify-signed-commits/README.md": { + "description": "Project documentation", + "tokens": 3628, + "hash": "63472d25069ef2f2", + "size": 14535, + "mtimeMs": 1766119069393.7017, + "updatedAt": "2026-10-03T20:25:02.288Z", + "source": "scan" + }, + "versioning/extract-version/action.yml": { + "description": "SPDX-License-Identifier: MIT", + "tokens": 1092, + "hash": "4a151885a667a4df", + "size": 3821, + "mtimeMs": 1773919224025.4333, + "updatedAt": "2026-10-03T20:25:02.288Z", + "source": "scan" + }, + "versioning/extract-version/README.md": { + "description": "Project documentation", + "tokens": 4453, + "hash": "62084504e15ca6f7", + "size": 17826, + "mtimeMs": 1773919224024.8845, + "updatedAt": "2026-10-03T20:25:02.289Z", + "source": "scan" + }, + ".claude/rules/no-real-infrastructure.md": { + "description": "Never Commit Real Infrastructure Identifiers or Personal Data", + "tokens": 1132, + "hash": "49bcf9d40b013ed2", + "size": 4530, + "mtimeMs": 1791059296469.5054, + "updatedAt": "2026-10-03T20:28:16.516Z", + "source": "hook" + } + }, + "preamble": [ + "> Project structure index. Auto-maintained by OpenWolf hooks and daemon.", + "> Run `openwolf scan` to generate, or wait for the first Claude Code session.", + "> Status: Pending initial scan" + ] +} \ No newline at end of file diff --git a/.wolf/anatomy.md b/.wolf/anatomy.md new file mode 100644 index 0000000..64d287e --- /dev/null +++ b/.wolf/anatomy.md @@ -0,0 +1,117 @@ +# anatomy.md + +> Auto-maintained by OpenWolf. Last scanned: 2026-10-03T20:28:16.516Z +> Files: 46 tracked | Anatomy hits: 0 | Misses: 0 + +> Project structure index. Auto-maintained by OpenWolf hooks and daemon. +> Run `openwolf scan` to generate, or wait for the first Claude Code session. +> Status: Pending initial scan + +## ./ + +- `.gitignore` β€” Git ignore rules (~147 tok) +- `AGENTS.md` β€” Firestoned GitHub Actions - Development Guidelines (~3931 tok) +- `CHANGELOG.md` β€” Change log (~3035 tok) +- `CLAUDE.md` β€” OpenWolf (~3955 tok) +- `CONTRIBUTING.md` β€” Contributing to Firestoned GitHub Actions (~3013 tok) +- `LICENSE` β€” Project license (~290 tok) +- `README_SAMPLES.md` β€” README Samples - Quality Demonstration (~2011 tok) +- `README.md` β€” Project documentation (~3645 tok) + +## .claude/rules/ + +- `no-real-infrastructure.md` β€” Never Commit Real Infrastructure Identifiers or Personal Data (~1132 tok) + +## .github/ + +- `dependabot.yml` β€” SPDX-License-Identifier: MIT (~553 tok) + +## .github/workflows/ + +- `pr.yml` β€” SPDX-License-Identifier: MIT (~9908 tok) +- `release.yml` β€” SPDX-License-Identifier: MIT (~3830 tok) + +## docker/setup-docker/ + +- `action.yaml` β€” SPDX-License-Identifier: MIT (~229 tok) +- `README.md` β€” Project documentation (~3631 tok) + +## examples/ + +- `README.md` β€” Project documentation (~1311 tok) +- `rust-library-ci.yml` β€” SPDX-License-Identifier: MIT (~1932 tok) + +## rust/build-binary/ + +- `action.yaml` β€” SPDX-License-Identifier: MIT (~1306 tok) +- `README.md` β€” Project documentation (~3856 tok) + +## rust/build-library/ + +- `action.yml` β€” SPDX-License-Identifier: MIT (~1751 tok) +- `README.md` β€” Project documentation (~3136 tok) + +## rust/cache-cargo/ + +- `action.yml` β€” SPDX-License-Identifier: MIT (~339 tok) +- `README.md` β€” Project documentation (~2344 tok) + +## rust/generate-sbom/ + +- `action.yaml` β€” SPDX-License-Identifier: MIT (~1433 tok) +- `README.md` β€” Project documentation (~4028 tok) + +## rust/lint/ + +- `action.yml` β€” SPDX-License-Identifier: MIT (~1609 tok) +- `README.md` β€” Project documentation (~3145 tok) + +## rust/package-crate/ + +- `action.yaml` β€” SPDX-License-Identifier: MIT (~1084 tok) +- `README.md` β€” Project documentation (~2709 tok) + +## rust/publish-crate/ + +- `action.yaml` β€” SPDX-License-Identifier: MIT (~1223 tok) +- `README.md` β€” Project documentation (~2816 tok) + +## rust/security-scan/ + +- `action.yaml` β€” SPDX-License-Identifier: MIT (~640 tok) +- `README.md` β€” Project documentation (~2523 tok) + +## rust/setup-rust-build/ + +- `action.yaml` β€” SPDX-License-Identifier: MIT (~682 tok) +- `README.md` β€” Project documentation (~2512 tok) + +## rust/verify-toolchain/ + +- `action.yml` β€” SPDX-License-Identifier: MIT (~1404 tok) +- `README.md` β€” Project documentation (~2774 tok) + +## security/cosign-sign/ + +- `action.yaml` β€” SPDX-License-Identifier: MIT (~1122 tok) +- `README.md` β€” Project documentation (~3449 tok) + +## security/license-check/ + +- `action.yml` β€” SPDX-License-Identifier: MIT (~1848 tok) +- `README.md` β€” Project documentation (~4315 tok) + +## security/trivy-scan/ + +- `action.yaml` β€” SPDX-License-Identifier: MIT (~933 tok) +- `README.md` β€” Project documentation (~3287 tok) + +## security/verify-signed-commits/ + +- `action.yaml` β€” SPDX-License-Identifier: MIT (~1052 tok) +- `README.md` β€” Project documentation (~3628 tok) + +## versioning/extract-version/ + +- `action.yml` β€” SPDX-License-Identifier: MIT (~1092 tok) +- `README.md` β€” Project documentation (~4453 tok) diff --git a/.wolf/buglog.json b/.wolf/buglog.json new file mode 100755 index 0000000..a11dde9 --- /dev/null +++ b/.wolf/buglog.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "bugs": [] +} diff --git a/.wolf/cerebrum.md b/.wolf/cerebrum.md new file mode 100644 index 0000000..aa1a849 --- /dev/null +++ b/.wolf/cerebrum.md @@ -0,0 +1,28 @@ +--- +description: learned preferences, project conventions, and Do-Not-Repeat rules +budget_tokens: 2000 +--- +# Cerebrum + +> OpenWolf's learning memory. Updated automatically as the AI learns from interactions. +> Do not edit manually unless correcting an error. +> Last updated: 2026-10-03 + +## User Preferences + + + +## Key Learnings + +- **Project:** github-actions +- **Description:** **Reusable composite GitHub Actions for CI/CD pipelines** + +## Do-Not-Repeat + + + +- [2026-10-03] `openwolf init` wrote absolute home-dir paths into `.claude/settings.json` hook commands. Tracked OpenWolf/opencode/Claude files must hold no personal data or home paths: use `$CLAUDE_PROJECT_DIR` in hooks and run the sweep in `.claude/rules/no-real-infrastructure.md` before committing. + +## Decision Log + + diff --git a/.wolf/config.json b/.wolf/config.json new file mode 100644 index 0000000..312297a --- /dev/null +++ b/.wolf/config.json @@ -0,0 +1,116 @@ +{ + "version": 1, + "openwolf": { + "enabled": true, + "reads": { + "duplicate_mode": "warn", + "skeleton_hints": true + }, + "bash": { + "filter_mode": "suggest", + "governor": { + "mode": "replace", + "threshold_tokens": 2000, + "families": { + "grep_flood": "replace", + "file_print": "replace", + "git_show": "replace", + "test": "suggest", + "build": "suggest", + "unknown": "suggest" + } + } + }, + "anatomy": { + "auto_scan_on_init": true, + "rescan_interval_hours": 6, + "max_description_length": 100, + "max_files": 500, + "exclude_patterns": [ + "node_modules", + "dist", + "build", + ".next", + ".nuxt", + ".turbo", + ".vercel", + ".netlify", + ".output", + "coverage", + "__pycache__", + ".venv", + "venv", + "site-packages", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".tox", + "*.pyc", + ".gradle", + "target", + ".m2", + ".cargo", + ".git", + ".wolf", + ".cache", + ".vscode", + ".idea", + ".DS_Store", + "Thumbs.db", + "*.min.js", + "*.min.css", + "*.map" + ], + "respect_gitignore": true + }, + "token_audit": { + "enabled": true, + "report_frequency": "weekly", + "waste_threshold_percent": 15, + "chars_per_token_code": 3.5, + "chars_per_token_prose": 4 + }, + "cron": { + "enabled": true, + "max_retry_attempts": 3, + "dead_letter_enabled": true, + "heartbeat_interval_minutes": 30 + }, + "memory": { + "consolidation_after_days": 7, + "max_entries_before_consolidation": 200 + }, + "cerebrum": { + "max_tokens": 2000, + "reflection_frequency": "weekly" + }, + "daemon": { + "port": 18802, + "log_level": "info" + }, + "dashboard": { + "enabled": true, + "port": 18803, + "host": "127.0.0.1" + }, + "context": { + "session_digest_budget_tokens": 1500, + "reinjection_interval": 25, + "state_budgets": { + ".wolf/cerebrum.md": 2000, + ".wolf/STATUS.md": 1000 + }, + "budgets": { + "claude": 1500, + "codex": 1200, + "gemini": 1200, + "opencode": 1200, + "cursor": 800 + } + }, + "agents": [ + "claude", + "opencode" + ] + } +} \ No newline at end of file diff --git a/.wolf/cron-manifest.json b/.wolf/cron-manifest.json new file mode 100755 index 0000000..7e58c5f --- /dev/null +++ b/.wolf/cron-manifest.json @@ -0,0 +1,66 @@ +{ + "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 + } + ] +} diff --git a/.wolf/memory.md b/.wolf/memory.md new file mode 100755 index 0000000..f073936 --- /dev/null +++ b/.wolf/memory.md @@ -0,0 +1,13 @@ +--- +description: chronological action log per session, consolidated weekly +--- +# Memory + +> Chronological action log. Hooks and AI append to this file automatically. +> Old sessions are consolidated by the daemon weekly. + +## Session: 2026-10-03 16:25 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 16:28 | Created .claude/rules/no-real-infrastructure.md | β€” | ~1207 | diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2eaec0d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,558 @@ +# Firestoned GitHub Actions - Development Guidelines + +This document provides comprehensive guidance for developing, maintaining, and contributing to this GitHub Actions repository. + +## Critical Requirements + +**CRITICAL**: Any time we make any changes to the GitHub Actions, make sure the readme/docs are fully in sync and we have test cases for each one. + +## Project Overview + +**Repository**: Firestoned GitHub Actions - A collection of production-ready, reusable GitHub Actions composite workflows +**Organization**: Firestoned (enterprise-focused) +**Primary Language**: Bash with YAML configuration +**Focus**: Rust, Security/Compliance, Docker, and Versioning +**License**: MIT +**Status**: Active development with 16 actions across 5 categories + +## Repository Structure + +``` +github-actions/ +β”œβ”€β”€ rust/ # 10 Rust-specific actions +β”‚ β”œβ”€β”€ cache-cargo +β”‚ β”œβ”€β”€ setup-rust-build +β”‚ β”œβ”€β”€ verify-toolchain +β”‚ β”œβ”€β”€ build-binary +β”‚ β”œβ”€β”€ build-library +β”‚ β”œβ”€β”€ lint +β”‚ β”œβ”€β”€ security-scan +β”‚ β”œβ”€β”€ generate-sbom +β”‚ β”œβ”€β”€ package-crate +β”‚ └── publish-crate +β”œβ”€β”€ security/ # 4 Security & Compliance actions +β”‚ β”œβ”€β”€ license-check +β”‚ β”œβ”€β”€ verify-signed-commits +β”‚ β”œβ”€β”€ trivy-scan +β”‚ └── cosign-sign +β”œβ”€β”€ docker/ # 1 Docker action +β”‚ └── setup-docker +β”œβ”€β”€ versioning/ # 1 Versioning action +β”‚ └── extract-version +β”œβ”€β”€ examples/ # Example workflows +β”œβ”€β”€ .github/workflows/ # CI/CD automation +β”‚ β”œβ”€β”€ pr.yml # Pull request testing workflow +β”‚ └── release.yml # Release automation workflow +``` + +## Action Design Principles + +### Core Principles + +1. **Zero Hardcoding**: All values must be input parameters - no hardcoded paths or values +2. **Sensible Defaults**: Optional inputs must have practical, production-ready defaults +3. **Composability**: Actions must chain together naturally for complex workflows +4. **Security First**: No secret logging, strict input validation, least privilege principle +5. **Enterprise Ready**: SBOM generation, compliance tracking, audit trails +6. **Multi-platform**: Support for Linux, macOS, Windows where applicable +7. **Language Agnostic**: Design actions to work with any language when possible +8. **Fail-Fast**: Clear error messages with helpful guidance for resolution +9. **Comprehensive Documentation**: Every action requires complete documentation + +### Mandatory Action Components + +Every action MUST include: + +1. **action.yml/action.yaml** with: + - SPDX license header in first 10 lines + - Clear name and description + - All inputs with descriptions and defaults + - All outputs with descriptions + - Proper branding configuration + +2. **README.md** (300-700 lines) with: + - Title and brief description (1-2 paragraphs) + - Features list (bullet points) + - Usage section (basic and complete examples) + - Inputs table (all parameters documented) + - Outputs table (if applicable) + - Examples section (minimum 5 scenarios) + - How It Works section (technical details) + - Best Practices section + - Troubleshooting section + - Advanced Usage section + - Compatibility section + - Related Actions section + - License section + - Contributing link + +3. **Test Coverage** in `.github/workflows/pr.yml`: + - Functional tests with realistic scenarios + - Matrix testing for multiple configurations + - Validation tests for syntax and required fields + - Negative testing (actions fail when they should) + - Output verification + +## Coding Standards + +### YAML (action.yml) + +```yaml +# Copyright header with SPDX license identifier (REQUIRED in first 10 lines) +# Copyright (c) 2025 Erick Bourgeois, firestoned +# SPDX-License-Identifier: MIT + +name: 'Action Name' +description: 'Clear, concise description' +author: 'Author Name' + +branding: + icon: 'icon-name' # From Feather icons + color: 'color' # blue, green, orange, red, purple, gray + +inputs: + input-name: + description: 'Input description' + required: true/false + default: 'default-value' + +outputs: + output-name: + description: 'Output description' + value: ${{ steps.step-id.outputs.value }} + +runs: + using: composite + steps: + # Implementation in Bash +``` + +**YAML Standards**: +- 2-space indentation +- Quoted strings for all values +- kebab-case for inputs, outputs, and step IDs +- No trailing whitespace +- Newline at end of file + +### Bash Scripts + +**Required Pattern**: +```bash +#!/usr/bin/env bash +set -euo pipefail + +# Always quote variables +echo "Value: ${VARIABLE}" + +# Use descriptive names +rust_target="${{ inputs.target }}" + +# Provide clear error messages +if [ ! -f "Cargo.toml" ]; then + echo "Error: Cargo.toml not found in current directory" + echo "Please run this action from a Rust project root" + exit 1 +fi +``` + +**Bash Standards**: +- Strict mode: `set -euo pipefail` +- Quote all variables: `"${VARIABLE}"` +- Descriptive variable names (no single letters except loop counters) +- Clear error messages with resolution guidance +- Use `compgen` for command/binary availability checks +- Proper exit codes (0 for success, non-zero for failure) + +### Markdown Documentation + +**Standards**: +- ATX-style headers (`#` not underlines) +- Fenced code blocks with language tags +- Tables for structured data (inputs, outputs) +- Clear section hierarchy (h2 for main sections, h3 for subsections) +- Code examples for all input combinations +- Cross-references to related actions + +## Naming Conventions + +- **Input names**: kebab-case (e.g., `copyright-holder`, `cargo-audit-version`) +- **Output names**: kebab-case (e.g., `fmt-status`, `tag-name`) +- **Step IDs**: kebab-case (e.g., `cache-cargo`, `verify-all`) +- **Action directories**: kebab-case category/action-name (e.g., `rust/cache-cargo`) +- **Variables**: snake_case in bash scripts (e.g., `rust_target`, `cargo_version`) + +## Common Patterns + +### Caching Pattern + +```yaml +- name: Cache tool binary + uses: actions/cache@v4 + with: + path: ~/.cargo/bin/tool-name + key: ${{ runner.os }}-tool-name-${{ inputs.tool-version }} +``` + +### Step Summary Pattern + +```yaml +- name: Generate step summary + shell: bash + run: | + echo "### Action Results" >> $GITHUB_STEP_SUMMARY + echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY + echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Status | βœ… Success |" >> $GITHUB_STEP_SUMMARY +``` + +### Output Pattern + +```yaml +outputs: + output-name: + description: 'Output description' + value: ${{ steps.step-id.outputs.value }} + +# In the step: +- name: Set output + id: step-id + shell: bash + run: | + echo "value=result" >> $GITHUB_OUTPUT +``` + +### Conditional Execution Pattern + +```yaml +- name: Optional step + if: inputs.enable-feature == 'true' + shell: bash + run: | + # Feature implementation +``` + +## Testing Requirements + +### Test Coverage Requirements + +Every action MUST have: +1. At least one functional test in `.github/workflows/pr.yml` +2. Tests for all major input combinations +3. Negative tests (failure scenarios) +4. Output verification +5. Matrix testing for multi-configuration actions + +### Test Pattern + +```yaml +test-action-name: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - config: value1 + description: 'Test case 1' + - config: value2 + description: 'Test case 2' + steps: + - uses: actions/checkout@v4 + + - name: Test action + uses: ./path/to/action + with: + input: ${{ matrix.config }} + + - name: Verify results + run: | + # Verification logic +``` + +## Commit Message Standards + +Follow conventional commits format: + +``` +(): + +[optional body] + +[optional footer] +``` + +**Types**: +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation only +- `test`: Adding or updating tests +- `refactor`: Code change that neither fixes a bug nor adds a feature +- `chore`: Changes to build process or auxiliary tools + +**Examples**: +``` +feat(rust): add cargo-deny security scanning +fix(security): correct Trivy SARIF output format +docs(readme): improve installation instructions +test(lint): add test for clippy warnings +``` + +## Pull Request Requirements + +Before submitting a PR, ensure: + +1. βœ… All commits are signed (GPG/SSH) +2. βœ… All tests pass locally +3. βœ… Documentation updated (README.md) +4. βœ… CHANGELOG.md updated +5. βœ… SPDX headers present in all action.yml files +6. βœ… Action README.md is comprehensive (300+ lines) +7. βœ… Test cases added to `.github/workflows/pr.yml` +8. βœ… No hardcoded values (all configurable via inputs) +9. βœ… Sensible defaults for optional inputs +10. βœ… Clear error messages with resolution guidance + +## Versioning Strategy + +### Semantic Versioning + +- **Major (v1, v2)**: Breaking changes to inputs/outputs +- **Minor (v1.1, v1.2)**: New features, backward compatible +- **Patch (v1.0.1, v1.0.2)**: Bug fixes + +### Version Tags + +- `v1` - Latest v1.x.x (auto-updates, recommended for most users) +- `v1.0` - Latest v1.0.x (patch updates only) +- `v1.0.0` - Exact version (no auto-updates, for strict pinning) + +### When to Bump Versions + +- **Major**: Renaming inputs, removing inputs, changing output format +- **Minor**: Adding new inputs, new outputs, new features +- **Patch**: Bug fixes, documentation updates, performance improvements + +## Security Guidelines + +1. **Never log secrets**: Use `::add-mask::` for sensitive values +2. **Input validation**: Validate all user inputs before use +3. **Least privilege**: Request minimum permissions needed +4. **Dependency pinning**: Pin all action dependencies to exact versions or SHA +5. **SPDX compliance**: All files must have SPDX headers +6. **Signed commits**: All commits must be GPG or SSH signed +7. **Security scanning**: All actions undergo security analysis + +## Supported Build Targets + +The Rust actions support building binaries for multiple target architectures: + +### Linux Targets + +**Native Builds (using `cargo`):** +- `x86_64-unknown-linux-gnu` - x86_64 Linux with GNU libc +- `x86_64-unknown-linux-musl` - x86_64 Linux with musl libc + +**Cross-Compilation Builds (using `cross`):** +- `aarch64-unknown-linux-gnu` - ARM64 Linux with GNU libc +- `aarch64-unknown-linux-musl` - ARM64 Linux with musl libc + +### Windows Targets + +**Cross-Compilation from Linux (using `cargo`):** +- `x86_64-pc-windows-msvc` - x86_64 Windows with MSVC toolchain (recommended) +- `x86_64-pc-windows-gnu` - x86_64 Windows with GNU/MinGW-w64 toolchain + +**Cross-Compilation from Linux (using `cross`):** +- `aarch64-pc-windows-msvc` - ARM64 Windows with MSVC toolchain + +**Choosing Between Windows MSVC and GNU:** + +MSVC (`x86_64-pc-windows-msvc`): +- Uses Microsoft Visual C++ toolchain +- Better integration with Windows ecosystem +- Best compatibility with Windows APIs +- **Requires Windows runners** (`runs-on: windows-latest`) +- Cannot cross-compile from Linux without complex setup (xwin) +- Recommended for production Windows deployments + +GNU (`x86_64-pc-windows-gnu`) - **Recommended for CI/CD**: +- Uses MinGW-w64 toolchain +- **Works on Linux runners** with mingw-w64 installed +- Better for cross-platform CI pipelines on Linux runners +- Requires mingw-w64 installation: `sudo apt-get install mingw-w64` +- Good compatibility with most Windows applications +- Simpler and faster for Linux-based CI/CD + +**CI/CD Recommendation**: +- Use `x86_64-pc-windows-gnu` on Linux runners (ubuntu-latest) +- Use `x86_64-pc-windows-msvc` on Windows runners (windows-latest) +- Both produce fully functional Windows executables + +### macOS Targets + +- `x86_64-apple-darwin` - x86_64 macOS (Intel) +- `aarch64-apple-darwin` - ARM64 macOS (Apple Silicon) + +### Target-Specific Requirements + +**Windows GNU Target (`x86_64-pc-windows-gnu`):** +```yaml +- name: Install mingw-w64 + if: matrix.target == 'x86_64-pc-windows-gnu' + run: | + sudo apt-get update + sudo apt-get install -y mingw-w64 +``` + +**Cross-compilation targets (require Docker):** +- `aarch64-unknown-linux-gnu` - ARM64 Linux +- `aarch64-pc-windows-msvc` - ARM64 Windows + +These targets automatically use the `cross` tool which requires Docker to be available on the runner. + +### Binary Output Paths + +**Linux binaries:** +``` +target/{target}/release/{binary-name} +Example: target/x86_64-unknown-linux-gnu/release/my-app +``` + +**Windows binaries (include .exe extension):** +``` +target/{target}/release/{binary-name}.exe +Example: target/x86_64-pc-windows-msvc/release/my-app.exe +``` + +## Technologies and Tools + +### Core Technologies + +- **Bash**: All action implementations +- **YAML**: Action definitions and workflows +- **Git**: Version control and commit verification +- **Docker**: Container operations and cross-compilation + +### Rust-Specific Tools + +- **Cargo**: Package management and building +- **cargo-audit**: Vulnerability scanning +- **cargo-cyclonedx**: SBOM generation +- **cross**: ARM64 cross-compilation +- **rustfmt**: Code formatting +- **clippy**: Linting + +### Security Tools + +- **Trivy**: Container vulnerability scanning +- **Cosign**: Container image signing +- **SPDX**: License identification standard +- **CycloneDX**: SBOM specification + +### GitHub Actions Dependencies + +- `actions/checkout@v4` - Code checkout +- `actions/cache@v4` - Artifact caching +- `dtolnay/rust-toolchain` - Rust setup +- `Swatinem/rust-cache@v2` - Rust dependency caching + +## Documentation Synchronization Checklist + +When making changes to any action, verify: + +- [ ] `action.yml` inputs/outputs match README.md documentation +- [ ] All examples in README.md are tested and work +- [ ] CHANGELOG.md updated with changes +- [ ] Test cases in `.github/workflows/pr.yml` cover new functionality +- [ ] Related actions documentation cross-referenced +- [ ] Main README.md updated if action list or features changed +- [ ] Examples directory updated if workflow patterns changed + +## Best Practices + +### Error Handling + +```bash +# Good: Clear error with guidance +if [ ! -f "Cargo.toml" ]; then + echo "Error: Cargo.toml not found" + echo "Please run this action from a Rust project root" + exit 1 +fi + +# Bad: Unclear error +if [ ! -f "Cargo.toml" ]; then + echo "File not found" + exit 1 +fi +``` + +### Input Validation + +```bash +# Validate required inputs +if [ -z "${INPUT_VALUE}" ]; then + echo "Error: input 'value' is required but not provided" + exit 1 +fi + +# Validate enum inputs +case "${INPUT_FORMAT}" in + json|yaml|xml) + # Valid + ;; + *) + echo "Error: format must be one of: json, yaml, xml" + exit 1 + ;; +esac +``` + +### Caching Strategy + +```yaml +# Cache expensive operations +- name: Cache cargo-audit + uses: actions/cache@v4 + with: + path: ~/.cargo/bin/cargo-audit + key: ${{ runner.os }}-cargo-audit-${{ inputs.cargo-audit-version }} + +# Use cache to avoid reinstalling tools +- name: Install cargo-audit + if: steps.cache.outputs.cache-hit != 'true' + shell: bash + run: cargo install cargo-audit --version "${{ inputs.cargo-audit-version }}" +``` + +### Tool Installation Pattern + +```bash +# Check if tool exists +if ! command -v tool-name >/dev/null 2>&1; then + echo "Installing tool-name..." + # Installation logic +else + echo "tool-name already installed" +fi +``` + +## Related Documentation + +- [CONTRIBUTING.md](CONTRIBUTING.md) - Contribution guidelines +- [README.md](README.md) - Main project documentation +- [CHANGELOG.md](CHANGELOG.md) - Version history +- [examples/README.md](examples/README.md) - Example workflows + +## License + +This project is licensed under the MIT License. All files must include SPDX license identifiers. + +```yaml +# Copyright (c) 2025 Erick Bourgeois, firestoned +# SPDX-License-Identifier: MIT +``` + + +# OpenWolf + +This project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md at session start. Check .wolf/cerebrum.md before generating code. Grep .wolf/anatomy.md for a file's path before reading it (never read the whole index). + diff --git a/CHANGELOG.md b/CHANGELOG.md index c6a35ee..ad8410b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **rust/generate-sbom** - The `package` input passed `--package`, which + cargo-cyclonedx 0.5.x rejects; it now resolves the package's manifest with + `cargo metadata` and passes `--manifest-path`. Inputs reach the generate + step through `env` instead of being interpolated into the script, and + `cargo-cyclonedx` is installed with `--locked`. + +### Added +- **rust/generate-sbom** - `spec-version` input (CycloneDX 1.3, 1.4 or 1.5) + and `extra-args` input passed through to `cargo cyclonedx`. + ### Security - **Supply-chain hardening** - Pinned every third-party GitHub Action to a full commit SHA (with a trailing `# vX.Y.Z` comment) across all workflows and diff --git a/CLAUDE.md b/CLAUDE.md index f7ac4d0..58b01da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,10 @@ +# OpenWolf + +This project uses OpenWolf for context management. The always-on rules live in `.claude/rules/openwolf.md`; the hooks handle bookkeeping (anatomy index, memory log, read tracking) automatically. + +For the full operating protocol (session handoff, memory discipline, bug logging), load the `openwolf` skill, or read `.wolf/OPENWOLF.md`. Regenerate the session handoff with `/handoff`. + + # Firestoned GitHub Actions - Development Guidelines This document provides comprehensive guidance for developing, maintaining, and contributing to this GitHub Actions repository. diff --git a/rust/generate-sbom/README.md b/rust/generate-sbom/README.md index c217cdd..22fc877 100644 --- a/rust/generate-sbom/README.md +++ b/rust/generate-sbom/README.md @@ -96,6 +96,19 @@ A composite GitHub Action that generates Software Bill of Materials (SBOM) for R format: json ``` +### CycloneDX 1.5 for one workspace package + +```yaml +- name: Generate SBOM + uses: firestoned/github-actions/rust/generate-sbom@v1.3.8 + with: + package: my-binary + target: x86_64-unknown-linux-gnu + spec-version: '1.5' + cyclonedx-version: '0.5.9' +# SBOM: crates/my-binary/my-binary.cdx.json +``` + ### Complete Workflow with Upload ```yaml @@ -140,8 +153,10 @@ jobs: | `cyclonedx-version` | Version of `cargo-cyclonedx` to use | No | `0.5.7` | | `describe` | What to describe: `crate` (entire crate with targets as subcomponents), `binaries` (separate SBOM per binary), or `all-cargo-targets` (separate SBOM per Cargo target) | No | `crate` | | `target` | Rust target triple (e.g., `x86_64-unknown-linux-gnu`) | No | `''` (default target) | -| `package` | Package to generate SBOM for (for workspaces with multiple packages) | No | `''` | +| `package` | Workspace package to generate the SBOM for. Resolved to that package's `--manifest-path` (cargo-cyclonedx 0.5.x has no `--package`); an SBOM is still written into every member's directory, so read the one in this package's directory | No | `''` | | `workspace` | Generate SBOM for all workspace members | No | `false` | +| `spec-version` | CycloneDX spec version: `1.3`, `1.4` or `1.5`. Empty keeps the tool default (`1.3`) | No | `''` | +| `extra-args` | Additional arguments passed to `cargo cyclonedx` | No | `''` | ### Describe Mode Details diff --git a/rust/generate-sbom/action.yaml b/rust/generate-sbom/action.yaml index 6c40890..96ace18 100644 --- a/rust/generate-sbom/action.yaml +++ b/rust/generate-sbom/action.yaml @@ -27,13 +27,27 @@ inputs: required: false default: '' package: - description: 'Package to generate SBOM for (for workspaces with multiple packages)' + description: >- + Workspace package to generate the SBOM for. cargo-cyclonedx 0.5.x has no + --package flag, so this is resolved to that package's --manifest-path + via cargo metadata. cargo-cyclonedx still writes an SBOM into every + workspace member's directory; take the one in this package's directory. required: false default: '' workspace: description: 'Generate SBOM for all workspace members' required: false default: 'false' + spec-version: + description: >- + CycloneDX specification version to emit (cargo-cyclonedx 0.5.x accepts + 1.3, 1.4 or 1.5). Empty keeps the tool default (1.3). + required: false + default: '' + extra-args: + description: 'Additional arguments to pass to cargo cyclonedx' + required: false + default: '' runs: using: 'composite' @@ -55,88 +69,79 @@ runs: - name: Install cargo-cyclonedx if: steps.cache-cyclonedx.outputs.cache-hit != 'true' shell: bash - run: cargo install cargo-cyclonedx --version ${{ inputs.cyclonedx-version }} + env: + CYCLONEDX_VERSION: ${{ inputs.cyclonedx-version }} + run: cargo install cargo-cyclonedx --locked --version "${CYCLONEDX_VERSION}" - name: Generate SBOM shell: bash + env: + SBOM_FORMAT: ${{ inputs.format }} + SBOM_DESCRIBE: ${{ inputs.describe }} + SBOM_TARGET: ${{ inputs.target }} + SBOM_PACKAGE: ${{ inputs.package }} + SBOM_WORKSPACE: ${{ inputs.workspace }} + SBOM_SPEC_VERSION: ${{ inputs.spec-version }} + SBOM_EXTRA_ARGS: ${{ inputs.extra-args }} run: | - # Build base command - BASE_CMD="cargo cyclonedx" - - # Add package or workspace flags - # Note: cargo-cyclonedx 0.5.7 doesn't have --workspace flag - # Use --all for workspace-wide generation - if [ "${{ inputs.workspace }}" = "true" ]; then - BASE_CMD="$BASE_CMD --all" - echo "Generating SBOM for entire workspace (using --all)" - elif [ -n "${{ inputs.package }}" ]; then - BASE_CMD="$BASE_CMD --package ${{ inputs.package }}" - echo "Generating SBOM for package: ${{ inputs.package }}" + set -euo pipefail + # Inputs arrive through env, never interpolated into the script. + args=(--all --describe "${SBOM_DESCRIBE}") + + if [ "${SBOM_WORKSPACE}" != "true" ] && [ -n "${SBOM_PACKAGE}" ]; then + # cargo-cyclonedx 0.5.x has no --package: point it at the package's + # manifest instead. + manifest=$(cargo metadata --no-deps --format-version 1 \ + | jq -r --arg pkg "${SBOM_PACKAGE}" \ + '.packages[] | select(.name == $pkg) | .manifest_path') + if [ -z "${manifest}" ]; then + echo "Error: package '${SBOM_PACKAGE}' is not in this workspace" + exit 1 + fi + args+=(--manifest-path "${manifest}") + echo "Generating SBOM for package: ${SBOM_PACKAGE} (${manifest})" else - BASE_CMD="$BASE_CMD --all" - echo "Generating SBOM for current crate" + echo "Generating SBOM for the workspace / current crate" fi - # Add target if specified - if [ -n "${{ inputs.target }}" ]; then - BASE_CMD="$BASE_CMD --target ${{ inputs.target }}" - echo "Target: ${{ inputs.target }}" + if [ -n "${SBOM_TARGET}" ]; then + args+=(--target "${SBOM_TARGET}") + echo "Target: ${SBOM_TARGET}" fi - # Add describe option - BASE_CMD="$BASE_CMD --describe ${{ inputs.describe }}" - echo "Describe mode: ${{ inputs.describe }}" - - # Generate JSON if requested - if [ "${{ inputs.format }}" = "json" ] || [ "${{ inputs.format }}" = "both" ]; then - echo "Generating SBOM in JSON format..." - $BASE_CMD --format json + if [ -n "${SBOM_SPEC_VERSION}" ]; then + args+=(--spec-version "${SBOM_SPEC_VERSION}") + echo "CycloneDX spec version: ${SBOM_SPEC_VERSION}" fi - # Generate XML if requested - if [ "${{ inputs.format }}" = "xml" ] || [ "${{ inputs.format }}" = "both" ]; then - echo "Generating SBOM in XML format..." - $BASE_CMD --format xml + if [ -n "${SBOM_EXTRA_ARGS}" ]; then + # Word-split on purpose: extra-args is a list of flags. + read -r -a extra <<< "${SBOM_EXTRA_ARGS}" + args+=("${extra[@]}") fi - # List generated files for verification - echo "" - echo "Generated SBOM files:" + echo "Describe mode: ${SBOM_DESCRIBE}" - # Check root directory - if compgen -G "*.cdx.*" > /dev/null 2>&1; then - echo "Root directory SBOMs:" - for file in *.cdx.*; do - ls -lh "$file" - done + if [ "${SBOM_FORMAT}" = "json" ] || [ "${SBOM_FORMAT}" = "both" ]; then + echo "Generating SBOM in JSON format..." + cargo cyclonedx "${args[@]}" --format json fi - # Check each crate directory (for workspace with describe=crate) - # cargo-cyclonedx generates SBOMs in each crate's directory - if [ "${{ inputs.workspace }}" = "true" ] || [ -n "${{ inputs.package }}" ]; then - echo "" - echo "Searching for SBOMs in workspace crates..." - # Find all Cargo.toml files (excluding target dirs and root if workspace) - find . -name "Cargo.toml" -not -path "*/target/*" -not -path "*/.git/*" | while read cargo_file; do - crate_dir=$(dirname "$cargo_file") - # Check if SBOMs exist in this directory before trying to list them - if compgen -G "$crate_dir/*.cdx.*" > /dev/null 2>&1; then - echo "" - echo "SBOMs in $crate_dir:" - for file in "$crate_dir"/*.cdx.*; do - ls -lh "$file" - done - fi - done + if [ "${SBOM_FORMAT}" = "xml" ] || [ "${SBOM_FORMAT}" = "both" ]; then + echo "Generating SBOM in XML format..." + cargo cyclonedx "${args[@]}" --format xml fi - # Summary count + # cargo-cyclonedx writes .cdx. into each package directory + # (the repo root for a single crate). echo "" - total_sboms=$(find . -name "*.cdx.*" -not -path "*/target/*" -not -path "*/.git/*" -type f 2>/dev/null | wc -l | tr -d ' ') - echo "Total SBOMs generated: $total_sboms" + echo "Generated SBOM files:" + find . -name "*.cdx.*" -not -path "*/target/*" -not -path "*/.git/*" -type f \ + -exec ls -lh {} \; - # Verify at least one SBOM was generated - if [ "$total_sboms" -eq 0 ]; then + total_sboms=$(find . -name "*.cdx.*" -not -path "*/target/*" -not -path "*/.git/*" -type f | wc -l | tr -d ' ') + echo "Total SBOMs generated: ${total_sboms}" + if [ "${total_sboms}" -eq 0 ]; then echo "Error: No SBOM files were generated" exit 1 fi