Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .claude/skills/pr/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
---
name: pr
description: Write the pull request for the current famstack branch, a title that is the changelog line and a description by the repo's Pull requests rules, both checked with tools/commit-lint. Use when asked to write, draft or open a PR, or for its title or squash message.
---

# Write the pull request

A PR is squash merged, so its title becomes the commit subject on main, and
that subject is the line in the release notes. The rules live in
`docs/agent/dev.md`, sections "Commits: the subject is the changelog" and
"Pull requests". Read both before writing: this file is the procedure, dev.md
is the rule, and where they differ dev.md wins.

## 1. Read the branch

```bash
git fetch origin
git log --reverse --format='%h %s%n%b' origin/main..HEAD
git diff --stat origin/main...HEAD
```

Read the diff wherever the subjects don't say what an admin would notice. If
`HEAD..origin/main` is not empty, say the branch is behind; don't rebase unasked.

## 2. One changelog entry, or two

List what a reader of the release notes would see: commits of a shown type
(`feat`, `fix`, `security`, `perf`, `docs`) and any commit with `!`, `Upgrade:`
or `BREAKING CHANGE:`. Tests, refactors and chores that serve one of those
belong to it and add no entry of their own.

If that leaves two changes a reader would see separately, stop there. Don't
write a title with "and": say the PR should be split and which commits go in
which PR. One PR is one changelog entry.

## 3. The title

- `type(scope)!: subject`, with the type and scope from the tables in dev.md.
The scope is what a reader recognises (a stacklet or a bot), never a file.
- The change as an admin sees it: imperative, lowercase after the colon, no
full stop, no class or function names. At most 72 characters as commit-lint
counts them; dev.md asks under 70 for a PR. The ` (#N)` GitHub appends is
not counted.
- `!` only when the admin has to act, and then the description carries the
`Upgrade:` or `BREAKING CHANGE:` footer.
- For a one-commit branch whose subject already passes, use that subject:
GitHub takes the commit's subject as the squash title of a one-commit PR.

## 4. The description

```
## Summary
- <what changes for the admin, and why>

Upgrade: <what the admin must do, as markdown>
Refs: FAM-12
```

- One to three bullets. The footers only when they apply.
- No "Test plan" section. Test output and measurements go in a PR comment.
- No em dashes: a comma, colon, period or parentheses.
- No Co-Authored-By trailer, no "Generated with" line, no tool link. The repo
is public and commit-lint rejects all three.

## 5. Check before showing it

```bash
python3 tools/commit-lint --title "<title>"
python3 tools/commit-lint --body "<description>"
python3 tools/commit-lint --range origin/main..HEAD
```

The last one is what CI runs over every commit in the PR. Fix and re-run until
all three pass. Rewording a commit that is already pushed needs a force push,
which needs Arthur's go.

Then show the title, the description in a code block, and the three results.

## Opening it

Pushing and opening the PR are Arthur's call. Ask for his go every time, even
if he gave it for the last one. With it:

```bash
git push -u origin HEAD
gh pr create --base main --title "<title>" --body-file <file>
```

`--draft` if he asks for a draft. Never merge.

## At merge time

The repo's squash setting fills the commit body from the branch's commit
messages, not from the PR description. A footer in the description reaches
main only if the merge uses the description as the body, in the merge dialog
or with `gh pr merge --squash --body-file`. Merging is Arthur's: say this when
handing the PR over, and don't run it.

A PR Arthur marks for a rebase merge is the exception: every commit lands as
its own changelog line, so each one has to pass commit-lint on its own, and
the title is not a changelog line.
15 changes: 10 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@ tests/e2e/eval/runs/

