diff --git a/.azure/azure-pipeline.template.yaml b/.azure/azure-pipeline.template.yaml
deleted file mode 100644
index eeadb38..0000000
--- a/.azure/azure-pipeline.template.yaml
+++ /dev/null
@@ -1,94 +0,0 @@
-parameters:
- name: ''
- displayName: ''
- vmImage: ''
- outputDirectory: ''
- artifactName: ''
- timeoutInMinutes: 120
-
-jobs:
- - job: ${{ parameters.name }}
- displayName: ${{ parameters.displayName }}
- timeoutInMinutes: ${{ parameters.timeoutInMinutes }}
-
- pool:
- vmImage: ${{ parameters.vmImage }}
-
- steps:
- - checkout: self # self represents the repo where the initial Pipelines YAML file was found
- clean: false # whether to fetch clean each time
- submodules: recursive # set to 'true' for a single level of submodules or 'recursive' to get submodules of submodules
- persistCredentials: true
-
- - task: UseDotNet@2
- displayName: 'Use .NET'
- inputs:
- packageType: 'sdk'
- useGlobalJson: true
-
- - script: dotnet tool restore
- displayName: 'Restore dotnet tools'
-
- - pwsh: |
- .\build.ps1
- displayName: 'Update Release Notes'
- continueOnError: false
-
- - script: dotnet build -c Release
- displayName: 'dotnet build'
- continueOnError: false
-
- - script: dotnet test -c Release --no-build --logger:trx --collect:"XPlat Code Coverage" --results-directory TestResults --settings coverlet.runsettings
- displayName: 'Run tests'
- continueOnError: true # Allow continuation even if tests fail
-
- - task: PublishTestResults@2
- inputs:
- testResultsFormat: VSTest
- testResultsFiles: '**/*.trx' #TestResults folder usually
- testRunTitle: ${{ parameters.name }}
- mergeTestResults: true
- failTaskOnFailedTests: false
- publishRunAttachments: true
-
- - pwsh: |
- $coverageFiles = Get-ChildItem -Path "$(Build.SourcesDirectory)/TestResults" -Filter "*.cobertura.xml" -Recurse
- $hasCoverageFiles = $coverageFiles.Count -gt 0
- Write-Host "##vso[task.setvariable variable=HasCoverageFiles]$hasCoverageFiles"
- displayName: 'Check for Coverage Files'
- condition: always()
- continueOnError: true
-
- - task: reportgenerator@5
- displayName: ReportGenerator
- # Only run if coverage files exist
- condition: and(always(), eq(variables['HasCoverageFiles'], 'True'))
- continueOnError: true
- inputs:
- reports: '$(Build.SourcesDirectory)/TestResults/**/*.cobertura.xml'
- targetdir: '$(Build.SourcesDirectory)/coveragereport'
- reporttypes: 'HtmlInline_AzurePipelines;Cobertura;Badges'
- assemblyfilters: '-xunit*'
- publishCodeCoverageResults: true
-
- - publish: $(Build.SourcesDirectory)/coveragereport
- displayName: 'Publish Coverage Report'
- # Only run if coverage files exist
- condition: and(always(), eq(variables['HasCoverageFiles'], 'True'))
- continueOnError: true
- artifact: 'CoverageReports-$(Agent.OS)-$(Build.BuildId)'
-
- - script: dotnet pack -c Release --no-build -o $(Build.ArtifactStagingDirectory)/nuget
- displayName: 'Create packages'
-
- - task: PublishBuildArtifacts@1
- displayName: 'Publish artifacts'
- inputs:
- PathtoPublish: '$(Build.ArtifactStagingDirectory)/nuget'
- ArtifactName: 'nuget'
- publishLocation: 'Container'
-
- - script: 'echo 1>&2'
- failOnStderr: true
- displayName: 'If above is partially succeeded, then fail'
- condition: eq(variables['Agent.JobStatus'], 'SucceededWithIssues')
diff --git a/.azure/pr-validation.yaml b/.azure/pr-validation.yaml
deleted file mode 100644
index 704d238..0000000
--- a/.azure/pr-validation.yaml
+++ /dev/null
@@ -1,29 +0,0 @@
-trigger:
- branches:
- include:
- - dev
- - master
-
-pr:
- autoCancel: true # indicates whether additional pushes to a PR should cancel in-progress runs for the same PR. Defaults to true
- branches:
- include: [ dev, master ] # branch names which will trigger a build
-
-name: $(SourceBranchName)_$(Year:yyyy).$(Month).$(DayOfMonth)$(Rev:.r)
-
-jobs:
- - template: azure-pipeline.template.yaml
- parameters:
- name: 'windows_pr'
- displayName: 'Windows PR Validation'
- vmImage: 'windows-latest'
- outputDirectory: 'bin/nuget'
- artifactName: 'nuget_pack-$(Build.BuildId)'
-
- - template: azure-pipeline.template.yaml
- parameters:
- name: 'linux_pr'
- displayName: 'Linux PR Validation'
- vmImage: 'ubuntu-latest'
- outputDirectory: 'bin/nuget'
- artifactName: 'nuget_pack-$(Build.BuildId)'
diff --git a/.azure/windows-release.yaml b/.azure/windows-release.yaml
deleted file mode 100644
index 7081f45..0000000
--- a/.azure/windows-release.yaml
+++ /dev/null
@@ -1,47 +0,0 @@
-# Release task for Akka.Streams.Kafka
-# See https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema for reference
-
-trigger:
- branches:
- include:
- - refs/tags/*
-pr: none
-
-variables:
- - group: nugetKeys
- - name: githubConnectionName
- value: AkkaDotNet_Releases
- - name: projectName
- value: Akka.Streams.Kafka
- - name: githubRepositoryName
- value: akkadotnet/Akka.Streams.Kafka
-
-jobs:
- - job: Release
- pool:
- vmImage: windows-latest
- demands: Cmd
- steps:
- - task: UseDotNet@2
- displayName: 'Use .NET SDK from global.json'
- inputs:
- useGlobalJson: true
-
- - powershell: ./build.ps1
- displayName: 'Update Release Notes'
-
- # Pack without version suffix for release
- - script: dotnet pack -c Release -o $(Build.ArtifactStagingDirectory)/nuget
- displayName: 'Create packages'
-
- - script: dotnet nuget push "$(Build.ArtifactStagingDirectory)\nuget\*.nupkg" --api-key $(nugetKey) --source https://api.nuget.org/v3/index.json --skip-duplicate
- displayName: 'Publish to NuGet.org'
-
- - task: GitHubRelease@0
- displayName: 'GitHub release (create)'
- inputs:
- gitHubConnection: $(githubConnectionName)
- repositoryName: $(githubRepositoryName)
- title: '$(projectName) v$(Build.SourceBranchName)'
- releaseNotesFile: 'RELEASE_NOTES.md'
- assets: '$(Build.ArtifactStagingDirectory)/nuget/*.nupkg'
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 71aff30..b82ebe4 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -8,13 +8,6 @@
"incrementalist"
],
"rollForward": false
- },
- "docfx": {
- "version": "2.78.3",
- "commands": [
- "docfx"
- ],
- "rollForward": false
}
}
-}
\ No newline at end of file
+}
diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml
index 5c4a6e4..9732270 100644
--- a/.github/workflows/pr_validation.yml
+++ b/.github/workflows/pr_validation.yml
@@ -36,19 +36,19 @@ jobs:
- name: "Restore .NET tools"
run: dotnet tool restore
- - name: "Update release notes"
+ - name: "Verify copyright headers"
shell: pwsh
- run: |
- ./build.ps1
+ run: ./scripts/Add-FileHeaders.ps1 -Verify
- - name: "dotnet build"
- run: dotnet build -c Release
+ - name: "dotnet restore"
+ run: dotnet restore
- # .NET Framework tests can't run reliably on Linux, so we only do .NET 8
+ - name: "dotnet build"
+ run: dotnet build -c Release --no-restore
- name: "dotnet test"
shell: bash
- run: dotnet test -c Release
+ run: dotnet test -c Release --no-build
- name: "dotnet pack"
- run: dotnet pack -c Release -o ./bin/nuget
\ No newline at end of file
+ run: dotnet pack -c Release --no-build -o ./bin/nuget
diff --git a/.github/workflows/publish_nuget.yml b/.github/workflows/publish_nuget.yml
index 5fa311f..8d5d129 100644
--- a/.github/workflows/publish_nuget.yml
+++ b/.github/workflows/publish_nuget.yml
@@ -3,72 +3,69 @@ name: Publish NuGet
on:
push:
tags:
- - '*'
+ - 'v*.*.*'
+ - 'v*.*.*-*'
permissions:
- contents: write
+ contents: write
jobs:
publish-nuget:
-
name: publish-nuget
- runs-on: ${{ matrix.os }}
- strategy:
- matrix:
- os: [ubuntu-latest]
+ runs-on: ubuntu-latest
steps:
- - name: "Checkout"
- uses: actions/checkout@v6.0.2
- with:
- lfs: true
- fetch-depth: 0
-
- - name: "Install .NET SDK"
- uses: actions/setup-dotnet@v5.1.0
- with:
- global-json-file: "./global.json"
+ - name: "Checkout"
+ uses: actions/checkout@v6.0.2
+ with:
+ lfs: true
+ fetch-depth: 0
- - name: "Restore .NET tools"
- run: dotnet tool restore
+ - name: "Install .NET SDK"
+ uses: actions/setup-dotnet@v5.1.0
+ with:
+ global-json-file: "./global.json"
- - name: "Update release notes"
- shell: pwsh
- run: |
- ./build.ps1
+ - name: "Restore .NET tools"
+ run: dotnet tool restore
- - name: Create Packages
- run: dotnet pack /p:PackageVersion=${{ github.ref_name }} -c Release -o ./output
+ - name: "Compute package version from tag"
+ shell: bash
+ run: |
+ # tag form is vX.Y.Z or vX.Y.Z-suffix
+ TAG="${GITHUB_REF_NAME}"
+ VERSION="${TAG#v}"
+ echo "PACKAGE_VERSION=${VERSION}" >> "$GITHUB_ENV"
+ echo "Publishing package version: ${VERSION}"
- - name: Push Packages
- run: dotnet nuget push "output/*.nupkg" -k ${{ secrets.NUGET_KEY }} -s https://api.nuget.org/v3/index.json
+ - name: "dotnet pack"
+ run: dotnet pack /p:PackageVersion=${{ env.PACKAGE_VERSION }} -c Release -o ./output
- - name: "Extract latest release notes"
- shell: pwsh
- run: |
- $content = Get-Content RELEASE_NOTES.md -Raw
- # Match from the first #### heading to just before the second one
- if ($content -match '(?s)(####.+?)(?=\n####|\z)') {
- $Matches[1].Trim() | Set-Content RELEASE_NOTES_LATEST.md
- } else {
- $content | Set-Content RELEASE_NOTES_LATEST.md
- }
+ - name: "dotnet nuget push"
+ run: dotnet nuget push "output/*.nupkg" -k ${{ secrets.NUGET_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate
- - name: release
- uses: actions/create-release@v1
- id: create_release
- with:
- draft: false
- prerelease: false
- release_name: 'Akka.Hosting ${{ github.ref_name }}'
- tag_name: ${{ github.ref }}
- body_path: RELEASE_NOTES_LATEST.md
- env:
- GITHUB_TOKEN: ${{ github.token }}
+ - name: "Extract latest release notes"
+ shell: pwsh
+ run: |
+ if (-not (Test-Path RELEASE_NOTES.md)) {
+ "No release notes available." | Set-Content RELEASE_NOTES_LATEST.md
+ exit 0
+ }
+ $content = Get-Content RELEASE_NOTES.md -Raw
+ if ($content -match '(?s)(####.+?)(?=\n####|\z)') {
+ $Matches[1].Trim() | Set-Content RELEASE_NOTES_LATEST.md
+ } else {
+ $content | Set-Content RELEASE_NOTES_LATEST.md
+ }
- - name: Upload Release Asset
- uses: AButler/upload-release-assets@v3.0
- with:
- repo-token: ${{ github.token }}
- release-tag: ${{ github.ref_name }}
- files: 'output/*.nupkg'
+ - name: "Create GitHub release"
+ uses: softprops/action-gh-release@v2
+ with:
+ name: "ShellSyntaxTree ${{ github.ref_name }}"
+ tag_name: ${{ github.ref_name }}
+ body_path: RELEASE_NOTES_LATEST.md
+ files: output/*.nupkg
+ draft: false
+ prerelease: ${{ contains(github.ref_name, '-') }}
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..f8bfe18
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,207 @@
+# ShellSyntaxTree Agent Constitution
+
+This file is the repository's stable agent constitution. Keep it small. Keep
+it durable. Keep it routing-focused. Volatile detail belongs in the
+referenced repo-owned artifacts, not here.
+
+## Authority and Scope
+
+- You are authorized to plan, design, implement, test, document, and
+ release ShellSyntaxTree v0.x.
+- Default to the smallest safe change that advances the v0.1 acceptance
+ criteria in `SPEC.md` §17.
+- Prefer explicit trade-offs over hidden complexity. Bias toward marking
+ inputs `DynamicSkip` / `IsUnparseable` over guessing — this library
+ feeds security gates.
+
+## Read First (in this order)
+
+1. `SPEC.md` — the locked v0.1 contract (public API, AST, grammar, verb
+ tables, resolver, corpus rules). **The contract.**
+2. `PROJECT_CONTEXT.md` — what this is, who consumes it, scope discipline.
+3. `TOOLING.md` — what's available and how to access it.
+4. `IMPLEMENTATION_PLAN.md` — current NOW / NEXT / LATER work.
+5. The relevant `dotnet-skills:*` skill for the .NET concern at hand
+ (see `TOOLING.md` routing table).
+
+When `SPEC.md` and any other artifact disagree, **`SPEC.md` wins** — fix
+the other artifact in the same change.
+
+## Required Task Routing (MODE)
+
+Pick a MODE before acting. Each MODE has different signals, expected
+outputs, and definition-of-done.
+
+### MODE=build
+
+**Use when:** implementing parser logic, lexer, resolver, verb tables,
+AST records, options, or any production code under `src/ShellSyntaxTree/`,
+plus the unit tests that exercise it.
+
+**Signals:** changes under `src/`, non-corpus changes under `tests/`,
+public-API surface changes (rare — requires bumping toward v0.2+).
+
+**Expected outputs:**
+
+- code under `src/ShellSyntaxTree/`
+- unit tests under `tests/ShellSyntaxTree.Tests/` (NOT corpus JSON)
+- `dotnet build` and `dotnet test` both pass locally
+- Public API surface matches `SPEC.md` §2 exactly (any drift is a bug)
+
+**Definition of done:** see Universal DoD below, plus: every behavior
+change is covered either by a unit test or a corpus entry.
+
+### MODE=corpus
+
+**Use when:** authoring, sanitizing, or curating JSON entries under
+`tests/ShellSyntaxTree.Tests/Corpus/bash/*.json`. The corpus is the
+acceptance contract — corpus work is materially different from code work
+(JSON shape, sanitization rules, PII audit).
+
+**Signals:** changes under `tests/.../Corpus/`, references to dogfood
+logs, requests like "add a case for `chmod`", "seed from logs".
+
+**Expected outputs:**
+
+- new or updated `*.json` corpus entries matching the SPEC §13 schema
+- entries cover the SPEC §13 category coverage targets
+- if seeded from logs: every sanitization rule in SPEC §14 is applied,
+ manually reviewed, and the entry is committed only after PII audit
+ would pass on it
+
+**Definition of done:** see Universal DoD, plus:
+
+- the PII audit scan (regex rules from SPEC §14) finds zero hits
+- every new entry parses to the declared `expected` AST when run through
+ `BashParser` (i.e., implementation must already cover it, OR this entry
+ is intentionally a `[Theory]` failure that pins a NOW item in the
+ implementation plan — note the link in the entry's `notes` field)
+
+### MODE=release
+
+**Use when:** version bumps, RELEASE_NOTES.md updates, tagging,
+NuGet publish flow changes.
+
+**Signals:** changes to `RELEASE_NOTES.md`, `Directory.Build.props`
+`VersionPrefix`/`VersionSuffix`, `.github/workflows/publish_nuget.yml`,
+or a request to "tag" / "ship" / "cut a release".
+
+**Expected outputs:**
+
+- semantic version that matches SPEC §15 progression rules
+ (`v0.1.x` additive, `v0.2.0` for first PowerShell parser, etc.)
+- `RELEASE_NOTES.md` updated with the new section in the existing format
+- on tag push, `publish_nuget.yml` produces a `.nupkg` matching
+ `VersionPrefix`/`VersionSuffix` and pushes to nuget.org
+
+**Definition of done:** see Universal DoD, plus:
+
+- the tag triggers the publish workflow and the package appears on
+ nuget.org
+- for v0.1.0-alpha specifically: SPEC §17 acceptance #1-#8 all hold
+
+## Discovery Rules
+
+Before adding code or a test, discover in this order:
+
+1. Is there a SPEC clause that already specifies the behavior? Implement
+ to that. Do not invent shape that disagrees with the SPEC.
+2. Is there an existing corpus entry that pins the expected AST for this
+ case? Make the implementation match the corpus.
+3. Is there a `dotnet-skills:*` skill for the .NET concern? Open it; apply
+ the parts that fit; note conflicts.
+4. Is the construct in SPEC §1 / §18 non-goals? If yes, mark
+ `IsUnparseable` and stop.
+
+If any of (1)-(4) conflict, **fix the conflict before implementing**.
+
+## Universal Quality Bar
+
+- **API surface is locked.** Public types in `SPEC.md` §2 do not change
+ without a deliberate version bump. Internal types are free.
+- **No silent fallbacks.** If a token can't be safely classified, mark
+ `DynamicSkip`. If a construct can't be parsed, mark `IsUnparseable`.
+ Do not guess. Consumers can always relax — they can't un-execute a
+ command we falsely classified safe.
+- **No native dependencies.** Multi-target `netstandard2.0` and `net8.0`;
+ AOT-trim friendly.
+- **`TreatWarningsAsErrors=true`** is enforced repo-wide via
+ `Directory.Build.props`. Don't suppress warnings to make a build pass —
+ fix the cause.
+- **No `Thread.Sleep`/`Task.Delay` in tests** to wait for conditions.
+ Tests should be deterministic; the parser is synchronous.
+- **Comments: skip noise, keep signal.** Don't narrate code that
+ identifiers already explain. Do explain *why* a verb is in (or absent
+ from) `BashArity` / `FileVerbs`, *why* a token marks `DynamicSkip`,
+ *why* a fallback is or isn't safe in this context.
+- **No stray sample apps, helper scripts, or "interesting" files.** This
+ repo ships one library and its tests. If a helper is genuinely needed,
+ put it under `tests/` or a clearly-scoped `tools/` folder and document
+ it in `TOOLING.md`.
+
+## Definition of Done (universal)
+
+A change is done when **all** of these hold:
+
+- behavior aligns with `SPEC.md` (or `SPEC.md` was updated in the same
+ change with explicit justification)
+- `dotnet build -c Release` is clean
+- `dotnet test -c Release` passes (unit + corpus)
+- copyright headers present on every new/modified `.cs` file —
+ `pwsh ./scripts/Add-FileHeaders.ps1 -Verify` exits 0 (CI enforces this)
+- public API surface still matches SPEC §2 exactly
+- corpus entries that touch the changed code path still pass
+- if MODE=corpus: PII audit regex finds zero hits
+- if MODE=release: tag-driven publish workflow succeeds end-to-end
+- `IMPLEMENTATION_PLAN.md` is updated (item moved, completed, or
+ parked) so the plan reflects reality
+
+## Validation Expectations
+
+Run before declaring done:
+
+```bash
+dotnet tool restore
+dotnet build -c Release
+dotnet test -c Release
+dotnet pack -c Release -o ./bin/nuget # only on release-shaped changes
+```
+
+For any code change that touches public API:
+
+- diff `src/ShellSyntaxTree/` headers against `SPEC.md` §2 — they must
+ match field-for-field.
+
+For corpus changes:
+
+- the PII audit test (a `[Fact]` that scans `tests/.../Corpus/bash/*.json`
+ for the SPEC §14 forbidden patterns) must pass.
+
+## Continuous Improvement Rule
+
+- If a workflow or correction repeats twice → extract or refine a skill or
+ add a row to `TOOLING.md`.
+- If volatile project knowledge surfaces → put it in `PROJECT_CONTEXT.md`
+ or `IMPLEMENTATION_PLAN.md`, not here.
+- This file should rarely change. Constitution edits are a deliberate act
+ — they ripple to every future agent run.
+- Compressing a referenced authority (SPEC, dotnet-skills, etc.) into a
+ rule here is a *retrieval* operation, not a *memory* operation. If you
+ can't write the rule without losing a distinction the source draws,
+ drop the rule and link to the source instead.
+
+## Handy Commands
+
+```bash
+# Restore and build everything
+dotnet tool restore && dotnet restore && dotnet build -c Release
+
+# Run the full test+corpus suite
+dotnet test -c Release
+
+# Pack locally to validate package metadata
+dotnet pack -c Release -o ./bin/nuget
+
+# Cut a release (manual, then push the tag)
+git tag v0.1.0-alpha && git push origin v0.1.0-alpha
+```
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 120000
index 0000000..47dc3e3
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/Directory.Build.props b/Directory.Build.props
index bc17372..fdea59e 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,48 +1,44 @@
- Copyright © 2015-$([System.DateTime]::Now.Year) Akka.NET Team
- Akka.NET Team
- akka, akka.streams, kafka, akkadotnet, akka streams, akka.streams.kafka
+ Copyright © 2026-$([System.DateTime]::Now.Year) Aaron Stannard
+ Aaron Stannard
+ Aaron Stannard
$(NoWarn);CS1591
latest
enable
true
- 1.0.0
- Example release notes
+ 0.1.0
+ alpha
-
+
netstandard2.0
- net6.0
- net9.0
+ net8.0
+ net10.0
-
-
-
-
-
-
-
+
+
+
+
+
+
-
+
- 1.5.25 June 17 2024
-
-* [Updated Akka.NET to 1.5.25](https://github.com/akkadotnet/akka.net/releases/tag/1.5.25)
- akka;actors;actor model;Akka;concurrency;test
- https://github.com/akkadotnet/Akka.MultiNodeTestRunner
+ bash;shell;parser;ast;security;agent;syntax-tree
+ https://github.com/Aaronontheweb/ShellSyntaxTree
+ https://github.com/Aaronontheweb/ShellSyntaxTree
+ git
Apache-2.0
- akkalogo.png
README.md
+ icon.png
true
-
true
-
true
snupkg
-
\ No newline at end of file
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ab3f264..7aedbe6 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,17 +1,19 @@
true
-
- 1.5.63
+
+ 2.9.2
+ 2.8.2
-
+
-
-
+
+
+
+
-
-
+
-
+
-
\ No newline at end of file
+
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
new file mode 100644
index 0000000..f2d4c80
--- /dev/null
+++ b/IMPLEMENTATION_PLAN.md
@@ -0,0 +1,164 @@
+# Implementation Plan — ShellSyntaxTree
+
+Source of truth for active work. Translates `SPEC.md` §16 into NOW / NEXT /
+LATER buckets. Park items aggressively — autonomous loops will otherwise
+bulldoze priorities.
+
+> **Hard rule:** every PR ends with this file updated (item moved /
+> completed / parked) so the plan reflects reality.
+
+---
+
+## NOW (v0.1.0-alpha shipping path)
+
+### 1. Bootstrap projects
+
+- [ ] Create `src/ShellSyntaxTree/ShellSyntaxTree.csproj` (library,
+ multi-target `netstandard2.0;net8.0`)
+- [ ] Create `tests/ShellSyntaxTree.Tests/ShellSyntaxTree.Tests.csproj`
+ (xunit, target `net10.0`)
+- [ ] Add both to `ShellSyntaxTree.slnx`
+- [ ] `dotnet build` clean, `dotnet test` clean (zero tests OK)
+
+### 2. Public API skeleton (lock surface first)
+
+- [ ] Implement public types from `SPEC.md` §2 verbatim:
+ `IShellParser`, `BashParser`, `BashParserOptions`, `ParsedCommand`,
+ `Clause`, `VerbChain`, `Arg`, `Redirect`, and the three enums
+- [ ] `BashParser.Parse` throws `NotImplementedException` for now
+- [ ] `Arg` includes `IsCwdAttribution` per SPEC §9
+- [ ] Public API snapshot test (a single test that asserts each public
+ member exists with the expected shape — fast feedback when the
+ surface drifts)
+
+### 3. BashLexer (SPEC §5)
+
+- [ ] Token kinds: WORD, QUOTED_STRING, OPERATOR, WHITESPACE,
+ CONTINUATION
+- [ ] Quote handling (single literal, double with `\"`, `\\`, `\$`)
+- [ ] Escape handling outside quotes
+- [ ] Operator boundaries (no whitespace required)
+- [ ] Heavy unit tests on tokenization
+
+### 4. Verb tables (SPEC §6, data only)
+
+- [ ] `BashArity` (multi-token verbs)
+- [ ] `CwdVerbs` (`cd`, `chdir`, `popd`, `pushd`, ...)
+- [ ] `FileVerbs` (cat, grep, find, ls, ...)
+- [ ] `FlagsWithValue` (per-verb flags that consume the next token)
+- [ ] Probe order: longest-match-first when joining 1, 2, 3 tokens
+
+### 5. BashParser core (SPEC §4)
+
+- [ ] Compound splitting on `&&`, `||`, `;`, `|`
+- [ ] Verb chain extraction using `BashArity`
+- [ ] Args + redirects per clause
+- [ ] Subshell `( ... )` handling
+- [ ] `bash -c "..."` recursion (capped at depth 5)
+- [ ] Anomaly safe-fail (SPEC §11): never throw on well-formed input
+
+### 6. Resolver (SPEC §8)
+
+- [ ] Tilde + `$HOME` expansion against `BashParserOptions.HomeDirectory`
+- [ ] All other `$VAR` / `${VAR}` → `DynamicSkip`
+- [ ] `filesystem::/path` prefix stripping
+- [ ] Glob detection (don't expand)
+- [ ] Relative path joining against `BashParserOptions.WorkingDirectory`
+- [ ] `LooksLikePath` heuristic per §8
+
+### 7. Per-verb path-arg rules (SPEC §7)
+
+- [ ] Default: every non-flag positional after the verb chain is a path
+- [ ] Per-verb overrides: `chmod`, `chown`, `chgrp`, `ln`, `find`,
+ `grep`, `rg`, `sed`, `awk`, `tar`, `curl`/`wget`, `scp`/`rsync`,
+ `cd`-family
+- [ ] Flag-with-value handling (`-o file`, `git -C /repo`, `--output=file`)
+
+### 8. cd-in-compound propagation (SPEC §9)
+
+- [ ] First-clause `cd` sets attributed cwd for the compound
+- [ ] Subsequent clauses receive synthetic `Arg` with
+ `IsCwdAttribution=true`
+- [ ] Subsequent `cd` in the same compound replaces attribution
+- [ ] Subshell boundaries reset attribution
+
+### 9. Subshell + bash -c surfacing (SPEC §10)
+
+- [ ] Flatten subshell clauses into parent's `Clauses` with
+ `IsSubshell=true`
+- [ ] Surface `bash -c` inner clauses inline with `IsBashCWrapped=true`
+- [ ] Recursion-depth cap
+
+### 10. Hand-authored corpus (SPEC §13 — minimum 105 entries)
+
+- [ ] 10 simple-verb cases
+- [ ] 10 multi-token-verb cases
+- [ ] 15 compound cases
+- [ ] 10 cd-in-compound cases
+- [ ] 10 quote-handling cases
+- [ ] 10 redirect cases
+- [ ] 10 subshell cases
+- [ ] 10 `bash -c` cases
+- [ ] 10 dynamic-skip cases
+- [ ] 10 per-verb path-rule cases
+- [ ] 10 unparseable cases
+
+### 11. Corpus runner test
+
+- [ ] Single `[Theory] [MemberData]` enumerating
+ `tests/.../Corpus/bash/*.json`
+- [ ] Per-entry test name so failures point at the specific case
+- [ ] Structural equality helper `AstAssert.Equal`
+
+### 12. PII audit (SPEC §14)
+
+- [ ] Single `[Fact]` that scans `tests/.../Corpus/bash/*.json` for
+ SPEC §14 forbidden patterns
+- [ ] Wired into `pr_validation.yml` via `dotnet test` (no separate job)
+
+### 13. Release v0.1.0-alpha
+
+- [ ] `RELEASE_NOTES.md` updated with v0.1.0-alpha section
+- [ ] Tag `v0.1.0-alpha`; verify `publish_nuget.yml` produces and pushes
+ the package
+
+### 14. Netclaw integration smoke (SPEC §17 #7-#8)
+
+- [ ] Add `` to a Netclaw
+ project; verify `IShellParser` resolves in DI
+- [ ] One Netclaw integration test exercises a real corpus entry through
+ the live matcher and gets the expected gate decision
+
+### 15. NuGet package icon
+
+- [ ] Generate a fitting icon via `/generate-image` (HCTI)
+- [ ] Place at `assets/icon.png`; wire into `Directory.Build.props`
+ (`PackageIcon` + a packed `` item)
+- [ ] Re-pack to validate icon embeds
+
+---
+
+## NEXT (v0.1.x — additive, post-alpha)
+
+- Seed 50–100 corpus entries from sanitized real-world dogfood logs
+ (SPEC §14 workflow)
+- Expand verb tables as corpus surfaces real commands
+- Document the "consumer's algorithm" — given a `ParsedCommand`, here is
+ how a security gate walks it (likely a section in `SPEC.md` Appendix
+ or a separate `docs/CONSUMER_GUIDE.md`)
+- Performance sanity check (~1 ms typical) with a tiny BenchmarkDotNet
+ harness — only if anything in the daemon hot path complains
+
+## LATER (v0.2+ — out of v0.1 scope)
+
+- PowerShell parser (`PwshParser : IShellParser`) — first time we exercise
+ the multi-shell seam
+- Windows `cmd` parser
+- Source-mapping (line/column on AST nodes) — only if an IDE consumer
+ asks
+- Heredoc body extraction, process substitution, function definitions —
+ only if a real consumer need surfaces
+
+## Parked
+
+*(empty; move items here when scope changes rather than deleting them)*
diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md
new file mode 100644
index 0000000..0c05623
--- /dev/null
+++ b/PROJECT_CONTEXT.md
@@ -0,0 +1,160 @@
+# Project Context — ShellSyntaxTree
+
+**Repository:** `Aaronontheweb/ShellSyntaxTree`
+**License:** Apache-2.0
+**Owner:** Aaron Stannard ([@Aaronontheweb](https://github.com/Aaronontheweb))
+
+## What ShellSyntaxTree Is
+
+ShellSyntaxTree is a focused .NET library that **parses shell command strings
+into a structured AST** for downstream policy / security gate evaluation. It
+is a *parser*, not an interpreter — it never executes, expands, or evaluates
+commands.
+
+The output is a `ParsedCommand` containing:
+
+- one or more `Clause` records (split on `&&`, `||`, `;`, `|`)
+- per-clause verb chain (multi-token verbs like `git push`, `docker compose up`)
+- per-clause args with `IsPath` classification, `Resolved` absolute path
+ when known, and explicit `DynamicSkip` marking for unresolved env vars
+ / unexpanded globs
+- redirect operators (`>`, `>>`, `<`, `2>`, `2>>`)
+- `cd && cmd` propagation — the cd target is attributed to subsequent
+ clauses in the same compound
+- recursion into `bash -c ""` so wrapped commands surface as clauses
+- subshell `( ... )` isolation for `cd` attribution
+- safe-fail flag `IsUnparseable` for unsupported constructs (control flow,
+ function definitions, process substitution, deep `bash -c` nesting,
+ unbalanced quotes/parens)
+
+The complete contract lives in [`SPEC.md`](./SPEC.md).
+
+## Who It's For
+
+**Primary consumer: [Netclaw](https://github.com/netclaw-dev/netclaw)** — an
+open-source autonomous operations agent. Netclaw's approval gate currently
+hand-rolls equivalent functionality in
+`src/Netclaw.Security/ShellApprovalSemantics.cs` and `ShellTokenizer.cs`.
+ShellSyntaxTree v0.1 is intended to **replace that hand-rolled approximation**
+with a richer, structured AST that Netclaw's gate can walk directly.
+
+**Acceptance is tied to Netclaw integration** (see SPEC §17 #7-#8): the
+package must be consumable via `` and exercise at least
+one corpus entry through Netclaw's live matcher.
+
+**Secondary consumers:** any tool that needs to reason about the shape of
+agent-emitted shell commands without executing them — pre-commit hooks,
+audit pipelines, sandbox policy engines, IDE security extensions.
+
+## Why It Exists
+
+LLM-driven agents emit shell commands. Naive substring matching is unsafe
+(`rm /tmp/foo` vs `rm -rf $HOME/foo`); shelling out to `bash -n` is unsafe
+and doesn't yield AST nodes; tree-sitter-bash is overkill and ships native
+binaries. ShellSyntaxTree fills the middle: a hand-rolled, AOT-friendly,
+zero-native-deps .NET parser sized to what security gates actually need.
+
+## Scope Discipline
+
+### v0.1 (current)
+
+- Bash only. PowerShell and Windows `cmd` are deferred — but the
+ `IShellParser` seam is in place so consumers don't have to refactor.
+- Public API surface in SPEC §2 is **locked**. Internal changes are free.
+- Acceptance is the corpus contract (SPEC §13): every JSON entry parses to
+ its expected AST.
+
+### Explicit non-goals (v0.1)
+
+- Command execution.
+- Variable expansion of any kind (we **mark** dynamic tokens, never resolve
+ them; `$HOME` is the only exception).
+- Heredoc body extraction.
+- Process substitution `<(cmd)`, `>(cmd)`.
+- Function definitions, `for`/`while`/`case` control flow, arithmetic
+ expansion `$((...))`.
+- Performance optimization beyond "fast enough to invoke per shell call
+ without noticeable latency" (~1 ms typical).
+- Source-mapping (line/column for AST nodes — useful for IDEs, irrelevant
+ for security gates).
+
+### Versioning
+
+- `v0.1.0-alpha` — first publishable cut, bash-only.
+- `v0.1.x` — additive (more verb table entries, more corpus, bug fixes).
+- `v0.2.0` — first PowerShell parser implementation.
+- `v1.0.0` — at least one external consumer beyond Netclaw ships against it
+ without finding API gaps.
+
+## Architectural Constraints
+
+- **Public API in `SPEC.md` §2 is the contract.** Everything else is
+ `internal`. Renaming/removing public fields requires a major version bump.
+- **`IShellParser` is the multi-shell seam.** PowerShell and `cmd` parsers
+ must be addable without touching consumer code.
+- **No native dependencies.** AOT-trim friendly; ship a single managed
+ package.
+- **Multi-targeting**: `netstandard2.0` for broad consumer reach, `net8.0`
+ for modern runtimes, tests on `net10.0`.
+- **Security defaults bias**: when in doubt, mark `DynamicSkip` /
+ `IsUnparseable`. Consumers can always relax — they cannot retroactively
+ un-execute a command we falsely classified as safe.
+
+## Acceptance for v0.1.0-alpha
+
+Per SPEC §17, all of the following must be true:
+
+1. Public API matches SPEC §2 exactly. `dotnet pack` produces a
+ `ShellSyntaxTree.0.1.0-alpha.nupkg`.
+2. Every corpus entry in `tests/Corpus/bash/*.json` parses to its expected
+ AST. `dotnet test` runs them all and passes.
+3. Corpus has ≥ 105 entries spanning the SPEC §13 categories.
+4. PII audit scan over `tests/Corpus/bash/*.json` finds zero hits.
+5. PR validation runs on GitHub Actions and passes.
+6. Tagging `v0.1.0-alpha` triggers `publish_nuget.yml` and the package
+ appears on nuget.org.
+7. Netclaw consumes the package via `` and `IShellParser`
+ resolves at runtime in Netclaw's DI container.
+8. At least one Netclaw integration test exercises a real corpus entry
+ through Netclaw's matcher and produces the expected gate decision.
+
+## Key Pain Points and Risks
+
+- **Corpus PII**. Real-world bash corpus entries seed from agent dogfood
+ logs (`~/.netclaw/logs/daemon-*.log`) that contain usernames, repo paths,
+ channel/thread IDs. Sanitization is mandatory and gated by a CI scan
+ (SPEC §14). Shipping unsanitized PII is a release-blocker.
+- **Verb table drift**. `BashArity`, `FileVerbs`, `CwdVerbs`, and
+ `FlagsWithValue` are static data. Adding entries is cheap; *missing*
+ entries means the parser silently mis-classifies. The corpus is the
+ early-warning system.
+- **`bash -c` recursion depth**. Capped at 5 (SPEC §10). Going deeper
+ marks the deepest clause `IsUnparseable` so we don't blow the stack on
+ hostile inputs.
+- **Resolver assumptions**. `WorkingDirectory` defaults to the daemon's
+ cwd. If consumers pass the wrong cwd, relative-path attribution will
+ silently disagree with what bash would do at runtime. Document loudly.
+- **API surface lock**. v0.1 commits to the AST shape. Mistakes here cost
+ a major-version bump to fix.
+
+## Where Things Live
+
+| Concern | Location |
+|---|---|
+| Library source | `src/ShellSyntaxTree/` *(to be created)* |
+| Tests + corpus | `tests/ShellSyntaxTree.Tests/` *(to be created)* |
+| Corpus entries | `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` |
+| The contract | `SPEC.md` |
+| Active work plan | `IMPLEMENTATION_PLAN.md` |
+| Tooling inventory | `TOOLING.md` |
+| Agent constitution | `AGENTS.md` (and `CLAUDE.md`) |
+| CI | `.github/workflows/{pr_validation,publish_nuget}.yml` |
+| Build settings | `Directory.Build.props`, `Directory.Packages.props`, `global.json` |
+| Solution | `ShellSyntaxTree.slnx` |
+
+## Update Discipline
+
+This file is **mutable** but should change only when the project's purpose,
+audience, scope, or constraints actually shift. Day-to-day work tracking
+belongs in `IMPLEMENTATION_PLAN.md`. Tooling changes belong in `TOOLING.md`.
+The agent constitution (`AGENTS.md`) should rarely change.
diff --git a/README.md b/README.md
index 73b4795..b093a0c 100644
--- a/README.md
+++ b/README.md
@@ -1,70 +1,84 @@
-# build-system-template
-Akka.NET project build system template that provides standardized build and CI/CD configuration for all Akka.NET projects.
+# ShellSyntaxTree
-## Build System Overview
-This repository contains our standardized build system setup that can be used across all Akka.NET projects. Here are the key components and practices we follow:
+A focused .NET library that parses shell command strings into a structured AST.
+Purpose-built for **security gate evaluators** — tools that inspect agent-emitted
+shell commands and decide whether to allow, prompt for, or deny execution.
-### CI/CD Configuration
-We primarily use GitHub Actions for our CI/CD pipelines, but also maintain Azure DevOps pipeline examples. You can find the configuration examples in:
-- `.github/workflows/` - GitHub Actions pipeline examples
-- `.azuredevops/` - Azure DevOps pipeline examples
+ShellSyntaxTree is **not** a shell interpreter. It does not execute, expand,
+or evaluate commands. It returns an AST (verb chain, args with path
+classification, redirects, compound operators, `cd`-in-compound propagation,
+`bash -c` recursion) that consumers walk to make policy decisions.
-### SDK Version Management
-We use `global.json` to pin the .NET SDK version for both CI/CD environments and local development. This ensures consistent builds across all environments and developers.
+The original consumer is [Netclaw](https://github.com/netclaw-dev/netclaw)'s
+approval policy. Any tool that needs to reason about the shape of an
+agent-emitted shell command — without running it — is welcome to consume it.
-### .NET Tools
-We use local .NET tools to enhance our build and documentation process. The tools are configured in `.config/dotnet-tools.json` and include:
+## Status
-- [Incrementalist](https://github.com/petabridge/Incrementalist) (v1.0.0-beta4) - Used for determining which projects need to be rebuilt based on Git changes
-- [DocFx](https://dotnet.github.io/docfx/) (v2.78.3) - Used for generating documentation
+**v0.1 — pre-alpha.** Public API surface and behavior are specified in
+[`SPEC.md`](./SPEC.md). Implementation is in progress.
-To restore these tools in your local environment, run:
-```powershell
-dotnet tool restore
-```
+## Why not `tree-sitter-bash`?
+
+Native dependencies, AOT trim concerns, and IDE-grade fidelity we don't need.
+We want unsupported constructs to mark `IsUnparseable = true` so consumers
+route to safe-fail. See [`SPEC.md` Appendix B](./SPEC.md) for the full
+trade-off analysis.
-This command is automatically executed in our CI/CD pipelines (both GitHub Actions and Azure DevOps) to ensure tools are available during builds.
+## Public API surface (locked for v0.1)
-### Centralized Package and Build Management
-We utilize two key MSBuild files for centralized configuration:
+```csharp
+namespace ShellSyntaxTree;
-1. `Directory.Packages.props` - Implements [Central Package Version Management](https://learn.microsoft.com/nuget/consume-packages/Central-Package-Management) for consistent NuGet package versions across all projects in the solution.
+public interface IShellParser { ParsedCommand Parse(string command); }
+public sealed class BashParser : IShellParser { /* ... */ }
+public sealed record BashParserOptions { /* HomeDirectory, WorkingDirectory */ }
-2. `Directory.Build.props` - Defines common build properties, including:
- - Copyright and author information
- - Source linking configuration
- - NuGet package metadata
- - Common compiler settings
- - Target framework definitions
+public sealed record ParsedCommand { /* Source, Clauses, IsUnparseable, ... */ }
+public sealed record Clause { /* Operator, Verb, Args, Redirects, ... */ }
+public sealed record VerbChain { /* Tokens */ }
+public sealed record Arg { /* Raw, Resolved, Kind, IsPath, ... */ }
+public sealed record Redirect { /* Direction, Target */ }
-### Code Coverage Configuration
-The `coverlet.runsettings` file configures code coverage collection using Coverlet, with settings for:
-- Multiple coverage report formats (JSON, Cobertura, LCOV, TeamCity, OpenCover)
-- Test assembly exclusions
-- Source linking integration
-- Performance optimizations
+public enum ArgKind { Literal, EnvVar, Glob, Tilde, DynamicSkip }
+public enum RedirectDirection { In, Out, Append, ErrOut, ErrAppend }
+public enum CompoundOperator { None, AndIf, OrIf, Sequence, Pipe }
+```
+
+PowerShell and Windows `cmd` parsers are deferred to later versions; the
+`IShellParser` seam is already in place so consumers don't have to refactor
+when they ship.
-### Release Management
-Our release process is streamlined through:
-- `RELEASE_NOTES.md` - Contains version history and release notes
-- `build.ps1` - PowerShell script that processes release notes and updates version information
-- Supporting scripts in `/scripts`:
- - `bumpVersion.ps1` - Updates version numbers
- - `getReleaseNotes.ps1` - Parses release notes
+## Multi-targeting
-The build system primarily relies on standard `dotnet` CLI commands, with the PowerShell scripts mainly handling release note processing and version management.
+`netstandard2.0` for broad consumer compatibility, `net8.0` for modern
+runtimes. Tests target `net10.0`.
-### Solution Format
-We prefer the new `.slnx` XML-based solution format over the traditional `.sln` format. This requires .NET 9 SDK or later. The new format is more concise and easier to work with. You can migrate existing solutions using:
+## Repository layout
-```powershell
-dotnet sln migrate
```
+src/ShellSyntaxTree/ # library (TBD)
+tests/ShellSyntaxTree.Tests/ # xunit tests + corpus runner (TBD)
+tests/ShellSyntaxTree.Tests/Corpus/ # JSON test corpus (acceptance contract)
+SPEC.md # the implementation specification
+PROJECT_CONTEXT.md # what this is, who it serves
+TOOLING.md # available tooling and how to access it
+AGENTS.md / CLAUDE.md # agent operating constitution
+IMPLEMENTATION_PLAN.md # NOW / NEXT / LATER work tracker
+```
+
+## Building
+
+```bash
+dotnet tool restore
+dotnet build -c Release
+dotnet test -c Release
+dotnet pack -c Release -o ./bin/nuget
+```
+
+`global.json` pins the SDK version. Targeting requires .NET 10 SDK or later
+(`.slnx` solution format).
-For more information about the new `.slnx` format, see the [official announcement](https://devblogs.microsoft.com/dotnet/introducing-slnx-support-dotnet-cli/).
+## License
-## Getting Started
-1. Ensure you have the correct .NET SDK version installed (check `global.json`)
-2. Clone this repository
-3. Run `dotnet build` to verify the build system
-4. Customize the configuration files for your specific project needs
+[Apache-2.0](./LICENSE). Copyright © 2026 Aaron Stannard.
diff --git a/SPEC.md b/SPEC.md
new file mode 100644
index 0000000..0fd2e36
--- /dev/null
+++ b/SPEC.md
@@ -0,0 +1,1208 @@
+# ShellSyntaxTree — v0.1 Specification
+
+**Status:** Draft for v0.1. Approved decisions; implementation pending.
+**Audience:** Whoever (human or agent) implements ShellSyntaxTree v0.1.
+**Read this end-to-end before writing any code.**
+
+This document specifies the public API, AST, grammar, verb tables, resolver
+semantics, and corpus contract for ShellSyntaxTree v0.1. The library is a
+focused bash command parser designed for **security gate evaluators** —
+tools that inspect agent-emitted shell commands to decide whether to allow,
+prompt for, or deny execution.
+
+It is **not** a general-purpose shell interpreter. It does not execute,
+expand, or evaluate commands. It returns a structured AST that consumers
+walk to make decisions.
+
+The original consumer is [Netclaw](https://github.com/Aaronontheweb/netclaw)'s
+approval policy. The library is designed to be reusable beyond Netclaw —
+any tool that needs to reason about the shape of an agent-emitted bash
+command can consume it.
+
+---
+
+## 1. Goals & Non-Goals
+
+### Goals (v0.1)
+
+1. **Parse bash commands into a structured AST** with per-clause verbs,
+ args, redirects, and compound operators.
+2. **Extract paths a command operates on** with per-verb knowledge of which
+ positional args are paths vs flags vs literal values (`chmod 755 file`
+ knows `755` is a mode).
+3. **Honor `cd && cmd` propagation** within a compound — ``
+ counts as a path each subsequent command operates on.
+4. **Recurse into `bash -c ""`** so the inner command is parsed and
+ its clauses surface to the consumer.
+5. **Mark dynamic-content tokens** (unresolved `$VAR`, unexpanded globs)
+ so consumers don't misextract literal `$VAR/foo` as a path.
+6. **Multi-shell-ready** via `IShellParser` interface — bash is the only
+ v0.1 implementation; PowerShell and cmd are deferred to later versions
+ without breaking the seam.
+
+### Non-Goals (v0.1)
+
+- PowerShell parsing (deferred; interface seam is present).
+- Windows cmd parsing (deferred).
+- Command execution. The library never runs anything.
+- Variable expansion. We mark dynamic tokens, never resolve them.
+- Function definitions, here-docs body extraction, complex parameter
+ expansion (`${var//pattern/replacement}`), arithmetic expansion.
+- Performance tuning beyond "fast enough to invoke per shell call without
+ noticeable latency" (~1ms per typical input).
+
+---
+
+## 2. Public API Surface
+
+The package exposes a small surface from a single namespace
+`ShellSyntaxTree`. **Public types only**:
+
+```csharp
+namespace ShellSyntaxTree;
+
+///
+/// Parses shell command strings into structured ASTs.
+///
+public interface IShellParser
+{
+ ///
+ /// Parse the command. Always returns a ParsedCommand; sets
+ /// when the input cannot
+ /// be tokenized (unbalanced quotes, etc.). Never throws on
+ /// well-formed strings; throws ArgumentNullException on null input.
+ ///
+ ParsedCommand Parse(string command);
+}
+
+/// Bash implementation of IShellParser.
+public sealed class BashParser : IShellParser
+{
+ public BashParser();
+ public BashParser(BashParserOptions options);
+ public ParsedCommand Parse(string command);
+}
+
+/// Configuration knobs for BashParser.
+public sealed record BashParserOptions
+{
+ ///
+ /// User home directory used to expand `~` and `$HOME` tokens during
+ /// resolution. Defaults to .
+ ///
+ public string? HomeDirectory { get; init; }
+
+ ///
+ /// Working directory used to resolve relative path tokens during
+ /// resolution. Defaults to the daemon-process cwd.
+ ///
+ public string? WorkingDirectory { get; init; }
+}
+
+// AST records — see §3.
+public sealed record ParsedCommand { ... }
+public sealed record Clause { ... }
+public sealed record VerbChain { ... }
+public sealed record Arg { ... }
+public sealed record Redirect { ... }
+public enum ArgKind { Literal, EnvVar, Glob, Tilde, DynamicSkip }
+public enum RedirectDirection { In, Out, Append, ErrOut, ErrAppend }
+public enum CompoundOperator { None, AndIf, OrIf, Sequence, Pipe }
+```
+
+That's the entire public API. **Everything else is internal.** The lexer,
+parser internals, verb tables, resolver — all implementation detail.
+
+---
+
+## 3. AST Reference
+
+### `ParsedCommand`
+
+The top-level result of parsing. Always returned (never null).
+
+```csharp
+public sealed record ParsedCommand
+{
+ /// The original input string, verbatim.
+ public string Source { get; init; } = "";
+
+ ///
+ /// Top-level clauses, split on compound operators (&&, ||, ;, |).
+ /// For a simple command, exactly one clause with Operator=None.
+ ///
+ public IReadOnlyList Clauses { get; init; } = [];
+
+ ///
+ /// True when the parser could not produce a clean AST (unbalanced
+ /// quotes, unparseable construct). When true, Clauses MAY be partial
+ /// or empty. Consumers should route to safe-fail.
+ ///
+ public bool IsUnparseable { get; init; }
+
+ ///
+ /// Human-readable diagnostic when IsUnparseable=true; null otherwise.
+ ///
+ public string? UnparseableReason { get; init; }
+}
+```
+
+### `Clause`
+
+One logical command within a compound. Each clause has its own verb chain,
+args, redirects, and the operator that joined it to the previous clause.
+
+```csharp
+public sealed record Clause
+{
+ ///
+ /// The operator joining this clause to the previous one. The first
+ /// clause in a ParsedCommand has Operator=None. Subsequent clauses
+ /// carry the operator that preceded them in the source
+ /// (e.g. `a && b` produces clauses [{None,a}, {AndIf,b}]).
+ ///
+ public CompoundOperator Operator { get; init; }
+
+ /// The verb chain (see §3.3 and §6).
+ public VerbChain Verb { get; init; } = new();
+
+ ///
+ /// All argument tokens after the verb chain, in source order. Includes
+ /// flags and positional args. See for token kind.
+ ///
+ public IReadOnlyList Args { get; init; } = [];
+
+ ///
+ /// Redirect operators on this clause (>, >>, <, 2>, 2>>). Each entry
+ /// includes direction and target path.
+ ///
+ public IReadOnlyList Redirects { get; init; } = [];
+
+ ///
+ /// True when this clause is wrapped in a subshell (parens). Subshells
+ /// isolate cd state — see §9.
+ ///
+ public bool IsSubshell { get; init; }
+
+ ///
+ /// True when this clause is the result of recursing into a `bash -c`
+ /// or `sh -c` wrapper. Useful for consumers that want to surface
+ /// "this came from a wrapped invocation" in UI.
+ ///
+ public bool IsBashCWrapped { get; init; }
+}
+```
+
+### `VerbChain`
+
+The verb of a clause. Multi-token to handle commands like `git push`,
+`docker compose up`, `bun run`, `dotnet test`. Length determined by the
+`BashArity` table (see §6).
+
+```csharp
+public sealed record VerbChain
+{
+ ///
+ /// Verb tokens in source order. Empty when the clause has no verb
+ /// (e.g. clause is just a redirect or an empty fragment).
+ ///
+ public IReadOnlyList Tokens { get; init; } = [];
+
+ /// Convenience: tokens joined with spaces.
+ public string Joined => string.Join(' ', Tokens);
+}
+```
+
+### `Arg`
+
+One argument token after the verb chain. Includes resolution state.
+
+```csharp
+public sealed record Arg
+{
+ /// Verbatim token from the source.
+ public string Raw { get; init; } = "";
+
+ ///
+ /// Resolved value for path tokens — tilde expanded, env vars
+ /// substituted, normalized to absolute path against
+ /// BashParserOptions.WorkingDirectory. Null when Kind is not a path
+ /// (Literal non-path / Glob / DynamicSkip).
+ ///
+ public string? Resolved { get; init; }
+
+ /// Token kind. See .
+ public ArgKind Kind { get; init; }
+
+ ///
+ /// True when this token starts with '-' or '--' (a flag, not a
+ /// positional arg).
+ ///
+ public bool IsFlag => Raw.StartsWith('-');
+
+ ///
+ /// True when this token is a path the clause operates on (per the
+ /// per-verb pathArgs table; see §7). Set during parsing so consumers
+ /// don't reapply per-verb rules.
+ ///
+ public bool IsPath { get; init; }
+}
+
+public enum ArgKind
+{
+ /// Literal value (string, number, flag).
+ Literal,
+ /// Token containing an unresolved env var reference.
+ EnvVar,
+ /// Token containing glob metachars (* ? [).
+ Glob,
+ /// Token starting with ~ (tilde).
+ Tilde,
+ ///
+ /// Token whose value cannot be safely resolved (unresolved env var,
+ /// unexpandable glob). Consumers SHALL treat as "no value extracted"
+ /// rather than using Raw as a literal path.
+ ///
+ DynamicSkip
+}
+```
+
+### `Redirect`
+
+```csharp
+public sealed record Redirect
+{
+ public RedirectDirection Direction { get; init; }
+ /// Target path (resolved per Arg conventions).
+ public string Target { get; init; } = "";
+ /// True when target is a dynamic token (skip).
+ public bool IsDynamicSkip { get; init; }
+}
+
+public enum RedirectDirection
+{
+ In, // <
+ Out, // >
+ Append, // >>
+ ErrOut, // 2>
+ ErrAppend // 2>>
+}
+```
+
+### `CompoundOperator`
+
+```csharp
+public enum CompoundOperator
+{
+ None, // first clause; no prior operator
+ AndIf, // &&
+ OrIf, // ||
+ Sequence, // ;
+ Pipe // |
+}
+```
+
+---
+
+## 4. Grammar
+
+Approximate BNF for what the parser accepts. Anything outside this grammar
+is unparseable (`ParsedCommand.IsUnparseable = true`).
+
+```
+command := clause (compound_op clause)*
+compound_op := "&&" | "||" | ";" | "|"
+clause := subshell | bash_c_wrapper | simple_clause
+subshell := "(" command ")"
+bash_c_wrapper := ("bash" | "sh") "-c" QUOTED_STRING
+simple_clause := verb_chain arg* redirect*
+verb_chain := word{1..N} // N = BashArity[word_0]
+arg := word | flag | quoted_string
+flag := "-" letter+ | "--" word
+redirect := redirect_op target
+redirect_op := ">" | ">>" | "<" | "2>" | "2>>"
+target := word | quoted_string
+word := non-whitespace, non-operator characters
+quoted_string := single-quoted | double-quoted
+```
+
+**Notes:**
+
+- Whitespace between tokens is one or more spaces or tabs.
+- `\` followed by a newline is a line continuation (treat as whitespace).
+- `\` before a metachar inside a double-quoted string escapes the metachar.
+- Single-quoted strings preserve all bytes literally — no escape processing.
+- Heredocs (`<`, `>>`, `<`, `2>`, `2>>`,
+ `(`, `)`, `<<`.
+- **WHITESPACE** — one or more spaces or tabs. Discarded after splitting.
+- **CONTINUATION** — `\` + `\n`. Treated as whitespace.
+
+### Quote handling
+
+- **Single quotes** `'...'` preserve bytes literally. No escape processing,
+ no variable expansion. Anything inside is one token.
+- **Double quotes** `"..."` preserve whitespace but allow:
+ - `\"` escapes the closing quote.
+ - `\\` escapes a backslash.
+ - `\$` escapes a dollar sign.
+ - `$VAR` and `${VAR}` are recognized as env var references but **not
+ expanded** — the token is marked `ArgKind.EnvVar` (or `DynamicSkip`
+ if resolution would be required for path classification).
+- Unbalanced quotes → `IsUnparseable = true` with reason
+ `"unbalanced quote at position N"`.
+
+### Escape handling
+
+- `\X` outside quotes: removes the backslash, takes X literally. Example:
+ `echo \$HOME` produces token `$HOME` with `ArgKind.Literal`.
+- `\X` inside double quotes: only `\"`, `\\`, `\$`, `\\`, and `\\`+newline
+ are recognized escape sequences. Other backslashes preserved literally.
+
+### Operator boundaries
+
+Operators terminate the current token. `cd /tmp&&ls` lexes as
+`[cd, /tmp, &&, ls]` — no whitespace required around operators. The lexer
+must handle this.
+
+---
+
+## 6. Verb Tables
+
+These are **data**, not logic. Implement as `static readonly` collections.
+
+### 6.1 BashArity
+
+How many tokens form the verb chain for known commands. Defaults to 1
+when not in the table.
+
+```csharp
+internal static readonly IReadOnlyDictionary BashArity =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+{
+ // Two-token verbs
+ ["git"] = 2, // git push, git log, git checkout
+ ["dotnet"] = 2, // dotnet test, dotnet build
+ ["npm"] = 2, // npm install, npm run
+ ["yarn"] = 2, // yarn add, yarn install
+ ["pnpm"] = 2, // pnpm add, pnpm install
+ ["cargo"] = 2, // cargo build, cargo test
+ ["go"] = 2, // go build, go test, go run
+ ["kubectl"] = 2, // kubectl apply, kubectl get
+ ["helm"] = 2, // helm install, helm upgrade
+ ["systemctl"] = 2, // systemctl start, systemctl status
+ ["service"] = 2, // service nginx
+ ["pip"] = 2, // pip install, pip uninstall
+ ["pip3"] = 2, // pip3 install
+ ["brew"] = 2, // brew install, brew upgrade
+ ["apt"] = 2, // apt install, apt update
+ ["apt-get"] = 2, // apt-get install
+ ["yum"] = 2, // yum install
+ ["dnf"] = 2, // dnf install
+ ["pacman"] = 2, // pacman -S, pacman -Syu (the -S is the verb)
+ ["aws"] = 2, // aws s3, aws ec2 (top-level)
+ ["gcloud"] = 2, // gcloud compute, gcloud auth
+ ["az"] = 2, // az vm, az storage
+
+ // Three-token verbs
+ ["docker"] = 2, // docker run, docker ps; "docker compose up" handled below
+ ["docker compose"] = 3, // docker compose up, docker compose down
+ ["docker-compose"] = 2, // legacy single-token form
+ ["bun"] = 2, // bun run, bun install
+ ["bun run"] = 3, // bun run my-script (treat as 3-tuple verb)
+ ["nuget"] = 2, // nuget push, nuget pack
+};
+```
+
+The table is not exhaustive. Missing verbs default to 1 token. Add entries
+as the corpus surfaces real commands.
+
+**Implementation note:** The parser must look up multi-token verbs by
+joining the first 1, 2, then 3 tokens and probing the table from longest
+to shortest. So `docker compose up nginx` first probes `docker compose up`
+(not in table; arity defaults wouldn't fire), then probes `docker compose`
+(in table → arity 3) → verb chain is the first 3 tokens.
+
+### 6.2 CWD verbs
+
+Verbs whose first non-flag positional arg becomes the cwd for subsequent
+clauses in the same compound (see §9).
+
+```csharp
+internal static readonly HashSet CwdVerbs =
+ new(StringComparer.OrdinalIgnoreCase)
+{
+ "cd", "chdir", "popd", "pushd",
+ "push-location", "set-location" // PowerShell idioms (forward-compat)
+};
+```
+
+### 6.3 FILE verbs
+
+Verbs whose positional args are paths. The default extraction rule is "all
+non-flag positional args after the verb chain are paths." Per-verb overrides
+in §7.
+
+```csharp
+internal static readonly HashSet FileVerbs =
+ new(StringComparer.OrdinalIgnoreCase)
+{
+ // CWD verbs are also FILE verbs (their target is a path)
+ "cd", "chdir", "popd", "pushd", "push-location", "set-location",
+ // File mutation
+ "rm", "cp", "mv", "mkdir", "rmdir", "touch", "ln",
+ "chmod", "chown", "chgrp", "stat", "test",
+ // Read
+ "cat", "less", "more", "head", "tail", "grep", "rg",
+ "find", "fd", "locate", "wc", "file",
+ // Editors / text tools
+ "sed", "awk", "vi", "vim", "nano", "emacs", "ed",
+ // Compression
+ "tar", "zip", "unzip", "gzip", "gunzip", "bzip2", "xz",
+ // Network with file targets
+ "curl", "wget", "scp", "rsync", "sftp",
+ // Shell / interpreter loaders
+ "bash", "sh", "zsh", "fish",
+ "python", "python3", "node", "ruby", "perl", "php",
+ // Diff / patch
+ "diff", "patch", "cmp",
+ // Listing
+ "ls", "dir", "tree",
+};
+```
+
+### 6.4 CMD_FILE verbs (Windows cmd; deferred for v0.1 implementation)
+
+Reserved for future PowerShell/cmd parser support. Document the table
+shape now so the seam is clear.
+
+```csharp
+internal static readonly HashSet CmdFileVerbs =
+ new(StringComparer.OrdinalIgnoreCase)
+{
+ "type", "copy", "move", "del", "erase", "ren", "ren",
+ "xcopy", "robocopy", "findstr",
+ // PowerShell cmdlets
+ "get-content", "set-content", "remove-item", "copy-item",
+ "move-item", "new-item",
+};
+```
+
+---
+
+## 7. Per-Verb Path-Arg Extraction Rules
+
+The default rule for FILE verbs: every non-flag positional arg after the
+verb chain is a path. Per-verb overrides:
+
+| Verb | Rule |
+|---|---|
+| `chmod` | First non-flag positional is **mode** (e.g. `755`, `+x`); rest are paths. |
+| `chown` | First non-flag positional is **user[:group]**; rest are paths. |
+| `chgrp` | First non-flag positional is **group**; rest are paths. |
+| `ln` | All positionals are paths (source then target). |
+| `find` | First positional is a path; rest are predicate args (skip). |
+| `grep` | First positional is **pattern**; rest are paths. |
+| `rg` | First positional is **pattern**; rest are paths. |
+| `sed` | First positional is **script**; rest are paths. |
+| `awk` | First positional is **program**; rest are paths. |
+| `tar` | Action flag determines path roles; default to extracting all non-flag positionals as paths. |
+| `curl`, `wget` | First positional is **URL**, not a path. `-o file` flag arg is a path. |
+| `scp`, `rsync`, `sftp` | All positionals are paths (some remote). |
+| `cd`, `chdir`, `pushd`, `popd` | First non-flag positional is the cwd target (a path). |
+| Others (in FileVerbs, no override) | All non-flag positionals are paths. |
+
+### Flag-with-value handling
+
+Some flags take values (`-o file`, `-C /repo`, `--output=file`). The parser
+must know which flags consume the next token as a value. Curated table:
+
+```csharp
+internal static readonly IReadOnlyDictionary>
+ FlagsWithValue = new Dictionary>(
+ StringComparer.OrdinalIgnoreCase)
+{
+ ["git"] = new HashSet(StringComparer.OrdinalIgnoreCase) { "-C", "--git-dir", "--work-tree" },
+ ["curl"] = new HashSet(StringComparer.OrdinalIgnoreCase) { "-o", "--output", "-d", "--data" },
+ ["wget"] = new HashSet(StringComparer.OrdinalIgnoreCase) { "-O", "--output-document" },
+ ["docker"]= new HashSet(StringComparer.OrdinalIgnoreCase) { "-v", "--volume", "-f", "--file" },
+ ["tar"] = new HashSet(StringComparer.OrdinalIgnoreCase) { "-f", "--file", "-C", "--directory" },
+ // Add as corpus surfaces real cases.
+};
+```
+
+When a flag-with-value consumes the next token, the consumed token's
+`IsPath` flag is set if the value is path-shaped (per the resolver in §8).
+For `git -C /repo log`: the `-C` flag consumes `/repo`, marks it as a
+path, then the verb chain continues with `log`.
+
+`--output=file` (equals form) is parsed as one token; the path value after
+`=` is extracted into a synthetic Arg with `IsPath=true`.
+
+---
+
+## 8. Resolver
+
+For each Arg with potential path content, the resolver attempts to produce
+a normalized absolute path. Resolution order:
+
+1. **Tilde expansion.** `~` → `BashParserOptions.HomeDirectory`.
+ `~/foo` → `/foo`. `~user` not supported → `DynamicSkip`.
+
+2. **Env-var substitution.** `$VAR` and `${VAR}` are **not expanded**
+ even if the value is in `Environment`. We treat any env var reference
+ as `DynamicSkip` because the env var available at parse time may
+ differ from what's available when the agent's command actually runs.
+ `$HOME` is the **only** exception — we treat it as equivalent to `~`
+ and expand it from `BashParserOptions.HomeDirectory`.
+
+3. **`filesystem::/path` prefix stripping.** Some tools emit
+ `filesystem::/path/to/file`; strip the prefix. Become `/path/to/file`.
+
+4. **Glob detection.** Tokens containing `*`, `?`, or `[` are marked
+ `ArgKind.Glob`. The resolver does **not** expand globs. The token
+ stays as-is in `Raw`; `Resolved` is null. Consumers that want the
+ glob's "covering directory" can use `Path.GetDirectoryName(Raw)` to
+ approximate.
+
+5. **Relative path resolution.** Tokens not starting with `/` (or `\\` on
+ Windows) are joined to `BashParserOptions.WorkingDirectory`. If
+ `WorkingDirectory` is null, the token stays relative and `Resolved`
+ is null with `Kind = DynamicSkip`.
+
+6. **Dynamic-skip predicates.** A token is `DynamicSkip` when:
+ - It contains an unresolved env var reference (other than `$HOME`).
+ - It contains glob metachars AND the resolver was asked for an
+ absolute resolution.
+ - Resolution throws an `IOException` or path-format exception.
+
+ `DynamicSkip` tokens have `Resolved = null`. Consumers must not use
+ `Raw` as a literal path.
+
+### Path-shape heuristic
+
+When deciding whether a token "looks like a path" (used to decide whether
+to apply the resolver):
+
+```
+LooksLikePath(token) =
+ token starts with '/' (Unix absolute)
+|| token starts with '\\' or ':' (Windows absolute)
+|| token starts with './' or '../' (Unix relative)
+|| token starts with '~' (Tilde)
+|| token contains '/' or '\\' anywhere
+|| token ends with a known file extension (.json, .md, .txt, .conf, ...)
+|| token is in the args of a FileVerb at a position the per-verb rule
+ marks as a path
+```
+
+The per-verb rule wins when present; the heuristic is the fallback.
+
+---
+
+## 9. cd-in-Compound Propagation
+
+The agent's natural idiom is `cd /target && cmd1 && cmd2`. Bash semantics:
+`cmd1` and `cmd2` execute with cwd `/target`. The parser honors this for
+**path attribution within the same compound**.
+
+### Rules
+
+1. **First clause is a `cd`-family verb** (per §6.2): the cd target becomes
+ the **attributed cwd** for subsequent clauses in the same compound.
+2. **Subsequent clauses inherit the attributed cwd** as if it were
+ prepended with `-C` semantics. Specifically: a synthetic `Arg` with
+ `IsPath=true`, `Resolved=`, and `Kind=Literal` is added to
+ each subsequent clause's `Args` list at the **end**, marked with a flag
+ `IsCwdAttribution=true` so consumers can distinguish it from
+ user-emitted args.
+
+ *(Add `IsCwdAttribution: bool` to the `Arg` record. Default false.)*
+
+3. **A subsequent `cd`** in the same compound **replaces** the attributed
+ cwd for clauses after it. (`cd /a && cmd1 && cd /b && cmd2` → cmd1
+ inherits `/a`, cmd2 inherits `/b`.)
+
+4. **Subshell boundaries reset attribution.** `cd /a && (cd /b && cmd1) && cmd2`:
+ cmd1 (inside subshell) inherits `/b`; cmd2 (outside subshell) inherits
+ `/a` (the subshell's `cd /b` does not leak out).
+
+5. **Attribution does not change the clause's verb or original args.**
+ The attribution is purely additive — the `cd` clause itself is still
+ parsed normally, and subsequent clauses retain everything the user
+ typed, plus the synthetic Arg.
+
+### Example
+
+Input: `cd /target && git -C /other log && cat file.txt`
+
+Parsed clauses:
+
+```
+Clause 0: Operator=None, Verb=[cd], Args=[/target]
+Clause 1: Operator=AndIf, Verb=[git, log],
+ Args=[
+ Arg{Raw="-C",IsFlag=true},
+ Arg{Raw="/other",IsPath=true,Resolved="/other"},
+ Arg{Raw="/target",IsPath=true,Resolved="/target",IsCwdAttribution=true}
+ ]
+Clause 2: Operator=AndIf, Verb=[cat],
+ Args=[
+ Arg{Raw="file.txt",IsPath=true,Resolved="/target/file.txt"},
+ Arg{Raw="/target",IsPath=true,Resolved="/target",IsCwdAttribution=true}
+ ]
+```
+
+Note: `file.txt` in clause 2 resolves against the attributed cwd
+`/target` to produce `/target/file.txt`. The attributed-cwd Arg is also
+appended for completeness, even though the resolver already used it.
+
+Consumers can choose to ignore `IsCwdAttribution=true` args if they
+already see the resolved path in another arg.
+
+---
+
+## 10. Subshell & `bash -c` Recursion
+
+### Subshells
+
+Subshells are clauses wrapped in parens: `(cd /a && cmd)`. The parser
+recognizes the parens, parses the inner command, and emits a single
+clause with:
+
+- `Verb` = empty (a subshell has no verb of its own)
+- `Args` = empty
+- `IsSubshell = true`
+- A nested `ParsedCommand` field — **but** since the AST should be flat
+ for consumer convenience, instead we **flatten** the subshell into the
+ parent's `Clauses` list with each inner clause's `IsSubshell=true`.
+
+Specifically: `(cd /b && cmd) && cmd2` produces three clauses:
+
+```
+Clause 0: Op=None, Verb=cd, Args=[/b], IsSubshell=true
+Clause 1: Op=AndIf, Verb=cmd, Args=[/b attribution], IsSubshell=true
+Clause 2: Op=AndIf, Verb=cmd2, Args=[] // no /b attribution — subshell isolated
+```
+
+### `bash -c` Recursion
+
+`bash -c "inner command"` and `sh -c "inner command"` are common wrappers
+the agent emits. The parser:
+
+1. Recognizes the `bash -c` or `sh -c` prefix.
+2. Parses the quoted argument as a fresh `ParsedCommand`.
+3. Surfaces the inner command's clauses inline in the outer's `Clauses`
+ list, each with `IsBashCWrapped=true`.
+
+Example: `bash -c "cd /a && cmd"` produces:
+
+```
+Clause 0: Op=None, Verb=cd, Args=[/a], IsBashCWrapped=true
+Clause 1: Op=AndIf, Verb=cmd, Args=[/a attribution], IsBashCWrapped=true
+```
+
+The outer `bash -c` itself does not appear as a clause — it's "consumed"
+by the recursion. Consumers that care that this came from a wrapper can
+inspect `IsBashCWrapped` on the surfaced clauses.
+
+**Recursion limit:** parse `bash -c "bash -c ..."` chains up to depth 5.
+Deeper nesting → mark the deepest clause as `IsUnparseable = true`.
+
+---
+
+## 11. Parser Anomaly Behavior
+
+When the parser cannot produce a clean AST:
+
+1. **Set `ParsedCommand.IsUnparseable = true`.**
+2. **Set `UnparseableReason`** to a human-readable diagnostic.
+3. **Return whatever clauses were successfully parsed** in `Clauses`. May
+ be empty.
+4. **Never throw** on well-formed input strings (only throw on null).
+
+Conditions that produce `IsUnparseable = true`:
+
+- Unbalanced quotes (`"foo` with no closing `"`).
+- Unbalanced parens (`(cmd && cmd2`).
+- Unrecognized control-flow keywords (`for`, `while`, `do`, `done`,
+ `then`, `fi`, `case`, `esac`).
+- Function definitions (`name() { ... }`).
+- Process substitution (`<(cmd)`, `>(cmd)`).
+- Recursion depth exceeded on `bash -c` chains.
+
+Consumers (e.g. Netclaw's gate evaluator) route unparseable commands to a
+safe-fail path (prompt the user; offer only Once and Deny — no persistent
+grants on shapes the parser can't model).
+
+---
+
+## 12. Public Examples
+
+A handful of input/expected-AST pairs to anchor understanding. These belong
+in the corpus (§13) verbatim.
+
+### Simple verb
+
+Input: `ls -la /tmp`
+
+```
+ParsedCommand {
+ Source = "ls -la /tmp",
+ IsUnparseable = false,
+ Clauses = [
+ Clause {
+ Operator = None,
+ Verb = VerbChain { Tokens = ["ls"] },
+ Args = [
+ Arg { Raw = "-la", IsFlag = true, Kind = Literal },
+ Arg { Raw = "/tmp", IsPath = true, Resolved = "/tmp", Kind = Literal }
+ ],
+ Redirects = [],
+ IsSubshell = false,
+ IsBashCWrapped = false
+ }
+ ]
+}
+```
+
+### Multi-token verb
+
+Input: `git push origin main`
+
+```
+Clauses = [
+ Clause {
+ Verb = VerbChain { Tokens = ["git", "push"] },
+ Args = [
+ Arg { Raw = "origin", Kind = Literal, IsPath = false },
+ Arg { Raw = "main", Kind = Literal, IsPath = false }
+ ]
+ }
+]
+```
+
+### Compound with cd attribution
+
+Input: `cd /target && cmd1 && cmd2 file.txt`
+
+```
+Clauses = [
+ Clause { Verb = [cd], Args = [/target attributed-as-path], Op = None },
+ Clause {
+ Verb = [cmd1], Op = AndIf,
+ Args = [Arg { Raw = "/target", Resolved = "/target",
+ IsPath = true, IsCwdAttribution = true }]
+ },
+ Clause {
+ Verb = [cmd2], Op = AndIf,
+ Args = [
+ Arg { Raw = "file.txt", Resolved = "/target/file.txt", IsPath = true },
+ Arg { Raw = "/target", Resolved = "/target",
+ IsPath = true, IsCwdAttribution = true }
+ ]
+ }
+]
+```
+
+### `git -C` flag-with-value
+
+Input: `git -C /repo log`
+
+```
+Clauses = [
+ Clause {
+ Verb = VerbChain { Tokens = ["git", "log"] },
+ Args = [
+ Arg { Raw = "-C", IsFlag = true },
+ Arg { Raw = "/repo", IsPath = true, Resolved = "/repo" }
+ ]
+ }
+]
+```
+
+### Redirect
+
+Input: `cmd > /tmp/out.txt`
+
+```
+Clauses = [
+ Clause {
+ Verb = [cmd],
+ Args = [],
+ Redirects = [Redirect { Direction = Out, Target = "/tmp/out.txt" }]
+ }
+]
+```
+
+### Subshell isolation
+
+Input: `cd /a && (cd /b && cmd1) && cmd2`
+
+```
+Clauses = [
+ Clause { Verb = [cd], Args = [/a], Op = None },
+ Clause { Verb = [cd], Args = [/b], Op = AndIf, IsSubshell = true,
+ Args = [/a attribution from outer compound] },
+ Clause { Verb = [cmd1], Op = AndIf, IsSubshell = true,
+ Args = [/b attribution — local to subshell] },
+ Clause { Verb = [cmd2], Op = AndIf,
+ Args = [/a attribution — inherited from outer cd, NOT /b] }
+]
+```
+
+### Dynamic skip
+
+Input: `rm $UNRESOLVED/foo`
+
+```
+Clauses = [
+ Clause {
+ Verb = [rm],
+ Args = [
+ Arg { Raw = "$UNRESOLVED/foo", Kind = DynamicSkip, IsPath = false,
+ Resolved = null }
+ ]
+ }
+]
+```
+
+Consumer impact: zone-gate sees zero paths to evaluate; routes to the
+fallback "treat as one untrusted path = the raw token" prompt.
+
+### Unparseable
+
+Input: `for i in 1 2; do echo $i; done`
+
+```
+ParsedCommand {
+ Source = "for i in 1 2; do echo $i; done",
+ IsUnparseable = true,
+ UnparseableReason = "control-flow keyword 'for' is not supported in v0.1",
+ Clauses = [] // or partial; consumer should not rely on contents
+}
+```
+
+---
+
+## 13. Test Corpus Contract
+
+The corpus is the **acceptance contract** for the parser. Implementation is
+"done" when every corpus entry parses to its expected AST.
+
+### Location
+
+`tests/Corpus/bash/*.json` — one file per corpus entry. File name pattern:
+`NN_descriptive_slug.json` where NN is a zero-padded sequence number.
+
+### Format
+
+Each file:
+
+```json
+{
+ "name": "Multi-token verb: git push",
+ "input": "git push origin main",
+ "expected": {
+ "isUnparseable": false,
+ "clauses": [
+ {
+ "operator": "None",
+ "verb": ["git", "push"],
+ "args": [
+ { "raw": "origin", "kind": "Literal", "isPath": false },
+ { "raw": "main", "kind": "Literal", "isPath": false }
+ ],
+ "redirects": [],
+ "isSubshell": false,
+ "isBashCWrapped": false
+ }
+ ]
+ },
+ "notes": "Optional explanation of edge case being captured."
+}
+```
+
+### Coverage targets for v0.1
+
+Author at least:
+
+- 10 simple-verb cases (ls, pwd, echo, cat, grep, etc.)
+- 10 multi-token-verb cases (git push, dotnet test, docker compose up, etc.)
+- 15 compound cases (`&&`, `||`, `;`, `|` combinations)
+- 10 `cd`-in-compound propagation cases (single, sequential, with subshell)
+- 10 quote-handling cases (single, double, escaped, mixed)
+- 10 redirect cases (`>`, `>>`, `<`, `2>`, `2>>`, multiple redirects)
+- 10 subshell cases (with and without isolation effects)
+- 10 `bash -c` recursion cases (depth 1, 2, with inner compounds)
+- 10 dynamic-skip cases (`$VAR`, `${VAR}`, `~user`, glob args)
+- 10 per-verb path-rule cases (chmod, chown, find, grep, curl, git -C, etc.)
+- 10 unparseable cases (unbalanced quotes, control-flow keywords, function
+ definitions)
+
+**Total minimum: 105 entries.** Strive for 150+ once seeded from sanitized
+real-world commands (see §14).
+
+### Test runner
+
+A single xunit test method enumerates `tests/Corpus/bash/*.json`, parses
+each `input`, and asserts the result matches `expected` field-by-field.
+The runner emits a per-corpus-entry test name so failures point at the
+specific case.
+
+```csharp
+[Theory]
+[MemberData(nameof(CorpusEntries))]
+public void Corpus_entry_parses_to_expected_ast(CorpusEntry entry)
+{
+ var parser = new BashParser();
+ var actual = parser.Parse(entry.Input);
+ AstAssert.Equal(entry.Expected, actual); // structural equality
+}
+
+public static IEnumerable