feat(primitives): kei-docs-scaffold shell + kei-changelog Rust

This commit is contained in:
Parfii-bot 2026-04-21 20:57:15 +08:00
parent 97d3fcb6ba
commit be20f5ba46
11 changed files with 829 additions and 0 deletions

View file

@ -0,0 +1,24 @@
[package]
name = "kei-changelog"
version = "0.1.0"
edition = "2021"
description = "Git-cliff-style CHANGELOG.md generator from Conventional Commits."
license = "MIT OR Apache-2.0"
[[bin]]
name = "kei-changelog"
path = "src/main.rs"
[lib]
path = "src/lib.rs"
[dependencies]
anyhow = "1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
clap = { version = "4", features = ["derive"] }
git2 = { version = "0.19", default-features = false }
regex = "1"
[profile.release]
opt-level = 3
lto = "thin"

View file

@ -0,0 +1,77 @@
//! Commit model — parsed conventional-commit record.
use std::fmt;
/// Conventional-commit kind.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CommitKind {
Feat,
Fix,
Refactor,
Docs,
Test,
Chore,
Perf,
Ci,
Build,
Checkpoint,
Audit,
/// Anything we do not recognise as conventional.
Other(String),
}
impl CommitKind {
/// Stable ordering for grouping in CHANGELOG.md (lower = earlier).
#[must_use]
pub fn sort_key(&self) -> u8 {
match self {
Self::Feat => 0,
Self::Fix => 1,
Self::Perf => 2,
Self::Refactor => 3,
Self::Docs => 4,
Self::Test => 5,
Self::Build => 6,
Self::Ci => 7,
Self::Chore => 8,
Self::Audit => 9,
Self::Checkpoint => 10,
Self::Other(_) => 11,
}
}
/// Human-facing section heading used in `render::render_markdown`.
#[must_use]
pub fn heading(&self) -> &str {
match self {
Self::Feat => "Features",
Self::Fix => "Fixes",
Self::Perf => "Performance",
Self::Refactor => "Refactor",
Self::Docs => "Documentation",
Self::Test => "Tests",
Self::Build => "Build",
Self::Ci => "CI",
Self::Chore => "Chore",
Self::Audit => "Audit",
Self::Checkpoint => "Checkpoints",
Self::Other(_) => "Other",
}
}
}
impl fmt::Display for CommitKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.heading())
}
}
/// Parsed commit record used by the walker and renderer.
#[derive(Debug, Clone)]
pub struct Commit {
pub sha: String,
pub kind: CommitKind,
pub scope: Option<String>,
pub subject: String,
pub breaking: bool,
}

View file

@ -0,0 +1,39 @@
//! Group commits by kind, preserving insertion order within each bucket.
use crate::commit::{Commit, CommitKind};
use std::collections::BTreeMap;
/// Commits grouped by `CommitKind`, sorted by `CommitKind::sort_key`.
#[derive(Debug, Default)]
pub struct Grouped {
pub by_kind: BTreeMap<u8, (CommitKind, Vec<Commit>)>,
pub breaking: Vec<Commit>,
}
impl Grouped {
/// Build a `Grouped` from an ordered slice of commits.
///
/// Breaking commits are additionally copied into `breaking` so renderers
/// can surface them in a "BREAKING CHANGES" section.
#[must_use]
pub fn from_commits(commits: &[Commit]) -> Self {
let mut g = Self::default();
for c in commits {
if c.breaking {
g.breaking.push(c.clone());
}
let key = c.kind.sort_key();
g.by_kind
.entry(key)
.or_insert_with(|| (c.kind.clone(), Vec::new()))
.1
.push(c.clone());
}
g
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_kind.is_empty() && self.breaking.is_empty()
}
}

View file

@ -0,0 +1,16 @@
//! kei-changelog — library surface.
//!
//! Public modules, re-exported for the binary and integration tests.
//! Constructor Pattern: one file = one concern; keep this root < 30 LOC.
pub mod commit;
pub mod group;
pub mod parse;
pub mod render;
pub mod walk;
pub use commit::{Commit, CommitKind};
pub use group::Grouped;
pub use parse::parse_subject;
pub use render::{render_markdown, RenderOpts};
pub use walk::{walk_range, WalkRange};

View file

