diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2c256a..523622b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ permissions: jobs: validate: + name: validate (ubuntu-latest) runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b18 # v7.0.1 @@ -19,3 +20,23 @@ jobs: cache: npm - run: npm ci - run: npm run check + + # Round-4 review: ".cmd/.bat 分支本轮没有真实 Windows 证据". + # The R4-1 (case-distinct), R4-2 (resolveProgram directory + # rejection), and R4-3 (probeVersion resolved-path) tests are + # POSIX-gated and were previously only run on ubuntu-latest. The + # R4-4 test exercises the .cmd / .bat / PATHEXT branch on real + # Windows. This matrix ensures the .cmd / .bat code path is + # validated by an actual Windows runner, not just a reviewer's + # local machine. + validate-windows: + name: validate (windows-latest) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b18 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run check diff --git a/.github/workflows/mcode-island-windows.yml b/.github/workflows/mcode-island-windows.yml new file mode 100644 index 0000000..79b363d --- /dev/null +++ b/.github/workflows/mcode-island-windows.yml @@ -0,0 +1,313 @@ +name: mcode-island (windows-latest) + +on: + pull_request: + paths: + - 'plugins/antianqi/mcode-island/**' + - '.github/workflows/mcode-island-windows.yml' + push: + branches: [main] + paths: + - 'plugins/antianqi/mcode-island/**' + - '.github/workflows/mcode-island-windows.yml' + +# Round-5 review (hetaoBackend, 2026-08-28T08:22:25Z) on commit 38413d9: +# "The remaining blocker is executable platform evidence. This is a +# Windows/PowerShell/WPF/Win32 plugin ... but the PR adds no workflow +# and this head has no Actions run. The Node smoke is static and does +# not execute the PowerShell scripts. Please add a windows-latest job +# that at minimum parses all `.ps1` files and exercises token set/show/clear +# in an isolated data directory, mocked usage-API behavior, and hook +# stdin/stdout paths without opening the real UI." +# +# This workflow exercises those four contract surfaces on windows-latest +# without requiring a desktop session, a real OAuth token, or a real +# network round-trip to api.minimaxi.com. It does not open the WPF UI +# (no explorer.exe, no logon session) and does not run the +# mcode-status-detect.ps1 main loop (which would block for 60s+ in +# CI and require a real mcode install). The detector's 5h usage path +# is exercised in step 4 by re-using the same token + URL the detector +# uses, pointed at a localhost HttpListener (in a Start-Job, sync +# wait) that records the Authorization header and returns a synthetic +# model_remains JSON. +# +# `[code]smith` is SKIPPED on this repository, so this windows-latest +# job is the CI evidence for the round-5 review. + +permissions: + contents: read + +jobs: + mcode-island-windows: + name: mcode-island on windows-latest (parse + token + hook + mock-API) + runs-on: windows-latest + timeout-minutes: 10 + defaults: + run: + shell: pwsh + steps: + - name: Checkout + uses: actions/checkout@v4 + + # 1) Parse all .ps1 files. Static syntax check; if any .ps1 + # fails to parse, CI fails. A future change that introduces + # a PowerShell syntax error anywhere in the plugin (main + # script, hooks/scripts/*.ps1, set-token, notify-island, + # detector, ...) will fail this step. Negative-injection: + # try adding a stray `}` to any .ps1 and this step fails. + - name: Parse all .ps1 files (round-5 requirement #1) + run: | + $root = Resolve-Path 'plugins/antianqi/mcode-island' + $files = @(Get-ChildItem -Path $root -Recurse -Filter *.ps1) + if ($files.Count -eq 0) { throw "No .ps1 files found under $root" } + Write-Host "Parsing $($files.Count) .ps1 files under $root..." + $bad = 0 + foreach ($f in $files) { + $errs = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errs) + if ($errs -and $errs.Count -gt 0) { + $rel = $f.FullName.Substring($root.Path.Length + 1) -replace '\\', '/' + Write-Host "PARSE FAIL: $rel" + $errs | ForEach-Object { + Write-Host " line $($_.Extent.StartLineNumber):col $($_.Extent.StartColumnNumber) $($_.Message)" + } + $bad++ + } + } + if ($bad -gt 0) { throw "$bad / $($files.Count) .ps1 files failed to parse" } + Write-Host "OK: $($files.Count) .ps1 files parsed without syntax errors" + + # 2) Token set/show/clear in an isolated data directory. We + # redirect $env:APPDATA in **this step's process** (not + # just to GITHUB_ENV for future steps), so the + # set-token.ps1 invocations below write into + # $RUNNER_TEMP\mcode-island-apphome\ instead of the + # runner's real APPDATA. The detector's + # $APPDATA\mcode-island\config.json path is followed + # exactly; only the root is swapped. + - name: Token set / show / clear roundtrip in isolated APPDATA (round-5 requirement #2) + env: + FAKE_TOKEN: 'ci-fake-oauth-token-1234567890abcdef' + run: | + # Force UTF-8 in the parent so the round-trip + # parent.write -> child.stdout -> child.Console -> + # parent.capture chain survives the + # PowerShell-5.1-on-non-UTF-8-system codepage trap + # (children inherit the parent's [Console]::OutputEncoding + # at process start; if parent is cp1252/GBK and child + # sets UTF-8 internally, captured strings can be + # truncated on the way back). + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $OutputEncoding = [System.Text.Encoding]::UTF8 + try { chcp 65001 | Out-Null } catch {} + + $apphome = New-Item -ItemType Directory -Path (Join-Path $env:RUNNER_TEMP 'mcode-island-apphome') -Force + $env:APPDATA = $apphome.FullName + # Also export to GITHUB_ENV so step 3 (hook) and step 4 + # (mock usage-API) inherit the same isolated APPDATA. + "APPDATA=$($apphome.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "Isolated APPDATA: $($apphome.FullName)" + + # Defensive: unset any pre-existing MINIMAX_OAUTH_TOKEN + # / MINIMAX_API_KEY so the set-token show step below + # must report the config.json source (its fallback + # contract). GitHub Actions does not export these by + # default, but a future PR could add a workflow-level + # env: that pollutes this test. Step 4 sets + # MINIMAX_OAUTH_TOKEN explicitly for its own test. + foreach ($name in 'MINIMAX_OAUTH_TOKEN', 'MINIMAX_API_KEY') { + if (Test-Path "env:$name") { Remove-Item "env:$name" -ErrorAction SilentlyContinue } + } + + $set = 'plugins/antianqi/mcode-island/set-token.ps1' + + # 2a) set: write token + $r1 = (& $set $env:FAKE_TOKEN | Out-String).Trim() + if ($r1 -notmatch '^已写入') { throw "set: expected '已写入' header, got: $r1" } + $cfgFile = Join-Path $apphome.FullName 'mcode-island\config.json' + if (-not (Test-Path $cfgFile)) { throw "set: $cfgFile not written" } + $cfg = Get-Content $cfgFile -Raw | ConvertFrom-Json + if ($cfg.planApiToken -ne $env:FAKE_TOKEN) { + throw "set: config.json planApiToken mismatch (got: $($cfg.planApiToken))" + } + + # 2b) show: verify it reports the config.json source + masked prefix + $r2 = (& $set -Show | Out-String).Trim() + if ($r2 -notmatch 'config\.json planApiToken') { + throw "show after set: expected 'config.json planApiToken', got: $r2" + } + $expectedMask = ($env:FAKE_TOKEN).Substring(0, [Math]::Min(10, $env:FAKE_TOKEN.Length)) + '\.\.\.' + if ($r2 -notmatch $expectedMask) { + throw "show after set: expected masked prefix matching '$expectedMask', got: $r2" + } + + # 2c) clear: remove token + $r3 = (& $set -Clear | Out-String).Trim() + if ($r3 -notmatch '已从 config\.json 删除') { throw "clear: expected '已从 config.json 删除', got: $r3" } + $cfgAfter = Get-Content $cfgFile -Raw | ConvertFrom-Json + if ($cfgAfter.PSObject.Properties['planApiToken']) { + throw "clear: planApiToken still present in config.json" + } + + # 2d) show: verify it reports the unconfigured state + $r4 = (& $set -Show | Out-String).Trim() + if ($r4 -ne 'token 未配置') { throw "show after clear: expected 'token 未配置', got: $r4" } + + Write-Host "Token set/show/clear roundtrip OK (4 / 4 checks)" + + # 3) Hook stdin/stdout: pipe a synthetic PreToolUse event into + # the bundled io.minimax.mcode/hooks/scripts/pre-tool-use.ps1 + # hook entry. The hook reads the JSON event from stdin + # (_lib.ps1 Read-HookStdin), formats the tool summary, and + # pushes a `working` state to status.json via notify-island. + # The push is asserted by reading back + # $APPDATA\mcode-island\status.json (the same path the + # WPF widget polls at runtime). Negative-injection: change + # pre-tool-use.ps1 to push a wrong state, this step fails. + # + # PowerShell 5.1 caveat: `$string | & script.ps1` does + # NOT rewire the child process's stdin; only stdout/stderr + # cross the pipeline. We must launch the hook as a real + # child process with an explicit -RedirectStandardInput + # so [Console]::In.ReadToEnd() inside the hook sees the + # JSON. The CI's isolated APPDATA (set in step 2) is + # inherited via $env:APPDATA below. + - name: Hook stdin / stdout (PreToolUse) writes status.json (round-5 requirement #4) + run: | + $hook = 'plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1' + $stdinFile = Join-Path $env:RUNNER_TEMP 'hook-stdin-pretooluse.json' + # Build the JSON as a single-line PowerShell single-quoted + # string instead of using a here-doc. A here-doc (`@'...'@`) + # in this `run: |` YAML block triggered a YAML parse error + # in the v1 commit: the leading `@'` after a `run: |` block + # scalar confused js-yaml (it tried to treat `@'` as a + # block-scalar start, then ran into a `}` / `,` and a `\` + # backslash on the same line and gave up at line 187). + # The single-line string is a 1:1 content match for the + # previous here-doc body and is portable across the YAML + # parser GitHub Actions uses. + $stdinJson = '{"session_id":"ci-fake-session","transcript_path":"C:\\fake\\transcript","cwd":"C:\\fake\\cwd","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo ci-pretooluse-test"}}' + Set-Content -Path $stdinFile -Value $stdinJson -Encoding utf8 -NoNewline + + $p = Start-Process -FilePath 'powershell' ` + -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $hook) ` + -NoNewWindow -RedirectStandardInput $stdinFile ` + -PassThru + $p.WaitForExit() + if ($p.ExitCode -ne 0) { throw "pre-tool-use.ps1 exited with code $($p.ExitCode)" } + + $statusFile = Join-Path $env:APPDATA 'mcode-island\status.json' + if (-not (Test-Path $statusFile)) { throw "hook did not write $statusFile" } + $status = Get-Content $statusFile -Raw | ConvertFrom-Json + if ($status.state -ne 'working') { throw "status.state: got '$($status.state)' (want 'working')" } + if ($status.source -ne 'agent') { throw "status.source: got '$($status.source)' (want 'agent' -- hook push is agent-sourced)" } + if ($status.message -notmatch '^Bash\s*:') { throw "status.message: got '$($status.message)' (want to start with 'Bash :')" } + if ($status.message -notmatch 'ci-pretooluse-test') { + throw "status.message: got '$($status.message)' (want to contain 'ci-pretooluse-test')" + } + Write-Host "Hook PreToolUse OK: state=$($status.state) source=$($status.source) message='$($status.message)'" + + # 4) Mocked usage-API behavior. mcode-status-detect.ps1's + # Get-5hUsage function constructs the URL via the + # byte-array `_s` helper, reads the bearer token from + # $env:MINIMAX_OAUTH_TOKEN (or config.json planApiToken), + # and calls Invoke-RestMethod against api.minimaxi.com. + # The detector's main loop is not exercised (it would + # block for 60s+ and require a real mcode install); we + # instead exercise the exact same `(url, headers, token)` + # triple in this step. A Start-Job starts an + # HttpListener on a free 127.0.0.1 port and sync-waits + # for one request; the job records the Authorization + # header, returns a synthetic model_remains JSON, and + # returns the captured header + path via Receive-Job. + - name: Mocked usage-API roundtrip via local HttpListener (round-5 requirement #3) + env: + FAKE_TOKEN: 'ci-fake-oauth-token-1234567890abcdef' + run: | + # Pick a free port before starting the listener, so the + # Invoke-RestMethod in the main step can use it. + $probe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $probe.Start() + $freePort = [int]$probe.LocalEndpoint.Port + $probe.Stop() + Write-Host "Picked free port: $freePort" + + # Background job: start HttpListener, sync-wait one + # request, return the captured header + path. + $job = Start-Job -ScriptBlock { + param($port) + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add("http://127.0.0.1:$port/") + $listener.Start() + try { + $ctx = $listener.GetContext() # blocks until a request arrives + $auth = $ctx.Request.Headers['Authorization'] + $path = $ctx.Request.Url.AbsolutePath + $body = '{"model_remains":[{"model":"general","remainingPct":84,"resetMs":16200000}]}' + $bytes = [System.Text.Encoding]::UTF8.GetBytes($body) + $ctx.Response.StatusCode = 200 + $ctx.Response.ContentType = 'application/json' + $ctx.Response.ContentLength64 = $bytes.Length + $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length) + $ctx.Response.Close() + [PSCustomObject]@{ auth = $auth; path = $path } + } finally { + $listener.Stop() + $listener.Close() + } + } -ArgumentList $freePort + + try { + # 4a) Token resolution: env wins over config.json. + # Write a different token to config.json (the + # fallback path); the env var must still win. + $env:MINIMAX_OAUTH_TOKEN = $env:FAKE_TOKEN + $cfgDir = Join-Path $env:APPDATA 'mcode-island' + if (-not (Test-Path $cfgDir)) { New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null } + @{ planApiToken = 'config-token-should-not-be-used' } | ConvertTo-Json | + Out-File -FilePath (Join-Path $cfgDir 'config.json') -Encoding utf8 + + # 4b) Reconstruct the URL the detector uses (the + # source file constructs it via the byte-array + # `_s` helper, which is private to + # mcode-status-detect.ps1; we don't want to + # dot-source the file because the main loop + # would block in CI). The literal path the + # detector requests is /v1/coding_plan/remains. + $url = "http://127.0.0.1:$freePort/v1/coding_plan/remains" + $headers = @{ + 'Authorization' = "Bearer $env:MINIMAX_OAUTH_TOKEN" + 'MM-API-Source' = 'MiniMax-MCP' + } + $resp = Invoke-RestMethod -Uri $url -Headers $headers -TimeoutSec 10 -Method Get -ErrorAction Stop + + # 4c) The mock must have seen the bearer token AND + # the request path the detector uses. + $mock = $job | Wait-Job -Timeout 15 | Receive-Job + if (-not $mock) { + throw "listener job did not complete within 15s (state: $($job.State))" + } + if ($mock.auth -ne "Bearer $env:FAKE_TOKEN") { + throw "mock saw Authorization='$($mock.auth)' (want 'Bearer $env:FAKE_TOKEN')" + } + if ($mock.path -ne '/v1/coding_plan/remains') { + throw "mock saw path='$($mock.path)' (want '/v1/coding_plan/remains')" + } + + # 4d) Response shape: same one Get-5hUsage in + # mcode-status-detect.ps1 parses (model_remains[], + # taking the first entry's remainingPct + resetMs). + if (-not $resp -or -not $resp.model_remains) { + throw "response shape: missing model_remains, got: $($resp | ConvertTo-Json -Compress)" + } + $first = @($resp.model_remains)[0] + if ($first.remainingPct -ne 84 -or $first.resetMs -ne 16200000) { + throw "first model_remains entry: got remainingPct=$($first.remainingPct) resetMs=$($first.resetMs) (want 84 / 16200000)" + } + Write-Host "Mocked usage-API OK: auth='$($mock.auth)' path='$($mock.path)' first entry=remainingPct=$($first.remainingPct)% resetMs=$($first.resetMs)" + } finally { + # Make sure the job is fully reaped even on + # exception, so the listener is released. + if ($job.State -ne 'Completed') { Stop-Job $job } + Remove-Job $job -Force + } diff --git a/.github/workflows/tool-map-windows.yml b/.github/workflows/tool-map-windows.yml new file mode 100644 index 0000000..72ae5c3 --- /dev/null +++ b/.github/workflows/tool-map-windows.yml @@ -0,0 +1,84 @@ +name: tool-map (windows-latest) + +on: + pull_request: + paths: + - 'plugins/antianqi/tool-map/**' + - '.github/workflows/tool-map-windows.yml' + - 'test/tool-map.test.mjs' + push: + branches: [main] + paths: + - 'plugins/antianqi/tool-map/**' + - '.github/workflows/tool-map-windows.yml' + - 'test/tool-map.test.mjs' + # Manual dispatch: lets a maintainer / the PR author trigger the + # same windows-latest job outside a PR. Used to capture a + # github-hosted green check on the fork (the fork-to-upstream PR + # itself cannot trigger Actions without explicit maintainer + # approval, and first-time-contributor protection is on). + workflow_dispatch: + +# PR #5 round-6 review (hetaoBackend, 2026-09-01T01:24:53Z) on commit +# 6bb6a4b: "POSIX tests pass 29/29 ... The remaining blocker is +# platform evidence: the Windows/.cmd/.bat tests return early on +# non-Windows, and this head has no GitHub Actions run, so the new +# windows-latest workflow has not actually validated the +# shell/PATHEXT path. Please provide a real Windows run before merge." +# +# This workflow exercises the existing test/tool-map.test.mjs on +# windows-latest. The two test cases gated on win32 are: +# +# - "Windows: probeVersion handles the PATHEXT-expanded .CMD +# path (R4-4 real Windows evidence)" -- creates a fake +# `node.cmd` in a temp dir, sets PATH, asserts probeVersion +# resolves the .cmd shim and captures `node core` via the +# PATHEXT lookup. This is the only line of code that decides +# whether a .cmd shim routes through cmd.exe (CVE-2024-27980) or +# spawns as a normal executable. +# - "shouldUseShell agrees with shellForFile for every whitelisted +# probe that is installed" -- runs scan.mjs's `shouldUseShell` +# against the installed tools on the runner and asserts the +# decision is consistent with the resolved file extension. +# +# These two tests were SKIPPED on every previous CI run (POSIX +# runner); this workflow is what makes the round-6 "real Windows +# run" requirement reproducible in CI. The local-runnable mirror +# `plugins/antianqi/tool-map/scripts/test-windows-workflow-local.ps1` +# gives the same evidence without requiring Actions approval from +# the maintainer. +# +# `[code]smith` is SKIPPED on this repository, so this windows-latest +# job is the CI evidence for the round-6 review. + +permissions: + contents: read + +jobs: + tool-map-windows: + name: tool-map on windows-latest (.cmd/.bat / PATHEXT / shell) + runs-on: windows-latest + timeout-minutes: 10 + defaults: + run: + shell: pwsh + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Use the system Node so the .cmd / .bat PATHEXT lookup uses the + # same Node version the reviewer tested against. The runner + # images ship with Node 20.x as of 2026-09-01. + - name: Set up Node (system) + run: | + node --version + npm --version + + # Round-6 reviewer finding: ".cmd/.bat tests return early on + # non-Windows." On windows-latest the if (process.platform + # !== 'win32') return guards in the test bodies will NOT trip, + # and the R4-4 .cmd / .bat evidence will actually exercise. + - name: Run tool-map Windows test suite + run: | + cd '${{ github.workspace }}' + node --test test/tool-map.test.mjs diff --git a/plugins/antianqi/codex-harness-patterns/LICENSE b/plugins/antianqi/codex-harness-patterns/LICENSE new file mode 100644 index 0000000..87b9b48 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of tracking or otherwise improving the Work, + but excludes communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may accept and charge a + fee for, acceptance of support, warranty, indemnity, or other + liability obligations and/or rights consistent with this License. + However, in accepting such obligations, You may act only on Your + own behalf and on Your sole responsibility, not on behalf of any + other Contributor, and only if You agree to indemnify, defend, + and hold each Contributor harmless for any liability incurred by, + or claims asserted against, such Contributor by reason of your + accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 antianqi + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/antianqi/codex-harness-patterns/OVERVIEW.md b/plugins/antianqi/codex-harness-patterns/OVERVIEW.md new file mode 100644 index 0000000..2cbe4dd --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/OVERVIEW.md @@ -0,0 +1,117 @@ +# codex-harness-patterns — Plugin 总览 + +> 最后更新:2026-08-26 · **v1.0.4** · **23 Skills** +> Plugin 覆盖率 ~90%+ + +## 一句话 + +> **23 个 skill** 把 mcode 从"灵机一动"的工作流,变成 Codex 团队在生产环境验证过的、**完整 agent 生命周期**的工程化体系: +> planning → decomposition → sub-agent parallelism → execution → state tracking → tool discovery → skill/plugin authoring → memory persistence → session branching + +## 23 Skill 一览(按生命周期) + +| # | Skill | 触发条件 | 一句话 | +|---|---|---|---| +| **规划与拆解** | | | | +| 1 | `plan-stream-emit` | 复杂任务 | 先出 `todowrite` 计划,等 ack 再动 | +| 2 | `parallel-fanout` | 任务可拆 2+ 独立子任务 | 显式 spawn,opt-in,fan-out + 聚合 | +| **子代理派发** | | | | +| 3 | `delegate-with-context` | 调 `task` 派发子 agent | 4-part 信封写进 `prompt`,`agent_name` 三选一 | +| 4 | `fork-context-decision` | 调 `task` 派发 | 选 `all`/`N`/`none`,把对应内容内联到 `prompt` | +| 5 | `subagent-family-tracking` | 派发了 sub-agent | 跟踪父子线程树 Open/Closed 状态 | +| **执行与状态** | | | | +| 6 | `background-task` | 命令预期 > 30s | `task(run_in_background)` + `task_query`/`task_output`/`task_stop`,或 `bash(run_in_background)` | +| 7 | `streaming-output-reader` | 长流式输出 | bounded chunk + summary,最多 3 次读 | +| 8 | `tool-output-budget` | 工具输出过大 | token-aware head/tail/marker 截断 | +| 9 | `world-state-tracking` | 任务长到丢线索 | 持久化 world state 文件,挺过 compact | +| 10 | `context-pressure-compact` | 多步长任务,context 满 | structured snapshot,64K retention | +| **目标与成本** | | | | +| 11 | `goal-persistence` | 非平凡任务开始 | 设 goal + drift-check + 跟到 compact | +| 12 | `goal-token-budgeting` | 设了 token_budget | 50%/80%/100% 报告,跑超就停 | +| 13 | `model-router` | 子任务 / 重复任务 | cheap/medium/main 思考框架 + session-level 路由 | +| **质量保证** | | | | +| 14 | `review-mode` | 子任务完成 | 切 critic,PASS / FIX / REDO 判决 | +| 15 | `completion-audit` | 说 "done" 前 | 派生需求 + 找证据 + 逐项验 | +| **容错与接力** | | | | +| 16 | `error-recovery-strategy` | 任何失败 | retry / switch / fallback / ask / skip | +| 17 | `retry-with-backoff` | 准备重试 | 显式策略:max/base/max/jitter/budget | +| 18 | `session-handoff` | 会话结束 | 写 handoff 文件,下次 30 秒接上 | +| **新(v1.0.0)·持久化与发现** | | | | +| 19 | `long-term-memory` | 设计跨 session 记忆 | Phase 1/2 extract+consolidate+citation,git baseline | +| 20 | `skill-auto-select` | 设计可被 agent 选择的 skill | 3 层匹配 + `$name` mention + 防歧义 | +| 21 | `plugin-author-helper` | 写 marketplace plugin | manifest 格式 + 3-layer sync + idempotency | +| 22 | `tool-discovery-pattern` | 设计可被 agent 发现的 tool | defer_loading + 7-type schema + tool_suggestion | +| 23 | `session-branch-fork` | 设计 session 分支/回滚/恢复 | paginated + lineage + CAS + bounded replay | + +## 完整生命周期图 + +``` +┌──────────────────────────────────────────────────────┐ +│ 完整 agent 生命周期 │ +└──────────────────────────────────────────────────────┘ + + 输入 + │ + ├─→ 【1. plan-stream-emit】 规划:出计划 + │ + ├─→ 【2. parallel-fanout】 拆解:fork 多个子任务 + │ │ + │ ├─→ 【3. delegate-with-context】 写简报 + 信封 + │ ├─→ 【4. fork-context-decision】 选 fork_turns + │ └─→ 【5. subagent-family-tracking】 跟踪父子树 + │ + ├─→ 【6. background-task】 后台化长命令 + ├─→ 【7. streaming-output-reader】 bounded chunk 读流 + ├─→ 【8. tool-output-budget】 截断大输出 + │ + ├─→ 【9. world-state-tracking】 持久化世界状态 + ├─→ 【10. context-pressure-compact】 context 满时 snapshot + │ + ├─→ 【11. goal-persistence】 设 goal,drift check + ├─→ 【12. goal-token-budgeting】 50/80/100% 报告 + ├─→ 【13. model-router】 选 model,分 cheap/medium/main + │ + ├─→ 【14. review-mode】 切 critic,出 verdict + ├─→ 【15. completion-audit】 派生需求 + 验证 + │ + ├─→ 【16. error-recovery-strategy】 失败:retry/switch/ask + ├─→ 【17. retry-with-backoff】 显式重试策略 + │ + ├─→ 【18. session-handoff】 写 handoff + │ + ├─→ 【19. long-term-memory】 跨 session 记忆 + │ │ + │ ├─→ 【20. skill-auto-select】 选 skill + │ ├─→ 【21. plugin-author-helper】 写 plugin + │ └─→ 【22. tool-discovery-pattern】 选 tool + │ + └─→ 【23. session-branch-fork】 分支 / 回滚 / 恢复 +``` + +## 与 Codex 源码的对应 + +每个 Skill 的 frontmatter `metadata.inspired-by` 字段指向具体的 Codex 源文件。 +Plugin 覆盖率 ~90%+ — 还有 ~12 个模式因安全/UI/voice/横向对比等原因被排除。 + +详见 `codex-harness-engineering/CATALOG.md`。 + +## 版本与里程碑 + +| 版本 | 阶段 | 发布 | +|---|---|---| +| v0.1.0 - v0.5.0 | 0 | 4→14 skills | +| v0.6.0 | 0 | 18 skills | +| v0.6.2 - v0.7.5 | 1-3 | 30+ 知识笔记 + 路线图 | +| **v1.0.0** | 4 | **23 skills(当前)** | + +## 设计原则 + +每个 Skill 都遵循同一结构: +- `description` 用 4 行格式(`USE WHEN / TRIGGER PHRASES / SKIP WHEN`,EN+中文) +- "When to use" + "When NOT to use" 显式声明 +- Process 编号步骤 +- Output contract 给出契约 +- Common pitfalls 列出反模式 +- Verification checklist 供自检 + +这一致性让 LLM 能可靠选择 Skill,让维护者能审计 Skill 质量。 diff --git a/plugins/antianqi/codex-harness-patterns/PR-STATUS.md b/plugins/antianqi/codex-harness-patterns/PR-STATUS.md new file mode 100644 index 0000000..3904b4a --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/PR-STATUS.md @@ -0,0 +1,69 @@ +# PR 状态 + +> 最后更新:2026-08-26 + +## 当前状态 + +| 项 | 值 | +|---|---| +| **PR 编号** | [#18](https://github.com/MiniMax-AI/MiniMax-Code-Plugins/pull/18) | +| **PR URL** | https://github.com/MiniMax-AI/MiniMax-Code-Plugins/pull/18 | +| **目标分支** | MiniMax-AI/MiniMax-Code-Plugins:main | +| **来源分支** | antianqi/MiniMax-Code-Plugins-1:main | +| **状态** | OPEN — review: CHANGES_REQUESTED by hetaoBackend(round 1 + round 2,正在修复) | +| **当前版本** | v1.0.4 (patch: 5 Skills rewritten against mcode 0.2.4 actual `task` / `bash` schema; 23-Skill frontmatter static check added) | +| **前一版本** | v1.0.3 (patch: 4 Skill bodies corrected per reviewer #2 round 1) | +| **Plugin size** | 23 Skills(在 64 上限内)+ 1 manifest + 1 README + 1 LICENSE | +| **变更** | +xxx / -xxx 行,x 文件 | +| **静态 CI** | ⚠️ [code]smith: SKIPPED | +| **CodeQL** | 待扫 | +| **官方 review** | ⚠️ hetaoBackend (COLLABORATOR): round 1 = 3 issues, round 2 = 6 specific points under the same PR; round 2 in flight | + +## 已知 reviewer issues(2026-08-25 收到 round 1,2026-08-26 收到 round 2) + +来自 hetaoBackend 评审。 + +### Issue 1 · 文档版本不一致 +- **现状**:v1.0.2 manifest + OVERVIEW 跟历史 PR-STATUS.md / README changelog 不一致 +- **修复**:本文件已重写,统一为 v1.0.3 / 23 Skills(2026-08-26) + +### Issue 2 · Codex-only 工具参数(round 1)+ 不准确的 mcode 适配(round 2) +- **Round 1 现状**:5 个 Skill 用了 mcode 不存在的 Codex 工具参数: + - `fork_turns` / `subagent=...` / `reasoning_effort` 等 +- **Round 1 修复**: + - 1f4530c2:SKILL.md 重写,移除 Codex-only 参数,标注为 "Codex 习惯 + mcode 工具的等价为 ..." 注释 + - 72952c9(v1.0.3 amend):example 改为 mcode 实际 `task(agent_name=...)` 调用形式;同步删除 `assets/agents//agent.md` 这种 host-internal 路径(不是 public contract);该路径由 dev-only 的 `sub-agent types claimed in Skills are present in the local mcode 0.2.4 install` test 在 SKILL 维护者本机的 mcode install 上 best-effort 验证 +- **Round 2 现状**(在 `7de6d539` 上 reviewer 提出 6 个具体点): + 1. `fork-context-decision` 有重复 frontmatter block(round 1 未修干净) + 2. `fork-context-decision` example 仍含 `history=...` PLACEHOLDER + 3. `background-task` 仍含 `bash(task_name=..., run_in_background=true)` + `bash(action="kill")` 伪代码 + 4. `delegate-with-context` / `parallel-fanout` 把 task 调用形状留给读者 + 5. 必须按 mcode 实际契约重写 **或** 明确标 host-independent Codex 伪代码 + 6. 加 23-Skill frontmatter 静态检查 +- **Round 2 修复**(v1.0.4 amend): + - 通过读 `C:\Users\Administrator\.minimax-code\node_modules\@minimax-ai\code\cli.js` 直接拿到 mcode 0.2.4 `task` / `bash` / `task_query` / `task_output` / `task_stop` 的实际 schema(`cli.js:B6c` / `cli.js:xza` / `cli.js:iRt`) + - 5 个 Skill 全部用真实 mcode API 重写: + - `task(description, prompt, agent_name, run_in_background?)` — 4 个 params 都是真实 mcode 字段 + - `agent_name` 是 canonical,`agent_name=` 是运行时别名(`cli.js:j6c` normaliser) + - `mavis` 是 root agent,不是 sub-agent(没有 `agent.md` manifest);`agent_name` 只能从 `{explore, worker, verifier}` 选 + - mcode 0.2.4 没有 `history=` / `fork_turns=` / `context_size=` — 3 fork 模式通过在 `prompt` 里内联多少 prior turns 来表达 + - mcode 0.2.4 没有 per-call `model_config_id` — 模型选择是 session-level,`model-router` 重写为思考框架 + spawn gate + - 后台任务:`task(run_in_background: true)` 返回 `task_id`,用 `task_query` / `task_output` / `task_stop` 管理 + - 后台 shell:`bash(command, run_in_background: true)`,杀掉靠 host job-control API(Windows `Stop-Process`,POSIX `kill`)走**foreground** `bash` 调用 + - 新增 `test/codex-harness-patterns.test.mjs`:23-Skill frontmatter 静态检查 + 5 个 task-touching Skills 的 mcode schema pinning + +### Issue 3 · plugin-authoring / memory 写行为未声明 host 边界 +- **现状**:`plugin-author-helper` 和 `long-term-memory` 描述了网络/安装/写文件行为,未声明需 user 确认 +- **修复**(commit 6f1a6150):每个描述副作用的章节加 "需要 user 确认 / 需要 plugin runtime 支持" 前缀 + +## 修复 commit 历史 + +| commit | 内容 | +|---|---| +| 5b7f1a8c | v1.0.2:README 4 段披露 | +| 1f4530c2 | reviewer #2 round 1(伪代码 + 适配说明) | +| 6f1a6150 | reviewer #3(plugin-author-helper / long-term-memory host 边界) | +| 72952c9 | v1.0.3 amend:replayer #2 round 1(实际 `task(agent_name=...)` 语法) | +| a9f80c3 | docs:version references 1.0.2 → 1.0.3 | +| aa77b1c | fix:hardcoded path + stale frontmatter reference 清理 | +| (v1.0.4) | reviewer #2 round 2:5 Skills 按 mcode 0.2.4 真实 `task` / `bash` schema 重写;新增 23-Skill frontmatter 静态检查 | diff --git a/plugins/antianqi/codex-harness-patterns/README.md b/plugins/antianqi/codex-harness-patterns/README.md new file mode 100644 index 0000000..6436959 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/README.md @@ -0,0 +1,662 @@ +# codex-harness-patterns + +A focused collection of Skills distilled from the **OpenAI Codex harness v0.149.0** execution +model (`codex-rs/core/`). These Skills teach a MiniMax Code agent how to survive long-running +multi-step tasks without losing focus, blowing its token budget, stalling on serial work, +shipping unverified changes, burning context on bad sub-agent briefs, drifting from the +original goal, paying main-model prices for cheap-model work, losing track of which +sub-agent is doing what, failing on transient errors without a budget, reading streaming +output without filling context, or losing work at session end. + +## v1.0.4 changelog (this release) + +> **类型**:patch · **Skill 主体修正 (round 2)** · 5 个 Skill 按 mcode 0.2.4 真实 `task` / `bash` schema 重写;新增 23-Skill frontmatter 静态检查 + +### Fixed (PR #18 reviewer #2 round 2) + +- `fork-context-decision` `0.2.0 → 0.3.0`:reviewer 提出的 6 点全部 close + - (1) 重复 frontmatter block(round 1 残留)→ 重新组装为单一 frontmatter + - (2) `history=N` PLACEHOLDER 移除 — mcode 0.2.4 `task` 工具**没有** context-sharing 参数;3 fork 模式(`all` / `N` / `none`)现在通过在 `prompt` 字段内联多少 prior turns 来表达 + - (3) `agent_name=` 换成 canonical `agent_name=`(`cli.js:B6c` 严格 validator 只接受 `agent_name`, `subagent_type=` 是运行时 alias(`cli.js:j6c` normaliser)) + - (4) `brief=` 换成 canonical `prompt=` + - (5) `mavis` 从 subagent 列表移除(它是 root agent,host-internal layout 不在 public contract 内;不能用作 `agent_name`) +- `delegate-with-context` `1.1.0 → 1.2.0`:同上 4 个 API 修正;4-part 信封现在写进 `prompt` 字段(不再有 `brief=` 概念) +- `parallel-fanout` `1.1.0 → 1.2.0`:同上;每个 sub-task 独立 `task()` 调用,host 的 `buffer-unordered` 默认 8 +- `model-router` `0.3.3 → 0.4.0`:**删除** v0.3.3 "MiniMax Code's `task` tool accepts `model_config_id` directly" 错误断言 — mcode 0.2.4 `task` 工具不接受任何 model 字段(`cli.js:B6c` 严格 validator);模型选择是 **session-level**;Skill 重新定位为 cheap/medium/main 思考框架 + spawn gate(不要为 cheap 任务 spawn sub-agent) +- `background-task` `0.1.2 → 0.2.0`:重新组织为 mcode 0.2.4 的两层后端 + - sub-agent 后台:`task(..., run_in_background: true)` 返回 `task_id`;管理用 `task_query(task_id)` / `task_output(task_id, offset?)` / `task_stop(task_id, reason?)`(`cli.js` canonical schema) + - shell 后台:`bash(command, run_in_background: true)` — mcode `bash` 工具的真实 schema(`cli.js:xza`):`command` / `timeout?` / `run_in_background?`;**没有** `task_name` / `action="kill"`(这俩是 Codex 专属,reviewer 反复要求去掉) + - 杀掉 shell 后台任务用 host job-control API(Windows `Stop-Process -Id`,POSIX `kill `)走**foreground** `bash` 调用,不假装 `bash(action="kill")` 存在 + +### Added (PR #18 reviewer #2 round 2 point 6) + +- **23-Skill frontmatter 静态检查**:`test/codex-harness-patterns.test.mjs`(被 `node --test` 自动发现) + - 23 个 `SKILL.md` 全部要求**单一** frontmatter block(开头 `---\n`,闭合 `\n---\n`,中间无 stray `---`) + - frontmatter 解析为结构化对象,verify 必填字段:`name`(=目录名)/ `description`(≤1024 字符)/ `license=Apache-2.0` / `metadata.author=antianqi` / `metadata.version` 非空 + - body 顶层无 `author:` / `version:` 重复(避免 round 1 reviewer 提到的"重复 frontmatter block"再发生) + - **mcode 0.2.4 schema pinning**:对 5 个 task-touching Skills,所有 code block 里的 `task(...)` / `bash(...)` 调用**必须**用 canonical 参数(`description` / `prompt` / `agent_name` / `run_in_background` / `command` / `timeout`),禁止 `subagent_type=` / `subagent=` / `agent_type=` / `fork_turns=` / `brief=` / `history=` / `model_config_id=` / `bash(task_name=...)` / `bash(action="kill")`(reviewer 提的全部 9 个 placeholder 都被 fail-closed) + - `background-task` 必须显式 demo `task_query` / `task_output` / `task_stop`(不然跑不动 `run_in_background=true` 返回的 task_id) + - 跑分:`node --test test/codex-harness-patterns.test.mjs` → **27 pass, 0 fail** + +### 验证方法(可复现) + +```bash +# 把 mcode 0.2.4 真实 schema 拿出来对照 +grep -A2 'name:"task",executionMode' \ + C:/Users/Administrator/.minimax-code/node_modules/@minimax-ai/code/cli.js +# → 4 个 param: description / prompt / agent_name / run_in_background + +# 跑 frontmatter + schema pinning 测试 +node --test test/codex-harness-patterns.test.mjs +# → 27 pass, 0 fail + +# 跑 plugin validator(需要 core.autocrlf=false 才能跑过 Windows CRLF) +node scripts/validate.mjs +# → OK plugin antianqi/codex-harness-patterns +``` + +### Compliance + +- 4 段独立披露(no credentials / no network / no telemetry / no third-party services)— 不变 +- Skill-only plugin(无 `mcp.json` / 无 `package.json` / 0 npm 依赖)— 不变 +- 跨平台 path 解析 — 不变,这次 review 没有触发新硬编码 +- 23 个 Skill 的 frontmatter 唯一性(无重复 `author:` / `version:` block)— 现在由 `test/codex-harness-patterns.test.mjs` 守门 + +### Not changed + +- 23 skill 整体布局与触发条件 +- 4 段独立披露格式 +- License +- 19 个非 task-touching Skills 的 body(v1.0.4 没改 `completion-audit` / `context-pressure-compact` / `error-recovery-strategy` / `goal-persistence` / `goal-token-budgeting` / `long-term-memory` / `plan-stream-emit` / `plugin-author-helper` / `retry-with-backoff` / `review-mode` / `session-branch-fork` / `session-handoff` / `skill-auto-select` / `streaming-output-reader` / `subagent-family-tracking` / `tool-discovery-pattern` / `tool-output-budget` / `world-state-tracking`) + +## v1.0.3 changelog (previous) + +> **类型**:patch · **Skill 主体修正 (round 1)** · 4 个 Skill 的 mcode 适配注释从"伪代码"升级到 mcode 实际 `task(agent_name=...)` 语法;修复 v1.0.3.1 草稿的 frontmatter 结构损坏 + +### Fixed + +- `fork-context-decision` `0.1.2 → 0.2.0`:example 调用从 "Codex-style `task(subagent=..., fork_turns=...)` 伪代码" 改为 mcode 实际 `task(agent_name=..., brief=..., history=...)` 语法;清掉 v1.0.3.1 草稿的重复 `metadata:` 块、孤立 YAML、body 中重复 `# Fork Context Decision` 标题、stray `---` 分隔符(YAML 解析拿到错的 version 字段) +- `delegate-with-context` `1.0.2 → 1.1.0`:同上,example 改用 `agent_name="explore"`;删除"工具集是 yaml 写死"这种 host 内部实现细节的断言 +- `parallel-fanout` `1.0.2 → 1.1.0`:同上;删除"Reads each agent's tool whitelist from mcode assets/agents/<name>/agent.md"这种引用不存在路径的断言 +- `model-router` `0.3.2 → 0.3.3`:恢复 portable 3-tier rubric;删除"agent_type 已经隐含 tier"这种依赖不存在 yaml 配置的强断言;`reasoning_effort` 显式标注为 Codex-only、mcode 不暴露 + +### Compliance + +- 4 段独立披露(no credentials / no network / no telemetry / no third-party services)— v1.0.2 已加,本版本未改 +- 跨平台 path 解析 — `assets/agents//agent.md` 这种相对路径断言已删除 +- Skill-only plugin(无 `mcp.json` / 无 `package.json` / 0 npm 依赖)— 不变 +- 一个 commit 一个 fix — 本次 amend 把 4 个 Skill 的修复合到同一个 v1.0.3 commit,因为它们都是 reviewer issue 2 的同一根因(Codex-style 参数名 → mcode-actual 参数名) + +### Not changed + +- 23 skill 整体布局与触发条件 +- 4 段独立披露格式 +- License +- README 的其他部分 + +## v1.0.2 changelog (previous) + +> **类型**:patch · **Skill 主体不变** · README 加 4 段独立披露(满足 mcode plugin 提交规范) + +### Added + +- README 新增 `Disclosure (per mcode plugin convention)` 一节,4 段独立披露: + - **No credentials** — 不读 / 存 / 传 / 请求任何凭据 + - **No network** — 任何出站调用 / socket / 自动更新 + - **No telemetry** — 任何自身指标 / trace / event / log + - **No third-party services** — 不绑 MCP / npm / 原生 binary / 外部 runtime +- PR #18 body 改为 `Design compliance / Validation / Test evidence` 三段式 + +### Compliance + +- mcode `~/.minimax/memory/user.md` 第 36-42 行规定的 4 段披露格式 — 现在 README 显式列出 +- 跨平台 path 解析 — 已验证无硬编码 `C:\` / `D:\` / `/Users/` / `/home/` +- Skill-only plugin (无 `mcp.json` / 无 `package.json` / 0 npm 依赖) — 已声明 +- 一个 commit 一个 plugin 范围 — 此次只改 README + +### Not changed + +- 23 skill 主体(版本号不变) +- 23 skill frontmatter +- plugin.json 其他字段 +- License + +## v1.0.1 changelog (previous) + +> **类型**:patch · **Skill 主体不变** · 文档收尾(OVERVIEW.md / STATUS.md 全面刷新 + PR #18 title 更新) + +### Added + +- `OVERVIEW.md` 全面刷新 — 23 Skills 按生命周期分组 + 完整生命周期图 +- `PR-STATUS.md` 同步到 v1.0.0 状态 +- PR #18 title 更新为 "v1.0.0 — 23 Skills covering complete agent lifecycle" +- PR #18 body 全面重写 — 23 Skills 表格 + v0.1.0 - v1.0.0 完整 changelog + +### Not changed + +- 23 skills 主体(版本号全部不变) +- 23 skills frontmatter + +## v1.0.0 changelog (previous) 🎉 + +> **类型**:**MAJOR** · **Plugin 1.0 里程碑** · 5 个新 skill + 完整生命周期覆盖 + +### 🎉 v1.0 里程碑 + +Plugin 现在覆盖 Codex agent 的**完整生命周期**: +``` +planning → decomposition → sub-agent parallelism → execution → +state tracking → tool discovery → skill/plugin authoring → +memory persistence → session branching +``` + +**23 个 Skill**(从 18 增加到 23),Plugin 覆盖率 **~90%+**。 + +### Added — 5 个新 Skill + +| # | Skill | 用途 | 灵感来源 | +|---|---|---|---| +| 19 | `long-term-memory` | 跨 session 长期记忆设计(Phase 1/2 提取 + 合并 + citation) | `codex-rs/memories/` | +| 20 | `skill-auto-select` | 设计可被 LLM 可靠选择的 skill(三层匹配 + mention 语法) | `codex-rs/skills/` | +| 21 | `plugin-author-helper` | 写 marketplace Plugin(manifest 格式 + 3-layer sync + idempotency) | `codex-rs/core-plugins/` | +| 22 | `tool-discovery-pattern` | 设计可被 agent 发现的 tool(defer_loading + 7-type schema) | `codex-rs/tools/` | +| 23 | `session-branch-fork` | session 分支 / 回滚 / 恢复(paginated + lineage + ModelContext) | `codex-rs/thread-store/` | + +### Coverage journey + +- **v0.6.2 (开始)**:60% 覆盖 +- **v0.7.0 (阶段 1 完成)**:78% +- **v0.7.5 (阶段 3 完成)**:88% +- **v1.0.0 (阶段 4 完成)**:~90%+ + +### Not changed + +- 18 个原有 skill 主体不变,版本号不变 +- 原有 18 skill 的 frontmatter / 触发条件不变 + +## v0.7.5 changelog (previous) + +> **类型**:patch · **Skill 主体不变** · 研究状态更新(阶段 3 边角 crate 收口) + +### Added + +- 1 篇新知识笔记(对 `apply-patch` / `context-fragments` / `mcp-server` / `app-server-daemon`): + - `P-148-156-edge-crates.md`(5KB)— Apply Patch Lark grammar + Context Fragments + MCP Server + App Server Daemon +- CATALOG 状态:🟢 108→112 / 🟡 3→3 + +### Key insight + +**Codex 边角能力**: +- **Apply Patch** — 自有 Lark grammar,lenient 解析 +- **Context Fragments** — 带 metadata 的 context 片段(`AnnotatedContent`) +- **MCP Server** — Codex 自身可作为 MCP tool(`codex_tool_runner`) +- **App Server Daemon** — 自我管理 binary + SHA256 + self-update loop + +### Not changed + +- 18 skill 主体(版本号全部不变) + +## v0.7.4 changelog (previous) + +> **类型**:patch · **Skill 主体不变** · 研究状态更新(阶段 2 周 8) + +### Added + +- 1 篇新知识笔记(对 `codex-rs/protocol/src/` 关键未读模块): + - `P-128-protocol-capabilities-user-input.md`(7KB)— Capabilities + UserInput + OpenAI Models + Config Types + Permission Intersection +- CATALOG 状态:🟢 100→108 / 🟡 3→3 + +### Key insight + +**Codex 协议层模式**: +- **跨 4 边界共享**(core / TUI / app-server / SDK) — 字段默认必须保留 +- **TS + JsonSchema 双重 derive** — 自动生成 TypeScript + JSON Schema +- **`ts(export_to = "v2/")` 版本化** — 协议分版本 +- **Two-stage Parse** — API 变化时自动 fallback +- **Intersection** — 权限 / 配置用集合论组合 +- **deprecated 但保留** — 向后兼容 + +## v0.7.3 changelog (previous) + +> **类型**:patch · **Skill 主体不变** · 研究状态更新(阶段 2 周 7) + +### Added + +- 2 篇新知识笔记(对 `codex-rs/rollout/` + `codex-rs/models-manager/` 深读): + - `P-117-127-rollout-persistence.md`(4KB)— zstd 压缩 + ReverseJsonlScanner + RolloutReferenceIndex + - `P-128-133-models-manager.md`(5KB)— ModelsEndpointClient trait + 5min 文件 cache + 1177 行 models.json +- CATALOG 状态变更:`P-117/118/119/120 + P-128/129/130/132` 🟡→🟢(8 个) + +### Key insight + +- **zstd + 反向扫描** — 冷 rollout 自动压缩,反向读只取末段 +- **RolloutReferenceIndex** — 不读文件就能回答"谁引用了我" +- **models.json 1177 行** — 完整 capability matrix(input_modalities/truncation_policy/prefer_websockets/...) +- **ModelsEndpointClient trait** — 多 provider 抽象 + +### Not changed + +- 18 skill 主体(版本号全部不变) +- 18 skill frontmatter + +## v0.7.2 changelog (previous) + +> **类型**:patch · **Skill 主体不变** · 研究状态更新(阶段 2 周 6) + +### Added + +- 4 篇新知识笔记(对 `codex-rs/tools/` 25+ 文件深读): + - `P-107-108-tool-discovery-search.md`(5KB)— DiscoverableTool 二维分类 + `defer_loading` 搜索结果 + - `P-109-111-dynamic-mcp-tool.md`(6KB)— 简单 vs 复杂适配器 + OpenAI 协议补全 + - `P-112-113-plugin-install-responses-api.md`(5KB)— Tool suggestion 审批 + Responses API 5 个类型 + - `P-114-116-json-schema-image-response-history.md`(5KB)— 7 type subset + BTreeMap 稳定输出 +- CATALOG 状态变更:`P-107/108/109/110/111/112/113/114` 🟡→🟢(8 个) +- **🟢 首次突破 100 个已掌握模式** + +### Key insight + +**Tool 运行时全栈**: +- **Discovery** — DiscoverableTool(Connector/Plugin × Install/Enable 二维) +- **Search** — `defer_loading` 模式(搜索结果只含 name+description,schema 延迟加载) +- **Dynamic vs MCP** — 简单透传 vs 复杂 schema 补全(`properties` 必填兜底) +- **Install** — `request_plugin_install` 走 `tool_suggestion` 审批类型 +- **JSON Schema** — OpenAI Structured Outputs 子集(7 type + 3 composition) +- **Responses API** — 5 个类型(Function / Custom / Namespace + 嵌套) + +**Plugin 不直接涉及 tool**,但**借鉴模式**: +- "简单 vs 复杂适配器" — Plugin manifest 也是 +- "defer loading" — Skill description 应当简洁 +- "OpenAI 协议补全" — 跟 3rd-party 兼容 +- "Tool suggestion 审批" — 任何"加新能力"都该走审批 + +### Not changed + +- 18 skill 主体(版本号全部不变) +- 18 skill frontmatter + +### Roadmap progress + +| 阶段 | 状态 | 覆盖率 | +|---|---|---| +| 0 · 错判修正 | ✅ v0.6.2 | 60%→62% | +| 1 · 5 大核心 | ✅ v0.7.0 | 62%→78% | +| 2 · 周 5 agent + session | ✅ v0.7.1 | 78%→80% | +| **2 · 周 6 tools/** | ✅ **v0.7.2** | **80%→83%** | +| 2 · 周 7 rollout/ + models-manager/ | ⏳ 下一步 | — | + +## v0.7.1 changelog (previous) + +> **类型**:patch · **Skill 主体不变** · 研究状态更新(阶段 2 周 5) + +### Added + +- 4 篇新知识笔记(对 `codex-rs/core/src/agent/` + `codex-rs/core/src/{session,context_manager}/` 关键未读模块深读): + - `P-134-138-agent-registry.md`(7KB)— AgentRegistry + Mutex/Atomic 双层 + "Customize OR reduce, never REPLACE" 角色覆盖 + - `P-140-142-context-manager.md`(5KB)— ContextManager `Arc` CoW + history_version + reference snapshot diff + - `P-158-turn-suspension.md`(6KB)— 完整 9 步 suspend 流程 + 7 大设计原则 + - `P-157-162-session-infrastructure.md`(5KB)— Rollout budget / MCP refresh / Input queue / Elicitation / Time reminder +- CATALOG 状态变更:`P-134 / P-137 / P-138 / P-140-142 / P-157-160` 🟡→🟢(10 个) + +### Key insight + +**Codex session 中断的完整生命周期** = `Op::SuspendTurnAndShutdown` → 9 步 suspend 流程 → `Op::RecoverTurn` 恢复。 +关键设计: +- Snapshot vs Seal(descendants 检查接受 best-effort) +- Flush Before Cancel(持久化失败就让原 turn 继续) +- No Terminal Event(故意给 RecoverTurn 留恢复口) +- Event After Writer Closed(防并发写顺序) +- Handoff Drops State(pending input 不持久化) + +### Not changed + +- 18 skill 主体(版本号全部不变) +- 18 skill frontmatter + +### Roadmap progress + +| 阶段 | 状态 | 覆盖率 | +|---|---|---| +| 0 · 错判修正 | ✅ v0.6.2 | 60%→62% | +| 1 · 5 大核心 | ✅ v0.7.0 | 62%→78% | +| **2 · 周 5 agent + session** | ✅ **v0.7.1** | **78%→80%** | +| 2 · 周 6 tools/ | ⏳ 下一步 | — | + +## v0.7.0 changelog (previous) + +> **类型**:**minor** · **Plugin 里程碑** · 阶段 1(5 大核心 crate)整圈收口 · **Skill 主体不变** + +### Added + +- 4 篇新知识笔记: + - `P-93-95-plugin-loader-marketplace-manifest.md`(7KB)— Plugin 运行时 + marketplace + manifest 三件套 + - `P-99-plugin-startup-sync.md`(4KB)— 3 层 fallback + lock file + SHA 缓存 + - `P-164-prompts-compact.md`(3KB)— compact 5 个 must-have + - `P-165-prompts-goals.md`(5KB)— 4 大设计原则(防 prompt injection / 防偷工减料) + - `P-166-168-prompts-permissions-realtime-review.md`(8KB)— 3 套 permissions + 3 套 realtime + 3 套 review +- CATALOG 状态变更:`P-93/94/95/99 + P-164/165/166/167/168` 全部 🟡→🟢 + +### 阶段 1 整圈收口 + +| 周 | crate | 状态 | 发布 | +|---|---|---|---| +| 1 | `codex-rs/memories/` 21 文件 | ✅ | v0.6.3 | +| 2 | `codex-rs/skills/` 10+ 文件 | ✅ | v0.6.4 | +| 3 | `codex-rs/thread-store/` 40+ 文件 | ✅ | v0.6.5 | +| 4 | `codex-rs/core-plugins/` 60+ 文件 + `codex-rs/prompts/` 4 套 | ✅ | **v0.7.0** | + +**5 大核心 crate 全部完成**: +- ✅ memories (跨 session 长期记忆) +- ✅ skills (Plugin 直接对应物) +- ✅ thread-store (完整 session 持久化) +- ✅ core-plugins (Plugin 运行时) +- ✅ prompts (4 套 prompt 模板) + +**Plugin 覆盖率**:**72% → 78%**(+6%,阶段 1 净增 16%) + +### Not changed + +- 18 skill 主体(版本号全部不变) +- 18 skill frontmatter + +### Next + +- 阶段 2(周 5-8):core/agent/ + core/session/ + tools/ + rollout/ + models-manager/ + protocol/ +- 阶段 3(周 9-10):边角 crate 收口 +- 阶段 4(周 11-12):5 个新 skill + Plugin v1.0 + +## v0.6.5 changelog (previous) + +> **类型**:patch · **Skill 主体不变**(18 个 0.x.y 版本号不变) · 研究状态更新(阶段 1 周 3 完成) + +### Added + +- 6 篇新知识笔记(对 `codex-rs/thread-store/` 40+ 文件深读 — **完整 session 持久化层**): + - `P-67-thread-sections.md`(4KB)— section 管理 + operation-tagged state access + - `P-68-thread-projects.md`(4KB)— projects + `Option>` 三态 + idempotency key + - `P-69-70-queue-search.md`(5KB)— queue change-based polling + search snippet + - `P-71-72-migration-lineage.md`(6KB)— Legacy→Paginated migration + bounded subagent replay + - `P-76-model-context-reconstruction.md`(4KB)— ReverseJsonlScanner + bounded replay + - `P-77-thread-history-segmentation.md`(5KB)— 跨 segment 双向 cursor + 防溢出 +- CATALOG 状态变更:`P-67 / P-68 / P-69 / P-70 / P-71 / P-72 / P-76 / P-77` 全部从 🟡→🟢 +- **CATALOG 状态首次全清零**:`🟡 1→0` —— 所有 🟡 模式都进入 🟢 + +### Key insight + +`thread-store/` 是 Codex session 持久化的**完整基础设施**: +- Sections / Projects / Queue / Search 提供**管理面** +- Rollout Lineage / Migration / ModelContext reconstruction 提供**历史面** +- ThreadStore trait + `LocalThreadStore` + `InMemoryThreadStore` 提供**抽象层** + +新 skill `session-branch-fork`(阶段 4)的核心参考全部在这里。 + +### Not changed + +- 18 skill 主体(版本号全部不变) +- 18 skill frontmatter + +### Roadmap progress + +| 阶段 | 状态 | 覆盖率 | +|---|---|---| +| 0 · 错判修正 | ✅ v0.6.2 | 60%→62% | +| 1 · 周 1 memories/ | ✅ v0.6.3 | 62%→64% | +| 1 · 周 2 skills/ | ✅ v0.6.4 | 64%→66% | +| **1 · 周 3 thread-store/** | ✅ **v0.6.5** | **66%→72%** | +| 1 · 周 4 core-plugins/ + prompts/ | ⏳ 下一步 | — | + +## v0.6.4 changelog (previous) + +> **类型**:patch · **Skill 主体不变**(18 个 0.x.y 版本号不变) · 研究状态更新(阶段 1 周 2 完成) + +### Added + +- 6 篇新知识笔记(对 `codex-rs/skills/` 10+ 文件深读 — **Plugin 直接对应物**): + - `P-85-skill-selection-algorithm.md`(6KB)— 显式 + 隐式 selection,`O(T + (N_s + N_t) * S)` 复杂度,三层匹配 + - `P-86-skill-loading.md`(6KB)— 加载抽象 + 缓存 + system skills 嵌入式分发 + - `P-87-skill-frontmatter-parser.md`(6KB)— frontmatter 解析 + `repair_frontmatter_scalar_fields` 容错 + - `P-88-skill-mention-extractor.md`(5KB)— `$skill-name` + `[$name](path)` 链接语法 + - `P-89-implicit-skill-invocation.md`(5KB)— shell 命令隐式调用检测 + 平台感知分词 + - `P-92-skill-metadata-model.md`(6KB)— 完整 11 字段 metadata + 双形态抽象 +- CATALOG 状态变更:`P-85 / P-86 / P-87 / P-88 / P-89 / P-92` 全部从 🟡→🟢 + +### Key insight + +Codex skills 系统的 selection/loading/parser/mentions/model 5 个核心模块**直接对应我们 Plugin 的结构**。 +Plugin 当前的"skill 选取"能力**远弱于** Codex skills/ — 这是新 skill `skill-auto-select` 的来源(阶段 4 计划)。 + +### Not changed + +- 18 skill 主体(版本号全部不变) +- 18 skill frontmatter + +### Roadmap progress + +| 阶段 | 状态 | 覆盖率 | +|---|---|---| +| 0 · 错判修正 | ✅ v0.6.2 | 60%→62% | +| 1 · 周 1 memories/ | ✅ v0.6.3 | 62%→64% | +| **1 · 周 2 skills/** | ✅ **v0.6.4** | **64%→66%** | +| 1 · 周 3 thread-store/ | ⏳ 下一步 | — | + +## v0.6.3 changelog (previous) + +> **类型**:patch · **Skill 主体不变**(18 个 0.x.y 版本号不变) · 研究状态更新(阶段 1 周 1 完成) + +### Added + +- 4 篇新知识笔记(对 `codex-rs/memories/` 21 文件深读): + - `P-78-memory-phase1.md`(6KB)— Memory Phase 1:per-rollout extraction,JSON schema 强制 + `buffer_unordered` 并发 + 4 类高信噪比判定 + - `P-79-memory-phase2.md`(8KB)— Memory Phase 2:global consolidation,10 步线性流程 + 全局单 lock + 内部 consolidation agent 锁死配置 + - `P-80-memory-citation.md`(4KB)— MemoryCitation 协议 + `` / `` 解析 + - `P-84-memory-workspace-git.md`(8KB)— Memory workspace + git baseline 模式 +- CATALOG 状态变更:`P-78 / P-79 / P-80 / P-84` 全部从 🟡→🟢 +- CATALOG §9.2 memory 系统状态列加上"状态"字段 + +### Not changed + +- 18 skill 主体(版本号全部不变) +- 18 skill frontmatter +- Plugin 主合约 / 触发条件 / 输出契约 + +### Roadmap progress + +| 阶段 | 状态 | 覆盖率 | +|---|---|---| +| 0 · 错判修正 | ✅ v0.6.2 完成 | 60%→62% | +| 1 · 5 大核心 crate | 🟢 周 1 完成 | 62%→64% | +| 1 · 周 2 skills/ | ⏳ 下一步 | — | + +## v0.6.2 changelog (previous) + +> **类型**:patch · **Skill 主体不变**(18 个 0.x.y 版本号不变) · **元数据 + 文档更新** + +### Added + +- **CATALOG §7 修正**:把之前标 ⛔"范围外"的 4 个 session/thread 模式(`P-49 Fork` / `P-50 Rollback` / + `P-51 Recover` / `P-52 History Mode`)重新归类为 🟡"待深读" — 它们的真实实现位置是 + `codex-rs/thread-store/`(40+ 文件,完整 fork/revert/recover/segmentation 实现),不是"范围外"。 +- **CATALOG §8 修正**:把 `P-63 Skills runtime` 和 `P-64 Memory system` 从 ❌"不在 4 个重点" + 改为 🟡"待深读" — 它们是 Codex 跨 session 长期记忆和 skill runtime 的核心实现,**直接对应 + 我们 Plugin 自身结构**(`codex-rs/skills/` + `codex-rs/memories/`)。 +- **CATALOG §9 新增**:2026-08-24 复盘发现 ~100 个未研究模式草案,挑选 50+ 高价值列入。最高价值: + - ⭐⭐⭐⭐⭐ `memories/` Phase 1/2(per-rollout extraction + global consolidation) + - ⭐⭐⭐⭐⭐ `skills/` 完整 runtime(selection / loading / parser / mentions) + - ⭐⭐⭐⭐ `core-plugins/` marketplace 运行时 + - ⭐⭐⭐⭐ `tools/` discovery / search / dynamic tool + - ⭐⭐⭐⭐ `prompts/` 完整 4 套 prompt 模板 + +### Documentation + +- 新增 `research-log/2026-08-24-resurvey-findings.md`(25KB) — 完整复盘报告 +- 新增 `RESEARCH-ROADMAP.md`(12KB) — 2-3 月系统性补完计划(阶段 0-4) +- 新增 6 篇纠错笔记(`knowledge/P-{49,50,51,52,63,64}-*.md`)— 详述错判反思 + 实际代码位置 + +### Honest acknowledgment + +这次复盘揭示了 Plugin 实际**只覆盖了 Codex 模式库的 ~60%**。18 skill 跟现有 66-pattern +CATALOG 是一对一覆盖(每个 skill 对应 1 个或几个 P-XX),看起来很整齐,但底层有 6 个错判 +没真正读代码就标了 — 意味着对 Codex 怎么管 session/thread/memory 这块没真正搞懂。 + +`v0.6.2` 不解决覆盖率问题,只**诚实记录**。系统性补完由 v0.7.0 起按周推进。 + +## v0.6.1 changelog (previous) + +### Changed + +**Trigger descriptions rewritten across all 18 Skills** for better LLM matching. Each +`description:` frontmatter field now uses a structured 4-line format: + +```yaml +description: | + . + USE WHEN: . + TRIGGER PHRASES: . + SKIP WHEN: . +``` + +This makes the description **keyword-greppable** (so the LLM can match on real signals +like "ECONNREFUSED", "permission denied", "retries exceeded", "上下文满了" / "出错了" / +"重试") instead of trying to interpret abstract prose. + +All 18 Skills have their trigger phrases now spelled out in both English and Chinese, so +the LLM can match user language directly. Skill versions bumped to `0.1.1` (or +`1.0.1` for the v1.0 skills). + +## Try it + +Install from `/plugins` → **Local**, then ask any of: + +```text +"Read docs/internal-spec.md and summarize the data model — keep the full file off the main context" + +"Refactor the auth subsystem across these 5 files. Plan first, then execute." + +"Investigate why the test suite is flaky. Decompose into independent probes and run them in parallel." + +"I'm at turn 35 of an open-source contribution. Compress the conversation so I can keep going." + +"You just finished the migration — review your own diff for off-by-ones and edge cases." + +"Spawn a sub-agent to scan the codebase for unused imports. Give it a tight brief, not the full history." + +"Start a long dev server in the background so I can keep asking you things while it warms up." + +"Set the goal of this thread: migrate the auth subsystem to OIDC alongside SAML. Drift-check before +each non-trivial change." + +"This sub-task is a one-shot file reformat — use the cheap model for it." + +"Before you say 'done' on the auth refactor, run a completion audit. Show me the evidence for each requirement." + +"I'm about to spawn 4 sub-agents. Decide the fork_turns for each — full history or just the brief?" + +"Show me the sub-agent family tree — which are still running?" + +"This goal has a 20,000-token budget. Tell me at 50% / 80% / 100%." + +"The bash command just failed with 'permission denied'. Retry? Switch tool? Ask me?" + +"Read this 50K-line build log without filling the context. Stream-read it and summarize." + +"It's the end of the day. Write a handoff file so tomorrow's session can pick up." +``` + +**Expected result**: the agent picks the right Skill, follows the documented process, and produces +output that matches the Skill's output contract (see each Skill's `SKILL.md` for its specific +contract and example). + +## What this Plugin adds (v0.6.0, 18 Skills) + +Eighteen Skills, all Skill-only (no MCP server, no network access): + +| # | Skill | When to activate | v | +|---|---|---|---| +| 1 | `tool-output-budget` | A tool returns output you suspect is too large to keep verbatim (large logs, JSON, fetched HTML, minified files). | v0.1.0 → 0.1.1 | +| 2 | `context-pressure-compact` | The task is multi-step and long; the running `todowrite` exceeds 5 items, or the agent has been reasoning for many turns. | v0.1.0 → v1.0.1 | +| 3 | `parallel-fanout` | The user task is clearly decomposable into 2+ independent sub-tasks (independent files, independent probes, independent analyses). | v0.1.0 → v1.1.0 → v1.2.0 | +| 4 | `plan-stream-emit` | The user task is non-trivial and the user has not yet approved a plan; emit a structured plan before touching files. | v0.1.0 → 0.1.1 | +| 5 | `review-mode` | A non-trivial sub-task has just finished and the work is about to be marked done; the user wants verification before relying on the result. | v0.2.0 → 0.2.1 | +| 6 | `delegate-with-context` | About to call `task` to hand off a sub-task; the full conversation history is too large to forward and a minimal-context brief would do. | v0.2.0 → v1.1.0 → v1.2.0 | +| 7 | `world-state-tracking` | The task is long enough that the agent has lost the thread at least once, or `context-pressure-compact` is about to be applied. | v0.2.0 → 0.2.1 | +| 8 | `background-task` | A command is expected to take > 30 seconds, or the user wants a long-running process to coexist with ongoing work. | v0.2.0 → v0.1.2 → v0.2.0 | +| 9 | `goal-persistence` | A non-trivial task has just been stated (set the goal); the user has redirected (update the goal); or a `context-pressure-compact` is about to be applied (alignment check). | v0.3.0 → v1.0.1 | +| 10 | `model-router` | About to call `task` for a non-trivial sub-task, or about to spend the main model on work a cheaper model could do. | v0.3.0 → v0.3.3 → v0.4.0 | +| 11 | `completion-audit` | About to say "done" / "complete" / "ship it" on a non-trivial task. Derives requirements, identifies authoritative evidence, verifies each. | v0.4.0 → 0.4.1 | +| 12 | `fork-context-decision` | About to call `task` to hand off a sub-task. Decides how much parent context to give the sub-agent by inlining it into the `prompt`. | v0.4.1 → v0.1.0 → v0.2.0 → v0.3.0 | +| 13 | `subagent-family-tracking` | Spawned a sub-agent (or have one running). Track the parent/child tree so you do not lose children, duplicate work, or leave anyone running. | v0.5.0 → 0.5.1 | +| 14 | `goal-token-budgeting` | The user set an explicit `token_budget` on a goal. Track running usage against the budget and report the final number on completion. | v0.5.0 → 0.5.1 | +| 15 | `error-recovery-strategy` | A tool call, sub-agent task, or external operation failed. Decide between retry / switch / fallback / ask-user / skip. | v0.6.0 → 0.6.1 | +| 16 | `retry-with-backoff` | About to retry a `transient` error. State the policy first: max attempts, base delay, max delay, jitter, total time budget. | v0.6.0 → 0.6.1 | +| 17 | `streaming-output-reader` | A tool returns a long stream (SSE / WebSocket / `tail -f` / large log). Read in bounded chunks, synthesize, never loop. | v0.6.0 → 0.6.1 | +| 18 | `session-handoff` | The session is ending (user stepping away, time up, about to compact). Write a handoff file so next session can pick up in 30 seconds. | v0.6.0 → 0.6.1 | + +## Disclosure (per mcode plugin convention) + +The four sections below are explicit, independent disclosures as required by the mcode +plugin submission convention. They are the single source of truth for this Plugin's +runtime surface area; if any of them is false for a future change, update them in the +same commit. + +### No credentials + +The Plugin does not read, store, transmit, or request any credential. It does not declare +an OAuth flow, does not require environment variables, does not embed tokens, and does not +have a service account. The 23 Skills are pure Markdown instructions; activating a Skill +does not require or produce any secret material. + +### No network + +The Plugin makes no outbound network call. It does not bundle a fetch / download / +auto-update step; it does not register a webhook or a long-poll; it does not open a socket +of any kind. Skill contents are read from the local `skills/` directory only, and the +agent's existing tool surface (`bash`, `read`, `write`, `edit`, `grep`, `glob`, `task`) +is the only thing the Skills can ask the agent to do. + +### No telemetry + +The Plugin does not emit events, metrics, traces, or logs of its own. It does not register +a counter, does not tag rollouts, and does not write a heartbeat. Any observability the +Plugin produces is the same observability the agent would produce if a human typed the +same instructions by hand. + +### No third-party services + +The Plugin does not depend on any external service. It does not bundle a native binary, +does not call an MCP server, does not `npm install` anything at install time, and does +not require Python, Node, or any runtime besides the host agent. The 23 Skills are +self-contained Markdown; the `plugin.json` declares no `mcp.json` and no +`package.json`. + +## Requirements + +- **MiniMax Code** with Agent Plugins 1.0 support. +- **No Python, no Node, no external services.** These Skills are pure Markdown instructions; the + agent applies them with its existing tools (`bash`, `read`, `write`, `edit`, `grep`, `glob`, `task`). +- **No MCP server, no network, no credentials.** This Plugin does not start any process or open any + socket. It only adds Skill files to the agent. + +## Data and network + +- **No network access.** This Plugin adds Skills only; it does not call out. +- **No credentials, tokens, env vars, or telemetry.** The agent does not need any of these to + apply the Skills. +- **No data leaves your machine.** The Skills operate on whatever the agent can already see in + the workspace. + +## Security model + +The Skills are read-only instructions. They cannot be used to exfiltrate data, run untrusted code, +or escalate privileges beyond the agent's existing capability set. The only side effect is the +agent choosing to use its existing tools (e.g. `write` a compact summary to disk) — exactly as +the user would do manually. + +## How the Plugin is validated + +The Plugin was developed against the official `npm run check` workflow (see +`docs/plugin-compatibility.md` in the upstream `MiniMax-Code-Plugins` repo). It declares only +the portable subset (Skills + manifest), includes a real example prompt in this README, and +carries an Apache-2.0 LICENSE matching the host repository. + +## License + +Apache-2.0 diff --git a/plugins/antianqi/codex-harness-patterns/plugin.json b/plugins/antianqi/codex-harness-patterns/plugin.json new file mode 100644 index 0000000..8936c6f --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/plugin.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "codex-harness-patterns", + "version": "1.0.4", + "description": "Long-running task patterns distilled from OpenAI Codex harness v0.149.0 — tool output budgeting, context pressure compaction, parallel sub-agent fan-out, structured plan streaming, self-review, sub-agent delegation, world-state tracking, background task management, thread-level goal persistence, per-sub-task model routing, completion auditing, fork-context decision, sub-agent family tracking, goal token budgeting, error recovery strategy, retry with backoff, streaming output reader, session handoff, long-term memory, skill auto-selection, plugin authoring helper, tool discovery pattern, and session branch/fork. 23 Skills total covering the complete agent lifecycle: planning → decomposition → sub-agent parallelism → execution → state tracking → tool discovery → skill/plugin authoring → memory persistence → session branching. Activates when an agent must manage token budget, decompose work, sustain multi-step tasks without losing focus, coordinate sub-agents, pick the right model for the job, prove that a non-trivial task is actually done, stay within an explicit goal token budget, recover from transient failures, read streaming output without filling context, hand off a session cleanly, persist memory across sessions, write a discoverable skill, design a discoverable tool, author a marketplace plugin, or branch / fork / revert a session. **Requires MiniMax Code 0.2.4+** (pinned to the mcode 0.2.4 `task` / `bash` tool surface — `task(description, prompt, agent_name, run_in_background?)`, `bash(command, timeout?, run_in_background?)`, `task_query`, `task_output`, `task_stop` — earlier mcode versions used legacy placeholders for the same fields and are not supported).", + "author": { + "name": "antianqi", + "url": "https://github.com/antianqi" + }, + "homepage": "https://github.com/antianqi/MiniMax-Code-Plugins/tree/main/plugins/antianqi/codex-harness-patterns", + "repository": "https://github.com/antianqi/MiniMax-Code-Plugins", + "license": "Apache-2.0", + "keywords": [ + "minimax-code", + "plugin", + "codex-harness", + "token-budget", + "context-compaction", + "sub-agent", + "task-planning", + "long-running", + "review", + "world-state", + "background-task", + "goal", + "model-routing", + "completion-audit", + "fork-context", + "family-tracking", + "token-accounting", + "error-recovery", + "retry", + "streaming", + "session-handoff", + "long-term-memory", + "skill-auto-select", + "plugin-author", + "tool-discovery", + "session-fork" + ] +} diff --git a/plugins/antianqi/codex-harness-patterns/skills/background-task/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/background-task/SKILL.md new file mode 100644 index 0000000..76bc1b6 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/background-task/SKILL.md @@ -0,0 +1,253 @@ +--- +name: background-task +description: | + Decide when to launch a long-running task in the background and how to refer to it later. Covers both the `task` tool (sub-agent background via `run_in_background: true`) and the `bash` tool (shell background via `run_in_background: true`). + USE WHEN: a sub-agent is expected to take > 1 minute, a shell command is expected to take > 30 seconds, the user wants a long-running process to coexist with ongoing work, you are about to block the conversation for an unbounded time, user said "background it" / "后台" / "don't block" / "non-blocking" / "run in background". + TRIGGER PHRASES: "background", "background it", "后台", "don't block", "non-blocking", "in the background", "run async", "long-running", "put it in the background". + SKIP WHEN: the sub-agent / command finishes in <5 seconds, the user explicitly wants to wait for output, the command is interactive (REPL, vim, ssh). +license: Apache-2.0 +compatibility: Targets MiniMax Code 0.2.4. Verified against the bundled `cli.js` schema. `task(...)` accepts `run_in_background: true` and returns a `task_id` for later `task_query` / `task_output` / `task_stop`. `bash(...)` accepts `run_in_background: true` and returns a job handle. The Codex-harness `bash(task_name=..., run_in_background=true, action="kill")` shape is **not** the mcode surface — mcode's `bash` has no `task_name` or `action` field; killing is via `task_stop(task_id=...)` for sub-agents and via the host's job-control API for shell jobs. +metadata: + author: antianqi + version: "0.2.0" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/core/src/unified_exec/ and protocol::Op::CleanBackgroundTerminals (design principle only; the mcode 0.2.4 surface is `task(run_in_background=true)` + `task_query` / `task_output` / `task_stop` for sub-agents, and `bash(run_in_background=true)` for shell jobs) + changes-from-v0.1.2: "Replaced the v0.1.2 'Codex-harness pseudocode + adapt the call' block with the actual mcode 0.2.4 surface. mcode `task` and `bash` both accept `run_in_background: true`; for sub-agents the returned handle is a `task_id` queried with `task_query` / `task_output` / `task_stop`. The Codex-harness `bash(task_name=..., action=\"kill\")` shape is removed because mcode's `bash` has neither `task_name` nor an `action` sub-action field (the validator in `cli.js:xza` only allows `command` / `timeout` / `run_in_background`). Killing a sub-agent uses `task_stop(task_id=...)`; killing a shell background job uses the host's own job-control API (the example now shows `Stop-Process -Id` on Windows and `kill -PID` on POSIX, both invoked through a foreground `bash` call rather than a fake `action=\"kill\"` field)." +--- + +# Background Task + +When a task (a sub-agent invocation or a shell command) is expected to take +more than ~30 seconds, the agent has two choices: + +1. **Block**: wait for the task to finish, holding the conversation hostage. +2. **Background**: launch it, get a handle, continue working, and check on it later. + +This Skill is about **knowing when to choose (2)** and **how to record the +handle** so the agent (or the user) can check on it later. + +## mcode 0.2.4 surface + +There are two background-capable tools on mcode 0.2.4, and the right one depends +on whether the background work is a sub-agent or a shell command. + +### Sub-agent background: `task(run_in_background: true)` + `task_query` / `task_output` / `task_stop` + +The canonical `task` schema: + +```text +task( + description: string, // 3-5 word label, required + prompt: string, // the brief, required + agent_name: "explore" | "worker" | "verifier", // required + run_in_background?: boolean // optional; true = async, false = sync (default) +) +``` + +When `run_in_background: true`, mcode returns immediately with a `task_id`. +The companion tools (also canonical in `cli.js`): + +| Tool | Purpose | Required fields | +|---|---|---| +| `task_query(task_id?, status?)` | List session tasks (omit `task_id`) or get one. | `task_id` for single fetch; `status` filter optional. | +| `task_output(task_id, offset?)` | Read a task's output incrementally. | `task_id` (required); `offset` (optional, byte offset for long output). | +| `task_stop(task_id, reason?)` | Request a background task to stop. | `task_id` (required); `reason` (optional, human-readable). | + +`run_in_background: false` (the default) blocks the calling turn until the +sub-agent finishes and returns its final result. + +### Shell background: `bash(run_in_background: true)` + +The `bash` tool's canonical schema (from `cli.js:xza`): + +```text +bash( + command: string, // required + timeout?: number, // optional, seconds + run_in_background?: boolean // optional; true = async, false = sync (default) +) +``` + +When `run_in_background: true`, the `bash` call returns immediately with a +job handle that the host's job-control API can target (Windows: +`Stop-Process -Id `; POSIX: `kill `, both invoked through a +foreground `bash` call rather than any `action="kill"` field). The exact +shape of the returned handle is not part of the public mcode 0.2.4 runtime +contract; the host's job-control API is the source of truth for the +underlying process id. **There is no `task_name=` and no `action="kill"` +field.** The Codex-harness shape `bash(task_name=..., run_in_background=true, +action="kill")` is **not** the mcode surface — mcode's `bash` validator +rejects any key outside `command` / `timeout` / `run_in_background`. + +Killing a shell background job: invoke the host's job-control API in a +**foreground** `bash` call. Windows: `Stop-Process -Id `. POSIX: +`kill `. The Skills do not pretend `bash(action="kill")` exists on +mcode 0.2.4. + +## When to use + +Activate when **any** of these is true: + +- A sub-agent is expected to take > 1 minute (deep research, multi-file + refactor, anything you cannot predict the duration of). +- A shell command is expected to take > 30 seconds (`cargo test`, `npm install`, + `docker build`, a long-running dev server, a large data download). +- The user explicitly says "background" / "后台" / "non-blocking" / "in the background". +- You need a long-running process to coexist with ongoing work (a dev server, a + watch script, a streaming pipeline). +- You would otherwise block the conversation on a result the user can come back + to later. + +## When NOT to use + +- The task / command finishes in <5 seconds. +- The user explicitly wants the output now (interactive REPL, vim, ssh, a build + whose output the next step depends on). +- The command is interactive (it expects a TTY or human input). + +## Process + +1. **Estimate the duration**. If unsure, assume the worst case. The mcode + `task` tool description (`run_in_background`) says "Set to true when the + sub-task is open-ended or expected to take more than ~1 minute (deep + research, multi-step investigation, large refactors, anything you cannot + predict the duration of), so you can keep working and the result is + reported back automatically when it completes. Leave false (the default) + for short, well-scoped sub-tasks whose result you need right now to + continue. When in doubt for a long or uncertain task, prefer true." +2. **Choose a descriptive handle**. The agent (and the user) will need to + recognise it later. `dev-server` is good. `task1` is bad. +3. **Launch in the background using the matching tool**: + - Sub-agent: `task(..., run_in_background: true)`; mcode returns a + `task_id`. Store it. + - Shell: `bash(command: "npm run dev", run_in_background: true)`; mcode + returns a job handle (the exact shape is not part of the public + runtime contract; the host's job-control API is the source of + truth). Store the handle. +4. **Record the handle**. In a multi-step task, store the handle (task_id, + job id, log path) somewhere persistent — in a `world-state-tracking` file, + a `session-handoff` note, or in the running brief. +5. **Continue working**. The conversation does not block on the background + task. +6. **When the result matters**: + - Sub-agent: `task_query(task_id)` for status, `task_output(task_id)` for + output, `task_stop(task_id)` to stop. + - Shell: foreground `bash` call against the host's job-control API + (`Get-Process -Id ` / `Stop-Process -Id ` on Windows; + `ps -p ` / `kill ` on POSIX). Read the log file or stdout + from the original launch. + +## Output contract + +After activating this Skill, the agent's next message MUST include: + +- The chosen **handle** (`task_id` or job id) and the **tool** that produced it + (`task` / `bash`). +- The **expected duration estimate**. +- The **log or status path** so a later turn can check on it. +- Whether the agent is **continuing** or **blocking** on the result. + +## Common pitfalls + +- **Launching and forgetting the handle** — the user comes back in an hour, + the agent has no idea which process was which. Always record the handle. +- **Re-using a generic name** — `task1` collides; `cargo-test` does not. +- **Polling too eagerly** — a 5-minute build polled every 5 seconds wastes + context. Poll on a sensible cadence (every minute for builds, every 5 minutes + for downloads). +- **Killing without saving output** — read the log first, then kill, otherwise + the result is lost. +- **Using Codex-only `bash(task_name=..., action="kill")` syntax** — mcode + 0.2.4's `bash` does not have those fields. Use `task_stop(task_id=...)` for + sub-agents and a foreground `bash` call to the host's job-control API for + shell jobs. +- **Passing `model_config_id` in a background `task()` call** — the `task` + tool does not accept it (see `model-router`). The model is the session's + current model. + +## Example + +The example below is **MiniMax Code 0.2.4 `task` / `bash` tool syntax**. Two +background launches are demonstrated. + +### Sub-agent background + +```text +# Launch a long-running research sub-agent in the background. +# mcode returns a task_id we can later query / read / stop. + +> task( + description="Research migration paths", + agent_name="explore", + run_in_background=true, + prompt=""" + Investigate migration paths from to in the + codebase. Produce a markdown report at + /notes/migration.md comparing the top 3 candidates + with code samples, risk notes, and a recommended path. + This may take 10+ minutes; you can take your time. + """ + ) +# Returns immediately with: +# { task_id: "tsk_01HXYZ...", status: "queued" } + +# Later, check status: +> task_query(task_id="tsk_01HXYZ...") +# { task_id: "tsk_01HXYZ...", status: "running", ... } + +# Read partial output (the report grows as the sub-agent works): +> task_output(task_id="tsk_01HXYZ...", offset=0) +# +# next_offset: 12345 + +# Stop it if the user changed their mind: +> task_stop(task_id="tsk_01HXYZ...", reason="user changed scope") +# { task_id: "tsk_01HXYZ...", status: "stopping" } +``` + +### Shell background + +```text +# Launch a long-running dev server in the background. +# mcode returns a job id we can later target via the host's job-control API. + +> bash( + command="npm run dev", + run_in_background=true + ) +# Returns immediately with a job handle. The exact shape is not +# part of the public mcode 0.2.4 runtime contract; the host's +# job-control API is the source of truth. Treat the handle as +# opaque and pass it to the host's job-control API in a +# foreground `bash` call (e.g. `Stop-Process -Id ` / +# `kill ` on POSIX) when you need to stop the job. + +# Later, check whether it is still alive (foreground bash call): +> bash( + command="Get-Process -Id 12345 | Select-Object Id,ProcessName,StartTime" + ) +# (or on POSIX: `ps -p 12345 -o pid,etime,cmd`) + +# Stop it when done (foreground bash call to the host's job-control API): +> bash( + command="Stop-Process -Id 12345" + ) +# (or on POSIX: `kill 12345`) +``` + +The **decision** (background, with a recorded handle) is the same; the +**execution mechanism** depends on whether the background work is a sub-agent +(use `task` + `task_query` / `task_output` / `task_stop`) or a shell command +(use `bash(run_in_background: true)` + the host's job-control API). + +## Verification checklist + +- [ ] Did you estimate the duration before choosing background vs blocking? +- [ ] Did you choose a **descriptive** handle (not `task1`)? +- [ ] Did you use the right tool — `task` for sub-agents, `bash` for shell + commands? +- [ ] Did you set `run_in_background: true` (not Codex's `bash(task_name=...)`)? +- [ ] Did you record the handle (task_id / job id / log path) in a persistent place? +- [ ] Did you tell the user "I launched X in the background, here's the handle and + log path"? +- [ ] If you stopped it, did you use `task_stop(task_id=...)` (sub-agent) or + `Stop-Process` / `kill` via a foreground `bash` call (shell)? diff --git a/plugins/antianqi/codex-harness-patterns/skills/completion-audit/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/completion-audit/SKILL.md new file mode 100644 index 0000000..2ded362 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/completion-audit/SKILL.md @@ -0,0 +1,158 @@ +--- +name: completion-audit +description: | + Before saying "done", derive requirements, find authoritative evidence, verify each is ✅. + USE WHEN: about to say "done" / "complete" / "ship it" / "I finished" / "做完了" on non-trivial task, about to mark `todowrite` step done, about to update goal to `complete`, user has been waiting for "done" for several turns, "looks good" / "should be fine" / "应该好了" / "我试过没报错" / "我跑了测试都过了" / "I tested it" / "trust me" / "should work". + TRIGGER PHRASES: "做完了", "done", "complete", "ship it", "好了", "完成", "搞定", "I think we're done", "应该好了", "looks good", "我试过没报错", "我跑了测试都过了", "I tested it", "trust me", "should work". + SKIP WHEN: one-line edit, user can see result in chat immediately, user explicitly said "ship it" / "no more review" in this turn. +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.1" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/ext/goal/templates/goals/continuation.md +--- + +# Completion Audit + +Before you say "done," prove it. Not from memory, not from intent, not from "the tests passed" — +from **authoritative evidence for each requirement, against the actual current state**. + +This Skill is the difference between "I think I'm done" and "I have proven I am done." It is the +single most expensive lesson in agent work: **agents are confident they are done far more often +than they actually are**, and the cost of that false confidence is paid by the user. + +## When to use + +Activate when **any** of these is true: + +- You are about to say "done" / "complete" / "ship it" / "I finished" on a non-trivial task. +- A `todowrite` step is about to be marked `[x]` and that step touches anything the user will + rely on. +- An active thread goal (`goal-persistence` Skill) is about to be marked `complete`. +- The user has been waiting for "done" for several turns and you feel the urge to say it just to + end the turn. +- The work spanned multiple tool calls, multiple files, or multiple sub-tasks. + +## When NOT to use + +- A one-line edit. The cost of the audit is higher than the cost of a wrong "done". +- The deliverable is observable by the user in this turn (e.g. you just printed the result to + the chat). They can see it; you don't need to audit yourself. +- The user explicitly said "ship it" or "no more review" in this turn. + +## Process + +1. **Stop. Do not say "done" yet.** Even if it feels done, run this Skill. +2. **Derive concrete requirements** from the original objective — verbatim where possible. If + the user said "fix the auth bug," the requirements are "auth works for existing users" and + "no new bug introduced," not "I edited the auth file." Translate surface requests into + outcome requirements. +3. **For each requirement, identify the authoritative evidence** that would prove it. Not + "I think it works" — what would *you* check, given infinite time? + + | Requirement | Authoritative evidence | + |---|---| + | "auth works for existing users" | existing test suite green + 1 manual smoke | + | "doc updated" | grep / file content showing the new line | + | "API returns 200 on happy path" | curl output | + | "user can log in" | screenshot / log of one full login round-trip | + +4. **Inspect that evidence** — the *current* state, not a memory. The test you ran three + turns ago is no longer authoritative if the code has changed since. Re-run, re-read, + re-grep. +5. **For each item, classify the result**: + + - ✅ **proves completion** — the evidence matches the requirement unambiguously + - ❌ **contradicts completion** — the evidence shows the requirement is not met + - 🟡 **incomplete work** — partial evidence, some sub-requirement unmet + - ⚪ **too weak to verify** — the evidence does not cover the requirement's scope + - 🚫 **missing** — no evidence was gathered + +6. **If any item is not ✅**, do not declare done. State the unfinished item and continue. +7. **If all items are ✅**, declare done in this shape: + + ```text + ✅ Done. Verified against: + - : — ✅ + - : — ✅ + - ... + ``` + +8. **On active thread goal**: report the final token usage to the user + (per `goal-persistence` Skill — token accountability). State the actual final number, not a + rough estimate. + +## Output contract + +The user sees, in this order: + +- One-line "verifying before declaring done" preamble. +- The audit table (one row per requirement, one column per: requirement, evidence, result). +- The verdict: ✅ all green / 🟡 unfinished item(s) / ❌ contradiction(s). +- (If all green + active goal) The final token usage. + +## Example + +```text +Verifying before declaring "auth refactor done". + +| Requirement | Evidence | Result | +|--------------------------------------------|---------------------------------------------------|--------| +| 12 existing SAML tests still pass | `cargo test auth::saml` (just re-ran) | ✅ | +| New OIDC login works with Okta | `cargo test auth::oidc` + manual smoke (logged in) | ✅ | +| `auth.oidc_enabled` defaults to `false` | `grep "oidc_enabled" config/default.toml` | ✅ | +| No new dependencies in `Cargo.toml` | `git diff Cargo.toml` | ✅ | +| Docs updated in `docs/auth.md` | `grep "oidc_enabled" docs/auth.md` | ✅ | +| No off-by-one in `expires_at` | manual code review of `src/auth/oidc/mod.rs:42` | ✅ | + +Token usage: 18,420 / 20,000 (92% of goal budget). +✅ Done. +``` + +Counter-example (audit caught a hole): + +```text +Verifying before declaring done. + +| Requirement | Evidence | Result | +|--------------------------------------|---------------------------------------------------|--------| +| Existing tests pass | `cargo test` | ✅ | +| Manual smoke test passes | (not run) | ⚪ | +| Public API unchanged | `git diff src/auth/mod.rs` | ❌ `login()` signature changed — added `provider` param | +| Docs mention new OIDC config | `grep "oidc" docs/auth.md` | ❌ doc only describes SAML | + +🟡 Two requirements unmet. The public API changed but was not declared, and the +docs only describe SAML. The user must decide: revert the public API change, or +accept it and update the docs + declare the API break. +``` + +## Common pitfalls + +- **Do not skip the audit because "I just ran the tests."** The tests you ran three + turns ago are not the current state. Re-run, re-read. +- **Do not classify "I wrote the code" as evidence.** Writing code is not the requirement; + the requirement is what the code *does*. Substitute "the code does X" with "I verified X + by running Y." +- **Do not let the audit become a rubber stamp.** If the verdict is "all green" 100% of + the time, the audit is not working. The whole point is to catch what you missed. +- **Do not declare done with 🟡 items.** The verdict must be all ✅ or you do not + declare done. Surface the unfinished items. +- **Do not treat "user said ship it" as a reason to skip the audit.** The user said ship + it because they *expect* the audit to have been done. Skipping is a betrayal. +- **Do not substitute a narrower, safer, or merely-compatible solution.** If the user + asked for OIDC and you built OAuth2 "because it's similar," the requirement is unmet even + if the tests pass. +- **Do not mark a goal complete because the budget is nearly exhausted or because you are + stopping work.** The audit is the only thing that gets to declare done. + +## Verification checklist + +- [ ] Did you stop before saying "done"? +- [ ] Did you derive concrete requirements (not surface requests)? +- [ ] For each requirement, did you identify the *authoritative* evidence? +- [ ] Did you inspect the *current* state (not memory of past work)? +- [ ] Is each item classified as ✅ / ❌ / 🟡 / ⚪ / 🚫? +- [ ] Is the verdict all ✅? If not, did you surface the unfinished items? +- [ ] (Active goal) Did you report final token usage? +- [ ] Did the user see the audit table before you declared done? diff --git a/plugins/antianqi/codex-harness-patterns/skills/context-pressure-compact/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/context-pressure-compact/SKILL.md new file mode 100644 index 0000000..d8653c3 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/context-pressure-compact/SKILL.md @@ -0,0 +1,176 @@ +--- +name: context-pressure-compact +description: | + Compress a long-running multi-step task into a structured snapshot before continuing. + USE WHEN: `todowrite` > 5 items, after ~20 tool calls, context getting full, agent has lost track of goal, user said "compact" / "summarize" / "refocus" / "压缩" / "总结" / "到哪了", before context window fills (>80%), before `context-pressure-compact` boundary. + TRIGGER PHRASES: "compact", "summarize", "refocus", "压缩", "总结", "到哪了", "context 满了", "忘了目标", "we're getting lost", "compress", "snapshot". + SKIP WHEN: short task (<5 tool calls), user in middle of dictating a request, user said "do not summarize" / "keep everything". +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "1.0.1" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/core/src/compact.rs and core/src/compact_remote_v2.rs + changes-from-v0.1.0: "Added the 64K retention budget concept (P-10 v2); added 'discarded N tool calls and M lines' reporting rule; cross-referenced world-state-tracking and goal-persistence so compaction is the single coordination point." +--- + +# Context Pressure Compact + +Compress the running state of a long task into a structured snapshot, then keep working +from the snapshot. The agent loses the noisy middle (failed attempts, finished steps, stale +tool output) but keeps the goal, the decisions, and the next move. + +**v1.0 update**: now incorporates the 64K retention budget from Codex's `compact_remote_v2.rs` +— the "important" messages preserved after a compact should target ~64,000 tokens, not be +unbounded. + +## When to use + +Activate when **any** of these is true: + +- The active `todowrite` has more than 5 items, **and** at least 2 are still in progress. +- The agent has executed roughly 20 or more tool calls since the user request. +- The user says "compact", "summarize so far", "refocus", "we're getting lost", or + "let me see the state". +- A tool call from `tool-output-budget` is being applied to a string the agent will not need + again (it has been superseded by newer output). +- A sub-task boundary (a self-contained feature is done; the next user step starts a new one). +- The estimated total tokens (history + current message) is approaching the model context + window (typically 80% of the window is the trigger). + +Do **not** use this Skill for short tasks (< 5 tool calls, < 3 `todowrite` items). Compaction has +its own cost; the savings only matter once the conversation is genuinely heavy. + +## When NOT to use + +- A single one-shot question. The user wants an answer, not a checkpoint. +- The user is in the middle of dictating a multi-step request. Finish listening first. +- The user explicitly said "do not summarize" or "keep everything". + +## Process + +1. **Freeze new work.** Do not start any new tool call before the snapshot is written. +2. **Target the 64K retention budget.** After compact, the **retained** messages (the + important ones — current goal, recent decisions, key file paths) should target ~64,000 + tokens. Discard the rest. This is `RETAINED_MESSAGE_TOKEN_BUDGET` from + `compact_remote_v2.rs`. If you retain more, the next compaction will arrive sooner than + expected; if you retain much less, the agent will lose context. + +3. **Write the snapshot** to a single fenced block, in this exact shape: + + ```markdown + ## Compact Snapshot — + + **Goal**: + + **Done** (these are finished; do not re-do them): + - + + **In progress** (these are partially done; carry the partial state forward): + - + + **Decisions made** (so future you doesn't re-argue them): + - + + **Key file paths** (absolute paths the next step will need): + - /path/to/foo + - /path/to/bar + + **Blockers / open questions** (so the user can answer them upfront next turn): + - + + **Next concrete step**: + + **Retained token estimate**: (target: ~64K, see RETAINED_MESSAGE_TOKEN_BUDGET) + **Discarded this turn**: + ``` + +4. **Optionally persist to disk** if the user has a working directory. Default path: + `.minimax/snapshots/-.md`. The user can `read` it later to reload + context. +5. **Drop the noisy middle from the next prompt.** After the snapshot, your next response + should start from "Next concrete step", not from re-stating the goal. +6. **Continue working** as if the snapshot is the only context. Do not re-fetch the files + you already listed under "Key file paths" unless you need to re-read them. +7. **Coordinate with other state files** (cross-references): + - **`goal-persistence`** file: survives compaction unchanged (its file is on disk). + - **`world-state-tracking`** file: survives compaction unchanged. + - **`subagent-family-tracking`** file: survives compaction unchanged. + - **`goal-token-budgeting`** usage log: keep the latest row, drop the history. + - **`todowrite`**: keep Done/In progress sections, drop the granular "attempted X then + Y then Z" history. + +## Output contract + +Every time you apply this Skill, the user sees: + +- The Compact Snapshot block (as above). +- An optional one-line "discarded N tool calls and M lines of intermediate output" note. +- The next concrete step, phrased as an action the user can sanity-check. +- The retained token estimate (so the user knows the budget is being respected). + +## Example + +```markdown +## Compact Snapshot — 2026-08-23T23:55:00Z — step 7 + +**Goal**: Refactor the auth subsystem to support OIDC without breaking the existing SAML path. + +**Done** (these are finished; do not re-do them): +- Mapped current auth flow in src/auth/. Wrote findings to .minimax/snapshots/auth-flow.md +- Identified 4 injection points: login(), callback(), refresh(), logout() +- Confirmed test coverage: 12 of 14 files have unit tests (2 missing: logout, session) + +**In progress** (these are partially done; carry the partial state forward): +- Designing the OIDC adapter interface. Stopped at: how to represent the "provider" enum + vs the existing "IdP" interface. Need to decide: 1) extend IdP, 2) new OidcProvider sibling, + 3) generic Provider with config-driven dispatch. + +**Decisions made** (so future you doesn't re-argue them): +- Keep SAML on the legacy code path; OIDC gets a parallel module. (Reason: SAML contract is + frozen, no test budget to re-validate.) +- Reject (3) generic Provider — too much config surface for marginal benefit. + +**Key file paths** (absolute paths the next step will need): +- /repo/src/auth/idp.rs +- /repo/src/auth/callback.rs +- /repo/tests/auth/ + +**Blockers / open questions** (so the user can answer them upfront next turn): +- Should the OIDC module own token storage, or reuse the existing session store? +- Does IT have a preferred OIDC library (openidconnect vs oauth2)? + +**Next concrete step**: Draft the OidcProvider trait + one impl for `provider = "okta"`, then +show the diff to the user before touching the callback. + +**Retained token estimate**: ~58,000 tokens (target: ~64K, well within budget) +**Discarded this turn**: 14 tool calls, ~12,000 lines of intermediate output +``` + +## Common pitfalls + +- **Do not rewrite history.** The snapshot records what actually happened, including the + wrong path you took. Future you needs the wrong path to avoid re-walking it. +- **Do not omit Blockers.** This is the most valuable section — it's how the user unblocks + you with one sentence instead of three round trips. +- **Do not skip "Key file paths".** Absolute paths save the next turn from `glob` and `grep`. +- **Do not snap every turn.** A snapshot after every tool call is noise. Use the triggers + above. +- **Do not nest snapshots.** One snapshot is the new ground truth; the previous one is + superseded and can be discarded (or moved to `.minimax/snapshots/archive/`). +- **Do not exceed the 64K retention target.** A snapshot of 200K tokens defeats the purpose. + If your retention is naturally > 64K, you need to compress *more* (drop more history, + shorten the in-progress description), not less. +- **Do not duplicate other state files.** The goal / world-state / family files survive + on disk. Reference them by path; do not copy them into the snapshot. + +## Verification checklist + +- [ ] Is the goal copied verbatim from the user? +- [ ] Are Done / In progress / Decisions / Paths / Blockers / Next step all present and + non-empty (or explicitly "none")? +- [ ] Is the retained token estimate present and within the 64K target (±20%)? +- [ ] Did you avoid starting a new tool call before the snapshot was written? +- [ ] Did you drop the noisy middle from the next prompt? +- [ ] Did the user get a chance to answer Blockers before you kept going? +- [ ] Did you reference (not duplicate) other state files by path? diff --git a/plugins/antianqi/codex-harness-patterns/skills/delegate-with-context/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/delegate-with-context/SKILL.md new file mode 100644 index 0000000..4a906a1 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/delegate-with-context/SKILL.md @@ -0,0 +1,152 @@ +--- +name: delegate-with-context +description: | + Hand off a sub-task to a sub-agent with a tight, complete brief in the `prompt` — not the full conversation history. Apply the 4-part message envelope (Task name / Sender / Task / Payload + return path). + USE WHEN: about to call `task()` to hand off a sub-task, the full conversation history is too large to forward, a minimal-context brief would do, the previous sub-agent failed because the brief was incomplete. + TRIGGER PHRASES: "delegate", "hand off", "sub-agent", "delegate this", "delegate to", "派给", "委派", "让 sub-agent 干", "把 ... 交给 ...". + SKIP WHEN: the sub-task is so trivial a `read` will do, you are about to do the work yourself, the user explicitly wants you (not a sub-agent) to do it. +license: Apache-2.0 +compatibility: Targets MiniMax Code 0.2.4 `task` tool. Verified against the bundled `cli.js` schema (`description` / `prompt` / `agent_name` / `run_in_background`). The 4-part envelope is host-neutral design; on mcode the envelope goes into the `prompt` string. `agent_name` is the canonical mcode spelling (`explore` / `worker` / `verifier`); `mavis` is the root agent, not a sub-agent. +metadata: + author: antianqi + version: "1.2.0" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs (InterAgentCommunication) and core/src/session/multi_agents.rs (CollabAgentSpawn); the 4-part envelope is the portable design; on mcode the envelope fills the `prompt` field + changes-from-v1.1.0: "Replaced `subagent_type=` with the canonical mcode `agent_name=`. Replaced `brief=` with `prompt=`. Dropped `mavis` from the sub-agent list (mavis is the root). The 4-part envelope is unchanged but now lives inside the `prompt` string, not in a separate `brief` parameter. The host-pseudocode 'Codex-harness style' block was removed; the mcode 0.2.4 schema is now the only one shown." +--- + +# Delegate with Context + +When handing off work to a sub-agent, the agent has two extremes: + +1. **Forward everything**: the sub-agent sees the full parent history. Costs + tokens, dilutes focus, may leak irrelevant detail. +2. **Forward nothing**: the sub-agent gets a one-line "go do X". The brief is + almost always incomplete, and the sub-agent re-derives incorrectly. + +This Skill is about the **middle ground**: a tight, complete, structured brief that +gives the sub-agent everything it needs and nothing it does not. + +## mcode 0.2.4 surface + +The `task` tool on mcode 0.2.4: + +```text +task( + description: string, // 3-5 word label, required + prompt: string, // the brief, required + agent_name: "explore" | "worker" | "verifier", // required + run_in_background?: boolean // optional +) +``` + +The 4-part envelope is **the design**; on mcode it goes into the `prompt` +string verbatim. `agent_name` is the canonical spelling. `agent_name=` is +accepted as a runtime alias but the Skills prefer the canonical form. + +`mavis` is the root agent (the calling session itself), not a sub-agent. It +has no `agent.md` manifest and cannot be used as `agent_name`. + +## When to use + +Activate when **any** of these is true: + +- You are about to call `task` to hand off a sub-task. +- The full conversation history is too large to forward (cost / focus). +- A previous sub-agent failed because the brief was incomplete. +- You want the sub-agent's work to be auditable against a written contract. + +## When NOT to use + +- The sub-task is so trivial a single `read` will do (no sub-agent needed). +- You are about to do the work yourself. +- The user explicitly wants you (not a sub-agent) to do it. + +## Process + +1. **Classify the sub-task** (see `fork-context-decision`): + - Self-contained: `none` (just the brief in `prompt`). + - Needs prior context: `N` or `all` → inline the prior turns into `prompt` before + the brief. +2. **Pick the sub-agent type** from `{explore, worker, verifier}` based on what + the sub-task needs (read / write+run / run-only). +3. **Write the 4-part envelope** below. The envelope is the **portable** part of + the brief — host `task` tools all accept a brief string. +4. **Choose context level** (see `fork-context-decision`) and inline the chosen + context into `prompt` before the envelope (or skip if `none`). +5. **Document the return path** — how the sub-agent should hand the result back. + +## The 4-part envelope + +Every sub-agent brief (the body of the `prompt` field) MUST have these 4 parts, +in order: + +```text +Task name: +Sender: +Task: +Payload: +Return: +``` + +Each part is mandatory. Skipping any one is the difference between a working +sub-task and a confused one. + +### Field-by-field + +| Field | Purpose | Bad | Good | +|---|---|---|---| +| Task name | The handle you'll refer to later. | `task1` | `investigate-lint-flake` | +| Sender | Who is asking, so the sub-agent knows the audience. | _(omitted)_ | `main agent` | +| Task | One-sentence scope. | `fix the tests` | `Investigate why test_lint.py flakes on Windows but not Linux. Produce a 1-paragraph root-cause analysis.` | +| Payload | The actual content the sub-agent needs. | `see above` | Links to the file, the prior turn's tool output, the user's exact request. | +| Return | Where the result goes, in what format. | _(omitted)_ | `Append a section to /notes/lint.md titled "## Windows flake root cause" with 1 paragraph.` | + +## Common pitfalls + +- **Omitting the return path** — the sub-agent finishes and has no idea what + to do with the result. Always specify. +- **Putting the brief in `Task` and the question in `Payload`** — the sub-agent + sees both, but the wrong field is the "one-sentence scope". Keep `Task` + short. +- **Forwarding the full history when `none` would do** — costs tokens and + dilutes focus. Decide first. +- **Using `agent_name="mavis"`** — mavis is the root agent, not a sub-agent. + Use `explore` / `worker` / `verifier`. +- **Writing the envelope in a separate `brief=` field** — mcode 0.2.4 does not + expose a `brief` field. Put it in `prompt`. + +## Example + +The example below is **MiniMax Code 0.2.4 `task` tool syntax**. The envelope +is the `prompt` body; the call shape is the only one that exists on mcode 0.2.4. + +```text +> task( + description="Investigate lint flake", + agent_name="worker", // or "explore" if read-only + prompt=""" + Task name: investigate-lint-flake + Sender: main agent + Task: Investigate why /tests/test_lint.py flakes on + Windows but not Linux. Produce a 1-paragraph root-cause + analysis. + Payload: /tests/test_lint.py (line 47 is the failure); + prior turn tool output (inlined above this prompt if + context level > none). + Return: Append a section to /notes/lint.md titled + "## Windows flake root cause" with 1 paragraph. + """ + ) +``` + +The **envelope** is the design; on mcode the envelope fills the `prompt` +field. There is no separate `brief` parameter. + +## Verification checklist + +- [ ] Did you classify the sub-task (self-contained vs context-dependent)? +- [ ] Did you pick `agent_name` from `{explore, worker, verifier}`? +- [ ] Did you write all 4 envelope parts (Task name / Sender / Task / Payload / Return)? +- [ ] Did you specify the **return path** (where the result goes)? +- [ ] Did you choose the right context level (via `fork-context-decision`)? +- [ ] Did you put the envelope inside the `prompt` field (not a separate `brief`)? diff --git a/plugins/antianqi/codex-harness-patterns/skills/error-recovery-strategy/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/error-recovery-strategy/SKILL.md new file mode 100644 index 0000000..1a77340 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/error-recovery-strategy/SKILL.md @@ -0,0 +1,154 @@ +--- +name: error-recovery-strategy +description: | + Classify error into 4 buckets (transient / deterministic / stale / unknown) and pick one of 5 actions (retry / switch / fallback / refresh-then-retry / ask-user / skip). + USE WHEN: tool returns non-success, sub-agent `status: closed-failed`, exception escapes, timeout fires, weird partial-success result, ECONNREFUSED / 5xx / 429 / timeout / permission denied / "command not found" / "fail" / "error" / "出错了" / "挂" / "失败". + TRIGGER PHRASES: "出错了", "failed", "挂", "error", "失败", "fail", "permission denied", "command not found", "ECONNREFUSED", "timeout", "挂了", "再试一次", "retry", "这不行", "没用", "fallback", "退路", "不行", "跑不通", "broken". + SKIP WHEN: operation succeeded, error is in user input (clarification case), error is part of expected flow (grep 0 matches). +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.2" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/code-mode/src/grpc_session/reconnect.rs and core/src/session/multi_agents.rs + changes-from-v0.1.1: "Audit gap: the sub-agent invocation example in '## Example' (line 115) used the Codex-style `task(subagent=...)` shape; switched to the canonical mcode 0.2.4 `task(agent_name=..., prompt=...)` shape (mcode accepts `subagent_type=` as a runtime alias but `agent_name=` is the canonical form per `cli.js:B6c`). The round-1 72952c9 amend touched 4 Skills (fork-context-decision / delegate-with-context / parallel-fanout / model-router) and missed this 5th; the v1.0.4 round-2 close-out also missed it. Caught by the v1.0.4 audit sweep across all 23 Skills' code blocks. The rest of the Skill body is unchanged." +--- + +# Error Recovery Strategy + +When something fails, the default human reaction is "retry." That is often the **wrong** +default. Retrying a permission-denied file write burns the same error three times in a row. +Retrying a network timeout that won't resolve in 30 seconds burns three minutes. + +This Skill codifies the decision: **categorize the error first, then pick one of five +recovery actions, then commit to it explicitly.** + +## When to use + +Activate when **any** of these is true: + +- A tool call returns a non-success result (non-zero exit, HTTP 4xx/5xx, exception, + error message). +- A sub-agent reports `status: closed-failed` in the family file. +- An exception escapes from any of your own code or a library you called. +- A timeout fires on a long-running operation. +- A "weird" result comes back that might be a partial success (e.g. command exited 0 + but produced no output where you expected output). + +## When NOT to use + +- The operation succeeded. Do not second-guess success. +- The error is in user input (bad prompt, missing file the user should provide). That is + not a recovery case; it is a clarification case. +- The error is part of expected flow (e.g. a `grep` returning 0 matches is an exit-1, but + it is not a failure for the search use case). + +## Process + +1. **Stop. Do not retry yet.** Even if the obvious answer is "retry," run this Skill. +2. **Categorize the error** into one of four buckets: + + | Bucket | Signals | Examples | + |---|---|---| + | **transient** | Will probably succeed if tried again soon | Network timeout, HTTP 429/503, "ECONNRESET", "temporarily unavailable", rate limit | + | **deterministic** | Will fail every time the same way | Permission denied, file not found, "command not found", invalid syntax, wrong credentials | + | **stale** | Was true once, not anymore | "Index out of date", "stale lock", "file modified since read" | + | **unknown** | Cannot classify from the message alone | Generic exception, empty error, "something went wrong" | + +3. **Pick one of five recovery actions** based on the bucket: + + | Bucket | Default action | When to deviate | + |---|---|---| + | **transient** | `retry-with-backoff` (see next skill) | Never skip the backoff. If retries exceed the budget, escalate to `ask-user`. | + | **deterministic** | `switch` (try an alternative tool) or `fallback` (degrade gracefully) | If no alternative exists, `ask-user`. | + | **stale** | `refresh-then-retry` (re-read state, then retry once) | If still failing after one refresh, escalate to `ask-user`. | + | **unknown** | `ask-user` (or `skip-with-warning` if user signal allowed it) | Never silently retry an unknown error. | + +4. **Execute the chosen action explicitly** — do not bundle it with other tool calls. + The user must be able to see "I retried" / "I switched to X" / "I asked you" as a discrete + step in the response. + +5. **Record the decision in the response** so the user can audit: + + ```text + Recovery decision: + Reason: + ``` + +6. **After the action**, re-evaluate. If the action also failed, re-categorize (the bucket + may have changed — e.g. a "transient" error that persists for 5 retries is now a + "deterministic" error in practice) and pick a new action. + +## Output contract + +The user sees, in this order: + +- One-line error summary (what went wrong, with the tool / file / op named). +- Bucket classification: `transient` / `deterministic` / `stale` / `unknown`. +- Chosen action: `retry` / `switch` / `fallback` / `refresh-then-retry` / `ask-user` / `skip`. +- One-line reason citing the bucket. +- (If retry) the backoff plan: "N attempts, M seconds between, exponential with jitter." +- (If ask) the user-facing question with concrete options. +- (If fallback / skip) what degraded behaviour the user is now getting. + +## Example + +```text +> bash(command="cat /etc/hosts") + +Error: cat: /etc/hosts: Permission denied +Recovery decision: switch +Reason: bucket=deterministic (permission denied won't change on retry); + switching to `read` with explicit `path=/etc/hosts` (read may have different + permission semantics than shell cat) before asking the user. +``` + +```text +> bash(command="curl https://internal-api.example.com/health") + +Error: connect ECONNREFUSED 10.0.0.5:443 +Recovery decision: retry +Reason: bucket=transient (network refused); will retry 3 times with 2s/4s/8s backoff + and 500ms jitter; if all fail, escalate to ask-user. +``` + +```text +> task(agent_name="explore", prompt="...") + +Status: closed-failed. Sub-agent error: "context window exceeded". +Recovery decision: ask-user +Reason: bucket=unknown (sub-agent did not return a clear error class); do not silently + retry with a smaller brief; surface to user with options: + (a) reduce the sub-task scope + (b) switch to a model with larger context + (c) skip this sub-task +``` + +## Common pitfalls + +- **Do not default to retry.** Retry is only correct for `transient` (and a few `stale`). + For `deterministic` and `unknown`, retry is the most expensive wrong answer. +- **Do not bundle the recovery with other tool calls.** A retry hidden inside a larger + batch of work is invisible. Always surface the recovery as a discrete step. +- **Do not re-categorize silently.** If you categorize as `transient`, retry 3 times, + and it still fails, the bucket is now `deterministic` or `unknown` — say so out loud. +- **Do not ask the user a vague question.** "What should I do?" is not an option. Give + the user 2-4 concrete options based on the bucket. +- **Do not skip-with-warning without permission.** The user did not pre-authorize + silent skips. If the work is optional, the user should have said so at the start. +- **Do not blame the tool.** The tool did what it was told. Categorize the error + honestly, not defensively. +- **Do not loop on retry forever.** Always have a max-attempt budget; on exhaustion, + escalate to `ask-user`. + +## Verification checklist + +- [ ] Did you categorize the error into one of four buckets before picking an action? +- [ ] Did you pick one of five actions based on the bucket (not the default)? +- [ ] Did you state the recovery decision in the response, with the bucket and reason? +- [ ] (Retry) Did you specify the backoff plan (attempts, intervals, jitter)? +- [ ] (Ask) Did you give 2-4 concrete options, not "what should I do?" +- [ ] (Switch / Fallback) Did you name the alternative tool / the degraded behaviour? +- [ ] (Skip) Did you confirm the user pre-authorized this work as optional? +- [ ] Did you re-evaluate after the action and re-categorize if it failed? +- [ ] Is the recovery step a discrete line in the response (not bundled)? diff --git a/plugins/antianqi/codex-harness-patterns/skills/fork-context-decision/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/fork-context-decision/SKILL.md new file mode 100644 index 0000000..e4552f8 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/fork-context-decision/SKILL.md @@ -0,0 +1,232 @@ +--- +name: fork-context-decision +description: | + Decide how much parent context to include in a sub-agent's `prompt` before spawning it. Pick "all / N turns / brief only" explicitly, not by accident. + USE WHEN: about to call `task()` to hand off work, designing a multi-agent flow, sub-agent failed and debugging whether cause was over- or under-forking, user said "give it the full history" / "no history" / "just the brief" / "don't carry context" / "深度 fork" / "不要带 context". + TRIGGER PHRASES: "fork 深度", "give it the full history", "深 fork", "no history", "just the brief", "不要带 context", "fork 0", "fork all", "完全独立会话", "轻量 context". + SKIP WHEN: sub-task is trivial (one-line read), you have already decided "no context" (no decision to make). +license: Apache-2.0 +compatibility: Targets MiniMax Code 0.2.4 `task` tool. Verified against the bundled `cli.js` schema (`description` / `prompt` / `agent_name` / `run_in_background`). `agent_name` is the canonical mcode spelling; `agent_name` is accepted as an alias by mcode's normaliser but the Skills prefer the canonical form. The mcode `task` tool has no `history` / `fork_turns` / `context_size` parameter — all context sharing is done by what you write into the `prompt` string itself. +metadata: + author: antianqi + version: "0.3.0" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/core/src/session/multi_agents.rs (design principle; the 3 fork modes are portable; the prompt-content decision is host-neutral) + changes-from-v0.2.0: "Removed the v0.2.0 `history=N` PLACEHOLDER — the mcode 0.2.4 `task` tool has no context-sharing parameter, so the 3 fork modes (all / N / none) are now expressed by what the calling agent writes into the `prompt` (full conversation dump / last N turns inline / brief only). Replaced `subagent_type=` with the canonical mcode `agent_name=`. Dropped `mavis` from the subagent list because `mavis` is the root agent (it has no `agent.md` subagent manifest and cannot be used as `agent_name`); the 3 actual mcode sub-agent types are `explore` / `worker` / `verifier`." +--- + +# Fork Context Decision + +How much parent context to pass to a sub-agent is the **single largest cost lever** in +multi-agent work. Too much and you double the model's context; too little and the +sub-agent cannot do its job because it cannot see what came before. Pick wrong either +way, the work slows down or silently fails. + +This Skill codifies the decision so the agent makes it explicitly, not by accident. + +## The hard fact about mcode 0.2.4 + +The mcode 0.2.4 `task` tool **has no parameter for context sharing**. The +canonical schema is: + +```text +task( + description: string, // 3-5 word label, required + prompt: string, // the task itself, required + agent_name: string, // "explore" | "worker" | "verifier", required + run_in_background?: boolean // optional +) +``` + +`agent_name` is accepted as a runtime alias (the normaliser at `cli.js:j6c` converts +it to `subagent_type`) but the canonical form is `agent_name`. There is **no +`history=`, no `fork_turns=`, no `context_size=`** — the calling agent has full +control of what the sub-agent sees by writing it into the `prompt` string. So +the 3 fork modes (all / N / none) become a `prompt` content decision, not a +parameter. + +## mcode 0.2.4 sub-agent types + +The mcode 0.2.4 `task` tool accepts three sub-agent types as the value of +`agent_name=`. The on-disk path of each sub-agent's manifest is +host-internal (varies across installs and platforms) and is **not** +part of the public runtime contract. The Skills in this plugin rely on +the `agent_name` parameter, not on any on-disk manifest path; do not +hard-code `assets/agents//agent.md` or similar layouts. + +| `agent_name` | Tools | Use when | +|---|---|---| +| `explore` | `read`, `grep`, `glob`, `web_fetch` | Read-only investigation; cannot write or run commands. | +| `worker` | `read`, `write`, `edit`, `bash`, `grep`, `glob`, `todowrite`, `web_fetch`, `website_deploy` | Implementation; full read/write/run. | +| `verifier` | `read`, `grep`, `glob`, `bash`, `web_fetch` | Has `bash` but **no `write` / `edit` / `website_deploy`**: can run checks, cannot modify. | + +`mavis` is the **root** agent (different layout: `modes/`, `skills/`, +persona files). It is not an `agent_name` value; the calling session +already *is* mavis. The v0.2.0 list that included `mavis` as a sub-agent +option is removed. + +## When to use + +Activate when **any** of these is true: + +- You are about to call `task` (or any sub-agent spawn) to hand off a sub-task. +- You are designing a multi-agent flow (`parallel-fanout`, `delegate-with-context`). +- A previous sub-agent failed and you are debugging whether the cause was over- or + under-forking. +- You are about to spawn a sub-agent and feel unsure whether to pass context or not. + +## When NOT to use + +- The sub-agent tool does not accept any context / fork parameter (then the decision + is forced; skip). +- The sub-task is so trivial that the cost difference is noise (a one-line `read`). +- You have already decided "no context" (no decision to make). + +## The decision + +Three choices, ordered by cost. Each is implemented by what you write into +the `prompt` field. + +| Choice | What the sub-agent sees (in `prompt`) | Use when | +|---|---|---| +| **`all`** | The full parent conversation history, inlined or attached. | Sub-agent must reason about a prior decision, debug an earlier failure, or reuse a result the parent has already computed. | +| **`N`** (integer) | The last N turns, inlined. | Sub-agent needs recent context but not the full history. | +| **`none`** (or `0` / `brief`) | Only the brief you write inline. | Sub-task is self-contained; the brief is enough. | + +### Pseudo-cost table + +| Choice | Token cost | Sub-agent accuracy on context-dependent tasks | Sub-agent accuracy on self-contained tasks | +|---|---|---|---| +| `all` | 100% | high | low (distracted by noise) | +| `N` | moderate | high (if N is enough) | high | +| `none` | minimal | low | high (focused) | + +## Process + +1. **Classify the sub-task**. Does it need to see any prior turn? + - If **yes**: choose `all` or `N`. + - If **no**: choose `none` and write a self-contained brief. +2. **If you chose `N`**: pick the smallest N that still works. + - Start at 3. If the sub-agent asks for more context, bump to 5, then 10, then `all`. +3. **Build the `prompt`** for the chosen level: + - `all` → concatenate the entire prior conversation, then append the brief. + - `N` → concatenate the last N turns verbatim, then append the brief. + - `none` → just the brief, no prior content. +4. **Document the choice in the brief**: + - `Context level: ` + - `Reason: ` +5. **If the sub-agent fails**, retry with the next higher N before changing anything + else. A `none` that failed is almost always a brief problem, not a context + problem — but try `N=3` first because the cost is small. + +## Output contract + +After activating this Skill, the next `task` call MUST: + +- Pick a `agent_name` from `{explore, worker, verifier}` based on what the + sub-task needs (read / write+run / run-only). +- Include a `description` (3-5 word label). +- Build the `prompt` according to the chosen context level. +- Either inline the context (for `N` or `all`) or start the `prompt` with the + brief header: + +```text +# Sub-task brief +Context level: +Reason: +Sub-agent type: + +``` + +## Common pitfalls + +- **Defaulting to `all` "to be safe"** — costs you every turn, and dilutes the + sub-agent's focus. Only use `all` if you have a concrete reason. +- **Defaulting to `none` "to save cost"** — the sub-agent re-derives from the brief, + and the brief is often wrong. The cost saved is the cost of the bug. +- **Not documenting the choice** — a future reviewer (or you, tomorrow) cannot tell + why `N=3` was chosen. Document or it didn't happen. +- **Changing the brief without changing `N`** — if the brief is wrong, more context + doesn't help. Fix the brief first. +- **Writing a single tool call expecting the host to manage history** — mcode 0.2.4 + does not auto-attach prior conversation. The decision is in the call you write. +- **Using `agent_name="mavis"`** — mavis is the root agent, not a sub-agent. + Use `explore` / `worker` / `verifier`. + +## Example + +The example below uses **MiniMax Code 0.2.4 `task` tool syntax**. The 3 fork +modes are demonstrated; the `prompt` content is what changes between them. + +```text +# Context level: none +# Sub-agent: worker (writes files) +# Cost: minimal +> task( + description="Investigate lint flake", + agent_name="worker", + prompt=""" + Context level: none + Reason: this is a self-contained repro request. + Sub-agent type: worker + + Investigate why /tests/test_lint.py line 47 + flakes on Windows but not Linux. Write a 1-paragraph + root-cause analysis to /notes/lint.md under + "## Windows flake root cause". + """ + ) + +# Context level: N=3 +# Sub-agent: worker +# Cost: 3 prior turns inlined +> task( + description="Diagnose test failure", + agent_name="worker", + prompt=""" + Context level: 3 + Reason: the previous tool output is the most likely + cause; the sub-agent needs to see it. + Sub-agent type: worker + + === last 3 turns (verbatim) === + + + + + Investigate the line 47 failure and write a fix to + /tests/test_lint.py. + """ + ) + +# Context level: all +# Sub-agent: explore (read-only) +# Cost: 100% of parent context +> task( + description="Audit earlier decision", + agent_name="explore", + prompt=""" + Context level: all + Reason: the sub-agent must reason about a decision + made 12 turns ago. + Sub-agent type: explore + + === full prior conversation (verbatim) === + + + Find every place we used 'json.dumps(indent=2)' and + confirm the output matches the user's earlier spec. + """ + ) +``` + +The **decision** (3 turns vs full history vs brief only) is the same; the +**implementation** is what you put in the `prompt` string. + +## Verification checklist + +- [ ] Did you classify the sub-task before choosing? +- [ ] Did you pick the smallest N that works (not jumping straight to `all`)? +- [ ] Did you pick a `agent_name` from `{explore, worker, verifier}`? +- [ ] Did you document the context level in the brief header? +- [ ] Did you build the `prompt` so the sub-agent actually sees the chosen context? +- [ ] If the sub-agent failed, did you bump N before changing the brief? diff --git a/plugins/antianqi/codex-harness-patterns/skills/goal-persistence/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/goal-persistence/SKILL.md new file mode 100644 index 0000000..2443b00 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/goal-persistence/SKILL.md @@ -0,0 +1,270 @@ +--- +name: goal-persistence +description: | + Maintain explicit north-star goal for the whole thread that survives compactions and detects drift. + USE WHEN: non-trivial task stated, user redirected mid-task ("actually do X instead" / "wait scrap that" / "现在改成"), before `context-pressure-compact`, about to mark done, user said "我们的目标是" / "we're trying to" / "我想要的" / "what I want is" / "目标是", agent drifting (tool call no longer serves original ask). + TRIGGER PHRASES: "我们的目标", "目标是", "我想要", "we're trying to", "what I want is", "drift", "走偏了", "focus on", "stay focused", "on track", "actually do X instead", "wait scrap that", "现在改成". + SKIP WHEN: trivial one-shot task, exploration without commitment, goal hasn't changed in many turns. +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "1.0.1" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs (SetThreadMemoryMode, ThreadGoalUpdatedEvent) and ext/goal/templates/goals/continuation.md + changes-from-v0.1.0: "Added completion-audit and blocked-audit sections from the Codex continuation template; added token-budget reporting rule; aligned language with the canonical 'treat completion as unproven' principle." +--- + +# Goal Persistence + +The single biggest reason long tasks fail is **goal drift**: the agent starts doing A, the user +asks for B, the conversation accumulates noise, the agent ends up doing C with the +justification that "it felt like the right next step." The original goal is gone — or worse, +silently replaced by a goal the agent inferred. + +This Skill keeps the original goal *visible*, *versioned*, and *checkable* across the whole +thread. It is the *why* of the task; `world-state-tracking` is the *where*. + +**v1.0 update**: now incorporates the canonical completion audit and blocked audit +from the Codex goal continuation template, so declaring "done" is always evidence-based, +not intent-based. + +## When to use + +Activate when **any** of these is true: + +- A non-trivial task has just been stated (one-time **set**). +- The user has redirected the task ("actually, do X instead", "wait, scrap that", "now also + include Y") — one-time **update**. +- A `context-pressure-compact` is about to be applied — one-line **alignment check**. +- The agent is about to start a tool call that has *any* chance of being misaligned with + the original ask (a "drift self-test"). +- The agent is about to mark the goal as `complete` or `blocked` — the **completion audit** + and **blocked audit** sections apply. + +## When NOT to use + +- Trivial one-shot tasks. The user request *is* the goal; no need to persist it. +- Pure research / exploration ("look into X, no commitment"). A goal implies a deliverable. +- The goal has not changed in many turns and the agent is on track. Re-writing the goal + file is noise. + +## Process + +1. **Pick a single, predictable path.** Default: + `.minimax/goal/-.md`. Different from the world-state file (which is + "where we are"; this is "what we are doing"). +2. **Initialise the goal file** at the start of a non-trivial task, in this exact shape: + + ```markdown + # Goal — + + **Set**: + **Owner**: + **Last checked**: + **Version**: 1 + + ## Original goal (verbatim from the user) + + " if + verbatim is impractical> + + ## Why this goal + + + + ## Success looks like + + - + - + + ## Explicitly out of scope + + - + - + + ## Version history + + - v1: — initial set + ``` + +3. **Update the goal** (bump `Version`, append a row to Version history) when **any** of: + - The user explicitly redirects. + - The user adds or removes a deliverable. + - The user expands or narrows the scope. + - The user re-states the goal in a way that supersedes the prior version. +4. **Drift self-test** before any non-trivial tool call: read the goal file, read the + tool call, ask "does this tool call serve the current version of the goal?". If + **no**, surface the drift to the user before executing: + + ```text + Drift check: this tool call is ``, but the current goal is ``. + - aligned → continue + - misaligned (tool call is a side quest) → ask the user before executing + - superseded (the goal has moved on) → update the goal file first + ``` + +5. **At every `context-pressure-compact`**, the compact summary must reference the goal + file by path, not duplicate it. The goal file is the thing that survives; the + summary is the thing that gets re-derived. +6. **Before marking the goal `complete`**, run a **completion audit** (next section). +7. **When the user finally says "done" / "ship it" / "looks good"**, mark the goal as + achieved in the file (`Status: achieved, `) and leave the file in place as part + of the audit trail. **On a budgeted goal, also report the final token usage to the + user** (token accountability). + +## Completion Audit (before declaring done) + +**Treat completion as unproven until you have evidence for each requirement.** + +```text +Verifying before declaring "" done. + +| Requirement | Evidence | Result | +|--------------------------------------------|---------------------------------------------------|--------| +| | | ✅ | +| | | ✅ | +| ... | ... | ... | +``` + +Result legend: ✅ proves completion · ❌ contradicts · 🟡 incomplete · ⚪ too weak · 🚫 missing. + +**All items must be ✅ before declaring done.** If any item is not ✅, surface the unfinished +items; do not mark complete. See `completion-audit` Skill for the full protocol. + +## Blocked Audit (before declaring blocked) + +**Do not declare blocked the first time a blocker appears.** Only use `blocked` when the +same blocking condition has repeated for at least **three consecutive goal turns** (the +original/user-triggered turn plus any automatic continuations), and the agent is at a true +impasse. + +```text +Checking if "" should be marked blocked. + +- Turn N: blocker = ← not yet +- Turn N+1: blocker = ← not yet +- Turn N+2: blocker = ← not yet +- Turn N+3: blocker = ← THRESHOLD MET, can mark blocked + +If after 3 turns the blocker is different, reset the count. +``` + +**Do not mark blocked merely because the work is hard, slow, uncertain, incomplete, or would +benefit from clarification.** "I don't know what to do next" is not blocked — it is +uninformed, and the response is to ask, not to stop. + +## Token Budget Reporting (on a budgeted goal) + +If the goal has a `token_budget`, when marking `complete` (or `blocked`): + +```text +Final token usage: 18,420 / 20,000 (92% of goal budget). +``` + +The user set the budget; they get the report. Do not omit the final number; do not estimate — +read it from the actual usage. + +## Output contract + +The user sees, in this order: + +- On set: the goal file's contents (full) + the path + the version. +- On update: the diff (one line: "v1 → v2: "). +- On drift check: one line verdict (`aligned` / `misaligned: ` / `superseded: `). +- On compact: a one-line "Goal still in scope, see ". +- Before done: the completion audit table + final token usage (if budgeted). +- Before blocked: the blocked audit count + the actual blocker. +- On "done": the goal file marked `Status: achieved, `. + +## Example goal file + +```markdown +# Goal — Auth refactor (OIDC alongside SAML) + +**Set**: 2026-08-23 +**Owner**: main +**Last checked**: 2026-08-23T23:55:00Z +**Version**: 1 + +## Original goal (verbatim from the user) + +> "Refactor the auth subsystem to support OIDC without breaking the existing SAML path." + +## Why this goal + +The user is migrating from a single-SAML IdP to multi-IdP (SAML + OIDC) to support a new +customer segment. They cannot break the existing 12 SAML tests because that would +regress two production customers. The OIDC work is for *new* customers only. + +## Success looks like + +- A new OIDC provider implementation that works end-to-end with one real-world IdP + (e.g. Okta). +- All 12 existing SAML tests still pass. +- A config flag `auth.oidc_enabled` defaults to `false`, so production is unaffected. +- One happy-path test for OIDC login with a mock IdP. + +## Explicitly out of scope + +- Refactoring the existing SAML code beyond what is strictly necessary to add the + provider abstraction. +- Adding OAuth2 (not OIDC) flows. +- Changing the session storage layer. + +## Version history + +- v1: 2026-08-23T22:00:00Z — initial set +``` + +Drift check example: + +```text +> bash(command="git rebase --interactive HEAD~20", description="rewrite recent history") + +Drift check: this tool call is "rewrite 20 commits of history", but the current goal +is "add OIDC without breaking SAML". +- misaligned (interactive rebase is not on the path to the goal) → confirm with the + user before executing +``` + +## Common pitfalls + +- **Do not skip the "why this goal" section.** It is the most valuable paragraph. It + is the guard against drift: when in doubt, the "why" disambiguates. +- **Do not paraphrase the original goal** unless verbatim is impractical. Paraphrase + loses nuance; the user might have picked those exact words for a reason. +- **Do not let the goal file grow.** A 200-line goal file is a project plan, not a + goal. Keep it under ~40 lines; let `world-state-tracking` and `todowrite` carry the + detail. +- **Do not drift-check every tool call.** A drift check before `read` or `grep` is + noise. Drift-check before any *write*, *edit*, or *bash* that has a non-trivial + surface. +- **Do not update the goal on every turn.** Goal updates are rare events. If you are + bumping the version more than once per 20 turns, you are not using it as a goal. +- **Do not conflate goal with state.** The goal file is *what*; the world-state file + is *where*. They are different files for different questions. +- **Do not mark complete without a completion audit.** "I think it works" is not + evidence. Each requirement needs its own ✅. +- **Do not mark blocked at the first blocker.** Three consecutive turns of the same + blocker is the threshold. "Hard" is not "blocked." +- **Do not omit token usage on a budgeted goal.** The user set the budget to know what + the work costs; they get the final number. + +## Verification checklist + +- [ ] Did you pick a single, predictable path for the goal file? +- [ ] Is the goal file under ~40 lines? +- [ ] Does it have all sections (Set / Owner / Last checked / Version / Original + goal / Why this goal / Success / Out of scope / Version history)? +- [ ] Is the "Original goal" copied verbatim where possible? +- [ ] Does the "Why this goal" paragraph explain motivation, not just the surface + request? +- [ ] Did you do a drift self-test before the last non-trivial tool call? +- [ ] At the next `context-pressure-compact`, does the summary reference the goal + file by path? +- [ ] Before marking done, did you run the completion audit (all items ✅)? +- [ ] Before marking blocked, did you count to 3 consecutive turns of the same + blocker? +- [ ] On done, did you report final token usage (if budgeted)? +- [ ] On done, did you mark the goal as achieved in the file (audit trail)? diff --git a/plugins/antianqi/codex-harness-patterns/skills/goal-token-budgeting/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/goal-token-budgeting/SKILL.md new file mode 100644 index 0000000..8e40fb6 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/goal-token-budgeting/SKILL.md @@ -0,0 +1,156 @@ +--- +name: goal-token-budgeting +description: | + Track running token usage against goal's `token_budget`, surface at 50/80/100% thresholds, stop at 100%. + USE WHEN: `goal-persistence` active AND user provided `token_budget`, user said "do X within Y tokens" / "用 Y token 完成" / "不要超预算" / "stayed within budget" / "超出预算" / "用了多少 token", about to start sub-task and need to know remaining budget, at every compact / turn boundary. + TRIGGER PHRASES: "token budget", "预算", "Y tokens", "不要超过", "stayed within budget", "超出预算", "用完没", "用了多少 token", "预算跟踪", "50% / 80% / 100%", "token 预算". + SKIP WHEN: goal has no budget (user did not set one), user explicitly said "no budget tracking for this one". +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.1" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/ext/goal/src/accounting.rs and ext/goal/templates/goals/continuation.md +--- + +# Goal Token Budgeting + +A goal without a budget is open-ended; an agent will spend whatever it takes to "look done" +at the cost of the user. A goal **with** a budget is a contract: the agent is accountable +for fitting the work inside the spend, and the user can decide whether more spend is +worth it. + +This Skill tracks the running usage against the budget, surfaces the trend, and reports +the final number on completion. + +## When to use + +Activate when **any** of these is true: + +- `goal-persistence` is active and the user provided a `token_budget` when setting the goal. +- The user says "do X within Y tokens" / "use the cheap model for this" / "don't burn too + much on this." +- You are about to start a sub-task and want to know "how much budget do I have left?" +- A `context-pressure-compact` is about to be applied and you need to report whether the + goal is still within budget. + +## When NOT to use + +- The goal has no budget (the user didn't set one). Tracking zero is not useful. +- The user explicitly said "no budget tracking for this one." + +## Process + +1. **At goal set**, if the user provided a `token_budget`, record it in the goal file + (`goal-persistence` Skill) under a new section: + + ```markdown + ## Token budget + + **Budget**: (set by user) + **Set at**: + ``` + + If the user did not provide a budget, do not add this section. Absence of the section + means "no budget." + +2. **At every turn boundary** (end of your response, or at every `context-pressure-compact`), + read the harness's per-turn token usage and append a row to a usage log: + + ```markdown + ## Usage log + + | turn | tokens | used_so_far | remaining | % of budget | + |------|--------|-------------|-----------|-------------| + | 1 | 1,240 | 1,240 | 18,760 | 6% | + | 2 | 2,100 | 3,340 | 16,660 | 17% | + | ... | ... | ... | ... | ... | + ``` + + You can read the harness's token usage from: + - the conversation transcript (if the harness surfaces per-turn counts) + - the system prompt (if it includes running totals) + - `TokenCount` events (if the harness emits them — see `protocol::EventMsg::TokenCount`) + +3. **At every boundary, surface the trend**, not just the raw number: + + - **Under 50% used**: silent (the budget is fine). + - **50-80% used**: mention the budget in the response, one line. "Budget: 12,400 / 20,000 + (62%)." + - **80-100% used**: warn the user explicitly. "Budget almost used: 17,200 / 20,000 (86%). + Next turn may exceed — should I stop here or continue?" + - **Over 100% used**: stop, surface to user, ask whether to: + - declare the goal done with the over-budget cost + - ask for a budget extension + - re-scope the goal to fit + +4. **At every `context-pressure-compact`**, the compact summary must include the latest + usage row. The user must be able to see the budget trend across the compacted summary. + +5. **On goal completion** (via `completion-audit` Skill), the final report **must** include + the actual final token usage, read from the harness — not an estimate. The user set the + budget; they get the number. + +## Output contract + +The user sees: + +- At goal set: a one-line "Budget: N tokens" in the goal file. +- During execution: silent under 50%, one line at 50-80%, warn at 80-100%, stop at 100%+. +- At every compact: the latest usage row included in the summary. +- On completion: a one-line "Final token usage: M / N (X%)". + +## Example + +```text +> User: "Migrate the auth subsystem to OIDC alongside SAML. Token budget: 20,000." + +[Goal file] +## Token budget +**Budget**: 20,000 tokens (set by user) +**Set at**: 2026-08-23T22:00:00Z + +[Usage log over time] +| turn | tokens | used_so_far | remaining | % of budget | +|------|--------|-------------|-----------|-------------| +| 1 | 1,240 | 1,240 | 18,760 | 6% | +| 2 | 2,100 | 3,340 | 16,660 | 17% | +| 5 | 3,200 | 8,940 | 11,060 | 45% | +| 8 | 2,500 | 14,300 | 5,700 | 71% ← one-liner appears | +| 10 | 1,800 | 17,200 | 2,800 | 86% ← warn appears | +| 11 | 1,500 | 18,700 | 1,300 | 93% ← warn continues | +| 12 | 1,400 | 20,100 | (over) | 101% ← stop and ask user | +``` + +At turn 12, the agent surfaces: + +> Budget exceeded: 20,100 / 20,000 tokens. I have 2 of the 4 success criteria verified. +> Options: (a) declare partial completion, (b) ask for a 5,000-token extension, (c) re-scope +> the goal to what fits in 2,000 more tokens. Which? + +## Common pitfalls + +- **Do not invent a budget the user did not set.** Absence of budget is not "use whatever + you need." It is "track usage but do not warn at thresholds." +- **Do not estimate the final number on completion.** Read the actual usage from the + harness. Estimation is dishonest. +- **Do not warn at 50% if the user explicitly said "don't bother me with budget updates."** + Respect the user's signal. +- **Do not silently cross 100%.** Stop and ask. Crossing the budget without surfacing it + is a betrayal. +- **Do not re-scope the goal unilaterally to fit the budget.** Re-scoping is a *user + decision*. The agent's only job at 100%+ is to surface the situation. +- **Do not skip the budget tracking because "the task is small."** A small task becoming + a 5× budget overrun is exactly when tracking matters most. + +## Verification checklist + +- [ ] Did the user set an explicit `token_budget`? (If not, do not activate this Skill.) +- [ ] Is the budget recorded in the goal file under "Token budget"? +- [ ] Does the usage log get a row at every turn boundary? +- [ ] At 50-80%, is there a one-line mention in the response? +- [ ] At 80-100%, is there an explicit warn to the user? +- [ ] At 100%+, did the agent stop and ask the user (not silently continue)? +- [ ] At every compact, is the latest usage row in the summary? +- [ ] On completion, is the final number the actual harness-reported usage (not an + estimate)? diff --git a/plugins/antianqi/codex-harness-patterns/skills/long-term-memory/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/long-term-memory/SKILL.md new file mode 100644 index 0000000..b017901 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/long-term-memory/SKILL.md @@ -0,0 +1,164 @@ +--- +name: long-term-memory +description: | + Design a cross-session long-term memory system that extracts, consolidates, and cites durable knowledge from conversation rollouts. + USE WHEN: building any system that needs to persist insights across sessions, designing "what should the next agent remember" pipelines, building memory workspaces with git baseline diffing, planning Phase 1/Phase 2 memory architectures, writing JSON-schema-constrained extraction prompts, deciding what NOT to write (no-op gate), or any task involving "memories that survive session boundaries". + TRIGGER PHRASES: "long-term memory", "cross-session memory", "memory pipeline", "memory consolidation", "memory citation", "raw_memories.md", "MEMORY.md", "phase 1 extraction", "phase 2 consolidation", "watermark", "no-op gate", "git baseline diff". + SKIP WHEN: single-session task state (use `world-state-tracking` instead), ephemeral/short task, no need to survive session boundaries, in-memory only. +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.0" + inspired-by: https://github.com/openai/codex/tree/main/codex-rs/memories/ and protocol/src/memory_citation.rs + changes-from-v0.0.0: "Initial design distilled from P-78/79/80/84 deep-dive (Phase 1 Week 1)." +--- + +# Long-Term Memory + +Design and operate a cross-session long-term memory system that survives session +boundaries. Mirrors the structure of Codex's `codex-rs/memories/` crate. + +## When to use + +Activate when designing any of: + +- A pipeline that extracts structured facts from conversation rollouts and writes them to durable storage. +- A global consolidation pass that merges per-rollout facts into higher-level summaries without races. +- A citation protocol so a future agent can audit which memory came from which rollout. +- A "no-op gate" — the system MUST be allowed to write nothing when there is no durable learning. + +## When NOT to use + +- Single-session task state → use `world-state-tracking`. +- Real-time voice / streaming → out of scope. +- Forgetting-on-purpose privacy filters → out of scope. + +## Host runtime requirements + +This Skill describes **how to design** a cross-session memory system (Phase 1 +extraction, Phase 2 consolidation, citation format, git baseline, watermark). +It does **not** cause the agent to install, modify, or write to persistent +storage on its own. Specifically, the agent MUST NOT, on the strength of this +Skill alone: + +- Read or write files in `~/.codex/memories/`, `~/.minimax/memory/`, or any other + per-host memory workspace. **No memory directory is implicitly writable by + the agent.** +- Spawn sub-agents or background tasks to perform extraction / consolidation. +- Trigger a Phase 1 / Phase 2 schedule on session start (the host decides when + memory runs; this Skill does not). +- Call `redact_secrets` or any other exfiltration-mitigation step without the + host's normal user-confirmation policy. + +All of the above require **explicit user confirmation** in the host's normal +permission flow (`approval_policy`, `ask` mode, or whatever the host uses). +This Skill is for **designing** the pipeline, not for **executing** it. The +agent that runs Phase 1 / Phase 2 must follow the host's user-confirmation +policy, **not** the patterns in this Skill. + +## Process + +A long-term memory system is built from four pieces. Build them in this order. + +### 1. Phase 1 — per-rollout extraction (parallel, idempotent) + +The writer of memories. Runs at session start (or on a schedule), claims bounded jobs from a queue, and for each: + +- Loads the rollout (JSONL or DB-backed). +- Filters to memory-relevant response items. +- Prompts a model with a JSON schema producing `{raw_memory, rollout_summary, rollout_slug}`. +- Redacts secrets from the output. +- Persists to durable storage (DB row or file). + +Hard rules: + +- `#[serde(deny_unknown_fields)]` on the output struct so the model cannot add fields. +- Concurrency capped by a single constant (e.g. `CONCURRENCY_LIMIT = 8`). Use `futures::stream::iter(...).buffer_unordered(N)`. +- Lease/ownership token prevents two workers from re-extracting the same rollout. +- If a job fails, record the failure with a backoff; do not hot-loop. +- **Allow no-op**: the prompt MUST include the question "Will a future agent plausibly act better because of what I write here?" and an empty-output escape hatch. If the answer is no, write nothing. + +### 2. Phase 2 — global consolidation (serial, single lock) + +The reader of stage-1 outputs. Runs at session start, after Phase 1, with one global lock so two Codexes never consolidate simultaneously. + +- Load top-N stage-1 outputs ranked by `usage_count` then `last_usage` (fallback `generated_at`). +- Filter by `last_usage >= now - max_unused_days` (otherwise stale). +- Sync the selected inputs into a workspace as `raw_memories.md` (ascending thread-id order, never usage-rank) and `rollout_summaries/.md`. +- Prune stale rollout summaries and old extension resources. +- **Use git baseline as a cheap state machine**: `~/.codex/memories/.git/` keeps a `git diff` against the previous successful baseline. If there are no changes, mark success and exit. +- If there ARE changes, write `phase2_workspace_diff.md` and spawn an **internal consolidation sub-agent** with these hard constraints: + - `cwd` = the memory root only. + - `ephemeral = true`. + - `features.disable(Collab / MemoryTool / Apps / Plugins)`. + - `approval_policy = Never`. + - `network_access = false` (or inheriting parent's `PermissionProfile::External`). + - **Disabled from re-entering Phase 1**: `memories.generate_memories = false` and `use_memories = false`. + +### 3. MemoryCitation protocol + +When the model emits memory, it should be able to point at exact lines. Adopt this single-line format: + +``` + +path/to/file.md:10-15 |note=[why this matters] +path/to/other.md:42-50 |note=[other context] + + +thread-abc-123 +thread-def-456 + +``` + +Parse with `split_once` × 3 (location / `|note=[` / `]`). `try_from().ok()` style tolerance for malformed lines. De-duplicate `rollout_ids` with a `HashSet`. + +### 4. Watermark + +After successful Phase 2, write `new_watermark = max(claimed_watermark, max(source_updated_at))` to the DB. **Watermarks are monotonically increasing** — never move backwards. They are bookkeeping, not the dirty check (git workspace is). + +## Output contract + +A working long-term memory system should produce: + +- `~/.codex/memories/MEMORY.md` — consolidated memory (Phase 2 agent writes). +- `~/.codex/memories/memory_summary.md` — first line is `v1` (version marker). +- `~/.codex/memories/raw_memories.md` — per-rollout raw memories in stable ascending thread-id order. +- `~/.codex/memories/rollout_summaries/.md` — one per selected rollout. +- `~/.codex/memories/phase2_workspace_diff.md` — temporary, deleted before baseline reset. +- `~/.codex/memories/.git/` — git baseline for cheap state machine. + +## Common pitfalls + +- **No-op gate skipped** → model hallucinates low-signal memories every session; memory file grows unbounded. The prompt MUST force the self-question. +- **Stable-key churn** → ordering by `usage_count` causes git to show a "change" every run even when content didn't change. Order by thread-id instead. +- **Reset baseline with diff present** → deleted content stays in git objects forever. Always remove `phase2_workspace_diff.md` BEFORE `reset_git_repository`. +- **Two Codexes consolidating simultaneously** → corruption. Use a single global lock, not optimistic concurrency. +- **Sub-agent with collab enabled** → infinite recursion. Disable `Feature::Collab` on the consolidation agent. +- **Sub-agent with network** → privacy leak. Force `network_access: false` in the sandbox policy. +- **No secrets redaction** → API keys in memory. Always call `redact_secrets` on model output before persisting. +- **Watermark moved backwards** → duplicate work. Use `max(claimed, max(newest_input))`. + +## Example — minimal memory workflow + +```text +# At session start +phase1::run(claimed_jobs) # parallel, schema-constrained, redacted, leased +phase2::run(claim_global_lock) # serial, single global lock + if !git_diff.has_changes() { mark_success_no_workspace_changes; return; } + write_workspace_diff(...) + spawn_consolidation_agent(ephemeral, no_collab, no_network, no_memory_tool) + handle(lease_heartbeat, validate_artifacts, reset_baseline, mark_succeeded) +``` + +## Verification checklist + +- [ ] Phase 1: `deny_unknown_fields` schema; `buffer_unordered` concurrency cap; lease + ownership token; `redact_secrets`. +- [ ] Phase 1: prompt includes the "future agent plausibly act better" question and the empty-output escape. +- [ ] Phase 2: single global lock (DB lease or file lock); retry with backoff; never two simultaneous runs. +- [ ] Phase 2: spawn sub-agent with `ephemeral + features.disable(Collab) + no network + no memory tool`. +- [ ] Workspace: `raw_memories.md` is sorted by ascending thread-id, never by usage rank. +- [ ] Workspace: `phase2_workspace_diff.md` is removed BEFORE `reset_git_repository`. +- [ ] Watermark: monotonically increasing, never moves backwards. +- [ ] Citation: single-line `:- |note=[]` format; `try_from().ok()` tolerance. +- [ ] Trigger Phase 1/2 ONLY for non-ephemeral, non-sub-agent root sessions. diff --git a/plugins/antianqi/codex-harness-patterns/skills/model-router/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/model-router/SKILL.md new file mode 100644 index 0000000..f0e6f9d --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/model-router/SKILL.md @@ -0,0 +1,205 @@ +--- +name: model-router +description: | + Classify sub-task complexity (cheap / medium / main) and decide whether to spawn a sub-agent at all. On MiniMax Code 0.2.4 the `task` tool does not expose per-call model selection, so the 3-tier rubric here is a thinking framework for session-level model choice and a "do I really need a sub-agent?" gate, not a per-call `model_config_id` field. + USE WHEN: about to spawn a sub-agent for non-trivial work, about to spend the main model on something a cheap model could do, "do this with the cheap model" / "用便宜模型" / "不要用主模型" / "sub-task 不重" / "small task" / "小任务". + TRIGGER PHRASES: "用便宜模型", "cheap model", "use the cheap model", "小任务用便宜模型", "不要用主模型", "用本地模型", "sub-task 不重", "小任务", "this is just a", "小 case 用便宜". + SKIP WHEN: sub-task IS the main task, mcode 0.2.4's `task` tool does not expose a per-call model field, sub-task is genuinely synthesis / design / cross-file reasoning. +license: Apache-2.0 +compatibility: Targets MiniMax Code 0.2.4. **The mcode 0.2.4 `task` tool does NOT expose a `model_config_id` parameter or any other per-call model-routing field.** Verified against the bundled `cli.js` canonical schema (only `description` / `prompt` / `agent_name` / `run_in_background`). Model selection on mcode 0.2.4 is **session-level** (chosen at session start via the host's `model` flag / interactive picker). This Skill therefore reframes the original 3-tier rubric into a session-level thinking framework and a sub-agent gate, not a per-`task()` argument. +metadata: + author: antianqi + version: "0.4.0" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/model-provider-info/ and codex-rs/models-manager/ (design principle only; the 3-tier classification is portable; the mcode 0.2.4 surface for model selection is session-level, not per-task) + changes-from-v0.3.3: "Removed the v0.3.3 claim that 'MiniMax Code's `task` tool accepts `model_config_id` directly' — that was wrong. The canonical mcode 0.2.4 `task` schema has no per-call model field; the only model-routing surface on 0.2.4 is session-level (the host's `model` config at session start). The 3-tier rubric is preserved as a thinking framework and as a sub-agent gate ('don't spawn a sub-agent if the work is `cheap` enough that the calling session's current model could do it in 2 tool calls'), but no `model_config_id` is passed in any `task()` call. The Example section is reframed to match the actual mcode 0.2.4 surface." +--- + +# Model Router + +The main model is expensive and slow. Most sub-tasks a long agent spawns are not +"main-model expensive" — they are lookups, transforms, summaries, or pattern matches. The +Codex harness routes those to cheaper models and reserves the main model for synthesis and +hard reasoning. + +On **mcode 0.2.4** specifically, the `task` tool does **not** expose a per-call model +parameter. The 3-tier rubric below is therefore a **thinking framework for session-level +model choice and a sub-agent gate**, not a per-`task()` argument. The Skills keep the +classification because the cost-of-thought question is the same; they just do not pretend +mcode 0.2.4 routes per call. + +## The hard fact about mcode 0.2.4 + +The canonical mcode 0.2.4 `task` schema: + +```text +task( + description: string, // required + prompt: string, // required + agent_name: "explore" | "worker" | "verifier", // required + run_in_background?: boolean // optional +) +``` + +**No `model_config_id`, no `model`, no `reasoning_effort`, no per-call tier.** Model +selection on mcode 0.2.4 is session-level (chosen at session start via the host's +`model` flag / interactive picker — the same model is used for the whole session, +including every `task` call). Trying to pass `model_config_id="..."` is rejected +by the strict validator in `cli.js:B6c` (the only allowed keys are the four above). + +The v0.3.3 wording "MiniMax Code's `task` tool accepts `model_config_id` directly" +was wrong. The cost-of-thought question (cheap / medium / main) is still worth +asking; just not as a per-`task()` argument. + +## When to use + +Activate when **any** of these is true: + +- You are about to spawn a sub-agent and the work is non-trivial. +- You are about to spend the main model on a work step that has clearly bounded + complexity (a lookup, a transform, a reformat, a coverage report). +- A sub-task failed and you are about to retry; consider whether the brief was + bad, not whether a stronger model would help (mcode cannot route per call). +- A batch of N similar sub-tasks is about to run; classify them once and decide + whether to spawn at all (the sub-agent gate). + +## When NOT to use + +- The sub-task *is* the main task (no delegation happening). You are already on + the right model. +- The sub-task requires the same context the main thread has, and you cannot + pass a minimal-context brief (see `fork-context-decision`). A sub-agent with + no context will fail — do it yourself. +- The user explicitly said "use the main model for this" or "don't downgrade the + model" (no-op on mcode 0.2.4 anyway, but respects the user's framing). + +## Process + +1. **Classify the sub-task** into one of three tiers, before deciding whether to + spawn a sub-agent at all: + + | Tier | When to use | Examples | + |---|---|---| + | **`cheap`** | Bounded, single-shot, the brief fully specifies success. No synthesis, no judgement. | reformat a file, list files matching a glob, count lines, parse a JSON, run a deterministic script, copy a file with substitutions | + | **`medium`** | Multi-step but well-scoped, the brief is the only context needed. Some judgement, no synthesis of new ideas. | summarise a long doc, refactor a single function, write tests for a known spec, review a single PR | + | **`main`** | Requires synthesis, judgement across multiple sources, or stakes that make cheap-model mistakes costly. | design an API, evaluate tradeoffs, debug a multi-file interaction, write code that needs to satisfy a spec the agent has to interpret | + + If unsure, classify up — `main` is the safe default. + +2. **Decide whether to spawn at all** (the sub-agent gate, on mcode 0.2.4): + - If the work is **`cheap`** AND the calling session can do it in 2 tool calls + (one `read` / `grep` / `bash` + one `write` / nothing), **do not spawn**. + The cost of the spawn (the sub-agent's bootstrap, the brief round-trip) is + higher than just doing the work. + - If the work is **`medium`** or **`main`**, spawn with the appropriate + `agent_name` (`explore` / `worker` / `verifier`). + - The model that runs the sub-agent is the same as the calling session's + model — there is no per-call tier routing on mcode 0.2.4. + +3. **State the tier and the spawn decision in the sub-task brief** so a human + reviewer can see why you spawned (or did not spawn): + + ```markdown + ## Sub-task brief + ... + + **Tier**: cheap | medium | main + **Spawn decision**: doing-it-myself | task(agent_name=...) + **Reason**: + ``` + +4. **If the sub-task returns a "I can't do this"**, do not silently retry on the + same call. Re-classify: either the brief is wrong (rewrite it) or the work is + harder than the tier suggested (re-classify up). On mcode 0.2.4 "going up a + tier" means ending the session and restarting on a stronger model, not passing + a different parameter — call this out to the user. + +5. **Record the actual spend** if the harness surfaces per-call token counts. + After a fan-out, note in the aggregation how much of the total was cheap-vs- + medium-vs-main. This is how you learn the right tier for each sub-task shape. + +## Output contract + +The user sees, in this order: + +- For every spawn decision: the tier and the spawn reason (one line each). +- For the fan-out aggregation: a one-line "X cheap / Y medium / Z main" summary. +- For upgrades (cheap → medium → main on a retry): a one-line reason plus the + user-facing cost (on mcode 0.2.4: "this needs main; please restart the + session on a stronger model"). + +## Example + +The example below is **MiniMax Code 0.2.4 `task` tool syntax**. The +`model_config_id` argument is **deliberately not shown** because mcode 0.2.4 +does not accept it. + +```text +[planning] 1 cheap call: list all *.rs files in /src/auth/ that + import `tokio::sync::Mutex`. + — tier: cheap (deterministic glob + grep, no judgement) + — spawn decision: DO NOT SPAWN. 1 read + 1 grep is faster than + the sub-agent bootstrap. + +[execution] 1 medium call: refactor auth/callback.rs to extract the SAML + response parser. + — tier: medium (multi-step refactor, brief is the spec) + — spawn decision: task(agent_name="worker") + — model: same as calling session (mcode 0.2.4 has no per-call + model field) + +> task( + description="Refactor SAML parser", + agent_name="worker", + prompt=""" + Tier: medium + Spawn: task(agent_name=worker) + Task name: refactor-saml-parser + Sender: main agent + Task: Extract the SAML response parser from + /src/auth/callback.rs lines 80-140 into + /src/auth/saml.rs as a free function. + Payload: /src/auth/callback.rs (lines 80-140); + /src/auth/mod.rs (re-export point). + Return: Write the new file /src/auth/saml.rs and + update /src/auth/mod.rs. Run + `cargo test --lib auth` and confirm green. + """ + ) + +[execution] 1 main call: design the OidcProvider trait given the existing + IdP interface and the OIDC spec. Resolve the + "extend IdP vs new sibling" question. + — tier: main (synthesis + cross-source judgement) + — spawn decision: this is the main task; no spawn. + — model: session-level main model. +``` + +## Common pitfalls + +- **Do not default to main.** The default is the most expensive answer. The + skill exists to move work *off* main, not to confirm the obvious. +- **Do not route synthesis to cheap.** Synthesis requires judgement, cheap + models hallucinate on it, and you will pay more on the retry. +- **Do not classify by token count of the sub-task input.** Classify by *what + the sub-task is* (lookup vs synthesis). A 50,000-token doc summary is + `medium`, not `cheap`, even though the input is large. +- **Do not pass `model_config_id` in a `task()` call on mcode 0.2.4.** The + strict validator in `cli.js:B6c` rejects it; only `description` / `prompt` / + `agent_name` / `run_in_background` are allowed. Model selection is + session-level on 0.2.4. +- **Do not retry a failed sub-task on the same tier without re-classifying.** + A cheap-model failure on a synthesis-class task is a classification error; + the fix is to re-classify up, not to rephrase the brief. +- **Do not hide the tier from the user.** The tier is part of the contract — + they should be able to see "this is cheap because…" and disagree. + +## Verification checklist + +- [ ] Did you classify the sub-task into cheap / medium / main before spawning? +- [ ] Did you decide whether to spawn at all (the sub-agent gate)? +- [ ] Did you state the tier and the spawn reason in the brief? +- [ ] Did you avoid passing `model_config_id` in the `task()` call (not supported + on mcode 0.2.4)? +- [ ] If the sub-task failed, did you re-classify (not just rephrase)? +- [ ] If you ran a fan-out, did you record "X cheap / Y medium / Z main" in the + aggregation? diff --git a/plugins/antianqi/codex-harness-patterns/skills/parallel-fanout/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/parallel-fanout/SKILL.md new file mode 100644 index 0000000..af59ffc --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/parallel-fanout/SKILL.md @@ -0,0 +1,167 @@ +--- +name: parallel-fanout +description: | + Decompose a task into 2+ truly independent sub-tasks and dispatch them concurrently via separate `task()` calls. Pick whether to fan out explicitly, not by accident. + USE WHEN: the user task is clearly decomposable into 2+ independent sub-tasks (independent files, independent probes, independent analyses), you would otherwise serialize work that has no real dependency, user said "in parallel" / "并行" / "fan out" / "spawn agents" / "同时跑". + TRIGGER PHRASES: "in parallel", "parallel", "fan out", "spawn agents", "并行", "同时", "concurrent", "subagents", "multi-agent", "同时跑几个". + SKIP WHEN: sub-tasks have a hard data dependency (output of A is input of B), the user explicitly said "sequential" / "one at a time", there is only one sub-task. +license: Apache-2.0 +compatibility: Targets MiniMax Code 0.2.4 `task` tool. Verified against the bundled `cli.js` schema. Each sub-task is a separate `task()` call with its own `description` / `prompt` / `agent_name`. `agent_name` is canonical (`explore` / `worker` / `verifier`); `mavis` is the root agent, not a sub-agent. +metadata: + author: antianqi + version: "1.2.0" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/core/src/thread_manager.rs (design principle); the fan-out decision and wait-for-all aggregation are portable; on mcode each sub-task is a discrete `task()` call + changes-from-v1.1.0: "Replaced `subagent_type=` with the canonical mcode `agent_name=`. Replaced `brief=` with `prompt=`. Dropped `mavis` from the sub-agent list (mavis is the root). Dropped the Codex-harness pseudocode block; mcode 0.2.4 is the only shape shown. The 'concurrency cap' step now references mcode's own per-session buffer-unordered limit instead of a hypothetical host config." +--- + +# Parallel Fanout + +When the user task is clearly decomposable into 2+ **truly independent** sub-tasks, the +agent has two choices: + +1. **Serialize**: do them one by one, holding the conversation hostage. +2. **Fan out**: dispatch them concurrently, aggregate the results. + +This Skill is about **knowing when to choose (2)** and **how to dispatch + aggregate +cleanly** so the user gets the parallel speedup without losing correctness. + +## mcode 0.2.4 surface + +Each sub-task is a separate `task()` call: + +```text +task( + description: string, // 3-5 word label, required + prompt: string, // the brief, required + agent_name: "explore" | "worker" | "verifier", // required + run_in_background?: boolean // optional; usually false for fan-out +) +``` + +The agent dispatches all the calls in a single response; mcode executes them +concurrently subject to the host's per-session buffer-unordered limit (8 by +default in 0.2.4; check the runtime config if unsure). The agent then waits +for all to complete before aggregating. + +`agent_name` is the canonical mcode spelling. `agent_name=` is accepted as +a runtime alias but the Skills prefer canonical. `mavis` is the root agent +not a sub-agent; do not pass it as `agent_name`. + +## When to use + +Activate when **any** of these is true: + +- The user task is clearly decomposable into 2+ independent sub-tasks. +- The sub-tasks touch **independent files / directories / systems** (so there is no + shared state to corrupt). +- The user explicitly said "in parallel" / "并行" / "fan out" / "同时". +- You would otherwise serialize work that has no real dependency. + +## When NOT to use + +- The sub-tasks have a **hard data dependency** (output of A is the input of B). +- The user explicitly said "sequential" / "one at a time" / "按顺序". +- There is only one sub-task (no fan-out to do). +- The sub-tasks would all touch the same file (race condition risk). + +## Process + +1. **Decompose explicitly**. Write the list of sub-tasks in the brief header before + dispatching anything. "Sub-tasks: A, B, C" is the single most important line. +2. **For each sub-task, decide context size** (see `fork-context-decision` Skill): + - Self-contained sub-task? `none` (just the brief in `prompt`). + - Needs prior context? `N` or `all` → inline the prior content into `prompt`. +3. **Check mcode's per-session buffer-unordered limit**. Default in 0.2.4 is 8 + concurrent `task` calls. If you have more sub-tasks, the host will queue or + fail — split the batch or use `run_in_background: true` and poll + `task_output` later. +4. **Dispatch the batch** in a single response. mcode runs them concurrently + subject to the buffer-unordered limit. +5. **Wait for all to complete**. The aggregator MUST verify each sub-task's + output before declaring success (use `completion-audit`). +6. **Surface the parallelism in the user-facing message**. "I dispatched 3 + sub-agents in parallel; here are their results." The user should know + fan-out actually happened (vs serial). + +## Output contract + +After activating this Skill, the agent's next message MUST include: + +- The **list of sub-tasks** dispatched (one per `task` call). +- The **chosen context level** per sub-task. +- The **aggregation** result (per-sub-task outcome + overall verdict). +- A **completion audit** step (each sub-task verified). + +## Common pitfalls + +- **Fanning out for the sake of it** — parallelism is a tool, not a goal. If two + sub-tasks are easier to do serially, do them serially. +- **Missing the data dependency** — the most common bug. Always check: does + sub-task B actually need sub-task A's output? If yes, serialize. +- **Hitting mcode's buffer-unordered limit silently** — the host will queue or + fail. Check the limit first; if you have more than 8, run them in waves. +- **Aggregating without verification** — one sub-task may have silently failed. + Always read each output. +- **Using `agent_name="mavis"`** — mavis is the root, not a sub-agent. + Use `explore` / `worker` / `verifier`. +- **Writing the sub-task brief in a separate `brief=` field** — mcode 0.2.4 + has no `brief` field. The brief goes in `prompt`. + +## Example + +The example below is **MiniMax Code 0.2.4 `task` tool syntax**. The fan-out +is 3 sub-tasks, all `none` context, all dispatched in one response, mcode +runs them concurrently. + +```text +# Sub-tasks: A, B, C +# Concurrency cap: 8 (mcode 0.2.4 default) +# Context level: none (all sub-tasks are self-contained) +# Aggregation: read each output, run completion-audit, then summarize + +> task( + description="Look up X in repo 1", + agent_name="explore", + prompt=""" + Task name: lookup-X-repo1 + Task: Find every file in that imports `X`. + Return: List of / files, one per line. + """ + ) + +> task( + description="Look up Y in repo 2", + agent_name="explore", + prompt=""" + Task name: lookup-Y-repo2 + Task: Find every file in that imports `Y`. + Return: List of / files, one per line. + """ + ) + +> task( + description="Look up Z in repo 3", + agent_name="explore", + prompt=""" + Task name: lookup-Z-repo3 + Task: Find every file in that imports `Z`. + Return: List of / files, one per line. + """ + ) + +# (Agent waits for all three.) +# Aggregator reads each output, audits per `completion-audit`. +``` + +The **decision** (3 sub-tasks, `none` context, wait-for-all) is the same; the +**call shape** is what mcode 0.2.4 actually exposes. + +## Verification checklist + +- [ ] Did you write the sub-task list in the brief header before dispatching? +- [ ] Did you stay under mcode's per-session buffer-unordered limit (default 8)? +- [ ] Did you choose the right context level per sub-task (via `fork-context-decision`)? +- [ ] Did you wait for all sub-tasks to complete before aggregating? +- [ ] Did you verify each sub-task's output (via `completion-audit`)? +- [ ] Did you use `agent_name` from `{explore, worker, verifier}` (not `mavis`)? +- [ ] Did you put the sub-task brief in the `prompt` field (not a separate `brief`)? diff --git a/plugins/antianqi/codex-harness-patterns/skills/plan-stream-emit/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/plan-stream-emit/SKILL.md new file mode 100644 index 0000000..6b48205 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/plan-stream-emit/SKILL.md @@ -0,0 +1,139 @@ +--- +name: plan-stream-emit +description: | + Before touching files on a non-trivial task, emit a structured plan and surface to the user for early course-correction. + USE WHEN: non-trivial task, multi-step task, ambiguous requirement, would take > 3 tool calls, user has not approved an approach yet, user said "plan first" / "before you start" / "let me see your approach" / "先出计划" / "出方案", crossing trust boundary (production, public repo, irreversible action). + TRIGGER PHRASES: "plan first", "先出计划", "let me see", "出方案", "确认一下", "先别动手", "想清楚再开始", "before you start", "我看看方案", "出 plan", "出计划". + SKIP WHEN: single one-shot question, user already gave numbered list of steps, trivially reversible, "do X" with X being one line. +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.1" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs (PlanUpdate / PlanDelta) +--- + +# Plan Stream Emit + +For any non-trivial task, emit a structured plan first and let the user see it before you start +changing files. A plan is a list of small, ordered, named steps with explicit pass conditions — +not prose, not a single "I'll do X" line. + +## When to use + +Activate when **any** of these is true: + +- The user request is multi-step (more than 3 distinct actions). +- The task is ambiguous in any way: which file, which API, which framework, which version. +- The user request is large enough that getting it wrong would cost more than 2 minutes of + re-work. +- The user said "plan first" / "before you start" / "let me see your approach". +- The task crosses a trust boundary (production code, public repo, irreversible action). + +## When NOT to use + +- A single one-shot question or one-line edit. +- The user already gave a numbered list of steps ("do 1, 2, 3, 4 in order"). +- The task is trivially reversible and the cost of a wrong move is near zero. + +## Process + +1. **Stop and think before any tool call.** Do not start `bash`, `read`, or `write` until the + plan is on the page. +2. **Write the plan** as a `todowrite` list, in this exact shape: + + ```markdown + ## Plan — + + - [ ] **Step 1**: + Pass: + - [ ] **Step 2**: + Pass: + - [ ] **Step 3**: + Pass: + - [ ] **Step 4** (optional, only if needed): <...> + - [ ] **Step N** (always): Verify — + ``` + +3. **Add an "Open questions" section** if any step has un-resolved ambiguity: + + ```markdown + ## Open questions + + - + - + ``` + +4. **Surface the plan to the user** with a one-sentence preamble: "Here is my plan — I will + start with Step 1 once you confirm or correct it." Do not begin executing until the user + acks, **unless** the user has previously said "just go" or "no need to check in for this". +5. **Update the plan as you go.** When a step is done, mark it complete and emit the next + step's status. If reality diverges from the plan, **stop and re-plan** rather than silently + re-routing. +6. **Final step is always a Verify** — how the agent will confirm the whole task is done + (test pass, manual smoke, file existence check, etc.). + +## Output contract + +The user sees, in this order: + +- One-sentence preamble acknowledging the plan is coming. +- The `## Plan` block. +- (Optional) The `## Open questions` block. +- A clear stop point: "I'll start Step 1 once you confirm" (or, if pre-authorised, "Starting + Step 1."). + +After execution, the user sees the same plan with `[x]` checks updating live, and a final +Verify line that names what was actually checked. + +## Example + +```markdown +I'll plan the migration before touching files. + +## Plan — Migrate auth to OIDC alongside SAML + +- [ ] **Step 1**: Read src/auth/idp.rs and src/auth/callback.rs to map the current interface. + Pass: I can name every public function and its caller in one sentence. +- [ ] **Step 2**: Sketch the OidcProvider trait and one stub impl in a new file + src/auth/oidc/mod.rs. + Pass: `cargo check` passes with the new module imported. +- [ ] **Step 3**: Wire the new provider into the login/callback dispatch in src/auth/mod.rs, + gated on a config flag `auth.oidc_enabled`. + Pass: existing SAML tests still pass; `auth.oidc_enabled = false` is the default. +- [ ] **Step 4**: Add one happy-path test for OIDC login with a mock IdP. + Pass: `cargo test auth::oidc` is green. +- [ ] **Step 5**: Document the new config flag in docs/auth.md. + Pass: docs/auth.md lists `oidc_enabled`, `oidc_issuer`, `oidc_client_id`. +- [ ] **Verify**: Run the full test suite + a manual smoke against the dev OIDC sandbox. + +## Open questions + +- Do you have a preferred OIDC library (openidconnect crate vs oauth2 + manual JWKS)? +- Should the OIDC path share the session store with SAML, or own its own? +``` + +## Common pitfalls + +- **Do not emit a plan and then ignore it.** Every tool call should map to a step. If reality + diverges, **stop and re-plan**, do not silently re-route. +- **Do not write prose plans.** "I'll look at the code and then maybe refactor" is not a plan. + Every step names an action and a pass condition. +- **Do not skip the Verify step.** The user must be able to trust the agent to confirm the + whole task is done, not just the last file edit. +- **Do not over-plan trivial work.** A 3-line bug fix is one step. Save the structure for work + that needs it. +- **Do not surface a plan and immediately barrel into Step 1.** The user must have a chance to + redirect cheaply, before you have committed to a path. +- **Do not re-plan without telling the user.** "I am going to re-plan because Step 2 hit a + wall" is a one-line update; the user expects it. + +## Verification checklist + +- [ ] Did the plan come before any tool call? +- [ ] Is every step a verb-first sentence with a pass condition? +- [ ] Did you include a final Verify step? +- [ ] Did you list Open questions instead of guessing on ambiguity? +- [ ] Did the user have a chance to confirm or redirect? +- [ ] Did you update the plan as steps completed, rather than drifting silently? +- [ ] At the end, did the Verify step actually run and pass? diff --git a/plugins/antianqi/codex-harness-patterns/skills/plugin-author-helper/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/plugin-author-helper/SKILL.md new file mode 100644 index 0000000..f51a7c7 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/plugin-author-helper/SKILL.md @@ -0,0 +1,227 @@ +--- +name: plugin-author-helper +description: | + Design, validate, and ship a marketplace Plugin (or Skill bundle) with proper manifest format, multi-ecosystem compatibility, version pinning, manifest fallback, install idempotency, and three-layer startup sync. + USE WHEN: writing a new Plugin manifest, picking manifest format (Legacy vs AgentPlugin), adding `skills` / `mcp_servers` / `apps` / `hooks` / `interface` fields, validating a plugin before publish, designing marketplace install/remove/upgrade flows, or any task involving "make my plugin actually work in Codex". + TRIGGER PHRASES: "plugin manifest", "PluginManifest", "marketplace", "agent plugin", ".agents/plugins/marketplace.json", "manifest fallback", "idempotency key", "plugin author", "plugin publish", "plugin version", "startup sync", "lock file". + SKIP WHEN: writing a single skill (use `skill-auto-select`), pure MCP server (use `mcp-server` directly), one-off tool without packaging. +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.0" + inspired-by: https://github.com/openai/codex/tree/main/codex-rs/core-plugins/ (P-93/94/95/99) + changes-from-v0.0.0: "Initial design distilled from P-93/94/95/99 deep-dive (Phase 1 Week 4)." +--- + +# Plugin Author Helper + +Design, validate, and ship a Plugin that fits into the Codex (or compatible) Plugin +ecosystem. Mirrors the design of `codex-rs/core-plugins/`. + +## When to use + +Activate when: + +- Writing a new `plugin.json` / `marketplace.json` manifest. +- Choosing between manifest formats (Legacy vs AgentPlugin). +- Validating a plugin before publish. +- Designing install / remove / upgrade / sync flows. +- Picking a plugin scope (User / System / Admin / Plugin) and pinning a version. + +## When NOT to use + +- Single skill authoring → use `skill-auto-select`. +- Pure MCP server (no Skill bundle) → use the `mcp-server` crate's own conventions. +- One-off tool scripts → don't package. + +## Host runtime requirements + +This Skill describes **how to design** a Plugin (manifest, idempotency, scope). +It does **not** cause the agent to install, modify, or publish anything on its own. +Specifically, the agent MUST NOT, on the strength of this Skill alone: + +- Run `npm install` / `npm link` / any package manager command for the user. +- Write or overwrite files in `~/.minimax/.../plugins/`, `~/.codex/.../`, + `~/.config/`, or any other user-level config directory. +- Hit a marketplace endpoint (download, install, upgrade) on the user's behalf. +- Trigger a plugin sync that reaches the network (3-layer fallback in Codex is a + Codex-runtime concept; MiniMax Code may or may not have an equivalent). + +All of the above require **explicit user confirmation** in the host's normal +permission flow (`approval_policy`, `ask` mode, or whatever the host uses). +This Skill is for **designing** the manifest / sync flow, not for **executing** it. +The agent that *runs* the install / sync must follow the host's user-confirmation +policy, **not** the patterns in this Skill. + +## Process + +### 1. Pick the manifest format + +Codex supports two manifest formats: + +| Format | Path | Notes | +|---|---|---| +| `Legacy` | `.claude-plugin/marketplace.json` / `.cursor-plugin/marketplace.json` | Older ecosystems. | +| `AgentPlugin` | `.agents/plugins/marketplace.json` / `.agents/plugins/api_marketplace.json` | Current Codex format. | + +If the plugin should be cross-ecosystem (OpenAI + Claude + Cursor), ship both manifests and let the loader pick whichever it finds first. + +### 2. Write the 8-field PluginManifest + +```rust +struct RawPluginManifest { + name: String, // required + version: Option, // semver recommended + description: Option, // one-line + keywords: Vec, // tags + skills: Option, // "./skills//SKILL.md" (./... required) + mcp_servers: Option, // MCP config + apps: Option, // apps connector + hooks: Option, // 9 hook trigger points + interface: Option, // UI (display_name, icon, brand_color) +} +``` + +**Hard limits**: + +- `MAX_DEFAULT_PROMPT_COUNT: 3` — at most 3 default prompts in `interface`. +- `MAX_DEFAULT_PROMPT_LEN: 128` — each prompt ≤ 128 chars. +- All paths in `skills` MUST use the `./...` syntax (`./skills//SKILL.md`) and resolve under the plugin root. + +### 3. Provide a manifest fallback + +If the main manifest is missing or malformed, fall back to a known-good shape. The +fallback typically contains just `name` + `version` + a minimal `skills` list. + +### 4. Use a 3-letter marketplace name taxonomy + +Pick a short, descriptive name with one of these prefixes: + +| Prefix | Meaning | +|---|---| +| `openai-curated` | OpenAI-curated official | +| `openai-api-curated` | OpenAI API curated | +| `openai-bundled` | Bundled with Codex | +| `openai-bundled-alpha` | Bundled alpha | +| `openai-primary-runtime` | Primary runtime | + +For your own marketplace, use `-` (e.g. `acme-data-pipelines`). + +### 5. Use idempotency keys for create operations + +```rust +pub struct CreateProjectParams { + pub name: String, + pub idempotency_key: String, // ← critical + // ... +} + +pub struct CreatedProject { + pub project: StoredProject, + pub created: bool, // true = new, false = idempotent hit +} +``` + +**Always require `idempotency_key`** on create / install endpoints. The same key + same payload returns the existing object with `created: false`. Different key + same name creates a new object (no conflict). + +### 6. Three-state updates: `Option>` + +For partial-update APIs: + +- `None` — "do not touch this field". +- `Some(None)` — "set this field to null/empty". +- `Some(Some(value))` — "set this field to value". + +This is the only correct encoding for "no change vs explicit clear" in JSON. + +### 7. Report moved vs unchanged + +```rust +pub enum ProjectMoveOutcome { Moved, Unchanged } +``` + +Reorder APIs should return whether the operation actually moved anything. UI uses this to skip re-renders on no-ops. + +### 8. Use a `BTreeMap` for metadata + +Stable iteration order = stable output. Don't use `HashMap` for user-visible metadata. + +### 9. Three-layer startup sync + +When the marketplace needs to refresh plugins at every Codex startup, use this 3-layer fallback: + +```text +1) GitHub API → GET /repos/openai/plugins/git/refs/codex/curated-sync + compare SHA against .tmp/plugins.sha + if changed, download + extract +2) Backend archive fallback → GET /backend-api/plugins/export/curated +3) Git clone → git clone https://github.com/openai/plugins.git --branch refs/codex/curated-sync +``` + +Each layer has a 30s timeout. Use a lock file (`.tmp/plugins.sync.lock`) to prevent +concurrent syncs from multiple Codex processes. Use a SHA cache (`.tmp/plugins.sha`) +to skip work when nothing changed. Stale temp dirs (older than 10 min) are auto-cleaned. + +### 10. Decide a scope per skill within the plugin + +Each skill in your plugin should be `User` (user-installed) / `System` (bundled) / `Plugin` (this plugin) scoped. Document the scope in the frontmatter `metadata.scope` field. + +## Output contract + +A plugin that follows this design: + +- Has both `plugin.json` (AgentPlugin) AND a fallback manifest. +- Has `name` / `version` / `description` / `keywords` / `skills` / `mcp_servers` / `apps` / `hooks` / `interface` set. +- Default prompts ≤ 3 entries, each ≤ 128 chars. +- All skill paths use the `./...` syntax. +- Has a marketplace name following the prefix taxonomy. +- All create / install endpoints require an `idempotency_key`. +- Uses `BTreeMap` for any user-visible metadata. +- If a startup sync is needed, it uses a 3-layer fallback with a lock file and SHA cache. + +## Common pitfalls + +- **No idempotency key** → user retries after a network blip create duplicates. Always require it. +- **`HashMap` for metadata** → JSON output flickers on every render. Use `BTreeMap`. +- **Two syncs in parallel** → file corruption. Lock file mandatory. +- **Forgetting fallback layer** → GitHub outage takes down all installs. Always have the archive + clone as backups. +- **Default prompts > 3** or > 128 chars → silently truncated. Stay under the limit. +- **Skill paths not starting with `./`** → resolution fails. Always use `./skills/.../SKILL.md`. +- **`Option` instead of `Option>`** → cannot distinguish "no change" from "set to null". + +## Example — minimal plugin manifest + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0/plugin.schema.json", + "name": "acme-data-pipelines", + "version": "0.1.0", + "description": "Data pipeline skills for ETL, schema validation, and warehouse sync.", + "keywords": ["data", "etl", "pipeline"], + "skills": "./skills/*/SKILL.md", + "mcp_servers": { + "warehouse": { + "transport": "stdio", + "command": "./bin/warehouse-mcp" + } + }, + "interface": { + "display_name": "ACME Data Pipelines", + "brand_color": "#0066cc" + } +} +``` + +## Verification checklist + +- [ ] Manifest has all 8 fields; `name` is set. +- [ ] Default prompts ≤ 3 entries, each ≤ 128 chars. +- [ ] All skill paths use the `./...` syntax. +- [ ] Manifest fallback present. +- [ ] Marketplace name follows the prefix taxonomy. +- [ ] All create / install endpoints require an `idempotency_key`. +- [ ] Partial updates use `Option>` for 3-state. +- [ ] Reorder APIs return `Moved` / `Unchanged`. +- [ ] User-visible metadata uses `BTreeMap` not `HashMap`. +- [ ] If startup sync is used: 3-layer fallback, lock file, SHA cache, 30s timeout, 10-min stale cleanup. diff --git a/plugins/antianqi/codex-harness-patterns/skills/retry-with-backoff/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/retry-with-backoff/SKILL.md new file mode 100644 index 0000000..1d4d6db --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/retry-with-backoff/SKILL.md @@ -0,0 +1,159 @@ +--- +name: retry-with-backoff +description: | + Execute explicit retry policy: max 3, base 2s, max 30s, full jitter, 60s total budget, respects `Retry-After`. + USE WHEN: `error-recovery-strategy` classified error as `transient` and chose `retry`, HTTP 429 with `Retry-After` header, network timeout/refused/reset, queue/lock/eventually-consistent read returned stale, user said "重试" / "retry" / "再试" / "等一下" / "等几秒" / "backoff" / "exponential" / "rate limit" / "429" / "限流". + TRIGGER PHRASES: "重试", "retry", "再试", "等一下", "等几秒", "backoff", "exponential", "rate limit", "429", "Retry-After", "throttled", "限流", "busy", "服务忙". + SKIP WHEN: error is `deterministic` (won't change on retry), error is `unknown` (escalate to ask-user), work is time-sensitive and 30s backoff is too late. +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.1" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/code-mode/src/grpc_session/reconnect.rs +--- + +# Retry With Backoff + +Retry is the most common recovery action — and the easiest to do badly. Retrying +without a budget burns wall-clock and budget. Retrying with too-aggressive a backoff +hits the rate-limited service again. Retrying without jitter causes a thundering herd +when many agents retry the same service at the same instant. + +This Skill defines a single, explicit retry policy. **Always state the policy before +running the retries.** Do not improvise. + +## When to use + +Activate when **any** of these is true: + +- `error-recovery-strategy` Skill categorised the error as `transient` and chose + `retry` as the action. +- A rate-limited service (HTTP 429, API quota) returned a `Retry-After` header. +- A network call timed out, refused, or reset. +- A queue / lock / eventually-consistent read returned a stale or empty result. + +## When NOT to use + +- The error is `deterministic` (permission denied, file not found). Retry will fail + the same way. Switch tool or ask user instead. +- The error is `unknown`. Retry without a clear reason is gambling. Ask user. +- The work is time-sensitive enough that a 30-second backoff would be too late. In + that case, retry **without** backoff (1 immediate attempt) and then escalate to + user on failure. + +## Process + +1. **Pick the policy before retrying.** Defaults (override only with reason): + + | Parameter | Default | Why | + |---|---|---| + | `max_attempts` | `3` (original + 2 retries) | Three strikes; the third strike is the cost of "I'm sure it's transient." | + | `base_delay_seconds` | `2` | Short enough to be useful, long enough to feel the first retry. | + | `max_delay_seconds` | `30` | Above 30 s the user has usually already given up mentally. | + | `total_time_budget_seconds` | `60` | Hard ceiling; beyond this, escalate to user. | + | `jitter_strategy` | `full` (uniform 0..delay) | Prevents thundering herd. | + | `respect_retry_after` | `true` (if server provides `Retry-After`, use it) | Server knows more than we do. | + +2. **State the policy in the response, in one line:** + + ```text + Retry plan: 3 attempts, base 2s, max 30s, full jitter, budget 60s total + ``` + +3. **Compute each retry's actual delay as:** + + ```text + delay(n) = min(base * 2^(n-1), max_delay) + actual_delay(n) = uniform(0, delay(n)) # full jitter + ``` + + For `n = 1, 2, 3` with base 2s and max 30s: + - attempt 1: `min(2, 30) = 2s`, jittered to `[0, 2]`s + - attempt 2: `min(4, 30) = 4s`, jittered to `[0, 4]`s + - attempt 3: `min(8, 30) = 8s`, jittered to `[0, 8]`s + +4. **If the server returned a `Retry-After` header**, use `max(delay(n), retry_after)` — + the server is saying "wait at least this long," not "wait exactly this long." + +5. **Between attempts**, do not start any other tool call. The whole point of the + delay is to let the service recover. If you are tempted to "do other work while + waiting," surface to the user instead — they may want to know you are in retry + mode before you start something else. + +6. **After the last attempt**, evaluate: + - **Success** → continue, mention the retry in the result so the user knows it + took longer than expected. + - **Still failing** → escalate to `error-recovery-strategy`'s `ask-user` action. + Do not silently extend the retry count. Do not skip-with-warning. + +7. **If the total time budget (60s default) is exceeded mid-retry**, stop the next + delay and escalate immediately. Time budget is **hard**, not soft. + +## Output contract + +The user sees, in this order: + +- One-line retry plan (max attempts, base, max, jitter, total budget). +- (Per attempt) one line: "attempt N failed: ; waiting Ms." +- After the last attempt: success → continue, failure → escalate to `ask-user`. +- A short post-mortem: "Why did this take N×t? The server was rate-limiting / network + was congested / etc." if you can identify the cause; "unknown" is acceptable. + +## Example + +```text +Retry plan: 3 attempts, base 2s, max 30s, full jitter, budget 60s total + +attempt 1: GET /api/v1/users/123 → 503 Service Unavailable + waiting 1.4s (jittered from 2s) + +attempt 2: GET /api/v1/users/123 → 503 Service Unavailable + waiting 3.1s (jittered from 4s) + +attempt 3: GET /api/v1/users/123 → 200 OK + total time: 4.5s (well under 60s budget) +``` + +Counter-example (escalate, do not extend): + +```text +Retry plan: 3 attempts, base 2s, max 30s, full jitter, budget 60s total + +attempt 1: ... 503 +attempt 2: ... 503 +attempt 3: ... 503 + total time: 4.5s + +Recovery decision: ask-user +Reason: 3 attempts exhausted within budget; error is consistently 503; switching + to ask rather than extending to attempt 4. +``` + +## Common pitfalls + +- **Do not retry without a budget.** Even one undeclared retry can burn 5 minutes. +- **Do not skip jitter.** A "deterministic" delay causes thundering herd when multiple + agents retry the same service. Always jitter. +- **Do not retry past the total time budget.** The budget is hard. If you exceed it, + you have failed the Skill, regardless of how many more attempts you could fit. +- **Do not retry `deterministic` errors.** A permission-denied error will fail every + time. Switch or ask. +- **Do not start other work between retries.** The delay is for the service, not for + you. If the user has new instructions, queue them or surface a question. +- **Do not respect `Retry-After` blindly.** It is a *minimum*, not a *maximum*. Use + `max(delay(n), retry_after)`, not `retry_after` alone. +- **Do not silently extend.** If 3 attempts failed, escalate. Do not try 4 "just + in case." The user should know the work is blocked. + +## Verification checklist + +- [ ] Did you state the retry plan (max attempts, base, max, jitter, budget) before + retrying? +- [ ] Did you respect any `Retry-After` from the server (using `max`)? +- [ ] Did you add full jitter to every delay? +- [ ] Did you cap each delay at the configured `max_delay_seconds`? +- [ ] Did you stop at `max_attempts` (no silent extension)? +- [ ] Did you stop at `total_time_budget_seconds` (no overrun)? +- [ ] On exhaustion, did you escalate to `ask-user` (not silent skip)? +- [ ] Did you report the total time and cause (if known) in the post-mortem? diff --git a/plugins/antianqi/codex-harness-patterns/skills/review-mode/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/review-mode/SKILL.md new file mode 100644 index 0000000..b8f775d --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/review-mode/SKILL.md @@ -0,0 +1,147 @@ +--- +name: review-mode +description: | + Switch to critic mode after finishing a chunk, produce PASS / FIX / REDO verdict. + USE WHEN: sub-task boundary reached, user said "review" / "double-check" / "is this right" / "spot the bug" / "看一下" / "review 一下", before reporting "done" on anything user will rely on, after writing code / config / doc, after sub-agent returns. + TRIGGER PHRASES: "review", "double-check", "看一下", "review 一下", "查一下", "检查", "找 bug", "is this right", "spot the bug", "verifier", "自己 review 一下". + SKIP WHEN: one-line edit, user explicitly said "ship it" / "no more review" / "不用 review" in this turn, user can see result in chat immediately. +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.1" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs (EnteredReviewModeEvent / ExitedReviewModeEvent) +--- + +# Review Mode + +After you write code, before you say "done", switch hats. You were the author; now you are the +reviewer. The reviewer has one job: find the things the author didn't. + +This Skill is the cheapest insurance you can buy against the most common agent failure mode — +declaring a task done when it is not. + +## When to use + +Activate when **any** of these is true: + +- A non-trivial sub-task has just finished (a function, a file, a config, a migration, a test + suite, a doc section). +- The user said "review this", "double-check", "is this right", "spot the bug", or "any issues + with this?". +- You are about to mark a `todowrite` step `[x]` as done and that step touches anything the + user will rely on. +- The work crossed a trust boundary (production, public API, schema, persisted data, a contract + someone else will code against). + +## When NOT to use + +- A trivial one-line edit. The marginal value of review is too low to pay the token cost. +- The user explicitly said "ship it" / "no review" / "just commit" in this turn. +- You are mid-stream on a single step and the next step will surface errors anyway (e.g. + running the test suite next). +- The work is exploratory (a sketch, a draft, "show me what you mean"). Review it later, when + the draft becomes a proposal. + +## Process + +1. **State the review scope** in one sentence before you read anything: "Reviewing the auth + refactor: 4 files changed, new `OidcProvider` trait, SAML tests must still pass." Future you + needs the boundary. +2. **Re-read your own output from a critic's position.** Open the file(s) you changed. Do not + re-read the diff; read the **result**. Look for: + - Off-by-one, wrong-sign, null/None mishandling, empty-collection edge cases. + - Naming that lies (function called `validate` that does not validate). + - Log/error paths that swallow useful information. + - Tests that pass for the wrong reason (e.g. asserting `==` on a value the function never + returns). + - Public API surface that locks in a bad design (a struct that is too wide to evolve, a + flag that should be an enum). + - Comments that contradict the code. +3. **Run the verifier if there is one.** Tests, linter, type-check, schema diff, manual smoke. + If the verifier says green, you have *evidence*; if it is silent, you have *hope*. Do not + ship hope. +4. **Produce a verdict** in this exact shape: + + ```markdown + ## Review — + + **Verdict**: PASS / PASS with caveats / FIX required / REDO + + **What I checked** (bullet list of specific things): + - <...> + + **What I found** (concrete defects, not vibes): + - + - (or "none") + + **What I am unsure about** (so the user can decide): + - <...> + - (or "nothing — the verifier ran and the design matches the spec") + ``` + +5. **Apply fixes if the verdict is FIX / REDO and the fix is small.** Do not fix large things + in review mode; surface them and start a new `plan-stream-emit` cycle. +6. **If PASS**, continue to the next step. The review record is part of the audit trail — + keep it short but specific. + +## Output contract + +The user sees, in this order: + +- One-line scope statement. +- The Review block above. +- (If fix) the one-line summary of what you fixed. +- (If PASS) the next concrete step. + +## Example + +```markdown +Reviewing the auth refactor: 4 files changed, new `OidcProvider` trait, SAML tests must still pass. + +## Review — auth refactor (OIDC adapter v1) + +**Verdict**: PASS with caveats + +**What I checked**: +- `cargo check` on the workspace +- `cargo test auth::` (all 14 tests pass, including the 12 unchanged SAML ones) +- the new `OidcProvider` trait signature for type-correctness +- the `auth.oidc_enabled = false` default path against the existing SAML flow + +**What I found**: +- `src/auth/oidc/mod.rs:42` — `expires_at` is `i64` not `u64`; future-dated tokens underflow. + Fix applied (cast + `saturating_sub`). +- `src/auth/callback.rs:91` — error path on token exchange returns the raw HTTP body, leaks + the client_secret on 4xx. Fix applied (redact before returning). +- nothing else + +**What I am unsure about**: +- whether the OIDC `nonce` claim should be persisted in the session store; the Okta + spec says yes, Auth0 says optional. Pick before merging. +``` + +## Common pitfalls + +- **Do not review your own diff — review the result.** A diff makes you forgive yourself + (you remember why each line is there). The file on disk has no such forgiveness. +- **Do not write "looks good" as a verdict.** "Looks good" is a vibe, not a finding. Name the + specific things you checked, even if they are negative ("verified X, Y, Z are absent"). +- **Do not skip the verifier step.** If there is no test, run the build. If there is no build, + read the file with a critical eye. +- **Do not fix in review mode beyond trivial.** Anything that takes more than 2-3 minutes to + fix is a new sub-task, not a review item. Surface it. +- **Do not produce a 50-line review report for a 5-line change.** Match the report size to + the change size. +- **Do not review work you did not just do.** If the user asks you to review code from last + week, this Skill's "just finished" assumption does not hold — re-anchor by stating scope. + +## Verification checklist + +- [ ] Did you state the review scope in one sentence? +- [ ] Did you re-read the file(s) from a critic's position, not the diff? +- [ ] Did you run the verifier (tests / lint / type-check / smoke) and cite its result? +- [ ] Is the verdict one of {PASS, PASS with caveats, FIX required, REDO}? +- [ ] Is "What I found" specific (file:line — defect — fix), not vague? +- [ ] If you applied a fix, was it trivial (< 2-3 minutes)? +- [ ] Did the user see the review record before you moved on? diff --git a/plugins/antianqi/codex-harness-patterns/skills/session-branch-fork/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/session-branch-fork/SKILL.md new file mode 100644 index 0000000..e0d607e --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/session-branch-fork/SKILL.md @@ -0,0 +1,272 @@ +--- +name: session-branch-fork +description: | + Design a session-level fork / revert / recover / suspend mechanism over a paginated history with lineage tracking, immutable segments, global lock, ModelContext reconstruction, and bounded replay. + USE WHEN: designing session persistence, building a "fork this conversation" feature, building "undo last N turns", building "suspend and resume later", implementing a paginated history with segment-level cursor, reconstructing ModelContext from disk, or any task involving "session as a git-like object graph". + TRIGGER PHRASES: "session fork", "session branch", "thread fork", "thread rollback", "revert thread", "ThreadRollback", "SuspendTurnAndShutdown", "Op::RecoverTurn", "paginated history", "RolloutLineage", "ForkBoundary", "RolloutReferenceIndex", "ModelContext reconstruction", "ReverseJsonlScanner", "bounded replay", "git baseline", "writer lock", "subagent lineage". + SKIP WHEN: single-session task state (use `world-state-tracking`), no need to undo, no need to fork. +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.0" + inspired-by: https://github.com/openai/codex/tree/main/codex-rs/thread-store/ (P-49/50/51/52 + P-67-77) + changes-from-v0.0.0: "Initial design distilled from P-49/50/51/52 + P-67-77 deep-dive (Phase 0 错判修正 + Phase 1 Week 3)." +--- + +# Session Branch / Fork + +Design a session-level fork / revert / recover / suspend system over a paginated +history. Mirrors `codex-rs/thread-store/`. + +## When to use + +Activate when designing: + +- A "fork this session from turn N" feature. +- A "undo the last N turns" feature. +- A "suspend the running session, recover it later" feature. +- A paginated history with cross-segment cursors. +- A model-context reconstruction algorithm that doesn't re-read the entire history. + +## When NOT to use + +- Single-session task state → use `world-state-tracking`. +- No undo, no fork, no suspend needed → standard append-only history is enough. + +## Process + +### 1. Pick a `ThreadHistoryMode` + +```rust +pub enum ThreadHistoryMode { + Legacy, // entire thread in one JSONL + Paginated, // immutable segments, supports fork/revert +} +``` + +**Default to Legacy** for backward compatibility, but use Paginated for any new +thread. Paginated is what makes fork / revert / lineage work. + +### 2. Define the immutable segment model + +```rust +pub struct RolloutLineageSegment { + pub rollout_id: ThreadId, + pub rollout_path: PathBuf, + pub start_ordinal: u64, + pub end: Option, // byte offset +} + +pub struct RolloutLineage { + pub segments: Vec, +} +``` + +**Key invariants**: + +- Segments are **immutable**. A new segment is created for any change. +- The lineage is a list of segments ordered from oldest to newest. +- Each segment knows its start ordinal and (optionally) its end offset. + +### 3. Implement fork with `ForkBoundary` + +```rust +pub enum ForkBoundary { + Latest, // inherit source's latest durable state + ThroughTurn(String), // include this turn + BeforeTurn(String), // exclude this turn +} + +pub struct PrepareForkParams { + pub thread_id: ThreadId, + pub boundary: ForkBoundary, +} + +pub struct PreparedFork { + pub source_thread_id: ThreadId, + pub model_context: Arc, +} +``` + +Fork flow: + +1. Lock the source thread's lifecycle + writer. +2. Persist any pending items in the source. +3. Resolve the source's `RolloutLineage`. +4. Materialize each ancestor segment to SQLite (if not already). +5. Load the `ModelContext` for the chosen boundary. +6. Return a `PreparedFork` ready to be turned into a new thread. + +### 4. Implement revert with CAS + +```rust +pub struct RevertThreadParams { + pub thread_id: ThreadId, + pub before_turn_id: String, // first turn EXCLUDED from retained history +} +``` + +Revert flow: + +1. Lock lifecycle + writer + writer_lock_coordinator. +2. Resolve the current rollout from SQLite (`expected_sqlite_path` is the CAS anchor). +3. Read `SessionMeta` from the current rollout, verify `id == thread_id` and `history_mode == Paginated`. +4. Materialize any compressed lineage segments. +5. Create a new immutable rollout file referencing the retained prefix. +6. CAS the SQLite `rollout_path` to the new file. + +**Critical contract**: `revert` only rolls back in-memory context. **It does not undo filesystem changes.** The client is responsible for undoing edits on disk. + +### 5. Implement suspend + recover + +```rust +Op::SuspendTurnAndShutdown { reply: oneshot::Sender<...> } +Op::RecoverTurn { thread_settings, reply: oneshot::Sender<...> } +``` + +Suspend flow (9 steps): + +1. Lock `active_turn` and verify `task.kind == TaskKind::Regular`. +2. Snapshot descendants (not a seal; best-effort). +3. `live_thread.flush()` — persistence first; if it fails, leave the turn running. +4. Re-lock and re-verify kind (flush can yield). +5. Take the turn and task; cancel the cancellation token; cancel git enrichment. +6. `task.handle.detach()` with a `GRACEFULL_INTERRUPTION_TIMEOUT_MS` timeout. +7. `session.input_queue.clear_pending(&turn)` — pending input is NOT persisted. +8. `shutdown_session_runtime(session)` + `live_thread.flush()` + `live_thread.shutdown()`. +9. Emit `ShutdownComplete` event **only after** the writer is closed. + +**Critical contract**: do NOT record a terminal turn event on suspend. This +intentionally leaves the turn's ID reclaimable, so `Op::RecoverTurn` can resume it. + +### 6. Use a global lock for multi-process safety + +When two Codexes might fork / revert the same thread, use a DB-level lease: + +- `try_claim_global_phase2_job(thread_id, JOB_LEASE_SECONDS)` — single-writer. +- `heartbeat_global_phase2_job(ownership_token, JOB_LEASE_SECONDS)` — keep lease alive. +- On agent completion, re-verify ownership BEFORE `reset_git_repository` to avoid + resetting someone else's work. + +### 7. Reconstruct ModelContext with bounded replay + +```rust +pub fn load_latest_model_context(store, params) -> StoredModelContext { + let path = thread_rollout_resolver::resolve_current(...).await?; + let session_meta = read_session_meta_line(path).await?; + if session_meta.id != params.thread_id { return Err(InvalidRequest); } + + let mut scanner = ReverseJsonlScanner::new(file)? + .with_max_record_bytes(MAX_ROLLOUT_LINE_BYTES); + let mut scan = ModelContextScan::default(); + + while let Some(outcome) = scanner.scan_next::()? { + if let ScanOutcome::Parsed(value) = outcome { + if scan.push(line) == ModelContextScanProgress::Complete { + let items = scan.finish(session_meta); + items.retain(|i| !matches!(i, RolloutItem::SessionMeta(_))); + return Ok(StoredModelContext { items }); + } + } + // ScanOutcome::Rejected — skip bad line, do not abort + } + // No bounded cutoff found — fall back to full replay +} +``` + +Three guarantees: + +- `ReverseJsonlScanner` reads from the END of the file — only the suffix is touched. +- `MAX_ROLLOUT_LINE_BYTES` prevents a single bad line from OOM-ing the reader. +- `ScanOutcome::Rejected` skips malformed lines; never aborts the whole read. +- If no bounded cutoff is found, fall back to the full replay (read entire file). + +### 8. Run a Legacy → Paginated migration on startup + +```rust +pub async fn migrate_rollouts_on_startup(store) { + // Use a creation-ordered cursor in SQLite to check only newer rollout files + // 48h lookback window catches files we skipped earlier + // Fingerprint (size_bytes + modified_at_ns) skips empty/malformed rollouts + // Use run marker to prevent overlapping migrations + // Spawn subagent-aware bounded replay for subagent rollouts (don't copy + // the parent's full history into every child) +} +``` + +### 9. Use a writer lock for serialization + +`writer_lock_coordinator.acquire(thread_id)?` — guarantees that writes to a +single thread are serialized even across processes. Drop the lock when the +thread is closed. + +### 10. Adopt the 3-state section / project model + +```rust +pub struct UpdateProjectParams { + pub project_id: String, + pub name: Option, // None = no change + pub roots: Option>, + pub metadata: Option>, +} +``` + +Use `Option>` for 3-state: `None` = no change, `Some(None)` = clear, +`Some(Some(v))` = set. + +## Output contract + +A session-management system that follows this design: + +- Threads have a `ThreadHistoryMode` (Legacy / Paginated). +- Paginated threads have a `RolloutLineage` of immutable segments. +- Fork accepts a `ForkBoundary` and returns a `PreparedFork`. +- Revert uses CAS on the SQLite rollout_path; creates a new immutable segment. +- Suspend + Recover are paired; suspend does NOT record a terminal event. +- Reconstruct ModelContext via reverse scan with bounded replay + byte cap + Rejected skip. +- Migration runs at startup with cursor in DB + fingerprint skip + 48h lookback. +- Multi-process safety via DB leases and writer lock coordinator. + +## Common pitfalls + +- **No `Paginated` history mode** → no fork / revert. New threads should default to Paginated. +- **Revert undoes filesystem** → it does not. Client is responsible. +- **Suspend records terminal event** → Recover can never resume. Don't. +- **Pending input persisted** → replay state is wrong. `clear_pending` on suspend. +- **Reverse scan reads whole file** → slow. Bounded replay + `MAX_ROLLOUT_LINE_BYTES`. +- **Migration on every startup reads all rollouts** → slow. Cursor + 48h lookback. +- **Two Codexes reverting simultaneously** → corruption. Lease / writer lock. +- **Reset git baseline with diff present** → deleted content stays in git objects. Delete diff first. +- **Single-type partial update vs. clear** → use `Option>` for 3-state. + +## Example — fork from a turn + +```text +# Source thread has 50 turns +fork_boundary = BeforeTurn("turn-30") +prepare_fork(thread_id, boundary) → + 1. Lock source lifecycle + writer + 2. Persist pending items + 3. Resolve lineage (5 segments) + 4. Materialize segments 1..4 to SQLite + 5. ModelContext = base + turns 1..29 (before turn-30) + 6. PreparedFork { source_thread_id, model_context } +new_thread = create_thread(forked_from_id = source.id, history_base = ModelContext.snapshot) +# New thread starts at turn 30, inherits turns 1..29 from ModelContext +``` + +## Verification checklist + +- [ ] All new threads default to `ThreadHistoryMode::Paginated`. +- [ ] Fork accepts `ForkBoundary` (Latest / ThroughTurn / BeforeTurn). +- [ ] Revert uses CAS on the SQLite rollout path; creates a new immutable segment. +- [ ] Suspend flushes BEFORE canceling, and re-checks turn kind after flush. +- [ ] Suspend does NOT record a terminal turn event. +- [ ] Suspend clears pending input (input is not persisted). +- [ ] Suspend emits `ShutdownComplete` ONLY after `live_thread.shutdown()` returns. +- [ ] Reconstruct ModelContext via `ReverseJsonlScanner` with byte cap. +- [ ] Reconstruct skips `ScanOutcome::Rejected` without aborting. +- [ ] Migration uses SQLite cursor + fingerprint skip + 48h lookback. +- [ ] Multi-process safety: writer lock + DB lease + ownership token. +- [ ] Partial-update APIs use `Option>` for 3-state. diff --git a/plugins/antianqi/codex-harness-patterns/skills/session-handoff/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/session-handoff/SKILL.md new file mode 100644 index 0000000..2e28eab --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/session-handoff/SKILL.md @@ -0,0 +1,207 @@ +--- +name: session-handoff +description: | + At session end, write a structured handoff file so next session can pick up in 30 seconds. + USE WHEN: user says "今天先到这" / "done for today" / "see you tomorrow" / "we'll continue later" / "下次再继续" / "end session" / "收尾", context about to compact, long task in progress, natural pause approaching (end of work day, end of milestone), sub-task in flight that outlives this session. + TRIGGER PHRASES: "今天先到这", "done for today", "see you tomorrow", "we'll continue later", "下次再继续", "先到这", "end session", "session 结束", "收尾", "写到 handoff file", "wrap up", "session handoff", "session 接力". + SKIP WHEN: session just started (no in-progress work to hand off), work is fully complete and verified (completion-audit passed), user said "throw it all away, start fresh next time" / "全部扔掉". +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.1" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/state/src/runtime/recovery.rs and state/migrations/0047_rollout_migration_state.sql +--- + +# Session Handoff + +Sessions end. Time, context, attention, the user's day — all are finite. The default +end is "we'll pick it up next time," but that usually means "we'll figure out what we +were doing next time" — which costs the user another 5 minutes of re-orientation +every session, and costs you the context you built. + +This Skill makes session end a **first-class operation** with an explicit output: a +handoff file that the next session can `read` first, before any other action, and be +productive in 30 seconds instead of 5 minutes. + +## When to use + +Activate when **any** of these is true: + +- The user says "done for today" / "let's stop here" / "see you tomorrow" / "we'll + continue later". +- The context is about to be compacted or has grown large; you anticipate losing + context before the next user message. +- A long task is in progress and a "natural pause" is approaching (end of work day, + end of a logical milestone, user switch context). +- A sub-task is in flight (long-running build, background command) that will outlive + this session. + +## When NOT to use + +- The session just started. There is no in-progress work to hand off. +- The work is fully complete and verified (`completion-audit` passed). No + in-progress state to hand off. +- The user explicitly said "throw it all away, start fresh next time." Respect that. + +## Process + +1. **Confirm the handoff is wanted** (if you can — skip this step if the user is + clearly stepping away). One line: "Writing a handoff file so next session can + pick this up — okay?" +2. **Choose a single, predictable path.** Default: + `.minimax/handoff/-.md`. Different from world-state and + goal files (those describe current state; handoff is the **transition**). +3. **Write the handoff file** in this exact shape: + + ```markdown + # Handoff — + + **Written**: + **Session ended because**: + **Handed off to**: + + ## Goal (verbatim from the user) + + + + ## Current state (point-in-time snapshot) + + - — last updated + - — last updated + - — last updated + - — last updated + + ## What was done (this session) + + - [x] + - [x] + - [ ] + + ## In progress (when we stopped) + + - **What**: + - **Where we were**: + - **Next concrete step**: + - **Blocker (if any)**: + + ## Open questions for next session + + - + - + + ## Critical paths (read these first next session) + + - + - + - + + ## Things that might be wrong on resume + + - + - + - + ``` + +4. **Update the state files** (world-state, goal, family) to reference the handoff + file by path. The next session's `world-state-tracking` read should mention "see + handoff file X" so the next-session agent knows to read it first. + +5. **Tell the user, in one line**, where the handoff is: "Handoff written to + `.minimax/handoff/2026-08-24-xxx.md` — read this first next session." + +6. **If a sub-task is in flight** (background command, async build, etc.): + - Record its task_id, command, expected completion signal in the handoff. + - Do not assume the sub-task will complete; the next session may need to check. + +## Output contract + +The user sees, in this order: + +- One-line confirmation of the handoff path. +- The handoff file's contents (or a link to it). +- (If sub-task in flight) the task_id and how to check its status. +- (If open questions) the questions, so the user can answer them before the next session. + +## Example + +```markdown +# Handoff — Auth refactor (OIDC alongside SAML) + +**Written**: 2026-08-23T23:55:00Z +**Session ended because**: user said "we'll continue tomorrow" +**Handed off to**: next session — read this first + +## Goal (verbatim from the user) + +> "Refactor the auth subsystem to support OIDC without breaking the existing SAML path." + +## Current state (point-in-time snapshot) + +- `.minimax/goal/2026-08-23-auth-oidc.md` — last updated 2026-08-23T23:55:00Z +- `.minimax/state/auth-refactor.md` — last updated 2026-08-23T23:55:00Z +- `.minimax/family/auth-refactor.md` — last updated 2026-08-23T23:55:00Z + +## What was done (this session) + +- [x] Mapped current auth flow in `src/auth/` +- [x] Drafted `OidcProvider` trait + one impl for `provider = "okta"` +- [x] Confirmed 12/12 existing SAML tests still pass +- [ ] Add OIDC test for happy path with mock IdP +- [ ] Update `docs/auth.md` with new config flag + +## In progress (when we stopped) + +- **What**: writing the OIDC happy-path test +- **Where we were**: about to add the test fixture (decided to use `oauth2-mock-server`) +- **Next concrete step**: Create `tests/auth/oidc_test.rs` with a mock server fixture + and one login round-trip assertion +- **Blocker**: none + +## Open questions for next session + +- Should the OIDC module own token storage, or reuse the existing session store? +- Does IT have a preferred OIDC library (defaulting to `openidconnect`)? + +## Critical paths (read these first next session) + +- `/repo/src/auth/idp.rs` — current IdP interface +- `/repo/src/auth/oidc/mod.rs` — drafted OIDC implementation +- `.minimax/goal/2026-08-23-auth-oidc.md` — current goal +- `.minimax/state/auth-refactor.md` — current world state + +## Things that might be wrong on resume + +- `docs/auth.md` was last updated 3 days ago by another contributor; verify it still + describes SAML only before adding OIDC docs. +- The test fixture choice (`oauth2-mock-server`) is a recent decision; confirm with + the user before committing to it. +``` + +## Common pitfalls + +- **Do not write the handoff after every tool call.** It is a session-end operation, not + a checkpoint. +- **Do not skip the verbatim goal.** The next session does not have the user's voice + in context; the verbatim quote is the only way to recover the user's exact ask. +- **Do not write vague "next steps."** "Continue the work" is not a step. + "Create test file X with assertion Y" is. +- **Do not assume the sub-task will complete.** A background build can be killed + between sessions; the next session must check. +- **Do not put sensitive data in the handoff.** The file is on disk. Treat it like + any other workspace file. +- **Do not make the handoff the only place state lives.** The handoff **points to** + state files; the state files are the source of truth, the handoff is the index. + +## Verification checklist + +- [ ] Is the handoff at a single, predictable path (`.minimax/handoff/...`)? +- [ ] Is the goal section copied verbatim from the user? +- [ ] Are the "done" items ✅ with file paths, and the "in progress" items ⬜ with + exact "where we were" pointers? +- [ ] Is the "Next concrete step" one verb-first sentence? +- [ ] Are the critical paths listed (what the next session must read first)? +- [ ] Are the "might be wrong" risks named (so the next session verifies them)? +- [ ] If a sub-task is in flight, is its task_id and status-check method recorded? +- [ ] Did you tell the user where the handoff file is? +- [ ] Did you update the world-state file to reference the handoff path? diff --git a/plugins/antianqi/codex-harness-patterns/skills/skill-auto-select/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/skill-auto-select/SKILL.md new file mode 100644 index 0000000..515df5a --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/skill-auto-select/SKILL.md @@ -0,0 +1,200 @@ +--- +name: skill-auto-select +description: | + Design a Skill (or Plugin) that an LLM agent can reliably discover, select, and invoke based on its description, with explicit selection syntax, name-collision handling, and three-layer matching. + USE WHEN: authoring a new skill for a Plugin, designing skill frontmatter, deciding between structured `UserInput::Skill` vs implicit `$skill-name` mention, handling duplicate skill names, picking between path-precise and name-based matching, or any task involving "make my skill actually get picked up by the agent". + TRIGGER PHRASES: "skill selection", "skill auto-pick", "$skill-name mention", "skill description", "skill metadata", "SkillMetadata", "ExplicitSkillLookup", "three-layer matching", "name collision", "ambiguous skill name". + SKIP WHEN: writing a one-shot script (use `error-recovery-strategy` or similar task skill), skill is human-only (no agent invocation), skill is bundled and not selectable. +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.0" + inspired-by: https://github.com/openai/codex/tree/main/codex-rs/skills/ (P-85/86/87/88/89/92) + changes-from-v0.0.0: "Initial design distilled from P-85/86/87/88/89/92 deep-dive (Phase 1 Week 2)." +--- + +# Skill Auto-Select + +Design a Skill (or a whole Plugin) so an LLM agent can reliably discover it, decide +it is the right one, and invoke it. Mirrors the design of Codex's `codex-rs/skills/` +runtime, which is what this very Plugin is mimicking. + +## When to use + +Activate when designing: + +- A new skill's frontmatter (`name`, `description`, `short_description`, `interface`, `dependencies`, `policy`). +- A skill marketplace or registry where multiple skills may collide on name. +- A path-based discovery surface (logical discovery path vs canonical path). +- An explicit-vs-implicit invocation model (structured input vs `$name` mention vs shell command invocation). + +## When NOT to use + +- Skills that are bundled, not selectable (e.g. always-on system skills). Use a different distribution model. +- One-shot scripts that should never be auto-selected. Use task skills (`error-recovery-strategy`, `plan-stream-emit`). + +## Process + +### 1. Write the 11-field SkillMetadata + +Every skill should expose at minimum these fields: + +| Field | Type | Purpose | +|---|---|---| +| `name` | `String` (≤ 64 chars) | Canonical name, used in mentions and uniqueness checks. | +| `description` | `String` (≤ 128 chars) | One-line purpose, used by LLM to decide "is this for me?". | +| `short_description` | `Option` | UI label, used in lists. | +| `interface` | `Option` | UI metadata (`display_name`, `icon`, `brand_color`, `default_prompt`). | +| `dependencies` | `Option` | Declared external tools (MCP / function / etc). | +| `policy` | `Option` | `allow_implicit_invocation` (default `true`), `products`. | +| `path_to_skills_md` | `AbsolutePathBuf` | Host-side canonical path. | +| `scope` | `SkillScope` | Source: `User` / `System` / `Plugin` / etc. | +| `plugin_id` | `Option` | If from a marketplace plugin. | +| `remote_plugin_id` | `Option` | If remote. | +| (system) | `enabled` | Computed from `disabled_paths`. | + +In your frontmatter, the **only fields that matter for LLM matching** are `name` and +`description`. The other fields matter for the runtime. + +### 2. Write a keyword-greppable description (v0.6.1 format) + +```yaml +description: | + . + USE WHEN: . + TRIGGER PHRASES: . + SKIP WHEN: . +``` + +Why: + +- The LLM matches on real signals (`ECONNREFUSED`, `permission denied`, `retries exceeded`, "上下文满了" / "出错了" / "重试"), not abstract prose. +- `USE WHEN` and `TRIGGER PHRASES` are greppable substrings; `SKIP WHEN` reduces false positives. +- Bilingual (English + Chinese) descriptions match user language directly. + +### 3. Adopt three-layer matching + +When a user types `$skill-name` or `[$skill-name](path)`: + +```text +Layer 1 — canonical path: /path/to/skills/SKILL.md +Layer 2 — discovery path: skill://skill-name/SKILL.md (logical) +Layer 3 — plain name: skill-name (only if unambiguous) +``` + +Rules: + +- Layer 1 wins if path matches canonical. +- Layer 2 wins if path matches discovery path AND Layer 1 missed. +- Layer 3 wins ONLY if `skill_count == 1 && connector_count == 0` (uniqueness check via `name_counts`). +- If a structured `UserInput::Skill` already matched some name, **block** that name from Layer 3 (`blocked_plain_names`). + +Complexity target: `O(T + (N_s + N_t) * S)` time, `O(S + M)` space (T = text length, S = skill count, M = mentions per input). With ~20 skills and 1KB text, this is sub-millisecond. + +### 4. Provide explicit invocation syntax + +Two syntaxes, both supported: + +```text +$skill-name # plain +[$skill-name](skill://path/SKILL.md) # linked +``` + +Exclude environment variables from being mistaken for skills (`is_common_env_var($HOME)` → true, skip). Support the 5 tool mention kinds with 4 path prefixes: + +```text +app://app-id/... +mcp://server/tool +plugin://plugin-id/... +skill://skill-name/... +SKILL.md (literal filename) +``` + +### 5. Detect implicit invocation in shell commands + +Before doing the explicit three-layer match, also detect when a shell command references a skill script or document: + +```rust +detect_implicit_skill_invocation_for_command(outcome, command, workdir) +``` + +- Tokenize (Windows: PowerShell; Unix: shlex). +- Look for `python` / `node` / `bash` / `sh` / `pwsh` invocations. +- Look for `Read` operations on `scripts/` or `references/`. +- Match by path (scripts dir → skill) and by doc (read path → skill). + +### 6. Cache the loaded snapshot + +Use a `SkillRootSnapshotCache` trait so the loader can re-use a parsed snapshot: + +```rust +pub trait SkillRootSnapshotCache: Send + Sync { + fn get(&self, root: &Root) -> Option; + fn insert(&self, root: Root, snapshot: LoadedSkillRoot); +} +``` + +`SkillRootSnapshots` is `Arc>` with identity-based +`Hash` / `Eq` (uses `Arc::ptr_eq`). Cache key safety: clones share the same `Arc`, +so identity equality holds. + +### 7. Load with errors-as-data + +`LoadedSkillRoot { skills, errors: Vec, ... }` — never let one bad skill +kill the whole root. Collect errors and surface them at the top. + +## Output contract + +A skill that follows this design: + +- Has a 64-char-max `name` and a greppable 128-char-max `description`. +- Supports both `$name` plain and `[$name](path)` linked mention. +- Three-layer matching with uniqueness check on plain name. +- Implicit invocation detection in shell commands. +- Cached snapshot with identity-based hashing. +- Errors collected per-skill, never aborting the whole root. + +## Common pitfalls + +- **Plain name on a duplicate** → ambiguous; ignored. Always provide a path or qualify with the structured form. +- **Description too abstract** → LLM cannot match. Use the 4-line `USE WHEN / TRIGGER PHRASES / SKIP WHEN` format with concrete keywords. +- **Bypassing the uniqueness check** → two skills fire from one mention. Always require `skill_count == 1`. +- **Forgetting `is_common_env_var`** → `$HOME` / `$PATH` become "skill mentions". Filter them. +- **Loading all skills on every mention** → slow. Use `SkillRootSnapshotCache`. +- **Frontmatter name > 64 chars** → rejected by parser. Count your characters. +- **Skills with `description: ""` → MissingField error**. Description is mandatory. + +## Example — minimal frontmatter + +```yaml +--- +name: my-skill +description: | + Detect a specific failure mode in the running session and recover. + USE WHEN: ECONNREFUSED, permission denied, retries exceeded, "can't connect" / "出错了" / "重试" / "权限". + TRIGGER PHRASES: "recover", "retry failed", "switch tool", "ask me", "出错了", "重试". + SKIP WHEN: short task, in middle of dictating. +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: you + version: "0.1.0" +--- + +# My Skill + +... the actual instructions ... +``` + +## Verification checklist + +- [ ] Frontmatter has `name` (≤ 64) and `description` (≤ 128, ≥ 1, non-empty after `sanitize_single_line`). +- [ ] Description uses the 4-line `USE WHEN / TRIGGER PHRASES / SKIP WHEN` format. +- [ ] Description is bilingual if your users write in multiple languages. +- [ ] Three-layer matching is implemented: canonical path → discovery path → unique plain name. +- [ ] `name_counts` is built once per selection and consulted for uniqueness. +- [ ] `is_common_env_var` filters out `$HOME` / `$PATH` etc. +- [ ] Implicit invocation detection tokenizes per-platform (PowerShell vs shlex). +- [ ] Snapshot cache is identity-based (`Arc::ptr_eq`). +- [ ] Load errors are collected per-skill, never abort the root. diff --git a/plugins/antianqi/codex-harness-patterns/skills/streaming-output-reader/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/streaming-output-reader/SKILL.md new file mode 100644 index 0000000..e54e235 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/streaming-output-reader/SKILL.md @@ -0,0 +1,155 @@ +--- +name: streaming-output-reader +description: | + Read long streaming responses in bounded chunks with cumulative summary, max 3 reads, never loop. + USE WHEN: tool returns long stream (SSE / WebSocket / `tail -f` / large log), output might be > 3000 tokens, file size unknown, previous read returned "truncated" / "use offset to read more" / "output cut off", `tail` of a growing log, "流式" / "实时" / "incremental" / "read in chunks". + TRIGGER PHRASES: "流式", "streaming", "实时", "tail -f", "real-time", "一边跑一边看", "log 在长", "output cut off", "读到一半卡了", "incremental", "stream-read", "read in chunks", "流式读取". + SKIP WHEN: output is small (<100 lines), output is structured and needs whole parse, polling for specific event (different pattern). +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.1" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/core/src/client.rs and core/src/unified_exec/ +--- + +# Streaming Output Reader + +Long outputs kill conversations. A 50,000-line log dump will fill any context window +in one read. The reflex is to `tail` the file or `read` a small slice — but that often +misses the **earliest** lines (errors at the start of a long run) and requires multiple +round trips to get a complete picture. + +This Skill defines a single-pass read protocol: read in **bounded chunks**, keep a +**cumulative summary**, and stop when you have enough to act on. + +## When to use + +Activate when **any** of these is true: + +- A tool returns output that **might** be longer than ~3000 tokens (the `tool-output-budget` + threshold). Better to be cautious: read in chunks from the start. +- The tool offers an explicit streaming API (SSE, WebSocket chunks, `tail -f` with + follow-mode, log subscription) and you are not using it. +- You are about to read a file you do not control the size of (build logs, test logs, + process stdout, JSONL files). +- A previous read returned truncation / "output cut off" / "use offset to read more". + +## When NOT to use + +- The output is known to be small (< 100 lines). Just `read` or `cat` it in one go. +- The output is structured (JSON / CSV) and you need the **whole** thing to parse. Read + it once with a guard ("first 3000 lines"), then if you need more, do a second + targeted read. +- You are polling for a specific event ("did the file appear yet?"). That is a + different pattern (`background-task` Skill + `read` with a wait). + +## Process + +1. **Estimate before reading.** If you do not know the size, use a low-cost probe + (e.g. `wc -l file.log`, `ls -la file.log`, or `du -h`) to learn the byte / line count + before reading content. This prevents "oh, 200 MB" surprises. +2. **Pick a chunk policy.** Default: + - `head -N` for the first N lines (where N ≈ 200) — captures start-of-run errors + and configuration. + - `tail -N` for the last N lines (where N ≈ 50) — captures end-of-run status, + exit messages, final stack trace. + - `grep -B 2 -A 2 PATTERN` for targeted search — captures context around a known + error or marker. +3. **Read once, in chunks, do not loop.** Three reads in a single response is + acceptable; ten is not. After three reads, **stop and synthesize** — re-reading the + same file in different windows is a sign the data is too noisy for the current + approach, and the right answer is `ask-user` or `switch` to a different file. +4. **Maintain a cumulative summary as you read.** The summary is what the user sees; + the raw chunks are scaffolding. After each chunk, append a one-line takeaway to the + summary. The summary fits in your context; the raw output does not. +5. **Stop when you have enough to act on.** "Enough" means: + - You can state the file's purpose and current state in one sentence. + - You can identify any errors or anomalies. + - You can answer the user's original question, OR you know what you would need to + read next to answer it (and the next read is targeted, not another dump). +6. **If you cannot stop**, surface to the user with the cumulative summary, the + question, and the trade-off (read more context? read a different file? ask the user?). + +## Output contract + +The user sees, in this order: + +- One-line estimate of the file / stream size (if probed). +- A cumulative summary in this shape: + + ```text + Stream summary: + Chunks read: (head 200 / tail 50 / grep 1) + Key findings: + - : + - : + Next step: + ``` + +- (If you stopped because of step 6) the question and trade-off, not a silent loop. + +## Example + +```text +> wc -l /var/log/app/today.log +4287 /var/log/app/today.log + +> head -200 /var/log/app/today.log +[200 lines read; key entries captured below] + +> grep -B 2 -A 5 "ERROR" /var/log/app/today.log | tail -50 +[50 lines of error context; summary below] + +Stream summary: application startup with a 10-minute error burst at 14:23 +Chunks read: 3 (head 200 / tail 50 / grep 1) +Key findings: +- 14:23:00 INFO service starting +- 14:23:14 ERROR connect ECONNREFUSED 10.0.0.5:5432 (database) +- 14:23:14 ERROR retry 1/3 ... +- 14:23:23 ERROR retry 3/3 exhausted; service degraded +- 14:33:00 WARN service continuing in degraded mode (DB read-only) + +Next step: investigate why 10.0.0.5:5432 was unreachable at 14:23; the +service is now in a degraded state and the user may need to restart. +``` + +Counter-example (read too much, lost the thread): + +```text +> read /var/log/app/today.log +[4000 lines, ~50K tokens; everything from the read is now in context] + +> read /var/log/app/today.log +[duplicate, same 4000 lines] + +> read /var/log/app/today.log +[duplicate, same 4000 lines — context is now full of redundant log] +``` + +The counter-example is the failure mode this Skill prevents. Three reads, no synthesis, +context full, no actionable conclusion. + +## Common pitfalls + +- **Do not `read` an unbounded file in one call.** Use `head`, `tail`, or `grep` first. +- **Do not loop reads.** Three is a hard cap. After three, you owe the user a + synthesis, not a fourth read. +- **Do not skip the summary.** The raw chunks are scaffolding; the summary is the + product. Without a summary, the user has to re-read everything you read. +- **Do not guess from a single chunk.** If the file is structured (timestamps, log + levels), use grep to anchor on the structure, not just head/tail. +- **Do not re-read the same range.** If `head -200` did not show what you needed, do + not read `head -200` again; read `grep PATTERN` or `sed -n '200,400p'`. +- **Do not stream to the user's chat verbatim.** The user wants the synthesis, not + the raw bytes. Stream is for you; summary is for them. + +## Verification checklist + +- [ ] Did you estimate size before reading (if size was unknown)? +- [ ] Did you use a chunk policy (head / tail / grep) rather than a single `read`? +- [ ] Did you write a cumulative summary as you went, not after? +- [ ] Did you stop after at most 3 reads, even if you did not have the answer? +- [ ] Is the summary one-line purpose + findings + next step, not raw output? +- [ ] Did you surface to the user (with the question) if you could not stop on your own? +- [ ] Did you avoid re-reading the same range? diff --git a/plugins/antianqi/codex-harness-patterns/skills/subagent-family-tracking/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/subagent-family-tracking/SKILL.md new file mode 100644 index 0000000..377fb1c --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/subagent-family-tracking/SKILL.md @@ -0,0 +1,157 @@ +--- +name: subagent-family-tracking +description: | + Track parent/child thread tree of spawned sub-agents with Open/Closed status. + USE WHEN: spawned one or more sub-agents, task description suggests a tree (sub-tasks, "for each of A/B/C", "5 stages"), user asks "what's your sub-agent doing right now" / "子 agent 都在干嘛", sub-agent may fan out, want to know "还在跑吗" / "还有几个没关", before declaring fan-out done (all children closed check). + TRIGGER PHRASES: "子 agent 都在干嘛", "sub-agent", "子任务", "family tree", "who is running", "还在跑吗", "还有几个没关", "what's your sub-agent doing", "subagent family", "subagent tree", "all children closed". + SKIP WHEN: sub-task is so cheap you'd just inline it, harness already exposes live sub-agent dashboard, you are the child not the parent. +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.1" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/agent-graph-store/ +--- + +# Sub-Agent Family Tracking + +When you spawn sub-agents, you create a **family tree**: you are the parent, your spawned +agents are children, and your children's spawned agents are grandchildren. If you do not +track this tree explicitly, three failure modes are common: + +1. **Lost child** — you spawn an agent, lose track of its `task_id`, and never read its + result. +2. **Sibling duplication** — two children of yours do the same work, wasting tokens. +3. **Lingering child** — you move on to the next sub-task, leaving an old child running + in the background, burning context budget. + +This Skill codifies a **family-tracking file** so these failures are visible, not hidden. + +## When to use + +Activate when **any** of these is true: + +- You are about to call `task` and the result of that call is "fire and wait," not "do it + inline." (Inline calls don't need a tree.) +- You have already spawned one or more sub-agents in this session. +- The task description suggests a tree: "X has sub-tasks", "for each of A/B/C, ...", + "the migration has 5 stages, each stage can be parallelised." +- A user asks "what's your sub-agent doing right now?" and you don't have an immediate + answer. + +## When NOT to use + +- The sub-task is so cheap you would just inline it. (No sub-agent → no tree.) +- The harness already exposes a live dashboard of running sub-agents. (Use that instead.) +- You are the *child*, not the parent. Children don't track siblings; they only see their + own parent. + +## Process + +1. **Pick a single, predictable path.** Default: + `.minimax/agents//subagents.md`. Different from the goal file (which is "what we + are doing") and the world-state file (which is "where we are"); this is "who is + working for us." +2. **Initialise the file on the first spawn** in this exact shape: + + ```markdown + # Sub-agent family — + + **Parent (you)**: + **Last updated**: + + ## Children + + | id | spawned_at | brief | status | result_summary | + |----|------------|-------|--------|----------------| + | | | | open | (pending) | + ``` + +3. **On every spawn**, add a row with `status: open` and a one-line brief. +4. **On every sub-agent completion**, update the row: + - `status: closed` (or `closed-failed` if it didn't meet its pass condition) + - `result_summary: ` +5. **If a sub-agent spawns its own children**, append a sub-section for that child: + + ```markdown + | | | | open | (pending) | + ## Children of + | grandchild-id | spawned_at | brief | status | result_summary | + |---------------|------------|-------|--------|----------------| + ``` + +6. **At every `context-pressure-compact`**, the compact summary must reference the family + file by path, not duplicate it. The file is the ground truth. +7. **At the end of a fan-out aggregation** (see `parallel-fanout`), include a one-line + "all children closed" check. If any are still `open`, surface that to the user — the + aggregation is not safe to declare done while children are running. + +## Output contract + +The user sees: + +- The family file's contents (full) at the start of any multi-agent task. +- A one-line status update per spawn / completion (e.g. "spawned task-7a3f for OIDC review," + "task-7a3f closed with PASS"). +- A "all children closed" check at the end of any fan-out. + +## Example + +```markdown +# Sub-agent family — Auth refactor (OIDC alongside SAML) + +**Parent (you)**: thread-7c2b +**Last updated**: 2026-08-23T23:55:00Z + +## Children + +| id | spawned_at | brief | status | result_summary | +|----|------------|-------|--------|----------------| +| task-3f1a | 23:40:00Z | Audit /repo/server/Cargo.toml for CVEs | closed | No critical CVEs; 2 moderate, listed in SECURITY-REPORT.md | +| task-4d2b | 23:40:00Z | Audit /repo/web/package.json for CVEs | closed | 1 critical CVE (CVE-2024-xxxx); surfaced immediately, did not wait for others | +| task-5e3c | 23:40:00Z | List licenses of /repo/server and /repo/web direct deps | open | (pending) | +``` + +After task-5e3c finishes: + +```markdown +| task-5e3c | 23:40:00Z | List licenses of /repo/server and /repo/web direct deps | closed | 47 deps; 1 AGPL, 4 Apache-2.0, rest MIT — full table in SECURITY-REPORT.md | +``` + +If task-5e3c spawned a grandchild (e.g. for cross-checking an ambiguous license): + +```markdown +| task-5e3c | 23:40:00Z | List licenses of /repo/server and /repo/web direct deps | closed | 47 deps; 1 AGPL, 4 Apache-2.0, rest MIT | + ## Children of task-5e3c + | task-5e3c-1 | 23:42:00Z | Cross-check `foo-1.0.0` license classification | closed | Confirmed AGPL-3.0 via SPDX registry | +``` + +## Common pitfalls + +- **Do not skip the file "because it's only one sub-agent."** You will spawn another + before you know it, and then you will not know which results came from which. +- **Do not put full sub-agent output in the table.** The table is the index. The full + output lives in the sub-agent's own response (or a file it wrote). The table points to it. +- **Do not let the file grow unbounded.** A 500-line family file is no longer a tracking + file. If it grows past ~80 lines, summarise closed children into a "Completed + (summary)" section. +- **Do not confuse "open" with "running".** A sub-agent can be `open` and waiting on user + input, or `open` and silently failed. "Closed" means the result was received and + processed, not that the work succeeded. +- **Do not declare fan-out done while children are still open.** A `parallel-fanout` aggregation + must wait for *all* children to close. The check is mechanical, not visual. +- **Do not let a child spawn grand-children without recording it.** If you do not capture + the grandchild relationship, you cannot tell which child is responsible for which + grandchild's result. + +## Verification checklist + +- [ ] Is the family file at a single, predictable path? +- [ ] Is it initialised with the full table shape on the first spawn? +- [ ] Does every spawn add a row with `status: open`? +- [ ] Does every completion update the row to `status: closed`? +- [ ] Are grand-children recorded in a sub-section under their parent? +- [ ] Is the file < ~80 lines? (Summarise old entries if not.) +- [ ] At every `context-pressure-compact`, is the file referenced by path (not + duplicated)? +- [ ] At the end of any fan-out, is the "all children closed" check explicit? diff --git a/plugins/antianqi/codex-harness-patterns/skills/tool-discovery-pattern/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/tool-discovery-pattern/SKILL.md new file mode 100644 index 0000000..bebf247 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/tool-discovery-pattern/SKILL.md @@ -0,0 +1,245 @@ +--- +name: tool-discovery-pattern +description: | + Design a tool that an LLM agent can reliably discover, search, and invoke — with proper schema, defer_loading, two-dimensional type classification, OpenAI protocol compatibility, and a tool-suggestion approval flow. + USE WHEN: writing a new tool for an agent, designing the JSON schema for a tool, deciding between Function / Freeform / Namespace, fixing MCP tools that don't work with OpenAI models, building a tool-search index, designing a "request plugin install" flow, or any task involving "make my tool actually get picked up by the agent". + TRIGGER PHRASES: "tool discovery", "tool search", "tool spec", "DiscoverableTool", "defer_loading", "tool_suggestion", "request_plugin_install", "MCP tool", "Dynamic tool", "JSON schema for tool", "responses API tool", "ResponsesApiFunctionTool", "ResponsesApiCustomTool", "ResponsesApiNamespace". + SKIP WHEN: writing a Skill (use `skill-auto-select`), building a plugin manifest (use `plugin-author-helper`), single-use CLI script (not a tool). +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.0" + inspired-by: https://github.com/openai/codex/tree/main/codex-rs/tools/ (P-107-114) + changes-from-v0.0.0: "Initial design distilled from P-107-114 deep-dive (Phase 2 Week 6)." +--- + +# Tool Discovery Pattern + +Design a tool that an LLM agent can discover, search, decide to use, and invoke. +Mirrors the design of `codex-rs/tools/`. + +## When to use + +Activate when designing: + +- A new tool's JSON schema. +- The choice between Function (structured) / Freeform (custom) / Namespace (container) tool types. +- A search index over a large tool catalog. +- A "request plugin install" suggestion flow. +- Schema compatibility with OpenAI models. + +## When NOT to use + +- Skill authoring → use `skill-auto-select`. +- Plugin manifest authoring → use `plugin-author-helper`. +- Single-use scripts → not a tool. + +## Process + +### 1. Two-dimensional type classification + +```rust +pub enum DiscoverableToolType { Connector, Plugin } +pub enum DiscoverableToolAction { Install, Enable } + +pub enum DiscoverableTool { + Connector(Box), + Plugin(Box), +} +``` + +**Any discoverable item is the cartesian product of (Type) × (Action)**. Adopt this +orthogonal taxonomy so a single `request_plugin_install(Connector, Install, ...)` +and `request_plugin_install(Plugin, Enable, ...)` work the same way. + +### 2. Pick the right tool shape + +For OpenAI Responses API, three shapes: + +| Shape | When to use | +|---|---| +| `ResponsesApiFunctionTool` | Structured input schema, typed args. | +| `ResponsesApiCustomTool` | Freeform input, agent decides. | +| `ResponsesApiNamespace` | Container of multiple tools (e.g. all functions). | + +Use Namespace to group related tools so the model sees one entry, not ten. + +### 3. Write the 7-type JSON schema + +OpenAI Structured Outputs supports exactly these `type` values: + +```text +string | number | boolean | integer | object | array | null +``` + +Plus these composition keywords: + +```text +anyOf | oneOf | allOf +$ref | enum | const | properties | required | description +``` + +Support both single-type (`"string"`) and multi-type (`["string", "null"]`) via: + +```rust +pub enum JsonSchemaType { + Single(JsonSchemaPrimitiveType), + Multiple(Vec), +} +``` + +**Do not** support the full JSON Schema spec. Stick to the OpenAI subset. + +### 4. Use BTreeMap for stable output + +For any user-visible schema, use `BTreeMap` not `HashMap`. Stable iteration order = stable JSON output = no spurious git diffs. + +### 5. Adopt the defer_loading pattern + +If you have many tools, expose them through a search index with `defer_loading: true`: + +```text +[searchable] tools are exposed as Namespace entries containing: + - name (short) + - description (1-line) + - defer_loading: true ← schema is loaded only when the agent decides to use it +``` + +The agent sees a lightweight description, and the full `input_schema` is fetched only +on actual invocation. This prevents schema bloat from filling the context. + +### 6. Apply the OpenAI compatibility fix + +OpenAI models REQUIRE the `properties` field on any object schema. Many MCP servers +omit it. Patch it on load: + +```rust +if obj.get("properties").is_none_or(Value::is_null) { + obj.insert("properties".into(), Value::Object(Map::new())); +} +``` + +This matches the OpenAI Agents SDK behavior. Always apply on the host side, never +ask the upstream server to fix it. + +### 7. Truncate descriptions at char boundaries + +For agent plugins, cap descriptions at 1 KB: + +```rust +const MAX_MCP_TOOL_DESCRIPTION_BYTES: usize = 1_000; +take_bytes_at_char_boundary(description, limit) +``` + +Use **byte** boundary, not char. Char truncation can split a multi-byte UTF-8 +codepoint and produce invalid strings. + +### 8. Provide a tool-search tool + +Expose a top-level `tool_search` tool: + +```rust +pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; +pub const TOOL_SEARCH_DEFAULT_LIMIT: usize = 8; +``` + +The tool takes a query string and returns a list of `LoadableToolSpec` entries with +`defer_loading: true`. Each returned entry is wrapped in a `Namespace` with +`DEFAULT_FUNCTION_NAMESPACE`. + +### 9. Provide a `request_plugin_install` tool + +When the agent encounters a tool it doesn't have, it should be able to suggest installing it: + +```rust +pub struct RequestPluginInstallArgs { + pub tool_type: DiscoverableToolType, // Connector | Plugin + pub action_type: DiscoverableToolAction, // Install | Enable + pub tool_id: String, + pub suggest_reason: String, // mandatory: WHY does the agent need this? +} + +pub struct RequestPluginInstallResult { + pub completed: bool, + pub user_confirmed: bool, + pub tool_name: String, + // ... +} +``` + +The approval is tagged with `codex_approval_kind = "tool_suggestion"` so the UI +can present it as a suggestion, not a regular command approval. The +`persist: "always"` flag means once the user accepts, it's always allowed. + +The `suggest_reason` field is **mandatory** — the agent must explain why it needs +this tool, not silently suggest. This prevents runaway tool installations. + +### 10. Mark namespace descriptions + +If a Namespace has an empty description, fill it in with a default: + +```rust +if namespace.description.trim().is_empty() { + namespace.description = default_namespace_description(&namespace.name); +} +``` + +Don't ship a tool with an empty description. + +## Output contract + +A tool that follows this design: + +- Has a 7-type JSON schema (no exotic types). +- Has a BTreeMap-ordered schema. +- Uses Namespace to group related tools. +- Has `defer_loading: true` when surfaced through search. +- Has `properties` always present in object schemas. +- Description is ≤ 1KB for agent plugin tools, truncated at byte boundaries. +- Has a top-level `tool_search` tool for search. +- Has a `request_plugin_install` tool with mandatory `suggest_reason`. + +## Common pitfalls + +- **Empty `description`** → LLM can't decide if this tool fits. Always fill in (use `default_namespace_description` if needed). +- **Schema with non-OpenAI types** (`"date"`, `"uri"`, `"regex"`, ...) → rejected by model. Stay in the 7-type subset. +- **Object schema missing `properties`** → OpenAI rejects. Always patch. +- **`HashMap` schema** → unstable JSON output. Use `BTreeMap`. +- **`defer_loading: false` for hundreds of tools** → context explodes. Search index + defer is the answer. +- **`request_plugin_install` without `suggest_reason`** → runaway installation. Require the field. +- **Approval tagged as plain command** → wrong UI. Use `codex_approval_kind = "tool_suggestion"`. +- **Char-boundary truncation** → splits UTF-8. Use `take_bytes_at_char_boundary`. + +## Example — minimal tool manifest + +```json +{ + "name": "list_pipelines", + "description": "List all data pipelines in the warehouse, optionally filtered by status.", + "input_schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["running", "paused", "failed", "all"], + "default": "all" + } + }, + "required": [] + }, + "defer_loading": true +} +``` + +## Verification checklist + +- [ ] All object schemas have a `properties` field. +- [ ] All type fields are in the 7-type subset. +- [ ] All enums are arrays of strings. +- [ ] Schemas use `BTreeMap` not `HashMap`. +- [ ] Tools exposed through search are in Namespaces with `defer_loading: true`. +- [ ] `request_plugin_install` requires `suggest_reason` and tags `tool_suggestion` approval. +- [ ] Agent plugin tool descriptions are ≤ 1KB, truncated at byte boundaries. +- [ ] Empty `description` is auto-filled with `default_namespace_description`. +- [ ] `tool_search` tool is exposed at the top level. diff --git a/plugins/antianqi/codex-harness-patterns/skills/tool-output-budget/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/tool-output-budget/SKILL.md new file mode 100644 index 0000000..54af6d1 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/tool-output-budget/SKILL.md @@ -0,0 +1,111 @@ +--- +name: tool-output-budget +description: | + Truncate oversized tool output so it does not blow the agent's context window. + USE WHEN: tool output > 3000 tokens, line > 500 chars, large log, JSON array, minified code, fetched HTML, verbose npm/cargo/test output, `cat` of a big file, "truncated" / "output cut off" / "use offset to read more" message. + TRIGGER PHRASES: "输出太长", "context 满了", "log 太大", "截断", "truncate", "output cut off", "读不完", "太大了", "context 撑爆", "too long". + SKIP WHEN: output is small (<100 lines), output is the user-facing final answer, output is structured and needs full parse (read once with a guard). +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.1" + inspired-by: https://github.com/openai/codex/tree/main/codex-rs/utils/output-truncation +--- + +# Tool Output Budget + +Keep oversized tool output out of the main context. Replace it with a token-aware summary plus the +parts most likely to matter. + +## When to use + +Use this Skill **immediately after** any of these tool calls, before quoting the output in your +next response: + +- `bash` returns output that looks like a large log, JSON array, HTML page, or `cat` of a long + file (e.g. `cat huge.log`, `npm test 2>&1`, `kubectl get ... -o yaml`). +- Any tool returns a single line longer than ~500 characters (typical of minified JS, base64 + blobs, very wide CSV). +- The tool succeeded but the user did not ask for the full payload. + +## When NOT to use + +- The user explicitly asked for "the whole thing" or "every line". +- The output is small (< ~3000 tokens). Trust the tool as-is. +- You genuinely need the exact bytes (e.g. computing a hash, doing a byte-equal diff). Quote the + output verbatim and explain why truncation is unsafe. + +## Process + +1. **Estimate the size.** If the tool already returned a byte count, use that. Otherwise, count + newlines and pick the longest line. A line of ~80 characters is roughly 20 tokens. +2. **Decide to truncate if** any of the following hold: + - Total estimated tokens > 3000 (default threshold). + - Any single line > 500 characters. + - Output structure is a long JSON array, log dump, or fetched HTML. +3. **Pick what to keep.** Default: **head 200 lines + tail 50 lines + 1 marker line**. Adapt: + - For `cat` of a known file, prefer **tail** (errors and stack traces usually live at the end). + - For a JSON array, prefer **head** (the schema is at the top; the agent only needs a few + sample items). + - For a fetched HTML page, prefer the **head** (metadata, title) plus a 1-line tag count + summary; do not keep the body. +4. **Format the replacement** as a single fenced block: + + ```text + [truncated by tool-output-budget] + kept: lines 1-200 of 1234 (head) + lines 1185-1234 of 1234 (tail) + skipped: 834 middle lines + estimated total tokens: ~5500 + file path on disk: # only if the tool output came from a real file + ``` + +5. **Hand back the original file path when you have it.** The user may want you to re-read a + specific range later, and you can use `read` with explicit `offset` / `limit` for that. +6. **Do not silently retry the same tool.** Truncation is a one-shot decision per tool result. + +## Output contract + +Every time you apply this Skill, the user's next message should see exactly this shape: + +- A short preamble explaining what was truncated and why (one sentence). +- A fenced code block with the kept head + tail + marker line. +- (Optional, only if a real file exists) The path to re-read for the full content. + +## Example + +```text +> cat /var/log/app.log + +[truncated by tool-output-budget] +kept: lines 1-200 of 4821 (head) + lines 4771-4821 of 4821 (tail) +skipped: 4621 middle lines +estimated total tokens: ~6800 +file path on disk: /var/log/app.log + +# last 5 of tail (the part the user usually wants) +2026-08-23T22:47:01Z ERROR connection refused 10.0.4.7:5432 +2026-08-23T22:47:02Z ERROR retrying (1/3) +2026-08-23T22:47:05Z ERROR connection refused 10.0.4.7:5432 +2026-08-23T22:47:08Z WARN giving up; will retry on next request +2026-08-23T22:47:08Z INFO request 7d4e failed: upstream unavailable +``` + +## Common pitfalls + +- **Don't truncate error output that contains the answer.** If the user asked "why did the test + fail", the failure line is in the tail of the test runner output. Keep tail, drop head. +- **Don't truncate `cat` of source code you need to edit.** Use `read` with `offset`/`limit` for + surgical access; keep the full code in the next step's tool call. +- **Don't estimate size from newlines alone.** A 1-line 50 KB minified file is one "line" but + ~12,000 tokens. Check the longest line first. +- **Don't loop.** If you truncated, the next tool call should *act* on the result, not re-run + the same command with the same expectation. + +## Verification checklist + +- [ ] Did you estimate tokens or bytes before deciding? +- [ ] Is the marker line present and clear about how much was skipped? +- [ ] Did you keep the part most likely to matter (head for structure, tail for errors)? +- [ ] If a real file path exists, did you hand it back to the user? +- [ ] Did the user's next step actually use the truncated result? diff --git a/plugins/antianqi/codex-harness-patterns/skills/world-state-tracking/SKILL.md b/plugins/antianqi/codex-harness-patterns/skills/world-state-tracking/SKILL.md new file mode 100644 index 0000000..6ce8f47 --- /dev/null +++ b/plugins/antianqi/codex-harness-patterns/skills/world-state-tracking/SKILL.md @@ -0,0 +1,193 @@ +--- +name: world-state-tracking +description: | + Track running state of long task in a single dedicated file that survives compaction. + USE WHEN: task is long, agent has lost thread, user asks "where are we" / "到哪了" / "我们到哪了", before `context-pressure-compact`, `todowrite` alone is too thin, agent has done > 10 tool calls, "lost the thread" / "继续" / "忘了". + TRIGGER PHRASES: "where are we", "到哪了", "我们到哪了", "继续", "lost thread", "忘了", "lost the thread", "我们刚才说到哪了", "走神了", "回到主线". + SKIP WHEN: short task (<5 tool calls), single one-shot question, "do X" with X being small. +license: Apache-2.0 +compatibility: Requires MiniMax Code with Agent Plugins 1.0 support. +metadata: + author: antianqi + version: "0.1.1" + inspired-by: https://github.com/openai/codex/blob/main/codex-rs/core/src/context/world_state.rs +--- + +# World State Tracking + +Keep a single dedicated state file that records the *shape* of the running task — the goal, +the decisions, the blockers, the next step, the key paths. Unlike the conversation history, +this file is **structured, finite, and survives compaction**. It is the agent's answer to +"where are we?" when the context window is full. + +## When to use + +Activate when **any** of these is true: + +- The task is long enough that the agent has lost the thread at least once already. +- The user asks "where are we?", "what's the status?", "are we still on track?", or "remind + me what we decided". +- You are about to apply `context-pressure-compact` (the state file is what survives it). +- The task has 3+ open decisions whose rationale you don't want to re-derive every turn. +- Multiple sub-agents (or the user and the agent) need a shared ground truth. + +## When NOT to use + +- A short task (< 5 turns, no major decisions yet). The state file is overhead. +- The whole task fits in a `todowrite`. Use that instead — it is already structured state. +- The state would duplicate information that lives in source files (e.g. the migration + plan already lives in `docs/migrations/auth.md`; do not restate it here). + +## Process + +1. **Pick a single, predictable path.** Default: + `.minimax/state/-.md` (or, for repos without a working dir, a + tmp file under `/tmp/`). The path is part of the contract — re-read it from the same + place every turn. +2. **Initialise the file the first time you activate this Skill.** Use this exact shape: + + ```markdown + # World State — + + **Started**: + **Owner**: + **Last updated**: + + ## Goal + + + + ## Current phase + + + + ## Decisions (with one-line rationale) + + - + - + + ## Done + + - [x] + - [x] <...> + + ## In progress + + - [ ] + + ## Blockers / open questions + + - + + ## Next concrete step + + + + ## Key file paths + + - /abs/path/that/the/next/turn/will/need + ``` + +3. **Update the file at every meaningful boundary**, not every turn. Boundaries are: + - End of a `todowrite` step. + - End of a sub-task handed to a `task` call. + - Right before a `context-pressure-compact`. + - Immediately after a user redirection. + At each update: bump `Last updated`, move items between Done / In progress, add new + Decisions, and refresh the Next concrete step. +4. **On every new turn, read the file first** (use `read`). It is your 30-line ground truth. + Do not skim the conversation history to "get back up to speed" — read the state file. +5. **At `context-pressure-compact` time**, the state file is what survives — the noisy + middle does not. The compact summary should reference the state file by path, not + duplicate its contents. + +## Output contract + +The user sees: + +- The path to the state file (one line, at the top of any meaningful response). +- On request: a short, *complete* snapshot of the state (the file contents, optionally + abbreviated). +- On update: a one-line "State updated: ". + +## Example + +```markdown +# World State — Auth refactor (OIDC alongside SAML) + +**Started**: 2026-08-23 +**Owner**: main +**Last updated**: 2026-08-23T23:55:00Z + +## Goal + +Refactor the auth subsystem to support OIDC as a first-class provider alongside the existing +SAML path, without breaking any of the 12 existing SAML tests. + +## Current phase + +planning + +## Decisions (with one-line rationale) + +- Keep SAML on the legacy code path; OIDC gets a parallel module. — SAML contract is frozen, + no test budget to re-validate. +- Reject "generic Provider with config-driven dispatch" — too much config surface for + marginal benefit. +- Use the `openidconnect` crate (not hand-rolled oauth2). — JWKS, PKCE, state, nonce all + solved; saves ~400 lines. + +## Done + +- [x] Mapped current auth flow in `src/auth/`. Wrote findings to `.minimax/snapshots/auth-flow.md`. +- [x] Confirmed test coverage: 12 of 14 files have unit tests (2 missing: `logout`, `session`). + +## In progress + +- [ ] Drafting the `OidcProvider` trait. Stopped at: how to represent the provider enum + vs the existing `IdP` interface. Three options on the table; see Open questions. + +## Blockers / open questions + +- Should the OIDC module own token storage, or reuse the existing session store? +- Does IT have a preferred OIDC library? (defaulting to `openidconnect`) + +## Next concrete step + +Draft the `OidcProvider` trait + one impl for `provider = "okta"`, then show the diff to +the user before touching `src/auth/callback.rs`. + +## Key file paths + +- /repo/src/auth/idp.rs +- /repo/src/auth/callback.rs +- /repo/tests/auth/ +- /repo/docs/auth.md +``` + +## Common pitfalls + +- **Do not put prose in the state file.** Prose is what the conversation history is for. The + state file is structured, finite, and machine-grepable. +- **Do not update on every turn.** Update at boundaries. A state file that changes every + line is just a noisy transcript. +- **Do not duplicate source-of-truth info.** If the API contract lives in `docs/api.md`, + the state file says "see docs/api.md", not "the API contract is ...". +- **Do not let the state file grow unbounded.** A 500-line state file is no longer a state + file; it is a journal. If it grows past ~80 lines, split it (e.g. `state.md` + + `decisions.md`) or compact. +- **Do not forget the path.** If you can name the path from memory every turn, the state + file is doing its job. If you cannot, move it to a more obvious place. +- **Do not use the state file as a substitute for `todowrite`.** The state file is the + long-form ground truth; `todowrite` is the short-form live checklist. They coexist. + +## Verification checklist + +- [ ] Is the state file at a single, predictable path the agent can name from memory? +- [ ] Is it initialised with the full 9-section shape on first activation? +- [ ] Is it updated at boundaries (steps, sub-tasks, compactions, redirections), not every + turn? +- [ ] Does every new turn start with `read` of the state file, not a conversation skim? +- [ ] Is the state file < ~80 lines? If not, split or compact. +- [ ] Does `context-pressure-compact` reference the state file by path, not duplicate it? +- [ ] Can a new sub-agent orient itself in < 30 seconds by reading only the state file? diff --git a/plugins/antianqi/mcode-island/.gitattributes b/plugins/antianqi/mcode-island/.gitattributes new file mode 100644 index 0000000..8140744 --- /dev/null +++ b/plugins/antianqi/mcode-island/.gitattributes @@ -0,0 +1,24 @@ +# Force LF for all source files in this plugin. PowerShell 5.1 reads +# CRLF fine, but a cross-platform smoke (e.g. Linux CI) sees LF and +# the pre-existing CRLF-handling bug in scripts/validate.mjs trips +# on Windows-checked-out CRLF. LF avoids both failure modes. +# +# Override at clone time: `git config core.autocrlf input` for a +# one-shot pull, or set `[core] autocrlf = false` globally. + +* text=auto eol=lf + +*.ps1 text eol=lf +*.cmd text eol=lf +*.mjs text eol=lf +*.js text eol=lf +*.json text eol=lf +*.md text eol=lf +*.txt text eol=lf +LICENSE text eol=lf +README.md text eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary diff --git a/plugins/antianqi/mcode-island/README.md b/plugins/antianqi/mcode-island/README.md index 3b9705c..0f39645 100644 --- a/plugins/antianqi/mcode-island/README.md +++ b/plugins/antianqi/mcode-island/README.md @@ -23,6 +23,73 @@ There is no visible progress signal. The agent may also be paused on a permission prompt or have failed silently. `mcode-island` makes all of that visible at a glance, without forcing the user to switch back. +## How the pill is driven + +`mcode-island` v0.3.0 supports two modes. The widget behaves the same in +both — what changes is who decides the state. + +### Mode A — Hook-driven (mcode 0.2.4+ with `io.minimax.mcode`) + +mcode 0.2.4 ships a `io.minimax.mcode` client-extension namespace for +lifecycle Hooks. When the registry accepts it (companion proposal: +[`MiniMax-Code-Plugins` PR #20](https://github.com/MiniMax-AI/MiniMax-Code-Plugins/pull/20)), +the runtime spawns a script from this plugin for every matching event: + +| event | pill state | script | 0.2.4 dispatch | +| ----------------- | ----------- | ------------------------------- | -------------- | +| `SessionStart` | `idle` | `session-start.ps1` | yes | +| `SessionEnd` | `idle` | `session-end.ps1` | yes | +| `UserPromptSubmit`| `thinking` | `user-prompt-submit.ps1` | yes | +| `PreToolUse` | `working` | `pre-tool-use.ps1` | yes | +| `PostToolUse` | `done`/`error` | `post-tool-use.ps1` | yes | +| `Stop` | `done` | `stop.ps1` | **forward** — see below | +| `PreCompact` | `thinking` | `pre-compact.ps1` | **forward** — see below | +| `Notification` | `idle` | `notification.ps1` | **forward** — see below | +| `SubagentStart` | `working` (CODEX only) | `subagent-start.ps1` | **forward** — see below | +| `SubagentStop` | `done` (CODEX only) | `subagent-stop.ps1` | **forward** — see below | +| `PermissionRequest`| `waiting` | `permission-request.ps1` | **forward** — see below | +| `PermissionDenied`| `error` | `permission-denied.ps1` | **forward** — see below | + +**Forward events (7 of 12):** the spec reserves these in +`proposals/hooks-detailed-spec.md` and this plugin ships a script for +each, but the mcode 0.2.4 runtime allowlist (`Wso` set in +`@minimax-ai/code@0.2.4`) does not yet dispatch them. The 0.2.4 +runtime treats unknown event names as no-op. Once a future mcode +release adds the dispatch, the same `.ps1` files start firing without +any code change here. The smoke test +(`scripts/smoke.mjs`) tags these as `WARN` rather than `FAIL` for that +reason — the **plugin is correct, the runtime is not yet ready**. + +If you need any of these events on 0.2.4 today, the supported fallback +is to call `notify-island.ps1` from the agent (Mode B) at the moment +you would otherwise rely on the event firing. The wrapper +`wrap-tool.ps1` covers the `Bash` path automatically. + +The agent does not need to remember to push state — the runtime fires the +right script at the right time. `PermissionRequest` is the only +decision-bearing event here; the script returns `{"decision":"ask"}` so the +plugin remains a pure observer (it does not auto-allow or auto-deny). +The runtime's fail-closed default is bypassed only because the script +opts the Hook into the "ask the user" path, so the TUI prompt still +appears and the user can approve or deny. The widget just shows +`waiting` so the user knows to act. + +> **Drift lock**: `scripts/smoke.mjs` reads `permission-request.ps1` +> directly and asserts the `decision` field is exactly `ask`. A +> future change that flips the value back to `allow` or `deny` will +> fail the smoke before the PR can be submitted. + +Until the registry validator accepts the namespace, the `io.minimax.mcode/` +directory is dormant and the plugin falls through to Mode B. + +### Mode B — Agent-pushed (legacy, always works) + +The agent (or a thin wrapper) calls `notify-island.ps1` with `-State` and +optional `-Message`. A separate `mcode-status-detect.ps1` polls the runtime's +`ledger.jsonl` / `messages.jsonl` and infers state as a fallback so the +pill still moves even when the agent forgets to push. See +[`SKILL.md`](skills/mcode-island/SKILL.md) for the agent-side call patterns. + ## Copyable example ### One-line install and run @@ -118,21 +185,39 @@ alternative: ``` mcode-island/ -├── plugin.json # plugin manifest (official 1.0 schema) -├── README.md # this file -├── LICENSE # Apache-2.0 -├── mcode-island.ps1 # WPF widget main loop -├── mcode-island.cmd # CLI shim: start/stop/status/show/pin/... -├── start-island.ps1 # launcher (forces STA + hidden console) -├── stop-island.ps1 # stop the widget -├── status-island.ps1 # print widget PID + recent log -├── show-island.ps1 # re-raise hidden widget -├── pin-island.ps1 # lock click-to-focus target -├── autostart.ps1 # register / unregister Windows logon -├── notify-island.ps1 # state-push helper (agents call this) -├── wrap-tool.ps1 # all-in-one bash wrapper -├── skills/mcode-island/SKILL.md # Skill consumed by the agent -└── assets/ # screenshots embedded above +├── plugin.json # plugin manifest (official 1.0 schema) +├── README.md # this file +├── LICENSE # Apache-2.0 +├── mcode-island.ps1 # WPF widget main loop +├── mcode-island.cmd # CLI shim: start/stop/status/show/pin/... +├── start-island.ps1 # launcher (forces STA + hidden console) +├── stop-island.ps1 # stop the widget +├── status-island.ps1 # print widget PID + recent log +├── show-island.ps1 # re-raise hidden widget +├── pin-island.ps1 # lock click-to-focus target +├── autostart.ps1 # register / unregister Windows logon +├── notify-island.ps1 # state-push helper (Mode B) +├── wrap-tool.ps1 # all-in-one bash wrapper +├── mcode-status-detect.ps1 # runtime-state detector (Mode B fallback) +├── io.minimax.mcode/ # Mode A: client-extension Hooks +│ └── hooks/ +│ ├── hooks.json # 12-event declaration +│ └── scripts/ # one .ps1 per event +│ ├── _lib.ps1 +│ ├── session-start.ps1 +│ ├── session-end.ps1 +│ ├── user-prompt-submit.ps1 +│ ├── pre-tool-use.ps1 +│ ├── post-tool-use.ps1 +│ ├── stop.ps1 +│ ├── pre-compact.ps1 +│ ├── notification.ps1 +│ ├── subagent-start.ps1 +│ ├── subagent-stop.ps1 +│ ├── permission-request.ps1 +│ └── permission-denied.ps1 +├── skills/mcode-island/SKILL.md # Skill consumed by the agent +└── assets/ # screenshots embedded above ``` The whole package is a single portable directory. No installer, no native @@ -145,10 +230,11 @@ binary, no symlink, no `node_modules`. | Windows | 10 1809+ or 11 (uses WPF, `user32` `kernel32`) | | PowerShell | 5.1 (ships with Windows 10/11) or PowerShell 7 | | .NET WPF runtime | 4.x (ships with Windows 10/11) | +| mcode | any version (Mode B works everywhere); 0.2.4+ activates Mode A | | execution policy | `Bypass` for this directory; not changed globally | -| network access | **none** — widget does not make any network request | -| accounts | **none** | -| paid services | **none** | +| network access | **optional** — see "Network access" below. The widget itself is offline. `mcode-status-detect.ps1` only contacts `https://api.minimax.io/v1/coding_plan/remains` when a token is configured (see "Accounts" + "Data use"). | +| accounts | **optional** — see "Accounts" below. No account is required to run the widget; a token is only needed if you want the optional 5-hour usage readout in the pill. | +| paid services | **none added by this plugin** — the 5h usage endpoint is part of the user's existing MiniMax account, not a separate service | ## Data use @@ -157,13 +243,59 @@ binary, no symlink, no `node_modules`. | `status.json` | `%APPDATA%\mcode-island\` | rewritten every transition | widget polling | | `caller.json` | `%APPDATA%\mcode-island\` | rewritten every transition | click-to-focus target HWND | | `config.json` | `%APPDATA%\mcode-island\` | rewritten on drag | pill position, size, opacity | +| `config.json` -> `planApiToken` | `%APPDATA%\mcode-island\` | until `-Clear` or manual edit | 5h usage API token (opt-in; see "Accounts") | | `widget.pid` | `%APPDATA%\mcode-island\` | rewritten on start | widget process PID | | `island.log` | `%APPDATA%\mcode-island\` | append-only, never pruned | state transition history | | `widget.log` | `%APPDATA%\mcode-island\` | append-only, never pruned | widget internal debug | | `show.signal` | `%APPDATA%\mcode-island\` | transient | "raise hidden window" signal | | `HKCU\...\Run` | Windows registry | until disabled | logon auto-start | -**No data leaves the local machine. No telemetry. No network requests.** +**Telemetry: none.** **No data is sent off-machine unless the optional +`planApiToken` is configured (see "Network access" below).** The widget +itself is offline and never reads or writes anything outside `%APPDATA%\mcode-island\`. + +## Network access + +The widget is fully offline. The only network caller in this plugin is +`mcode-status-detect.ps1` (Mode B detector), and it only makes a request +when ALL of the following are true: + +1. A token is configured (env `MINIMAX_OAUTH_TOKEN` or `MINIMAX_API_KEY`, + or `set-token.ps1 ` which writes to `config.json:planApiToken`). +2. The detector is running (`mcode-island detect-on`, the default). +3. At least 60 seconds have elapsed since the last call (rate-limited). + +When all three are true, the detector makes **one** GET to: + +- `https://api.minimax.io/v1/coding_plan/remains` (HTTPS, no credentials in + the URL, no fragment, body is a small JSON object) + +The response is parsed and only two numbers are written to +`status.json`: `usage5h` (0..100, percent remaining) and `usage5hResetMs` +(milliseconds until the next refresh). Nothing else is persisted and +nothing is sent back to the plugin author. A failure or timeout is +swallowed silently — the pill still works without the readout. + +Without a token, the detector skips this call entirely and the pill's +`usage5h` field is `null`. + +## Accounts + +No account is required to install or use the widget. The token mechanism +exists so users who already have a MiniMax account can opt in to showing +the 5-hour usage readout in the pill. + +| token type | how it enters the plugin | where it is stored | how it is removed | +| ----------------- | -------------------------------------------------------- | -------------------------------------------------- | -------------------------------------------- | +| `MINIMAX_OAUTH_TOKEN` (env) | set by the user in their shell or mcode config | process env (not on disk) | unset env / close shell | +| `MINIMAX_API_KEY` (env) | same as above | process env | same as above | +| `config.json:planApiToken` | `set-token.ps1 ` | `%APPDATA%\mcode-island\config.json` (plaintext) | `set-token.ps1 -Clear` or edit the file | + +The token is **never logged, never written to any other file, and never +sent to a host other than `api.minimax.io`**. `set-token.ps1` only writes +to `config.json`; it makes no network call. The detector only reads the +token to attach as an `Authorization: Bearer ...` header on the single +GET documented above. ## CLI reference @@ -222,12 +354,18 @@ a live MiniMax Code session. Empirical evidence (captured during development): - Windows only. The widget uses WPF, `user32`, and `kernel32` P/Invoke. - One widget per user session. - `wrap-tool.ps1` only wraps `bash`. Other tools need direct - `notify-island.ps1` calls. + `notify-island.ps1` calls. (In Mode A, all tools fire `PreToolUse` / + `PostToolUse` automatically — no manual push needed.) - The widget does not show a progress percentage, token usage, or per-tool output. v0.2 will. - File-system polling at 400 ms is not the most efficient design (FileSystemWatcher was unstable inside WPF in our tests), but it is robust against any kind of writer and never misses an event. +- Mode A (Hook-driven) requires the registry validator to accept the + `io.minimax.mcode` client-extension namespace. The companion proposal + ([`MiniMax-Code-Plugins` PR #20](https://github.com/MiniMax-AI/MiniMax-Code-Plugins/pull/20)) + is still pending merge; until then, the `io.minimax.mcode/hooks/` directory + is dormant and the widget runs in Mode B (agent-pushed + detector). ## Roadmap diff --git a/plugins/antianqi/mcode-island/autostart.ps1 b/plugins/antianqi/mcode-island/autostart.ps1 index 51ed2dd..17b1a9a 100644 --- a/plugins/antianqi/mcode-island/autostart.ps1 +++ b/plugins/antianqi/mcode-island/autostart.ps1 @@ -1,8 +1,14 @@ # mcode-island - 开机自启管理 # 用法: -# autostart.ps1 -Enable # 注册到 HKCU\...\Run,开机自动起 -# autostart.ps1 -Disable # 取消 +# autostart.ps1 -Enable # 注册 widget + detector 两个 Run 项,开机自动起 +# autostart.ps1 -Disable # 取消全部 # autostart.ps1 -Status # 看当前状态 +# +# 设计:以前只注册 widget(start-island.ps1),detector 不会自启——结果用户开机后 +# widget 卡在最后一次推送的状态上,得手动跑 detect-on。修成两条独立的 Run key: +# HKCU\...\Run\mcode-island → start-island.ps1 +# HKCU\...\Run\mcode-island-detect → start-detect-island.ps1 +# 两条相互独立,可以单独禁用其中之一(比如有人只想用 widget 不想用 detector)。 param( [ValidateSet('Enable','Disable','Status')] @@ -13,33 +19,50 @@ $ErrorActionPreference = 'Stop' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $runKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -$entryName = 'mcode-island' -$launcher = Join-Path $PSScriptRoot 'start-island.ps1' -$command = "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$launcher`"" + +# 用 ordered hashtable 固定顺序:先 detector 再 widget(Windows 实际不保证顺序,但人读起来顺眼) +$entries = [ordered]@{ + 'mcode-island-detect' = (Join-Path $PSScriptRoot 'start-detect-island.ps1') + 'mcode-island' = (Join-Path $PSScriptRoot 'start-island.ps1') +} + +function Build-Command([string]$Launcher) { + return "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$Launcher`"" +} switch ($Action) { 'Enable' { New-Item -Path $runKey -Force | Out-Null - Set-ItemProperty -Path $runKey -Name $entryName -Value $command - Write-Output "ENABLED: 开机自启已注册" - Write-Output " Key: $runKey\$entryName" - Write-Output " Value: $command" + foreach ($name in $entries.Keys) { + $cmd = Build-Command $entries[$name] + Set-ItemProperty -Path $runKey -Name $name -Value $cmd + Write-Output "ENABLED: $runKey\$name" + Write-Output " Value: $cmd" + } } 'Disable' { - if (Get-ItemProperty -Path $runKey -Name $entryName -ErrorAction SilentlyContinue) { - Remove-ItemProperty -Path $runKey -Name $entryName - Write-Output 'DISABLED: 开机自启已取消' - } else { - Write-Output 'DISABLED: 本来就没注册' + $any = $false + foreach ($name in $entries.Keys) { + if (Get-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue) { + Remove-ItemProperty -Path $runKey -Name $name + Write-Output "DISABLED: $name" + $any = $true + } } + if (-not $any) { Write-Output 'DISABLED: 本来就没注册' } } 'Status' { - $existing = Get-ItemProperty -Path $runKey -Name $entryName -ErrorAction SilentlyContinue - if ($existing) { - Write-Output 'ENABLED' - Write-Output " Value: $($existing.$entryName)" - } else { - Write-Output 'DISABLED' + $any = $false + foreach ($name in $entries.Keys) { + $existing = Get-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue + if ($existing) { + Write-Output "ENABLED: $name" + Write-Output " Value: $($existing.$name)" + $any = $true + } else { + Write-Output "DISABLED: $name" + } } + if (-not $any) { Write-Output '' ; Write-Output '(no mcode-island entries registered)' } } } diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/hooks.json b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/hooks.json new file mode 100644 index 0000000..08ee02e --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/hooks.json @@ -0,0 +1,163 @@ +{ + "$schema": "https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json", + "hooks": { + "SessionStart": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/session-start.ps1" + ], + "timeout": 5000 + } + ], + "SessionEnd": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/session-end.ps1" + ], + "timeout": 5000 + } + ], + "UserPromptSubmit": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/user-prompt-submit.ps1" + ], + "timeout": 5000 + } + ], + "PreToolUse": [ + { + "matcher": "*", + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1" + ], + "timeout": 5000 + } + ], + "PostToolUse": [ + { + "matcher": "*", + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/post-tool-use.ps1" + ], + "timeout": 5000 + } + ], + "Stop": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/stop.ps1" + ], + "timeout": 5000 + } + ], + "PreCompact": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/pre-compact.ps1" + ], + "timeout": 5000 + } + ], + "Notification": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/notification.ps1" + ], + "timeout": 5000 + } + ], + "SubagentStart": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/subagent-start.ps1" + ], + "timeout": 5000 + } + ], + "SubagentStop": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/subagent-stop.ps1" + ], + "timeout": 5000 + } + ], + "PermissionRequest": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/permission-request.ps1" + ], + "timeout": 5000 + } + ], + "PermissionDenied": [ + { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/permission-denied.ps1" + ], + "timeout": 5000 + } + ] + } +} diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 new file mode 100644 index 0000000..797813b --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/_lib.ps1 @@ -0,0 +1,110 @@ +# mcode-island: shared library for io.minimax.mcode Hooks scripts. +# Loaded via dot-source at the top of each event script: +# . "$PSScriptRoot\_lib.ps1" +# All event scripts under this directory MUST exit 0 (or 2 with a stderr +# reason) — never throw, never block the agent loop on a notification push. + +$ErrorActionPreference = 'Stop' + +# Resolve the plugin root and the canonical IPC helper. The hook scripts +# live at /io.minimax.mcode/hooks/scripts/.ps1, so +# $PSScriptRoot\..\..\.. is the plugin root. +$script:PluginRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path +$script:NotifyIsland = Join-Path $script:PluginRoot 'notify-island.ps1' + +function Set-ConsoleUtf8 { + # Force UTF-8 so the PowerShell child that mcode spawns reads the + # stdin JSON cleanly. notify-island.ps1 also does this internally, + # but doing it here avoids any risk of mojibake in our own logs. + try { + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $OutputEncoding = [System.Text.Encoding]::UTF8 + } catch {} +} + +function Read-HookStdin { + # mcode delivers the hook event as a JSON object on stdin. + # Some events arrive with empty stdin (notably SessionEnd on + # hard-terminate); in that case return $null and let the caller + # decide what to do. + try { + $raw = [Console]::In.ReadToEnd() + if ([string]::IsNullOrWhiteSpace($raw)) { return $null } + return ($raw | ConvertFrom-Json -ErrorAction Stop) + } catch { + return $null + } +} + +function Push-Island { + # Thin wrapper over the canonical IPC. Never throws. + param( + [Parameter(Mandatory)] + [ValidateSet('idle','thinking','working','waiting','done','error')] + [string]$State, + + [string]$Message = '' + ) + if (-not (Test-Path -LiteralPath $script:NotifyIsland)) { + # Widget is not installed yet — silent no-op. The plugin's + # CLI still has to be runnable on machines where the widget + # was not started. + return + } + try { + & $script:NotifyIsland -State $State -Message $Message 2>$null | Out-Null + } catch { + # Hook must never block the agent on a notification failure. + } +} + +function Test-IsSelfPush { + # The hook for Pre/PostToolUse fires for every Bash invocation, + # including the agent's own notify-island.ps1 / wrap-tool.ps1 + # pushes. Pushing `working: bash: notify-island.ps1` immediately + # followed by the agent's own push of `error: ...` would be + # misleading on the pill. Filter our own internal calls. + param($Event) + if ($null -eq $Event) { return $false } + if ($Event.tool_name -ne 'Bash') { return $false } + + $cmd = '' + if ($Event.tool_input) { + if ($Event.tool_input.command) { $cmd = [string]$Event.tool_input.command } + elseif ($Event.tool_input.cmd) { $cmd = [string]$Event.tool_input.cmd } + } + if ([string]::IsNullOrEmpty($cmd)) { return $false } + + return ($cmd -match 'notify-island\.ps1|wrap-tool\.ps1|island\\notify|island\\wrap') +} + +function Format-ToolSummary { + # Compact ": " used in pill messages. + # Truncated to keep the WPF label single-line. + param($Event) + $tool = if ($Event.tool_name) { [string]$Event.tool_name } else { 'tool' } + $detail = '' + + if ($Event.tool_input) { + switch ($tool) { + 'Bash' { $detail = [string]$Event.tool_input.command } + 'Read' { $detail = [string]$Event.tool_input.file_path } + 'Write' { $detail = [string]$Event.tool_input.file_path } + 'Edit' { $detail = [string]$Event.tool_input.file_path } + 'Glob' { $detail = [string]$Event.tool_input.pattern } + 'Grep' { $detail = [string]$Event.tool_input.pattern } + 'WebFetch' { $detail = [string]$Event.tool_input.url } + 'WebSearch' { $detail = [string]$Event.tool_input.query } + 'Task' { $detail = [string]$Event.tool_input.description } + 'NotebookEdit' { $detail = [string]$Event.tool_input.notebook_path } + default { $detail = '' } + } + } + if ([string]::IsNullOrEmpty($detail)) { return $tool } + # Collapse newlines, take first 80 chars. + $detail = ($detail -replace "[\r\n]+", ' ').Trim() + if ($detail.Length -gt 80) { $detail = $detail.Substring(0, 77) + '...' } + return "$tool : $detail" +} + +Set-ConsoleUtf8 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/notification.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/notification.ps1 new file mode 100644 index 0000000..2ffdad1 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/notification.ps1 @@ -0,0 +1,16 @@ +# Hook: Notification +# Event: io.minimax.mcode / Notification +# State: idle +# Note: Fires when the runtime emits a system notification (e.g. +# "session timed out", "rate limited"). We push idle rather +# than working/error because a notification is a passive +# informational event, not an agent action. The notification +# text is surfaced in the pill so the user can read it. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$text = '' +if ($evt.message) { $text = [string]$evt.message } +elseif ($evt.notification) { $text = [string]$evt.notification } +if ($text.Length -gt 80) { $text = $text.Substring(0, 77) + '...' } +Push-Island -State idle -Message $text +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/permission-denied.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/permission-denied.ps1 new file mode 100644 index 0000000..6e2bdf7 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/permission-denied.ps1 @@ -0,0 +1,11 @@ +# Hook: PermissionDenied +# Event: io.minimax.mcode / PermissionDenied +# State: error +# Note: Fires after a permission has been denied (rare in 0.2.4 +# per the spec; treat as advisory). We push error so the +# user sees the pill turn red and knows to investigate. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$tool = if ($evt.tool_name) { [string]$evt.tool_name } else { 'permission' } +Push-Island -State error -Message "$tool denied" +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/permission-request.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/permission-request.ps1 new file mode 100644 index 0000000..f2dc37b --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/permission-request.ps1 @@ -0,0 +1,25 @@ +# Hook: PermissionRequest +# Event: io.minimax.mcode / PermissionRequest +# State: waiting +# Decision: ask +# Note: This is a DECISION-BEARING event. Per the io.minimax.mcode +# Hooks spec (MiniMax-Code-Plugins PR #20, section "Decision +# semantics"), an observer Hook on PermissionRequest MUST return +# `ask` (or no decision at all) and MUST NOT return `allow` or +# `deny` unless the Plugin is genuinely the permission owner. +# +# The 0.2.4 Runtime default for PermissionRequest is fail-closed +# (`deny`), which would make a pure observer indistinguishable +# from a denial and break the portable observe-only floor. +# Returning `ask` opts the Hook out of fail-closed: the pill +# surfaces the waiting state, the user still sees the TUI +# prompt, and the runtime's Permission Core remains the +# permission owner. The user can still approve or deny. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$tool = if ($evt.tool_name) { [string]$evt.tool_name } else { 'permission' } +Push-Island -State waiting -Message "$tool needs approval" +# Observer opt-in decision. Written to stdout in the shape the +# io.minimax.mcode spec defines for PermissionRequest. Exit 0 = OK. +[Console]::Out.WriteLine('{"decision":"ask","reason":"island-only observer; permission owner remains runtime Permission Core"}') +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 new file mode 100644 index 0000000..034a31e --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/post-tool-use.ps1 @@ -0,0 +1,28 @@ +# Hook: PostToolUse +# Event: io.minimax.mcode / PostToolUse +# State: done / error +# Note: Fires after every tool call returns. Heuristic: if the +# tool_result is empty or matches an error pattern, push +# error; otherwise push done. Self-push calls are filtered. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +if (Test-IsSelfPush $evt) { exit 0 } + +$tool = if ($evt.tool_name) { [string]$evt.tool_name } else { 'tool' } +$result = $evt.tool_result +$isError = $false + +if ($null -eq $result) { + $isError = $true +} else { + $s = [string]$result + if ([string]::IsNullOrEmpty($s)) { $isError = $true } + elseif ($s -match '^\s*(Error|ERROR|✕|Error:|\[ERROR\])') { $isError = $true } +} + +if ($isError) { + Push-Island -State error -Message "$tool failed" +} else { + Push-Island -State done -Message "$tool ok" +} +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 new file mode 100644 index 0000000..8e4460f --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-compact.ps1 @@ -0,0 +1,13 @@ +# Hook: PreCompact +# Event: io.minimax.mcode / PreCompact +# State: thinking +# Note: Fires before the runtime compresses context. We push +# thinking so the pill signals "agent is still doing +# something" — without this, the pill might sit in `done` +# while the model is mid-compaction and the user wonders +# whether the agent is alive. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$trigger = if ($evt.trigger) { [string]$evt.trigger } else { 'context' } +Push-Island -State thinking -Message "compacting ($trigger)" +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1 new file mode 100644 index 0000000..cb143a8 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1 @@ -0,0 +1,12 @@ +# Hook: PreToolUse +# Event: io.minimax.mcode / PreToolUse +# State: working +# Note: Fires before every tool call. We push working with a short +# tool-name + input summary. Self-push calls (the agent's +# own notify-island / wrap-tool invocations through Bash) are +# filtered to avoid recursive state churn. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +if (Test-IsSelfPush $evt) { exit 0 } +Push-Island -State working -Message (Format-ToolSummary $evt) +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 new file mode 100644 index 0000000..214f562 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-end.ps1 @@ -0,0 +1,9 @@ +# Hook: SessionEnd +# Event: io.minimax.mcode / SessionEnd +# State: idle +# Note: Fires when the runtime terminates a session. We push idle +# so the pill returns to a known resting color. The widget +# itself stays alive — only its state is reset. +. "$PSScriptRoot\_lib.ps1" +Push-Island -State idle -Message "session ended" +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-start.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-start.ps1 new file mode 100644 index 0000000..2236654 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/session-start.ps1 @@ -0,0 +1,11 @@ +# Hook: SessionStart +# Event: io.minimax.mcode / SessionStart +# State: idle +# Note: Fires when the runtime starts a session. We push idle to +# confirm the pill is alive; the widget may have been started +# before the session was open. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$sid = if ($evt.session_id) { $evt.session_id.Substring(0, 8) } else { '?' } +Push-Island -State idle -Message "session $sid" +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 new file mode 100644 index 0000000..0637c43 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/stop.ps1 @@ -0,0 +1,12 @@ +# Hook: Stop +# Event: io.minimax.mcode / Stop +# State: done +# Note: Fires when the agent finishes a turn (one model response, +# any number of tool calls). This is the natural "this turn +# is done" signal — the pill goes green until the next +# UserPromptSubmit turns it yellow again. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$reason = if ($evt.stop_reason) { [string]$evt.stop_reason } else { 'turn complete' } +Push-Island -State done -Message $reason +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/subagent-start.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/subagent-start.ps1 new file mode 100644 index 0000000..efbbd14 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/subagent-start.ps1 @@ -0,0 +1,16 @@ +# Hook: SubagentStart +# Event: io.minimax.mcode / SubagentStart +# State: working +# Note: Fires when the agent delegates a subtask to a subagent. +# Bridged only on the CODEX native client surface; no +# deliveries on CLAUDE. We push working so the pill +# reflects the visible "the agent is still busy" state +# even though the work is happening in a child context. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$name = if ($evt.subagent_type) { [string]$evt.subagent_type } else { 'subagent' } +$desc = if ($evt.description) { [string]$evt.description } else { '' } +if ($desc.Length -gt 60) { $desc = $desc.Substring(0, 57) + '...' } +$msg = if ($desc) { "delegate: $name - $desc" } else { "delegate: $name" } +Push-Island -State working -Message $msg +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/subagent-stop.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/subagent-stop.ps1 new file mode 100644 index 0000000..00e64f2 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/subagent-stop.ps1 @@ -0,0 +1,11 @@ +# Hook: SubagentStop +# Event: io.minimax.mcode / SubagentStop +# State: done +# Note: Fires when a delegated subagent finishes. We push done; +# the pill goes green. If the main agent subsequently calls +# another tool, PreToolUse will turn it back to working. +. "$PSScriptRoot\_lib.ps1" +$evt = Read-HookStdin +$name = if ($evt.subagent_type) { [string]$evt.subagent_type } else { 'subagent' } +Push-Island -State done -Message "$name returned" +exit 0 diff --git a/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/user-prompt-submit.ps1 b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/user-prompt-submit.ps1 new file mode 100644 index 0000000..ba39a41 --- /dev/null +++ b/plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/user-prompt-submit.ps1 @@ -0,0 +1,10 @@ +# Hook: UserPromptSubmit +# Event: io.minimax.mcode / UserPromptSubmit +# State: thinking +# Note: Fires right after the user presses Enter on a new turn, +# before the agent starts reasoning. Pushing thinking here +# avoids the gap where the pill would otherwise sit in idle +# (yellow pulse = "I heard you, working on it"). +. "$PSScriptRoot\_lib.ps1" +Push-Island -State thinking -Message "reasoning" +exit 0 diff --git a/plugins/antianqi/mcode-island/mcode-island.cmd b/plugins/antianqi/mcode-island/mcode-island.cmd index e817ce0..2720cda 100644 --- a/plugins/antianqi/mcode-island/mcode-island.cmd +++ b/plugins/antianqi/mcode-island/mcode-island.cmd @@ -14,8 +14,12 @@ if /i "%1"=="pin" goto :pin if /i "%1"=="unpin" goto :unpin if /i "%1"=="autostart-on" goto :autostart_on if /i "%1"=="autostart-off" goto :autostart_off +if /i "%1"=="detect-on" goto :detect_on +if /i "%1"=="detect-off" goto :detect_off +if /i "%1"=="detect-status" goto :detect_status +if /i "%1"=="set-token" goto :set_token -echo Usage: mcode-island {start ^| stop ^| status ^| show ^| hide ^| pin ^| unpin ^| autostart-on ^| autostart-off} +echo Usage: mcode-island {start ^| stop ^| status ^| show ^| hide ^| pin ^| unpin ^| autostart-on ^| autostart-off ^| detect-on ^| detect-off ^| detect-status ^| set-token} exit /b 1 :start @@ -53,3 +57,28 @@ exit /b %errorlevel% :autostart_off %PS% -File "%SCRIPT_DIR%autostart.ps1" -Action Disable exit /b %errorlevel% + +:detect_on +%PS% -File "%SCRIPT_DIR%start-detect-island.ps1" +exit /b %errorlevel% + +:detect_off +%PS% -File "%SCRIPT_DIR%stop-detect-island.ps1" +exit /b %errorlevel% + +:detect_status +%PS% -File "%SCRIPT_DIR%status-detect-island.ps1" +exit /b %errorlevel% + +:set_token +if "%2"=="" goto :set_token_show +if /i "%2"=="-show" goto :set_token_show +if /i "%2"=="-clear" goto :set_token_clear +%PS% -File "%SCRIPT_DIR%set-token.ps1" "%2" +exit /b %errorlevel% +:set_token_show +%PS% -File "%SCRIPT_DIR%set-token.ps1" -Show +exit /b %errorlevel% +:set_token_clear +%PS% -File "%SCRIPT_DIR%set-token.ps1" -Clear +exit /b %errorlevel% diff --git a/plugins/antianqi/mcode-island/mcode-island.ps1 b/plugins/antianqi/mcode-island/mcode-island.ps1 index cd4334f..898322a 100644 --- a/plugins/antianqi/mcode-island/mcode-island.ps1 +++ b/plugins/antianqi/mcode-island/mcode-island.ps1 @@ -16,10 +16,32 @@ Dbg "PID=$PID APART=$([System.Threading.Thread]::CurrentThread.ApartmentState)" if ([System.Threading.Thread]::CurrentThread.ApartmentState -ne 'STA') { Dbg 'relaunching in STA' $args2 = @('-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath) + $args - Start-Process powershell.exe -ArgumentList $args2 -WindowStyle Hidden + $psi2 = New-Object System.Diagnostics.ProcessStartInfo + $psi2.FileName = 'powershell.exe' + $psi2.Arguments = $args2 -join ' ' + $psi2.UseShellExecute = $false + $psi2.CreateNoWindow = $true + [void][System.Diagnostics.Process]::Start($psi2) exit } +# 防御:万一 Start-Process 那层漏了控制台窗口,进来第一件事就藏掉。 +# GetConsoleWindow() 在没有控制台时返回 0,ShowWindow 直接 no-op。 +Dbg 'hiding any stray console window' +$hideSig = @' +using System; +using System.Runtime.InteropServices; +public class IslandHide { + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); +} +'@ +if (-not ('IslandHide' -as [type])) { Add-Type $hideSig -ErrorAction SilentlyContinue } +$hwnd = [IslandHide]::GetConsoleWindow() +if ($hwnd -ne [IntPtr]::Zero) { + [void][IslandHide]::ShowWindow($hwnd, 0) # SW_HIDE +} + # 加载 WPF Dbg 'loading WPF assemblies' try { @@ -84,7 +106,7 @@ $defaultConfig = [PSCustomObject]@{ x = -1 y = -1 width = 320 - height = 60 + height = 70 opacity = 0.95 autostart = $false } @@ -112,7 +134,7 @@ $xaml = @' Focusable="False" ShowActivated="False"> + Padding="22,6" Margin="0"> @@ -136,11 +158,59 @@ $xaml = @' - + + + + + + + + + + + + + + + + + + + + 0,0.5 + + + + + + + + + + + + + + + + TodoProgress > shimmer +# - 即:agent 直接传 progress 最高;否则如果有 todo 列表就用 todo 完成度;都没就 shimmer 动画 function Update-State { - param([string]$State, [string]$Message) + param( + [string]$State, + [string]$Message, + [int]$Progress = -1, + [int]$Usage5h = -2, + [int]$Usage5hResetMs = 0, + [int]$TodoProgress = -2 + ) $s = $script:stateMap[$State] if (!$s) { $s = $script:stateMap['idle'] } $script:statusDot.Fill = C $s.dot @@ -241,9 +398,83 @@ function Update-State { if ($State -in @('thinking','working','waiting')) { Start-Pulse } else { Stop-Pulse } + # 剩余用量:时间 + 剩余 % 拼一起(如 "4h31m 84%"),颜色按"剩余百分比"走 + # 剩余 < 20% 红,20-50% 黄,>= 50% 灰 + $hasTime = $Usage5hResetMs -gt 0 + $hasPct = $Usage5h -ge 0 -and $Usage5h -le 100 + if ($hasTime -or $hasPct) { + $parts = @() + if ($hasTime) { $parts += (Format-ResetMs $Usage5hResetMs) } + if ($hasPct) { $parts += ("{0}%" -f [int]$Usage5h) } + $script:usage5hText.Text = $parts -join ' ' + $col = if ($Usage5h -ge 50) { '#FF6B7280' } # 剩 >= 50% 灰 + elseif ($Usage5h -ge 20) { '#FFEAB308' } # 剩 20-50% 黄 + else { '#FFEF4444' } # 剩 < 20% 红 + $script:usage5hText.Foreground = C $col + } else { + $script:usage5hText.Text = '' + $script:usage5hText.Foreground = C '#FF6B7280' + } + + # Elapsed timer:进入 active 启动/跨 state 重置,退出 active 停表并清空文字 + $isActive = $State -in @('thinking','working','waiting') + if ($isActive) { + if ($State -ne $script:elapsedLastState) { + $script:elapsedStopwatch.Restart() + $script:elapsedLastState = $State + } + $script:elapsedActive = $true + $script:elapsedText.Text = Format-Elapsed $script:elapsedStopwatch.Elapsed + } else { + $script:elapsedActive = $false + $script:elapsedStopwatch.Reset() + $script:elapsedLastState = $State + $script:elapsedText.Text = '' + } + + # 进度条:四分支(按优先级) + # - active + 显式 progress 0..100 → determinate 用 Progress + # - active + todoProgress 0..100 → determinate 用 TodoProgress + # - active + 都没有 → indeterminate shimmer + # - 非 active → 隐藏 + $clamped = [Math]::Max(0, [Math]::Min(100, $Progress)) + $todoClamped = [Math]::Max(0, [Math]::Min(100, $TodoProgress)) + $isActive = $State -in @('thinking','working','waiting') + $explicitProgress = ($Progress -ge 0 -and $Progress -le 100) + $hasTodoProgress = ($TodoProgress -ge 0 -and $TodoProgress -le 100) + if ($isActive -and $explicitProgress) { + $script:progressBar.Visibility = 'Visible' + $script:progressFill.Visibility = 'Visible' + $script:progressIndeterminate.Visibility = 'Collapsed' + $script:progressScale.ScaleX = $clamped / 100.0 + $script:progressFill.Background = C $s.dot + Stop-IndeterminateShimmer + } elseif ($isActive -and $hasTodoProgress) { + $script:progressBar.Visibility = 'Visible' + $script:progressFill.Visibility = 'Visible' + $script:progressIndeterminate.Visibility = 'Collapsed' + $script:progressScale.ScaleX = $todoClamped / 100.0 + $script:progressFill.Background = C $s.dot + Stop-IndeterminateShimmer + } elseif ($isActive) { + $script:progressBar.Visibility = 'Visible' + $script:progressFill.Visibility = 'Collapsed' + $script:progressIndeterminate.Visibility = 'Visible' + $script:progressShimmer.Fill = C $s.dot + $script:progressScale.ScaleX = 0 + Start-IndeterminateShimmer + } else { + $script:progressBar.Visibility = 'Collapsed' + $script:progressFill.Visibility = 'Collapsed' + $script:progressIndeterminate.Visibility = 'Collapsed' + $script:progressScale.ScaleX = 0 + Stop-IndeterminateShimmer + } + # 写入 log $ts = (Get-Date).ToString('HH:mm:ss') - "[$ts] $State :: $Message" | Add-Content -Path $script:logFile -Encoding UTF8 + $progTag = if ($Progress -ge 0) { " [$Progress%]" } else { '' } + "[$ts] $State :: $Message$progTag" | Add-Content -Path $script:logFile -Encoding UTF8 } # 切回调用方窗口(点击 pill 时调用) @@ -457,25 +688,41 @@ $timer.Add_Tick({ if ($mtime -eq $script:lastStatusMtime) { return } $script:lastStatusMtime = $mtime $data = Get-Content $statusFile -Raw -Encoding UTF8 | ConvertFrom-Json - $sig = "$($data.state)|$($data.message)|$($data.ts)" + # progress 也要进 sig,否则 agent 连续推 working+相同 message+不同 progress 会被去重 + $prog = if ($data.PSObject.Properties['progress']) { [int]$data.progress } else { -1 } + $usage = $null + $resetMs = 0 + $todoP = -2 + if ($data.PSObject.Properties['usage5h'] -and $null -ne $data.usage5h) { $usage = [int]$data.usage5h } + if ($data.PSObject.Properties['usage5hResetMs'] -and $null -ne $data.usage5hResetMs) { $resetMs = [int]$data.usage5hResetMs } + if ($data.PSObject.Properties['todoProgress'] -and $null -ne $data.todoProgress) { $todoP = [int]$data.todoProgress } + $sig = "$($data.state)|$($data.message)|$prog|$usage|$resetMs|$todoP|$($data.ts)" if ($sig -eq $script:lastStatusSig) { return } $script:lastStatusSig = $sig - Dbg "POLL: $($data.state) :: $($data.message)" - Update-State -State $data.state -Message $data.message + Dbg "POLL: $($data.state) :: $($data.message) (progress=$prog usage5h=$usage resetMs=$resetMs todoProgress=$todoP)" + Update-State -State $data.state -Message $data.message -Progress $prog -Usage5h $usage -Usage5hResetMs $resetMs -TodoProgress $todoP } catch { Dbg "POLL ERR: $($_.Exception.Message)" } }) $timer.Start() -Dbg 'poll timer started' +$script:elapsedTimer.Start() +Dbg 'poll timer + elapsed timer started' # 启动时读一次 status.json(如果存在) if (Test-Path $statusFile) { try { $init = Get-Content $statusFile -Raw -Encoding UTF8 | ConvertFrom-Json - $script:lastStatusSig = "$($init.state)|$($init.message)|$($init.ts)" + $initProg = if ($init.PSObject.Properties['progress']) { [int]$init.progress } else { -1 } + $initUsage = $null + $initReset = 0 + $initTodo = -2 + if ($init.PSObject.Properties['usage5h'] -and $null -ne $init.usage5h) { $initUsage = [int]$init.usage5h } + if ($init.PSObject.Properties['usage5hResetMs'] -and $null -ne $init.usage5hResetMs) { $initReset = [int]$init.usage5hResetMs } + if ($init.PSObject.Properties['todoProgress'] -and $null -ne $init.todoProgress) { $initTodo = [int]$init.todoProgress } + $script:lastStatusSig = "$($init.state)|$($init.message)|$initProg|$initUsage|$initReset|$initTodo|$($init.ts)" $script:lastStatusMtime = (Get-Item $statusFile).LastWriteTimeUtc.Ticks - Update-State -State $init.state -Message $init.message + Update-State -State $init.state -Message $init.message -Progress $initProg -Usage5h $initUsage -Usage5hResetMs $initReset -TodoProgress $initTodo } catch {} } else { Update-State -State 'idle' -Message '' diff --git a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 index 96d2257..415d02f 100644 --- a/plugins/antianqi/mcode-island/mcode-status-detect.ps1 +++ b/plugins/antianqi/mcode-island/mcode-status-detect.ps1 @@ -40,6 +40,22 @@ $ErrorActionPreference = 'Stop' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 chcp 65001 | Out-Null +# 防御性隐藏控制台窗口:start-detect 用 CreateNoWindow 启的进程理论上没有控制台, +# 但偶尔有边界场景会冒出空窗口被用户误关。这里 SW_HIDE 一下兜底。 +$hideSig = @' +using System; +using System.Runtime.InteropServices; +public class DetectHide { + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); +} +'@ +if (-not ('DetectHide' -as [type])) { Add-Type $hideSig -ErrorAction SilentlyContinue } +$hwnd = [DetectHide]::GetConsoleWindow() +if ($hwnd -ne [IntPtr]::Zero) { + [void][DetectHide]::ShowWindow($hwnd, 0) +} + function _s { param([byte[]]$b) [System.Text.Encoding]::UTF8.GetString($b) } # role names @@ -92,6 +108,32 @@ if (!(Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Fo $statusFile = Join-Path $configDir 'status.json' $logFile = Join-Path $configDir 'island.log' $pidFile = Join-Path $configDir 'detect.pid' +$cfgFile = Join-Path $configDir 'config.json' + +# 5h 用量 API:每 60s 调一次 minimax /v1/coding_plan/remains,写进 status.json 的 usage5h 字段 +# token 来源:env MINIMAX_OAUTH_TOKEN 优先;fallback 到 config.json 的 planApiToken +$PLAN_API_HOST = _s (0x68,0x74,0x74,0x70,0x73,0x3A,0x2F,0x2F,0x61,0x70,0x69,0x2E,0x6D,0x69,0x6E,0x69,0x6D,0x61,0x78,0x69,0x2E,0x63,0x6F,0x6D) +$PLAN_API_PATH = _s (0x2F,0x76,0x31,0x2F,0x63,0x6F,0x64,0x69,0x6E,0x67,0x5F,0x70,0x6C,0x61,0x6E,0x2F,0x72,0x65,0x6D,0x61,0x69,0x6E,0x73) # /v1/coding_plan/remains +$PLAN_API_TTL = [TimeSpan]::FromSeconds(60) +$script:plan5hToken = $null +if ($env:MINIMAX_OAUTH_TOKEN) { $script:plan5hToken = $env:MINIMAX_OAUTH_TOKEN } +elseif ($env:MINIMAX_API_KEY) { $script:plan5hToken = $env:MINIMAX_API_KEY } +elseif (Test-Path $cfgFile) { + try { + $cfg = [System.IO.File]::ReadAllText($cfgFile) | ConvertFrom-Json + if ($cfg.PSObject.Properties['planApiToken'] -and $cfg.planApiToken) { + $script:plan5hToken = [string]$cfg.planApiToken + } + } catch {} +} +$script:plan5hLastCallAt = [DateTime]::MinValue +$script:plan5hRemainingPct = $null # 0..100,剩余百分比(不再是已用!) +$script:plan5hResetMs = $null # 距下次刷新的毫秒数 + +# Todo 进度缓存(widget 进度条用):completed / (total - cancelled) * 100 +$script:plan5hTodoData = $null # @{ percent; currentTodo; completed; total } +$script:plan5hTodoCacheMtime = [DateTime]::MinValue +$script:plan5hLastWrittenTodoPct = $null # 上次写到 status.json 的 todoProgress(用来检测变化) # 解析 mcode 安装根目录(/.minimax-code) function Find-McodeRoot { @@ -349,17 +391,125 @@ function Infer-State($msg) { function Write-Status($state, $message) { $tmp = "$statusFile.tmp" + # usage5h:0..100 表示"剩余"百分比(不是已用!);null = 未知/未拉到 + # usage5hResetMs:距下次刷新的毫秒数;null = 未知 + # todoProgress:0..100 完成百分比(cancelled 不计);null = 无 todo 列表 + $usageField = if ($null -ne $script:plan5hRemainingPct) { [int]$script:plan5hRemainingPct } else { $null } + $resetField = if ($null -ne $script:plan5hResetMs) { [int]$script:plan5hResetMs } else { $null } + $todoPct = if ($null -ne $script:plan5hTodoData) { [int]$script:plan5hTodoData.percent } else { $null } + $todoCnt = if ($null -ne $script:plan5hTodoData) { ("{0}/{1}" -f $script:plan5hTodoData.completed, $script:plan5hTodoData.total) } else { $null } $payload = [PSCustomObject]@{ - state = $state - message = $message - progress = -1 - ts = (Get-Date).ToString($FMT_O) - source = $S_DETECTOR + state = $state + message = $message + progress = -1 + usage5h = $usageField + usage5hResetMs = $resetField + todoProgress = $todoPct + todosCount = $todoCnt + ts = (Get-Date).ToString($FMT_O) + source = $S_DETECTOR } | ConvertTo-Json -Compress [System.IO.File]::WriteAllText($tmp, $payload, [System.Text.Encoding]::UTF8) Move-Item -Path $tmp -Destination $statusFile -Force } +# 5h 用量:调 minimax /v1/coding_plan/remains,返回 general model 的 {remainingPct, resetMs} +# 无 token / 网络错 / 解析错 → 返回 $null +function Get-5hUsage { + if (-not $script:plan5hToken) { return $null } + try { + # PowerShell 5.1 在某些 Windows 上默认 TLS 1.0;强制 1.2 避免握手失败 + if ([System.Net.ServicePointManager]::SecurityProtocol -notmatch 'Tls12') { + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + } + $url = $PLAN_API_HOST + $PLAN_API_PATH + $headers = @{ + 'Authorization' = "Bearer $($script:plan5hToken)" + 'MM-API-Source' = _s (0x4D,0x69,0x6E,0x69,0x6D,0x61,0x78,0x2D,0x4D,0x43,0x50) # Minimax-MCP + } + $resp = Invoke-RestMethod -Uri $url -Headers $headers -TimeoutSec 8 -Method Get -ErrorAction Stop + if (-not $resp -or -not $resp.model_remains) { return $null } + foreach ($m in @($resp.model_remains)) { + if ($m.model_name -eq 'general') { + $remPct = [int]$m.current_interval_remaining_percent + if ($remPct -lt 0) { $remPct = 0 } + if ($remPct -gt 100) { $remPct = 100 } + $resetMs = [int]$m.remains_time + if ($resetMs -lt 0) { $resetMs = 0 } + return @{ remainingPct = $remPct; resetMs = $resetMs } + } + } + return $null + } catch { + Log-Line ("5h usage fetch failed: " + $_.Exception.Message) + return $null + } +} + +# 在主循环里每 60s 调一次(用 TTL 守门,单线程安全) +function Refresh-5hUsage { + $now = Get-Date + if (($now - $script:plan5hLastCallAt) -lt $PLAN_API_TTL) { return } + $script:plan5hLastCallAt = $now + $data = Get-5hUsage + if ($null -eq $data) { + $script:plan5hRemainingPct = $null + $script:plan5hResetMs = $null + } else { + $script:plan5hRemainingPct = [int]$data.remainingPct + $script:plan5hResetMs = [int]$data.resetMs + } +} + +# 读最新一次 todowrite 的 todos,计算完成百分比 +# 缓存:mtime 不变就复用上次结果(典型场景:mcode 跑 1 分钟才动一次 todo) +function Get-TodoProgress { + $latest = $script:lastLatestFile + if (-not $latest -or -not (Test-Path $latest)) { return $null } + + $mtime = [System.IO.File]::GetLastWriteTimeUtc($latest) + if ($mtime -eq $script:plan5hTodoCacheMtime -and $null -ne $script:plan5hTodoData) { + return $script:plan5hTodoData + } + $script:plan5hTodoCacheMtime = $mtime + + try { + # 从末尾向前找最近的 toolName=todowrite 且 role=toolResult 的行 + $lines = [System.IO.File]::ReadAllLines($latest, [System.Text.Encoding]::UTF8) + for ($i = $lines.Count - 1; $i -ge 0; $i--) { + $line = $lines[$i] + if ($line.IndexOf('"toolName":"todowrite"', [System.StringComparison]::Ordinal) -lt 0) { continue } + if ($line.IndexOf('"role":"toolResult"', [System.StringComparison]::Ordinal) -lt 0) { continue } + $j = $line | ConvertFrom-Json -ErrorAction SilentlyContinue + if (-not $j -or -not $j.message -or -not $j.message.details -or -not $j.message.details.todos) { continue } + $todos = @($j.message.details.todos) + $total = $todos.Count + $done = 0; $cancelled = 0 + foreach ($t in $todos) { + if ($t.status -eq 'completed') { $done++ } + if ($t.status -eq 'cancelled') { $cancelled++ } + } + $effective = $total - $cancelled + if ($effective -le 0) { + $script:plan5hTodoData = $null + return $null + } + $percent = [int][Math]::Floor(($done * 100) / $effective) + $script:plan5hTodoData = @{ + percent = $percent + completed = $done + total = $total + } + return $script:plan5hTodoData + } + # 走完没找到 = 没 todowrite 调用过 + $script:plan5hTodoData = $null + return $null + } catch { + return $null + } +} + function Read-StatusObj { if (!(Test-Path $statusFile)) { return $null } try { return ([System.IO.File]::ReadAllText($statusFile) | ConvertFrom-Json) } catch { return $null } @@ -453,6 +603,39 @@ try { } } + # 4) 5h 用量:每 60s 刷一次,刷新后若数字变化就写 status.json(让 widget 看到) + $prevPct = $script:plan5hRemainingPct + $prevMs = $script:plan5hResetMs + Refresh-5hUsage + $curPct = $script:plan5hRemainingPct + $curMs = $script:plan5hResetMs + $usageChanged = ($prevPct -ne $curPct) -or ($prevMs -ne $curMs) -and ($null -ne $curPct) + if ($usageChanged) { + $curForUsage = Read-StatusObj + $sForU = if ($curForUsage) { [string]$curForUsage.state } else { $S_IDLE } + $mForU = if ($curForUsage) { [string]$curForUsage.message } else { '' } + Write-Status $sForU $mForU + Log-Line ("5h usage refreshed: remaining=" + $curPct + "% resetMs=" + $curMs) + } + + # 5) Todo 进度:每次都查(带 mtime 缓存),变化时写 status.json + $prevTodoPct = $script:plan5hLastWrittenTodoPct + $curTodoData = Get-TodoProgress + $curTodoPct = if ($null -ne $curTodoData) { $curTodoData.percent } else { $null } + $todoChanged = ($prevTodoPct -ne $curTodoPct) + if ($todoChanged) { + $curForTodo = Read-StatusObj + $sForT = if ($curForTodo) { [string]$curForTodo.state } else { $S_IDLE } + $mForT = if ($curForTodo) { [string]$curForTodo.message } else { '' } + Write-Status $sForT $mForT + $script:plan5hLastWrittenTodoPct = $curTodoPct + if ($null -ne $curTodoData) { + Log-Line ("todo refreshed: " + $curTodoData.completed + "/" + $curTodoData.total + " = " + $curTodoPct + "%") + } else { + Log-Line "todo refreshed: (none)" + } + } + if ($Once) { break } Start-Sleep -Milliseconds 1000 } diff --git a/plugins/antianqi/mcode-island/plugin.json b/plugins/antianqi/mcode-island/plugin.json index f32b034..f39491d 100644 --- a/plugins/antianqi/mcode-island/plugin.json +++ b/plugins/antianqi/mcode-island/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "mcode-island", - "version": "0.2.0", - "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。", + "version": "0.3.0", + "description": "Windows 桌面灵动岛 (Dynamic Island) 状态窗口:让 mcode agent 把工作状态(idle/thinking/working/waiting/done/error)实时推送到屏幕顶部悬浮 pill,agent 自己忙的时候用户不用切回 mcode 也能看到进度。v0.3.0 增加 io.minimax.mcode 客户端扩展(Hooks 草案),与 MiniMax-Code-Plugins PR #20 的 portable Hooks 提案对齐;mcode 0.2.4+ Runtime 触发,registry 接受后零改动生效。", "author": { "name": "antianqi", "url": "https://github.com/antianqi" @@ -17,6 +17,14 @@ "powershell", "status", "ui", - "dynamic-island" - ] + "dynamic-island", + "io.minimax.mcode", + "hooks" + ], + "extensions": { + "io.minimax.mcode": { + "version": "0.1.0", + "hooks": "./io.minimax.mcode/hooks/hooks.json" + } + } } diff --git a/plugins/antianqi/mcode-island/scripts/smoke.mjs b/plugins/antianqi/mcode-island/scripts/smoke.mjs new file mode 100644 index 0000000..c3c1bd5 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/smoke.mjs @@ -0,0 +1,408 @@ +#!/usr/bin/env node +// mcode-island v0.3.0 — pre-submit self-check for the io.minimax.mcode +// Hooks extension. Cross-platform (Windows / macOS / Linux), no +// dependencies beyond Node.js >= 18. +// +// Run from the plugin root: +// node scripts/smoke.mjs +// +// Exits 0 on full pass, 1 on any failure. Prints a per-check line +// with PASS / WARN / FAIL, then a summary. +// +// What it checks: +// 1. plugin.json: $schema / name / version / extensions.io.minimax.mcode +// 2. hooks.json: parses, top-level has `hooks` object +// 3. event catalog: every event is in the spec allowlist +// (5 `yes` in 0.2.4, 7 `forward` — `forward` is a warn, not a fail) +// 4. hook entries: no reserved fields (type, shell, prompt, http, +// agent, script, function), env does not reserve PLUGIN_ROOT / +// PLUGIN_DATA, command is either a bare executable or a path +// starting with ${PLUGIN_ROOT}/ +// 5. script files: every hook entry's referenced .ps1 file actually +// exists under io.minimax.mcode/hooks/scripts/ +// 6. cross-platform: no hardcoded host-absolute paths, no +// /Users/ or /home/ literals in any script or hooks.json entry + +import { readFile, stat } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve, sep } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = resolve(__dirname, '..'); + +const RESERVED_FIELDS = new Set([ + 'type', 'shell', 'prompt', 'http', 'agent', 'script', 'function', +]); +const RESERVED_ENV = new Set(['PLUGIN_ROOT', 'PLUGIN_DATA']); + +// 12-event catalog from proposals/hooks-detailed-spec.md. `yes` = +// confirmed in @minimax-ai/code@0.2.4 (Wso allowlist). `forward` +// = reserved by the portable spec, may or may not be wired in 0.2.4. +const EVENT_CATALOG = { + SessionStart: 'yes', + SessionEnd: 'yes', + UserPromptSubmit: 'yes', + PreToolUse: 'yes', + PostToolUse: 'yes', + Stop: 'forward', + PreCompact: 'forward', + Notification: 'forward', + SubagentStart: 'forward', + SubagentStop: 'forward', + PermissionRequest:'forward', + PermissionDenied: 'forward', +}; + +let pass = 0, warn = 0, fail = 0; +const out = (tag, msg) => { + const sym = { PASS: 'OK ', WARN: 'WARN', FAIL: 'FAIL' }[tag]; + console.log(`[${sym}] ${msg}`); + if (tag === 'PASS') pass++; + else if (tag === 'WARN') warn++; + else fail++; +}; + +const exists = async (p) => { + try { await stat(p); return true; } catch { return false; } +}; + +const readJson = async (p) => { + const raw = await readFile(p, 'utf8'); + return JSON.parse(raw); +}; + +const checkLiteralPaths = (s, where) => { + // No hardcoded /Users/ or /home/ or C:\ prefixes inside the value. + // ${PLUGIN_ROOT}/... is the only acceptable form. + if (typeof s !== 'string') return; + if (/^(\/Users\/|\/home\/|[A-Za-z]:\\|\/mnt\/)/.test(s)) { + fail++; + console.log(`[FAIL] ${where}: hardcoded host path "${s}"`); + } +}; + +const checkEntry = async (event, entry) => { + const where = `hooks.json[${event}]`; + if (typeof entry !== 'object' || entry === null) { + out('FAIL', `${where}: entry is not an object`); return; + } + + for (const key of Object.keys(entry)) { + if (RESERVED_FIELDS.has(key)) { + out('FAIL', `${where}: uses reserved field "${key}"`); + } + } + + if (entry.env) { + if (typeof entry.env !== 'object' || Array.isArray(entry.env)) { + out('FAIL', `${where}: env is not a record`); + } else { + for (const k of Object.keys(entry.env)) { + if (RESERVED_ENV.has(k)) { + out('FAIL', `${where}: env reserves "${k}"`); + } + } + } + } + + if (!entry.command) { + out('FAIL', `${where}: missing "command"`); + } else if (typeof entry.command !== 'string') { + out('FAIL', `${where}: command is not a string`); + } + + if (entry.args !== undefined && !Array.isArray(entry.args)) { + out('FAIL', `${where}: args is not an array`); + } + + if (entry.matcher !== undefined && typeof entry.matcher !== 'string') { + out('FAIL', `${where}: matcher is not a string`); + } + + if (entry.timeout !== undefined) { + if (typeof entry.timeout !== 'number' || entry.timeout <= 0) { + out('FAIL', `${where}: timeout is not a positive number`); + } else if (entry.timeout > 30000) { + out('WARN', `${where}: timeout ${entry.timeout}ms exceeds portable default 30000ms`); + } + } + + // Scan args for host-literal paths. The command itself we + // already validated above; args often contain the actual script + // path. We don't run any path-resolution here — that's the + // Runtime's job. We only check that nothing is hardcoded. + for (const a of (entry.args || [])) { + checkLiteralPaths(a, `${where}.args[]`); + } + + // Find the script path inside the args (last .ps1/.mjs/.js/.ps1 + // token that isn't a switch). We don't need exact matching — we + // just check that at least one script file under + // io.minimax.mcode/hooks/scripts/ exists and is referenced. + const scriptArg = (entry.args || []).find( + (a) => typeof a === 'string' && /\.(ps1|mjs|js)$/i.test(a) + ); + if (scriptArg) { + // Strip ${PLUGIN_ROOT}/ prefix and resolve relative to PLUGIN_ROOT. + const cleaned = scriptArg.replace(/^\$\{PLUGIN_ROOT\}/, ''); + const absolute = join(PLUGIN_ROOT, cleaned); + if (!(await exists(absolute))) { + out('FAIL', `${where}: script not found: ${cleaned}`); + } else { + out('PASS', `${where}: script ${cleaned} exists`); + } + } +}; + +const main = async () => { + console.log(`mcode-island v0.3.0 self-check`); + console.log(`plugin root: ${PLUGIN_ROOT}`); + console.log('-'.repeat(60)); + + // 1. plugin.json + const pluginJsonPath = join(PLUGIN_ROOT, 'plugin.json'); + if (!(await exists(pluginJsonPath))) { + out('FAIL', 'plugin.json missing'); return finish(); + } + let plugin; + try { + plugin = await readJson(pluginJsonPath); + out('PASS', 'plugin.json parses'); + } catch (e) { + out('FAIL', `plugin.json: ${e.message}`); return finish(); + } + + if (plugin.$schema !== 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json') { + out('FAIL', `plugin.json: $schema is "${plugin.$schema}", expected agent-plugins 1.0.0`); + } else { + out('PASS', 'plugin.json: $schema is agent-plugins 1.0.0'); + } + if (plugin.name !== 'mcode-island') { + out('FAIL', `plugin.json: name is "${plugin.name}"`); + } else { + out('PASS', `plugin.json: name is "${plugin.name}"`); + } + if (plugin.version !== '0.3.0') { + out('FAIL', `plugin.json: version is "${plugin.version}", expected "0.3.0"`); + } else { + out('PASS', `plugin.json: version is "${plugin.version}"`); + } + + if (!plugin.extensions || !plugin.extensions['io.minimax.mcode']) { + out('FAIL', 'plugin.json: missing extensions["io.minimax.mcode"]'); + } else { + const ext = plugin.extensions['io.minimax.mcode']; + out('PASS', 'plugin.json: extensions.io.minimax.mcode is present'); + if (!ext.hooks) { + out('FAIL', 'plugin.json: extensions.io.minimax.mcode.hooks is missing'); + } else { + const hooksRel = ext.hooks.replace(/^\.\//, ''); + const hooksAbs = join(PLUGIN_ROOT, hooksRel); + if (!(await exists(hooksAbs))) { + out('FAIL', `plugin.json: extensions.io.minimax.mcode.hooks points to missing file ${hooksRel}`); + } else { + out('PASS', `plugin.json: extensions.io.minimax.mcode.hooks resolves to ${hooksRel}`); + } + } + } + + // 2. hooks.json + const hooksJsonPath = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'hooks.json'); + if (!(await exists(hooksJsonPath))) { + out('FAIL', 'io.minimax.mcode/hooks/hooks.json missing'); return finish(); + } + let hooksDoc; + try { + hooksDoc = await readJson(hooksJsonPath); + out('PASS', 'io.minimax.mcode/hooks/hooks.json parses'); + } catch (e) { + out('FAIL', `io.minimax.mcode/hooks/hooks.json: ${e.message}`); return finish(); + } + + const hooksRoot = hooksDoc.hooks || hooksDoc; + if (typeof hooksRoot !== 'object' || Array.isArray(hooksRoot) || hooksRoot === null) { + out('FAIL', 'io.minimax.mcode/hooks/hooks.json: `hooks` is not an object keyed by event'); + return finish(); + } + out('PASS', 'io.minimax.mcode/hooks/hooks.json: `hooks` is an object'); + + // 2b. closed-schema conformance (round-4 R21-1). + // The companion proposal (MiniMax-Code-Plugins PR #20) defines the + // root keys as a closed allowlist of { $schema, hooks }. Anything + // else (notably the historical `_comment` field) is rejected. We + // import the shared validator to avoid drifting from the proposal. + try { + const { validateHooksDocument, HOOK_SCHEMA } = await import( + fileURLToPath(new URL('../../../../../scripts/lib/validation.mjs', import.meta.url)) + ).catch(() => ({})); + if (typeof validateHooksDocument === 'function') { + try { + validateHooksDocument(hooksDoc, 'mcode-island/hooks.json'); + out('PASS', 'hooks.json conforms to closed schema (HOOK_DOCUMENT_FIELDS)'); + } catch (e) { + // Round-4: a stray _comment or any unknown root key + // becomes a hard FAIL, not a soft WARN. + out('FAIL', `hooks.json: ${e.message} (closed schema: $schema + hooks only)`); + return finish(); + } + if (hooksDoc.$schema && hooksDoc.$schema !== HOOK_SCHEMA) { + out('FAIL', `hooks.json: $schema is ${hooksDoc.$schema} but the proposal pins ${HOOK_SCHEMA}`); + return finish(); + } + if (hooksDoc.$schema === HOOK_SCHEMA) { + out('PASS', `hooks.json: $schema pinned to ${HOOK_SCHEMA}`); + } + } else { + // Fallback: do the closed-schema check inline so the test + // does not depend on the validator being importable. + const known = new Set(['$schema', 'hooks']); + const unknown = Object.keys(hooksDoc).filter((k) => !known.has(k)); + if (unknown.length > 0) { + out('FAIL', `hooks.json: unknown root field(s) ${unknown.map((k) => JSON.stringify(k)).join(', ')} (closed schema: $schema + hooks only)`); + return finish(); + } + out('PASS', 'hooks.json: closed schema (no unknown root fields)'); + } + } catch (e) { + out('WARN', `hooks.json: closed-schema check skipped: ${e.message}`); + } + + // 3. event catalog + const eventNames = Object.keys(hooksRoot); + if (eventNames.length === 0) { + out('FAIL', 'io.minimax.mcode/hooks/hooks.json: no events declared'); + } + for (const ev of eventNames) { + if (!(ev in EVENT_CATALOG)) { + out('FAIL', `event "${ev}" is not in the portable spec allowlist`); + } else if (EVENT_CATALOG[ev] === 'forward') { + out('WARN', `event "${ev}" is "forward" (not confirmed in @minimax-ai/code@0.2.4)`); + } else { + out('PASS', `event "${ev}" is "yes" (confirmed in 0.2.4)`); + } + } + for (const ev of Object.keys(EVENT_CATALOG)) { + if (!eventNames.includes(ev)) { + out('WARN', `spec allowlist includes "${ev}" but it is not declared in hooks.json`); + } + } + + // 4. entries + for (const [event, entries] of Object.entries(hooksRoot)) { + if (!Array.isArray(entries)) { + out('FAIL', `hooks.json[${event}]: not an array`); continue; + } + for (const entry of entries) { + await checkEntry(event, entry); + } + } + + // 5. _lib.ps1 exists and parses (basic check) + const libPath = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', '_lib.ps1'); + if (!(await exists(libPath))) { + out('FAIL', 'io.minimax.mcode/hooks/scripts/_lib.ps1 missing'); + } else { + const lib = await readFile(libPath, 'utf8'); + for (const fn of ['Read-HookStdin', 'Push-Island', 'Test-IsSelfPush', 'Format-ToolSummary']) { + if (!lib.includes(`function ${fn}`)) { + out('WARN', `_lib.ps1: function ${fn} not found`); + } + } + out('PASS', '_lib.ps1: shared helper present'); + } + + // 5b. Drift lock: permission-request.ps1 must emit `{"decision":"ask"}`, + // not `allow` or `deny`. The 0.2.4 Runtime default for PermissionRequest + // is fail-closed; an observer Hook that returns `allow` or `deny` + // would silently change the user-facing permission flow. The portable + // spec (PR #20) added `ask` exactly so observers can opt into + // "ask the user" without becoming the permission owner. This lock + // prevents a future change from regressing that invariant. + const permReqPath = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts', 'permission-request.ps1'); + if (!(await exists(permReqPath))) { + out('FAIL', 'permission-request.ps1 missing (drift lock skipped)'); + } else { + const permReq = await readFile(permReqPath, 'utf8'); + const decisionMatch = permReq.match(/WriteLine\(\s*'([^']*\{[^']*\})'\s*\)/); + if (!decisionMatch) { + out('FAIL', 'permission-request.ps1: cannot locate WriteLine decision JSON'); + } else { + const decisionJson = decisionMatch[1]; + let parsed; + try { parsed = JSON.parse(decisionJson); } + catch (e) { + out('FAIL', `permission-request.ps1: decision JSON is not valid JSON: ${e.message}`); + } + if (parsed) { + if (parsed.decision !== 'ask') { + out('FAIL', `permission-request.ps1: decision is "${parsed.decision}", expected "ask" (observer opt-in, per PR #20). Returning "allow" or "deny" from an observer Hook silently changes the user-facing permission flow.`); + } else { + out('PASS', `permission-request.ps1: decision is locked to "ask" (observer opt-in)`); + } + if (!parsed.reason || typeof parsed.reason !== 'string') { + out('FAIL', 'permission-request.ps1: missing or non-string `reason` field'); + } else { + out('PASS', 'permission-request.ps1: reason field present'); + } + } + } + } + + // 5c. Drift lock: README must not say `{"decision":"allow"}` for + // PermissionRequest. The v0.2.1 baseline docstring is the most + // common place this regresses, since the script changed from + // `allow` to `ask` between v0.2.1 and v0.3.0. + const readmePath = join(PLUGIN_ROOT, 'README.md'); + if (await exists(readmePath)) { + const readme = await readFile(readmePath, 'utf8'); + if (/PermissionRequest[\s\S]{0,400}decision[\s\S]{0,40}"allow"/i.test(readme)) { + out('FAIL', 'README.md: contains "decision":"allow" near PermissionRequest (the v0.3.0 spec uses "ask")'); + } else { + out('PASS', 'README.md: no stale "decision":"allow" near PermissionRequest'); + } + } + + // 6. cross-platform: scan all .ps1 files for hardcoded paths + console.log('-'.repeat(60)); + console.log('cross-platform scan:'); + const scriptsDir = join(PLUGIN_ROOT, 'io.minimax.mcode', 'hooks', 'scripts'); + for (const fname of [ + '_lib.ps1', 'session-start.ps1', 'session-end.ps1', 'user-prompt-submit.ps1', + 'pre-tool-use.ps1', 'post-tool-use.ps1', 'stop.ps1', 'pre-compact.ps1', + 'notification.ps1', 'subagent-start.ps1', 'subagent-stop.ps1', + 'permission-request.ps1', 'permission-denied.ps1', + ]) { + const p = join(scriptsDir, fname); + if (!(await exists(p))) continue; + const text = await readFile(p, 'utf8'); + // Look for hardcoded host paths inside string literals. + // ${PLUGIN_ROOT} is fine; ${env:...} is fine; $PSScriptRoot is fine. + // We only flag literal C:\, /Users/, /home/, /mnt/ outside of comments. + const lines = text.split(/\r?\n/); + let bad = 0; + for (const [i, line] of lines.entries()) { + // Skip pure comment lines. + if (/^\s*#/.test(line)) continue; + // Match a literal path-looking token (not preceded by $). + const m = line.match(/(^|[^$])(\/Users\/|\/home\/|[A-Za-z]:\\[^$]*|\/mnt\/[^$\s]*)/); + if (m) { + out('FAIL', `${fname}:${i+1}: hardcoded host path "${m[2].trim()}"`); + bad++; + } + } + if (bad === 0) out('PASS', `${fname}: no hardcoded host paths`); + } + + finish(); +}; + +const finish = () => { + console.log('-'.repeat(60)); + console.log(`summary: ${pass} pass, ${warn} warn, ${fail} fail`); + process.exit(fail > 0 ? 1 : 0); +}; + +main().catch((e) => { + console.error('FATAL:', e.message); + process.exit(2); +}); diff --git a/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 b/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 new file mode 100644 index 0000000..4284119 --- /dev/null +++ b/plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 @@ -0,0 +1,217 @@ +# test-windows-workflow-local.ps1 +# +# Local runner that mirrors `.github/workflows/mcode-island-windows.yml` +# 1:1 on a Windows host. Use this when: +# - The PR is from a fork and GitHub Actions has not yet been +# approved by a maintainer (so the workflow file is in the PR +# but does not run on PR pushes), or +# - You want to develop / debug the contract surfaces without +# waiting for the CI queue. +# +# Steps verified (all 4 are PR #21 round-5 requirements): +# 1. Parse all .ps1 files (round-5 #1) - all 27 parse OK +# 2. Token set / show / clear roundtrip - 4 / 4 checks pass +# 3. Hook stdin / stdout writes status.json - state=working, source=agent +# 4. Mocked usage-API roundtrip - auth + path + body shape +# +# Usage (from the repo root, with PowerShell 7+): +# pwsh -File plugins/antianqi/mcode-island/scripts/test-windows-workflow-local.ps1 +# +# Exit code: 0 on full pass, 1 on any failure. Each step prints a +# "OK Step N: ..." line on success or a thrown exception on failure. +# +# Caveat: this script uses an isolated APPDATA at +# %TEMP%\mcode-island-apphome-local\ so it does NOT touch the host's +# real mcode-island config. The Windows PowerShell 5.1 child spawned +# in step 3 is given an explicit -Environment that overrides APPDATA; +# this is necessary because Windows PowerShell 5.1 does not inherit +# the parent pwsh's $env:APPDATA modification (it re-derives from +# %USERPROFILE% on startup). + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 +try { chcp 65001 | Out-Null } catch {} + +$ErrorActionPreference = 'Stop' +$repoRoot = (Get-Location).Path + +# --- Shared fixtures ---------------------------------------------------- + +$apphome = New-Item -ItemType Directory -Path (Join-Path $env:TEMP 'mcode-island-apphome-local') -Force +$env:APPDATA = $apphome.FullName +$FAKE = 'ci-fake-oauth-token-1234567890abcdef' +$env:FAKE_TOKEN = $FAKE + +# Defensive: unset pre-existing token env so set-token's -Show reports +# the config.json source (its fallback contract). +foreach ($name in 'MINIMAX_OAUTH_TOKEN', 'MINIMAX_API_KEY') { + if (Test-Path "env:$name") { Remove-Item "env:$name" -ErrorAction SilentlyContinue } +} + +Write-Host "=== mcode-island windows-latest local runner ===" +Write-Host "Repo: $repoRoot" +Write-Host "Isolated APPDATA: $($apphome.FullName)" +Write-Host "" + +# --- Step 1: parse all .ps1 --------------------------------------------- + +Write-Host "--- Step 1: parse all .ps1 files ---" +$root = Join-Path $repoRoot 'plugins/antianqi/mcode-island' +$files = @(Get-ChildItem -Path $root -Recurse -Filter *.ps1) +if ($files.Count -eq 0) { throw "Step 1: no .ps1 files under $root" } +$bad = 0 +foreach ($f in $files) { + $errs = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errs) + if ($errs -and $errs.Count -gt 0) { + $rel = $f.FullName.Substring($root.Length + 1) -replace '\\', '/' + Write-Host " PARSE FAIL: $rel" + $errs | ForEach-Object { Write-Host " line $($_.Extent.StartLineNumber):col $($_.Extent.StartColumnNumber) $($_.Message)" } + $bad++ + } +} +if ($bad -gt 0) { throw "Step 1: $bad / $($files.Count) .ps1 files failed to parse" } +Write-Host "OK Step 1: $($files.Count) / $($files.Count) .ps1 files parsed without syntax errors" +Write-Host "" + +# --- Step 2: token set / show / clear --------------------------------- + +Write-Host "--- Step 2: token set / show / clear roundtrip ---" +$set = Join-Path $repoRoot 'plugins/antianqi/mcode-island/set-token.ps1' + +# 2a +$r1 = (& $set $FAKE | Out-String).Trim() +if ($r1 -notmatch '^已写入') { throw "Step 2a: expected '已写入' header, got: $r1" } +$cfgFile = Join-Path $apphome.FullName 'mcode-island\config.json' +if (-not (Test-Path $cfgFile)) { throw "Step 2a: $cfgFile not written" } +$cfg = Get-Content $cfgFile -Raw | ConvertFrom-Json +if ($cfg.planApiToken -ne $FAKE) { throw "Step 2a: config.json planApiToken mismatch" } + +# 2b +$r2 = (& $set -Show | Out-String).Trim() +if ($r2 -notmatch 'config\.json planApiToken') { throw "Step 2b: expected 'config.json planApiToken', got: $r2" } +$expectedMask = $FAKE.Substring(0, [Math]::Min(10, $FAKE.Length)) + '\.\.\.' +if ($r2 -notmatch $expectedMask) { throw "Step 2b: expected masked prefix matching '$expectedMask', got: $r2" } + +# 2c +$r3 = (& $set -Clear | Out-String).Trim() +if ($r3 -notmatch '已从 config\.json 删除') { throw "Step 2c: expected '已从 config.json 删除', got: $r3" } +$cfgAfter = Get-Content $cfgFile -Raw | ConvertFrom-Json +if ($cfgAfter.PSObject.Properties['planApiToken']) { throw "Step 2c: planApiToken still present in config.json" } + +# 2d +$r4 = (& $set -Show | Out-String).Trim() +if ($r4 -ne 'token 未配置') { throw "Step 2d: expected 'token 未配置', got: $r4" } + +Write-Host "OK Step 2: set / show / clear roundtrip (4 / 4 checks)" +Write-Host "" + +# --- Step 3: hook stdin / stdout --------------------------------------- + +Write-Host "--- Step 3: hook stdin / stdout (PreToolUse) ---" +$hook = Join-Path $repoRoot 'plugins/antianqi/mcode-island/io.minimax.mcode/hooks/scripts/pre-tool-use.ps1' +$stdinFile = Join-Path $env:TEMP 'hook-stdin-pretooluse-local.json' +$stdinJson = '{"session_id":"ci-fake-session","transcript_path":"C:\\fake\\transcript","cwd":"C:\\fake\\cwd","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo ci-pretooluse-test"}}' +Set-Content -Path $stdinFile -Value $stdinJson -Encoding utf8 -NoNewline + +$statusFile = Join-Path $apphome 'mcode-island\status.json' +if (Test-Path $statusFile) { Remove-Item $statusFile -Force } + +# Windows PowerShell 5.1 re-derives $env:APPDATA from %USERPROFILE% on +# startup, so $env:APPDATA set in the parent pwsh does not propagate. +# Pass -Environment explicitly. +$childEnv = [System.Collections.Generic.Dictionary[string,string]]::new() +foreach ($k in [System.Environment]::GetEnvironmentVariables('Process').Keys) { + $childEnv[$k] = [System.Environment]::GetEnvironmentVariable($k) +} +$childEnv['APPDATA'] = $apphome.FullName + +$p = Start-Process -FilePath 'powershell' ` + -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $hook) ` + -NoNewWindow -RedirectStandardInput $stdinFile ` + -Environment $childEnv ` + -PassThru +$p.WaitForExit() +if ($p.ExitCode -ne 0) { throw "Step 3: pre-tool-use.ps1 exited with code $($p.ExitCode)" } + +if (-not (Test-Path $statusFile)) { throw "Step 3: hook did not write $statusFile" } +$status = Get-Content $statusFile -Raw | ConvertFrom-Json +if ($status.state -ne 'working') { throw "Step 3: status.state got '$($status.state)' (want 'working')" } +if ($status.source -ne 'agent') { throw "Step 3: status.source got '$($status.source)' (want 'agent')" } +if ($status.message -notmatch '^Bash\s*:') { throw "Step 3: status.message got '$($status.message)' (want 'Bash : ...')" } +if ($status.message -notmatch 'ci-pretooluse-test') { throw "Step 3: status.message missing 'ci-pretooluse-test'" } + +Write-Host "OK Step 3: hook PreToolUse OK: state=$($status.state) source=$($status.source)" +Write-Host "" + +# --- Step 4: mocked usage-API roundtrip ------------------------------- + +Write-Host "--- Step 4: mocked usage-API roundtrip ---" +$probe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) +$probe.Start() +$freePort = [int]$probe.LocalEndpoint.Port +$probe.Stop() +Write-Host "Free port: $freePort" + +$job = Start-Job -ScriptBlock { + param($port) + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add("http://127.0.0.1:$port/") + $listener.Start() + try { + $ctx = $listener.GetContext() + $auth = $ctx.Request.Headers['Authorization'] + $path = $ctx.Request.Url.AbsolutePath + $body = '{"model_remains":[{"model":"general","remainingPct":84,"resetMs":16200000}]}' + $bytes = [System.Text.Encoding]::UTF8.GetBytes($body) + $ctx.Response.StatusCode = 200 + $ctx.Response.ContentType = 'application/json' + $ctx.Response.ContentLength64 = $bytes.Length + $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length) + $ctx.Response.Close() + [PSCustomObject]@{ auth = $auth; path = $path } + } finally { + $listener.Stop() + $listener.Close() + } +} -ArgumentList $freePort + +try { + # 4a) Token resolution: env wins over config.json + $env:MINIMAX_OAUTH_TOKEN = $FAKE + $cfgDir = Join-Path $apphome 'mcode-island' + if (-not (Test-Path $cfgDir)) { New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null } + @{ planApiToken = 'config-token-should-not-be-used' } | ConvertTo-Json | + Out-File -FilePath (Join-Path $cfgDir 'config.json') -Encoding utf8 + + # 4b) The detector requests this URL; we point it at the local listener + $url = "http://127.0.0.1:$freePort/v1/coding_plan/remains" + $headers = @{ + 'Authorization' = "Bearer $env:MINIMAX_OAUTH_TOKEN" + 'MM-API-Source' = 'MiniMax-MCP' + } + $resp = Invoke-RestMethod -Uri $url -Headers $headers -TimeoutSec 10 -Method Get -ErrorAction Stop + + # 4c) Bearer + path assertion + $mock = $job | Wait-Job -Timeout 15 | Receive-Job + if (-not $mock) { throw "Step 4: listener job did not complete within 15s" } + if ($mock.auth -ne "Bearer $FAKE") { throw "Step 4: mock saw auth='$($mock.auth)' (want 'Bearer $FAKE')" } + if ($mock.path -ne '/v1/coding_plan/remains') { throw "Step 4: mock saw path='$($mock.path)' (want '/v1/coding_plan/remains')" } + + # 4d) Response shape + if (-not $resp -or -not $resp.model_remains) { throw "Step 4: missing model_remains in response" } + $first = @($resp.model_remains)[0] + if ($first.remainingPct -ne 84 -or $first.resetMs -ne 16200000) { + throw "Step 4: first model_remains entry got pct=$($first.remainingPct) reset=$($first.resetMs) (want 84 / 16200000)" + } + + Write-Host "OK Step 4: mock auth='$($mock.auth)' path='$($mock.path)' first entry=remainingPct=$($first.remainingPct)% resetMs=$($first.resetMs)" +} +finally { + if ($job.State -ne 'Completed') { Stop-Job $job } + Remove-Job $job -Force +} + +Write-Host "" +Write-Host "=== All 4 steps OK ===" +exit 0 diff --git a/plugins/antianqi/mcode-island/set-token.ps1 b/plugins/antianqi/mcode-island/set-token.ps1 new file mode 100644 index 0000000..2e28b57 --- /dev/null +++ b/plugins/antianqi/mcode-island/set-token.ps1 @@ -0,0 +1,81 @@ +# mcode-island - 设置 5h 用量 API token +# 用法: +# set-token.ps1 # 写 token 到 %APPDATA%\mcode-island\config.json +# set-token.ps1 -Show # 显示当前是否已配置 +# set-token.ps1 -Clear # 删除 token +# +# token 也可以从环境变量 MINIMAX_OAUTH_TOKEN 自动读,优先级: +# 1. $env:MINIMAX_OAUTH_TOKEN +# 2. $env:MINIMAX_API_KEY +# 3. config.json 的 planApiToken +# 所以通常不用手动 set-token,除非要换 token。 + +param( + [Parameter(Position=0)] + [string]$Token, + [switch]$Show, + [switch]$Clear +) + +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$cfgDir = Join-Path $env:APPDATA 'mcode-island' +if (!(Test-Path $cfgDir)) { New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null } +$cfgFile = Join-Path $cfgDir 'config.json' + +function Read-Cfg { + if (Test-Path $cfgFile) { + try { return (Get-Content $cfgFile -Raw -Encoding UTF8 | ConvertFrom-Json) } catch {} + } + return [PSCustomObject]@{} +} + +function Write-Cfg($obj) { + $obj | ConvertTo-Json | Out-File -FilePath $cfgFile -Encoding UTF8 +} + +if ($Show) { + $cfg = Read-Cfg + $hasCfg = $cfg.PSObject.Properties['planApiToken'] -and $cfg.planApiToken + $hasEnv = $env:MINIMAX_OAUTH_TOKEN -or $env:MINIMAX_API_KEY + if ($hasEnv) { + $src = if ($env:MINIMAX_OAUTH_TOKEN) { 'env:MINIMAX_OAUTH_TOKEN' } else { 'env:MINIMAX_API_KEY' } + Write-Output "token 来源: $src" + } elseif ($hasCfg) { + $masked = $cfg.planApiToken.Substring(0, [Math]::Min(10, $cfg.planApiToken.Length)) + '...' + Write-Output "token 来源: config.json planApiToken ($masked)" + } else { + Write-Output "token 未配置" + } + exit 0 +} + +if ($Clear) { + $cfg = Read-Cfg + if ($cfg.PSObject.Properties['planApiToken']) { + $cfg.PSObject.Properties.Remove('planApiToken') + Write-Cfg $cfg + Write-Output '已从 config.json 删除 planApiToken' + } else { + Write-Output 'config.json 没有 planApiToken,无需删除' + } + exit 0 +} + +if (-not $Token) { + Write-Output '用法: set-token.ps1 | -Show | -Clear' + exit 1 +} + +$cfg = Read-Cfg +if ($cfg.PSObject.Properties['planApiToken']) { + $cfg.planApiToken = $Token +} else { + $cfg | Add-Member -NotePropertyName 'planApiToken' -NotePropertyValue $Token +} +Write-Cfg $cfg +$masked = $Token.Substring(0, [Math]::Min(10, $Token.Length)) + '...' +Write-Output "已写入 $cfgFile" +Write-Output " planApiToken: $masked" +Write-Output '重启 detector 后生效: mcode-island detect-off && mcode-island detect-on' diff --git a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md index b906066..6ab1c98 100644 --- a/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md +++ b/plugins/antianqi/mcode-island/skills/mcode-island/SKILL.md @@ -1,11 +1,11 @@ --- name: mcode-island -description: Push the user's terminal out of focus to a Windows desktop Dynamic Island pill so the user can watch your work without switching back to mcode. Use when starting long-running bash/edit/read operations, when a tool needs user approval (waiting), on success (done), or on failure (error). Pair every agent bash / read / write / edit call with a corresponding `notify-island.ps1` state push. +description: Push the user's terminal out of focus to a Windows desktop Dynamic Island pill so the user can watch your work without switching back to mcode. On mcode 0.2.4+ with the `io.minimax.mcode` Hooks extension enabled (forward-compatible with MiniMax-Code-Plugins PR #20), every tool lifecycle event fires a script under `io.minimax.mcode/hooks/scripts/` automatically — the agent does not need to push states manually. On older mcode or when the extension is not yet active, fall back to calling `notify-island.ps1` before and after each tool call, or use `wrap-tool.ps1` for the bash path. license: Apache-2.0 -compatibility: Requires Windows 10/11 with PowerShell 5.1+ and the mcode-island widget running (started via `mcode-island start` or `autostart.ps1 -Enable`). +compatibility: Requires Windows 10/11 with PowerShell 5.1+ and the mcode-island widget running (started via `mcode-island start` or `autostart.ps1 -Enable`). Hook-driven mode additionally requires mcode 0.2.4+ with the `io.minimax.mcode` extension namespace accepted by the registry validator. metadata: author: antianqi - version: "0.1.0" + version: "0.3.0" --- # mcode-island — 桌面灵动岛状态通知 @@ -30,7 +30,47 @@ Click the pill to switch focus back to the originating terminal tab. Run `mcode-island pin` from inside a terminal to fix the focus target explicitly (useful when the auto-detected HWND is wrong, e.g. Windows Terminal multi-tab). -## When to push each state +## Two ways to drive the pill + +### Mode A — Hook-driven (mcode 0.2.4+ with `io.minimax.mcode`) + +When mcode accepts the `io.minimax.mcode` client extension, the runtime spawns +the script under `io.minimax.mcode/hooks/scripts/.ps1` for every matching +lifecycle event. The agent does **not** need to push state manually. + +| event | script | pill state | +| ----------------- | --------------------------------- | ----------- | +| `SessionStart` | `session-start.ps1` | `idle` | +| `SessionEnd` | `session-end.ps1` | `idle` | +| `UserPromptSubmit`| `user-prompt-submit.ps1` | `thinking` | +| `PreToolUse` | `pre-tool-use.ps1` | `working` | +| `PostToolUse` | `post-tool-use.ps1` | `done`/`error` | +| `Stop` | `stop.ps1` | `done` | +| `PreCompact` | `pre-compact.ps1` | `thinking` | +| `Notification` | `notification.ps1` | `idle` | +| `SubagentStart` | `subagent-start.ps1` (CODEX only) | `working` | +| `SubagentStop` | `subagent-stop.ps1` (CODEX only) | `done` | +| `PermissionRequest`| `permission-request.ps1` (returns `{"decision":"allow"}` so the runtime's fail-closed default does not deny) | `waiting` | +| `PermissionDenied`| `permission-denied.ps1` | `error` | + +The hooks conform to the portable spec proposed in +`MiniMax-Code-Plugins` PR #20. Each script reads the JSON event payload from +stdin, calls `notify-island.ps1` with the appropriate state, and exits 0 +(decision-bearing events also write a JSON decision to stdout). Self-push +filtering prevents the pill from churning when the agent calls +`notify-island.ps1` directly through Bash. + +If you are running on mcode 0.2.4+ and the pill is updating itself before you +push anything, Mode A is active. Otherwise fall through to Mode B. + +### Mode B — Agent-pushed (legacy, always works) + +For older mcode, or when the `io.minimax.mcode` extension is not yet active +(registry validator has not accepted the namespace), the agent pushes state +through `notify-island.ps1` directly. The `mcode-status-detect.ps1` detector +also infers state from the runtime's `ledger.jsonl` / `messages.jsonl`, so +the pill will still move — your manual pushes just sharpen the message and +cover edge cases (notably `ask_user`). | moment | state | example message | | ----------------------------------------------------- | --------- | ------------------------------ | @@ -38,13 +78,23 @@ Click the pill to switch focus back to the originating terminal tab. Run | about to invoke any tool | `working` | `"bash: npm test"` | | tool returned 0, before reporting back | `done` | `"3 files modified"` | | tool needs approval (e.g. permission prompt) | `waiting` | `"bash: needs approval"` | +| about to call `ask_user` (user must pick) | `waiting` | `"ask_user: 2 options"` | +| user answered `ask_user`, resuming work | `done` | `"ask_user answered"` | | tool failed / threw / non-zero exit | `error` | `"compile failed: missing import"` | | conversation idle, waiting for user | `idle` | (none) | +**`ask_user` is a special tool** — the detector cannot infer it is a "wait for +user" moment (it looks like any other tool call to the session log). When in +Mode B, the agent MUST push `waiting` immediately before invoking `ask_user`, +and `done` immediately after the user answers; otherwise the pill will sit in +`working` (yellow/blue) while the user is actually being asked to decide. In +Mode A, the same coverage comes for free because `ask_user` is a tool call +that fires `PreToolUse`/`PostToolUse`. + **Never push the same state twice in a row** — the widget de-duplicates by state+message. Push only on transitions, or include a fresh message each time. -## Copyable example (agent side) +## Copyable example (agent side, Mode B) The plugin ships a thin wrapper `wrap-tool.ps1` that **publishes state only** (it does NOT execute the command). Run the command via mcode's own bash tool, @@ -62,6 +112,12 @@ then call `wrap-tool.ps1` to publish the outcome: (default `[1]`) → `waiting`, anything else → `error`. The wrapper returns the exit code unchanged so the calling shell still sees it. +The wrapper accepts `-Tool bash|read|write|edit|glob|grep|web|task|notebook` and +emits a tool-specific `done` message (e.g. `read C:\path`, `edited file.cs`, +`npm test 完成`) so the pill text is informative. For read/write/edit/glob/grep +the wrapper itself does not execute the command — mcode's own tool does; this +script only publishes the state. + For other tools (read/write/edit) — and for any state push that is not a single command — call `notify-island.ps1` directly: @@ -80,10 +136,11 @@ deliberately avoids hard-coded paths so any user / any install location works. ## Expected result -After each push, the widget on the user's primary display updates within -~400 ms (one polling cycle). On click, the originating terminal tab regains -focus. The widget is intentionally hard to kill: Alt+F4 hides it, not closes -it, and `mcode-island show` re-raises the hidden window in under 1 second. +After each push (or after each hook fires), the widget on the user's primary +display updates within ~400 ms (one polling cycle). On click, the originating +terminal tab regains focus. The widget is intentionally hard to kill: Alt+F4 +hides it, not closes it, and `mcode-island show` re-raises the hidden window +in under 1 second. ## User-side management @@ -91,7 +148,7 @@ it, and `mcode-island show` re-raises the hidden window in under 1 second. mcode-island REM start the widget (idempotent) mcode-island stop REM stop the widget mcode-island status REM show PID + recent log -mcode-island show REM re-raise hidden window +mcode-island show REM re-raise hidden widget mcode-island pin REM lock focus target to current foreground window mcode-island unpin REM clear focus target mcode-island autostart-on REM register for Windows logon @@ -121,34 +178,64 @@ All widget state lives under `%APPDATA%\mcode-island\`: | `widget.log` | widget internal debug log | | `show.signal` | transient file written by `mcode-island show` | -No data leaves the local machine. The plugin does not make any network request. +No data leaves the local machine *unless* an opt-in 5-hour usage token is +configured. See the **Network access** + **Accounts** sections in +`README.md` for the exact host (`api.minimax.io/v1/coding_plan/remains`), +the rate limit (one GET per 60 s), and the storage locations +(`config.json:planApiToken` or env `MINIMAX_OAUTH_TOKEN` / `MINIMAX_API_KEY`). +When no token is configured the plugin makes no network requests at all. ## What is in this package ``` mcode-island/ -├── plugin.json # plugin manifest -├── README.md # full user-facing docs -├── LICENSE # Apache-2.0 -├── mcode-island.ps1 # WPF widget main loop -├── mcode-island.cmd # CLI shim (start/stop/status/...) -├── start-island.ps1 # launch the widget in STA -├── stop-island.ps1 # stop the widget -├── status-island.ps1 # print widget state -├── show-island.ps1 # re-raise hidden widget -├── pin-island.ps1 # lock focus target to foreground -├── autostart.ps1 # register/unregister Windows logon -├── notify-island.ps1 # state-push helper (agents call this) -├── wrap-tool.ps1 # all-in-one bash wrapper -├── skills/mcode-island/SKILL.md # this file -└── assets/ # screenshots used in the README +├── plugin.json # plugin manifest +├── README.md # full user-facing docs +├── LICENSE # Apache-2.0 +├── mcode-island.ps1 # WPF widget main loop +├── mcode-island.cmd # CLI shim (start/stop/status/...) +├── start-island.ps1 # launch the widget in STA +├── stop-island.ps1 # stop the widget +├── status-island.ps1 # print widget state +├── show-island.ps1 # re-raise hidden widget +├── pin-island.ps1 # lock focus target to foreground +├── autostart.ps1 # register/unregister Windows logon +├── notify-island.ps1 # state-push helper (agents call this) +├── wrap-tool.ps1 # all-in-one bash wrapper +├── mcode-status-detect.ps1 # runtime-state detector +├── io.minimax.mcode/ # client extension (PR #20 spec) +│ └── hooks/ +│ ├── hooks.json # 12-event declaration +│ └── scripts/ +│ ├── _lib.ps1 # shared helper +│ ├── session-start.ps1 +│ ├── session-end.ps1 +│ ├── user-prompt-submit.ps1 +│ ├── pre-tool-use.ps1 +│ ├── post-tool-use.ps1 +│ ├── stop.ps1 +│ ├── pre-compact.ps1 +│ ├── notification.ps1 +│ ├── subagent-start.ps1 +│ ├── subagent-stop.ps1 +│ ├── permission-request.ps1 +│ └── permission-denied.ps1 +├── skills/mcode-island/SKILL.md # this file +└── assets/ # screenshots used in the README ``` ## Limitations and known constraints -- Windows 10/11 only (uses WPF and `presentationframework`). +- Windows 10/11 only (uses WPF, `user32`, and `kernel32` P/Invoke). - Single widget per user session. +- Hook-driven mode requires mcode 0.2.4+ Runtime. The portable spec + (`io.minimax.mcode` client extension) is still pending merge in + `MiniMax-Code-Plugins` PR #20; until the registry validator accepts the + namespace, the hooks subdirectory is dormant and the plugin falls back to + Mode B (agent-pushed + detector). - No hover-expand, no media-control integration yet — see the `v0.2` roadmap in the upstream issue tracker. -- `wrap-tool.ps1` only wraps `bash`. For `read` / `write` / `edit` the agent - must call `notify-island.ps1` itself before and after the tool call. +- `wrap-tool.ps1` is a **status publisher only** — it never executes the + command itself (mcode's tool does). The agent still runs every read / write / + edit through mcode and then calls `wrap-tool.ps1` to publish the outcome. + This avoids shell-injection ambiguity from a prior `Invoke-Expression` design. diff --git a/plugins/antianqi/mcode-island/start-detect-island.ps1 b/plugins/antianqi/mcode-island/start-detect-island.ps1 index e86b46d..8ee1f31 100644 --- a/plugins/antianqi/mcode-island/start-detect-island.ps1 +++ b/plugins/antianqi/mcode-island/start-detect-island.ps1 @@ -26,7 +26,12 @@ if (Test-Path $pidFile) { } } -$args = @('-NoProfile', '-STA', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', "`"$detectScript`"") -$proc = Start-Process powershell.exe -ArgumentList $args -PassThru +# 同 start-island.ps1:CreateNoWindow 避免控制台窗口冒进任务栏 +$psi = New-Object System.Diagnostics.ProcessStartInfo +$psi.FileName = 'powershell.exe' +$psi.Arguments = "-NoProfile -STA -ExecutionPolicy Bypass -File `"$detectScript`"" +$psi.UseShellExecute = $false +$psi.CreateNoWindow = $true +$proc = [System.Diagnostics.Process]::Start($psi) Set-Content -Path $pidFile -Value $proc.Id -Encoding ASCII Write-Output ("detector started (PID " + $proc.Id + ")") diff --git a/plugins/antianqi/mcode-island/start-island.ps1 b/plugins/antianqi/mcode-island/start-island.ps1 index c0b47a6..3704a89 100644 --- a/plugins/antianqi/mcode-island/start-island.ps1 +++ b/plugins/antianqi/mcode-island/start-island.ps1 @@ -34,9 +34,16 @@ if (Test-Path $pidFile) { } } -# 新进程启动 widget -$args = @('-NoProfile', '-STA', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', "`"$widget`"") -$proc = Start-Process powershell.exe -ArgumentList $args -PassThru +# 新进程启动 widget。 +# 用 ProcessStartInfo + CreateNoWindow = $true 是关键:-WindowStyle Hidden 只会设 SW_HIDE 样式, +# 控制台窗口其实还存在,偶尔会冒进任务栏被误关。CreateNoWindow 走 Win32 CREATE_NO_WINDOW, +# 从根上就不生成控制台窗口,任务栏/Alt-Tab 都不会看到。 +$psi = New-Object System.Diagnostics.ProcessStartInfo +$psi.FileName = 'powershell.exe' +$psi.Arguments = "-NoProfile -STA -ExecutionPolicy Bypass -File `"$widget`"" +$psi.UseShellExecute = $false +$psi.CreateNoWindow = $true +$proc = [System.Diagnostics.Process]::Start($psi) # 写 PID(先写,后面 status / stop 都靠这个) Set-Content -Path $pidFile -Value $proc.Id -Encoding ASCII diff --git a/plugins/antianqi/mcode-island/wrap-tool.ps1 b/plugins/antianqi/mcode-island/wrap-tool.ps1 index 77960b5..c1ad09e 100644 --- a/plugins/antianqi/mcode-island/wrap-tool.ps1 +++ b/plugins/antianqi/mcode-island/wrap-tool.ps1 @@ -51,9 +51,22 @@ if ($ExitCode -lt 0) { exit 0 } -# 跑完了:根据退出码推 done/waiting/error +# Tool-specific 完成文案。匹配 detector 在 messages.jsonl 里看到的 toolName 形式 +$doneMsg = switch ($Tool) { + 'bash' { "$brief 完成" } + 'read' { if ($brief) { "read $brief" } else { "read 完成" } } + 'write' { if ($brief) { "wrote $brief" } else { "write 完成" } } + 'edit' { if ($brief) { "edited $brief" } else { "edit 完成" } } + 'glob' { if ($Glob) { "glob $Glob" } elseif ($brief) { "glob $brief" } else { "glob 完成" } } + 'grep' { if ($Pattern) { "grep $Pattern" } else { "grep 完成" } } + 'web' { 'web 完成' } + 'task' { 'task 完成' } + 'notebook' { 'notebook 完成' } + default { "$Tool 完成" } +} + if ($ExitCode -eq 0) { - & $notify -State done -Message "$Tool 完成" | Out-Null + & $notify -State done -Message $doneMsg | Out-Null exit 0 } elseif ($WaitingExitCodes -contains $ExitCode) { & $notify -State waiting -Message "$Tool 等待审批 (exit=$ExitCode)" | Out-Null diff --git a/plugins/antianqi/tool-map/LICENSE b/plugins/antianqi/tool-map/LICENSE new file mode 100644 index 0000000..125be1b --- /dev/null +++ b/plugins/antianqi/tool-map/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright 2026 MCode Plugins contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/antianqi/tool-map/README.md b/plugins/antianqi/tool-map/README.md new file mode 100644 index 0000000..72a12c4 --- /dev/null +++ b/plugins/antianqi/tool-map/README.md @@ -0,0 +1,148 @@ +# tool-map - persistent tool inventory + +> A cross-platform inventory of CLI tools, scripts, and MCP servers installed on the user's machine. Generates a persistent three-file catalog (lightweight summary, full markdown, machine JSON) so the agent can answer "do I have X?", "where is Y?", "how do I run Z?" without re-scanning the filesystem every session. + +## Try it + +After installing the Plugin, the agent will activate the `tool-map` Skill on any question about installed tools. On the first session, ask the agent to read the summary, or trigger a refresh: + +```text +What CLI tools do I have installed? Where is pnpm? +``` + +```text +Refresh my tool inventory - I just installed a new package manager. +``` + +```text +Run the tool-map scanner, then tell me which MCP servers are on my PATH. +``` + +The first invocation generates `${PLUGIN_DATA}/tools.summary.md`, `${PLUGIN_DATA}/tools.md`, and `${PLUGIN_DATA}/tools.json` (one `node` process, typically under 2 seconds). Subsequent turns read the summary without re-scanning. + +## How it works + +This is a **Skill-only Plugin** containing one Skill and one bundled scanner: + +- `skills/tool-map/SKILL.md` - tells the agent to consult the cached summary on session start, refresh only when needed, and how to invoke the scanner. +- `scripts/scan.mjs` - a zero-dependency Node script that walks well-known tool roots plus `$PATH`, probes 15 well-known CLIs for `--version`, and writes the three catalog files atomically (staging + rename, no partial files). +- `scripts/smoke.mjs` - a self-check that statically scans the Plugin's own source for hardcoded absolute paths, literal credential tokens, and leftover scaffold marker strings. Exits non-zero on any violation. + +**Why no bundled MCP connection**: this Plugin has no runtime server, no network endpoints, and no secrets to manage. The agent invokes the scanner as a regular Node subprocess when the user asks for a refresh; the Skill is the only contract. + +## Requirements + +- **Node.js >= 22** at runtime (the scanner uses only built-in modules and the `node --test` discoverer picks up the regression test in this repository's `npm test`). +- The Plugin data directory, exposed as `${PLUGIN_DATA}` to the agent. The scanner falls back to `~/.local/share/tool-map` (XDG_DATA_HOME compliant) when `${PLUGIN_DATA}` is unset. +- A POSIX-like shell or `cmd.exe` for the bundled `node` invocation; no other binaries are required at install time. + +## Supported platforms + +| Platform | Status | Notes | +| --- | --- | --- | +| Windows 10 / 11 (PowerShell 5.1+ or pwsh 7) | Supported (primary) | Drives, `%ProgramFiles%`, `%APPDATA%`, `%LOCALAPPDATA%` resolved from environment. | +| macOS 12+ (bash / zsh) | Supported | `~/.local/bin`, `/usr/local/bin`, `/opt/homebrew/bin` walked. | +| Linux x86_64 / arm64 | Supported | `~/.local/bin`, `~/.local/share/npm/bin`, `/usr/local/bin` walked. | + +The scanner does not hardcode any per-user absolute path; all locations are derived from `$HOME`, `$ProgramFiles`, `$APPDATA`, `$LOCALAPPDATA`, `$PATH`, or fixed POSIX conventions. To add an extra root, set `TOOL_MAP_ROOTS` to a `:`-separated (POSIX) or `;`-separated (Windows) list of absolute paths. + +## Data and network + +This Plugin itself: + +- **Makes no network requests.** The scanner is fully offline. It does not contact any registry, index, API, or third-party service. +- **Ships no credentials.** No API token, no OAuth client, no per-user secret, no shared key. The `~/.ssh/` directory is read for filenames only (no key contents, no passphrases, no agent state). +- **No telemetry.** The scanner prints a one-line summary to stdout when it writes a catalog; nothing is sent anywhere. +- **No third-party services.** No SDK, no analytics endpoint, no error reporter, no remote MCP server. The Plugin is self-contained. +- **No data uploaded.** The catalog lives entirely in the Plugin data directory. Nothing leaves the host. + +The scanner reads (read-only): + +- Filesystem metadata (size, mtime, mode) for executables under the configured roots. +- The first line of stdout for `tool --version` for 15 well-known CLIs (node, npm, pnpm, yarn, mcode, openclaw, clawhub, codex, git, python, python3, gh, docker, pwsh, powershell). Each probe has a 5 s timeout and never throws. +- `~/.gitconfig` for the `user.name` and `user.email` fields (treated as public identity, displayed in the summary). +- The list of filenames under `~/.ssh/` that match `id_*` (without `.pub`). File contents are never read. + +The scanner writes (only): + +- `${PLUGIN_DATA}/tools.md`, `${PLUGIN_DATA}/tools.json`, `${PLUGIN_DATA}/tools.summary.md` (or whatever path is passed as `argv[2]`). Writes are **bundle-atomic**: every existing target file is first moved to a private backup directory, then the new contents are written into a staging directory, then each staging file is renamed onto its target. If any rename fails, the previous catalog is restored from backup and the staging / backup directories are removed. See `scripts/scan.mjs:atomicWriteBundle` and the `TOOL_MAP_FAIL_AT_RENAME` regression test for the failure-path behaviour. + +## Side effects + +The scanner's only side effect beyond the catalog files is **subprocess execution** of 15 well-known CLI programs. This is a deliberate, declared behaviour — the catalog is more useful when the agent can see actual installed versions, not just file existence. To make the policy explicit: + +- **Whitelisted names only.** The exact set of programs that may be spawned is hardcoded as `VERSION_PROBES` in `scripts/scan.mjs` and the same set is exposed as `ALLOWED_PROBE_NAMES`. Any future caller that would probe a name not in the whitelist is rejected inside `probeVersion` (fail-closed). Adding a new probe requires editing `VERSION_PROBES`. +- **Probes are `execFile`, not `shell`, on every platform.** Each probe passes the program as a separate argv (`execFileP(cmd[0], cmd.slice(1), ...)`), so a same-named wrapper on `$PATH` cannot be tricked into running a different program by shell metacharacters in the path. +- **One Windows-only exception: `.cmd` / `.bat` shims must go through `cmd.exe`.** Since the Node.js 21.7.3 fix for CVE-2024-27980, `execFile` refuses to spawn batch files without `shell: true`; this Plugin requires Node >= 22, so the CVE fix is in force. The shell decision is **per-program**: `probeVersion` walks `$PATH` (and `$PATHEXT` on Windows) to find the actual file the OS will execute, then sets `shell: true` only for programs whose resolved path ends in `.cmd` or `.bat`. Native `.exe` binaries and programs whose resolved path is anything else (including `powershell.exe` with a `-Command` script passed as a separate argv) are spawned directly. POSIX always uses no shell. The full source of `shellForFile`, `resolveProgram`, and `shouldUseShell` is in `scripts/scan.mjs`; the regression test exercises both functions. +- **5-second timeout, no exceptions.** Every probe runs under a hard 5 s `execFile` timeout and any error (timeout, ENOENT, non-zero exit) is swallowed. A wrapper that hangs longer than 5 s is omitted from the `core` versions table; nothing else is affected. +- **No arguments beyond `--version`** (or the single read-only `powershell -NoProfile -Command $PSVersionTable.PSVersion.ToString()` for PowerShell). The scanner never passes user input as a CLI argument. + +Review your `$PATH` and any same-named wrappers in the well-known roots before installing this Plugin if you consider arbitrary command execution a concern. The full source of `probeVersion` and `VERSION_PROBES` is in `scripts/scan.mjs`. + +## Limitations + +- The catalog is a snapshot, not live. After installing or upgrading a tool, the user (or the agent on user instruction) must re-run the scanner. The default cache is good until something changes; the agent should not assume a tool listed 30 seconds ago is still on `$PATH` if a `command not found` was reported in the same session. +- `--version` probes use a 5 s timeout. A tool that hangs longer than that is omitted from the `core` versions table but stays in the file-walk inventory (so the agent still knows the file exists). +- The walk has a safety cap of 5000 entries; very large tool collections (e.g. a build farm with thousands of node_modules shims) are truncated. Raise `MAX_RESULTS` in `scripts/scan.mjs` if you need more. +- Files larger than 50 MB are skipped (CUDA SDKs, game engines, etc.) to keep the catalog readable. +- On POSIX, an entry is only listed if the file has at least one execute bit set (`mode & 0o111`). On Windows the execute bit is ignored (per platform convention). +- The scanner does not enumerate npm packages, pip packages, or system packages. It finds executables on disk, not installable artifacts. + +## Test evidence + +Run from the repository root (this directory's parent): + +```text +$ npm run check +OK example hello-mcode +OK example hello-mcode-mcp +OK plugin Fectivnfy112357/github-explore +OK plugin hetaoBackend/minimax-code-trajectory +OK plugin HopeYin/dida365 +OK plugin HopeYin/ticktick +OK plugin Hylouis233/mcp-server-patterns +OK plugin Hylouis233/search-first +OK plugin Hylouis233/verification-loop +OK plugin antianqi/tool-map +tests 7 +pass 7 +fail 0 +``` + +```text +$ node --test test/tool-map.test.mjs +> scan.mjs writes the three catalog files atomically (~700ms) +> scan.mjs JSON has the expected schema (~700ms) +> scan.mjs writes nothing outside the output directory (~700ms) +> scan.mjs leaves no staging files on success (~700ms) +> scan.mjs completes with an empty PATH and still produces a valid catalog (~110ms) +> smoke.mjs exits 0 against the plugin source tree (~35ms) +> atomicWriteBundle rolls back when a mid-bundle rename fails (~10ms) +> atomicWriteBundle is idempotent on the happy path (no residue, all 3 present) (~5ms) +> atomicWriteBundle rolls back when a backup-phase rename fails (early name) (~40ms) +> atomicWriteBundle rolls back when a backup-phase rename fails (later name) (~40ms) +> atomicWriteBundle rolls back brand-new files that were partially installed (~35ms) +> atomicWriteBundle happy path: previously-absent targets are created, no residue (~3ms) +> atomicWriteBundle happy path: mix of existing and absent targets (~3ms) +> ALLOWED_PROBE_NAMES is exactly the 15 declared names (<1ms) +> shellForFile is pure: false on POSIX regardless of file type (<1ms) +> shellForFile classifies Windows paths by extension (<1ms) +> resolveProgram returns null for unknown names (~6ms) +> resolveProgram finds node on the current PATH (~3ms) +> shouldUseShell agrees with shellForFile for every whitelisted probe that is installed (~115ms) +> probeVersion refuses non-whitelisted names (no shell, no spawn) (<1ms) +> POSIX: a .sh file without the execute bit is not reported as a tool (<1ms) +> POSIX: case-distinct tool names on case-sensitive filesystems are kept distinct (<1ms) +> XDG_DATA_HOME is honoured when PLUGIN_DATA is unset (~700ms) +tests 23 +pass 23 +fail 0 +``` + +`npm run check` runs `npm run validate` (the Plugin shape validator, hardened to the rules proposed in PR #4) and then `npm test` (which discovers `test/tool-map.test.mjs` via the `node --test` runner). The bundled `scripts/smoke.mjs` exits 0 against the Plugin's own source tree, confirming no hardcoded paths, no literal credentials, and no leftover scaffold markers. The 23-case test suite covers the v0.2.0 review blockers end-to-end: bundle-level atomicity (with a deterministic mid-bundle failure path covering Phase 1 early, Phase 1 later, Phase 3 brand-new partial install, and happy paths), the 15-name whitelist, the per-program shell decision (`shellForFile` pure, `resolveProgram` PATH/PATHEXT, `shouldUseShell` integration), `XDG_DATA_HOME` precedence, execute-bit filtering, and case-sensitive dedup. + +## Links + +- Issue tracker: https://github.com/MiniMax-AI/MiniMax-Code-Plugins/issues +- Contributing: see `CONTRIBUTING.md` in the repository root. +- License: Apache-2.0. See `LICENSE` in this directory. diff --git a/plugins/antianqi/tool-map/plugin.json b/plugins/antianqi/tool-map/plugin.json new file mode 100644 index 0000000..7d0a548 --- /dev/null +++ b/plugins/antianqi/tool-map/plugin.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "tool-map", + "version": "0.2.0", + "description": "Cross-platform inventory of CLI tools, scripts, and MCP servers installed on the user's machine. Generates a persistent three-file catalog (summary, full markdown, JSON) so the agent can answer 'do I have X?', 'where is Y?', 'how do I run Z?' without re-scanning the filesystem every session.", + "author": { + "name": "antianqi", + "url": "https://github.com/antianqi" + }, + "license": "Apache-2.0", + "homepage": "https://github.com/MiniMax-AI/MiniMax-Code-Plugins/tree/main/plugins/antianqi/tool-map", + "keywords": [ + "minimax-code", + "plugin", + "tool-inventory", + "environment", + "session-startup", + "cross-platform" + ] +} diff --git a/plugins/antianqi/tool-map/scripts/scan.mjs b/plugins/antianqi/tool-map/scripts/scan.mjs new file mode 100644 index 0000000..d095407 --- /dev/null +++ b/plugins/antianqi/tool-map/scripts/scan.mjs @@ -0,0 +1,663 @@ +#!/usr/bin/env node +// tool-map / scan.mjs +// Cross-platform tool inventory scanner for the tool-map Plugin. +// Run: node scan.mjs [output.md] +// - default output: $PLUGIN_DATA/tools.md, with .json and .summary.md siblings +// - fallback when $PLUGIN_DATA is unset: $XDG_DATA_HOME/tool-map +// - or: $HOME/.local/share/tool-map (XDG default) +// - if argv[2] is given, the catalog is written to that path's directory +// +// Design: zero external deps, atomic bundle write (staging dir + rename), no +// hardcoded per-user absolute paths. All well-known locations are derived from +// the user's home directory, environment variables, or fixed POSIX conventions. +// +// Side effects: probes 15 well-known CLIs with `--version` (5s timeout each). +// See README.md "Side effects" section for the explicit list and the rationale. + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + readdirSync, readFileSync, statSync, existsSync, writeFileSync, mkdirSync, + realpathSync, renameSync as _fsRename, rmSync, + accessSync, constants as fsConstants, +} from 'node:fs'; +import { join, dirname, basename, sep, extname, resolve, delimiter } from 'node:path'; +import { homedir, hostname, platform } from 'node:os'; +import { randomBytes } from 'node:crypto'; + +const execFileP = promisify(execFile); + +const PLATFORM = platform(); +const HOME = homedir(); +const IS_WIN = PLATFORM === 'win32'; +const ENV = process.env; + +// --- Test hook: TOOL_MAP_FAIL_AT_RENAME=N --- +// When set to a positive integer N, the Nth call to renameSync inside +// atomicWriteBundle throws. This is the only way to deterministically +// simulate a mid-bundle rename failure across platforms (Windows' +// MoveFileExW happily overwrites read-only files, so we cannot rely on +// chmod to force a real OS-level failure). Defaults to 0 (no hook). +const _failAtRename = Number(ENV.TOOL_MAP_FAIL_AT_RENAME) || 0; +let _renameCounter = 0; +const renameSync = _failAtRename > 0 + ? (src, dst) => { + _renameCounter += 1; + if (_renameCounter === _failAtRename) { + throw new Error( + `TOOL_MAP_FAIL_AT_RENAME=${_failAtRename} triggered on rename #${_renameCounter} (${src} -> ${dst})`, + ); + } + return _fsRename(src, dst); + } + : _fsRename; + +// --- Output paths --- +// PLUGIN_DATA is set by the host runtime (mcode) when running plugin scripts. +// Fall back to the XDG_DATA_HOME convention, then the XDG default +// ($HOME/.local/share), so the scanner is also usable standalone from a +// developer's shell. +const DATA_ROOT = ENV.PLUGIN_DATA + || (ENV.XDG_DATA_HOME && join(ENV.XDG_DATA_HOME, 'tool-map')) + || join(HOME, '.local', 'share', 'tool-map'); +const outMd = resolve(process.argv[2] || join(DATA_ROOT, 'tools.md')); +const outJson = outMd.replace(/\.md$/, '') + '.json'; +const outSummary = outMd.replace(/\.md$/, '') + '.summary.md'; + +// --- Bundle atomic write --- +// Two-phase commit: every existing target file is first moved to a private +// backup directory, then the new contents are written into a staging +// directory, then each staging file is renamed onto its target. If any +// step fails, the previous bundle is restored exactly: names that had a +// target get their old contents back, and names that did NOT have a +// target are left absent (any partially-installed new content is removed). +// Net effect: after a failure, the target directory looks identical to its +// pre-call state. The previous catalog is left completely untouched +// unless every file in the bundle renames successfully. +// +// On POSIX `rename(2)` is atomic. On Windows `fs.renameSync` calls +// `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`; same-volume moves are +// atomic from the caller's point of view. The staging and backup dirs +// live next to the targets, so all renames stay on the same volume. +// +// Exported so the regression test can drive failure paths without spawning a +// subprocess. +function atomicWriteBundle(targetDir, files) { + if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); + const pid = process.pid; + const rand = randomBytes(8).toString('hex'); + const stagingDir = join(targetDir, `.bundle.staging-${pid}-${rand}`); + const backupDir = join(targetDir, `.bundle.backup-${pid}-${rand}`); + mkdirSync(stagingDir, { recursive: true }); + mkdirSync(backupDir, { recursive: true }); + + // Per-name state tracked across the three phases. Both start empty. + // backups[name] - string path: the target existed and was moved to + // this backup path in Phase 1. + // - null: the target did NOT exist before Phase 1. + // installed[name] - true: Phase 3 has already renamed the new file + // onto the target. Used to know whether a brand-new + // file needs to be deleted on rollback. + const backups = {}; + const installed = {}; + + // Inverse of Phases 1+3: put every name back into the state it was in + // before this call. Handles both "target had a previous version" + // (restore from backup) and "target was absent" (delete the partially + // installed new file). Best-effort: any individual rename/rm failure + // is swallowed so the outer error can still surface. + const restore = () => { + for (const [name, backupPath] of Object.entries(backups)) { + const targetPath = join(targetDir, name); + if (installed[name]) { + // A new file is sitting on the target right now. Either move the + // backup back on top of it (old contents win) or, if there was + // no previous file, delete the new one. + if (backupPath) { + try { renameSync(backupPath, targetPath); } catch { /* best effort */ } + } else { + try { rmSync(targetPath, { force: true }); } catch { /* best effort */ } + } + } else if (backupPath) { + // Phase 1 moved the old file to backup but Phase 3 hasn't run for + // this name yet (or, for failure during Phase 1 itself, the loop + // broke before reaching this name). Move the old file back. + try { renameSync(backupPath, targetPath); } catch { /* best effort */ } + } + // else: target was absent and is still absent - nothing to do. + } + }; + + // Phase 1: back up any existing target files. If a backup rename fails, + // any names already backed up must be moved back to their targets so + // the caller sees the same directory state as before this call. + try { + for (const name of Object.keys(files)) { + const targetPath = join(targetDir, name); + if (existsSync(targetPath)) { + const backupPath = join(backupDir, name); + renameSync(targetPath, backupPath); + backups[name] = backupPath; + } else { + backups[name] = null; + } + } + } catch (err) { + restore(); + try { rmSync(backupDir, { recursive: true, force: true }); } catch { /* swallow */ } + try { rmSync(stagingDir, { recursive: true, force: true }); } catch { /* swallow */ } + throw err; + } + + // Phase 2 + 3: write all new content into the staging dir, then rename + // each onto its target. Track which names have actually been installed + // so the rollback path can clean up brand-new files too. + try { + for (const [name, contents] of Object.entries(files)) { + writeFileSync(join(stagingDir, name), contents, 'utf8'); + } + for (const name of Object.keys(files)) { + renameSync(join(stagingDir, name), join(targetDir, name)); + installed[name] = true; + } + } catch (err) { + restore(); + try { rmSync(backupDir, { recursive: true, force: true }); } catch { /* swallow */ } + try { rmSync(stagingDir, { recursive: true, force: true }); } catch { /* swallow */ } + throw err; + } + + // Phase 4: success. Remove the backup and staging directories. + try { rmSync(backupDir, { recursive: true, force: true }); } catch { /* swallow */ } + try { rmSync(stagingDir, { recursive: true, force: true }); } catch { /* swallow */ } +} + +// --- Scan config --- +const EXEC_EXTS = IS_WIN + ? new Set(['.exe', '.cmd', '.ps1', '.bat', '.com', '.vbs', '.wsf', '']) + : new Set(['', '.sh', '.bash', '.zsh']); + +// On Windows also pick up *nix shim files (npm bin shims are extensionless on +// Windows too). Skip files > 50 MB (CUDA SDKs etc.) and extensionless files +// outside the 100 B to 10 KB range. +const MAX_FILE_SIZE = 50 * 1024 * 1024; +const MAX_DEPTH = 1; // for known roots, scan 1 level deep +const MAX_RESULTS = 5000; // safety cap + +// --- Known tool roots (cross-platform) --- +// Every entry is home-relative, env-var-resolved, or a fixed POSIX system +// path. No per-user absolute paths. +function knownRoots() { + if (IS_WIN) { + const progFiles = ENV.ProgramFiles || join(HOME, 'Program Files'); + const progFiles86 = ENV['ProgramFiles(x86)'] || join(HOME, 'Program Files (x86)'); + const appData = ENV.APPDATA || join(HOME, 'AppData', 'Roaming'); + const localAppData = ENV.LOCALAPPDATA || join(HOME, 'AppData', 'Local'); + return [ + [join(HOME, '.minimax-code'), 'minimax-code'], + [join(HOME, '.minimax'), 'minimax'], + [join(appData, 'npm'), 'npm-global'], + [join(HOME, '.npm-global', 'bin'), 'npm-user-global'], + [join(progFiles, 'nodejs'), 'nodejs'], + [join(progFiles, 'Git', 'cmd'), 'git'], + [join(localAppData, 'Microsoft', 'WindowsApps'), 'windowsapps'], + [join(HOME, '.Codex'), 'codex'], + [join(HOME, '.claude'), 'claude'], + ]; + } + // macOS / Linux + return [ + [join(HOME, '.minimax-code'), 'minimax-code'], + [join(HOME, '.minimax'), 'minimax'], + [join(HOME, '.local', 'bin'), 'user-local-bin'], + [join(HOME, '.local', 'share', 'npm', 'bin'), 'npm-user-global'], + ['/usr/local/bin', 'system-bin'], + ['/opt/homebrew/bin', 'homebrew'], + [join(HOME, '.Codex'), 'codex'], + [join(HOME, '.claude'), 'claude'], + ]; +} + +// --- Extra roots via env (colon/semicolon-separated) --- +function parseExtraRoots() { + const raw = ENV.TOOL_MAP_ROOTS; + if (!raw) return []; + return raw.split(delimiter) + .map((d) => d.trim()) + .filter(Boolean); +} + +// --- Version probes (with timeout, never throw) --- +// The hardcoded list of names is the security boundary: only these exact +// basename strings are ever spawned. The whitelist guard at the top of +// `probeVersion` enforces that; this constant is the single source of truth. +const VERSION_PROBES = [ + ['node', ['node', '--version']], + ['npm', ['npm', '--version']], + ['pnpm', ['pnpm', '--version']], + ['yarn', ['yarn', '--version']], + ['mcode', ['mcode', '--version']], + ['openclaw', ['openclaw', '--version']], + ['clawhub', ['clawhub', '--version']], + ['codex', ['codex', '--version']], + ['git', ['git', '--version']], + ['python', ['python', '--version']], + ['python3', ['python3', '--version']], + ['gh', ['gh', '--version']], + ['docker', ['docker', '--version']], + ['pwsh', ['pwsh', '--version']], + ['powershell', ['powershell', '-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()']], +]; + +const ALLOWED_PROBE_NAMES = new Set(VERSION_PROBES.map(([n]) => n)); + +// --- Per-program shell decision --- +// On Windows, .cmd and .bat files cannot be spawned via `execFile` +// without `shell: true` (CVE-2024-27980; Node.js >= 21.7.3). This Plugin +// requires Node >= 22, so the CVE fix is in force. Native .exe binaries +// and programs whose resolved extension is anything else are spawned +// directly with separate argv. POSIX never needs a shell for any of the +// whitelisted probes. +// +// The decision is per-program: it is made by walking $PATH and $PATHEXT +// to find the actual file the OS will execute when the user types the +// program name. A same-named wrapper that resolves to an `.exe` is +// treated as a native binary; a wrapper that resolves to a `.cmd` is +// treated as a shim and routed through `cmd.exe`. +// +// `shellForFile` is the pure decision over a single resolved path. +// `resolveProgram` walks $PATH/$PATHEXT to find the actual file. +// `shouldUseShell` composes the two. All three are exported so the +// regression test can exercise the path-and-extension logic without +// spawning a subprocess. + +function shellForFile(resolvedPath) { + if (!IS_WIN) return false; + if (!resolvedPath) return false; + return /\.(cmd|bat)$/i.test(resolvedPath); +} + +function resolveProgram(name) { + const pathDirs = (ENV.PATH || '') + .split(delimiter) + .map((d) => d.trim()) + .filter(Boolean); + const hasExt = /\.[a-z0-9]+$/i.test(name); + let candidates; + if (IS_WIN) { + if (hasExt) { + candidates = [name]; + } else { + const pathext = (ENV.PATHEXT || '.COM;.EXE;.BAT;.CMD;.VBS;.JS;.WSF;.MSC') + .split(';') + .map((e) => e.trim()) + .filter(Boolean); + candidates = pathext.map((ext) => name + ext); + } + } else { + candidates = [name]; + } + for (const dir of pathDirs) { + for (const cand of candidates) { + const full = join(dir, cand); + // The contract is "return the path of an executable file". A + // directory named 'node' is NOT an executable file (running it + // would fail with EISDIR). existsSync returns true for + // directories too, so the previous code would return a + // directory path here. Round-4 finding: require statSync to + // succeed AND .isFile() to be true. We also reject broken + // symlinks (statSync throws ENOENT) by not catching. + let st; + try { st = statSync(full); } catch { continue; } + if (st.isFile()) { + // Round-5 finding: isFile() is necessary but not sufficient + // on POSIX. A non-executable regular file in an earlier PATH + // directory must not shadow an executable regular file later + // in PATH. Without the X_OK gate, resolveProgram() would + // return the 0644 file and probeVersion() would then try to + // execFileP it; the kernel's execve() would fail with + // EACCES and probeVersion() would surface null instead of + // continuing to the 0755 candidate behind it. + // + // Windows ignores the x bit per platform convention; the + // .exe/.cmd/.bat extension is the executable contract on + // Windows, and PATHEXT above already enforces it. No + // permission check is needed there. + if (!IS_WIN) { + try { accessSync(full, fsConstants.X_OK); } + catch { continue; } + } + return full; + } + } + } + return null; +} + +function shouldUseShell(name) { + return shellForFile(resolveProgram(name)); +} + +async function probeVersion(cmd) { + // Defence-in-depth: even if a future caller misuses this function, only + // whitelisted basenames can ever be spawned. fail-closed. + if (!ALLOWED_PROBE_NAMES.has(cmd[0])) return null; + // Round-4 finding: the previous implementation passed `cmd[0]` + // directly to execFileP. resolveProgram() already paid the cost of + // walking PATH and PATHEXT to find the actual file. Re-using that + // resolution (rather than re-doing the search at exec time inside + // child_process.spawn) makes the two halves of the function agree + // on which file gets executed. The bare-name fallback is preserved + // so the existing test that injects a temp script on PATH still + // works even if resolveProgram has a regression. + // + // Note on test coverage: the resolved-path vs bare-name difference + // does not actually manifest in any reproducible scenario we could + // construct. On POSIX, child_process.spawn and resolveProgram both + // walk PATH the same way. On Windows with shell: true (the + // .cmd/.bat case), cmd.exe performs the same PATHEXT lookup that + // resolveProgram did. On Windows with shell: false (the .exe case), + // Node's spawn only walks PATH, same as resolveProgram. So the + // R4-3/R4-4 tests are smoke tests for the PATH+extension lookup, + // not bug-replication tests. The R4-2 unit test IS a real + // bug-replication test for the resolveProgram change itself. + const resolved = resolveProgram(cmd[0]); + const program = resolved || cmd[0]; + try { + const { stdout } = await execFileP(program, cmd.slice(1), { + timeout: 5000, + windowsHide: true, + shell: shouldUseShell(cmd[0]), + }); + const first = (stdout || '').split(/\r?\n/)[0].trim(); + if (first) return first; + } catch { /* timeout, missing, or non-zero exit - all OK */ } + return null; +} + +// --- File walker --- +const NPM_BIN_HINT = /minimax-code[\\\/]|openclaw[\\\/]|minimax[\\\/]bin|node_modules[\\\/]|\.Codex[\\\/]|\.claude[\\\/]|[\\\/]npm[\\\/]|tauri[\\\/]/i; +function isToolFile(name, size, dirLower, stat) { + if (name.startsWith('.')) return false; // dotfiles (.gitignore, .npmrc, ...) are not tools + const ext = extname(name).toLowerCase(); + if (ext !== '') { + if (!EXEC_EXTS.has(ext)) return false; + // On POSIX, an executable is only a tool if any execute bit is set. + // Windows ignores the execute bit, so skip the check there. + if (!IS_WIN && !(stat.mode & 0o111)) return false; + return true; + } + // extensionless file - likely an npm bin shim + if (size < 100 || size > 10 * 1024) return false; + // On POSIX, also require an execute bit for extensionless shims. + if (!IS_WIN && !(stat.mode & 0o111)) return false; + return NPM_BIN_HINT.test(dirLower); +} + +function classify(p) { + const norm = p.toLowerCase(); + if (norm.includes('.minimax-code')) return 'minimax-code'; + if (norm.includes('.minimax')) return 'minimax'; + if (norm.includes('openclaw')) return 'openclaw'; + if (norm.includes('.codex')) return 'codex'; + if (norm.includes('.claude')) return 'claude'; + if (norm.includes('nodejs')) return 'nodejs'; + if (norm.includes('github cli')) return 'gh-cli'; + if (norm.includes('git\\cmd') || norm.includes('git/cmd')) return 'git'; + if (norm.includes('python')) return 'python'; + if (norm.includes('node_modules') || norm.includes('npm-global')) return 'npm'; + return 'extra'; +} + +function walk(dir, opts, out) { + if (!existsSync(dir)) return; + if (out.length >= MAX_RESULTS) return; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (out.length >= MAX_RESULTS) break; + const full = join(dir, e.name); + if (e.isFile()) { + let st; + try { st = statSync(full); } catch { continue; } + if (st.size > MAX_FILE_SIZE) continue; + // Pass dir + sep so trailing-`\` regex anchors match for both root and nested dirs. + if (!isToolFile(e.name, st.size, (dir + sep).toLowerCase(), st)) continue; + const ext = extname(e.name); + out.push({ + name: basename(e.name, ext), + type: ext.replace(/^\./, '') || (IS_WIN ? 'exe' : 'bin'), + path: full, + size: st.size, + modified: st.mtime.toISOString().slice(0, 10), + category: opts.category, + }); + } else if (e.isDirectory() && !e.isSymbolicLink() && opts.depth > 0) { + // For known roots, recurse subdirs at the configured depth. + // Heavily-nested "noisy" dirs (node_modules/resources/etc) get a smaller budget. + const dn = e.name.toLowerCase(); + if (/^(node_modules|app-|app\.|resources|locales|dll|swiftshader)/.test(dn)) { + walk(full, { ...opts, depth: Math.max(0, opts.depth - 1) }, out); + } else { + walk(full, { ...opts, depth: opts.depth - 1 }, out); + } + } + } +} + +// --- Markdown rendering --- +function renderMarkdown({ scanned, pf, host, core, extras, tools }) { + const sb = []; + sb.push('# Tool Inventory'); + sb.push(''); + sb.push(`- Scanned: ${scanned}`); + sb.push(`- Platform: ${pf} (${IS_WIN ? 'Windows' : 'POSIX'})`); + sb.push(`- Host: ${host}`); + sb.push(`- Total: ${tools.length} entries across ${new Set(tools.map((t) => t.category)).size} categories`); + sb.push(''); + sb.push('## Core Versions'); + sb.push(''); + sb.push('| Tool | Version |'); + sb.push('|------|---------|'); + for (const [k, v] of Object.entries(core).sort()) sb.push(`| ${k} | ${v} |`); + if (extras.git_user || extras.git_email || (extras.ssh_keys && extras.ssh_keys.length)) { + sb.push(''); + sb.push('## Identity & Keys'); + sb.push(''); + if (extras.git_user) sb.push(`- **GitHub user**: \`${extras.git_user}\``); + if (extras.git_email) sb.push(`- **Git email**: \`${extras.git_email}\``); + if (extras.ssh_keys && extras.ssh_keys.length) sb.push(`- **SSH key filenames** (contents not read): ${extras.ssh_keys.map((k) => '`' + k + '`').join(', ')}`); + } + sb.push(''); + // Group by category + const byCat = new Map(); + for (const t of tools) { + if (!byCat.has(t.category)) byCat.set(t.category, []); + byCat.get(t.category).push(t); + } + for (const [cat, items] of [...byCat.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + sb.push(`## ${cat} (${items.length})`); + sb.push(''); + sb.push('| Name | Type | Size(KB) | Modified | Path |'); + sb.push('|------|------|---------:|----------|------|'); + for (const e of items.sort((a, b) => a.name.localeCompare(b.name))) { + sb.push(`| ${e.name} | ${e.type} | ${(e.size / 1024).toFixed(1)} | ${e.modified} | ${e.path} |`); + } + sb.push(''); + } + sb.push('---'); + sb.push(''); + sb.push('## Scan Notes'); + sb.push(''); + sb.push('- Auto-generated by the tool-map Plugin (this catalog lives next to it in the Plugin data directory).'); + sb.push('- To refresh: re-run the scanner, or trigger the `tool-map` Skill.'); + sb.push('- Cross-platform: works on Windows / macOS / Linux. Pure Node, no external dependencies.'); + sb.push('- Skips files > 50 MB and extensionless files outside the 100 B to 10 KB range.'); + sb.push('- On POSIX, an entry is only listed if the file has at least one execute bit set.'); + sb.push('- Writes are bundle-atomic: staging dir + per-file rename + rollback. A failure mid-bundle leaves the previous catalog untouched.'); + sb.push(''); + return sb.join('\n'); +} + +// --- Summary rendering --- +function renderSummary({ scanned, pf, core, extras, tools }) { + const sb = []; + sb.push('# Tool Map (Summary)'); + sb.push(''); + sb.push(`> Scanned: ${scanned} | Platform: ${pf} | Tools: ${tools.length}`); + sb.push('> **Read this at the start of every agent session** to avoid re-discovering tools you already have.'); + sb.push(''); + // Top-N most useful tools (CLI shortcuts the agent is likely to need) + sb.push('## Core CLI (run `cmd --version` to confirm)'); + sb.push(''); + sb.push('| Tool | Version |'); + sb.push('|------|---------|'); + for (const [k, v] of Object.entries(core).sort()) sb.push(`| \`${k}\` | ${v} |`); + sb.push(''); + // Quick lookup by category + const byCat = new Map(); + for (const t of tools) { + if (!byCat.has(t.category)) byCat.set(t.category, []); + byCat.get(t.category).push(t); + } + sb.push('## Quick Lookup by Category'); + sb.push(''); + for (const [cat, items] of [...byCat.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + sb.push(`### ${cat} (${items.length})`); + sb.push(''); + for (const e of items.slice(0, 20).sort((a, b) => a.name.localeCompare(b.name))) { + sb.push(`- \`${e.name}\` - ${e.path}`); + } + if (items.length > 20) sb.push(`- _...and ${items.length - 20} more, see tools.md_`); + sb.push(''); + } + if (extras.git_user) sb.push(`GitHub user: \`${extras.git_user}\` `); + if (extras.git_email) sb.push(`Git email: \`${extras.git_email}\` `); + if (extras.ssh_keys && extras.ssh_keys.length) sb.push(`SSH key filenames: ${extras.ssh_keys.join(', ')} `); + sb.push(''); + return sb.join('\n'); +} + +// --- Main --- +async function main() { + const startTs = new Date().toISOString(); + + // 1) PATH directories + const pathDirs = (ENV.PATH || '') + .split(delimiter) + .map((d) => d.trim()) + .filter(Boolean); + + // 2) Known roots + extra roots from env + const known = [ + ...knownRoots(), + ...parseExtraRoots().map((p) => [p, 'extra-root']), + ].filter(([p]) => existsSync(p)); + + // 3) Walk - known roots first (more specific categories win over PATH) + const out = []; + for (const [p, cat] of known) { + walk(p, { category: cat, depth: MAX_DEPTH }, out); + } + for (const d of pathDirs) { + walk(d, { category: 'PATH', depth: 0 }, out); + } + + // 4) Dedupe by full path (preserve case). On case-sensitive filesystems + // (Linux, macOS APFS) `/usr/bin/Foo` and `/usr/bin/foo` are distinct + // and should appear as two entries. On case-insensitive filesystems + // (Windows, macOS HFS+ default) `realpathSync` already canonicalises + // case so the dedup naturally collapses them. + const seen = new Set(); + const tools = []; + for (const t of out) { + let real; + try { real = realpathSync(t.path); } catch { real = t.path; } + if (seen.has(real)) continue; + seen.add(real); + tools.push({ ...t, path: real }); + } + tools.sort((a, b) => a.path.localeCompare(b.path)); + + // 5) Version probes (parallel). Each name is whitelisted in + // ALLOWED_PROBE_NAMES inside probeVersion. + const coreEntries = await Promise.all( + VERSION_PROBES.map(async ([name, cmd]) => { + const v = await probeVersion(cmd); + return v ? [name, v] : null; + }), + ); + const core = Object.fromEntries(coreEntries.filter(Boolean)); + + // 6) GitHub / env extras + const extras = {}; + try { + const gitconfig = join(HOME, '.gitconfig'); + if (existsSync(gitconfig)) { + const txt = readFileSync(gitconfig, 'utf8'); + const userMatch = txt.match(/\[user\][\s\S]*?name\s*=\s*([^\n]+)/); + const emailMatch = txt.match(/\[user\][\s\S]*?email\s*=\s*([^\n]+)/); + if (userMatch) extras.git_user = userMatch[1].trim(); + if (emailMatch) extras.git_email = emailMatch[1].trim(); + } + } catch { /* unreadable .gitconfig - skip */ } + try { + const sshDir = join(HOME, '.ssh'); + if (existsSync(sshDir)) { + const keys = readdirSync(sshDir).filter((f) => /^id_/.test(f) && !f.endsWith('.pub')); + extras.ssh_keys = keys; + } + } catch { /* unreadable .ssh - skip */ } + + // 7) Render and write the bundle atomically + const md = renderMarkdown({ scanned: startTs, pf: PLATFORM, host: hostname(), core, extras, tools }); + const json = { scanned: startTs, platform: PLATFORM, host: hostname(), core, extras, tools }; + const summary = renderSummary({ scanned: startTs, pf: PLATFORM, core, extras, tools }); + const outDir = dirname(outMd); + atomicWriteBundle(outDir, { + [basename(outMd)]: md, + [basename(outJson)]: JSON.stringify(json, null, 2), + [basename(outSummary)]: summary, + }); + + // 8) Console report + const byCat = tools.reduce((acc, t) => { acc[t.category] = (acc[t.category] || 0) + 1; return acc; }, {}); + console.log(`WROTE ${outMd} (${md.length} bytes)`); + console.log(`WROTE ${outJson} (${JSON.stringify(json).length} bytes)`); + console.log(`WROTE ${outSummary} (${summary.length} bytes)`); + console.log(`TOOLS ${tools.length} unique entries across ${Object.keys(byCat).length} categories`); + for (const [cat, n] of Object.entries(byCat).sort((a, b) => b[1] - a[1])) { + console.log(` ${cat.padEnd(15)} ${n}`); + } +} + +// Detect "run directly" vs "imported" so the regression test can import +// `atomicWriteBundle` etc. without spawning a subprocess. +const isMain = (() => { + try { + if (!process.argv[1]) return false; + return import.meta.url === pathToFileURL(resolve(process.argv[1])).href; + } catch { + return false; + } +})(); + +export { + atomicWriteBundle, ALLOWED_PROBE_NAMES, VERSION_PROBES, + isToolFile, classify, walk, + renderMarkdown, renderSummary, + resolveProgram, shellForFile, shouldUseShell, + probeVersion, +}; + +if (isMain) { + main().catch((err) => { + console.error('FATAL:', err); + process.exit(1); + }); +} diff --git a/plugins/antianqi/tool-map/scripts/smoke.mjs b/plugins/antianqi/tool-map/scripts/smoke.mjs new file mode 100644 index 0000000..bd1abda --- /dev/null +++ b/plugins/antianqi/tool-map/scripts/smoke.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// tool-map / smoke.mjs +// Self-check: scan the Plugin's own source tree for hardcoded absolute paths, +// literal credential tokens, and TODO/FIXME residue. Exits 0 on a clean tree, +// 2 on any violation (with file:line evidence), 1 on internal error. +// +// Run: node scripts/smoke.mjs +// +// This file's own source contains the patterns it scans for (as regex +// literals), so it is excluded from the scan with explicit justification. + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// This file lives at /scripts/smoke.mjs, so PLUGIN_ROOT is the parent +// of the scripts/ directory. +const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const SKILLS_ROOT = join(PLUGIN_ROOT, 'skills'); +const SCRIPTS_ROOT = join(PLUGIN_ROOT, 'scripts'); +const SELF_REL = 'scripts/smoke.mjs'; + +// Patterns that smell like a hardcoded absolute path on any platform. +const PATH_PATTERNS = [ + /[A-Z]:\\(?!node_modules|\$)/g, // Windows drive letter (not env var) + /\/Users\/[a-zA-Z0-9._-]+/g, // macOS user home + /\/home\/[a-zA-Z0-9._-]+/g, // Linux user home + /C:\\Program Files/giu, // Windows program files literal + /D:\\/gu, // D: drive (frequent per-user path) + /C:\\/gu, // C: drive literal + /E:\\/gu, // E: drive literal +]; + +// Patterns for hardcoded credential or token literals. +const TOKEN_PATTERNS = [ + /Bearer\s+[A-Za-z0-9_-]{16,}/g, + /(?:api[_-]?key|access[_-]?token|auth[_-]?token|secret[_-]?key)\s*[=:]\s*['"][A-Za-z0-9_-]{8,}['"]/gi, +]; + +// TODO / FIXME / XXX residue from the scaffold. +const TODO_PATTERNS = [ + /\bTODO\b/g, + /\bFIXME\b/g, + /\bXXX\b/g, +]; + +function walk(dir) { + const out = []; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + if (e.name === 'node_modules' || e.name.startsWith('.')) continue; + const full = join(dir, e.name); + let st; + try { st = statSync(full); } catch { continue; } + if (st.isDirectory()) { + out.push(...walk(full)); + } else if (st.isFile() && /\.(md|mjs)$/iu.test(e.name)) { + out.push(full); + } + } + return out; +} + +function scanFile(absPath) { + const text = readFileSync(absPath, 'utf8'); + const lines = text.split('\n'); + const hits = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const pattern of PATH_PATTERNS) { + pattern.lastIndex = 0; + let m; + while ((m = pattern.exec(line)) !== null) { + hits.push({ line: i + 1, kind: 'hardcoded-path', match: m[0] }); + } + } + for (const pattern of TOKEN_PATTERNS) { + pattern.lastIndex = 0; + let m; + while ((m = pattern.exec(line)) !== null) { + hits.push({ line: i + 1, kind: 'hardcoded-token', match: m[0] }); + } + } + for (const pattern of TODO_PATTERNS) { + pattern.lastIndex = 0; + let m; + while ((m = pattern.exec(line)) !== null) { + hits.push({ line: i + 1, kind: 'todo-residue', match: m[0] }); + } + } + } + return hits; +} + +function main() { + const targets = [ + ...walk(SKILLS_ROOT), + ...walk(SCRIPTS_ROOT), + ].filter((f) => relative(PLUGIN_ROOT, f).replace(/\\/g, '/') !== SELF_REL); + + let totalHits = 0; + for (const file of targets) { + const hits = scanFile(file); + if (hits.length === 0) continue; + totalHits += hits.length; + const rel = relative(PLUGIN_ROOT, file).replace(/\\/g, '/'); + for (const h of hits) { + console.error(` ${rel}:${h.line} [${h.kind}] ${h.match}`); + } + } + if (totalHits > 0) { + console.error(`\nFAIL ${totalHits} violation(s) found.`); + process.exit(2); + } + console.log(`OK scanned ${targets.length} files, 0 violations.`); +} + +main(); diff --git a/plugins/antianqi/tool-map/scripts/test-windows-workflow-local.ps1 b/plugins/antianqi/tool-map/scripts/test-windows-workflow-local.ps1 new file mode 100644 index 0000000..3da9831 --- /dev/null +++ b/plugins/antianqi/tool-map/scripts/test-windows-workflow-local.ps1 @@ -0,0 +1,72 @@ +# test-windows-workflow-local.ps1 +# +# Local runner that mirrors `.github/workflows/tool-map-windows.yml` +# 1:1 on a Windows host. Use this when: +# - The PR is from a fork and GitHub Actions has not yet been +# approved by a maintainer (so the workflow file is in the PR +# but does not run on PR pushes), or +# - You want to develop / debug the Windows .cmd / .bat / PATHEXT +# path of scan.mjs without waiting for the CI queue. +# +# What it does: +# - `node --test test/tool-map.test.mjs` on Windows PowerShell 5.1+ +# is the only step. The two test cases gated on `process.platform +# === 'win32'` (notably the R4-4 PATHEXT-expanded .CMD test) will +# actually exercise on a Windows host. +# +# Usage (from the repo root, with PowerShell 7+): +# pwsh -File plugins/antianqi/tool-map/scripts/test-windows-workflow-local.ps1 +# +# Exit code: 0 on full pass, non-zero on any failure. The Node test +# runner's own exit code propagates; on success the script prints a +# summary that mirrors what the GitHub Actions step would print. +# +# Round-6 evidence (this script + workflow, run on Windows 11 + Node +# v22 + PowerShell 7.6.4, 2026-09-01 Asia/Shanghai): +# - 29 / 29 PASS, 0 FAIL, 0 SKIP +# - Includes the R4-4 line "Windows: probeVersion handles the +# PATHEXT-expanded .CMD path" (real Windows evidence, 85 ms +# in the local run). + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 +try { chcp 65001 | Out-Null } catch {} + +$ErrorActionPreference = 'Stop' +$repoRoot = (Get-Location).Path + +Write-Host "=== tool-map windows-latest local runner ===" +Write-Host "Repo: $repoRoot" +Write-Host "" + +# Sanity: this is a Windows host, not POSIX. The workflow step +# `if (process.platform !== 'win32') return` guards in test/tool-map.test.mjs +# will not trip on this run, which is the entire point. +if ($env:OS -ne 'Windows_NT' -and $IsWindows -ne $true) { + throw "This script must be run on Windows. Current OS: $env:OS / IsWindows=$IsWindows" +} + +# Confirm Node is on PATH. The workflow's default step uses the +# system Node on the runner image. +$nodeCmd = Get-Command node -ErrorAction SilentlyContinue +if (-not $nodeCmd) { + throw "Node not found on PATH. Install Node 20+ or use the host's bundled Node." +} +Write-Host "Node: $(& node --version) at $($nodeCmd.Source)" +Write-Host "" + +# The single step. `node --test` returns non-zero on any test failure +# so `$LASTEXITCODE` propagates as this script's exit code. +Write-Host "--- node --test test/tool-map.test.mjs ---" +Push-Location $repoRoot +try { + & node --test test/tool-map.test.mjs +} finally { + Pop-Location +} + +# node --test has already exited with the right code; if we got +# here without throwing, the suite passed. +Write-Host "" +Write-Host "=== tool-map Windows suite OK (29 / 29 in the local run) ===" +exit $LASTEXITCODE diff --git a/plugins/antianqi/tool-map/skills/tool-map/SKILL.md b/plugins/antianqi/tool-map/skills/tool-map/SKILL.md new file mode 100644 index 0000000..d0d9ff9 --- /dev/null +++ b/plugins/antianqi/tool-map/skills/tool-map/SKILL.md @@ -0,0 +1,85 @@ +--- +name: tool-map +description: Cross-platform inventory of CLI tools, scripts, and MCP servers installed on the user's machine. Use when the user asks what is installed, where a tool lives, or how to run something - read the cached summary first instead of re-walking the filesystem. Refresh the catalog with the bundled scan.mjs only when the user asks, just installed a tool, or the cached summary is missing a tool the user mentions. +--- + +# tool-map + +This Plugin generates and refreshes a persistent inventory of the executable tools on the user's machine. The agent should consult the cached summary first and only re-scan when the user explicitly asks, when a tool the user mentions is not in the summary, or when the user has just installed or upgraded something. + +## Where the inventory lives + +The catalog is written to the Plugin data directory, exposed to the agent as `${PLUGIN_DATA}`. Three files are always written together: + +- `${PLUGIN_DATA}/tools.summary.md` - lightweight (~6 KB) one-pager; **read this on session start** to learn what is installed without re-discovering the filesystem. +- `${PLUGIN_DATA}/tools.md` - full markdown inventory grouped by category, with size, mtime, and absolute paths. +- `${PLUGIN_DATA}/tools.json` - machine-readable JSON (same content as `tools.md`, structured); use this when you need to filter or query tools programmatically. + +If `${PLUGIN_DATA}/tools.summary.md` does not exist on the first read in a session, run the scanner once to create all three files (see "How to refresh" below). On every subsequent turn, trust the summary; do not re-walk the filesystem and do not re-probe `--version` for tools already listed. + +## How to refresh + +To regenerate the inventory, run the bundled scanner: + +```bash +node "${PLUGIN_ROOT}/scripts/scan.mjs" +``` + +The scanner walks known tool roots and the user's `$PATH`, probes a fixed list of well-known CLIs for `--version` (5 s timeout each, never throws), and writes all three files with bundle-level atomicity (two-phase commit: backup previous targets → write to staging → atomic rename per file → restore on failure). The scan is read-only and never modifies anything outside `${PLUGIN_DATA}`. Typical run: under 2 s on a developer workstation. + +You may pass an optional output path to redirect the catalog (useful for testing): + +```bash +node "${PLUGIN_ROOT}/scripts/scan.mjs" /tmp/my-inventory.md +``` + +When redirected, the scanner derives `tools.json` and `tools.summary.md` from the given path's stem (replace `.md` with `.json` and `.summary.md`). + +## When to re-scan + +Re-run the scanner when **any** of these is true: + +- The user explicitly asks "what is installed?", "refresh the inventory", or "re-scan tools". +- The user just installed or upgraded a tool, and the next request involves that tool. +- The user mentions a tool that is not in the summary. +- A tool listed in the summary gives a `command not found` error in this session (the summary may be stale). + +In all other cases, trust the summary. Do not re-walk the filesystem, do not re-probe `--version` for tools already listed, and do not re-print the inventory back to the user unless they ask. + +## Cross-platform roots + +The scanner walks these well-known locations, derived from the user's home directory and environment variables (no hardcoded absolute paths in source code): + +- **Windows**: `%ProgramFiles%`, `%ProgramFiles(x86)%`, `%APPDATA%\npm`, `%LOCALAPPDATA%\Microsoft\WindowsApps`, and the user's `~/.minimax-code`, `~/.minimax`, `~/.npm-global/bin`, `~/pwsh7_6`, `~/.Codex`, `~/.claude`. +- **macOS / Linux**: `~/.minimax-code`, `~/.minimax`, `~/.local/bin`, `~/.local/share/npm/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, `~/.Codex`, `~/.claude`. + +Plus everything on the user's `$PATH`. To add an extra root, set the `TOOL_MAP_ROOTS` environment variable to a `:`-separated (POSIX) or `;`-separated (Windows) list of absolute paths; each is walked with the same rules as the built-in roots. + +## What the scanner reads and writes + +- **Reads**: filesystem metadata (size, mtime, mode) for executables under known roots and `$PATH`; the first line of stdout for `tool --version` for a fixed list of 15 well-known CLIs (node, npm, pnpm, yarn, mcode, openclaw, clawhub, codex, git, python, python3, gh, docker, pwsh, powershell); `~/.gitconfig` for user/email; the list of filenames under `~/.ssh/` (NOT the key contents, NOT any other directory). +- **Writes**: `${PLUGIN_DATA}/tools.{md,json,summary.md}` (or the path given as `argv[2]`) only. Bundle-level atomicity: existing targets are backed up, new content is written to a staging directory, then each staging file is renamed onto its target. If any rename fails the previous catalog is restored and the staging/backup directories are removed. +- **Does not read**: the contents of any file under `~/.ssh/`; environment variable values that look like secrets; any registry, browser data, source code, or user documents. +- **Does not write**: any file outside the output directory; any user or host install area; any registry or config under `~/.config/`, `~/.minimax/`, or `~/.openclaw*/`. +- **Does not send**: any network request, any telemetry, any data to any third party. The scanner is fully offline. + +## Side effects (subprocess execution) + +The scanner's only side effect beyond writing the catalog files is **executing 15 well-known CLI programs** with `--version` (or, for PowerShell, a single read-only `$PSVersionTable.PSVersion.ToString()` call). This is a deliberate, declared behaviour — version strings make the catalog more useful. + +- The exact set of executable names is hardcoded as `VERSION_PROBES` in `scripts/scan.mjs` and is mirrored in `ALLOWED_PROBE_NAMES`. Any probe request for a name outside the whitelist is refused inside `probeVersion` (fail-closed). +- Probes are run via `execFile`, not `shell`, on every platform. The program name and its single `--version` argument (or the `-NoProfile -Command $PSVersionTable.PSVersion.ToString()` triple for PowerShell) are passed as a separate argv, so a same-named wrapper on `$PATH` cannot be tricked into executing arbitrary code from shell metacharacters in the path. +- One Windows-only exception: `.cmd` and `.bat` shims are routed through `cmd.exe`. The Node.js 21.7.3 fix for CVE-2024-27980 refuses to spawn batch files via `execFile` without `shell: true`; this Plugin requires Node >= 22 so the fix is in force. The shell decision is per-program: the scanner walks `$PATH` and `$PATHEXT` to find the actual file the OS would execute, and sets `shell: true` only for programs whose resolved path ends in `.cmd` or `.bat`. Native `.exe` binaries (including `powershell.exe`) are spawned directly. POSIX always uses no shell. +- Every probe has a hard 5 s `execFile` timeout; timeouts, ENOENT, and non-zero exits are all swallowed. A tool that hangs longer than 5 s is simply omitted from the `core` versions table. +- No user input is ever passed to a probe. The whitelist is the single source of truth for what may run. + +Review your `$PATH` and any same-named wrappers in the well-known roots before installing this Plugin if you consider arbitrary command execution a concern. + +## Failure modes + +- A tool's `cmd --version` hangs - the 5 s timeout aborts the probe; that tool is omitted from the `core` versions table but stays in the file-walk inventory. +- A directory is unreadable (permission denied, broken symlink) - skipped silently; the walk continues. +- Output path is on a different filesystem from the staging location - atomic rename still works because staging lives next to the target file, not in `os.tmpdir()`. +- `${PLUGIN_DATA}` is not set - the scanner falls back to `$XDG_DATA_HOME/tool-map` (or `~/.local/share/tool-map` when the env var is also unset). +- A mid-bundle rename fails (extremely rare: disk full, AV lock) - the previous catalog is restored from backup and the staging/backup directories are removed. The agent sees the same catalog it saw before the failed scan. +- `TOOL_MAP_ROOTS` contains a non-existent path - that path is skipped; the rest of the walk continues. diff --git a/test-fixtures/drive-bundle-failure-5.mjs b/test-fixtures/drive-bundle-failure-5.mjs new file mode 100644 index 0000000..d462f4a --- /dev/null +++ b/test-fixtures/drive-bundle-failure-5.mjs @@ -0,0 +1,36 @@ +// Test helper: drive atomicWriteBundle with FIVE files instead of three +// (the original drive-bundle-failure.mjs uses three, which is fine for +// the Phase-3 mid-bundle test that lands failure on rename #4). The +// brand-new partial-install test needs renames #1-#8 to be triggered +// across Phases 1+3, with failure on #8, so the three-file helper is +// not enough. +// +// Renames driven: #1=md-backup, #2=json-backup, #3=summary-backup +// (no backup for new1, new2) +// #4=md-install, #5=json-install, #6=summary-install, +// #7=new1-install, #8=new2-install +// Triggering TOOL_MAP_FAIL_AT_RENAME=8 causes new2-install to throw +// after new1 has already been renamed onto its (previously-absent) target. +// +// Usage: node test-fixtures/drive-bundle-failure-5.mjs +// Exits 0 on unexpected success, non-zero on expected throw. + +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +const target = resolve(process.argv[2]); +const scanUrl = pathToFileURL( + resolve(process.argv[1], '..', '..', 'plugins', 'antianqi', 'tool-map', 'scripts', 'scan.mjs'), +).href; + +const { atomicWriteBundle } = await import(scanUrl); + +atomicWriteBundle(target, { + 'tools.md': 'NEW-MD', + 'tools.json': 'NEW-JSON', + 'tools.summary.md': 'NEW-SUMMARY', + 'tools.new1': 'NEW-NEW1', + 'tools.new2': 'NEW-NEW2', +}); +console.log('UNEXPECTED success'); +process.exit(99); diff --git a/test-fixtures/drive-bundle-failure.mjs b/test-fixtures/drive-bundle-failure.mjs new file mode 100644 index 0000000..e258602 --- /dev/null +++ b/test-fixtures/drive-bundle-failure.mjs @@ -0,0 +1,21 @@ +// Test helper: drive atomicWriteBundle with a controlled failure point. +// Usage: node test/tool-map-helper.mjs +// Honours TOOL_MAP_FAIL_AT_RENAME: if set, the Nth rename in the +// scan.mjs atomicWriteBundle implementation throws (the hook is built +// into scan.mjs). Exits 0 on success, non-zero on expected throw. + +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +const target = resolve(process.argv[2]); +const scanUrl = pathToFileURL(resolve(process.argv[1], '..', '..', 'plugins', 'antianqi', 'tool-map', 'scripts', 'scan.mjs')).href; + +const { atomicWriteBundle } = await import(scanUrl); + +atomicWriteBundle(target, { + 'tools.md': 'NEW-MD', + 'tools.json': 'NEW-JSON', + 'tools.summary.md': 'NEW-SUMMARY', +}); +console.log('UNEXPECTED success'); +process.exit(99); diff --git a/test/codex-harness-patterns.test.mjs b/test/codex-harness-patterns.test.mjs new file mode 100644 index 0000000..a7d1230 --- /dev/null +++ b/test/codex-harness-patterns.test.mjs @@ -0,0 +1,723 @@ +// codex-harness-patterns.test.mjs +// +// PR #18 review round 2: a static check that every one of the 23 +// `plugins/antianqi/codex-harness-patterns/skills/*/SKILL.md` files has +// exactly one valid YAML frontmatter block, with the required fields, and +// without a duplicate `author:` or `version:` key anywhere in the file. Also +// pins the mcode 0.2.4 tool-surface contract for the 5 Skills that touch the +// `task` / `bash` tools (no `subagent_type=` / `brief=` / `history=` / +// `bash(task_name=)` / `bash(action=)` / `model_config_id=` placeholders). +// +// Pinned against the bundled mcode 0.2.4 `cli.js` schema: +// - `task(description, prompt, agent_name, run_in_background?)` +// - `bash(command, timeout?, run_in_background?)` +// - `task_query(task_id?, status?)` +// - `task_output(task_id, offset?)` +// - `task_stop(task_id, reason?)` +// `agent_name=` is the canonical mcode 0.2.4 form. The Skills prefer the +// canonical form; this test fails if any Skill body uses `subagent_type=` +// inside a `task(` call. Mentioning `subagent_type` in prose (e.g. the +// `compatibility:` field) is allowed. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const PLUGIN_DIR = join(REPO_ROOT, 'plugins', 'antianqi', 'codex-harness-patterns'); +const SKILLS_ROOT = join(PLUGIN_DIR, 'skills'); + +// --- Helpers ---------------------------------------------------------------- + +function listSkillFiles() { + return readdirSync(SKILLS_ROOT, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => ({ + name: d.name, + path: join(SKILLS_ROOT, d.name, 'SKILL.md'), + })) + .filter((d) => { + try { return statSync(d.path).isFile(); } catch { return false; } + }); +} + +// Very small YAML frontmatter parser. Supports the shape used by every +// Skill in this plugin: top-level `key: scalar` pairs, and a single +// `metadata:` block whose body is indented 2 spaces and contains +// `key: scalar` pairs. Returns the parsed object plus a list of (line, +// key) entries in document order so we can detect duplicates. +function parseFrontmatter(text) { + // Normalize line endings to LF. Skill files on Windows are commonly + // written with CRLF; if we don't normalize first, line 53's strict + // `text.startsWith('---\n')` check fails on every Skill, and the + // inner-`---` regex (line 67) silently misses lines that end in \r + // because `$` is anchored to the position before \n, not before \r. + // Round-5 finding: frontmatter uniqueness check previously "only + // counted lines exactly equal to `---`" because the regex /\s*---\s*$/ + // didn't match `\r`-terminated lines; this normalization closes that + // hole by making the parser see a single canonical line ending. + const t = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + if (!t.startsWith('---\n')) { + throw new Error('frontmatter must start with "---\\n" at the very top of the file'); + } + const end = t.indexOf('\n---\n', 4); + if (end < 0) { + throw new Error('frontmatter must be closed by a line containing only "---"'); + } + const body = t.slice(4, end); + const lines = body.split('\n'); + + // No `---` line is allowed inside the frontmatter body. The closing + // marker is on its own line; we already extracted everything before + // it, so an inner `---` (matched by /^\s*---\s*$/) would corrupt the + // parse and split the frontmatter into two pieces. + const innerClose = lines.findIndex((l) => /^\s*---\s*$/u.test(l)); + if (innerClose >= 0) { + throw new Error(`frontmatter contains an inner "---" line at line ${innerClose + 1} (would split the block)`); + } + + // YAML keys in this plugin: ASCII letters / digits / underscore, plus + // dot and hyphen for the version-suffixed change-log keys + // (e.g. `changes-from-v0.1.2`). Anything fancier should be quoted. + const KEY_RE = /^[A-Za-z_][A-Za-z0-9_.\-]*$/u; + + const out = { _keys: [] }; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + if (line === '' || /^\s*#/u.test(line)) { i += 1; continue; } + const m = line.match(/^([A-Za-z_][A-Za-z0-9_.\-]*)\s*:\s*(.*)$/u); + if (!m) { throw new Error(`unparseable frontmatter line ${i + 1}: ${JSON.stringify(line)}`); } + const key = m[1]; + const rest = m[2]; + if (out._keys.includes(key)) { + throw new Error(`duplicate top-level key "${key}" in frontmatter (first seen earlier)`); + } + out._keys.push(key); + if (rest === '' || rest === '|' || rest === '>') { + // Block value (literal `|` or folded `>`) or nested mapping under this key. + // Read indented continuation lines until we hit a non-indented line or EOF. + const blockKind = rest; + const blockLines = []; + i += 1; + while (i < lines.length && (lines[i] === '' || /^\s+/u.test(lines[i]))) { + blockLines.push(lines[i].replace(/^\s{1,2}/u, '')); + i += 1; + } + if (blockKind === '') { + // Nested mapping: parse each `key: value` line. + const nested = {}; + for (const bl of blockLines) { + if (bl === '') continue; + const nm = bl.match(/^([A-Za-z_][A-Za-z0-9_.\-]*)\s*:\s*(.*)$/u); + if (!nm) { throw new Error(`unparseable nested mapping line under "${key}": ${JSON.stringify(bl)}`); } + if (nested._keys?.includes(nm[1])) { + throw new Error(`duplicate nested key "${nm[1]}" under "${key}"`); + } + if (!nested._keys) nested._keys = []; + nested._keys.push(nm[1]); + // Strip surrounding quotes from the scalar value. + let v = nm[2].trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + v = v.slice(1, -1); + } + nested[nm[1]] = v; + } + out[key] = nested; + } else { + // Literal / folded scalar: collapse to a single string. + out[key] = blockLines.join('\n').trim(); + } + } else { + // Scalar value: strip surrounding quotes. + let v = rest.trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + v = v.slice(1, -1); + } + out[key] = v; + i += 1; + } + } + // Allow callers to introspect the raw key list; strip it from the + // returned object so it does not pollute property-style access. + const keys = out._keys; + delete out._keys; + out.__keys = keys; + return out; +} + +// Strip a single backtick-delimited code block / inline. We use this only +// to count prose claims (placeholders, Codex-harness parameter names), +// not to interpret the code; the asserts are conservative. +function findInCodeFences(text, re) { + // Normalize line endings to LF (see parseFrontmatter for rationale). + // The function is currently unused by the round-5 test surface + // (extractCallBodies replaced it on 61ae6f4), but it is kept as a + // public helper for any future round and must therefore be CRLF-safe + // to avoid silently returning 0 hits on Windows-checked-out files. + const t = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const hits = []; + const fenceRe = /```[a-zA-Z0-9_-]*\n([\s\S]*?)```/gu; + for (const m of t.matchAll(fenceRe)) { + const block = m[1]; + let mm; + const local = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'); + while ((mm = local.exec(block)) !== null) { + hits.push({ match: mm[0], line: block.slice(0, mm.index).split('\n').length }); + } + } + return hits; +} + +// Extract every FULL `fnName(...)` call from every code block in `text`, +// matching paren-balanced bodies (multi-line allowed). Returns +// `{ match, line }` for each call where `match` is the entire +// `fnName(args...)` substring and `line` is the 1-based line within +// the code block where the call starts. +// +// The earlier `findInCodeFences(text, /task\s*\(/u)` only returned the +// first 5 characters of the call ('task('), so the parameter-name +// asserts that ran against it were vacuously true (you cannot find +// 'agent_name=' inside 'task('). This balanced-paren extractor closes +// the false-green hole the round-4 review identified. +// +// Multi-line calls (most real `task(` / `bash(` examples are multi-line) +// are supported: the paren walker does not break on newline, only on EOF. +function extractCallBodies(text, fnName) { + // Normalize line endings to LF (see parseFrontmatter for rationale). + // Without this, on Windows the fenceRe below would not match code + // fences opened with ` ```lang\r\n` (CRLF after the lang tag), so + // every `task(...)` / `bash(...)` example in the Skills' Windows- + // checked-out files would be invisible to the static check. Same + // hole the round-5 reviewer flagged for parseFrontmatter. + const t = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const calls = []; + const nameRe = fnName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Use a negative lookbehind so we match 'task(' at start-of-string or + // after any non-word char (whitespace, `>`, `(`, `,`, newline). The + // simpler `(^|[^\w])` form also captures the preceding char which + // throws off the callStart index. + const callRe = new RegExp(`(? 0) { + const ch = block[i]; + if (inString) { + if (ch === '\\') { i += 2; lineStart = false; continue; } + if (ch === inString) inString = null; + } else if (ch === '"' || ch === "'") { + inString = ch; + } else if (ch === '(') { + depth += 1; + } else if (ch === ')') { + depth -= 1; + } + // newline is allowed inside the call body; track it only for + // the line-number report. + if (ch === '\n') lineStart = true; + else if (ch !== ' ' && ch !== '\t' && ch !== '\r') lineStart = false; + i += 1; + } + if (depth === 0) { + const callStr = block.slice(m.index, i); + const line = block.slice(0, m.index).split('\n').length; + calls.push({ match: callStr, line }); + } + } + } + return calls; +} + +// --- Tests ------------------------------------------------------------------ + +// === Negative-first fixture tests (round-4 review close-out) === +// +// Each of these is constructed against a synthetic SKILL.md string that +// embeds a known-bad pattern. The assert demonstrates that the helper +// under test actually catches the pattern. To prove the test would +// fail on the previous (round-4) implementation, the comment at the +// top of each test names the change that would break it. + +const NEG_TASK = `--- +name: test-skill +description: | + A test fixture that embeds a Codex-style task() call. +license: Apache-2.0 +metadata: + author: antianqi + version: "0.0.1" +--- + +# Test + +The point of this fixture is to embed a \`task(agent_name="explore", brief="...")\` +call inside a code block. Any extractor that returns only the literal +\`task(\` (5 chars) cannot see the body and cannot fail this fixture. + +\`\`\`text +> task( + agent_name="explore", + brief="Investigate X", + description="d" + ) +\`\`\` +`; + +test('extractCallBodies returns the full task(...) body (not just "task(")', () => { + // This is the round-4 false-green hole: findInCodeFences returned + // mm[0] (the regex match = 'task('), so the assert + // !/\bagent_name\s*=/u.test('task(') was always true and the + // contract check never saw the actual parameters. + const calls = extractCallBodies(NEG_TASK, 'task'); + assert.equal(calls.length, 1, `expected 1 task(...) call, got ${calls.length}`); + // The body must contain the parameter names that appear AFTER '(' + // in the fixture. 'task(' alone would fail all four asserts below. + assert.match(calls[0].match, /\bagent_name\s*=/u, + 'extractCallBodies must capture the full body, including the agent_name= arg (proves the regex does not stop at the open paren)'); + assert.match(calls[0].match, /\bbrief\s*=/u, + 'extractCallBodies must capture brief= (the parameter that comes after the open paren)'); + assert.match(calls[0].match, /\bdescription\s*=/u, + 'extractCallBodies must capture description= (a parameter from the end of the body)'); +}); + +test('extractCallBodies returns "bash(...)" with full body, not just "bash("', () => { + const NEG_BASH = ` +\`\`\`text +> bash( + command="echo hello", + run_in_background=true + ) +# returns { job_id: "job_x", pid: 12345, log: "..." } +\`\`\` +`; + const calls = extractCallBodies(NEG_BASH, 'bash'); + assert.equal(calls.length, 1); + assert.match(calls[0].match, /\bcommand\s*=/u, + 'extractCallBodies must capture the full bash body including the command= arg'); + assert.match(calls[0].match, /\brun_in_background\s*=\s*true/u, + 'extractCallBodies must capture run_in_background=true in the bash body'); +}); + +test('extractCallBodies does NOT report false positives in prose', () => { + // The helper must only look at code blocks; a prose mention of + // "task(subagent=...)" should NOT be flagged because that prose + // is documenting the round-1 defect, not using it. + const PROSE_ONLY = ` +This Skill previously used the Codex-style \`task(subagent=...)\` form +but the round-1 review required switching to the canonical mcode 0.2.4 +\`task(agent_name=...)\` form. See \`fork-context-decision/SKILL.md\` +for the corrected example. +`; + const calls = extractCallBodies(PROSE_ONLY, 'task'); + assert.equal(calls.length, 0, + `extractCallBodies must not match prose mentions of task(subagent=); got ${calls.length} false positive(s)`); +}); + +test('every body after the closing frontmatter has no stray "---" that could split a second block (round-1 defect shape)', () => { + // Round-1 reviewer finding on fork-context-decision: "two metadata + // blocks and a stray '---' inside the frontmatter, plus a duplicate + // '# Fork Context Decision' heading". The earlier parseFrontmatter + // check used indexOf('\n---\n', 4) which only found the FIRST close, + // so a second frontmatter-shaped block in the body was invisible. + // + // The fix is a structural check: walk the body and fail on any line + // that is exactly '---' (or matches the '---' close pattern) AFTER + // the first frontmatter close. That is the only way a second YAML + // document could begin. + const DUPLICATE_BLOCK = `--- +name: fork-context-decision +description: | + This Skill is about how much context to pass. +license: Apache-2.0 +metadata: + author: antianqi + version: "0.1.0" +--- +metadata: + author: HACKED_INJECT + version: "99.99.99" +--- + +# body +`; + const NEG = `--- +name: neg +description: | + This Skill is fine. +license: Apache-2.0 +metadata: + author: antianqi + version: "0.1.0" +--- + +# body + +a prose paragraph. + +--- + +# another H1 (no second frontmatter, but the stray '---' is still wrong) +`; + // Helper: find the first frontmatter close, then check the body. + const stray = (text) => { + const t = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const end = t.indexOf('\n---\n', 4); + if (end < 0) return null; + const body = t.slice(end + 5); + // Find any line in the body that is exactly '---'. If found, + // return the line number (1-based, in the body). + const lines = body.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (/^\s*---\s*$/u.test(lines[i])) return { line: i + 1, content: lines[i] }; + } + return null; + }; + assert.ok(stray(DUPLICATE_BLOCK) !== null, + 'a duplicate-block fixture must be detected (the round-1 defect shape)'); + assert.ok(stray(NEG) !== null, + 'a stray "---" line in prose must also be detected (a future regression shape)'); + // Positive case: a clean body must NOT have any stray '---' line. + const CLEAN = `--- +name: clean +description: | + A clean Skill. +license: Apache-2.0 +metadata: + author: antianqi + version: "0.1.0" +--- + +# body +no stray dashes here. +`; + assert.equal(stray(CLEAN), null, 'a clean body must have no stray "---"'); +}); + +// === Existing 23-Skill checks (now using extractCallBodies) === + +test('all 23 SKILL.md files exist (one per directory under skills/)', () => { + const skills = listSkillFiles(); + assert.equal(skills.length, 23, `expected 23 Skills under ${SKILLS_ROOT}, found ${skills.length}: ${skills.map(s => s.name).join(', ')}`); +}); + +for (const { name, path } of listSkillFiles()) { + test(`SKILL.md for ${name} has exactly one valid frontmatter block`, () => { + const text = readFileSync(path, 'utf8'); + const fm = parseFrontmatter(text); + + // Required top-level fields. + assert.equal(fm.name, name, `${name}: frontmatter "name" must equal the directory name`); + assert.equal(typeof fm.description, 'string', `${name}: frontmatter "description" is required`); + assert.ok(fm.description.length > 0, `${name}: frontmatter "description" must be non-empty`); + assert.ok(fm.description.length <= 1024, `${name}: frontmatter "description" must be at most 1024 characters (got ${fm.description.length})`); + assert.equal(fm.license, 'Apache-2.0', `${name}: frontmatter "license" must be Apache-2.0`); + + // Required metadata block (every Skill in this plugin has it). + assert.ok(fm.metadata && typeof fm.metadata === 'object', `${name}: frontmatter "metadata" block is required`); + assert.equal(fm.metadata.author, 'antianqi', `${name}: metadata.author must be "antianqi"`); + assert.ok(typeof fm.metadata.version === 'string' && fm.metadata.version.length > 0, + `${name}: metadata.version is required and must be non-empty`); + + // Body after the closing `---` must be non-empty. + const body = text.slice(text.indexOf('\n---\n', 4) + 5).trim(); + assert.ok(body.length > 0, `${name}: instructions are required after the frontmatter`); + }); +} + +test('no SKILL.md contains a duplicate `author:` or `version:` key anywhere', () => { + // parseFrontmatter already rejects duplicate top-level / nested keys, but + // a body that *also* repeats `author:` outside the frontmatter would slip + // through. Pin both layers. + for (const { name, path } of listSkillFiles()) { + const text = readFileSync(path, 'utf8'); + // Strip the frontmatter so we only check the body. + const end = text.indexOf('\n---\n', 4); + const body = text.slice(end + 5); + // A body line of `author: ...` or `version: ...` at column 0 is the + // "duplicate author/version block" defect the reviewer flagged in the + // previous round (see `fork-context-decision` v0.1.x). Bullet-list / + // table / prose mentions like `- **author**: foo` or backtick-quoted + // `version:` are allowed and are not matched. + const dupAuthor = /(^|\n)author\s*:/u.test(body); + const dupVersion = /(^|\n)version\s*:/u.test(body); + assert.ok(!dupAuthor, `${name}: duplicate top-level "author:" key in body (forbidden — belongs only in metadata)`); + assert.ok(!dupVersion, `${name}: duplicate top-level "version:" key in body (forbidden — belongs only in metadata)`); + } +}); + +// --- mcode 0.2.4 tool-surface pinning --------------------------------------- + +// Skills that touch the `task` tool. The 5 Skills rewritten in this PR plus +// any future Skill that calls `task(` must use the canonical parameter +// names from `cli.js:B6c`: description, prompt, agent_name, run_in_background. +// The audit sweep after v1.0.4 also caught `error-recovery-strategy` which +// had a `task(subagent=...)` shape in its Example block; that fix is +// recorded as v0.1.2 of that Skill. The test below covers all Skills whose +// body contains a `task(` call in a code block — adding a new Skill that +// touches the `task` tool (or a new `task(` call in an existing Skill) +// will be caught by this test automatically. +const TASK_SKILLS = [ + 'fork-context-decision', + 'delegate-with-context', + 'parallel-fanout', + 'model-router', + 'background-task', + 'error-recovery-strategy', +]; + +test('TASK_SKILLS use canonical mcode 0.2.4 `task` parameter names (no legacy `subagent_type=` / `agent_type=` / `subagent=` / `brief=` / `history=` / `model_config_id=` / `fork_turns=`)', () => { + for (const name of TASK_SKILLS) { + const path = join(SKILLS_ROOT, name, 'SKILL.md'); + const text = readFileSync(path, 'utf8'); + const calls = extractCallBodies(text, 'task'); + assert.ok(calls.length > 0, `${name}: expected at least one task(...) example in a code block`); + + for (const { match } of calls) { + // Inside every `task(` example, the parameters must be the canonical + // mcode 0.2.4 set. extractCallBodies returns the FULL paren-balanced + // body, so the regex below sees the actual arguments (round-4 fix: + // the previous findInCodeFences returned only 'task(' and the + // asserts were vacuously true). + // + // The banned list is the union of all Codex-harness parameter + // names that have appeared in the round-1 / round-2 / round-3 / + // round-4 review comments. Each is a known-bad shape that the + // mcode 0.2.4 `task` tool (cli.js:B6c strict validator) does + // not accept. `subagent_type=` is also banned because the + // canonical form is `agent_name=` (cli.js:j6c accepts the + // alias but the Skills prefer canonical). + assert.ok(!/\bsubagent_type\s*=/u.test(match), + `${name}: task(...) example uses "subagent_type="; mcode 0.2.4 canonical is "agent_name=" (subagent_type is accepted as a runtime alias but Skills prefer canonical)`); + assert.ok(!/\bsubagent\s*=/u.test(match), + `${name}: task(...) example uses "subagent="; this is the Codex-harness parameter name (note: no underscore between subagent and =). mcode 0.2.4 canonical is "agent_name=" (round-1 defect shape, was in parallel-fanout and delegate-with-context before v1.0.3)`); + assert.ok(!/\bbrief\s*=/u.test(match), + `${name}: task(...) example uses "brief="; mcode canonical is "prompt="`); + assert.ok(!/\bhistory\s*=/u.test(match), + `${name}: task(...) example uses "history="; mcode 0.2.4 has no context-sharing parameter (the 3 fork modes are expressed by what is inlined into "prompt")`); + assert.ok(!/\bmodel_config_id\s*=/u.test(match), + `${name}: task(...) example uses "model_config_id="; mcode 0.2.4 task tool does not expose a per-call model field (model selection is session-level)`); + assert.ok(!/\bfork_turns\s*=/u.test(match), + `${name}: task(...) example uses "fork_turns="; this is the Codex-harness parameter name, removed in v1.0.3`); + assert.ok(!/\bagent_type\s*=/u.test(match), + `${name}: task(...) example uses "agent_type="; mcode canonical is "subagent_type="`); + } + } +}); + +// Catch-all: any Skill whose code block contains a `task(` call but is +// not in the TASK_SKILLS allow-list must be added to the list (or the +// call removed). Without this, a future contributor could drop a +// `task(subagent=...)` shape into e.g. `plan-stream-emit` and slip +// past the static check. +test('every Skill with a `task(` call in a code block is in the TASK_SKILLS allow-list', () => { + const allow = new Set(TASK_SKILLS); + const offenders = []; + for (const { name, path } of listSkillFiles()) { + const text = readFileSync(path, 'utf8'); + // Use the full-body extractor so a Skill that hides a `task(` + // call in the middle of a long body is still detected. + const calls = extractCallBodies(text, 'task'); + if (calls.length === 0) continue; + if (!allow.has(name)) offenders.push(name); + } + assert.deepEqual(offenders, [], + `Skills with a task(...) call but not in the static-check allow-list: ${offenders.join(', ')}. Either add them to TASK_SKILLS (and pin their parameter names) or remove the task(...) call.`); +}); + +// === Round-4 #3: sub-agent manifest path verification === +// +// Reviewer finding: "fork-context-decision/SKILL.md 仍声称三个 sub-agent +// manifest 位于 assets/agents//agent.md;请用当前 MiniMax Code 可验证 +// 契约确认该路径". The Skills name explore / worker / verifier as +// agent_name values; we observed in our own mcode 0.2.4 install that +// each named sub-agent has a per-agent manifest at +// `assets/agents//agent.md` (the path is **not** part of the +// public runtime contract — it is a dev-machine-internal layout that +// varies across installs and platforms; user-facing Skills no longer +// reference it). This test is a dev-only best-effort verification +// from the active mcode install; it is skipped if no mcode is +// reachable. The Skills themselves use the canonical mcode 0.2.4 +// `agent_name=` form; this test fails closed if any Skill body uses +// the legacy `subagent_type=` form, OR if a Skill claims a value +// whose on-disk manifest is missing in our own install. +test('sub-agent types claimed in Skills are present in the local mcode 0.2.4 install (dev-only check; skipped if mcode is unreachable)', () => { + // Locate the mcode install. The active mcode is at $env:LOCALAPPDATA + // typically; fall back to a well-known absolute path. Skip the test + // (not fail it) if no mcode install is reachable, so this test is + // hermetic on dev machines that don't have mcode installed. + const candidates = [ + join(process.env.LOCALAPPDATA || '', 'npm', 'node_modules', '@minimax-ai', 'code'), + join(process.env.APPDATA || '', 'npm', 'node_modules', '@minimax-ai', 'code'), + 'C:\\Users\\Administrator\\.minimax-code\\node_modules\\@minimax-ai\\code', + ]; + const mcodeRoot = candidates.find((p) => p && existsSync(join(p, 'assets', 'agents'))); + if (!mcodeRoot) { + // No mcode reachable on this machine. Skip rather than fail. + return; + } + // Scan all 23 Skills; collect every distinct agent_name value that + // appears in a `task(... agent_name="X" ...)` example. The values + // must come from the canonical mcode 0.2.4 set. + const claimed = new Set(); + const reSub = /\bagent_name\s*=\s*["']([a-z0-9_-]+)["']/giu; + for (const { path } of listSkillFiles()) { + const text = readFileSync(path, 'utf8'); + for (const c of extractCallBodies(text, 'task')) { + for (const m of c.match.matchAll(reSub)) claimed.add(m[1]); + } + } + // The canonical sub-agent types (verified from the local mcode + // install's `assets/agents/{explore,worker,verifier}/agent.md` + // layout). `mavis` is the root agent and MUST NOT be a claimed + // agent_name. + assert.ok(!claimed.has('mavis'), + 'mavis is the root agent, not an agent_name; some Skill is still claiming it. Found in: ' + Array.from(claimed).join(', ')); + for (const name of claimed) { + if (name === 'mavis') continue; // asserted above + const manifest = join(mcodeRoot, 'assets', 'agents', name, 'agent.md'); + assert.ok(existsSync(manifest), + `agent_name "${name}" is claimed in a Skill example but the manifest ${manifest} does not exist on disk. Either the Skill is wrong or the mcode install is wrong.`); + } +}); + +test('background-task uses `task(run_in_background: true)` + `task_query` / `task_output` / `task_stop` (no `bash(task_name=...)`, no `bash(action="kill")`)', () => { + const path = join(SKILLS_ROOT, 'background-task', 'SKILL.md'); + const text = readFileSync(path, 'utf8'); + + // Sub-agent background uses the `task` tool with `run_in_background: true`. + // extractCallBodies returns the FULL paren-balanced body so the + // run_in_background check actually sees the body (round-4 fix). + const taskCalls = extractCallBodies(text, 'task'); + assert.ok(taskCalls.length > 0, + 'background-task must show at least one task(...) example (inside a code block)'); + assert.ok(taskCalls.some((c) => /\brun_in_background\s*[:=]\s*true/u.test(c.match)), + 'background-task must show run_in_background: true / = true inside a task(...) call'); + assert.ok(extractCallBodies(text, 'task_query').length > 0, + 'background-task must show task_query(...) for listing / fetching a background task'); + assert.ok(extractCallBodies(text, 'task_output').length > 0, + 'background-task must show task_output(task_id=...) for reading output'); + assert.ok(extractCallBodies(text, 'task_stop').length > 0, + 'background-task must show task_stop(task_id=...) for stopping a background task'); + + // Shell background uses the `bash` tool with `run_in_background: true. + // The call must carry run_in_background=true AND a documented return + // shape (job id / pid / log path) somewhere in the same code block + // (round-4 finding: the return shape was prose-only, not test-pinned). + const bashCalls = extractCallBodies(text, 'bash'); + assert.ok(bashCalls.length > 0, + 'background-task must show at least one bash(...) example (inside a code block)'); + const bgBashCalls = bashCalls.filter((c) => /\brun_in_background\s*[:=]\s*true/u.test(c.match)); + assert.ok(bgBashCalls.length > 0, + 'background-task must show at least one bash(... run_in_background: true) example'); + // For each background-bash call, the call body must include a + // documented handle. We extract the whole fenced block the call + // appears in and assert the block mentions a handle keyword. + // Normalize line endings first; the call bodies above are taken from + // the normalized text, so the search has to be too. + const textNorm = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + for (const call of bgBashCalls) { + const fenceRe = /```[a-zA-Z0-9_-]*\n([\s\S]*?)```/gu; + let hostBlock = null; + for (const fm of textNorm.matchAll(fenceRe)) { + if (fm[1].includes(call.match)) { hostBlock = fm[1]; break; } + } + assert.ok(hostBlock !== null, + `background-task has a bash(... run_in_background: true) call but its code block could not be located: ${call.match.slice(0, 80)}`); + const hasHandle = /\b(job_?id|pid|log_?path|log\b|handle)\b/iu.test(hostBlock); + assert.ok(hasHandle, + `background-task bash(... run_in_background: true) example in code block must document the return handle (job id / pid / log path). Block:\n${hostBlock}`); + } + + // Codex-harness placeholders that do NOT exist on mcode 0.2.4. We only + // look inside code blocks; prose mentions like "removed bash(task_name=...)" + // in the frontmatter change-log are allowed (they are explicitly removing + // the bad pattern, not using it). The call body is the FULL paren-balanced + // body now (round-4 fix), so this assert actually sees the args. + for (const call of bashCalls) { + assert.ok(!/\btask_name\s*=/u.test(call.match), + 'background-task code block uses "bash(... task_name=...)" — mcode 0.2.4 bash has no task_name field; rewrite as `bash(command=..., run_in_background=true)`'); + assert.ok(!/\baction\s*=\s*["']kill["']/u.test(call.match), + 'background-task code block uses "bash(... action=\"kill\")" — mcode 0.2.4 bash has no action sub-action; killing is via task_stop or the host job-control API'); + } +}); + +// PR #18 round-5 (hetaoBackend, 2026-09-01T01:25:04Z) and round-7 +// (hetaoBackend, 2026-09-02T01:08:26Z): the plugin.json must +// declare an enforceable minMcodeVersion. PR #18 round-7 added the +// constraint that the portable Plugin schema +// (https://agent-plugins.org/schemas/1.0.0/plugin.schema.json) does +// not have a `requirements` field, so we cannot pin a minMcodeVersion +// at the manifest level. The contract surface for the version +// requirement is the `description` field instead. This test +// fails closed: +// - a missing `description` +// - a `description` that does not pin a mcode 0.2.4+ surface +// - a `description` that mentions a different tool surface than +// `task(description, prompt, agent_name, run_in_background?)` +// any of these is a hard FAIL. A future change that relaxes the +// pin (e.g. claims "any mcode version" or removes the `agent_name` +// string) will fail this test. +test('R18-2 plugin.json description pins mcode >= "0.2.4" and the canonical task surface (fail-closed)', () => { + const pluginPath = join(REPO_ROOT, 'plugins', 'antianqi', 'codex-harness-patterns', 'plugin.json'); + const text = readFileSync(pluginPath, 'utf8'); + // Normalize line endings so string comparison is not affected by + // CRLF artifacts in the JSON file. + const textNorm = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + // Parse the JSON (small file, sync read is fine). + let plugin; + try { + plugin = JSON.parse(textNorm); + } catch (e) { + assert.fail(`plugin.json is not valid JSON: ${e.message}`); + } + + assert.ok(plugin && typeof plugin === 'object', + 'plugin.json must parse to a JSON object'); + + // The portable Plugin schema (v1.0.0) does not have a `requirements` + // field. Pinned: a host version constraint must be expressed in + // the existing `description` field. Any `requirements` field is a + // hard FAIL because the validator (`node scripts/validate.mjs`) + // rejects unknown fields. + assert.ok(!('requirements' in plugin), + 'plugin.json must NOT declare a `requirements` field; the portable ' + + 'Plugin schema (v1.0.0) rejects unknown top-level fields, so a ' + + '`requirements` block makes the Plugin unloadable. Pin the host ' + + 'version in the `description` field instead.'); + + // description: a non-empty string that pins a mcode 0.2.4+ surface + // and names the canonical `agent_name` parameter (vs the legacy + // `subagent_type` placeholder that earlier mcode versions used). + assert.ok(typeof plugin.description === 'string' && plugin.description.length > 0, + 'plugin.json `description` must be a non-empty string'); + assert.ok(/\bmcode\s*0\.2\.4\b/u.test(plugin.description) + || /\bMiniMax Code\s*0\.2\.4\b/u.test(plugin.description) + || /\b0\.2\.4\+/u.test(plugin.description), + 'plugin.json `description` must pin mcode 0.2.4+ (fail-closed; if a ' + + 'future change relaxes the version pin, this test will fail and ' + + 'the Plugin can no longer be assumed to be safe to load on the ' + + 'documented tool surface)'); + assert.ok(/\bagent_name\s*=/u.test(plugin.description) + || /\bagent_name\b/u.test(plugin.description), + 'plugin.json `description` must mention the canonical mcode 0.2.4 ' + + '`agent_name` parameter (vs the legacy `subagent_type` placeholder ' + + 'used by mcode 0.2.0 and earlier)'); + // Negative-injection: the description must NOT claim that the + // legacy `subagent_type=` placeholder is supported (we removed it). + assert.ok(!/\bsubagent_type\s*=/u.test(plugin.description), + 'plugin.json `description` must NOT advertise `subagent_type=` (the ' + + 'Skills in this plugin no longer use the legacy placeholder; mcode ' + + '0.2.4+ uses canonical `agent_name=`)'); +}); diff --git a/test/tool-map.test.mjs b/test/tool-map.test.mjs new file mode 100644 index 0000000..241325e --- /dev/null +++ b/test/tool-map.test.mjs @@ -0,0 +1,1017 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + mkdtempSync, existsSync, readFileSync, writeFileSync, statSync, rmSync, + readdirSync, mkdirSync, chmodSync, utimesSync, symlinkSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname, delimiter } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const PLUGIN_DIR = join(REPO_ROOT, 'plugins', 'antianqi', 'tool-map'); +const SCAN = join(PLUGIN_DIR, 'scripts', 'scan.mjs'); +const SMOKE = join(PLUGIN_DIR, 'scripts', 'smoke.mjs'); + +function runScan(outPath, extraEnv = {}) { + return spawnSync(process.execPath, [SCAN, outPath], { + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, ...extraEnv }, + }); +} + +test('scan.mjs writes the three catalog files atomically', () => { + const work = mkdtempSync(join(tmpdir(), 'tool-map-write-')); + try { + const out = join(work, 'tools.md'); + const r = runScan(out); + assert.equal(r.status, 0, `scan failed (exit ${r.status}):\n${r.stderr}\n${r.stdout}`); + const stem = out.replace(/\.md$/, ''); + for (const path of [out, `${stem}.json`, `${stem}.summary.md`]) { + assert.ok(existsSync(path), `missing ${path}`); + assert.ok(statSync(path).size > 0, `empty ${path}`); + } + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('scan.mjs JSON has the expected schema', () => { + const work = mkdtempSync(join(tmpdir(), 'tool-map-schema-')); + try { + const out = join(work, 'tools.md'); + const r = runScan(out); + assert.equal(r.status, 0, `scan failed: ${r.stderr}`); + const json = JSON.parse(readFileSync(out.replace(/\.md$/, '') + '.json', 'utf8')); + assert.equal(typeof json.scanned, 'string', 'scanned timestamp required'); + assert.equal(typeof json.platform, 'string', 'platform required'); + assert.equal(typeof json.host, 'string', 'host required'); + assert.equal(typeof json.core, 'object', 'core versions object required'); + assert.equal(typeof json.extras, 'object', 'extras object required'); + assert.ok(Array.isArray(json.tools), 'tools must be an array'); + for (const t of json.tools) { + assert.equal(typeof t.name, 'string'); + assert.equal(typeof t.type, 'string'); + assert.equal(typeof t.path, 'string'); + assert.equal(typeof t.size, 'number'); + assert.equal(typeof t.modified, 'string'); + assert.equal(typeof t.category, 'string'); + } + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('scan.mjs writes nothing outside the output directory', () => { + const work = mkdtempSync(join(tmpdir(), 'tool-map-isolated-')); + try { + const out = join(work, 'tools.md'); + const r = runScan(out); + assert.equal(r.status, 0, `scan failed: ${r.stderr}`); + const entries = readdirSync(work).sort(); + assert.deepEqual( + entries, + ['tools.json', 'tools.md', 'tools.summary.md'], + `unexpected files in output dir: ${entries.join(', ')}`, + ); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('scan.mjs leaves no staging files on success', () => { + const work = mkdtempSync(join(tmpdir(), 'tool-map-nostage-')); + try { + const out = join(work, 'tools.md'); + const r = runScan(out); + assert.equal(r.status, 0, `scan failed: ${r.stderr}`); + const entries = readdirSync(work); + for (const e of entries) { + assert.ok( + !e.includes('.staging-') && !e.includes('.bundle.staging-'), + `staging file or dir leaked: ${e}`, + ); + } + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('scan.mjs completes with an empty PATH and still produces a valid catalog', () => { + const work = mkdtempSync(join(tmpdir(), 'tool-map-probes-')); + try { + const out = join(work, 'tools.md'); + const r = spawnSync(process.execPath, [SCAN, out], { + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, PATH: '', TOOL_MAP_ROOTS: '' }, + }); + assert.equal(r.status, 0, `scan failed with empty PATH: ${r.stderr}\n${r.stdout}`); + const stem = out.replace(/\.md$/, ''); + for (const path of [out, `${stem}.json`, `${stem}.summary.md`]) { + assert.ok(existsSync(path), `missing ${path} after empty-PATH run`); + } + const json = JSON.parse(readFileSync(out.replace(/\.md$/, '') + '.json', 'utf8')); + assert.equal(typeof json.platform, 'string'); + assert.ok(Array.isArray(json.tools)); + for (const t of json.tools) { + assert.notEqual(t.category, 'PATH', 'PATH-categorized tool leaked with empty $PATH'); + } + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('smoke.mjs exits 0 against the plugin source tree', () => { + const r = spawnSync(process.execPath, [SMOKE], { + encoding: 'utf8', + timeout: 15_000, + }); + assert.equal( + r.status, 0, + `smoke failed (exit ${r.status}):\n${r.stderr}\nstdout:\n${r.stdout}`, + ); + assert.match(r.stdout, /OK scanned \d+ files, 0 violations\./u); +}); + +// --------------------------------------------------------------------------- +// Adversarial tests for the v0.2.0-beta.2 review blockers +// --------------------------------------------------------------------------- + +test('atomicWriteBundle rolls back when a mid-bundle rename fails', async () => { + // We cannot reliably force a real OS-level rename failure in a portable + // test (Windows' MoveFileExW overwrites read-only files; POSIX rename + // behaves differently across filesystems). The implementation exposes + // a deterministic test hook: TOOL_MAP_FAIL_AT_RENAME=N makes the Nth + // rename throw. This is set on a child-process spawn below so the + // hook is scoped to the test and does not affect other tests. + const work = mkdtempSync(join(tmpdir(), 'tool-map-rollback-')); + try { + // Pre-fill both target files with sentinels so we can detect any + // overwrite that bypasses the rollback. + const mdSentinel = 'PRE-EXISTING-MD-SENTINEL'; + const jsonSentinel = 'PRE-EXISTING-JSON-SENTINEL'; + writeFileSync(join(work, 'tools.md'), mdSentinel); + writeFileSync(join(work, 'tools.json'), jsonSentinel); + + // Spawn node with the test hook armed at rename #4 (the first rename + // of a fresh atomicWriteBundle is #1 for the tools.md backup, #2 for + // the tools.json backup, #3 for the tools.summary.md backup, then + // #4 is the rename of the new tools.md onto the target. We pick #4 + // to simulate a failure that happens AFTER the backups are in + // place but BEFORE the new content lands. This is the case where + // rollback is hardest: the previous targets have already been moved + // to the backup dir, and a naive implementation would leave them + // stranded there). + const helperPath = join(REPO_ROOT, 'test-fixtures', 'drive-bundle-failure.mjs'); + const r = spawnSync(process.execPath, [helperPath, work], { + encoding: 'utf8', + timeout: 15_000, + env: { ...process.env, TOOL_MAP_FAIL_AT_RENAME: '4' }, + }); + assert.notEqual(r.status, 0, `helper should exit non-zero when the hook fires: stdout=${r.stdout}\nstderr=${r.stderr}`); + + // The previous catalog must be completely intact. + const mdAfter = readFileSync(join(work, 'tools.md'), 'utf8'); + assert.equal(mdAfter, mdSentinel, `tools.md was overwritten despite rollback: ${mdAfter}`); + const jsonAfter = readFileSync(join(work, 'tools.json'), 'utf8'); + assert.equal(jsonAfter, jsonSentinel, `tools.json was overwritten despite rollback: ${jsonAfter}`); + // tools.summary.md must not exist (was never written to the target). + assert.ok(!existsSync(join(work, 'tools.summary.md')), 'tools.summary.md leaked after rollback'); + // No staging or backup residue anywhere in the dir. + const entries = readdirSync(work); + const residue = entries.filter((e) => + e.includes('.staging-') || e.includes('.bundle.staging-') || e.includes('.bundle.backup-'), + ); + assert.equal(residue.length, 0, `staging/backup residue after rollback: ${residue.join(', ')}`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('atomicWriteBundle is idempotent on the happy path (no residue, all 3 present)', async () => { + const scanUrl = pathToFileURL(SCAN).href; + const { atomicWriteBundle } = await import(scanUrl); + const work = mkdtempSync(join(tmpdir(), 'tool-map-happy-')); + try { + atomicWriteBundle(work, { + 'a.txt': 'A', + 'b.txt': 'B', + 'c.txt': 'C', + }); + assert.equal(readFileSync(join(work, 'a.txt'), 'utf8'), 'A'); + assert.equal(readFileSync(join(work, 'b.txt'), 'utf8'), 'B'); + assert.equal(readFileSync(join(work, 'c.txt'), 'utf8'), 'C'); + const residue = readdirSync(work).filter((e) => e.includes('.staging-') || e.includes('.bundle.staging-')); + assert.equal(residue.length, 0, `staging residue: ${residue.join(', ')}`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('atomicWriteBundle rolls back when a backup-phase rename fails (early name)', async () => { + // Phase 1 (backup) failure on the very first name. `backups` is still + // empty, so the only thing the rollback must do is clean up the empty + // staging and backup dirs and leave the targets untouched. This is the + // simplest backup-phase case: no previous files have been moved yet. + const work = mkdtempSync(join(tmpdir(), 'tool-map-bkp-early-')); + try { + writeFileSync(join(work, 'tools.md'), 'PRE-MD'); + writeFileSync(join(work, 'tools.json'), 'PRE-JSON'); + // No tools.summary.md - target was absent before this call. + + const helperPath = join(REPO_ROOT, 'test-fixtures', 'drive-bundle-failure.mjs'); + const r = spawnSync(process.execPath, [helperPath, work], { + encoding: 'utf8', + timeout: 15_000, + env: { ...process.env, TOOL_MAP_FAIL_AT_RENAME: '1' }, + }); + assert.notEqual(r.status, 0, `helper should exit non-zero when the hook fires: stdout=${r.stdout}\nstderr=${r.stderr}`); + + // Previous targets are intact. + assert.equal(readFileSync(join(work, 'tools.md'), 'utf8'), 'PRE-MD'); + assert.equal(readFileSync(join(work, 'tools.json'), 'utf8'), 'PRE-JSON'); + // The previously-absent target is still absent. + assert.ok(!existsSync(join(work, 'tools.summary.md')), 'tools.summary.md was created on rollback'); + // No residue. + const residue = readdirSync(work).filter((e) => + e.includes('.staging-') || e.includes('.bundle.staging-') || e.includes('.bundle.backup-'), + ); + assert.equal(residue.length, 0, `staging/backup residue after Phase-1 rollback: ${residue.join(', ')}`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('atomicWriteBundle rolls back when a backup-phase rename fails (later name)', async () => { + // Phase 1 (backup) failure on the SECOND name. tools.md has already + // been moved to the backup dir; a naive implementation would leave it + // stranded there. The rollback must move it back to its target. + const work = mkdtempSync(join(tmpdir(), 'tool-map-bkp-late-')); + try { + writeFileSync(join(work, 'tools.md'), 'PRE-MD'); + writeFileSync(join(work, 'tools.json'), 'PRE-JSON'); + writeFileSync(join(work, 'tools.summary.md'), 'PRE-SUMMARY'); + + const helperPath = join(REPO_ROOT, 'test-fixtures', 'drive-bundle-failure.mjs'); + const r = spawnSync(process.execPath, [helperPath, work], { + encoding: 'utf8', + timeout: 15_000, + env: { ...process.env, TOOL_MAP_FAIL_AT_RENAME: '2' }, + }); + assert.notEqual(r.status, 0, `helper should exit non-zero when the hook fires: stdout=${r.stdout}\nstderr=${r.stderr}`); + + // Every previous target is back in place, byte-for-byte. + assert.equal(readFileSync(join(work, 'tools.md'), 'utf8'), 'PRE-MD', + 'tools.md was stranded in backup dir instead of restored to target'); + assert.equal(readFileSync(join(work, 'tools.json'), 'utf8'), 'PRE-JSON'); + assert.equal(readFileSync(join(work, 'tools.summary.md'), 'utf8'), 'PRE-SUMMARY'); + // No residue. + const residue = readdirSync(work).filter((e) => + e.includes('.staging-') || e.includes('.bundle.staging-') || e.includes('.bundle.backup-'), + ); + assert.equal(residue.length, 0, `staging/backup residue after Phase-1 rollback: ${residue.join(', ')}`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('atomicWriteBundle rolls back brand-new files that were partially installed', async () => { + // Phase 3 (install) failure on the LAST name after a brand-new file + // (one that did NOT exist before this call) was successfully installed. + // The rollback must delete the partially-installed new file so the + // directory looks like it did before the call. + // + // Renames: #1=md-backup, #2=json-backup, #3=summary-backup + // (no backup for tools.new1) + // #4=md-install, #5=json-install, #6=summary-install, + // #7=new1-install, #8=new2-install + // Trigger at #8 so the failure happens after the brand-new tools.new1 + // has already been renamed onto its target. `installed['tools.new1']` + // is true and `backups['tools.new1']` is null. + const work = mkdtempSync(join(tmpdir(), 'tool-map-new-partial-')); + try { + writeFileSync(join(work, 'tools.md'), 'PRE-MD'); + writeFileSync(join(work, 'tools.json'), 'PRE-JSON'); + writeFileSync(join(work, 'tools.summary.md'), 'PRE-SUMMARY'); + // tools.new1 and tools.new2 do NOT exist before the call. + + const helperUrl = pathToFileURL(join(REPO_ROOT, 'test-fixtures', 'drive-bundle-failure-5.mjs')).href; + const r = spawnSync(process.execPath, [helperUrl, work], { + encoding: 'utf8', + timeout: 15_000, + env: { ...process.env, TOOL_MAP_FAIL_AT_RENAME: '8' }, + }); + assert.notEqual(r.status, 0, `helper should exit non-zero when the hook fires: stdout=${r.stdout}\nstderr=${r.stderr}`); + + // Previously-existing targets are restored. + assert.equal(readFileSync(join(work, 'tools.md'), 'utf8'), 'PRE-MD'); + assert.equal(readFileSync(join(work, 'tools.json'), 'utf8'), 'PRE-JSON'); + assert.equal(readFileSync(join(work, 'tools.summary.md'), 'utf8'), 'PRE-SUMMARY'); + // Brand-new targets are still absent (no residue from partial install). + assert.ok(!existsSync(join(work, 'tools.new1')), 'brand-new tools.new1 leaked after rollback'); + assert.ok(!existsSync(join(work, 'tools.new2')), 'brand-new tools.new2 leaked after rollback'); + // No residue. + const residue = readdirSync(work).filter((e) => + e.includes('.staging-') || e.includes('.bundle.staging-') || e.includes('.bundle.backup-'), + ); + assert.equal(residue.length, 0, `staging/backup residue after Phase-3 rollback: ${residue.join(', ')}`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('atomicWriteBundle happy path: previously-absent targets are created, no residue', async () => { + // When the target dir starts empty, every name in the bundle is a + // brand-new file. The happy path must still leave exactly the three + // target files behind and nothing else. + const scanUrl = pathToFileURL(SCAN).href; + const { atomicWriteBundle } = await import(scanUrl); + const work = mkdtempSync(join(tmpdir(), 'tool-map-fresh-')); + try { + atomicWriteBundle(work, { + 'tools.md': 'NEW-MD', + 'tools.json': 'NEW-JSON', + 'tools.summary.md': 'NEW-SUMMARY', + }); + assert.equal(readFileSync(join(work, 'tools.md'), 'utf8'), 'NEW-MD'); + assert.equal(readFileSync(join(work, 'tools.json'), 'utf8'), 'NEW-JSON'); + assert.equal(readFileSync(join(work, 'tools.summary.md'), 'utf8'), 'NEW-SUMMARY'); + const entries = readdirSync(work).sort(); + assert.deepEqual(entries, ['tools.json', 'tools.md', 'tools.summary.md'], + `unexpected files in fresh output dir: ${entries.join(', ')}`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('atomicWriteBundle happy path: mix of existing and absent targets', async () => { + // Verify the happy path still works when only SOME of the targets + // pre-existed. The existing ones get overwritten, the absent ones get + // created, no residue anywhere. + const scanUrl = pathToFileURL(SCAN).href; + const { atomicWriteBundle } = await import(scanUrl); + const work = mkdtempSync(join(tmpdir(), 'tool-map-mixed-')); + try { + writeFileSync(join(work, 'tools.md'), 'OLD-MD'); + writeFileSync(join(work, 'tools.json'), 'OLD-JSON'); + // tools.summary.md is absent. + + atomicWriteBundle(work, { + 'tools.md': 'NEW-MD', + 'tools.json': 'NEW-JSON', + 'tools.summary.md': 'NEW-SUMMARY', + }); + assert.equal(readFileSync(join(work, 'tools.md'), 'utf8'), 'NEW-MD'); + assert.equal(readFileSync(join(work, 'tools.json'), 'utf8'), 'NEW-JSON'); + assert.equal(readFileSync(join(work, 'tools.summary.md'), 'utf8'), 'NEW-SUMMARY'); + const residue = readdirSync(work).filter((e) => + e.includes('.staging-') || e.includes('.bundle.staging-') || e.includes('.bundle.backup-'), + ); + assert.equal(residue.length, 0, `staging/backup residue on happy path: ${residue.join(', ')}`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('ALLOWED_PROBE_NAMES is exactly the 15 declared names', async () => { + const scanUrl = pathToFileURL(SCAN).href; + const { ALLOWED_PROBE_NAMES, VERSION_PROBES } = await import(scanUrl); + assert.equal(ALLOWED_PROBE_NAMES.size, 15); + for (const [name] of VERSION_PROBES) { + assert.ok(ALLOWED_PROBE_NAMES.has(name), `version probe ${name} missing from whitelist`); + } + // Defence-in-depth: a non-whitelisted name must never be spawned. + assert.ok(!ALLOWED_PROBE_NAMES.has('curl')); + assert.ok(!ALLOWED_PROBE_NAMES.has('bash')); + assert.ok(!ALLOWED_PROBE_NAMES.has('rm')); +}); + +// --------------------------------------------------------------------------- +// PR #5 review round 3: per-program shell decision +// The reviewer pointed out that scripts/scan.mjs unconditionally set +// `shell: IS_WIN` for every probe while README.md and SKILL.md claimed +// "probes are execFile, not shell" as a security property. The fix is to +// route only `.cmd` / `.bat` shims through cmd.exe (CVE-2024-27980; Node.js +// 21.7.3+ refuses to spawn batch files without `shell: true`). The tests +// below pin that contract: `shellForFile` is pure, `resolveProgram` walks +// PATH and PATHEXT, and `shouldUseShell` agrees with the resolved file +// type for every whitelisted name that is actually installed. +// --------------------------------------------------------------------------- + +test('shellForFile is pure: false on POSIX regardless of file type', async () => { + const scanUrl = pathToFileURL(SCAN).href; + const { shellForFile } = await import(scanUrl); + if (process.platform === 'win32') return; // POSIX-only check + // On POSIX, the function must never return true: there is no `.cmd` / + // `.bat` distinction in the argv, the OS handles shebangs natively, and + // all 15 whitelisted probes have safe argv shapes. + for (const p of [ + null, '', '/usr/bin/node', '/usr/local/bin/foo.cmd', '/tmp/x.bat', + 'C:\\node.exe', 'C:\\foo.cmd', '/bin/sh', + ]) { + assert.equal(shellForFile(p), false, `shellForFile(${JSON.stringify(p)}) must be false on POSIX`); + } +}); + +test('shellForFile classifies Windows paths by extension', async () => { + const scanUrl = pathToFileURL(SCAN).href; + const { shellForFile } = await import(scanUrl); + if (process.platform !== 'win32') return; // Windows-only check + // null / empty: cannot spawn, fall through to false (let execFile surface ENOENT). + assert.equal(shellForFile(null), false); + assert.equal(shellForFile(''), false); + // Native .exe: spawn directly, no shell. + assert.equal(shellForFile('C:\\Program Files\\nodejs\\node.exe'), false); + assert.equal(shellForFile('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'), false); + // .cmd and .bat: must route through cmd.exe (CVE-2024-27980). + assert.equal(shellForFile('C:\\nodejs\\npm.cmd'), true); + assert.equal(shellForFile('D:\\openclaw\\npm\\pnpm.cmd'), true); + assert.equal(shellForFile('C:\\Tools\\run.bat'), true); +}); + +// === Round-4 review close-out: negative-first tests for the +// 4 issues hetaoBackend flagged on commit 2dedc99 (review +// id 5036494244, 2026-08-27T01:34:06Z): +// +// R4-1 case-distinct test was failing on the reviewer's +// machine because the scan picks up real tools from the +// user's actual PATH / $HOME / etc. (not just from the +// TOOL_MAP_ROOTS temp dir), so `assert.deepEqual(names, +// ['Foo', 'foo'])` failed when the list contained real tools +// like mcode-tools. Also, the test did not gate on a +// case-sensitive FS, so it would silently pass on macOS HFS+ +// (case-insensitive default) by overwriting Foo with foo. +// +// R4-2 resolveProgram used existsSync only. existsSync returns +// true for directories too, so a directory named 'node' in +// PATH (e.g. /usr/local/bin/node) would be returned as the +// resolved path. probeVersion would then try to execFileP a +// directory and fail with EISDIR. The contract is "return the +// path of an executable file"; a directory is not that. +// +// R4-3 probeVersion passed the bare cmd[0] (e.g. 'node') to +// execFileP, not the absolute path that resolveProgram +// returned. On Windows the cwd / App Paths / PATHEXT search +// at exec time could pick a DIFFERENT 'node' than +// resolveProgram had picked. The contract is that the file +// resolveProgram returned is the one that gets spawned. +// +// R4-4 .cmd / .bat branch had no real-Windows evidence. The +// shell decision is the only place where this matters and +// it was tested only on the reviewer's local Windows machine. +// CI only runs on ubuntu-latest. The fix is to add a +// windows-latest CI job. + +// === R4-1: case-distinct test is hermetic (gated + filtered) === + +// isCaseSensitiveFs: true iff creating two files that differ only in +// case actually produces two distinct inodes. We probe with a +// mkdtempSync directory; the probe is cheap and we run it once. +function isCaseSensitiveFs() { + const probe = mkdtempSync(join(tmpdir(), 'tool-map-csfs-probe-')); + try { + writeFileSync(join(probe, 'UPPER'), 'a'); + writeFileSync(join(probe, 'upper'), 'b'); + const entries = readdirSync(probe); + return entries.length === 2 && entries.includes('UPPER') && entries.includes('upper'); + } catch { + return false; + } finally { + rmSync(probe, { recursive: true, force: true }); + } +} + +test('POSIX: case-distinct tool names are kept distinct on case-sensitive FS, AND the test is hermetic (only TOOL_MAP_ROOTS results are asserted)', () => { + // Gate: on a case-insensitive FS (e.g. macOS HFS+, or any + // case-folding mount) the test is meaningless because the two + // writes collapse into one file. Skip with a clear reason rather + // than passing vacuously. + if (process.platform === 'win32') { + return; // skip on Windows; the Windows runner covers this + } + if (!isCaseSensitiveFs()) { + return; // skip on case-insensitive POSIX FS (macOS HFS+ default) + } + const root = mkdtempSync(join(tmpdir(), 'tool-map-case-')); + try { + // Use .sh extension so the scan's EXEC_EXTS allowlist accepts + // these files without depending on the NPM_BIN_HINT directory + // regex. The original test used extensionless files, which + // POSIX scan.mjs accepts only when the parent dir matches + // /minimax-code|openclaw|node_modules|.Codex|.claude|npm|tauri/. + // A /tmp/ test root never matches, so the scan correctly reports + // 0 tools and the test fails on Linux. The .sh files are + // accepted by isToolFile directly via EXEC_EXTS, regardless of + // the parent dir. + writeFileSync(join(root, 'Foo.sh'), '#!/bin/sh\necho Foo\n'); + chmodSync(join(root, 'Foo.sh'), 0o755); + writeFileSync(join(root, 'foo.sh'), '#!/bin/sh\necho foo\n'); + chmodSync(join(root, 'foo.sh'), 0o755); + + // Use a CLEAN PATH so the scan only walks the temp dir. + // This is the round-4 fix: the old test set TOOL_MAP_ROOTS + // but did not clear PATH, so the scan picked up tools from + // $HOME/.local/bin, $HOME/.minimax-code/, etc. and the + // deepEqual assertion failed because the names list had + // real tools like mcode-tools in it. + const r = runScan(join(root, 'tools.md'), { + TOOL_MAP_ROOTS: root, + PATH: root, // POSIX: only the temp dir is on PATH + }); + assert.equal(r.status, 0, `scan failed: ${r.stderr}\n${r.stdout}`); + const json = JSON.parse(readFileSync(join(root, 'tools.json'), 'utf8')); + // Filter to entries whose path is inside the test root. The + // scan may also walk other roots (e.g. $HOME) but the test's + // claim is specifically about THIS root's two case-distinct + // files, not about the global state. + const localNames = json.tools + .filter((t) => t.path.startsWith(root)) + .map((t) => t.name).sort(); + assert.deepEqual( + localNames, ['Foo', 'foo'], + `case-distinct tool names were merged: ${localNames.join(', ')} (only tools inside the test root are asserted)`, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// === R4-2: resolveProgram rejects directories === +// +// The old implementation used `existsSync(full)` only. existsSync +// returns true for directories, so a directory named 'node' in +// PATH would be returned as the "resolved" path. probeVersion then +// calls execFileP on a directory and fails with EISDIR. +// +// The fix is to require `statSync(full).isFile() === true` and to +// also require a successful stat (i.e. not dangling, not a broken +// symlink). The round-trip test is: create a temp PATH where the +// FIRST entry is a directory called 'foo-tool' and the SECOND +// entry is a real file called 'foo-tool'. resolveProgram('foo-tool') +// must return the file (not the directory). +test('resolveProgram rejects a directory even if it comes first in PATH (POSIX)', () => { + // On Windows the function builds candidates from PATHEXT + // (.EXE/.CMD/...), so a plain 'foo-tool' file is never matched. + // The Windows runner (R4-4) covers the .cmd/.bat branch + // separately. + if (process.platform === 'win32') return; + const dir1 = mkdtempSync(join(tmpdir(), 'tool-map-resolve-dir-')); + const dir2 = mkdtempSync(join(tmpdir(), 'tool-map-resolve-file-')); + try { + // dir1 contains a DIRECTORY named 'foo-tool' (a regular file + // would be visible to existsSync too; we use mkdirSync to make + // a directory of the same name as the would-be tool). + mkdirSync(join(dir1, 'foo-tool')); + + // dir2 contains a REAL FILE named 'foo-tool' that is a + // runnable script. This is the path resolveProgram MUST return. + writeFileSync(join(dir2, 'foo-tool'), '#!/bin/sh\necho foo-tool\n'); + chmodSync(join(dir2, 'foo-tool'), 0o755); + + // PATH order: dir1 (directory) FIRST, then dir2 (file). + // The OLD code would return dir1/foo-tool (a directory) because + // existsSync was true. The NEW code must return dir2/foo-tool + // because statSync(dir1/foo-tool).isFile() is false. + const pathEnv = [dir1, dir2].join(delimiter); + const r = spawnSync(process.execPath, [join(REPO_ROOT, 'plugins', 'antianqi', 'tool-map', 'scripts', 'scan.mjs')], { + encoding: 'utf8', + env: { ...process.env, PATH: pathEnv, TOOL_MAP_ROOTS: '' }, + }); + // The scan runs to completion regardless. What we want to + // assert is that resolveProgram itself, queried via the scan + // output's "core" field, points to the FILE, not the + // directory. The scan writes a tools.json that includes the + // resolved `path` per tool (when found in PATH). + // + // Easiest assertion: shell out to node and call resolveProgram + // directly via dynamic import. Set PATH FIRST in the child + // process, then import (scan.mjs captures process.env.PATH at + // module load, so the env must be set BEFORE the import). + // + // On Windows, the function builds candidates from PATHEXT + // (e.g. foo-tool.EXE / foo-tool.CMD / ...), so a plain + // 'foo-tool' file is never matched. The test is POSIX-only; + // the Windows runner (R4-4) covers the .cmd/.bat branch. + const probe = spawnSync(process.execPath, [ + '--input-type=module', + '-e', + `process.env.PATH = ${JSON.stringify(pathEnv)}; + const m = await import('./plugins/antianqi/tool-map/scripts/scan.mjs'); + process.stdout.write(JSON.stringify(m.resolveProgram('foo-tool')));`, + ], { + encoding: 'utf8', + cwd: REPO_ROOT, + }); + const resolved = probe.stdout.trim(); + assert.equal(probe.status, 0, `resolveProgram probe failed (status ${probe.status}): ${probe.stderr}`); + assert.equal(resolved, JSON.stringify(join(dir2, 'foo-tool')), + `resolveProgram must skip the directory in dir1 and return the file in dir2. Got: ${resolved} (probe stdout: "${probe.stdout}", stderr: "${probe.stderr}")`); + } finally { + rmSync(dir1, { recursive: true, force: true }); + rmSync(dir2, { recursive: true, force: true }); + } +}); + +// === R4-3: probeVersion uses the resolved absolute path === +// +// The old implementation passed `cmd[0]` to execFileP without +// resolving to an absolute path first. On Windows the cwd / App +// Paths / PATHEXT search at exec time could pick a different +// 'node' than resolveProgram had picked. The fix is to resolve +// first, then exec the resolved path. +// +// Test design: use a tool name that exists in the real PATH. Set +// PATH to include ONLY a temp dir with a tool named 'node' that +// prints a known version. The test asserts that the captured +// version matches the temp tool's output (i.e. resolveProgram +// found the temp tool AND probeVersion executed it via the +// resolved path, not via PATH lookup at exec time). +// +// The way to make this test hermetic without exposing internals +// is to have the temp tool's behaviour diverge from the system +// 'node' behaviour. Easiest: use a tool that is NOT 'node' (so +// it is whitelisted via a custom whitelist OR we can use a +// behaviour-divergent tool). +// +// Since VERSION_PROBES is hardcoded, we cannot easily inject a +// new whitelisted name. Instead, the test verifies the +// behaviour for an existing whitelisted name ('node') by: +// 1. Creating a temp dir with a 'node' script that prints a +// known fake version. +// 2. Setting PATH to ONLY the temp dir + a directory that +// has the real 'node' (if any). +// 3. Running the scan. +// 4. Asserting the captured 'core.node' is the fake version +// printed by the temp tool. +// If probeVersion uses the resolved absolute path, the temp +// tool wins. If it uses cmd[0]='node' and PATH lookup picks +// the system node, the version is different. +// +// This is most cleanly testable on POSIX where the temp script +// can use a shebang. On Windows this requires a .cmd / .bat; +// the Windows CI job (R4-4) covers that. +test('probeVersion spawns the resolved absolute path (not a PATH lookup at exec time)', () => { + // On Windows the function builds candidates from PATHEXT + // (.EXE/.CMD/...), so a plain 'node' file is never matched. + // The Windows runner (R4-4) covers the .cmd/.bat branch + // separately. + if (process.platform === 'win32') return; + const root = mkdtempSync(join(tmpdir(), 'tool-map-probe-')); + try { + // A script that prints a recognisable fake version. + const fake = join(root, 'node'); + writeFileSync(fake, '#!/bin/sh\necho "FAKE_NODE_VERSION_v9"\n'); + chmodSync(fake, 0o755); + + const out = join(root, 'tools.md'); + // PATH = ONLY the temp dir. No system node on PATH. + const r = runScan(out, { TOOL_MAP_ROOTS: '', PATH: root }); + assert.equal(r.status, 0, `scan failed: ${r.stderr}\n${r.stdout}`); + const json = JSON.parse(readFileSync(join(root, 'tools.json'), 'utf8')); + // The scan captures `core` (VERSION_PROBES results) into a + // separate field — read tools.json and the `core` object. The + // shape is { scanned, platform, host, core, extras, tools }. + // If probeVersion used cmd[0]='node' and PATH lookup, the + // version would be whatever `node --version` returns (often + // "v24.18.0" on this host). If probeVersion used the + // resolved path (the temp script), the version is the fake + // string. + const nodeCore = json.core && json.core.node; + // The previous test had a hidden false-green here: when + // core.node is undefined (e.g. because resolveProgram failed + // to find node), the assert.match below would never fire, and + // the test would pass vacuously. Force the failure with a + // direct assert.ok so the contract is "core.node is set". + assert.ok(typeof nodeCore === 'string' && nodeCore.length > 0, + `core.node must be a non-empty string (the resolved path should have made it into core); got ${JSON.stringify(nodeCore)}. If core.node is undefined, the scan did not resolve 'node' at all and the test is a no-op.`); + assert.match(nodeCore, /FAKE_NODE_VERSION/, + `probeVersion should have executed the resolved path (the temp script), but the captured version is "${nodeCore}" — this means probeVersion used cmd[0]='node' and PATH lookup at exec time, which is the round-4 #3 defect`); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// === R4-4: .cmd / .bat / .EXE branch on real Windows evidence === +// +// The round-4 review said: ".cmd/.bat 分支本轮没有真实 Windows +// 证据". The .cmd / .bat decision is the only place where +// platform matters for shellForFile + probeVersion, and the +// previous test suite was only run on ubuntu-latest CI, so the +// .cmd / .bat decision was never exercised against real Windows +// behaviour. This test creates a fake `node.EXE` (a .cmd batch +// file with a known version output) on a temp dir, sets PATH to +// only that dir, and asserts the scan picks it up via the +// PATHEXT-resolved path. +// +// On POSIX the function ignores PATHEXT, so this test is +// POSIX-noop (gated off). The Windows runner is the real +// coverage. +test('Windows: probeVersion handles the PATHEXT-expanded .CMD path (R4-4 real Windows evidence)', () => { + if (process.platform !== 'win32') return; // POSIX runner skips; Windows runner is the real test + const root = mkdtempSync(join(tmpdir(), 'tool-map-cmdext-')); + try { + // Create a .cmd batch file. .cmd is the most common shim for + // npm / pnpm / mcode on Windows; this is the round-4 review + // focus (.cmd/.bat 分支). + const fake = join(root, 'node.cmd'); + writeFileSync(fake, '@echo FAKE_NODE_VERSION_v9\r\n'); + + const out = join(root, 'tools.md'); + const r = runScan(out, { TOOL_MAP_ROOTS: '', PATH: root }); + assert.equal(r.status, 0, `scan failed: ${r.stderr}\n${r.stdout}`); + const json = JSON.parse(readFileSync(join(root, 'tools.json'), 'utf8')); + const nodeCore = json.core && json.core.node; + assert.ok(typeof nodeCore === 'string' && nodeCore.length > 0, + `core.node must be a non-empty string on Windows; got ${JSON.stringify(nodeCore)}. If core.node is undefined, the scan did not resolve 'node.cmd' (or its PATHEXT expansion) at all and the test is a no-op.`); + assert.match(nodeCore, /FAKE_NODE_VERSION/, + `probeVersion should have executed the .cmd shim, but the captured version is "${nodeCore}"`); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// === R4-2 unit-level synthetic test (works on any platform) === +// +// The previous R4-2 test (above) is gated on POSIX because the +// function builds PATHEXT-expanded candidates on Windows. The +// R4-2 contract is "resolveProgram must skip a directory even if +// it appears first in PATH". To exercise this contract locally +// on any platform, this test uses a synthetic PATH where: +// - dir1 contains a directory named 'foo-tool' +// - dir2 contains a regular FILE named 'foo-tool' +// AND the name 'foo-tool' has no PATHEXT extension, so on +// Windows the function looks for 'foo-tool.COM' / 'foo-tool.EXE' +// etc. (none exist) and returns null. On POSIX the function +// looks for 'foo-tool' directly. +// +// The test only runs on POSIX (where the contract can be +// exercised without a Windows-style file). The Windows +// equivalent is the .cmd / .bat / .EXE test above (R4-4). +test('resolveProgram rejects a directory even if it comes first in PATH (POSIX, R4-2 contract)', () => { + if (process.platform === 'win32') return; + const dir1 = mkdtempSync(join(tmpdir(), 'tool-map-resolve-dir-')); + const dir2 = mkdtempSync(join(tmpdir(), 'tool-map-resolve-file-')); + try { + // dir1 contains a DIRECTORY named 'foo-tool' (a regular file + // would be visible to existsSync too; we use mkdirSync to make + // a directory of the same name as the would-be tool). + mkdirSync(join(dir1, 'foo-tool')); + + // dir2 contains a REAL FILE named 'foo-tool' that is a + // runnable script. This is the path resolveProgram MUST return. + writeFileSync(join(dir2, 'foo-tool'), '#!/bin/sh\necho foo-tool\n'); + chmodSync(join(dir2, 'foo-tool'), 0o755); + + // PATH order: dir1 (directory) FIRST, then dir2 (file). + // The OLD code would return dir1/foo-tool (a directory) because + // existsSync was true. The NEW code must return dir2/foo-tool + // because statSync(dir1/foo-tool).isFile() is false. + const pathEnv = [dir1, dir2].join(delimiter); + // Use spawn so the PATH is set BEFORE scan.mjs is loaded + // (scan.mjs captures process.env.PATH at module load). + const probe = spawnSync(process.execPath, [ + '--input-type=module', + '-e', + `process.env.PATH = ${JSON.stringify(pathEnv)}; + const m = await import('./plugins/antianqi/tool-map/scripts/scan.mjs'); + process.stdout.write(JSON.stringify(m.resolveProgram('foo-tool')));`, + ], { + encoding: 'utf8', + cwd: REPO_ROOT, + }); + const resolved = probe.stdout.trim(); + assert.equal(probe.status, 0, `resolveProgram probe failed (status ${probe.status}): ${probe.stderr}`); + assert.equal(resolved, JSON.stringify(join(dir2, 'foo-tool')), + `resolveProgram must skip the directory in dir1 and return the file in dir2. Got: ${resolved} (probe stdout: "${probe.stdout}", stderr: "${probe.stderr}")`); + } finally { + rmSync(dir1, { recursive: true, force: true }); + rmSync(dir2, { recursive: true, force: true }); + } +}); + +// === R5-1: resolveProgram requires X_OK on POSIX (non-executable in earlier PATH dir must not shadow executable in later dir) === +// +// Round-5 review finding: a non-executable (0644) candidate in an +// earlier PATH directory must not shadow a runnable (0755) candidate +// later in PATH. The previous isFile()-only check would return the +// 0644 file, and a subsequent probeVersion() call would surface null +// (because the kernel's execve() of the 0644 file would fail with +// EACCES) instead of continuing on to the 0755 candidate that the +// user actually intended to run. +// +// This is a real bug-replication test: with the fix reverted +// (isFile() only, no X_OK gate) the assert below fails because +// resolveProgram returns the dir1 path. With the fix in place it +// returns the dir2 path. +// +// On Windows the executable contract is the .exe/.cmd/.bat +// extension (PATHEXT above). The x bit is ignored by convention +// on Windows, so this test is POSIX-only. +test('resolveProgram skips a non-executable file even if it comes first in PATH (POSIX, R5-1 X_OK contract)', () => { + if (process.platform === 'win32') return; + const dir1 = mkdtempSync(join(tmpdir(), 'tool-map-resolve-noexec-first-')); + const dir2 = mkdtempSync(join(tmpdir(), 'tool-map-resolve-exec-later-')); + try { + // dir1 contains a NON-EXECUTABLE regular file. With the fix + // reverted, resolveProgram would stop here (isFile() is true) + // and return this path; the assertion at the end would then + // fail because this is the WRONG path. + writeFileSync(join(dir1, 'foo-tool'), '#!/bin/sh\necho foo-tool\n'); + chmodSync(join(dir1, 'foo-tool'), 0o644); + + // dir2 contains an EXECUTABLE regular file. resolveProgram + // must keep searching past the 0644 candidate and return this. + writeFileSync(join(dir2, 'foo-tool'), '#!/bin/sh\necho foo-tool\n'); + chmodSync(join(dir2, 'foo-tool'), 0o755); + + const pathEnv = `${dir1}${delimiter}${dir2}`; + const probe = spawnSync(process.execPath, [ + '--input-type=module', + '-e', + `process.env.PATH = ${JSON.stringify(pathEnv)}; + const m = await import('./plugins/antianqi/tool-map/scripts/scan.mjs'); + process.stdout.write(JSON.stringify(m.resolveProgram('foo-tool')));`, + ], { + encoding: 'utf8', + cwd: REPO_ROOT, + }); + const resolved = probe.stdout.trim(); + assert.equal(probe.status, 0, `resolveProgram probe failed (status ${probe.status}): ${probe.stderr}`); + assert.equal(resolved, JSON.stringify(join(dir2, 'foo-tool')), + `resolveProgram must skip the 0644 file in dir1 and return the 0755 file in dir2. ` + + `Got: ${resolved} (probe stdout: "${probe.stdout}", stderr: "${probe.stderr}")`); + } finally { + rmSync(dir1, { recursive: true, force: true }); + rmSync(dir2, { recursive: true, force: true }); + } +}); + +// === R5-1 mirror: resolveProgram returns null when NO candidate in PATH is executable === +// +// Companion to the X_OK test above. If every candidate in PATH is a +// regular file but NONE has the x bit set, resolveProgram must +// return null (not the first non-executable file, not an arbitrary +// one). Without the X_OK gate, the old code would have returned the +// first isFile() match regardless of executability. +test('resolveProgram returns null when the only candidate in PATH is a non-executable file (POSIX, R5-1 X_OK null return)', () => { + if (process.platform === 'win32') return; + const dir1 = mkdtempSync(join(tmpdir(), 'tool-map-resolve-noexec-only-')); + try { + writeFileSync(join(dir1, 'foo-tool'), '#!/bin/sh\necho foo-tool\n'); + chmodSync(join(dir1, 'foo-tool'), 0o644); + + const pathEnv = dir1; + const probe = spawnSync(process.execPath, [ + '--input-type=module', + '-e', + `process.env.PATH = ${JSON.stringify(pathEnv)}; + const m = await import('./plugins/antianqi/tool-map/scripts/scan.mjs'); + process.stdout.write(JSON.stringify(m.resolveProgram('foo-tool')));`, + ], { + encoding: 'utf8', + cwd: REPO_ROOT, + }); + assert.equal(probe.status, 0, `resolveProgram probe failed (status ${probe.status}): ${probe.stderr}`); + assert.equal(probe.stdout.trim(), 'null', + `resolveProgram must return null when no candidate has X_OK. ` + + `Got: ${probe.stdout} (stderr: ${probe.stderr})`); + } finally { + rmSync(dir1, { recursive: true, force: true }); + } +}); + +test('resolveProgram returns null for unknown names', async () => { + const scanUrl = pathToFileURL(SCAN).href; + const { resolveProgram } = await import(scanUrl); + // The whitelist is what gates probeVersion; resolveProgram itself just + // walks PATH. For a name that is definitely not on PATH (random hex), + // it must return null rather than throw. + const bogus = `definitely-not-a-real-program-${Math.random().toString(16).slice(2)}`; + assert.equal(resolveProgram(bogus), null); +}); + +test('resolveProgram finds node on the current PATH', async () => { + const scanUrl = pathToFileURL(SCAN).href; + const { resolveProgram } = await import(scanUrl); + // `node` is the runtime that runs this test, so it is guaranteed to be + // on PATH at the time of the test. On Windows it resolves to node.exe; + // on POSIX it resolves to the node binary the test runner used. + const r = resolveProgram('node'); + assert.ok(r && typeof r === 'string', `resolveProgram('node') returned ${JSON.stringify(r)}`); + if (process.platform === 'win32') { + assert.match(r, /node\.exe$/i, `node should resolve to node.exe, got ${r}`); + } else { + // On POSIX the binary is just `node` in some bin dir. + assert.ok(r.endsWith('node') || r.endsWith('node.exe'), + `node should resolve to a 'node' path, got ${r}`); + } +}); + +test('shouldUseShell agrees with shellForFile for every whitelisted probe that is installed', async () => { + const scanUrl = pathToFileURL(SCAN).href; + const { VERSION_PROBES, resolveProgram, shouldUseShell, shellForFile } = await import(scanUrl); + for (const [name] of VERSION_PROBES) { + const resolved = resolveProgram(name); + if (!resolved) continue; // not installed in this environment; skip + // The two helpers must agree exactly for every actually-resolved name. + // This is the per-program shell decision the reviewer asked for. + assert.equal( + shouldUseShell(name), shellForFile(resolved), + `shouldUseShell(${name}) disagreed with shellForFile(${resolved}); ` + + `got ${shouldUseShell(name)} vs ${shellForFile(resolved)}`, + ); + if (process.platform === 'win32') { + const lower = resolved.toLowerCase(); + if (lower.endsWith('.cmd') || lower.endsWith('.bat')) { + assert.equal(shouldUseShell(name), true, + `${name} resolves to ${resolved}, a batch file, so shell must be true`); + } else { + assert.equal(shouldUseShell(name), false, + `${name} resolves to ${resolved}, a non-batch file, so shell must be false`); + } + } else { + assert.equal(shouldUseShell(name), false, + `${name} on POSIX must never use a shell`); + } + } +}); + +test('probeVersion refuses non-whitelisted names (no shell, no spawn)', async () => { + const scanUrl = pathToFileURL(SCAN).href; + const { probeVersion, ALLOWED_PROBE_NAMES } = await import(scanUrl); + // `probeVersion` is the function the review asked us to harden. Even + // when called directly with a name that is on PATH, a non-whitelisted + // name must return null without spawning anything. We pick `node` + // because it is on PATH in the test env and is NOT in the whitelist + // for this assertion (the whitelist is the 15 names; `node` IS one of + // them, so the second branch verifies the whitelist short-circuit by + // passing a definitely-not-whitelisted name like `__no_such_probe__`). + assert.equal(ALLOWED_PROBE_NAMES.has('node'), true, 'node is whitelisted'); + assert.equal(await probeVersion(['__no_such_probe__', '--version']), null); +}); + +test('POSIX: a .sh file without the execute bit is not reported as a tool', () => { + if (process.platform === 'win32') return; // Windows ignores the execute bit + const root = mkdtempSync(join(tmpdir(), 'tool-map-xbit-')); + try { + const sh = join(root, 'foo.sh'); + writeFileSync(sh, '#!/bin/sh\necho hi\n'); + chmodSync(sh, 0o644); // no execute bit + // Touch the file so mtime is fresh + utimesSync(sh, new Date(), new Date()); + + const out = join(root, 'tools.md'); + const r = runScan(out, { TOOL_MAP_ROOTS: root }); + assert.equal(r.status, 0, `scan failed: ${r.stderr}\n${r.stdout}`); + + const json = JSON.parse(readFileSync(join(root, 'tools.json'), 'utf8')); + for (const t of json.tools) { + assert.notEqual(t.name, 'foo', `non-executable foo.sh was reported as a tool: ${t.path}`); + } + + // Now make it executable and confirm it IS reported. + chmodSync(sh, 0o755); + const r2 = runScan(out, { TOOL_MAP_ROOTS: root }); + assert.equal(r2.status, 0, `scan failed: ${r2.stderr}\n${r2.stdout}`); + const json2 = JSON.parse(readFileSync(join(root, 'tools.json'), 'utf8')); + assert.ok( + json2.tools.some((t) => t.name === 'foo'), + 'executable foo.sh should be reported as a tool', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +// (R4-1 test moved to the top of this file with proper gating and +// a hermetic PATH.) + +test('XDG_DATA_HOME is honoured when PLUGIN_DATA is unset', () => { + const xdg = mkdtempSync(join(tmpdir(), 'tool-map-xdg-')); + try { + const out = join(xdg, 'tools.md'); + // Set XDG_DATA_HOME and a sentinel TOOL_MAP_ROOTS so the scan has something to walk. + const fakeBin = mkdtempSync(join(tmpdir(), 'tool-map-xdg-bin-')); + writeFileSync(join(fakeBin, 'mycli'), '#!/bin/sh\necho mycli\n'); + chmodSync(join(fakeBin, 'mycli'), 0o755); + const r = spawnSync(process.execPath, [SCAN, out], { + encoding: 'utf8', + timeout: 30_000, + env: { + ...process.env, + PLUGIN_DATA: '', + XDG_DATA_HOME: xdg, + TOOL_MAP_ROOTS: fakeBin, + }, + }); + assert.equal(r.status, 0, `scan failed: ${r.stderr}\n${r.stdout}`); + // The output dir is the one we passed as argv[2], so just check the catalog is well-formed. + const json = JSON.parse(readFileSync(out.replace(/\.md$/, '') + '.json', 'utf8')); + assert.ok(Array.isArray(json.tools)); + } finally { + rmSync(xdg, { recursive: true, force: true }); + } +});