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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .config/markdownlint.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
"MD033": false,
// First line need not be a top-level heading.
"MD041": false,
// Duplicate headings are fine under DIFFERENT parents (e.g. a "Reach Green"
// step repeated per worked example); only same-level siblings must differ.
"MD024": { "siblings_only": true },

// Emphasis / strong markers: asterisk (deliberate preference; the table
// formatter never touches emphasis, so there is no underscore conflict).
Expand Down
26 changes: 26 additions & 0 deletions .config/scripts/Initialize-DevEnvironment.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Describe 'Invoke-Initialize' -Tag 'Fast' {
Mock Invoke-DevModuleInstall { }
Mock Invoke-PreCommitInstall { }
Mock Write-Information { }
Mock Test-SymlinkCapability { $true }
Mock git { 'true' } # core.symlinks
}

It 'returns 1 when git is missing' {
Expand Down Expand Up @@ -44,4 +46,28 @@ Describe 'Invoke-Initialize' -Tag 'Fast' {
Invoke-Initialize | Out-Null
Should -Invoke Invoke-DevModuleInstall -Times 0
}

It 'warns when the machine cannot create symlinks (Developer Mode off)' {
Mock Test-SymlinkCapability { $false }
Invoke-Initialize | Out-Null
Should -Invoke Write-Warning -ParameterFilter { $Message -match 'Developer Mode' }
}

It 'warns when git core.symlinks is explicitly false' {
Mock git { 'false' }
Invoke-Initialize | Out-Null
Should -Invoke Write-Warning -ParameterFilter { $Message -match 'core\.symlinks' }
}

It 'stays quiet about symlinks when the machine and git are capable' {
Invoke-Initialize | Out-Null
Should -Invoke Write-Warning -ParameterFilter { $Message -match 'symlink' } -Exactly -Times 0
}
}

Describe 'Test-SymlinkCapability' -Tag 'Fast' {
It 'probes an actual symlink creation and reports a boolean, never throws' {
# Machine-dependent result (Developer Mode / OS); the contract is the type.
Test-SymlinkCapability | Should -BeOfType [bool]
}
}
23 changes: 23 additions & 0 deletions .config/scripts/Initialize-DevEnvironment.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ function Test-Elevated {
([Security.Principal.WindowsPrincipal]$id).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
}

function Test-SymlinkCapability {
<# Probe an actual symlink creation, not a settings query — Developer Mode /
SeCreateSymbolicLink can only be proven by trying. #>
$target = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName())
$link = "$target.link"
try {
New-Item -ItemType File -Path $target -ErrorAction Stop | Out-Null
New-Item -ItemType SymbolicLink -Path $link -Target $target -ErrorAction Stop | Out-Null
$true
}
catch { $false }
finally { Remove-Item $link, $target -Force -ErrorAction SilentlyContinue }
}