@ -0,0 +1,88 @@
//! kei-changelog — CLI entry point.
//!
//! Thin wrapper over the library modules. Keeps flag parsing + IO here; all
//! commit / render logic lives in `lib.rs`.
use anyhow::{Context, Result};
use clap::Parser;
use kei_changelog::{
group::Grouped, render::render_markdown, render::RenderOpts, walk::walk_range, walk::WalkRange,
};
use std::fs;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "kei-changelog", version, about = "Generate CHANGELOG.md from conventional commits")]
struct Cli {
/// Starting ref (exclusive). Defaults to the full history root.
#[arg(long)]
from: Option<String>,
/// Ending ref (inclusive). Defaults to `HEAD`.
#[arg(long, default_value = "HEAD")]
to: String,
/// Treat the range as an Unreleased section (overrides --version heading).
#[arg(long)]
unreleased: bool,
/// Version label for the rendered block (e.g. "v0.7.0"). Ignored with --unreleased.
#[arg(long, default_value = "v0.1.0")]
version: String,
/// Repository path. Defaults to current directory.
#[arg(long, default_value = ".")]
repo: PathBuf,
/// Prepend output to this file (creates if missing). Without it, prints to stdout.
#[arg(long)]
update: Option<PathBuf>,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let range = WalkRange {
from: cli.from.clone(),
to: cli.to.clone(),
};
let commits = walk_range(&cli.repo, &range)?;
let grouped = Grouped::from_commits(&commits);
let version = if cli.unreleased {
"Unreleased".to_string()
} else {
cli.version.clone()
};
let opts = RenderOpts::new(version);
let rendered = render_markdown(&grouped, &opts);
if let Some(path) = cli.update.as_ref() {
let existing = fs::read_to_string(path).unwrap_or_default();
let body = if existing.is_empty() {
format!("# CHANGELOG\n\n{rendered}")
} else {
prepend_section(&existing, &rendered)
};
fs::write(path, body).with_context(|| format!("write {}", path.display()))?;
eprintln!("[kei-changelog] updated {}", path.display());
} else {
print!("{rendered}");
}
Ok(())
}
/// Insert `section` after the top-level `# CHANGELOG` heading if present,
/// otherwise prepend. Never duplicates an existing identical section verbatim.
fn prepend_section(existing: &str, section: &str) -> String {
if section.trim().is_empty() {
return existing.to_string();
}
if existing.contains(section.trim()) {
return existing.to_string();
}
if let Some(rest) = existing.strip_prefix("# CHANGELOG\n\n") {
format!("# CHANGELOG\n\n{section}{rest}")
} else {
format!("{section}\n{existing}")
}
}

View file

@ -0,0 +1,58 @@
//! Conventional-commit subject parser.
//!
//! Shape: `type(scope)!: subject` — scope and `!` optional.
//! Returns `(kind, scope, subject, breaking)`. Malformed → `Other` kind with
//! the full subject as `subject`.
use crate::commit::CommitKind;
use regex::Regex;
use std::sync::OnceLock;
fn re() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| {
Regex::new(r"^(?P<kind>[a-zA-Z]+)(?:\((?P<scope>[^)]+)\))?(?P<bang>!)?:\s+(?P<subject>.+)$")
.expect("valid regex")
})
}
fn kind_from(raw: &str) -> CommitKind {
match raw.to_ascii_lowercase().as_str() {
"feat" => CommitKind::Feat,
"fix" => CommitKind::Fix,
"refactor" => CommitKind::Refactor,
"docs" => CommitKind::Docs,
"test" => CommitKind::Test,
"chore" => CommitKind::Chore,
"perf" => CommitKind::Perf,
"ci" => CommitKind::Ci,
"build" => CommitKind::Build,
"checkpoint" => CommitKind::Checkpoint,
"audit" => CommitKind::Audit,
other => CommitKind::Other(other.to_string()),
}
}
/// Parse a commit subject line.
///
/// Returns `(kind, scope, subject, breaking)`. On a non-conventional subject,
/// returns `(Other("_"), None, full_line, false)`.
#[must_use]
pub fn parse_subject(first_line: &str) -> (CommitKind, Option<String>, String, bool) {
let trimmed = first_line.trim();
match re().captures(trimmed) {
Some(c) => {
let kind = kind_from(&c["kind"]);
let scope = c.name("scope").map(|m| m.as_str().to_string());
let subject = c["subject"].to_string();
let breaking = c.name("bang").is_some();
(kind, scope, subject, breaking)
}
None => (
CommitKind::Other("_".into()),
None,
trimmed.to_string(),
false,
),
}
}

