diff --git a/.claude/skills b/.claude/skills deleted file mode 120000 index 2b7a412..0000000 --- a/.claude/skills +++ /dev/null @@ -1 +0,0 @@ -../.agents/skills \ No newline at end of file diff --git a/.github/workflows/skills.yml b/.github/workflows/skills.yml new file mode 100644 index 0000000..d73b252 --- /dev/null +++ b/.github/workflows/skills.yml @@ -0,0 +1,29 @@ +name: Skills + +on: + push: + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Install loadout + run: cargo install --git https://github.com/pentaxis93/loadout.git --tag v0.4.0 loadout + + - name: Verify skills manifest + run: ./scripts/skills_verify.sh + + - name: Smoke test loadout pilot + run: ./tests/loadout_pilot_smoke.sh + + - name: Smoke test gitignore rules + run: ./tests/gitignore_skill_targets_smoke.sh + + - name: Run workspace tests + run: cargo test --workspace diff --git a/.gitignore b/.gitignore index ea8c4bf..86ba6f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /target +/.agents/skills +/.claude/skills +/.opencode/skills diff --git a/.loadout/agentd.toml b/.loadout/agentd.toml new file mode 100644 index 0000000..86b1e22 --- /dev/null +++ b/.loadout/agentd.toml @@ -0,0 +1,23 @@ +[sources] +skills = ["../skills"] + +[target_aliases.claude_code] +global = "~/.claude/skills" +project = ".claude/skills" + +[target_aliases.opencode] +global = "~/.config/opencode/skills" +project = ".opencode/skills" + +[target_aliases.codex] +global = "~/.agents/skills" +project = ".agents/skills" + +[global] +targets = [] +skills = ["ground", "bdd", "land", "issue-craft", "planning"] + +[projects.".."] +inherit = true +skills = [] +targets = ["claude_code", "opencode", "codex"] diff --git a/AGENTS.md b/AGENTS.md index 397295b..714c465 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ agentd uses a workspace-and-plugin shape: the `agentd` binary crate composes foc This workflow is always in effect for contributions to this repository and is not optional. ### Ground Before Designing -For any new module, API surface, protocol, or data structure, define what capability must exist when the change is complete before inspecting existing implementation patterns. State required outcomes first, then derive constraints from what must be true for those outcomes to hold. Separate actual constraints from inherited assumptions and challenge assumptions unless they are verified by requirements, interfaces, or tests. Compare against existing approaches only after a need-first design exists. Reference: `.agents/skills/ground/SKILL.md`. +For any new module, API surface, protocol, or data structure, define what capability must exist when the change is complete before inspecting existing implementation patterns. State required outcomes first, then derive constraints from what must be true for those outcomes to hold. Separate actual constraints from inherited assumptions and challenge assumptions unless they are verified by requirements, interfaces, or tests. Compare against existing approaches only after a need-first design exists. Reference: `skills/ground/SKILL.md`. ### BDD First Every change must follow this sequence: behavioral spec -> test -> implementation -> verification. Define done as observable behavior before coding. Write or update tests that fail without the change and pass when behavior is correct. Implement only what is necessary to satisfy the behavioral contract. No PR is complete without behavioral coverage for the change. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0b8ea57 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: skills-sync skills-verify + +skills-sync: + ./scripts/skills_sync.sh + +skills-verify: + ./scripts/skills_verify.sh diff --git a/README.md b/README.md index eaa90de..3dcd2fe 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,21 @@ Coming soon. ## Developer Tooling: Skills -agentd supports project-level skills with two layers: - -- Shared public skills tracked in this repository at `.agents/skills/`. -- Optional personal skills overlay installed locally at project level and kept untracked. - -See `docs/personal-skill-overlay.md` for the personal overlay workflow, collision policy, and git-clean verification steps. +agentd pilots `loadout` for project-level skill discovery. + +- Project skills are tracked in this repository at `skills/` and used as the + sole default `loadout` source for project setup. +- Skill vendoring is manifest-driven via `skills.manifest.toml`. + - Sync vendored skills from upstream: `make skills-sync` + - Verify manifest + config coherence: `make skills-verify` +- `loadout` installs enabled skills into tool discovery directories: + - `.agents/skills/` (Codex) + - `.claude/skills/` (Claude Code) + - `.opencode/skills/` (OpenCode) +- Project pilot config lives at `.loadout/agentd.toml` and is intended to be used with: + - `LOADOUT_CONFIG=$PWD/.loadout/agentd.toml loadout install` + +See `docs/personal-skill-overlay.md` for personal overlay layering and rollback to manual links. ## License diff --git a/docs/personal-skill-overlay.md b/docs/personal-skill-overlay.md index ab56f67..25d62a4 100644 --- a/docs/personal-skill-overlay.md +++ b/docs/personal-skill-overlay.md @@ -1,64 +1,100 @@ # Personal Skills Overlay -How to combine shared public skills with personal developer skills at project scope. +How to layer personal skills on top of project skills using `loadout`. ## Goal -- Keep shared project skills committed with the repository. -- Allow each developer to add personal skills locally at project level. -- Keep personal skill content and identity details out of committed files. +- Keep shared project skills committed at `skills/`. +- Allow each developer to layer private personal skills locally. +- Keep personal skill names, URLs, and paths out of committed files. -## Directory Contract +## Project Pilot Contract -- Public skills (tracked): `.agents/skills//SKILL.md` -- Personal overlay (local only): `.agents/skills//` +- Canonical tracked source in this repo: `skills//SKILL.md` +- Skills are curated through `skills.manifest.toml` and synchronized with + `scripts/skills_sync.sh`. +- Project loadout config: `.loadout/agentd.toml` +- Project default source is self-contained in this repo: `../skills` +- Project default enabled skills: `ground`, `bdd`, `land`, `issue-craft`, + `planning` +- Loadout-managed project targets: + - `.agents/skills` (Codex) + - `.claude/skills` (Claude Code) + - `.opencode/skills` (OpenCode) -## Installation Pattern (Personal Overlay) +Install project skills: -1. Store personal skills in a personal location (for example a personal repo clone). -2. Install them into this project as symlinks under `.agents/skills/`. -3. Add personal overlay paths to local-only excludes in `.git/info/exclude`. +```bash +LOADOUT_CONFIG="$PWD/.loadout/agentd.toml" loadout install +``` -Example local installation pattern: +## Personal Overlay Pattern (Local Only) -```bash -PROJECT_ROOT=/path/to/agentd -PERSONAL_ROOT=/path/to/personal-skills +1. Store personal skills in a private location (for example a personal repo + clone). +2. Add that location to your local loadout config `sources.skills` before the + project source so first match wins. +3. Enable personal skill names in `global.skills` or project skills for your + local config. + +Example local config snippet: -mkdir -p "$PROJECT_ROOT/.agents/skills" -ln -sfn "$PERSONAL_ROOT/skills/my-personal-skill" \ - "$PROJECT_ROOT/.agents/skills/my-personal-skill" -printf '%s\n' '.agents/skills/my-personal-skill' >> "$PROJECT_ROOT/.git/info/exclude" +```toml +[sources] +skills = [ + "/path/to/personal-skills", + "/path/to/agentd/skills", +] ``` -Use an idempotent local installer script if you have many personal skills. +Do not commit personal config edits. Keep them in your user-scoped +`~/.config/loadout/loadout.toml` or in an untracked local copy. ## Collision Policy -- Personal overlay names may intentionally collide with tracked public skill directory names. -- On collision, the personal overlay skill overrides the project skill for that developer's local environment. -- Use this for personal adaptation of shared workflows without changing team-wide defaults. +- Name collisions are allowed by source ordering. +- First matching source wins; this enables personal override of a shared skill. +- Shared defaults remain unchanged for other developers. ## Git Hygiene Policy -- Do not add personal overlay patterns to `.gitignore`. -- Do not commit personal skill names, URLs, or local paths. -- Use `.git/info/exclude` (or equivalent local-only ignore) for personal overlay entries. +- Do not commit personal source paths or private skill names. +- Keep personal local setup out of tracked files. +- Generated target directories are ignored by this repo. ## Verification Checklist -Run after installing or updating personal overlays: +Run after personal overlay changes: ```bash +./scripts/skills_verify.sh +LOADOUT_CONFIG="$PWD/.loadout/agentd.toml" loadout list +LOADOUT_CONFIG="$PWD/.loadout/agentd.toml" loadout check git status --porcelain ``` -Expected: no personal overlay paths appear in output. +Expected: -Optional link verification: +- `skills_verify.sh` confirms manifest/loadout/skills directory coherence. +- `loadout list` resolves skills from expected source paths. +- `loadout check` reports no blocking errors. +- `git status --porcelain` contains no personal path leakage. + +## Rollback To Manual Alias Flow + +If pilot behavior fails, use manual links: ```bash -find .agents/skills -maxdepth 1 -type l -print +mkdir -p .agents .claude .opencode +ln -sfn ../skills .agents/skills +ln -sfn ../.agents/skills .claude/skills +ln -sfn ../.agents/skills .opencode/skills ``` -Expected: only intentionally installed personal symlinks are listed. +This restores manual project-level discovery paths for Codex, Claude Code, and +OpenCode. + +When using normal pilot operation, do not hand-manage symlinks inside these +target directories; let `loadout install` and `loadout clean` own them. +These target roots are ignored by git in both directory and symlink forms to +keep local setup changes out of repository status. diff --git a/docs/skills-loadout-pilot-spec.md b/docs/skills-loadout-pilot-spec.md new file mode 100644 index 0000000..1e9e34d --- /dev/null +++ b/docs/skills-loadout-pilot-spec.md @@ -0,0 +1,37 @@ +# Skills Loadout Pilot Spec + +Behavioral specification for issue #14: use `loadout` to manage project-level +skill discovery for Codex, Claude Code, and OpenCode in `agentd`. + +## Scenario 1: Fresh project install exposes identical project skills + +Given a clone of `agentd` with `skills/` populated +And no sibling skill repositories present +And `LOADOUT_CONFIG` set to `.loadout/agentd.toml` +When `loadout install` runs in the project root +Then `.agents/skills`, `.claude/skills`, and `.opencode/skills` each contain +the same enabled skill names +And each entry is a symlink to the canonical source under `skills/`. + +## Scenario 2: Config change propagates across all project targets + +Given an installed project from Scenario 1 +When the enabled skill set in `.loadout/agentd.toml` changes +And `loadout clean` then `loadout install` runs again +Then all project target directories reflect the same updated skill set +And no target has stale managed links. + +## Scenario 3: Skill behavior is unchanged + +Given an existing tracked skill in `skills/` +When `loadout validate` runs +Then frontmatter and markdown content validation results match pre-pilot +expectations +And only discovery plumbing has changed. + +## Scenario 4: Manual rollback remains available + +Given loadout pilot setup is removed or disabled +When the documented manual symlink commands are applied +Then Codex and Claude Code discover project skills through manual links +And the rollback path is deterministic and documented. diff --git a/scripts/skills_sync.sh b/scripts/skills_sync.sh new file mode 100755 index 0000000..63fe2aa --- /dev/null +++ b/scripts/skills_sync.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MANIFEST="$ROOT/skills.manifest.toml" +SOURCE_ROOT="${1:-${AGENTS_SKILLS_ROOT:-$ROOT/../agents/skills}}" + +if [[ ! -f "$MANIFEST" ]]; then + echo "missing manifest: $MANIFEST" + exit 1 +fi + +if [[ ! -d "$SOURCE_ROOT" ]]; then + echo "missing source root: $SOURCE_ROOT" + echo "set AGENTS_SKILLS_ROOT or pass path as first arg" + exit 1 +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +mapfile -t ENTRIES < <( + awk -F'"' ' + /^name = / {name=$2} + /^source_path = / {print name "\t" $2} + ' "$MANIFEST" +) + +if [[ "${#ENTRIES[@]}" -eq 0 ]]; then + echo "no skill entries found in manifest" + exit 1 +fi + +for row in "${ENTRIES[@]}"; do + name="${row%%$'\t'*}" + rel="${row#*$'\t'}" + src="$SOURCE_ROOT/$rel" + dst="$TMP/$name" + + if [[ ! -d "$src" ]]; then + echo "missing skill source: $src" + exit 1 + fi + + rsync -a --exclude '__pycache__' "$src/" "$dst/" +done + +rm -rf "$ROOT/skills" +mkdir -p "$ROOT/skills" +rsync -a "$TMP/" "$ROOT/skills/" + +echo "synced skills from $SOURCE_ROOT to $ROOT/skills" diff --git a/scripts/skills_verify.sh b/scripts/skills_verify.sh new file mode 100755 index 0000000..525bce0 --- /dev/null +++ b/scripts/skills_verify.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MANIFEST="$ROOT/skills.manifest.toml" +LOADOUT="$ROOT/.loadout/agentd.toml" + +norm_csv() { + tr -d ' ' | tr ',' '\n' | sed '/^$/d' | sort +} + +extract_inline_list() { + local file="$1" + local section="$2" + local key="$3" + awk -v sec="$section" -v key="$key" ' + $0 == "[" sec "]" {in_sec=1; next} + /^\[/ {in_sec=0} + in_sec && $0 ~ ("^" key " = \\[") {print; exit} + ' "$file" +} + +defaults_line="$(extract_inline_list "$MANIFEST" "defaults" "skills")" +if [[ -z "$defaults_line" ]]; then + echo "manifest defaults.skills missing" + exit 1 +fi + +loadout_line="$(extract_inline_list "$LOADOUT" "global" "skills")" +if [[ -z "$loadout_line" ]]; then + echo "loadout global.skills missing" + exit 1 +fi + +manifest_defaults="$(echo "$defaults_line" | sed -E 's/^skills = \[(.*)\]$/\1/' | tr -d '"' | norm_csv)" +loadout_defaults="$(echo "$loadout_line" | sed -E 's/^skills = \[(.*)\]$/\1/' | tr -d '"' | norm_csv)" + +if [[ "$manifest_defaults" != "$loadout_defaults" ]]; then + echo "mismatch: manifest defaults vs loadout global skills" + echo "manifest:" + echo "$manifest_defaults" + echo "loadout:" + echo "$loadout_defaults" + exit 1 +fi + +sources_line="$(extract_inline_list "$LOADOUT" "sources" "skills")" +if [[ "$sources_line" != 'skills = ["../skills"]' ]]; then + echo "unexpected loadout [sources].skills: $sources_line" + exit 1 +fi + +mapfile -t MANIFEST_NAMES < <( + awk -F'"' '/^name = / {print $2}' "$MANIFEST" | sort +) + +mapfile -t SKILL_DIRS < <( + find "$ROOT/skills" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort +) + +if [[ "${MANIFEST_NAMES[*]}" != "${SKILL_DIRS[*]}" ]]; then + echo "mismatch: manifest skill names vs skills directory set" + echo "manifest: ${MANIFEST_NAMES[*]}" + echo "skills: ${SKILL_DIRS[*]}" + exit 1 +fi + +for name in "${MANIFEST_NAMES[@]}"; do + if [[ ! -f "$ROOT/skills/$name/SKILL.md" ]]; then + echo "missing SKILL.md for $name" + exit 1 + fi +done + +echo "skills manifest and loadout config verified" diff --git a/skills.manifest.toml b/skills.manifest.toml new file mode 100644 index 0000000..c3234ea --- /dev/null +++ b/skills.manifest.toml @@ -0,0 +1,27 @@ +[metadata] +format_version = 1 +source_repo = "https://github.com/pentaxis93/agents" +source_ref = "workspace-local" + +[defaults] +skills = ["ground", "bdd", "land", "issue-craft", "planning"] + +[[skill]] +name = "ground" +source_path = "cognition/ground" + +[[skill]] +name = "bdd" +source_path = "development/bdd" + +[[skill]] +name = "land" +source_path = "workflow/land" + +[[skill]] +name = "issue-craft" +source_path = "workflow/issue-craft" + +[[skill]] +name = "planning" +source_path = "workflow/planning" diff --git a/skills/bdd/SKILL.md b/skills/bdd/SKILL.md new file mode 100644 index 0000000..4a83987 --- /dev/null +++ b/skills/bdd/SKILL.md @@ -0,0 +1,81 @@ +--- +name: bdd +description: Behaviour-driven development for writing tests as executable behavior specifications. Use when deciding what to test next, structuring tests with Given/When/Then, or diagnosing failing tests. +--- + +# Behaviour-Driven Development + +BDD reframes testing as behavior specification: what the system should do, +under what context, with observable outcomes. + +References: +- [Dan North: Introducing BDD](https://dannorth.net/introducing-bdd/) +- [language patterns](references/language-patterns.md) + +## Goal +Drive development through sentence-named behaviors and Given/When/Then +structured scenarios. + +## Constraints +- `behaviour-not-test`: ask "what should this do?" before "how to test?" +- `sentences-not-labels`: test names read as behavior statements. +- `given-when-then`: every case contains setup, action, assertion phases. +- `native-tooling`: use language-native test framework. +- `one-behaviour-per-test`: split cases containing multiple behaviors. + +## Requirements +- `name-reads-as-spec`: test names form readable module specification. +- `given-establishes-context`: setup only. +- `when-performs-one-action`: single behavior trigger. +- `then-verifies-outcome`: assert observable outcomes, not internals. +- `behaviour-drives-priority`: next test chosen by behavior gap importance. + +## Procedures + +### identify-next-behaviour +1. List existing behaviors from test names. +2. Extract needed behaviors from AC/issue/module purpose. +3. Compute missing behaviors. +4. Rank by importance: +- happy path before error path +- core before edge +- dependency foundation before dependents +- user-visible impact first +5. Pick highest-priority behavior that can be expressed as one sentence. + +### write-behaviour +1. Name behavior as sentence-style test. +2. Write Given/When/Then structure. +3. Red: run and confirm failure. +4. Green: implement minimal code to pass. +5. Refactor while keeping full suite green. + +### evaluate-existing-tests +When a test fails after change, classify failure as one of: +- `bug_introduced`: fix implementation. +- `behaviour_moved`: move/redirect test. +- `behaviour_obsolete`: delete outdated test. + +## Triggers +- writing tests +- starting new module +- implementing issue acceptance criteria +- diagnosing test failures +- deciding what to implement next + +## Corruption Modes +- `testing-implementation`: asserting internals instead of behavior. +- `vague-names`: non-descriptive test names. +- `missing-given`: unclear setup context. +- `multi-behaviour-tests`: multiple behaviors in one case. +- `test-hoarding`: retaining obsolete tests. +- `framework-over-thinking`: choosing framework over method. + +## Principles +- `words-shape-thinking`: behavior vocabulary improves test design. +- `specification-not-verification`: tests are living executable spec. +- `delete-freely`: obsolete behavior tests should be removed. + +## Cross-References +- `planning`: session-level prioritization and execution sequencing. +- `issue-craft`: acceptance-criteria rigor and issue decomposition. diff --git a/skills/bdd/references/language-patterns.md b/skills/bdd/references/language-patterns.md new file mode 100644 index 0000000..11585ae --- /dev/null +++ b/skills/bdd/references/language-patterns.md @@ -0,0 +1,259 @@ +# Language Patterns + +Concrete BDD patterns for common languages. Each section shows how to +apply sentence-named behaviours and Given/When/Then structure using the +language's native test tooling. + +## Table of Contents + +- [Naming Conventions](#naming-conventions) +- [Rust](#rust) +- [Python](#python) +- [TypeScript / JavaScript](#typescript--javascript) +- [When to Use a BDD Framework](#when-to-use-a-bdd-framework) + +--- + +## Naming Conventions + +The naming pattern adapts to each language's conventions while +preserving the core rule: test names are sentences describing behaviour. + +| Language | Convention | Example | +|----------|-----------|---------| +| Rust | `should_` prefix, snake_case | `should_reject_empty_name` | +| Python | `should_` prefix, snake_case | `test_should_reject_empty_name` | +| TypeScript | `it("should ...")` or `test("should ...")` | `it("should reject empty name")` | +| Go | `TestShould` prefix, PascalCase | `TestShouldRejectEmptyName` | +| Java | `should` prefix, camelCase | `shouldRejectEmptyName` | + +The `should` prefix enforces the sentence template from Dan North: +"The module **should** do something." If a name doesn't fit this +template, the behaviour may belong elsewhere. + +--- + +## Rust + +### Module structure + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_expand_tilde_to_home_directory() { + // Given + let home = "/home/user"; + let input = "~/config/loadout.toml"; + + // When + let result = expand_path(input, home); + + // Then + assert_eq!(result, PathBuf::from("/home/user/config/loadout.toml")); + } + + #[test] + fn should_return_path_unchanged_when_no_tilde() { + // Given + let input = "/absolute/path/file.toml"; + + // When + let result = expand_path(input, "/home/user"); + + // Then + assert_eq!(result, PathBuf::from("/absolute/path/file.toml")); + } +} +``` + +Reading the test names as a specification: + +``` +expand_path + - should expand tilde to home directory + - should return path unchanged when no tilde +``` + +### Integration tests with temp directories + +```rust +use tempfile::TempDir; + +#[test] +fn should_create_symlink_to_skill_source() { + // Given + let tmp = TempDir::new().unwrap(); + let source = tmp.path().join("source/my-skill"); + let target = tmp.path().join("target"); + fs::create_dir_all(&source).unwrap(); + fs::write(source.join("SKILL.md"), "---\nname: my-skill\n---").unwrap(); + fs::create_dir_all(&target).unwrap(); + + // When + link_skill(&source, &target).unwrap(); + + // Then + let link = target.join("my-skill"); + assert!(link.is_symlink()); + assert_eq!(fs::read_link(&link).unwrap(), source); +} +``` + +### Error behaviours + +```rust +#[test] +fn should_return_error_when_config_file_missing() { + // Given + let path = PathBuf::from("/nonexistent/loadout.toml"); + + // When + let result = load_config(&path); + + // Then + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not found")); +} +``` + +### Naming patterns for common scenarios + +| Scenario | Test name | +|----------|-----------| +| Happy path | `should_parse_valid_config` | +| Missing input | `should_return_error_when_config_missing` | +| Invalid input | `should_reject_name_with_uppercase` | +| Edge case | `should_handle_empty_sources_list` | +| Boundary | `should_reject_name_longer_than_64_chars` | +| State-dependent | `should_skip_existing_symlink_when_unchanged` | + +--- + +## Python + +### pytest with BDD naming + +```python +class TestConfigLoader: + + def test_should_parse_valid_toml_config(self, tmp_path): + # Given + config_file = tmp_path / "loadout.toml" + config_file.write_text('[sources]\nskills = ["~/skills"]') + + # When + config = load_config(config_file) + + # Then + assert config.sources.skills == ["~/skills"] + + def test_should_raise_error_when_config_missing(self): + # Given + path = Path("/nonexistent/config.toml") + + # When / Then + with pytest.raises(FileNotFoundError): + load_config(path) +``` + +### unittest style + +```python +class TestConfigLoaderBehaviour(unittest.TestCase): + + def test_should_expand_environment_variables(self): + # Given + os.environ["MY_DIR"] = "/custom/path" + raw = "$MY_DIR/skills" + + # When + result = expand_path(raw) + + # Then + self.assertEqual(result, Path("/custom/path/skills")) +``` + +--- + +## TypeScript / JavaScript + +### Jest / Vitest + +```typescript +describe("ConfigLoader", () => { + it("should parse a valid TOML config", () => { + // Given + const toml = `[sources]\nskills = ["~/skills"]`; + + // When + const config = parseConfig(toml); + + // Then + expect(config.sources.skills).toEqual(["~/skills"]); + }); + + it("should throw when config file is missing", async () => { + // Given + const path = "/nonexistent/config.toml"; + + // When / Then + await expect(loadConfig(path)).rejects.toThrow("not found"); + }); +}); +``` + +The `describe`/`it` pattern naturally produces readable specs: + +``` +ConfigLoader + - should parse a valid TOML config + - should throw when config file is missing +``` + +### Node test runner + +```typescript +import { describe, it } from "node:test"; +import assert from "node:assert"; + +describe("PathExpander", () => { + it("should expand tilde to home directory", () => { + // Given + const input = "~/config/app.toml"; + + // When + const result = expandPath(input); + + // Then + assert.strictEqual(result, `${process.env.HOME}/config/app.toml`); + }); +}); +``` + +--- + +## When to Use a BDD Framework + +Native test tooling with Given/When/Then comments covers most +codebases. Consider a framework when: + +| Signal | Framework option | +|--------|-----------------| +| Complex domain with many scenario permutations | `cucumber` (Rust), `pytest-bdd` (Python), `cucumber-js` (TS) | +| Parameterized scenarios (same structure, varying data) | `rstest` (Rust), `pytest.mark.parametrize` (Python) | +| Stakeholders need to read/write feature files | Full Gherkin: `.feature` files + step definitions | + +For most developer-facing libraries and CLIs, the lightweight +approach (sentence names + Given/When/Then comments) is sufficient. +The value of BDD is in the thinking pattern, not the framework. + +### Rust BDD crates + +| Crate | Style | Use when | +|-------|-------|----------| +| `rstest` | Parameterized fixtures | Many scenarios with shared setup, data-driven tests | +| `rstest-bdd` | Given/When/Then macros on rstest | Want structured BDD syntax without Gherkin files | +| `cucumber` | Full Gherkin with `.feature` files | Complex domain, stakeholder-readable specs | diff --git a/.agents/skills/ground/SKILL.md b/skills/ground/SKILL.md similarity index 95% rename from .agents/skills/ground/SKILL.md rename to skills/ground/SKILL.md index f9df9fc..b5b625d 100644 --- a/.agents/skills/ground/SKILL.md +++ b/skills/ground/SKILL.md @@ -1,12 +1,12 @@ --- name: ground description: >- - Ground a design from first principles before generating original work. - Use when creating specs, architectures, processes, solutions, methodologies, - problem framings — any task requiring original design. Also use for migration, - upgrade, and technology selection decisions. Triggers: 'ground this', - 'start from first principles', 'what does this need to enable', - 'check my assumptions', 'design from constraints'. + First-principles cognitive discipline for all generative work. Use when + creating specs, architectures, processes, solutions, methodologies, problem + framings — any task requiring original design. Also applies to migration, + upgrade, and technology selection decisions. Establishes what the work must + enable before decomposing to verified constraints, then builds from what + is actually true. metadata: version: "2.0.0" updated: "2026-03-01" @@ -111,7 +111,7 @@ Defaulting to the abstraction level of adjacent or existing systems. Documenting what is instead of designing what's needed. **Recognition:** Your output reads as a description of the current system rather than a design derived from requirements. Every claim traces to existing code or configuration, none trace to user needs or capability requirements. -**Corrective:** "Am I describing what exists or defining what's needed?" If every statement traces to implementation and none trace to need, you have not designed anything. Return to Orient. +**Corrective:** "Am I describing what exists or defining what's needed?" If every statement traces to implementation and none trace to need, you have written a description, not a design. Return to Orient. --- diff --git a/skills/issue-craft/SKILL.md b/skills/issue-craft/SKILL.md new file mode 100644 index 0000000..91f86d4 --- /dev/null +++ b/skills/issue-craft/SKILL.md @@ -0,0 +1,139 @@ +--- +name: issue-craft +description: Full issue lifecycle for GitHub/Forgejo projects. Use for creating, decomposing, refining, triaging, and closing issues that autonomous agents can execute without clarification. +--- + +# Issue Craft + +Issues are the contract between intent and execution. A strong issue is +agent-executable without clarification. + +For concrete task/epic/bug templates, see +[references/templates.md](references/templates.md). + +## Goal +Produce and maintain issues that autonomous agents can execute end-to-end +without clarification. + +## Constraints +- `agent-executable`: each task issue should be completable in one focused session. +- `independently-verifiable`: each acceptance criterion supports yes/no verification. +- `explicit-dependencies`: dependency links are explicit issue references. +- `hard-dependencies-only`: dependencies represent true blockers, not preferred ordering. +- `single-concern`: one logical change per issue. +- `scope-bounded`: scope names specific modules/files. +- `vertical-slice-bias`: decomposition favors independently shippable slices. + +## Requirements +- `summary-states-what-and-why`: concise summary with explicit rationale. +- `scope-names-artifacts`: scope lists concrete code locations. +- `criteria-are-behaviors`: criteria describe outcomes, not implementation steps. +- `criteria-include-tests`: testing expectation is explicit. +- `criteria-include-docs`: user-facing changes include documentation updates. +- `criteria-are-binary`: each criterion can be verified as pass/fail. +- `dependencies-link-issues`: dependency references use issue numbers. +- `size-is-visible`: size class included (`small`, `medium`, `large`). +- `epic-has-dependency-graph`: epics with 4+ tasks include execution order graph. +- `tasks-are-session-sized`: each task can complete in one focused session. + +## Procedures + +### create-issue +1. Classify issue type (`task`, `epic`, `bug`, `spike`). +2. Write summary (what and why, not how). +3. Define scope with concrete files/modules. +4. Write acceptance criteria by category: +- functional outcome +- verification/testing +- documentation +5. Identify dependencies by searching existing issues. +6. Estimate size. +7. Title format: `(): `. +8. Run deterministic lint: +`python scripts/issue_lint.py --type `. +9. Assemble using template from `references/templates.md`. + +### decompose-epic +1. Extract deliverables (artifacts that must exist when done). +2. Split into vertical slices that are independently verifiable. +3. Group by module boundary where it clarifies ownership. +4. Build dependency graph using hard blockers only. +5. Size-check each candidate: +- split if oversized +- merge if trivial +6. Create topologically ordered task issues. +7. Validate each task has binary acceptance criteria. +8. Create parent epic with checklist and graph. + +### define-task-boundary +Task template: +- title: verb + object + short outcome +- scope: concrete files/modules touched +- goal: one sentence observable outcome +- acceptance criteria: binary pass/fail checks +- test plan: exact verification command or scenario +- effort: `small`, `medium`, or `large` + +Pre-save checks: +- one logical concern only +- executable in one session +- dependencies are hard blockers only +- no implementation prescription in acceptance criteria + +### refine-issue +1. Diagnose problems: +- vague summary +- missing scope +- untestable criteria +- missing tests/docs criteria +- implicit dependencies +- oversized/mixed concern +- missing size +2. Apply targeted fixes only where weak. +3. Keep already-strong criteria unchanged. +4. Re-run `scripts/issue_lint.py`. +Prefer strict mode when type is known: +`python scripts/issue_lint.py --type `. + +### triage-issues +1. Refine non-ready issues first. +2. Build dependency graph for backlog. +3. Create topological execution layers. +4. Apply labels (`size:*`, module/area). +5. Assign milestones. +6. Flag stale issues for review. + +### close-issue +1. Verify all acceptance criteria against implementation. +2. Check scope deviations and split unintended extra work. +3. Update parent epic/task checklist. +4. Close with commit/PR reference (`Closes #N`). + +## Triggers +- creating issues +- decomposing large goals +- refining vague issues +- triaging/prioritizing backlog +- closing completed work +- planning milestones/releases + +## Corruption Modes +- `activity-criteria`: criteria describe activities, not outcomes. +- `scope-sprawl`: issue spans unrelated modules. +- `implicit-how`: implementation prescription leaks into issue contract. +- `orphan-issues`: no epic/milestone context. +- `dependency-blindness`: hidden blockers. +- `kitchen-sink-epic`: oversized epic without phasing. +- `premature-issues`: filing work too far out. +- `test-afterthought`: no testing expectation. +- `docs-afterthought`: user-facing change without docs criterion. + +## Principles +- `contract-not-conversation`: issue must stand alone. +- `outcomes-over-activities`: define required end state. +- `right-sized`: optimize for single focused execution session. + +## Cross-References +- `planning`: session-level prioritization and execution discipline. +- `bdd`: behavior framing and test naming discipline. +- `dev-workflow`: issue-to-commit linkage and completion hygiene. diff --git a/skills/issue-craft/references/templates.md b/skills/issue-craft/references/templates.md new file mode 100644 index 0000000..8286c9b --- /dev/null +++ b/skills/issue-craft/references/templates.md @@ -0,0 +1,287 @@ +# Issue Templates + +Concrete templates for each issue type. Copy and fill in the sections. +All templates use GitHub-flavored markdown. + +## Table of Contents + +- [Task Issue](#task-issue) +- [Epic Issue](#epic-issue) +- [Bug Report](#bug-report) +- [Spike / Investigation](#spike--investigation) +- [Acceptance Criteria Patterns](#acceptance-criteria-patterns) +- [Dependency Graph Notation](#dependency-graph-notation) + +--- + +## Task Issue + +A single deliverable unit of work. Completable in one focused session. + +**Title format:** `(): ` + +```markdown +## Summary + +[1-3 sentences. What needs to exist and why. Reference parent epic if applicable.] + +Part of: #N + +## Scope + +- `src/module/file.rs` — [what changes here] +- `src/module/other.rs` — [what changes here] + +## Acceptance criteria + +- [ ] [Observable outcome using a verb: creates, returns, validates, rejects, reports] +- [ ] [Another observable outcome] +- [ ] [Testing expectation: unit tests, integration tests, or both] + +## Dependencies + +Depends on: #A (reason), #B (reason) +``` + +### Example: task issue + +```markdown +Title: feat(config): TOML parsing, path expansion, XDG resolution + +## Summary + +Implement the `config/` module: parse `loadout.toml` with serde, +expand `~` to `$HOME`, resolve config location via XDG, and support +`$LOADOUT_CONFIG` override. + +Part of: #1 (Phase 2: Rust Parity) + +## Scope + +- `src/config/mod.rs` — Config loading + path resolution +- `src/config/types.rs` — Serde structs for `loadout.toml` + +## Acceptance criteria + +- [ ] Parses `loadout.toml` with all sections: `[sources]`, `[global]`, `[projects.*]` +- [ ] Expands `~` and `~/` to `$HOME` in all path fields +- [ ] Resolves config from `$LOADOUT_CONFIG`, then `$XDG_CONFIG_HOME/loadout/loadout.toml`, then `~/.config/loadout/loadout.toml` +- [ ] Returns typed errors for missing config, parse failures, invalid paths +- [ ] Unit tests covering path expansion and config resolution + +## Dependencies + +None — this is the first module to implement. +``` + +--- + +## Epic Issue + +A tracking issue that decomposes into task issues. Contains no +implementation work itself. + +**Title format:** `Phase N: ` or `epic: ` + +```markdown +## Summary + +[2-4 sentences. The goal of this body of work and what's different +when it's complete.] + +Full spec: [link to design doc or roadmap section] + +## Task issues + +### [Layer name] (e.g., Library modules, CLI commands) + +- [ ] #N — `module/` — [brief description] +- [ ] #M — `other/` — [brief description] + +## Dependency graph + +``` +#A ──┬── #C + │ +#B ──┴── #D +``` + +## Acceptance criteria + +[System-level criteria, not per-task. What's true when the epic is done.] + +- [ ] [Overall behaviour that proves the epic is complete] +- [ ] [Quality gate: no regressions, all tests pass, docs updated] +``` + +### Example: epic issue + +```markdown +Title: Phase 2: Rust Parity (v0.2.0) + +## Summary + +Replace the three bash scripts with a single Rust binary. The +`loadout` command should be installable via `cargo install --path .` +with zero Python dependency at runtime. + +Full spec: docs/ROADMAP.md — Phase 2 + +## Task issues + +### Library modules + +- [ ] #5 — `config/` — TOML parsing, path expansion, XDG resolution +- [ ] #6 — `skill/frontmatter.rs` — YAML frontmatter parsing + validation +- [ ] #7 — `skill/mod.rs` — Source directory walking, skill resolution +- [ ] #8 — `linker/` — Symlink creation/removal, marker management + +### CLI commands + +- [ ] #9 — `loadout install` (depends on #5, #7, #8) +- [ ] #10 — `loadout clean` (depends on #5, #8) + +## Dependency graph + +``` +#5 config ──────┬── #9 install +#6 frontmatter ─┤ +#7 skill/mod ───┤── #10 clean +#8 linker ──────┘ +``` + +## Acceptance criteria + +- [ ] `loadout install` produces identical symlink layout to `install.sh` +- [ ] No Python dependency at runtime +- [ ] `cargo install --path .` places binary in `~/.cargo/bin/loadout` +``` + +--- + +## Bug Report + +A defect with reproduction steps and expected vs actual behaviour. + +**Title format:** `fix(): ` + +```markdown +## Bug + +[One sentence: what's broken.] + +## Reproduction + +1. [Step to reproduce] +2. [Step to reproduce] +3. [Observe: what actually happens] + +## Expected behaviour + +[What should happen instead.] + +## Environment + +- OS: [e.g., Ubuntu 24.04] +- Version: [e.g., v0.2.0, commit abc123] +- Config: [relevant config if applicable] + +## Acceptance criteria + +- [ ] [The fixed behaviour, stated as an observable outcome] +- [ ] [Regression test covering this case] +``` + +--- + +## Spike / Investigation + +Time-boxed research. Produces answers and follow-up issues, not code. + +**Title format:** `spike: ` + +```markdown +## Question + +[The specific question this spike answers.] + +## Time box + +[Maximum time to spend before reporting findings, e.g., "2 hours"] + +## Context + +[Why we need to investigate this. What decision depends on the answer.] + +## Expected output + +- [ ] Written summary of findings (comment on this issue) +- [ ] Recommendation with rationale +- [ ] Follow-up issues created if work is needed +``` + +--- + +## Acceptance Criteria Patterns + +Good criteria describe **outcomes**, not activities. + +| Pattern | Example | +|---------|---------| +| Creates artifact | Creates `.managed-by-loadout` marker files in managed directories | +| Returns value | Returns typed error for missing config file | +| Validates input | Rejects skill names containing uppercase characters | +| Handles edge case | Returns empty list when no source directories are configured | +| Reports to user | Prints resolved skill paths during `--dry-run` | +| Preserves invariant | Never removes content it didn't create | +| Testing | Unit tests covering path expansion and XDG resolution | +| Integration | Integration tests verifying end-to-end install with temp directories | + +### Anti-patterns + +| Anti-pattern | Problem | Rewrite as | +|-------------|---------|------------| +| "Research best approach" | Activity, not outcome | Spike issue with specific question | +| "Clean up the code" | Vague, no verification | "Extract X into separate module with public API" | +| "Handle errors properly" | No specific behaviour | "Returns ConfigError::NotFound when file missing" | +| "Add tests" | No scope | "Unit tests covering path expansion and config resolution" | +| "Update docs" | No specifics | "README install section reflects cargo install method" | + +--- + +## Dependency Graph Notation + +Use ASCII art for dependency graphs in epic issues. Keep it simple. + +### Linear chain + +``` +#5 config → #7 skill → #9 install +``` + +### Fan-out (one foundation, many dependents) + +``` +#5 config ──┬── #9 install + ├── #10 clean + ├── #11 list + └── #13 new +``` + +### Diamond (multiple foundations merge) + +``` +#5 config ──────┬── #9 install +#7 skill ───────┤ +#8 linker ──────┘ +``` + +### Layered (with labels) + +``` +Foundation: #5 config #6 frontmatter #8 linker + │ │ │ +Integration: #7 skill (depends on #5, #6) │ + │ │ +Commands: #9 install (depends on #5, #7, #8) ─┘ +``` diff --git a/skills/issue-craft/scripts/issue_lint.py b/skills/issue-craft/scripts/issue_lint.py new file mode 100755 index 0000000..8d5b5b1 --- /dev/null +++ b/skills/issue-craft/scripts/issue_lint.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Deterministic lint checks for issue bodies used by issue-craft.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +HEADING_ALIASES = { + "acceptance criteria": "acceptance criteria", + "acceptance criteria:": "acceptance criteria", + "expected behavior": "expected behaviour", + "expected behavior:": "expected behaviour", + "expected behaviour": "expected behaviour", + "expected behaviour:": "expected behaviour", +} + +SCHEMAS = { + "task": { + "required": ["summary", "scope", "acceptance criteria"], + "checklist_section": "acceptance criteria", + }, + "epic": { + "required": ["summary", "task issues", "acceptance criteria"], + "checklist_section": "acceptance criteria", + }, + "bug": { + "required": ["bug", "reproduction", "expected behaviour", "acceptance criteria"], + "checklist_section": "acceptance criteria", + }, + "spike": { + "required": ["question", "time box", "expected output"], + "checklist_section": "expected output", + }, +} + + + +def load_text(path: str | None) -> str: + if path is None or path == "-": + return sys.stdin.read() + return Path(path).read_text(encoding="utf-8") + + + +def normalize_heading(heading: str) -> str: + collapsed = re.sub(r"\s+", " ", heading.strip().lower()) + return HEADING_ALIASES.get(collapsed, collapsed) + + + +def extract_sections(text: str) -> dict[str, str]: + headings = list(re.finditer(r"^##\s+(.+?)\s*$", text, re.MULTILINE)) + sections: dict[str, str] = {} + + for index, match in enumerate(headings): + normalized_heading = normalize_heading(match.group(1)) + start = match.end() + if index + 1 < len(headings): + end = headings[index + 1].start() + else: + end = len(text) + + # Keep first section for each heading label for deterministic behavior. + sections.setdefault(normalized_heading, text[start:end]) + + return sections + + + +def run_checks(text: str, issue_type: str | None = None) -> dict: + errors: list[str] = [] + warnings: list[str] = [] + sections = extract_sections(text) + + if issue_type: + selected_type = issue_type + schema = SCHEMAS[selected_type] + missing = [heading for heading in schema["required"] if heading not in sections] + if missing: + errors.append( + f"{selected_type} issue missing headings: " + ", ".join(missing) + ) + else: + candidates: list[tuple[str, list[str]]] = [] + for candidate_type, schema in SCHEMAS.items(): + missing = [heading for heading in schema["required"] if heading not in sections] + candidates.append((candidate_type, missing)) + + matches = [candidate_type for candidate_type, missing in candidates if not missing] + if not matches: + errors.append("issue does not match known template headings") + for candidate_type, missing in candidates: + errors.append(f"{candidate_type} missing headings: {', '.join(missing)}") + return { + "passed": False, + "issue_type": None, + "errors": errors, + "warnings": warnings, + } + + selected_type = matches[0] + schema = SCHEMAS[selected_type] + + checklist_section = schema["checklist_section"] + checklist_block = sections.get(checklist_section, "") + if checklist_block: + checklist_items = re.findall(r"^-\s+\[\s?[xX ]\]\s+.+$", checklist_block, re.MULTILINE) + if not checklist_items: + errors.append(f"{checklist_section} must include markdown checkboxes") + + scope_block = sections.get("scope", "") + if scope_block and re.search(r"\b(various|across the codebase|misc)\b", scope_block, re.IGNORECASE): + warnings.append("scope contains broad wording; consider naming exact files/modules") + + size_block = sections.get("size", "") + if size_block and not re.search(r"\bsmall\b|\bmedium\b|\blarge\b", size_block, re.IGNORECASE): + warnings.append("size section should declare small/medium/large") + + if re.search(r"\b(depends on|parent)\b", text, re.IGNORECASE) and not re.search(r"#\d+", text): + warnings.append("dependencies mentioned without issue references") + + passed = not errors + return { + "passed": passed, + "issue_type": selected_type, + "errors": errors, + "warnings": warnings, + } + + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Lint issue markdown for deterministic structural checks." + ) + parser.add_argument("path", nargs="?", help="Issue markdown path or '-' for stdin") + parser.add_argument( + "--type", + choices=sorted(SCHEMAS.keys()), + help="Issue type for strict schema validation", + ) + parser.add_argument( + "--json", action="store_true", help="Output machine-readable JSON report" + ) + args = parser.parse_args() + + text = load_text(args.path) + report = run_checks(text, issue_type=args.type) + + if args.json: + print(json.dumps(report, indent=2)) + else: + print("PASS" if report["passed"] else "FAIL") + if report["issue_type"]: + print(f"Type: {report['issue_type']}") + for error in report["errors"]: + print(f"ERROR: {error}") + for warning in report["warnings"]: + print(f"WARN: {warning}") + + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/land/SKILL.md b/skills/land/SKILL.md new file mode 100644 index 0000000..b1f91af --- /dev/null +++ b/skills/land/SKILL.md @@ -0,0 +1,148 @@ +--- +name: land +description: "One-word closeout workflow: merge active branch to main, sync local main, delete feature branch (remote+local), post issue completion comment, and close the issue. Trigger on: 'land', 'merge and close', 'ship it'." +--- + +# Land — Merge, Sync, Cleanup, Close + +**Version 1.0** + +## Overview + +Use this skill when the user wants full delivery closure in one command. + +`land` means: +1. Merge the active feature branch into `main` +2. Push `main` +3. Remove the feature branch (remote + local) +4. Post a completion comment on the issue +5. Close the issue +6. Verify final state + +Do not stop after merge. + +--- + +## Preconditions + +- Working tree must be clean before starting. +- Current branch must not be `main`. +- Issue number must be known: + - Prefer explicit user-provided issue number. + - Else infer from branch name pattern `issue-`. +- Use WeForge API token from `pass`: + - Default: `pass show weforge/fulcrum-token` + +--- + +## Procedure + +### 1. Resolve context + +```bash +FEATURE_BRANCH="$(git branch --show-current)" +if [ "$FEATURE_BRANCH" = "main" ]; then + echo "ERROR: land must run from a feature branch"; exit 1 +fi + +git diff --quiet && git diff --cached --quiet || { + echo "ERROR: working tree not clean"; exit 1; +} + +ORIGIN_URL="$(git remote get-url origin)" +REPO_PATH="$(echo "$ORIGIN_URL" | sed -E 's#(https?://[^/]+/|git@[^:]+:)##; s#\\.git$##')" +OWNER="${REPO_PATH%%/*}" +REPO="${REPO_PATH##*/}" + +ISSUE_NUMBER="$(echo "$FEATURE_BRANCH" | sed -nE 's#.*issue-([0-9]+).*#\\1#p')" +if [ -z "$ISSUE_NUMBER" ]; then + echo "ERROR: cannot infer issue number from branch name; require explicit issue"; exit 1 +fi +``` + +### 2. Merge and push + +```bash +git fetch origin --prune +git checkout main +git pull --ff-only origin main +git merge --no-ff "$FEATURE_BRANCH" +git push origin main +MERGE_SHA="$(git rev-parse --short HEAD)" +``` + +### 3. Delete feature branch + +```bash +git push origin --delete "$FEATURE_BRANCH" || true +git branch -d "$FEATURE_BRANCH" +git fetch origin --prune +``` + +### 4. Discover PR number (best effort) + +```bash +PR_NUMBER="$(curl -sf \ + -H "Authorization: token $(pass show weforge/fulcrum-token)" \ + "https://weforge.build/api/v1/repos/${OWNER}/${REPO}/pulls?state=closed&limit=100" \ + | jq -r --arg b "$FEATURE_BRANCH" '.[] | select(.head.ref == $b) | .number' \ + | head -n1)" +``` + +If PR is not found, continue with issue close using merge commit only. + +### 5. Comment and close issue + +```bash +if [ -n "$PR_NUMBER" ]; then + BODY="Implemented and merged in PR #${PR_NUMBER} (commit ${MERGE_SHA}). Closing as complete." +else + BODY="Implemented and merged in commit ${MERGE_SHA}. Closing as complete." +fi + +curl -sf -X POST \ + -H "Authorization: token $(pass show weforge/fulcrum-token)" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg body "$BODY" '{body: $body}')" \ + "https://weforge.build/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}/comments" >/dev/null + +curl -sf -X PATCH \ + -H "Authorization: token $(pass show weforge/fulcrum-token)" \ + -H "Content-Type: application/json" \ + -d '{"state":"closed"}' \ + "https://weforge.build/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}" >/dev/null +``` + +### 6. Verify and report + +```bash +git status --short +git branch --show-current +git rev-parse --short HEAD + +curl -sf \ + -H "Authorization: token $(pass show weforge/fulcrum-token)" \ + "https://weforge.build/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}" \ + | jq -r '.state' +``` + +Success conditions: +- current branch is `main` +- working tree is clean +- feature branch absent on origin +- issue state is `closed` + +--- + +## Failure Policy + +- If merge/push fails: stop immediately, do not close issue. +- If branch deletion fails after successful merge: report partial completion and keep issue open. +- If issue comment/close API fails: report partial completion and include exact failing step. + +--- + +## Related Skills + +- `forgejo-api` for API endpoint details +- `credentials` for token-safe command patterns diff --git a/skills/planning/SKILL.md b/skills/planning/SKILL.md new file mode 100644 index 0000000..7853dc0 --- /dev/null +++ b/skills/planning/SKILL.md @@ -0,0 +1,84 @@ +--- +name: planning +description: Session planning discipline for issue-tracker-first execution. Use for selecting next work, declaring a concrete session goal, and closing with explicit state updates. +--- + +# Planning + +Plan from the issue graph, not from memory. The goal is always a single, +verifiable increment that can be completed in one focused session. + +For issue decomposition and boundary contracts, use `issue-craft`. +For first-principles design decisions, use `ground`. + +## Goal +Choose the highest-leverage unblocked issue, execute one session-sized +increment, and leave the next session a truthful handoff. + +## Constraints +- `issue-tracker-source-of-truth`: planning state lives in forge issues, not local task trackers. +- `session-goal-declared-first`: write one concrete session goal before execution. +- `one-session-increment`: commit to one independently verifiable increment. +- `dependencies-are-hard-blockers`: do not start blocked work. +- `session-close-mandatory`: every session ends with explicit state update. + +## Requirements +- `next-action-is-executable`: next action names artifact, command, and done condition. +- `priority-from-impact`: prioritize value, time criticality, and unblock leverage. +- `scope-gate-explicit`: record what is intentionally out of scope for this session. +- `state-is-honest`: issue status/comments reflect actual implementation state. +- `handoff-is-actionable`: end with concrete next step for the next session. + +## Procedures + +### session-open +1. Read operator request and relevant issue thread(s). +2. Identify all ready (unblocked) candidate issues. +3. Apply force filters first: direct operator request or hard deadline. +4. Rank top candidates by: +- value +- time criticality +- unblock leverage +- expected effort +5. Select one issue-sized increment. +6. Declare session goal and scope gate before touching code. + +### choose-next-issue +Decision stack (<=3 minutes): +1. Force filter: operator request or deadline cliff wins immediately. +2. Shortlist 3-5 unblocked issues. +3. Score with WSJF-lite: +`(Value + TimeCriticality + UnblockLeverage) / Effort` +4. Prefer the highest score that can be completed this session. +5. If tie: choose the option that unblocks the most downstream work. + +### define-session-goal +Write: +- `Session goal`: one observable outcome (artifact or behavior). +- `Done condition`: binary pass/fail check. +- `Scope gate`: specific nearby work intentionally excluded this session. + +### session-close +1. Reach stable checkpoint (done increment or explicit WIP note). +2. Update issue state and leave a concise progress comment. +3. Record decisions, blockers, and the exact next step. +4. Ensure any follow-up work is represented as issue(s). +5. Sync workspace and close. + +## Corruption Modes +- `recency-drift`: picking last-touched work instead of highest leverage. +- `implicit-goal`: starting implementation without explicit session goal. +- `scope-creep`: crossing concern boundaries mid-session. +- `blocker-bypass`: beginning blocked work anyway. +- `state-lag`: issue tracker not reflecting real implementation state. +- `open-loop-close`: ending session without a concrete next step. + +## Principles +- `clarity-over-volume`: fewer, sharper goals beat broad, vague activity. +- `truthful-state`: inaccurate issue state is planning debt. +- `finish-or-frame`: either finish the increment or clearly frame unfinished state. + +## Cross-References +- `issue-craft`: decomposition, issue boundaries, acceptance criteria contracts. +- `ground`: validate assumptions before committing to an approach. +- `bdd`: behavior-first test strategy for implementation increments. diff --git a/tests/gitignore_skill_targets_smoke.sh b/tests/gitignore_skill_targets_smoke.sh new file mode 100755 index 0000000..c0f65af --- /dev/null +++ b/tests/gitignore_skill_targets_smoke.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +OUT="$(printf '%s\n' \ + '.agents/skills' \ + '.agents/skills/' \ + '.claude/skills' \ + '.claude/skills/' \ + '.opencode/skills' \ + '.opencode/skills/' \ + | git check-ignore -v --stdin --non-matching)" + +echo "$OUT" + +if echo "$OUT" | grep -q '^::'; then + echo "expected all managed target forms to be ignored" + exit 1 +fi + +for pattern in '/.agents/skills' '/.claude/skills' '/.opencode/skills'; do + if ! echo "$OUT" | grep -q "$pattern"; then + echo "missing ignore match for $pattern" + exit 1 + fi +done + +echo "gitignore managed target smoke test passed" diff --git a/tests/loadout_pilot_smoke.sh b/tests/loadout_pilot_smoke.sh new file mode 100755 index 0000000..bd01f99 --- /dev/null +++ b/tests/loadout_pilot_smoke.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +command -v loadout >/dev/null 2>&1 || { + echo "loadout is required in PATH" + exit 1 +} + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +PROJECT="$WORKDIR/agentd-smoke" +mkdir -p "$PROJECT" +cp -a skills "$PROJECT/skills" +cp -a .loadout "$PROJECT/.loadout" + +cd "$PROJECT" + +LOADOUT_CONFIG="$PROJECT/.loadout/agentd.toml" loadout validate +LOADOUT_CONFIG="$PROJECT/.loadout/agentd.toml" loadout check +LOADOUT_CONFIG="$PROJECT/.loadout/agentd.toml" loadout install + +EXPECTED="$(printf '%s\n' bdd ground issue-craft land planning | sort)" + +list_names() { + local dir="$1" + find "$dir" -mindepth 1 -maxdepth 1 -type l -printf '%f\n' | sort +} + +CODEX_LIST="$(list_names ".agents/skills")" +CLAUDE_LIST="$(list_names ".claude/skills")" +OPENCODE_LIST="$(list_names ".opencode/skills")" + +[[ "$CODEX_LIST" == "$CLAUDE_LIST" ]] || { + echo "mismatch: codex vs claude" + exit 1 +} + +[[ "$CODEX_LIST" == "$OPENCODE_LIST" ]] || { + echo "mismatch: codex vs opencode" + exit 1 +} + +[[ "$CODEX_LIST" == "$EXPECTED" ]] || { + echo "unexpected enabled skill set" + echo "expected:" + echo "$EXPECTED" + echo "actual:" + echo "$CODEX_LIST" + exit 1 +} + +awk ' + BEGIN { in_global = 0 } + /^\[global\]$/ { in_global = 1; print; next } + /^\[/ { in_global = 0 } + in_global && /^skills = / { print "skills = []"; next } + { print } +' ".loadout/agentd.toml" > ".loadout/agentd.toml.tmp" +mv ".loadout/agentd.toml.tmp" ".loadout/agentd.toml" + +LOADOUT_CONFIG="$PROJECT/.loadout/agentd.toml" loadout install +LOADOUT_CONFIG="$PROJECT/.loadout/agentd.toml" loadout clean +LOADOUT_CONFIG="$PROJECT/.loadout/agentd.toml" loadout install + +[[ -z "$(find .agents -type l -print)" ]] || { + echo "expected empty codex target after disabling skills" + exit 1 +} +[[ -z "$(find .claude -type l -print)" ]] || { + echo "expected empty claude target after disabling skills" + exit 1 +} +[[ -z "$(find .opencode -type l -print)" ]] || { + echo "expected empty opencode target after disabling skills" + exit 1 +} + +echo "loadout pilot smoke test passed"