feat: 需要用户决策时主动通知(桌面 toast / 决策日志 / IM 机器人) #95
No files matched your search
@@ -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` 时自动换成飞书格式。
|
||||
@@ -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`。
|
||||
@@ -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 <webhook>] [-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", '<text>'
|
||||
# split the message into lines -> one <text> per line
|
||||
$lines = $m -split "`r?`n" | ForEach-Object { '<text>' + (& $esc $_) + '</text>' }
|
||||
$xmlText = @"
|
||||
<toast scenario="reminder">
|
||||
<visual>
|
||||
<binding template="ToastGeneric">
|
||||
<text>$(& $esc $t)</text>
|
||||
$($lines -join "`r`n ")
|
||||
</binding>
|
||||
</visual>
|
||||
<audio src="ms-winsoundevent:Notification.Reminder"/>
|
||||
</toast>
|
||||
"@
|
||||
$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
|
||||
Reference in new issue
Block a user