View file

@ -0,0 +1,74 @@
//! Render a `Grouped` set of commits as a CHANGELOG.md section.
use crate::group::Grouped;
use chrono::{DateTime, Utc};
/// Options governing the rendered section.
#[derive(Debug, Clone)]
pub struct RenderOpts {
/// Heading for the version block, e.g. "v0.7.0" or "Unreleased".
pub version: String,
/// Optional release date. If `None`, uses today (UTC).
pub date: Option<DateTime<Utc>>,
/// If true, include short (7-char) SHA suffix on each line.
pub include_sha: bool,
}
impl RenderOpts {
#[must_use]
pub fn new(version: impl Into<String>) -> Self {
Self {
version: version.into(),
date: None,
include_sha: true,
}
}
}
fn fmt_line(subj: &str, scope: Option<&str>, sha: Option<&str>) -> String {
let mut s = String::new();
s.push_str("- ");
if let Some(sc) = scope {
s.push_str("**");
s.push_str(sc);
s.push_str(":** ");
}
s.push_str(subj);
if let Some(h) = sha {
s.push_str(&format!(" (`{}`)", &h[..h.len().min(7)]));
}
s
}
/// Render the grouped commits into markdown. Returns an empty string if the
/// grouping has no entries (caller can detect via `Grouped::is_empty`).
#[must_use]
pub fn render_markdown(grouped: &Grouped, opts: &RenderOpts) -> String {
if grouped.is_empty() {
return String::new();
}
let date = opts.date.unwrap_or_else(Utc::now).format("%Y-%m-%d");
let mut out = String::new();
out.push_str(&format!("## {}{date}\n\n", opts.version));
if !grouped.breaking.is_empty() {
out.push_str("### BREAKING CHANGES\n\n");
for c in &grouped.breaking {
let sha = if opts.include_sha { Some(c.sha.as_str()) } else { None };
out.push_str(&fmt_line(&c.subject, c.scope.as_deref(), sha));
out.push('\n');
}
out.push('\n');
}
for (_, (kind, commits)) in &grouped.by_kind {
out.push_str(&format!("### {}\n\n", kind.heading()));
for c in commits {
let sha = if opts.include_sha { Some(c.sha.as_str()) } else { None };
out.push_str(&fmt_line(&c.subject, c.scope.as_deref(), sha));
out.push('\n');
}
out.push('\n');
}
out
}

View file

@ -0,0 +1,57 @@
//! git2 walker — collect commits between two refs.
use crate::commit::Commit;
use crate::parse::parse_subject;
use anyhow::{Context, Result};
use git2::{Oid, Repository, Sort};
/// Range specification passed in from CLI.
#[derive(Debug, Clone)]
pub struct WalkRange {
pub from: Option<String>,
pub to: String,
}
fn resolve(repo: &Repository, name: &str) -> Result<Oid> {
let obj = repo
.revparse_single(name)
.with_context(|| format!("cannot resolve ref: {name}"))?;
Ok(obj.id())
}
/// Walk commits in topological order (newest first) from `to` back to `from`.
/// If `from` is `None`, walks the full history reachable from `to`.
pub fn walk_range(repo_path: &std::path::Path, range: &WalkRange) -> Result<Vec<Commit>> {
let repo = Repository::discover(repo_path)
.with_context(|| format!("not a git repo: {}", repo_path.display()))?;
let to_oid = resolve(&repo, &range.to)?;
let from_oid = match &range.from {
Some(name) => Some(resolve(&repo, name)?),
None => None,
};
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TOPOLOGICAL)?;
revwalk.push(to_oid)?;
if let Some(f) = from_oid {
revwalk.hide(f)?;
}
let mut out: Vec<Commit> = Vec::new();
for oid in revwalk {
let oid = oid?;
let commit = repo.find_commit(oid)?;
let first = commit.summary().unwrap_or("").to_string();
let body = commit.body().unwrap_or("");
let (kind, scope, subject, breaking_bang) = parse_subject(&first);
let breaking = breaking_bang || body.contains("BREAKING CHANGE");
out.push(Commit {
sha: oid.to_string(),
kind,
scope,
subject,
breaking,
});
}
Ok(out)
}