# AI assistant. Local settings and transcripts stay out, but the LSP plugin
# is repo configuration: it makes agents read this codebase with a language
# server instead of grep, so every clone should get it.
# server instead of grep, so every clone should get it. The same goes for
# the pr skill, which applies this repo's pull request rules.
# Git cannot re-include a file whose parent directory is excluded, so these
# ignore directory *contents* and re-open the one path down to the plugin.
# ignore directory *contents* and re-open each path that is tracked.
.claude/*
!.claude/skills/
.claude/skills/*
!.claude/skills/famstack-lsp/
!.claude/skills/pr/

# Extension stacklets live in ~/<product>-extensions, not here. This is
# the obvious place to try anyway, so keep anything dropped here out of
Expand All @@ -69,10 +71,13 @@ impl/

**CLAUDE.md
**AGENT.md
# …except the repo's canonical agent guide at the root, and the
# CLAUDE.md that loads it into Claude Code.
!/AGENT.md
**AGENTS.md
# …except the repo's canonical agent guide at the root, the CLAUDE.md
# that loads it into Claude Code, and the family agent's workspace
# guide, which is product content the agent stacklet ships.
!/AGENTS.md
!/CLAUDE.md
!/stacklets/agent/workspace/AGENTS.md
# Local planning workspace (Nimbalyst) — not product source
nimbalyst-local/

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Apply to every role, every session.
7. **Never read or copy a user's production family vault.** Reference paths only; fabricate test data.
8. **Destructive ops need confirmation.** `stack destroy`, `stack uninstall`, `rm -rf`, `git reset --hard`, force-push.
9. **Announce actions before running them.** No silent long running or integration test runs, scripts, or background commands.
10. **No em dashes in user-facing prose.** Use hyphens or sentence breaks.
10. **No em dashes in user-facing prose.** Use a comma, colon, period or parentheses instead.
11. **Put data in through the front door.** Seed and exercise a stacklet the way a family does, never by writing to the service behind it. Documents go through `tools/family-docs/ingest.py` (Matrix room -> archivist -> OCR -> classify -> mirror), never a direct `POST /api/documents/post_document/`. The back door skips the pipeline, so what lands is not what users get: no tags, no correspondent, no document type, no rewritten title, no summary note, no vault entry. Any conclusion drawn from that data is about a system famstack does not ship.
12. **Comments explain the code, not sell it.** Literate but neutral: state the constraint and the reason, without rhetoric or drama. Write `# Tags are mutable, so only the digest identifies the image this dump came from.` rather than `# Tags lie over time -- the digest is the load-bearing field that still names this exact image in five years.`

Expand Down
109 changes: 109 additions & 0 deletions cliff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Release notes, generated from the commit log by git-cliff.
#
# script/release-notes what the next tag will ship
# script/release-notes --latest what the newest tag shipped
#
# The layout is the one docs/agent/dev.md specifies ("Commits: the subject
# is the changelog"): Action required first, then Security, Added, Fixed,
# Performance and Documentation, each grouped by rendered scope.
#
# The types and scopes below repeat TYPES and SCOPES in tools/commit-lint,
# because git-cliff cannot read Python. A unit test fails when the two
# disagree (tests/unit/framework/test_commit_lint.py).

[changelog]
header = ""
# Tera template. An entry is "- **<scope label>:** <subject>", and an
# Action required entry adds its Upgrade: or BREAKING CHANGE: footer as a
# second paragraph, verbatim, because that text is what the admin must do.
# Entries without a scope come last in their section (General).
body = """
{%- macro entry(commit, label) %}
- {% if label %}**{{ label }}:** {% endif %}{{ commit.message | upper_first }}
{%- for footer in commit.footers | default(value=[]) %}{% if footer.token == "Upgrade" or footer.breaking %}

**{{ footer.token }}:** {{ footer.value | upper_first | replace(from="\n", to="\n ") }}
{%- endif %}{% endfor %}
{%- endmacro entry -%}

{%- if commits | length == 0 -%}
No user-facing changes since {{ previous.version | default(value="the last release") }}.
{% else -%}
{%- for group, commits in commits | group_by(attribute="group") -%}
{% if not loop.first %}
{% endif %}### {{ group | striptags | trim }}
{% for scope, scoped in commits | group_by(attribute="scope") %}{% for commit in scoped %}{{ self::entry(commit=commit, label=scope) }}{% endfor %}{% endfor %}
{%- for commit in commits %}{% if not commit.scope %}{{ self::entry(commit=commit, label="") }}{% endif %}{% endfor %}
{% endfor -%}
{%- endif -%}
"""
# Off, because it strips the indent that keeps a footer inside its entry.
trim = false
# Render a release with nothing to announce, so that it says so.
render_always = true

[git]
conventional_commits = true
filter_unconventional = false
split_commits = false
protect_breaking_commits = true
filter_commits = false
tag_pattern = "v[0-9].*"
sort_commits = "oldest"

# Scope to label, one entry per SCOPES key in tools/commit-lint. Every
# label has a space in it, so the last entry only strips a scope that is
# not in the table, which then lands under General as dev.md says.
commit_preprocessors = [
{ pattern = "^([a-z]+)\\(agent\\)", replace = "${1}(Agent Stacklet)" },
{ pattern = "^([a-z]+)\\(ai\\)", replace = "${1}(AI Stacklet)" },
{ pattern = "^([a-z]+)\\(backup\\)", replace = "${1}(Backup Stacklet)" },
{ pattern = "^([a-z]+)\\(chatai\\)", replace = "${1}(ChatAI Stacklet)" },
{ pattern = "^([a-z]+)\\(code\\)", replace = "${1}(Code Stacklet)" },
{ pattern = "^([a-z]+)\\(core\\)", replace = "${1}(Core Stacklet)" },
{ pattern = "^([a-z]+)\\(docs\\)", replace = "${1}(Docs Stacklet)" },
{ pattern = "^([a-z]+)\\(infra\\)", replace = "${1}(Infra Stacklet)" },
{ pattern = "^([a-z]+)\\(memory\\)", replace = "${1}(Memory Stacklet)" },
{ pattern = "^([a-z]+)\\(messages\\)", replace = "${1}(Messages Stacklet)" },
{ pattern = "^([a-z]+)\\(photos\\)", replace = "${1}(Photos Stacklet)" },
{ pattern = "^([a-z]+)\\(archivist\\)", replace = "${1}(Archivist Bot)" },
{ pattern = "^([a-z]+)\\(mail\\)", replace = "${1}(Mail Bot)" },
{ pattern = "^([a-z]+)\\(scribe\\)", replace = "${1}(Scribe Bot)" },
{ pattern = "^([a-z]+)\\(stacker\\)", replace = "${1}(Stacker Bot)" },
{ pattern = "^([a-z]+)\\(curator\\)", replace = "${1}(Memory Stacklet)" },
{ pattern = "^([a-z]+)\\(wiki\\)", replace = "${1}(Memory Stacklet)" },
{ pattern = "^([a-z]+)\\(diary\\)", replace = "${1}(Memory Stacklet)" },
{ pattern = "^([a-z]+)\\(capture\\)", replace = "${1}(Archivist Bot)" },
{ pattern = "^([a-z]+)\\(voice\\)", replace = "${1}(AI Stacklet)" },
{ pattern = "^([a-z]+)\\(stack\\)", replace = "${1}(stack CLI)" },
{ pattern = "^([a-z]+)\\(cli\\)", replace = "${1}(stack CLI)" },
{ pattern = "^([a-z]+)\\(doctor\\)", replace = "${1}(stack CLI)" },
{ pattern = "^([a-z]+)\\(update\\)", replace = "${1}(stack CLI)" },
{ pattern = "^([a-z]+)\\(install\\)", replace = "${1}(stack CLI)" },
{ pattern = "^([a-z]+)\\(web\\)", replace = "${1}(web fetch)" },
{ pattern = "^([a-z]+)\\([a-z0-9-]+\\)", replace = "${1}" },
]

# First match wins. Anything that needs the admin to act goes to Action
# required whatever its type. A type with no section is committed but not
# announced. Merges are skipped because the commits they merge are already
# listed; commit-lint --range leaves them out the same way. What is left
# does not parse and shows as Unclassified, which blocks a tag (dev.md,
# Releases, step 3b).
commit_parsers = [
{ field = "breaking", pattern = "true", group = "<!-- 0 -->Action required" },
{ footer = "^Upgrade", group = "<!-- 0 -->Action required" },
{ message = "^Merge ", skip = true },
{ message = "^security", group = "<!-- 1 -->Security" },
{ message = "^feat", group = "<!-- 2 -->Added" },
{ message = "^fix", group = "<!-- 3 -->Fixed" },
{ message = "^perf", group = "<!-- 4 -->Performance" },
{ message = "^docs", group = "<!-- 5 -->Documentation" },
{ message = "^refactor", skip = true },
{ message = "^test", skip = true },
{ message = "^chore", skip = true },
{ message = "^ci", skip = true },
{ message = "^style", skip = true },
{ message = "^build", skip = true },
{ message = ".*", group = "<!-- 9 -->Unclassified" },
]
26 changes: 19 additions & 7 deletions docs/agent/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ Testing rules:
- **Literate code.** Narrative docstrings, section dividers (`# ── Section ──`), prose flow over terse chains. The code IS the specification AND the implementation - keep both legible.
- **Python 3.11 floor.** `tomllib` is stdlib; no compat shims. Use modern Python (`match`, structural pattern matching, walrus when it earns its keep).
- **Comments explain WHY, not WHAT.** If a comment paraphrases the code, delete it. Keep comments that document constraints, invariants, or surprises.
- **No em dashes in user-facing text** (commit messages, rendered docstrings, blog drafts). Hyphens or sentence breaks instead.
- **No em dashes in user-facing text** (commit messages, rendered docstrings, blog drafts). Use a comma, colon, period or parentheses instead.
- **No `--no-verify` or `--no-gpg-sign`** on commits unless the user explicitly asks. If a hook fails, fix the underlying issue.
- **Unchecked return values are a smell.** On the third site in a session, propose an audit instead of patching a third instance.
- **Re-read the full error line before calling a failure a duplicate.** Check sender, target, specific IDs. Two errors that look similar at a glance often differ in the load-bearing field.
Expand Down Expand Up @@ -339,6 +339,11 @@ needed and what it changes; the diff shows how. It must make sense without
the PR. Bullets for sets of changes. No debugging history, test narrative
or rhetoric; those go in the PR.

**No em dashes**, in the subject, the body, or the PR title and
description. All of them are published as release notes. Use a comma,
colon, period or parentheses. An en dash in a range (`1–2`) and a hyphen
are fine. `tools/commit-lint` rejects the em dash in all four places.

| Type | Body states |
|---|---|
| `feat` | what is now possible, how to use it, limits |
Expand Down Expand Up @@ -397,10 +402,17 @@ release gate runs it over the range being tagged. `script/setup`
turns the hooks on, as do `make test-unit` and `make typecheck`; git
cannot do it on clone, by design.

A generator reads the header as
`^(type)(\((scope)\))?(!)?: (subject)( \(#(pr)\))?$` and groups into **Action
required** (any `!` or `Upgrade:`), Security, Added, Fixed, Performance,
Documentation, by rendered scope within each.
`script/release-notes` generates the release notes from these messages
with git-cliff, through `uvx`, so there is nothing to install. It reads the
header as `^(type)(\((scope)\))?(!)?: (subject)( \(#(pr)\))?$` and groups
into **Action required** (any `!`, `Upgrade:` or `BREAKING CHANGE:`, with the
footer quoted), Security, Added, Fixed, Performance, Documentation, by
rendered scope within each. With no argument it prints what the next tag
will ship; `--latest` prints what the newest tag shipped, and a range such
as `v0.3.0-beta.3..main` works too. It runs from any directory and needs the
full history: in a shallow clone it stops and says to `git fetch
--unshallow`. `cliff.toml` repeats commit-lint's types and scopes, and a
unit test fails when the two disagree.

## Branch rules

Expand All @@ -423,10 +435,10 @@ Pre-tag gate, in order. A published tag is never moved; anything missed here shi
1. Working tree clean - `git status` shows nothing modified, no stale `uv.lock` (the version bump touches `pyproject.toml` AND the lock; commit them together).
2. Version bumped in `lib/stack/cli.py` (`VERSION`) and `pyproject.toml`.
3. Full test round green: framework, stacklets, integration.
3b. Every commit since the previous tag parses as a changelog entry. An unclassified subject blocks the tag: it would be missing from the release notes and from the website. Reword it if it has not shipped, add the entry by hand if it has.
3b. Every commit since the previous tag parses as a changelog entry. An unclassified subject blocks the tag: it would be missing from the release notes and from the website. Reword it if it has not shipped, add the entry by hand if it has. `script/release-notes` shows such a subject under Unclassified.
4. Fresh-instance install verified.
5. Stale references updated: README version callouts, docs links, blog "Try it" instructions.
6. Tag (`vX.Y.Z` / `vX.Y.Z-beta.N`, annotated), push main + tag, publish the GitHub release with Highlights and an "Upgrading from" section.
6. Tag (`vX.Y.Z` / `vX.Y.Z-beta.N`, annotated), push main + tag, publish the GitHub release with Highlights and an "Upgrading from" section, followed by the change list from `script/release-notes --latest`.

**The tag format is load-bearing.** `stack update` parses `v?MAJOR.MINOR.PATCH[-label.N]` and ignores anything else, so a tag spelled `v0.3.0.beta1` is invisible: `stack update` would keep offering the previous release and never mention it. Hyphen before the label, dot before its number, as SemVer has it. The prerelease sorts below the release it leads to, and label numbers compare as numbers, so `beta.10` is newer than `beta.9`.

Expand Down
3 changes: 2 additions & 1 deletion hooks/commit-msg
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/bin/sh
# Block commit subjects that would not parse as a changelog entry, and
# messages that carry a credit line for the tool that wrote them.
# messages that carry a credit line for the tool that wrote them or an
# em dash.
#
# Enable once per clone (same switch as the pre-commit hook):
# git config core.hooksPath hooks
Expand Down
35 changes: 35 additions & 0 deletions script/release-notes
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# script/release-notes: the release notes, generated from the commit log.
#
# script/release-notes what the next tag will ship
# script/release-notes --latest what the newest tag shipped
# script/release-notes <from>..<to> any range, e.g. v0.3.0-beta.3..main
# script/release-notes --bumped-version the next tag, from the commit types
#
# Markdown on stdout, ready to paste into a GitHub release. It reads the
# checkout this script lives in, so it can be called from any directory.
# The layout and the tables are in cliff.toml; git-cliff runs through
# uvx, pinned to the version the template was written for, so there is
# nothing to install.

set -euo pipefail

root="$(cd "$(dirname "$0")/.." && pwd)"

# git-cliff reads history through libgit2, which does not handle a shallow
# clone: it finds no commits, or fails, and neither says why. An empty
# release would look like a quiet one, so refuse instead.
if [[ "$(git -C "$root" rev-parse --is-shallow-repository)" == true ]]; then
echo "release-notes: $root is a shallow clone, so its history is incomplete." >&2
echo " Fetch the rest first: git -C $root fetch --unshallow" >&2
exit 1
fi

if [[ $# -eq 0 ]]; then
set -- --unreleased
fi

# git-cliff logs which config it read at INFO level, on every run.
export RUST_LOG="${RUST_LOG:-warn}"
exec uvx --quiet git-cliff@2.14.2 \
--repository "$root" --config "$root/cliff.toml" --strip all "$@"
Loading
Loading