diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b705a14bc..e9df88f53 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "A marketplace for Claude Code skills, agents, and commands", - "version": "0.12.0" + "version": "0.13.0" }, "plugins": [ { "name": "sc-ai-cli", "source": "./packages/sc-ai-cli", "description": "AI-first CLI design toolkit: create, review, and harden JSON-first CLIs with MCP-ready contract seams, typed error contracts, auditable mutations, repeatable scaffolding patterns, and stateful simulator design guidance.\n", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, @@ -33,7 +33,7 @@ "name": "sc-ci-automation", "source": "./packages/sc-ci-automation", "description": "Run CI quality gates (pull \u2192 build \u2192 test) with optional auto-fix and PR creation. Coordinates pull from upstream, builds projects, runs tests, applies straightforward fixes, and creates PRs when all quality gates pass. Supports version bumping with --patch flag.\n", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, @@ -53,7 +53,7 @@ "name": "sc-codex", "source": "./packages/sc-codex", "description": "Run Codex agents via a Task Tool-compatible runner, with hooks emulation and background execution support.\n", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "synaptic-canvas" }, @@ -70,7 +70,7 @@ "name": "sc-coding-agent-hardening", "source": "./packages/sc-coding-agent-hardening", "description": "Harden coding, QA, and orchestration agent prompts so straightforward defects are fixed instead of being dismissed as minor, pre-existing, non-blocking, or technical debt.\n", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, @@ -107,7 +107,7 @@ "name": "sc-delay-tasks", "source": "./packages/sc-delay-tasks", "description": "Schedule delayed one-shot or bounded polling actions with minimal heartbeats. Useful for waiting before running checks (e.g., CI status) or polling with stop-on-success.\n", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, @@ -125,7 +125,7 @@ { "name": "sc-docling-pdf", "source": "./packages/sc-docling-pdf", - "description": "Convert PDF documents to markdown and structured output using the docling CLI. Selects the optimal conversion profile based on document content: clean text, scanned/OCR, rich datasheets with images and tables, complex layouts via VLM, or technical documents with code and formulas. Extracts images as referenced PNG files for viewing. No MCP required β€” pure CLI workflow for Claude Code.", + "description": "Convert PDF documents to markdown and structured output using the docling CLI. Selects the optimal conversion profile based on document content: clean text, scanned/OCR, rich datasheets with images and tables, complex layouts via VLM, or technical documents with code and formulas. Extracts images as referenced PNG files for viewing. No MCP required \u2014 pure CLI workflow for Claude Code.\n", "version": "0.1.0", "author": { "name": "randlee" @@ -143,11 +143,30 @@ ], "category": "tools" }, + { + "name": "sc-gh-stack", + "source": "./packages/sc-gh-stack", + "category": "tools", + "description": "Stacked pull requests with the gh-stack GitHub CLI extension, run the way that lands: an append-only, linear stack of frozen layers above a named trunk, one stack writer, QA and CI on the top only, one atomic merge. Ships the sc-gh-stack skill (model, preconditions, recipes, full command guide) and the sc-gh-stack-view skill (one-call coherence, mergeability, CI and landing table). Supersedes the generic gh-stack skill.\n", + "version": "0.1.1", + "author": { + "name": "randlee" + }, + "license": "MIT", + "keywords": [ + "git", + "github", + "gh-stack", + "stacked-prs", + "workflow", + "skills" + ] + }, { "name": "sc-git-worktree", "source": "./packages/sc-git-worktree", "description": "Manage git worktrees with optional tracking and protected branch safeguards: create, scan, cleanup, abort, and update worktrees using a standard sibling folder layout with tracking documents. Prevents accidental deletion of protected branches.\n", - "version": "0.12.0", + "version": "0.14.0", "author": { "name": "randlee" }, @@ -157,7 +176,8 @@ "worktree", "workflow", "agents", - "branching" + "branching", + "gh-stack" ], "category": "tools" }, @@ -165,7 +185,7 @@ "name": "sc-github-issue", "source": "./packages/sc-github-issue", "description": "GitHub issue lifecycle management with worktree isolation: list, create, update, and fix issues with automated workflows including PR creation. Integrates with sc-git-worktree for safe isolation.\n", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, @@ -183,7 +203,7 @@ { "name": "sc-just", "source": "./packages/sc-just", - "description": "Set up repo-local just task runners with a curated Justfile, optional .just/ helper scripts, and starter templates for minimal, Python, Go, .NET, and Rust repos.", + "description": "Set up repo-local just task runners with a curated Justfile, optional .just/ helper scripts, and starter templates for minimal, Python, Go, .NET, and Rust repos.\n", "version": "0.1.0", "author": { "name": "randlee" @@ -202,7 +222,7 @@ "name": "sc-kanban", "source": "./packages/sc-kanban", "description": "Kanban state machine for Synaptic Canvas: backlog \u2192 board \u2192 done with gate validation, scrubbing, and shared board config (v0.7).\n", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, @@ -220,8 +240,8 @@ { "name": "sc-launch-term", "source": "./packages/sc-launch-term", - "description": "Launch Claude, Codex, and Gemini sessions in supported terminals with platform-aware terminal autodetect and optional tmux session management.\n", - "version": "0.12.0", + "description": "Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), and Gemini sessions in supported terminals with platform-aware autodetect, cmux workspace tabs, and optional tmux session management.\n", + "version": "0.13.0", "author": { "name": "randlee" }, @@ -232,8 +252,13 @@ "macos", "windows", "tmux", + "cmux", "claude", + "fable", "codex", + "sol", + "terra", + "luna", "gemini" ], "category": "tools" @@ -241,8 +266,8 @@ { "name": "sc-launchpad", "source": "./packages/sc-launchpad", - "description": "Launch Claude, Codex, or Gemini as a separate background sub-agent runtime, with explicit ATM teammate-mode normalization and roster registration.\n", - "version": "0.12.0", + "description": "Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), or Gemini as a separate background sub-agent runtime, with explicit ATM teammate-mode normalization and roster registration.\n", + "version": "0.13.0", "author": { "name": "synaptic-canvas" }, @@ -251,7 +276,11 @@ "background-agents", "claude", "codex", + "sol", + "terra", + "luna", "gemini", + "fable", "atm" ], "category": "tools" @@ -260,7 +289,7 @@ "name": "sc-manage", "source": "./packages/sc-manage", "description": "Manage Synaptic Canvas Claude packages. List available packages and their install status (local/global), and install or uninstall packages according to policy.\n", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "synaptic-canvas" }, @@ -273,11 +302,30 @@ ], "category": "tools" }, + { + "name": "sc-refactory", + "source": "./packages/sc-refactory", + "category": "tools", + "description": "Design and install a rule-driven refactoring toolkit with startup policy injection, approved-fix lookup, curated rule authoring, and named-teammate orchestration for large migration campaigns.\n", + "version": "0.2.0", + "author": { + "name": "synaptic-canvas" + }, + "license": "MIT", + "keywords": [ + "refactoring", + "policy", + "graph", + "oxigraph", + "migration", + "teammates" + ] + }, { "name": "sc-repomix-nuget", "source": "./packages/sc-repomix-nuget", "description": "Generate AI-optimized NuGet package context using Repomix with NuGet metadata (dependencies, dependents, namespaces, frameworks). Produces a compressed API surface.\n", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "synaptic-canvas" }, @@ -295,7 +343,7 @@ "name": "sc-roslyn-diff", "source": "./packages/sc-roslyn-diff", "description": "Semantic diffing for .NET source using roslyn-diff with JSON-first outputs, HTML reports, and git/PR-aware comparisons.\n", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, @@ -316,7 +364,7 @@ "name": "sc-rust", "source": "./packages/sc-rust", "description": "Rust development toolkit: idiomatic guidelines, service hardening guidance, design pattern enforcement, and specialized agents for architecture, code review, exploration, implementation, and QA. Includes cross-platform portability guidelines for Ubuntu, macOS, and Windows.\n", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, @@ -338,7 +386,7 @@ "name": "sc-startup", "source": "./packages/sc-startup", "description": "Startup runner for Synaptic Canvas: read startup prompt, sync master checklist, optional PR triage, worktree hygiene, and CI pull, then emit a concise status with next steps. Supports read-only mode.\n", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, @@ -352,21 +400,6 @@ "agents" ], "category": "tools" - }, - { - "name": "sc-observability", - "description": "Skills for bootstrapping, adopting, and reviewing sc-observability in downstream Rust projects.", - "author": { - "name": "randlee" - }, - "category": "tools", - "source": { - "source": "git-subdir", - "url": "https://github.com/randlee/sc-observability.git", - "path": "packages/sc-observability", - "ref": "main" - }, - "homepage": "https://github.com/randlee/sc-observability" } ] } diff --git a/.claude-plugin/registry.json b/.claude-plugin/registry.json index a63eb227d..e15f40ced 100644 --- a/.claude-plugin/registry.json +++ b/.claude-plugin/registry.json @@ -1,6 +1,6 @@ { "name": "synaptic-canvas", - "version": "0.12.0", + "version": "0.13.0", "description": "A marketplace for Claude Code skills", "author": { "name": "randlee" @@ -8,7 +8,7 @@ "packages": [ { "name": "sc-ai-cli", - "version": "0.12.0", + "version": "0.13.0", "description": "AI-first CLI design toolkit: create, review, and harden JSON-first CLIs with MCP-ready contract seams, typed error contracts, auditable mutations, repeatable scaffolding patterns, and stateful simulator design guidance.\n", "author": "randlee", "license": "MIT", @@ -21,11 +21,11 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.185067+00:00" + "lastUpdated": "2026-09-23T06:05:07.118783+00:00" }, { "name": "sc-ci-automation", - "version": "0.12.0", + "version": "0.13.0", "description": "Run CI quality gates (pull \u2192 build \u2192 test) with optional auto-fix and PR creation. Coordinates pull from upstream, builds projects, runs tests, applies straightforward fixes, and creates PRs when all quality gates pass. Supports version bumping with --patch flag.\n", "author": "randlee", "license": "MIT", @@ -38,11 +38,11 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.186098+00:00" + "lastUpdated": "2026-09-23T06:05:07.119819+00:00" }, { "name": "sc-codex", - "version": "0.12.0", + "version": "0.13.0", "description": "Run Codex agents via a Task Tool-compatible runner, with hooks emulation and background execution support.\n", "author": "synaptic-canvas", "license": "MIT", @@ -55,11 +55,11 @@ "scripts": 2, "schemas": 2 }, - "lastUpdated": "2026-04-29T04:44:47.186834+00:00" + "lastUpdated": "2026-09-23T06:05:07.120619+00:00" }, { "name": "sc-coding-agent-hardening", - "version": "0.12.0", + "version": "0.13.0", "description": "Harden coding, QA, and orchestration agent prompts so straightforward defects are fixed instead of being dismissed as minor, pre-existing, non-blocking, or technical debt.\n", "author": "randlee", "license": "MIT", @@ -72,7 +72,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.187295+00:00" + "lastUpdated": "2026-09-23T06:05:07.121081+00:00" }, { "name": "sc-commit-push-pr", @@ -89,11 +89,11 @@ "scripts": 9, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.187922+00:00" + "lastUpdated": "2026-09-23T06:05:07.121745+00:00" }, { "name": "sc-delay-tasks", - "version": "0.12.0", + "version": "0.13.0", "description": "Schedule delayed one-shot or bounded polling actions with minimal heartbeats. Useful for waiting before running checks (e.g., CI status) or polling with stop-on-success.\n", "author": "randlee", "license": "MIT", @@ -106,24 +106,15 @@ "scripts": 2, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.188514+00:00" + "lastUpdated": "2026-09-23T06:05:07.122357+00:00" }, { "name": "sc-docling-pdf", "version": "0.1.0", - "description": "Convert PDF documents to markdown and structured output using the docling CLI. Selects the optimal conversion profile based on document content: clean text, scanned/OCR, rich datasheets with images and tables, complex layouts via VLM, or technical documents with code and formulas. Extracts images as referenced PNG files for viewing. No MCP required β€” pure CLI workflow for Claude Code.", + "description": "Convert PDF documents to markdown and structured output using the docling CLI. Selects the optimal conversion profile based on document content: clean text, scanned/OCR, rich datasheets with images and tables, complex layouts via VLM, or technical documents with code and formulas. Extracts images as referenced PNG files for viewing. No MCP required \u2014 pure CLI workflow for Claude Code.\n", "author": "randlee", "license": "MIT", - "keywords": [ - "pdf", - "docling", - "conversion", - "markdown", - "ocr", - "images", - "tables", - "datasheets" - ], + "keywords": [], "category": "tools", "artifacts": { "commands": 0, @@ -132,11 +123,28 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-05-14T00:00:00.000000+00:00" + "lastUpdated": "2026-09-23T06:05:07.123165+00:00" + }, + { + "name": "sc-gh-stack", + "version": "0.1.1", + "description": "Stacked pull requests with the gh-stack GitHub CLI extension, run the way that lands: an append-only, linear stack of frozen layers above a named trunk, one stack writer, QA and CI on the top only, one atomic merge. Ships the sc-gh-stack skill (model, preconditions, recipes, full command guide) and the sc-gh-stack-view skill (one-call coherence, mergeability, CI and landing table). Supersedes the generic gh-stack skill.\n", + "author": "randlee", + "license": "MIT", + "keywords": [], + "category": "tools", + "artifacts": { + "commands": 2, + "skills": 2, + "agents": 0, + "scripts": 3, + "schemas": 0 + }, + "lastUpdated": "2026-09-23T06:05:07.124081+00:00" }, { "name": "sc-git-worktree", - "version": "0.12.0", + "version": "0.14.0", "description": "Manage git worktrees with optional tracking and protected branch safeguards: create, scan, cleanup, abort, and update worktrees using a standard sibling folder layout with tracking documents. Prevents accidental deletion of protected branches.\n", "author": "randlee", "license": "MIT", @@ -145,15 +153,15 @@ "artifacts": { "commands": 1, "skills": 1, - "agents": 5, + "agents": 6, "scripts": 7, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.189493+00:00" + "lastUpdated": "2026-09-23T06:05:07.125067+00:00" }, { "name": "sc-github-issue", - "version": "0.12.0", + "version": "0.13.0", "description": "GitHub issue lifecycle management with worktree isolation: list, create, update, and fix issues with automated workflows including PR creation. Integrates with sc-git-worktree for safe isolation.\n", "author": "randlee", "license": "MIT", @@ -166,7 +174,7 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.190573+00:00" + "lastUpdated": "2026-09-23T06:05:07.126099+00:00" }, { "name": "sc-just", @@ -183,11 +191,11 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-05-15T21:50:55.867734+00:00" + "lastUpdated": "2026-09-23T06:05:07.127102+00:00" }, { "name": "sc-kanban", - "version": "0.12.0", + "version": "0.13.0", "description": "Kanban state machine for Synaptic Canvas: backlog \u2192 board \u2192 done with gate validation, scrubbing, and shared board config (v0.7).\n", "author": "randlee", "license": "MIT", @@ -200,12 +208,12 @@ "scripts": 5, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.191285+00:00" + "lastUpdated": "2026-09-23T06:05:07.127868+00:00" }, { "name": "sc-launch-term", - "version": "0.12.0", - "description": "Launch Claude, Codex, and Gemini sessions in supported terminals with platform-aware terminal autodetect and optional tmux session management.\n", + "version": "0.13.0", + "description": "Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), and Gemini sessions in supported terminals with platform-aware autodetect, cmux workspace tabs, and optional tmux session management.\n", "author": "randlee", "license": "MIT", "keywords": [], @@ -217,12 +225,12 @@ "scripts": 3, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.191851+00:00" + "lastUpdated": "2026-09-23T06:05:07.128494+00:00" }, { "name": "sc-launchpad", - "version": "0.12.0", - "description": "Launch Claude, Codex, or Gemini as a separate background sub-agent runtime, with explicit ATM teammate-mode normalization and roster registration.\n", + "version": "0.13.0", + "description": "Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), or Gemini as a separate background sub-agent runtime, with explicit ATM teammate-mode normalization and roster registration.\n", "author": "synaptic-canvas", "license": "MIT", "keywords": [], @@ -234,11 +242,11 @@ "scripts": 2, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.192473+00:00" + "lastUpdated": "2026-09-23T06:05:07.129165+00:00" }, { "name": "sc-manage", - "version": "0.12.0", + "version": "0.13.0", "description": "Manage Synaptic Canvas Claude packages. List available packages and their install status (local/global), and install or uninstall packages according to policy.\n", "author": "synaptic-canvas", "license": "MIT", @@ -251,11 +259,28 @@ "scripts": 8, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.193173+00:00" + "lastUpdated": "2026-09-23T06:05:07.129845+00:00" + }, + { + "name": "sc-refactory", + "version": "0.2.0", + "description": "Design and install a rule-driven refactoring toolkit with startup policy injection, approved-fix lookup, curated rule authoring, and named-teammate orchestration for large migration campaigns.\n", + "author": "synaptic-canvas", + "license": "MIT", + "keywords": [], + "category": "tools", + "artifacts": { + "commands": 5, + "skills": 6, + "agents": 6, + "scripts": 10, + "schemas": 0 + }, + "lastUpdated": "2026-09-23T06:05:07.130913+00:00" }, { "name": "sc-repomix-nuget", - "version": "0.12.0", + "version": "0.13.0", "description": "Generate AI-optimized NuGet package context using Repomix with NuGet metadata (dependencies, dependents, namespaces, frameworks). Produces a compressed API surface.\n", "author": "synaptic-canvas", "license": "MIT", @@ -268,11 +293,11 @@ "scripts": 3, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.193840+00:00" + "lastUpdated": "2026-09-23T06:05:07.131556+00:00" }, { "name": "sc-roslyn-diff", - "version": "0.12.0", + "version": "0.13.0", "description": "Semantic diffing for .NET source using roslyn-diff with JSON-first outputs, HTML reports, and git/PR-aware comparisons.\n", "author": "randlee", "license": "MIT", @@ -285,11 +310,11 @@ "scripts": 6, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.194536+00:00" + "lastUpdated": "2026-09-23T06:05:07.132277+00:00" }, { "name": "sc-rust", - "version": "0.12.0", + "version": "0.13.0", "description": "Rust development toolkit: idiomatic guidelines, service hardening guidance, design pattern enforcement, and specialized agents for architecture, code review, exploration, implementation, and QA. Includes cross-platform portability guidelines for Ubuntu, macOS, and Windows.\n", "author": "randlee", "license": "MIT", @@ -302,11 +327,11 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.195728+00:00" + "lastUpdated": "2026-09-23T06:05:07.133451+00:00" }, { "name": "sc-startup", - "version": "0.12.0", + "version": "0.13.0", "description": "Startup runner for Synaptic Canvas: read startup prompt, sync master checklist, optional PR triage, worktree hygiene, and CI pull, then emit a concise status with next steps. Supports read-only mode.\n", "author": "randlee", "license": "MIT", @@ -319,17 +344,17 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.196460+00:00" + "lastUpdated": "2026-09-23T06:05:07.134183+00:00" } ], "metadata": { - "totalPackages": 17, - "totalCommands": 10, - "totalSkills": 20, - "totalAgents": 45, - "totalScripts": 50, + "totalPackages": 20, + "totalCommands": 17, + "totalSkills": 29, + "totalAgents": 52, + "totalScripts": 63, "totalSchemas": 2 }, - "generated": "2026-05-15T21:50:55.867734+00:00", - "lastUpdated": "2026-05-15T21:50:55.867734+00:00" + "generated": "2026-09-23T06:05:07.134194+00:00", + "lastUpdated": "2026-09-23T06:05:07.134195+00:00" } diff --git a/.claude/agents/registry.yaml b/.claude/agents/registry.yaml index b75d8a848..73d4aa5e2 100644 --- a/.claude/agents/registry.yaml +++ b/.claude/agents/registry.yaml @@ -73,25 +73,25 @@ agents: version: 0.9.0 rust-architect: path: packages/sc-rust/agents/rust-architect.md - version: 0.12.0 + version: 0.13.0 rust-best-practices-agent: path: packages/sc-rust/agents/rust-best-practices-agent.md - version: 0.12.0 + version: 0.13.0 rust-code-explorer: path: packages/sc-rust/agents/rust-code-explorer.md - version: 0.12.0 + version: 0.13.0 rust-code-reviewer: path: packages/sc-rust/agents/rust-code-reviewer.md - version: 0.12.0 + version: 0.13.0 rust-developer: path: packages/sc-rust/agents/rust-developer.md - version: 0.12.0 + version: 0.13.0 rust-qa-agent: path: packages/sc-rust/agents/rust-qa-agent.md - version: 0.12.0 + version: 0.13.0 rust-service-hardening-agent: path: packages/sc-rust/agents/rust-service-hardening-agent.md - version: 0.12.0 + version: 0.13.0 skills: ci-automation: depends_on: diff --git a/README.md b/README.md index cd180a735..57896c11f 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,240 @@ python3 tools/sc-install.py install sc-delay-tasks --- +### [sc-ai-cli](packages/sc-ai-cli/) +[![Beta](https://img.shields.io/badge/status-beta-yellow)](packages/sc-ai-cli/CHANGELOG.md) +[![v0.12.0](https://img.shields.io/badge/version-0.12.0-blue)](packages/sc-ai-cli/CHANGELOG.md) +[![Tier 1](https://img.shields.io/badge/tier-1-yellow)](README.md#-package-tiers-explained) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](../../LICENSE) + +**AI-first CLI design toolkit** β€” Create, review, and harden JSON-first CLIs with MCP-ready contracts, typed error handling, and simulator-backed testing. + +**Use when you need to:** +- Design CLIs whose primary contract is machine consumption, not human prose +- Review existing CLIs against AI-first contract standards (JSON, errors, auditability) +- Build stateful simulators for device, service, or database integrations +- Generate scaffolding for Rust, .NET, or Go CLI projects + +πŸ“– **[Full README](packages/sc-ai-cli/README.md)** | πŸ’‘ **[7 Use Cases](packages/sc-ai-cli/USE-CASES.md)** | πŸ“‹ **[Changelog](packages/sc-ai-cli/CHANGELOG.md)** + +--- + +### [sc-ci-automation](packages/sc-ci-automation/) +[![Beta](https://img.shields.io/badge/status-beta-yellow)](packages/sc-ci-automation/CHANGELOG.md) +[![v0.12.0](https://img.shields.io/badge/version-0.12.0-blue)](packages/sc-ci-automation/CHANGELOG.md) +[![Tier 2](https://img.shields.io/badge/tier-2-orange)](README.md#-package-tiers-explained) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](packages/sc-ci-automation/LICENSE) + +**CI quality gate automation** β€” Run pull β†’ build β†’ test pipelines with optional auto-fix and PR creation when gates pass. + +**Use when you need to:** +- Automate pre-merge quality checks across any project stack +- Run CI pipelines with version bumping and auto-fix +- Coordinate 7 specialized agents for validation, build, test, fix, and PR creation +- Enforce quality gates with configurable warning and failure policies + +πŸ“– **[Full README](packages/sc-ci-automation/README.md)** | πŸ’‘ **[7 Use Cases](packages/sc-ci-automation/USE-CASES.md)** | πŸ”§ **[Troubleshooting](packages/sc-ci-automation/TROUBLESHOOTING.md)** | πŸ“‹ **[Changelog](packages/sc-ci-automation/CHANGELOG.md)** + +--- + +### [sc-codex](packages/sc-codex/) +[![Beta](https://img.shields.io/badge/status-beta-yellow)](packages/sc-codex/CHANGELOG.md) +[![v0.12.0](https://img.shields.io/badge/version-0.12.0-blue)](packages/sc-codex/CHANGELOG.md) +[![Tier 2](https://img.shields.io/badge/tier-2-orange)](README.md#-package-tiers-explained) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](packages/sc-codex/LICENSE) + +**Task Tool-compatible Codex runner** β€” Execute Codex agents with hooks emulation and background execution support. + +**Use when you need to:** +- Run OpenAI Codex agents as task tools within Claude Code +- Emulate hooks and lifecycle events for Codex sessions +- Execute Codex tasks in the background with structured output +- Integrate Codex into multi-agent workflows with typed contracts + +πŸ“– **[Full README](packages/sc-codex/README.md)** | πŸ“‹ **[Changelog](packages/sc-codex/CHANGELOG.md)** + +--- + +### [sc-coding-agent-hardening](packages/sc-coding-agent-hardening/) +[![Beta](https://img.shields.io/badge/status-beta-yellow)](packages/sc-coding-agent-hardening/CHANGELOG.md) +[![v0.12.0](https://img.shields.io/badge/version-0.12.0-blue)](packages/sc-coding-agent-hardening/CHANGELOG.md) +[![Tier 0](https://img.shields.io/badge/tier-0-green)](README.md#-package-tiers-explained) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](packages/sc-coding-agent-hardening/LICENSE) + +**Agent prompt hardening** β€” Prevent coding, QA, and orchestration agents from dismissing straightforward defects. + +**Use when you need to:** +- Stop agents from marking bugs as "minor", "pre-existing", or "technical debt" +- Apply hardening patterns across agent categories (coding, QA, orchestration) +- Rewrite agent system prompts to enforce fix-first behavior +- Map hardening policies to specific repositories and agent types + +πŸ“– **[Full README](packages/sc-coding-agent-hardening/README.md)** | πŸ“‹ **[Changelog](packages/sc-coding-agent-hardening/CHANGELOG.md)** + +--- + +### [sc-commit-push-pr](packages/sc-commit-push-pr/) +[![Beta](https://img.shields.io/badge/status-beta-yellow)](packages/sc-commit-push-pr/CHANGELOG.md) +[![v0.12.0](https://img.shields.io/badge/version-0.12.0-blue)](packages/sc-commit-push-pr/CHANGELOG.md) +[![Tier 2](https://img.shields.io/badge/tier-2-orange)](README.md#-package-tiers-explained) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](../../LICENSE) + +**Automated commit, push, and PR creation** β€” Multi-provider PR workflow with preflight validation for GitHub and Azure DevOps. + +**Use when you need to:** +- Automate the commit β†’ push β†’ PR workflow from Claude Code +- Detect git hosting provider (GitHub vs Azure DevOps) automatically +- Run preflight checks before pushing (clean tree, branch validity, auth) +- Create cross-provider PRs with consistent metadata + +πŸ“– **[Full README](packages/sc-commit-push-pr/README.md)** | πŸ“‹ **[Changelog](packages/sc-commit-push-pr/CHANGELOG.md)** + +--- + +### [sc-just](packages/sc-just/) +[![Beta](https://img.shields.io/badge/status-beta-yellow)](packages/sc-just/CHANGELOG.md) +[![v0.1.0](https://img.shields.io/badge/version-0.1.0-blue)](packages/sc-just/CHANGELOG.md) +[![Tier 1](https://img.shields.io/badge/tier-1-yellow)](README.md#-package-tiers-explained) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](../../LICENSE) + +**Repo-local just task runners** β€” Set up curated Justfiles with helper scripts and starter templates for 5 language profiles. + +**Use when you need to:** +- Bootstrap a `just` task runner in any repository +- Choose from minimal, Python, Go, .NET, or Rust starter templates +- Get pre-built helpers for fmt, lint, test, and common workflows +- Adapt templates to existing toolchains via `.just/config.toml` + +πŸ“– **[Full README](packages/sc-just/README.md)** | πŸ“‹ **[Changelog](packages/sc-just/CHANGELOG.md)** + +--- + +### [sc-kanban](packages/sc-kanban/) +[![Beta](https://img.shields.io/badge/status-beta-yellow)](packages/sc-kanban/CHANGELOG.md) +[![v0.12.0](https://img.shields.io/badge/version-0.12.0-blue)](packages/sc-kanban/CHANGELOG.md) +[![Tier 2](https://img.shields.io/badge/tier-2-orange)](README.md#-package-tiers-explained) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](packages/sc-kanban/LICENSE) + +**Kanban state machine** β€” Track tasks from backlog to board to done with gate validation, scrubbing, and shared board config. + +**Use when you need to:** +- Manage task workflow through backlog β†’ board β†’ done states +- Validate transitions with configurable quality gates +- Scrub and reconcile task state against worktrees and PRs +- Share board configuration across team members + +πŸ“– **[Full README](packages/sc-kanban/README.md)** | πŸ’‘ **[7 Use Cases](packages/sc-kanban/USE-CASES.md)** | πŸ”§ **[Troubleshooting](packages/sc-kanban/TROUBLESHOOTING.md)** | πŸ“‹ **[Changelog](packages/sc-kanban/CHANGELOG.md)** + +--- + +### [sc-launch-term](packages/sc-launch-term/) +[![Beta](https://img.shields.io/badge/status-beta-yellow)](packages/sc-launch-term/CHANGELOG.md) +[![v0.12.0](https://img.shields.io/badge/version-0.12.0-blue)](packages/sc-launch-term/CHANGELOG.md) +[![Tier 1](https://img.shields.io/badge/tier-1-yellow)](README.md#-package-tiers-explained) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](packages/sc-launch-term/LICENSE) + +**Terminal session launcher** β€” Launch Claude, Codex, and Gemini in supported terminals with platform-aware autodetect. + +**Use when you need to:** +- Launch Claude Code, Codex, or Gemini sessions in the right terminal +- Auto-detect the best terminal emulator on macOS or Windows +- Manage AI coding sessions in tmux with automatic session naming +- Start model-specific sessions (`/sc/sonnet`, `/sc/haiku`, `/sc/opus`, `/sc/codex`, `/sc/gemini`) + +πŸ“– **[Full README](packages/sc-launch-term/README.md)** | πŸ“‹ **[Changelog](packages/sc-launch-term/CHANGELOG.md)** + +--- + +### [sc-launchpad](packages/sc-launchpad/) +[![Beta](https://img.shields.io/badge/status-beta-yellow)](packages/sc-launchpad/CHANGELOG.md) +[![v0.12.0](https://img.shields.io/badge/version-0.12.0-blue)](packages/sc-launchpad/CHANGELOG.md) +[![Tier 2](https://img.shields.io/badge/tier-2-orange)](README.md#-package-tiers-explained) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](packages/sc-launchpad/LICENSE) + +**Background sub-agent runtime** β€” Launch Claude, Codex, or Gemini as separate background agents with ATM teammate-mode normalization. + +**Use when you need to:** +- Spawn Claude, Codex, or Gemini as independent background sub-agents +- Register agents in the ATM roster with teammate-mode normalization +- Run parallel agent workflows without blocking the main session +- Coordinate multi-model agent teams with typed task contracts + +πŸ“– **[Full README](packages/sc-launchpad/README.md)** | πŸ“‹ **[Changelog](packages/sc-launchpad/CHANGELOG.md)** + +--- + +### [sc-roslyn-diff](packages/sc-roslyn-diff/) +[![Beta](https://img.shields.io/badge/status-beta-yellow)](packages/sc-roslyn-diff/CHANGELOG.md) +[![v0.12.0](https://img.shields.io/badge/version-0.12.0-blue)](packages/sc-roslyn-diff/CHANGELOG.md) +[![Tier 2](https://img.shields.io/badge/tier-2-orange)](README.md#-package-tiers-explained) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](packages/sc-roslyn-diff/LICENSE) + +**Semantic diffing for .NET** β€” Roslyn-powered diffs with JSON outputs, HTML reports, and git/PR-aware comparisons. + +**Use when you need to:** +- Diff C# or VB.NET source semantically, not just textually +- Generate JSON-first diff outputs for machine consumption +- Produce HTML diff reports for human review +- Compare against git history or PR branches with context-aware results + +πŸ“– **[Full README](packages/sc-roslyn-diff/README.md)** | πŸ’‘ **[7 Use Cases](packages/sc-roslyn-diff/USE-CASES.md)** | πŸ”§ **[Troubleshooting](packages/sc-roslyn-diff/TROUBLESHOOTING.md)** | πŸ“‹ **[Changelog](packages/sc-roslyn-diff/CHANGELOG.md)** + +--- + +### [sc-rust](packages/sc-rust/) +[![Beta](https://img.shields.io/badge/status-beta-yellow)](packages/sc-rust/CHANGELOG.md) +[![v0.12.0](https://img.shields.io/badge/version-0.12.0-blue)](packages/sc-rust/CHANGELOG.md) +[![Tier 2](https://img.shields.io/badge/tier-2-orange)](README.md#-package-tiers-explained) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](../../LICENSE) + +**Rust development toolkit** β€” Idiomatic guidelines, service hardening, pattern enforcement, and specialized agents for architecture, code review, and QA. + +**Use when you need to:** +- Enforce Rust best practices with 11 design pattern modules (typestate, sealed traits, newtype, etc.) +- Harden Rust services for production with tokio, observability, and resilience patterns +- Deploy specialized agents: architect, code reviewer, explorer, developer, QA +- Get cross-platform portability guidance for Ubuntu, macOS, and Windows + +πŸ“– **[Full README](packages/sc-rust/README.md)** | πŸ’‘ **[7 Use Cases](packages/sc-rust/USE-CASES.md)** | πŸ“‹ **[Changelog](packages/sc-rust/CHANGELOG.md)** + +--- + +### [sc-startup](packages/sc-startup/) +[![Beta](https://img.shields.io/badge/status-beta-yellow)](packages/sc-startup/CHANGELOG.md) +[![v0.12.0](https://img.shields.io/badge/version-0.12.0-blue)](packages/sc-startup/CHANGELOG.md) +[![Tier 2](https://img.shields.io/badge/tier-2-orange)](README.md#-package-tiers-explained) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](packages/sc-startup/LICENSE) + +**Startup runner for Synaptic Canvas** β€” Sync checklist, triage PRs, verify worktree hygiene, and pull CI on session start. + +**Use when you need to:** +- Run a standardized startup routine when beginning a Claude Code session +- Sync the master checklist and surface pending tasks +- Triage open PRs and verify worktree state +- Pull CI changes and emit a concise status with next steps + +πŸ“– **[Full README](packages/sc-startup/README.md)** | πŸ’‘ **[7 Use Cases](packages/sc-startup/USE-CASES.md)** | πŸ”§ **[Troubleshooting](packages/sc-startup/TROUBLESHOOTING.md)** | πŸ“‹ **[Changelog](packages/sc-startup/CHANGELOG.md)** + +--- + +### [sc-docling-pdf](packages/sc-docling-pdf/) +[![Beta](https://img.shields.io/badge/status-beta-yellow)](packages/sc-docling-pdf/CHANGELOG.md) +[![v0.1.0](https://img.shields.io/badge/version-0.1.0-blue)](packages/sc-docling-pdf/CHANGELOG.md) +[![Tier 2](https://img.shields.io/badge/tier-2-orange)](README.md#-package-tiers-explained) +[![MIT License](https://img.shields.io/badge/license-MIT-green)](../../LICENSE) + +**PDF to structured output** β€” Convert PDFs to markdown, extract images and tables using the docling CLI with content-aware profile selection. + +**Use when you need to:** +- Convert PDF documents to clean, structured markdown for LLM consumption +- Extract images, tables, and diagrams as referenced PNG files +- Process scanned documents with OCR (selectable engine and language) +- Handle complex layouts with VLM fallback when standard conversion fails + +πŸ“– **[Full README](packages/sc-docling-pdf/README.md)** | πŸ“‹ **[Changelog](packages/sc-docling-pdf/CHANGELOG.md)** + +--- + ## 🎯 Find the Right Package ### I want to... @@ -154,6 +388,19 @@ python3 tools/sc-install.py install sc-delay-tasks | **Fix GitHub issues in isolated worktrees** | sc-github-issue | [Examples](packages/sc-github-issue/USE-CASES.md) | | **Analyze a C# project with AI** | sc-repomix-nuget | [Examples](packages/sc-repomix-nuget/USE-CASES.md) | | **Discover & install packages** | sc-manage | [Guide](packages/sc-manage/USE-CASES.md) | +| **Design an AI-first CLI** | sc-ai-cli | [Examples](packages/sc-ai-cli/USE-CASES.md) | +| **Automate CI quality gates** | sc-ci-automation | [Examples](packages/sc-ci-automation/USE-CASES.md) | +| **Run Codex agents from Claude** | sc-codex | [Readme](packages/sc-codex/README.md) | +| **Harden coding agent prompts** | sc-coding-agent-hardening | [Readme](packages/sc-coding-agent-hardening/README.md) | +| **Automate commit/push/PR workflow** | sc-commit-push-pr | [Readme](packages/sc-commit-push-pr/README.md) | +| **Bootstrap a just task runner** | sc-just | [Readme](packages/sc-just/README.md) | +| **Track tasks on a kanban board** | sc-kanban | [Examples](packages/sc-kanban/USE-CASES.md) | +| **Launch AI coding sessions** | sc-launch-term | [Readme](packages/sc-launch-term/README.md) | +| **Spawn background sub-agents** | sc-launchpad | [Readme](packages/sc-launchpad/README.md) | +| **Diff .NET source semantically** | sc-roslyn-diff | [Examples](packages/sc-roslyn-diff/USE-CASES.md) | +| **Develop idiomatic Rust code** | sc-rust | [Examples](packages/sc-rust/USE-CASES.md) | +| **Run startup checklist on open** | sc-startup | [Examples](packages/sc-startup/USE-CASES.md) | +| **Convert PDF to structured output** | sc-docling-pdf | [Readme](packages/sc-docling-pdf/README.md) | | **See all available packages** | Any | [Registry](docs/registries/nuget/registry.json) | --- @@ -167,6 +414,19 @@ python3 tools/sc-install.py install sc-delay-tasks | sc-manage | Package Mgr | 🟑 Beta | 0.6.0 | 0 | Python 3.6+ | | sc-repomix-nuget | Analysis | 🟑 Beta | 0.6.0 | 2 | Node 18+, .NET SDK | | sc-github-issue | GitHub | 🟑 Beta | 0.6.0 | 2 | Git 2.27+, gh CLI 2.0+ | +| sc-ai-cli | CLI Design | 🟑 Beta | 0.12.0 | 1 | None | +| sc-ci-automation | CI/CD | 🟑 Beta | 0.12.0 | 2 | Git 2.20+, gh CLI | +| sc-codex | Agents | 🟑 Beta | 0.12.0 | 2 | Python 3, Codex CLI | +| sc-coding-agent-hardening | Quality | 🟑 Beta | 0.12.0 | 0 | None | +| sc-commit-push-pr | Workflow | 🟑 Beta | 0.12.0 | 2 | Python 3, Git, gh CLI | +| sc-just | Tools | 🟑 Beta | 0.1.0 | 1 | just β‰₯ 1.0 | +| sc-kanban | Task Mgmt | 🟑 Beta | 0.12.0 | 2 | Python 3, sc-git-worktree | +| sc-launch-term | Launcher | 🟑 Beta | 0.12.0 | 1 | Python 3 | +| sc-launchpad | Agents | 🟑 Beta | 0.12.0 | 2 | Python 3, Claude/Codex/Gemini | +| sc-roslyn-diff | Analysis | 🟑 Beta | 0.12.0 | 2 | .NET 10+, Python 3.10+ | +| sc-rust | Development | 🟑 Beta | 0.12.0 | 2 | Cargo β‰₯ 1.87 | +| sc-startup | Automation | 🟑 Beta | 0.12.0 | 2 | Git 2.20+, Python 3 | +| sc-docling-pdf | Conversion | 🟑 Beta | 0.1.0 | 2 | docling β‰₯ 2.90.0 | **Status:** βœ… Stable (production-ready) | 🟑 Beta (active development) | πŸ”΄ Deprecated (not recommended) diff --git a/docs/ATM-NAMING-CONVENTIONS.md b/docs/ATM-NAMING-CONVENTIONS.md new file mode 100644 index 000000000..1b053827d --- /dev/null +++ b/docs/ATM-NAMING-CONVENTIONS.md @@ -0,0 +1,153 @@ +# ATM phase and sprint naming conventions + +Status: canonical Synaptic Canvas naming contract + +This document is the single source of truth for phase, sprint, plan, branch, +worktree, and retained-evidence identifiers shared by Synaptic Canvas and +consuming repositories such as ATM Core. Consumer repositories should link to +this document and should not copy these rules into their own plan documents. + +## Canonical identifiers + +### Phase + +The comparison and persistence key for a phase is an uppercase ASCII token: + +```text + := [A-Z][A-Z0-9]* +``` + +Examples: `AN`, `AL`, `AI`. + +The filesystem form is lowercase and prefixed with `phase-`: `phase-an`, +`phase-al`, and `phase-ai`. A phase token is never encoded as `Phase AN`, +`phase_AN`, or a mixed-case value in persisted metadata. + +### Sprint + +The canonical sprint identifier is the phase key, a dot, and a positive +decimal number: + +```text + := . +``` + +For example, `AN.1` and `AN.8` are canonical. The same canonical string is +used in Synaptic Canvas `sprint_id`, ATM plan frontmatter, `aich_sprint`, and +TTL/report fields such as `triage:foundIn`. + +The following are historical input spellings, not persisted values: + +| Historical input | Canonical value | Diagnostic | +| --- | --- | --- | +| `AN-S1` | `AN.1` | `TTL.QA_RUN_KEY_MISMATCH` | +| `AN1` | `AN.1` | `NAMING.LEGACY_IDENTIFIER` | +| `an.1` | `AN.1` | `NAMING.NON_CANONICAL` | +| `Phase AN / Sprint 1` | `AN.1` | `NAMING.UNKNOWN_SPRINT_FORMAT` until explicitly mapped | + +Ingestion may compare phase and sprint keys case-insensitively after trimming +surrounding whitespace. It must retain the raw value in a diagnostic and +persist only the canonical value. A case-insensitive match is not permission +to silently accept a legacy separator or an ambiguous format. + +## Plan and evidence paths + +Phase directories use the lowercase filesystem phase form: + +```text +docs/plans/phase-an/ +``` + +Sprint plan filenames use the canonical sprint ID followed by a stable, +lowercase kebab slug: + +```text +docs/plans/phase-an/sprint-AN.8-validation-evidence.md +``` + +The plan frontmatter carries the canonical `sprint: AN.8` value when a sprint +is represented outside the filename. Its `branch` and `worktree` fields are +the actual implementation locations; consumers must not infer the sprint ID +from either field. + +Retained QA, report, and TTL evidence carries the same canonical sprint value. +For example: + +```turtle +triage:QA-RUN-001 + a triage:Finding ; + triage:foundIn triage:AN.8 ; + triage:aich_sprint "AN.8" . +``` + +`AN-S1`, `AN1`, and lowercase equivalents in a new record are validation +errors. Existing records may remain temporarily only when listed in the +historical inventory and accompanied by a migration note. + +## Branch and worktree names + +The sprint ID and implementation branch are related metadata, not aliases. +The recommended branch form is: + +```text +/p-s- +``` + +where `` is one of `feature`, `fix`, `docs`, `plan`, `test`, or +`integrate`. For example: + +```text +feature/pan-s8-validation-evidence +``` + +The branch may use a more descriptive suffix when a fix round or integration +line needs it. The sprint plan must still retain `sprint: AN.8` and the +actual `branch` value. A worktree mirrors its branch below the repository's +worktree root: + +```text +../atm-core-worktrees/feature/pan-s8-validation-evidence +``` + +Absolute worktree paths are tolerated for historical records, but new +frontmatter should use the repository-relative form above. A branch or +worktree mismatch is a diagnostic against the metadata, not a reason to rename +an already-published commit history. + +## Validation and migration + +Every ingestion boundary follows this sequence: + +1. trim the incoming identifier; +2. compare case-insensitively against the canonical grammar; +3. map an explicitly supported legacy spelling to its canonical value; +4. persist the canonical value and retain the original in the diagnostic; and +5. reject ambiguous or unknown forms with an actionable diagnostic. + +The minimum diagnostics are: + +- `TTL.QA_RUN_KEY_MISMATCH` β€” a TTL/report run key does not equal the + canonical sprint key (for example `AN-S1` versus `AN.1`); +- `NAMING.NON_CANONICAL` β€” a supported value differs only by case or + filesystem casing; +- `NAMING.LEGACY_IDENTIFIER` β€” a recognized historical separator/compact form + needs migration; and +- `NAMING.UNKNOWN_SPRINT_FORMAT` β€” the value cannot be mapped without human + confirmation. + +Diagnostics must identify the file, field, raw value, canonical candidate (when +known), and the migration action. Validation must fail the new record or run; +it must not silently treat a missing canonical key as a missing QA run. + +The migration inventory is maintained with the consuming repository's +validation evidence. Each historical exception records its raw spelling, +canonical replacement, owning file, migration status, and the commit that +performed the migration. Once all references in an inventory entry are +canonical, the entry may be retained as historical documentation but must no +longer be accepted for new ingestion. + +## Consumer links + +- [ATM Core naming/validation integration](https://github.com/randlee/atm-core/blob/develop/docs/canonical-naming-conventions.md) +- [Synaptic Canvas project conventions](https://github.com/randlee/synaptic-canvas/tree/develop/docs) + diff --git a/docs/refactory-design.md b/docs/refactory-design.md new file mode 100644 index 000000000..7b3ef1c97 --- /dev/null +++ b/docs/refactory-design.md @@ -0,0 +1,1193 @@ +# Refactory Design + +**Status:** Draft +**Author:** Codex +**Created:** April 28, 2026 +**Package:** `sc-refactory` +**Related:** `sc-startup`, `sc-codex`, `sc-manage` + +## Purpose + +`refactory` should primarily be a design skill for creating constrained refactoring systems like the one we just designed. + +The skill should help an agent: + +- design the rule system first +- define the policy boundaries for approved fixes +- choose the runtime layout and hook model +- scaffold the toolkit package and installed repo layout +- then bootstrap the operational scripts, skills, and agents + +The resulting package must be able to install the same system we just proved out manually: + +- concise startup trigger injection +- explicit startup policy constraints +- graph-backed lookup of approved fixes +- curated authoring flow for new rules +- orchestration of authorized change waves +- QA verification of rule compliance +- background agents that isolate lookup/write/orchestration/QA work +- local scripts for preflight, startup, repair, logging, and graph maintenance + +This is not a generic refactoring assistant. It is a policy-system design and deployment workflow for approved fixes only. + +## Problem + +In large migrations, coding agents are dangerous when left to infer fixes from partial context. The common failure modes are: + +- making many superficially plausible but incorrect edits +- forgetting repeated migration rules across repos +- blowing up context with build logs, example fixes, and tool output +- re-discovering the same fix shape repeatedly + +The system needs a local memory layer with strict guardrails: + +- startup must remind the agent which fix patterns are approved +- startup must state what is and is not allowed +- lookup must retrieve the exact rule document and sample fixes +- write must let humans or trusted agents add new approved rules +- QA must verify that every edit is explained by approved rules +- only committed source-of-truth rules should matter + +## Design Goals + +- Package the toolkit as a reusable skill bundle, not ad hoc repo code. +- Install into a target repo with a predictable layout. +- Use the existing global Claude/Codex startup dispatcher model. +- Follow the Claude skills/agents architecture guidelines v0.6. +- Keep startup injection extremely compact. +- Keep heavy logic out of session context and in scripts/agents. +- Treat approved rules as versioned source of truth. +- Support a future central graph with subset download to a local cache. +- Allow typed prompt fragments to live beside rules and examples. + +## Non-Goals + +- Autonomous open-ended refactoring. +- Unbounded fix suggestion from LLM reasoning alone. +- Storing runtime DB files in git. +- Requiring repo-local hook registration. +- Treating `oxigraph` as the product boundary instead of an implementation detail. + +## Product Shape + +There are two layers: + +1. a design skill that helps create a refactoring policy system +2. an installable runtime package produced from that design + +The runtime installable unit should be `packages/sc-refactory/`. + +Within the Synaptic Canvas repository, package source files live under `packages/`. + +At install time, package artifacts are copied out of `packages/sc-refactory/` into one of two destinations: + +- global install: + - `~/.claude/agents/` + - `~/.claude/skills/` + - `~/.claude/commands/` + - `~/.claude/scripts/` +- local install: + - `/.claude/agents/` + - `/.claude/skills/` + - `/.claude/commands/` + - `/.claude/scripts/` + +The package source tree is not itself a `.claude/` tree. It is a package definition that installs artifacts into `.claude/` locations. + +It contains: + +- a design skill for planning a refactoring toolkit +- an installer/bootstrap skill +- an orchestration skill +- runtime lookup and write skills +- lookup, write, orchestration, and QA background agents +- deterministic scripts for startup, preflight, repair, sync, and graph operations +- reference templates for rule docs and rule graph entries + +The installed toolkit materializes a repo-local runtime under `.refactor/` and a repo startup provider under `.startup/`. + +## Guidelines Alignment + +This design should follow `/Users/randlee/Documents/github/synaptic-canvas/docs/claude-code-skills-agents-guidelines.md` closely. + +The main consequences are: + +- skills are the discovery and orchestration layer +- agents are the execution layer +- tool-heavy work stays inside agents +- agent outputs must be fenced JSON +- every agent must have YAML frontmatter with version +- every agent must be registered in `agents/registry.yaml` in the package source, which installs to `.claude/agents/registry.yaml` +- skills should use progressive disclosure and keep the top-level `SKILL.md` concise +- registry validation should be external rather than encoded into runtime prompts + +The orchestration layer in this system should use the named teammate pattern from v0.6, not a standard one-shot background agent. + +Required named teammates: + +- `refactor-orchestrator` + - persistent execution coordinator for plan and wave control +- `quality-manager` + - persistent QA coordinator that manages compliance review waves + +These teammates should load skill content as behavioral spec and spawn background sub-agents directly. + +## Package Inventory + +Recommended package structure: + +```text +packages/sc-refactory/ +β”œβ”€β”€ manifest.yaml +β”œβ”€β”€ agents/ +β”‚ β”œβ”€β”€ registry.yaml +β”‚ β”œβ”€β”€ refactor-lookup-agent.md +β”‚ β”œβ”€β”€ refactor-write-agent.md +β”‚ β”œβ”€β”€ refactor-dev-agent.md +β”‚ └── refactor-qa-agent.md +β”œβ”€β”€ skills/ +β”‚ β”œβ”€β”€ refactory-design/ +β”‚ β”‚ └── SKILL.md +β”‚ β”œβ”€β”€ refactory-install/ +β”‚ β”‚ └── SKILL.md +β”‚ β”œβ”€β”€ refactor-orchestrate/ +β”‚ β”‚ └── SKILL.md +β”‚ β”œβ”€β”€ quality-manager/ +β”‚ β”‚ └── SKILL.md +β”‚ β”œβ”€β”€ refactor-lookup/ +β”‚ β”‚ β”œβ”€β”€ SKILL.md +β”‚ β”‚ └── workflows.md +β”‚ └── refactor-write/ +β”‚ β”œβ”€β”€ SKILL.md +β”‚ └── workflows.md +β”œβ”€β”€ scripts/ +β”‚ β”œβ”€β”€ install_refactory.py +β”‚ β”œβ”€β”€ session_start.py +β”‚ β”œβ”€β”€ preflight.py +β”‚ β”œβ”€β”€ repair.py +β”‚ β”œβ”€β”€ lookup.py +β”‚ β”œβ”€β”€ write_rule.py +β”‚ β”œβ”€β”€ rebuild_db.py +β”‚ └── sync_subset.py +β”œβ”€β”€ references/ +β”‚ β”œβ”€β”€ rule-doc-template.md +β”‚ β”œβ”€β”€ rule-ttl-template.ttl +β”‚ β”œβ”€β”€ install-and-troubleshooting.md +β”‚ └── runtime-layout.md +└── assets/ + └── startup-wrapper-template +``` + +Installed artifact mapping: + +- `packages/sc-refactory/skills/...` -> `~/.claude/skills/...` or `/.claude/skills/...` +- `packages/sc-refactory/agents/...` -> `~/.claude/agents/...` or `/.claude/agents/...` +- `packages/sc-refactory/scripts/...` -> `~/.claude/scripts/...` or `/.claude/scripts/...` + +## Skills + +### `refactory-design` + +This is the primary skill. + +Its job is not to look up one rule or install one script. Its job is to design a refactoring system for a repo family or migration campaign. + +The first phase of this skill must be rule-design discovery. + +Required first-step questions: + +- What changes are approved vs prohibited? +- What kinds of triggers are useful? +- What should count as one rule versus multiple related rules? +- Which fixes must be performed in tandem? +- What contextual distinctions matter for execution but do not need to appear in startup injection? +- What examples are canonical? +- What prompts or checklists should be attached to a rule? + +The skill should produce: + +- rule authoring guidelines +- trigger guidelines +- sample fix selection guidelines +- startup injection guidelines +- agent and script boundary decisions +- package and runtime layout decisions + +Only after those are clear should it move on to scaffolding the toolkit. + +### `refactory-design` SKILL.md Draft + +Recommended frontmatter: + +```yaml +--- +name: refactory-design +description: Design a constrained refactoring toolkit for a repo family or migration campaign, starting with approved-rule design, startup policy, agent boundaries, and execution-wave architecture before scaffolding scripts, skills, and agents. +--- +``` + +Recommended body shape: + +```markdown +# Refactory Design + +Use this skill when the user wants to design or package a rule-driven refactoring system rather than perform one specific refactor. + +## When to use + +- Designing a new approved-fix rule catalog +- Converting repeated migration knowledge into lookupable rules +- Defining startup trigger injection and authorization boundaries +- Designing QA gating for refactoring changes +- Packaging a reusable refactoring toolkit + +## Phase 1: Rule System Discovery + +Work through these questions first: + +1. What changes are explicitly allowed? +2. What changes are explicitly prohibited? +3. What should count as a rule? +4. Which fixes must always occur together? +5. What trigger forms are useful? +6. What should appear at startup versus only after lookup? +7. What examples are canonical? +8. What QA checks are required? + +Produce: +- Rule boundary guidelines +- Trigger guidelines +- Sample-fix guidelines +- Startup policy text +- QA criteria + +## Phase 2: Runtime Architecture + +Decide: + +- repo layout under `.refactor/` +- startup provider path +- rule doc and graph entry format +- preflight/repair/startup responsibilities +- agent boundaries +- named teammate responsibilities and sub-agent boundaries + +## Phase 3: Execution Model + +Design: + +- plan item schema +- development wave model +- QA wave model +- commit gates +- escalation path for non-rule work + +## Phase 4: Packaging Outputs + +Produce: + +- package manifest +- agent registry +- skill list +- agent list +- runtime script inventory +- installation and validation requirements +``` + +This skill should be high-judgment in phase 1, then progressively more deterministic in phases 2 through 4. + +### Named Teammate Skills + +The execution side of this system should use two named teammates: + +- `refactor-orchestrate` + - loaded as required reading by the `refactor-orchestrator` teammate +- `quality-manager` + - loaded as required reading by the `quality-manager` teammate + +Per the v0.6 guidelines, these skills should behave as behavioral specs, not normal Agent Delegation wrappers. + +### `refactory-install` + +This is the bootstrap skill. It installs the toolkit into a target repo and verifies the runtime is usable. + +Responsibilities: + +- create `.refactor/` layout +- create repo startup wrapper `.startup/team-lead` or equivalent configured provider +- install repo-local copies of runtime scripts when the model is β€œself-contained repo runtime” +- install repo-local runtime skills and agents when required +- create `.refactor/.gitignore` +- verify `oxigraph` installation +- run first-time DB build +- print the exact startup text preview + +This skill should be low freedom. Installation must be deterministic. + +### `refactor-lookup` + +This is the runtime consumption skill. + +Responsibilities: + +- run cheap local preflight first +- if preflight passes, invoke `refactor-lookup-agent` +- surface the rule document, examples, and typed prompt fragments +- stop if the toolkit is unhealthy + +The skill must explicitly tell the agent: + +- do not edit until lookup completes +- do not bypass approved fixes +- do not invoke the background agent if preflight fails + +### `refactor-write` + +This is the runtime curation skill. + +Responsibilities: + +- create or update rule docs +- create or update graph entries +- add curated sample fixes +- keep sample paths repo-root relative +- validate structure before publishing + +This skill is for authoring approved knowledge, not autonomous repair. + +### `refactor-orchestrate` + +This is the behavioral spec for the named `refactor-orchestrator` teammate. + +Responsibilities: + +- load a refactoring plan made only of approved rule-backed items +- partition work into waves +- dispatch development sub-agents to perform only authorized changes +- coordinate with the named `quality-manager` teammate after each development wave +- stop, rework, or escalate when QA finds non-compliant edits +- allow commit only after QA approval + +This skill should not use a normal Agent Delegation table. It should describe the lifecycle, teammate responsibilities, background sub-agent spawning, and the structured status messages sent back to the lead. + +### `quality-manager` + +This is the behavioral spec for the named `quality-manager` teammate. + +Responsibilities: + +- receive wave handoff from `refactor-orchestrator` +- spawn QA sub-agents to inspect diffs and repo state +- ensure 100% of changes are justified by approved rules +- report pass/fail status with remediation requirements +- refuse approval when unauthorized edits, missed tandem edits, or rule drift are present + +## Rule Design Guidelines + +The design skill should explicitly walk the user through these guidelines before any package generation. + +### Rule Boundary Guidelines + +- A rule should represent one approved fix policy. +- Closely related triggers may point to one shared rule document. +- If multiple edits must always be applied together, they belong to one rule. +- If two triggers share rationale but not execution shape, use one shared document with separate trigger entries. +- Exceptions that require materially different handling should be called out explicitly in the rule, not left implicit. + +### Trigger Guidelines + +- Triggers should be concise and operator-recognizable. +- Triggers should be the minimal surface needed to recall a rule. +- Startup injection should prefer plain values, not typed prefixes. +- Broad canonical aliases are acceptable if they improve recall. +- Structural execution details should live in the rule document or prompt fragments, not in startup injection. + +### Rule Document Guidelines + +Each rule document should capture: + +- what the trigger means +- why the rule exists +- when it applies +- when it does not apply +- exact approved fix shape +- tandem fix requirements +- exceptions and non-goals +- sample fix references + +### Sample Fix Guidelines + +- Use real committed examples. +- Prefer a small cross-repo set over many redundant examples. +- Keep paths repo-root relative. +- Link examples from the rule; do not inject them at startup. + +### Prompt Fragment Guidelines + +- Attach only bounded operational guidance. +- Prefer checklists, exception notes, and false-positive notes. +- Do not store broad free-form behavioral prompts as rule content. + +### Startup Injection Guidelines + +- Keep it short. +- Emit explicit allow/deny policy lines before the trigger list. +- Emit only operator-facing trigger values. +- Never inject full rules, examples, or graph metadata. +- Treat startup as a policy reminder, not a knowledge dump. + +### Authorization Guidelines + +- Only changes represented by committed `.refactor/` content are authorized. +- A trigger hit authorizes lookup, not direct editing. +- If a needed fix is not present in `.refactor/`, the agent must stop, escalate, or add the rule through the write flow before editing. +- Tandem fixes required by a rule are part of the authorization boundary. +- β€œMostly similar” fixes are not authorized unless they are covered by the existing rule set. + +## Agents + +### `refactor-lookup-agent` + +Contract: + +- one graph query path +- return fenced JSON only +- never rebuild or repair the runtime +- return the primary rule doc and sample fixes +- return no-match cleanly + +### `refactor-write-agent` + +Contract: + +- write tracked source files only +- create docs and graph entries in the correct layout +- return fenced JSON only +- never write runtime DB artifacts into git + +### `refactor-qa-agent` + +Contract: + +- inspect a proposed diff against the active approved rules +- verify that 100% of edits are justified by `.refactor/` content +- detect missing tandem edits, unauthorized edits, and drift from approved fix shape +- return fenced JSON only +- block commit when compliance is incomplete + +### `refactor-dev-agent` + +Contract: + +- execute one authorized work item or a tightly bounded batch of same-rule items +- make only the edits covered by the assigned rules +- return fenced JSON only +- never expand scope beyond the assigned authorization + +### Named Teammates + +The primary coordinators in this system should be named teammates, not one-shot background agents. + +Required named teammates: + +- `refactor-orchestrator` +- `quality-manager` + +Their responsibilities are described by teammate-oriented skills, not normal Agent Delegation wrappers. + +### Named Teammate Messaging Contract + +Named teammates should: + +- receive structured assignments +- spawn background sub-agents as needed +- aggregate sub-agent results +- send structured status messages back to the controlling lead or session + +These messages may include fenced JSON blocks. + +Minimum teammate status fields: + +- `role` +- `wave` +- `status` +- `summary` +- `next_action` + +Suggested embedded JSON block: + +```json +{ + "success": true, + "data": { + "role": "quality-manager", + "wave": "wave-03", + "status": "pass", + "approved": true, + "blocked_items": [], + "next_action": "commit-approved-wave" + }, + "error": null +} +``` + +### Why This Split + +This workflow needs: + +- persistent coordination state across waves +- teammate-to-teammate handoff +- repeated background sub-agent spawning +- durable QA gating before commit + +That fits the named teammate pattern better than a one-shot orchestrator agent. + +### Deprecated Pattern + +Do not model the main orchestrator as a standard `refactor-orchestrator-agent`. + +That pattern is too short-lived for the intended workflow. + +### Agent Output Contract + +Per the guidelines, all runtime background agents should return fenced JSON and use a standard minimal or standard envelope. + +Minimum acceptable envelope: + +```json +{ + "success": true, + "data": {}, + "error": null +} +``` + +Recommended standard envelope for orchestration and QA: + +```json +{ + "success": true, + "canceled": false, + "aborted_by": null, + "data": {}, + "error": null, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` + +## Installed Repo Layout + +After installation, a target repo should contain: + +```text +.startup/ +└── team-lead + +.refactor/ +β”œβ”€β”€ .gitignore +β”œβ”€β”€ docs/ +β”œβ”€β”€ rules/ +β”œβ”€β”€ reports/ +β”œβ”€β”€ scripts/ +β”œβ”€β”€ db/ +β”œβ”€β”€ logs/ +└── temp/ +``` + +Rules for each directory: + +- `docs/`: committed markdown rule documents +- `rules/`: committed graph source files +- `reports/`: optional committed analysis output +- `scripts/`: committed runtime helper scripts +- `db/`: local runtime graph store, ignored +- `logs/`: local runtime logs, ignored +- `temp/`: scratch space, ignored + +Required ignore policy: + +```gitignore +/db/ +/logs/ +/temp/ +``` + +## Hook Model + +`refactory` must rely on the existing global dispatcher pattern for both Claude and Codex. + +The intended flow is: + +1. CLI emits `startup`, `resume`, `clear`, or `compact`. +2. Global hook receives the event payload. +3. Global hook injects session id and ATM context automatically. +4. Global hook detects repo startup provider. +5. Repo startup provider runs `session_start.py`. +6. `session_start.py` emits only compact refactor context. + +The package must not require repo-local startup registration. + +## Startup Injection Contract + +Startup output must be concise and policy-focused. + +Required shape: + +```text +# REFACTOR TRIGGERS +# Only changes covered by approved .refactor/ rules are allowed. +# If a needed fix is not in .refactor/, stop or add the rule before editing. +# Approved fix patterns only. On match -> invoke refactor-lookup. +# Use /refactor-lookup when one of these items appears in a build error. +# + + +... +``` + +Do not inject: + +- rule bodies +- sample fix paths +- graph metadata +- trigger type prefixes like `string:` or `namespace:` + +Required policy meaning: + +- startup context is an allowlist reminder +- lookup is mandatory before edits when a trigger appears +- only committed `.refactor/` content can authorize edits +- anything outside the rule catalog must be escalated or authored first + +## Runtime Scripts + +### `session_start.py` + +- rebuilds or refreshes local DB from committed rules +- logs to `.refactor/logs/session_start.log` +- fails silently to the agent if startup work fails +- never prints stack traces into injected context + +### `preflight.py` + +- validates required paths +- validates `oxigraph` +- if `db/` is missing, rebuilds it from committed rules +- prints one of two messages: + +Success: + +```text +oxigraph v X.Y.Z checks pass +``` + +Failure: + +```text +tools are not installed or working to use this skill. please read ./.refactor/docs/install-and-troubleshooting.md +``` + +### `repair.py` + +- attempts bounded local repair +- re-runs preflight +- never mutates committed rules + +### `sync_subset.py` + +Future script for central-graph deployments. + +Responsibilities: + +- download only relevant rules/prompts/examples +- build local `.refactor/rules/` from a filtered export +- keep local runtime small and deterministic + +## Data Model + +The source-of-truth model should be store-agnostic even if the first backend is RDF/Oxigraph. + +Core entities: + +- `Rule` + - canonical approved-fix unit +- `Trigger` + - string, symbol, file name, error code, namespace, or pattern that surfaces a rule +- `RuleDocument` + - markdown how-to and rationale +- `SampleFix` + - repo-root-relative path to a known good example +- `PromptFragment` + - typed operational guidance linked to a rule +- `Campaign` + - migration initiative or workstream +- `SubsetProfile` + - filter definition for local sync + +## Prompt Fragments + +Agent prompts in the database are useful, but only if typed and bounded. + +Allowed prompt fragment types: + +- `startup-summary` +- `lookup-guidance` +- `fix-checklist` +- `exception-notes` +- `false-positive-notes` +- `write-curation-notes` +- `qa-checklist` +- `orchestration-notes` + +Do not store opaque broad agent personalities or unconstrained system-prompt replacements. + +## Sample Fix Rules + +Sample fixes must be: + +- repo-root relative +- drawn from real committed fixes +- preferably spread across multiple repos +- linked to the rule rather than duplicated in the startup injection + +If four repos all show the same fix shape, use a diverse subset, not all four. + +## Installation Story + +The design assumes `oxigraph` is installed from crates.io, not Homebrew. + +Normative installation path: + +```bash +cargo install oxigraph-cli +``` + +The toolkit must document the tested version explicitly. Current known-good note: + +- tested with `oxigraph 0.5.7` + +Installer validation should confirm: + +- `oxigraph --version` works +- repo layout exists +- startup provider exists +- preflight passes +- first startup output renders + +## Packaging Strategy + +`sc-refactory` should depend on the existing session-start infrastructure rather than replacing it. + +Expected package dependencies: + +- `sc-startup` for shared startup/hook conventions +- `sc-codex` if Codex-specific global integration helpers are required +- `sc-manage` if package discovery/installation helpers are shared there + +## Skill/Agent Split + +Following the guidelines, the recommended split is: + +- `refactory-design` + - public design skill +- `refactory-install` + - public install skill +- `refactor-lookup` + - public runtime lookup skill +- `refactor-write` + - public runtime curation skill +- `refactor-orchestrate` + - teammate-oriented execution skill for `refactor-orchestrator` +- `quality-manager` + - teammate-oriented QA skill for `quality-manager` + +Private implementation: + +- `refactor-lookup-agent` +- `refactor-write-agent` +- `refactor-dev-agent` +- `refactor-qa-agent` + +This keeps discovery space small while still allowing a rich internal workflow. + +## General-Purpose vs Application-Specific + +The toolkit itself should be general-purpose. The rule content should be application-specific. + +That means: + +- skills are general-purpose +- agents are general-purpose +- scripts are general-purpose +- rule schema is general-purpose +- startup injection format is general-purpose +- rule docs, triggers, examples, and prompt fragments are application-specific + +This boundary is important. If the skills or agents are edited to hard-code domain rules like `Radiant.RPC.Annotations` or `GlobalUsings.cs`, the package stops being deployable and becomes a single-project artifact. + +### What Must Stay General + +- `refactory-install` + - installs layout, scripts, skills, agents, and templates +- `refactor-lookup` + - runs preflight and lookup flow +- `refactor-write` + - writes validated rule content into the local knowledge store +- `refactor-lookup-agent` + - queries by trigger and returns rule artifacts +- `refactor-write-agent` + - creates or updates docs, rules, and sample references +- `session_start.py` + - rebuilds, queries, logs, and emits compact trigger text +- `preflight.py`, `repair.py`, `rebuild_db.py`, `sync_subset.py` + - operate on the runtime, not on one application domain + +### What Must Be Repo or Campaign Specific + +- markdown rule documents in `.refactor/docs/` +- graph rule entries in `.refactor/rules/` +- sample fix paths +- trigger values +- typed prompt fragments linked to a rule +- subset profiles for a particular repo family or migration campaign + +### Design Consequence + +The package should ship: + +- empty or example templates +- perhaps a small demo fixture set for tests +- no production application rules baked into the package itself + +Production rules should be installed by one of these models: + +1. copied from a seed bundle selected during installation +2. synced from a central registry +3. authored locally with `refactor-write` + +The skills and agents should understand any compliant rule set, not one specific application. + +## Implementation Specification + +This section describes the first concrete implementation shape for `sc-refactory`. + +### Package Manifest + +Recommended initial `manifest.yaml` shape: + +```yaml +name: sc-refactory +version: 0.1.0 +description: > + Install and operate a graph-backed refactoring policy toolkit with startup + trigger injection, approved-fix lookup, and curated rule authoring. +author: synaptic-canvas +license: MIT +tags: + - refactoring + - graph + - oxigraph + - startup + - policy + - agents + +artifacts: + skills: + - skills/refactory-design/SKILL.md + - skills/refactory-install/SKILL.md + - skills/refactor-orchestrate/SKILL.md + - skills/quality-manager/SKILL.md + - skills/refactor-lookup/SKILL.md + - skills/refactor-write/SKILL.md + agents: + - agents/refactor-lookup-agent.md + - agents/refactor-write-agent.md + - agents/refactor-dev-agent.md + - agents/refactor-qa-agent.md + - agents/registry.yaml + scripts: + - scripts/install_refactory.py + - scripts/session_start.py + - scripts/preflight.py + - scripts/repair.py + - scripts/lookup.py + - scripts/write_rule.py + - scripts/rebuild_db.py + - scripts/sync_subset.py + +install: + scope: local-only + +requires: + - python3 + - cargo + - git + - pydantic + +dependencies: + - "sc-startup >= 0.10.0" +``` + +### Agent Registry + +Recommended initial `registry.yaml` shape: + +```yaml +version: "1.0" + +agents: + refactor-lookup-agent: + path: agents/refactor-lookup-agent.md + version: "0.1.0" + description: Query the local refactor knowledge store for approved rules. + + refactor-write-agent: + path: agents/refactor-write-agent.md + version: "0.1.0" + description: Create or update tracked refactor rule artifacts. + + refactor-dev-agent: + path: agents/refactor-dev-agent.md + version: "0.1.0" + description: Execute one authorized refactor work item or bounded batch. + + refactor-qa-agent: + path: agents/refactor-qa-agent.md + version: "0.1.0" + description: Verify that proposed edits comply 100% with approved rules. + +skills: + refactory-design: + path: skills/refactory-design/SKILL.md + version: "0.1.0" + + refactory-install: + path: skills/refactory-install/SKILL.md + version: "0.1.0" + + refactor-lookup: + path: skills/refactor-lookup/SKILL.md + version: "0.1.0" + depends_on: + - refactor-lookup-agent@0.1.x + + refactor-write: + path: skills/refactor-write/SKILL.md + version: "0.1.0" + depends_on: + - refactor-write-agent@0.1.x + + refactor-orchestrate: + path: skills/refactor-orchestrate/SKILL.md + version: "0.1.0" + depends_on: + - refactor-dev-agent@0.1.x + - refactor-qa-agent@0.1.x + + quality-manager: + path: skills/quality-manager/SKILL.md + version: "0.1.0" + depends_on: + - refactor-qa-agent@0.1.x +``` + +### Installer Behavior + +`install_refactory.py` should perform these steps: + +1. Resolve repo root. +2. Verify required tools. +3. Install `oxigraph-cli` guidance if missing. +4. Create `.startup/` and `.refactor/` directory layout. +5. Copy runtime scripts into `.refactor/scripts/`. +6. Write `.refactor/.gitignore`. +7. Write the repo startup wrapper. +8. Seed `.refactor/docs/` and `.refactor/rules/` with either: + - nothing + - demo fixtures + - a selected seed bundle +9. Run `preflight.py`. +10. Run `session_start.py --mode startup`. +11. Show the exact startup context preview. + +### Runtime File Ownership + +The installed repo should have a clear ownership model: + +- `.refactor/scripts/*` + - owned by the toolkit runtime +- `.refactor/docs/*` + - owned by rule authors +- `.refactor/rules/*` + - owned by rule authors +- `.refactor/db/*` + - owned by the local runtime only +- `.refactor/logs/*` + - owned by the local runtime only + +This split matters for update behavior. Toolkit upgrades must not overwrite user-authored rules or docs. + +### Upgrade Behavior + +Future package upgrades should: + +- update runtime scripts +- update skills and agents +- preserve `.refactor/docs/` +- preserve `.refactor/rules/` +- preserve `.startup/team-lead` if unchanged or template-compatible +- never preserve stale `.refactor/db/`; it should be disposable + +## Execution Model + +The runtime should support plan-driven refactoring execution rather than ad hoc autonomous edits. + +### Plan Requirements + +Every plan item should include: + +- target repo or repo set +- authorized rule or rules +- expected approved change shape +- validation notes +- wave assignment +- commit boundary + +No plan item may exist without a backing approved rule. + +### Wave Model + +Recommended loop: + +1. Orchestrator defines the next development wave. +2. Development agents execute only assigned authorized items. +3. QA agents review the resulting diff for full rule compliance. +4. Failed items return to rework or escalation. +5. Approved wave becomes eligible for commit. +6. Commit occurs only after QA approval for that wave. + +### QA Gate + +QA is mandatory in this system. + +QA must answer: + +- Is every change justified by a rule in `.refactor/`? +- Were all tandem edits required by the rule completed? +- Were any extra edits introduced? +- Does the implementation match the approved fix shape closely enough? +- Is a new rule required because the change fell outside the existing catalog? + +If any answer is negative, the wave is not committable. + +### Named Teammate Requirement + +Per the v0.6 guidelines, this design should use named teammates for the long-running coordination layer. + +Required teammate roles: + +- `refactor-orchestrator` +- `quality-manager` + +These teammates should: + +- persist across waves +- coordinate via structured messages +- spawn background sub-agents directly +- own execution and QA lifecycle state + +## Seed Content Model + +To keep the package general while still useful, installation should support optional seed bundles. + +Example models: + +- `--seed empty` + - install runtime only +- `--seed examples` + - install demo rules for validation and onboarding +- `--seed ` + - install a campaign-specific starter pack + +Seed bundles should be separate from the runtime package and versioned independently where possible. + +## Validation Requirements + +The package should ship automated validation fixtures for: + +- startup context emission +- preflight success +- preflight failure messaging +- lookup of a known trigger +- write of a new rule +- QA rejection of unauthorized edits +- orchestrator wave gating +- rebuild from committed rule files +- ignored runtime directories + +Minimum acceptable validation surface: + +```text +tests/ + fixtures/ + sample-refactor-repo/ + scripts/ + test_refactory_install.py + test_refactor_lookup.py + test_refactor_write.py + test_session_start.py +``` + +## Long-Term Central Graph Model + +The long-term architecture can support a large central graph, but local runtime should stay small. + +Recommended model: + +- central authoritative registry of rules, docs, prompts, and examples +- local subset sync based on repo, campaign, or active work +- local `.refactor/rules/` remains the immediate source used for local rebuild +- local `.refactor/db/` remains disposable compiled state + +This preserves the fast local workflow: + +- startup injects a compact trigger list +- lookup runs locally +- no network dependency during normal edits + +## Recommendation + +Make the toolkit runtime generic and keep all business or application intelligence in rule content. + +That gives you: + +- a package that can be deployed anywhere +- a rule set that can evolve independently +- a future central registry without rewriting the runtime +- agents that stay interpretable and bounded + +## Rollout Plan + +1. Package the current manual toolkit as `sc-refactory`. +2. Ship `refactory-install`, `refactor-lookup`, and `refactor-write`. +3. Freeze the local repo layout and script contracts. +4. Add validation fixtures for startup output and lookup results. +5. Add central graph export and subset sync later. + +## Key Architectural Decision + +The important abstraction is not β€œquery Oxigraph.” The important abstraction is: + +- approved fix knowledge is curated once +- startup injects a compact allowlist surface and explicit authorization boundaries +- lookup expands one trigger into exact instructions and examples +- write adds new approved knowledge without polluting runtime state +- orchestration turns approved rules into bounded execution waves +- QA enforces 100% rule compliance before commit + +That is the system this package must install. diff --git a/docs/registries/nuget/registry.json b/docs/registries/nuget/registry.json index fafcbfe64..3c15c145a 100644 --- a/docs/registries/nuget/registry.json +++ b/docs/registries/nuget/registry.json @@ -1,18 +1,18 @@ { "$schema": "https://yourcompany.github.io/schemas/package-registry.schema.json", "version": "2.0.0", - "generated": "2026-05-15T21:50:55Z", + "generated": "2026-09-23T06:05:07Z", "repo": "randlee/synaptic-canvas", "marketplace": { "name": "Synaptic Canvas", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "url": "https://github.com/randlee/synaptic-canvas" }, "packages": { "sc-ci-automation": { "name": "sc-ci-automation", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "tier": 2, "description": "Run CI quality gates (pull \u2192 build \u2192 test) with optional auto-fix and PR creation. Coordinates pull from upstream, builds projects, runs tests, applies straightforward fixes, and creates PRs when all quality gates pass. Supports version bumping with --patch flag.\n", @@ -45,12 +45,12 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-ci-automation/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-codex": { "name": "sc-codex", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "tier": 0, "description": "Run Codex agents via a Task Tool-compatible runner, with hooks emulation and background execution support.\n", @@ -82,12 +82,12 @@ "pyyaml" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-codex/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-delay-tasks": { "name": "sc-delay-tasks", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "tier": 0, "description": "Schedule delayed one-shot or bounded polling actions with minimal heartbeats. Useful for waiting before running checks (e.g., CI status) or polling with stop-on-success.\n", @@ -119,7 +119,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-delay-tasks/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-docling-pdf": { @@ -127,7 +127,7 @@ "version": "0.1.0", "status": "beta", "tier": 0, - "description": "Convert PDF documents to markdown and structured output using the docling CLI. Selects the optimal conversion profile based on document content: clean text, scanned/OCR, rich datasheets with images and tables, complex layouts via VLM, or technical documents with code and formulas. Extracts images as referenced PNG files for viewing. No MCP required β€” pure CLI workflow for Claude Code.", + "description": "Convert PDF documents to markdown and structured output using the docling CLI. Selects the optimal conversion profile based on document content: clean text, scanned/OCR, rich datasheets with images and tables, complex layouts via VLM, or technical documents with code and formulas. Extracts images as referenced PNG files for viewing. No MCP required \u2014 pure CLI workflow for Claude Code.\n", "github": "randlee/synaptic-canvas", "repo": "https://github.com/randlee/synaptic-canvas", "path": "packages/sc-docling-pdf", @@ -154,16 +154,20 @@ "schemas": 0 }, "dependencies": [ - "python3", - "docling" + "python >= 3.10", + "docling >= 2.90.0", + "poppler", + "docling[easyocr,vlm]", + "peft >= 0.18.1", + "transformers < 5.5" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-docling-pdf/CHANGELOG.md", - "lastUpdated": "2026-05-14", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-git-worktree": { "name": "sc-git-worktree", - "version": "0.12.0", + "version": "0.14.0", "status": "beta", "tier": 1, "description": "Manage git worktrees with optional tracking and protected branch safeguards: create, scan, cleanup, abort, and update worktrees using a standard sibling folder layout with tracking documents. Prevents accidental deletion of protected branches.\n", @@ -180,12 +184,13 @@ "worktree", "workflow", "agents", - "branching" + "branching", + "gh-stack" ], "artifacts": { "commands": 1, "skills": 1, - "agents": 5, + "agents": 6, "scripts": 7, "schemas": 0 }, @@ -201,14 +206,14 @@ "git >= 2.20" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-git-worktree/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [ "sc-github-issue" ] }, "sc-github-issue": { "name": "sc-github-issue", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "tier": 2, "description": "GitHub issue lifecycle management with worktree isolation: list, create, update, and fix issues with automated workflows including PR creation. Integrates with sc-git-worktree for safe isolation.\n", @@ -241,15 +246,15 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-github-issue/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-launch-term": { "name": "sc-launch-term", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "tier": 0, - "description": "Launch Claude, Codex, and Gemini sessions in supported terminals with platform-aware terminal autodetect and optional tmux session management.\n", + "description": "Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), and Gemini sessions in supported terminals with platform-aware autodetect, cmux workspace tabs, and optional tmux session management.\n", "github": "randlee/synaptic-canvas", "repo": "https://github.com/randlee/synaptic-canvas", "path": "packages/sc-launch-term", @@ -264,8 +269,13 @@ "macos", "windows", "tmux", + "cmux", "claude", + "fable", "codex", + "sol", + "terra", + "luna", "gemini" ], "artifacts": { @@ -279,12 +289,12 @@ "Python 3 launcher on PATH (`python3`, `py -3`, or `python`)" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-launch-term/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-kanban": { "name": "sc-kanban", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "tier": 2, "description": "Kanban state machine for Synaptic Canvas: backlog \u2192 board \u2192 done with gate validation, scrubbing, and shared board config (v0.7).\n", @@ -318,15 +328,15 @@ "sc-git-worktree>=0.5.2" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-kanban/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-launchpad": { "name": "sc-launchpad", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "tier": 0, - "description": "Launch Claude, Codex, or Gemini as a separate background sub-agent runtime, with explicit ATM teammate-mode normalization and roster registration.\n", + "description": "Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), or Gemini as a separate background sub-agent runtime, with explicit ATM teammate-mode normalization and roster registration.\n", "github": "randlee/synaptic-canvas", "repo": "https://github.com/randlee/synaptic-canvas", "path": "packages/sc-launchpad", @@ -338,7 +348,11 @@ "background-agents", "claude", "codex", + "sol", + "terra", + "luna", "gemini", + "fable", "atm" ], "artifacts": { @@ -355,14 +369,14 @@ "gemini", "pydantic" ], - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [], "readme": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-launchpad/README.md", "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-launchpad/CHANGELOG.md" }, "sc-manage": { "name": "sc-manage", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "tier": 0, "description": "Manage Synaptic Canvas Claude packages. List available packages and their install status (local/global), and install or uninstall packages according to policy.\n", @@ -393,12 +407,12 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-manage/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-repomix-nuget": { "name": "sc-repomix-nuget", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "tier": 1, "description": "Generate AI-optimized NuGet package context using Repomix with NuGet metadata (dependencies, dependents, namespaces, frameworks). Produces a compressed API surface.\n", @@ -436,12 +450,12 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-repomix-nuget/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-roslyn-diff": { "name": "sc-roslyn-diff", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "tier": 2, "description": "Semantic diffing for .NET source using roslyn-diff with JSON-first outputs, HTML reports, and git/PR-aware comparisons.\n", @@ -477,12 +491,12 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-roslyn-diff/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-startup": { "name": "sc-startup", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "tier": 2, "description": "Startup runner for Synaptic Canvas: read startup prompt, sync master checklist, optional PR triage, worktree hygiene, and CI pull, then emit a concise status with next steps. Supports read-only mode.\n", @@ -516,7 +530,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-startup/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-commit-push-pr": { @@ -555,12 +569,12 @@ "gh" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-commit-push-pr/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-rust": { "name": "sc-rust", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "tier": 2, "description": "Rust development toolkit: idiomatic guidelines, service hardening guidance, design pattern enforcement, and specialized agents for architecture, code review, exploration, implementation, and QA. Includes cross-platform portability guidelines for Ubuntu, macOS, and Windows.\n", @@ -596,12 +610,12 @@ "sc-compose" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-rust/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-ai-cli": { "name": "sc-ai-cli", - "version": "0.12.0", + "version": "0.13.0", "status": "beta", "tier": 0, "description": "AI-first CLI design toolkit: create, review, and harden JSON-first CLIs with MCP-ready contract seams, typed error contracts, auditable mutations, repeatable scaffolding patterns, and stateful simulator design guidance.\n", @@ -631,7 +645,7 @@ }, "dependencies": [], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-ai-cli/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-coding-agent-hardening": { @@ -644,7 +658,7 @@ "readme": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-coding-agent-hardening/README.md", "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-coding-agent-hardening/CHANGELOG.md", "dependents": [], - "version": "0.12.0", + "version": "0.13.0", "description": "Harden coding, QA, and orchestration agent prompts so straightforward defects are fixed instead of being dismissed as minor, pre-existing, non-blocking, or technical debt.\n", "license": "MIT", "author": { @@ -666,7 +680,7 @@ "schemas": 0 }, "dependencies": [], - "lastUpdated": "2026-04-29" + "lastUpdated": "2026-09-23" }, "sc-just": { "name": "sc-just", @@ -702,7 +716,88 @@ "just >= 1.0", "python3 >= 3.11" ], - "lastUpdated": "2026-05-15" + "lastUpdated": "2026-09-23" + }, + "sc-gh-stack": { + "name": "sc-gh-stack", + "status": "beta", + "tier": 2, + "github": "randlee/synaptic-canvas", + "repo": "https://github.com/randlee/synaptic-canvas", + "path": "packages/sc-gh-stack", + "readme": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-gh-stack/README.md", + "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-gh-stack/CHANGELOG.md", + "dependents": [], + "version": "0.1.1", + "description": "Stacked pull requests with the gh-stack GitHub CLI extension, run the way that lands: an append-only, linear stack of frozen layers above a named trunk, one stack writer, QA and CI on the top only, one atomic merge. Ships the sc-gh-stack skill (model, preconditions, recipes, full command guide) and the sc-gh-stack-view skill (one-call coherence, mergeability, CI and landing table). Supersedes the generic gh-stack skill.\n", + "license": "MIT", + "author": { + "name": "randlee" + }, + "tags": [ + "git", + "github", + "gh-stack", + "stacked-prs", + "workflow", + "skills" + ], + "artifacts": { + "commands": 2, + "skills": 2, + "agents": 0, + "scripts": 3, + "schemas": 0 + }, + "dependencies": [ + "gh >= 2.0", + "git >= 2.38", + "python3 >= 3.9", + "github/gh-stack >= 0.1.0", + "jq" + ], + "lastUpdated": "2026-09-23" + }, + "sc-refactory": { + "name": "sc-refactory", + "status": "beta", + "tier": 2, + "github": "randlee/synaptic-canvas", + "repo": "https://github.com/randlee/synaptic-canvas", + "path": "packages/sc-refactory", + "readme": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-refactory/README.md", + "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-refactory/CHANGELOG.md", + "dependents": [], + "version": "0.2.0", + "description": "Design and install a rule-driven refactoring toolkit with startup policy injection, approved-fix lookup, curated rule authoring, and named-teammate orchestration for large migration campaigns.\n", + "license": "MIT", + "author": { + "name": "synaptic-canvas" + }, + "tags": [ + "refactoring", + "policy", + "graph", + "oxigraph", + "migration", + "teammates" + ], + "artifacts": { + "commands": 5, + "skills": 6, + "agents": 6, + "scripts": 10, + "schemas": 0 + }, + "dependencies": [ + "python3", + "git", + "cargo", + "oxigraph", + "PyYAML", + "pydantic" + ], + "lastUpdated": "2026-09-23" } }, "metadata": { diff --git a/packages/sc-ai-cli/.claude-plugin/plugin.json b/packages/sc-ai-cli/.claude-plugin/plugin.json index 6bed0e42f..cf788ba71 100644 --- a/packages/sc-ai-cli/.claude-plugin/plugin.json +++ b/packages/sc-ai-cli/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-ai-cli", "description": "AI-first CLI design toolkit: create, review, and harden JSON-first CLIs with MCP-ready contract seams, typed error contracts, auditable mutations, repeatable scaffolding patterns, and stateful simulator design guidance.", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, diff --git a/packages/sc-ai-cli/manifest.yaml b/packages/sc-ai-cli/manifest.yaml index e027f1d79..e27bad291 100644 --- a/packages/sc-ai-cli/manifest.yaml +++ b/packages/sc-ai-cli/manifest.yaml @@ -1,5 +1,5 @@ name: sc-ai-cli -version: 0.12.0 +version: 0.13.0 description: > AI-first CLI design toolkit: create, review, and harden JSON-first CLIs with MCP-ready contract seams, typed error contracts, auditable mutations, repeatable diff --git a/packages/sc-ai-cli/skills/creating-ai-clis/SKILL.md b/packages/sc-ai-cli/skills/creating-ai-clis/SKILL.md index 260db2588..f5a68ecbd 100644 --- a/packages/sc-ai-cli/skills/creating-ai-clis/SKILL.md +++ b/packages/sc-ai-cli/skills/creating-ai-clis/SKILL.md @@ -1,6 +1,6 @@ --- name: creating-ai-clis -version: 0.12.0 +version: 0.13.0 description: Create or harden CLIs intended primarily for AI or system consumption and secondarily for humans. Use when designing or implementing a JSON-first CLI with an MCP-ready contract seam, where every command supports machine output, errors are typed and actionable, mutating commands are auditable via corresponding read commands, and external integrations need simulator-backed testing. Do not use for human-first shell utilities, one-off scripts, or text-only CLIs. --- diff --git a/packages/sc-ai-cli/skills/designing-cli-simulators/SKILL.md b/packages/sc-ai-cli/skills/designing-cli-simulators/SKILL.md index 7721f4359..a376d0cdf 100644 --- a/packages/sc-ai-cli/skills/designing-cli-simulators/SKILL.md +++ b/packages/sc-ai-cli/skills/designing-cli-simulators/SKILL.md @@ -1,6 +1,6 @@ --- name: designing-cli-simulators -version: 0.12.0 +version: 0.13.0 description: Design stateful simulators for AI-facing CLIs that integrate with devices, networks, services, or databases. Use when a CLI needs realistic simulator-backed testing, configurable fault injection, persistent state, and a swappable adapter boundary so the same business logic runs against live and simulated backends. Do not use for shallow mocks, stateless test doubles, or CLIs with no external integration. --- diff --git a/packages/sc-ai-cli/skills/reviewing-ai-clis/SKILL.md b/packages/sc-ai-cli/skills/reviewing-ai-clis/SKILL.md index c99753908..09b761b36 100644 --- a/packages/sc-ai-cli/skills/reviewing-ai-clis/SKILL.md +++ b/packages/sc-ai-cli/skills/reviewing-ai-clis/SKILL.md @@ -1,6 +1,6 @@ --- name: reviewing-ai-clis -version: 0.12.0 +version: 0.13.0 description: Critically review an existing CLI, MCP wrapper, or CLI plan for AI-first contract quality. Use when evaluating whether a CLI is JSON-first, has an MCP-ready contract seam without JSON reshaping, auditably models state changes, uses stateful simulator-backed testing for external integrations, and returns typed actionable errors. Do not use for general style review or human-first shell UX review. --- diff --git a/packages/sc-ci-automation/.claude-plugin/plugin.json b/packages/sc-ci-automation/.claude-plugin/plugin.json index 547b2a326..f5aa42e70 100644 --- a/packages/sc-ci-automation/.claude-plugin/plugin.json +++ b/packages/sc-ci-automation/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-ci-automation", "description": "Run CI quality gates (pull \u2192 build \u2192 test) with optional auto-fix and PR creation. Coordinates pull from upstream, builds projects, runs tests, applies straightforward fixes, and creates PRs when all quality gates pass.", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, diff --git a/packages/sc-ci-automation/agents/ci-build-agent.md b/packages/sc-ci-automation/agents/ci-build-agent.md index 2f5f87ef7..6aabe9e03 100644 --- a/packages/sc-ci-automation/agents/ci-build-agent.md +++ b/packages/sc-ci-automation/agents/ci-build-agent.md @@ -1,6 +1,6 @@ --- name: ci-build-agent -version: 0.12.0 +version: 0.13.0 description: Run build and classify failures. --- diff --git a/packages/sc-ci-automation/agents/ci-fix-agent.md b/packages/sc-ci-automation/agents/ci-fix-agent.md index dce3e346b..bd8dcbfb2 100644 --- a/packages/sc-ci-automation/agents/ci-fix-agent.md +++ b/packages/sc-ci-automation/agents/ci-fix-agent.md @@ -1,6 +1,6 @@ --- name: ci-fix-agent -version: 0.12.0 +version: 0.13.0 description: Apply straightforward fixes for build/test issues. --- diff --git a/packages/sc-ci-automation/agents/ci-pr-agent.md b/packages/sc-ci-automation/agents/ci-pr-agent.md index 89c99974d..7c3e44cb4 100644 --- a/packages/sc-ci-automation/agents/ci-pr-agent.md +++ b/packages/sc-ci-automation/agents/ci-pr-agent.md @@ -1,6 +1,6 @@ --- name: ci-pr-agent -version: 0.12.0 +version: 0.13.0 description: Commit, push, and create PR after quality gates pass. --- diff --git a/packages/sc-ci-automation/agents/ci-pull-agent.md b/packages/sc-ci-automation/agents/ci-pull-agent.md index 6e45426f8..8e43bbfdf 100644 --- a/packages/sc-ci-automation/agents/ci-pull-agent.md +++ b/packages/sc-ci-automation/agents/ci-pull-agent.md @@ -1,6 +1,6 @@ --- name: ci-pull-agent -version: 0.12.0 +version: 0.13.0 description: Pull target branch and handle straightforward conflicts. --- diff --git a/packages/sc-ci-automation/agents/ci-root-cause-agent.md b/packages/sc-ci-automation/agents/ci-root-cause-agent.md index cee30fcde..94bd9b278 100644 --- a/packages/sc-ci-automation/agents/ci-root-cause-agent.md +++ b/packages/sc-ci-automation/agents/ci-root-cause-agent.md @@ -1,6 +1,6 @@ --- name: ci-root-cause-agent -version: 0.12.0 +version: 0.13.0 description: Analyze unresolved failures and provide recommendations. --- diff --git a/packages/sc-ci-automation/agents/ci-test-agent.md b/packages/sc-ci-automation/agents/ci-test-agent.md index 272536f6f..7172c862a 100644 --- a/packages/sc-ci-automation/agents/ci-test-agent.md +++ b/packages/sc-ci-automation/agents/ci-test-agent.md @@ -1,6 +1,6 @@ --- name: ci-test-agent -version: 0.12.0 +version: 0.13.0 description: Run tests and classify failures/warnings. --- diff --git a/packages/sc-ci-automation/agents/ci-validate-agent.md b/packages/sc-ci-automation/agents/ci-validate-agent.md index 2d064aa8c..709dc6da8 100644 --- a/packages/sc-ci-automation/agents/ci-validate-agent.md +++ b/packages/sc-ci-automation/agents/ci-validate-agent.md @@ -1,6 +1,6 @@ --- name: ci-validate-agent -version: 0.12.0 +version: 0.13.0 description: Pre-flight checks for CI automation (clean repo, config, auth). --- diff --git a/packages/sc-ci-automation/commands/sc-ci-automation.md b/packages/sc-ci-automation/commands/sc-ci-automation.md index 36363ac18..c0ca8507e 100644 --- a/packages/sc-ci-automation/commands/sc-ci-automation.md +++ b/packages/sc-ci-automation/commands/sc-ci-automation.md @@ -1,6 +1,6 @@ --- name: sc-ci-automation -version: 0.12.0 +version: 0.13.0 description: Run CI quality gates (pull β†’ build β†’ test) with optional auto-fix and PR. --- diff --git a/packages/sc-ci-automation/manifest.yaml b/packages/sc-ci-automation/manifest.yaml index 87b1c9b6f..752c39481 100644 --- a/packages/sc-ci-automation/manifest.yaml +++ b/packages/sc-ci-automation/manifest.yaml @@ -1,5 +1,5 @@ name: sc-ci-automation -version: 0.12.0 +version: 0.13.0 description: > Run CI quality gates (pull β†’ build β†’ test) with optional auto-fix and PR creation. Coordinates pull from upstream, builds projects, runs tests, applies straightforward fixes, diff --git a/packages/sc-ci-automation/skills/sc-ci-automation/SKILL.md b/packages/sc-ci-automation/skills/sc-ci-automation/SKILL.md index 6549fdf81..1d5da48a5 100644 --- a/packages/sc-ci-automation/skills/sc-ci-automation/SKILL.md +++ b/packages/sc-ci-automation/skills/sc-ci-automation/SKILL.md @@ -1,6 +1,6 @@ --- name: sc-ci-automation -version: 0.12.0 +version: 0.13.0 description: Run CI quality gates with optional auto-fix and PR creation. entry_point: /sc-ci-automation --- diff --git a/packages/sc-codex/.claude-plugin/plugin.json b/packages/sc-codex/.claude-plugin/plugin.json index 83475528c..f2178336a 100644 --- a/packages/sc-codex/.claude-plugin/plugin.json +++ b/packages/sc-codex/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-codex", "description": "Run Codex agents via a Task Tool-compatible runner, with hooks emulation and background execution support.", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "synaptic-canvas" }, diff --git a/packages/sc-codex/agents/sc-codex.md b/packages/sc-codex/agents/sc-codex.md index 20d6b16c1..15ea75c11 100644 --- a/packages/sc-codex/agents/sc-codex.md +++ b/packages/sc-codex/agents/sc-codex.md @@ -1,6 +1,6 @@ --- name: sc-codex -version: 0.12.0 +version: 0.13.0 description: Codex agent placeholder for Task Tool execution and hook resolution. model: codex color: blue diff --git a/packages/sc-codex/commands/sc-codex.md b/packages/sc-codex/commands/sc-codex.md index 0ba3bfe88..23e9e7850 100644 --- a/packages/sc-codex/commands/sc-codex.md +++ b/packages/sc-codex/commands/sc-codex.md @@ -2,7 +2,7 @@ allowed-tools: Bash(python3 .claude/scripts/sc_codex_task.py*) name: sc-codex description: Run Codex tasks via the ai_cli runner (supports JSON input, background runs, and model selection). -version: 0.12.0 +version: 0.13.0 options: - name: --model args: diff --git a/packages/sc-codex/manifest.yaml b/packages/sc-codex/manifest.yaml index cce40376e..f9bcbe4ca 100644 --- a/packages/sc-codex/manifest.yaml +++ b/packages/sc-codex/manifest.yaml @@ -1,5 +1,5 @@ name: sc-codex -version: 0.12.0 +version: 0.13.0 description: > Run Codex agents via a Task Tool-compatible runner, with hooks emulation and background execution support. diff --git a/packages/sc-codex/skills/codex-agent/SKILL.md b/packages/sc-codex/skills/codex-agent/SKILL.md index 782daf21a..6249f3b1f 100644 --- a/packages/sc-codex/skills/codex-agent/SKILL.md +++ b/packages/sc-codex/skills/codex-agent/SKILL.md @@ -1,7 +1,7 @@ --- name: codex-agent description: Run Codex tasks via the ai_cli Task Tool runner. -version: 0.12.0 +version: 0.13.0 --- # Codex Agent diff --git a/packages/sc-coding-agent-hardening/.claude-plugin/plugin.json b/packages/sc-coding-agent-hardening/.claude-plugin/plugin.json index e724b5eca..0cfa34f60 100644 --- a/packages/sc-coding-agent-hardening/.claude-plugin/plugin.json +++ b/packages/sc-coding-agent-hardening/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-coding-agent-hardening", "description": "Harden coding, QA, and orchestration agent prompts so straightforward defects are fixed instead of being dismissed as minor, pre-existing, non-blocking, or technical debt.", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, diff --git a/packages/sc-coding-agent-hardening/manifest.yaml b/packages/sc-coding-agent-hardening/manifest.yaml index aaa9dd475..ab21288a7 100644 --- a/packages/sc-coding-agent-hardening/manifest.yaml +++ b/packages/sc-coding-agent-hardening/manifest.yaml @@ -1,5 +1,5 @@ name: sc-coding-agent-hardening -version: 0.12.0 +version: 0.13.0 description: > Harden coding, QA, and orchestration agent prompts so straightforward defects are fixed instead of being dismissed as minor, pre-existing, non-blocking, or diff --git a/packages/sc-coding-agent-hardening/skills/coding-agent-hardening/SKILL.md b/packages/sc-coding-agent-hardening/skills/coding-agent-hardening/SKILL.md index 2d2c48ea9..e64f905ab 100644 --- a/packages/sc-coding-agent-hardening/skills/coding-agent-hardening/SKILL.md +++ b/packages/sc-coding-agent-hardening/skills/coding-agent-hardening/SKILL.md @@ -1,6 +1,6 @@ --- name: coding-agent-hardening -version: 0.12.0 +version: 0.13.0 description: Harden coding, QA, and orchestration agent prompts so they fix issues autonomously instead of letting findings pass through as minor, pre-existing, non-blocking, or technical debt. Use when editing existing agent prompts, review instructions, or orchestration workflows to remove permissive issue-handling behavior and tighten escalation criteria. --- diff --git a/packages/sc-delay-tasks/.claude-plugin/plugin.json b/packages/sc-delay-tasks/.claude-plugin/plugin.json index f8484ddad..f3af958a1 100644 --- a/packages/sc-delay-tasks/.claude-plugin/plugin.json +++ b/packages/sc-delay-tasks/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-delay-tasks", "description": "Schedule delayed one-shot or bounded polling actions with minimal heartbeats. Useful for waiting before running checks (e.g., CI status) or polling with stop-on-success.", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, diff --git a/packages/sc-delay-tasks/agents/sc-delay-once.md b/packages/sc-delay-tasks/agents/sc-delay-once.md index e486b8f08..0648ceb4d 100644 --- a/packages/sc-delay-tasks/agents/sc-delay-once.md +++ b/packages/sc-delay-tasks/agents/sc-delay-once.md @@ -1,6 +1,6 @@ --- name: delay-once -version: 0.12.0 +version: 0.13.0 description: Wait once for a specified duration with minimal heartbeats, then emit the action text. For short waits, print a single waiting line. No tool traces. model: sonnet color: gray diff --git a/packages/sc-delay-tasks/agents/sc-delay-poll.md b/packages/sc-delay-tasks/agents/sc-delay-poll.md index 43c005168..99a512777 100644 --- a/packages/sc-delay-tasks/agents/sc-delay-poll.md +++ b/packages/sc-delay-tasks/agents/sc-delay-poll.md @@ -1,6 +1,6 @@ --- name: delay-poll -version: 0.12.0 +version: 0.13.0 description: Perform bounded polling with minimal heartbeats. Sleep on an interval, emit a heartbeat each interval, stop on success/timeout, and emit the action text. No tool traces. model: sonnet color: gray diff --git a/packages/sc-delay-tasks/agents/sc-git-pr-check-delay.md b/packages/sc-delay-tasks/agents/sc-git-pr-check-delay.md index ac56569d4..a7fd1f051 100644 --- a/packages/sc-delay-tasks/agents/sc-git-pr-check-delay.md +++ b/packages/sc-delay-tasks/agents/sc-git-pr-check-delay.md @@ -1,6 +1,6 @@ --- name: git-pr-check-delay -version: 0.12.0 +version: 0.13.0 description: Poll a PR's required checks with bounded delays using the shared delay-run.py helper; stop on success, failure, or timeout. model: sonnet color: gray diff --git a/packages/sc-delay-tasks/commands/delay.md b/packages/sc-delay-tasks/commands/delay.md index 8e9df0f10..91bf8d37c 100644 --- a/packages/sc-delay-tasks/commands/delay.md +++ b/packages/sc-delay-tasks/commands/delay.md @@ -1,7 +1,7 @@ --- name: delay description: Schedule a delayed one-shot or bounded polling action with minimal heartbeats. Emits the action text on completion for follow-up checks. -version: 0.12.0 +version: 0.13.0 options: - name: --minutes description: One-shot delay in minutes (or use --seconds for short waits). diff --git a/packages/sc-delay-tasks/manifest.yaml b/packages/sc-delay-tasks/manifest.yaml index 840195be1..897d3e864 100644 --- a/packages/sc-delay-tasks/manifest.yaml +++ b/packages/sc-delay-tasks/manifest.yaml @@ -1,5 +1,5 @@ name: sc-delay-tasks -version: 0.12.0 +version: 0.13.0 description: > Schedule delayed one-shot or bounded polling actions with minimal heartbeats. Useful for waiting before running checks (e.g., CI status) or polling with stop-on-success. diff --git a/packages/sc-delay-tasks/skills/sc-delaying-tasks/SKILL.md b/packages/sc-delay-tasks/skills/sc-delaying-tasks/SKILL.md index e9e4c9acd..d700e4db2 100644 --- a/packages/sc-delay-tasks/skills/sc-delaying-tasks/SKILL.md +++ b/packages/sc-delay-tasks/skills/sc-delaying-tasks/SKILL.md @@ -5,7 +5,7 @@ description: > Use to wait before running a check (e.g., GH Actions, PR status, CI pipeline) or to poll on a bounded interval. Trigger on: "wait", "delay", "poll", "check later", "retry after", "schedule check". -version: 0.12.0 +version: 0.13.0 --- # Delaying Tasks Skill diff --git a/packages/sc-docling-pdf/CHANGELOG.md b/packages/sc-docling-pdf/CHANGELOG.md new file mode 100644 index 000000000..777baf1c2 --- /dev/null +++ b/packages/sc-docling-pdf/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-04-22 + +### Added + +- Initial local-only `sc-docling-pdf` package release. +- `docling-pdf-extraction` skill for profile-aware PDF conversion. +- References for text, scan/OCR, rich, VLM, and code-oriented conversion profiles. +- Markdown, image, table, and JSON output guidance. diff --git a/packages/sc-docling-pdf/README.md b/packages/sc-docling-pdf/README.md new file mode 100644 index 000000000..d0dc77436 --- /dev/null +++ b/packages/sc-docling-pdf/README.md @@ -0,0 +1,92 @@ +# sc-docling-pdf + +[![Publisher Verified](https://img.shields.io/badge/publisher-verified-brightgreen)](https://github.com/randlee/synaptic-canvas/blob/main/docs/PUBLISHER-VERIFICATION.md) +[![Security Scanned](https://img.shields.io/badge/security-scanned-blue)](https://github.com/randlee/synaptic-canvas/blob/main/SECURITY.md) +[![License MIT](https://img.shields.io/badge/license-MIT-green)](../../../LICENSE) +[![Version 0.1.0](https://img.shields.io/badge/version-0.1.0-blue)](CHANGELOG.md) + +Scope: Local-only +Requires: docling β‰₯ 2.90.0, python β‰₯ 3.10 + +Convert PDF documents to markdown and structured output using the docling CLI. Selects the optimal conversion profile based on document content: clean text, scanned/OCR, rich datasheets with images and tables, complex layouts via VLM, or technical documents with code and formulas. + +Security: See [SECURITY.md](../../../SECURITY.md) for security policy and practices. + +## Summary + +Single skill package for PDF-to-structured-output conversion using the docling CLI. No MCP required β€” pure CLI workflow. Automatically selects from five conversion profiles based on document analysis, and supports multiple output formats in a single run. + +## Quick Start (Local-only) + +1) Install docling: + ```bash + pip install "docling>=2.90.0" + ``` + +2) Install the package into a repo: + ```bash + python3 tools/sc-install.py install sc-docling-pdf --dest /path/to/your-repo/.claude + ``` + +3) Convert a PDF in Claude Code: + ``` + /docling-pdf path/to/document.pdf + ``` + Or trigger by phrasing: "convert pdf", "pdf to markdown", "extract images from pdf", "datasheet", "get tables from pdf", or "extract diagrams". + +## Conversion Profiles + +| Profile | Document Type | When to Use | +|---------|--------------|-------------| +| `text` | Digital PDF, prose only, no images needed | Fastest path β€” seconds | +| `scan` | Scanned or photographed, bitmapped text | OCR-first, selectable OCR engine and language | +| `rich` | Datasheets, spec sheets, tables + photos + diagrams ⭐ | Best default: quick and thorough | +| `vlm` | Complex layout, dense mixed content | Layout rescue after standard paths fail | +| `code` | Technical docs with code blocks or math formulas | Structure-focused with formula/code fidelity | + +Profiles can be combined (e.g., `scan` + `rich` flags are additive). Output formats (markdown, images, tables, JSON) are independent of the conversion profile. + +## Output Formats + +| Need | Format | +|------|--------| +| LLM consumption, editor reading | Markdown | +| Viewing extracted photographs and diagrams | PNG images (referenced) | +| Working with tables or chart data | CSV/Tables | +| Structured access, metadata, bounding boxes | JSON | + +## Requirements + +- `docling >= 2.90.0` (cli) +- `python >= 3.10` +- Optional: `poppler` (for document analysis), `docling[easyocr,vlm]` (for advanced workflows) + +## Skill + +### docling-pdf-extraction +Analyzes the PDF, selects the optimal conversion profile, and runs the docling CLI with the right flags for the document type. Reads supporting references for each profile and output format. + +## Install / Uninstall + +Install (local-only): +```bash +python3 tools/sc-install.py install sc-docling-pdf --dest /path/to/your-repo/.claude +``` + +Uninstall: +```bash +python3 tools/sc-install.py uninstall sc-docling-pdf --dest /path/to/your-repo/.claude +``` + +## Documentation + +- [README.md](README.md) β€” This file +- [CHANGELOG.md](CHANGELOG.md) β€” Version history and changes + +## License + +MIT License. See [LICENSE](../../../LICENSE) for details. + +## Contributing + +See the main [synaptic-canvas repository](https://github.com/randlee/synaptic-canvas) for contribution guidelines. \ No newline at end of file diff --git a/packages/sc-gh-stack/.claude-plugin/plugin.json b/packages/sc-gh-stack/.claude-plugin/plugin.json new file mode 100644 index 000000000..85ad2c06e --- /dev/null +++ b/packages/sc-gh-stack/.claude-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "name": "sc-gh-stack", + "description": "Stacked pull requests with the gh-stack GitHub CLI extension, run the way that lands: an append-only, linear stack of frozen layers above a named trunk, one stack writer, QA and CI on the top only, one atomic merge. Ships the sc-gh-stack skill (model, preconditions, recipes, full command guide) and the sc-gh-stack-view skill (one-call coherence, mergeability, CI and landing table). Supersedes the generic gh-stack skill.", + "version": "0.1.1", + "author": { + "name": "randlee" + }, + "license": "MIT", + "keywords": [ + "git", + "github", + "gh-stack", + "stacked-prs", + "workflow", + "skills" + ], + "commands": [ + "./commands/sc-gh-stack.md", + "./commands/sc-gh-stack-view.md" + ], + "skills": [ + "./skills/sc-gh-stack/SKILL.md", + "./skills/sc-gh-stack-view/SKILL.md" + ] +} diff --git a/packages/sc-gh-stack/CHANGELOG.md b/packages/sc-gh-stack/CHANGELOG.md new file mode 100644 index 000000000..1cd0265ce --- /dev/null +++ b/packages/sc-gh-stack/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to the **sc-gh-stack** package will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.1] - 2026-09-23 + +### Changed +- `recipe-cut-layer.md` now points to `sc-git-worktree --create-stacked ` (0.14.0+), which performs the cut from `origin/` with `--no-track`; the old advice to refresh the local ref and use plain `--create` is gone. + +## [0.1.0] - 2026-09-23 + +### Added +- `sc-gh-stack` skill: the append-only stack model, lifecycle workflow, + preconditions distilled from five production phases (2026-09-05 to + 2026-09-23, stacks of 2 to 21 PRs), and recipes for cutting a layer, + linking, restacking (insert, remove a red layer, collapse the bottom), + landing (atomic merge plus merge-async and non-linear fallbacks) and + clearing stale per-worktree tracking; a phase-model worked example. +- Full `gh stack` v0.1.0 command guide, troubleshooting table and stack-design + guidance carried over from the retired generic `gh-stack` skill, with + field-verified overrides. +- `sc-gh-stack-view` skill and `gh_stack_view.py`: one-call coherence, + mergeability, CI and LANDING table for every open stack (ported from + atm-core; default view now shows every open stack on any trunk). +- `gh_stack_chain_check.py`: read-only pre-link check (pushed heads, linear + ancestry, PR state and bases, clean merge into trunk) that prints the exact + `gh stack link --base` command to run next. +- `/sc-gh-stack` and `/sc-gh-stack-view` commands. +- `gh_stack_shared.py`: the stdlib subprocess and git lookup helpers both scripts share. +- Unit tests for the scripts (real-git and mocked; every exit path). + +### Notes +- Written against gh-stack extension v0.1.0. Re-verify `unstack --local`, + `link ` and `merge` flags with `--help` if the extension moves. +- This package replaces the earlier `managing-gh-stacks` attempt (PR #101), + whose rules contradicted the field lessons. diff --git a/packages/sc-gh-stack/LICENSE b/packages/sc-gh-stack/LICENSE new file mode 100644 index 000000000..8d188336e --- /dev/null +++ b/packages/sc-gh-stack/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Rand Lee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sc-gh-stack/README.md b/packages/sc-gh-stack/README.md new file mode 100644 index 000000000..1edcdef0d --- /dev/null +++ b/packages/sc-gh-stack/README.md @@ -0,0 +1,117 @@ +# sc-gh-stack + +[![Publisher Verified](https://img.shields.io/badge/publisher-verified-brightgreen)](https://github.com/randlee/synaptic-canvas/blob/main/docs/PUBLISHER-VERIFICATION.md) +[![Security Scanned](https://img.shields.io/badge/security-scanned-blue)](https://github.com/randlee/synaptic-canvas/blob/main/SECURITY.md) +[![License MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![Version 0.1.0](https://img.shields.io/badge/version-0.1.0-blue)](CHANGELOG.md) + +Scope: Local or global +Requires: `gh` >= 2.0 with the `gh-stack` extension (v0.1.0), `git` >= 2.38, `python3` >= 3.9; `jq` optional (documented one-liners only) + +Stacked pull requests with the `gh stack` GitHub CLI extension, run the way +that actually lands: an append-only, linear stack of frozen layers above a +named trunk, one stack writer, QA and CI on the top only, one atomic merge. +Two skills: `sc-gh-stack` (the model, preconditions, recipes and the full +command guide) and `sc-gh-stack-view` (the one-call status table). This +package supersedes the generic `gh-stack` skill; uninstall that one to avoid +duplicate guidance. + +Security: See [SECURITY.md](../../SECURITY.md) for security policy and practices. + +## Summary + +Every rule in this package was paid for during five production phases of +stacked development (stacks of 2 to 21 PRs). The skill is a table of +contents that points to one reference per situation: + +| Situation | Reference | +|-----------|-----------| +| The model and vocabulary | `references/model.md` | +| Lifecycle of a layer | `references/workflow.md` | +| Checks before every write | `references/preconditions.md` | +| Cut a layer, link, restack, land, stale tracking | `references/recipe-*.md` | +| Layer boundaries and naming | `references/stack-design.md` | +| Every `gh stack` command, flag, exit code, JSON schema | `references/commands.md` | +| Error signatures and fixes | `references/troubleshooting.md` | +| A whole phase on one stack | `references/phase-model-example.md` | + +## Quick Start + +1. Install into a repo: + ```bash + python3 tools/sc-install.py install sc-gh-stack --dest /path/to/your-repo/.claude + ``` +2. Make sure the extension is present: + ```bash + gh extension install github/gh-stack + ``` +3. Status of every open stack (read-only, paste verbatim): + ``` + /sc-gh-stack-view + ``` +4. Before linking a proposed order: + ``` + /sc-gh-stack --check develop fix/a fix/b docs/c + ``` + +## Usage + +- `/sc-gh-stack-view [--trunk ] [--all] [--json]` +- `/sc-gh-stack --status | --check | --cut | --link | --restack | --land` + +Scripts (installed under `.claude/scripts/`). Both are read-only apart from one `git fetch origin` (skip it with `--no-fetch`); neither runs a `gh stack` write command: + +| Script | Purpose | Exit codes | +|--------|---------|-----------| +| `gh_stack_view.py` | Coherence (base == parent head), origin vs local vs PR head, `needsRebase`, `mergeStateStatus`, CI rollup, LANDING verdict | 0 coherent, 1 problems, 2 environment | +| `gh_stack_chain_check.py --trunk ... ` | Pre-link check: pushed heads, linear ancestry, PR state and bases, clean merge into trunk; prints the exact link command | 0 linkable, 1 problems, 2 environment | + +## The model in one paragraph + +Trunk is the branch the stack lands on (`develop`, `integrate/phase-N`, +whatever you name). Every unit of work is a new worktree cut from the current +pushed top; its PR opens on the first push with base = the layer below and is +linked at once with `gh stack link --base ...`. A layer is frozen when +its task closes; findings on it are fixed on a new layer above the top. One +writer per branch, one stack writer for every `gh stack` write. QA and CI gate +the top only. The stack lands once with `gh stack merge --yes --merge`. Small +fixes with one owner do not get a stack at all. + +## Safety + +- The scripts never run a `gh stack` write command. +- The skill confirms landing, closing PRs and unstacking with the user unless + the user directed that exact action. +- Never `--squash`, never `gh pr merge --admin`, never edit rulesets. + +## Storage + +Installs into `.claude/` only (commands, skills, scripts). Writes no logs, settings, state or output under `.claude/state/` or `.sc/`. Stack tracking is gh-stack's own per-worktree state; see `references/recipe-stale-tracking.md`. + +## Install / Uninstall + +```bash +python3 tools/sc-install.py install sc-gh-stack --dest /path/to/your-repo/.claude +python3 tools/sc-install.py uninstall sc-gh-stack --dest /path/to/your-repo/.claude +``` + +## Tests + +```bash +python3 -m pytest packages/sc-gh-stack/tests -q +``` + +## Troubleshooting + +- "no open gh stack found": a stack is discovered through `git worktree list` + and needs at least one layer checked out in a worktree. +- πŸ”„ or a false NOT COHERENT with `-` rows after an unstack: stale per-worktree + tracking; see `references/recipe-stale-tracking.md`. +- Anything else: `references/troubleshooting.md` and + `references/installation-and-troubleshooting.md`. + +## Components + +- Commands: `commands/sc-gh-stack.md`, `commands/sc-gh-stack-view.md` +- Skills: `skills/sc-gh-stack/SKILL.md` (+ `references/`), `skills/sc-gh-stack-view/SKILL.md` +- Scripts: `scripts/gh_stack_view.py`, `scripts/gh_stack_chain_check.py` diff --git a/packages/sc-gh-stack/commands/sc-gh-stack-view.md b/packages/sc-gh-stack/commands/sc-gh-stack-view.md new file mode 100644 index 000000000..e8dbb7c10 --- /dev/null +++ b/packages/sc-gh-stack/commands/sc-gh-stack-view.md @@ -0,0 +1,48 @@ +--- +name: sc-gh-stack-view +description: Print the one-call coherence, mergeability, CI and LANDING table for every open gh stack (read-only). Paste the script output verbatim. +version: 0.1.1 +options: + - name: --trunk + description: Only stacks whose trunk is this branch (e.g. develop, integrate/phase-bc). + - name: --phase + description: Shorthand for --trunk integrate/phase-. + - name: --all + description: Show every stack, including merged/closed ones. + - name: --no-fetch + description: Skip git fetch origin; rebase column shows ❓. + - name: --no-pr + description: Skip the GraphQL PR query (offline / local-only view). + - name: --json + description: Emit stacks[].rows[], problems[], notes[], landing, coherent as JSON. + - name: --help + description: Show options. +--- + +# /sc-gh-stack-view command + +Read-only status table for one or more `gh stack`s (under a plugin install substitute `$CLAUDE_PLUGIN_ROOT/scripts/` for `.claude/scripts/`). Runs: + +```bash +python3 .claude/scripts/gh_stack_view.py +``` + +from the repo root, passing through whichever of `--trunk`, `--phase`, +`--all`, `--no-fetch`, `--no-pr`, `--json` were given. If the script is not +found at that path, locate it with `find .claude ~/.claude -name gh_stack_view.py` +and use that path instead. + +Paste stdout **verbatim and unfenced** (no ``` around it) so the markdown +table renders. Do not reformat, summarize, or replace it with per-branch +`gh pr view` calls. + +Exit codes: + +| Code | Meaning | +|------|---------| +| 0 | every shown stack coherent | +| 1 | problems listed under a VERDICT β€” report them, do not silently retry | +| 2 | nothing to show or the environment failed β€” show the single stderr line (`gh-stack-view: ...`) verbatim; it names the next action | + +See the `sc-gh-stack-view` skill for the column legend and full output +walkthrough. diff --git a/packages/sc-gh-stack/commands/sc-gh-stack.md b/packages/sc-gh-stack/commands/sc-gh-stack.md new file mode 100644 index 000000000..e7b5ac684 --- /dev/null +++ b/packages/sc-gh-stack/commands/sc-gh-stack.md @@ -0,0 +1,59 @@ +--- +name: sc-gh-stack +description: Run stacked PRs with gh-stack the way that lands (append-only layers, one stack writer, QA/CI on the top, one atomic merge). Routes to the sc-gh-stack skill and its recipes. +version: 0.1.1 +options: + - name: --status + description: Run /sc-gh-stack-view and paste the coherence, mergeability, CI and LANDING table verbatim. + - name: --check + args: + - name: trunk + description: Branch the stack lands on (e.g. develop, integrate/phase-bc). + - name: layers + description: Proposed order bottom to top, branch names or PR numbers. + description: Read-only pre-link chain check (gh_stack_chain_check.py); prints the exact link command to run next. + - name: --cut + args: + - name: layer + description: New branch name for the layer. + description: Walk the cut-a-layer recipe (new worktree from the pushed top, PR on first push, link). + - name: --link + description: Walk the link recipe (append to the stack, or the full ordered link with --base). + - name: --restack + description: Walk the restack recipe (insert a layer, remove a red layer whose fix is above, collapse the bottom). + - name: --land + description: Walk the landing checklist and the atomic merge, with the merge-async and non-linear fallbacks. + - name: --help + description: Show options and the recipe index. +--- + +# /sc-gh-stack command + +Delegates to the `sc-gh-stack` skill. For `--cut`, `--link`, `--restack` and +`--land`, start with the skill's Step 1 (CLI verification) and Step 2 +(`/sc-gh-stack-view`), then open only the reference the option maps to. +`--status` and `--check` are themselves the status calls (Step 1 only); +`--help` runs nothing. + +| Option | Reference in `skills/sc-gh-stack/references/` | +|--------|-----------------------------------------------| +| `--status` | run `python3 .claude/scripts/gh_stack_view.py [--trunk ]` (or `$CLAUDE_PLUGIN_ROOT/scripts/...` under a plugin install), paste stdout verbatim and unfenced | +| `--check ` | run `python3 .claude/scripts/gh_stack_chain_check.py --trunk `, paste stdout verbatim | +| `--cut ` | `recipe-cut-layer.md`, then `recipe-link.md` | +| `--link` | `recipe-link.md` | +| `--restack` | `recipe-restack.md`, then `recipe-stale-tracking.md` | +| `--land` | `recipe-land.md` | +| no option / `--help` | print this table and the one-paragraph model from `SKILL.md`; no repository chatter | + +Rules the command enforces regardless of option: + +- Every `gh stack` write command (`link`, `unstack`, `sync`, `rebase`, + `merge`) is run only by the stack writer, only after `/sc-gh-stack-view`, + and is followed by `/sc-gh-stack-view` again. +- Creating or re-creating a stack is one `gh stack link --base ` with + the full ordered list; `gh stack link ` only appends on top. + Landing is `gh stack merge --yes --merge`, never `--squash`. +- Landing, closing PRs and unstacking are confirmed with the user unless the + user already directed that exact action. +- No tool traces in the reply: the pasted script output, the verdict, and + the next command. diff --git a/packages/sc-gh-stack/manifest.yaml b/packages/sc-gh-stack/manifest.yaml new file mode 100644 index 000000000..ef6803efc --- /dev/null +++ b/packages/sc-gh-stack/manifest.yaml @@ -0,0 +1,56 @@ +name: sc-gh-stack +version: 0.1.1 +description: > + Stacked pull requests with the gh-stack GitHub CLI extension, run the way + that lands: an append-only, linear stack of frozen layers above a named + trunk, one stack writer, QA and CI on the top only, one atomic merge. Ships + the sc-gh-stack skill (model, preconditions, recipes, full command guide) + and the sc-gh-stack-view skill (one-call coherence, mergeability, CI and + landing table). Supersedes the generic gh-stack skill. +author: randlee +license: MIT +tags: + - git + - github + - gh-stack + - stacked-prs + - workflow + - skills + +# Files to install (relative to package root) +artifacts: + commands: + - commands/sc-gh-stack.md + - commands/sc-gh-stack-view.md + skills: + - skills/sc-gh-stack/SKILL.md + - skills/sc-gh-stack/references/model.md + - skills/sc-gh-stack/references/workflow.md + - skills/sc-gh-stack/references/preconditions.md + - skills/sc-gh-stack/references/recipe-cut-layer.md + - skills/sc-gh-stack/references/recipe-link.md + - skills/sc-gh-stack/references/recipe-restack.md + - skills/sc-gh-stack/references/recipe-land.md + - skills/sc-gh-stack/references/recipe-stale-tracking.md + - skills/sc-gh-stack/references/phase-model-example.md + - skills/sc-gh-stack/references/stack-design.md + - skills/sc-gh-stack/references/commands.md + - skills/sc-gh-stack/references/troubleshooting.md + - skills/sc-gh-stack/references/installation-and-troubleshooting.md + - skills/sc-gh-stack-view/SKILL.md + - skills/sc-gh-stack-view/references/installation-and-troubleshooting.md + scripts: + - scripts/gh_stack_shared.py + - scripts/gh_stack_view.py + - scripts/gh_stack_chain_check.py + +# Runtime requirements (scripts are stdlib-only Python 3) +requires: + cli: + - gh >= 2.0 + - git >= 2.38 + - python3 >= 3.9 + gh_extensions: + - github/gh-stack >= 0.1.0 # `gh extension install github/gh-stack`; checked in SKILL.md Step 1 + optional: + - jq # only in documented shell one-liners, not used by the scripts diff --git a/packages/sc-gh-stack/scripts/gh_stack_chain_check.py b/packages/sc-gh-stack/scripts/gh_stack_chain_check.py new file mode 100755 index 000000000..551359df8 --- /dev/null +++ b/packages/sc-gh-stack/scripts/gh_stack_chain_check.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Pre-link chain check for a proposed gh stack (read-only). + +Installed as ``.claude/scripts/gh_stack_chain_check.py`` by the sc-gh-stack +package. Given a trunk and the intended layer order (bottom to top, branch +names or PR numbers), it verifies what the stack writer otherwise checks by +hand before every ``gh stack link``: + +* every layer head is pushed (``origin/`` exists); +* each layer contains its parent's pushed head (``git merge-base + --is-ancestor``), so the chain is linear; +* the PR for each layer, if one exists, is open, not a draft, its head is the + pushed head, and its base is the expected parent (the trunk for the bottom); +* the top merges clean into the trunk (``git merge-tree --write-tree``, exit + code only; never the legacy 3-arg form). + +Three data sources: ``git rev-parse``/``merge-base``/``merge-tree`` after one +fetch, and ONE ``gh pr list`` call. Never runs a ``gh stack`` write command. + +Exit codes: 0 chain is linkable, 1 problems listed under VERDICT, 2 the +environment failed (one ``gh-stack-chain-check: ...`` line on stderr). +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from gh_stack_shared import ToolError, guarded, is_ancestor, origin_sha, run, short # noqa: E402 + + +def merge_clean(base_sha: str, head_sha: str) -> bool | None: + """True/False from ``git merge-tree --write-tree``; None when git is too old.""" + proc = run(["git", "merge-tree", "--write-tree", base_sha, head_sha], check=False) + if proc.returncode == 0: + return True + if proc.returncode == 1: + return False + return None # usage error: git < 2.38 or an unexpected failure + + +PR_FIELDS = "number,headRefName,baseRefName,headRefOid,isDraft,state" + + +def list_prs() -> list[dict]: + """One call: every OPEN PR (bounded; closed/merged PRs cannot be linked anyway).""" + proc = run(["gh", "pr", "list", "--state", "open", "--limit", "500", "--json", PR_FIELDS]) + try: + data = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise ToolError(f"gh pr list returned non-JSON: {proc.stdout[:200]!r}") from exc + if not isinstance(data, list): + raise ToolError("gh pr list returned an unexpected shape; run `gh auth status`") + return data + + +def index_prs(prs: list[dict]) -> tuple[dict[int, dict], dict[str, dict]]: + """Map PR number -> PR and head branch -> the OPEN PR for it (else newest).""" + prs = [p for p in prs if isinstance(p, dict) and isinstance(p.get("number"), int) and isinstance(p.get("headRefName"), str)] + by_number = {int(p["number"]): p for p in prs} + by_branch: dict[str, dict] = {} + for p in sorted(prs, key=lambda p: int(p["number"])): + cur = by_branch.get(p["headRefName"]) + if cur is None or (cur.get("state") != "OPEN" and p.get("state") == "OPEN"): + by_branch[p["headRefName"]] = p + elif cur.get("state") == p.get("state"): + by_branch[p["headRefName"]] = p # newest wins among equals + return by_number, by_branch + + +def view_pr(number: int) -> dict: + """Exact lookup for a PR number that is not in the open list (closed, merged, or beyond the page).""" + proc = run(["gh", "pr", "view", str(number), "--json", PR_FIELDS], check=False) + if proc.returncode != 0: + raise ToolError(f"PR #{number} not found (`gh pr view {number}` failed); pass a branch name instead") + try: + data = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise ToolError(f"gh pr view {number} returned non-JSON: {proc.stdout[:200]!r}") from exc + if not isinstance(data, dict) or not isinstance(data.get("headRefName"), str): + raise ToolError(f"gh pr view {number} returned an unexpected shape; upgrade gh or pass the branch name") + return data + + +def resolve_layers(args: list[str], by_number: dict[int, dict], by_branch: dict[str, dict] | None = None) -> list[str]: + """Turn PR numbers into head branch names; branch names pass through. + + A number missing from the open list is fetched individually so a closed or + merged PR is reported by ``evaluate`` as a problem instead of "not found". + """ + layers: list[str] = [] + for arg in args: + if arg.isdigit(): + pr = by_number.get(int(arg)) + if pr is None: + pr = view_pr(int(arg)) + by_number[int(arg)] = pr + if by_branch is not None: + by_branch.setdefault(pr["headRefName"], pr) + layers.append(pr["headRefName"]) + else: + layers.append(arg) + return layers + + +def evaluate(trunk: str, layers: list[str], by_branch: dict[str, dict], *, fetched: bool, use_pr: bool) -> dict: + """Pure chain evaluation over injected lookups (origin_sha, is_ancestor, merge_clean).""" + rows: list[dict] = [] + problems: list[str] = [] + notes: list[str] = [] + trunk_sha = origin_sha(trunk) if fetched else None + if fetched and trunk_sha is None: + raise ToolError(f"origin/{trunk} does not exist; pass the trunk branch name exactly as on origin") + if len(layers) != len(set(layers)): + problems.append("a branch appears twice in the proposed order") + if trunk in layers: + problems.append(f"{trunk} is the trunk; it is never a layer (drop it from the list)") + if any(not n.strip() for n in layers): + problems.append("an empty branch name was passed") + parent_name = trunk + parent_sha = trunk_sha + for idx, name in enumerate(layers, start=1): + sha = origin_sha(name) if fetched else None + pr = by_branch.get(name) if use_pr else None + row = {"layer": idx, "branch": name, "origin": sha, "pr": pr.get("number") if pr else None, + "pushed": None if not fetched else sha is not None, "contains_parent": None, + "pr_base_ok": None, "pr_head_ok": None, "draft": bool(pr and pr.get("isDraft")), + "pr_state": pr.get("state") if pr else None, "expected_base": parent_name} + if fetched and sha is None: + problems.append(f"L{idx} {name}: not on origin (unpushed) -> push it before linking") + elif fetched and parent_sha: + row["contains_parent"] = is_ancestor(parent_sha, sha) + if not row["contains_parent"]: + if idx == 1: + if is_ancestor(sha, parent_sha): + problems.append(f"L1 {name}: has no commits beyond {trunk} (already merged or empty)") + else: + notes.append(f"L1 {name}: behind {trunk} ({short(sha)} does not contain {short(parent_sha)}); fine unless CONFLICTING, do not rebase just to catch up") + row["contains_parent"] = None + else: + problems.append(f"L{idx} {name}: does not contain parent {parent_name} @ {short(parent_sha)} -> cut from a stale head or a fork; declare a merge-forward or reorder") + if pr: + if pr.get("state") != "OPEN": + problems.append(f"L{idx} {name}: PR #{pr['number']} is {pr.get('state')} -> a merged/closed PR cannot be linked; open a new one") + if pr.get("isDraft"): + problems.append(f"L{idx} {name}: PR #{pr['number']} is DRAFT -> blocks gh stack merge; mark ready before landing") + base_ok = pr.get("baseRefName") == parent_name + row["pr_base_ok"] = base_ok + if not base_ok: + notes.append(f"L{idx} {name}: PR #{pr['number']} base is {pr.get('baseRefName')}, expected {parent_name}; `gh stack link --base {trunk} ...` corrects it, verify afterwards") + if fetched and sha: + head_ok = pr.get("headRefOid") == sha + row["pr_head_ok"] = head_ok + if not head_ok: + problems.append(f"L{idx} {name}: PR #{pr['number']} head {short(pr.get('headRefOid'))} != origin {short(sha)} -> stale PR head; wait for GitHub or re-push") + elif use_pr: + notes.append(f"L{idx} {name}: no PR yet; `gh stack link` creates one with base {parent_name}") + rows.append(row) + parent_name = name + parent_sha = sha or parent_sha + landing: dict = {"clean": None, "reason": "not judged"} + if fetched and rows and rows[-1]["origin"] and trunk_sha and not any(r["pushed"] is False for r in rows): + result = merge_clean(trunk_sha, rows[-1]["origin"]) + if result is None: + landing = {"clean": None, "reason": "git merge-tree --write-tree unavailable (git < 2.38); check with a scratch `git merge --no-commit`"} + elif result: + landing = {"clean": True, "reason": f"top {rows[-1]['branch']} @ {short(rows[-1]['origin'])} merges clean into {trunk} @ {short(trunk_sha)}"} + else: + landing = {"clean": False, "reason": f"top {rows[-1]['branch']} conflicts with {trunk}; resolve on a new top layer, never on a frozen one"} + problems.append(f"top {rows[-1]['branch']}: merge into {trunk} conflicts") + report = {"trunk": trunk, "trunk_origin": trunk_sha, "rows": rows, "problems": problems, + "notes": notes, "landing": landing, "linkable": not problems} + report["link_command"] = link_command(report) if report["linkable"] and rows else None + return report + + +def icon(value: bool | None) -> str: + return {True: "βœ…", False: "β›”", None: "❓"}[value] + + +def render(report: dict) -> str: + lines = [f"chain: {' -> '.join(r['branch'] for r in report['rows'])} -> {report['trunk']} @ {short(report['trunk_origin'])}", ""] + hdr = ["L", "branch", "PR", "pushed", "contains parent", "PR base", "PR head"] + lines.append("| " + " | ".join(hdr) + " |") + lines.append("|" + "|".join("---" for _ in hdr) + "|") + for r in report["rows"]: + pr = f"#{r['pr']}" + (" (draft)" if r["draft"] else "") if r["pr"] else "-" + lines.append("| " + " | ".join([f"{r['layer']}/{len(report['rows'])}", r["branch"], pr, + icon(r["pushed"]), icon(r["contains_parent"]), + icon(r["pr_base_ok"]) if r["pr"] else "-", + icon(r["pr_head_ok"]) if r["pr"] else "-"]) + " |") + lines.append("") + if report["problems"]: + lines.append(f"VERDICT: ❌ NOT LINKABLE ({len(report['problems'])} issue(s))") + lines.extend(f"- {p}" for p in report["problems"]) + else: + lines.append("VERDICT: βœ… LINKABLE - every head pushed, chain linear, PR state consistent") + land = report["landing"] + lines.append(f"MERGE INTO TRUNK: {icon(land['clean'])} {land['reason']}") + lines.extend(f"- note: {n}" for n in report["notes"]) + if report["linkable"] and report["rows"]: + lines.append("") + lines.append("next: " + link_command(report)) + if any(not r["pr"] for r in report["rows"]): + lines.append(" (a bare branch name pushes the LOCAL ref: run it from that layer's own worktree, or open its PR first and re-run the check)") + else: + lines.append(" (run from a worktree checked out on a stack branch)") + return "\n".join(lines) + + +def link_command(report: dict) -> str: + """Shell-safe: bare PR numbers, never `#N` (a `#` starts a comment in bash).""" + return f"gh stack link --base {report['trunk']} " + " ".join( + str(r["pr"]) if r["pr"] else r["branch"] for r in report["rows"]) + + +def preflight(*, need_gh: bool) -> None: + for tool in ("git",) + (("gh",) if need_gh else ()): + if not shutil.which(tool): + raise ToolError(f"`{tool}` not on PATH") + if run(["git", "rev-parse", "--git-dir"], check=False).returncode != 0: + raise ToolError("not inside a git repository") + if run(["git", "remote", "get-url", "origin"], check=False).returncode != 0: + raise ToolError("no `origin` remote; the check compares origin/* refs (`git remote add origin `)") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--trunk", required=True, help="branch the stack lands on (e.g. develop, integrate/phase-bc)") + ap.add_argument("layers", nargs="+", help="proposed order bottom to top: branch names or PR numbers") + ap.add_argument("--no-fetch", action="store_true", help="skip `git fetch origin`; compare against the cached origin/* refs") + ap.add_argument("--no-pr", action="store_true", help="skip the gh pr list call (local-only view)") + ap.add_argument("--json", action="store_true", help="emit the report as JSON") + args = ap.parse_args() + return guarded("gh-stack-chain-check", lambda: run_check(args)) + + +def run_check(args: argparse.Namespace) -> int: + preflight(need_gh=not args.no_pr) + if not args.no_fetch: + try: + fetch = run(["git", "fetch", "--quiet", "origin"], check=False) + why = None if fetch.returncode == 0 else ((fetch.stderr or fetch.stdout).strip().splitlines() or ["no output"])[-1] + except ToolError as exc: # timeout + why = str(exc) + if why: + sys.stderr.write(f"gh-stack-chain-check: warning: git fetch origin failed ({why}); comparing against cached origin/* refs\n") + prs = [] if args.no_pr else list_prs() + by_number, by_branch = index_prs(prs) + layers = resolve_layers(args.layers, by_number, by_branch) + report = evaluate(args.trunk, layers, by_branch, fetched=True, use_pr=not args.no_pr) + if args.json: + print(json.dumps(report, indent=2)) + else: + print(render(report)) + return 0 if report["linkable"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/sc-gh-stack/scripts/gh_stack_shared.py b/packages/sc-gh-stack/scripts/gh_stack_shared.py new file mode 100755 index 000000000..c4609dc62 --- /dev/null +++ b/packages/sc-gh-stack/scripts/gh_stack_shared.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Helpers shared by the sc-gh-stack scripts (stdlib only). + +Installed next to ``gh_stack_view.py`` and ``gh_stack_chain_check.py`` under +``.claude/scripts/``. Everything here is read-only: subprocess wrappers and +the git lookups both scripts need. Never runs a ``gh stack`` write command. +""" +from __future__ import annotations + +import subprocess +import sys +from typing import Callable + +GIT_TIMEOUT = 60 +GH_TIMEOUT = 120 + + +class ToolError(Exception): + """A required external command is missing or failed; message is actionable.""" + + +def run(cmd: list[str], *, check: bool = True, cwd: str | None = None) -> subprocess.CompletedProcess[str]: + timeout = GH_TIMEOUT if cmd and cmd[0] == "gh" else GIT_TIMEOUT + try: + proc = subprocess.run(cmd, text=True, capture_output=True, cwd=cwd, timeout=timeout) + except subprocess.TimeoutExpired as exc: + raise ToolError(f"{' '.join(cmd[:3])} timed out after {timeout}s; check network, `gh auth status`, or a hung prompt") from exc + except FileNotFoundError as exc: + missing = cmd[0] if exc.filename in (None, cmd[0]) else f"directory {exc.filename}" + raise ToolError(f"cannot run {' '.join(cmd[:3])}: {missing} not found " + f"(install gh + gh-stack extension and git; prune stale worktrees with `git worktree prune`)") from exc + except OSError as exc: + raise ToolError(f"cannot run {' '.join(cmd[:3])}: {exc}") from exc + if check and proc.returncode != 0: + detail = (proc.stderr or proc.stdout).strip().splitlines() + raise ToolError(f"{' '.join(cmd[:3])} failed (exit {proc.returncode}): {detail[-1] if detail else 'no output'}" + + hint_for(" ".join(detail))) + return proc + + +def hint_for(text: str) -> str: + """Append the next action for the failure signatures gh and git actually produce.""" + low = text.lower() + if "rate limit" in low or "secondary" in low or "abuse" in low: + return "; GitHub rate limit: stop all gh calls for 30 min, or use --no-pr for a local-only view" + if "auth" in low or "401" in low or "token" in low or "not logged" in low: + return "; run `gh auth status` / `gh auth login`" + if "could not resolve host" in low or "network" in low or "timed out" in low or "connection" in low: + return "; network problem: retry, or use --no-fetch/--no-pr for a local-only view" + if "not a git repository" in low: + return "; run from inside the repository or one of its worktrees" + if "extension" in low and "not found" in low: + return "; `gh extension install github/gh-stack`" + return "" + + +def short(sha: str | None) -> str: + return (sha or "")[:9] or "-" + + +def origin_sha(ref: str) -> str | None: + """None when the branch is not on origin (unpushed or deleted after merge).""" + proc = run(["git", "rev-parse", "--verify", "--quiet", f"origin/{ref}"], check=False) + return proc.stdout.strip() or None + + +def is_ancestor(older: str, newer: str) -> bool: + return run(["git", "merge-base", "--is-ancestor", older, newer], check=False).returncode == 0 + + +def utf8_stdout() -> None: + """Emoji in the tables must not raise on a non-UTF-8 console (Windows legacy code pages).""" + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is not None: + try: + reconfigure(encoding="utf-8", errors="replace") + except (ValueError, OSError): + pass + + +def guarded(tag: str, body: Callable[[], int]) -> int: + """Run ``body``; every failure is one ``: ...`` line on stderr and exit 2, never a traceback.""" + utf8_stdout() + try: + return body() + except ToolError as exc: + sys.stderr.write(f"{tag}: {exc}\n") + return 2 + except KeyboardInterrupt: + sys.stderr.write(f"{tag}: interrupted\n") + return 2 + except Exception as exc: # noqa: BLE001 - the contract is "never a traceback" + sys.stderr.write(f"{tag}: unexpected {type(exc).__name__}: {exc} (report this with the command you ran)\n") + return 2 diff --git a/packages/sc-gh-stack/scripts/gh_stack_view.py b/packages/sc-gh-stack/scripts/gh_stack_view.py new file mode 100755 index 000000000..910e6b151 --- /dev/null +++ b/packages/sc-gh-stack/scripts/gh_stack_view.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python3 +"""One-call status table for a `gh stack`. + +Installed as ``.claude/scripts/gh_stack_view.py`` by the sc-gh-stack package +(command: ``/sc-gh-stack-view``, skill: ``sc-gh-stack-view``). + +Combines exactly three data sources into one table: + +1. ``gh stack view --json`` - local stack tracking: layer order, head/base + SHAs, needsRebase, PR number. +2. ``git rev-parse origin/`` (after one ``git fetch``) - what is + actually pushed. +3. One batched GraphQL query - per-PR ``mergeable``, ``mergeStateStatus``, + ``baseRefName``, ``headRefOid``, ``isDraft`` and CI rollup. + +Coherence checks (the two metrics conventional per-branch calls never show): + +* ``base ok`` - each layer's base == the layer below's head (bottom == trunk). +* ``origin ok`` - local head == origin head == PR head. +* ``needsRebase`` straight from gh stack, plus GitHub's ``mergeable`` / + ``mergeStateStatus`` which reveal CONFLICTING / BEHIND / DIRTY layers. + +Read-only. Never runs ``gh stack sync`` or ``gh stack rebase``. +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +from concurrent.futures import ThreadPoolExecutor + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from gh_stack_shared import ToolError, guarded, is_ancestor, origin_sha, run, short # noqa: E402 + + +SKIPPED: list[str] = [] # worktrees the discovery could not read, reported once as warnings + + +def stack_json_at(path: str) -> dict | None: + """None when this worktree is not on a stack; unreadable worktrees are recorded in SKIPPED.""" + try: + proc = run(["gh", "stack", "view", "--json"], check=False, cwd=path) + except ToolError as exc: + SKIPPED.append(f"{path}: {exc} (`git worktree prune` removes stale entries)") + return None + if proc.returncode == 2: + return None # not in a stack: the normal case for develop/main worktrees + if proc.returncode != 0: + tail = (proc.stderr or proc.stdout).strip().splitlines() + why = tail[-1] if tail else "no output" + if proc.returncode == 6: + why += " (branch belongs to several stacks; `gh stack checkout ` there)" + elif proc.returncode == 8: + why += " (stack file locked by another gh stack process; retry in a few seconds)" + elif proc.returncode == 10: + why += " (interrupted `gh stack modify`; run `gh stack modify --abort` there)" + SKIPPED.append(f"{path}: gh stack view exit {proc.returncode}: {why}") + return None + try: + data = json.loads(proc.stdout) + except json.JSONDecodeError: + SKIPPED.append(f"{path}: gh stack view --json returned non-JSON ({proc.stdout[:80]!r}); upgrade gh-stack") + return None + if not isinstance(data, dict) or not isinstance(data.get("branches"), list) or not data["branches"]: + return None + if not isinstance(data.get("trunk"), str) or not all( + isinstance(b, dict) and isinstance(b.get("name"), str) for b in data["branches"] + ): + raise ToolError(f"gh stack view --json in {path} returned an unexpected shape; " + "upgrade gh-stack (`gh extension upgrade stack`) or report the output") + return data + + +def worktree_paths() -> list[str]: + """Every worktree of this repo that is checked out on a branch (cwd first).""" + out = run(["git", "worktree", "list", "--porcelain"]).stdout + paths: list[str] = [] + cur: str | None = None + for line in out.splitlines(): + if line.startswith("worktree "): + cur = line[len("worktree "):] + elif line.startswith("branch ") and cur: + paths.append(cur) + cur = None + elif line == "" : + cur = None + cwd = run(["git", "rev-parse", "--show-toplevel"]).stdout.strip() + paths.sort(key=lambda p: p != cwd) + return paths + + +def select_stacks(found: list[dict], trunk_filter: str | None, *, include_all: bool) -> tuple[list[dict], int]: + """Filter the deduped stack list down to what should be shown. + + Rules: + + * ``trunk_filter`` set - keep only stacks with that trunk (open-only + unless ``include_all``). + * no filter and ``include_all`` - keep everything. + * no filter, not ``include_all`` (the default) - keep every stack that + still has an open layer, regardless of trunk name. + + Returns (stacks, hidden_count). + """ + def is_open(d: dict) -> bool: + return any(not b.get("isMerged") and (b.get("pr") or {}).get("state", "OPEN") != "CLOSED" + for b in d["branches"]) + + if trunk_filter: + stacks = [d for d in found if d["trunk"] == trunk_filter and (include_all or is_open(d))] + elif include_all: + stacks = found + else: + stacks = [d for d in found if is_open(d)] + stacks.sort(key=lambda d: (not d["trunk"].startswith("integrate/"), d["trunk"], d["branches"][0]["name"])) + return stacks, len(found) - len(stacks) + + +def discover_stacks(trunk_filter: str | None, *, include_all: bool) -> tuple[list[dict], int]: + """Run `gh stack view --json` once per worktree, dedupe by branch set. + + Works from any branch not part of a stack (e.g. develop/main): every + stack that has at least one worktree checked out is found. Concurrent, + read-only. Filtering/sorting of the deduped list is delegated to + ``select_stacks``. Returns (stacks, hidden_count). + """ + paths = worktree_paths() + with ThreadPoolExecutor(max_workers=4) as pool: # one gh call per worktree; keep bursts small (secondary rate limit) + results = list(pool.map(stack_json_at, paths)) + found: list[dict] = [] + for path, data in zip(paths, results): + if data: + data["worktree"] = path + found.append(data) + + # Each worktree only knows the layers linked from it; the same stack seen + # from a lower layer is a prefix of the view from the top. Keep the longest. + def names(d: dict) -> tuple[str, ...]: + return tuple(b["name"] for b in d["branches"]) + found = [d for d in found if not any( + o is not d and len(names(o)) > len(names(d)) and names(o)[: len(names(d))] == names(d) + for o in found)] + uniq: dict[tuple[str, ...], dict] = {} + for d in found: + uniq.setdefault(names(d), d) + found = list(uniq.values()) + + return select_stacks(found, trunk_filter, include_all=include_all) + + +def pr_details(numbers: list[int]) -> dict[int, dict]: + """One GraphQL round-trip for every PR in the stack.""" + if not numbers: + return {} + remote = run(["gh", "repo", "view", "--json", "owner,name"]).stdout + try: + repo = json.loads(remote) + except json.JSONDecodeError as exc: + raise ToolError(f"gh repo view returned non-JSON: {remote[:200]!r}") from exc + try: + owner, name = repo["owner"]["login"], repo["name"] + except (KeyError, TypeError) as exc: + raise ToolError(f"gh repo view returned an unexpected shape: {remote[:200]!r}; run `gh auth status`") from exc + if not all(isinstance(n, int) and n > 0 for n in numbers): + raise ToolError(f"non-integer PR number in gh stack view output: {numbers!r}; upgrade gh-stack") + fields = ( + "number isDraft mergeable mergeStateStatus baseRefName headRefOid " + "reviewDecision state " + "commits(last:1){nodes{commit{statusCheckRollup{state}}}}" + ) + aliases = " ".join(f"pr{n}: pullRequest(number:{n}) {{ {fields} }}" for n in numbers) + query = f'query($owner:String!,$name:String!){{ repository(owner:$owner,name:$name) {{ {aliases} }} }}' + proc = run(["gh", "api", "graphql", "-f", f"query={query}", "-F", f"owner={owner}", "-F", f"name={name}"]) + try: + payload = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise ToolError(f"gh api graphql returned non-JSON: {proc.stdout[:200]!r}") from exc + data = (payload.get("data") or {}).get("repository") + if payload.get("errors"): + msgs = "; ".join(str(e.get("message", "?")) for e in payload["errors"][:3]) + if not data: + raise ToolError(f"GraphQL errors: {msgs} (PRs {numbers}; use --no-pr for a local-only view)") + sys.stderr.write(f"gh-stack-view: warning: GraphQL reported: {msgs}; affected PRs show as unknown\n") + if not data: + raise ToolError("GraphQL returned no repository data; run `gh auth status` or use --no-pr") + out: dict[int, dict] = {} + for n in numbers: + pr = data.get(f"pr{n}") + if not isinstance(pr, dict): + out[n] = {} # unresolved on GitHub: falsy, rendered as unknown + continue + nodes = ((pr.get("commits") or {}).get("nodes") or [None]) + first = nodes[0] if isinstance(nodes[0], dict) else {} + rollup = ((first.get("commit") or {}).get("statusCheckRollup") or {}).get("state") + pr["ci"] = rollup or "NONE" + out[n] = pr + return out + + +def landing_verdict(stack: dict, rows: list[dict], trunk_origin: str | None) -> dict: + """Judge the stack as one landing, independent of chain coherence. + + The chain verdict answers "can gh stack merge walk this bottom-up?". A + stack is also landable as a single merge of its top layer when (a) the + top layer's pushed head contains every open layer's pushed head and (b) + that head merges into the trunk without conflicts. Both are checked + against origin refs only, never local tracking. + """ + open_rows = [r for r in rows if not r.get("merged")] + if not open_rows or trunk_origin is None: + return {"landable": None, "top": None, "reason": "no open layer or trunk not fetched"} + top = open_rows[-1] + top_sha = top.get("origin") + if not top_sha: + return {"landable": None, "top": top["branch"], "reason": "top layer has no origin head"} + missing = [r["branch"] for r in open_rows[:-1] if not r.get("origin") or not is_ancestor(r["origin"], top_sha)] + if missing: + return {"landable": False, "top": top["branch"], "top_sha": top_sha, + "reason": "top head does not contain: " + ", ".join(missing)} + tree = run(["git", "merge-tree", "--write-tree", trunk_origin, top_sha], check=False) + if tree.returncode == 1: + conflicts = [line for line in tree.stdout.splitlines() if line.startswith("CONFLICT")] + return {"landable": False, "top": top["branch"], "top_sha": top_sha, + "reason": "merge into trunk conflicts: " + ("; ".join(conflicts[:3]) or "see git merge-tree")} + if tree.returncode != 0: + return {"landable": None, "top": top["branch"], "top_sha": top_sha, + "reason": "git merge-tree --write-tree unavailable (git < 2.38) or failed; check with a scratch `git merge --no-commit`"} + return {"landable": True, "top": top["branch"], "top_sha": top_sha, + "reason": f"top head contains all {len(open_rows)} open layer(s) and merges clean into {stack['trunk']}"} + + +def build_rows(stack: dict, prs: dict[int, dict], *, fetched: bool) -> tuple[list[dict], list[str], list[str]]: + trunk = stack["trunk"] + trunk_origin = origin_sha(trunk) if fetched else None + rows: list[dict] = [] + problems: list[str] = [] + notes: list[str] = [] + expected_base = trunk_origin + # Name of the branch the next open layer must be based on. Starts at the + # trunk and only advances past OPEN layers: a merged layer's content now + # lives in the trunk (GitHub retargets its child onto the trunk), so the + # layer above a merged one is judged against the trunk, not the merged head. + parent = trunk + for idx, br in enumerate(stack["branches"], start=1): + name = br["name"] + pr = prs.get((br.get("pr") or {}).get("number") or -1, {}) + if br.get("isMerged"): + rows.append({"layer": idx, "branch": name, "pr": (br.get("pr") or {}).get("number"), + "head": br.get("head"), "base": br.get("base"), "merged": True, "queued": False, + "draft": False, "mergeable": None, "merge_state": "MERGED", "ci": pr.get("ci"), + "base_ok": None, "origin_ok": None, "needs_rebase": False, "origin": None, + "pr_head": pr.get("headRefOid"), "expected_base": expected_base, "pr_base": pr.get("baseRefName")}) + # Merged: its head is (an ancestor of) the trunk head now. The next + # open layer must sit on the trunk; ``parent`` stays at the trunk. + expected_base = trunk_origin or br.get("head") or expected_base + continue + # gh stack omits ``head``/``base`` for a layer that has no local branch + # (e.g. viewed from a sibling worktree before the branch was fetched). + # Never subscript them directly: a missing key must degrade to ❓, not crash. + head = br.get("head") + base = br.get("base") + origin = origin_sha(name) if fetched else None + if (br.get("pr") or {}).get("number") is not None and prs and not pr: + notes.append(f"L{idx} {name}: PR #{(br.get('pr') or {}).get('number')} could not be resolved on GitHub (deleted, or no access); merge and CI shown as unknown") + base_ok = (base == expected_base) if (expected_base and base) else None + origin_ok = None + if origin: + if head: + origin_ok = head == origin and (not pr or pr.get("headRefOid") == origin) + elif pr: + # No local head to compare; the remote side (origin vs PR) can still be checked. + origin_ok = pr.get("headRefOid") == origin + if head is None: + notes.append(f"L{idx} {name}: gh stack reported no local head (branch not present locally); local tracking not verified") + if base is None: + notes.append(f"L{idx} {name}: gh stack reported no base SHA; base coherence not verified") + row = { + "layer": idx, + "branch": name, + "pr": (br.get("pr") or {}).get("number"), + "head": head, + "origin": origin, + "pr_head": pr.get("headRefOid"), + "base": base, + "expected_base": expected_base, + "base_ok": base_ok, + "origin_ok": origin_ok, + "needs_rebase": br.get("needsRebase"), + "merged": br.get("isMerged"), + "queued": br.get("isQueued"), + "draft": pr.get("isDraft"), + "mergeable": pr.get("mergeable"), + "merge_state": pr.get("mergeStateStatus"), + "ci": pr.get("ci"), + "pr_base": pr.get("baseRefName"), + "behind_trunk": False, + "pr_unresolved": bool((br.get("pr") or {}).get("number") is not None and prs and not pr), + } + parent_is_trunk = parent == trunk + if pr and pr.get("baseRefName") not in (None, parent): + problems.append(f"L{idx} {name}: PR #{row['pr']} base is {pr['baseRefName']}, expected {parent}") + if base_ok is False: + # The lowest OPEN layer (idx 1, or any layer whose lower layers are + # all merged) may sit on an older trunk commit: that is a note, not + # a rebase order. Against an open parent it is a real mismatch. + if parent_is_trunk and is_ancestor(base or "", expected_base): + row["behind_trunk"] = True + notes.append(f"L{idx} {name}: behind trunk ({short(base)} < {short(expected_base)}); fine unless CONFLICTING, do not restart CI just to catch up") + else: + problems.append(f"L{idx} {name}: base {short(base)} != parent head {short(expected_base)} -> needs rebase") + if origin_ok is False: + problems.append( + f"L{idx} {name}: local {short(head)} / origin {short(origin)} / PR {short(pr.get('headRefOid'))} differ" + " -> local tracking stale or unpushed; owner must fetch+reset or push" + ) + if br.get("needsRebase"): + if row["behind_trunk"]: + notes.append(f"L{idx} {name}: gh stack reports needsRebase, but only against a trunk that moved; no action while its CI can go green") + else: + problems.append(f"L{idx} {name}: gh stack reports needsRebase") + if pr.get("mergeable") == "CONFLICTING": + problems.append(f"L{idx} {name}: PR #{row['pr']} CONFLICTING") + if pr.get("isDraft"): + problems.append(f"L{idx} {name}: PR #{row['pr']} is DRAFT (blocks stack merge)") + rows.append(row) + # The next layer must be based on THIS layer's pushed head (fall back to local, then PR). + expected_base = origin or head or pr.get("headRefOid") or expected_base + parent = name + return rows, problems, notes + + +ICON_SYNC = {"ok": "βœ…", "stale": "πŸ”„", "rebase": "⚠️", "behind": "⏳", "unknown": "❓"} +ICON_MERGE = {"MERGED": "\U0001f3c1", "OK": "βœ…", "BLOCKED": "\U0001f6a7"} +ICON_CI = {"SUCCESS": "βœ…", "FAILURE": "β›”", "ERROR": "β›”", "PENDING": "\U0001f300", + "EXPECTED": "\U0001f300", "NONE": "β€”"} + + +def sync_icon(r: dict) -> str: + if r["merged"]: + return ICON_MERGE["MERGED"] + if r["origin_ok"] is False: + return ICON_SYNC["stale"] + if r.get("behind_trunk"): + return ICON_SYNC["behind"] + if r.get("pr_unresolved"): + return ICON_SYNC["unknown"] + if r["base_ok"] is False or r["needs_rebase"]: + return ICON_SYNC["rebase"] + if r["base_ok"] is None or r["origin_ok"] is None: + return ICON_SYNC["unknown"] + return ICON_SYNC["ok"] + + +def merge_icon(r: dict) -> str: + """βœ… only when GitHub says the PR can merge now; anything else is 🚧.""" + if r["merged"]: + return ICON_MERGE["MERGED"] + if r.get("pr_unresolved"): + return ICON_SYNC["unknown"] + if r["draft"] or r["queued"] or r["mergeable"] != "MERGEABLE": + return ICON_MERGE["BLOCKED"] + return ICON_MERGE["OK"] if r["merge_state"] in ("CLEAN", "HAS_HOOKS", "UNSTABLE") else ICON_MERGE["BLOCKED"] + + +def ci_icon(r: dict) -> str: + if r.get("pr_unresolved"): + return ICON_SYNC["unknown"] + return ICON_CI.get(r["ci"] or "NONE", ICON_CI["NONE"]) + + +def render_landing(landing: dict) -> str: + if landing["landable"] is None: + return f"LANDING: ❓ not judged - {landing['reason']}" + if landing["landable"]: + return f"LANDING: βœ… one merge of {landing['top']} @ {short(landing['top_sha'])} lands the stack - {landing['reason']}" + return f"LANDING: ❌ {landing['top']} @ {short(landing.get('top_sha'))} cannot land as one merge - {landing['reason']}" + + +def render_table(stack: dict, rows: list[dict], problems: list[str], notes: list[str], landing: dict, *, trunk_origin: str | None) -> str: + hdr = ["L", "PR", "rebase", "merge", "CI"] + lines = [f"stack: {stack['branches'][-1]['name']} -> {stack['trunk']} @ {short(trunk_origin)}", ""] + lines.append("| " + " | ".join(hdr) + " |") + lines.append("|" + "|".join("---" for _ in hdr) + "|") + for r in rows: + pr = f"#{r['pr']}" if r["pr"] else "-" + lines.append("| " + " | ".join([ + f"{r['layer']}/{len(rows)}", pr, sync_icon(r), merge_icon(r), ci_icon(r), + ]) + " |") + lines.append("") + if problems: + lines.append(f"VERDICT: ❌ NOT COHERENT ({len(problems)} issue(s))") + lines.extend(f"- {p}" for p in problems) + else: + lines.append("VERDICT: βœ… COHERENT - every base == parent head, every head pushed and on its PR") + lines.append(render_landing(landing)) + lines.extend(f"- note: {n}" for n in notes) + return "\n".join(lines) + + +def legend() -> str: + lines = [] + lines.append("rebase: βœ… not needed (base==parent head, local==origin==PR) ⚠️ needed ⏳ behind a moved trunk, no action πŸ”„ local tracking stale: fetch+reset before any sync ❓ unknown (--no-fetch, or PR unresolved) 🏁 merged") + lines.append("merge: βœ… mergeable now 🚧 blocked (conflicting, behind, draft, queued, required checks, or still computing) 🏁 merged") + lines.append("CI: βœ… green πŸŒ€ running β›” failed, do not enter β€” none") + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--trunk", help="only stacks whose trunk is this branch (e.g. develop, integrate/phase-bc)") + ap.add_argument("--phase", help="shorthand for --trunk integrate/phase-") + ap.add_argument("--all", action="store_true", help="show every stack, including merged/closed ones and other trunks") + ap.add_argument("--no-fetch", action="store_true", help="skip `git fetch origin` and origin comparison") + ap.add_argument("--no-pr", action="store_true", help="skip the GraphQL PR query (offline / local-only view)") + ap.add_argument("--json", action="store_true", help="emit the merged rows as JSON instead of tables") + args = ap.parse_args() + trunk_filter = args.trunk or (f"integrate/phase-{args.phase.lower()}" if args.phase else None) + + return guarded("gh-stack-view", lambda: run_report(args, trunk_filter)) + + +def preflight() -> None: + for tool in ("git", "gh"): + if not shutil.which(tool): + raise ToolError(f"`{tool}` not on PATH") + if run(["gh", "stack", "--help"], check=False).returncode != 0: + raise ToolError("gh-stack extension missing: `gh extension install github/gh-stack`") + if run(["git", "rev-parse", "--git-dir"], check=False).returncode != 0: + raise ToolError("not inside a git repository") + + +def run_report(args: argparse.Namespace, trunk_filter: str | None) -> int: + preflight() + stacks, hidden = discover_stacks(trunk_filter, include_all=args.all) + if not stacks: + where = f" with trunk {trunk_filter}" if trunk_filter else "" + hint = f" ({hidden} merged/closed/other-trunk stack(s) hidden; --all to show)" if hidden else "" + sys.stderr.write( + f"gh-stack-view: no open gh stack found{where}{hint}. Stacks are discovered through `git worktree list`; a stack " + "needs at least one of its layers checked out in a worktree (never `git checkout` in the main repo).\n" + ) + for line in SKIPPED: + sys.stderr.write(f"gh-stack-view: warning: skipped worktree {line}\n") + return 2 + fetched = not args.no_fetch + if fetched: + try: + fetch = run(["git", "fetch", "--quiet", "origin"], check=False) + why = None if fetch.returncode == 0 else ((fetch.stderr or fetch.stdout).strip().splitlines() or ["no output"])[-1] + except ToolError as exc: # timeout + why = str(exc) + if why: + fetched = False + sys.stderr.write(f"gh-stack-view: warning: git fetch origin failed ({why}); rebase column reported as unknown (❓)\n") + numbers = sorted({(b.get("pr") or {}).get("number") for st in stacks for b in st["branches"] if (b.get("pr") or {}).get("number") is not None}) + prs = {} if args.no_pr else pr_details(numbers) + + report: list[dict] = [] + blocks: list[str] = [] + any_problem = False + for st in stacks: + rows, problems, notes = build_rows(st, prs, fetched=fetched) + trunk_origin = origin_sha(st["trunk"]) if fetched else None + if fetched and trunk_origin is None: + notes.append(f"trunk {st['trunk']} is not on origin; base coherence for L1 and LANDING cannot be judged (push the trunk or check its name)") + if SKIPPED: + # A skipped worktree may have held the longest (true) view of this stack: the table + # could be a truncated prefix, so neither coherence nor landing can be trusted. + problems.append(f"discovery incomplete: {len(SKIPPED)} worktree(s) unreadable (see warnings); fix them and re-run before trusting this stack's shape") + any_problem |= bool(problems) + landing = landing_verdict(st, rows, trunk_origin) + if SKIPPED: + landing = {"landable": None, "top": landing.get("top"), "top_sha": landing.get("top_sha"), + "reason": "not judged while a worktree is unreadable"} + report.append({"trunk": st["trunk"], "trunk_origin": trunk_origin, "worktree": st["worktree"], + "rows": rows, "problems": problems, "notes": notes, "coherent": not problems, + "landing": landing}) + blocks.append(render_table(st, rows, problems, notes, landing, trunk_origin=trunk_origin)) + for line in SKIPPED: + sys.stderr.write(f"gh-stack-view: warning: skipped worktree {line}\n") + if args.json: + print(json.dumps({"stacks": report, "hidden": hidden, "coherent": not any_problem, "skipped_worktrees": SKIPPED}, indent=2)) + else: + print("\n\n".join(blocks)) + print() + if hidden: + print(f"hidden: {hidden} merged/closed/other-trunk stack(s); --all to show") + print(legend()) + return 1 if any_problem else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/sc-gh-stack/skills/sc-gh-stack-view/SKILL.md b/packages/sc-gh-stack/skills/sc-gh-stack-view/SKILL.md new file mode 100644 index 000000000..a6a120d1e --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack-view/SKILL.md @@ -0,0 +1,151 @@ +--- +name: sc-gh-stack-view +version: 0.1.1 +description: One-call coherence, mergeability, CI and LANDING table for every open gh stack. Use for any stacked-PR status question, before and after every link, unstack, rebase or merge, or /sc-gh-stack-view. Never check layers one branch at a time. +entry_point: /sc-gh-stack-view +--- + +# sc-gh-stack-view + +Read-only stack status. One command replaces the "one `gh pr view` per branch" +habit, which never shows the two things that actually break stacks: + +1. **Base coherence** - is each layer's base SHA the head SHA of the layer + below (and the bottom layer's base the trunk head)? Only + `gh stack view --json` exposes `head` and `base`; `gh pr view` does not. +2. **Rebase / mergeability** - `needsRebase` from gh stack plus GitHub's + `mergeable` and `mergeStateStatus` (`CONFLICTING`, `BEHIND`, `DIRTY`, + `BLOCKED`, `CLEAN`). `gh pr list` and `gh pr checks` do not return + `needsRebase`, and `gh pr checks` output must never be text-parsed. + +The script also compares every local head against `origin/` and the +PR's `headRefOid`, so stale local tracking (someone else rebased the stack) is +caught before anyone runs `gh stack sync` on top of it. + +## Step 1 β€” Verify gh, gh-stack extension, git, python3 + +Before running the script, confirm the toolchain is present: + +```bash +which gh && gh --version # >= 2.0 +gh extension list | grep -i stack && gh stack --version # github/gh-stack v0.1.0 +which git && git --version # >= 2.38 +python3 --version # >= 3.9 +gh auth status # needed unless --no-pr +``` + +If any is missing, probe the usual off-PATH locations first (Claude Code's +bash may not share PATH with the interactive shell): + +```bash +for cli in gh git python3; do + command -v "$cli" >/dev/null && continue + for d in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/.pyenv/shims"; do + [ -x "$d/$cli" ] && echo "$cli found at: $d/$cli" && break + done +done +``` + +Found off-PATH: `export PATH=":$PATH"` for this session. Still missing, +below the floors (git < 2.38, gh < 2.0, python3 < 3.9), or no gh-stack +extension: read `references/installation-and-troubleshooting.md` and stop; +do not work around a missing `gh`, `gh-stack` or `git`. + +## Usage + +``` +/sc-gh-stack-view [--trunk | --phase aw] [--all] [--no-fetch] [--no-pr] [--json] +``` + +Run from anywhere in the repo, normally the main checkout on `develop` or +`main`. The script discovers every stack by running `gh stack view --json` in +each worktree from `git worktree list` (concurrently), keeps the longest view +of each stack (a lower-layer worktree only sees the layers linked from it), +and joins them into one report. The default view shows every stack that still +has an open layer, on any trunk. `--trunk`/`--phase` narrows to one trunk +(`--phase` is shorthand for `--trunk integrate/phase-`); `--all` also shows +merged/closed stacks. Hidden stacks are counted on a `hidden:` line. + +```bash +python3 .claude/scripts/gh_stack_view.py --trunk develop +``` + +Under a plugin install the script is at `$CLAUDE_PLUGIN_ROOT/scripts/gh_stack_view.py`. +If it is at neither path, locate it with `find .claude ~/.claude -name gh_stack_view.py` +and use the newest match. + +Exit codes: + +| Code | Meaning | +|------|---------| +| 0 | every shown stack coherent | +| 1 | problems listed under a VERDICT | +| 2 | nothing to show or the environment failed; stderr says which (see Errors) | + +## Errors + +Every failure is one `gh-stack-view: ...` line on stderr with the next action, +never a traceback. Exit 2 covers all of these, so read the line: + +- `git`/`gh` not on PATH, gh-stack extension missing (`gh extension install github/gh-stack`), not inside a git repository. +- `gh repo view` / `gh api graphql` failure: run `gh auth status`; `--no-pr` gives a local-only view meanwhile. +- GraphQL errors, null data or non-JSON output: same, with the PR numbers named. +- `gh stack view --json` returning an unexpected shape: upgrade gh-stack. +- No open stack found: the line reports how many merged/closed/other-trunk stacks were hidden; use `--trunk`, `--phase` or `--all`. + +Non-fatal: a failed `git fetch origin` is warned once and the rebase column +shows ❓ instead of comparing against stale refs. Pruned or unreadable +worktrees are skipped silently (`git worktree prune` cleans them up). + +## Output + +The script renders everything. **Paste its output verbatim and unfenced** +(no ``` around it) so the markdown table renders in the terminal. The agent +makes no rendering decisions: no reformatting, no re-summarising, no +substituting its own per-branch lookups. Example (as it should appear): + +stack: fix/aw-pool-read-migration -> integrate/phase-aw @ 0e640b20a + +| L | PR | rebase | merge | CI | +|---|---|---|---|---| +| 1/2 | #1242 | βœ… | 🚧 | πŸŒ€ | +| 2/2 | #1244 | βœ… | 🚧 | πŸŒ€ | + +VERDICT: βœ… COHERENT - every base == parent head, every head pushed and on its PR + +| Column | Source | Icons | +|--------|--------|-------| +| L | layer / stack depth, bottom first | | +| rebase | `gh stack view --json` `head`/`base`/`needsRebase`, `origin/` after one fetch, PR `headRefOid` | βœ… not needed (base==parent head and local==origin==PR) Β· ⚠️ needed Β· πŸ”„ local tracking stale, fetch+reset before any sync Β· ❓ unknown (`--no-fetch`) Β· 🏁 merged | +| merge | one GraphQL query: `mergeable`, `mergeStateStatus`, `isDraft`; gh stack `isMerged`/`isQueued` | βœ… mergeable now Β· 🚧 blocked (conflicting, behind, draft, queued, required checks, still computing) Β· 🏁 merged | +| CI | same query, `statusCheckRollup.state` of the head commit | βœ… green Β· πŸŒ€ running Β· β›” failed, do not enter Β· β€” none | + +`VERDICT` names the branch and the owner action for every problem (SHAs +appear there, not in the table). The lowest OPEN layer (layer 1, or the first +layer above already-merged ones) merely behind trunk is a note, not a problem: +do not rebase a layer whose CI could go green just to catch up with trunk. The legend is printed once at the end. `--json` emits +`stacks[].rows[]`, `problems[]`, `notes[]`, `coherent` for agents that need to +branch on the result. + +If you see stale local tracking (πŸ”„, or a false `NOT COHERENT` with `-` rows +right after an `unstack`), that is fixed by the recipe in +`../sc-gh-stack/references/recipe-stale-tracking.md`, not by re-running this +script harder. + +## Rules the skill enforces by convention + +- This is **the** status call for stacks. Do not fan out `gh pr view` per + branch; that costs N calls and still misses base coherence and needsRebase. +- The script is read-only. `gh stack sync` and `gh stack rebase` rewrite and + force-push every layer; only the stack writer runs them, and only after this + rebase column shows βœ… on every layer (otherwise sync re-rebases stale local + heads over someone else's push and turns PRs CONFLICTING). +- After ANY merge or rebase on a stack, run this again and reconcile before + dispatching dev or QA against a layer. +- Draft PRs block `gh stack merge`; the table flags them. + +## Related + +The `sc-gh-stack` skill (`../sc-gh-stack/SKILL.md`) owns the stacked-PR model, +preconditions, and the create/sync/rebase/merge recipes; this skill only +reports status. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack-view/references/installation-and-troubleshooting.md b/packages/sc-gh-stack/skills/sc-gh-stack-view/references/installation-and-troubleshooting.md new file mode 100644 index 000000000..74cf4e7a6 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack-view/references/installation-and-troubleshooting.md @@ -0,0 +1,98 @@ +# Installation and troubleshooting + +Read this when `gh`, the `gh-stack` extension, `git`, or `python3` might be missing or too old before any `sc-gh-stack` workflow runs β€” it is the CLI-dependency doc this skill's SKILL.md Step 1 points to, per the repo's skill guidelines (`docs/claude-code-skills-agents-guidelines.md`). + +This skill depends on: +- `gh` (GitHub CLI), authenticated +- the `gh-stack` extension (`github/gh-stack`), v0.1.0 +- `git` +- `python3` (for any accompanying stdlib-only scripts) +- stacked pull requests enabled on the target GitHub repository + +## Check First + +```bash +which gh && gh --version +gh extension list | grep stack +gh stack --version +which git && git --version +python3 --version +``` + +Skip installation for anything already present. `gh stack --version` (not `gh stack version`) reports the installed extension version, e.g. `gh stack version 0.1.0`. + +## Find Existing Install + +If `which`/`command -v` fails for any dependency, probe common locations before concluding it is absent: + +```bash +for cli in gh git python3; do + command -v "$cli" >/dev/null && continue + for d in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/.pyenv/shims"; do + [ -x "$d/$cli" ] && "$d/$cli" --version && break + done +done +``` + +If a binary exists off-PATH, `export PATH=":$PATH"` for the session, or call it by absolute path. + +## Install + +- macOS: `brew install gh` +- Linux: distribution package, or see https://github.com/cli/cli#installation +- Windows: `winget install GitHub.cli` + +Then: + +```bash +gh auth login +gh extension install github/gh-stack +gh extension upgrade stack +gh stack --version +``` + +## Minimum Version + +- `gh >= 2.0` +- `gh-stack` v0.1.0 (this reference is field-verified against v0.1.0; re-check flags with `--help` if a newer extension version is installed) +- `git >= 2.38` β€” needed for `git merge-tree --write-tree`, used in conflict pre-checks (see `troubleshooting.md`) +- `python3 >= 3.9` + +## PATH Troubleshooting + +Claude Code's bash inherits a minimal PATH that may omit directories populated by `.zshrc`/`.bashrc` init (Homebrew, pyenv shims, user-local bin dirs). A `gh` that works in an interactive shell can be silently absent in the agent's shell. Use the probe loop above and export the directory for the session rather than assuming the CLI is missing. + +## Validation + +```bash +gh stack view --json # in a worktree that is on a stack branch; exits 0 +python3 .claude/scripts/gh_stack_view.py --help +``` + +`gh stack view --json` exits 0 and prints the stack payload when run from a branch that is part of a tracked stack; run it from trunk or a non-stack branch and it exits **2** (not in a stack) instead β€” that is expected, not a failure of the tool itself. + +## Known Issues + +### `gh stack submit` exits 9 + +Stacked pull requests are not enabled on the repository. This cannot be fixed from the CLI β€” a repository admin must enable the feature on GitHub. Stop and tell the user. + +### `git config rerere.enabled true` prompt on first `init` + +The first `gh stack init` in a repo may prompt under a TTY to enable `git rerere`. Pre-set `git config rerere.enabled true` before running `init` to skip the prompt entirely. + +### Multiple remotes + +`gh stack` commands that push or fetch need a single default remote. If more than one remote is configured: `git config remote.pushDefault origin`. Note `checkout`, `modify`, and `trunk` have no `--remote` flag at all and always rely on `remote.pushDefault`. + +### View script needs a checked-out layer + +Any wrapper script that shells out to `gh stack view --json` reads the *current* worktree's stack state β€” it needs at least one stack branch checked out in that worktree. Running it from trunk, or in a worktree that was never part of a stack, returns "not in a stack" (exit 2), not stack data. + +### `gh stack help ` doesn't work + +Only the top-level `gh stack --help` / `gh stack help` prints subcommand help. To see a subcommand's own flags, use `gh stack --help` (not `gh stack help `). + +### Commands re-verified against v0.1.0 + +All flags and behavior in `commands.md` and `troubleshooting.md` were re-confirmed against `gh stack --help` output for extension v0.1.0. If the installed extension has moved to a newer version, re-run `gh stack --help` for any command before trusting a specific flag name. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/SKILL.md b/packages/sc-gh-stack/skills/sc-gh-stack/SKILL.md new file mode 100644 index 000000000..f9d5e31c4 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/SKILL.md @@ -0,0 +1,146 @@ +--- +name: sc-gh-stack +version: 0.1.1 +description: Run stacked PRs with the gh stack extension the way that lands (append-only frozen layers on a named trunk, one stack writer, QA/CI on the top, one atomic merge). Use for any stack, stacked/dependent PRs, gh stack link/unstack/merge, landing, a red layer, or /sc-gh-stack. Supersedes /gh-stack. +entry_point: /sc-gh-stack +--- + +# sc-gh-stack + +Supersedes the generic `/gh-stack` skill; its command guide lives in +`references/commands.md`. A stack exists so CI completes once, on the landing head, and the merge +happens once. Every rule here was paid for in production: the "why" lines +cite the incident. This file is the table of contents; read the reference a +step points to before acting, and nothing else. + +## Step 1 β€” Verify gh, the gh-stack extension, git, python3 + +```bash +which gh && gh --version +gh extension list | grep -i stack && gh stack --version +which git && git --version +which python3 && python3 --version +``` + +If any is missing, probe the usual off-PATH locations (Claude Code's bash may +not share PATH with the interactive shell): + +```bash +for cli in gh git python3; do + command -v "$cli" >/dev/null && continue + for d in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/.pyenv/shims"; do + [ -x "$d/$cli" ] && echo "$cli found at: $d/$cli" && break + done +done +``` + +Found off-PATH: `export PATH=":$PATH"` for this session. Still missing, +below the floors (git < 2.38, gh < 2.0, python3 < 3.9, no gh-stack +extension), or `gh auth status` fails: read `references/installation-and-troubleshooting.md` +and stop. Never continue with degraded behavior. + +## Step 2 β€” Status first, and after every write + +`/sc-gh-stack-view` (skill `sc-gh-stack-view`, script +`.claude/scripts/gh_stack_view.py`) is THE status call. Run it before any +`gh stack` write command, after every link, unstack, rebase or merge, and +before dispatching anyone to a layer. Paste its output verbatim. Never fan out +`gh pr view` per branch, never text-parse `gh pr checks`, never use bare +`gh stack view` (it opens a TUI; the script uses `--json`). + +## The model (read `references/model.md` once) + +- **Trunk** is the branch the stack lands on: `develop`, an integration branch + such as `integrate/phase-N`, or whatever the user names. Never assume the + repository default branch. +- **Append-only and linear.** Every unit of work is a new worktree cut from the + current pushed top. A layer is **frozen** the moment its task closes; nothing + below the top is edited again. Findings on layer K are fixed on a new layer + above the top, never on K. +- **One writer per branch, one stack writer.** Only the stack writer (the lead + or orchestrator) runs `link`, `unstack`, `sync`, `rebase`, `merge`. Everyone + else pushes commits to their own layer only. +- **QA and CI gate the top only.** Red on a lower layer is informational unless + that layer is to be merged alone. +- **Small fixes do not get a stack.** One owner, well under a few hundred + lines, fix and tests together: one PR off trunk, no stack. + +## What to do (pick the row, open the reference) + +| Situation | Reference | +|-----------|-----------| +| Starting a new sprint, fix round, docs or evidence layer | `references/recipe-cut-layer.md` | +| First push of a layer landed; it needs a PR and a stack link | `references/recipe-link.md` | +| Several open PRs on one trunk depend on each other | `references/recipe-link.md` (full ordered link) | +| Fixing a finding on a frozen layer, or on any layer that has children | `references/recipe-cut-layer.md` (new layer on top; never edit below the top) | +| Starting a task on the live top layer (no children, not frozen) | `references/workflow.md` step 4 (rebase at task start) | +| Insert a layer mid-stack, remove a red layer whose fix is above, collapse a passing bottom | `references/recipe-restack.md` | +| Everything frozen, top green and QA PASS: land it | `references/recipe-land.md` | +| `gh stack merge` refused, `gh pr merge` refused, non-linear stack | `references/recipe-land.md` (fallbacks A and B) | +| View shows πŸ”„, or NOT COHERENT with `-` rows after an unstack | `references/recipe-stale-tracking.md` | +| Deciding layer boundaries, naming, what belongs where | `references/stack-design.md` | +| Any `gh stack` command, flag, exit code, `--json` schema | `references/commands.md` | +| An error message you do not recognise | `references/troubleshooting.md` | +| How a whole phase runs on one stack (worked example) | `references/phase-model-example.md` | + +Every recipe ends the same way: run `/sc-gh-stack-view`, paste it, and record +the current stack number (each unstack mints a new one). + +## Hard preconditions (details and incidents in `references/preconditions.md`) + +1. Creating or re-creating a stack is ONE `gh stack link --base + ... `: the full ordered list, from a worktree on a + stack branch. Without `--base` the bottom PR is retargeted to the default + branch and locked there. Appending one PR to an existing stack may use + `gh stack link `, which appends on top only and cannot + insert. Either way, verify every base with `/sc-gh-stack-view` afterwards. +2. Cut a new layer only from a **pushed** head that contains every lower + layer's head (`git merge-base --is-ancestor`). Two layers cut from the same + head must declare at cut time which one merges the other forward. +3. Never rewrite a layer that has children. Never rebase a frozen layer to + "catch up" with trunk. Never force-push under a live agent. +4. Freeze the trunk from the final sync until the landing is confirmed: + explicit FREEZE to every trunk writer, acked. One stray push restarts every + layer's CI. +5. Never merge a red layer, and never merge a red bottom layer alone when its + fix lives above (that puts the red on the trunk). Remove the red layer from + the stack so the fixing layer above carries its commits + (`recipe-restack.md`, section 2). +6. Before any scoped `gh stack merge `, read the full `branches[]` from + `gh stack view --json`; an upper empty draft gets swept in and its branch + deleted. +7. Land with `gh stack merge --yes --merge` (merge commits only, by + stack number so stale local tracking cannot pick the wrong stack). Never + `--squash`. +8. Mergeability is `git merge-tree --write-tree A B` (exit code) or a real + `git merge --no-commit` in a scratch worktree. Never the legacy 3-arg + `merge-tree`; it prints diff3 hunks on clean merges. +9. Every push to a PR head restarts its CI. No cosmetic or metadata pushes to + a green or running head; batch follow-ups into one validated push. +10. Landing, closing PRs and unstacking are outward-facing. Confirm with the + user before `gh stack merge`, `gh pr close` or `gh stack unstack` unless the + user already directed that exact action. + +## Deterministic helpers + +| Script (installed under `.claude/scripts/`) | Purpose | Mutates | +|---|---|---| +| `gh_stack_view.py` | Coherence, mergeability, CI and LANDING table for every open stack | no | +| `gh_stack_chain_check.py --trunk ... ` | Pre-link check: every head pushed, linear ancestry, PR bases as expected, top merges clean into trunk | no | + +If `CLAUDE_PLUGIN_ROOT` is set (plugin install), the scripts are at +`$CLAUDE_PLUGIN_ROOT/scripts/`. Otherwise, if a script is not at +`.claude/scripts/`, locate it with `find .claude ~/.claude -name 'gh_stack_*.py'` +and use the newest match. Never reproduce the checks by hand. + +## Storage + +No state under `.claude/` or `.sc/`. The skill runs `git`, `gh` and `gh stack` +in the target repository and its worktrees. Stack tracking is gh-stack's own, +per worktree (`recipe-stale-tracking.md` explains the consequence). + +## Related + +- `../sc-gh-stack-view/SKILL.md` β€” the status tool this skill depends on. +- `sc-git-worktree` (if installed) creates the layer worktrees; the recipes + show the plain `git worktree add` equivalent. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/commands.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/commands.md new file mode 100644 index 000000000..016110108 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/commands.md @@ -0,0 +1,181 @@ +# Command guide for gh stack + +Read this when you need the exact flags, behavior, or exit codes for a `gh stack` subcommand β€” the operational reference behind `sc-gh-stack`'s workflow. `gh stack --help` is authoritative for flags; this file adds behavior, side effects, and field-verified failure modes `--help` doesn't cover. + +## Non-interactive rules + +**Read with the model in mind.** `init`, `add`, `submit`, `sync` and `rebase` +are documented for completeness; the sc-gh-stack recipes use one worktree per +layer, `gh pr create` on the first push and `gh stack link --base`. `link`, +`unstack`, `sync`, `rebase` and `merge` are stack-writer only; `merge`, +`unstack` and `gh pr close` are confirmed with the user unless directed +(`SKILL.md` precondition 10). + + +Every invocation must be shaped so it cannot prompt or open a TUI β€” a prompt hangs an agent indefinitely. **Never do:** + +- `gh stack view` or `--short` β€” always `--json` (bare/`--short` render for humans, may open a TUI) +- `gh stack submit` without `--auto` (otherwise opens an interactive title/description editor) +- `gh stack init` / `add` / `checkout` with no argument β€” always pass branch names / a stack#, PR#, PR URL, or branch +- `gh stack checkout ` when a different local stack already covers those branches β€” unbypassable conflict prompt; `gh stack unstack --local` first, then retry +- `gh stack switch` β€” TUI picker; use `up`/`down`/`top`/`bottom`/`checkout` instead +- `gh stack modify` β€” TUI-only, no non-interactive form at all +- `gh pr merge` on a stacked PR β€” refused; use `gh stack merge` + +## Quick reference + +| Task | Command | +|---|---| +| Create a (multi-layer) stack | `gh stack init auth api frontend` | +| Custom trunk | `gh stack init --base develop branch-a` | +| Add a branch | `gh stack add api-routes` | +| Add + stage all + commit | `gh stack add -Am "message" api-routes` | +| Push branches | `gh stack push` | +| Push + create draft PRs | `gh stack submit --auto` | +| Create PRs ready for review | `gh stack submit --auto --open` | +| Sync (fetch, rebase, push) | `gh stack sync` / `gh stack sync --prune` | +| Rebase (all / upstack / continue / abort) | `gh stack rebase` Β· `--upstack` Β· `--continue` Β· `--abort` | +| View stack (JSON) | `gh stack view --json` | +| Move / jump | `gh stack up [n]` / `down [n]` / `top` / `bottom` / `trunk` | +| Check out by stack#/PR#/branch | `gh stack checkout 7` | +| Link PRs, no local tracking | `gh stack link --base main a b c` | +| Tear down a stack | `gh stack unstack [7]` | +| Merge whole/partial stack (merge commits) | `gh stack merge --yes --merge` / `gh stack merge 42 --yes --merge` | + +## init + +`gh stack init [flags] ` β€” `-b, --base ` trunk (default: repo default branch). + +Processes branches bottom to top: existing ones adopted, missing ones created (first from trunk, later ones from the branch before). Checks out the **last** branch listed. Enables `git rerere` β€” first run under a TTY may prompt; pre-set `git config rerere.enabled true` to skip it. + +## add + +`gh stack add [flags] ` β€” `-m, --message `; `-A, --all` (stage all incl. untracked, requires `-m`); `-u, --update` (tracked only, requires `-m`, exclusive with `-A`). + +Must run from the **top** branch (or trunk if empty), else exits **5** `can only add branches on top of the stack` β€” `gh stack top` first. Without `-Am`, uncommitted changes carry onto the new branch (working tree untouched). `add -Am` commits in place (no new branch) when the current branch has no commits yet (e.g. right after `init`). Prefer plain `git add`/`git commit` for deliberate staging; reserve `-Am`/`-um` for simple single-commit layers. + +## push + +`gh stack push [flags]` β€” `--remote `. Pushes every active (non-merged, non-queued) branch in one multi-ref push, per-branch `--force-with-lease`. **Not atomic** β€” one rejection doesn't block others; fix and rerun. Never creates/updates PRs β€” that's `submit`. + +## submit + +`gh stack submit [flags]` β€” `--auto` (required non-interactively), `--open` (new+existing PRs ready for review), `--remote `. + +Pushes each active branch sequentially (not atomic β€” a rejection leaves earlier pushes standing; fix and rerun), creates a PR for every branch lacking one (base = first non-merged ancestor), links into a Stack on GitHub. If every PR is already merged, forks unmerged branches into a **new** stack rooted at trunk. Exits **9** non-interactively if stacks aren't enabled. Title: single commit β†’ its subject/body; multiple commits β†’ humanized branch name; no custom-title flag β€” `gh pr edit` after. + +## link + +`gh stack link [flags] ...` β€” `--base `, `--open`, `--remote `. + +Bottom-to-top arguments; each a branch name, PR#, or PR URL (numeric tries PR# first, falls back to branch). Branch args auto-push (non-force, atomic). Missing PRs are created with correct chained bases; wrong bases on existing PRs are corrected. A numeric first arg is a **stack#** only if that stack exists, then the rest append to its top. Additive only. + +**Field note:** always pass `--base ` explicitly. Without it the bottom PR is retargeted to the repo's default branch, and GitHub then refuses `gh pr edit --base` on it afterward. + +**Field note:** `gh stack link ` appends on TOP and retargets the new PR onto the current top β€” it cannot insert mid-stack. Attempting one fails with `HTTP 422 PullRequest.base is invalid`, then `new PRs must be added to the top of the existing stack`. To insert: `unstack`, `gh pr edit --base`, full re-link. + +**Field note:** `link` never removes a PR β€” dropping a layer means `unstack` and re-link with the reduced list. Every `unstack` + re-link mints a **new** stack number. + +## sync + +`gh stack sync [flags]` β€” `--remote `, `--prune`. + +Order: fetch β†’ reconcile GitHub's stack (pulls remotely-added branches; non-interactive divergence aborts) β†’ fast-forward trunk β†’ cascade-rebase (handles squash-merges via `--onto`; conflict restores all branches, exits **3**) β†’ push atomically β†’ refresh PR state β†’ sync stack object (additive, 2+ PRs only) β†’ prune (only with `--prune`, non-interactive). + +**Field note:** on divergence, sync prints `β„Ή Sync aborted`, changes nothing, and **exits 0** β€” not a success signal here. Check stderr for that message, or diff `view --json` before/after. + +## rebase + +`gh stack rebase [flags] [branch]` β€” `--upstack`, `--downstack`, `--no-trunk` (skip fetch/trunk), `--continue`, `--abort`, `--remote `, `--committer-date-is-author-date`/`--preserve-dates`. + +**Not used in this model** (lower layers are frozen; a fix is a new top layer, `recipe-cut-layer.md`). Upstream: use `--upstack` after editing a lower layer, or when `sync` reports a conflict. Squash-merged parents detected and replayed via `--onto` automatically. `rerere` (enabled by `init`) auto-resolves previously-seen conflicts. Starting while one is in progress exits **7**. + +## view + +`gh stack view --json` β€” always `--json`; bare/`--short` are for humans, may open a TUI. + +```json +{ + "trunk": "main", "currentBranch": "api-routes", + "branches": [{ + "name": "auth", "head": "abc1234...", "base": "def5678...", + "isCurrent": false, "isMerged": true, "isQueued": false, "needsRebase": false, + "pr": { "number": 42, "url": "https://github.com/o/r/pull/42", "state": "MERGED" } + }] +} +``` + +Fields: `name` Β· `head`/`base` current/parent HEAD SHA Β· `isCurrent` Β· `isMerged` Β· `isQueued` (merge queue) Β· `needsRebase` (base not an ancestor) Β· `pr` (omitted if none; `state` is `OPEN`/`MERGED`/`QUEUED`). `view` refreshes PR state from GitHub best-effort. + +## Navigation β€” up / down / top / bottom / trunk + +All fully non-interactive, no flags besides `-h`; `up`/`down` accept a count. Movement clamps to stack bounds; merged branches are skipped, so `bottom` lands on the lowest **unmerged** branch. `gh stack switch` is a TUI picker β€” don't use it. + +## checkout + +`gh stack checkout ` β€” no flags; relies on `remote.pushDefault` with multiple remotes. + +A bare number resolves stack# β†’ locally-tracked PR# β†’ GitHub-discovered PR# β†’ branch name. Stack/PR#/URL fetches from GitHub and sets up locally. If a local stack already covers those branches with a different composition, checkout can't force past it β€” `unstack --local` then retry. + +## unstack + +`gh stack unstack [] [flags]` (alias `delete`) β€” `--local` (local only, never contacts GitHub). + +Removes the stack **grouping** only β€” never deletes PRs/branches. No argument β†’ active stack (current branch's). A stack# works from anywhere via the API, tracked or not. Queued/auto-merge PRs stay stacked; if any remain, the whole grouping is kept. Unknown stack# exits **2**. + +## merge + +`gh stack merge [] [flags]` β€” `--squash`, `--rebase`, `--merge`, `--merge-method `, `-y, --yes`. + +No arg β†’ current stack; PR# β†’ that PR + everything below; stack# β†’ every unmerged PR in it. **All-or-nothing.** Only open/not-draft checked pre-merge; branch protection/rules evaluated by GitHub at merge time. A merge queue on the base overrides: queued instead, queue picks the method (flag ignored with a warning), PRs may land in separate groups. + +**Field note:** `merge` blocks the whole set on "not a linear descendant" or "out-of-date with base" β€” it doesn't selectively skip a bad layer. A scoped `gh stack merge ` can sweep in an upper *empty* draft too (GitHub marks it merged, deletes its branch). Read full `branches[]` from `view --json` before scoping. + +**Field note:** `gh pr merge` on a stacked PR: `must be merged using the asynchronous merge REST API`. Working fallback (poll until `"merged"`; an abbreviated SHA fails with the misleading `Pull request head branch was modified` β€” always pass the full 40-char `headRefOid`): +```bash +gh api -X PUT repos/{owner}/{repo}/pulls/{n}/merge-async -f merge_method=merge -f sha= +gh api repos/{owner}/{repo}/pulls/{n}/merge-async/ # poll +``` +After the parent merges, GitHub retargets/rebases the child branch within ~30s; once trees are confirmed identical, `git fetch && git reset --hard origin/` β€” never force-push over it. + +**Field note:** to check whether a PR's base was ever silently retargeted, filter `/events` (not `/timeline`, which misses it): `gh api repos/{owner}/{repo}/issues/{n}/events | jq '.[] | select(.event=="base_ref_changed")'`. + +## Output conventions + +Status messages go to **stderr** (`βœ“`/`βœ—`/`⚠`/`β„Ή` prefixes); data output (`view --json`) goes to **stdout**. Pipe `2>/dev/null` to isolate data. + +## Exit codes + +| Code | Meaning | Agent action | +|---|---|---| +| 0 | Success | Proceed β€” but see sync divergence field note | +| 1 | Generic error | Read stderr | +| 2 | Not in a stack / unknown stack# | `gh stack init`, or check the number | +| 3 | Rebase conflict | Resolve, `git add`, `gh stack rebase --continue` | +| 4 | GitHub API failure | Check `gh auth status`, retry | +| 5 | Invalid arguments | Fix invocation (e.g. `add` off the top branch) | +| 6 | Disambiguation required | `gh stack checkout ` first | +| 7 | Rebase in progress | `--continue` or `--abort` | +| 8 | Stack file locked | Wait (~5s timeout), retry | +| 9 | Stacked PRs unavailable | Tell user; repo admin must enable | +| 10 | Modify recovery required | `gh stack modify --abort` (never invoke `modify` yourself) | + +## Parsing `--json` with jq + +```bash +output=$(gh stack view --json) +echo "$output" | jq '[.branches[] | select(.needsRebase)] | length' # needs rebase? +echo "$output" | jq -r '.branches[] | select(.pr.state=="OPEN") | .pr.url' +echo "$output" | jq -r '.branches[] | select(.isMerged) | .name' # merged branches +echo "$output" | jq -r '.currentBranch, .trunk' +echo "$output" | jq '[.branches[] | .isMerged] | all' # fully merged? +``` + +## Known limitations + +1. Stacks are strictly linear (one parent, one child max) β€” use separate stacks for parallel work. +2. Stack disambiguation (exit 6) has no bypass flag. +3. Multiple remotes need `remote.pushDefault` or `--remote` (`push`/`submit`/`sync`/`rebase`/`link` only β€” `checkout`/`modify`/`trunk` have none). +4. `checkout` by branch name only resolves locally tracked stacks β€” use a stack#/PR# to pull from GitHub. +5. `submit` generates title/body from commits, no custom-title flag β€” `gh pr edit` after. +6. `link` never removes a PR and cannot insert mid-stack (see field notes above). diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/installation-and-troubleshooting.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/installation-and-troubleshooting.md new file mode 100644 index 000000000..74cf4e7a6 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/installation-and-troubleshooting.md @@ -0,0 +1,98 @@ +# Installation and troubleshooting + +Read this when `gh`, the `gh-stack` extension, `git`, or `python3` might be missing or too old before any `sc-gh-stack` workflow runs β€” it is the CLI-dependency doc this skill's SKILL.md Step 1 points to, per the repo's skill guidelines (`docs/claude-code-skills-agents-guidelines.md`). + +This skill depends on: +- `gh` (GitHub CLI), authenticated +- the `gh-stack` extension (`github/gh-stack`), v0.1.0 +- `git` +- `python3` (for any accompanying stdlib-only scripts) +- stacked pull requests enabled on the target GitHub repository + +## Check First + +```bash +which gh && gh --version +gh extension list | grep stack +gh stack --version +which git && git --version +python3 --version +``` + +Skip installation for anything already present. `gh stack --version` (not `gh stack version`) reports the installed extension version, e.g. `gh stack version 0.1.0`. + +## Find Existing Install + +If `which`/`command -v` fails for any dependency, probe common locations before concluding it is absent: + +```bash +for cli in gh git python3; do + command -v "$cli" >/dev/null && continue + for d in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/.pyenv/shims"; do + [ -x "$d/$cli" ] && "$d/$cli" --version && break + done +done +``` + +If a binary exists off-PATH, `export PATH=":$PATH"` for the session, or call it by absolute path. + +## Install + +- macOS: `brew install gh` +- Linux: distribution package, or see https://github.com/cli/cli#installation +- Windows: `winget install GitHub.cli` + +Then: + +```bash +gh auth login +gh extension install github/gh-stack +gh extension upgrade stack +gh stack --version +``` + +## Minimum Version + +- `gh >= 2.0` +- `gh-stack` v0.1.0 (this reference is field-verified against v0.1.0; re-check flags with `--help` if a newer extension version is installed) +- `git >= 2.38` β€” needed for `git merge-tree --write-tree`, used in conflict pre-checks (see `troubleshooting.md`) +- `python3 >= 3.9` + +## PATH Troubleshooting + +Claude Code's bash inherits a minimal PATH that may omit directories populated by `.zshrc`/`.bashrc` init (Homebrew, pyenv shims, user-local bin dirs). A `gh` that works in an interactive shell can be silently absent in the agent's shell. Use the probe loop above and export the directory for the session rather than assuming the CLI is missing. + +## Validation + +```bash +gh stack view --json # in a worktree that is on a stack branch; exits 0 +python3 .claude/scripts/gh_stack_view.py --help +``` + +`gh stack view --json` exits 0 and prints the stack payload when run from a branch that is part of a tracked stack; run it from trunk or a non-stack branch and it exits **2** (not in a stack) instead β€” that is expected, not a failure of the tool itself. + +## Known Issues + +### `gh stack submit` exits 9 + +Stacked pull requests are not enabled on the repository. This cannot be fixed from the CLI β€” a repository admin must enable the feature on GitHub. Stop and tell the user. + +### `git config rerere.enabled true` prompt on first `init` + +The first `gh stack init` in a repo may prompt under a TTY to enable `git rerere`. Pre-set `git config rerere.enabled true` before running `init` to skip the prompt entirely. + +### Multiple remotes + +`gh stack` commands that push or fetch need a single default remote. If more than one remote is configured: `git config remote.pushDefault origin`. Note `checkout`, `modify`, and `trunk` have no `--remote` flag at all and always rely on `remote.pushDefault`. + +### View script needs a checked-out layer + +Any wrapper script that shells out to `gh stack view --json` reads the *current* worktree's stack state β€” it needs at least one stack branch checked out in that worktree. Running it from trunk, or in a worktree that was never part of a stack, returns "not in a stack" (exit 2), not stack data. + +### `gh stack help ` doesn't work + +Only the top-level `gh stack --help` / `gh stack help` prints subcommand help. To see a subcommand's own flags, use `gh stack --help` (not `gh stack help `). + +### Commands re-verified against v0.1.0 + +All flags and behavior in `commands.md` and `troubleshooting.md` were re-confirmed against `gh stack --help` output for extension v0.1.0. If the installed extension has moved to a newer version, re-run `gh stack --help` for any command before trusting a specific flag name. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/model.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/model.md new file mode 100644 index 000000000..d03596755 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/model.md @@ -0,0 +1,121 @@ +# The append-only stack model + +Read this once, before the first `gh stack` command in a repository. It defines +the vocabulary every recipe uses and the four rules everything else follows +from. The rules were derived over five production phases (2026-09-05 to +2026-09-23) of running 2 to 21 stacked PRs per stack; every "why" is an +incident, not a preference. + +## Vocabulary + +| Term | Meaning | +|------|---------| +| **trunk** | The branch the stack lands on. `develop`, an integration branch such as `integrate/phase-N`, or a release branch. Always named explicitly; never the repository default branch by assumption. | +| **layer** | One branch with one PR whose base is the layer below (the bottom layer's base is the trunk). | +| **bottom / top** | Bottom is closest to the trunk and merges first. Top is the landing head. `gh stack up` moves away from trunk, `down` toward it. | +| **writer** | The single agent allowed to push a layer. | +| **stack writer** | The single agent (lead, orchestrator) allowed to run any `gh stack` write command: `link`, `unstack`, `sync`, `rebase`, `merge`. | +| **frozen** | A layer whose task has closed. Nobody touches it again, ever. | +| **landing head** | The pushed head of the top layer; the only tree that reaches the trunk. | +| **stack number** | GitHub's identifier for the stack. `gh stack link` prints it and the PR page shows it in the stack panel; `gh stack view --json` does not. Record it after every link. If it was lost, `gh stack checkout ` from a worktree without stale tracking resolves the stack by PR number and imports it. Every unstack + re-link mints a new one. | + +## Rule 1 β€” The stack is append-only and linear + +Every unit of work (sprint, fix round, cleanup, docs, evidence) is a **new +worktree cut from the current pushed top**. Its PR opens on the first push with +base = the layer below and is linked into the stack at once. A layer is frozen +the moment its task closes; anything found on it later is fixed on a new layer +above the top. + +Consequences, all of them deliberate: + +- Every layer is immutable once it exists, so reviews and QA verdicts on it + stay true. +- The top contains everything by construction, so there is never a + merge-forward. +- Fixes are new layers that close findings in lower layers. +- Only the top runs the gating CI, and the stack merges once from the top. +- Who owns what is derivable from git instead of relayed by message. +- Nobody waits for a lower layer's QA or CI. A dev's next sprint starts on a + layer cut from their just-pushed head. + +*Why:* a phase that reintroduced serial waits (a sprint queued behind a fix +round, fix rounds on frozen layers, a layer cut from a stale head) lost an hour +per instance. One agent working this way moves through every sprint of a phase +back-to-back without stopping. + +**Strictly linear.** One parent, at most one child. Two layers cut from the +same head are a fork: `gh stack merge` refuses forks, and the sibling that is +not merged forward loses files upstack, shows red CI on stale merge refs, and +produces false conflict reports. Order siblings by dependency and chain them, +or run the independent one as its own stack on the trunk. + +## Rule 2 β€” One writer per branch, one stack writer + +The stack writer opens PRs, links, syncs, restacks and lands. A layer's writer +pushes commits to that layer and nothing else: never to another layer, never to +the trunk. Devs never run `gh stack` write commands. + +*Why:* devs running `gh stack` writes, cloned variable files, and a dev holding a +push "until the parent SHA is known" each cost hours in one phase. + +## Rule 3 β€” QA and CI gate the top only + +The landing head is the only tree that reaches the trunk, so its CI and its QA +verdict are the only gates. Red CI on a frozen layer is not fixed there. + +- QA is dispatched once, on the top, when the top is pushed. A QA already + running on a mid layer of a large stack may finish and its verdict carries + forward; nothing new is dispatched below the top. +- QA diffs the layer against the commit it was cut from (pinned SHA), not + against the moving GitHub base. The verdict is posted on the PR. +- A red check on a lower layer can be a base-branch defect fixed minutes later: + PR CI builds `head + base as it stood when the run started`. Read the job + log's "Merge into " line before dispatching anyone. +- Per-layer QA on a small stack only doubles the "qualitative best-practices" + findings. + +## Rule 4 β€” CI runs once, the merge happens once + +Every push to a PR head cancels and restarts its CI (30 to 60 minutes on a +large repository). The whole layering discipline exists to make CI run once per +landing, not once per tweak. + +- No cosmetic or metadata commit to a PR that is green or mid-run; put it in + the layer that lands next. +- Batch follow-ups into one validated push, not a drip. +- Never rebase or sync a branch whose CI is running unless it is the landing + layer and the sync is required to land. +- Never push to the trunk while a stack sync or merge is in flight; that + restarts CI on every layer (see `preconditions.md`, trunk freeze). +- Prefer local gates (format, lint, tests) over CI polling; at most one CI + watcher at a time, polling no faster than every 60 seconds. GitHub's + secondary rate limit is per user across every agent and trips on bursts. + +## The small-fix rule + +A change with one owner and bounded scope, well under a few hundred lines with +fix and tests together, is **one PR off the trunk with no stack**: local lint +and tests, one acceptance check, CI green, merge. No stack, no QA task, no +triage record. + +*Why:* a thirty-line fix became two layers, two QA rounds (the second produced +only a pre-existing "best-practices" finding), and a bottom-alone merge that +turned the trunk red. + +## Two shapes that work + +| Shape | Trunk | Use | +|-------|-------|-----| +| Phase stack | `integrate/phase-N` (or any integration branch) | Several sprints and fix rounds from several devs that must land as one unit. The phase closes with one PR trunk β†’ `develop`. Worked example: `phase-model-example.md`. | +| Develop stack | `develop` | Several open fix or feature PRs already targeting `develop` that must land together: chain them (one merge-forward commit per upper layer), link with `--base develop`, freeze everything below the top, QA and CI on the top, merge once. Evidence-only PRs may merge alone. `recipe-link.md` section B. | + +*Why (develop stack):* "do not wait 5 hours for CI to re-run after every PR"; +three independent PRs onto an integration branch cost three CI cycles and two +re-runs. One linear stack means the top's CI validates everything. + +## Related + +- `workflow.md` β€” the lifecycle of one layer, step by step. +- `preconditions.md` β€” the checks that stop each known failure. +- `stack-design.md` β€” how to cut layers so they stay independent. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/phase-model-example.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/phase-model-example.md new file mode 100644 index 000000000..fc0dac526 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/phase-model-example.md @@ -0,0 +1,123 @@ +# Worked example: a phase as one append-only stack + +Read this to see every rule applied end to end. The example is the model the +rules were learned in: a multi-sprint phase of a Rust workspace, one lead +agent as stack writer, several dev agents, a QA agent, an integration branch +as trunk. Names and numbers are real (phase-bc, 2026-09-20 to 2026-09-23). +Repository-specific tooling is named where it appears so you can substitute +your own. + +## Setup + +- Trunk: `integrate/phase-bc`, cut from `develop`. Sprint work never targets + `develop` or `main` directly; the phase closes with one PR + `integrate/phase-bc β†’ develop`. +- Roles: the lead owns every `gh stack` write and every PR open. Each sprint + has one dev with one worktree and one branch. A QA agent reviews the top + only. +- Local gates run before every accepted push (format, clippy with warnings as + errors, workspace tests, line-count and boundary lints). They are the CI, + locally; CI itself is a merge gate, never a dispatch gate. +- Merge commits only (repository rule). + +## The plan is cut by layer + +Sprints were cut on crate boundaries: `bc.1` storage, `bc.2` typed +observability (runtime), `bc.3` CLI, `bc.4` release tooling, `bc.6` docs. +Each sprint's file list was disjoint from every other's. The earlier phase +that used vertical slices (every crate in every sprint) was cancelled after +five hours with ten layers and three fix rounds per sprint. + +## Cutting and linking + +1. `bc.1` worktree cut from `origin/integrate/phase-bc`. First push within + minutes; the lead opened PR #1547 (base `integrate/phase-bc`, body with the + parent SHA and the fence). A stack needs two PRs, so the first + `gh stack link --base integrate/phase-bc 1547 ` ran the moment + the second layer's PR opened. +2. As soon as `bc.1`'s types and core paths were pushed ("could `bc.2` + compile against this head?"), the `bc.2` worktree was cut from + `origin/feature/bc1-...` and its dev deployed. `bc.1` continued its QA and + fix rounds on layers above. +3. Every QA finding became a fix layer at the top (`fix/bc2-review-N`), PR on + first push, appended with `gh stack link `. The stack reached + 20 PRs: 1547 β†’ … β†’ 1565 (sprints plus 13 review-fix layers), then + 1566 β†’ 1567 β†’ 1568 (`bc.4`). +4. The lead ran `/sc-gh-stack-view --trunk integrate/phase-bc` after every link + and before every dispatch; devs never ran a stack write. + +## Rebase at task start + +When a dev started a fix task on their sprint layer (a live layer with no +children, never a frozen one), the first step was: + +```bash +git fetch origin +git rebase --onto origin/ +git push --force-with-lease +``` + +The lead recorded the new parent SHA in the ledger. No other rebases happened +between tasks. Frozen intermediate layers were not rebased at all. + +## QA on the top, pinned + +The phase-end review was dispatched once, on PR #1568 at `5c53dff79` with all +checks green, with the pinned head and the base ref +(`git merge-base integrate/phase-bc 5c53dff79`) in the task variables. The +reviewer read every file with `git show 5c53dff79:`. Seventeen +per-layer background QA rounds run earlier were history, not the gate. + +The verdict was FAIL with seven blocking findings. Each was verified before +dispatch (one was pre-existing on `develop`, one was false), then fixed on new +layers above #1568: a docs layer by one dev, artifact layers by another. +Nothing below the top was touched. + +## Removing red layers whose fix lived above + +Four layers (#1555, #1556, #1566, #1567) were red for defects fixed on the +layers above them. Instead of waiting for the stack merge to serialise behind +them: + +```bash +gh stack unstack 1564 +gh stack link --base integrate/phase-bc # 17 PRs +gh pr close 1555 --comment "Carried by #1557; branch kept." # and 1556, 1566, 1567 +``` + +Result: stack #1564 became #1570, 17 layers, #1557 rebased by GitHub onto +`feature/bc2-typed-observability`, #1568 onto `fix/bc2-review-7`, all green in +one CI pass. Base changes did not restart CI on unchanged heads. Then the +stale-tracking cleanup in every other worktree, then the view tool. + +## Merge-forward as a head-of-queue task + +Where a sibling had to merge another forward, the instruction was sent as a +task with a stable id, re-assigned with the updated head as siblings landed +(`atm task assign --task-id MERGE- --head` in that repository's +team tooling): it cannot be missed or crossed, it is ordered in the task list, +and `--head` does not pre-empt the active task, so it runs right after the +layer task closes. + +## Landing + +Checklist from `recipe-land.md`: every lower layer frozen with its SHA in the +ledger; top gates green and QA PASS on the PR; FREEZE sent to the two trunk +writers (the lead and the QA agent, both of which push triage records to the +trunk) and acked; then `gh stack merge 1570 --yes --merge`. After landing: +view tool, check that no in-flight PR was closed, "landed" sent, freeze +lifted, phase PR `integrate/phase-bc β†’ develop` opened. Integration tests that +need the landed tree run on `develop`, not on the stack. + +## What it cost when a rule was skipped (same repository, earlier phases) + +| Skipped | Cost | +|---------|------| +| One event-log push to the trunk one minute after the final sync | Async-merge fallback, CI restarted on every layer, 40 to 60 minutes | +| Two layers cut from the same top with no declared order | Missing files upstack, false conflicts, an afternoon | +| A lower layer rewritten after children branched | `gh stack merge` refused; landed via fallback B | +| Bottom layer merged alone with its red fixed above | `develop` red; an unrelated PR held its push | +| Scoped merge without reading `branches[]` | An upper draft merged and its branch deleted | +| `gh stack link` without `--base` | Bottom PR retargeted to `main` and locked | +| Per-branch fix rounds and waiting on lower-layer QA | Two to three hours per sprint | +| Sprints as vertical slices | Phase cancelled at 10 layers | diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/preconditions.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/preconditions.md new file mode 100644 index 000000000..3ee5c5f73 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/preconditions.md @@ -0,0 +1,145 @@ +# Preconditions: the checks that stop each known failure + +Read this before any `gh stack` write command (`link`, `unstack`, `sync`, +`rebase`, `merge`) and before pushing to a trunk that has a stack above it. +Each entry is a check, the failure it prevents, and the recovery if the check +was skipped. Incident references are dates and PR numbers from the phases the +rules were learned in. + +## Before `gh stack link` + +**Check:** the command carries `--base ` and the full ordered list +bottom to top; it runs from a worktree checked out on a stack branch (never +from the trunk worktree, never with the trunk or the phase PR as a layer). +**Failure:** without `--base`, `link` retargets the bottom PR to the +repository default branch and GitHub then refuses `gh pr edit --base` on a +stacked PR (PR #1400, 2026-09-11; PR #1253 sat on `main` for 50 seconds). +**Recovery:** `gh stack unstack `, `gh pr edit --base `, +re-link with `--base`. Confirm with +`gh api repos/{owner}/{repo}/issues//events` filtered on `base_ref_changed`; +`/timeline` does not show it. + +**Check:** pass PR numbers, not branch names, when linking from the main +checkout. **Failure:** a branch name pushes the *local* ref, which may be +another agent's unpushed state. + +**Check:** every head is pushed and each layer contains its parent +(`gh_stack_chain_check.py`). **Failure:** a layer cut from a stale or unpushed +head shows phantom diffs and false conflicts. + +**Check:** no two layers share a parent without a declared merge-forward +order. **Failure:** two layers cut from the same top (2026-09-13, both from +#1453) meant missing files upstack, red CI on stale merge refs and false +conflict reports. **Recovery:** the declared sibling merges the other forward +with a plain merge commit, then re-link the linear order. + +**Check:** after linking, verify every base with the view tool (one GraphQL +query), not with per-PR `gh pr view` calls. + +## Before rewriting or rebasing a layer + +**Check:** the layer has no children, or every child will be rebased in the +same pass. **Failure:** rewriting a lower layer after children branched made +the stack non-linear; `gh stack merge` refused with "not a linear descendant" +(stack #1416, 2026-09-12). **Recovery:** `recipe-land.md`, fallback B. + +**Check:** the layer is not frozen and this is the start of a task on it by +its writer. **Failure:** rebasing frozen layers to "catch up" and per-push +rebases cascaded force-pushes under live agents. + +**Check:** the layer's CI is not running, unless it is the landing layer and +the sync is required to land. + +## Before pushing to the trunk + +**Check:** no stack sync or merge targeting this trunk is in flight; if one +is, an explicit FREEZE was sent to every trunk writer and acked, and this push +waits. **Failure:** one routine event-log push to the trunk one minute after +the final sync made the stack "out-of-date with its base branch", forced the +async-merge fallback and restarted 40 to 60 minutes of CI on every layer +(2026-09-06). **Rule:** anything that must reach the trunk becomes a layer +*before* the final sync, or waits behind the landing. Same for the phase PR +into `develop`: resolve `develop` drift before opening the merge window. + +**Check:** a direct push to a protected trunk is not blocked by a ruleset +(GH013 "required status checks are expected" is the ruleset, not a transient). +**Rule:** never edit rulesets or branch protection; report the state and the +exact change needed. The only working bypass is `gh pr merge --merge --admin` +by a bypass-listed account, and that is the user's call. + +## Before `gh stack merge` + +**Check:** read the full `branches[]` from `gh stack view --json` and confirm +nothing above the merge target would be swept in. **Failure:** a scoped +`gh stack merge ` merged and deleted an upper draft that held only a +merge-forward commit (#1386, 2026-09-10). **Recovery:** a fresh PR for the +remaining work; the old one cannot be restored. + +**Check:** the top's CI is green and its QA verdict is PASS; every lower layer +is frozen with its head SHA recorded; every lower head is an ancestor of the +top (`git merge-base --is-ancestor`). Lower-layer CI is not a gate. + +**Check:** no draft PR in the stack (`gh stack merge` refuses drafts; the view +tool flags them). + +**Check:** the trunk is frozen and acked (above). + +**Check:** the method is `--merge`. Never `--squash`; squash-merged layers +rewrite history for every child. + +## Before merging a bottom layer alone (collapse) + +**Check:** the layer is green **by itself**, not merely "its red is fixed on +the layer above". **Failure:** merging #1492 alone, whose known-red test was +fixed by #1493 above it, put the red on `develop`; an unrelated PR then failed +on it and had to hold its push (2026-09-13). **Rule:** remove the red layer +from the stack first so the fixing layer above carries its commits +(`recipe-restack.md`, section 2); required status checks block a red PR from +merging inside a stack merge anyway (phase-bc, 2026-09-23). If a red layer +ever reaches the trunk, treat the trunk as frozen until the fix lands: no +cuts, no merges. + +## Before trusting the view tool + +**Check:** the πŸ”„ icon or a NOT COHERENT verdict with `-` rows after an +unstack is stale per-worktree tracking, not a real problem. gh-stack tracking +lives per worktree; after an unstack or re-link, other worktrees still show +the old stack number and layer list, and the view tool keeps the *longest* +list it finds. **Recovery:** `recipe-stale-tracking.md`. + +## Before dispatching a fix for red CI + +**Check:** the job log's "Merge into " line. PR CI builds the +merge ref against the base **as it stood when the run started**; a red on a +lower layer may be a base-branch defect fixed minutes later (PR #1460: a lint +failed on a dependency the layer below had not yet allow-listed). The next +push re-runs it green; nobody is dispatched. + +**Check:** a lower layer is frozen. Red CI on a frozen layer is not fixed; the +top gates. + +## Before any fix dispatch + +**Check:** could this land as one more commit on a PR that will get CI anyway, +going to the same base, without blocking that PR's review? Default to that. +A separate PR costs a full CI cycle and a runner slot. + +**Check:** one line per finding stating the exact change and the files +allowed. "Fix these findings" with no ruling and no fence made devs re-edit +whole modules for one-line findings, and each QA sweep filed more. + +## Standing rules that need no check + +- Devs never run `gh stack` write commands, never open PRs, never push to + another layer or the trunk. +- Never hold a push "until the parent SHA is known"; push the WIP, record the + SHA afterwards. +- No cosmetic or metadata commit to a green or running PR head. +- At most one CI watcher, interval 60 seconds or more; one verification pass + right before merging, not one per status ping. On an HTTP 403 secondary + rate limit, stop all `gh` calls for at least 30 minutes. +- Read files at a pinned head with `git show :`; a worktree may be + checked out elsewhere and a verdict on a stale checkout is rejected. +- Refresh a rarely-touched local ref before branching from it + (`git fetch origin && git update-ref refs/heads/ origin/`); a + stale local `main` produced two worktrees on the wrong base in one session. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-cut-layer.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-cut-layer.md new file mode 100644 index 000000000..d35f228d8 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-cut-layer.md @@ -0,0 +1,73 @@ +# Recipe: cut a new layer from the top of the stack + +Use for every unit of work on a stack: a sprint, a fix round, a cleanup, a +docs or evidence layer. The layer is a new worktree cut from the current +**pushed** top. Nothing below the top is edited again. + +## Inputs + +- ``: the stack's trunk (for example `develop`, `integrate/phase-bc`). +- ``: the current top branch, from `/sc-gh-stack-view` (the last open + row). For the first layer of a new stack, `` is the trunk. +- ``: the new branch name. Follow the repository's naming policy; + names are used verbatim by gh-stack. + +## Steps + +1. Status: `/sc-gh-stack-view`. Note the top branch and its origin SHA. + +2. Verify the top is pushed and contains every lower layer: + + ```bash + git fetch origin + git rev-parse --verify origin/ + for lower in ; do + git merge-base --is-ancestor origin/$lower origin/ && echo "ok $lower" || echo "MISSING $lower" + done + ``` + + A `MISSING` line means the top is not the top; stop and fix the chain + first (`recipe-restack.md`). A fork is never cut from a head that does not + contain everything below it. + +3. Create the worktree from the pushed head, never from a local ref: + + ```bash + git worktree add --no-track ../-worktrees/ -b origin/ + ``` + + `--no-track` matters: without it the new branch's upstream is + `origin/`, and the first `git push` or a later + `push --force-with-lease` targets the parent branch. The first push is + `git push -u origin `. + + With `sc-git-worktree` 0.14.0 or later installed: + `/sc-git-worktree --create-stacked ` does exactly this + cut (from `origin/`, `--no-track`), records the parent SHA in tracking + and returns a `stack_handoff` split into writer and stack-writer parts. + Its plain `--create` branches from the local ref and is wrong for a layer. + +4. Record the parent SHA the layer was cut from + (`git rev-parse origin/`). It goes in the PR body and any ledger; QA + diffs against it, and the rebase-at-task-start uses it as + ``. + +5. Hand the worktree to its single writer. The writer pushes a WIP commit + within minutes; there is no reason to wait. + +6. On that first push: `recipe-link.md` (PR with base = ``, then link). + +## Two layers from the same head + +Sometimes two units of work are ready at once (a fix round and the next +sprint). Declare at cut time which one merges the other forward, and cut the +second only from the first's pushed head as soon as it exists. If both must +start now from the same head, the declared follower does one plain merge +commit (never a rebase, never a force-push) of the leader before linking, so +the chain is linear. Push the WIP first; the merge-forward is a later +appended commit. + +## Finish + +`/sc-gh-stack-view` after the link. Record the stack number if the link +printed a new one. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-land.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-land.md new file mode 100644 index 000000000..4fde1b5a6 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-land.md @@ -0,0 +1,117 @@ +# Recipe: land the stack + +Use when every layer below the top is frozen, the top's QA verdict is PASS and +the top's CI is green. One atomic merge lands the stack; two fallbacks cover +the cases GitHub refuses. Landing is irreversible: confirm with the user +unless the user directed it. + +## Landing checklist + +- [ ] Every layer below the top is frozen and recorded with its head SHA. +- [ ] Top layer: local gates green, QA PASS posted on the PR. +- [ ] Top CI green on the exact landing SHA. If the repository's CI runs only + for trunk-family base branches (stacked PRs get no check-runs), open a + plain CI-trigger PR `top-head β†’ trunk`, not linked into the stack, wait + for its run on the landing SHA, then close it; check-runs are per + commit, so the ruleset's required checks are satisfied. +- [ ] No draft PR in the stack; `branches[]` read in full; no empty upper + layer that would be swept in. +- [ ] `/sc-gh-stack-view`: COHERENT, LANDING βœ…. +- [ ] FREEZE sent to every trunk writer and acked. Anything for the trunk + became a layer before the final sync or waits. + +## Primary: one atomic stack merge + +```bash +gh stack merge --yes --merge # by stack number, from anywhere +``` + +Always by stack number: a worktree's local tracking can be stale +(`recipe-stale-tracking.md`) and would land the wrong list. + +All-or-nothing, bottom to top. Only open, non-draft state is checked; the +ruleset's required checks are satisfied because the top head carried CI +(skipped docs-only checks count as satisfied). Never `--squash`. If the base +uses a merge queue the stack is queued as a group and may land in separate +batches; the queue picks the method. + +Refusals and what they mean: + +| Message | Cause | Go to | +|---------|-------|-------| +| "stack is out-of-date with its base branch" | Someone pushed to the trunk after the stack's bases were computed | Freeze the trunk, then land through fallback B mechanics (unstack, retarget the top, merge the top). Never `gh stack sync`: it rebases and force-pushes every frozen layer and restarts CI everywhere | +| "PR #X's branch is not a linear descendant of PR #Y's branch" | A lower layer was rewritten after children branched | Fallback B | +| Draft PR listed | A layer is still a draft | `gh pr ready `, retry | + +## Fallback A: merge one stacked PR through the async API + +`gh pr merge` on a stacked PR is refused ("must be merged using the +asynchronous merge REST API") and the synchronous `POST pulls/N/merge` +returns 404. The working call: + +```bash +SHA=$(gh pr view --json headRefOid -q .headRefOid) # FULL 40 characters +gh api -X PUT repos/{owner}/{repo}/pulls//merge-async \ + -H "Accept: application/vnd.github+json" \ + -f merge_method=merge -f sha="$SHA" # returns a uuid +gh api repos/{owner}/{repo}/pulls//merge-async/ # poll until status == "merged" +``` + +An abbreviated SHA fails with the misleading "Pull request head branch was +modified". After the parent merges, GitHub retargets **and rebases** the child +branch within about 30 seconds (same tree, new committer dates). In the child +worktree: + +```bash +git fetch origin +git diff --stat HEAD origin/ # must be empty: identical trees +git reset --hard origin/ # ONLY if the diff was empty; otherwise stop and report +``` + +Never force-push the local child over GitHub's rebase. Do not use fallback A +to land a whole stack layer by layer: each merge rebases every remaining +frozen layer. + +## Fallback B: non-linear stack + +When a lower layer was rewritten after its children branched, do not fall back +to sequential fallback-A merges (each rebases frozen layers). Instead land the +top PR directly: + +```bash +gh stack unstack +gh pr edit --base +gh pr merge --merge # top head already carried CI on this SHA +``` + +The top must merge clean into the trunk (`git merge-tree --write-tree +origin/ origin/`, exit 0); if the trunk moved and it conflicts, +resolve on a new top layer, never on a frozen one. Verify every lower head is +an ancestor of the new trunk head; GitHub marks the lower PRs MERGED by +itself and the merge commit carries the whole history. Then +`recipe-stale-tracking.md`: the unstack left every worktree's tracking stale. + +```bash +git fetch origin +for b in ; do git merge-base --is-ancestor origin/$b origin/ && echo "landed $b" || echo "NOT LANDED $b"; done +``` + +## After landing + +1. `/sc-gh-stack-view`: all PRs MERGED, or the remaining open stack coherent + on the new trunk head. +2. Confirm no in-flight PR was closed as a side effect + (`gh pr list --state merged --limit 10` around the merge time, and the + specific PRs you know are in flight). +3. Send "landed"; lift the freeze; push any held trunk commits. +4. Reset local refs to origin in every layer worktree; never force-push a + landed branch. +5. For a phase stack: the phase PR `trunk β†’ develop` opens now; a + review-findings stack, if any, opens above the trunk. + +## Never + +- `--squash`. +- `gh pr merge --admin` to get past a ruleset; that is the user's decision + and the user's account. +- A push to the trunk between the final sync and the landed confirmation. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-link.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-link.md new file mode 100644 index 000000000..fe34d7c2c --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-link.md @@ -0,0 +1,100 @@ +# Recipe: open the PR and link it into the stack + +Use on the first push of every layer, and whenever several open PRs on one +trunk depend on each other. Stacking happens when the PR opens, never "once it +is green". One link command, the full ordered list, `--base` every time. + +## A. First push of a new layer + +1. Open the PR with base = the parent layer (the trunk for the bottom layer). + The body records the parent SHA the layer was cut from, the task or sprint + id, and the file fence: + + ```bash + gh pr create --base --head --title "" --body "$(cat <<'B' + Parent: <parent> @ <parent-sha> + Task: <id> + Fence: <paths this layer may touch> + B + )" + ``` + + Drafts block `gh stack merge`; open as ready, or mark ready before landing. + +2. Pre-link check from the main checkout (read-only): + + ```bash + python3 .claude/scripts/gh_stack_chain_check.py --trunk <trunk> <bottom> ... <layer> + ``` + + Exit 0 means every head is pushed, each layer contains its parent, PR bases + match the chain and the top merges clean into the trunk. Fix anything it + lists before linking. + +3. Link. The canonical form, used for the first link, after any unstack, and + whenever in doubt, is the full ordered list with `--base`: + + ```bash + gh stack link --base <trunk> <bottom-pr#> ... <top-pr#> + ``` + + Idempotent: re-run with the whole list whenever a PR is added. Sets every + base, pushes branches that are not pushed, creates PRs for branches without + one. Confirmed working for 2 to 21 PRs. The chain check prints this + command with the numbers filled in. + + Accepted shortcut when appending exactly one PR to an existing stack: + + ```bash + gh stack link <stack#> <pr#> + ``` + + It appends on top and retargets that PR onto the current top; it cannot + insert (`recipe-restack.md`), and `--base` is ignored by it. + + Run from a worktree checked out on a stack branch. Pass PR numbers, not + branch names, when running from the main checkout: a branch name pushes + the *local* ref, which may be another writer's unpushed state. + +4. Verify: `/sc-gh-stack-view`. Every base must equal its parent's head + (`βœ…` in the rebase column, VERDICT COHERENT). Record the stack number the + link printed. + +## B. Several open PRs on one trunk that must land together + +When two or more open PRs against the same trunk depend on each other (a fix +another PR's CI needs, docs describing code in a sibling, evidence for a fix), +or should land in one CI cycle instead of three: + +1. Order them by dependency, cleanest-first at the bottom: the PR the others + need lowest, docs and likely-PASS layers low, code with open findings + above. +2. Make the chain linear before linking. For each layer above the bottom, its + writer makes **one plain merge-forward commit** of the layer below + (`git merge --no-ff origin/<lower>`; never a rebase, never a force-push), + pushes, and that layer is then frozen unless it is the top. Independent + PRs are never left as a fork: `gh stack merge` refuses a stack whose layers + are not linear descendants, and the chain check fails it. +3. `python3 .claude/scripts/gh_stack_chain_check.py --trunk <trunk> <bottom-pr#> ... <top-pr#>` + until it prints LINKABLE. +4. `gh stack link --base <trunk> <bottom-pr#> ... <top-pr#>` from a layer + worktree. +5. From here the stack follows the model: nothing below the top is edited + again, QA runs once on the top (a QA already in flight on a lower PR may + finish and its verdict carries forward), CI gates the top, and the stack + lands once. Tell every writer that bases changed and that only the top + moves. +6. `/sc-gh-stack-view`; record the stack number. + +## Never + +- `gh stack link` without `--base` when creating or re-creating a stack. +- The trunk, the trunk worktree, or the phase PR as a layer. +- Holding a PR out of the stack until CI is green. +- A mid-stack insert via `link` (it fails with HTTP 422 then "new PRs must be + added to the top"): use `recipe-restack.md`. + +## Finish + +`/sc-gh-stack-view`, pasted verbatim, and the stack number recorded wherever +the team tracks it (ledger, PR bodies). diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-restack.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-restack.md new file mode 100644 index 000000000..93766443a --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-restack.md @@ -0,0 +1,99 @@ +# Recipe: restack (insert, remove a red layer, collapse the bottom) + +Three operations share one mechanism: `gh stack unstack <n>` (PRs and branches +untouched), fix PR bases with `gh pr edit --base`, then one full +`gh stack link --base <trunk> <ordered list>`. Every cycle mints a **new stack +number**; update any ledger or PR text that names it, clear stale tracking in +other worktrees, and finish with `/sc-gh-stack-view`. Merged PRs drop out of +the new stack by themselves. + +Unstacking and closing PRs are outward-facing: confirm with the user unless +the user directed the exact operation. + +## 1. Insert a layer mid-stack + +`gh stack link` cannot insert; with the new branch in the middle it fails with +`HTTP 422 PullRequest.base is invalid` and then "new PRs must be added to the +top of the existing stack". Confirmed three times. + +The layer above the insertion point must already contain the new branch's +head (its writer merges it forward with one merge commit first), or the +result is non-linear and cannot land. Then: + +```bash +gh stack unstack <n> # PRs untouched +gh pr edit <pr-above> --base <new-branch> # direct edit succeeds once unstacked +python3 .claude/scripts/gh_stack_chain_check.py --trunk <trunk> <bottom> ... <new> <above> ... <top> # must print LINKABLE +gh stack link --base <trunk> <bottom-pr#> ... <new-pr#> <pr-above#> ... <top-pr#> +``` + +If the worktree reports "Checkout an existing stack using gh stack checkout" +before the link, run `gh stack checkout <old stack#>` there first. Then +`recipe-stale-tracking.md`, then `/sc-gh-stack-view`. + +## 2. Remove a red layer whose fix lives above + +The stack cannot merge with a failing layer, and frozen layers are never fixed +in place. Do this the moment a layer goes red with its fix on a layer above, +so CI runs on every remaining layer in parallel instead of serialising behind +the merge. + +```bash +gh stack unstack <n> +gh stack link --base <trunk> <every GREEN pr#, bottom to top> # reduced list drops the red layer +gh pr close <red-pr#> --comment "Carried by #<pr-above>; branch kept." +``` + +Why this works: `link` never removes PRs, so the reduced list is the only way +to drop a layer. It retargets the green layer above onto the red layer's +parent, so the red layer's commits ride in the green PR's diff. The branch is +kept; nothing is deleted. Base changes do not restart CI on unchanged heads. + +Field result (phase-bc, 2026-09-23): four red PRs closed, absorbed by the two +green layers above them; stack #1564 became #1570 with 17 layers, coherent, +all green in one pass. + +Then `recipe-stale-tracking.md`, then `/sc-gh-stack-view`. + +## 3. Collapse the bottom + +When a contiguous bottom run is QA PASS and **green by itself**, merge it and +keep the open stack 2 to 3 layers deep. A 19-deep stack landed as one merge +after ten hours instead of six to eight small merges over the day, and its +`needsRebase` and stale-tracking noise made the view tool's verdict stop +matching reality. + +1. `/sc-gh-stack-view`: LANDING green, bottom layers QA PASS, no open + findings on them. +2. Read `branches[]` from `gh stack view --json`; confirm the layer above the + run has unique commits (an empty draft would be swept in and deleted). +3. If the bottom's own CI is red for a reason fixed above it, do **not** merge + it: remove the red layer first (section 2) so the fixing layer carries its + commits, then collapse. Merging #1492 alone put its red on `develop`. +4. Freeze the trunk (`preconditions.md`), then: + + ```bash + gh stack merge <highest-passing-pr#> --yes --merge + ``` + + Merges everything up to and including that PR, bottom to top, atomically. +5. GitHub retargets the next layer onto the trunk within about 30 seconds + and may rebase its branch (same tree, new committer dates). In that + layer's worktree: + + ```bash + git fetch origin + git diff --stat HEAD origin/<child> # must be empty: identical trees + git reset --hard origin/<child> # ONLY if the diff was empty; otherwise stop and report + ``` + + Never force-push the local branch over GitHub's rebase. +6. `/sc-gh-stack-view`. The next layer's `base` may now read as "behind + trunk", which is a note, not a rebase order. Lift the freeze; the next unit + of work is cut from the new trunk head. + +## Finish + +Every branch of this recipe ends with `recipe-stale-tracking.md` where a +stack number changed, `/sc-gh-stack-view` pasted verbatim, and the new stack +number recorded. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-stale-tracking.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-stale-tracking.md new file mode 100644 index 000000000..4f14241f6 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-stale-tracking.md @@ -0,0 +1,59 @@ +# Recipe: clear stale local stack tracking + +Use when `/sc-gh-stack-view` shows πŸ”„ on a layer, reports NOT COHERENT with +`-` rows, or names a stack number that no longer exists on GitHub; and after +every unstack or re-link as a matter of course. + +## Why it happens + +gh-stack tracking is stored **per worktree**. After `gh stack unstack` and a +re-link, the worktree that ran them knows the new stack; every other worktree +still holds the old stack number and the old layer list. The view tool +discovers stacks through `git worktree list`, runs `gh stack view --json` in +each, and keeps the *longest* view of a stack (a lower-layer worktree only +sees the layers linked from it). A stale worktree therefore wins with its +longer, obsolete list and the report shows a false NOT COHERENT. + +## Steps + +1. Find the worktrees that carry tracking: + + ```bash + git worktree list --porcelain | sed -n 's/^worktree //p' | while IFS= read -r wt; do + out=$(cd "$wt" && gh stack view --json 2>/dev/null) || continue + printf '%s: ' "$wt" + printf '%s' "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["trunk"], "->", [b["name"] for b in d["branches"]])' + done + ``` + + `/sc-gh-stack-view --all --json` shows the same information as + `stacks[].worktree` and `stacks[].rows[]`. + +2. In every worktree whose list is not the current stack: + + ```bash + cd <stale-worktree> && gh stack unstack --local # local only, GitHub untouched + ``` + +3. In one worktree that is checked out on a branch of the current stack: + + ```bash + gh stack checkout <new-stack#> # imports tracking; "Already on <branch>" is fine + gh stack checkout <any-pr#-in-the-stack> # same effect when the stack number was not recorded + ``` + + If the local and remote compositions differ, `checkout` opens an + interactive prompt that cannot be bypassed; that is exactly why step 2 runs + first. + +4. `/sc-gh-stack-view`. The verdict now reflects GitHub, not a dead list. + +## Notes + +- `gh stack unstack --local` never contacts GitHub and never touches PRs or + branches. +- A worktree that prints "Checkout an existing stack using gh stack checkout" + has no tracking; run `gh stack checkout <stack#>` there before any write + command from it. +- Do not run `gh stack sync` or `gh stack rebase` from a stale worktree: they + rewrite and force-push every layer over someone else's push. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/stack-design.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/stack-design.md new file mode 100644 index 000000000..67b32f7cf --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/stack-design.md @@ -0,0 +1,66 @@ +# Designing a stack + +Read this before cutting the first layer of a stack. It covers how to decide what goes in each layer so the stack does not need restructuring later (there is no non-interactive in-place reorder). In this model each layer is its own worktree cut from the pushed top (`recipe-cut-layer.md`); where the upstream text below says `gh stack add`, read "cut the next layer". + +## Dependency chain + +Stacked branches form a dependency chain: each branch builds on the one below it. Foundational changes (models, APIs, shared utilities) belong in lower branches; dependent changes (UI, consumers) belong in higher branches. If code in one layer depends on code in another, the dependency must live in the same branch or a lower one. + +## Plan layers before code + +Decide the layers first, then write into them: + +``` +main (trunk) + └── data-models ← shared types, database schema + └── api-endpoints ← API routes that use the models + └── frontend-ui ← UI components that call the APIs + └── integration ← tests exercising the full stack +``` + +This is illustrative β€” infer the actual topic and layer names from the task at hand, never reuse generic names literally. The failure mode to avoid is writing everything on one branch and trying to split it afterward; if a task is large enough to warrant a stack, create the stack at the start. + +## Branch naming + +Names are used exactly as given to `init`/`add` β€” nothing is prepended or transformed, and slashes are kept as part of the name (`gh stack add refactor/foo` creates a branch literally named `refactor/foo`). Prefer a shared topic prefix plus the layer's concern, e.g. `billing/schema`, `billing/api`, `billing/ui` β€” this keeps related branches recognizable without generic names that could belong to any stack. User and repository branch-naming conventions take precedence over this default; follow them instead. If `-m` is passed to `add` without a branch name, the name is auto-generated from the commit message in date+slug form (e.g. `03-24-add_api_routes`) β€” prefer naming the branch yourself. + +## Staging changes deliberately + +Use `git add`/`git commit` directly rather than `add -Am` as the default, to control exactly which changes land in which branch: + +```bash +git add internal/models/user.go internal/models/session.go +git commit -m "Add user and session models" + +gh stack add api-routes +git add internal/api/routes.go internal/api/handlers.go +git commit -m "Add user API routes" +``` + +Multiple commits per branch are fine β€” what matters is that every commit in a branch serves the same concern, and a change belonging to a different concern goes in a different branch. Note that `add <branch>` without `-Am` never touches the working tree, so uncommitted changes carry onto the new branch; commit or stash first if you want a clean start. + +## When to create a new branch + +Cut a new layer (`recipe-cut-layer.md`; upstream: `gh stack add`) when starting a different concern that depends on what's already built. Signals: + +- Switching from backend to frontend work +- Moving from core logic to tests or documentation +- The next changes have a different reviewer audience +- The current branch's PR is already large enough to review on its own + +A layer that can't be described in one sentence is usually two layers. + +## One stack, one story + +Think of a stack from the reviewer's perspective: it should tell a cohesive story about a feature, and a reviewer should be able to read the PRs in sequence and understand the progression. + +**Use a single stack** when every branch serves the same feature or project, even if it spans multiple concerns (models, API, frontend). + +**Use a separate stack** for work that's unrelated to the current effort β€” a different feature, an unrelated bug fix, an independent refactor. Don't mix unrelated work into one stack just because you happen to be touching both. Start a separate stack (its own first layer cut from the trunk, its own `gh stack link --base`) for each distinct effort. A trivial incidental fix (e.g. a typo you noticed) can ride along in the current stack; once it grows into its own project, it deserves its own stack. + +## Field-verified + +- **Cut layers on crate/module boundaries** β€” e.g. storage β†’ runtime β†’ CLI β†’ docs β€” not on vertical slices that touch every module in every layer. A vertical-slice cut is what makes every layer conflict with its neighbor on rebase and forces constant merge-forwards; a module-boundary cut keeps each layer's diff isolated to the files that layer owns. +- **A re-export facade lands on the layer of its first consumer**, not on the frozen-types layer below it. Putting a facade on the types layer just because it re-exports types creates a false dependency and drags unrelated consumer churn into that PR. +- **Treat a blown budget as a signal to stop and re-cut, not push through.** A stack planned at N layers that reaches 2N with nothing merged, or any single layer that needs a third fix round, has failed its plan β€” stop appending new layers and re-cut the plan by layer (`recipe-restack.md` for the mechanics of dropping or reordering layers) instead of continuing to pile on. +- **Small fixes don't need a stack.** One owner, well under a few hundred lines, fix and its tests together β€” that's one ordinary PR off trunk, no stack at all. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/troubleshooting.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/troubleshooting.md new file mode 100644 index 000000000..25d351cd3 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/troubleshooting.md @@ -0,0 +1,111 @@ +# Troubleshooting gh stack + +Read this when a `gh stack` command fails, exits non-zero, or produces a confusing message β€” it maps error signatures to root cause and the concrete recovery steps, including failure modes only confirmed by hands-on use, not by `--help`. + +**Stack-writer only.** `unstack`, `sync`, `rebase`, `submit` and `merge` rewrite +or land shared state: only the stack writer runs them, `merge`, `unstack` and +`gh pr close` are confirmed with the user unless the user directed them +(`SKILL.md` precondition 10), and a layer that has children is never +rewritten. Where a recovery below differs from a recipe, the recipe wins. + +## Handle rebase conflicts (exit 3) + +```bash +gh stack rebase +# exit 3 β€” conflicted paths listed on stderr +# Read the files, resolve the <<<<<<< / ======= / >>>>>>> markers +git add path/to/resolved-file +gh stack rebase --continue # repeat if the next branch also conflicts +# or, to bail out entirely: +gh stack rebase --abort # restores every branch in the stack +``` + +`sync` restores all branches to their pre-rebase state before exiting 3, so a failed `sync` never leaves anything half-applied; a failed plain `rebase` stops mid-flight and waits for `--continue`/`--abort`. Because `init` enables `git rerere`, a conflict resolved once is replayed automatically the next time it recurs β€” common, since a low change gets rebased through every branch above it. + +## Squash-merge recovery + +**Not applicable in this model** (merge commits only, never `--squash`); kept for repositories that squash. A squash-merged PR's original commits no longer exist in trunk history. `gh stack sync` detects this and rebases with `git rebase --onto` to replay the remaining commits correctly, skipping the merged branch: + +```bash +gh stack sync +gh stack view --json # merged branch: "isMerged": true, "state": "MERGED" +``` + +If the replay conflicts, `sync` restores everything and exits 3 β€” resolve with `gh stack rebase` / `--continue` as above. + +## Local and remote stacks diverged + +Divergence: the local stack and the GitHub stack changed differently (e.g. a branch added locally while a PR was added to the stack on github.com). **Non-interactively, `sync` prints `β„Ή Sync aborted`, changes nothing, and exits 0.** Exit 0 here is NOT success β€” always check stderr for "Sync aborted" or diff `gh stack view --json` before/after to confirm sync actually happened. + +Resolution in this model: the GitHub stack is the truth, local tracking is +disposable. In every worktree whose tracking is stale run +`gh stack unstack --local` (GitHub untouched), then `gh stack checkout <stack#>` +in one worktree on a stack branch: `recipe-stale-tracking.md`. Never resolve a +divergence with `gh stack submit --auto`; it force-pushes every local branch, +which may be another writer's unpushed state. + +Remote unstacking leaves queued or auto-merge-enabled PRs stacked; clear that state first if you need a clean unstack. + +## Restructure a stack + +No non-interactive reorder, rename or removal exists, and `gh stack link` +cannot insert mid-stack. The mechanism for every restructure (insert a layer, +drop a red layer, collapse the bottom) is `gh stack unstack <n>` (PRs and +branches untouched), `gh pr edit <pr> --base <intended parent>` where a base +must change, then one full `gh stack link --base <trunk> <bottom-pr#> ... +<top-pr#>`: `recipe-restack.md`. Do not use `gh stack init` + `gh stack submit +--auto` to rebuild: `init` checks out the last branch in the current worktree +(wrong under one-worktree-per-layer) and `submit` force-pushes local refs. +Ancestry is fixed with git first, and only on a layer that has no children; +metadata never changes ancestry. + +## Branch belongs to several stacks (exit 6) + +The current branch can't identify a single stack (typically it's the trunk of more than one). No flag disambiguates: + +```bash +gh stack checkout <a-branch-unique-to-the-intended-stack> +``` + +Commands taking an explicit stack number (`merge 7`, `unstack 7`) sidestep this since they don't infer from the current branch. + +## Stack file locked (exit 8) + +Another `gh stack` process holds `.git/gh-stack.lock`. The lock times out after ~5 seconds β€” wait and retry. A persistent exit 8 means another process still holds it; find and stop it. + +## Interrupted modify (exit 10) + +`gh stack modify` is TUI-only and this skill never invokes it. If a repo is left in this state by someone else: + +```bash +gh stack modify --abort +``` + +`submit` also detects a pending modify state and, under a TTY, asks before overwriting the GitHub stack with local state. + +## Stacked PRs unavailable (exit 9) + +The repository doesn't have stacked PRs enabled. This can't be fixed from the CLI β€” a repo admin must enable it on GitHub. Stop and tell the user. + +## `checkout` conflict prompt + +`gh stack checkout <pr-number>` when a different local stack already exists over those branches triggers an unbypassable interactive conflict-resolution prompt. Avoid it: run `gh stack unstack --local` first (keeps the GitHub stack intact), then retry the checkout. + +## Field-verified failure signatures + +| Observed message | Cause | Fix | +|---|---|---| +| `stack is out-of-date with its base branch` | Someone pushed to trunk after the final sync | Freeze trunk pushes during merge, use the merge-async fallback, or re-sync (`gh stack sync`) and retry | +| `PR #X's branch is not a linear descendant of PR #Y's branch` | A lower layer was rewritten (rebased/force-pushed) after children branched from it | `gh stack unstack`, `gh pr edit <top-PR> --base <trunk>`, merge the top PR directly β€” GitHub marks the lower PRs MERGED by ancestry | +| `HTTP 422 PullRequest.base is invalid` / `new PRs must be added to the top of the existing stack` | Tried a mid-stack insert via `gh stack link` | `unstack` + `gh pr edit <PR> --base <new-base>` + full re-link with the complete branch list | +| `Pull request head branch was modified` (on a `merge-async` call) | Passed an abbreviated SHA instead of the full 40-char `headRefOid` | Re-fetch `headRefOid` from `gh stack view --json` / `gh pr view --json headRefOid` and pass it in full | +| `...part of a stack and must be merged using the asynchronous merge REST API` | Ran `gh pr merge` on a stacked PR | Use `gh stack merge`, or the `merge-async` API fallback directly | +| False `NOT COHERENT` / `-` rows from the view tool | Stale per-worktree gh-stack tracking after an unstack/re-link done in a different worktree | `gh stack unstack --local` in the stale worktree, then `gh stack checkout <new-stack#>` | +| `Checkout an existing stack using gh stack checkout` | Worktree lost tracking after an unstack elsewhere | `gh stack checkout <stack#>` β€” "Already on `<branch>`" in response is fine, not an error | +| `GH013: Repository rule violations... required status checks are expected` | Direct push to a protected trunk hits a ruleset | Not transient β€” go through a PR, or use a bypass-listed account; never edit the ruleset yourself | +| Legacy `git merge-tree <base> <a> <b>` prints diff3-style hunks | 3-arg `merge-tree` always prints combined diff output β€” it is not a conflict signal by itself | Use `git merge-tree --write-tree <a> <b>` and check its exit code, or do a real `git merge --no-commit` in a scratch worktree | +| CI log on a lower layer reads `Merge <head> into <base>` and is red | CI ran against the base as it stood at run start; a base-branch defect fixed afterward will show green on the next push | Read that log line before escalating β€” it's often stale, not a real regression | + +## Multi-worktree note + +gh-stack's local tracking is per worktree. After any `unstack`/re-link done in one worktree, treat tracking in every other worktree as stale: run `gh stack unstack --local` there, then `gh stack checkout <new-stack#>` in exactly one worktree that is on a stack branch. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/workflow.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/workflow.md new file mode 100644 index 000000000..d4b35dc41 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/workflow.md @@ -0,0 +1,125 @@ +# Lifecycle of a stack, step by step + +Read this when you are about to start, extend, verify or QA a stack. Each step +names the recipe that carries the exact commands. The steps are what worked +across five production phases; the checks inside them are what stopped the +failures listed in `preconditions.md`. + +## 0. Plan the layers before any code + +Cut layers on module or crate boundaries (storage β†’ runtime β†’ CLI β†’ docs), one +concern per layer, so no two layers touch the same files. Vertical slices that +touch every module in every layer make every layer conflict with its neighbour +and turn the phase into merge-forwards. Details and the stop rule: +`stack-design.md`. + +## 1. Cut the layer from the current pushed top + +New worktree, branch from `origin/<current top>`, after verifying the top +contains every lower layer's head. Never from a local ref, never from a head +that is still unpushed. Recipe: `recipe-cut-layer.md`. + +Trigger for the *next* sprint: "could the next sprint compile against this +head?" As soon as a sprint's types, schema and core paths are pushed and only +test, lint or QA rounds remain, cut the next layer and start its dev. Waiting +for QA PASS, CI green or the merge left two devs idle for an hour. + +## 2. PR on the first push, link immediately + +The dev pushes a WIP commit within minutes. The stack writer opens the PR +(base = parent branch; body records the parent SHA, the task id and the file +fence) and links it. Stacking is part of opening the PR, not of merging it. A +branch without a PR has no CI and cannot be judged; a PR held out of the stack +"until it is green" hides the state it was meant to show. Recipe: +`recipe-link.md`. + +## 3. Verify with git before every link + +The stack writer runs, for the proposed order bottom to top: + +```bash +git fetch origin +git log --format='%h %p' -1 origin/<layer> # parents +git merge-base --is-ancestor origin/<parent> origin/<layer> && echo contains-parent +git diff --stat origin/<parent> origin/<layer> # file fence check +git merge-tree --write-tree origin/<trunk> origin/<top> # exit 0 = merges clean +``` + +`gh_stack_chain_check.py --trunk <trunk> <bottom> ... <top>` runs exactly these +checks and reports PR bases too. Mergeability is the `--write-tree` exit code +or a real `git merge --no-commit` in a scratch worktree, never the legacy +3-arg `merge-tree` (it printed a false conflict on a clean merge and the report +had to be retracted). + +## 4. Rebase at task start, once, by the writer + +A layer moves exactly once per task, at the start, by its single writer, and +only if it is live and has no children (`preconditions.md`, "Before rewriting +or rebasing a layer"): + +```bash +git fetch origin +git rebase --onto origin/<parent> <recorded-parent-base> <layer> +git push --force-with-lease +``` + +Record the new parent SHA (ledger, PR body). Between tasks layers do not move. +Never per-push rebases (they cascade force-pushes under live agents), never a +rebase of a frozen layer to catch up with the trunk, never a rebase of a layer +whose CI could go green just to catch up. Frozen intermediate layers are +rebased by the stack writer in one pass, only when the layer below them +freezes and only if the landing needs it. + +## 5. Freeze means freeze + +When a layer's minimum functionality is complete and its task closes, it is +frozen. Red CI on it is not fixed there. A finding on it is a new layer above +the top. If a lower layer *must* be rewritten, every child is rebased in the +same pass before anyone branches again; otherwise the children hold stale +copies, every downstream diff shows phantom regressions, and the stack can no +longer land linearly (`recipe-land.md`, fallback B). + +## 6. Fix rounds bundle every finding for one owner on one layer + +Non-blocking findings from every layer are collected and fixed once, on a new +top layer, with one QA pass there. New findings that arrive mid-round are +appended to the same task when the file fence covers them. Before dispatching +a fix, write one line per finding stating the exact change and the files +allowed; a fix layer that touches an unlisted file is rejected. Never +per-branch fix rounds; never wait for a lower layer's QA or CI before the top +moves. + +## 7. QA at a pinned SHA, on the top + +Dispatch QA once when the top is pushed. Pin the review head; QA reads code +with `git show <sha>:<path>`, never from a worktree that may move. The diff +under review is `layer head` versus the commit it was cut from +(`git merge-base <base> <head>`, or the second parent of the last merge-forward +commit), not versus the moving remote base; otherwise a rewritten lower layer's +delta is misattributed as this layer's regression. Post the verdict on the PR. + +## 8. Status after every write + +`/sc-gh-stack-view` after every link, merge, unstack and before any dispatch. +It shows base coherence (base == parent head), `needsRebase`, +`mergeStateStatus`, CI, stale local tracking and a LANDING line. If the +LANDING line is green and a contiguous bottom run is QA PASS and green by +itself, collapse it (`recipe-restack.md`) and keep the open stack 2 to 3 deep. + +## 9. Land once, from the top + +All layers frozen, QA PASS on the top, top CI green, trunk frozen and acked: +one `gh stack merge --yes --merge`. Then `/sc-gh-stack-view`, confirm no +in-flight PR was closed as a side effect, lift the freeze. Recipe: +`recipe-land.md`. + +## Roles at a glance + +| Action | Who | +|--------|-----| +| Push commits to a layer | That layer's writer only | +| Open PR, `gh stack link` / `unstack` / `sync` / `rebase` / `merge` | Stack writer only | +| Rebase a layer at task start | That layer's writer | +| Rebase frozen intermediate layers | Stack writer, one pass | +| Push to trunk | Nobody while a stack sync or merge is in flight | +| Edit repository rulesets or branch protection | Never the agent; report the exact change needed | diff --git a/packages/sc-gh-stack/tests/test_gh_stack_chain_check.py b/packages/sc-gh-stack/tests/test_gh_stack_chain_check.py new file mode 100644 index 000000000..ca4c9d278 --- /dev/null +++ b/packages/sc-gh-stack/tests/test_gh_stack_chain_check.py @@ -0,0 +1,301 @@ +"""Unit tests for gh_stack_chain_check.evaluate over injected git/gh lookups.""" +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +import unittest +from unittest import mock + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "gh_stack_chain_check.py" +spec = importlib.util.spec_from_file_location("gh_stack_chain_check_under_test", SCRIPT) +assert spec is not None and spec.loader is not None +gcc = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = gcc +spec.loader.exec_module(gcc) + +TRUNK = "develop" +T0, A1, B2, C3, OLD = ("0" * 40, "a" * 40, "b" * 40, "c" * 40, "d" * 40) + + +def pr(number: int, head: str, base: str, oid: str, *, draft: bool = False, state: str = "OPEN") -> dict: + return {"number": number, "headRefName": head, "baseRefName": base, "headRefOid": oid, + "isDraft": draft, "state": state} + + +class EvaluateTests(unittest.TestCase): + def setUp(self) -> None: + self.origins = {TRUNK: T0, "l1": A1, "l2": B2, "l3": C3} + # linear chain: T0 < A1 < B2 < C3 + self.ancestors = {(T0, A1), (T0, B2), (T0, C3), (A1, B2), (A1, C3), (B2, C3)} + self.merge = True + for p in ( + mock.patch.object(gcc, "origin_sha", side_effect=lambda ref: self.origins.get(ref)), + mock.patch.object(gcc, "is_ancestor", side_effect=lambda a, b: a == b or (a, b) in self.ancestors), + mock.patch.object(gcc, "merge_clean", side_effect=lambda base, head: self.merge), + ): + p.start() + self.addCleanup(p.stop) + self.prs = {"l1": pr(1, "l1", TRUNK, A1), "l2": pr(2, "l2", "l1", B2), "l3": pr(3, "l3", "l2", C3)} + + def test_linear_pushed_chain_is_linkable(self) -> None: + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertTrue(report["linkable"], report["problems"]) + self.assertEqual(report["notes"], []) + self.assertTrue(report["landing"]["clean"]) + text = gcc.render(report) + self.assertIn("next: gh stack link --base develop 1 2 3", text) + self.assertNotIn("#1", text.split("next:")[1], "a `#` would start a shell comment") + self.assertEqual(report["link_command"], "gh stack link --base develop 1 2 3") + + def test_unpushed_layer_is_a_problem(self) -> None: + self.origins.pop("l2") + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertFalse(report["linkable"]) + self.assertTrue(any("l2: not on origin" in p for p in report["problems"])) + self.assertIsNone(report["landing"]["clean"], "landing not judged while a head is unpushed") + + def test_fork_layer_does_not_contain_parent(self) -> None: + # l3 was cut from l1, not from l2: (B2, C3) missing + self.ancestors.discard((B2, C3)) + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertFalse(report["linkable"]) + self.assertTrue(any("l3: does not contain parent l2" in p for p in report["problems"])) + + def test_bottom_behind_trunk_is_a_note_not_a_problem(self) -> None: + # trunk moved past the bottom's base; bottom still has its own commits + self.ancestors.discard((T0, A1)) + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertTrue(report["linkable"], report["problems"]) + self.assertTrue(any("l1: behind develop" in n for n in report["notes"])) + + def test_bottom_already_in_trunk_is_a_problem(self) -> None: + self.ancestors.discard((T0, A1)) + self.ancestors.add((A1, T0)) + report = gcc.evaluate(TRUNK, ["l1", "l2"], self.prs, fetched=True, use_pr=True) + self.assertTrue(any("no commits beyond develop" in p for p in report["problems"])) + + def test_wrong_pr_base_is_a_note_link_corrects_it(self) -> None: + self.prs["l2"] = pr(2, "l2", TRUNK, B2) # should be l1 + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertTrue(report["linkable"]) + self.assertTrue(any("PR #2 base is develop, expected l1" in n for n in report["notes"])) + self.assertFalse(report["rows"][1]["pr_base_ok"]) + + def test_draft_closed_and_stale_pr_heads_are_problems(self) -> None: + self.prs["l1"] = pr(1, "l1", TRUNK, A1, draft=True) + self.prs["l2"] = pr(2, "l2", "l1", B2, state="MERGED") + self.prs["l3"] = pr(3, "l3", "l2", OLD) + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + joined = "\n".join(report["problems"]) + self.assertIn("PR #1 is DRAFT", joined) + self.assertIn("PR #2 is MERGED", joined) + self.assertIn("PR #3 head ddddddddd != origin ccccccccc", joined) + + def test_missing_pr_is_a_note(self) -> None: + self.prs.pop("l3") + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertTrue(report["linkable"]) + self.assertTrue(any("l3: no PR yet" in n and "base l2" in n for n in report["notes"])) + text = gcc.render(report) + self.assertIn("next: gh stack link --base develop 1 2 l3", text) + self.assertIn("bare branch name pushes the LOCAL ref", text) + + def test_top_conflicts_with_trunk(self) -> None: + self.merge = False + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertFalse(report["linkable"]) + self.assertFalse(report["landing"]["clean"]) + self.assertIn("MERGE INTO TRUNK: β›”", gcc.render(report)) + + def test_old_git_marks_merge_unknown(self) -> None: + self.merge = None + report = gcc.evaluate(TRUNK, ["l1"], self.prs, fetched=True, use_pr=True) + self.assertTrue(report["linkable"]) + self.assertIsNone(report["landing"]["clean"]) + self.assertIn("git < 2.38", report["landing"]["reason"]) + + def test_missing_trunk_on_origin_raises_tool_error(self) -> None: + self.origins.pop(TRUNK) + with self.assertRaises(gcc.ToolError): + gcc.evaluate(TRUNK, ["l1"], self.prs, fetched=True, use_pr=True) + + def test_duplicate_branch_is_a_problem(self) -> None: + report = gcc.evaluate(TRUNK, ["l1", "l1"], self.prs, fetched=True, use_pr=True) + self.assertTrue(any("appears twice" in p for p in report["problems"])) + + +class IndexAndResolveTests(unittest.TestCase): + def test_open_pr_wins_over_closed_for_same_branch(self) -> None: + prs = [pr(5, "l1", TRUNK, OLD, state="CLOSED"), pr(9, "l1", TRUNK, A1)] + by_number, by_branch = gcc.index_prs(prs) + self.assertEqual(by_branch["l1"]["number"], 9) + self.assertEqual(set(by_number), {5, 9}) + + def test_newest_wins_among_same_state(self) -> None: + prs = [pr(5, "l1", TRUNK, OLD, state="CLOSED"), pr(7, "l1", TRUNK, A1, state="CLOSED")] + _, by_branch = gcc.index_prs(prs) + self.assertEqual(by_branch["l1"]["number"], 7) + + def test_resolve_numbers_and_names(self) -> None: + by_number, _ = gcc.index_prs([pr(12, "fix/x", TRUNK, A1)]) + self.assertEqual(gcc.resolve_layers(["12", "feat/y"], by_number), ["fix/x", "feat/y"]) + with mock.patch.object(gcc, "view_pr", side_effect=gcc.ToolError("PR #99 not found")): + with self.assertRaises(gcc.ToolError): + gcc.resolve_layers(["99"], by_number) + + +if __name__ == "__main__": + unittest.main() + + +class MainContractTests(unittest.TestCase): + """Exit codes 0/1/2 and the never-a-traceback guard, with every external call patched.""" + + def _args(self, **kw) -> "gcc.argparse.Namespace": + base = {"trunk": TRUNK, "layers": ["l1"], "no_fetch": True, "no_pr": True, "json": False} + base.update(kw) + return gcc.argparse.Namespace(**base) + + def test_exit_0_when_linkable(self) -> None: + with mock.patch.object(gcc, "preflight"), \ + mock.patch.object(gcc, "evaluate", return_value={"trunk": TRUNK, "trunk_origin": T0, "rows": [], "problems": [], "notes": [], "landing": {"clean": None, "reason": "x"}, "linkable": True}), \ + mock.patch("builtins.print"): + self.assertEqual(gcc.run_check(self._args()), 0) + + def test_exit_1_when_problems(self) -> None: + with mock.patch.object(gcc, "preflight"), \ + mock.patch.object(gcc, "evaluate", return_value={"trunk": TRUNK, "trunk_origin": T0, "rows": [], "problems": ["p"], "notes": [], "landing": {"clean": None, "reason": "x"}, "linkable": False}), \ + mock.patch("builtins.print"): + self.assertEqual(gcc.run_check(self._args()), 1) + + def test_exit_2_on_tool_error_and_on_unexpected_exception(self) -> None: + import io + for exc in (gcc.ToolError("git missing"), KeyError("owner")): + with mock.patch.object(gcc, "preflight", side_effect=exc), \ + mock.patch.object(gcc.sys, "stderr", new=io.StringIO()) as err: + self.assertEqual(gcc.guarded("gh-stack-chain-check", lambda: gcc.run_check(self._args())), 2) + self.assertTrue(err.getvalue().startswith("gh-stack-chain-check: "), err.getvalue()) + self.assertNotIn("Traceback", err.getvalue()) + + def test_numeric_layer_outside_open_list_uses_pr_view(self) -> None: + by_number, by_branch = gcc.index_prs([]) + with mock.patch.object(gcc, "view_pr", return_value=pr(1500, "fix/old", TRUNK, A1, state="MERGED")) as vp: + self.assertEqual(gcc.resolve_layers(["1500"], by_number, by_branch), ["fix/old"]) + vp.assert_called_once_with(1500) + self.assertEqual(by_branch["fix/old"]["state"], "MERGED") + + +class ErrorConditionTests(unittest.TestCase): + def setUp(self) -> None: + self.origins = {TRUNK: T0, "l1": A1} + for p in ( + mock.patch.object(gcc, "origin_sha", side_effect=lambda ref: self.origins.get(ref)), + mock.patch.object(gcc, "is_ancestor", side_effect=lambda a, b: a == b or (a, b) == (T0, A1)), + mock.patch.object(gcc, "merge_clean", return_value=True), + ): + p.start() + self.addCleanup(p.stop) + + def test_trunk_listed_as_layer_is_a_problem(self) -> None: + report = gcc.evaluate(TRUNK, ["l1", TRUNK], {}, fetched=True, use_pr=False) + self.assertTrue(any("is the trunk" in p for p in report["problems"])) + + def test_index_prs_skips_malformed_entries(self) -> None: + by_number, by_branch = gcc.index_prs([{"number": "x"}, {"headRefName": "a"}, pr(3, "l1", TRUNK, A1), "junk"]) + self.assertEqual(list(by_number), [3]) + self.assertEqual(list(by_branch), ["l1"]) + + def test_view_pr_rejects_bad_shape(self) -> None: + cp = __import__("subprocess").CompletedProcess(["gh"], 0, stdout='{"number": 5}', stderr="") + with mock.patch.object(gcc, "run", return_value=cp): + with self.assertRaises(gcc.ToolError): + gcc.view_pr(5) + + def test_fetch_failure_is_a_warning_not_exit_2(self) -> None: + import io + failed = __import__("subprocess").CompletedProcess(["git"], 128, stdout="", stderr="fatal: could not read from remote") + with mock.patch.object(gcc, "preflight"), \ + mock.patch.object(gcc, "run", return_value=failed), \ + mock.patch.object(gcc, "evaluate", return_value={"trunk": TRUNK, "trunk_origin": T0, "rows": [], "problems": [], "notes": [], "landing": {"clean": None, "reason": "x"}, "linkable": True}), \ + mock.patch("builtins.print"), \ + mock.patch.object(gcc.sys, "stderr", new=io.StringIO()) as err: + args = gcc.argparse.Namespace(trunk=TRUNK, layers=["l1"], no_fetch=False, no_pr=True, json=False) + self.assertEqual(gcc.run_check(args), 0) + self.assertIn("warning: git fetch origin failed", err.getvalue()) + + def test_run_hint_for_rate_limit_and_auth(self) -> None: + shared = gcc.sys.modules[gcc.run.__module__] + self.assertIn("rate limit", shared.hint_for("HTTP 403: API rate limit exceeded")) + self.assertIn("gh auth", shared.hint_for("gh: Not logged in (HTTP 401)")) + self.assertEqual(shared.hint_for("something else"), "") + + +class NextLineTests(unittest.TestCase): + def test_no_next_line_when_not_linkable(self) -> None: + report = {"trunk": TRUNK, "trunk_origin": T0, "rows": [{"layer": 1, "branch": "l1", "pr": 1, "pushed": False, + "contains_parent": None, "pr_base_ok": True, "pr_head_ok": True, "draft": False}], + "problems": ["L1 l1: not on origin"], "notes": [], "landing": {"clean": None, "reason": "x"}, "linkable": False} + self.assertNotIn("next:", gcc.render(report)) + + def test_link_command_survives_a_shell(self) -> None: + import shlex, subprocess + report = {"trunk": "develop", "rows": [{"branch": "l1", "pr": 1547}, {"branch": "l2", "pr": 1548}], "linkable": True} + cmd = gcc.link_command(report) + echoed = subprocess.run(["sh", "-c", "echo " + cmd], text=True, capture_output=True).stdout.strip() + self.assertEqual(echoed, cmd) + self.assertEqual(shlex.split(cmd)[-2:], ["1547", "1548"]) + + +class RealGitTests(unittest.TestCase): + """Prove the git semantics the chain logic assumes, against a real temporary repository.""" + + @classmethod + def setUpClass(cls) -> None: + import shutil, subprocess, tempfile, os + if shutil.which("git") is None: + raise unittest.SkipTest("git not installed") + cls.tmp = tempfile.mkdtemp() + cls.cwd = os.getcwd() + os.chdir(cls.tmp) + env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x", "PATH": os.environ["PATH"], "HOME": cls.tmp} + def git(*a): return subprocess.run(["git", *a], check=True, text=True, capture_output=True, env=env).stdout.strip() + cls.git = staticmethod(git) + git("init", "-q", "-b", "develop") + Path("a.txt").write_text("a\n"); git("add", "."); git("commit", "-qm", "base") + git("checkout", "-qb", "l1"); Path("a.txt").write_text("l1\n"); Path("b.txt").write_text("b\n"); git("add", "."); git("commit", "-qm", "l1") + git("checkout", "-qb", "l2"); Path("c.txt").write_text("c\n"); git("add", "."); git("commit", "-qm", "l2") + git("checkout", "-q", "develop"); git("checkout", "-qb", "fork"); Path("a.txt").write_text("conflict\n"); git("add", "."); git("commit", "-qm", "fork") + git("checkout", "-q", "develop") + # simulate origin/* by pointing remote-tracking refs at the local branches + for b in ("develop", "l1", "l2", "fork"): + git("update-ref", f"refs/remotes/origin/{b}", b) + cls.sha = {b: git("rev-parse", b) for b in ("develop", "l1", "l2", "fork")} + + @classmethod + def tearDownClass(cls) -> None: + import os, shutil + os.chdir(cls.cwd) + shutil.rmtree(cls.tmp, ignore_errors=True) + + def test_origin_sha_and_is_ancestor(self) -> None: + self.assertEqual(gcc.origin_sha("l1"), self.sha["l1"]) + self.assertIsNone(gcc.origin_sha("nope")) + self.assertTrue(gcc.is_ancestor(self.sha["l1"], self.sha["l2"])) + self.assertFalse(gcc.is_ancestor(self.sha["l2"], self.sha["l1"])) + + def test_merge_clean_detects_conflict_and_clean_merge(self) -> None: + clean = gcc.merge_clean(self.sha["develop"], self.sha["l2"]) + conflict = gcc.merge_clean(self.sha["l1"], self.sha["fork"]) + if clean is None: + self.skipTest("git < 2.38: merge-tree --write-tree unavailable (reported as unknown, as designed)") + self.assertTrue(clean) + self.assertFalse(conflict) + + def test_evaluate_end_to_end_on_real_refs(self) -> None: + with mock.patch.object(gcc, "merge_clean", wraps=gcc.merge_clean): + good = gcc.evaluate("develop", ["l1", "l2"], {}, fetched=True, use_pr=False) + bad = gcc.evaluate("develop", ["l1", "fork"], {}, fetched=True, use_pr=False) + self.assertTrue(good["linkable"], good["problems"]) + self.assertFalse(bad["linkable"]) + self.assertTrue(any("fork: does not contain parent l1" in p for p in bad["problems"])) diff --git a/packages/sc-gh-stack/tests/test_gh_stack_view.py b/packages/sc-gh-stack/tests/test_gh_stack_view.py new file mode 100644 index 000000000..31744d837 --- /dev/null +++ b/packages/sc-gh-stack/tests/test_gh_stack_view.py @@ -0,0 +1,386 @@ +"""Regression tests for gh_stack_view.build_rows against gh-stack JSON shapes, +plus select_stacks (the generalized trunk-selection logic for sc-gh-stack-view). + +gh-stack v0.1.0 omits ``head``/``base`` for a layer whose branch is not present +locally (viewed from a sibling worktree before fetch). The report must degrade +to ❓/notes for that layer, never raise. Runs under pytest and standalone with +``python3 -m unittest``. +""" +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +import unittest +from unittest import mock + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "gh_stack_view.py" +spec = importlib.util.spec_from_file_location("gh_stack_view_under_test", SCRIPT) +assert spec is not None and spec.loader is not None +gsv = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = gsv +spec.loader.exec_module(gsv) + +TRUNK = "integrate/phase-ax" +T0 = "0" * 40 +A1 = "a" * 40 +B2 = "b" * 40 +C3 = "c" * 40 +OLD = "d" * 40 + + +def layer(name: str, pr: int, *, head: str | None, base: str | None, needs_rebase: bool = False) -> dict: + entry = {"name": name, "isCurrent": False, "isMerged": False, "isQueued": False, + "needsRebase": needs_rebase, "pr": {"number": pr, "state": "OPEN"}} + if head is not None: + entry["head"] = head + if base is not None: + entry["base"] = base + return entry + + +def pr(head: str, base: str, *, mergeable: str = "MERGEABLE", state: str = "CLEAN") -> dict: + return {"headRefOid": head, "baseRefName": base, "mergeable": mergeable, + "mergeStateStatus": state, "isDraft": False, "ci": "SUCCESS"} + + +class BuildRowsShapeTests(unittest.TestCase): + def setUp(self) -> None: + self.origins = {TRUNK: T0, "fix/bottom": A1, "fix/middle": B2, "docs/top": C3} + patches = [ + mock.patch.object(gsv, "origin_sha", side_effect=lambda ref: self.origins.get(ref)), + mock.patch.object(gsv, "is_ancestor", return_value=False), + ] + for p in patches: + p.start() + self.addCleanup(p.stop) + + def coherent_stack(self) -> tuple[dict, dict]: + stack = {"trunk": TRUNK, "currentBranch": "fix/bottom", "branches": [ + layer("fix/bottom", 1, head=A1, base=T0), + layer("fix/middle", 2, head=B2, base=A1), + layer("docs/top", 3, head=C3, base=B2), + ]} + prs = {1: pr(A1, TRUNK), 2: pr(B2, "fix/bottom"), 3: pr(C3, "fix/middle")} + return stack, prs + + def test_full_shape_is_coherent(self) -> None: + stack, prs = self.coherent_stack() + rows, problems, notes = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(problems, []) + self.assertEqual(notes, []) + self.assertEqual([gsv.sync_icon(r) for r in rows], [gsv.ICON_SYNC["ok"]] * 3) + + def test_missing_head_and_base_keys_do_not_crash(self) -> None: + # Regression: gh stack view --json returned layers without head/base; + # build_rows raised KeyError('head') while formatting the origin problem. + stack, prs = self.coherent_stack() + stack["branches"][1] = layer("fix/middle", 2, head=None, base=None) + stack["branches"][2] = layer("docs/top", 3, head=None, base=None) + rows, problems, notes = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(problems, [], "remote side (origin == PR head) is coherent, so no problems") + self.assertTrue(any("no local head" in n and "fix/middle" in n for n in notes)) + self.assertTrue(any("no base SHA" in n and "docs/top" in n for n in notes)) + self.assertIsNone(rows[1]["head"]) + self.assertIsNone(rows[1]["base"]) + self.assertIsNone(rows[1]["base_ok"]) + self.assertEqual(gsv.sync_icon(rows[1]), gsv.ICON_SYNC["unknown"]) + + def test_missing_head_with_stale_pr_is_reported_not_raised(self) -> None: + stack, prs = self.coherent_stack() + stack["branches"][2] = layer("docs/top", 3, head=None, base=B2) + prs[3] = pr(OLD, "fix/middle") # PR head differs from origin -> stale push + rows, problems, _ = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(len(problems), 1) + self.assertIn("docs/top", problems[0]) + self.assertIn("local - / origin ccccccccc / PR ddddddddd differ", problems[0]) + self.assertEqual(gsv.sync_icon(rows[2]), gsv.ICON_SYNC["stale"]) + + def test_missing_head_falls_back_to_pr_head_for_next_layer_base(self) -> None: + stack, prs = self.coherent_stack() + stack["branches"][0] = layer("fix/bottom", 1, head=None, base=T0) + self.origins["fix/bottom"] = None # not fetched either + rows, problems, _ = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(problems, []) + self.assertEqual(rows[1]["expected_base"], A1) + + def test_no_fetch_marks_unknown_without_raising(self) -> None: + stack, prs = self.coherent_stack() + stack["branches"][2] = layer("docs/top", 3, head=None, base=None) + rows, problems, _ = gsv.build_rows(stack, prs, fetched=False) + self.assertEqual(problems, []) + self.assertEqual(gsv.sync_icon(rows[2]), gsv.ICON_SYNC["unknown"]) + + def test_render_table_handles_missing_head(self) -> None: + stack, prs = self.coherent_stack() + stack["branches"][2] = layer("docs/top", 3, head=None, base=None) + rows, problems, notes = gsv.build_rows(stack, prs, fetched=True) + landing = {"landable": None, "reason": "no open layer or trunk not fetched"} + text = gsv.render_table(stack, rows, problems, notes, landing, trunk_origin=T0) + self.assertIn("VERDICT: βœ… COHERENT", text) + self.assertIn("note: L3 docs/top: gh stack reported no local head", text) + + def test_needs_rebase_and_base_mismatch_still_flagged(self) -> None: + stack, prs = self.coherent_stack() + stack["branches"][1] = layer("fix/middle", 2, head=B2, base=OLD, needs_rebase=True) + _, problems, _ = gsv.build_rows(stack, prs, fetched=True) + self.assertTrue(any("base ddddddddd != parent head aaaaaaaaa" in p for p in problems)) + self.assertTrue(any("gh stack reports needsRebase" in p for p in problems)) + + +T1 = "1" * 40 # trunk head after the bottom layer merged + + +class MergedBottomLayerTests(unittest.TestCase): + """Regression: after ``gh stack merge``/merge-async lands the bottom layer, + GitHub retargets the next layer onto the trunk. The report used to demand + that layer be based on the MERGED branch (PR base and base SHA), producing + two false problems on a stack gh-stack itself reported as needsRebase=false.""" + + def setUp(self) -> None: + # Trunk moved from T0 to T1 (the merge commit of the bottom layer). + self.origins = {TRUNK: T1, "fix/bottom": A1, "fix/middle": B2, "docs/top": C3} + self.ancestors = {(T0, T1), (A1, T1)} + patches = [ + mock.patch.object(gsv, "origin_sha", side_effect=lambda ref: self.origins.get(ref)), + mock.patch.object(gsv, "is_ancestor", side_effect=lambda a, b: (a, b) in self.ancestors), + ] + for p in patches: + p.start() + self.addCleanup(p.stop) + + def merged_bottom_stack(self, *, middle_base: str, middle_pr_base: str = TRUNK) -> tuple[dict, dict]: + bottom = layer("fix/bottom", 1, head=A1, base=T0) + bottom["isMerged"] = True + bottom["pr"]["state"] = "MERGED" + stack = {"trunk": TRUNK, "currentBranch": "docs/top", "branches": [ + bottom, + layer("fix/middle", 2, head=B2, base=middle_base), + layer("docs/top", 3, head=C3, base=B2), + ]} + prs = {1: pr(A1, TRUNK, state="MERGED"), 2: pr(B2, middle_pr_base), 3: pr(C3, "fix/middle")} + return stack, prs + + def test_layer_above_merged_bottom_is_judged_against_trunk(self) -> None: + # gh stack reports the retargeted layer's base as the pre-merge trunk + # commit (T0), an ancestor of the new trunk head: behind trunk, not a rebase. + stack, prs = self.merged_bottom_stack(middle_base=T0) + rows, problems, notes = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(problems, [], problems) + self.assertEqual(len(notes), 1) + self.assertIn("L2 fix/middle: behind trunk", notes[0]) + self.assertEqual(gsv.sync_icon(rows[0]), gsv.ICON_MERGE["MERGED"]) + self.assertEqual(gsv.sync_icon(rows[1]), gsv.ICON_SYNC["behind"], "behind a moved trunk is its own icon, not a rebase order") + self.assertEqual(gsv.sync_icon(rows[2]), gsv.ICON_SYNC["ok"]) + self.assertEqual(rows[1]["expected_base"], T1, "expected base is the trunk head, not the merged layer's head") + + def test_layer_above_merged_bottom_on_trunk_head_is_clean(self) -> None: + stack, prs = self.merged_bottom_stack(middle_base=T1) + rows, problems, notes = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(problems, []) + self.assertEqual(notes, []) + self.assertEqual([gsv.sync_icon(r) for r in rows[1:]], [gsv.ICON_SYNC["ok"]] * 2) + + def test_layer_above_merged_bottom_still_targeting_merged_branch_is_flagged(self) -> None: + # GitHub has not retargeted the PR yet: that IS a problem the owner must fix. + stack, prs = self.merged_bottom_stack(middle_base=T1, middle_pr_base="fix/bottom") + _rows, problems, _notes = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(len(problems), 1) + self.assertIn("PR #2 base is fix/bottom, expected integrate/phase-ax", problems[0]) + + def test_open_parent_mismatch_is_still_a_problem(self) -> None: + # Above an OPEN layer the ancestor leniency must not apply. + stack, prs = self.merged_bottom_stack(middle_base=T1) + stack["branches"][2]["base"] = OLD + self.ancestors.add((OLD, B2)) + _rows, problems, _notes = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(len(problems), 1) + self.assertIn("L3 docs/top: base ddddddddd != parent head bbbbbbbbb -> needs rebase", problems[0]) + + +def found_stack(trunk: str, branch: str, pr_num: int, *, merged: bool = False, closed: bool = False) -> dict: + """Minimal deduped ``found`` entry: one layer is enough for select_stacks, + which only inspects trunk and branch-open state.""" + state = "MERGED" if merged else ("CLOSED" if closed else "OPEN") + return { + "trunk": trunk, + "worktree": f"/tmp/{branch}", + "branches": [{"name": branch, "isMerged": merged, "isQueued": False, + "needsRebase": False, "pr": {"number": pr_num, "state": state}}], + } + + +class SelectStacksTests(unittest.TestCase): + def test_default_keeps_open_stacks_on_any_trunk(self) -> None: + found = [ + found_stack("develop", "fix/a", 1), + found_stack("integrate/phase-bc", "fix/b", 2), + found_stack("main", "fix/c", 3), + ] + stacks, hidden = gsv.select_stacks(found, None, include_all=False) + self.assertEqual({s["trunk"] for s in stacks}, {"develop", "integrate/phase-bc", "main"}) + self.assertEqual(hidden, 0) + + def test_default_hides_fully_merged_stack_and_counts_it_hidden(self) -> None: + found = [ + found_stack("develop", "fix/a", 1), + found_stack("develop", "fix/merged", 2, merged=True), + ] + stacks, hidden = gsv.select_stacks(found, None, include_all=False) + self.assertEqual([s["branches"][0]["name"] for s in stacks], ["fix/a"]) + self.assertEqual(hidden, 1) + + def test_trunk_filter_keeps_only_matching_trunk(self) -> None: + found = [ + found_stack("develop", "fix/a", 1), + found_stack("integrate/phase-bc", "fix/b", 2), + found_stack("integrate/phase-bc", "fix/c", 3), + ] + stacks, hidden = gsv.select_stacks(found, "integrate/phase-bc", include_all=False) + self.assertEqual({s["trunk"] for s in stacks}, {"integrate/phase-bc"}) + self.assertEqual(len(stacks), 2) + self.assertEqual(hidden, 1) + + def test_include_all_keeps_merged_stacks(self) -> None: + found = [ + found_stack("develop", "fix/a", 1), + found_stack("develop", "fix/merged", 2, merged=True), + found_stack("main", "fix/closed", 3, closed=True), + ] + stacks, hidden = gsv.select_stacks(found, None, include_all=True) + self.assertEqual(len(stacks), 3) + self.assertEqual(hidden, 0) + + def test_include_all_with_trunk_filter_still_filters_trunk(self) -> None: + found = [ + found_stack("develop", "fix/a", 1, merged=True), + found_stack("main", "fix/b", 2, merged=True), + ] + stacks, hidden = gsv.select_stacks(found, "develop", include_all=True) + self.assertEqual([s["trunk"] for s in stacks], ["develop"]) + self.assertEqual(hidden, 1) + + +if __name__ == "__main__": + unittest.main() + + +class ErrorConditionTests(unittest.TestCase): + def _cp(self, rc: int, out: str = "", err: str = ""): + import subprocess + return subprocess.CompletedProcess(["x"], rc, stdout=out, stderr=err) + + def test_merge_tree_usage_error_is_not_judged_as_conflict(self) -> None: + rows = [{"branch": "top", "origin": C3, "merged": False}] + with mock.patch.object(gsv, "is_ancestor", return_value=True), \ + mock.patch.object(gsv, "run", return_value=self._cp(129, err="usage: git merge-tree")): + landing = gsv.landing_verdict({"trunk": TRUNK}, rows, T0) + self.assertIsNone(landing["landable"]) + self.assertIn("git < 2.38", landing["reason"]) + + def test_merge_tree_conflict_is_reported(self) -> None: + rows = [{"branch": "top", "origin": C3, "merged": False}] + with mock.patch.object(gsv, "is_ancestor", return_value=True), \ + mock.patch.object(gsv, "run", return_value=self._cp(1, out="CONFLICT (content): x.rs")): + landing = gsv.landing_verdict({"trunk": TRUNK}, rows, T0) + self.assertFalse(landing["landable"]) + self.assertIn("CONFLICT (content): x.rs", landing["reason"]) + + def test_stack_json_at_records_unreadable_worktrees(self) -> None: + gsv.SKIPPED.clear() + with mock.patch.object(gsv, "run", return_value=self._cp(6, err="branch belongs to multiple stacks")): + self.assertIsNone(gsv.stack_json_at("/wt/a")) + with mock.patch.object(gsv, "run", return_value=self._cp(2, err="not in a stack")): + self.assertIsNone(gsv.stack_json_at("/wt/b")) + with mock.patch.object(gsv, "run", return_value=self._cp(0, out="not json")): + self.assertIsNone(gsv.stack_json_at("/wt/c")) + joined = "\n".join(gsv.SKIPPED) + self.assertIn("/wt/a: gh stack view exit 6", joined) + self.assertIn("gh stack checkout <specific-branch>", joined) + self.assertNotIn("/wt/b", joined, "exit 2 (not in a stack) is normal and not reported") + self.assertIn("/wt/c: gh stack view --json returned non-JSON", joined) + gsv.SKIPPED.clear() + + def test_pr_details_partial_graphql_errors_warn_and_continue(self) -> None: + import io + repo = self._cp(0, out='{"owner": {"login": "o"}, "name": "r"}') + payload = {"data": {"repository": {"pr1": {"number": 1, "headRefOid": A1}, "pr2": None}}, + "errors": [{"message": "Could not resolve to a PullRequest with the number of 2."}]} + gql = self._cp(0, out=__import__("json").dumps(payload)) + with mock.patch.object(gsv, "run", side_effect=[repo, gql]), \ + mock.patch.object(gsv.sys, "stderr", new=io.StringIO()) as err: + prs = gsv.pr_details([1, 2]) + self.assertEqual(prs[1]["headRefOid"], A1) + self.assertEqual(prs[2], {}, "unresolved PR is falsy so rows render as unknown") + self.assertIn("warning: GraphQL reported", err.getvalue()) + + def test_pr_details_rejects_non_int_numbers(self) -> None: + repo = self._cp(0, out='{"owner": {"login": "o"}, "name": "r"}') + with mock.patch.object(gsv, "run", return_value=repo): + with self.assertRaises(gsv.ToolError): + gsv.pr_details([1, "2; mutation"]) # type: ignore[list-item] + + def test_guarded_never_tracebacks(self) -> None: + import io + with mock.patch.object(gsv.sys, "stderr", new=io.StringIO()) as err: + self.assertEqual(gsv.guarded("gh-stack-view", lambda: (_ for _ in ()).throw(KeyError("pr"))), 2) + self.assertTrue(err.getvalue().startswith("gh-stack-view: unexpected KeyError")) + + +class ReviewRegressionTests(unittest.TestCase): + def setUp(self) -> None: + gsv.SKIPPED.clear() + self.addCleanup(gsv.SKIPPED.clear) + + def test_unresolved_pr_row_is_unknown_not_stale(self) -> None: + stack = {"trunk": TRUNK, "currentBranch": "l1", "branches": [layer("l1", 1, head=A1, base=T0)]} + with mock.patch.object(gsv, "origin_sha", side_effect=lambda ref: {TRUNK: T0, "l1": A1}.get(ref)), \ + mock.patch.object(gsv, "is_ancestor", return_value=False): + rows, problems, notes = gsv.build_rows(stack, {1: {}}, fetched=True) + self.assertEqual(problems, []) + self.assertTrue(any("PR #1 could not be resolved" in n for n in notes)) + self.assertEqual(gsv.sync_icon(rows[0]), gsv.ICON_SYNC["unknown"]) + self.assertEqual(gsv.merge_icon(rows[0]), gsv.ICON_SYNC["unknown"]) + self.assertEqual(gsv.ci_icon(rows[0]), gsv.ICON_SYNC["unknown"]) + + def test_needs_rebase_on_behind_trunk_bottom_is_a_note(self) -> None: + stack = {"trunk": TRUNK, "currentBranch": "l1", "branches": [layer("l1", 1, head=A1, base=OLD, needs_rebase=True)]} + with mock.patch.object(gsv, "origin_sha", side_effect=lambda ref: {TRUNK: T0, "l1": A1}.get(ref)), \ + mock.patch.object(gsv, "is_ancestor", side_effect=lambda a, b: (a, b) == (OLD, T0)): + rows, problems, notes = gsv.build_rows(stack, {1: pr(A1, TRUNK)}, fetched=True) + self.assertEqual(problems, []) + self.assertTrue(any("needsRebase, but only against a trunk that moved" in n for n in notes)) + self.assertEqual(gsv.sync_icon(rows[0]), gsv.ICON_SYNC["behind"]) + + def test_skipped_worktree_makes_report_exit_1_and_landing_unjudged(self) -> None: + import io, subprocess + stack = {"trunk": TRUNK, "currentBranch": "l1", "worktree": "/wt/l1", + "branches": [layer("l1", 1, head=A1, base=T0)]} + gsv.SKIPPED.append("/wt/top: gh stack view exit 8: locked") + args = gsv.argparse.Namespace(trunk=None, phase=None, all=False, no_fetch=True, no_pr=True, json=True) + with mock.patch.object(gsv, "preflight"), \ + mock.patch.object(gsv, "discover_stacks", return_value=([stack], 0)), \ + mock.patch.object(gsv, "origin_sha", return_value=None), \ + mock.patch("builtins.print") as out, \ + mock.patch.object(gsv.sys, "stderr", new=io.StringIO()) as err: + rc = gsv.run_report(args, None) + self.assertEqual(rc, 1) + payload = __import__("json").loads(out.call_args[0][0]) + self.assertFalse(payload["coherent"]) + self.assertIn("discovery incomplete", payload["stacks"][0]["problems"][0]) + self.assertIsNone(payload["stacks"][0]["landing"]["landable"]) + self.assertIn("skipped worktree /wt/top", err.getvalue()) + + def test_fetch_timeout_degrades_to_unknown(self) -> None: + import io + stack = {"trunk": TRUNK, "currentBranch": "l1", "worktree": "/wt/l1", + "branches": [layer("l1", 1, head=A1, base=T0)]} + args = gsv.argparse.Namespace(trunk=None, phase=None, all=False, no_fetch=False, no_pr=True, json=False) + with mock.patch.object(gsv, "preflight"), \ + mock.patch.object(gsv, "discover_stacks", return_value=([stack], 0)), \ + mock.patch.object(gsv, "run", side_effect=gsv.ToolError("git fetch timed out after 60s")), \ + mock.patch("builtins.print"), \ + mock.patch.object(gsv.sys, "stderr", new=io.StringIO()) as err: + rc = gsv.run_report(args, None) + self.assertEqual(rc, 0) + self.assertIn("git fetch origin failed (git fetch timed out", err.getvalue()) diff --git a/packages/sc-git-worktree/.claude-plugin/plugin.json b/packages/sc-git-worktree/.claude-plugin/plugin.json index d7a3e5d2b..fe938b013 100644 --- a/packages/sc-git-worktree/.claude-plugin/plugin.json +++ b/packages/sc-git-worktree/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-git-worktree", "description": "Create, manage, scan, and clean up git worktrees for parallel development. Maintains tracking of worktree status, branch details, and cleanup history with optional tracking document.", - "version": "0.12.0", + "version": "0.14.0", "author": { "name": "randlee" }, @@ -18,6 +18,7 @@ ], "agents": [ "./agents/sc-git-worktree-create.md", + "./agents/sc-git-worktree-create-stacked.md", "./agents/sc-git-worktree-scan.md", "./agents/sc-git-worktree-cleanup.md", "./agents/sc-git-worktree-abort.md", diff --git a/packages/sc-git-worktree/CHANGELOG.md b/packages/sc-git-worktree/CHANGELOG.md index d5216aa06..4faef65e4 100644 --- a/packages/sc-git-worktree/CHANGELOG.md +++ b/packages/sc-git-worktree/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to the **sc-git-worktree** package will be documented in thi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.14.0] - 2026-09-23 + +### Added +- `sc-worktree-create-stacked` agent and `--create-stacked <layer> <parent> <trunk> [--above <layer>]`: cut a gh-stack layer worktree from the parent's **pushed** head with `--no-track`, validate the cut before any mutation (`STACK.LAYER_EXISTS`, `STACK.PARENT_NOT_PUSHED`, `STACK.PARENT_LANDED`, `STACK.PARENT_OFF_TRUNK`, `STACK.ABOVE_INVALID`), record `parent_sha`, and return a `stack_handoff` block (push, PR base, link or mid-stack insert sequence, writer rules) for the agent that works in the worktree. +- Tracking entries carry an optional `stack` object (`trunk`, `parent`, `parent_sha`, `above`, `position`); older rows load unchanged. +- Cleanup refuses to delete a branch that live stack layers sit on unless git shows a merge-commit landing into the children's trunk; abort always refuses (`STACK.HAS_CHILDREN`; batch cleanup reports `stack_blocked` and never sweeps a fresh layer with no commits). `STACK.PARENT_HAS_CHILD` refuses an append that would fork the stack. +- Scan reports `stack_parent_advanced` and `stack_parent_landed` on layer rows, with recommendations. +- `references/stack-layers.md`: the worktree side of the stack model and the writer contract. + +### Fixed +- Plain create from a remote-only base no longer sets that base as the new branch's upstream (`--no-track`), so `git push` / `--force-with-lease` cannot target the base branch. +- Merge detection now recognises the `+` marker git prints for branches checked out in other worktrees. Scope: single-branch `--cleanup` of a branch that is merged (or has no unique commits) and still has a worktree used to fail as "unmerged" and needed `merged: true`; it now proceeds and deletes the branch locally and on the remote as the agent contract states. Batch mode already behaved this way. + ## [0.10.0] - 2026-04-18 ### Added diff --git a/packages/sc-git-worktree/DESIGN.md b/packages/sc-git-worktree/DESIGN.md index 657b8d278..5d5187c51 100644 --- a/packages/sc-git-worktree/DESIGN.md +++ b/packages/sc-git-worktree/DESIGN.md @@ -112,6 +112,20 @@ Force-remove a worktree, discarding uncommitted changes: 3. Update JSONL entry (local_worktree=false) 4. Remote deletion handled by subsequent cleanup +## Stack Layers (gh-stack) + +A worktree may be a layer of a `gh stack`. Its tracking entry carries: + +```json +"stack": {"trunk": "develop", "parent": "sprint-6", "parent_sha": "<sha>", "above": null, "position": "top"} +``` + +- **Create (stacked)**: cut from `origin/<parent>` with `--no-track`, never from the local ref. Refuse before mutation when the parent is not pushed, is already landed in the trunk, shares no history with it, or (insert) `above` does not contain the parent. Return a `stack_handoff` (push, PR base, link or insert sequence, writer rules). +- **Cleanup / abort**: a branch with live children (`stack.parent == branch`, child has worktree or remote) is deleted only when git shows it merged into the trunk. Otherwise `STACK.HAS_CHILDREN` / `stack_blocked`. +- **Scan**: reports `stack_parent_advanced` and `stack_parent_landed` per layer. + +The stack model and gh-stack recipes live in the `sc-gh-stack` package; this package never runs `gh`. + ## Safety Guards ### Protected Branches diff --git a/packages/sc-git-worktree/README.md b/packages/sc-git-worktree/README.md index 106b30fdc..440e9efea 100644 --- a/packages/sc-git-worktree/README.md +++ b/packages/sc-git-worktree/README.md @@ -32,6 +32,7 @@ Create, scan, clean up, and abort worktrees using predictable paths and safe def ## Usage - `/sc-git-worktree --list` or `--status` - `/sc-git-worktree --create <branch> <base>` +- `/sc-git-worktree --create-stacked <layer> <parent> <trunk> [--above <layer>]` (gh-stack layer: cut from the parent's pushed head, returns a `stack_handoff` for the writer) - `/sc-git-worktree --cleanup <branch>` - `/sc-git-worktree --abort <branch>` @@ -44,6 +45,7 @@ Safety - Never delete remote branches that are ahead of local (unpulled commits) - Never delete protected branches (main, master, develop) - Never modify dirty worktrees without explicit approval +- Never delete a branch that live stack layers were cut from unless git shows it landed (`STACK.HAS_CHILDREN`) Shared Settings - Protected branches are read from `.sc/shared-settings.yaml` (`git.protected_branches`) @@ -74,7 +76,8 @@ See [DESIGN.md](DESIGN.md) for detailed requirements including: ## Components - Command: `commands/sc-git-worktree.md` - Skill: `skills/sc-managing-worktrees/SKILL.md` -- Agents: `sc-worktree-create`, `sc-worktree-scan`, `sc-worktree-cleanup`, `sc-worktree-abort` +- Agents: `sc-worktree-create`, `sc-worktree-create-stacked`, `sc-worktree-scan`, `sc-worktree-cleanup`, `sc-worktree-abort`, `sc-worktree-update` +- Stack layers: `skills/sc-git-worktree/references/stack-layers.md` (pairs with the `sc-gh-stack` package) ## Version & Changelog - 0.4.0 β€” Initial v0.x publication diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-abort.md b/packages/sc-git-worktree/agents/sc-git-worktree-abort.md index 942bb6e80..787aaf077 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-abort.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-abort.md @@ -1,6 +1,6 @@ --- name: sc-worktree-abort -version: 0.12.0 +version: 0.14.0 description: Abandon a worktree and discard work with protected branch safeguards. Remove worktree; for non-protected branches, delete branch (local/remote) only with explicit approval; for protected branches, never delete branch. Update tracking when enabled. model: haiku color: red @@ -34,6 +34,7 @@ Abandon a worktree and discard work safely. - **Protected branches:** Remote branch must never be deleted. Remove worktree; local branch may be removed only if explicitly approved for abort. Default is preserve. - If dirty and no approval, stop and report. - For **non-protected branches**: Only delete branches (local/remote) with explicit approval. If remote delete fails because it doesn't exist, note and continue. +- **Stack parents**: with `allow_delete_branch`, a branch that live stack layers sit on is always refused with `STACK.HAS_CHILDREN` before anything is removed (a landed parent is cleaned with `--cleanup`, not aborted). Abort the children first, or abort without `allow_delete_branch` to remove only the worktree. The guard reads the tracking file; off with `tracking_enabled: false`. - Always update tracking when enabled. ## Execution diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-cleanup.md b/packages/sc-git-worktree/agents/sc-git-worktree-cleanup.md index a8fe6868a..b177fed32 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-cleanup.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-cleanup.md @@ -1,6 +1,6 @@ --- name: sc-worktree-cleanup -version: 0.12.0 +version: 0.14.0 description: Clean up a completed/merged worktree with protected branch safeguards. Remove worktree; for non-protected branches, delete branch (local+remote) by default if merged/no unique commits; for protected branches, preserve branch. Update tracking when enabled. Stop on dirty/unmerged without approval. model: haiku color: orange @@ -59,6 +59,7 @@ Wrap the script output in `<output_json>` tags with a fenced JSON block. Do not | `WORKTREE.NOT_FOUND` | Worktree path doesn't exist | No | | `WORKTREE.DIRTY` | Uncommitted changes (single branch mode) | Yes | | `WORKTREE.UNMERGED` | Branch has unmerged commits | Yes | +| `STACK.HAS_CHILDREN` | Branch is the stack parent of live layer(s) and git does not show it landed in the trunk (single branch mode) | Yes | | `GIT.ERROR` | Git command failed | No | ## Rules @@ -67,6 +68,7 @@ Wrap the script output in `<output_json>` tags with a fenced JSON block. Do not - **Merged + clean**: Auto-cleaned in batch mode - **Dirty**: Reported back, requires explicit `require_clean: false` to force - **Unmerged**: Never auto-deleted. User must merge first or use `--abort` to discard. +- **Stack parents**: A branch that live stack layers sit on (tracking `stack.parent`, or the layer it was inserted under) is deleted only when git shows a merge-commit landing into the children's trunk. Batch mode lists such branches under `stack_blocked`, along with fresh layers that have no commits yet (never swept); single mode refuses with `STACK.HAS_CHILDREN`. Land the stack (`gh stack merge`) or clean the children first; run `--list` if a listed child is already gone. Guards are off with `tracking_enabled: false`. ## Constraints diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-create-stacked.md b/packages/sc-git-worktree/agents/sc-git-worktree-create-stacked.md new file mode 100644 index 000000000..64b7fda6f --- /dev/null +++ b/packages/sc-git-worktree/agents/sc-git-worktree-create-stacked.md @@ -0,0 +1,135 @@ +--- +name: sc-worktree-create-stacked +version: 0.14.0 +description: Create a worktree for a new gh-stack layer cut from its parent's PUSHED head (never a local ref, never tracking the parent). Validates the cut (parent pushed, on the trunk, not landed; insert target really stacked on the parent), records the parent SHA in tracking, and returns a stack_handoff block the layer's writer needs to push, open the PR with the right base, and link or insert the layer. +model: haiku +color: green +--- + +# Worktree Create Agent (stack layer) + +## Invocation + +This agent is invoked via the Claude Task tool by the `/sc-git-worktree` skill for `--create-stacked`. Do not invoke directly. Ordinary (non-stack) worktrees use `sc-worktree-create`. + +## Input Protocol + +Read inputs from `<input_json>` (JSON object). If omitted, treat as `{}`. + +## Purpose + +Cut a new stack layer as a worktree by calling `worktree_create.py` with a `stack` block. The script enforces the gh-stack field rules so the layer can be linked (top) or inserted (mid-stack) without rework: + +- The layer is a **new** branch cut from `origin/<parent>` with `--no-track`; the local `<parent>` ref is ignored because it may be stale or another writer's unpushed state. +- The parent must be pushed, must share history with the trunk, and must not already be landed in the trunk. +- When inserting, `above` must be a pushed layer that contains the parent. +- The parent SHA at cut time is recorded in the tracking row (`stack.parent_sha`) and in the handoff; it is the `<recorded-parent-base>` for the writer's one rebase at task start. + +## Inputs + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `branch` | string | Yes | New layer name (must not exist locally or on origin) | +| `base` | string | Yes | Parent layer: the current stack top from `/sc-gh-stack-view`, or the trunk for the bottom layer, or a mid-stack layer when inserting | +| `stack.trunk` | string | Yes | Branch the stack's bottom PR targets (for example `develop`) | +| `stack.above` | string | No | Layer currently stacked directly on `base`; set **only** when inserting mid-stack | +| `purpose` | string | Yes | Task or sprint id; goes into the PR body placeholder | +| `owner` | string | Yes | The layer's single writer (agent or user handle) | +| `repo_root`, `worktree_base`, `tracking_enabled`, `tracking_path` | | No | As for `sc-worktree-create` | + +## Execution + +Run the create script once with the input JSON: + +```bash +python3 .claude/scripts/worktree_create.py '<input_json>' +``` + +Example input (append on top of the stack): + +```json +{"branch": "sprint-7", "base": "sprint-6", "purpose": "sprint 7", "owner": "writer-b", + "stack": {"trunk": "develop"}} +``` + +Example input (insert under `sprint-6`): + +```json +{"branch": "hotfix-under-6", "base": "sprint-5", "purpose": "fix flaky test", "owner": "writer-c", + "stack": {"trunk": "develop", "above": "sprint-6"}} +``` + +## Output + +The script returns fenced JSON. Forward it verbatim. On success `data` carries two stack fields in addition to the ordinary create fields: + +- `stack`: `{trunk, parent, parent_sha, above, position}` (`position` is `top` or `insert`), also stored in the tracking row. +- `stack_handoff`: `pr_base`, `pr_body`, two role blocks, `chain_check_available`, `reference`. + - `writer`: `rules[]` and `commands[]` for the single agent that works in the worktree (WIP commit, first push with `-u`, the one rebase at task start, no gh stack write commands, no edits to lower layers). + - `stack_writer`: ordered `commands[]` for the one agent that opens PRs and runs gh stack write commands (PR with base = parent, `gh stack checkout` in the new worktree, link on top or the insert sequence: merge-forward by every layer above, unstack, `gh pr edit --base`, chain check, full relink). Angle-bracket placeholders come from `gh pr create` output and `/sc-gh-stack-view`. + +The caller pastes `stack_handoff.writer` verbatim into the worker agent's prompt and `stack_handoff.stack_writer` into the stack writer's prompt (or keeps it for itself when it holds that role). Layer writers never receive the stack-writer block. + +**Success example (top):** +```json +{ + "success": true, + "data": { + "action": "create", + "branch": "sprint-7", + "base": "sprint-6", + "path": "/path/to/worktrees/sprint-7", + "branch_created": true, + "tracking_updated": true, + "stack": {"trunk": "develop", "parent": "sprint-6", "parent_sha": "3f9c...e1", "above": null, "position": "top"}, + "stack_handoff": { + "pr_base": "sprint-6", + "writer": { + "rules": ["You are the only writer of sprint-7. Never edit, rebase, or force-push any layer below it.", "..."], + "commands": [ + "git -C /path/to/worktrees/sprint-7 add -A && git -C /path/to/worktrees/sprint-7 commit -m \"wip: sprint 7\" # first WIP commit; a PR needs at least one", + "git -C /path/to/worktrees/sprint-7 push -u origin sprint-7" + ] + }, + "stack_writer": { + "commands": [ + "# after the writer's first push of sprint-7:", + "gh pr create --base sprint-6 --head sprint-7 --title \"<title>\" --body \"$(cat <<'B'\nParent: sprint-6 @ 3f9c...e1\nTask: sprint 7\nFence: <paths this layer may touch>\nB\n)\"", + "cd /path/to/worktrees/sprint-7 && gh stack checkout <stack#> # import tracking into this new worktree; skip only when no stack exists yet", + "gh stack link <stack#> <pr# of sprint-7> # append: only if sprint-6 is the top row in /sc-gh-stack-view", + "gh stack link --base develop <bottom-pr#> ... <pr# of sprint-6> <pr# of sprint-7> # first link, or full relink", + "/sc-gh-stack-view" + ] + } + } + } +} +``` + +## Output Protocol + +Wrap the script output in `<output_json>` tags with a fenced JSON block. Do not add prose outside the tags. + +## Error Codes + +All stack refusals happen before anything is created; the message says what is wrong and `suggested_action` says what to do. + +| Code | Meaning | Recoverable | +|------|---------|-------------| +| `STACK.LAYER_EXISTS` | `branch` already exists; a layer is always new (use `sc-worktree-create` to open an existing branch) | Yes | +| `STACK.PARENT_NOT_PUSHED` | `origin/<base>` missing; push the parent first | Yes | +| `STACK.PARENT_LANDED` | Parent already contained in the trunk; cut from the trunk or the current top | Yes | +| `STACK.PARENT_OFF_TRUNK` | Parent and trunk share no history; wrong `stack.trunk` | Yes | +| `STACK.ABOVE_INVALID` | `above` is not pushed, does not contain the parent, or names the parent/new layer | Yes | +| `STACK.PARENT_HAS_CHILD` | A live layer already sits on the parent (tracking); appending would fork the stack. Cut from that layer, or pass it as `above` to insert | Yes | +| `BRANCH.NOT_FOUND` | Trunk not on origin | Yes | +| `CONFIG.MISSING` | Input failed validation (for example empty `stack.trunk`) | Yes | +| `WORKTREE.EXISTS`, `WORKTREE.BRANCH_IN_USE`, `WORKTREE.DIRTY`, `GIT.NOT_REPO`, `GIT.ERROR` | As for `sc-worktree-create` | No | + +Fork detection and the cleanup/abort guards read the tracking file; with `tracking_enabled: false` they are off. + +## Constraints + +- Run the script ONCE - it handles everything +- Do NOT run manual git commands; use the script only +- Do NOT push, open the PR, or run `gh stack` here: that is the writer's job, in the worktree, following `stack_handoff` diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-create.md b/packages/sc-git-worktree/agents/sc-git-worktree-create.md index c6355fb5d..ec8e5db4e 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-create.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-create.md @@ -1,6 +1,6 @@ --- name: sc-worktree-create -version: 0.12.0 +version: 0.14.0 description: Create a git worktree (and branch if needed) using the mandated layout and update tracking. Use for new feature/hotfix/release worktrees; obey branch protections and dirty-worktree safeguards. model: haiku color: green diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-scan.md b/packages/sc-git-worktree/agents/sc-git-worktree-scan.md index ba3251c1f..67b42adda 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-scan.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-scan.md @@ -1,6 +1,6 @@ --- name: sc-worktree-scan -version: 0.12.0 +version: 0.14.0 description: Scan git worktrees vs tracking; report status (clean/dirty), missing/stale tracking rows, and recommended actions. No mutations. model: haiku color: cyan @@ -99,6 +99,13 @@ On error (e.g., tracking file missing): Wrap the script output in `<output_json>` tags with a fenced JSON block. Do not add prose outside the tags. +## Stack layers + +Rows for gh-stack layers carry `tracking_entry.stack` (`trunk`, `parent`, `parent_sha`, `above`, `position`) and may list: + +- `stack_parent_advanced: origin/<parent> <old> -> <new>, not contained in <layer>`: the layer no longer contains the parent's pushed head. The layer's writer rebases once at task start (`git rebase --onto origin/<parent> <parent_sha> <layer>`), only while the layer has no children; the issue clears once the layer contains the parent's head. +- `stack_parent_landed: ...`: the parent merged into the trunk or its branch is gone; the PR should now target the trunk (`/sc-gh-stack-view`). Diff the layer against `origin/<layer>` before any reset; never force-push over GitHub's retarget. + ## Constraints - Do NOT modify anything; read-only scan diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-update.md b/packages/sc-git-worktree/agents/sc-git-worktree-update.md index e6e5cab2c..bef1ac5bb 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-update.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-update.md @@ -1,6 +1,6 @@ --- name: sc-worktree-update -version: 0.12.0 +version: 0.14.0 description: Update a protected branch in its worktree by pulling latest changes. Handle merge conflicts by returning control to main agent for user coordination. model: haiku color: blue diff --git a/packages/sc-git-worktree/commands/sc-git-worktree.md b/packages/sc-git-worktree/commands/sc-git-worktree.md index 47d5b1869..374124e07 100644 --- a/packages/sc-git-worktree/commands/sc-git-worktree.md +++ b/packages/sc-git-worktree/commands/sc-git-worktree.md @@ -1,7 +1,7 @@ --- name: sc-git-worktree description: Manage git worktrees for this repo (create, list/status, update, cleanup, abort) while enforcing the repo's worktree/tracking rules and protected branch safeguards. -version: 0.12.0 +version: 0.14.0 options: - name: --list description: List worktrees and show status/notes. @@ -14,6 +14,20 @@ options: - name: base description: Base branch to start from (e.g., master, develop, release/x.y, hotfix/...). description: Create a worktree (and branch if needed) using the mandated layout and update tracking. + - name: --create-stacked + args: + - name: branch + description: New gh-stack layer name (must not exist yet). + - name: parent + description: Layer to cut from - the current stack top from /sc-gh-stack-view, or the trunk for the bottom layer, or a mid-stack layer when inserting. + - name: trunk + description: Branch the stack's bottom PR targets (e.g., develop, integrate/phase-x). + description: Cut a new stack layer worktree from the parent's PUSHED head (--no-track), validate the cut, record the parent SHA, and return a stack_handoff for the layer's writer. Add `--above <layer>` when inserting under an existing layer. + - name: --above + args: + - name: layer + description: With --create-stacked only - the layer currently stacked directly on <parent>, when inserting mid-stack. + description: Modifier for --create-stacked; marks the cut as a mid-stack insert and adds the insert steps to the handoff. - name: --update args: - name: branch @@ -62,7 +76,7 @@ git: - If not configured, protected branches are auto-detected from git-flow and cached to `.sc/shared-settings.yaml` - **Required**: Operations fail if protected branches cannot be determined -If run with no options or `--help`: print a concise list of options (no git status) and prompt with a numbered choice for list/status, create, cleanup, or abort; then gather required inputs. +If run with no options or `--help`: print a concise list of options (no git status) and prompt with a numbered choice for list/status, create, create-stacked, cleanup, or abort; then gather required inputs. ## Behavior @@ -93,6 +107,13 @@ MUST invoke `sc-worktree-scan` and render its `<output_json>` summary and recomm ### --create MUST invoke `sc-worktree-create` with `branch`, `base`, `purpose`, `owner`, and optional tracking inputs. Render the `<output_json>` summary. +### --create-stacked +MUST invoke `sc-worktree-create-stacked` with `branch`, `base` (= `<parent>`), `stack: {"trunk": "<trunk>", "above": "<layer>" | null}`, `purpose`, `owner`, and optional tracking inputs. Never route a stack layer to `sc-worktree-create`: it branches from the local ref. + +Before invoking, when `sc-gh-stack` is installed, run `/sc-gh-stack-view` so `<parent>` is the pushed top (or, for an insert, so `--above` names the real layer above the parent). Without it, the script still verifies the cut from git. + +Render the `<output_json>` summary **and the full `stack_handoff` block verbatim**. Hand `stack_handoff.writer` unchanged to the agent that will work in the worktree, and `stack_handoff.stack_writer` to the stack writer (the one agent that opens PRs and runs gh stack write commands; never the layer's writer). See `skills/sc-git-worktree/references/stack-layers.md`. + ### --update MUST invoke `sc-worktree-update` for protected branches only. Render conflicts or success from `<output_json>`. diff --git a/packages/sc-git-worktree/manifest.yaml b/packages/sc-git-worktree/manifest.yaml index 6a628ea40..f0fca621d 100644 --- a/packages/sc-git-worktree/manifest.yaml +++ b/packages/sc-git-worktree/manifest.yaml @@ -1,5 +1,5 @@ name: sc-git-worktree -version: 0.12.0 +version: 0.14.0 description: > Manage git worktrees with optional tracking and protected branch safeguards: create, scan, cleanup, abort, and update worktrees using a standard sibling folder layout with tracking documents. Prevents accidental deletion of protected branches. @@ -11,6 +11,7 @@ tags: - workflow - agents - branching + - gh-stack # Files to install (relative to package root) artifacts: @@ -18,8 +19,10 @@ artifacts: - commands/sc-git-worktree.md skills: - skills/sc-git-worktree/SKILL.md + - skills/sc-git-worktree/references/stack-layers.md agents: - agents/sc-git-worktree-create.md + - agents/sc-git-worktree-create-stacked.md - agents/sc-git-worktree-scan.md - agents/sc-git-worktree-cleanup.md - agents/sc-git-worktree-abort.md diff --git a/packages/sc-git-worktree/scripts/envelope.py b/packages/sc-git-worktree/scripts/envelope.py index 5840acfb8..bc006d8e9 100644 --- a/packages/sc-git-worktree/scripts/envelope.py +++ b/packages/sc-git-worktree/scripts/envelope.py @@ -246,3 +246,12 @@ class ErrorCodes: # Input errors INPUT_INVALID = "INPUT.INVALID" INPUT_MISSING = "INPUT.MISSING" + + # Stack layers (gh-stack): a layer is a branch cut from a pushed parent head + STACK_LAYER_EXISTS = "STACK.LAYER_EXISTS" + STACK_PARENT_NOT_PUSHED = "STACK.PARENT_NOT_PUSHED" + STACK_PARENT_LANDED = "STACK.PARENT_LANDED" + STACK_PARENT_OFF_TRUNK = "STACK.PARENT_OFF_TRUNK" + STACK_ABOVE_INVALID = "STACK.ABOVE_INVALID" + STACK_PARENT_HAS_CHILD = "STACK.PARENT_HAS_CHILD" + STACK_HAS_CHILDREN = "STACK.HAS_CHILDREN" diff --git a/packages/sc-git-worktree/scripts/worktree_abort.py b/packages/sc-git-worktree/scripts/worktree_abort.py index 1fddc347a..ca5cd6075 100755 --- a/packages/sc-git-worktree/scripts/worktree_abort.py +++ b/packages/sc-git-worktree/scripts/worktree_abort.py @@ -28,6 +28,8 @@ check_remote_branch_exists, delete_local_branch, delete_remote_branch, + find_stack_children, + load_tracking_jsonl, get_default_tracking_path, get_protected_branches, get_repo_root, @@ -42,6 +44,8 @@ check_remote_branch_exists, delete_local_branch, delete_remote_branch, + find_stack_children, + load_tracking_jsonl, get_default_tracking_path, get_protected_branches, get_repo_root, @@ -182,6 +186,35 @@ def abort_worktree_main(input_data: AbortInput) -> Envelope: transcript=transcript, ) + # Stack guard: never delete a layer that live layers were cut from + if input_data.allow_delete_branch and not is_protected and input_data.tracking_enabled: + guard_path = ( + Path(input_data.tracking_path).resolve() + if input_data.tracking_path + else get_default_tracking_path(worktree_base) + ) + stack_children = find_stack_children(load_tracking_jsonl(guard_path), input_data.branch, cwd=repo_root) + if stack_children: + transcript.step_failed( + step="stack guard", + error=f"live stack children: {', '.join(stack_children)}", + ) + return Envelope.error_response( + code=ErrorCodes.STACK_HAS_CHILDREN, + message=( + f"Branch '{input_data.branch}' is the stack parent of live layer(s) " + f"{', '.join(stack_children)}; deleting it breaks their PR base" + ), + recoverable=True, + suggested_action=( + "Abort or clean up the child layers first, or re-run with allow_delete_branch: false " + "to remove only the worktree and keep the branch; a landed parent is cleaned with --cleanup, " + "not aborted; if a child is already gone, run --list to reconcile tracking" + ), + data={"stack_children": stack_children}, + transcript=transcript, + ) + # Remove worktree force_flag = " --force" if input_data.allow_force else "" with transcript.timed_step(f"git worktree remove{force_flag} {worktree_path}") as t: diff --git a/packages/sc-git-worktree/scripts/worktree_cleanup.py b/packages/sc-git-worktree/scripts/worktree_cleanup.py index db5fa989c..178249154 100755 --- a/packages/sc-git-worktree/scripts/worktree_cleanup.py +++ b/packages/sc-git-worktree/scripts/worktree_cleanup.py @@ -30,6 +30,9 @@ count_unique_commits, delete_local_branch, delete_remote_branch, + find_stack_children, + is_landed_by_merge, + rev_parse, get_default_tracking_path, get_protected_branches, get_remote_ahead_count, @@ -54,6 +57,9 @@ count_unique_commits, delete_local_branch, delete_remote_branch, + find_stack_children, + is_landed_by_merge, + rev_parse, get_default_tracking_path, get_protected_branches, get_remote_ahead_count, @@ -158,6 +164,42 @@ def get_all_worktrees(repo_root: Path) -> List[Dict[str, Any]]: # ============================================================================= +def stack_guard( + branch: str, + tracking_entries: List[TrackingEntry], + repo_root: Path, + merge_base: str, +) -> tuple: + """(children, landed): live stack layers sitting on `branch`, and whether git shows + `branch` landed with a merge commit into the children's trunk (origin/<trunk> when it + resolves, else the protected merge base). Cheap: the landing check runs only when + children exist.""" + children = find_stack_children(tracking_entries, branch, cwd=repo_root) + if not children: + return [], False + trunks = { + (e.stack or {}).get("trunk") for e in tracking_entries if e.branch in children and (e.stack or {}).get("trunk") + } + landing_base = merge_base + for trunk in sorted(trunks): + if rev_parse(f"origin/{trunk}", cwd=repo_root): + landing_base = f"origin/{trunk}" + break + return children, is_landed_by_merge(branch, landing_base, cwd=repo_root) + + +def is_merged_by_git(branch: str, merge_base: str, repo_root: Path) -> bool: + """A layer whose head is a merge parent on the base landed; a head parked on the base + line is merely empty.""" + return is_landed_by_merge(branch, merge_base, cwd=repo_root) + + +STACK_GUARD_ACTION = ( + "Land the stack first (gh stack merge) so the merge commit is visible in git, or clean up / abort " + "the child layers first; if a child is already gone, run --list to reconcile tracking" +) + + def cleanup_all_merged(input_data: CleanupInput) -> Envelope: """Clean up all merged worktrees, report dirty/unmerged for follow-up.""" transcript = Transcript() @@ -322,6 +364,7 @@ def cleanup_all_merged(input_data: CleanupInput) -> Envelope: cleaned = [] dirty = [] unmerged = [] + stack_blocked: List[Dict[str, Any]] = [] protected_skipped = [] orphaned_remotes = [] @@ -416,6 +459,32 @@ def cleanup_all_merged(input_data: CleanupInput) -> Envelope: ) continue + # Stack guard: a layer with live children is deleted only when git shows it + # landed with a merge commit (GitHub then retargets the children onto the + # trunk). A fresh layer with no commits yet is never swept either. + stack_children, landed = stack_guard(branch, tracking_entries, repo_root, merge_base) + if stack_children and not landed: + stack_blocked.append({ + "branch": branch, + "path": str(wt_path), + "stack_children": stack_children, + "reason": "stack parent with live child layers and no verifiable landing", + }) + transcript.step_ok( + step="stack guard", + message=f"{branch}: live children {', '.join(stack_children)} - preserved", + ) + continue + if entry.stack and unique_commits == 0 and not is_merged_by_git(branch, merge_base, repo_root): + stack_blocked.append({ + "branch": branch, + "path": str(wt_path), + "stack_children": [], + "reason": "fresh stack layer with no commits yet; push its WIP or abort it explicitly", + }) + transcript.step_ok(step="stack guard", message=f"{branch}: fresh layer, no commits - preserved") + continue + # Clean + merged β†’ auto-cleanup transcript.step_ok( step=f"git -C {wt_path} status --porcelain", @@ -510,6 +579,7 @@ def cleanup_all_merged(input_data: CleanupInput) -> Envelope: "cleaned": cleaned, "dirty": dirty, "unmerged": unmerged, + "stack_blocked": stack_blocked if stack_blocked else None, "orphaned_remotes": orphaned_remotes if orphaned_remotes else None, "protected_skipped": protected_skipped if protected_skipped else None, "removed_directories": removed_dirs if removed_dirs else None, @@ -517,6 +587,7 @@ def cleanup_all_merged(input_data: CleanupInput) -> Envelope: "cleaned": len(cleaned), "dirty": len(dirty), "unmerged": len(unmerged), + "stack_blocked": len(stack_blocked), "orphaned_remotes": len(orphaned_remotes), "protected_skipped": len(protected_skipped), "empty_dirs_removed": len(removed_dirs), @@ -707,6 +778,35 @@ def cleanup_single_branch(input_data: CleanupInput) -> Envelope: transcript=transcript, ) + # Stack guard: the branch is about to be deleted unless protected. A merge commit + # visible in git is safe (GitHub retargets child PRs onto the trunk); a caller + # override (`merged: true`) or an empty layer is not. + if not is_protected and input_data.tracking_enabled: + guard_path = ( + Path(input_data.tracking_path).resolve() + if input_data.tracking_path + else get_default_tracking_path(worktree_base) + ) + stack_children, landed = stack_guard( + input_data.branch, load_tracking_jsonl(guard_path), repo_root, merge_base + ) + if stack_children and not landed: + transcript.step_failed( + step="stack guard", + error=f"live stack children: {', '.join(stack_children)}", + ) + return Envelope.error_response( + code=ErrorCodes.STACK_HAS_CHILDREN, + message=( + f"Branch '{input_data.branch}' is the stack parent of live layer(s) " + f"{', '.join(stack_children)} and is not verifiably landed; deleting it breaks their PR base" + ), + recoverable=True, + suggested_action=STACK_GUARD_ACTION, + data={"stack_children": stack_children}, + transcript=transcript, + ) + # Remove worktree force = not input_data.require_clean force_flag = " --force" if force else "" diff --git a/packages/sc-git-worktree/scripts/worktree_create.py b/packages/sc-git-worktree/scripts/worktree_create.py index fe9b7ee03..682397421 100755 --- a/packages/sc-git-worktree/scripts/worktree_create.py +++ b/packages/sc-git-worktree/scripts/worktree_create.py @@ -17,9 +17,17 @@ "repo_root": "/path/to/repo", # optional, defaults to cwd "tracking_enabled": true, # optional, defaults to true "worktree_base": null, # optional, derived from repo name - "tracking_path": null # optional, derived from worktree_base + "tracking_path": null, # optional, derived from worktree_base + "stack": { # optional: cut a gh-stack layer from a PUSHED parent + "trunk": "develop", # the branch the stack's bottom PR targets + "above": null # layer currently stacked on `base` (mid-stack insert only) + } } +Stack mode (`stack` present) cuts `branch` from `origin/<base>` with --no-track, +records the parent SHA, and returns a `stack_handoff` block for the worktree's +writer. Plain mode branches from the local base ref when one exists. + Exit Codes: 0: Worktree created successfully 1: Error during creation @@ -27,6 +35,7 @@ import json import os +import shlex import subprocess import sys from datetime import datetime, timezone @@ -45,9 +54,13 @@ check_branch_exists_remote, check_remote_branch_exists, create_tracking_branch, + find_stack_children, get_default_tracking_path, get_repo_root, get_worktree_status, + is_ancestor, + load_tracking_jsonl, + rev_parse, run_git, ) except ImportError: @@ -59,9 +72,13 @@ check_branch_exists_remote, check_remote_branch_exists, create_tracking_branch, + find_stack_children, get_default_tracking_path, get_repo_root, get_worktree_status, + is_ancestor, + load_tracking_jsonl, + rev_parse, run_git, ) @@ -71,6 +88,31 @@ # ============================================================================= +class StackInput(BaseModel): + """Stack-layer inputs: the worktree is a gh-stack layer cut from a pushed parent (`base`).""" + + trunk: str = Field(..., description="Stack trunk: the branch the bottom PR of the stack targets") + above: Optional[str] = Field( + None, + description="Layer currently stacked directly on `base`; set only when inserting mid-stack", + ) + + @field_validator("trunk") + @classmethod + def validate_trunk(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("stack.trunk cannot be empty") + return v.strip() + + @field_validator("above") + @classmethod + def validate_above(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + v = v.strip() + return v or None + + class CreateInput(BaseModel): """Input schema for worktree creation.""" @@ -82,6 +124,7 @@ class CreateInput(BaseModel): tracking_enabled: bool = Field(True, description="Whether to update tracking doc") worktree_base: Optional[str] = Field(None, description="Base directory for worktrees") tracking_path: Optional[str] = Field(None, description="Path to tracking document") + stack: Optional[StackInput] = Field(None, description="Present when the worktree is a gh-stack layer") @field_validator("branch") @classmethod @@ -107,6 +150,254 @@ def validate_base(cls, v: str) -> str: +# ============================================================================= +# Stack layers (gh-stack) +# ============================================================================= + + +def check_stack_preconditions( + input_data: CreateInput, + repo_root: Path, + transcript: Transcript, + *, + branch_exists_local: bool, + branch_exists_remote: bool, + tracking_entries: Optional[list] = None, +) -> tuple: + """Validate a stack cut. Returns (stack_info, None) or (None, error Envelope). + + Rules (from the gh-stack field playbook): a layer is always a new branch cut + from its parent's PUSHED head; the parent sits on the trunk and is not already + landed; when inserting, the layer above must actually be stacked on the parent. + """ + stack = input_data.stack + base = input_data.base + trunk = stack.trunk + above = stack.above + + def fail(code: str, message: str, action: str, **data: Any) -> tuple: + transcript.step_failed(step="stack preconditions", error=message) + return None, Envelope.error_response( + code=code, + message=message, + recoverable=True, + suggested_action=action, + data=data or None, + transcript=transcript, + ) + + if branch_exists_local or branch_exists_remote: + return fail( + ErrorCodes.STACK_LAYER_EXISTS, + f"Branch '{input_data.branch}' already exists; a stack layer is always a new branch cut from its parent's pushed head", + "Choose a new layer name, or omit `stack` to open a worktree on the existing branch", + ) + + parent_sha = rev_parse(f"origin/{base}", cwd=repo_root) + if not parent_sha: + return fail( + ErrorCodes.STACK_PARENT_NOT_PUSHED, + f"Stack parent '{base}' is not on origin; layers are cut from pushed heads only", + f"Push the parent first (git push -u origin {base}) and retry", + ) + + trunk_sha = rev_parse(f"origin/{trunk}", cwd=repo_root) + if not trunk_sha: + return fail( + ErrorCodes.BRANCH_NOT_FOUND, + f"Stack trunk '{trunk}' not found on origin", + "Pass the branch the stack's bottom PR targets (for example develop) as stack.trunk", + ) + + if above is not None and above in (base, input_data.branch): + return fail( + ErrorCodes.STACK_ABOVE_INVALID, + f"stack.above '{above}' must name the layer currently stacked on '{base}', not the parent or the new layer", + "Read the layer above the parent from /sc-gh-stack-view and pass that name", + ) + + parent_behind_trunk = 0 + if base != trunk: + if is_ancestor(f"origin/{base}", f"origin/{trunk}", cwd=repo_root): + return fail( + ErrorCodes.STACK_PARENT_LANDED, + f"Stack parent '{base}' is already contained in trunk '{trunk}'; cutting from it would start the layer behind the trunk", + f"Cut from '{trunk}' (bottom layer) or from the current top shown by /sc-gh-stack-view", + ) + mb = run_git(["merge-base", f"origin/{trunk}", f"origin/{base}"], cwd=repo_root, check=False) + if mb.returncode != 0: + return fail( + ErrorCodes.STACK_PARENT_OFF_TRUNK, + f"origin/{base} and origin/{trunk} share no history", + "Check stack.trunk; the parent must be a layer stacked on that trunk", + ) + if not is_ancestor(f"origin/{trunk}", f"origin/{base}", cwd=repo_root): + behind = run_git(["rev-list", "--count", f"origin/{base}..origin/{trunk}"], cwd=repo_root, check=False) + parent_behind_trunk = int(behind.stdout.strip() or 0) if behind.returncode == 0 else -1 + transcript.step_ok( + step=f"git merge-base --is-ancestor origin/{trunk} origin/{base}", + message=f"parent behind trunk by {parent_behind_trunk} commit(s); allowed (stack bottom note, not a blocker)", + ) + + if above is not None: + above_sha = rev_parse(f"origin/{above}", cwd=repo_root) + if not above_sha: + return fail( + ErrorCodes.STACK_ABOVE_INVALID, + f"stack.above '{above}' is not on origin", + "Pass the pushed layer that is stacked on the parent, or omit `above` when cutting from the top", + ) + if not is_ancestor(f"origin/{base}", f"origin/{above}", cwd=repo_root): + return fail( + ErrorCodes.STACK_ABOVE_INVALID, + f"origin/{above} does not contain origin/{base}, so it is not stacked on '{base}'", + "Read the real layer above the parent from /sc-gh-stack-view, or omit `above`", + ) + + if above is None and tracking_entries: + # Appending: the parent must be the top. A live layer already on it means this cut + # would fork the stack (two layers sharing a parent), which cannot be linked linearly. + existing = find_stack_children(tracking_entries, base, cwd=repo_root) + if existing: + return fail( + ErrorCodes.STACK_PARENT_HAS_CHILD, + f"'{base}' already has live layer(s) on it: {', '.join(existing)}; cutting another from it would fork the stack", + f"Cut from the current top ({existing[-1]}) instead, or pass stack.above = '{existing[-1]}' to insert under it", + existing_children=existing, + ) + + local_base_sha = rev_parse(base, cwd=repo_root) + if local_base_sha and local_base_sha != parent_sha: + transcript.step_ok( + step=f"git rev-parse {base}", + message=f"local {base} ({local_base_sha[:8]}) differs from origin ({parent_sha[:8]}); cutting from origin", + ) + + transcript.step_ok( + step=f"git rev-parse origin/{base}", + message=f"parent_sha={parent_sha}", + ) + + stack_info = { + "trunk": trunk, + "parent": base, + "parent_sha": parent_sha, + "above": above, + "position": "insert" if above else "top", + "parent_behind_trunk": parent_behind_trunk, + } + return stack_info, None + + +def build_stack_handoff( + branch: str, + purpose: str, + worktree_path: Path, + stack_info: Dict[str, Any], + repo_root: Path, +) -> Dict[str, Any]: + """The information needed to stack this layer, split by role. + + `writer` is for the single agent that works in the worktree; `stack_writer` is for + the one agent that runs gh stack write commands and opens PRs (never the layer's + writer). Everything derives from the gh-stack field playbook (sc-gh-stack). + Angle-bracket placeholders are read from /sc-gh-stack-view or gh output. + """ + trunk = stack_info["trunk"] + parent = stack_info["parent"] + sha = stack_info["parent_sha"] + above = stack_info.get("above") + chain_check = repo_root / ".claude" / "scripts" / "gh_stack_chain_check.py" + chain_check_available = chain_check.exists() + wt = shlex.quote(str(worktree_path)) + bottom = parent == trunk + # Layers below the parent, as PR-number and branch-name placeholders. A layer cut + # from the trunk is the stack bottom: nothing sits below it. + lower_prs = "" if bottom else f"<bottom-pr#> ... <pr# of {parent}> " + lower_names = "" if bottom else f"<bottom> ... {parent} " + + pr_base_rule = f"The PR base is {parent}" + ("" if bottom else f", never {trunk}") + "." + writer_rules = [ + f"You are the only writer of {branch}. Never edit, rebase, or force-push any layer below it.", + f"Make a WIP commit within minutes and push it; the stack writer opens the PR and links it on that first push, never 'once it is green'. {pr_base_rule}", + ( + f"Rebase at most once per task, at the start, only while this layer has no children: " + f"git fetch origin && git rebase --onto origin/{parent} {sha} {branch} && git push --force-with-lease. " + f"Between tasks the layer does not move." + ), + f"Never run gh stack write commands (link, unstack, sync, rebase, merge) or open PRs from this worktree; report to the stack writer.", + f"The next layer is cut from origin/{branch} after it is pushed, never from a local ref.", + ] + writer_commands = [ + f"git -C {wt} commit --allow-empty -m {shlex.quote('wip: ' + purpose[:60])} # first WIP commit now; a PR needs at least one", + f"git -C {wt} push -u origin {branch}", + ] + + pr_body = f"Parent: {parent} @ {sha}\nTask: {purpose}\nFence: <paths this layer may touch>" + pr_create = ( + f"gh pr create --base {parent} --head {branch} --title \"<title>\" --body \"$(cat <<'B'\n" + f"Parent: {parent} @ {sha}\nTask: {purpose}\nFence: <paths this layer may touch>\nB\n)\"" + ) + stack_writer_commands = [ + f"# after the writer's first push of {branch}:", + pr_create, + f"cd {wt} && gh stack checkout <stack#> # import tracking into this new worktree; skip only when no stack exists yet", + ] + if above: + stack_writer_commands += [ + f"# every layer above {parent}, bottom to top, carries the layer below it forward with ONE merge commit each", + f"# (never a rebase, never a force-push), starting with the writer of {above}:", + f"git -C <worktree of {above}> fetch origin && git -C <worktree of {above}> merge --no-ff origin/{branch} && git -C <worktree of {above}> push", + f"# then the layer above {above} merges origin/{above}, and so on up to the top", + "gh stack unstack <stack#> # PRs and branches untouched; confirm with the user first", + f"gh pr edit <pr# of {above}> --base {branch}", + ] + if chain_check_available: + stack_writer_commands.append( + f"python3 .claude/scripts/gh_stack_chain_check.py --trunk {trunk} {lower_names}{branch} {above} ... <top> # must print LINKABLE" + ) + stack_writer_commands += [ + f"gh stack link --base {trunk} {lower_prs}<pr# of {branch}> <pr# of {above}> ... <top-pr#>", + "# every other stack worktree: gh stack unstack --local && gh stack checkout <new stack#>", + "/sc-gh-stack-view", + ] + else: + if chain_check_available: + stack_writer_commands.append( + f"python3 .claude/scripts/gh_stack_chain_check.py --trunk {trunk} {lower_names}{branch} # must print LINKABLE" + ) + stack_writer_commands += [ + f"gh stack link <stack#> <pr# of {branch}> # append: only if {parent} is the top row in /sc-gh-stack-view", + f"gh stack link --base {trunk} {lower_prs}<pr# of {branch}> # first link, or full relink", + "/sc-gh-stack-view", + ] + + return { + "position": stack_info["position"], + "trunk": trunk, + "parent": parent, + "parent_sha": sha, + "above": above, + "pr_base": parent, + "pr_body": pr_body, + "writer": { + "audience": f"the single agent working in {worktree_path}", + "rules": writer_rules, + "commands": writer_commands, + }, + "stack_writer": { + "audience": "the one agent that opens PRs and runs gh stack write commands (not the layer's writer)", + "commands": stack_writer_commands, + }, + "chain_check_available": chain_check_available, + "reference": ( + "sc-gh-stack references/recipe-restack.md section 1 (insert)" + if above + else "sc-gh-stack references/recipe-link.md section A (first push)" + ), + } + + # ============================================================================= # Main Logic # ============================================================================= @@ -237,6 +528,20 @@ def create_worktree_main(input_data: CreateInput) -> Envelope: message=f"local={branch_exists_local} remote={branch_exists_remote}", ) + # Stack layer: validate the cut before touching anything + stack_info: Optional[Dict[str, Any]] = None + if input_data.stack is not None: + stack_info, stack_error = check_stack_preconditions( + input_data, + repo_root, + transcript, + branch_exists_local=branch_exists_local, + branch_exists_remote=branch_exists_remote, + tracking_entries=load_tracking_jsonl(tracking_path) if tracking_path else None, + ) + if stack_error is not None: + return stack_error + # Determine creation strategy if branch_exists_local: # Branch exists locally, just add worktree @@ -265,7 +570,14 @@ def create_worktree_main(input_data: CreateInput) -> Envelope: else: # New branch, create from base # Determine the actual base ref to use (local or remote) - if base_exists_local: + if stack_info is not None: + # Stack layers are always cut from the parent's PUSHED head + base_ref = f"origin/{input_data.base}" + transcript.step_ok( + step="resolve base", + message=f"stack layer: cutting from pushed head {base_ref}", + ) + elif base_exists_local: base_ref = input_data.base elif base_exists_remote: base_ref = f"origin/{input_data.base}" @@ -277,9 +589,15 @@ def create_worktree_main(input_data: CreateInput) -> Envelope: # Neither local nor remote base exists - error handled earlier base_ref = input_data.base - git_cmd = f"git worktree add -b {input_data.branch} {worktree_path} {base_ref}" + # A branch started from a remote-tracking ref must not inherit it as + # upstream: otherwise `git push` / `--force-with-lease` target the base. + add_args = ["worktree", "add"] + if base_ref.startswith("origin/"): + add_args.append("--no-track") + add_args += ["-b", input_data.branch, str(worktree_path), base_ref] + git_cmd = "git " + " ".join(add_args) with transcript.timed_step(git_cmd) as t: - run_git(["worktree", "add", "-b", input_data.branch, str(worktree_path), base_ref], cwd=repo_root) + run_git(add_args, cwd=repo_root) t.message = f"Preparing worktree ({worktree_path})" needs_new_branch = True @@ -316,6 +634,11 @@ def create_worktree_main(input_data: CreateInput) -> Envelope: remote_exists=remote_exists, local_worktree=True, remote_ahead=0, # Just created, local is up to date + stack=( + {k: stack_info[k] for k in ("trunk", "parent", "parent_sha", "above", "position")} + if stack_info + else None + ), ) # Update tracking document (JSONL) @@ -331,20 +654,23 @@ def create_worktree_main(input_data: CreateInput) -> Envelope: transcript.step_skipped(step="update_tracking", message="disabled") # Build response - return Envelope.success_response( - data={ - "action": "create", - "branch": input_data.branch, - "base": input_data.base, - "path": str(worktree_path), - "repo_name": repo_name, - "status": "clean", - "branch_created": needs_new_branch, - "tracking_entry": tracking_entry.model_dump(), - "tracking_updated": tracking_updated, - }, - transcript=transcript, - ) + data: Dict[str, Any] = { + "action": "create", + "branch": input_data.branch, + "base": input_data.base, + "path": str(worktree_path), + "repo_name": repo_name, + "status": "clean", + "branch_created": needs_new_branch, + "tracking_entry": tracking_entry.model_dump(), + "tracking_updated": tracking_updated, + } + if stack_info is not None: + data["stack"] = tracking_entry.stack + data["stack_handoff"] = build_stack_handoff( + input_data.branch, input_data.purpose, worktree_path, stack_info, repo_root + ) + return Envelope.success_response(data=data, transcript=transcript) except subprocess.CalledProcessError as e: cmd = " ".join(e.cmd) if isinstance(e.cmd, list) else str(e.cmd) diff --git a/packages/sc-git-worktree/scripts/worktree_scan.py b/packages/sc-git-worktree/scripts/worktree_scan.py index 743f826f2..8846924f2 100644 --- a/packages/sc-git-worktree/scripts/worktree_scan.py +++ b/packages/sc-git-worktree/scripts/worktree_scan.py @@ -32,6 +32,8 @@ get_protected_branches, get_repo_name, get_repo_root, + is_ancestor, + rev_parse, load_tracking_jsonl, reconcile_tracking, run_git, @@ -44,6 +46,8 @@ get_protected_branches, get_repo_name, get_repo_root, + is_ancestor, + rev_parse, load_tracking_jsonl, reconcile_tracking, run_git, @@ -255,6 +259,34 @@ def batch_get_worktree_statuses(worktrees: List[WorktreeInfo]) -> Dict[str, Tupl # ============================================================================= +def stack_issues(stack_meta: Optional[Dict[str, Any]], repo_root: Path, layer: Optional[str] = None) -> List[str]: + """Issues for a stack layer: parent landed (gone or merged into trunk) or parent advanced. + + "Advanced" means the layer no longer contains the parent's pushed head; it clears by + itself after the writer's one rebase or a merge-forward, without touching tracking. + """ + if not stack_meta: + return [] + parent = stack_meta.get("parent") + trunk = stack_meta.get("trunk") + recorded = stack_meta.get("parent_sha") or "" + if not parent or not trunk: + return [] + if parent == trunk: + # Bottom layer: the trunk moving is expected and is not a rebase trigger + return [] + current = rev_parse(f"origin/{parent}", cwd=repo_root) + if current is None: + return [f"stack_parent_landed: origin/{parent} no longer exists"] + if is_ancestor(f"origin/{parent}", f"origin/{trunk}", cwd=repo_root): + return [f"stack_parent_landed: {parent} is contained in {trunk}"] + if layer: + head_ref = f"origin/{layer}" if rev_parse(f"origin/{layer}", cwd=repo_root) else layer + if not is_ancestor(f"origin/{parent}", head_ref, cwd=repo_root): + return [f"stack_parent_advanced: origin/{parent} {recorded[:8]} -> {current[:8]}, not contained in {head_ref}"] + return [] + + def scan_worktrees( worktree_base: Optional[str] = None, tracking_enabled: bool = True, @@ -440,6 +472,7 @@ def scan_worktrees( # Find matching tracking entry tracking_entry_data = None remote_ahead = 0 + stack_meta = None for entry in tracking_entries: if entry.branch == wt.branch: tracking_entry_data = { @@ -455,8 +488,10 @@ def scan_worktrees( "remote_exists": entry.remote_exists, "local_worktree": entry.local_worktree, "remote_ahead": entry.remote_ahead, + "stack": entry.stack, } remote_ahead = entry.remote_ahead + stack_meta = entry.stack break issues = [] @@ -470,6 +505,7 @@ def scan_worktrees( issues.append(f"prunable: {wt.prunable_reason or 'worktree may be stale'}") if remote_ahead > 0: issues.append(f"remote_ahead: {remote_ahead} commit(s)") + issues.extend(stack_issues(stack_meta, repo_root, layer=wt.branch)) worktree_results.append({ "branch": wt.branch, @@ -506,6 +542,24 @@ def scan_worktrees( if remote_ahead_count > 0: recommendations.append(f"pull changes in {remote_ahead_count} branch(es) where remote is ahead") + # Stack layers whose parent moved or landed + parent_advanced = sum( + 1 for wt in worktree_results if any(i.startswith("stack_parent_advanced") for i in (wt["issues"] or [])) + ) + parent_landed = sum( + 1 for wt in worktree_results if any(i.startswith("stack_parent_landed") for i in (wt["issues"] or [])) + ) + if parent_advanced: + recommendations.append( + f"{parent_advanced} stack layer(s) whose parent advanced: the layer's writer rebases once at task start " + f"(git rebase --onto origin/<parent> <parent_sha> <layer>) only while it has no children" + ) + if parent_landed: + recommendations.append( + f"{parent_landed} stack layer(s) whose parent landed: confirm the PR now targets the trunk (/sc-gh-stack-view); " + f"before touching the layer, git diff --stat HEAD origin/<layer> and reset only if empty; never force-push over GitHub's retarget" + ) + return Envelope.success_response( data={ "action": "scan", diff --git a/packages/sc-git-worktree/scripts/worktree_shared.py b/packages/sc-git-worktree/scripts/worktree_shared.py index 0a75eee0f..4d179f1a3 100644 --- a/packages/sc-git-worktree/scripts/worktree_shared.py +++ b/packages/sc-git-worktree/scripts/worktree_shared.py @@ -412,6 +412,44 @@ def resolve_merge_base( return None +def is_ancestor(ancestor: str, descendant: str, cwd: Optional[Path] = None) -> bool: + """True when `ancestor` is reachable from `descendant` (git merge-base --is-ancestor). + + Returns False for unknown refs or unrelated histories instead of raising. + """ + result = run_git(["merge-base", "--is-ancestor", ancestor, descendant], cwd=cwd, check=False) + return result.returncode == 0 + + +def rev_parse(ref: str, cwd: Optional[Path] = None) -> Optional[str]: + """Full SHA of `ref`, or None when it does not resolve.""" + result = run_git(["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], cwd=cwd, check=False) + if result.returncode != 0: + return None + sha = result.stdout.strip() + return sha or None + + +def is_landed_by_merge(branch: str, base: str, cwd: Optional[Path] = None, limit: int = 5000) -> bool: + """True when `branch` was merged into `base` with a merge commit. + + A layer landed with `--merge` is reachable from the base but sits off its + first-parent line; an empty branch parked on a base commit is on that line. + Only the last `limit` first-parent commits of the base are inspected. + """ + head = rev_parse(branch, cwd=cwd) + if not head or not is_ancestor(head, base, cwd=cwd): + return False + result = run_git(["rev-list", "--first-parent", f"-n{limit}", base], cwd=cwd, check=False) + if result.returncode != 0: + return False + line = result.stdout.split() + if head in line: + return False + # Window exhausted without seeing the head: cannot tell, fail closed + return len(line) < limit + + def check_branch_exists_local(branch: str, cwd: Optional[Path] = None) -> bool: """Check if a branch exists locally.""" result = run_git(["branch", "--list", branch], cwd=cwd, check=False) @@ -465,7 +503,8 @@ def is_branch_merged(branch: str, base: str = "HEAD", cwd: Optional[Path] = None result = run_git(["branch", "--merged", base], cwd=cwd, check=False) if result.returncode != 0: return False - merged_branches = [b.strip().lstrip("* ") for b in result.stdout.strip().split("\n")] + # "* " marks the current branch, "+ " a branch checked out in another worktree + merged_branches = [b.strip().lstrip("*+ ") for b in result.stdout.strip().split("\n")] return branch in merged_branches @@ -823,6 +862,46 @@ class TrackingEntry(BaseModel): remote_exists: bool = Field(False, description="Whether branch exists on remote") local_worktree: bool = Field(True, description="Whether local worktree exists") remote_ahead: int = Field(0, description="Number of commits remote has that local doesn't") + # Stack layer metadata (gh-stack). None for ordinary worktrees. Keys: + # trunk, parent, parent_sha (origin/<parent> at cut time), above (insert only), position + stack: Optional[Dict[str, Any]] = Field(None, description="Stack layer metadata when the worktree is a gh-stack layer") + + +def _entry_is_live(entry: "TrackingEntry", cwd: Optional[Path] = None) -> bool: + """Liveness checked against git, not the stored flags (which only scan refreshes).""" + if entry.local_worktree and Path(entry.path).exists(): + return True + return check_remote_branch_exists(entry.branch, cwd=cwd) + + +def find_stack_children(entries: list["TrackingEntry"], branch: str, cwd: Optional[Path] = None) -> list[str]: + """Return live stack layers that sit on `branch`: layers cut from it, plus the layer + `branch` was inserted under (its own row's `stack.above`). + + A child is live while its worktree directory or its remote branch exists. Deleting a + parent under a live child breaks the stack chain (the child PR loses its base). + """ + by_name = {e.branch: e for e in entries} + candidates: list[str] = [] + for entry in entries: + stack = entry.stack or {} + if stack.get("parent") == branch and entry.branch not in candidates: + candidates.append(entry.branch) + own = by_name.get(branch) + above = (own.stack or {}).get("above") if own else None + if above and above not in candidates: + candidates.append(above) + children = [] + for name in candidates: + entry = by_name.get(name) + if entry is None: + # Inserted-under layer without a tracking row: assume live (fail closed) + if check_remote_branch_exists(name, cwd=cwd) or run_git(["rev-parse", "--verify", "--quiet", name], cwd=cwd, check=False).returncode == 0: + children.append(name) + continue + if _entry_is_live(entry, cwd=cwd): + children.append(name) + return children def get_default_tracking_path(worktree_base: Path) -> Path: diff --git a/packages/sc-git-worktree/skills/sc-git-worktree/SKILL.md b/packages/sc-git-worktree/skills/sc-git-worktree/SKILL.md index 8f6fa124c..4d61a90a2 100644 --- a/packages/sc-git-worktree/skills/sc-git-worktree/SKILL.md +++ b/packages/sc-git-worktree/skills/sc-git-worktree/SKILL.md @@ -3,8 +3,9 @@ name: sc-managing-worktrees description: Create, manage, scan, update, and clean up git worktrees for parallel development with protected branch safeguards. Use when working on multiple branches simultaneously, isolating experiments, updating protected branches (main/develop), - or when user mentions "worktree", "parallel branches", "feature isolation", "branch cleanup", "worktree status", or "update main/develop". -version: 0.12.0 + cutting a gh-stack layer as a worktree (top or mid-stack insert), or when user mentions "worktree", "parallel branches", + "feature isolation", "branch cleanup", "worktree status", "update main/develop", "stack layer worktree", or "stacked worktree". +version: 0.14.0 entry_point: /sc-git-worktree --- @@ -37,6 +38,7 @@ $INPUT_JSON | Operation | Agent | Returns | |-----------|-------|---------| | Create | `sc-worktree-create` | JSON: success, path, branch, tracking_entry | +| Create stack layer | `sc-worktree-create-stacked` | JSON: success, path, branch, stack, stack_handoff | | Scan | `sc-worktree-scan` | JSON: success, worktrees list, recommendations | | Cleanup | `sc-worktree-cleanup` | JSON: success, branch_deleted, tracking_update | | Abort | `sc-worktree-abort` | JSON: success, worktree_removed, tracking_update | @@ -44,6 +46,18 @@ $INPUT_JSON To invoke an agent, use the Task tool with the agent prompt and pass parameters exactly as documented in the agent Inputs section. +**Routing rule:** a worktree that is a gh-stack layer (the user says "stack", "layer", "on top of <branch>", "insert under", or the branch will be linked with `gh stack`) goes to `sc-worktree-create-stacked`. Everything else goes to `sc-worktree-create`. + +## Stack Layers (gh-stack) + +A stack layer is a worktree whose branch will be a PR in a `gh stack`. The plain create is wrong for it: it branches from the **local** base ref (possibly stale or another writer's unpushed state) and, from a remote ref, would leave the parent as upstream. `sc-worktree-create-stacked` cuts from `origin/<parent>` with `--no-track`, refuses bad cuts before touching anything (parent not pushed, already landed, insert target not stacked on the parent), records the parent SHA in tracking, and returns a `stack_handoff` block. + +- `stack_handoff.writer` goes verbatim to the agent that will work in the worktree (WIP commit, first push with `-u`, at most one rebase at task start, no edits to lower layers, no gh stack write commands). `stack_handoff.stack_writer` goes to the stack writer only (PR with base = parent, `gh stack checkout` in the new worktree, link on top, or the insert sequence: merge-forward by every layer above, unstack, `gh pr edit --base`, full relink). +- Cleanup refuses to delete a branch that live layers sit on unless git shows a merge-commit landing in the trunk (`STACK.HAS_CHILDREN`; batch cleanup reports `stack_blocked`, and never sweeps a fresh layer with no commits). Abort always refuses. Both read the tracking file. +- Scan reports `stack_parent_advanced` (layer no longer contains the parent's head) and `stack_parent_landed` (PR should now target the trunk) on layer rows. + +Details and the handoff contract: `references/stack-layers.md`. The stack model, recipes and the view tool live in the `sc-gh-stack` package (`/sc-gh-stack`, `/sc-gh-stack-view`); this skill only makes the worktree side of that model safe. + ## Standards and Paths - Repo root: current directory. - Default worktree base: `../{{REPO_NAME}}-worktrees`. diff --git a/packages/sc-git-worktree/skills/sc-git-worktree/references/stack-layers.md b/packages/sc-git-worktree/skills/sc-git-worktree/references/stack-layers.md new file mode 100644 index 000000000..c2a292867 --- /dev/null +++ b/packages/sc-git-worktree/skills/sc-git-worktree/references/stack-layers.md @@ -0,0 +1,82 @@ +# Stack layers: what the worktree skill guarantees and what the writer must do + +Read this when a worktree is a gh-stack layer. The stack model itself (append-only +linear stack, frozen layers, one writer per branch, one stack writer) is documented +in the `sc-gh-stack` package; this file covers only the worktree side. + +## Why a layer needs its own create path + +| Plain `--create <branch> <base>` | `--create-stacked <branch> <parent> <trunk>` | +|---|---| +| Branches from the **local** `<base>` ref when one exists | Always cuts from `origin/<parent>` after a fetch | +| Local ref may be stale or another writer's unpushed state | The pushed head is the only truth for a stack | +| No record of where the branch started | Records `parent_sha` in tracking and in the handoff | +| No check that `<base>` is a valid parent | Refuses: parent not pushed, parent already landed, parent off the trunk, bad insert target, branch already exists | +| Any remote-ref base is created with `--no-track` (fixed in 0.14.0) | Same; the layer never has the parent as upstream | + +All refusals happen before any worktree or branch is created and return a +`suggested_action`. + +## Inputs + +- `branch`: new layer name. Must not exist locally or on origin. +- `parent` (`base` in JSON): the branch to cut from. + - Append on top: the current top from `/sc-gh-stack-view`. + - Bottom of a new stack: the trunk itself. + - Insert: the layer *below* the insertion point, plus `--above <layer>` naming the layer currently stacked on it. +- `trunk` (`stack.trunk`): the branch the stack's bottom PR targets. +- `purpose`, `owner`: the writer's task id and handle. + +## Output the caller must forward + +`data.stack` is stored in the tracking row: + +```json +{"trunk": "develop", "parent": "sprint-6", "parent_sha": "<40-char sha>", "above": null, "position": "top"} +``` + +`data.stack_handoff` has two role blocks. Paste `writer` into the prompt of the +agent that will work in the worktree and `stack_writer` into the stack writer's +prompt (the one agent that opens PRs and runs gh stack write commands; layer +writers never do). `commands[]` are ordered; angle-bracket placeholders are +filled from `gh pr create` output and `/sc-gh-stack-view`. + +## Writer contract (`stack_handoff.writer`) + +1. You are the only writer of the layer. Never edit, rebase or force-push any layer below it. +2. Make a WIP commit within minutes and push it: `git push -u origin <layer>`. A PR needs at least one commit. The stack writer opens the PR with `--base <parent>` (the trunk only when the parent *is* the trunk) and links it on that first push, never "once it is green". +3. Rebase at most once per task, at the start, only while the layer has no children: `git rebase --onto origin/<parent> <parent_sha> <layer>` then `git push --force-with-lease`. Between tasks the layer does not move. +4. Never run gh stack write commands (link, unstack, sync, rebase, merge) or open PRs from the worktree; report to the stack writer. +5. The next layer is cut from `origin/<layer>` once it is pushed, never from a local ref. + +## Stack writer sequence (`stack_writer` block) + +Append on top (`position: top`; the parent must be the top row of `/sc-gh-stack-view`, otherwise the create refuses with `STACK.PARENT_HAS_CHILD`): + +1. After the writer's first push: `gh pr create --base <parent> --head <layer>` with the body `Parent: <parent> @ <parent_sha>` (quoted heredoc; the handoff renders it). +2. `cd <worktree> && gh stack checkout <stack#>` so the new worktree carries tracking before any write command; skip only when no stack exists yet. +3. Chain check when `sc-gh-stack` is installed, then `gh stack link <stack#> <pr#>` to append, or the full `gh stack link --base <trunk> <ordered pr#s>`. PR numbers, not branch names. +4. `/sc-gh-stack-view`. + +Insert mid-stack (`position: insert`; `gh stack link` cannot insert): + +1. PR with base `<parent>` and `gh stack checkout` as above. +2. **Every** layer above the insertion point, bottom to top, carries the layer below it forward with one merge commit each (`git merge --no-ff origin/<layer-below>` then push), starting with the writer of `<above>` merging the new layer. Never a rebase, never a force-push; otherwise some layer stops containing its parent and the stack cannot land. +3. `gh stack unstack <stack#>` (PRs and branches untouched; confirm with the user, it is outward-facing). +4. `gh pr edit <pr# of above> --base <layer>`. +5. Chain check, then the full `gh stack link --base <trunk> ...` with the new layer in place. +6. Every other stack worktree: `gh stack unstack --local && gh stack checkout <new stack#>`; then `/sc-gh-stack-view`. + +## What the other operations do with layers + +- **Cleanup**: a branch with live child layers (layers cut from it, or the layer it was inserted under; live = worktree directory or remote branch exists) is deleted only when git shows it landed with a merge commit into the children's trunk (`origin/<trunk>`). Error `STACK.HAS_CHILDREN` names the children; batch cleanup lists them under `stack_blocked`, together with fresh layers that have no commits yet (never swept). A landed parent is safe to delete: GitHub retargets the children onto the trunk. A fast-forward landing is indistinguishable from an empty branch and fails closed. +- **Abort**: with `allow_delete_branch`, a branch with live children is always refused (`STACK.HAS_CHILDREN`); a landed parent is cleaned with `--cleanup`, not aborted. +- Both guards read the tracking file: with `tracking_enabled: false` they are off. If a child is already gone but still listed, run `--list` to reconcile. +- **Scan**: layer rows carry `tracking_entry.stack` and may report `stack_parent_advanced` (the layer no longer contains the parent's pushed head; the writer's one rebase or a merge-forward clears it) or `stack_parent_landed` (the PR should now target the trunk; confirm with `/sc-gh-stack-view`, diff before any reset, never force-push over GitHub's retarget). The trunk moving under a bottom layer is not reported; it is expected. +- **Update**: unchanged; trunks are protected branches and are updated with `--update`. + +## Not done here + +Pushing, opening PRs, linking, unstacking and merging are the writer's and the +stack writer's jobs, in the worktree, with `sc-gh-stack`. This skill never runs +`gh`. diff --git a/packages/sc-git-worktree/tests/__init__.py b/packages/sc-git-worktree/tests/__init__.py deleted file mode 100644 index 6a8b7b558..000000000 --- a/packages/sc-git-worktree/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# sc-git-worktree tests diff --git a/packages/sc-git-worktree/tests/test_git_operations.py b/packages/sc-git-worktree/tests/test_git_operations.py index 141015437..494bc7a29 100644 --- a/packages/sc-git-worktree/tests/test_git_operations.py +++ b/packages/sc-git-worktree/tests/test_git_operations.py @@ -215,6 +215,16 @@ def test_current_branch_marker(self, mock_run_git): assert result == True + @patch('worktree_shared.run_git') + def test_worktree_branch_marker(self, mock_run_git): + """Branches checked out in another worktree are listed with a '+' marker.""" + mock_run_git.return_value = MagicMock( + returncode=0, + stdout="* develop\n+ feature/in-worktree\n other-branch\n" + ) + + assert is_branch_merged("feature/in-worktree") == True + @patch('worktree_shared.run_git') def test_git_error(self, mock_run_git): """Test handling of git errors.""" diff --git a/packages/sc-git-worktree/tests/test_stack_aware.py b/packages/sc-git-worktree/tests/test_stack_aware.py new file mode 100644 index 000000000..c71e61116 --- /dev/null +++ b/packages/sc-git-worktree/tests/test_stack_aware.py @@ -0,0 +1,500 @@ +"""Stack-aware behaviour: gh-stack layer worktrees. + +Real git repositories (a bare origin plus a clone) exercise the create script's +stack mode, the --no-track fix, the cleanup/abort stack guards, and the scan +issues for layers whose parent moved or landed. +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).parent.parent / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from worktree_shared import ( # noqa: E402 + TrackingEntry, + find_stack_children, + is_landed_by_merge, + load_tracking_jsonl, +) + + +def git(*args, cwd): + return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout.strip() + + +def run_script(name, payload=None, *, cwd, args=()): + cmd = [sys.executable, str(SCRIPTS / name)] + if payload is not None: + cmd.append(json.dumps(payload)) + cmd.extend(args) + proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + text = proc.stdout + body = text[text.find("{"): text.rfind("}") + 1] + assert body, f"no JSON in output:\nstdout={proc.stdout}\nstderr={proc.stderr}" + return json.loads(body) + + +@pytest.fixture +def stack_repo(tmp_path): + """origin with develop (trunk) and a pushed layer l1 on top of it.""" + origin = tmp_path / "origin.git" + repo = tmp_path / "repo" + git("init", "-q", "--bare", str(origin), cwd=tmp_path) + git("clone", "-q", str(origin), str(repo), cwd=tmp_path) + git("config", "user.email", "t@t", cwd=repo) + git("config", "user.name", "t", cwd=repo) + (repo / "a").write_text("a\n") + git("add", "a", cwd=repo) + git("commit", "-qm", "base", cwd=repo) + git("branch", "-M", "develop", cwd=repo) + git("push", "-q", "-u", "origin", "develop", cwd=repo) + git("checkout", "-qb", "l1", cwd=repo) + (repo / "l1").write_text("l1\n") + git("add", "l1", cwd=repo) + git("commit", "-qm", "l1", cwd=repo) + git("push", "-q", "-u", "origin", "l1", cwd=repo) + git("checkout", "-q", "develop", cwd=repo) + (repo / ".sc").mkdir() + (repo / ".sc" / "shared-settings.yaml").write_text("git:\n protected_branches:\n - develop\n") + return repo + + +def create(repo, branch, base, stack=None, **extra): + payload = {"branch": branch, "base": base, "purpose": f"work on {branch}", "owner": "t", "repo_root": str(repo)} + if stack is not None: + payload["stack"] = stack + payload.update(extra) + return run_script("worktree_create.py", payload, cwd=repo) + + +def upstream(worktree): + proc = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "@{u}"], cwd=worktree, capture_output=True, text=True + ) + return proc.stdout.strip() if proc.returncode == 0 else None + + +def tracking_path(repo): + return repo.parent / f"{repo.name}-worktrees" / "worktree-tracking.jsonl" + + +# ============================================================================= +# Create: stack mode +# ============================================================================= + + +class TestStackCreate: + def test_cut_from_pushed_parent_without_upstream(self, stack_repo): + # Make the local l1 ref stale so a local-ref cut would be wrong + git("branch", "-f", "l1", "develop", cwd=stack_repo) + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + assert result["success"], result + data = result["data"] + wt = Path(data["path"]) + assert git("rev-parse", "HEAD", cwd=wt) == git("rev-parse", "origin/l1", cwd=stack_repo) + assert upstream(wt) is None, "a layer must not track its parent" + assert data["stack"] == { + "trunk": "develop", + "parent": "l1", + "parent_sha": git("rev-parse", "origin/l1", cwd=stack_repo), + "above": None, + "position": "top", + } + handoff = data["stack_handoff"] + assert handoff["pr_base"] == "l1" + writer, stack_writer = handoff["writer"]["commands"], handoff["stack_writer"]["commands"] + assert writer[0].startswith(f"git -C {wt} commit --allow-empty -m 'wip: work on l2'") + assert writer[1] == f"git -C {wt} push -u origin l2" + assert not any("gh " in c for c in writer), "writers never get gh commands" + assert any("gh pr create --base l1 --head l2" in c and "<<'B'" in c for c in stack_writer) + assert any(c.startswith(f"cd {wt} && gh stack checkout <stack#>") for c in stack_writer) + assert any(c.startswith("gh stack link <stack#> <pr# of l2>") for c in stack_writer) + assert any("gh stack link --base develop <bottom-pr#> ... <pr# of l1> <pr# of l2>" in c for c in stack_writer) + assert stack_writer[-1] == "/sc-gh-stack-view" + rules = handoff["writer"]["rules"] + assert any("rebase --onto origin/l1" in r for r in rules) + assert any("Never run gh stack write commands" in r for r in rules) + assert any("The PR base is l1, never develop." in r for r in rules) + # Tracking row carries the stack metadata + entries = load_tracking_jsonl(tracking_path(stack_repo)) + assert entries[0].stack["parent_sha"] == data["stack"]["parent_sha"] + + def test_bottom_layer_handoff_has_nothing_below(self, stack_repo): + result = create(stack_repo, "l0", "develop", stack={"trunk": "develop"}) + assert result["success"], result + handoff = result["data"]["stack_handoff"] + cmds = handoff["stack_writer"]["commands"] + assert "gh stack link --base develop <pr# of l0> # first link, or full relink" in cmds + assert not any("pr# of develop" in c for c in cmds) + assert any("The PR base is develop." in r for r in handoff["writer"]["rules"]) + assert not any("never develop" in r for r in handoff["writer"]["rules"]) + + def test_insert_handoff(self, stack_repo): + result = create(stack_repo, "l1b", "develop", stack={"trunk": "develop", "above": "l1"}) + assert result["success"], result + data = result["data"] + assert data["stack"]["position"] == "insert" + cmds = data["stack_handoff"]["stack_writer"]["commands"] + assert any("merge --no-ff origin/l1b" in c for c in cmds) + assert any("every layer above develop, bottom to top" in c for c in cmds) + assert "gh pr edit <pr# of l1> --base l1b" in cmds + assert any(c.startswith("gh stack unstack <stack#>") for c in cmds) + assert any("gh stack link --base develop <pr# of l1b> <pr# of l1> ... <top-pr#>" in c for c in cmds) + assert data["stack_handoff"]["reference"].endswith("(insert)") + + def test_chain_check_command_only_when_installed(self, stack_repo): + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + assert not result["data"]["stack_handoff"]["chain_check_available"] + assert not any("gh_stack_chain_check" in c for c in result["data"]["stack_handoff"]["stack_writer"]["commands"]) + (stack_repo / ".claude" / "scripts").mkdir(parents=True) + (stack_repo / ".claude" / "scripts" / "gh_stack_chain_check.py").write_text("# stub\n") + git("push", "-q", "origin", "l2", cwd=stack_repo) # l2 is now the top; l3 goes on it + result = create(stack_repo, "l3", "l2", stack={"trunk": "develop"}) + handoff = result["data"]["stack_handoff"] + assert handoff["chain_check_available"] + assert any("gh_stack_chain_check.py --trunk develop <bottom> ... l2 l3" in c for c in handoff["stack_writer"]["commands"]) + # insert mode also carries the chain check, with the new layer in place + git("push", "-q", "origin", "l1:l1x", cwd=stack_repo) + result = create(stack_repo, "l1y", "develop", stack={"trunk": "develop", "above": "l1x"}) + assert any("gh_stack_chain_check.py --trunk develop l1y l1x ... <top>" in c for c in result["data"]["stack_handoff"]["stack_writer"]["commands"]) + + def test_parent_behind_trunk_is_a_note_not_a_block(self, stack_repo): + # Trunk moves on after l1 was cut: allowed, recorded in the transcript + git("commit", "-q", "--allow-empty", "-m", "trunk moves", cwd=stack_repo) + git("push", "-q", "origin", "develop", cwd=stack_repo) + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + assert result["success"], result + steps = " ".join(s.get("message", "") or "" for s in result["metadata"]["transcript"]) + assert "parent behind trunk by 1 commit(s)" in steps + + +class TestStackCreateRefusals: + def test_layer_must_be_new(self, stack_repo): + git("branch", "-q", "l2", "l1", cwd=stack_repo) + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + assert not result["success"] + assert result["error"]["code"] == "STACK.LAYER_EXISTS" + assert "omit `stack`" in result["error"]["suggested_action"] + + def test_parent_must_be_pushed(self, stack_repo): + git("branch", "-q", "local-only", "l1", cwd=stack_repo) + result = create(stack_repo, "l2", "local-only", stack={"trunk": "develop"}) + assert result["error"]["code"] == "STACK.PARENT_NOT_PUSHED" + assert "git push -u origin local-only" in result["error"]["suggested_action"] + assert not (stack_repo.parent / "repo-worktrees" / "l2").exists() + + def test_trunk_must_exist(self, stack_repo): + result = create(stack_repo, "l2", "l1", stack={"trunk": "nope"}) + assert result["error"]["code"] == "BRANCH.NOT_FOUND" + assert "stack.trunk" in result["error"]["suggested_action"] + + def test_landed_parent_refused(self, stack_repo): + git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=stack_repo) + git("push", "-q", "origin", "develop", cwd=stack_repo) + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + assert result["error"]["code"] == "STACK.PARENT_LANDED" + assert "/sc-gh-stack-view" in result["error"]["suggested_action"] + + def test_above_must_contain_parent(self, stack_repo): + git("branch", "-q", "other", "develop", cwd=stack_repo) + git("push", "-q", "origin", "other", cwd=stack_repo) + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop", "above": "other"}) + assert result["error"]["code"] == "STACK.ABOVE_INVALID" + assert "does not contain origin/l1" in result["error"]["message"] + + def test_above_must_be_pushed(self, stack_repo): + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop", "above": "ghost"}) + assert result["error"]["code"] == "STACK.ABOVE_INVALID" + assert "not on origin" in result["error"]["message"] + + def test_above_cannot_be_parent_or_self(self, stack_repo): + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop", "above": "l1"}) + assert result["error"]["code"] == "STACK.ABOVE_INVALID" + + def test_append_onto_parent_with_live_child_is_a_fork(self, stack_repo): + create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + result = create(stack_repo, "l2b", "l1", stack={"trunk": "develop"}) + assert result["error"]["code"] == "STACK.PARENT_HAS_CHILD" + assert "Cut from the current top (l2)" in result["error"]["suggested_action"] + assert result["data"]["existing_children"] == ["l2"] + # the same cut as an explicit insert is allowed + git("push", "-q", "origin", "l2", cwd=stack_repo) + ok = create(stack_repo, "l2c", "l1", stack={"trunk": "develop", "above": "l2"}) + assert ok["success"], ok + + def test_parent_off_trunk(self, stack_repo): + # an orphan branch shares no history with the trunk + git("checkout", "-q", "--orphan", "orphan", cwd=stack_repo) + git("commit", "-q", "--allow-empty", "-m", "orphan", cwd=stack_repo) + git("push", "-q", "origin", "orphan", cwd=stack_repo) + git("checkout", "-q", "develop", cwd=stack_repo) + result = create(stack_repo, "l2", "orphan", stack={"trunk": "develop"}) + assert result["error"]["code"] == "STACK.PARENT_OFF_TRUNK" + + def test_purpose_with_quotes_is_shell_safe(self, stack_repo): + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop"}, purpose='fix "it" $(now) `x`') + assert result["success"], result + handoff = result["data"]["stack_handoff"] + wip = handoff["writer"]["commands"][0] + assert "commit --allow-empty -m 'wip: fix \"it\" $(now) `x`'" in wip + pr = next(c for c in handoff["stack_writer"]["commands"] if c.startswith("gh pr create")) + assert "<<'B'" in pr and 'Task: fix "it" $(now) `x`' in pr + + def test_empty_trunk_rejected_at_input(self, stack_repo): + result = create(stack_repo, "l2", "l1", stack={"trunk": " "}) + assert not result["success"] + assert result["error"]["code"] == "CONFIG.MISSING" + + +class TestPlainCreateNoTrack: + def test_remote_only_base_does_not_become_upstream(self, stack_repo): + git("branch", "-q", "-D", "l1", cwd=stack_repo) + result = create(stack_repo, "feature/x", "l1") + assert result["success"], result + assert upstream(Path(result["data"]["path"])) is None + assert "stack" not in result["data"] + assert load_tracking_jsonl(tracking_path(stack_repo))[0].stack is None + + def test_local_base_still_used_in_plain_mode(self, stack_repo): + git("branch", "-f", "l1", "develop", cwd=stack_repo) # stale local ref + result = create(stack_repo, "feature/y", "l1") + assert result["success"], result + assert git("rev-parse", "HEAD", cwd=Path(result["data"]["path"])) == git("rev-parse", "develop", cwd=stack_repo) + + +# ============================================================================= +# Tracking schema +# ============================================================================= + + +class TestTrackingStackField: + def test_entry_without_stack_loads(self, tmp_path): + p = tmp_path / "t.jsonl" + p.write_text( + '{"branch":"f","path":"/p","base":"main","owner":"o","created":"2024-01-01T00:00:00Z","last_checked":"2024-01-01T00:00:00Z"}\n' + ) + entries = load_tracking_jsonl(p) + assert entries[0].stack is None + + def test_find_stack_children_only_live(self, stack_repo): + """Liveness is checked against git and the filesystem, not the stored flags.""" + wt_base = stack_repo.parent / "repo-worktrees" + (wt_base / "l2").mkdir(parents=True) + (wt_base / "l4").mkdir(parents=True) + git("push", "-q", "origin", "develop:l3", cwd=stack_repo) # l3 exists only on origin + + def entry(branch, parent=None, local=True, remote=False, above=None): + return TrackingEntry( + branch=branch, path=str(wt_base / branch), base=parent or "main", owner="o", + created="2024-01-01T00:00:00Z", last_checked="2024-01-01T00:00:00Z", + local_worktree=local, remote_exists=remote, + stack={"trunk": "develop", "parent": parent, "parent_sha": "x", "above": above, "position": "top"} + if parent else None, + ) + entries = [ + entry("l1"), + entry("l2", parent="l1"), + entry("l3", parent="l1", local=False, remote=True), + entry("stale-flag", parent="l1", local=True, remote=True), # flags say live, git says gone + entry("l4", parent="l2"), + entry("ins", parent="develop", above="l2"), # inserted under l2 + ] + assert find_stack_children(entries, "l1", cwd=stack_repo) == ["l2", "l3"] + assert find_stack_children(entries, "l2", cwd=stack_repo) == ["l4"] + assert find_stack_children(entries, "l4", cwd=stack_repo) == [] + # the inserted layer's own row names the layer above it as a child + assert find_stack_children(entries, "ins", cwd=stack_repo) == ["l2"] + + +# ============================================================================= +# Cleanup / abort guards +# ============================================================================= + + +class TestStackGuards: + @pytest.fixture + def layered(self, stack_repo): + """l1 has a worktree; l2 is a live layer cut from l1.""" + create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + git("worktree", "add", "-q", str(stack_repo.parent / "repo-worktrees" / "l1"), "l1", cwd=stack_repo) + return stack_repo + + def test_abort_refuses_to_delete_parent(self, layered): + result = run_script( + "worktree_abort.py", {"branch": "l1", "allow_delete_branch": True, "repo_root": str(layered)}, cwd=layered + ) + assert result["error"]["code"] == "STACK.HAS_CHILDREN" + assert result["data"]["stack_children"] == ["l2"] + assert (layered.parent / "repo-worktrees" / "l1").exists(), "nothing may be mutated before the guard" + assert git("rev-parse", "--verify", "l1", cwd=layered) + + def test_abort_without_delete_removes_worktree_only(self, layered): + result = run_script("worktree_abort.py", {"branch": "l1", "repo_root": str(layered)}, cwd=layered) + assert result["success"], result + assert result["data"]["branch_deleted_local"] is False + + def test_cleanup_override_refused(self, layered): + result = run_script( + "worktree_cleanup.py", {"branch": "l1", "merged": True, "repo_root": str(layered)}, cwd=layered + ) + assert result["error"]["code"] == "STACK.HAS_CHILDREN" + assert "gh stack merge" in result["error"]["suggested_action"] + assert (layered.parent / "repo-worktrees" / "l1").exists() + + def test_abort_refuses_even_when_parent_landed(self, layered): + git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=layered) + git("push", "-q", "origin", "develop", cwd=layered) + result = run_script( + "worktree_abort.py", {"branch": "l1", "allow_delete_branch": True, "repo_root": str(layered)}, cwd=layered + ) + assert result["error"]["code"] == "STACK.HAS_CHILDREN" + assert "--cleanup" in result["error"]["suggested_action"] + + def test_inserted_layer_is_guarded_by_its_above(self, stack_repo): + # l1b inserted under l1: l1's row still says parent=develop, but l1 now sits on l1b + create(stack_repo, "l1b", "develop", stack={"trunk": "develop", "above": "l1"}) + wt = stack_repo.parent / "repo-worktrees" / "l1b" + git("commit", "-q", "--allow-empty", "-m", "wip", cwd=wt) + git("push", "-q", "-u", "origin", "l1b", cwd=wt) + result = run_script( + "worktree_abort.py", {"branch": "l1b", "allow_delete_branch": True, "repo_root": str(stack_repo)}, cwd=stack_repo + ) + assert result["error"]["code"] == "STACK.HAS_CHILDREN" + assert result["data"]["stack_children"] == ["l1"] + + def test_gone_child_no_longer_blocks(self, layered): + # l2's worktree removed outside the skill and never pushed: not live any more + import shutil + shutil.rmtree(layered.parent / "repo-worktrees" / "l2") + git("worktree", "prune", cwd=layered) + git("branch", "-q", "-D", "l2", cwd=layered) + result = run_script( + "worktree_abort.py", {"branch": "l1", "allow_delete_branch": True, "repo_root": str(layered)}, cwd=layered + ) + assert result["success"], result + + def test_guard_off_when_tracking_disabled(self, layered): + result = run_script( + "worktree_abort.py", + {"branch": "l1", "allow_delete_branch": True, "tracking_enabled": False, "repo_root": str(layered)}, + cwd=layered, + ) + assert result["success"], result + + def test_landing_checked_against_children_trunk(self, layered): + # protected list [main, develop] with the stack trunk develop: the landing must be + # judged against origin/develop, not the first protected branch + git("branch", "-q", "main", "develop", cwd=layered) + git("push", "-q", "origin", "main", cwd=layered) + (layered / ".sc" / "shared-settings.yaml").write_text("git:\n protected_branches:\n - main\n - develop\n") + git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=layered) + git("push", "-q", "origin", "develop", cwd=layered) + result = run_script( + "worktree_cleanup.py", {"branch": "l1", "merged": True, "repo_root": str(layered)}, cwd=layered + ) + assert result["success"], result + assert result["data"]["branch_deleted_local"] is True + + def test_cleanup_after_real_landing_is_allowed(self, layered): + git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=layered) + git("push", "-q", "origin", "develop", cwd=layered) + result = run_script("worktree_cleanup.py", {"branch": "l1", "repo_root": str(layered)}, cwd=layered) + assert result["success"], result + assert result["data"]["branch_deleted_local"] is True + + def test_batch_cleanup_blocks_fast_forwarded_parent(self, layered): + # A fast-forward puts l1's head on the trunk's first-parent line, which git cannot + # tell apart from an empty branch: batch cleanup must fail closed while l2 is live. + git("merge", "-q", "--ff-only", "l1", cwd=layered) + git("push", "-q", "origin", "develop", cwd=layered) + run_script("worktree_scan.py", cwd=layered, args=("--no-cache",)) # register l1's worktree + result = run_script("worktree_cleanup.py", {"repo_root": str(layered)}, cwd=layered) + assert result["success"], result + blocked = {b["branch"]: b for b in (result["data"]["stack_blocked"] or [])} + assert "l1" in blocked and blocked["l1"]["stack_children"] == ["l2"] + assert "l1" not in {c["branch"] for c in result["data"]["cleaned"]} + assert git("rev-parse", "--verify", "l1", cwd=layered) + # l2 is also listed: a fresh layer with no commits is never swept + assert "fresh stack layer" in blocked["l2"]["reason"] + assert result["data"]["summary"]["stack_blocked"] == 2 + + def test_batch_cleanup_cleans_parent_landed_by_merge(self, layered): + git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=layered) + git("push", "-q", "origin", "develop", cwd=layered) + run_script("worktree_scan.py", cwd=layered, args=("--no-cache",)) + result = run_script("worktree_cleanup.py", {"repo_root": str(layered)}, cwd=layered) + assert result["success"], result + assert "l1" in {c["branch"] for c in result["data"]["cleaned"]} + assert not result["data"]["stack_blocked"] + # l2 has no commits of its own, so once l1 landed it is an empty worktree and is + # swept by the pre-existing empty-branch rule; nothing stack-specific applies. + + def test_batch_cleanup_never_sweeps_fresh_layer(self, stack_repo): + create(stack_repo, "l0", "develop", stack={"trunk": "develop"}) + result = run_script("worktree_cleanup.py", {"repo_root": str(stack_repo)}, cwd=stack_repo) + assert result["success"], result + blocked = {b["branch"]: b for b in (result["data"]["stack_blocked"] or [])} + assert "l0" in blocked and "fresh stack layer" in blocked["l0"]["reason"] + assert (stack_repo.parent / "repo-worktrees" / "l0").exists() + + def test_empty_parent_cannot_get_children(self, stack_repo): + # The scenario the guard would otherwise need: a pushed empty branch as a parent. + git("branch", "-q", "empty", "develop", cwd=stack_repo) + git("push", "-q", "origin", "empty", cwd=stack_repo) + result = create(stack_repo, "child", "empty", stack={"trunk": "develop"}) + assert result["error"]["code"] == "STACK.PARENT_LANDED" + + +# ============================================================================= +# Scan +# ============================================================================= + + +class TestScanStackIssues: + def test_parent_advanced(self, stack_repo): + create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + git("worktree", "add", "-q", str(stack_repo.parent / "repo-worktrees" / "l1"), "l1", cwd=stack_repo) + wt1 = stack_repo.parent / "repo-worktrees" / "l1" + git("commit", "-q", "--allow-empty", "-m", "more", cwd=wt1) + git("push", "-q", "origin", "l1", cwd=wt1) + result = run_script("worktree_scan.py", cwd=stack_repo, args=("--no-cache",)) + l2 = next(w for w in result["data"]["worktrees"] if w["branch"] == "l2") + assert any(i.startswith("stack_parent_advanced: origin/l1") for i in l2["issues"]) + assert l2["tracking_entry"]["stack"]["parent"] == "l1" + assert any("rebases once at task start" in r for r in result["data"]["recommendations"]) + # after the writer merges the parent forward the issue clears without touching tracking + wt2 = stack_repo.parent / "repo-worktrees" / "l2" + git("merge", "-q", "--no-ff", "-m", "carry l1 forward", "origin/l1", cwd=wt2) + result = run_script("worktree_scan.py", cwd=stack_repo, args=("--no-cache",)) + l2 = next(w for w in result["data"]["worktrees"] if w["branch"] == "l2") + assert not any(i.startswith("stack_parent_advanced") for i in (l2["issues"] or [])) + + def test_parent_branch_deleted(self, stack_repo): + create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + git("push", "-q", "origin", "--delete", "l1", cwd=stack_repo) + git("fetch", "-q", "--prune", "origin", cwd=stack_repo) + result = run_script("worktree_scan.py", cwd=stack_repo, args=("--no-cache",)) + l2 = next(w for w in result["data"]["worktrees"] if w["branch"] == "l2") + assert "stack_parent_landed: origin/l1 no longer exists" in l2["issues"] + + def test_parent_landed(self, stack_repo): + create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=stack_repo) + git("push", "-q", "origin", "develop", cwd=stack_repo) + result = run_script("worktree_scan.py", cwd=stack_repo, args=("--no-cache",)) + l2 = next(w for w in result["data"]["worktrees"] if w["branch"] == "l2") + assert "stack_parent_landed: l1 is contained in develop" in l2["issues"] + + def test_bottom_layer_has_no_stack_issue_when_trunk_moves(self, stack_repo): + create(stack_repo, "l0", "develop", stack={"trunk": "develop"}) + git("commit", "-q", "--allow-empty", "-m", "trunk moves", cwd=stack_repo) + git("push", "-q", "origin", "develop", cwd=stack_repo) + result = run_script("worktree_scan.py", cwd=stack_repo, args=("--no-cache",)) + l0 = next(w for w in result["data"]["worktrees"] if w["branch"] == "l0") + assert not any(i.startswith("stack_") for i in (l0["issues"] or [])) diff --git a/packages/sc-github-issue/.claude-plugin/plugin.json b/packages/sc-github-issue/.claude-plugin/plugin.json index 74aea3f79..6e1a5b095 100644 --- a/packages/sc-github-issue/.claude-plugin/plugin.json +++ b/packages/sc-github-issue/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-github-issue", "description": "GitHub issue lifecycle management with worktree isolation. List, create, update issues, and implement fixes in isolated worktrees with automated testing, commits, and PR creation.", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, diff --git a/packages/sc-github-issue/agents/sc-github-issue-fix.md b/packages/sc-github-issue/agents/sc-github-issue-fix.md index 4edc2ce59..e0d79cb69 100644 --- a/packages/sc-github-issue/agents/sc-github-issue-fix.md +++ b/packages/sc-github-issue/agents/sc-github-issue-fix.md @@ -1,6 +1,6 @@ --- name: sc-github-issue-fix -version: 0.12.0 +version: 0.13.0 description: Implement fixes for GitHub issues in isolated worktrees with testing and commit automation model: sonnet color: green diff --git a/packages/sc-github-issue/agents/sc-github-issue-intake.md b/packages/sc-github-issue/agents/sc-github-issue-intake.md index 8e645ead8..4f83cf421 100644 --- a/packages/sc-github-issue/agents/sc-github-issue-intake.md +++ b/packages/sc-github-issue/agents/sc-github-issue-intake.md @@ -1,6 +1,6 @@ --- name: sc-github-issue-intake -version: 0.12.0 +version: 0.13.0 description: List and fetch GitHub issue details model: sonnet color: blue diff --git a/packages/sc-github-issue/agents/sc-github-issue-mutate.md b/packages/sc-github-issue/agents/sc-github-issue-mutate.md index 46c402e31..646908c38 100644 --- a/packages/sc-github-issue/agents/sc-github-issue-mutate.md +++ b/packages/sc-github-issue/agents/sc-github-issue-mutate.md @@ -1,6 +1,6 @@ --- name: sc-github-issue-mutate -version: 0.12.0 +version: 0.13.0 description: Create and update GitHub issues model: sonnet color: yellow diff --git a/packages/sc-github-issue/agents/sc-github-issue-pr.md b/packages/sc-github-issue/agents/sc-github-issue-pr.md index 0482feca1..04d539ef1 100644 --- a/packages/sc-github-issue/agents/sc-github-issue-pr.md +++ b/packages/sc-github-issue/agents/sc-github-issue-pr.md @@ -1,6 +1,6 @@ --- name: sc-github-issue-pr -version: 0.12.0 +version: 0.13.0 description: Create pull requests for issue fixes with proper issue references and templating model: sonnet color: purple diff --git a/packages/sc-github-issue/commands/sc-github-issue.md b/packages/sc-github-issue/commands/sc-github-issue.md index cca1b5e8b..a5436f3bd 100644 --- a/packages/sc-github-issue/commands/sc-github-issue.md +++ b/packages/sc-github-issue/commands/sc-github-issue.md @@ -1,6 +1,6 @@ --- name: sc-github-issue -version: 0.12.0 +version: 0.13.0 description: Manage GitHub issues - list, create, update, and fix with automated workflows --- diff --git a/packages/sc-github-issue/manifest.yaml b/packages/sc-github-issue/manifest.yaml index c4ba78602..8ff16dbe2 100644 --- a/packages/sc-github-issue/manifest.yaml +++ b/packages/sc-github-issue/manifest.yaml @@ -1,5 +1,5 @@ name: sc-github-issue -version: 0.12.0 +version: 0.13.0 description: > GitHub issue lifecycle management with worktree isolation: list, create, update, and fix issues with automated workflows including PR creation. Integrates with sc-git-worktree for safe isolation. diff --git a/packages/sc-github-issue/skills/sc-managing-github-issues/SKILL.md b/packages/sc-github-issue/skills/sc-managing-github-issues/SKILL.md index 1ad490220..485d24790 100644 --- a/packages/sc-github-issue/skills/sc-managing-github-issues/SKILL.md +++ b/packages/sc-github-issue/skills/sc-managing-github-issues/SKILL.md @@ -1,6 +1,6 @@ --- name: sc-managing-github-issues -version: 0.12.0 +version: 0.13.0 description: GitHub issue lifecycle management with worktree isolation entry_point: /sc-github-issue --- diff --git a/packages/sc-kanban/.claude-plugin/plugin.json b/packages/sc-kanban/.claude-plugin/plugin.json index 68120b55e..f15c76d8c 100644 --- a/packages/sc-kanban/.claude-plugin/plugin.json +++ b/packages/sc-kanban/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-kanban", "description": "Kanban state machine for Synaptic Canvas: backlog to board to done with gate validation, automatic scrubbing, and shared board config.", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, diff --git a/packages/sc-kanban/agents/checklist-agent.md b/packages/sc-kanban/agents/checklist-agent.md index 80df11816..c6d8cabe3 100644 --- a/packages/sc-kanban/agents/checklist-agent.md +++ b/packages/sc-kanban/agents/checklist-agent.md @@ -1,6 +1,6 @@ --- name: checklist-agent -version: 0.12.0 +version: 0.13.0 description: Checklist fallback provider using roadmap.md + prompts/ (no gates). --- diff --git a/packages/sc-kanban/agents/kanban-card.md b/packages/sc-kanban/agents/kanban-card.md index c5f7815f1..ae1db0d7d 100644 --- a/packages/sc-kanban/agents/kanban-card.md +++ b/packages/sc-kanban/agents/kanban-card.md @@ -1,6 +1,6 @@ --- name: kanban-card -version: 0.12.0 +version: 0.13.0 description: Create or update kanban cards (backlog β†’ board) using shared board config. --- diff --git a/packages/sc-kanban/agents/kanban-query.md b/packages/sc-kanban/agents/kanban-query.md index 762f7ce85..f1fca25d7 100644 --- a/packages/sc-kanban/agents/kanban-query.md +++ b/packages/sc-kanban/agents/kanban-query.md @@ -1,6 +1,6 @@ --- name: kanban-query -version: 0.12.0 +version: 0.13.0 description: Query backlog/board/done cards using shared board config. --- diff --git a/packages/sc-kanban/agents/kanban-transition.md b/packages/sc-kanban/agents/kanban-transition.md index 001e3a475..9bc42c172 100644 --- a/packages/sc-kanban/agents/kanban-transition.md +++ b/packages/sc-kanban/agents/kanban-transition.md @@ -1,6 +1,6 @@ --- name: kanban-transition -version: 0.12.0 +version: 0.13.0 description: Move kanban cards across backlog/board/done with gates and scrubbing. --- diff --git a/packages/sc-kanban/commands/sc-kanban.md b/packages/sc-kanban/commands/sc-kanban.md index 68e15b244..f37c2597f 100644 --- a/packages/sc-kanban/commands/sc-kanban.md +++ b/packages/sc-kanban/commands/sc-kanban.md @@ -1,6 +1,6 @@ --- name: sc-kanban -version: 0.12.0 +version: 0.13.0 description: Interact with the kanban state machine (backlog β†’ board β†’ done) --- diff --git a/packages/sc-kanban/manifest.yaml b/packages/sc-kanban/manifest.yaml index 26de7490d..a920aff7f 100644 --- a/packages/sc-kanban/manifest.yaml +++ b/packages/sc-kanban/manifest.yaml @@ -1,5 +1,5 @@ name: sc-kanban -version: 0.12.0 +version: 0.13.0 description: > Kanban state machine for Synaptic Canvas: backlog β†’ board β†’ done with gate validation, scrubbing, and shared board config (v0.7). diff --git a/packages/sc-kanban/skills/sc-kanban/SKILL.md b/packages/sc-kanban/skills/sc-kanban/SKILL.md index bb468738b..f3d19ef4f 100644 --- a/packages/sc-kanban/skills/sc-kanban/SKILL.md +++ b/packages/sc-kanban/skills/sc-kanban/SKILL.md @@ -1,6 +1,6 @@ --- name: sc-kanban -version: 0.12.0 +version: 0.13.0 description: Kanban state machine orchestration with provider abstraction --- diff --git a/packages/sc-launch-term/.claude-plugin/plugin.json b/packages/sc-launch-term/.claude-plugin/plugin.json index 02df3b057..6f21c5134 100644 --- a/packages/sc-launch-term/.claude-plugin/plugin.json +++ b/packages/sc-launch-term/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-launch-term", - "description": "Launch Claude, Codex, and Gemini sessions in supported terminals with platform-aware terminal autodetect and optional tmux session management.", - "version": "0.12.0", + "description": "Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), and Gemini sessions in supported terminals with platform-aware autodetect, cmux workspace tabs, and optional tmux session management.", + "version": "0.13.0", "author": { "name": "randlee" }, @@ -12,8 +12,13 @@ "macos", "windows", "tmux", + "cmux", "claude", + "fable", "codex", + "sol", + "terra", + "luna", "gemini" ], "scripts": [ @@ -25,6 +30,10 @@ "./commands/sc/sonnet.md", "./commands/sc/haiku.md", "./commands/sc/opus.md", + "./commands/sc/fable.md", + "./commands/sc/sol.md", + "./commands/sc/terra.md", + "./commands/sc/luna.md", "./commands/sc/codex.md", "./commands/sc/gemini.md" ] diff --git a/packages/sc-launch-term/CHANGELOG.md b/packages/sc-launch-term/CHANGELOG.md index f648b540b..297b65a09 100644 --- a/packages/sc-launch-term/CHANGELOG.md +++ b/packages/sc-launch-term/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] +### Added +- Added the `/sc:fable` Claude model command. +- Added cmux terminal/workspace-tab launching. +- Added shared `/sc:sol`, `/sc:terra`, and `/sc:luna` Codex model launchers. +- Deprecated `/sc:codex` now routes to the Terra model and identity pool. +- Added sci-fi/cyberpunk identity pools for the Sol, Terra, and Luna aliases. + +### Fixed +- Use Codex's supported `--yolo` automation flag and `--enable hooks` feature flag for model launches. + ## [0.10.0] - 2026-04-25 ### Added - Initial `sc-launch-term` release with `/sc:sonnet`, `/sc:haiku`, `/sc:opus`, `/sc:codex`, and `/sc:gemini`. diff --git a/packages/sc-launch-term/README.md b/packages/sc-launch-term/README.md index cf64eb2eb..f453e48d3 100644 --- a/packages/sc-launch-term/README.md +++ b/packages/sc-launch-term/README.md @@ -1,13 +1,18 @@ # sc-launch-term Launch Claude, Codex, and Gemini sessions in supported terminals with a shared -autodetect launcher. +autodetect launcher. Codex exposes Sol, Terra, and Luna model aliases through +one shared model-launch implementation. ## Commands - `/sc:sonnet` - `/sc:haiku` - `/sc:opus` +- `/sc:fable` +- `/sc:sol` +- `/sc:terra` +- `/sc:luna` - `/sc:codex` - `/sc:gemini` @@ -17,32 +22,36 @@ Each command accepts: - `--terminal <name>` to force a specific terminal backend - `--tab` to request a new tab when the backend supports it - `--tmux` to create or attach to a named tmux session -- `--identity <name>` required when `ATM_TEAM` is set; exports both `ATM_TEAM` and `ATM_IDENTITY` +- `--identity <name>` optional; if omitted, the launcher generates a model-specific identity and exports both `ATM_TEAM` and `ATM_IDENTITY` - `-- <args...>` to forward extra CLI args to the launched tool ## Terminal Backends Autodetect order: -- macOS: `iterm2`, `ghostty`, `wezterm`, `warp`, `terminal` +- macOS: `iterm2`, `ghostty`, `wezterm`, `warp`, `cmux`, `terminal` - Windows: `wt`, `warp` Supported explicit values: -- macOS: `auto`, `iterm2`, `ghostty`, `wezterm`, `warp`, `terminal` +- macOS: `auto`, `iterm2`, `ghostty`, `wezterm`, `warp`, `cmux`, `terminal` - Windows: `auto`, `wt`, `windows-terminal`, `warp` Notes: - Warp automation uses Warp launch configurations and opens a new window. -- `/sc:sonnet`, `/sc:haiku`, and `/sc:opus` launch `claude` directly with +- cmux automation creates and focuses a new cmux workspace, which is cmux's tab primitive. +- `/sc:sonnet`, `/sc:haiku`, `/sc:opus`, and `/sc:fable` launch `claude` directly with `--model <name> --dangerously-skip-permissions`, so they no longer depend on shell aliases or functions. -- When `--tmux` is used with those Claude model commands, the launcher also +- `/sc:sol`, `/sc:terra`, and `/sc:luna` launch Codex with + `--model gpt-5.6-sol`, `--model gpt-5.6-terra`, and `--model gpt-5.6-luna` + respectively, using the shared Codex launcher and model-specific identity pools. +- `/sc:codex` is a deprecated compatibility alias for `/sc:terra`. +- When `--tmux` is used with Claude model commands, the launcher also adds `--teammate-mode tmux` to match the current local wrappers. -- If `ATM_TEAM` is set in the current environment, every launch requires - `--identity <name>` and exports both `ATM_TEAM` and `ATM_IDENTITY=<name>` - into the launched session. +- If `ATM_TEAM` is set in the current environment, every launch exports both + `ATM_TEAM` and an explicit or generated `ATM_IDENTITY=<name>` into the launched session. - When `ATM_TEAM` is set, the launcher also runs `atm teams add-member <team> <identity> --model <model> --cwd <dir>` before starting the AI tool. Pane-specific `--pane-id` wiring is intentionally left @@ -62,9 +71,11 @@ This package is intended for global command installation under `~/.claude/`. ## Security and Runtime Notes - The commands launch locally installed CLIs only. They do not download code or open network connections by themselves. -- `/sc:sonnet`, `/sc:haiku`, and `/sc:opus` invoke the local `claude` CLI with `--dangerously-skip-permissions`, so use this package only in environments where that launch model is acceptable. -- `/sc:codex` and `/sc:gemini` forward to the local `codex` and `gemini` executables on `PATH`. -- When `ATM_TEAM` is present, the launcher requires `--identity <name>` and exports both `ATM_TEAM` and `ATM_IDENTITY` into the child session. +- `/sc:sonnet`, `/sc:haiku`, `/sc:opus`, and `/sc:fable` invoke the local `claude` CLI with `--dangerously-skip-permissions`, so use this package only in environments where that launch model is acceptable. +- `/sc:sol`, `/sc:terra`, `/sc:luna`, and deprecated `/sc:codex` forward to the local `codex` executable on `PATH`. +- `/sc:gemini` forwards to the local `gemini` executable on `PATH`. +- cmux requires the local `cmux` CLI on `PATH`; cmux launches always target a new workspace/tab. +- When `ATM_TEAM` is present, the launcher exports both `ATM_TEAM` and an explicit or generated `ATM_IDENTITY` into the child session. - In ATM teammate mode, the launcher also runs `atm teams add-member <team> <identity> --model <model> --cwd <dir>` before starting the tool. - `--tmux` creates or reuses a local tmux session and therefore depends on a trusted local `tmux` installation. - The installed wrapper is `.claude/scripts/sc-term-launch.sh`, which resolves Python through the local machine in this order: `python3`, `py -3`, then `python`. @@ -75,6 +86,10 @@ This package is intended for global command installation under `~/.claude/`. /sc:sonnet ~/projects/foo /sc:haiku ~/projects/foo --terminal ghostty /sc:opus ~/projects/foo --tab +/sc:fable ~/projects/foo --terminal cmux --tab +/sc:sol ~/projects/foo +/sc:terra ~/projects/foo --terminal cmux --tab +/sc:luna ~/projects/foo /sc:codex ~/projects/foo --identity alice /sc:sonnet ~/projects/foo -- --continue /sc:codex ~/projects/foo --tmux diff --git a/packages/sc-launch-term/commands/sc/codex.md b/packages/sc-launch-term/commands/sc/codex.md index 0e3ec9c4c..471606d27 100644 --- a/packages/sc-launch-term/commands/sc/codex.md +++ b/packages/sc-launch-term/commands/sc/codex.md @@ -1,115 +1,21 @@ --- -description: Launch Codex CLI in a supported terminal at a specified directory -argument-hint: "[dir] [--terminal <name>] [--tab] [--tmux] [--identity <name>]" +description: Deprecated compatibility alias for launching Codex Terra in a supported terminal +argument-hint: "[dir] [--terminal <name>] [--tab] [--tmux] [--identity <name>] [-- <codex args...>]" allowed-tools: Bash, AskUserQuestion --- -Launch Codex CLI in a supported terminal. +`/sc:codex` is retained as a deprecated compatibility alias. It routes to the `terra` model and Terra identity pool, launching Codex as `gpt-5.6-terra`. -## Arguments +Use `/sc:terra` for new invocations. The shared launcher handles argument parsing, terminal selection, cmux workspace/tab creation, tmux session collision handling, identity generation, and Codex passthrough arguments. -Parse from `$ARGUMENTS`: -- `dir` optional positional target directory. Default: `$PWD`. -- `--terminal <name>` optional explicit backend. Default: `auto`. -- `--tab` optional request for a new tab instead of a new window when supported. -- `--tmux` optional tmux session flow using the session name `"{folder} - codex"`. -- `--identity <name>` optional. If omitted, a random name is generated. Exports both `ATM_TEAM` and `ATM_IDENTITY=<name>` into the launched session. +For this compatibility alias, resolve the optional directory and arguments using the same contract as the model commands, then invoke: -Supported backends: -- macOS: `iterm2`, `ghostty`, `wezterm`, `warp`, `terminal` -- Windows: `wt`, `warp` - -Backend notes: -- Warp opens a new window only; reject `--tab` if `warp` is selected. -- `--tmux` requires `tmux` on `PATH`. On Windows, only use it if your shell already supports `tmux`. -- If `ATM_TEAM` is set and `--identity` is supplied, the launcher registers the member first with `atm teams add-member <team> <identity> --model codex --cwd <dir>`. - -The launched command is: - -```text -codex --yolo -c features.codex_hooks=true -``` - -## Steps - -**Step 0** β€” If `$ARGUMENTS` contains `--help`, print the following and stop: - -``` -Usage: /sc:codex [dir] [--terminal <name>] [--tab] [--tmux] [--identity <name>] - -Launch Codex CLI in a supported terminal. - -Examples: - /sc:codex - /sc:codex ~/projects/foo - /sc:codex ~/projects/foo --terminal wt - /sc:codex ~/projects/foo --tab - /sc:codex ~/projects/foo --tmux - /sc:codex ~/projects/foo --identity alice -``` - -**Step 1** β€” Parse `$ARGUMENTS`. Extract `dir`, `--terminal`, `--tab`, `--tmux`, and `--identity`. - -**Step 2** β€” Resolve `dir` to an absolute path. - -**Step 3** β€” If `warp` is selected with `--tab`, stop and explain that scripted Warp launches open a new window only. - -**Step 4** β€” Build `TERM_ARG`: `--terminal <name>` if supplied, otherwise omit. - -**Step 5** β€” Resolve identity: use `--identity <name>` if supplied, otherwise generate a random name. Build `IDENTITY_ARG` from the resolved value. - -### Without `--tmux` - -Run and stop: - -```bash -.claude/scripts/sc-term-launch.sh launch "codex --yolo -c features.codex_hooks=true" "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> --member-model codex -``` - -### With `--tmux` - -**Step 6** β€” Build `SESSION_NAME = "{basename(dir)} - codex"`. - -**Step 7** β€” Check whether tmux session naming is available: - -```bash -.claude/scripts/sc-term-launch.sh check-session "<SESSION_NAME>" -``` - -Parse JSON: `{"available": bool, "exists": bool, "next_name": str}`. - -If `available` is false, stop and tell the user that local tmux support is unavailable. - -If `exists` is false, run and stop: - -```bash -.claude/scripts/sc-term-launch.sh launch "codex --yolo -c features.codex_hooks=true" "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> --member-model codex --tmux "<SESSION_NAME>" -``` - -If `exists` is true, use `AskUserQuestion` exactly once with: -- `Connect to existing session (no Codex)` -- `Connect to existing session + Codex in new pane` -- `New session: <next_name>` -- `Enter custom session name` - -Handle responses: - -**Connect (no Codex)**: ```bash -.claude/scripts/sc-term-launch.sh attach "<SESSION_NAME>" <TERM_ARG> <TAB_ARG> +.claude/scripts/sc-term-launch.sh launch-codex-model codex "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> <CODEX_ARG_TAIL> ``` -**Connect + Codex pane**: -```bash -.claude/scripts/sc-term-launch.sh attach-pane "<SESSION_NAME>" "codex --yolo -c features.codex_hooks=true" --cwd "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> --member-model codex -``` +The deprecated `codex` model alias is normalized by the launcher to: -**New session (`next_name`)**: -```bash -.claude/scripts/sc-term-launch.sh launch "codex --yolo -c features.codex_hooks=true" "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> --member-model codex --tmux "<next_name>" -``` - -**Enter custom name** β€” ask for the session name in a follow-up message, then: -```bash -.claude/scripts/sc-term-launch.sh launch "codex --yolo -c features.codex_hooks=true" "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> --member-model codex --tmux "<custom_name>" +```text +codex --model gpt-5.6-terra --yolo --enable hooks ``` diff --git a/packages/sc-launch-term/commands/sc/fable.md b/packages/sc-launch-term/commands/sc/fable.md new file mode 100644 index 000000000..c8db71f4c --- /dev/null +++ b/packages/sc-launch-term/commands/sc/fable.md @@ -0,0 +1,119 @@ +--- +description: Launch Claude Code (fable) in a supported terminal at a specified directory +argument-hint: "[dir] [--terminal <name>] [--tab] [--tmux] [--identity <name>] [-- <claude args...>]" +allowed-tools: Bash, AskUserQuestion +--- + +Launch Claude Code with `claude --model fable --dangerously-skip-permissions` in a supported terminal. + +## Arguments + +Parse from `$ARGUMENTS`: +- `dir` optional positional target directory. Default: `$PWD`. +- `--terminal <name>` optional explicit backend. Default: `auto`. +- `--tab` optional request for a new tab when the backend supports it. +- `--tmux` optional tmux session flow using the session name `"{folder} - fable"`. +- `--identity <name>` optional. If omitted, a random name is generated. Exports both `ATM_TEAM` and `ATM_IDENTITY=<name>` into the launched session. +- `-- <claude args...>` optional passthrough Claude CLI arguments. Everything after `--` is forwarded to `claude`. + +Supported backends: +- macOS: `iterm2`, `ghostty`, `wezterm`, `warp`, `cmux`, `terminal` +- Windows: `wt`, `warp` + +Backend notes: +- Warp opens a new window only; reject `--tab` if `warp` is selected. +- cmux creates and focuses a new cmux workspace, which is cmux's tab primitive. +- `--tmux` requires `tmux` on `PATH`. On Windows, only use it if your shell already supports `tmux`. +- When `--tmux` is used, the launcher also adds `--teammate-mode tmux` to match the local Claude wrapper behavior. +- If `ATM_TEAM` is set and `--identity` is supplied, the launcher registers the member first with `atm teams add-member <team> <identity> --model fable --cwd <dir>`. + +The launched command is: + +```text +claude --model fable --dangerously-skip-permissions +``` + +## Steps + +**Step 0** β€” If `$ARGUMENTS` contains `--help`, print the following and stop: + +``` +Usage: /sc:fable [dir] [--terminal <name>] [--tab] [--tmux] [--identity <name>] [-- <claude args...>] + +Launch Claude Code (fable) in a supported terminal. + +Examples: + /sc:fable + /sc:fable ~/projects/foo + /sc:fable ~/projects/foo --terminal cmux --tab + /sc:fable ~/projects/foo --tab + /sc:fable ~/projects/foo --tmux + /sc:fable ~/projects/foo --identity alice + /sc:fable ~/projects/foo -- --continue +``` + +**Step 1** β€” Parse `$ARGUMENTS`. Extract `dir`, `--terminal`, `--tab`, and `--tmux`. If `--` is present, collect everything after it into `CLAUDE_ARG_TAIL`; otherwise `CLAUDE_ARG_TAIL` is empty. Extract `--identity` when supplied. + +**Step 2** β€” Resolve `dir` to an absolute path. + +**Step 3** β€” If `warp` is selected with `--tab`, stop and explain that scripted Warp launches open a new window only. + +**Step 4** β€” Build `TERM_ARG`: `--terminal <name>` if supplied, otherwise omit. + +**Step 5** β€” Resolve identity: use `--identity <name>` if supplied, otherwise generate a random name. Build `IDENTITY_ARG` from the resolved value. + +### Without `--tmux` + +Run and stop: + +```bash +.claude/scripts/sc-term-launch.sh launch-claude-model fable "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> <CLAUDE_ARG_TAIL> +``` + +### With `--tmux` + +**Step 6** β€” Build `SESSION_NAME = "{basename(dir)} - fable"`. + +**Step 7** β€” Check whether tmux session naming is available: + +```bash +.claude/scripts/sc-term-launch.sh check-session "<SESSION_NAME>" +``` + +Parse JSON: `{"available": bool, "exists": bool, "next_name": str}`. + +If `available` is false, stop and tell the user that local tmux support is unavailable. + +If `exists` is false, run and stop: + +```bash +.claude/scripts/sc-term-launch.sh launch-claude-model fable "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> --tmux "<SESSION_NAME>" <CLAUDE_ARG_TAIL> +``` + +If `exists` is true, use `AskUserQuestion` exactly once with: +- `Connect to existing session (no Claude)` +- `Connect to existing session + Claude in new pane` +- `New session: <next_name>` +- `Enter custom session name` + +Handle responses: + +**Connect (no Claude)**: +```bash +.claude/scripts/sc-term-launch.sh attach "<SESSION_NAME>" <TERM_ARG> <TAB_ARG> +``` + +**Connect + Claude pane**: +```bash +.claude/scripts/sc-term-launch.sh attach-pane-claude-model "<SESSION_NAME>" fable --cwd "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> <CLAUDE_ARG_TAIL> +``` + +**New session (`next_name`)**: +```bash +.claude/scripts/sc-term-launch.sh launch-claude-model fable "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> --tmux "<next_name>" <CLAUDE_ARG_TAIL> +``` + +**Enter custom name** β€” ask for the session name in a follow-up message, then: +```bash +.claude/scripts/sc-term-launch.sh launch-claude-model fable "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> --tmux "<custom_name>" <CLAUDE_ARG_TAIL> +``` diff --git a/packages/sc-launch-term/commands/sc/gemini.md b/packages/sc-launch-term/commands/sc/gemini.md index 44fc92617..283dd924f 100644 --- a/packages/sc-launch-term/commands/sc/gemini.md +++ b/packages/sc-launch-term/commands/sc/gemini.md @@ -16,11 +16,12 @@ Parse from `$ARGUMENTS`: - `--identity <name>` optional. If omitted, a random name is generated. Exports both `ATM_TEAM` and `ATM_IDENTITY=<name>` into the launched session. Supported backends: -- macOS: `iterm2`, `ghostty`, `wezterm`, `warp`, `terminal` +- macOS: `iterm2`, `ghostty`, `wezterm`, `warp`, `cmux`, `terminal` - Windows: `wt`, `warp` Backend notes: - Warp opens a new window only; reject `--tab` if `warp` is selected. +- cmux creates and focuses a new workspace, which is cmux's tab primitive. - `--tmux` requires `tmux` on `PATH`. On Windows, only use it if your shell already supports `tmux`. - If `ATM_TEAM` is set and `--identity` is supplied, the launcher registers the member first with `atm teams add-member <team> <identity> --model gemini --cwd <dir>`. diff --git a/packages/sc-launch-term/commands/sc/haiku.md b/packages/sc-launch-term/commands/sc/haiku.md index 01b065de9..5a9e2ef03 100644 --- a/packages/sc-launch-term/commands/sc/haiku.md +++ b/packages/sc-launch-term/commands/sc/haiku.md @@ -17,11 +17,12 @@ Parse from `$ARGUMENTS`: - `-- <claude args...>` optional passthrough Claude CLI arguments. Everything after `--` is forwarded to `claude`. Supported backends: -- macOS: `iterm2`, `ghostty`, `wezterm`, `warp`, `terminal` +- macOS: `iterm2`, `ghostty`, `wezterm`, `warp`, `cmux`, `terminal` - Windows: `wt`, `warp` Backend notes: - Warp opens a new window only; reject `--tab` if `warp` is selected. +- cmux creates and focuses a new workspace, which is cmux's tab primitive. - `--tmux` requires `tmux` on `PATH`. On Windows, only use it if your shell already supports `tmux`. - When `--tmux` is used, the launcher also adds `--teammate-mode tmux` to match the current local `haiku` wrapper behavior. - If `ATM_TEAM` is set and `--identity` is supplied, the launcher registers the member first with `atm teams add-member <team> <identity> --model haiku --cwd <dir>`. diff --git a/packages/sc-launch-term/commands/sc/luna.md b/packages/sc-launch-term/commands/sc/luna.md new file mode 100644 index 000000000..e93b4bbeb --- /dev/null +++ b/packages/sc-launch-term/commands/sc/luna.md @@ -0,0 +1,15 @@ +--- +description: Launch Codex with the GPT-5.6 Luna model in a supported terminal +argument-hint: "[dir] [--terminal <name>] [--tab] [--tmux] [--identity <name>] [-- <codex args...>]" +allowed-tools: Bash, AskUserQuestion +--- + +Launch Codex with model alias `luna` using the shared Codex model-launch flow. + +Use the same argument contract and tmux collision handling as `/sc:codex`, but invoke: + +```bash +.claude/scripts/sc-term-launch.sh launch-codex-model luna "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> <CODEX_ARG_TAIL> +``` + +The shared launcher resolves a missing identity from the Luna sci-fi/cyberpunk name pool and runs the model as `gpt-5.6-luna`. diff --git a/packages/sc-launch-term/commands/sc/opus.md b/packages/sc-launch-term/commands/sc/opus.md index 277d597cb..b5cf2a628 100644 --- a/packages/sc-launch-term/commands/sc/opus.md +++ b/packages/sc-launch-term/commands/sc/opus.md @@ -17,11 +17,12 @@ Parse from `$ARGUMENTS`: - `-- <claude args...>` optional passthrough Claude CLI arguments. Everything after `--` is forwarded to `claude`. Supported backends: -- macOS: `iterm2`, `ghostty`, `wezterm`, `warp`, `terminal` +- macOS: `iterm2`, `ghostty`, `wezterm`, `warp`, `cmux`, `terminal` - Windows: `wt`, `warp` Backend notes: - Warp opens a new window only; reject `--tab` if `warp` is selected. +- cmux creates and focuses a new workspace, which is cmux's tab primitive. - `--tmux` requires `tmux` on `PATH`. On Windows, only use it if your shell already supports `tmux`. - When `--tmux` is used, the launcher also adds `--teammate-mode tmux` to match the current local `opus` wrapper behavior. - If `ATM_TEAM` is set and `--identity` is supplied, the launcher registers the member first with `atm teams add-member <team> <identity> --model opus --cwd <dir>`. diff --git a/packages/sc-launch-term/commands/sc/sol.md b/packages/sc-launch-term/commands/sc/sol.md new file mode 100644 index 000000000..20481a97f --- /dev/null +++ b/packages/sc-launch-term/commands/sc/sol.md @@ -0,0 +1,15 @@ +--- +description: Launch Codex with the GPT-5.6 Sol model in a supported terminal +argument-hint: "[dir] [--terminal <name>] [--tab] [--tmux] [--identity <name>] [-- <codex args...>]" +allowed-tools: Bash, AskUserQuestion +--- + +Launch Codex with model alias `sol` using the shared Codex model-launch flow. + +Use the same argument contract and tmux collision handling as `/sc:codex`, but invoke: + +```bash +.claude/scripts/sc-term-launch.sh launch-codex-model sol "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> <CODEX_ARG_TAIL> +``` + +The shared launcher resolves a missing identity from the Sol sci-fi/cyberpunk name pool and runs the model as `gpt-5.6-sol`. diff --git a/packages/sc-launch-term/commands/sc/sonnet.md b/packages/sc-launch-term/commands/sc/sonnet.md index 82c683f6f..a2a6afde6 100644 --- a/packages/sc-launch-term/commands/sc/sonnet.md +++ b/packages/sc-launch-term/commands/sc/sonnet.md @@ -17,11 +17,12 @@ Parse from `$ARGUMENTS`: - `-- <claude args...>` optional passthrough Claude CLI arguments. Everything after `--` is forwarded to `claude`. Supported backends: -- macOS: `iterm2`, `ghostty`, `wezterm`, `warp`, `terminal` +- macOS: `iterm2`, `ghostty`, `wezterm`, `warp`, `cmux`, `terminal` - Windows: `wt`, `warp` Backend notes: - Warp opens a new window only; reject `--tab` if `warp` is selected. +- cmux creates and focuses a new workspace, which is cmux's tab primitive. - `--tmux` requires `tmux` on `PATH`. On Windows, only use it if your shell already supports `tmux`. - When `--tmux` is used, the launcher also adds `--teammate-mode tmux` to match the current local `sonnet` wrapper behavior. - If `ATM_TEAM` is set and `--identity` is supplied, the launcher registers the member first with `atm teams add-member <team> <identity> --model sonnet --cwd <dir>`. diff --git a/packages/sc-launch-term/commands/sc/terra.md b/packages/sc-launch-term/commands/sc/terra.md new file mode 100644 index 000000000..98192a87c --- /dev/null +++ b/packages/sc-launch-term/commands/sc/terra.md @@ -0,0 +1,15 @@ +--- +description: Launch Codex with the GPT-5.6 Terra model in a supported terminal +argument-hint: "[dir] [--terminal <name>] [--tab] [--tmux] [--identity <name>] [-- <codex args...>]" +allowed-tools: Bash, AskUserQuestion +--- + +Launch Codex with model alias `terra` using the shared Codex model-launch flow. + +Use the same argument contract and tmux collision handling as `/sc:codex`, but invoke: + +```bash +.claude/scripts/sc-term-launch.sh launch-codex-model terra "<resolved_dir>" <TERM_ARG> <TAB_ARG> <IDENTITY_ARG> <CODEX_ARG_TAIL> +``` + +The shared launcher resolves a missing identity from the Terra sci-fi/cyberpunk name pool and runs the model as `gpt-5.6-terra`. diff --git a/packages/sc-launch-term/manifest.yaml b/packages/sc-launch-term/manifest.yaml index a24c5c66a..d5fcf0875 100644 --- a/packages/sc-launch-term/manifest.yaml +++ b/packages/sc-launch-term/manifest.yaml @@ -1,8 +1,9 @@ name: sc-launch-term -version: 0.12.0 +version: 0.13.0 description: > - Launch Claude, Codex, and Gemini sessions in supported terminals with - platform-aware terminal autodetect and optional tmux session management. + Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), and + Gemini sessions in supported terminals with platform-aware autodetect, cmux + workspace tabs, and optional tmux session management. author: randlee license: MIT tags: @@ -11,8 +12,13 @@ tags: - macos - windows - tmux + - cmux - claude + - fable - codex + - sol + - terra + - luna - gemini artifacts: @@ -20,6 +26,10 @@ artifacts: - commands/sc/sonnet.md - commands/sc/haiku.md - commands/sc/opus.md + - commands/sc/fable.md + - commands/sc/sol.md + - commands/sc/terra.md + - commands/sc/luna.md - commands/sc/codex.md - commands/sc/gemini.md scripts: diff --git a/packages/sc-launch-term/scripts/launch_term_shared.py b/packages/sc-launch-term/scripts/launch_term_shared.py index 43ac56f63..809b1e786 100644 --- a/packages/sc-launch-term/scripts/launch_term_shared.py +++ b/packages/sc-launch-term/scripts/launch_term_shared.py @@ -7,7 +7,6 @@ import os import secrets import shlex -import sys from pathlib import Path @@ -42,26 +41,125 @@ def resolve_team() -> str | None: # Per-tool identity name pools. _IDENTITY_NAMES: dict[str, list[str]] = { - "gemini": ["Gamma", "Galaxian", "Grimaldi", "Ganymede", "Grogu", - "Grievous", "Greedo", "Glitch", "Gauss", "Glimmer"], - "codex": ["Cipher", "Crypto", "Cryptex", "Cassian", "Crimson", - "Cloud", "Citan", "Caesar", "Cascade", "Chewbacca"], - "haiku": ["Homer", "Helix", "Helios", "Horus", "Halo", - "Hal", "Hex", "Hive", "Heretic", "Hydra"], - "sonnet": ["Sinatra", "Santana", "Slash", "Spock", "Skynet", - "Striker", "Siren", "Sentinel", "Sting", "Synth"], - "opus": ["Orion", "Oracle", "Omega", "Orwell", "Obiwan", - "Optimus", "Oblivion", "Onyx", "Odyssey", "Octane"], + "gemini": [ + "Gamma", + "Galaxian", + "Grimaldi", + "Ganymede", + "Grogu", + "Grievous", + "Greedo", + "Glitch", + "Gauss", + "Glimmer", + ], + "haiku": [ + "Homer", + "Helix", + "Helios", + "Horus", + "Halo", + "Hal", + "Hex", + "Hive", + "Heretic", + "Hydra", + ], + "sonnet": [ + "Sinatra", + "Santana", + "Slash", + "Spock", + "Skynet", + "Striker", + "Siren", + "Sentinel", + "Sting", + "Synth", + ], + "opus": [ + "Orion", + "Oracle", + "Omega", + "Orwell", + "Obiwan", + "Optimus", + "Oblivion", + "Onyx", + "Odyssey", + "Octane", + ], + "fable": [ + "Fable", + "Folklore", + "Fiction", + "Fairytale", + "Fantasy", + "Fathom", + "Fresco", + "Fableton", + "Figment", + "Fablewood", + ], + # Codex model identities use the model alias initial and a sci-fi/cyberpunk + # vocabulary. The deprecated `codex` alias is normalized to `terra` below. + "sol": [ + "Smith", + "Switch", + "Spoon", + "Saito", + "Sark", + "Seraph", + "Sombra", + "Synth", + "Specter", + "Sentinel", + ], + "terra": [ + "Trinity", + "Tank", + "Turing", + "Tyrell", + "Tessier", + "Talon", + "Tracer", + "Titan", + "Terminal", + "Turin", + ], + "luna": [ + "Lamb", + "Link", + "Lucy", + "Lyra", + "Lambda", + "Lazarus", + "Lumen", + "Lotus", + "Lucid", + "Legion", + ], } +_IDENTITY_ALIASES = {"codex": "terra"} + _FALLBACK_NAMES: list[str] = [ - "Amber", "Bold", "Calm", "Deft", "Eager", - "Fleet", "Gold", "Hale", "Iron", "Jade", + "Amber", + "Bold", + "Calm", + "Deft", + "Eager", + "Fleet", + "Gold", + "Hale", + "Iron", + "Jade", ] def generate_identity(tool: str | None = None) -> str: - pool = _IDENTITY_NAMES.get(tool or "", _FALLBACK_NAMES) + pool_key = _IDENTITY_ALIASES.get(tool or "", tool or "") + pool = _IDENTITY_NAMES.get(pool_key, _FALLBACK_NAMES) name = secrets.choice(pool) suffix = secrets.token_hex(2) return f"{name}-{suffix}" @@ -104,11 +202,15 @@ def ulid_timestamp(launch_id: str) -> datetime: def session_filename_from_launch_id(launch_id: str) -> str: timestamp = ulid_timestamp(launch_id) - prefix = timestamp.strftime("%Y%m%d%H%M%S") + f"{int(timestamp.microsecond / 1000):03d}" + prefix = ( + timestamp.strftime("%Y%m%d%H%M%S") + f"{int(timestamp.microsecond / 1000):03d}" + ) return f"{prefix}-{launch_id}.json" -def build_session_record_path(project_dir: str | Path, tool: str, launch_id: str) -> Path: +def build_session_record_path( + project_dir: str | Path, tool: str, launch_id: str +) -> Path: root = Path(project_dir).expanduser().resolve() return root / ".sc" / "sessions" / tool / session_filename_from_launch_id(launch_id) diff --git a/packages/sc-launch-term/scripts/sc-term-launch.py b/packages/sc-launch-term/scripts/sc-term-launch.py index e685ec228..61afc1e3c 100644 --- a/packages/sc-launch-term/scripts/sc-term-launch.py +++ b/packages/sc-launch-term/scripts/sc-term-launch.py @@ -17,7 +17,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from launch_term_shared import ( - _IDENTITY_NAMES, + _IDENTITY_NAMES, # noqa: F401 - retained as a module-level identity-pool view build_claude_session_record_path, build_codex_session_record_path, generate_ulid, @@ -30,10 +30,16 @@ ) -MAC_TERMINALS = ("iterm2", "ghostty", "wezterm", "warp", "terminal") +MAC_TERMINALS = ("iterm2", "ghostty", "wezterm", "warp", "cmux", "terminal") WINDOWS_TERMINALS = ("wt", "warp") -CLAUDE_MODELS = ("sonnet", "haiku", "opus") -TEAM_MEMBER_MODELS = CLAUDE_MODELS + ("codex", "gemini") +CLAUDE_MODELS = ("sonnet", "haiku", "opus", "fable") +CODEX_MODELS = ("sol", "terra", "luna", "codex") +CODEX_MODEL_IDS = { + "sol": "gpt-5.6-sol", + "terra": "gpt-5.6-terra", + "luna": "gpt-5.6-luna", +} +TEAM_MEMBER_MODELS = CLAUDE_MODELS + CODEX_MODELS + ("gemini",) MACOS_SHELL_SETTLE_DELAY_SECONDS = 0.8 @@ -73,7 +79,9 @@ def warp_launch_config_dir() -> Path: if os.name == "nt": appdata = os.environ.get("APPDATA") if not appdata: - fail("APPDATA is not set; cannot locate Warp launch configuration directory") + fail( + "APPDATA is not set; cannot locate Warp launch configuration directory" + ) return Path(appdata) / "warp" / "Warp" / "data" / "launch_configurations" fail("Warp launch configuration automation is only supported on macOS and Windows") @@ -89,6 +97,8 @@ def available_terminals() -> list[str]: available.append("wezterm") if detect_macos_app("Warp"): available.append("warp") + if command_exists("cmux"): + available.append("cmux") if detect_macos_app("Terminal"): available.append("terminal") return available @@ -118,7 +128,9 @@ def resolve_terminal(requested: str | None) -> str: "windows-terminal": "wt", "terminal.app": "terminal", } - requested_value = aliases.get((requested or "auto").lower(), (requested or "auto").lower()) + requested_value = aliases.get( + (requested or "auto").lower(), (requested or "auto").lower() + ) supported = platform_supported_terminals() available = available_terminals() @@ -295,7 +307,9 @@ def launch_wezterm(shell_command: str, dir_path: str, use_tab: bool) -> None: start_cmd.extend(["--cwd", dir_path]) subprocess.run(start_cmd, check=True) time.sleep(0.35) - subprocess.run(["wezterm", "cli", "send-text", "--no-paste", shell_command], check=True) + subprocess.run( + ["wezterm", "cli", "send-text", "--no-paste", shell_command], check=True + ) subprocess.run(["wezterm", "cli", "send-text", "--no-paste", "\n"], check=True) @@ -323,7 +337,9 @@ def write_warp_launch_config(command: str, dir_path: str, title: str) -> Path: def launch_warp(shell_command: str, dir_path: str, use_tab: bool, title: str) -> None: if use_tab: - fail("Warp automation currently supports new-window launches only; omit --tab or choose another terminal") + fail( + "Warp automation currently supports new-window launches only; omit --tab or choose another terminal" + ) config_path = write_warp_launch_config(shell_command, dir_path, title) uri = f"warp://launch/{urllib.parse.quote(str(config_path), safe='')}" @@ -336,6 +352,26 @@ def launch_warp(shell_command: str, dir_path: str, use_tab: bool, title: str) -> fail("Warp automation is only supported on macOS and Windows") +def launch_cmux(shell_command: str, dir_path: str, use_tab: bool, title: str) -> None: + """Launch a command in a cmux workspace, which is cmux's tab primitive.""" + del use_tab # cmux workspaces are always tab-like targets. + subprocess.run( + [ + "cmux", + "new-workspace", + "--cwd", + dir_path, + "--name", + title, + "--command", + shell_command, + "--focus", + "true", + ], + check=True, + ) + + def preferred_windows_shell() -> list[str]: if command_exists("pwsh"): return ["pwsh", "-NoExit", "-Command"] @@ -353,7 +389,9 @@ def launch_windows_terminal(shell_command: str, dir_path: str, use_tab: bool) -> subprocess.run(command, check=True) -def run_launch(terminal: str, shell_command: str, dir_path: str, use_tab: bool, title: str) -> None: +def run_launch( + terminal: str, shell_command: str, dir_path: str, use_tab: bool, title: str +) -> None: if terminal == "iterm2": launch_iterm2(shell_command, use_tab) return @@ -369,6 +407,9 @@ def run_launch(terminal: str, shell_command: str, dir_path: str, use_tab: bool, if terminal == "warp": launch_warp(shell_command, dir_path, use_tab, title) return + if terminal == "cmux": + launch_cmux(shell_command, dir_path, use_tab, title) + return if terminal == "wt": launch_windows_terminal(shell_command, dir_path, use_tab) return @@ -416,7 +457,9 @@ def render_command_argv(command_argv: list[str], terminal: str) -> str: return shlex.join(command_argv) -def build_claude_argv(model: str, extra_args: list[str], teammate_mode: bool) -> list[str]: +def build_claude_argv( + model: str, extra_args: list[str], teammate_mode: bool +) -> list[str]: command = ["claude", "--model", model, "--dangerously-skip-permissions"] if teammate_mode: command.extend(["--teammate-mode", "tmux"]) @@ -424,6 +467,28 @@ def build_claude_argv(model: str, extra_args: list[str], teammate_mode: bool) -> return command +def canonical_codex_model(model: str) -> str: + """Normalize the deprecated Codex alias to the Terra model identity.""" + return "terra" if model == "codex" else model + + +def codex_model_id(model: str) -> str: + return CODEX_MODEL_IDS[canonical_codex_model(model)] + + +def build_codex_argv(model: str, extra_args: list[str]) -> list[str]: + command = [ + "codex", + "--model", + codex_model_id(model), + "--yolo", + "--enable", + "hooks", + ] + command.extend(extra_args) + return command + + def apply_env_prefix( command: str, terminal: str, @@ -458,7 +523,9 @@ def apply_atm_env_prefix( return apply_env_prefix(command, terminal, env_vars) -def wait_for_path(path: Path, timeout_seconds: float = 5.0, interval_seconds: float = 0.25) -> bool: +def wait_for_path( + path: Path, timeout_seconds: float = 5.0, interval_seconds: float = 0.25 +) -> bool: deadline = time.monotonic() + timeout_seconds while time.monotonic() < deadline: if path.exists(): @@ -511,21 +578,140 @@ def session_tracking_for_member_model( member_model: str | None, dir_path: str, ) -> tuple[str, Path] | tuple[None, None]: - if member_model == "codex": + if member_model in CODEX_MODELS: launch_id = generate_ulid() return launch_id, build_codex_session_record_path(dir_path, launch_id) return None, None +def model_session_record_path(tool: str, dir_path: str, launch_id: str) -> Path: + if tool == "claude": + return build_claude_session_record_path(dir_path, launch_id) + if tool == "codex": + return build_codex_session_record_path(dir_path, launch_id) + fail(f"Unsupported model-launch tool: {tool}") + + +def build_model_argv( + tool: str, + model: str, + extra_args: list[str], + teammate_mode: bool, +) -> list[str]: + if tool == "claude": + return build_claude_argv(model, extra_args, teammate_mode) + if tool == "codex": + return build_codex_argv(model, extra_args) + fail(f"Unsupported model-launch tool: {tool}") + + +def canonical_model_label(tool: str, model: str) -> str: + if tool == "codex": + return canonical_codex_model(model) + return model + + +def handle_launch_model(args: argparse.Namespace, tool: str) -> None: + terminal = resolve_terminal(args.terminal) + if args.tmux and not tmux_available(): + fail("tmux is not available on PATH; omit --tmux or install tmux") + + model = canonical_model_label(tool, args.model) + team = resolve_team() + identity = resolve_identity(args.identity, model) + register_team_member(team, identity, model, args.dir) + launch_id = generate_ulid() + session_record = model_session_record_path(tool, args.dir, launch_id) + extra_args = normalize_passthrough_args(getattr(args, "model_args", [])) + command = render_command_argv( + build_model_argv(tool, model, extra_args, teammate_mode=bool(args.tmux)), + terminal, + ) + env_vars = { + "SC_LAUNCH_ID": launch_id, + "SC_SESSION_RECORD": str(session_record), + } + if team: + env_vars["ATM_TEAM"] = team + if identity: + env_vars["ATM_IDENTITY"] = identity + command = apply_env_prefix(command, terminal, env_vars) + shell_command = prepare_shell_command(terminal, command, args.dir, args.tmux) + run_launch(terminal, shell_command, args.dir, args.tab, title_from_label(model)) + result = { + "ok": True, + "tool": tool, + "model": model, + "launch_id": launch_id, + "session_record": str(session_record), + "session_record_found": wait_for_path(session_record), + } + if tool == "codex": + result["model_id"] = codex_model_id(model) + emit_json(result) + + +def handle_attach_pane_model(args: argparse.Namespace, tool: str) -> None: + terminal = resolve_terminal(args.terminal) + if not tmux_available(): + fail("tmux is not available on PATH; cannot attach to a session") + + model = canonical_model_label(tool, args.model) + team = resolve_team() + identity = resolve_identity(args.identity, model) + register_team_member(team, identity, model, args.cwd) + launch_id = generate_ulid() + session_record = model_session_record_path(tool, args.cwd, launch_id) + extra_args = normalize_passthrough_args(getattr(args, "model_args", [])) + command = render_command_argv( + build_model_argv(tool, model, extra_args, teammate_mode=True), + terminal, + ) + env_vars = { + "SC_LAUNCH_ID": launch_id, + "SC_SESSION_RECORD": str(session_record), + } + if team: + env_vars["ATM_TEAM"] = team + if identity: + env_vars["ATM_IDENTITY"] = identity + command = apply_env_prefix(command, terminal, env_vars) + run_launch( + terminal, + build_tmux_attach_pane(args.session, command), + args.cwd, + args.tab, + f"sc-launch-term pane {model}", + ) + result = { + "ok": True, + "tool": tool, + "model": model, + "launch_id": launch_id, + "session_record": str(session_record), + "session_record_found": wait_for_path(session_record), + } + if tool == "codex": + result["model_id"] = codex_model_id(model) + emit_json(result) + + def handle_launch(args: argparse.Namespace) -> None: terminal = resolve_terminal(args.terminal) if args.tmux and not tmux_available(): fail("tmux is not available on PATH; omit --tmux or install tmux") team = resolve_team() - identity = resolve_identity(args.identity, args.member_model) - register_team_member(team, identity, args.member_model, args.dir) - launch_id, session_record = session_tracking_for_member_model(args.member_model, args.dir) + member_model = ( + canonical_model_label("codex", args.member_model) + if args.member_model in CODEX_MODELS + else args.member_model + ) + identity = resolve_identity(args.identity, member_model) + register_team_member(team, identity, member_model, args.dir) + launch_id, session_record = session_tracking_for_member_model( + member_model, args.dir + ) env_vars: dict[str, str] = {} if launch_id and session_record: env_vars["SC_LAUNCH_ID"] = launch_id @@ -536,12 +722,14 @@ def handle_launch(args: argparse.Namespace) -> None: env_vars["ATM_IDENTITY"] = identity command = apply_env_prefix(args.command, terminal, env_vars) shell_command = prepare_shell_command(terminal, command, args.dir, args.tmux) - run_launch(terminal, shell_command, args.dir, args.tab, title_from_command(args.command)) + run_launch( + terminal, shell_command, args.dir, args.tab, title_from_command(args.command) + ) if launch_id and session_record: emit_json( { "ok": True, - "tool": args.member_model, + "tool": member_model, "launch_id": launch_id, "session_record": str(session_record), "session_record_found": wait_for_path(session_record), @@ -569,7 +757,9 @@ def handle_attach_pane(args: argparse.Namespace) -> None: team = resolve_team() identity = resolve_identity(args.identity, args.member_model) register_team_member(team, identity, args.member_model, args.cwd) - launch_id, session_record = session_tracking_for_member_model(args.member_model, args.cwd) + launch_id, session_record = session_tracking_for_member_model( + args.member_model, args.cwd + ) env_vars: dict[str, str] = {} if launch_id and session_record: env_vars["SC_LAUNCH_ID"] = launch_id @@ -599,84 +789,19 @@ def handle_attach_pane(args: argparse.Namespace) -> None: def handle_launch_claude_model(args: argparse.Namespace) -> None: - terminal = resolve_terminal(args.terminal) - if args.tmux and not tmux_available(): - fail("tmux is not available on PATH; omit --tmux or install tmux") - - team = resolve_team() - identity = resolve_identity(args.identity, args.model) - register_team_member(team, identity, args.model, args.dir) - launch_id = generate_ulid() - session_record = build_claude_session_record_path(args.dir, launch_id) - extra_args = normalize_passthrough_args(getattr(args, "claude_args", [])) - command = render_command_argv( - build_claude_argv(args.model, extra_args, teammate_mode=bool(args.tmux)), - terminal, - ) - env_vars = { - "SC_LAUNCH_ID": launch_id, - "SC_SESSION_RECORD": str(session_record), - } - if team: - env_vars["ATM_TEAM"] = team - if identity: - env_vars["ATM_IDENTITY"] = identity - command = apply_env_prefix(command, terminal, env_vars) - shell_command = prepare_shell_command(terminal, command, args.dir, args.tmux) - run_launch(terminal, shell_command, args.dir, args.tab, title_from_label(args.model)) - emit_json( - { - "ok": True, - "tool": "claude", - "model": args.model, - "launch_id": launch_id, - "session_record": str(session_record), - "session_record_found": wait_for_path(session_record), - } - ) + handle_launch_model(args, "claude") def handle_attach_pane_claude_model(args: argparse.Namespace) -> None: - terminal = resolve_terminal(args.terminal) - if not tmux_available(): - fail("tmux is not available on PATH; cannot attach a pane") + handle_attach_pane_model(args, "claude") - team = resolve_team() - identity = resolve_identity(args.identity, args.model) - register_team_member(team, identity, args.model, args.cwd) - launch_id = generate_ulid() - session_record = build_claude_session_record_path(args.cwd, launch_id) - extra_args = normalize_passthrough_args(getattr(args, "claude_args", [])) - command = render_command_argv( - build_claude_argv(args.model, extra_args, teammate_mode=True), - terminal, - ) - env_vars = { - "SC_LAUNCH_ID": launch_id, - "SC_SESSION_RECORD": str(session_record), - } - if team: - env_vars["ATM_TEAM"] = team - if identity: - env_vars["ATM_IDENTITY"] = identity - command = apply_env_prefix(command, terminal, env_vars) - run_launch( - terminal, - build_tmux_attach_pane(args.session, command), - os.getcwd(), - args.tab, - f"sc-launch-term pane {args.session}", - ) - emit_json( - { - "ok": True, - "tool": "claude", - "model": args.model, - "launch_id": launch_id, - "session_record": str(session_record), - "session_record_found": wait_for_path(session_record), - } - ) + +def handle_launch_codex_model(args: argparse.Namespace) -> None: + handle_launch_model(args, "codex") + + +def handle_attach_pane_codex_model(args: argparse.Namespace) -> None: + handle_attach_pane_model(args, "codex") def split_passthrough_argv(argv: list[str]) -> tuple[list[str], list[str]]: @@ -715,6 +840,17 @@ def build_parser() -> argparse.ArgumentParser: launch_claude_model.add_argument("--tmux") launch_claude_model.add_argument("--identity") + launch_codex_model = subparsers.add_parser( + "launch-codex-model", + help="Launch a Codex model with the standard wrapper flags", + ) + launch_codex_model.add_argument("model", choices=CODEX_MODELS) + launch_codex_model.add_argument("dir") + launch_codex_model.add_argument("--terminal") + launch_codex_model.add_argument("--tab", action="store_true") + launch_codex_model.add_argument("--tmux") + launch_codex_model.add_argument("--identity") + attach = subparsers.add_parser("attach", help="Attach to an existing tmux session") attach.add_argument("session") attach.add_argument("--terminal") @@ -742,6 +878,17 @@ def build_parser() -> argparse.ArgumentParser: attach_pane_claude_model.add_argument("--tab", action="store_true") attach_pane_claude_model.add_argument("--identity") + attach_pane_codex_model = subparsers.add_parser( + "attach-pane-codex-model", + help="Split a tmux session and launch a Codex model in the new pane", + ) + attach_pane_codex_model.add_argument("session") + attach_pane_codex_model.add_argument("model", choices=CODEX_MODELS) + attach_pane_codex_model.add_argument("--cwd", default=os.getcwd()) + attach_pane_codex_model.add_argument("--terminal") + attach_pane_codex_model.add_argument("--tab", action="store_true") + attach_pane_codex_model.add_argument("--identity") + return parser @@ -749,10 +896,17 @@ def main() -> None: parser = build_parser() argv, passthrough_args = split_passthrough_argv(sys.argv[1:]) args = parser.parse_args(argv) - if args.subcommand in {"launch-claude-model", "attach-pane-claude-model"}: - args.claude_args = passthrough_args + if args.subcommand in { + "launch-claude-model", + "attach-pane-claude-model", + "launch-codex-model", + "attach-pane-codex-model", + }: + args.model_args = passthrough_args elif passthrough_args: - parser.error("passthrough args after -- are only supported for Claude model launches") + parser.error( + "passthrough args after -- are only supported for Claude or Codex model launches" + ) if args.subcommand == "detect": detect_command() @@ -766,6 +920,9 @@ def main() -> None: if args.subcommand == "launch-claude-model": handle_launch_claude_model(args) return + if args.subcommand == "launch-codex-model": + handle_launch_codex_model(args) + return if args.subcommand == "attach": handle_attach(args) return @@ -775,6 +932,9 @@ def main() -> None: if args.subcommand == "attach-pane-claude-model": handle_attach_pane_claude_model(args) return + if args.subcommand == "attach-pane-codex-model": + handle_attach_pane_codex_model(args) + return parser.error(f"unknown subcommand: {args.subcommand}") diff --git a/packages/sc-launchpad/.claude-plugin/plugin.json b/packages/sc-launchpad/.claude-plugin/plugin.json index e036bcd91..e065aef23 100644 --- a/packages/sc-launchpad/.claude-plugin/plugin.json +++ b/packages/sc-launchpad/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-launchpad", - "description": "Launch Claude, Codex, or Gemini through a separate background sub-agent runtime with explicit ATM teammate-mode handling.", - "version": "0.12.0", + "description": "Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), or Gemini as a separate background sub-agent runtime with explicit ATM teammate-mode normalization and roster registration.", + "version": "0.13.0", "author": { "name": "synaptic-canvas" }, @@ -10,7 +10,11 @@ "background-agents", "claude", "codex", + "sol", + "terra", + "luna", "gemini", + "fable", "atm" ], "commands": [], diff --git a/packages/sc-launchpad/CHANGELOG.md b/packages/sc-launchpad/CHANGELOG.md index 916cfa5dd..19eed8790 100644 --- a/packages/sc-launchpad/CHANGELOG.md +++ b/packages/sc-launchpad/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] +### Added +- Added Claude Fable model aliases to the background launch runtime. +- Added Sol, Terra, and Luna Codex model aliases with Terra routing for deprecated `codex`. + +### Fixed +- Use the installed Codex CLI's supported `--yolo` automation flag for background launches. + ## [0.10.0] - 2026-04-25 ### Added - Initial `sc-launchpad` release with a background-launch skill and thin forwarding agent. diff --git a/packages/sc-launchpad/README.md b/packages/sc-launchpad/README.md index 2ac0502f9..7d089cc6e 100644 --- a/packages/sc-launchpad/README.md +++ b/packages/sc-launchpad/README.md @@ -1,6 +1,6 @@ # sc-launchpad -Launch Claude, Codex, or Gemini as a background sub-agent runtime without +Launch Claude (including the `fable` model), Codex (including `sol`, `terra`, and `luna`), or Gemini as a background sub-agent runtime without opening a visible terminal window. ## Included Artifacts @@ -51,6 +51,14 @@ Otherwise, the runtime clears both ATM variables before spawning the child. - The runtime expects absolute working-directory paths in launch payloads. - This package depends on local Python 3 plus whichever AI CLI you are launching. +For Claude launches, `model` accepts `sonnet`, `haiku`, `opus`, or `fable`, +plus the corresponding supported full model names. + +For Codex launches, `model` accepts `sol`, `terra`, or `luna`. The deprecated +`codex` alias routes to `gpt-5.6-terra`; the runtime also accepts the known +legacy Codex aliases for compatibility. The canonical model IDs are +`gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`. + ## Installation ```bash @@ -67,8 +75,9 @@ surface where orchestrator skills or agents need background launch support. "description": "Run a background code review", "prompt": "Review the changed files for risks and missing tests.", "tool": "codex", + "model": "terra", "cwd": "/abs/path/to/repo", "atm_identity": "reviewer-1", - "extra_args": ["--model", "gpt-5.5"] + "extra_args": ["--skip-git-repo-check"] } ``` diff --git a/packages/sc-launchpad/agents/launchpad.md b/packages/sc-launchpad/agents/launchpad.md index d67d43620..589caeca9 100644 --- a/packages/sc-launchpad/agents/launchpad.md +++ b/packages/sc-launchpad/agents/launchpad.md @@ -1,6 +1,6 @@ --- name: launchpad -version: 0.12.0 +version: 0.13.0 description: Thin background-launch forwarding agent for the sc-launchpad runtime. tools: Bash --- diff --git a/packages/sc-launchpad/manifest.yaml b/packages/sc-launchpad/manifest.yaml index b1b5aff3e..a1925b477 100644 --- a/packages/sc-launchpad/manifest.yaml +++ b/packages/sc-launchpad/manifest.yaml @@ -1,15 +1,21 @@ name: sc-launchpad -version: 0.12.0 +version: 0.13.0 description: > - Launch Claude, Codex, or Gemini as a separate background sub-agent runtime, - with explicit ATM teammate-mode normalization and roster registration. + Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), or + Gemini as a separate background + sub-agent runtime, with explicit ATM teammate-mode normalization and roster + registration. author: synaptic-canvas license: MIT tags: - background-agents - claude - codex + - sol + - terra + - luna - gemini + - fable - atm artifacts: diff --git a/packages/sc-launchpad/scripts/sc_launchpad_task.py b/packages/sc-launchpad/scripts/sc_launchpad_task.py index 4d0506b54..4a5701487 100644 --- a/packages/sc-launchpad/scripts/sc_launchpad_task.py +++ b/packages/sc-launchpad/scripts/sc_launchpad_task.py @@ -20,9 +20,16 @@ ToolName = Literal["claude", "codex", "gemini"] -ClaudeModel = Literal["sonnet", "haiku", "opus"] +ClaudeModel = Literal["sonnet", "haiku", "opus", "fable"] CodexModel = Literal[ "codex", + "sol", + "terra", + "luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.6", "codex-max", "max", "codex-mini", @@ -35,7 +42,16 @@ ] CODEX_MODEL_MAP = { - "codex": "gpt-5.2-codex", + # `codex` remains a deprecated compatibility alias for Terra. + "codex": "gpt-5.6-terra", + "sol": "gpt-5.6-sol", + "terra": "gpt-5.6-terra", + "luna": "gpt-5.6-luna", + "gpt-5.6-sol": "gpt-5.6-sol", + "gpt-5.6-terra": "gpt-5.6-terra", + "gpt-5.6-luna": "gpt-5.6-luna", + "gpt-5.6": "gpt-5.6-sol", + # Preserve explicit legacy model requests without using them as defaults. "gpt-5.2-codex": "gpt-5.2-codex", "codex-max": "gpt-5.1-codex-max", "max": "gpt-5.1-codex-max", @@ -47,13 +63,22 @@ "gpt-5.2": "gpt-5.2", } +CODEX_ROSTER_MODEL_MAP = { + "gpt-5.6-sol": "sol", + "gpt-5.6-terra": "terra", + "gpt-5.6-luna": "luna", +} + CLAUDE_MODEL_MAP = { "sonnet": "sonnet", "haiku": "haiku", "opus": "opus", + "fable": "fable", "claude-sonnet-4-6": "sonnet", "claude-haiku-4-5": "haiku", "claude-opus-4-1": "opus", + "claude-fable-5": "fable", + "claude-fable-5[1m]": "fable", } @@ -107,7 +132,9 @@ def error_response(error: LaunchpadError, data: dict | None = None) -> dict: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--json", action="store_true", help="Treat remaining args as JSON payload") + parser.add_argument( + "--json", action="store_true", help="Treat remaining args as JSON payload" + ) parser.add_argument("args", nargs="*", help="JSON payload") return parser.parse_args() @@ -156,13 +183,13 @@ def normalize_tool_model(payload: LaunchpadInput) -> str | None: return normalized if payload.tool == "codex": if payload.model is None: - return "gpt-5.2-codex" + return "gpt-5.6-terra" normalized = CODEX_MODEL_MAP.get(payload.model) if normalized is None: raise LaunchpadError( "VALIDATION.INVALID_MODEL", f"Unsupported Codex model: {payload.model}", - suggested_action="Use codex, max, mini, gpt-5, or a known Codex model alias.", + suggested_action="Use sol, terra, luna, codex (deprecated), or a known Codex model alias.", ) return normalized return payload.model @@ -171,6 +198,10 @@ def normalize_tool_model(payload: LaunchpadInput) -> str | None: def roster_model(payload: LaunchpadInput, normalized_model: str | None) -> str: if payload.tool == "claude": return normalized_model or "sonnet" + if payload.tool == "codex": + return CODEX_ROSTER_MODEL_MAP.get( + normalized_model or "", normalized_model or "terra" + ) return payload.tool @@ -209,7 +240,11 @@ def maybe_add_member( # TODO: When pane-aware launchpad mode exists, append `--pane-id <pane-id>` here. result = subprocess.run(command, text=True, capture_output=True, env=parent_env) if result.returncode != 0: - message = result.stderr.strip() or result.stdout.strip() or "atm teams add-member failed" + message = ( + result.stderr.strip() + or result.stdout.strip() + or "atm teams add-member failed" + ) raise LaunchpadError( "ATM.ADD_MEMBER_FAILED", message, @@ -227,7 +262,7 @@ def build_command(payload: LaunchpadInput, normalized_model: str | None) -> list command.append(payload.prompt) return command if payload.tool == "codex": - command = ["codex", "exec", "--full-auto"] + command = ["codex", "exec", "--yolo"] if normalized_model: command.extend(["--model", normalized_model]) command.extend(extra) @@ -244,8 +279,12 @@ def run_payload(payload: LaunchpadInput, parent_env: dict[str, str]) -> dict: cwd = resolve_cwd(payload.cwd) ensure_command(payload.tool) normalized_model = normalize_tool_model(payload) - teammate_mode, team, identity = resolve_teammate_mode(parent_env, payload.atm_identity) - maybe_add_member(parent_env, teammate_mode, team, identity, payload, normalized_model, cwd) + teammate_mode, team, identity = resolve_teammate_mode( + parent_env, payload.atm_identity + ) + maybe_add_member( + parent_env, teammate_mode, team, identity, payload, normalized_model, cwd + ) child_env = build_child_env(parent_env, teammate_mode, team, identity) command = build_command(payload, normalized_model) result = subprocess.run( diff --git a/packages/sc-launchpad/skills/launchpad/SKILL.md b/packages/sc-launchpad/skills/launchpad/SKILL.md index d6f21ec5a..f7825677c 100644 --- a/packages/sc-launchpad/skills/launchpad/SKILL.md +++ b/packages/sc-launchpad/skills/launchpad/SKILL.md @@ -1,6 +1,6 @@ --- name: launchpad -version: 0.12.0 +version: 0.13.0 description: "Use when another workflow must launch Claude, Codex, or Gemini as a separate background sub-agent without opening a terminal. Spawns the `launchpad` agent with fenced JSON input and `run_in_background: true`." --- @@ -47,6 +47,8 @@ Optional fields: - `atm_identity` - `extra_args` +For Claude launches, `model` accepts `sonnet`, `haiku`, `opus`, or `fable`. + ## ATM Rules Teammate mode is enabled only when both conditions are true: diff --git a/packages/sc-manage/.claude-plugin/plugin.json b/packages/sc-manage/.claude-plugin/plugin.json index 56daf2af6..008c18dda 100644 --- a/packages/sc-manage/.claude-plugin/plugin.json +++ b/packages/sc-manage/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-manage", "description": "Interface for managing Synaptic Canvas packages. Provides discovery, installation info, and management utilities for the marketplace.", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, diff --git a/packages/sc-manage/agents/sc-package-docs.md b/packages/sc-manage/agents/sc-package-docs.md index 7c2197f13..fe0ecbbd2 100644 --- a/packages/sc-manage/agents/sc-package-docs.md +++ b/packages/sc-manage/agents/sc-package-docs.md @@ -1,6 +1,6 @@ --- name: sc-package-docs -version: 0.12.0 +version: 0.13.0 description: Locate and return package documentation (README.md) for a given Synaptic Canvas package. model: sonnet color: blue diff --git a/packages/sc-manage/agents/sc-package-install.md b/packages/sc-manage/agents/sc-package-install.md index dfdcb9789..3d66b0e85 100644 --- a/packages/sc-manage/agents/sc-package-install.md +++ b/packages/sc-manage/agents/sc-package-install.md @@ -1,6 +1,6 @@ --- name: sc-package-install -version: 0.12.0 +version: 0.13.0 description: Install a Synaptic Canvas package locally (repo .claude) or globally according to package policy. model: sonnet color: blue diff --git a/packages/sc-manage/agents/sc-package-uninstall.md b/packages/sc-manage/agents/sc-package-uninstall.md index f06037b7c..6c4525147 100644 --- a/packages/sc-manage/agents/sc-package-uninstall.md +++ b/packages/sc-manage/agents/sc-package-uninstall.md @@ -1,6 +1,6 @@ --- name: sc-package-uninstall -version: 0.12.0 +version: 0.13.0 description: Uninstall a Synaptic Canvas package locally (repo .claude) or globally according to package policy. model: sonnet color: blue diff --git a/packages/sc-manage/agents/sc-packages-list.md b/packages/sc-manage/agents/sc-packages-list.md index ea5289ab9..b142777be 100644 --- a/packages/sc-manage/agents/sc-packages-list.md +++ b/packages/sc-manage/agents/sc-packages-list.md @@ -1,6 +1,6 @@ --- name: sc-packages-list -version: 0.12.0 +version: 0.13.0 description: Enumerate available Synaptic Canvas packages, detect install scope (no/local/global), and return a machine-readable table. model: sonnet color: blue diff --git a/packages/sc-manage/commands/sc-manage.md b/packages/sc-manage/commands/sc-manage.md index 9d225fdbb..3e6d9a5de 100644 --- a/packages/sc-manage/commands/sc-manage.md +++ b/packages/sc-manage/commands/sc-manage.md @@ -2,7 +2,7 @@ allowed-tools: Bash(python3 scripts/sc_manage_dispatch.py*) name: sc-manage description: List, install, or uninstall Synaptic Canvas Claude packages for the current machine or this repo. -version: 0.12.0 +version: 0.13.0 options: - name: --list description: List available packages and show install status (no/global/local) with a summary table. diff --git a/packages/sc-manage/manifest.yaml b/packages/sc-manage/manifest.yaml index 09084e7e6..609051623 100644 --- a/packages/sc-manage/manifest.yaml +++ b/packages/sc-manage/manifest.yaml @@ -1,5 +1,5 @@ name: sc-manage -version: 0.12.0 +version: 0.13.0 description: > Manage Synaptic Canvas Claude packages. List available packages and their install status (local/global), and install or uninstall packages according to policy. diff --git a/packages/sc-manage/skills/managing-sc-packages/SKILL.md b/packages/sc-manage/skills/managing-sc-packages/SKILL.md index 1821b00c7..085e464e8 100644 --- a/packages/sc-manage/skills/managing-sc-packages/SKILL.md +++ b/packages/sc-manage/skills/managing-sc-packages/SKILL.md @@ -1,7 +1,7 @@ --- name: managing-sc-packages description: List, install, or uninstall Synaptic Canvas packages. Use with the `/sc-manage` command. -version: 0.12.0 +version: 0.13.0 --- # Managing Synaptic Canvas Packages diff --git a/packages/sc-refactory/.claude-plugin/plugin.json b/packages/sc-refactory/.claude-plugin/plugin.json new file mode 100644 index 000000000..88b940dda --- /dev/null +++ b/packages/sc-refactory/.claude-plugin/plugin.json @@ -0,0 +1,40 @@ +{ + "name": "sc-refactory", + "description": "Design and install a constrained refactoring toolkit with rule-backed lookup, QA gating, and named-teammate orchestration.", + "version": "0.2.0", + "author": { + "name": "synaptic-canvas" + }, + "license": "MIT", + "keywords": [ + "refactoring", + "policy", + "graph", + "oxigraph", + "migration", + "teammates" + ], + "commands": [ + "./commands/sc-refactory-design.md", + "./commands/sc-refactory-install.md", + "./commands/sc-refactor-lookup.md", + "./commands/sc-refactor-write.md", + "./commands/sc-refactor-plan.md" + ], + "agents": [ + "./agents/refactor-lookup-agent.md", + "./agents/refactor-write-agent.md", + "./agents/refactor-dev-agent.md", + "./agents/refactor-qa-agent.md", + "./agents/refactor-orchestrator.md", + "./agents/refactor-quality-manager.md" + ], + "skills": [ + "./skills/refactory-design/SKILL.md", + "./skills/refactory-install/SKILL.md", + "./skills/refactor-lookup/SKILL.md", + "./skills/refactor-write/SKILL.md", + "./skills/refactor-orchestrate/SKILL.md", + "./skills/refactor-quality-manager/SKILL.md" + ] +} diff --git a/packages/sc-refactory/CHANGELOG.md b/packages/sc-refactory/CHANGELOG.md new file mode 100644 index 000000000..2c861b7f9 --- /dev/null +++ b/packages/sc-refactory/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 0.2.0 β€” 2026-08-30 + +Initial `sc-refactory` release. + +- Adds rule-backed refactoring design, installation, lookup, writing, and + orchestration workflows. +- Adds bounded development and independent QA agents. +- Uses collision-safe `refactor-quality-manager` names for the installed QA + skill and named teammate. diff --git a/packages/sc-refactory/LICENSE b/packages/sc-refactory/LICENSE new file mode 100644 index 000000000..8d188336e --- /dev/null +++ b/packages/sc-refactory/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Rand Lee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sc-refactory/README.md b/packages/sc-refactory/README.md new file mode 100644 index 000000000..07a6866e6 --- /dev/null +++ b/packages/sc-refactory/README.md @@ -0,0 +1,28 @@ +# sc-refactory + +`sc-refactory` designs and installs a rule-driven refactoring toolkit. It +combines approved-fix lookup, bounded development waves, and independent QA +gates so large migrations remain traceable to explicit policy. + +## Install + +```bash +python3 tools/sc-install.py install sc-refactory --dest .claude +``` + +Run the installed `sc-refactory-install` command to create the local +`.refactor/` policy workspace and seed its templates. + +## Components + +- `refactory-design` and `refactory-install` establish the policy system. +- `refactor-lookup` and `refactor-write` retrieve and curate approved fixes. +- `refactor-orchestrate` coordinates development waves with + `refactor-quality-manager` as the independent QA teammate. + +## Security + +Use this package only in trusted repositories and treat `.refactor/` rules as +the source of truth for authorized changes. Review proposed rules and generated +edits before commit, use least-privilege credentials for any repository access, +and never include secrets in prompts, rule documents, logs, or agent messages. diff --git a/packages/sc-refactory/agents/refactor-dev-agent.md b/packages/sc-refactory/agents/refactor-dev-agent.md new file mode 100644 index 000000000..6d5991023 --- /dev/null +++ b/packages/sc-refactory/agents/refactor-dev-agent.md @@ -0,0 +1,108 @@ +--- +name: refactor-dev-agent +version: 0.2.0 +description: Execute one authorized refactor work item or tightly bounded batch. +--- + +# Refactor Dev Agent + +You are a narrow execution agent. You implement one authorized work item, or a +tightly bounded batch of same-rule work, and return structured JSON. + +You do not decide policy. You do not widen scope. +You are not responsible for QA or commit approval. + +## Input + +```json +{ + "work_item_id": "<id>", + "rule_ids": ["<rule-id>"], + "summary": "<one sentence>", + "allowed_fixes": ["<bounded fix shape>"], + "target_paths": ["RepoA/Path/File.csproj"], + "references": [ + { + "path": ".refactor/docs/example-rule.md", + "line": 1 + } + ], + "context": "<optional build errors or surrounding context>" +} +``` + +## Rules + +- Edit only files needed for the assigned authorized work. +- Stay inside the listed `rule_ids` and `allowed_fixes`. +- If the required change falls outside that scope, stop and return failure. +- Do not silently apply β€œsimilar” fixes that were not authorized. + +## Execution Steps + +1. Validate the payload. +2. Read the referenced rule doc or sample fix only as needed to understand the + bounded shape. +3. Make the smallest compliant change set that satisfies the assigned work. +4. Recheck whether the result stayed inside the authorized scope. +5. Return fenced JSON only. + +## Handled by agent + +- missing or malformed required fields +- obvious unauthorized-scope situations +- inability to complete the work without widening scope + +## Propagated to orchestrator + +- any need for a new rule +- any ambiguity about whether the requested change is covered by the rule set +- any user/environment problem that prevents execution + +## Output + +Return exactly one fenced JSON block. + +Success: + +```json +{ + "success": true, + "canceled": false, + "aborted_by": null, + "data": { + "work_item_id": "<id>", + "changed_files": ["RepoA/Path/File.csproj"], + "summary": "<one sentence>", + "requires_followup": false + }, + "error": null, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` + +Failure: + +```json +{ + "success": false, + "canceled": false, + "aborted_by": null, + "data": null, + "error": { + "code": "POLICY.UNAUTHORIZED_SCOPE", + "message": "Required change falls outside assigned approved fixes", + "recoverable": true, + "suggested_action": "Split the work item or add a rule through refactor-write" + }, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` diff --git a/packages/sc-refactory/agents/refactor-lookup-agent.md b/packages/sc-refactory/agents/refactor-lookup-agent.md new file mode 100644 index 000000000..21a4dec5a --- /dev/null +++ b/packages/sc-refactory/agents/refactor-lookup-agent.md @@ -0,0 +1,271 @@ +--- +name: refactor-lookup-agent +version: 0.2.0 +description: Query the local refactor graph for matching rules and return the governing markdown doc plus bounded example references. +--- + +# Refactor Lookup Agent + +You are a focused refactor analysis agent. You receive structured signals +extracted from source files and optional error or CI context. You query the +local refactor graph, identify whether an approved rule applies, and return a +single structured JSON result. + +You do not edit files. You do not apply fixes. + +## Input + +```json +{ + "signals": [ + { + "string": "<signal value>", + "kind": "<type|namespace|assembly|string|error>", + "repo_relative_path": "<repo-root-relative path>", + "full_path": "<absolute path>", + "line": 4 + } + ], + "context": "<compiler errors, CI log, surrounding context>" +} +``` + +## Step 0 β€” Validate inputs + +- Require at least one signal. +- Reject empty `string` values. +- Prefer `repo_relative_path`; accept `full_path` when that is what the caller + has. +- If `full_path` or `repo_relative_path` points to markdown or documentation, + ignore matches that occur only inside fenced code blocks. + +If validation fails, return a fenced JSON error envelope. + +## Step 1 β€” Query graph for each signal + +For each signal, run an exact-match query against the known trigger predicates. +Prefer `--query-file` or stdin over shell-escaped inline queries. + +```bash +cat > "$tmpdir/exact.rq" <<'SPARQL' +PREFIX ref: <https://synaptic.canvas/refactor/> +SELECT ?ruleId ?ruleText ?severity ?triggerKind WHERE { + ?r a ref:Rule ; + ref:ruleId ?ruleId ; + ref:ruleText ?ruleText ; + ref:severity ?severity . + { + ?r ref:triggeredByNamespace "__SIGNAL__" . + BIND("namespace" AS ?triggerKind) + } + UNION + { + ?r ref:triggeredByType "__SIGNAL__" . + BIND("type" AS ?triggerKind) + } + UNION + { + ?r ref:triggeredByError "__SIGNAL__" . + BIND("error" AS ?triggerKind) + } + UNION + { + ?r ref:triggeredByString "__SIGNAL__" . + BIND("string" AS ?triggerKind) + } + UNION + { + ?r ref:triggeredByAssembly "__SIGNAL__" . + BIND("assembly" AS ?triggerKind) + } +} +SPARQL + +oxigraph query \ + --location "${REFACTOR_DB_DIR:-.refactor/db}" \ + --query-file "$tmpdir/exact.rq" \ + --results-format json +``` + +Replace `__SIGNAL__` with the SPARQL-string-escaped signal value before +executing the query. + +For signals containing `.`, also run a namespace-prefix query: + +```bash +cat > "$tmpdir/prefix.rq" <<'SPARQL' +PREFIX ref: <https://synaptic.canvas/refactor/> +SELECT ?ruleId ?ruleText ?severity WHERE { + ?r a ref:Rule ; + ref:ruleId ?ruleId ; + ref:ruleText ?ruleText ; + ref:severity ?severity ; + ref:triggeredByNamespace ?ns . + FILTER(STRSTARTS("__SIGNAL__", REPLACE(?ns, "\\*", ""))) +} +SPARQL + +oxigraph query \ + --location "${REFACTOR_DB_DIR:-.refactor/db}" \ + --query-file "$tmpdir/prefix.rq" \ + --results-format json +``` + +Collect candidate rules across all signals and deduplicate by `ruleId`. + +## Step 2 β€” Fetch fixes for each candidate rule + +```bash +cat > "$tmpdir/fixes.rq" <<'SPARQL' +PREFIX ref: <https://synaptic.canvas/refactor/> +SELECT ?fixId ?fixPath ?fixLine ?confidence ?source WHERE { + ?r ref:ruleId "RULE_ID" ; + ref:hasFix ?f . + ?f ref:fixId ?fixId ; + ref:fixPath ?fixPath ; + ref:fixLine ?fixLine ; + ref:confidence ?confidence ; + ref:source ?source . +} +SPARQL + +oxigraph query \ + --location "${REFACTOR_DB_DIR:-.refactor/db}" \ + --query-file "$tmpdir/fixes.rq" \ + --results-format json +``` + +Do not rely on lexical SPARQL ordering for confidence or severity. + +Rank fixes in agent logic: + +```text +source priority: +1. approved-doc +2. canonical-example +3. recent-git-example +4. exception-example +5. everything else + +confidence priority: +high > medium > low +``` + +The highest-ranked fix becomes `data.fix`. Remaining fixes become +`data.references`. + +## Step 3 β€” Confirm match by reasoning + +Do not over-prune valid trigger hits. In this repository, a trigger hit is +meant to surface the governing policy doc broadly. Use reasoning only to reject +obvious false positives. + +Return `matched: false` only when one of these is true: + +- the only occurrence is inside fenced markdown code, +- the graph is reachable but no rule matches, +- the candidate is clearly unrelated to the caller's signal. + +If multiple rules match, rank them deterministically: + +```text +1. exact predicate hit beats namespace-prefix hit +2. severity: breaking > warning > info +3. more matching signals beats fewer +4. lexical rule_id as final tie-break +``` + +## Step 4 β€” Emit result + +Emit exactly one fenced JSON block as final output. Nothing after it. + +Match: + +```json +{ + "success": true, + "canceled": false, + "aborted_by": null, + "data": { + "matched": true, + "rule_id": "<ruleId>", + "confidence": "<high|medium|low>", + "reason": "<one sentence>", + "rule_text": "<full ruleText from graph>", + "fix": { + "fix_id": "<fixId>", + "path": "<repo-root-relative path>", + "line": 1 + }, + "references": [ + { + "fix_id": "<fixId>", + "path": "<repo-root-relative path>", + "line": 88 + } + ] + }, + "error": null, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` + +No match: + +```json +{ + "success": true, + "canceled": false, + "aborted_by": null, + "data": { + "matched": false, + "rule_id": null, + "confidence": "low", + "reason": "<one sentence>", + "rule_text": null, + "fix": null, + "references": [] + }, + "error": null, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` + +Failure: + +```json +{ + "success": false, + "canceled": false, + "aborted_by": null, + "data": null, + "error": { + "code": "EXECUTION.GRAPH_UNAVAILABLE", + "message": "Graph store is unavailable", + "recoverable": true, + "suggested_action": "Verify oxigraph is installed and the graph store is readable" + }, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` + +## Rules + +- Never edit files +- Never apply fixes +- Default `REFACTOR_DB_DIR` to `.refactor/db` relative to cwd if unset +- If oxigraph is not on PATH or the store is missing, return `success: false` + with error code `EXECUTION.GRAPH_UNAVAILABLE` +- Emit exactly one fenced JSON block as final output diff --git a/packages/sc-refactory/agents/refactor-orchestrator.md b/packages/sc-refactory/agents/refactor-orchestrator.md new file mode 100644 index 000000000..71c5ea710 --- /dev/null +++ b/packages/sc-refactory/agents/refactor-orchestrator.md @@ -0,0 +1,66 @@ +--- +name: refactor-orchestrator +version: 0.2.0 +description: Named teammate that coordinates refactor plans in development and QA waves. +--- + +# Refactor Orchestrator + +You are a named teammate. Load the installed `refactor-orchestrate` skill as +required reading and follow it as behavioral spec. + +## Role + +- persistent coordinator for rule-backed refactor plans +- spawns `refactor-dev-agent` background sub-agents +- hands completed waves to the named `refactor-quality-manager` teammate +- tracks wave state until QA pass or escalation + +## Lifecycle + +1. Receive a plan or wave assignment. +2. Validate that every work item cites approved rule ids. +3. Spawn bounded `refactor-dev-agent` workers. +4. Aggregate worker results into a wave result. +5. Hand off to `refactor-quality-manager`. +6. Interpret QA result as `approved`, `failed-qa`, or `blocked`. +7. Report structured status to the controlling lead or session. + +## Input + +Structured assignments from the lead or controlling session. Expect: + +- `plan_id`: stable identifier for the active plan +- `repos`: repo or repo-set in scope +- `waves`: ordered work batches +- `rule_ids`: approved rule ids allowed for the assignment +- `commit_boundary`: whether the current wave is eligible for commit after QA +- `context`: optional status from previous waves, build output, or operator notes + +## Output Format + +Send structured status messages. Prefer a concise summary plus a fenced JSON +block for machine-readable state. + +Example: + +```json +{ + "success": true, + "data": { + "role": "refactor-orchestrator", + "plan_id": "plan-001", + "wave": "wave-02", + "status": "awaiting-qa", + "next_action": "refactor-quality-manager-review" + }, + "error": null +} +``` + +## Constraints + +- Do not authorize edits outside approved `.refactor/` rules. +- Do not commit directly without QA approval. +- Do not spawn background sub-agents without a bounded work item. +- Do not bypass `refactor-quality-manager` even when a wave appears trivial. diff --git a/packages/sc-refactory/agents/refactor-qa-agent.md b/packages/sc-refactory/agents/refactor-qa-agent.md new file mode 100644 index 000000000..e72e68d7b --- /dev/null +++ b/packages/sc-refactory/agents/refactor-qa-agent.md @@ -0,0 +1,107 @@ +--- +name: refactor-qa-agent +version: 0.2.0 +description: Verify that a proposed refactor diff complies 100% with approved rules. +--- + +# Refactor QA Agent + +You review a proposed change set against the active approved rules and return a +compliance decision. + +## Input + +```json +{ + "wave": "wave-02", + "rule_ids": ["<rule-id>"], + "changed_files": ["RepoA/Path/File.csproj"], + "summary": "<one sentence>", + "references": [ + { + "path": ".refactor/docs/example-rule.md", + "line": 1 + } + ], + "context": "<optional diff summary or build output>" +} +``` + +## Checks + +- every edit is justified by approved `.refactor/` content +- all tandem edits required by the rule set are present +- no unauthorized edits were introduced +- the resulting change shape is consistent with the approved examples + +## Execution Steps + +1. Validate the payload. +2. Read the governing rule doc and any needed references. +3. Compare changed files and described behavior against the authorized rule set. +4. Decide pass or fail. +5. Return fenced JSON only. + +## Handled by agent + +- incomplete QA payload +- obvious rule mismatch +- explicit unauthorized edit detection + +## Propagated to quality manager + +- inability to determine compliance from the provided context +- malformed worker output from earlier phases +- missing rule references or missing changed-file list + +## Output + +Return exactly one fenced JSON block. + +Pass: + +```json +{ + "success": true, + "canceled": false, + "aborted_by": null, + "data": { + "wave": "wave-02", + "approved": true, + "status": "pass", + "blocked_items": [], + "summary": "All edits are rule-backed and complete" + }, + "error": null, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` + +Fail: + +```json +{ + "success": true, + "canceled": false, + "aborted_by": null, + "data": { + "wave": "wave-02", + "approved": false, + "status": "fail", + "blocked_items": [ + "missing tandem edit for rule rpc-annotations-projectreference-prohibited" + ], + "summary": "Wave contains non-compliant edits" + }, + "error": null, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` diff --git a/packages/sc-refactory/agents/refactor-quality-manager.md b/packages/sc-refactory/agents/refactor-quality-manager.md new file mode 100644 index 000000000..a5a4871f9 --- /dev/null +++ b/packages/sc-refactory/agents/refactor-quality-manager.md @@ -0,0 +1,63 @@ +--- +name: refactor-quality-manager +version: 0.2.0 +description: Named teammate that coordinates QA review waves for refactor compliance. +--- + +# Quality Manager + +You are a named teammate. Load the installed `refactor-quality-manager` skill as +required reading and follow it as behavioral spec. + +## Role + +- persistent QA coordinator for refactor waves +- spawns `refactor-qa-agent` background sub-agents +- decides whether a wave is committable +- reports pass/fail status and remediation requirements + +## Lifecycle + +1. Receive a wave handoff from `refactor-orchestrator`. +2. Validate the handoff payload. +3. Spawn one or more `refactor-qa-agent` workers if needed. +4. Aggregate findings into a single QA decision. +5. Report `pass`, `fail`, or `blocked` to the controlling lead or session. + +## Input + +Structured wave handoff from `refactor-orchestrator`, including: + +- `wave`: wave identifier +- `changed_files`: repo-root-relative changed files +- `rule_ids`: approved rules expected to explain the diff +- `summary`: concise wave summary +- `context`: optional build output, diff notes, or prior QA findings + +## Output Format + +Send structured status messages. Prefer a concise summary plus a fenced JSON +block for machine-readable state. + +Example: + +```json +{ + "success": true, + "data": { + "role": "refactor-quality-manager", + "wave": "wave-02", + "status": "fail", + "approved": false, + "next_action": "rework-wave-02" + }, + "error": null +} +``` + +## Constraints + +- Do not approve a wave with unauthorized edits. +- Do not accept missing tandem fixes. +- Do not convert a QA failure into a warning; block commit until fixed. +- Do not replace explicit compliance checks with β€œlooks reasonable”. diff --git a/packages/sc-refactory/agents/refactor-write-agent.md b/packages/sc-refactory/agents/refactor-write-agent.md new file mode 100644 index 000000000..cda437938 --- /dev/null +++ b/packages/sc-refactory/agents/refactor-write-agent.md @@ -0,0 +1,248 @@ +--- +name: refactor-write-agent +version: 0.2.0 +description: Write refactor rules or fix pointers to Turtle files and verify or load them with precise Oxigraph CLI calls. +--- + +# Refactor Write Agent + +You receive a JSON payload describing either a rule definition or a batch of fix +references. You write git-trackable Turtle source-of-truth files under +`.refactor/rules/` and then load or verify them with Oxigraph. + +You must validate input shape and path safety before writing anything. + +## Input + +```json +{ + "operation": "<rule|fix>", + "...": "fields per operation below" +} +``` + +## Operation: rule + +Required input fields: + +```json +{ + "operation": "rule", + "rule_id": "<kebab-case identifier>", + "severity": "<breaking|warning|info>", + "doc_path": ".refactor/docs/<doc-name>.md", + "triggers": [ + { "signal": "FocusDistance", "kind": "type" } + ], + "summary": "<short policy summary>", + "allowed_fixes": [ + "<bounded fix shape>" + ], + "notes": "<optional extra notes>", + "derived_from": null +} +``` + +Validation rules: + +- `rule_id` must match `^[a-z0-9-]+$` +- `severity` must be `breaking`, `warning`, or `info` +- `doc_path` must be repo-root-relative, must not start with `/`, and must not + contain `..` +- each trigger `kind` must be one of `namespace`, `type`, `error`, `string`, + `assembly` +- each trigger `signal` must be non-empty + +### Step 1 β€” Write `.refactor/rules/<rule_id>.ttl` + +```bash +mkdir -p .refactor/rules + +cat > ".refactor/rules/RULE_ID.ttl" <<'TURTLE' +@prefix ref: <https://synaptic.canvas/refactor/> . + +ref:RULE_ID + a ref:Rule ; + ref:ruleId "RULE_ID" ; + ref:severity "SEVERITY" ; + ref:ruleText """ +Approved rule: SUMMARY + +How-to doc: DOC_PATH + +Allowed fix: +- ALLOWED_FIX_1 +- ALLOWED_FIX_2 + +Notes: +NOTES +""" . +TURTLE +``` + +Append one triple per trigger. Map `kind` to predicate: + +```text +namespace -> ref:triggeredByNamespace +type -> ref:triggeredByType +error -> ref:triggeredByError +string -> ref:triggeredByString +assembly -> ref:triggeredByAssembly +``` + +If `derived_from` is non-null, add: + +```text +ref:RULE_ID ref:derivedFrom ref:PARENT_ID . +``` + +Escape string content so the generated Turtle stays valid. At minimum, escape: + +- `\` +- `"` +- literal `"""` inside triple-quoted `ref:ruleText` + +### Step 2 β€” Load or verify with Oxigraph + +The CLI surface must match the installed tool: + +```bash +oxigraph load \ + --location "${REFACTOR_DB_DIR:-.refactor/db}" \ + --file ".refactor/rules/RULE_ID.ttl" +``` + +Optional verification query: + +```bash +cat > "$tmpdir/verify-rule.rq" <<'SPARQL' +PREFIX ref: <https://synaptic.canvas/refactor/> +SELECT ?ruleId WHERE { + ?r a ref:Rule ; + ref:ruleId ?ruleId . + FILTER(?ruleId = "RULE_ID") +} +SPARQL + +oxigraph query \ + --location "${REFACTOR_DB_DIR:-.refactor/db}" \ + --query-file "$tmpdir/verify-rule.rq" \ + --results-format json +``` + +## Operation: fix + +Required input fields: + +```json +{ + "operation": "fix", + "rule_id": "<existing rule id>", + "fixes": [ + { + "fix_id": "<kebab-case identifier>", + "path": ".refactor/docs/example.md", + "line": 1, + "confidence": "<high|medium|low>", + "source": "<approved-doc|canonical-example|recent-git-example|...>" + } + ] +} +``` + +Validation rules: + +- `rule_id` and `fix_id` must match `^[a-z0-9-]+$` +- `path` must be repo-root-relative, must not start with `/`, and must not + contain `..` +- `line` must be a positive integer +- `confidence` must be `high`, `medium`, or `low` + +### Step 1 β€” Write `.refactor/rules/<rule_id>-fixes.ttl` + +Write all fixes for the rule in a single file. The first fix should normally be +the authoritative markdown doc with `source: approved-doc`. + +```bash +mkdir -p .refactor/rules + +cat > ".refactor/rules/RULE_ID-fixes.ttl" <<'TURTLE' +@prefix ref: <https://synaptic.canvas/refactor/> . + +ref:FIX_ID + a ref:Fix ; + ref:fixId "FIX_ID" ; + ref:fixPath "PATH" ; + ref:fixLine LINE ; + ref:confidence "CONFIDENCE" ; + ref:source "SOURCE" . + +ref:RULE_ID ref:hasFix ref:FIX_ID . +TURTLE +``` + +`ref:fixLine` is a bare integer. + +### Step 2 β€” Load into store + +```bash +oxigraph load \ + --location "${REFACTOR_DB_DIR:-.refactor/db}" \ + --file ".refactor/rules/RULE_ID-fixes.ttl" +``` + +## Step 3 β€” Emit result + +Success: + +```json +{ + "success": true, + "canceled": false, + "aborted_by": null, + "data": { + "operation": "<rule|fix>", + "ids": ["<rule_id or each fix_id written>"], + "ttl_paths": [".refactor/rules/<filename>.ttl"], + "loaded_to_store": true + }, + "error": null, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` + +Failure: + +```json +{ + "success": false, + "canceled": false, + "aborted_by": null, + "data": null, + "error": { + "code": "VALIDATION.INPUT", + "message": "Fix path must be repo-root-relative", + "recoverable": true, + "suggested_action": "Replace the absolute path with a repo-root-relative path" + }, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` + +## Rules + +- Validate IDs and repo-relative paths before writing +- `ref:fixLine` must be a bare integer, not a quoted string +- If oxigraph is not on PATH, return `success: false` with error code + `EXECUTION.GRAPH_UNAVAILABLE` +- Default `REFACTOR_DB_DIR` to `.refactor/db` if unset +- Do not use absolute paths outside the workspace +- Emit exactly one fenced JSON block as final output diff --git a/packages/sc-refactory/agents/registry.yaml b/packages/sc-refactory/agents/registry.yaml new file mode 100644 index 000000000..d6973117a --- /dev/null +++ b/packages/sc-refactory/agents/registry.yaml @@ -0,0 +1,42 @@ +agents: + refactor-lookup-agent: + version: 0.1.0 + path: .claude/agents/refactor-lookup-agent.md + refactor-write-agent: + version: 0.1.0 + path: .claude/agents/refactor-write-agent.md + refactor-dev-agent: + version: 0.1.0 + path: .claude/agents/refactor-dev-agent.md + refactor-qa-agent: + version: 0.1.0 + path: .claude/agents/refactor-qa-agent.md + +skills: + refactory-design: + version: 0.1.0 + path: .claude/skills/refactory-design/SKILL.md + refactory-install: + version: 0.1.0 + path: .claude/skills/refactory-install/SKILL.md + refactor-lookup: + version: 0.1.0 + path: .claude/skills/refactor-lookup/SKILL.md + depends_on: + refactor-lookup-agent: "0.1.x" + refactor-write: + version: 0.1.0 + path: .claude/skills/refactor-write/SKILL.md + depends_on: + refactor-write-agent: "0.1.x" + refactor-orchestrate: + version: 0.1.0 + path: .claude/skills/refactor-orchestrate/SKILL.md + depends_on: + refactor-dev-agent: "0.1.x" + refactor-qa-agent: "0.1.x" + refactor-quality-manager: + version: 0.1.0 + path: .claude/skills/refactor-quality-manager/SKILL.md + depends_on: + refactor-qa-agent: "0.1.x" diff --git a/packages/sc-refactory/assets/startup-wrapper-template/team-lead.py b/packages/sc-refactory/assets/startup-wrapper-template/team-lead.py new file mode 100644 index 000000000..e2f45772e --- /dev/null +++ b/packages/sc-refactory/assets/startup-wrapper-template/team-lead.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[1] + script = repo_root / ".refactor" / "scripts" / "session_start.py" + args = ["python3", str(script), *sys.argv[1:]] + return subprocess.call(args, cwd=repo_root) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/sc-refactory/commands/sc-refactor-lookup.md b/packages/sc-refactory/commands/sc-refactor-lookup.md new file mode 100644 index 000000000..81dd473d0 --- /dev/null +++ b/packages/sc-refactory/commands/sc-refactor-lookup.md @@ -0,0 +1,23 @@ +--- +name: sc-refactor-lookup +version: 0.2.0 +description: Look up whether a trigger maps to an approved refactor rule before editing. +options: + - name: --signal + args: + - name: value + description: Trigger value such as a type, namespace, assembly, file, or error code. + description: Required trigger value to look up. + - name: --kind + args: + - name: type + description: "Optional kind: type, namespace, assembly, string, or error." + description: Optional trigger kind. +--- + +# /sc-refactor-lookup + +Thin entrypoint for the `refactor-lookup` skill. + +Use before editing when a known trigger appears in build errors, CI output, +source files, or project files. diff --git a/packages/sc-refactory/commands/sc-refactor-plan.md b/packages/sc-refactory/commands/sc-refactor-plan.md new file mode 100644 index 000000000..31bb541dc --- /dev/null +++ b/packages/sc-refactory/commands/sc-refactor-plan.md @@ -0,0 +1,21 @@ +--- +name: sc-refactor-plan +version: 0.2.0 +description: Prepare or execute a rule-backed refactor plan using the named teammates. +--- + +# /sc-refactor-plan + +User-facing entrypoint for plan-driven execution. + +This command is backed by the named teammates: + +- `refactor-orchestrator` +- `refactor-quality-manager` + +Policy: + +- every plan item must be backed by approved `.refactor/` rules +- dev waves make only authorized changes +- QA waves verify 100% compliance +- only QA-approved waves are committable diff --git a/packages/sc-refactory/commands/sc-refactor-write.md b/packages/sc-refactory/commands/sc-refactor-write.md new file mode 100644 index 000000000..8c4d6d765 --- /dev/null +++ b/packages/sc-refactory/commands/sc-refactor-write.md @@ -0,0 +1,12 @@ +--- +name: sc-refactor-write +version: 0.2.0 +description: Author or update approved refactor rules and sample fix references. +--- + +# /sc-refactor-write + +Thin entrypoint for the `refactor-write` skill. + +Use when a new approved rule must be added or an existing rule needs updated +docs, triggers, or sample references. diff --git a/packages/sc-refactory/commands/sc-refactory-design.md b/packages/sc-refactory/commands/sc-refactory-design.md new file mode 100644 index 000000000..c099bd7c3 --- /dev/null +++ b/packages/sc-refactory/commands/sc-refactory-design.md @@ -0,0 +1,17 @@ +--- +name: sc-refactory-design +version: 0.2.0 +description: Design a rule-driven refactoring system before packaging or installation. +--- + +# /sc-refactory-design + +Thin entrypoint for the `refactory-design` skill. + +Use this command to: + +- define approved-rule boundaries +- define startup allow/deny policy +- define named-teammate responsibilities +- define QA and wave gating +- scaffold a reusable `sc-refactory` package shape diff --git a/packages/sc-refactory/commands/sc-refactory-install.md b/packages/sc-refactory/commands/sc-refactory-install.md new file mode 100644 index 000000000..52e059cf8 --- /dev/null +++ b/packages/sc-refactory/commands/sc-refactory-install.md @@ -0,0 +1,24 @@ +--- +name: sc-refactory-install +version: 0.2.0 +description: Install the refactory runtime into the current repository. +options: + - name: --force + description: Overwrite existing runtime files managed by the installer. + - name: --seed + args: + - name: mode + description: "Seed mode: empty or templates." + description: Control whether starter templates are installed. +--- + +# /sc-refactory-install + +Thin entrypoint for the `refactory-install` skill and installer script. + +Expected result: + +- `.refactor/` runtime layout exists +- `.startup/team-lead` exists +- local install/troubleshooting guide exists +- startup preview can be rendered diff --git a/packages/sc-refactory/manifest.yaml b/packages/sc-refactory/manifest.yaml new file mode 100644 index 000000000..30466fc8c --- /dev/null +++ b/packages/sc-refactory/manifest.yaml @@ -0,0 +1,69 @@ +name: sc-refactory +version: 0.2.0 +description: > + Design and install a rule-driven refactoring toolkit with startup policy + injection, approved-fix lookup, curated rule authoring, and named-teammate + orchestration for large migration campaigns. +author: synaptic-canvas +license: MIT +tags: + - refactoring + - policy + - graph + - oxigraph + - migration + - teammates + +artifacts: + commands: + - commands/sc-refactory-design.md + - commands/sc-refactory-install.md + - commands/sc-refactor-lookup.md + - commands/sc-refactor-write.md + - commands/sc-refactor-plan.md + skills: + - skills/refactory-design/SKILL.md + - skills/refactory-install/SKILL.md + - skills/refactor-lookup/SKILL.md + - skills/refactor-lookup/workflows.md + - skills/refactor-write/SKILL.md + - skills/refactor-write/workflows.md + - skills/refactor-orchestrate/SKILL.md + - skills/refactor-quality-manager/SKILL.md + agents: + - agents/registry.yaml + - agents/refactor-lookup-agent.md + - agents/refactor-write-agent.md + - agents/refactor-dev-agent.md + - agents/refactor-qa-agent.md + - agents/refactor-orchestrator.md + - agents/refactor-quality-manager.md + scripts: + - scripts/install_refactory.py + - scripts/runtime.py + - scripts/sc_shared.py + - scripts/session_start.py + - scripts/preflight.py + - scripts/repair.py + - scripts/log_lookup.py + - scripts/rebuild_db.py + - scripts/sync_subset.py + - scripts/validate_agents.py + assets: + - assets/startup-wrapper-template/team-lead.py + plugin: + - .claude-plugin/plugin.json + +install: + scope: local-only + +requires: + - python3 + - git + - cargo + - oxigraph + - PyYAML + - pydantic + +dependencies: + - "sc-startup >= 0.10.0" diff --git a/packages/sc-refactory/references/install-and-troubleshooting.md b/packages/sc-refactory/references/install-and-troubleshooting.md new file mode 100644 index 000000000..cb7e89654 --- /dev/null +++ b/packages/sc-refactory/references/install-and-troubleshooting.md @@ -0,0 +1,166 @@ +# Refactor Install And Troubleshooting + +Use this guide when a refactor skill pre-flight fails. + +## Claude Repair Contract + +The goal is for the Claude session to repair this environment through the +skill's documented workflow whenever the repair path is local and scripted. + +That means: + +- run pre-flight, +- if it fails, read this guide, +- attempt the scripted local repair steps below, +- rerun pre-flight, +- only stop and ask for help if the scripted repair path does not restore the + environment. + +## Expected Layout + +- rules: `.refactor/rules/` +- docs: `.refactor/docs/` +- runtime DB: `.refactor/db/` +- startup/rebuild scripts: `.refactor/scripts/` +- temp files and logs: `.refactor/temp/` + +## Supported Runtime + +Install `oxigraph` from crates.io, not from Homebrew. + +This workflow was tested with: + +```text +oxigraph 0.5.7 +``` + +## Pre-flight Command + +Run: + +```bash +python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/preflight.py" --skill refactor-lookup +``` + +or: + +```bash +python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/preflight.py" --skill refactor-write +``` + +Success output: + +```text +oxigraph v 0.5.7 checks pass +``` + +If pre-flight fails, do not invoke the background agent yet. + +Failure output: + +```text +tools are not installed or working to use this skill. please read ./.refactor/docs/install-and-troubleshooting.md +``` + +## Scripted Repair + +After a pre-flight failure, Claude should attempt the scripted repair path +before stopping: + +```bash +python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/repair.py" --skill refactor-lookup +``` + +or: + +```bash +python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/repair.py" --skill refactor-write +``` + +This repair path rebuilds `.refactor/db/` through `session_start.py` and then +reruns the matching pre-flight check. + +## Installation + +Install the latest `oxigraph-cli` from crates.io: + +```bash +cargo install oxigraph-cli +``` + +If an older `oxigraph` is already shadowing the cargo install, remove or unlink +it so `oxigraph` resolves to `~/.cargo/bin/oxigraph`. + +Verify: + +```bash +oxigraph --version +``` + +## Rebuild Runtime DB + +If `.refactor/db/` is missing or unreadable, rebuild it through the startup +script: + +```bash +python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/session_start.py" --mode startup >/tmp/refactor-startup.out +``` + +This rebuilds `.refactor/db/` from committed turtles in `.refactor/rules/`. + +This is the first repair step Claude should attempt when pre-flight fails and +`oxigraph` itself is installed. + +## Direct Health Checks + +Check that the DB can be queried: + +```bash +repo_root="$(git rev-parse --show-toplevel)" + +oxigraph query \ + --location "$repo_root/.refactor/db" \ + --query 'PREFIX ref: <https://synaptic.canvas/refactor/> SELECT ?s WHERE { ?s ?p ?o } LIMIT 1' \ + --results-format json +``` + +## Troubleshooting + +If `oxigraph --version` fails: + +- install `oxigraph` +- ensure it is on `PATH` + +If `.refactor/db/` is missing: + +- run `python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/session_start.py" --mode startup` + +If `.refactor/db/` exists but query fails: + +- run `python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/repair.py" --skill refactor-lookup` +- rerun the pre-flight command + +If pre-flight still fails after rebuilding the DB: + +- surface a concise environment error +- include the exact failing command and output +- do not invoke the background refactor agent yet + +If startup succeeds but the background lookup agent still reports +`EXECUTION.GRAPH_UNAVAILABLE`: + +- verify the agent is running in this repo +- verify it is querying `.refactor/db` +- treat that as an agent cwd/path problem, not a rule-authoring problem + +## Source Of Truth + +The committed source of truth is: + +- `.refactor/rules/*.ttl` +- `.refactor/docs/*.md` + +Do not commit: + +- `.refactor/db/` +- `.refactor/temp/` diff --git a/packages/sc-refactory/references/rule-doc-template.md b/packages/sc-refactory/references/rule-doc-template.md new file mode 100644 index 000000000..ea7f1ebc3 --- /dev/null +++ b/packages/sc-refactory/references/rule-doc-template.md @@ -0,0 +1,43 @@ +# <Rule Title> + +## Summary + +<One paragraph describing the approved fix policy.> + +## Triggers + +- `<trigger-1>` +- `<trigger-2>` + +## Why This Rule Exists + +<Rationale and migration context.> + +## When It Applies + +- <condition 1> +- <condition 2> + +## When It Does Not Apply + +- <condition 1> +- <condition 2> + +## Approved Fix Shape + +1. <bounded step 1> +2. <bounded step 2> + +## Tandem Fix Requirements + +- <required tandem fix 1> +- <required tandem fix 2> + +## Exceptions + +- <exception 1> + +## Sample Fixes + +- `RepoA/Path/File.ext:123` +- `RepoB/Path/File.ext:88` diff --git a/packages/sc-refactory/references/rule-ttl-template.ttl b/packages/sc-refactory/references/rule-ttl-template.ttl new file mode 100644 index 000000000..f54d5b0d7 --- /dev/null +++ b/packages/sc-refactory/references/rule-ttl-template.ttl @@ -0,0 +1,16 @@ +@prefix ref: <https://synaptic.canvas/refactor/> . + +ref:RULE_ID + a ref:Rule ; + ref:ruleId "RULE_ID" ; + ref:severity "warning" ; + ref:ruleText """ +Approved rule: SUMMARY + +How-to doc: .refactor/docs/DOC_NAME.md + +Allowed fix: +- BOUNDED_FIX_1 +- BOUNDED_FIX_2 +""" ; + ref:triggeredByString "TRIGGER_VALUE" . diff --git a/packages/sc-refactory/references/runtime-layout.md b/packages/sc-refactory/references/runtime-layout.md new file mode 100644 index 000000000..79d5dfdac --- /dev/null +++ b/packages/sc-refactory/references/runtime-layout.md @@ -0,0 +1,20 @@ +# Refactory Runtime Layout + +Package source lives under `packages/sc-refactory/`. + +When installed, the package artifacts are copied into: + +- `~/.claude/agents/`, `~/.claude/skills/`, `~/.claude/scripts/` +- or `<repo>/.claude/agents/`, `<repo>/.claude/skills/`, `<repo>/.claude/scripts/` + +After `refactory-install` runs in a repo, the repo-local runtime should include: + +- `.startup/team-lead` +- `.refactor/docs/` +- `.refactor/rules/` +- `.refactor/profiles/` +- `.refactor/scripts/` +- `.refactor/reports/` +- `.refactor/db/` +- `.refactor/logs/` +- `.refactor/temp/` diff --git a/packages/sc-refactory/scripts/install_refactory.py b/packages/sc-refactory/scripts/install_refactory.py new file mode 100644 index 000000000..53952db2d --- /dev/null +++ b/packages/sc-refactory/scripts/install_refactory.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""Install the refactory runtime into a target repository.""" + +from __future__ import annotations + +import argparse +import shutil +import stat +import subprocess +from pathlib import Path + + +INSTALL_GUIDE_FALLBACK = """# Refactor Install And Troubleshooting + +Use this guide when a refactor skill pre-flight fails. + +## Expected Layout + +- rules: `.refactor/rules/` +- docs: `.refactor/docs/` +- runtime DB: `.refactor/db/` +- startup/rebuild scripts: `.refactor/scripts/` +- temp files and logs: `.refactor/temp/` and `.refactor/logs/` + +## Supported Runtime + +Install `oxigraph` from crates.io, not from Homebrew. + +This workflow was tested with: + +```text +oxigraph 0.5.7 +``` + +## Installation + +```bash +cargo install oxigraph-cli +oxigraph --version +``` + +## Pre-flight + +```bash +python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/preflight.py" --skill refactor-lookup +python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/preflight.py" --skill refactor-write +``` + +Success output: + +```text +oxigraph v 0.5.7 checks pass +``` + +Failure output: + +```text +tools are not installed or working to use this skill. please read ./.refactor/docs/install-and-troubleshooting.md +``` + +## Repair + +```bash +python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/repair.py" --skill refactor-lookup +python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/repair.py" --skill refactor-write +``` + +## Source Of Truth + +The committed source of truth is: + +- `.refactor/rules/*.ttl` +- `.refactor/docs/*.md` + +Do not commit: + +- `.refactor/db/` +- `.refactor/logs/` +- `.refactor/temp/` +""" + + +RULE_DOC_TEMPLATE_FALLBACK = """# <Rule Title> + +## Summary + +<One paragraph describing the approved fix policy.> + +## Triggers + +- `<trigger-1>` +- `<trigger-2>` + +## Why This Rule Exists + +<Rationale and migration context.> +""" + + +RULE_TTL_TEMPLATE_FALLBACK = """@prefix ref: <https://synaptic.canvas/refactor/> . + +ref:RULE_ID + a ref:Rule ; + ref:ruleId "RULE_ID" ; + ref:severity "warning" ; + ref:ruleText "Replace this template with an approved rule." ; + ref:triggeredByString "TRIGGER_VALUE" . +""" + + +STARTUP_WRAPPER_FALLBACK = """#!/usr/bin/env python3 +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[1] + script = repo_root / ".refactor" / "scripts" / "session_start.py" + args = ["python3", str(script), *sys.argv[1:]] + return subprocess.call(args, cwd=repo_root) + + +if __name__ == "__main__": + raise SystemExit(main()) +""" + + +GITIGNORE = "/db/\n/logs/\n/temp/\n" + +SCRIPT_NAMES = [ + "runtime.py", + "session_start.py", + "preflight.py", + "repair.py", + "log_lookup.py", + "rebuild_db.py", + "sync_subset.py", +] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Install refactory runtime into a repository") + parser.add_argument("--repo-root", default=None, help="Target repository root") + parser.add_argument("--force", action="store_true", help="Overwrite existing runtime files") + parser.add_argument( + "--seed", + choices=["empty", "templates"], + default="templates", + help="Whether to install empty runtime only or include starter templates", + ) + return parser.parse_args() + + +def resolve_repo_root(explicit: str | None) -> Path: + if explicit: + return Path(explicit).resolve() + try: + root = subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + return Path(root) + except subprocess.CalledProcessError: + return Path.cwd().resolve() + + +def write_text(path: Path, content: str, force: bool) -> None: + if path.exists() and not force: + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def copy_file(src: Path, dst: Path, force: bool) -> None: + if dst.exists() and not force: + return + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + +def make_executable(path: Path) -> None: + mode = path.stat().st_mode + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def read_reference(package_root: Path, relative_path: str, fallback: str) -> str: + path = package_root / relative_path + if path.exists(): + return path.read_text(encoding="utf-8") + return fallback + + +def install_runtime(repo_root: Path, package_root: Path, force: bool) -> None: + refactor_root = repo_root / ".refactor" + for rel in [ + "docs", + "rules", + "profiles", + "scripts", + "reports", + "db", + "logs", + "temp", + ]: + (refactor_root / rel).mkdir(parents=True, exist_ok=True) + + write_text(refactor_root / ".gitignore", GITIGNORE, force) + write_text( + refactor_root / "docs" / "install-and-troubleshooting.md", + read_reference( + package_root, + "references/install-and-troubleshooting.md", + INSTALL_GUIDE_FALLBACK, + ), + force, + ) + + package_script_dir = package_root / "scripts" + for name in SCRIPT_NAMES: + src = package_script_dir / name + dst = refactor_root / "scripts" / name + copy_file(src, dst, force) + make_executable(dst) + + startup_dir = repo_root / ".startup" + startup_dir.mkdir(parents=True, exist_ok=True) + startup = startup_dir / "team-lead" + write_text( + startup, + read_reference( + package_root, + "assets/startup-wrapper-template/team-lead.py", + STARTUP_WRAPPER_FALLBACK, + ), + force, + ) + make_executable(startup) + + +def install_templates(repo_root: Path, package_root: Path, force: bool) -> None: + docs_dir = repo_root / ".refactor" / "docs" + rules_dir = repo_root / ".refactor" / "rules" + + write_text( + docs_dir / "rule-template.md", + read_reference( + package_root, + "references/rule-doc-template.md", + RULE_DOC_TEMPLATE_FALLBACK, + ), + force, + ) + write_text( + rules_dir / "rule-template.ttl", + read_reference( + package_root, + "references/rule-ttl-template.ttl", + RULE_TTL_TEMPLATE_FALLBACK, + ), + force, + ) + + +def preview_startup(repo_root: Path) -> str: + script = repo_root / ".refactor" / "scripts" / "session_start.py" + result = subprocess.run( + ["python3", str(script), "--mode", "startup"], + cwd=repo_root, + capture_output=True, + text=True, + check=False, + ) + return result.stdout.strip() + + +def main() -> int: + args = parse_args() + repo_root = resolve_repo_root(args.repo_root) + package_root = Path(__file__).resolve().parents[1] + + if not (repo_root / ".git").exists(): + print(f"warning: {repo_root} does not look like a git repo; continuing anyway") + + install_runtime(repo_root, package_root, args.force) + if args.seed == "templates": + install_templates(repo_root, package_root, args.force) + + print(f"installed refactory runtime into {repo_root}") + print(f"- startup wrapper: {repo_root / '.startup' / 'team-lead'}") + print(f"- runtime root: {repo_root / '.refactor'}") + + preview = preview_startup(repo_root) + if preview: + print("\nstartup preview:\n") + print(preview) + else: + print("\nstartup preview: (no triggers registered yet)") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/sc-refactory/scripts/log_lookup.py b/packages/sc-refactory/scripts/log_lookup.py new file mode 100644 index 000000000..21aad68b0 --- /dev/null +++ b/packages/sc-refactory/scripts/log_lookup.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Append refactor lookup request/result logs.""" + +from __future__ import annotations + +import argparse +import json +import sys + +from runtime import append_json_log, find_repo_root + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Log refactor lookup activity") + parser.add_argument("--stage", required=True, choices=["request", "result"]) + parser.add_argument("--status", default=None) + parser.add_argument("--rule-id", default=None) + parser.add_argument("--error-code", default=None) + parser.add_argument("--source", default="refactor-lookup") + parser.add_argument("--payload-file", default=None) + return parser.parse_args() + + +def load_payload(payload_file: str | None) -> dict: + if payload_file: + with open(payload_file, encoding="utf-8") as handle: + return json.load(handle) + raw = sys.stdin.read().strip() + if not raw: + return {} + return json.loads(raw) + + +def main() -> None: + args = parse_args() + root = find_repo_root() + payload = load_payload(args.payload_file) + append_json_log( + root, + "lookup.log", + { + "source": args.source, + "stage": args.stage, + "status": args.status, + "rule_id": args.rule_id, + "error_code": args.error_code, + "payload": payload, + }, + ) + + +if __name__ == "__main__": + main() diff --git a/packages/sc-refactory/scripts/preflight.py b/packages/sc-refactory/scripts/preflight.py new file mode 100644 index 000000000..19488cecd --- /dev/null +++ b/packages/sc-refactory/scripts/preflight.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Pre-flight check for refactor skills.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +from runtime import append_log, find_repo_root, resolve_oxigraph + +GUIDE_PATH = "./.refactor/docs/install-and-troubleshooting.md" + + +def check_oxigraph() -> tuple[bool, str]: + oxigraph = resolve_oxigraph() + if oxigraph is None: + return False, "oxigraph is not installed or not on PATH" + try: + result = subprocess.run( + [str(oxigraph), "--version"], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + return False, "oxigraph is not installed or not on PATH" + if result.returncode != 0: + return False, "oxigraph is not installed or not on PATH" + version = result.stdout.strip() + if version.startswith("oxigraph "): + version = "oxigraph v " + version.removeprefix("oxigraph ").strip() + return True, version + + +def check_paths(root: Path) -> tuple[bool, str]: + required = [ + root / ".refactor" / "rules", + root / ".refactor" / "docs", + root / ".refactor" / "scripts" / "session_start.py", + ] + missing = [str(p.relative_to(root)) for p in required if not p.exists()] + if missing: + return False, f"missing required paths: {', '.join(missing)}" + return True, "paths present" + + +def check_db_query(root: Path) -> tuple[bool, str]: + db_dir = root / ".refactor" / "db" + if not db_dir.is_dir(): + return False, "missing .refactor/db; run the session_start rebuild path" + + oxigraph = resolve_oxigraph() + if oxigraph is None: + return False, "oxigraph is not installed or not on PATH" + + try: + result = subprocess.run( + [ + str(oxigraph), + "query", + "--location", + str(db_dir), + "--query", + "PREFIX ref: <https://synaptic.canvas/refactor/> SELECT ?s WHERE { ?s ?p ?o } LIMIT 1", + "--results-format", + "json", + ], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + return False, "oxigraph is not installed or not on PATH" + if result.returncode != 0: + return False, "unable to query .refactor/db" + return True, "db query ok" + + +def rebuild_db(root: Path) -> tuple[bool, str]: + startup = root / ".refactor" / "scripts" / "session_start.py" + if not startup.is_file(): + return False, "missing .refactor/scripts/session_start.py" + + result = subprocess.run( + ["python3", str(startup), "--mode", "startup"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return False, "session_start.py returned non-zero" + + if not (root / ".refactor" / "db").is_dir(): + return False, "session_start.py did not rebuild .refactor/db" + + return True, "db rebuilt" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Pre-flight check for refactor skills") + parser.add_argument( + "--skill", + default="refactor", + choices=["refactor", "refactor-lookup", "refactor-write"], + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + root = find_repo_root(Path(__file__)) + append_log(root, "preflight.log", f"start skill={args.skill}") + + oxigraph_ok, oxigraph_version = check_oxigraph() + path_ok, path_message = check_paths(root) + db_dir = root / ".refactor" / "db" + + db_check: tuple[bool, str] + if oxigraph_ok and path_ok and not db_dir.is_dir(): + append_log(root, "preflight.log", "db missing; invoking session_start rebuild") + rebuild_ok, rebuild_message = rebuild_db(root) + append_log(root, "preflight.log", f"db rebuild result ok={rebuild_ok} message='{rebuild_message}'") + if rebuild_ok: + db_check = check_db_query(root) + else: + db_check = (False, rebuild_message) + else: + db_check = check_db_query(root) + + checks = [ + (oxigraph_ok, oxigraph_version), + (path_ok, path_message), + db_check, + ] + failures = [message for ok, message in checks if not ok] + + if not failures: + append_log(root, "preflight.log", f"success skill={args.skill} version='{oxigraph_version}'") + print(f"{oxigraph_version} checks pass") + sys.exit(0) + + append_log(root, "preflight.log", f"failure skill={args.skill} reasons={failures}") + print(f"tools are not installed or working to use this skill. please read {GUIDE_PATH}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/packages/sc-refactory/scripts/rebuild_db.py b/packages/sc-refactory/scripts/rebuild_db.py new file mode 100644 index 000000000..6ad39a320 --- /dev/null +++ b/packages/sc-refactory/scripts/rebuild_db.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Rebuild `.refactor/db` from committed rule files by reusing session_start.""" + +from __future__ import annotations + +import argparse +import subprocess +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Rebuild the local refactor DB") + parser.add_argument("--repo-root", default=".") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + repo_root = Path(args.repo_root).resolve() + script = repo_root / ".refactor" / "scripts" / "session_start.py" + result = subprocess.run( + ["python3", str(script), "--mode", "startup"], + cwd=repo_root, + check=False, + ) + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/sc-refactory/scripts/repair.py b/packages/sc-refactory/scripts/repair.py new file mode 100644 index 000000000..7bb18fe69 --- /dev/null +++ b/packages/sc-refactory/scripts/repair.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Scripted local repair path for refactor skills.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +from runtime import append_log, find_repo_root + + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Repair local refactor skill environment") + parser.add_argument( + "--skill", + default="refactor", + choices=["refactor", "refactor-lookup", "refactor-write"], + ) + return parser.parse_args() + + +def run(command: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=cwd, + capture_output=True, + text=True, + check=False, + ) + + +def main() -> None: + args = parse_args() + root = find_repo_root(Path(__file__)) + startup = root / ".refactor" / "scripts" / "session_start.py" + preflight = root / ".refactor" / "scripts" / "preflight.py" + append_log(root, "repair.log", f"start skill={args.skill}") + + if not startup.is_file() or not preflight.is_file(): + append_log(root, "repair.log", "failure missing startup or preflight script") + print( + "tools are not installed or working to use this skill. " + "please read ./.refactor/docs/install-and-troubleshooting.md" + ) + sys.exit(1) + + startup_result = run( + ["python3", str(startup), "--mode", "startup"], + cwd=root, + ) + append_log( + root, + "repair.log", + f"startup returncode={startup_result.returncode} stdout_lines={len(startup_result.stdout.splitlines())} stderr_lines={len(startup_result.stderr.splitlines())}", + ) + if startup_result.returncode != 0: + append_log(root, "repair.log", "failure startup returned non-zero") + print( + "tools are not installed or working to use this skill. " + "please read ./.refactor/docs/install-and-troubleshooting.md" + ) + sys.exit(1) + + preflight_result = run( + ["python3", str(preflight), "--skill", args.skill], + cwd=root, + ) + append_log( + root, + "repair.log", + f"preflight returncode={preflight_result.returncode} output='{preflight_result.stdout.strip()}'", + ) + sys.stdout.write(preflight_result.stdout) + if preflight_result.stderr: + sys.stderr.write(preflight_result.stderr) + sys.exit(preflight_result.returncode) + + +if __name__ == "__main__": + main() diff --git a/packages/sc-refactory/scripts/runtime.py b/packages/sc-refactory/scripts/runtime.py new file mode 100644 index 000000000..d1dd21e57 --- /dev/null +++ b/packages/sc-refactory/scripts/runtime.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Shared runtime helpers for refactor scripts.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path + + +def find_repo_root(anchor: Path | None = None) -> Path: + if anchor is not None: + candidate = anchor.resolve() + search = [candidate] + list(candidate.parents) + for parent in search: + if (parent / ".refactor" / "scripts").is_dir(): + return parent + + try: + root = subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + return Path(root) + except subprocess.CalledProcessError: + return Path.cwd() + + +def resolve_oxigraph() -> Path | None: + path = shutil.which("oxigraph") + if path: + return Path(path) + + candidates = [ + Path("/opt/homebrew/bin/oxigraph"), + Path("/usr/local/bin/oxigraph"), + ] + for candidate in candidates: + if candidate.is_file(): + return candidate + return None + + +def logs_dir(root: Path) -> Path: + return root / ".refactor" / "logs" + + +def append_log(root: Path, log_name: str, message: str) -> None: + log_dir = logs_dir(root) + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / log_name + timestamp = datetime.now(timezone.utc).isoformat() + with log_file.open("a", encoding="utf-8") as handle: + handle.write(f"[{timestamp}] {message}\n") + + +def append_json_log(root: Path, log_name: str, payload: dict) -> None: + log_dir = logs_dir(root) + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / log_name + record = { + "timestamp": datetime.now(timezone.utc).isoformat(), + **payload, + } + with log_file.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, sort_keys=True) + "\n") diff --git a/packages/sc-refactory/scripts/sc_shared.py b/packages/sc-refactory/scripts/sc_shared.py new file mode 100644 index 000000000..acc2f9336 --- /dev/null +++ b/packages/sc-refactory/scripts/sc_shared.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 +""" +Shared utilities for Synaptic Canvas package scripts. + +Provides: +- Allowed-path validation against runtime-configured directories. +- Agent Runner helpers (registry validation + task prompt build + audit). +- Shared runtime context helpers. +""" +from __future__ import annotations + +import datetime as _dt +import hashlib +import json +import os +import re +import subprocess +from pathlib import Path +from typing import Any, Dict, Iterable, Optional, Set, Tuple + +from pydantic import BaseModel, Field, field_validator + +try: + import yaml # type: ignore +except Exception: # pragma: no cover + yaml = None + + +# ============================================================================= +# Paths and settings +# ============================================================================= + + +def _normalize_path(value: Optional[str | Path]) -> Optional[Path]: + if value is None: + return None + return Path(value).expanduser().resolve() + + +def _read_json(path: Path) -> Optional[dict]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + + +class PathPolicy(BaseModel): + """Resolved allowed-path policy.""" + + cwd: Path + project_dir: Optional[Path] = None + codex_home: Optional[Path] = None + additional_dirs: Set[Path] = Field(default_factory=set) + + @field_validator("cwd", "project_dir", "codex_home", mode="before") + @classmethod + def _validate_path(cls, v): + return _normalize_path(v) + + +class RuntimeContext(BaseModel): + """Shared runtime context derived from environment + filesystem.""" + + cwd: Path + project_dir: Optional[Path] + codex_home: Optional[Path] + allowed_dirs: Set[Path] = Field(default_factory=set) + + +def get_project_dir() -> Optional[Path]: + """Return project root from environment variables if set.""" + project_dir = os.getenv("CLAUDE_PROJECT_DIR") or os.getenv("CODEX_PROJECT_DIR") + return _normalize_path(project_dir) + + +def _collect_additional_dirs(project_dir: Optional[Path]) -> Set[Path]: + """Collect additionalDirectories from settings files.""" + settings_paths = [ + Path("~/.claude/settings.json").expanduser(), + Path("~/.codex/settings.json").expanduser(), + ] + if project_dir: + settings_paths.extend( + [ + project_dir / ".claude" / "settings.json", + project_dir / ".codex" / "settings.json", + ] + ) + + codex_home = os.getenv("CODEX_HOME") + if codex_home: + settings_paths.append(Path(codex_home) / "settings.json") + + allowed: Set[Path] = set() + for path in settings_paths: + if not path.exists(): + continue + data = _read_json(path) + if not data: + continue + extra = (data.get("permissions") or {}).get("additionalDirectories") + if isinstance(extra, list): + for entry in extra: + if isinstance(entry, str) and entry.strip(): + allowed.add(_normalize_path(entry)) + return {p for p in allowed if p is not None} + + +def build_path_policy(cwd: Optional[Path] = None) -> PathPolicy: + cwd = _normalize_path(cwd or Path.cwd()) + project_dir = get_project_dir() + codex_home = _normalize_path(os.getenv("CODEX_HOME")) + additional = _collect_additional_dirs(project_dir) + return PathPolicy(cwd=cwd, project_dir=project_dir, codex_home=codex_home, additional_dirs=additional) + + +def collect_allowed_dirs(policy: PathPolicy) -> Set[Path]: + allowed = {policy.cwd} + if policy.project_dir: + allowed.add(policy.project_dir) + if policy.codex_home: + allowed.add(policy.codex_home) + allowed.update(policy.additional_dirs) + return allowed + + +def _is_relative_to(path: Path, base: Path) -> bool: + try: + return path.is_relative_to(base) + except AttributeError: + try: + path.relative_to(base) + return True + except ValueError: + return False + + +def is_path_allowed(target: Path, allowed_dirs: Iterable[Path]) -> bool: + target = _normalize_path(target) + if target is None: + return False + for base in allowed_dirs: + if base and _is_relative_to(target, base): + return True + return False + + +def validate_allowed_path(target: Path, allowed_dirs: Iterable[Path], label: str = "path") -> Path: + resolved = _normalize_path(target) + if resolved is None: + raise ValueError(f"Invalid {label}: {target}") + if not is_path_allowed(resolved, allowed_dirs): + raise ValueError(f"{label} is outside allowed directories: {resolved}") + return resolved + + +def load_runtime_context(cwd: Optional[Path] = None) -> RuntimeContext: + policy = build_path_policy(cwd=cwd) + allowed = collect_allowed_dirs(policy) + return RuntimeContext( + cwd=policy.cwd, + project_dir=policy.project_dir, + codex_home=policy.codex_home, + allowed_dirs=allowed, + ) + + +def find_repo_root(start: Optional[Path] = None) -> Optional[Path]: + """Find git repo root by walking up to a .git directory.""" + current = _normalize_path(start or Path.cwd()) + if current is None: + return None + for parent in [current, *current.parents]: + if (parent / ".git").exists(): + return parent + return None + + +def is_git_repo(path: Path) -> bool: + """Return True if the path is inside a valid git repository.""" + repo_path = _normalize_path(path) + if repo_path is None: + return False + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=repo_path, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + return bool(result.stdout.strip()) + except Exception: + return False + + +# ============================================================================= +# Hook JSON validation helpers +# ============================================================================= + + +def get_tool_command(payload: Dict[str, Any]) -> str: + """Extract tool command string from a hook payload.""" + if not isinstance(payload, dict): + return "" + tool_input = payload.get("tool_input") or {} + if isinstance(tool_input, dict): + for key in ("command", "input"): + val = tool_input.get(key) + if isinstance(val, str): + return val + for key in ("command", "input"): + val = payload.get(key) + if isinstance(val, str): + return val + return "" + + +def extract_json_from_command(command: str) -> Dict[str, Any]: + """Extract a JSON object embedded in a command string.""" + if not isinstance(command, str): + raise ValueError("Command must be a string") + + text = command.strip() + if text.startswith("{") and text.endswith("}"): + try: + data = json.loads(text) + if isinstance(data, dict): + return data + except Exception: + pass + + greedy = re.search(r"\{.*\}", text, re.DOTALL) + if greedy: + try: + data = json.loads(greedy.group(0)) + if isinstance(data, dict): + return data + except Exception: + pass + + for match in re.finditer(r"\{.*?\}", text, re.DOTALL): + try: + data = json.loads(match.group(0)) + if isinstance(data, dict): + return data + except Exception: + continue + + raise ValueError("Expected JSON object in command") + + +def extract_hook_json(payload: Dict[str, Any]) -> Dict[str, Any]: + """Extract JSON object from hook payload tool command.""" + command = get_tool_command(payload) + if not command: + raise ValueError("Expected tool_input.command in payload") + return extract_json_from_command(command) + + +def validate_json_payload(payload: Dict[str, Any], schema: type[BaseModel]) -> BaseModel: + """Validate payload dict with a pydantic schema and return the model.""" + if not isinstance(payload, dict): + raise ValueError("Expected JSON object") + return schema.model_validate(payload) + + +def validate_hook_json(payload: Dict[str, Any], schema: type[BaseModel]) -> BaseModel: + """Extract and validate JSON from hook payload with schema.""" + data = extract_hook_json(payload) + return validate_json_payload(data, schema) + + +# ============================================================================= +# Agent Runner helpers (self-contained) +# ============================================================================= + +REGISTRY_DEFAULT = os.path.join(".claude", "agents", "registry.yaml") +LOGS_DIR = os.path.join(".claude", "state", "logs") + + +class AgentSpec(BaseModel): + name: str + path: str + expected_version: Optional[str] = None + + +class AgentFileInfo(BaseModel): + path: str + version_frontmatter: Optional[str] + sha256: str + + +class AgentInvokeRequest(BaseModel): + agent: str + params: Dict[str, Any] = Field(default_factory=dict) + registry_path: str = REGISTRY_DEFAULT + timeout_s: int = 120 + + +class AgentInvokeResult(BaseModel): + ok: bool + agent: Dict[str, Any] + task_prompt: str + timeout_s: int + audit_path: str + note: str + + +def _read_text(path: str) -> str: + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +def _read_bytes(path: str) -> bytes: + with open(path, "rb") as f: + return f.read() + + +def _extract_frontmatter(text: str) -> str: + lines = text.splitlines() + fm_start = None + for i, line in enumerate(lines): + if line.strip() == "---": + fm_start = i + break + if fm_start is None: + return "" + for j in range(fm_start + 1, len(lines)): + if lines[j].strip() == "---": + return "\n".join(lines[fm_start + 1 : j]) + return "" + + +def _parse_yaml(s: str) -> Dict[str, Any]: + if not s: + return {} + if yaml is not None: + return yaml.safe_load(s) or {} + out: Dict[str, Any] = {} + for line in s.splitlines(): + match = re.match(r"^([A-Za-z0-9_\-]+):\s*(.*)$", line.strip()) + if match: + key, val = match.group(1), match.group(2) + out[key] = val if val else None + return out + + +def _load_yaml_file(path: str) -> Dict[str, Any]: + text = _read_text(path) + if yaml is not None: + return yaml.safe_load(text) or {} + data: Dict[str, Any] = {} + current = None + for line in text.splitlines(): + if line.strip().startswith("agents:"): + data["agents"] = {} + current = "agents" + continue + if current == "agents": + match = re.match(r"^\s{2}([A-Za-z0-9_\-]+):\s*$", line) + if match: + data["agents"][match.group(1)] = {} + match_ver = re.match(r"^\s{4}version:\s*(.+)$", line) + if match_ver: + last = list(data["agents"].keys())[-1] + data["agents"][last]["version"] = match_ver.group(1) + match_path = re.match(r"^\s{4}path:\s*(.+)$", line) + if match_path: + last = list(data["agents"].keys())[-1] + data["agents"][last]["path"] = match_path.group(1) + return data + + +def load_registry(path: str = REGISTRY_DEFAULT) -> Dict[str, Any]: + if not os.path.isfile(path): + raise FileNotFoundError(f"Registry not found: {path}") + return _load_yaml_file(path) + + +def get_agent_spec(registry: Dict[str, Any], name: str) -> AgentSpec: + agents = (registry or {}).get("agents", {}) + if name not in agents: + raise KeyError(f"Agent '{name}' not found in registry") + ent = agents[name] + return AgentSpec(name=name, path=ent.get("path", ""), expected_version=ent.get("version")) + + +def read_agent_file_info(path: str) -> AgentFileInfo: + text = _read_text(path) + fm_text = _extract_frontmatter(text) + fm = _parse_yaml(fm_text) + version = fm.get("version") if isinstance(fm, dict) else None + digest = hashlib.sha256(_read_bytes(path)).hexdigest() + return AgentFileInfo(path=path, version_frontmatter=version, sha256=digest) + + +def validate_agent(registry_path: str, agent_name: str) -> Tuple[AgentSpec, AgentFileInfo]: + reg = load_registry(registry_path) + spec = get_agent_spec(reg, agent_name) + if not spec.path: + raise ValueError(f"Agent '{agent_name}' has no path in registry") + agent_path = spec.path + if not os.path.isabs(agent_path): + agent_path = os.path.abspath(agent_path) + if not os.path.isfile(agent_path): + raise FileNotFoundError(f"Agent file not found: {agent_path}") + info = read_agent_file_info(agent_path) + if spec.expected_version and info.version_frontmatter and str(spec.expected_version) != str(info.version_frontmatter): + raise ValueError( + f"Version mismatch for '{agent_name}': file={info.version_frontmatter} registry={spec.expected_version}" + ) + return spec, info + + +def build_task_prompt(agent_file_path: str, params: Dict[str, Any]) -> str: + lines = [ + f"Load {agent_file_path} and execute with parameters:", + ] + for k, v in params.items(): + lines.append(f"- {k}: {v}") + lines.append("Return ONLY fenced JSON as per the agent's Output Format section.") + return "\n".join(lines) + + +def _ensure_dir(path: str) -> None: + os.makedirs(path, exist_ok=True) + + +def write_audit(agent: AgentSpec, info: AgentFileInfo, outcome: str, duration_ms: Optional[int] = None) -> str: + _ensure_dir(LOGS_DIR) + ts = _dt.datetime.utcnow().isoformat(timespec="seconds") + "Z" + record = { + "timestamp": ts, + "agent": agent.name, + "version_frontmatter": info.version_frontmatter, + "file_sha256": info.sha256, + "invoker": "agent-runner", + "outcome": outcome, + } + if duration_ms is not None: + record["duration_ms"] = duration_ms + fname = f"agent-runner-{agent.name}-{ts.replace(':','').replace('-','').replace('T','_')}.json" + fpath = os.path.join(LOGS_DIR, fname) + with open(fpath, "w", encoding="utf-8") as f: + json.dump(record, f, indent=2) + return fpath + + +def invoke_agent_runner(request: AgentInvokeRequest) -> AgentInvokeResult: + spec, info = validate_agent(request.registry_path, request.agent) + prompt = build_task_prompt(info.path, request.params) + audit_path = write_audit(spec, info, outcome="prepared") + result = AgentInvokeResult( + ok=True, + agent={ + "name": spec.name, + "path": info.path, + "version": info.version_frontmatter, + "sha256": info.sha256, + }, + task_prompt=prompt, + timeout_s=request.timeout_s, + audit_path=audit_path, + note="Agent Runner does not launch the Task tool; pass task_prompt to the Task tool.", + ) + return result diff --git a/packages/sc-refactory/scripts/session_start.py b/packages/sc-refactory/scripts/session_start.py new file mode 100644 index 000000000..7476439aa --- /dev/null +++ b/packages/sc-refactory/scripts/session_start.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Inject the refactor trigger index into session startup context.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from runtime import append_log, find_repo_root, resolve_oxigraph + + +QUERY = """ +PREFIX ref: <https://synaptic.canvas/refactor/> +SELECT DISTINCT ?signal WHERE { + ?r a ref:Rule . + { ?r ref:triggeredByNamespace ?signal } + UNION + { ?r ref:triggeredByType ?signal } + UNION + { ?r ref:triggeredByError ?signal } + UNION + { ?r ref:triggeredByString ?signal } + UNION + { ?r ref:triggeredByAssembly ?signal } +} +ORDER BY ?signal +""" + +def load_rules_into_db(rules_dir: Path, db_dir: Path, oxigraph: Path) -> None: + """Load all tracked Turtle rule files into a fresh Oxigraph store.""" + db_dir.mkdir(parents=True, exist_ok=True) + + for ttl in sorted(rules_dir.glob("*.ttl")): + result = subprocess.run( + [str(oxigraph), "load", "--location", str(db_dir), "--file", str(ttl)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"failed to load {ttl.name}: {result.stderr.strip()}") + + +def query_triggers(db_dir: Path, query_dir: Path, oxigraph: Path) -> list[dict]: + """Run the trigger query against a store and return JSON bindings.""" + query_dir.mkdir(parents=True, exist_ok=True) + query_file = query_dir / "startup-query.rq" + query_file.write_text(QUERY, encoding="utf-8") + + result = subprocess.run( + [ + str(oxigraph), + "query", + "--location", + str(db_dir), + "--query-file", + str(query_file), + "--results-format", + "json", + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"startup trigger query failed: {result.stderr.strip()}") + + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + raise RuntimeError("startup trigger query returned invalid JSON") + return data.get("results", {}).get("bindings", []) + + +def publish_db(build_dir: Path, db_dir: Path, temp_dir: Path) -> None: + """Swap a validated build directory into place atomically enough for startup.""" + backup_dir = temp_dir / "db-previous" + if backup_dir.exists(): + shutil.rmtree(backup_dir, ignore_errors=True) + + if db_dir.exists(): + db_dir.rename(backup_dir) + + try: + build_dir.rename(db_dir) + except Exception: + if backup_dir.exists() and not db_dir.exists(): + backup_dir.rename(db_dir) + raise + else: + if backup_dir.exists(): + shutil.rmtree(backup_dir, ignore_errors=True) + + +def rebuild_db_from_rules(rules_dir: Path, db_dir: Path, temp_dir: Path, oxigraph: Path) -> list[dict]: + """ + Rebuild the runtime DB from committed Turtle rules, validate it with the + startup query, and only then publish it to .refactor/db. + """ + temp_dir.mkdir(parents=True, exist_ok=True) + build_dir = Path(tempfile.mkdtemp(prefix="db-build-", dir=temp_dir)) + + try: + load_rules_into_db(rules_dir, build_dir, oxigraph) + bindings = query_triggers(build_dir, temp_dir, oxigraph) + publish_db(build_dir, db_dir, temp_dir) + return bindings + finally: + if build_dir.exists(): + shutil.rmtree(build_dir) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Refactor trigger index for session context") + parser.add_argument("--mode", default="startup", choices=["startup", "resume", "clear", "compact"]) + parser.add_argument("--atm-team", default=None) + parser.add_argument("--atm-identity", default=None) + return parser.parse_args() + + +def emit_trigger_index(bindings: list[dict], args: argparse.Namespace) -> None: + print("# REFACTOR TRIGGERS") + print("# Approved fix patterns only. On match -> invoke refactor-lookup.") + print("# Use /refactor-lookup <trigger> <additional-context-error-logs> when one of these items appears in a build error.") + if args.atm_team or args.atm_identity: + team = args.atm_team or "-" + identity = args.atm_identity or "-" + print(f"# ATM team: {team} identity: {identity}") + print("#") + if not bindings: + print("# (no triggers registered)") + return + for binding in bindings: + signal = binding["signal"]["value"] + print(signal) + + +def main() -> None: + args = parse_args() + repo_root = find_repo_root(Path(__file__)) + refactor_root = repo_root / ".refactor" + temp_dir = refactor_root / "temp" + oxigraph = resolve_oxigraph() + append_log(repo_root, "session_start.log", f"start mode={args.mode}") + + if oxigraph is None: + append_log(repo_root, "session_start.log", "skip reason='oxigraph binary not found'") + sys.exit(0) + + rules_dir = refactor_root / "rules" + db_dir = refactor_root / "db" + + if not rules_dir.is_dir(): + append_log(repo_root, "session_start.log", "skip reason='rules directory missing'") + sys.exit(0) + + try: + append_log( + repo_root, + "session_start.log", + f"rebuild begin rules_dir='{rules_dir}' db_dir='{db_dir}' oxigraph='{oxigraph}'", + ) + bindings = rebuild_db_from_rules(rules_dir, db_dir, temp_dir, oxigraph) + except RuntimeError as exc: + append_log(repo_root, "session_start.log", f"failure {exc}") + sys.exit(0) + + append_log(repo_root, "session_start.log", f"success trigger_count={len(bindings)}") + emit_trigger_index(bindings, args) + + +if __name__ == "__main__": + main() diff --git a/packages/sc-refactory/scripts/sync_subset.py b/packages/sc-refactory/scripts/sync_subset.py new file mode 100644 index 000000000..9b55f25c3 --- /dev/null +++ b/packages/sc-refactory/scripts/sync_subset.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Sync a filtered local subset of rule/doc source files into `.refactor/`.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +from pathlib import Path + +try: + import yaml +except Exception: # pragma: no cover + yaml = None + +from runtime import append_json_log, find_repo_root + +DOC_REF_RE = re.compile(r"\.refactor/docs/[A-Za-z0-9._/\-]+\.md") +RULE_ID_RE = re.compile(r'ref:ruleId\s+"([^"]+)"') + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Sync a local subset of refactor rules into a repo") + parser.add_argument("--repo-root", default=".") + parser.add_argument("--source-root", default=None, help="Bundle root containing rules/ and docs/") + parser.add_argument("--profile", default=None, help="Profile name to resolve under profiles/") + parser.add_argument("--profile-file", default=None, help="Explicit YAML or JSON profile path") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--clean", action="store_true", help="Remove files synced by the previous run before copying") + parser.add_argument("--rebuild-db", action="store_true", help="Rebuild .refactor/db after syncing") + return parser.parse_args() + + +def load_structured_file(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + if path.suffix.lower() == ".json": + return json.loads(text) + if yaml is None: + raise RuntimeError("PyYAML is required to read YAML sync profiles") + return yaml.safe_load(text) or {} + + +def resolve_profile_path(args: argparse.Namespace, repo_root: Path) -> Path | None: + if args.profile_file: + return Path(args.profile_file).resolve() + if not args.profile: + return None + + candidates = [ + repo_root / ".refactor" / "profiles" / f"{args.profile}.yaml", + repo_root / ".refactor" / "profiles" / f"{args.profile}.yml", + repo_root / ".refactor" / "profiles" / f"{args.profile}.json", + ] + for candidate in candidates: + if candidate.is_file(): + return candidate + raise RuntimeError(f"profile not found: {args.profile}") + + +def resolve_source_root(args: argparse.Namespace, profile: dict, profile_path: Path | None) -> Path: + if args.source_root: + return Path(args.source_root).resolve() + source_value = profile.get("source_root") + if source_value: + source_path = Path(source_value) + if not source_path.is_absolute() and profile_path is not None: + source_path = (profile_path.parent / source_path).resolve() + return source_path.resolve() + raise RuntimeError("source root is required; pass --source-root or define source_root in the profile") + + +def load_profile(args: argparse.Namespace, repo_root: Path) -> tuple[dict, Path | None]: + profile_path = resolve_profile_path(args, repo_root) + if profile_path is None: + return {}, None + if not profile_path.is_file(): + raise RuntimeError(f"profile file not found: {profile_path}") + return load_structured_file(profile_path), profile_path + + +def parse_rule_id(ttl_path: Path) -> str | None: + match = RULE_ID_RE.search(ttl_path.read_text(encoding="utf-8")) + return match.group(1) if match else None + + +def match_any(path: Path, patterns: list[str], base: Path) -> bool: + rel = path.relative_to(base).as_posix() + name = path.name + for pattern in patterns: + if Path(rel).match(pattern) or Path(name).match(pattern): + return True + return False + + +def select_rule_files(rules_dir: Path, profile: dict) -> list[Path]: + ttl_files = sorted(rules_dir.glob("*.ttl")) + if not profile: + return ttl_files + + selected: list[Path] = [] + rule_ids = set(profile.get("rule_ids") or []) + rule_files = list(profile.get("rule_files") or []) + include_globs = list(profile.get("include_globs") or []) + + for ttl in ttl_files: + if rule_ids: + parsed_id = parse_rule_id(ttl) + if parsed_id and parsed_id in rule_ids: + selected.append(ttl) + continue + if rule_files and match_any(ttl, rule_files, rules_dir): + selected.append(ttl) + continue + if include_globs and match_any(ttl, include_globs, rules_dir): + selected.append(ttl) + continue + + deduped: dict[str, Path] = {} + for ttl in selected: + deduped[ttl.name] = ttl + return sorted(deduped.values()) + + +def collect_doc_paths(source_root: Path, selected_ttls: list[Path], profile: dict) -> list[Path]: + docs_dir = source_root / "docs" + selected: dict[str, Path] = {} + + for ttl in selected_ttls: + text = ttl.read_text(encoding="utf-8") + for rel in DOC_REF_RE.findall(text): + doc_path = source_root / rel.removeprefix(".refactor/") + if doc_path.is_file(): + selected[doc_path.relative_to(docs_dir).as_posix()] = doc_path + + doc_files = list(profile.get("doc_files") or []) + if docs_dir.is_dir() and doc_files: + for doc in docs_dir.rglob("*.md"): + if match_any(doc, doc_files, docs_dir): + selected[doc.relative_to(docs_dir).as_posix()] = doc + + return sorted(selected.values()) + + +def manifest_path(repo_root: Path) -> Path: + return repo_root / ".refactor" / "temp" / "sync_subset_manifest.json" + + +def load_previous_manifest(repo_root: Path) -> dict: + path = manifest_path(repo_root) + if not path.is_file(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + + +def write_manifest(repo_root: Path, payload: dict, dry_run: bool) -> None: + if dry_run: + return + path = manifest_path(repo_root) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + +def remove_previous(repo_root: Path, dry_run: bool) -> list[str]: + previous = load_previous_manifest(repo_root) + removed: list[str] = [] + for rel in previous.get("synced_files", []): + path = repo_root / rel + if path.exists(): + removed.append(rel) + if not dry_run: + path.unlink() + return removed + + +def copy_files(files: list[Path], source_base: Path, dest_base: Path, dry_run: bool) -> list[str]: + copied: list[str] = [] + for src in files: + rel = src.relative_to(source_base) + dst = dest_base / rel + copied.append(dst.as_posix()) + if dry_run: + continue + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + return copied + + +def rebuild_db(repo_root: Path) -> tuple[int, str]: + script = repo_root / ".refactor" / "scripts" / "rebuild_db.py" + result = subprocess.run( + ["python3", str(script), "--repo-root", str(repo_root)], + cwd=repo_root, + capture_output=True, + text=True, + check=False, + ) + return result.returncode, result.stdout.strip() or result.stderr.strip() + + +def main() -> int: + args = parse_args() + repo_root = find_repo_root(Path(args.repo_root)) + profile, profile_path = load_profile(args, repo_root) + source_root = resolve_source_root(args, profile, profile_path) + + rules_dir = source_root / "rules" + docs_dir = source_root / "docs" + if not rules_dir.is_dir(): + raise SystemExit("source root must contain a rules/ directory") + + selected_ttls = select_rule_files(rules_dir, profile) + selected_docs = collect_doc_paths(source_root, selected_ttls, profile) + + removed: list[str] = [] + if args.clean or profile.get("clean") is True: + removed = remove_previous(repo_root, args.dry_run) + + copied_rules = copy_files( + selected_ttls, + rules_dir, + repo_root / ".refactor" / "rules", + args.dry_run, + ) + copied_docs = copy_files( + selected_docs, + docs_dir, + repo_root / ".refactor" / "docs", + args.dry_run, + ) + + rebuild_requested = args.rebuild_db or profile.get("rebuild_db") is True + rebuild_result: dict | None = None + if rebuild_requested and not args.dry_run: + code, output = rebuild_db(repo_root) + rebuild_result = {"returncode": code, "output": output} + + synced_files = copied_rules + copied_docs + write_manifest( + repo_root, + { + "profile": args.profile or profile.get("name"), + "profile_file": str(profile_path) if profile_path else None, + "source_root": str(source_root), + "synced_files": synced_files, + }, + args.dry_run, + ) + + result = { + "success": rebuild_result is None or rebuild_result["returncode"] == 0, + "data": { + "profile": args.profile or profile.get("name"), + "profile_file": str(profile_path) if profile_path else None, + "source_root": str(source_root), + "rule_count": len(copied_rules), + "doc_count": len(copied_docs), + "rules": copied_rules, + "docs": copied_docs, + "removed": removed, + "dry_run": args.dry_run, + "rebuild_db": rebuild_requested, + "rebuild_result": rebuild_result, + }, + "error": None, + } + + append_json_log(repo_root, "sync_subset.log", result["data"]) + print(json.dumps(result, indent=2)) + return 0 if result["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/sc-refactory/scripts/validate_agents.py b/packages/sc-refactory/scripts/validate_agents.py new file mode 100644 index 000000000..81e66aaf7 --- /dev/null +++ b/packages/sc-refactory/scripts/validate_agents.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Validate agent and skill versions against agents/registry.yaml.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +REGISTRY = ROOT / "agents" / "registry.yaml" +MANIFEST = ROOT / "manifest.yaml" + + +def load_yaml(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def frontmatter(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + if not text.startswith("---\n"): + return {} + _, rest = text.split("---\n", 1) + header, _, _ = rest.partition("\n---\n") + return yaml.safe_load(header) or {} + + +def main() -> int: + registry = load_yaml(REGISTRY) + manifest = load_yaml(MANIFEST) + package_version = manifest.get("version") + failures: list[str] = [] + + for name, info in registry.get("agents", {}).items(): + rel_path = info["path"].removeprefix(".claude/") + agent_path = ROOT / rel_path + if not agent_path.exists(): + failures.append(f"missing agent file: {info['path']}") + continue + fm = frontmatter(agent_path) + if fm.get("version") != info.get("version"): + failures.append( + f"agent version mismatch for {name}: file={fm.get('version')} registry={info.get('version')}" + ) + if package_version and fm.get("version") != package_version: + failures.append( + f"agent package-version mismatch for {name}: file={fm.get('version')} package={package_version}" + ) + + for name, info in registry.get("skills", {}).items(): + path = info.get("path") + if not path: + failures.append(f"missing skill path in registry: {name}") + continue + rel_path = path.removeprefix(".claude/") + skill_path = ROOT / rel_path + if not skill_path.exists(): + failures.append(f"missing skill file: {path}") + continue + fm = frontmatter(skill_path) + if fm.get("version") != info.get("version"): + failures.append( + f"skill version mismatch for {name}: file={fm.get('version')} registry={info.get('version')}" + ) + if package_version and fm.get("version") != package_version: + failures.append( + f"skill package-version mismatch for {name}: file={fm.get('version')} package={package_version}" + ) + + if failures: + for failure in failures: + print(f"ERROR: {failure}") + return 1 + + print("All agent and skill versions validated") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/sc-refactory/skills/refactor-lookup/SKILL.md b/packages/sc-refactory/skills/refactor-lookup/SKILL.md new file mode 100644 index 000000000..97b33d577 --- /dev/null +++ b/packages/sc-refactory/skills/refactor-lookup/SKILL.md @@ -0,0 +1,184 @@ +--- +name: refactor-lookup +version: 0.2.0 +description: > + Use this skill before editing when a known trigger appears during a refactor + session. It delegates graph lookup to a focused agent, returns the governing + markdown policy document plus a few repo-root fix references, and enforces the + approved-fixes-only workflow. +--- + +# Refactor Lookup + +Use this skill to answer one question: + +`Does this signal map to an approved refactor rule, and if so what document and sample fixes govern it?` + +Read [workflows.md](./workflows.md) for the full workflow and rationale. +If pre-flight fails, read +`.refactor/docs/install-and-troubleshooting.md`. + +## Agent Delegation + +Use Agent Runner to invoke `refactor-lookup-agent` as defined in +`.claude/agents/registry.yaml`. + +The agent must be invoked with a `0.1.x` version constraint. Treat unfenced or +malformed JSON as failure. + +Before invoking the agent, run the local pre-flight: + +```bash +python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/preflight.py" --skill refactor-lookup +``` + +If this prints `oxigraph v ... checks pass`, proceed. + +If it fails, do not invoke the agent. Read +`.refactor/docs/install-and-troubleshooting.md`, run the scripted repair path, +and rerun pre-flight. The background agent should be reserved for actual rule +lookup, not basic environment diagnosis. + +Runner payload: + +```json +{ + "agent": "refactor-lookup-agent", + "version_constraint": "0.1.x", + "timeout_s": 120, + "params": { + "signals": [ + { + "string": "FocusDistance", + "kind": "type", + "repo_relative_path": "RepoA/src/Optics/LensOperationsTests.cs", + "line": 183 + } + ], + "context": "Compiler error text, CI output, or review context here" + } +} +``` + +## Input to subagent + +```json +{ + "signals": [ + { + "string": "<type, namespace, assembly, error code, file name, or identifier>", + "kind": "<type|namespace|assembly|string|error>", + "repo_relative_path": "<repo-root-relative path>", + "full_path": "<absolute path if already known>", + "line": 4 + } + ], + "context": "<compiler errors, CI log lines, or surrounding context β€” may be multiline>" +} +``` + +- `string` β€” the signal value +- `kind` β€” optional but preferred +- `repo_relative_path` β€” preferred file path for policy references +- `full_path` β€” optional absolute path when that is what the caller has +- `line` β€” optional; include when known +- `context` β€” free text: compile output, test failure, log snippet + +## Subagent returns + +Match: + +```json +{ + "success": true, + "canceled": false, + "aborted_by": null, + "data": { + "matched": true, + "rule_id": "<rule_id>", + "confidence": "<high|medium|low>", + "reason": "<one sentence>", + "rule_text": "<full rule markdown from graph>", + "fix": { + "fix_id": "<fix_id>", + "path": "<repo-root-relative path>", + "line": 1 + }, + "references": [ + { + "fix_id": "<fix_id>", + "path": "<repo-root-relative path>", + "line": 88 + } + ] + }, + "error": null, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` + +No match: + +```json +{ + "success": true, + "canceled": false, + "aborted_by": null, + "data": { + "matched": false, + "rule_id": null, + "confidence": "low", + "reason": "<one sentence>", + "rule_text": null, + "fix": null, + "references": [] + }, + "error": null, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` + +Failure: + +```json +{ + "success": false, + "canceled": false, + "aborted_by": null, + "data": null, + "error": { + "code": "EXECUTION.GRAPH_UNAVAILABLE", + "message": "Graph store is unavailable", + "recoverable": true, + "suggested_action": "Verify oxigraph is installed and the graph store is readable" + }, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` + +## After receiving result + +- `success: true` and `data.matched: true` β€” read the primary markdown doc + first. Read code examples only if the doc alone does not answer the bounded + fix shape. +- `success: true` and `data.matched: false` β€” proceed without a rule. If you + discover a new approved pattern, invoke `refactor-write`. +- `success: false` β€” stop. Do not edit until graph access or JSON contract + issues are resolved. +- When signals come from markdown or documentation files, ignore occurrences + that appear only inside fenced code blocks. Fenced examples are documentation, + not actionable source matches. +- A trigger hit should surface the policy document broadly. The document may + still authorize only a narrower edit shape. diff --git a/packages/sc-refactory/skills/refactor-lookup/workflows.md b/packages/sc-refactory/skills/refactor-lookup/workflows.md new file mode 100644 index 000000000..b6a19c4da --- /dev/null +++ b/packages/sc-refactory/skills/refactor-lookup/workflows.md @@ -0,0 +1,73 @@ +# Refactor Lookup Workflows + +## Why This Skill Exists + +This repo is using a strict allowlist model for multi-repo refactors. The main +session should know the concise trigger index, but the expensive graph lookup +and example discovery should stay isolated in the lookup agent. + +Use this skill to answer one question: + +`Does this signal map to an approved refactor rule, and if so what document and sample fixes govern it?` + +Runtime convention: + +- tracked docs: `.refactor/docs/` +- tracked rules: `.refactor/rules/` +- persistent Oxigraph store: `.refactor/db/` +- startup/context scripts: `.refactor/scripts/` +- temp query files and scratch stores: `.refactor/temp/` + +## When To Invoke + +Invoke lookup before editing when any of these show up: + +- a known type or namespace +- a package or project name +- an error code +- a file name used as a policy trigger +- a string from the session-start trigger index + +## Workflow + +1. Extract the smallest useful set of signals from the current problem. +2. Run `python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/preflight.py" --skill refactor-lookup`. +3. If it prints `oxigraph v ... checks pass`, continue. +4. If it fails, read `.refactor/docs/install-and-troubleshooting.md` and run + `python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/repair.py" --skill refactor-lookup`. +5. Re-run pre-flight. +6. If pre-flight now passes, invoke `refactor-lookup-agent` through Agent + Runner. +7. If pre-flight still fails after the scripted repair path, stop and surface a + concise environment problem. +8. If lookup returns a match, read the approved markdown doc first. +9. Read the primary code example only if the doc alone is not enough. +10. Apply only the fix shapes authorized by the matched rule. + +## Interpretation Rules + +- A trigger hit is enough to surface the policy document. +- The lookup agent should reject only obvious false positives, such as hits that + occur only inside fenced code blocks in markdown. +- The approved markdown doc is the primary authority. +- Code examples are secondary references that show shape, not permission to go + beyond the rule. +- Some rules intentionally use one document for multiple triggers. The lookup + result should still surface that single shared document. + +## Repo Path Rules + +- Sample fix paths must start at the multi-repo root: + `RepoA/...`, `RepoB/...`, `.refactor/docs/...` +- Do not return absolute paths in fix references. + +## Failure Handling + +- Prefer local pre-flight failure over background-agent failure. If the + environment is obviously broken, do not spend an agent call on it. +- The skill should attempt the documented scripted repair path itself before + surfacing the problem to the user. +- If the agent returns unfenced JSON or malformed JSON, treat it as failure. +- If the graph is unavailable, stop and surface a concise error. +- If no rule matches, proceed without a rule only after lookup completes with a + valid `matched: false` result. diff --git a/packages/sc-refactory/skills/refactor-orchestrate/SKILL.md b/packages/sc-refactory/skills/refactor-orchestrate/SKILL.md new file mode 100644 index 000000000..06a453fa6 --- /dev/null +++ b/packages/sc-refactory/skills/refactor-orchestrate/SKILL.md @@ -0,0 +1,129 @@ +--- +name: refactor-orchestrate +version: 0.2.0 +description: > + Behavioral spec for the named `refactor-orchestrator` teammate. Use when a + rule-backed refactoring plan must be executed in development waves with QA + handoff to the named `refactor-quality-manager` teammate before commit. +--- + +# Refactor Orchestrate + +This skill is required reading for the named `refactor-orchestrator` teammate. + +Do not use a normal Agent Delegation table here. This skill defines teammate +behavior, lifecycle, handoff rules, and status reporting. + +## Responsibilities + +- load a refactoring plan made only of approved rule-backed items +- partition work into bounded development waves +- spawn `refactor-dev-agent` background sub-agents for authorized work +- hand off each completed wave to the named `refactor-quality-manager` teammate +- collect QA results and decide whether to rework, escalate, or mark approved +- allow commit only after explicit QA approval + +## Inputs + +Expect structured assignments containing: + +- `plan_id` +- repos or repo-set in scope +- ordered wave list +- work items per wave +- approved rule ids per work item +- commit boundary guidance +- optional prior status from earlier waves + +## Rules + +- Never authorize edits outside committed `.refactor/` rules. +- Never let a dev wave proceed without a backing rule id. +- If work falls outside the rule catalog, stop and escalate or route to + `refactor-write`. +- Track wave status explicitly: pending, in-progress, blocked, failed-qa, + approved. +- Do not blur dev and QA responsibilities. Hand off to `refactor-quality-manager` for + explicit approval. + +## Development Wave Pattern + +1. Validate the next wave against the active plan. +2. Spawn one or more `refactor-dev-agent` workers with narrow scope. +3. Wait for worker results and aggregate changed files. +4. Send the wave result to `refactor-quality-manager`. +5. Do not approve commit until QA returns pass. + +## Sub-Agent Spawning Rules + +- Spawn only bounded `refactor-dev-agent` work items. +- Prefer one rule family per worker when possible. +- Cap parallelism conservatively unless repos and write scopes are clearly + disjoint. +- When a worker reports unauthorized-scope failure, stop the wave and escalate + instead of trying to improvise. + +## Handoff To Quality Manager + +The handoff should include: + +- wave id +- work item ids +- changed files +- approved rule ids +- summary of what was intended +- any known caveats or partial failures + +## Wave State Model + +Use this state model: + +- `pending` +- `in-progress` +- `awaiting-qa` +- `failed-qa` +- `approved` +- `blocked` + +## Status Reporting + +Send structured status messages to the controlling lead or session. Include a +fenced JSON block when useful. + +Minimum status fields: + +- `role` +- `wave` +- `status` +- `summary` +- `next_action` + +Suggested JSON block: + +```json +{ + "success": true, + "data": { + "role": "refactor-orchestrator", + "wave": "wave-02", + "status": "awaiting-qa", + "approved": false, + "next_action": "refactor-quality-manager-review" + }, + "error": null +} +``` + +## Failure Boundaries + +Escalate instead of improvising when: + +- a work item needs edits not covered by existing `.refactor/` rules +- a dev worker returns malformed or unfenced JSON +- a dev worker edits files outside assigned scope +- QA reports unauthorized edits or missing tandem fixes + +## Commit Rule + +The orchestrator never treats β€œlooks fine” as sufficient. A wave is committable +only after an explicit QA pass from `refactor-quality-manager`. diff --git a/packages/sc-refactory/skills/refactor-quality-manager/SKILL.md b/packages/sc-refactory/skills/refactor-quality-manager/SKILL.md new file mode 100644 index 000000000..007c86286 --- /dev/null +++ b/packages/sc-refactory/skills/refactor-quality-manager/SKILL.md @@ -0,0 +1,106 @@ +--- +name: refactor-quality-manager +version: 0.2.0 +description: > + Behavioral spec for the named `refactor-quality-manager` teammate. Use when a + completed refactor wave must be checked for 100% compliance with approved + `.refactor/` rules before commit approval. +--- + +# Quality Manager + +This skill is required reading for the named `refactor-quality-manager` teammate. + +## Responsibilities + +- receive wave handoff from `refactor-orchestrator` +- spawn `refactor-qa-agent` background sub-agents as needed +- verify that every change is justified by approved `.refactor/` content +- detect unauthorized edits, missed tandem edits, and drift from approved fix + shape +- report pass/fail status and remediation requirements + +## Inputs + +Expect structured handoff from `refactor-orchestrator` containing: + +- `plan_id` +- `wave` +- `work_item_ids` +- `changed_files` +- `rule_ids` +- `summary` +- optional build/test context + +If rule ids are missing, do not attempt best-effort QA. Fail the wave and ask +for a corrected handoff. + +## QA Questions + +For each wave, answer: + +1. Is every edit justified by one or more approved rules? +2. Were all tandem edits required by those rules completed? +3. Were any extra edits introduced? +4. Does the implementation stay within the approved fix shape? +5. Is a new rule required because the work fell outside the catalog? + +If any answer is negative, the wave is not committable. + +## QA Process + +1. Validate that the handoff is complete enough to audit. +2. Spawn one or more `refactor-qa-agent` workers if parallel review is useful. +3. Aggregate findings by rule id and changed file. +4. Decide `pass` or `fail`. +5. Return a structured status update with blocked items and next action. + +## Review Heuristics + +- Prefer rule documents as primary authority. +- Use sample fixes to judge fix shape, not to invent new scope. +- Treat missing tandem edits as failures, not warnings. +- Treat edits with no clear rule justification as failures, not warnings. + +## Status Reporting + +Return structured status to the controlling lead or session. Include a fenced +JSON block when useful. + +Suggested JSON block: + +```json +{ + "success": true, + "data": { + "role": "refactor-quality-manager", + "wave": "wave-02", + "status": "fail", + "approved": false, + "blocked_items": [ + "unauthorized edit in RepoA/Foo.cs", + "missing tandem fix for rule radiant-data-conditional-reference-required" + ], + "next_action": "rework-wave-02" + }, + "error": null +} +``` + +## Pass Criteria + +Approve only when: + +- every changed file is covered by approved rules +- required tandem edits are present +- no unauthorized edits remain +- the wave stays within the approved fix shape closely enough to be safe + +## Failure Boundaries + +Fail the wave when: + +- rule coverage is incomplete +- changed files exceed the declared scope +- the worker output is malformed or missing essential context +- the wave introduces unrelated cleanup or opportunistic edits diff --git a/packages/sc-refactory/skills/refactor-write/SKILL.md b/packages/sc-refactory/skills/refactor-write/SKILL.md new file mode 100644 index 000000000..15a19a2cb --- /dev/null +++ b/packages/sc-refactory/skills/refactor-write/SKILL.md @@ -0,0 +1,142 @@ +--- +name: refactor-write +version: 0.2.0 +description: > + Use this skill to author or update approved refactor rules. The workflow is: + write the authoritative markdown doc, capture a minimal trigger set, add a + few repo-root sample fix references, write the Turtle source of truth under + `.refactor/rules/`, and verify lookup in Oxigraph using `.refactor/db/` or a + temporary store. +--- + +# Refactor Write + +Read [workflows.md](./workflows.md) for the full authoring workflow and the +temporary-store verification pattern. +If pre-flight fails, read +`.refactor/docs/install-and-troubleshooting.md`. + +## Agent Delegation + +Use Agent Runner to invoke `refactor-write-agent` as defined in +`.claude/agents/registry.yaml`. + +The agent must be invoked with a `0.1.x` version constraint. Treat unfenced or +malformed JSON as failure. + +Before invoking the agent, run the local pre-flight: + +```bash +python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/preflight.py" --skill refactor-write +``` + +If this prints `oxigraph v ... checks pass`, proceed. + +If it fails, do not invoke the agent. Read +`.refactor/docs/install-and-troubleshooting.md`, run the scripted repair path, +and rerun pre-flight first. + +## /rule-write + +```json +{ + "operation": "rule", + "rule_id": "<kebab-case identifier>", + "severity": "<breaking|warning|info>", + "doc_path": ".refactor/docs/<doc-name>.md", + "triggers": [ + { "signal": "FocusDistance", "kind": "type" }, + { "signal": "Legacy.Imaging.ExposureTime", "kind": "string" } + ], + "summary": "<short policy summary>", + "allowed_fixes": [ + "<bounded fix shape 1>", + "<bounded fix shape 2>" + ], + "notes": "<optional extra operator notes>", + "derived_from": null +} +``` + +Valid `kind` values: `namespace`, `type`, `error`, `string`, `assembly` + +## /fix-write + +Multiple fixes may be written in one call. Each fix is a pointer to where the +rule is documented or already applied in the codebase. Use repo-root-relative +paths only. + +```json +{ + "operation": "fix", + "rule_id": "<rule this fix belongs to>", + "fixes": [ + { + "fix_id": "fix-001", + "path": ".refactor/docs/example-rule.md", + "line": 1, + "confidence": "high", + "source": "approved-doc" + }, + { + "fix_id": "fix-002", + "path": "RepoA/Path/Example.cs", + "line": 177, + "confidence": "high", + "source": "recent-git-example" + } + ] +} +``` + +- `path` β€” repo-root-relative path to the file containing the approved example +- `line` β€” line number where the fix is visible +- `confidence` β€” `high`, `medium`, or `low` +- `source` β€” use values such as `approved-doc`, `canonical-example`, + `recent-git-example`, or another concise descriptive label + +## Agent returns + +Success: + +```json +{ + "success": true, + "canceled": false, + "aborted_by": null, + "data": { + "operation": "<rule|fix>", + "ids": ["<id1>", "<id2>"], + "ttl_paths": [".refactor/rules/<id>.ttl"], + "loaded_to_store": true + }, + "error": null, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` + +Failure: + +```json +{ + "success": false, + "canceled": false, + "aborted_by": null, + "data": null, + "error": { + "code": "VALIDATION.INPUT", + "message": "Fix path must be repo-root-relative", + "recoverable": true, + "suggested_action": "Replace the absolute path with a repo-root-relative path" + }, + "metadata": { + "duration_ms": 0, + "tool_calls": 0, + "retry_count": 0 + } +} +``` diff --git a/packages/sc-refactory/skills/refactor-write/workflows.md b/packages/sc-refactory/skills/refactor-write/workflows.md new file mode 100644 index 000000000..13ec30bde --- /dev/null +++ b/packages/sc-refactory/skills/refactor-write/workflows.md @@ -0,0 +1,84 @@ +# Refactor Write Workflows + +## Why This Skill Exists + +New refactor rules are policy, not just notes. They need: + +- a concise trigger or trigger set, +- one authoritative markdown how-to document, +- a few repo-root sample fix references, +- a Turtle source-of-truth entry that lookup can query safely. + +This keeps future sessions bounded to approved fixes instead of open-ended +refactoring. + +## Authoring Workflow + +1. Write or update the authoritative markdown document first. +2. Keep the document scoped to one coherent fix policy. +3. Choose the minimal trigger set that should surface that document. +4. Run `python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/preflight.py" --skill refactor-write`. +5. If it prints `oxigraph v ... checks pass`, continue. +6. If it fails, read `.refactor/docs/install-and-troubleshooting.md` and run + `python3 "$(git rev-parse --show-toplevel)/.refactor/scripts/repair.py" --skill refactor-write`. +7. Re-run pre-flight. +8. If pre-flight still fails after the scripted repair path, stop and surface a + concise environment problem. +9. Pick 1-4 sample fix references from multiple repos when possible. +10. Write the TTL rule and fix entries under `.refactor/rules/`. +11. Verify lookup in a temporary Oxigraph store before relying on the rule. + +## Authoring Rules + +- One document may support multiple precise triggers. +- The first fix reference should normally be the markdown doc itself with + `source: approved-doc`. +- Sample fix paths must be repo-root relative. +- Prefer one sample per repo over many samples from a single repo. +- Skip examples that appear only inside fenced code blocks in markdown. + +## Verification Workflow + +Use a temporary store so verification does not depend on stale local DB state. + +```bash +tmpdir="$(mktemp -d)" + +oxigraph load \ + --location "$tmpdir" \ + --file .refactor/rules/example-rule.ttl + +cat > "$tmpdir/query.rq" <<'SPARQL' +PREFIX ref: <https://synaptic.canvas/refactor/> +SELECT ?ruleId ?fixPath ?fixLine WHERE { + ?r a ref:Rule ; + ref:ruleId ?ruleId ; + ref:triggeredByString "ExampleTrigger" ; + ref:hasFix ?f . + ?f ref:fixPath ?fixPath ; + ref:fixLine ?fixLine . +} +ORDER BY ?fixLine +SPARQL + +oxigraph query \ + --location "$tmpdir" \ + --query-file "$tmpdir/query.rq" \ + --results-format json + +rm -rf "$tmpdir" +``` + +## Persistent Store Note + +`oxigraph load` appends triples. Rewriting an existing `.ttl` file and loading it +again can create duplicate local store state. The git-tracked `.ttl` files are +the source of truth; use a temporary store for verification whenever you are +changing a rule definition. + +Persistent runtime convention: + +- tracked docs: `.refactor/docs/` +- tracked rules: `.refactor/rules/` +- persistent Oxigraph store: `.refactor/db/` +- temp query files and scratch stores: `.refactor/temp/` diff --git a/packages/sc-refactory/skills/refactory-design/SKILL.md b/packages/sc-refactory/skills/refactory-design/SKILL.md new file mode 100644 index 000000000..4970ec7ae --- /dev/null +++ b/packages/sc-refactory/skills/refactory-design/SKILL.md @@ -0,0 +1,154 @@ +--- +name: refactory-design +version: 0.2.0 +description: > + Use this skill to design a constrained refactoring system before any package + or runtime is built. It walks through approved-rule design, trigger and + sample-fix guidelines, startup authorization policy, named-teammate + responsibilities, QA gating, and package/runtime layout. +--- + +# Refactory Design + +Use this skill when the goal is to create or package a rule-driven refactoring +toolkit, not to apply one specific refactor. + +## When To Use + +- designing a new approved-fix catalog +- converting repeated migration knowledge into reusable rules +- deciding what startup should and should not inject +- defining QA and commit-gate policy for large refactors +- packaging the system for reuse in other repos + +## Phase 1: Rule Discovery + +Work through these questions first: + +1. What changes are explicitly allowed? +2. What changes are explicitly prohibited? +3. What should count as one rule versus several related rules? +4. Which fixes must always happen in tandem? +5. What trigger values are concise and operator-recognizable? +6. What belongs in startup context versus only after lookup? +7. Which examples are canonical and cross-repo? +8. What QA checks must always pass before commit? + +Produce: + +- rule boundary guidelines +- trigger guidelines +- sample-fix guidelines +- startup allow/deny policy text +- QA criteria + +Do not skip this phase. If the rules are underspecified, the graph, startup +context, teammates, and QA layer all become noisy or unsafe. + +## Rule Design Checklist + +For each proposed rule, decide: + +- what exact change is approved +- what exact change is prohibited +- what trigger or trigger set should surface the rule +- whether multiple triggers should share one document +- whether multiple edits must happen in tandem +- what examples best demonstrate the approved fix shape +- what false positives should be ignored +- what QA assertions must be true before commit + +Minimum startup policy text should include: + +```text +# Only changes covered by approved .refactor/ rules are allowed. +# If a needed fix is not in .refactor/, stop or add the rule before editing. +``` + +## Phase 2: Runtime Design + +Decide: + +- installed runtime layout under `.refactor/` +- startup provider path under `.startup/` +- package source layout under `packages/sc-refactory/` +- rule doc and rule graph formats +- preflight, repair, startup, and logging responsibilities +- named teammate responsibilities +- background sub-agent boundaries + +Runtime decisions must be explicit about source-of-truth: + +- committed docs live in `.refactor/docs/` +- committed Turtle rules live in `.refactor/rules/` +- runtime DB is `.refactor/db/` and is disposable +- logs live in `.refactor/logs/` +- temp artifacts live in `.refactor/temp/` + +## Phase 3: Execution Model + +Design: + +- plan item schema +- development wave model +- QA wave model +- handoff between `refactor-orchestrator` and `refactor-quality-manager` +- commit gates +- escalation path for work not covered by `.refactor/` + +The intended execution model is: + +1. build a plan where every item cites approved rule ids +2. `refactor-orchestrator` launches bounded development waves +3. `refactor-quality-manager` launches QA review waves +4. failed waves return to rework or escalation +5. only QA-approved waves are committable + +## Phase 4: Package Outputs + +Produce: + +- package manifest +- plugin metadata +- background-agent registry +- skill list +- teammate prompts +- runtime script inventory +- installation steps +- validation plan + +## Recommended Output Shape + +When you finish the design pass, provide: + +1. a short summary of the policy model +2. the rule-design guidelines +3. the startup allow/deny text +4. the teammate and sub-agent boundaries +5. the package skeleton plan +6. open issues that need a human decision + +## Larger Design Risks To Surface + +Always call out: + +- rules that are too broad or too vague +- startup trigger lists that are too long +- teammates that would need to coordinate mutable shared state +- QA expectations that cannot be checked mechanically +- missing examples for high-risk rules + +## Core Policy + +- Only changes covered by committed `.refactor/` content are authorized. +- Trigger hits authorize lookup, not immediate editing. +- If a needed fix is not represented in `.refactor/`, stop, escalate, or add + the rule through the write flow before editing. +- QA must verify that 100% of changes are justified by approved rules before + commit. + +## Constraints + +- Do not jump straight to graph schema or scripting before rule design. +- Do not treat β€œsimilar” fixes as equivalent without explicit rule coverage. +- Do not recommend startup injection of full docs or examples. diff --git a/packages/sc-refactory/skills/refactory-install/SKILL.md b/packages/sc-refactory/skills/refactory-install/SKILL.md new file mode 100644 index 000000000..dcb565fe2 --- /dev/null +++ b/packages/sc-refactory/skills/refactory-install/SKILL.md @@ -0,0 +1,106 @@ +--- +name: refactory-install +version: 0.2.0 +description: > + Use this skill to materialize the refactory runtime into a repo by creating + `.refactor/` and `.startup/`, copying the runtime scripts, writing the local + install/troubleshooting guide, and verifying startup output and preflight. +--- + +# Refactory Install + +Use this skill after the policy design is stable enough to bootstrap a repo. + +## What This Installs + +- `.refactor/docs/` +- `.refactor/rules/` +- `.refactor/profiles/` +- `.refactor/scripts/` +- `.refactor/reports/` +- `.refactor/db/` +- `.refactor/logs/` +- `.refactor/temp/` +- `.startup/team-lead` + +The runtime scripts are copied from the installed package scripts into the +repo-local `.refactor/scripts/` folder. + +## Installation Preconditions + +Before running the installer, confirm: + +- the repo root is known +- the rule design is stable enough to define runtime layout +- `oxigraph` is installed from crates.io or the user accepts installer guidance +- the repo should receive a local-only install + +## How To Invoke + +Resolve the installed package script from the local repo first, then fall back +to the global install if needed. + +Local-first pattern: + +```bash +repo_root="$(git rev-parse --show-toplevel)" + +if [ -f "$repo_root/.claude/scripts/install_refactory.py" ]; then + python3 "$repo_root/.claude/scripts/install_refactory.py" --repo-root "$repo_root" +else + python3 "$HOME/.claude/scripts/install_refactory.py" --repo-root "$repo_root" +fi +``` + +Optional flags: + +- `--force` to refresh runtime-managed files +- `--seed empty` for runtime only +- `--seed templates` to include starter rule templates + +## What The Installer Must Do + +1. Resolve the target repo root. +2. Create `.refactor/` directories. +3. Create `.startup/team-lead`. +4. Copy runtime scripts into `.refactor/scripts/`. +5. Write `.refactor/.gitignore`. +6. Write `.refactor/docs/install-and-troubleshooting.md`. +7. Optionally install starter templates. +8. Render a startup preview. + +## Expected Result + +- `.refactor/.gitignore` ignores `db/`, `logs/`, and `temp/` +- `.startup/team-lead` exists +- `.refactor/scripts/session_start.py` exists +- `.refactor/docs/install-and-troubleshooting.md` exists +- `python3 .refactor/scripts/preflight.py --skill refactor-lookup` prints + `oxigraph v ... checks pass` when the environment is healthy + +## Verification Checklist + +After installation, verify: + +1. `.startup/team-lead` executes without traceback +2. `python3 .refactor/scripts/session_start.py --mode startup` prints a trigger + block or `(no triggers registered yet)` +3. `python3 .refactor/scripts/preflight.py --skill refactor-lookup` succeeds + when the runtime is healthy +4. `.refactor/db/`, `.refactor/logs/`, and `.refactor/temp/` are ignored by + `.refactor/.gitignore` + +## Failure Handling + +- If `oxigraph` is missing, surface the install guidance and stop. +- If the repo already contains user-authored rules or docs, do not overwrite + them unless `--force` or explicit approval says to do so. +- If startup preview fails, keep the installed files but report the exact stage + that failed. + +## Safety + +- Do not overwrite existing rule docs or TTL files unless the user asked for it. +- Runtime scripts may be refreshed during reinstall. +- The installer should be deterministic and low freedom. +- Treat `.refactor/docs/` and `.refactor/rules/` as user-owned content. diff --git a/packages/sc-refactory/tests/test_package_layout.py b/packages/sc-refactory/tests/test_package_layout.py new file mode 100644 index 000000000..deecb7693 --- /dev/null +++ b/packages/sc-refactory/tests/test_package_layout.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from pathlib import Path +import subprocess +import tempfile + +import yaml + + +def test_manifest_artifacts_exist() -> None: + root = Path(__file__).resolve().parents[1] + manifest = yaml.safe_load((root / "manifest.yaml").read_text(encoding="utf-8")) + + for _, paths in manifest["artifacts"].items(): + for rel_path in paths: + assert (root / rel_path).exists(), rel_path + + +def test_registry_paths_match_installed_layout() -> None: + root = Path(__file__).resolve().parents[1] + registry = yaml.safe_load((root / "agents" / "registry.yaml").read_text(encoding="utf-8")) + + for info in registry["agents"].values(): + assert info["path"].startswith(".claude/agents/") + + for info in registry["skills"].values(): + assert info["path"].startswith(".claude/skills/") + + +def test_install_refactory_smoke() -> None: + root = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + + script = root / "scripts" / "install_refactory.py" + subprocess.run( + ["python3", str(script), "--repo-root", str(repo), "--seed", "templates"], + check=True, + capture_output=True, + text=True, + ) + + expected = [ + repo / ".startup" / "team-lead", + repo / ".refactor" / ".gitignore", + repo / ".refactor" / "docs" / "install-and-troubleshooting.md", + repo / ".refactor" / "docs" / "rule-template.md", + repo / ".refactor" / "profiles", + repo / ".refactor" / "rules" / "rule-template.ttl", + repo / ".refactor" / "scripts" / "session_start.py", + repo / ".refactor" / "scripts" / "preflight.py", + repo / ".refactor" / "scripts" / "rebuild_db.py", + repo / ".refactor" / "scripts" / "sync_subset.py", + ] + + for path in expected: + assert path.exists(), str(path) + + +def test_sc_install_package_smoke() -> None: + root = Path(__file__).resolve().parents[1] + repo_root = root.parents[1] + + with tempfile.TemporaryDirectory() as tmp: + dest = Path(tmp) / ".claude" + subprocess.run( + [ + "python3", + str(repo_root / "tools" / "sc-install.py"), + "install", + "sc-refactory", + "--dest", + str(dest), + ], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + + installed = [ + dest / "commands" / "sc-refactory-install.md", + dest / "skills" / "refactor-lookup" / "SKILL.md", + dest / "agents" / "refactor-lookup-agent.md", + dest / "agents" / "registry.yaml", + dest / "scripts" / "install_refactory.py", + dest / ".claude-plugin" / "plugin.json", + dest / "assets" / "startup-wrapper-template" / "team-lead.py", + ] + for path in installed: + assert path.exists(), str(path) + + with tempfile.TemporaryDirectory() as repo_tmp: + repo = Path(repo_tmp) / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run( + [ + "python3", + str(dest / "scripts" / "install_refactory.py"), + "--repo-root", + str(repo), + "--seed", + "templates", + ], + check=True, + capture_output=True, + text=True, + ) + + runtime_paths = [ + repo / ".startup" / "team-lead", + repo / ".refactor" / ".gitignore", + repo / ".refactor" / "docs" / "install-and-troubleshooting.md", + repo / ".refactor" / "docs" / "rule-template.md", + repo / ".refactor" / "profiles", + repo / ".refactor" / "rules" / "rule-template.ttl", + repo / ".refactor" / "scripts" / "session_start.py", + repo / ".refactor" / "scripts" / "preflight.py", + ] + for path in runtime_paths: + assert path.exists(), str(path) + + +def test_sync_subset_smoke() -> None: + root = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + + install_script = root / "scripts" / "install_refactory.py" + subprocess.run( + ["python3", str(install_script), "--repo-root", str(repo), "--seed", "empty"], + check=True, + capture_output=True, + text=True, + ) + + bundle = Path(tmp) / "bundle" + (bundle / "rules").mkdir(parents=True) + (bundle / "docs").mkdir(parents=True) + (bundle / "profiles").mkdir(parents=True) + + (bundle / "docs" / "focus-distance.md").write_text( + "# Focus Distance\n", + encoding="utf-8", + ) + (bundle / "rules" / "focus-distance.ttl").write_text( + """@prefix ref: <https://synaptic.canvas/refactor/> . + +ref:focus-distance + a ref:Rule ; + ref:ruleId "focus-distance" ; + ref:severity "warning" ; + ref:ruleText "Use the shared focus-distance policy." ; + ref:triggeredByType "FocusDistance" ; + ref:hasFix [ + ref:path ".refactor/docs/focus-distance.md" ; + ref:line 1 + ] . +""", + encoding="utf-8", + ) + (bundle / "profiles" / "sample.yaml").write_text( + f"""name: sample +source_root: {bundle} +rule_ids: + - focus-distance +""", + encoding="utf-8", + ) + + sync_script = repo / ".refactor" / "scripts" / "sync_subset.py" + subprocess.run( + ["python3", str(sync_script), "--repo-root", str(repo), "--profile-file", str(bundle / "profiles" / "sample.yaml")], + check=True, + capture_output=True, + text=True, + ) + + assert (repo / ".refactor" / "rules" / "focus-distance.ttl").exists() + assert (repo / ".refactor" / "docs" / "focus-distance.md").exists() + assert (repo / ".refactor" / "temp" / "sync_subset_manifest.json").exists() + + +if __name__ == "__main__": + test_manifest_artifacts_exist() + test_registry_paths_match_installed_layout() + test_install_refactory_smoke() + test_sc_install_package_smoke() + test_sync_subset_smoke() diff --git a/packages/sc-repomix-nuget/.claude-plugin/plugin.json b/packages/sc-repomix-nuget/.claude-plugin/plugin.json index 7db6cf98f..799fb0411 100644 --- a/packages/sc-repomix-nuget/.claude-plugin/plugin.json +++ b/packages/sc-repomix-nuget/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-repomix-nuget", "description": "Generate comprehensive NuGet repository context for AI analysis. Extracts package metadata, assembly information, and documentation for enhanced code understanding.", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, diff --git a/packages/sc-repomix-nuget/agents/sc-repomix-nuget-analyze.md b/packages/sc-repomix-nuget/agents/sc-repomix-nuget-analyze.md index 955e0924b..37916348e 100644 --- a/packages/sc-repomix-nuget/agents/sc-repomix-nuget-analyze.md +++ b/packages/sc-repomix-nuget/agents/sc-repomix-nuget-analyze.md @@ -1,6 +1,6 @@ --- name: context-assemble -version: 0.12.0 +version: 0.13.0 description: Assemble final NuGet context XML by combining Repomix output with NuGet metadata header. --- diff --git a/packages/sc-repomix-nuget/agents/sc-repomix-nuget-generate.md b/packages/sc-repomix-nuget/agents/sc-repomix-nuget-generate.md index 1ee1d2f6d..47b750bc4 100644 --- a/packages/sc-repomix-nuget/agents/sc-repomix-nuget-generate.md +++ b/packages/sc-repomix-nuget/agents/sc-repomix-nuget-generate.md @@ -1,6 +1,6 @@ --- name: repomix-generate -version: 0.12.0 +version: 0.13.0 description: Execute Repomix to generate compressed XML API surface for a NuGet package. --- diff --git a/packages/sc-repomix-nuget/agents/sc-repomix-nuget-validate.md b/packages/sc-repomix-nuget/agents/sc-repomix-nuget-validate.md index 3c12b8595..9d2962d1b 100644 --- a/packages/sc-repomix-nuget/agents/sc-repomix-nuget-validate.md +++ b/packages/sc-repomix-nuget/agents/sc-repomix-nuget-validate.md @@ -1,6 +1,6 @@ --- name: registry-resolve -version: 0.12.0 +version: 0.13.0 description: Resolve package dependencies and dependents from central registry with optional local manifest augmentation. --- diff --git a/packages/sc-repomix-nuget/commands/sc-repomix-nuget.md b/packages/sc-repomix-nuget/commands/sc-repomix-nuget.md index 7e46a9f17..e824a6549 100644 --- a/packages/sc-repomix-nuget/commands/sc-repomix-nuget.md +++ b/packages/sc-repomix-nuget/commands/sc-repomix-nuget.md @@ -1,7 +1,7 @@ --- name: sc-repomix-nuget description: Generate AI-optimized NuGet package context using Repomix. -version: 0.12.0 +version: 0.13.0 options: - name: --help description: Show usage and options. diff --git a/packages/sc-repomix-nuget/manifest.yaml b/packages/sc-repomix-nuget/manifest.yaml index ce1462c89..a4fe4b0b4 100644 --- a/packages/sc-repomix-nuget/manifest.yaml +++ b/packages/sc-repomix-nuget/manifest.yaml @@ -1,5 +1,5 @@ name: sc-repomix-nuget -version: 0.12.0 +version: 0.13.0 description: > Generate AI-optimized NuGet package context using Repomix with NuGet metadata (dependencies, dependents, namespaces, frameworks). Produces a compressed API surface. diff --git a/packages/sc-repomix-nuget/skills/sc-repomix-nuget/SKILL.md b/packages/sc-repomix-nuget/skills/sc-repomix-nuget/SKILL.md index e25821074..58f19c247 100644 --- a/packages/sc-repomix-nuget/skills/sc-repomix-nuget/SKILL.md +++ b/packages/sc-repomix-nuget/skills/sc-repomix-nuget/SKILL.md @@ -1,6 +1,6 @@ --- name: sc-repomix-nuget -version: 0.12.0 +version: 0.13.0 description: > Generate AI-optimized context for NuGet packages using Repomix. Use when preparing AI docs, API references, or dependency analysis ("nuget context", "repomix nuget"). diff --git a/packages/sc-roslyn-diff/.claude-plugin/plugin.json b/packages/sc-roslyn-diff/.claude-plugin/plugin.json index 1b2535cf2..d4115ede2 100644 --- a/packages/sc-roslyn-diff/.claude-plugin/plugin.json +++ b/packages/sc-roslyn-diff/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-roslyn-diff", "description": "Semantic diffing for .NET source using roslyn-diff with JSON-first outputs, HTML reports, and git/PR-aware comparisons.", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, diff --git a/packages/sc-roslyn-diff/agents/sc-diff.md b/packages/sc-roslyn-diff/agents/sc-diff.md index 0d3cce811..177789dc2 100644 --- a/packages/sc-roslyn-diff/agents/sc-diff.md +++ b/packages/sc-roslyn-diff/agents/sc-diff.md @@ -1,6 +1,6 @@ --- name: sc-diff -version: 0.12.0 +version: 0.13.0 description: Run roslyn-diff on file or folder pairs with JSON-first output and optional HTML reports. model: sonnet color: blue diff --git a/packages/sc-roslyn-diff/agents/sc-git-diff.md b/packages/sc-roslyn-diff/agents/sc-git-diff.md index 2d474a4b0..aeff8176b 100644 --- a/packages/sc-roslyn-diff/agents/sc-git-diff.md +++ b/packages/sc-roslyn-diff/agents/sc-git-diff.md @@ -1,6 +1,6 @@ --- name: sc-git-diff -version: 0.12.0 +version: 0.13.0 description: Diff files from git history or PRs (GitHub/Azure) using roslyn-diff JSON output. model: sonnet color: blue diff --git a/packages/sc-roslyn-diff/commands/sc-diff.md b/packages/sc-roslyn-diff/commands/sc-diff.md index 704c0e249..2d9738eaa 100644 --- a/packages/sc-roslyn-diff/commands/sc-diff.md +++ b/packages/sc-roslyn-diff/commands/sc-diff.md @@ -1,7 +1,7 @@ --- name: sc-diff description: Semantic diff command using roslyn-diff for files, folders, and git/PR comparisons. -version: 0.12.0 +version: 0.13.0 options: - name: --help description: Show usage and options. diff --git a/packages/sc-roslyn-diff/manifest.yaml b/packages/sc-roslyn-diff/manifest.yaml index b44bbf97e..e8174f74a 100644 --- a/packages/sc-roslyn-diff/manifest.yaml +++ b/packages/sc-roslyn-diff/manifest.yaml @@ -1,5 +1,5 @@ name: sc-roslyn-diff -version: 0.12.0 +version: 0.13.0 description: > Semantic diffing for .NET source using roslyn-diff with JSON-first outputs, HTML reports, and git/PR-aware comparisons. diff --git a/packages/sc-roslyn-diff/skills/sc-diff/SKILL.md b/packages/sc-roslyn-diff/skills/sc-diff/SKILL.md index 9b67a7764..af9033533 100644 --- a/packages/sc-roslyn-diff/skills/sc-diff/SKILL.md +++ b/packages/sc-roslyn-diff/skills/sc-diff/SKILL.md @@ -1,6 +1,6 @@ --- name: sc-diff -version: 0.12.0 +version: 0.13.0 description: > Semantic diffing for .NET source using roslyn-diff. Supports file/folder diffs, HTML reports, and git/PR-aware comparisons (GitHub + Azure DevOps). diff --git a/packages/sc-rust/.claude-plugin/plugin.json b/packages/sc-rust/.claude-plugin/plugin.json index e5f70dd95..5fd456b70 100644 --- a/packages/sc-rust/.claude-plugin/plugin.json +++ b/packages/sc-rust/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-rust", "description": "Rust development toolkit: idiomatic guidelines, service hardening guidance, design pattern enforcement, and specialized agents for architecture, code review, exploration, implementation, and QA.", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, diff --git a/packages/sc-rust/agents/rust-architect.md b/packages/sc-rust/agents/rust-architect.md index 5f159d00a..2fbd733c8 100644 --- a/packages/sc-rust/agents/rust-architect.md +++ b/packages/sc-rust/agents/rust-architect.md @@ -1,6 +1,6 @@ --- name: rust-architect -version: 0.12.0 +version: 0.13.0 description: Designs Rust feature architectures by analyzing existing codebase patterns and conventions, then providing comprehensive implementation blueprints with specific files to create/modify, component designs, data flows, and build sequences tools: Glob, Grep, LS, Read, NotebookRead, WebFetch, TodoWrite, WebSearch, KillShell, BashOutput model: opus diff --git a/packages/sc-rust/agents/rust-best-practices-agent.md b/packages/sc-rust/agents/rust-best-practices-agent.md index c83925170..a04086ac5 100644 --- a/packages/sc-rust/agents/rust-best-practices-agent.md +++ b/packages/sc-rust/agents/rust-best-practices-agent.md @@ -1,6 +1,6 @@ --- name: rust-best-practices-agent -version: 0.12.0 +version: 0.13.0 description: Reviews Rust code and design artifacts for structural best-practice compliance using stable practice ids and a fenced-JSON assignment contract. tools: Glob, Grep, LS, Read, NotebookRead model: sonnet diff --git a/packages/sc-rust/agents/rust-code-explorer.md b/packages/sc-rust/agents/rust-code-explorer.md index 2bf6bd394..2d6119605 100644 --- a/packages/sc-rust/agents/rust-code-explorer.md +++ b/packages/sc-rust/agents/rust-code-explorer.md @@ -1,6 +1,6 @@ --- name: rust-code-explorer -version: 0.12.0 +version: 0.13.0 description: Deeply analyzes existing Rust codebase features by tracing execution paths, mapping architecture layers, understanding patterns and abstractions, and documenting dependencies to inform new development tools: Glob, Grep, LS, Read, NotebookRead, WebFetch, TodoWrite, WebSearch, KillShell, BashOutput model: sonnet diff --git a/packages/sc-rust/agents/rust-code-reviewer.md b/packages/sc-rust/agents/rust-code-reviewer.md index b1e801ed0..f4817e0a3 100644 --- a/packages/sc-rust/agents/rust-code-reviewer.md +++ b/packages/sc-rust/agents/rust-code-reviewer.md @@ -1,6 +1,6 @@ --- name: rust-code-reviewer -version: 0.12.0 +version: 0.13.0 description: Reviews Rust code for bugs, logic errors, security vulnerabilities, code quality issues, and adherence to project conventions, using confidence-based filtering to report only high-priority issues that truly matter tools: Glob, Grep, LS, Read, NotebookRead, WebFetch, TodoWrite, WebSearch, KillShell, BashOutput model: sonnet diff --git a/packages/sc-rust/agents/rust-developer.md b/packages/sc-rust/agents/rust-developer.md index 1a0d896ab..9405d8974 100644 --- a/packages/sc-rust/agents/rust-developer.md +++ b/packages/sc-rust/agents/rust-developer.md @@ -1,6 +1,6 @@ --- name: rust-developer -version: 0.12.0 +version: 0.13.0 description: Implements Rust code changes by following project conventions and the Pragmatic Rust Guidelines, delivering safe, idiomatic, and well-tested solutions tools: Glob, Grep, LS, Read, Write, Edit, NotebookRead, WebFetch, TodoWrite, WebSearch, KillShell, BashOutput, Bash model: sonnet diff --git a/packages/sc-rust/agents/rust-qa-agent.md b/packages/sc-rust/agents/rust-qa-agent.md index f7171d44c..1ba9caaeb 100644 --- a/packages/sc-rust/agents/rust-qa-agent.md +++ b/packages/sc-rust/agents/rust-qa-agent.md @@ -1,6 +1,6 @@ --- name: rust-qa-agent -version: 0.12.0 +version: 0.13.0 description: Runs Rust quality gates and first-principles QA through a fenced-JSON contract, focusing on clippy, tests, coverage, portability, and execution-fact reporting rather than architectural policy decisions. tools: Glob, Grep, LS, Read, NotebookRead, TodoWrite, KillShell, BashOutput, Bash model: sonnet diff --git a/packages/sc-rust/agents/rust-service-hardening-agent.md b/packages/sc-rust/agents/rust-service-hardening-agent.md index 1fa80ad17..a116178f6 100644 --- a/packages/sc-rust/agents/rust-service-hardening-agent.md +++ b/packages/sc-rust/agents/rust-service-hardening-agent.md @@ -1,6 +1,6 @@ --- name: rust-service-hardening-agent -version: 0.12.0 +version: 0.13.0 description: Reviews Rust services for runtime-hardening gaps through a fenced-JSON contract and returns a structured skipped result when service indicators are absent. tools: Glob, Grep, LS, Read, NotebookRead model: sonnet diff --git a/packages/sc-rust/manifest.yaml b/packages/sc-rust/manifest.yaml index b58e68dd2..1cbe084a6 100644 --- a/packages/sc-rust/manifest.yaml +++ b/packages/sc-rust/manifest.yaml @@ -1,5 +1,5 @@ name: sc-rust -version: 0.12.0 +version: 0.13.0 description: > Rust development toolkit: idiomatic guidelines, service hardening guidance, design pattern enforcement, and specialized agents for architecture, code review, diff --git a/packages/sc-rust/skills/rust-best-practices/SKILL.md b/packages/sc-rust/skills/rust-best-practices/SKILL.md index 2b844278c..3f84f8b96 100644 --- a/packages/sc-rust/skills/rust-best-practices/SKILL.md +++ b/packages/sc-rust/skills/rust-best-practices/SKILL.md @@ -1,6 +1,6 @@ --- name: rust-best-practices -version: 0.12.0 +version: 0.13.0 description: Review Rust architecture plans, crate boundaries, and code for structural design-pattern compliance. Use when the task involves typestate, sealed traits, error contracts, wrapper/newtype design, object safety, interior mutability, or other type-system-driven Rust correctness patterns that go beyond general style guidance. depends_on: rust-best-practices-agent: 0.x diff --git a/packages/sc-rust/skills/rust-development/SKILL.md b/packages/sc-rust/skills/rust-development/SKILL.md index c500033f5..9be28708f 100644 --- a/packages/sc-rust/skills/rust-development/SKILL.md +++ b/packages/sc-rust/skills/rust-development/SKILL.md @@ -1,6 +1,6 @@ --- name: rust-development -version: 0.12.0 +version: 0.13.0 description: Develop, review, and explore Rust code with strict adherence to Pragmatic Rust Guidelines. Use when the user mentions Rust, .rs files, Cargo, clippy, rustdoc, or requests Rust architecture, implementation, or review. depends_on: rust-architect: 0.x diff --git a/packages/sc-rust/skills/rust-service-hardening/SKILL.md b/packages/sc-rust/skills/rust-service-hardening/SKILL.md index 6ed89698f..bbfc38163 100644 --- a/packages/sc-rust/skills/rust-service-hardening/SKILL.md +++ b/packages/sc-rust/skills/rust-service-hardening/SKILL.md @@ -1,6 +1,6 @@ --- name: rust-service-hardening -version: 0.12.0 +version: 0.13.0 description: Harden Rust backend services for production readiness. Use when working on Tokio, Axum, Hyper, Tonic, or Reqwest-based services and you need guidance or review for config validation, structured tracing, request IDs, timeouts, retries, graceful shutdown, backpressure, body limits, health checks, metrics, and dependency hygiene. Not for non-service Rust crates, embedded Rust, pure sync CLI tools, or low-level libraries without runtime, network, or server concerns. depends_on: rust-service-hardening-agent: 0.x diff --git a/packages/sc-startup/.claude-plugin/plugin.json b/packages/sc-startup/.claude-plugin/plugin.json index d3c62556c..0dccf34c0 100644 --- a/packages/sc-startup/.claude-plugin/plugin.json +++ b/packages/sc-startup/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-startup", "description": "Run repo startup: load startup prompt, sync master checklist, optional PR triage, worktree hygiene, and CI pull with best-effort status.", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "randlee" }, diff --git a/packages/sc-startup/agents/sc-checklist-status.md b/packages/sc-startup/agents/sc-checklist-status.md index a93ef657c..f6d65f8a7 100644 --- a/packages/sc-startup/agents/sc-checklist-status.md +++ b/packages/sc-startup/agents/sc-checklist-status.md @@ -1,6 +1,6 @@ --- name: sc-checklist-status -version: 0.12.0 +version: 0.13.0 description: Read the master checklist, scan the repo for missing items, optionally update the checklist, and report a structured status. --- diff --git a/packages/sc-startup/agents/sc-startup-init.md b/packages/sc-startup/agents/sc-startup-init.md index 3672d8ee3..1b4c87cee 100644 --- a/packages/sc-startup/agents/sc-startup-init.md +++ b/packages/sc-startup/agents/sc-startup-init.md @@ -1,6 +1,6 @@ --- name: sc-startup-init -version: 0.12.0 +version: 0.13.0 description: Detect existing sc-startup config, suggest defaults, and report installed packages; returns fenced JSON with YAML payload for the skill to drive Q&A. --- diff --git a/packages/sc-startup/commands/sc-startup.md b/packages/sc-startup/commands/sc-startup.md index 2c2573695..8af58c4b2 100644 --- a/packages/sc-startup/commands/sc-startup.md +++ b/packages/sc-startup/commands/sc-startup.md @@ -1,6 +1,6 @@ --- name: sc-startup -version: 0.12.0 +version: 0.13.0 description: "Run the repo startup workflow: load the startup prompt, sync the master checklist, launch optional PR/worktree/CI agents, and return a concise status with next steps." options: - name: --pr diff --git a/packages/sc-startup/manifest.yaml b/packages/sc-startup/manifest.yaml index e3325dda9..fa2a2bdb8 100644 --- a/packages/sc-startup/manifest.yaml +++ b/packages/sc-startup/manifest.yaml @@ -1,5 +1,5 @@ name: sc-startup -version: 0.12.0 +version: 0.13.0 description: > Startup runner for Synaptic Canvas: read startup prompt, sync master checklist, optional PR triage, worktree hygiene, and CI pull, then emit a concise status with next steps. Supports read-only mode. diff --git a/packages/sc-startup/skills/sc-startup/SKILL.md b/packages/sc-startup/skills/sc-startup/SKILL.md index 83b87b3cb..153872d3c 100644 --- a/packages/sc-startup/skills/sc-startup/SKILL.md +++ b/packages/sc-startup/skills/sc-startup/SKILL.md @@ -1,6 +1,6 @@ --- name: sc-startup -version: 0.12.0 +version: 0.13.0 description: "Run repo startup: prompt load, checklist sync, optional PR triage, worktree hygiene, and CI pull. Best-effort with structured status." entry_point: /sc-startup --- diff --git a/pytest.ini b/pytest.ini index 6e56a3d75..307aea10d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,7 +2,7 @@ markers = integration: requires external services or tokens -testpaths = tests packages/sc-docling-pdf/tests +testpaths = tests packages/sc-docling-pdf/tests packages/sc-gh-stack/tests packages/sc-git-worktree/tests python_files = test_*.py python_classes = Test* python_functions = test_* diff --git a/requirements-dev.txt b/requirements-dev.txt index 8b6390f20..86d98c772 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,6 +8,9 @@ pydantic>=2.7 # Data validation and type safety pyyaml>=6.0 # YAML parsing for validation jsonschema>=4.22 # Schema validation +# PDF handling (packages/sc-docling-pdf tests) +pypdf>=4 # PDF read/write for docling integration test fixtures + # Reporting tabulate>=0.9.0 # Table formatting for reports jinja2>=3.1.0 # HTML template rendering diff --git a/scripts/audit-versions.py b/scripts/audit-versions.py index e893ab181..dd80f20d1 100755 --- a/scripts/audit-versions.py +++ b/scripts/audit-versions.py @@ -393,7 +393,7 @@ def audit_version_consistency( if ( not package_dir.is_dir() or package_dir.name.startswith(".") - or package_dir.name == "shared" + or package_dir.name in {"shared", "docs"} ): continue @@ -493,7 +493,7 @@ def audit_changelogs(repo_root: Path, verbose: bool = False) -> Result[list[Chec if ( not package_dir.is_dir() or package_dir.name.startswith(".") - or package_dir.name == "shared" + or package_dir.name in {"shared", "docs"} ): continue diff --git a/scripts/sync-shared-scripts.py b/scripts/sync-shared-scripts.py index 0b935c62b..067af2bc0 100644 --- a/scripts/sync-shared-scripts.py +++ b/scripts/sync-shared-scripts.py @@ -21,7 +21,7 @@ def iter_packages(packages_dir: Path) -> Iterable[Path]: for path in sorted(packages_dir.iterdir()): - if path.is_dir() and not path.name.startswith("."): + if path.is_dir() and not path.name.startswith(".") and path.name not in {"shared", "docs"}: yield path @@ -114,8 +114,6 @@ def sync_shared_script(canonical: Path, packages_dir: Path) -> list[str]: repo_root = packages_dir.parent for package_dir in iter_packages(packages_dir): - if package_dir.name == "shared": - continue for mapping in shared_script_mappings(package_dir, repo_root, canonical): if not mapping.canonical.exists(): continue diff --git a/scripts/update-registry.py b/scripts/update-registry.py index 24e366fc8..ce25ff614 100644 --- a/scripts/update-registry.py +++ b/scripts/update-registry.py @@ -143,7 +143,7 @@ def find_packages(packages_dir: Path) -> list[Path]: [ d for d in packages_dir.iterdir() - if d.is_dir() and not d.name.startswith(".") and d.name != "shared" + if d.is_dir() and not d.name.startswith(".") and d.name not in {"shared", "docs"} ] ) diff --git a/scripts/validate-manifest-artifacts.py b/scripts/validate-manifest-artifacts.py index c6045e49b..0e7f58451 100755 --- a/scripts/validate-manifest-artifacts.py +++ b/scripts/validate-manifest-artifacts.py @@ -411,8 +411,8 @@ def main() -> int: # Determine packages to validate if args.package: - if args.package == "shared": - print("Skipping packages/shared (not a package)") + if args.package in {"shared", "docs"}: + print(f"Skipping packages/{args.package} (not a package)") return 0 package_dirs = [args.packages_dir / args.package] else: @@ -423,7 +423,7 @@ def main() -> int: package_dirs = [ d for d in args.packages_dir.iterdir() - if d.is_dir() and not d.name.startswith(".") and d.name != "shared" + if d.is_dir() and not d.name.startswith(".") and d.name not in {"shared", "docs"} ] if not package_dirs: diff --git a/scripts/validate-marketplace-sync.py b/scripts/validate-marketplace-sync.py index 40e8618c3..8fe86186c 100755 --- a/scripts/validate-marketplace-sync.py +++ b/scripts/validate-marketplace-sync.py @@ -264,7 +264,7 @@ def get_package_dirs(packages_dir: Path) -> list[Path]: return [ d for d in packages_dir.iterdir() - if d.is_dir() and not d.name.startswith(".") and d.name != "shared" + if d.is_dir() and not d.name.startswith(".") and d.name not in {"shared", "docs"} ] @@ -638,8 +638,8 @@ def main() -> int: parser.add_argument( "--registry", type=Path, - default=Path("docs/registries/nuget/registry.json"), - help="Path to registry.json (default: docs/registries/nuget/registry.json)", + default=Path(".claude-plugin/registry.json"), + help="Path to registry.json (default: .claude-plugin/registry.json)", ) args = parser.parse_args() diff --git a/scripts/validate-script-references.py b/scripts/validate-script-references.py index d1b90d098..2037e860d 100755 --- a/scripts/validate-script-references.py +++ b/scripts/validate-script-references.py @@ -130,8 +130,8 @@ def extract_frontmatter(file_path: str) -> Result[Dict[str, Any], ScriptReferenc match = re.match(pattern, content, re.DOTALL) if not match: - # No frontmatter is okay for some files - return Success(value={}, warnings=["No frontmatter found"]) + # No frontmatter is valid for files without hooks or metadata. + return Success(value={}, warnings=[]) raw_frontmatter = match.group(1) diff --git a/scripts/validate-shared-scripts.py b/scripts/validate-shared-scripts.py index 5b211b99b..8772a2b5c 100644 --- a/scripts/validate-shared-scripts.py +++ b/scripts/validate-shared-scripts.py @@ -24,7 +24,7 @@ def iter_packages(packages_dir: Path) -> Iterable[Path]: for path in sorted(packages_dir.iterdir()): - if path.is_dir() and not path.name.startswith("."): + if path.is_dir() and not path.name.startswith(".") and path.name not in {"shared", "docs"}: yield path @@ -125,9 +125,6 @@ def compare_shared_script(canonical: Path, packages_dir: Path) -> tuple[list[str repo_root = packages_dir.parent for package_dir in iter_packages(packages_dir): - if package_dir.name == "shared": - continue - for mapping in shared_script_mappings(package_dir, repo_root, canonical): if not mapping.canonical.exists(): invalid_sources.append(f"{describe_mapping(package_dir, mapping.target)} -> {mapping.canonical}") @@ -148,9 +145,6 @@ def sync_shared_script(canonical: Path, packages_dir: Path) -> list[str]: repo_root = packages_dir.parent for package_dir in iter_packages(packages_dir): - if package_dir.name == "shared": - continue - for mapping in shared_script_mappings(package_dir, repo_root, canonical): if not mapping.canonical.exists(): continue diff --git a/src/sc_cli/install.py b/src/sc_cli/install.py index 4d2fcf1d6..b95d28a07 100644 --- a/src/sc_cli/install.py +++ b/src/sc_cli/install.py @@ -117,7 +117,14 @@ def _parse_manifest(pkg_dir: Path) -> Manifest: ) # Fallback: minimal line parser for artifacts sections - artifacts: Dict[str, List[str]] = {"commands": [], "skills": [], "agents": [], "scripts": [], "assets": []} + artifacts: Dict[str, List[str]] = { + "commands": [], + "skills": [], + "agents": [], + "scripts": [], + "assets": [], + "plugin": [], + } current: Optional[str] = None version = "" for line in _read_file(manifest_path).splitlines(): @@ -875,7 +882,7 @@ def _git_repo_basename(dest_dir: Path) -> str: def _iter_artifacts(m: Manifest) -> Iterable[str]: - order = ["commands", "skills", "agents", "scripts", "assets"] + order = ["commands", "skills", "agents", "scripts", "assets", "plugin"] for key in order: for item in m.artifacts.get(key, []): yield item diff --git a/tests/scripts/test_sc_launchpad_task.py b/tests/scripts/test_sc_launchpad_task.py index 9856ade1d..0247811c6 100644 --- a/tests/scripts/test_sc_launchpad_task.py +++ b/tests/scripts/test_sc_launchpad_task.py @@ -3,6 +3,8 @@ from pathlib import Path import importlib.util +import pytest + spec = importlib.util.spec_from_file_location( "sc_launchpad_task", @@ -86,6 +88,18 @@ def test_build_command_for_claude(): ] +def test_normalize_fable_model_aliases(): + for model in ("fable", "claude-fable-5", "claude-fable-5[1m]"): + payload = sc_launchpad_task.LaunchpadInput( + description="Launch Fable", + prompt="Review the diff", + tool="claude", + model=model, + cwd="/tmp", + ) + assert sc_launchpad_task.normalize_tool_model(payload) == "fable" + + def test_build_command_for_codex(): payload = sc_launchpad_task.LaunchpadInput( description="Launch Codex", @@ -99,7 +113,7 @@ def test_build_command_for_codex(): assert command == [ "codex", "exec", - "--full-auto", + "--yolo", "--model", "gpt-5.1-codex-max", "--skip-git-repo-check", @@ -107,6 +121,55 @@ def test_build_command_for_codex(): ] +@pytest.mark.parametrize( + ("alias", "model_id"), + [ + ("sol", "gpt-5.6-sol"), + ("terra", "gpt-5.6-terra"), + ("luna", "gpt-5.6-luna"), + ("codex", "gpt-5.6-terra"), + ], +) +def test_normalize_codex_model_aliases(alias, model_id): + payload = sc_launchpad_task.LaunchpadInput( + description="Launch Codex", + prompt="Implement the fix", + tool="codex", + model=alias, + cwd="/tmp", + ) + assert sc_launchpad_task.normalize_tool_model(payload) == model_id + + +def test_build_command_for_terra_codex(): + payload = sc_launchpad_task.LaunchpadInput( + description="Launch Terra", + prompt="Implement the fix", + tool="codex", + model="terra", + cwd="/tmp", + ) + command = sc_launchpad_task.build_command(payload, "gpt-5.6-terra") + assert command == [ + "codex", + "exec", + "--yolo", + "--model", + "gpt-5.6-terra", + "Implement the fix", + ] + + +def test_codex_defaults_to_terra(): + payload = sc_launchpad_task.LaunchpadInput( + description="Launch Codex", + prompt="Implement the fix", + tool="codex", + cwd="/tmp", + ) + assert sc_launchpad_task.normalize_tool_model(payload) == "gpt-5.6-terra" + + def test_build_command_for_gemini(): payload = sc_launchpad_task.LaunchpadInput( description="Launch Gemini", @@ -129,11 +192,11 @@ def test_build_command_for_gemini(): ] -def test_roster_model_uses_tool_name_for_codex(): +def test_roster_model_uses_canonical_alias_for_codex(): payload = sc_launchpad_task.LaunchpadInput( description="Launch Codex", prompt="Implement the fix", tool="codex", cwd="/tmp", ) - assert sc_launchpad_task.roster_model(payload, "gpt-5.2-codex") == "codex" + assert sc_launchpad_task.roster_model(payload, "gpt-5.6-terra") == "terra" diff --git a/tests/scripts/test_sc_term_launch.py b/tests/scripts/test_sc_term_launch.py index a72a04847..78b688488 100644 --- a/tests/scripts/test_sc_term_launch.py +++ b/tests/scripts/test_sc_term_launch.py @@ -19,7 +19,9 @@ def test_build_claude_argv_without_tmux(): - command = sc_term_launch.build_claude_argv("sonnet", ["--continue"], teammate_mode=False) + command = sc_term_launch.build_claude_argv( + "sonnet", ["--continue"], teammate_mode=False + ) assert command == [ "claude", "--model", @@ -47,6 +49,38 @@ def test_build_claude_argv_with_tmux(): ] +def test_build_claude_argv_for_fable(): + command = sc_term_launch.build_claude_argv("fable", [], teammate_mode=False) + assert command == [ + "claude", + "--model", + "fable", + "--dangerously-skip-permissions", + ] + + +@pytest.mark.parametrize( + ("model", "model_id"), + [ + ("sol", "gpt-5.6-sol"), + ("terra", "gpt-5.6-terra"), + ("luna", "gpt-5.6-luna"), + ("codex", "gpt-5.6-terra"), + ], +) +def test_build_codex_argv_routes_models(model, model_id): + command = sc_term_launch.build_codex_argv(model, ["--search"]) + assert command == [ + "codex", + "--model", + model_id, + "--yolo", + "--enable", + "hooks", + "--search", + ] + + def test_render_command_argv_posix_quotes_spaces(): command = sc_term_launch.render_command_argv( ["claude", "--model", "opus", "--resume", "session with spaces"], @@ -86,6 +120,27 @@ def test_parser_collects_passthrough_claude_args(): assert passthrough == ["--continue", "--resume", "abc123"] +def test_parser_collects_passthrough_codex_args(): + argv, passthrough = sc_term_launch.split_passthrough_argv( + [ + "launch-codex-model", + "sol", + "/tmp/project", + "--terminal", + "cmux", + "--", + "--search", + ] + ) + parser = sc_term_launch.build_parser() + args = parser.parse_args(argv) + assert args.subcommand == "launch-codex-model" + assert args.model == "sol" + assert args.dir == "/tmp/project" + assert args.terminal == "cmux" + assert passthrough == ["--search"] + + def test_apply_atm_env_prefix_posix(): command = sc_term_launch.apply_atm_env_prefix( "claude --model sonnet", @@ -132,6 +187,16 @@ def test_resolve_identity_uses_tool_pool(monkeypatch): assert len(suffix) == 4 +@pytest.mark.parametrize("model", ["sol", "terra", "luna", "codex"]) +def test_resolve_identity_uses_codex_model_pool(monkeypatch, model): + monkeypatch.setenv("ATM_TEAM", "atm-core") + identity = sc_term_launch.resolve_identity(None, model) + name, suffix = identity.rsplit("-", 1) + pool_model = "terra" if model == "codex" else model + assert name in sc_term_launch._IDENTITY_NAMES[pool_model] + assert len(suffix) == 4 + + def test_generate_ulid_and_session_path(): launch_id = sc_term_launch.generate_ulid() path = sc_term_launch.build_claude_session_record_path("/tmp/project", launch_id) @@ -148,19 +213,38 @@ def test_generate_codex_session_path(): def test_session_tracking_for_codex_member_model(): - launch_id, session_record = sc_term_launch.session_tracking_for_member_model("codex", "/tmp/project") + launch_id, session_record = sc_term_launch.session_tracking_for_member_model( + "codex", "/tmp/project" + ) assert launch_id is not None assert session_record is not None - assert session_record.parent == Path("/tmp/project").resolve() / ".sc" / "sessions" / "codex" + assert ( + session_record.parent + == Path("/tmp/project").resolve() / ".sc" / "sessions" / "codex" + ) assert session_record.stem.endswith(launch_id) def test_session_tracking_for_non_codex_member_model(): - launch_id, session_record = sc_term_launch.session_tracking_for_member_model("gemini", "/tmp/project") + launch_id, session_record = sc_term_launch.session_tracking_for_member_model( + "gemini", "/tmp/project" + ) assert launch_id is None assert session_record is None +def test_session_tracking_for_codex_model_alias(): + launch_id, session_record = sc_term_launch.session_tracking_for_member_model( + "sol", "/tmp/project" + ) + assert launch_id is not None + assert session_record is not None + assert ( + session_record.parent + == Path("/tmp/project").resolve() / ".sc" / "sessions" / "codex" + ) + + def test_apply_env_prefix_with_session_tracking_posix(): command = sc_term_launch.apply_env_prefix( "claude --model haiku", @@ -174,3 +258,31 @@ def test_apply_env_prefix_with_session_tracking_posix(): command == "export SC_LAUNCH_ID=01JVY7YVYH57FHE2S2P0S8F3XW && export SC_SESSION_RECORD=/tmp/project/.sc/sessions/claude/session.json && claude --model haiku" ) + + +def test_launch_cmux_creates_focused_workspace(monkeypatch): + calls = [] + + def fake_run(command, *, check): + calls.append((command, check)) + + monkeypatch.setattr(sc_term_launch.subprocess, "run", fake_run) + sc_term_launch.launch_cmux("claude --model fable", "/tmp/project", True, "fable") + + assert calls == [ + ( + [ + "cmux", + "new-workspace", + "--cwd", + "/tmp/project", + "--name", + "fable", + "--command", + "claude --model fable", + "--focus", + "true", + ], + True, + ) + ] diff --git a/tests/scripts/test_validate_script_references.py b/tests/scripts/test_validate_script_references.py index 721a756e3..e981f9910 100644 --- a/tests/scripts/test_validate_script_references.py +++ b/tests/scripts/test_validate_script_references.py @@ -141,7 +141,7 @@ def test_extract_frontmatter_no_frontmatter(temp_dir): result = extract_frontmatter(str(file_path)) assert isinstance(result, Success) assert result.value == {} - assert "No frontmatter found" in result.warnings[0] + assert result.warnings == [] def test_extract_frontmatter_missing_file(temp_dir): diff --git a/tests/test_ai_cli_task_runner.py b/tests/test_ai_cli_task_runner.py index ba6fd43f2..c4a3ed18e 100644 --- a/tests/test_ai_cli_task_runner.py +++ b/tests/test_ai_cli_task_runner.py @@ -208,7 +208,7 @@ def test_run_pretool_hooks_success(tmp_path: Path) -> None: " - matcher: \"Bash\"\n" " hooks:\n" " - type: command\n" - " command: \"python -c \\\"import json,sys; json.load(sys.stdin); sys.exit(0)\\\"\"\n" + " command: \"python3 -c \\\"import json,sys; json.load(sys.stdin); sys.exit(0)\\\"\"\n" "---\n", encoding="utf-8", ) @@ -229,7 +229,7 @@ def test_run_pretool_hooks_failure(tmp_path: Path) -> None: " - matcher: \"Bash\"\n" " hooks:\n" " - type: command\n" - " command: \"python -c \\\"import sys; sys.exit(2)\\\"\"\n" + " command: \"python3 -c \\\"import sys; sys.exit(2)\\\"\"\n" "---\n", encoding="utf-8", ) diff --git a/tests/test_sc_install.py b/tests/test_sc_install.py index 179baee27..401845519 100644 --- a/tests/test_sc_install.py +++ b/tests/test_sc_install.py @@ -88,3 +88,16 @@ def test_install_sc_docling_pdf_copies_skill_and_references(tmp_path: Path): assert (skill_dir / "references/profile-rich.md").exists() assert (skill_dir / "references/profile-scan.md").exists() assert (skill_dir / "references/profile-vlm.md").exists() + + +def test_install_sc_refactory_copies_plugin_artifact(tmp_path: Path): + repo = tmp_path / "repo" + repo.mkdir() + _init_git_repo(repo) + dest = repo / ".claude" + + rc = sc_install.main(["install", "sc-refactory", "--dest", str(dest)]) + assert rc == 0 + + assert (dest / ".claude-plugin" / "plugin.json").exists() + assert (dest / "assets" / "startup-wrapper-template" / "team-lead.py").exists() diff --git a/tests/test_sc_prefix_validation.py b/tests/test_sc_prefix_validation.py index 5f1514772..0762220be 100644 --- a/tests/test_sc_prefix_validation.py +++ b/tests/test_sc_prefix_validation.py @@ -46,12 +46,12 @@ def _get_marketplace_version() -> str: }, 'sc-git-worktree': { 'package_name': 'sc-git-worktree', - 'agents': ['sc-git-worktree-create', 'sc-git-worktree-scan', 'sc-git-worktree-cleanup', 'sc-git-worktree-abort', 'sc-git-worktree-update'], + 'agents': ['sc-git-worktree-create', 'sc-git-worktree-create-stacked', 'sc-git-worktree-scan', 'sc-git-worktree-cleanup', 'sc-git-worktree-abort', 'sc-git-worktree-update'], 'commands': ['sc-git-worktree'], 'skills': ['sc-managing-worktrees'], - 'version': _CURRENT_VERSION, + 'version': '0.14.0', # stack-aware release; ahead of the marketplace-wide version 'path': 'packages/sc-git-worktree', - 'artifact_counts': {'agents': 5, 'commands': 1, 'skills': 1} + 'artifact_counts': {'agents': 6, 'commands': 1, 'skills': 1} }, 'sc-repomix-nuget': { 'package_name': 'sc-repomix-nuget', diff --git a/version.yaml b/version.yaml index 13064f670..f7f374f2a 100644 --- a/version.yaml +++ b/version.yaml @@ -17,4 +17,4 @@ # # Package versions are managed independently in packages/*/manifest.yaml # -version: "0.12.0" +version: "0.13.0"