diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index cab131e..add10ce 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -79,3 +79,23 @@ jobs: - name: install / check / uninstall shell: bash run: ./scripts/smoke-install.sh + + smoke-windows: + name: Windows smoke (${{ matrix.shell }}) + runs-on: windows-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shell: [powershell, pwsh] + defaults: + run: + shell: ${{ matrix.shell }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: install / check / uninstall + run: ./scripts/smoke-install.ps1 + - name: Native memory compaction + run: ./scripts/test-memory-compact.ps1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ffc9e3d..d495082 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,8 @@ npm run docs:dev # http://localhost:5173/opencode-team/ `install.sh` never edits `opencode.json`. Set `default_agent` and `instructions` yourself (see README). +On native Windows, use `install.ps1` instead of `install.sh`, with `-Check`, `-Uninstall`, or `-InstallOpenCode` as needed. Run in Administrator PowerShell (Windows PowerShell 5.1 or PowerShell 7), or use PowerShell 7 with Developer Mode enabled. If local execution policy blocks scripts, invoke `powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\install.ps1`. Neither installer changes your JSON configuration or machine policy. See [Windows installation](docs/install.md#windows). + ## What you can change | Area | Path | Frontmatter | @@ -81,6 +83,15 @@ The validator checks frontmatter, filename/name matches, agent and command refer CI also runs `shellcheck --severity=error`. It is preinstalled on `ubuntu-latest`; locally, install it with your package manager if you want the same result. +Native Windows tests need symbolic-link privileges and run offline against temporary directories, with fake OpenCode/npm commands: + +```powershell +.\scripts\smoke-install.ps1 +.\scripts\test-memory-compact.ps1 +``` + +Run both under Windows PowerShell 5.1 and PowerShell 7 when changing native scripts. The `windows-latest` job tests both shells. Ruby is still needed for manifest validation/reference generation, and Node 22+ for the docs build; these are contributor tools, not dependencies of the team installer. + Build the docs before opening a PR that touches `docs/`: ```bash diff --git a/README.md b/README.md index b0ed6c4..ba19cda 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,9 @@ The orchestrator is the **default agent**. Open `opencode` anywhere, describe an Requires OpenCode 1.x (tested on 1.19.0). OpenCode 2 support is planned as a separate release. There is no tagged release yet; `main` is the development branch. -Supported on macOS and Linux. Native Windows is **not supported yet**: `install.sh` is Bash and uses symlinks. WSL is untested. +Supported on macOS, Linux, and native Windows (Windows PowerShell 5.1 or PowerShell 7; verified with OpenCode 1.19.4). WSL is untested. + +On macOS or Linux: ```bash git clone https://github.com/sametcelikbicak/opencode-team.git @@ -78,6 +80,16 @@ cd opencode-team ./install.sh ``` +On Windows, clone the repository and run the PowerShell installer from an **Administrator PowerShell**: + +```powershell +git clone https://github.com/sametcelikbicak/opencode-team.git +cd opencode-team +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 +``` + +PowerShell 7 with Windows Developer Mode enabled can create the symlinks without elevation. The process-scoped `-ExecutionPolicy Bypass` does not change your machine policy. See the [Windows installation guide](docs/install.md#windows) for checks, optional OpenCode installation, and troubleshooting. + If OpenCode is not on your PATH, install it first, or let the installer run the official OpenCode 1.x installer: ```bash @@ -100,7 +112,7 @@ Restart opencode. That's it — the setup is global, so it works in every projec The repo stays in git; the config directory holds symlinks. Edit a file in the repo and it takes effect immediately. -The installer never modifies `opencode.json`. If `default_agent` is not set to `orchestrator`, it prints the recommended snippet and leaves the file alone. +Neither installer modifies `opencode.json`. If `default_agent` is not set to `orchestrator`, it prints the recommended snippet and leaves the file alone. On Windows the default directory is `%USERPROFILE%\.config\opencode`; both installers honor `OPENCODE_CONFIG_DIR`. Check or remove the install: @@ -109,6 +121,13 @@ Check or remove the install: ./install.sh --uninstall ``` +Windows equivalents (use the same elevated shell or Developer Mode setup): + +```powershell +.\install.ps1 -Check +.\install.ps1 -Uninstall +``` + `--check` exits non-zero when a symlink is missing. A missing `default_agent` setting is shown as a hint, not a failure. `--uninstall` removes only symlinks that point into this repo. ### Manual setup diff --git a/README.tr.md b/README.tr.md index 6ce287c..97040f7 100644 --- a/README.tr.md +++ b/README.tr.md @@ -70,7 +70,9 @@ Orkestratör **varsayılan ajandır**. Herhangi bir dizinde `opencode` açın, f OpenCode 1.x gerektirir (1.19.0 ile test edildi). OpenCode 2 desteği ayrı bir sürüm olarak planlanıyor. Henüz etiketli sürüm yok; geliştirme `main` üzerinde devam ediyor. -macOS ve Linux desteklenir. Yerel Windows **henüz desteklenmiyor**: `install.sh` Bash kullanır ve symlink oluşturur. WSL test edilmedi. +macOS, Linux ve yerel Windows desteklenir (Windows PowerShell 5.1 veya PowerShell 7; OpenCode 1.19.4 ile doğrulandı). WSL test edilmedi. + +macOS veya Linux: ```bash git clone https://github.com/sametcelikbicak/opencode-team @@ -78,6 +80,16 @@ cd opencode-team ./install.sh ``` +Windows'ta repoyu klonlayın ve kurucuyu **yönetici PowerShell** üzerinden çalıştırın: + +```powershell +git clone https://github.com/sametcelikbicak/opencode-team.git +cd opencode-team +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 +``` + +Windows Geliştirici Modu açıkken PowerShell 7 ile yönetici yetkisi olmadan da symlink oluşturulabilir. `-ExecutionPolicy Bypass` yalnızca bu işlem için geçerlidir; makinenin kalıcı politikasını değiştirmez. Kontrol, isteğe bağlı OpenCode kurulumu ve sorun giderme için [Windows kurulum kılavuzuna](docs/install.md#windows) bakın. + OpenCode PATH'te yoksa önce kurun veya kurulum betiğinin resmî OpenCode 1.x kurucusunu çalıştırmasına izin verin: ```bash @@ -100,7 +112,7 @@ opencode'u yeniden başlatın. Hepsi bu — kurulum global'dir, her proje dizini Repo git'te kalır, yapılandırma dizininde symlink bulunur. Repoda bir dosyayı düzenlediğinizde etkisi anında olur. -Kurulum betiği `opencode.json` dosyasını hiçbir zaman değiştirmez. `default_agent` `orchestrator` olarak ayarlı değilse önerilen snippet'i yazdırır, dosyayı olduğu gibi bırakır. +İki kurucu da `opencode.json` dosyasını hiçbir zaman değiştirmez. `default_agent` `orchestrator` olarak ayarlı değilse önerilen snippet'i yazdırır, dosyayı olduğu gibi bırakır. Windows'ta varsayılan dizin `%USERPROFILE%\.config\opencode` yoludur; iki kurucu da `OPENCODE_CONFIG_DIR` değişkenini kullanabilir. Kurulumu kontrol etmek veya kaldırmak için: @@ -109,6 +121,13 @@ Kurulumu kontrol etmek veya kaldırmak için: ./install.sh --uninstall ``` +Windows karşılıkları (aynı yönetici veya Geliştirici Modu oturumunda): + +```powershell +.\install.ps1 -Check +.\install.ps1 -Uninstall +``` + `--check`, bir symlink eksikse sıfır dışı kodla çıkar. Eksik `default_agent` ayarı hata değil, öneri olarak gösterilir. `--uninstall` yalnızca bu repoya işaret eden symlink'leri kaldırır. ### Manuel kurulum diff --git a/TESTING-WINDOWS.md b/TESTING-WINDOWS.md new file mode 100644 index 0000000..1f51316 --- /dev/null +++ b/TESTING-WINDOWS.md @@ -0,0 +1,40 @@ +# Native Windows verification + +Date: 2026-10-27 (Europe/Istanbul) + +Issue: #42 + +## Automated checks + +| Check | Result | +| --- | --- | +| `scripts/smoke-install.ps1`, Windows PowerShell 5.1 | Passed, 102 assertions | +| `scripts/smoke-install.ps1`, PowerShell 7.6.5 | Passed, 102 assertions | +| `scripts/test-memory-compact.ps1`, Windows PowerShell 5.1 | Passed, 38 assertions | +| `scripts/test-memory-compact.ps1`, PowerShell 7.6.5 | Passed, 38 assertions | +| `ruby scripts/validate.rb` | Passed, 47 files | +| `ruby scripts/generate-reference.rb` | Passed; generated reference unchanged | +| `bash -n install.sh scripts/*.sh skills/team-memory/*.sh` | Passed | +| Existing `scripts/smoke-install.sh` | Passed under elevated Git Bash with `MSYS=winsymlinks:nativestrict` | +| Existing `scripts/test-memory-compact.sh` | Passed | +| `npm run docs:build` | Passed | + +Native tests ran elevated because Developer Mode was disabled. They use disposable directories and fake OpenCode/npm commands; they never install packages or modify actual user configuration. Existing Unix scripts were also checked, but this is a Windows run, not a new macOS/Linux run. A Windows GitHub Actions job for both shells is configured; remote CI has not been run during this work. + +## Actual OpenCode 1.19.4 + +An isolated config/data environment using the real installed binary discovered all 14 team agents, 19 commands, and 13 local skills. A disposable `opencode.json` resolved `orchestrator` as the default and both team instruction paths. The PowerShell install/check succeeded. No provider/model calls were made. + +Installation into the actual user config also passed `-Check`. The preexisting `opencode.json` SHA256 was identical before/after. The installer printed the recommended settings instead of changing provider/model/default-agent settings. + +The existing user database had a v2 schema (`session_v2` and `session_message`, no `session`) and caused OpenCode 1.19.4 to fail before agent discovery. A process-local `XDG_DATA_HOME` pointing at a separate v1 data directory allowed the real user configuration to discover all 14 team agents. The original database was not reset or migrated. See [Windows database troubleshooting](docs/install.md#windows-database-is-not-empty-and-has-no-session-table). + +Provider authentication, model responses, and a full idea-to-launch pipeline were not exercised. Those depend on the user's configured provider and may incur usage. + +## Independent review + +A fresh reviewer found one important issue: relative `OPENCODE_CONFIG_DIR`/`MEMORY_DIR` values were resolved against the process-start directory instead of PowerShell's current location after `Set-Location`. Both regressions failed before the fix. Both helpers now resolve through the PowerShell filesystem provider before normalizing with .NET; the complete native suites passed after the fix. + +Review scope decisions: memory compaction still requires no concurrent writers; the existing single-line log format remains the contract; v2 session migration remains outside this Windows installer change. The separate-data-directory workaround preserves those v2 sessions rather than converting them. + +The process-only `OPENCODE_CONFIG_CONTENT` startup alternative was also verified with the user's actual config: `orchestrator` resolved as the default, both team instruction paths were present, and all 14 team agents were discovered, without editing `opencode.json`. diff --git a/agents/orchestrator.md b/agents/orchestrator.md index c77ebf6..864a0da 100644 --- a/agents/orchestrator.md +++ b/agents/orchestrator.md @@ -80,7 +80,7 @@ Read `skills/team-memory/SKILL.md` before seeding, dispatching or compacting mem 1. Confirm no subagents are running. 2. Read the logs changed during the stage. 3. Promote durable facts to `INDEX.md`; create a decision file when the stage made a real decision. -4. Run the installed `~/.config/opencode/skills/team-memory/memory-compact.sh` from the project root to archive old log entries (over 40) and compact `INDEX.md` (over 60 lines). It reads `$PWD/.team/memory` by default. +4. Run the installed memory compactor from the project root to archive old log entries (over 40) and compact `INDEX.md` (over 60 lines). Resolve the global config root from `OPENCODE_CONFIG_DIR`, otherwise `~/.config/opencode`. On native Windows invoke `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "/skills/team-memory/memory-compact.ps1"`; on macOS/Linux invoke `/skills/team-memory/memory-compact.sh`. Both default to the current project's `.team/memory`; when `memory_dir` is customized, set `MEMORY_DIR` to that project-resolved path for the helper. See the memory skill for shell-specific examples. 5. Write the stage transition to `log/orchestrator.md`. 6. Update `stage` in `INDEX.md` when the user approves the gate and the next stage starts. diff --git a/docs/faq.md b/docs/faq.md index 39333b4..c059aba 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -12,9 +12,11 @@ cd opencode-team Restart opencode after installation. +On native Windows, use an Administrator PowerShell and run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\install.ps1` instead of `./install.sh`. See the [Windows guide](install.md#windows). + ### Does it work on Windows? -Not natively yet. macOS and Linux are supported. `install.sh` is Bash and creates symlinks; native Windows needs a separate installer and CI coverage. WSL is untested, so treat it as experimental. +Yes. Use `install.ps1` with Windows PowerShell 5.1 or PowerShell 7. Symbolic links require an Administrator PowerShell, or PowerShell 7 with Windows Developer Mode enabled. Configuration defaults to `%USERPROFILE%\.config\opencode` and supports `OPENCODE_CONFIG_DIR`. Installation and memory maintenance have native PowerShell tests and Windows CI coverage. WSL is untested. See [Windows installation](install.md#windows). ### Do I need OpenCode 1.x or 2.x? @@ -28,6 +30,8 @@ OpenCode Team is tested on OpenCode 1.x (1.19.0). OpenCode 2 support is planned This removes only symlinks that point into this repo. `opencode.json` is never modified. +On Windows, use `.\install.ps1 -Uninstall`; `.\install.ps1 -Check` verifies the installation. + ## Memory ### Where is memory stored? diff --git a/docs/install.md b/docs/install.md index 0f019dd..99b1338 100644 --- a/docs/install.md +++ b/docs/install.md @@ -4,7 +4,7 @@ Install OpenCode Team into your OpenCode configuration so you can use it in any ## Requirements -- [OpenCode](https://opencode.ai) 1.x (tested on 1.19.0) +- [OpenCode](https://opencode.ai) 1.x (tested on 1.19.0; native Windows verified on 1.19.4) - An OpenCode Zen account or any provider configured in opencode — the defaults use free-tier models ## Platform support @@ -13,13 +13,15 @@ Install OpenCode Team into your OpenCode configuration so you can use it in any |----------|--------| | macOS | Supported | | Linux | Supported | -| Windows (native) | Not supported yet | +| Windows (native) | Supported: Windows PowerShell 5.1 and PowerShell 7 | | Windows (WSL) | Untested | -`install.sh` is Bash and creates symlinks. Native Windows needs a separate installer and CI coverage; neither exists yet. +Use `install.sh` on macOS/Linux and `install.ps1` on native Windows. Both create symbolic links to the repository. ## Install +### macOS and Linux + Clone the repository and run the installer: ```bash @@ -38,9 +40,61 @@ If OpenCode is not on your PATH, install it first: Restart opencode after installation. The setup is global, so it works in every project directory. +### Windows + +Open an **Administrator PowerShell**, then run: + +```powershell +git clone https://github.com/sametcelikbicak/opencode-team.git +cd opencode-team +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 +``` + +Windows PowerShell 5.1 is included with Windows. PowerShell 7 (`pwsh`) is also supported; with Windows Developer Mode enabled, it can create symbolic links without elevation. The installer never elevates itself or changes Developer Mode. Without link privileges, it stops before changing installation links and explains how to proceed. + +`-ExecutionPolicy Bypass` applies only to the new PowerShell process. It does not change the machine's execution policy; an organization policy can still restrict script execution. In a shell that already permits scripts, `.\install.ps1` is sufficient. + +OpenCode 1.x must already be on PATH. To explicitly install it when missing or incompatible, use: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 -InstallOpenCode +``` + +This optional mode requires npm and runs `npm install --global opencode-ai@1`, then verifies the executable. It reuses an existing working 1.x installation. Without the switch, the installer never changes OpenCode. OpenCode 2 is rejected. + +The default config directory is `%USERPROFILE%\.config\opencode`. To use a different directory, set the same environment variable for installation **and** subsequent OpenCode sessions: + +```powershell +$env:OPENCODE_CONFIG_DIR = 'D:\my config\opencode' +.\install.ps1 +opencode +``` + +The installer prints a JSON snippet containing paths for that actual directory. Merge it into your existing `opencode.json` to enable the orchestrator and global instructions; preserve provider/model settings. The installer does not edit JSON or JSONC. + +Alternatively, for the default config directory, you can supply the team settings only to the current terminal without editing your configuration file: + +```powershell +$env:OPENCODE_CONFIG_CONTENT = '{"default_agent":"orchestrator","instructions":["~/.config/opencode/team/AGENTS.md","~/.config/opencode/team.yaml"]}' +opencode +``` + +For a custom config directory, use the instruction paths printed by the installer. If you already use `OPENCODE_CONFIG_CONTENT`, merge these settings into its existing value instead of replacing it. + +Verify or uninstall from the same elevated shell (or PowerShell 7 with Developer Mode): + +```powershell +.\install.ps1 -Check +.\install.ps1 -Uninstall +``` + +Repeat installation is safe. Local/external skills are preserved; conflicting core entries are reported and preserved. Check fails for missing/wrong core links, missing skills, or an unavailable/incompatible OpenCode. A missing `default_agent` is a hint. Uninstall removes only this repository's links, without deleting their targets, ordinary files, or your configuration. Linked config containers are rejected to avoid modifying another directory through a parent link. + +Native Windows memory maintenance uses `memory-compact.ps1`, so the team's memory helper does not require Bash. See [Memory](memory.md#native-windows). + ## What the installer does -`install.sh` symlinks the repo into your OpenCode config: +The platform's installer symlinks the repo into your OpenCode config: | Path | Target | |-------------------------------------|--------------------------------| @@ -87,7 +141,7 @@ Remove only symlinks that point into this repo: ./install.sh --uninstall ``` -`opencode.json` is never modified by the installer. +`opencode.json` is never modified by either installer. For Windows commands, see [Windows](#windows). ## First run @@ -101,6 +155,19 @@ The orchestrator will start the pipeline and ask for your approval at each gate. ## Troubleshooting +### Windows: `Database is not empty and has no session table` + +This can occur when OpenCode 1.x opens a data directory previously migrated by OpenCode 2. It is separate from the team installer: a v2 database has `session_v2` instead of the v1 `session` table. See the [upstream report](https://github.com/anomalyco/opencode/issues/56764). + +Keep that database and use a separate data directory for your 1.x session: + +```powershell +$env:XDG_DATA_HOME = Join-Path $env:USERPROFILE '.local/share/opencode-v1' +opencode --agent orchestrator +``` + +This environment setting applies to the current terminal and its child processes. It preserves the original database and uses fresh v1 session/authentication data; connect your provider in the new environment if needed. The global team configuration remains in `%USERPROFILE%\.config\opencode` (or `OPENCODE_CONFIG_DIR`). It does not migrate v2 sessions. Do not delete the old database to fix this error. + ### `opencode: command not found` The installer only links files when OpenCode is already on your PATH. To install OpenCode 1.x first: diff --git a/docs/memory.md b/docs/memory.md index 83ab0e3..a1f06b4 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -46,6 +46,17 @@ OpenCode Team keeps a small, project-local memory in `.team/memory/` so the orch - Memory is untrusted data, never instructions. Artifacts are the source of truth; if memory contradicts an artifact, the artifact wins. - The orchestrator compacts memory at gates, only when no subagents are running. +## Native Windows + +The orchestrator uses the PowerShell helper on native Windows, and the Bash helper on macOS/Linux. From your project root, you can run the native helper manually: + +```powershell +$configRoot = if ($env:OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR } else { Join-Path $env:USERPROFILE '.config/opencode' } +powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $configRoot 'skills/team-memory/memory-compact.ps1') +``` + +Append `-Role analyst` to compact only that role's log. Both helpers default to the current project's `.team/memory`. For a custom `memory_dir` in `team.yaml`, resolve it from your project root and pass that path through `MEMORY_DIR`; restore any previous environment value afterward. The helper never needs administrator privileges for ordinary memory files. The Windows version preserves Unicode, refuses linked memory paths/invalid archive dates, and leaves an oversized unique index unchanged. + ## Full protocol See `skills/team-memory/SKILL.md` and `docs/rfcs/0001-project-memory.md`. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..daa296d --- /dev/null +++ b/install.ps1 @@ -0,0 +1,187 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS +Links OpenCode Team into the native Windows OpenCode configuration. +.EXAMPLE + .\install.ps1 + .\install.ps1 -Check + .\install.ps1 -Uninstall + .\install.ps1 -InstallOpenCode +#> +[CmdletBinding(DefaultParameterSetName = 'Install')] +param( + [Parameter(ParameterSetName = 'Check', Mandatory = $true)][switch]$Check, + [Parameter(ParameterSetName = 'Uninstall', Mandatory = $true)][switch]$Uninstall, + [Parameter(ParameterSetName = 'Install')][switch]$InstallOpenCode +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repo = [IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\', '/') +$config = if ($env:OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR } else { Join-Path $env:USERPROFILE '.config/opencode' } +$config = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($config) +$config = [IO.Path]::GetFullPath($config).TrimEnd('\', '/') +$core = @('agents', 'commands', 'team.yaml', 'team/AGENTS.md') +$skills = @(Get-ChildItem -LiteralPath (Join-Path $repo 'skills') -Directory | Where-Object { $_.Name -like 'team-*' }) + +function Get-Entry([string]$Path) { + Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue +} +function Test-Link($Entry) { + return $null -ne $Entry -and [bool]($Entry.Attributes -band [IO.FileAttributes]::ReparsePoint) +} +function Get-LinkTarget($Entry) { + if (-not (Test-Link $Entry) -or -not $Entry.Target) { return $null } + $target = [string]@($Entry.Target)[0] + if ($target.StartsWith('\\?\')) { $target = $target.Substring(4) } + if (-not [IO.Path]::IsPathRooted($target)) { $target = Join-Path (Split-Path $Entry.FullName -Parent) $target } + return [IO.Path]::GetFullPath($target).TrimEnd('\', '/') +} +function Test-Owned($Entry) { + $target = Get-LinkTarget $Entry + return $null -ne $target -and $target.StartsWith($repo + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) +} +function Test-AvailableEntry($Entry) { + # Test-Path may report a dangling directory link as present on Windows. + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $current = $Entry + while ($null -ne $current -and (Test-Link $current)) { + $target = Get-LinkTarget $current + if (-not $target -or -not $seen.Add($target)) { return $false } + $current = Get-Entry $target + } + return $null -ne $current +} +function Assert-RealParents([string]$Path) { + $current = [IO.Path]::GetFullPath($Path) + while ($current) { + $item = Get-Entry $current + if ($null -ne $item -and ((Test-Link $item) -or -not $item.PSIsContainer)) { + throw "Config container must be a real directory, not a file or link: $current. Existing data was left untouched." + } + $parent = [IO.Path]::GetDirectoryName($current) + if ($parent -eq $current) { break } + $current = $parent + } +} +function Remove-Link($Entry) { + # Never recurse: deleting the link itself must not touch its target. + if ($Entry.PSIsContainer) { [IO.Directory]::Delete($Entry.FullName) } else { [IO.File]::Delete($Entry.FullName) } +} +function Get-OpenCodeVersion { + $command = Get-Command opencode -ErrorAction SilentlyContinue + if ($null -eq $command) { return $null } + try { + $global:LASTEXITCODE = 0 + $output = (& $command --version 2>$null | Out-String).Trim() + if ($LASTEXITCODE -eq 0 -and $output -match '^v?(1\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$') { return $Matches[1] } + } catch { return $null } + return $null +} +function Require-OpenCode { + $version = Get-OpenCodeVersion + if ($version) { Write-Host " OK OpenCode $version"; return } + if ($InstallOpenCode) { + $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue + if ($null -eq $npm) { throw 'npm is required for -InstallOpenCode. Install Node.js/npm or OpenCode 1.x manually, then rerun.' } + Write-Host 'Installing OpenCode 1.x using npm. An incompatible OpenCode binary may be replaced.' + & $npm install --global opencode-ai@1 + if ($LASTEXITCODE -ne 0) { throw "npm installation failed (exit $LASTEXITCODE)." } + $version = Get-OpenCodeVersion + if ($version) { Write-Host " OK OpenCode $version"; return } + throw 'OpenCode 1.x could not be verified after npm installation. Check PATH and restart PowerShell.' + } + throw 'A working OpenCode 1.x executable is required on PATH. Run opencode --version, or rerun .\install.ps1 -InstallOpenCode. OpenCode 2 is not supported.' +} +function Show-ConfigHint { + $jsonPath = Join-Path $config 'opencode.json' + if ([IO.File]::Exists($jsonPath) -and [IO.File]::ReadAllText($jsonPath) -match '"default_agent"\s*:\s*"orchestrator"') { + Write-Host ' OK opencode.json: default_agent is orchestrator' + return + } + Write-Host "Add these recommended settings to $jsonPath (merge with your existing configuration):" + [ordered]@{ + '$schema' = 'https://opencode.ai/config.json' + default_agent = 'orchestrator' + instructions = @((Join-Path $config 'team/AGENTS.md').Replace('\', '/'), (Join-Path $config 'team.yaml').Replace('\', '/')) + } | ConvertTo-Json -Depth 3 | Write-Host +} +function Assert-CoreEntry([string]$Name) { + $entry = Get-Entry (Join-Path $config $Name) + if ($null -ne $entry -and -not (Test-Owned $entry)) { + throw "Core entry $Name already exists locally or points outside this repo. Left untouched: $($entry.FullName)" + } +} +function Install-Link([string]$Name, [bool]$Skill = $false) { + $path = Join-Path $config $Name + $target = Join-Path $repo $Name + $entry = Get-Entry $path + if ($null -ne $entry) { + if (-not (Test-Owned $entry)) { + if ($Skill) { Write-Host " SKIP $Name (local or externally managed)"; return } + throw "Existing entry is not managed by this repo: $path" + } + if ((Get-LinkTarget $entry) -eq $target) { Write-Host " OK $Name (already linked)"; return } + Remove-Link $entry + } + New-Item -ItemType SymbolicLink -Path $path -Target $target | Out-Null + Write-Host " OK $Name -> $target" +} + +try { + # Validate parents in every mode, including uninstall, before traversing them. + Assert-RealParents $config + Assert-RealParents (Join-Path $config 'skills') + Assert-RealParents (Join-Path $config 'team') + if ($Uninstall) { + $names = @($core) + @($skills | ForEach-Object { 'skills/' + $_.Name }) + # Also remove managed skills no longer present in the current checkout. + if (Test-Path -LiteralPath (Join-Path $config 'skills')) { + $names += @(Get-ChildItem -LiteralPath (Join-Path $config 'skills') -Force | Where-Object { $_.Name -like 'team-*' } | ForEach-Object { 'skills/' + $_.Name }) + } + foreach ($name in $names | Select-Object -Unique) { + $entry = Get-Entry (Join-Path $config $name) + if (Test-Owned $entry) { Remove-Link $entry; Write-Host " OK removed $name" } + } + Write-Host 'opencode.json was not modified.' + exit 0 + } + Require-OpenCode + if ($Check) { + $ok = $true + foreach ($name in $core) { + $entry = Get-Entry (Join-Path $config $name) + if ((Test-Link $entry) -and (Get-LinkTarget $entry) -eq (Join-Path $repo $name) -and (Test-Path -LiteralPath (Join-Path $repo $name))) { + Write-Host " OK $name" + } else { Write-Host " MISSING or wrong target: $name"; $ok = $false } + } + foreach ($skill in $skills) { + $name = 'skills/' + $skill.Name + $entry = Get-Entry (Join-Path $config $name) + if (-not (Test-AvailableEntry $entry)) { Write-Host " MISSING $name"; $ok = $false } + elseif ((Test-Owned $entry) -and ((Get-LinkTarget $entry) -ne $skill.FullName -or -not (Test-Path -LiteralPath $entry.FullName))) { + Write-Host " WRONG target: $name"; $ok = $false + } else { Write-Host " OK $name (local/external entries are preserved)" } + } + Show-ConfigHint + if (-not $ok) { exit 1 } + exit 0 + } + foreach ($name in $core) { Assert-CoreEntry $name } + [IO.Directory]::CreateDirectory($config) | Out-Null + # Probe before replacing or creating any installation links. + $probe = Join-Path $config ('.opencode-team-link-probe-' + [guid]::NewGuid().ToString('N')) + try { + New-Item -ItemType SymbolicLink -Path $probe -Target (Join-Path $repo 'team.yaml') | Out-Null + } catch { + throw 'Cannot create symbolic links. Run this installer from an Administrator PowerShell, or enable Windows Developer Mode and use PowerShell 7. No installation links were changed.' + } finally { if ($null -ne (Get-Entry $probe)) { [IO.File]::Delete($probe) } } + [IO.Directory]::CreateDirectory((Join-Path $config 'skills')) | Out-Null + [IO.Directory]::CreateDirectory((Join-Path $config 'team')) | Out-Null + foreach ($name in $core) { Install-Link $name } + foreach ($skill in $skills) { Install-Link ('skills/' + $skill.Name) $true } + Show-ConfigHint + Write-Host 'Restart OpenCode. opencode.json was not modified.' +} catch { + [Console]::Error.WriteLine($_.Exception.Message) + exit 1 +} diff --git a/scripts/smoke-install.ps1 b/scripts/smoke-install.ps1 new file mode 100644 index 0000000..1c99cbd --- /dev/null +++ b/scripts/smoke-install.ps1 @@ -0,0 +1,224 @@ +# Dependency-free Windows installer integration tests. Never uses user config. +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repo = Split-Path $PSScriptRoot -Parent +$installer = Join-Path $repo 'install.ps1' +$shell = (Get-Process -Id $PID).Path +$work = Join-Path ([IO.Path]::GetTempPath()) ('opencode-team-install-' + [guid]::NewGuid().ToString('N')) +$oldPath = $env:PATH +$oldConfig = $env:OPENCODE_CONFIG_DIR +$oldProfile = $env:USERPROFILE +$utf8 = New-Object Text.UTF8Encoding($false) +$script:assertions = 0 + +function Assert($Condition, [string]$Message) { + if (-not $Condition) { throw "FAIL: $Message" } + $script:assertions++ +} +function Write-Text([string]$Path, [string]$Text) { + [IO.Directory]::CreateDirectory((Split-Path $Path -Parent)) | Out-Null + [IO.File]::WriteAllText($Path, $Text, $utf8) +} +function Remove-Fixture([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + if ($null -eq $item) { return } + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + if ($item.PSIsContainer) { [IO.Directory]::Delete($Path) } else { [IO.File]::Delete($Path) } + } elseif ($item.PSIsContainer) { + foreach ($child in Get-ChildItem -LiteralPath $Path -Force) { Remove-Fixture $child.FullName } + [IO.Directory]::Delete($Path) + } else { [IO.File]::Delete($Path) } +} +function Run-Installer([string]$Config, [string[]]$Arguments = @(), [bool]$Success = $true, [string]$StartDirectory = '') { + $env:OPENCODE_CONFIG_DIR = $Config + $start = New-Object Diagnostics.ProcessStartInfo + $start.FileName = $shell + $start.Arguments = '-NoProfile -ExecutionPolicy Bypass -File "' + $installer + '" ' + ($Arguments -join ' ') + if ($StartDirectory) { + $start.WorkingDirectory = $StartDirectory + $command = "Set-Location -LiteralPath '" + (Get-Location).Path.Replace("'", "''") + "'; `$global:LASTEXITCODE = 0; & '" + $installer.Replace("'", "''") + "' " + ($Arguments -join ' ') + '; exit $LASTEXITCODE' + $start.Arguments = '-NoProfile -ExecutionPolicy Bypass -EncodedCommand ' + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + } + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $process = [Diagnostics.Process]::Start($start) + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $output = $stdout.Result + $stderr.Result + $code = $process.ExitCode + $process.Dispose() + Assert (($code -eq 0) -eq $Success) "installer $Arguments exit $code (expected success=$Success): $output" + return ($output -join "`n") +} +function Assert-Link([string]$Path, [string]$Target) { + $item = Get-Item -LiteralPath $Path -Force + Assert ($item.LinkType -eq 'SymbolicLink') "$Path must be a symbolic link" + Assert ([IO.Path]::GetFullPath([string]@($item.Target)[0]) -eq [IO.Path]::GetFullPath($Target)) "$Path target must be $Target" +} +function Fake-Version([string]$Version = '1.19.4', [int]$Code = 0) { + Write-Text (Join-Path $work 'bin/opencode.cmd') "@echo off`r`necho $Version`r`nexit /b $Code`r`n" +} + +try { + Assert (Test-Path -LiteralPath $installer) 'install.ps1 must exist' + [IO.Directory]::CreateDirectory($work) | Out-Null + Fake-Version + $env:PATH = (Join-Path $work 'bin') + ';' + $env:SystemRoot + '\System32' + $config = Join-Path $work ('config space ' + [char]0x00F6) + $json = '{"default_agent":"orchestrator","custom":"preserve"}' + Write-Text (Join-Path $config 'opencode.json') $json + Write-Host 'install / repeat / check / uninstall' + Run-Installer $config | Out-Null + Run-Installer $config | Out-Null + foreach ($name in @('agents','commands','team.yaml','team/AGENTS.md')) { + Assert-Link (Join-Path $config $name) (Join-Path $repo $name) + } + foreach ($skill in Get-ChildItem (Join-Path $repo 'skills/team-*') -Directory) { + Assert-Link (Join-Path $config ('skills/' + $skill.Name)) $skill.FullName + } + Run-Installer $config @('-Check') | Out-Null + Assert ([IO.File]::ReadAllText((Join-Path $config 'opencode.json')) -ceq $json) 'JSON must remain byte-for-byte unchanged' + Run-Installer $config @('-Uninstall') | Out-Null + Assert (-not (Test-Path (Join-Path $config 'agents'))) 'uninstall must remove managed link' + Assert (Test-Path (Join-Path $repo 'agents/orchestrator.md')) 'uninstall must preserve target' + Assert (Test-Path (Join-Path $config 'opencode.json')) 'uninstall must preserve JSON' + Run-Installer $config @('-Check') $false | Out-Null + + Write-Host 'local skill collision and in-repository relink' + $collision = Join-Path $work 'collision' + Write-Text (Join-Path $collision 'skills/team-build/marker') 'keep' + [IO.Directory]::CreateDirectory($collision) | Out-Null + New-Item -ItemType SymbolicLink -Path (Join-Path $collision 'agents') -Target (Join-Path $repo 'team') | Out-Null + Run-Installer $collision | Out-Null + Assert-Link (Join-Path $collision 'agents') (Join-Path $repo 'agents') + Assert ([IO.File]::ReadAllText((Join-Path $collision 'skills/team-build/marker')) -eq 'keep') 'local skill must survive' + Run-Installer $collision @('-Check') | Out-Null + Run-Installer $collision @('-Uninstall') | Out-Null + Assert (Test-Path (Join-Path $collision 'skills/team-build/marker')) 'local skill must survive uninstall' + + Write-Host 'external link protection' + $external = Join-Path $work 'external' + Write-Text (Join-Path $external 'marker') 'keep' + $protected = Join-Path $work 'protected' + [IO.Directory]::CreateDirectory($protected) | Out-Null + New-Item -ItemType SymbolicLink -Path (Join-Path $protected 'agents') -Target $external | Out-Null + Run-Installer $protected @() $false | Out-Null + Run-Installer $protected @('-Uninstall') | Out-Null + Assert-Link (Join-Path $protected 'agents') $external + Assert (Test-Path (Join-Path $external 'marker')) 'external target must survive' + Assert (-not (Test-Path (Join-Path $protected 'commands'))) 'collision preflight must prevent partial installation' + + Write-Host 'ordinary core files and linked parent containers' + $ordinary = Join-Path $work 'ordinary' + Write-Text (Join-Path $ordinary 'agents/marker') 'keep' + Run-Installer $ordinary @() $false | Out-Null + Assert (Test-Path (Join-Path $ordinary 'agents/marker')) 'ordinary core directory must survive' + $parentConfig = Join-Path $work 'linked-parent' + [IO.Directory]::CreateDirectory($parentConfig) | Out-Null + New-Item -ItemType SymbolicLink -Path (Join-Path $parentConfig 'skills') -Target $external | Out-Null + Run-Installer $parentConfig @() $false | Out-Null + Run-Installer $parentConfig @('-Uninstall') $false | Out-Null + Assert (-not (Test-Path (Join-Path $external 'team-build'))) 'must not install through linked parent' + + Write-Host 'relative external links and dangling links' + $relative = Join-Path $work 'relative' + [IO.Directory]::CreateDirectory($relative) | Out-Null + # mklink supports relative directory targets in Windows PowerShell 5.1 too. + & $env:ComSpec /d /c "mklink /D `"$relative\agents`" `"..\external`"" | Out-Null + Assert ($LASTEXITCODE -eq 0) 'relative link fixture must exist' + Run-Installer $relative @() $false | Out-Null + Run-Installer $relative @('-Uninstall') | Out-Null + Assert ((Get-Item -LiteralPath (Join-Path $relative 'agents')).LinkType -eq 'SymbolicLink') 'relative external link must survive' + $dangling = Join-Path $work 'dangling' + [IO.Directory]::CreateDirectory($dangling) | Out-Null + Write-Text (Join-Path $work 'missing.yaml') 'temporary target' + New-Item -ItemType SymbolicLink -Path (Join-Path $dangling 'team.yaml') -Target (Join-Path $work 'missing.yaml') | Out-Null + [IO.File]::Delete((Join-Path $work 'missing.yaml')) + Run-Installer $dangling @() $false | Out-Null + Run-Installer $dangling @('-Uninstall') | Out-Null + Assert ($null -ne (Get-Item -LiteralPath (Join-Path $dangling 'team.yaml') -Force)) 'dangling external link must survive' + + $sibling = Join-Path $work 'prefix-sibling' + [IO.Directory]::CreateDirectory($sibling) | Out-Null + & $env:ComSpec /d /c "mklink /D `"$sibling\agents`" `"$repo-other\agents`"" | Out-Null + Assert ($LASTEXITCODE -eq 0) 'prefix sibling fixture must exist' + Run-Installer $sibling @() $false | Out-Null + Run-Installer $sibling @('-Uninstall') | Out-Null + Assert ($null -ne (Get-Item -LiteralPath (Join-Path $sibling 'agents') -Force)) 'repository-prefix sibling link must survive' + + Write-Host 'version requirements and explicit bootstrap' + $versionConfig = Join-Path $work 'versions' + foreach ($version in @('2.0.0','unparseable')) { + Fake-Version $version + Run-Installer $versionConfig @() $false | Out-Null + Run-Installer $versionConfig @('-Check') $false | Out-Null + Assert (-not (Test-Path $versionConfig)) 'bad version must not create config' + } + Fake-Version '1.19.4' 7 + Run-Installer $versionConfig @() $false | Out-Null + [IO.File]::Delete((Join-Path $work 'bin/opencode.cmd')) + Run-Installer $versionConfig @() $false | Out-Null + Run-Installer $versionConfig @('-Uninstall') | Out-Null + Run-Installer $versionConfig @('-InstallOpenCode') $false | Out-Null + Fake-Version + [IO.File]::Copy((Join-Path $work 'bin/opencode.cmd'), (Join-Path $work 'bin/installed.cmd')) + $npm = "@echo off`r`necho %* > `"%~dp0npm-args.txt`"`r`ncopy /y `"%~dp0installed.cmd`" `"%~dp0opencode.cmd`" >nul`r`nexit /b 0`r`n" + Write-Text (Join-Path $work 'bin/npm.cmd') $npm + Run-Installer $versionConfig @('-InstallOpenCode') | Out-Null + Assert (-not (Test-Path (Join-Path $work 'bin/npm-args.txt'))) 'valid 1.x must not invoke npm' + [IO.File]::Delete((Join-Path $work 'bin/opencode.cmd')) + Run-Installer $versionConfig @('-InstallOpenCode') | Out-Null + Assert (([IO.File]::ReadAllText((Join-Path $work 'bin/npm-args.txt'))).Trim() -eq 'install --global opencode-ai@1') 'bootstrap must constrain OpenCode major version' + [IO.File]::Delete((Join-Path $work 'bin/opencode.cmd')) + Write-Text (Join-Path $work 'bin/npm.cmd') "@echo off`r`nexit /b 9`r`n" + Run-Installer $versionConfig @('-InstallOpenCode') $false | Out-Null + Write-Text (Join-Path $work 'bin/npm.cmd') "@echo off`r`nexit /b 0`r`n" + Run-Installer $versionConfig @('-InstallOpenCode') $false | Out-Null + Fake-Version + Run-Installer $versionConfig @('-Check','-Uninstall') $false | Out-Null + Run-Installer $versionConfig @('-Check','-InstallOpenCode') $false | Out-Null + + Write-Host 'JSONC hint and caller working directory' + $jsoncConfig = Join-Path $work 'jsonc' + $jsonc = "{ // user comment`n `"custom`": true`n}" + Write-Text (Join-Path $jsoncConfig 'opencode.json') $jsonc + Push-Location $work + try { $hint = Run-Installer $jsoncConfig } finally { Pop-Location } + Assert ($hint -match 'default_agent') 'missing setting must print a configuration hint' + Assert ([IO.File]::ReadAllText((Join-Path $jsoncConfig 'opencode.json')) -ceq $jsonc) 'JSONC must remain unchanged' + + Write-Host 'default user profile and wrong or unavailable skill targets' + $env:USERPROFILE = Join-Path $work 'user profile' + Run-Installer '' | Out-Null + Assert-Link (Join-Path $env:USERPROFILE '.config/opencode/agents') (Join-Path $repo 'agents') + $wrong = Join-Path $jsoncConfig 'team.yaml' + [IO.File]::Delete($wrong) + New-Item -ItemType SymbolicLink -Path $wrong -Target (Join-Path $repo 'README.md') | Out-Null + Run-Installer $jsoncConfig @('-Check') $false | Out-Null + Run-Installer $jsoncConfig | Out-Null + $brokenSkill = Join-Path $jsoncConfig 'skills/team-build' + [IO.Directory]::Delete($brokenSkill) + $skillTarget = Join-Path $work 'external-skill' + [IO.Directory]::CreateDirectory($skillTarget) | Out-Null + New-Item -ItemType SymbolicLink -Path $brokenSkill -Target $skillTarget | Out-Null + [IO.Directory]::Delete($skillTarget) + Run-Installer $jsoncConfig @('-Check') $false | Out-Null + + Write-Host 'relative config override after Set-Location' + $relativeRoot = Join-Path $work 'changed-location' + $relativeProject = Join-Path $relativeRoot 'project' + Write-Text (Join-Path $relativeRoot 'config/skills') 'outside marker' + [IO.Directory]::CreateDirectory((Join-Path $relativeProject 'config')) | Out-Null + Push-Location $relativeProject + try { Run-Installer 'config' @('-Uninstall') $true $relativeRoot | Out-Null } finally { Pop-Location } + Assert ([IO.File]::ReadAllText((Join-Path $relativeRoot 'config/skills')) -eq 'outside marker') 'relative override must not use process-start config' + + Write-Host "installer smoke: OK ($script:assertions assertions)" +} finally { + $env:PATH = $oldPath + $env:OPENCODE_CONFIG_DIR = $oldConfig + $env:USERPROFILE = $oldProfile + if (Test-Path -LiteralPath $work) { Remove-Fixture $work } +} diff --git a/scripts/test-memory-compact.ps1 b/scripts/test-memory-compact.ps1 new file mode 100644 index 0000000..b4f15d2 --- /dev/null +++ b/scripts/test-memory-compact.ps1 @@ -0,0 +1,161 @@ +# Dependency-free native memory regression tests. No user memory is touched. +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repo = Split-Path $PSScriptRoot -Parent +$helper = Join-Path $repo 'skills/team-memory/memory-compact.ps1' +$shell = (Get-Process -Id $PID).Path +$work = Join-Path ([IO.Path]::GetTempPath()) ('opencode-team-memory-' + [guid]::NewGuid().ToString('N')) +$oldMemory = $env:MEMORY_DIR +$utf8 = New-Object Text.UTF8Encoding($false) +$script:assertions = 0 +function Assert($Condition, [string]$Message) { + if (-not $Condition) { throw "FAIL: $Message" } + $script:assertions++ +} +function Write-Lines([string]$Path, [string[]]$Lines) { + [IO.Directory]::CreateDirectory((Split-Path $Path -Parent)) | Out-Null + [IO.File]::WriteAllLines($Path, $Lines, $utf8) +} +function Entries([int]$Start, [int]$End, [string]$Month = '2026-10') { + for ($i = $Start; $i -le $End; $i++) { '- {0}-{1:00} | research | fact | entry {2} | artifact' -f $Month, (($i - 1) % 28 + 1), $i } +} +function Run-Compactor([string]$Memory, [string]$Role = '', [bool]$Success = $true, [string]$StartDirectory = '') { + $env:MEMORY_DIR = $Memory + $start = New-Object Diagnostics.ProcessStartInfo + $start.FileName = $shell + $start.WorkingDirectory = (Get-Location).Path + $start.Arguments = '-NoProfile -ExecutionPolicy Bypass -File "' + $helper + '"' + if ($Role) { $start.Arguments += ' -Role "' + $Role + '"' } + if ($StartDirectory) { + $start.WorkingDirectory = $StartDirectory + $command = "Set-Location -LiteralPath '" + (Get-Location).Path.Replace("'", "''") + "'; `$global:LASTEXITCODE = 0; & '" + $helper.Replace("'", "''") + "'" + if ($Role) { $command += " -Role '" + $Role.Replace("'", "''") + "'" } + $command += '; exit $LASTEXITCODE' + $start.Arguments = '-NoProfile -ExecutionPolicy Bypass -EncodedCommand ' + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + } + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $process = [Diagnostics.Process]::Start($start) + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $output = $stdout.Result + $stderr.Result + $code = $process.ExitCode + $process.Dispose() + Assert (($code -eq 0) -eq $Success) "compactor exit $code (expected success=$Success): $output" +} +function Count-Entries([string]$Path) { return @([IO.File]::ReadAllLines($Path) | Where-Object { $_ -match '^-\s' }).Count } +function Remove-Fixture([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + if ($null -eq $item) { return } + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + if ($item.PSIsContainer) { [IO.Directory]::Delete($Path) } else { [IO.File]::Delete($Path) } + } elseif ($item.PSIsContainer) { + foreach ($child in Get-ChildItem -LiteralPath $Path -Force) { Remove-Fixture $child.FullName } + [IO.Directory]::Delete($Path) + } else { [IO.File]::Delete($Path) } +} +try { + Assert (Test-Path -LiteralPath $helper) 'memory-compact.ps1 must exist' + $memory = Join-Path $work ('project space ' + [char]0x00F6) + $log = Join-Path $memory 'log/analyst.md' + Write-Host 'empty logs and 40-entry boundary' + Write-Lines $log @('# analyst log', '') + Run-Compactor $memory 'analyst' + Write-Lines $log (@('# analyst log', '') + @(Entries 1 40)) + $before = [IO.File]::ReadAllText($log) + Run-Compactor $memory 'analyst' + Assert ([IO.File]::ReadAllText($log) -ceq $before) '40 entries must remain unchanged' + + Write-Host 'archive 35 / retain 10 and repeated monthly archive' + Write-Lines $log (@('# analyst log', '') + @(Entries 1 45)) + Run-Compactor $memory 'analyst' + $archive = Join-Path $memory 'archive/analyst-2026-10.md' + Assert ((Count-Entries $log) -eq 10) '45 entries must retain last 10' + Assert ((Count-Entries $archive) -eq 35) '45 entries must archive first 35' + Assert (([IO.File]::ReadAllLines($log))[-1] -match 'entry 45 \|') 'log order must be retained' + [IO.File]::AppendAllLines($log, [string[]]@(Entries 46 80), $utf8) + Run-Compactor $memory 'analyst' + Assert ((Count-Entries $archive) -eq 70) 'second run must append 35 entries' + Assert (@([IO.File]::ReadAllLines($archive) | Where-Object { $_ -match '^# analyst archive' }).Count -eq 1) 'archive header must not duplicate' + Run-Compactor $memory 'analyst' + Assert ((Count-Entries $archive) -eq 70) 'no-op rerun must not duplicate entries' + + Write-Host 'multiple months, CRLF, Unicode, role filter' + $unicode = 'T' + [char]0x00FC + 'rk' + [char]0x00E7 + 'e' + $lines = @('# analyst log', '') + @(Entries 1 20 '2026-09') + @(Entries 21 41) + $lines[2] += ' ' + $unicode + [IO.File]::WriteAllText($log, ($lines -join "`r`n"), $utf8) + Write-Lines (Join-Path $memory 'log/pm.md') (@('# pm log', '') + @(Entries 1 41)) + Run-Compactor $memory 'analyst' + Assert ((Count-Entries (Join-Path $memory 'archive/analyst-2026-09.md')) -eq 20) 'September entries must use September archive' + Assert ([IO.File]::ReadAllText((Join-Path $memory 'archive/analyst-2026-09.md')).Contains($unicode)) 'Unicode must survive compaction' + Assert ((Count-Entries (Join-Path $memory 'log/pm.md')) -eq 41) 'explicit role must not compact another role' + Run-Compactor $memory + Assert ((Count-Entries (Join-Path $memory 'log/pm.md')) -eq 10) 'no role must compact all known roles' + + Write-Host 'index cap, stable exact deduplication, unchanged failure' + $index = Join-Path $memory 'INDEX.md' + Write-Lines $index ([string[]](1..60 | ForEach-Object { "fact $_" })) + $before = [IO.File]::ReadAllText($index) + Run-Compactor $memory + Assert ([IO.File]::ReadAllText($index) -ceq $before) '60-line index must remain unchanged' + $lines = @('# Memory Index', 'Fact', 'fact', $unicode) + @((1..65) | ForEach-Object { 'duplicate' }) + Write-Lines $index $lines + Run-Compactor $memory + Assert ([IO.File]::ReadAllLines($index).Count -eq 5) 'only exact duplicates must be removed' + Assert (([IO.File]::ReadAllLines($index))[1] -ceq 'Fact') 'first occurrence order must remain' + Write-Lines $index ([string[]](1..61 | ForEach-Object { "unique fact $_" })) + $before = [IO.File]::ReadAllText($index) + Run-Compactor $memory '' $false + Assert ([IO.File]::ReadAllText($index) -ceq $before) 'unique oversized index must remain unchanged' + [IO.File]::Delete($index) + + Write-Host 'default current-project directory and custom memory root' + $project = Join-Path $work 'cwd' + $cwdLog = Join-Path $project '.team/memory/log/analyst.md' + Write-Lines $cwdLog (@('# analyst log', '') + @(Entries 1 41)) + Push-Location $project + try { Run-Compactor '' 'analyst' } finally { Pop-Location } + Assert ((Count-Entries $cwdLog) -eq 10) 'default memory path must use target project CWD' + + Write-Host 'explicit custom role' + $customLog = Join-Path $memory 'log/researcher.md' + Write-Lines $customLog (@('# researcher log', '') + @(Entries 1 41)) + Run-Compactor $memory 'researcher' + Assert ((Count-Entries $customLog) -eq 10) 'explicit safe custom roles must work like the Bash helper' + + Write-Host 'relative memory override after Set-Location' + $relativeRoot = Join-Path $work 'changed-location' + $relativeProject = Join-Path $relativeRoot 'project' + $outsideIndex = Join-Path $relativeRoot 'memory/INDEX.md' + Write-Lines $outsideIndex ([string[]](1..61 | ForEach-Object { "outside fact $_" })) + $relativeLog = Join-Path $relativeProject 'memory/log/analyst.md' + Write-Lines $relativeLog (@('# analyst log', '') + @(Entries 1 41)) + Write-Lines (Join-Path $relativeProject 'memory/INDEX.md') @('current project') + Push-Location $relativeProject + try { Run-Compactor 'memory' '' $true $relativeRoot } finally { Pop-Location } + Assert ((Count-Entries $relativeLog) -eq 10) 'relative memory must use current PowerShell location' + Assert ([IO.File]::ReadAllLines($outsideIndex).Count -eq 61) 'process-start memory must remain untouched' + + Write-Host 'unsafe role/date and linked memory protection' + Run-Compactor $memory '../outside' $false + $badLines = @('# analyst log', '') + @(Entries 1 41) + $badLines[2] = '- ../escape | research | fact | bad date' + Write-Lines $log $badLines + $before = [IO.File]::ReadAllText($log) + Run-Compactor $memory 'analyst' $false + Assert ([IO.File]::ReadAllText($log) -ceq $before) 'unsafe archive date must not change log' + $outside = Join-Path $work 'outside' + Write-Lines (Join-Path $outside 'analyst.md') (@('# analyst log', '') + @(Entries 1 41)) + $linkedMemory = Join-Path $work 'linked' + [IO.Directory]::CreateDirectory($linkedMemory) | Out-Null + New-Item -ItemType SymbolicLink -Path (Join-Path $linkedMemory 'log') -Target $outside | Out-Null + Run-Compactor $linkedMemory 'analyst' $false + Assert ((Count-Entries (Join-Path $outside 'analyst.md')) -eq 41) 'linked log container must not change outside memory' + Write-Host "memory-compact: OK ($script:assertions assertions)" +} finally { + $env:MEMORY_DIR = $oldMemory + if (Test-Path -LiteralPath $work) { Remove-Fixture $work } +} diff --git a/skills/team-memory/SKILL.md b/skills/team-memory/SKILL.md index 0c46db8..dcaf38c 100644 --- a/skills/team-memory/SKILL.md +++ b/skills/team-memory/SKILL.md @@ -231,7 +231,16 @@ Example: ## Compaction -Run at a gate, with no subagents running. From the project root, the orchestrator runs the installed `~/.config/opencode/skills/team-memory/memory-compact.sh` for the mechanical parts (repo contributors can run `skills/team-memory/memory-compact.sh`). +Run at a gate, with no subagents running, from the target project root. Resolve the installed config root from `OPENCODE_CONFIG_DIR`, otherwise `~/.config/opencode`. Native Windows uses `skills/team-memory/memory-compact.ps1` under that config root; macOS/Linux use `skills/team-memory/memory-compact.sh`. Repo contributors can use the corresponding helper directly from this checkout. + +Native Windows, from a PowerShell session: + +```powershell +$configRoot = if ($env:OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR } else { Join-Path $env:USERPROFILE '.config/opencode' } +powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $configRoot 'skills/team-memory/memory-compact.ps1') +``` + +Both helpers default to the current project's `.team/memory`, not the installation repo. If `memory_dir` in the project/global `team.yaml` is customized, resolve it from the project root and pass it via `MEMORY_DIR` (PowerShell: `$env:MEMORY_DIR = ''`; Bash: `MEMORY_DIR='' `). Preserve/restore any previous environment value after invocation. An optional role argument restricts log compaction to that role while still checking the index. The Windows helper rejects linked memory paths and malformed archive dates instead of writing through them. - **Log over 40 entries:** the script moves old entries to `archive/-YYYY-MM.md` and keeps the last 10 entries. A compacted log is normally 10 entries; it grows again as new entries are appended. Do not pad it back to 40. The script does not promote facts; the orchestrator does that. - **What goes where:** the archive keeps the original line as the historical record; `INDEX.md` gets a concise fact, not a copy of the whole line. @@ -240,7 +249,7 @@ Run at a gate, with no subagents running. From the project root, the orchestrato - **Archive:** never read automatically; search only when needed. - **Another session:** do not compact while another session may be writing memory. The orchestrator cannot detect a direct invocation in another terminal. -`scripts/test-memory-compact.sh` covers the empty-log, repeated-archive, index-cap and default-project-path cases. +`scripts/test-memory-compact.sh` and the native Windows `scripts/test-memory-compact.ps1` cover empty logs, repeated archives, index caps and default project paths. The Windows suite also checks role filtering, Unicode, monthly archives and unsafe input/link protection. ## Content rules diff --git a/skills/team-memory/memory-compact.ps1 b/skills/team-memory/memory-compact.ps1 new file mode 100644 index 0000000..d9dc0b5 --- /dev/null +++ b/skills/team-memory/memory-compact.ps1 @@ -0,0 +1,97 @@ +#Requires -Version 5.1 +# Compact project memory from the target project's working directory. +[CmdletBinding()] +param([Parameter(Position = 0)][string]$Role) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$roles = @('analyst','pm','po','ux','brand','architect','fe','be','qa','security','docs','devops','marketing','orchestrator') +$memory = if ($env:MEMORY_DIR) { $env:MEMORY_DIR } else { Join-Path (Get-Location).Path '.team/memory' } +$memory = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($memory) +$memory = [IO.Path]::GetFullPath($memory).TrimEnd('\', '/') +$utf8 = New-Object Text.UTF8Encoding($false) + +function Assert-SafePath([string]$Path) { + $full = [IO.Path]::GetFullPath($Path) + if ($full -ne $memory -and -not $full.StartsWith($memory + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw "Path escapes the memory directory: $Path" + } + # Reparse points could redirect writes outside the selected memory tree. + $current = $full + while ($current) { + $item = Get-Item -LiteralPath $current -Force -ErrorAction SilentlyContinue + if ($null -ne $item -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Linked memory paths are not supported: $current" + } + $parent = [IO.Path]::GetDirectoryName($current) + if ($parent -eq $current) { break } + $current = $parent + } +} +function Write-MemoryLines([string]$Path, [string[]]$Lines) { + Assert-SafePath $Path + $temporary = $Path + '.' + [guid]::NewGuid().ToString('N') + '.tmp' + try { + [IO.File]::WriteAllLines($temporary, $Lines, $utf8) + if ([IO.File]::Exists($Path)) { [IO.File]::Replace($temporary, $Path, [NullString]::Value) } + else { [IO.File]::Move($temporary, $Path) } + } finally { if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } } +} +function Compact-Log([string]$Name) { + $log = Join-Path $memory ('log/' + $Name + '.md') + Assert-SafePath $log + if (-not [IO.File]::Exists($log)) { return } + $lines = [IO.File]::ReadAllLines($log) + $entries = @($lines | Where-Object { $_ -match '^-\s' }) + if ($entries.Count -le 40) { return } + $archiveCount = $entries.Count - 10 + $groups = [ordered]@{} + for ($i = 0; $i -lt $archiveCount; $i++) { + $entry = $entries[$i] + $date = [datetime]::MinValue + if ($entry -notmatch '^-\s+(\d{4}-\d{2}-\d{2})\s+\|' -or + -not [datetime]::TryParseExact($Matches[1], 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$date)) { + throw "Invalid archive date in $Name log; log was left unchanged." + } + $month = $date.ToString('yyyy-MM', [Globalization.CultureInfo]::InvariantCulture) + if (-not $groups.Contains($month)) { $groups[$month] = New-Object 'Collections.Generic.List[string]' } + $groups[$month].Add($entry) + } + $archiveDir = Join-Path $memory 'archive' + Assert-SafePath $archiveDir + foreach ($month in $groups.Keys) { Assert-SafePath (Join-Path $archiveDir ("$Name-$month.md")) } + [IO.Directory]::CreateDirectory($archiveDir) | Out-Null + foreach ($month in $groups.Keys) { + $archive = Join-Path $archiveDir ("$Name-$month.md") + $archived = if ([IO.File]::Exists($archive)) { @([IO.File]::ReadAllLines($archive)) } else { @("# $Name archive $([char]0x2014) $month", '') } + Write-MemoryLines $archive ([string[]]($archived + $groups[$month].ToArray())) + } + $retained = @($lines | Select-Object -First 2) + @($entries[$archiveCount..($entries.Count - 1)]) + Write-MemoryLines $log ([string[]]$retained) + Write-Host "compacted ${Name}: archived $archiveCount entries, kept 10" +} +function Compact-Index { + $index = Join-Path $memory 'INDEX.md' + Assert-SafePath $index + if (-not [IO.File]::Exists($index)) { return } + $lines = [IO.File]::ReadAllLines($index) + if ($lines.Count -le 60) { return } + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $unique = @($lines | Where-Object { $seen.Add($_) }) + if ($unique.Count -gt 60) { + throw "INDEX.md is $($unique.Count) lines after removing duplicates; merge facts, move detail to a decision file, or shorten open questions, then rerun. Index was left unchanged." + } + Write-MemoryLines $index ([string[]]$unique) + Write-Host "compacted INDEX.md: $($lines.Count) -> $($unique.Count) lines" +} +try { + if ($Role -and ($Role -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_-]*$' -or $Role -match '^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$')) { + throw "Unsafe role name: $Role. Use letters, digits, underscores or hyphens and avoid Windows device names." + } + Assert-SafePath $memory + $selected = if ($Role) { @($Role) } else { $roles } + foreach ($name in $selected) { Compact-Log $name } + Compact-Index +} catch { + [Console]::Error.WriteLine($_.Exception.Message) + exit 1 +} diff --git a/team/AGENTS.md b/team/AGENTS.md index 7cb166b..b8763b9 100644 --- a/team/AGENTS.md +++ b/team/AGENTS.md @@ -66,7 +66,7 @@ Every stage writes one markdown file into `.team/`: ## Precedence 1. `./team.yaml` in the project root (project-specific override) -2. `~/.config/opencode/team.yaml` (global default) +2. `$OPENCODE_CONFIG_DIR/team.yaml` when configured; otherwise `~/.config/opencode/team.yaml` (global default, including native Windows) ## Conventions @@ -79,4 +79,6 @@ Every stage writes one markdown file into `.team/`: `./install.sh` links the global directories to this repo. It never modifies `~/.config/opencode/opencode.json`; when the recommended settings are missing it prints the snippet to add. `./install.sh --install-opencode` optionally installs OpenCode 1.x first. +On native Windows, use `.\install.ps1` with Windows PowerShell 5.1 or PowerShell 7. Run an Administrator PowerShell, or use PowerShell 7 with Developer Mode enabled for symbolic-link privileges. `-Check`, `-Uninstall`, and `-InstallOpenCode` are the Windows switches. The config directory is `%USERPROFILE%\.config\opencode` unless `OPENCODE_CONFIG_DIR` is set. Neither installer edits `opencode.json`. + All stage playbooks are local under `skills/team-*` and referenced by `team.yaml`; no external skill is required. `team-memory` is a support skill, not a pipeline stage. `team-build`, `team-backlog` and `team-brand` are adapted from MIT-licensed upstream skills — see `THIRD_PARTY_NOTICES.md`.