diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d0e1a50..183caf8 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -58,3 +58,16 @@ jobs: - name: Stamp templates and verify the module works run: ./scripts/smoke_test_powershell_module.ps1 + + smoke-living-docs: + name: Smoke-test living-docs add-on (pwsh on Linux) + runs-on: ubuntu-latest + defaults: + run: + shell: pwsh + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Stamp the add-on and verify check-docs enforces + run: ./scripts/smoke_test_living_docs.ps1 diff --git a/AGENTS.md b/AGENTS.md index a7ad58d..6dcbbd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ This repo **dogfoods its own standard** (Core + Public tier). | The standard itself (tiers, where-things-live, do/don't) | `plugins/repokit/skills/repo-standard/standard/the-standard.md` | | Commit / changelog / version conventions | `plugins/repokit/skills/repo-standard/standard/commit-conventions.md` | | Pre-commit / pre-PR checklists | `plugins/repokit/skills/repo-standard/standard/pre-commit-checklist.md`, `pre-pr-checklist.md` | +| Doc formatting & the living-docs pattern | `plugins/repokit/skills/repo-standard/standard/doc-style.md`, `living-docs.md` | | What to test per repo type | `plugins/repokit/skills/repo-standard/standard/testing-matrix.md` | | The scaffolding methodology (`/new-repo`) | `plugins/repokit/skills/new-repo/SKILL.md` | | Templates stamped into new repos | `plugins/repokit/skills/new-repo/templates/` | diff --git a/CHANGELOG.md b/CHANGELOG.md index ad4190d..bb19a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A **living-docs add-on** for `/new-repo` (opt-in, orthogonal to type and tier): volatile shared + facts live once in `docs/STATE.json`, `README.md`/`docs/**/*.md` render them via marker-delimited + state blocks, and a cross-platform `scripts/check-docs.ps1` (pwsh 7) enforces consistency — + blocks match the state file, no stale/future `as_of` dates, no superseded-content markers in the + runbook, one table-separator style — locally and in a Core-tier `docs.yml` workflow. Stamps a + current-state-only `docs/RUNBOOK.md` skeleton and adds living-docs ground rules to the scaffolded + `AGENTS.md` (ADR-0006). +- Two new `repo-standard` docs: `standard/living-docs.md` (the pattern, plus an + adopt-in-an-existing-repo recipe) and `standard/doc-style.md` (deterministic doc formatting — + one table style, ISO dates, fixed status words — so many sessions/models write like one author). + The pre-commit / pre-PR checklists gain matching doc-consistency steps. +- A smoke test for the add-on (`scripts/smoke_test_living_docs.ps1`): stamps the templates, + renders the state blocks, and proves the check *fails* on each enforced problem class (drifted + block, stale date, superseded marker, malformed table separator). CI runs it on `ubuntu-latest`. + - A cross-platform smoke test for the `powershell-module` templates (`scripts/smoke_test_powershell_module.ps1`): stamps the core-tier templates with dummy values and verifies the result parses (`Test-ModuleManifest`), imports, lints clean (PSScriptAnalyzer), diff --git a/docs/adr/0006-living-docs-state-file.md b/docs/adr/0006-living-docs-state-file.md new file mode 100644 index 0000000..26c9595 --- /dev/null +++ b/docs/adr/0006-living-docs-state-file.md @@ -0,0 +1,65 @@ +# ADR-0006: Living docs — a state file, rendered blocks, and a deterministic check + +- **Status:** accepted +- **Date:** 2026-07-13 + +## Context + +A RepoKit-scaffolded ops repo (44 commits in) showed what happens to docs that track live +operational state when the only defence is prose rules. Its `AGENTS.md` explicitly mandated +"update the README in the same commit as any state change" — yet the runbook was touched in 31 +commits and the README in 18, with 15 runbook-only commits. The 1,271-line runbook accreted +history instead of state: three policy versions coexisting under "superseded by v2" annotations, +a "checked , due later today" note contradicting a "PASSED" record three days younger, and +the same counts appearing with different values in different places (43,228 vs 43,229; 42 vs 43). +Two table styles alternated through the file, and it was wholesale-restructured three times in a +week. The symptoms were worse under some models than others — a rule that depends on per-session +model judgment doesn't hold. + +RepoKit had nothing to prevent any of this: no runbook concept, no doc-sync rule, no doc +formatting standard, and no doc-consistency step in either checklist. + +Rejected alternatives: + +- **A hand-maintained standalone HTML runbook** (with a design system): the failures are content + problems, not rendering problems — they would exist identically in HTML, but harder to diff, + review, and edit reliably; formatting drift would move from table styles to diverging markup. +- **Generated HTML from markdown/state**: solves nothing the state file doesn't already solve, + and adds a render pipeline to every stamped repo. +- **Prose-only strengthening of the checklists**: that is the mechanism that already failed. +- **`STATE.yml`**: pwsh 7 has no built-in YAML parser; YAML would force a module dependency (or a + hand-rolled parser) into every stamped repo. JSON parses natively in pwsh, Python, node, `jq`. + +## Decision + +1. **Volatile shared facts live once, in `docs/STATE.json`** — statuses, live resources, counts, + sizes, each with an `as_of` date. Values are strings, so rendering is byte-deterministic. +2. **Docs render facts via marker-delimited state blocks** (`` / + ``) in `README.md` and `docs/**/*.md`, rewritten only by + `scripts/check-docs.ps1 -Update` — never by hand. +3. **A deterministic pwsh 7 check script enforces it** (per ADR-0005's cross-platform pwsh rule + for stamped executable content): blocks match the state file, no stale or future `as_of` + (default 14 days, per-fact override, `0` = never stale), no superseded-content markers in the + runbook, one table-separator style. A `docs.yml` workflow runs it in CI. +4. **The runbook is current-state-only**: replaced content is deleted (git keeps history), dated + journal prose goes to `CHANGELOG.md`, and formatting follows the new `doc-style.md` (which + applies to all repos, not just living-docs ones). +5. **Packaged as an opt-in `/new-repo` add-on** (`templates/addons/living-docs/core/**`) — a + third, orthogonal axis beside type and tier, because operational state is independent of both. + Add-ons only add files, so the type-x-tier collision rules are untouched. Deliberate + exception: the add-on ships its CI workflow at the **core** tier even though CI is normally + Public-tier ceremony — deterministic enforcement is the add-on's entire point, especially in + private repos, where the motivating failure happened. + +## Consequences + +- Cross-file drift becomes structurally impossible for facts in the state file: they render from + one source, and CI fails when a block is out of date, a fact goes stale, or superseded prose + survives — independent of which model or session made the change. +- Stamped living-docs repos carry a small tooling footprint (one script, one workflow, one JSON + file) and require pwsh 7 to refresh blocks; the staleness check deliberately fails CI until a + human re-confirms an aged fact — a forcing function, not a bug. +- Facts not lifted into `STATE.json` are still unguarded; the checklists and `living-docs.md` + push toward lifting anything two docs both state. +- `scripts/smoke_test_living_docs.ps1` proves the enforcement in this repo's CI, negative tests + included (drifted block, stale date, superseded marker, malformed separator must each fail). diff --git a/plugins/repokit/skills/new-repo/SKILL.md b/plugins/repokit/skills/new-repo/SKILL.md index ab6bd40..426b842 100644 --- a/plugins/repokit/skills/new-repo/SKILL.md +++ b/plugins/repokit/skills/new-repo/SKILL.md @@ -31,6 +31,10 @@ Gather these from the user's arguments / message, else ask — keep it to load-b - **visibility** — `private` (= Core tier), `public` (= +Public), or `published` (= +Published). - **author** — default `Peter Braun` (`PBNZ`). - **license** — default `Apache-2.0`. +- **living-docs add-on** — yes/no, default **no**. Ask: *"Will this repo's docs track live + operational state (deployed resources, scheduled jobs, long-running migrations)?"* If yes, the + add-on stamps `docs/RUNBOOK.md`, `docs/STATE.json`, `scripts/check-docs.ps1`, and a `docs.yml` + check workflow (see `references/living-docs-rules.md`). - For `powershell-module` only: **ModuleName** (PascalCase, e.g. `MyModule`). If the chosen type is a **stub** (anything other than `powershell-module`, `docker-compose`, or `power-platform-connectors`), tell the user so: the @@ -42,7 +46,9 @@ want to continue. 1. **Resolve the file set** (see `references/file-set-resolution.md`). `active_tiers = [core] + ([public] if visibility in {public, published}) + ([published] if visibility == published)`. The file set is the union, over each active tier `t`, of everything under `templates//` and - everything under `templates/types///`. On a path collision pick one winner: **higher + everything under `templates/types///` — plus, for each chosen add-on, everything + under `templates/addons///` (add-ons only add files; they never collide). On a path + collision pick one winner: **higher tier wins (`published` > `public` > `core`), then within a tier the type overlay wins**. Produce an explicit list of `(template path → target path)` pairs: drop any trailing `.tmpl`, and substitute filename placeholders (e.g. `{{ModuleName}}.psd1.tmpl` → `MyModule.psd1`). @@ -62,6 +68,14 @@ want to continue. into the `{{START_HERE_MAP}}` placeholder in the stamped `AGENTS.md`. Add a one-line pointer in the README. + **Resolve `{{LIVING_DOCS_RULES}}`** in the stamped `AGENTS.md`: with the living-docs add-on + on, substitute the verbatim rules block from `references/living-docs-rules.md` and append that + file's `## Status` snippet to the stamped `README.md`; with the add-on off, delete the + placeholder line entirely. When the add-on is on, finish by running + `pwsh scripts/check-docs.ps1 -Update` then `pwsh scripts/check-docs.ps1` inside the new repo + (both must succeed); if pwsh 7 is missing on this host, say so in the summary — running it is + the user's first task. + **Self-check (gate — both must pass before you continue).** See `references/placeholders.md`: (a) no enumerated placeholder tokens remain anywhere in the output; (b) every expected target file exists and no `.tmpl` suffix survived. If either fails, fix and re-check. diff --git a/plugins/repokit/skills/new-repo/references/file-set-resolution.md b/plugins/repokit/skills/new-repo/references/file-set-resolution.md index afdc4cf..3724299 100644 --- a/plugins/repokit/skills/new-repo/references/file-set-resolution.md +++ b/plugins/repokit/skills/new-repo/references/file-set-resolution.md @@ -18,6 +18,23 @@ So visibility decides **how many tiers** turn on; the type contributes an overla active tier**. A `private` repo gets only `core` (base + type-core). A `published` repo gets all three base tiers plus all three type-overlay tiers. +## Add-ons — orthogonal to type and tier + +An **add-on** is an opt-in overlay chosen in the interview (currently only `living-docs`). For +each chosen add-on: + +``` +file_set ∪= templates/addons///** for each active tier t +``` + +Add-ons **only add files** — an add-on template must never produce a target path that a base or +type template also produces, so add-ons need no precedence rule. (If you ever author one that +collides, that's a bug in the add-on, not a resolution question.) + +Deliberate exception to "CI ships at the Public tier": the `living-docs` add-on ships its +`docs.yml` check workflow at **core**, because deterministic doc-consistency enforcement is the +add-on's entire point — including, especially, in a private repo (see ADR-0006). + ## Precedence — higher tier wins, then type overlay wins If the same target path is produced by more than one template, pick a single winner (you never diff --git a/plugins/repokit/skills/new-repo/references/living-docs-rules.md b/plugins/repokit/skills/new-repo/references/living-docs-rules.md new file mode 100644 index 0000000..5318b20 --- /dev/null +++ b/plugins/repokit/skills/new-repo/references/living-docs-rules.md @@ -0,0 +1,40 @@ +# living-docs add-on — AGENTS.md rules block and README snippet + +Used by `/new-repo` when the **living-docs add-on** is selected. Two verbatim snippets: + +1. Substitute the rules block below into the `{{LIVING_DOCS_RULES}}` placeholder in the stamped + `AGENTS.md`. When the add-on is **not** selected, delete the placeholder line entirely. +2. Append the `## Status` snippet to the stamped `README.md` (before any final "see AGENTS.md" + pointer), so the README and the runbook dashboard render from the same facts. + +The same wording is what an existing repo adds when adopting the pattern by hand — see the +`repo-standard` skill's `standard/living-docs.md`, "Adopting in an existing repo". + +## The `{{LIVING_DOCS_RULES}}` block + +```markdown +- **Docs move together (living docs).** `docs/STATE.json` is the single source for volatile + shared facts (statuses, live resources, counts, as-of dates). A commit that changes anything a + doc states updates `docs/STATE.json` in the same commit; run + `pwsh scripts/check-docs.ps1 -Update` and include the re-rendered blocks. CI (`docs.yml`) + fails otherwise. +- **`docs/RUNBOOK.md` is current-state-only.** Replace outdated text instead of annotating it — + git keeps the history. Dated journal entries go to `CHANGELOG.md`, never the runbook. See the + `repo-standard` skill: `standard/living-docs.md` and `standard/doc-style.md`. +``` + +## The README `## Status` snippet + +````markdown +## Status + + + +```` + +## After stamping + +Run `pwsh scripts/check-docs.ps1 -Update` inside the new repo to render the empty state blocks, +then the plain check — it must pass before the scaffold commit. If pwsh 7 is not available on the +scaffolding host, leave the blocks empty and tell the user in the summary that running it is +their first task. diff --git a/plugins/repokit/skills/new-repo/references/placeholders.md b/plugins/repokit/skills/new-repo/references/placeholders.md index 435fd82..42f9d9f 100644 --- a/plugins/repokit/skills/new-repo/references/placeholders.md +++ b/plugins/repokit/skills/new-repo/references/placeholders.md @@ -19,8 +19,10 @@ | Token | How to compute | |-------|----------------| | `{{year}}` | the current year | +| `{{today}}` | the current date, `YYYY-MM-DD` | | `{{Guid}}` | a **fresh** GUID per repo — use whatever the host offers: `uuidgen` (Linux/macOS), `[guid]::NewGuid()` (pwsh), or `python3 -c "import uuid; print(uuid.uuid4())"`. Never reuse a literal GUID; a hardcoded one would collide across every scaffolded module. | | `{{START_HERE_MAP}}` | the where-things-live table you build in step 4 from the resolved file set | +| `{{LIVING_DOCS_RULES}}` | living-docs add-on **on**: the verbatim rules block from `references/living-docs-rules.md`; add-on **off**: delete the placeholder line entirely | ## Post-scaffold self-check @@ -33,7 +35,7 @@ Do **not** grep for a bare `{{` — GitHub Actions expressions like `${{ secrets workflow templates are legitimate and must not be flagged. Grep only for the known tokens: ``` -rg -n '\{\{(name|description|author|year|license|type|tier|ModuleName|Guid|START_HERE_MAP)\}\}' +rg -n '\{\{(name|description|author|year|today|license|type|tier|ModuleName|Guid|START_HERE_MAP|LIVING_DOCS_RULES)\}\}' ``` Any hit is a failure — report the file and line, fix the substitution, and re-run. diff --git a/plugins/repokit/skills/new-repo/templates/addons/living-docs/core/.github/workflows/docs.yml b/plugins/repokit/skills/new-repo/templates/addons/living-docs/core/.github/workflows/docs.yml new file mode 100644 index 0000000..f789fa7 --- /dev/null +++ b/plugins/repokit/skills/new-repo/templates/addons/living-docs/core/.github/workflows/docs.yml @@ -0,0 +1,22 @@ +name: Docs + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + check-docs: + name: Living-docs consistency check + runs-on: ubuntu-latest + defaults: + run: + shell: pwsh + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Run check-docs + run: ./scripts/check-docs.ps1 diff --git a/plugins/repokit/skills/new-repo/templates/addons/living-docs/core/docs/RUNBOOK.md.tmpl b/plugins/repokit/skills/new-repo/templates/addons/living-docs/core/docs/RUNBOOK.md.tmpl new file mode 100644 index 0000000..956b6fb --- /dev/null +++ b/plugins/repokit/skills/new-repo/templates/addons/living-docs/core/docs/RUNBOOK.md.tmpl @@ -0,0 +1,25 @@ +# Runbook — {{name}} + +> **Rules for this file** (living docs — see the `repo-standard` skill's `standard/living-docs.md`): +> current state only — when something changes, replace the old text; git keeps the history. +> Volatile facts come from `docs/STATE.json` via state blocks — edit the JSON, then run +> `pwsh scripts/check-docs.ps1 -Update`; never edit between the markers by hand. Dated journal +> entries go to `CHANGELOG.md`, not here. Formatting per `standard/doc-style.md`: one table +> style, `YYYY-MM-DD` dates, fixed status words (`PLANNED / IN PROGRESS / BLOCKED / DONE`, +> `OK / DEGRADED / DOWN`). + +## Dashboard + + + + +## Example area — current state + + + +## Reference + + diff --git a/plugins/repokit/skills/new-repo/templates/addons/living-docs/core/docs/STATE.json.tmpl b/plugins/repokit/skills/new-repo/templates/addons/living-docs/core/docs/STATE.json.tmpl new file mode 100644 index 0000000..b7cd408 --- /dev/null +++ b/plugins/repokit/skills/new-repo/templates/addons/living-docs/core/docs/STATE.json.tmpl @@ -0,0 +1,16 @@ +{ + "stale_after_days": 14, + "facts": { + "overall_status": { + "value": "PLANNED", + "as_of": "{{today}}", + "note": "Overall project status" + }, + "live_resources": { + "value": "none yet", + "as_of": "{{today}}", + "note": "Deployed resources", + "stale_after_days": 0 + } + } +} diff --git a/plugins/repokit/skills/new-repo/templates/addons/living-docs/core/scripts/check-docs.ps1 b/plugins/repokit/skills/new-repo/templates/addons/living-docs/core/scripts/check-docs.ps1 new file mode 100644 index 0000000..847b075 --- /dev/null +++ b/plugins/repokit/skills/new-repo/templates/addons/living-docs/core/scripts/check-docs.ps1 @@ -0,0 +1,229 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 +# check-docs.ps1 - living-docs consistency check (RepoKit living-docs add-on). +# +# Default mode: verify, exit 1 on any problem. -Update: rewrite every state block in +# README.md + docs/**/*.md from docs/STATE.json, then verify the rest. +# +# Checks: +# 1. Every state block matches what docs/STATE.json renders, and references known keys. +# 2. No fact's as_of is in the future or older than its stale_after_days (0 = never stale). +# 3. docs/RUNBOOK.md carries no superseded-content markers - old content must be deleted. +# 4. Every markdown table separator row starts and ends with a pipe (doc-style rule). +# +# Only check 1 is auto-fixable (-Update); the others always need a human edit. + +[CmdletBinding()] +param( + [switch]$Update, + [string]$Root +) + +$ErrorActionPreference = 'Stop' + +if (-not $Root) { $Root = Split-Path -Parent $PSScriptRoot } +$Root = (Resolve-Path -Path $Root).Path + +$problems = [System.Collections.Generic.List[string]]::new() + +# --- Load STATE.json --------------------------------------------------------- + +$statePath = Join-Path $Root 'docs/STATE.json' +if (-not (Test-Path $statePath)) { + Write-Host "check-docs: ERROR - docs/STATE.json not found under $Root" + exit 1 +} +try { + $state = Get-Content -Path $statePath -Raw | ConvertFrom-Json -AsHashtable +} catch { + Write-Host "check-docs: ERROR - docs/STATE.json is not valid JSON: $($_.Exception.Message)" + exit 1 +} +$facts = $state['facts'] +if ($null -eq $facts -or $facts.Count -eq 0) { + Write-Host "check-docs: ERROR - docs/STATE.json has no 'facts' object." + exit 1 +} + +# --- Rendering --------------------------------------------------------------- + +function Get-FactLabel([string]$Key, $Fact) { + if ($Fact['note']) { return ([string]$Fact['note']).Replace('|', '\|') } + return $Key +} + +function Format-TableBlock([string[]]$Keys) { + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('| Fact | Value | As of |') + $lines.Add('|---|---|---|') + foreach ($k in $Keys) { + $f = $facts[$k] + $value = ([string]$f['value']).Replace('|', '\|') + $lines.Add("| $(Get-FactLabel $k $f) | $value | $($f['as_of']) |") + } + return $lines.ToArray() +} + +function Format-InlineBlock([string]$Key) { + $f = $facts[$Key] + return @("$([string]$f['value']) (as of $($f['as_of']))") +} + +# --- Check 2: fact validity + staleness --------------------------------------- + +$today = (Get-Date).Date +$defaultStale = 14 +if ($null -ne $state['stale_after_days']) { $defaultStale = [int]$state['stale_after_days'] } + +foreach ($key in @($facts.Keys)) { + $fact = $facts[$key] + if ($null -eq $fact['value'] -or -not $fact['as_of']) { + $problems.Add("STATE.json: fact '$key' must have 'value' and 'as_of'.") + continue + } + $asOf = [datetime]::MinValue + $ok = [datetime]::TryParseExact([string]$fact['as_of'], 'yyyy-MM-dd', + [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$asOf) + if (-not $ok) { + $problems.Add("STATE.json: fact '$key' has invalid as_of '$($fact['as_of'])' - use YYYY-MM-DD.") + continue + } + if ($asOf.Date -gt $today) { + $problems.Add("STATE.json: fact '$key' has a future as_of ($($fact['as_of'])).") + continue + } + $limit = $defaultStale + if ($null -ne $fact['stale_after_days']) { $limit = [int]$fact['stale_after_days'] } + if ($limit -gt 0) { + $age = ($today - $asOf.Date).Days + if ($age -gt $limit) { + $problems.Add("STATE.json: fact '$key' is stale - as_of $($fact['as_of']) is $age days old (limit $limit). Re-confirm the value and update as_of.") + } + } +} + +# --- Checks 1 + 4: state blocks and table separators over the managed files --- + +$docFiles = [System.Collections.Generic.List[System.IO.FileInfo]]::new() +$readmePath = Join-Path $Root 'README.md' +if (Test-Path $readmePath) { $docFiles.Add((Get-Item $readmePath)) } +$docsDir = Join-Path $Root 'docs' +if (Test-Path $docsDir) { + foreach ($f in (Get-ChildItem -Path $docsDir -Recurse -Filter '*.md' -File)) { $docFiles.Add($f) } +} + +foreach ($file in $docFiles) { + $rel = [System.IO.Path]::GetRelativePath($Root, $file.FullName).Replace('\', '/') + $raw = Get-Content -Path $file.FullName -Raw + if ($null -eq $raw) { $raw = '' } + $eol = if ($raw.Contains("`r`n")) { "`r`n" } else { "`n" } + $hadTrailingNewline = $raw.EndsWith("`n") + $lines = @($raw -split "`r?`n") + if ($hadTrailingNewline -and $lines.Count -gt 0 -and $lines[-1] -eq '') { + $lines = $lines[0..($lines.Count - 2)] + } + + $out = [System.Collections.Generic.List[string]]::new() + $fileChanged = $false + $i = 0 + while ($i -lt $lines.Count) { + $line = $lines[$i] + + # Check 4: table separator rows must start and end with a pipe. + $trimmed = $line.Trim() + if ($trimmed -match '^[|:\- ]+$' -and $trimmed.Contains('-') -and $trimmed.Contains('|')) { + if (-not ($trimmed.StartsWith('|') -and $trimmed.EndsWith('|'))) { + $problems.Add("${rel}:$($i + 1): table separator row must start and end with '|' (doc-style).") + } + } + + $m = [regex]::Match($line, '^\s*$') + if (-not $m.Success) { + $out.Add($line) + $i++ + continue + } + + # Find the matching end marker. + $end = -1 + for ($j = $i + 1; $j -lt $lines.Count; $j++) { + if ($lines[$j] -match '^\s*$') { $end = $j; break } + if ($lines[$j] -match '^`, markers each on their own line: + +**Table block** — renders the listed facts as a table: + +```markdown + +| Fact | Value | As of | +|---|---|---| +| Overall project status | PLANNED | 2026-07-13 | +| Deployed resources | none yet | 2026-07-13 | + +``` + +**Inline block** — renders one fact as `value (as of date)` on a single line: + +```markdown + +PLANNED (as of 2026-07-13) + +``` + +The table's *Fact* column shows the fact's `note` (falling back to the key). Never edit the +content between markers by hand — edit `STATE.json` and run the script with `-Update`. + +## `scripts/check-docs.ps1` + +Requires pwsh 7. Scans `README.md` and `docs/**/*.md`; no configuration. + +- `pwsh scripts/check-docs.ps1` — **check mode** (CI runs this). Exit 1 if any of: + 1. a state block's content doesn't match what `STATE.json` renders, or references an unknown key; + 2. a fact's `as_of` is in the future, or older than its effective `stale_after_days`; + 3. `docs/RUNBOOK.md` contains a superseded-content marker (`superseded`, `obsolete`, + `no longer current`) — superseded content must be deleted, not annotated; + 4. a markdown table separator row doesn't start and end with `|` (the doc-style table rule). +- `pwsh scripts/check-docs.ps1 -Update` — rewrites every state block from `STATE.json`, then + runs the same checks (staleness etc. still fail; only block content is auto-fixed). + +## Runbook rules (`docs/RUNBOOK.md`) + +- **Current state only.** The runbook answers "how does it work *now* and how do I operate it" — + never "how did we get here". +- **Delete superseded content** — git keeps history. No "superseded by v2" sections, no + before/after duplicates. +- **Dated journal entries go to `CHANGELOG.md`** (or stay in commit messages), never the runbook. + At most one as-of date per section — and prefer a state block over a hand-written date. +- **Structure changes are their own commit** (see [`doc-style.md`](doc-style.md)). +- Follow [`doc-style.md`](doc-style.md) throughout — one table style, fixed status words, + ISO dates. + +## Docs move together + +If a commit changes anything a doc states — status, resources, counts, dates — update +`docs/STATE.json` **in the same commit**, run `pwsh scripts/check-docs.ps1 -Update`, and commit +the re-rendered blocks with it. The pre-commit checklist has this as a step; CI enforces it. + +## Adopting in an existing repo + +1. Copy in the three add-on files from RepoKit + (`plugins/repokit/skills/new-repo/templates/addons/living-docs/core/`): + `scripts/check-docs.ps1`, `.github/workflows/docs.yml`, and a seed `docs/STATE.json` + (drop the `.tmpl` suffix and fill the placeholders). +2. **Inventory the volatile facts** across README and every doc: statuses, live resources, + counts, sizes, dates. Where two docs disagree, determine the current true value (say from + which source) and record it once in `STATE.json` with today's `as_of`. +3. **Rewrite the runbook to current-state-only**: delete superseded versions and before/after + duplicates, move dated journal prose to `CHANGELOG.md` or delete it, normalise tables to + [`doc-style.md`](doc-style.md), and replace inline volatile facts with state blocks. +4. Add a state block to the README's status section; add the living-docs ground rules to + `AGENTS.md` (see the add-on's `references/living-docs-rules.md` for the canonical wording). +5. Run `pwsh scripts/check-docs.ps1 -Update`, then the plain check — it must pass. +6. Keep the restructure commit separate from fact-correction commits. diff --git a/plugins/repokit/skills/repo-standard/standard/pre-commit-checklist.md b/plugins/repokit/skills/repo-standard/standard/pre-commit-checklist.md index 37b33ad..452417e 100644 --- a/plugins/repokit/skills/repo-standard/standard/pre-commit-checklist.md +++ b/plugins/repokit/skills/repo-standard/standard/pre-commit-checklist.md @@ -7,6 +7,10 @@ Run through this before every commit. - [ ] **Every changed line traces to the task.** No drive-by edits; surgical changes only. - [ ] **No secrets, tokens, or private contact info** (no email addresses) in the diff. - [ ] **`CHANGELOG.md` updated** under `## [Unreleased]` if the change is user-visible. +- [ ] **Docs move together** *(living-docs repos)* — if the change alters anything a doc states + (status, resources, counts, dates), update `docs/STATE.json` in the same commit, run + `pwsh scripts/check-docs.ps1 -Update`, and include the re-rendered blocks; the plain check must + pass (see `living-docs.md`). - [ ] **Conventional Commit message** (see `commit-conventions.md`), one concern per commit. > Enforcement note: Claude Code hooks only catch commands run *in-session*; a native `git commit` diff --git a/plugins/repokit/skills/repo-standard/standard/pre-pr-checklist.md b/plugins/repokit/skills/repo-standard/standard/pre-pr-checklist.md index 657a8ee..1aed012 100644 --- a/plugins/repokit/skills/repo-standard/standard/pre-pr-checklist.md +++ b/plugins/repokit/skills/repo-standard/standard/pre-pr-checklist.md @@ -8,6 +8,9 @@ Run through this before opening a pull request. - [ ] **The PR template is filled in** (what & why + the checklist). - [ ] **One concern per PR** — split unrelated changes. - [ ] **`CHANGELOG.md`** has the user-visible changes under `## [Unreleased]`. +- [ ] **Doc consistency** *(living-docs repos)* — `pwsh scripts/check-docs.ps1` passes, and the + runbook is current-state-only: no superseded sections, no dated journal prose (see + `living-docs.md`). ## After a push or PR — check the remote diff --git a/plugins/repokit/skills/repo-standard/standard/testing-matrix.md b/plugins/repokit/skills/repo-standard/standard/testing-matrix.md index 50c655e..ab85926 100644 --- a/plugins/repokit/skills/repo-standard/standard/testing-matrix.md +++ b/plugins/repokit/skills/repo-standard/standard/testing-matrix.md @@ -16,3 +16,8 @@ minimum, whatever proves the code runs.) When you fill in a stub type overlay, add its row here and ship a CI workflow in the type's `public/` overlay. + +Regardless of type: a repo with the **living-docs add-on** additionally runs +`pwsh scripts/check-docs.ps1` locally (pre-commit) and in CI via its `docs.yml` workflow — this +ships at the Core tier, because doc-consistency enforcement is the add-on's whole point even in a +private repo (see `living-docs.md`). diff --git a/plugins/repokit/skills/repo-standard/standard/the-standard.md b/plugins/repokit/skills/repo-standard/standard/the-standard.md index 1736b8d..7cd6e9b 100644 --- a/plugins/repokit/skills/repo-standard/standard/the-standard.md +++ b/plugins/repokit/skills/repo-standard/standard/the-standard.md @@ -81,6 +81,15 @@ pre-create empty directories. When the docs grow substantial, a proven split is `docs/explanation/`, plus a `docs/README.md` index and `docs/assets/` for images. Adopt it when you have docs to organise, not before. +### Living docs (opt-in add-on) + +If the repo's docs track **live operational state** (deployed resources, running jobs, milestone +status), adopt the **living-docs add-on**: volatile shared facts live once in `docs/STATE.json`, +README/runbook render them via marker blocks, and `scripts/check-docs.ps1` (+ a `docs.yml` +workflow) enforces consistency deterministically. `/new-repo` offers it at scaffold time; see +[`living-docs.md`](living-docs.md) for the pattern and the adopt-in-an-existing-repo recipe, and +[`doc-style.md`](doc-style.md) for the formatting rules every repo's docs should follow. + ## Naming conventions RepoKit does **not** impose a single uniform casing. Each name follows the convention of the @@ -124,3 +133,5 @@ a script from a private collection into a public collection repo is a **copy, no - **Do** keep private repos at Core. **Don't** force public/published governance onto them. - **Do** record notable decisions as ADRs. **Don't** rely on commit messages alone for rationale. - **Do** keep `SKILL.md` files and templates as plain prose — no secrets, no email addresses. +- **Do** delete superseded doc content — git keeps the history. **Don't** keep "superseded by v2" + annotations or before/after duplicates in living docs (see `living-docs.md`). diff --git a/plugins/repokit/skills/repo-standard/standard/where-things-go.md b/plugins/repokit/skills/repo-standard/standard/where-things-go.md index 7dd0733..4cab2c9 100644 --- a/plugins/repokit/skills/repo-standard/standard/where-things-go.md +++ b/plugins/repokit/skills/repo-standard/standard/where-things-go.md @@ -30,6 +30,9 @@ or when there's no folder for it yet and you need to know where, and how, to cre | Application / library source code | the language idiom — `src/` (TS/JS, Python package), the module root for a PowerShell module | per language | language idiom | | Tool configuration | the exact path the tool requires (`.editorconfig`, `.gitignore`, `release-please-config.json`, `pyproject.toml`, …) | at that exact path | the tool's docs | | A user-visible change to record | `CHANGELOG.md` under `## [Unreleased]` | — | Keep a Changelog | +| An operational runbook (how the system runs *now*, how to operate it) | `docs/RUNBOOK.md`, current-state-only | adopt the living-docs add-on (`living-docs.md`) | RepoKit living-docs | +| A volatile shared fact (status, live resource, count, size, as-of date) | `docs/STATE.json`, rendered into docs via state blocks | adopt the living-docs add-on (`living-docs.md`) | RepoKit living-docs | +| A dated operational journal entry ("checked X on date") | `CHANGELOG.md` (or the commit message) — never the runbook | — | RepoKit living-docs | | Secrets / API keys / credentials | **Never in the repo.** Use a gated GitHub Environment secret, or a secrets manager. | n/a | security | | (RepoKit dev) a new repo-type's scaffolding | `templates/types//{core,public,published}/` | create the type overlay | the RepoKit standard | diff --git a/scripts/smoke_test_living_docs.ps1 b/scripts/smoke_test_living_docs.ps1 new file mode 100644 index 0000000..d7b3e60 --- /dev/null +++ b/scripts/smoke_test_living_docs.ps1 @@ -0,0 +1,125 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 +# Smoke-test for the living-docs add-on templates. Stamps the add-on core-tier +# templates with dummy values into a temp directory, renders the state blocks via +# check-docs.ps1 -Update, and proves the check passes when the docs are consistent +# and FAILS for every enforced problem class: empty/drifted state block, stale +# as_of date, superseded-content marker, malformed table separator. The negative +# tests are the point - they prove the enforcement actually enforces. +# Runs on any pwsh 7 platform; CI runs it on ubuntu-latest. + +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent $PSScriptRoot +$templateRoot = Join-Path $repoRoot 'plugins/repokit/skills/new-repo/templates/addons/living-docs/core' +$stageDir = Join-Path ([IO.Path]::GetTempPath()) 'repokit-living-docs-smoke' + +if (-not (Test-Path $templateRoot)) { throw "Template root not found: $templateRoot" } +if (Test-Path $stageDir) { Remove-Item $stageDir -Recurse -Force } +New-Item -ItemType Directory -Path $stageDir -Force | Out-Null + +# Dummy values for every placeholder the templates can contain +# (see skills/new-repo/references/placeholders.md). +$values = @{ + name = 'repokit-living-docs-smoke' + description = 'RepoKit living-docs add-on smoke test.' + author = 'RepoKit CI' + license = 'Apache-2.0' + type = 'script-collection' + tier = 'Core' + year = (Get-Date).Year + today = (Get-Date).ToString('yyyy-MM-dd') +} + +Write-Host "Stamping add-on templates from $templateRoot" +Get-ChildItem -Path $templateRoot -Recurse -File | ForEach-Object { + $rel = [IO.Path]::GetRelativePath($templateRoot, $_.FullName) -replace '\.tmpl$', '' + foreach ($k in $values.Keys) { $rel = $rel.Replace("{{$k}}", [string]$values[$k]) } + $target = Join-Path $stageDir $rel + New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force | Out-Null + $content = Get-Content -Path $_.FullName -Raw + if ($null -eq $content) { $content = '' } + foreach ($k in $values.Keys) { $content = $content.Replace("{{$k}}", [string]$values[$k]) } + Set-Content -Path $target -Value $content -NoNewline + Write-Host " $rel" +} + +# The scaffolder appends the README Status snippet (the add-on itself ships no README); +# recreate that here so the block-rendering path is exercised in two files. +$readmeLines = @( + "# $($values.name)" + '' + '## Status' + '' + '' + '' +) +Set-Content -Path (Join-Path $stageDir 'README.md') -Value ($readmeLines -join "`n") + +Write-Host "`nCheck 1: no leftover placeholder tokens" +$tokenPattern = '\{\{(name|description|author|year|today|license|type|tier|ModuleName|Guid|START_HERE_MAP|LIVING_DOCS_RULES)\}\}' +$leftovers = Get-ChildItem -Path $stageDir -Recurse -File | Select-String -Pattern $tokenPattern +if ($leftovers) { + $leftovers | ForEach-Object { Write-Host " LEFTOVER: $_" } + throw 'Placeholder tokens survived stamping.' +} + +$checkScript = Join-Path $stageDir 'scripts/check-docs.ps1' + +function Invoke-CheckDocs { + param([switch]$Update) + $pwshArgs = @('-NoProfile', '-File', $checkScript) + if ($Update) { $pwshArgs += '-Update' } + & pwsh @pwshArgs 2>&1 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +function Assert-ExitCode { + param([int]$Expected, [string]$Label, [switch]$Update) + Write-Host " $Label (expect exit $Expected)" + $code = Invoke-CheckDocs -Update:$Update + if ($code -ne $Expected) { throw "$Label - expected exit $Expected, got $code." } +} + +Write-Host "`nCheck 2: empty state blocks are detected, -Update renders them, then the check passes" +Assert-ExitCode -Expected 1 -Label 'fresh stamp, blocks still empty' +Assert-ExitCode -Expected 0 -Label 'render blocks with -Update' -Update +Assert-ExitCode -Expected 0 -Label 'plain check after render' + +$runbookPath = Join-Path $stageDir 'docs/RUNBOOK.md' +$statePath = Join-Path $stageDir 'docs/STATE.json' +$readmePath = Join-Path $stageDir 'README.md' +if ((Get-Content $readmePath -Raw) -notmatch [regex]::Escape('| Overall project status | PLANNED |')) { + throw 'Rendered README Status block does not contain the expected table row.' +} + +Write-Host "`nCheck 3: a STATE.json change without re-rendering fails the check" +$state = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable +$state['facts']['overall_status']['value'] = 'IN PROGRESS' +$state | ConvertTo-Json -Depth 5 | Set-Content -Path $statePath +Assert-ExitCode -Expected 1 -Label 'fact changed, blocks not re-rendered' +Assert-ExitCode -Expected 0 -Label 're-render fixes it' -Update + +Write-Host "`nCheck 4: a stale as_of fails even with -Update (staleness is not auto-fixable)" +$state = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable +$state['facts']['overall_status']['as_of'] = (Get-Date).AddDays(-30).ToString('yyyy-MM-dd') +$state | ConvertTo-Json -Depth 5 | Set-Content -Path $statePath +Assert-ExitCode -Expected 1 -Label 'as_of 30 days old (limit 14)' -Update +$state['facts']['overall_status']['as_of'] = $values.today +$state | ConvertTo-Json -Depth 5 | Set-Content -Path $statePath +Assert-ExitCode -Expected 0 -Label 'as_of restored' -Update + +Write-Host "`nCheck 5: a superseded-content marker in the runbook fails the check" +$runbookRaw = Get-Content $runbookPath -Raw +Add-Content -Path $runbookPath -Value "`nThis section is superseded by policy v2." +Assert-ExitCode -Expected 1 -Label 'runbook contains a superseded marker' +Set-Content -Path $runbookPath -Value $runbookRaw -NoNewline +Assert-ExitCode -Expected 0 -Label 'marker removed' + +Write-Host "`nCheck 6: a malformed table separator row fails the check" +Add-Content -Path $runbookPath -Value "`nCol A | Col B`n--- | ---`nx | y" +Assert-ExitCode -Expected 1 -Label 'separator row without leading/trailing pipes' +Set-Content -Path $runbookPath -Value $runbookRaw -NoNewline +Assert-ExitCode -Expected 0 -Label 'final state is clean' + +Write-Host "`nSmoke test passed: living-docs templates stamp, render, and enforce on $($PSVersionTable.Platform ?? 'Windows') / pwsh $($PSVersionTable.PSVersion)." diff --git a/scripts/smoke_test_powershell_module.ps1 b/scripts/smoke_test_powershell_module.ps1 index 1c0a07f..2a726a9 100644 --- a/scripts/smoke_test_powershell_module.ps1 +++ b/scripts/smoke_test_powershell_module.ps1 @@ -45,7 +45,7 @@ Get-ChildItem -Path $templateRoot -Recurse -File | ForEach-Object { } Write-Host "`nCheck 1: no leftover placeholder tokens" -$tokenPattern = '\{\{(name|description|author|year|license|type|tier|ModuleName|Guid|START_HERE_MAP)\}\}' +$tokenPattern = '\{\{(name|description|author|year|today|license|type|tier|ModuleName|Guid|START_HERE_MAP|LIVING_DOCS_RULES)\}\}' $leftovers = Get-ChildItem -Path $stageDir -Recurse -File | Select-String -Pattern $tokenPattern if ($leftovers) { $leftovers | ForEach-Object { Write-Host " LEFTOVER: $_" }