diff --git a/.claude/prose-dictionary.yaml b/.claude/prose-dictionary.yaml new file mode 100644 index 0000000..0337c65 --- /dev/null +++ b/.claude/prose-dictionary.yaml @@ -0,0 +1,160 @@ +# Maven project dictionary for the direct-prose skill. +# +# These terms override every word preference in the skill's word-choice tables. +# Each entry exists because the name drifted in real docs or real answers, not +# because the word looked improvable. +# +# Format and the rule for adding a term: ~/.claude/skills/direct-prose/references/modes.md + +terms: + resident_model: + name: resident model + meaning: the one always-warm Qwen3-1.7B llama-server that both routes and phrases + avoid: + - the model + - the LLM + - the 1.7B + - the phraser model + examples: + good: The resident model emits GBNF-constrained JSON. + bad: The 1.7B emits GBNF-constrained JSON. + + router: + name: router + meaning: the stage that turns an utterance into a Decision with one of 7 intents + avoid: + - orchestrator + - intent classifier + - dispatcher + + classifier: + name: classifier + meaning: the embedder nearest-neighbour path that runs when the router is off or errors + avoid: + - the fallback + - the floor + - the old router + examples: + good: A router error falls through to the classifier. + bad: A router error falls through to the floor. + + cascade: + name: cascade + meaning: the ordered path stage 0, then router, then classifier + avoid: + - the pipeline + - the chain + - the fallback chain + + stage_0: + name: stage 0 + meaning: the deterministic rules that answer before the resident model is called + avoid: + - the fast path + - bypass + - deterministic assist + - preemption + examples: + good: Stage 0 routes agenda questions to IntentQuery. + bad: The bypass routes agenda questions to IntentQuery. + + query_source: + name: query source + meaning: one entry in querySources, which either claims a turn or passes + avoid: + - arm + - handler + - branch + - answerer + examples: + good: Kiwix is the last query source before the model answers from memory. + bad: Kiwix is the last arm before the model answers from memory. + + personal_boundary: + name: personal boundary + meaning: the query source that stops a question about him from reaching the world + avoid: + - the boundary + - the privacy gate + - the personal filter + + clarify: + name: clarify + meaning: the turn outcome where Maven asks instead of acting + avoid: + - refusal + - rejection + - punt + examples: + good: The gate produced two false clarifies. + bad: The gate produced two false refusals. + + fact: + name: fact + meaning: a keyed, supersedable row in the fact store + avoid: + - memory entry + - datum + - record + + note: + name: note + meaning: free text he captured, indexed for recall + avoid: + - memo + - entry + + memory: + name: memory + meaning: the embedded index over notes and facts that backs recall + avoid: + - RAG store + - vector db + - long-term memory + + nudge: + name: nudge + meaning: one proactive message the digestion worker proposes and the dispatcher sends + avoid: + - suggestion + - proposal + - proactive prompt + - reminder + examples: + good: A fact can close the nudge that asked for it. + bad: A fact can close the suggestion that asked for it. + + digestion_worker: + name: digestion worker + meaning: the background engine that consolidates memory and proposes nudges + avoid: + - digestion tick + - background engine + - reflection loop + + reach: + name: reach + meaning: an outbound channel Maven speaks through, such as telegram, ntfy or voice + avoid: + - sink + - delivery channel + - notification backend + + ecosystem: + name: ecosystem + meaning: Nexus, Praxis and Hexis together + avoid: + - the services + - the integrations + - upstream + + act: + name: act + meaning: the intent that runs a capability through Hexis + avoid: + - action + - command + - execution + examples: + good: An act with no allowlisted fn is gated to a clarify. + bad: An action with no allowlisted fn is gated to a clarify. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..05832a4 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,20 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "f=.claude/prose-dictionary.yaml; [ -f \"$f\" ] && jq -Rs '{hookSpecificOutput:{hookEventName:\"SessionStart\",additionalContext:(\"Project prose dictionary. These terms override every word preference in the direct-prose output style. Use the name, never the avoid list.\\n\\n\"+.)}}' \"$f\" 2>/dev/null || true", + "statusMessage": "Loading prose dictionary" + }, + { + "type": "command", + "command": "f=HANDOFF.md; [ -f \"$f\" ] && jq -Rs '{hookSpecificOutput:{hookEventName:\"SessionStart\",additionalContext:(\"An unconsumed HANDOFF.md is present. Run the pickup skill before anything else: read it, read the Vikunja task it names, restate the assumption set in at most five bullets, and wait for the user to confirm before writing code. It is a claim from the previous session, not truth. Delete it once consumed.\\n\\n\"+.)}}' \"$f\" 2>/dev/null || true", + "statusMessage": "Loading handoff" + } + ] + } + ] + } +} diff --git a/.claude/skills/pickup/SKILL.md b/.claude/skills/pickup/SKILL.md new file mode 100644 index 0000000..92f1446 --- /dev/null +++ b/.claude/skills/pickup/SKILL.md @@ -0,0 +1,78 @@ +--- +name: pickup +description: Start a work session on a Maven task. Runs task start, reads the brief and the disposable handoff, restates the assumption set, and waits for correction before touching code. Use at the start of any session that continues earlier work, when the user says "pickup", "continue", "resume", or names a Vikunja task id. +--- + +# Pickup + +The point of this skill is the pause in step 5. Every wasted session in this repo +started with an agent that inferred the goal instead of stating it back. + +## 0. Get on the branch + +```sh +task start +``` + +`~/.local/bin/task` owns the branch, the identity and the PR. It cuts +`task/-` off `origin/master` and sets the commit author to the `claude` +gitea user. It writes `TASK.md` from the Vikunja task, and pulls any waiting +review comments into `.task/review-comments.md`. Do not hand-roll any of that. + +`TASK.md` is the brief and it is immutable. If it says a PR already exists, this +is a review-fix session and not new work. Read the comments first. + +## 1. Read the handoff + +`HANDOFF.md` at the repo root, if it exists. It is gitignored, it belongs to one +session, and it holds only what is needed to resume. Treat it as a claim from the +previous agent, not as truth. It can be stale or wrong. + +If there is no handoff, that is normal. It means the last session closed clean. + +## 2. Read the durable state + +In this order, and stop as soon as you have enough: + +- The Vikunja task, by id. Project Maven is ID 2, MCP at `http://localhost:9100/mcp`. + The task description and its comments hold the goal, the constraints, and the + assumption ledger. This outranks the handoff on every conflict. +- `CLAUDE.md`, the section that covers the area you are about to touch. +- The one file under `docs/` that owns the area. Check its `Last verified` line. + If the sha is behind the code you are reading, say so in step 4 and trust the code. + +Do not read the dated files under `docs/evals/`. They are measurements from one day, +never updated. Read one only when you need the number it recorded. + +If no task id is known, ask for one before doing anything else. Work without a task +is work nobody can resume. + +## 3. Look at the ground + +`git status`, `git log --oneline -5`, and the diff on the current branch. What the +repo says beats what any document says. + +## 4. Restate, then stop + +Write at most five bullets and stop. Do not write code, do not open files to "check +one thing first", do not start with a small safe change. + +``` +Task: V-359, one line. +Done: what is already on the branch. +Next: the one thing this session does. +Constraints: what would make this wrong. +Assuming: the beliefs that, if false, waste the session. +``` + +Then ask: is this right? Wait for the answer. + +A corrected assumption goes into the Vikunja task as a comment, not into the handoff. +The handoff dies tonight. The task does not. + +## 5. Then begin + +- Delete `HANDOFF.md`. It has been consumed and must not outlive this step. +- On master, cut the branch: `scripts/task-branch.sh `. +- One task per session. When context passes roughly half, run `/wrap` rather than + pushing on. A compacted session is a session that forgot why it made a choice. diff --git a/.claude/skills/wrap/SKILL.md b/.claude/skills/wrap/SKILL.md new file mode 100644 index 0000000..180418e --- /dev/null +++ b/.claude/skills/wrap/SKILL.md @@ -0,0 +1,94 @@ +--- +name: wrap +description: Close a Maven work session cleanly. Runs the tests, updates the durable docs, commits in reviewable slices with the Vikunja ref, pushes so the PR opens, records state in Vikunja, and leaves a disposable handoff only if work remains. Use when the user says "wrap", "wrap up", "done for now", or when context passes roughly half. +--- + +# Wrap + +Run every step. A partial wrap is worse than none, because the next session trusts +the parts that did run. + +## 1. Prove it works + +`make test`. If something fails, fix it or say plainly in the handoff and in Vikunja +that it fails, with the output. Never wrap on an untested claim. + +## 2. Update the durable docs + +Ask what a future agent would have to learn the hard way, and write that down. + +- `CLAUDE.md` when a fact an agent needs before touching code has changed: routing + behaviour, a measured number, a flag default, a constraint. A commit that changed + routing or phrasing without touching the matching CLAUDE.md section is a bug. + Correct stale text in place. Do not append a new paragraph next to the wrong one. +- `AGENTS.md` when the recipe to build, run or preview changed. +- The one file under `docs/` that owns the area, plus its `Last verified: @ ` + line. Only a doc directly under `docs/` carries that line. +- A new dated file under `docs/evals/` when you measured something. Never edit an + existing dated file. A newer measurement is a new file, and the living doc points + at it. + +Nothing that must survive tonight goes anywhere else. Not into the handoff, not into +a commit message, not into a comment in the code. + +## 3. Commit in slices + +Under 300 changed lines per commit in non-markdown files, enforced by `.githooks/pre-commit`. +Markdown is exempt and may land as one batch. + +Each commit is one idea, subject in the repo's voice, lowercase area prefix, and it +ends with the Vikunja ref: + +``` +router: narrow the single-token rule (V-359) +``` + +If a change genuinely cannot split under 300 lines, say why in the commit body before +reaching for `--no-verify`. + +## 4. Land it + +```sh +task pr +``` + +It refuses a dirty tree, pushes, opens or refreshes the PR against the repo default +branch, labels the Vikunja task in-review, comments the PR url on it, and pushes an +ntfy. Do not push by hand and do not call `tea` yourself. + +## 5. Record what `task pr` cannot know + +Comment on the Vikunja task: what you measured, what is still open. List every +assumption that turned out to be wrong. If the session found new work, create a task +for it now rather than describing it in prose. + +This step is what makes the handoff disposable. + +## 6. Leave the handoff, or leave none + +If the task is finished, delete `HANDOFF.md` and stop. An empty root is the correct +end state. + +If work remains, write `HANDOFF.md` with nothing but what the next agent needs to +resume, and no history: + +```markdown +# Handoff — + +Task: V-359 +Branch: task/359-, cut from master + +## Where I stopped + + +## Next action + + +## Do not + +``` + +Nothing else goes in it. No summary of what landed, that is in git and Vikunja. No +design rationale, that is in `docs/`. No fact an agent needs on any task, that is in +`CLAUDE.md`. If a line in the handoff would still matter next week, it is in the wrong +file. diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..b69caf1 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,30 @@ +#!/bin/sh +# Every commit names the Vikunja task it belongs to. +# +# router: narrow the single-token rule (V-359) +# +# V- and not #, because Gitea autolinks #359 to a Gitea issue, which is a +# different tracker and a wrong link. +# +# Exempt: merges, reverts, fixup/squash, and the initial commit. + +msg_file=$1 +subject=$(sed -n '1p' "$msg_file") + +case "$subject" in + Merge\ *|Revert\ *|fixup!\ *|squash!\ *|amend!\ *) exit 0 ;; +esac + +if [ -f "$(git rev-parse --git-dir)/MERGE_HEAD" ]; then + exit 0 +fi + +if printf '%s' "$subject" | grep -qE '\(V-[0-9]+\)$'; then + exit 0 +fi + +echo "commit-msg: subject must end with a Vikunja task ref." >&2 +echo " got: $subject" >&2 +echo " want: router: narrow the single-token rule (V-359)" >&2 +echo " No task yet? Create one. Work without a task is work nobody can resume." >&2 +exit 1 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..73756df --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,30 @@ +#!/bin/sh +# Two guards, both bypassable with --no-verify when you mean it. +# 1. master is not a working branch. +# 2. a code commit stays under 300 changed lines. +# Markdown is exempt from the size cap on purpose: docs land as one batch. + +branch=$(git symbolic-ref --short HEAD 2>/dev/null) + +case "$branch" in + master|main) + echo "pre-commit: refusing to commit on $branch." >&2 + echo " task start # branch off origin/master, write TASK.md" >&2 + exit 1 + ;; +esac + +# Added + deleted lines across staged files that are not markdown. +# numstat prints "-\t-\t" for binaries; those count 0 and that is fine, +# a binary blob is not the kind of diff this cap exists to stop. +loc=$(git diff --cached --numstat -- . ':(exclude)*.md' | + awk '$1 ~ /^[0-9]+$/ { a += $1 } $2 ~ /^[0-9]+$/ { d += $2 } END { print a + d + 0 }') + +if [ "$loc" -gt 300 ]; then + echo "pre-commit: $loc changed lines in non-markdown files, cap is 300." >&2 + echo " Split it. Each commit should be one reviewable idea." >&2 + echo " git reset to unstage, or --no-verify if this genuinely cannot split." >&2 + exit 1 +fi + +exit 0 diff --git a/.gitignore b/.gitignore index 8b29eb9..f26c0ed 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,16 @@ opencode.json # Test coverage output coverage.out -# Agent worktrees and local agent state -.claude/ +# Agent worktrees and local agent state. The workflow itself is tracked: the +# hooks, the skills and the prose dictionary are how a session behaves, so they +# get reviewed like code. Everything else under .claude/ is scratch. +/.claude/* +!/.claude/settings.json +!/.claude/prose-dictionary.yaml +!/.claude/skills/ + +# The disposable handoff. One session, then deleted. Never committed: +# anything worth keeping belongs in Vikunja, CLAUDE.md or docs/. +/HANDOFF.md /models/stt /models/tts diff --git a/CLAUDE.md b/CLAUDE.md index 5c2ce01..ec70178 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,3 +213,60 @@ data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.m This repo is project **Maven** (ID 2) in Vikunja. MCP: `http://localhost:9100/mcp` (or `http://192.168.1.104:9100/mcp` from workpc). Feature/bug/deploy tasks go there. + +Vikunja is the durable task store. A task holds the goal, the constraints and the +assumption ledger. Work without a task id is work nobody can resume, so a session that +has no id asks for one before it starts. + +## Session workflow + +`~/.local/bin/task` owns the branch, the commit identity and the PR. One task, one +session, one PR. + +```sh +task start # branch off origin/master, write TASK.md, fetch review comments +task pr # push, open or refresh the PR, label Vikunja, notify +task comments # re-pull this branch's review comments into .task/ +``` + +Around that, `/pickup` opens a session and `/wrap` closes it. Wrap at roughly half +context rather than letting the session compact. + +Five stores, and each one owns something the others must not hold: + +| Store | Holds | Lifetime | +|---|---|---| +| Vikunja task | goal, constraints, assumption ledger, status | durable | +| `CLAUDE.md`, `AGENTS.md` | what an agent must know before touching code | durable | +| `docs/` | design, measurements, decisions | durable | +| `TASK.md` | the brief for this branch, written by `task start`, immutable | one branch | +| `HANDOFF.md` | only what the next agent needs to resume | one session | + +`TASK.md` and `.task/` are excluded through `.git/info/exclude`. `HANDOFF.md` is +gitignored and injected at session start. If a line in the handoff would still matter +next week, it is in the wrong file. + +Docs are tiered by path, so staleness is visible from the filename. Files directly under +`docs/` are living and carry a `Last verified: @ ` line. Files under +`docs/evals/` are dated measurements and are never edited after the day, so a newer +number is a new file. Files under `docs/archive/` are dead and read by nobody by default. + +## Git guards + +Two hooks in `.githooks/`, tracked, wired with `core.hooksPath`. Fresh clone: + +```sh +git config core.hooksPath .githooks +``` + +- `pre-commit` refuses master, and refuses more than 300 changed lines in non-markdown + files. Markdown is exempt and may land as one batch. +- `commit-msg` requires the subject to end with `(V-)`. `V-` and not `#`, because + Gitea autolinks `#123` to a Gitea issue, which is the wrong tracker. + +Two more guards live outside the repo, in `~/.claude/hooks/`. `diff-budget.sh` blocks +further edits past 600 changed lines on a `task/` branch. `prose_lint_hook.py` checks +prose on every write. Both measure against `origin/master`, so a local master that is +ahead of the remote makes the diff budget read high. + +`--no-verify` exists. Using it means saying why in the commit body.