function Invoke-DevModuleInstall {
param([string]$Name, [string]$MinimumVersion)
Install-Module -Name $Name -MinimumVersion $MinimumVersion -Scope CurrentUser -Force -Repository PSGallery
Expand Down Expand Up @@ -76,6 +90,15 @@ function Invoke-Initialize {
Write-Warning 'This shell is ELEVATED (admin). pre-commit will fail to fetch hooks. Close it and run VS Code / your terminal WITHOUT admin rights.'
}

# Symlinked files (see AGENTS.md "Reuse") silently check out as plain text
# when the machine or git can't do symlinks — warn, don't fail.
if (-not (Test-SymlinkCapability)) {
Write-Warning 'Symlinks cannot be created here. On Windows, enable Developer Mode (Settings -> For developers) — do NOT use an elevated shell. Symlinked repo files will check out as plain text until then.'
}
elseif ((git config core.symlinks) -eq 'false') {
Write-Warning 'git config core.symlinks is false — git will check symlinks out as plain text files. Run: git config core.symlinks true (then re-checkout affected files).'
}

if (-not (Test-Command uv)) {
Write-Error @'
uv is not installed. It manages pre-commit in isolation.
Expand Down
20 changes: 20 additions & 0 deletions .config/scripts/Invoke-TestLane.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,24 @@ Describe 'Invoke-TestLane (isolated)' -Tag 'Standard' {
Set-Content -LiteralPath (Join-Path $testsDir 'Dummy.Tests.ps1')
Invoke-ScriptFile -Path $iso.Script -Arguments @('-Lane', 'Fast') | Should -Not -Be 0
}

It 'exposes no pre-5 Pester to the run (legacy module path scrubbed)' -Skip:(-not $HasPester) {
# A legacy Pester on the Windows PowerShell path auto-loads when a test
# calls a Pester-3-only command (e.g. Assert-MockCalled), silently
# corrupting discovery of sibling files. The lane must expose only the
# pinned Pester so such a command fails loudly instead.
$iso = Get-IsolatedScript
$testsDir = Join-Path $iso.Root 'tests'
New-Item -ItemType Directory -Path $testsDir -Force | Out-Null
@'
Describe 'pester-isolation' -Tag 'Fast' {
It 'sees no Pester older than 5 on the module path' {
$legacy = Get-Module -ListAvailable Pester |
Where-Object { $_.Version -lt [version]'5.0.0' }
$legacy | Should -BeNullOrEmpty
}
}
'@ | Set-Content -LiteralPath (Join-Path $testsDir 'Isolation.Tests.ps1')
Invoke-ScriptFile -Path $iso.Script -Arguments @('-Lane', 'Fast') | Should -Be 0
}
}
9 changes: 9 additions & 0 deletions .config/scripts/Invoke-TestLane.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Expose only this pwsh's modules to the run. Windows PowerShell ships an old
# Pester (3.x) on its module path; a test that calls a Pester-3-only command
# (e.g. Assert-MockCalled) auto-loads that legacy Pester and corrupts discovery
# of sibling files. Dropping the Windows PowerShell paths keeps core cmdlets
# intact (they live under $PSHOME) while making a stray legacy command fail
# loudly. No-op off Windows, where those paths are absent.
$env:PSModulePath = ($env:PSModulePath -split [IO.Path]::PathSeparator |
Where-Object { $_ -and $_ -notmatch 'WindowsPowerShell' }) -join [IO.Path]::PathSeparator

$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '..') | Select-Object -ExpandProperty Path

$testFiles = Get-ChildItem -Path $repoRoot -Recurse -File -Filter '*.Tests.ps1' -ErrorAction SilentlyContinue |
Expand Down
5 changes: 5 additions & 0 deletions .config/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ cost/risk — *not* by a unit/integration label.
there are none. Standard/thorough hooks ship **commented-in-place** in the
pre-commit config — uncomment when you have tests for them.

Tests are **co-located** beside the script they cover (`Foo.Tests.ps1` next to
`Foo.ps1`) — the repo-wide default. The exception is [`setup/`](../../setup/),
a user-facing runbook that segregates its tests into `setup/tests/`; discovery
recurses from the repo root, so either layout runs the same.

Keep the fast (commit) lane genuinely fast, or contributors reach for
`--no-verify`. The floor is pwsh + Pester warm-up (~1-3s), not your assertions.

Expand Down
8 changes: 7 additions & 1 deletion .config/scripts/Test-AsciiDoc.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
Set-StrictMode -Version Latest

# Capability flags must be set at DISCOVERY time (-Skip is evaluated then).
$HasAsciidoctor = [bool](Get-Command asciidoctor -ErrorAction SilentlyContinue)
# Probe an actual run, not just the command's existence: a Windows shim
# (asciidoctor.bat) can be on PATH while the Ruby behind it is not.
$HasAsciidoctor = $false
if (Get-Command asciidoctor -ErrorAction SilentlyContinue) {
asciidoctor --version *> $null
$HasAsciidoctor = ($LASTEXITCODE -eq 0)
}

