diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1fa49f4..2fd43f3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,7 +7,14 @@ updates: open-pull-requests-limit: 3 commit-message: prefix: "chore(audit)" + - package-ecosystem: "npm" + directory: "/tools/loop-init" + schedule: + interval: "weekly" + open-pull-requests-limit: 3 + commit-message: + prefix: "chore(loop-init)" - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 4671987..178dd39 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -10,7 +10,6 @@ on: pull_request: branches: [main] schedule: - # Daily-ish dogfood of the reference itself - cron: '17 9 * * *' jobs: @@ -31,33 +30,44 @@ jobs: npm ci npm test echo "=== Audit of repo root ===" - node dist/cli.js ../../ || true + node dist/cli.js ../../ --json > /tmp/root-audit.json echo "" - echo "=== Audit of starters ===" + echo "=== Audit of starters (L1 gate) ===" + FAILED=0 for s in ../../starters/*/; do - echo "--- $(basename "$s") ---" - node dist/cli.js "$s" || true + NAME=$(basename "$s") + node dist/cli.js "$s" --json > "/tmp/starter-${NAME}.json" + node -e " + const data = JSON.parse(require('fs').readFileSync('/tmp/starter-${NAME}.json', 'utf8')); + console.log('--- ${NAME}: score=' + data.score + ' level=' + data.level); + if (data.score < 38) { + console.error('Starter ${NAME} below L1 threshold (38): ' + data.score); + process.exit(1); + } + " || FAILED=1 done + if [ "$FAILED" -ne 0 ]; then + echo "One or more starters failed L1 gate" + exit 1 + fi + cp /tmp/root-audit.json /tmp/audit.json - name: Report score and gate on critically low (reference is source, not a consumer project) run: | - cd tools/loop-audit - node dist/cli.js ../../ --json > /tmp/audit.json || true - SCORE=$(node -e ' + node -e ' const fs = require("fs"); try { const data = JSON.parse(fs.readFileSync("/tmp/audit.json", "utf8")); console.log("Reference score: " + data.score); - // Reference repo dogfoods L2: STATE.md, skills/, AGENTS.md, LOOP.md gates. if (data.score < 58) { console.error("Reference score below L2 threshold (58). Restore dogfood signals: STATE.md, skills/, AGENTS.md."); process.exit(2); } } catch (e) { console.error("Could not parse audit JSON:", e.message); - process.exit(0); // do not fail the whole job on parse issues + process.exit(1); } - ') + ' - name: Comment PR with loop readiness score if: github.event_name == 'pull_request' @@ -89,4 +99,4 @@ jobs: repo: context.repo.repo, issue_number: context.issue.number, body, - }); + }); \ No newline at end of file diff --git a/.github/workflows/changelog-drafter.yml b/.github/workflows/changelog-drafter.yml new file mode 100644 index 0000000..c9350c4 --- /dev/null +++ b/.github/workflows/changelog-drafter.yml @@ -0,0 +1,86 @@ +name: Changelog Drafter (dogfood) + +on: + schedule: + - cron: '30 18 * * 1' + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + draft: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Summarize release window + id: window + run: | + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "none") + SINCE=$(git log -1 --format=%ci "${LAST_TAG}" 2>/dev/null || git log -1 --format=%ci) + echo "last_tag=${LAST_TAG}" >> "$GITHUB_OUTPUT" + echo "since=${SINCE}" >> "$GITHUB_OUTPUT" + git log --oneline "${LAST_TAG}..HEAD" 2>/dev/null | head -30 || git log --oneline -15 + + - name: Open or update release-prep issue (L1 — human drafts notes) + uses: actions/github-script@v9 + with: + script: | + const title = `Release prep — week of ${new Date().toISOString().slice(0, 10)}`; + const body = [ + '## Changelog drafter (L1 dogfood)', + '', + 'Automated scan surface for release notes. **Human reviews and edits before any publish.**', + '', + `- Last tag: \`${{ steps.window.outputs.last_tag }}\``, + `- Window since: ${{ steps.window.outputs.since }}`, + '', + '### Next steps', + '1. Run the changelog-drafter starter skills locally or in your agent TUI.', + '2. Produce `RELEASE_NOTES_DRAFT.md` from merges since the last tag.', + '3. Update `changelog-drafter-state.md` when the draft is ready.', + '', + 'Pattern: [changelog-drafter.md](https://github.com/cobusgreyling/loop-engineering/blob/main/patterns/changelog-drafter.md)', + '', + '_Generated by `.github/workflows/changelog-drafter.yml` — report-only, no auto-publish._', + ].join('\n'); + + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + labels: 'release-prep', + state: 'open', + per_page: 1, + }); + + if (issues.length) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issues[0].number, + body: `### Weekly scan refresh\n\n${body}`, + }); + return; + } + + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'release-prep', + color: '3ee8c5', + description: 'Release notes draft tracking (changelog-drafter L1)', + }); + } catch (_) {} + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + labels: ['release-prep'], + body, + }); \ No newline at end of file diff --git a/.github/workflows/daily-triage.yml b/.github/workflows/daily-triage.yml index 01c99e9..4527084 100644 --- a/.github/workflows/daily-triage.yml +++ b/.github/workflows/daily-triage.yml @@ -65,14 +65,14 @@ jobs: ## High Priority (loop is acting or waiting on human) - Maintain loop readiness score ≥ 58 (current: **${SCORE}**, level **${LEVEL}**). - - Publish \`@cobusgreyling/loop-audit\` to npm if not yet live (enables \`npx\` installs). + - Publish \`@cobusgreyling/loop-audit\` and \`@cobusgreyling/loop-init\` to npm when \`NPM_TOKEN\` is configured (see docs/RELEASE.md). $([ "$FAILING" -gt 0 ] && echo "- **${FAILING}** dogfood workflow(s) failing — investigate CI.") ## Watch List - Expand contributor failure stories (dependency sweeper, multi-loop). - - Complete Claude Code / Codex starters for all L2 patterns. - - Run \`loop-init\` on a fresh project and verify scaffold output. + - Collect a production story for Post-Merge Cleanup. + - Validate \`loop-init\` scaffolds on fresh projects across all patterns. ## Recent Noise (ignored this run) diff --git a/.github/workflows/release-loop-init.yml b/.github/workflows/release-loop-init.yml index 74e764f..3f2b6c7 100644 --- a/.github/workflows/release-loop-init.yml +++ b/.github/workflows/release-loop-init.yml @@ -20,11 +20,11 @@ jobs: node-version: '22' registry-url: 'https://registry.npmjs.org' - - name: Build & publish + - name: Test, build & publish working-directory: tools/loop-init run: | - npm install - npm run build + npm ci + npm test npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/validate-patterns.yml b/.github/workflows/validate-patterns.yml index 13c0875..85b9a8d 100644 --- a/.github/workflows/validate-patterns.yml +++ b/.github/workflows/validate-patterns.yml @@ -12,6 +12,10 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Check registry covers all pattern files run: | set -e @@ -44,7 +48,16 @@ jobs: test -f templates/loop-budget.md.template || (echo "Missing loop-budget template"; exit 1) echo "Templates present ✓" - - name: Validate registry.yaml schema + - name: Validate registry.yaml schema and starter paths run: | - npm install --no-save yaml@2 + npm install --no-save yaml@2 ajv@8 node scripts/validate-registry.mjs + + - name: Verify loop-init pattern sync + run: node scripts/check-loop-init-sync.mjs + + - name: Smoke test loop-init package layout + run: | + cd tools/loop-init + npm ci + npm test \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..23fa2e1 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,8 @@ +# Default owner for review routing (solo maintainer today). +* @cobusgreyling + +/patterns/ @cobusgreyling +/tools/ @cobusgreyling +/.github/ @cobusgreyling +/docs/ @cobusgreyling +/starters/ @cobusgreyling \ No newline at end of file diff --git a/README.md b/README.md index 681e897..fa06c8e 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,8 @@