View file

@ -0,0 +1,51 @@
use kei_changelog::{parse_subject, CommitKind};
#[test]
fn feat_with_scope() {
let (kind, scope, subj, breaking) = parse_subject("feat(blocks): 5 documentation blocks");
assert_eq!(kind, CommitKind::Feat);
assert_eq!(scope.as_deref(), Some("blocks"));
assert_eq!(subj, "5 documentation blocks");
assert!(!breaking);
}
#[test]
fn fix_no_scope() {
let (kind, scope, _, breaking) = parse_subject("fix: off-by-one in walker");
assert_eq!(kind, CommitKind::Fix);
assert!(scope.is_none());
assert!(!breaking);
}
#[test]
fn breaking_bang() {
let (kind, _, _, breaking) = parse_subject("feat(api)!: rename endpoint");
assert_eq!(kind, CommitKind::Feat);
assert!(breaking);
}
#[test]
fn unknown_kind_falls_to_other() {
let (kind, _, _, _) = parse_subject("nonsense: whatever");
match kind {
CommitKind::Other(raw) => assert_eq!(raw, "nonsense"),
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn non_conventional_subject() {
let (kind, scope, subj, _) = parse_subject("just a plain message");
match kind {
CommitKind::Other(raw) => assert_eq!(raw, "_"),
other => panic!("expected Other('_'), got {other:?}"),
}
assert!(scope.is_none());
assert_eq!(subj, "just a plain message");
}
#[test]
fn checkpoint_kind() {
let (kind, _, _, _) = parse_subject("checkpoint: before big refactor");
assert_eq!(kind, CommitKind::Checkpoint);
}

View file

@ -0,0 +1,60 @@
use chrono::{TimeZone, Utc};
use kei_changelog::{render_markdown, Commit, CommitKind, Grouped, RenderOpts};
fn mk(kind: CommitKind, scope: Option<&str>, subject: &str, breaking: bool, sha: &str) -> Commit {
Commit {
sha: sha.to_string(),
kind,
scope: scope.map(str::to_string),
subject: subject.to_string(),
breaking,
}
}
#[test]
fn renders_feat_and_fix_sections() {
let commits = vec![
mk(CommitKind::Feat, Some("blocks"), "5 blocks", false, "abcdef1234"),
mk(CommitKind::Fix, None, "regex bug", false, "1234567890"),
];
let grouped = Grouped::from_commits(&commits);
let mut opts = RenderOpts::new("v0.1.0");
opts.date = Some(Utc.with_ymd_and_hms(2026, 4, 21, 0, 0, 0).unwrap());
let out = render_markdown(&grouped, &opts);
assert!(out.starts_with("## v0.1.0 — 2026-04-21\n"));
assert!(out.contains("### Features"));
assert!(out.contains("### Fixes"));
assert!(out.contains("**blocks:** 5 blocks (`abcdef1`)"));
assert!(out.contains("- regex bug (`1234567`)"));
}
#[test]
fn breaking_section_comes_first() {
let commits = vec![
mk(CommitKind::Feat, None, "non-breaking", false, "aaaaaaa"),
mk(CommitKind::Feat, Some("api"), "rename", true, "bbbbbbb"),
];
let grouped = Grouped::from_commits(&commits);
let out = render_markdown(&grouped, &RenderOpts::new("v1.0.0"));
let bi = out.find("BREAKING CHANGES").expect("section present");
let fi = out.find("### Features").expect("features present");
assert!(bi < fi, "BREAKING must come before Features");
}
#[test]
fn empty_grouped_renders_empty() {
let grouped = Grouped::from_commits(&[]);
let out = render_markdown(&grouped, &RenderOpts::new("v0.0.0"));
assert!(out.is_empty());
}
#[test]
fn include_sha_false_hides_hash() {
let commits = vec![mk(CommitKind::Feat, None, "x", false, "deadbeef0")];
let grouped = Grouped::from_commits(&commits);
let mut opts = RenderOpts::new("v0.1.0");
opts.include_sha = false;
let out = render_markdown(&grouped, &opts);
assert!(!out.contains("deadbee"));
assert!(out.contains("- x"));
}

285
_primitives/kei-docs-scaffold.sh Executable file
View file

@ -0,0 +1,285 @@
#!/bin/sh
# kei-docs-scaffold — detect project type, generate missing docs from templates.
# First-class primitive, POSIX sh (no bash-isms), ports to KeiSeiKit convention.
# Install path: $HOME/.claude/agents/_primitives/kei-docs-scaffold.sh
#
# Usage:
# kei-docs-scaffold.sh [--type=all|claude|decisions|runbook|readme] [--force] [--dry-run] [DIR]
#
# Flags:
# --type=TYPE Which doc to scaffold. Default: all.
# Values: all | claude | decisions | runbook | readme
# --force Overwrite existing files. Default: skip if present.
# --dry-run Print actions, do not write.
# DIR Project directory. Default: $PWD.
#
# Detection: examines DIR for Cargo.toml / package.json / pyproject.toml /
# pubspec.yaml / go.mod / Package.swift / docker-compose.yml. Writes
# scaffolds pre-filled with the detected stack name.
# Safe to re-run: idempotent without --force.
set -eu
# ---- defaults -------------------------------------------------------------
TYPE="all"
FORCE=0
DRY_RUN=0
DIR=""
# ---- flag parsing (POSIX, no getopt_long) ---------------------------------
while [ $# -gt 0 ]; do
case "$1" in
--type=*) TYPE="${1#--type=}" ;;
--type) shift; TYPE="$1" ;;
--force) FORCE=1 ;;
--dry-run) DRY_RUN=1 ;;
-h|--help)
sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
-*)
printf '[scaffold] unknown flag: %s\n' "$1" >&2
exit 2
;;
*)
[ -z "$DIR" ] || { printf '[scaffold] multiple DIR args\n' >&2; exit 2; }
DIR="$1"
;;
esac
shift
done
DIR="${DIR:-$PWD}"
[ -d "$DIR" ] || { printf '[scaffold] not a directory: %s\n' "$DIR" >&2; exit 2; }
case "$TYPE" in
all|claude|decisions|runbook|readme) : ;;
*) printf '[scaffold] invalid --type: %s\n' "$TYPE" >&2; exit 2 ;;
esac
# ---- stack detection ------------------------------------------------------
detect_stack() {
if [ -f "$DIR/Cargo.toml" ]; then echo "Rust (Cargo)"
elif [ -f "$DIR/pubspec.yaml" ]; then echo "Flutter / Dart"
elif [ -f "$DIR/package.json" ]; then echo "Node.js / TypeScript"
elif [ -f "$DIR/pyproject.toml" ]; then echo "Python (pyproject)"
elif [ -f "$DIR/requirements.txt" ]; then echo "Python (pip)"
elif [ -f "$DIR/go.mod" ]; then echo "Go"
elif [ -f "$DIR/Package.swift" ]; then echo "Swift (SPM)"
elif [ -f "$DIR/docker-compose.yml" ]; then echo "Docker (compose)"
else echo "Unknown"
fi
}
detect_test_cmd() {
case "$1" in
"Rust (Cargo)") echo "cargo test --release && cargo clippy -- -D warnings" ;;
"Flutter / Dart") echo "flutter test && flutter analyze" ;;
"Node.js / TypeScript") echo "npm test" ;;
"Python"*) echo "pytest -q" ;;
"Go") echo "go test ./..." ;;
"Swift (SPM)") echo "swift test" ;;
*) echo "# TODO: set test command" ;;
esac
}
STACK=$(detect_stack)
TEST_CMD=$(detect_test_cmd "$STACK")
PROJECT_NAME=$(basename "$DIR")
printf '[scaffold] project: %s stack: %s\n' "$PROJECT_NAME" "$STACK" >&2
# ---- write helpers --------------------------------------------------------
write_file() {
target="$1"
if [ -e "$target" ] && [ "$FORCE" -eq 0 ]; then
printf '[scaffold] skip (exists): %s\n' "$target" >&2
return 0
fi
if [ "$DRY_RUN" -eq 1 ]; then
printf '[scaffold] would write: %s\n' "$target" >&2
cat > /dev/null
return 0
fi
cat > "$target"
printf '[scaffold] wrote: %s\n' "$target" >&2
}
# ---- doc generators (one function per file, ≤ 30 LOC) ---------------------
gen_claude() {
write_file "$DIR/CLAUDE.md" <<EOF
# CLAUDE.md — $PROJECT_NAME
> Agent-facing project guide. Read FIRST at session start.
## Architecture
- [ ] Layer 1 — <describe>
- [ ] Layer 2 — <describe>
- [ ] Data flow — <describe>
## Stack
- **Language / framework:** $STACK
- **Package manager:** <pin>
- **Test runner:** \`$TEST_CMD\`
## Constraints
- Constructor Pattern: file < 200 LOC, function < 30 LOC
- No new dependency without Plan Mode
- No \`.unwrap()\` / \`.expect()\` in prod paths (Rust)
## Known issues
- [ ] <file:line> — <symptom>
## Test invariants
\`\`\`
$TEST_CMD
\`\`\`
## Hot paths (double-audit on touch)
- [ ] <file> — <why>
## References
- \`DECISIONS.md\` — architectural decisions
- \`docs/runbook.md\` — ops playbook (if deployed)
EOF
}
gen_decisions() {
write_file "$DIR/DECISIONS.md" <<'EOF'
# DECISIONS.md — Architectural Decision Records (MADR 4.0)
> Append-only. One decision = one ADR entry. Never delete; supersede instead.
## ADR-001 — Adopt Constructor Pattern
- **Status:** accepted
- **Date:** TODO
- **Deciders:** TODO
- **Evidence grade:** E4
### Context and problem statement
Projects tend to grow monolithic files / DI containers / abstract factories.
Debugging and refactoring slow down.
### Decision drivers
- Every file readable in one screen (< 200 LOC)
- Every function fits in one mental buffer (< 30 LOC)
- Reproducibility: any cube swappable
### Considered options
1. **Constructor Pattern** — 1 file = 1 class = 1 responsibility
2. Classical layered OOP — DI containers, mixins
3. Free-form — no rules
### Decision outcome
Chosen: **Option 1** — Constructor Pattern.
### Consequences
- Good: easy audit, easy swap, cubes compose
- Bad: more files, more module boundaries
- Neutral: matches KeiSeiKit kit defaults
### Verification
File-size lint on every PR. See repo CI.
EOF
}
gen_runbook() {
mkdir -p "$DIR/docs"
write_file "$DIR/docs/runbook.md" <<'EOF'
# Runbook — Ops Playbook
> One symptom = one entry. Check → Fix → Escalation. Read-only before write.
## Service does not start
### Check (read-only, < 5 min)
- `<status command>` — expected: running
- Log path: `<path>` — grep for `panic|ERROR|fatal`
- Port open? `<nc / lsof command>`
### Fix (by likelihood, safest first)
1. **Config drift** — diff env vs template, reapply
2. **Port conflict** — kill conflicting process (NOT the service itself)
3. **Upstream outage** — check status page, fall back
4. **Data corruption** — restore from last checkpoint
### Escalation
- After 15 min failed fixes → escalate to on-call
- After 3 repeats in 24h → open an incident review
---
## Latency spike
### Check
- `<metrics command>`
- Grep slow queries in `<log path>`
### Fix
1. Restart worker (BENIGN first)
2. Check DB connection pool saturation
3. Scale up if sustained
### Escalation
- After 30 min sustained p99 > SLO → page on-call
EOF
}
gen_readme() {
write_file "$DIR/README.md" <<EOF
# $PROJECT_NAME
> One-line pitch: <what this project does in ≤ 12 words>.
## Why
<One paragraph: the problem + how this differs from alternatives.>
## Install
\`\`\`
# TODO: paste copy-pasteable install command
\`\`\`
## Quickstart
\`\`\`
# TODO: minimal runnable example, ≤ 15 lines
\`\`\`
## Features
- [ ] Feature A — link to docs
- [ ] Feature B — link to docs
## Architecture
See \`CLAUDE.md\` for agent-facing details. Stack: **$STACK**.
## Status
Alpha. Versioning: SemVer 0.x.
## License
See \`LICENSE\`.
EOF
}
# ---- dispatch -------------------------------------------------------------
case "$TYPE" in
all) gen_claude; gen_decisions; gen_runbook; gen_readme ;;
claude) gen_claude ;;
decisions) gen_decisions ;;
runbook) gen_runbook ;;
readme) gen_readme ;;
esac
printf '[scaffold] done\n' >&2