BeforeAll {
$script:Script = Join-Path $PSScriptRoot 'Test-AsciiDoc.ps1'
Expand Down
10 changes: 9 additions & 1 deletion .config/scripts/Test-PowerShellScript.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,18 @@ Describe 'Test-PowerShellScript' -Tag 'Fast' {
Describe 'Test-PowerShellScript (PSScriptAnalyzer)' -Tag 'Standard' {
It 'exits 0 for a clean script' -Skip:(-not $HasPSSA) {
$f = Join-Path $TestDrive 'clean.ps1'
"Write-Output 'hello'" | Set-Content -LiteralPath $f
"Set-StrictMode -Version Latest`nWrite-Output 'hello'" | Set-Content -LiteralPath $f
Invoke-ScriptFile -Path $script:Script -Arguments @($f) | Should -Be 0
}

It 'names the finding count even when there is exactly one finding' -Skip:(-not $HasPSSA) {
$f = Join-Path $TestDrive 'one.ps1'
"Set-StrictMode -Version Latest`ngci" | Set-Content -LiteralPath $f # alias -> PSAvoidUsingCmdletAliases (Warning)
$output = & pwsh -NoProfile -File $script:Script $f 2>&1 | Out-String
$LASTEXITCODE | Should -Be 1
$output | Should -Match 'reported \d+ issue'
}

It 'exits non-zero for a Warning-level violation' -Skip:(-not $HasPSSA) {
$f = Join-Path $TestDrive 'dirty.ps1'
'gci' | Set-Content -LiteralPath $f # alias -> PSAvoidUsingCmdletAliases (Warning)
Expand Down
2 changes: 1 addition & 1 deletion .config/scripts/Test-PowerShellScript.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ $findings = foreach ($file in $Path) {

if ($findings) {
$findings | Format-Table -AutoSize | Out-String -Width 4096 | Write-Output
Write-Error "PSScriptAnalyzer reported $($findings.Count) issue(s)."
Write-Error "PSScriptAnalyzer reported $(@($findings).Count) issue(s)."
exit 1
}

Expand Down
12 changes: 7 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ desktop.ini

# --- Editor state -------------------------------------------------------------
# Ignore personal editor config, but KEEP the shipped tooling files.
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
/.vscode/*
!/.vscode/settings.json
!/.vscode/extensions.json
!/.vscode/README.md

# --- transient work (kept out of tracking for now) ----------------------------
.temp/
.history/
/.temp/
!/.temp/.gitkeep
/.history/

# --- Project-specific (add below) ---------------------------------------------
19 changes: 19 additions & 0 deletions .vscode/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Editor integration

VS Code workspace settings that keep the editor in sync with the same rules
`pre-commit` enforces at commit time. See [`/AGENTS.md`](../AGENTS.md) for the
overall architecture.

## The files

| File | Purpose |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `settings.json` | Wires live linting to `.config/` sources (e.g. `markdownlint.config` `extends` [`.config/markdownlint.jsonc`](../.config/markdownlint.jsonc)) so editor and commit agree. Deliberately tooling-only — no personal prefs (theme, fonts). |
| `extensions.json` | Recommended extensions so the linters that run at commit also surface live in the editor. VS Code prompts contributors to install these on open. |

## Why settings.json is tracked but scoped

`.gitignore` keeps the rest of `.vscode/` ignored (so personal workspace state
doesn't leak into commits) while explicitly tracking `settings.json` and
`extensions.json`. Keep that split: personal preferences go in the user's own
VS Code profile, not here.
74 changes: 38 additions & 36 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ PRs, full at the merge gate.
| Policy rules | `.config/PSScriptAnalyzerRules/` | [`.config/scripts/README.md`](.config/scripts/README.md) |
| Opt-in tooling | `.config/overlays/` | [`.config/overlays/vale/README.md`](.config/overlays/vale/README.md) |
| One-time repo setup | `setup/` (delete after use) | [`setup/README.md`](setup/README.md) |
| Optional AI skills | `setup/optional-skills/` | [`setup/README.md`](setup/README.md) |

Root convention files are declarative and self-documenting: `.editorconfig`
(style), `.gitattributes` (eol=lf), `.gitignore`, `.claudeignore`.
Expand All @@ -43,45 +44,46 @@ root). Key principles encoded there:
- **Local hooks fail fast**, never self-install. Bootstrap is explicit
(`.config/scripts/Initialize-DevEnvironment.ps1`).

## Reuse: prefer a symlink over a copy

When the same file must exist in more than one place, **symlink it** instead of
copying — one source of truth, no drift (ai-lab copied such files and they
drifted). Git tracks symlinks natively.

- **Windows:** enable **Developer Mode** (Settings → For developers) so symlinks
materialize *without admin rights* — do **not** reach for an elevated shell
(that breaks pre-commit, see the bootstrap note). Without it, git checks
symlinks out as plain text files holding the target path. Keep
`git config core.symlinks true`.
- Reserve copies for cases where the two files are meant to diverge.
## Development workflow: TDD

Behavioral changes to the repo's scripts are developed test-first with the
`develop-with-tdd` skill. If that skill — or an equivalent TDD skill — is
already installed in your agent, use it; there is no need for a second copy.
If none exists, use the bundled one at
[`setup/optional-skills/skills/develop-with-tdd/`](setup/optional-skills/skills/develop-with-tdd/SKILL.md):
install it into your agent's skill directory (Claude Code: `.claude/skills/`
in the repo, or `~/.claude/skills/` for all repos), or follow its `SKILL.md`
in place. The bundle is deleted with the rest of `setup/` at the end of
bootstrap — install it *before* running `Complete-Setup.ps1` if you want to
keep it.

## Reuse: prefer an include over a copy; a symlink only when no include exists

When the same content must exist in more than one place, in order of
preference:

1. **Content-level include** — the format's own indirection: the `@AGENTS.md`
import in `CLAUDE.md`, `extends` in markdownlint/VS Code settings, explicit
config paths in pre-commit hook `args`. Degrades *loudly* (a missing file
errors) and works on every machine — use it whenever the format offers one.
2. **Symlink** — when the format has no include mechanism. One source of
truth, no drift (ai-lab copied such files and they drifted). Git tracks
symlinks natively, but on Windows they only materialize with **Developer
Mode** (Settings → For developers) *and* `git config core.symlinks true` —
without both, git silently checks them out as plain text files holding the
target path, and nothing errors. `Initialize-DevEnvironment.ps1` probes
symlink creation and warns when the machine can't do it. Do **not** reach
for an elevated shell as a workaround (that breaks pre-commit, see the
bootstrap note).
3. **Copy** — only when the two files are meant to diverge.

## Retrofit an existing repo

To bring another repo (e.g. one using split per-language workflows and a
hand-rolled `.githooks/` hook) up to this architecture:

1. **Adopt the orchestrator.** Copy `.pre-commit-config.yaml` and adapt the hook
list to the repo's languages. Remove any `core.hooksPath`/`.githooks/` setup —
pre-commit replaces it. Don't set `core.hooksPath`; the two conflict.
2. **Relocate configs** into `.config/`, referenced explicitly via hook `args`
(keep root-forced files at root). Factor markdown rules into
`markdownlint.jsonc` so the editor and CLI share them.
3. **Move custom checks into shared scripts.** Any bespoke lint/test logic that
lived in CI *and* a local hook becomes one `.config/scripts/*.ps1` invoked as
a `repo: local` hook — run identically local and in CI. Delete the duplicate.
4. **Collapse CI to one workflow** that runs `pre-commit run` (scoped on
`pull_request`, full on `merge_group`). Replace N split per-language workflows;
path-filtering is no longer needed because pre-commit scopes by file.
5. **Wire the editor** (`.vscode/settings.json`) and add recommendations.
6. **Copy the docs** (this file + the per-folder READMEs), trimming to the
retrofitted repo's actual tools.
7. **Purge phantoms.** Verify every tool exists (e.g. there is **no**
`asciidoctor-lint` gem — use `asciidoctor --failure-level` for syntax + Vale
for prose).

Anti-pattern this replaces: local hook logic and CI workflows re-implementing
"the same" checks separately, which drift apart. One orchestrator, one source.
See [`setup/MIGRATION.md`](setup/MIGRATION.md) for the full step-by-step walkthrough of
bringing this architecture into a repo not scaffolded from this template — one
step per concept in the mechanism map above, in dependency order, including
the platform limitations you'll hit along the way (e.g. `merge_queue` on a
personal/user-owned repo).

## Opening a pull request

Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Claude Code

This repo's tooling architecture, conventions, and retrofit guide live in
[AGENTS.md](AGENTS.md) — read it first. Use the `develop-with-tdd` skill for
behavioral changes to the scripts (test-first).
[AGENTS.md](AGENTS.md) — read it first (imported below in full); it also
defines the TDD workflow and where the bundled skill lives.

@AGENTS.md
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ pwsh -NoProfile -File .config/scripts/Initialize-DevEnvironment.ps1
```

For what the tooling does and why it's shaped this way, see
[.config/README.md](.config/README.md).
[.config/README.md](.config/README.md). Migrating this tooling into a repo
that isn't scaffolded from this template? See [setup/MIGRATION.md](setup/MIGRATION.md).
25 changes: 25 additions & 0 deletions setup/AI-Maintainer-Identity.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ With that in place, the branch ruleset (`Protect-MainBranch.ps1`) is enforced
*against the agents*, and `CODEOWNERS` can require your review on the control
surfaces while routine agent PRs flow through CI + auto-merge.

== Which agents need an identity you create

Only agents that run *in your shell* use a credential you provide — those are
the ones this document is for:

* *Locally-run agents* (Claude Code CLI, Codex CLI, aider, …) inherit the
`GH_TOKEN`/git credential of the shell they run in. Create *one App (or PAT)
per agent* — separate identities keep attribution clean and let you revoke
one agent without touching the others.
* *Vendor-hosted agents* (GitHub Copilot coding agent, Codex cloud) act
through the vendor's own pre-built GitHub App. You install and repo-scope
*theirs* — you cannot substitute your own. Verify their installation is
limited to the intended repos and grants no admin.

Verification is scriptable either way: `Test-AIMaintainerIdentity.ps1`, run
from the agent's shell, fails unless the active credential is an installation
token or fine-grained PAT with write/push access and without admin.

== Choose a mechanism

[cols="1,3,3",options="header"]
Expand Down Expand Up @@ -72,6 +90,11 @@ PAT, and never grant `Administration`.

=== Create

`New-AIMaintainerApp.ps1` automates this section via GitHub's app-manifest
flow — it builds the manifest below, opens the browser for the single
confirmation GitHub requires (there is no pure-API path to App creation), and
saves the private key locally. The manual path, for reference:

. *Settings → Developer settings → GitHub Apps → New GitHub App.*
. Name it per agent or one shared maintainer app (e.g. `denwin-ai-maintainer`).
. *Repository permissions* — grant only:
Expand Down Expand Up @@ -150,6 +173,8 @@ Mitigate by convention:

== See also

* `setup/New-AIMaintainerApp.ps1` — scripted App creation (manifest flow).
* `setup/Test-AIMaintainerIdentity.ps1` — verify an agent shell's credential; run before `Complete-Setup.ps1`.
* `setup/Protect-MainBranch.ps1` — the branch ruleset the identities are enforced against.
* `setup/Enable-RepoSecurity.ps1` — server-side security toggles.
* `.github/CODEOWNERS` — the control surfaces that require owner review.
Loading