diff --git a/.pi/skills/notify-decision/SKILL.md b/.pi/skills/notify-decision/SKILL.md new file mode 100644 index 0000000..7dbb8b5 --- /dev/null +++ b/.pi/skills/notify-decision/SKILL.md @@ -0,0 +1,66 @@ +--- +name: notify-decision +description: Ping the user when you are blocked on a decision they must make (or before a destructive/irreversible action). Use at the end of a turn that cannot proceed without their input, so they are not left waiting in front of a silent terminal. +--- + +# 需要用户决策时,主动通知 + +用户可能不在终端前面(可能在另一台机器上远程连过来,也可能去开会了)。 +**需要他拍板的时候,不要只是把问题打在终端里就停住**——先发通知,再结束本轮。 + +## 什么时候用 + +用: + +- 你有两个以上方案,选错的代价明显(改架构、改存盘格式、删数据、动公共接口) +- 需要只有他知道的信息(构建机状态、客户现场限制、CAD 版本要不要支持) +- 即将执行不可逆操作(`git push --force`、批量删除、改版本号) +- 长任务跑完后需要他验收(编译结果、实测结论) + +不用: + +- 自己能查代码/文档确认的事情(先查,别问) +- 纯执行类问题("要我继续吗"这种,直接做完再报) +- 已经在同一轮里问过一次、还没回答的(等回答,别刷屏) + +## 怎么用 + +把问题写成文件(**中文必须走文件**,命令行参数会受控制台代码页影响而乱码): + +```bash +cat > /tmp/q.txt <<'EOF' +决策点:管道截面缓存放在哪一层? +A. CoreTools::StringToRegion 内部(覆盖 Equipment/Insulation,但概念上离 region 较远) +B. AecDbEwPipeBase 与 StringToRegion 各自接入(精确,两处代码) +建议 A,理由:…… +EOF +powershell -NoProfile -ExecutionPolicy Bypass -File tools/notify_decision.ps1 \ + -Title "pi: 需要决策" -MessageFile C:\\Users\\<你>\\AppData\\Local\\Temp\\q.txt +``` + +脚本做三件事: + +1. Windows toast(失败则弹模态框),带提示音 +2. 追加到 `%USERPROFILE%\.pi\agent\decisions\pending.md`(漏看 toast 也不丢) +3. 若设了 `PI_NOTIFY_WEBHOOK`(或传 `-Url`)→ POST 到企业微信/钉钉/飞书机器人 + +**发完之后**:本轮回复里把问题和选项完整写一遍,然后结束本轮。 +不要一边通知一边自己替他选。 + +## 消息里必须写清的三件事 + +1. **要决定什么**(一句话) +2. **选项 + 各自代价**(表格或短列表) +3. **我的建议和理由**,以及"如果你不回答,我会怎么做"(默认动作) + +缺第 3 条时用户还得追问一次,等于没通知到位。 + +## 通道配置 + +| 通道 | 配置 | +| --- | --- | +| 桌面通知 | 默认开启,无需配置 | +| 决策日志 | 默认写入 `~/.pi/agent/decisions/pending.md` | +| IM 机器人 | `setx PI_NOTIFY_WEBHOOK "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."` | + +企业微信/钉钉用 `{"msgtype":"text",...}`;URL 里含 `feishu`/`lark` 时自动换成飞书格式。 diff --git a/AGENTS.md b/AGENTS.md index 86901e4..74a27d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,11 @@ 里面记录了很多"已经决定不做"的理由,别重复踩。 - 构建命令与 target 对照见 `CODEBUDDY.md`,或直接用 skill `build-arx`。 +## 需要用户决策时 + +不要把问题打在终端里就停住——先跑 `tools/notify_decision.ps1`(用 `-MessageFile` 传中文), +发完 toast 再结束本轮。细则见 skill `notify-decision`。 + ## 仓库内 skill | skill | 用途 | @@ -40,5 +45,6 @@ | `verify-utf8` | UTF-8 BOM 自检、写坏后的回滚 | | `pr-flow` | 分支命名 / 中文 commit / `refs/for/main` 推送 | | `analyze-big-entity` | 万行实体类的分析与改动套路 | +| `notify-decision` | 需要用户拍板时主动推送通知(桌面 toast / IM 机器人),不让他干等终端 | 使用说明(常用命令、会话管理、Windows 终端问题、多机同步、插件取舍)见 `pi-agent-setup.md`。 diff --git a/tools/notify_decision.ps1 b/tools/notify_decision.ps1 new file mode 100644 index 0000000..da42024 --- /dev/null +++ b/tools/notify_decision.ps1 @@ -0,0 +1,160 @@ +# notify_decision.ps1 - ping the user when the agent needs a decision. +# +# Usage: +# powershell -NoProfile -ExecutionPolicy Bypass -File tools\notify_decision.ps1 ` +# -Title "Decision needed" -MessageFile C:\temp\q.txt [-Url ] [-NoToast] +# +# - Chinese text must go through -MessageFile (UTF-8) to avoid console codepage damage; +# -Message is fine for ASCII. +# - Every call appends to %USERPROFILE%\.pi\agent\decisions\pending.md so the question +# is not lost if the toast is missed. +# - Optional webhook: if -Url (or $env:PI_NOTIFY_WEBHOOK) is set, POST a text message. +# WeCom / DingTalk style payload is the default; Feishu/Lark URLs get their own shape. +# - Always exits 0: a failed notification must never break the agent turn. + +[CmdletBinding()] +param( + [string]$Title = 'Agent needs a decision', + [string]$Message = '', + [string]$MessageFile = '', + [string]$Url = '', + [switch]$NoToast, + [switch]$NoBeep, + [switch]$Probe +) + +$ErrorActionPreference = 'Continue' + +function Write-Pending { + param([string]$text) + try { + $dir = Join-Path $env:USERPROFILE '.pi\agent\decisions' + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + $log = Join-Path $dir 'pending.md' + $stamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $cwd = (Get-Location).Path + $entry = "## $stamp`r`n`r`n- cwd: $cwd`r`n- title: $Title`r`n`r`n$text`r`n`r`n---`r`n" + [System.IO.File]::AppendAllText($log, $entry, (New-Object System.Text.UTF8Encoding $false)) + } catch { } +} + +function Send-Toast { + param([string]$t, [string]$m) + try { + [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] | Out-Null + [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType=WindowsRuntime] | Out-Null + + $esc = { param($s) ($s -replace '&', '&' -replace '<', '<' -replace '>', '>') } + $body = (& $esc $m) -replace "`r?`n", '' + # split the message into lines -> one per line + $lines = $m -split "`r?`n" | ForEach-Object { '' + (& $esc $_) + '' } + $xmlText = @" + + + + $(& $esc $t) + $($lines -join "`r`n ") + + + +"@ + $xml = New-Object Windows.Data.Xml.Dom.XmlDocument + $xml.LoadXml($xmlText) + $toast = New-Object Windows.UI.Notifications.ToastNotification $xml + $appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe' + [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast) + return $true + } catch { + return $false + } +} + +function Send-Popup { + # Toast can silently fail (session locked, notifications off, appId not registered). + # A popup is intrusive but guaranteed visible; it auto-closes after 120 s. + param([string]$t, [string]$m) + try { + $w = New-Object -ComObject Wscript.Shell + $w.Popup("$t`r`n`r`n$m", 120, 'pi agent', 64) | Out-Null + return $true + } catch { + return $false + } +} + + +function Mask-Url { + param([string]$u) + if (-not $u) { return '(none)' } + if ($u.Length -le 48) { return $u } + return $u.Substring(0, 32) + '...' + $u.Substring($u.Length - 8) +} + +function Get-WebhookUrl { + # Order: -Url param > env var > ~/.pi/agent/notify.json > ~/.pi/agent/notify_webhook.txt + # Config lives OUTSIDE the repo on purpose: a webhook URL is a secret. + if ($Url) { return $Url } + if ($env:PI_NOTIFY_WEBHOOK) { return $env:PI_NOTIFY_WEBHOOK } + + $cfg = Join-Path $env:USERPROFILE '.pi\agent\notify.json' + if (Test-Path $cfg) { + try { + $o = [System.IO.File]::ReadAllText($cfg, [System.Text.Encoding]::UTF8) | ConvertFrom-Json + if ($o.webhook) { return [string]$o.webhook } + } catch { } + } + $txt = Join-Path $env:USERPROFILE '.pi\agent\notify_webhook.txt' + if (Test-Path $txt) { + $v = ([System.IO.File]::ReadAllText($txt, [System.Text.Encoding]::UTF8)).Trim() + if ($v) { return $v } + } + return '' +} + + +function Post-Webhook { + param([string]$u, [string]$t, [string]$m) + try { + $text = "$t`n$m" + if ($u -match 'feishu|larksuite|lark') { + $payload = @{ msg_type = 'text'; content = @{ text = $text } } | ConvertTo-Json -Depth 5 -Compress + } else { + # WeCom / DingTalk robot + $payload = @{ msgtype = 'text'; text = @{ content = $text } } | ConvertTo-Json -Depth 5 -Compress + } + $bytes = [System.Text.Encoding]::UTF8.GetBytes($payload) + $resp = Invoke-RestMethod -Uri $u -Method Post -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 10 + # WeCom / DingTalk answer 200 with errcode; Feishu answers with code. 200 does NOT mean delivered. + if ($null -ne $resp.errcode -and $resp.errcode -ne 0) { return "errcode=$($resp.errcode) $($resp.errmsg)" } + if ($null -ne $resp.code -and $resp.code -ne 0) { return "code=$($resp.code) $($resp.msg)" } + return 'ok' + } catch { + return "http: $($_.Exception.Message)" + } +} + +# ---- resolve message ------------------------------------------------------- +$text = $Message +if ($MessageFile -and (Test-Path $MessageFile)) { + $text = [System.IO.File]::ReadAllText($MessageFile, [System.Text.Encoding]::UTF8) +} +if (-not $text) { $text = '(empty message)' } + +Write-Pending -text $text + +if (-not $NoToast) { + $shown = Send-Toast -t $Title -m $text + if (-not $shown) { Send-Popup -t $Title -m $text | Out-Null } +} +if (-not $NoBeep) { try { [console]::Beep(880, 180); [console]::Beep(1175, 220) } catch { } } + +$hook = Get-WebhookUrl +if ($hook) { + $st = Post-Webhook -u $hook -t $Title -m $text + if ($Probe) { Write-Output ("webhook -> " + (Mask-Url $hook) + " result: " + $st) } +} elseif ($Probe) { + Write-Output 'webhook not configured' +} + +exit 0