Cobus Greyling -
-

-

- - **Loop engineering is replacing yourself as the person who prompts the agent. You design the system that does it instead.** A loop is a recursive goal: you define a purpose and the AI iterates (often with sub-agents, verification, and external state) until the goal is complete or the loop decides to hand off to you. @@ -132,6 +127,13 @@ bash scripts/before-after-demo.sh /loop 1d Run loop-triage. Update STATE.md. No auto-fix in week one. ``` +Packages publish from tagged releases — see [docs/RELEASE.md](docs/RELEASE.md). Until npm is live, run from this repo: + +```bash +cd tools/loop-init && npm ci && npm test && node dist/cli.js /path/to/project --pattern daily-triage --tool grok +cd tools/loop-audit && npm ci && npm test && node dist/cli.js /path/to/project --suggest +``` + Phased rollout: **L1 report → L2 assisted fixes → L3 unattended** — see [loop-design-checklist](docs/loop-design-checklist.md). ## Examples by Tool @@ -166,7 +168,7 @@ Addy Osmani: ## Contributing -Share production patterns, tool mappings, and failure stories. See [CONTRIBUTING.md](CONTRIBUTING.md). +Share production patterns, tool mappings, and failure stories. See [CONTRIBUTING.md](CONTRIBUTING.md) and [GitHub Discussions](https://github.com/cobusgreyling/loop-engineering/discussions). ## Sources diff --git a/SECURITY.md b/SECURITY.md index 2aef24f..68e9645 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,10 @@ Loop engineering runs unattended automation against your codebase. Treat loops l ## Reporting vulnerabilities -Email security concerns privately if you discover issues in this reference repo or in `loop-audit` / `loop-init` tooling. Do not open public issues for exploitable vulnerabilities. +Report security issues **privately** — do not open public issues for exploitable vulnerabilities. + +- **Preferred:** [GitHub private vulnerability reporting](https://github.com/cobusgreyling/loop-engineering/security/advisories/new) +- **Email:** security@cobusgreyling.me (PGP on request) For general loop safety guidance, see [docs/safety.md](docs/safety.md). diff --git a/STATE.md b/STATE.md index 8726595..d894f5c 100644 --- a/STATE.md +++ b/STATE.md @@ -1,22 +1,21 @@ # Loop State — loop-engineering reference -Last run: 2026-06-09T10:49:32Z (automated daily-triage workflow) +Last run: 2026-06-09T13:45:00Z (manual improvement batch) ## High Priority (loop is acting or waiting on human) - Maintain loop readiness score ≥ 58 (current: **100**, level **L3**). -- Publish `@cobusgreyling/loop-audit` to npm if not yet live (enables `npx` installs). - +- Add `NPM_TOKEN` repo secret and publish `@cobusgreyling/loop-audit` + `@cobusgreyling/loop-init` (see [docs/RELEASE.md](docs/RELEASE.md)). ## Watch List -- Expand contributor failure stories (dependency sweeper, multi-loop) beyond the six in `stories/`. -- Complete Claude Code / Codex starters for all L2 patterns. -- Validate `loop-init` scaffolds on fresh projects across all patterns (including changelog-drafter). +- Expand contributor failure stories (dependency sweeper, multi-loop) beyond stories in `stories/`. +- Collect a production story for Post-Merge Cleanup. +- Monitor starter L1 gates in `audit.yml` after the loop-init packaging fix. ## Recent Noise (ignored this run) — --- -Run log: Updated by `.github/workflows/daily-triage.yml`. See `LOOP.md` for cadence and gates. +Run log: Updated by `.github/workflows/daily-triage.yml`. See `LOOP.md` for cadence and gates. \ No newline at end of file diff --git a/docs/GITHUB_PAGES.md b/docs/GITHUB_PAGES.md index e7c1016..dd950cc 100644 --- a/docs/GITHUB_PAGES.md +++ b/docs/GITHUB_PAGES.md @@ -5,10 +5,10 @@ 3. **Folder**: `/docs` 4. Save — showcase at `https://cobusgreyling.github.io/loop-engineering/` -The homepage (`docs/index.md`) is the full interactive showcase with patterns, primitives, quick-start, and an anatomy diagram. +The homepage (`docs/index.html`) is the full interactive showcase with patterns, primitives, quick-start, and an anatomy diagram. -The root `README.md` and `LOOP.md` now describe how this reference dogfoods its own patterns via `.github/workflows/audit.yml` + `validate-patterns.yml`. +The root `README.md` and `LOOP.md` describe how this reference dogfoods its own patterns via `.github/workflows/audit.yml` + `validate-patterns.yml`. -First deploy may take 1–2 minutes. After enabling, the daily scheduled audit workflow will also keep the published site "honest" about the reference's own loop readiness. +First deploy may take 1–2 minutes. After enabling, the daily scheduled audit workflow keeps the published site aligned with the reference's loop readiness score. -**Pro tip**: once live, run `node tools/loop-audit/dist/cli.js . --suggest` locally and improve the score over time — the workflows will reflect it. +**Pro tip**: run `node tools/loop-audit/dist/cli.js . --suggest` locally and improve the score over time — the workflows will reflect it. \ No newline at end of file diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..4ef8703 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,51 @@ +# Release playbook — npm packages + +This repo ships two public npm packages from `tools/`: + +| Package | Directory | Release tag | +|---------|-----------|-------------| +| `@cobusgreyling/loop-audit` | `tools/loop-audit` | `loop-audit-v*` | +| `@cobusgreyling/loop-init` | `tools/loop-init` | `loop-init-v*` | + +## One-time setup + +1. Create an npm org/user scope `@cobusgreyling` on [npmjs.com](https://www.npmjs.com/). +2. Generate an npm **Automation** or **Publish** token. +3. Add it to the repo as **`NPM_TOKEN`** (Settings → Secrets → Actions). + +## Version bump + +Edit `version` in the package `package.json`, update that package's `CHANGELOG.md` if present, and commit to `main` via PR. + +## Publish + +Tag pushes trigger the release workflows: + +```bash +# loop-audit (runs tests before publish) +git tag loop-audit-v1.3.0 +git push origin loop-audit-v1.3.0 + +# loop-init (bundles starters/templates, runs smoke tests) +git tag loop-init-v1.1.0 +git push origin loop-init-v1.1.0 +``` + +Workflows: `.github/workflows/release-loop-audit.yml`, `.github/workflows/release-loop-init.yml`. + +## Verify after publish + +```bash +npx @cobusgreyling/loop-audit --help +npx @cobusgreyling/loop-init --help + +mkdir /tmp/loop-init-test && cd /tmp/loop-init-test +npx @cobusgreyling/loop-init . --pattern daily-triage --tool grok --dry-run +``` + +## Before npm is live (local / monorepo) + +```bash +cd tools/loop-audit && npm ci && npm test && node dist/cli.js ../.. --suggest +cd tools/loop-init && npm ci && npm test && node dist/cli.js /tmp/target --pattern daily-triage --dry-run +``` \ No newline at end of file diff --git a/docs/index.html b/docs/index.html index a7a2f4a..03670d2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -11,7 +11,7 @@ - + diff --git a/examples/README.md b/examples/README.md index 3a88df2..5abeef3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,11 +10,20 @@ Same patterns, different tools. Skills and state schemas are shared; only schedu | GitHub Actions | [github-actions/](./github-actions/) | | MCP connectors | [mcp/](./mcp/) | -New in this release of the reference: **Changelog Drafter** pattern + full starter (see patterns/ and starters/changelog-drafter). - Start with [primitives-matrix.md](../docs/primitives-matrix.md) to map capabilities. -**New pattern**: [Changelog Drafter](../patterns/changelog-drafter.md) — low-risk, high-ROI loop for release notes. Starter available for all tools. +## Pattern coverage + +| Pattern | Grok | Claude | Codex | GH Actions | +|---------|------|--------|-------|------------| +| Daily Triage | [grok/daily-triage.md](./grok/daily-triage.md) | [claude-code/daily-triage.md](./claude-code/daily-triage.md) | [codex/daily-triage.md](./codex/daily-triage.md) | [github-actions/daily-triage.yml](./github-actions/daily-triage.yml) | +| PR Babysitter | [grok/pr-babysitter.md](./grok/pr-babysitter.md) | [claude-code/pr-babysitter.md](./claude-code/pr-babysitter.md) | [codex/pr-babysitter.md](./codex/pr-babysitter.md) | [github-actions/pr-babysitter.yml](./github-actions/pr-babysitter.yml) | +| CI Sweeper | [grok/ci-sweeper.md](./grok/ci-sweeper.md) | [claude-code/ci-sweeper.md](./claude-code/ci-sweeper.md) | [codex/ci-sweeper.md](./codex/ci-sweeper.md) | [github-actions/ci-sweeper.yml](./github-actions/ci-sweeper.yml) | +| Post-Merge Cleanup | [grok/post-merge-cleanup.md](./grok/post-merge-cleanup.md) | [claude-code/post-merge-cleanup.md](./claude-code/post-merge-cleanup.md) | [codex/post-merge-cleanup.md](./codex/post-merge-cleanup.md) | [github-actions/post-merge-cleanup.yml](./github-actions/post-merge-cleanup.yml) | +| Dependency Sweeper | [grok/dependency-sweeper.md](./grok/dependency-sweeper.md) | [claude-code/dependency-sweeper.md](./claude-code/dependency-sweeper.md) | [codex/dependency-sweeper.md](./codex/dependency-sweeper.md) | [github-actions/dependency-sweeper.yml](./github-actions/dependency-sweeper.yml) | +| Changelog Drafter | [grok/changelog-drafter.md](./grok/changelog-drafter.md) | [claude-code/changelog-drafter.md](./claude-code/changelog-drafter.md) | [codex/changelog-drafter.md](./codex/changelog-drafter.md) | [github-actions/changelog-drafter.yml](./github-actions/changelog-drafter.yml) | + +L2 patterns ship multi-tool skills inside one starter folder — see `starters//`. **Copy-paste starters** (L1 daily triage): diff --git a/examples/claude-code/changelog-drafter.md b/examples/claude-code/changelog-drafter.md new file mode 100644 index 0000000..5f52386 --- /dev/null +++ b/examples/claude-code/changelog-drafter.md @@ -0,0 +1,7 @@ +# Claude Code — Changelog Drafter Example + +```text +/loop 1d $changelog-scan + $draft-release-notes — write RELEASE_NOTES_DRAFT.md and update changelog-drafter-state.md. Human approves before any publish or tag. +``` + +Week one: draft-only (L1). Starter: [starters/changelog-drafter](../../starters/changelog-drafter/). \ No newline at end of file diff --git a/examples/claude-code/ci-sweeper.md b/examples/claude-code/ci-sweeper.md new file mode 100644 index 0000000..99a60e9 --- /dev/null +++ b/examples/claude-code/ci-sweeper.md @@ -0,0 +1,7 @@ +# Claude Code — CI Sweeper Example + +```text +/loop 15m $ci-triage — update ci-sweeper-state.md. Classify failures first. Fix only clear regressions in a worktree with verifier. Max 3 attempts. Escalate infra and security test failures. +``` + +Copy skills from [starters/ci-sweeper/.claude](../../starters/ci-sweeper/.claude/). \ No newline at end of file diff --git a/examples/claude-code/dependency-sweeper.md b/examples/claude-code/dependency-sweeper.md new file mode 100644 index 0000000..6ece754 --- /dev/null +++ b/examples/claude-code/dependency-sweeper.md @@ -0,0 +1,7 @@ +# Claude Code — Dependency Sweeper Example + +```text +/loop 6h $dependency-triage — patch-only with verifier in worktree. Update dependency-sweeper-state.md. Escalate majors and high-severity CVEs. +``` + +See [patterns/dependency-sweeper.md](../../patterns/dependency-sweeper.md). \ No newline at end of file diff --git a/examples/claude-code/post-merge-cleanup.md b/examples/claude-code/post-merge-cleanup.md new file mode 100644 index 0000000..48c683e --- /dev/null +++ b/examples/claude-code/post-merge-cleanup.md @@ -0,0 +1,7 @@ +# Claude Code — Post-Merge Cleanup Example + +```text +/loop 1d $post-merge-scan — update post-merge-state.md. Small fixes only; ticket large debt. No changes on denylist paths without human approval. +``` + +Starter: [starters/post-merge-cleanup](../../starters/post-merge-cleanup/). \ No newline at end of file diff --git a/examples/codex/changelog-drafter.md b/examples/codex/changelog-drafter.md new file mode 100644 index 0000000..f33e956 --- /dev/null +++ b/examples/codex/changelog-drafter.md @@ -0,0 +1,10 @@ +# Codex — Changelog Drafter Example + +Daily automation: + +1. `changelog-scan` since last tag or state date +2. `draft-release-notes` → `RELEASE_NOTES_DRAFT.md` +3. Update `changelog-drafter-state.md` +4. Human review before merge to `CHANGELOG.md` + +See [examples/grok/changelog-drafter.md](../grok/changelog-drafter.md) for the Grok invocation variant. \ No newline at end of file diff --git a/examples/codex/ci-sweeper.md b/examples/codex/ci-sweeper.md new file mode 100644 index 0000000..d174d51 --- /dev/null +++ b/examples/codex/ci-sweeper.md @@ -0,0 +1,10 @@ +# Codex — CI Sweeper Example + +Schedule a **15m** automation: + +1. Run `ci-triage` skill on failing checks +2. Update `ci-sweeper-state.md` +3. Attempt minimal fixes in isolated checkout with verifier sub-agent +4. Stop after 3 failures on the same root cause + +See [starters/ci-sweeper/.codex](../../starters/ci-sweeper/.codex/). \ No newline at end of file diff --git a/examples/codex/dependency-sweeper.md b/examples/codex/dependency-sweeper.md new file mode 100644 index 0000000..8bbf900 --- /dev/null +++ b/examples/codex/dependency-sweeper.md @@ -0,0 +1,10 @@ +# Codex — Dependency Sweeper Example + +**6h** automation: + +1. `dependency-triage` — scan lockfiles and advisories +2. Patch-only fixes with verifier +3. Update `dependency-sweeper-state.md` +4. Human gate on major version bumps + +Starter: [starters/dependency-sweeper](../../starters/dependency-sweeper/). \ No newline at end of file diff --git a/examples/codex/post-merge-cleanup.md b/examples/codex/post-merge-cleanup.md new file mode 100644 index 0000000..354b793 --- /dev/null +++ b/examples/codex/post-merge-cleanup.md @@ -0,0 +1,10 @@ +# Codex — Post-Merge Cleanup Example + +Daily automation: + +1. `post-merge-scan` on recent merges +2. Update `post-merge-state.md` +3. Optional `minimal-fix` in worktree for docs/lint only +4. Escalate architectural items to issues + +Pattern: [post-merge-cleanup.md](../../patterns/post-merge-cleanup.md) \ No newline at end of file diff --git a/examples/github-actions/dependency-sweeper.yml b/examples/github-actions/dependency-sweeper.yml new file mode 100644 index 0000000..316e1da --- /dev/null +++ b/examples/github-actions/dependency-sweeper.yml @@ -0,0 +1,25 @@ +name: Dependency Sweeper (example) + +on: + schedule: + - cron: '0 */6 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + sweep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Placeholder — wire to your agent runtime + run: | + echo "Run dependency-triage (npm audit / outdated)." + echo "Patch-only fixes in worktree with verifier." + echo "Update dependency-sweeper-state.md — escalate majors." \ No newline at end of file diff --git a/examples/github-actions/pr-babysitter.yml b/examples/github-actions/pr-babysitter.yml new file mode 100644 index 0000000..d765022 --- /dev/null +++ b/examples/github-actions/pr-babysitter.yml @@ -0,0 +1,23 @@ +name: PR Babysitter (example) + +on: + schedule: + - cron: '*/15 * * * *' + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + issues: read + +jobs: + babysit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Placeholder — wire to your agent runtime + run: | + echo "Run pr-review-triage against open PRs." + echo "Update pr-babysitter-state.md in a follow-up PR." + echo "No auto-merge in week one — see patterns/pr-babysitter.md" \ No newline at end of file diff --git a/examples/grok/ci-sweeper.md b/examples/grok/ci-sweeper.md new file mode 100644 index 0000000..5c5473d --- /dev/null +++ b/examples/grok/ci-sweeper.md @@ -0,0 +1,7 @@ +# Grok — CI Sweeper Example + +```bash +/loop 15m Run ci-triage on failing CI for default branch and open PRs. Update ci-sweeper-state.md. Fix only clear regressions in an isolated worktree. Spawn loop-verifier before any push. Max 3 attempts per failure cluster. +``` + +Week one: classify-only — no fixes until triage accuracy is trusted. See [patterns/ci-sweeper.md](../../patterns/ci-sweeper.md). \ No newline at end of file diff --git a/examples/grok/dependency-sweeper.md b/examples/grok/dependency-sweeper.md new file mode 100644 index 0000000..daece8e --- /dev/null +++ b/examples/grok/dependency-sweeper.md @@ -0,0 +1,7 @@ +# Grok — Dependency Sweeper Example + +```bash +/loop 6h Run dependency-triage for outdated and vulnerable packages. Update dependency-sweeper-state.md. Patch-only auto-fix in worktree with loop-verifier. Escalate major bumps and denylisted packages. +``` + +See [patterns/dependency-sweeper.md](../../patterns/dependency-sweeper.md). \ No newline at end of file diff --git a/examples/grok/post-merge-cleanup.md b/examples/grok/post-merge-cleanup.md new file mode 100644 index 0000000..a95a4b0 --- /dev/null +++ b/examples/grok/post-merge-cleanup.md @@ -0,0 +1,7 @@ +# Grok — Post-Merge Cleanup Example + +```bash +/loop 1d Run post-merge-scan on merges to main in the last 48h. Update post-merge-state.md. Propose only small doc/lint/comment fixes in a worktree. Ticket anything architectural. Human gate on denylist paths. +``` + +See [patterns/post-merge-cleanup.md](../../patterns/post-merge-cleanup.md) and [stories/post-merge-cleanup-honest-win.md](../../stories/post-merge-cleanup-honest-win.md). \ No newline at end of file diff --git a/examples/grok/pr-babysitter.md b/examples/grok/pr-babysitter.md new file mode 100644 index 0000000..ce26a89 --- /dev/null +++ b/examples/grok/pr-babysitter.md @@ -0,0 +1,7 @@ +# Grok — PR Babysitter Example + +```bash +/loop 10m Run pr-review-triage on open PRs. Update pr-babysitter-state.md. Use worktree + minimal-fix + loop-verifier only for allowlisted low-risk PRs. Escalate after 3 attempts. No auto-merge in week one. +``` + +See [patterns/pr-babysitter.md](../../patterns/pr-babysitter.md) and [starters/pr-babysitter](../../starters/pr-babysitter/). \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4772e24..4456c66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,9 +6,68 @@ "": { "name": "loop-engineering", "devDependencies": { + "ajv": "^8.17.1", "yaml": "^2.8.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index 2434080..ef1994e 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,14 @@ "type": "module", "scripts": { "validate:registry": "node scripts/validate-registry.mjs", + "check:loop-init": "node scripts/check-loop-init-sync.mjs", "test:loop-audit": "cd tools/loop-audit && npm test", + "test:loop-init": "cd tools/loop-init && npm test", + "test:tools": "npm run test:loop-audit && npm run test:loop-init", "build:tools": "cd tools/loop-audit && npm run build && cd ../loop-init && npm run build" }, "devDependencies": { + "ajv": "^8.17.1", "yaml": "^2.8.0" } } \ No newline at end of file diff --git a/patterns/README.md b/patterns/README.md index 2fc9abc..c0e1a9a 100644 --- a/patterns/README.md +++ b/patterns/README.md @@ -19,6 +19,7 @@ Each pattern answers: | CI Sweeper | 5–15m | Medium | [ci-sweeper.md](./ci-sweeper.md) | | Post-Merge Cleanup | 1d–6h | Low | [post-merge-cleanup.md](./post-merge-cleanup.md) | | Dependency Sweeper | 6h–1d | Medium | [dependency-sweeper.md](./dependency-sweeper.md) | +| Changelog Drafter | 1d | Low | [changelog-drafter.md](./changelog-drafter.md) | Machine-readable index: [registry.yaml](./registry.yaml) @@ -28,12 +29,9 @@ Machine-readable index: [registry.yaml](./registry.yaml) 2. Scaffold with `npx @cobusgreyling/loop-init . --pattern --tool grok` or copy from `starters/` 3. Copy skills from `templates/` if customizing beyond the starter 4. Set up scheduling (`/loop`, `scheduler_create`, GitHub Action, Codex Automation) -5. Create the initial state file (or let `loop-init` do it) -6. Start the loop — **report-only first** when the pattern supports phased rollout -7. Iterate on the loop definition based on what actually happens +5. Run week one in **L1 report-only** mode before enabling fixes +6. Audit with `npx @cobusgreyling/loop-audit . --suggest` -Good loops are boring and reliable. Start with one that runs every few hours or daily before going to sub-minute cadences. +## Adding a Pattern -## Contributing a Pattern - -See [CONTRIBUTING.md](../CONTRIBUTING.md) and [templates/pattern-template.md](../templates/pattern-template.md). \ No newline at end of file +Use [templates/pattern-template.md](../templates/pattern-template.md), add an entry to `registry.yaml`, and open a PR. See [CONTRIBUTING.md](../CONTRIBUTING.md). \ No newline at end of file diff --git a/scripts/check-loop-init-sync.mjs b/scripts/check-loop-init-sync.mjs new file mode 100644 index 0000000..fdba179 --- /dev/null +++ b/scripts/check-loop-init-sync.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'yaml'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function fail(msg) { + console.error(`ERROR: ${msg}`); + process.exit(1); +} + +const registry = yaml.parse(await readFile(path.join(ROOT, 'patterns/registry.yaml'), 'utf8')); +const cli = await readFile(path.join(ROOT, 'tools/loop-init/src/cli.ts'), 'utf8'); + +for (const p of registry.patterns) { + if (!cli.includes(`'${p.id}'`)) { + fail(`loop-init cli.ts missing pattern id: ${p.id}`); + } +} + +console.log(`loop-init pattern sync OK (${registry.patterns.length} patterns) ✓`); \ No newline at end of file diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs index 5e50eaf..acaa016 100644 --- a/scripts/validate-registry.mjs +++ b/scripts/validate-registry.mjs @@ -1,12 +1,13 @@ #!/usr/bin/env node /** * Validates patterns/registry.yaml against registry.schema.json - * and ensures file/registry alignment. + * and ensures file/registry/starter alignment. */ -import { readFile, readdir } from 'node:fs/promises'; +import { readFile, readdir, stat } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import yaml from 'yaml'; +import Ajv from 'ajv'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -47,8 +48,18 @@ function validatePattern(p, index) { async function main() { const registryPath = path.join(ROOT, 'patterns', 'registry.yaml'); + const schemaPath = path.join(ROOT, 'patterns', 'registry.schema.json'); const raw = await readFile(registryPath, 'utf8'); const doc = yaml.parse(raw); + const schema = JSON.parse(await readFile(schemaPath, 'utf8')); + delete schema.$schema; + + const ajv = new Ajv({ allErrors: true, strict: false }); + const validate = ajv.compile(schema); + if (!validate(doc)) { + fail(`registry schema: ${ajv.errorsText(validate.errors)}`); + } + console.log('JSON Schema validation passed ✓'); if (!doc?.patterns?.length) fail('registry.yaml must have patterns array'); @@ -64,6 +75,15 @@ async function main() { } catch { fail(`registry entry ${p.id} references missing file: patterns/${p.file}`); } + if (p.starter) { + try { + await stat(path.join(ROOT, p.starter)); + } catch { + fail(`registry entry ${p.id} references missing starter: ${p.starter}`); + } + } else { + fail(`registry entry ${p.id} missing starter path`); + } } const mdFiles = (await readdir(path.join(ROOT, 'patterns'))) diff --git a/stories/README.md b/stories/README.md index c09be99..0b269f0 100644 --- a/stories/README.md +++ b/stories/README.md @@ -11,6 +11,7 @@ Real-world loop engineering — including failures. Contribute yours via [CONTRI | [multi-loop-collision.md](./multi-loop-collision.md) | Multi-loop | Branch lock + collision detection | | [l1-to-l2-graduation.md](./l1-to-l2-graduation.md) | Daily Triage | Calibration before auto-fix | | [changelog-drafter-week-one.md](./changelog-drafter-week-one.md) | Changelog Drafter | Low-risk, high-ROI L1 win | +| [post-merge-cleanup-honest-win.md](./post-merge-cleanup-honest-win.md) | Post-Merge Cleanup | Off-peak cadence + L1 report value | **Template for new stories:** diff --git a/stories/post-merge-cleanup-honest-win.md b/stories/post-merge-cleanup-honest-win.md new file mode 100644 index 0000000..c408d82 --- /dev/null +++ b/stories/post-merge-cleanup-honest-win.md @@ -0,0 +1,32 @@ +# Post-Merge Cleanup — small wins without the babysitter tax + +**Pattern:** [post-merge-cleanup](../patterns/post-merge-cleanup.md) +**Cadence:** 1d (off-peak) +**Outcome:** Useful, low-noise follow-up PRs — not a magic janitor + +## Context + +After shipping a feature branch, the team had predictable leftovers: stale comments, half-updated docs, and tiny lint issues that nobody wanted to context-switch for. PR Babysitter felt too heavy for work already on `main`. + +## What we ran + +- **Loop:** daily post-merge scan on the last 24–48h of merges +- **Skills:** `post-merge-scan`, `minimal-fix`, `loop-verifier` +- **State:** `post-merge-state.md` with “fixed / ticketed / ignored” per merge +- **Week one:** L1 report only — the loop listed candidates, humans picked two + +## What worked + +- Off-peak cadence avoided fighting active feature work +- State file prevented re-scanning the same merge three days in a row +- Verifier caught a “doc fix” that secretly changed a public API example + +## What we learned + +- The loop over-triaged bot-authored merge commits — we added a ignore list in state +- Anything touching `auth/` or `payments/` stayed human-only (denylist in LOOP.md) +- Two people got value from the **report** alone without enabling L2 auto-fix + +## Recommendation + +Start L1 for two weeks. If the report is consistently right, enable L2 for docs and comment-only paths. Keep architectural debt in Linear, not in the loop. \ No newline at end of file diff --git a/tools/loop-audit/dist/auditor.js b/tools/loop-audit/dist/auditor.js index 5bb0bfd..24d2e2b 100644 --- a/tools/loop-audit/dist/auditor.js +++ b/tools/loop-audit/dist/auditor.js @@ -5,6 +5,8 @@ const STATE_FILES = [ 'pr-babysitter-state.md', 'ci-sweeper-state.md', 'post-merge-state.md', + 'dependency-sweeper-state.md', + 'changelog-drafter-state.md', ]; const LOOP_SKILL_NAMES = [ 'loop-triage', @@ -14,8 +16,9 @@ const LOOP_SKILL_NAMES = [ 'ci-triage', 'post-merge-scan', 'dependency-triage', - 'post-merge-scan', 'rebase-and-clean', + 'changelog-scan', + 'draft-release-notes', ]; const SAFETY_FILES = ['safety.md', 'docs/safety.md', 'SECURITY.md']; const MCP_FILES = ['.mcp.json', 'mcp.json', '.mcp/config.json']; @@ -133,7 +136,8 @@ export async function auditProject(target) { skillNames.includes('pr-review-triage') || skillNames.includes('ci-triage') || skillNames.includes('dependency-triage') || - skillNames.includes('post-merge-scan'); + skillNames.includes('post-merge-scan') || + skillNames.includes('changelog-scan'); let loopMdContent = ''; if (loopMd) { loopMdContent = await readFile(path.join(root, 'LOOP.md'), 'utf8'); diff --git a/tools/loop-audit/src/auditor.ts b/tools/loop-audit/src/auditor.ts index 1807611..de7bad8 100644 --- a/tools/loop-audit/src/auditor.ts +++ b/tools/loop-audit/src/auditor.ts @@ -37,6 +37,8 @@ const STATE_FILES = [ 'pr-babysitter-state.md', 'ci-sweeper-state.md', 'post-merge-state.md', + 'dependency-sweeper-state.md', + 'changelog-drafter-state.md', ]; const LOOP_SKILL_NAMES = [ @@ -47,8 +49,9 @@ const LOOP_SKILL_NAMES = [ 'ci-triage', 'post-merge-scan', 'dependency-triage', - 'post-merge-scan', 'rebase-and-clean', + 'changelog-scan', + 'draft-release-notes', ]; const SAFETY_FILES = ['safety.md', 'docs/safety.md', 'SECURITY.md']; @@ -158,7 +161,8 @@ export async function auditProject(target: string): Promise { skillNames.includes('pr-review-triage') || skillNames.includes('ci-triage') || skillNames.includes('dependency-triage') || - skillNames.includes('post-merge-scan'); + skillNames.includes('post-merge-scan') || + skillNames.includes('changelog-scan'); let loopMdContent = ''; if (loopMd) { diff --git a/tools/loop-init/.gitignore b/tools/loop-init/.gitignore new file mode 100644 index 0000000..2490e50 --- /dev/null +++ b/tools/loop-init/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +starters/ +templates/ \ No newline at end of file diff --git a/tools/loop-init/README.md b/tools/loop-init/README.md index 1c40442..8479119 100644 --- a/tools/loop-init/README.md +++ b/tools/loop-init/README.md @@ -10,6 +10,8 @@ npx @cobusgreyling/loop-init . -p pr-babysitter -t claude npx @cobusgreyling/loop-init . -p dependency-sweeper --dry-run ``` +See [docs/RELEASE.md](../../docs/RELEASE.md) for npm publish tags. The published package bundles `starters/` and `templates/` from this monorepo. + ## Patterns | Pattern | Default state file | @@ -19,6 +21,9 @@ npx @cobusgreyling/loop-init . -p dependency-sweeper --dry-run | `ci-sweeper` | `ci-sweeper-state.md` | | `dependency-sweeper` | `dependency-sweeper-state.md` | | `post-merge-cleanup` | `post-merge-state.md` | +| `changelog-drafter` | `changelog-drafter-state.md` | + +L2 patterns (`ci-sweeper`, `dependency-sweeper`) also copy `minimal-fix` and `loop-verifier` templates when missing from the starter. ## Tools @@ -31,7 +36,7 @@ Falls back to Grok starter paths when a per-tool variant is not yet available. ## From this repo ```bash -cd tools/loop-init && npm install && npm run build +cd tools/loop-init && npm ci && npm test node dist/cli.js /path/to/project --pattern daily-triage --tool grok ``` diff --git a/tools/loop-init/dist/cli.js b/tools/loop-init/dist/cli.js index f6db7ef..16d1001 100644 --- a/tools/loop-init/dist/cli.js +++ b/tools/loop-init/dist/cli.js @@ -1,9 +1,11 @@ #!/usr/bin/env node -import { cp, mkdir, writeFile, access } from 'node:fs/promises'; +import { cp, mkdir, readFile, writeFile, access } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(__dirname, '../../..'); +const PACKAGE_ROOT = path.resolve(__dirname, '..'); +const MONOREPO_STARTERS = path.resolve(PACKAGE_ROOT, '../../starters'); +const MONOREPO_TEMPLATES = path.resolve(PACKAGE_ROOT, '../../templates'); const PATTERN_STARTERS = { 'daily-triage': 'minimal-loop', 'pr-babysitter': 'pr-babysitter', @@ -17,6 +19,13 @@ const TOOL_SUFFIX = { claude: '-claude', codex: '-codex', }; +const L2_PATTERNS = new Set(['ci-sweeper', 'dependency-sweeper']); +const PATTERNS_NEEDING_FIX = new Set([ + 'pr-babysitter', + 'ci-sweeper', + 'dependency-sweeper', + 'post-merge-cleanup', +]); const STATE_FILES = { 'daily-triage': 'STATE.md', 'pr-babysitter': 'pr-babysitter-state.md', @@ -66,6 +75,57 @@ async function copyDir(src, dest, dryRun) { console.log(` copied: ${src} → ${dest}`); return true; } +async function resolveBundledOrMonorepo(name) { + const bundled = path.join(PACKAGE_ROOT, name); + if (await exists(bundled)) + return bundled; + return name === 'starters' ? MONOREPO_STARTERS : MONOREPO_TEMPLATES; +} +async function copyTemplateSkill(templatesRoot, templateFile, targetDir, tool, skillName, dryRun) { + const src = path.join(templatesRoot, templateFile); + const destByTool = { + grok: path.join(targetDir, '.grok', 'skills', skillName, 'SKILL.md'), + claude: path.join(targetDir, '.claude', 'skills', skillName, 'SKILL.md'), + codex: path.join(targetDir, '.codex', 'skills', skillName, 'SKILL.md'), + }; + const dest = destByTool[tool]; + if (await exists(dest)) + return; + await copyFile(src, dest, dryRun); +} +async function copyTemplateVerifier(templatesRoot, targetDir, tool, dryRun) { + const verifierPaths = { + grok: path.join(targetDir, '.grok', 'skills', 'loop-verifier', 'SKILL.md'), + claude: path.join(targetDir, '.claude', 'agents', 'loop-verifier.md'), + codex: path.join(targetDir, '.codex', 'agents', 'verifier.toml'), + }; + const dest = verifierPaths[tool]; + if (await exists(dest)) + return; + if (tool === 'codex') { + const src = path.join(templatesRoot, 'SKILL.md.verifier'); + const body = await readFile(src, 'utf8'); + const toml = `name = "loop-verifier"\ndescription = "Independent verification agent for loop-produced changes."\n\n[system_prompt]\ncontent = """\n${body}\n"""\n`; + if (dryRun) { + console.log(` would write verifier: ${dest}`); + return; + } + await mkdir(path.dirname(dest), { recursive: true }); + await writeFile(dest, toml); + console.log(` created: ${dest} (from verifier template)`); + return; + } + const src = path.join(templatesRoot, 'SKILL.md.verifier'); + await copyFile(src, dest, dryRun); +} +async function copyL2Templates(pattern, tool, targetDir, templatesRoot, dryRun) { + if (!PATTERNS_NEEDING_FIX.has(pattern) && !L2_PATTERNS.has(pattern)) + return; + await copyTemplateSkill(templatesRoot, 'SKILL.md.minimal-fix', targetDir, tool, 'minimal-fix', dryRun); + if (L2_PATTERNS.has(pattern) || pattern === 'dependency-sweeper') { + await copyTemplateVerifier(templatesRoot, targetDir, tool, dryRun); + } +} async function copyFile(src, dest, dryRun) { if (!(await exists(src))) return false; @@ -146,10 +206,11 @@ Examples: const baseStarter = PATTERN_STARTERS[pattern]; const suffix = TOOL_SUFFIX[tool]; const starterName = pattern === 'daily-triage' ? `minimal-loop${suffix}` : baseStarter; - const starterRoot = path.join(REPO_ROOT, 'starters', starterName); + const startersRoot = await resolveBundledOrMonorepo('starters'); + const templatesRoot = await resolveBundledOrMonorepo('templates'); + const starterRoot = path.join(startersRoot, starterName); if (!(await exists(starterRoot))) { - // Fall back to grok starter for patterns without per-tool variants - const fallback = path.join(REPO_ROOT, 'starters', baseStarter); + const fallback = path.join(startersRoot, baseStarter); if (!(await exists(fallback))) { console.error(`Starter not found: ${starterRoot}`); process.exit(1); @@ -158,7 +219,7 @@ Examples: } const effectiveStarter = (await exists(starterRoot)) ? starterRoot - : path.join(REPO_ROOT, 'starters', baseStarter); + : path.join(startersRoot, baseStarter); console.log(`\nloop-init: ${pattern} → ${targetDir} (${tool})${dryRun ? ' [dry-run]' : ''}\n`); const skillRoots = [ path.join(effectiveStarter, '.grok', 'skills'), @@ -205,6 +266,7 @@ Examples: if (await exists(loopMd)) { await copyFile(loopMd, path.join(targetDir, 'LOOP.md'), dryRun); } + await copyL2Templates(pattern, tool, targetDir, templatesRoot, dryRun); if (!dryRun && !(await exists(path.join(targetDir, 'AGENTS.md')))) { const agentsTemplate = `# AGENTS.md diff --git a/tools/loop-init/package.json b/tools/loop-init/package.json index 45557ae..5a15c1c 100644 --- a/tools/loop-init/package.json +++ b/tools/loop-init/package.json @@ -1,6 +1,6 @@ { "name": "@cobusgreyling/loop-init", - "version": "1.0.0", + "version": "1.1.0", "description": "Scaffold loop engineering starters into your project by pattern and tool.", "type": "module", "bin": { @@ -8,11 +8,15 @@ }, "files": [ "dist", + "starters", + "templates", "README.md" ], "scripts": { - "build": "tsc", - "prepublishOnly": "npm run build", + "bundle": "node scripts/bundle-assets.mjs", + "build": "npm run bundle && tsc", + "test": "npm run build && node --test test/cli.test.mjs", + "prepublishOnly": "npm test", "start": "node dist/cli.js" }, "engines": { diff --git a/tools/loop-init/scripts/bundle-assets.mjs b/tools/loop-init/scripts/bundle-assets.mjs new file mode 100644 index 0000000..3ce6744 --- /dev/null +++ b/tools/loop-init/scripts/bundle-assets.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { cp, rm, access } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const REPO_ROOT = path.resolve(PACKAGE_ROOT, '../..'); + +async function exists(p) { + try { + await access(p); + return true; + } catch { + return false; + } +} + +for (const dir of ['starters', 'templates']) { + const dest = path.join(PACKAGE_ROOT, dir); + const src = path.join(REPO_ROOT, dir); + if (!(await exists(src))) { + console.error(`bundle-assets: missing ${src}`); + process.exit(1); + } + await rm(dest, { recursive: true, force: true }); + await cp(src, dest, { recursive: true }); + console.log(`bundled ${dir}/ → tools/loop-init/${dir}/`); +} \ No newline at end of file diff --git a/tools/loop-init/src/cli.ts b/tools/loop-init/src/cli.ts index ff0b2d6..dbc36e8 100644 --- a/tools/loop-init/src/cli.ts +++ b/tools/loop-init/src/cli.ts @@ -4,7 +4,9 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(__dirname, '../../..'); +const PACKAGE_ROOT = path.resolve(__dirname, '..'); +const MONOREPO_STARTERS = path.resolve(PACKAGE_ROOT, '../../starters'); +const MONOREPO_TEMPLATES = path.resolve(PACKAGE_ROOT, '../../templates'); type Pattern = | 'daily-triage' @@ -31,6 +33,15 @@ const TOOL_SUFFIX: Record = { codex: '-codex', }; +const L2_PATTERNS = new Set(['ci-sweeper', 'dependency-sweeper']); + +const PATTERNS_NEEDING_FIX: Set = new Set([ + 'pr-babysitter', + 'ci-sweeper', + 'dependency-sweeper', + 'post-merge-cleanup', +]); + const STATE_FILES: Record = { 'daily-triage': 'STATE.md', 'pr-babysitter': 'pr-babysitter-state.md', @@ -79,6 +90,79 @@ async function copyDir(src: string, dest: string, dryRun: boolean) { return true; } +async function resolveBundledOrMonorepo(name: 'starters' | 'templates'): Promise { + const bundled = path.join(PACKAGE_ROOT, name); + if (await exists(bundled)) return bundled; + return name === 'starters' ? MONOREPO_STARTERS : MONOREPO_TEMPLATES; +} + +async function copyTemplateSkill( + templatesRoot: string, + templateFile: string, + targetDir: string, + tool: Tool, + skillName: string, + dryRun: boolean, +) { + const src = path.join(templatesRoot, templateFile); + const destByTool: Record = { + grok: path.join(targetDir, '.grok', 'skills', skillName, 'SKILL.md'), + claude: path.join(targetDir, '.claude', 'skills', skillName, 'SKILL.md'), + codex: path.join(targetDir, '.codex', 'skills', skillName, 'SKILL.md'), + }; + const dest = destByTool[tool]; + if (await exists(dest)) return; + await copyFile(src, dest, dryRun); +} + +async function copyTemplateVerifier( + templatesRoot: string, + targetDir: string, + tool: Tool, + dryRun: boolean, +) { + const verifierPaths: Record = { + grok: path.join(targetDir, '.grok', 'skills', 'loop-verifier', 'SKILL.md'), + claude: path.join(targetDir, '.claude', 'agents', 'loop-verifier.md'), + codex: path.join(targetDir, '.codex', 'agents', 'verifier.toml'), + }; + const dest = verifierPaths[tool]; + if (await exists(dest)) return; + + if (tool === 'codex') { + const src = path.join(templatesRoot, 'SKILL.md.verifier'); + const body = await readFile(src, 'utf8'); + const toml = `name = "loop-verifier"\ndescription = "Independent verification agent for loop-produced changes."\n\n[system_prompt]\ncontent = """\n${body}\n"""\n`; + if (dryRun) { + console.log(` would write verifier: ${dest}`); + return; + } + await mkdir(path.dirname(dest), { recursive: true }); + await writeFile(dest, toml); + console.log(` created: ${dest} (from verifier template)`); + return; + } + + const src = path.join(templatesRoot, 'SKILL.md.verifier'); + await copyFile(src, dest, dryRun); +} + +async function copyL2Templates( + pattern: Pattern, + tool: Tool, + targetDir: string, + templatesRoot: string, + dryRun: boolean, +) { + if (!PATTERNS_NEEDING_FIX.has(pattern) && !L2_PATTERNS.has(pattern)) return; + + await copyTemplateSkill(templatesRoot, 'SKILL.md.minimal-fix', targetDir, tool, 'minimal-fix', dryRun); + + if (L2_PATTERNS.has(pattern) || pattern === 'dependency-sweeper') { + await copyTemplateVerifier(templatesRoot, targetDir, tool, dryRun); + } +} + async function copyFile(src: string, dest: string, dryRun: boolean) { if (!(await exists(src))) return false; if (dryRun) { @@ -162,11 +246,12 @@ Examples: const baseStarter = PATTERN_STARTERS[pattern]; const suffix = TOOL_SUFFIX[tool]; const starterName = pattern === 'daily-triage' ? `minimal-loop${suffix}` : baseStarter; - const starterRoot = path.join(REPO_ROOT, 'starters', starterName); + const startersRoot = await resolveBundledOrMonorepo('starters'); + const templatesRoot = await resolveBundledOrMonorepo('templates'); + const starterRoot = path.join(startersRoot, starterName); if (!(await exists(starterRoot))) { - // Fall back to grok starter for patterns without per-tool variants - const fallback = path.join(REPO_ROOT, 'starters', baseStarter); + const fallback = path.join(startersRoot, baseStarter); if (!(await exists(fallback))) { console.error(`Starter not found: ${starterRoot}`); process.exit(1); @@ -176,7 +261,7 @@ Examples: const effectiveStarter = (await exists(starterRoot)) ? starterRoot - : path.join(REPO_ROOT, 'starters', baseStarter); + : path.join(startersRoot, baseStarter); console.log(`\nloop-init: ${pattern} → ${targetDir} (${tool})${dryRun ? ' [dry-run]' : ''}\n`); @@ -232,6 +317,8 @@ Examples: await copyFile(loopMd, path.join(targetDir, 'LOOP.md'), dryRun); } + await copyL2Templates(pattern, tool, targetDir, templatesRoot, dryRun); + if (!dryRun && !(await exists(path.join(targetDir, 'AGENTS.md')))) { const agentsTemplate = `# AGENTS.md diff --git a/tools/loop-init/test/cli.test.mjs b/tools/loop-init/test/cli.test.mjs new file mode 100644 index 0000000..5cfb95a --- /dev/null +++ b/tools/loop-init/test/cli.test.mjs @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); +const CLI = path.resolve('dist/cli.js'); + +test('loop-init --help exits 0', async () => { + const { stdout } = await exec('node', [CLI, '--help']); + assert.match(stdout, /changelog-drafter/); +}); + +test('loop-init dry-run scaffolds daily-triage', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'loop-init-')); + try { + const { stdout } = await exec('node', [ + CLI, + dir, + '--pattern', + 'daily-triage', + '--tool', + 'grok', + '--dry-run', + ]); + assert.match(stdout, /loop-init: daily-triage/); + assert.match(stdout, /would copy|copied/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('loop-init scaffolds ci-sweeper with bundled assets', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'loop-init-')); + try { + await exec('node', [CLI, dir, '--pattern', 'ci-sweeper', '--tool', 'grok']); + await access(path.join(dir, 'ci-sweeper-state.md')); + await access(path.join(dir, '.grok', 'skills', 'ci-triage', 'SKILL.md')); + await access(path.join(dir, '.grok', 'skills', 'minimal-fix', 'SKILL.md')); + await access(path.join(dir, '.grok', 'skills', 'loop-verifier', 'SKILL.md')); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); \ No newline at end of file