Merge branch 'feat/v0.7-docs' — 5 blocks + kei-docs-scaffold + kei-changelog Rust + /docs-scaffold
This commit is contained in:
commit
e5d565a11e
22 changed files with 1564 additions and 0 deletions
86
_blocks/docs-architecture-diagrams.md
Normal file
86
_blocks/docs-architecture-diagrams.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# DOCS — Architecture diagrams (Mermaid)
|
||||
|
||||
Diagrams live beside the prose they describe. Mermaid renders natively on
|
||||
GitHub / Forgejo / Gitea / Obsidian — no extra tooling needed to view.
|
||||
|
||||
## When to include
|
||||
|
||||
- Any agent/skill that scaffolds documentation for a multi-component system
|
||||
- Any repo with ≥ 3 services / layers / subsystems
|
||||
|
||||
## Four diagram patterns (use the right one)
|
||||
|
||||
### 1. System context (C4 level 1) — `flowchart LR`
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[User] -->|HTTP| API[API Gateway]
|
||||
API -->|gRPC| SVC[Service]
|
||||
SVC -->|SQL| DB[(PostgreSQL)]
|
||||
SVC -->|publish| Q[[Queue]]
|
||||
```
|
||||
|
||||
Use for: one-page overview, onboarding, README architecture section.
|
||||
|
||||
### 2. Sequence — `sequenceDiagram`
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
Client->>API: POST /orders
|
||||
API->>DB: INSERT
|
||||
DB-->>API: id
|
||||
API-->>Client: 201 Created
|
||||
```
|
||||
|
||||
Use for: request flow, auth handshake, error recovery sequence.
|
||||
|
||||
### 3. State machine — `stateDiagram-v2`
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Pending
|
||||
Pending --> Running: start
|
||||
Running --> Done: success
|
||||
Running --> Failed: error
|
||||
Failed --> Pending: retry
|
||||
```
|
||||
|
||||
Use for: job lifecycle, FSM-driven features, connection state.
|
||||
|
||||
### 4. ER / data model — `erDiagram`
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USER ||--o{ ORDER : places
|
||||
ORDER ||--|{ LINE_ITEM : contains
|
||||
```
|
||||
|
||||
Use for: DB schema summary. Keep ≤ 10 entities per diagram.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Diagram-as-code, no binary exports.** `.mmd` or fenced block, never `.png`
|
||||
- **≤ 15 nodes / 20 edges per diagram.** Over that → split
|
||||
- **Labels are nouns.** Edges are verbs. No prose inside nodes
|
||||
- **One diagram = one concern.** Don't mix system context + sequence in one chart
|
||||
- **Preview locally** with `mmdc` before commit: `mmdc -i diagram.mmd -o /tmp/preview.svg`
|
||||
- **Link to source in caption** — "See `docs/diagrams/orders.mmd` for source"
|
||||
|
||||
## Forbidden
|
||||
|
||||
- ASCII art for multi-node graphs (use Mermaid — renders everywhere)
|
||||
- Diagrams that contradict the code (stale → delete or fix)
|
||||
- Secrets / real hostnames / IPs in diagrams (use placeholders)
|
||||
|
||||
## Install `mmdc` (preview tool)
|
||||
|
||||
```
|
||||
npm install -g @mermaid-js/mermaid-cli # one-time
|
||||
mmdc -i docs/diagrams/context.mmd -o /tmp/preview.svg
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Mermaid syntax — https://mermaid.js.org/intro/ [VERIFIED: https://mermaid.js.org/intro/]
|
||||
- C4 model — https://c4model.com/ [VERIFIED: https://c4model.com/]
|
||||
- `~/.claude/rules/doc-conventions.md`
|
||||
25
_blocks/docs-claude-md.md
Normal file
25
_blocks/docs-claude-md.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# DOCS — `CLAUDE.md` (project bootstrap template)
|
||||
|
||||
A per-project `CLAUDE.md` answers one question: *what does a Claude agent need to know in the first 30 seconds on this repo?* It is read before any code work. Keep it under ~150 lines.
|
||||
|
||||
**Canonical sections (in this order):**
|
||||
|
||||
1. **Project one-liner** — name, domain, status (`active | maintenance | archived`), primary stack, public-surface flag.
|
||||
2. **Architecture** — 2-5 bullets + optional Mermaid block. Layer names match the code tree. If a layer diagram helps, `_blocks/docs-architecture-diagrams.md` has the patterns.
|
||||
3. **Stack / dependencies** — language(s), major frameworks, DB, queue, deploy target. One line per item.
|
||||
4. **Constraints** — API rate limits, licensing, cost tiers, platform quirks (e.g. "Flux 2 Pro zero-config", "SPM needs `-Xlinker`").
|
||||
5. **Known issues** — bugs that aren't fixable now, workarounds, tickets. Keep dated.
|
||||
6. **Test invariants** — how tests are run (`cargo test --release`, `pytest`, `flutter test`), coverage floor, which tests are load-bearing.
|
||||
7. **Commands cheatsheet** — 5-8 commands the agent will type most: build, test, lint, deploy, format.
|
||||
8. **Secrets / credentials** — env var NAMES only (RULE 0.8). Never literal tokens. Path: `secrets/*.env`.
|
||||
9. **Related files** — `DECISIONS.md`, `HOTPATHS.md`, `TODO.md`, runbooks.
|
||||
|
||||
**Placeholders used by `kei-docs-scaffold.sh`:**
|
||||
`{{PROJECT_NAME}}`, `{{STACK}}`, `{{DEPLOY}}`, `{{PRIMARY_LANGUAGE}}`, `{{TEST_CMD}}`.
|
||||
|
||||
**Forbidden:**
|
||||
- Copying the umbrella `~/.claude/CLAUDE.md` here — link to it, do not duplicate.
|
||||
- Storing API tokens / private URLs (use `secrets/*.env`).
|
||||
- Marketing prose. Every line must be actionable by the agent.
|
||||
|
||||
**Source:** Anthropic Claude Code docs — `claude.ai/code` project-memory convention (E4). Karpathy viral CLAUDE.md (forrestchang/andrej-karpathy-skills, 15K+ stars) [E4].
|
||||
59
_blocks/docs-decisions-adr.md
Normal file
59
_blocks/docs-decisions-adr.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# DOCS — `DECISIONS.md` / ADR template (MADR 4.0)
|
||||
|
||||
Architecture Decision Records capture *why* a non-trivial choice was made, so future maintainers (including agents) don't re-litigate. Format: **MADR 4.0** (Markdown Any Decision Records, 2024). Nygard originated the practice in 2011.
|
||||
|
||||
**One ADR per non-trivial decision.** File path convention:
|
||||
- Single-file log: append to `DECISIONS.md` (top-of-file = newest).
|
||||
- Per-decision files: `docs/adr/NNNN-kebab-case-title.md` (NNNN = zero-padded int).
|
||||
|
||||
**MADR 4.0 template (copy as-is):**
|
||||
|
||||
```markdown
|
||||
# ADR-NNNN: <short imperative title>
|
||||
|
||||
- **Status:** Proposed | Accepted | Rejected | Superseded-by-ADR-NNNN | Deprecated
|
||||
- **Date:** YYYY-MM-DD
|
||||
- **Deciders:** @handle, @handle
|
||||
- **Evidence grade:** E1-E6 (see `_blocks/evidence-grading.md`)
|
||||
|
||||
## Context and Problem Statement
|
||||
<1-3 sentences: what forces us to decide? What breaks if we don't?>
|
||||
|
||||
## Decision Drivers
|
||||
- Driver 1 (e.g. cost < $X/mo)
|
||||
- Driver 2 (e.g. must run offline)
|
||||
- Driver 3 (e.g. team knows language Y)
|
||||
|
||||
## Considered Options
|
||||
1. **Option A** — one-line summary
|
||||
2. **Option B** — one-line summary
|
||||
3. **Option C** — one-line summary
|
||||
|
||||
## Decision Outcome
|
||||
Chosen: **Option <letter>**, because <1-2 sentences tying back to drivers>.
|
||||
|
||||
### Consequences
|
||||
- Positive: <what improves>
|
||||
- Negative: <what we give up, tech debt incurred>
|
||||
- Neutral: <noteworthy but not directional>
|
||||
|
||||
## Pros and Cons of the Options
|
||||
### Option A
|
||||
- Pro: ...
|
||||
- Con: ...
|
||||
### Option B
|
||||
- Pro: ...
|
||||
- Con: ...
|
||||
|
||||
## Links
|
||||
- Supersedes: ADR-NNNN
|
||||
- Related: `HOTPATHS.md#section`, external URL
|
||||
- Evidence source: [VERIFIED: url] or [UNVERIFIED]
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Status `Accepted` = implemented or actively being implemented. `Proposed` = under review. `Rejected` stays as an ADR (the record of why we said no).
|
||||
- Never delete a past ADR. Supersede with a new ADR that references the old number.
|
||||
- Evidence grade mandatory (RULE 0.4). No grade → the ADR is unreviewable.
|
||||
|
||||
**Source:** MADR 4.0 spec — [adr/madr](https://adr.github.io/madr/) [E4]. Nygard 2011 original post `cognitect.com/blog/2011/11/15/documenting-architecture-decisions` [E4].
|
||||
75
_blocks/docs-readme-template.md
Normal file
75
_blocks/docs-readme-template.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# DOCS — Public `README.md` scaffold
|
||||
|
||||
`README.md` is the first file a new reader (human OR agent) opens. One file, nine sections, in this order. Keep ≤ 300 lines; longer material lives in `docs/`.
|
||||
|
||||
**Nine-section template:**
|
||||
|
||||
```markdown
|
||||
# {{PROJECT_NAME}}
|
||||
|
||||
> One-line pitch (what + why, ≤ 100 chars).
|
||||
|
||||
[](link) [](link) [](link)
|
||||
|
||||
## What
|
||||
2-3 sentences: what it does, who it's for. No marketing adjectives.
|
||||
|
||||
## Why
|
||||
2-3 sentences: problem this solves, alternatives considered, why this one.
|
||||
Link to the relevant ADR: [DECISIONS.md](DECISIONS.md#adr-nnnn).
|
||||
|
||||
## Install
|
||||
```bash
|
||||
# Primary path — the 90% case
|
||||
<one command>
|
||||
```
|
||||
|
||||
**Prerequisites:** <language X >= vN, OS constraints, system deps>.
|
||||
|
||||
<If needed: alternative install methods in `docs/install.md`.>
|
||||
|
||||
## Usage
|
||||
Smallest working example. Copy-pasteable.
|
||||
```bash
|
||||
<command producing visible output>
|
||||
```
|
||||
|
||||
Link to a richer quickstart in `docs/quickstart.md` if >20 lines.
|
||||
|
||||
## Development
|
||||
```bash
|
||||
git clone <repo>
|
||||
cd <repo>
|
||||
<bootstrap command, e.g. cargo build>
|
||||
<test command>
|
||||
```
|
||||
|
||||
Project layout:
|
||||
- `src/` — implementation
|
||||
- `tests/` — integration tests
|
||||
- `docs/` — long-form docs
|
||||
- `{{STACK}}-specific notes → link>
|
||||
|
||||
## Deploy
|
||||
Target: **{{DEPLOY}}**. One-liner: `<deploy command>`.
|
||||
Full runbook: `docs/runbooks/deploy.md`.
|
||||
|
||||
## Architecture
|
||||
One paragraph + one Mermaid diagram (see `_blocks/docs-architecture-diagrams.md`). Detail in `docs/architecture.md`.
|
||||
|
||||
## Contributing
|
||||
- Issue tracker: <url>
|
||||
- Commit convention: Conventional Commits (see `_blocks/git-conventions` in kit)
|
||||
- PR checklist: `docs/CONTRIBUTING.md`
|
||||
|
||||
## License
|
||||
<SPDX id> — see [LICENSE](LICENSE).
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- No secrets (RULE 0.8). No literal tokens.
|
||||
- Install command must be ONE command for the happy path.
|
||||
- Every "see docs/X" link must resolve — scaffolder verifies or creates the target.
|
||||
- If the project is private / not publicly deployable (banned list per `rules/security.md`), mark the repo header with `<!-- PRIVATE — do not publish -->` and omit public badges.
|
||||
|
||||
**Source:** standard-readme spec (RichardLitt/standard-readme) [E4]; GitHub "About READMEs" [E4].
|
||||
66
_blocks/docs-runbook.md
Normal file
66
_blocks/docs-runbook.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# DOCS — Operational runbook template
|
||||
|
||||
A runbook tells on-call (or a future agent) exactly what to do when an alert fires. Every production system needs one per failure class. Format: *symptoms → checks → fixes → escalation*.
|
||||
|
||||
**File path:** `docs/runbooks/<component>-<alert-name>.md`. Index in `docs/runbooks/README.md` (or link from `HOTPATHS.md`).
|
||||
|
||||
**Template (copy as-is):**
|
||||
|
||||
```markdown
|
||||
# Runbook — <component>: <alert or symptom name>
|
||||
|
||||
## Metadata
|
||||
- **Severity:** SEV1 (page now) | SEV2 (work hours) | SEV3 (next day)
|
||||
- **On-call rotation:** <team / pagerduty schedule / single handle>
|
||||
- **Last rehearsed:** YYYY-MM-DD (stale > 90d → re-rehearse)
|
||||
- **Linked ADRs:** ADR-NNNN
|
||||
|
||||
## Symptoms
|
||||
- Observable signal: <metric name> > <threshold> for <duration>
|
||||
- User impact: <what breaks for end users>
|
||||
- Typical dashboards: <URLs>
|
||||
|
||||
## Diagnostic checks (in order)
|
||||
1. Check dashboard X — if metric Y is flat, skip to step 4
|
||||
2. Tail logs: `<exact command>`
|
||||
3. Inspect dependency Z status page: <URL>
|
||||
4. Reproduce locally if unclear: `<command>`
|
||||
|
||||
## Fixes (try in order; STOP at first that works)
|
||||
### Fix A — restart (lowest risk)
|
||||
```bash
|
||||
<exact command>
|
||||
```
|
||||
Verify: <metric returns to <threshold> within <time>>
|
||||
|
||||
### Fix B — rollback
|
||||
```bash
|
||||
<exact command>
|
||||
```
|
||||
Verify: <...>
|
||||
|
||||
### Fix C — scale up (if load-related)
|
||||
```bash
|
||||
<exact command>
|
||||
```
|
||||
|
||||
## Escalation
|
||||
- 15 min without recovery → page <secondary on-call>
|
||||
- Data loss suspected → page <eng-lead> AND <security>
|
||||
- Customer-visible > 30 min → post to <status-page-url>
|
||||
|
||||
## Post-incident
|
||||
- File incident report at `docs/incidents/YYYY-MM-DD-<slug>.md`
|
||||
- If root cause new → new ADR in `DECISIONS.md`
|
||||
- If runbook step failed → update this file (date the edit)
|
||||
|
||||
## Known non-issues
|
||||
- Symptom X that looks scary but is benign (e.g. queue lag < 5s during deploy)
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- One alert = one runbook. Do not bundle.
|
||||
- Every command is copy-pasteable. No placeholders `<...>` in the live fixes section.
|
||||
- Rehearse quarterly. Mark the date.
|
||||
|
||||
**Source:** Google SRE Book Ch. 11 "Being On-Call" and Ch. 14 "Managing Incidents" [E4]; PagerDuty Incident Response Documentation [E4].
|
||||
24
_primitives/_rust/kei-changelog/Cargo.toml
Normal file
24
_primitives/_rust/kei-changelog/Cargo.toml
Normal 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"
|
||||
77
_primitives/_rust/kei-changelog/src/commit.rs
Normal file
77
_primitives/_rust/kei-changelog/src/commit.rs
Normal 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,
|
||||
}
|
||||
39
_primitives/_rust/kei-changelog/src/group.rs
Normal file
39
_primitives/_rust/kei-changelog/src/group.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
16
_primitives/_rust/kei-changelog/src/lib.rs
Normal file
16
_primitives/_rust/kei-changelog/src/lib.rs
Normal 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};
|
||||
88
_primitives/_rust/kei-changelog/src/main.rs
Normal file
88
_primitives/_rust/kei-changelog/src/main.rs
Normal 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}")
|
||||
}
|
||||
}
|
||||
58
_primitives/_rust/kei-changelog/src/parse.rs
Normal file
58
_primitives/_rust/kei-changelog/src/parse.rs
Normal 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,
|
||||
),
|
||||
}
|
||||
}
|
||||
74
_primitives/_rust/kei-changelog/src/render.rs
Normal file
74
_primitives/_rust/kei-changelog/src/render.rs
Normal 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
|
||||
}
|
||||
57
_primitives/_rust/kei-changelog/src/walk.rs
Normal file
57
_primitives/_rust/kei-changelog/src/walk.rs
Normal 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)
|
||||
}
|
||||
51
_primitives/_rust/kei-changelog/tests/parse.rs
Normal file
51
_primitives/_rust/kei-changelog/tests/parse.rs
Normal 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);
|
||||
}
|
||||
60
_primitives/_rust/kei-changelog/tests/render.rs
Normal file
60
_primitives/_rust/kei-changelog/tests/render.rs
Normal 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
285
_primitives/kei-docs-scaffold.sh
Executable 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
|
||||
93
skills/docs-scaffold/SKILL.md
Normal file
93
skills/docs-scaffold/SKILL.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
name: docs-scaffold
|
||||
description: 5-phase documentation scaffolder — auto-detect project type, audit existing docs, generate CLAUDE.md / DECISIONS.md / runbook / README / diagrams / CHANGELOG from KeiSeiKit templates. Each phase is a click-driven file in this skill directory.
|
||||
argument-hint: <project directory | omit to use $PWD>
|
||||
---
|
||||
|
||||
# Docs-Scaffold — Project Documentation Pipeline (index)
|
||||
|
||||
You are bootstrapping or auditing the documentation layer of a repository.
|
||||
The pipeline runs 5 phases in order; each phase owns one concern and has
|
||||
its own verify-criterion. Never skip a phase. Never re-order.
|
||||
|
||||
This `SKILL.md` is the INDEX. Each phase lives in its own file.
|
||||
|
||||
---
|
||||
|
||||
## Pipeline overview (5 phases)
|
||||
|
||||
| Phase | File | Purpose | AskUserQuestion |
|
||||
|---|---|---|---|
|
||||
| 1 | [phase-1-intake.md](phase-1-intake.md) | Auto-detect stack + audit existing docs + pick gaps | 1× |
|
||||
| 2 | [phase-2-scaffold.md](phase-2-scaffold.md) | Run `kei-docs-scaffold.sh` with selected type | 1× |
|
||||
| 3 | [phase-3-decisions.md](phase-3-decisions.md) | Walk through first ADR (optional) | 1× |
|
||||
| 4 | [phase-4-diagrams.md](phase-4-diagrams.md) | Seed Mermaid architecture starter | 1× |
|
||||
| 5 | [phase-5-changelog.md](phase-5-changelog.md) | Init CHANGELOG via `kei-changelog` | 1× |
|
||||
|
||||
Minimum AskUserQuestion count: **5** (one per phase). Phases 3-5 each have
|
||||
an early "Skip this phase" option to keep a lightweight run short.
|
||||
|
||||
---
|
||||
|
||||
## Variables the pipeline produces
|
||||
|
||||
| Name | Set in | Meaning |
|
||||
|---|---|---|
|
||||
| `DIR` | Phase 1a | Target repo directory (defaults to `$PWD`) |
|
||||
| `STACK` | Phase 1a | Detected stack label (Rust / Flutter / Node / …) |
|
||||
| `EXISTING` | Phase 1b | Set of docs already present (CLAUDE.md, DECISIONS.md, …) |
|
||||
| `GAPS` | Phase 1c | User-selected subset to scaffold |
|
||||
| `SCAFFOLDED` | Phase 2 | Files actually written (could be < `GAPS` if `--force` declined) |
|
||||
| `ADR_N` | Phase 3 | Number of ADR entries appended (0 if skipped) |
|
||||
| `DIAGRAMS` | Phase 4 | Mermaid files seeded (0 if skipped) |
|
||||
| `CHANGELOG_STATUS` | Phase 5 | initialized / updated / skipped |
|
||||
|
||||
---
|
||||
|
||||
## Final report (emit after Phase 5)
|
||||
|
||||
```
|
||||
=== DOCS-SCAFFOLD REPORT ===
|
||||
Target: <DIR>
|
||||
Stack: <STACK>
|
||||
Existing: <list>
|
||||
Scaffolded: <list of new files>
|
||||
ADRs added: <ADR_N>
|
||||
Diagrams: <DIAGRAMS>
|
||||
Changelog: <CHANGELOG_STATUS>
|
||||
Next action: <what user should run / review / commit>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rules (apply throughout)
|
||||
|
||||
- **Pure-click contract.** Every phase has exactly one `AskUserQuestion`
|
||||
call. Only Phase 1a takes free-text (the target directory path).
|
||||
- **NO DOWNGRADE (RULE -1).** If scaffolding fails, return 2-3 concrete
|
||||
alternative paths; never "can't be done".
|
||||
- **NO HALLUCINATION (RULE 0.4).** Every primitive / block / template
|
||||
referenced MUST exist on disk. Phase 1 greps before citing. Phase 2
|
||||
verifies `kei-docs-scaffold.sh` is executable before invoking.
|
||||
- **Plan Mode First (RULE 0.5).** This skill IS the plan. No Edit/Write
|
||||
before the corresponding phase's confirm click.
|
||||
- **Constructor Pattern (RULE ZERO).** This `SKILL.md` stays < 200 LOC.
|
||||
Each phase file < 80 LOC.
|
||||
- **Surgical Changes.** Scaffold only creates files; never modifies code.
|
||||
Only touches: `CLAUDE.md`, `DECISIONS.md`, `docs/runbook.md`,
|
||||
`README.md`, `docs/diagrams/*.mmd`, `CHANGELOG.md`. Never edits source.
|
||||
- **Idempotent.** Re-runs skip existing files unless `--force` is passed
|
||||
in Phase 2. No duplicate ADR numbering in Phase 3.
|
||||
- **Public-publish gate.** README scaffold refuses to write if the repo is
|
||||
on the banned-public list (see `~/.claude/rules/security.md`). User
|
||||
must type "yes, deploy" + "confirm publication" to override.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Phases: [phase-1-intake.md](phase-1-intake.md) · [phase-2-scaffold.md](phase-2-scaffold.md) · [phase-3-decisions.md](phase-3-decisions.md) · [phase-4-diagrams.md](phase-4-diagrams.md) · [phase-5-changelog.md](phase-5-changelog.md)
|
||||
- Primitive: `_primitives/kei-docs-scaffold.sh` — detector + generator (POSIX sh)
|
||||
- Primitive: `_primitives/_rust/kei-changelog/` — Conventional Commit → CHANGELOG.md
|
||||
- Blocks: `_blocks/docs-claude-md.md`, `_blocks/docs-decisions-adr.md`, `_blocks/docs-runbook.md`, `_blocks/docs-readme-template.md`, `_blocks/docs-architecture-diagrams.md`
|
||||
- Rules: `~/.claude/rules/doc-conventions.md`, `~/.claude/rules/security.md`
|
||||
67
skills/docs-scaffold/phase-1-intake.md
Normal file
67
skills/docs-scaffold/phase-1-intake.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Phase 1 — Intake (auto-detect + audit + pick gaps)
|
||||
|
||||
Goal: identify the target repo, detect its stack, enumerate docs that
|
||||
already exist, and let the user pick which gaps to scaffold.
|
||||
|
||||
## 1a — Target directory + stack detect
|
||||
|
||||
If the skill was invoked with a directory argument, use it. Otherwise use
|
||||
`$PWD`. Store as `DIR`. Reject non-directories with a short error + the
|
||||
user re-enters.
|
||||
|
||||
Run `_primitives/kei-docs-scaffold.sh --dry-run --type=all "$DIR"` just
|
||||
to print the detected stack, or replicate the detection via Read:
|
||||
|
||||
- `Cargo.toml` → **Rust (Cargo)**
|
||||
- `pubspec.yaml` → **Flutter / Dart**
|
||||
- `package.json` → **Node.js / TypeScript**
|
||||
- `pyproject.toml` | `requirements.txt` → **Python**
|
||||
- `go.mod` → **Go**
|
||||
- `Package.swift` → **Swift (SPM)**
|
||||
- `docker-compose.yml` → **Docker (compose)**
|
||||
- else → **Unknown**
|
||||
|
||||
Store as `STACK`. Print: `[docs-scaffold] DIR=<DIR> STACK=<STACK>`.
|
||||
|
||||
## 1b — Audit existing docs
|
||||
|
||||
List presence of each target file; store the set as `EXISTING`:
|
||||
|
||||
- `<DIR>/CLAUDE.md`
|
||||
- `<DIR>/DECISIONS.md`
|
||||
- `<DIR>/docs/runbook.md`
|
||||
- `<DIR>/README.md`
|
||||
- `<DIR>/docs/diagrams/` (directory, non-empty)
|
||||
- `<DIR>/CHANGELOG.md`
|
||||
|
||||
## 1c — Pick gaps to scaffold (AskUserQuestion #1, multi-select)
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Which docs to scaffold? (existing files are skipped unless --force selected in Phase 2)",
|
||||
"header": "Gaps",
|
||||
"multiSelect": true,
|
||||
"options": [
|
||||
{"label": "CLAUDE.md", "description": "Agent-facing project guide (architecture, stack, constraints)"},
|
||||
{"label": "DECISIONS.md", "description": "MADR 4.0 append-only ADR log"},
|
||||
{"label": "docs/runbook.md", "description": "Ops playbook (symptom → check → fix → escalation)"},
|
||||
{"label": "README.md", "description": "Public README — scaffolder checks banned-public list first"},
|
||||
{"label": "docs/diagrams/", "description": "Mermaid architecture starter (Phase 4 seeds one file)"},
|
||||
{"label": "CHANGELOG.md", "description": "Via kei-changelog from conventional commits (Phase 5)"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Store selection as `GAPS`. If empty → skip to final report with
|
||||
"Nothing to scaffold".
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- `DIR` exists as a directory on disk.
|
||||
- `STACK` is one of the labels above (or `Unknown`).
|
||||
- `EXISTING` is computed and reported to the user inline.
|
||||
- `GAPS` is captured from the click.
|
||||
72
skills/docs-scaffold/phase-2-scaffold.md
Normal file
72
skills/docs-scaffold/phase-2-scaffold.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Phase 2 — Scaffold (run `kei-docs-scaffold.sh`)
|
||||
|
||||
Goal: produce the files selected in `GAPS`, non-destructively by default.
|
||||
|
||||
## 2a — Confirm scaffold mode (AskUserQuestion #2)
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Scaffold mode?",
|
||||
"header": "Mode",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "Safe — skip existing files", "description": "Recommended. Writes only to files not already on disk."},
|
||||
{"label": "Force — overwrite existing", "description": "Pass --force. Existing files are replaced (one git checkpoint emitted first)."},
|
||||
{"label": "Dry-run — print planned actions only", "description": "No writes. Use for an audit report before committing."},
|
||||
{"label": "Abort", "description": "Stop — nothing gets written."}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 2b — Invoke the primitive
|
||||
|
||||
Locate the scaffolder at `_primitives/kei-docs-scaffold.sh` (repo-local)
|
||||
or `~/.claude/agents/_primitives/kei-docs-scaffold.sh` (installed). If
|
||||
neither exists, abort with a NO DOWNGRADE error listing two paths:
|
||||
|
||||
1. Run `./install.sh` from the KeiSeiKit repo to install primitives.
|
||||
2. Clone KeiSeiKit and invoke the scaffolder directly from the repo.
|
||||
|
||||
For each `GAPS` entry (except `CHANGELOG.md` — that is Phase 5, and
|
||||
`docs/diagrams/` — Phase 4), map to the scaffolder's `--type` value:
|
||||
|
||||
| GAPS entry | `--type` |
|
||||
|---|---|
|
||||
| CLAUDE.md | `claude` |
|
||||
| DECISIONS.md | `decisions` |
|
||||
| docs/runbook.md | `runbook` |
|
||||
| README.md | `readme` |
|
||||
|
||||
Invoke per selected type (or `--type=all` if all four non-Phase 4/5
|
||||
entries are selected):
|
||||
|
||||
```bash
|
||||
kei-docs-scaffold.sh [--force] [--dry-run] --type=<type> "$DIR"
|
||||
```
|
||||
|
||||
Capture each write line (`[scaffold] wrote: <path>`) into `SCAFFOLDED`.
|
||||
|
||||
## 2c — README banned-public gate
|
||||
|
||||
If `README.md` is in `GAPS`, grep the project for banned-public markers
|
||||
BEFORE invoking the scaffolder:
|
||||
|
||||
```
|
||||
grep -rEi "weight|checkpoint|training-loop|offensive|kernel|guidance-law" "$DIR" --include="*.md" --include="*.toml"
|
||||
```
|
||||
|
||||
If matches found → stop and require the user to type `"yes, deploy"` +
|
||||
`"confirm publication"` literal phrases before proceeding. See
|
||||
`~/.claude/rules/security.md`.
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- The scaffolder exited with status 0.
|
||||
- `SCAFFOLDED` is a non-empty list (unless dry-run was chosen).
|
||||
- No file outside `$DIR` was written.
|
||||
- If README was scaffolded, the banned-public grep was clean OR the
|
||||
double-confirmation was captured.
|
||||
56
skills/docs-scaffold/phase-3-decisions.md
Normal file
56
skills/docs-scaffold/phase-3-decisions.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Phase 3 — First ADR walk-through (optional)
|
||||
|
||||
Goal: if `DECISIONS.md` was just scaffolded, offer to append one real ADR
|
||||
now while context is fresh. Otherwise skip.
|
||||
|
||||
## 3a — Gate (AskUserQuestion #3)
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Append a real ADR now?",
|
||||
"header": "ADR",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "Yes — walk me through ADR-002", "description": "Interactive: I ask context / drivers / options / outcome; I write the entry"},
|
||||
{"label": "No — keep the template only", "description": "Phase 2 already wrote ADR-001 Constructor Pattern template; leave it"},
|
||||
{"label": "Skip this phase", "description": "Move to Phase 4 diagrams"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
On `Skip` or `No` → `ADR_N = 0`, continue to Phase 4.
|
||||
|
||||
## 3b — Free-text elicitation (only if "Yes")
|
||||
|
||||
Ask the user, in a single message (no AskUserQuestion), for four lines:
|
||||
|
||||
1. **Title** (≤ 60 chars) — short decision name
|
||||
2. **Context** (1-2 sentences) — what forced the decision
|
||||
3. **Options considered** — comma-separated list (2-4 items)
|
||||
4. **Chosen option + evidence grade [E1-E6]** — one line
|
||||
|
||||
## 3c — Compose the ADR entry
|
||||
|
||||
Renumber: Read `DECISIONS.md`, find the highest existing `ADR-NNN`,
|
||||
assign `NNN+1` (three-digit zero-pad). Append the block using the MADR
|
||||
4.0 shape from `_blocks/docs-decisions-adr.md`. Never rewrite existing
|
||||
ADR entries. Never drop below the highest existing number.
|
||||
|
||||
Set `ADR_N = 1`.
|
||||
|
||||
## 3d — Show the user the appended block
|
||||
|
||||
Print the new entry inline so they can confirm correctness. No
|
||||
AskUserQuestion here — they can ask to amend in the next turn. Append-
|
||||
only invariant stands: amendments become ADR-MMM that supersedes.
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- `DECISIONS.md` exists at `<DIR>/DECISIONS.md`.
|
||||
- The new ADR number is strictly greater than all prior numbers.
|
||||
- Evidence grade is one of E1-E6; if missing, re-ask before writing.
|
||||
- No existing ADR entry was modified.
|
||||
66
skills/docs-scaffold/phase-4-diagrams.md
Normal file
66
skills/docs-scaffold/phase-4-diagrams.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Phase 4 — Mermaid architecture starter
|
||||
|
||||
Goal: seed `<DIR>/docs/diagrams/` with a minimal Mermaid file the user
|
||||
can evolve. Keep it small; the block `_blocks/docs-architecture-diagrams.md`
|
||||
carries the full pattern catalogue.
|
||||
|
||||
## 4a — Pick starter pattern (AskUserQuestion #4)
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Seed which Mermaid pattern?",
|
||||
"header": "Diagram",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "System context (flowchart LR)", "description": "One-page overview — User / API / Service / DB / Queue. Good default."},
|
||||
{"label": "Sequence (sequenceDiagram)", "description": "Request flow — Client / API / DB. Pick for API-first projects."},
|
||||
{"label": "State machine (stateDiagram-v2)","description": "FSM-driven projects — Pending / Running / Done / Failed."},
|
||||
{"label": "ER (erDiagram)", "description": "DB schema summary — two related entities."},
|
||||
{"label": "Skip this phase", "description": "No diagram seeded; move to Phase 5"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
On `Skip` → `DIAGRAMS = 0`, continue to Phase 5.
|
||||
|
||||
## 4b — Write the starter file
|
||||
|
||||
Create `<DIR>/docs/diagrams/` and write one `.mmd` file matching the
|
||||
click:
|
||||
|
||||
- `context.mmd` — system context
|
||||
- `request.mmd` — sequence
|
||||
- `lifecycle.mmd` — state machine
|
||||
- `schema.mmd` — ER
|
||||
|
||||
Use the short templates from `_blocks/docs-architecture-diagrams.md` §1-4
|
||||
verbatim. Placeholders (User / API / Service / DB / Queue) are fine —
|
||||
the user evolves them next session.
|
||||
|
||||
If the target file exists → skip and warn; do not overwrite without
|
||||
`--force` (same contract as Phase 2).
|
||||
|
||||
Set `DIAGRAMS = 1`.
|
||||
|
||||
## 4c — Preview hint (no write)
|
||||
|
||||
After writing, print:
|
||||
|
||||
```
|
||||
[docs-scaffold] preview locally:
|
||||
npm install -g @mermaid-js/mermaid-cli # one-time
|
||||
mmdc -i <DIR>/docs/diagrams/<file>.mmd -o /tmp/preview.svg
|
||||
```
|
||||
|
||||
No AskUserQuestion; this is just a hint line.
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- `<DIR>/docs/diagrams/` directory exists after writing.
|
||||
- Exactly one `.mmd` file was created (or zero if `Skip` was chosen).
|
||||
- File is syntactically valid Mermaid (heading matches the picked pattern).
|
||||
- No other files under `<DIR>/` were touched.
|
||||
70
skills/docs-scaffold/phase-5-changelog.md
Normal file
70
skills/docs-scaffold/phase-5-changelog.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Phase 5 — CHANGELOG via `kei-changelog`
|
||||
|
||||
Goal: initialize or refresh `<DIR>/CHANGELOG.md` from the repo's
|
||||
conventional-commit history using the Rust primitive.
|
||||
|
||||
## 5a — Pick invocation mode (AskUserQuestion #5)
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "CHANGELOG action?",
|
||||
"header": "Changelog",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "Initialize — full history as v0.1.0", "description": "First run. Walks from root to HEAD, writes a single v0.1.0 section."},
|
||||
{"label": "Unreleased — since last tag", "description": "Prepends an Unreleased block since the most recent annotated tag."},
|
||||
{"label": "Update — since explicit --from <ref>", "description": "User supplies a git ref in the next message (tag name or SHA)"},
|
||||
{"label": "Skip this phase", "description": "No CHANGELOG changes; final report only"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
On `Skip` → `CHANGELOG_STATUS = skipped`, continue to the final report.
|
||||
|
||||
## 5b — Resolve the binary
|
||||
|
||||
The Rust primitive lives at `_primitives/_rust/kei-changelog/`. Build if
|
||||
not yet built:
|
||||
|
||||
```bash
|
||||
( cd _primitives/_rust/kei-changelog && cargo build --release --offline ) \
|
||||
|| ( cd _primitives/_rust/kei-changelog && cargo build --release )
|
||||
```
|
||||
|
||||
Binary path: `_primitives/_rust/kei-changelog/target/release/kei-changelog`.
|
||||
|
||||
If the build fails (missing `git2` system deps — on Linux needs
|
||||
`libgit2-dev`), fall back to NO DOWNGRADE advice:
|
||||
|
||||
1. Install system dep: `apt install libgit2-dev` / `brew install libgit2`.
|
||||
2. Re-run this phase after install.
|
||||
|
||||
## 5c — Run the binary
|
||||
|
||||
Map the click to CLI flags:
|
||||
|
||||
| Click | Command |
|
||||
|---|---|
|
||||
| Initialize | `kei-changelog --version v0.1.0 --update "$DIR/CHANGELOG.md" --repo "$DIR"` |
|
||||
| Unreleased | `kei-changelog --unreleased --from "$(git -C "$DIR" describe --tags --abbrev=0)" --update "$DIR/CHANGELOG.md" --repo "$DIR"` |
|
||||
| Update | `kei-changelog --from <user_ref> --version <user_version> --update "$DIR/CHANGELOG.md" --repo "$DIR"` |
|
||||
|
||||
If the `Unreleased` variant fails because there are no annotated tags,
|
||||
fall back to `--version v0.1.0` and continue — print a short note.
|
||||
|
||||
## 5d — Verify the result
|
||||
|
||||
Read the first 30 lines of `<DIR>/CHANGELOG.md` and show them inline so
|
||||
the user confirms the output. Set `CHANGELOG_STATUS` to `initialized`,
|
||||
`updated`, or `skipped`.
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- The binary exited with status 0 (or the Skip branch was chosen).
|
||||
- `<DIR>/CHANGELOG.md` exists and starts with `# CHANGELOG`.
|
||||
- New content was prepended, not appended, when the file already existed.
|
||||
- `CHANGELOG_STATUS` is set to one of the three values above.
|
||||
Loading…
Reference in a new issue