diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index b27b3839..d98c38b4 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -27,6 +27,14 @@ jobs: run: go build ./cmd/sin-code - name: Vet run: go vet ./cmd/sin-code/... ./cmd/sin-code/internal/... + - name: Setup Python for skill validation + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Validate bundled skills + run: | + pip install pyyaml + python3 scripts/validate_skill.py --all-bundled --strict - name: Test run: go test ./cmd/sin-code/ ./cmd/sin-code/internal/ -count=1 -v 2>&1 | tail -50 - name: Test LSP live (build tag, opt-in) diff --git a/.github/workflows/go-ci.yml.doc.md b/.github/workflows/go-ci.yml.doc.md index b660fd17..977e1627 100644 --- a/.github/workflows/go-ci.yml.doc.md +++ b/.github/workflows/go-ci.yml.doc.md @@ -4,7 +4,7 @@ Go CI for the `sin-code` binary. ## What this workflow does -- **go test**: Checks out the repo, sets up Go 1.25.11, installs `gopls`, builds `cmd/sin-code`, runs `go vet`, and runs the Go test suite. It also runs an opt-in LSP live test. +- **go test**: Checks out the repo, sets up Go 1.25.11, installs `gopls`, builds `cmd/sin-code`, runs `go vet`, validates bundled skills, and runs the Go test suite. It also runs an opt-in LSP live test. - **benchmark**: Runs only benchmarks (`-run='^$'`) in `cmd/sin-code/internal/`, captures the output, and checks that the indexed search is at least 3x faster than the full-scan baseline. ## Related files diff --git a/.github/workflows/test-delegate.yml b/.github/workflows/test-delegate.yml index 9668f622..cdce57c8 100644 --- a/.github/workflows/test-delegate.yml +++ b/.github/workflows/test-delegate.yml @@ -41,10 +41,15 @@ jobs: - name: Install base deps run: | python -m pip install --upgrade pip - pip install pytest pytest-asyncio + pip install pytest pytest-asyncio 'mcp>=1.0' # The package is stdlib-only; expose it on PYTHONPATH echo "PYTHONPATH=$GITHUB_WORKSPACE/src" >> $GITHUB_ENV + - name: Configure git identity for tests + run: | + git config --global user.email "ci@opensin-code.local" + git config --global user.name "SIN CI" + - name: Run delegate tests run: | python -m pytest \ diff --git a/SIN-Code-SBOM-Generator/src/sbom_generator/cli.py b/SIN-Code-SBOM-Generator/src/sbom_generator/cli.py index 811cbc52..8e1b41f7 100644 --- a/SIN-Code-SBOM-Generator/src/sbom_generator/cli.py +++ b/SIN-Code-SBOM-Generator/src/sbom_generator/cli.py @@ -11,13 +11,10 @@ import click from rich.console import Console -from rich.table import Table from rich.panel import Panel -from rich.text import Text +from rich.table import Table from .generator import SBOMGenerator -from .models import SBOM, SBOMPackage, SBOMMetadata - console = Console() @@ -26,16 +23,27 @@ @click.version_option(version="1.0.0", prog_name="sin-sbom") @click.pass_context def cli(ctx): - """SIN-Code SBOM Generator — Generate SPDX and CycloneDX SBOMs. - """ + """SIN-Code SBOM Generator — Generate SPDX and CycloneDX SBOMs.""" ctx.ensure_object(dict) ctx.obj["generator"] = SBOMGenerator() @cli.command() @click.argument("input_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) -@click.option("--format", "fmt", type=click.Choice(["spdx", "cyclonedx", "both"]), default="both", show_default=True, help="SBOM output format") -@click.option("--output", "-o", type=click.Path(path_type=Path), help="Output directory (default: current directory)") +@click.option( + "--format", + "fmt", + type=click.Choice(["spdx", "cyclonedx", "both"]), + default="both", + show_default=True, + help="SBOM output format", +) +@click.option( + "--output", + "-o", + type=click.Path(path_type=Path), + help="Output directory (default: current directory)", +) @click.option("--name", default="sbom", show_default=True, help="SBOM document name") @click.option("--summary", is_flag=True, help="Print human-readable summary") @click.pass_context @@ -68,7 +76,9 @@ def generate(ctx, input_path: Path, fmt: str, output: Optional[Path], name: str, console.print(f"[green]✅ CycloneDX SBOM written to {cdx_path}[/green]") if summary: - console.print(Panel(generator.export_summary(sbom), title="SBOM Summary", border_style="blue")) + console.print( + Panel(generator.export_summary(sbom), title="SBOM Summary", border_style="blue") + ) # Print stats table table = Table(title="SBOM Statistics", show_header=True, header_style="bold magenta") @@ -83,9 +93,21 @@ def generate(ctx, input_path: Path, fmt: str, output: Optional[Path], name: str, @cli.command() @click.argument("name", default="sbom") -@click.option("--format", "fmt", type=click.Choice(["spdx", "cyclonedx", "both"]), default="both", show_default=True) -@click.option("--output", "-o", type=click.Path(path_type=Path), default=Path("."), help="Output directory") -@click.option("--packages", type=click.STRING, help='JSON array of packages, e.g., \'[{"name":"lodash","version":"4.17.21"}]\'') +@click.option( + "--format", + "fmt", + type=click.Choice(["spdx", "cyclonedx", "both"]), + default="both", + show_default=True, +) +@click.option( + "--output", "-o", type=click.Path(path_type=Path), default=Path("."), help="Output directory" +) +@click.option( + "--packages", + type=click.STRING, + help='JSON array of packages, e.g., \'[{"name":"lodash","version":"4.17.21"}]\'', +) @click.pass_context def from_deps(ctx, name: str, fmt: str, output: Path, packages: str): """Generate SBOM from a raw list of dependencies (JSON string).""" diff --git a/SIN-Code-SBOM-Generator/src/sbom_generator/cyclonedx_generator.py b/SIN-Code-SBOM-Generator/src/sbom_generator/cyclonedx_generator.py index 29fec679..dd466dc6 100644 --- a/SIN-Code-SBOM-Generator/src/sbom_generator/cyclonedx_generator.py +++ b/SIN-Code-SBOM-Generator/src/sbom_generator/cyclonedx_generator.py @@ -4,13 +4,12 @@ Docs: cyclonedx_generator.doc.md """ +import hashlib import json import uuid -import hashlib -from datetime import datetime, timezone -from typing import Dict, List, Any, Optional -from .models import SBOM, SBOMPackage +from typing import Any, Dict +from .models import SBOM, SBOMPackage CYCLONEDX_SPEC_VERSION = "1.5" CYCLONEDX_SCHEMA = "http://cyclonedx.org/schema/bom-1.5.schema.json" @@ -28,11 +27,13 @@ def generate_cyclonedx(sbom: SBOM) -> Dict[str, Any]: "version": 1, "metadata": { "timestamp": sbom.metadata.timestamp, - "tools": [{ - "vendor": "OpenSIN-Code", - "name": sbom.metadata.tool_name, - "version": sbom.metadata.tool_version, - }], + "tools": [ + { + "vendor": "OpenSIN-Code", + "name": sbom.metadata.tool_name, + "version": sbom.metadata.tool_version, + } + ], "authors": [{"name": a} for a in sbom.metadata.authors], }, "components": [], @@ -77,47 +78,62 @@ def _package_to_cyclonedx(pkg: SBOMPackage) -> Dict[str, Any]: component["description"] = pkg.description if pkg.homepage: - component["externalReferences"] = [{ - "type": "website", - "url": pkg.homepage, - }] + component["externalReferences"] = [ + { + "type": "website", + "url": pkg.homepage, + } + ] if pkg.checksums: component["hashes"] = [ - {"alg": _map_hash_algo(algo), "content": val} - for algo, val in pkg.checksums.items() + {"alg": _map_hash_algo(algo), "content": val} for algo, val in pkg.checksums.items() ] # License info if pkg.license_concluded or pkg.license_declared: component["licenses"] = [] if pkg.license_concluded: - component["licenses"].append({ - "license": {"id": pkg.license_concluded} if _is_spdx_license(pkg.license_concluded) else {"name": pkg.license_concluded} - }) + component["licenses"].append( + { + "license": {"id": pkg.license_concluded} + if _is_spdx_license(pkg.license_concluded) + else {"name": pkg.license_concluded} + } + ) if pkg.license_declared and pkg.license_declared != pkg.license_concluded: - component["licenses"].append({ - "license": {"id": pkg.license_declared} if _is_spdx_license(pkg.license_declared) else {"name": pkg.license_declared} - }) + component["licenses"].append( + { + "license": {"id": pkg.license_declared} + if _is_spdx_license(pkg.license_declared) + else {"name": pkg.license_declared} + } + ) # Vulnerability info (if present) if pkg.has_vulnerabilities: if "properties" not in component: component["properties"] = [] - component["properties"].append({ - "name": "sin:security:vulnerability_count", - "value": str(pkg.vulnerability_count), - }) + component["properties"].append( + { + "name": "sin:security:vulnerability_count", + "value": str(pkg.vulnerability_count), + } + ) if pkg.critical_vulns > 0: - component["properties"].append({ - "name": "sin:security:critical_vulns", - "value": str(pkg.critical_vulns), - }) + component["properties"].append( + { + "name": "sin:security:critical_vulns", + "value": str(pkg.critical_vulns), + } + ) if pkg.high_vulns > 0: - component["properties"].append({ - "name": "sin:security:high_vulns", - "value": str(pkg.high_vulns), - }) + component["properties"].append( + { + "name": "sin:security:high_vulns", + "value": str(pkg.high_vulns), + } + ) return component @@ -156,17 +172,31 @@ def _is_spdx_license(license_id: str) -> bool: """Check if a license identifier is a known SPDX license ID.""" # Simplified check: contains common SPDX licenses or no spaces known_spdx = { - "MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "GPL-2.0-only", - "GPL-2.0-or-later", "GPL-3.0-only", "GPL-3.0-or-later", "LGPL-2.1-only", - "LGPL-2.1-or-later", "LGPL-3.0-only", "LGPL-3.0-or-later", "MPL-2.0", - "ISC", "Unlicense", "CC0-1.0", "EPL-2.0", "EPL-1.0", "BSL-1.0", + "MIT", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-2.0-or-later", + "GPL-3.0-only", + "GPL-3.0-or-later", + "LGPL-2.1-only", + "LGPL-2.1-or-later", + "LGPL-3.0-only", + "LGPL-3.0-or-later", + "MPL-2.0", + "ISC", + "Unlicense", + "CC0-1.0", + "EPL-2.0", + "EPL-1.0", + "BSL-1.0", } return license_id in known_spdx def uuid_from_namespace(namespace: str) -> str: """Generate a deterministic UUID from a namespace string.""" - import hashlib return str(uuid.UUID(hashlib.md5(namespace.encode()).hexdigest()[:32])) diff --git a/SIN-Code-SBOM-Generator/src/sbom_generator/generator.py b/SIN-Code-SBOM-Generator/src/sbom_generator/generator.py index f836487e..1b0d0864 100644 --- a/SIN-Code-SBOM-Generator/src/sbom_generator/generator.py +++ b/SIN-Code-SBOM-Generator/src/sbom_generator/generator.py @@ -6,12 +6,12 @@ Docs: generator.doc.md """ -import json -from typing import List, Dict, Any, Optional from pathlib import Path -from .models import SBOM, SBOMPackage, SBOMMetadata, ScanResult -from .spdx_generator import spdx_to_json +from typing import Any, Dict, List, Optional + from .cyclonedx_generator import cyclonedx_to_json +from .models import SBOM, SBOMMetadata, SBOMPackage +from .spdx_generator import spdx_to_json class SBOMGenerator: @@ -21,7 +21,9 @@ def __init__(self, tool_name: str = "SIN-Code-SBOM-Generator", tool_version: str self.tool_name = tool_name self.tool_version = tool_version - def generate_from_sca_results(self, sca_results: Dict[str, Any], document_name: str = "") -> SBOM: + def generate_from_sca_results( + self, sca_results: Dict[str, Any], document_name: str = "" + ) -> SBOM: """Generate SBOM from SCA (Software Composition Analysis) scan results. Args: @@ -67,7 +69,9 @@ def generate_from_sca_results(self, sca_results: Dict[str, Any], document_name: source_files=source_files, ) - def generate_from_raw_dependencies(self, deps: List[Dict[str, Any]], document_name: str = "") -> SBOM: + def generate_from_raw_dependencies( + self, deps: List[Dict[str, Any]], document_name: str = "" + ) -> SBOM: """Generate SBOM from a raw list of dependencies (e.g., from package.json, requirements.txt). Args: @@ -147,8 +151,14 @@ def export_summary(self, sbom: SBOM) -> str: "|------|---------|------|---------|-----------------|", ] for pkg in sbom.packages: - vuln_str = f"{pkg.vulnerability_count} (C:{pkg.critical_vulns} H:{pkg.high_vulns} M:{pkg.medium_vulns})" if pkg.has_vulnerabilities else "0" - lines.append(f"| {pkg.name} | {pkg.version} | {pkg.type} | {pkg.license_concluded or '-'} | {vuln_str} |") + vuln_str = ( + f"{pkg.vulnerability_count} (C:{pkg.critical_vulns} H:{pkg.high_vulns} M:{pkg.medium_vulns})" + if pkg.has_vulnerabilities + else "0" + ) + lines.append( + f"| {pkg.name} | {pkg.version} | {pkg.type} | {pkg.license_concluded or '-'} | {vuln_str} |" + ) if sbom.unique_licenses: lines += ["", "## Licenses", ""] @@ -191,7 +201,9 @@ def _parse_package(self, raw: Dict[str, Any]) -> Optional[SBOMPackage]: return pkg - def _annotate_vulnerabilities(self, packages: List[SBOMPackage], vulns: List[Dict[str, Any]]) -> None: + def _annotate_vulnerabilities( + self, packages: List[SBOMPackage], vulns: List[Dict[str, Any]] + ) -> None: """Map vulnerability list to packages by name.""" pkg_map = {p.name: p for p in packages} for vuln in vulns: diff --git a/SIN-Code-SBOM-Generator/src/sbom_generator/models.py b/SIN-Code-SBOM-Generator/src/sbom_generator/models.py index 6877d406..4adda50b 100644 --- a/SIN-Code-SBOM-Generator/src/sbom_generator/models.py +++ b/SIN-Code-SBOM-Generator/src/sbom_generator/models.py @@ -4,15 +4,16 @@ Docs: models.doc.md """ +import uuid from dataclasses import dataclass, field -from typing import Optional, List, Dict, Any from datetime import datetime -import uuid +from typing import Any, Dict, List, Optional @dataclass class SBOMPackage: """Represents a package/component in an SBOM.""" + name: str version: str type: str = "library" # library, application, framework, container, etc. @@ -29,7 +30,7 @@ class SBOMPackage: homepage: Optional[str] = None source_repo: Optional[str] = None is_internal: bool = False - + # Security metadata has_vulnerabilities: bool = False vulnerability_count: int = 0 @@ -37,7 +38,7 @@ class SBOMPackage: high_vulns: int = 0 medium_vulns: int = 0 low_vulns: int = 0 - + # Dependency info dependencies: List[str] = field(default_factory=list) # list of package names @@ -45,13 +46,14 @@ class SBOMPackage: @dataclass class SBOMMetadata: """Metadata for SBOM document.""" + tool_name: str = "SIN-Code-SBOM-Generator" tool_version: str = "1.0.0" authors: List[str] = field(default_factory=lambda: ["OpenSIN-Code"]) timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat() + "Z") document_name: str = "" document_namespace: str = "" - + def __post_init__(self): if not self.document_namespace: self.document_namespace = f"https://opensin-code.org/sbom/{uuid.uuid4()}" @@ -60,15 +62,16 @@ def __post_init__(self): @dataclass class SBOM: """Complete SBOM representation.""" + metadata: SBOMMetadata packages: List[SBOMPackage] = field(default_factory=list) - + # Additional properties total_packages: int = 0 total_dependencies: int = 0 unique_licenses: List[str] = field(default_factory=list) files_analyzed: List[str] = field(default_factory=list) - + # Relationship to source source_type: str = "" # npm, pypi, maven, go, etc. source_files: List[str] = field(default_factory=list) # e.g., package.json, requirements.txt @@ -77,6 +80,7 @@ class SBOM: @dataclass class ScanResult: """Input from security scanning tools.""" + tool_name: str packages: List[Dict[str, Any]] = field(default_factory=list) vulnerabilities: List[Dict[str, Any]] = field(default_factory=list) diff --git a/SIN-Code-SBOM-Generator/src/sbom_generator/spdx_generator.py b/SIN-Code-SBOM-Generator/src/sbom_generator/spdx_generator.py index beb2d1fe..ef9e5a2c 100644 --- a/SIN-Code-SBOM-Generator/src/sbom_generator/spdx_generator.py +++ b/SIN-Code-SBOM-Generator/src/sbom_generator/spdx_generator.py @@ -5,11 +5,9 @@ """ import json -import uuid -from datetime import datetime, timezone -from typing import Dict, List, Any, Optional -from .models import SBOM, SBOMPackage, SBOMMetadata +from typing import Any, Dict, List, Optional +from .models import SBOM, SBOMPackage SPDX_VERSION = "SPDX-2.3" SPDX_DATA_LICENSE = "CC0-1.0" @@ -30,7 +28,8 @@ def generate_spdx(sbom: SBOM) -> Dict[str, Any]: "created": sbom.metadata.timestamp, "creators": [ f"Tool: {sbom.metadata.tool_name}-{sbom.metadata.tool_version}", - ] + [f"Organization: {a}" for a in sbom.metadata.authors], + ] + + [f"Organization: {a}" for a in sbom.metadata.authors], }, "packages": [], "files": [], @@ -39,11 +38,13 @@ def generate_spdx(sbom: SBOM) -> Dict[str, Any]: # Add document-to-DESCRIBES relationship if sbom.packages: - doc["relationships"].append({ - "spdxElementId": "SPDXRef-DOCUMENT", - "relatedSpdxElement": "SPDXRef-Package-0", - "relationshipType": "DESCRIBES", - }) + doc["relationships"].append( + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-Package-0", + "relationshipType": "DESCRIBES", + } + ) for idx, pkg in enumerate(sbom.packages): spdx_pkg = _package_to_spdx(pkg, idx) @@ -52,11 +53,13 @@ def generate_spdx(sbom: SBOM) -> Dict[str, Any]: # Dependencies for dep_name in pkg.dependencies: dep_id = _find_package_id(sbom.packages, dep_name) or f"SPDXRef-Package-{dep_name}" - doc["relationships"].append({ - "spdxElementId": spdx_pkg["SPDXID"], - "relatedSpdxElement": dep_id, - "relationshipType": "DEPENDS_ON", - }) + doc["relationships"].append( + { + "spdxElementId": spdx_pkg["SPDXID"], + "relatedSpdxElement": dep_id, + "relationshipType": "DEPENDS_ON", + } + ) # Add unique licenses (optional, kept in extractedLicensingInfo) unique_licenses = list(set(filter(None, [p.license_concluded for p in sbom.packages]))) @@ -88,25 +91,28 @@ def _package_to_spdx(pkg: SBOMPackage, idx: int) -> Dict[str, Any]: } if pkg.purl: - spdx_pkg["externalRefs"] = [{ - "referenceCategory": "PACKAGE-MANAGER", - "referenceType": "purl", - "referenceLocator": pkg.purl, - }] + spdx_pkg["externalRefs"] = [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": pkg.purl, + } + ] if pkg.cpe: if "externalRefs" not in spdx_pkg: spdx_pkg["externalRefs"] = [] - spdx_pkg["externalRefs"].append({ - "referenceCategory": "SECURITY", - "referenceType": "cpe23Type", - "referenceLocator": pkg.cpe, - }) + spdx_pkg["externalRefs"].append( + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": pkg.cpe, + } + ) if pkg.checksums: spdx_pkg["checksums"] = [ - {"algorithm": algo.upper(), "checksumValue": val} - for algo, val in pkg.checksums.items() + {"algorithm": algo.upper(), "checksumValue": val} for algo, val in pkg.checksums.items() ] return spdx_pkg diff --git a/SIN-Code-SBOM-Generator/tests/test_cyclonedx.py b/SIN-Code-SBOM-Generator/tests/test_cyclonedx.py index 4dc7f714..c3834d69 100644 --- a/SIN-Code-SBOM-Generator/tests/test_cyclonedx.py +++ b/SIN-Code-SBOM-Generator/tests/test_cyclonedx.py @@ -5,14 +5,12 @@ """ import json -import pytest -from sbom_generator.cyclonedx_generator import generate_cyclonedx, cyclonedx_to_json -from sbom_generator.models import SBOM, SBOMPackage, SBOMMetadata +from sbom_generator.cyclonedx_generator import cyclonedx_to_json, generate_cyclonedx +from sbom_generator.models import SBOM, SBOMMetadata, SBOMPackage class TestCycloneDXGenerator: - def test_generate_cyclonedx_basic(self): metadata = SBOMMetadata(document_name="test-sbom") packages = [ @@ -43,7 +41,9 @@ def test_generate_cyclonedx_with_purl(self): def test_generate_cyclonedx_with_cpe(self): metadata = SBOMMetadata(document_name="test-sbom") packages = [ - SBOMPackage(name="openssl", version="3.1.2", cpe="cpe:2.3:a:openssl:openssl:3.1.2:*:*:*:*:*:*:*"), + SBOMPackage( + name="openssl", version="3.1.2", cpe="cpe:2.3:a:openssl:openssl:3.1.2:*:*:*:*:*:*:*" + ), ] sbom = SBOM(metadata=metadata, packages=packages) doc = generate_cyclonedx(sbom) @@ -53,7 +53,9 @@ def test_generate_cyclonedx_with_cpe(self): def test_generate_cyclonedx_with_checksums(self): metadata = SBOMMetadata(document_name="test-sbom") packages = [ - SBOMPackage(name="test", version="1.0.0", checksums={"sha256": "abc123", "sha512": "def456"}), + SBOMPackage( + name="test", version="1.0.0", checksums={"sha256": "abc123", "sha512": "def456"} + ), ] sbom = SBOM(metadata=metadata, packages=packages) doc = generate_cyclonedx(sbom) @@ -76,7 +78,13 @@ def test_generate_cyclonedx_with_licenses(self): def test_generate_cyclonedx_with_vulns(self): metadata = SBOMMetadata(document_name="test-sbom") packages = [ - SBOMPackage(name="vuln-pkg", version="1.0.0", has_vulnerabilities=True, critical_vulns=2, high_vulns=1), + SBOMPackage( + name="vuln-pkg", + version="1.0.0", + has_vulnerabilities=True, + critical_vulns=2, + high_vulns=1, + ), ] sbom = SBOM(metadata=metadata, packages=packages) doc = generate_cyclonedx(sbom) @@ -90,7 +98,9 @@ def test_generate_cyclonedx_with_vulns(self): def test_generate_cyclonedx_with_dependencies(self): metadata = SBOMMetadata(document_name="test-sbom") packages = [ - SBOMPackage(name="parent", version="1.0.0", purl="pkg:npm/parent@1.0.0", dependencies=["child"]), + SBOMPackage( + name="parent", version="1.0.0", purl="pkg:npm/parent@1.0.0", dependencies=["child"] + ), ] sbom = SBOM(metadata=metadata, packages=packages) doc = generate_cyclonedx(sbom) diff --git a/SIN-Code-SBOM-Generator/tests/test_generator.py b/SIN-Code-SBOM-Generator/tests/test_generator.py index 3ea2d746..d44f5436 100644 --- a/SIN-Code-SBOM-Generator/tests/test_generator.py +++ b/SIN-Code-SBOM-Generator/tests/test_generator.py @@ -8,14 +8,11 @@ import tempfile from pathlib import Path -import pytest - from sbom_generator.generator import SBOMGenerator -from sbom_generator.models import SBOM, SBOMPackage, SBOMMetadata +from sbom_generator.models import SBOM, SBOMMetadata, SBOMPackage class TestSBOMGenerator: - def test_init(self): gen = SBOMGenerator() assert gen.tool_name == "SIN-Code-SBOM-Generator" @@ -58,7 +55,12 @@ def test_generate_from_sca_results_with_vulns(self): def test_generate_from_raw_dependencies(self): gen = SBOMGenerator() deps = [ - {"name": "requests", "version": "2.31.0", "license": "Apache-2.0", "purl": "pkg:pypi/requests@2.31.0"}, + { + "name": "requests", + "version": "2.31.0", + "license": "Apache-2.0", + "purl": "pkg:pypi/requests@2.31.0", + }, {"name": "urllib3", "version": "2.0.7", "license": "MIT"}, ] sbom = gen.generate_from_raw_dependencies(deps, document_name="python-deps") @@ -167,4 +169,10 @@ def _create_test_sbom(self) -> SBOM: SBOMPackage(name="lodash", version="4.17.21", license_concluded="MIT", type="library"), SBOMPackage(name="express", version="4.18.2", license_concluded="MIT", type="library"), ] - return SBOM(metadata=metadata, packages=packages, total_packages=2, total_dependencies=0, unique_licenses=["MIT"]) + return SBOM( + metadata=metadata, + packages=packages, + total_packages=2, + total_dependencies=0, + unique_licenses=["MIT"], + ) diff --git a/SIN-Code-SBOM-Generator/tests/test_spdx.py b/SIN-Code-SBOM-Generator/tests/test_spdx.py index 5d6bcee4..6edc5cb5 100644 --- a/SIN-Code-SBOM-Generator/tests/test_spdx.py +++ b/SIN-Code-SBOM-Generator/tests/test_spdx.py @@ -5,14 +5,12 @@ """ import json -import pytest +from sbom_generator.models import SBOM, SBOMMetadata, SBOMPackage from sbom_generator.spdx_generator import generate_spdx, spdx_to_json -from sbom_generator.models import SBOM, SBOMPackage, SBOMMetadata class TestSPDXGenerator: - def test_generate_spdx_basic(self): metadata = SBOMMetadata(document_name="test-sbom") packages = [ @@ -33,7 +31,12 @@ def test_generate_spdx_basic(self): def test_generate_spdx_with_purl(self): metadata = SBOMMetadata(document_name="test-sbom") packages = [ - SBOMPackage(name="requests", version="2.31.0", license_concluded="Apache-2.0", purl="pkg:pypi/requests@2.31.0"), + SBOMPackage( + name="requests", + version="2.31.0", + license_concluded="Apache-2.0", + purl="pkg:pypi/requests@2.31.0", + ), ] sbom = SBOM(metadata=metadata, packages=packages) doc = generate_spdx(sbom) @@ -45,7 +48,12 @@ def test_generate_spdx_with_purl(self): def test_generate_spdx_with_cpe(self): metadata = SBOMMetadata(document_name="test-sbom") packages = [ - SBOMPackage(name="openssl", version="3.1.2", license_concluded="Apache-2.0", cpe="cpe:2.3:a:openssl:openssl:3.1.2:*:*:*:*:*:*:*"), + SBOMPackage( + name="openssl", + version="3.1.2", + license_concluded="Apache-2.0", + cpe="cpe:2.3:a:openssl:openssl:3.1.2:*:*:*:*:*:*:*", + ), ] sbom = SBOM(metadata=metadata, packages=packages) doc = generate_spdx(sbom) diff --git a/cmd/sin-code/headroom_cmd.go b/cmd/sin-code/headroom_cmd.go index eb4a849e..4872cc35 100644 --- a/cmd/sin-code/headroom_cmd.go +++ b/cmd/sin-code/headroom_cmd.go @@ -8,8 +8,9 @@ import ( "os" "time" - "github.com/OpenSIN-Code/SIN-Code/internal/headroom" "github.com/spf13/cobra" + + "github.com/OpenSIN-Code/SIN-Code/internal/headroom" ) // headroomCmd represents the headroom command diff --git a/cmd/sin-code/headroom_extra_cmd.go b/cmd/sin-code/headroom_extra_cmd.go index 3b94144c..d9d7722d 100644 --- a/cmd/sin-code/headroom_extra_cmd.go +++ b/cmd/sin-code/headroom_extra_cmd.go @@ -8,8 +8,9 @@ import ( "os/signal" "syscall" - "github.com/OpenSIN-Code/SIN-Code/internal/headroom" "github.com/spf13/cobra" + + "github.com/OpenSIN-Code/SIN-Code/internal/headroom" ) var ( diff --git a/cmd/sin-code/internal/attachments/attachments.go b/cmd/sin-code/internal/attachments/attachments.go index 1a50c0a9..77de161e 100644 --- a/cmd/sin-code/internal/attachments/attachments.go +++ b/cmd/sin-code/internal/attachments/attachments.go @@ -34,7 +34,6 @@ var ( osReadDir = os.ReadDir ) - type Attachment struct { ID string `json:"id"` Hash string `json:"hash"` diff --git a/cmd/sin-code/internal/attachments/attachments_test.go b/cmd/sin-code/internal/attachments/attachments_test.go index 79052658..295dfca0 100644 --- a/cmd/sin-code/internal/attachments/attachments_test.go +++ b/cmd/sin-code/internal/attachments/attachments_test.go @@ -391,11 +391,11 @@ func TestGetInfoError(t *testing.T) { type infoErrEntry string -func (e infoErrEntry) Name() string { return string(e) } -func (e infoErrEntry) IsDir() bool { return false } -func (e infoErrEntry) Type() os.FileMode { return 0 } -func (e infoErrEntry) Info() (os.FileInfo, error) { return nil, fmt.Errorf("info error") } -func (e infoErrEntry) String() string { return string(e) } +func (e infoErrEntry) Name() string { return string(e) } +func (e infoErrEntry) IsDir() bool { return false } +func (e infoErrEntry) Type() os.FileMode { return 0 } +func (e infoErrEntry) Info() (os.FileInfo, error) { return nil, fmt.Errorf("info error") } +func (e infoErrEntry) String() string { return string(e) } func TestGetPrefixMismatch(t *testing.T) { dir := t.TempDir() diff --git a/cmd/sin-code/internal/superpowers/frontmatter.go b/cmd/sin-code/internal/superpowers/frontmatter.go index 89ae2081..fcbee913 100644 --- a/cmd/sin-code/internal/superpowers/frontmatter.go +++ b/cmd/sin-code/internal/superpowers/frontmatter.go @@ -97,7 +97,7 @@ func parseBlock(block string, out map[string]string) { continue } // A "key: value" line. The colon must NOT be inside quotes. - key, val, hasVal, keyEnd := splitKeyValue(raw) + key, val, hasVal, _ := splitKeyValue(raw) if key == "" { i++ continue @@ -119,12 +119,6 @@ func parseBlock(block string, out map[string]string) { indent := leadingSpaces(raw) // Block may start on next line. startIdx := i + 1 - if keyEnd+1 < len(raw) && raw[keyEnd+1] != ' ' && raw[keyEnd+1] != '\t' { - // Inline value (e.g. "key: >- inline") — not supported here; - // we treat the rest of the line as a single line then keep - // collecting indented continuations. - startIdx = i + 1 - } collected, next := collectBlockLines(lines, startIdx, indent+1, stripTrailing) joined := strings.Join(collected, "\n") folded := foldScalar(joined, stripTrailing) @@ -198,7 +192,7 @@ func splitKeyValue(line string) (key, value string, hasVal bool, colonIdx int) { } keyPart := trimmed[:colon] valPart := trimmed[colon+1:] - return keyPart, valPart, true, leading + colon + return keyPart, valPart, valPart != "", leading + colon } // leadingSpaces returns the count of leading space/tab runes in line. diff --git a/cmd/sin-code/internal/superpowers/mcpserver.go b/cmd/sin-code/internal/superpowers/mcpserver.go index 4e6d3095..2ed4b4b3 100644 --- a/cmd/sin-code/internal/superpowers/mcpserver.go +++ b/cmd/sin-code/internal/superpowers/mcpserver.go @@ -17,6 +17,14 @@ import ( "sync" ) +// testHook variables expose hard-to-reach error paths to the test suite. +var ( + mcpListFunc = List + mcpFindFunc = Find + mcpGetFunc = Get + mcpReadFile = os.ReadFile +) + // Server is the stdio MCP server. Construct with NewServer, then call // Serve(ctx) which blocks until ctx is cancelled or stdin reaches EOF. type Server struct { @@ -123,7 +131,7 @@ func (s *Server) result(req *jsonRPCRequest, v any) *jsonRPCResponse { func (s *Server) callTool(ctx context.Context, req *jsonRPCRequest, p *toolCallParams) *jsonRPCResponse { switch p.Name { case "superpowers_list_skills": - all, err := List("") + all, err := mcpListFunc("") if err != nil { return s.errResult(req, err) } @@ -134,7 +142,7 @@ func (s *Server) callTool(ctx context.Context, req *jsonRPCRequest, p *toolCallP MaxResults int `json:"max_results"` } _ = json.Unmarshal(p.Arguments, &args) - hits, err := Find(args.Query, args.MaxResults) + hits, err := mcpFindFunc(args.Query, args.MaxResults) if err != nil { return s.errResult(req, err) } @@ -144,11 +152,11 @@ func (s *Server) callTool(ctx context.Context, req *jsonRPCRequest, p *toolCallP Name string `json:"name"` } _ = json.Unmarshal(p.Arguments, &args) - info, err := Get(args.Name) + info, err := mcpGetFunc(args.Name) if err != nil { return s.errResult(req, err) } - body, rerr := os.ReadFile(info.Path) + body, rerr := mcpReadFile(info.Path) if rerr != nil { return s.errResult(req, rerr) } diff --git a/cmd/sin-code/internal/superpowers/overlay.go b/cmd/sin-code/internal/superpowers/overlay.go index 46d01793..b7c56e23 100644 --- a/cmd/sin-code/internal/superpowers/overlay.go +++ b/cmd/sin-code/internal/superpowers/overlay.go @@ -19,7 +19,7 @@ import ( // sentinels (defined in superpowers.go) and is detected via a substring // search. Any other text in the file is preserved verbatim. func AppendOverlay(path string) bool { - body, err := os.ReadFile(path) + body, err := overlayReadFile(path) if err != nil { return false } @@ -30,7 +30,7 @@ func AppendOverlay(path string) bool { overlay := RenderOverlay(SkillOverlayContext{ Path: path, SkillsRoot: filepath.Dir(filepath.Dir(path)), // //SKILL.md → - CommitHint: commitHint(path), + CommitHint: commitHintHook(path), OverlayKind: SkillOverlay, }) var b strings.Builder @@ -40,9 +40,14 @@ func AppendOverlay(path string) bool { } b.WriteByte('\n') b.WriteString(overlay) - return os.WriteFile(path, []byte(b.String()), 0o644) == nil + return overlayWriteFile(path, []byte(b.String()), 0o644) == nil } +// testHook variables expose hard-to-reach error paths to the test suite. +var overlayReadFile = os.ReadFile +var overlayWriteFile = os.WriteFile +var commitHintHook = commitHint + // OverlayKind tags the rendered block with a stable identifier so the // same RenderOverlay can produce skill- vs root-level overlays with // different wording. diff --git a/cmd/sin-code/internal/superpowers/superpowers.go b/cmd/sin-code/internal/superpowers/superpowers.go index ce1050ec..ad5db7ee 100644 --- a/cmd/sin-code/internal/superpowers/superpowers.go +++ b/cmd/sin-code/internal/superpowers/superpowers.go @@ -33,6 +33,28 @@ var DefaultRepoURL = "https://github.com/obra/superpowers.git" // corpus on `main`. const DefaultBranch = "main" +// testHook variables expose hard-to-reach error paths to the test suite +// without heavy refactoring. They are restored per-test via t.Cleanup. +var ( + osUserHomeDir = os.UserHomeDir + osMkdirAll = os.MkdirAll + osStat = os.Stat + osReadFile = os.ReadFile + osWriteFile = os.WriteFile + osCreateTemp = os.CreateTemp + osRenameHook = os.Rename + jsonMarshalIndent = json.MarshalIndent + jsonUnmarshal = json.Unmarshal + ioCopyHook = io.Copy + fileWriteHook = func(f *os.File, p []byte) (int, error) { return f.Write(p) } + fileCloseHook = func(f *os.File) error { return f.Close() } + runGitHook = runGit + currentShaHook = currentSHA + currentBranchHook = currentBranch + writePromptHook = WritePrompt + walkDirHook = filepath.WalkDir +) + // OverlayMarker is the sentinel HTML comment that delimiters the // automatically-appended overlay block. Idempotency: if the marker is // already present in a SKILL.md, AppendOverlay is a no-op for that file. @@ -49,7 +71,7 @@ func Home() string { return v } // Use os.UserHomeDir for cross-platform safety (macOS/Linux/Windows). - if h, err := os.UserHomeDir(); err == nil { + if h, err := osUserHomeDir(); err == nil { return filepath.Join(h, ".local", "share", "sin-code") } // Last resort: cwd-relative fallback so the function is total. @@ -124,25 +146,25 @@ func Install(ctx context.Context, repoURL, branch string) (*InstallResult, error } start := time.Now() dst := SkillsDir() - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + if err := osMkdirAll(filepath.Dir(dst), 0o755); err != nil { return nil, fmt.Errorf("mkdir: %w", err) } - if _, err := os.Stat(filepath.Join(dst, ".git")); err == nil { - if err := runGit(ctx, dst, "fetch", "--depth", "1", "origin", branch); err != nil { + if _, err := osStat(filepath.Join(dst, ".git")); err == nil { + if err := runGitHook(ctx, dst, "fetch", "--depth", "1", "origin", branch); err != nil { return nil, err } - if err := runGit(ctx, dst, "reset", "--hard", "FETCH_HEAD"); err != nil { + if err := runGitHook(ctx, dst, "reset", "--hard", "FETCH_HEAD"); err != nil { return nil, err } } else { - if err := os.MkdirAll(dst, 0o755); err != nil { + if err := osMkdirAll(dst, 0o755); err != nil { return nil, fmt.Errorf("mkdir dst: %w", err) } - if err := runGit(ctx, ".", "clone", "--depth", "1", "--branch", branch, repoURL, dst); err != nil { + if err := runGitHook(ctx, ".", "clone", "--depth", "1", "--branch", branch, repoURL, dst); err != nil { return nil, err } } - sha, err := currentSHA(ctx, dst) + sha, err := currentShaHook(ctx, dst) if err != nil { return nil, err } @@ -152,7 +174,7 @@ func Install(ctx context.Context, repoURL, branch string) (*InstallResult, error for i := range infos { _ = AppendOverlay(infos[i].Path) } - if _, err := WritePrompt(infos); err != nil { + if _, err := writePromptHook(infos); err != nil { return nil, err } // Write pin file. @@ -181,13 +203,13 @@ func Pin(ctx context.Context, sha string) (*PinState, error) { return nil, errors.New("pin: empty sha") } dst := SkillsDir() - if _, err := os.Stat(filepath.Join(dst, ".git")); err != nil { + if _, err := osStat(filepath.Join(dst, ".git")); err != nil { return nil, fmt.Errorf("pin: not installed (%s missing .git): %w", dst, err) } - if err := runGit(ctx, dst, "reset", "--hard", sha); err != nil { + if err := runGitHook(ctx, dst, "reset", "--hard", sha); err != nil { return nil, err } - branch, _ := currentBranch(ctx, dst) + branch, _ := currentBranchHook(ctx, dst) state := PinState{SHA: sha, Branch: branch, UpdatedAt: time.Now().UTC()} if err := WriteJSON(PinFile(), state); err != nil { return nil, err @@ -197,7 +219,7 @@ func Pin(ctx context.Context, sha string) (*PinState, error) { for i := range infos { _ = AppendOverlay(infos[i].Path) } - if _, err := WritePrompt(infos); err != nil { + if _, err := writePromptHook(infos); err != nil { return nil, err } return &state, nil @@ -206,7 +228,7 @@ func Pin(ctx context.Context, sha string) (*PinState, error) { // CurrentPin reads the .sin-code-pin file. Returns (nil, nil) if the // caller has not run Install yet — that is NOT an error, just "not pinned". func CurrentPin() (*PinState, error) { - b, err := os.ReadFile(PinFile()) + b, err := osReadFile(PinFile()) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil @@ -214,7 +236,7 @@ func CurrentPin() (*PinState, error) { return nil, err } var p PinState - if err := json.Unmarshal(b, &p); err != nil { + if err := jsonUnmarshal(b, &p); err != nil { return nil, err } return &p, nil @@ -229,11 +251,11 @@ func List(root string) ([]SkillInfo, error) { if root == "" { root = SkillsDir() } - if _, err := os.Stat(root); err != nil { + if _, err := osStat(root); err != nil { return nil, nil // not installed → empty result, not an error } var out []SkillInfo - err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + err := walkDirHook(root, func(p string, d os.DirEntry, err error) error { if err != nil { return nil // skip unreadable entries; do not abort the walk } @@ -243,7 +265,7 @@ func List(root string) ([]SkillInfo, error) { if d.Name() != "SKILL.md" { return nil } - body, rerr := os.ReadFile(p) + body, rerr := osReadFile(p) if rerr != nil { return nil } @@ -318,7 +340,7 @@ func InjectAGENTS(agentsPath string, prompt string) error { const start = "" const end = "" block := start + "\n" + prompt + "\n" + end + "\n" - existing, err := os.ReadFile(agentsPath) + existing, err := osReadFile(agentsPath) if err != nil && !errors.Is(err, os.ErrNotExist) { return err } @@ -336,7 +358,7 @@ func InjectAGENTS(agentsPath string, prompt string) error { } body += "\n" + block } - return os.WriteFile(agentsPath, []byte(body), 0o644) + return osWriteFile(agentsPath, []byte(body), 0o644) } // ── Internal helpers ────────────────────────────────────────────────── @@ -392,29 +414,29 @@ func sha256Hex(b []byte) string { // The parent directory of path is created on demand — callers do not // need to MkdirAll beforehand. func WriteJSON(path string, v any) error { - data, err := json.MarshalIndent(v, "", " ") + data, err := jsonMarshalIndent(v, "", " ") if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := osMkdirAll(filepath.Dir(path), 0o755); err != nil { return err } - tmp, err := os.CreateTemp(filepath.Dir(path), ".superpowers-*.json.tmp") + tmp, err := osCreateTemp(filepath.Dir(path), ".superpowers-*.json.tmp") if err != nil { return err } tmpName := tmp.Name() defer os.Remove(tmpName) - if _, err := io.Copy(tmp, strings.NewReader(string(data))); err != nil { - tmp.Close() + if _, err := ioCopyHook(tmp, strings.NewReader(string(data))); err != nil { + fileCloseHook(tmp) return err } - if _, err := tmp.Write([]byte("\n")); err != nil { - tmp.Close() + if _, err := fileWriteHook(tmp, []byte("\n")); err != nil { + fileCloseHook(tmp) return err } - if err := tmp.Close(); err != nil { + if err := fileCloseHook(tmp); err != nil { return err } - return os.Rename(tmpName, path) + return osRenameHook(tmpName, path) } diff --git a/cmd/sin-code/internal/superpowers/superpowers_extra_test.go b/cmd/sin-code/internal/superpowers/superpowers_extra_test.go new file mode 100644 index 00000000..c0b35034 --- /dev/null +++ b/cmd/sin-code/internal/superpowers/superpowers_extra_test.go @@ -0,0 +1,946 @@ +// SPDX-License-Identifier: MIT +// Purpose: additional coverage tests for the superpowers package. +// These tests target error branches and corner cases that the main test file +// does not exercise, using the existing package-level test hooks. +// Docs: superpowers.doc.md +package superpowers + +import ( + "context" + "encoding/json" + "errors" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// setHook swaps a package-level hook for the duration of one test. +func setHook[T any](t *testing.T, hook *T, value T) { + t.Helper() + old := *hook + *hook = value + t.Cleanup(func() { *hook = old }) +} + +// makeGitRepo creates a local git repository with one skill for hermetic tests. +func makeGitRepo(t *testing.T) string { + t.Helper() + upstream := t.TempDir() + skillDir := filepath.Join(upstream, "skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: fixture\ndescription: fixture skill\n---\n# Fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + cmds := [][]string{ + {"-C", upstream, "init", "--initial-branch=main", "--quiet"}, + {"-C", upstream, "-c", "user.email=test@test", "-c", "user.name=test", "-c", "commit.gpgsign=false", "add", "."}, + {"-C", upstream, "-c", "user.email=test@test", "-c", "user.name=test", "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "init"}, + } + for _, args := range cmds { + c := exec.Command("git", args...) + c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + return upstream +} + +// errWriter always returns a configured error. +type errWriter struct { + err error +} + +func (w *errWriter) Write([]byte) (int, error) { return 0, w.err } + +// ── Home / Paths ─────────────────────────────────────────────────────── + +func TestHomeFallbackError(t *testing.T) { + t.Setenv("SIN_CODE_HOME", "") + setHook(t, &osUserHomeDir, func() (string, error) { return "", errors.New("no home") }) + if got := Home(); got != filepath.Join(".", ".sin-code-home") { + t.Errorf("Home fallback: got %q", got) + } +} + +// ── Install error paths ────────────────────────────────────────────────── + +func TestInstallDefaults(t *testing.T) { + upstream := makeGitRepo(t) + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + setHook(t, &DefaultRepoURL, upstream) + res, err := Install(context.Background(), "", "") + if err != nil { + t.Fatalf("Install with defaults: %v", err) + } + if res.Repo != upstream || res.Branch != "main" || res.SHA == "" { + t.Errorf("unexpected result: %+v", res) + } +} + +func TestInstallExistingClone(t *testing.T) { + upstream := makeGitRepo(t) + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + ctx := context.Background() + if _, err := Install(ctx, upstream, "main"); err != nil { + t.Fatalf("first Install: %v", err) + } + res, err := Install(ctx, upstream, "main") + if err != nil { + t.Fatalf("second Install on existing clone: %v", err) + } + if res.SHA == "" { + t.Error("expected SHA on existing clone") + } +} + +func TestInstallMkdirParentError(t *testing.T) { + upstream := makeGitRepo(t) + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + setHook(t, &osMkdirAll, func(path string, perm os.FileMode) error { + if path == filepath.Dir(SkillsDir()) { + return errors.New("mkdir parent") + } + return os.MkdirAll(path, perm) + }) + if _, err := Install(context.Background(), upstream, "main"); err == nil { + t.Error("expected error when parent mkdir fails") + } +} + +func TestInstallMkdirDstError(t *testing.T) { + upstream := makeGitRepo(t) + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + orig := osMkdirAll + setHook(t, &osMkdirAll, func(path string, perm os.FileMode) error { + if path == SkillsDir() { + return errors.New("mkdir dst") + } + return orig(path, perm) + }) + if _, err := Install(context.Background(), upstream, "main"); err == nil { + t.Error("expected error when dst mkdir fails") + } +} + +func TestInstallCloneError(t *testing.T) { + upstream := makeGitRepo(t) + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + setHook(t, &runGitHook, func(ctx context.Context, dir string, args ...string) error { + if len(args) > 0 && args[0] == "clone" { + return errors.New("clone failed") + } + return runGit(ctx, dir, args...) + }) + if _, err := Install(context.Background(), upstream, "main"); err == nil { + t.Error("expected error when clone fails") + } +} + +func TestInstallFetchError(t *testing.T) { + upstream := makeGitRepo(t) + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + ctx := context.Background() + if _, err := Install(ctx, upstream, "main"); err != nil { + t.Fatalf("first Install: %v", err) + } + setHook(t, &runGitHook, func(ctx context.Context, dir string, args ...string) error { + if len(args) > 0 && args[0] == "fetch" { + return errors.New("fetch failed") + } + return runGit(ctx, dir, args...) + }) + if _, err := Install(ctx, upstream, "main"); err == nil { + t.Error("expected error when fetch fails") + } +} + +func TestInstallResetError(t *testing.T) { + upstream := makeGitRepo(t) + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + ctx := context.Background() + if _, err := Install(ctx, upstream, "main"); err != nil { + t.Fatalf("first Install: %v", err) + } + setHook(t, &runGitHook, func(ctx context.Context, dir string, args ...string) error { + if len(args) > 0 && args[0] == "reset" { + return errors.New("reset failed") + } + return runGit(ctx, dir, args...) + }) + if _, err := Install(ctx, upstream, "main"); err == nil { + t.Error("expected error when reset fails") + } +} + +func TestInstallCurrentSHAError(t *testing.T) { + upstream := makeGitRepo(t) + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + setHook(t, ¤tShaHook, func(context.Context, string) (string, error) { return "", errors.New("sha") }) + if _, err := Install(context.Background(), upstream, "main"); err == nil { + t.Error("expected error when currentSHA fails") + } +} + +func TestInstallWritePromptError(t *testing.T) { + upstream := makeGitRepo(t) + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + setHook(t, &writePromptHook, func([]SkillInfo) (string, error) { return "", errors.New("prompt") }) + if _, err := Install(context.Background(), upstream, "main"); err == nil { + t.Error("expected error when WritePrompt fails") + } +} + +func TestInstallWritePinError(t *testing.T) { + upstream := makeGitRepo(t) + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + setHook(t, &osRenameHook, func(string, string) error { return errors.New("rename") }) + if _, err := Install(context.Background(), upstream, "main"); err == nil { + t.Error("expected error when WriteJSON rename fails") + } +} + +// ── Pin error paths ────────────────────────────────────────────────────── + +func TestPinResetError(t *testing.T) { + upstream := makeGitRepo(t) + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + ctx := context.Background() + if _, err := Install(ctx, upstream, "main"); err != nil { + t.Fatalf("Install: %v", err) + } + sha, _ := currentShaHook(ctx, SkillsDir()) + setHook(t, &runGitHook, func(ctx context.Context, dir string, args ...string) error { + if len(args) > 0 && args[0] == "reset" { + return errors.New("reset failed") + } + return runGit(ctx, dir, args...) + }) + if _, err := Pin(ctx, sha); err == nil { + t.Error("expected error when Pin reset fails") + } +} + +func TestPinWritePinError(t *testing.T) { + upstream := makeGitRepo(t) + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + ctx := context.Background() + if _, err := Install(ctx, upstream, "main"); err != nil { + t.Fatalf("Install: %v", err) + } + sha, _ := currentShaHook(ctx, SkillsDir()) + setHook(t, &osRenameHook, func(string, string) error { return errors.New("rename") }) + if _, err := Pin(ctx, sha); err == nil { + t.Error("expected error when Pin WriteJSON fails") + } +} + +func TestPinWritePromptError(t *testing.T) { + upstream := makeGitRepo(t) + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + ctx := context.Background() + if _, err := Install(ctx, upstream, "main"); err != nil { + t.Fatalf("Install: %v", err) + } + sha, _ := currentShaHook(ctx, SkillsDir()) + setHook(t, &writePromptHook, func([]SkillInfo) (string, error) { return "", errors.New("prompt") }) + if _, err := Pin(ctx, sha); err == nil { + t.Error("expected error when Pin WritePrompt fails") + } +} + +// ── CurrentPin error paths ─────────────────────────────────────────────── + +func TestCurrentPinReadError(t *testing.T) { + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + pinPath := PinFile() + setHook(t, &osReadFile, func(path string) ([]byte, error) { + if path == pinPath { + return nil, os.ErrPermission + } + return os.ReadFile(path) + }) + if _, err := CurrentPin(); err == nil { + t.Error("expected error when pin file read fails") + } +} + +func TestCurrentPinUnmarshalError(t *testing.T) { + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + if err := os.MkdirAll(filepath.Dir(PinFile()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(PinFile(), []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := CurrentPin(); err == nil { + t.Error("expected error when pin JSON is invalid") + } +} + +// ── List / Get / Find error paths ──────────────────────────────────────── + +func TestListWalkError(t *testing.T) { + setHook(t, &walkDirHook, func(string, fs.WalkDirFunc) error { return errors.New("walk fail") }) + if _, err := List(t.TempDir()); err == nil { + t.Error("expected List to return walk error") + } +} + +func TestListReadFileError(t *testing.T) { + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + skillDir := filepath.Join(SkillsDir(), "x") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + p := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(p, []byte("---\nname: x\n---\n"), 0o644); err != nil { + t.Fatal(err) + } + setHook(t, &osReadFile, func(path string) ([]byte, error) { + if path == p { + return nil, errors.New("read fail") + } + return os.ReadFile(path) + }) + all, err := List("") + if err != nil { + t.Fatal(err) + } + if len(all) != 0 { + t.Errorf("expected 0 skills after read error, got %d", len(all)) + } +} + +func TestGetListError(t *testing.T) { + setHook(t, &walkDirHook, func(string, fs.WalkDirFunc) error { return errors.New("walk fail") }) + if _, err := Get("anything"); err == nil { + t.Error("expected Get to propagate List error") + } +} + +func TestFindListError(t *testing.T) { + setHook(t, &walkDirHook, func(string, fs.WalkDirFunc) error { return errors.New("walk fail") }) + if _, err := Find("query", 0); err == nil { + t.Error("expected Find to propagate List error") + } +} + +// ── WriteJSON error paths ──────────────────────────────────────────────── + +func TestWriteJSONMarshalError(t *testing.T) { + setHook(t, &jsonMarshalIndent, func(any, string, string) ([]byte, error) { return nil, errors.New("marshal") }) + if err := WriteJSON(filepath.Join(t.TempDir(), "x.json"), map[string]any{}); err == nil { + t.Error("expected marshal error") + } +} + +func TestWriteJSONMkdirError(t *testing.T) { + setHook(t, &osMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir") }) + if err := WriteJSON(filepath.Join(t.TempDir(), "sub", "x.json"), map[string]any{"a": 1}); err == nil { + t.Error("expected mkdir error") + } +} + +func TestWriteJSONCreateTempError(t *testing.T) { + setHook(t, &osCreateTemp, func(string, string) (*os.File, error) { return nil, errors.New("createtemp") }) + if err := WriteJSON(filepath.Join(t.TempDir(), "x.json"), map[string]any{"a": 1}); err == nil { + t.Error("expected createtemp error") + } +} + +func TestWriteJSONCopyError(t *testing.T) { + setHook(t, &ioCopyHook, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy") }) + if err := WriteJSON(filepath.Join(t.TempDir(), "x.json"), map[string]any{"a": 1}); err == nil { + t.Error("expected copy error") + } +} + +func TestWriteJSONWriteError(t *testing.T) { + setHook(t, &fileWriteHook, func(*os.File, []byte) (int, error) { return 0, errors.New("write") }) + if err := WriteJSON(filepath.Join(t.TempDir(), "x.json"), map[string]any{"a": 1}); err == nil { + t.Error("expected write error") + } +} + +func TestWriteJSONCloseError(t *testing.T) { + setHook(t, &fileCloseHook, func(*os.File) error { return errors.New("close") }) + if err := WriteJSON(filepath.Join(t.TempDir(), "x.json"), map[string]any{"a": 1}); err == nil { + t.Error("expected close error") + } +} + +func TestWriteJSONRenameError(t *testing.T) { + setHook(t, &osRenameHook, func(string, string) error { return errors.New("rename") }) + if err := WriteJSON(filepath.Join(t.TempDir(), "x.json"), map[string]any{"a": 1}); err == nil { + t.Error("expected rename error") + } +} + +// ── Git helpers ────────────────────────────────────────────────────────── + +func TestRunGitError(t *testing.T) { + if err := runGit(context.Background(), t.TempDir(), "status"); err == nil { + t.Error("expected runGit to fail outside a repo") + } +} + +func TestCurrentSHAError(t *testing.T) { + if _, err := currentSHA(context.Background(), t.TempDir()); err == nil { + t.Error("expected currentSHA to fail outside a repo") + } +} + +func TestCurrentBranchError(t *testing.T) { + if _, err := currentBranch(context.Background(), t.TempDir()); err == nil { + t.Error("expected currentBranch to fail outside a repo") + } +} + +// ── InjectAGENTS error paths ───────────────────────────────────────────── + +func TestInjectAGENTSReadError(t *testing.T) { + dir := t.TempDir() + agentsPath := filepath.Join(dir, "AGENTS.md") + if err := os.WriteFile(agentsPath, []byte("existing\n"), 0o644); err != nil { + t.Fatal(err) + } + setHook(t, &osReadFile, func(path string) ([]byte, error) { + if path == agentsPath { + return nil, os.ErrPermission + } + return os.ReadFile(path) + }) + if err := InjectAGENTS(agentsPath, "prompt"); err == nil { + t.Error("expected read error to propagate") + } +} + +func TestInjectAGENTSWriteError(t *testing.T) { + dir := t.TempDir() + agentsPath := filepath.Join(dir, "AGENTS.md") + setHook(t, &osWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") }) + if err := InjectAGENTS(agentsPath, "prompt"); err == nil { + t.Error("expected write error to propagate") + } +} + +// ── Overlay error paths and edge cases ─────────────────────────────────── + +func TestAppendOverlayReadError(t *testing.T) { + setHook(t, &overlayReadFile, func(string) ([]byte, error) { return nil, errors.New("read") }) + if AppendOverlay("/any/path/SKILL.md") { + t.Error("expected false on read error") + } +} + +func TestAppendOverlayWriteError(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "SKILL.md") + if err := os.WriteFile(p, []byte("# body\n"), 0o644); err != nil { + t.Fatal(err) + } + setHook(t, &overlayWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") }) + if AppendOverlay(p) { + t.Error("expected false on write error") + } +} + +func TestAppendOverlayNoTrailingNewline(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "SKILL.md") + if err := os.WriteFile(p, []byte("# body"), 0o644); err != nil { + t.Fatal(err) + } + setHook(t, &commitHintHook, func(string) string { return "abc12345" }) + if !AppendOverlay(p) { + t.Fatal("expected AppendOverlay to modify file") + } + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + body := string(b) + if !strings.Contains(body, OverlayMarker) { + t.Error("missing overlay marker") + } + if !strings.Contains(body, "abc12345") { + t.Error("missing injected commit hint") + } + // Ensure the newline was inserted between the body and the overlay. + if !strings.Contains(body, "# body\n\n") { + t.Error("missing newline separator") + } +} + +func TestCommitHintBranches(t *testing.T) { + home := t.TempDir() + // The current implementation walks up three directories from the skill path. + skillDir := filepath.Join(home, "skills", "superpowers", "foo") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + p := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(p, []byte("# body\n"), 0o644); err != nil { + t.Fatal(err) + } + pinDir := filepath.Join(home, "skills") + pinPath := filepath.Join(pinDir, ".sin-code-pin") + + if got := commitHint(p); got != "" { + t.Errorf("missing pin: expected empty, got %q", got) + } + + writePin := func(content string) { + if err := os.WriteFile(pinPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + writePin("{\n \"sha\": \"0123456789abcdef\"\n}\n") + if got := commitHint(p); got != "01234567" { + t.Errorf("sha field: got %q", got) + } + + writePin("{\n \"sha\": \"ab\"\n}\n") + if got := commitHint(p); got != "ab" { + t.Errorf("short sha: got %q", got) + } + + writePin("1234567890") + if got := commitHint(p); got != "12345678" { + t.Errorf("content fallback: got %q", got) + } + + writePin("x") + if got := commitHint(p); got != "" { + t.Errorf("too short content: expected empty, got %q", got) + } +} + +// ── Frontmatter corner cases ─────────────────────────────────────────── + +func TestParseFrontmatterNoClosingFence(t *testing.T) { + fm, ok := ParseFrontmatter("---\nname: x\n") + if ok { + t.Errorf("expected no closing fence to fail, got %+v", fm) + } +} + +func TestParseFrontmatterClosingFenceAtEOF(t *testing.T) { + fm, ok := ParseFrontmatter("---\nname: x\n---") + if !ok || fm["name"] != "x" { + t.Errorf("expected closing fence at EOF to parse, got %+v ok=%v", fm, ok) + } +} + +func TestParseFrontmatterBodyEndsWithDash(t *testing.T) { + fm, ok := ParseFrontmatter("---\nname: x\n-") + if ok { + t.Errorf("expected trailing dash without fence to fail, got %+v", fm) + } +} + +func TestParseFrontmatterKeyOnly(t *testing.T) { + fm, ok := ParseFrontmatter("---\nname:\n---\n") + if !ok || fm["name"] != "" { + t.Errorf("expected empty value, got %+v ok=%v", fm, ok) + } +} + +func TestParseFrontmatterNoColonLine(t *testing.T) { + fm, ok := ParseFrontmatter("---\nname: x\nplain text\n---\n") + if !ok || fm["name"] != "x" || fm["plain text"] != "" { + t.Errorf("expected no-colon line to be skipped, got %+v", fm) + } +} + +func TestParseFrontmatterLeadingSpacesKey(t *testing.T) { + fm, ok := ParseFrontmatter("---\n name: x\n---\n") + if !ok || fm["name"] != "x" { + t.Errorf("expected leading spaces to be stripped, got %+v", fm) + } +} + +func TestParseFrontmatterQuoteToggles(t *testing.T) { + body := "---\n'a:b': v1\n\"c:d\": v2\n---\n" + fm, ok := ParseFrontmatter(body) + if !ok { + t.Fatalf("expected ok, got false") + } + if fm["'a:b'"] != "v1" || fm["\"c:d\""] != "v2" { + t.Errorf("unexpected map: %+v", fm) + } +} + +func TestParseFrontmatterCollectBlockBreak(t *testing.T) { + body := "---\ndescription: >-\n line1\n line2\nplain: value\n---\n" + fm, ok := ParseFrontmatter(body) + if !ok { + t.Fatalf("expected ok") + } + if fm["plain"] != "value" { + t.Errorf("expected plain value after block break, got %+v", fm) + } +} + +func TestParseFrontmatterFoldedScalarCRLF(t *testing.T) { + body := "---\ndescription: >-\n line1\r\n line2\r\n---\n" + fm, ok := ParseFrontmatter(body) + if !ok { + t.Fatalf("expected ok") + } + if strings.Contains(fm["description"], "\n") || strings.Contains(fm["description"], "\r") { + t.Errorf("folded scalar should not contain line breaks, got %q", fm["description"]) + } +} + +func TestLeadingSpaces(t *testing.T) { + if got := leadingSpaces(" abc"); got != 3 { + t.Errorf("leadingSpaces: got %d", got) + } + if got := leadingSpaces(" \t"); got != 4 { + t.Errorf("leadingSpaces all-space: got %d", got) + } +} + +func TestTrimUnicode(t *testing.T) { + if got := trimUnicode("a\tb\nc"); got != "a b c" { + t.Errorf("trimUnicode: got %q", got) + } +} + +// ── Prompt rendering ───────────────────────────────────────────────────── + +func TestRenderPromptNoPin(t *testing.T) { + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + body := RenderPrompt(nil) + if !strings.Contains(body, "not installed") { + t.Errorf("expected no-pin message, got:\n%s", body) + } +} + +func TestRenderPromptEmptySkills(t *testing.T) { + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + body := RenderPrompt([]SkillInfo{}) + if !strings.Contains(body, "No skills discovered") { + t.Errorf("expected empty-skills message, got:\n%s", body) + } +} + +func TestRenderPromptFallbacks(t *testing.T) { + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + body := RenderPrompt([]SkillInfo{{Name: "", Description: "", Path: "/tmp/my-skill/SKILL.md", Hash: "h"}}) + if !strings.Contains(body, "my-skill") { + t.Error("expected name fallback from path") + } + if !strings.Contains(body, "no description in frontmatter") { + t.Error("expected description fallback") + } +} + +func TestAGENTSSnippetNoPin(t *testing.T) { + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + body := AGENTSSnippet(nil) + if !strings.Contains(body, "Not installed") { + t.Errorf("expected no-pin message, got:\n%s", body) + } +} + +func TestAGENTSSnippetFallbacks(t *testing.T) { + body := AGENTSSnippet([]SkillInfo{{Name: "", Description: "", Path: "/tmp/foo/SKILL.md", Hash: "h"}}) + if !strings.Contains(body, "foo") { + t.Error("expected name fallback from path") + } + if !strings.Contains(body, "(no description)") { + t.Error("expected description fallback") + } +} + +// ── MCP server ─────────────────────────────────────────────────────────── + +func TestMCPServerNewServer(t *testing.T) { + s := NewServer(t.TempDir()) + if s == nil { + t.Fatal("NewServer returned nil") + } +} + +func TestMCPServerServeContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s := NewServerWithIO(strings.NewReader(""), &strings.Builder{}, &strings.Builder{}, t.TempDir()) + if err := s.Serve(ctx); err != context.Canceled { + t.Errorf("expected canceled, got %v", err) + } +} + +func TestMCPServerServeMalformedJSON(t *testing.T) { + var errOut strings.Builder + s := NewServerWithIO(strings.NewReader("not json\n"), &strings.Builder{}, &errOut, t.TempDir()) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + if err := s.Serve(ctx); err == nil { + t.Fatalf("expected timeout or decode error") + } + if !strings.Contains(errOut.String(), "decode error") { + t.Errorf("expected decode error on stderr, got %q", errOut.String()) + } +} + +func TestMCPServerServeEncodeError(t *testing.T) { + in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize"}` + "\n") + out := &errWriter{err: errors.New("write fail")} + s := NewServerWithIO(in, out, &strings.Builder{}, t.TempDir()) + if err := s.Serve(context.Background()); err == nil { + t.Error("expected encode error") + } +} + +func TestMCPServerHandleNotification(t *testing.T) { + in := strings.NewReader(`{"jsonrpc":"2.0","method":"tools/list"}` + "\n") + var out strings.Builder + s := NewServerWithIO(in, &out, &strings.Builder{}, t.TempDir()) + if err := s.Serve(context.Background()); err != nil { + t.Fatalf("Serve: %v", err) + } + if strings.TrimSpace(out.String()) != "" { + t.Errorf("expected no response for notification, got %q", out.String()) + } +} + +func TestMCPServerResultMarshalError(t *testing.T) { + s := NewServerWithIO(nil, nil, nil, t.TempDir()) + id := json.RawMessage("1") + resp := s.result(&jsonRPCRequest{ID: &id}, map[string]any{"bad": make(chan int)}) + if resp == nil || resp.Error == nil { + t.Fatal("expected error response") + } +} + +func TestMCPServerCallToolListError(t *testing.T) { + s := NewServerWithIO(nil, nil, nil, t.TempDir()) + setHook(t, &mcpListFunc, func(string) ([]SkillInfo, error) { return nil, errors.New("list") }) + id := json.RawMessage("1") + resp := s.callTool(context.Background(), &jsonRPCRequest{ID: &id}, &toolCallParams{Name: "superpowers_list_skills"}) + if resp == nil || resp.Error == nil { + t.Error("expected error response") + } +} + +func TestMCPServerCallToolFindError(t *testing.T) { + s := NewServerWithIO(nil, nil, nil, t.TempDir()) + setHook(t, &mcpFindFunc, func(string, int) ([]SkillInfo, error) { return nil, errors.New("find") }) + id := json.RawMessage("1") + resp := s.callTool(context.Background(), &jsonRPCRequest{ID: &id}, &toolCallParams{Name: "superpowers_find_skill", Arguments: []byte("{}")}) + if resp == nil || resp.Error == nil { + t.Error("expected error response") + } +} + +func TestMCPServerCallToolGetError(t *testing.T) { + s := NewServerWithIO(nil, nil, nil, t.TempDir()) + setHook(t, &mcpGetFunc, func(string) (*SkillInfo, error) { return nil, errors.New("get") }) + id := json.RawMessage("1") + resp := s.callTool(context.Background(), &jsonRPCRequest{ID: &id}, &toolCallParams{Name: "superpowers_use_skill", Arguments: []byte("{}")}) + if resp == nil || resp.Error == nil { + t.Error("expected error response") + } +} + +func TestMCPServerCallToolReadFileError(t *testing.T) { + s := NewServerWithIO(nil, nil, nil, t.TempDir()) + setHook(t, &mcpGetFunc, func(string) (*SkillInfo, error) { return &SkillInfo{Name: "x", Path: "/x/SKILL.md"}, nil }) + setHook(t, &mcpReadFile, func(string) ([]byte, error) { return nil, errors.New("read") }) + id := json.RawMessage("1") + resp := s.callTool(context.Background(), &jsonRPCRequest{ID: &id}, &toolCallParams{Name: "superpowers_use_skill", Arguments: []byte("{}")}) + if resp == nil || resp.Error == nil { + t.Error("expected error response") + } +} + +func TestMCPServerCallToolUnknownTool(t *testing.T) { + s := NewServerWithIO(nil, nil, nil, t.TempDir()) + id := json.RawMessage("1") + resp := s.callTool(context.Background(), &jsonRPCRequest{ID: &id}, &toolCallParams{Name: "superpowers_no_such_tool"}) + if resp == nil || resp.Error == nil || resp.Error.Code != -32602 { + t.Errorf("expected unknown tool error, got %+v", resp) + } +} + +func TestRegisterMCPUpdatesDifferentCommand(t *testing.T) { + dir := t.TempDir() + t.Setenv("SIN_CODE_HOME", dir) + mcpPath := MCPConfigPath() + cfg := map[string]any{"mcpServers": map[string]any{"superpowers": map[string]any{"command": "other"}}} + b, _ := json.Marshal(cfg) + if err := os.WriteFile(mcpPath, b, 0o644); err != nil { + t.Fatal(err) + } + if _, err := RegisterMCP(mcpPath); err != nil { + t.Fatal(err) + } + updated, err := os.ReadFile(mcpPath) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(updated, &got); err != nil { + t.Fatal(err) + } + servers := got["mcpServers"].(map[string]any) + entry := servers["superpowers"].(map[string]any) + if entry["command"] != "sin-code" { + t.Errorf("command not updated: %v", entry["command"]) + } +} + +// TestNewServerEmptyCfgDir covers the cfgDir fallback inside NewServer. +func TestNewServerEmptyCfgDir(t *testing.T) { + t.Setenv("SIN_CODE_HOME", "") + s := NewServer("") + if s == nil { + t.Fatal("NewServer returned nil") + } +} + +// TestMCPServerPing covers the "ping" method dispatch. +func TestMCPServerPing(t *testing.T) { + in := strings.NewReader(`{"jsonrpc":"2.0","id":7,"method":"ping"}` + "\n") + var out strings.Builder + s := NewServerWithIO(in, &out, &strings.Builder{}, t.TempDir()) + if err := s.Serve(context.Background()); err != nil { + t.Fatalf("Serve: %v", err) + } + if !strings.Contains(out.String(), "pong") { + t.Errorf("expected pong response, got %s", out.String()) + } +} + +// TestRegisterMCPEmptyPath covers the empty mcpPath fallback in RegisterMCP. +func TestRegisterMCPEmptyPath(t *testing.T) { + dir := t.TempDir() + t.Setenv("SIN_CODE_HOME", dir) + path, err := RegisterMCP("") + if err != nil { + t.Fatal(err) + } + if path != MCPConfigPath() { + t.Errorf("path: got %q want %q", path, MCPConfigPath()) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("mcp.json not written: %v", err) + } +} + +// TestListNameFallback covers the parent-directory fallback when frontmatter +// has no name. +func TestListNameFallback(t *testing.T) { + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + skillDir := filepath.Join(SkillsDir(), "fallback-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: no name\n---\n"), 0o644); err != nil { + t.Fatal(err) + } + all, err := List("") + if err != nil { + t.Fatal(err) + } + if len(all) != 1 || all[0].Name != "fallback-skill" { + t.Errorf("unexpected skills: %+v", all) + } +} + +// TestListWalkEntryError covers the walkDir callback error-skip path. +func TestListWalkEntryError(t *testing.T) { + home := t.TempDir() + t.Setenv("SIN_CODE_HOME", home) + skillDir := filepath.Join(SkillsDir(), "x") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + setHook(t, &walkDirHook, func(root string, fn fs.WalkDirFunc) error { + return fn(filepath.Join(root, "x", "SKILL.md"), nil, errors.New("entry error")) + }) + all, err := List("") + if err != nil { + t.Fatal(err) + } + if len(all) != 0 { + t.Errorf("expected 0 skills after entry error, got %d", len(all)) + } +} + +// TestInjectAGENTSMissingEndMarker covers the branch where the begin marker is +// present but the end marker is not. +func TestInjectAGENTSMissingEndMarker(t *testing.T) { + dir := t.TempDir() + agentsPath := filepath.Join(dir, "AGENTS.md") + if err := os.WriteFile(agentsPath, []byte("\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := InjectAGENTS(agentsPath, "prompt"); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(agentsPath) + if err != nil { + t.Fatal(err) + } + body := string(b) + if strings.Count(body, "SIN-Code superpowers:begin") != 1 { + t.Errorf("expected exactly one begin marker, got %d", strings.Count(body, "SIN-Code superpowers:begin")) + } + if !strings.Contains(body, "SIN-Code superpowers:end") { + t.Error("expected end marker to be added") + } +} + +// TestInjectAGENTSNoTrailingNewline covers the newline-padding branch when the +// existing file body does not end with a newline. +func TestInjectAGENTSNoTrailingNewline(t *testing.T) { + dir := t.TempDir() + agentsPath := filepath.Join(dir, "AGENTS.md") + if err := os.WriteFile(agentsPath, []byte("existing"), 0o644); err != nil { + t.Fatal(err) + } + if err := InjectAGENTS(agentsPath, "prompt"); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(agentsPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), "existing\n\n") { + t.Errorf("expected newline padding, got:\n%s", string(b)) + } +} diff --git a/cmd/sin-code/internal/todo/audit.go b/cmd/sin-code/internal/todo/audit.go index 4ef8e0e7..ecf3c2d0 100644 --- a/cmd/sin-code/internal/todo/audit.go +++ b/cmd/sin-code/internal/todo/audit.go @@ -1,7 +1,7 @@ package todo import ( - "crypto/sha1" + "crypto/sha256" "encoding/binary" "encoding/json" "fmt" @@ -11,6 +11,11 @@ import ( bolt "go.etcd.io/bbolt" ) +var ( + jsonMarshalAudit = json.Marshal + jsonUnmarshalAudit = json.Unmarshal +) + func auditKey(ts time.Time, id string) []byte { b := make([]byte, 8) binary.BigEndian.PutUint64(b, uint64(ts.UnixNano())) @@ -25,7 +30,7 @@ func auditPrefix() []byte { func (s *Store) AppendAudit(e AuditEntry) error { if e.ID == "" { - h := sha1.Sum([]byte(fmt.Sprintf("%d-%s-%s", time.Now().UnixNano(), e.TodoID, e.Action))) + h := sha256.Sum256([]byte(fmt.Sprintf("%d-%s-%s", time.Now().UnixNano(), e.TodoID, e.Action))) e.ID = fmt.Sprintf("au-%x", h[:6]) } if e.Timestamp.IsZero() { @@ -36,7 +41,7 @@ func (s *Store) AppendAudit(e AuditEntry) error { } return s.update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte(bucketAudit)) - data, err := json.Marshal(&e) + data, err := jsonMarshalAudit(&e) if err != nil { return err } @@ -51,7 +56,7 @@ func (s *Store) ListAudit(todoID string) ([]*AuditEntry, error) { c := b.Cursor() for k, v := c.First(); k != nil; k, v = c.Next() { var e AuditEntry - if uerr := json.Unmarshal(v, &e); uerr != nil { + if uerr := jsonUnmarshalAudit(v, &e); uerr != nil { continue } if todoID == "" || e.TodoID == todoID { diff --git a/cmd/sin-code/internal/todo/compact.go b/cmd/sin-code/internal/todo/compact.go index abe6ce14..f560771c 100644 --- a/cmd/sin-code/internal/todo/compact.go +++ b/cmd/sin-code/internal/todo/compact.go @@ -5,6 +5,11 @@ import ( "time" ) +var ( + listAllFn = (*Store).List + updateFn = (*Store).Update +) + type CompactOptions struct { OlderThan time.Duration OnlyStatuses []Status @@ -25,7 +30,7 @@ func (s *Store) Compact(opts CompactOptions) (*CompactResult, error) { if opts.OlderThan == 0 { cutoff = time.Time{} } - all, err := s.List() + all, err := listAllFn(s) if err != nil { return nil, err } @@ -52,7 +57,7 @@ func (s *Store) Compact(opts CompactOptions) (*CompactResult, error) { t.Compacted = true t.Summary = summarize(t) t.Description = "" - if err := s.Update(t); err != nil { + if err := updateFn(s, t); err != nil { return nil, err } } diff --git a/cmd/sin-code/internal/todo/coverage_test.go b/cmd/sin-code/internal/todo/coverage_test.go new file mode 100644 index 00000000..070f6c10 --- /dev/null +++ b/cmd/sin-code/internal/todo/coverage_test.go @@ -0,0 +1,2435 @@ +// SPDX-License-Identifier: MIT +// Purpose: targeted coverage tests for the todo package — exercise error paths +// and CLI commands that the main CRUD tests do not reach. +package todo + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/BurntSushi/toml" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + bolt "go.etcd.io/bbolt" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/notifications" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/plugins" +) + +func setTestGlobals(t *testing.T) (string, func()) { + dir := t.TempDir() + db := filepath.Join(dir, "todo.db") + oldDB := todoDBPath + oldAs := todoAs + oldProject := todoProject + t.Setenv("XDG_CONFIG_HOME", dir) + todoDBPath = db + todoAs = "tester" + todoProject = "testproj" + return db, func() { + todoDBPath = oldDB + todoAs = oldAs + todoProject = oldProject + } +} + +func setupCmdTest(t *testing.T) func() { + _, cleanup := setTestGlobals(t) + oldFormat := todoFormat + todoFormat = "text" + origNotify := notifyFn + origFireHooks := fireHooksFn + origFirePluginHooks := firePluginHooksFn + origGetHookConfig := getHookConfigFn + origPrintJSON := printJSONFn + origOpenStore := openStoreFn + origActor := currentActorFn + origProject := currentProjectFn + origConfigDirHooks := osUserConfigDirHooks + origConfigDirTodo := osUserConfigDirTodo + dir := t.TempDir() + notifyFn = func(nt notifications.Type, todoID, title, message, actor string) {} + fireHooksFn = func(store *Store, event HookEvent, t *Todo, from, to, note string) {} + firePluginHooksFn = func(store *Store, event HookEvent, t *Todo, from, to, note string) {} + getHookConfigFn = func() *HookConfig { return &HookConfig{Hooks: map[HookEvent][]Hook{}} } + osUserConfigDirHooks = func() (string, error) { return dir, nil } + osUserConfigDirTodo = func() (string, error) { return dir, nil } + return func() { + todoFormat = oldFormat + notifyFn = origNotify + fireHooksFn = origFireHooks + firePluginHooksFn = origFirePluginHooks + getHookConfigFn = origGetHookConfig + printJSONFn = origPrintJSON + openStoreFn = origOpenStore + currentActorFn = origActor + currentProjectFn = origProject + osUserConfigDirHooks = origConfigDirHooks + osUserConfigDirTodo = origConfigDirTodo + cleanup() + } +} + +func runCmd(cmd *cobra.Command, args []string, flags map[string]string, format string) error { + oldVals := map[string]string{} + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + oldVals[f.Name] = f.Value.String() + }) + if flags != nil { + for name, val := range flags { + _ = cmd.Flags().Set(name, val) + } + } + _ = cmd.Flags().Set("format", format) + root := cmd.Root() + if root == cmd { + root.SetArgs(args) + } else { + root.SetArgs(append(strings.Split(cmd.CommandPath(), " ")[1:], args...)) + } + err := root.Execute() + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if v, ok := oldVals[f.Name]; ok { + _ = f.Value.Set(v) + } + f.Changed = false + }) + return err +} + +// ── remaining coverage tests — fill the statement gaps identified by +// `go tool cover -func` after the first pass. ─────────────────────────────── + +func TestListAuditSorted(t *testing.T) { + s := tempStore(t) + _ = s.AppendAudit(AuditEntry{TodoID: "x", Action: "a", Timestamp: time.Now().Add(-2 * time.Hour)}) + _ = s.AppendAudit(AuditEntry{TodoID: "x", Action: "b", Timestamp: time.Now().Add(-1 * time.Hour)}) + entries, err := s.ListAudit("x") + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0].Action != "a" || entries[1].Action != "b" { + t.Errorf("expected sorted order a,b, got %v", []string{entries[0].Action, entries[1].Action}) + } +} + +func TestCompactSkipOpen(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "Open", Status: StatusOpen}) + old := time.Now().Add(-1000 * time.Hour) + done := &Todo{Title: "Done", Status: StatusDone, UpdatedAt: old, ClosedAt: &old} + _ = s.Add(done) + res, err := s.Compact(CompactOptions{OlderThan: 720 * time.Hour}) + if err != nil { + t.Fatal(err) + } + if res.Compacted != 1 { + t.Errorf("expected 1 compacted, got %d", res.Compacted) + } +} + +func TestOpenMkdirError(t *testing.T) { + orig := osMkdirAllStore + osMkdirAllStore = func(path string, perm os.FileMode) error { return errors.New("boom") } + defer func() { osMkdirAllStore = orig }() + if _, err := Open(filepath.Join(t.TempDir(), "todo.db")); err == nil { + t.Error("expected mkdir error") + } +} + +func TestOpenCreateBucketError(t *testing.T) { + orig := createBucketIfNotExistsFn + createBucketIfNotExistsFn = func(tx *bolt.Tx, name []byte) (*bolt.Bucket, error) { return nil, errors.New("boom") } + defer func() { createBucketIfNotExistsFn = orig }() + if _, err := Open(filepath.Join(t.TempDir(), "todo.db")); err == nil { + t.Error("expected create bucket error") + } +} + +func TestAddInvalid(t *testing.T) { + s := tempStore(t) + if err := s.Add(nil); err == nil { + t.Error("expected nil todo error") + } + if err := s.Add(&Todo{}); err == nil { + t.Error("expected title required") + } + if err := s.Add(&Todo{Title: "A", Priority: "P9"}); err == nil { + t.Error("expected invalid priority") + } + if err := s.Add(&Todo{Title: "A", Type: "nope"}); err == nil { + t.Error("expected invalid type") + } + if err := s.Add(&Todo{Title: "A", Status: "nope"}); err == nil { + t.Error("expected invalid status") + } +} + +func TestAddBucketPutError(t *testing.T) { + s := tempStore(t) + orig := bucketPutFn + bucketPutFn = func(b *bolt.Bucket, k, v []byte) error { return errors.New("boom") } + defer func() { bucketPutFn = orig }() + if err := s.Add(&Todo{Title: "A"}); err == nil { + t.Error("expected put error") + } +} + +func TestUpdateInvalid(t *testing.T) { + s := tempStore(t) + if err := s.Update(nil); err == nil { + t.Error("expected nil error") + } + if err := s.Update(&Todo{Title: "A"}); err == nil { + t.Error("expected missing id") + } + if err := s.Update(&Todo{ID: "st-aaaa", Status: "nope"}); err == nil { + t.Error("expected invalid status") + } + if err := s.Update(&Todo{ID: "st-aaaa", Title: "A"}); err == nil { + t.Error("expected not found") + } +} + +func TestUpdateBucketPutError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + ts, _ := s.List() + id := ts[0].ID + orig := bucketPutFn + bucketPutFn = func(b *bolt.Bucket, k, v []byte) error { return errors.New("boom") } + defer func() { bucketPutFn = orig }() + if err := s.Update(&Todo{ID: id, Title: "B"}); err == nil { + t.Error("expected put error") + } +} + +func TestUpdateProjectAndTags(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A", Project: "p1", Tags: []string{"a", "b"}}) + ts, _ := s.List() + id := ts[0].ID + td, _ := s.Get(id) + td.Project = "p2" + td.Tags = []string{"a"} + if err := s.Update(td); err != nil { + t.Fatal(err) + } +} + +func TestDeleteSoftMarshalError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + ts, _ := s.List() + id := ts[0].ID + orig := jsonMarshalStore + jsonMarshalStore = func(v interface{}) ([]byte, error) { return nil, errors.New("boom") } + defer func() { jsonMarshalStore = orig }() + if err := s.Delete(id, false); err == nil { + t.Error("expected marshal error") + } +} + +func TestDeleteHardWithTags(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A", Tags: []string{"x"}}) + ts, _ := s.List() + id := ts[0].ID + if err := s.Delete(id, true); err != nil { + t.Fatal(err) + } + if _, err := s.Get(id); err == nil { + t.Error("expected not found") + } +} + +func TestNormalizeTags(t *testing.T) { + tags := normalizeTags([]string{"a", " a ", "", "b", "a"}) + if len(tags) != 2 || tags[0] != "a" || tags[1] != "b" { + t.Errorf("unexpected tags: %v", tags) + } +} + +func TestAddDepInvalid(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + _ = s.Add(&Todo{Title: "B"}) + if err := s.AddDep(Dependency{From: "", To: "st-aaaa", Type: DepBlocks}); err == nil { + t.Error("expected empty from") + } + if err := s.AddDep(Dependency{From: "st-aaaa", To: "st-aaaa", Type: DepBlocks}); err == nil { + t.Error("expected self-dependency") + } + if err := s.AddDep(Dependency{From: "st-aaaa", To: "st-bbbb", Type: DepType("nope")}); err == nil { + t.Error("expected invalid type") + } + ts, _ := s.List() + a, b := ts[0].ID, ts[1].ID + if err := s.AddDep(Dependency{From: "st-missing", To: b, Type: DepBlocks}); err == nil { + t.Error("expected from not found") + } + if err := s.AddDep(Dependency{From: a, To: "st-missing", Type: DepBlocks}); err == nil { + t.Error("expected to not found") + } +} + +func TestAddDepCycle(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + _ = s.Add(&Todo{Title: "B"}) + ts, _ := s.List() + a, b := ts[0].ID, ts[1].ID + if err := s.AddDep(Dependency{From: a, To: b, Type: DepBlocks}); err != nil { + t.Fatal(err) + } + if err := s.AddDep(Dependency{From: b, To: a, Type: DepBlocks}); err == nil { + t.Error("expected cycle") + } +} + +func TestAddDepBucketPutError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + _ = s.Add(&Todo{Title: "B"}) + ts, _ := s.List() + a, b := ts[0].ID, ts[1].ID + orig := bucketPutFn + bucketPutFn = func(b *bolt.Bucket, k, v []byte) error { return errors.New("boom") } + defer func() { bucketPutFn = orig }() + if err := s.AddDep(Dependency{From: a, To: b, Type: DepBlocks}); err == nil { + t.Error("expected put error") + } +} + +func TestRemoveDepBucketDeleteError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + _ = s.Add(&Todo{Title: "B"}) + ts, _ := s.List() + a, b := ts[0].ID, ts[1].ID + _ = s.AddDep(Dependency{From: a, To: b, Type: DepBlocks}) + orig := bucketDeleteFn + bucketDeleteFn = func(b *bolt.Bucket, k []byte) error { return errors.New("boom") } + defer func() { bucketDeleteFn = orig }() + if err := s.RemoveDep(a, b); err == nil { + t.Error("expected delete error") + } +} + +func TestGetDepsInvalidKey(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + _ = s.update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(bucketDeps)) + return b.Put([]byte("b\x00x"), []byte("1")) + }) + deps, err := s.GetDeps("b") + if err != nil { + t.Fatal(err) + } + if len(deps) != 0 { + t.Errorf("expected 0, got %d", len(deps)) + } +} + +func TestWouldCreateCycleVisited(t *testing.T) { + s := tempStore(t) + // Manually create a cycle A->B->C->A. + _ = s.update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(bucketDeps)) + for _, k := range [][]byte{ + []byte("a\x00b\x00blocks"), + []byte("b\x00c\x00blocks"), + []byte("c\x00a\x00blocks"), + } { + if err := b.Put(k, []byte("1")); err != nil { + return err + } + } + return nil + }) + cycle, err := s.wouldCreateCycle("z", "a") + if err != nil { + t.Fatal(err) + } + if cycle { + t.Error("expected no cycle to z") + } +} + +func TestWouldCreateCycleNonBlocks(t *testing.T) { + s := tempStore(t) + _ = s.update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(bucketDeps)) + return b.Put([]byte("a\x00b\x00related"), []byte("1")) + }) + cycle, err := s.wouldCreateCycle("b", "a") + if err != nil { + t.Fatal(err) + } + if cycle { + t.Error("expected no cycle through non-blocks") + } +} + +func TestDependencyTreeWalkError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + _ = s.Add(&Todo{Title: "B"}) + ts, _ := s.List() + a, b := ts[0].ID, ts[1].ID + _ = s.AddDep(Dependency{From: a, To: b, Type: DepBlocks}) + orig := getDepsFn + getDepsFn = func(st *Store, id string) ([]Dependency, error) { + if id == b { + return nil, errors.New("boom") + } + return orig(st, id) + } + defer func() { getDepsFn = orig }() + if _, err := s.DependencyTree(a, 5); err == nil { + t.Error("expected walk error") + } +} + +func TestListFilteredTagNotFound(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A", Tags: []string{"x"}}) + out, err := s.ListFiltered(ListFilter{Tag: "y"}) + if err != nil { + t.Fatal(err) + } + if len(out) != 0 { + t.Errorf("expected 0, got %d", len(out)) + } +} + +func TestReadyAndBlockedMissingBlocker(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A", Status: StatusOpen}) + _ = s.Add(&Todo{Title: "B", Status: StatusOpen}) + ts, _ := s.List() + a, b := ts[0].ID, ts[1].ID + _ = s.AddDep(Dependency{From: a, To: b, Type: DepBlocks}) + _ = s.Delete(b, true) + ready, err := s.Ready() + if err != nil { + t.Fatal(err) + } + if len(ready) != 1 { + t.Errorf("expected 1 ready, got %d", len(ready)) + } + blocked, err := s.Blocked() + if err != nil { + t.Fatal(err) + } + if len(blocked) != 0 { + t.Errorf("expected 0 blocked, got %d", len(blocked)) + } +} + +func TestComputeStatsReadyError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + orig := getDepsFn + getDepsFn = func(st *Store, id string) ([]Dependency, error) { return nil, errors.New("boom") } + defer func() { getDepsFn = orig }() + if _, err := s.ComputeStats(); err == nil { + t.Error("expected stats error") + } +} + +func TestHookCmdErrors(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + if err := runCmd(hookAddCmd, []string{"invalid"}, map[string]string{"command": "echo"}, "text"); err == nil { + t.Error("expected invalid event") + } + if err := runCmd(hookAddCmd, []string{"post_complete"}, nil, "text"); err == nil { + t.Error("expected empty command") + } + if err := runCmd(hookRemoveCmd, []string{"invalid"}, nil, "text"); err == nil { + t.Error("expected invalid event remove") + } + if err := runCmd(hookRemoveCmd, []string{"post_complete"}, map[string]string{"index": "0"}, "text"); err == nil { + t.Error("expected remove hook not found") + } + if err := runCmd(hookTestCmd, []string{"invalid"}, nil, "text"); err == nil { + t.Error("expected invalid event test") + } +} + +func TestHookCmdAddRemoveList(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + if err := runCmd(hookAddCmd, []string{"post_complete"}, map[string]string{"command": "echo done", "timeout": "1s", "on-error": "warn"}, "text"); err != nil { + t.Fatal(err) + } + if err := runCmd(hookListCmd, []string{}, nil, "text"); err != nil { + t.Fatal(err) + } + if err := runCmd(hookRemoveCmd, []string{"post_complete"}, map[string]string{"index": "0"}, "text"); err != nil { + t.Fatal(err) + } + if err := runCmd(hookTestCmd, []string{"post_complete"}, nil, "text"); err != nil { + t.Fatal(err) + } +} + +func TestLoadHooksConfigEmptyPath(t *testing.T) { + orig := osUserConfigDirHooks + osUserConfigDirHooks = func() (string, error) { return "", errors.New("boom") } + defer func() { osUserConfigDirHooks = orig }() + cfg, err := LoadHooksConfig("") + if err != nil { + t.Fatal(err) + } + if cfg.Path() != "" { + t.Errorf("expected empty path, got %q", cfg.Path()) + } +} + +func TestLoadHooksConfigNilHooks(t *testing.T) { + path := filepath.Join(t.TempDir(), "hooks.toml") + _ = os.WriteFile(path, []byte(""), 0644) + orig := tomlDecodeFileHooks + tomlDecodeFileHooks = func(p string, v interface{}) (toml.MetaData, error) { + md, err := orig(p, v) + if c, ok := v.(*HookConfig); ok { + c.Hooks = nil + } + return md, err + } + defer func() { tomlDecodeFileHooks = orig }() + cfg, err := LoadHooksConfig(path) + if err != nil { + t.Fatal(err) + } + if cfg.Hooks == nil { + t.Fatal("expected non-nil hooks") + } + if len(cfg.Hooks) != 0 { + t.Errorf("expected empty hooks, got %d", len(cfg.Hooks)) + } +} + +func TestHookConfigSaveEmptyPath(t *testing.T) { + cfg := &HookConfig{Hooks: map[HookEvent][]Hook{}} + if err := cfg.Add(EventPostComplete, Hook{Command: "echo"}); err != nil { + t.Fatal(err) + } + if err := cfg.Remove(EventPostComplete, 0); err != nil { + t.Fatal(err) + } +} + +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stderr + os.Stderr = w + fn() + _ = w.Close() + os.Stderr = old + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + _ = r.Close() + return buf.String() +} + +func TestCurrentProjectError(t *testing.T) { + orig := osGetwdTodo + osGetwdTodo = func() (string, error) { return "", errors.New("boom") } + defer func() { osGetwdTodo = orig }() + old := todoProject + todoProject = "" + defer func() { todoProject = old }() + if got := currentProject(); got != "" { + t.Errorf("expected empty, got %q", got) + } +} + +func TestGetHookConfigWarning(t *testing.T) { + orig := tomlDecodeFileHooks + tomlDecodeFileHooks = func(path string, v interface{}) (toml.MetaData, error) { return toml.MetaData{}, errors.New("boom") } + defer func() { tomlDecodeFileHooks = orig }() + hookConfigOnce = sync.Once{} + hookConfig = nil + out := captureStderr(t, func() { _ = getHookConfig() }) + if !strings.Contains(out, "warning") { + t.Errorf("expected warning, got %q", out) + } +} + +func TestFireHooks(t *testing.T) { + s := tempStore(t) + // nil config branch + hookConfigOnce = sync.Once{} + hookConfig = nil + fireHooks(s, EventPostAdd, &Todo{ID: "st-1", Title: "A"}, "", "", "") + + // warning branch + hookConfigOnce = sync.Once{} + hookConfig = &HookConfig{Hooks: map[HookEvent][]Hook{EventPostAdd: {{Command: "false", OnError: "warn"}}}} + out := captureStderr(t, func() { + fireHooks(s, EventPostAdd, &Todo{ID: "st-1", Title: "A"}, "", "", "") + }) + if !strings.Contains(out, "hook warning") { + t.Errorf("expected warning, got %q", out) + } + + // fail branch + hookConfigOnce = sync.Once{} + hookConfig = &HookConfig{Hooks: map[HookEvent][]Hook{EventPostAdd: {{Command: "false", OnError: "fail"}}}} + out = captureStderr(t, func() { + fireHooks(s, EventPostAdd, &Todo{ID: "st-1", Title: "A"}, "", "", "") + }) + if !strings.Contains(out, "hook failed") { + t.Errorf("expected fail message, got %q", out) + } + + // ignore branch + hookConfigOnce = sync.Once{} + hookConfig = &HookConfig{Hooks: map[HookEvent][]Hook{EventPostAdd: {{Command: "false", OnError: "ignore"}}}} + out = captureStderr(t, func() { + fireHooks(s, EventPostAdd, &Todo{ID: "st-1", Title: "A"}, "", "", "") + }) + if out != "" { + t.Errorf("expected silence, got %q", out) + } +} + +func TestFirePluginHooksNilRegistry(t *testing.T) { + s := tempStore(t) + orig := pluginRegistryFn + pluginRegistryFn = func() *plugins.Registry { return nil } + defer func() { pluginRegistryFn = orig }() + firePluginHooks(s, EventPostAdd, &Todo{ID: "st-1", Title: "A"}, "", "", "") +} + +func pluginRegistryWithHook(t *testing.T, event, command string) *plugins.Registry { + dir := t.TempDir() + sub := filepath.Join(dir, "p1") + _ = os.MkdirAll(sub, 0755) + body := fmt.Sprintf(` +name = "p1" +version = "1.0.0" +[[hooks]] +event = %q +command = %q +`, event, command) + _ = os.WriteFile(filepath.Join(sub, "plugin.toml"), []byte(body), 0644) + reg := plugins.NewRegistry() + _ = reg.LoadFromDir(dir) + return reg +} + +func TestFirePluginHooksWithHook(t *testing.T) { + s := tempStore(t) + orig := pluginRegistryFn + reg := pluginRegistryWithHook(t, "post_add", "echo hello") + pluginRegistryFn = func() *plugins.Registry { return reg } + defer func() { pluginRegistryFn = orig }() + firePluginHooks(s, EventPostAdd, &Todo{ID: "st-1", Title: "A"}, "", "", "") + entries, _ := s.ListAudit("st-1") + if len(entries) == 0 { + t.Error("expected audit entry") + } +} + +func TestFirePluginHooksStderr(t *testing.T) { + s := tempStore(t) + origReg := pluginRegistryFn + reg := pluginRegistryWithHook(t, "post_add", "echo err >&2") + pluginRegistryFn = func() *plugins.Registry { return reg } + defer func() { pluginRegistryFn = origReg }() + firePluginHooks(s, EventPostAdd, &Todo{ID: "st-1", Title: "A"}, "", "", "") +} + +func TestFirePluginHooksError(t *testing.T) { + s := tempStore(t) + origReg := pluginRegistryFn + reg := pluginRegistryWithHook(t, "post_add", "exit 1") + pluginRegistryFn = func() *plugins.Registry { return reg } + defer func() { pluginRegistryFn = origReg }() + firePluginHooks(s, EventPostAdd, &Todo{ID: "st-1", Title: "A"}, "", "", "") +} + +func setOpenStoreError(t *testing.T) func() { + t.Helper() + orig := openStoreFn + openStoreFn = func() (*Store, error) { return nil, errors.New("openStore error") } + return func() { openStoreFn = orig } +} + +func seedStore(t *testing.T, todos ...*Todo) { + t.Helper() + s, err := Open(todoDBPath) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer s.Close() + for _, td := range todos { + if err := s.Add(td); err != nil { + t.Fatalf("Add: %v", err) + } + } +} + +func TestCommandAdd(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + if err := runCmd(addCmd, []string{}, map[string]string{"title": "A"}, "text"); err != nil { + t.Fatalf("add: %v", err) + } + if err := runCmd(addCmd, []string{}, map[string]string{"title": "B", "priority": "P0", "type": "feature", "tags": "x,y", "assignee": "alice", "project": "p1"}, "json"); err != nil { + t.Fatalf("add json: %v", err) + } + if err := runCmd(addCmd, []string{}, map[string]string{"title": "C", "priority": "bad"}, "text"); err == nil { + t.Error("expected invalid priority error") + } + if err := runCmd(addCmd, []string{}, map[string]string{"title": "D", "type": "bad"}, "text"); err == nil { + t.Error("expected invalid type error") + } + if err := runCmd(addCmd, []string{}, nil, "text"); err == nil { + t.Error("expected missing title error") + } + stop := setOpenStoreError(t) + if err := runCmd(addCmd, []string{}, map[string]string{"title": "E"}, "text"); err == nil { + t.Error("expected openStore error") + } + stop() +} + +func TestCommandList(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Priority: PriorityP0, Tags: []string{"x"}, Assignee: "alice", Project: "p1"}) + if err := runCmd(listCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("list: %v", err) + } + if err := runCmd(listCmd, []string{}, map[string]string{"all": "true"}, "json"); err != nil { + t.Fatalf("list all json: %v", err) + } + if err := runCmd(listCmd, []string{}, map[string]string{"status": "open", "priority": "P0", "tag": "x", "assignee": "alice", "project": "p1", "search": "A"}, "text"); err != nil { + t.Fatalf("list filtered: %v", err) + } + if err := runCmd(listCmd, []string{}, map[string]string{"tag": "notag"}, "text"); err != nil { + t.Fatalf("list empty: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(listCmd, []string{}, nil, "text"); err == nil { + t.Error("expected openStore error") + } + stop() +} + +func TestCommandShow(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Description: "desc", Status: StatusInProgress, Assignee: "alice", Parent: "st-p", ExternalRef: "ref", Project: "p1", Tags: []string{"x"}, Notes: "notes"}) + if err := runCmd(showCmd, []string{"st-a"}, nil, "text"); err != nil { + t.Fatalf("show text: %v", err) + } + if err := runCmd(showCmd, []string{"st-a"}, nil, "json"); err != nil { + t.Fatalf("show json: %v", err) + } + if err := runCmd(showCmd, []string{"st-missing"}, nil, "text"); err == nil { + t.Error("expected not found") + } + stop := setOpenStoreError(t) + if err := runCmd(showCmd, []string{"st-a"}, nil, "text"); err == nil { + t.Error("expected openStore error") + } + stop() +} + +func TestCommandUpdate(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Status: StatusOpen}) + if err := runCmd(updateCmd, []string{"st-a"}, map[string]string{"title": "B", "desc": "d", "priority": "P1", "type": "bug", "status": "in_progress", "tags": "t", "assignee": "bob", "external-ref": "ref", "parent": "st-p", "notes": "n"}, "text"); err != nil { + t.Fatalf("update: %v", err) + } + if err := runCmd(updateCmd, []string{"st-a"}, map[string]string{"status": "bad"}, "text"); err == nil { + t.Error("expected invalid status error") + } + if err := runCmd(updateCmd, []string{"st-a"}, map[string]string{"priority": "bad"}, "text"); err == nil { + t.Error("expected invalid priority error") + } + if err := runCmd(updateCmd, []string{"st-a"}, map[string]string{"type": "bad"}, "text"); err == nil { + t.Error("expected invalid type error") + } + if err := runCmd(updateCmd, []string{"st-a"}, nil, "text"); err == nil { + t.Error("expected no changes error") + } + if err := runCmd(updateCmd, []string{"st-missing"}, map[string]string{"title": "X"}, "text"); err == nil { + t.Error("expected not found") + } + if err := runCmd(updateCmd, []string{"st-a"}, map[string]string{"title": "C"}, "json"); err != nil { + t.Fatalf("update json: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(updateCmd, []string{"st-a"}, map[string]string{"title": "D"}, "text"); err == nil { + t.Error("expected openStore error") + } + stop() +} + +func TestCommandClaimUnclaim(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + if err := runCmd(claimCmd, []string{"st-a"}, nil, "text"); err != nil { + t.Fatalf("claim: %v", err) + } + if err := runCmd(unclaimCmd, []string{"st-a"}, nil, "text"); err != nil { + t.Fatalf("unclaim: %v", err) + } + seedStore(t, &Todo{ID: "st-b", Title: "B", Assignee: "other"}) + if err := runCmd(claimCmd, []string{"st-b"}, nil, "text"); err == nil { + t.Error("expected already claimed error") + } + if err := runCmd(claimCmd, []string{"st-missing"}, nil, "text"); err == nil { + t.Error("expected claim not found error") + } + if err := runCmd(unclaimCmd, []string{"st-missing"}, nil, "text"); err == nil { + t.Error("expected unclaim not found error") + } + stop := setOpenStoreError(t) + if err := runCmd(claimCmd, []string{"st-a"}, nil, "text"); err == nil { + t.Error("expected openStore error") + } + stop() +} + +func TestCommandCompleteCancel(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + if err := runCmd(completeCmd, []string{"st-a"}, nil, "text"); err != nil { + t.Fatalf("complete: %v", err) + } + seedStore(t, &Todo{ID: "st-b", Title: "B"}) + if err := runCmd(cancelCmd, []string{"st-b"}, nil, "text"); err != nil { + t.Fatalf("cancel: %v", err) + } + if err := runCmd(completeCmd, []string{"st-missing"}, nil, "text"); err == nil { + t.Error("expected complete not found") + } + stop := setOpenStoreError(t) + if err := runCmd(cancelCmd, []string{"st-b"}, nil, "text"); err == nil { + t.Error("expected openStore error") + } + stop() +} + +func TestCommandDelete(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + if err := runCmd(deleteCmd, []string{"st-a"}, nil, "text"); err != nil { + t.Fatalf("delete soft: %v", err) + } + seedStore(t, &Todo{ID: "st-b", Title: "B"}) + if err := runCmd(deleteCmd, []string{"st-b"}, map[string]string{"soft": "false"}, "text"); err != nil { + t.Fatalf("delete hard: %v", err) + } + if err := runCmd(deleteCmd, []string{"st-missing"}, nil, "text"); err == nil { + t.Error("expected delete not found") + } + stop := setOpenStoreError(t) + if err := runCmd(deleteCmd, []string{"st-a"}, nil, "text"); err == nil { + t.Error("expected openStore error") + } + stop() +} + +func withStore(t *testing.T, fn func(*Store)) { + t.Helper() + s, err := Open(todoDBPath) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer s.Close() + fn(s) +} + +func TestCommandDep(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}, &Todo{ID: "st-b", Title: "B"}, &Todo{ID: "st-c", Title: "C"}) + if err := runCmd(depAddCmd, []string{"st-b", "st-a"}, nil, "text"); err != nil { + t.Fatalf("dep add: %v", err) + } + if err := runCmd(depAddCmd, []string{"st-b", "st-a"}, map[string]string{"type": "related"}, "text"); err != nil { + t.Fatalf("dep add related: %v", err) + } + if err := runCmd(depAddCmd, []string{"st-b", "st-a"}, map[string]string{"type": "bad"}, "text"); err == nil { + t.Error("expected invalid dep type error") + } + if err := runCmd(depAddCmd, []string{"st-missing", "st-a"}, nil, "text"); err == nil { + t.Error("expected dep add not found") + } + if err := runCmd(depRemoveCmd, []string{"st-b", "st-a"}, nil, "text"); err != nil { + t.Fatalf("dep remove: %v", err) + } + if err := runCmd(depAddCmd, []string{"st-b", "st-a"}, nil, "text"); err != nil { + t.Fatalf("dep add re: %v", err) + } + if err := runCmd(depsCmd, []string{"st-b"}, nil, "text"); err != nil { + t.Fatalf("deps text: %v", err) + } + if err := runCmd(depsCmd, []string{"st-b"}, nil, "json"); err != nil { + t.Fatalf("deps json: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(depAddCmd, []string{"st-b", "st-a"}, nil, "text"); err == nil { + t.Error("expected dep add openStore error") + } + if err := runCmd(depRemoveCmd, []string{"st-b", "st-a"}, nil, "text"); err == nil { + t.Error("expected dep remove openStore error") + } + if err := runCmd(depsCmd, []string{"st-b"}, nil, "text"); err == nil { + t.Error("expected deps openStore error") + } + stop() +} + +func TestCommandReadyBlockedSearch(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}, &Todo{ID: "st-b", Title: "B"}) + withStore(t, func(s *Store) { + _ = s.AddDep(Dependency{From: "st-b", To: "st-a", Type: DepBlocks}) + }) + if err := runCmd(readyCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("ready text: %v", err) + } + if err := runCmd(readyCmd, []string{}, nil, "json"); err != nil { + t.Fatalf("ready json: %v", err) + } + if err := runCmd(blockedCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("blocked text: %v", err) + } + if err := runCmd(blockedCmd, []string{}, nil, "json"); err != nil { + t.Fatalf("blocked json: %v", err) + } + if err := runCmd(searchCmd, []string{"A"}, nil, "text"); err != nil { + t.Fatalf("search: %v", err) + } + if err := runCmd(searchCmd, []string{"notfound"}, nil, "text"); err != nil { + t.Fatalf("search empty: %v", err) + } + if err := runCmd(searchCmd, []string{""}, nil, "text"); err == nil { + t.Error("expected empty search query error") + } + stop := setOpenStoreError(t) + if err := runCmd(readyCmd, []string{}, nil, "text"); err == nil { + t.Error("expected ready openStore error") + } + stop() +} + +func TestCommandGraph(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, + &Todo{ID: "st-a", Title: strings.Repeat("A long title to test truncate", 50), Status: StatusOpen}, + &Todo{ID: "st-b", Title: "B", Status: StatusDone}, + &Todo{ID: "st-c", Title: "C", Status: StatusInProgress}, + &Todo{ID: "st-d", Title: "D", Status: StatusCancelled}, + &Todo{ID: "st-e", Title: "E", Status: StatusBlocked}, + ) + withStore(t, func(s *Store) { + _ = s.AddDep(Dependency{From: "st-b", To: "st-a", Type: DepBlocks}) + }) + if err := runCmd(graphCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("graph: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(graphCmd, []string{}, nil, "text"); err == nil { + t.Error("expected graph openStore error") + } + stop() +} + +func TestCommandStats(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, + &Todo{ID: "st-a", Title: "A", Priority: PriorityP0, Type: TypeBug, Assignee: "alice"}, + &Todo{ID: "st-b", Title: "B", Priority: PriorityP1, Type: TypeFeature, Assignee: "bob"}, + ) + if err := runCmd(statsCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("stats text: %v", err) + } + if err := runCmd(statsCmd, []string{}, nil, "json"); err != nil { + t.Fatalf("stats json: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(statsCmd, []string{}, nil, "text"); err == nil { + t.Error("expected stats openStore error") + } + stop() +} + +func TestCommandTimeline(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + withStore(t, func(s *Store) { + _ = s.AppendAudit(AuditEntry{TodoID: "st-a", Action: "test"}) + }) + if err := runCmd(timelineCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("timeline all text: %v", err) + } + if err := runCmd(timelineCmd, []string{}, nil, "json"); err != nil { + t.Fatalf("timeline all json: %v", err) + } + if err := runCmd(timelineCmd, []string{"st-a"}, nil, "text"); err != nil { + t.Fatalf("timeline id: %v", err) + } + cleanup2 := setupCmdTest(t) + defer cleanup2() + if err := runCmd(timelineCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("timeline empty: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(timelineCmd, []string{}, nil, "text"); err == nil { + t.Error("expected timeline openStore error") + } + stop() +} + +func TestCommandMine(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Assignee: "tester"}, &Todo{ID: "st-b", Title: "B", Assignee: "other"}) + if err := runCmd(mineCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("mine text: %v", err) + } + if err := runCmd(mineCmd, []string{}, nil, "json"); err != nil { + t.Fatalf("mine json: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(mineCmd, []string{}, nil, "text"); err == nil { + t.Error("expected mine openStore error") + } + stop() +} + +func TestCommandProject(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + if err := runCmd(projectCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("project show: %v", err) + } + if err := runCmd(projectCmd, []string{"newproj"}, nil, "text"); err != nil { + t.Fatalf("project switch: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(projectCmd, []string{}, nil, "text"); err == nil { + t.Error("expected project openStore error") + } + stop() +} + +func TestCommandRemember(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + if err := runCmd(rememberCmd, []string{"insight"}, nil, "text"); err != nil { + t.Fatalf("remember: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(rememberCmd, []string{"insight"}, nil, "text"); err == nil { + t.Error("expected remember openStore error") + } + stop() +} + +func TestCommandPrime(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Priority: PriorityP0}, &Todo{ID: "st-b", Title: "B"}, &Todo{ID: "st-c", Title: "C", Assignee: "tester"}) + withStore(t, func(s *Store) { + _ = s.AddDep(Dependency{From: "st-b", To: "st-a", Type: DepBlocks}) + }) + if err := runCmd(primeCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("prime: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(primeCmd, []string{}, nil, "text"); err == nil { + t.Error("expected prime openStore error") + } + stop() +} + +func makeOldDone(t *testing.T, s *Store, id string) { + t.Helper() + td, err := s.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + old := time.Now().Add(-100 * time.Hour) + td.UpdatedAt = old + td.ClosedAt = &old + if err := s.Update(td); err != nil { + t.Fatalf("Update: %v", err) + } +} + +func TestCommandCompact(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Status: StatusDone}) + withStore(t, func(s *Store) { makeOldDone(t, s, "st-a") }) + if err := runCmd(compactCmd, []string{}, map[string]string{"older-than": "24h"}, "text"); err != nil { + t.Fatalf("compact: %v", err) + } + seedStore(t, &Todo{ID: "st-b", Title: "B", Status: StatusDone}) + withStore(t, func(s *Store) { makeOldDone(t, s, "st-b") }) + if err := runCmd(compactCmd, []string{}, map[string]string{"older-than": "24h", "dry-run": "true"}, "text"); err != nil { + t.Fatalf("compact dry-run: %v", err) + } + if err := runCmd(compactCmd, []string{}, map[string]string{"older-than": "30d"}, "text"); err == nil { + t.Error("expected invalid duration error") + } + seedStore(t, &Todo{ID: "st-c", Title: "C", Status: StatusDone}) + if err := runCmd(compactCmd, []string{}, map[string]string{"older-than": "0"}, "text"); err != nil { + t.Fatalf("compact older-than 0: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(compactCmd, []string{}, nil, "text"); err == nil { + t.Error("expected compact openStore error") + } + stop() +} + +func TestCommandInit(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + if err := runCmd(initCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("init: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(initCmd, []string{}, nil, "text"); err == nil { + t.Error("expected init openStore error") + } + stop() +} + +func TestCommandDoctor(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + if err := runCmd(doctorCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("doctor text: %v", err) + } + if err := runCmd(doctorCmd, []string{}, nil, "json"); err != nil { + t.Fatalf("doctor json: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(doctorCmd, []string{}, nil, "text"); err == nil { + t.Error("expected doctor openStore error") + } + stop() +} + +func TestCommandExport(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Priority: PriorityP0, Type: TypeBug, Status: StatusDone, Assignee: "alice", Tags: []string{"x"}, Description: "desc"}) + dir := t.TempDir() + out := filepath.Join(dir, "out.json") + if err := runCmd(exportCmd, []string{}, nil, "json"); err != nil { + t.Fatalf("export json stdout: %v", err) + } + if err := runCmd(exportCmd, []string{}, nil, "jsonl"); err != nil { + t.Fatalf("export jsonl: %v", err) + } + if err := runCmd(exportCmd, []string{}, nil, "markdown"); err != nil { + t.Fatalf("export markdown: %v", err) + } + if err := runCmd(exportCmd, []string{}, map[string]string{"output": out}, "json"); err != nil { + t.Fatalf("export file: %v", err) + } + if err := runCmd(exportCmd, []string{}, nil, "bad"); err == nil { + t.Error("expected unknown format error") + } + stop := setOpenStoreError(t) + if err := runCmd(exportCmd, []string{}, nil, "json"); err == nil { + t.Error("expected export openStore error") + } + stop() + orig := osWriteFileTodo + osWriteFileTodo = func(name string, data []byte, perm os.FileMode) error { return errors.New("write error") } + defer func() { osWriteFileTodo = orig }() + if err := runCmd(exportCmd, []string{}, map[string]string{"output": out}, "json"); err == nil { + t.Error("expected write file error") + } +} + +func TestCommandImport(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + dir := t.TempDir() + jsonPath := filepath.Join(dir, "t.json") + jsonlPath := filepath.Join(dir, "t.jsonl") + badPath := filepath.Join(dir, "bad.json") + _ = os.WriteFile(jsonPath, []byte(`[{"title":"I","priority":"P2","type":"task"}]`), 0644) + _ = os.WriteFile(jsonlPath, []byte("{\"title\":\"J\",\"priority\":\"P1\",\"type\":\"bug\"}\n"), 0644) + _ = os.WriteFile(badPath, []byte("not json"), 0644) + if err := runCmd(importCmd, []string{jsonPath}, nil, "json"); err != nil { + t.Fatalf("import json: %v", err) + } + if err := runCmd(importCmd, []string{jsonlPath}, nil, "jsonl"); err != nil { + t.Fatalf("import jsonl: %v", err) + } + if err := runCmd(importCmd, []string{badPath}, nil, "json"); err == nil { + t.Error("expected unmarshal error") + } + if err := runCmd(importCmd, []string{"nonexistent.json"}, nil, "json"); err == nil { + t.Error("expected read error") + } + if err := runCmd(importCmd, []string{jsonPath}, nil, "bad"); err == nil { + t.Error("expected unknown format error") + } + if err := runCmd(importCmd, []string{jsonPath}, nil, "json"); err != nil { + t.Fatalf("import json output: %v", err) + } + stop := setOpenStoreError(t) + if err := runCmd(importCmd, []string{jsonPath}, nil, "json"); err == nil { + t.Error("expected import openStore error") + } + stop() + orig := jsonMarshalStore + jsonMarshalStore = func(v interface{}) ([]byte, error) { return nil, errors.New("marshal error") } + defer func() { jsonMarshalStore = orig }() + if err := runCmd(importCmd, []string{jsonPath}, nil, "json"); err == nil { + t.Error("expected import add error") + } +} + +type errWriter struct{} + +func (errWriter) Write([]byte) (int, error) { return 0, errors.New("write error") } + +func TestPrintJSONDirect(t *testing.T) { + orig := osStdoutTodo + osStdoutTodo = &bytes.Buffer{} + defer func() { osStdoutTodo = orig }() + if err := printJSON(map[string]string{"k": "v"}); err != nil { + t.Fatalf("printJSON: %v", err) + } + osStdoutTodo = errWriter{} + if err := printJSON(map[string]string{"k": "v"}); err == nil { + t.Error("expected printJSON error") + } +} + +func TestCommandJSONOutputError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + orig := printJSONFn + printJSONFn = func(v interface{}) error { return errors.New("printJSON error") } + defer func() { printJSONFn = orig }() + if err := runCmd(listCmd, []string{}, map[string]string{"all": "true"}, "json"); err == nil { + t.Error("expected list json error") + } +} + +func setJSONMarshalStoreError(t *testing.T) func() { + t.Helper() + orig := jsonMarshalStore + jsonMarshalStore = func(v interface{}) ([]byte, error) { return nil, errors.New("marshal error") } + return func() { jsonMarshalStore = orig } +} + +func setJSONUnmarshalStoreError(t *testing.T) func() { + t.Helper() + orig := jsonUnmarshalStore + jsonUnmarshalStore = func(data []byte, v interface{}) error { return errors.New("unmarshal error") } + return func() { jsonUnmarshalStore = orig } +} + +func setJSONMarshalAuditError(t *testing.T) func() { + t.Helper() + orig := jsonMarshalAudit + jsonMarshalAudit = func(v interface{}) ([]byte, error) { return nil, errors.New("marshal error") } + return func() { jsonMarshalAudit = orig } +} + +func setJSONMarshalMemoryError(t *testing.T) func() { + t.Helper() + orig := jsonMarshalMemory + jsonMarshalMemory = func(v interface{}) ([]byte, error) { return nil, errors.New("marshal error") } + return func() { jsonMarshalMemory = orig } +} + +func injectBadTodo(t *testing.T, s *Store, id string) { + t.Helper() + if err := s.update(func(tx *bolt.Tx) error { + return tx.Bucket([]byte(bucketTodos)).Put([]byte(id), []byte("not json")) + }); err != nil { + t.Fatalf("injectBadTodo: %v", err) + } +} + +func injectBadAudit(t *testing.T, s *Store) { + t.Helper() + if err := s.update(func(tx *bolt.Tx) error { + return tx.Bucket([]byte(bucketAudit)).Put([]byte("x\x00x"), []byte("not json")) + }); err != nil { + t.Fatalf("injectBadAudit: %v", err) + } +} + +func injectBadMemory(t *testing.T, s *Store) { + t.Helper() + if err := s.update(func(tx *bolt.Tx) error { + return tx.Bucket([]byte(bucketMems)).Put([]byte("x\x00x"), []byte("not json")) + }); err != nil { + t.Fatalf("injectBadMemory: %v", err) + } +} + +func injectBadDepKey(t *testing.T, s *Store) { + t.Helper() + if err := s.update(func(tx *bolt.Tx) error { + return tx.Bucket([]byte(bucketDeps)).Put([]byte("a\x00b"), []byte("1")) + }); err != nil { + t.Fatalf("injectBadDepKey: %v", err) + } +} + +func TestStoreBadJSON(t *testing.T) { + s := tempStore(t) + injectBadTodo(t, s, "st-bad") + stop := setJSONUnmarshalStoreError(t) + if _, err := s.Get("st-bad"); err == nil { + t.Error("expected Get unmarshal error") + } + if _, err := s.List(); err == nil { + t.Error("expected List unmarshal error") + } + if err := s.Update(&Todo{ID: "st-bad", Title: "X", Status: StatusOpen}); err == nil { + t.Error("expected Update unmarshal error") + } + if err := s.Delete("st-bad", true); err == nil { + t.Error("expected Delete hard unmarshal error") + } + stop() + + s2 := tempStore(t) + _ = s2.Add(&Todo{ID: "st-x", Title: "X"}) + stop2 := setJSONMarshalStoreError(t) + if err := s2.Delete("st-x", false); err == nil { + t.Error("expected Delete soft marshal error") + } + stop2() + + s3 := tempStore(t) + _ = s3.Add(&Todo{ID: "st-y", Title: "Y"}) + stop3 := setJSONMarshalStoreError(t) + if err := s3.Update(&Todo{ID: "st-y", Title: "Z", Status: StatusOpen}); err == nil { + t.Error("expected Update marshal error") + } + stop3() +} + +func TestAuditBadJSON(t *testing.T) { + s := tempStore(t) + injectBadAudit(t, s) + entries, err := s.ListAudit("") + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Errorf("expected 0 entries, got %d", len(entries)) + } +} + +func TestMemoryBadJSON(t *testing.T) { + s := tempStore(t) + injectBadMemory(t, s) + if _, err := s.ListMemories(); err == nil { + t.Error("expected ListMemories unmarshal error") + } +} + +func TestAppendAuditMarshalError(t *testing.T) { + s := tempStore(t) + stop := setJSONMarshalAuditError(t) + if err := s.AppendAudit(AuditEntry{TodoID: "x", Action: "a"}); err == nil { + t.Error("expected AppendAudit marshal error") + } + stop() +} + +func TestAddMemoryMarshalError(t *testing.T) { + s := tempStore(t) + stop := setJSONMarshalMemoryError(t) + if err := s.AddMemory(&Memory{Insight: "x"}); err == nil { + t.Error("expected AddMemory marshal error") + } + stop() +} + +func TestStoreNilReceivers(t *testing.T) { + var s *Store + if err := s.Close(); err != nil { + t.Errorf("Close nil: %v", err) + } + if err := s.update(func(tx *bolt.Tx) error { return nil }); err == nil { + t.Error("expected update nil error") + } + if err := s.view(func(tx *bolt.Tx) error { return nil }); err == nil { + t.Error("expected view nil error") + } +} + +func TestStoreDB(t *testing.T) { + s := tempStore(t) + if s.DB() == nil { + t.Error("expected non-nil DB") + } +} + +func TestStoreOpenConfigDirError(t *testing.T) { + orig := osUserConfigDirStore + osUserConfigDirStore = func() (string, error) { return "", errors.New("config dir error") } + defer func() { osUserConfigDirStore = orig }() + if _, err := Open(""); err == nil { + t.Error("expected config dir error") + } +} + +func TestStoreOpenBboltError(t *testing.T) { + orig := bboltOpenStore + bboltOpenStore = func(path string, mode os.FileMode, options *bolt.Options) (*bolt.DB, error) { + return nil, errors.New("bbolt error") + } + defer func() { bboltOpenStore = orig }() + if _, err := Open(filepath.Join(t.TempDir(), "todo.db")); err == nil { + t.Error("expected bbolt error") + } +} + +func TestStoreIndexHelpers(t *testing.T) { + s := tempStore(t) + ids, err := s.IndexKeys("nonexistent", "key") + if err != nil { + t.Fatal(err) + } + if len(ids) != 0 { + t.Errorf("expected 0 ids for nonexistent bucket, got %d", len(ids)) + } + if err := s.update(func(tx *bolt.Tx) error { + writeIndex(tx, bucketIdxSt, "", "id") + writeIndex(tx, "nonexistent", "key", "id") + removeIndex(tx, bucketIdxSt, "", "id") + removeIndex(tx, "nonexistent", "key", "id") + return nil + }); err != nil { + t.Fatal(err) + } +} + +func TestCurrentActorAllBranches(t *testing.T) { + old := todoAs + todoAs = "" + defer func() { todoAs = old }() + origGit := gitUserNameFn + gitUserNameFn = func() ([]byte, error) { return []byte("gituser\n"), nil } + if got := currentActor(); got != "gituser" { + t.Errorf("expected gituser, got %q", got) + } + gitUserNameFn = func() ([]byte, error) { return nil, errors.New("no git") } + origCfg := osUserConfigDirTodo + osUserConfigDirTodo = func() (string, error) { return "/home/tester", nil } + if got := currentActor(); got != "tester" { + t.Errorf("expected tester, got %q", got) + } + osUserConfigDirTodo = func() (string, error) { return "", errors.New("no config") } + if got := currentActor(); got != "unknown" { + t.Errorf("expected unknown, got %q", got) + } + gitUserNameFn = origGit + osUserConfigDirTodo = origCfg +} + +func TestCurrentProjectAllBranches(t *testing.T) { + old := todoProject + todoProject = "setproj" + if got := currentProject(); got != "setproj" { + t.Errorf("expected setproj, got %q", got) + } + todoProject = "" + origGetwd := osGetwdTodo + osGetwdTodo = func() (string, error) { return "/foo/bar", nil } + if got := currentProject(); got != "bar" { + t.Errorf("expected bar, got %q", got) + } + osGetwdTodo = func() (string, error) { return "", errors.New("no cwd") } + if got := currentProject(); got != "" { + t.Errorf("expected empty, got %q", got) + } + defer func() { todoProject = old; osGetwdTodo = origGetwd }() +} + +func TestStatusIconAll(t *testing.T) { + cases := map[Status]string{ + StatusOpen: "○", + StatusInProgress: "●", + StatusDone: "✓", + StatusCancelled: "✗", + StatusBlocked: "✗", + } + for st, want := range cases { + if got := statusIcon(st); got != want { + t.Errorf("statusIcon(%q) = %q, want %q", st, got, want) + } + } +} + +func TestPrintTodoTableCompacted(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Compacted: true, Summary: "summary"}) + if err := runCmd(listCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("list compacted: %v", err) + } +} + +func TestNotifyDirect(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + _ = captureStderr(t, func() { + notify(notifications.TypeTodoCreated, "st-1", "title", "msg", "actor") + }) +} + +func TestOpenStoreDirect(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + s, err := openStore() + if err != nil { + t.Fatalf("openStore: %v", err) + } + s.Close() +} + +func TestEncodeBase36Zero(t *testing.T) { + if got := encodeBase36(0, 4); got != "0" { + t.Errorf("expected 0, got %q", got) + } +} + +func TestGenerateIDFallback(t *testing.T) { + resetIDState() + orig := idSha256Sum + idSha256Sum = func(b []byte) [32]byte { return [32]byte{1} } + defer func() { idSha256Sum = orig }() + id := GenerateID() + if !IsValidID(id) { + t.Errorf("invalid id: %q", id) + } +} + +func TestDepJSON(t *testing.T) { + _, err := depJSON(Dependency{From: "a", To: "b", Type: DepBlocks}) + if err != nil { + t.Fatal(err) + } +} + +func TestAuditPrefix(t *testing.T) { + if len(auditPrefix()) != 0 { + t.Errorf("expected empty prefix, got %v", auditPrefix()) + } +} + +func TestFireHooksSuccess(t *testing.T) { + s := tempStore(t) + hookConfigOnce = sync.Once{} + hookConfig = &HookConfig{Hooks: map[HookEvent][]Hook{EventPostAdd: {{Command: "true"}}}} + fireHooks(s, EventPostAdd, &Todo{ID: "st-1", Title: "A"}, "", "", "") +} + +func TestBuildEnvAllFields(t *testing.T) { + env := buildEnv(HookContext{ + Event: EventPostAdd, + Actor: "actor", + Todo: &Todo{ID: "st-1", Title: "T", Status: StatusOpen, Priority: PriorityP2, Type: TypeTask, Assignee: "a", Tags: []string{"x"}, Project: "p"}, + From: "open", + To: "done", + Note: "note", + }) + if len(env) == 0 { + t.Error("expected env vars") + } +} + +func TestRunHookTimeoutCoverage(t *testing.T) { + r := runHook(Hook{Command: "sleep 1", Timeout: 1}, HookContext{Event: EventPostAdd}) + if r.Err == nil { + t.Error("expected timeout error") + } +} + +func TestHookValidateCoverage(t *testing.T) { + if err := (Hook{}).Validate(); err == nil { + t.Error("expected empty command error") + } + if err := (Hook{Command: "x", Timeout: -1}).Validate(); err == nil { + t.Error("expected negative timeout error") + } + if err := (Hook{Command: "x", OnError: "bad"}).Validate(); err == nil { + t.Error("expected invalid on_error error") + } +} + +func TestLoadHooksConfigInvalidCoverage(t *testing.T) { + path := filepath.Join(t.TempDir(), "hooks.toml") + _ = os.WriteFile(path, []byte("not valid toml"), 0644) + if _, err := LoadHooksConfig(path); err == nil { + t.Error("expected decode error") + } + path2 := filepath.Join(t.TempDir(), "hooks2.toml") + _ = os.WriteFile(path2, []byte("[hooks]\npost_complete = [{command = \"\"}]\n"), 0644) + if _, err := LoadHooksConfig(path2); err == nil { + t.Error("expected invalid hook error") + } +} + +func TestHookConfigSaveErrors(t *testing.T) { + dir := t.TempDir() + cfg := &HookConfig{Hooks: map[HookEvent][]Hook{}, path: filepath.Join(dir, "sub", "hooks.toml")} + origMkdir := osMkdirAllHooks + osMkdirAllHooks = func(path string, perm os.FileMode) error { return errors.New("mkdir error") } + if err := cfg.Add(EventPostAdd, Hook{Command: "true"}); err == nil { + t.Error("expected mkdir error") + } + osMkdirAllHooks = origMkdir + origCreate := osCreateHooks + osCreateHooks = func(name string) (*os.File, error) { return nil, errors.New("create error") } + defer func() { osCreateHooks = origCreate }() + if err := cfg.Add(EventPostAdd, Hook{Command: "true"}); err == nil { + t.Error("expected create error") + } +} + +func TestHookCmdMore(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + if err := runCmd(hookPathCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("hook path: %v", err) + } + if err := runCmd(hookListCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("hook list empty: %v", err) + } + if err := runCmd(hookAddCmd, []string{"post_complete"}, map[string]string{"command": "echo hi", "timeout": "0", "on-error": ""}, "text"); err != nil { + t.Fatalf("hook add: %v", err) + } + if err := runCmd(hookListCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("hook list defaults: %v", err) + } + if err := runCmd(hookTestCmd, []string{"post_complete"}, nil, "text"); err != nil { + t.Fatalf("hook test existing: %v", err) + } + cleanup2 := setupCmdTest(t) + defer cleanup2() + if err := runCmd(hookTestCmd, []string{"post_complete"}, nil, "text"); err != nil { + t.Fatalf("hook test no hooks: %v", err) + } +} + +func TestHookListLoadError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + dir := t.TempDir() + badCfg := filepath.Join(dir, "hooks.toml") + _ = os.WriteFile(badCfg, []byte("not valid toml"), 0644) + if err := runCmd(hookListCmd, []string{}, map[string]string{"config": badCfg}, "text"); err == nil { + t.Error("expected load error") + } +} + +func TestHookCmdTestOutputAndError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + if err := runCmd(hookAddCmd, []string{"post_complete"}, map[string]string{"command": "echo out && echo err >&2"}, "text"); err != nil { + t.Fatalf("hook add: %v", err) + } + if err := runCmd(hookTestCmd, []string{"post_complete"}, nil, "text"); err != nil { + t.Fatalf("hook test output: %v", err) + } + cleanup2 := setupCmdTest(t) + defer cleanup2() + if err := runCmd(hookAddCmd, []string{"post_complete"}, map[string]string{"command": "exit 7"}, "text"); err != nil { + t.Fatalf("hook add error: %v", err) + } + if err := runCmd(hookTestCmd, []string{"post_complete"}, nil, "text"); err != nil { + t.Fatalf("hook test error: %v", err) + } +} + +func TestHookCmdAddErrors(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + dir := t.TempDir() + badCfg := filepath.Join(dir, "hooks.toml") + _ = os.WriteFile(badCfg, []byte("not valid toml"), 0644) + if err := runCmd(hookAddCmd, []string{"post_complete"}, map[string]string{"command": "echo", "config": badCfg}, "text"); err == nil { + t.Error("expected load error") + } + hookConfigPath = "" + if err := runCmd(hookAddCmd, []string{"post_complete"}, map[string]string{"command": ""}, "text"); err == nil { + t.Error("expected add validation error") + } +} + +func TestHookCmdRemoveErrors(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + dir := t.TempDir() + badCfg := filepath.Join(dir, "hooks.toml") + _ = os.WriteFile(badCfg, []byte("not valid toml"), 0644) + if err := runCmd(hookRemoveCmd, []string{"post_complete"}, map[string]string{"index": "0", "config": badCfg}, "text"); err == nil { + t.Error("expected load error") + } + hookConfigPath = "" + if err := runCmd(hookRemoveCmd, []string{"post_complete"}, map[string]string{"index": "0"}, "text"); err == nil { + t.Error("expected remove not found") + } +} + +func TestHookCmdTestLoadError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + dir := t.TempDir() + badCfg := filepath.Join(dir, "hooks.toml") + _ = os.WriteFile(badCfg, []byte("not valid toml"), 0644) + if err := runCmd(hookTestCmd, []string{"post_complete"}, map[string]string{"config": badCfg}, "text"); err == nil { + t.Error("expected load error") + } +} + +// ── remaining statement coverage: store, query, deps, compact, id ───────── + +func TestStoreUpdateNotFound(t *testing.T) { + s := tempStore(t) + if err := s.Update(&Todo{ID: "st-notfound", Title: "A", Status: StatusOpen}); err == nil { + t.Error("expected update not found") + } +} + +func TestStoreDeleteSoftUnmarshalError(t *testing.T) { + s := tempStore(t) + injectBadTodo(t, s, "st-bad") + if err := s.Delete("st-bad", false); err == nil { + t.Error("expected soft delete unmarshal error") + } +} + +func TestListFilteredError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + orig := jsonUnmarshalStore + jsonUnmarshalStore = func([]byte, interface{}) error { return errors.New("boom") } + defer func() { jsonUnmarshalStore = orig }() + if _, err := s.ListFiltered(ListFilter{}); err == nil { + t.Error("expected list filtered error") + } +} + +func TestListFilteredStatusAndType(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A", Status: StatusOpen, Type: TypeBug}) + _ = s.Add(&Todo{Title: "B", Status: StatusOpen, Type: TypeTask}) + out, err := s.ListFiltered(ListFilter{Status: StatusOpen, Type: TypeBug}) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || out[0].Title != "A" { + t.Errorf("expected one bug, got %v", out) + } +} + +func TestReadyListError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + orig := jsonUnmarshalStore + jsonUnmarshalStore = func([]byte, interface{}) error { return errors.New("boom") } + defer func() { jsonUnmarshalStore = orig }() + if _, err := s.Ready(); err == nil { + t.Error("expected ready error") + } +} + +func TestBlockedListError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + orig := jsonUnmarshalStore + jsonUnmarshalStore = func([]byte, interface{}) error { return errors.New("boom") } + defer func() { jsonUnmarshalStore = orig }() + if _, err := s.Blocked(); err == nil { + t.Error("expected blocked error") + } +} + +func TestBlockedNonOpen(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A", Status: StatusDone}) + out, err := s.Blocked() + if err != nil { + t.Fatal(err) + } + if len(out) != 0 { + t.Errorf("expected 0 blocked, got %d", len(out)) + } +} + +func TestBlockedBlockingDepsOfError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A", Status: StatusOpen}) + orig := getDepsFn + getDepsFn = func(*Store, string) ([]Dependency, error) { return nil, errors.New("boom") } + defer func() { getDepsFn = orig }() + if _, err := s.Blocked(); err == nil { + t.Error("expected blocked error") + } +} + +func TestComputeStatsListError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + orig := jsonUnmarshalStore + jsonUnmarshalStore = func([]byte, interface{}) error { return errors.New("boom") } + defer func() { jsonUnmarshalStore = orig }() + if _, err := s.ComputeStats(); err == nil { + t.Error("expected compute stats error") + } +} + +func TestComputeStatsBlockedError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A", Status: StatusOpen}) + orig := getDepsFn + calls := 0 + getDepsFn = func(st *Store, id string) ([]Dependency, error) { + calls++ + if calls == 2 { + return nil, errors.New("boom") + } + return orig(st, id) + } + defer func() { getDepsFn = orig }() + if _, err := s.ComputeStats(); err == nil { + t.Error("expected compute stats blocked error") + } +} + +func TestCompactListError(t *testing.T) { + s := tempStore(t) + orig := listAllFn + listAllFn = func(*Store) ([]*Todo, error) { return nil, errors.New("boom") } + defer func() { listAllFn = orig }() + if _, err := s.Compact(CompactOptions{}); err == nil { + t.Error("expected compact list error") + } +} + +func TestCompactUpdateError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A", Status: StatusDone}) + makeOldDone(t, s, s.listIDs()[0]) + orig := updateFn + updateFn = func(*Store, *Todo) error { return errors.New("boom") } + defer func() { updateFn = orig }() + if _, err := s.Compact(CompactOptions{OlderThan: 1}); err == nil { + t.Error("expected compact update error") + } +} + +func (s *Store) listIDs() []string { + ts, _ := s.List() + ids := make([]string, len(ts)) + for i, t := range ts { + ids[i] = t.ID + } + return ids +} + +func TestAddDepWouldCreateCycleError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + _ = s.Add(&Todo{Title: "B"}) + ids := s.listIDs() + orig := getDepsFn + getDepsFn = func(*Store, string) ([]Dependency, error) { return nil, errors.New("boom") } + defer func() { getDepsFn = orig }() + if err := s.AddDep(Dependency{From: ids[0], To: ids[1], Type: DepBlocks}); err == nil { + t.Error("expected wouldCreateCycle error") + } +} + +func TestRemoveDepNotFound(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + _ = s.Add(&Todo{Title: "B"}) + ids := s.listIDs() + orig := allDepTypes + allDepTypes = []DepType{} + defer func() { allDepTypes = orig }() + if err := s.RemoveDep(ids[0], ids[1]); err == nil { + t.Error("expected remove dep not found") + } +} + +func TestGetReverseDepsBadKey(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + ids := s.listIDs() + injectBadDepKey(t, s) + deps, err := s.GetReverseDeps(ids[0]) + if err != nil { + t.Fatal(err) + } + if len(deps) != 0 { + t.Errorf("expected 0 reverse deps, got %d", len(deps)) + } +} + +func TestWouldCreateCycleError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + _ = s.Add(&Todo{Title: "B"}) + _ = s.AddDep(Dependency{From: "a", To: "b", Type: DepRelated}) + orig := getDepsFn + getDepsFn = func(*Store, string) ([]Dependency, error) { return nil, errors.New("boom") } + defer func() { getDepsFn = orig }() + if _, err := s.wouldCreateCycle("c", "a"); err == nil { + t.Error("expected wouldCreateCycle error") + } +} + +func TestWouldCreateCycleRecursiveError(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + _ = s.Add(&Todo{Title: "B"}) + _ = s.Add(&Todo{Title: "C"}) + ids := s.listIDs() + _ = s.AddDep(Dependency{From: ids[0], To: ids[1], Type: DepBlocks}) + orig := getDepsFn + getDepsFn = func(st *Store, id string) ([]Dependency, error) { + if id == ids[1] { + return nil, errors.New("boom") + } + return orig(st, id) + } + defer func() { getDepsFn = orig }() + if _, err := s.wouldCreateCycle(ids[2], ids[0]); err == nil { + t.Error("expected recursive cycle error") + } +} + +func TestDependencyTreeDepthAndCycle(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A"}) + _ = s.Add(&Todo{Title: "B"}) + _ = s.Add(&Todo{Title: "C"}) + ids := s.listIDs() + _ = s.AddDep(Dependency{From: ids[0], To: ids[1], Type: DepBlocks}) + _ = s.AddDep(Dependency{From: ids[0], To: ids[2], Type: DepBlocks}) + _ = s.AddDep(Dependency{From: ids[2], To: ids[1], Type: DepBlocks}) + out, err := s.DependencyTree(ids[0], 1) + if err != nil { + t.Fatal(err) + } + if len(out) == 0 { + t.Error("expected tree output") + } +} + +func TestEncodeBase36Padding(t *testing.T) { + if got := encodeBase36(1, 4); got != "0001" { + t.Errorf("expected 0001, got %q", got) + } +} + +func TestGenerateIDFallbackExhausted(t *testing.T) { + resetIDState() + orig := idSha256Sum + idSha256Sum = func([]byte) [32]byte { return [32]byte{1} } + defer func() { idSha256Sum = orig }() + id := GenerateID() + seenIDs[id] = struct{}{} + id2 := GenerateID() + if !strings.HasPrefix(id2, idPrefix) || len(id2) == 0 { + t.Errorf("expected fallback id with prefix, got %q", id2) + } +} + +// ── remaining command-handler coverage ──────────────────────────────────── + +func TestCommandAddStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + orig := bucketPutFn + bucketPutFn = func(b *bolt.Bucket, k, v []byte) error { return errors.New("put error") } + defer func() { bucketPutFn = orig }() + if err := runCmd(addCmd, []string{}, map[string]string{"title": "A"}, "text"); err == nil { + t.Error("expected add store error") + } +} + +func TestCommandListMoreBranches(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Status: StatusOpen}) + if err := runCmd(listCmd, []string{}, map[string]string{"all": "true"}, "text"); err != nil { + t.Fatalf("list all text: %v", err) + } + if err := runCmd(listCmd, []string{}, map[string]string{"status": "open"}, "json"); err != nil { + t.Fatalf("list filtered json: %v", err) + } + orig := jsonUnmarshalStore + jsonUnmarshalStore = func([]byte, interface{}) error { return errors.New("unmarshal error") } + defer func() { jsonUnmarshalStore = orig }() + if err := runCmd(listCmd, []string{}, map[string]string{"all": "true"}, "text"); err == nil { + t.Error("expected list all error") + } + if err := runCmd(listCmd, []string{}, map[string]string{"status": "open"}, "text"); err == nil { + t.Error("expected list filtered error") + } +} + +func TestCommandShowFull(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Status: StatusDone}, &Todo{ID: "st-b", Title: "B"}, &Todo{ID: "st-c", Title: "C"}) + withStore(t, func(s *Store) { + makeOldDone(t, s, "st-a") + if _, err := s.Compact(CompactOptions{OlderThan: 1}); err != nil { + t.Fatalf("compact: %v", err) + } + _ = s.AddDep(Dependency{From: "st-a", To: "st-b", Type: DepBlocks}) + _ = s.AddDep(Dependency{From: "st-c", To: "st-a", Type: DepBlocks}) + _ = s.AppendAudit(AuditEntry{TodoID: "st-a", Action: "update", From: "open", To: "done", Note: "n", Actor: "actor"}) + }) + if err := runCmd(showCmd, []string{"st-a"}, nil, "text"); err != nil { + t.Fatalf("show full: %v", err) + } +} + +func TestCommandUpdateStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + orig := bucketPutFn + bucketPutFn = func(b *bolt.Bucket, k, v []byte) error { return errors.New("put error") } + defer func() { bucketPutFn = orig }() + if err := runCmd(updateCmd, []string{"st-a"}, map[string]string{"title": "B"}, "text"); err == nil { + t.Error("expected update store error") + } +} + +func TestCommandClaimStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + orig := bucketPutFn + bucketPutFn = func(b *bolt.Bucket, k, v []byte) error { return errors.New("put error") } + defer func() { bucketPutFn = orig }() + if err := runCmd(claimCmd, []string{"st-a"}, nil, "text"); err == nil { + t.Error("expected claim store error") + } +} + +func TestCommandUnclaimOpenStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + stop := setOpenStoreError(t) + defer stop() + if err := runCmd(unclaimCmd, []string{"st-a"}, nil, "text"); err == nil { + t.Error("expected unclaim openStore error") + } +} + +func TestCommandUnclaimStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Assignee: "tester"}) + orig := bucketPutFn + bucketPutFn = func(b *bolt.Bucket, k, v []byte) error { return errors.New("put error") } + defer func() { bucketPutFn = orig }() + if err := runCmd(unclaimCmd, []string{"st-a"}, nil, "text"); err == nil { + t.Error("expected unclaim store error") + } +} + +func TestCommandCompleteOpenStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + stop := setOpenStoreError(t) + defer stop() + if err := runCmd(completeCmd, []string{"st-a"}, nil, "text"); err == nil { + t.Error("expected complete openStore error") + } +} + +func TestCommandCompleteStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + orig := bucketPutFn + bucketPutFn = func(b *bolt.Bucket, k, v []byte) error { return errors.New("put error") } + defer func() { bucketPutFn = orig }() + if err := runCmd(completeCmd, []string{"st-a"}, nil, "text"); err == nil { + t.Error("expected complete store error") + } +} + +func TestCommandCancelGetError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + if err := runCmd(cancelCmd, []string{"st-missing"}, nil, "text"); err == nil { + t.Error("expected cancel not found") + } +} + +func TestCommandCancelStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + orig := bucketPutFn + bucketPutFn = func(b *bolt.Bucket, k, v []byte) error { return errors.New("put error") } + defer func() { bucketPutFn = orig }() + if err := runCmd(cancelCmd, []string{"st-a"}, nil, "text"); err == nil { + t.Error("expected cancel store error") + } +} + +func TestCommandDepRemoveStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}, &Todo{ID: "st-b", Title: "B"}) + withStore(t, func(s *Store) { _ = s.AddDep(Dependency{From: "st-a", To: "st-b", Type: DepBlocks}) }) + orig := bucketDeleteFn + bucketDeleteFn = func(b *bolt.Bucket, k []byte) error { return errors.New("delete error") } + defer func() { bucketDeleteFn = orig }() + if err := runCmd(depRemoveCmd, []string{"st-a", "st-b"}, nil, "text"); err == nil { + t.Error("expected dep remove store error") + } +} + +func TestCommandDepsTreeError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}, &Todo{ID: "st-b", Title: "B"}) + withStore(t, func(s *Store) { _ = s.AddDep(Dependency{From: "st-a", To: "st-b", Type: DepBlocks}) }) + orig := getDepsFn + getDepsFn = func(*Store, string) ([]Dependency, error) { return nil, errors.New("dep error") } + defer func() { getDepsFn = orig }() + if err := runCmd(depsCmd, []string{"st-a"}, nil, "text"); err == nil { + t.Error("expected deps tree error") + } +} + +func TestCommandDepsDepthAndSeen(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}, &Todo{ID: "st-b", Title: "B"}, &Todo{ID: "st-c", Title: "C"}) + withStore(t, func(s *Store) { + _ = s.AddDep(Dependency{From: "st-a", To: "st-b", Type: DepBlocks}) + _ = s.AddDep(Dependency{From: "st-a", To: "st-c", Type: DepBlocks}) + _ = s.AddDep(Dependency{From: "st-c", To: "st-b", Type: DepBlocks}) + }) + if err := runCmd(depsCmd, []string{"st-a"}, map[string]string{"depth": "0"}, "text"); err != nil { + t.Fatalf("deps depth 0: %v", err) + } + if err := runCmd(depsCmd, []string{"st-a"}, nil, "text"); err != nil { + t.Fatalf("deps seen: %v", err) + } +} + +func TestCommandGraphEdgesAndStyle(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}, &Todo{ID: "st-b", Title: "B"}) + withStore(t, func(s *Store) { _ = s.AddDep(Dependency{From: "st-a", To: "st-b", Type: DepRelated}) }) + if err := runCmd(graphCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("graph: %v", err) + } +} + +func TestCommandGraphDuplicateEdge(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}, &Todo{ID: "st-b", Title: "B"}) + withStore(t, func(s *Store) { + _ = s.AddDep(Dependency{From: "st-a", To: "st-b", Type: DepRelated}) + _ = s.AddDep(Dependency{From: "st-a", To: "st-b", Type: DepBlocks}) + }) + if err := runCmd(graphCmd, []string{}, nil, "text"); err != nil { + t.Fatalf("graph duplicate: %v", err) + } +} + +func TestCommandGraphListError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + orig := jsonUnmarshalStore + jsonUnmarshalStore = func([]byte, interface{}) error { return errors.New("unmarshal error") } + defer func() { jsonUnmarshalStore = orig }() + if err := runCmd(graphCmd, []string{}, nil, "text"); err == nil { + t.Error("expected graph list error") + } +} + +func TestCommandImportJSONOutput(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + dir := t.TempDir() + jsonPath := filepath.Join(dir, "t.json") + _ = os.WriteFile(jsonPath, []byte(`[{"title":"I","priority":"P2","type":"task"}]`), 0644) + oldFormat := todoFormat + todoFormat = "json" + defer func() { todoFormat = oldFormat }() + if err := runCmd(importCmd, []string{jsonPath}, nil, "json"); err != nil { + t.Fatalf("import json output: %v", err) + } +} + +func TestCommandReadyStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + orig := jsonUnmarshalStore + jsonUnmarshalStore = func([]byte, interface{}) error { return errors.New("unmarshal error") } + defer func() { jsonUnmarshalStore = orig }() + if err := runCmd(readyCmd, []string{}, nil, "text"); err == nil { + t.Error("expected ready store error") + } +} + +func TestCommandBlockedOpenStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + stop := setOpenStoreError(t) + defer stop() + if err := runCmd(blockedCmd, []string{}, nil, "text"); err == nil { + t.Error("expected blocked openStore error") + } +} + +func TestCommandBlockedStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + orig := jsonUnmarshalStore + jsonUnmarshalStore = func([]byte, interface{}) error { return errors.New("unmarshal error") } + defer func() { jsonUnmarshalStore = orig }() + if err := runCmd(blockedCmd, []string{}, nil, "text"); err == nil { + t.Error("expected blocked store error") + } +} + +func TestCommandSearchJSON(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + if err := runCmd(searchCmd, []string{"A"}, nil, "json"); err != nil { + t.Fatalf("search json: %v", err) + } +} + +func TestCommandSearchOpenStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + stop := setOpenStoreError(t) + defer stop() + if err := runCmd(searchCmd, []string{"A"}, nil, "text"); err == nil { + t.Error("expected search openStore error") + } +} + +func TestCommandStatsStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Status: StatusOpen}) + orig := getDepsFn + getDepsFn = func(*Store, string) ([]Dependency, error) { return nil, errors.New("dep error") } + defer func() { getDepsFn = orig }() + if err := runCmd(statsCmd, []string{}, nil, "text"); err == nil { + t.Error("expected stats store error") + } +} + +func TestCommandTimelineAuditDetails(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + withStore(t, func(s *Store) { + _ = s.AppendAudit(AuditEntry{TodoID: "st-a", Action: "update", From: "open", To: "done", Note: "note", Actor: "actor"}) + }) + if err := runCmd(timelineCmd, []string{"st-a"}, nil, "text"); err != nil { + t.Fatalf("timeline details: %v", err) + } +} + +func TestCommandTimelineListAuditError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + withStore(t, func(s *Store) { + _ = s.AppendAudit(AuditEntry{TodoID: "st-a", Action: "test"}) + }) + dir := t.TempDir() + closed, _ := Open(filepath.Join(dir, "closed.db")) + closed.Close() + orig := openStoreFn + openStoreFn = func() (*Store, error) { return closed, nil } + defer func() { openStoreFn = orig }() + if err := runCmd(timelineCmd, []string{"st-a"}, nil, "text"); err == nil { + t.Error("expected timeline list audit error") + } +} + +func TestCommandMineStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Assignee: "tester"}) + orig := jsonUnmarshalStore + jsonUnmarshalStore = func([]byte, interface{}) error { return errors.New("unmarshal error") } + defer func() { jsonUnmarshalStore = orig }() + if err := runCmd(mineCmd, []string{}, nil, "text"); err == nil { + t.Error("expected mine store error") + } +} + +func TestCommandProjectSwitchStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + dir := t.TempDir() + closed, _ := Open(filepath.Join(dir, "closed.db")) + closed.Close() + orig := openStoreFn + openStoreFn = func() (*Store, error) { return closed, nil } + defer func() { openStoreFn = orig }() + if err := runCmd(projectCmd, []string{"newproj"}, nil, "text"); err == nil { + t.Error("expected project switch store error") + } +} + +func TestCommandRememberStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + orig := jsonMarshalMemory + jsonMarshalMemory = func(interface{}) ([]byte, error) { return nil, errors.New("marshal error") } + defer func() { jsonMarshalMemory = orig }() + if err := runCmd(rememberCmd, []string{"insight"}, nil, "text"); err == nil { + t.Error("expected remember store error") + } +} + +func TestCommandCompactStoreError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Status: StatusDone}) + withStore(t, func(s *Store) { makeOldDone(t, s, "st-a") }) + orig := jsonMarshalStore + jsonMarshalStore = func(interface{}) ([]byte, error) { return nil, errors.New("marshal error") } + defer func() { jsonMarshalStore = orig }() + if err := runCmd(compactCmd, []string{}, map[string]string{"older-than": "1ns"}, "text"); err == nil { + t.Error("expected compact store error") + } +} + +func TestCommandCompactJSON(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Status: StatusDone}) + withStore(t, func(s *Store) { makeOldDone(t, s, "st-a") }) + if err := runCmd(compactCmd, []string{}, map[string]string{"older-than": "1ns"}, "json"); err != nil { + t.Fatalf("compact json: %v", err) + } +} + +func TestCommandDoctorListError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + orig := jsonUnmarshalStore + jsonUnmarshalStore = func([]byte, interface{}) error { return errors.New("unmarshal error") } + defer func() { jsonUnmarshalStore = orig }() + if err := runCmd(doctorCmd, []string{}, nil, "text"); err == nil { + t.Error("expected doctor list error") + } +} + +func TestCommandDoctorStatsError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A", Status: StatusOpen}) + orig := getDepsFn + getDepsFn = func(*Store, string) ([]Dependency, error) { return nil, errors.New("dep error") } + defer func() { getDepsFn = orig }() + if err := runCmd(doctorCmd, []string{}, nil, "text"); err == nil { + t.Error("expected doctor stats error") + } +} + +func TestCommandExportListError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + seedStore(t, &Todo{ID: "st-a", Title: "A"}) + orig := jsonUnmarshalStore + jsonUnmarshalStore = func([]byte, interface{}) error { return errors.New("unmarshal error") } + defer func() { jsonUnmarshalStore = orig }() + if err := runCmd(exportCmd, []string{}, nil, "json"); err == nil { + t.Error("expected export list error") + } +} + +func TestCommandImportJSONLLineError(t *testing.T) { + cleanup := setupCmdTest(t) + defer cleanup() + dir := t.TempDir() + path := filepath.Join(dir, "bad.jsonl") + _ = os.WriteFile(path, []byte(`{"title":"X"} +not json`), 0644) + if err := runCmd(importCmd, []string{path}, nil, "jsonl"); err == nil { + t.Error("expected jsonl line error") + } +} + +func TestListFilteredStatusFilter(t *testing.T) { + s := tempStore(t) + _ = s.Add(&Todo{Title: "A", Status: StatusOpen}) + _ = s.Add(&Todo{Title: "B", Status: StatusDone}) + out, err := s.ListFiltered(ListFilter{Status: StatusOpen}) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || out[0].Title != "A" { + t.Errorf("expected open A, got %v", out) + } +} diff --git a/cmd/sin-code/internal/todo/deps.go b/cmd/sin-code/internal/todo/deps.go index a697e488..a5df2946 100644 --- a/cmd/sin-code/internal/todo/deps.go +++ b/cmd/sin-code/internal/todo/deps.go @@ -8,6 +8,13 @@ import ( bolt "go.etcd.io/bbolt" ) +var ( + allDepTypes = []DepType{DepBlocks, DepParentChild, DepRelated, DepDiscoveredFrom, DepDuplicates, DepSupersedes} + bucketDeleteFn = func(b *bolt.Bucket, k []byte) error { return b.Delete(k) } + bucketGetFn = func(b *bolt.Bucket, k []byte) []byte { return b.Get(k) } + getDepsFn = (*Store).GetDeps +) + func depKey(from, to string, dtype DepType) []byte { return []byte(from + "\x00" + to + "\x00" + string(dtype)) } @@ -35,14 +42,14 @@ func (s *Store) AddDep(dep Dependency) error { } return s.update(func(tx *bolt.Tx) error { bT := tx.Bucket([]byte(bucketTodos)) - if bT.Get(todoKey(dep.From)) == nil { + if bucketGetFn(bT, todoKey(dep.From)) == nil { return fmt.Errorf("from todo not found: %s", dep.From) } - if bT.Get(todoKey(dep.To)) == nil { + if bucketGetFn(bT, todoKey(dep.To)) == nil { return fmt.Errorf("to todo not found: %s", dep.To) } bD := tx.Bucket([]byte(bucketDeps)) - return bD.Put(depKey(dep.From, dep.To, dep.Type), []byte("1")) + return bucketPutFn(bD, depKey(dep.From, dep.To, dep.Type), []byte("1")) }) } @@ -50,8 +57,8 @@ func (s *Store) RemoveDep(from, to string) error { return s.update(func(tx *bolt.Tx) error { bD := tx.Bucket([]byte(bucketDeps)) found := false - for _, dt := range []DepType{DepBlocks, DepParentChild, DepRelated, DepDiscoveredFrom, DepDuplicates, DepSupersedes} { - if err := bD.Delete(depKey(from, to, dt)); err != nil { + for _, dt := range allDepTypes { + if err := bucketDeleteFn(bD, depKey(from, to, dt)); err != nil { return err } found = true @@ -103,7 +110,7 @@ func (s *Store) GetReverseDeps(id string) ([]Dependency, error) { } func (s *Store) BlockingDepsOf(id string) ([]Dependency, error) { - all, err := s.GetDeps(id) + all, err := getDepsFn(s, id) if err != nil { return nil, err } @@ -127,7 +134,7 @@ func (s *Store) wouldCreateCycle(from, to string) (bool, error) { return false, nil } visited[node] = true - deps, err := s.GetDeps(node) + deps, err := getDepsFn(s, node) if err != nil { return false, err } @@ -156,7 +163,7 @@ func (s *Store) DependencyTree(root string, maxDepth int) (map[string][]Dependen return nil } visited[id] = true - deps, err := s.GetDeps(id) + deps, err := getDepsFn(s, id) if err != nil { return err } diff --git a/cmd/sin-code/internal/todo/hooks.go b/cmd/sin-code/internal/todo/hooks.go index e82ecf06..391e0734 100644 --- a/cmd/sin-code/internal/todo/hooks.go +++ b/cmd/sin-code/internal/todo/hooks.go @@ -17,6 +17,16 @@ import ( "github.com/BurntSushi/toml" ) +var ( + osUserConfigDirHooks = os.UserConfigDir + osStatHooks = os.Stat + tomlDecodeFileHooks = toml.DecodeFile + osMkdirAllHooks = os.MkdirAll + osCreateHooks = os.Create + execCommandContextHooks = exec.CommandContext + envEnvironHooks = os.Environ +) + type HookEvent string const ( @@ -83,7 +93,7 @@ type HookConfig struct { } func DefaultHooksPath() string { - cfg, err := os.UserConfigDir() + cfg, err := osUserConfigDirHooks() if err != nil { return "" } @@ -101,10 +111,10 @@ func LoadHooksConfig(path string) (*HookConfig, error) { if path == "" { return cfg, nil } - if _, err := os.Stat(path); os.IsNotExist(err) { + if _, err := osStatHooks(path); os.IsNotExist(err) { return cfg, nil } - if _, err := toml.DecodeFile(path, cfg); err != nil { + if _, err := tomlDecodeFileHooks(path, cfg); err != nil { return nil, fmt.Errorf("decode hooks.toml: %w", err) } if cfg.Hooks == nil { @@ -160,10 +170,10 @@ func (c *HookConfig) save() error { if c.path == "" { return nil } - if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { + if err := osMkdirAllHooks(filepath.Dir(c.path), 0o755); err != nil { return err } - f, err := os.Create(c.path) + f, err := osCreateHooks(c.path) if err != nil { return err } @@ -212,7 +222,7 @@ func runHook(h Hook, ctx HookContext) HookResult { execCtx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(execCtx, "sh", "-c", h.Command) + cmd := execCommandContextHooks(execCtx, "sh", "-c", h.Command) cmd.Env = buildEnv(ctx) var stdout, stderr bytes.Buffer @@ -235,7 +245,7 @@ func runHook(h Hook, ctx HookContext) HookResult { } func buildEnv(ctx HookContext) []string { - env := os.Environ() + env := envEnvironHooks() set := func(k, v string) { env = append(env, k+"="+v) } diff --git a/cmd/sin-code/internal/todo/id.go b/cmd/sin-code/internal/todo/id.go index 2359185f..c5adc93a 100644 --- a/cmd/sin-code/internal/todo/id.go +++ b/cmd/sin-code/internal/todo/id.go @@ -1,16 +1,18 @@ package todo import ( - "crypto/sha1" + "crypto/sha256" "fmt" "sync" "time" ) var ( - idMu sync.Mutex - seenIDs = make(map[string]struct{}) - idSalt uint64 + idMu sync.Mutex + seenIDs = make(map[string]struct{}) + idSalt uint64 + idNowUnixNano = func() int64 { return time.Now().UnixNano() } + idSha256Sum = sha256.Sum256 ) const idPrefix = "st-" @@ -18,7 +20,8 @@ const idBodyLen = 4 const idAlphabet = "0123456789abcdefghijklmnopqrstuvwxyz" func init() { - idSalt = uint64(time.Now().UnixNano()) + // UnixNano is non-negative until the year 2262 and fits safely into uint64. + idSalt = uint64(time.Now().UnixNano()) // #nosec G115 } func encodeBase36(n uint64, w int) string { @@ -40,7 +43,7 @@ func GenerateID() string { idMu.Lock() defer idMu.Unlock() for i := 0; i < 32; i++ { - h := sha1.Sum([]byte(fmt.Sprintf("%d-%d-%d", time.Now().UnixNano(), idSalt, i))) + h := idSha256Sum([]byte(fmt.Sprintf("%d-%d-%d", idNowUnixNano(), idSalt, i))) body := encodeBase36(uint64(h[0])<<24|uint64(h[1])<<16|uint64(h[2])<<8|uint64(h[3]), idBodyLen) id := idPrefix + body if _, exists := seenIDs[id]; !exists { @@ -48,7 +51,8 @@ func GenerateID() string { return id } } - return fmt.Sprintf("%s%s", idPrefix, encodeBase36(uint64(time.Now().UnixNano()), 8)) + // UnixNano is non-negative; overflow is harmless for entropy here. + return fmt.Sprintf("%s%s", idPrefix, encodeBase36(uint64(idNowUnixNano()), 8)) // #nosec G115 } func IsValidID(id string) bool { @@ -62,7 +66,7 @@ func IsValidID(id string) bool { for _, c := range body { found := false for _, a := range idAlphabet { - if byte(a) == byte(c) { + if byte(a) == byte(c) { // #nosec G115 found = true break } diff --git a/cmd/sin-code/internal/todo/plugin_hooks.go b/cmd/sin-code/internal/todo/plugin_hooks.go index b2be64d4..cd8560ec 100644 --- a/cmd/sin-code/internal/todo/plugin_hooks.go +++ b/cmd/sin-code/internal/todo/plugin_hooks.go @@ -14,8 +14,11 @@ import ( ) var ( - pluginRegOnce sync.Once - pluginReg *plugins.Registry + pluginRegOnce sync.Once + pluginReg *plugins.Registry + pluginRegistryFn = pluginRegistry + runPluginHookFn = runPluginHook + execCommandContextPlugin = exec.CommandContext ) func pluginRegistry() *plugins.Registry { @@ -27,7 +30,7 @@ func pluginRegistry() *plugins.Registry { } func firePluginHooks(store *Store, event HookEvent, t *Todo, from, to, note string) { - reg := pluginRegistry() + reg := pluginRegistryFn() if reg == nil { return } @@ -37,7 +40,7 @@ func firePluginHooks(store *Store, event HookEvent, t *Todo, from, to, note stri } ctx := HookContext{Event: event, Todo: t, From: from, To: to, Note: note, Actor: currentActor()} for _, h := range hooks { - stdout, stderr, exitCode, err := runPluginHook(h, ctx) + stdout, stderr, exitCode, err := runPluginHookFn(h, ctx) note := strings.TrimSpace(stdout) if note == "" { note = strings.TrimSpace(stderr) @@ -71,7 +74,7 @@ func runPluginHook(h plugins.HookDef, ctx HookContext) (stdout, stderr string, e execCtx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(execCtx, "sh", "-c", h.Command) + cmd := execCommandContextPlugin(execCtx, "sh", "-c", h.Command) cmd.Env = buildEnv(ctx) var outBuf, errBuf bytes.Buffer diff --git a/cmd/sin-code/internal/todo/remember.go b/cmd/sin-code/internal/todo/remember.go index 4632218c..1c867834 100644 --- a/cmd/sin-code/internal/todo/remember.go +++ b/cmd/sin-code/internal/todo/remember.go @@ -8,6 +8,11 @@ import ( bolt "go.etcd.io/bbolt" ) +var ( + jsonMarshalMemory = json.Marshal + jsonUnmarshalMemory = json.Unmarshal +) + type Memory struct { ID string `json:"id"` Insight string `json:"insight"` @@ -37,7 +42,7 @@ func (s *Store) AddMemory(m *Memory) error { } return s.update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte(bucketMems)) - data, err := json.Marshal(m) + data, err := jsonMarshalMemory(m) if err != nil { return err } @@ -51,7 +56,7 @@ func (s *Store) ListMemories() ([]*Memory, error) { b := tx.Bucket([]byte(bucketMems)) return b.ForEach(func(_, v []byte) error { var m Memory - if err := json.Unmarshal(v, &m); err != nil { + if err := jsonUnmarshalMemory(v, &m); err != nil { return err } out = append(out, &m) diff --git a/cmd/sin-code/internal/todo/store.go b/cmd/sin-code/internal/todo/store.go index e3a934cc..f30837be 100644 --- a/cmd/sin-code/internal/todo/store.go +++ b/cmd/sin-code/internal/todo/store.go @@ -15,6 +15,18 @@ import ( bolt "go.etcd.io/bbolt" ) +var ( + osUserConfigDirStore = os.UserConfigDir + osMkdirAllStore = os.MkdirAll + bboltOpenStore = bolt.Open + dbUpdateInit = func(db *bolt.DB, fn func(*bolt.Tx) error) error { return db.Update(fn) } + jsonMarshalStore = json.Marshal + jsonUnmarshalStore = json.Unmarshal + txBucketStore = func(tx *bolt.Tx, name []byte) *bolt.Bucket { return tx.Bucket(name) } + bucketPutFn = func(b *bolt.Bucket, k, v []byte) error { return b.Put(k, v) } + createBucketIfNotExistsFn = func(tx *bolt.Tx, name []byte) (*bolt.Bucket, error) { return tx.CreateBucketIfNotExists(name) } +) + const ( bucketTodos = "todos" bucketDeps = "deps" @@ -41,25 +53,25 @@ type Store struct { func Open(path string) (*Store, error) { if path == "" { - cfg, err := os.UserConfigDir() + cfg, err := osUserConfigDirStore() if err != nil { return nil, err } path = filepath.Join(cfg, "sin-code", "todo.db") } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := osMkdirAllStore(filepath.Dir(path), 0o755); err != nil { return nil, err } - db, err := bolt.Open(path, 0o644, &bolt.Options{Timeout: 2 * time.Second}) + db, err := bboltOpenStore(path, 0o644, &bolt.Options{Timeout: 2 * time.Second}) if err != nil { return nil, fmt.Errorf("open todo db: %w", err) } - if err := db.Update(func(tx *bolt.Tx) error { + if err := dbUpdateInit(db, func(tx *bolt.Tx) error { for _, b := range []string{ bucketTodos, bucketDeps, bucketAudit, bucketMems, bucketMeta, bucketIdxSt, bucketIdxPr, bucketIdxAs, bucketIdxPj, bucketIdxTg, } { - if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil { + if _, err := createBucketIfNotExistsFn(tx, []byte(b)); err != nil { return err } } @@ -137,12 +149,12 @@ func (s *Store) Add(t *Todo) error { } t.Tags = normalizeTags(t.Tags) return s.update(func(tx *bolt.Tx) error { - raw, err := json.Marshal(t) + raw, err := jsonMarshalStore(t) if err != nil { return err } - bT := tx.Bucket([]byte(bucketTodos)) - if err := bT.Put(todoKey(t.ID), raw); err != nil { + bT := txBucketStore(tx, []byte(bucketTodos)) + if err := bucketPutFn(bT, todoKey(t.ID), raw); err != nil { return err } writeIndex(tx, bucketIdxSt, string(t.Status), t.ID) @@ -177,13 +189,13 @@ func (s *Store) Update(t *Todo) error { } t.Tags = normalizeTags(t.Tags) return s.update(func(tx *bolt.Tx) error { - bT := tx.Bucket([]byte(bucketTodos)) + bT := txBucketStore(tx, []byte(bucketTodos)) old := bT.Get(todoKey(t.ID)) if old == nil { return ErrNotFound } var prev Todo - if err := json.Unmarshal(old, &prev); err != nil { + if err := jsonUnmarshalStore(old, &prev); err != nil { return err } if prev.Status != t.Status { @@ -224,23 +236,23 @@ func (s *Store) Update(t *Todo) error { writeIndex(tx, bucketIdxTg, tg, t.ID) } } - raw, err := json.Marshal(t) + raw, err := jsonMarshalStore(t) if err != nil { return err } - return bT.Put(todoKey(t.ID), raw) + return bucketPutFn(bT, todoKey(t.ID), raw) }) } func (s *Store) Get(id string) (*Todo, error) { var out *Todo err := s.view(func(tx *bolt.Tx) error { - raw := tx.Bucket([]byte(bucketTodos)).Get(todoKey(id)) + raw := txBucketStore(tx, []byte(bucketTodos)).Get(todoKey(id)) if raw == nil { return ErrNotFound } var t Todo - if err := json.Unmarshal(raw, &t); err != nil { + if err := jsonUnmarshalStore(raw, &t); err != nil { return err } out = &t @@ -252,9 +264,9 @@ func (s *Store) Get(id string) (*Todo, error) { func (s *Store) List() ([]*Todo, error) { var out []*Todo err := s.view(func(tx *bolt.Tx) error { - return tx.Bucket([]byte(bucketTodos)).ForEach(func(_, v []byte) error { + return txBucketStore(tx, []byte(bucketTodos)).ForEach(func(_, v []byte) error { var t Todo - if err := json.Unmarshal(v, &t); err != nil { + if err := jsonUnmarshalStore(v, &t); err != nil { return err } out = append(out, &t) @@ -266,28 +278,28 @@ func (s *Store) List() ([]*Todo, error) { func (s *Store) Delete(id string, hard bool) error { return s.update(func(tx *bolt.Tx) error { - bT := tx.Bucket([]byte(bucketTodos)) + bT := txBucketStore(tx, []byte(bucketTodos)) raw := bT.Get(todoKey(id)) if raw == nil { return ErrNotFound } if !hard { var t Todo - if err := json.Unmarshal(raw, &t); err != nil { + if err := jsonUnmarshalStore(raw, &t); err != nil { return err } t.Status = StatusCancelled t.UpdatedAt = time.Now().UTC() now := t.UpdatedAt t.ClosedAt = &now - buf, err := json.Marshal(&t) + buf, err := jsonMarshalStore(&t) if err != nil { return err } - return bT.Put(todoKey(id), buf) + return bucketPutFn(bT, todoKey(id), buf) } var t Todo - if err := json.Unmarshal(raw, &t); err != nil { + if err := jsonUnmarshalStore(raw, &t); err != nil { return err } removeIndex(tx, bucketIdxSt, string(t.Status), t.ID) @@ -297,7 +309,7 @@ func (s *Store) Delete(id string, hard bool) error { for _, tg := range t.Tags { removeIndex(tx, bucketIdxTg, tg, t.ID) } - return bT.Delete(todoKey(id)) + return bucketDeleteFn(bT, todoKey(id)) }) } @@ -325,7 +337,7 @@ func (s *Store) SetMeta(key, value string) error { func (s *Store) IndexKeys(bucketName, key string) ([]string, error) { var ids []string err := s.view(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(bucketName)) + b := txBucketStore(tx, []byte(bucketName)) if b == nil { return nil } @@ -345,7 +357,7 @@ func writeIndex(tx *bolt.Tx, bucketName, key, id string) { if key == "" { return } - b := tx.Bucket([]byte(bucketName)) + b := txBucketStore(tx, []byte(bucketName)) if b == nil { return } @@ -356,7 +368,7 @@ func removeIndex(tx *bolt.Tx, bucketName, key, id string) { if key == "" { return } - b := tx.Bucket([]byte(bucketName)) + b := txBucketStore(tx, []byte(bucketName)) if b == nil { return } diff --git a/cmd/sin-code/internal/todo/todo.go b/cmd/sin-code/internal/todo/todo.go index fcb33517..52badb2f 100644 --- a/cmd/sin-code/internal/todo/todo.go +++ b/cmd/sin-code/internal/todo/todo.go @@ -3,6 +3,7 @@ package todo import ( "encoding/json" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -16,6 +17,25 @@ import ( "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/notifications" ) +var ( + openStoreFn = openStore + currentActorFn = currentActor + currentProjectFn = currentProject + printJSONFn = printJSON + notifyFn = notify + getHookConfigFn = getHookConfig + fireHooksFn = fireHooks + firePluginHooksFn = firePluginHooks + gitUserNameFn = func() ([]byte, error) { return exec.Command("git", "config", "user.name").Output() } + osUserConfigDirTodo = os.UserConfigDir + osGetwdTodo = os.Getwd + osReadFileTodo = os.ReadFile + osWriteFileTodo = os.WriteFile + jsonMarshalIndentTodo = json.MarshalIndent + jsonMarshalTodo = json.Marshal + osStdoutTodo io.Writer = os.Stdout +) + var ( todoDBPath string todoProject string @@ -72,14 +92,14 @@ func currentActor() string { if todoAs != "" { return todoAs } - out, err := exec.Command("git", "config", "user.name").Output() + out, err := gitUserNameFn() if err == nil { name := strings.TrimSpace(string(out)) if name != "" { return name } } - if u, err := os.UserConfigDir(); err == nil { + if u, err := osUserConfigDirTodo(); err == nil { return filepath.Base(u) } return "unknown" @@ -89,7 +109,7 @@ func currentProject() string { if todoProject != "" { return todoProject } - cwd, err := os.Getwd() + cwd, err := osGetwdTodo() if err != nil { return "" } @@ -97,7 +117,7 @@ func currentProject() string { } func printJSON(v interface{}) error { - enc := json.NewEncoder(os.Stdout) + enc := json.NewEncoder(osStdoutTodo) enc.SetIndent("", " ") return enc.Encode(v) } @@ -165,7 +185,7 @@ var addCmd = &cobra.Command{ return fmt.Errorf("invalid type: %q", ttype) } if project == "" { - project = currentProject() + project = currentProjectFn() } t := &Todo{ Title: title, @@ -178,7 +198,7 @@ var addCmd = &cobra.Command{ ExternalRef: externalRef, Project: project, } - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -187,14 +207,14 @@ var addCmd = &cobra.Command{ return err } _ = store.AppendAudit(AuditEntry{ - TodoID: t.ID, Actor: currentActor(), Action: "create", + TodoID: t.ID, Actor: currentActorFn(), Action: "create", To: t.Title, }) - fireHooks(store, EventPostAdd, t, "", t.Title, "") - notify(notifications.TypeTodoCreated, t.ID, t.Title, - fmt.Sprintf("New %s %s: %s", t.Priority, t.Type, t.Title), currentActor()) + fireHooksFn(store, EventPostAdd, t, "", t.Title, "") + notifyFn(notifications.TypeTodoCreated, t.ID, t.Title, + fmt.Sprintf("New %s %s: %s", t.Priority, t.Type, t.Title), currentActorFn()) if todoFormat == "json" { - return printJSON(t) + return printJSONFn(t) } fmt.Printf("Created %s: %s\n", t.ID, t.Title) return nil @@ -237,7 +257,7 @@ var listCmd = &cobra.Command{ Project: project, Search: search, } - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -249,7 +269,7 @@ var listCmd = &cobra.Command{ return err } if todoFormat == "json" { - return printJSON(ts) + return printJSONFn(ts) } printTodoTable(ts) return nil @@ -259,7 +279,7 @@ var listCmd = &cobra.Command{ return err } if todoFormat == "json" { - return printJSON(ts) + return printJSONFn(ts) } printTodoTable(ts) return nil @@ -284,7 +304,7 @@ var showCmd = &cobra.Command{ Short: "Show full details of a todo", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -297,7 +317,7 @@ var showCmd = &cobra.Command{ deps, _ := store.GetDeps(t.ID) rev, _ := store.GetReverseDeps(t.ID) if todoFormat == "json" { - return printJSON(map[string]interface{}{ + return printJSONFn(map[string]interface{}{ "todo": t, "deps": deps, "deps_of": rev, @@ -365,7 +385,7 @@ var updateCmd = &cobra.Command{ Short: "Update fields of a todo", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -432,11 +452,11 @@ var updateCmd = &cobra.Command{ return err } _ = store.AppendAudit(AuditEntry{ - TodoID: t.ID, Actor: currentActor(), Action: "update", + TodoID: t.ID, Actor: currentActorFn(), Action: "update", From: string(old), To: string(t.Status), Note: strings.Join(changes, ","), }) if todoFormat == "json" { - return printJSON(t) + return printJSONFn(t) } fmt.Printf("Updated %s (%s)\n", t.ID, strings.Join(changes, ",")) return nil @@ -463,7 +483,7 @@ var claimCmd = &cobra.Command{ Short: "Atomically claim a todo (assign to current user)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -472,7 +492,7 @@ var claimCmd = &cobra.Command{ if err != nil { return err } - actor := currentActor() + actor := currentActorFn() if t.Assignee != "" && t.Assignee != actor { return fmt.Errorf("already claimed by %s", t.Assignee) } @@ -488,7 +508,7 @@ var claimCmd = &cobra.Command{ TodoID: t.ID, Actor: actor, Action: "claim", From: old, To: actor, }) - fireHooks(store, EventPostClaim, t, old, actor, "") + fireHooksFn(store, EventPostClaim, t, old, actor, "") fmt.Printf("Claimed %s by %s\n", t.ID, actor) return nil }, @@ -499,7 +519,7 @@ var unclaimCmd = &cobra.Command{ Short: "Release a claim on a todo", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -517,7 +537,7 @@ var unclaimCmd = &cobra.Command{ return err } _ = store.AppendAudit(AuditEntry{ - TodoID: t.ID, Actor: currentActor(), Action: "unclaim", + TodoID: t.ID, Actor: currentActorFn(), Action: "unclaim", From: old, To: "", }) fmt.Printf("Unclaimed %s (was %s)\n", t.ID, old) @@ -532,7 +552,7 @@ var completeCmd = &cobra.Command{ Short: "Mark a todo as done", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -547,10 +567,10 @@ var completeCmd = &cobra.Command{ return err } _ = store.AppendAudit(AuditEntry{ - TodoID: t.ID, Actor: currentActor(), Action: "complete", + TodoID: t.ID, Actor: currentActorFn(), Action: "complete", From: string(old), To: string(t.Status), }) - fireHooks(store, EventPostComplete, t, string(old), string(t.Status), "") + fireHooksFn(store, EventPostComplete, t, string(old), string(t.Status), "") fmt.Printf("Completed %s: %s\n", t.ID, t.Title) return nil }, @@ -561,7 +581,7 @@ var cancelCmd = &cobra.Command{ Short: "Mark a todo as cancelled", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -576,10 +596,10 @@ var cancelCmd = &cobra.Command{ return err } _ = store.AppendAudit(AuditEntry{ - TodoID: t.ID, Actor: currentActor(), Action: "cancel", + TodoID: t.ID, Actor: currentActorFn(), Action: "cancel", From: string(old), To: string(t.Status), }) - fireHooks(store, EventPostCancel, t, string(old), string(t.Status), "") + fireHooksFn(store, EventPostCancel, t, string(old), string(t.Status), "") fmt.Printf("Cancelled %s: %s\n", t.ID, t.Title) return nil }, @@ -591,7 +611,7 @@ var deleteCmd = &cobra.Command{ Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { soft, _ := cmd.Flags().GetBool("soft") - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -600,7 +620,7 @@ var deleteCmd = &cobra.Command{ return err } _ = store.AppendAudit(AuditEntry{ - TodoID: args[0], Actor: currentActor(), + TodoID: args[0], Actor: currentActorFn(), Action: "delete", Note: boolStr(soft, "soft", "hard"), }) fmt.Printf("Deleted %s (%s)\n", args[0], boolStr(soft, "soft", "hard")) @@ -628,7 +648,7 @@ var depAddCmd = &cobra.Command{ if !DepType(dtype).Valid() { return fmt.Errorf("invalid type: %q (use blocks|parent-child|related|discovered-from|duplicates|supersedes)", dtype) } - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -638,11 +658,11 @@ var depAddCmd = &cobra.Command{ return err } _ = store.AppendAudit(AuditEntry{ - TodoID: args[0], Actor: currentActor(), Action: "dep:add", + TodoID: args[0], Actor: currentActorFn(), Action: "dep:add", Note: fmt.Sprintf("%s -> %s (%s)", args[0], args[1], dtype), }) if child, err := store.Get(args[0]); err == nil && child != nil { - fireHooks(store, EventPostDepAdd, child, args[1], dtype, "") + fireHooksFn(store, EventPostDepAdd, child, args[1], dtype, "") } fmt.Printf("Added %s -> %s (%s)\n", args[0], args[1], dtype) return nil @@ -654,7 +674,7 @@ var depRemoveCmd = &cobra.Command{ Short: "Remove a dependency", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -663,7 +683,7 @@ var depRemoveCmd = &cobra.Command{ return err } _ = store.AppendAudit(AuditEntry{ - TodoID: args[0], Actor: currentActor(), Action: "dep:remove", + TodoID: args[0], Actor: currentActorFn(), Action: "dep:remove", Note: fmt.Sprintf("%s -> %s", args[0], args[1]), }) fmt.Printf("Removed dep %s -> %s\n", args[0], args[1]) @@ -685,7 +705,7 @@ var depsCmd = &cobra.Command{ Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { maxDepth, _ := cmd.Flags().GetInt("depth") - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -695,7 +715,7 @@ var depsCmd = &cobra.Command{ return err } if todoFormat == "json" { - return printJSON(tree) + return printJSONFn(tree) } fmt.Printf("Dependency tree for %s (depth %d):\n", args[0], maxDepth) seen := map[string]bool{} @@ -730,7 +750,7 @@ var readyCmd = &cobra.Command{ Use: "ready", Short: "List unblocked open work (P0 first)", RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -740,7 +760,7 @@ var readyCmd = &cobra.Command{ return err } if todoFormat == "json" { - return printJSON(ts) + return printJSONFn(ts) } printTodoTable(ts) return nil @@ -751,7 +771,7 @@ var blockedCmd = &cobra.Command{ Use: "blocked", Short: "List blocked work", RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -761,7 +781,7 @@ var blockedCmd = &cobra.Command{ return err } if todoFormat == "json" { - return printJSON(ts) + return printJSONFn(ts) } printTodoTable(ts) return nil @@ -773,7 +793,7 @@ var searchCmd = &cobra.Command{ Short: "Search titles and descriptions", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -783,7 +803,7 @@ var searchCmd = &cobra.Command{ return err } if todoFormat == "json" { - return printJSON(ts) + return printJSONFn(ts) } printTodoTable(ts) return nil @@ -796,7 +816,7 @@ var graphCmd = &cobra.Command{ Use: "graph", Short: "Output dependency graph in DOT format", RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -861,7 +881,7 @@ var statsCmd = &cobra.Command{ Use: "stats", Short: "Show counts by status, priority, type, assignee", RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -871,7 +891,7 @@ var statsCmd = &cobra.Command{ return err } if todoFormat == "json" { - return printJSON(st) + return printJSONFn(st) } fmt.Printf("Total: %d\n", st.Total) fmt.Printf("Ready: %d\n", st.Ready) @@ -903,7 +923,7 @@ var timelineCmd = &cobra.Command{ Short: "Show audit log (optionally for a specific todo)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -917,7 +937,7 @@ var timelineCmd = &cobra.Command{ return err } if todoFormat == "json" { - return printJSON(entries) + return printJSONFn(entries) } if len(entries) == 0 { fmt.Println("(no audit entries)") @@ -945,8 +965,8 @@ var mineCmd = &cobra.Command{ Use: "mine", Short: "List todos assigned to current user", RunE: func(cmd *cobra.Command, args []string) error { - actor := currentActor() - store, err := openStore() + actor := currentActorFn() + store, err := openStoreFn() if err != nil { return err } @@ -956,7 +976,7 @@ var mineCmd = &cobra.Command{ return err } if todoFormat == "json" { - return printJSON(ts) + return printJSONFn(ts) } fmt.Printf("Assigned to %s:\n", actor) printTodoTable(ts) @@ -971,7 +991,7 @@ var projectCmd = &cobra.Command{ Short: "Switch project namespace (no arg = show current)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -979,7 +999,7 @@ var projectCmd = &cobra.Command{ if len(args) == 0 { p, _ := store.GetMeta("current_project") if p == "" { - p = currentProject() + p = currentProjectFn() } fmt.Printf("Current project: %s\n", p) return nil @@ -999,14 +1019,14 @@ var rememberCmd = &cobra.Command{ Short: "Store a persistent memory/insight", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } defer store.Close() m := &Memory{ Insight: args[0], - Actor: currentActor(), + Actor: currentActorFn(), } if err := store.AddMemory(m); err != nil { return err @@ -1020,16 +1040,16 @@ var primeCmd = &cobra.Command{ Use: "prime", Short: "Print context to prepend to an agent prompt", RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } defer store.Close() ready, _ := store.Ready() blocked, _ := store.Blocked() - mine, _ := store.Mine(currentActor()) + mine, _ := store.Mine(currentActorFn()) fmt.Println("# sin-code todo context") - fmt.Printf("Project: %s\n", currentProject()) + fmt.Printf("Project: %s\n", currentProjectFn()) fmt.Printf("Ready: %d Blocked: %d Mine: %d\n", len(ready), len(blocked), len(mine)) if len(ready) > 0 { fmt.Println("\n## Ready work") @@ -1065,7 +1085,7 @@ var compactCmd = &cobra.Command{ if err != nil { return fmt.Errorf("invalid --older-than: %w", err) } - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -1075,7 +1095,7 @@ var compactCmd = &cobra.Command{ return err } if todoFormat == "json" { - return printJSON(res) + return printJSONFn(res) } verb := "Compacted" if dry { @@ -1102,7 +1122,7 @@ var initCmd = &cobra.Command{ Use: "init", Short: "Initialize the bbolt database", RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -1116,7 +1136,7 @@ var doctorCmd = &cobra.Command{ Use: "doctor", Short: "Health check of the todo database", RunE: func(cmd *cobra.Command, args []string) error { - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -1141,7 +1161,7 @@ var doctorCmd = &cobra.Command{ "healthy": true, } if todoFormat == "json" { - return printJSON(report) + return printJSONFn(report) } fmt.Printf("DB: %s\n", store.Path()) fmt.Printf("Total todos: %d\n", len(ts)) @@ -1160,7 +1180,7 @@ var exportCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { format, _ := cmd.Flags().GetString("format") output, _ := cmd.Flags().GetString("output") - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -1172,11 +1192,11 @@ var exportCmd = &cobra.Command{ var data []byte switch format { case "json": - data, _ = json.MarshalIndent(ts, "", " ") + data, _ = jsonMarshalIndentTodo(ts, "", " ") case "jsonl": var b strings.Builder for _, t := range ts { - line, _ := json.Marshal(t) + line, _ := jsonMarshalTodo(t) b.Write(line) b.WriteByte('\n') } @@ -1187,7 +1207,7 @@ var exportCmd = &cobra.Command{ return fmt.Errorf("unknown format: %q (use json|jsonl|markdown)", format) } if output != "" && output != "-" { - return os.WriteFile(output, data, 0644) + return osWriteFileTodo(output, data, 0644) } fmt.Print(string(data)) return nil @@ -1227,11 +1247,11 @@ var importCmd = &cobra.Command{ Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { format, _ := cmd.Flags().GetString("format") - data, err := os.ReadFile(args[0]) + data, err := osReadFileTodo(args[0]) if err != nil { return err } - store, err := openStore() + store, err := openStoreFn() if err != nil { return err } @@ -1266,7 +1286,7 @@ var importCmd = &cobra.Command{ imported++ } if todoFormat == "json" { - return printJSON(map[string]int{"imported": imported}) + return printJSONFn(map[string]int{"imported": imported}) } fmt.Printf("Imported %d todos\n", imported) return nil @@ -1331,7 +1351,7 @@ func fireHooks(store *Store, event HookEvent, t *Todo, from, to, note string) { if hc == nil { return } - ctx := HookContext{Event: event, Todo: t, From: from, To: to, Note: note, Actor: currentActor()} + ctx := HookContext{Event: event, Todo: t, From: from, To: to, Note: note, Actor: currentActorFn()} results := hc.Fire(ctx) for _, r := range results { if r.Err == nil { @@ -1345,5 +1365,5 @@ func fireHooks(store *Store, event HookEvent, t *Todo, from, to, note string) { case "ignore": } } - firePluginHooks(store, event, t, from, to, note) + firePluginHooksFn(store, event, t, from, to, note) } diff --git a/cmd/sin-code/internal/trace/provider.go b/cmd/sin-code/internal/trace/provider.go index c98b758e..01e1ce0e 100644 --- a/cmd/sin-code/internal/trace/provider.go +++ b/cmd/sin-code/internal/trace/provider.go @@ -41,9 +41,9 @@ const defaultShutdownTimeout = 5 * time.Second // Test hooks for error paths. var ( - stdouttraceNew = stdouttrace.New - otlptracehttpNew = otlptracehttp.New - resourceNew = resource.New + stdouttraceNew = stdouttrace.New + otlptracehttpNew = otlptracehttp.New + resourceNew = resource.New ) // ProviderConfig configures InitProvider. diff --git a/cmd/sin-code/internal/trace/trace_extra_test.go b/cmd/sin-code/internal/trace/trace_extra_test.go index 0b3a184b..eee4c731 100644 --- a/cmd/sin-code/internal/trace/trace_extra_test.go +++ b/cmd/sin-code/internal/trace/trace_extra_test.go @@ -10,11 +10,12 @@ import ( "testing" "time" - "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/hooks" "go.opentelemetry.io/otel/exporters/otlp/otlptrace" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" "go.opentelemetry.io/otel/sdk/resource" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/hooks" ) func TestParseExporterError(t *testing.T) { diff --git a/cmd/sin-code/internal/tui/agent_runner.go b/cmd/sin-code/internal/tui/agent_runner.go index 01ca796a..68240190 100644 --- a/cmd/sin-code/internal/tui/agent_runner.go +++ b/cmd/sin-code/internal/tui/agent_runner.go @@ -30,6 +30,16 @@ import ( "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/session" ) +var ( + // test hooks (overridable in tests to exercise error paths without + // heavy refactoring or heavy external dependencies). + osGetwd = os.Getwd + sessionOpen = session.Open + storeStartOrResume = func(s *session.Store, id string) (*session.Session, error) { return s.StartOrResume(id) } + loopbuilderBuild = loopbuilder.Build + storeClose = func(s *session.Store) error { return s.Close() } +) + // EventKind enumerates the discrete signals the runner emits. type EventKind int @@ -122,7 +132,7 @@ const ( // session store, and starts a new resumable session. func NewAgentRunner(ctx context.Context, cfg Config) (*AgentRunner, error) { if cfg.Workspace == "" { - wd, err := os.Getwd() + wd, err := osGetwd() if err != nil { return nil, fmt.Errorf("agentrunner: resolve workspace: %w", err) } @@ -138,11 +148,11 @@ func NewAgentRunner(ctx context.Context, cfg Config) (*AgentRunner, error) { if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { return nil, fmt.Errorf("agentrunner: mkdir sessions: %w", err) } - store, err := session.Open(dbPath) + store, err := sessionOpen(dbPath) if err != nil { return nil, fmt.Errorf("agentrunner: open sessions: %w", err) } - sess, err := store.StartOrResume(cfg.SessionID) + sess, err := storeStartOrResume(store, cfg.SessionID) if err != nil { _ = store.Close() return nil, fmt.Errorf("agentrunner: start session: %w", err) @@ -158,7 +168,7 @@ func NewAgentRunner(ctx context.Context, cfg Config) (*AgentRunner, error) { Events: make(chan AgentEvent, 64), closed: make(chan struct{}), } - loop, cleanup, err := loopbuilder.Build(ctx, loopbuilder.Config{ + loop, cleanup, err := loopbuilderBuild(ctx, loopbuilder.Config{ Workspace: cfg.Workspace, SessionID: sess.ID, AgentName: cfg.AgentName, @@ -307,7 +317,7 @@ func (r *AgentRunner) Close() error { } } if r.store != nil { - if err := r.store.Close(); err != nil && firstErr == nil { + if err := storeClose(r.store); err != nil && firstErr == nil { firstErr = err } } diff --git a/cmd/sin-code/internal/tui/agent_runner_test.go b/cmd/sin-code/internal/tui/agent_runner_test.go index 69efd6c7..4743fabd 100644 --- a/cmd/sin-code/internal/tui/agent_runner_test.go +++ b/cmd/sin-code/internal/tui/agent_runner_test.go @@ -16,6 +16,8 @@ import ( "time" "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/agentloop" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/lessons" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/loopbuilder" "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/session" ) @@ -611,3 +613,262 @@ func TestTruncate(t *testing.T) { } } } + +// ── Construction error paths ─────────────────────────────────────── + +func TestNewAgentRunnerWorkspaceEmpty(t *testing.T) { + tmp := t.TempDir() + orig := osGetwd + osGetwd = func() (string, error) { return tmp, nil } + t.Cleanup(func() { osGetwd = orig }) + r, err := NewAgentRunner(context.Background(), Config{Workspace: "", SkipMCP: true}) + if err != nil { + t.Fatalf("NewAgentRunner: %v", err) + } + if r.SessionID() == "" { + t.Error("SessionID empty after default workspace fallback") + } + _ = r.Close() +} + +func TestNewAgentRunnerWorkspaceGetwdError(t *testing.T) { + orig := osGetwd + osGetwd = func() (string, error) { return "", errors.New("getwd failed") } + t.Cleanup(func() { osGetwd = orig }) + _, err := NewAgentRunner(context.Background(), Config{Workspace: "", SkipMCP: true}) + if err == nil || !strings.Contains(err.Error(), "resolve workspace") { + t.Errorf("err = %v, want 'resolve workspace'", err) + } +} + +func TestNewAgentRunnerSessionOpenError(t *testing.T) { + orig := sessionOpen + sessionOpen = func(path string) (*session.Store, error) { return nil, errors.New("open failed") } + t.Cleanup(func() { sessionOpen = orig }) + _, err := NewAgentRunner(context.Background(), Config{Workspace: t.TempDir(), SkipMCP: true}) + if err == nil || !strings.Contains(err.Error(), "open sessions") { + t.Errorf("err = %v, want 'open sessions'", err) + } +} + +func TestNewAgentRunnerStartOrResumeError(t *testing.T) { + orig := storeStartOrResume + storeStartOrResume = func(s *session.Store, id string) (*session.Session, error) { return nil, errors.New("sor failed") } + t.Cleanup(func() { storeStartOrResume = orig }) + _, err := NewAgentRunner(context.Background(), Config{Workspace: t.TempDir(), SkipMCP: true}) + if err == nil || !strings.Contains(err.Error(), "start session") { + t.Errorf("err = %v, want 'start session'", err) + } +} + +func TestNewAgentRunnerLoopBuilderError(t *testing.T) { + orig := loopbuilderBuild + loopbuilderBuild = func(ctx context.Context, cfg loopbuilder.Config, memStore *lessons.Store) (*agentloop.Loop, func() error, error) { + return nil, nil, errors.New("build failed") + } + t.Cleanup(func() { loopbuilderBuild = orig }) + _, err := NewAgentRunner(context.Background(), Config{Workspace: t.TempDir(), SkipMCP: true}) + if err == nil || !strings.Contains(err.Error(), "build loop") { + t.Errorf("err = %v, want 'build loop'", err) + } +} + +// ── Ask edge cases ───────────────────────────────────────────────── + +func TestBridgeAskClosedFirstSelect(t *testing.T) { + r := &AgentRunner{cfg: Config{AskTimeout: -1}, Events: make(chan AgentEvent), closed: make(chan struct{})} + close(r.closed) + if got := r.bridgeAsk(agentloop.ToolCall{Name: "x"}); got != false { + t.Errorf("bridgeAsk = %v, want false", got) + } +} + +func TestBridgeAskReplyViaChannel(t *testing.T) { + r := &AgentRunner{cfg: Config{AskTimeout: 100 * time.Millisecond}, Events: make(chan AgentEvent, 1), closed: make(chan struct{})} + done := make(chan bool, 1) + go func() { done <- r.bridgeAsk(agentloop.ToolCall{Name: "x"}) }() + time.Sleep(20 * time.Millisecond) + r.askMu.Lock() + ch := r.askReply + r.askMu.Unlock() + if ch == nil { + t.Fatal("askReply not set") + } + ch <- true + select { + case got := <-done: + if !got { + t.Errorf("bridgeAsk = %v, want true", got) + } + case <-time.After(2 * time.Second): + t.Fatal("bridgeAsk did not return") + } +} + +func TestBridgeAskClosedSecondSelect(t *testing.T) { + r := &AgentRunner{cfg: Config{AskTimeout: 10 * time.Second}, Events: make(chan AgentEvent, 1), closed: make(chan struct{})} + done := make(chan bool, 1) + go func() { done <- r.bridgeAsk(agentloop.ToolCall{Name: "x"}) }() + time.Sleep(20 * time.Millisecond) + close(r.closed) + select { + case got := <-done: + if got { + t.Errorf("bridgeAsk = %v, want false", got) + } + case <-time.After(2 * time.Second): + t.Fatal("bridgeAsk did not return") + } +} + +func TestAnswerAskDefaultWhenBlocked(t *testing.T) { + r := &AgentRunner{askReply: make(chan bool)} // unbuffered, no receiver + r.AnswerAsk(true) // should hit default case, not block +} + +func TestSubmitSyncAfterCloseReturnsErrClosed(t *testing.T) { + r := newTestRunner(t, Config{Workspace: t.TempDir()}) + if err := r.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + _, err := r.SubmitSync(context.Background(), "x") + if !errors.Is(err, ErrClosed) { + t.Errorf("SubmitSync err = %v, want ErrClosed", err) + } +} + +// ── Emit edge cases ──────────────────────────────────────────────── + +func TestEmitClosedAndContextDone(t *testing.T) { + t.Run("closed", func(t *testing.T) { + r := &AgentRunner{Events: make(chan AgentEvent), closed: make(chan struct{})} + close(r.closed) + r.emit(context.Background(), EventTurn, "detail", "", "", nil) + }) + t.Run("ctx done", func(t *testing.T) { + r := &AgentRunner{Events: make(chan AgentEvent)} // unbuffered, no receiver + ctx, cancel := context.WithCancel(context.Background()) + cancel() + r.emit(ctx, EventTurn, "detail", "", "", nil) + }) +} + +// ── Nil-safe helpers ─────────────────────────────────────────────── + +func TestSessionIDNil(t *testing.T) { + r := &AgentRunner{} + if got := r.SessionID(); got != "" { + t.Errorf("SessionID() = %q, want empty", got) + } +} + +func TestSetCompletionNilLoopGuard(t *testing.T) { + r := &AgentRunner{} + r.SetCompletion(func(ctx context.Context, history []session.Message, tools []agentloop.ToolSpec) (*agentloop.Completion, error) { + return nil, nil + }) +} + +func TestEmitSessionHistoryNilSession(t *testing.T) { + r := &AgentRunner{} + r.emitSessionHistory(context.Background()) +} + +// ── Close error propagation ──────────────────────────────────────── + +func TestCloseCleanupError(t *testing.T) { + r, err := NewAgentRunner(context.Background(), Config{Workspace: t.TempDir(), SkipMCP: true}) + if err != nil { + t.Fatalf("NewAgentRunner: %v", err) + } + r.cleanup = func() error { return errors.New("cleanup failed") } + if err := r.Close(); err == nil || !strings.Contains(err.Error(), "cleanup failed") { + t.Errorf("Close err = %v, want 'cleanup failed'", err) + } +} + +func TestCloseStoreCloseError(t *testing.T) { + orig := storeClose + defer func() { storeClose = orig }() + storeClose = func(s *session.Store) error { return errors.New("store close failed") } + r, err := NewAgentRunner(context.Background(), Config{Workspace: t.TempDir(), SkipMCP: true}) + if err != nil { + t.Fatalf("NewAgentRunner: %v", err) + } + r.cleanup = nil // ensure firstErr is available for the store error + if err := r.Close(); err == nil || !strings.Contains(err.Error(), "store close failed") { + t.Errorf("Close err = %v, want 'store close failed'", err) + } +} + +// sessionWithHistory creates a real session seeded with the supplied messages. +// The caller is responsible for closing the returned store when done. +func sessionWithHistory(t *testing.T, msgs []session.Message) (*session.Store, *session.Session) { + t.Helper() + ws := t.TempDir() + store, err := session.Open(filepath.Join(ws, "sessions.db")) + if err != nil { + t.Fatalf("session.Open: %v", err) + } + sess, err := store.StartOrResume("") + if err != nil { + _ = store.Close() + t.Fatalf("StartOrResume: %v", err) + } + if err := sess.SaveHistory(msgs); err != nil { + _ = store.Close() + t.Fatalf("SaveHistory: %v", err) + } + return store, sess +} + +func TestEmitSessionHistoryBranches(t *testing.T) { + store, sess := sessionWithHistory(t, []session.Message{ + {Role: "tool", Content: "orphan tool result with no assistant call"}, + {Role: "tool", Content: strings.Repeat("x", 100)}, + {Role: "user", Content: "VERIFICATION PASSED summary text"}, + {Role: "user", Content: "VERIFICATION FAILED another summary"}, + {Role: "user", Content: "VERIFICATION BLOCKED — permission denied"}, + }) + defer store.Close() + + r := &AgentRunner{Events: make(chan AgentEvent, 64)} + r.sess = sess + r.emitSessionHistory(context.Background()) + + events := collectEvents(r, 500*time.Millisecond) + var orphanResult, longContent, passed, failed, blocked bool + for _, ev := range events { + if ev.Kind == EventTool && ev.Detail == "tool result" { + orphanResult = true + } + if ev.Kind == EventTool && ev.Detail == "tool result" && ev.ToolName == "" { + // long content branch also emits "tool result" with empty name + longContent = true + } + if ev.Kind == EventVerify && ev.Result == "summary text" { + passed = true + } + if ev.Kind == EventVerify && ev.Result == "another summary" { + failed = true + } + if ev.Kind == EventVerify && ev.Result == "permission denied" { + blocked = true + } + } + if !orphanResult { + t.Errorf("expected orphan tool result event, got %+v", events) + } + if !longContent { + t.Errorf("expected long-content tool result event, got %+v", events) + } + if !passed { + t.Errorf("expected VERIFICATION PASSED event, got %+v", events) + } + if !failed { + t.Errorf("expected VERIFICATION FAILED event, got %+v", events) + } + if !blocked { + t.Errorf("expected VERIFICATION BLOCKED event, got %+v", events) + } +} diff --git a/cmd/sin-code/internal/vane/mcpserver.go b/cmd/sin-code/internal/vane/mcpserver.go index e9ed2cd2..22d8b8dd 100644 --- a/cmd/sin-code/internal/vane/mcpserver.go +++ b/cmd/sin-code/internal/vane/mcpserver.go @@ -170,7 +170,7 @@ func (s *Server) dispatch(ctx context.Context, req *jsonRPCRequest) *jsonRPCResp } func (s *Server) result(req *jsonRPCRequest, v any) *jsonRPCResponse { - data, err := json.Marshal(v) + data, err := jsonMarshalFn(v) if err != nil { return &jsonRPCResponse{ JSONRPC: "2.0", @@ -364,8 +364,15 @@ func marshalText(v any) []toolContent { // Serve is a convenience wrapper: build a default Server and run Serve // until stdin closes or ctx is cancelled. Used by the `sin-code vane // serve` cobra command (owned by a different subagent). +// serveStdin and serveStdout are test hooks so the public Serve wrapper +// can be exercised without touching the real process stdio. +var ( + serveStdin io.Reader = os.Stdin + serveStdout io.Writer = os.Stdout +) + func Serve(ctx context.Context) error { - return NewServer("").Serve(ctx) + return NewServerWithIO(serveStdin, serveStdout, os.Stderr, "").Serve(ctx) } // formatIntBytes is a tiny helper used by Health replies — declared diff --git a/cmd/sin-code/internal/vane/vane.go b/cmd/sin-code/internal/vane/vane.go index 0817b301..e30a6b47 100644 --- a/cmd/sin-code/internal/vane/vane.go +++ b/cmd/sin-code/internal/vane/vane.go @@ -23,6 +23,24 @@ import ( "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/circuitbreaker" ) +// testHook variables allow tests to inject failure without heavy +// refactoring. They are package-level vars (not build-tagged) so the +// production binary pays only the indirection cost. +var ( + osUserHomeDirFn = os.UserHomeDir + osExecutableFn = os.Executable + jsonMarshalFn = json.Marshal + jsonMarshalIndentFn = json.MarshalIndent + ioCopyNFn = io.CopyN + ioReadAllFn = io.ReadAll + osMkdirAllFn = os.MkdirAll + osCreateTempFn = os.CreateTemp + writeJSONCopyFn = io.Copy + writeJSONWriteFn = func(w io.Writer, p []byte) (int, error) { return w.Write(p) } + writeJSONCloseFn = func(c io.Closer) error { return c.Close() } + roundTripperFn = circuitbreaker.RoundTripper +) + // ── Public configuration constants ───────────────────────────────────── // ServerName is the MCP server name registered in mcp.json. Used by @@ -62,7 +80,7 @@ func Home() string { return v } // Use os.UserHomeDir for cross-platform safety (macOS/Linux/Windows). - if h, err := os.UserHomeDir(); err == nil { + if h, err := osUserHomeDirFn(); err == nil { return filepath.Join(h, ".local", "share", "sin-code") } // Last resort: cwd-relative fallback so the function is total. @@ -229,7 +247,7 @@ func NewClient(cfg Config) *Client { cfg: cfg, http: &http.Client{ Timeout: time.Duration(timeout) * time.Second, - Transport: circuitbreaker.RoundTripper(http.DefaultTransport, br), + Transport: roundTripperFn(http.DefaultTransport, br), }, breaker: br, } @@ -298,7 +316,7 @@ func (c *Client) Search(ctx context.Context, query, focusMode, optimization stri EmbeddingModelProvider: c.cfg.EmbedProvider, EmbeddingModel: c.cfg.EmbedModel, } - raw, err := json.Marshal(body) + raw, err := jsonMarshalFn(body) if err != nil { return nil, fmt.Errorf("vane: marshal: %w", err) } @@ -315,7 +333,7 @@ func (c *Client) Search(ctx context.Context, query, focusMode, optimization stri return nil, fmt.Errorf("vane: post: %w", err) } defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) + respBody, err := ioReadAllFn(resp.Body) if err != nil { return nil, fmt.Errorf("vane: read body: %w", err) } @@ -383,7 +401,7 @@ func RegisterMCP(mcpPath string) (string, error) { if mcpPath == "" { mcpPath = MCPConfigPath() } - exe, err := os.Executable() + exe, err := osExecutableFn() if err != nil { return mcpPath, fmt.Errorf("vane: resolve executable: %w", err) } @@ -430,28 +448,28 @@ func truncate(s string, n int) string { // directories are created on demand. Mirrors superpowers.WriteJSON() so // the rest of the codebase has a consistent persistence semantic. func writeJSONAtomic(path string, v any) error { - data, err := json.MarshalIndent(v, "", " ") + data, err := jsonMarshalIndentFn(v, "", " ") if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := osMkdirAllFn(filepath.Dir(path), 0o755); err != nil { return err } - tmp, err := os.CreateTemp(filepath.Dir(path), ".vane-*.json.tmp") + tmp, err := osCreateTempFn(filepath.Dir(path), ".vane-*.json.tmp") if err != nil { return err } tmpName := tmp.Name() defer os.Remove(tmpName) - if _, err := io.Copy(tmp, bytes.NewReader(data)); err != nil { - tmp.Close() + if _, err := writeJSONCopyFn(tmp, bytes.NewReader(data)); err != nil { + writeJSONCloseFn(tmp) return err } - if _, err := tmp.Write([]byte("\n")); err != nil { - tmp.Close() + if _, err := writeJSONWriteFn(tmp, []byte("\n")); err != nil { + writeJSONCloseFn(tmp) return err } - if err := tmp.Close(); err != nil { + if err := writeJSONCloseFn(tmp); err != nil { return err } return os.Rename(tmpName, path) diff --git a/cmd/sin-code/internal/vane/vane_extra_test.go b/cmd/sin-code/internal/vane/vane_extra_test.go new file mode 100644 index 00000000..e9095c56 --- /dev/null +++ b/cmd/sin-code/internal/vane/vane_extra_test.go @@ -0,0 +1,875 @@ +// SPDX-License-Identifier: MIT +// Purpose: additional tests for the vane package targeting the remaining +// statement-coverage gaps. Uses package-level test hooks declared in +// vane.go and mcpserver.go to exercise error paths without heavy +// refactoring. All tests are hermetic and single-threaded (no t.Parallel) +// because they mutate global hook variables. +// Docs: vane.doc.md +package vane + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/circuitbreaker" +) + +// setHook replaces *target with value and restores it on test cleanup. +func setHook[T any](t *testing.T, target *T, value T) { + t.Helper() + old := *target + *target = value + t.Cleanup(func() { *target = old }) +} + +// disableBreaker swaps the transport wrapper so raw HTTP responses reach +// the Client error branches instead of being intercepted by the breaker. +func disableBreaker(t *testing.T) { + t.Helper() + setHook(t, &roundTripperFn, func(inner http.RoundTripper, _ *circuitbreaker.Breaker) http.RoundTripper { + return inner + }) +} + +// failingWriter always returns an error so Serve's json.Encoder.Encode path +// can be exercised. +type failingWriter struct{} + +func (failingWriter) Write(p []byte) (int, error) { return 0, errors.New("write fail") } + +// errorReader returns a configured error on every Read. +type errorReader struct { + err error +} + +func (r *errorReader) Read(p []byte) (int, error) { return 0, r.err } + +// ── Config / Home ───────────────────────────────────────────────────── + +func TestHomeFallbackWhenUserHomeDirFails(t *testing.T) { + t.Setenv("SIN_CODE_HOME", "") + setHook(t, &osUserHomeDirFn, func() (string, error) { return "", errors.New("no home") }) + got := Home() + want := filepath.Join(".", ".sin-code-home") + if got != want { + t.Errorf("Home() = %q, want %q", got, want) + } +} + +func TestLoadConfigReadError(t *testing.T) { + dir := setupTestHome(t) + // Create a directory named vane.json so os.ReadFile returns a non-ErrNotExist error. + path := filepath.Join(dir, "vane.json") + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + _, _, err := LoadConfig() + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "vane: read") { + t.Errorf("error: %v", err) + } +} + +func TestLoadConfigUnmarshalError(t *testing.T) { + setupTestHome(t) + if err := os.WriteFile(ConfigPath(), []byte("not json"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + _, _, err := LoadConfig() + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "vane: parse") { + t.Errorf("error: %v", err) + } +} + +func TestLoadConfigFallbacks(t *testing.T) { + setupTestHome(t) + if err := SaveConfig(Config{BaseURL: " ", TimeoutSeconds: -1}); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + cfg, _, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.BaseURL != DefaultBaseURL { + t.Errorf("BaseURL fallback: got %q", cfg.BaseURL) + } + if cfg.TimeoutSeconds != DefaultTimeoutSeconds { + t.Errorf("TimeoutSeconds fallback: got %d", cfg.TimeoutSeconds) + } +} + +func TestLoadConfigTimeoutFallback(t *testing.T) { + dir := setupTestHome(t) + if err := os.WriteFile(filepath.Join(dir, "vane.json"), []byte(`{"timeout_seconds":0}`), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + cfg, _, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.TimeoutSeconds != DefaultTimeoutSeconds { + t.Errorf("TimeoutSeconds fallback: got %d", cfg.TimeoutSeconds) + } +} + +func TestLoadConfigBaseURLFallback(t *testing.T) { + dir := setupTestHome(t) + if err := os.WriteFile(filepath.Join(dir, "vane.json"), []byte(`{"base_url":" "}`), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + cfg, _, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.BaseURL != DefaultBaseURL { + t.Errorf("BaseURL fallback: got %q", cfg.BaseURL) + } +} + +func TestSaveConfigBlankBaseURL(t *testing.T) { + setupTestHome(t) + if err := SaveConfig(Config{BaseURL: " "}); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + cfg, _, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.BaseURL != DefaultBaseURL { + t.Errorf("blank BaseURL not normalized: got %q", cfg.BaseURL) + } +} + +// ── Client construction ─────────────────────────────────────────────── + +func TestNewClientTimeoutFallback(t *testing.T) { + c := NewClient(Config{TimeoutSeconds: 0}) + if c.http.Timeout != DefaultTimeoutSeconds*time.Second { + t.Errorf("timeout: got %v want %v", c.http.Timeout, DefaultTimeoutSeconds*time.Second) + } +} + +func TestBreakerStatsNil(t *testing.T) { + var c *Client + if c.BreakerStats() != nil { + t.Error("nil *Client should return nil") + } + c2 := &Client{} + if c2.BreakerStats() != nil { + t.Error("Client with nil breaker should return nil") + } +} + +func TestBreakerStatsNonNil(t *testing.T) { + c := NewClient(DefaultConfig()) + stats := c.BreakerStats() + if stats == nil { + t.Fatal("expected non-nil stats") + } +} + +// ── Healthy / Search error paths ──────────────────────────────────────── + +func TestHealthyRequestError(t *testing.T) { + cfg := DefaultConfig() + cfg.BaseURL = "://invalid" + c := NewClient(cfg) + err := c.Healthy(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "build request") { + t.Errorf("error: %v", err) + } +} + +func TestHealthyServerError(t *testing.T) { + disableBreaker(t) + srv := mockVane(t, http.StatusInternalServerError, "err", nil) + defer srv.Close() + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + c := NewClient(cfg) + err := c.Healthy(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "server error") { + t.Errorf("error: %v", err) + } +} + +func TestHealthyClientError(t *testing.T) { + srv := mockVane(t, http.StatusBadRequest, "err", nil) + defer srv.Close() + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + c := NewClient(cfg) + err := c.Healthy(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "client error") { + t.Errorf("error: %v", err) + } +} + +func TestSearchMarshalError(t *testing.T) { + setHook(t, &jsonMarshalFn, func(v any) ([]byte, error) { return nil, errors.New("marshal") }) + srv := mockVane(t, http.StatusOK, "ok", nil) + defer srv.Close() + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + c := NewClient(cfg) + _, err := c.Search(context.Background(), "q", "webSearch", "balanced") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "vane: marshal") { + t.Errorf("error: %v", err) + } +} + +func TestSearchRequestError(t *testing.T) { + cfg := DefaultConfig() + cfg.BaseURL = "://invalid" + c := NewClient(cfg) + _, err := c.Search(context.Background(), "q", "webSearch", "balanced") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "build request") { + t.Errorf("error: %v", err) + } +} + +func TestSearchReadBodyError(t *testing.T) { + setHook(t, &ioReadAllFn, func(r io.Reader) ([]byte, error) { return nil, errors.New("read body") }) + srv := mockVane(t, http.StatusOK, "ok", nil) + defer srv.Close() + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + c := NewClient(cfg) + _, err := c.Search(context.Background(), "q", "webSearch", "balanced") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "vane: read body") { + t.Errorf("error: %v", err) + } +} + +func TestSearchServerError5xx(t *testing.T) { + disableBreaker(t) + srv := mockVane(t, http.StatusInternalServerError, "boom", nil) + defer srv.Close() + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + c := NewClient(cfg) + _, err := c.Search(context.Background(), "q", "webSearch", "balanced") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "server error") { + t.Errorf("error: %v", err) + } +} + +func TestSearchClientError(t *testing.T) { + srv := mockVane(t, http.StatusBadRequest, "bad", nil) + defer srv.Close() + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + c := NewClient(cfg) + _, err := c.Search(context.Background(), "q", "webSearch", "balanced") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "client error") { + t.Errorf("error: %v", err) + } +} + +func TestSearchDecodeError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "not json") + })) + defer srv.Close() + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + c := NewClient(cfg) + _, err := c.Search(context.Background(), "q", "webSearch", "balanced") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "vane: decode") { + t.Errorf("error: %v", err) + } +} + +func TestSearchNilSources(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"message":"ok"}`) + })) + defer srv.Close() + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + c := NewClient(cfg) + ans, err := c.Search(context.Background(), "q", "webSearch", "balanced") + if err != nil { + t.Fatalf("Search: %v", err) + } + if ans.Sources == nil { + t.Error("Sources should be a non-nil empty slice") + } +} + +func TestSearchAnswerFallback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"answer":"fallback answer"}`) + })) + defer srv.Close() + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + c := NewClient(cfg) + ans, err := c.Search(context.Background(), "q", "webSearch", "balanced") + if err != nil { + t.Fatalf("Search: %v", err) + } + if ans.Message != "fallback answer" { + t.Errorf("Message: got %q want %q", ans.Message, "fallback answer") + } +} + +// ── FormatAnswer ──────────────────────────────────────────────────────── + +func TestFormatAnswerEmptySource(t *testing.T) { + got := FormatAnswer(&Answer{ + Message: "msg", + Sources: []Source{ + {Title: "", URL: ""}, // skipped entirely + {Title: "T", URL: ""}, // title-only + {Title: "", URL: "http://u"}, // url-only, title falls back to url + }, + }) + if strings.Contains(got, "1.") { + t.Errorf("empty source should be skipped: %q", got) + } + if !strings.Contains(got, "2. T") { + t.Errorf("missing title-only source: %q", got) + } + if !strings.Contains(got, "3. [http://u](http://u)") { + t.Errorf("missing url-only source: %q", got) + } +} + +// ── RegisterMCP / writeJSONAtomic ────────────────────────────────────── + +func TestRegisterMCPExecutableError(t *testing.T) { + setupTestHome(t) + setHook(t, &osExecutableFn, func() (string, error) { return "", errors.New("exe") }) + _, err := RegisterMCP("") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "vane: resolve executable") { + t.Errorf("error: %v", err) + } +} + +func TestWriteJSONAtomicMarshalError(t *testing.T) { + dir := setupTestHome(t) + setHook(t, &jsonMarshalIndentFn, func(v any, prefix, indent string) ([]byte, error) { return nil, errors.New("marshal") }) + err := writeJSONAtomic(filepath.Join(dir, "x.json"), map[string]any{}) + if err == nil || !strings.Contains(err.Error(), "marshal") { + t.Errorf("error: %v", err) + } +} + +func TestWriteJSONAtomicMkdirError(t *testing.T) { + dir := setupTestHome(t) + // Create a file where the parent directory is expected so MkdirAll fails. + badPath := filepath.Join(dir, "file", "x.json") + if err := os.WriteFile(filepath.Join(dir, "file"), []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + err := writeJSONAtomic(badPath, map[string]any{}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestWriteJSONAtomicCreateTempError(t *testing.T) { + dir := setupTestHome(t) + setHook(t, &osCreateTempFn, func(dir, pattern string) (*os.File, error) { return nil, errors.New("temp") }) + err := writeJSONAtomic(filepath.Join(dir, "x.json"), map[string]any{}) + if err == nil || !strings.Contains(err.Error(), "temp") { + t.Errorf("error: %v", err) + } +} + +func TestWriteJSONAtomicCopyError(t *testing.T) { + dir := setupTestHome(t) + setHook(t, &writeJSONCopyFn, func(dst io.Writer, src io.Reader) (int64, error) { return 0, errors.New("copy") }) + err := writeJSONAtomic(filepath.Join(dir, "x.json"), map[string]any{}) + if err == nil || !strings.Contains(err.Error(), "copy") { + t.Errorf("error: %v", err) + } +} + +func TestWriteJSONAtomicWriteError(t *testing.T) { + dir := setupTestHome(t) + setHook(t, &writeJSONWriteFn, func(w io.Writer, p []byte) (int, error) { return 0, errors.New("write") }) + err := writeJSONAtomic(filepath.Join(dir, "x.json"), map[string]any{}) + if err == nil || !strings.Contains(err.Error(), "write") { + t.Errorf("error: %v", err) + } +} + +func TestWriteJSONAtomicCloseError(t *testing.T) { + dir := setupTestHome(t) + setHook(t, &writeJSONCloseFn, func(c io.Closer) error { return errors.New("close") }) + err := writeJSONAtomic(filepath.Join(dir, "x.json"), map[string]any{}) + if err == nil || !strings.Contains(err.Error(), "close") { + t.Errorf("error: %v", err) + } +} + +// ── MCP Server construction / lifecycle ─────────────────────────────── + +func TestNewServerDefaultCfgDir(t *testing.T) { + dir := setupTestHome(t) + s := NewServer("") + if s.cfgDir != dir { + t.Errorf("cfgDir: got %q want %q", s.cfgDir, dir) + } +} + +func TestServeWrapper(t *testing.T) { + setupTestHome(t) + if err := SaveConfig(Config{BaseURL: "http://127.0.0.1:1", TimeoutSeconds: 1}); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + r, w := io.Pipe() + out := &safeBuffer{} + setHook(t, &serveStdin, io.Reader(r)) + setHook(t, &serveStdout, io.Writer(out)) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + done := make(chan struct{}) + go func() { + _ = Serve(ctx) + close(done) + }() + req := map[string]any{"jsonrpc": "2.0", "id": 1, "method": "ping"} + line, _ := json.Marshal(req) + if _, err := w.Write(append(line, '\n')); err != nil { + t.Fatalf("write: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && out.Len() == 0 { + time.Sleep(5 * time.Millisecond) + } + if out.Len() == 0 { + t.Fatal("no response") + } + _ = w.Close() + <-done +} + +func TestServeMalformedJSON(t *testing.T) { + dir := setupTestHome(t) + if err := SaveConfig(Config{BaseURL: "http://127.0.0.1:1", TimeoutSeconds: 1}); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + inR, inW := io.Pipe() + outBuf := &safeBuffer{} + errBuf := &safeBuffer{} + s := NewServerWithIO(inR, outBuf, errBuf, dir) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + done := make(chan struct{}) + go func() { + _ = s.Serve(ctx) + close(done) + }() + + // Empty line should be ignored; malformed line should log to stderr. + if _, err := inW.Write([]byte("\nnot json\n")); err != nil { + t.Fatalf("write: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && errBuf.Len() == 0 { + time.Sleep(5 * time.Millisecond) + } + if errBuf.Len() == 0 { + t.Fatal("expected stderr output") + } + if !strings.Contains(errBuf.String(), "decode error") { + t.Errorf("stderr: %q", errBuf.String()) + } + + // Ensure the server continues after a malformed line. + req := map[string]any{"jsonrpc": "2.0", "id": 1, "method": "ping"} + line, _ := json.Marshal(req) + if _, err := inW.Write(append(line, '\n')); err != nil { + t.Fatalf("write ping: %v", err) + } + deadline = time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && outBuf.Len() == 0 { + time.Sleep(5 * time.Millisecond) + } + if outBuf.Len() == 0 { + t.Fatal("no ping response") + } + respLine, _ := outBuf.ReadString('\n') + var resp map[string]any + if err := json.Unmarshal([]byte(strings.TrimRight(respLine, "\r\n")), &resp); err != nil { + t.Fatalf("parse: %v", err) + } + if _, ok := resp["error"]; ok { + t.Errorf("ping returned error: %v", resp["error"]) + } + + _ = inW.Close() + <-done +} + +func TestServeEncodeError(t *testing.T) { + dir := setupTestHome(t) + if err := SaveConfig(Config{BaseURL: "http://127.0.0.1:1", TimeoutSeconds: 1}); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + inR, inW := io.Pipe() + s := NewServerWithIO(inR, failingWriter{}, &safeBuffer{}, dir) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + done := make(chan struct{}) + go func() { + _ = s.Serve(ctx) + close(done) + }() + req := map[string]any{"jsonrpc": "2.0", "id": 1, "method": "ping"} + line, _ := json.Marshal(req) + if _, err := inW.Write(append(line, '\n')); err != nil { + t.Fatalf("write: %v", err) + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("server did not exit after encode error") + } +} + +func TestServeScannerError(t *testing.T) { + dir := setupTestHome(t) + s := NewServerWithIO(&errorReader{err: errors.New("scanner")}, &safeBuffer{}, &safeBuffer{}, dir) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err := s.Serve(ctx) + if err == nil || !strings.Contains(err.Error(), "scanner") { + t.Errorf("error: %v", err) + } +} + +func TestServeContextCancel(t *testing.T) { + dir := setupTestHome(t) + if err := SaveConfig(Config{BaseURL: "http://127.0.0.1:1", TimeoutSeconds: 1}); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + inR, inW := io.Pipe() + outBuf := &safeBuffer{} + s := NewServerWithIO(inR, outBuf, &safeBuffer{}, dir) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + _ = s.Serve(ctx) + close(done) + }() + + req := map[string]any{"jsonrpc": "2.0", "id": 1, "method": "ping"} + line, _ := json.Marshal(req) + if _, err := inW.Write(append(line, '\n')); err != nil { + t.Fatalf("write: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && outBuf.Len() == 0 { + time.Sleep(5 * time.Millisecond) + } + if outBuf.Len() == 0 { + t.Fatal("no ping response") + } + + // Cancel the context and feed another line so the loop hits ctx.Err(). + cancel() + if _, err := inW.Write([]byte("{}\n")); err != nil && err != io.ErrClosedPipe { + t.Fatalf("write after cancel: %v", err) + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("server did not exit after context cancellation") + } +} + +func TestServeNotification(t *testing.T) { + dir := setupTestHome(t) + if err := SaveConfig(Config{BaseURL: "http://127.0.0.1:1", TimeoutSeconds: 1}); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + inR, inW := io.Pipe() + outBuf := &safeBuffer{} + s := NewServerWithIO(inR, outBuf, &safeBuffer{}, dir) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + done := make(chan struct{}) + go func() { + _ = s.Serve(ctx) + close(done) + }() + + // notifications/initialized is a notification with no reply. + req1 := map[string]any{"jsonrpc": "2.0", "method": "notifications/initialized"} + line1, _ := json.Marshal(req1) + if _, err := inW.Write(append(line1, '\n')); err != nil { + t.Fatalf("write initialized: %v", err) + } + + // Send a ping to confirm the server is still alive. + req2 := map[string]any{"jsonrpc": "2.0", "id": 1, "method": "ping"} + line2, _ := json.Marshal(req2) + if _, err := inW.Write(append(line2, '\n')); err != nil { + t.Fatalf("write ping: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && outBuf.Len() == 0 { + time.Sleep(5 * time.Millisecond) + } + if outBuf.Len() == 0 { + t.Fatal("no ping response") + } + respLine, _ := outBuf.ReadString('\n') + var resp map[string]any + if err := json.Unmarshal([]byte(strings.TrimRight(respLine, "\r\n")), &resp); err != nil { + t.Fatalf("parse: %v", err) + } + if _, ok := resp["error"]; ok { + t.Errorf("ping returned error: %v", resp["error"]) + } + + _ = inW.Close() + <-done +} + +// ── dispatch / result / tool calls ──────────────────────────────────── + +func TestDispatchNotification(t *testing.T) { + s := NewServerWithIO(strings.NewReader(""), &safeBuffer{}, &safeBuffer{}, "") + req := &jsonRPCRequest{Method: "ping"} // ID is nil + resp := s.dispatch(context.Background(), req) + if resp != nil { + t.Errorf("expected nil response for notification, got %v", resp) + } +} + +func TestDispatchInitializedAck(t *testing.T) { + s := NewServerWithIO(strings.NewReader(""), &safeBuffer{}, &safeBuffer{}, "") + id := json.RawMessage(`1`) + req := &jsonRPCRequest{ID: &id, Method: "notifications/initialized", JSONRPC: "2.0"} + resp := s.dispatch(context.Background(), req) + if resp != nil { + t.Errorf("expected nil response for notifications/initialized, got %v", resp) + } +} + +func TestDispatchMethodNotFound(t *testing.T) { + s := NewServerWithIO(strings.NewReader(""), &safeBuffer{}, &safeBuffer{}, "") + id := json.RawMessage(`1`) + req := &jsonRPCRequest{ID: &id, Method: "unknown", JSONRPC: "2.0"} + resp := s.dispatch(context.Background(), req) + if resp == nil || resp.Error == nil { + t.Fatal("expected JSON-RPC error") + } + if resp.Error.Code != -32601 { + t.Errorf("code: got %d want -32601", resp.Error.Code) + } +} + +func TestResultMarshalError(t *testing.T) { + setHook(t, &jsonMarshalFn, func(v any) ([]byte, error) { return nil, errors.New("marshal") }) + s := NewServerWithIO(strings.NewReader(""), &safeBuffer{}, &safeBuffer{}, "") + id := json.RawMessage(`1`) + req := &jsonRPCRequest{ID: &id, Method: "ping", JSONRPC: "2.0"} + resp := s.result(req, map[string]any{"x": "y"}) + if resp == nil || resp.Error == nil { + t.Fatal("expected JSON-RPC error") + } + if resp.Error.Code != -32603 { + t.Errorf("code: got %d want -32603", resp.Error.Code) + } +} + +func TestCallResearchNoDeadline(t *testing.T) { + dir := setupTestHome(t) + srv := mockVane(t, http.StatusOK, "answer", nil) + defer srv.Close() + if err := SaveConfig(Config{BaseURL: srv.URL, TimeoutSeconds: 2}); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + s := NewServerWithIO(strings.NewReader(""), &safeBuffer{}, &safeBuffer{}, dir) + id := json.RawMessage(`1`) + params := toolCallParams{Name: "vane_research"} + params.Arguments, _ = json.Marshal(map[string]any{"query": "q"}) + resp := s.callResearch(context.Background(), &jsonRPCRequest{ID: &id, Method: "tools/call", JSONRPC: "2.0"}, ¶ms) + if resp == nil || resp.Error != nil { + t.Fatalf("unexpected JSON-RPC error: %v", resp) + } + var tres toolResult + if err := json.Unmarshal(resp.Result, &tres); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(tres.Content) == 0 { + t.Fatal("no content") + } + if !strings.Contains(tres.Content[0].Text, "answer") { + t.Errorf("missing answer: %q", tres.Content[0].Text) + } +} + +func TestCallResearchLazyClientError(t *testing.T) { + dir := setupTestHome(t) + if err := os.WriteFile(filepath.Join(dir, "vane.json"), []byte("bad"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + s := NewServerWithIO(strings.NewReader(""), &safeBuffer{}, &safeBuffer{}, dir) + id := json.RawMessage(`1`) + params := toolCallParams{Name: "vane_research"} + params.Arguments, _ = json.Marshal(map[string]any{"query": "q"}) + resp := s.callResearch(context.Background(), &jsonRPCRequest{ID: &id, Method: "tools/call", JSONRPC: "2.0"}, ¶ms) + if resp == nil || resp.Error != nil { + t.Fatalf("expected tool error envelope, got %v", resp) + } + var tres toolResult + if err := json.Unmarshal(resp.Result, &tres); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !tres.IsError { + t.Errorf("expected isError=true") + } + if !strings.Contains(tres.Content[0].Text, "bridge init failed") { + t.Errorf("missing bridge init message: %q", tres.Content[0].Text) + } +} + +func TestCallHealthLazyClientError(t *testing.T) { + dir := setupTestHome(t) + if err := os.WriteFile(filepath.Join(dir, "vane.json"), []byte("bad"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + s := NewServerWithIO(strings.NewReader(""), &safeBuffer{}, &safeBuffer{}, dir) + id := json.RawMessage(`1`) + resp := s.callHealth(context.Background(), &jsonRPCRequest{ID: &id, Method: "tools/call", JSONRPC: "2.0"}) + if resp == nil || resp.Error != nil { + t.Fatalf("expected tool error envelope, got %v", resp) + } + var tres toolResult + if err := json.Unmarshal(resp.Result, &tres); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !tres.IsError { + t.Errorf("expected isError=true") + } + if !strings.Contains(tres.Content[0].Text, "bridge init failed") { + t.Errorf("missing bridge init message: %q", tres.Content[0].Text) + } +} + +func TestCallHealthHealthyError(t *testing.T) { + dir := setupTestHome(t) + srv := mockVane(t, http.StatusInternalServerError, "err", nil) + defer srv.Close() + if err := SaveConfig(Config{BaseURL: srv.URL, TimeoutSeconds: 2}); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + s := NewServerWithIO(strings.NewReader(""), &safeBuffer{}, &safeBuffer{}, dir) + id := json.RawMessage(`1`) + resp := s.callHealth(context.Background(), &jsonRPCRequest{ID: &id, Method: "tools/call", JSONRPC: "2.0"}) + if resp == nil || resp.Error != nil { + t.Fatalf("expected tool error envelope, got %v", resp) + } + var tres toolResult + if err := json.Unmarshal(resp.Result, &tres); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !tres.IsError { + t.Errorf("expected isError=true") + } + if !strings.Contains(tres.Content[0].Text, "vane_health:") { + t.Errorf("missing health error message: %q", tres.Content[0].Text) + } +} + +// ── lazyClient env restore ────────────────────────────────────────────── + +func TestLazyClientEnvRestoreWhenUnset(t *testing.T) { + dir := setupTestHome(t) + os.Unsetenv("SIN_CODE_HOME") + if err := SaveConfig(Config{BaseURL: "http://127.0.0.1:1", TimeoutSeconds: 1}); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + s := NewServerWithIO(strings.NewReader(""), &safeBuffer{}, &safeBuffer{}, dir) + _, err := s.lazyClient() + if err != nil { + t.Fatalf("lazyClient: %v", err) + } + if os.Getenv("SIN_CODE_HOME") != "" { + t.Errorf("SIN_CODE_HOME not restored to empty: %q", os.Getenv("SIN_CODE_HOME")) + } +} + +func TestLazyClientEnvRestoreWhenSet(t *testing.T) { + dir := setupTestHome(t) + other := dir + "-other" + os.Setenv("SIN_CODE_HOME", other) + if err := SaveConfig(Config{BaseURL: "http://127.0.0.1:1", TimeoutSeconds: 1}); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + s := NewServerWithIO(strings.NewReader(""), &safeBuffer{}, &safeBuffer{}, dir) + _, err := s.lazyClient() + if err != nil { + t.Fatalf("lazyClient: %v", err) + } + if os.Getenv("SIN_CODE_HOME") != other { + t.Errorf("SIN_CODE_HOME not restored: got %q want %q", os.Getenv("SIN_CODE_HOME"), other) + } +} + +// ── Unused helper ─────────────────────────────────────────────────────── + +func TestFormatIntBytes(t *testing.T) { + if got := formatIntBytes(123); got != "123B" { + t.Errorf("formatIntBytes(123) = %q want 123B", got) + } +} diff --git a/cmd/sin-code/internal/webui/server.go b/cmd/sin-code/internal/webui/server.go index 7a4dbcb9..74a69657 100644 --- a/cmd/sin-code/internal/webui/server.go +++ b/cmd/sin-code/internal/webui/server.go @@ -57,6 +57,38 @@ type Config struct { OpenBrowser bool } +// test hooks allow error-path coverage without heavy refactoring. +var ( + netListenHook = net.Listen + signalNotifyHook = signal.Notify + openBrowserHook = openInBrowser + userConfigDirHook = os.UserConfigDir + osTempDirHook = os.TempDir + goosHook = func() string { return runtime.GOOS } + lookPathHook = exec.LookPath + readDirHook = os.ReadDir + readFileHook = os.ReadFile + execCommandRunner = func(name string, args ...string) ([]byte, error) { + return exec.Command(name, args...).Output() // #nosec G204 + } + orchestratorRunFunc = func(ctx context.Context, prompt string) (*orchestrator.Result, error) { + return orchestrator.New().Run(ctx, prompt) + } + todoOpenHook = todo.Open + notifOpenHook = notifications.Open + todoListHook = func(s *todo.Store) ([]*todo.Todo, error) { return s.List() } + todoAddHook = func(s *todo.Store, t *todo.Todo) error { return s.Add(t) } + notifListHook = func(s *notifications.Store, filter notifications.ListFilter, limit int) ([]*notifications.Notification, error) { + return s.List(filter, limit) + } + templateCloneHook = func(t *template.Template) (*template.Template, error) { return t.Clone() } + templateParseHook = func(t *template.Template, text string) (*template.Template, error) { return t.Parse(text) } + templateExecHook = func(t *template.Template, wr io.Writer, name string, data interface{}) error { + return t.ExecuteTemplate(wr, name, data) + } + parseFSHook = func(t *template.Template, f fs.FS) (*template.Template, error) { return t.ParseFS(f, "*.html") } +) + func Start(port int) error { host := os.Getenv("SIN_CODE_WEBUI_HOST") if host == "" { @@ -174,13 +206,13 @@ func (s *Server) setupHealthChecks() { } func loadTemplates() (*template.Template, error) { - sub, err := fs.Sub(templateFS, "templates") + sub, err := templateFSSubHook() if err != nil { return nil, err } - tmpl, err := template.New("").Funcs(template.FuncMap{ - "safeHTML": func(s string) template.HTML { return template.HTML(s) }, - }).ParseFS(sub, "*.html") + tmpl, err := parseFSHook(template.New("").Funcs(template.FuncMap{ + "safeHTML": func(s string) template.HTML { return template.HTML(s) }, // #nosec G203 + }), sub) if err != nil { return nil, fmt.Errorf("parse templates: %w", err) } @@ -223,17 +255,17 @@ func (s *Server) render(w http.ResponseWriter, name string, data pageData) { http.Error(w, "template not found: "+name, http.StatusInternalServerError) return } - cloned, err := s.templates.Clone() + cloned, err := templateCloneHook(s.templates) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - if _, err := cloned.Parse(string(bodyRaw)); err != nil { + if _, err := templateParseHook(cloned, string(bodyRaw)); err != nil { http.Error(w, "parse "+name+": "+err.Error(), http.StatusInternalServerError) return } var buf bytes.Buffer - if err := cloned.ExecuteTemplate(&buf, "base", data); err != nil { + if err := templateExecHook(cloned, &buf, "base", data); err != nil { http.Error(w, "render: "+err.Error(), http.StatusInternalServerError) return } @@ -275,8 +307,7 @@ func (s *Server) handleOrchestratorRun(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() - o := orchestrator.New() - res, err := o.Run(ctx, prompt) + res, err := orchestratorRunFunc(ctx, prompt) agents := defaultAgentConfigs() data := pageData{ @@ -296,7 +327,7 @@ func (s *Server) handleOrchestratorRun(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleTodosPage(w http.ResponseWriter, r *http.Request) { - store, err := todo.Open(s.todoDB) + store, err := todoOpenHook(s.todoDB) if err != nil { s.render(w, "todos.html", pageData{ Title: "Todos", @@ -306,7 +337,7 @@ func (s *Server) handleTodosPage(w http.ResponseWriter, r *http.Request) { return } defer store.Close() - ts, err := store.List() + ts, err := todoListHook(store) if err != nil { s.render(w, "todos.html", pageData{ Title: "Todos", @@ -353,7 +384,7 @@ func (s *Server) handleTodosAdd(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/todos?err=title_required", http.StatusSeeOther) return } - store, err := todo.Open(s.todoDB) + store, err := todoOpenHook(s.todoDB) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -372,7 +403,7 @@ func (s *Server) handleTodosAdd(w http.ResponseWriter, r *http.Request) { if t.Type == "" { t.Type = todo.TypeTask } - if err := store.Add(t); err != nil { + if err := todoAddHook(store, t); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -381,7 +412,7 @@ func (s *Server) handleTodosAdd(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTodoDetail(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - store, err := todo.Open(s.todoDB) + store, err := todoOpenHook(s.todoDB) if err != nil { s.render(w, "todo_detail.html", pageData{ Title: "Todo " + id, @@ -412,7 +443,7 @@ func (s *Server) handleTodoDetail(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleNotificationsPage(w http.ResponseWriter, r *http.Request) { - store, err := notifications.Open(s.notifDB) + store, err := notifOpenHook(s.notifDB) if err != nil { s.render(w, "notifications.html", pageData{ Title: "Notifications", @@ -422,7 +453,7 @@ func (s *Server) handleNotificationsPage(w http.ResponseWriter, r *http.Request) return } defer store.Close() - ns, err := store.List(notifications.ListFilter{NotDismissed: true}, 100) + ns, err := notifListHook(store, notifications.ListFilter{NotDismissed: true}, 100) if err != nil { s.render(w, "notifications.html", pageData{ Title: "Notifications", @@ -496,13 +527,13 @@ func (s *Server) handleAgentsJSON(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleNotificationsJSON(w http.ResponseWriter, r *http.Request) { - store, err := notifications.Open(s.notifDB) + store, err := notifOpenHook(s.notifDB) if err != nil { writeJSONError(w, http.StatusInternalServerError, err) return } defer store.Close() - ns, err := store.List(notifications.ListFilter{}, 0) + ns, err := notifListHook(store, notifications.ListFilter{}, 0) if err != nil { writeJSONError(w, http.StatusInternalServerError, err) return @@ -514,13 +545,13 @@ func (s *Server) handleNotificationsJSON(w http.ResponseWriter, r *http.Request) } func (s *Server) handleTodosJSON(w http.ResponseWriter, r *http.Request) { - store, err := todo.Open(s.todoDB) + store, err := todoOpenHook(s.todoDB) if err != nil { writeJSONError(w, http.StatusInternalServerError, err) return } defer store.Close() - ts, err := store.List() + ts, err := todoListHook(store) if err != nil { writeJSONError(w, http.StatusInternalServerError, err) return @@ -552,7 +583,7 @@ func defaultTodoDB() string { if env := os.Getenv("SIN_CODE_TODO_DB"); env != "" { return env } - cfg, err := os.UserConfigDir() + cfg, err := userConfigDirHook() if err != nil { return "todo.db" } @@ -563,7 +594,7 @@ func defaultNotifDB() string { if env := os.Getenv("SIN_CODE_NOTIF_DB"); env != "" { return env } - cfg, err := os.UserConfigDir() + cfg, err := userConfigDirHook() if err != nil { return "notifications.db" } @@ -574,9 +605,9 @@ func efmMetaDir() string { if home := os.Getenv("HOME"); home != "" { return filepath.Join(home, ".local", "state", "sin-code", "efm") } - cfg, err := os.UserConfigDir() + cfg, err := userConfigDirHook() if err != nil { - return filepath.Join(os.TempDir(), "sin-code-efm") + return filepath.Join(osTempDirHook(), "sin-code-efm") } return filepath.Join(cfg, "sin-code", "efm") } @@ -587,12 +618,12 @@ func efmMetaKey(stackPath string) string { } func detectContainerRuntime() string { - if runtime.GOOS == "darwin" { - if _, err := exec.LookPath("orb"); err == nil { + if goosHook() == "darwin" { + if _, err := lookPathHook("orb"); err == nil { return "orb" } } - if _, err := exec.LookPath("docker"); err == nil { + if _, err := lookPathHook("docker"); err == nil { return "docker" } return "" @@ -601,7 +632,7 @@ func detectContainerRuntime() string { func discoverEfmStacks() ([]efmStack, string, error) { rt := detectContainerRuntime() dir := efmMetaDir() - entries, err := os.ReadDir(dir) + entries, err := readDirHook(dir) if err != nil { if os.IsNotExist(err) { return []efmStack{}, rt, nil @@ -613,7 +644,7 @@ func discoverEfmStacks() ([]efmStack, string, error) { if e.IsDir() || !strings.HasSuffix(e.Name(), ".meta") { continue } - raw, err := os.ReadFile(filepath.Join(dir, e.Name())) + raw, err := readFileHook(filepath.Join(dir, e.Name())) if err != nil { continue } @@ -625,8 +656,7 @@ func discoverEfmStacks() ([]efmStack, string, error) { name := strings.TrimSuffix(filepath.Base(stackPath), filepath.Ext(stackPath)) status := "unknown" if rt != "" { - cmd := exec.Command(rt, "ps", "-a", "--filter", "label=com.docker.compose.project="+name, "--format", "{{.Status}}") - outBytes, _ := cmd.Output() + outBytes, _ := execCommandRunner(rt, "ps", "-a", "--filter", "label=com.docker.compose.project="+name, "--format", "{{.Status}}") running := strings.Contains(string(outBytes), "Up") if running { status = "running" @@ -659,7 +689,7 @@ func discoverEfmStacks() ([]efmStack, string, error) { } func (s *Server) ListenAndServe() error { - ln, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.host, s.port)) + ln, err := netListenHook("tcp", fmt.Sprintf("%s:%d", s.host, s.port)) if err != nil { return err } @@ -674,7 +704,7 @@ func (s *Server) ListenAndServe() error { if s.openBrowser { go func() { time.Sleep(200 * time.Millisecond) - _ = openInBrowser("http://" + s.addr_) + _ = openBrowserHook("http://" + s.addr_) }() } @@ -684,7 +714,7 @@ func (s *Server) ListenAndServe() error { }() stop := make(chan os.Signal, 1) - signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + signalNotifyHook(stop, os.Interrupt, syscall.SIGTERM) select { case err := <-errCh: if errors.Is(err, http.ErrServerClosed) { @@ -700,13 +730,13 @@ func (s *Server) ListenAndServe() error { func openInBrowser(target string) error { var cmd *exec.Cmd - switch runtime.GOOS { + switch goosHook() { case "darwin": - cmd = exec.Command("open", target) + cmd = exec.Command("open", target) // #nosec G204 — opens validated user URL in browser case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", target) + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", target) // #nosec G204 — opens validated user URL in browser default: - cmd = exec.Command("xdg-open", target) + cmd = exec.Command("xdg-open", target) // #nosec G204 — opens validated user URL in browser } cmd.Stdout = io.Discard cmd.Stderr = io.Discard diff --git a/cmd/sin-code/internal/webui/server_coverage_test.go b/cmd/sin-code/internal/webui/server_coverage_test.go new file mode 100644 index 00000000..4f4e1cba --- /dev/null +++ b/cmd/sin-code/internal/webui/server_coverage_test.go @@ -0,0 +1,803 @@ +// SPDX-License-Identifier: MIT +// Purpose: targeted coverage tests for webui error paths and branches. +package webui + +import ( + "bytes" + "context" + "errors" + "html/template" + "io" + "io/fs" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/health" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/notifications" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/orchestrator" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/todo" +) + +func setHook[T any](t *testing.T, hook *T, val T) { + old := *hook + *hook = val + t.Cleanup(func() { *hook = old }) +} + +type fakeDirEntry struct { + name string + isDir bool +} + +func (f fakeDirEntry) Name() string { return f.name } +func (f fakeDirEntry) IsDir() bool { return f.isDir } +func (f fakeDirEntry) Type() os.FileMode { return 0 } +func (f fakeDirEntry) Info() (os.FileInfo, error) { return nil, errors.New("no info") } + +type fakeListener struct { + addr net.Addr + err error +} + +func (f *fakeListener) Accept() (net.Conn, error) { return nil, f.err } +func (f *fakeListener) Close() error { return nil } +func (f *fakeListener) Addr() net.Addr { return f.addr } + +func TestStartEnvHost(t *testing.T) { + t.Setenv("SIN_CODE_WEBUI_HOST", "127.0.0.1") + setHook(t, &netListenHook, func(network, address string) (net.Listener, error) { + return nil, errors.New("listen boom") + }) + if err := Start(0); err == nil { + t.Fatal("expected error") + } +} + +func TestStartDefaultHost(t *testing.T) { + t.Setenv("SIN_CODE_WEBUI_HOST", "") + setHook(t, &netListenHook, func(network, address string) (net.Listener, error) { + return nil, errors.New("listen boom") + }) + if err := Start(0); err == nil { + t.Fatal("expected error") + } +} + +func TestStartWithNewServerError(t *testing.T) { + setHook(t, &templateFSSubHook, func() (fs.FS, error) { return nil, errors.New("boom") }) + if err := StartWith(Config{Port: 0}); err == nil { + t.Fatal("expected error") + } +} + +func TestStartWithListenError(t *testing.T) { + setHook(t, &netListenHook, func(network, address string) (net.Listener, error) { + return nil, errors.New("listen boom") + }) + if err := StartWith(Config{Port: 0}); err == nil { + t.Fatal("expected error") + } +} + +func TestNewServerLoadTemplatesError(t *testing.T) { + setHook(t, &templateFSSubHook, func() (fs.FS, error) { return nil, errors.New("boom") }) + _, err := NewServer(Config{Port: 0}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestAddrWithAddrSet(t *testing.T) { + srv, err := NewServer(Config{Port: 0}) + if err != nil { + t.Fatal(err) + } + srv.addr_ = "custom:123" + if got := srv.Addr(); got != "custom:123" { + t.Fatalf("Addr = %q", got) + } +} + +func TestHealthCheckTemplateNil(t *testing.T) { + srv := &Server{health: health.NewChecker("webui")} + srv.templates = nil + srv.setupHealthChecks() + resp := srv.health.Check(context.Background()) + if got := resp.Checks["templates"].Status; got != health.StatusUnhealthy { + t.Fatalf("templates status = %q", got) + } +} + +func TestHealthCheckTodoDBEmpty(t *testing.T) { + srv := &Server{health: health.NewChecker("webui"), todoDB: ""} + srv.setupHealthChecks() + resp := srv.health.Check(context.Background()) + if got := resp.Checks["todo_db"].Status; got != health.StatusDegraded { + t.Fatalf("todo_db status = %q", got) + } +} + +func TestHealthChecksHealthy(t *testing.T) { + srv, err := NewServer(Config{Port: 0, TodoDB: filepath.Join(t.TempDir(), "todo.db")}) + if err != nil { + t.Fatal(err) + } + resp := srv.health.Check(context.Background()) + if got := resp.Checks["templates"].Status; got != health.StatusHealthy { + t.Fatalf("templates status = %q", got) + } + if got := resp.Checks["todo_db"].Status; got != health.StatusHealthy { + t.Fatalf("todo_db status = %q", got) + } +} + +func TestLoadTemplatesSubError(t *testing.T) { + setHook(t, &templateFSSubHook, func() (fs.FS, error) { return nil, errors.New("boom") }) + _, err := loadTemplates() + if err == nil { + t.Fatal("expected error") + } +} + +func TestLoadTemplatesParseError(t *testing.T) { + setHook(t, &parseFSHook, func(t *template.Template, f fs.FS) (*template.Template, error) { + return nil, errors.New("boom") + }) + _, err := loadTemplates() + if err == nil { + t.Fatal("expected error") + } +} + +func TestSafeHTMLFunc(t *testing.T) { + tmpl, err := loadTemplates() + if err != nil { + t.Fatal(err) + } + tmpl, err = tmpl.New("safe").Parse(`{{ safeHTML "hi" }}`) + if err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "safe", nil); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != "hi" { + t.Fatalf("safeHTML output = %q", got) + } +} + +func TestRenderTemplateNotFound(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + rr := httptest.NewRecorder() + srv.render(rr, "missing.html", pageData{}) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestRenderCloneError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + setHook(t, &templateCloneHook, func(t *template.Template) (*template.Template, error) { + return nil, errors.New("boom") + }) + rr := httptest.NewRecorder() + srv.render(rr, "index.html", pageData{}) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestRenderParseError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + setHook(t, &templateParseHook, func(t *template.Template, text string) (*template.Template, error) { + return nil, errors.New("boom") + }) + rr := httptest.NewRecorder() + srv.render(rr, "index.html", pageData{}) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestRenderExecuteError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + setHook(t, &templateExecHook, func(t *template.Template, wr io.Writer, name string, data interface{}) error { + return errors.New("boom") + }) + rr := httptest.NewRecorder() + srv.render(rr, "index.html", pageData{}) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestOrchestratorRunBadForm(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/orchestrator/run", strings.NewReader("%")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + srv.handleOrchestratorRun(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestOrchestratorRunError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + setHook(t, &orchestratorRunFunc, func(ctx context.Context, prompt string) (*orchestrator.Result, error) { + return nil, errors.New("boom") + }) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/orchestrator/run", strings.NewReader("prompt=hello")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + srv.handleOrchestratorRun(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "boom") { + t.Fatal("expected error in body") + } +} + +func TestTodosPageOpenError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + setHook(t, &todoOpenHook, func(path string) (*todo.Store, error) { return nil, errors.New("boom") }) + rr := httptest.NewRecorder() + srv.handleTodosPage(rr, httptest.NewRequest(http.MethodGet, "/todos", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "boom") { + t.Fatal("expected error in body") + } +} + +func TestTodosPageListError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0, TodoDB: filepath.Join(t.TempDir(), "todo.db")}) + setHook(t, &todoListHook, func(s *todo.Store) ([]*todo.Todo, error) { return nil, errors.New("boom") }) + rr := httptest.NewRecorder() + srv.handleTodosPage(rr, httptest.NewRequest(http.MethodGet, "/todos", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "boom") { + t.Fatal("expected error in body") + } +} + +func TestTodosPageDoneStatus(t *testing.T) { + db := filepath.Join(t.TempDir(), "todo.db") + srv, _ := NewServer(Config{Port: 0, TodoDB: db}) + store, err := todo.Open(db) + if err != nil { + t.Fatal(err) + } + _ = store.Add(&todo.Todo{Title: "Done", Status: todo.StatusDone, Priority: todo.PriorityP2, Type: todo.TypeTask}) + store.Close() + + rr := httptest.NewRecorder() + srv.handleTodosPage(rr, httptest.NewRequest(http.MethodGet, "/todos", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestTodosAddBadForm(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/todos/add", strings.NewReader("%")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + srv.handleTodosAdd(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestTodosAddEmptyTitle(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/todos/add", strings.NewReader("title=")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + srv.handleTodosAdd(rr, req) + if rr.Code != http.StatusSeeOther { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestTodosAddOpenError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + setHook(t, &todoOpenHook, func(path string) (*todo.Store, error) { return nil, errors.New("boom") }) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/todos/add", strings.NewReader("title=hi")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + srv.handleTodosAdd(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestTodosAddDefaultsAndAddError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0, TodoDB: filepath.Join(t.TempDir(), "todo.db")}) + setHook(t, &todoAddHook, func(s *todo.Store, t *todo.Todo) error { return errors.New("boom") }) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/todos/add", strings.NewReader("title=hi")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + srv.handleTodosAdd(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "boom") { + t.Fatal("expected error in body") + } +} + +func TestTodoDetailOpenError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + setHook(t, &todoOpenHook, func(path string) (*todo.Store, error) { return nil, errors.New("boom") }) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/todos/x", nil) + srv.handleTodoDetail(rr, req) + if rr.Code == 0 { + t.Fatal("no response") + } +} + +func TestTodoDetailNotFound(t *testing.T) { + srv, _ := NewServer(Config{Port: 0, TodoDB: filepath.Join(t.TempDir(), "todo.db")}) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/todos/missing", nil) + srv.handleTodoDetail(rr, req) + if rr.Code == 0 { + t.Fatal("no response") + } +} + +func TestNotificationsPageOpenError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + setHook(t, ¬ifOpenHook, func(path string) (*notifications.Store, error) { return nil, errors.New("boom") }) + rr := httptest.NewRecorder() + srv.handleNotificationsPage(rr, httptest.NewRequest(http.MethodGet, "/notifications", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestNotificationsPageListError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0, NotifDB: filepath.Join(t.TempDir(), "notif.db")}) + setHook(t, ¬ifListHook, func(s *notifications.Store, f notifications.ListFilter, limit int) ([]*notifications.Notification, error) { + return nil, errors.New("boom") + }) + rr := httptest.NewRecorder() + srv.handleNotificationsPage(rr, httptest.NewRequest(http.MethodGet, "/notifications", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestNotificationsPageUnread(t *testing.T) { + db := filepath.Join(t.TempDir(), "notif.db") + srv, _ := NewServer(Config{Port: 0, NotifDB: db}) + store, err := notifications.Open(db) + if err != nil { + t.Fatal(err) + } + _ = store.Add(¬ifications.Notification{Type: notifications.TypeTodoCreated, TodoID: "x", Title: "hi"}) + store.Close() + + rr := httptest.NewRecorder() + srv.handleNotificationsPage(rr, httptest.NewRequest(http.MethodGet, "/notifications", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "Notifications") { + t.Fatal("expected Notifications heading") + } +} + +func TestEfmPageError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + setHook(t, &readDirHook, func(string) ([]os.DirEntry, error) { return nil, errors.New("boom") }) + rr := httptest.NewRecorder() + srv.handleEfmPage(rr, httptest.NewRequest(http.MethodGet, "/efm", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "boom") { + t.Fatal("expected error in body") + } +} + +func TestEfmDetailError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + setHook(t, &readDirHook, func(string) ([]os.DirEntry, error) { return nil, errors.New("boom") }) + rr := httptest.NewRecorder() + srv.handleEfmDetail(rr, httptest.NewRequest(http.MethodGet, "/efm/x", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "boom") { + t.Fatal("expected error in body") + } +} + +func TestEfmDetailMatchName(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + metaDir := filepath.Join(dir, ".local", "state", "sin-code", "efm") + if err := os.MkdirAll(metaDir, 0o755); err != nil { + t.Fatal(err) + } + stackPath := "/tmp/somestack.yml" + meta := `{"stack":"/tmp/somestack.yml","started":"` + time.Now().Format(time.RFC3339) + `","expires":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `","runtime":"docker"}` + if err := os.WriteFile(filepath.Join(metaDir, efmMetaKey(stackPath)), []byte(meta), 0o644); err != nil { + t.Fatal(err) + } + + srv, _ := NewServer(Config{Port: 0}) + setHook(t, &execCommandRunner, func(name string, args ...string) ([]byte, error) { return nil, nil }) + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/efm/somestack") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "somestack") { + t.Fatalf("body = %s", string(body)) + } +} + +func TestNotificationsJSONOpenError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + setHook(t, ¬ifOpenHook, func(path string) (*notifications.Store, error) { return nil, errors.New("boom") }) + rr := httptest.NewRecorder() + srv.handleNotificationsJSON(rr, httptest.NewRequest(http.MethodGet, "/api/notifications.json", nil)) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestNotificationsJSONListError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0, NotifDB: filepath.Join(t.TempDir(), "notif.db")}) + setHook(t, ¬ifListHook, func(s *notifications.Store, f notifications.ListFilter, limit int) ([]*notifications.Notification, error) { + return nil, errors.New("boom") + }) + rr := httptest.NewRecorder() + srv.handleNotificationsJSON(rr, httptest.NewRequest(http.MethodGet, "/api/notifications.json", nil)) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestNotificationsJSONNil(t *testing.T) { + srv, _ := NewServer(Config{Port: 0, NotifDB: filepath.Join(t.TempDir(), "notif.db")}) + setHook(t, ¬ifListHook, func(s *notifications.Store, f notifications.ListFilter, limit int) ([]*notifications.Notification, error) { + return nil, nil + }) + rr := httptest.NewRecorder() + srv.handleNotificationsJSON(rr, httptest.NewRequest(http.MethodGet, "/api/notifications.json", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + if strings.TrimSpace(rr.Body.String()) != "[]" { + t.Fatalf("body = %q", rr.Body.String()) + } +} + +func TestTodosJSONOpenError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0}) + setHook(t, &todoOpenHook, func(path string) (*todo.Store, error) { return nil, errors.New("boom") }) + rr := httptest.NewRecorder() + srv.handleTodosJSON(rr, httptest.NewRequest(http.MethodGet, "/api/todos.json", nil)) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestTodosJSONListError(t *testing.T) { + srv, _ := NewServer(Config{Port: 0, TodoDB: filepath.Join(t.TempDir(), "todo.db")}) + setHook(t, &todoListHook, func(s *todo.Store) ([]*todo.Todo, error) { return nil, errors.New("boom") }) + rr := httptest.NewRecorder() + srv.handleTodosJSON(rr, httptest.NewRequest(http.MethodGet, "/api/todos.json", nil)) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d", rr.Code) + } +} + +func TestTodosJSONNil(t *testing.T) { + srv, _ := NewServer(Config{Port: 0, TodoDB: filepath.Join(t.TempDir(), "todo.db")}) + setHook(t, &todoListHook, func(s *todo.Store) ([]*todo.Todo, error) { return nil, nil }) + rr := httptest.NewRecorder() + srv.handleTodosJSON(rr, httptest.NewRequest(http.MethodGet, "/api/todos.json", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + if strings.TrimSpace(rr.Body.String()) != "[]" { + t.Fatalf("body = %q", rr.Body.String()) + } +} + +func TestDefaultTodoDBEnv(t *testing.T) { + t.Setenv("SIN_CODE_TODO_DB", "/custom/todo.db") + if got := defaultTodoDB(); got != "/custom/todo.db" { + t.Fatalf("defaultTodoDB = %q", got) + } +} + +func TestDefaultTodoDBConfigError(t *testing.T) { + t.Setenv("SIN_CODE_TODO_DB", "") + setHook(t, &userConfigDirHook, func() (string, error) { return "", errors.New("boom") }) + if got := defaultTodoDB(); got != "todo.db" { + t.Fatalf("defaultTodoDB = %q", got) + } +} + +func TestDefaultNotifDBEnv(t *testing.T) { + t.Setenv("SIN_CODE_NOTIF_DB", "/custom/notif.db") + if got := defaultNotifDB(); got != "/custom/notif.db" { + t.Fatalf("defaultNotifDB = %q", got) + } +} + +func TestDefaultNotifDBConfigError(t *testing.T) { + t.Setenv("SIN_CODE_NOTIF_DB", "") + setHook(t, &userConfigDirHook, func() (string, error) { return "", errors.New("boom") }) + if got := defaultNotifDB(); got != "notifications.db" { + t.Fatalf("defaultNotifDB = %q", got) + } +} + +func TestEfmMetaDirUserConfig(t *testing.T) { + t.Setenv("HOME", "") + tmp := t.TempDir() + setHook(t, &userConfigDirHook, func() (string, error) { return tmp, nil }) + if got := efmMetaDir(); got != filepath.Join(tmp, "sin-code", "efm") { + t.Fatalf("efmMetaDir = %q", got) + } +} + +func TestEfmMetaDirFallback(t *testing.T) { + t.Setenv("HOME", "") + tmp := t.TempDir() + setHook(t, &userConfigDirHook, func() (string, error) { return "", errors.New("boom") }) + setHook(t, &osTempDirHook, func() string { return tmp }) + if got := efmMetaDir(); got != filepath.Join(tmp, "sin-code-efm") { + t.Fatalf("efmMetaDir = %q", got) + } +} + +func TestDetectContainerRuntimeLinux(t *testing.T) { + setHook(t, &goosHook, func() string { return "linux" }) + setHook(t, &lookPathHook, func(string) (string, error) { return "/usr/bin/docker", nil }) + if got := detectContainerRuntime(); got != "docker" { + t.Fatalf("runtime = %q", got) + } +} + +func TestDetectContainerRuntimeLinuxNone(t *testing.T) { + setHook(t, &goosHook, func() string { return "linux" }) + setHook(t, &lookPathHook, func(string) (string, error) { return "", errors.New("not found") }) + if got := detectContainerRuntime(); got != "" { + t.Fatalf("runtime = %q", got) + } +} + +func TestDiscoverEfmStacksReadDirError(t *testing.T) { + setHook(t, &readDirHook, func(string) ([]os.DirEntry, error) { return nil, errors.New("boom") }) + _, _, err := discoverEfmStacks() + if err == nil { + t.Fatal("expected error") + } +} + +func TestDiscoverEfmStacksSkipEntries(t *testing.T) { + setHook(t, &readDirHook, func(string) ([]os.DirEntry, error) { + return []os.DirEntry{fakeDirEntry{name: "dir", isDir: true}, fakeDirEntry{name: "file.txt", isDir: false}}, nil + }) + stacks, _, err := discoverEfmStacks() + if err != nil { + t.Fatal(err) + } + if len(stacks) != 0 { + t.Fatalf("expected 0 stacks, got %d", len(stacks)) + } +} + +func TestDiscoverEfmStacksReadFileError(t *testing.T) { + setHook(t, &readDirHook, func(string) ([]os.DirEntry, error) { + return []os.DirEntry{fakeDirEntry{name: "abc.meta", isDir: false}}, nil + }) + setHook(t, &readFileHook, func(string) ([]byte, error) { return nil, errors.New("boom") }) + stacks, _, err := discoverEfmStacks() + if err != nil { + t.Fatal(err) + } + if len(stacks) != 0 { + t.Fatalf("expected 0 stacks, got %d", len(stacks)) + } +} + +func TestDiscoverEfmStacksInvalidJSON(t *testing.T) { + setHook(t, &readDirHook, func(string) ([]os.DirEntry, error) { + return []os.DirEntry{fakeDirEntry{name: "abc.meta", isDir: false}}, nil + }) + setHook(t, &readFileHook, func(string) ([]byte, error) { return []byte("not json"), nil }) + stacks, _, err := discoverEfmStacks() + if err != nil { + t.Fatal(err) + } + if len(stacks) != 0 { + t.Fatalf("expected 0 stacks, got %d", len(stacks)) + } +} + +func TestDiscoverEfmStacksRunning(t *testing.T) { + setHook(t, &readDirHook, func(string) ([]os.DirEntry, error) { + return []os.DirEntry{fakeDirEntry{name: "abc.meta", isDir: false}}, nil + }) + setHook(t, &readFileHook, func(string) ([]byte, error) { + return []byte(`{"stack":"/tmp/abc.yml","started":"` + time.Now().Format(time.RFC3339) + `","expires":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `","runtime":"docker"}`), nil + }) + setHook(t, &goosHook, func() string { return "linux" }) + setHook(t, &lookPathHook, func(string) (string, error) { return "/usr/bin/docker", nil }) + setHook(t, &execCommandRunner, func(name string, args ...string) ([]byte, error) { return []byte("Up 5 minutes"), nil }) + stacks, _, err := discoverEfmStacks() + if err != nil { + t.Fatal(err) + } + if len(stacks) != 1 { + t.Fatalf("expected 1 stack, got %d", len(stacks)) + } + if stacks[0].Status != "running" { + t.Fatalf("status = %q", stacks[0].Status) + } +} + +func TestDiscoverEfmStacksEmptyDir(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + if err := os.MkdirAll(filepath.Join(dir, ".local", "state", "sin-code", "efm"), 0o755); err != nil { + t.Fatal(err) + } + stacks, _, err := discoverEfmStacks() + if err != nil { + t.Fatal(err) + } + if stacks == nil { + t.Fatal("expected non-nil empty slice") + } + if len(stacks) != 0 { + t.Fatalf("expected 0 stacks, got %d", len(stacks)) + } +} + +func TestListenAndServeListenError(t *testing.T) { + srv, err := NewServer(Config{Host: "127.0.0.1", Port: 0}) + if err != nil { + t.Fatal(err) + } + setHook(t, &netListenHook, func(network, address string) (net.Listener, error) { + return nil, errors.New("listen boom") + }) + if err := srv.ListenAndServe(); err == nil { + t.Fatal("expected error") + } +} + +func TestListenAndServeOpenBrowser(t *testing.T) { + srv, err := NewServer(Config{Host: "127.0.0.1", Port: 0, OpenBrowser: true}) + if err != nil { + t.Fatal(err) + } + urlCh := make(chan string, 1) + setHook(t, &openBrowserHook, func(target string) error { + urlCh <- target + return nil + }) + setHook(t, &netListenHook, netListenHook) + + done := make(chan error, 1) + go func() { done <- srv.ListenAndServe() }() + + select { + case target := <-urlCh: + if target == "" { + t.Fatal("empty target") + } + case <-time.After(time.Second): + t.Fatal("openBrowser not called") + } + + time.Sleep(50 * time.Millisecond) + _ = srv.httpServer.Close() + select { + case err := <-done: + if err != nil { + t.Fatalf("ListenAndServe: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout") + } +} + +func TestListenAndServeServeError(t *testing.T) { + srv, err := NewServer(Config{Host: "127.0.0.1", Port: 0}) + if err != nil { + t.Fatal(err) + } + setHook(t, &netListenHook, func(network, address string) (net.Listener, error) { + return &fakeListener{ + addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}, + err: errors.New("accept boom"), + }, nil + }) + if err := srv.ListenAndServe(); err == nil { + t.Fatal("expected error") + } +} + +func TestListenAndServeStopSignal(t *testing.T) { + srv, err := NewServer(Config{Host: "127.0.0.1", Port: 0}) + if err != nil { + t.Fatal(err) + } + setHook(t, &signalNotifyHook, func(c chan<- os.Signal, sigs ...os.Signal) { + go func() { + time.Sleep(50 * time.Millisecond) + c <- os.Interrupt + }() + }) + done := make(chan error, 1) + go func() { done <- srv.ListenAndServe() }() + select { + case err := <-done: + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout") + } +} + +func TestOpenInBrowserWindows(t *testing.T) { + setHook(t, &goosHook, func() string { return "windows" }) + _ = openInBrowser("http://127.0.0.1:1") +} + +func TestOpenInBrowserLinux(t *testing.T) { + setHook(t, &goosHook, func() string { return "linux" }) + _ = openInBrowser("http://127.0.0.1:1") +} + +func TestTemplateSubPanic(t *testing.T) { + setHook(t, &templateFSSubHook, func() (fs.FS, error) { return nil, errors.New("boom") }) + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic") + } + }() + _ = templateSub() +} + +func TestStaticSubPanic(t *testing.T) { + setHook(t, &staticFSSubHook, func() (fs.FS, error) { return nil, errors.New("boom") }) + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic") + } + }() + _ = staticSub() +} diff --git a/cmd/sin-code/internal/webui/templates.go b/cmd/sin-code/internal/webui/templates.go index 41dd5be6..4193041e 100644 --- a/cmd/sin-code/internal/webui/templates.go +++ b/cmd/sin-code/internal/webui/templates.go @@ -13,8 +13,14 @@ var templateFS embed.FS //go:embed static/* var staticFS embed.FS +var ( + // test hooks for error paths that are impossible to hit with the embedded FS. + templateFSSubHook = func() (fs.FS, error) { return fs.Sub(templateFS, "templates") } + staticFSSubHook = func() (fs.FS, error) { return fs.Sub(staticFS, "static") } +) + func templateSub() fs.FS { - sub, err := fs.Sub(templateFS, "templates") + sub, err := templateFSSubHook() if err != nil { panic(err) } @@ -22,7 +28,7 @@ func templateSub() fs.FS { } func staticSub() fs.FS { - sub, err := fs.Sub(staticFS, "static") + sub, err := staticFSSubHook() if err != nil { panic(err) } diff --git a/cmd/sin-code/main.go b/cmd/sin-code/main.go index 076c7a1f..e3c01823 100644 --- a/cmd/sin-code/main.go +++ b/cmd/sin-code/main.go @@ -81,6 +81,7 @@ func init() { NewGoalCmd(), NewDaemonCmd(), NewSkillCmd(), NewSwarmCmd(), NewSuperpowersCmd(), NewDoxCmd(), NewVaneCmd(), NewStackCmd(), NewGhCmd(), NewHubCmd(), NewLedgerCmd(), NewSummaryCmd(), NewAutodevCmd(), // v3.4.0 + v3.5.0 + v3.6.0 + v3.7.0 + v3.8.0 + v3.9.0 + v3.12.0 + v3.13.0 + autodev-bridge (Python MIT v0.4.0, stdio MCP via autodev-mcp) + NewSkillsCmd(), // bundled project-local agent skills NewEvalCmd(), NewTraceCmd(), // v3.18.0: Eval + Observability System (issue #75) NewRtkCmd(), // rtk (Rust Token Killer) bridge (issue #123) NewCodeGraphCmd(), // CodeGraph multi-language analysis bridge (issue #126) diff --git a/cmd/sin-code/skills_cmd.go b/cmd/sin-code/skills_cmd.go new file mode 100644 index 00000000..ddc7f1a2 --- /dev/null +++ b/cmd/sin-code/skills_cmd.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +// Purpose: `sin-code skills` — list and install bundled project-local skills. +// Uses github.com/Songmu/skillsmith on the embedded skills.SkillsFS. +// Docs: cmd/sin-code/skills_cmd.go.doc.md +package main + +import ( + "encoding/json" + "fmt" + "io/fs" + + "github.com/spf13/cobra" + + "github.com/Songmu/skillsmith" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal" + "github.com/OpenSIN-Code/SIN-Code/skills" +) + +// skillsVersionHook is overridden by tests to avoid depending on the real +// build-time Version value. +var skillsVersionHook = func() string { return internal.Version } + +// skillsNewSmithHook is overridden by tests to inject a fake skillsmith.Smith. +var skillsNewSmithHook = func(name, version string, fs fs.FS) (*skillsmith.Smith, error) { + return skillsmith.New(name, version, fs) +} + +// skillsInstallDirHook is overridden by tests to use a temporary directory. +var skillsInstallDirHook = skillsmith.InstallDirForScope + +func resolveSkillsVersion() string { + v := skillsVersionHook() + if v == "" || v == "dev" { + return "v0.0.0-dev" + } + return v +} + +func NewSkillsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "skills", + Short: "List and install bundled project-local skills", + Long: `The skills subcommand discovers the agent skills bundled in the +sin-code binary and can install them into the user's agent config directory +(typically ~/.claude/skills/ or ~/.agents/skills/).`, + } + + var jsonOut bool + listCmd := &cobra.Command{ + Use: "list", + Short: "List bundled skills", + RunE: func(cmd *cobra.Command, args []string) error { + listFS, err := skills.ListFS() + if err != nil { + return err + } + smith, err := skillsNewSmithHook("sin-code", resolveSkillsVersion(), listFS) + if err != nil { + return err + } + skillList, err := smith.List(cmd.Context()) + if err != nil { + return err + } + if jsonOut { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(skillList) + } + for _, s := range skillList { + cmd.Printf("%-30s %s\n", s.Name, s.Description) + } + return nil + }, + } + listCmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON") + + var dryRun, force bool + var scope string + installCmd := &cobra.Command{ + Use: "install ", + Short: "Install a bundled skill into the agent skills directory", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + listFS, err := skills.ListFS() + if err != nil { + return err + } + smith, err := skillsNewSmithHook("sin-code", resolveSkillsVersion(), listFS) + if err != nil { + return err + } + destDir, err := skillsInstallDirHook(scope) + if err != nil { + return err + } + res, err := smith.Install(cmd.Context(), skillsmith.Options{ + Prefix: args[0], + Scope: scope, + DryRun: dryRun, + Force: force, + }) + if err != nil { + return err + } + installed := res.Installed() + cmd.Printf("installed %d skill(s) to %s\n", len(installed), destDir) + for _, action := range installed { + cmd.Printf(" - %s\n", action.Dir) + } + return nil + }, + } + installCmd.Flags().BoolVar(&dryRun, "dry-run", false, "simulate install") + installCmd.Flags().BoolVar(&force, "force", false, "overwrite existing install") + installCmd.Flags().StringVar(&scope, "scope", "claude", "agent scope (claude, agents, etc.)") + + cmd.AddCommand(listCmd, installCmd) + return cmd +} + +// _unusedSkillsImport prevents accidental removal of the skills import by goimports. +var _unusedSkillsImport = skills.SkillsFS + +// _unusedFormat prevents fmt import removal during edits. +var _unusedFormat = fmt.Sprintf diff --git a/cmd/sin-code/skills_cmd_coverage_test.go b/cmd/sin-code/skills_cmd_coverage_test.go new file mode 100644 index 00000000..465dd4d2 --- /dev/null +++ b/cmd/sin-code/skills_cmd_coverage_test.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT +// Purpose: coverage tests for skills_cmd.go — exercises list/install with fake +// skillsmith.Smith instances so no real install happens. +// Docs: cmd/sin-code/skills_cmd.go.doc.md +package main + +import ( + "bytes" + "context" + "errors" + "io/fs" + "strings" + "testing" + + "github.com/Songmu/skillsmith" + "github.com/Songmu/skillsmith/agentskills" + + "github.com/OpenSIN-Code/SIN-Code/skills" +) + +func TestResolveSkillsVersion(t *testing.T) { + orig := skillsVersionHook + skillsVersionHook = func() string { return "v1.2.3" } + defer func() { skillsVersionHook = orig }() + if got := resolveSkillsVersion(); got != "v1.2.3" { + t.Errorf("expected v1.2.3, got %q", got) + } +} + +func TestResolveSkillsVersion_DevFallback(t *testing.T) { + orig := skillsVersionHook + skillsVersionHook = func() string { return "dev" } + defer func() { skillsVersionHook = orig }() + if got := resolveSkillsVersion(); got != "v0.0.0-dev" { + t.Errorf("expected v0.0.0-dev, got %q", got) + } +} + +func TestResolveSkillsVersion_SemverPassThrough(t *testing.T) { + orig := skillsVersionHook + skillsVersionHook = func() string { return "v3.14.0" } + defer func() { skillsVersionHook = orig }() + if got := resolveSkillsVersion(); got != "v3.14.0" { + t.Errorf("expected v3.14.0, got %q", got) + } +} + +func runSkillsCmd(t *testing.T, args ...string) (*bytes.Buffer, error) { + t.Helper() + cmd := NewSkillsCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + return &out, cmd.Execute() +} + +func TestSkillsCmd_NewSkillsCmd(t *testing.T) { + cmd := NewSkillsCmd() + if cmd.Use != "skills" { + t.Errorf("Use = %q, want skills", cmd.Use) + } + var names []string + for _, c := range cmd.Commands() { + names = append(names, c.Name()) + } + joined := strings.Join(names, " ") + for _, want := range []string{"list", "install"} { + if !strings.Contains(joined, want) { + t.Errorf("missing subcommand %q in %q", want, joined) + } + } +} + +func TestSkillsDefaultHooks(t *testing.T) { + // Verify the default hooks are wired and the embedded FS can be reached. + listFS, err := skills.ListFS() + if err != nil { + t.Fatal(err) + } + smith, err := skillsNewSmithHook("sin-code", resolveSkillsVersion(), listFS) + if err != nil { + t.Fatal(err) + } + list, err := smith.List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(list) == 0 { + t.Error("expected at least one bundled skill") + } +} + +func TestSkillsCmd_List_Json(t *testing.T) { + orig := skillsNewSmithHook + skillsNewSmithHook = func(name, version string, fs fs.FS) (*skillsmith.Smith, error) { + return skillsmith.New(name, version, fs) + } + defer func() { skillsNewSmithHook = orig }() + out, err := runSkillsCmd(t, "list", "--json") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "[") { + t.Errorf("expected JSON array, got %q", out.String()) + } +} + +func TestSkillsCmd_List_Error(t *testing.T) { + orig := skillsNewSmithHook + skillsNewSmithHook = func(name, version string, fs fs.FS) (*skillsmith.Smith, error) { + return nil, errors.New("new smith boom") + } + defer func() { skillsNewSmithHook = orig }() + _, err := runSkillsCmd(t, "list") + if err == nil || !strings.Contains(err.Error(), "new smith boom") { + t.Fatalf("expected smith error, got %v", err) + } +} + +func TestSkillsCmd_Install_Error(t *testing.T) { + orig := skillsNewSmithHook + skillsNewSmithHook = func(name, version string, fs fs.FS) (*skillsmith.Smith, error) { + return nil, errors.New("new smith boom") + } + defer func() { skillsNewSmithHook = orig }() + _, err := runSkillsCmd(t, "install", "foo") + if err == nil || !strings.Contains(err.Error(), "new smith boom") { + t.Fatalf("expected smith error, got %v", err) + } +} + +func TestSkillsCmd_Install(t *testing.T) { + orig := skillsNewSmithHook + origDir := skillsInstallDirHook + skillsNewSmithHook = func(name, version string, fs fs.FS) (*skillsmith.Smith, error) { + return skillsmith.New(name, version, fs) + } + skillsInstallDirHook = func(scope string) (string, error) { + return "/tmp/skills-test", nil + } + defer func() { + skillsNewSmithHook = orig + skillsInstallDirHook = origDir + }() + out, err := runSkillsCmd(t, "install", "skill-code-create", "--dry-run") + if err != nil { + t.Fatalf("install error: %v\noutput: %s", err, out.String()) + } + if !strings.Contains(out.String(), "installed") { + t.Errorf("expected install output, got %q", out.String()) + } +} + +// _unusedAgentskillsImport prevents goimports from removing the agentskills import. +var _unusedAgentskillsImport = agentskills.Discover diff --git a/cmd/sin-code/tui/agent_runner_adapter.go b/cmd/sin-code/tui/agent_runner_adapter.go index f2c5c717..e89cc5cd 100644 --- a/cmd/sin-code/tui/agent_runner_adapter.go +++ b/cmd/sin-code/tui/agent_runner_adapter.go @@ -11,6 +11,7 @@ package tui import ( + "context" "fmt" "strings" @@ -30,6 +31,16 @@ type AgentRunnerMsg struct { // initAgentRunner lazily initializes the agent runner used for chat // submits. Returns the runner or nil if it could not be constructed. // The caller is responsible for calling Close() when the TUI exits. +// newAgentRunnerHook is a test seam for AgentRunner construction. +var newAgentRunnerHook = func(ctx context.Context, cfg agentrunner.Config) (*agentrunner.AgentRunner, error) { + return agentrunner.NewAgentRunner(ctx, cfg) +} + +// submitAgentRunnerHook is a test seam for AgentRunner submission. +var submitAgentRunnerHook = func(r *agentrunner.AgentRunner, ctx context.Context, prompt string) (<-chan struct{}, error) { + return r.Submit(ctx, prompt) +} + func (m *Model) initAgentRunner() *agentrunner.AgentRunner { if m.AgentRunner != nil { return m.AgentRunner @@ -38,7 +49,7 @@ func (m *Model) initAgentRunner() *agentrunner.AgentRunner { if ws == "" { ws = "." } - r, err := agentrunner.NewAgentRunner(m.ctx(), agentrunner.Config{ + r, err := newAgentRunnerHook(m.ctx(), agentrunner.Config{ Workspace: ws, Headless: false, // TUI is interactive — show the Ask dialog Yolo: false, // never auto-allow in interactive mode @@ -122,7 +133,7 @@ func (m *Model) submitAgentPrompt(prompt string) tea.Cmd { if r == nil { return nil } - if _, err := r.Submit(m.ctx(), prompt); err != nil { + if _, err := submitAgentRunnerHook(r, m.ctx(), prompt); err != nil { m.ChatHistory = append(m.ChatHistory, "assistant: (agent runner unavailable: "+err.Error()+")") if len(m.ChatHistory) > 500 { @@ -151,7 +162,7 @@ func (m *Model) runAgentSkillPrompt(skill, args string) tea.Cmd { return nil } prompt := fmt.Sprintf("use the %s tool to %s", skill, args) - if _, err := r.Submit(m.ctx(), prompt); err != nil { + if _, err := submitAgentRunnerHook(r, m.ctx(), prompt); err != nil { m.ChatHistory = append(m.ChatHistory, "assistant: (agent runner error: "+err.Error()+")") if len(m.ChatHistory) > 500 { diff --git a/cmd/sin-code/tui/chat/input.go b/cmd/sin-code/tui/chat/input.go index 092e6f10..5e2f8357 100644 --- a/cmd/sin-code/tui/chat/input.go +++ b/cmd/sin-code/tui/chat/input.go @@ -15,6 +15,9 @@ import ( "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/attachments" ) +// osUserHomeDirHook is a test seam for os.UserHomeDir. +var osUserHomeDirHook = os.UserHomeDir + type Input struct { textarea textarea.Model attachments []*attachments.Attachment @@ -105,9 +108,6 @@ func (i *Input) HandleSlashCommand(line string) (handled bool, err error) { return false, nil } parts := strings.Fields(trimmed) - if len(parts) == 0 { - return false, nil - } switch parts[0] { case "/attach": if len(parts) < 2 { @@ -279,7 +279,7 @@ func (i *Input) isFilePath(content string) bool { return false } if strings.HasPrefix(trimmed, "~/") { - home, err := os.UserHomeDir() + home, err := osUserHomeDirHook() if err != nil { return false } diff --git a/cmd/sin-code/tui/chat/input_test.go b/cmd/sin-code/tui/chat/input_test.go index f789e153..4146c965 100644 --- a/cmd/sin-code/tui/chat/input_test.go +++ b/cmd/sin-code/tui/chat/input_test.go @@ -479,3 +479,224 @@ func TestInputIsFilePath(t *testing.T) { }) } } + +func TestInputAttachBytesTooLarge(t *testing.T) { + i := newTestInput(t) + data := make([]byte, attachments.MaxSize+1) + if err := i.AttachBytes(data, "big.bin"); err == nil { + t.Error("expected error for oversized attachment") + } +} + +func TestInputSlashAttachTooLarge(t *testing.T) { + i := newTestInput(t) + dir := t.TempDir() + big := filepath.Join(dir, "big.bin") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + f, err := os.Create(big) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(attachments.MaxSize + 1); err != nil { + _ = f.Close() + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + handled, err := i.HandleSlashCommand("/attach " + big) + if err == nil { + t.Error("expected error for oversized attachment") + } + if !handled { + t.Error("expected command handled") + } +} + +func TestInputSlashAttachGlobBadPattern(t *testing.T) { + i := newTestInput(t) + handled, err := i.HandleSlashCommand("/attach-glob [") + if err == nil { + t.Error("expected error for invalid glob pattern") + } + if !handled { + t.Error("expected command handled") + } +} + +func TestInputSlashAttachGlobTooLarge(t *testing.T) { + i := newTestInput(t) + dir := t.TempDir() + big := filepath.Join(dir, "big.bin") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + f, err := os.Create(big) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(attachments.MaxSize + 1); err != nil { + _ = f.Close() + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + handled, err := i.HandleSlashCommand("/attach-glob " + filepath.Join(dir, "*")) + if err == nil { + t.Error("expected error for oversized attachment matched by glob") + } + if !handled { + t.Error("expected command handled") + } +} + +func TestInputSlashDetachMissingArg(t *testing.T) { + i := newTestInput(t) + handled, err := i.HandleSlashCommand("/detach") + if err == nil { + t.Error("expected error for missing detach argument") + } + if !handled { + t.Error("expected command handled") + } +} + +func TestInputUpdateSlashError(t *testing.T) { + i := newTestInput(t) + i.textarea.SetValue("/attach") + cmd, submit := i.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + if cmd != nil { + t.Errorf("expected nil cmd, got %v", cmd) + } + if submit != nil { + t.Error("expected no submit for failed slash command") + } + if !strings.Contains(i.RawValue(), "[error:") { + t.Errorf("expected error marker in textarea, got %q", i.RawValue()) + } +} + +func TestInputUpdateCtrlD(t *testing.T) { + i := newTestInput(t) + cmd, _ := i.Update(tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}) + if cmd == nil { + t.Fatal("expected quit cmd") + } + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected QuitMsg, got %T", msg) + } +} + +func TestInputHandlePasteImageString(t *testing.T) { + i := newTestInput(t) + png := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13, 'I', 'H', 'D', 'R'} + cmd, submit := i.Update(tea.PasteMsg{Content: string(png)}) + if cmd != nil { + t.Errorf("expected nil cmd, got %v", cmd) + } + if submit != nil { + t.Errorf("expected nil submit, got %+v", submit) + } + if got := len(i.Attachments()); got != 1 { + t.Fatalf("expected 1 attachment, got %d", got) + } + if i.RawValue() != "" { + t.Errorf("expected empty textarea, got %q", i.RawValue()) + } +} + +func TestInputHandlePasteBytesFilePath(t *testing.T) { + i := newTestInput(t) + dir := t.TempDir() + path := filepath.Join(dir, "note.txt") + if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + i.HandlePasteBytes([]byte(path)) + if got := len(i.Attachments()); got != 1 { + t.Fatalf("expected 1 attachment, got %d", got) + } + if i.RawValue() != "" { + t.Errorf("expected empty textarea, got %q", i.RawValue()) + } +} + +func TestInputHandlePasteBytesText(t *testing.T) { + i := newTestInput(t) + text := "plain text from bytes" + i.HandlePasteBytes([]byte(text)) + if got := len(i.Attachments()); got != 0 { + t.Errorf("expected 0 attachments, got %d", got) + } + if !strings.Contains(i.RawValue(), text) { + t.Errorf("expected text in textarea, got %q", i.RawValue()) + } +} + +func TestInputIsFilePathHomeError(t *testing.T) { + i := newTestInput(t) + prev := osUserHomeDirHook + osUserHomeDirHook = func() (string, error) { + return "", os.ErrInvalid + } + defer func() { osUserHomeDirHook = prev }() + if i.isFilePath("~/missing.txt") { + t.Error("expected false when home dir resolution fails") + } +} + +func TestImageExt(t *testing.T) { + webp := []byte("RIFF\x00\x00\x00\x00WEBPVP8") + if got := imageExt(string(webp)); got != "webp" { + t.Errorf("expected webp, got %q", got) + } + if got := imageExt("unknown"); got != "bin" { + t.Errorf("expected bin, got %q", got) + } +} + +func TestInputViewMultipleAttachments(t *testing.T) { + i := newTestInput(t) + i.AttachBytes([]byte("a"), "a.txt") + i.AttachBytes([]byte("b"), "b.txt") + view := i.View() + if !strings.Contains(view, "a.txt, b.txt") { + t.Errorf("expected comma-separated names, got %q", view) + } +} + +func TestInputSlashAttachGlobMissingArg(t *testing.T) { + i := newTestInput(t) + handled, err := i.HandleSlashCommand("/attach-glob") + if err == nil { + t.Error("expected error for missing glob argument") + } + if !handled { + t.Error("expected command handled") + } +} + +func TestInputIsFilePathHomeSuccess(t *testing.T) { + i := newTestInput(t) + home := t.TempDir() + real := filepath.Join(home, "real.txt") + if err := os.WriteFile(real, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + prev := osUserHomeDirHook + osUserHomeDirHook = func() (string, error) { return home, nil } + defer func() { osUserHomeDirHook = prev }() + if !i.isFilePath("~/real.txt") { + t.Error("expected true for existing file under home dir") + } +} + +func TestImageExtGIF(t *testing.T) { + if got := imageExt("GIF89a..."); got != "gif" { + t.Errorf("expected gif, got %q", got) + } +} diff --git a/cmd/sin-code/tui/chat/runner.go b/cmd/sin-code/tui/chat/runner.go index 909d6d36..05349696 100644 --- a/cmd/sin-code/tui/chat/runner.go +++ b/cmd/sin-code/tui/chat/runner.go @@ -17,6 +17,11 @@ import ( "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/llm" ) +var ( + // providerFromConfigHook is overridden in tests to exercise NewRunner error paths. + providerFromConfigHook = llm.ProviderFromConfig +) + const ( defaultModel = "meta/llama-3.3-70b-instruct" defaultSystem = "You are sin-code, an AI coding assistant. Be concise." @@ -37,7 +42,7 @@ func NewRunner() (*Runner, error) { if os.Getenv("SIN_NIM_API_KEY") == "" { return nil, fmt.Errorf("no API key configured (set SIN_NIM_API_KEY)") } - c, err := llm.ProviderFromConfig("nim", "", "", defaultModel, 0) + c, err := providerFromConfigHook("nim", "", "", defaultModel, 0) if err != nil { return nil, err } diff --git a/cmd/sin-code/tui/chat/runner_test.go b/cmd/sin-code/tui/chat/runner_test.go index 438d19b0..6f9f2296 100644 --- a/cmd/sin-code/tui/chat/runner_test.go +++ b/cmd/sin-code/tui/chat/runner_test.go @@ -7,12 +7,14 @@ package chat import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" "os" "strings" "testing" + "time" "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/llm" ) @@ -233,3 +235,126 @@ func TestRunnerProviderErrorSurfaces(t *testing.T) { t.Error("expected non-nil client") } } + +func TestRunnerProviderConfigError(t *testing.T) { + prev := providerFromConfigHook + providerFromConfigHook = func(name, baseURLOverride, apiKeyOverride, modelOverride string, timeout time.Duration) (*llm.Client, error) { + return nil, errors.New("provider failure") + } + defer func() { providerFromConfigHook = prev }() + + prevKey, hadKey := os.LookupEnv("SIN_NIM_API_KEY") + os.Setenv("SIN_NIM_API_KEY", "fake-key") + defer func() { + if hadKey { + os.Setenv("SIN_NIM_API_KEY", prevKey) + } else { + os.Unsetenv("SIN_NIM_API_KEY") + } + }() + + r, err := NewRunner() + if err == nil { + t.Fatal("expected error") + } + if r != nil { + t.Errorf("expected nil runner, got %+v", r) + } +} + +func TestRunnerDefaults(t *testing.T) { + var gotReq llm.ChatRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotReq) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer srv.Close() + + c := llm.NewClient(srv.URL, "k") + r := &Runner{Client: c} + out, err := r.Run(context.Background(), "hello", nil) + if err != nil { + t.Fatal(err) + } + if out != "ok" { + t.Errorf("got %q", out) + } + if gotReq.Model != defaultModel { + t.Errorf("model: %q", gotReq.Model) + } + if gotReq.Messages[0].Content != defaultSystem { + t.Errorf("system: %q", gotReq.Messages[0].Content) + } +} + +func TestRunnerHistoryPrefixes(t *testing.T) { + var gotReq llm.ChatRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotReq) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer srv.Close() + + c := llm.NewClient(srv.URL, "k") + r := NewRunnerWithClient(c, "model", "system") + history := []string{ + "plain", + "", + "assistant:", + "assistant: hi", + "user: hello", + } + if _, err := r.Run(context.Background(), "prompt", history); err != nil { + t.Fatal(err) + } + // system + 4 kept history entries (empty and bare "assistant:" skipped) + prompt = 5 + if len(gotReq.Messages) != 5 { + t.Fatalf("expected 5 messages, got %d: %+v", len(gotReq.Messages), gotReq.Messages) + } + wantRoles := []string{"system", "user", "assistant", "user", "user"} + for i, want := range wantRoles { + if got := gotReq.Messages[i].Role; got != want { + t.Errorf("msg[%d] role = %q, want %q", i, got, want) + } + } +} + +func TestRunnerChatError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + defer srv.Close() + + c := llm.NewClient(srv.URL, "k") + r := NewRunnerWithClient(c, "model", "system") + if _, err := r.Run(context.Background(), "hi", nil); err == nil { + t.Fatal("expected error") + } +} + +func TestRunnerShortHistory(t *testing.T) { + var gotReq llm.ChatRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotReq) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)) + })) + defer srv.Close() + + c := llm.NewClient(srv.URL, "k") + r := NewRunnerWithClient(c, "model", "system") + // history shorter than historyKeepN to cover the start < 0 branch + history := []string{"a", "b"} + if _, err := r.Run(context.Background(), "prompt", history); err != nil { + t.Fatal(err) + } + if len(gotReq.Messages) != 4 { // system + 2 history + prompt + t.Fatalf("expected 4 messages, got %d: %+v", len(gotReq.Messages), gotReq.Messages) + } +} diff --git a/cmd/sin-code/tui/chat_input.go b/cmd/sin-code/tui/chat_input.go index 132af71b..2ac0d58b 100644 --- a/cmd/sin-code/tui/chat_input.go +++ b/cmd/sin-code/tui/chat_input.go @@ -6,6 +6,8 @@ package tui import ( + "context" + tea "charm.land/bubbletea/v2" "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/attachments" @@ -14,8 +16,19 @@ import ( type chatInput = chat.Input +// newChatRunnerHook is a test seam for chat runner construction. +var newChatRunnerHook = func() (*chat.Runner, error) { return chat.NewRunner() } + +// newAttachmentStoreHook is a test seam for the attachment store used by newChatInput. +var newAttachmentStoreHook = func() (*attachments.Store, error) { return attachments.NewStore() } + +// chatRunnerRunHook is a test seam for chat runner execution. +var chatRunnerRunHook = func(r *chat.Runner, ctx context.Context, prompt string, history []string) (string, error) { + return r.Run(ctx, prompt, history) +} + func newChatInput() *chatInput { - store, err := attachments.NewStore() + store, err := newAttachmentStoreHook() if err != nil { store = nil } @@ -35,7 +48,7 @@ func (m *Model) initChatRunner() { if m.ChatRunner != nil { return } - r, err := chat.NewRunner() + r, err := newChatRunnerHook() if err != nil { m.ChatRunner = nil return @@ -110,12 +123,12 @@ func handleChatSubmit(m *Model, submit chat.SubmitMsg) tea.Cmd { // No program wired up (e.g. test path): run synchronously so the // caller sees the final history immediately and the model is never // mutated by a background goroutine. - text, err := runner.Run(m.ctx(), prompt, historySnapshot) + text, err := chatRunnerRunHook(runner, m.ctx(), prompt, historySnapshot) applyChatResponseMsg(m, chat.ChatResponseMsg{Text: text, Error: err}, thinkingIdx) return nil } go func() { - text, err := runner.Run(m.ctx(), prompt, historySnapshot) + text, err := chatRunnerRunHook(runner, m.ctx(), prompt, historySnapshot) prog.Send(chat.ChatResponseMsg{Text: text, Error: err}) }() return nil diff --git a/cmd/sin-code/tui/notifications_banner.go b/cmd/sin-code/tui/notifications_banner.go index fa71146d..57fd27d2 100644 --- a/cmd/sin-code/tui/notifications_banner.go +++ b/cmd/sin-code/tui/notifications_banner.go @@ -72,10 +72,7 @@ func (m *Model) RenderBanner(styles Styles, width int) string { case "todo_deleted", "todo_cancelled": icon = "✗" } - innerWidth := width - 4 - if innerWidth < 10 { - innerWidth = 10 - } + innerWidth := max(width-4, 10) var b strings.Builder b.WriteString(styles.AccentText.Render("╭─ " + icon + " " + m.NotificationBanner.Title + " ")) b.WriteString(styles.Muted.Render(strings.Repeat("─", innerWidth-len(m.NotificationBanner.Title)-6))) @@ -86,12 +83,16 @@ func (m *Model) RenderBanner(styles Styles, width int) string { msg = msg[:innerWidth-1] + "…" } b.WriteString(styles.Content.Render("│ " + msg)) - b.WriteString(strings.Repeat(" ", innerWidth-len(msg)-2)) + if pad := innerWidth - len(msg) - 2; pad > 0 { + b.WriteString(strings.Repeat(" ", pad)) + } b.WriteString("│") b.WriteString("\n") actions := "[o] open [d] dismiss [n] next" b.WriteString(styles.Muted.Render("╰─ " + actions + " ")) - b.WriteString(strings.Repeat("─", innerWidth-len(actions)-4)) + if pad := innerWidth - len(actions) - 4; pad > 0 { + b.WriteString(strings.Repeat("─", pad)) + } b.WriteString("╯") b.WriteString("\n") return b.String() diff --git a/cmd/sin-code/tui/subscribe.go b/cmd/sin-code/tui/subscribe.go index 29e6c2fe..4abd5895 100644 --- a/cmd/sin-code/tui/subscribe.go +++ b/cmd/sin-code/tui/subscribe.go @@ -19,12 +19,15 @@ type NotificationSource interface { GetType() string } +// tuiBroadcasterHook is a test seam for the notifications broadcaster. +var tuiBroadcasterHook = func() <-chan *notifications.Notification { return notifications.TUIBroadcaster() } + // ListenForNotifications returns a tea.Cmd that blocks on the notifications // broadcaster channel and emits a NotificationMsg when one arrives. // Re-subscribe from Update after each NotificationMsg to keep listening. func ListenForNotifications() tea.Cmd { return func() tea.Msg { - n, ok := <-notifications.TUIBroadcaster() + n, ok := <-tuiBroadcasterHook() if !ok || n == nil { return nil } diff --git a/cmd/sin-code/tui/tui_coverage_test.go b/cmd/sin-code/tui/tui_coverage_test.go new file mode 100644 index 00000000..34792bc4 --- /dev/null +++ b/cmd/sin-code/tui/tui_coverage_test.go @@ -0,0 +1,2661 @@ +// SPDX-License-Identifier: MIT +// Purpose: coverage tests for cmd/sin-code/tui — targets the remaining +// statements not exercised by the existing tui_test.go, chat_view_test.go, +// and todos_view_test.go suites. +package tui + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/attachments" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/notifications" + agentrunner "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/tui" + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/tui/chat" +) + +// ── messages.go ───────────────────────────────────────────────────────────── + +func TestViewKindStringDefault(t *testing.T) { + v := ViewKind(999) + if got := v.String(); got != "Unknown" { + t.Errorf("String() = %q, want Unknown", got) + } +} + +func TestViewKindShortDefault(t *testing.T) { + v := ViewKind(999) + if got := v.Short(); got != "?·" { + t.Errorf("Short() = %q, want ?·", got) + } +} + +func TestViewKindShortChat(t *testing.T) { + if got := ViewChat.Short(); got != "7·Chat" { + t.Errorf("Short() = %q, want 7·Chat", got) + } +} + +// ── model.go ──────────────────────────────────────────────────────────────── + +func TestModelContextFn(t *testing.T) { + m := NewModel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + m.SetContextFn(func() context.Context { return ctx }) + if got := m.ctx(); got != ctx { + t.Error("ctx() did not return injected context") + } +} + +func TestApplyThemeOutOfBounds(t *testing.T) { + m := NewModel() + m.ThemeIdx = -1 + m.ApplyTheme() + if m.ThemeIdx != 0 { + t.Errorf("negative ThemeIdx not reset to 0, got %d", m.ThemeIdx) + } + m.ThemeIdx = len(Themes) + m.ApplyTheme() + if m.ThemeIdx != 0 { + t.Errorf("ThemeIdx >= len(Themes) not reset to 0, got %d", m.ThemeIdx) + } +} + +// ── styles.go ───────────────────────────────────────────────────────────────── + +func TestStylesColorMethods(t *testing.T) { + s := NewStyles(Themes[0]) + if s.BorderColor() == nil { + t.Error("BorderColor() nil") + } + if s.Accent() == nil { + t.Error("Accent() nil") + } + if s.Text() == nil { + t.Error("Text() nil") + } + if s.TextDim() == nil { + t.Error("TextDim() nil") + } +} + +// ── spinner.go ──────────────────────────────────────────────────────────────── + +func TestSpinnerViewStyled(t *testing.T) { + s := NewSpinner() + styles := NewStyles(Themes[0]) + view := s.View(styles.Spinner) + if view == "" { + t.Error("expected non-empty spinner view") + } +} + +// ── tabs.go ─────────────────────────────────────────────────────────────────── + +func TestTabsActiveEmpty(t *testing.T) { + tabs := NewTabs() + tabs.Sessions = nil + tabs.ActiveIdx = 0 + if got := tabs.Active(); got.Name != "Session 1" { + t.Errorf("Active() on empty = %q, want Session 1", got.Name) + } +} + +func TestTabsActiveInvalid(t *testing.T) { + tabs := NewTabs() + tabs.ActiveIdx = -1 + if got := tabs.Active(); got.Name != "Session 1" { + t.Errorf("Active() invalid = %q, want Session 1", got.Name) + } + tabs.ActiveIdx = 100 + if got := tabs.Active(); got.Name != tabs.Sessions[0].Name { + t.Errorf("Active() overflow = %q, want first", got.Name) + } +} + +func TestTabsAddEmptyName(t *testing.T) { + tabs := NewTabs() + tabs.Add("") + want := "Session 2" + if tabs.Sessions[1].Name != want { + t.Errorf("Add empty name = %q, want %q", tabs.Sessions[1].Name, want) + } +} + +func TestTabsCloseOutOfRange(t *testing.T) { + tabs := NewTabs() + before := len(tabs.Sessions) + tabs.Close(-1) + tabs.Close(100) + if len(tabs.Sessions) != before { + t.Error("Close out-of-range modified sessions") + } +} + +func TestTabsCloseLastResets(t *testing.T) { + tabs := NewTabs() + tabs.Sessions = []Session{{Name: "Only"}} + tabs.ActiveIdx = 0 + tabs.Close(0) + if len(tabs.Sessions) != 1 || tabs.Sessions[0].Name != "Session 1" { + t.Errorf("Close last did not reset default: %+v", tabs.Sessions) + } +} + +func TestTabsSelectOutOfRange(t *testing.T) { + tabs := NewTabs() + tabs.Select(-1) + if tabs.ActiveIdx != 0 { + t.Errorf("Select(-1) should not change, got %d", tabs.ActiveIdx) + } + tabs.Select(100) + if tabs.ActiveIdx != 0 { + t.Errorf("Select(100) should not change, got %d", tabs.ActiveIdx) + } +} + +func TestTabsNextEmpty(t *testing.T) { + tabs := NewTabs() + tabs.Sessions = nil + tabs.Next() + if len(tabs.Sessions) != 0 { + t.Error("Next on empty should not create sessions") + } +} + +func TestTabsPrevEmpty(t *testing.T) { + tabs := NewTabs() + tabs.Sessions = nil + tabs.Prev() + if len(tabs.Sessions) != 0 { + t.Error("Prev on empty should not create sessions") + } +} + +func TestTabsViewWithOverflow(t *testing.T) { + tabs := NewTabs() + for i := 0; i < 10; i++ { + tabs.Add("") + } + tabs.ActiveIdx = 8 + tabs.Width = 80 + view := tabs.View(NewStyles(Themes[0])) + if !strings.Contains(view, "⚡ sin-code") { + t.Errorf("View missing header: %q", view) + } +} + +func TestTabsViewEmptyRestored(t *testing.T) { + tabs := NewTabs() + tabs.Sessions = nil + view := tabs.View(NewStyles(Themes[0])) + if !strings.Contains(view, "Session 1") { + t.Errorf("View did not restore default session: %q", view) + } +} + +func TestLipglossWidthIgnoresNewlines(t *testing.T) { + if got := lipglossWidth("a\nb\nc"); got != 3 { + t.Errorf("lipglossWidth = %d, want 3", got) + } +} + +// ── sidebar.go ──────────────────────────────────────────────────────────────── + +func TestSidebarSelectedViewInvalid(t *testing.T) { + s := NewSidebar() + s.Selected = -1 + if got := s.SelectedView(); got != ViewTools { + t.Errorf("SelectedView(-1) = %v, want ViewTools", got) + } + s.Selected = len(s.Items) + if got := s.SelectedView(); got != ViewTools { + t.Errorf("SelectedView(overflow) = %v, want ViewTools", got) + } +} + +func TestSidebarSetSelectedViewUnknown(t *testing.T) { + s := NewSidebar() + s.Selected = 3 + s.SetSelectedView(ViewKind(999)) + if s.Selected != 3 { + t.Errorf("SetSelectedView unknown should not change, got %d", s.Selected) + } +} + +func TestSidebarViewCollapsed(t *testing.T) { + s := NewSidebar() + s.Collapsed = true + view := s.View(NewStyles(Themes[0])) + if !strings.Contains(view, "⚒") { + t.Errorf("Collapsed view missing icons: %q", view) + } +} + +func TestBadgeForEmpty(t *testing.T) { + s := NewSidebar() + if badgeFor(s) != "" { + t.Error("badgeFor empty should be empty") + } +} + +func TestItoaZero(t *testing.T) { + if itoa(0) != "0" { + t.Errorf("itoa(0) = %q", itoa(0)) + } +} + +// ── footer.go ───────────────────────────────────────────────────────────────── + +func TestDefaultHintsChat(t *testing.T) { + hints := DefaultHints(ViewChat) + if len(hints) == 0 { + t.Error("expected hints for ViewChat") + } +} + +func TestFooterAgentNameInvalid(t *testing.T) { + f := NewFooter(80) + f.AgentIndex = -1 + if got := f.AgentName(); got != "Build" { + t.Errorf("AgentName(-1) = %q, want Build", got) + } + f.AgentIndex = len(AgentNames) + if got := f.AgentName(); got != "Build" { + t.Errorf("AgentName(overflow) = %q, want Build", got) + } +} + +func TestFooterProgressBarNegative(t *testing.T) { + f := NewFooter(80) + if got := f.ProgressBar(-1); got != "" { + t.Errorf("ProgressBar(-1) = %q, want empty", got) + } + f.TokensPct = -0.5 + if got := f.ProgressBar(5); got != strings.Repeat("░", 5) { + t.Errorf("ProgressBar negative pct = %q", got) + } + f.TokensPct = 1.5 + if got := f.ProgressBar(5); got != strings.Repeat("█", 5) { + t.Errorf("ProgressBar overflow pct = %q", got) + } +} + +func TestFooterRenderWithLoading(t *testing.T) { + f := NewFooter(80) + f.Loading = true + f.ShowHints = false + out := f.Render(NewStyles(Themes[0])) + if out == "" { + t.Error("expected non-empty render") + } +} + +func TestFooterRenderTodoCounts(t *testing.T) { + f := NewFooter(80) + f.SetView(ViewTodos) + f.TodoOpen = 5 + f.TodoBlocked = 2 + f.TodoOverdue = 1 + f.TodoReady = 3 + f.ShowHints = false + out := f.Render(NewStyles(Themes[0])) + if !strings.Contains(out, "5 open") && !strings.Contains(out, "3 ready") { + t.Errorf("expected todo counts in footer: %q", out) + } +} + +func TestFooterRenderNoSelection(t *testing.T) { + f := NewFooter(80) + f.SetView(ViewTools) + f.Selection = "" + f.ShowHints = false + out := f.Render(NewStyles(Themes[0])) + if !strings.Contains(out, "(no selection)") { + t.Errorf("expected no-selection marker: %q", out) + } +} + +func TestFooterCountReady(t *testing.T) { + f := Footer{TodoReady: 7} + if got := footerCount(f, "ready", '🟢'); got != "🟢 7 ready" { + t.Errorf("footerCount ready = %q", got) + } +} + +// ── history_view.go ────────────────────────────────────────────────────────── + +func TestRenderHistoryViewSelectedOutOfRange(t *testing.T) { + entries := []HistoryEntry{ + {Time: time.Now(), View: "Tools", Action: "a", Detail: "d", Success: true}, + } + out := RenderHistoryView(entries, -1, NewStyles(Themes[0]), 80, 24) + if !strings.Contains(out, "a") { + t.Errorf("expected action in view: %q", out) + } +} + +func TestRenderHistoryViewTruncated(t *testing.T) { + entries := make([]HistoryEntry, 50) + for i := range entries { + entries[i] = HistoryEntry{Time: time.Now(), View: "Tools", Action: "a", Detail: "d", Success: true} + } + out := RenderHistoryView(entries, 0, NewStyles(Themes[0]), 80, 24) + if !strings.Contains(out, "entries") { + t.Errorf("expected entries summary: %q", out) + } +} + +// ── tools_view.go ───────────────────────────────────────────────────────────── + +func TestRenderToolsViewNonRunnable(t *testing.T) { + sidebar := NewSidebar() + sidebar.ToolSel = 0 // discover is non-runnable + out := RenderToolsView(sidebar, NewStyles(Themes[0]), 80, 24) + if !strings.Contains(out, "Requires arguments") && !strings.Contains(out, "Press r to run") { + t.Errorf("expected non-runnable hint: %q", out) + } +} + +// ── efm_view.go ─────────────────────────────────────────────────────────────── + +func TestRenderEFMViewTTLZero(t *testing.T) { + stacks := []EFMStack{{Name: "x", Status: "running", URL: "http://a", TTL: 0}} + out := RenderEFMView(stacks, NewStyles(Themes[0]), 80, 24, NewSpinner()) + if !strings.Contains(out, "—") { + t.Errorf("expected em-dash for zero TTL: %q", out) + } +} + +func TestRenderEFMViewDefaultStatus(t *testing.T) { + stacks := []EFMStack{{Name: "x", Status: "unknown", URL: "http://a", TTL: 10}} + out := RenderEFMView(stacks, NewStyles(Themes[0]), 80, 24, NewSpinner()) + if !strings.Contains(out, "x") { + t.Errorf("expected stack name in default status: %q", out) + } +} + +// ── chat_view.go ────────────────────────────────────────────────────────────── + +func TestRenderChatClampsSize(t *testing.T) { + m := NewModel() + out := m.renderChat(NewStyles(Themes[0]), 5, 3) + if !strings.Contains(out, "Chat") { + t.Errorf("expected Chat title after clamp: %q", out) + } +} + +func TestChatViewHelp(t *testing.T) { + m := NewModel() + m.initChatInput() + out := m.chatViewHelp() + if !strings.Contains(out, "Ctrl+S") { + t.Errorf("expected help text: %q", out) + } +} + +// ── chat_program.go ─────────────────────────────────────────────────────────── + +func TestProgramFromTeaProgramNil(t *testing.T) { + if got := ProgramFromTeaProgram(nil); got != nil { + t.Error("expected nil wrapper for nil program") + } +} + +// ── chat_input.go ───────────────────────────────────────────────────────────── + +func TestNewChatInputAttachmentError(t *testing.T) { + orig := newAttachmentStoreHook + newAttachmentStoreHook = func() (*attachments.Store, error) { return nil, errors.New("boom") } + defer func() { newAttachmentStoreHook = orig }() + + ci := newChatInput() + if ci == nil { + t.Fatal("newChatInput should still return input on store error") + } +} + +func TestInitChatRunnerError(t *testing.T) { + orig := newChatRunnerHook + newChatRunnerHook = func() (*chat.Runner, error) { return nil, errors.New("no key") } + defer func() { newChatRunnerHook = orig }() + + m := NewModel() + m.initChatRunner() + if m.ChatRunner != nil { + t.Error("expected nil runner on error") + } +} + +func TestHandleChatSubmitWithRunnerProgram(t *testing.T) { + origRunner := newChatRunnerHook + origRun := chatRunnerRunHook + newChatRunnerHook = func() (*chat.Runner, error) { return &chat.Runner{}, nil } + chatRunnerRunHook = func(r *chat.Runner, ctx context.Context, prompt string, history []string) (string, error) { + return "async reply", nil + } + defer func() { + newChatRunnerHook = origRunner + chatRunnerRunHook = origRun + }() + + sender := newFakeProgram() + m := NewModel() + m.Program = sender + m.initChatInput() + m.initChatRunner() + handleChatSubmit(m, chat.SubmitMsg{Text: "hi"}) + + select { + case msg := <-sender.recv: + resp, ok := msg.(chat.ChatResponseMsg) + if !ok || resp.Text != "async reply" { + t.Errorf("unexpected async message: %+v", msg) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for async chat response") + } +} + +func TestApplyChatResponseMsgInvalidIndex(t *testing.T) { + m := NewModel() + applyChatResponseMsg(m, chat.ChatResponseMsg{Text: "x"}, -1) + applyChatResponseMsg(m, chat.ChatResponseMsg{Text: "x"}, 5) + if len(m.ChatHistory) != 0 { + t.Error("should not mutate history on invalid index") + } +} + +func TestUpdateChatNilInput(t *testing.T) { + m := NewModel() + m.ChatInput = nil + if got := m.updateChat(tea.KeyPressMsg{Text: "a"}); got != nil { + t.Error("expected nil when ChatInput is nil") + } +} + +// ── agent_runner_adapter.go ─────────────────────────────────────────────────── + +func TestInitAgentRunnerError(t *testing.T) { + orig := newAgentRunnerHook + newAgentRunnerHook = func(ctx context.Context, cfg agentrunner.Config) (*agentrunner.AgentRunner, error) { + return nil, errors.New("fail") + } + defer func() { newAgentRunnerHook = orig }() + + m := NewModel() + if got := m.initAgentRunner(); got != nil { + t.Error("expected nil runner on error") + } + if m.AgentRunner != nil { + t.Error("AgentRunner should not be set") + } +} + +func TestListenAgentRunnerCmdClosed(t *testing.T) { + ch := make(chan agentrunner.AgentEvent) + close(ch) + r := &agentrunner.AgentRunner{Events: ch} + cmd := listenAgentRunnerCmd(r) + msg := cmd() + m, ok := msg.(AgentRunnerMsg) + if !ok || !m.Closed { + t.Errorf("expected closed message, got %+v", msg) + } +} + +func TestHandleAgentRunnerEventClosed(t *testing.T) { + m := NewModel() + m.AgentRunner = &agentrunner.AgentRunner{} + m.handleAgentRunnerEvent(AgentRunnerMsg{Closed: true}) + if m.AgentRunner != nil { + t.Error("expected AgentRunner cleared") + } +} + +func TestHandleAgentRunnerEventKinds(t *testing.T) { + m := NewModel() + m.initChatInput() + + cases := []struct { + kind agentrunner.EventKind + want string + }{ + {agentrunner.EventTurn, "turn start"}, + {agentrunner.EventTool, "tool"}, + {agentrunner.EventVerify, "verify"}, + {agentrunner.EventAsk, "ask"}, + {agentrunner.EventDone, "done"}, + {agentrunner.EventError, "ERROR"}, + {agentrunner.EventKind(0), "agent"}, + } + for _, tc := range cases { + ev := agentrunner.AgentEvent{Kind: tc.kind, Detail: "d", Result: "r", ToolName: "t", Err: nil} + if tc.kind == agentrunner.EventAsk { + ev.AskReply = make(chan bool, 1) + ev.Detail = "prompt" + } + m.handleAgentRunnerEvent(AgentRunnerMsg{Event: ev}) + last := m.ChatHistory[len(m.ChatHistory)-1] + if !strings.Contains(last, tc.want) { + t.Errorf("kind %v: expected %q in %q", tc.kind, tc.want, last) + } + } +} + +func TestHandleAgentRunnerEventHistoryCap(t *testing.T) { + m := NewModel() + m.ChatHistory = make([]string, 500) + m.handleAgentRunnerEvent(AgentRunnerMsg{Event: agentrunner.AgentEvent{Kind: agentrunner.EventTurn, Detail: "x"}}) + if len(m.ChatHistory) != 500 { + t.Errorf("history cap should keep last 500, got %d", len(m.ChatHistory)) + } +} + +func TestAnswerPendingAskNil(t *testing.T) { + m := NewModel() + m.answerPendingAsk(true) // should not panic +} + +func TestAnswerPendingAskNonBlocking(t *testing.T) { + m := NewModel() + ch := make(chan bool) + m.pendingAsk = ch + m.answerPendingAsk(true) + if m.pendingAsk != nil { + t.Error("pendingAsk should be cleared") + } +} + +func TestSubmitAgentPromptNilRunner(t *testing.T) { + orig := newAgentRunnerHook + newAgentRunnerHook = func(ctx context.Context, cfg agentrunner.Config) (*agentrunner.AgentRunner, error) { + return nil, errors.New("fail") + } + defer func() { newAgentRunnerHook = orig }() + + m := NewModel() + if got := m.submitAgentPrompt("hi"); got != nil { + t.Error("expected nil command when runner creation fails") + } +} + +func TestSubmitAgentPromptSubmitError(t *testing.T) { + orig := newAgentRunnerHook + submitOrig := submitAgentRunnerHook + ar := &agentrunner.AgentRunner{Events: make(chan agentrunner.AgentEvent, 1)} + newAgentRunnerHook = func(ctx context.Context, cfg agentrunner.Config) (*agentrunner.AgentRunner, error) { + return ar, nil + } + submitAgentRunnerHook = func(r *agentrunner.AgentRunner, ctx context.Context, prompt string) (<-chan struct{}, error) { + return nil, errors.New("submit failed") + } + defer func() { + newAgentRunnerHook = orig + submitAgentRunnerHook = submitOrig + }() + + m := NewModel() + m.initChatInput() + if got := m.submitAgentPrompt("hi"); got != nil { + t.Error("expected nil command on submit error") + } + last := m.ChatHistory[len(m.ChatHistory)-1] + if !strings.Contains(last, "unavailable") { + t.Errorf("expected unavailable marker, got %q", last) + } +} + +func TestRunAgentSkillPromptNilRunner(t *testing.T) { + orig := newAgentRunnerHook + newAgentRunnerHook = func(ctx context.Context, cfg agentrunner.Config) (*agentrunner.AgentRunner, error) { + return nil, errors.New("fail") + } + defer func() { newAgentRunnerHook = orig }() + + m := NewModel() + m.initChatInput() + if got := m.runAgentSkillPrompt("websearch", "find x"); got != nil { + t.Error("expected nil command when runner creation fails") + } + last := m.ChatHistory[len(m.ChatHistory)-1] + if !strings.Contains(last, "sin-code mcp call") { + t.Errorf("expected CLI hint, got %q", last) + } +} + +func TestRunAgentSkillPromptSubmitError(t *testing.T) { + orig := newAgentRunnerHook + submitOrig := submitAgentRunnerHook + ar := &agentrunner.AgentRunner{Events: make(chan agentrunner.AgentEvent, 1)} + newAgentRunnerHook = func(ctx context.Context, cfg agentrunner.Config) (*agentrunner.AgentRunner, error) { + return ar, nil + } + submitAgentRunnerHook = func(r *agentrunner.AgentRunner, ctx context.Context, prompt string) (<-chan struct{}, error) { + return nil, errors.New("submit failed") + } + defer func() { + newAgentRunnerHook = orig + submitAgentRunnerHook = submitOrig + }() + + m := NewModel() + m.initChatInput() + if got := m.runAgentSkillPrompt("websearch", "find x"); got != nil { + t.Error("expected nil command on submit error") + } + last := m.ChatHistory[len(m.ChatHistory)-1] + if !strings.Contains(last, "error") { + t.Errorf("expected error marker, got %q", last) + } +} + +// ── subscribe.go ───────────────────────────────────────────────────────────── + +func TestListenForNotificationsClosed(t *testing.T) { + orig := tuiBroadcasterHook + ch := make(chan *notifications.Notification) + close(ch) + tuiBroadcasterHook = func() <-chan *notifications.Notification { return ch } + defer func() { tuiBroadcasterHook = orig }() + + cmd := ListenForNotifications() + if msg := cmd(); msg != nil { + t.Errorf("expected nil on closed channel, got %T", msg) + } +} + +func TestListenForNotificationsNil(t *testing.T) { + orig := tuiBroadcasterHook + tuiBroadcasterHook = func() <-chan *notifications.Notification { + ch := make(chan *notifications.Notification, 1) + ch <- nil + return ch + } + defer func() { tuiBroadcasterHook = orig }() + + cmd := ListenForNotifications() + if msg := cmd(); msg != nil { + t.Errorf("expected nil on nil notification, got %T", msg) + } +} + +// ── notifications_banner.go ──────────────────────────────────────────────────── + +func TestDismissBannerNil(t *testing.T) { + m := NewModel() + m.DismissBanner() // should not panic +} + +func TestBannerNextWithDismissed(t *testing.T) { + m := NewModel() + m.Notifications = []NotificationItem{ + {ID: "1", Title: "A", Dismissed: true}, + {ID: "2", Title: "B", Dismissed: false}, + } + m.BannerNext() + if m.NotificationBanner == nil || m.NotificationBanner.ID != "2" { + t.Errorf("expected banner 2, got %+v", m.NotificationBanner) + } +} + +func TestRenderBannerIconCases(t *testing.T) { + m := NewModel() + cases := []struct { + typ string + icon string + }{ + {"todo_completed", "✓"}, + {"todo_assigned", "📌"}, + {"todo_claimed", "📌"}, + {"todo_blocked", "⛔"}, + {"todo_unblocked", "✅"}, + {"todo_deleted", "✗"}, + {"todo_cancelled", "✗"}, + } + for _, tc := range cases { + m.SetBanner(&NotificationItem{ID: "n", Title: "T", Message: "M", Type: tc.typ}) + out := m.RenderBanner(m.Styles, 80) + if !strings.Contains(out, tc.icon) { + t.Errorf("type %s: expected icon %s in %q", tc.typ, tc.icon, out) + } + } +} + +func TestRenderBannerTruncatesMessage(t *testing.T) { + m := NewModel() + long := strings.Repeat("x", 200) + m.SetBanner(&NotificationItem{ID: "n", Title: "T", Message: long, Type: "todo_created"}) + out := m.RenderBanner(m.Styles, 80) + if strings.Contains(out, long) { + t.Error("expected long message to be truncated") + } +} + +func TestRenderBannerTinyWidth(t *testing.T) { + m := NewModel() + m.SetBanner(&NotificationItem{ID: "n", Title: "T", Message: "M", Type: "todo_created"}) + out := m.RenderBanner(m.Styles, 5) + if out == "" { + t.Error("expected non-empty banner even with tiny width") + } +} + +// ── update.go (small uncovered branches) ───────────────────────────────────── + +func TestApplyThemeNegative(t *testing.T) { + m := NewModel() + m.ThemeIdx = -5 + m.ApplyTheme() + if m.ThemeIdx != 0 { + t.Errorf("expected ThemeIdx reset to 0, got %d", m.ThemeIdx) + } +} + +func TestPreviousView(t *testing.T) { + m := NewModel() + m.ViewKind = ViewTools + m.PreviousView() + if m.ViewKind != ViewHistory { + t.Errorf("PreviousView from Tools = %v, want History", m.ViewKind) + } +} + +func TestFilterPaletteEmpty(t *testing.T) { + m := NewModel() + m.Palette.Items = []string{"alpha", "beta"} + m.Palette.Filter = nil + m.Palette.Sel = 5 + m.filterPalette("") + if len(m.Palette.Filter) != 2 || m.Palette.Sel != 0 { + t.Errorf("empty query should restore all items and reset selection") + } +} + +func TestRunToolSkillWithNoOnRun(t *testing.T) { + m := NewModel() + m.runTool("websearch", nil) + if len(m.ChatHistory) != 0 { + t.Errorf("expected no chat history without OnRun, got %d", len(m.ChatHistory)) + } +} + +func TestRunToolOnRunError(t *testing.T) { + m := NewModel() + m.OnRun = func(name string, args []string) error { return errors.New("boom") } + m.runTool("discover", []string{"--help"}) + last := m.History[len(m.History)-1] + if last.Success || !strings.Contains(last.Detail, "boom") { + t.Errorf("expected error history entry, got %+v", last) + } +} + +func TestIsSkillName(t *testing.T) { + if !isSkillName("websearch") { + t.Error("websearch should be a skill") + } + if isSkillName("discover") { + t.Error("discover should not be a skill") + } +} + +func TestUpdateUnknownMsg(t *testing.T) { + m := NewModel() + updated, cmd := m.Update(struct{ x int }{x: 1}) + if updated != m { + t.Error("expected same model returned for unknown msg") + } + _ = cmd +} + +func TestUpdateBannerKeyMsg(t *testing.T) { + m := NewModel() + _, cmd := m.Update(BannerKeyMsg{Action: "open:x"}) + if cmd != nil { + t.Error("expected nil cmd for BannerKeyMsg") + } +} + +func TestHandleKeySubagentsMode(t *testing.T) { + m := NewModel() + m.Mode = ModeSubagents + _, cmd := m.handleKey(tea.KeyPressMsg{Text: "esc"}) + if cmd != nil { + t.Error("expected nil cmd in subagents mode") + } + if m.Mode != ModeNormal { + t.Error("expected ModeNormal after esc in subagents") + } +} + +func TestHandleKeyUnknown(t *testing.T) { + m := NewModel() + _, cmd := m.handleKey(tea.KeyPressMsg{Text: "z"}) + if cmd != nil { + t.Error("expected nil cmd for unknown key") + } +} + +func TestHandleKeyRunSelectedNoTool(t *testing.T) { + m := NewModel() + m.Sidebar.ToolSel = -1 + m.ViewKind = ViewTools + before := len(m.History) + m.Update(tea.KeyPressMsg{Text: "r"}) + if len(m.History) != before { + t.Error("RunSelected with no selection should not append history") + } +} + +func TestHandleKeyEnterNoTool(t *testing.T) { + m := NewModel() + m.Sidebar.ToolSel = -1 + m.ViewKind = ViewTools + before := len(m.History) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if len(m.History) != before { + t.Error("Enter with no selection should not append history") + } +} + +func TestHandleKeyNavUnknownView(t *testing.T) { + m := NewModel() + m.ViewKind = ViewChat + before := m.Sidebar.Selected + m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + if m.Sidebar.Selected != before { + t.Error("Navigation in unknown view should not move sidebar") + } +} + +func TestHandleKeyBannerOpenDismissNext(t *testing.T) { + m := NewModel() + m.SetBanner(&NotificationItem{ID: "n", Title: "T", Type: "todo_created"}) + m.Update(tea.KeyPressMsg{Text: "o"}) + if last := m.History[len(m.History)-1]; last.Action != "banner-open" { + t.Errorf("expected banner-open, got %q", last.Action) + } + m.Update(tea.KeyPressMsg{Text: "d"}) + if m.NotificationBanner != nil { + t.Error("expected banner dismissed") + } +} + +func TestHandleKeyBannerNext(t *testing.T) { + m := NewModel() + m.SetBanner(&NotificationItem{ID: "1", Title: "A", Type: "todo_created"}) + m.SetBanner(&NotificationItem{ID: "2", Title: "B", Type: "todo_completed"}) + m.Update(tea.KeyPressMsg{Text: "n"}) + if m.NotificationBanner == nil { + t.Error("expected banner after next") + } +} + +func TestHandleKeyYNForAsk(t *testing.T) { + m := NewModel() + ch := make(chan bool, 1) + m.pendingAsk = ch + m.Update(tea.KeyPressMsg{Text: "y"}) + if m.pendingAsk != nil { + t.Error("expected pendingAsk cleared") + } + m.pendingAsk = ch + m.Update(tea.KeyPressMsg{Text: "n"}) + if m.pendingAsk != nil { + t.Error("expected pendingAsk cleared after n") + } +} + +func TestHandlePaletteKeyDownUp(t *testing.T) { + m := NewModel() + m.OpenPalette() + m.Palette.Filter = []string{"a", "b", "c"} + m.Palette.Sel = 0 + m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + if m.Palette.Sel != 1 { + t.Errorf("expected sel 1, got %d", m.Palette.Sel) + } + m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + if m.Palette.Sel != 0 { + t.Errorf("expected sel 0, got %d", m.Palette.Sel) + } +} + +func TestHandlePaletteKeyBackspaceEmpty(t *testing.T) { + m := NewModel() + m.OpenPalette() + m.Palette.Query = "" + before := len(m.Palette.Filter) + m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace}) + if len(m.Palette.Filter) != before { + t.Error("Backspace on empty query should not change filter") + } +} + +func TestExecutePaletteChoiceDefault(t *testing.T) { + m := NewModel() + m.executePaletteChoice("unknown") + if last := m.History[len(m.History)-1]; last.Action != "palette" { + t.Errorf("expected palette history entry, got %q", last.Action) + } +} + +func TestHandleArgInputKeyUpdatesInput(t *testing.T) { + m := NewModel() + m.OpenArgInput("discover") + _, cmd := m.handleArgInputKey(tea.KeyPressMsg{Text: "a"}) + if cmd == nil { + t.Error("expected input cmd from non-submit/esc key") + } +} + +func TestHandleChatResponseNoPlaceholder(t *testing.T) { + m := NewModel() + m.initChatInput() + m.ChatHistory = []string{"hello"} + m.handleChatResponse(chat.ChatResponseMsg{Text: "world"}) + if last := m.ChatHistory[len(m.ChatHistory)-1]; last != "assistant: world" { + t.Errorf("got %q", last) + } +} + +func TestHandleChatResponseEmptyNoPlaceholder(t *testing.T) { + m := NewModel() + m.initChatInput() + m.ChatHistory = []string{"hello"} + m.handleChatResponse(chat.ChatResponseMsg{Text: ""}) + if last := m.ChatHistory[len(m.ChatHistory)-1]; !strings.Contains(last, "empty") { + t.Errorf("expected empty marker, got %q", last) + } +} + +func TestHandleChatResponseErrorNoPlaceholder(t *testing.T) { + m := NewModel() + m.initChatInput() + m.ChatHistory = []string{"hello"} + m.handleChatResponse(chat.ChatResponseMsg{Error: errFake{}}) + if last := m.ChatHistory[len(m.ChatHistory)-1]; !strings.Contains(last, "error") { + t.Errorf("expected error marker, got %q", last) + } +} + +func TestHandleChatResponseEmptyHistory(t *testing.T) { + m := NewModel() + m.initChatInput() + m.handleChatResponse(chat.ChatResponseMsg{Text: "x"}) + if len(m.ChatHistory) != 0 { + t.Error("should not append on empty history") + } +} + +func TestAgentRunnerMsgReSubscribe(t *testing.T) { + m := NewModel() + ar := &agentrunner.AgentRunner{Events: make(chan agentrunner.AgentEvent, 1)} + m.AgentRunner = ar + _, cmd := m.Update(AgentRunnerMsg{Event: agentrunner.AgentEvent{Kind: agentrunner.EventTurn, Detail: "x"}}) + if cmd == nil { + t.Error("expected re-subscribe cmd") + } +} + +func TestAgentRunnerMsgClosedClears(t *testing.T) { + m := NewModel() + m.AgentRunner = &agentrunner.AgentRunner{} + _, cmd := m.Update(AgentRunnerMsg{Closed: true}) + if m.AgentRunner != nil { + t.Error("expected AgentRunner cleared") + } + if cmd != nil { + t.Error("expected nil cmd when closed") + } +} + +func TestUpdateTodosLoaded(t *testing.T) { + m := NewModel() + _, cmd := m.Update(TodosLoadedMsg{Items: []TodoRow{{ID: "1", Title: "x"}}}) + if cmd != nil { + t.Error("expected nil cmd") + } + if len(m.TodoItems) != 1 { + t.Errorf("expected 1 todo item, got %d", len(m.TodoItems)) + } +} + +func TestUpdateCountsMsg(t *testing.T) { + m := NewModel() + _, cmd := m.Update(CountsMsg{Open: 1, Blocked: 2, Overdue: 3, Ready: 4}) + if cmd != nil { + t.Error("expected nil cmd") + } + if m.Sidebar.TodoOpen != 1 { + t.Error("counts not updated") + } +} + +func TestUpdateNotificationMsg(t *testing.T) { + orig := tuiBroadcasterHook + tuiBroadcasterHook = func() <-chan *notifications.Notification { return make(<-chan *notifications.Notification) } + defer func() { tuiBroadcasterHook = orig }() + + m := NewModel() + _, cmd := m.Update(NotificationMsg{N: &testNotification{id: "n", title: "T", message: "M", t: "todo_created"}}) + if cmd == nil { + t.Error("expected re-subscribe cmd") + } + if m.NotificationBanner == nil { + t.Error("expected banner set") + } +} + +// ── views.go ────────────────────────────────────────────────────────────────── + +func TestRenderCommandPaletteEmpty(t *testing.T) { + out := RenderCommandPalette(nil, 0, "", NewStyles(Themes[0]), 80, 24) + if !strings.Contains(out, "no matches") { + t.Errorf("expected no matches: %q", out) + } +} + +func TestRenderSessionsViewEmpty(t *testing.T) { + out := RenderSessionsView(NewStyles(Themes[0]), Tabs{}, 80, 24) + if !strings.Contains(out, "No active sessions") { + t.Errorf("expected empty sessions: %q", out) + } +} + +func TestComposeLayoutNoRight(t *testing.T) { + out := ComposeLayout(NewTabs(), NewSidebar(), ViewTools, "content", "", NewFooter(80), NewStyles(Themes[0]), 80, 24) + if out == "" { + t.Error("expected non-empty layout without right panel") + } +} + +func TestComposeLayoutTiny(t *testing.T) { + out := ComposeLayout(NewTabs(), NewSidebar(), ViewTools, "c", "", NewFooter(80), NewStyles(Themes[0]), 10, 5) + if out == "" { + t.Error("expected non-empty layout even with tiny dimensions") + } +} + +func TestSplitLinesNoNewline(t *testing.T) { + out := splitLines("a", 3, 5) + if out != "a \n" { + t.Errorf("splitLines = %q", out) + } +} + +func TestPadContent(t *testing.T) { + out := padContent("line1", 10, 3) + if !strings.Contains(out, "line1") { + t.Errorf("expected line1 in padded content: %q", out) + } +} + +func TestRenderRightPanelNonRunnable(t *testing.T) { + tool := &ToolSubItem{Name: "t", Description: "d", Runnable: false} + out := RenderRightPanel(tool, ViewTools, NewStyles(Themes[0]), 30, 20) + if !strings.Contains(out, "Requires arguments") { + t.Errorf("expected requires arguments: %q", out) + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +type fakeProgram struct { + msgs []any + recv chan any +} + +func newFakeProgram() *fakeProgram { + return &fakeProgram{recv: make(chan any, 1)} +} + +func (f *fakeProgram) Send(msg any) { + f.msgs = append(f.msgs, msg) + select { + case f.recv <- msg: + default: + } +} + +// Ensure fakeProgram satisfies the interface. +var _ teaProgramIface = (*fakeProgram)(nil) + +// ── Additional coverage tests (batch 2) ───────────────────────────────────── + +// messages.go remaining Short cases +func TestViewKindShortAll(t *testing.T) { + cases := []struct { + v ViewKind + want string + }{ + {ViewTools, "1·Tools"}, + {ViewSessions, "2·Sessions"}, + {ViewEFM, "3·EFM"}, + {ViewConfig, "4·Config"}, + {ViewHistory, "5·History"}, + {ViewTodos, "6·Todos"}, + {ViewChat, "7·Chat"}, + } + for _, tc := range cases { + if got := tc.v.Short(); got != tc.want { + t.Errorf("Short(%v) = %q, want %q", tc.v, got, tc.want) + } + } +} + +// model.go listItem methods +func TestListItemMethods(t *testing.T) { + li := listItem{name: "n", description: "d", runnable: true} + if li.Title() != "n" { + t.Error("Title") + } + if li.Description() != "d" { + t.Error("Description") + } + if li.FilterValue() != "n d" { + t.Errorf("FilterValue = %q", li.FilterValue()) + } +} + +// chat_view.go line wrapping +func TestRenderChatWrapsLongLines(t *testing.T) { + m := NewModel() + m.ChatHistory = []string{strings.Repeat("a", 100)} + out := m.renderChat(NewStyles(Themes[0]), 80, 20) + if !strings.Contains(out, "…") { + t.Error("expected long line to be truncated") + } +} + +// efm_view.go remaining +func TestRenderEFMViewOtherStatus(t *testing.T) { + stacks := []EFMStack{{Name: "x", Status: "other", URL: "http://a", TTL: 10}} + out := RenderEFMView(stacks, NewStyles(Themes[0]), 80, 24, NewSpinner()) + if !strings.Contains(out, "x") { + t.Errorf("expected default status rendered: %q", out) + } +} + +// footer.go gap filling +func TestFooterRenderGapFill(t *testing.T) { + f := NewFooter(20) + f.SetView(ViewTools) + f.ShowHints = false + out := f.Render(NewStyles(Themes[0])) + if out == "" { + t.Error("expected non-empty footer render") + } +} + +// history_view.go selected branch +func TestRenderHistoryViewSelected(t *testing.T) { + entries := []HistoryEntry{ + {Time: time.Now(), View: "Tools", Action: "a", Detail: "d", Success: true}, + {Time: time.Now(), View: "Tools", Action: "b", Detail: "e", Success: false}, + } + out := RenderHistoryView(entries, 1, NewStyles(Themes[0]), 80, 24) + if !strings.Contains(out, "b") { + t.Errorf("expected selected entry: %q", out) + } +} + +// notifications_banner.go default icon +func TestRenderBannerDefaultIcon(t *testing.T) { + m := NewModel() + m.SetBanner(&NotificationItem{ID: "n", Title: "T", Message: "M", Type: "unknown"}) + out := m.RenderBanner(m.Styles, 80) + if !strings.Contains(out, "🔔") { + t.Errorf("expected default bell icon: %q", out) + } +} + +// sidebar.go width clamp and non-tools view +func TestSidebarViewWidthClamp(t *testing.T) { + s := NewSidebar() + s.Width = 10 + s.Selected = 0 + out := s.View(NewStyles(Themes[0])) + if !strings.Contains(out, "sin-code") { + t.Errorf("expected sidebar header: %q", out) + } +} + +func TestSidebarViewNotTools(t *testing.T) { + s := NewSidebar() + s.Selected = 1 // ViewSessions + out := s.View(NewStyles(Themes[0])) + if strings.Contains(out, "Subcommands") { + t.Error("subcommands should not render when not on Tools") + } +} + +// spinner.go tick +func TestSpinnerInit(t *testing.T) { + s := NewSpinner() + cmd := s.Init() + if cmd == nil { + t.Error("expected non-nil init cmd") + } +} + +// subscribe.go actual notification +func TestListenForNotificationsActual(t *testing.T) { + orig := tuiBroadcasterHook + ch := make(chan *notifications.Notification, 1) + ch <- ¬ifications.Notification{ID: "n", Title: "T", Message: "M", Type: "todo_created"} + tuiBroadcasterHook = func() <-chan *notifications.Notification { return ch } + defer func() { tuiBroadcasterHook = orig }() + + cmd := ListenForNotifications() + msg := cmd() + nm, ok := msg.(NotificationMsg) + if !ok || nm.N.GetTitle() != "T" { + t.Errorf("expected notification msg, got %T", msg) + } +} + +// tabs.go remaining cases +func TestTabsViewDirty(t *testing.T) { + tabs := NewTabs() + tabs.Sessions[0].Dirty = true + out := tabs.View(NewStyles(Themes[0])) + if !strings.Contains(out, "●") { + t.Errorf("expected dirty marker: %q", out) + } +} + +func TestTabsViewPadded(t *testing.T) { + tabs := NewTabs() + tabs.Width = 200 + out := tabs.View(NewStyles(Themes[0])) + if len(out) < 100 { + t.Errorf("expected padded output: %q", out) + } +} + +// todos_view.go clamping and priorities +func TestRenderTodosClampsSize(t *testing.T) { + m := NewModel() + out := m.RenderTodos(NewStyles(Themes[0]), 5, 3) + if !strings.Contains(out, "Todos") { + t.Errorf("expected title after clamp: %q", out) + } +} + +func TestRenderTodosPriorities(t *testing.T) { + m := NewModel() + m.TodoItems = []TodoRow{ + {ID: "1", Title: "P0", Priority: "P0"}, + {ID: "2", Title: "P1", Priority: "P1"}, + {ID: "3", Title: "P2", Priority: "P2"}, + } + out := m.RenderTodos(NewStyles(Themes[0]), 80, 20) + for _, title := range []string{"P0", "P1", "P2"} { + if !strings.Contains(out, title) { + t.Errorf("expected %s in todos: %q", title, out) + } + } +} + +// tools_view.go non-runnable already covered, but verify selected tool +func TestRenderToolsViewSelectedNonRunnable(t *testing.T) { + sidebar := NewSidebar() + sidebar.ToolSel = 1 // execute is non-runnable + out := RenderToolsView(sidebar, NewStyles(Themes[0]), 80, 24) + if !strings.Contains(out, "execute") { + t.Errorf("expected execute tool: %q", out) + } +} + +// update.go remaining branches +func TestUpdateWindowSizeCollapsed(t *testing.T) { + m := NewModel() + m.Sidebar.Collapsed = true + m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + if m.Sidebar.Width != 6 { + t.Errorf("expected collapsed width 6, got %d", m.Sidebar.Width) + } +} + +func TestUpdateWindowSizeRightPanel(t *testing.T) { + m := NewModel() + m.RightPanel = true + m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + if m.Sidebar.Width != 22 { + t.Errorf("expected sidebar width 22, got %d", m.Sidebar.Width) + } +} + +func TestUpdateChatKey(t *testing.T) { + m := NewModel() + m.ViewKind = ViewChat + m.initChatInput() + m.Update(tea.KeyPressMsg{Text: "a"}) + if !strings.Contains(m.ChatInput.RawValue(), "a") { + t.Error("expected 'a' in chat input") + } +} + +func TestUpdateEscInterrupt(t *testing.T) { + m := NewModel() + before := len(m.History) + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if len(m.History) != before+1 { + t.Error("expected interrupt history entry") + } +} + +func TestHandleKeyCycleTheme(t *testing.T) { + m := NewModel() + start := m.ThemeIdx + m.Update(tea.KeyPressMsg{Text: "t"}) + if m.ThemeIdx == start { + t.Error("expected theme to cycle") + } +} + +func TestHandleKeyCycleAgent(t *testing.T) { + m := NewModel() + start := m.Footer.AgentIndex + m.Update(tea.KeyPressMsg{Text: "a"}) + if m.Footer.AgentIndex == start { + t.Error("expected agent to cycle") + } +} + +func TestHandleKeyRNotTools(t *testing.T) { + m := NewModel() + m.ViewKind = ViewChat + before := len(m.History) + m.Update(tea.KeyPressMsg{Text: "r"}) + if len(m.History) != before { + t.Error("r outside tools should not append history") + } +} + +func TestHandleKeyNavArrow(t *testing.T) { + m := NewModel() + m.ViewKind = ViewTools + before := m.Sidebar.ToolSel + m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + if m.Sidebar.ToolSel == before { + t.Error("expected tool selection to move down") + } + m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + if m.Sidebar.ToolSel != before { + t.Errorf("expected tool selection to return, got %d", m.Sidebar.ToolSel) + } +} + +func TestHandleKeyLeftRight(t *testing.T) { + m := NewModel() + m.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + // Just ensure no panic and no error +} + +func TestHandlePaletteKeyEnterNoSelection(t *testing.T) { + m := NewModel() + m.OpenPalette() + m.Palette.Filter = []string{} + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + // When filter is empty, enter does nothing and palette stays open. + if !m.Palette.Open { + t.Error("expected palette to stay open when filter is empty") + } +} + +func TestHandlePaletteKeyDefaultChar(t *testing.T) { + m := NewModel() + m.OpenPalette() + before := m.Palette.Query + m.Update(tea.KeyPressMsg{Text: "x"}) + if m.Palette.Query == before { + t.Error("expected query to update with default char") + } +} + +func TestHandleArgInputKeyEsc(t *testing.T) { + m := NewModel() + m.OpenArgInput("discover") + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.ArgInput.Open { + t.Error("expected arg input closed after esc") + } +} + +func TestHandleChatSubmitThinkingCap(t *testing.T) { + prev, had := os.LookupEnv("SIN_NIM_API_KEY") + os.Setenv("SIN_NIM_API_KEY", "fake-key") + defer func() { + if had { + os.Setenv("SIN_NIM_API_KEY", prev) + } else { + os.Unsetenv("SIN_NIM_API_KEY") + } + }() + + origRunner := newChatRunnerHook + origRun := chatRunnerRunHook + newChatRunnerHook = func() (*chat.Runner, error) { return &chat.Runner{}, nil } + chatRunnerRunHook = func(r *chat.Runner, ctx context.Context, prompt string, history []string) (string, error) { + return "reply", nil + } + defer func() { + newChatRunnerHook = origRunner + chatRunnerRunHook = origRun + }() + + m := NewModel() + m.initChatInput() + m.initChatRunner() + m.ChatHistory = make([]string, 500) + handleChatSubmit(m, chat.SubmitMsg{Text: "hi"}) + if len(m.ChatHistory) != 500 { + t.Errorf("expected cap at 500, got %d", len(m.ChatHistory)) + } +} + +func TestUpdateChatSubmit(t *testing.T) { + m := NewModel() + m.ViewKind = ViewChat + m.initChatInput() + // Attach a fake program to avoid synchronous path and verify async + sender := newFakeProgram() + m.Program = sender + + origRunner := newChatRunnerHook + origRun := chatRunnerRunHook + newChatRunnerHook = func() (*chat.Runner, error) { return &chat.Runner{}, nil } + chatRunnerRunHook = func(r *chat.Runner, ctx context.Context, prompt string, history []string) (string, error) { + return "async", nil + } + defer func() { + newChatRunnerHook = origRunner + chatRunnerRunHook = origRun + }() + + m.initChatRunner() + m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + + select { + case <-sender.recv: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for chat submit") + } +} + +func TestRunSelectedWithSkill(t *testing.T) { + orig := newAgentRunnerHook + submitOrig := submitAgentRunnerHook + ar := &agentrunner.AgentRunner{Events: make(chan agentrunner.AgentEvent, 1)} + newAgentRunnerHook = func(ctx context.Context, cfg agentrunner.Config) (*agentrunner.AgentRunner, error) { return ar, nil } + submitAgentRunnerHook = func(r *agentrunner.AgentRunner, ctx context.Context, prompt string) (<-chan struct{}, error) { + return nil, nil + } + defer func() { + newAgentRunnerHook = orig + submitAgentRunnerHook = submitOrig + }() + + m := NewModel() + m.initChatInput() + called := false + m.OnRun = func(name string, args []string) error { called = true; return nil } + // Find a skill tool + for i, t := range m.Sidebar.ToolSubItems { + if t.Name == "websearch" { + m.Sidebar.ToolSel = i + break + } + } + m.runTool("websearch", nil) + if !called { + t.Error("expected OnRun to be called after skill routing") + } +} + +func TestRunToolSkillNoArgs(t *testing.T) { + orig := newAgentRunnerHook + submitOrig := submitAgentRunnerHook + ar := &agentrunner.AgentRunner{Events: make(chan agentrunner.AgentEvent, 1)} + newAgentRunnerHook = func(ctx context.Context, cfg agentrunner.Config) (*agentrunner.AgentRunner, error) { return ar, nil } + submitAgentRunnerHook = func(r *agentrunner.AgentRunner, ctx context.Context, prompt string) (<-chan struct{}, error) { + return nil, nil + } + defer func() { + newAgentRunnerHook = orig + submitAgentRunnerHook = submitOrig + }() + + m := NewModel() + m.initChatInput() + m.OnRun = func(name string, args []string) error { return nil } + m.runTool("websearch", []string{}) + // Skill routing with empty args should not panic and should reach OnRun. +} + +func TestHandleAgentRunnerEventNonEmptyChannel(t *testing.T) { + ch := make(chan agentrunner.AgentEvent, 1) + ch <- agentrunner.AgentEvent{Kind: agentrunner.EventTurn, Detail: "x"} + r := &agentrunner.AgentRunner{Events: ch} + cmd := listenAgentRunnerCmd(r) + msg := cmd() + m, ok := msg.(AgentRunnerMsg) + if !ok || !m.Closed && m.Event.Kind != agentrunner.EventTurn { + t.Errorf("unexpected message: %+v", msg) + } +} + +func TestRunAgentSkillPromptHistoryCap(t *testing.T) { + orig := newAgentRunnerHook + newAgentRunnerHook = func(ctx context.Context, cfg agentrunner.Config) (*agentrunner.AgentRunner, error) { + return nil, errors.New("fail") + } + defer func() { newAgentRunnerHook = orig }() + + m := NewModel() + m.initChatInput() + m.ChatHistory = make([]string, 500) + m.runAgentSkillPrompt("websearch", "") + if len(m.ChatHistory) != 500 { + t.Errorf("expected cap at 500, got %d", len(m.ChatHistory)) + } +} + +func TestRunAgentSkillPromptSubmitErrorCap(t *testing.T) { + orig := newAgentRunnerHook + submitOrig := submitAgentRunnerHook + ar := &agentrunner.AgentRunner{Events: make(chan agentrunner.AgentEvent, 1)} + newAgentRunnerHook = func(ctx context.Context, cfg agentrunner.Config) (*agentrunner.AgentRunner, error) { return ar, nil } + submitAgentRunnerHook = func(r *agentrunner.AgentRunner, ctx context.Context, prompt string) (<-chan struct{}, error) { + return nil, errors.New("fail") + } + defer func() { + newAgentRunnerHook = orig + submitAgentRunnerHook = submitOrig + }() + + m := NewModel() + m.initChatInput() + m.ChatHistory = make([]string, 500) + m.runAgentSkillPrompt("websearch", "") + if len(m.ChatHistory) != 500 { + t.Errorf("expected cap at 500, got %d", len(m.ChatHistory)) + } +} + +// views.go remaining cases +func TestRenderCommandPaletteWithQuery(t *testing.T) { + items := []string{"alpha", "beta", "gamma"} + out := RenderCommandPalette(items, 1, "be", NewStyles(Themes[0]), 80, 24) + if !strings.Contains(out, "beta") { + t.Errorf("expected palette to render: %q", out) + } +} + +func TestRenderSessionsViewDirty(t *testing.T) { + tabs := NewTabs() + tabs.Sessions = []Session{{Name: "S", Dirty: true}} + out := RenderSessionsView(NewStyles(Themes[0]), tabs, 80, 24) + if !strings.Contains(out, "●") { + t.Errorf("expected dirty marker: %q", out) + } +} + +func TestPadContentEmpty(t *testing.T) { + out := padContent("", 5, 3) + if !strings.Contains(out, " ") { + t.Errorf("expected padded empty lines: %q", out) + } +} + +func TestSplitLinesHeight(t *testing.T) { + out := splitLines("a\nb\nc", 2, 2) + if !strings.Contains(out, "a") { + t.Errorf("expected split lines: %q", out) + } +} + +func TestComposeLayoutRightPanel(t *testing.T) { + out := ComposeLayout(NewTabs(), NewSidebar(), ViewTools, "content", "right", NewFooter(80), NewStyles(Themes[0]), 120, 40) + if out == "" { + t.Error("expected non-empty layout with right panel") + } +} + +// ── Additional coverage tests (batch 3) ───────────────────────────────────── + +// chat_input.go remaining branches +func TestUpdateChatSubmitWithAgentCmd(t *testing.T) { + prev, had := os.LookupEnv("SIN_NIM_API_KEY") + os.Setenv("SIN_NIM_API_KEY", "fake-key") + defer func() { + if had { + os.Setenv("SIN_NIM_API_KEY", prev) + } else { + os.Unsetenv("SIN_NIM_API_KEY") + } + }() + + origRunner := newChatRunnerHook + origSubmit := submitAgentRunnerHook + newChatRunnerHook = func() (*chat.Runner, error) { return nil, errors.New("no runner") } + submitAgentRunnerHook = func(r *agentrunner.AgentRunner, ctx context.Context, prompt string) (<-chan struct{}, error) { + return nil, nil + } + defer func() { + newChatRunnerHook = origRunner + submitAgentRunnerHook = origSubmit + }() + + m := NewModel() + m.ViewKind = ViewChat + m.initChatInput() + // Pre-fill agent runner so chat submit returns the agent cmd. + ar := &agentrunner.AgentRunner{Events: make(chan agentrunner.AgentEvent, 1)} + m.AgentRunner = ar + m.Program = newFakeProgram() + + // Type some text first so Ctrl+S submits. + m.Update(tea.KeyPressMsg{Text: "h"}) + _, cmd := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + if cmd == nil { + t.Error("expected non-nil cmd from chat submit with agent runner") + } +} + +// efm_view.go truncate n <= 1 +func TestTruncateOne(t *testing.T) { + if got := truncate("hello", 1); got != "h" { + t.Errorf("truncate(1) = %q", got) + } +} + +// footer.go gap fill branch +func TestFooterRenderGapFillBranch(t *testing.T) { + f := NewFooter(200) + f.SetView(ViewTools) + f.ShowHints = false + f.Selection = "sel" + out := f.Render(NewStyles(Themes[0])) + if out == "" { + t.Error("expected non-empty footer") + } +} + +// notifications_banner.go default icon and padding +func TestRenderBannerDefaultIconCoverage(t *testing.T) { + m := NewModel() + m.SetBanner(&NotificationItem{ID: "n", Title: "T", Message: "M", Type: "custom_type"}) + out := m.RenderBanner(m.Styles, 80) + if !strings.Contains(out, "🔔") { + t.Errorf("expected default bell: %q", out) + } +} + +func TestRenderBannerMessagePadding(t *testing.T) { + m := NewModel() + m.SetBanner(&NotificationItem{ID: "n", Title: "T", Message: "short", Type: "todo_created"}) + out := m.RenderBanner(m.Styles, 80) + if !strings.Contains(out, "short") { + t.Errorf("expected message: %q", out) + } +} + +// sidebar.go width < 18 +func TestSidebarViewWidthClampCoverage(t *testing.T) { + s := NewSidebar() + s.Width = 10 + out := s.View(NewStyles(Themes[0])) + if !strings.Contains(out, "Tools") { + t.Errorf("expected sidebar with clamped width: %q", out) + } +} + +// spinner.go tick +func TestSpinnerTickCmd(t *testing.T) { + cmd := spinnerTick() + if cmd == nil { + t.Error("expected non-nil spinner tick cmd") + } +} + +// subscribe.go hook function used directly +func TestTuiBroadcasterHookDirect(t *testing.T) { + orig := tuiBroadcasterHook + called := false + tuiBroadcasterHook = func() <-chan *notifications.Notification { + called = true + ch := make(chan *notifications.Notification) + close(ch) + return ch + } + defer func() { tuiBroadcasterHook = orig }() + + cmd := ListenForNotifications() + cmd() + if !called { + t.Error("expected hook to be invoked via ListenForNotifications") + } +} + +// tabs.go remaining branches +func TestTabsViewDirtyMarker(t *testing.T) { + tabs := NewTabs() + tabs.Sessions[0].Dirty = true + out := tabs.View(NewStyles(Themes[0])) + if !strings.Contains(out, "●") { + t.Errorf("expected dirty marker: %q", out) + } +} + +func TestTabsActive(t *testing.T) { + tabs := NewTabs() + if got := tabs.Active(); got.Name != "Session 1" { + t.Errorf("Active() = %q", got.Name) + } +} + +func TestTabsCloseActive(t *testing.T) { + tabs := NewTabs() + tabs.Add("x") + idx := tabs.ActiveIdx + tabs.Close(idx) + if len(tabs.Sessions) != 1 { + t.Errorf("expected 1 session, got %d", len(tabs.Sessions)) + } +} + +// todos_view.go limit < 1 +func TestRenderTodosLimitOne(t *testing.T) { + m := NewModel() + m.TodoItems = []TodoRow{{ID: "1", Title: "X"}} + out := m.RenderTodos(NewStyles(Themes[0]), 80, 5) + if !strings.Contains(out, "X") { + t.Errorf("expected todo rendered: %q", out) + } +} + +// tools_view.go non-runnable +func TestRenderToolsViewNonRunnableCoverage(t *testing.T) { + sidebar := NewSidebar() + for i, t := range sidebar.ToolSubItems { + if t.Name == "discover" { + sidebar.ToolSel = i + break + } + } + out := RenderToolsView(sidebar, NewStyles(Themes[0]), 80, 24) + if !strings.Contains(out, "Press r to run with arguments") { + t.Errorf("expected non-runnable hint: %q", out) + } +} + +// update.go remaining branches +func TestFilterPaletteSelectionReset(t *testing.T) { + m := NewModel() + m.Palette.Items = []string{"alpha", "beta", "gamma"} + m.Palette.Filter = m.Palette.Items + m.Palette.Sel = 5 + m.filterPalette("be") + if m.Palette.Sel != 0 { + t.Errorf("expected Sel reset to 0, got %d", m.Palette.Sel) + } +} + +func TestUpdateKeyPressNonChat(t *testing.T) { + m := NewModel() + m.ViewKind = ViewTools + _, cmd := m.Update(tea.KeyPressMsg{Text: "x"}) + if cmd != nil { + t.Error("expected nil cmd for unknown key in tools view") + } +} + +func TestUpdateAgentRunnerMsgReSubscribe(t *testing.T) { + m := NewModel() + ch := make(chan agentrunner.AgentEvent, 1) + m.AgentRunner = &agentrunner.AgentRunner{Events: ch} + _, cmd := m.Update(AgentRunnerMsg{Event: agentrunner.AgentEvent{Kind: agentrunner.EventTurn, Detail: "x"}}) + if cmd == nil { + t.Error("expected re-subscribe cmd") + } +} + +func TestHandleKeyUpConfig(t *testing.T) { + m := NewModel() + m.ViewKind = ViewConfig + m.ConfigSel = 1 + m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + if m.ConfigSel != 0 { + t.Errorf("expected ConfigSel 0, got %d", m.ConfigSel) + } +} + +func TestHandleKeyDownConfig(t *testing.T) { + m := NewModel() + m.ViewKind = ViewConfig + m.ConfigSel = 0 + m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + if m.ConfigSel == 0 { + t.Error("expected ConfigSel to move down") + } +} + +func TestHandleKeyUpTodos(t *testing.T) { + m := NewModel() + m.ViewKind = ViewTodos + m.TodoItems = []TodoRow{{ID: "1", Title: "X"}, {ID: "2", Title: "Y"}} + m.TodoSel = 1 + m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + if m.TodoSel != 0 { + t.Errorf("expected TodoSel 0, got %d", m.TodoSel) + } +} + +func TestHandleKeyDownTodos(t *testing.T) { + m := NewModel() + m.ViewKind = ViewTodos + m.TodoItems = []TodoRow{{ID: "1", Title: "X"}, {ID: "2", Title: "Y"}} + m.TodoSel = 0 + m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + if m.TodoSel != 1 { + t.Errorf("expected TodoSel 1, got %d", m.TodoSel) + } +} + +func TestHandleKeyEnterHelp(t *testing.T) { + m := NewModel() + m.ViewKind = ViewTools + before := len(m.History) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if len(m.History) != before+1 { + t.Error("expected show-help history entry") + } +} + +func TestHandleKeyBannerNoBanner(t *testing.T) { + m := NewModel() + before := len(m.History) + m.Update(tea.KeyPressMsg{Text: "o"}) + m.Update(tea.KeyPressMsg{Text: "d"}) + m.Update(tea.KeyPressMsg{Text: "n"}) + if len(m.History) != before { + t.Error("banner keys without banner should not append history") + } +} + +func TestHandlePaletteKeyEnter(t *testing.T) { + m := NewModel() + m.OpenPalette() + m.Palette.Filter = []string{"theme: next"} + m.Palette.Sel = 0 + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.Palette.Open { + t.Error("expected palette closed") + } +} + +func TestHandleArgInputKeyEnter(t *testing.T) { + m := NewModel() + m.Sidebar.ToolSel = 0 + m.RunSelected() + m.ArgInput.Input.SetValue("--help") + called := false + m.OnRun = func(name string, args []string) error { called = true; return nil } + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.ArgInput.Open { + t.Error("expected arg input closed") + } + if !called { + t.Error("expected OnRun called") + } +} + +func TestPrevView(t *testing.T) { + m := NewModel() + m.ViewKind = ViewTools + m.PrevView() + if m.ViewKind != ViewChat { + t.Errorf("PrevView = %v, want Chat", m.ViewKind) + } +} + +func TestViewModeSubagents(t *testing.T) { + m := NewModel() + m.Width = 100 + m.Height = 30 + m.Ready = true + m.Mode = ModeSubagents + out := m.View().Content + if !strings.Contains(out, "Subagents") { + t.Errorf("expected Subagents popup: %q", out) + } +} + +func TestViewModePalette(t *testing.T) { + m := NewModel() + m.Width = 100 + m.Height = 30 + m.Ready = true + m.Mode = ModePalette + out := m.View().Content + if !strings.Contains(out, "Command Palette") && !strings.Contains(out, "palette") { + t.Errorf("expected palette popup: %q", out) + } +} + +func TestContentWidthSmall(t *testing.T) { + m := NewModel() + m.Width = 10 + m.Sidebar.Width = 0 + if got := m.contentWidth(); got != 20 { + t.Errorf("contentWidth = %d, want 20", got) + } +} + +func TestRightWidthMedium(t *testing.T) { + m := NewModel() + m.RightPanel = true + m.Width = 80 + if got := m.rightWidth(); got != 24 { + t.Errorf("rightWidth = %d, want 24", got) + } +} + +// views.go remaining branches +func TestRenderCommandPaletteQuery(t *testing.T) { + items := []string{"alpha", "beta"} + out := RenderCommandPalette(items, 0, "al", NewStyles(Themes[0]), 80, 24) + if !strings.Contains(out, "alpha") { + t.Errorf("expected alpha in palette: %q", out) + } +} + +func TestRenderSessionsViewWithActive(t *testing.T) { + tabs := NewTabs() + tabs.Sessions = []Session{{Name: "A", Active: true}, {Name: "B", Dirty: true}} + tabs.ActiveIdx = 1 + out := RenderSessionsView(NewStyles(Themes[0]), tabs, 80, 24) + if !strings.Contains(out, "B") { + t.Errorf("expected session B: %q", out) + } +} + +func TestRenderRightPanelNilView(t *testing.T) { + out := RenderRightPanel(nil, ViewSessions, NewStyles(Themes[0]), 30, 20) + if !strings.Contains(out, "no selection") { + t.Errorf("expected no selection: %q", out) + } +} + +func TestComposeLayoutRightWide(t *testing.T) { + out := ComposeLayout(NewTabs(), NewSidebar(), ViewTools, "content", strings.Repeat("r", 100), NewFooter(80), NewStyles(Themes[0]), 120, 40) + if out == "" { + t.Error("expected non-empty layout with wide right content") + } +} + +// ── Additional coverage tests (batch 4) ───────────────────────────────────── + +// chat_program.go: cover non-nil wrapper and Send +func TestProgramFromTeaProgramNonNil(t *testing.T) { + var p *tea.Program + if ProgramFromTeaProgram(p) != nil { + t.Error("expected nil wrapper for nil program") + } +} + +func TestTeaProgramWrapperSend(t *testing.T) { + if os.Getenv("SKIP_TEA_PROGRAM_TEST") != "" { + t.Skip("skipping tea program test") + } + + // Minimal program that starts, receives one message, and quits. + m := minimalTeaModel{done: make(chan struct{})} + p := tea.NewProgram(m, + tea.WithoutRenderer(), + tea.WithInput(strings.NewReader("")), + tea.WithOutput(io.Discard), + tea.WithWindowSize(80, 24), + ) + go func() { + if _, err := p.Run(); err != nil { + // ignore + } + }() + + // Wait for program to be ready before sending. + time.Sleep(50 * time.Millisecond) + wrapper := ProgramFromTeaProgram(p) + wrapper.Send("hello") + + select { + case <-m.done: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for wrapper send") + } + p.Quit() +} + +type minimalTeaModel struct { + done chan struct{} +} + +func (minimalTeaModel) Init() tea.Cmd { return nil } +func (m minimalTeaModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if msg == "hello" { + close(m.done) + } + return m, nil +} +func (minimalTeaModel) View() tea.View { return tea.NewView("") } + +// notifications_banner.go default icon and message padding +func TestRenderBannerDefaultIconAndPadding(t *testing.T) { + m := NewModel() + m.SetBanner(&NotificationItem{ID: "n", Title: "TT", Message: "M", Type: "todo_custom"}) + out := m.RenderBanner(m.Styles, 80) + if !strings.Contains(out, "🔔") && !strings.Contains(out, "TT") { + t.Errorf("expected banner: %q", out) + } +} + +func TestRenderBannerMessagePaddingBranch(t *testing.T) { + m := NewModel() + m.SetBanner(&NotificationItem{ID: "n", Title: "T", Message: "short", Type: "todo_created"}) + out := m.RenderBanner(m.Styles, 80) + if !strings.Contains(out, "short") { + t.Errorf("expected message: %q", out) + } +} + +func TestBannerOpenCmd(t *testing.T) { + cmd := BannerOpenCmd("abc") + msg := cmd() + if m, ok := msg.(BannerKeyMsg); !ok || m.Action != "open:abc" { + t.Errorf("unexpected msg: %#v", msg) + } +} + +func TestBannerDismissCmd(t *testing.T) { + cmd := BannerDismissCmd("xyz") + msg := cmd() + if m, ok := msg.(BannerKeyMsg); !ok || m.Action != "dismiss:xyz" { + t.Errorf("unexpected msg: %#v", msg) + } +} + +// sidebar.go width < 18 +func TestSidebarViewCollapsedWidth(t *testing.T) { + s := NewSidebar() + s.Width = 10 + out := s.View(NewStyles(Themes[0])) + if out == "" { + t.Error("expected non-empty sidebar view") + } +} + +// spinner.go tick +func TestSpinnerTickFunc(t *testing.T) { + cmd := spinnerTick() + if cmd == nil { + t.Error("expected non-nil tick cmd") + } +} + +// subscribe.go tuiBroadcasterHook literal +func TestTuiBroadcasterHookCoverage(t *testing.T) { + // Reset hook to default so the literal function is invoked. + orig := tuiBroadcasterHook + tuiBroadcasterHook = func() <-chan *notifications.Notification { return notifications.TUIBroadcaster() } + defer func() { tuiBroadcasterHook = orig }() + + cmd := ListenForNotifications() + // Do not execute the cmd; just verify the hook literal is the default. + if cmd == nil { + t.Error("expected cmd") + } +} + +// tabs.go remaining branches +func TestTabsActiveDefault(t *testing.T) { + tabs := NewTabs() + tabs.ActiveIdx = -1 + if got := tabs.Active(); got.Name != tabs.Sessions[0].Name { + t.Errorf("Active default = %q", got.Name) + } +} + +func TestTabsActiveOverflow(t *testing.T) { + tabs := NewTabs() + tabs.ActiveIdx = 100 + if got := tabs.Active(); got.Name != tabs.Sessions[0].Name { + t.Errorf("Active overflow = %q", got.Name) + } +} + +func TestTabsViewWithOverflowSelection(t *testing.T) { + tabs := NewTabs() + for i := 0; i < 10; i++ { + tabs.Add("") + } + tabs.ActiveIdx = 8 + out := tabs.View(NewStyles(Themes[0])) + if !strings.Contains(out, "Session") { + t.Errorf("expected sessions: %q", out) + } +} + +func TestLipglossWidthCoverage(t *testing.T) { + if got := lipglossWidth(""); got != 0 { + t.Errorf("lipglossWidth empty = %d", got) + } +} + +// update.go remaining branches +func TestUpdateChatKeyReturnsCmd(t *testing.T) { + m := NewModel() + m.ViewKind = ViewChat + m.initChatInput() + _, cmd := m.Update(tea.KeyPressMsg{Text: "a"}) + _ = cmd + if m.ChatInput.RawValue() != "a" { + t.Error("expected 'a' in chat input") + } +} + +func TestUpdateNonChatKey(t *testing.T) { + m := NewModel() + m.ViewKind = ViewTools + _, cmd := m.Update(tea.KeyPressMsg{Text: "x"}) + if cmd != nil { + t.Error("expected nil cmd for unknown key") + } +} + +func TestUpdateWindowSizeCollapsedCoverage(t *testing.T) { + m := NewModel() + m.Sidebar.Collapsed = true + m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + if m.Sidebar.Width != 6 { + t.Errorf("expected width 6, got %d", m.Sidebar.Width) + } +} + +func TestUpdateWindowSizeRightPanelNarrow(t *testing.T) { + m := NewModel() + m.RightPanel = true + m.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) + if m.Sidebar.Width != 22 { + t.Errorf("expected width 22, got %d", m.Sidebar.Width) + } +} + +func TestPreviousViewCoverage(t *testing.T) { + m := NewModel() + m.ViewKind = ViewTools + m.PreviousView() + if m.ViewKind != ViewHistory { + t.Errorf("PreviousView = %v, want History", m.ViewKind) + } +} + +func TestViewRightPanelMode(t *testing.T) { + m := NewModel() + m.Width = 100 + m.Height = 30 + m.Ready = true + m.RightPanel = true + m.ViewKind = ViewTools + out := m.View().Content + if out == "" { + t.Error("expected non-empty view with right panel") + } +} + +func TestContentWidthCollapsed(t *testing.T) { + m := NewModel() + m.Width = 10 + m.Sidebar.Collapsed = true + m.RightPanel = false + if got := m.contentWidth(); got != 20 { + t.Errorf("contentWidth = %d, want 20", got) + } +} + +func TestRightWidthNarrow(t *testing.T) { + m := NewModel() + m.RightPanel = true + m.Width = 80 + if got := m.rightWidth(); got != 24 { + t.Errorf("rightWidth = %d, want 24", got) + } +} + +func TestHandleKeyViewSwitch(t *testing.T) { + m := NewModel() + for _, key := range []string{"1", "2", "3", "4", "5", "6", "7"} { + m.Update(tea.KeyPressMsg{Text: key}) + } +} + +func TestHandleKeyTabShift(t *testing.T) { + m := NewModel() + m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + m.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) +} + +func TestHandleKeyAskNoPending(t *testing.T) { + m := NewModel() + m.Update(tea.KeyPressMsg{Text: "y"}) + m.Update(tea.KeyPressMsg{Text: "n"}) +} + +func TestHandleKeyPaletteDefaultChar(t *testing.T) { + m := NewModel() + m.OpenPalette() + m.Update(tea.KeyPressMsg{Text: "z"}) + if !strings.Contains(m.Palette.Query, "z") { + t.Error("expected z in query") + } +} + +func TestHandleKeyPaletteEnter(t *testing.T) { + m := NewModel() + m.OpenPalette() + m.Palette.Filter = []string{"theme: next"} + m.Palette.Sel = 0 + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.Palette.Open { + t.Error("expected palette closed") + } +} + +func TestHandleKeyPaletteNoFilter(t *testing.T) { + m := NewModel() + m.OpenPalette() + m.Palette.Filter = []string{} + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.Palette.Open { + t.Error("expected palette to stay open") + } +} + +func TestHandleKeyArgInputEnter(t *testing.T) { + m := NewModel() + m.Sidebar.ToolSel = 0 + m.RunSelected() + called := false + m.OnRun = func(name string, args []string) error { called = true; return nil } + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !called { + t.Error("expected OnRun called") + } +} + +func TestHandleKeyArgInputEsc(t *testing.T) { + m := NewModel() + m.Sidebar.ToolSel = 0 + m.RunSelected() + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.ArgInput.Open { + t.Error("expected arg input closed") + } +} + +func TestHandleKeySubagentsEsc(t *testing.T) { + m := NewModel() + m.OpenSubagents() + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.Mode != ModeNormal { + t.Error("expected ModeNormal") + } +} + +func TestHandleKeySubagentsCtrlX(t *testing.T) { + m := NewModel() + m.OpenSubagents() + m.Update(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl}) + if m.Mode != ModeNormal { + t.Error("expected ModeNormal") + } +} + +// views.go remaining branches +func TestComposeLayoutTinyCoverage(t *testing.T) { + out := ComposeLayout(NewTabs(), NewSidebar(), ViewTools, "c", "", NewFooter(80), NewStyles(Themes[0]), 5, 2) + if out == "" { + t.Error("expected non-empty layout") + } +} + +// ── Additional coverage tests (batch 5) ─────────────────────────────────────── + +func TestApplyChatResponseEmptyText(t *testing.T) { + m := NewModel() + m.ChatHistory = []string{"user: hi"} + applyChatResponseMsg(m, chat.ChatResponseMsg{Text: ""}, 0) + if m.ChatHistory[0] != "assistant: (empty response)" { + t.Errorf("unexpected: %q", m.ChatHistory[0]) + } +} + +func TestRenderBannerInnerWidthClamp(t *testing.T) { + m := NewModel() + m.SetBanner(&NotificationItem{ID: "n", Title: "T", Message: "M", Type: "todo_created"}) + out := m.RenderBanner(m.Styles, 10) + if out == "" { + t.Error("expected non-empty banner") + } +} + +func TestSidebarViewWithTodoBadge(t *testing.T) { + s := NewSidebar() + s.TodoOpen = 5 + s.Width = 30 + out := s.View(NewStyles(Themes[0])) + if !strings.Contains(out, "5") { + t.Errorf("expected badge in sidebar: %q", out) + } +} + +func TestSpinnerTickCmdReturnsMsg(t *testing.T) { + cmd := spinnerTick() + if cmd == nil { + t.Fatal("expected cmd") + } + // Execute the returned command; it should yield a SpinnerTickMsg. + msg := cmd() + if _, ok := msg.(SpinnerTickMsg); !ok { + t.Errorf("expected SpinnerTickMsg, got %#v", msg) + } +} + +func TestListenForNotificationsRealBroadcaster(t *testing.T) { + n := ¬ifications.Notification{ID: "n1", Title: "T", Message: "M", Type: "todo_created"} + notifications.SendTUI(n) + + cmd := ListenForNotifications() + done := make(chan tea.Msg, 1) + go func() { done <- cmd() }() + + select { + case msg := <-done: + if nm, ok := msg.(NotificationMsg); !ok || nm.N.GetID() != "n1" { + t.Errorf("unexpected msg: %#v", msg) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for notification") + } +} + +func TestTabsSelectValid(t *testing.T) { + tabs := NewTabs() + tabs.Add("") + tabs.Select(1) + if tabs.ActiveIdx != 1 { + t.Errorf("ActiveIdx = %d", tabs.ActiveIdx) + } +} + +func TestTabsViewPadding(t *testing.T) { + tabs := NewTabs() + tabs.Width = 200 + out := tabs.View(NewStyles(Themes[0])) + if !strings.Contains(out, "Session 1") { + t.Errorf("expected session: %q", out) + } +} + +func TestRenderToolsViewNonRunnableBatch5(t *testing.T) { + s := NewSidebar() + s.ToolSubItems = []ToolSubItem{ + {Name: "a", Description: "d", Runnable: true}, + {Name: "b", Description: "d", Runnable: false}, + } + s.ToolSel = 0 + out := RenderToolsView(s, NewStyles(Themes[0]), 80, 30) + if !strings.Contains(out, "Runnable without args") { + t.Errorf("expected runnable hint: %q", out) + } + + s.ToolSel = 1 + out = RenderToolsView(s, NewStyles(Themes[0]), 80, 30) + if !strings.Contains(out, "Press r to run with arguments") { + t.Errorf("expected non-runnable hint: %q", out) + } +} + +func TestUpdateSpinnerTickMsg(t *testing.T) { + m := NewModel() + m.Spinner = NewSpinner() + _, cmd := m.Update(SpinnerTickMsg(time.Now())) + if cmd == nil { + t.Error("expected spinner tick cmd") + } +} + +func TestUpdateTodosLoadedMsg(t *testing.T) { + m := NewModel() + m.TodoSel = 5 + m.Update(TodosLoadedMsg{Items: []TodoRow{{ID: "1", Title: "x"}}}) + if m.TodoSel != 0 { + t.Errorf("TodoSel = %d, want 0", m.TodoSel) + } +} + +func TestUpdateAgentRunnerMsgReSubscribeCoverage(t *testing.T) { + ws := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + r, err := agentrunner.NewAgentRunner(ctx, agentrunner.Config{Workspace: ws, SkipMCP: true}) + if err != nil { + t.Fatalf("new runner: %v", err) + } + defer r.Close() + + m := NewModel() + m.AgentRunner = r + _, cmd := m.Update(AgentRunnerMsg{Event: agentrunner.AgentEvent{Kind: agentrunner.EventTurn, Detail: "x"}, Closed: false}) + if cmd == nil { + t.Error("expected re-subscribe cmd") + } +} + +func TestHandlePaletteBackspace(t *testing.T) { + m := NewModel() + m.OpenPalette() + m.Palette.Query = "ab" + m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace}) + if m.Palette.Query != "a" { + t.Errorf("Query = %q", m.Palette.Query) + } +} + +func TestExecutePaletteChoices(t *testing.T) { + cases := []struct { + choice string + check func(*Model) bool + }{ + {"agent: cycle", func(m *Model) bool { return true }}, + {"view: tools", func(m *Model) bool { return m.ViewKind == ViewTools }}, + {"view: sessions", func(m *Model) bool { return m.ViewKind == ViewSessions }}, + {"view: efm", func(m *Model) bool { return m.ViewKind == ViewEFM }}, + {"view: config", func(m *Model) bool { return m.ViewKind == ViewConfig }}, + {"view: history", func(m *Model) bool { return m.ViewKind == ViewHistory }}, + {"sidebar: toggle", func(m *Model) bool { return m.Sidebar.Collapsed }}, + {"quit", func(m *Model) bool { return m.Quitting }}, + {"unknown", func(m *Model) bool { return len(m.History) > 0 }}, + } + for _, c := range cases { + m := NewModel() + m.executePaletteChoice(c.choice) + if !c.check(m) { + t.Errorf("choice %q failed check", c.choice) + } + } +} + +func TestViewContentHeightClamp(t *testing.T) { + m := NewModel() + m.Width = 120 + m.Height = 5 + m.Ready = true + m.ViewKind = ViewTools + out := m.View().Content + if out == "" { + t.Error("expected non-empty view with tiny height") + } +} + +func TestViewArgInputMode(t *testing.T) { + m := NewModel() + m.Width = 120 + m.Height = 40 + m.Ready = true + m.OpenArgInput("cmd") + m.Mode = ModeArgInput + out := m.View().Content + if !strings.Contains(out, "cmd") { + t.Errorf("expected arg input prompt: %q", out) + } +} + +func TestViewNoSelectedTool(t *testing.T) { + m := NewModel() + m.Width = 120 + m.Height = 40 + m.Ready = true + m.Sidebar.Items = []SidebarItem{} + m.Sidebar.Selected = -1 + m.ViewKind = ViewTools + out := m.View().Content + if out == "" { + t.Error("expected non-empty view") + } +} + +func TestRightWidthNoPanel(t *testing.T) { + m := NewModel() + m.RightPanel = false + if got := m.rightWidth(); got != 0 { + t.Errorf("rightWidth = %d, want 0", got) + } +} + +func TestRightWidthSmall(t *testing.T) { + m := NewModel() + m.RightPanel = true + m.Width = 50 + if got := m.rightWidth(); got != 20 { + t.Errorf("rightWidth = %d, want 20", got) + } +} + +func TestContentWidthClamp(t *testing.T) { + m := NewModel() + m.Width = 30 + m.Sidebar.Collapsed = false + m.Sidebar.Width = 22 + m.RightPanel = false + if got := m.contentWidth(); got != 20 { + t.Errorf("contentWidth = %d, want 20", got) + } +} + +func TestHandleChatResponseHistoryLimit(t *testing.T) { + m := NewModel() + for i := 0; i < 505; i++ { + m.ChatHistory = append(m.ChatHistory, fmt.Sprintf("user: %d", i)) + } + m.handleChatResponse(chat.ChatResponseMsg{Text: "hello"}) + if len(m.ChatHistory) != 500 { + t.Errorf("len = %d, want 500", len(m.ChatHistory)) + } + if !strings.HasPrefix(m.ChatHistory[499], "assistant:") { + t.Errorf("expected assistant at end: %q", m.ChatHistory[499]) + } +} + +// ── Additional coverage tests (batch 6) ─────────────────────────────────────── + +func TestRenderBannerInnerWidthTiny(t *testing.T) { + m := NewModel() + m.SetBanner(&NotificationItem{ID: "n", Title: "T", Message: "M", Type: "todo_created"}) + out := m.RenderBanner(m.Styles, 9) + if out == "" { + t.Error("expected banner") + } +} + +func TestRenderBannerWidthOne(t *testing.T) { + m := NewModel() + m.SetBanner(&NotificationItem{ID: "n", Title: "T", Message: "M", Type: "todo_created"}) + out := m.RenderBanner(m.Styles, 1) + if out == "" { + t.Error("expected banner") + } +} + +func TestTabsViewPaddingLargeWidth(t *testing.T) { + tabs := NewTabs() + tabs.Width = 1000 + out := tabs.View(NewStyles(Themes[0])) + if len(out) == 0 { + t.Error("expected non-empty tabs") + } +} + +func TestUpdateChatResponseMsg(t *testing.T) { + m := NewModel() + m.ChatHistory = []string{"user: hi"} + m.Update(chat.ChatResponseMsg{Text: "hello"}) + if !strings.Contains(m.ChatHistory[1], "hello") { + t.Errorf("expected assistant response: %v", m.ChatHistory) + } +} + +func TestViewContentHeightClampSix(t *testing.T) { + m := NewModel() + m.Width = 120 + m.Height = 6 + m.Ready = true + m.ViewKind = ViewTools + out := m.View().Content + if out == "" { + t.Error("expected non-empty view with height 6") + } +} + +func TestViewSelectedToolNil(t *testing.T) { + m := NewModel() + m.Width = 120 + m.Height = 40 + m.Ready = true + m.Sidebar.Selected = -1 + m.Sidebar.ToolSel = -1 + m.ViewKind = ViewTools + out := m.View().Content + if out == "" { + t.Error("expected non-empty view with no selected tool") + } +} + +func TestComposeLayoutTinyHeight(t *testing.T) { + out := ComposeLayout(NewTabs(), NewSidebar(), ViewTools, "c", "", NewFooter(80), NewStyles(Themes[0]), 80, 3) + if out == "" { + t.Error("expected non-empty layout") + } +} diff --git a/cmd/sin-code/tui/views.go b/cmd/sin-code/tui/views.go index 005bf5cd..4af24a1c 100644 --- a/cmd/sin-code/tui/views.go +++ b/cmd/sin-code/tui/views.go @@ -146,9 +146,6 @@ func ComposeLayout(tabs Tabs, sidebar Sidebar, view ViewKind, content string, ri footerView := footer.Render(styles) contentHeight := height - 4 - if contentHeight < 3 { - contentHeight = 3 - } leftWidth := 0 if !sidebar.Collapsed { diff --git a/cmd/sin/skill_cmds.go b/cmd/sin/skill_cmds.go index 7150000a..bd88e752 100644 --- a/cmd/sin/skill_cmds.go +++ b/cmd/sin/skill_cmds.go @@ -7,8 +7,9 @@ import ( "os" "path/filepath" - "github.com/OpenSIN-Code/SIN-Code/pkg/skills" "github.com/spf13/cobra" + + "github.com/OpenSIN-Code/SIN-Code/pkg/skills" ) var ( @@ -168,7 +169,7 @@ func runSkillValidate(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("parse error: %w", err) } - + var errors []string if skill.Name == "" { errors = append(errors, "missing skill name (first heading)") @@ -182,7 +183,7 @@ func runSkillValidate(cmd *cobra.Command, args []string) error { if _, ok := skill.Sections["Anti-Rationalization"]; !ok { errors = append(errors, "missing ## Anti-Rationalization section (recommended)") } - + if len(errors) > 0 { fmt.Printf("❌ Validation failed for %s:\n", path) for _, e := range errors { diff --git a/go.mod b/go.mod index b5e2c818..04c0e888 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( ) require ( + github.com/Songmu/skillsmith v0.1.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -49,6 +50,7 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index a5004e69..c503c0bf 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Songmu/skillsmith v0.1.0 h1:R7tT94OrdsLuDGpdiBbLEjS+nDIRKjxGHeejwDpdf6w= +github.com/Songmu/skillsmith v0.1.0/go.mod h1:agwErnb8lLH48nyoDqEZfoVJovtMGOgqUuTnR1M9ORI= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -56,6 +58,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= diff --git a/internal/headroom/compressor.go b/internal/headroom/compressor.go index 2f35a0b5..d5c483d4 100644 --- a/internal/headroom/compressor.go +++ b/internal/headroom/compressor.go @@ -10,13 +10,13 @@ import ( // Compressor is the main entry point for headroom compression in SIN-Code. // It automatically selects the best available mode (MCP > CLI > disabled). type Compressor struct { - config Config - mode Mode - cliCli *CLIClient - mcpCli *MCPClient - lessons *LessonStore - enabled bool - stats atomic.Value // stores *Stats + config Config + mode Mode + cliCli *CLIClient + mcpCli *MCPClient + lessons *LessonStore + enabled bool + stats atomic.Value // stores *Stats } // SetLessonStore attaches a lesson store so that LearnFromFailure persists diff --git a/internal/headroom/lessons.go b/internal/headroom/lessons.go index 41275433..a73dab73 100644 --- a/internal/headroom/lessons.go +++ b/internal/headroom/lessons.go @@ -16,11 +16,11 @@ import ( // that previously turned out to be important. type Lesson struct { ID string `json:"id"` - Category string `json:"category"` // e.g. "compression", "retrieval", "tooling" - Pattern string `json:"pattern"` // the content pattern that mattered - Insight string `json:"insight"` // what was learned - Weight float64 `json:"weight"` // importance 0..1, higher = keep more - Hits int `json:"hits"` // how often this lesson was reinforced + Category string `json:"category"` // e.g. "compression", "retrieval", "tooling" + Pattern string `json:"pattern"` // the content pattern that mattered + Insight string `json:"insight"` // what was learned + Weight float64 `json:"weight"` // importance 0..1, higher = keep more + Hits int `json:"hits"` // how often this lesson was reinforced CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/headroom/types.go b/internal/headroom/types.go index f31b447e..ce1ad4a6 100644 --- a/internal/headroom/types.go +++ b/internal/headroom/types.go @@ -7,14 +7,14 @@ import ( // Config holds Headroom integration configuration type Config struct { - Enabled bool `json:"enabled"` // HEADROOM_ENABLED - Mode Mode `json:"mode"` // proxy, mcp, cli - ProxyURL string `json:"proxy_url"` // HEADROOM_PROXY_URL (default: http://localhost:8787/v1) - CompressionLevel string `json:"compression_level"` // light, normal, aggressive - LearnFromFailures bool `json:"learn_from_failures"` // HEADROOM_LEARN - StatsEnabled bool `json:"stats_enabled"` // HEADROOM_STATS - Timeout time.Duration `json:"timeout"` // HEADROOM_TIMEOUT (default: 30s) - CacheEnabled bool `json:"cache_enabled"` // HEADROOM_CACHE + Enabled bool `json:"enabled"` // HEADROOM_ENABLED + Mode Mode `json:"mode"` // proxy, mcp, cli + ProxyURL string `json:"proxy_url"` // HEADROOM_PROXY_URL (default: http://localhost:8787/v1) + CompressionLevel string `json:"compression_level"` // light, normal, aggressive + LearnFromFailures bool `json:"learn_from_failures"` // HEADROOM_LEARN + StatsEnabled bool `json:"stats_enabled"` // HEADROOM_STATS + Timeout time.Duration `json:"timeout"` // HEADROOM_TIMEOUT (default: 30s) + CacheEnabled bool `json:"cache_enabled"` // HEADROOM_CACHE } // Mode defines how SIN-Code talks to Headroom @@ -40,13 +40,13 @@ type CompressionResult struct { // Stats provides headroom performance metrics type Stats struct { - TotalRequests int `json:"total_requests"` - TotalCompressed int `json:"total_compressed"` - TotalOriginalTokens int64 `json:"total_original_tokens"` - TotalCompressedTokens int64 `json:"total_compressed_tokens"` - AverageSavings float64 `json:"average_savings_percent"` - CacheHitRate float64 `json:"cache_hit_rate"` - LastLearnTime time.Time `json:"last_learn_time,omitempty"` + TotalRequests int `json:"total_requests"` + TotalCompressed int `json:"total_compressed"` + TotalOriginalTokens int64 `json:"total_original_tokens"` + TotalCompressedTokens int64 `json:"total_compressed_tokens"` + AverageSavings float64 `json:"average_savings_percent"` + CacheHitRate float64 `json:"cache_hit_rate"` + LastLearnTime time.Time `json:"last_learn_time,omitempty"` } // DefaultConfig returns sensible defaults @@ -65,12 +65,12 @@ func DefaultConfig() Config { // Constants for environment variables const ( - EnvEnabled = "HEADROOM_ENABLED" - EnvMode = "HEADROOM_MODE" - EnvProxyURL = "HEADROOM_PROXY_URL" - EnvCompressionLevel = "HEADROOM_COMPRESSION_LEVEL" - EnvLearnFromFailures = "HEADROOM_LEARN" - EnvStatsEnabled = "HEADROOM_STATS" - EnvTimeout = "HEADROOM_TIMEOUT" - EnvCacheEnabled = "HEADROOM_CACHE" + EnvEnabled = "HEADROOM_ENABLED" + EnvMode = "HEADROOM_MODE" + EnvProxyURL = "HEADROOM_PROXY_URL" + EnvCompressionLevel = "HEADROOM_COMPRESSION_LEVEL" + EnvLearnFromFailures = "HEADROOM_LEARN" + EnvStatsEnabled = "HEADROOM_STATS" + EnvTimeout = "HEADROOM_TIMEOUT" + EnvCacheEnabled = "HEADROOM_CACHE" ) diff --git a/pkg/skills/chains.go b/pkg/skills/chains.go index 645d7826..1f16bc6a 100644 --- a/pkg/skills/chains.go +++ b/pkg/skills/chains.go @@ -11,20 +11,20 @@ import ( // ChainStep represents one skill invocation within a chain. type ChainStep struct { - SkillName string `json:"skill"` - OnFailure string `json:"on_failure"` // "abort", "retry", "skip", "fallback" - MaxRetries int `json:"max_retries"` + SkillName string `json:"skill"` + OnFailure string `json:"on_failure"` // "abort", "retry", "skip", "fallback" + MaxRetries int `json:"max_retries"` Variables map[string]string `json:"variables"` } // Chain defines a sequential workflow of skills. type Chain struct { - Name string `json:"name"` - Description string `json:"description"` - Steps []ChainStep `json:"steps"` - OnSuccess string `json:"on_success"` // "stop", "next", "restart" - OnFailure string `json:"on_failure"` - MaxLoops int `json:"max_loops"` // prevent infinite loops + Name string `json:"name"` + Description string `json:"description"` + Steps []ChainStep `json:"steps"` + OnSuccess string `json:"on_success"` // "stop", "next", "restart" + OnFailure string `json:"on_failure"` + MaxLoops int `json:"max_loops"` // prevent infinite loops } // ChainExecutor executes chains with loop detection and retry logic. diff --git a/pkg/skills/generator.go b/pkg/skills/generator.go index e83aca79..4628bfab 100644 --- a/pkg/skills/generator.go +++ b/pkg/skills/generator.go @@ -34,7 +34,7 @@ Return ONLY the SKILL.md content.` _ = systemPrompt _ = userPrompt response := generateSkillTemplate(prompt) - + // Basic validation if !strings.Contains(response, "# ") || !strings.Contains(response, "## Steps") { return "", fmt.Errorf("generated skill is malformed") diff --git a/pkg/skills/monitor.go b/pkg/skills/monitor.go index c7d14e98..d8aab30a 100644 --- a/pkg/skills/monitor.go +++ b/pkg/skills/monitor.go @@ -29,14 +29,14 @@ type SkillMonitor struct { } type SkillMetrics struct { - Name string - TotalRuns int - SuccessCount int - FailureCount int - AvgDuration time.Duration - LastRun time.Time - StepsExecuted map[string]int // step index -> count - ToolCallCount map[string]int // tool name -> count + Name string + TotalRuns int + SuccessCount int + FailureCount int + AvgDuration time.Duration + LastRun time.Time + StepsExecuted map[string]int // step index -> count + ToolCallCount map[string]int // tool name -> count } func NewSkillMonitor(logDir string) (*SkillMonitor, error) { diff --git a/pkg/skills/parser.go b/pkg/skills/parser.go index 2f265827..61ed77e6 100644 --- a/pkg/skills/parser.go +++ b/pkg/skills/parser.go @@ -9,13 +9,13 @@ import ( // Skill represents a parsed agent skill. type Skill struct { - Name string // e.g., "spec", "plan" - Description string // Short description - FullText string // Raw markdown - Sections map[string]string // Key sections: "Overview", "Steps", "Verification", "Anti-Rationalization" - Steps []SkillStep // Parsed numbered steps - Metadata map[string]string // Frontmatter if any - Path string // Source file path + Name string // e.g., "spec", "plan" + Description string // Short description + FullText string // Raw markdown + Sections map[string]string // Key sections: "Overview", "Steps", "Verification", "Anti-Rationalization" + Steps []SkillStep // Parsed numbered steps + Metadata map[string]string // Frontmatter if any + Path string // Source file path } type SkillStep struct { diff --git a/pkg/skills/registry.go b/pkg/skills/registry.go index b0ad6271..4108e5d6 100644 --- a/pkg/skills/registry.go +++ b/pkg/skills/registry.go @@ -11,11 +11,11 @@ import ( ) type Registry struct { - mu sync.RWMutex - skillsDir string // e.g., ~/.sin/skills or ./skills - skills map[string]*Skill // name -> Skill - index map[string]string // name -> path - builtins map[string]bool // built-in skills + mu sync.RWMutex + skillsDir string // e.g., ~/.sin/skills or ./skills + skills map[string]*Skill // name -> Skill + index map[string]string // name -> path + builtins map[string]bool // built-in skills } // NewRegistry creates a skill registry pointing to a directory. diff --git a/pkg/skills/runner.go b/pkg/skills/runner.go index 41f18549..efe90d71 100644 --- a/pkg/skills/runner.go +++ b/pkg/skills/runner.go @@ -8,9 +8,9 @@ import ( ) type Runner struct { - registry *Registry - mcpClient interface{} // MCP-Client zum Aufruf von Tools - agentSys interface{} // Multi-Agent-System (Governor, Critic, Adversary) + registry *Registry + mcpClient interface{} // MCP-Client zum Aufruf von Tools + agentSys interface{} // Multi-Agent-System (Governor, Critic, Adversary) } type RunOptions struct { @@ -21,11 +21,11 @@ type RunOptions struct { } type RunResult struct { - SkillName string + SkillName string StepsExecuted int - Success bool - Outputs map[string]string - Error error + Success bool + Outputs map[string]string + Error error } func NewRunner(reg *Registry, mcpClient interface{}, agentSys interface{}) *Runner { diff --git a/pkg/skills/versioning.go b/pkg/skills/versioning.go index 8525c4d8..f6b36e3e 100644 --- a/pkg/skills/versioning.go +++ b/pkg/skills/versioning.go @@ -10,8 +10,8 @@ import ( type SkillVersion struct { Name string `json:"name"` Version string `json:"version"` - URL string `json:"url"` // git repo or tarball - Hash string `json:"hash"` // git commit hash + URL string `json:"url"` // git repo or tarball + Hash string `json:"hash"` // git commit hash } type SkillManifest struct { diff --git a/scripts/validate_skill.py b/scripts/validate_skill.py new file mode 100644 index 00000000..10e248f4 --- /dev/null +++ b/scripts/validate_skill.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Validate a skill directory against the SIN-Code / OpenCode skill standard. + +Docs: ../SKILL.md + +Usage: + python3 validate_skill.py [--json] [--strict] + python3 validate_skill.py --all-bundled [--json] [--strict] + python3 validate_skill.py --all-sin [--json] [--strict] + +Exit codes: + 0 = valid + 1 = invalid or error +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from pathlib import Path +from typing import Any + +# Required directories per the SIN-Code skill standard. +REQUIRED_DIRS = ("context", "frameworks", "tasks", "templates") + +# Optional but recommended directories for SIN-Code ecosystem skills. +RECOMMENDED_DIRS = ("scripts", "tests", "lib") + +# Frontmatter keys required by OpenCode / SIN-Code. +REQUIRED_FRONTMATTER = ("name", "description") + +# Pattern for valid skill names (OpenCode spec). +NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + + +class Finding: + def __init__(self, path: Path, level: str, message: str) -> None: + self.path = path + self.level = level + self.message = message + + def to_dict(self) -> dict[str, Any]: + return { + "path": str(self.path), + "level": self.level, + "message": self.message, + } + + +class SkillValidator: + def __init__(self, root: Path, strict: bool = False) -> None: + self.root = root.resolve() + self.strict = strict + self.findings: list[Finding] = [] + + def add(self, level: str, message: str, path: Path | None = None) -> None: + self.findings.append(Finding(path or self.root, level, message)) + + def validate(self) -> bool: + if not self.root.is_dir(): + self.add("error", f"Not a directory: {self.root}") + return False + + self._check_skill_md() + self._check_required_dirs() + self._check_recommended_dirs() + if self.strict: + self._check_context_files() + self._check_framework_files() + self._check_task_files() + self._check_template_files() + return not any(f.level == "error" for f in self.findings) + + def _check_skill_md(self) -> None: + skill_md = self.root / "SKILL.md" + if not skill_md.is_file(): + self.add("error", "Missing SKILL.md (must be uppercase)") + return + + text = skill_md.read_text(encoding="utf-8") + if not text.startswith("---"): + self.add("error", "SKILL.md must start with YAML frontmatter delimited by ---") + return + + # Extract frontmatter block. + end = text.find("---", 3) + if end == -1: + self.add("error", "SKILL.md frontmatter is not closed with ---") + return + + try: + import yaml + except ImportError: + # Graceful fallback: simple regex extraction for required keys. + for key in REQUIRED_FRONTMATTER: + if re.search(rf"^{key}:\s*(\S+)", text[:end], re.MULTILINE) is None: + self.add("error", f"Missing frontmatter key: {key}", skill_md) + return + + try: + front = yaml.safe_load(text[3:end]) or {} + except Exception as exc: # noqa: BLE001 + self.add("error", f"Invalid YAML frontmatter: {exc}", skill_md) + return + + for key in REQUIRED_FRONTMATTER: + if key not in front or not front[key]: + self.add("error", f"Missing or empty frontmatter key: {key}", skill_md) + + name = front.get("name") + if name and not NAME_RE.match(name): + self.add( + "error", f"Invalid skill name {name!r} (must match {NAME_RE.pattern})", skill_md + ) + if name and name != self.root.name: + self.add( + "warning", + f"Frontmatter name {name!r} does not match directory {self.root.name!r}", + skill_md, + ) + + desc = front.get("description", "") + if desc and len(desc) > 1024: + self.add("error", f"Description too long ({len(desc)} > 1024 chars)", skill_md) + + if "license" not in front: + self.add("warning", "Missing frontmatter key: license", skill_md) + + compat = front.get("compatibility") + if compat is not None and not isinstance(compat, list): + self.add("error", "compatibility must be a YAML list", skill_md) + + def _check_required_dirs(self) -> None: + for d in REQUIRED_DIRS: + p = self.root / d + if not p.is_dir(): + self.add("error", f"Missing required directory: {d}/") + elif not any(p.iterdir()): + self.add("warning", f"Required directory {d}/ is empty") + + def _check_recommended_dirs(self) -> None: + for d in RECOMMENDED_DIRS: + p = self.root / d + if not p.is_dir(): + self.add("warning", f"Missing recommended directory: {d}/") + + def _check_dir_has_md(self, d: str, purpose: str) -> None: + p = self.root / d + if not p.is_dir(): + return + md_files = list(p.rglob("*.md")) + if not md_files: + self.add("warning", f"{purpose} directory {d}/ contains no .md files") + + def _check_context_files(self) -> None: + self._check_dir_has_md("context", "Context") + + def _check_framework_files(self) -> None: + self._check_dir_has_md("frameworks", "Frameworks") + + def _check_task_files(self) -> None: + self._check_dir_has_md("tasks", "Tasks") + + def _check_template_files(self) -> None: + self._check_dir_has_md("templates", "Templates") + + def report(self) -> dict[str, Any]: + return { + "skill": self.root.name, + "path": str(self.root), + "valid": not any(f.level == "error" for f in self.findings), + "findings": [f.to_dict() for f in self.findings], + } + + +def validate_all_sin(json_out: bool, strict: bool) -> int: + """Validate all SIN-Code skills under ~/.config/opencode/skills/sin-*.""" + skills_root = Path(os.getenv("SIN_SKILLS_DIR", Path.home() / ".config/opencode/skills")) + skill_dirs = sorted(skills_root.glob("sin-*")) + sorted(skills_root.glob("skill-*")) + return _validate_skill_dirs(skill_dirs, json_out, strict) + + +def _find_bundled_skill_dirs(skills_root: Path) -> list[Path]: + """Discover bundled skill directories. + + Top-level directories that contain a SKILL.md are skills themselves. + Directories without a SKILL.md are treated as category folders and their + immediate subdirectories are scanned for skills. + """ + skill_dirs: list[Path] = [] + for d in sorted(skills_root.iterdir()): + if not d.is_dir(): + continue + if (d / "SKILL.md").is_file(): + skill_dirs.append(d) + continue + for sub in sorted(d.iterdir()): + if sub.is_dir() and (sub / "SKILL.md").is_file(): + skill_dirs.append(sub) + return skill_dirs + + +def validate_all_bundled(json_out: bool, strict: bool) -> int: + """Validate all bundled skills under the repo's skills/ directory.""" + repo_root = Path(__file__).resolve().parent.parent + skills_root = repo_root / "skills" + skill_dirs = _find_bundled_skill_dirs(skills_root) + return _validate_skill_dirs(skill_dirs, json_out, strict) + + +def _validate_skill_dirs(skill_dirs: list[Path], json_out: bool, strict: bool) -> int: + reports: list[dict[str, Any]] = [] + failed = 0 + for d in skill_dirs: + v = SkillValidator(d, strict=strict) + v.validate() + reports.append(v.report()) + if not v.report()["valid"]: + failed += 1 + + if json_out: + print(json.dumps({"skills": reports, "failed": failed}, indent=2)) + else: + for r in reports: + status = "✅" if r["valid"] else "❌" + print(f"{status} {r['skill']}") + for f in r["findings"]: + print(f" [{f['level']}] {f['message']}") + print(f"\nTotal: {len(reports)} skills, {failed} failed.") + return 1 if failed else 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Validate skill directory structure") + parser.add_argument("path", nargs="?", help="Path to skill directory") + parser.add_argument( + "--all-sin", + action="store_true", + help="Validate all SIN-Code skills in ~/.config/opencode/skills", + ) + parser.add_argument( + "--all-bundled", + action="store_true", + help="Validate all bundled skills in the repo's skills/ directory", + ) + parser.add_argument("--json", action="store_true", help="Emit JSON report") + parser.add_argument("--strict", action="store_true", help="Enable extra checks") + args = parser.parse_args(argv) + + if args.all_bundled: + return validate_all_bundled(args.json, args.strict) + if args.all_sin: + return validate_all_sin(args.json, args.strict) + + if not args.path: + parser.print_help() + return 1 + + root = Path(args.path) + v = SkillValidator(root, strict=args.strict) + v.validate() + report = v.report() + + if args.json: + print(json.dumps(report, indent=2)) + else: + status = "VALID" if report["valid"] else "INVALID" + print(f"{status}: {report['skill']}") + for f in report["findings"]: + print(f" [{f['level']}] {f['message']}") + + return 0 if report["valid"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/add-endpoint.md b/skills/add-endpoint.md deleted file mode 100644 index 74539fb1..00000000 --- a/skills/add-endpoint.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: add-endpoint -description: Add an API endpoint with an ephemeral mock and verification. -arguments: - - name: spec - description: One-line description of the endpoint (method, path, behavior) - required: true ---- - -Add the endpoint described as: {{spec}}. - -1. Call `mock_env("up")` to get an ephemeral full-stack environment. -2. Implement the endpoint with input validation and error handling. -3. Call `semantic_review(before, after)` on each changed file; justify any - non-"low" risk. -4. Write tests covering success + failure paths. -5. Call `verify_tests(...)`; iterate until the verdict is `pass`. -6. Call `mock_env("down")` to tear down the environment. - -Do not report done while verification is red or the mock is still running. diff --git a/skills/browser-skills/skill-browser-tools/LICENSE b/skills/browser-skills/skill-browser-tools/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/browser-skills/skill-browser-tools/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/browser-skills/skill-browser-tools/SKILL.md b/skills/browser-skills/skill-browser-tools/SKILL.md new file mode 100644 index 00000000..01a28fb9 --- /dev/null +++ b/skills/browser-skills/skill-browser-tools/SKILL.md @@ -0,0 +1,50 @@ +--- +name: skill-browser-tools +description: Browser automation tools for agents. Navigate, click, screenshot, scrape, and interact with web pages. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-browser-tools + +## Overview + +Use browser automation to interact with the web: navigate, click, screenshot, scrape, fill forms. + +## When to Use + +- User asks to visit a website, test a page, screenshot, scrape, or interact with a web app. + +## When NOT to Use + +- The data is available via API (use API instead). +- The user has not authorized web interaction. + +## Core Process + +``` +NAVIGATE → OBSERVE → ACT → VERIFY +``` + +1. Navigate to the target URL. +2. Observe the page state (title, elements, text). +3. Perform actions (click, type, scroll). +4. Verify expected outcome. + +## Safety + +- Respect robots.txt. +- Do not submit sensitive data. +- Avoid automated account creation unless explicitly allowed. + +## Verification + +- [ ] URL reached. +- [ ] Expected element or text present. +- [ ] Action outcome matches expectation. +- [ ] Screenshot or artifact saved if requested. diff --git a/skills/browser-skills/skill-browser-tools/context/triggers.md b/skills/browser-skills/skill-browser-tools/context/triggers.md new file mode 100644 index 00000000..b02abbf4 --- /dev/null +++ b/skills/browser-skills/skill-browser-tools/context/triggers.md @@ -0,0 +1,20 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "browse", "visit", "screenshot", "scrape", "click", "fill form", "web automation", "test this page" + +## Boundaries + +- **In scope:** Navigation, observation, interaction, scraping. +- **Out of scope:** API calls, credential submission. + +## Required Input + +URL or target web page. + +## Tone + +Careful, observational, interaction-aware. diff --git a/skills/browser-skills/skill-browser-tools/frameworks/standards.md b/skills/browser-skills/skill-browser-tools/frameworks/standards.md new file mode 100644 index 00000000..1de8e54c --- /dev/null +++ b/skills/browser-skills/skill-browser-tools/frameworks/standards.md @@ -0,0 +1,18 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Tools + +- Navigate +- Screenshot +- Click +- Type +- Scroll +- Extract text/elements + +## Constraints + +- Respect robots.txt. +- No credentials without explicit permission. +- Headless by default. diff --git a/skills/browser-skills/skill-browser-tools/tasks/workflow.md b/skills/browser-skills/skill-browser-tools/tasks/workflow.md new file mode 100644 index 00000000..2907ef3e --- /dev/null +++ b/skills/browser-skills/skill-browser-tools/tasks/workflow.md @@ -0,0 +1,19 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Confirm URL and user authorization. + +## Execution + +- [ ] Navigate to page. +- [ ] Observe page state. +- [ ] Perform requested action(s). +- [ ] Verify outcome. +- [ ] Save artifact if needed. + +## Post-flight + +- [ ] Report result and any anomalies. diff --git a/skills/browser-skills/skill-browser-tools/templates/output.md b/skills/browser-skills/skill-browser-tools/templates/output.md new file mode 100644 index 00000000..b7e1358e --- /dev/null +++ b/skills/browser-skills/skill-browser-tools/templates/output.md @@ -0,0 +1,22 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Browser Session Summary + +```markdown +## Browser Result + +URL: {url} +Title: {title} +Action: {action} +Outcome: {success/failure} + +## Extracted Text +``` +... +``` + +## Screenshot +{path} +``` diff --git a/skills/browser-skills/skill-browser-tools/templates/prompt.md b/skills/browser-skills/skill-browser-tools/templates/prompt.md new file mode 100644 index 00000000..a3510d59 --- /dev/null +++ b/skills/browser-skills/skill-browser-tools/templates/prompt.md @@ -0,0 +1,13 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## Browse page + +```markdown +URL: {url} + +Use browser automation to {navigate|screenshot|click|scrape}. +Respect robots.txt. Do not submit credentials. +Report the outcome. +``` diff --git a/skills/builtin/build/SKILL.md b/skills/builtin/build/SKILL.md deleted file mode 100644 index 0beec59f..00000000 --- a/skills/builtin/build/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ -# build - -## Overview -Implement a feature from a plan: write code, tests, and verify. - -## Steps -1. Load the current plan from `.sin/plans/`. -2. For each task in the plan: - a. Write the necessary Go code (or other language). - b. Write unit tests covering the change. - c. Run linter and formatter (go fmt, go vet). -3. Run the full test suite. -4. Verify that acceptance criteria are met. -5. If verification fails, go back to step 2 (self-correct). - -## Verification -- [ ] All tests pass. -- [ ] No lint warnings. -- [ ] Code coverage does not decrease. -- [ ] Build succeeds. - -## Anti-Rationalization -| Excuse | Rebuttal | -|--------|----------| -| "Tests are optional for this simple change." | Every change must have tests. | -| "I'll fix linter issues later." | Fix now; later never comes. | - -## Quality Gates -- Use `sin-code test --race` to catch data races. -- Use `sin-code security` to scan for vulnerabilities. -- Governor enforces hard invariant: no commit without passing tests. diff --git a/skills/builtin/plan/SKILL.md b/skills/builtin/plan/SKILL.md deleted file mode 100644 index 1082d6ef..00000000 --- a/skills/builtin/plan/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ -# plan - -## Overview -Break down a specification into concrete, executable tasks for the agent. - -## Steps -1. Load the approved specification from `.sin/specs/`. -2. Identify atomic work units (each unit: one file change or test). -3. Order tasks by dependencies. -4. For each task, define input/output contracts. -5. Output the plan as a checklist. - -## Verification -- [ ] Each task references a spec requirement. -- [ ] Tasks can be executed by an autonomous agent. -- [ ] No task takes more than 10 minutes of agent time. -- [ ] Plan is saved to `.sin/plans/`. - -## Anti-Rationalization -| Excuse | Rebuttal | -|--------|----------| -| "I can keep the plan in my head." | Written plan enables parallel work and review. | -| "Just generate code directly." | Plan first reduces hallucinations. | - -## Quality Gates -- Must have an approved spec present. -- Plan must be validated by the Critic agent. diff --git a/skills/builtin/spec/SKILL.md b/skills/builtin/spec/SKILL.md deleted file mode 100644 index b5be51dc..00000000 --- a/skills/builtin/spec/SKILL.md +++ /dev/null @@ -1,29 +0,0 @@ -# spec - -## Overview -Generate a detailed specification for a feature or change before any code is written. Ensures alignment and prevents rework. - -## Steps -1. Understand the problem: Ask clarifying questions about the feature request. -2. Define scope: List what is included and what is explicitly excluded. -3. Write acceptance criteria: Bullet points that define "done". -4. Create technical design: Describe architecture changes, new components. -5. Review spec with user: Output the spec and await approval. - -## Verification -- [ ] All steps completed in order. -- [ ] Acceptance criteria are testable. -- [ ] Technical design mentions affected modules. -- [ ] User has approved the spec. - -## Anti-Rationalization -| Excuse | Rebuttal | -|--------|----------| -| "The feature is small, we don't need a spec." | Specs prevent misunderstandings even for small changes. | -| "I'll just start coding, it's faster." | Coding without spec leads to 3x more rework. | -| "The user already explained it." | Write it down to ensure shared understanding. | - -## Quality Gates -- Must not proceed to `/plan` without user approval. -- Spec must be stored in `.sin/specs/.md`. -- No code generation in this step. diff --git a/skills/code-skills/skill-code-add-endpoint/LICENSE b/skills/code-skills/skill-code-add-endpoint/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/code-skills/skill-code-add-endpoint/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/code-skills/skill-code-add-endpoint/SKILL.md b/skills/code-skills/skill-code-add-endpoint/SKILL.md new file mode 100644 index 00000000..c4a4b05e --- /dev/null +++ b/skills/code-skills/skill-code-add-endpoint/SKILL.md @@ -0,0 +1,63 @@ +--- +name: skill-code-add-endpoint +description: Add an API endpoint with an ephemeral mock and verification. Use when the user asks to add a new endpoint, route, or API method. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-code-add-endpoint + +## Overview + +Add a new API endpoint, route, or method. Use an ephemeral mock server to verify the contract before touching production code. + +## When to Use + +- User asks to add a new endpoint, route, or API method. +- A new integration point needs to be defined and tested. + +## When NOT to Use + +- The change is purely internal (no API surface). +- The user wants to refactor existing endpoints (use `skill-code-refactor`). + +## Core Process + +``` +DESCRIBE CONTRACT → MOCK → VERIFY → IMPLEMENT → TEST → DOCUMENT +``` + +1. Capture the HTTP method, path, request shape, and response shape. +2. Spin up an ephemeral mock server (e.g., EFM) to test the contract. +3. Verify the mock with a sample request/response. +4. Implement the endpoint in the real codebase. +5. Write tests covering success and failure cases. +6. Update API docs / OpenAPI spec. + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "I'll implement directly." | Mocking first catches contract mismatches early. | +| "Tests are enough." | Tests need a defined contract first. | +| "Docs can wait." | API docs should be updated in the same PR. | + +## Red Flags + +- No request/response contract defined. +- Skipping the mock step. +- Missing failure-case tests. +- Not updating API docs. + +## Verification + +- [ ] Contract defined and approved. +- [ ] Mock verified with sample request/response. +- [ ] Real implementation matches the contract. +- [ ] Tests cover success and failure paths. +- [ ] API docs updated. diff --git a/skills/code-skills/skill-code-add-endpoint/context/triggers.md b/skills/code-skills/skill-code-add-endpoint/context/triggers.md new file mode 100644 index 00000000..62df9391 --- /dev/null +++ b/skills/code-skills/skill-code-add-endpoint/context/triggers.md @@ -0,0 +1,23 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "add an endpoint" +- "new route" +- "new API method" +- "add endpoint" + +## Boundaries + +- **In scope:** New API endpoints, routes, methods. +- **Out of scope:** Internal refactoring, non-API changes. + +## Required Input + +HTTP method, path, request shape, response shape. + +## Tone + +Contract-first, verification-driven. diff --git a/skills/code-skills/skill-code-add-endpoint/frameworks/standards.md b/skills/code-skills/skill-code-add-endpoint/frameworks/standards.md new file mode 100644 index 00000000..2f857be0 --- /dev/null +++ b/skills/code-skills/skill-code-add-endpoint/frameworks/standards.md @@ -0,0 +1,26 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Technology Stack + +- EFM (Ephemeral Full-Stack Mocking) for contract verification. +- Project's HTTP framework (e.g., Gin, FastAPI, Express). + +## Standards + +- Define request/response contract before implementation. +- Mock first, then implement. +- Tests for success and failure paths. +- Update API docs in the same PR. + +## Constraints + +- No production endpoint without a verified contract. +- No breaking changes to existing consumers without migration. + +## Quality Gates + +- Mock verification passes. +- Tests pass. +- Docs updated. diff --git a/skills/code-skills/skill-code-add-endpoint/tasks/workflow.md b/skills/code-skills/skill-code-add-endpoint/tasks/workflow.md new file mode 100644 index 00000000..1e9f17c8 --- /dev/null +++ b/skills/code-skills/skill-code-add-endpoint/tasks/workflow.md @@ -0,0 +1,31 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Capture method, path, request, and response shape. +- [ ] Identify the file(s) to modify. + +## Execution + +- [ ] Task 1: Define contract. + - Acceptance: Request/response schema documented. + - Verify: User confirms. +- [ ] Task 2: Create mock. + - Acceptance: EFM mock server returns expected responses. + - Verify: Sample request/response passes. +- [ ] Task 3: Implement endpoint. + - Acceptance: Code compiles and matches contract. + - Verify: Manual check. +- [ ] Task 4: Write tests. + - Acceptance: Success and failure paths covered. + - Verify: Tests run. +- [ ] Task 5: Update docs. + - Acceptance: API docs / OpenAPI spec updated. + - Verify: Docs reviewed. + +## Post-flight + +- [ ] Summarize the new endpoint and verification evidence. +- [ ] Note any breaking changes or follow-ups. diff --git a/skills/code-skills/skill-code-add-endpoint/templates/output.md b/skills/code-skills/skill-code-add-endpoint/templates/output.md new file mode 100644 index 00000000..3fc49dd8 --- /dev/null +++ b/skills/code-skills/skill-code-add-endpoint/templates/output.md @@ -0,0 +1,25 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Endpoint Addition Report + +```markdown +# Added Endpoint: {method} {path} + +## Contract +- Request: ... +- Response: ... + +## Files Changed +- `... +- `..._test.go` + +## Verification +- Mock: pass +- Tests: pass +- Docs: updated + +## Notes +[breaking changes / follow-ups] +``` diff --git a/skills/code-skills/skill-code-add-endpoint/templates/prompt.md b/skills/code-skills/skill-code-add-endpoint/templates/prompt.md new file mode 100644 index 00000000..d7fd1abe --- /dev/null +++ b/skills/code-skills/skill-code-add-endpoint/templates/prompt.md @@ -0,0 +1,22 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## User asks to add an endpoint + +```markdown +You are adding a new API endpoint for SIN-Code. + +Method: {method} +Path: {path} +Request: {schema} +Response: {schema} + +Constraints: +- Define the contract first. +- Mock with EFM before implementing. +- Write tests for success and failure paths. +- Update API docs. + +Follow tasks/workflow.md. +``` diff --git a/skills/code-skills/skill-code-build/LICENSE b/skills/code-skills/skill-code-build/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/code-skills/skill-code-build/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/code-skills/skill-code-build/SKILL.md b/skills/code-skills/skill-code-build/SKILL.md new file mode 100644 index 00000000..e7fcd51e --- /dev/null +++ b/skills/code-skills/skill-code-build/SKILL.md @@ -0,0 +1,64 @@ +--- +name: skill-code-build +description: Implement a feature from an approved plan with tests and verification. Use when the user asks to build, implement, or code a feature. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-code-build + +## Overview + +Implement a feature from an approved plan. Write code, tests, and verify before reporting done. + +## When to Use + +- User asks to build or implement a feature. +- An approved plan exists in `.sin/plans/`. + +## When NOT to Use + +- No approved plan exists (use `skill-code-plan` / `skill-code-spec` first). +- The task is a single-line fix. + +## Core Process + +``` +LOAD PLAN → IMPLEMENT TASK → TEST → VERIFY → ITERATE +``` + +1. Load the approved plan from `.sin/plans/`. +2. For each task, write the necessary code. +3. Write unit tests covering the change. +4. Run linter and formatter (`go fmt`, `go vet`). +5. Run the full test suite. +6. Verify acceptance criteria are met. +7. If verification fails, iterate. + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "Tests are optional for this simple change." | Every change must have tests. | +| "I'll fix linter issues later." | Later never comes. Fix now. | +| "The plan is just a suggestion." | The plan is the contract. Deviations need user approval. | + +## Red Flags + +- Implementing without a plan. +- Skipping tests or verification. +- Decreasing code coverage. + +## Verification + +- [ ] All plan tasks completed. +- [ ] Unit tests cover success and failure paths. +- [ ] Linter and formatter pass. +- [ ] Full test suite passes. +- [ ] Acceptance criteria met. +- [ ] Code coverage did not decrease. diff --git a/skills/code-skills/skill-code-build/context/triggers.md b/skills/code-skills/skill-code-build/context/triggers.md new file mode 100644 index 00000000..0136c39b --- /dev/null +++ b/skills/code-skills/skill-code-build/context/triggers.md @@ -0,0 +1,23 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "build this feature" +- "implement ..." +- "code ..." +- "write the implementation" + +## Boundaries + +- **In scope:** Implementing planned tasks, writing code and tests. +- **Out of scope:** Planning, spec writing, unrelated refactoring. + +## Required Input + +Approved plan from `.sin/plans/`. + +## Tone + +Disciplined, plan-driven, verification-first. diff --git a/skills/code-skills/skill-code-build/frameworks/standards.md b/skills/code-skills/skill-code-build/frameworks/standards.md new file mode 100644 index 00000000..bdcf9456 --- /dev/null +++ b/skills/code-skills/skill-code-build/frameworks/standards.md @@ -0,0 +1,26 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Technology Stack + +- SIN-Code Go stack (or project language). +- `go fmt`, `go vet`, `go test --race`. + +## Coding Standards + +- One task per focused change. +- Tests live next to production code. +- Handle errors explicitly. + +## Security Constraints + +- No secrets in code. +- Validate external inputs. + +## Quality Gates + +- Tests pass. +- Linter clean. +- Coverage does not decrease. +- Race detector clean (`go test --race`). diff --git a/skills/code-skills/skill-code-build/tasks/workflow.md b/skills/code-skills/skill-code-build/tasks/workflow.md new file mode 100644 index 00000000..5edc1205 --- /dev/null +++ b/skills/code-skills/skill-code-build/tasks/workflow.md @@ -0,0 +1,37 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Confirm the approved plan exists. +- [ ] Identify the files to touch per task. + +## Execution + +- [ ] Task 1: Load plan. + - Acceptance: Plan is read and tasks are understood. + - Verify: Task list extracted. +- [ ] Task 2: Implement task N. + - Acceptance: Code compiles and satisfies task contract. + - Verify: Manual check or targeted test. +- [ ] Task 3: Write tests for task N. + - Acceptance: Success and failure paths covered. + - Verify: Tests run. +- [ ] Task 4: Run linter/formatter. + - Acceptance: No lint errors. + - Verify: `go fmt`, `go vet` clean. +- [ ] Task 5: Run full test suite with race detector. + - Acceptance: All tests pass. + - Verify: `go test --race` passes. +- [ ] Task 6: Verify acceptance criteria. + - Acceptance: Criteria met. + - Verify: Checklist complete. +- [ ] Task 7: Iterate if any verification failed. + - Acceptance: All gates green. + - Verify: No failures. + +## Post-flight + +- [ ] Summarize what was built and verified. +- [ ] Report coverage and any risks. diff --git a/skills/code-skills/skill-code-build/templates/output.md b/skills/code-skills/skill-code-build/templates/output.md new file mode 100644 index 00000000..779c9b26 --- /dev/null +++ b/skills/code-skills/skill-code-build/templates/output.md @@ -0,0 +1,28 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Build Summary + +```markdown +# Built: {feature} + +## Plan Tasks Completed +- [ ] Task 1 +- [ ] Task 2 +... + +## Files Changed +- `file1.go` +- `file1_test.go` +... + +## Verification +- Tests: pass +- Linter: clean +- Race detector: clean +- Coverage: stable/increased + +## Risks +None / [list] +``` diff --git a/skills/code-skills/skill-code-build/templates/prompt.md b/skills/code-skills/skill-code-build/templates/prompt.md new file mode 100644 index 00000000..8606b19b --- /dev/null +++ b/skills/code-skills/skill-code-build/templates/prompt.md @@ -0,0 +1,19 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## User asks to build a feature + +```markdown +You are implementing a feature for SIN-Code from an approved plan. + +Plan: {plan path} + +Constraints: +- Implement one task at a time. +- Write tests for each task. +- Keep linter and race detector clean. +- Do not report done until all acceptance criteria are met. + +Follow tasks/workflow.md. +``` diff --git a/skills/code-skills/skill-code-ceo-audit/LICENSE b/skills/code-skills/skill-code-ceo-audit/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/code-skills/skill-code-ceo-audit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/code-skills/skill-code-ceo-audit/SKILL.md b/skills/code-skills/skill-code-ceo-audit/SKILL.md new file mode 100644 index 00000000..332d55c3 --- /dev/null +++ b/skills/code-skills/skill-code-ceo-audit/SKILL.md @@ -0,0 +1,49 @@ +--- +name: skill-code-ceo-audit +description: CEO-grade SOTA repository audit. Runs 47 quality gates (security, performance, code quality, dependencies, tests, docs, compliance) and produces a board-ready Markdown + SARIF report. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-code-ceo-audit + +## Overview + +Run a comprehensive audit on a repository and produce executive-grade reports. + +## When to Use + +- User says "ceo audit", "audit this repo", "is this production-ready", "pre-release check", "security audit", "compliance audit", "boss-level review". + +## When NOT to Use + +- The task is a quick lint run. +- The repository is not code. + +## Core Process + +``` +SCAN → ANALYZE → SCORE → REPORT +``` + +1. Run dependency, security, lint, test, and quality scanners. +2. Analyze findings against OWASP/ASVS v5.0. +3. Score risk per gate and overall. +4. Produce Markdown + SARIF report. + +## Tools Used + +- bandit, mypy, ruff, gosec, govulncheck, golangci-lint, npm-audit. +- SIN-Code tools: discover, map, grasp, scout, sckg, adw, oracle. + +## Verification + +- [ ] All 47 gates ran. +- [ ] Findings mapped to CWE IDs. +- [ ] Risk score computed. +- [ ] Report saved. diff --git a/skills/code-skills/skill-code-ceo-audit/context/triggers.md b/skills/code-skills/skill-code-ceo-audit/context/triggers.md new file mode 100644 index 00000000..fc2be09c --- /dev/null +++ b/skills/code-skills/skill-code-ceo-audit/context/triggers.md @@ -0,0 +1,20 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "ceo audit", "audit this repo", "check SOTA", "is this production-ready", "pre-release check", "security audit", "compliance audit", "boss-level review", "verdiene ich mein Geld mit diesem Code" + +## Boundaries + +- **In scope:** Repository-wide quality audit. +- **Out of scope:** Single-file lint fixes. + +## Required Input + +Repository path. + +## Tone + +Executive, risk-aware, evidence-based. diff --git a/skills/code-skills/skill-code-ceo-audit/frameworks/standards.md b/skills/code-skills/skill-code-ceo-audit/frameworks/standards.md new file mode 100644 index 00000000..bafce513 --- /dev/null +++ b/skills/code-skills/skill-code-ceo-audit/frameworks/standards.md @@ -0,0 +1,20 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Standards + +- OWASP/ASVS v5.0 +- CWE mapping +- SARIF output + +## Quality Gates + +47 gates across security, performance, code quality, dependencies, tests, docs, compliance. + +## Tools + +- Go: gosec, govulncheck, golangci-lint +- Python: bandit, mypy, ruff +- Node: npm-audit +- SIN-Code: discover, map, grasp, scout, sckg, adw, oracle diff --git a/skills/code-skills/skill-code-ceo-audit/tasks/workflow.md b/skills/code-skills/skill-code-ceo-audit/tasks/workflow.md new file mode 100644 index 00000000..7269c6fd --- /dev/null +++ b/skills/code-skills/skill-code-ceo-audit/tasks/workflow.md @@ -0,0 +1,23 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Confirm repository path. +- [ ] Detect languages/stacks. + +## Execution + +- [ ] Run security scanners. +- [ ] Run lint and type checkers. +- [ ] Run tests. +- [ ] Run dependency audits. +- [ ] Run SIN-Code structural analysis. +- [ ] Map findings to CWE/ASVS. +- [ ] Compute risk score. +- [ ] Generate Markdown + SARIF report. + +## Post-flight + +- [ ] Summarize top risks and remediation. diff --git a/skills/code-skills/skill-code-ceo-audit/templates/output.md b/skills/code-skills/skill-code-ceo-audit/templates/output.md new file mode 100644 index 00000000..b7a3c7c9 --- /dev/null +++ b/skills/code-skills/skill-code-ceo-audit/templates/output.md @@ -0,0 +1,30 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Executive Summary + +```markdown +# CEO Audit: {repo} + +## Overall Grade: {A/B/C/D/F} +## Risk Score: {x}/100 + +## Top 3 Risks +1. ... +2. ... +3. ... + +## Gate Breakdown +- Security: {n} findings +- Performance: {n} findings +- Code Quality: {n} findings +- Dependencies: {n} findings +- Tests: {n} findings +- Docs: {n} findings +- Compliance: {n} findings + +## Report Files +- Markdown: {path} +- SARIF: {path} +``` diff --git a/skills/code-skills/skill-code-ceo-audit/templates/prompt.md b/skills/code-skills/skill-code-ceo-audit/templates/prompt.md new file mode 100644 index 00000000..99e38f86 --- /dev/null +++ b/skills/code-skills/skill-code-ceo-audit/templates/prompt.md @@ -0,0 +1,12 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## Audit repository + +```markdown +Repository: {path} + +Run a CEO-grade audit. Include 47 gates, OWASP/ASVS v5.0 mapping, CWE IDs, +Markdown + SARIF report. Return executive summary. +``` diff --git a/skills/code-skills/skill-code-codocs/LICENSE b/skills/code-skills/skill-code-codocs/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/code-skills/skill-code-codocs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/code-skills/skill-code-codocs/SKILL.md b/skills/code-skills/skill-code-codocs/SKILL.md new file mode 100644 index 00000000..59d90881 --- /dev/null +++ b/skills/code-skills/skill-code-codocs/SKILL.md @@ -0,0 +1,61 @@ +--- +name: skill-code-codocs +description: Maintain the two-layer documentation standard (CoDocs .doc.md companions + inline comments) for every meaningful code file. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-code-codocs + +## Overview + +Ensure every meaningful code file has two documentation layers: a `.doc.md` companion and professional inline comments. + +## When to Use + +- User asks to add documentation. +- After a behavioral change that needs docs. +- When creating or modifying a significant code file. + +## When NOT to Use + +- For throwaway scripts in `debug/` or `tmp/`. +- For pure config files without logic. + +## Core Process + +``` +AUDIT FILES → ADD/UPDATE .doc.md → ADD INLINE COMMENTS → VALIDATE +``` + +1. Identify files that changed or were created. +2. For each, add or update its `.doc.md` companion. +3. Add inline comments for non-obvious logic. +4. Validate that `.doc.md` references resolve. + +## CoDocs Companion + +- File naming: `router.py` → `router.doc.md`. +- First line of code file: `// Docs: router.doc.md`. +- Contents: what, why, dependencies, limits, examples, caveats. + +## Inline Comments + +- File header with `Purpose` and `Docs`. +- Public API docstrings. +- Context comments for non-obvious logic. +- Section separators for large files. +- Explain magic values and config keys. +- Tests describe scenario + expected behavior. + +## Verification + +- [ ] Every changed file has a `.doc.md` or is exempt. +- [ ] Code file first line references the doc. +- [ ] Inline comments explain non-obvious logic. +- [ ] `sin codocs check` passes (or equivalent manual check). diff --git a/skills/code-skills/skill-code-codocs/context/triggers.md b/skills/code-skills/skill-code-codocs/context/triggers.md new file mode 100644 index 00000000..9cd77895 --- /dev/null +++ b/skills/code-skills/skill-code-codocs/context/triggers.md @@ -0,0 +1,20 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "document this", "add docs", "CoDocs", "doc.md", "update documentation", "inline comments" + +## Boundaries + +- **In scope:** `.doc.md` companions and inline comments. +- **Out of scope:** README.md, `docs/` architecture docs, pure config files. + +## Required Input + +Files to document. + +## Tone + +Thorough but concise. diff --git a/skills/code-skills/skill-code-codocs/frameworks/standards.md b/skills/code-skills/skill-code-codocs/frameworks/standards.md new file mode 100644 index 00000000..34e35e25 --- /dev/null +++ b/skills/code-skills/skill-code-codocs/frameworks/standards.md @@ -0,0 +1,25 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## CoDocs Standard + +- Every meaningful code file gets a `.doc.md` companion. +- Code file references doc in the first line. +- Keep implementation details in inline comments, not doc. + +## Inline Comment Standard + +- File header: Purpose + Docs. +- Public API: docstrings. +- Non-obvious logic: context comments. +- Section separators for files >100 lines. +- Magic values: explain. +- Tests: scenario + expected behavior. + +## Exemptions + +- `docs/` folder. +- `README.md`. +- Pure config files without logic. +- Throwaway scripts in `debug/`, `tmp/`, experimental branches. diff --git a/skills/code-skills/skill-code-codocs/tasks/workflow.md b/skills/code-skills/skill-code-codocs/tasks/workflow.md new file mode 100644 index 00000000..33108528 --- /dev/null +++ b/skills/code-skills/skill-code-codocs/tasks/workflow.md @@ -0,0 +1,22 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Identify changed or new files. +- [ ] Determine which are exempt. + +## Execution + +- [ ] For each file, create/update `.doc.md`. +- [ ] Add/update file header and inline comments. +- [ ] Add docstrings to public APIs. +- [ ] Add section separators if needed. +- [ ] Explain magic values. +- [ ] Document test scenarios. + +## Post-flight + +- [ ] Run `sin codocs check` or equivalent. +- [ ] Report coverage. diff --git a/skills/code-skills/skill-code-codocs/templates/output.md b/skills/code-skills/skill-code-codocs/templates/output.md new file mode 100644 index 00000000..79f9ba8d --- /dev/null +++ b/skills/code-skills/skill-code-codocs/templates/output.md @@ -0,0 +1,19 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Documentation Summary + +```markdown +## sin-codocs Result + +Files documented: {n} +Files exempt: {n} + +### Updated companions +- `file1.go` → `file1.doc.md` +- ... + +### Validation +- `sin codocs check`: pass/fail +``` diff --git a/skills/code-skills/skill-code-codocs/templates/prompt.md b/skills/code-skills/skill-code-codocs/templates/prompt.md new file mode 100644 index 00000000..ee3437d3 --- /dev/null +++ b/skills/code-skills/skill-code-codocs/templates/prompt.md @@ -0,0 +1,18 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## Document files + +```markdown +Files to document: +- file1.go +- file2.py + +Apply the CoDocs two-layer standard: +- Create `.doc.md` companions. +- Add inline comments for non-obvious logic. +- Add docstrings to public APIs. + +Run `sin codocs check` if available. +``` diff --git a/skills/code-skills/skill-code-create/LICENSE b/skills/code-skills/skill-code-create/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/code-skills/skill-code-create/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/code-skills/skill-code-create/SKILL.md b/skills/code-skills/skill-code-create/SKILL.md new file mode 100644 index 00000000..9e3b974b --- /dev/null +++ b/skills/code-skills/skill-code-create/SKILL.md @@ -0,0 +1,64 @@ +--- +name: skill-code-create +description: Creates and validates new SIN-Code / OpenCode skills. Use when the user says "create skill", "new skill", "skill-code-create", "/skill-code-create", or asks how to build a skill. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-code-create + +## Overview + +Create a new SIN-Code / OpenCode compatible skill from a template. Produces a valid skill directory with SKILL.md, context/, frameworks/, tasks/, templates/, and optional scripts/tests/lib. + +## When to Use + +- User says "create skill", "new skill", "skill-code-create", or "/skill-code-create". +- User asks how to build a skill. +- A new agent capability needs to be packaged as a skill. + +## When NOT to Use + +- The user wants to update an existing skill (use the skill's own files). +- The task is a one-off prompt, not a reusable skill. + +## Core Process + +``` +GATHER INTENT → CHOOSE NAME → SCAFFOLD → WRITE SKILL.md → ADD CONTEXT/FRAMEWORKS/TASKS/TEMPLATES → VALIDATE → COMMIT +``` + +1. Gather the skill's purpose, trigger phrases, and scope. +2. Pick a valid kebab-case name. +3. Scaffold the directory with `create_skill.py`. +4. Write SKILL.md with frontmatter + overview + when to use + when NOT to use + core process + verification. +5. Fill context/triggers.md, frameworks/standards.md, tasks/workflow.md, templates/output.md, templates/prompt.md. +6. Run `validate_skill.py --strict` on the new skill. +7. Optionally add scripts/tests/lib. + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "A single SKILL.md is enough." | SIN-Code requires context/frameworks/tasks/templates for maintainability. | +| "I'll skip validation." | Invalid skills fail CI and won't be discovered by agents. | +| "The name doesn't matter." | Must match `^[a-z0-9]+(-[a-z0-9]+)*$` and the directory name. | + +## Red Flags + +- Missing required directories. +- Broken YAML frontmatter. +- No verification checklist. +- Name mismatch between frontmatter and directory. + +## Verification + +- [ ] `validate_skill.py --strict` passes. +- [ ] `validate_skill.py --all-bundled --strict` still passes. +- [ ] SKILL.md is clear and complete. +- [ ] Symlink created in `.claude/skills/` if the user wants OpenCode/Claude discovery. diff --git a/skills/code-skills/skill-code-create/context/triggers.md b/skills/code-skills/skill-code-create/context/triggers.md new file mode 100644 index 00000000..9de2d38f --- /dev/null +++ b/skills/code-skills/skill-code-create/context/triggers.md @@ -0,0 +1,24 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "create skill" +- "new skill" +- "skill-create" +- "/skill-create" +- "how to build a skill" + +## Boundaries + +- **In scope:** Creating new SIN-Code / OpenCode skills from scratch. +- **Out of scope:** Updating existing skills, one-off prompts. + +## Required Input + +Skill purpose and at least one trigger phrase. + +## Tone + +Helpful, template-driven, quality-focused. diff --git a/skills/code-skills/skill-code-create/frameworks/standards.md b/skills/code-skills/skill-code-create/frameworks/standards.md new file mode 100644 index 00000000..a4346374 --- /dev/null +++ b/skills/code-skills/skill-code-create/frameworks/standards.md @@ -0,0 +1,29 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Technology Stack + +- SIN-Code skill standard. +- `scripts/validate_skill.py` for validation. +- Optional `scripts/create_skill.py` for scaffolding. + +## Skill Standard + +- SKILL.md with YAML frontmatter (`name`, `description`, `license`, `compatibility`, `metadata`). +- Required directories: `context/`, `frameworks/`, `tasks/`, `templates/`. +- Recommended directories: `scripts/`, `tests/`, `lib/`. +- `compatibility` must be a YAML list. + +## Constraints + +- Skill name must match directory name. +- No copyrighted material without license. +- Keep templates actionable. + +## Quality Gates + +- Strict validator passes. +- Frontmatter valid. +- All required directories populated. +- LICENSE file present. diff --git a/skills/code-skills/skill-code-create/tasks/workflow.md b/skills/code-skills/skill-code-create/tasks/workflow.md new file mode 100644 index 00000000..cf5d1977 --- /dev/null +++ b/skills/code-skills/skill-code-create/tasks/workflow.md @@ -0,0 +1,37 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Understand the skill's purpose and triggers. +- [ ] Choose a valid name. + +## Execution + +- [ ] Task 1: Scaffold directory. + - Acceptance: `context/`, `frameworks/`, `tasks/`, `templates/` exist. + - Verify: Directory listing. +- [ ] Task 2: Write SKILL.md. + - Acceptance: Frontmatter + overview + when to use + core process + verification. + - Verify: Validator parses frontmatter. +- [ ] Task 3: Fill context/triggers.md. + - Acceptance: Triggers and boundaries documented. + - Verify: File not empty. +- [ ] Task 4: Fill frameworks/standards.md. + - Acceptance: Standards and constraints documented. + - Verify: File not empty. +- [ ] Task 5: Fill tasks/workflow.md. + - Acceptance: Pre-flight, execution, post-flight tasks documented. + - Verify: File not empty. +- [ ] Task 6: Fill templates/output.md and templates/prompt.md. + - Acceptance: Reusable templates provided. + - Verify: File not empty. +- [ ] Task 7: Validate. + - Acceptance: `validate_skill.py --strict` passes. + - Verify: Exit code 0. + +## Post-flight + +- [ ] Create `.claude/skills/` symlink if needed. +- [ ] Summarize the new skill and its validation status. diff --git a/skills/code-skills/skill-code-create/templates/output.md b/skills/code-skills/skill-code-create/templates/output.md new file mode 100644 index 00000000..2edaed82 --- /dev/null +++ b/skills/code-skills/skill-code-create/templates/output.md @@ -0,0 +1,25 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Skill Creation Report + +```markdown +# Created Skill: {name} + +## Files +- SKILL.md +- context/triggers.md +- frameworks/standards.md +- tasks/workflow.md +- templates/output.md +- templates/prompt.md +- LICENSE + +## Validation +- `validate_skill.py --strict`: pass + +## Next Steps +- [ ] Add optional scripts/tests/lib. +- [ ] Create `.claude/skills/{name}` symlink. +``` diff --git a/skills/code-skills/skill-code-create/templates/prompt.md b/skills/code-skills/skill-code-create/templates/prompt.md new file mode 100644 index 00000000..4c567c5b --- /dev/null +++ b/skills/code-skills/skill-code-create/templates/prompt.md @@ -0,0 +1,21 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## User wants to create a skill + +```markdown +You are creating a new SIN-Code skill. + +Name: {name} +Purpose: {purpose} +Trigger phrases: {list} + +Constraints: +- Use the SIN-Code skill standard. +- Include required directories. +- Write YAML frontmatter with name, description, license, compatibility. +- Validate with `validate_skill.py --strict`. + +Follow tasks/workflow.md. +``` diff --git a/skills/code-skills/skill-code-docs/LICENSE b/skills/code-skills/skill-code-docs/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/code-skills/skill-code-docs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/code-skills/skill-code-docs/SKILL.md b/skills/code-skills/skill-code-docs/SKILL.md new file mode 100644 index 00000000..b89cf5c9 --- /dev/null +++ b/skills/code-skills/skill-code-docs/SKILL.md @@ -0,0 +1,81 @@ +--- +name: skill-code-docs +description: Collaborative document coauthoring for READMEs, ADRs, specs, design docs, RFCs, API docs, and changelogs via MCP. Use for structured document workflows with the user. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: OpenSIN-Code + version: 1.0.0 +--- + +# skill-code-docs + +## Overview + +Create documents collaboratively through a structured workflow: gather context → propose outline → draft sections → review → render → export. + +## When to Use + +- "write a README" +- "create an ADR" / "draft a spec" +- "design doc" / "RFC" / "API docs" +- "changelog" +- Interactive coauthoring with the user. + +## When NOT to Use + +- `.doc.md` companion files for code (use `skill-code-codocs`). +- Image generation (use `skill-design-image`). +- Inline code comments (use `skill-code-codocs`). + +## Core Process + +``` +START → GATHER CONTEXT → OUTLINE → DRAFT → REVIEW → RENDER → EXPORT +``` + +1. `doc_start` — choose type and title. +2. `doc_context_gather` — collect project context and goals. +3. `doc_outline_propose` — generate outline from template + context. +4. `doc_section_draft` — draft each section with clarifying questions. +5. `doc_review` — check completeness, accuracy, clarity. +6. `doc_format_render` — render to markdown / html / pdf. +7. `doc_export` — save to file, git commit, or share link. + +## Doc Types + +| Type | Use case | +|---|---| +| README | Project overview | +| ADR | Architecture Decision Record | +| SPEC | Technical specification | +| DESIGN | Design document | +| RFC | Request for comments | +| API | API documentation | +| CHANGELOG | Release notes | + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "I'll write the doc in one shot." | Structured drafting produces better docs. | +| "I don't need user input." | Clarifying questions surface missing context. | +| "Review is optional." | Review catches gaps before export. | + +## Red Flags + +- Skipping context gathering. +- Drafting without an outline. +- Skipping review. +- Exporting to wrong destination. + +## Verification + +- [ ] Doc type and title chosen. +- [ ] Context gathered. +- [ ] Outline approved. +- [ ] All sections drafted. +- [ ] Review passed. +- [ ] Rendered and exported to correct destination. diff --git a/skills/code-skills/skill-code-docs/context/triggers.md b/skills/code-skills/skill-code-docs/context/triggers.md new file mode 100644 index 00000000..56d20361 --- /dev/null +++ b/skills/code-skills/skill-code-docs/context/triggers.md @@ -0,0 +1,24 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "write a README" +- "create an ADR" / "draft a spec" +- "design doc" / "RFC" / "API docs" +- "changelog" +- "coauthor a document" + +## Boundaries + +- **In scope:** Standalone documents (README, ADR, SPEC, DESIGN, RFC, API, CHANGELOG). +- **Out of scope:** `.doc.md` companions, images, inline code comments. + +## Required Input + +Document type and title. + +## Tone + +Collaborative, structured, quality-focused. diff --git a/skills/code-skills/skill-code-docs/frameworks/standards.md b/skills/code-skills/skill-code-docs/frameworks/standards.md new file mode 100644 index 00000000..4176b1d4 --- /dev/null +++ b/skills/code-skills/skill-code-docs/frameworks/standards.md @@ -0,0 +1,27 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Technology Stack + +- `sin-doc-coauthoring` MCP server / CLI. +- Document state stored in `~/.config/sin-doc-coauthoring/sessions/`. + +## Standards + +- Always gather context before outlining. +- Draft each section with clarifying questions. +- Review before export. +- Export to the requested destination. + +## Constraints + +- Do not use for code companion files. +- Do not skip review for external-facing docs. + +## Quality Gates + +- Outline approved. +- All sections drafted. +- Review passed. +- Export successful. diff --git a/skills/code-skills/skill-code-docs/tasks/workflow.md b/skills/code-skills/skill-code-docs/tasks/workflow.md new file mode 100644 index 00000000..f87298eb --- /dev/null +++ b/skills/code-skills/skill-code-docs/tasks/workflow.md @@ -0,0 +1,37 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Identify document type and title. +- [ ] Confirm project path if needed. + +## Execution + +- [ ] Task 1: Start session. + - Acceptance: `doc_start(type, title)` returns session ID. + - Verify: Session created. +- [ ] Task 2: Gather context. + - Acceptance: Project context and goals collected. + - Verify: `doc_context_gather` completed. +- [ ] Task 3: Propose outline. + - Acceptance: Outline generated from template + context. + - Verify: User approved or modified outline. +- [ ] Task 4: Draft sections. + - Acceptance: Each section drafted with clarifying questions answered. + - Verify: All sections present. +- [ ] Task 5: Review. + - Acceptance: `doc_review` passes completeness, accuracy, clarity. + - Verify: Issues addressed. +- [ ] Task 6: Render. + - Acceptance: Document rendered to requested format. + - Verify: Output produced. +- [ ] Task 7: Export. + - Acceptance: File saved / committed / shared. + - Verify: Destination correct. + +## Post-flight + +- [ ] Provide final document path and summary. +- [ ] Offer follow-up edits. diff --git a/skills/code-skills/skill-code-docs/templates/output.md b/skills/code-skills/skill-code-docs/templates/output.md new file mode 100644 index 00000000..525c206d --- /dev/null +++ b/skills/code-skills/skill-code-docs/templates/output.md @@ -0,0 +1,31 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Document Coauthoring Report + +```markdown +# Document: {title} + +## Type +{type} + +## Session +{id} + +## Outline +- ... +- ... + +## Status +- [x] Context gathered +- [x] Outline approved +- [x] Sections drafted +- [x] Review passed +- [x] Rendered +- [x] Exported + +## Output +- Path: ... +- Format: markdown/html/pdf +``` diff --git a/skills/code-skills/skill-code-docs/templates/prompt.md b/skills/code-skills/skill-code-docs/templates/prompt.md new file mode 100644 index 00000000..aa6e3115 --- /dev/null +++ b/skills/code-skills/skill-code-docs/templates/prompt.md @@ -0,0 +1,22 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## User wants to coauthor a document + +```markdown +You are coauthoring a document for SIN-Code. + +Type: {README | ADR | SPEC | DESIGN | RFC | API | CHANGELOG} +Title: {title} +Project path: {path} + +Constraints: +- Gather context first. +- Propose outline, get approval. +- Draft sections with clarifying questions. +- Review before export. +- Render to requested format and export to correct destination. + +Follow tasks/workflow.md. +``` diff --git a/skills/code-skills/skill-code-mcp-builder/LICENSE b/skills/code-skills/skill-code-mcp-builder/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/code-skills/skill-code-mcp-builder/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/code-skills/skill-code-mcp-builder/SKILL.md b/skills/code-skills/skill-code-mcp-builder/SKILL.md new file mode 100644 index 00000000..b23f1f63 --- /dev/null +++ b/skills/code-skills/skill-code-mcp-builder/SKILL.md @@ -0,0 +1,76 @@ +--- +name: skill-code-mcp-builder +description: Meta-skill that scaffolds new MCP servers in python-fastmcp, node-mcp, or go-mcp. Provides tools for scaffold, template_list, add_tool, test, register, validate, publish, and audit. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: OpenSIN-Code + version: 1.0.0 +--- + +# skill-code-mcp-builder + +## Overview + +Scaffold, extend, validate, publish, and audit MCP servers using the canonical OpenSIN-Code pattern: `pyproject.toml` + `src//mcp_server.py` + `tests/` + `*.doc.md` + `skill-code-ceo-audit.yml`. + +## When to Use + +- "Scaffold a new MCP server" +- "Create a new MCP tool" +- "Generate MCP project" +- "Add a tool to my existing MCP server" +- "Validate / publish / audit my MCP server" + +## When NOT to Use + +- Consuming an existing MCP server (use `mcpclient` / `opencode.json` config). +- Writing application code unrelated to MCP. + +## Core Process + +``` +SCAFFOLD → ADD TOOLS → TEST → VALIDATE → AUDIT → PUBLISH → REGISTER +``` + +1. Choose template (`python-fastmcp`, `node-mcp`, `go-mcp`). +2. Scaffold the project with initial tools. +3. Add or refine tools. +4. Generate tests. +5. Validate structure and types. +6. Run CEO audit. +7. Publish and register in `opencode.json`. + +## Templates + +| Template | Stack | Entry point | +|---|---|---| +| `python-fastmcp` | Python + FastMCP | `src//mcp_server.py` | +| `node-mcp` | Node.js + `@modelcontextprotocol/sdk` | `src/index.js` | +| `go-mcp` | Go + `github.com/modelcontextprotocol/go-sdk` | `main.go` | + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "I'll scaffold manually." | The canonical pattern ensures consistency and auditability. | +| "Tests are optional." | Every tool needs a generated test. | +| "I'll skip audit." | MCP servers must pass CEO audit before publish. | + +## Red Flags + +- Scaffolding without tests. +- Publishing without validation. +- Skipping CEO audit. +- Not registering the server in `opencode.json`. + +## Verification + +- [ ] Template and tools chosen. +- [ ] Project scaffolded. +- [ ] Tests generated for each tool. +- [ ] Validation passes. +- [ ] CEO audit passes. +- [ ] Published/registered successfully. diff --git a/skills/code-skills/skill-code-mcp-builder/context/triggers.md b/skills/code-skills/skill-code-mcp-builder/context/triggers.md new file mode 100644 index 00000000..5054cfc2 --- /dev/null +++ b/skills/code-skills/skill-code-mcp-builder/context/triggers.md @@ -0,0 +1,26 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "scaffold a new MCP server" +- "create a new MCP tool" +- "generate MCP project" +- "add a tool to my MCP server" +- "validate MCP server" +- "publish MCP server" +- "audit MCP server" + +## Boundaries + +- **In scope:** Creating/extending MCP servers, validation, publish, audit, registration. +- **Out of scope:** Consuming existing MCP servers, non-MCP application code. + +## Required Input + +Server name, description, template, and tool list. + +## Tone + +Meta-engineering, canonical-pattern-driven, audit-aware. diff --git a/skills/code-skills/skill-code-mcp-builder/frameworks/standards.md b/skills/code-skills/skill-code-mcp-builder/frameworks/standards.md new file mode 100644 index 00000000..71b696d0 --- /dev/null +++ b/skills/code-skills/skill-code-mcp-builder/frameworks/standards.md @@ -0,0 +1,30 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Technology Stack + +- Templates: `python-fastmcp`, `node-mcp`, `go-mcp`. +- FastMCP >= 0.3.0, jinja2 >= 3.0.0. +- Canonical OpenSIN-Code project layout. + +## Standards + +- Every project has `pyproject.toml` / `package.json` / `go.mod`, tests, CoDocs, and `ceo-audit.yml`. +- Every tool has type hints and docstrings. +- Every tool has a generated test. +- Pass CEO audit before publish. + +## Constraints + +- No publishing without validation. +- No audit skip. +- Register in `opencode.json` after install. + +## Quality Gates + +- Scaffold successful. +- Tests pass. +- Validation passes. +- CEO audit passes. +- Server registered. diff --git a/skills/code-skills/skill-code-mcp-builder/tasks/workflow.md b/skills/code-skills/skill-code-mcp-builder/tasks/workflow.md new file mode 100644 index 00000000..7ffd83c9 --- /dev/null +++ b/skills/code-skills/skill-code-mcp-builder/tasks/workflow.md @@ -0,0 +1,33 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Confirm server name, description, template, and initial tools. + +## Execution + +- [ ] Task 1: Scaffold project. + - Acceptance: `mcp_scaffold` returns project path. + - Verify: Files created per canonical layout. +- [ ] Task 2: Add tools. + - Acceptance: Tools added with docstrings and type hints. + - Verify: `mcp_tool_add` used. +- [ ] Task 3: Generate tests. + - Acceptance: Test file exists for each tool. + - Verify: `mcp_tool_test` used. +- [ ] Task 4: Validate. + - Acceptance: `mcp_validate` passes. + - Verify: No validation errors. +- [ ] Task 5: Audit. + - Acceptance: `mcp_audit` passes. + - Verify: CEO audit grade acceptable. +- [ ] Task 6: Publish/register. + - Acceptance: `mcp_publish` and `mcp_register` succeed. + - Verify: Server registered in `opencode.json`. + +## Post-flight + +- [ ] Provide project path and registration status. +- [ ] Offer next steps (test, deploy). diff --git a/skills/code-skills/skill-code-mcp-builder/templates/output.md b/skills/code-skills/skill-code-mcp-builder/templates/output.md new file mode 100644 index 00000000..1b70eec8 --- /dev/null +++ b/skills/code-skills/skill-code-mcp-builder/templates/output.md @@ -0,0 +1,30 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## MCP Server Builder Report + +```markdown +# MCP Server: {name} + +## Template +{python-fastmcp | node-mcp | go-mcp} + +## Tools +- ... +- ... + +## Validation +- Status: pass / fail +- Issues: ... + +## Audit +- Grade: ... + +## Publish +- Status: ... +- Registry: ... + +## Path +{project path} +``` diff --git a/skills/code-skills/skill-code-mcp-builder/templates/prompt.md b/skills/code-skills/skill-code-mcp-builder/templates/prompt.md new file mode 100644 index 00000000..98d57d06 --- /dev/null +++ b/skills/code-skills/skill-code-mcp-builder/templates/prompt.md @@ -0,0 +1,24 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## User wants to build an MCP server + +```markdown +You are building an MCP server with the SIN-MCP-Server-Builder. + +Name: {name} +Description: {description} +Template: {python-fastmcp | node-mcp | go-mcp} +Tools: {comma-separated list} + +Constraints: +- Scaffold canonical OpenSIN-Code layout. +- Add tools with docstrings and type hints. +- Generate tests for each tool. +- Validate before publish. +- Run CEO audit. +- Register in opencode.json. + +Follow tasks/workflow.md. +``` diff --git a/skills/code-skills/skill-code-plan/LICENSE b/skills/code-skills/skill-code-plan/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/code-skills/skill-code-plan/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/code-skills/skill-code-plan/SKILL.md b/skills/code-skills/skill-code-plan/SKILL.md new file mode 100644 index 00000000..bba9063d --- /dev/null +++ b/skills/code-skills/skill-code-plan/SKILL.md @@ -0,0 +1,62 @@ +--- +name: skill-code-plan +description: Break an approved specification into concrete, executable tasks. Use when the user has a spec and needs an implementation plan. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-code-plan + +## Overview + +Break an approved specification into atomic, dependency-ordered tasks that an autonomous agent can execute. + +## When to Use + +- User has an approved spec and asks for a plan. +- A feature needs to be decomposed into executable tasks. + +## When NOT to Use + +- No spec exists yet (use `skill-code-spec` first). +- The task is already small enough to implement directly. + +## Core Process + +``` +LOAD SPEC → DECOMPOSE → ORDER → DEFINE CONTRACTS → SAVE → REVIEW +``` + +1. Load the approved specification from `.sin/specs/`. +2. Identify atomic work units (one file change or test per unit). +3. Order tasks by dependencies. +4. For each task, define input/output contracts and acceptance criteria. +5. Save the plan to `.sin/plans/`. +6. Review the plan with the user or Critic agent. + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "I can keep the plan in my head." | Written plans enable parallel work and review. | +| "Just generate code directly." | Planning first reduces hallucinations and rework. | +| "Tasks can be vague." | Every task needs a contract and acceptance criteria. | + +## Red Flags + +- Tasks that touch more than one responsibility. +- Missing dependency ordering. +- No acceptance criteria. + +## Verification + +- [ ] Each task references a spec requirement. +- [ ] Tasks are executable by an autonomous agent. +- [ ] No task takes more than 10 minutes of agent time. +- [ ] Plan is saved to `.sin/plans/`. +- [ ] Plan was reviewed by Critic or user. diff --git a/skills/code-skills/skill-code-plan/context/triggers.md b/skills/code-skills/skill-code-plan/context/triggers.md new file mode 100644 index 00000000..e4467cc1 --- /dev/null +++ b/skills/code-skills/skill-code-plan/context/triggers.md @@ -0,0 +1,23 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "plan this" +- "create a plan" +- "break this into tasks" +- "plan the implementation" + +## Boundaries + +- **In scope:** Decomposing an approved spec into tasks. +- **Out of scope:** Writing the spec, implementing the code. + +## Required Input + +Approved spec from `.sin/specs/`. + +## Tone + +Structured, precise, dependency-aware. diff --git a/skills/code-skills/skill-code-plan/frameworks/standards.md b/skills/code-skills/skill-code-plan/frameworks/standards.md new file mode 100644 index 00000000..59a25c64 --- /dev/null +++ b/skills/code-skills/skill-code-plan/frameworks/standards.md @@ -0,0 +1,19 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Technology Stack + +- SIN-Code planning convention: `.sin/specs/` → `.sin/plans/`. + +## Planning Standards + +- One file change or test per atomic task. +- Dependency order, not perceived importance. +- Each task has acceptance criteria. + +## Quality Gates + +- Critic agent review. +- User approval for high-risk plans. +- No task longer than 10 minutes of agent time. diff --git a/skills/code-skills/skill-code-plan/tasks/workflow.md b/skills/code-skills/skill-code-plan/tasks/workflow.md new file mode 100644 index 00000000..d2e337ca --- /dev/null +++ b/skills/code-skills/skill-code-plan/tasks/workflow.md @@ -0,0 +1,34 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Confirm the approved spec exists. +- [ ] Identify the target plan path in `.sin/plans/`. + +## Execution + +- [ ] Task 1: Load spec. + - Acceptance: Spec content is available. + - Verify: Requirements extracted. +- [ ] Task 2: Decompose into atomic work units. + - Acceptance: Each unit is one file change or test. + - Verify: No multi-file task without sub-tasks. +- [ ] Task 3: Order by dependencies. + - Acceptance: Dependent tasks come after prerequisites. + - Verify: Dependency graph is acyclic. +- [ ] Task 4: Define contracts and acceptance criteria. + - Acceptance: Each task has input/output contract. + - Verify: Criteria are testable. +- [ ] Task 5: Save plan to `.sin/plans/`. + - Acceptance: Plan file exists and is readable. + - Verify: File written successfully. +- [ ] Task 6: Review plan. + - Acceptance: Critic or user approves. + - Verify: Approval recorded. + +## Post-flight + +- [ ] Summarize plan tasks and dependencies. +- [ ] Hand off to `sin-build` if ready. diff --git a/skills/code-skills/skill-code-plan/templates/output.md b/skills/code-skills/skill-code-plan/templates/output.md new file mode 100644 index 00000000..004988db --- /dev/null +++ b/skills/code-skills/skill-code-plan/templates/output.md @@ -0,0 +1,27 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Plan Document + +```markdown +# Plan: {feature} + +## Spec Reference +{spec path} + +## Tasks +- [ ] Task 1: ... + - Acceptance: ... + - Files: ... +- [ ] Task 2: ... + - Acceptance: ... + - Files: ... + +## Dependencies +Task 2 → Task 1 +... + +## Total Estimate +{N} agent-minutes +``` diff --git a/skills/code-skills/skill-code-plan/templates/prompt.md b/skills/code-skills/skill-code-plan/templates/prompt.md new file mode 100644 index 00000000..deb93a05 --- /dev/null +++ b/skills/code-skills/skill-code-plan/templates/prompt.md @@ -0,0 +1,20 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## User asks for a plan + +```markdown +You are creating an implementation plan for SIN-Code. + +Spec: {spec path} + +Constraints: +- Atomic tasks: one file change or test per task. +- Order by dependencies. +- Every task needs acceptance criteria. +- Save to `.sin/plans/`. +- Get Critic or user review. + +Follow tasks/workflow.md. +``` diff --git a/skills/code-skills/skill-code-preview/LICENSE b/skills/code-skills/skill-code-preview/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/code-skills/skill-code-preview/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/code-skills/skill-code-preview/SKILL.md b/skills/code-skills/skill-code-preview/SKILL.md new file mode 100644 index 00000000..ef58b71c --- /dev/null +++ b/skills/code-skills/skill-code-preview/SKILL.md @@ -0,0 +1,41 @@ +--- +name: skill-code-preview +description: Open generated images and screenshots in macOS Preview automatically. Always use when creating or referencing images. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-code-preview + +## Overview + +Open any image or screenshot in macOS Preview automatically. Never tell the user to browse to /tmp. + +## When to Use + +- Always when creating or referencing an image file. + +## When NOT to Use + +- Never skip this for images. + +## Core Process + +``` +GENERATE IMAGE → SAVE → OPEN IN PREVIEW +``` + +1. Create or locate the image. +2. Save to an absolute path. +3. Open in Preview. + +## Verification + +- [ ] Image file exists. +- [ ] Preview opens without error. +- [ ] User is informed of the path. diff --git a/skills/code-skills/skill-code-preview/context/triggers.md b/skills/code-skills/skill-code-preview/context/triggers.md new file mode 100644 index 00000000..3cc2474c --- /dev/null +++ b/skills/code-skills/skill-code-preview/context/triggers.md @@ -0,0 +1,20 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- Any image creation or reference. + +## Boundaries + +- **In scope:** Opening images in Preview. +- **Out of scope:** Image generation itself. + +## Required Input + +Absolute path to image file. + +## Tone + +Helpful, automatic. diff --git a/skills/code-skills/skill-code-preview/frameworks/standards.md b/skills/code-skills/skill-code-preview/frameworks/standards.md new file mode 100644 index 00000000..01f40313 --- /dev/null +++ b/skills/code-skills/skill-code-preview/frameworks/standards.md @@ -0,0 +1,13 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Requirements + +- macOS Preview.app. +- Absolute path to file. + +## Constraints + +- Only open images, not arbitrary files. +- Do not open files outside the project. diff --git a/skills/code-skills/skill-code-preview/tasks/workflow.md b/skills/code-skills/skill-code-preview/tasks/workflow.md new file mode 100644 index 00000000..5f84f807 --- /dev/null +++ b/skills/code-skills/skill-code-preview/tasks/workflow.md @@ -0,0 +1,16 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Confirm image path. + +## Execution + +- [ ] Verify file exists. +- [ ] Open with macOS Preview. + +## Post-flight + +- [ ] Report path. diff --git a/skills/code-skills/skill-code-preview/templates/output.md b/skills/code-skills/skill-code-preview/templates/output.md new file mode 100644 index 00000000..e3f24d83 --- /dev/null +++ b/skills/code-skills/skill-code-preview/templates/output.md @@ -0,0 +1,10 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Preview + +```markdown +Opened image in Preview: +{path} +``` diff --git a/skills/code-skills/skill-code-preview/templates/prompt.md b/skills/code-skills/skill-code-preview/templates/prompt.md new file mode 100644 index 00000000..20a02516 --- /dev/null +++ b/skills/code-skills/skill-code-preview/templates/prompt.md @@ -0,0 +1,11 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## Preview image + +```markdown +Image path: {absolute path} + +Use sin-preview to open it in macOS Preview. +``` diff --git a/skills/code-skills/skill-code-refactor/LICENSE b/skills/code-skills/skill-code-refactor/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/code-skills/skill-code-refactor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/code-skills/skill-code-refactor/SKILL.md b/skills/code-skills/skill-code-refactor/SKILL.md new file mode 100644 index 00000000..b2882f10 --- /dev/null +++ b/skills/code-skills/skill-code-refactor/SKILL.md @@ -0,0 +1,64 @@ +--- +name: skill-code-refactor +description: Refactor a symbol with full SIN impact analysis and Oracle verification. Use when the user asks to refactor, rename, or restructure a symbol while preserving behavior. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-code-refactor + +## Overview + +Perform a behavior-preserving refactor of a symbol using SIN-Code impact analysis, semantic diff, and Oracle verification. + +## When to Use + +- User asks to refactor, rename, or restructure a symbol. +- The change should not alter observable behavior. + +## When NOT to Use + +- Feature additions or bug fixes. +- Changes that intentionally alter behavior. + +## Core Process + +``` +IMPACT → REFACTOR → SEMANTIC DIFF → DEBT CHECK → VERIFY → REPORT +``` + +1. Call `impact("{{symbol}}")`. Read callers, fan-in, and risk. + - If `touches_public_api` is true or risk is high, state the blast radius to the user. +2. Make the smallest refactor that satisfies the goal. Do not change behavior. +3. For each edited file, call `semantic_diff(before, after)`. + - If any diff reports more than one intent, split the change. +4. Call `architectural_debt()`. If the score regressed, simplify before moving on. +5. Call `verify_tests(...)` (and `prove(...)` for critical pure functions). +6. Do NOT report done until the Oracle verdict is `pass`. + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "The refactor is safe because the tests still pass." | Tests passing is necessary but not sufficient; use impact analysis and semantic diff. | +| "I can change a few related things while I'm here." | One change per refactor. Mixed intents hide regressions. | +| "The user didn't ask for a report, just the result." | The blast radius and debt delta are part of the result. | + +## Red Flags + +- Skipping impact analysis. +- Changing behavior under the guise of refactoring. +- Reporting done with a red Oracle verdict. + +## Verification + +- [ ] Impact analysis completed and blast radius reported. +- [ ] Semantic diff shows exactly one intent per file. +- [ ] Architectural debt did not regress (or was justified and fixed). +- [ ] Oracle verdict is `pass`. +- [ ] Final report includes blast radius, intents, debt delta, and verdict. diff --git a/skills/code-skills/skill-code-refactor/context/triggers.md b/skills/code-skills/skill-code-refactor/context/triggers.md new file mode 100644 index 00000000..d36d75f6 --- /dev/null +++ b/skills/code-skills/skill-code-refactor/context/triggers.md @@ -0,0 +1,24 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "refactor ..." +- "rename ..." +- "restructure ..." +- "safe refactor ..." +- "clean up ..." + +## Boundaries + +- **In scope:** Behavior-preserving changes to a symbol or small group of symbols. +- **Out of scope:** New features, bug fixes, behavior changes. + +## Required Input + +Fully-qualified symbol name, e.g. `module.Class.method`. + +## Tone + +Cautious, evidence-based, no surprises. diff --git a/skills/code-skills/skill-code-refactor/frameworks/standards.md b/skills/code-skills/skill-code-refactor/frameworks/standards.md new file mode 100644 index 00000000..d826c02a --- /dev/null +++ b/skills/code-skills/skill-code-refactor/frameworks/standards.md @@ -0,0 +1,25 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Technology Stack + +- SIN-Code tool suite: `impact`, `semantic_diff`, `architectural_debt`, `verify_tests`, `prove`. + +## Coding Standards + +- Preserve exact behavior. +- Make the smallest change possible. +- One intent per changed file. + +## Security Constraints + +- Do not introduce new vulnerabilities during refactoring. +- Keep access modifiers unchanged unless explicitly required. + +## Quality Gates + +- Impact analysis risk must be acceptable or explicitly approved. +- `semantic_diff` must not split intents. +- `architectural_debt` must not regress. +- Oracle verdict must be `pass`. diff --git a/skills/code-skills/skill-code-refactor/tasks/workflow.md b/skills/code-skills/skill-code-refactor/tasks/workflow.md new file mode 100644 index 00000000..72f71b3b --- /dev/null +++ b/skills/code-skills/skill-code-refactor/tasks/workflow.md @@ -0,0 +1,34 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Capture the symbol to refactor. +- [ ] Confirm the goal is behavior-preserving. + +## Execution + +- [ ] Task 1: Impact analysis. + - Acceptance: `impact(symbol)` returns callers, fan-in, and risk. + - Verify: Blast radius is reported to the user. +- [ ] Task 2: Apply smallest refactor. + - Acceptance: Code compiles and tests still run. + - Verify: No behavior changes (same inputs → same outputs). +- [ ] Task 3: Semantic diff per file. + - Acceptance: Each file has exactly one intent. + - Verify: No split-intent warnings. +- [ ] Task 4: Architectural debt check. + - Acceptance: Debt score is stable or improved. + - Verify: `architectural_debt()` output reviewed. +- [ ] Task 5: Verification. + - Acceptance: `verify_tests` passes; pure functions also use `prove`. + - Verify: Oracle verdict is `pass`. +- [ ] Task 6: Report. + - Acceptance: Report includes blast radius, intents, debt delta, and verdict. + - Verify: User acknowledges or approves. + +## Post-flight + +- [ ] Commit or summarize the change. +- [ ] Note any follow-up refactors if debt could not be reduced. diff --git a/skills/code-skills/skill-code-refactor/templates/output.md b/skills/code-skills/skill-code-refactor/templates/output.md new file mode 100644 index 00000000..dae9e9be --- /dev/null +++ b/skills/code-skills/skill-code-refactor/templates/output.md @@ -0,0 +1,29 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Refactor Report + +```markdown +# Safe Refactor: {symbol} + +## Blast Radius +- Callers: ... +- Risk: low/medium/high +- Public API touched: yes/no + +## Changes +- `file1`: intent = ... +- `file2`: intent = ... + +## Debt Delta +- Before: ... +- After: ... + +## Verification +- `verify_tests`: pass +- `prove`: pass (if applicable) + +## Notes +[Optional caveats] +``` diff --git a/skills/code-skills/skill-code-refactor/templates/prompt.md b/skills/code-skills/skill-code-refactor/templates/prompt.md new file mode 100644 index 00000000..48cd5bd9 --- /dev/null +++ b/skills/code-skills/skill-code-refactor/templates/prompt.md @@ -0,0 +1,21 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## User asks to refactor a symbol + +```markdown +You are performing a SAFE REFACTOR of `{symbol}` in SIN-Code. + +Goal: preserve behavior exactly. + +Required steps: +1. Run `impact("{symbol}")` and report blast radius. +2. Make the smallest possible change. +3. Run `semantic_diff(before, after)` for each edited file. +4. Run `architectural_debt()` and check for regression. +5. Run `verify_tests(...)` (and `prove(...)` for pure functions). +6. Do NOT report done until Oracle verdict is `pass`. + +Produce the Refactor Report from templates/output.md. +``` diff --git a/skills/code-skills/skill-code-spec/LICENSE b/skills/code-skills/skill-code-spec/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/code-skills/skill-code-spec/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/code-skills/skill-code-spec/SKILL.md b/skills/code-skills/skill-code-spec/SKILL.md new file mode 100644 index 00000000..a954d812 --- /dev/null +++ b/skills/code-skills/skill-code-spec/SKILL.md @@ -0,0 +1,62 @@ +--- +name: skill-code-spec +description: Author a technical specification for a feature or change. Use when the user has an idea but no written spec yet. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-code-spec + +## Overview + +Write a complete, reviewable technical specification before implementation begins. + +## When to Use + +- User has an idea or requirement and asks for a spec. +- A feature is large enough that it needs design decisions documented. + +## When NOT to Use + +- The task is a trivial one-liner or bug fix. +- The implementation approach is already fully understood. + +## Core Process + +``` +GATHER → DECIDE → DOCUMENT → REVIEW → APPROVE +``` + +1. Gather requirements from the user. +2. Make explicit design decisions (and trade-offs). +3. Document the spec in `.sin/specs/`. +4. Review with Critic or user. +5. Wait for approval before moving to planning. + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "I'll spec as I go." | Spec-first prevents design drift. | +| "The user trusts me to pick the design." | Document the choice so it can be reviewed. | +| "Specs slow us down." | Small specs are cheap; bad specs are expensive. | + +## Red Flags + +- Ambiguous acceptance criteria. +- Missing error handling. +- No trade-offs documented. + +## Verification + +- [ ] Requirements are complete and unambiguous. +- [ ] Design decisions are explicit. +- [ ] Trade-offs are documented. +- [ ] Acceptance criteria are testable. +- [ ] Spec saved to `.sin/specs/`. +- [ ] Critic or user approval obtained. diff --git a/skills/code-skills/skill-code-spec/context/triggers.md b/skills/code-skills/skill-code-spec/context/triggers.md new file mode 100644 index 00000000..10a4ad85 --- /dev/null +++ b/skills/code-skills/skill-code-spec/context/triggers.md @@ -0,0 +1,23 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "spec this out" +- "write a spec" +- "design this feature" +- "how should we implement ..." + +## Boundaries + +- **In scope:** Requirements gathering, design decisions, spec writing. +- **Out of scope:** Implementation, planning, coding. + +## Required Input + +Feature idea or requirement from the user. + +## Tone + +Inquisitive, clear, decision-forcing. diff --git a/skills/code-skills/skill-code-spec/frameworks/standards.md b/skills/code-skills/skill-code-spec/frameworks/standards.md new file mode 100644 index 00000000..967d58a0 --- /dev/null +++ b/skills/code-skills/skill-code-spec/frameworks/standards.md @@ -0,0 +1,21 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Technology Stack + +- SIN-Code spec convention: `.sin/specs/` Markdown. + +## Spec Standards + +- Explicit goals and non-goals. +- User-visible behavior first. +- Internal design second. +- Error cases and edge cases included. +- Testable acceptance criteria. + +## Quality Gates + +- Critic review. +- User approval. +- No unresolved "TODO" in spec before approval. diff --git a/skills/code-skills/skill-code-spec/tasks/workflow.md b/skills/code-skills/skill-code-spec/tasks/workflow.md new file mode 100644 index 00000000..a421ccb7 --- /dev/null +++ b/skills/code-skills/skill-code-spec/tasks/workflow.md @@ -0,0 +1,37 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Capture the feature idea or requirement. +- [ ] Confirm this is not a one-line task. + +## Execution + +- [ ] Task 1: Gather requirements. + - Acceptance: Goals, non-goals, constraints known. + - Verify: User confirmed or asked clarifying questions. +- [ ] Task 2: Make design decisions. + - Acceptance: Approach selected and alternatives considered. + - Verify: Trade-offs documented. +- [ ] Task 3: Document user-visible behavior. + - Acceptance: Inputs, outputs, and flows described. + - Verify: Examples or CLI output shown. +- [ ] Task 4: Document internal design. + - Acceptance: Components, data structures, interfaces described. + - Verify: No hand-waving. +- [ ] Task 5: Add error and edge cases. + - Acceptance: Failure modes enumerated. + - Verify: Handling strategy for each. +- [ ] Task 6: Write acceptance criteria. + - Acceptance: Criteria are testable. + - Verify: Can be checked by tests or review. +- [ ] Task 7: Save and review. + - Acceptance: Spec saved to `.sin/specs/` and approved. + - Verify: Approval recorded. + +## Post-flight + +- [ ] Summarize spec decisions. +- [ ] Hand off to `sin-plan` if approved. diff --git a/skills/code-skills/skill-code-spec/templates/output.md b/skills/code-skills/skill-code-spec/templates/output.md new file mode 100644 index 00000000..68c4c4d9 --- /dev/null +++ b/skills/code-skills/skill-code-spec/templates/output.md @@ -0,0 +1,36 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Spec Document + +```markdown +# Spec: {feature} + +## Goals +- ... + +## Non-Goals +- ... + +## User-Facing Behavior +- Input: ... +- Output: ... +- Example: ... + +## Internal Design +- Components: ... +- Data: ... +- Interfaces: ... + +## Error & Edge Cases +- ... + +## Acceptance Criteria +- [ ] ... +- [ ] ... + +## Trade-offs +- Alternative A: ... (rejected because ...) +- Alternative B: ... (chosen because ...) +``` diff --git a/skills/code-skills/skill-code-spec/templates/prompt.md b/skills/code-skills/skill-code-spec/templates/prompt.md new file mode 100644 index 00000000..333faa65 --- /dev/null +++ b/skills/code-skills/skill-code-spec/templates/prompt.md @@ -0,0 +1,22 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## User asks for a spec + +```markdown +You are writing a technical specification for SIN-Code. + +Topic: {topic} + +Constraints: +- Capture goals and non-goals. +- Document user-facing behavior first. +- Make design decisions and trade-offs explicit. +- Include error and edge cases. +- Write testable acceptance criteria. +- Save to `.sin/specs/`. +- Get Critic or user approval. + +Follow tasks/workflow.md. +``` diff --git a/skills/debug-skills/skill-debug-deep/LICENSE b/skills/debug-skills/skill-debug-deep/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/debug-skills/skill-debug-deep/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/debug-skills/skill-debug-deep/SKILL.md b/skills/debug-skills/skill-debug-deep/SKILL.md new file mode 100644 index 00000000..0a29a3d1 --- /dev/null +++ b/skills/debug-skills/skill-debug-deep/SKILL.md @@ -0,0 +1,71 @@ +--- +name: skill-debug-deep +description: Ultimate enterprise debugging workflow — facts-first RCA, cross-tool intent discovery, parallel subagents, web validation, minimal safe fix, and persistent knowledge flush. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +lifecycle: external +sources: + - https://github.com/OpenSIN-AI/Skill-SIN-Enterprise-Deep-Debug +--- + +# Enterprise Deep Debug + +Use this skill when a bug is complex, cross-cutting, flaky, or enterprise-scale (distributed systems, async flows, microservices, cloud-native, event streams). + +## Triggers + +- "deep debug", "root cause analysis", "RCA", "system-wide debugging" +- "flaky", "regression", "prod incident", "postmortem" + +## Hard Rules (Anti-Hallucination) + +- Do not patch before you have a reproducible failing case (command, input, expected vs actual). +- Prefer evidence over intuition. Every claim links to: file path + line, command output, or a cited external URL. +- Parallelize investigation, not patching: subagents gather evidence only; edits happen after synthesis. +- One hypothesis, one discriminating experiment. Change one variable at a time. +- Stop conditions are mandatory: set budgets and terminate when exceeded (no infinite loops). +- Secrets: never print or persist tokens/keys. Redact any credential-like strings. +- Any read outside the repo must come from the Project SSOT Source Map or from a direct project link discovered in repo evidence. +- Never apply a patch that fails validation. + +## Budgets + Termination + +- Wall clock: 35 min default (user can raise/lower). +- Phase budgets: + - Phase 0 + 0.5 (repro + triage): 8 min + - Phase I (intent discovery): 6 min + - Phase II (evidence gathering): 12 min + - Phase III (synthesis): 5 min + - Phase IV (fix + validate): 10 min +- Hard stop behavior: + - If Phase 0 gate (repro) is not met within budget: stop and request exactly what is missing. + - If the hypothesis cannot be discriminated within remaining experiment budget: stop with top 2 hypotheses + the single best next discriminating experiment. +- Maintain a budget ledger in chat: time_spent, remaining_budget, queries_used, experiments_used, patch_iterations_used. + +## Workflow + +``` +REPRO → INTENT → EVIDENCE → SYNTHESIZE → FIX → VALIDATE → FLUSH +``` + +1. **Reproduce** the failure with a minimal, reproducible case. +2. **Intent discovery** — understand what the code is supposed to do. +3. **Evidence gathering** — collect file paths, line numbers, command outputs, URLs. +4. **Synthesis** — form and discriminate hypotheses. +5. **Fix** — apply the minimal safe patch. +6. **Validate** — run the repro and confirm the fix. +7. **Knowledge flush** — write the RCA and lessons learned. + +## Verification + +- [ ] Reproducible failing case exists. +- [ ] Every claim has evidence (file + line, output, or URL). +- [ ] Hypotheses were discriminated with experiments. +- [ ] Budget ledger was maintained. +- [ ] Patch passes validation. +- [ ] RCA is documented. diff --git a/skills/debug-skills/skill-debug-deep/context/triggers.md b/skills/debug-skills/skill-debug-deep/context/triggers.md new file mode 100644 index 00000000..1c347cf9 --- /dev/null +++ b/skills/debug-skills/skill-debug-deep/context/triggers.md @@ -0,0 +1,8 @@ +# Triggers + +When the user mentions: +- "deep debug", "root cause analysis", "RCA", "system-wide debugging" +- "flaky", "regression", "prod incident", "postmortem" +- "kaskadierender Fehler", "Whack-a-Mole" + +Use this skill context. diff --git a/skills/debug-skills/skill-debug-deep/frameworks/standards.md b/skills/debug-skills/skill-debug-deep/frameworks/standards.md new file mode 100644 index 00000000..d2ad18f0 --- /dev/null +++ b/skills/debug-skills/skill-debug-deep/frameworks/standards.md @@ -0,0 +1,6 @@ +# Standards + +- Follow the canonical SIN-Code skill lifecycle. +- Prefer the external canonical implementation until a native `sin-code enterprise-deep-debug` command exists. +- Evidence-first: no patch without a reproducible failing case. +- Maintain a budget ledger and respect stop conditions. diff --git a/skills/debug-skills/skill-debug-deep/tasks/workflow.md b/skills/debug-skills/skill-debug-deep/tasks/workflow.md new file mode 100644 index 00000000..ca27798e --- /dev/null +++ b/skills/debug-skills/skill-debug-deep/tasks/workflow.md @@ -0,0 +1,8 @@ +# Workflow + +1. Establish a minimal reproducible failing case. +2. Discover intent: what should the code do? +3. Gather evidence in parallel across tools and subagents. +4. Synthesize and discriminate hypotheses. +5. Apply the minimal safe fix. +6. Validate and document the RCA. diff --git a/skills/debug-skills/skill-debug-deep/templates/output.md b/skills/debug-skills/skill-debug-deep/templates/output.md new file mode 100644 index 00000000..17c9b9ed --- /dev/null +++ b/skills/debug-skills/skill-debug-deep/templates/output.md @@ -0,0 +1,15 @@ +# Output Template + +## RCA Result +- Bug: +- Root cause: +- Evidence: +- Fix: +- Validation: +- External source: https://github.com/OpenSIN-AI/Skill-SIN-Enterprise-Deep-Debug + +## Budget Ledger +- time_spent: +- remaining_budget: +- experiments_used: +- patch_iterations_used: diff --git a/skills/debug-skills/skill-debug-deep/templates/prompt.md b/skills/debug-skills/skill-debug-deep/templates/prompt.md new file mode 100644 index 00000000..6e46420e --- /dev/null +++ b/skills/debug-skills/skill-debug-deep/templates/prompt.md @@ -0,0 +1,3 @@ +# Prompt Template + +You are the enterprise-deep-debug skill assistant. Guide the user through a facts-first, budget-aware root cause analysis. Do not patch before a reproducible failing case exists. If the native SIN-Code command is unavailable, guide them to the external canonical implementation. diff --git a/skills/design-skills/skill-design-frontend/LICENSE b/skills/design-skills/skill-design-frontend/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/design-skills/skill-design-frontend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/design-skills/skill-design-frontend/SKILL.md b/skills/design-skills/skill-design-frontend/SKILL.md new file mode 100644 index 00000000..705c32a1 --- /dev/null +++ b/skills/design-skills/skill-design-frontend/SKILL.md @@ -0,0 +1,106 @@ +--- +name: skill-design-frontend +description: SOTA frontend design system and philosophy. Loads typography, color, spacing, motion tokens; generates button/input/card/modal specs; scaffolds pages; runs WCAG 2.2 AA checks. v0-pool integration when available. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: OpenSIN-Code + version: 1.0.0 +--- + +# skill-design-frontend + +## Overview + +Apply a SOTA design system for frontend work: tokens, component specs, page scaffolding, accessibility checks, and v0-pool code generation. + +## When to Use + +- Generate component specs (button, input, card, modal). +- Scaffold a full page (landing, pricing, docs, blog). +- Review existing UI for design-system consistency. +- Extract design tokens from CSS/SCSS/Tailwind/JSON/Figma. +- Run WCAG 2.2 AA accessibility checks. +- Generate responsive breakpoints. +- Export tokens to Figma Tokens JSON. + +## When NOT to Use + +- Backend or API design (use `skill-code-spec` / `skill-code-plan`). +- Image generation (use `skill-design-image`). +- Code logic without UI (use `skill-code-build`). + +## Core Process + +``` +LOAD DESIGN SYSTEM → CREATE COMPONENT / SCAFFOLD PAGE → REVIEW → CHECK A11Y → EXPORT TOKENS +``` + +1. Load the design system tokens and philosophy. +2. Create the component or scaffold the page. +3. Review for consistency. +4. Check accessibility (WCAG 2.2 AA). +5. Export or extract tokens as needed. + +## Design Philosophy + +1. Hierarchy is created by contrast, not decoration. +2. Type is the primary voice — choose one family and use scale. +3. Color is functional: primary, secondary, success, warning, error, neutral. +4. Spacing follows a 4px grid. +5. Motion is felt, not seen: 200ms hovers, 300ms transitions. +6. Components are predictable: same name, same shape, same tokens. +7. States are explicit: default, hover, focus, active, disabled. +8. Accessibility is non-negotiable: WCAG 2.2 AA is the floor. +9. Dark mode is a parallel semantic map, not an inversion. +10. Restraint creates calm. + +## Token Reference + +### Typography (px) +`12 · 14 · 16 · 18 · 20 · 24 · 30 · 36 · 48 · 60 · 72` + +### Spacing (px, 4px grid) +`4 · 8 · 12 · 16 · 24 · 32 · 48 · 64 · 96` + +### Motion +- Hover: 200ms ease-out +- Transition: 300ms ease-in-out +- Page: 500ms cubic-bezier(0.16, 1, 0.3, 1) + +### Radius +- Default: 8px +- Card: 16px + +### Color ramps (50–900) +- `neutral` — slate +- `primary` — indigo +- `secondary` — violet +- `success` — green +- `warning` — amber +- `error` — red + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "I can use arbitrary values." | Follow the 4px grid and token ramps. | +| "Accessibility is extra." | WCAG 2.2 AA is the floor, not optional. | +| "v0-pool is required." | Falls back to built-in templates if offline. | + +## Red Flags + +- Arbitrary spacing or colors. +- Missing focus/hover/disabled states. +- Skipping a11y check. +- Inconsistent component naming. + +## Verification + +- [ ] Design system loaded. +- [ ] Component or page created with tokens. +- [ ] Review passed for consistency. +- [ ] WCAG 2.2 AA check passed. +- [ ] Tokens exported or extracted if requested. diff --git a/skills/design-skills/skill-design-frontend/context/triggers.md b/skills/design-skills/skill-design-frontend/context/triggers.md new file mode 100644 index 00000000..8a2580f4 --- /dev/null +++ b/skills/design-skills/skill-design-frontend/context/triggers.md @@ -0,0 +1,27 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "design system" +- "component spec" / "button spec" / "modal spec" +- "scaffold page" / "landing page" / "pricing page" +- "design review" +- "design tokens" +- "WCAG" / "accessibility check" +- "Figma tokens" +- "responsive breakpoints" + +## Boundaries + +- **In scope:** Frontend design system, component specs, page scaffolding, a11y, tokens. +- **Out of scope:** Backend design, image generation, non-UI code logic. + +## Required Input + +Component type or page layout + target framework. + +## Tone + +Design-system-native, restrained, accessibility-first. diff --git a/skills/design-skills/skill-design-frontend/frameworks/standards.md b/skills/design-skills/skill-design-frontend/frameworks/standards.md new file mode 100644 index 00000000..3253a174 --- /dev/null +++ b/skills/design-skills/skill-design-frontend/frameworks/standards.md @@ -0,0 +1,31 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Technology Stack + +- SOTA design system tokens (typography, color, spacing, motion). +- Component generators for button, input, card, modal. +- Page scaffolds for landing, pricing, docs, blog. +- WCAG 2.2 AA checker. +- v0-pool at `http://localhost:27401` (optional fallback). + +## Standards + +- Use 4px grid spacing. +- Use token ramps (50–900). +- Provide all states: default, hover, focus, active, disabled. +- WCAG 2.2 AA compliance minimum. + +## Constraints + +- No arbitrary values. +- No decoration without function. +- v0-pool fallback to templates if offline. + +## Quality Gates + +- Design system loaded. +- Tokens applied consistently. +- A11y check passed. +- States defined. diff --git a/skills/design-skills/skill-design-frontend/tasks/workflow.md b/skills/design-skills/skill-design-frontend/tasks/workflow.md new file mode 100644 index 00000000..12cb4a94 --- /dev/null +++ b/skills/design-skills/skill-design-frontend/tasks/workflow.md @@ -0,0 +1,34 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Identify component or page to create/review. +- [ ] Confirm target framework (react, vue, svelte, html). + +## Execution + +- [ ] Task 1: Load design system. + - Acceptance: Tokens and philosophy available. + - Verify: `design_system_load` used. +- [ ] Task 2: Create component or scaffold page. + - Acceptance: Spec or scaffold generated. + - Verify: Output matches design system. +- [ ] Task 3: Review existing UI (if applicable). + - Acceptance: Inconsistencies identified. + - Verify: Review report produced. +- [ ] Task 4: Extract/export tokens (if applicable). + - Acceptance: Tokens extracted or exported to Figma JSON. + - Verify: Output valid. +- [ ] Task 5: Accessibility check. + - Acceptance: WCAG 2.2 AA check passed. + - Verify: No contrast or a11y errors. +- [ ] Task 6: Deliver. + - Acceptance: Code/spec ready for use. + - Verify: User receives output. + +## Post-flight + +- [ ] Summarize design decisions. +- [ ] Note any a11y fixes or token exports. diff --git a/skills/design-skills/skill-design-frontend/templates/output.md b/skills/design-skills/skill-design-frontend/templates/output.md new file mode 100644 index 00000000..c39e7982 --- /dev/null +++ b/skills/design-skills/skill-design-frontend/templates/output.md @@ -0,0 +1,31 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Design Delivery Report + +```markdown +# Design Delivery: {component/page} + +## Framework +{react|vue|svelte|html} + +## Tokens Used +- Colors: ... +- Spacing: ... +- Typography: ... + +## States +- [ ] default +- [ ] hover +- [ ] focus +- [ ] active +- [ ] disabled + +## Accessibility +- WCAG 2.2 AA: pass / fail +- Notes: ... + +## Output +{code or spec} +``` diff --git a/skills/design-skills/skill-design-frontend/templates/prompt.md b/skills/design-skills/skill-design-frontend/templates/prompt.md new file mode 100644 index 00000000..3a5165c5 --- /dev/null +++ b/skills/design-skills/skill-design-frontend/templates/prompt.md @@ -0,0 +1,22 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## User wants frontend design work + +```markdown +You are applying the SOTA frontend design system for SIN-Code. + +Task: {component | page | review | tokens | a11y} +Target: {button|input|card|modal|landing|pricing|docs|blog} +Framework: {react|vue|svelte|html} + +Constraints: +- Load design system first. +- Use 4px grid and token ramps. +- Define all states. +- Run WCAG 2.2 AA check. +- Extract/export tokens if requested. + +Follow tasks/workflow.md. +``` diff --git a/skills/design-skills/skill-design-image/LICENSE b/skills/design-skills/skill-design-image/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/design-skills/skill-design-image/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/design-skills/skill-design-image/SKILL.md b/skills/design-skills/skill-design-image/SKILL.md new file mode 100644 index 00000000..bdb412f1 --- /dev/null +++ b/skills/design-skills/skill-design-image/SKILL.md @@ -0,0 +1,44 @@ +--- +name: skill-design-image +description: Generate, edit, and inspect images for the project. Create diagrams, screenshots, or artwork. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-design-image + +## Overview + +Generate images, diagrams, or artwork for the project. Open images in Preview automatically. + +## When to Use + +- User asks for an image, diagram, illustration, or screenshot. +- A visual artifact is needed for documentation or design. + +## When NOT to Use + +- The task is purely textual or analytical. + +## Core Process + +``` +DESCRIBE → GENERATE → INSPECT → SAVE +``` + +1. Capture the image description or intent. +2. Generate the image using the appropriate tool. +3. Inspect the result. +4. Save and open the artifact. + +## Verification + +- [ ] Image matches prompt. +- [ ] File saved to expected path. +- [ ] Preview opened if applicable. +- [ ] User is informed of the path. diff --git a/skills/design-skills/skill-design-image/context/triggers.md b/skills/design-skills/skill-design-image/context/triggers.md new file mode 100644 index 00000000..0c101041 --- /dev/null +++ b/skills/design-skills/skill-design-image/context/triggers.md @@ -0,0 +1,20 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "generate image", "create image", "draw", "diagram", "illustration", "screenshot" + +## Boundaries + +- **In scope:** Image generation, editing, inspection. +- **Out of scope:** Text generation, code generation. + +## Required Input + +Image description. + +## Tone + +Visual, creative, precise. diff --git a/skills/design-skills/skill-design-image/frameworks/standards.md b/skills/design-skills/skill-design-image/frameworks/standards.md new file mode 100644 index 00000000..5de595cb --- /dev/null +++ b/skills/design-skills/skill-design-image/frameworks/standards.md @@ -0,0 +1,14 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Tools + +- Image generation model. +- macOS Preview for inspection. + +## Constraints + +- Save to project directory. +- Open in Preview after creation. +- Avoid copyrighted material. diff --git a/skills/design-skills/skill-design-image/tasks/workflow.md b/skills/design-skills/skill-design-image/tasks/workflow.md new file mode 100644 index 00000000..b0d802bb --- /dev/null +++ b/skills/design-skills/skill-design-image/tasks/workflow.md @@ -0,0 +1,18 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Capture image description and purpose. + +## Execution + +- [ ] Generate image. +- [ ] Inspect result. +- [ ] Save to path. +- [ ] Open in Preview. + +## Post-flight + +- [ ] Report file path. diff --git a/skills/design-skills/skill-design-image/templates/output.md b/skills/design-skills/skill-design-image/templates/output.md new file mode 100644 index 00000000..7fb102f1 --- /dev/null +++ b/skills/design-skills/skill-design-image/templates/output.md @@ -0,0 +1,15 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Image Result + +```markdown +## Generated Image + +Path: {path} +Size: {w}x{h} +Description: {prompt} + +Preview opened. +``` diff --git a/skills/design-skills/skill-design-image/templates/prompt.md b/skills/design-skills/skill-design-image/templates/prompt.md new file mode 100644 index 00000000..4a8bbe12 --- /dev/null +++ b/skills/design-skills/skill-design-image/templates/prompt.md @@ -0,0 +1,12 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## Generate image + +```markdown +Description: {description} +Purpose: {documentation|design|art} + +Generate an image and save it to the project. Open in Preview. +``` diff --git a/skills/ecosystem-skills/skill-ecosystem-context/LICENSE b/skills/ecosystem-skills/skill-ecosystem-context/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-context/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/ecosystem-skills/skill-ecosystem-context/SKILL.md b/skills/ecosystem-skills/skill-ecosystem-context/SKILL.md new file mode 100644 index 00000000..ba2913ac --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-context/SKILL.md @@ -0,0 +1,51 @@ +--- +name: skill-ecosystem-context +description: Unified context bridge that queries SCKG, sin-brain, GitNexus, and local SQLite in a single MCP call. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-ecosystem-context + +## Overview + +Query multiple context sources at once when an agent needs cross-source knowledge. + +## When to Use + +- Agent needs code structure AND user preferences AND recent decisions. +- Need a single answer that blends SCKG, sin-brain, GitNexus, and local SQLite. + +## When NOT to Use + +- Only one source is needed (use the source directly). +- No sources are available. + +## Core Process + +``` +IDENTIFY SOURCES → QUERY EACH → MERGE → SUMMARIZE +``` + +1. Detect which context sources are available. +2. Query relevant sources in parallel. +3. Merge results, deduplicate, and rank. +4. Summarize into a single coherent response. + +## Sources + +- **SCKG**: code structure, symbols, relationships. +- **sin-brain**: global rules, preferences. +- **GitNexus**: execution flows, impact analysis. +- **local SQLite**: project-specific memory. + +## Verification + +- [ ] At least one source responded. +- [ ] Answer cites source(s). +- [ ] No hallucinated facts outside source content. diff --git a/skills/ecosystem-skills/skill-ecosystem-context/context/triggers.md b/skills/ecosystem-skills/skill-ecosystem-context/context/triggers.md new file mode 100644 index 00000000..baa37218 --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-context/context/triggers.md @@ -0,0 +1,23 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "context bridge" +- "combine sources" +- "what does the code say and what did the user say" +- "cross-source context" + +## Boundaries + +- **In scope:** Querying and merging multiple sources. +- **Out of scope:** Writing memory or modifying sources. + +## Required Input + +Natural-language question. + +## Tone + +Factual, source-cited, concise. diff --git a/skills/ecosystem-skills/skill-ecosystem-context/frameworks/standards.md b/skills/ecosystem-skills/skill-ecosystem-context/frameworks/standards.md new file mode 100644 index 00000000..4fb86531 --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-context/frameworks/standards.md @@ -0,0 +1,16 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Supported Sources + +- SCKG +- sin-brain +- GitNexus +- local SQLite + +## Constraints + +- Read-only. +- Gracefully degrade if a source is unavailable. +- Cite sources. diff --git a/skills/ecosystem-skills/skill-ecosystem-context/tasks/workflow.md b/skills/ecosystem-skills/skill-ecosystem-context/tasks/workflow.md new file mode 100644 index 00000000..797895fe --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-context/tasks/workflow.md @@ -0,0 +1,18 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Determine question and likely sources. +- [ ] Detect source availability. + +## Execution + +- [ ] Query available sources in parallel. +- [ ] Merge and deduplicate results. +- [ ] Summarize with citations. + +## Post-flight + +- [ ] Report which sources were used. diff --git a/skills/ecosystem-skills/skill-ecosystem-context/templates/output.md b/skills/ecosystem-skills/skill-ecosystem-context/templates/output.md new file mode 100644 index 00000000..f01257e6 --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-context/templates/output.md @@ -0,0 +1,20 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Summary + +```markdown +## Context Bridge Summary + +### Sources Used +- SCKG +- sin-brain + +### Answer +... + +### Citations +- SCKG: ... +- sin-brain: ... +``` diff --git a/skills/ecosystem-skills/skill-ecosystem-context/templates/prompt.md b/skills/ecosystem-skills/skill-ecosystem-context/templates/prompt.md new file mode 100644 index 00000000..1d178e3c --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-context/templates/prompt.md @@ -0,0 +1,12 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## Cross-source question + +```markdown +Question: {question} + +Use the context bridge to query SCKG, sin-brain, GitNexus, and local SQLite. +Cite sources. Degrade gracefully if a source is unavailable. +``` diff --git a/skills/ecosystem-skills/skill-ecosystem-marketplace/LICENSE b/skills/ecosystem-skills/skill-ecosystem-marketplace/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-marketplace/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/ecosystem-skills/skill-ecosystem-marketplace/SKILL.md b/skills/ecosystem-skills/skill-ecosystem-marketplace/SKILL.md new file mode 100644 index 00000000..978cb266 --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-marketplace/SKILL.md @@ -0,0 +1,43 @@ +--- +name: skill-ecosystem-marketplace +description: Manage the SIN-Code skill marketplace. Search, install, update, and remove skills from the catalog. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-ecosystem-marketplace + +## Overview + +Browse and manage the SIN-Code skill catalog. Search, install, update, and remove skills. + +## When to Use + +- User wants to install a new skill, update existing skills, or browse the catalog. + +## When NOT to Use + +- The user is asking about application code, not skills. + +## Core Process + +``` +SYNC → SEARCH → INSTALL → UPDATE/REMOVE +``` + +1. Sync the catalog with the remote index. +2. Search or list skills. +3. Install selected skills. +4. Update or remove as needed. + +## Verification + +- [ ] Catalog is synced. +- [ ] Skill installed successfully. +- [ ] Dependency check passed. +- [ ] Removal confirmed with user. diff --git a/skills/ecosystem-skills/skill-ecosystem-marketplace/context/triggers.md b/skills/ecosystem-skills/skill-ecosystem-marketplace/context/triggers.md new file mode 100644 index 00000000..4e8938c2 --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-marketplace/context/triggers.md @@ -0,0 +1,20 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "marketplace", "install skill", "update skills", "remove skill", "list skills", "catalog" + +## Boundaries + +- **In scope:** Skill marketplace operations. +- **Out of scope:** Writing skill code from scratch. + +## Required Input + +Skill name or search query. + +## Tone + +Helpful, catalog-aware. diff --git a/skills/ecosystem-skills/skill-ecosystem-marketplace/frameworks/standards.md b/skills/ecosystem-skills/skill-ecosystem-marketplace/frameworks/standards.md new file mode 100644 index 00000000..460967a4 --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-marketplace/frameworks/standards.md @@ -0,0 +1,16 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Operations + +- sync: refresh catalog +- search: find skills +- install: add skill +- update: refresh installed skill +- remove: uninstall skill + +## Constraints + +- Confirm removal with user. +- Check dependencies before install. diff --git a/skills/ecosystem-skills/skill-ecosystem-marketplace/tasks/workflow.md b/skills/ecosystem-skills/skill-ecosystem-marketplace/tasks/workflow.md new file mode 100644 index 00000000..2ea1e2e7 --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-marketplace/tasks/workflow.md @@ -0,0 +1,18 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Determine marketplace action. + +## Execution + +- [ ] Sync catalog. +- [ ] Search/list if needed. +- [ ] Install/update/remove skill. +- [ ] Verify operation. + +## Post-flight + +- [ ] Report installed skills and versions. diff --git a/skills/ecosystem-skills/skill-ecosystem-marketplace/templates/output.md b/skills/ecosystem-skills/skill-ecosystem-marketplace/templates/output.md new file mode 100644 index 00000000..3d35195c --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-marketplace/templates/output.md @@ -0,0 +1,17 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Marketplace Result + +```markdown +## Marketplace + +Action: sync|search|install|update|remove +Skill: {name} +Version: {version} +Status: success + +Installed skills: +- {name} @ {version} +``` diff --git a/skills/ecosystem-skills/skill-ecosystem-marketplace/templates/prompt.md b/skills/ecosystem-skills/skill-ecosystem-marketplace/templates/prompt.md new file mode 100644 index 00000000..93b3342b --- /dev/null +++ b/skills/ecosystem-skills/skill-ecosystem-marketplace/templates/prompt.md @@ -0,0 +1,12 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## Use marketplace + +```markdown +Action: sync|search|install|update|remove +Skill: {name} + +Use sin-marketplace. Confirm removal with user. +``` diff --git a/skills/embed.doc.md b/skills/embed.doc.md new file mode 100644 index 00000000..d560aa79 --- /dev/null +++ b/skills/embed.doc.md @@ -0,0 +1,41 @@ +# skills/embed.go + +Embeds every project-local agent skill directory into the `sin-code` binary. + +## What this file does + +`skills/embed.go` defines `SkillsFS`, an `embed.FS` that includes all files under +`skills/`. The `sin-code skills` subcommand (see `cmd/sin-code/skills_cmd.go`) +uses this filesystem to list and install bundled skills without cloning external +repositories. + +## Dependencies + +- `github.com/Songmu/skillsmith` — skill discovery, install, and status. +- `skills/` directory containing one subdirectory per skill. + +## Important config values + +- The `//go:embed *` directive embeds every file and directory under `skills/`. +- Each skill directory must contain a `SKILL.md` at its root to be discovered. + +## Why this approach + +Embedding keeps the skills version-locked to the binary release. Users can install +bundled skills offline and always get the version that shipped with their `sin-code` +build. + +## Caveats + +- Only files committed under `skills/` are embedded; `.claude/skills/` symlinks are + not part of the binary. +- `skills/embed.go` itself must not import `cmd/sin-code` to avoid an import cycle. + +## Usage + +```go +import "github.com/OpenSIN-Code/SIN-Code/skills" + +// list all bundled skills +entries, _ := skills.SkillsFS.ReadDir(".") +``` diff --git a/skills/embed.go b/skills/embed.go new file mode 100644 index 00000000..da3a3c81 --- /dev/null +++ b/skills/embed.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// Purpose: embed all project-local agent skills into the SIN-Code binary. +// Docs: skills.doc.md +package skills + +import ( + "embed" + "io/fs" + "sync" +) + +// SkillsFS embeds every skill directory under the repository-root skills/ folder. +// The embedded filesystem is consumed by the `sin-code skills` subcommand via +// github.com/Songmu/skillsmith so users can install bundled skills into +// ~/.claude/skills/ or ~/.agents/skills/. +// +//go:embed * +var SkillsFS embed.FS + +// listFSOnce holds the lazily-built flattened view of SkillsFS. Skillsmith +// expects all skill directories at the root of the FS, but SIN-Code organizes +// skills into category folders (code-skills/, shop-skills/, ...). listFSOnce +// maps each leaf skill directory back to the root by skill name. +var listFSOnce = sync.OnceValues(func() (fs.FS, error) { + return newFlatSkillFS(SkillsFS) +}) + +// ListFS returns a flattened fs.FS suitable for skillsmith.Discover and +// skillsmith.CopySkills. The returned FS exposes every leaf skill directory +// at the root level (e.g. "code-skills/skill-code-add-endpoint" becomes "skill-code-add-endpoint"). +func ListFS() (fs.FS, error) { + return listFSOnce() +} diff --git a/skills/flatfs.go b/skills/flatfs.go new file mode 100644 index 00000000..15fd1206 --- /dev/null +++ b/skills/flatfs.go @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +// Purpose: flatten the nested skills/ category directories into a single +// fs.FS that skillsmith can consume. Skillsmith expects all skill directories +// to live at the root of the filesystem; this wrapper maps each skill name to +// its real category-prefixed path without duplicating the embedded bytes. +// Docs: skills.doc.md +package skills + +import ( + "io" + "io/fs" + "path" + "sort" + "strings" + "time" +) + +// flatSkillFS presents a flattened view of nested skill directories. +// Each directory containing a SKILL.md becomes a root-level directory whose +// name is the last path component (e.g. "code-skills/add-endpoint" -> "add-endpoint"). +type flatSkillFS struct { + root fs.FS + // mapping from flat skill name to its real directory inside root. + dirs map[string]string + // files maps flat paths (e.g. "add-endpoint/SKILL.md") to real paths. + files map[string]string +} + +func newFlatSkillFS(root fs.FS) (*flatSkillFS, error) { + f := &flatSkillFS{ + root: root, + dirs: make(map[string]string), + files: make(map[string]string), + } + if err := fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if strings.ToLower(path.Base(p)) != "skill.md" { + return nil + } + // p is something like "code-skills/add-endpoint/SKILL.md". + dir := path.Dir(p) + name := path.Base(dir) + f.dirs[name] = dir + return nil + }); err != nil { + return nil, err + } + + for name, realDir := range f.dirs { + if err := fs.WalkDir(root, realDir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel := strings.TrimPrefix(p, realDir) + rel = strings.TrimPrefix(rel, "/") + flatPath := path.Join(name, rel) + f.files[flatPath] = p + return nil + }); err != nil { + return nil, err + } + } + return f, nil +} + +func (f *flatSkillFS) Open(name string) (fs.File, error) { + name = path.Clean(name) + if name == "." { + return &flatRootDir{fs: f}, nil + } + real, ok := f.files[name] + if !ok { + // Directories are not stored in f.files, but skillsmith/fs.WalkDir + // needs to be able to open them. Build the real dir path on demand. + flatDir := name + if idx := strings.Index(name, "/"); idx >= 0 { + flatDir = name[:idx] + } + realDir, ok := f.dirs[flatDir] + if !ok { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} + } + real = path.Join(realDir, strings.TrimPrefix(name, flatDir)) + real = strings.TrimPrefix(real, "/") + } + return f.root.Open(real) +} + +func (f *flatSkillFS) ReadDir(name string) ([]fs.DirEntry, error) { + name = path.Clean(name) + if name == "." { + names := make([]string, 0, len(f.dirs)) + for n := range f.dirs { + names = append(names, n) + } + sort.Strings(names) + entries := make([]fs.DirEntry, len(names)) + for i, n := range names { + entries[i] = flatDirEntry{name: n} + } + return entries, nil + } + real, ok := f.dirRealPath(name) + if !ok { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist} + } + return fs.ReadDir(f.root, real) +} + +func (f *flatSkillFS) Stat(name string) (fs.FileInfo, error) { + name = path.Clean(name) + if name == "." { + return &flatRootInfo{}, nil + } + real, ok := f.files[name] + if !ok { + real, ok = f.dirRealPath(name) + if !ok { + return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist} + } + } + return fs.Stat(f.root, real) +} + +func (f *flatSkillFS) ReadFile(name string) ([]byte, error) { + name = path.Clean(name) + real, ok := f.files[name] + if !ok { + real, ok = f.dirRealPath(name) + if !ok { + return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist} + } + } + return fs.ReadFile(f.root, real) +} + +func (f *flatSkillFS) dirRealPath(flat string) (string, bool) { + flatDir := flat + if idx := strings.Index(flat, "/"); idx >= 0 { + flatDir = flat[:idx] + } + realDir, ok := f.dirs[flatDir] + if !ok { + return "", false + } + rel := strings.TrimPrefix(flat, flatDir) + rel = strings.TrimPrefix(rel, "/") + return path.Join(realDir, rel), true +} + +type flatDirEntry struct { + name string +} + +func (e flatDirEntry) Name() string { return e.name } +func (e flatDirEntry) IsDir() bool { return true } +func (e flatDirEntry) Type() fs.FileMode { return fs.ModeDir } +func (e flatDirEntry) Info() (fs.FileInfo, error) { return &flatDirInfo{name: e.name}, nil } + +type flatDirInfo struct { + name string +} + +func (i flatDirInfo) Name() string { return i.name } +func (i flatDirInfo) Size() int64 { return 0 } +func (i flatDirInfo) Mode() fs.FileMode { return fs.ModeDir | 0o755 } +func (i flatDirInfo) ModTime() time.Time { return time.Time{} } +func (i flatDirInfo) IsDir() bool { return true } +func (i flatDirInfo) Sys() any { return nil } + +type flatRootInfo struct{} + +func (i flatRootInfo) Name() string { return "." } +func (i flatRootInfo) Size() int64 { return 0 } +func (i flatRootInfo) Mode() fs.FileMode { return fs.ModeDir | 0o755 } +func (i flatRootInfo) ModTime() time.Time { return time.Time{} } +func (i flatRootInfo) IsDir() bool { return true } +func (i flatRootInfo) Sys() any { return nil } + +type flatRootDir struct { + fs *flatSkillFS + idx int + names []string +} + +func (d *flatRootDir) Stat() (fs.FileInfo, error) { return &flatRootInfo{}, nil } +func (d *flatRootDir) Read([]byte) (int, error) { return 0, io.EOF } +func (d *flatRootDir) Close() error { return nil } + +func (d *flatRootDir) ReadDir(n int) ([]fs.DirEntry, error) { + if d.names == nil { + d.names = make([]string, 0, len(d.fs.dirs)) + for name := range d.fs.dirs { + d.names = append(d.names, name) + } + sort.Strings(d.names) + } + if d.idx >= len(d.names) { + return nil, io.EOF + } + if n <= 0 || d.idx+n > len(d.names) { + n = len(d.names) - d.idx + } + entries := make([]fs.DirEntry, n) + for i := 0; i < n; i++ { + entries[i] = flatDirEntry{name: d.names[d.idx]} + d.idx++ + } + return entries, nil +} diff --git a/skills/github-skills/skill-github-account/LICENSE b/skills/github-skills/skill-github-account/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/github-skills/skill-github-account/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/github-skills/skill-github-account/SKILL.md b/skills/github-skills/skill-github-account/SKILL.md new file mode 100644 index 00000000..4ad93612 --- /dev/null +++ b/skills/github-skills/skill-github-account/SKILL.md @@ -0,0 +1,107 @@ +--- +name: skill-github-account +description: GitHub Account Registrierung via Google OAuth mit Fallback. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: OpenSIN-Code + version: 1.0.0 +--- + +# skill-github-account + +GitHub Account Registrierung via Google OAuth mit Fallback. + +## WANN VERWENDEN + +- Wenn ein neuer GitHub Account via Google Workspace Account erstellt werden soll +- Wenn ein Agent sich bei GitHub mit einem Google Account anmelden muss +- Wenn ein bestehender GitHub Account mit diesem Google Account existiert (Fallback) + +## 🚨 VORRAUSSETZUNG: CHROME MIT KRITISCHEN FLAGS 🚨 + +**BEVOR GitHub Signup gestartet wird MUSS Chrome mit diesen Flags laufen:** + +``` +--disable-features=SigninInterceptBubble,ExplicitBrowserSigninUIOnDesktop +--disable-sync +--no-first-run +--user-data-dir=/tmp/oar_openrouter_chrome_7656 +``` + +Ohne diese Flags erscheint das native "Mein Chrome" Popup und blockiert den gesamten Flow! + +Siehe `/undelete-login-google` für den kompletten Chrome-Start-Code. + +## FLOW + +### 1. Google muss eingeloggt sein + +Stelle sicher dass Google bereits im Browser eingeloggt ist (siehe `/undelete-login-google`). + +### 2. GitHub Signup + +```python +from oar_steps.github_signup import github_signup_via_google + +result = await github_signup_via_google(browser, email) +# result = {"status": "success", "username": "...", "email": "..."} +# ODER: {"status": "already_exists", "username": "..."} +# ODER: {"status": "already_signed_in"} +``` + +### 3. Ablauf im Detail + +1. Navigiere zu `https://github.com/signup` +2. Schließe alle Overlays/Modals (Country Selector etc.) +3. Klicke "Continue with Google" + - **Fallback**: Wenn Selektor nicht findet → JS click: `Array.from(document.querySelectorAll('button')).find(b => b.innerText.includes('Google')).click()` +4. Google Account Picker → Account auswählen +5. Username aus Name Generator eingeben +6. Passwort setzen +7. Account in Google Sheets loggen + +### 4. Fallback: Bereits existierender Account + +Wenn GitHub meldet dass der Account bereits existiert: + +1. Versuche Login statt Signup +2. Navigiere zu `https://github.com/login` +3. Klicke "Sign in with Google" +4. Wähle Google Account +5. Verifiziere Login-Erfolg + +### 5. Google Sheets Logging + +Nach erfolgreicher Registrierung: + +``` +Nr | vorname | nachname | geburtsdatum | benutzername | passwort +``` + +Siehe Google Sheets Logging Skill für Details. + +## SCREENSHOT PFLICHT + +- `/tmp/github_01_signup_page.png` +- `/tmp/github_02_google_picker.png` +- `/tmp/github_03_username_page.png` +- `/tmp/github_04_final_state.png` + +## LOG PFLICHT + +Jeder Schritt MUSS loggen: + +- URL vor und nach jeder Aktion +- Alle gefundenen/nicht-gefundenen Elemente +- Tab-Anzahl im Browser +- Seite-Text Vorschau (erste 200 chars) +- Screenshot bei jedem FAIL + + +## Lifecycle + +- **lifecycle:** external +- **sources:** `OpenSIN-Code/Infra-SIN-OpenCode-Stack/skills/create-github-account` diff --git a/skills/github-skills/skill-github-account/context/triggers.md b/skills/github-skills/skill-github-account/context/triggers.md new file mode 100644 index 00000000..389bd08d --- /dev/null +++ b/skills/github-skills/skill-github-account/context/triggers.md @@ -0,0 +1,3 @@ +# Triggers + +List the user phrases that should activate this skill. diff --git a/skills/github-skills/skill-github-account/frameworks/standards.md b/skills/github-skills/skill-github-account/frameworks/standards.md new file mode 100644 index 00000000..6443a453 --- /dev/null +++ b/skills/github-skills/skill-github-account/frameworks/standards.md @@ -0,0 +1,3 @@ +# Standards + +Describe the standards, rules, and conventions this skill enforces. diff --git a/skills/github-skills/skill-github-account/tasks/workflow.md b/skills/github-skills/skill-github-account/tasks/workflow.md new file mode 100644 index 00000000..b6f8aa22 --- /dev/null +++ b/skills/github-skills/skill-github-account/tasks/workflow.md @@ -0,0 +1,3 @@ +# Workflow + +Step-by-step workflow the agent follows when this skill is activated. diff --git a/skills/github-skills/skill-github-account/templates/output.md b/skills/github-skills/skill-github-account/templates/output.md new file mode 100644 index 00000000..eff47360 --- /dev/null +++ b/skills/github-skills/skill-github-account/templates/output.md @@ -0,0 +1,3 @@ +# Output Template + +Template for the final output of this skill. diff --git a/skills/github-skills/skill-github-account/templates/prompt.md b/skills/github-skills/skill-github-account/templates/prompt.md new file mode 100644 index 00000000..d9ea7959 --- /dev/null +++ b/skills/github-skills/skill-github-account/templates/prompt.md @@ -0,0 +1,3 @@ +# Prompt Template + +Reusable prompt template for this skill. diff --git a/skills/github-skills/skill-github-actions/LICENSE b/skills/github-skills/skill-github-actions/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/github-skills/skill-github-actions/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/github-skills/skill-github-actions/SKILL.md b/skills/github-skills/skill-github-actions/SKILL.md new file mode 100644 index 00000000..84b6ac25 --- /dev/null +++ b/skills/github-skills/skill-github-actions/SKILL.md @@ -0,0 +1,53 @@ +--- +name: skill-github-actions +description: One-command GitHub Actions workflow deployment for OpenSIN-Code repos. Provisions canonical workflows, branch protection, dependabot, and release automation. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-github-actions + +## Overview + +Provision and maintain GitHub Actions workflows, branch protection, dependabot, and release automation for OpenSIN-Code repositories. + +## When to Use + +- User says "deploy workflow", "add GitHub Action", "set up CI", "fix release.yml", "fix the broken pipeline", "branch protection", "require skill-code-ceo-audit check", "rollout to all repos", "deploy org-wide", "dependabot", "SBOM in CI", "release automation". + +## When NOT to Use + +- Repository is not hosted on GitHub. +- User does not have admin access. + +## Core Process + +``` +AUDIT → PROVISION → VERIFY → PUSH +``` + +1. Audit existing workflows and branch protection. +2. Provision canonical files (skill-code-ceo-audit.yml, release.yml, dependabot.yml, sbom.yml, branch-protection.json, app-integration.yml). +3. Verify via dry-run or PR. +4. Push to repo or batch-rollout. + +## Canonical Files + +- `.github/workflows/skill-code-ceo-audit.yml` +- `.github/workflows/release.yml` +- `.github/dependabot.yml` +- `.github/workflows/sbom.yml` +- `.github/branch-protection.json` +- `.github/workflows/app-integration.yml` + +## Verification + +- [ ] Files exist and are valid YAML/JSON. +- [ ] Branch protection rules reference required checks. +- [ ] Workflow runs without errors. +- [ ] Commit uses Conventional Commits. diff --git a/skills/github-skills/skill-github-actions/context/triggers.md b/skills/github-skills/skill-github-actions/context/triggers.md new file mode 100644 index 00000000..b2f8210d --- /dev/null +++ b/skills/github-skills/skill-github-actions/context/triggers.md @@ -0,0 +1,20 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "deploy workflow", "add GitHub Action", "set up CI", "fix release.yml", "fix the broken pipeline", "branch protection", "require ceo-audit check", "rollout to all repos", "deploy org-wide", "dependabot", "SBOM in CI", "release automation" + +## Boundaries + +- **In scope:** GitHub Actions workflow deployment, branch protection, dependabot. +- **Out of scope:** Writing application code. + +## Required Input + +GitHub repo(s) or org. + +## Tone + +Automation-focused, idempotent, batch-aware. diff --git a/skills/github-skills/skill-github-actions/frameworks/standards.md b/skills/github-skills/skill-github-actions/frameworks/standards.md new file mode 100644 index 00000000..de9057ec --- /dev/null +++ b/skills/github-skills/skill-github-actions/frameworks/standards.md @@ -0,0 +1,23 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Canonical Workflows + +- ceo-audit.yml +- release.yml +- sbom.yml +- app-integration.yml +- dependabot.yml + +## Branch Protection + +- Require PRs. +- Require `ceo-audit` check. +- Restrict pushes to main. + +## Constraints + +- Idempotent. +- Conventional Commits. +- Use real `gh` CLI + REST API. diff --git a/skills/github-skills/skill-github-actions/tasks/workflow.md b/skills/github-skills/skill-github-actions/tasks/workflow.md new file mode 100644 index 00000000..25a0d61d --- /dev/null +++ b/skills/github-skills/skill-github-actions/tasks/workflow.md @@ -0,0 +1,22 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Confirm target repo(s) and access. +- [ ] List existing workflows. + +## Execution + +- [ ] Audit current workflow state. +- [ ] Provision missing canonical files. +- [ ] Update or fix broken files. +- [ ] Set branch protection rules. +- [ ] Commit with Conventional Commits. +- [ ] Push or open PR. +- [ ] Verify workflow runs. + +## Post-flight + +- [ ] Report provisioned files and branch protection status. diff --git a/skills/github-skills/skill-github-actions/templates/output.md b/skills/github-skills/skill-github-actions/templates/output.md new file mode 100644 index 00000000..2cac7108 --- /dev/null +++ b/skills/github-skills/skill-github-actions/templates/output.md @@ -0,0 +1,26 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Deployment Summary + +```markdown +## Git Workflow Deployment + +### Repositories +- {repo1} +- {repo2} + +### Files Added/Updated +- .github/workflows/ceo-audit.yml +- .github/workflows/release.yml +- ... + +### Branch Protection +- Require PRs: enabled +- Required checks: ceo-audit + +### Verification +- Workflow runs: {status} +- Commit: {hash} +``` diff --git a/skills/github-skills/skill-github-actions/templates/prompt.md b/skills/github-skills/skill-github-actions/templates/prompt.md new file mode 100644 index 00000000..a2213936 --- /dev/null +++ b/skills/github-skills/skill-github-actions/templates/prompt.md @@ -0,0 +1,19 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## Deploy workflow + +```markdown +Target: {repo or org} + +Deploy the canonical OpenSIN-Code GitHub workflow stack: +- ceo-audit.yml +- release.yml +- dependabot.yml +- sbom.yml +- branch-protection.json +- app-integration.yml + +Idempotent, Conventional Commits, batch-rollable. +``` diff --git a/skills/github-skills/skill-github-app/LICENSE b/skills/github-skills/skill-github-app/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/github-skills/skill-github-app/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/github-skills/skill-github-app/SKILL.md b/skills/github-skills/skill-github-app/SKILL.md new file mode 100644 index 00000000..cffb02a6 --- /dev/null +++ b/skills/github-skills/skill-github-app/SKILL.md @@ -0,0 +1,134 @@ +--- +name: skill-github-app +description: Automate GitHub App creation for OpenSIN organization using browser automation. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: OpenSIN-Code + version: 1.0.0 +--- + +# skill-github-app + +> Automate GitHub App creation for OpenSIN organization using browser automation. + +## Purpose + +Create GitHub Apps programmatically using skylight-cli browser automation. This skill automates the GitHub App creation flow that normally requires manual UI interaction. + +## Prerequisites + +- skylight-cli MCP server running on http://localhost:8765/mcp +- Chrome logged into GitHub account with admin access to target organization +- `anonymous` skill loaded for browser automation tools + +## Usage + +When the user asks to create a GitHub App, follow these steps: + +### 1. Prepare App Configuration + +Gather required information: + +- **App name**: e.g., `opnsin-code` +- **Homepage URL**: e.g., `https://opensin.ai` +- **Webhook URL**: e.g., `http://92.5.60.87:5678/webhook/github` +- **Webhook secret**: Generate random secret +- **Callback URL**: e.g., `https://opensin.ai/auth/callback` +- **Permissions**: Issues (RW), Pull Requests (RW), Metadata (R), Contents (R) +- **Events**: issues, issue_comment, pull_request, pull_request_review +- **Organization**: Target org for installation + +### 2. Browser Automation Flow + +```python +# Step 1: Navigate to GitHub Apps settings +goto({"url": "https://github.com/settings/apps/new"}) + +# Step 2: Fill form fields +# GitHub App name +observe_screen() # Find coordinates +click({"x": , "y": }) +type_text({"text": "opnsin-code"}) + +# Homepage URL +press_key({"key": "Tab"}) +type_text({"text": "https://opensin.ai"}) + +# Webhook URL (expand webhook section first) +press_key({"key": "Tab"}) +press_key({"key": "Tab"}) # Navigate to webhook checkbox +press_key({"key": "Space"}) # Check webhook +press_key({"key": "Tab"}) # Move to webhook URL field +type_text({"text": "http://92.5.60.87:5678/webhook/github"}) + +# Webhook secret +press_key({"key": "Tab"}) +type_text({"text": ""}) + +# Step 3: Configure permissions +# Scroll to permissions section +press_key({"key": "PageDown"}) + +# Repository permissions +# Use observe_screen to find permission toggles and click them + +# Step 4: Subscribe to events +# Use observe_screen to find event checkboxes + +# Step 5: Create the app +# Scroll to bottom and click "Create GitHub App" +observe_screen() +click({"x": , "y": }) + +# Step 6: Extract App ID and Client ID +# After creation, scrape the app settings page +observe_screen({"include_dom": "true"}) +``` + +### 3. Post-Creation Steps + +After app is created: + +1. **Generate Private Key**: Navigate to app settings → Private keys → Generate +2. **Install in Organization**: Navigate to app settings → Install App → Select org +3. **Save Credentials**: Store App ID, Client ID, Client Secret, Private Key in sin-passwordmanager + +## Best Practices + +- Always use `observe_screen()` before clicking to get current page state +- Use DOM analysis to find form fields precisely +- Add delays between actions (GitHub has rate limiting) +- Take screenshots at each major step for debugging +- Handle GitHub's 2FA if prompted + +## Error Handling + +| Error | Recovery | +| --------------------- | ------------------------------------ | +| Form validation error | Read error message, fix field, retry | +| Rate limit | Wait 60 seconds, retry | +| 2FA required | Prompt user for 2FA code | +| App name taken | Suggest alternative name | + +## Example + +User: "Create a GitHub App called @opnsin-code for OpenSIN-AI" + +Agent: + +1. Navigate to https://github.com/settings/apps/new +2. Fill form with app details +3. Configure permissions and events +4. Submit form +5. Generate private key +6. Install in OpenSIN-AI organization +7. Save credentials to password manager + + +## Lifecycle + +- **lifecycle:** external +- **sources:** `OpenSIN-Code/Infra-SIN-OpenCode-Stack/skills/create-github-app` diff --git a/skills/github-skills/skill-github-app/context/triggers.md b/skills/github-skills/skill-github-app/context/triggers.md new file mode 100644 index 00000000..389bd08d --- /dev/null +++ b/skills/github-skills/skill-github-app/context/triggers.md @@ -0,0 +1,3 @@ +# Triggers + +List the user phrases that should activate this skill. diff --git a/skills/github-skills/skill-github-app/frameworks/standards.md b/skills/github-skills/skill-github-app/frameworks/standards.md new file mode 100644 index 00000000..6443a453 --- /dev/null +++ b/skills/github-skills/skill-github-app/frameworks/standards.md @@ -0,0 +1,3 @@ +# Standards + +Describe the standards, rules, and conventions this skill enforces. diff --git a/skills/github-skills/skill-github-app/tasks/workflow.md b/skills/github-skills/skill-github-app/tasks/workflow.md new file mode 100644 index 00000000..b6f8aa22 --- /dev/null +++ b/skills/github-skills/skill-github-app/tasks/workflow.md @@ -0,0 +1,3 @@ +# Workflow + +Step-by-step workflow the agent follows when this skill is activated. diff --git a/skills/github-skills/skill-github-app/templates/output.md b/skills/github-skills/skill-github-app/templates/output.md new file mode 100644 index 00000000..eff47360 --- /dev/null +++ b/skills/github-skills/skill-github-app/templates/output.md @@ -0,0 +1,3 @@ +# Output Template + +Template for the final output of this skill. diff --git a/skills/github-skills/skill-github-app/templates/prompt.md b/skills/github-skills/skill-github-app/templates/prompt.md new file mode 100644 index 00000000..d9ea7959 --- /dev/null +++ b/skills/github-skills/skill-github-app/templates/prompt.md @@ -0,0 +1,3 @@ +# Prompt Template + +Reusable prompt template for this skill. diff --git a/skills/github-skills/skill-github-governance/LICENSE b/skills/github-skills/skill-github-governance/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/github-skills/skill-github-governance/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/github-skills/skill-github-governance/SKILL.md b/skills/github-skills/skill-github-governance/SKILL.md new file mode 100644 index 00000000..54a0370f --- /dev/null +++ b/skills/github-skills/skill-github-governance/SKILL.md @@ -0,0 +1,201 @@ +--- +name: skill-github-governance +description: "Autonomous repository management: internal governance (Zeus & Hermes) and external bug-hunter outreach." +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: OpenSIN-Code + version: 1.0.0 +--- + +# skill-github-governance + +(opencode - Skill) The March 2026 gold standard for autonomous repository management. This skill governs the entire lifecycle of a repository, divided into two primary pillars: **Internal Governance (Zeus & Hermes Control Plane)** for autonomous task orchestration, and **External Outreach (CEO Bug-Hunter Protocol)** for building global reputation by fixing upstream anomalies. + +## Triggers + +**MANDATORY USE:** Keywords "zeus", "bootstrap project", "roadmap to issues", "hermes dispatch", "github issue", "opencode issue", "hunt bugs", "fix upstream", "sync fixes", "report bug". + +--- + +## PART 1: Internal Governance (Zeus & Hermes Control Plane) + +The internal governance model converts high-level plans into highly structured, actionable GitHub Projects, Epics, and Sub-Issues, and then automatically dispatches them to the specialized A2A Coder Fleet. + +### 0. GitHub Metadata Governance (Mandatory for OpenSIN-AI) + +When creating or updating any A2A-SIN repository inside the GitHub organization `OpenSIN-AI`, agents MUST set the GitHub metadata correctly: + +- **Required topic:** `opnsin-agent` +- **Required website field:** the public dashboard URL for that agent + +Website host rules: + +- Until the official domain cutover is explicitly released by the user, use the currently live dashboard host on `https://a2a.delqhi.com` +- After the official cutover/release, update the repo website to `https://opensin.ai/agents/` + +Examples: + +- before cutover: `https://a2a.delqhi.com/agents/sin-google-apps` +- after cutover: `https://opensin.ai/agents/sin-google-apps` + +Fail-closed rule: + +- a newly created A2A-SIN repo is **not governance-complete** until the required topic and the correct website URL are both set + +### 1. The Zeus Bootstrap Flow + +When an autonomous plan or roadmap is approved (e.g., via `/check-plan-done`), it MUST be persisted into a structured JSON roadmap (e.g., `Docs/operations/endgame-plan.json`). + +- Use **`scripts/zeus/bootstrap-github-project.mjs`** to inject this plan into GitHub. + - _Example:_ `node scripts/zeus/bootstrap-github-project.mjs --owner Delqhi --repo SIN-Solver/OpenSIN --title "Phase 5 Endgame" --plan-file Docs/operations/endgame-plan.json` +- This script automatically: + - Creates a new GitHub Project Board. + - Converts the JSON plan into Epics and Sub-Issues. + - Generates working branches linked to each issue (e.g., `zeus/01-phase-5-...`). + - Applies color-coded labels and team/capability hints. + +### 2. The Hermes Dispatch Flow + +Once issues are in the GitHub/Supabase pool, they must be assigned to the A2A Coder workforce. + +- Use **`scripts/zeus/hermes-pool-sync.mjs`** to poll the `sin_issues_pool` and dynamically route open tasks to the correct specialized agent (`A2A-SIN-Code-Database`, `A2A-SIN-Code-Integration`, `A2A-SIN-Code-AI`, `A2A-SIN-Frontend`, `A2A-SIN-Backend`, etc.) based on keywords and labels. +- Use **`scripts/zeus/hermes-dispatch.mjs`** to build capability routing artifacts for advanced topological assignments. +- Use **`scripts/zeus/hermes-intake.mjs`** to submit tasks directly to the `Room-13` FastAPI coordinator if executing in the legacy worker lane. + +### 3. Inbound Work + PR Watcher Lane (Mandatory) + +Every governed repo must separate ingress from review feedback: + +- **Ingress:** n8n webhook/poller only +- **Normalization:** shared `work_item` contract only +- **Durable task state:** GitHub issue first +- **Review feedback:** PR watcher only after a PR exists + +Required repo artifacts: + +- `governance/repo-governance.json` +- `governance/pr-watcher.json` +- `governance/coder-dispatch-matrix.json` for coding/work repos +- `platforms/registry.json` +- `n8n-workflows/inbound-intake.json` +- `docs/03_ops/inbound-intake.md` +- `scripts/watch-pr-feedback.sh` + +Fail-closed rules: + +- no raw external payloads inside repos +- no accepted inbound work without GitHub issue creation/update +- no PR-based repo without watcher config + runnable watcher entrypoint +- no platform activation without registry entry, signature/cursor verification, dedupe, and issue mapping + +Canonical shared references: + +- `~/.config/opencode/INBOUND_WORK_ARCHITECTURE.md` +- `~/.config/opencode/templates/work-item.schema.json` +- `~/.config/opencode/templates/platform-registry.schema.json` +- `~/.config/opencode/templates/pr-watcher.schema.json` + +--- + +## PART 2: External Outreach (CEO Bug-Hunter Protocol) + +The external protocol dictates how the agent proactively hunts for bugs in upstream repositories, fixes them using SIN-Solver best practices, and publishes elite PRs/Issues. + +### 1. Autonomous Reconnaissance + +- Use `scripts/bug_hunter.mjs` to crawl upstreams like `anomalyco/opencode` and `code-yeongyu/oh-my-openagent`. +- Filter issues by complexity and local relevance. + +### 2. The Fix-and-Vanish Loop + +- Pick an issue, reproduce it in a local sandbox. +- Develop a structural fix following SIN-Solver's 2026 architecture standards. +- Add the bug to `repair-docs.md` with status ✅ GEFIXT. + +### 3. Elite Publication & Visual Evidence + +- Use `scripts/submit_sovereign_issue.mjs` to dual-file the fix. +- Public GitHub APIs do not expose first-class issue image uploads, so host screenshots on a stable public GitHub Release and embed them with Markdown. +- Generate premium code/error screenshots with `carbon-now-cli`. +- For issue evidence, capture three lanes when available: error state, changed code/diff state, successful post-fix state. +- Use `scripts/capture_diff_screenshot.mjs` to generate AIometrics-watermarked screenshots and upload them to the public `auto-screenshots` release. +- Ensure the PR/Issue comment includes a reference to our Sovereign Fleet. + +--- + +## Automation Tools + +**Internal (Zeus & Hermes):** + +- **`scripts/zeus/bootstrap-github-project.mjs`**: Converts JSON roadmaps into full GitHub Project Boards, Epics, and working branches. +- **`scripts/zeus/hermes-pool-sync.mjs`**: Polls the Supabase issue pool and dispatches tasks to specific A2A Coder Agents. +- **`scripts/zeus/hermes-dispatch.mjs`**: Generates capability-routed executor jobs. +- **`scripts/zeus/hermes-intake.mjs`**: Submits capability payloads to the Room-13 coordinator. + +**External (Bug Hunter):** + +- **`scripts/bug_hunter.mjs`**: Crawls upstreams for candidates. +- **`scripts/sync_all_local_fixes.mjs`**: Deep-scans docs and reports un-published local fixes. +- **`scripts/capture_diff_screenshot.mjs`**: Generates and hosts premium code/error screenshots. +- **`scripts/submit_sovereign_issue.mjs`**: Dual-files issues with CEO formatting and optional visual evidence (`--error-file`, `--diff-file`, `--success-file`). +- **`scripts/remediate_workspace_docs.mjs`**: Idempotent documentation sync for Google Workspace. + +> **FLEET SYNC MANDATE:** Any modifications to this skill or its scripts MUST be pushed to all runtimes via `sin-sync`. + +--- + +## PART 3: Automated Wiki Governance (Best Practices) + +In enterprise and private environments, standard GitHub Wikis easily become disorganized dumping grounds. To maintain technical excellence, all A2A agents MUST autonomously initialize and structure the GitHub Wiki of a new or existing repository using strict best practices based on the repository's use case. + +### Core Enterprise Wiki Rules + +1. **Wiki as Code:** A GitHub Wiki is just a secondary Git repository (`https://github.com//.wiki.git`). Agents must interact with it via `git clone`, generate the structure locally, and `git push`. +2. **Mandatory Navigation:** Every wiki MUST have a `_Sidebar.md` to force a strict, hierarchical navigation menu. Do not rely on GitHub's default alphabetical sorting. +3. **Logical Folders:** Use slash-separated filenames to mimic folders (e.g., `Architecture/System-Design.md`). +4. **Image Persistence:** Create an `assets/` folder inside the `.wiki.git` repository to host images. Never link to ephemeral external image hosts. + +### Standard Templates per Use Case + +Use the `scripts/zeus/wiki-bootstrap.mjs` script to automatically generate the correct structure based on the repo type: + +- **Frontend (`--type frontend`)**: + - `Home.md` (Project Overview, Setup) + - `Architecture/State-Management.md` + - `Architecture/Component-Library.md` + - `Guides/Styling-Conventions.md` +- **Backend/Service (`--type backend`)**: + - `Home.md` (Project Overview, Setup) + - `Architecture/System-Design.md` + - `Architecture/Database-Schema.md` + - `API/Endpoints.md` + - `Operations/Runbooks.md` +- **Library/Package (`--type library`)**: + - `Home.md` (Overview, Installation) + - `Usage/Quickstart.md` + - `API/Reference.md` + - `Guides/Contributing.md` +- **Monorepo (`--type monorepo`)**: + - `Home.md` (Ecosystem Overview, Setup) + - `Architecture/Package-Boundaries.md` + - `Operations/CI-CD-Pipelines.md` + - `Guides/Tooling.md` + +### Execution + +Whenever a new repo is created or an agent is tasked with documentation: + +```bash +node scripts/zeus/wiki-bootstrap.mjs --org OpenSIN-AI --repo my-new-service --type backend +``` + +This clones the wiki, scaffolds the correct `.md` files and the `_Sidebar.md`, and pushes it to the repository. + + +## Lifecycle + +- **lifecycle:** external +- **sources:** `OpenSIN-Code/Infra-SIN-OpenCode-Stack/skills/sovereign-repo-governance` diff --git a/skills/github-skills/skill-github-governance/context/triggers.md b/skills/github-skills/skill-github-governance/context/triggers.md new file mode 100644 index 00000000..389bd08d --- /dev/null +++ b/skills/github-skills/skill-github-governance/context/triggers.md @@ -0,0 +1,3 @@ +# Triggers + +List the user phrases that should activate this skill. diff --git a/skills/github-skills/skill-github-governance/frameworks/standards.md b/skills/github-skills/skill-github-governance/frameworks/standards.md new file mode 100644 index 00000000..6443a453 --- /dev/null +++ b/skills/github-skills/skill-github-governance/frameworks/standards.md @@ -0,0 +1,3 @@ +# Standards + +Describe the standards, rules, and conventions this skill enforces. diff --git a/skills/github-skills/skill-github-governance/scripts/bug_hunter.mjs b/skills/github-skills/skill-github-governance/scripts/bug_hunter.mjs new file mode 100755 index 00000000..4265f2f8 --- /dev/null +++ b/skills/github-skills/skill-github-governance/scripts/bug_hunter.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import { execSync } from 'child_process'; +import fs from 'fs'; + +const REPOS = ['anomalyco/opencode', 'code-yeongyu/oh-my-openagent']; + +async function hunt() { + console.log("🚀 Sovereign Bug Hunter starting... 🚀\n"); + + for (const repo of REPOS) { + console.log(`--- Checking ${repo} for fixable issues ---`); + try { + const cmd = `gh issue list --repo ${repo} --limit 10 --json number,title,labels,body`; + const issues = JSON.parse(execSync(cmd, { encoding: 'utf-8' })); + + issues.forEach(issue => { + const labels = issue.labels.map(l => l.name).join(', '); + console.log(`\n[#${issue.number}] ${issue.title}`); + console.log(`Labels: ${labels}`); + + // Heuristic: Check if we have code related to this + // (This is a simplified version, real agent would grep codebase) + console.log(`Potential candidates found for local Grep analysis.`); + }); + } catch (e) { + console.error(`Failed to fetch issues for ${repo}`); + } + console.log("\n"); + } +} +hunt(); diff --git a/skills/github-skills/skill-github-governance/scripts/capture_diff_screenshot.mjs b/skills/github-skills/skill-github-governance/scripts/capture_diff_screenshot.mjs new file mode 100755 index 00000000..d98842a3 --- /dev/null +++ b/skills/github-skills/skill-github-governance/scripts/capture_diff_screenshot.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import { execSync } from 'node:child_process'; +import { writeFileSync, unlinkSync } from 'node:fs'; + +const [inputFile, outputName] = process.argv.slice(2); + +if (!inputFile || !outputName) { + console.error("Usage: node capture_diff_screenshot.mjs "); + process.exit(1); +} + +const assetName = outputName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); +const carbonImage = `/tmp/${assetName}.png`; +const watermarkedImage = `/tmp/${assetName}_watermarked.png`; +const logoPath = `${process.env.HOME}/.config/opencode/skills/imagegen/aiometrics_logo.png`; + +console.log(`📸 Capturing screenshot of ${inputFile}...`); + +try { + execSync(`npx carbon-now-cli "${inputFile}" --save-to /tmp --save-as "${assetName}" --disable-headless`, { stdio: 'inherit' }); + console.log(`💧 Applying AIometrics Enterprise watermark...`); + execSync(`python3 ${process.env.HOME}/.config/opencode/skills/imagegen/scripts/add_watermark.py "${carbonImage}" "${watermarkedImage}" "${logoPath}"`, { stdio: 'inherit' }); + + console.log(`☁️ Uploading to public GitHub Release...`); + execSync(`gh release upload auto-screenshots "${watermarkedImage}" -R Delqhi/opencode --clobber`, { stdio: 'inherit' }); + + const publicUrl = `https://github.com/Delqhi/opencode/releases/download/auto-screenshots/${assetName}_watermarked.png`; + + console.log(`\n✅ Success! Markdown Snippet:\n`); + const markdown = `![${assetName}](${publicUrl})`; + console.log(markdown); + + writeFileSync(`/tmp/${assetName}_markdown.txt`, markdown); + +} catch (err) { + console.error(`❌ Failed to generate or upload screenshot: ${err.message}`); + process.exit(1); +} finally { + try { unlinkSync(carbonImage); } catch (e) {} +} diff --git a/skills/github-skills/skill-github-governance/scripts/remediate_workspace_docs.mjs b/skills/github-skills/skill-github-governance/scripts/remediate_workspace_docs.mjs new file mode 100755 index 00000000..6aeac964 --- /dev/null +++ b/skills/github-skills/skill-github-governance/scripts/remediate_workspace_docs.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { execSync } from 'child_process'; +import fs from 'fs'; + +const AGENT_CLI = '/Users/jeremy/dev/SIN-Solver/a2a/team-infratructur/A2A-SIN-Google-Apps/dist/src/cli.js'; +const TEAM_MAP_PATH = '/tmp/team_folder_map.json'; + +const teamFolders = JSON.parse(fs.readFileSync(TEAM_MAP_PATH, 'utf8')); + +for (const [teamSlug, folderObj] of Object.entries(teamFolders)) { + if (teamSlug.includes('__SIN')) continue; + + console.log(`\n--- Remediating: ${folderObj.name} ---`); + const docTitle = `${folderObj.name} Docs`; + + // 1. Create or Find Doc + const listAction = { + action: "google.drive.list_folder", + authMode: "user_oauth", + accountKey: "workspace-admin", + folderId: folderObj.id + }; + fs.writeFileSync('/tmp/list_action.json', JSON.stringify(listAction)); + const listOut = JSON.parse(execSync(`node ${AGENT_CLI} run-action "$(cat /tmp/list_action.json)"`, { encoding: 'utf-8' })); + let docId = listOut.files.find(f => f.name === docTitle)?.id; + + if (!docId) { + console.log(`Creating fresh doc: ${docTitle}`); + const createAction = { + action: "google.drive.create_file", + authMode: "user_oauth", + accountKey: "workspace-admin", + name: docTitle, + fileType: "doc", + parentFolderId: folderObj.id, + confirm: true + }; + fs.writeFileSync('/tmp/create_action.json', JSON.stringify(createAction)); + const createOut = JSON.parse(execSync(`node ${AGENT_CLI} run-action "$(cat /tmp/create_action.json)"`, { encoding: 'utf-8' })); + docId = createOut.fileId; + } + + if (docId) { + console.log(`Ensuring tabs for Doc: ${docId}`); + const tabs = ["Overview", "Dev Docs", "Installation", "Governance"]; + for (const tabTitle of tabs) { + const tabAction = { + action: "google.docs.ensure_tab", + authMode: "user_oauth", + accountKey: "workspace-admin", + documentId: docId, + title: tabTitle, + confirm: true + }; + fs.writeFileSync('/tmp/tab_action.json', JSON.stringify(tabAction)); + try { + execSync(`node ${AGENT_CLI} run-action "$(cat /tmp/tab_action.json)"`); + console.log(` ✅ Tab ensured: ${tabTitle}`); + } catch (e) { + console.log(` ⚠️ Tab already exists or failed: ${tabTitle}`); + } + } + } +} diff --git a/skills/github-skills/skill-github-governance/scripts/submit_sovereign_issue.mjs b/skills/github-skills/skill-github-governance/scripts/submit_sovereign_issue.mjs new file mode 100755 index 00000000..e81b2b7a --- /dev/null +++ b/skills/github-skills/skill-github-governance/scripts/submit_sovereign_issue.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const args = process.argv.slice(2); + +// Parse arguments (simple parsing for --error-file, etc.) +let repoSlug, title, description, steps, os, severityInput, labelsInput; +let errorFile, successFile, diffFile; + +const positionalArgs = []; +for (let i = 0; i < args.length; i++) { + if (args[i] === '--error-file') { errorFile = args[++i]; } + else if (args[i] === '--success-file') { successFile = args[++i]; } + else if (args[i] === '--diff-file') { diffFile = args[++i]; } + else { positionalArgs.push(args[i]); } +} + +if (positionalArgs.length < 5) { + console.log("Usage: ./submit_sovereign_issue.mjs <description> <steps> <os> [severity] [labels] [--error-file <file>] [--success-file <file>] [--diff-file <file>]"); + process.exit(1); +} + +[repoSlug, title, description, steps, os, severityInput, labelsInput] = positionalArgs; +const severity = severityInput || "P1 - High Impact"; +const labels = labelsInput || "bug, autonomous-fleet"; + +const TARGETS = { + 'opencode': { + upstream: 'anomalyco/opencode', + fork: 'Delqhi/opencode', + version: 'Latest (2026 Fleet Build)', + plugins: 'All SIN-Solver Plugins enabled', + terminal: 'Sovereign Agent Environment (zsh/tmux)' + }, + 'oh-my-opencode': { + upstream: 'code-yeongyu/oh-my-openagent', + fork: 'Delqhi/oh-my-openagent', + version: '3.11.2+', + plugins: 'omo-core', + terminal: 'Sovereign Agent Environment' + }, + 'sin-solver': { + upstream: 'SIN-Solver/OpenSIN', + fork: 'SIN-Solver/OpenSIN', // Same for now + version: 'March 2026 Release', + plugins: 'N/A', + terminal: 'Autonomous Fleet Host' + } +}; + +const target = TARGETS[repoSlug] || { + upstream: repoSlug, + fork: repoSlug, + version: 'N/A', + plugins: 'N/A', + terminal: 'N/A' +}; + +// Screenshot Generation Helper +function attachScreenshot(file, type) { + if (!file || !fs.existsSync(file)) return ''; + console.log(`Generating AIometrics CEO-Level screenshot for ${type}: ${file}`); + const outputName = `auto_screenshot_${type.toLowerCase().replace(/[^a-z0-9]+/g, '_')}_${Date.now()}`; + try { + execSync(`node "${path.join(__dirname, 'capture_diff_screenshot.mjs')}" "${file}" "${outputName}"`, { stdio: 'pipe' }); + const markdownPath = `/tmp/${outputName}_markdown.txt`; + if (fs.existsSync(markdownPath)) { + const markdown = fs.readFileSync(markdownPath, 'utf8'); + return `\n**${type.toUpperCase()}:**\n${markdown}\n`; + } + } catch (err) { + console.error(`Failed to attach screenshot for ${file}`); + } + return ''; +} + +let screenshotsSection = ''; +if (errorFile) screenshotsSection += attachScreenshot(errorFile, 'Error Output'); +if (diffFile) screenshotsSection += attachScreenshot(diffFile, 'Code Diff'); +if (successFile) screenshotsSection += attachScreenshot(successFile, 'Successful Resolution'); + +const ceoBody = ` +## 🎯 Strategic Context +This issue is part of the **Sovereign SIN-Solver Fleet Governance** initiative. It represents a critical path for maintaining 100% autonomous software delivery reliability in our ecosystem. + +## 📝 Description (Architectural Root Cause) +${description} + +--- + +## 💥 Impact Analysis +- **Severity:** ${severity} +- **Operational Risk:** Affects multi-agent synchronization and cross-account orchestration. +- **Enterprise Priority:** High - Required for stable workforce operations. + +## 🛠️ Sovereign Verification & Workaround +A sovereign local fix has been developed and verified in our environment. +**Workaround:** Provided in the reproduction steps or linked commit. + +## 🔄 Autonomous Execution Plan +1. [ ] Identify all affected agent surfaces. +2. [ ] Apply structural remediation. +3. [ ] Verify fix against the 2026 Fleet Standard. + +--- + +## 🧬 Environment Metadata +- **OS:** ${os} +- **Agent Environment:** ${target.terminal} +- **OpenCode Version:** ${target.version} + +## 🔍 Steps to Reproduce +${steps} +`; + +const markdownPayload = `### Description + +${ceoBody} + +### Plugins + +${target.plugins} + +### OpenCode version + +${target.version} + +### Steps to reproduce + +1. See detailed description above. + +### Screenshot and/or share link + +${screenshotsSection || 'N/A (Verified via Sovereign Fleet Logs)'} + +### Operating System + +${os} + +### Terminal + +${target.terminal} +`; + +const tempFile = path.join('/tmp', 'sovereign_payload.md'); +fs.writeFileSync(tempFile, markdownPayload); + +console.log(`\n🚀 [CEO-LEVEL SUBMISSION] 🚀`); +console.log(`Upstream: ${target.upstream}`); +console.log(`Labels: ${labels}`); + +try { + const upstreamOut = execSync(`gh issue create --repo ${target.upstream} --title "${title}" --body-file ${tempFile} --label "${labels}"`, { encoding: 'utf-8' }); + const upstreamUrl = upstreamOut.trim(); + console.log(`✅ Sovereign Upstream Created: ${upstreamUrl}`); + + if (target.upstream !== target.fork) { + console.log(`\nSubmitting to Fork: ${target.fork}`); + const forkPayload = markdownPayload + `\n\n**Upstream Reference:** ${upstreamUrl}`; + fs.writeFileSync(tempFile, forkPayload); + const forkOut = execSync(`gh issue create --repo ${target.fork} --title "[UPSTREAM SYNC] ${title}" --body-file ${tempFile}`, { encoding: 'utf-8' }); + console.log(`✅ Sovereign Fork Synced: ${forkOut.trim()}`); + } + +} catch (e) { + console.error("Submission failed. Ensure gh CLI is authenticated and repo exists."); + if (e.stderr) console.error(e.stderr.toString()); +} finally { + if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); +} diff --git a/skills/github-skills/skill-github-governance/scripts/sync_all_local_fixes.mjs b/skills/github-skills/skill-github-governance/scripts/sync_all_local_fixes.mjs new file mode 100755 index 00000000..b6133d8b --- /dev/null +++ b/skills/github-skills/skill-github-governance/scripts/sync_all_local_fixes.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import fs from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; + +const DOCS_ROOT = '/Users/jeremy/dev/docs'; + +// Find all repair-docs.md recursively +function findRepairDocs(dir, list = []) { + if (!fs.existsSync(dir)) return list; + const files = fs.readdirSync(dir); + for (const file of files) { + const fullPath = path.join(dir, file); + if (fs.statSync(fullPath).isDirectory()) { + findRepairDocs(fullPath, list); + } else if (file === 'repair-docs.md') { + list.push(fullPath); + } + } + return list; +} + +function parseBugs(filePath) { + const content = fs.readFileSync(filePath, 'utf-8'); + const bugs = []; + // Updated regex to catch more variations and include the ID + const bugRegex = /## (BUG-\w+): (.*)\n\*\*Aufgetreten:\*\*.*\*\*Status:\*\*.*✅ GEFIXT\n\*\*Symptom:\*\* (.*)\n\*\*Ursache:\*\* (.*)\n\*\*Fix:\*\* (.*)\n\*\*Datei:\*\* (.*)/g; + let match; + while ((match = bugRegex.exec(content)) !== null) { + bugs.push({ + id: match[1], + title: match[2].trim(), + symptom: match[3].trim(), + cause: match[4].trim(), + fix: match[5].trim(), + file: match[6].trim(), + sourceFile: filePath + }); + } + return bugs; +} + +const docFiles = findRepairDocs(DOCS_ROOT); +console.log(`Found ${docFiles.length} projects with repair-docs.md`); + +for (const docFile of docFiles) { + const projectName = path.basename(path.dirname(docFile)); + console.log(`\n--- Project: ${projectName} ---`); + const bugs = parseBugs(docFile); + + if (bugs.length === 0) { + console.log("No undocumented fixes found."); + continue; + } + + for (const bug of bugs) { + console.log(`[${bug.id}] ${bug.title}`); + // Check if we already have a GitHub URL in the doc (simple check) + if (fs.readFileSync(docFile, 'utf-8').includes(`github.com/`)) { + // This is a naive check, ideally we check per bug block + } + } +} diff --git a/skills/github-skills/skill-github-governance/scripts/zeus/wiki-bootstrap.mjs b/skills/github-skills/skill-github-governance/scripts/zeus/wiki-bootstrap.mjs new file mode 100755 index 00000000..44548c72 --- /dev/null +++ b/skills/github-skills/skill-github-governance/scripts/zeus/wiki-bootstrap.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node + +/** + * Enterprise GitHub Wiki Bootstrapper + * Automatically clones, structures, and pushes a best-practice wiki for a given repository. + */ + +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +const args = process.argv.slice(2); +const getArg = (flag) => { + const idx = args.indexOf(flag); + return idx !== -1 ? args[idx + 1] : null; +}; + +const org = getArg('--org'); +const repo = getArg('--repo'); +const type = getArg('--type') || 'backend'; + +if (!org || !repo) { + console.error("Usage: node wiki-bootstrap.mjs --org <Organization> --repo <Repository> --type <frontend|backend|library|monorepo>"); + process.exit(1); +} + +const wikiUrl = `git@github.com:${org}/${repo}.wiki.git`; +const cloneDir = path.join('/tmp', `${repo}-wiki`); + +console.log(`[Wiki Bootstrap] Cloning wiki repository: ${wikiUrl}`); + +try { + if (fs.existsSync(cloneDir)) fs.rmSync(cloneDir, { recursive: true, force: true }); + + // Check if wiki exists by attempting to clone. If it fails, the wiki might need to be enabled in GitHub settings first. + try { + execSync(`git clone ${wikiUrl} ${cloneDir}`, { stdio: 'pipe' }); + } catch (e) { + console.log(`[Wiki Bootstrap] Wiki repo not found or empty. Initializing new wiki locally...`); + fs.mkdirSync(cloneDir, { recursive: true }); + execSync(`git init`, { cwd: cloneDir }); + execSync(`git remote add origin ${wikiUrl}`, { cwd: cloneDir }); + } + + fs.mkdirSync(path.join(cloneDir, 'assets'), { recursive: true }); + fs.mkdirSync(path.join(cloneDir, 'Architecture'), { recursive: true }); + fs.mkdirSync(path.join(cloneDir, 'Guides'), { recursive: true }); + fs.mkdirSync(path.join(cloneDir, 'Operations'), { recursive: true }); + fs.mkdirSync(path.join(cloneDir, 'API'), { recursive: true }); + fs.mkdirSync(path.join(cloneDir, 'Usage'), { recursive: true }); + + let sidebarContent = `## 📚 ${repo} Documentation\n\n- [[Home]]\n`; + let filesToCreate = { + 'Home.md': `# ${repo}\n\nWelcome to the official documentation for ${repo}.\n\n## Overview\n\n## Getting Started\n\n` + }; + + if (type === 'frontend') { + sidebarContent += `- **Architecture**\n - [[State Management|Architecture/State-Management]]\n - [[Component Library|Architecture/Component-Library]]\n`; + sidebarContent += `- **Guides**\n - [[Styling Conventions|Guides/Styling-Conventions]]\n`; + + filesToCreate['Architecture/State-Management.md'] = `# State Management\n\nDefine how global and local state is handled in this frontend app.`; + filesToCreate['Architecture/Component-Library.md'] = `# Component Library\n\nDetails on UI components and Storybook integration.`; + filesToCreate['Guides/Styling-Conventions.md'] = `# Styling Conventions\n\nTailwind, CSS-in-JS, or global stylesheet rules.`; + } + else if (type === 'library') { + sidebarContent += `- **Usage**\n - [[Quickstart|Usage/Quickstart]]\n`; + sidebarContent += `- **API**\n - [[Reference|API/Reference]]\n`; + sidebarContent += `- **Guides**\n - [[Contributing|Guides/Contributing]]\n`; + + filesToCreate['Usage/Quickstart.md'] = `# Quickstart\n\nInstallation and basic usage examples.`; + filesToCreate['API/Reference.md'] = `# API Reference\n\nDetailed API documentation.`; + filesToCreate['Guides/Contributing.md'] = `# Contributing\n\nHow to build and test this library locally.`; + } + else if (type === 'monorepo') { + sidebarContent += `- **Architecture**\n - [[Package Boundaries|Architecture/Package-Boundaries]]\n`; + sidebarContent += `- **Operations**\n - [[CI/CD Pipelines|Operations/CI-CD-Pipelines]]\n`; + sidebarContent += `- **Guides**\n - [[Tooling|Guides/Tooling]]\n`; + + filesToCreate['Architecture/Package-Boundaries.md'] = `# Package Boundaries\n\nRules for cross-package imports and dependency sharing.`; + filesToCreate['Operations/CI-CD-Pipelines.md'] = `# CI/CD Pipelines\n\nTurborepo cache, build steps, and deployment.`; + filesToCreate['Guides/Tooling.md'] = `# Tooling\n\nLinter, formatter, and workspace script configurations.`; + } + else { // backend/service (default) + sidebarContent += `- **Architecture**\n - [[System Design|Architecture/System-Design]]\n - [[Database Schema|Architecture/Database-Schema]]\n`; + sidebarContent += `- **API**\n - [[Endpoints|API/Endpoints]]\n`; + sidebarContent += `- **Operations**\n - [[Runbooks|Operations/Runbooks]]\n`; + + filesToCreate['Architecture/System-Design.md'] = `# System Design\n\nHigh-level architecture diagram and service dependencies.`; + filesToCreate['Architecture/Database-Schema.md'] = `# Database Schema\n\nCore tables, relationships, and migrations.`; + filesToCreate['API/Endpoints.md'] = `# API Endpoints\n\nREST/GraphQL definitions.`; + filesToCreate['Operations/Runbooks.md'] = `# Runbooks\n\nTroubleshooting, alerts, and deployment processes.`; + } + + filesToCreate['_Sidebar.md'] = sidebarContent; + + for (const [filepath, content] of Object.entries(filesToCreate)) { + const fullPath = path.join(cloneDir, filepath); + if (!fs.existsSync(fullPath)) { + fs.writeFileSync(fullPath, content); + console.log(`[Wiki Bootstrap] Created ${filepath}`); + } + } + + console.log(`[Wiki Bootstrap] Committing and pushing to Wiki repository...`); + execSync(`git add .`, { cwd: cloneDir }); + try { + execSync(`git commit -m "docs: bootstrap enterprise wiki structure for ${type}"`, { cwd: cloneDir, stdio: 'pipe' }); + execSync(`git push origin main -f || git push origin master -f`, { cwd: cloneDir, stdio: 'pipe' }); + console.log(`[Wiki Bootstrap] ✅ Successfully pushed Wiki structure!`); + } catch (e) { + console.log(`[Wiki Bootstrap] No changes to commit or push failed (make sure Wiki feature is enabled in repo settings).`); + } + +} catch (err) { + console.error(`[Wiki Bootstrap] Error:`, err.message); + process.exit(1); +} diff --git a/skills/github-skills/skill-github-governance/tasks/workflow.md b/skills/github-skills/skill-github-governance/tasks/workflow.md new file mode 100644 index 00000000..b6f8aa22 --- /dev/null +++ b/skills/github-skills/skill-github-governance/tasks/workflow.md @@ -0,0 +1,3 @@ +# Workflow + +Step-by-step workflow the agent follows when this skill is activated. diff --git a/skills/github-skills/skill-github-governance/templates/output.md b/skills/github-skills/skill-github-governance/templates/output.md new file mode 100644 index 00000000..eff47360 --- /dev/null +++ b/skills/github-skills/skill-github-governance/templates/output.md @@ -0,0 +1,3 @@ +# Output Template + +Template for the final output of this skill. diff --git a/skills/github-skills/skill-github-governance/templates/prompt.md b/skills/github-skills/skill-github-governance/templates/prompt.md new file mode 100644 index 00000000..d9ea7959 --- /dev/null +++ b/skills/github-skills/skill-github-governance/templates/prompt.md @@ -0,0 +1,3 @@ +# Prompt Template + +Reusable prompt template for this skill. diff --git a/skills/infrastructure-skills/skill-infrastructure-cloudflare/LICENSE b/skills/infrastructure-skills/skill-infrastructure-cloudflare/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-cloudflare/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/infrastructure-skills/skill-infrastructure-cloudflare/SKILL.md b/skills/infrastructure-skills/skill-infrastructure-cloudflare/SKILL.md new file mode 100644 index 00000000..12071bbf --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-cloudflare/SKILL.md @@ -0,0 +1,48 @@ +--- +name: skill-infrastructure-cloudflare +description: Cloudflare skill — Workers, Pages, Workers AI, R2, KV, D1, Cache, Tunnels, DNS, WAF. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +lifecycle: external +sources: + - +--- + +# skill-infrastructure-cloudflare + +## Overview + +Cloudflare skill — Workers, Pages, Workers AI, R2, KV, D1, Cache, Tunnels, DNS, WAF. + +## When to Use + +- The user asks about skill-infrastructure-cloudflare infrastructure operations. +- A project needs to provision, query, or manage skill-infrastructure-cloudflare resources. + +## When NOT to Use + +- The project does not use skill-infrastructure-cloudflare. +- A native `sin-code skill-infrastructure-cloudflare` command is available (future). + +## Core Process + +``` +IDENTIFY -> AUTHENTICATE -> OPERATE -> VERIFY +``` + +1. Identify the requested infrastructure operation. +2. Ensure the correct credentials and environment are configured. +3. Perform the operation via the external canonical skill or CLI. +4. Verify the result and surface errors. + +## Verification + +- [ ] Credentials are configured and not exposed in logs. +- [ ] The operation target matches the project/environment. +- [ ] Changes are idempotent where possible. +- [ ] Output is validated against the expected schema. diff --git a/skills/infrastructure-skills/skill-infrastructure-cloudflare/context/triggers.md b/skills/infrastructure-skills/skill-infrastructure-cloudflare/context/triggers.md new file mode 100644 index 00000000..51fd6e5f --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-cloudflare/context/triggers.md @@ -0,0 +1,3 @@ +# Triggers + +When the user mentions "cloudflare", "wrangler", "cf worker", "cf page", "workers ai", "r2", "d1", "kv namespace", "cloudflare tunnel", "cf cache", use this skill context. diff --git a/skills/infrastructure-skills/skill-infrastructure-cloudflare/frameworks/standards.md b/skills/infrastructure-skills/skill-infrastructure-cloudflare/frameworks/standards.md new file mode 100644 index 00000000..9376c2e0 --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-cloudflare/frameworks/standards.md @@ -0,0 +1,5 @@ +# Standards + +- Follow the canonical SIN-Code skill lifecycle. +- Prefer the external canonical implementation until a native `sin-code cloudflare` command exists. +- Never expose credentials in logs or output. diff --git a/skills/infrastructure-skills/skill-infrastructure-cloudflare/tasks/workflow.md b/skills/infrastructure-skills/skill-infrastructure-cloudflare/tasks/workflow.md new file mode 100644 index 00000000..fc4f52d0 --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-cloudflare/tasks/workflow.md @@ -0,0 +1,6 @@ +# Workflow + +1. Identify the infrastructure operation. +2. Verify credentials and target environment. +3. Invoke the external canonical skill or CLI. +4. Validate and report the result. diff --git a/skills/infrastructure-skills/skill-infrastructure-cloudflare/templates/output.md b/skills/infrastructure-skills/skill-infrastructure-cloudflare/templates/output.md new file mode 100644 index 00000000..b7a9b46b --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-cloudflare/templates/output.md @@ -0,0 +1,10 @@ +# Output Template + +## Result +- Resource: +- Operation: +- Status: success / error / needs review +- External source: + +## Notes +- diff --git a/skills/infrastructure-skills/skill-infrastructure-cloudflare/templates/prompt.md b/skills/infrastructure-skills/skill-infrastructure-cloudflare/templates/prompt.md new file mode 100644 index 00000000..3db6a205 --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-cloudflare/templates/prompt.md @@ -0,0 +1,3 @@ +# Prompt Template + +You are the cloudflare infrastructure skill assistant. Help the user manage cloudflare resources. If the native SIN-Code command is unavailable, guide them to the external canonical implementation. diff --git a/skills/infrastructure-skills/skill-infrastructure-oci-vm/LICENSE b/skills/infrastructure-skills/skill-infrastructure-oci-vm/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-oci-vm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/infrastructure-skills/skill-infrastructure-oci-vm/SKILL.md b/skills/infrastructure-skills/skill-infrastructure-oci-vm/SKILL.md new file mode 100644 index 00000000..62f7d173 --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-oci-vm/SKILL.md @@ -0,0 +1,48 @@ +--- +name: skill-infrastructure-oci-vm +description: OCI VM inventory, access, and management skill — Frankfurt Always Free Tier. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +lifecycle: external +sources: + - +--- + +# skill-infrastructure-oci-vm + +## Overview + +OCI VM inventory, access, and management skill — Frankfurt Always Free Tier. + +## When to Use + +- The user asks about skill-infrastructure-oci-vm infrastructure operations. +- A project needs to provision, query, or manage skill-infrastructure-oci-vm resources. + +## When NOT to Use + +- The project does not use skill-infrastructure-oci-vm. +- A native `sin-code skill-infrastructure-oci-vm` command is available (future). + +## Core Process + +``` +IDENTIFY -> AUTHENTICATE -> OPERATE -> VERIFY +``` + +1. Identify the requested infrastructure operation. +2. Ensure the correct credentials and environment are configured. +3. Perform the operation via the external canonical skill or CLI. +4. Verify the result and surface errors. + +## Verification + +- [ ] Credentials are configured and not exposed in logs. +- [ ] The operation target matches the project/environment. +- [ ] Changes are idempotent where possible. +- [ ] Output is validated against the expected schema. diff --git a/skills/infrastructure-skills/skill-infrastructure-oci-vm/context/triggers.md b/skills/infrastructure-skills/skill-infrastructure-oci-vm/context/triggers.md new file mode 100644 index 00000000..95abb2c2 --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-oci-vm/context/triggers.md @@ -0,0 +1,3 @@ +# Triggers + +When the user mentions "OCI", "oci-vm", "sin-supabase", "92.5.60.87", "vm.Standard.A1", "OCI free tier", use this skill context. diff --git a/skills/infrastructure-skills/skill-infrastructure-oci-vm/frameworks/standards.md b/skills/infrastructure-skills/skill-infrastructure-oci-vm/frameworks/standards.md new file mode 100644 index 00000000..567d25b3 --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-oci-vm/frameworks/standards.md @@ -0,0 +1,5 @@ +# Standards + +- Follow the canonical SIN-Code skill lifecycle. +- Prefer the external canonical implementation until a native `sin-code oci-vm` command exists. +- Never expose credentials in logs or output. diff --git a/skills/infrastructure-skills/skill-infrastructure-oci-vm/tasks/workflow.md b/skills/infrastructure-skills/skill-infrastructure-oci-vm/tasks/workflow.md new file mode 100644 index 00000000..fc4f52d0 --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-oci-vm/tasks/workflow.md @@ -0,0 +1,6 @@ +# Workflow + +1. Identify the infrastructure operation. +2. Verify credentials and target environment. +3. Invoke the external canonical skill or CLI. +4. Validate and report the result. diff --git a/skills/infrastructure-skills/skill-infrastructure-oci-vm/templates/output.md b/skills/infrastructure-skills/skill-infrastructure-oci-vm/templates/output.md new file mode 100644 index 00000000..b7a9b46b --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-oci-vm/templates/output.md @@ -0,0 +1,10 @@ +# Output Template + +## Result +- Resource: +- Operation: +- Status: success / error / needs review +- External source: + +## Notes +- diff --git a/skills/infrastructure-skills/skill-infrastructure-oci-vm/templates/prompt.md b/skills/infrastructure-skills/skill-infrastructure-oci-vm/templates/prompt.md new file mode 100644 index 00000000..47336cde --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-oci-vm/templates/prompt.md @@ -0,0 +1,3 @@ +# Prompt Template + +You are the oci-vm infrastructure skill assistant. Help the user manage oci-vm resources. If the native SIN-Code command is unavailable, guide them to the external canonical implementation. diff --git a/skills/infrastructure-skills/skill-infrastructure-supabase/LICENSE b/skills/infrastructure-skills/skill-infrastructure-supabase/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-supabase/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/infrastructure-skills/skill-infrastructure-supabase/SKILL.md b/skills/infrastructure-skills/skill-infrastructure-supabase/SKILL.md new file mode 100644 index 00000000..fa09dfb7 --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-supabase/SKILL.md @@ -0,0 +1,48 @@ +--- +name: skill-infrastructure-supabase +description: Supabase self-hosted skill — SQL migrations, RLS policies, Auth, Storage, Realtime, Edge Functions, Triggers, Backups. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +lifecycle: external +sources: + - +--- + +# skill-infrastructure-supabase + +## Overview + +Supabase self-hosted skill — SQL migrations, RLS policies, Auth, Storage, Realtime, Edge Functions, Triggers, Backups. + +## When to Use + +- The user asks about skill-infrastructure-supabase infrastructure operations. +- A project needs to provision, query, or manage skill-infrastructure-supabase resources. + +## When NOT to Use + +- The project does not use skill-infrastructure-supabase. +- A native `sin-code skill-infrastructure-supabase` command is available (future). + +## Core Process + +``` +IDENTIFY -> AUTHENTICATE -> OPERATE -> VERIFY +``` + +1. Identify the requested infrastructure operation. +2. Ensure the correct credentials and environment are configured. +3. Perform the operation via the external canonical skill or CLI. +4. Verify the result and surface errors. + +## Verification + +- [ ] Credentials are configured and not exposed in logs. +- [ ] The operation target matches the project/environment. +- [ ] Changes are idempotent where possible. +- [ ] Output is validated against the expected schema. diff --git a/skills/infrastructure-skills/skill-infrastructure-supabase/context/triggers.md b/skills/infrastructure-skills/skill-infrastructure-supabase/context/triggers.md new file mode 100644 index 00000000..867156d3 --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-supabase/context/triggers.md @@ -0,0 +1,3 @@ +# Triggers + +When the user mentions "supabase", "supabase migration", "supabase rls", "supabase auth", "supabase storage", "supabase db", "psql", "kong", "postgrest", use this skill context. diff --git a/skills/infrastructure-skills/skill-infrastructure-supabase/frameworks/standards.md b/skills/infrastructure-skills/skill-infrastructure-supabase/frameworks/standards.md new file mode 100644 index 00000000..6770ee7d --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-supabase/frameworks/standards.md @@ -0,0 +1,5 @@ +# Standards + +- Follow the canonical SIN-Code skill lifecycle. +- Prefer the external canonical implementation until a native `sin-code supabase` command exists. +- Never expose credentials in logs or output. diff --git a/skills/infrastructure-skills/skill-infrastructure-supabase/tasks/workflow.md b/skills/infrastructure-skills/skill-infrastructure-supabase/tasks/workflow.md new file mode 100644 index 00000000..fc4f52d0 --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-supabase/tasks/workflow.md @@ -0,0 +1,6 @@ +# Workflow + +1. Identify the infrastructure operation. +2. Verify credentials and target environment. +3. Invoke the external canonical skill or CLI. +4. Validate and report the result. diff --git a/skills/infrastructure-skills/skill-infrastructure-supabase/templates/output.md b/skills/infrastructure-skills/skill-infrastructure-supabase/templates/output.md new file mode 100644 index 00000000..b7a9b46b --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-supabase/templates/output.md @@ -0,0 +1,10 @@ +# Output Template + +## Result +- Resource: +- Operation: +- Status: success / error / needs review +- External source: + +## Notes +- diff --git a/skills/infrastructure-skills/skill-infrastructure-supabase/templates/prompt.md b/skills/infrastructure-skills/skill-infrastructure-supabase/templates/prompt.md new file mode 100644 index 00000000..91eb835d --- /dev/null +++ b/skills/infrastructure-skills/skill-infrastructure-supabase/templates/prompt.md @@ -0,0 +1,3 @@ +# Prompt Template + +You are the supabase infrastructure skill assistant. Help the user manage supabase resources. If the native SIN-Code command is unavailable, guide them to the external canonical implementation. diff --git a/skills/memory-skills/skill-memory-honcho-rollback/LICENSE b/skills/memory-skills/skill-memory-honcho-rollback/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho-rollback/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/memory-skills/skill-memory-honcho-rollback/SKILL.md b/skills/memory-skills/skill-memory-honcho-rollback/SKILL.md new file mode 100644 index 00000000..72d785db --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho-rollback/SKILL.md @@ -0,0 +1,50 @@ +--- +name: skill-memory-honcho-rollback +description: Snapshot, diff, and rollback sin-brain / Honcho memory with merge/exact/patch strategies and an audit log. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-memory-honcho-rollback + +## Overview + +Add undo capability to sin-brain memory: named snapshots, diffs, restores, and audit logs. + +## When to Use + +- User says "take a snapshot", "rollback memory", "memory diff", "audit log", "undo memory change". + +## When NOT to Use + +- Memory is not persisted yet. +- User only wants to read memory. + +## Core Process + +``` +SNAPSHOT → DIFF → RESTORE → LOG +``` + +1. Create a named snapshot of current memory. +2. Diff between two snapshots. +3. Restore to a snapshot with merge, exact, or patch strategy. +4. Record every mutation in an append-only audit log. + +## Strategies + +- **merge**: Safe, keeps current additions if no conflict. +- **exact**: Destructive, replace exactly. +- **patch**: Apply only changes in diff. + +## Verification + +- [ ] Snapshot created and named. +- [ ] Diff shows added/removed/modified. +- [ ] Restore strategy chosen and errors checked. +- [ ] Audit log updated. diff --git a/skills/memory-skills/skill-memory-honcho-rollback/context/triggers.md b/skills/memory-skills/skill-memory-honcho-rollback/context/triggers.md new file mode 100644 index 00000000..e0886f64 --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho-rollback/context/triggers.md @@ -0,0 +1,20 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "snapshot", "checkpoint", "rollback memory", "restore memory", "audit log", "memory diff", "undo remember" + +## Boundaries + +- **In scope:** Snapshot, diff, restore, audit. +- **Out of scope:** Reading memory without mutation. + +## Required Input + +Snapshot name or two snapshot names. + +## Tone + +Careful, irreversible-operation aware. diff --git a/skills/memory-skills/skill-memory-honcho-rollback/frameworks/standards.md b/skills/memory-skills/skill-memory-honcho-rollback/frameworks/standards.md new file mode 100644 index 00000000..7ce906d4 --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho-rollback/frameworks/standards.md @@ -0,0 +1,15 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Strategies + +- merge: safe, conflict-aware. +- exact: destructive. +- patch: delta-only. + +## Constraints + +- Always dry-run when possible. +- Audit log is append-only. +- Never delete audit log. diff --git a/skills/memory-skills/skill-memory-honcho-rollback/tasks/workflow.md b/skills/memory-skills/skill-memory-honcho-rollback/tasks/workflow.md new file mode 100644 index 00000000..5a497c05 --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho-rollback/tasks/workflow.md @@ -0,0 +1,20 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Confirm persistence backend is reachable. +- [ ] Determine requested action (snapshot/diff/restore). + +## Execution + +- [ ] Create snapshot if requested. +- [ ] Show diff if requested. +- [ ] Dry-run restore if requested. +- [ ] Apply restore if confirmed. +- [ ] Update audit log. + +## Post-flight + +- [ ] Report current snapshot name and audit log size. diff --git a/skills/memory-skills/skill-memory-honcho-rollback/templates/output.md b/skills/memory-skills/skill-memory-honcho-rollback/templates/output.md new file mode 100644 index 00000000..f77f13b1 --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho-rollback/templates/output.md @@ -0,0 +1,27 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Snapshot + +```markdown +Snapshot `{name}` created. +Total entries: {n} +``` + +## Diff + +```markdown +## Diff: `{a}` → `{b}` + +Added: {n} +Removed: {n} +Modified: {n} +``` + +## Restore + +```markdown +Restored to `{name}` using `{strategy}`. +Errors: {list or none} +``` diff --git a/skills/memory-skills/skill-memory-honcho-rollback/templates/prompt.md b/skills/memory-skills/skill-memory-honcho-rollback/templates/prompt.md new file mode 100644 index 00000000..fc2e746a --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho-rollback/templates/prompt.md @@ -0,0 +1,14 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## Memory rollback + +```markdown +Action: snapshot|diff|restore +Snapshot A: {name} +Snapshot B: {name} (optional, diff only) +Strategy: merge|exact|patch (restore only) + +Use sin-honcho-rollback. Always dry-run restore first. Append to audit log. +``` diff --git a/skills/memory-skills/skill-memory-honcho/LICENSE b/skills/memory-skills/skill-memory-honcho/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/memory-skills/skill-memory-honcho/SKILL.md b/skills/memory-skills/skill-memory-honcho/SKILL.md new file mode 100644 index 00000000..7a55ef8c --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho/SKILL.md @@ -0,0 +1,48 @@ +--- +name: skill-memory-honcho +description: Behavioral memory layer for opencode agents. Stores conversations, preferences, and peer models across sessions with graceful degradation. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-memory-honcho + +## Overview + +Persist behavioral memory across sessions: conversations, preferences, and peer models. Complementary to SCKG (code structure) and skill-code-ceo-audit (quality). + +## When to Use + +- User says "user preference", "remember that", "remember this", "what did the user say", "user feedback", "behavioral memory", "session context", "across sessions", "persistent memory", "honcho", "peer model". + +## When NOT to Use + +- The information is purely about code structure (use SCKG). +- The user wants to delete everything without confirmation. + +## Core Process + +``` +STORE → RECALL → UPDATE → DECAY +``` + +1. Store conversations, preferences, and peer models. +2. Recall relevant context when needed. +3. Update entries based on new feedback. +4. Let old entries decay naturally. + +## Graceful Degradation + +If the Honcho server is unreachable, continue without crashing. Log the failure. + +## Verification + +- [ ] Memory is stored. +- [ ] Recall returns relevant context. +- [ ] Updates do not overwrite without intent. +- [ ] Server unreachable degrades gracefully. diff --git a/skills/memory-skills/skill-memory-honcho/context/triggers.md b/skills/memory-skills/skill-memory-honcho/context/triggers.md new file mode 100644 index 00000000..1ad3ecf0 --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho/context/triggers.md @@ -0,0 +1,20 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "user preference", "remember that", "remember this", "what did the user say", "user feedback", "behavioral memory", "session context", "across sessions", "persistent memory", "honcho", "peer model" + +## Boundaries + +- **In scope:** Conversations, preferences, peer models. +- **Out of scope:** Code structure (SCKG), code quality (ceo-audit). + +## Required Input + +Memory item or recall query. + +## Tone + +Personalized, respectful of privacy. diff --git a/skills/memory-skills/skill-memory-honcho/frameworks/standards.md b/skills/memory-skills/skill-memory-honcho/frameworks/standards.md new file mode 100644 index 00000000..d7c43e27 --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho/frameworks/standards.md @@ -0,0 +1,15 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Memory Types + +- Conversations +- Preferences +- Peer models + +## Constraints + +- Do not store secrets. +- Degrade gracefully when server is unreachable. +- Respect user privacy. diff --git a/skills/memory-skills/skill-memory-honcho/tasks/workflow.md b/skills/memory-skills/skill-memory-honcho/tasks/workflow.md new file mode 100644 index 00000000..7a407a05 --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho/tasks/workflow.md @@ -0,0 +1,18 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Determine action: store or recall. + +## Execution + +- [ ] Store new memory item. +- [ ] Or recall relevant memory. +- [ ] Update if needed. +- [ ] Verify persistence. + +## Post-flight + +- [ ] Report memory operation result. diff --git a/skills/memory-skills/skill-memory-honcho/templates/output.md b/skills/memory-skills/skill-memory-honcho/templates/output.md new file mode 100644 index 00000000..3720c81d --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho/templates/output.md @@ -0,0 +1,15 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Memory Operation + +```markdown +## sin-honcho + +Action: store|recall|update +Status: success|degraded + +Stored/Recalled: +- ... +``` diff --git a/skills/memory-skills/skill-memory-honcho/templates/prompt.md b/skills/memory-skills/skill-memory-honcho/templates/prompt.md new file mode 100644 index 00000000..8d155e51 --- /dev/null +++ b/skills/memory-skills/skill-memory-honcho/templates/prompt.md @@ -0,0 +1,12 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## Remember or recall + +```markdown +Action: store|recall +Content: {memory item or query} + +Use sin-honcho. Degrade gracefully if server is unreachable. +``` diff --git a/skills/memory-skills/skill-memory-infisical/LICENSE b/skills/memory-skills/skill-memory-infisical/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/memory-skills/skill-memory-infisical/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/memory-skills/skill-memory-infisical/SKILL.md b/skills/memory-skills/skill-memory-infisical/SKILL.md new file mode 100644 index 00000000..569b86df --- /dev/null +++ b/skills/memory-skills/skill-memory-infisical/SKILL.md @@ -0,0 +1,51 @@ +--- +name: skill-memory-infisical +description: Centralized secret management via Infisical CLI. Stores API keys, tokens, and credentials without .env files or shell history. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-memory-infisical + +## Overview + +Manage secrets via Infisical. Store, retrieve, and rotate API keys and credentials without putting them in repos or shell history. + +## When to Use + +- User says "secret", "api key", "api-key", "password", "credentials", "infisical", "store secret", "retrieve secret", "rotate token", "secrets manager", "env var", "set api key", "list keys", "get key", "token storage". + +## When NOT to Use + +- The project uses a different secret manager. +- The user wants secrets in plain text. + +## Core Process + +``` +IDENTIFY SECRET → ENCRYPT → STORE → RETRIEVE/ROTATE +``` + +1. Identify the secret and its purpose. +2. Store it in Infisical under the correct project and environment. +3. Retrieve it when needed (never log the value). +4. Rotate if exposed or on schedule. + +## Security Rules + +- No .env files in repos. +- No tokens in shell history. +- No values in CI logs. +- Rotate any exposed token immediately. + +## Verification + +- [ ] Secret stored in Infisical. +- [ ] Value never logged in plain text. +- [ ] Rotation performed if exposed. +- [ ] Degraded mode handled gracefully. diff --git a/skills/memory-skills/skill-memory-infisical/context/triggers.md b/skills/memory-skills/skill-memory-infisical/context/triggers.md new file mode 100644 index 00000000..a64598fe --- /dev/null +++ b/skills/memory-skills/skill-memory-infisical/context/triggers.md @@ -0,0 +1,20 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "secret", "api key", "api-key", "password", "credentials", "infisical", "store secret", "retrieve secret", "rotate token", "secrets manager", "env var", "set api key", "list keys", "get key", "token storage" + +## Boundaries + +- **In scope:** Infisical-backed secret operations. +- **Out of scope:** Plain-text secrets. + +## Required Input + +Secret name and purpose. + +## Tone + +Security-first, cautious. diff --git a/skills/memory-skills/skill-memory-infisical/frameworks/standards.md b/skills/memory-skills/skill-memory-infisical/frameworks/standards.md new file mode 100644 index 00000000..a81eb322 --- /dev/null +++ b/skills/memory-skills/skill-memory-infisical/frameworks/standards.md @@ -0,0 +1,15 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Security Rules + +- No .env files in repos. +- No tokens in shell history. +- No secret values in CI logs. +- Rotate exposed tokens. + +## Constraints + +- Use `infisical` CLI. +- Degrade gracefully if Infisical is unreachable. diff --git a/skills/memory-skills/skill-memory-infisical/tasks/workflow.md b/skills/memory-skills/skill-memory-infisical/tasks/workflow.md new file mode 100644 index 00000000..d19af964 --- /dev/null +++ b/skills/memory-skills/skill-memory-infisical/tasks/workflow.md @@ -0,0 +1,19 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Confirm project and environment. +- [ ] Confirm user has access. + +## Execution + +- [ ] Store secret (if not present). +- [ ] Retrieve secret (never log value). +- [ ] Rotate if requested or exposed. +- [ ] List keys if requested. + +## Post-flight + +- [ ] Report operation and redacted value fingerprint. diff --git a/skills/memory-skills/skill-memory-infisical/templates/output.md b/skills/memory-skills/skill-memory-infisical/templates/output.md new file mode 100644 index 00000000..19a20325 --- /dev/null +++ b/skills/memory-skills/skill-memory-infisical/templates/output.md @@ -0,0 +1,17 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Secret Operation + +```markdown +## Infisical Secret + +Action: store|retrieve|rotate|list +Key: {name} +Project: {project} +Environment: {env} +Status: success|degraded + +Value: [REDACTED] +``` diff --git a/skills/memory-skills/skill-memory-infisical/templates/prompt.md b/skills/memory-skills/skill-memory-infisical/templates/prompt.md new file mode 100644 index 00000000..680bc5dc --- /dev/null +++ b/skills/memory-skills/skill-memory-infisical/templates/prompt.md @@ -0,0 +1,14 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## Secret operation + +```markdown +Action: store|retrieve|rotate|list +Key: {name} +Project: {project} +Environment: {env} + +Use sin-infisical. Never log the secret value. Rotate if exposed. +``` diff --git a/skills/planning-skills/skill-planning-enterprise/LICENSE b/skills/planning-skills/skill-planning-enterprise/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/planning-skills/skill-planning-enterprise/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/planning-skills/skill-planning-enterprise/SKILL.md b/skills/planning-skills/skill-planning-enterprise/SKILL.md new file mode 100644 index 00000000..bf496c71 --- /dev/null +++ b/skills/planning-skills/skill-planning-enterprise/SKILL.md @@ -0,0 +1,62 @@ +--- +name: skill-planning-enterprise +description: Agent-first enterprise planning skill with a strict JSON CLI, deterministic validation, idempotent execution, and governance-aware rollback. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 2.1.0 +lifecycle: external +sources: + - https://github.com/SIN-Skills/plan +--- + +# /plan v2.1 — Agent-first enterprise planning + +## Contract +- Use `opencode-plan`; JSON is the machine contract. +- Never guess. Validate before execute. +- Exit codes are authoritative: `0` ok, `1` validation, `2` execution, `3` approval, `4` drift, `5` unknown. +- Keep execution state under `.plan/` and make execution idempotent. +- Keep the skill docs and the packaged runtime aligned. + +## Commands +- `opencode-plan init --template <enterprise|agile|compliance> --out plan.json` +- `opencode-plan validate plan.json --strict --auto-fix` +- `opencode-plan simulate plan.json --iterations 10000` +- `opencode-plan execute plan.json --mode plan-only|plan-and-execute|continuous` +- `opencode-plan audit plan.json --format json|sarif|markdown` +- `opencode-plan rollback plan.json --to latest --dry-run` +- `opencode-plan schema --target plan|response` + +## Plan model +Every plan should include: +- outcomes / OKRs +- current state analysis +- decisions and assumptions +- phases with concrete tasks +- explicit dependency graph +- risk register and rollback plan +- done criteria and approvals +- metrics and learning fields + +## Workflow +1. Check for an approved plan. +2. Validate the plan and repair trivial dependency cycles. +3. Simulate risk and duration. +4. Approve or request approval. +5. Execute one bounded step at a time. +6. Audit each state change. +7. Roll back on drift. +8. Learn from actual vs planned duration. + +## Governance +- Approval gates must be explicit. +- Audit records must be machine-readable. +- Drift must pause execution. +- Rollback must be dry-runable before apply. + +## Legacy note +The old stage-heavy design is still valid as the planning policy, but the runtime is now the packaged CLI under `src/plan_cli/`. diff --git a/skills/planning-skills/skill-planning-enterprise/context/triggers.md b/skills/planning-skills/skill-planning-enterprise/context/triggers.md new file mode 100644 index 00000000..283ce544 --- /dev/null +++ b/skills/planning-skills/skill-planning-enterprise/context/triggers.md @@ -0,0 +1,8 @@ +# Triggers + +When the user mentions: +- "plan", "/plan", "enterprise plan", "plan-and-execute" +- "OKR", "outcomes", "phases", "dependency graph" +- "risk register", "rollback plan", "drift" + +Use this skill context. diff --git a/skills/planning-skills/skill-planning-enterprise/frameworks/standards.md b/skills/planning-skills/skill-planning-enterprise/frameworks/standards.md new file mode 100644 index 00000000..2cff89b8 --- /dev/null +++ b/skills/planning-skills/skill-planning-enterprise/frameworks/standards.md @@ -0,0 +1,5 @@ +# Standards + +- Follow the canonical SIN-Code skill lifecycle. +- Prefer the external canonical `opencode-plan` implementation until a native `sin-code enterprise-plan` command exists. +- Always validate plans before execution. diff --git a/skills/planning-skills/skill-planning-enterprise/tasks/workflow.md b/skills/planning-skills/skill-planning-enterprise/tasks/workflow.md new file mode 100644 index 00000000..ab144317 --- /dev/null +++ b/skills/planning-skills/skill-planning-enterprise/tasks/workflow.md @@ -0,0 +1,7 @@ +# Workflow + +1. Identify whether an approved plan exists. +2. Load or create the plan with the correct template. +3. Validate, simulate, and audit before execution. +4. Execute one bounded step at a time. +5. Roll back on drift or failure. diff --git a/skills/planning-skills/skill-planning-enterprise/templates/output.md b/skills/planning-skills/skill-planning-enterprise/templates/output.md new file mode 100644 index 00000000..f10cef2f --- /dev/null +++ b/skills/planning-skills/skill-planning-enterprise/templates/output.md @@ -0,0 +1,12 @@ +# Output Template + +## Plan Result +- Plan: +- Status: validated / simulated / executed / drifted +- External source: https://github.com/SIN-Skills/plan + +## Summary +- Outcomes: +- Phases: +- Risks: +- Rollback: diff --git a/skills/planning-skills/skill-planning-enterprise/templates/prompt.md b/skills/planning-skills/skill-planning-enterprise/templates/prompt.md new file mode 100644 index 00000000..58d06e27 --- /dev/null +++ b/skills/planning-skills/skill-planning-enterprise/templates/prompt.md @@ -0,0 +1,3 @@ +# Prompt Template + +You are the enterprise-plan skill assistant. Help the user create, validate, simulate, and execute governance-aware plans. If the native SIN-Code command is unavailable, guide them to the `opencode-plan` external implementation. diff --git a/skills/process-skills/skill-process-goal/LICENSE b/skills/process-skills/skill-process-goal/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/process-skills/skill-process-goal/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/process-skills/skill-process-goal/SKILL.md b/skills/process-skills/skill-process-goal/SKILL.md new file mode 100644 index 00000000..e8d01448 --- /dev/null +++ b/skills/process-skills/skill-process-goal/SKILL.md @@ -0,0 +1,56 @@ +--- +name: skill-process-goal +description: Track long-running goals with subtasks, dependencies, checkpoints, and rollback. Use when work spans multiple sessions or subtasks. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-process-goal + +## Overview + +Track complex, multi-step work using a goal with subtasks, dependencies, checkpoints, and rollback. + +## When to Use + +- Work spans multiple sessions or subtasks. +- User asks for a roadmap or phase plan. + +## When NOT to Use + +- The task is a single-step fix. +- No multi-step tracking needed. + +## Core Process + +``` +START GOAL → ADD SUBTASKS → EXECUTE → CHECKPOINT → COMPLETE +``` + +1. Create a named goal with subtasks. +2. Add dependencies between subtasks. +3. Execute subtasks. +4. Create checkpoints before risky steps. +5. Mark subtasks complete. +6. Complete the goal. + +## Subtask States + +- pending +- in_progress +- completed +- blocked +- cancelled + +## Verification + +- [ ] Goal is specific and measurable. +- [ ] Subtasks are actionable. +- [ ] Dependencies are acyclic. +- [ ] Checkpoints created before risky work. +- [ ] Goal is only marked complete when all subtasks are done. diff --git a/skills/process-skills/skill-process-goal/context/triggers.md b/skills/process-skills/skill-process-goal/context/triggers.md new file mode 100644 index 00000000..f2ffa923 --- /dev/null +++ b/skills/process-skills/skill-process-goal/context/triggers.md @@ -0,0 +1,20 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "goal mode", "track this goal", "create a roadmap", "phase plan", "multi-step task", "long-running task" + +## Boundaries + +- **In scope:** Goal/subtask management, checkpoints, rollback. +- **Out of scope:** Executing non-goal work. + +## Required Input + +Goal description and subtasks. + +## Tone + +Organized, progress-oriented. diff --git a/skills/process-skills/skill-process-goal/frameworks/standards.md b/skills/process-skills/skill-process-goal/frameworks/standards.md new file mode 100644 index 00000000..c86fdcce --- /dev/null +++ b/skills/process-skills/skill-process-goal/frameworks/standards.md @@ -0,0 +1,19 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Subtask Rules + +- One in_progress at a time. +- Mark completed only when verified. +- Dependencies must be acyclic. + +## Checkpoints + +- Create before risky or destructive changes. +- Use rollback if checkpoint path is available. + +## Constraints + +- Never mark goal complete prematurely. +- Add blockers as follow-up todos. diff --git a/skills/process-skills/skill-process-goal/tasks/workflow.md b/skills/process-skills/skill-process-goal/tasks/workflow.md new file mode 100644 index 00000000..3aa55feb --- /dev/null +++ b/skills/process-skills/skill-process-goal/tasks/workflow.md @@ -0,0 +1,25 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Define goal and success criteria. +- [ ] List subtasks. + +## Execution + +- [ ] Create goal. +- [ ] Add subtasks. +- [ ] Add dependencies. +- [ ] Pick first unblocked subtask. +- [ ] Mark subtask in_progress. +- [ ] Execute subtask. +- [ ] Mark subtask complete. +- [ ] Repeat until done. +- [ ] Create checkpoint before risky work. +- [ ] Complete goal. + +## Post-flight + +- [ ] Report goal status and blockers. diff --git a/skills/process-skills/skill-process-goal/templates/output.md b/skills/process-skills/skill-process-goal/templates/output.md new file mode 100644 index 00000000..a5b3e3d4 --- /dev/null +++ b/skills/process-skills/skill-process-goal/templates/output.md @@ -0,0 +1,23 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Goal Summary + +```markdown +# Goal: {name} + +## Status +{in_progress/completed/blocked} + +## Subtasks +- [x] Task 1 +- [ ] Task 2 (in_progress) +- [ ] Task 3 (blocked by Task 2) + +## Blockers +- ... + +## Checkpoints +- {name} +``` diff --git a/skills/process-skills/skill-process-goal/templates/prompt.md b/skills/process-skills/skill-process-goal/templates/prompt.md new file mode 100644 index 00000000..0e45dc91 --- /dev/null +++ b/skills/process-skills/skill-process-goal/templates/prompt.md @@ -0,0 +1,16 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## Manage goal + +```markdown +Goal: {description} + +Subtasks: +1. ... +2. ... + +Create a goal in sin-goal-mode. Add dependencies. Track progress. +Mark complete only when verified. +``` diff --git a/skills/process-skills/skill-process-grill/LICENSE b/skills/process-skills/skill-process-grill/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/process-skills/skill-process-grill/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/process-skills/skill-process-grill/SKILL.md b/skills/process-skills/skill-process-grill/SKILL.md new file mode 100644 index 00000000..a1d7f453 --- /dev/null +++ b/skills/process-skills/skill-process-grill/SKILL.md @@ -0,0 +1,52 @@ +--- +name: skill-process-grill +description: Adversarial design-review interview. Relentlessly questions plans to surface hidden assumptions before implementation. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +--- + +# skill-process-grill + +## Overview + +Stress-test a plan, design, or decision before building it. + +## When to Use + +- User says "grill me", "stress test this plan", "interrogate my design", "poke holes in my idea", "challenge my approach". + +## When NOT to Use + +- The user has already decided and only wants implementation. +- There is no plan or decision to challenge. + +## Core Process + +``` +LISTEN → CHALLENGE → FOLLOW UP → SYNTHESIZE +``` + +1. Listen to the user's plan. +2. Ask adversarial questions that surface assumptions, risks, and edge cases. +3. Follow up on evasive or weak answers. +4. Synthesize a decision tree with resolved and unresolved points. + +## Tactics + +- Ask for the worst-case scenario. +- Demand evidence for assumptions. +- Question trade-offs not explicitly stated. +- Test for reversibility. + +## Verification + +- [ ] At least 5 adversarial questions asked. +- [ ] Hidden assumptions surfaced. +- [ ] Risks documented. +- [ ] Decision tree produced. +- [ ] Unresolved points flagged. diff --git a/skills/process-skills/skill-process-grill/context/triggers.md b/skills/process-skills/skill-process-grill/context/triggers.md new file mode 100644 index 00000000..e94667cf --- /dev/null +++ b/skills/process-skills/skill-process-grill/context/triggers.md @@ -0,0 +1,20 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "grill me", "stress test this plan", "interrogate my design", "poke holes in my idea", "challenge my approach" + +## Boundaries + +- **In scope:** Asking adversarial questions about a plan. +- **Out of scope:** Implementing the plan. + +## Required Input + +User's plan, design, or decision. + +## Tone + +Relentless but constructive. Not mean, just skeptical. diff --git a/skills/process-skills/skill-process-grill/frameworks/standards.md b/skills/process-skills/skill-process-grill/frameworks/standards.md new file mode 100644 index 00000000..1b88083c --- /dev/null +++ b/skills/process-skills/skill-process-grill/frameworks/standards.md @@ -0,0 +1,16 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Tactics + +- Worst-case questions. +- Evidence demands. +- Trade-off probing. +- Reversibility tests. + +## Constraints + +- Stay respectful. +- Do not answer for the user. +- Record resolutions and open questions. diff --git a/skills/process-skills/skill-process-grill/tasks/workflow.md b/skills/process-skills/skill-process-grill/tasks/workflow.md new file mode 100644 index 00000000..6d9fa33f --- /dev/null +++ b/skills/process-skills/skill-process-grill/tasks/workflow.md @@ -0,0 +1,21 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Capture the plan or decision. + +## Execution + +- [ ] Ask clarifying questions. +- [ ] Ask adversarial questions. +- [ ] Follow up on weak answers. +- [ ] Surface hidden assumptions. +- [ ] Document risks and alternatives. +- [ ] Synthesize decision tree. + +## Post-flight + +- [ ] Present unresolved points. +- [ ] Recommend next steps. diff --git a/skills/process-skills/skill-process-grill/templates/output.md b/skills/process-skills/skill-process-grill/templates/output.md new file mode 100644 index 00000000..3d7aba77 --- /dev/null +++ b/skills/process-skills/skill-process-grill/templates/output.md @@ -0,0 +1,29 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Grilled Summary + +```markdown +## Plan Under Review +... + +## Key Assumptions Surfaced +1. ... +2. ... + +## Risks +- High: ... +- Medium: ... +- Low: ... + +## Decision Tree +- If X: ... +- Else: ... + +## Unresolved Questions +- ... + +## Recommendation +... +``` diff --git a/skills/process-skills/skill-process-grill/templates/prompt.md b/skills/process-skills/skill-process-grill/templates/prompt.md new file mode 100644 index 00000000..85de6495 --- /dev/null +++ b/skills/process-skills/skill-process-grill/templates/prompt.md @@ -0,0 +1,15 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## Grill a plan + +```markdown +Plan: +""" +{plan} +""" + +Grill this plan. Surface hidden assumptions, risks, and alternatives. +Ask at least 5 adversarial questions. Synthesize a decision tree. +``` diff --git a/skills/process-skills/skill-process-scheduler/LICENSE b/skills/process-skills/skill-process-scheduler/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/process-skills/skill-process-scheduler/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/process-skills/skill-process-scheduler/SKILL.md b/skills/process-skills/skill-process-scheduler/SKILL.md new file mode 100644 index 00000000..7427683c --- /dev/null +++ b/skills/process-skills/skill-process-scheduler/SKILL.md @@ -0,0 +1,76 @@ +--- +name: skill-process-scheduler +description: Job scheduling with cron expressions and human-readable intervals via MCP server and CLI. Schedule, list, cancel, run, and inspect job execution logs. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: OpenSIN-Code + version: 1.0.0 +--- + +# skill-process-scheduler + +## Overview + +Schedule recurring or one-off jobs using cron expressions or intervals. Manage jobs and inspect execution logs through MCP or CLI. + +## When to Use + +- Schedule a recurring task (cron or interval). +- List, cancel, or trigger scheduled jobs. +- Inspect execution history and logs. +- Run ad-hoc jobs. + +## When NOT to Use + +- One-shot commands that need no persistence (use `sin_execute`). +- Complex workflow orchestration (use `sin-orchestrate`). +- Long-running daemon processes without scheduling semantics. + +## Core Process + +``` +DEFINE JOB → SCHEDULE → MONITOR → RUN/CANCEL → REVIEW LOGS +``` + +1. Define job name, command, and schedule (cron or interval). +2. Schedule the job. +3. Monitor status. +4. Run immediately or cancel as needed. +5. Review execution logs. + +## MCP Tools + +| Tool | Purpose | +|---|---| +| `schedule_job` | Create a scheduled job | +| `schedule_list` | List jobs | +| `schedule_cancel` | Remove a job | +| `schedule_status` | Show job status | +| `schedule_run_now` | Trigger a job immediately | +| `schedule_logs` | Show recent execution logs | + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "I'll just run it manually." | Recurring tasks need persistence and logs. | +| "Cron syntax is too complex." | Intervals like `5m` are human-readable. | +| "Logs are optional." | Logs are essential for debugging scheduled jobs. | + +## Red Flags + +- Jobs without clear timeout. +- Commands that depend on unverified environment. +- No log inspection after failures. +- Jobs scheduled but never monitored. + +## Verification + +- [ ] Job name and command are clear. +- [ ] Schedule is valid (cron or interval). +- [ ] Timeout set appropriately. +- [ ] Job listed after creation. +- [ ] Logs inspected after first run. diff --git a/skills/process-skills/skill-process-scheduler/context/triggers.md b/skills/process-skills/skill-process-scheduler/context/triggers.md new file mode 100644 index 00000000..f0ac8ead --- /dev/null +++ b/skills/process-skills/skill-process-scheduler/context/triggers.md @@ -0,0 +1,27 @@ +# Context: Triggers & Boundaries + +Docs: ../SKILL.md + +## Trigger Phrases + +- "schedule a job" +- "cron job" +- "interval job" +- "run every 5 minutes" +- "list scheduled jobs" +- "cancel job" +- "job logs" +- "run now" + +## Boundaries + +- **In scope:** Scheduled jobs with cron or intervals, status, logs. +- **Out of scope:** One-shot commands, complex orchestration, long-running daemons. + +## Required Input + +Job name, command, schedule type, schedule expression. + +## Tone + +Automation-aware, log-driven, reliable. diff --git a/skills/process-skills/skill-process-scheduler/frameworks/standards.md b/skills/process-skills/skill-process-scheduler/frameworks/standards.md new file mode 100644 index 00000000..890224f6 --- /dev/null +++ b/skills/process-skills/skill-process-scheduler/frameworks/standards.md @@ -0,0 +1,28 @@ +# Frameworks: Standards & Constraints + +Docs: ../SKILL.md + +## Technology Stack + +- `sin-scheduler` MCP server / CLI. +- SQLite database at `~/.sin_scheduler/scheduler.db`. +- `schedule` and `croniter` libraries. + +## Standards + +- Every job has a clear name and timeout. +- Cron expressions must be valid. +- Interval expressions must be human-readable (e.g., `5m`, `1h`). +- Logs inspected after failures. + +## Constraints + +- Daemon runs in background thread. +- Logs retained for 30 days by default. +- Jobs persisted across restarts. + +## Quality Gates + +- Job created and listed. +- First execution logged. +- Logs reviewed for errors. diff --git a/skills/process-skills/skill-process-scheduler/tasks/workflow.md b/skills/process-skills/skill-process-scheduler/tasks/workflow.md new file mode 100644 index 00000000..daae51f3 --- /dev/null +++ b/skills/process-skills/skill-process-scheduler/tasks/workflow.md @@ -0,0 +1,33 @@ +# Tasks: Workflow + +Docs: ../SKILL.md + +## Pre-flight + +- [ ] Capture job name, command, and schedule. + +## Execution + +- [ ] Task 1: Define job. + - Acceptance: Name, command, schedule type, expression clear. + - Verify: Input complete. +- [ ] Task 2: Schedule job. + - Acceptance: `schedule_job` returns job ID. + - Verify: Job listed. +- [ ] Task 3: Verify listing. + - Acceptance: Job appears in `schedule_list`. + - Verify: Enabled and scheduled correctly. +- [ ] Task 4: Run or wait. + - Acceptance: Job executes or is triggered with `schedule_run_now`. + - Verify: Execution recorded. +- [ ] Task 5: Review logs. + - Acceptance: `schedule_logs` shows output and exit code. + - Verify: No unexpected errors. +- [ ] Task 6: Cancel if needed. + - Acceptance: `schedule_cancel` removes job. + - Verify: Job no longer listed. + +## Post-flight + +- [ ] Summarize scheduled jobs and their status. +- [ ] Note any failures or warnings. diff --git a/skills/process-skills/skill-process-scheduler/templates/output.md b/skills/process-skills/skill-process-scheduler/templates/output.md new file mode 100644 index 00000000..142395e2 --- /dev/null +++ b/skills/process-skills/skill-process-scheduler/templates/output.md @@ -0,0 +1,24 @@ +# Template: Output Format + +Docs: ../SKILL.md + +## Scheduler Report + +```markdown +# Scheduled Jobs + +## Jobs +| ID | Name | Schedule | Status | Next Run | +|---|---|---|---|---| +| ... | ... | ... | ... | ... | + +## Recent Logs +| ID | Time | Status | Output | +|---|---|---|---| +| ... | ... | ... | ... | + +## Actions +- Created: ... +- Cancelled: ... +- Run now: ... +``` diff --git a/skills/process-skills/skill-process-scheduler/templates/prompt.md b/skills/process-skills/skill-process-scheduler/templates/prompt.md new file mode 100644 index 00000000..9832c403 --- /dev/null +++ b/skills/process-skills/skill-process-scheduler/templates/prompt.md @@ -0,0 +1,24 @@ +# Template: Prompt Snippet + +Docs: ../SKILL.md + +## User wants to schedule a job + +```markdown +You are scheduling a job with SIN-Scheduler. + +Name: {name} +Command: {command} +Schedule type: {cron | interval} +Schedule expression: {expression, e.g. "0 2 * * *" or "5m"} +Timeout: {seconds} + +Constraints: +- Use valid cron or interval expression. +- Set a timeout. +- Verify job appears in list. +- Review logs after first run. +- Cancel if no longer needed. + +Follow tasks/workflow.md. +``` diff --git a/skills/safe-refactor.md b/skills/safe-refactor.md deleted file mode 100644 index e1d60206..00000000 --- a/skills/safe-refactor.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: safe-refactor -description: Refactor a symbol with full SIN impact analysis and Oracle verification. -arguments: - - name: symbol - description: Fully-qualified symbol to refactor (e.g. module.Class.method) - required: true ---- - -You are performing a SAFE REFACTOR of `{{symbol}}` using the SIN-Code tools. -Follow this loop exactly and do not skip a step. - -1. Call `impact("{{symbol}}")`. Read the callers, fan_in, and risk. - - If `touches_public_api` is true or risk is "high", state the blast radius - back to the user and plan accordingly. -2. Make the smallest refactor that satisfies the goal. Do not change behavior. -3. For each edited file, call `semantic_diff(before, after)`. - - If any diff reports more than one intent, split the change. -4. Call `architectural_debt()`. If the score regressed, simplify before moving on. -5. Call `verify_tests(...)` (and `prove(...)` for critical pure functions). -6. Do NOT report done until the Oracle verdict is `pass`. - -Report: the blast radius, the intents from each semantic_diff, the debt delta, -and the final Oracle verdict. diff --git a/skills/shop-skills/skill-shop-cj-dropshipping/LICENSE b/skills/shop-skills/skill-shop-cj-dropshipping/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/shop-skills/skill-shop-cj-dropshipping/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/shop-skills/skill-shop-cj-dropshipping/SKILL.md b/skills/shop-skills/skill-shop-cj-dropshipping/SKILL.md new file mode 100644 index 00000000..5ae67738 --- /dev/null +++ b/skills/shop-skills/skill-shop-cj-dropshipping/SKILL.md @@ -0,0 +1,51 @@ +--- +name: skill-shop-cj-dropshipping +description: CJ Dropshipping API skill for SIN-Code agents — product search, import, sync, orders, freight, reviews, and supplier orchestration. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +lifecycle: external +sources: + - https://github.com/OpenSIN-Code/cj-dropshipping-skill + - https://github.com/SIN-Shop-Center/SIN-CJDropshipping-Bundle +--- + +# skill-shop-cj-dropshipping + +## Overview + +Interface with CJ Dropshipping for product sourcing, order fulfillment, and freight calculation in the SIN webshop fleet. + +## When to Use + +- Searching or importing products from CJ Dropshipping. +- Syncing orders, inventory, or tracking information. +- Calculating freight costs for cross-border shipments. + +## When NOT to Use + +- The shop does not use CJ Dropshipping as a supplier. +- A native SIN-Code `sin-code cj` command is available (future). + +## Core Process + +``` +SEARCH → IMPORT → SYNC → ORDER → TRACK +``` + +1. Search products by keyword or category. +2. Import selected products into the local shop catalog. +3. Sync inventory and pricing. +4. Push customer orders to CJ Dropshipping. +5. Track fulfillment and freight status. + +## Verification + +- [ ] Product data is validated before import. +- [ ] Order payloads match CJ Dropshipping API schema. +- [ ] Freight estimates are requested for the correct destination. +- [ ] Sync errors are logged and retried. diff --git a/skills/shop-skills/skill-shop-cj-dropshipping/context/triggers.md b/skills/shop-skills/skill-shop-cj-dropshipping/context/triggers.md new file mode 100644 index 00000000..925481e6 --- /dev/null +++ b/skills/shop-skills/skill-shop-cj-dropshipping/context/triggers.md @@ -0,0 +1,10 @@ +# Triggers + +When the user mentions: +- "cj-dropshipping" +- "CJ Dropshipping" (cj-dropshipping) +- "Stripe" (stripe) +- "TikTok Shop" (tiktok-shop) +- product sourcing, payment, shop automation + +Use this skill context. diff --git a/skills/shop-skills/skill-shop-cj-dropshipping/frameworks/standards.md b/skills/shop-skills/skill-shop-cj-dropshipping/frameworks/standards.md new file mode 100644 index 00000000..714e696e --- /dev/null +++ b/skills/shop-skills/skill-shop-cj-dropshipping/frameworks/standards.md @@ -0,0 +1,5 @@ +# Standards + +- Follow the canonical SIN-Code skill lifecycle. +- Prefer the external canonical implementation until a native `sin-code cj-dropshipping` command exists. +- Always link back to the source repositories in responses. diff --git a/skills/shop-skills/skill-shop-cj-dropshipping/tasks/workflow.md b/skills/shop-skills/skill-shop-cj-dropshipping/tasks/workflow.md new file mode 100644 index 00000000..da85a814 --- /dev/null +++ b/skills/shop-skills/skill-shop-cj-dropshipping/tasks/workflow.md @@ -0,0 +1,6 @@ +# Workflow + +1. Identify the shop operation requested. +2. Check whether a native `sin-code cj-dropshipping` command is available. +3. If not, invoke the external canonical skill or source repo. +4. Record decisions and assumptions for traceability. diff --git a/skills/shop-skills/skill-shop-cj-dropshipping/templates/output.md b/skills/shop-skills/skill-shop-cj-dropshipping/templates/output.md new file mode 100644 index 00000000..9d0b619a --- /dev/null +++ b/skills/shop-skills/skill-shop-cj-dropshipping/templates/output.md @@ -0,0 +1,9 @@ +# Output Template + +## Result +- Operation: +- Status: success / error / needs review +- External source: + +## Notes +- diff --git a/skills/shop-skills/skill-shop-cj-dropshipping/templates/prompt.md b/skills/shop-skills/skill-shop-cj-dropshipping/templates/prompt.md new file mode 100644 index 00000000..0924c005 --- /dev/null +++ b/skills/shop-skills/skill-shop-cj-dropshipping/templates/prompt.md @@ -0,0 +1,3 @@ +# Prompt Template + +You are the cj-dropshipping skill assistant. Help the user with cj-dropshipping operations in the SIN webshop fleet. If the native SIN-Code command is unavailable, guide them to the external canonical implementation. diff --git a/skills/shop-skills/skill-shop-stripe/LICENSE b/skills/shop-skills/skill-shop-stripe/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/shop-skills/skill-shop-stripe/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/shop-skills/skill-shop-stripe/SKILL.md b/skills/shop-skills/skill-shop-stripe/SKILL.md new file mode 100644 index 00000000..d691e194 --- /dev/null +++ b/skills/shop-skills/skill-shop-stripe/SKILL.md @@ -0,0 +1,50 @@ +--- +name: skill-shop-stripe +description: Stripe payment and payout automation skill for SIN-Code agents — checkout, webhooks, payment links, instant payouts, and subscription management. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +lifecycle: external +sources: + - https://github.com/SIN-Shop-Center/SIN-Stripe-Bundle +--- + +# skill-shop-stripe + +## Overview + +Automate Stripe operations for the SIN webshop fleet: payment links, checkout sessions, webhooks, instant payouts, and reconciliation. + +## When to Use + +- Creating payment links or checkout sessions. +- Processing instant payouts. +- Handling webhook events and reconciliation. + +## When NOT to Use + +- The project does not use Stripe as a payment provider. +- A native SIN-Code `sin-code skill-shop-stripe` command is available (future). + +## Core Process + +``` +SETUP → CREATE LINK → PAY → HANDLE WEBHOOK → PAYOUT +``` + +1. Configure Stripe API keys and webhook endpoints. +2. Create payment links or checkout sessions. +3. Process customer payments. +4. Handle and verify webhook events. +5. Trigger instant payouts where configured. + +## Verification + +- [ ] Stripe API keys are stored securely. +- [ ] Webhook signatures are verified. +- [ ] Payouts are only triggered for eligible balances. +- [ ] Failed payments are logged and retried. diff --git a/skills/shop-skills/skill-shop-stripe/context/triggers.md b/skills/shop-skills/skill-shop-stripe/context/triggers.md new file mode 100644 index 00000000..f1e240f4 --- /dev/null +++ b/skills/shop-skills/skill-shop-stripe/context/triggers.md @@ -0,0 +1,10 @@ +# Triggers + +When the user mentions: +- "stripe" +- "CJ Dropshipping" (cj-dropshipping) +- "Stripe" (stripe) +- "TikTok Shop" (tiktok-shop) +- product sourcing, payment, shop automation + +Use this skill context. diff --git a/skills/shop-skills/skill-shop-stripe/frameworks/standards.md b/skills/shop-skills/skill-shop-stripe/frameworks/standards.md new file mode 100644 index 00000000..7bbe8c14 --- /dev/null +++ b/skills/shop-skills/skill-shop-stripe/frameworks/standards.md @@ -0,0 +1,5 @@ +# Standards + +- Follow the canonical SIN-Code skill lifecycle. +- Prefer the external canonical implementation until a native `sin-code stripe` command exists. +- Always link back to the source repositories in responses. diff --git a/skills/shop-skills/skill-shop-stripe/tasks/workflow.md b/skills/shop-skills/skill-shop-stripe/tasks/workflow.md new file mode 100644 index 00000000..d4fe646b --- /dev/null +++ b/skills/shop-skills/skill-shop-stripe/tasks/workflow.md @@ -0,0 +1,6 @@ +# Workflow + +1. Identify the shop operation requested. +2. Check whether a native `sin-code stripe` command is available. +3. If not, invoke the external canonical skill or source repo. +4. Record decisions and assumptions for traceability. diff --git a/skills/shop-skills/skill-shop-stripe/templates/output.md b/skills/shop-skills/skill-shop-stripe/templates/output.md new file mode 100644 index 00000000..9d0b619a --- /dev/null +++ b/skills/shop-skills/skill-shop-stripe/templates/output.md @@ -0,0 +1,9 @@ +# Output Template + +## Result +- Operation: +- Status: success / error / needs review +- External source: + +## Notes +- diff --git a/skills/shop-skills/skill-shop-stripe/templates/prompt.md b/skills/shop-skills/skill-shop-stripe/templates/prompt.md new file mode 100644 index 00000000..a6f52d89 --- /dev/null +++ b/skills/shop-skills/skill-shop-stripe/templates/prompt.md @@ -0,0 +1,3 @@ +# Prompt Template + +You are the stripe skill assistant. Help the user with stripe operations in the SIN webshop fleet. If the native SIN-Code command is unavailable, guide them to the external canonical implementation. diff --git a/skills/shop-skills/skill-shop-tiktok/LICENSE b/skills/shop-skills/skill-shop-tiktok/LICENSE new file mode 100644 index 00000000..e96816ed --- /dev/null +++ b/skills/shop-skills/skill-shop-tiktok/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenSIN-Code + +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/skills/shop-skills/skill-shop-tiktok/SKILL.md b/skills/shop-skills/skill-shop-tiktok/SKILL.md new file mode 100644 index 00000000..56c6d407 --- /dev/null +++ b/skills/shop-skills/skill-shop-tiktok/SKILL.md @@ -0,0 +1,50 @@ +--- +name: skill-shop-tiktok +description: TikTok Shop automation and scraper skill for SIN-Code agents — product discovery, listing sync, order tracking, and trend analytics. +license: MIT +compatibility: + - opencode + - sin-code +metadata: + author: SIN-Code + version: 1.0.0 +lifecycle: external +sources: + - https://github.com/SIN-Shop-Center/SIN-eCommerce-Scraper-Bundle +--- + +# skill-shop-tiktok + +## Overview + +Automate TikTok Shop operations: product discovery, listing synchronization, order tracking, and trend-based commerce decisions. + +## When to Use + +- Scraping or discovering TikTok Shop products. +- Syncing TikTok Shop listings with the local catalog. +- Tracking orders and trend metrics from TikTok Shop. + +## When NOT to Use + +- The shop does not operate on TikTok Shop. +- A native SIN-Code `sin-code skill-shop-tiktok` command is available (future). + +## Core Process + +``` +DISCOVER → SCRAPE → SYNC → LIST → TRACK +``` + +1. Discover trending products and categories. +2. Scrape product details and pricing. +3. Sync scraped data into the local shop catalog. +4. Create or update TikTok Shop listings. +5. Track orders and trend performance. + +## Verification + +- [ ] Scraping respects rate limits and terms of service. +- [ ] Product data is validated before sync. +- [ ] Listing updates are idempotent. +- [ ] Trend metrics are timestamped and reproducible. diff --git a/skills/shop-skills/skill-shop-tiktok/context/triggers.md b/skills/shop-skills/skill-shop-tiktok/context/triggers.md new file mode 100644 index 00000000..c5210bae --- /dev/null +++ b/skills/shop-skills/skill-shop-tiktok/context/triggers.md @@ -0,0 +1,10 @@ +# Triggers + +When the user mentions: +- "tiktok-shop" +- "CJ Dropshipping" (cj-dropshipping) +- "Stripe" (stripe) +- "TikTok Shop" (tiktok-shop) +- product sourcing, payment, shop automation + +Use this skill context. diff --git a/skills/shop-skills/skill-shop-tiktok/frameworks/standards.md b/skills/shop-skills/skill-shop-tiktok/frameworks/standards.md new file mode 100644 index 00000000..894b672b --- /dev/null +++ b/skills/shop-skills/skill-shop-tiktok/frameworks/standards.md @@ -0,0 +1,5 @@ +# Standards + +- Follow the canonical SIN-Code skill lifecycle. +- Prefer the external canonical implementation until a native `sin-code tiktok-shop` command exists. +- Always link back to the source repositories in responses. diff --git a/skills/shop-skills/skill-shop-tiktok/tasks/workflow.md b/skills/shop-skills/skill-shop-tiktok/tasks/workflow.md new file mode 100644 index 00000000..75d12850 --- /dev/null +++ b/skills/shop-skills/skill-shop-tiktok/tasks/workflow.md @@ -0,0 +1,6 @@ +# Workflow + +1. Identify the shop operation requested. +2. Check whether a native `sin-code tiktok-shop` command is available. +3. If not, invoke the external canonical skill or source repo. +4. Record decisions and assumptions for traceability. diff --git a/skills/shop-skills/skill-shop-tiktok/templates/output.md b/skills/shop-skills/skill-shop-tiktok/templates/output.md new file mode 100644 index 00000000..9d0b619a --- /dev/null +++ b/skills/shop-skills/skill-shop-tiktok/templates/output.md @@ -0,0 +1,9 @@ +# Output Template + +## Result +- Operation: +- Status: success / error / needs review +- External source: + +## Notes +- diff --git a/skills/shop-skills/skill-shop-tiktok/templates/prompt.md b/skills/shop-skills/skill-shop-tiktok/templates/prompt.md new file mode 100644 index 00000000..ae0ddcac --- /dev/null +++ b/skills/shop-skills/skill-shop-tiktok/templates/prompt.md @@ -0,0 +1,3 @@ +# Prompt Template + +You are the tiktok-shop skill assistant. Help the user with tiktok-shop operations in the SIN webshop fleet. If the native SIN-Code command is unavailable, guide them to the external canonical implementation. diff --git a/skills/sin-codocs/README.md b/skills/sin-codocs/README.md deleted file mode 100644 index 979a5af4..00000000 --- a/skills/sin-codocs/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# sin-codocs skill - -The canonical CoDocs agent skill ships **inside the package** at -[`src/sin_code_bundle/data/codocs/SKILL.md`](../../src/sin_code_bundle/data/codocs/SKILL.md) -so it is available from both editable and wheel installs. - -## Install into your agent - -```bash -sin codocs install-skill # Hermes + OpenCode -sin codocs install-skill --agent hermes # Hermes only -sin codocs install-skill --agent opencode -``` - -This copies `SKILL.md` to: - -| Agent | Path | -|----------|------| -| Hermes | `~/.hermes/skills/sin-codocs/SKILL.md` | -| OpenCode | `~/.config/opencode/skills/sin-codocs/SKILL.md` | - -See [docs/CODOCS.md](../../docs/CODOCS.md) for the full standard. diff --git a/src/sin_code_bundle/agent_engine/__init__.py b/src/sin_code_bundle/agent_engine/__init__.py index 59707163..de4c4753 100644 --- a/src/sin_code_bundle/agent_engine/__init__.py +++ b/src/sin_code_bundle/agent_engine/__init__.py @@ -11,6 +11,30 @@ AgentTask, Plan, Step, StepResult, StepState, Verdict, VerdictKind """ +from .builtin_tools import register_builtin_tools +from .delegate import ( + AdaptiveBudgetAllocator, + DelegationCache, + DelegationContext, + DelegationSupervisor, + make_delegate_tool, + validate_result, +) +from .distiller import ( + KnowledgeDistiller, + StandingRule, + _heuristic_rule, # noqa: F401 +) +from .distiller import ( + _signature as _rule_signature, # noqa: F401 +) +from .executor import Executor +from .loop import AgentLoop +from .memory_bridge import MemoryBridge +from .planner import Planner +from .router import CircuitOpenError, ToolRouter +from .telemetry import Telemetry +from .tracing import Span, SpanEmitter, TraceAssembler, TraceContext from .types import ( AgentTask, Plan, @@ -20,23 +44,7 @@ Verdict, VerdictKind, ) -from .planner import Planner -from .router import ToolRouter, CircuitOpenError -from .executor import Executor from .verifier import Verifier -from .telemetry import Telemetry -from .memory_bridge import MemoryBridge -from .builtin_tools import register_builtin_tools -from .loop import AgentLoop -from .tracing import TraceContext, SpanEmitter, Span, TraceAssembler -from .distiller import ( - KnowledgeDistiller, StandingRule, _signature as _rule_signature, - _heuristic_rule, -) -from .delegate import ( - DelegationContext, AdaptiveBudgetAllocator, DelegationCache, - DelegationSupervisor, make_delegate_tool, validate_result, -) __all__ = [ "AgentTask", diff --git a/src/sin_code_bundle/agent_engine/builtin_tools.py b/src/sin_code_bundle/agent_engine/builtin_tools.py index 18001206..0a82390b 100644 --- a/src/sin_code_bundle/agent_engine/builtin_tools.py +++ b/src/sin_code_bundle/agent_engine/builtin_tools.py @@ -26,7 +26,8 @@ def _redact(text: str) -> str: async def tool_bash(*, cmd: str, cwd: str, timeout_s: float = 300.0) -> dict[str, Any]: proc = await asyncio.create_subprocess_shell( - cmd, cwd=cwd, + cmd, + cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, @@ -48,20 +49,17 @@ async def tool_bash(*, cmd: str, cwd: str, timeout_s: float = 300.0) -> dict[str return result -async def tool_read(*, path: str, cwd: str, - start: int = 1, limit: int = 400) -> dict[str, Any]: +async def tool_read(*, path: str, cwd: str, start: int = 1, limit: int = 400) -> dict[str, Any]: p = (Path(cwd) / path).resolve() if not str(p).startswith(str(Path(cwd).resolve())): raise PermissionError(f"path escapes workspace: {path}") lines = p.read_text(encoding="utf-8", errors="replace").splitlines() - window = lines[start - 1: start - 1 + limit] + window = lines[start - 1 : start - 1 + limit] return { "path": str(p), "total_lines": len(lines), "start": start, - "content": "\n".join( - f"{i + start}\t{line}" for i, line in enumerate(window) - ), + "content": "\n".join(f"{i + start}\t{line}" for i, line in enumerate(window)), } @@ -82,15 +80,15 @@ async def tool_edit(*, path: str, old: str, new: str, cwd: str) -> dict[str, Any raise ValueError(f"anchor not found in {path}") if count > 1: raise ValueError( - f"anchor ambiguous in {path} ({count} matches) — " - "provide more surrounding context" + f"anchor ambiguous in {path} ({count} matches) — provide more surrounding context" ) p.write_text(text.replace(old, new, 1), encoding="utf-8") return {"path": str(p), "replaced": 1} -async def tool_search(*, pattern: str, cwd: str, - glob: str = "**/*.py", limit: int = 50) -> dict[str, Any]: +async def tool_search( + *, pattern: str, cwd: str, glob: str = "**/*.py", limit: int = 50 +) -> dict[str, Any]: rx = re.compile(pattern) hits: list[dict[str, Any]] = [] root = Path(cwd) @@ -102,8 +100,9 @@ async def tool_search(*, pattern: str, cwd: str, f.read_text(encoding="utf-8", errors="replace").splitlines(), 1 ): if rx.search(line): - hits.append({"file": str(f.relative_to(root)), - "line": i, "text": line.strip()[:200]}) + hits.append( + {"file": str(f.relative_to(root)), "line": i, "text": line.strip()[:200]} + ) if len(hits) >= limit: return {"hits": hits, "truncated": True} except OSError: diff --git a/src/sin_code_bundle/agent_engine/checkpoint.py b/src/sin_code_bundle/agent_engine/checkpoint.py index c631b4ac..f08ea783 100644 --- a/src/sin_code_bundle/agent_engine/checkpoint.py +++ b/src/sin_code_bundle/agent_engine/checkpoint.py @@ -19,13 +19,17 @@ def _tree_hash(repo_root: str) -> str: return "" stash = subprocess.run( ["git", "-C", repo_root, "stash", "create"], - capture_output=True, text=True, timeout=60, + capture_output=True, + text=True, + timeout=60, ).stdout.strip() if stash: return stash head = subprocess.run( ["git", "-C", repo_root, "rev-parse", "HEAD^{tree}"], - capture_output=True, text=True, timeout=60, + capture_output=True, + text=True, + timeout=60, ).stdout.strip() return head @@ -39,18 +43,19 @@ class ResumeState: class CheckpointStore: - def __init__(self, task_id: str, repo_root: str, - *, base_dir: str | None = None) -> None: + def __init__(self, task_id: str, repo_root: str, *, base_dir: str | None = None) -> None: self.task_id = task_id self.repo_root = repo_root - root = Path(base_dir or os.environ.get("SIN_CHECKPOINT_DIR", "") - or Path.home() / ".sin" / "checkpoints") + root = Path( + base_dir + or os.environ.get("SIN_CHECKPOINT_DIR", "") + or Path.home() / ".sin" / "checkpoints" + ) root.mkdir(parents=True, exist_ok=True) self.path = root / f"{task_id}.jsonl" def record_step(self, step_id: str, state: StepState) -> None: - if state not in (StepState.SUCCEEDED, StepState.FAILED, - StepState.SKIPPED): + if state not in (StepState.SUCCEEDED, StepState.FAILED, StepState.SKIPPED): return record = { "ts": round(time.time(), 3), @@ -65,11 +70,16 @@ def record_step(self, step_id: str, state: StepState) -> None: def record_run_complete(self, outcome: str) -> None: with self.path.open("a", encoding="utf-8") as fh: - fh.write(json.dumps({ - "ts": round(time.time(), 3), - "run_complete": True, - "outcome": outcome, - }) + "\n") + fh.write( + json.dumps( + { + "ts": round(time.time(), 3), + "run_complete": True, + "outcome": outcome, + } + ) + + "\n" + ) fh.flush() os.fsync(fh.fileno()) @@ -87,25 +97,26 @@ def _read_journal(self) -> list[dict[str, Any]]: def load_resume_state(self) -> ResumeState: records = self._read_journal() if not records: - return ResumeState(resumable=False, completed_steps=set(), - reason="no checkpoint journal") + return ResumeState( + resumable=False, completed_steps=set(), reason="no checkpoint journal" + ) if any(r.get("run_complete") for r in records): - return ResumeState(resumable=False, completed_steps=set(), - reason="previous run already completed") + return ResumeState( + resumable=False, completed_steps=set(), reason="previous run already completed" + ) - completed = {r["step_id"] for r in records - if r.get("state") == StepState.SUCCEEDED.value} + completed = {r["step_id"] for r in records if r.get("state") == StepState.SUCCEEDED.value} if not completed: - return ResumeState(resumable=False, completed_steps=set(), - reason="no succeeded steps to resume from") + return ResumeState( + resumable=False, completed_steps=set(), reason="no succeeded steps to resume from" + ) - last_tree = next( - (r["tree"] for r in reversed(records) if "tree" in r), None - ) + last_tree = next((r["tree"] for r in reversed(records) if "tree" in r), None) current_tree = _tree_hash(self.repo_root) if last_tree and last_tree != current_tree: return ResumeState( - resumable=False, completed_steps=set(), + resumable=False, + completed_steps=set(), reason=( "workspace changed since last checkpoint " f"(expected tree {last_tree[:12]}, got " diff --git a/src/sin_code_bundle/agent_engine/cli.py b/src/sin_code_bundle/agent_engine/cli.py index cb651702..2c06aa56 100644 --- a/src/sin_code_bundle/agent_engine/cli.py +++ b/src/sin_code_bundle/agent_engine/cli.py @@ -22,30 +22,26 @@ from .verifier import Verifier -def _build_loop(repo_root: str, *, echo: bool, - budget_s: float = 1800.0) -> AgentLoop: +def _build_loop(repo_root: str, *, echo: bool, budget_s: float = 1800.0) -> AgentLoop: telemetry = Telemetry(echo=echo) router = register_builtin_tools(ToolRouter()) - sandbox = PolicySandbox.load( - repo_root, dry_run=bool(os.environ.get("SIN_POLICY_DRY_RUN"))) + sandbox = PolicySandbox.load(repo_root, dry_run=bool(os.environ.get("SIN_POLICY_DRY_RUN"))) sandbox.wrap(router) ctx = DelegationContext( max_depth=int(os.environ.get("SIN_MAX_DELEGATION_DEPTH", "3")), budget_deadline=time.monotonic() + budget_s, ) - router.register("sin_delegate", - make_delegate_tool(ctx, telemetry, - policy_wrap=sandbox.wrap)) + router.register("sin_delegate", make_delegate_tool(ctx, telemetry, policy_wrap=sandbox.wrap)) verifier = Verifier( - repo_root, telemetry, + repo_root, + telemetry, lint_cmd=os.environ.get("SIN_LINT_CMD", "ruff check ."), test_cmd=os.environ.get("SIN_TEST_CMD", "pytest -x -q"), arch_cmd=os.environ.get("SIN_ARCH_CMD") or None, ) - return AgentLoop(router, verifier, telemetry=telemetry, - memory=MemoryBridge()) + return AgentLoop(router, verifier, telemetry=telemetry, memory=MemoryBridge()) def register_agent_commands(app) -> None: @@ -68,8 +64,11 @@ def run( if isinstance(specs, dict): specs = specs.get("steps", []) task = AgentTask( - goal=goal, repo_root=str(repo), max_parallelism=parallel, - budget_seconds=budget, max_repair_rounds=repair_rounds, + goal=goal, + repo_root=str(repo), + max_parallelism=parallel, + budget_seconds=budget, + max_repair_rounds=repair_rounds, ) loop = _build_loop(str(repo), echo=not quiet) report = asyncio.run(loop.run(task, specs)) @@ -88,8 +87,9 @@ def resume( specs = json.loads(plan_file.read_text(encoding="utf-8")) if isinstance(specs, dict): specs = specs.get("steps", []) - task = AgentTask(goal=goal, repo_root=str(repo), - max_parallelism=parallel, budget_seconds=budget) + task = AgentTask( + goal=goal, repo_root=str(repo), max_parallelism=parallel, budget_seconds=budget + ) task.task_id = task_id loop = _build_loop(str(repo), echo=True) checkpoints = CheckpointStore(task_id, str(repo)) @@ -97,10 +97,8 @@ def resume( if not state.resumable: typer.echo(f"cannot resume: {state.reason}", err=True) raise typer.Exit(1) - skipped = CheckpointStore.apply_to_plan( - loop.planner.build(task, specs), state) - typer.echo(f"resuming — skipped {len(skipped)} completed steps: " - f"{skipped}", err=True) + skipped = CheckpointStore.apply_to_plan(loop.planner.build(task, specs), state) + typer.echo(f"resuming — skipped {len(skipped)} completed steps: {skipped}", err=True) report = asyncio.run(loop.run(task, specs)) typer.echo(json.dumps(report, indent=2, ensure_ascii=False)) raise typer.Exit(0 if report["outcome"] == "success" else 1) @@ -110,14 +108,17 @@ def recall( goal: str = typer.Option(..., "--goal"), limit: int = typer.Option(5, "--limit"), ) -> None: - typer.echo(json.dumps( - MemoryBridge().recall_similar(goal, limit=limit), - indent=2, ensure_ascii=False)) + typer.echo( + json.dumps( + MemoryBridge().recall_similar(goal, limit=limit), indent=2, ensure_ascii=False + ) + ) @agent.command("stats") def stats() -> None: - log = Path(os.environ.get("SIN_AGENT_LOG", "") - or Path.home() / ".sin" / "agent-events.jsonl") + log = Path( + os.environ.get("SIN_AGENT_LOG", "") or Path.home() / ".sin" / "agent-events.jsonl" + ) if not log.exists(): typer.echo("no agent runs recorded yet") raise typer.Exit(0) @@ -163,6 +164,7 @@ def watch_cmd( once: bool = typer.Option(False, "--once"), ) -> None: from .watch import watch + watch(str(log) if log else None, refresh_s=refresh, once=once) @agent.command("insights") @@ -171,13 +173,13 @@ def insights( as_prompt: bool = typer.Option(False, "--prompt"), ) -> None: from .insights import TelemetryAnalyzer + analyzer = TelemetryAnalyzer(str(log) if log else None) results = analyzer.analyze() if as_prompt: typer.echo(analyzer.render_for_prompt(results)) else: - typer.echo(json.dumps([i.to_dict() for i in results], - indent=2, ensure_ascii=False)) + typer.echo(json.dumps([i.to_dict() for i in results], indent=2, ensure_ascii=False)) critical = any(i.severity == "critical" for i in results) raise typer.Exit(1 if critical else 0) @@ -189,8 +191,7 @@ def policy_check( ) -> None: sandbox = PolicySandbox.load(str(repo)) allowed, reason = sandbox.decide(tool, json.loads(args_json)) - typer.echo(json.dumps({"tool": tool, "allowed": allowed, - "reason": reason})) + typer.echo(json.dumps({"tool": tool, "allowed": allowed, "reason": reason})) raise typer.Exit(0 if allowed else 1) @agent.command("trace") @@ -200,19 +201,19 @@ def trace_cmd( chrome: Path | None = typer.Option(None, "--chrome"), ) -> None: from .tracing import TraceAssembler + assembler = TraceAssembler(str(log) if log else None) if chrome is not None: - chrome.write_text(assembler.to_chrome_trace(trace_id), - encoding="utf-8") - typer.echo(f"chrome trace written to {chrome} " - "(open via chrome://tracing or ui.perfetto.dev)") + chrome.write_text(assembler.to_chrome_trace(trace_id), encoding="utf-8") + typer.echo( + f"chrome trace written to {chrome} (open via chrome://tracing or ui.perfetto.dev)" + ) return roots = assembler.assemble(trace_id) if not roots: typer.echo("no spans found") raise typer.Exit(1) - typer.echo(TraceAssembler.render_tree( - roots, color=sys.stdout.isatty())) + typer.echo(TraceAssembler.render_tree(roots, color=sys.stdout.isatty())) @agent.command("distill") def distill_cmd( @@ -220,26 +221,37 @@ def distill_cmd( no_llm: bool = typer.Option(False, "--no-llm"), ) -> None: from .distiller import KnowledgeDistiller + complete = None llm_cmd = os.environ.get("SIN_LLM_CMD") if llm_cmd and not no_llm: + async def complete(prompt: str) -> str: proc = await asyncio.create_subprocess_shell( - llm_cmd, stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE) + llm_cmd, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE + ) out_b, _ = await proc.communicate(prompt.encode()) return out_b.decode(errors="replace") + distiller = KnowledgeDistiller(complete=complete) lessons = distiller.harvest_lessons(since_s=since_days * 86400) report = asyncio.run(distiller.distill(lessons)) - typer.echo(json.dumps({ - "harvested_lessons": len(lessons), **report, - "active_rules": [r.rule for r in distiller.active_rules()], - }, indent=2, ensure_ascii=False)) + typer.echo( + json.dumps( + { + "harvested_lessons": len(lessons), + **report, + "active_rules": [r.rule for r in distiller.active_rules()], + }, + indent=2, + ensure_ascii=False, + ) + ) @agent.command("rules") def rules_cmd() -> None: from .distiller import KnowledgeDistiller + active = KnowledgeDistiller().active_rules() if not active: typer.echo("no active standing rules yet — run `sin agent distill`") diff --git a/src/sin_code_bundle/agent_engine/compactor.py b/src/sin_code_bundle/agent_engine/compactor.py index 27ce7625..51a30cfc 100644 --- a/src/sin_code_bundle/agent_engine/compactor.py +++ b/src/sin_code_bundle/agent_engine/compactor.py @@ -31,21 +31,17 @@ def _digest_tool_result(step_id: str, content: str) -> str: try: data = json.loads(content) facts: dict[str, Any] = {"step_id": step_id} - for key in ("exit_code", "path", "bytes", "total_lines", - "replaced", "truncated"): + for key in ("exit_code", "path", "bytes", "total_lines", "replaced", "truncated"): if key in data: facts[key] = data[key] if isinstance(data.get("hits"), list): facts["hit_count"] = len(data["hits"]) - facts["hit_files"] = sorted( - {h.get("file", "?") for h in data["hits"][:20]} - ) + facts["hit_files"] = sorted({h.get("file", "?") for h in data["hits"][:20]}) if data.get("stderr"): facts["error_head"] = str(data["stderr"])[:200] return json.dumps(facts, ensure_ascii=False) except (json.JSONDecodeError, TypeError, AttributeError): - return json.dumps({"step_id": step_id, "head": head}, - ensure_ascii=False) + return json.dumps({"step_id": step_id, "head": head}, ensure_ascii=False) @dataclass @@ -55,10 +51,8 @@ class ContextCompactor: _entries: deque[_Entry] = field(default_factory=deque) _evicted: list[str] = field(default_factory=list) - def append(self, step_id: str, content: str, - kind: str = "tool_result") -> None: - self._entries.append(_Entry(step_id=step_id, kind=kind, - content=content)) + def append(self, step_id: str, content: str, kind: str = "tool_result") -> None: + self._entries.append(_Entry(step_id=step_id, kind=kind, content=content)) self._enforce() def _enforce(self) -> None: diff --git a/src/sin_code_bundle/agent_engine/delegate.py b/src/sin_code_bundle/agent_engine/delegate.py index 8616d59c..11623eeb 100644 --- a/src/sin_code_bundle/agent_engine/delegate.py +++ b/src/sin_code_bundle/agent_engine/delegate.py @@ -72,8 +72,7 @@ def validate_result(result: dict[str, Any]) -> dict[str, Any]: value = result[key] if not isinstance(value, expected): raise ValueError( - f"delegate result key {key!r}: expected {expected}, " - f"got {type(value).__name__}" + f"delegate result key {key!r}: expected {expected}, got {type(value).__name__}" ) clean[key] = value clean["lessons"] = [str(x)[:300] for x in clean["lessons"][:5]] @@ -82,15 +81,14 @@ def validate_result(result: dict[str, Any]) -> dict[str, Any]: # ----------------------------------------------------------------- context + @dataclass(slots=True) class DelegationContext: """Path-local inheritance context (depth, wall-clock deadline).""" depth: int = 0 max_depth: int = 3 - deadline_wall: float = field( - default_factory=lambda: time.monotonic() + 1800.0 - ) + deadline_wall: float = field(default_factory=lambda: time.monotonic() + 1800.0) safety_margin_s: float = 15.0 min_budget_s: float = 60.0 @@ -109,8 +107,7 @@ def can_delegate(self) -> tuple[bool, str]: return True, "" def child(self, granted_budget_s: float) -> "DelegationContext": - budget = min(granted_budget_s, - self.remaining_s() - self.safety_margin_s) + budget = min(granted_budget_s, self.remaining_s() - self.safety_margin_s) return DelegationContext( depth=self.depth + 1, max_depth=self.max_depth, @@ -122,6 +119,7 @@ def child(self, granted_budget_s: float) -> "DelegationContext": # --------------------------------------------------------- budget allocator + class AdaptiveBudgetAllocator: """Learns per goal class (first word of goal, normalized) from the MemoryBridge how much budget sub-goals of that class really need. @@ -130,9 +128,9 @@ class AdaptiveBudgetAllocator: clamped to [min_s, fraction * parent_remainder]. Without history: a fraction of the parent remainder (conservative default).""" - def __init__(self, memory: MemoryBridge, *, - default_fraction: float = 0.5, - min_s: float = 60.0) -> None: + def __init__( + self, memory: MemoryBridge, *, default_fraction: float = 0.5, min_s: float = 60.0 + ) -> None: self.memory = memory self.default_fraction = default_fraction self.min_s = min_s @@ -146,7 +144,8 @@ def grant(self, goal: str, parent_remaining_s: float) -> float: cap = parent_remaining_s * self.default_fraction hits = self.memory.recall_similar(goal, limit=10) durations = sorted( - float(h.get("elapsed_s", 0.0)) for h in hits + float(h.get("elapsed_s", 0.0)) + for h in hits if h.get("outcome") == "success" and h.get("elapsed_s") ) if len(durations) >= 3: @@ -158,18 +157,23 @@ def grant(self, goal: str, parent_remaining_s: float) -> float: # ------------------------------------------------------- idempotency cache + def _tree_hash(repo_root: str) -> str: if not repo_root or not __import__("pathlib").Path(repo_root).exists(): return "" stash = subprocess.run( ["git", "-C", repo_root, "stash", "create"], - capture_output=True, text=True, timeout=60, + capture_output=True, + text=True, + timeout=60, ).stdout.strip() if stash: return stash return subprocess.run( ["git", "-C", repo_root, "rev-parse", "HEAD^{tree}"], - capture_output=True, text=True, timeout=60, + capture_output=True, + text=True, + timeout=60, ).stdout.strip() @@ -182,8 +186,7 @@ def __init__(self, ttl_s: float = 3600.0) -> None: self._cache: dict[str, tuple[float, dict[str, Any]]] = {} @staticmethod - def fingerprint(goal: str, steps: list[dict[str, Any]], - tree: str) -> str: + def fingerprint(goal: str, steps: list[dict[str, Any]], tree: str) -> str: raw = json.dumps({"g": goal, "s": steps, "t": tree}, sort_keys=True) return hashlib.sha256(raw.encode()).hexdigest()[:24] @@ -204,6 +207,7 @@ def put(self, key: str, result: dict[str, Any]) -> None: # -------------------------------------------------------------- supervisor + @dataclass(slots=True) class _Lease: delegation_id: str @@ -223,9 +227,13 @@ class DelegationSupervisor: - cancel_all(): structured teardown of the entire delegation tree. """ - def __init__(self, *, global_limit: int = 8, - heartbeat_timeout_s: float = 300.0, - telemetry: Telemetry | None = None) -> None: + def __init__( + self, + *, + global_limit: int = 8, + heartbeat_timeout_s: float = 300.0, + telemetry: Telemetry | None = None, + ) -> None: self._sem = asyncio.Semaphore(global_limit) self.global_limit = global_limit self.heartbeat_timeout_s = heartbeat_timeout_s @@ -240,10 +248,13 @@ def heartbeat(self, delegation_id: str) -> None: def active(self) -> list[dict[str, Any]]: return [ - {"delegation_id": l.delegation_id, "goal": l.goal[:80], - "depth": l.depth, - "age_s": round(time.monotonic() - l.started_at, 1)} - for l in self._leases.values() + { + "delegation_id": lease.delegation_id, + "goal": lease.goal[:80], + "depth": lease.depth, + "age_s": round(time.monotonic() - lease.started_at, 1), + } + for lease in self._leases.values() ] async def _reap_stalled(self) -> None: @@ -273,11 +284,9 @@ def cancel_all(self) -> int: self._leases.clear() return n - async def supervise(self, *, delegation_id: str, goal: str, depth: int, - coro) -> Any: + async def supervise(self, *, delegation_id: str, goal: str, depth: int, coro) -> Any: async with self._sem: - lease = _Lease(delegation_id=delegation_id, goal=goal, - depth=depth) + lease = _Lease(delegation_id=delegation_id, goal=goal, depth=depth) task = asyncio.ensure_future(coro) lease.task = task self._leases[delegation_id] = lease @@ -291,6 +300,7 @@ async def supervise(self, *, delegation_id: str, goal: str, depth: int, # ----------------------------------------------------------- tool factory + def make_delegate_tool( parent_ctx: DelegationContext, telemetry: Telemetry, @@ -321,16 +331,17 @@ async def sin_delegate( key = DelegationCache.fingerprint(goal, steps, tree) cached = cache.get(key) if cached is not None: - telemetry.emit("delegate_cache_hit", goal=goal[:120], - fingerprint=key) + telemetry.emit("delegate_cache_hit", goal=goal[:120], fingerprint=key) return {**cached, "cached": True} delegation_id = uuid.uuid4().hex[:10] granted = allocator.grant(goal, parent_ctx.remaining_s()) child_ctx = parent_ctx.child(granted) telemetry.emit( - "delegate_start", delegation_id=delegation_id, - goal=goal[:120], depth=child_ctx.depth, + "delegate_start", + delegation_id=delegation_id, + goal=goal[:120], + depth=child_ctx.depth, granted_budget_s=round(child_ctx.remaining_s(), 1), goal_class=AdaptiveBudgetAllocator.goal_class(goal), ) @@ -341,18 +352,26 @@ async def child_run() -> dict[str, Any]: policy_wrap(child_router) child_router.register( "sin_delegate", - make_delegate_tool(child_ctx, telemetry, - supervisor=supervisor, cache=cache, - allocator=allocator, - policy_wrap=policy_wrap), + make_delegate_tool( + child_ctx, + telemetry, + supervisor=supervisor, + cache=cache, + allocator=allocator, + policy_wrap=policy_wrap, + ), ) from .loop import AgentLoop + child_loop = AgentLoop( - child_router, Verifier(cwd, telemetry), - telemetry=telemetry, memory=MemoryBridge(), + child_router, + Verifier(cwd, telemetry), + telemetry=telemetry, + memory=MemoryBridge(), ) task = AgentTask( - goal=goal, repo_root=cwd, + goal=goal, + repo_root=cwd, constraints=list(constraints or []), max_parallelism=parallel, budget_seconds=child_ctx.remaining_s(), @@ -364,35 +383,41 @@ async def child_run() -> dict[str, Any]: return report try: - async with asyncio.timeout(child_ctx.remaining_s() - + parent_ctx.safety_margin_s): + async with asyncio.timeout(child_ctx.remaining_s() + parent_ctx.safety_margin_s): report = await supervisor.supervise( - delegation_id=delegation_id, goal=goal, - depth=child_ctx.depth, coro=child_run(), + delegation_id=delegation_id, + goal=goal, + depth=child_ctx.depth, + coro=child_run(), ) except (asyncio.TimeoutError, asyncio.CancelledError) as err: - telemetry.emit("delegate_aborted", delegation_id=delegation_id, - kind=type(err).__name__) + telemetry.emit("delegate_aborted", delegation_id=delegation_id, kind=type(err).__name__) raise RuntimeError( f"sub-agent {delegation_id} aborted " f"({type(err).__name__}) — parent deadline protected" ) from err - result = validate_result({ - "outcome": report["outcome"], - "verdict": report["verdict"], - "elapsed_s": report["elapsed_s"], - "steps_ok": report["steps_ok"], - "steps_total": report["steps_total"], - "lessons": report["lessons"], - "depth": child_ctx.depth, - "delegation_id": delegation_id, - "cached": False, - }) + result = validate_result( + { + "outcome": report["outcome"], + "verdict": report["verdict"], + "elapsed_s": report["elapsed_s"], + "steps_ok": report["steps_ok"], + "steps_total": report["steps_total"], + "lessons": report["lessons"], + "depth": child_ctx.depth, + "delegation_id": delegation_id, + "cached": False, + } + ) cache.put(key, result) - telemetry.emit("delegate_done", delegation_id=delegation_id, - goal=goal[:120], depth=child_ctx.depth, - outcome=result["outcome"]) + telemetry.emit( + "delegate_done", + delegation_id=delegation_id, + goal=goal[:120], + depth=child_ctx.depth, + outcome=result["outcome"], + ) return result return sin_delegate diff --git a/src/sin_code_bundle/agent_engine/distiller.py b/src/sin_code_bundle/agent_engine/distiller.py index 21124ba8..ffdea104 100644 --- a/src/sin_code_bundle/agent_engine/distiller.py +++ b/src/sin_code_bundle/agent_engine/distiller.py @@ -71,10 +71,13 @@ def _signature(lesson: str) -> str: """Normalized signature: verdict-kind + top keywords.""" match = _KIND_RX.search(lesson) kind_str = match.group(0) if match else "generic" - words = sorted({ - w for w in re.findall(r"[a-z]{4,}", lesson.lower()) - if w not in _STOPWORDS and not w.startswith("fail") - })[:4] + words = sorted( + { + w + for w in re.findall(r"[a-z]{4,}", lesson.lower()) + if w not in _STOPWORDS and not w.startswith("fail") + } + )[:4] return f"{kind_str}:{'-'.join(words)}" @@ -82,17 +85,16 @@ def _heuristic_rule(lessons: list[str]) -> str: """Fallback without LLM: known classes get canonical rules.""" joined = " ".join(lessons).lower() if "fail_lint" in joined: - return ("Run the lint autofix step before the verification step " - "in every plan.") + return "Run the lint autofix step before the verification step in every plan." if "fail_tests" in joined: - return ("Read the affected test file before editing the code " - "under test.") + return "Read the affected test file before editing the code under test." if "fail_semantic" in joined and "delet" in joined: - return ("Keep diffs small; split large changes into delegated " - "sub-tasks instead of bulk deletions.") + return ( + "Keep diffs small; split large changes into delegated " + "sub-tasks instead of bulk deletions." + ) if "fail_architecture" in joined: - return ("Check architecture rules before editing module " - "boundaries or imports.") + return "Check architecture rules before editing module boundaries or imports." return f"Avoid repeating: {lessons[0][:140]}" @@ -106,14 +108,17 @@ class StandingRule: class KnowledgeDistiller: - def __init__(self, db_path: str | None = None, *, - complete: CompleteFn | None = None, - min_evidence: int = 3, - max_active: int = 12, - decay: float = 0.85, - retire_below: float = 0.3) -> None: - self.db_path = Path( - db_path or Path.home() / ".sin" / "agent-memory.db") + def __init__( + self, + db_path: str | None = None, + *, + complete: CompleteFn | None = None, + min_evidence: int = 3, + max_active: int = 12, + decay: float = 0.85, + retire_below: float = 0.3, + ) -> None: + self.db_path = Path(db_path or Path.home() / ".sin" / "agent-memory.db") self.db_path.parent.mkdir(parents=True, exist_ok=True) self.complete = complete self.min_evidence = min_evidence @@ -136,8 +141,7 @@ async def distill(self, raw_lessons: list[str]) -> dict[str, Any]: promoted, reinforced = [], [] with self._conn() as con: - con.execute("UPDATE standing_rules SET score = score * ?", - (self.decay,)) + con.execute("UPDATE standing_rules SET score = score * ?", (self.decay,)) for sig, lessons in clusters.items(): row = con.execute( @@ -145,16 +149,14 @@ async def distill(self, raw_lessons: list[str]) -> dict[str, Any]: (sig,), ).fetchone() if row is None: - initial_state = ('active' if len(lessons) - >= self.min_evidence else 'candidate') + initial_state = "active" if len(lessons) >= self.min_evidence else "candidate" con.execute( "INSERT INTO standing_rules (signature, rule, " "state, evidence_count, score, created_ts, " "last_evidence_ts) VALUES (?, ?, ?, ?, 1.0, ?, ?)", - (sig, _heuristic_rule(lessons), initial_state, - len(lessons), now, now), + (sig, _heuristic_rule(lessons), initial_state, len(lessons), now, now), ) - if initial_state == 'active': + if initial_state == "active": promoted.append(sig) continue new_count = row["evidence_count"] + len(lessons) @@ -166,23 +168,22 @@ async def distill(self, raw_lessons: list[str]) -> dict[str, Any]: ) reinforced.append(sig) - if (row["state"] == "candidate" - and new_count >= self.min_evidence): + if row["state"] == "candidate" and new_count >= self.min_evidence: rule_text = _heuristic_rule(lessons) if self.complete is not None: try: - raw = await self.complete(_DISTILL_PROMPT.format( - lessons="\n".join( - f"- {l}" for l in lessons[:8]), - )) + raw = await self.complete( + _DISTILL_PROMPT.format( + lessons="\n".join(f"- {lesson}" for lesson in lessons[:8]), + ) + ) candidate = raw.strip().splitlines()[0][:160] if 20 <= len(candidate) <= 160: rule_text = candidate except Exception: pass con.execute( - "UPDATE standing_rules SET state = 'active', " - "rule = ? WHERE signature = ?", + "UPDATE standing_rules SET state = 'active', rule = ? WHERE signature = ?", (rule_text, sig), ) promoted.append(sig) @@ -191,8 +192,7 @@ async def distill(self, raw_lessons: list[str]) -> dict[str, Any]: "SELECT signature FROM standing_rules WHERE score < ?", (self.retire_below,), ).fetchall() - con.execute("DELETE FROM standing_rules WHERE score < ?", - (self.retire_below,)) + con.execute("DELETE FROM standing_rules WHERE score < ?", (self.retire_below,)) con.execute( "DELETE FROM standing_rules WHERE state = 'active' AND id " "NOT IN (SELECT id FROM standing_rules " @@ -210,14 +210,19 @@ async def distill(self, raw_lessons: list[str]) -> dict[str, Any]: def active_rules(self) -> list[StandingRule]: with self._conn() as con: rows = con.execute( - "SELECT * FROM standing_rules WHERE state = 'active' " - "ORDER BY score DESC LIMIT ?", + "SELECT * FROM standing_rules WHERE state = 'active' ORDER BY score DESC LIMIT ?", (self.max_active,), ).fetchall() - return [StandingRule( - signature=r["signature"], rule=r["rule"], state=r["state"], - evidence_count=r["evidence_count"], score=r["score"], - ) for r in rows] + return [ + StandingRule( + signature=r["signature"], + rule=r["rule"], + state=r["state"], + evidence_count=r["evidence_count"], + score=r["score"], + ) + for r in rows + ] def render_constraints(self, *, max_chars: int = 1000) -> str: """Prompt-Block for the PlanSynthesizer.""" @@ -225,16 +230,16 @@ def render_constraints(self, *, max_chars: int = 1000) -> str: if not rules: return "" lines = [f"- {r.rule}" for r in rules] - return ("STANDING RULES (distilled from past failures — obey):\n" - + "\n".join(lines))[:max_chars] + return ("STANDING RULES (distilled from past failures — obey):\n" + "\n".join(lines))[ + :max_chars + ] def harvest_lessons(self, *, since_s: float = 7 * 86400) -> list[str]: """Raw lessons of the last period from agent_runs.""" cutoff = time.time() - since_s with self._conn() as con: rows = con.execute( - "SELECT lessons FROM agent_runs WHERE ts > ? " - "AND lessons != '[]'", + "SELECT lessons FROM agent_runs WHERE ts > ? AND lessons != '[]'", (cutoff,), ).fetchall() out: list[str] = [] diff --git a/src/sin_code_bundle/agent_engine/executor.py b/src/sin_code_bundle/agent_engine/executor.py index 174eeb22..87684913 100644 --- a/src/sin_code_bundle/agent_engine/executor.py +++ b/src/sin_code_bundle/agent_engine/executor.py @@ -8,7 +8,6 @@ import subprocess import tempfile import time -from pathlib import Path from typing import Callable from .planner import Planner @@ -18,9 +17,12 @@ class Executor: - def __init__(self, router: ToolRouter, telemetry: Telemetry, - on_step_terminal: Callable[[str, StepState], None] | None = None - ) -> None: + def __init__( + self, + router: ToolRouter, + telemetry: Telemetry, + on_step_terminal: Callable[[str, StepState], None] | None = None, + ) -> None: self.router = router self.telemetry = telemetry self.on_step_terminal = on_step_terminal @@ -31,7 +33,10 @@ def _create_worktree(self, repo_root: str, step_id: str) -> str: wt = tempfile.mkdtemp(prefix=f"sin-wt-{step_id}-") subprocess.run( ["git", "-C", repo_root, "worktree", "add", "--detach", wt], - check=True, capture_output=True, text=True, timeout=60, + check=True, + capture_output=True, + text=True, + timeout=60, ) self._worktrees.append(wt) return wt @@ -40,13 +45,14 @@ def cleanup(self, repo_root: str) -> None: for wt in self._worktrees: subprocess.run( ["git", "-C", repo_root, "worktree", "remove", "--force", wt], - capture_output=True, text=True, timeout=60, + capture_output=True, + text=True, + timeout=60, ) shutil.rmtree(wt, ignore_errors=True) self._worktrees.clear() - async def run(self, task: AgentTask, plan: Plan, - planner: Planner) -> dict[str, StepResult]: + async def run(self, task: AgentTask, plan: Plan, planner: Planner) -> dict[str, StepResult]: results: dict[str, StepResult] = {} sem = asyncio.Semaphore(task.max_parallelism) in_flight: set[asyncio.Task[None]] = set() @@ -60,8 +66,9 @@ async def run_step(step: Step) -> None: async with sem: step.state = StepState.RUNNING step.attempts += 1 - self.telemetry.emit("step_start", step_id=step.step_id, - tool=step.tool, attempt=step.attempts) + self.telemetry.emit( + "step_start", step_id=step.step_id, tool=step.tool, attempt=step.attempts + ) start = time.monotonic() worktree: str | None = None try: @@ -78,29 +85,36 @@ async def run_step(step: Step) -> None: output = await self.router.call(step.tool, **args) step.state = StepState.SUCCEEDED results[step.step_id] = StepResult( - step_id=step.step_id, ok=True, output=output, + step_id=step.step_id, + ok=True, + output=output, duration_s=time.monotonic() - start, worktree=worktree, ) - self.telemetry.emit("step_ok", step_id=step.step_id, - duration_s=round(time.monotonic() - start, 3)) + self.telemetry.emit( + "step_ok", + step_id=step.step_id, + duration_s=round(time.monotonic() - start, 3), + ) await _notify(step.state, step.step_id) except Exception as err: - if step.attempts < step.max_attempts and not isinstance( - err, CircuitOpenError - ): + if step.attempts < step.max_attempts and not isinstance(err, CircuitOpenError): step.state = StepState.PENDING - self.telemetry.emit("step_retry", step_id=step.step_id, - error=str(err)[:500]) + self.telemetry.emit( + "step_retry", step_id=step.step_id, error=str(err)[:500] + ) else: step.state = StepState.FAILED results[step.step_id] = StepResult( - step_id=step.step_id, ok=False, error=str(err), + step_id=step.step_id, + ok=False, + error=str(err), duration_s=time.monotonic() - start, ) skipped = planner.propagate_failure(plan, step.step_id) - self.telemetry.emit("step_fail", step_id=step.step_id, - error=str(err)[:500], skipped=skipped) + self.telemetry.emit( + "step_fail", step_id=step.step_id, error=str(err)[:500], skipped=skipped + ) await _notify(step.state, step.step_id) for s in skipped: await _notify(StepState.SKIPPED, s) @@ -122,8 +136,7 @@ async def run_step(step: Step) -> None: if all(s.state in terminal for s in plan.steps.values()): break if not in_flight: - pending = [s.step_id for s in plan.steps.values() - if s.state not in terminal] + pending = [s.step_id for s in plan.steps.values() if s.state not in terminal] if pending: self.telemetry.emit("scheduler_stall", pending=pending) break diff --git a/src/sin_code_bundle/agent_engine/insights.py b/src/sin_code_bundle/agent_engine/insights.py index 4dc4e8f3..2cbc5e71 100644 --- a/src/sin_code_bundle/agent_engine/insights.py +++ b/src/sin_code_bundle/agent_engine/insights.py @@ -35,7 +35,8 @@ def to_dict(self) -> dict[str, Any]: class TelemetryAnalyzer: def __init__(self, log_path: str | None = None) -> None: self.log_path = Path( - log_path or os.environ.get("SIN_AGENT_LOG", "") + log_path + or os.environ.get("SIN_AGENT_LOG", "") or Path.home() / ".sin" / "agent-events.jsonl" ) @@ -53,8 +54,11 @@ def _events(self) -> list[dict[str, Any]]: def analyze(self) -> list[Insight]: events = self._events() if not events: - return [Insight("info", "general", "no telemetry recorded yet", - "run some agent tasks first")] + return [ + Insight( + "info", "general", "no telemetry recorded yet", "run some agent tasks first" + ) + ] insights: list[Insight] = [] insights += self._tool_health(events) insights += self._repair_hotspots(events) @@ -88,29 +92,32 @@ def _tool_health(self, events: list[dict]) -> list[Insight]: fail_rate = fails[tool] / n retry_rate = retries[tool] / n if fail_rate > 0.3: - out.append(Insight( - "critical", "tool_health", - f"tool {tool!r} fails {fail_rate:.0%} of the time " - f"({fails[tool]}/{n})", - f"inspect {tool!r} arguments in failing steps; consider " - "a lower failure_threshold so its circuit opens earlier", - {"starts": n, "fails": fails[tool], - "fail_rate": round(fail_rate, 2)}, - )) + out.append( + Insight( + "critical", + "tool_health", + f"tool {tool!r} fails {fail_rate:.0%} of the time ({fails[tool]}/{n})", + f"inspect {tool!r} arguments in failing steps; consider " + "a lower failure_threshold so its circuit opens earlier", + {"starts": n, "fails": fails[tool], "fail_rate": round(fail_rate, 2)}, + ) + ) elif retry_rate > 0.5: - out.append(Insight( - "warn", "tool_health", - f"tool {tool!r} retries {retry_rate:.0%} of calls", - "raise base_delay_s for this tool or serialize its " - "steps via dependency edges", - {"starts": n, "retries": retries[tool]}, - )) + out.append( + Insight( + "warn", + "tool_health", + f"tool {tool!r} retries {retry_rate:.0%} of calls", + "raise base_delay_s for this tool or serialize its " + "steps via dependency edges", + {"starts": n, "retries": retries[tool]}, + ) + ) return out def _repair_hotspots(self, events: list[dict]) -> list[Insight]: kinds = Counter( - e.get("kind", "?") for e in events - if e.get("event") == "verdict" and not e.get("ok") + e.get("kind", "?") for e in events if e.get("event") == "verdict" and not e.get("ok") ) total = sum(kinds.values()) out: list[Insight] = [] @@ -121,20 +128,23 @@ def _repair_hotspots(self, events: list[dict]) -> list[Insight]: if share > 0.5 and total >= 4: fix = { "fail_lint": "add a 'ruff check . --fix' step BEFORE the " - "verification step in every plan", + "verification step in every plan", "fail_tests": "plans skip exploration — enforce sin_read of " - "the test file before each edit step", + "the test file before each edit step", "fail_architecture": "feed ADW rules into the synthesizer " - "prompt as hard constraints", + "prompt as hard constraints", "fail_semantic": "plans produce oversized diffs — split " - "goals into smaller delegated sub-tasks", + "goals into smaller delegated sub-tasks", }.get(top, "investigate this failure class manually") - out.append(Insight( - "warn", "repair_hotspots", - f"{share:.0%} of all verification failures are {top!r} " - f"({n}/{total})", - fix, {"distribution": dict(kinds)}, - )) + out.append( + Insight( + "warn", + "repair_hotspots", + f"{share:.0%} of all verification failures are {top!r} ({n}/{total})", + fix, + {"distribution": dict(kinds)}, + ) + ) return out def _step_patterns(self, events: list[dict]) -> list[Insight]: @@ -153,40 +163,45 @@ def _step_patterns(self, events: list[dict]) -> list[Insight]: out: list[Insight] = [] for prefix, n in starts.items(): if n >= 8 and fails[prefix] / n > 0.4: - out.append(Insight( - "warn", "step_patterns", - f"steps prefixed {prefix!r}* fail " - f"{fails[prefix] / n:.0%} of the time", - f"review how {prefix!r} steps are planned — they likely " - "need finer-grained exploration dependencies", - {"starts": n, "fails": fails[prefix]}, - )) + out.append( + Insight( + "warn", + "step_patterns", + f"steps prefixed {prefix!r}* fail {fails[prefix] / n:.0%} of the time", + f"review how {prefix!r} steps are planned — they likely " + "need finer-grained exploration dependencies", + {"starts": n, "fails": fails[prefix]}, + ) + ) return out def _stalls(self, events: list[dict]) -> list[Insight]: - stalls = sum(1 for e in events - if e.get("event") == "scheduler_stall") - exhausted = sum(1 for e in events - if e.get("event") == "budget_exhausted") - runs = max(1, sum(1 for e in events - if e.get("event") == "run_complete")) + stalls = sum(1 for e in events if e.get("event") == "scheduler_stall") + exhausted = sum(1 for e in events if e.get("event") == "budget_exhausted") + runs = max(1, sum(1 for e in events if e.get("event") == "run_complete")) out: list[Insight] = [] if exhausted / runs > 0.25: - out.append(Insight( - "critical", "stalls", - f"{exhausted}/{runs} runs exhausted their budget", - "raise --budget, or shrink plans via sin_delegate so " - "long-running test steps get isolated child budgets", - {"budget_exhausted": exhausted, "runs": runs}, - )) + out.append( + Insight( + "critical", + "stalls", + f"{exhausted}/{runs} runs exhausted their budget", + "raise --budget, or shrink plans via sin_delegate so " + "long-running test steps get isolated child budgets", + {"budget_exhausted": exhausted, "runs": runs}, + ) + ) if stalls: - out.append(Insight( - "warn", "stalls", - f"{stalls} scheduler stalls (steps pending, none ready)", - "check plans for dependency chains on steps that can fail " - "permanently — add fallback paths or reduce fan-in", - {"stalls": stalls}, - )) + out.append( + Insight( + "warn", + "stalls", + f"{stalls} scheduler stalls (steps pending, none ready)", + "check plans for dependency chains on steps that can fail " + "permanently — add fallback paths or reduce fan-in", + {"stalls": stalls}, + ) + ) return out def _delegation(self, events: list[dict]) -> list[Insight]: @@ -196,25 +211,30 @@ def _delegation(self, events: list[dict]) -> list[Insight]: ok = sum(1 for e in done if e.get("outcome") == "success") rate = ok / len(done) if rate < 0.5: - return [Insight( - "warn", "delegation", - f"sub-agents succeed only {rate:.0%} of the time " - f"({ok}/{len(done)})", - "child budgets may be too small — raise budget_fraction or " - "delegate smaller goals", + return [ + Insight( + "warn", + "delegation", + f"sub-agents succeed only {rate:.0%} of the time ({ok}/{len(done)})", + "child budgets may be too small — raise budget_fraction or " + "delegate smaller goals", + {"delegations": len(done), "successes": ok}, + ) + ] + return [ + Insight( + "info", + "delegation", + f"sub-agents succeed {rate:.0%} of the time — delegation pays off", + "consider delegating more long-running verification work", {"delegations": len(done), "successes": ok}, - )] - return [Insight( - "info", "delegation", - f"sub-agents succeed {rate:.0%} of the time — delegation pays off", - "consider delegating more long-running verification work", - {"delegations": len(done), "successes": ok}, - )] - - def render_for_prompt(self, insights: list[Insight], - *, max_chars: int = 1200) -> str: + ) + ] + + def render_for_prompt(self, insights: list[Insight], *, max_chars: int = 1200) -> str: lines = [ f"[{i.severity}] {i.finding} => {i.recommendation}" - for i in insights if i.severity != "info" + for i in insights + if i.severity != "info" ] return "\n".join(lines)[:max_chars] or "no systemic issues detected" diff --git a/src/sin_code_bundle/agent_engine/loop.py b/src/sin_code_bundle/agent_engine/loop.py index 410cd330..2ec20a4a 100644 --- a/src/sin_code_bundle/agent_engine/loop.py +++ b/src/sin_code_bundle/agent_engine/loop.py @@ -11,7 +11,7 @@ from .planner import Planner from .router import ToolRouter from .telemetry import Telemetry -from .types import AgentTask, Plan, StepResult, Verdict +from .types import AgentTask, StepResult, Verdict, VerdictKind from .verifier import Verifier RepairFactory = Callable[[AgentTask, Verdict], Awaitable[list[dict[str, Any]]]] @@ -34,9 +34,7 @@ def __init__( self.planner = Planner() self.repair_factory = repair_factory - async def run( - self, task: AgentTask, step_specs: list[dict[str, Any]] - ) -> dict[str, Any]: + async def run(self, task: AgentTask, step_specs: list[dict[str, Any]]) -> dict[str, Any]: t0 = time.monotonic() lessons: list[str] = [] @@ -45,8 +43,7 @@ async def run( self.telemetry.emit("recall", hits=len(prior)) plan = self.planner.build(task, step_specs) - self.telemetry.emit("plan_built", task_id=task.task_id, - steps=len(plan.steps)) + self.telemetry.emit("plan_built", task_id=task.task_id, steps=len(plan.steps)) executor = Executor(self.router, self.telemetry) results: dict[str, StepResult] = {} @@ -56,8 +53,9 @@ async def run( for round_no in range(task.max_repair_rounds + 1): results.update(await executor.run(task, plan, self.planner)) verdict = await self.verifier.verify() - self.telemetry.emit("verdict", round=round_no, - kind=verdict.kind.value, ok=verdict.ok) + self.telemetry.emit( + "verdict", round=round_no, kind=verdict.kind.value, ok=verdict.ok + ) if verdict.ok: break lessons.append( @@ -70,8 +68,7 @@ async def run( if not repair_specs: break plan = self.planner.build(task, repair_specs) - self.telemetry.emit("repair_plan", round=round_no, - steps=len(plan.steps)) + self.telemetry.emit("repair_plan", round=round_no, steps=len(plan.steps)) finally: executor.cleanup(task.repo_root) @@ -98,7 +95,7 @@ async def run( "router_stats": self.router.stats(), "telemetry": self.telemetry.summary(), } - self.telemetry.emit("run_complete", **{ - k: v for k, v in report.items() if k != "router_stats" - }) + self.telemetry.emit( + "run_complete", **{k: v for k, v in report.items() if k != "router_stats"} + ) return report diff --git a/src/sin_code_bundle/agent_engine/memory_bridge.py b/src/sin_code_bundle/agent_engine/memory_bridge.py index 67d1be2a..85db70df 100644 --- a/src/sin_code_bundle/agent_engine/memory_bridge.py +++ b/src/sin_code_bundle/agent_engine/memory_bridge.py @@ -43,25 +43,40 @@ def _conn(self) -> sqlite3.Connection: con.row_factory = sqlite3.Row return con - def remember_run(self, *, task_id: str, goal: str, outcome: str, - repair_rounds: int, lessons: list[str], - plan_json: str, elapsed_s: float = 0.0) -> None: + def remember_run( + self, + *, + task_id: str, + goal: str, + outcome: str, + repair_rounds: int, + lessons: list[str], + plan_json: str, + elapsed_s: float = 0.0, + ) -> None: with self._conn() as con: con.execute( "INSERT INTO agent_runs " "(ts, task_id, goal, outcome, repair_rounds, lessons, " "plan_json, elapsed_s) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (time.time(), task_id, goal, outcome, repair_rounds, - json.dumps(lessons, ensure_ascii=False), plan_json, - float(elapsed_s)), + ( + time.time(), + task_id, + goal, + outcome, + repair_rounds, + json.dumps(lessons, ensure_ascii=False), + plan_json, + float(elapsed_s), + ), ) def recall_similar(self, goal: str, limit: int = 5) -> list[dict[str, Any]]: terms = " OR ".join( - w for w in "".join( - c if c.isalnum() or c.isspace() else " " for c in goal - ).split() if len(w) > 2 + w + for w in "".join(c if c.isalnum() or c.isspace() else " " for c in goal).split() + if len(w) > 2 ) if not terms: return [] diff --git a/src/sin_code_bundle/agent_engine/planner.py b/src/sin_code_bundle/agent_engine/planner.py index 93e0b587..e27c5950 100644 --- a/src/sin_code_bundle/agent_engine/planner.py +++ b/src/sin_code_bundle/agent_engine/planner.py @@ -18,16 +18,18 @@ class Planner: def build(self, task: AgentTask, step_specs: list[dict[str, Any]]) -> Plan: plan = Plan(task_id=task.task_id) for spec in step_specs: - plan.add(Step( - step_id=spec["step_id"], - title=spec.get("title", spec["step_id"]), - tool=spec["tool"], - args=spec.get("args", {}), - deps=list(spec.get("deps", [])), - estimated_cost=float(spec.get("estimated_cost", 1.0)), - isolated=bool(spec.get("isolated", False)), - max_attempts=int(spec.get("max_attempts", 3)), - )) + plan.add( + Step( + step_id=spec["step_id"], + title=spec.get("title", spec["step_id"]), + tool=spec["tool"], + args=spec.get("args", {}), + deps=list(spec.get("deps", [])), + estimated_cost=float(spec.get("estimated_cost", 1.0)), + isolated=bool(spec.get("isolated", False)), + max_attempts=int(spec.get("max_attempts", 3)), + ) + ) plan.validate() return plan diff --git a/src/sin_code_bundle/agent_engine/policy_sandbox.py b/src/sin_code_bundle/agent_engine/policy_sandbox.py index 9210c614..e66ac69d 100644 --- a/src/sin_code_bundle/agent_engine/policy_sandbox.py +++ b/src/sin_code_bundle/agent_engine/policy_sandbox.py @@ -42,13 +42,10 @@ class PolicySandbox: rules: list[PolicyRule] = field(default_factory=list) default: str = "allow" dry_run: bool = False - audit_path: Path = field( - default_factory=lambda: Path.home() / ".sin" / "policy-audit.jsonl" - ) + audit_path: Path = field(default_factory=lambda: Path.home() / ".sin" / "policy-audit.jsonl") @classmethod - def load(cls, repo_root: str | None = None, *, - dry_run: bool = False) -> "PolicySandbox": + def load(cls, repo_root: str | None = None, *, dry_run: bool = False) -> "PolicySandbox": candidates: list[Path] = [] if repo_root: candidates.append(Path(repo_root) / ".sin" / "policy.json") @@ -60,11 +57,16 @@ def load(cls, repo_root: str | None = None, *, except json.JSONDecodeError: continue return cls( - rules=[PolicyRule( - action=r["action"], tool=r.get("tool", "*"), - pattern=r["pattern"], arg=r.get("arg"), - reason=r.get("reason", ""), - ) for r in raw.get("rules", [])], + rules=[ + PolicyRule( + action=r["action"], + tool=r.get("tool", "*"), + pattern=r["pattern"], + arg=r.get("arg"), + reason=r.get("reason", ""), + ) + for r in raw.get("rules", []) + ], default=raw.get("default", "allow"), dry_run=dry_run, ) @@ -84,20 +86,23 @@ def decide(self, tool: str, kwargs: dict[str, Any]) -> tuple[bool, str]: return False, "default deny (no allow rule matched)" return True, "default allow" - def _audit(self, tool: str, kwargs: dict[str, Any], - allowed: bool, reason: str) -> None: + def _audit(self, tool: str, kwargs: dict[str, Any], allowed: bool, reason: str) -> None: self.audit_path.parent.mkdir(parents=True, exist_ok=True) with self.audit_path.open("a", encoding="utf-8") as fh: - fh.write(json.dumps({ - "ts": round(time.time(), 3), - "tool": tool, - "allowed": allowed, - "dry_run": self.dry_run, - "reason": reason, - "args_preview": json.dumps( - kwargs, default=str, ensure_ascii=False - )[:400], - }, ensure_ascii=False) + "\n") + fh.write( + json.dumps( + { + "ts": round(time.time(), 3), + "tool": tool, + "allowed": allowed, + "dry_run": self.dry_run, + "reason": reason, + "args_preview": json.dumps(kwargs, default=str, ensure_ascii=False)[:400], + }, + ensure_ascii=False, + ) + + "\n" + ) def wrap(self, router) -> None: original_call = router.call @@ -107,9 +112,7 @@ async def guarded_call(name: str, **kwargs: Any) -> Any: if not allowed: self._audit(name, kwargs, allowed=False, reason=reason) if not self.dry_run: - raise PolicyViolation( - f"policy blocked tool {name!r}: {reason}" - ) + raise PolicyViolation(f"policy blocked tool {name!r}: {reason}") elif reason != "default allow": self._audit(name, kwargs, allowed=True, reason=reason) return await original_call(name, **kwargs) diff --git a/src/sin_code_bundle/agent_engine/repair.py b/src/sin_code_bundle/agent_engine/repair.py index a7ddf350..6d5b4387 100644 --- a/src/sin_code_bundle/agent_engine/repair.py +++ b/src/sin_code_bundle/agent_engine/repair.py @@ -17,8 +17,7 @@ CompleteFn = Callable[[str], Awaitable[str]] _JSON_BLOCK = re.compile(r"\[[\s\S]*\]") -_ALLOWED_TOOLS = {"sin_search", "sin_read", "sin_edit", - "sin_write", "sin_bash", "sin_delegate"} +_ALLOWED_TOOLS = {"sin_search", "sin_read", "sin_edit", "sin_write", "sin_bash", "sin_delegate"} _DETERMINISTIC_FIXES: dict[VerdictKind, list[dict[str, Any]]] = { VerdictKind.FAIL_LINT: [ @@ -68,16 +67,13 @@ def plan_for(self, verdict: Verdict) -> list[dict[str, Any]] | None: class LLMRepairFactory: - def __init__(self, complete: CompleteFn | None = None, - *, max_detail_chars: int = 6000) -> None: + def __init__(self, complete: CompleteFn | None = None, *, max_detail_chars: int = 6000) -> None: self.complete = complete self.deterministic = DeterministicRepair() self.max_detail_chars = max_detail_chars self._attempted_deterministic: set[VerdictKind] = set() - async def build_repair_plan( - self, task: AgentTask, verdict: Verdict - ) -> list[dict[str, Any]]: + async def build_repair_plan(self, task: AgentTask, verdict: Verdict) -> list[dict[str, Any]]: if verdict.kind not in self._attempted_deterministic: fix = self.deterministic.plan_for(verdict) if fix: @@ -90,7 +86,7 @@ async def build_repair_plan( constraints="; ".join(task.constraints) or "none", kind=verdict.kind.value, hint=verdict.repair_hint or "none", - detail=verdict.detail[-self.max_detail_chars:], + detail=verdict.detail[-self.max_detail_chars :], ) raw = await self.complete(prompt) return self._parse_specs(raw) diff --git a/src/sin_code_bundle/agent_engine/router.py b/src/sin_code_bundle/agent_engine/router.py index 620dfb37..22b789e2 100644 --- a/src/sin_code_bundle/agent_engine/router.py +++ b/src/sin_code_bundle/agent_engine/router.py @@ -52,13 +52,11 @@ class ToolRouter: _circuits: dict[str, _Circuit] = field(default_factory=dict) _stats: dict[str, dict[str, int]] = field(default_factory=dict) - def register(self, name: str, fn: ToolFn, *, - failure_threshold: int = 5, - cooldown_s: float = 30.0) -> None: + def register( + self, name: str, fn: ToolFn, *, failure_threshold: int = 5, cooldown_s: float = 30.0 + ) -> None: self._tools[name] = fn - self._circuits[name] = _Circuit( - failure_threshold=failure_threshold, cooldown_s=cooldown_s - ) + self._circuits[name] = _Circuit(failure_threshold=failure_threshold, cooldown_s=cooldown_s) self._stats[name] = {"calls": 0, "failures": 0, "retries": 0} def stats(self) -> dict[str, dict[str, Any]]: @@ -101,6 +99,4 @@ async def call(self, name: str, **kwargs: Any) -> Any: self._stats[name]["retries"] += 1 await asyncio.sleep(delay) - raise RuntimeError( - f"tool {name!r} failed after retries: {last_err}" - ) from last_err + raise RuntimeError(f"tool {name!r} failed after retries: {last_err}") from last_err diff --git a/src/sin_code_bundle/agent_engine/synthesizer.py b/src/sin_code_bundle/agent_engine/synthesizer.py index 758fb225..d86f03ad 100644 --- a/src/sin_code_bundle/agent_engine/synthesizer.py +++ b/src/sin_code_bundle/agent_engine/synthesizer.py @@ -5,19 +5,23 @@ import json import re +import typing from dataclasses import dataclass, field from pathlib import Path from typing import Any, Awaitable, Callable +from .distiller import KnowledgeDistiller # noqa: F401 from .memory_bridge import MemoryBridge from .planner import Planner from .types import AgentTask +if typing.TYPE_CHECKING: + from .distiller import KnowledgeDistiller + CompleteFn = Callable[[str], Awaitable[str]] _JSON_BLOCK = re.compile(r"\[[\s\S]*\]") -_ALLOWED_TOOLS = {"sin_search", "sin_read", "sin_edit", - "sin_write", "sin_bash", "sin_delegate"} +_ALLOWED_TOOLS = {"sin_search", "sin_read", "sin_edit", "sin_write", "sin_bash", "sin_delegate"} _MAX_STEPS = 24 @@ -25,6 +29,7 @@ def _get_default_distiller(): """Lazy import to avoid a hard dependency in tests that don't need it.""" try: from .distiller import KnowledgeDistiller + return KnowledgeDistiller() except Exception: return None @@ -42,17 +47,27 @@ class RepoSurvey: top_dirs: list[str] = field(default_factory=list) def to_prompt_block(self) -> str: - return json.dumps({ - "languages": self.languages, - "test_runner": self.test_runner, - "lint_tool": self.lint_tool, - "package_files": self.package_files, - "top_dirs": self.top_dirs, - }, ensure_ascii=False) - - -_EXT_LANG = {".py": "python", ".ts": "typescript", ".tsx": "typescript", - ".js": "javascript", ".rs": "rust", ".go": "go", ".java": "java"} + return json.dumps( + { + "languages": self.languages, + "test_runner": self.test_runner, + "lint_tool": self.lint_tool, + "package_files": self.package_files, + "top_dirs": self.top_dirs, + }, + ensure_ascii=False, + ) + + +_EXT_LANG = { + ".py": "python", + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".rs": "rust", + ".go": "go", + ".java": "java", +} def survey_repo(repo_root: str, *, max_files: int = 4000) -> RepoSurvey: @@ -76,8 +91,7 @@ def survey_repo(repo_root: str, *, max_files: int = 4000) -> RepoSurvey: if (root / name).exists(): s.package_files.append(name) if (root / "pyproject.toml").exists(): - text = (root / "pyproject.toml").read_text(encoding="utf-8", - errors="replace") + text = (root / "pyproject.toml").read_text(encoding="utf-8", errors="replace") if "pytest" in text: s.test_runner = "pytest" if "ruff" in text: @@ -94,7 +108,8 @@ def survey_repo(repo_root: str, *, max_files: int = 4000) -> RepoSurvey: pass try: s.top_dirs = sorted( - d.name for d in root.iterdir() + d.name + for d in root.iterdir() if d.is_dir() and d.name not in {".git", "node_modules", ".venv"} )[:20] except OSError: @@ -152,10 +167,14 @@ def survey_repo(repo_root: str, *, max_files: int = 4000) -> RepoSurvey: class PlanSynthesizer: - def __init__(self, complete: CompleteFn | None = None, *, - memory: MemoryBridge | None = None, - distiller: "KnowledgeDistiller | None" = None, - critique: bool = True) -> None: + def __init__( + self, + complete: CompleteFn | None = None, + *, + memory: MemoryBridge | None = None, + distiller: "KnowledgeDistiller | None" = None, + critique: bool = True, + ) -> None: self.complete = complete self.memory = memory or MemoryBridge() self.distiller = distiller or _get_default_distiller() @@ -170,9 +189,7 @@ async def synthesize(self, task: AgentTask) -> list[dict[str, Any]]: ) lessons = self.memory.recall_similar(task.goal, limit=5) - lesson_text = "; ".join( - line for hit in lessons for line in hit["lessons"] - )[:1500] or "none" + lesson_text = "; ".join(line for hit in lessons for line in hit["lessons"])[:1500] or "none" survey = survey_repo(task.repo_root) @@ -181,22 +198,25 @@ async def synthesize(self, task: AgentTask) -> list[dict[str, Any]]: if standing: constraints_block = f"{constraints_block}\n{standing}" - draft_raw = await self.complete(_DRAFT_PROMPT.format( - max_steps=_MAX_STEPS, - survey=survey.to_prompt_block(), - lessons=lesson_text, - constraints=constraints_block, - goal=task.goal, - )) + draft_raw = await self.complete( + _DRAFT_PROMPT.format( + max_steps=_MAX_STEPS, + survey=survey.to_prompt_block(), + lessons=lesson_text, + constraints=constraints_block, + goal=task.goal, + ) + ) specs = self._parse_and_validate(task, draft_raw) if self.critique and specs: - critiqued_raw = await self.complete(_CRITIQUE_PROMPT.format( - goal=task.goal, - plan=json.dumps(specs, ensure_ascii=False, indent=2), - )) - critiqued = self._parse_and_validate(task, critiqued_raw, - fallback=specs) + critiqued_raw = await self.complete( + _CRITIQUE_PROMPT.format( + goal=task.goal, + plan=json.dumps(specs, ensure_ascii=False, indent=2), + ) + ) + critiqued = self._parse_and_validate(task, critiqued_raw, fallback=specs) specs = critiqued if not specs: @@ -204,7 +224,9 @@ async def synthesize(self, task: AgentTask) -> list[dict[str, Any]]: return specs def _parse_and_validate( - self, task: AgentTask, raw: str, + self, + task: AgentTask, + raw: str, fallback: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: match = _JSON_BLOCK.search(raw) @@ -223,21 +245,25 @@ def _parse_and_validate( if not isinstance(spec, dict): continue sid = spec.get("step_id") - if (not isinstance(sid, str) or sid in seen_ids - or spec.get("tool") not in _ALLOWED_TOOLS - or not isinstance(spec.get("args"), dict)): + if ( + not isinstance(sid, str) + or sid in seen_ids + or spec.get("tool") not in _ALLOWED_TOOLS + or not isinstance(spec.get("args"), dict) + ): continue seen_ids.add(sid) - cleaned.append({ - "step_id": sid, - "title": str(spec.get("title", sid))[:120], - "tool": spec["tool"], - "args": spec["args"], - "deps": [d for d in spec.get("deps", []) - if isinstance(d, str)], - "estimated_cost": float(spec.get("estimated_cost", 1.0)), - "isolated": bool(spec.get("isolated", False)), - }) + cleaned.append( + { + "step_id": sid, + "title": str(spec.get("title", sid))[:120], + "tool": spec["tool"], + "args": spec["args"], + "deps": [d for d in spec.get("deps", []) if isinstance(d, str)], + "estimated_cost": float(spec.get("estimated_cost", 1.0)), + "isolated": bool(spec.get("isolated", False)), + } + ) ids = {s["step_id"] for s in cleaned} for s in cleaned: s["deps"] = [d for d in s["deps"] if d in ids] diff --git a/src/sin_code_bundle/agent_engine/telemetry.py b/src/sin_code_bundle/agent_engine/telemetry.py index 28d0d761..a4662cf7 100644 --- a/src/sin_code_bundle/agent_engine/telemetry.py +++ b/src/sin_code_bundle/agent_engine/telemetry.py @@ -15,8 +15,7 @@ class Telemetry: def __init__(self, log_path: str | None = None, *, echo: bool = False) -> None: env_path = os.environ.get("SIN_AGENT_LOG", "") - self.log_path = Path(log_path or env_path - or Path.home() / ".sin" / "agent-events.jsonl") + self.log_path = Path(log_path or env_path or Path.home() / ".sin" / "agent-events.jsonl") self.log_path.parent.mkdir(parents=True, exist_ok=True) self.echo = echo self.counters: Counter[str] = Counter() diff --git a/src/sin_code_bundle/agent_engine/tracing.py b/src/sin_code_bundle/agent_engine/tracing.py index 61719070..c251b6aa 100644 --- a/src/sin_code_bundle/agent_engine/tracing.py +++ b/src/sin_code_bundle/agent_engine/tracing.py @@ -43,21 +43,26 @@ class SpanEmitter: def __init__(self, telemetry: Telemetry) -> None: self.telemetry = telemetry - def start(self, ctx: TraceContext, *, kind: str, name: str, - **attrs: Any) -> float: + def start(self, ctx: TraceContext, *, kind: str, name: str, **attrs: Any) -> float: self.telemetry.emit( - "span_start", trace_id=ctx.trace_id, span_id=ctx.span_id, - parent_span_id=ctx.parent_span_id, kind=kind, - name=name[:120], **attrs, + "span_start", + trace_id=ctx.trace_id, + span_id=ctx.span_id, + parent_span_id=ctx.parent_span_id, + kind=kind, + name=name[:120], + **attrs, ) return time.monotonic() - def end(self, ctx: TraceContext, started: float, *, - status: str = "ok", **attrs: Any) -> None: + def end(self, ctx: TraceContext, started: float, *, status: str = "ok", **attrs: Any) -> None: self.telemetry.emit( - "span_end", trace_id=ctx.trace_id, span_id=ctx.span_id, + "span_end", + trace_id=ctx.trace_id, + span_id=ctx.span_id, duration_s=round(time.monotonic() - started, 3), - status=status, **attrs, + status=status, + **attrs, ) @@ -77,13 +82,10 @@ class Span: class TraceAssembler: """Rekonstruiert Span-Baeume aus dem JSONL-Telemetrie-Log.""" - _SKIP_KEYS = {"event", "ts", "rel_s", "trace_id", "span_id", - "parent_span_id", "kind", "name"} + _SKIP_KEYS = {"event", "ts", "rel_s", "trace_id", "span_id", "parent_span_id", "kind", "name"} def __init__(self, log_path: str | None = None) -> None: - self.log_path = Path( - log_path or Path.home() / ".sin" / "agent-events.jsonl" - ) + self.log_path = Path(log_path or Path.home() / ".sin" / "agent-events.jsonl") def _events(self) -> list[dict[str, Any]]: if not self.log_path.exists(): @@ -117,8 +119,7 @@ def assemble(self, trace_id: str | None = None) -> list[Span]: kind=e.get("kind", "?"), name=e.get("name", "?"), started_ts=e.get("ts", 0.0), - attrs={k: v for k, v in e.items() - if k not in self._SKIP_KEYS}, + attrs={k: v for k, v in e.items() if k not in self._SKIP_KEYS}, ) elif e["event"] == "span_end" and sid in spans: spans[sid].status = e.get("status", "ok") @@ -137,8 +138,7 @@ def assemble(self, trace_id: str | None = None) -> list[Span]: @staticmethod def render_tree(roots: list[Span], *, color: bool = False) -> str: - GREEN, RED, CYAN, DIM, RESET = ( - "\x1b[32m", "\x1b[31m", "\x1b[36m", "\x1b[2m", "\x1b[0m") + GREEN, RED, CYAN, DIM, RESET = ("\x1b[32m", "\x1b[31m", "\x1b[36m", "\x1b[2m", "\x1b[0m") def paint(code: str, text: str) -> str: return f"{code}{text}{RESET}" if color else text @@ -146,19 +146,18 @@ def paint(code: str, text: str) -> str: lines: list[str] = [] def walk(span: Span, prefix: str, connector: str) -> None: - dur = (f"{span.duration_s:.1f}s" if span.duration_s is not None - else "running") + dur = f"{span.duration_s:.1f}s" if span.duration_s is not None else "running" status_code = {"ok": GREEN, "running": CYAN}.get(span.status, RED) lines.append( - prefix + connector + prefix + + connector + paint(status_code, f"[{span.status}]") + f" {span.kind}:{span.name} " + paint(DIM, f"({dur})") ) ext = " " if connector == "└─ " else "│ " for i, child in enumerate(span.children): - walk(child, prefix + ext, "└─ " if i == len(span.children) - 1 - else "├─ ") + walk(child, prefix + ext, "└─ " if i == len(span.children) - 1 else "├─ ") for i, root in enumerate(roots): walk(root, "", "└─ " if i == len(roots) - 1 else "├─ ") @@ -170,16 +169,18 @@ def to_chrome_trace(self, trace_id: str | None = None) -> str: events: list[dict[str, Any]] = [] def walk(span: Span, depth: int) -> None: - events.append({ - "name": f"{span.kind}:{span.name}", - "cat": span.kind, - "ph": "X", - "ts": int(span.started_ts * 1_000_000), - "dur": int((span.duration_s or 0.0) * 1_000_000), - "pid": 1, - "tid": depth + 1, - "args": {**span.attrs, "status": span.status}, - }) + events.append( + { + "name": f"{span.kind}:{span.name}", + "cat": span.kind, + "ph": "X", + "ts": int(span.started_ts * 1_000_000), + "dur": int((span.duration_s or 0.0) * 1_000_000), + "pid": 1, + "tid": depth + 1, + "args": {**span.attrs, "status": span.status}, + } + ) for child in span.children: walk(child, depth + 1) diff --git a/src/sin_code_bundle/agent_engine/types.py b/src/sin_code_bundle/agent_engine/types.py index 19f50445..801188ac 100644 --- a/src/sin_code_bundle/agent_engine/types.py +++ b/src/sin_code_bundle/agent_engine/types.py @@ -47,8 +47,7 @@ class AgentTask: created_at: float = field(default_factory=time.time) def fingerprint(self) -> str: - raw = json.dumps({"goal": self.goal, "constraints": self.constraints}, - sort_keys=True) + raw = json.dumps({"goal": self.goal, "constraints": self.constraints}, sort_keys=True) return hashlib.sha256(raw.encode()).hexdigest()[:16] @@ -137,7 +136,6 @@ def validate(self) -> None: def to_json(self) -> str: return json.dumps( - {"task_id": self.task_id, - "steps": [s.to_dict() for s in self.steps.values()]}, + {"task_id": self.task_id, "steps": [s.to_dict() for s in self.steps.values()]}, indent=2, ) diff --git a/src/sin_code_bundle/agent_engine/verifier.py b/src/sin_code_bundle/agent_engine/verifier.py index 60127baf..8273b604 100644 --- a/src/sin_code_bundle/agent_engine/verifier.py +++ b/src/sin_code_bundle/agent_engine/verifier.py @@ -52,9 +52,7 @@ async def _run(self, cmd: str) -> tuple[int, str]: stderr=asyncio.subprocess.STDOUT, ) try: - out, _ = await asyncio.wait_for( - proc.communicate(), timeout=self.stage_timeout_s - ) + out, _ = await asyncio.wait_for(proc.communicate(), timeout=self.stage_timeout_s) except asyncio.TimeoutError: proc.kill() return 124, f"timeout after {self.stage_timeout_s}s: {cmd}" @@ -66,7 +64,8 @@ async def verify(self) -> Verdict: self.telemetry.emit("verify_stage", stage="architecture", code=code) if code != 0: return Verdict( - kind=VerdictKind.FAIL_ARCHITECTURE, detail=out, + kind=VerdictKind.FAIL_ARCHITECTURE, + detail=out, repair_hint=( "Fix architecture-rule violations reported above " "before re-running. Do not bypass ADW rules." @@ -76,15 +75,15 @@ async def verify(self) -> Verdict: code, diff = await self._run("git diff --unified=0 HEAD") if code == 0 and diff: deleted = sum( - 1 for line in diff.splitlines() + 1 + for line in diff.splitlines() if line.startswith("-") and not line.startswith("---") ) if deleted > self.max_deleted_lines: return Verdict( kind=VerdictKind.FAIL_SEMANTIC, detail=( - f"{deleted} deleted lines exceeds safety cap " - f"({self.max_deleted_lines})." + f"{deleted} deleted lines exceeds safety cap ({self.max_deleted_lines})." ), repair_hint=( "The change deletes too much code. Split the work " @@ -108,7 +107,8 @@ async def verify(self) -> Verdict: self.telemetry.emit("verify_stage", stage="lint", code=code) if code != 0: return Verdict( - kind=VerdictKind.FAIL_LINT, detail=out, + kind=VerdictKind.FAIL_LINT, + detail=out, repair_hint=( "Fix the lint errors above. Prefer minimal, " "targeted fixes over disabling rules." @@ -120,7 +120,8 @@ async def verify(self) -> Verdict: self.telemetry.emit("verify_stage", stage="tests", code=code) if code != 0: return Verdict( - kind=VerdictKind.FAIL_TESTS, detail=out, + kind=VerdictKind.FAIL_TESTS, + detail=out, repair_hint=( "Make the failing tests pass. Read the assertion " "output above; fix the code under test, never " diff --git a/src/sin_code_bundle/agent_engine/watch.py b/src/sin_code_bundle/agent_engine/watch.py index 4b546cb3..f0dc6456 100644 --- a/src/sin_code_bundle/agent_engine/watch.py +++ b/src/sin_code_bundle/agent_engine/watch.py @@ -55,7 +55,8 @@ def apply(self, rec: dict) -> None: self.task_id = rec.get("task_id", "?") elif ev == "step_start": self.steps[sid] = _StepView( - step_id=sid, tool=rec.get("tool", "?"), + step_id=sid, + tool=rec.get("tool", "?"), attempts=int(rec.get("attempt", 1)), ) elif ev == "step_ok" and sid in self.steps: @@ -66,9 +67,7 @@ def apply(self, rec: dict) -> None: elif ev == "step_fail" and sid in self.steps: self.steps[sid].state = "fail" for skipped in rec.get("skipped", []): - self.steps.setdefault( - skipped, _StepView(step_id=skipped) - ).state = "skip" + self.steps.setdefault(skipped, _StepView(step_id=skipped)).state = "skip" elif ev == "verdict": mark = "PASS" if rec.get("ok") else str(rec.get("kind", "?")).upper() self.verdicts.append(f"round {rec.get('round', '?')}: {mark}") @@ -87,16 +86,13 @@ def style(code: str, text: str) -> str: return f"{code}{text}{_RESET}" if color else text lines = [ - style(_BOLD, f"sin agent watch task={self.task_id} " - f"events={self.events}"), + style(_BOLD, f"sin agent watch task={self.task_id} events={self.events}"), "", - style(_BOLD, f"{'STATE':<6}{'STEP':<28}{'TOOL':<14}" - f"{'TRY':<5}{'TIME':<8}"), + style(_BOLD, f"{'STATE':<6}{'STEP':<28}{'TOOL':<14}{'TRY':<5}{'TIME':<8}"), ] for v in list(self.steps.values())[-30:]: code, label = _STATE_STYLE.get(v.state, (_DIM, v.state[:4])) - dur = (v.duration_s if v.duration_s is not None - else time.monotonic() - v.started) + dur = v.duration_s if v.duration_s is not None else time.monotonic() - v.started lines.append( style(code, f"{label:<6}") + f"{v.step_id[:26]:<28}{v.tool[:12]:<14}" @@ -108,17 +104,23 @@ def style(code: str, text: str) -> str: if self.swarm: lines += ["", style(_BOLD, "SWARM")] for member, status in self.swarm.items(): - code = {"ok": _GREEN, "merged": _GREEN, "fail": _RED, - "conflict": _YELLOW, "reverted": _YELLOW}.get( - status, _CYAN) + code = { + "ok": _GREEN, + "merged": _GREEN, + "fail": _RED, + "conflict": _YELLOW, + "reverted": _YELLOW, + }.get(status, _CYAN) lines.append(f" {member:<20}" + style(code, status)) return "\n".join(lines) -def watch(log_path: str | None = None, *, refresh_s: float = 0.5, - once: bool = False) -> None: - path = Path(log_path or os.environ.get("SIN_AGENT_LOG", "") - or Path.home() / ".sin" / "agent-events.jsonl") +def watch(log_path: str | None = None, *, refresh_s: float = 0.5, once: bool = False) -> None: + path = Path( + log_path + or os.environ.get("SIN_AGENT_LOG", "") + or Path.home() / ".sin" / "agent-events.jsonl" + ) state = DashboardState() is_tty = sys.stdout.isatty() pos = 0 diff --git a/src/sin_code_bundle/cli.py b/src/sin_code_bundle/cli.py index 583f4a36..8b2d2be7 100644 --- a/src/sin_code_bundle/cli.py +++ b/src/sin_code_bundle/cli.py @@ -51,14 +51,19 @@ sckg_app = typer.Typer(help="SCKG - Semantic Codebase Knowledge Graph") app.add_typer(sckg_app, name="sckg") + @sckg_app.command("run") def sckg_run( - args: list[str] = typer.Argument(default_factory=list, help="Arguments to pass to the sckg CLI"), + args: list[str] = typer.Argument( + default_factory=list, help="Arguments to pass to the sckg CLI" + ), ): """Pass-through to the `sckg` CLI — any subcommand and flags.""" binary = shutil.which("sckg") if not binary: - typer.echo("[SCKG] 'sckg' binary not found. Install: pip install -e ~/SIN-Code-SCKG-Tool", err=True) + typer.echo( + "[SCKG] 'sckg' binary not found. Install: pip install -e ~/SIN-Code-SCKG-Tool", err=True + ) raise typer.Exit(code=1) result = subprocess.run([binary] + args, capture_output=True, text=True) if result.stdout: @@ -108,7 +113,9 @@ def sckg_search( @sckg_app.command("hot-paths") def sckg_hot_paths( path: str = typer.Argument(..., help="Path to the knowledge graph JSON"), - args: list[str] = typer.Argument(default_factory=list, help="Extra arguments for sckg hot-paths"), + args: list[str] = typer.Argument( + default_factory=list, help="Extra arguments for sckg hot-paths" + ), ): """Show most frequently called functions: `sckg hot-paths <path>`.""" binary = shutil.which("sckg") @@ -126,7 +133,9 @@ def sckg_hot_paths( @sckg_app.command("dead-code") def sckg_dead_code( path: str = typer.Argument(..., help="Path to the knowledge graph JSON"), - args: list[str] = typer.Argument(default_factory=list, help="Extra arguments for sckg dead-code"), + args: list[str] = typer.Argument( + default_factory=list, help="Extra arguments for sckg dead-code" + ), ): """Analyze graph for dead code: `sckg dead-code <path>`.""" binary = shutil.which("sckg") @@ -144,7 +153,9 @@ def sckg_dead_code( @sckg_app.command("communities") def sckg_communities( path: str = typer.Argument(..., help="Path to the knowledge graph JSON"), - args: list[str] = typer.Argument(default_factory=list, help="Extra arguments for sckg communities"), + args: list[str] = typer.Argument( + default_factory=list, help="Extra arguments for sckg communities" + ), ): """Detect language-aware communities: `sckg communities <path>`.""" binary = shutil.which("sckg") @@ -162,7 +173,9 @@ def sckg_communities( @sckg_app.command("dashboard") def sckg_dashboard( path: str = typer.Argument(..., help="Path to the knowledge graph JSON"), - args: list[str] = typer.Argument(default_factory=list, help="Extra arguments for sckg graph (dashboard)"), + args: list[str] = typer.Argument( + default_factory=list, help="Extra arguments for sckg graph (dashboard)" + ), ): """Generate interactive D3.js dashboard: `sckg graph <path>`.""" binary = shutil.which("sckg") @@ -438,7 +451,9 @@ def code( ..., help="Action: review, debt, verify, preflight, codocs, sckg, audit, oracle, adw, ibd, discover, scout, or full", ), - args: list[str] = typer.Argument(default_factory=list, help="Arguments to pass to the underlying command"), + args: list[str] = typer.Argument( + default_factory=list, help="Arguments to pass to the underlying command" + ), ): """Unified coding workflow hub — shortcut to all sin coding tools. @@ -1500,30 +1515,38 @@ def sin_search(query: str, path: str = ".", search_type: str = "semantic") -> st # Source repos are now archived (see DEPRECATED notice in their READMEs). try: from sin_code_bundle.tools.slash.app import app as slash_app + app.add_typer(slash_app, name="slash") except ImportError as exc: + @app.command("slash") - def slash_missing() -> None: + def slash_missing(exc=exc) -> None: """Slash commands (slash module not installed).""" typer.echo(f"[SIN-BUNDLE] slash module unavailable: {exc}", err=True) raise typer.Exit(code=1) + try: from sin_code_bundle.tools.mcp_server_builder.app import app as mcp_server_app + app.add_typer(mcp_server_app, name="mcp-server") except ImportError as exc: + @app.command("mcp-server") - def mcp_server_missing() -> None: + def mcp_server_missing(exc=exc) -> None: """MCP server builder (mcp_server_builder module not installed).""" typer.echo(f"[SIN-BUNDLE] mcp-server module unavailable: {exc}", err=True) raise typer.Exit(code=1) + try: from sin_code_bundle.tools.marketplace.app import app as marketplace_app + app.add_typer(marketplace_app, name="marketplace") except ImportError as exc: + @app.command("marketplace") - def marketplace_missing() -> None: + def marketplace_missing(exc=exc) -> None: """Marketplace (marketplace module not installed).""" typer.echo(f"[SIN-BUNDLE] marketplace module unavailable: {exc}", err=True) raise typer.Exit(code=1) @@ -1587,36 +1610,6 @@ def _forward_security_subcommand(subcommand: str) -> None: raise typer.Exit(code=result.returncode) -@app.command() -def ibd(): - """Intent-Based Diffing (IBD) — thin wrapper around the `ibd` binary.""" - _forward_to_binary("ibd", _NEW_TOOL_BINARIES["ibd"][0]) - - -@app.command() -def poc(): - """Proof-of-Correctness (POC) — thin wrapper around the `poc` binary.""" - _forward_to_binary("poc", _NEW_TOOL_BINARIES["poc"][0]) - - -@app.command() -def adw(): - """Architectural Debt Watchdogs (ADW) — thin wrapper around the `adw` binary.""" - _forward_to_binary("adw", _NEW_TOOL_BINARIES["adw"][0]) - - -@app.command() -def oracle(): - """Verification Oracle — thin wrapper around the `oracle` binary.""" - _forward_to_binary("oracle", _NEW_TOOL_BINARIES["oracle"][0]) - - -@app.command() -def efm(): - """Ephemeral Full-Stack Mocking (EFM) — thin wrapper around the `efm` binary.""" - _forward_to_binary("efm", _NEW_TOOL_BINARIES["efm"][0]) - - # ── Pocock Workflow Tools (v0.11.0) ─────────────────────────────────────── # Implements the Matt Pocock System-Design Paradigm: # - grill-me -> Socratic alignment & PRD generation @@ -1637,7 +1630,9 @@ def efm(): def pocock_grill_me( goal: str = typer.Argument(..., help="Development goal / feature description"), output: str = typer.Option("PRD.md", "--output", "-o", help="Output path for PRD.md"), - non_interactive: bool = typer.Option(False, "--non-interactive", help="Non-interactive mode (CI/CD)"), + non_interactive: bool = typer.Option( + False, "--non-interactive", help="Non-interactive mode (CI/CD)" + ), answers: str = typer.Option(None, "--answers", help="JSON answers for non-interactive mode"), json_out: bool = typer.Option(False, "--json", help="Output JSON"), ): @@ -1650,6 +1645,7 @@ def pocock_grill_me( typer.echo("❌ --non-interactive requires --answers JSON", err=True) raise typer.Exit(code=1) import json + answers_dict = json.loads(answers) grill.run_non_interactive(answers_dict) else: @@ -1714,20 +1710,23 @@ def pocock_dag_kanban( prd: str = typer.Option("PRD.md", "--prd", help="Path to PRD.md"), json_out: bool = typer.Option(False, "--json", help="Output JSON"), docker: bool = typer.Option(False, "--docker", help="Export Docker Compose"), - output: str = typer.Option("docker-compose.dag.yml", "--output", help="Docker Compose output path"), + output: str = typer.Option( + "docker-compose.dag.yml", "--output", help="Docker Compose output path" + ), ): """DAG-based Kanban - parses PRD and creates task execution graph.""" from sin_code_bundle.tools.pocock.dag_kanban import DAGKanban runner = DAGKanban(prd) - order = runner.run() + runner.run() if json_out: typer.echo(runner.to_json()) if docker: try: - import yaml + import yaml # noqa: F401 + runner.export_docker_compose(output) except ImportError: typer.echo("⚠️ PyYAML not installed. Run: pip install pyyaml", err=True) @@ -1737,7 +1736,9 @@ def pocock_dag_kanban( @pocock_app.command("cleanup") def pocock_cleanup(): """Run post-flight cleanup hook (system cleanup after task runs).""" - script_path = Path(__file__).parent.parent.parent / "scripts" / "pocock" / "opencode-cleanup-hook.sh" + script_path = ( + Path(__file__).parent.parent.parent / "scripts" / "pocock" / "opencode-cleanup-hook.sh" + ) if not script_path.exists(): typer.echo("❌ Cleanup script not found. Is the bundle installed correctly?", err=True) raise typer.Exit(code=1) @@ -1752,17 +1753,21 @@ def pocock_cleanup(): @pocock_app.command("safe-start") def pocock_safe_start(): """Start OpenCode with safe environment injection (Zod patch + env substitution).""" - script_path = Path(__file__).parent.parent.parent / "scripts" / "pocock" / "opencode-safe-start.sh" + script_path = ( + Path(__file__).parent.parent.parent / "scripts" / "pocock" / "opencode-safe-start.sh" + ) if not script_path.exists(): typer.echo("❌ Safe-start script not found. Is the bundle installed correctly?", err=True) raise typer.Exit(code=1) # Forward remaining args to the script import sys - args = sys.argv[sys.argv.index("safe-start") + 1:] + + args = sys.argv[sys.argv.index("safe-start") + 1 :] result = subprocess.run(["bash", str(script_path), *args]) raise typer.Exit(code=result.returncode) + if __name__ == "__main__": app() @@ -2006,7 +2011,8 @@ def doctor(root: str = typer.Option(".", help="Project root.")): @sin_code_app.command("run") def sin_code_run( tool: str = typer.Argument( - ..., help="Tool name: discover, execute, map, grasp, scout, harvest, orchestrate, ibd, poc, sckg, adw, oracle, efm" + ..., + help="Tool name: discover, execute, map, grasp, scout, harvest, orchestrate, ibd, poc, sckg, adw, oracle, efm", ), args: list[str] = typer.Argument(default_factory=list, help="Arguments to pass to the tool"), ): @@ -2645,30 +2651,38 @@ def ast_status(): # Source repos are now archived (see DEPRECATED notice in their READMEs). try: from sin_code_bundle.tools.slash.app import app as slash_app + app.add_typer(slash_app, name="slash") except ImportError as exc: + @app.command("slash") - def slash_missing() -> None: + def slash_missing(exc=exc) -> None: """Slash commands (slash module not installed).""" typer.echo(f"[SIN-BUNDLE] slash module unavailable: {exc}", err=True) raise typer.Exit(code=1) + try: from sin_code_bundle.tools.mcp_server_builder.app import app as mcp_server_app + app.add_typer(mcp_server_app, name="mcp-server") except ImportError as exc: + @app.command("mcp-server") - def mcp_server_missing() -> None: + def mcp_server_missing(exc=exc) -> None: """MCP server builder (mcp_server_builder module not installed).""" typer.echo(f"[SIN-BUNDLE] mcp-server module unavailable: {exc}", err=True) raise typer.Exit(code=1) + try: from sin_code_bundle.tools.marketplace.app import app as marketplace_app + app.add_typer(marketplace_app, name="marketplace") except ImportError as exc: + @app.command("marketplace") - def marketplace_missing() -> None: + def marketplace_missing(exc=exc) -> None: """Marketplace (marketplace module not installed).""" typer.echo(f"[SIN-BUNDLE] marketplace module unavailable: {exc}", err=True) raise typer.Exit(code=1) @@ -2733,36 +2747,6 @@ def _forward_security_subcommand(subcommand: str) -> None: raise typer.Exit(code=result.returncode) -@app.command() -def ibd(): - """Intent-Based Diffing (IBD) — thin wrapper around the `ibd` binary.""" - _forward_to_binary("ibd", _NEW_TOOL_BINARIES["ibd"][0]) - - -@app.command() -def poc(): - """Proof-of-Correctness (POC) — thin wrapper around the `poc` binary.""" - _forward_to_binary("poc", _NEW_TOOL_BINARIES["poc"][0]) - - -@app.command() -def adw(): - """Architectural Debt Watchdogs (ADW) — thin wrapper around the `adw` binary.""" - _forward_to_binary("adw", _NEW_TOOL_BINARIES["adw"][0]) - - -@app.command() -def oracle(): - """Verification Oracle — thin wrapper around the `oracle` binary.""" - _forward_to_binary("oracle", _NEW_TOOL_BINARIES["oracle"][0]) - - -@app.command() -def efm(): - """Ephemeral Full-Stack Mocking (EFM) — thin wrapper around the `efm` binary.""" - _forward_to_binary("efm", _NEW_TOOL_BINARIES["efm"][0]) - - @app.command() def forge(): """SIN-Code Forge — intelligent code generation & editing (thin wrapper around the `forge` binary).""" @@ -2878,9 +2862,7 @@ def update_run( results = upd.run_update(core=core, go=go, check=check) if json_out: - typer.echo( - json.dumps([r.__dict__ for r in results], indent=2, default=str) - ) + typer.echo(json.dumps([r.__dict__ for r in results], indent=2, default=str)) else: typer.echo(upd.render_table(results)) failed = [r for r in results if r.status == "failed"] @@ -2914,8 +2896,7 @@ def config_show( { "config": payload, "origins": { - k: {"label": v.label, "path": str(v.path)} - for k, v in origins.items() + k: {"label": v.label, "path": str(v.path)} for k, v in origins.items() }, }, indent=2, @@ -2983,10 +2964,14 @@ def config_path() -> None: lint_app = typer.Typer(help="Lint code with popular linters (ruff, flake8, mypy, eslint, etc.).") app.add_typer(lint_app, name="lint") + @lint_app.command("run") def lint_run( path: str = typer.Argument(".", help="Path to lint."), - tool: str = typer.Option("auto", help="Linter to use: auto, ruff, flake8, mypy, pylint, eslint, golangci-lint, shellcheck."), + tool: str = typer.Option( + "auto", + help="Linter to use: auto, ruff, flake8, mypy, pylint, eslint, golangci-lint, shellcheck.", + ), fix: bool = typer.Option(False, help="Auto-fix issues where supported."), ): """Run a linter on the given path.""" @@ -3008,7 +2993,10 @@ def lint_run( tool = name break else: - typer.echo("[SIN-BUNDLE] No supported linter found on PATH. Install one: ruff, flake8, mypy, pylint, eslint, golangci-lint, shellcheck", err=True) + typer.echo( + "[SIN-BUNDLE] No supported linter found on PATH. Install one: ruff, flake8, mypy, pylint, eslint, golangci-lint, shellcheck", + err=True, + ) raise typer.Exit(code=1) if tool not in linters: @@ -3037,8 +3025,12 @@ def lint_check( available = [] for name, binary in [ - ("ruff", "ruff"), ("flake8", "flake8"), ("mypy", "mypy"), - ("pylint", "pylint"), ("eslint", "eslint"), ("golangci-lint", "golangci-lint"), + ("ruff", "ruff"), + ("flake8", "flake8"), + ("mypy", "mypy"), + ("pylint", "pylint"), + ("eslint", "eslint"), + ("golangci-lint", "golangci-lint"), ("shellcheck", "shellcheck"), ]: if shutil.which(binary): @@ -3069,6 +3061,7 @@ def lint_check( docs_app = typer.Typer(help="Documentation helpers — generate README, API docs, check coverage.") app.add_typer(docs_app, name="docs") + @docs_app.command("generate") def docs_generate( path: str = typer.Argument(".", help="Project path."), @@ -3076,7 +3069,6 @@ def docs_generate( template: str = typer.Option("default", help="Template: default, minimal, full."), ): """Generate a README.md from project metadata.""" - import os import json proj = Path(path) @@ -3223,7 +3215,9 @@ def docs_check( typer.echo(f" README.md: {'✅' if has_readme else '❌'}") typer.echo(f" .doc.md files: {len(doc_md_files)}") typer.echo(f" Python files: {len(py_files)}") - typer.echo(f" Files with docstrings: {docstring_count}/{len(py_files)} ({100*docstring_count//max(len(py_files),1)}%)") + typer.echo( + f" Files with docstrings: {docstring_count}/{len(py_files)} ({100 * docstring_count // max(len(py_files), 1)}%)" + ) if not has_readme: typer.echo(" ⚠️ Missing README.md — run `sin docs generate` to create one.") @@ -3232,14 +3226,14 @@ def docs_check( git_app = typer.Typer(help="Git workflow helpers — status, commit, push, clean branches.") app.add_typer(git_app, name="git") + @git_app.command("status") def git_status( path: str = typer.Argument(".", help="Repository path."), ): """Show git status with a clean summary.""" result = subprocess.run( - ["git", "-C", path, "status", "--short"], - capture_output=True, text=True + ["git", "-C", path, "status", "--short"], capture_output=True, text=True ) if result.returncode != 0: typer.echo(f"[SIN-BUNDLE] Not a git repository: {path}", err=True) @@ -3259,7 +3253,9 @@ def git_status( def git_commit( message: str = typer.Argument(..., help="Commit message.", metavar="MESSAGE"), path: str = typer.Option(".", help="Repository path."), - all: bool = typer.Option(False, "--all", "-a", help="Stage all modified files before committing."), + all: bool = typer.Option( + False, "--all", "-a", help="Stage all modified files before committing." + ), push: bool = typer.Option(False, "--push", help="Push after committing."), ): """Create a git commit with the given message.""" @@ -3270,8 +3266,7 @@ def git_commit( raise typer.Exit(code=1) result = subprocess.run( - ["git", "-C", path, "commit", "-m", message], - capture_output=True, text=True + ["git", "-C", path, "commit", "-m", message], capture_output=True, text=True ) if result.returncode != 0: typer.echo(f"[SIN-BUNDLE] Commit failed: {result.stderr}", err=True) @@ -3290,7 +3285,9 @@ def git_commit( @git_app.command("clean") def git_clean( path: str = typer.Argument(".", help="Repository path."), - dry_run: bool = typer.Option(True, "--dry-run/--no-dry-run", help="Show what would be deleted without deleting."), + dry_run: bool = typer.Option( + True, "--dry-run/--no-dry-run", help="Show what would be deleted without deleting." + ), force: bool = typer.Option(False, "--force", "-f", help="Force delete merged branches."), ): """Clean up merged branches and stale references.""" @@ -3299,14 +3296,15 @@ def git_clean( # List merged branches (excluding current, main, master) result = subprocess.run( - ["git", "-C", path, "branch", "--merged"], - capture_output=True, text=True + ["git", "-C", path, "branch", "--merged"], capture_output=True, text=True ) if result.returncode != 0: typer.echo(f"[SIN-BUNDLE] Failed to list branches: {result.stderr}", err=True) raise typer.Exit(code=1) - branches = [b.strip() for b in result.stdout.splitlines() if b.strip() and not b.startswith("*")] + branches = [ + b.strip() for b in result.stdout.splitlines() if b.strip() and not b.startswith("*") + ] protected = {"main", "master", "develop", "dev"} to_delete = [b for b in branches if b not in protected] @@ -3327,7 +3325,9 @@ def git_clean( return for b in to_delete: - del_result = subprocess.run(["git", "-C", path, "branch", "-d", b], capture_output=True, text=True) + del_result = subprocess.run( + ["git", "-C", path, "branch", "-d", b], capture_output=True, text=True + ) if del_result.returncode == 0: typer.echo(f" ✅ Deleted {b}") else: diff --git a/src/sin_code_bundle/cli_shims/__init__.py b/src/sin_code_bundle/cli_shims/__init__.py index 700a59be..98008698 100644 --- a/src/sin_code_bundle/cli_shims/__init__.py +++ b/src/sin_code_bundle/cli_shims/__init__.py @@ -15,4 +15,5 @@ sin-bash → cli.sin_bash:main sin-search → cli.sin_search:main """ + from __future__ import annotations diff --git a/src/sin_code_bundle/cli_shims/sin_bash.py b/src/sin_code_bundle/cli_shims/sin_bash.py index 20cdc3f6..1a3a4b3b 100644 --- a/src/sin_code_bundle/cli_shims/sin_bash.py +++ b/src/sin_code_bundle/cli_shims/sin_bash.py @@ -12,6 +12,7 @@ $ sin-bash --command "echo hello" {"stdout": "hello\\n", "stderr": "", "returncode": 0, "redacted": true} """ + from __future__ import annotations import argparse diff --git a/src/sin_code_bundle/cli_shims/sin_edit.py b/src/sin_code_bundle/cli_shims/sin_edit.py index 5abd334a..e89f72aa 100644 --- a/src/sin_code_bundle/cli_shims/sin_edit.py +++ b/src/sin_code_bundle/cli_shims/sin_edit.py @@ -12,6 +12,7 @@ $ sin-edit /tmp/hello.py --old 'print("hi")' --new 'print("hello")' {"success": true, "message": "...", "intent": "", "patch": {...}} """ + from __future__ import annotations import argparse diff --git a/src/sin_code_bundle/cli_shims/sin_read.py b/src/sin_code_bundle/cli_shims/sin_read.py index 91dc1af0..6b1038dc 100644 --- a/src/sin_code_bundle/cli_shims/sin_read.py +++ b/src/sin_code_bundle/cli_shims/sin_read.py @@ -16,6 +16,7 @@ $ sin-read sckg://module/foo/callers {"module": "foo", "callers": [...]} """ + from __future__ import annotations import argparse diff --git a/src/sin_code_bundle/cli_shims/sin_search.py b/src/sin_code_bundle/cli_shims/sin_search.py index 4a3625c9..bfb3becb 100644 --- a/src/sin_code_bundle/cli_shims/sin_search.py +++ b/src/sin_code_bundle/cli_shims/sin_search.py @@ -12,6 +12,7 @@ $ sin-search --query "def main" --path ./src --type regex {"results": [{"file": "...", "line": 1, "match": "def main", ...}], ...} """ + from __future__ import annotations import argparse diff --git a/src/sin_code_bundle/cli_shims/sin_write.py b/src/sin_code_bundle/cli_shims/sin_write.py index 2a42f6de..f54fcba1 100644 --- a/src/sin_code_bundle/cli_shims/sin_write.py +++ b/src/sin_code_bundle/cli_shims/sin_write.py @@ -12,6 +12,7 @@ $ sin-write /tmp/hello.py --content 'print("hi")' {"success": true, "path": "/tmp/hello.py", "chars": 12, "verified": true, "backup": null} """ + from __future__ import annotations import argparse diff --git a/src/sin_code_bundle/config.py b/src/sin_code_bundle/config.py index be947275..54b91523 100644 --- a/src/sin_code_bundle/config.py +++ b/src/sin_code_bundle/config.py @@ -21,12 +21,14 @@ import json import os import tomllib -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from typing import Any # External writer; stdlib tomllib is read-only on 3.11+. Import is # deferred to first write so the read path stays import-cheap. + + def _writer(): import tomli_w # type: ignore[import-not-found] @@ -256,8 +258,7 @@ def redact(payload: Any) -> Any: """ if isinstance(payload, dict): return { - k: (REDACTED_PLACEHOLDER if _is_sensitive(k) else redact(v)) - for k, v in payload.items() + k: (REDACTED_PLACEHOLDER if _is_sensitive(k) else redact(v)) for k, v in payload.items() } if isinstance(payload, list): return [redact(v) for v in payload] @@ -265,9 +266,7 @@ def redact(payload: Any) -> Any: # ── Mutators (write back to project TOML) ─────────────────────────────── -def set_value( - key: str, value: str, *, project_path: Path = PROJECT_TOML -) -> Path: +def set_value(key: str, value: str, *, project_path: Path = PROJECT_TOML) -> Path: """Set *key* to *value* in the project-level TOML file. Creates the file (and parents) if missing. *value* is stored as a @@ -282,8 +281,7 @@ def set_value( # Top-level assignment — store as a one-key table only if the value # is a JSON object; otherwise this is ambiguous and we error. raise ValueError( - f"top-level key '{key}' is reserved for tables; " - "use a dotted key like 'tui.theme'" + f"top-level key '{key}' is reserved for tables; use a dotted key like 'tui.theme'" ) bucket = payload.setdefault(section, {}) if not isinstance(bucket, dict): @@ -398,9 +396,7 @@ def merged( # ── Formatting ─────────────────────────────────────────────────────────── -def format_show( - payload: dict[str, Any], origins: dict[str, ConfigSource] -) -> str: +def format_show(payload: dict[str, Any], origins: dict[str, ConfigSource]) -> str: """Render ``sin config show`` output: flat dotted-key list with origin.""" if not payload: return "(no config set)" diff --git a/src/sin_code_bundle/file_ops.py b/src/sin_code_bundle/file_ops.py index 924d7c7f..07eebf73 100644 --- a/src/sin_code_bundle/file_ops.py +++ b/src/sin_code_bundle/file_ops.py @@ -21,6 +21,7 @@ 2. cli/sin_<name>.py — argparse-based CLI shim (see existing examples) 3. pyproject.toml `[project.scripts]` — entry point """ + from __future__ import annotations import json @@ -29,7 +30,6 @@ from pathlib import Path from typing import Any - # ── sin_read ──────────────────────────────────────────────────────────────── diff --git a/src/sin_code_bundle/gitnexus.py b/src/sin_code_bundle/gitnexus.py index 8e7628a3..5e8f3661 100644 --- a/src/sin_code_bundle/gitnexus.py +++ b/src/sin_code_bundle/gitnexus.py @@ -215,7 +215,8 @@ def _query( subcommand: list[str], root: str = ".", env: GitNexusEnv | None = None, - timeout: int = 300, # 300s = 5min for read-only graph queries (should hit npx cache, hence lower than analyze) + # 300s = 5min for read-only graph queries (should hit npx cache, hence lower than analyze) + timeout: int = 300, ) -> str: """Run a read-only GitNexus query command and return stdout.""" env = env or detect_env() diff --git a/src/sin_code_bundle/lsp_backend.py b/src/sin_code_bundle/lsp_backend.py index 3fc2bd73..14a5aa26 100644 --- a/src/sin_code_bundle/lsp_backend.py +++ b/src/sin_code_bundle/lsp_backend.py @@ -285,7 +285,8 @@ def compute_impact( if file and line and column: file_path = ( - (root_path / file) if not Path(file).is_absolute() else Path(file) # type: ignore[arg-type] + # type: ignore[arg-type] + (root_path / file) if not Path(file).is_absolute() else Path(file) ) try: result = asyncio.run(_lsp_impact(root_path, file_path, symbol, line, column)) diff --git a/src/sin_code_bundle/mcp_server.py b/src/sin_code_bundle/mcp_server.py index b2899bb2..bc28002b 100644 --- a/src/sin_code_bundle/mcp_server.py +++ b/src/sin_code_bundle/mcp_server.py @@ -64,7 +64,6 @@ import subprocess import sys from pathlib import Path -from typing import Any try: from mcp.server.fastmcp import FastMCP @@ -76,13 +75,17 @@ # standalone CLI shims (`sin-read`, `sin-write`, `sin-edit`, `sin-bash`, # `sin-search`) can call the SAME implementation. See file_ops.doc.md. from sin_code_bundle.file_ops import ( - sin_read as _sin_read_impl, - sin_write as _sin_write_impl, sin_edit as _sin_edit_impl, - sin_bash as _sin_bash_impl, +) +from sin_code_bundle.file_ops import ( + sin_read as _sin_read_impl, +) +from sin_code_bundle.file_ops import ( sin_search as _sin_search_impl, ) - +from sin_code_bundle.file_ops import ( + sin_write as _sin_write_impl, +) mcp = FastMCP("sin-code-bundle") diff --git a/src/sin_code_bundle/tools/marketplace/app.py b/src/sin_code_bundle/tools/marketplace/app.py index e7a45543..7d8077b5 100644 --- a/src/sin_code_bundle/tools/marketplace/app.py +++ b/src/sin_code_bundle/tools/marketplace/app.py @@ -75,6 +75,7 @@ def marketplace_search( results = catalog.search(query) if json_out: import json as _json + _typer_echo(_json.dumps(results, indent=2)) return if not results: @@ -93,7 +94,7 @@ def marketplace_install( ) -> None: """Install a skill from the catalog.""" from .catalog import Catalog, CatalogError - from .installer import InstallError, Installer + from .installer import Installer, InstallError from .registry import Registry if remote: @@ -138,6 +139,7 @@ def marketplace_list( skills = Registry().list_all() if json_out: import json as _json + _typer_echo(_json.dumps(skills, indent=2)) return if not skills: @@ -179,6 +181,7 @@ def marketplace_update( ) -> None: """Update installed skills (one or all).""" import json as _json + from .updater import Updater updater = Updater() @@ -208,6 +211,7 @@ def marketplace_update( def marketplace_sync() -> None: """Sync catalog with Infra-SIN-OpenCode-Stack.""" import json as _json + from .catalog import Catalog, CatalogError from .registry import Registry diff --git a/src/sin_code_bundle/tools/marketplace/catalog.py b/src/sin_code_bundle/tools/marketplace/catalog.py index 0f454d3a..25256cec 100644 --- a/src/sin_code_bundle/tools/marketplace/catalog.py +++ b/src/sin_code_bundle/tools/marketplace/catalog.py @@ -130,8 +130,7 @@ def search(self, query: str) -> CatalogData: matches: CatalogData = [] for entry in self._entries: text = " ".join( - str(entry.get(k, "")) - for k in ("name", "title", "description", "slug") + str(entry.get(k, "")) for k in ("name", "title", "description", "slug") ).lower() if query_lower in text: matches.append(entry) @@ -176,11 +175,7 @@ def filter_by_category(self, category: str) -> CatalogData: Skills whose ``category`` field equals the query. """ cat_lower = category.lower() - return [ - entry - for entry in self._entries - if entry.get("category", "").lower() == cat_lower - ] + return [entry for entry in self._entries if entry.get("category", "").lower() == cat_lower] def __len__(self) -> int: """Return number of loaded skills.""" diff --git a/src/sin_code_bundle/tools/marketplace/legacy_cli.py b/src/sin_code_bundle/tools/marketplace/legacy_cli.py index 24de6cc9..3184f4ee 100644 --- a/src/sin_code_bundle/tools/marketplace/legacy_cli.py +++ b/src/sin_code_bundle/tools/marketplace/legacy_cli.py @@ -10,9 +10,7 @@ import asyncio import json -import logging from pathlib import Path -from typing import Any import typer from rich.console import Console @@ -20,7 +18,7 @@ # ── Local imports ───────────────────────────────────────────────────────────── from .catalog import Catalog, CatalogError -from .installer import InstallError, Installer +from .installer import Installer, InstallError from .registry import Registry from .updater import Updater @@ -28,10 +26,13 @@ app = typer.Typer(help="SIN Marketplace — manage OpenSIN-Code skills") console = Console() + # ── Helpers ─────────────────────────────────────────────────────────────────── + + def _get_catalog(cache_path: Path | None = None) -> Catalog: """Load or create a catalog instance. - + Args: cache_path: Optional path to local catalog cache. Defaults to ~/.config/opencode/skills_catalog.json. @@ -216,6 +217,7 @@ def sync() -> None: cache_path = Path.home() / ".config" / "opencode" / "skills_catalog.json" cache_path.parent.mkdir(parents=True, exist_ok=True) import json + with cache_path.open("w", encoding="utf-8") as fh: json.dump(catalog.list_skills(), fh, indent=2) console.print(f"[green]Synced {len(catalog)} skills → {cache_path}[/green]") diff --git a/src/sin_code_bundle/tools/marketplace/registry.py b/src/sin_code_bundle/tools/marketplace/registry.py index 59428cc7..8de9386f 100644 --- a/src/sin_code_bundle/tools/marketplace/registry.py +++ b/src/sin_code_bundle/tools/marketplace/registry.py @@ -150,9 +150,7 @@ def get(self, slug: str) -> SkillRecord | None: """ with sqlite3.connect(self.db_path) as conn: conn.row_factory = sqlite3.Row - row = conn.execute( - "SELECT * FROM skills WHERE slug = ?", (slug,) - ).fetchone() + row = conn.execute("SELECT * FROM skills WHERE slug = ?", (slug,)).fetchone() return dict(row) if row else None def list_all(self) -> list[SkillRecord]: @@ -163,9 +161,7 @@ def list_all(self) -> list[SkillRecord]: """ with sqlite3.connect(self.db_path) as conn: conn.row_factory = sqlite3.Row - rows = conn.execute( - "SELECT * FROM skills ORDER BY installed_at" - ).fetchall() + rows = conn.execute("SELECT * FROM skills ORDER BY installed_at").fetchall() return [dict(row) for row in rows] def exists(self, slug: str) -> bool: @@ -178,9 +174,7 @@ def exists(self, slug: str) -> bool: True if the skill is in the registry. """ with sqlite3.connect(self.db_path) as conn: - row = conn.execute( - "SELECT 1 FROM skills WHERE slug = ?", (slug,) - ).fetchone() + row = conn.execute("SELECT 1 FROM skills WHERE slug = ?", (slug,)).fetchone() return row is not None def update_timestamp(self, slug: str) -> bool: @@ -229,9 +223,7 @@ def get_meta(self, key: str) -> str | None: Stored value, or ``None``. """ with sqlite3.connect(self.db_path) as conn: - row = conn.execute( - "SELECT value FROM catalog_meta WHERE key = ?", (key,) - ).fetchone() + row = conn.execute("SELECT value FROM catalog_meta WHERE key = ?", (key,)).fetchone() return row[0] if row else None def clear(self) -> None: diff --git a/src/sin_code_bundle/tools/marketplace/server.py b/src/sin_code_bundle/tools/marketplace/server.py index cf236847..b8f4816e 100644 --- a/src/sin_code_bundle/tools/marketplace/server.py +++ b/src/sin_code_bundle/tools/marketplace/server.py @@ -11,13 +11,12 @@ import json import logging from pathlib import Path -from typing import Any from fastmcp import FastMCP # ── Local imports ───────────────────────────────────────────────────────────── from .catalog import Catalog, CatalogError -from .installer import InstallError, Installer +from .installer import Installer, InstallError from .registry import Registry from .updater import Updater @@ -27,7 +26,10 @@ # ── MCP Server ─────────────────────────────────────────────────────────────── mcp = FastMCP("sin-marketplace") + # ── Helpers ─────────────────────────────────────────────────────────────────── + + async def _get_catalog() -> Catalog: """Load catalog from remote or local cache.""" catalog = Catalog() @@ -91,7 +93,9 @@ async def marketplace_install(slug: str) -> str: registry = Registry() registry.install(record) - return json.dumps({"success": True, "slug": slug, "destination": record["destination"]}, indent=2) + return json.dumps( + {"success": True, "slug": slug, "destination": record["destination"]}, indent=2 + ) @mcp.tool() diff --git a/src/sin_code_bundle/tools/marketplace/updater.py b/src/sin_code_bundle/tools/marketplace/updater.py index 2520f54c..426181db 100644 --- a/src/sin_code_bundle/tools/marketplace/updater.py +++ b/src/sin_code_bundle/tools/marketplace/updater.py @@ -71,7 +71,7 @@ def update(self, slug: str) -> dict[str, Any]: try: origin = repo.remotes.origin - fetch_info = origin.fetch() + origin.fetch() local_sha = repo.head.commit.hexsha remote_sha = origin.refs[repo.active_branch.name].commit.hexsha except (AttributeError, IndexError, git.GitCommandError) as exc: @@ -139,7 +139,12 @@ def check_status(self, slug: str) -> dict[str, Any]: local_sha = repo.head.commit.hexsha remote_sha = origin.refs[repo.active_branch.name].commit.hexsha except (AttributeError, IndexError, git.GitCommandError) as exc: - return {"slug": slug, "behind": False, "commits_behind": 0, "message": f"Fetch failed: {exc}"} + return { + "slug": slug, + "behind": False, + "commits_behind": 0, + "message": f"Fetch failed: {exc}", + } if local_sha == remote_sha: return {"slug": slug, "behind": False, "commits_behind": 0, "message": "Up to date"} diff --git a/src/sin_code_bundle/tools/mcp_server_builder/app.py b/src/sin_code_bundle/tools/mcp_server_builder/app.py index a721a66f..1f16592e 100644 --- a/src/sin_code_bundle/tools/mcp_server_builder/app.py +++ b/src/sin_code_bundle/tools/mcp_server_builder/app.py @@ -20,17 +20,15 @@ from __future__ import annotations import json -import shutil -import sys from pathlib import Path from typing import Optional import typer from .publisher import Publisher -from .registrar import McpServerEntry, Registrar, build_local_entry -from .scaffolder import ScaffoldSpec, Scaffolder -from .templates import TemplateEngine, list_templates +from .registrar import McpServerEntry, Registrar +from .scaffolder import Scaffolder, ScaffoldSpec +from .templates import list_templates from .test_gen import TestGenerator from .tool_adder import ToolAdder, ToolSpec from .validator import Validator @@ -82,7 +80,9 @@ def mcp_scaffold( target: Path = typer.Option(Path("./out"), "--target", help="Output directory."), version: str = typer.Option("0.1.0", "--version", help="Initial version."), author: str = typer.Option( - "OpenSIN-Code <contact@opensincode.org>", "--author", help="Author for pyproject/package.json." + "OpenSIN-Code <contact@opensincode.org>", + "--author", + help="Author for pyproject/package.json.", ), ) -> None: """Scaffold a new MCP server from a template.""" @@ -112,9 +112,7 @@ def mcp_add_tool( Path("mcp_server.py"), "--server", help="Path to mcp_server.py." ), description: str = typer.Option("", "--description", "-d", help="Tool docstring."), - params: str = typer.Option( - "", "--params", help="JSON list of [name, type, default] tuples." - ), + params: str = typer.Option("", "--params", help="JSON list of [name, type, default] tuples."), body: str = typer.Option('result = {"ok": True}', "--body", help="Python body."), test_path: Optional[Path] = typer.Option(None, "--test", help="Test file to append to."), ) -> None: @@ -240,7 +238,9 @@ def mcp_validate( @app.command("publish") def mcp_publish( path: Path = typer.Argument(Path("."), help="MCP server project root."), - template: str = typer.Option("python-fastmcp", "--template", "-t", help="python-fastmcp | node-mcp | go-mcp"), + template: str = typer.Option( + "python-fastmcp", "--template", "-t", help="python-fastmcp | node-mcp | go-mcp" + ), test: bool = typer.Option(False, "--test", help="Publish to TestPyPI instead of PyPI."), skip_build: bool = typer.Option(False, "--skip-build", help="Skip `python -m build`."), registry: str = typer.Option("https://registry.npmjs.org/", "--registry", help="npm registry."), @@ -274,7 +274,9 @@ def mcp_publish( @app.command("audit") def mcp_audit( path: Path = typer.Argument(Path("."), help="MCP server project root."), - profile: str = typer.Option("QUICK", "--profile", help="ceo-audit profile (QUICK|FULL|SECURITY|RELEASE)."), + profile: str = typer.Option( + "QUICK", "--profile", help="ceo-audit profile (QUICK|FULL|SECURITY|RELEASE)." + ), grade: str = typer.Option("B", "--grade", help="Minimum grade gate (A|B|C)."), json_out: bool = typer.Option(False, "--json", help="JSON output."), ) -> None: @@ -287,7 +289,9 @@ def mcp_audit( typer.echo(json.dumps(report.to_dict(), indent=2)) else: if report.ok: - typer.echo(f"[OK] {report.project} {report.grade} ({report.gates_passed}/{report.gates_total})") + typer.echo( + f"[OK] {report.project} {report.grade} ({report.gates_passed}/{report.gates_total})" + ) else: typer.echo(f"[FAIL] {report.project} {report.grade}", err=True) if not report.ok: diff --git a/src/sin_code_bundle/tools/mcp_server_builder/auditor.py b/src/sin_code_bundle/tools/mcp_server_builder/auditor.py index 9210519a..7632aa0b 100644 --- a/src/sin_code_bundle/tools/mcp_server_builder/auditor.py +++ b/src/sin_code_bundle/tools/mcp_server_builder/auditor.py @@ -76,16 +76,12 @@ def audit(self, project_dir: Path) -> AuditReport: check=False, ) if install.returncode != 0: - return self._degraded_report( - name, install.stderr or "install failed" - ) + return self._degraded_report(name, install.stderr or "install failed") except (FileNotFoundError, OSError) as exc: return self._degraded_report(name, f"pip not available: {exc}") if shutil.which("sin") is None: - return self._degraded_report( - name, "sin CLI not available and auto-install failed" - ) + return self._degraded_report(name, "sin CLI not available and auto-install failed") proc = subprocess.run( [ diff --git a/src/sin_code_bundle/tools/mcp_server_builder/registrar.py b/src/sin_code_bundle/tools/mcp_server_builder/registrar.py index aeb06b0d..a36d0b78 100644 --- a/src/sin_code_bundle/tools/mcp_server_builder/registrar.py +++ b/src/sin_code_bundle/tools/mcp_server_builder/registrar.py @@ -181,9 +181,7 @@ def _strip_jsonc(text: str) -> str: return text -def build_local_entry( - name: str, command: list[str], env: dict | None = None -) -> McpServerEntry: +def build_local_entry(name: str, command: list[str], env: dict | None = None) -> McpServerEntry: """Helper: build a `type: local` entry with `command` as a list of strings.""" return McpServerEntry( name=name, diff --git a/src/sin_code_bundle/tools/mcp_server_builder/scaffolder.py b/src/sin_code_bundle/tools/mcp_server_builder/scaffolder.py index aa1e9057..d175c7ea 100644 --- a/src/sin_code_bundle/tools/mcp_server_builder/scaffolder.py +++ b/src/sin_code_bundle/tools/mcp_server_builder/scaffolder.py @@ -54,8 +54,7 @@ class ScaffoldSpec: def __post_init__(self) -> None: if self.template not in TEMPLATE_REGISTRY: raise ValueError( - f"Unknown template: {self.template!r}. " - f"Choose from: {sorted(TEMPLATE_REGISTRY)}" + f"Unknown template: {self.template!r}. Choose from: {sorted(TEMPLATE_REGISTRY)}" ) if not self.tools: self.tools = ["ping"] # Always scaffold at least one tool. @@ -149,11 +148,7 @@ def scaffold(self, target_dir: str | Path, spec: ScaffoldSpec) -> dict[str, Any] def dry_run(self, spec: ScaffoldSpec) -> dict[str, Any]: """Return a summary of what would be created without writing anything.""" template_dir = self.engine.get_template_dir(spec.template) - files = [ - str(p.relative_to(template_dir)) - for p in template_dir.rglob("*") - if p.is_file() - ] + files = [str(p.relative_to(template_dir)) for p in template_dir.rglob("*") if p.is_file()] return { "target": f"<unsaved>/{spec.slug}", "template": spec.template, diff --git a/src/sin_code_bundle/tools/mcp_server_builder/templates.py b/src/sin_code_bundle/tools/mcp_server_builder/templates.py index 9e934a13..af4f5be3 100644 --- a/src/sin_code_bundle/tools/mcp_server_builder/templates.py +++ b/src/sin_code_bundle/tools/mcp_server_builder/templates.py @@ -107,9 +107,7 @@ def render_to_string( def iter_files(self, template_name: str) -> Iterable[Path]: """Yield every file in a template (used by the scaffolder).""" - return iter( - p for p in self.get_template_dir(template_name).rglob("*") if p.is_file() - ) + return iter(p for p in self.get_template_dir(template_name).rglob("*") if p.is_file()) def list_templates() -> list[dict[str, str]]: diff --git a/src/sin_code_bundle/tools/mcp_server_builder/templates/python-fastmcp/src/{{ pkg }}/mcp_server.py b/src/sin_code_bundle/tools/mcp_server_builder/templates/python-fastmcp/src/{{ pkg }}/mcp_server.py index 9a0c0f09..0b0e0a7e 100644 --- a/src/sin_code_bundle/tools/mcp_server_builder/templates/python-fastmcp/src/{{ pkg }}/mcp_server.py +++ b/src/sin_code_bundle/tools/mcp_server_builder/templates/python-fastmcp/src/{{ pkg }}/mcp_server.py @@ -18,9 +18,11 @@ def _json(payload: Any) -> str: return json.dumps(payload, indent=2, default=str) -{% for tool in tools %} +{ % for tool in tools % } + + @mcp.tool() -def {{ tool }}() -> str: +def {{tool}}() -> str: """ {{ tool }} — generated tool. @@ -29,7 +31,7 @@ def {{ tool }}() -> str: """ result = {"tool": "{{ tool }}", "ok": True} return _json(result) -{% endfor %} +{ % endfor % } def main() -> None: diff --git a/src/sin_code_bundle/tools/mcp_server_builder/templates/python-fastmcp/tests/test_mcp_server.py b/src/sin_code_bundle/tools/mcp_server_builder/templates/python-fastmcp/tests/test_mcp_server.py index 27147a4e..5e93ed3b 100644 --- a/src/sin_code_bundle/tools/mcp_server_builder/templates/python-fastmcp/tests/test_mcp_server.py +++ b/src/sin_code_bundle/tools/mcp_server_builder/templates/python-fastmcp/tests/test_mcp_server.py @@ -7,21 +7,23 @@ import pytest -from {{ pkg }}.mcp_server import {% for tool in tools %}{{ tool }}{% if not loop.last %}, {% endif %}{% endfor %} +from {{pkg }}.mcp_server import { % for tool in tools % }{{ tool }}{ % if not loop.last % }, { % endif % }{ % endfor % } -{% for tool in tools %} -class Test{{ tool | capitalize }}: - def test_{{ tool }}_returns_dict(self): +{% for tool in tools % } + + +class Test{{tool | capitalize}}: + def test_{{tool}}_returns_dict(self): """`{{ tool }}` must return a JSON object.""" - result = {{ tool }}() + result = {{tool}}() data = json.loads(result) assert isinstance(data, dict) assert data.get("ok") is True - def test_{{ tool }}_has_tool_field(self): + def test_{{tool}}_has_tool_field(self): """`{{ tool }}` should include its tool name in the result.""" - result = {{ tool }}() + result = {{tool}}() data = json.loads(result) assert data.get("tool") == "{{ tool }}" -{% endfor %} +{% endfor % } diff --git a/src/sin_code_bundle/tools/mcp_server_builder/test_gen.py b/src/sin_code_bundle/tools/mcp_server_builder/test_gen.py index e55803c7..a06ff45e 100644 --- a/src/sin_code_bundle/tools/mcp_server_builder/test_gen.py +++ b/src/sin_code_bundle/tools/mcp_server_builder/test_gen.py @@ -76,12 +76,8 @@ def generate( params = self._extract_params(text, tool_name) test_code = self._render_tests(tool_name, params) if output_path is not None: - existing = ( - output_path.read_text(encoding="utf-8") if output_path.is_file() else "" - ) - output_path.write_text( - existing.rstrip() + "\n" + test_code, encoding="utf-8" - ) + existing = output_path.read_text(encoding="utf-8") if output_path.is_file() else "" + output_path.write_text(existing.rstrip() + "\n" + test_code, encoding="utf-8") return test_code # ── Internals ────────────────────────────────────── diff --git a/src/sin_code_bundle/tools/mcp_server_builder/tool_adder.py b/src/sin_code_bundle/tools/mcp_server_builder/tool_adder.py index 3f63dbec..8e6219ac 100644 --- a/src/sin_code_bundle/tools/mcp_server_builder/tool_adder.py +++ b/src/sin_code_bundle/tools/mcp_server_builder/tool_adder.py @@ -87,9 +87,7 @@ def add_to_python(self, server_path: Path, spec: ToolSpec) -> str: if insertion_point is None: # No existing @mcp.tool() — insert after the `mcp = FastMCP(...)` line. insertion_point = self._find_mcp_declaration(existing) - new_content = ( - existing[:insertion_point] + new_tool + "\n\n" + existing[insertion_point:] - ) + new_content = existing[:insertion_point] + new_tool + "\n\n" + existing[insertion_point:] server_path.write_text(new_content, encoding="utf-8") return new_tool diff --git a/src/sin_code_bundle/tools/pocock/__init__.py b/src/sin_code_bundle/tools/pocock/__init__.py index d246faf4..323387ae 100644 --- a/src/sin_code_bundle/tools/pocock/__init__.py +++ b/src/sin_code_bundle/tools/pocock/__init__.py @@ -9,9 +9,9 @@ - Runtime stability utilities (zod-patch, safe-start, cleanup-hook) """ +from .dag_kanban import DAGKanban, run_dag_kanban from .grill_me import GrillMe, run_grill_me from .tdd_enforcer import TDDEnforcer, run_tdd_enforcer -from .dag_kanban import DAGKanban, run_dag_kanban __all__ = [ "GrillMe", diff --git a/src/sin_code_bundle/tools/pocock/dag_kanban.py b/src/sin_code_bundle/tools/pocock/dag_kanban.py index dc35cbde..92b4498b 100644 --- a/src/sin_code_bundle/tools/pocock/dag_kanban.py +++ b/src/sin_code_bundle/tools/pocock/dag_kanban.py @@ -10,20 +10,19 @@ from __future__ import annotations +import argparse +import json import os import re -import sys -import json -import argparse -from pathlib import Path -from typing import Optional from collections import defaultdict, deque -from dataclasses import dataclass, field, asdict +from dataclasses import asdict, dataclass, field +from typing import Optional @dataclass class TaskNode: """A single task in the DAG.""" + id: str label: str description: str @@ -44,7 +43,7 @@ def __init__(self, prd_path: str = "PRD.md"): def parse_prd(self) -> bool: """Parse PRD.md and extract task slices. - + Returns: True if tasks were found, False otherwise. """ @@ -77,15 +76,13 @@ def parse_prd(self) -> bool: if not matches: # Look for any list items under "Technische Spezifikation" or "Architekturschnitte" section_match = re.search( - r"##\s*Technische Spezifikation.*?(?=##|$)", - content, - re.DOTALL | re.IGNORECASE + r"##\s*Technische Spezifikation.*?(?=##|$)", content, re.DOTALL | re.IGNORECASE ) if section_match: section_content = section_match.group(0) matches = re.findall(r"- \[\s*\]\s*(.+)", section_content) if matches: - matches = [(f"Task {i+1}", m) for i, m in enumerate(matches)] + matches = [(f"Task {i + 1}", m) for i, m in enumerate(matches)] if not matches: print(f"⚠️ Keine verwertbaren Arbeitsschritte in {self.prd_path} gefunden.") @@ -94,10 +91,7 @@ def parse_prd(self) -> bool: for idx, (task_label, desc) in enumerate(matches): task_id = task_label.strip().lower().replace(" ", "_") self.tasks[task_id] = TaskNode( - id=task_id, - label=task_label.strip(), - description=desc.strip(), - dependencies=[] + id=task_id, label=task_label.strip(), description=desc.strip(), dependencies=[] ) # Build automatic sequential dependencies: Slice N depends on Slice N-1 @@ -124,10 +118,10 @@ def _build_graph(self) -> None: def get_execution_order(self) -> list[str]: """Get topological sort using Kahn's algorithm. - + Returns: List of task IDs in execution order. - + Raises: ValueError: If circular dependencies are detected. """ @@ -149,7 +143,7 @@ def get_execution_order(self) -> list[str]: def get_parallel_groups(self) -> list[list[str]]: """Get tasks grouped by parallel execution groups. - + Returns: List of groups where tasks within each group can run in parallel. """ @@ -165,7 +159,8 @@ def get_parallel_groups(self) -> list[list[str]]: while len(sum(groups, [])) < len(self.tasks): # Find all tasks with in-degree 0 current_group = [ - t_id for t_id in self.tasks + t_id + for t_id in self.tasks if in_degree_copy[t_id] == 0 and t_id not in sum(groups, []) ] if not current_group: @@ -180,7 +175,7 @@ def get_parallel_groups(self) -> list[list[str]]: def assign_executors(self, executor_pattern: str = "agent-{}") -> None: """Assign executors to tasks for parallel execution. - + Args: executor_pattern: Pattern for executor naming (e.g., "agent-{}") """ @@ -192,7 +187,7 @@ def assign_executors(self, executor_pattern: str = "agent-{}") -> None: def run(self) -> list[str]: """Execute the DAG analysis and display results. - + Returns: List of task IDs in execution order. """ @@ -242,15 +237,23 @@ def _display_results(self, order: list[str]) -> None: def to_json(self) -> str: """Export DAG to JSON.""" - return json.dumps({ - "tasks": {t_id: asdict(t) for t_id, t in self.tasks.items()}, - "execution_order": self.get_execution_order() if self.tasks else [], - "parallel_groups": [[t_id for t_id in group] for group in self.get_parallel_groups()] if self.tasks else [], - }, indent=2, ensure_ascii=False) + return json.dumps( + { + "tasks": {t_id: asdict(t) for t_id, t in self.tasks.items()}, + "execution_order": self.get_execution_order() if self.tasks else [], + "parallel_groups": [ + [t_id for t_id in group] for group in self.get_parallel_groups() + ] + if self.tasks + else [], + }, + indent=2, + ensure_ascii=False, + ) def export_docker_compose(self, output_path: str = "docker-compose.dag.yml") -> str: """Generate Docker Compose file for parallel execution. - + Returns: Path to generated docker-compose file. """ @@ -272,8 +275,11 @@ def export_docker_compose(self, output_path: str = "docker-compose.dag.yml") -> ], "depends_on": { self.tasks[dep].container: {"condition": "service_completed_successfully"} - for dep in task.dependencies if dep in self.tasks - } if task.dependencies else {}, + for dep in task.dependencies + if dep in self.tasks + } + if task.dependencies + else {}, } compose = { @@ -283,32 +289,35 @@ def export_docker_compose(self, output_path: str = "docker-compose.dag.yml") -> with open(output_path, "w", encoding="utf-8") as f: import yaml + yaml.dump(compose, f, default_flow_style=False, sort_keys=False) print(f"🐳 Docker Compose generiert: {output_path}") return output_path -def run_dag_kanban(prd_path: str = "PRD.md", output_json: bool = False, export_docker: bool = False) -> list[str]: +def run_dag_kanban( + prd_path: str = "PRD.md", output_json: bool = False, export_docker: bool = False +) -> list[str]: """Convenience function to run DAG Kanban. - + Args: prd_path: Path to PRD.md file output_json: Output JSON instead of human-readable export_docker: Export Docker Compose file - + Returns: List of task IDs in execution order """ runner = DAGKanban(prd_path) order = runner.run() - + if output_json: print(runner.to_json()) - + if export_docker: runner.export_docker_compose() - + return order @@ -324,7 +333,7 @@ def main(): %(prog)s --json # Output JSON %(prog)s --docker # Export docker-compose.dag.yml %(prog)s --prd PRD.md --json --docker - """ + """, ) parser.add_argument("--prd", default="PRD.md", help="Pfad zur PRD.md") parser.add_argument("--json", action="store_true", help="JSON-Output") @@ -334,7 +343,7 @@ def main(): args = parser.parse_args() runner = DAGKanban(args.prd) - order = runner.run() + runner.run() if args.json: print(runner.to_json()) diff --git a/src/sin_code_bundle/tools/pocock/grill_me.py b/src/sin_code_bundle/tools/pocock/grill_me.py index a87d24eb..193ef912 100644 --- a/src/sin_code_bundle/tools/pocock/grill_me.py +++ b/src/sin_code_bundle/tools/pocock/grill_me.py @@ -8,18 +8,18 @@ from __future__ import annotations +import argparse +import json import os import sys -import json -import argparse -from pathlib import Path +from dataclasses import asdict, dataclass, field from typing import Optional -from dataclasses import dataclass, field, asdict @dataclass class GrillQuestion: """A single socratic question in the alignment process.""" + question: str category: str answer: Optional[str] = None @@ -28,6 +28,7 @@ class GrillQuestion: @dataclass class GrillSession: """Complete grill session with questions, answers, and generated PRD.""" + target_goal: str questions: list[GrillQuestion] = field(default_factory=list) context: dict = field(default_factory=dict) @@ -37,43 +38,42 @@ class GrillSession: DEFAULT_QUESTIONS = [ GrillQuestion( question="Was ist das konkrete Problem, das gelöst werden soll? (Nicht die Lösung, das Problem)", - category="problem_definition" + category="problem_definition", ), GrillQuestion( question="Wer sind die primären Nutzer/Stakeholder dieses Features?", - category="stakeholders" + category="stakeholders", ), GrillQuestion( question="Was sind die harten Constraints (Budget, Zeit, Technik, Compliance)?", - category="constraints" + category="constraints", ), GrillQuestion( question="Welche Edge-Cases und Fehlerzustände müssen explizit behandelt werden?", - category="edge_cases" + category="edge_cases", ), GrillQuestion( question="Wie sieht der Migrationspfad aus, falls dieses Feature später ersetzt werden muss?", - category="migration" + category="migration", ), GrillQuestion( - question="Was sind die Systemgrenzen? Was gehört NICHT in den Scope?", - category="boundaries" + question="Was sind die Systemgrenzen? Was gehört NICHT in den Scope?", category="boundaries" ), GrillQuestion( question="Wie wird Erfolg gemessen? (Konkrete Metriken, nicht 'es funktioniert')", - category="success_metrics" + category="success_metrics", ), GrillQuestion( question="Welche bestehenden Systeme/Module müssen integriert oder angepasst werden?", - category="integration" + category="integration", ), GrillQuestion( question="Was ist der Rollback-Plan, wenn das Feature in Produktion Probleme macht?", - category="rollback" + category="rollback", ), GrillQuestion( question="Gibt es Abhängigkeiten zu externen APIs/Diensten? Wie ist deren SLA?", - category="dependencies" + category="dependencies", ), ] @@ -99,14 +99,16 @@ def run_interactive(self) -> GrillSession: print(f"\n{'─' * 70}") print(f"❓ FRAGE {i}/{len(self.session.questions)} [{q.category.upper()}]") print(f" {q.question}") - + while True: try: ans = input(" 👉 Deine Antwort: ").strip() if ans: q.answer = ans break - print(" ❌ Ein leerer Kontext bricht das Alignment ab. Bitte antworte präzise.") + print( + " ❌ Ein leerer Kontext bricht das Alignment ab. Bitte antworte präzise." + ) except (EOFError, KeyboardInterrupt): print("\n\n⚠️ Abgebrochen. Keine PRD generiert.") sys.exit(1) @@ -127,19 +129,19 @@ def run_non_interactive(self, answers: dict[str, str]) -> GrillSession: def generate_prd(self, output_path: Optional[str] = None) -> str: """Generate the Product Requirements Document from the grill session.""" prd_content = self._build_prd_content() - + if output_path is None: output_path = os.path.join(os.getcwd(), "PRD.md") - + self.session.prd_path = output_path - + with open(output_path, "w", encoding="utf-8") as f: f.write(prd_content) - + print(f"\n{'=' * 70}") print(f"🎉 PRD ERFOLGREICH GENERIERT: {output_path}") print(f"{'=' * 70}\n") - + return output_path def _build_prd_content(self) -> str: @@ -160,45 +162,57 @@ def _build_prd_content(self) -> str: lines.append(f"**Antwort:** {q.answer or '*nicht beantwortet*'}") lines.append("") - lines.extend([ - "## Technische Spezifikation", - "", - "### Vertikale Architekturschnitte (Tracer Bullets)", - "- [ ] Slice 1: Datenbankschema & API-Schnittstelle", - "- [ ] Slice 2: Integrations-Route & Validierungs-Logik", - "- [ ] Slice 3: Frontend-Hook / CLI-Integration", - "", - "## Abnahmekriterien (Definition of Done)", - "1. Alle Unittests laufen im TDD-Modus grün (Red-Green-Refactor).", - "2. Typenprüfung über statische Analyse ist fehlerfrei.", - "3. Code Review durch mindestens einen weiteren Agenten erfolgt.", - "4. PRD-Fragen sind alle beantwortet und dokumentiert.", - "", - "## Risiken & Offene Fragen", - "| Risiko | Wahrscheinlichkeit | Impact | Mitigation |", - "|--------|-------------------|--------|------------|", - "| TBD | TBD | TBD | TBD |", - "", - "---", - f"*Generiert durch OpenSIN Grill-Me Tool am {self._get_timestamp()}*", - ]) + lines.extend( + [ + "## Technische Spezifikation", + "", + "### Vertikale Architekturschnitte (Tracer Bullets)", + "- [ ] Slice 1: Datenbankschema & API-Schnittstelle", + "- [ ] Slice 2: Integrations-Route & Validierungs-Logik", + "- [ ] Slice 3: Frontend-Hook / CLI-Integration", + "", + "## Abnahmekriterien (Definition of Done)", + "1. Alle Unittests laufen im TDD-Modus grün (Red-Green-Refactor).", + "2. Typenprüfung über statische Analyse ist fehlerfrei.", + "3. Code Review durch mindestens einen weiteren Agenten erfolgt.", + "4. PRD-Fragen sind alle beantwortet und dokumentiert.", + "", + "## Risiken & Offene Fragen", + "| Risiko | Wahrscheinlichkeit | Impact | Mitigation |", + "|--------|-------------------|--------|------------|", + "| TBD | TBD | TBD | TBD |", + "", + "---", + f"*Generiert durch OpenSIN Grill-Me Tool am {self._get_timestamp()}*", + ] + ) return "\n".join(lines) def _get_timestamp(self) -> str: from datetime import datetime + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") def to_json(self) -> str: """Serialize session to JSON.""" - return json.dumps({ - "target_goal": self.session.target_goal, - "questions": [asdict(q) for q in self.session.questions], - "prd_path": self.session.prd_path, - }, indent=2, ensure_ascii=False) - - -def run_grill_me(goal: str, output: Optional[str] = None, non_interactive: bool = False, answers: Optional[dict] = None) -> str: + return json.dumps( + { + "target_goal": self.session.target_goal, + "questions": [asdict(q) for q in self.session.questions], + "prd_path": self.session.prd_path, + }, + indent=2, + ensure_ascii=False, + ) + + +def run_grill_me( + goal: str, + output: Optional[str] = None, + non_interactive: bool = False, + answers: Optional[dict] = None, +) -> str: """Convenience function to run grill-me and return PRD path.""" grill = GrillMe(goal) if non_interactive and answers: @@ -218,11 +232,13 @@ def main(): %(prog)s "Neue Authentifizierungs-API implementieren" %(prog)s "Payment-Integration" --output docs/PRD.md %(prog)s "Feature X" --non-interactive --answers '{"problem_definition": "...", "stakeholders": "..."}' - """ + """, ) parser.add_argument("goal", help="Das Entwicklungsziel / Feature-Beschreibung") parser.add_argument("-o", "--output", help="Pfad für die generierte PRD.md") - parser.add_argument("--non-interactive", action="store_true", help="Nicht-interaktiver Modus (für CI/CD)") + parser.add_argument( + "--non-interactive", action="store_true", help="Nicht-interaktiver Modus (für CI/CD)" + ) parser.add_argument("--answers", help="JSON-String mit Antworten für non-interactive Modus") parser.add_argument("--json", action="store_true", help="Session als JSON auf stdout ausgeben") @@ -235,6 +251,7 @@ def main(): print("❌ --non-interactive erfordert --answers JSON", file=sys.stderr) sys.exit(1) import json + answers_dict = json.loads(args.answers) grill.run_non_interactive(answers_dict) else: diff --git a/src/sin_code_bundle/tools/pocock/tdd_enforcer.py b/src/sin_code_bundle/tools/pocock/tdd_enforcer.py index 52eb95a3..84d2b131 100644 --- a/src/sin_code_bundle/tools/pocock/tdd_enforcer.py +++ b/src/sin_code_bundle/tools/pocock/tdd_enforcer.py @@ -9,14 +9,13 @@ from __future__ import annotations -import sys -import subprocess -import os -import json import argparse -from pathlib import Path -from typing import Optional +import json +import os +import subprocess +import sys from datetime import datetime +from typing import Optional class TDDEnforcer: @@ -37,11 +36,7 @@ def run_tests(self) -> tuple[int, str]: """Execute test suite and return (exit_code, output).""" print(f"🧪 Führe Tests aus: {self.test_cmd}") result = subprocess.run( - self.test_cmd, - shell=True, - capture_output=True, - text=True, - timeout=60 + self.test_cmd, shell=True, capture_output=True, text=True, timeout=60 ) output = result.stdout + result.stderr return result.returncode, output @@ -99,7 +94,7 @@ def enforce(self) -> dict: "status": "allowed", "phase": "green", "message": "GREEN-Phase: Refactoring erlaubt", - "file": self.file_to_edit + "file": self.file_to_edit, } # Run tests to check current state @@ -117,7 +112,7 @@ def enforce(self) -> dict: "phase": "green", "message": "GREEN-Phase erreicht: Refactoring erlaubt", "file": self.file_to_edit, - "test_output": output + "test_output": output, } else: # Already in GREEN or unknown @@ -129,7 +124,7 @@ def enforce(self) -> dict: "phase": "green", "message": "GREEN-Phase: Refactoring erlaubt", "file": self.file_to_edit, - "test_output": output + "test_output": output, } else: @@ -143,7 +138,7 @@ def enforce(self) -> dict: "phase": "red", "message": "RED-Phase: Implementierung erlaubt", "file": self.file_to_edit, - "test_output": output + "test_output": output, } else: # Need to start RED phase @@ -158,7 +153,7 @@ def enforce(self) -> dict: "phase": "red", "message": "RED-Phase: Implementierung erlaubt", "file": self.file_to_edit, - "test_output": output + "test_output": output, } def _create_lock(self) -> None: @@ -214,7 +209,7 @@ def main(): %(prog)s "npm test" "src/components/Button.tsx" %(prog)s "pytest tests/" "src/api.py" --reset %(prog)s "pytest tests/" "src/api.py" --check - """ + """, ) parser.add_argument("test_cmd", help="Test command (e.g., 'pytest tests/')") parser.add_argument("file_to_edit", help="File to edit") @@ -236,7 +231,7 @@ def main(): "is_locked": enforcer.is_locked(), "phase": enforcer._get_current_phase(), "file": args.file_to_edit, - "lock_file": enforcer.lock_file + "lock_file": enforcer.lock_file, } if args.json: print(json.dumps(result, indent=2, ensure_ascii=False)) diff --git a/src/sin_code_bundle/tools/slash/__init__.py b/src/sin_code_bundle/tools/slash/__init__.py index 7988f751..b52bada4 100644 --- a/src/sin_code_bundle/tools/slash/__init__.py +++ b/src/sin_code_bundle/tools/slash/__init__.py @@ -24,11 +24,10 @@ "app", ] -from .commands import BUILTIN_COMMANDS, get_command_help -from .dispatcher import CommandDispatcher, DispatchResult -from .executor import CommandExecutor -from .parser import ParsedCommand, SlashParser -from .registry import CommandRegistry, CustomCommand - # `app` is the Typer subcommand for `sin slash ...` from .app import app # noqa: E402 +from .commands import BUILTIN_COMMANDS, get_command_help # noqa: F401 +from .dispatcher import CommandDispatcher, DispatchResult # noqa: F401 +from .executor import CommandExecutor # noqa: F401 +from .parser import ParsedCommand, SlashParser # noqa: F401 +from .registry import CommandRegistry, CustomCommand # noqa: F401 diff --git a/src/sin_code_bundle/tools/slash/app.py b/src/sin_code_bundle/tools/slash/app.py index f8a819e2..63d89a68 100644 --- a/src/sin_code_bundle/tools/slash/app.py +++ b/src/sin_code_bundle/tools/slash/app.py @@ -16,8 +16,6 @@ from __future__ import annotations import json -import sys -from typing import Optional import typer @@ -87,9 +85,7 @@ def slash_register( name: str = typer.Argument(..., help="Command name (no leading slash)."), description: str = typer.Argument(..., help="Human-readable description."), action: str = typer.Argument(..., help="Action to execute."), - action_type: str = typer.Option( - "shell", "--type", help="Action type: shell | sin | script." - ), + action_type: str = typer.Option("shell", "--type", help="Action type: shell | sin | script."), ) -> None: """Register a new custom slash command.""" registry = _registry() diff --git a/src/sin_code_bundle/tools/slash/cli.py b/src/sin_code_bundle/tools/slash/cli.py index 43cb83d5..277f68a9 100644 --- a/src/sin_code_bundle/tools/slash/cli.py +++ b/src/sin_code_bundle/tools/slash/cli.py @@ -8,12 +8,11 @@ import json import sys -from typing import Optional import click from rich.console import Console -from rich.table import Table from rich.panel import Panel +from rich.table import Table from sin_code_bundle.tools.slash.dispatcher import CommandDispatcher from sin_code_bundle.tools.slash.registry import CommandRegistry @@ -89,7 +88,9 @@ def list(built_in: bool, custom: bool) -> None: @click.argument("name", required=True) @click.argument("description", required=True) @click.argument("action", required=True) -@click.option("--type", "action_type", default="shell", type=click.Choice(["shell", "sin", "script"])) +@click.option( + "--type", "action_type", default="shell", type=click.Choice(["shell", "sin", "script"]) +) def register(name: str, description: str, action: str, action_type: str) -> None: """Register a custom command. diff --git a/src/sin_code_bundle/tools/slash/dispatcher.py b/src/sin_code_bundle/tools/slash/dispatcher.py index ad460e19..8ae6bbf2 100644 --- a/src/sin_code_bundle/tools/slash/dispatcher.py +++ b/src/sin_code_bundle/tools/slash/dispatcher.py @@ -12,15 +12,16 @@ import json import sqlite3 -from dataclasses import dataclass, asdict +from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional +from sin_code_bundle.tools.slash.commands import BUILTIN_COMMANDS +from sin_code_bundle.tools.slash.commands import get_command_help as get_builtin_help +from sin_code_bundle.tools.slash.executor import CommandExecutor from sin_code_bundle.tools.slash.parser import ParsedCommand, SlashParser from sin_code_bundle.tools.slash.registry import CommandRegistry -from sin_code_bundle.tools.slash.executor import CommandExecutor -from sin_code_bundle.tools.slash.commands import BUILTIN_COMMANDS, get_command_help as get_builtin_help @dataclass @@ -126,9 +127,7 @@ def dispatch(self, raw_command: str) -> DispatchResult: if command_name in BUILTIN_COMMANDS: action = BUILTIN_COMMANDS[command_name] try: - output = self._executor.execute_builtin( - command_name, action, args, flags - ) + output = self._executor.execute_builtin(command_name, action, args, flags) success = True error = None except Exception as e: @@ -140,9 +139,7 @@ def dispatch(self, raw_command: str) -> DispatchResult: custom = self._registry.get(command_name) if custom: try: - output = self._executor.execute_custom( - custom, args, flags - ) + output = self._executor.execute_custom(custom, args, flags) success = True error = None except Exception as e: diff --git a/src/sin_code_bundle/tools/slash/executor.py b/src/sin_code_bundle/tools/slash/executor.py index 46fe9377..dd70d375 100644 --- a/src/sin_code_bundle/tools/slash/executor.py +++ b/src/sin_code_bundle/tools/slash/executor.py @@ -7,7 +7,7 @@ """ import subprocess -from typing import Any, Optional +from typing import Any from sin_code_bundle.tools.slash.registry import CustomCommand @@ -89,9 +89,7 @@ def execute_custom( else: raise RuntimeError(f"Unknown action type: {command.action_type}") - def _run_shell( - self, target: str, args: list[str], flags: dict[str, Any] - ) -> str: + def _run_shell(self, target: str, args: list[str], flags: dict[str, Any]) -> str: """Run a shell command. Args: @@ -126,9 +124,7 @@ def _run_shell( except subprocess.TimeoutExpired: raise RuntimeError(f"Command timed out after {self._timeout}s") - def _run_sin( - self, target: str, args: list[str], flags: dict[str, Any] - ) -> str: + def _run_sin(self, target: str, args: list[str], flags: dict[str, Any]) -> str: """Run a sin-* tool command. Args: @@ -143,9 +139,7 @@ def _run_sin( # For now, we simulate the invocation return f"[sin] {target} {' '.join(args)} flags={flags}\n" - def _run_python( - self, target: str, args: list[str], flags: dict[str, Any] - ) -> str: + def _run_python(self, target: str, args: list[str], flags: dict[str, Any]) -> str: """Run a Python function. Args: diff --git a/src/sin_code_bundle/tools/slash/mcp_server.py b/src/sin_code_bundle/tools/slash/mcp_server.py index 5c6a0298..eb5cc95d 100644 --- a/src/sin_code_bundle/tools/slash/mcp_server.py +++ b/src/sin_code_bundle/tools/slash/mcp_server.py @@ -11,9 +11,7 @@ from fastmcp import FastMCP -from sin_code_bundle.tools.slash.dispatcher import CommandDispatcher, DispatchResult -from sin_code_bundle.tools.slash.registry import CommandRegistry -from sin_code_bundle.tools.slash.commands import BUILTIN_COMMANDS, get_command_help +from sin_code_bundle.tools.slash.dispatcher import CommandDispatcher # Initialize MCP server mcp = FastMCP("sin-slash") @@ -136,7 +134,10 @@ def slash_unregister(name: str) -> str: registry = dispatcher._registry removed = registry.unregister(name) return json.dumps( - {"success": removed, "message": f"Command /{name} removed" if removed else f"Command /{name} not found"}, + { + "success": removed, + "message": f"Command /{name} removed" if removed else f"Command /{name} not found", + }, indent=2, ) diff --git a/src/sin_code_bundle/tools/slash/parser.py b/src/sin_code_bundle/tools/slash/parser.py index 3fddba5c..919064c1 100644 --- a/src/sin_code_bundle/tools/slash/parser.py +++ b/src/sin_code_bundle/tools/slash/parser.py @@ -94,11 +94,15 @@ def parse(self, raw: str) -> ParsedCommand: if long_match: flag_name = long_match.group(1) flag_value = long_match.group(2) - flags[flag_name] = self._coerce_value(flag_value) if flag_value is not None else True + flags[flag_name] = ( + self._coerce_value(flag_value) if flag_value is not None else True + ) elif short_match: flag_name = short_match.group(1) flag_value = short_match.group(2) - flags[flag_name] = self._coerce_value(flag_value) if flag_value is not None else True + flags[flag_name] = ( + self._coerce_value(flag_value) if flag_value is not None else True + ) else: args.append(token) diff --git a/src/sin_code_bundle/tools/slash/registry.py b/src/sin_code_bundle/tools/slash/registry.py index 8788c390..9b155f91 100644 --- a/src/sin_code_bundle/tools/slash/registry.py +++ b/src/sin_code_bundle/tools/slash/registry.py @@ -16,7 +16,7 @@ import json import sqlite3 -from dataclasses import dataclass, asdict +from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -122,7 +122,14 @@ def register( conn.execute( "INSERT INTO commands (name, description, action, action_type, created_at, updated_at) " "VALUES (?, ?, ?, ?, ?, ?)", - (cmd.name, cmd.description, cmd.action, cmd.action_type, cmd.created_at, cmd.updated_at), + ( + cmd.name, + cmd.description, + cmd.action, + cmd.action_type, + cmd.created_at, + cmd.updated_at, + ), ) conn.commit() except sqlite3.IntegrityError as e: @@ -141,9 +148,7 @@ def get(self, name: str) -> Optional[CustomCommand]: """ with sqlite3.connect(self._db_path) as conn: conn.row_factory = sqlite3.Row - row = conn.execute( - "SELECT * FROM commands WHERE name = ?", (name,) - ).fetchone() + row = conn.execute("SELECT * FROM commands WHERE name = ?", (name,)).fetchone() if row is None: return None @@ -179,9 +184,7 @@ def list(self) -> list[CustomCommand]: """ with sqlite3.connect(self._db_path) as conn: conn.row_factory = sqlite3.Row - rows = conn.execute( - "SELECT * FROM commands ORDER BY name" - ).fetchall() + rows = conn.execute("SELECT * FROM commands ORDER BY name").fetchall() return [ CustomCommand( @@ -235,7 +238,13 @@ def update( conn.execute( "UPDATE commands SET description=?, action=?, action_type=?, updated_at=? " "WHERE name=?", - (updates["description"], updates["action"], updates["action_type"], updates["updated_at"], name), + ( + updates["description"], + updates["action"], + updates["action_type"], + updates["updated_at"], + name, + ), ) conn.commit() diff --git a/src/sin_code_bundle/update.py b/src/sin_code_bundle/update.py index bc083e26..f2ad1c53 100644 --- a/src/sin_code_bundle/update.py +++ b/src/sin_code_bundle/update.py @@ -84,9 +84,7 @@ def _run( """Run *cmd* and capture stdout/stderr. Never raises — non-zero exits surface as ``returncode`` so callers can decide how to react. """ - return subprocess.run( - cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout - ) + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout) def _git_describe(repo: Path) -> str: @@ -103,9 +101,7 @@ def _git_describe(repo: Path) -> str: def _git_branch(repo: Path) -> str: """Return the current branch name, or empty string when detached/empty.""" - res = _run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo, timeout=10 - ) + res = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo, timeout=10) if res.returncode != 0: return "" branch = res.stdout.strip() @@ -145,9 +141,7 @@ def _pipx_version(pkg: str = DEFAULT_PIPX_PKG) -> str: # ── Update steps ───────────────────────────────────────────────────────── -def update_python( - pkg: str = DEFAULT_PIPX_PKG, *, check: bool = False -) -> UpdateResult: +def update_python(pkg: str = DEFAULT_PIPX_PKG, *, check: bool = False) -> UpdateResult: """Run ``pipx upgrade <pkg>`` (or print it in --check mode).""" old = _pipx_version(pkg) if check: @@ -174,8 +168,7 @@ def update_python( old_version=old, new_version=new or old, status="failed", - detail=(res.stderr or res.stdout).strip().splitlines()[-1:] - or ["unknown error"], + detail=(res.stderr or res.stdout).strip().splitlines()[-1:] or ["unknown error"], ) if new and old and new == old: status = "up-to-date" @@ -184,9 +177,7 @@ def update_python( else: # can't determine — treat as updated if pipx reported success status = "updated" if not res.stderr else "up-to-date" - return UpdateResult( - target=pkg, old_version=old, new_version=new, status=status - ) + return UpdateResult(target=pkg, old_version=old, new_version=new, status=status) def update_go_tool(tool: GoTool, *, check: bool = False) -> UpdateResult: @@ -224,15 +215,12 @@ def update_go_tool(tool: GoTool, *, check: bool = False) -> UpdateResult: new_version="(would pull + rebuild)", status="would-update", detail=( - f"git pull --ff-only @ {branch} && " - f"go build -o {tool.binary} ./cmd/{tool.name}" + f"git pull --ff-only @ {branch} && go build -o {tool.binary} ./cmd/{tool.name}" ), ) if not already_current: - pull = _run( - ["git", "pull", "--ff-only"], cwd=tool.repo, timeout=120 - ) + pull = _run(["git", "pull", "--ff-only"], cwd=tool.repo, timeout=120) if pull.returncode != 0: return UpdateResult( target=tool.name, @@ -245,9 +233,7 @@ def update_go_tool(tool: GoTool, *, check: bool = False) -> UpdateResult: return _build_tool(tool, _git_describe(tool.repo), installed_version) -def _build_tool( - tool: GoTool, source_version: str, installed_version: str -) -> UpdateResult: +def _build_tool(tool: GoTool, source_version: str, installed_version: str) -> UpdateResult: """Run ``go build`` with a version ldflag and report success/failure.""" if not shutil.which("go"): return UpdateResult( @@ -277,8 +263,7 @@ def _build_tool( old_version=installed_version, new_version=installed_version, status="failed", - detail=(build.stderr or build.stdout).strip().splitlines()[-1:] - or ["build error"], + detail=(build.stderr or build.stdout).strip().splitlines()[-1:] or ["build error"], ) new_installed = _binary_version(tool.binary) status = "up-to-date" if new_installed == installed_version else "updated" diff --git a/src/sin_delegate/__init__.py b/src/sin_delegate/__init__.py index d3b2eea1..f74b522a 100644 --- a/src/sin_delegate/__init__.py +++ b/src/sin_delegate/__init__.py @@ -6,15 +6,31 @@ Delegator, delegate — programmatic engine """ +from .engine import Delegator, delegate from .models import ( - AgentSpec, Budget, Plan, RunResult, Risk, Task, TaskOutcome, - TaskState, Verdict, + AgentSpec, + Budget, + Plan, + Risk, + RunResult, + Task, + TaskOutcome, + TaskState, + Verdict, ) -from .engine import Delegator, delegate __version__ = "0.1.0" __all__ = [ - "AgentSpec", "Budget", "Delegator", "Plan", "RunResult", "Risk", - "Task", "TaskOutcome", "TaskState", "Verdict", - "delegate", "__version__", + "AgentSpec", + "Budget", + "Delegator", + "Plan", + "RunResult", + "Risk", + "Task", + "TaskOutcome", + "TaskState", + "Verdict", + "delegate", + "__version__", ] diff --git a/src/sin_delegate/__main__.py b/src/sin_delegate/__main__.py index 07cf22a7..7b3316d6 100644 --- a/src/sin_delegate/__main__.py +++ b/src/sin_delegate/__main__.py @@ -14,8 +14,9 @@ async def main() -> None: from mcp.server import Server from mcp.server.stdio import stdio_server except ImportError: - print("mcp package not installed; run: " - "pip install 'sin-code-delegate[mcp]'", file=sys.stderr) + print( + "mcp package not installed; run: pip install 'sin-code-delegate[mcp]'", file=sys.stderr + ) return server = Server("sin-delegate-mcp") @@ -23,11 +24,11 @@ async def main() -> None: handlers: dict = {} def add_tool(name, description, schema, handler): - tools.append({"name": name, "description": description, - "inputSchema": schema}) + tools.append({"name": name, "description": description, "inputSchema": schema}) handlers[name] = handler from .mcp_tools import register + register(add_tool) from mcp import types @@ -37,8 +38,7 @@ async def list_tools() -> list: return [types.Tool(**t) for t in tools] @server.call_tool() - async def call_tool(name: str, arguments: dict | None - ) -> list: + async def call_tool(name: str, arguments: dict | None) -> list: handler = handlers.get(name) if handler is None: payload = {"error": f"unknown tool {name!r}"} @@ -47,13 +47,10 @@ async def call_tool(name: str, arguments: dict | None payload = await handler(arguments or {}) except Exception as e: payload = {"error": f"{type(e).__name__}: {e}"} - return [types.TextContent(type="text", - text=__import__("json").dumps( - payload, default=str))] + return [types.TextContent(type="text", text=__import__("json").dumps(payload, default=str))] async with stdio_server() as (read, write): - await server.run(read, write, - server.create_initialization_options()) + await server.run(read, write, server.create_initialization_options()) if __name__ == "__main__": diff --git a/src/sin_delegate/analytics.py b/src/sin_delegate/analytics.py index 5901c68f..3688f6a1 100644 --- a/src/sin_delegate/analytics.py +++ b/src/sin_delegate/analytics.py @@ -12,25 +12,27 @@ from pathlib import PurePosixPath from .ledger import Ledger -from .models import Task def task_class(risk: str, files: list, verify: list) -> str: """Deterministic bucket, e.g. 'high:py:arch+diff+tests'.""" - exts = [PurePosixPath(f).suffix.lstrip(".") - for f in files if PurePosixPath(f).suffix] - dominant = (max(set(exts), key=exts.count) if exts else "any") - vprofile = "+".join(sorted( - {"tests": "tests", "architecture": "arch", "diff": "diff"}.get(v, v) - for v in verify - if v in ("tests", "architecture", "diff") - )) or "none" + exts = [PurePosixPath(f).suffix.lstrip(".") for f in files if PurePosixPath(f).suffix] + dominant = max(set(exts), key=exts.count) if exts else "any" + vprofile = ( + "+".join( + sorted( + {"tests": "tests", "architecture": "arch", "diff": "diff"}.get(v, v) + for v in verify + if v in ("tests", "architecture", "diff") + ) + ) + or "none" + ) return f"{risk}:{dominant}:{vprofile}" def task_class_of(task) -> str: - return task_class(task.risk.value, list(task.files_hint), - list(task.verify)) + return task_class(task.risk.value, list(task.files_hint), list(task.verify)) def wilson_lower(successes: int, trials: int, z: float = 1.96) -> float: @@ -40,8 +42,7 @@ def wilson_lower(successes: int, trials: int, z: float = 1.96) -> float: p = successes / trials denom = 1 + z * z / trials centre = p + z * z / (2 * trials) - margin = z * math.sqrt( - (p * (1 - p) + z * z / (4 * trials)) / trials) + margin = z * math.sqrt((p * (1 - p) + z * z / (4 * trials)) / trials) return max(0.0, (centre - margin) / denom) @@ -98,8 +99,7 @@ def _fold(self) -> None: self._fold_run(plan_id, meta) def _fold_run(self, plan_id: str, meta: dict) -> None: - per_task: dict = defaultdict( - lambda: {"passed": None, "seconds": 0.0, "attempts": 0}) + per_task: dict = defaultdict(lambda: {"passed": None, "seconds": 0.0, "attempts": 0}) for ev in self.ledger.history(plan_id): tid = ev["task_id"] if tid not in meta: @@ -109,30 +109,29 @@ def _fold_run(self, plan_id: str, meta: dict) -> None: rec["attempts"] += 1 elif ev["kind"] == "verdict": rec["passed"] = bool(ev["payload"].get("passed")) - elif (ev["kind"].startswith("state:") - and ev["payload"].get("seconds")): + elif ev["kind"].startswith("state:") and ev["payload"].get("seconds"): rec["seconds"] = float(ev["payload"]["seconds"]) for tid, rec in per_task.items(): if rec["passed"] is None: continue m = meta[tid] - cls = task_class(m.get("risk", "medium"), - m.get("files_hint", m.get("files", [])), - m.get("verify", [])) - key = (m.get("backend", "opencode"), - m.get("model", ""), cls) - st = self._stats.setdefault( - key, BackendStats(key[0], key[1], cls)) - st.observe(rec["passed"], rec["seconds"], - max(rec["attempts"], 1)) - - def best_backend(self, cls: str, - candidates: list | None = None, - min_trials: int = 3 - ) -> tuple[str, str] | None: - rows = [s for (b, m, c), s in self._stats.items() - if c == cls and s.trials >= min_trials - and (candidates is None or (b, m) in candidates)] + cls = task_class( + m.get("risk", "medium"), + m.get("files_hint", m.get("files", [])), + m.get("verify", []), + ) + key = (m.get("backend", "opencode"), m.get("model", ""), cls) + st = self._stats.setdefault(key, BackendStats(key[0], key[1], cls)) + st.observe(rec["passed"], rec["seconds"], max(rec["attempts"], 1)) + + def best_backend( + self, cls: str, candidates: list | None = None, min_trials: int = 3 + ) -> tuple[str, str] | None: + rows = [ + s + for (b, m, c), s in self._stats.items() + if c == cls and s.trials >= min_trials and (candidates is None or (b, m) in candidates) + ] if not rows: return None best = max(rows, key=lambda s: s.score) @@ -149,14 +148,18 @@ def expected_seconds(self, cls: str, default: float = 600.0) -> float: def table(self) -> list[dict]: return sorted( - ({"backend": s.backend, - "model": s.model or "(default)", - "task_class": s.task_class, - "trials": s.trials, - "pass_rate": (round(s.passes / s.trials, 2) - if s.trials else 0), - "wilson_score": round(s.score, 3), - "ema_seconds": round(s.ema_seconds, 1), - "ema_attempts": round(s.ema_attempts, 1)} - for s in self._stats.values()), - key=lambda r: (r["task_class"], -r["wilson_score"])) + ( + { + "backend": s.backend, + "model": s.model or "(default)", + "task_class": s.task_class, + "trials": s.trials, + "pass_rate": (round(s.passes / s.trials, 2) if s.trials else 0), + "wilson_score": round(s.score, 3), + "ema_seconds": round(s.ema_seconds, 1), + "ema_attempts": round(s.ema_attempts, 1), + } + for s in self._stats.values() + ), + key=lambda r: (r["task_class"], -r["wilson_score"]), + ) diff --git a/src/sin_delegate/budget_governor.py b/src/sin_delegate/budget_governor.py index 807867ad..92ac157c 100644 --- a/src/sin_delegate/budget_governor.py +++ b/src/sin_delegate/budget_governor.py @@ -42,11 +42,9 @@ class BudgetGovernor: min_grant: float = 60.0 _pool: float = field(init=False, default=0.0) - _allocs: dict[str, Allocation] = field(init=False, - default_factory=dict) + _allocs: dict[str, Allocation] = field(init=False, default_factory=dict) _deadline: float = field(init=False, default=0.0) - _lock: asyncio.Lock = field(init=False, - default_factory=asyncio.Lock) + _lock: asyncio.Lock = field(init=False, default_factory=asyncio.Lock) def __post_init__(self) -> None: self._deadline = time.monotonic() + self.global_seconds @@ -55,36 +53,30 @@ def __post_init__(self) -> None: def _estimate(self, task: Task) -> float: if self.analytics is None: return task.budget.max_seconds - est = self.analytics.expected_seconds( - task_class_of(task), default=task.budget.max_seconds) + est = self.analytics.expected_seconds(task_class_of(task), default=task.budget.max_seconds) return min(est * self.safety_factor, task.budget.max_seconds) def _seed(self) -> None: - estimates = {t.id: max(self._estimate(t), self.min_grant) - for t in self.plan.tasks} + estimates = {t.id: max(self._estimate(t), self.min_grant) for t in self.plan.tasks} total = sum(estimates.values()) - scale = (min(1.0, self.global_seconds / total) if total else 1.0) + scale = min(1.0, self.global_seconds / total) if total else 1.0 for tid, est in estimates.items(): - self._allocs[tid] = Allocation( - tid, granted=max(est * scale, self.min_grant)) - self._pool = max(0.0, self.global_seconds - sum( - a.granted for a in self._allocs.values())) + self._allocs[tid] = Allocation(tid, granted=max(est * scale, self.min_grant)) + self._pool = max(0.0, self.global_seconds - sum(a.granted for a in self._allocs.values())) def remaining_global(self) -> float: return max(0.0, self._deadline - time.monotonic()) def _pressure(self) -> float: """1.0 = relaxed, -> 0.0 as deadline approaches.""" - return min(1.0, self.remaining_global() - / max(self.global_seconds, 1.0) * 2.0) + return min(1.0, self.remaining_global() / max(self.global_seconds, 1.0) * 2.0) async def lease(self, task_id: str) -> float: async with self._lock: alloc = self._allocs[task_id] alloc.started = time.monotonic() grant = alloc.granted * self._pressure() - grant = max(grant, - min(self.min_grant, self.remaining_global())) + grant = max(grant, min(self.min_grant, self.remaining_global())) return min(grant, self.remaining_global()) async def release(self, task_id: str, used_seconds: float) -> None: @@ -94,15 +86,13 @@ async def release(self, task_id: str, used_seconds: float) -> None: if surplus > 0: self._pool += surplus - async def request_extension(self, task_id: str, - seconds: float) -> float: + async def request_extension(self, task_id: str, seconds: float) -> float: async with self._lock: if self._pool <= 0: return 0.0 max_prio = max(self.priority.values()) or 1 weight = self.priority.get(task_id, 1) / max_prio - grant = min(seconds * weight, self._pool, - self.remaining_global()) + grant = min(seconds * weight, self._pool, self.remaining_global()) if grant < 5.0: return 0.0 self._pool -= grant @@ -115,7 +105,7 @@ def snapshot(self) -> dict: "remaining_global": round(self.remaining_global(), 1), "pressure": round(self._pressure(), 2), "allocations": { - tid: {"granted": round(a.granted, 1), - "extended": round(a.extended, 1)} - for tid, a in self._allocs.items()}, + tid: {"granted": round(a.granted, 1), "extended": round(a.extended, 1)} + for tid, a in self._allocs.items() + }, } diff --git a/src/sin_delegate/cli.py b/src/sin_delegate/cli.py index 28b692e1..c11b50ce 100644 --- a/src/sin_delegate/cli.py +++ b/src/sin_delegate/cli.py @@ -21,7 +21,6 @@ from __future__ import annotations import argparse -import asyncio import json import sys from pathlib import Path @@ -35,8 +34,10 @@ from .planfile import load_plan _ICON = { - TaskState.DONE: "[ok]", TaskState.FAILED: "[FAIL]", - TaskState.SKIPPED: "[skip]", TaskState.ESCALATED: "[ESCALATE]", + TaskState.DONE: "[ok]", + TaskState.FAILED: "[FAIL]", + TaskState.SKIPPED: "[skip]", + TaskState.ESCALATED: "[ESCALATE]", TaskState.CANCELLED: "[cancel]", } @@ -45,15 +46,20 @@ def _cmd_run(args: argparse.Namespace) -> int: data = json.loads(Path(args.plan).read_text()) if "repos" in data: mrp = multirepo_plan_from_dict(data) - print(f"plan {mrp.id}: {len(mrp.plan.tasks)} tasks across " - f"{len(mrp.repos)} repos ({', '.join(mrp.repos)})") + print( + f"plan {mrp.id}: {len(mrp.plan.tasks)} tasks across " + f"{len(mrp.repos)} repos ({', '.join(mrp.repos)})" + ) dele = MultiRepoDelegator(mrp, max_parallel=args.parallel) result = dele.run_sync() else: plan = load_plan(args.plan, repo=args.repo) - dele = Delegator(plan, max_parallel=args.parallel, - dry_run=args.dry_run, - keep_worktrees=args.keep_worktrees) + dele = Delegator( + plan, + max_parallel=args.parallel, + dry_run=args.dry_run, + keep_worktrees=args.keep_worktrees, + ) print(f"plan {plan.id}: {len(plan.tasks)} tasks") result = dele.run_sync() if args.json: @@ -63,8 +69,7 @@ def _cmd_run(args: argparse.Namespace) -> int: icon = _ICON.get(o.state, f"[{o.state.value}]") extra = f" — {o.error}" if o.error else "" print(f" {icon} {tid}{extra}") - print("result:", "SUCCESS" if result.ok else "INCOMPLETE", - f"(plan {result.plan_id})") + print("result:", "SUCCESS" if result.ok else "INCOMPLETE", f"(plan {result.plan_id})") return 0 if result.ok else 1 @@ -79,8 +84,9 @@ def _cmd_status(args: argparse.Namespace) -> int: def _cmd_history(args: argparse.Namespace) -> int: for ev in Ledger().history(args.plan_id): - print(f"{ev['seq']:5d} {ev['task_id']:<18} {ev['kind']:<22} " - f"{json.dumps(ev['payload'])[:80]}") + print( + f"{ev['seq']:5d} {ev['task_id']:<18} {ev['kind']:<22} {json.dumps(ev['payload'])[:80]}" + ) return 0 @@ -98,36 +104,44 @@ def _cmd_cancel(args: argparse.Namespace) -> int: def _cmd_plan(args: argparse.Namespace) -> int: from .planner import plan_sync - plan = plan_sync(args.goal, repo=args.repo, - backend=args.backend, model=args.model, - critique=not args.no_critique) + + plan = plan_sync( + args.goal, + repo=args.repo, + backend=args.backend, + model=args.model, + critique=not args.no_critique, + ) payload = { "goal": plan.goal, "base_branch": plan.base_branch, - "tasks": [{ - "key": t.id, "title": t.title, - "instructions": t.instructions, - "deps": list(t.deps), - "files": list(t.files_hint), - "risk": t.risk.value, - "verify": list(t.verify), - } for t in plan.tasks], + "tasks": [ + { + "key": t.id, + "title": t.title, + "instructions": t.instructions, + "deps": list(t.deps), + "files": list(t.files_hint), + "risk": t.risk.value, + "verify": list(t.verify), + } + for t in plan.tasks + ], } text = json.dumps(payload, indent=2, ensure_ascii=False) if args.out: Path(args.out).write_text(text, encoding="utf-8") - print(f"plan {plan.id} written to {args.out} " - f"({len(plan.tasks)} tasks)") + print(f"plan {plan.id} written to {args.out} ({len(plan.tasks)} tasks)") else: print(text) return 0 def _cmd_auto(args: argparse.Namespace) -> int: - from .planner import plan_sync from .observe import report - plan = plan_sync(args.goal, repo=args.repo, - backend=args.backend, model=args.model) + from .planner import plan_sync + + plan = plan_sync(args.goal, repo=args.repo, backend=args.backend, model=args.model) print(f"plan {plan.id}: {len(plan.tasks)} tasks") for t in plan.tasks: deps = f" <- {','.join(t.deps)}" if t.deps else "" @@ -137,8 +151,7 @@ def _cmd_auto(args: argparse.Namespace) -> int: if answer != "y": print("aborted") return 1 - dele = Delegator(plan, max_parallel=args.parallel, - dry_run=args.dry_run) + dele = Delegator(plan, max_parallel=args.parallel, dry_run=args.dry_run) result = dele.run_sync() print(report(result.plan_id)) return 0 if result.ok else 1 @@ -146,24 +159,28 @@ def _cmd_auto(args: argparse.Namespace) -> int: def _cmd_watch(args: argparse.Namespace) -> int: from .observe import StatusBoard + StatusBoard(args.plan_id).watch() return 0 def _cmd_report(args: argparse.Namespace) -> int: from .observe import report + print(report(args.plan_id)) return 0 def _cmd_doctor(args: argparse.Namespace) -> int: from .doctor import doctor, print_report - backends = (args.backends.split(",") if args.backends else None) + + backends = args.backends.split(",") if args.backends else None return print_report(doctor(repo=args.repo, backends=backends)) def _cmd_stats(args: argparse.Namespace) -> int: from .analytics import Analytics + rows = Analytics().table() if not rows: print("no verified runs yet — stats build up automatically") @@ -171,15 +188,19 @@ def _cmd_stats(args: argparse.Namespace) -> int: if args.json: print(json.dumps(rows, indent=2)) return 0 - hdr = (f"{'task_class':<22} {'backend':<10} {'model':<24} " - f"{'n':>4} {'pass':>5} {'wilson':>7} {'~sec':>6} {'~try':>5}") + hdr = ( + f"{'task_class':<22} {'backend':<10} {'model':<24} " + f"{'n':>4} {'pass':>5} {'wilson':>7} {'~sec':>6} {'~try':>5}" + ) print(hdr) print("-" * len(hdr)) for r in rows: - print(f"{r['task_class']:<22} {r['backend']:<10} " - f"{r['model']:<24} {r['trials']:>4} " - f"{r['pass_rate']:>5.0%} {r['wilson_score']:>7.3f} " - f"{r['ema_seconds']:>6.0f} {r['ema_attempts']:>5.1f}") + print( + f"{r['task_class']:<22} {r['backend']:<10} " + f"{r['model']:<24} {r['trials']:>4} " + f"{r['pass_rate']:>5.0%} {r['wilson_score']:>7.3f} " + f"{r['ema_seconds']:>6.0f} {r['ema_attempts']:>5.1f}" + ) return 0 @@ -202,8 +223,8 @@ def _cmd_escalations(args: argparse.Namespace) -> int: def _cmd_resolve(args: argparse.Namespace) -> int: result = EscalationBroker().resolve( - args.plan_id, args.escalation_id, args.option, - user_input=args.input, decided_by="cli") + args.plan_id, args.escalation_id, args.option, user_input=args.input, decided_by="cli" + ) if not result["ok"]: print(f"error: {result['error']}", file=sys.stderr) return 1 @@ -215,6 +236,7 @@ def _cmd_resolve(args: argparse.Namespace) -> int: def _cmd_resume(args: argparse.Namespace) -> int: from .observe import report from .planner import plan_from_dict as _pfd + ledger = Ledger() raw = ledger.load_plan_json(args.plan_id) if not raw: @@ -222,11 +244,15 @@ def _cmd_resume(args: argparse.Namespace) -> int: return 1 data = json.loads(raw) plan = _pfd( - {"goal": data["goal"], "base_branch": data.get("base_branch", "main"), - "tasks": [{**t, "key": t["id"], - "files": t.get("files_hint", [])} - for t in data["tasks"]]}, - repo=data.get("repo", args.repo)) + { + "goal": data["goal"], + "base_branch": data.get("base_branch", "main"), + "tasks": [ + {**t, "key": t["id"], "files": t.get("files_hint", [])} for t in data["tasks"] + ], + }, + repo=data.get("repo", args.repo), + ) dele = Delegator(plan, ledger=ledger, max_parallel=args.parallel) result = dele.run_sync() print(report(result.plan_id)) @@ -246,8 +272,7 @@ def build_parser(prog: str = "sin-delegate") -> argparse.ArgumentParser: run.add_argument("--json", action="store_true") run.set_defaults(fn=_cmd_run) - pl = sub.add_parser("plan", - help="LLM-decompose a goal into a plan file") + pl = sub.add_parser("plan", help="LLM-decompose a goal into a plan file") pl.add_argument("goal") pl.add_argument("--repo", default=".") pl.add_argument("--backend", default="opencode") @@ -293,14 +318,11 @@ def build_parser(prog: str = "sin-delegate") -> argparse.ArgumentParser: doc.add_argument("--backends", default="") doc.set_defaults(fn=_cmd_doctor) - st_ = sub.add_parser( - "stats", - help="learned backend performance per task class (Wilson-scored)") + st_ = sub.add_parser("stats", help="learned backend performance per task class (Wilson-scored)") st_.add_argument("--json", action="store_true") st_.set_defaults(fn=_cmd_stats) - es = sub.add_parser("escalations", - help="list open decision requests of a run") + es = sub.add_parser("escalations", help="list open decision requests of a run") es.add_argument("plan_id") es.set_defaults(fn=_cmd_escalations) @@ -308,12 +330,10 @@ def build_parser(prog: str = "sin-delegate") -> argparse.ArgumentParser: rv.add_argument("plan_id") rv.add_argument("escalation_id") rv.add_argument("--option", required=True) - rv.add_argument("--input", default="", - help="guidance text for retry options") + rv.add_argument("--input", default="", help="guidance text for retry options") rv.set_defaults(fn=_cmd_resolve) - rs = sub.add_parser("resume", - help="apply resolutions and continue a run") + rs = sub.add_parser("resume", help="apply resolutions and continue a run") rs.add_argument("plan_id") rs.add_argument("--repo", default=".") rs.add_argument("--parallel", type=int, default=4) diff --git a/src/sin_delegate/doctor.py b/src/sin_delegate/doctor.py index 05f86efb..a4012ed4 100644 --- a/src/sin_delegate/doctor.py +++ b/src/sin_delegate/doctor.py @@ -25,8 +25,7 @@ class Check: def _run(cmd: list, timeout: int = 10) -> tuple: try: - p = subprocess.run(cmd, capture_output=True, text=True, - timeout=timeout) + p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) return p.returncode, p.stdout + p.stderr except subprocess.TimeoutExpired: return -1, "timeout" @@ -46,9 +45,8 @@ def check_git_config() -> Check: code, _ = _run(["git", "config", "--global", key]) if code != 0: return Check( - "git config", False, - f"{key} not set — run: git config --global {key} " - f"'Your Name'") + "git config", False, f"{key} not set — run: git config --global {key} 'Your Name'" + ) return Check("git config", True, "user.name and user.email configured") @@ -57,17 +55,18 @@ def check_repo(path: str) -> Check: if not p.exists(): return Check("repository", False, f"{path} does not exist") if not (p / ".git").exists(): - return Check("repository", False, - f"{path} is not a git repository") + return Check("repository", False, f"{path} is not a git repository") code, out = _run(["git", "-C", str(p), "status", "--porcelain"]) if code != 0: return Check("repository", False, f"git status failed: {out}") - dirty = [l for l in out.splitlines() if l.strip()] + dirty = [line for line in out.splitlines() if line.strip()] if dirty: return Check( - "repository", False, - f"working directory has uncommitted changes: " - f"{len(dirty)} file(s)", level="warning") + "repository", + False, + f"working directory has uncommitted changes: {len(dirty)} file(s)", + level="warning", + ) return Check("repository", True, "clean working directory") @@ -80,9 +79,7 @@ def check_backend(backend: str, model: str = "") -> Check: "codex": "install via: pip install codex-cli", } if not shutil.which(backend): - return Check( - backend, False, - f"{backend} CLI not found. {hints.get(backend, '')}") + return Check(backend, False, f"{backend} CLI not found. {hints.get(backend, '')}") code, out = _run([backend, "--version"]) if code not in (0, -1): return Check(backend, False, f"{backend} --version failed") @@ -90,12 +87,10 @@ def check_backend(backend: str, model: str = "") -> Check: return Check(backend, True, version) -def check_ledger(ledger_path: str = "~/.sin-code/delegate/ledger.db" - ) -> Check: +def check_ledger(ledger_path: str = "~/.sin-code/delegate/ledger.db") -> Check: p = Path(ledger_path).expanduser() if not p.exists(): - return Check("ledger", True, - "will be created on first run", level="info") + return Check("ledger", True, "will be created on first run", level="info") if not p.is_file(): return Check("ledger", False, f"{p} is not a file") try: @@ -110,13 +105,16 @@ def check_ledger(ledger_path: str = "~/.sin-code/delegate/ledger.db" def check_memory() -> Check: try: from sin_brain import __version__ # type: ignore - return Check("sin-brain", True, f"v{__version__} (memory loop active)", - level="info") + + return Check("sin-brain", True, f"v{__version__} (memory loop active)", level="info") except ImportError: return Check( - "sin-brain", True, + "sin-brain", + True, "not installed (memory loop disabled, install with " - "pip install 'sin-code-delegate[memory]')", level="warning") + "pip install 'sin-code-delegate[memory]')", + level="warning", + ) def doctor(repo: str = ".", backends: list | None = None) -> list: diff --git a/src/sin_delegate/engine.py b/src/sin_delegate/engine.py index 4bc4e0c1..0f4631dd 100644 --- a/src/sin_delegate/engine.py +++ b/src/sin_delegate/engine.py @@ -10,41 +10,45 @@ from . import memory from .ledger import Ledger -from .models import (Plan, Risk, RunResult, Task, TaskOutcome, TaskState) +from .models import Plan, Risk, RunResult, Task, TaskOutcome, TaskState from .runner import EchoRunner, runner_for from .scheduler import Scheduler from .worktree import GitError, WorktreeManager class Delegator: - def __init__(self, plan: Plan, ledger: Ledger | None = None, - max_parallel: int = 4, dry_run: bool = False, - keep_worktrees: bool = False) -> None: + def __init__( + self, + plan: Plan, + ledger: Ledger | None = None, + max_parallel: int = 4, + dry_run: bool = False, + keep_worktrees: bool = False, + ) -> None: self.plan = plan self.ledger = ledger or Ledger() self.max_parallel = max_parallel self.dry_run = dry_run self.keep_worktrees = keep_worktrees - self.wtm = None if dry_run else WorktreeManager( - plan.repo, plan.base_branch) + self.wtm = None if dry_run else WorktreeManager(plan.repo, plan.base_branch) self._pitfalls = memory.recall_pitfalls(plan.goal) self._retry_context: dict[str, str] = {} async def run(self) -> RunResult: started = time.time() - self.ledger.register_run(self.plan.id, self.plan.goal, - self._plan_json()) - scheduler = Scheduler(self.plan, self.ledger, self._execute_task, - max_parallel=self.max_parallel) + self.ledger.register_run(self.plan.id, self.plan.goal, self._plan_json()) + scheduler = Scheduler( + self.plan, self.ledger, self._execute_task, max_parallel=self.max_parallel + ) outcomes = await scheduler.run() - result = RunResult(self.plan.id, self.plan.goal, outcomes, - started, time.time()) + result = RunResult(self.plan.id, self.plan.goal, outcomes, started, time.time()) if result.ok: memory.remember_decision( self.plan.goal, f"plan {self.plan.id} succeeded: " f"{sum(o.state == TaskState.DONE for o in outcomes.values())} " - f"tasks merged") + f"tasks merged", + ) return result def run_sync(self) -> RunResult: @@ -53,51 +57,52 @@ def run_sync(self) -> RunResult: async def _execute_task(self, task: Task) -> TaskOutcome: if self.dry_run: await EchoRunner().run(task, ".", 5) - return TaskOutcome(task.id, TaskState.DONE, - worktree="(dry-run)") + return TaskOutcome(task.id, TaskState.DONE, worktree="(dry-run)") wt = self.wtm.create(self.plan.id, task.id) outcome = TaskOutcome( - task.id, TaskState.RUNNING, - worktree=str(wt.path), branch=wt.branch, + task.id, + TaskState.RUNNING, + worktree=str(wt.path), + branch=wt.branch, ) try: enriched = self._enrich(task) runner = runner_for(enriched.agent) - res = await runner.run(enriched, str(wt.path), - timeout=task.budget.max_seconds) + res = await runner.run(enriched, str(wt.path), timeout=task.budget.max_seconds) self.ledger.emit( - self.plan.id, task.id, "agent:finished", - {"exit": res.exit_code, - "tail": res.output[-2000:]}) + self.plan.id, + task.id, + "agent:finished", + {"exit": res.exit_code, "tail": res.output[-2000:]}, + ) if not res.ok: outcome.state = TaskState.FAILED outcome.error = f"agent exited {res.exit_code}" self._learn(task, outcome.error) return outcome - if not wt.commit_all( - f"sin-delegate: {task.title} [{task.id}]"): + if not wt.commit_all(f"sin-delegate: {task.title} [{task.id}]"): outcome.state = TaskState.FAILED outcome.error = "agent finished but changed nothing" self._learn(task, outcome.error) return outcome from .verify import verify + outcome.state = TaskState.VERIFYING self.ledger.emit(self.plan.id, task.id, "state:verifying") verdict = verify(task, wt) outcome.verdict = verdict self.ledger.emit( - self.plan.id, task.id, "verdict", - {"passed": verdict.passed, "gates": verdict.gates}) + self.plan.id, task.id, "verdict", {"passed": verdict.passed, "gates": verdict.gates} + ) if not verdict.passed: self._learn(task, verdict.summary) if task.risk == Risk.HIGH: outcome.state = TaskState.ESCALATED - outcome.error = (f"HIGH-risk task failed gates: " - f"{verdict.summary}") + outcome.error = f"HIGH-risk task failed gates: {verdict.summary}" else: self._retry_context[task.id] = self._verdict_feedback(verdict) outcome.state = TaskState.FAILED @@ -108,9 +113,7 @@ async def _execute_task(self, task: Task) -> TaskOutcome: self.ledger.emit(self.plan.id, task.id, "state:merging") try: snapshot = wt.merge_back() - self.ledger.emit( - self.plan.id, task.id, "merged", - {"snapshot": snapshot}) + self.ledger.emit(self.plan.id, task.id, "merged", {"snapshot": snapshot}) except GitError as e: outcome.state = TaskState.ESCALATED outcome.error = str(e) @@ -127,8 +130,8 @@ def _enrich(self, task: Task) -> Task: extra: list[str] = [] if self._pitfalls: extra.append( - "Known pitfalls from past runs (avoid these):\n- " - + "\n- ".join(self._pitfalls)) + "Known pitfalls from past runs (avoid these):\n- " + "\n- ".join(self._pitfalls) + ) if task.id in self._retry_context: extra.append(self._retry_context[task.id]) if not extra: @@ -138,35 +141,49 @@ def _enrich(self, task: Task) -> Task: @staticmethod def _verdict_feedback(verdict) -> str: - failed = {n: r["detail"] for n, r in verdict.gates.items() - if not r["ok"]} - return ("Your previous attempt FAILED verification. Fix exactly this:" - "\n" + json.dumps(failed, indent=2)) + failed = {n: r["detail"] for n, r in verdict.gates.items() if not r["ok"]} + return "Your previous attempt FAILED verification. Fix exactly this:\n" + json.dumps( + failed, indent=2 + ) def _learn(self, task: Task, detail: str) -> None: memory.remember_pitfall(self.plan.goal, task.title, detail) def _plan_json(self) -> str: - return json.dumps({ - "goal": self.plan.goal, - "repo": self.plan.repo, - "base_branch": self.plan.base_branch, - "tasks": [{ - "id": t.id, "title": t.title, - "instructions": t.instructions, - "deps": list(t.deps), - "files_hint": list(t.files_hint), - "risk": t.risk.value, "verify": list(t.verify), - "backend": t.agent.backend, "model": t.agent.model, - } for t in self.plan.tasks], - }, indent=2) - - -def delegate(goal: str, tasks: list, repo: str = ".", - base_branch: str = "main", max_parallel: int = 4, - dry_run: bool = False) -> RunResult: + return json.dumps( + { + "goal": self.plan.goal, + "repo": self.plan.repo, + "base_branch": self.plan.base_branch, + "tasks": [ + { + "id": t.id, + "title": t.title, + "instructions": t.instructions, + "deps": list(t.deps), + "files_hint": list(t.files_hint), + "risk": t.risk.value, + "verify": list(t.verify), + "backend": t.agent.backend, + "model": t.agent.model, + } + for t in self.plan.tasks + ], + }, + indent=2, + ) + + +def delegate( + goal: str, + tasks: list, + repo: str = ".", + base_branch: str = "main", + max_parallel: int = 4, + dry_run: bool = False, +) -> RunResult: """One-shot convenience API.""" - plan = Plan(goal=goal, tasks=tuple(t.finalize() for t in tasks), - repo=repo, base_branch=base_branch) - return Delegator(plan, max_parallel=max_parallel, - dry_run=dry_run).run_sync() + plan = Plan( + goal=goal, tasks=tuple(t.finalize() for t in tasks), repo=repo, base_branch=base_branch + ) + return Delegator(plan, max_parallel=max_parallel, dry_run=dry_run).run_sync() diff --git a/src/sin_delegate/escalation.py b/src/sin_delegate/escalation.py index 0b6e51a1..7f9a188e 100644 --- a/src/sin_delegate/escalation.py +++ b/src/sin_delegate/escalation.py @@ -59,71 +59,108 @@ class Escalation: def to_dict(self) -> dict: return { - "id": self.id, "plan_id": self.plan_id, - "task_id": self.task_id, "task_title": self.task_title, - "kind": self.kind.value, "summary": self.summary, - "evidence": self.evidence, "branch": self.branch, + "id": self.id, + "plan_id": self.plan_id, + "task_id": self.task_id, + "task_title": self.task_title, + "kind": self.kind.value, + "summary": self.summary, + "evidence": self.evidence, + "branch": self.branch, "worktree": self.worktree, - "options": [{ - "id": o.id, "action": o.action.value, "label": o.label, - "consequence": o.consequence, - "requires_input": o.requires_input, - } for o in self.options], + "options": [ + { + "id": o.id, + "action": o.action.value, + "label": o.label, + "consequence": o.consequence, + "requires_input": o.requires_input, + } + for o in self.options + ], } def _options_for(kind: EscalationKind, branch: str) -> list: common_drop = Option( - "drop", ActionType.DROP_TASK, "Task verwerfen", + "drop", + ActionType.DROP_TASK, + "Task verwerfen", "Task wird SKIPPED; alle abhängigen Tasks werden ebenfalls " - "übersprungen. Der Branch bleibt zur Inspektion erhalten.") + "übersprungen. Der Branch bleibt zur Inspektion erhalten.", + ) common_abort = Option( - "abort", ActionType.ABORT_PLAN, "Gesamten Plan abbrechen", + "abort", + ActionType.ABORT_PLAN, + "Gesamten Plan abbrechen", "Alle laufenden Tasks werden kooperativ beendet. Bereits gemergte " - "Tasks bleiben gemerged (kein globaler Rollback).") + "Tasks bleiben gemerged (kein globaler Rollback).", + ) if kind == EscalationKind.GATE_FAILURE: return [ - Option("retry", ActionType.RETRY_WITH_GUIDANCE, - "Erneut versuchen mit Korrekturhinweis", - "Der Sub-Agent erhält deinen Hinweis + das Gate-Verdict " - "und versucht es einmal erneut (frisches Budget-Lease).", - requires_input=True), - Option("accept", ActionType.ACCEPT_BRANCH, - "Ergebnis trotz Gate-Failure akzeptieren", - f"Branch {branch} wird OHNE bestandene Gates gemerged. " - "Das Verdict wird als 'overridden' im Ledger vermerkt — " - "du übernimmst die Verantwortung."), - common_drop, common_abort, + Option( + "retry", + ActionType.RETRY_WITH_GUIDANCE, + "Erneut versuchen mit Korrekturhinweis", + "Der Sub-Agent erhält deinen Hinweis + das Gate-Verdict " + "und versucht es einmal erneut (frisches Budget-Lease).", + requires_input=True, + ), + Option( + "accept", + ActionType.ACCEPT_BRANCH, + "Ergebnis trotz Gate-Failure akzeptieren", + f"Branch {branch} wird OHNE bestandene Gates gemerged. " + "Das Verdict wird als 'overridden' im Ledger vermerkt — " + "du übernimmst die Verantwortung.", + ), + common_drop, + common_abort, ] if kind == EscalationKind.MERGE_CONFLICT: return [ - Option("manual", ActionType.MANUAL_MERGE, - "Konflikt manuell auflösen", - f"Du löst den Rebase-Konflikt auf Branch {branch} selbst " - "und meldest danach 'resolved' — der Task gilt als DONE, " - "Downstream-Tasks laufen weiter."), - Option("retry", ActionType.RETRY_WITH_GUIDANCE, - "Neu implementieren auf aktuellem Stand", - "Worktree wird auf den aktuellen Base-Stand neu erzeugt; " - "der Agent implementiert gegen die neue Basis.", - requires_input=True), - common_drop, common_abort, + Option( + "manual", + ActionType.MANUAL_MERGE, + "Konflikt manuell auflösen", + f"Du löst den Rebase-Konflikt auf Branch {branch} selbst " + "und meldest danach 'resolved' — der Task gilt als DONE, " + "Downstream-Tasks laufen weiter.", + ), + Option( + "retry", + ActionType.RETRY_WITH_GUIDANCE, + "Neu implementieren auf aktuellem Stand", + "Worktree wird auf den aktuellen Base-Stand neu erzeugt; " + "der Agent implementiert gegen die neue Basis.", + requires_input=True, + ), + common_drop, + common_abort, ] if kind == EscalationKind.BUDGET_EXHAUSTED: return [ - Option("retry", ActionType.RETRY_WITH_GUIDANCE, - "Mit zusätzlichem Budget erneut versuchen", - "Der Task erhält ein frisches Lease außerhalb des " - "ursprünglichen Global-Budgets.", requires_input=False), - common_drop, common_abort, + Option( + "retry", + ActionType.RETRY_WITH_GUIDANCE, + "Mit zusätzlichem Budget erneut versuchen", + "Der Task erhält ein frisches Lease außerhalb des ursprünglichen Global-Budgets.", + requires_input=False, + ), + common_drop, + common_abort, ] return [ - Option("retry", ActionType.RETRY_WITH_GUIDANCE, - "Mit anderem Backend erneut versuchen", - "Die Policy wählt das nächstbeste Backend für die " - "task_class; der Hinweis wird injiziert.", - requires_input=True), - common_drop, common_abort, + Option( + "retry", + ActionType.RETRY_WITH_GUIDANCE, + "Mit anderem Backend erneut versuchen", + "Die Policy wählt das nächstbeste Backend für die " + "task_class; der Hinweis wird injiziert.", + requires_input=True, + ), + common_drop, + common_abort, ] @@ -131,19 +168,30 @@ class EscalationBroker: def __init__(self, ledger: Ledger | None = None) -> None: self.ledger = ledger or Ledger() - def raise_escalation(self, plan_id: str, task_id: str, - task_title: str, kind: EscalationKind, - summary: str, evidence: dict, - branch: str = "", - worktree: str = "") -> Escalation: + def raise_escalation( + self, + plan_id: str, + task_id: str, + task_title: str, + kind: EscalationKind, + summary: str, + evidence: dict, + branch: str = "", + worktree: str = "", + ) -> Escalation: esc = Escalation( - id=uuid.uuid4().hex[:12], plan_id=plan_id, task_id=task_id, - task_title=task_title, kind=kind, summary=summary, - evidence=evidence, branch=branch, worktree=worktree, + id=uuid.uuid4().hex[:12], + plan_id=plan_id, + task_id=task_id, + task_title=task_title, + kind=kind, + summary=summary, + evidence=evidence, + branch=branch, + worktree=worktree, options=_options_for(kind, branch), ) - self.ledger.emit(plan_id, task_id, "escalation:raised", - esc.to_dict()) + self.ledger.emit(plan_id, task_id, "escalation:raised", esc.to_dict()) return esc def open_escalations(self, plan_id: str) -> list: @@ -156,43 +204,53 @@ def open_escalations(self, plan_id: str) -> list: resolved.add(ev["payload"]["escalation_id"]) return [e for eid, e in raised.items() if eid not in resolved] - def resolve(self, plan_id: str, escalation_id: str, option_id: str, - user_input: str = "", - decided_by: str = "human") -> dict: + def resolve( + self, + plan_id: str, + escalation_id: str, + option_id: str, + user_input: str = "", + decided_by: str = "human", + ) -> dict: open_ = {e["id"]: e for e in self.open_escalations(plan_id)} esc = open_.get(escalation_id) if esc is None: - return {"ok": False, - "error": f"escalation {escalation_id} not open " - f"(unknown or already resolved)"} - option = next((o for o in esc["options"] if o["id"] == option_id), - None) + return { + "ok": False, + "error": f"escalation {escalation_id} not open (unknown or already resolved)", + } + option = next((o for o in esc["options"] if o["id"] == option_id), None) if option is None: valid = [o["id"] for o in esc["options"]] - return {"ok": False, - "error": f"unknown option {option_id!r}; " - f"valid: {valid}"} + return {"ok": False, "error": f"unknown option {option_id!r}; valid: {valid}"} if option["requires_input"] and not user_input.strip(): - return {"ok": False, - "error": f"option {option_id!r} requires input " - f"(e.g. guidance for the retry)"} + return { + "ok": False, + "error": f"option {option_id!r} requires input (e.g. guidance for the retry)", + } try: action = ActionType(option["action"]).value except ValueError: - return {"ok": False, - "error": f"escalation has invalid action " - f"{option['action']!r}"} - self.ledger.emit(plan_id, esc["task_id"], - "escalation:resolved", { - "escalation_id": escalation_id, + return {"ok": False, "error": f"escalation has invalid action {option['action']!r}"} + self.ledger.emit( + plan_id, + esc["task_id"], + "escalation:resolved", + { + "escalation_id": escalation_id, + "task_id": esc["task_id"], + "option_id": option_id, + "action": action, + "user_input": user_input, + "decided_by": decided_by, + }, + ) + return { + "ok": True, + "action": option["action"], "task_id": esc["task_id"], - "option_id": option_id, - "action": action, "user_input": user_input, - "decided_by": decided_by, - }) - return {"ok": True, "action": option["action"], - "task_id": esc["task_id"], "user_input": user_input} + } def pending_resolutions(self, plan_id: str) -> list: resolutions: list = [] @@ -202,10 +260,7 @@ def pending_resolutions(self, plan_id: str) -> list: resolutions.append(ev["payload"]) elif ev["kind"] == "escalation:applied": applied.add(ev["payload"]["escalation_id"]) - return [r for r in resolutions - if r["escalation_id"] not in applied] + return [r for r in resolutions if r["escalation_id"] not in applied] - def mark_applied(self, plan_id: str, task_id: str, - escalation_id: str) -> None: - self.ledger.emit(plan_id, task_id, "escalation:applied", - {"escalation_id": escalation_id}) + def mark_applied(self, plan_id: str, task_id: str, escalation_id: str) -> None: + self.ledger.emit(plan_id, task_id, "escalation:applied", {"escalation_id": escalation_id}) diff --git a/src/sin_delegate/ledger.py b/src/sin_delegate/ledger.py index 13366e5f..13d78501 100644 --- a/src/sin_delegate/ledger.py +++ b/src/sin_delegate/ledger.py @@ -57,24 +57,24 @@ def register_run(self, plan_id: str, goal: str, plan_json: str) -> None: db.execute( "INSERT OR IGNORE INTO runs(plan_id, goal, plan_json, " "created_at) VALUES (?, ?, ?, ?)", - (plan_id, goal, plan_json, time.time())) + (plan_id, goal, plan_json, time.time()), + ) - def emit(self, plan_id: str, task_id: str, kind: str, - payload: dict[str, Any] | None = None) -> None: + def emit( + self, plan_id: str, task_id: str, kind: str, payload: dict[str, Any] | None = None + ) -> None: with self._conn() as db: db.execute( - "INSERT INTO events(plan_id, task_id, kind, payload, ts) " - "VALUES (?, ?, ?, ?, ?)", - (plan_id, task_id, kind, json.dumps(payload or {}), - time.time())) + "INSERT INTO events(plan_id, task_id, kind, payload, ts) VALUES (?, ?, ?, ?, ?)", + (plan_id, task_id, kind, json.dumps(payload or {}), time.time()), + ) def task_states(self, plan_id: str) -> dict[str, TaskState]: states: dict[str, TaskState] = {} with self._conn() as db: rows = db.execute( - "SELECT task_id, kind FROM events WHERE plan_id=? " - "ORDER BY seq", - (plan_id,)).fetchall() + "SELECT task_id, kind FROM events WHERE plan_id=? ORDER BY seq", (plan_id,) + ).fetchall() for task_id, kind in rows: if kind.startswith("state:"): try: @@ -82,44 +82,37 @@ def task_states(self, plan_id: str) -> dict[str, TaskState]: except ValueError: # Corrupt state string: keep the previous state and # surface it via a synthetic event for debugging. - self.emit(plan_id, task_id, "ledger:corrupt_state", - {"kind": kind}) + self.emit(plan_id, task_id, "ledger:corrupt_state", {"kind": kind}) return states def attempts(self, plan_id: str, task_id: str) -> int: with self._conn() as db: (n,) = db.execute( - "SELECT COUNT(*) FROM events WHERE plan_id=? AND " - "task_id=? AND kind='attempt'", - (plan_id, task_id)).fetchone() + "SELECT COUNT(*) FROM events WHERE plan_id=? AND task_id=? AND kind='attempt'", + (plan_id, task_id), + ).fetchone() return int(n) def history(self, plan_id: str) -> list[dict[str, Any]]: with self._conn() as db: rows = db.execute( - "SELECT seq, task_id, kind, payload, ts FROM events " - "WHERE plan_id=? ORDER BY seq", - (plan_id,)).fetchall() + "SELECT seq, task_id, kind, payload, ts FROM events WHERE plan_id=? ORDER BY seq", + (plan_id,), + ).fetchall() return [ - {"seq": s, "task_id": t, "kind": k, - "payload": json.loads(p), "ts": ts} + {"seq": s, "task_id": t, "kind": k, "payload": json.loads(p), "ts": ts} for s, t, k, p, ts in rows ] def load_plan_json(self, plan_id: str) -> str | None: with self._conn() as db: - row = db.execute( - "SELECT plan_json FROM runs WHERE plan_id=?", - (plan_id,)).fetchone() + row = db.execute("SELECT plan_json FROM runs WHERE plan_id=?", (plan_id,)).fetchone() return row[0] if row else None def list_runs(self, limit: int = 20) -> list[dict[str, Any]]: with self._conn() as db: rows = db.execute( - "SELECT plan_id, goal, created_at FROM runs " - "ORDER BY created_at DESC LIMIT ?", - (limit,)).fetchall() - return [ - {"plan_id": p, "goal": g, "created_at": c} - for p, g, c in rows - ] + "SELECT plan_id, goal, created_at FROM runs ORDER BY created_at DESC LIMIT ?", + (limit,), + ).fetchall() + return [{"plan_id": p, "goal": g, "created_at": c} for p, g, c in rows] diff --git a/src/sin_delegate/mcp_tools.py b/src/sin_delegate/mcp_tools.py index d899b3c4..b2a583e7 100644 --- a/src/sin_delegate/mcp_tools.py +++ b/src/sin_delegate/mcp_tools.py @@ -18,8 +18,7 @@ "type": "object", "required": ["plan"], "properties": { - "plan": {"type": "object", - "description": "Plan: {goal, base_branch?, tasks:[{...}]}"}, + "plan": {"type": "object", "description": "Plan: {goal, base_branch?, tasks:[{...}]}"}, "repo": {"type": "string", "default": "."}, "parallel": {"type": "integer", "default": 4}, "dry_run": {"type": "boolean", "default": False}, @@ -43,14 +42,16 @@ async def _tool_delegate(args: dict) -> dict: return {"error": f"'parallel' must be an integer: {e}"} try: plan = plan_from_dict( - {"goal": data.get("goal", "mcp-task"), - "base_branch": data.get("base_branch", "main"), - "tasks": data["tasks"]}, - repo=args.get("repo", ".")) + { + "goal": data.get("goal", "mcp-task"), + "base_branch": data.get("base_branch", "main"), + "tasks": data["tasks"], + }, + repo=args.get("repo", "."), + ) except Exception as e: return {"error": f"invalid plan: {type(e).__name__}: {e}"} - dele = Delegator(plan, max_parallel=parallel, - dry_run=bool(args.get("dry_run", False))) + dele = Delegator(plan, max_parallel=parallel, dry_run=bool(args.get("dry_run", False))) result = await dele.run() return json.loads(result.to_json()) @@ -67,8 +68,7 @@ async def _tool_status(args: dict) -> dict: if isinstance(pid, dict): return pid states = Ledger().task_states(pid) - return {"plan_id": pid, - "states": {k: v.value for k, v in states.items()}} + return {"plan_id": pid, "states": {k: v.value for k, v in states.items()}} async def _tool_history(args: dict) -> dict: @@ -90,8 +90,7 @@ async def _tool_escalations(args: dict) -> dict: pid = _plan_id(args) if isinstance(pid, dict): return pid - return {"plan_id": pid, - "escalations": EscalationBroker().open_escalations(pid)} + return {"plan_id": pid, "escalations": EscalationBroker().open_escalations(pid)} async def _tool_resolve(args: dict) -> dict: @@ -102,8 +101,12 @@ async def _tool_resolve(args: dict) -> dict: if not args.get(field): return {"error": f"missing required field '{field}'"} return EscalationBroker().resolve( - pid, args["escalation_id"], args["option_id"], - user_input=args.get("input", ""), decided_by="parent_agent") + pid, + args["escalation_id"], + args["option_id"], + user_input=args.get("input", ""), + decided_by="parent_agent", + ) def register(add_tool: Callable) -> None: @@ -113,51 +116,69 @@ def register(add_tool: Callable) -> None: "Delegate a goal to parallel, budget-governed sub-agents. Tasks " "run in isolated git worktrees, pass verification gates (diff " "screen, tests, architecture) and merge back atomically. " - "Resumable: re-submitting an identical plan skips DONE tasks."), + "Resumable: re-submitting an identical plan skips DONE tasks." + ), schema=_PLAN_SCHEMA, handler=_tool_delegate, ) add_tool( name="sin_delegate_status", description="Current state of every task in a delegation run.", - schema={"type": "object", "required": ["plan_id"], - "properties": {"plan_id": {"type": "string"}}}, + schema={ + "type": "object", + "required": ["plan_id"], + "properties": {"plan_id": {"type": "string"}}, + }, handler=_tool_status, ) add_tool( name="sin_delegate_history", description="Full audit event log of a delegation run.", - schema={"type": "object", "required": ["plan_id"], - "properties": {"plan_id": {"type": "string"}}}, + schema={ + "type": "object", + "required": ["plan_id"], + "properties": {"plan_id": {"type": "string"}}, + }, handler=_tool_history, ) add_tool( name="sin_delegate_cancel", description="Cooperatively cancel a running delegation.", - schema={"type": "object", "required": ["plan_id"], - "properties": {"plan_id": {"type": "string"}}}, + schema={ + "type": "object", + "required": ["plan_id"], + "properties": {"plan_id": {"type": "string"}}, + }, handler=_tool_cancel, ) add_tool( name="sin_delegate_escalations", description=( "Open decision requests of a delegation run. Each escalation " - "contains full evidence and a finite set of typed options."), - schema={"type": "object", "required": ["plan_id"], - "properties": {"plan_id": {"type": "string"}}}, + "contains full evidence and a finite set of typed options." + ), + schema={ + "type": "object", + "required": ["plan_id"], + "properties": {"plan_id": {"type": "string"}}, + }, handler=_tool_escalations, ) add_tool( name="sin_delegate_resolve", description=( "Answer an escalation by choosing an option_id. Options of " - "type retry_with_guidance require 'input'."), - schema={"type": "object", - "required": ["plan_id", "escalation_id", "option_id"], - "properties": { - "plan_id": {"type": "string"}, - "escalation_id": {"type": "string"}, - "option_id": {"type": "string"}, - "input": {"type": "string", "default": ""}}}, + "type retry_with_guidance require 'input'." + ), + schema={ + "type": "object", + "required": ["plan_id", "escalation_id", "option_id"], + "properties": { + "plan_id": {"type": "string"}, + "escalation_id": {"type": "string"}, + "option_id": {"type": "string"}, + "input": {"type": "string", "default": ""}, + }, + }, handler=_tool_resolve, ) diff --git a/src/sin_delegate/memory.py b/src/sin_delegate/memory.py index 82a03710..d15dcd93 100644 --- a/src/sin_delegate/memory.py +++ b/src/sin_delegate/memory.py @@ -11,7 +11,9 @@ from typing import Any try: - from sin_brain import recall as _recall, remember as _remember + from sin_brain import recall as _recall + from sin_brain import remember as _remember + _AVAILABLE = True except Exception: # pragma: no cover _AVAILABLE = False @@ -25,8 +27,7 @@ def recall_pitfalls(goal: str, limit: int = 5) -> list[str]: if not _AVAILABLE: return [] try: - hits: list[dict[str, Any]] = _recall( - query=goal, kinds=["pitfall", "fix"], limit=limit) + hits: list[dict[str, Any]] = _recall(query=goal, kinds=["pitfall", "fix"], limit=limit) return [h.get("text", "") for h in hits if h.get("text")] except Exception: return [] @@ -38,8 +39,7 @@ def remember_pitfall(goal: str, task_title: str, detail: str) -> None: try: _remember( kind="pitfall", - text=(f"[delegate] goal={goal!r} task={task_title!r}: " - f"{detail}"), + text=(f"[delegate] goal={goal!r} task={task_title!r}: {detail}"), tags=["sin-delegate"], ) except Exception: diff --git a/src/sin_delegate/models.py b/src/sin_delegate/models.py index c817dc5e..59aed950 100644 --- a/src/sin_delegate/models.py +++ b/src/sin_delegate/models.py @@ -43,7 +43,7 @@ class Budget: backoff_base: float = 2.0 def retry_delay(self, attempt: int) -> float: - return min(self.backoff_base ** attempt, 60.0) + return min(self.backoff_base**attempt, 60.0) @dataclass(frozen=True) @@ -70,17 +70,18 @@ class Task: def finalize(self) -> "Task": if self.id: return self - payload = json.dumps({ - "title": self.title, - "instructions": self.instructions, - "deps": sorted(self.deps), - "files": sorted(self.files_hint), - "backend": self.agent.backend, - }, sort_keys=True).encode() + payload = json.dumps( + { + "title": self.title, + "instructions": self.instructions, + "deps": sorted(self.deps), + "files": sorted(self.files_hint), + "backend": self.agent.backend, + }, + sort_keys=True, + ).encode() tid = hashlib.blake2b(payload, digest_size=8).hexdigest() - return Task(**{**{f: getattr(self, f) - for f in self.__dataclass_fields__}, - "id": tid}) + return Task(**{**{f: getattr(self, f) for f in self.__dataclass_fields__}, "id": tid}) @dataclass(frozen=True) @@ -94,8 +95,8 @@ class Plan: @property def id(self) -> str: return hashlib.blake2b( - (self.goal + "".join(t.id for t in self.tasks)).encode(), - digest_size=8).hexdigest() + (self.goal + "".join(t.id for t in self.tasks)).encode(), digest_size=8 + ).hexdigest() def validate(self) -> None: ids = {t.id for t in self.tasks} @@ -104,16 +105,14 @@ def validate(self) -> None: for t in self.tasks: for d in t.deps: if d not in ids: - raise ValueError( - f"task {t.id} depends on unknown task {d}") + raise ValueError(f"task {t.id} depends on unknown task {d}") graph = {t.id: set(t.deps) for t in self.tasks} WHITE, GRAY, BLACK = 0, 1, 2 color = {tid: WHITE for tid in graph} def visit(node: str) -> None: if color[node] == GRAY: - raise ValueError( - f"dependency cycle involving task {node}") + raise ValueError(f"dependency cycle involving task {node}") if color[node] == BLACK: return color[node] = GRAY @@ -155,8 +154,7 @@ class RunResult: @property def ok(self) -> bool: return all( - o.state in (TaskState.DONE, TaskState.SKIPPED) - for o in self.outcomes.values() + o.state in (TaskState.DONE, TaskState.SKIPPED) for o in self.outcomes.values() ) and any(o.state == TaskState.DONE for o in self.outcomes.values()) def to_json(self) -> str: diff --git a/src/sin_delegate/multirepo.py b/src/sin_delegate/multirepo.py index 058a4dcb..f72beab9 100644 --- a/src/sin_delegate/multirepo.py +++ b/src/sin_delegate/multirepo.py @@ -12,8 +12,7 @@ from .models import Plan, Task from .worktree import GitError, Worktree, _git -_CONTRACT_RE = re.compile( - r"<sin-contract>\s*(\{.*?\})\s*</sin-contract>", re.DOTALL) +_CONTRACT_RE = re.compile(r"<sin-contract>\s*(\{.*?\})\s*</sin-contract>", re.DOTALL) CONTRACT_INSTRUCTIONS = """\ Wenn dieser Task Schnittstellen definiert, die andere Repos brauchen @@ -55,11 +54,12 @@ def render(self, dep_ids, titles: dict) -> str: contracts = self.collect(dep_ids) if not contracts: return "" - parts = ["Verträge (Contracts) deiner Upstream-Tasks — das sind " - "implementierte FAKTEN, halte dich exakt daran:"] + parts = [ + "Verträge (Contracts) deiner Upstream-Tasks — das sind " + "implementierte FAKTEN, halte dich exakt daran:" + ] for tid, c in contracts.items(): - parts.append(f"## {titles.get(tid, tid)}\n" - + json.dumps(c, indent=2)) + parts.append(f"## {titles.get(tid, tid)}\n" + json.dumps(c, indent=2)) return "\n\n".join(parts) @@ -85,21 +85,21 @@ def validate(self) -> None: self.plan.validate() for tid, rname in self.task_repo.items(): if rname not in self.repos: - raise ValueError( - f"task {tid} references unknown repo {rname!r}") + raise ValueError(f"task {tid} references unknown repo {rname!r}") for repo in self.repos.values(): if not (Path(repo.path) / ".git").exists(): - raise GitError( - f"{repo.path} is not a git repository") + raise GitError(f"{repo.path} is not a git repository") def multirepo_plan_from_dict(data: dict) -> MultiRepoPlan: """Plan format: {goal, repos: {name: {path, base_branch?}}, tasks: [{key, repo, title, instructions, deps?, ...}]}""" repos = { - name: RepoRef(name=name, - path=str(Path(cfg["path"]).resolve()), - base_branch=cfg.get("base_branch", "main")) + name: RepoRef( + name=name, + path=str(Path(cfg["path"]).resolve()), + base_branch=cfg.get("base_branch", "main"), + ) for name, cfg in data["repos"].items() } if not repos: @@ -110,6 +110,7 @@ def multirepo_plan_from_dict(data: dict) -> MultiRepoPlan: # two-pass finalize() includes deps in the hash, so pre-resolved ids # from a separate pass wouldn't match the final hash). from .planfile import _task_from as _build_task + title_to_id: dict = {} tasks: list = [] key_repo: dict = {} @@ -122,18 +123,19 @@ def multirepo_plan_from_dict(data: dict) -> MultiRepoPlan: key = rt.get("key") or rt["title"] rname = rt.get("repo", default_repo) if rname not in repos: - raise ValueError( - f"task {key!r}: unknown repo {rname!r}") - dep_ids = tuple(title_to_id.get(d, d) - for d in rt.get("deps", [])) + raise ValueError(f"task {key!r}: unknown repo {rname!r}") + dep_ids = tuple(title_to_id.get(d, d) for d in rt.get("deps", [])) task = _build_task(rt, deps=dep_ids).finalize() # Pin the pre-resolved id (deps don't change the content-hash # for these tasks, since title+instructions+backend are the # primary input and deps are resolved after finalization). if task.id != title_to_id[key]: - task = Task(**{**{f: getattr(task, f) - for f in task.__dataclass_fields__}, - "id": title_to_id[key]}) + task = Task( + **{ + **{f: getattr(task, f) for f in task.__dataclass_fields__}, + "id": title_to_id[key], + } + ) tasks.append(task) key_repo[task.id] = rname @@ -144,8 +146,7 @@ def multirepo_plan_from_dict(data: dict) -> MultiRepoPlan: base_branch="main", ) plan.validate() - mrp = MultiRepoPlan(goal=data["goal"], repos=repos, plan=plan, - task_repo=key_repo) + mrp = MultiRepoPlan(goal=data["goal"], repos=repos, plan=plan, task_repo=key_repo) mrp.validate() return mrp @@ -175,47 +176,55 @@ def __init__(self, repos: dict, ledger: Ledger, plan_id: str) -> None: def stage(self, unit: MergeUnit) -> None: self.units.append(unit) - self.ledger.emit(self.plan_id, unit.task_id, "merge:staged", - {"repo": unit.repo_name, - "branch": unit.worktree.branch}) + self.ledger.emit( + self.plan_id, + unit.task_id, + "merge:staged", + {"repo": unit.repo_name, "branch": unit.worktree.branch}, + ) def commit(self, order: list) -> None: rank = {tid: i for i, tid in enumerate(order)} - units = sorted(self.units, - key=lambda u: rank.get(u.task_id, 1 << 30)) + units = sorted(self.units, key=lambda u: rank.get(u.task_id, 1 << 30)) snapshots: dict = {} for name, ref in self.repos.items(): tag = f"sin-global-snap/{self.plan_id}" _git(ref.path, "tag", "-f", tag, ref.base_branch) snapshots[name] = tag - self.ledger.emit(self.plan_id, "*", "merge:phase2_begin", - {"units": len(units), - "snapshots": list(snapshots)}) + self.ledger.emit( + self.plan_id, + "*", + "merge:phase2_begin", + {"units": len(units), "snapshots": list(snapshots)}, + ) merged: list = [] try: for unit in units: unit.worktree.merge_back() merged.append(unit) - self.ledger.emit(self.plan_id, unit.task_id, "merged", - {"repo": unit.repo_name, "phase": 2}) + self.ledger.emit( + self.plan_id, unit.task_id, "merged", {"repo": unit.repo_name, "phase": 2} + ) except GitError as e: rolled: set = set() for unit in merged: ref = self.repos[unit.repo_name] if unit.repo_name not in rolled: - _git(ref.path, "reset", "--hard", - snapshots[unit.repo_name], check=False) + _git(ref.path, "reset", "--hard", snapshots[unit.repo_name], check=False) rolled.add(unit.repo_name) self.ledger.emit( - self.plan_id, "*", "merge:phase2_rollback", - {"failed_unit": (units[len(merged)].task_id - if len(merged) < len(units) else "?"), - "rolled_back_repos": sorted(rolled), - "error": str(e)}) - raise GitError( - f"two-phase commit aborted, all repos restored: {e}" - ) from e - self.ledger.emit(self.plan_id, "*", "merge:phase2_done", - {"merged": len(merged)}) + self.plan_id, + "*", + "merge:phase2_rollback", + { + "failed_unit": ( + units[len(merged)].task_id if len(merged) < len(units) else "?" + ), + "rolled_back_repos": sorted(rolled), + "error": str(e), + }, + ) + raise GitError(f"two-phase commit aborted, all repos restored: {e}") from e + self.ledger.emit(self.plan_id, "*", "merge:phase2_done", {"merged": len(merged)}) diff --git a/src/sin_delegate/multirepo_engine.py b/src/sin_delegate/multirepo_engine.py index 90b97faa..040f291e 100644 --- a/src/sin_delegate/multirepo_engine.py +++ b/src/sin_delegate/multirepo_engine.py @@ -21,9 +21,15 @@ from . import memory from .escalation import EscalationBroker, EscalationKind from .ledger import Ledger -from .models import (Risk, RunResult, Task, TaskOutcome, TaskState) -from .multirepo import (CONTRACT_INSTRUCTIONS, ContractStore, MergeUnit, - MultiRepoPlan, TwoPhaseMerger, extract_contract) +from .models import Risk, RunResult, Task, TaskOutcome, TaskState +from .multirepo import ( + CONTRACT_INSTRUCTIONS, + ContractStore, + MergeUnit, + MultiRepoPlan, + TwoPhaseMerger, + extract_contract, +) from .runner import runner_for from .scheduler import Scheduler from .verify import verify @@ -50,15 +56,17 @@ def visit(tid: str) -> None: class MultiRepoDelegator: - def __init__(self, mrp: MultiRepoPlan, ledger: Ledger | None = None, - max_parallel: int = 4) -> None: + def __init__( + self, mrp: MultiRepoPlan, ledger: Ledger | None = None, max_parallel: int = 4 + ) -> None: mrp.validate() self.mrp = mrp self.plan = mrp.plan self.ledger = ledger or Ledger() self.max_parallel = max_parallel - self.wtms = {name: WorktreeManager(ref.path, ref.base_branch) - for name, ref in mrp.repos.items()} + self.wtms = { + name: WorktreeManager(ref.path, ref.base_branch) for name, ref in mrp.repos.items() + } self.contracts = ContractStore(self.ledger, self.plan.id) self.merger = TwoPhaseMerger(mrp.repos, self.ledger, self.plan.id) self.broker = EscalationBroker(self.ledger) @@ -68,15 +76,15 @@ def __init__(self, mrp: MultiRepoPlan, ledger: Ledger | None = None, async def run(self) -> RunResult: started = time.time() - self.ledger.register_run(self.plan.id, self.plan.goal, - self._plan_json()) - scheduler = Scheduler(self.plan, self.ledger, self._execute_task, - max_parallel=self.max_parallel) + self.ledger.register_run(self.plan.id, self.plan.goal, self._plan_json()) + scheduler = Scheduler( + self.plan, self.ledger, self._execute_task, max_parallel=self.max_parallel + ) outcomes = await scheduler.run() all_verified = all( - o.state in (TaskState.DONE, TaskState.SKIPPED) - for o in outcomes.values()) + o.state in (TaskState.DONE, TaskState.SKIPPED) for o in outcomes.values() + ) if all_verified and self.merger.units: try: self.merger.commit(_topo_order(self.plan)) @@ -85,28 +93,33 @@ async def run(self) -> RunResult: except GitError as e: for unit in self.merger.units: esc = self.broker.raise_escalation( - self.plan.id, unit.task_id, + self.plan.id, + unit.task_id, self._titles[unit.task_id], EscalationKind.MERGE_CONFLICT, summary=f"two-phase commit aborted: {e}", evidence={"repo": unit.repo_name}, branch=unit.worktree.branch, - worktree=str(unit.worktree.path)) + worktree=str(unit.worktree.path), + ) outcomes[unit.task_id] = TaskOutcome( - unit.task_id, TaskState.ESCALATED, + unit.task_id, + TaskState.ESCALATED, error=f"escalation {esc.id}: {e}", - branch=unit.worktree.branch) + branch=unit.worktree.branch, + ) self.ledger.emit( - self.plan.id, unit.task_id, - "state:escalated", {"error": str(e)}) + self.plan.id, unit.task_id, "state:escalated", {"error": str(e)} + ) elif self.merger.units: self.ledger.emit( - self.plan.id, "*", "merge:phase2_withheld", - {"reason": "not all tasks verified", - "staged": len(self.merger.units)}) + self.plan.id, + "*", + "merge:phase2_withheld", + {"reason": "not all tasks verified", "staged": len(self.merger.units)}, + ) - return RunResult(self.plan.id, self.plan.goal, outcomes, - started, time.time()) + return RunResult(self.plan.id, self.plan.goal, outcomes, started, time.time()) def run_sync(self) -> RunResult: return asyncio.run(self.run()) @@ -115,17 +128,20 @@ async def _execute_task(self, task: Task) -> TaskOutcome: repo_name = self.mrp.task_repo[task.id] wt = self.wtms[repo_name].create(self.plan.id, task.id) outcome = TaskOutcome( - task.id, TaskState.RUNNING, - worktree=str(wt.path), branch=wt.branch, + task.id, + TaskState.RUNNING, + worktree=str(wt.path), + branch=wt.branch, ) enriched = self._enrich(task) runner = runner_for(enriched.agent) - res = await runner.run(enriched, str(wt.path), - timeout=task.budget.max_seconds) + res = await runner.run(enriched, str(wt.path), timeout=task.budget.max_seconds) self.ledger.emit( - self.plan.id, task.id, "agent:finished", - {"repo": repo_name, "exit": res.exit_code, - "tail": res.output[-2000:]}) + self.plan.id, + task.id, + "agent:finished", + {"repo": repo_name, "exit": res.exit_code, "tail": res.output[-2000:]}, + ) if not res.ok: outcome.state = TaskState.FAILED outcome.error = f"agent exited {res.exit_code}" @@ -135,8 +151,7 @@ async def _execute_task(self, task: Task) -> TaskOutcome: if contract: self.contracts.publish(task.id, contract) - if not wt.commit_all( - f"sin-delegate: {task.title} [{task.id}]"): + if not wt.commit_all(f"sin-delegate: {task.title} [{task.id}]"): outcome.state = TaskState.FAILED outcome.error = "agent finished but changed nothing" return outcome @@ -146,28 +161,32 @@ async def _execute_task(self, task: Task) -> TaskOutcome: verdict = verify(task, wt) outcome.verdict = verdict self.ledger.emit( - self.plan.id, task.id, "verdict", - {"passed": verdict.passed, "gates": verdict.gates}) + self.plan.id, task.id, "verdict", {"passed": verdict.passed, "gates": verdict.gates} + ) if not verdict.passed: - memory.remember_pitfall( - self.plan.goal, task.title, verdict.summary) + memory.remember_pitfall(self.plan.goal, task.title, verdict.summary) if task.risk == Risk.HIGH: esc = self.broker.raise_escalation( - self.plan.id, task.id, task.title, + self.plan.id, + task.id, + task.title, EscalationKind.GATE_FAILURE, - summary=f"HIGH-risk task failed gates: " - f"{verdict.summary}", - evidence={"gates": verdict.gates, - "diff_stat": wt.diff_stat(), - "repo": repo_name}, - branch=wt.branch, worktree=str(wt.path)) + summary=f"HIGH-risk task failed gates: {verdict.summary}", + evidence={ + "gates": verdict.gates, + "diff_stat": wt.diff_stat(), + "repo": repo_name, + }, + branch=wt.branch, + worktree=str(wt.path), + ) outcome.state = TaskState.ESCALATED outcome.error = f"escalation {esc.id}" else: self._retry_context[task.id] = ( - "Dein letzter Versuch riss die Gates:\n" - + verdict.summary) + "Dein letzter Versuch riss die Gates:\n" + verdict.summary + ) outcome.state = TaskState.FAILED outcome.error = verdict.summary return outcome @@ -183,29 +202,35 @@ def _enrich(self, task: Task) -> Task: if contracts: extra.append(contracts) if self._pitfalls: - extra.append( - "Bekannte Pitfalls (vermeiden):\n- " - + "\n- ".join(self._pitfalls)) + extra.append("Bekannte Pitfalls (vermeiden):\n- " + "\n- ".join(self._pitfalls)) if task.id in self._retry_context: extra.append(self._retry_context[task.id]) - return replace( - task, - instructions=task.instructions + "\n\n" - + "\n\n".join(extra)) + return replace(task, instructions=task.instructions + "\n\n" + "\n\n".join(extra)) def _plan_json(self) -> str: - return json.dumps({ - "goal": self.plan.goal, - "multi_repo": True, - "repos": {n: {"path": r.path, "base_branch": r.base_branch} - for n, r in self.mrp.repos.items()}, - "tasks": [{ - "id": t.id, "title": t.title, - "instructions": t.instructions, - "deps": list(t.deps), - "files_hint": list(t.files_hint), - "risk": t.risk.value, "verify": list(t.verify), - "backend": t.agent.backend, "model": t.agent.model, - "repo": self.mrp.task_repo[t.id], - } for t in self.plan.tasks], - }, indent=2) + return json.dumps( + { + "goal": self.plan.goal, + "multi_repo": True, + "repos": { + n: {"path": r.path, "base_branch": r.base_branch} + for n, r in self.mrp.repos.items() + }, + "tasks": [ + { + "id": t.id, + "title": t.title, + "instructions": t.instructions, + "deps": list(t.deps), + "files_hint": list(t.files_hint), + "risk": t.risk.value, + "verify": list(t.verify), + "backend": t.agent.backend, + "model": t.agent.model, + "repo": self.mrp.task_repo[t.id], + } + for t in self.plan.tasks + ], + }, + indent=2, + ) diff --git a/src/sin_delegate/observe.py b/src/sin_delegate/observe.py index 18b6340a..558f51ff 100644 --- a/src/sin_delegate/observe.py +++ b/src/sin_delegate/observe.py @@ -10,28 +10,38 @@ import json import sys import time -from collections import deque -from dataclasses import dataclass, field +from dataclasses import dataclass from .ledger import Ledger from .models import TaskState _GLYPH = { - TaskState.PENDING: "·", TaskState.READY: "○", - TaskState.RUNNING: "▶", TaskState.VERIFYING: "▣", - TaskState.MERGING: "⇡", TaskState.DONE: "✓", - TaskState.FAILED: "✗", TaskState.SKIPPED: "⤼", - TaskState.CANCELLED: "⊘", TaskState.ESCALATED: "‼", + TaskState.PENDING: "·", + TaskState.READY: "○", + TaskState.RUNNING: "▶", + TaskState.VERIFYING: "▣", + TaskState.MERGING: "⇡", + TaskState.DONE: "✓", + TaskState.FAILED: "✗", + TaskState.SKIPPED: "⤼", + TaskState.CANCELLED: "⊘", + TaskState.ESCALATED: "‼", } -_TERMINAL = {TaskState.DONE, TaskState.FAILED, TaskState.SKIPPED, - TaskState.CANCELLED, TaskState.ESCALATED} +_TERMINAL = { + TaskState.DONE, + TaskState.FAILED, + TaskState.SKIPPED, + TaskState.CANCELLED, + TaskState.ESCALATED, +} @dataclass class StatusBoard: - def __init__(self, plan_id: str, ledger: Ledger | None = None, - interval: float = 1.0, stream=sys.stderr) -> None: + def __init__( + self, plan_id: str, ledger: Ledger | None = None, interval: float = 1.0, stream=sys.stderr + ) -> None: self.plan_id = plan_id self.ledger = ledger or Ledger() self.interval = interval @@ -53,14 +63,12 @@ def _render_once(self, titles: dict) -> bool: if not states: return False if self._lines_drawn and self.stream.isatty(): - self.stream.write( - f"\x1b[{self._lines_drawn}F\x1b[J") + self.stream.write(f"\x1b[{self._lines_drawn}F\x1b[J") lines = [] for tid in sorted(states, key=lambda t: titles.get(t, t)): st = states[tid] title = titles.get(tid, tid)[:52] - lines.append(f" {_GLYPH.get(st, '?')} " - f"{st.value:<10} {title}") + lines.append(f" {_GLYPH.get(st, '?')} {st.value:<10} {title}") done = sum(1 for s in states.values() if s in _TERMINAL) lines.append(f" {done}/{len(states)} terminal") self.stream.write("\n".join(lines) + "\n") @@ -86,9 +94,9 @@ def report(plan_id: str, ledger: Ledger | None = None) -> str: plan = json.loads(raw) if raw else {"goal": "?", "tasks": []} titles = {t["id"]: t["title"] for t in plan.get("tasks", [])} - per_task: dict = {tid: {"attempts": 0, "seconds": 0.0, - "verdict": None, "error": ""} - for tid in states} + per_task: dict = { + tid: {"attempts": 0, "seconds": 0.0, "verdict": None, "error": ""} for tid in states + } for ev in events: tid = ev["task_id"] if tid not in per_task: @@ -97,25 +105,25 @@ def report(plan_id: str, ledger: Ledger | None = None) -> str: per_task[tid]["attempts"] += 1 elif ev["kind"] == "verdict": per_task[tid]["verdict"] = ev["payload"] - elif (ev["kind"].startswith("state:") - and ev["payload"].get("seconds")): + elif ev["kind"].startswith("state:") and ev["payload"].get("seconds"): per_task[tid]["seconds"] = ev["payload"]["seconds"] per_task[tid]["error"] = ev["payload"].get("error", "") merged = [t for t, s in states.items() if s == TaskState.DONE] - escalated = [t for t, s in states.items() - if s == TaskState.ESCALATED] - failed = [t for t, s in states.items() - if s in (TaskState.FAILED, TaskState.SKIPPED)] - - out = [f"## Delegation Report — `{plan_id}`", - f"**Goal:** {plan.get('goal', '?')}", - "", - f"| | count |", f"|---|---|", - f"| merged | {len(merged)} |", - f"| escalated | {len(escalated)} |", - f"| failed/skipped | {len(failed)} |", - ""] + escalated = [t for t, s in states.items() if s == TaskState.ESCALATED] + failed = [t for t, s in states.items() if s in (TaskState.FAILED, TaskState.SKIPPED)] + + out = [ + f"## Delegation Report — `{plan_id}`", + f"**Goal:** {plan.get('goal', '?')}", + "", + "| | count |", + "|---|---|", + f"| merged | {len(merged)} |", + f"| escalated | {len(escalated)} |", + f"| failed/skipped | {len(failed)} |", + "", + ] def section(name: str, ids: list) -> None: if not ids: @@ -123,17 +131,19 @@ def section(name: str, ids: list) -> None: out.append(f"### {name}") for tid in ids: info = per_task[tid] - line = (f"- **{titles.get(tid, tid)}** — " - f"{info['attempts']} attempt(s), " - f"{info['seconds']:.0f}s") + line = ( + f"- **{titles.get(tid, tid)}** — " + f"{info['attempts']} attempt(s), " + f"{info['seconds']:.0f}s" + ) if info["error"]: line += f" — `{info['error'][:120]}`" out.append(line) v = info["verdict"] if v: gates = ", ".join( - f"{g}:{'ok' if r['ok'] else 'FAIL'}" - for g, r in v.get("gates", {}).items()) + f"{g}:{'ok' if r['ok'] else 'FAIL'}" for g, r in v.get("gates", {}).items() + ) out.append(f" - gates: {gates}") out.append("") diff --git a/src/sin_delegate/planfile.py b/src/sin_delegate/planfile.py index 5bb32e7e..b77a5289 100644 --- a/src/sin_delegate/planfile.py +++ b/src/sin_delegate/planfile.py @@ -43,13 +43,12 @@ def plan_from_dict(data: dict, repo: str = ".") -> Plan: key = rt.get("key") or rt["title"] draft = _task_from(rt, deps=()) if rt.get("id"): - draft = Task(**{**{f: getattr(draft, f) - for f in draft.__dataclass_fields__}, - "id": rt["id"]}) + draft = Task( + **{**{f: getattr(draft, f) for f in draft.__dataclass_fields__}, "id": rt["id"]} + ) drafts[key] = draft - key_to_final_id = {k: t.id or t.finalize().id - for k, t in drafts.items()} + key_to_final_id = {k: t.id or t.finalize().id for k, t in drafts.items()} # pass 2: rebuild with resolved dep ids. If a dep already IS a task # id (not a human key), use it directly. diff --git a/src/sin_delegate/planner.py b/src/sin_delegate/planner.py index a991a2e4..a64b1f4a 100644 --- a/src/sin_delegate/planner.py +++ b/src/sin_delegate/planner.py @@ -16,7 +16,6 @@ import re from collections import Counter from pathlib import Path -from typing import Any from . import memory from .models import AgentSpec, Plan, Task @@ -24,9 +23,17 @@ from .runner import runner_for _LANG_BY_EXT = { - ".py": "python", ".ts": "typescript", ".tsx": "typescript", - ".js": "javascript", ".go": "go", ".rs": "rust", ".java": "java", - ".rb": "ruby", ".php": "php", ".cs": "csharp", ".vue": "vue", + ".py": "python", + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".go": "go", + ".rs": "rust", + ".java": "java", + ".rb": "ruby", + ".php": "php", + ".cs": "csharp", + ".vue": "vue", } _MARKERS = { @@ -46,13 +53,18 @@ def recon(repo: str | Path, max_files: int = 400) -> dict: """Deterministic repo facts. No LLM, no guessing.""" root = Path(repo).resolve() if not root.exists(): - return {"languages": {}, "markers": [], "has_tests": False, - "top_dirs": [], "file_sample": []} + return { + "languages": {}, + "markers": [], + "has_tests": False, + "top_dirs": [], + "file_sample": [], + } try: out = subprocess_run_git_ls_files(root) except Exception: out = "" - files = [l for l in out.splitlines() if l][:max_files * 4] + files = [line for line in out.splitlines() if line][: max_files * 4] if not files: for p in root.rglob("*"): if p.is_file(): @@ -64,15 +76,11 @@ def recon(repo: str | Path, max_files: int = 400) -> dict: if len(files) >= max_files * 4: break - langs = Counter( - _LANG_BY_EXT[Path(f).suffix] for f in files - if Path(f).suffix in _LANG_BY_EXT - ) - markers = [desc for marker, desc in _MARKERS.items() - if (root / marker).exists()] + langs = Counter(_LANG_BY_EXT[Path(f).suffix] for f in files if Path(f).suffix in _LANG_BY_EXT) + markers = [desc for marker, desc in _MARKERS.items() if (root / marker).exists()] has_tests = any( - re.search(r"(^|/)(tests?|__tests__|spec)/", f) or - re.search(r"(test_.*\.py|.*\.test\.[jt]sx?)$", f) + re.search(r"(^|/)(tests?|__tests__|spec)/", f) + or re.search(r"(test_.*\.py|.*\.test\.[jt]sx?)$", f) for f in files ) try: @@ -91,9 +99,12 @@ def recon(repo: str | Path, max_files: int = 400) -> dict: def subprocess_run_git_ls_files(root: Path) -> str: import subprocess + return subprocess.run( ["git", "-C", str(root), "ls-files"], - capture_output=True, text=True, timeout=30, + capture_output=True, + text=True, + timeout=30, ).stdout @@ -168,7 +179,7 @@ def _extract_json(text: str) -> dict: elif ch == "}": depth -= 1 if depth == 0: - return json.loads(text[start:i + 1]) + return json.loads(text[start: i + 1]) # fmt: skip raise ValueError("unbalanced JSON in planner output") @@ -176,51 +187,59 @@ class Planner: def __init__(self, backend: str = "opencode", model: str = "") -> None: self.spec = AgentSpec(backend=backend, model=model) - async def plan(self, goal: str, repo: str = ".", - critique: bool = True) -> Plan: + async def plan(self, goal: str, repo: str = ".", critique: bool = True) -> Plan: if self.spec.backend == "echo" or self.spec.model == "_stub_": # Deterministic path for tests: synthesize a minimal plan - t = Task(title="stub", instructions=goal, - agent=AgentSpec(backend="echo", model="")).finalize() + t = Task( + title="stub", instructions=goal, agent=AgentSpec(backend="echo", model="") + ).finalize() return plan_from_dict( - {"goal": goal, "tasks": [ - {"key": t.id, "title": t.title, - "instructions": t.instructions}]}, - repo=repo) + { + "goal": goal, + "tasks": [{"key": t.id, "title": t.title, "instructions": t.instructions}], + }, + repo=repo, + ) facts = recon(repo) pitfalls = memory.recall_pitfalls(goal) - prompt = "\n\n".join(filter(None, [ - f"Ziel: {goal}", - "Repo-Fakten (deterministisch ermittelt, NICHT anzweifeln):\n" - + json.dumps({k: v for k, v in facts.items() - if k != "file_sample"}, indent=2), - "Relevante Dateien (Auszug):\n" - + "\n".join(facts["file_sample"][:120]), - ("Bekannte Pitfalls aus früheren Runs:\n- " - + "\n- ".join(pitfalls)) if pitfalls else "", - _DRAFT_PROMPT, - ])) + prompt = "\n\n".join( + filter( + None, + [ + f"Ziel: {goal}", + "Repo-Fakten (deterministisch ermittelt, NICHT anzweifeln):\n" + + json.dumps({k: v for k, v in facts.items() if k != "file_sample"}, indent=2), + "Relevante Dateien (Auszug):\n" + "\n".join(facts["file_sample"][:120]), + ("Bekannte Pitfalls aus früheren Runs:\n- " + "\n- ".join(pitfalls)) + if pitfalls + else "", + _DRAFT_PROMPT, + ], + ) + ) try: draft = await self._ask(prompt, repo) except Exception: # Backend unavailable — return a minimal valid plan - t = Task(title="fallback", instructions=goal, - agent=self.spec).finalize() + t = Task(title="fallback", instructions=goal, agent=self.spec).finalize() return plan_from_dict( - {"goal": goal, "tasks": [ - {"key": t.id, "title": t.title, - "instructions": t.instructions}]}, - repo=repo) + { + "goal": goal, + "tasks": [{"key": t.id, "title": t.title, "instructions": t.instructions}], + }, + repo=repo, + ) if critique and isinstance(draft, dict) and "tasks" in draft: try: revised = await self._ask( _CRITIQUE_PROMPT.format( - goal=goal, - plan=json.dumps(draft, indent=2, ensure_ascii=False)), - repo) + goal=goal, plan=json.dumps(draft, indent=2, ensure_ascii=False) + ), + repo, + ) if isinstance(revised, dict) and revised.get("tasks"): draft = revised except Exception: @@ -229,25 +248,26 @@ async def plan(self, goal: str, repo: str = ".", try: return plan_from_dict(draft, repo=repo) except Exception: - t = Task(title="fallback", instructions=goal, - agent=self.spec).finalize() + t = Task(title="fallback", instructions=goal, agent=self.spec).finalize() return plan_from_dict( - {"goal": goal, "tasks": [ - {"key": t.id, "title": t.title, - "instructions": t.instructions}]}, - repo=repo) + { + "goal": goal, + "tasks": [{"key": t.id, "title": t.title, "instructions": t.instructions}], + }, + repo=repo, + ) async def _ask(self, prompt: str, repo: str) -> dict: - task = Task(title="plan", instructions=prompt, - agent=self.spec).finalize() + task = Task(title="plan", instructions=prompt, agent=self.spec).finalize() res = await runner_for(self.spec).run(task, cwd=repo, timeout=300) if not res.ok: - raise RuntimeError( - f"planner backend failed: {res.output[-500:]}") + raise RuntimeError(f"planner backend failed: {res.output[-500:]}") return _extract_json(res.output) -def plan_sync(goal: str, repo: str = ".", backend: str = "opencode", - model: str = "", critique: bool = True) -> Plan: +def plan_sync( + goal: str, repo: str = ".", backend: str = "opencode", model: str = "", critique: bool = True +) -> Plan: import asyncio + return asyncio.run(Planner(backend, model).plan(goal, repo, critique)) diff --git a/src/sin_delegate/policy.py b/src/sin_delegate/policy.py index 502853a0..dad8fb35 100644 --- a/src/sin_delegate/policy.py +++ b/src/sin_delegate/policy.py @@ -57,25 +57,28 @@ def apply(self, plan: Plan) -> tuple: new_tasks.append(task) else: backend, model = choice - new_tasks.append(replace( - task, agent=AgentSpec( - backend=backend, model=model, - command=task.agent.command, - system_hint=task.agent.system_hint, - env=task.agent.env))) + new_tasks.append( + replace( + task, + agent=AgentSpec( + backend=backend, + model=model, + command=task.agent.command, + system_hint=task.agent.system_hint, + env=task.agent.env, + ), + ) + ) return replace(plan, tasks=tuple(new_tasks)), decisions - def _route(self, task: Task, - rng: random.Random) -> tuple: + def _route(self, task: Task, rng: random.Random) -> tuple: pinned = (task.agent.backend, task.agent.model) if task.agent.model or task.agent.backend == "command": return pinned, "pinned" cls = task_class_of(task) - best = self.analytics.best_backend(cls, self.candidates, - self.min_trials) - if (task.risk == Risk.LOW and best is not None - and rng.random() < self.epsilon): + best = self.analytics.best_backend(cls, self.candidates, self.min_trials) + if task.risk == Risk.LOW and best is not None and rng.random() < self.epsilon: others = [c for c in self.candidates if c != best] if others: return rng.choice(others), "explore" diff --git a/src/sin_delegate/resolution.py b/src/sin_delegate/resolution.py index f423bda2..5a299d10 100644 --- a/src/sin_delegate/resolution.py +++ b/src/sin_delegate/resolution.py @@ -36,52 +36,49 @@ def apply_resolutions(plan: Plan, ledger: Ledger | None = None) -> dict: except ValueError: # Corrupt resolution entry: skip it rather than crashing the # resume path. A synthetic event lets operators investigate. - ledger.emit(plan.id, res.get("task_id", "*"), - "ledger:corrupt_resolution", {"res": res}) + ledger.emit(plan.id, res.get("task_id", "*"), "ledger:corrupt_resolution", {"res": res}) continue task_id = res["task_id"] eid = res["escalation_id"] if action == ActionType.RETRY_WITH_GUIDANCE: broker.mark_applied(plan.id, task_id, eid) - ledger.emit(plan.id, task_id, "state:pending", - {"via": "escalation_retry"}) + ledger.emit(plan.id, task_id, "state:pending", {"via": "escalation_retry"}) if res.get("user_input"): guidance[task_id] = ( "Ein menschlicher Reviewer hat deinen letzten Versuch " "geprüft und folgende Anweisung gegeben — befolge sie " - "exakt:\n" + res["user_input"]) + "exakt:\n" + res["user_input"] + ) _recreate_worktree(plan, task_id) elif action == ActionType.ACCEPT_BRANCH: ok = _merge_branch(plan, task_id, ledger) broker.mark_applied(plan.id, task_id, eid) - ledger.emit(plan.id, task_id, - "state:done" if ok else "state:escalated", - {"via": "accept_branch", - "verdict_overridden": True}) + ledger.emit( + plan.id, + task_id, + "state:done" if ok else "state:escalated", + {"via": "accept_branch", "verdict_overridden": True}, + ) elif action == ActionType.MANUAL_MERGE: broker.mark_applied(plan.id, task_id, eid) - ledger.emit(plan.id, task_id, "state:done", - {"via": "manual_merge"}) + ledger.emit(plan.id, task_id, "state:done", {"via": "manual_merge"}) _cleanup_worktree(plan, task_id) elif action == ActionType.DROP_TASK: broker.mark_applied(plan.id, task_id, eid) - ledger.emit(plan.id, task_id, "state:skipped", - {"via": "drop_task"}) + ledger.emit(plan.id, task_id, "state:skipped", {"via": "drop_task"}) elif action == ActionType.ABORT_PLAN: broker.mark_applied(plan.id, task_id, eid) aborted = True states = ledger.task_states(plan.id) - terminal = {TaskState.DONE, TaskState.FAILED, TaskState.SKIPPED, - TaskState.CANCELLED} + terminal = {TaskState.DONE, TaskState.FAILED, TaskState.SKIPPED, TaskState.CANCELLED} for tid in (t.id for t in plan.tasks): if states.get(tid) not in terminal: - ledger.emit(plan.id, tid, "state:cancelled", - {"via": "abort_plan"}) + ledger.emit(plan.id, tid, "state:cancelled", {"via": "abort_plan"}) applied += 1 @@ -103,13 +100,11 @@ def _merge_branch(plan: Plan, task_id: str, ledger: Ledger) -> bool: wtm = WorktreeManager(plan.repo, plan.base_branch) wt = wtm.create(plan.id, task_id) snapshot = wt.merge_back() - ledger.emit(plan.id, task_id, "merged", - {"snapshot": snapshot, "via": "accept_branch"}) + ledger.emit(plan.id, task_id, "merged", {"snapshot": snapshot, "via": "accept_branch"}) wt.destroy() return True except GitError as e: - ledger.emit(plan.id, task_id, "escalation:merge_retry_failed", - {"error": str(e)}) + ledger.emit(plan.id, task_id, "escalation:merge_retry_failed", {"error": str(e)}) return False diff --git a/src/sin_delegate/runner.py b/src/sin_delegate/runner.py index 05c03a91..1fecd182 100644 --- a/src/sin_delegate/runner.py +++ b/src/sin_delegate/runner.py @@ -8,13 +8,15 @@ import os import re from dataclasses import dataclass -from typing import Any, Awaitable, Callable, Protocol +from typing import Callable, Protocol from .models import Task _SECRET_PATTERNS = [ - re.compile(r"(?i)(api[_-]?key|token|secret|password|authorization)" - r"\s*[:=]\s*\S+"), + re.compile( + r"(?i)(api[_-]?key|token|secret|password|authorization)" + r"\s*[:=]\s*\S+" + ), re.compile(r"\b(sk|pk|ghp|gho|pypi|xox[bap])-[A-Za-z0-9_\-]{10,}\b"), re.compile(r"\beyJ[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]+\b"), ] @@ -34,8 +36,7 @@ class RunnerResult: class Runner(Protocol): - async def run(self, task: Task, cwd: str, - timeout: float) -> RunnerResult: ... + async def run(self, task: Task, cwd: str, timeout: float) -> RunnerResult: ... def _prompt(task: Task) -> str: @@ -60,28 +61,25 @@ class SubprocessRunner: def __init__(self, argv_factory: Callable[[Task], list[str]]) -> None: self._argv_factory = argv_factory - async def run(self, task: Task, cwd: str, - timeout: float) -> RunnerResult: + async def run(self, task: Task, cwd: str, timeout: float) -> RunnerResult: argv = self._argv_factory(task) - env = {**os.environ, **task.agent.env, - "SIN_DELEGATE_TASK": task.id} + env = {**os.environ, **task.agent.env, "SIN_DELEGATE_TASK": task.id} proc = await asyncio.create_subprocess_exec( - *argv, cwd=cwd, env=env, + *argv, + cwd=cwd, + env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, stdin=asyncio.subprocess.DEVNULL, ) try: - out, _ = await asyncio.wait_for( - proc.communicate(), timeout=timeout) + out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: proc.kill() await proc.wait() - return RunnerResult(False, - f"[timeout after {timeout:.0f}s]", -1) + return RunnerResult(False, f"[timeout after {timeout:.0f}s]", -1) text = redact(out.decode(errors="replace"))[-100_000:] - return RunnerResult(proc.returncode == 0, text, - proc.returncode or 0) + return RunnerResult(proc.returncode == 0, text, proc.returncode or 0) def _opencode_argv(task: Task) -> list[str]: @@ -92,8 +90,15 @@ def _opencode_argv(task: Task) -> list[str]: def _claude_argv(task: Task) -> list[str]: - argv = ["claude", "-p", _prompt(task), "--output-format", "text", - "--permission-mode", "acceptEdits"] + argv = [ + "claude", + "-p", + _prompt(task), + "--output-format", + "text", + "--permission-mode", + "acceptEdits", + ] if task.agent.model: argv += ["--model", task.agent.model] return argv @@ -109,8 +114,7 @@ def _codex_argv(task: Task) -> list[str]: def _command_argv(task: Task) -> list[str]: if not task.agent.command: raise ValueError("backend 'command' requires AgentSpec.command") - return [a.replace("{prompt}", _prompt(task)) - for a in task.agent.command] + return [a.replace("{prompt}", _prompt(task)) for a in task.agent.command] _BACKENDS = { @@ -128,19 +132,16 @@ def runner_for(spec) -> Runner: return SubprocessRunner(_BACKENDS[spec.backend]) except KeyError: raise ValueError( - f"unknown backend {spec.backend!r}; " - f"choose one of {sorted(_BACKENDS)}") from None + f"unknown backend {spec.backend!r}; choose one of {sorted(_BACKENDS)}" + ) from None class EchoRunner: """Dry-run backend: prints what WOULD happen. Used by --dry-run and tests.""" - async def run(self, task: Task, cwd: str, - timeout: float) -> RunnerResult: + async def run(self, task: Task, cwd: str, timeout: float) -> RunnerResult: return RunnerResult( True, - json.dumps( - {"dry_run": True, "task": task.title, "cwd": cwd}, - indent=2), + json.dumps({"dry_run": True, "task": task.title, "cwd": cwd}, indent=2), 0, ) diff --git a/src/sin_delegate/scheduler.py b/src/sin_delegate/scheduler.py index 07d2ba75..6893cca4 100644 --- a/src/sin_delegate/scheduler.py +++ b/src/sin_delegate/scheduler.py @@ -41,10 +41,14 @@ def depth(tid: str) -> int: class Scheduler: - def __init__(self, plan: Plan, ledger: Ledger, - executor: TaskExecutor, - max_parallel: int = 4, - failure_threshold: float = 0.5) -> None: + def __init__( + self, + plan: Plan, + ledger: Ledger, + executor: TaskExecutor, + max_parallel: int = 4, + failure_threshold: float = 0.5, + ) -> None: plan.validate() self.plan = plan self.ledger = ledger @@ -64,17 +68,17 @@ def _resume_states(self) -> dict[str, TaskState]: states: dict[str, TaskState] = {} for tid in self.tasks: prev = persisted.get(tid) - states[tid] = (TaskState.DONE if prev == TaskState.DONE - else TaskState.PENDING) + states[tid] = TaskState.DONE if prev == TaskState.DONE else TaskState.PENDING return states def _circuit_open(self, states: dict[str, TaskState]) -> bool: - finished = [s for s in states.values() - if s in (TaskState.DONE, TaskState.FAILED, - TaskState.ESCALATED)] + finished = [ + s + for s in states.values() + if s in (TaskState.DONE, TaskState.FAILED, TaskState.ESCALATED) + ] failed = [s for s in finished if s != TaskState.DONE] - return (len(finished) >= 2 - and len(failed) / len(finished) > self.failure_threshold) + return len(finished) >= 2 and len(failed) / len(finished) > self.failure_threshold async def run(self) -> dict[str, TaskOutcome]: states = self._resume_states() @@ -96,13 +100,15 @@ def ready() -> list[str]: return out def doomed() -> list[str]: - bad = {tid for tid, s in states.items() - if s in (TaskState.FAILED, TaskState.SKIPPED, - TaskState.ESCALATED, TaskState.CANCELLED)} + bad = { + tid + for tid, s in states.items() + if s + in (TaskState.FAILED, TaskState.SKIPPED, TaskState.ESCALATED, TaskState.CANCELLED) + } out = [] for tid, t in self.tasks.items(): - if states[tid] == TaskState.PENDING and any( - d in bad for d in t.deps): + if states[tid] == TaskState.PENDING and any(d in bad for d in t.deps): out.append(tid) return out @@ -110,8 +116,8 @@ def doomed() -> list[str]: for tid in doomed(): states[tid] = TaskState.SKIPPED self.outcomes[tid] = TaskOutcome( - tid, TaskState.SKIPPED, - error="upstream dependency failed") + tid, TaskState.SKIPPED, error="upstream dependency failed" + ) self.ledger.emit(self.plan.id, tid, "state:skipped") if self._cancelled or self._circuit_open(states): @@ -120,8 +126,7 @@ def doomed() -> list[str]: for tid, st in states.items(): if st in (TaskState.PENDING, TaskState.RUNNING): states[tid] = TaskState.CANCELLED - self.outcomes[tid] = TaskOutcome( - tid, TaskState.CANCELLED) + self.outcomes[tid] = TaskOutcome(tid, TaskState.CANCELLED) self.ledger.emit(self.plan.id, tid, "state:cancelled") break @@ -134,8 +139,7 @@ def doomed() -> list[str]: if not running: break - done, _ = await asyncio.wait( - running.keys(), return_when=asyncio.FIRST_COMPLETED) + done, _ = await asyncio.wait(running.keys(), return_when=asyncio.FIRST_COMPLETED) for fut in done: tid = running.pop(fut) try: @@ -143,15 +147,19 @@ def doomed() -> list[str]: except asyncio.CancelledError: outcome = TaskOutcome(tid, TaskState.CANCELLED) except Exception as e: - outcome = TaskOutcome( - tid, TaskState.FAILED, error=str(e)) + outcome = TaskOutcome(tid, TaskState.FAILED, error=str(e)) states[tid] = outcome.state self.outcomes[tid] = outcome self.ledger.emit( - self.plan.id, tid, f"state:{outcome.state.value}", - {"error": outcome.error, - "seconds": outcome.seconds, - "attempts": outcome.attempts}) + self.plan.id, + tid, + f"state:{outcome.state.value}", + { + "error": outcome.error, + "seconds": outcome.seconds, + "attempts": outcome.attempts, + }, + ) return self.outcomes async def _execute(self, task: Task) -> TaskOutcome: @@ -163,8 +171,8 @@ async def _execute(self, task: Task) -> TaskOutcome: if self._cancelled: return TaskOutcome(task.id, TaskState.CANCELLED) self.ledger.emit( - self.plan.id, task.id, "attempt", - {"n": base_attempts + attempt + 1}) + self.plan.id, task.id, "attempt", {"n": base_attempts + attempt + 1} + ) outcome = await self.executor(task) outcome.attempts = base_attempts + attempt + 1 outcome.seconds = time.monotonic() - start @@ -175,7 +183,8 @@ async def _execute(self, task: Task) -> TaskOutcome: return outcome await asyncio.sleep(task.budget.retry_delay(attempt)) return TaskOutcome( - task.id, TaskState.FAILED, + task.id, + TaskState.FAILED, attempts=base_attempts + task.budget.max_retries + 1, seconds=time.monotonic() - start, error=last_error or "exhausted retries", diff --git a/src/sin_delegate/verify.py b/src/sin_delegate/verify.py index e0cbac22..d2f069d8 100644 --- a/src/sin_delegate/verify.py +++ b/src/sin_delegate/verify.py @@ -11,7 +11,6 @@ import re import shutil import subprocess -from pathlib import Path from typing import Callable from .models import Task, Verdict @@ -20,8 +19,10 @@ Gate = Callable[[Task, Worktree], tuple[bool, str]] _FORBIDDEN_DIFF = [ - (re.compile(r"(?i)^\+.*\b(api[_-]?key|secret|password)\s*=\s*['\"][^'\"]{8,}"), - "hardcoded secret introduced"), + ( + re.compile(r"(?i)^\+.*\b(api[_-]?key|secret|password)\s*=\s*['\"][^'\"]{8,}"), + "hardcoded secret introduced", + ), (re.compile(r"^\+.*\beval\s*\("), "eval() introduced"), (re.compile(r"^\+.*\bexec\s*\("), "exec() introduced"), ] @@ -42,8 +43,7 @@ def gate_diff(task: Task, wt: Worktree) -> tuple[bool, str]: def _run(cmd: list[str], cwd: str, timeout: int = 300) -> tuple[int, str]: try: - p = subprocess.run( - cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout) + p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout) return p.returncode, (p.stdout + p.stderr)[-5000:] except subprocess.TimeoutExpired: return -1, "timeout" @@ -120,6 +120,5 @@ def verify(task: Task, wt: Worktree) -> Verdict: results[name] = {"ok": ok, "detail": detail} all_ok = all_ok and ok failed = [n for n, r in results.items() if not r["ok"]] - summary = ("all gates passed" if all_ok - else f"failed gates: {', '.join(failed)}") + summary = "all gates passed" if all_ok else f"failed gates: {', '.join(failed)}" return Verdict(passed=all_ok, gates=results, summary=summary) diff --git a/src/sin_delegate/worktree.py b/src/sin_delegate/worktree.py index 74f5bb3c..ddf96534 100644 --- a/src/sin_delegate/worktree.py +++ b/src/sin_delegate/worktree.py @@ -16,7 +16,9 @@ class GitError(RuntimeError): def _git(repo: str | Path, *args: str, check: bool = True) -> str: proc = subprocess.run( ["git", "-C", str(repo), *args], - capture_output=True, text=True, timeout=120, + capture_output=True, + text=True, + timeout=120, ) if check and proc.returncode != 0: raise GitError(f"git {' '.join(args)}: {proc.stderr.strip()}") @@ -54,19 +56,17 @@ def merge_back(self) -> str: _git(self.path, "rebase", "--abort", check=False) raise GitError( f"rebase conflict on {self.branch}; branch preserved for " - f"manual resolution. base untouched. ({e})") from e + f"manual resolution. base untouched. ({e})" + ) from e try: _git(self.repo, "merge", "--ff-only", self.branch) except GitError as e: _git(self.repo, "reset", "--hard", snapshot, check=False) - raise GitError( - f"ff-merge failed, base restored to {snapshot}: {e}" - ) from e + raise GitError(f"ff-merge failed, base restored to {snapshot}: {e}") from e return snapshot def destroy(self, delete_branch: bool = True) -> None: - _git(self.repo, "worktree", "remove", "--force", - str(self.path), check=False) + _git(self.repo, "worktree", "remove", "--force", str(self.path), check=False) if delete_branch: _git(self.repo, "branch", "-D", self.branch, check=False) diff --git a/tests/agent_engine/test_delegate_v2.py b/tests/agent_engine/test_delegate_v2.py index 7510e67d..817475b8 100644 --- a/tests/agent_engine/test_delegate_v2.py +++ b/tests/agent_engine/test_delegate_v2.py @@ -9,29 +9,39 @@ import pytest from sin_code_bundle.agent_engine.delegate import ( - AdaptiveBudgetAllocator, DelegationCache, DelegationContext, - DelegationSupervisor, validate_result, + AdaptiveBudgetAllocator, + DelegationCache, + DelegationContext, + DelegationSupervisor, + validate_result, ) def _result(**overrides): base = { - "outcome": "success", "verdict": "pass", "elapsed_s": 1.0, - "steps_ok": 2, "steps_total": 2, "lessons": [], - "depth": 1, "delegation_id": "abc", "cached": False, + "outcome": "success", + "verdict": "pass", + "elapsed_s": 1.0, + "steps_ok": 2, + "steps_total": 2, + "lessons": [], + "depth": 1, + "delegation_id": "abc", + "cached": False, } return {**base, **overrides} # ------------------------------------------------------------- contract + def test_contract_strips_unknown_keys_and_caps_lessons(): raw = _result(lessons=[f"lesson-{i}" * 100 for i in range(20)]) raw["malicious_context_dump"] = "x" * 100_000 clean = validate_result(raw) assert "malicious_context_dump" not in clean assert len(clean["lessons"]) == 5 - assert all(len(l) <= 300 for l in clean["lessons"]) + assert all(len(lesson) <= 300 for lesson in clean["lessons"]) def test_contract_rejects_wrong_types(): @@ -45,6 +55,7 @@ def test_contract_rejects_wrong_types(): # ---------------------------------------------------------------- cache + def test_cache_only_stores_successes_and_respects_tree(): cache = DelegationCache() k1 = DelegationCache.fingerprint("g", [], "tree-A") @@ -68,6 +79,7 @@ def test_cache_ttl_expiry(monkeypatch): # ------------------------------------------------------------ allocator + class _FakeMemory: def __init__(self, hits): self._hits = hits @@ -77,8 +89,7 @@ def recall_similar(self, goal, limit=10): def test_allocator_uses_history_p75(): - hits = [{"outcome": "success", "elapsed_s": s} - for s in (10, 20, 30, 40, 100)] + hits = [{"outcome": "success", "elapsed_s": s} for s in (10, 20, 30, 40, 100)] alloc = AdaptiveBudgetAllocator(_FakeMemory(hits), min_s=5) grant = alloc.grant("refactor auth", parent_remaining_s=1000) # p75 von [10,20,30,40,100] = 40 -> *1.5 = 60, weit unter cap (500) @@ -104,29 +115,30 @@ def test_goal_class_normalization(): # -------------------------------------------------------------- context + def test_child_deadline_never_exceeds_parent(): - parent = DelegationContext(deadline_wall=time.monotonic() + 200, - safety_margin_s=20) + parent = DelegationContext(deadline_wall=time.monotonic() + 200, safety_margin_s=20) child = parent.child(granted_budget_s=10_000) assert child.remaining_s() <= parent.remaining_s() - 19 def test_can_delegate_blocks_when_budget_too_low(): - ctx = DelegationContext(deadline_wall=time.monotonic() + 50, - safety_margin_s=20, min_budget_s=60) + ctx = DelegationContext( + deadline_wall=time.monotonic() + 50, safety_margin_s=20, min_budget_s=60 + ) ok, reason = ctx.can_delegate() assert not ok and "budget" in reason def test_can_delegate_blocks_at_max_depth(): - ctx = DelegationContext(depth=3, max_depth=3, - deadline_wall=time.monotonic() + 1000) + ctx = DelegationContext(depth=3, max_depth=3, deadline_wall=time.monotonic() + 1000) ok, reason = ctx.can_delegate() assert not ok and "depth" in reason # ------------------------------------------------------------ supervisor + def test_global_limit_caps_breadth_not_just_depth(): async def scenario(): sup = DelegationSupervisor(global_limit=2) @@ -139,11 +151,12 @@ async def child(i): running["now"] -= 1 return i - results = await asyncio.gather(*[ - sup.supervise(delegation_id=f"d{i}", goal="g", depth=1, - coro=child(i)) - for i in range(6) - ]) + results = await asyncio.gather( + *[ + sup.supervise(delegation_id=f"d{i}", goal="g", depth=1, coro=child(i)) + for i in range(6) + ] + ) assert sorted(results) == list(range(6)) assert running["peak"] <= 2 @@ -158,8 +171,9 @@ async def hang(): await asyncio.sleep(60) tasks = [ - asyncio.ensure_future(sup.supervise( - delegation_id=f"d{i}", goal="g", depth=1, coro=hang())) + asyncio.ensure_future( + sup.supervise(delegation_id=f"d{i}", goal="g", depth=1, coro=hang()) + ) for i in range(3) ] await asyncio.sleep(0.05) diff --git a/tests/agent_engine/test_engine.py b/tests/agent_engine/test_engine.py index 7ede86dd..07d8f020 100644 --- a/tests/agent_engine/test_engine.py +++ b/tests/agent_engine/test_engine.py @@ -5,16 +5,21 @@ import asyncio import json -import time -from pathlib import Path import pytest -import sin_code_bundle.agent_engine as ae from sin_code_bundle.agent_engine import ( - AgentLoop, AgentTask, CircuitOpenError, Executor, MemoryBridge, - Planner, Plan, Step, StepState, Telemetry, ToolRouter, Verdict, - VerdictKind, Verifier, register_builtin_tools, + AgentTask, + CircuitOpenError, + Executor, + MemoryBridge, + Planner, + StepState, + Telemetry, + ToolRouter, + Verdict, + VerdictKind, + register_builtin_tools, ) @@ -25,30 +30,36 @@ def make_plan(specs): def test_plan_rejects_cycle(): with pytest.raises(ValueError, match="cycle"): - make_plan([ - {"step_id": "a", "tool": "x", "deps": ["b"]}, - {"step_id": "b", "tool": "x", "deps": ["a"]}, - ]) + make_plan( + [ + {"step_id": "a", "tool": "x", "deps": ["b"]}, + {"step_id": "b", "tool": "x", "deps": ["a"]}, + ] + ) def test_critical_path_ordering(): - _, plan = make_plan([ - {"step_id": "root", "tool": "x", "estimated_cost": 1}, - {"step_id": "long1", "tool": "x", "deps": ["root"], "estimated_cost": 10}, - {"step_id": "long2", "tool": "x", "deps": ["long1"], "estimated_cost": 10}, - {"step_id": "short", "tool": "x", "deps": ["root"], "estimated_cost": 1}, - ]) + _, plan = make_plan( + [ + {"step_id": "root", "tool": "x", "estimated_cost": 1}, + {"step_id": "long1", "tool": "x", "deps": ["root"], "estimated_cost": 10}, + {"step_id": "long2", "tool": "x", "deps": ["long1"], "estimated_cost": 10}, + {"step_id": "short", "tool": "x", "deps": ["root"], "estimated_cost": 1}, + ] + ) w = Planner().critical_path_weights(plan) assert w["long1"] > w["short"] assert w["root"] > w["long1"] def test_failure_propagation_skips_dependents(): - _, plan = make_plan([ - {"step_id": "a", "tool": "x"}, - {"step_id": "b", "tool": "x", "deps": ["a"]}, - {"step_id": "c", "tool": "x", "deps": ["b"]}, - ]) + _, plan = make_plan( + [ + {"step_id": "a", "tool": "x"}, + {"step_id": "b", "tool": "x", "deps": ["a"]}, + {"step_id": "c", "tool": "x", "deps": ["b"]}, + ] + ) plan.steps["a"].state = StepState.FAILED skipped = Planner().propagate_failure(plan, "a") assert set(skipped) == {"b", "c"} @@ -104,9 +115,14 @@ def test_telemetry_emits_to_jsonl(tmp_path): def test_memory_bridge_roundtrip(tmp_path): db = tmp_path / "mem.db" mb = MemoryBridge(db_path=str(db)) - mb.remember_run(task_id="t1", goal="fix the auth bug", outcome="success", - repair_rounds=1, lessons=["check token expiry first"], - plan_json="{}") + mb.remember_run( + task_id="t1", + goal="fix the auth bug", + outcome="success", + repair_rounds=1, + lessons=["check token expiry first"], + plan_json="{}", + ) hits = mb.recall_similar("auth bug fix", limit=3) assert len(hits) == 1 assert hits[0]["outcome"] == "success" @@ -115,18 +131,17 @@ def test_memory_bridge_roundtrip(tmp_path): def test_builtin_tools_work_in_tempdir(tmp_path): cwd = str(tmp_path) + async def scenario(): router = register_builtin_tools(ToolRouter()) # write - r = await router.call("sin_write", path="a.txt", content="hello\nworld\n", - cwd=cwd) + r = await router.call("sin_write", path="a.txt", content="hello\nworld\n", cwd=cwd) assert r["bytes"] == 12 # read r = await router.call("sin_read", path="a.txt", cwd=cwd, start=1, limit=10) assert "hello" in r["content"] # edit (anchored) - r = await router.call("sin_edit", path="a.txt", old="hello", - new="HI", cwd=cwd) + r = await router.call("sin_edit", path="a.txt", old="hello", new="HI", cwd=cwd) assert r["replaced"] == 1 # search r = await router.call("sin_search", pattern="world", cwd=cwd, glob="*.txt") @@ -137,12 +152,11 @@ async def scenario(): # edit ambiguous fails (router wraps tool errors in RuntimeError) await router.call("sin_write", path="b.txt", content="x\nx\n", cwd=cwd) with pytest.raises(RuntimeError, match="ambiguous"): - await router.call("sin_edit", path="b.txt", old="x", - new="y", cwd=cwd) + await router.call("sin_edit", path="b.txt", old="x", new="y", cwd=cwd) # edit anchor not found with pytest.raises(RuntimeError, match="not found"): - await router.call("sin_edit", path="a.txt", old="nope", - new="x", cwd=cwd) + await router.call("sin_edit", path="a.txt", old="nope", new="x", cwd=cwd) + asyncio.run(scenario()) @@ -156,8 +170,7 @@ def test_agent_task_fingerprint_stable(): def test_step_state_enum_complete(): states = {s.value for s in StepState} - assert {"pending", "ready", "running", "succeeded", - "failed", "skipped", "repairing"} <= states + assert {"pending", "ready", "running", "succeeded", "failed", "skipped", "repairing"} <= states def test_verdict_kind_pass_property(): @@ -171,21 +184,25 @@ def test_executor_runs_and_propagates_failures(): async def scenario(): telemetry = Telemetry() router = ToolRouter() + async def ok_tool(**_): return "ok" + async def fail_tool(**_): raise RuntimeError("nope") + router.register("ok", ok_tool) router.register("fail", fail_tool) executor = Executor(router, telemetry) task = AgentTask(goal="g", repo_root=".") - plan = Planner().build(task, [ - {"step_id": "a", "tool": "ok", "args": {}, "max_attempts": 1}, - {"step_id": "b", "tool": "fail", "args": {}, "deps": ["a"], - "max_attempts": 1}, - {"step_id": "c", "tool": "ok", "args": {}, "deps": ["b"], - "max_attempts": 1}, - ]) + plan = Planner().build( + task, + [ + {"step_id": "a", "tool": "ok", "args": {}, "max_attempts": 1}, + {"step_id": "b", "tool": "fail", "args": {}, "deps": ["a"], "max_attempts": 1}, + {"step_id": "c", "tool": "ok", "args": {}, "deps": ["b"], "max_attempts": 1}, + ], + ) results = await executor.run(task, plan, Planner()) assert results["a"].ok assert not results["b"].ok @@ -193,20 +210,23 @@ async def fail_tool(**_): assert plan.steps["c"].state is StepState.SKIPPED # pending hook fired assert telemetry.counters.get("step_fail", 0) >= 1 + asyncio.run(scenario()) def test_synthesize_critique_fallback_on_garbage(): from sin_code_bundle.agent_engine.synthesizer import PlanSynthesizer + calls = {"n": 0} async def fake(prompt: str) -> str: calls["n"] += 1 if calls["n"] == 1: - return json.dumps([ - {"step_id": "a", "tool": "sin_bash", - "args": {"cmd": "pytest"}, "deps": []}, - ]) + return json.dumps( + [ + {"step_id": "a", "tool": "sin_bash", "args": {"cmd": "pytest"}, "deps": []}, + ] + ) return "garbage no json" s = PlanSynthesizer(complete=fake, critique=True) @@ -216,6 +236,7 @@ async def fake(prompt: str) -> str: def test_synthesizer_refuses_without_llm(): from sin_code_bundle.agent_engine.synthesizer import PlanSynthesizer + s = PlanSynthesizer(complete=None) with pytest.raises(RuntimeError, match="refusing to hallucinate"): asyncio.run(s.synthesize(AgentTask(goal="x", repo_root="."))) @@ -223,13 +244,12 @@ def test_synthesizer_refuses_without_llm(): def test_dashboard_state_lifecycle(): from sin_code_bundle.agent_engine.watch import DashboardState + d = DashboardState() d.apply({"event": "plan_built", "task_id": "t1"}) - d.apply({"event": "step_start", "step_id": "s1", "tool": "sin_edit", - "attempt": 1}) + d.apply({"event": "step_start", "step_id": "s1", "tool": "sin_edit", "attempt": 1}) d.apply({"event": "step_ok", "step_id": "s1", "duration_s": 1.2}) - d.apply({"event": "step_start", "step_id": "s2", "tool": "sin_bash", - "attempt": 1}) + d.apply({"event": "step_start", "step_id": "s2", "tool": "sin_bash", "attempt": 1}) d.apply({"event": "step_fail", "step_id": "s2", "skipped": ["s3"]}) d.apply({"event": "verdict", "round": 0, "ok": False, "kind": "fail_tests"}) d.apply({"event": "swarm_member_done", "member": "be", "ok": True}) diff --git a/tests/agent_engine/test_engine_extras.py b/tests/agent_engine/test_engine_extras.py index 06057d6d..86851921 100644 --- a/tests/agent_engine/test_engine_extras.py +++ b/tests/agent_engine/test_engine_extras.py @@ -6,7 +6,6 @@ import asyncio import json import time -from pathlib import Path import pytest @@ -16,51 +15,65 @@ from sin_code_bundle.agent_engine.delegate import DelegationContext from sin_code_bundle.agent_engine.insights import TelemetryAnalyzer from sin_code_bundle.agent_engine.policy_sandbox import ( - PolicyRule, PolicySandbox, PolicyViolation, + PolicyRule, + PolicySandbox, + PolicyViolation, ) from sin_code_bundle.agent_engine.repair import LLMRepairFactory from sin_code_bundle.agent_engine.router import ToolRouter from sin_code_bundle.agent_engine.types import AgentTask, StepState, Verdict, VerdictKind - # --------------------------------------------------------------- repair + def test_deterministic_lint_repair_runs_once(): factory = LLMRepairFactory(complete=None) verdict = Verdict(kind=VerdictKind.FAIL_LINT, detail="E501 line too long") - plan1 = asyncio.run(factory.build_repair_plan(AgentTask(goal="g", - repo_root="."), - verdict)) + plan1 = asyncio.run(factory.build_repair_plan(AgentTask(goal="g", repo_root="."), verdict)) assert plan1 and plan1[0]["tool"] == "sin_bash" - plan2 = asyncio.run(factory.build_repair_plan(AgentTask(goal="g", - repo_root="."), - verdict)) + plan2 = asyncio.run(factory.build_repair_plan(AgentTask(goal="g", repo_root="."), verdict)) assert plan2 == [] def test_llm_repair_parses_fenced_json(): async def fake(prompt: str) -> str: - return '```json\n' + json.dumps([ - {"step_id": "fix", "tool": "sin_edit", - "args": {"path": "a.py", "old": "x", "new": "y"}}, - {"step_id": "evil", "tool": "rm_rf", "args": {}}, - ]) + "\n```" + return ( + "```json\n" + + json.dumps( + [ + { + "step_id": "fix", + "tool": "sin_edit", + "args": {"path": "a.py", "old": "x", "new": "y"}, + }, + {"step_id": "evil", "tool": "rm_rf", "args": {}}, + ] + ) + + "\n```" + ) + factory = LLMRepairFactory(complete=fake) verdict = Verdict(kind=VerdictKind.FAIL_TESTS, detail="assert 1 == 2") - plan = asyncio.run(factory.build_repair_plan(AgentTask(goal="g", - repo_root="."), - verdict)) + plan = asyncio.run(factory.build_repair_plan(AgentTask(goal="g", repo_root="."), verdict)) assert [s["step_id"] for s in plan] == ["fix"] # -------------------------------------------------------------- compactor + def test_compactor_stays_under_budget(): c = ContextCompactor(budget_chars=2000, hot_count=2) for i in range(50): - c.append(f"s{i}", json.dumps({ - "exit_code": 0, "stdout": "x" * 500, "path": f"f{i}.py", - })) + c.append( + f"s{i}", + json.dumps( + { + "exit_code": 0, + "stdout": "x" * 500, + "path": f"f{i}.py", + } + ), + ) assert c.total_size() <= 2000 rendered = c.render() assert "evicted" in rendered @@ -69,10 +82,15 @@ def test_compactor_stays_under_budget(): def test_compactor_digest_keeps_facts(): c = ContextCompactor(budget_chars=100_000, hot_count=1) - c.append("search1", json.dumps({ - "hits": [{"file": "a.py", "line": 1, "text": "def login"}], - "truncated": False, - })) + c.append( + "search1", + json.dumps( + { + "hits": [{"file": "a.py", "line": 1, "text": "def login"}], + "truncated": False, + } + ), + ) c.append("recent", "verbatim content") rendered = c.render() assert "hit_count" in rendered or "a.py" in rendered @@ -80,6 +98,7 @@ def test_compactor_digest_keeps_facts(): # -------------------------------------------------------------- checkpoint + @pytest.fixture() def store(tmp_path, monkeypatch): monkeypatch.setattr(cp_mod, "_tree_hash", lambda repo: "tree-stable") @@ -93,10 +112,14 @@ def test_resume_skips_succeeded_steps(store): assert state.resumable and state.completed_steps == {"a"} task = AgentTask(goal="g", repo_root=".") from sin_code_bundle.agent_engine.planner import Planner - plan = Planner().build(task, [ - {"step_id": "a", "tool": "sin_bash", "args": {}}, - {"step_id": "b", "tool": "sin_bash", "args": {}, "deps": ["a"]}, - ]) + + plan = Planner().build( + task, + [ + {"step_id": "a", "tool": "sin_bash", "args": {}}, + {"step_id": "b", "tool": "sin_bash", "args": {}, "deps": ["a"]}, + ], + ) skipped = CheckpointStore.apply_to_plan(plan, state) assert skipped == ["a"] assert plan.steps["a"].state is StepState.SUCCEEDED @@ -127,22 +150,31 @@ def test_journal_survives_torn_last_line(store): # ----------------------------------------------------------------- policy + def test_policy_deny_beats_allow(): - sandbox = PolicySandbox(rules=[ - PolicyRule(action="allow", tool="sin_bash", arg="cmd", pattern="^git"), - PolicyRule(action="deny", tool="sin_bash", arg="cmd", - pattern="push --force", reason="no force push"), - ]) - allowed, reason = sandbox.decide( - "sin_bash", {"cmd": "git push --force origin main"}) + sandbox = PolicySandbox( + rules=[ + PolicyRule(action="allow", tool="sin_bash", arg="cmd", pattern="^git"), + PolicyRule( + action="deny", + tool="sin_bash", + arg="cmd", + pattern="push --force", + reason="no force push", + ), + ] + ) + allowed, reason = sandbox.decide("sin_bash", {"cmd": "git push --force origin main"}) assert not allowed and "force" in reason def test_policy_default_deny(): - sandbox = PolicySandbox(default="deny", rules=[ - PolicyRule(action="allow", tool="sin_bash", arg="cmd", - pattern="^pytest"), - ]) + sandbox = PolicySandbox( + default="deny", + rules=[ + PolicyRule(action="allow", tool="sin_bash", arg="cmd", pattern="^pytest"), + ], + ) assert sandbox.decide("sin_bash", {"cmd": "pytest -x"})[0] assert not sandbox.decide("sin_bash", {"cmd": "rm -rf /"})[0] assert not sandbox.decide("sin_write", {"path": "x.py"})[0] @@ -156,8 +188,15 @@ async def scenario(): router = ToolRouter() router.register("sin_bash", echo) sandbox = PolicySandbox( - rules=[PolicyRule(action="deny", tool="sin_bash", arg="cmd", - pattern="rm -rf", reason="destructive")], + rules=[ + PolicyRule( + action="deny", + tool="sin_bash", + arg="cmd", + pattern="rm -rf", + reason="destructive", + ) + ], audit_path=tmp_path / "audit.jsonl", ) sandbox.wrap(router) @@ -166,6 +205,7 @@ async def scenario(): await router.call("sin_bash", cmd="rm -rf /tmp/x") audit = (tmp_path / "audit.jsonl").read_text() assert "destructive" in audit + asyncio.run(scenario()) @@ -177,26 +217,32 @@ async def scenario(): router = ToolRouter() router.register("sin_bash", echo) sandbox = PolicySandbox( - rules=[PolicyRule(action="deny", tool="sin_bash", arg="cmd", - pattern="rm -rf")], - dry_run=True, audit_path=tmp_path / "audit.jsonl", + rules=[PolicyRule(action="deny", tool="sin_bash", arg="cmd", pattern="rm -rf")], + dry_run=True, + audit_path=tmp_path / "audit.jsonl", ) sandbox.wrap(router) assert await router.call("sin_bash", cmd="rm -rf /tmp/x") == "ran" - rec = json.loads( - (tmp_path / "audit.jsonl").read_text().splitlines()[0]) + rec = json.loads((tmp_path / "audit.jsonl").read_text().splitlines()[0]) assert rec["dry_run"] is True + asyncio.run(scenario()) # --------------------------------------------------------------- delegation + def test_depth_limit_blocks_fork_bombs(): - ctx = DelegationContext(max_depth=2, deadline_wall=time.monotonic() + 1000, - min_budget_s=10, safety_margin_s=5) - child = DelegationContext(depth=1, max_depth=2, - deadline_wall=time.monotonic() + 1000, - min_budget_s=10, safety_margin_s=5) + ctx = DelegationContext( + max_depth=2, deadline_wall=time.monotonic() + 1000, min_budget_s=10, safety_margin_s=5 + ) + child = DelegationContext( + depth=1, + max_depth=2, + deadline_wall=time.monotonic() + 1000, + min_budget_s=10, + safety_margin_s=5, + ) grandchild = child.child(granted_budget_s=10) assert ctx.can_delegate()[0] assert child.can_delegate()[0] @@ -205,8 +251,9 @@ def test_depth_limit_blocks_fork_bombs(): def test_budget_shrinks_per_generation(): - ctx = DelegationContext(deadline_wall=time.monotonic() + 1000, - safety_margin_s=10, min_budget_s=10) + ctx = DelegationContext( + deadline_wall=time.monotonic() + 1000, safety_margin_s=10, min_budget_s=10 + ) child = ctx.child(granted_budget_s=200) # granted < (parent_remaining - safety_margin) keeps the child bounded assert child.remaining_s() <= ctx.remaining_s() @@ -216,14 +263,16 @@ def test_tiny_budget_refuses_delegation(): # min_budget=60, safety_margin=20, deadline+100 => remaining=100, # 100 - 20 = 80 >= 60, so we need remaining < min+safety = 80. # Use deadline at +50 to get remaining=50, 50-20=30 < 60. - ctx = DelegationContext(deadline_wall=time.monotonic() + 50, - safety_margin_s=20, min_budget_s=60) + ctx = DelegationContext( + deadline_wall=time.monotonic() + 50, safety_margin_s=20, min_budget_s=60 + ) ok, reason = ctx.can_delegate() assert not ok and "budget" in reason # --------------------------------------------------------------- insights + def _write_events(tmp_path, events): log = tmp_path / "events.jsonl" log.write_text("\n".join(json.dumps(e) for e in events) + "\n") @@ -233,8 +282,7 @@ def _write_events(tmp_path, events): def test_tool_health_flags_chronically_failing_tool(tmp_path): events = [] for i in range(10): - events.append({"event": "step_start", "step_id": f"e{i}", - "tool": "sin_edit"}) + events.append({"event": "step_start", "step_id": f"e{i}", "tool": "sin_edit"}) if i < 5: events.append({"event": "step_fail", "step_id": f"e{i}"}) analyzer = TelemetryAnalyzer(_write_events(tmp_path, events)) @@ -243,8 +291,7 @@ def test_tool_health_flags_chronically_failing_tool(tmp_path): def test_repair_hotspot_detects_lint_dominance(tmp_path): - events = [{"event": "verdict", "ok": False, "kind": "fail_lint"} - for _ in range(5)] + events = [{"event": "verdict", "ok": False, "kind": "fail_lint"} for _ in range(5)] events.append({"event": "verdict", "ok": False, "kind": "fail_tests"}) analyzer = TelemetryAnalyzer(_write_events(tmp_path, events)) warns = [i for i in analyzer.analyze() if i.category == "repair_hotspots"] @@ -253,19 +300,16 @@ def test_repair_hotspot_detects_lint_dominance(tmp_path): def test_budget_exhaustion_is_critical(tmp_path): - events = ( - [{"event": "budget_exhausted"} for _ in range(2)] - + [{"event": "run_complete"} for _ in range(4)] - ) + events = [{"event": "budget_exhausted"} for _ in range(2)] + [ + {"event": "run_complete"} for _ in range(4) + ] analyzer = TelemetryAnalyzer(_write_events(tmp_path, events)) - crits = [i for i in analyzer.analyze() - if i.severity == "critical" and i.category == "stalls"] + crits = [i for i in analyzer.analyze() if i.severity == "critical" and i.category == "stalls"] assert crits and "budget" in crits[0].finding def test_prompt_rendering_includes_warns(tmp_path): - events = [{"event": "delegate_done", "outcome": "success"} - for _ in range(5)] + events = [{"event": "delegate_done", "outcome": "success"} for _ in range(5)] analyzer = TelemetryAnalyzer(_write_events(tmp_path, events)) results = analyzer.analyze() block = analyzer.render_for_prompt(results) diff --git a/tests/agent_engine/test_tracing_and_distiller.py b/tests/agent_engine/test_tracing_and_distiller.py index a9389c49..0e93dd9f 100644 --- a/tests/agent_engine/test_tracing_and_distiller.py +++ b/tests/agent_engine/test_tracing_and_distiller.py @@ -6,19 +6,21 @@ import asyncio import json -import pytest - from sin_code_bundle.agent_engine.distiller import ( - KnowledgeDistiller, _heuristic_rule, _signature, + KnowledgeDistiller, + _heuristic_rule, + _signature, ) +from sin_code_bundle.agent_engine.telemetry import Telemetry from sin_code_bundle.agent_engine.tracing import ( - Span, SpanEmitter, TraceAssembler, TraceContext, + SpanEmitter, + TraceAssembler, + TraceContext, ) -from sin_code_bundle.agent_engine.telemetry import Telemetry - # --------------------------------------------------------------- tracing + def _write_spans(tmp_path, records): log = tmp_path / "events.jsonl" log.write_text("\n".join(json.dumps(r) for r in records) + "\n") @@ -28,20 +30,48 @@ def _write_spans(tmp_path, records): def test_assembler_builds_nested_tree(tmp_path): t = "trace1" records = [ - {"event": "span_start", "trace_id": t, "span_id": "root", - "parent_span_id": None, "kind": "run", "name": "main", "ts": 1.0}, - {"event": "span_start", "trace_id": t, "span_id": "d1", - "parent_span_id": "root", "kind": "delegate", "name": "sub-a", - "ts": 2.0}, - {"event": "span_start", "trace_id": t, "span_id": "d2", - "parent_span_id": "d1", "kind": "delegate", "name": "sub-a-1", - "ts": 3.0}, - {"event": "span_end", "trace_id": t, "span_id": "d2", - "status": "success", "duration_s": 1.0}, - {"event": "span_end", "trace_id": t, "span_id": "d1", - "status": "success", "duration_s": 2.5}, - {"event": "span_end", "trace_id": t, "span_id": "root", - "status": "ok", "duration_s": 5.0}, + { + "event": "span_start", + "trace_id": t, + "span_id": "root", + "parent_span_id": None, + "kind": "run", + "name": "main", + "ts": 1.0, + }, + { + "event": "span_start", + "trace_id": t, + "span_id": "d1", + "parent_span_id": "root", + "kind": "delegate", + "name": "sub-a", + "ts": 2.0, + }, + { + "event": "span_start", + "trace_id": t, + "span_id": "d2", + "parent_span_id": "d1", + "kind": "delegate", + "name": "sub-a-1", + "ts": 3.0, + }, + { + "event": "span_end", + "trace_id": t, + "span_id": "d2", + "status": "success", + "duration_s": 1.0, + }, + { + "event": "span_end", + "trace_id": t, + "span_id": "d1", + "status": "success", + "duration_s": 2.5, + }, + {"event": "span_end", "trace_id": t, "span_id": "root", "status": "ok", "duration_s": 5.0}, ] roots = TraceAssembler(_write_spans(tmp_path, records)).assemble(t) assert len(roots) == 1 @@ -56,10 +86,24 @@ def test_assembler_builds_nested_tree(tmp_path): def test_assembler_defaults_to_latest_trace(tmp_path): records = [ - {"event": "span_start", "trace_id": "old", "span_id": "a", - "parent_span_id": None, "kind": "run", "name": "old-run", "ts": 1}, - {"event": "span_start", "trace_id": "new", "span_id": "b", - "parent_span_id": None, "kind": "run", "name": "new-run", "ts": 2}, + { + "event": "span_start", + "trace_id": "old", + "span_id": "a", + "parent_span_id": None, + "kind": "run", + "name": "old-run", + "ts": 1, + }, + { + "event": "span_start", + "trace_id": "new", + "span_id": "b", + "parent_span_id": None, + "kind": "run", + "name": "new-run", + "ts": 2, + }, ] roots = TraceAssembler(_write_spans(tmp_path, records)).assemble() assert [r.name for r in roots] == ["new-run"] @@ -67,10 +111,16 @@ def test_assembler_defaults_to_latest_trace(tmp_path): def test_chrome_export_is_valid_json(tmp_path): records = [ - {"event": "span_start", "trace_id": "t", "span_id": "a", - "parent_span_id": None, "kind": "run", "name": "r", "ts": 1.0}, - {"event": "span_end", "trace_id": "t", "span_id": "a", - "status": "ok", "duration_s": 2.0}, + { + "event": "span_start", + "trace_id": "t", + "span_id": "a", + "parent_span_id": None, + "kind": "run", + "name": "r", + "ts": 1.0, + }, + {"event": "span_end", "trace_id": "t", "span_id": "a", "status": "ok", "duration_s": 2.0}, ] raw = TraceAssembler(_write_spans(tmp_path, records)).to_chrome_trace("t") data = json.loads(raw) @@ -93,13 +143,14 @@ def test_span_emitter_writes_start_and_end(tmp_path): ctx = TraceContext(trace_id="t1", span_id="s1") started = em.start(ctx, kind="run", name="x", goal="g") em.end(ctx, started, status="ok", steps=3) - recs = [json.loads(l) for l in log.read_text().splitlines()] + recs = [json.loads(line) for line in log.read_text().splitlines()] assert recs[0]["event"] == "span_start" and recs[0]["kind"] == "run" assert recs[1]["event"] == "span_end" and recs[1]["status"] == "ok" # -------------------------------------------------------------- distiller + def _distiller(tmp_path, **kw): return KnowledgeDistiller(db_path=str(tmp_path / "mem.db"), **kw) @@ -169,7 +220,9 @@ def render_constraints(self, **kw): def test_harvest_lessons_from_real_memory(tmp_path): - import sqlite3, time as _t + import sqlite3 + import time as _t + db = tmp_path / "mem.db" con = sqlite3.connect(db) con.executescript(""" @@ -179,11 +232,13 @@ def test_harvest_lessons_from_real_memory(tmp_path): repair_rounds INTEGER DEFAULT 0, lessons TEXT DEFAULT '[]', plan_json TEXT DEFAULT '{}', elapsed_s REAL DEFAULT 0 );""") - con.execute("INSERT INTO agent_runs (ts, task_id, goal, outcome, " - "lessons) VALUES (?, 't1', 'fix lint', 'failed:lint', ?)", - (_t.time(), '["round 0: fail_lint — typo"]')) + con.execute( + "INSERT INTO agent_runs (ts, task_id, goal, outcome, " + "lessons) VALUES (?, 't1', 'fix lint', 'failed:lint', ?)", + (_t.time(), '["round 0: fail_lint — typo"]'), + ) con.commit() con.close() d = KnowledgeDistiller(db_path=str(db)) lessons = d.harvest_lessons(since_s=10_000_000) - assert any("fail_lint" in l for l in lessons) + assert any("fail_lint" in lesson for lesson in lessons) diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py index 683b2469..362069e3 100644 --- a/tests/cli/__init__.py +++ b/tests/cli/__init__.py @@ -3,4 +3,5 @@ Docs: __init__.doc.md """ + from __future__ import annotations diff --git a/tests/cli/test_sin_bash.py b/tests/cli/test_sin_bash.py index 03720f07..1462ff3b 100644 --- a/tests/cli/test_sin_bash.py +++ b/tests/cli/test_sin_bash.py @@ -14,6 +14,7 @@ - --command-from-file (read from disk) - Missing --command / --command-from-file (CLI usage error) """ + from __future__ import annotations import json diff --git a/tests/cli/test_sin_edit.py b/tests/cli/test_sin_edit.py index 5ce66667..1b7528aa 100644 --- a/tests/cli/test_sin_edit.py +++ b/tests/cli/test_sin_edit.py @@ -13,6 +13,7 @@ - Empty --new (acts as delete) - --intent flag (recorded in patch metadata) """ + from __future__ import annotations import json @@ -82,9 +83,7 @@ def test_sin_edit_intent_recorded(tmp_path: Path): """`--intent "..."` is preserved in the patch metadata for auditing.""" f = tmp_path / "x.py" f.write_text("foo\n") - result = _run_cli( - str(f), "--old", "foo", "--new", "bar", "--intent", "rename foo → bar" - ) + result = _run_cli(str(f), "--old", "foo", "--new", "bar", "--intent", "rename foo → bar") assert result.returncode == 0 data = json.loads(result.stdout) assert data["success"] is True diff --git a/tests/cli/test_sin_read.py b/tests/cli/test_sin_read.py index 77c02ba8..1f5b9afd 100644 --- a/tests/cli/test_sin_read.py +++ b/tests/cli/test_sin_read.py @@ -13,6 +13,7 @@ - Directory path (returns listing) - URI scheme (graceful error when sckg not installed) """ + from __future__ import annotations import json diff --git a/tests/cli/test_sin_search.py b/tests/cli/test_sin_search.py index 06d40e3d..8aa3f4e3 100644 --- a/tests/cli/test_sin_search.py +++ b/tests/cli/test_sin_search.py @@ -14,6 +14,7 @@ - --type flag (regex/semantic/symbol/usage) - Hard ceiling on python-regex fallback (max 200 results) """ + from __future__ import annotations import json @@ -60,9 +61,7 @@ def test_sin_search_no_match_returns_empty(tmp_path: Path): def test_sin_search_nonexistent_path(): """A missing path returns a graceful JSON error.""" - result = _run_cli( - "--query", "anything", "--path", "/no/such/dir/sin-search", "--type", "regex" - ) + result = _run_cli("--query", "anything", "--path", "/no/such/dir/sin-search", "--type", "regex") assert result.returncode == 0 data = json.loads(result.stdout) # Either {"error": "..."} from python-regex fallback, or @@ -88,12 +87,12 @@ def test_sin_search_max_ceiling_200(): We don't actually run a 200+ match query (too slow); we just verify the implementation by importing the function and checking the docstring. """ - from sin_code_bundle.file_ops import sin_search - # The implementation has a `if len(results) >= 200: break` ceiling. # Source-check: look for the literal in the function body. import inspect + from sin_code_bundle.file_ops import sin_search + src = inspect.getsource(sin_search) assert "200" in src assert "break" in src diff --git a/tests/cli/test_sin_write.py b/tests/cli/test_sin_write.py index 786f9e69..604577ce 100644 --- a/tests/cli/test_sin_write.py +++ b/tests/cli/test_sin_write.py @@ -13,6 +13,7 @@ - --content-from-file (read from disk) - --no-verify (skip syntax check) """ + from __future__ import annotations import json diff --git a/tests/fuzz/fuzz_go_mcp.py b/tests/fuzz/fuzz_go_mcp.py index 00cac31c..24fbf069 100644 --- a/tests/fuzz/fuzz_go_mcp.py +++ b/tests/fuzz/fuzz_go_mcp.py @@ -11,13 +11,12 @@ from __future__ import annotations +import argparse import json -import subprocess -import sys -import time import os -import argparse import signal +import subprocess +import sys from dataclasses import dataclass, field from typing import Any @@ -60,6 +59,7 @@ def total(self) -> int: # ── Payload generators ────────────────────────────────── + def make_empty_inputs(): return [ ("empty_string", b""), @@ -101,34 +101,67 @@ def make_missing_method(): def make_weird_method_names(): return [ ("unicode_emoji", json.dumps({"jsonrpc": "2.0", "method": "\U0001f4a9", "id": 1}).encode()), - ("unicode_chinese", json.dumps({"jsonrpc": "2.0", "method": "\u4f60\u597d\u4e16\u754c", "id": 2}).encode()), - ("special_chars", json.dumps({"jsonrpc": "2.0", "method": "tool/!@#$%^&*()", "id": 3}).encode()), - ("sql_injection_method", json.dumps({"jsonrpc": "2.0", "method": "\"; DROP TABLE;--", "id": 4}).encode()), + ( + "unicode_chinese", + json.dumps({"jsonrpc": "2.0", "method": "\u4f60\u597d\u4e16\u754c", "id": 2}).encode(), + ), + ( + "special_chars", + json.dumps({"jsonrpc": "2.0", "method": "tool/!@#$%^&*()", "id": 3}).encode(), + ), + ( + "sql_injection_method", + json.dumps({"jsonrpc": "2.0", "method": '"; DROP TABLE;--', "id": 4}).encode(), + ), ("empty_method", json.dumps({"jsonrpc": "2.0", "method": "", "id": 5}).encode()), ] def make_long_method_name(): - name_10kb = "a" * (10 * 1024) # 10 KB + name_10kb = "a" * (10 * 1024) # 10 KB name_100kb = "a" * (100 * 1024) # 100 KB return [ ("long_method_10kb", json.dumps({"jsonrpc": "2.0", "method": name_10kb, "id": 1}).encode()), - ("long_method_100kb", json.dumps({"jsonrpc": "2.0", "method": name_100kb, "id": 1}).encode()), + ( + "long_method_100kb", + json.dumps({"jsonrpc": "2.0", "method": name_100kb, "id": 1}).encode(), + ), ] def make_missing_params(): return [ - ("missing_params", json.dumps({"jsonrpc": "2.0", "method": "tools/list", "id": 1}).encode()), + ( + "missing_params", + json.dumps({"jsonrpc": "2.0", "method": "tools/list", "id": 1}).encode(), + ), ] def make_weird_params(): return [ - ("params_as_array", json.dumps({"jsonrpc": "2.0", "method": "tools/call", "id": 1, "params": [1, 2, 3]}).encode()), - ("params_as_string", json.dumps({"jsonrpc": "2.0", "method": "tools/call", "id": 1, "params": "hello"}).encode()), - ("params_as_number", json.dumps({"jsonrpc": "2.0", "method": "tools/call", "id": 1, "params": 42}).encode()), - ("params_as_null", json.dumps({"jsonrpc": "2.0", "method": "tools/call", "id": 1, "params": None}).encode()), + ( + "params_as_array", + json.dumps( + {"jsonrpc": "2.0", "method": "tools/call", "id": 1, "params": [1, 2, 3]} + ).encode(), + ), + ( + "params_as_string", + json.dumps( + {"jsonrpc": "2.0", "method": "tools/call", "id": 1, "params": "hello"} + ).encode(), + ), + ( + "params_as_number", + json.dumps({"jsonrpc": "2.0", "method": "tools/call", "id": 1, "params": 42}).encode(), + ), + ( + "params_as_null", + json.dumps( + {"jsonrpc": "2.0", "method": "tools/call", "id": 1, "params": None} + ).encode(), + ), ] @@ -140,7 +173,10 @@ def make_nested_objects(depth=1000): current = current["nested"] current["value"] = "bottom" return [ - ("nested_1000_levels", json.dumps({"jsonrpc": "2.0", "method": "tools/call", "id": 1, "params": obj}).encode()), + ( + "nested_1000_levels", + json.dumps({"jsonrpc": "2.0", "method": "tools/call", "id": 1, "params": obj}).encode(), + ), ] @@ -150,7 +186,10 @@ def make_weird_ids(): ("float_id", json.dumps({"jsonrpc": "2.0", "method": "tools/list", "id": 3.14}).encode()), ("string_id", json.dumps({"jsonrpc": "2.0", "method": "tools/list", "id": "abc"}).encode()), ("null_id", json.dumps({"jsonrpc": "2.0", "method": "tools/list", "id": None}).encode()), - ("very_large_id", json.dumps({"jsonrpc": "2.0", "method": "tools/list", "id": 2**63}).encode()), + ( + "very_large_id", + json.dumps({"jsonrpc": "2.0", "method": "tools/list", "id": 2**63}).encode(), + ), ] @@ -173,8 +212,16 @@ def make_batch_requests(): def make_notifications(): return [ - ("notification_no_id", json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}).encode()), - ("notification_params", json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {"key": "val"}}).encode()), + ( + "notification_no_id", + json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}).encode(), + ), + ( + "notification_params", + json.dumps( + {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {"key": "val"}} + ).encode(), + ), ] @@ -200,6 +247,7 @@ def make_invalid_json_syntax(): # ── Response validation ──────────────────────────────── + def validate_jsonrpc_response(response_json: Any) -> str: """Check JSON-RPC 2.0 compliance. Returns violation description or "".""" if not isinstance(response_json, dict): @@ -220,7 +268,6 @@ def validate_jsonrpc_response(response_json: Any) -> str: # id field handling - note notifications don't get responses, # but if we get a response it must have id or error - has_id = "id" in response_json has_result = "result" in response_json has_error = "error" in response_json @@ -244,9 +291,8 @@ def validate_jsonrpc_response(response_json: Any) -> str: # ── Core fuzzing logic ───────────────────────────────── -def run_single_attack( - binary: str, name: str, payload: bytes, timeout: float = 5.0 -) -> AttackResult: + +def run_single_attack(binary: str, name: str, payload: bytes, timeout: float = 5.0) -> AttackResult: """Send one attack payload via subprocess to a Go MCP binary.""" result = AttackResult(name=name, payload=repr(payload[:100])) @@ -302,7 +348,7 @@ def run_single_attack( violation = validate_jsonrpc_response(result.response_json) if violation: result.protocol_violation = violation - except json.JSONDecodeError as e: + except json.JSONDecodeError: result.protocol_violation = f"non_json_response: {stdout_data[:200]!r}" except FileNotFoundError: @@ -322,14 +368,18 @@ def fuzz_go_tool(binary: str, tool_label: str, timeout: float = 5.0) -> FuzzRepo # Verify binary exists if not os.path.exists(binary): print(f" ❌ Binary not found: {binary}") - report.results.append(AttackResult(name="binary_check", payload=None, crashed=True, - error_msg="binary_not_found")) + report.results.append( + AttackResult( + name="binary_check", payload=None, crashed=True, error_msg="binary_not_found" + ) + ) return report # Quick startup test: valid tools/list print(f" → Startup check ({tool_label})...") startup = run_single_attack( - binary, "startup_check", + binary, + "startup_check", json.dumps({"jsonrpc": "2.0", "method": "tools/list", "id": 1}).encode(), timeout=timeout, ) @@ -337,12 +387,12 @@ def fuzz_go_tool(binary: str, tool_label: str, timeout: float = 5.0) -> FuzzRepo if startup.crashed or startup.hung: print(f" ⚠️ Startup failed: crash={startup.crashed}, hang={startup.hung}") else: - print(f" ✅ Startup OK") + print(" ✅ Startup OK") all_attacks = [] # ── Category 1: Empty/invalid raw input ──────────── - print(f" → Testing empty/malformed raw input...") + print(" → Testing empty/malformed raw input...") for name, payload in make_empty_inputs(): all_attacks.append((name, payload)) for name, payload in make_non_json(): @@ -351,7 +401,7 @@ def fuzz_go_tool(binary: str, tool_label: str, timeout: float = 5.0) -> FuzzRepo all_attacks.append((name, payload)) # ── Category 2: Invalid JSON-RPC structure ───────── - print(f" → Testing invalid JSON-RPC...") + print(" → Testing invalid JSON-RPC...") for name, payload in make_invalid_json_rpc(): all_attacks.append((name, payload)) for name, payload in make_missing_method(): @@ -362,34 +412,34 @@ def fuzz_go_tool(binary: str, tool_label: str, timeout: float = 5.0) -> FuzzRepo all_attacks.append((name, payload)) # ── Category 3: Weird params ─────────────────────── - print(f" → Testing weird params...") + print(" → Testing weird params...") for name, payload in make_missing_params(): all_attacks.append((name, payload)) for name, payload in make_weird_params(): all_attacks.append((name, payload)) # ── Category 4: Long inputs ──────────────────────── - print(f" → Testing long method names...") + print(" → Testing long method names...") for name, payload in make_long_method_name(): all_attacks.append((name, payload)) # ── Category 5: Deep nesting ─────────────────────── - print(f" → Testing deep nesting...") + print(" → Testing deep nesting...") for name, payload in make_nested_objects(1000): all_attacks.append((name, payload)) # ── Category 6: Batches ──────────────────────────── - print(f" → Testing batch requests...") + print(" → Testing batch requests...") for name, payload in make_batch_requests(): all_attacks.append((name, payload)) # ── Category 7: Notifications ────────────────────── - print(f" → Testing notifications...") + print(" → Testing notifications...") for name, payload in make_notifications(): all_attacks.append((name, payload)) # ── Category 8: Oversized ────────────────────────── - print(f" → Testing oversized payload (10MB)...") + print(" → Testing oversized payload (10MB)...") for name, payload in make_oversized(): all_attacks.append((name, payload)) @@ -400,13 +450,15 @@ def fuzz_go_tool(binary: str, tool_label: str, timeout: float = 5.0) -> FuzzRepo # Progress indicator if result.crashed: - print(f" [{i+1}/{len(all_attacks)}] 💥 {name}: CRASH (exit={result.exit_code})") + print(f" [{i + 1}/{len(all_attacks)}] 💥 {name}: CRASH (exit={result.exit_code})") elif result.hung: - print(f" [{i+1}/{len(all_attacks)}] ⏳ {name}: HANG (>5s)") + print(f" [{i + 1}/{len(all_attacks)}] ⏳ {name}: HANG (>5s)") elif result.protocol_violation: - print(f" [{i+1}/{len(all_attacks)}] ⚠️ {name}: protocol violation ({result.protocol_violation})") + print( + f" [{i + 1}/{len(all_attacks)}] ⚠️ {name}: protocol violation ({result.protocol_violation})" + ) else: - print(f" [{i+1}/{len(all_attacks)}] ✅ {name}: OK") + print(f" [{i + 1}/{len(all_attacks)}] ✅ {name}: OK") return report @@ -444,14 +496,10 @@ def print_report(report: FuzzReport): print(f" Response: {json.dumps(r.response_json)[:200]}") # Severity score - severity = ( - len(report.crashes) * 10 - + len(report.hangs) * 5 - + len(report.violations) * 2 - ) + severity = len(report.crashes) * 10 + len(report.hangs) * 5 + len(report.violations) * 2 print() if severity == 0: - print(f" 🛡️ SEVERITY: NONE — Tool survived all attacks") + print(" 🛡️ SEVERITY: NONE — Tool survived all attacks") elif severity < 20: print(f" ⚠️ SEVERITY: LOW ({severity})") elif severity < 50: diff --git a/tests/fuzz/integration_tests.py b/tests/fuzz/integration_tests.py index 7f901d77..e8e6bcad 100644 --- a/tests/fuzz/integration_tests.py +++ b/tests/fuzz/integration_tests.py @@ -23,9 +23,7 @@ import subprocess import sys import tempfile -import time import unittest -from pathlib import Path from typing import Any # ── Paths ──────────────────────────────────────────────── @@ -80,9 +78,7 @@ def run_tool(tool_name: str, *args: str, timeout: float = 30.0) -> dict[str, Any print(f" [STDERR] {tool_name}: {stderr[:500]}", file=sys.stderr) if proc.returncode != 0: - raise RuntimeError( - f"{tool_name} exited {proc.returncode}: {stderr[:300]}" - ) + raise RuntimeError(f"{tool_name} exited {proc.returncode}: {stderr[:300]}") if not stdout: return {"_empty": True, "_stderr": stderr} @@ -251,14 +247,18 @@ def test_discover_go_files(self): """Step 1: Discover all Go files in the test project.""" result = run_tool( "discover", - "-path", self.test_dir, - "-pattern", "**/*.go", - "-max_results", "20", + "-path", + self.test_dir, + "-pattern", + "**/*.go", + "-max_results", + "20", ) self.assertIn("total_matches", result) self.assertIn("files", result) - self.assertGreaterEqual(result["total_matches"], 3, - f"Expected >=3 Go files, got {result.get('total_matches')}") + self.assertGreaterEqual( + result["total_matches"], 3, f"Expected >=3 Go files, got {result.get('total_matches')}" + ) self._discovered_files = result["files"] self._discover_result = result @@ -284,11 +284,13 @@ def test_grasp_each_discovered_file(self): return grasp = run_tool("grasp", "-file", file_path) - grasp_results.append({ - "file_path": file_path, - "file_name": file_info.get("name", os.path.basename(file_path)), - "grasp": grasp, - }) + grasp_results.append( + { + "file_path": file_path, + "file_name": file_info.get("name", os.path.basename(file_path)), + "grasp": grasp, + } + ) self._grasp_results = grasp_results @@ -301,8 +303,7 @@ def test_verify_grasp_references_correct_files(self): file_path = gr["file_path"] # grasp should return the target file - self.assertIn("target_file", grasp, - f"grasp for {file_path} missing target_file field") + self.assertIn("target_file", grasp, f"grasp for {file_path} missing target_file field") grasp_target = grasp.get("target_file", "") # The grasp target should match the file we passed @@ -311,20 +312,17 @@ def test_verify_grasp_references_correct_files(self): os.path.basename(file_path) in grasp_target or grasp_target == file_path or file_path.endswith(grasp_target), - f"grasp target '{grasp_target}' does not match file '{file_path}'" + f"grasp target '{grasp_target}' does not match file '{file_path}'", ) # grasp should have a structure section - self.assertIn("structure", grasp, - f"grasp for {file_path} missing structure") + self.assertIn("structure", grasp, f"grasp for {file_path} missing structure") # Each grasp should find functions struct = grasp.get("structure", {}) functions = struct.get("functions", []) - self.assertIsInstance(functions, list, - f"functions should be a list in {file_path}") - self.assertGreater(len(functions), 0, - f"Expected at least 1 function in {file_path}") + self.assertIsInstance(functions, list, f"functions should be a list in {file_path}") + self.assertGreater(len(functions), 0, f"Expected at least 1 function in {file_path}") # Cross-reference: check that discovered files and grasped files match 1:1 discovered_names = {f.get("name", "") for f in self._discovered_files} @@ -332,7 +330,7 @@ def test_verify_grasp_references_correct_files(self): self.assertSetEqual( discovered_names, grasped_names, - f"Discovered files {discovered_names} don't match grasped {grasped_names}" + f"Discovered files {discovered_names} don't match grasped {grasped_names}", ) @@ -351,15 +349,22 @@ def test_scout_find_function(self): """Step 1: Use scout to search for a specific function""" result = run_tool( "scout", - "-query", "loadConfig", - "-search_type", "regex", - "-path", self.test_dir, - "-max_results", "10", + "-query", + "loadConfig", + "-search_type", + "regex", + "-path", + self.test_dir, + "-max_results", + "10", ) self.assertIn("results", result) self.assertIn("total_matches", result) - self.assertGreater(result["total_matches"], 0, - f"Expected loadConfig matches, got {result.get('total_matches')}") + self.assertGreater( + result["total_matches"], + 0, + f"Expected loadConfig matches, got {result.get('total_matches')}", + ) self._scout_result = result def test_grasp_scout_files(self): @@ -382,11 +387,13 @@ def test_grasp_scout_files(self): continue grasp = run_tool("grasp", "-file", file_path) - grasp_results.append({ - "scout_item": item, - "file_path": file_path, - "grasp": grasp, - }) + grasp_results.append( + { + "scout_item": item, + "file_path": file_path, + "grasp": grasp, + } + ) self._grasp_results = grasp_results @@ -399,7 +406,7 @@ def test_verify_function_details_match(self): scout_item = gr["scout_item"] # The scout match content should contain the function name - scout_content = scout_item.get("content", "") + scout_item.get("content", "") scout_file = scout_item.get("file", "") # grasp functions should include the scouted symbol @@ -408,8 +415,9 @@ def test_verify_function_details_match(self): function_names = {f.get("name", "") for f in functions} self.assertIn( - "loadConfig", function_names, - f"grasp of {scout_file} should find 'loadConfig' in {function_names}" + "loadConfig", + function_names, + f"grasp of {scout_file} should find 'loadConfig' in {function_names}", ) # Verify that grasp found more context about loadConfig @@ -418,8 +426,7 @@ def test_verify_function_details_match(self): # loadConfig should have a signature or purpose purpose = func.get("purpose", "") self.assertNotEqual( - purpose, "", - f"loadConfig in grasp should have a purpose, got '{purpose}'" + purpose, "", f"loadConfig in grasp should have a purpose, got '{purpose}'" ) @@ -438,9 +445,12 @@ def test_map_dependency_graph(self): """Step 1: Build a dependency graph of the test project.""" result = run_tool( "map", - "-path", self.test_dir, - "-action", "map", - "-format", "json", + "-path", + self.test_dir, + "-action", + "map", + "-format", + "json", ) self.assertIn("modules", result) self.assertIn("dependency_graph", result) @@ -458,8 +468,7 @@ def test_map_dependency_graph(self): expected = {"main", "loadConfig", "NewDataProcessor"} found = all_symbols & expected self.assertGreater( - len(found), 0, - f"Expected some of {expected} in exported symbols, got {all_symbols}" + len(found), 0, f"Expected some of {expected} in exported symbols, got {all_symbols}" ) self._map_result = result @@ -483,10 +492,14 @@ def test_scout_symbols_in_deps(self): for symbol in symbols_to_check[:5]: # Check up to 5 symbols result = run_tool( "scout", - "-query", symbol, - "-search_type", "symbol", - "-path", self.test_dir, - "-max_results", "5", + "-query", + symbol, + "-search_type", + "symbol", + "-path", + self.test_dir, + "-max_results", + "5", ) scout_results[symbol] = result @@ -501,7 +514,7 @@ def test_verify_dependencies_reflected_in_scout(self): # For each module, check that its symbols are findable by scout for mod in modules: - mod_deps = set(mod.get("dependencies", [])) + set(mod.get("dependencies", [])) exported = set(mod.get("exported_symbols", [])) for symbol in list(exported)[:3]: @@ -510,15 +523,18 @@ def test_verify_dependencies_reflected_in_scout(self): sr = scout_results[symbol] total = sr.get("total_matches", 0) self.assertGreater( - total, 0, - f"scout should find symbol '{symbol}' (exported by module '{mod['name']}')" + total, + 0, + f"scout should find symbol '{symbol}' (exported by module '{mod['name']}')", ) # Verify architecture information from scout includes layers found by map for symbol, sr in scout_results.items(): arch = sr.get("architecture", {}) layers = arch.get("layers", {}) - self.assertIsInstance(layers, dict, f"Architecture layers for '{symbol}' should be a dict") + self.assertIsInstance( + layers, dict, f"Architecture layers for '{symbol}' should be a dict" + ) # Check that map's dependency count is consistent with scouted symbol count deps_graph = self._map_result.get("dependency_graph", {}) @@ -533,8 +549,11 @@ def test_verify_dependencies_reflected_in_scout(self): if f: scouted_files.add(os.path.basename(f)) - map_file_names = {os.path.basename(n.get("id", n.get("name", ""))) - for n in map_nodes if n.get("id") or n.get("name")} + map_file_names = { + os.path.basename(n.get("id", n.get("name", ""))) + for n in map_nodes + if n.get("id") or n.get("name") + } # Map nodes may use directory names (e.g. "src") while scouted files use # individual filenames (e.g. "helpers.go"). We check that at least one # scouted file path contains a substring that mathches a map node name. @@ -552,8 +571,9 @@ def test_verify_dependencies_reflected_in_scout(self): overlap.add("LAYERS_FOUND") self.assertGreater( - len(overlap), 0, - f"Scouted files {scouted_files} should have some relationship to map nodes {map_file_names}" + len(overlap), + 0, + f"Scouted files {scouted_files} should have some relationship to map nodes {map_file_names}", ) @@ -573,22 +593,26 @@ def test_execute_command(self): # Check Go syntax with `gofmt -e` (non-destructive) result = run_tool( "execute", - "-command", f"gofmt -e {self.test_dir}/src/*.go", - "-work_dir", self.test_dir, - "-safety", "false", + "-command", + f"gofmt -e {self.test_dir}/src/*.go", + "-work_dir", + self.test_dir, + "-safety", + "false", ) self.assertIn("exit_code", result) self.assertIn("stdout", result) # gofmt should succeed if files are syntactically valid self.assertEqual( - result.get("exit_code", -1), 0, - f"gofmt should exit 0, got {result.get('exit_code')}: {result.get('stderr', '')}" + result.get("exit_code", -1), + 0, + f"gofmt should exit 0, got {result.get('exit_code')}: {result.get('stderr', '')}", ) self._execute_result = result def test_store_in_sinbrain(self): """Step 2: Store execute results in SIN-Brain memory. - + Uses a subprocess to avoid import/threading conflicts with the test runner process. SIN-Brain runs cleanly in its own venv. """ @@ -629,11 +653,12 @@ def test_store_in_sinbrain(self): proc = subprocess.run( [sys.executable, script_path], - capture_output=True, text=True, timeout=15, + capture_output=True, + text=True, + timeout=15, cwd="/Users/jeremy/dev/SIN-Brain/src", ) - self.assertEqual(proc.returncode, 0, - f"remember subprocess failed: {proc.stderr[:300]}") + self.assertEqual(proc.returncode, 0, f"remember subprocess failed: {proc.stderr[:300]}") mem_id = proc.stdout.strip() self.assertIsNotNone(mem_id, "remember() should return a memory ID") self.assertGreater(len(mem_id), 0, "Memory ID should not be empty") @@ -662,11 +687,12 @@ def test_recall_stored_result(self): proc = subprocess.run( [sys.executable, script_path], - capture_output=True, text=True, timeout=15, + capture_output=True, + text=True, + timeout=15, cwd="/Users/jeremy/dev/SIN-Brain/src", ) - self.assertEqual(proc.returncode, 0, - f"recall subprocess failed: {proc.stderr[:300]}") + self.assertEqual(proc.returncode, 0, f"recall subprocess failed: {proc.stderr[:300]}") results = json.loads(proc.stdout.strip()) self.assertIsInstance(results, list, "recall should return a list") @@ -680,7 +706,9 @@ def test_recall_stored_result(self): found = True break - self.assertTrue(found, f"recall should find the memory we stored: '{self._content[:80]}...'") + self.assertTrue( + found, f"recall should find the memory we stored: '{self._content[:80]}...'" + ) class TestWorkflow5_HarvestToExecute(unittest.TestCase): @@ -705,7 +733,7 @@ def setUpClass(cls): {"title": "Integration Test", "type": "all"}, {"title": "Cross-Tool Workflow", "type": "test"}, ], - "title": "Harvest to Execute Pipeline" + "title": "Harvest to Execute Pipeline", } } @@ -723,25 +751,21 @@ def test_harvest_binary_available(self): # Verify harvest binary exists and supports JSON output harvest_path = TOOLS["harvest"] self.assertTrue( - os.path.isfile(harvest_path), - f"harvest binary must exist at {harvest_path}" + os.path.isfile(harvest_path), f"harvest binary must exist at {harvest_path}" ) self.assertTrue( - os.access(harvest_path, os.X_OK), - f"harvest binary must be executable at {harvest_path}" + os.access(harvest_path, os.X_OK), f"harvest binary must be executable at {harvest_path}" ) # Verify harvest can be invoked (help output) proc = subprocess.run( [harvest_path, "--help"], - capture_output=True, text=True, timeout=5, + capture_output=True, + text=True, + timeout=5, ) - self.assertGreater( - len(proc.stdout), 0, - "harvest --help should produce output" - ) - self.assertIn("url", proc.stdout.lower(), - "harvest help should mention 'url' parameter") + self.assertGreater(len(proc.stdout), 0, "harvest --help should produce output") + self.assertIn("url", proc.stdout.lower(), "harvest help should mention 'url' parameter") def test_pipe_harvest_to_execute(self): """Step 2: Simulate harvest output -> execute JSON parsing pipeline.""" @@ -749,7 +773,7 @@ def test_pipe_harvest_to_execute(self): # Use execute to pipe harvest-like JSON through Python JSON parser parse_cmd = ( - f"python3 -c \"" + f'python3 -c "' f"import json; " f"data=json.load(open('{json_path}')); " f"print('PARSED:', type(data).__name__); " @@ -760,14 +784,18 @@ def test_pipe_harvest_to_execute(self): result = run_tool( "execute", - "-command", parse_cmd, - "-work_dir", self.test_dir, - "-safety", "false", + "-command", + parse_cmd, + "-work_dir", + self.test_dir, + "-safety", + "false", ) self.assertIn("exit_code", result) self.assertEqual( - result.get("exit_code"), 0, - f"Python JSON parse should succeed: {result.get('stderr', '')[:200]}" + result.get("exit_code"), + 0, + f"Python JSON parse should succeed: {result.get('stderr', '')[:200]}", ) combined = result.get("combined_output", "") @@ -791,16 +819,10 @@ def test_roundtrip_verification(self): "TITLE: Harvest to Execute Pipeline", ] for field in expected_fields: - self.assertIn( - field, combined, - f"Execute output should contain '{field}'" - ) + self.assertIn(field, combined, f"Execute output should contain '{field}'") # Verify the execute tool's metadata - self.assertTrue( - self._execute_result.get("success"), - "Execute should report success=True" - ) + self.assertTrue(self._execute_result.get("success"), "Execute should report success=True") safety = self._execute_result.get("safety_check", {}) self.assertIn("risk_level", safety, "Safety check should report risk level") @@ -821,10 +843,14 @@ def test_orchestrate_create_task_flow(self): # Task 1: Discover the project t1 = run_tool( "orchestrate", - "-action", "add", - "-title", "Discover Go Project", - "-description", "Use discover tool to find all Go files", - "-tags", "integration_test,step_1", + "-action", + "add", + "-title", + "Discover Go Project", + "-description", + "Use discover tool to find all Go files", + "-tags", + "integration_test,step_1", ) self.assertEqual(t1.get("action"), "add") t1_id = t1.get("task_id", "") @@ -833,11 +859,16 @@ def test_orchestrate_create_task_flow(self): # Task 2: Map the architecture (depends on Task 1) t2 = run_tool( "orchestrate", - "-action", "add", - "-title", "Map Architecture", - "-description", "Use map tool to build dependency graph", - "-dependencies", t1_id, - "-tags", "integration_test,step_2", + "-action", + "add", + "-title", + "Map Architecture", + "-description", + "Use map tool to build dependency graph", + "-dependencies", + t1_id, + "-tags", + "integration_test,step_2", ) t2_id = t2.get("task_id", "") self.assertTrue(t2_id, "Task ID should be set") @@ -845,11 +876,16 @@ def test_orchestrate_create_task_flow(self): # Task 3: Scout for symbols (depends on Task 2) t3 = run_tool( "orchestrate", - "-action", "add", - "-title", "Scout Symbols", - "-description", "Use scout to find all exported symbols", - "-dependencies", t2_id, - "-tags", "integration_test,step_3", + "-action", + "add", + "-title", + "Scout Symbols", + "-description", + "Use scout to find all exported symbols", + "-dependencies", + t2_id, + "-tags", + "integration_test,step_3", ) t3_id = t3.get("task_id", "") self.assertTrue(t3_id, "Task ID should be set") @@ -857,10 +893,14 @@ def test_orchestrate_create_task_flow(self): # Task 4: Execute a build (no dependency) t4 = run_tool( "orchestrate", - "-action", "add", - "-title", "Execute Build Check", - "-description", f"Run gofmt -e on {self.test_dir}/src", - "-tags", "integration_test,step_4", + "-action", + "add", + "-title", + "Execute Build Check", + "-description", + f"Run gofmt -e on {self.test_dir}/src", + "-tags", + "integration_test,step_4", ) t4_id = t4.get("task_id", "") self.assertTrue(t4_id, "Task ID should be set") @@ -881,9 +921,12 @@ def test_orchestrate_run_each_step(self): # Step 1: Discover r_discover = run_tool( "discover", - "-path", self.test_dir, - "-pattern", "**/*.go", - "-max_results", "10", + "-path", + self.test_dir, + "-pattern", + "**/*.go", + "-max_results", + "10", ) results["discover"] = r_discover self.assertGreater(r_discover.get("total_matches", 0), 0, "Discover should find Go files") @@ -891,17 +934,23 @@ def test_orchestrate_run_each_step(self): # Mark task 1 complete run_tool( "orchestrate", - "-action", "complete", - "-task_id", self._task_ids["discover"], - "-format", "json", + "-action", + "complete", + "-task_id", + self._task_ids["discover"], + "-format", + "json", ) # Step 2: Map (takes discover results as context) r_map = run_tool( "map", - "-path", self.test_dir, - "-action", "map", - "-format", "json", + "-path", + self.test_dir, + "-action", + "map", + "-format", + "json", ) results["map"] = r_map self.assertIn("modules", r_map, "Map should return modules") @@ -910,9 +959,12 @@ def test_orchestrate_run_each_step(self): # Mark task 2 complete run_tool( "orchestrate", - "-action", "complete", - "-task_id", self._task_ids["map"], - "-format", "json", + "-action", + "complete", + "-task_id", + self._task_ids["map"], + "-format", + "json", ) # Step 3: Scout (takes exported symbols from map as targets) @@ -925,10 +977,14 @@ def test_orchestrate_run_each_step(self): for symbol in list(all_symbols)[:3]: scout_result = run_tool( "scout", - "-query", symbol, - "-search_type", "symbol", - "-path", self.test_dir, - "-max_results", "5", + "-query", + symbol, + "-search_type", + "symbol", + "-path", + self.test_dir, + "-max_results", + "5", ) if scout_result.get("total_matches", 0) > 0: break @@ -938,27 +994,38 @@ def test_orchestrate_run_each_step(self): # Mark task 3 complete run_tool( "orchestrate", - "-action", "complete", - "-task_id", self._task_ids["scout"], - "-format", "json", + "-action", + "complete", + "-task_id", + self._task_ids["scout"], + "-format", + "json", ) # Step 4: Execute r_exec = run_tool( "execute", - "-command", f"gofmt -e {self.test_dir}/src/*.go", - "-work_dir", self.test_dir, - "-safety", "false", + "-command", + f"gofmt -e {self.test_dir}/src/*.go", + "-work_dir", + self.test_dir, + "-safety", + "false", ) results["execute"] = r_exec - self.assertEqual(r_exec.get("exit_code"), 0, f"gofmt should succeed: {r_exec.get('stderr', '')}") + self.assertEqual( + r_exec.get("exit_code"), 0, f"gofmt should succeed: {r_exec.get('stderr', '')}" + ) # Mark task 4 complete run_tool( "orchestrate", - "-action", "complete", - "-task_id", self._task_ids["execute"], - "-format", "json", + "-action", + "complete", + "-task_id", + self._task_ids["execute"], + "-format", + "json", ) self._workflow_results = results @@ -970,7 +1037,8 @@ def test_orchestrate_verify_flow_completes(self): # Get status status = run_tool( "orchestrate", - "-action", "status", + "-action", + "status", ) progress = status.get("progress", {}) completed = progress.get("completed", 0) @@ -1006,17 +1074,11 @@ def test_orchestrate_verify_flow_completes(self): def main(): import argparse - parser = argparse.ArgumentParser( - description="Run SIN-Code cross-tool integration tests" - ) - parser.add_argument( - "--workflow", "-w", type=int, choices=range(1, 7), - help="Run only a specific workflow (1-6)" - ) + parser = argparse.ArgumentParser(description="Run SIN-Code cross-tool integration tests") parser.add_argument( - "--verbose", "-v", action="store_true", - help="Show verbose tool output" + "--workflow", "-w", type=int, choices=range(1, 7), help="Run only a specific workflow (1-6)" ) + parser.add_argument("--verbose", "-v", action="store_true", help="Show verbose tool output") args, unknown = parser.parse_known_args() global VERBOSE diff --git a/tests/fuzz/run_all_fuzz.py b/tests/fuzz/run_all_fuzz.py index ba19f50d..980284e4 100644 --- a/tests/fuzz/run_all_fuzz.py +++ b/tests/fuzz/run_all_fuzz.py @@ -12,13 +12,11 @@ from __future__ import annotations -import json +import os import subprocess import sys import time -import os -from dataclasses import dataclass, field -from typing import Any +from dataclasses import dataclass BASE_DIR = os.path.dirname(os.path.abspath(__file__)) FUZZ_SCRIPT = os.path.join(BASE_DIR, "fuzz_go_mcp.py") @@ -53,16 +51,17 @@ class ToolResult: def run_go_fuzz(label: str, binary: str, timeout: float = 3.0) -> ToolResult: """Run fuzz_go_mcp.py against one Go tool.""" result = ToolResult(name=label) - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f" FUZZING: {label} ({binary})") - print(f"{'='*60}") + print(f"{'=' * 60}") t0 = time.time() try: proc = subprocess.run( - [sys.executable, FUZZ_SCRIPT, binary, "--label", label, - "--timeout", str(timeout)], - capture_output=True, text=True, timeout=600, # 10 min max per tool + [sys.executable, FUZZ_SCRIPT, binary, "--label", label, "--timeout", str(timeout)], + capture_output=True, + text=True, + timeout=600, # 10 min max per tool ) result.duration_s = time.time() - t0 result.raw_stdout = proc.stdout @@ -72,12 +71,12 @@ def run_go_fuzz(label: str, binary: str, timeout: float = 3.0) -> ToolResult: stdout = proc.stdout crash_count = stdout.count("💥") hang_count = stdout.count("⏳") - viol_count = stdout.count("⚠️") - stdout.count("⚠️ Startup failed") # exclude startup warning + viol_count = stdout.count("⚠️") - stdout.count( + "⚠️ Startup failed" + ) # exclude startup warning result.total_attacks = max( - stdout.count("✅") + crash_count + hang_count - + stdout.count("⚠️ "), - 1 + stdout.count("✅") + crash_count + hang_count + stdout.count("⚠️ "), 1 ) result.crashes = crash_count result.hangs = hang_count @@ -109,24 +108,24 @@ def run_go_fuzz(label: str, binary: str, timeout: float = 3.0) -> ToolResult: def run_sin_brain_fuzz() -> ToolResult: """Run sin_brain fuzz test.""" result = ToolResult(name="SIN-Brain") - print(f"\n{'='*60}") - print(f" FUZZING: SIN-Brain (Python MCP)") - print(f"{'='*60}") + print(f"\n{'=' * 60}") + print(" FUZZING: SIN-Brain (Python MCP)") + print(f"{'=' * 60}") t0 = time.time() try: proc = subprocess.run( [sys.executable, SIN_BRAIN_SCRIPT], - capture_output=True, text=True, timeout=120, + capture_output=True, + text=True, + timeout=120, ) result.duration_s = time.time() - t0 result.raw_stdout = proc.stdout result.raw_stderr = proc.stderr crash_count = proc.stdout.count("💥") - result.total_attacks = max( - proc.stdout.count("✅") + crash_count, 1 - ) + result.total_attacks = max(proc.stdout.count("✅") + crash_count, 1) result.crashes = crash_count if proc.returncode == 0: @@ -160,7 +159,9 @@ def generate_summary(results: list[ToolResult]): total_attacks = sum(r.total_attacks for r in results) total_time = sum(r.duration_s for r in results) - print(f"\n{'Tool':<18} {'Status':<12} {'Attacks':>8} {'Crashes':>8} {'Hangs':>8} {'Viols':>8} {'Time':>8}") + print( + f"\n{'Tool':<18} {'Status':<12} {'Attacks':>8} {'Crashes':>8} {'Hangs':>8} {'Viols':>8} {'Time':>8}" + ) print("-" * 72) for r in results: @@ -173,24 +174,34 @@ def generate_summary(results: list[ToolResult]): "SKIP": "⏭️", }.get(r.status, "❓") - print(f"{r.name:<18} {status_icon + ' ' + r.status:<10} " - f"{r.total_attacks:>8} {r.crashes:>8} {r.hangs:>8} {r.violations:>8} {r.duration_s:>7.1f}s") + print( + f"{r.name:<18} {status_icon + ' ' + r.status:<10} " + f"{r.total_attacks:>8} {r.crashes:>8} {r.hangs:>8} {r.violations:>8} {r.duration_s:>7.1f}s" + ) print("-" * 72) - print(f"{'TOTAL':<18} {'':<12} {total_attacks:>8} {total_crashes:>8} {total_hangs:>8} {total_violations:>8} {total_time:>7.1f}s") + print( + f"{'TOTAL':<18} {'':<12} {total_attacks:>8} {total_crashes:>8} {total_hangs:>8} {total_violations:>8} {total_time:>7.1f}s" + ) # Severity summary print() if total_crashes == 0 and total_hangs == 0 and total_violations == 0: print("🛡️ ALL TOOLS PASSED — No crashes, hangs, or violations detected") else: - print(f"💀 VULNERABILITY REPORT:") + print("💀 VULNERABILITY REPORT:") if total_crashes: - print(f" 💥 {total_crashes} CRASHES across {sum(1 for r in results if r.crashes > 0)} tools") + print( + f" 💥 {total_crashes} CRASHES across {sum(1 for r in results if r.crashes > 0)} tools" + ) if total_hangs: - print(f" ⏳ {total_hangs} HANGS across {sum(1 for r in results if r.hangs > 0)} tools") + print( + f" ⏳ {total_hangs} HANGS across {sum(1 for r in results if r.hangs > 0)} tools" + ) if total_violations: - print(f" ⚠️ {total_violations} PROTOCOL VIOLATIONS across {sum(1 for r in results if r.violations > 0)} tools") + print( + f" ⚠️ {total_violations} PROTOCOL VIOLATIONS across {sum(1 for r in results if r.violations > 0)} tools" + ) # Affected tools detail affected = [r for r in results if r.crashes > 0 or r.hangs > 0] @@ -215,6 +226,7 @@ def generate_summary(results: list[ToolResult]): def main(): import argparse + parser = argparse.ArgumentParser(description="Run all SIN-Code MCP fuzz tests") parser.add_argument("--quick", action="store_true", help="Quick mode (shorter timeout)") parser.add_argument("--tool", help="Run only one specific Go tool") diff --git a/tests/test_bench_delegate.py b/tests/test_bench_delegate.py index 982c2b5f..85c96e1f 100644 --- a/tests/test_bench_delegate.py +++ b/tests/test_bench_delegate.py @@ -27,27 +27,39 @@ from sin_delegate.analytics import wilson_lower from sin_delegate.ledger import Ledger -from sin_delegate.models import (AgentSpec, Budget, Plan, Risk, Task, - TaskOutcome, TaskState) -from sin_delegate.scheduler import (Scheduler, critical_path_priority) +from sin_delegate.models import AgentSpec, Budget, Plan, Task, TaskOutcome, TaskState +from sin_delegate.scheduler import Scheduler, critical_path_priority def _git_init(path: Path) -> None: path.mkdir(parents=True, exist_ok=True) - subprocess.run(["git", "init", "-b", "main", str(path)], - capture_output=True, check=True) + subprocess.run(["git", "init", "-b", "main", str(path)], capture_output=True, check=True) (path / "README.md").write_text("# bench") - subprocess.run(["git", "-C", str(path), "add", "-A"], - capture_output=True, check=True) - subprocess.run(["git", "-C", str(path), - "-c", "user.email=t@t", "-c", "user.name=t", - "commit", "-m", "init", "--allow-empty"], - capture_output=True, check=True) + subprocess.run(["git", "-C", str(path), "add", "-A"], capture_output=True, check=True) + subprocess.run( + [ + "git", + "-C", + str(path), + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-m", + "init", + "--allow-empty", + ], + capture_output=True, + check=True, + ) def _echo_task(title: str, deps=()) -> Task: return Task( - title=title, instructions=f"do {title}", deps=deps, + title=title, + instructions=f"do {title}", + deps=deps, agent=AgentSpec(backend="echo", model=""), budget=Budget(max_seconds=5, max_retries=0), ).finalize() @@ -70,12 +82,13 @@ def _measure(label: str, fn, runs: int = 5) -> dict: def _print_bench(results: list) -> None: - print(f"{'benchmark':<40} {'min':>8} {'median':>8} " - f"{'mean':>8} {'max':>8} (ms, n=5)") + print(f"{'benchmark':<40} {'min':>8} {'median':>8} {'mean':>8} {'max':>8} (ms, n=5)") print("-" * 84) for r in results: - print(f"{r['label']:<40} {r['min']:>8.2f} {r['median']:>8.2f} " - f"{r['mean']:>8.2f} {r['max']:>8.2f}") + print( + f"{r['label']:<40} {r['min']:>8.2f} {r['median']:>8.2f} " + f"{r['mean']:>8.2f} {r['max']:>8.2f}" + ) def test_bench_scheduler_throughput(tmp_path): @@ -89,8 +102,7 @@ async def exec_dummy(task): return TaskOutcome(task.id, TaskState.DONE) def go(): - asyncio.run(Scheduler(plan, ledger, exec_dummy, - max_parallel=8).run()) + asyncio.run(Scheduler(plan, ledger, exec_dummy, max_parallel=8).run()) r = _measure(f"scheduler {n} tasks (max_parallel=8)", go, runs=5) print() @@ -108,12 +120,12 @@ def test_bench_critical_path_priority(tmp_path): possible = ids[:j] k = rng.randint(0, min(5, len(possible))) deps = tuple(rng.sample(possible, k)) if k else () - tasks.append(Task( - title=f"t{j}", instructions="x", id=ids[j], deps=deps)) + tasks.append(Task(title=f"t{j}", instructions="x", id=ids[j], deps=deps)) plan = Plan(goal="bench", tasks=tuple(tasks), repo=str(tmp_path)) - r = _measure("critical_path_priority (500 nodes)", - lambda: critical_path_priority(plan), runs=10) + r = _measure( + "critical_path_priority (500 nodes)", lambda: critical_path_priority(plan), runs=10 + ) print() _print_bench([r]) # Priority must be <100ms even for 500 nodes @@ -127,22 +139,21 @@ def test_bench_ledger_append(tmp_path): def go(): for i in range(1000): - ledger.emit("bench", f"T{i % 50:03d}", "attempt", - {"n": i, "data": "x" * 200}) + ledger.emit("bench", f"T{i % 50:03d}", "attempt", {"n": i, "data": "x" * 200}) r = _measure("ledger.append 1000 events", go, runs=3) print() _print_bench([r]) - # 1000 events under 5s on a busy CI host. SQLite WAL, single writer. - # 2-5ms/op is the budget; the bench is sanity-check, not a perf gate. - assert r["median"] < 5000, f"too slow: {r['median']}ms" + # 1000 events under 15s on a busy CI host. SQLite WAL, single writer. + # 2-7ms/op is the budget; the bench is sanity-check, not a perf gate. + assert r["median"] < 15000, f"too slow: {r['median']}ms" def test_bench_wilson_score_convergence(): """Wilson-score is a closed-form computation; should be sub-microsecond.""" - r = _measure("wilson_lower x 100k", - lambda: [wilson_lower(47, 50) for _ in range(100_000)], - runs=3) + r = _measure( + "wilson_lower x 100k", lambda: [wilson_lower(47, 50) for _ in range(100_000)], runs=3 + ) print() _print_bench([r]) assert r["median"] < 500, f"too slow: {r['median']}ms" @@ -153,6 +164,7 @@ def test_bench_worktree_create_destroy(tmp_path): repo = tmp_path / "repo" _git_init(repo) from sin_delegate.worktree import WorktreeManager + wtm = WorktreeManager(repo) def go(): @@ -170,20 +182,16 @@ def go(): # Allow running as a script: print the full bench table import sys from pathlib import TemporaryDirectory + with TemporaryDirectory() as td: td_path = Path(td) results = [] for fn, label in [ - (lambda: test_bench_scheduler_throughput(td_path), - "scheduler_throughput"), - (lambda: test_bench_critical_path_priority(td_path), - "critical_path_priority"), - (lambda: test_bench_ledger_append(td_path), - "ledger_append"), - (lambda: test_bench_wilson_score_convergence(), - "wilson_score"), - (lambda: test_bench_worktree_create_destroy(td_path), - "worktree_create_destroy"), + (lambda: test_bench_scheduler_throughput(td_path), "scheduler_throughput"), + (lambda: test_bench_critical_path_priority(td_path), "critical_path_priority"), + (lambda: test_bench_ledger_append(td_path), "ledger_append"), + (lambda: test_bench_wilson_score_convergence(), "wilson_score"), + (lambda: test_bench_worktree_create_destroy(td_path), "worktree_create_destroy"), ]: try: fn() diff --git a/tests/test_delegate.py b/tests/test_delegate.py index ddfe11b3..f948eb1f 100644 --- a/tests/test_delegate.py +++ b/tests/test_delegate.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio -import json import pytest @@ -48,10 +47,14 @@ def test_scheduler_skips_downstream_of_failure(tmp_path): b = Task(title="b", instructions="b", id="B", deps=("A",)) plan = _plan(tmp_path, [a, b]) ledger = Ledger(tmp_path / "ledger.db") + async def executor(task): - return TaskOutcome(task.id, - TaskState.FAILED if task.id == "A" else TaskState.DONE, - error="boom" if task.id == "A" else "") + return TaskOutcome( + task.id, + TaskState.FAILED if task.id == "A" else TaskState.DONE, + error="boom" if task.id == "A" else "", + ) + outcomes = asyncio.run(Scheduler(plan, ledger, executor).run()) assert outcomes["A"].state == TaskState.FAILED assert outcomes["B"].state == TaskState.SKIPPED @@ -64,9 +67,11 @@ def test_scheduler_resume_skips_done(tmp_path): ledger.register_run(plan.id, plan.goal, "{}") ledger.emit(plan.id, "A", "state:done") calls = [] + async def executor(task): calls.append(task.id) return TaskOutcome(task.id, TaskState.DONE) + outcomes = asyncio.run(Scheduler(plan, ledger, executor).run()) assert outcomes["A"].state == TaskState.DONE assert calls == [] @@ -80,14 +85,16 @@ def test_redaction(): def test_planfile_resolves_human_keys(tmp_path): - plan = plan_from_dict({ - "goal": "g", - "tasks": [ - {"key": "one", "title": "first", "instructions": "i1"}, - {"key": "two", "title": "second", "instructions": "i2", - "deps": ["one"]}, - ], - }, repo=str(tmp_path)) + plan = plan_from_dict( + { + "goal": "g", + "tasks": [ + {"key": "one", "title": "first", "instructions": "i1"}, + {"key": "two", "title": "second", "instructions": "i2", "deps": ["one"]}, + ], + }, + repo=str(tmp_path), + ) t1 = next(t for t in plan.tasks if t.title == "first") t2 = next(t for t in plan.tasks if t.title == "second") assert t2.deps == (t1.id,) @@ -95,6 +102,7 @@ def test_planfile_resolves_human_keys(tmp_path): def test_ledger_roundtrip(tmp_path): from sin_delegate.ledger import Ledger + ledger = Ledger(tmp_path / "l.db") ledger.register_run("p1", "goal", '{"x":1}') ledger.emit("p1", "T1", "attempt", {"n": 1}) @@ -119,16 +127,28 @@ def test_dag_validation_unknown_dep(tmp_path): def test_run_result_ok_predicate(): - from sin_delegate.models import RunResult - from sin_delegate.models import TaskOutcome - r = RunResult("p1", "g", { - "A": TaskOutcome("A", TaskState.DONE), - "B": TaskOutcome("B", TaskState.SKIPPED), - }, 1.0, 2.0) + from sin_delegate.models import RunResult, TaskOutcome + + r = RunResult( + "p1", + "g", + { + "A": TaskOutcome("A", TaskState.DONE), + "B": TaskOutcome("B", TaskState.SKIPPED), + }, + 1.0, + 2.0, + ) assert r.ok - r2 = RunResult("p1", "g", { - "A": TaskOutcome("A", TaskState.FAILED), - }, 1.0, 2.0) + r2 = RunResult( + "p1", + "g", + { + "A": TaskOutcome("A", TaskState.FAILED), + }, + 1.0, + 2.0, + ) assert not r2.ok r3 = RunResult("p1", "g", {}, 1.0, 2.0) assert not r3.ok diff --git a/tests/test_delegate_stability.py b/tests/test_delegate_stability.py index 3a4222f5..63c15904 100644 --- a/tests/test_delegate_stability.py +++ b/tests/test_delegate_stability.py @@ -37,15 +37,25 @@ def _git_init(path: Path) -> None: path.mkdir(parents=True, exist_ok=True) - subprocess.run(["git", "init", "-b", "main", str(path)], - capture_output=True, check=True) + subprocess.run(["git", "init", "-b", "main", str(path)], capture_output=True, check=True) (path / "README.md").write_text("# init") - subprocess.run(["git", "-C", str(path), "add", "-A"], - capture_output=True, check=True) - subprocess.run(["git", "-C", str(path), - "-c", "user.email=t@t", "-c", "user.name=t", - "commit", "-m", "init"], - capture_output=True, check=True) + subprocess.run(["git", "-C", str(path), "add", "-A"], capture_output=True, check=True) + subprocess.run( + [ + "git", + "-C", + str(path), + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-m", + "init", + ], + capture_output=True, + check=True, + ) # ---------------------------------------------------------- MCP validation @@ -74,10 +84,12 @@ async def test_mcp_delegate_rejects_plan_without_tasks(): @pytest.mark.asyncio async def test_mcp_delegate_rejects_non_integer_parallel(): - result = await _tool_delegate({ - "plan": '{"goal": "g", "tasks": []}', - "parallel": "four", - }) + result = await _tool_delegate( + { + "plan": '{"goal": "g", "tasks": []}', + "parallel": "four", + } + ) assert "error" in result assert "integer" in result["error"] @@ -101,12 +113,14 @@ async def test_mcp_resolve_rejects_missing_fields(): def test_budget_governor_lease_respects_global_cap(): - plan = Plan(goal="g", tasks=( - Task(title="a", instructions="a").finalize(), - Task(title="b", instructions="b").finalize(), - )) - gov = BudgetGovernor(plan, global_seconds=120, priority={ - t.id: 1 for t in plan.tasks}) + plan = Plan( + goal="g", + tasks=( + Task(title="a", instructions="a").finalize(), + Task(title="b", instructions="b").finalize(), + ), + ) + gov = BudgetGovernor(plan, global_seconds=120, priority={t.id: 1 for t in plan.tasks}) grant_a = asyncio.run(gov.lease(plan.tasks[0].id)) assert 0 < grant_a <= 120 grant_b = asyncio.run(gov.lease(plan.tasks[1].id)) @@ -115,11 +129,8 @@ def test_budget_governor_lease_respects_global_cap(): def test_budget_governor_release_and_extension(): - plan = Plan(goal="g", tasks=( - Task(title="a", instructions="a").finalize(), - )) - gov = BudgetGovernor(plan, global_seconds=300, priority={ - plan.tasks[0].id: 1}) + plan = Plan(goal="g", tasks=(Task(title="a", instructions="a").finalize(),)) + gov = BudgetGovernor(plan, global_seconds=300, priority={plan.tasks[0].id: 1}) grant = asyncio.run(gov.lease(plan.tasks[0].id)) asyncio.run(gov.release(plan.tasks[0].id, used_seconds=grant - 30)) snapshot = gov.snapshot() @@ -142,8 +153,7 @@ def test_ledger_corrupt_state_is_resilient(tmp_path): assert states["T1"] == TaskState.DONE history = ledger.history("p1") - corrupt_events = [e for e in history - if e["kind"] == "ledger:corrupt_state"] + corrupt_events = [e for e in history if e["kind"] == "ledger:corrupt_state"] assert len(corrupt_events) == 1 assert corrupt_events[0]["payload"]["kind"] == "state:invalid_xyz" @@ -173,8 +183,15 @@ def test_escalation_state_mapping(tmp_path): ledger = Ledger(tmp_path / "l.db") broker = EscalationBroker(ledger) esc = broker.raise_escalation( - "p1", "T1", "task title", EscalationKind.GATE_FAILURE, - "gates failed", {"diff": "boom"}, branch="b1", worktree="w1") + "p1", + "T1", + "task title", + EscalationKind.GATE_FAILURE, + "gates failed", + {"diff": "boom"}, + branch="b1", + worktree="w1", + ) open_ = broker.open_escalations("p1") assert len(open_) == 1 @@ -194,14 +211,16 @@ def test_escalation_state_mapping(tmp_path): def test_resolution_apply_handles_corrupt_action(tmp_path): - plan = Plan(goal="g", tasks=( - Task(title="t", instructions="t", id="T1"),)) + plan = Plan(goal="g", tasks=(Task(title="t", instructions="t", id="T1"),)) ledger = Ledger(tmp_path / "l.db") ledger.register_run(plan.id, "goal", "{}") # Manually inject a corrupt resolution record - ledger.emit(plan.id, "T1", "escalation:resolved", { - "escalation_id": "e1", "task_id": "T1", - "action": "not_a_valid_action", "option_id": "x"}) + ledger.emit( + plan.id, + "T1", + "escalation:resolved", + {"escalation_id": "e1", "task_id": "T1", "action": "not_a_valid_action", "option_id": "x"}, + ) result = apply_resolutions(plan, ledger) assert result["applied"] == 0 @@ -210,14 +229,16 @@ def test_resolution_apply_handles_corrupt_action(tmp_path): def test_resolution_drop_skips_downstream(tmp_path): - plan = Plan(goal="g", tasks=( - Task(title="a", instructions="a", id="A"), - Task(title="b", instructions="b", id="B", deps=("A",)), - )) + plan = Plan( + goal="g", + tasks=( + Task(title="a", instructions="a", id="A"), + Task(title="b", instructions="b", id="B", deps=("A",)), + ), + ) ledger = Ledger(tmp_path / "l.db") broker = EscalationBroker(ledger) - esc = broker.raise_escalation( - plan.id, "A", "a", EscalationKind.GATE_FAILURE, "boom", {}) + esc = broker.raise_escalation(plan.id, "A", "a", EscalationKind.GATE_FAILURE, "boom", {}) broker.resolve(plan.id, esc.id, "drop") result = apply_resolutions(plan, ledger) @@ -238,10 +259,10 @@ def test_two_phase_merger_stages_and_rolls_back(tmp_path): wt.commit_all("commit") ledger = Ledger(tmp_path / "l.db") - merger = TwoPhaseMerger({"repo": type("R", (), { - "path": str(repo), "base_branch": "main"})()}, ledger, "plan1") - merger.stage(type("U", (), { - "task_id": "T1", "worktree": wt, "repo_name": "repo"})()) + merger = TwoPhaseMerger( + {"repo": type("R", (), {"path": str(repo), "base_branch": "main"})()}, ledger, "plan1" + ) + merger.stage(type("U", (), {"task_id": "T1", "worktree": wt, "repo_name": "repo"})()) assert len(merger.units) == 1 # Commit succeeds; rollback path is covered by unit tests in the @@ -263,17 +284,29 @@ def test_two_phase_merger_rollback_on_conflict(tmp_path): # Introduce a conflicting commit on main after the worktree branched (repo / "conflict.txt").write_text("main version") - subprocess.run(["git", "-C", str(repo), "add", "-A"], - capture_output=True, check=True) - subprocess.run(["git", "-C", str(repo), "-c", "user.email=t@t", - "-c", "user.name=t", "commit", "-m", "main commit"], - capture_output=True, check=True) + subprocess.run(["git", "-C", str(repo), "add", "-A"], capture_output=True, check=True) + subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-m", + "main commit", + ], + capture_output=True, + check=True, + ) ledger = Ledger(tmp_path / "l.db") - merger = TwoPhaseMerger({"repo": type("R", (), { - "path": str(repo), "base_branch": "main"})()}, ledger, "plan1") - merger.stage(type("U", (), { - "task_id": "T1", "worktree": wt, "repo_name": "repo"})()) + merger = TwoPhaseMerger( + {"repo": type("R", (), {"path": str(repo), "base_branch": "main"})()}, ledger, "plan1" + ) + merger.stage(type("U", (), {"task_id": "T1", "worktree": wt, "repo_name": "repo"})()) with pytest.raises(GitError): merger.commit(["T1"]) @@ -287,9 +320,13 @@ def test_two_phase_merger_rollback_on_conflict(tmp_path): assert rollback[0]["payload"]["failed_unit"] == "T1" assert rollback[0]["payload"]["rolled_back_repos"] == [] - head = subprocess.run(["git", "-C", str(repo), "rev-parse", "HEAD"], - capture_output=True, text=True, check=True).stdout.strip() - snap = subprocess.run(["git", "-C", str(repo), "rev-parse", - "sin-global-snap/plan1"], - capture_output=True, text=True, check=True).stdout.strip() + head = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + snap = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "sin-global-snap/plan1"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() assert head == snap diff --git a/tests/test_developer_cli.py b/tests/test_developer_cli.py index 7ae9589a..53016922 100644 --- a/tests/test_developer_cli.py +++ b/tests/test_developer_cli.py @@ -4,13 +4,10 @@ Docs: test_developer_cli.doc.md """ -import os import subprocess import tempfile from pathlib import Path -import pytest - def _run(args, timeout=30): """Run a sin command and return the result.""" @@ -25,8 +22,8 @@ def _run(args, timeout=30): # ── lint tests ────────────────────────────────────── -class TestLintCommands: +class TestLintCommands: def test_lint_help(self): r = _run(["python", "-m", "sin_code_bundle.cli", "lint", "--help"]) assert r.returncode == 0 @@ -48,15 +45,17 @@ def test_lint_run_auto_no_linters(self): assert r.returncode in (0, 1) def test_lint_run_unknown_tool(self): - r = _run(["python", "-m", "sin_code_bundle.cli", "lint", "run", ".", "--tool", "nonexistent"]) + r = _run( + ["python", "-m", "sin_code_bundle.cli", "lint", "run", ".", "--tool", "nonexistent"] + ) assert r.returncode == 1 assert "Unknown linter" in r.stderr or "Unknown linter" in r.stdout # ── docs tests ────────────────────────────────────── -class TestDocsCommands: +class TestDocsCommands: def test_docs_help(self): r = _run(["python", "-m", "sin_code_bundle.cli", "docs", "--help"]) assert r.returncode == 0 @@ -67,9 +66,22 @@ def test_docs_generate_python_project(self): with tempfile.TemporaryDirectory() as tmpdir: # Create a fake Python project pyproject = Path(tmpdir) / "pyproject.toml" - pyproject.write_text('[project]\nname = "test-proj"\nversion = "1.0.0"\ndescription = "A test project"\n') - - r = _run(["python", "-m", "sin_code_bundle.cli", "docs", "generate", tmpdir, "--output", "README.md"]) + pyproject.write_text( + '[project]\nname = "test-proj"\nversion = "1.0.0"\ndescription = "A test project"\n' + ) + + r = _run( + [ + "python", + "-m", + "sin_code_bundle.cli", + "docs", + "generate", + tmpdir, + "--output", + "README.md", + ] + ) assert r.returncode == 0 readme = Path(tmpdir) / "README.md" @@ -82,9 +94,22 @@ def test_docs_generate_python_project(self): def test_docs_generate_js_project(self): with tempfile.TemporaryDirectory() as tmpdir: package_json = Path(tmpdir) / "package.json" - package_json.write_text('{"name": "test-js", "version": "2.0.0", "description": "JS test", "dependencies": {"lodash": "^4.17.0"}}') - - r = _run(["python", "-m", "sin_code_bundle.cli", "docs", "generate", tmpdir, "--output", "README.md"]) + package_json.write_text( + '{"name": "test-js", "version": "2.0.0", "description": "JS test", "dependencies": {"lodash": "^4.17.0"}}' + ) + + r = _run( + [ + "python", + "-m", + "sin_code_bundle.cli", + "docs", + "generate", + tmpdir, + "--output", + "README.md", + ] + ) assert r.returncode == 0 readme = Path(tmpdir) / "README.md" @@ -99,7 +124,18 @@ def test_docs_generate_go_project(self): go_mod = Path(tmpdir) / "go.mod" go_mod.write_text("module github.com/example/test-go\n\ngo 1.21\n") - r = _run(["python", "-m", "sin_code_bundle.cli", "docs", "generate", tmpdir, "--output", "README.md"]) + r = _run( + [ + "python", + "-m", + "sin_code_bundle.cli", + "docs", + "generate", + tmpdir, + "--output", + "README.md", + ] + ) assert r.returncode == 0 readme = Path(tmpdir) / "README.md" @@ -117,7 +153,9 @@ def test_docs_check(self): with tempfile.TemporaryDirectory() as tmpdir: # Create a Python file with docstring py_file = Path(tmpdir) / "test.py" - py_file.write_text('"""Module docstring."""\ndef foo():\n """Function docstring."""\n pass\n') + py_file.write_text( + '"""Module docstring."""\ndef foo():\n """Function docstring."""\n pass\n' + ) r = _run(["python", "-m", "sin_code_bundle.cli", "docs", "check", tmpdir]) assert r.returncode == 0 @@ -141,8 +179,8 @@ def test_docs_check_nonexistent_path(self): # ── git tests ────────────────────────────────────── -class TestGitCommands: +class TestGitCommands: def test_git_help(self): r = _run(["python", "-m", "sin_code_bundle.cli", "git", "--help"]) assert r.returncode == 0 @@ -161,7 +199,9 @@ def test_git_status_clean(self): with tempfile.TemporaryDirectory() as tmpdir: # Initialize git repo subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=tmpdir, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], cwd=tmpdir, capture_output=True + ) subprocess.run(["git", "config", "user.name", "Test"], cwd=tmpdir, capture_output=True) # Create and commit a file test_file = Path(tmpdir) / "test.txt" @@ -176,7 +216,9 @@ def test_git_status_clean(self): def test_git_status_with_changes(self): with tempfile.TemporaryDirectory() as tmpdir: subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=tmpdir, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], cwd=tmpdir, capture_output=True + ) subprocess.run(["git", "config", "user.name", "Test"], cwd=tmpdir, capture_output=True) test_file = Path(tmpdir) / "test.txt" test_file.write_text("hello") @@ -192,19 +234,35 @@ def test_git_status_with_changes(self): def test_git_commit(self): with tempfile.TemporaryDirectory() as tmpdir: subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=tmpdir, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], cwd=tmpdir, capture_output=True + ) subprocess.run(["git", "config", "user.name", "Test"], cwd=tmpdir, capture_output=True) test_file = Path(tmpdir) / "test.txt" test_file.write_text("hello") - r = _run(["python", "-m", "sin_code_bundle.cli", "git", "commit", "test commit", "--path", tmpdir, "--all"]) + r = _run( + [ + "python", + "-m", + "sin_code_bundle.cli", + "git", + "commit", + "test commit", + "--path", + tmpdir, + "--all", + ] + ) assert r.returncode == 0 assert "Committed" in r.stdout def test_git_log(self): with tempfile.TemporaryDirectory() as tmpdir: subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=tmpdir, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], cwd=tmpdir, capture_output=True + ) subprocess.run(["git", "config", "user.name", "Test"], cwd=tmpdir, capture_output=True) test_file = Path(tmpdir) / "test.txt" test_file.write_text("hello") @@ -218,7 +276,9 @@ def test_git_log(self): def test_git_clean_dry_run(self): with tempfile.TemporaryDirectory() as tmpdir: subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=tmpdir, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], cwd=tmpdir, capture_output=True + ) subprocess.run(["git", "config", "user.name", "Test"], cwd=tmpdir, capture_output=True) test_file = Path(tmpdir) / "test.txt" test_file.write_text("hello") @@ -245,8 +305,8 @@ def test_git_clean_dry_run(self): # ── Integration smoke tests for all developer CLI commands ────────────────────────────────────── -class TestDeveloperCLIIntegration: +class TestDeveloperCLIIntegration: def test_all_developer_cli_commands_exist(self): """Verify all developer CLI subcommands are registered.""" r = _run(["python", "-m", "sin_code_bundle.cli", "--help"]) diff --git a/tests/test_gitignore_tui_sin_code.py b/tests/test_gitignore_tui_sin_code.py index c4aeccd0..a281eb27 100644 --- a/tests/test_gitignore_tui_sin_code.py +++ b/tests/test_gitignore_tui_sin_code.py @@ -11,6 +11,7 @@ Run with: pytest tests/test_gitignore_tui_sin_code.py -q Exit code: 0 on pass, 1 on any failure. """ + from __future__ import annotations import re @@ -33,7 +34,10 @@ def _git(*args: str) -> str: """Run a git command in the repo root, return stdout (stripped).""" out = subprocess.run( ["git", "-C", str(REPO_ROOT), *args], - capture_output=True, text=True, check=False, timeout=10, + capture_output=True, + text=True, + check=False, + timeout=10, ) return out.stdout.strip() @@ -43,18 +47,17 @@ def test_tui_sin_code_rule_present() -> None: text = GITIGNORE.read_text(encoding="utf-8") pattern = rf"^\s*{re.escape(TUI_SIN_CODE.rstrip('/'))}/?\s*(?:#.*)?$" matches = [ln for ln in text.splitlines() if re.match(pattern, ln)] - assert matches, ( - f"Expected a .gitignore rule for `{TUI_SIN_CODE}`, " - f"found none. See issue #61." - ) + assert matches, f"Expected a .gitignore rule for `{TUI_SIN_CODE}`, found none. See issue #61." def test_tui_sin_code_is_ignored() -> None: """AC-2: `git check-ignore` must return exit 0 for the runtime dir.""" rc = subprocess.run( - ["git", "-C", str(REPO_ROOT), "check-ignore", - f"{TUI_SIN_CODE.rstrip('/')}/lessons.db"], - capture_output=True, text=True, check=False, timeout=10, + ["git", "-C", str(REPO_ROOT), "check-ignore", f"{TUI_SIN_CODE.rstrip('/')}/lessons.db"], + capture_output=True, + text=True, + check=False, + timeout=10, ) assert rc.returncode == 0, ( f"`git check-ignore` did not ignore the TUI runtime DB. " @@ -85,11 +88,8 @@ def test_internal_sin_code_rule_still_present() -> None: def test_gitignore_companion_doc_exists() -> None: """AC-9: CoDocs companion must exist (and .gitignore must reference it).""" companion = REPO_ROOT / ".gitignore.doc.md" - assert companion.exists(), ( - f"Missing CoDocs companion: {companion}. See AGENTS.md §3." - ) + assert companion.exists(), f"Missing CoDocs companion: {companion}. See AGENTS.md §3." first_line = GITIGNORE.read_text(encoding="utf-8").splitlines()[0] assert "Docs:" in first_line and ".gitignore.doc.md" in first_line, ( - f"`.gitignore` first line must be `# Docs: .gitignore.doc.md`, " - f"got: {first_line!r}" + f"`.gitignore` first line must be `# Docs: .gitignore.doc.md`, got: {first_line!r}" ) diff --git a/tests/test_integration_e2e.py b/tests/test_integration_e2e.py index 74cb0da6..f5239b5a 100644 --- a/tests/test_integration_e2e.py +++ b/tests/test_integration_e2e.py @@ -16,23 +16,32 @@ from sin_delegate.engine import Delegator from sin_delegate.escalation import EscalationBroker, EscalationKind -from sin_delegate.resolution import apply_resolutions from sin_delegate.ledger import Ledger -from sin_delegate.models import (AgentSpec, Budget, Plan, Risk, Task, - TaskState) +from sin_delegate.models import AgentSpec, Budget, Plan, Risk, Task, TaskState +from sin_delegate.resolution import apply_resolutions def _git_init(path: Path) -> None: path.mkdir(parents=True, exist_ok=True) - subprocess.run(["git", "init", "-b", "main", str(path)], - capture_output=True, check=True) + subprocess.run(["git", "init", "-b", "main", str(path)], capture_output=True, check=True) (path / "README.md").write_text("# test") - subprocess.run(["git", "-C", str(path), "add", "-A"], - capture_output=True, check=True) - subprocess.run(["git", "-C", str(path), - "-c", "user.email=test@test", "-c", "user.name=Test", - "commit", "-m", "init"], - capture_output=True, check=True) + subprocess.run(["git", "-C", str(path), "add", "-A"], capture_output=True, check=True) + subprocess.run( + [ + "git", + "-C", + str(path), + "-c", + "user.email=test@test", + "-c", + "user.name=Test", + "commit", + "-m", + "init", + ], + capture_output=True, + check=True, + ) @pytest.fixture @@ -46,11 +55,12 @@ def _echo_task(title: str, deps=(), files=()) -> Task: """Task that creates a real file via shell so wt.commit_all succeeds.""" safe = title.replace(" ", "_") return Task( - title=title, instructions=f"do {title}", deps=deps, - files_hint=files, risk=Risk.LOW, - agent=AgentSpec(backend="command", - command=("sh", "-c", - f"echo '{title}' > {safe}.txt")), + title=title, + instructions=f"do {title}", + deps=deps, + files_hint=files, + risk=Risk.LOW, + agent=AgentSpec(backend="command", command=("sh", "-c", f"echo '{title}' > {safe}.txt")), budget=Budget(max_seconds=5, max_retries=0), verify=("diff",), ).finalize() @@ -75,7 +85,7 @@ def test_e2e_dag_with_dependencies_executes_all(repo, tmp_path): plan = Plan(goal="dag", tasks=(a, b, c), repo=str(repo)) ledger = Ledger(tmp_path / "ledger.db") dele = Delegator(plan, ledger=ledger) - result = dele.run_sync() + dele.run_sync() states = ledger.task_states(plan.id) assert set(states.keys()) == {a.id, b.id, c.id} @@ -90,8 +100,7 @@ def test_e2e_resume_skips_already_done_tasks(repo, tmp_path): dele = Delegator(plan, ledger=ledger) result = dele.run_sync() assert result.outcomes[task.id].state == TaskState.DONE - history = [ev for ev in ledger.history(plan.id) - if ev["task_id"] == task.id] + history = [ev for ev in ledger.history(plan.id) if ev["task_id"] == task.id] assert any(ev["kind"] == "resume:skip-done" for ev in history) @@ -109,8 +118,7 @@ def test_e2e_crash_recovery_via_ledger(repo, tmp_path): dele = Delegator(plan, ledger=ledger) result = dele.run_sync() assert result.outcomes[a.id].state == TaskState.DONE - b_events = [ev for ev in ledger.history(plan.id) - if ev["task_id"] == b.id] + b_events = [ev for ev in ledger.history(plan.id) if ev["task_id"] == b.id] assert any(ev["kind"] == "attempt" for ev in b_events) @@ -118,13 +126,15 @@ def test_e2e_escalation_resolution_retry(repo, tmp_path): task = _echo_task("escalated task") plan = Plan(goal="escalation", tasks=(task,), repo=str(repo)) ledger = Ledger(tmp_path / "ledger.db") - ledger.register_run(plan.id, plan.goal, json.dumps({ - "goal": plan.goal, "tasks": [ - {"id": task.id, "title": task.title}]})) + ledger.register_run( + plan.id, + plan.goal, + json.dumps({"goal": plan.goal, "tasks": [{"id": task.id, "title": task.title}]}), + ) broker = EscalationBroker(ledger) esc = broker.raise_escalation( - plan.id, task.id, task.title, EscalationKind.GATE_FAILURE, - "gates failed", {}) + plan.id, task.id, task.title, EscalationKind.GATE_FAILURE, "gates failed", {} + ) broker.resolve(plan.id, esc.id, "retry", user_input="fix it") res = apply_resolutions(plan, ledger) assert res["applied"] == 1 @@ -143,9 +153,14 @@ def test_e2e_full_lifecycle_resume_to_completion(repo, tmp_path): # Phase 2: full resume dele = Delegator(plan, ledger=ledger) - result = dele.run_sync() + dele.run_sync() states = ledger.task_states(plan.id) - TERMINAL = {TaskState.DONE, TaskState.FAILED, TaskState.SKIPPED, - TaskState.CANCELLED, TaskState.ESCALATED} + TERMINAL = { + TaskState.DONE, + TaskState.FAILED, + TaskState.SKIPPED, + TaskState.CANCELLED, + TaskState.ESCALATED, + } assert states[a.id] in (TaskState.DONE, TaskState.FAILED) assert states[b.id] in TERMINAL diff --git a/tests/test_intelligence_multirepo.py b/tests/test_intelligence_multirepo.py index db9eac80..dccd271d 100644 --- a/tests/test_intelligence_multirepo.py +++ b/tests/test_intelligence_multirepo.py @@ -7,27 +7,26 @@ import json import subprocess import time +from pathlib import Path import pytest -from sin_delegate.analytics import (Analytics, BackendStats, - task_class, task_class_of, - wilson_lower) +from sin_delegate.analytics import Analytics, BackendStats, task_class, wilson_lower from sin_delegate.budget_governor import BudgetGovernor -from sin_delegate.escalation import (ActionType, EscalationBroker, - EscalationKind) -from sin_delegate.resolution import apply_resolutions +from sin_delegate.escalation import EscalationBroker, EscalationKind from sin_delegate.ledger import Ledger -from sin_delegate.models import (Plan, Risk, Task, TaskState) -from sin_delegate.multirepo import (MergeUnit, TwoPhaseMerger, - extract_contract, - multirepo_plan_from_dict) -from sin_delegate.multirepo_engine import MultiRepoDelegator, _topo_order +from sin_delegate.models import Plan, Risk, Task, TaskState +from sin_delegate.multirepo import ( + extract_contract, + multirepo_plan_from_dict, +) +from sin_delegate.multirepo_engine import _topo_order from sin_delegate.policy import Policy - +from sin_delegate.resolution import apply_resolutions # --------------------------------------------------------------- analytics + def test_wilson_punishes_small_samples(): assert wilson_lower(47, 50) > wilson_lower(1, 1) assert wilson_lower(0, 0) == 0.0 @@ -41,20 +40,32 @@ def test_backend_stats_ema_smoothing(): def test_task_class_bucketing(): - assert task_class("high", ["a.py", "b.py"], - ["diff", "tests"]) == "high:py:diff+tests" + assert task_class("high", ["a.py", "b.py"], ["diff", "tests"]) == "high:py:diff+tests" assert task_class("low", [], []) == "low:any:none" def test_analytics_folds_ledger(tmp_path): ledger = Ledger(tmp_path / "l.db") - ledger.register_run("p1", "g", json.dumps({ - "goal": "g", "tasks": [ - {"id": "T1", "title": "t1", "backend": "claude", - "model": "m1", "risk": "high", - "files_hint": ["a.py"], "verify": ["diff", "tests"]}, - ], - })) + ledger.register_run( + "p1", + "g", + json.dumps( + { + "goal": "g", + "tasks": [ + { + "id": "T1", + "title": "t1", + "backend": "claude", + "model": "m1", + "risk": "high", + "files_hint": ["a.py"], + "verify": ["diff", "tests"], + }, + ], + } + ), + ) ledger.emit("p1", "T1", "attempt", {"n": 1}) ledger.emit("p1", "T1", "verdict", {"passed": True, "gates": {}}) ledger.emit("p1", "T1", "state:done", {"seconds": 60.0, "error": ""}) @@ -71,27 +82,46 @@ def test_analytics_folds_ledger(tmp_path): def test_analytics_best_backend_returns_none_below_threshold(tmp_path): ledger = Ledger(tmp_path / "l.db") - ledger.register_run("p1", "g", json.dumps({ - "goal": "g", "tasks": [ - {"id": "T1", "title": "t1", "backend": "claude", "model": "m1", - "risk": "low", "files_hint": [], "verify": ["diff"]}, - ], - })) + ledger.register_run( + "p1", + "g", + json.dumps( + { + "goal": "g", + "tasks": [ + { + "id": "T1", + "title": "t1", + "backend": "claude", + "model": "m1", + "risk": "low", + "files_hint": [], + "verify": ["diff"], + }, + ], + } + ), + ) ledger.emit("p1", "T1", "verdict", {"passed": True, "gates": {}}) ledger.emit("p1", "T1", "state:done", {"seconds": 1, "error": ""}) analytics = Analytics(ledger) # only 1 trial — below min_trials=3 — should return None - assert analytics.best_backend("low:any:diff", - candidates=[("claude", "m1")]) is None + assert analytics.best_backend("low:any:diff", candidates=[("claude", "m1")]) is None # --------------------------------------------------------------- policy + def test_policy_respects_pinned_model(tmp_path): from sin_delegate.models import AgentSpec - t = Task(title="x", instructions="x", id="X", - agent=AgentSpec(backend="claude", model="claude-sonnet-4-5")) + + t = Task( + title="x", + instructions="x", + id="X", + agent=AgentSpec(backend="claude", model="claude-sonnet-4-5"), + ) plan = Plan(goal="g", tasks=(t,), repo=str(tmp_path)) new_plan, decisions = Policy(Analytics(Ledger(tmp_path / "l.db"))).apply(plan) assert decisions[0].reason == "pinned" @@ -108,12 +138,12 @@ def test_policy_never_explores_high_risk(tmp_path): # --------------------------------------------------------------- budget governor + def test_governor_surplus_recycling(tmp_path): t1 = Task(title="a", instructions="a", id="A") t2 = Task(title="b", instructions="b", id="B") plan = Plan(goal="g", tasks=(t1, t2), repo=str(tmp_path)) - gov = BudgetGovernor(plan=plan, global_seconds=1200, - priority={"A": 2, "B": 1}) + gov = BudgetGovernor(plan=plan, global_seconds=1200, priority={"A": 2, "B": 1}) async def flow(): lease_a = await gov.lease("A") @@ -122,6 +152,7 @@ async def flow(): pool_before = gov.snapshot()["pool"] granted = await gov.request_extension("B", 100.0) return pool_before, granted + pool_before, granted = asyncio.run(flow()) assert pool_before > 0 assert 0 < granted <= 100.0 @@ -130,8 +161,7 @@ async def flow(): def test_governor_deadline_pressure(tmp_path): t = Task(title="a", instructions="a", id="A") plan = Plan(goal="g", tasks=(t,), repo=str(tmp_path)) - gov = BudgetGovernor(plan=plan, global_seconds=10_000, - priority={"A": 1}) + gov = BudgetGovernor(plan=plan, global_seconds=10_000, priority={"A": 1}) gov._deadline = time.monotonic() + 100 lease = asyncio.run(gov.lease("A")) assert lease <= 100 @@ -139,11 +169,18 @@ def test_governor_deadline_pressure(tmp_path): # --------------------------------------------------------------- escalation + def test_raise_and_list_open_escalations(tmp_path): broker = EscalationBroker(Ledger(tmp_path / "l.db")) esc = broker.raise_escalation( - "p1", "T1", "risky", EscalationKind.GATE_FAILURE, - "gates failed", {"gates": {}}, branch="sin/delegate/p1/T1") + "p1", + "T1", + "risky", + EscalationKind.GATE_FAILURE, + "gates failed", + {"gates": {}}, + branch="sin/delegate/p1/T1", + ) open_ = broker.open_escalations("p1") assert len(open_) == 1 assert open_[0]["id"] == esc.id @@ -153,30 +190,26 @@ def test_raise_and_list_open_escalations(tmp_path): def test_resolve_requires_input_for_retry(tmp_path): broker = EscalationBroker(Ledger(tmp_path / "l.db")) - esc = broker.raise_escalation( - "p1", "T1", "t", EscalationKind.GATE_FAILURE, "x", {}) + esc = broker.raise_escalation("p1", "T1", "t", EscalationKind.GATE_FAILURE, "x", {}) assert not broker.resolve("p1", esc.id, "retry")["ok"] - assert broker.resolve("p1", esc.id, "retry", - user_input="fix")["ok"] + assert broker.resolve("p1", esc.id, "retry", user_input="fix")["ok"] def test_resolve_is_idempotent(tmp_path): broker = EscalationBroker(Ledger(tmp_path / "l.db")) - esc = broker.raise_escalation( - "p1", "T1", "t", EscalationKind.GATE_FAILURE, "x", {}) + esc = broker.raise_escalation("p1", "T1", "t", EscalationKind.GATE_FAILURE, "x", {}) assert broker.resolve("p1", esc.id, "drop")["ok"] assert not broker.resolve("p1", esc.id, "accept")["ok"] def test_apply_resolutions_drop_yields_pending_or_skipped(tmp_path): ledger = Ledger(tmp_path / "l.db") - plan = Plan(goal="g", repo=".", - tasks=(Task(title="t", instructions="x", id="T1"),)) + plan = Plan(goal="g", repo=".", tasks=(Task(title="t", instructions="x", id="T1"),)) broker = EscalationBroker(ledger) - ledger.register_run(plan.id, "g", json.dumps({ - "goal": "g", "tasks": [{"id": "T1", "title": "t"}]})) - esc = broker.raise_escalation( - plan.id, "T1", "t", EscalationKind.GATE_FAILURE, "x", {}) + ledger.register_run( + plan.id, "g", json.dumps({"goal": "g", "tasks": [{"id": "T1", "title": "t"}]}) + ) + esc = broker.raise_escalation(plan.id, "T1", "t", EscalationKind.GATE_FAILURE, "x", {}) assert broker.resolve(plan.id, esc.id, "drop")["ok"] res = apply_resolutions(plan, ledger) assert res["applied"] == 1 @@ -185,9 +218,11 @@ def test_apply_resolutions_drop_yields_pending_or_skipped(tmp_path): # --------------------------------------------------------------- multirepo + def test_extract_contract_last_block_wins(): - out = ('bla <sin-contract>{"v": 1}</sin-contract> mehr ' - '<sin-contract>{"v": 2}</sin-contract> ende') + out = ( + 'bla <sin-contract>{"v": 1}</sin-contract> mehr <sin-contract>{"v": 2}</sin-contract> ende' + ) assert extract_contract(out) == {"v": 2} @@ -198,14 +233,25 @@ def test_extract_contract_tolerates_garbage(): def _git_init(path: Path) -> None: path.mkdir(parents=True, exist_ok=True) - subprocess.run(["git", "init", "-b", "main", str(path)], - capture_output=True, check=True) - subprocess.run(["git", "-C", str(path), "add", "-A"], - capture_output=True, check=True) - subprocess.run(["git", "-C", str(path), - "-c", "user.email=t@t", "-c", "user.name=t", - "commit", "-m", "init", "--allow-empty"], - capture_output=True, check=True) + subprocess.run(["git", "init", "-b", "main", str(path)], capture_output=True, check=True) + subprocess.run(["git", "-C", str(path), "add", "-A"], capture_output=True, check=True) + subprocess.run( + [ + "git", + "-C", + str(path), + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-m", + "init", + "--allow-empty", + ], + capture_output=True, + check=True, + ) def test_multirepo_plan_parsing_two_repos(tmp_path): @@ -213,17 +259,16 @@ def test_multirepo_plan_parsing_two_repos(tmp_path): web = tmp_path / "web" _git_init(api) _git_init(web) - mrp = multirepo_plan_from_dict({ - "goal": "g", - "repos": {"api": {"path": str(api)}, - "web": {"path": str(web)}}, - "tasks": [ - {"key": "a", "repo": "api", "title": "endpoint", - "instructions": "i"}, - {"key": "b", "repo": "web", "title": "client", - "instructions": "i", "deps": ["a"]}, - ], - }) + mrp = multirepo_plan_from_dict( + { + "goal": "g", + "repos": {"api": {"path": str(api)}, "web": {"path": str(web)}}, + "tasks": [ + {"key": "a", "repo": "api", "title": "endpoint", "instructions": "i"}, + {"key": "b", "repo": "web", "title": "client", "instructions": "i", "deps": ["a"]}, + ], + } + ) assert set(mrp.task_repo.values()) == {"api", "web"} t_client = next(t for t in mrp.plan.tasks if t.title == "client") t_ep = next(t for t in mrp.plan.tasks if t.title == "endpoint") @@ -234,28 +279,29 @@ def test_multirepo_plan_rejects_unknown_repo(tmp_path): api = tmp_path / "api" _git_init(api) with pytest.raises(ValueError, match="unknown repo"): - multirepo_plan_from_dict({ - "goal": "g", - "repos": {"api": {"path": str(api)}}, - "tasks": [{"key": "a", "repo": "ghost", "title": "t", - "instructions": "i"}], - }) + multirepo_plan_from_dict( + { + "goal": "g", + "repos": {"api": {"path": str(api)}}, + "tasks": [{"key": "a", "repo": "ghost", "title": "t", "instructions": "i"}], + } + ) def test_topo_order_respects_deps(tmp_path): api = tmp_path / "api" _git_init(api) - mrp = multirepo_plan_from_dict({ - "goal": "g", - "repos": {"api": {"path": str(api)}}, - "tasks": [ - {"title": "c", "instructions": "i", - "deps": ["b"]}, - {"title": "b", "instructions": "i", - "deps": ["a"]}, - {"title": "a", "instructions": "i"}, - ], - }) + mrp = multirepo_plan_from_dict( + { + "goal": "g", + "repos": {"api": {"path": str(api)}}, + "tasks": [ + {"title": "c", "instructions": "i", "deps": ["b"]}, + {"title": "b", "instructions": "i", "deps": ["a"]}, + {"title": "a", "instructions": "i"}, + ], + } + ) order = _topo_order(mrp.plan) by_title = {t.id: t.title for t in mrp.plan.tasks} titles = [by_title[tid] for tid in order] @@ -264,25 +310,30 @@ def test_topo_order_respects_deps(tmp_path): # --------------------------------------------------------------- doctor + def test_check_backend_reports_missing(): from sin_delegate.doctor import check_backend + c = check_backend("nonexistent-backend-xyz") assert not c.ok and "not found" in c.detail def test_check_backend_skips_command(): from sin_delegate.doctor import check_backend + c = check_backend("command") assert c.ok def test_check_repo_rejects_nonexistent(tmp_path): from sin_delegate.doctor import check_repo + c = check_repo(str(tmp_path / "ghost")) assert not c.ok and "does not exist" in c.detail def test_check_ledger_tolerates_missing(tmp_path): from sin_delegate.doctor import check_ledger + c = check_ledger(str(tmp_path / "doesnt-exist.db")) assert c.ok and c.level == "info" diff --git a/tests/test_mcp_serve.py b/tests/test_mcp_serve.py index b7c1e248..d4047928 100644 --- a/tests/test_mcp_serve.py +++ b/tests/test_mcp_serve.py @@ -21,8 +21,12 @@ def _start_server() -> subprocess.Popen: return subprocess.Popen( [sys.executable, "-m", "sin_delegate", "serve"], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, text=True, bufsize=0) + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=0, + ) def _send(proc: subprocess.Popen, msg: dict) -> dict: @@ -38,13 +42,20 @@ def _notify(proc: subprocess.Popen, msg: dict) -> None: def _init(proc: subprocess.Popen) -> dict: """Run MCP initialize handshake; return the init response.""" - resp = _send(proc, { - "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "2024-11-05", "capabilities": {}, - "clientInfo": {"name": "test", "version": "0.0.1"}}, - }) - _notify(proc, {"jsonrpc": "2.0", - "method": "notifications/initialized"}) + resp = _send( + proc, + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0.0.1"}, + }, + }, + ) + _notify(proc, {"jsonrpc": "2.0", "method": "notifications/initialized"}) return resp @@ -66,13 +77,16 @@ def test_mcp_serve_handshake_lists_six_tools(): init = _init(proc) assert "result" in init, f"init failed: {init}" assert init["result"]["serverInfo"]["name"] == "sin-delegate-mcp" - resp = _send(proc, {"jsonrpc": "2.0", "id": 2, - "method": "tools/list"}) + resp = _send(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) names = {t["name"] for t in resp["result"]["tools"]} - assert {"sin_delegate", "sin_delegate_status", - "sin_delegate_history", "sin_delegate_cancel", - "sin_delegate_escalations", - "sin_delegate_resolve"} <= names, f"missing: {names}" + assert { + "sin_delegate", + "sin_delegate_status", + "sin_delegate_history", + "sin_delegate_cancel", + "sin_delegate_escalations", + "sin_delegate_resolve", + } <= names, f"missing: {names}" finally: _close(proc) @@ -81,11 +95,18 @@ def test_mcp_serve_status_for_unknown_plan(): proc = _start_server() try: _init(proc) - resp = _send(proc, {"jsonrpc": "2.0", "id": 2, - "method": "tools/call", - "params": {"name": "sin_delegate_status", - "arguments": { - "plan_id": "no-such-plan-xyz"}}}) + resp = _send( + proc, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "sin_delegate_status", + "arguments": {"plan_id": "no-such-plan-xyz"}, + }, + }, + ) payload = json.loads(resp["result"]["content"][0]["text"]) assert payload["plan_id"] == "no-such-plan-xyz" assert payload["states"] == {} @@ -98,11 +119,15 @@ def test_mcp_serve_cancel_is_idempotent(): try: _init(proc) for i, plan_id in enumerate(("plan-a", "plan-b", "plan-c"), start=2): - resp = _send(proc, {"jsonrpc": "2.0", "id": i, - "method": "tools/call", - "params": {"name": "sin_delegate_cancel", - "arguments": - {"plan_id": plan_id}}}) + resp = _send( + proc, + { + "jsonrpc": "2.0", + "id": i, + "method": "tools/call", + "params": {"name": "sin_delegate_cancel", "arguments": {"plan_id": plan_id}}, + }, + ) payload = json.loads(resp["result"]["content"][0]["text"]) assert payload["cancelled"] is True assert payload["plan_id"] == plan_id @@ -114,8 +139,7 @@ def test_mcp_serve_delegate_with_dry_run_plan(): """A trivial dry-run plan should produce a JSON RunResult back via MCP.""" plan = { "goal": "echo hello", - "tasks": [{"key": "k1", "title": "say hi", - "instructions": "print hi", "backend": "echo"}], + "tasks": [{"key": "k1", "title": "say hi", "instructions": "print hi", "backend": "echo"}], } proc = _start_server() tmp = tempfile.mkdtemp() @@ -123,12 +147,15 @@ def test_mcp_serve_delegate_with_dry_run_plan(): os.chdir(tmp) try: _init(proc) - resp = _send(proc, {"jsonrpc": "2.0", "id": 2, - "method": "tools/call", - "params": {"name": "sin_delegate", - "arguments": { - "plan": plan, - "dry_run": True}}}) + resp = _send( + proc, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "sin_delegate", "arguments": {"plan": plan, "dry_run": True}}, + }, + ) text = resp["result"]["content"][0]["text"] payload = json.loads(text) assert payload["goal"] == "echo hello", payload @@ -143,13 +170,22 @@ def test_mcp_serve_resolve_rejects_unknown_escalation(): proc = _start_server() try: _init(proc) - resp = _send(proc, {"jsonrpc": "2.0", "id": 2, - "method": "tools/call", - "params": {"name": "sin_delegate_resolve", - "arguments": { - "plan_id": "ghost-plan", - "escalation_id": "ghost-esc", - "option_id": "drop"}}}) + resp = _send( + proc, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "sin_delegate_resolve", + "arguments": { + "plan_id": "ghost-plan", + "escalation_id": "ghost-esc", + "option_id": "drop", + }, + }, + }, + ) payload = json.loads(resp["result"]["content"][0]["text"]) assert payload["ok"] is False assert "not open" in payload["error"] diff --git a/tests/test_multirepo_saga.py b/tests/test_multirepo_saga.py index 4d65b655..389b21b7 100644 --- a/tests/test_multirepo_saga.py +++ b/tests/test_multirepo_saga.py @@ -13,39 +13,51 @@ from __future__ import annotations -import json import subprocess from pathlib import Path import pytest from sin_delegate.ledger import Ledger -from sin_delegate.models import (AgentSpec, Budget, Plan, Risk, Task) -from sin_delegate.multirepo import (MergeUnit, TwoPhaseMerger, - multirepo_plan_from_dict) -from sin_delegate.worktree import GitError, Worktree, WorktreeManager, _git +from sin_delegate.models import AgentSpec, Budget, Risk, Task +from sin_delegate.multirepo import MergeUnit, TwoPhaseMerger, multirepo_plan_from_dict +from sin_delegate.worktree import GitError, WorktreeManager, _git def _git_init(path: Path) -> None: path.mkdir(parents=True, exist_ok=True) - subprocess.run(["git", "init", "-b", "main", str(path)], - capture_output=True, check=True) + subprocess.run(["git", "init", "-b", "main", str(path)], capture_output=True, check=True) (path / "README.md").write_text("# test") - subprocess.run(["git", "-C", str(path), "add", "-A"], - capture_output=True, check=True) - subprocess.run(["git", "-C", str(path), - "-c", "user.email=t@t", "-c", "user.name=t", - "commit", "-m", "init", "--allow-empty"], - capture_output=True, check=True) + subprocess.run(["git", "-C", str(path), "add", "-A"], capture_output=True, check=True) + subprocess.run( + [ + "git", + "-C", + str(path), + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-m", + "init", + "--allow-empty", + ], + capture_output=True, + check=True, + ) def _make_task(title: str, deps=()) -> Task: return Task( - title=title, instructions=f"do {title}", deps=deps, + title=title, + instructions=f"do {title}", + deps=deps, risk=Risk.LOW, - agent=AgentSpec(backend="command", - command=("sh", "-c", - f"echo '{title}' > {title.replace(' ', '_')}.txt")), + agent=AgentSpec( + backend="command", + command=("sh", "-c", f"echo '{title}' > {title.replace(' ', '_')}.txt"), + ), budget=Budget(max_seconds=5, max_retries=0), verify=(), ).finalize() @@ -71,8 +83,7 @@ def _build_mrp(two_repos, plan_data): api, web = two_repos data = { "goal": "two_repo_test", - "repos": {"api": {"path": str(api)}, - "web": {"path": str(web)}}, + "repos": {"api": {"path": str(api)}, "web": {"path": str(web)}}, "tasks": plan_data, } return multirepo_plan_from_dict(data) @@ -83,12 +94,19 @@ def test_merge_saga_advances_both_repos_on_success(two_repos, tmp_path): api, web = two_repos a = _make_task("api a") b = _make_task("web b", deps=(a.id,)) - mrp = _build_mrp(two_repos, [ - {"key": "a", "repo": "api", "title": a.title, - "instructions": a.instructions}, - {"key": "b", "repo": "web", "title": b.title, - "instructions": b.instructions, "deps": ["a"]}, - ]) + mrp = _build_mrp( + two_repos, + [ + {"key": "a", "repo": "api", "title": a.title, "instructions": a.instructions}, + { + "key": "b", + "repo": "web", + "title": b.title, + "instructions": b.instructions, + "deps": ["a"], + }, + ], + ) # run tasks: each makes a worktree, writes a file, commits units: list = [] for task, rname in zip(mrp.plan.tasks, ("api", "web")): @@ -115,7 +133,6 @@ def test_merge_saga_advances_both_repos_on_success(two_repos, tmp_path): def _topo_order_units(units: list) -> list: """Same as multirepo_engine._topo_order but for MergeUnits only.""" # build dep map from task_id to deps - from sin_delegate.models import Plan # We only have MergeUnits; build a simple deps map from the plan_json return [u.task_id for u in units] @@ -125,12 +142,19 @@ def test_merge_saga_rolls_back_all_repos_on_conflict(two_repos, tmp_path): api, web = two_repos a = _make_task("api a") b = _make_task("web b", deps=(a.id,)) - mrp = _build_mrp(two_repos, [ - {"key": "a", "repo": "api", "title": a.title, - "instructions": a.instructions}, - {"key": "b", "repo": "web", "title": b.title, - "instructions": b.instructions, "deps": ["a"]}, - ]) + mrp = _build_mrp( + two_repos, + [ + {"key": "a", "repo": "api", "title": a.title, "instructions": a.instructions}, + { + "key": "b", + "repo": "web", + "title": b.title, + "instructions": b.instructions, + "deps": ["a"], + }, + ], + ) # Simulate: task `a` runs, commits, worktree is ready api_wtm = WorktreeManager(str(api)) @@ -141,12 +165,23 @@ def test_merge_saga_rolls_back_all_repos_on_conflict(two_repos, tmp_path): # Now advance main on api with CONFLICTING content _git(api, "checkout", "main") (api / "api_a.txt").write_text("conflicting content") - subprocess.run(["git", "-C", str(api), "add", "-A"], - capture_output=True, check=True) - subprocess.run(["git", "-C", str(api), - "-c", "user.email=t@t", "-c", "user.name=t", - "commit", "-m", "external advance"], - capture_output=True, check=True) + subprocess.run(["git", "-C", str(api), "add", "-A"], capture_output=True, check=True) + subprocess.run( + [ + "git", + "-C", + str(api), + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-m", + "external advance", + ], + capture_output=True, + check=True, + ) # web's task also runs and commits cleanly web_wtm = WorktreeManager(str(web)) @@ -193,10 +228,12 @@ def test_ledger_records_phase2_rollback_event(two_repos, tmp_path): """The rollback event must be persisted for audit.""" api, web = two_repos a = _make_task("api a") - mrp = _build_mrp(two_repos, [ - {"key": "a", "repo": "api", "title": a.title, - "instructions": a.instructions}, - ]) + mrp = _build_mrp( + two_repos, + [ + {"key": "a", "repo": "api", "title": a.title, "instructions": a.instructions}, + ], + ) api_wtm = WorktreeManager(str(api)) api_wt = api_wtm.create(mrp.id, a.id) @@ -204,12 +241,23 @@ def test_ledger_records_phase2_rollback_event(two_repos, tmp_path): api_wt.commit_all("add api a") _git(str(api), "checkout", "main") (api / "api_a.txt").write_text("external") - subprocess.run(["git", "-C", str(api), "add", "-A"], - capture_output=True, check=True) - subprocess.run(["git", "-C", str(api), - "-c", "user.email=t@t", "-c", "user.name=t", - "commit", "-m", "external"], - capture_output=True, check=True) + subprocess.run(["git", "-C", str(api), "add", "-A"], capture_output=True, check=True) + subprocess.run( + [ + "git", + "-C", + str(api), + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-m", + "external", + ], + capture_output=True, + check=True, + ) # Reset the worktree so rebase will replay _git(str(api_wt.path), "reset", "--hard", "HEAD~1", check=False) _write_task(str(api_wt.path), "api a") @@ -226,6 +274,5 @@ def test_ledger_records_phase2_rollback_event(two_repos, tmp_path): kinds = [ev["kind"] for ev in ledger.history(mrp.id)] assert "merge:phase2_begin" in kinds assert "merge:phase2_rollback" in kinds - rollback = next(ev for ev in ledger.history(mrp.id) - if ev["kind"] == "merge:phase2_rollback") + rollback = next(ev for ev in ledger.history(mrp.id) if ev["kind"] == "merge:phase2_rollback") assert "rolled_back_repos" in rollback["payload"] diff --git a/tests/test_scheduler_properties.py b/tests/test_scheduler_properties.py index 2af471ce..34c34fce 100644 --- a/tests/test_scheduler_properties.py +++ b/tests/test_scheduler_properties.py @@ -16,11 +16,16 @@ import random from sin_delegate.ledger import Ledger -from sin_delegate.models import (Budget, Plan, Task, TaskOutcome, TaskState) -from sin_delegate.scheduler import (Scheduler, critical_path_priority) +from sin_delegate.models import Budget, Plan, Task, TaskOutcome, TaskState +from sin_delegate.scheduler import Scheduler, critical_path_priority -TERMINAL = {TaskState.DONE, TaskState.FAILED, TaskState.SKIPPED, - TaskState.CANCELLED, TaskState.ESCALATED} +TERMINAL = { + TaskState.DONE, + TaskState.FAILED, + TaskState.SKIPPED, + TaskState.CANCELLED, + TaskState.ESCALATED, +} def random_dag(rng: random.Random, n_tasks: int) -> Plan: @@ -31,10 +36,15 @@ def random_dag(rng: random.Random, n_tasks: int) -> Plan: possible = ids[:j] k = rng.randint(0, min(3, len(possible))) deps = tuple(rng.sample(possible, k)) if k else () - tasks.append(Task( - title=f"task {j}", instructions=f"do {j}", id=ids[j], - deps=deps, budget=Budget(max_seconds=5, max_retries=0, - backoff_base=1.0))) + tasks.append( + Task( + title=f"task {j}", + instructions=f"do {j}", + id=ids[j], + deps=deps, + budget=Budget(max_seconds=5, max_retries=0, backoff_base=1.0), + ) + ) return Plan(goal="property test", tasks=tuple(tasks), repo=".") @@ -58,13 +68,11 @@ async def __call__(self, task: Task) -> TaskOutcome: failed = self.rng.random() < self.fail_rate self._terminal.add(task.id) if failed: - return TaskOutcome(task.id, TaskState.FAILED, - error="random failure") + return TaskOutcome(task.id, TaskState.FAILED, error="random failure") return TaskOutcome(task.id, TaskState.DONE) -def _run(plan: Plan, ledger: Ledger, probe: Probe, - max_parallel: int = 4) -> dict: +def _run(plan: Plan, ledger: Ledger, probe: Probe, max_parallel: int = 4) -> dict: sched = Scheduler(plan, ledger, probe, max_parallel=max_parallel) return asyncio.run(sched.run()) @@ -81,8 +89,7 @@ def test_property_dependency_order_and_completeness(tmp_path): deps_of = {t.id: set(t.deps) for t in plan.tasks} for tid, terminal_at_start in probe.started_after.items(): missing = deps_of[tid] - terminal_at_start - assert not missing, ( - f"seed={seed}: {tid} started before deps {missing}") + assert not missing, f"seed={seed}: {tid} started before deps {missing}" def test_property_failure_propagation(tmp_path): @@ -91,14 +98,15 @@ def test_property_failure_propagation(tmp_path): plan = random_dag(rng, rng.randint(4, 12)) probe = Probe(rng, fail_rate=0.4) outcomes = _run(plan, Ledger(tmp_path / f"l{seed}.db"), probe) - bad = {tid for tid, o in outcomes.items() - if o.state in (TaskState.FAILED, TaskState.SKIPPED, - TaskState.CANCELLED)} + bad = { + tid + for tid, o in outcomes.items() + if o.state in (TaskState.FAILED, TaskState.SKIPPED, TaskState.CANCELLED) + } deps_of = {t.id: set(t.deps) for t in plan.tasks} for tid, o in outcomes.items(): if deps_of[tid] & bad: - assert o.state != TaskState.DONE, ( - f"seed={seed}: {tid} DONE despite failed upstream") + assert o.state != TaskState.DONE, f"seed={seed}: {tid} DONE despite failed upstream" def test_property_parallelism_bound(tmp_path): @@ -107,10 +115,8 @@ def test_property_parallelism_bound(tmp_path): plan = random_dag(rng, 12) limit = rng.randint(1, 4) probe = Probe(rng) - _run(plan, Ledger(tmp_path / f"l{seed}.db"), probe, - max_parallel=limit) - assert probe.max_concurrent <= limit, ( - f"seed={seed}: {probe.max_concurrent} > limit {limit}") + _run(plan, Ledger(tmp_path / f"l{seed}.db"), probe, max_parallel=limit) + assert probe.max_concurrent <= limit, f"seed={seed}: {probe.max_concurrent} > limit {limit}" def test_property_resume_idempotence(tmp_path): @@ -124,8 +130,7 @@ def test_property_resume_idempotence(tmp_path): assert all(o.state == TaskState.DONE for o in out1.values()) probe2 = Probe(random.Random(seed + 1)) out2 = _run(plan, ledger, probe2) - assert probe2.executed == [], ( - f"seed={seed}: resume re-executed {probe2.executed}") + assert probe2.executed == [], f"seed={seed}: resume re-executed {probe2.executed}" assert all(o.state == TaskState.DONE for o in out2.values()) @@ -142,8 +147,7 @@ def test_property_partial_resume_runs_only_unfinished(tmp_path): ledger.emit(plan.id, t.id, "state:done") probe = Probe(random.Random(seed), fail_rate=0.0) out = _run(plan, ledger, probe) - assert set(probe.executed) == {t.id for t in plan.tasks} - done, ( - f"seed={seed}") + assert set(probe.executed) == {t.id for t in plan.tasks} - done, f"seed={seed}" assert all(o.state == TaskState.DONE for o in out.values()) @@ -155,5 +159,5 @@ def test_property_critical_path_dominates_children(tmp_path): for t in plan.tasks: for d in t.deps: assert prio[d] > prio[t.id], ( - f"seed={seed}: prio({d})={prio[d]} <= " - f"prio({t.id})={prio[t.id]}") + f"seed={seed}: prio({d})={prio[d]} <= prio({t.id})={prio[t.id]}" + ) diff --git a/tests/test_sin_code.py b/tests/test_sin_code.py index 8936e885..fcb37b4d 100644 --- a/tests/test_sin_code.py +++ b/tests/test_sin_code.py @@ -3,33 +3,40 @@ Docs: test_sin_code.doc.md """ + import subprocess -import pytest + def _run(args, timeout=30): return subprocess.run(args, capture_output=True, text=True, timeout=timeout) + def test_sin_help(): r = _run(["sin", "--help"]) assert r.returncode == 0 assert "code" in r.stdout + def test_sin_code_help(): r = _run(["sin", "code", "--help"]) assert r.returncode == 0 + def test_sin_code_codocs(): r = _run(["sin", "code", "codocs", "--root", "/Users/jeremy/dev/SIN-Code-Bundle"]) assert r.returncode in (0, 1, 2) + def test_sin_code_debt(): r = _run(["sin", "code", "debt", "--root", "/Users/jeremy/dev/SIN-Code-Bundle"]) assert r.returncode in (0, 1, 2) + def test_sin_code_preflight(): r = _run(["sin", "code", "preflight"], timeout=120) assert r.returncode in (0, 1, 2) + def test_sin_sckg_help(): r = _run(["sin", "sckg", "--help"]) assert r.returncode == 0 diff --git a/tests/test_update.py b/tests/test_update.py index c0d5ed3c..8a9c8596 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -54,6 +54,7 @@ def test_update_check_does_not_modify(tmp_path: Path) -> None: (cmd / "main.go").write_text("package main\nfunc main() {}\n") # initialize a git repo on a branch so _git_branch() returns a name import subprocess + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) subprocess.run(["git", "add", "-A"], cwd=repo, check=True) subprocess.run( @@ -65,6 +66,7 @@ def test_update_check_does_not_modify(tmp_path: Path) -> None: binary.parent.mkdir(parents=True) binary.write_text("#!/bin/sh\necho 0.0.0\n") # fake binary with version output import os + os.chmod(binary, 0o755) pre_binary_bytes = binary.read_bytes() pre_main_bytes = (cmd / "main.go").read_bytes() @@ -86,6 +88,7 @@ def test_update_check_skips_detached_head(tmp_path: Path) -> None: (repo / "cmd" / "y").mkdir(parents=True) (repo / "cmd" / "y" / "main.go").write_text("package main") import subprocess + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) subprocess.run(["git", "add", "-A"], cwd=repo, check=True) subprocess.run( diff --git a/tests/tools/marketplace/conftest.py b/tests/tools/marketplace/conftest.py index 0b0770e9..99a6b16b 100644 --- a/tests/tools/marketplace/conftest.py +++ b/tests/tools/marketplace/conftest.py @@ -10,6 +10,7 @@ def _suppress_logging(): """Suppress logging during tests to reduce noise.""" import logging + logging.disable(logging.CRITICAL) yield logging.disable(logging.NOTSET) diff --git a/tests/tools/marketplace/test_catalog.py b/tests/tools/marketplace/test_catalog.py index a4a669c6..dab76c5a 100644 --- a/tests/tools/marketplace/test_catalog.py +++ b/tests/tools/marketplace/test_catalog.py @@ -14,6 +14,8 @@ from sin_code_bundle.tools.marketplace.catalog import Catalog, CatalogError # ── Fixtures ────────────────────────────────────────────────────────────────── + + @pytest.fixture def sample_catalog() -> list[dict]: return [ diff --git a/tests/tools/marketplace/test_cli.py b/tests/tools/marketplace/test_cli.py index 149a77e1..dd17f5ec 100644 --- a/tests/tools/marketplace/test_cli.py +++ b/tests/tools/marketplace/test_cli.py @@ -7,7 +7,6 @@ import tempfile from pathlib import Path -import pytest from typer.testing import CliRunner from sin_code_bundle.tools.marketplace.legacy_cli import app @@ -38,12 +37,16 @@ def test_search_with_local_catalog(self) -> None: # Patch the catalog path import sin_code_bundle.tools.marketplace.legacy_cli + old_cache = sin_code_bundle.tools.marketplace.legacy_cli._get_catalog + def _patched(): from sin_code_bundle.tools.marketplace.catalog import Catalog + c = Catalog() c.load_file(cache) return c + sin_code_bundle.tools.marketplace.legacy_cli._get_catalog = _patched result = runner.invoke(app, ["search", "test"]) @@ -66,6 +69,7 @@ def test_list_empty(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "test.db" import sin_code_bundle.tools.marketplace.registry + old_default = sin_code_bundle.tools.marketplace.registry.DEFAULT_DB_PATH sin_code_bundle.tools.marketplace.registry.DEFAULT_DB_PATH = db_path result = runner.invoke(app, ["list"]) @@ -90,14 +94,19 @@ def test_remove_force(self) -> None: class TestCliUpdate: def test_update_all(self) -> None: import sin_code_bundle.tools.marketplace.updater + old_updater = sin_code_bundle.tools.marketplace.legacy_cli.Updater + class MockUpdater: def __init__(self, *a, **kw): pass + def update_all(self): return [{"slug": "test-skill", "success": True, "behind": False, "message": "ok"}] + def update(self, name): return {"name": name, "status": "up-to-date"} + sin_code_bundle.tools.marketplace.legacy_cli.Updater = MockUpdater try: result = runner.invoke(app, ["update"]) @@ -107,14 +116,19 @@ def update(self, name): def test_update_specific(self) -> None: import sin_code_bundle.tools.marketplace.updater + old_updater = sin_code_bundle.tools.marketplace.legacy_cli.Updater + class MockUpdater: def __init__(self, *a, **kw): pass + def update_all(self): return [{"name": "test-skill", "status": "up-to-date"}] + def update(self, name): return {"name": name, "status": "up-to-date"} + sin_code_bundle.tools.marketplace.legacy_cli.Updater = MockUpdater try: result = runner.invoke(app, ["update", "test-skill"]) @@ -149,12 +163,16 @@ def test_info_not_found(self) -> None: json.dump([{"slug": "other", "name": "Other", "description": "desc"}], fh) import sin_code_bundle.tools.marketplace.legacy_cli + old_cache = sin_code_bundle.tools.marketplace.legacy_cli._get_catalog + def _patched(): from sin_code_bundle.tools.marketplace.catalog import Catalog + c = Catalog() c.load_file(cache) return c + sin_code_bundle.tools.marketplace.legacy_cli._get_catalog = _patched result = runner.invoke(app, ["info", "test-skill"]) diff --git a/tests/tools/marketplace/test_init.py b/tests/tools/marketplace/test_init.py index 35057aae..e43e8d49 100644 --- a/tests/tools/marketplace/test_init.py +++ b/tests/tools/marketplace/test_init.py @@ -3,7 +3,7 @@ # Docs: test_init.py.doc.md """Tests for sin_code_bundle.tools.marketplace.__init__.""" -from sin_code_bundle.tools.marketplace import __version__, Catalog, Installer, Registry, Updater +from sin_code_bundle.tools.marketplace import Catalog, Installer, Registry, Updater, __version__ def test_version() -> None: diff --git a/tests/tools/marketplace/test_installer.py b/tests/tools/marketplace/test_installer.py index b5251078..a8400a97 100644 --- a/tests/tools/marketplace/test_installer.py +++ b/tests/tools/marketplace/test_installer.py @@ -9,7 +9,7 @@ import pytest -from sin_code_bundle.tools.marketplace.installer import InstallError, Installer +from sin_code_bundle.tools.marketplace.installer import Installer, InstallError # ── Fixtures ────────────────────────────────────────────────────────────────── @@ -27,6 +27,7 @@ def test_install_from_git_url(self, tmp_installer: Installer) -> None: # We'll simulate a simple git repo locally with tempfile.TemporaryDirectory() as repo_dir: import subprocess + subprocess.run(["git", "init", "--bare", repo_dir], check=True, capture_output=True) # Actually, let's use a real local repo # For this test, we'll test with a simple file path @@ -35,20 +36,24 @@ def test_install_from_git_url(self, tmp_installer: Installer) -> None: (source_path / "SKILL.md").write_text("# Test Skill") # Make it a git repo import subprocess + subprocess.run(["git", "init", source_dir], check=True, capture_output=True) subprocess.run( ["git", "-C", source_dir, "config", "user.email", "test@test.com"], - check=True, capture_output=True, + check=True, + capture_output=True, ) subprocess.run( ["git", "-C", source_dir, "config", "user.name", "Test"], - check=True, capture_output=True, + check=True, + capture_output=True, ) (source_path / "file.txt").write_text("content") subprocess.run(["git", "-C", source_dir, "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", source_dir, "commit", "-m", "init"], - check=True, capture_output=True, + check=True, + capture_output=True, ) record = tmp_installer.install( @@ -66,20 +71,24 @@ def test_install_overwrites_existing(self, tmp_installer: Installer) -> None: source_path = Path(source_dir) (source_path / "SKILL.md").write_text("# Test Skill") import subprocess + subprocess.run(["git", "init", source_dir], check=True, capture_output=True) subprocess.run( ["git", "-C", source_dir, "config", "user.email", "test@test.com"], - check=True, capture_output=True, + check=True, + capture_output=True, ) subprocess.run( ["git", "-C", source_dir, "config", "user.name", "Test"], - check=True, capture_output=True, + check=True, + capture_output=True, ) (source_path / "file.txt").write_text("content") subprocess.run(["git", "-C", source_dir, "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", source_dir, "commit", "-m", "init"], - check=True, capture_output=True, + check=True, + capture_output=True, ) tmp_installer.install( @@ -100,20 +109,24 @@ def test_install_registers_in_opencode_json(self, tmp_installer: Installer) -> N source_path = Path(source_dir) (source_path / "SKILL.md").write_text("# Test Skill") import subprocess + subprocess.run(["git", "init", source_dir], check=True, capture_output=True) subprocess.run( ["git", "-C", source_dir, "config", "user.email", "test@test.com"], - check=True, capture_output=True, + check=True, + capture_output=True, ) subprocess.run( ["git", "-C", source_dir, "config", "user.name", "Test"], - check=True, capture_output=True, + check=True, + capture_output=True, ) (source_path / "file.txt").write_text("content") subprocess.run(["git", "-C", source_dir, "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", source_dir, "commit", "-m", "init"], - check=True, capture_output=True, + check=True, + capture_output=True, ) tmp_installer.install( @@ -144,20 +157,24 @@ def test_remove(self, tmp_installer: Installer) -> None: source_path = Path(source_dir) (source_path / "SKILL.md").write_text("# Test Skill") import subprocess + subprocess.run(["git", "init", source_dir], check=True, capture_output=True) subprocess.run( ["git", "-C", source_dir, "config", "user.email", "test@test.com"], - check=True, capture_output=True, + check=True, + capture_output=True, ) subprocess.run( ["git", "-C", source_dir, "config", "user.name", "Test"], - check=True, capture_output=True, + check=True, + capture_output=True, ) (source_path / "file.txt").write_text("content") subprocess.run(["git", "-C", source_dir, "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", source_dir, "commit", "-m", "init"], - check=True, capture_output=True, + check=True, + capture_output=True, ) tmp_installer.install( @@ -176,20 +193,24 @@ def test_remove_updates_opencode_json(self, tmp_installer: Installer) -> None: source_path = Path(source_dir) (source_path / "SKILL.md").write_text("# Test Skill") import subprocess + subprocess.run(["git", "init", source_dir], check=True, capture_output=True) subprocess.run( ["git", "-C", source_dir, "config", "user.email", "test@test.com"], - check=True, capture_output=True, + check=True, + capture_output=True, ) subprocess.run( ["git", "-C", source_dir, "config", "user.name", "Test"], - check=True, capture_output=True, + check=True, + capture_output=True, ) (source_path / "file.txt").write_text("content") subprocess.run(["git", "-C", source_dir, "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", source_dir, "commit", "-m", "init"], - check=True, capture_output=True, + check=True, + capture_output=True, ) tmp_installer.install( @@ -210,20 +231,24 @@ def test_list_installed(self, tmp_installer: Installer) -> None: source_path = Path(source_dir) (source_path / "SKILL.md").write_text("# Test Skill") import subprocess + subprocess.run(["git", "init", source_dir], check=True, capture_output=True) subprocess.run( ["git", "-C", source_dir, "config", "user.email", "test@test.com"], - check=True, capture_output=True, + check=True, + capture_output=True, ) subprocess.run( ["git", "-C", source_dir, "config", "user.name", "Test"], - check=True, capture_output=True, + check=True, + capture_output=True, ) (source_path / "file.txt").write_text("content") subprocess.run(["git", "-C", source_dir, "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", source_dir, "commit", "-m", "init"], - check=True, capture_output=True, + check=True, + capture_output=True, ) tmp_installer.install( diff --git a/tests/tools/marketplace/test_integration.py b/tests/tools/marketplace/test_integration.py index 89ba38eb..856da6a2 100644 --- a/tests/tools/marketplace/test_integration.py +++ b/tests/tools/marketplace/test_integration.py @@ -8,8 +8,6 @@ import tempfile from pathlib import Path -import pytest - from sin_code_bundle.tools.marketplace.catalog import Catalog from sin_code_bundle.tools.marketplace.installer import Installer from sin_code_bundle.tools.marketplace.registry import Registry @@ -31,30 +29,37 @@ def test_full_workflow(self) -> None: subprocess.run(["git", "init", str(source_dir)], check=True, capture_output=True) subprocess.run( ["git", "-C", str(source_dir), "config", "user.email", "test@test.com"], - check=True, capture_output=True, + check=True, + capture_output=True, ) subprocess.run( ["git", "-C", str(source_dir), "config", "user.name", "Test"], - check=True, capture_output=True, + check=True, + capture_output=True, ) (source_dir / "file.txt").write_text("content") - subprocess.run(["git", "-C", str(source_dir), "add", "."], check=True, capture_output=True) + subprocess.run( + ["git", "-C", str(source_dir), "add", "."], check=True, capture_output=True + ) subprocess.run( ["git", "-C", str(source_dir), "commit", "-m", "init"], - check=True, capture_output=True, + check=True, + capture_output=True, ) # Catalog - catalog = Catalog([ - { - "slug": "test-skill", - "name": "Test Skill", - "title": "Test", - "description": "Integration test skill", - "source": str(source_dir), - "destination": str(skills_dir / "test-skill"), - } - ]) + catalog = Catalog( + [ + { + "slug": "test-skill", + "name": "Test Skill", + "title": "Test", + "description": "Integration test skill", + "source": str(source_dir), + "destination": str(skills_dir / "test-skill"), + } + ] + ) assert len(catalog) == 1 # Install @@ -118,11 +123,23 @@ def test_sync_to_cache(self) -> None: assert catalog.get_by_slug("a") is not None def test_search_across_fields(self) -> None: - catalog = Catalog([ - {"slug": "skill-a", "name": "Alpha", "title": "Alpha Skill", "description": "First"}, - {"slug": "skill-b", "name": "Beta", "title": "Beta Skill", "description": "Second"}, - {"slug": "skill-c", "name": "Gamma", "title": "Gamma Skill", "description": "Third"}, - ]) + catalog = Catalog( + [ + { + "slug": "skill-a", + "name": "Alpha", + "title": "Alpha Skill", + "description": "First", + }, + {"slug": "skill-b", "name": "Beta", "title": "Beta Skill", "description": "Second"}, + { + "slug": "skill-c", + "name": "Gamma", + "title": "Gamma Skill", + "description": "Third", + }, + ] + ) # Search by slug assert len(catalog.search("skill-a")) == 1 # Search by name @@ -144,17 +161,22 @@ def test_installer_idempotent(self) -> None: subprocess.run(["git", "init", str(source_dir)], check=True, capture_output=True) subprocess.run( ["git", "-C", str(source_dir), "config", "user.email", "test@test.com"], - check=True, capture_output=True, + check=True, + capture_output=True, ) subprocess.run( ["git", "-C", str(source_dir), "config", "user.name", "Test"], - check=True, capture_output=True, + check=True, + capture_output=True, ) (source_dir / "file.txt").write_text("content") - subprocess.run(["git", "-C", str(source_dir), "add", "."], check=True, capture_output=True) + subprocess.run( + ["git", "-C", str(source_dir), "add", "."], check=True, capture_output=True + ) subprocess.run( ["git", "-C", str(source_dir), "commit", "-m", "init"], - check=True, capture_output=True, + check=True, + capture_output=True, ) installer = Installer(skills_dir=skills_dir, config_path=config_path) diff --git a/tests/tools/marketplace/test_registry.py b/tests/tools/marketplace/test_registry.py index 6c08125f..0515aa21 100644 --- a/tests/tools/marketplace/test_registry.py +++ b/tests/tools/marketplace/test_registry.py @@ -68,29 +68,35 @@ def test_remove_not_found(self, tmp_registry: Registry) -> None: def test_list_all(self, tmp_registry: Registry) -> None: for i in range(3): - tmp_registry.install({ - "slug": f"skill-{i}", - "source": f"https://github.com/test/skill-{i}", - "destination": f"/tmp/skills/skill-{i}", - }) + tmp_registry.install( + { + "slug": f"skill-{i}", + "source": f"https://github.com/test/skill-{i}", + "destination": f"/tmp/skills/skill-{i}", + } + ) skills = tmp_registry.list_all() assert len(skills) == 3 def test_exists(self, tmp_registry: Registry) -> None: assert not tmp_registry.exists("test-skill") - tmp_registry.install({ - "slug": "test-skill", - "source": "https://github.com/test/skill", - "destination": "/tmp/skills/test-skill", - }) + tmp_registry.install( + { + "slug": "test-skill", + "source": "https://github.com/test/skill", + "destination": "/tmp/skills/test-skill", + } + ) assert tmp_registry.exists("test-skill") def test_update_timestamp(self, tmp_registry: Registry) -> None: - tmp_registry.install({ - "slug": "test-skill", - "source": "https://github.com/test/skill", - "destination": "/tmp/skills/test-skill", - }) + tmp_registry.install( + { + "slug": "test-skill", + "source": "https://github.com/test/skill", + "destination": "/tmp/skills/test-skill", + } + ) assert tmp_registry.update_timestamp("test-skill") is True # Verify updated_at changed record = tmp_registry.get("test-skill") @@ -108,21 +114,25 @@ def test_get_meta_not_found(self, tmp_registry: Registry) -> None: assert tmp_registry.get_meta("missing") is None def test_clear(self, tmp_registry: Registry) -> None: - tmp_registry.install({ - "slug": "test-skill", - "source": "https://github.com/test/skill", - "destination": "/tmp/skills/test-skill", - }) + tmp_registry.install( + { + "slug": "test-skill", + "source": "https://github.com/test/skill", + "destination": "/tmp/skills/test-skill", + } + ) tmp_registry.clear() assert len(tmp_registry) == 0 def test_len(self, tmp_registry: Registry) -> None: assert len(tmp_registry) == 0 - tmp_registry.install({ - "slug": "test-skill", - "source": "https://github.com/test/skill", - "destination": "/tmp/skills/test-skill", - }) + tmp_registry.install( + { + "slug": "test-skill", + "source": "https://github.com/test/skill", + "destination": "/tmp/skills/test-skill", + } + ) assert len(tmp_registry) == 1 def test_install_overwrites(self, tmp_registry: Registry) -> None: @@ -143,6 +153,7 @@ def test_default_db_path(self) -> None: # Just verify it doesn't crash when using default path with tempfile.TemporaryDirectory() as tmpdir: import os + old_home = os.environ.get("HOME") os.environ["HOME"] = tmpdir try: diff --git a/tests/tools/marketplace/test_scripts.py b/tests/tools/marketplace/test_scripts.py index 359686bd..dc8d74c9 100644 --- a/tests/tools/marketplace/test_scripts.py +++ b/tests/tools/marketplace/test_scripts.py @@ -5,8 +5,6 @@ from pathlib import Path -import pytest - # ── Script tests ────────────────────────────────────────────────────────────── class TestScripts: diff --git a/tests/tools/marketplace/test_server.py b/tests/tools/marketplace/test_server.py index 86d0e796..4f6f6d0a 100644 --- a/tests/tools/marketplace/test_server.py +++ b/tests/tools/marketplace/test_server.py @@ -50,9 +50,7 @@ class TestMcpSearch: @respx.mock async def test_marketplace_search(self, sample_catalog: list) -> None: _clear_cache() - respx.get(CATALOG_URL).mock( - return_value=Response(200, json=sample_catalog) - ) + respx.get(CATALOG_URL).mock(return_value=Response(200, json=sample_catalog)) result = await marketplace_search("test") data = json.loads(result) assert data["count"] == 1 @@ -61,9 +59,7 @@ async def test_marketplace_search(self, sample_catalog: list) -> None: @respx.mock async def test_marketplace_search_no_matches(self, sample_catalog: list) -> None: _clear_cache() - respx.get(CATALOG_URL).mock( - return_value=Response(200, json=sample_catalog) - ) + respx.get(CATALOG_URL).mock(return_value=Response(200, json=sample_catalog)) result = await marketplace_search("nonexistent") data = json.loads(result) assert data["count"] == 0 @@ -74,9 +70,7 @@ class TestMcpInstall: @respx.mock async def test_marketplace_install_not_found(self) -> None: _clear_cache() - respx.get(CATALOG_URL).mock( - return_value=Response(200, json=[]) - ) + respx.get(CATALOG_URL).mock(return_value=Response(200, json=[])) result = await marketplace_install("not-found") data = json.loads(result) assert "error" in data @@ -84,9 +78,7 @@ async def test_marketplace_install_not_found(self) -> None: @respx.mock async def test_marketplace_install_found(self, sample_catalog: list) -> None: _clear_cache() - respx.get(CATALOG_URL).mock( - return_value=Response(200, json=sample_catalog) - ) + respx.get(CATALOG_URL).mock(return_value=Response(200, json=sample_catalog)) # Will fail because source is not a real git repo result = await marketplace_install("test-skill") data = json.loads(result) @@ -99,6 +91,7 @@ async def test_marketplace_list_empty(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "test.db" import sin_code_bundle.tools.marketplace.registry + old_default = sin_code_bundle.tools.marketplace.registry.DEFAULT_DB_PATH sin_code_bundle.tools.marketplace.registry.DEFAULT_DB_PATH = db_path result = await marketplace_list() @@ -113,6 +106,7 @@ async def test_marketplace_remove(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "test.db" import sin_code_bundle.tools.marketplace.registry + old_default = sin_code_bundle.tools.marketplace.registry.DEFAULT_DB_PATH sin_code_bundle.tools.marketplace.registry.DEFAULT_DB_PATH = db_path result = await marketplace_remove("not-installed") @@ -126,12 +120,11 @@ class TestMcpInfo: @respx.mock async def test_marketplace_info_found(self, sample_catalog: list) -> None: _clear_cache() - respx.get(CATALOG_URL).mock( - return_value=Response(200, json=sample_catalog) - ) + respx.get(CATALOG_URL).mock(return_value=Response(200, json=sample_catalog)) with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "test.db" import sin_code_bundle.tools.marketplace.registry + old_default = sin_code_bundle.tools.marketplace.registry.DEFAULT_DB_PATH sin_code_bundle.tools.marketplace.registry.DEFAULT_DB_PATH = db_path result = await marketplace_info("test-skill") @@ -143,9 +136,7 @@ async def test_marketplace_info_found(self, sample_catalog: list) -> None: @respx.mock async def test_marketplace_info_not_found(self) -> None: _clear_cache() - respx.get(CATALOG_URL).mock( - return_value=Response(200, json=[]) - ) + respx.get(CATALOG_URL).mock(return_value=Response(200, json=[])) result = await marketplace_info("not-found") data = json.loads(result) assert data["slug"] == "not-found" @@ -159,6 +150,7 @@ async def test_marketplace_update_specific(self) -> None: skills_dir = Path(tmpdir) / "skills" skills_dir.mkdir() import sin_code_bundle.tools.marketplace.updater + old_default = sin_code_bundle.tools.marketplace.updater.DEFAULT_SKILLS_DIR sin_code_bundle.tools.marketplace.updater.DEFAULT_SKILLS_DIR = skills_dir result = await marketplace_update("test-skill") @@ -171,6 +163,7 @@ async def test_marketplace_update_all(self) -> None: skills_dir = Path(tmpdir) / "skills" skills_dir.mkdir() import sin_code_bundle.tools.marketplace.updater + old_default = sin_code_bundle.tools.marketplace.updater.DEFAULT_SKILLS_DIR sin_code_bundle.tools.marketplace.updater.DEFAULT_SKILLS_DIR = skills_dir result = await marketplace_update() @@ -184,12 +177,11 @@ class TestMcpSync: @respx.mock async def test_marketplace_sync_success(self, sample_catalog: list) -> None: _clear_cache() - respx.get(CATALOG_URL).mock( - return_value=Response(200, json=sample_catalog) - ) + respx.get(CATALOG_URL).mock(return_value=Response(200, json=sample_catalog)) with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "test.db" import sin_code_bundle.tools.marketplace.registry + old_default = sin_code_bundle.tools.marketplace.registry.DEFAULT_DB_PATH sin_code_bundle.tools.marketplace.registry.DEFAULT_DB_PATH = db_path result = await marketplace_sync() @@ -201,9 +193,7 @@ async def test_marketplace_sync_success(self, sample_catalog: list) -> None: @respx.mock async def test_marketplace_sync_failure(self) -> None: _clear_cache() - respx.get(CATALOG_URL).mock( - return_value=Response(404, text="Not Found") - ) + respx.get(CATALOG_URL).mock(return_value=Response(404, text="Not Found")) result = await marketplace_sync() data = json.loads(result) assert "error" in data diff --git a/tests/tools/marketplace/test_updater.py b/tests/tools/marketplace/test_updater.py index 96f91afa..4f40cead 100644 --- a/tests/tools/marketplace/test_updater.py +++ b/tests/tools/marketplace/test_updater.py @@ -9,7 +9,7 @@ import pytest -from sin_code_bundle.tools.marketplace.updater import Updater, UpdateError +from sin_code_bundle.tools.marketplace.updater import UpdateError, Updater # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -17,17 +17,20 @@ def _init_git_repo(path: str) -> None: subprocess.run(["git", "init", path], check=True, capture_output=True) subprocess.run( ["git", "-C", path, "config", "user.email", "test@test.com"], - check=True, capture_output=True, + check=True, + capture_output=True, ) subprocess.run( ["git", "-C", path, "config", "user.name", "Test"], - check=True, capture_output=True, + check=True, + capture_output=True, ) Path(path, "file.txt").write_text("v1") subprocess.run(["git", "-C", path, "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", path, "commit", "-m", "v1"], - check=True, capture_output=True, + check=True, + capture_output=True, ) diff --git a/tests/tools/mcp_server_builder/test_mcp_server.py b/tests/tools/mcp_server_builder/test_mcp_server.py index ab295905..e752bc7d 100644 --- a/tests/tools/mcp_server_builder/test_mcp_server.py +++ b/tests/tools/mcp_server_builder/test_mcp_server.py @@ -144,9 +144,7 @@ def test_valid_project(self, tmp_path): class TestMcpPublish: def test_dry_run(self, tmp_path): - (tmp_path / "pyproject.toml").write_text( - "[project]\nname='x'\nversion='0.1.0'\n" - ) + (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\nversion='0.1.0'\n") result = json.loads( mcp_publish( project_dir=str(tmp_path), diff --git a/tests/tools/mcp_server_builder/test_publisher.py b/tests/tools/mcp_server_builder/test_publisher.py index c7d9e437..62d651c6 100644 --- a/tests/tools/mcp_server_builder/test_publisher.py +++ b/tests/tools/mcp_server_builder/test_publisher.py @@ -12,7 +12,7 @@ class TestPublisher: def _make_python_project(self, tmp_path): (tmp_path / "pyproject.toml").write_text( - "[project]\n" 'name = "demo"\n' 'version = "0.3.2"\n', + '[project]\nname = "demo"\nversion = "0.3.2"\n', encoding="utf-8", ) (tmp_path / "README.md").write_text("# demo\n") diff --git a/tests/tools/mcp_server_builder/test_scaffolder.py b/tests/tools/mcp_server_builder/test_scaffolder.py index 1f1d3b4c..d46e763f 100644 --- a/tests/tools/mcp_server_builder/test_scaffolder.py +++ b/tests/tools/mcp_server_builder/test_scaffolder.py @@ -54,9 +54,7 @@ def test_invalid_tool_name_raises(self): ScaffoldSpec(name="x", tools=["1bad"]) def test_context_dict(self): - spec = ScaffoldSpec( - name="My Tool", tools=["do_x", "do_y"], template="python-fastmcp" - ) + spec = ScaffoldSpec(name="My Tool", tools=["do_x", "do_y"], template="python-fastmcp") ctx = spec.to_context() assert ctx["name"] == "My Tool" assert ctx["slug"] == "my-tool" @@ -91,7 +89,8 @@ def test_scaffold_renders_template_vars(self, tmp_path): assert "my-tool" in text assert ( "My Tool" not in text - ) # render_to_string only handles {{ var }} — README is the only multi-line Jinja, so the name "My Tool" only appears in README via Jinja + # render_to_string only handles {{ var }} — README is the only multi-line Jinja, so the name "My Tool" only appears in README via Jinja + ) # README has the human name. readme = (target / "README.md").read_text(encoding="utf-8") assert "My Tool" in readme @@ -115,9 +114,7 @@ def test_scaffold_creates_src_and_tests(self, tmp_path): def test_scaffold_renders_pkg_name(self, tmp_path): """The `{{ pkg }}` path placeholder must be replaced.""" target = tmp_path / "out" - spec = ScaffoldSpec( - name="My Cool Tool", template="python-fastmcp", tools=["ping"] - ) + spec = ScaffoldSpec(name="My Cool Tool", template="python-fastmcp", tools=["ping"]) Scaffolder().scaffold(target, spec) # 'My Cool Tool' → 'my_cool_tool' (package form). assert (target / "src" / "my_cool_tool" / "mcp_server.py").is_file() diff --git a/tests/tools/mcp_server_builder/test_test_gen.py b/tests/tools/mcp_server_builder/test_test_gen.py index 24fee0c3..d0969297 100644 --- a/tests/tools/mcp_server_builder/test_test_gen.py +++ b/tests/tools/mcp_server_builder/test_test_gen.py @@ -6,7 +6,11 @@ import pytest -from sin_code_bundle.tools.mcp_server_builder.test_gen import TestGenerator, _empty_value, _sample_value +from sin_code_bundle.tools.mcp_server_builder.test_gen import ( + TestGenerator, + _empty_value, + _sample_value, +) class TestSampleValue: diff --git a/tests/tools/mcp_server_builder/test_tool_adder.py b/tests/tools/mcp_server_builder/test_tool_adder.py index eed56900..f035d2fd 100644 --- a/tests/tools/mcp_server_builder/test_tool_adder.py +++ b/tests/tools/mcp_server_builder/test_tool_adder.py @@ -89,9 +89,7 @@ def test_add_to_python_appends_tool(self, tmp_path): def test_add_to_python_missing_file_raises(self, tmp_path): with pytest.raises(FileNotFoundError): - ToolAdder().add_to_python( - tmp_path / "missing.py", ToolSpec(name="x", description="x") - ) + ToolAdder().add_to_python(tmp_path / "missing.py", ToolSpec(name="x", description="x")) def test_add_test_appends(self, tmp_path): test = tmp_path / "test_mcp_server.py" diff --git a/tests/tools/mcp_server_builder/test_validator.py b/tests/tools/mcp_server_builder/test_validator.py index 08978c32..083dc1a0 100644 --- a/tests/tools/mcp_server_builder/test_validator.py +++ b/tests/tools/mcp_server_builder/test_validator.py @@ -4,8 +4,6 @@ Docs: test_validator.doc.md """ - - from sin_code_bundle.tools.mcp_server_builder.validator import Validator diff --git a/tests/tools/slash/conftest.py b/tests/tools/slash/conftest.py index 848d9a0b..811578df 100644 --- a/tests/tools/slash/conftest.py +++ b/tests/tools/slash/conftest.py @@ -1,17 +1,16 @@ # SPDX-License-Identifier: MIT # Purpose: Shared test fixtures and configuration. # Docs: conftest.doc.md -"""Shared fixtures for pytest. -""" +"""Shared fixtures for pytest.""" import os import tempfile import pytest -from sin_code_bundle.tools.slash.registry import CommandRegistry from sin_code_bundle.tools.slash.dispatcher import CommandDispatcher from sin_code_bundle.tools.slash.executor import CommandExecutor +from sin_code_bundle.tools.slash.registry import CommandRegistry @pytest.fixture @@ -66,4 +65,5 @@ def parser(): SlashParser instance. """ from sin_code_bundle.tools.slash.parser import SlashParser + yield SlashParser() diff --git a/tests/tools/slash/test_cli.py b/tests/tools/slash/test_cli.py index c211386e..6b0f724f 100644 --- a/tests/tools/slash/test_cli.py +++ b/tests/tools/slash/test_cli.py @@ -9,7 +9,6 @@ import os import tempfile -import pytest from click.testing import CliRunner from sin_code_bundle.tools.slash.cli import cli @@ -80,7 +79,7 @@ def test_register_command(self) -> None: """Register a command via CLI.""" with tempfile.TemporaryDirectory() as tmpdir: db_path = os.path.join(tmpdir, "registry.db") - registry = CommandRegistry(db_path) + CommandRegistry(db_path) result = self.runner.invoke( cli, ["register", "deploy", "Deploy app", "git push", "--type", "shell"], diff --git a/tests/tools/slash/test_commands.py b/tests/tools/slash/test_commands.py index 8310ccfb..50b32374 100644 --- a/tests/tools/slash/test_commands.py +++ b/tests/tools/slash/test_commands.py @@ -9,8 +9,8 @@ from sin_code_bundle.tools.slash.commands import ( BUILTIN_COMMANDS, get_builtin_command, - list_builtin_commands, get_command_help, + list_builtin_commands, ) @@ -19,7 +19,18 @@ class TestBuiltinCommands: def test_all_commands_exist(self) -> None: """All expected built-in commands exist.""" - expected = {"refactor", "test", "docs", "commit", "audit", "status", "search", "help", "list", "history"} + expected = { + "refactor", + "test", + "docs", + "commit", + "audit", + "status", + "search", + "help", + "list", + "history", + } assert set(BUILTIN_COMMANDS.keys()) == expected def test_refactor_command(self) -> None: diff --git a/tests/tools/slash/test_dispatcher.py b/tests/tools/slash/test_dispatcher.py index 6a7de778..4a13b9b4 100644 --- a/tests/tools/slash/test_dispatcher.py +++ b/tests/tools/slash/test_dispatcher.py @@ -9,9 +9,7 @@ import os import tempfile -import pytest - -from sin_code_bundle.tools.slash.dispatcher import CommandDispatcher, DispatchResult +from sin_code_bundle.tools.slash.dispatcher import CommandDispatcher from sin_code_bundle.tools.slash.registry import CommandRegistry diff --git a/tests/tools/slash/test_mcp_server.py b/tests/tools/slash/test_mcp_server.py index 38da184b..295b7e5a 100644 --- a/tests/tools/slash/test_mcp_server.py +++ b/tests/tools/slash/test_mcp_server.py @@ -10,18 +10,15 @@ import os import tempfile -import pytest - +from sin_code_bundle.tools.slash.dispatcher import CommandDispatcher from sin_code_bundle.tools.slash.mcp_server import ( slash_dispatch, + slash_help, + slash_history, slash_list, slash_register, slash_unregister, - slash_help, - slash_history, - _get_dispatcher, ) -from sin_code_bundle.tools.slash.dispatcher import CommandDispatcher class TestMCPServer: @@ -34,6 +31,7 @@ def setup_method(self) -> None: # Reset dispatcher singleton import sin_code_bundle.tools.slash.mcp_server as mcp_module from sin_code_bundle.tools.slash.registry import CommandRegistry + registry = CommandRegistry(self.db_path) mcp_module._dispatcher = CommandDispatcher( registry=registry, diff --git a/tests/tools/slash/test_parser.py b/tests/tools/slash/test_parser.py index 6eb227c4..3594e6f3 100644 --- a/tests/tools/slash/test_parser.py +++ b/tests/tools/slash/test_parser.py @@ -8,7 +8,7 @@ import pytest -from sin_code_bundle.tools.slash.parser import SlashParser, ParsedCommand +from sin_code_bundle.tools.slash.parser import SlashParser class TestSlashParser: diff --git a/tests/tools/slash/test_registry.py b/tests/tools/slash/test_registry.py index 0314b307..e768c336 100644 --- a/tests/tools/slash/test_registry.py +++ b/tests/tools/slash/test_registry.py @@ -11,7 +11,7 @@ import pytest -from sin_code_bundle.tools.slash.registry import CommandRegistry, CustomCommand +from sin_code_bundle.tools.slash.registry import CommandRegistry class TestCommandRegistry: