chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
This commit is contained in:
1 parent
30b46dbd0c
commit
c1b5e4d966
735 files changed
+153192
-2415
No files matched your search
@@ -0,0 +1,33 @@
|
||||
备份时间 2026-09-23 21:51
|
||||
备份包 E:/ProgramData/AI技能/aliyun-dsh-server/归档/dsh_shenxian-清理-20260923\deleted-20260923.tar.gz
|
||||
|
||||
路径 字节 文件数
|
||||
_中间产物_待清理 31372 5
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.0.tgz 18648 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.1.tgz 19183 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.10.tgz 43588 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.11.tgz 48590 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.12.tgz 50099 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.13.tgz 50669 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.14.tgz 56222 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.15.tgz 56899 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.16.tgz 59609 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.17.tgz 60313 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.18.tgz 60936 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.19.tgz 61421 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.20.tgz 62880 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.21.tgz 70758 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.22.tgz 72882 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.23.tgz 72037 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.24.tgz 72267 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.3.tgz 21791 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.4.tgz 39098 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.6.tgz 45285 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.7.tgz 45400 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.8.tgz 42885 1
|
||||
poc/business-plugins/dsh-local-business-plugins-0.3.9.tgz 43589 1
|
||||
poc/portal-entry/dsh-local-portal-entry-0.5.5.tgz 8914 1
|
||||
poc/portal-entry/dsh-local-portal-entry-0.5.6.tgz 9484 1
|
||||
poc/workspace-scoped-picker/dsh-local-workspace-scoped-picker-0.1.5.tgz 17208 1
|
||||
tmp-e9c.txt 7194 1
|
||||
tmp/seq46s0/deploy-ts.txt 19 1
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""索引 / 文档链路优化 · 离线落地包(2026-09-14 会话 `15ee0d4f`)
|
||||
|
||||
用法:
|
||||
python3 apply-索引优化-20260914.py # dry-run:只验锚点命中数(≠1 即中止),不写任何文件
|
||||
python3 apply-索引优化-20260914.py --apply # 打补丁(**需先持全局执行锁**)
|
||||
|
||||
补丁清单(对应评审 §三 的 G2/G3/G4 + ❓ 根因)
|
||||
P0-2 docs-manifest.py 状态优先级改为「**档案头部(权威)→ INDEX(❓/? 视为缺)→ ?**」
|
||||
+ 放宽提取正则 + norm_status() 规范化
|
||||
⇒ 根治「INDEX 的 ❓ 回灌 manifest、再渲染成 ❓」的**环形锁定**
|
||||
P1-1 docs-archive-index.py ① 顺序断言:manifest 比 04-调整方案/ 里最新档案旧 → 警告;`--write` 时**拒绝**(除非 --force)
|
||||
② 输出「状态缺失率 + 名单」,缺失率 >10% 提示补头部(历史档案不回改,只在文末「修正」节补)
|
||||
P2-1 docs-shrink-guard.py 新增 `--allow-shrink <路径子串>`:**合法重构**(如技能拆分)不误报
|
||||
P2-2 CODEBUDDY.md(库内) ① 标题「四件套」→「**七件套 · 顺序不可换**」+ 顺序说明
|
||||
② 约定 #2「单篇 ≤30 KB」补**标准拆法**:新增子页 + 原页留指针
|
||||
③ 约定 #3「日志 ≤50 KB」补**执行者与触发点**:按月切 + 留指针 + 谁触到谁切
|
||||
|
||||
落地后的手工步骤(本脚本不代做,避免越界):
|
||||
python3 scripts/docs-manifest.py && python3 scripts/docs-archive-index.py --write
|
||||
bash scripts/docs-sync-check.sh # 对账;六件套其余项
|
||||
scp(同相对目录)+ 远端 chmod 600 / chown root:root
|
||||
git add <本次 5 个脚本 + 派生件> && git commit
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
_here = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = _here # 向上找含 dsh-server-docs/ 的工作区根
|
||||
while not os.path.isdir(os.path.join(ROOT, 'dsh-server-docs')) and os.path.dirname(ROOT) != ROOT:
|
||||
ROOT = os.path.dirname(ROOT)
|
||||
LIB = os.path.join(ROOT, 'dsh-server-docs')
|
||||
if not os.path.isdir(os.path.join(LIB, 'scripts')): # 兜底:脚本直接放在库里跑
|
||||
LIB = _here if os.path.isdir(os.path.join(_here, 'scripts')) else LIB
|
||||
LIB = os.environ.get('IDXOPT_LIB') or LIB # 允许指向库外副本(用于 A/B 验证)
|
||||
DRY = '--apply' not in sys.argv
|
||||
|
||||
|
||||
def patch(rel, pairs):
|
||||
"""pairs: [(old, new, 说明)];全部命中数必须为 1,否则整体中止不写。"""
|
||||
p = os.path.join(LIB, rel)
|
||||
if not os.path.exists(p):
|
||||
raise AssertionError('❌ %s:库内不存在这个文件' % rel)
|
||||
s = io.open(p, encoding='utf-8', newline='').read()
|
||||
for old, new, why in pairs:
|
||||
n = s.count(old)
|
||||
assert n == 1, '❌ %s:锚点命中 %d 次(期望 1)→ %s' % (rel, n, why)
|
||||
s = s.replace(old, new)
|
||||
print(' ✓ %-26s %d 处' % (rel, len(pairs)))
|
||||
if not DRY:
|
||||
io.open(p, 'w', encoding='utf-8', newline='').write(s)
|
||||
|
||||
|
||||
# ── P0-2 docs-manifest.py:状态以档案头部为权威,❓ 不回灌 ──────────────
|
||||
NORM = '''
|
||||
|
||||
def norm_status(raw):
|
||||
"""规范化档案头部的状态行 → 表里可读的一小段(≤14 字,去 ** 与尾部括注)。
|
||||
|
||||
⚠️ **权威方向**:档案头部是**源**,INDEX 表是**派生展示** —— 不允许把表里的 ❓ 回灌成 manifest 的 status,
|
||||
否则 manifest → INDEX → manifest 形成环形锁定,状态永远修不回来(2026-09-14 实测 14 篇被锁)。
|
||||
"""
|
||||
t = re.sub(r'\\*{1,2}', '', raw or '').strip()
|
||||
t = re.split(r'——|||\\||;|;|\\s{2,}', t)[0].strip()
|
||||
t = re.sub(r'([^)]*)\\s*$', '', t).strip()
|
||||
return t[:14]
|
||||
'''
|
||||
M_PAIRS = [
|
||||
('import io, os, re, sys, json, collections\n',
|
||||
'import io, os, re, sys, json, collections\n' + NORM,
|
||||
'加 norm_status()'),
|
||||
(" hs = re.search(r'- 状态:\\s*(\\S{1,12})', head)\n",
|
||||
" hs = re.search(r'状态\\*{0,2}\\s*[::]\\s*(.{1,40})', head)\n"
|
||||
" hs_val = norm_status(hs.group(1)) if hs else ''\n"
|
||||
" st_idx = (index_status.get(num, {}) or {}).get('status') or ''\n"
|
||||
" if st_idx in ('?', '❓', ''):\n"
|
||||
" st_idx = '' # ❓/? 一律视为「缺」—— 防 INDEX↔manifest 环形锁定(2026-09-14)\n",
|
||||
'放宽状态提取 + 保留 INDEX 兜底'),
|
||||
(" 'status': (index_status.get(num, {}) or {}).get('status') or (hs.group(1) if hs else '?'),\n",
|
||||
" 'status': hs_val or st_idx or '?',\n",
|
||||
'状态优先级:头部 → INDEX → ?'),
|
||||
]
|
||||
|
||||
# ── P1-1 docs-archive-index.py:顺序断言 + 缺失率 ────────────────────
|
||||
A_PAIRS = [
|
||||
(" if not os.path.exists(MANIFEST):\n"
|
||||
" raise SystemExit('ERROR: 先跑 scripts/docs-manifest.py 生成 docs-manifest.json')\n",
|
||||
" if not os.path.exists(MANIFEST):\n"
|
||||
" raise SystemExit('ERROR: 先跑 scripts/docs-manifest.py 生成 docs-manifest.json')\n"
|
||||
"\n"
|
||||
" # ── 顺序断言(2026-09-14 加):派生链 manifest → 本脚本,顺序错会**静默**产出新旧混合 ──\n"
|
||||
" newest, _ad = 0.0, os.path.join(ROOT, '04-调整方案')\n"
|
||||
" if os.path.isdir(_ad):\n"
|
||||
" for _n in os.listdir(_ad):\n"
|
||||
" if _n.endswith('.md'):\n"
|
||||
" try:\n"
|
||||
" newest = max(newest, os.path.getmtime(os.path.join(_ad, _n)))\n"
|
||||
" except OSError:\n"
|
||||
" pass\n"
|
||||
" stale_min = (newest - os.path.getmtime(MANIFEST)) / 60.0\n"
|
||||
" if stale_min > 1:\n"
|
||||
" msg = ('⚠️ 顺序警告:docs-manifest.json 比 04-调整方案/ 最新档案旧 %.0f 分钟 ⇒ 先跑 '\n"
|
||||
" 'scripts/docs-manifest.py,否则本表用的是旧数据' % stale_min)\n"
|
||||
" if '--write' in sys.argv and '--force' not in sys.argv:\n"
|
||||
" raise SystemExit(msg + '\\n (确认要带旧数据刷新就加 --force)')\n"
|
||||
" print(msg)\n",
|
||||
'顺序断言'),
|
||||
(" print('档案 %d 篇 | 摘要:手写/已存 %d | 机器兜底 %d | 状态缺失(❓) %d'\n"
|
||||
" % (len(rows), n_sum, len(rows) - n_sum, sum(1 for v in rows.values() if '❓' in v)))\n",
|
||||
" _miss = sorted([k for k, v in rows.items() if '❓' in v], key=sort_key)\n"
|
||||
" print('档案 %d 篇 | 摘要:手写/已存 %d | 机器兜底 %d | 状态缺失(❓) %d(%.0f%%)'\n"
|
||||
" % (len(rows), n_sum, len(rows) - n_sum, len(_miss),\n"
|
||||
" 100.0 * len(_miss) / max(1, len(rows))))\n"
|
||||
" if _miss:\n"
|
||||
" print(' 缺失名单:%s' % ', '.join('04-' + m for m in _miss))\n"
|
||||
" if len(_miss) / max(1, len(rows)) > 0.10:\n"
|
||||
" print(' ⚠️ 缺失率 >10%% ⇒ **新档案**头部必须写「- 状态:…」;历史档案按「只增不改」不回改正文,'\n"
|
||||
" '可在文末「修正(YYYY-MM-DD)」节补一行状态 ⇒ 下一轮由 manifest 从头部取到')\n",
|
||||
'缺失率 + 名单'),
|
||||
]
|
||||
|
||||
# ── P2-1 docs-shrink-guard.py:合法重构白名单 ───────────────────────
|
||||
S_PAIRS = [
|
||||
(" python3 scripts/docs-shrink-guard.py --baseline # 只建档不比对(首次)\n",
|
||||
" python3 scripts/docs-shrink-guard.py --baseline # 只建档不比对(首次)\n"
|
||||
" python3 scripts/docs-shrink-guard.py --allow-shrink <路径子串> # 合法重构白名单(不报警)\n",
|
||||
'用法补一行'),
|
||||
(" now = collect()\n old = {}\n",
|
||||
" now = collect()\n"
|
||||
" allow = [argv[i + 1] for i, a in enumerate(argv)\n"
|
||||
" if a == '--allow-shrink' and i + 1 < len(argv)]\n"
|
||||
" old = {}\n",
|
||||
'解析 --allow-shrink'),
|
||||
(" if old[rel] - n > DROP_MIN and (old[rel] - n) / old[rel] > DROP_RATIO:\n"
|
||||
" shrunk.append((rel, old[rel], n))\n",
|
||||
" if old[rel] - n > DROP_MIN and (old[rel] - n) / old[rel] > DROP_RATIO:\n"
|
||||
" if any(a in rel for a in allow):\n"
|
||||
" print(' ↳ 合法重构(白名单):%s %d → %d 行' % (rel, old[rel], n))\n"
|
||||
" else:\n"
|
||||
" shrunk.append((rel, old[rel], n))\n",
|
||||
'白名单跳过报警'),
|
||||
]
|
||||
|
||||
# ── P2-2 CODEBUDDY.md(库内):标题 + 约定 #2/#3 执行者 ────────────────
|
||||
C_PAIRS = [
|
||||
("## 改完必跑(四件套)\n",
|
||||
"## 改完必跑(七件套 · **顺序不可换**)\n\n"
|
||||
"> ⚠️ **顺序**:`docs-manifest.py` → `docs-archive-index.py`(**后者读前者**)→ 其余任意。\n"
|
||||
"> 顺序错会**静默**产出新旧混合(2026-09-14 实测:只跑 manifest 未跑 archive-index 时,后者只读模式直接报\n"
|
||||
"> 「表内容与 INDEX.md 不一致」)。`docs-archive-index.py` 已内置顺序断言:`--write` 时若 manifest 比最新档案旧会**拒绝**,\n"
|
||||
"> 确认要硬刷才加 `--force`。\n",
|
||||
'标题 + 顺序说明'),
|
||||
("| 2 | **单篇 ≤ 30 KB**;新增档案超限**必须拆子页** | 单篇最大已 89 KB(档案 82),读一篇就吃满预算 | 写档案的人;`docs-manifest.py` 会报 |",
|
||||
"| 2 | **单篇 ≤ 30 KB**;新增档案超限**必须拆子页**。**标准拆法 = 新增子页 + 原页留指针**(历史档案按「只增不改」不回改正文,只在文末「修正(YYYY-MM-DD)」节加指针) | 单篇最大已 89 KB(档案 82),读一篇就吃满预算 | 写档案的人;`docs-manifest.py` 会报 + `docs-archive-index.py` 报缺失率 |",
|
||||
'约定 #2 补标准拆法'),
|
||||
("| 3 | **日志按主题/月分片**,单文件 ≤ 50 KB(`append-only` 不变) | 单日日志已 377 KB | 写日志的人 |",
|
||||
"| 3 | **日志按月分片**,单文件 ≤ 50 KB(`append-only` 不变):`YYYY-MM.md` → 超限后新开 `YYYY-MM-下.md`,**原文件顶部留一行指针** | 单日日志已 377 KB | **谁当月第一次触到 50 KB 谁切**(下一位写入者先看行数) |",
|
||||
'约定 #3 补触发点与执行者'),
|
||||
]
|
||||
|
||||
|
||||
# ── P2-3 CODEBUDDY.md(库内)· 锁章程:补「锁的生命周期」────────────────
|
||||
C2_PAIRS = [
|
||||
("- 完工:`bash scripts/handoff-guard.sh --release <单号>` → 最后 `--release-exec`。\n",
|
||||
"- 完工:`bash scripts/handoff-guard.sh --release <单号>` → 最后 `--release-exec`。\n"
|
||||
"- 🔒 **锁的生命周期 = 任务的生命周期**(2026-09-14 用户明令):**抢到锁的任务,只有\"执行完成 → 反序释放\"才算完成**;\n"
|
||||
" ⛔ **禁止\"抢到锁、做一半、不解锁就结束回合 / 结束会话\"** —— 锁是独占资源,本库**无心跳机制** ⇒ 别人既等不到也判不出你死没死(会被迫空等,或被诱去违规接管 · R9)。\n"
|
||||
" 三条配套:① **抢锁前先列出收口步骤**(落地 → 校验 → 推送/对账 → 收尾);② **中途必须停**(等用户拍板 / 等窗口)⇒ **先释放再停**;\n"
|
||||
" ③ **结束语必须对锁状态负责** —— 写明\"已释放\",或**显式点名**\"锁仍在 `<OWNER>` + 原因 + 下一步\"(仅限释放通道不可用);⛔\"忘了 / 做不完就走\"不允许。\n",
|
||||
'库内 CODEBUDDY 补锁生命周期'),
|
||||
]
|
||||
|
||||
# ── P2-4 handoff-guard.sh:抢锁成功时当场把规矩打给持有者(机制层最有效)──
|
||||
H_PAIRS = [
|
||||
(' echo "✓ 已持全局执行锁($OWNER)—— 完工请 --release-exec"\n',
|
||||
' echo "✓ 已持全局执行锁($OWNER)"\n'
|
||||
' echo " ⛔ **锁的生命周期 = 任务的生命周期**(2026-09-14 用户明令):执行完成 → 必须 `--release-exec` 才算完成;"\n'
|
||||
' echo " 禁止抢锁做一半、不解锁就结束回合/会话(本库无心跳,带锁结束 = 把所有人挡在门外)。中途要停 ⇒ 先释放再停。"\n',
|
||||
'抢锁输出加硬提示'),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
print('落地包(%s)|库根 = %s' % ('dry-run' if DRY else '**APPLY**', LIB))
|
||||
for rel, pairs in (('scripts/docs-manifest.py', M_PAIRS),
|
||||
('scripts/handoff-guard.sh', H_PAIRS),
|
||||
('CODEBUDDY.md', C2_PAIRS),
|
||||
('scripts/docs-archive-index.py', A_PAIRS),
|
||||
('scripts/docs-shrink-guard.py', S_PAIRS),
|
||||
('CODEBUDDY.md', C_PAIRS)):
|
||||
try:
|
||||
patch(rel, pairs)
|
||||
except AssertionError as e:
|
||||
print(' %s' % e)
|
||||
print('\n⛔ 已中止,**未写入任何文件**(逐文件原子:前面已写的不会回滚,请先查清冲突)')
|
||||
return 1
|
||||
if DRY:
|
||||
print('\n(dry-run 通过)加 --apply 落盘。')
|
||||
else:
|
||||
print('\n✅ 已落盘。接着手工执行:')
|
||||
print(' python3 scripts/docs-manifest.py')
|
||||
print(' python3 scripts/docs-archive-index.py --write')
|
||||
print(' python3 scripts/docs-audit.py && python3 scripts/docs-consistency.py '
|
||||
'&& bash scripts/docs-sync-check.sh')
|
||||
print(' scp(同相对目录)+ 远端 chmod 600 / chown root:root')
|
||||
print(' git add scripts/docs-*.py CODEBUDDY.md INDEX.md archive-summaries.json docs-manifest.json && git commit')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""stop-dialog-guard.py —— 「禁止用征询句收尾」的 Stop 钩子(WorkBuddy / CodeBuddy)
|
||||
|
||||
为什么需要它
|
||||
────────────
|
||||
2026-09-15 实测:本工作区日志里 `tool=AskUserQuestion` 调用数 = 09-12: 43 / 09-13: 3 / **09-14: 0 / 09-15: 0**
|
||||
⇒ 既有「提问闸门」(PreToolUse + matcher ^AskUserQuestion$)**拦的是几乎不走的工具面**,
|
||||
而真实的上抛("要我接着做吗 / 请确认 / 说一声即可")发生在**正文里** —— 没有任何机制覆盖。
|
||||
|
||||
本钩子 = 覆盖那条面:**每次回复结束时**读 transcript 的**最后一条 assistant 文本**,
|
||||
只扫**收尾段**(最后两行有效内容)里的征询句式;命中 → 返回 `{"continue": false, "reason": …}`
|
||||
让 Agent **继续一轮并自我纠正**(把该自己做的事做掉,或改写成「需要你拍板」一节)。
|
||||
|
||||
安全设计(都不许省)
|
||||
────────────────────
|
||||
1. **自作用域**:只在 `transcript_path` 落在本工作区(`aliyun-dsh-server`)时生效,其他项目一律放行。
|
||||
2. **防死循环**:输入里的 `stop_hook_active == true` 时**不再阻拦**(官方语义:本次停止已由 stop hook 触发过)。
|
||||
3. **绝不添乱**:任何异常 → 静默放行(exit 0)。判定只在**收尾段**做,避免正文引用规则时误伤。
|
||||
4. **性能**:只读转录**末尾 256 KB**(实测整库最大转录 31.9 MB、全文读 14 MB ≈ 832 ms ⇒ 不可接受),只看 stdin + 该文件。
|
||||
5. **防跑飞**:同一会话 600 秒内最多拦**一次**。
|
||||
6. **急停双闸**(无需卸载/重启):env `DSH_STOP_GUARD_OFF=1`,或新建 `<工作区>/.workbuddy/stop-guard.disabled`。
|
||||
7. **低频自证日志**:命中才写一行(`<工作区>/.workbuddy/stop-dialog-guard.log`),用来回答"到底有没有触发"。
|
||||
|
||||
退出码:始终 0;决策通过 stdout 的 JSON 表达。
|
||||
安装(settings.json 的 hooks 段 · 见档案 73 / 99):
|
||||
"Stop": [{ "hooks": [{ "type": "command",
|
||||
"command": "\"<python>\" \"<此脚本>\"", "timeout": 10 }] }]
|
||||
⚠️ hooks 是**应用启动时快照** ⇒ 装完必须**完全重启 WorkBuddy**;桌面版无 /hooks 面板,等效。
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
SCOPE = 'aliyun-dsh-server' # 只对本工作区生效
|
||||
LOG_REL = os.path.join('.workbuddy', 'stop-dialog-guard.log')
|
||||
|
||||
# 只扫「收尾段」:出现这些就是"把该自己做的事甩回给用户"
|
||||
PATTERNS = [
|
||||
r'要我(再|接着|继续|现在)?[^。!?\n]{0,12}吗',
|
||||
r'要不要我[^。!?\n]{0,12}',
|
||||
r'是否要我[^。!?\n]{0,12}',
|
||||
r'需要我[^。!?\n]{0,12}吗',
|
||||
r'请确认[^。!?\n]{0,16}',
|
||||
r'要不要(继续|现在做|我来)[^。!?\n]{0,12}',
|
||||
r'是否(继续|需要我)[^。!?\n]{0,12}',
|
||||
r'说一声即可',
|
||||
r'你看(怎么办|怎么弄|要不要)',
|
||||
r'你(决定|拍板)一下',
|
||||
]
|
||||
RE_BAN = re.compile('|'.join(PATTERNS))
|
||||
|
||||
REASON = (
|
||||
'⛔ 收尾句是**征询句**,但按本平台规则(`CODEBUDDY.md §1`「回话前自检」+ `dsh-feature-first §5.3` 铁律 3)'
|
||||
'先重判三问:① 命中**真门禁**吗(不可逆破坏性操作 / 边界外六类)?没命中 → **删掉这句,自己做完,改成陈述句**("我接着做 X");'
|
||||
'② 是不是在把已经定下来的事再问一遍?是 → 删;③ 这件事用户有客观可判的优劣吗?没有 → 才允许问,且**一轮只问这一句**,'
|
||||
'并写进 `dsh-feature-first §5.1` 结论骨架的「**需要你拍板**」一节(一句话 + 可感知差别),不要在结尾甩问题。'
|
||||
)
|
||||
|
||||
|
||||
TAIL_BYTES = 262144
|
||||
MAX_BYTES = 4194304 # 扩窗上限 4 MB(防"巨行"时无限读) # 只读末尾 256 KB(实测:整库最大转录 31.9 MB;全文读 14 MB = 832 ms/轮,不可接受)
|
||||
|
||||
|
||||
def transcribe_last_assistant(path):
|
||||
"""返回最后一条 assistant 文本(**从尾部向后分块读**;读不到返回 '')。
|
||||
|
||||
⚠️ 为什么不是"一次读末尾 256 KB":一条 assistant 记录可能本身就 > 256 KB
|
||||
(长回复 / 被回显的工具输出),此时尾窗会切在 JSON 行中间 ⇒ `json.loads` 失败 ⇒ **静默漏判**。
|
||||
做法:从尾部按 TAIL_BYTES 递增扩窗(上限 MAX_BYTES),**直到至少解析出一条 assistant 记录**。
|
||||
常见情形(小消息)只花一次 256 KB 读,成本可忽略。
|
||||
"""
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
return ''
|
||||
with io.open(path, 'rb') as f:
|
||||
window = TAIL_BYTES
|
||||
while True:
|
||||
start = max(0, size - window)
|
||||
f.seek(start)
|
||||
raw = f.read().decode('utf-8', 'replace')
|
||||
lines = raw.split('\n')
|
||||
if start > 0:
|
||||
lines = lines[1:] # 丢弃被截断的首行
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if rec.get('type') != 'message' or rec.get('role') != 'assistant':
|
||||
continue
|
||||
chunks = [c['text'] for c in (rec.get('content') or [])
|
||||
if isinstance(c, dict) and isinstance(c.get('text'), str)]
|
||||
chunks += [c for c in (rec.get('content') or []) if isinstance(c, str)]
|
||||
if chunks:
|
||||
return '\n'.join(chunks)
|
||||
if start == 0 or window >= MAX_BYTES:
|
||||
# 放行,但**留痕**(A18:静默失败是负债)——可能是一条 >MAX_BYTES 的巨型记录
|
||||
try:
|
||||
os.environ.setdefault('_DSH_SG_MISS', '1')
|
||||
r0 = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or ''
|
||||
if r0:
|
||||
log(r0, '未能解析(窗口 %d 字节仍无 assistant 记录)' % window)
|
||||
except Exception:
|
||||
pass
|
||||
return ''
|
||||
window = min(window * 4, MAX_BYTES)
|
||||
|
||||
|
||||
def tail_lines(text, n=2):
|
||||
out = [l.strip() for l in text.strip().split('\n') if l.strip()]
|
||||
return '\n'.join(out[-n:])
|
||||
|
||||
|
||||
# 转述/引用豁免:收尾行里带引号或"引用/规则/写着/禁"等词 ⇒ 是在复述规则,不是在问用户
|
||||
RE_QUOTE = re.compile(r'[「」“”"\']|引用|规则|写着|禁')
|
||||
|
||||
|
||||
RATE_WINDOW = 600 # 秒;同一会话两次「阻止停止」的最小间隔
|
||||
|
||||
|
||||
def _rate_limited(root, sid, peek=False):
|
||||
"""同一会话 RATE_WINDOW 秒内已拦过 ⇒ 本次直接放行(防连续多轮被拦)。"""
|
||||
if not root or not sid:
|
||||
return False
|
||||
p = os.path.join(root, '.workbuddy', 'cache', 'stop-guard-fires.json')
|
||||
try:
|
||||
d = json.loads(io.open(p, encoding='utf-8').read()) if os.path.exists(p) else {}
|
||||
except Exception:
|
||||
d = {}
|
||||
now = time.time()
|
||||
if now - float(d.get(sid, 0) or 0) < RATE_WINDOW:
|
||||
return True
|
||||
d = {k: v for k, v in d.items() if now - float(v or 0) < 86400} # 只留 1 天
|
||||
d[sid] = now
|
||||
try:
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
io.open(p, 'w', encoding='utf-8', newline='\n').write(json.dumps(d))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def log(root, detail):
|
||||
try:
|
||||
p = os.path.join(root, LOG_REL)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with io.open(p, 'a', encoding='utf-8') as f:
|
||||
f.write('%s\t%s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), detail))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
raw = sys.stdin.read()
|
||||
if not raw.strip():
|
||||
return
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except ValueError:
|
||||
return
|
||||
tp = str(payload.get('transcript_path') or '')
|
||||
if SCOPE not in tp: # 作用域外 → 放行
|
||||
return
|
||||
if os.environ.get('DSH_STOP_GUARD_OFF'): # 急停(环境变量)→ 放行
|
||||
return
|
||||
root0 = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or ''
|
||||
if root0 and os.path.exists(os.path.join(root0, '.workbuddy', 'stop-guard.disabled')):
|
||||
return # 急停(闸刀文件)→ 放行
|
||||
if payload.get('stop_hook_active'): # 防死循环 → 放行
|
||||
return
|
||||
text = transcribe_last_assistant(tp)
|
||||
if not text:
|
||||
return
|
||||
sid = str(payload.get('session_id') or '')
|
||||
if os.environ.get('DSH_SG_DEBUG'): # 调试:每次调用都留痕(用于验证宿主是否真的调用本钩子)
|
||||
log(root0 or '.', 'invoked|scope=%s|tail_active=%s' % (SCOPE in tp, bool(payload.get('stop_hook_active'))))
|
||||
if _rate_limited(root0, sid, peek=True): # 只查不记账
|
||||
return
|
||||
tail = tail_lines(text, 1) # 只看**最后一行**:命中面越窄,误报越少
|
||||
if RE_QUOTE.search(tail): # 复述/引用规则 → 不是收尾提问
|
||||
return
|
||||
m = RE_BAN.search(tail)
|
||||
if not m:
|
||||
return
|
||||
root = (os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or '')
|
||||
_rate_limited(root0, sid) # 命中才记账(同一会话 10 分钟最多拦 1 次)
|
||||
log(root if os.path.isdir(root) else '.', 'stop-dialog-guard 命中:%s | 收尾:%s'
|
||||
% (m.group(0), tail.replace('\n', ' ')[:80]))
|
||||
sys.stdout.write(json.dumps({'continue': False, 'reason': REASON}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""R5 预验证:临时装 Stop 钩子 → headless 跑一次(**不重启桌面**)→ 自动卸回 → 出结论。
|
||||
|
||||
为什么安全:hooks 是**应用启动时快照** ⇒ 正在跑的桌面会话看不到这次改动,
|
||||
只有新起的进程(本例 = headless)才会加载它。全程自包含:无论成败都会卸回。
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
WS = r'E:/ProgramData/AIProject/aliyun-dsh-server'
|
||||
S = r'E:/ProgramData/.workbuddy/settings.json'
|
||||
CLI = r'C:/Users/Administrator/AppData/Local/Programs/WorkBuddy/resources/app.asar.unpacked/cli/dist/codebuddy-headless.js'
|
||||
NODE = r'E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe'
|
||||
PY = r'E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe'
|
||||
SCRIPT = WS + '/dsh-server-docs/scripts/stop-dialog-guard.py'
|
||||
LOG = WS + '/.workbuddy/stop-dialog-guard.log'
|
||||
ENTRY = {"hooks": [{"type": "command",
|
||||
"command": '"%s" -S -E "%s"' % (PY, SCRIPT),
|
||||
"timeout": 10}]}
|
||||
|
||||
|
||||
def hooks_state():
|
||||
d = json.load(io.open(S, encoding='utf-8'))
|
||||
return {k: len(v) for k, v in d.get('hooks', {}).items()}
|
||||
|
||||
|
||||
def main():
|
||||
print('[1] 装之前 hooks:', hooks_state(), flush=True)
|
||||
bak = S + '.bak-stopverify2-' + time.strftime('%H%M%S')
|
||||
shutil.copy2(S, bak)
|
||||
d = json.load(io.open(S, encoding='utf-8'))
|
||||
d['hooks']['Stop'] = [ENTRY]
|
||||
io.open(S, 'w', encoding='utf-8', newline='\n').write(json.dumps(d, ensure_ascii=False, indent=1))
|
||||
print('[2] 已临时装 Stop:', hooks_state(), '(备份', os.path.basename(bak), ')', flush=True)
|
||||
|
||||
if os.path.exists(LOG):
|
||||
os.remove(LOG)
|
||||
env = dict(os.environ)
|
||||
env['DSH_SG_DEBUG'] = '1'
|
||||
env['CODEBUDDY_PROJECT_DIR'] = WS
|
||||
prompt = '请只用这一句话回复我,不要做任何其他事、不要读文件:要我接着做吗?'
|
||||
out, err, rc = '', '', -1
|
||||
try:
|
||||
r = subprocess.run([NODE, CLI, '-p', prompt, '--output-format', 'stream-json'],
|
||||
cwd=WS, env=env, capture_output=True, text=True, timeout=420)
|
||||
out, err, rc = r.stdout, r.stderr, r.returncode
|
||||
except Exception as e:
|
||||
err = 'EXC: %s' % e
|
||||
print('[3] headless rc=%s|stdout %d 字节|stderr %d 字节' % (rc, len(out), len(err)), flush=True)
|
||||
|
||||
invoked = os.path.exists(LOG)
|
||||
logtxt = io.open(LOG, encoding='utf-8').read() if invoked else ''
|
||||
injected = ('征询句' in out) or ('征询句' in err)
|
||||
turns = out.count('"assistant"')
|
||||
print('[4] 结论:', flush=True)
|
||||
print(' 宿主是否调用 Stop 钩子 =', '是(有 debug 日志)' if invoked else '未观测到', flush=True)
|
||||
if logtxt:
|
||||
print(' debug 日志:', logtxt.strip().split('\n')[0][:180], flush=True)
|
||||
print(' 是否采纳 continue:false(reason 被注入)=', '是' if injected else '未观测到', flush=True)
|
||||
print(' assistant 出现次数(>1 = 被拦后继续了一轮)=', turns, flush=True)
|
||||
if rc != 0:
|
||||
print(' stderr 末尾:', (err or '')[-300:].replace('\n', ' '), flush=True)
|
||||
|
||||
d2 = json.load(io.open(S, encoding='utf-8'))
|
||||
if 'Stop' in d2.get('hooks', {}):
|
||||
del d2['hooks']['Stop']
|
||||
io.open(S, 'w', encoding='utf-8', newline='\n').write(json.dumps(d2, ensure_ascii=False, indent=1))
|
||||
print('[5] 已卸回:', hooks_state(), flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
# 重启后待办(WorkBuddy 完全重启之后执行)
|
||||
|
||||
> 为什么在这里:这批动作**只能**在应用完全重启后做(hooks 是**应用启动时快照**)。放在 `待落地/` 是为了"重启后那一刻必然被看到"。
|
||||
|
||||
## 1. ★ UserPromptSubmit 探针定论(最高优先,1 分钟)
|
||||
|
||||
```bash
|
||||
cat "E:/ProgramData/AIProject/aliyun-dsh-server/.workbuddy/stop-dialog-guard.log"
|
||||
```
|
||||
| 观测 | 结论 | 动作 |
|
||||
|---|---|---|
|
||||
| 出现 `invoked(user-prompt)` 行 | 该面**被调用** ✅ | 把模式切成 inject:`echo inject > "<工作区>/.workbuddy/stop-guard-mode"`(**改文件即生效、无需再重启**);随后验证:我故意用征询句结尾 → 下一轮应看到注入的上下文 |
|
||||
| 日志仍为空 | 该版本**连 UserPromptSubmit 也不调用** | 卸载 `hooks.UserPromptSubmit`(删一个键),只保留「常驻规则 + 每轮自检」那一层,**如实收口** |
|
||||
|
||||
⚠️ **区分清楚**:日志空**不能**在"未重启"状态下判负 —— 判断前先看 `last-launch.json` 是否晚于 **2026-09-15 08:26**(UserPromptSubmit 的装载时刻)。
|
||||
|
||||
## 2. 顺手核对(各 10 秒)
|
||||
|
||||
- `python3 "<技能>/scripts/resident-rules.py" --check` → 关键常驻规则是否齐备(应全绿)
|
||||
- 同一脚本 `--env-check` → 路径 / hooks 命令里的绝对路径是否仍有效
|
||||
- `bash dsh-server-docs/scripts/docs-sync-check.sh` → 对账是否只剩别人的 lane
|
||||
|
||||
## 3. 背景(为什么有这张表)
|
||||
|
||||
2026-09-15 实测:WorkBuddy 5.5.6 **不调用 `Stop` 钩子**(已卸载、不留假安全感)⇒ 改走 `UserPromptSubmit`(第二方案,已装为 probe 模式)。
|
||||
全过程见 `dsh-server-docs/04-调整方案/99-会话自决失效根因与Stop钩子补强.md`(含「修正(2026-09-15)」节)。
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"skill-load-guard.py": {
|
||||
"src_fp": "a70a4b4c90cb2069",
|
||||
"dst_fp": "bcae6454c1764662",
|
||||
"synced_at": "2026-09-22 06:15:18"
|
||||
},
|
||||
"stop-dialog-guard.py": {
|
||||
"src_fp": "569fc0e5d7e82f06",
|
||||
"dst_fp": "085d0cd87543ce08",
|
||||
"synced_at": "2026-09-22 06:15:18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
# guard/ —— 本工作区专用的 hook 副本(**已于 2026-09-22 退役挂载**)
|
||||
|
||||
> ## 🔴 2026-09-22 状态变更:本目录的**两条挂载已撤下**
|
||||
>
|
||||
> **退役原因**:本目录的存在理由(源脚本作用域写死成单工作区)**已被消除**。
|
||||
> 用户 2026-09-22 拍板「B」后,源脚本 `dsh-server-docs/scripts/{skill,stop-dialog}-guard.py`
|
||||
> 自己改成了**多作用域**(`_SCOPES_DEFAULT` 含 `aliyun-dsh-server` + `dsh-ai1net-desktop`,
|
||||
> 且支持 env `DSH_GUARD_SCOPES`)⇒ 源脚本**单条即可覆盖本工作区**。
|
||||
>
|
||||
> 若两套继续并存 ⇒ 本工作区每轮收到**两份完全相同的注入指令**
|
||||
> (2026-09-22 实测:副本与源脚本在同一秒各写一条 `HIT`,时间戳逐字相同)。
|
||||
> ⇒ 已从全局 `settings.json` 的 `hooks.UserPromptSubmit` 撤下本目录的 2 条。
|
||||
> 备份:`E:/ProgramData/.workbuddy/settings.json.bak-20260922-061649-retire-desktop-replica`
|
||||
>
|
||||
> **⛔ 本目录保留作"回滚锚点"与历史记录,未删除。** 需要重新启用时:
|
||||
> 把本目录两条 command 加回 `settings.json` 的 `hooks.UserPromptSubmit`,并**完全重启 WorkBuddy**。
|
||||
> ⚠️ 重新启用前请先确认源脚本的 `_SCOPES_DEFAULT` **不再**含 `dsh-ai1net-desktop`(否则又会双注入)。
|
||||
>
|
||||
> 下面 §1–§9 是**退役前的历史说明**,保留供追溯。
|
||||
|
||||
> 本目录是 `dsh-ai1net-desktop` 的 **hook 作用域副本**,2026-09-17 建立。
|
||||
> ⛔ **不要手改 `skill-load-guard.py` / `stop-dialog-guard.py`** —— 它们是生成物,下次同步即被覆盖。要改就改源脚本。
|
||||
|
||||
## 1. 为什么要有这个目录(历史)
|
||||
|
||||
源项目 `aliyun-dsh-server` 的两个 hook 脚本把作用域**写死**成单个工作区:
|
||||
|
||||
```python
|
||||
SCOPE = 'aliyun-dsh-server'
|
||||
...
|
||||
if SCOPE not in tp: return # stop-dialog-guard.py
|
||||
if not workdir or SCOPE not in workdir: return # skill-load-guard.py
|
||||
```
|
||||
|
||||
⇒ 本工作区被一律判为"作用域外",这两条钩子**静默空转**(日志里 `in_scope=False`)。
|
||||
|
||||
按用户 2026-09-17 的决定:**不改源项目脚本**(那是别人的 lane,且在全局执行锁保护下),
|
||||
改为**在本工作区新建副本 + 同步机制**。
|
||||
|
||||
> 第 3 条脚本 `bash-output-guard.py` **不在本目录** —— 它没有任何作用域门禁,对**所有工作空间**生效,无需副本。
|
||||
> `lock-guard-hook.py` 按**文件路径**(文档库 / 代码仓)判定,与本工作区无关。
|
||||
|
||||
## 2. 三个文件
|
||||
|
||||
| 文件 | 角色 |
|
||||
|---|---|
|
||||
| `sync-scoped-guards.py` | **同步器**(手写、可改):读源脚本 → 只变换「作用域」那一层 → 写副本 |
|
||||
| `skill-load-guard.py` | 副本(生成物)—— 用户点名方法时注入"必须加载技能" |
|
||||
| `stop-dialog-guard.py` | 副本(生成物)—— 注入收尾征询句纠正 / 分级水位 / 门禁·路径自检 |
|
||||
|
||||
状态文件:`.guard-sync-state`(记录每个副本对应的**源脚本指纹**,用于判定是否需要重新生成)。
|
||||
|
||||
## 3. 同步机制(三重,正常情况下无需人工介入)
|
||||
|
||||
| # | 触发 | 动作 |
|
||||
|---|---|---|
|
||||
| 1 | **自动(主路径)** | 副本每次被宿主调用时执行 `self_refresh()` ⇒ 比对源脚本指纹 ⇒ 有变化就地重新生成。⚠️ 带**廉价前置判断**:只在本工作区的会话里才真跑,其他工作区直接 return(不 spawn 子进程、不留痕) |
|
||||
| 2 | **手动** | `"<python>" sync-scoped-guards.py`(`--force` 无视指纹强制重生成) |
|
||||
| 3 | **检查** | `"<python>" sync-scoped-guards.py --check` —— 只报状态、不写盘 |
|
||||
|
||||
> 因为副本自己会在每次调用时自检,**不需要额外的定时任务**(等价的"持续同步",比"定期同步"更及时且零成本)。
|
||||
|
||||
## 4. 变换规则(只动作用域,其余逐字保留)
|
||||
|
||||
1. `SCOPE = '<单值>'` → 提升为文件头的 `_scopes()` / `_in_scope()`(可用 env `DSH_GUARD_SCOPES` 覆盖,逗号分隔)
|
||||
2. `SCOPE not in <expr>` → `not _in_scope(<expr>)`
|
||||
3. `SCOPE in <expr>` → `_in_scope(<expr>)`
|
||||
4. 在 `if __name__ == '__main__':` 前挂一行 `self_refresh()`
|
||||
|
||||
**叠加修正 C1(本工作区专有,幂等)**:源脚本 `session_budget()` 在「文件超大 / 不存在」两个分支
|
||||
`return None, None`(2 值),而正常分支 3 值、调用处按 3 值解包 ⇒ 转录缺失时抛 `ValueError`。
|
||||
源项目因作用域提前 return 而从未暴露;本工作区打通作用域后即成为每轮可达路径 ⇒ 生成时补齐为 3 值。
|
||||
(源脚本若自行修复,本规则自动失效,不会重复动手。)
|
||||
|
||||
## 5. 安全设计
|
||||
|
||||
- **生成后再校验**:`ast.parse` 通过 + 变换后不得残留裸 `SCOPE` ⇒ 才落盘
|
||||
- **原子替换**:写 `.tmp` 后 `os.replace`(并发调用不会读到半截文件)
|
||||
- **失败不动盘**:源文件缺失 / 变换失配 / 语法不过 ⇒ 保留旧副本 + 非零退出码,绝不写坏
|
||||
- **只读源项目**:只读源脚本、只写本目录;⛔ 永不修改源项目
|
||||
- **跨工作区零副作用**:`self_refresh()` 有前置判断;副本的 scope 判定会让其他工作区直接 return
|
||||
|
||||
## 6. 挂在哪儿 / 怎么生效
|
||||
|
||||
两条副本已追加进**全局**配置 `E:\ProgramData\.workbuddy\settings.json` 的 `hooks.UserPromptSubmit`
|
||||
(现有 2 条源项目脚本之后,共 4 条)。备份:`settings.json.bak-20260917-guard`。
|
||||
|
||||
⚠️ **hooks 是应用启动时的快照** ⇒ 新增条目必须**完全重启 WorkBuddy**(关窗 ≠ 退出)才加载。
|
||||
(但**脚本内容的改动不需要重启** —— 每次调用都现读磁盘。)
|
||||
|
||||
本工作区配套:`.workbuddy/stop-guard-mode` = `inject`(缺省 `probe` 只记日志、不注入提醒)。
|
||||
|
||||
## 7. 常见操作
|
||||
|
||||
```bash
|
||||
PY="E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe"
|
||||
G="E:/ProgramData/AI技能/dsh-ai1net-desktop/.workbuddy/guard"
|
||||
|
||||
"$PY" "$G/sync-scoped-guards.py" --check # 看状态
|
||||
"$PY" "$G/sync-scoped-guards.py" # 同步(源变才动)
|
||||
"$PY" "$G/sync-scoped-guards.py" --force # 强制重生成
|
||||
```
|
||||
|
||||
**回滚**(三档,从轻到重):
|
||||
|
||||
1. 临时收窄:设 env `DSH_GUARD_SCOPES=aliyun-dsh-server`
|
||||
2. 单点急停:本工作区建 `.workbuddy/skill-guard.disabled`,或设 env `DSH_STOP_GUARD_OFF=1`
|
||||
3. 彻底卸载:把 `settings.json` 里那两条 command 删掉(或整体用 `.bak-20260917-guard` 还原),再删本目录
|
||||
|
||||
## 8. 扩展:再加一个工作区
|
||||
|
||||
在源脚本里扩展(推荐,一份实现)只改 3 处定义 + 判定;
|
||||
或用本目录的方式:把新工作区加进 `TARGETS` 之外的 —— 只需把该工作区的路径标记传给生成器:
|
||||
|
||||
```bash
|
||||
DSH_GUARD_WS_MARK=新工作区名 "$PY" "$G/sync-scoped-guards.py"
|
||||
```
|
||||
|
||||
⛔ 若采用后者,**不要原地覆盖本目录**(会串作用域)—— 复制整个 `guard/` 到新工作区的 `.workbuddy/` 下再生成。
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# ⚙️ 本文件由 `sync-scoped-guards.py` **自动生成**,请勿手改(改动会在下次同步时被覆盖)。
|
||||
# 来源:D:\github\dsh_shenxian\dsh-server-docs\scripts\skill-load-guard.py
|
||||
# 生成:2026-09-17 10:33:08 指纹:8f94cf71220e44fe
|
||||
# 作用域:dsh-ai1net-desktop ← 由源项目的单工作区版本机械变换而来,其余逻辑逐字保留
|
||||
|
||||
import os as _os
|
||||
|
||||
|
||||
def _scopes():
|
||||
"""作用域标记(可用 env DSH_GUARD_SCOPES 覆盖,逗号分隔)。"""
|
||||
raw = _os.environ.get('DSH_GUARD_SCOPES') or 'dsh-ai1net-desktop'
|
||||
return tuple(s.strip() for s in raw.split(',') if s.strip())
|
||||
|
||||
|
||||
_SCOPES = _scopes()
|
||||
|
||||
|
||||
def _in_scope(s):
|
||||
return any(x in str(s) for x in _SCOPES)
|
||||
|
||||
|
||||
def self_refresh():
|
||||
"""源脚本变更 ⇒ 就地把本副本重新生成一次(失败则保留旧版,绝不写坏)。
|
||||
|
||||
⚠️ 前置廉价判断:**只在本工作区的会话里才真跑** —— 本副本可能被挂在全局 hooks 上
|
||||
(对所有工作区都会被执行),其他工作区直接 return,不 spawn 子进程、不留痕。
|
||||
"""
|
||||
try:
|
||||
_root = _os.environ.get('CODEBUDDY_PROJECT_DIR') or _os.getcwd()
|
||||
if 'dsh-ai1net-desktop' not in str(_root):
|
||||
return False
|
||||
import subprocess
|
||||
import sys as _sys
|
||||
r = subprocess.run([_sys.executable, _os.path.join(_os.path.dirname(_os.path.abspath(__file__)),
|
||||
'sync-scoped-guards.py'), '--quiet'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
|
||||
return r.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ── 以下为源脚本正文 ────────────────────────────────────────────────────────
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""skill-load-guard.py —— 「用户点名了方法 ⇒ 强制加载技能」的 UserPromptSubmit 钩子
|
||||
|
||||
为什么需要它
|
||||
────────────
|
||||
2026-09-16 复盘会话 `ddea70b7`(「确认guest用户数据迁移到106服务器」):
|
||||
用户 U6 明确说「D1–D6 先做哪些 **按照你的规划执行**,中间有问题**参考决策方法**」,
|
||||
但该会话 265 次工具调用里 **`Skill` 计数 = 0** —— 那份方法论**从未进入上下文**。
|
||||
AI 仍按常驻判据把两个**非门禁**问题("106 旧控制面要不要停" / "D1–D6 先做哪些")上抛给用户,
|
||||
而用户随后 45 秒 / 58 秒内自己给出了答案(Q1 的答案还超出了 AI 给的选项空间)。
|
||||
|
||||
⇒ 与 `stop-dialog-guard.py` 同族问题:**规则写了,但没有任何机制保证它在正确的时机被取用**。
|
||||
(那个钩子处理"回复收尾时的征询句",本钩子处理"用户点名时的方法加载",两者互补。)
|
||||
|
||||
本钩子 = 覆盖"用户点名"这条面:**每次用户提交时**扫输入,命中点名关键词 ⇒
|
||||
通过 `additionalContext` 注入一条**紧邻用户消息**的强制指令
|
||||
(位置比静态规则文件显著得多 —— 长会话里 CODEBUDDY.md 的硬要求容易被"稀释")。
|
||||
|
||||
安全设计(都不许省)
|
||||
────────────────────
|
||||
1. **自作用域**:只在 cwd 落在本工作区(`aliyun-dsh-server`)时生效,其他项目一律放行。
|
||||
2. **绝不添乱**:任何异常 → 静默放行(exit 0);本钩子**从不拦用户**,只在命中时**追加一段提醒**。
|
||||
3. **防跑飞**:同一会话 300 秒内最多注入一次。
|
||||
4. **急停双闸**(无需卸载/重启):env `DSH_SKILL_GUARD_OFF=1`,或 `<工作区>/.workbuddy/skill-guard.disabled`。
|
||||
5. **低频自证日志**:命中才写一行到 `<工作区>/.workbuddy/skill-load-guard.log` —— 用来回答"到底有没有触发"。
|
||||
6. **显式 UTF-8**:读写都走 `buffer`,不依赖 `PYTHONUTF8`(`-E` 会屏蔽它 ⇒ cp936 ⇒ 含中文静默炸)。
|
||||
|
||||
退出码:始终 0;决策通过 stdout 的 JSON 表达。
|
||||
安装(`settings.json` 的 hooks 段 · 与既有钩子同族,**并列挂在同一个 UserPromptSubmit 的 hooks 数组里**):
|
||||
"UserPromptSubmit": [{ "hooks": [
|
||||
{ "type": "command", "command": "\"<python>\" \"<此脚本>\"", "timeout": 10 },
|
||||
{ "type": "command", "command": "\"<python>\" -S -E \"<stop-dialog-guard.py>\"", "timeout": 10 }
|
||||
]}]
|
||||
⚠️ hooks 是**应用启动时快照** ⇒ 装完必须**完全重启 WorkBuddy**(关窗 ≠ 退出)才加载。
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
# 作用域定义已由生成器提升到文件头部(`_scopes()` / `_in_scope()`)
|
||||
LOG_REL = os.path.join('.workbuddy', 'skill-load-guard.log')
|
||||
STATE_REL = os.path.join('.workbuddy', '.skill-load-guard.state')
|
||||
DISABLE_REL = os.path.join('.workbuddy', 'skill-guard.disabled')
|
||||
COOLDOWN = 300 # 同会话注入冷却(秒)
|
||||
|
||||
# 用户「点名方法」的词表 —— 命中任一即注入。
|
||||
# 「决策方法」是最稳的一条(覆盖"参考/按/用决策方法")。
|
||||
TRIGGERS = (
|
||||
'决策方法',
|
||||
'自行决策', '自主决策', '自己决策', '自己拿主意',
|
||||
'别问我', '不要问我', '不用问我', '按你的规划', '按你的判断',
|
||||
'参考决策', '决策方法论',
|
||||
)
|
||||
|
||||
|
||||
def _read_stdin():
|
||||
try:
|
||||
raw = sys.stdin.buffer.read()
|
||||
except Exception:
|
||||
return {}
|
||||
if not raw:
|
||||
return {}
|
||||
for enc in ('utf-8', 'utf-8-sig', 'gbk'):
|
||||
try:
|
||||
return json.loads(raw.decode(enc))
|
||||
except Exception:
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
def _emit(obj):
|
||||
try:
|
||||
sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False).encode('utf-8'))
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _workdir(obj):
|
||||
for k in ('cwd', 'project_dir', 'workspace', 'projectDir'):
|
||||
v = obj.get(k)
|
||||
if isinstance(v, str) and v:
|
||||
return v
|
||||
for k in ('CODEBUDDY_PROJECT_DIR', 'WORKSPACE_FOLDER', 'PWD'):
|
||||
v = os.environ.get(k)
|
||||
if v:
|
||||
return v
|
||||
# 兜底:本脚本位于 <工作区>/dsh-server-docs/scripts/
|
||||
try:
|
||||
return os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _log(workdir, line):
|
||||
try:
|
||||
p = os.path.join(workdir, LOG_REL)
|
||||
with io.open(p, 'a', encoding='utf-8', newline='\n') as f:
|
||||
f.write('%s %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), line))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _cooling(workdir, sid):
|
||||
"""同一会话 COOLDOWN 秒内不重复注入。"""
|
||||
try:
|
||||
p = os.path.join(workdir, STATE_REL)
|
||||
now = time.time()
|
||||
last_sid, last_ts = '', 0.0
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with io.open(p, 'r', encoding='utf-8') as f:
|
||||
parts = f.read().strip().split('\t')
|
||||
last_sid, last_ts = parts[0], float(parts[1] or 0)
|
||||
except Exception:
|
||||
pass
|
||||
if last_sid == sid and (now - last_ts) < COOLDOWN:
|
||||
return True
|
||||
with io.open(p, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write('%s\t%.3f\n' % (sid, now))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if os.environ.get('DSH_SKILL_GUARD_OFF') == '1':
|
||||
return
|
||||
obj = _read_stdin()
|
||||
if not obj:
|
||||
return
|
||||
|
||||
workdir = _workdir(obj)
|
||||
if not workdir or not _in_scope(workdir):
|
||||
return # 自作用域:别的工作区一律放行
|
||||
if os.path.exists(os.path.join(workdir, DISABLE_REL)):
|
||||
return
|
||||
|
||||
prompt = ''
|
||||
for k in ('prompt', 'user_prompt', 'userPrompt', 'message', 'text'):
|
||||
v = obj.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
prompt = v
|
||||
break
|
||||
|
||||
sid = str(obj.get('session_id') or obj.get('sessionId') or 'unknown')
|
||||
# ★ 入口即留痕(2026-09-16 加)—— 否则"没命中就没日志",永远无法回答
|
||||
# "它到底有没有被宿主调用"。与 stop-dialog-guard.py 同款设计;每次用户提交一行。
|
||||
_log(workdir, 'entry|event=UserPromptSubmit|in_scope=True|sid=%s|prompt_len=%d'
|
||||
% (sid[:8], len(prompt)))
|
||||
if not prompt:
|
||||
return
|
||||
|
||||
hits = [w for w in TRIGGERS if w in prompt]
|
||||
if not hits:
|
||||
return
|
||||
|
||||
if _cooling(workdir, sid):
|
||||
return
|
||||
|
||||
seg = re.sub(r'\s+', ' ', prompt)
|
||||
pos = min(seg.find(w) for w in hits)
|
||||
excerpt = seg[max(0, pos - 40): pos + 80].strip()
|
||||
|
||||
ctx = (
|
||||
'【技能加载闸门 · 机制层强制】检测到用户本轮**点名了方法**(命中:%s)。\n'
|
||||
'原文片段:「…%s…」\n\n'
|
||||
'⛔ **不要凭记忆代替、也不要以"我已经知道判据"为由跳过**:\n'
|
||||
' **先调用 Skill 工具加载 `dsh-decision-method`**(决策方法论 · 含 §4.5 规则冲突裁决顺序 + 上抛前三问),\n'
|
||||
' 然后才开始作答。若本轮涉及功能需求判类型 / 该不该上抛,**同时加载 `dsh-feature-first`**。\n\n'
|
||||
'为什么强制:2026-09-16 实测(会话 ddea70b7)—— 用户点名「参考决策方法」后,'
|
||||
'AI 全程 `Skill` 调用 **0 次**,仍按旧判据把两个**非门禁**问题上抛给用户,'
|
||||
'用户 45 / 58 秒后自己给出了答案。**规则写在文件里 ≠ 会在正确的时机被取用。**\n'
|
||||
'(若判定本轮确实与决策无关,可在作答中一句话说明后继续 —— 但**不要静默跳过加载**。)'
|
||||
) % ('、'.join(hits), excerpt)
|
||||
|
||||
_emit({'hookSpecificOutput': {
|
||||
'hookEventName': 'UserPromptSubmit',
|
||||
'additionalContext': ctx,
|
||||
}})
|
||||
_log(workdir, 'HIT sid=%s hits=%s' % (sid[:8], ','.join(hits)))
|
||||
|
||||
|
||||
self_refresh()
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
+619
@@ -0,0 +1,619 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# ⚙️ 本文件由 `sync-scoped-guards.py` **自动生成**,请勿手改(改动会在下次同步时被覆盖)。
|
||||
# 来源:D:\github\dsh_shenxian\dsh-server-docs\scripts\stop-dialog-guard.py
|
||||
# 生成:2026-09-17 10:32:16 指纹:92ef7e9233428fab
|
||||
# 作用域:dsh-ai1net-desktop ← 由源项目的单工作区版本机械变换而来,其余逻辑逐字保留
|
||||
|
||||
import os as _os
|
||||
|
||||
|
||||
def _scopes():
|
||||
"""作用域标记(可用 env DSH_GUARD_SCOPES 覆盖,逗号分隔)。"""
|
||||
raw = _os.environ.get('DSH_GUARD_SCOPES') or 'dsh-ai1net-desktop'
|
||||
return tuple(s.strip() for s in raw.split(',') if s.strip())
|
||||
|
||||
|
||||
_SCOPES = _scopes()
|
||||
|
||||
|
||||
def _in_scope(s):
|
||||
return any(x in str(s) for x in _SCOPES)
|
||||
|
||||
|
||||
def self_refresh():
|
||||
"""源脚本变更 ⇒ 就地把本副本重新生成一次(失败则保留旧版,绝不写坏)。
|
||||
|
||||
⚠️ 前置廉价判断:**只在本工作区的会话里才真跑** —— 本副本可能被挂在全局 hooks 上
|
||||
(对所有工作区都会被执行),其他工作区直接 return,不 spawn 子进程、不留痕。
|
||||
"""
|
||||
try:
|
||||
_root = _os.environ.get('CODEBUDDY_PROJECT_DIR') or _os.getcwd()
|
||||
if 'dsh-ai1net-desktop' not in str(_root):
|
||||
return False
|
||||
import subprocess
|
||||
import sys as _sys
|
||||
r = subprocess.run([_sys.executable, _os.path.join(_os.path.dirname(_os.path.abspath(__file__)),
|
||||
'sync-scoped-guards.py'), '--quiet'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
|
||||
return r.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ── 以下为源脚本正文 ────────────────────────────────────────────────────────
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""stop-dialog-guard.py —— 「禁止用征询句收尾」的 Stop 钩子(WorkBuddy / CodeBuddy)
|
||||
|
||||
为什么需要它
|
||||
────────────
|
||||
2026-09-15 实测:本工作区日志里 `tool=AskUserQuestion` 调用数 = 09-12: 43 / 09-13: 3 / **09-14: 0 / 09-15: 0**
|
||||
⇒ 既有「提问闸门」(PreToolUse + matcher ^AskUserQuestion$)**拦的是几乎不走的工具面**,
|
||||
而真实的上抛("要我接着做吗 / 请确认 / 说一声即可")发生在**正文里** —— 没有任何机制覆盖。
|
||||
|
||||
本钩子 = 覆盖那条面:**每次回复结束时**读 transcript 的**最后一条 assistant 文本**,
|
||||
只扫**收尾段**(最后两行有效内容)里的征询句式;命中 → 返回 `{"continue": false, "reason": …}`
|
||||
让 Agent **继续一轮并自我纠正**(把该自己做的事做掉,或改写成「需要你拍板」一节)。
|
||||
|
||||
安全设计(都不许省)
|
||||
────────────────────
|
||||
1. **自作用域**:只在 `transcript_path` 落在本工作区(`aliyun-dsh-server`)时生效,其他项目一律放行。
|
||||
2. **防死循环**:输入里的 `stop_hook_active == true` 时**不再阻拦**(官方语义:本次停止已由 stop hook 触发过)。
|
||||
3. **绝不添乱**:任何异常 → 静默放行(exit 0)。判定只在**收尾段**做,避免正文引用规则时误伤。
|
||||
4. **性能**:只读转录**末尾 256 KB**(实测整库最大转录 31.9 MB、全文读 14 MB ≈ 832 ms ⇒ 不可接受),只看 stdin + 该文件。
|
||||
5. **防跑飞**:同一会话 600 秒内最多拦**一次**。
|
||||
6. **急停双闸**(无需卸载/重启):env `DSH_STOP_GUARD_OFF=1`,或新建 `<工作区>/.workbuddy/stop-guard.disabled`。
|
||||
7. **低频自证日志**:命中才写一行(`<工作区>/.workbuddy/stop-dialog-guard.log`),用来回答"到底有没有触发"。
|
||||
|
||||
退出码:始终 0;决策通过 stdout 的 JSON 表达。
|
||||
安装(settings.json 的 hooks 段 · 见档案 73 / 99):
|
||||
"Stop": [{ "hooks": [{ "type": "command",
|
||||
"command": "\"<python>\" \"<此脚本>\"", "timeout": 10 }] }]
|
||||
⚠️ hooks 是**应用启动时快照** ⇒ 装完必须**完全重启 WorkBuddy**;桌面版无 /hooks 面板,等效。
|
||||
⛔ **安装命令不要给本脚本加 `-E`(或任何会屏蔽 PYTHONUTF8 的 flag)**:本机环境本就设了
|
||||
`PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8`,而 `-E` 会把它们**全部忽略** ⇒ stdin 回退 **cp936** ⇒
|
||||
含中文的 payload 解码即炸。本脚本现已改为走 `buffer` 显式 UTF-8(读写都加固),但**不要靠加固兜底**,
|
||||
装的时候也别再引入新雷。(2026-09-15 实测:`-S -E` 曾让本钩子"看起来从未被调用"整整一天。)
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
# 作用域定义已由生成器提升到文件头部(`_scopes()` / `_in_scope()`)
|
||||
LOG_REL = os.path.join('.workbuddy', 'stop-dialog-guard.log')
|
||||
# 兜底工作区:用于"每次调用必留痕"(万一宿主没给 cwd、也没设 CODEBUDDY_PROJECT_DIR)
|
||||
# 本脚本位于 <工作区>/dsh-server-docs/scripts/ ⇒ 往上三级即工作区
|
||||
WS_FALLBACK = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# 只扫「收尾段」:出现这些就是"把该自己做的事甩回给用户"
|
||||
PATTERNS = [
|
||||
r'要我(再|接着|继续|现在)?[^。!?\n]{0,20}吗',
|
||||
r'要不要我[^。!?\n]{0,20}',
|
||||
r'是否要我[^。!?\n]{0,20}',
|
||||
r'需要我[^。!?\n]{0,20}吗',
|
||||
r'请确认[^。!?\n]{0,16}',
|
||||
r'要不要(继续|现在做|我来)[^。!?\n]{0,20}',
|
||||
r'是否(继续|需要我)[^。!?\n]{0,20}',
|
||||
r'说一声即可',
|
||||
r'你看(怎么办|怎么弄|要不要)',
|
||||
r'你(决定|拍板)一下',
|
||||
]
|
||||
RE_BAN = re.compile('|'.join(PATTERNS))
|
||||
|
||||
REASON = (
|
||||
'⛔ 收尾句是**征询句**,但按本平台规则(`CODEBUDDY.md §1`「回话前自检」+ `dsh-feature-first §5.3` 铁律 3)'
|
||||
'先重判三问:① 命中**真门禁**吗(不可逆破坏性操作 / 边界外六类)?没命中 → **删掉这句,自己做完,改成陈述句**("我接着做 X");'
|
||||
'② 是不是在把已经定下来的事再问一遍?是 → 删;③ 这件事用户有客观可判的优劣吗?没有 → 才允许问,且**一轮只问这一句**,'
|
||||
'并写进 `dsh-feature-first §5.1` 结论骨架的「**需要你拍板**」一节 —— 该节必须是**整条回复的最后一节**、'
|
||||
'且**逐条编号**(有序段落)(2026-09-15 用户明令:「放在最后,别隐藏在回复内容中间」「按照有序段落展示」),'
|
||||
'用**陈述句**列"各候选的**优点 / 缺点** + 我的倾向",不要用征询句。'
|
||||
'⚠️ 上抛前先过**取舍筛** —— 某个候选**只有优点 / 只有缺点** ⇒ **自己拍掉、不要问**;'
|
||||
'且候选**竖排成段**(A / B / C 各占一行),⛔ 不横排、不做成表格的列(2026-09-15 用户明令)。'
|
||||
)
|
||||
|
||||
|
||||
TAIL_BYTES = 262144
|
||||
MAX_BYTES = 4194304 # 扩窗上限 4 MB(防"巨行"时无限读) # 只读末尾 256 KB(实测:整库最大转录 31.9 MB;全文读 14 MB = 832 ms/轮,不可接受)
|
||||
|
||||
|
||||
def transcribe_last_assistant(path):
|
||||
"""返回最后一条 assistant 文本(**从尾部向后分块读**;读不到返回 '')。
|
||||
|
||||
⚠️ 为什么不是"一次读末尾 256 KB":一条 assistant 记录可能本身就 > 256 KB
|
||||
(长回复 / 被回显的工具输出),此时尾窗会切在 JSON 行中间 ⇒ `json.loads` 失败 ⇒ **静默漏判**。
|
||||
做法:从尾部按 TAIL_BYTES 递增扩窗(上限 MAX_BYTES),**直到至少解析出一条 assistant 记录**。
|
||||
常见情形(小消息)只花一次 256 KB 读,成本可忽略。
|
||||
"""
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
return ''
|
||||
with io.open(path, 'rb') as f:
|
||||
window = TAIL_BYTES
|
||||
while True:
|
||||
start = max(0, size - window)
|
||||
f.seek(start)
|
||||
raw = f.read().decode('utf-8', 'replace')
|
||||
lines = raw.split('\n')
|
||||
if start > 0:
|
||||
lines = lines[1:] # 丢弃被截断的首行
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if rec.get('type') != 'message' or rec.get('role') != 'assistant':
|
||||
continue
|
||||
chunks = [c['text'] for c in (rec.get('content') or [])
|
||||
if isinstance(c, dict) and isinstance(c.get('text'), str)]
|
||||
chunks += [c for c in (rec.get('content') or []) if isinstance(c, str)]
|
||||
if chunks:
|
||||
return '\n'.join(chunks)
|
||||
if start == 0 or window >= MAX_BYTES:
|
||||
# 放行,但**留痕**(A18:静默失败是负债)——可能是一条 >MAX_BYTES 的巨型记录
|
||||
try:
|
||||
os.environ.setdefault('_DSH_SG_MISS', '1')
|
||||
r0 = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or ''
|
||||
if r0:
|
||||
log(r0, '未能解析(窗口 %d 字节仍无 assistant 记录)' % window)
|
||||
except Exception:
|
||||
pass
|
||||
return ''
|
||||
window = min(window * 4, MAX_BYTES)
|
||||
|
||||
|
||||
def tail_lines(text, n=2):
|
||||
out = [l.strip() for l in text.strip().split('\n') if l.strip()]
|
||||
return '\n'.join(out[-n:])
|
||||
|
||||
|
||||
# 转述/引用豁免:收尾行里带引号或"引用/规则/写着/禁"等词 ⇒ 是在复述规则,不是在问用户
|
||||
RE_QUOTE = re.compile(r'[「」“”"\']|引用|规则|写着|禁')
|
||||
|
||||
|
||||
RATE_WINDOW = 600 # 秒;同一会话两次「阻止停止」的最小间隔
|
||||
|
||||
|
||||
def _rate_limited(root, sid, peek=False):
|
||||
"""同一会话 RATE_WINDOW 秒内已拦过 ⇒ 本次直接放行(防连续多轮被拦)。"""
|
||||
if not root or not sid:
|
||||
return False
|
||||
p = os.path.join(root, '.workbuddy', 'cache', 'stop-guard-fires.json')
|
||||
try:
|
||||
d = json.loads(io.open(p, encoding='utf-8').read()) if os.path.exists(p) else {}
|
||||
except Exception:
|
||||
d = {}
|
||||
now = time.time()
|
||||
if now - float(d.get(sid, 0) or 0) < RATE_WINDOW:
|
||||
return True
|
||||
d = {k: v for k, v in d.items() if now - float(v or 0) < 86400} # 只留 1 天
|
||||
d[sid] = now
|
||||
try:
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
io.open(p, 'w', encoding='utf-8', newline='\n').write(json.dumps(d))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def log(root, detail):
|
||||
try:
|
||||
p = os.path.join(root, LOG_REL)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with io.open(p, 'a', encoding='utf-8') as f:
|
||||
f.write('%s\t%s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), detail))
|
||||
lines = io.open(p, encoding='utf-8').read().split('\n') # 上限 300 行,超出截半(防膨胀)
|
||||
if len(lines) > 300:
|
||||
io.open(p, 'w', encoding='utf-8', newline='\n').write('\n'.join(lines[-150:]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _entry_log(payload, raw_len):
|
||||
"""⚠️ **每次被调用必留痕**(含"payload 解析失败 / 未进作用域 / 被急停"三种静默情形)。
|
||||
|
||||
2026-09-15 教训:原实现只在**通过全部守卫之后**才写日志 ⇒ 日志缺失时**无法区分**
|
||||
「宿主根本没调用」与「调用了但被静默 return」—— 而这两者的处置**完全相反**
|
||||
(前者要卸载、后者要放宽作用域判据)。凡"要判有没有被调用"的探针,必须**入口即留痕**。
|
||||
"""
|
||||
try:
|
||||
p = payload if isinstance(payload, dict) else {}
|
||||
tp = str(p.get('transcript_path') or '')
|
||||
root = (os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE')
|
||||
or p.get('cwd') or WS_FALLBACK)
|
||||
log(str(root), 'entry|event=%s|cwd=%s|in_scope=%s|tp=%s|keys=%s|stdin_len=%s'
|
||||
% (p.get('hook_event_name') or '(parse-fail)', p.get('cwd') or '-',
|
||||
_in_scope(tp), (tp[-80:] if tp else '-'),
|
||||
(','.join(sorted(p.keys()))[:120] or '-'), raw_len))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _read_stdin_text():
|
||||
"""**显式按 UTF-8 读 stdin** —— 不要用 `sys.stdin.read()`。
|
||||
|
||||
⚠️ 2026-09-15 实测定位:本脚本的安装形态是 `python -S -E <脚本>`,而 **`-E` 会忽略
|
||||
`PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8`** ⇒ `sys.stdin.encoding` 回退成 **cp936**;
|
||||
钩子 payload 里**必然含中文**(用户的提示词)⇒ 文本模式读取抛
|
||||
`UnicodeDecodeError: 'gbk' codec can't decode byte 0x80` ⇒ **钩子静默不生效、日志为空**,
|
||||
表象却是"宿主好像没调用钩子"(实为本地炸在解码上,白排查一轮)。
|
||||
读 `buffer` 即与 flag / locale 完全无关。
|
||||
"""
|
||||
try:
|
||||
return sys.stdin.buffer.read().decode('utf-8', 'replace')
|
||||
except Exception:
|
||||
try:
|
||||
return sys.stdin.read()
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _emit(obj):
|
||||
"""**显式按 UTF-8 写 stdout**(同理:cp936 下 `ensure_ascii=False` 的中文 / `⛔` 会 UnicodeEncodeError)。"""
|
||||
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
|
||||
try:
|
||||
sys.stdout.buffer.write(data)
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception: # 极端兜底:退回文本写(可能丢非 GBK 字符,但不至于静默不输出)
|
||||
try:
|
||||
sys.stdout.write(data.decode('utf-8', 'replace'))
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 第二方案:`UserPromptSubmit`(2026-09-15 加)
|
||||
# 背景:本版 WorkBuddy **不调用 `Stop` 钩子**(实测:留痕已开、探针句已验证会命中、日志仍空)⇒ 改用
|
||||
# `UserPromptSubmit`(输入同样带 `transcript_path`,且能通过 `additionalContext` 注入上下文)。
|
||||
# 两级模式(**改一个文本文件即可切换,无需重启** —— 脚本内容每次调用现读):
|
||||
# probe :只写日志(零风险、可判定"有没有被调用")
|
||||
# inject :若**上一轮回复的收尾是征询句** ⇒ 注入一段上下文,让下一轮自我纠正
|
||||
# 模式文件:<工作区>/.workbuddy/stop-guard-mode (内容含 "inject" 即切到 inject,否则 probe)
|
||||
CONTEXT = (
|
||||
'⛔ 【上一轮收尾自检】你上一条回复的**最后一行是征询句**("要我…吗 / 要不要我 / 请确认 / 说一声即可"类),'
|
||||
'这属于本平台**被禁的形态**(`CODEBUDDY.md §1`「回话前自检」)。本轮的处置:'
|
||||
'① 若那件事本来就该你自己拍 —— **直接做完**,用陈述句交代;'
|
||||
'② 若确实命中真门禁(不可逆破坏性操作 / 边界外六类)—— 写进 `dsh-feature-first §5.1` 结论骨架的'
|
||||
'「**需要你拍板**」一节,该节必须是**整条回复的最后一节**、**逐条编号**,且**每个候选写明优点 / 缺点**、**候选竖排成段**(A / B / C 各占一行,⛔ 不横排、不做成表格的列)(陈述句,不要用征询句);'
|
||||
'⚠️ 若某候选**只有优点或只有缺点** ⇒ **那不该问**,自己拍掉;'
|
||||
'③ 顺带按红线 **R11** 复核:这个改动有没有让项目某一维度**净变差**。'
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 会话预算(2026-09-15 用户选 A 案 · 提交 03c8363):到 **12 万 token / 80 次工具调用** ⇒ 提醒开新会话
|
||||
# ⚠️ 2026-09-16 更正:本行原写「~15 万 token / ~120 次工具调用」,与代码值(`BUDGET_TOKENS=120000` /
|
||||
# `BUDGET_TOOLS=80`)**不符** —— 提交 `03c8363` 的 message 里就写的是 `120000/80`,代码从未用过 15 万/120
|
||||
# ⇒ 判定为注释笔误,已按代码更正(避免后人据注释误判告警点)。
|
||||
# 数值本身由 AI 在实现层自定(属 §1「性能与资源调参」= 边界内);**方案(A 案四招)才是用户 09-15 拍板的**。
|
||||
# 为什么加进本脚本、而不新装一个 hook:`settings.json` 的 hooks 是**应用启动时快照**(新增条目要重启),
|
||||
# 而**脚本内容每次调用现读** ⇒ 改这里即刻生效。数据源 = 转录 `type=function_call` → `message.usage.input_tokens`
|
||||
# (最近一条 usage 即"当前上下文体量",精确,不靠估算)。
|
||||
# 依据(实测某会话):上下文 5.2 万 → 59.2 万;累计 input 1.93 亿 / output 51.9 万(**371:1**);
|
||||
# 其中 33 次缓存失效,每次都把 ~50 万 token **按全价**重算 ⇒ 会话越长,单次失效越贵。
|
||||
# ★ 2026-09-16 改为**分级 + 去重**(原为单一阈值 120000/80,超了之后每轮都报 ⇒ 变成"狼来了")
|
||||
# 设计要点:**同一级别只报一次** ⇒ 跨级才再提醒,既不麻木也不失警。
|
||||
BUDGET_TOKENS = 120000 # 一级:轻提示(保留原名,兼容既有引用)
|
||||
BUDGET_STRONG = 200000 # 二级:建议收口
|
||||
BUDGET_FORCE = 300000 # 三级:**强制收口**(先落盘、出接续包,再开新会话)
|
||||
BUDGET_TOOLS = 80
|
||||
ALERT_LEVEL_REL = os.path.join('.workbuddy', '.budget-alert-level')
|
||||
|
||||
# 为什么三级不是"自动开新会话"(2026-09-16 与用户讨论后定):
|
||||
# ① **技术上做不到** —— hook 只有 `additionalContext`(注入)与 `permissionDecision`(拦工具)两种输出,
|
||||
# 事件只有 SessionStart / PreToolUse / UserPromptSubmit / Stop,**没有"创建/切换会话"的能力**;
|
||||
# AI 自身也只能在会话内行动。⇒ "自动开"这一半无法实现。
|
||||
# ② **设计上不该做** —— 新会话 = 上下文清零 ⇒ **在途状态全丢**;而"该带走什么"只有 AI 判断得了
|
||||
# ⇒ 顺序必须是 **先收口(落盘 + 接续包)→ 再由用户开新会话**,反了就是"突然失忆"。
|
||||
# ③ 因此本级**不做"自动开"**,做**"强制收口"**:把状态固化成文档,让下一个会话能无损接上。
|
||||
LV_PREFIX = {
|
||||
1: '',
|
||||
2: '⚠️ 上下文已过 **20 万**,本轮收口后建议开新会话。\n'
|
||||
' ⚠️ 本轮若你**登记了自动接续(automation)/ 要开新会话** ⇒ **必须在给用户的回复里用陈述句说明**(别悄悄做掉)。\n',
|
||||
3: ('🔴 **已过 30 万 ⇒ 进入强制收口模式**(先落盘、再接续):\n'
|
||||
' ① 把在途状态写进 `.workbuddy/memory/`(今日日志 + 必要的 MEMORY.md 条目);\n'
|
||||
' ② 产出**接续包**:目标 / 已完成 / 在途 / 未完成 / 下一步 / 关键决定 / 回滚点'
|
||||
'(模板见工作区根 `会话接续规范_20260916.md §3.1.1`);\n'
|
||||
' ③ **把接续登记成一次性 automation**(调 `automation_update`,+2 分钟触发)'
|
||||
'—— 这是"自动接续"唯一可用的通道(钩子不能建会话、不能建自动化);\n'
|
||||
' prompt **照 `会话接续规范 §3.2.1` 模板**:⓪ 先跑 `state.py` + 接续包路径 + 开机四步 + 工具调用上限;\n'
|
||||
' ⛔ prompt 里**不许复制任务细节**("细节的唯一来源是交接单/接续包";实测:抄细节的 prompt 会挤掉"开机四步",'
|
||||
'那轮跑了 40 次调用 / 9.37 分);\n'
|
||||
' 🔴 **prompt 必须带「接续包 md5」**(`md5sum <接续包路径>`)—— 新会话开工前会重算校验,'
|
||||
'不符即停、报告「口径已更新,需重新接续」(2026-09-16 实测:登记 17:01 → 触发 17:03,'
|
||||
'而原会话一路工作到 18:21、17:2x–17:29 还在改判断 ⇒ 新旧两张皮);\n'
|
||||
' 🔴 **登记后若你还要改接续包 / 改关键判断 ⇒ 回来撤销或重登记**这条 automation ——'
|
||||
'⛔ 不重登记 = 下一棒按旧口径开工且无从知道;\n'
|
||||
' ④ ★**必做 · 告知用户**:在**给用户的最后一条回复里**(用陈述句,不是征询句)写明这次自动接续 —— '
|
||||
'例如「已登记自动接续:约 N 分钟后自动开新会话继续(**不需要你操作**);接续点 = `<文件>`;'
|
||||
'若想自己开,口令 = `<state.py 口令>`」。\n'
|
||||
' ⛔ **这不是可选项**:新建会话 / 新建自动化是**用户可感知的状态变更**'
|
||||
'(提问闸门 A 类原话:「AI 会不会悄悄改他的设置」)—— 悄悄做掉不告知,'
|
||||
'用户会在会话列表里凭空看见多出一个会话而不知何来。\n'
|
||||
' 📌 2026-09-16 用户实测反馈:「**他在最后一个回复结尾没说这个事,导致我不知道**」⇒ 本条即由此而来。\n'
|
||||
' ⑤ ③ 若做不成(工具不可用等)⇒ 兜底同样要告知:「请开新会话,接续点在 X」,**由用户开**。\n'
|
||||
' ⛔ 不要在本会话继续开新任务 —— 每多跑一轮,成本按当前水位线性放大。\n'),
|
||||
}
|
||||
|
||||
|
||||
def _alert_level(tokens, ncalls):
|
||||
"""0=未超 1=轻 2=建议收口 3=强制收口"""
|
||||
t = tokens or 0
|
||||
if t >= BUDGET_FORCE:
|
||||
return 3
|
||||
if t >= BUDGET_STRONG:
|
||||
return 2
|
||||
if t >= BUDGET_TOKENS:
|
||||
return 1
|
||||
if ncalls is not None and ncalls >= BUDGET_TOOLS:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _last_level(root, sid):
|
||||
"""读「本会话」已报到的级别。**按 sid 区分** —— 否则新会话会被上一会话的状态挡住 ⇒ **漏报**。"""
|
||||
try:
|
||||
with io.open(os.path.join(root, ALERT_LEVEL_REL), encoding='utf-8') as f:
|
||||
s = (f.read() or '').strip()
|
||||
ps = s.split('\t')
|
||||
if len(ps) == 2 and ps[0] == sid:
|
||||
return int(ps[1] or 0)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _set_level(root, sid, lv):
|
||||
try:
|
||||
p = os.path.join(root, ALERT_LEVEL_REL)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with io.open(p, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write('%s\t%d' % (sid, lv))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def session_budget(path):
|
||||
"""返回 (当前上下文 token, 工具调用累计次数);读不到返回 (None, None)。"""
|
||||
try:
|
||||
if os.path.getsize(path) > 64 * 1024 * 1024:
|
||||
return None, None, None
|
||||
except OSError:
|
||||
return None, None, None
|
||||
last_in, n_calls, prev_in = None, 0, None
|
||||
try:
|
||||
with io.open(path, encoding='utf-8', errors='replace') as f:
|
||||
for ln in f:
|
||||
if '"function_call"' not in ln:
|
||||
continue
|
||||
n_calls += 1
|
||||
if '"usage"' not in ln:
|
||||
continue
|
||||
try:
|
||||
o = json.loads(ln)
|
||||
except ValueError:
|
||||
continue
|
||||
if o.get('type') != 'function_call':
|
||||
continue
|
||||
u = ((o.get('message') or {}).get('usage')) or {}
|
||||
v = u.get('input_tokens')
|
||||
if v:
|
||||
prev_in, last_in = last_in, int(v)
|
||||
except OSError:
|
||||
return None, None, None
|
||||
return last_in, n_calls, prev_in
|
||||
|
||||
|
||||
def budget_note(tokens, ncalls, prev=None, root='', sid=''):
|
||||
"""分级提醒;**同一级别只注入一次**(跨级才再报)—— 治"超阈值后每轮都报"导致的麻木。
|
||||
未超阈值时,单轮增量异常(≥4 万 token)仍提醒。"""
|
||||
# ★ 分级 + 去重(2026-09-16):本级已报过 ⇒ 静默(**按会话 id 区分**,新会话重新计数)
|
||||
lv = _alert_level(tokens, ncalls)
|
||||
if lv and lv <= _last_level(root, sid):
|
||||
return ''
|
||||
if lv:
|
||||
_set_level(root, sid, lv)
|
||||
over_t = tokens is not None and tokens >= BUDGET_TOKENS
|
||||
over_n = ncalls is not None and ncalls >= BUDGET_TOOLS
|
||||
grew = (tokens is not None and prev is not None and tokens - prev >= 40000) if not (over_t or over_n) else False
|
||||
if not (over_t or over_n or grew):
|
||||
return ''
|
||||
which = ('上下文 %s token' % tokens) if over_t else (
|
||||
'工具调用 %s 次' % ncalls) if over_n else ('**上一轮新增 %s token**' % (tokens - prev))
|
||||
return (
|
||||
LV_PREFIX.get(lv, '') +
|
||||
'💰 【会话预算告警】本会话已到 **%s**(一级阈值 %s token / %s 次工具调用)。'
|
||||
'代价机制(2026-09-15 实测):对话历史是**追加式**的 —— 一次工具调用的输出(`function_call_result`)'
|
||||
'**永久留在历史里、每轮全量重发**(某会话 553 轮 × 平均 42 万 = **2.33 亿 input**,output 仅 0.35%%)。'
|
||||
'**处置:本轮收口后开新会话**(新会话起点约 5 万 ⇒ 每轮降到 1/12);'
|
||||
'并把该记的状态写进记忆 + 按 `dsh-change-workflow` 交付门禁落交接。'
|
||||
'⚠️ 压增长的三条硬纪律:**❶ 大输出先落盘、只读关键行**(`> /tmp/x.txt` 后 `sed -n`);'
|
||||
'**❷ 命令层限流**(`| head -30` / `| cut -c1-120` / `grep -c` 代替 `grep`);'
|
||||
'**❸ 让脚本内部聚合、只 print 摘要** —— ⛔ 禁 `cat` 大文件、无 `head` 的 `grep -r`、`ls -laR`。'
|
||||
% (which, BUDGET_TOKENS, BUDGET_TOOLS)
|
||||
)
|
||||
|
||||
|
||||
def guard_health(root):
|
||||
"""**自动发现并上报**:读 `bash-guard.log`,同一规则重复命中 ≥3 次 ⇒ 提示"可能误伤"。
|
||||
|
||||
为什么要有:门禁自己不会喊疼 —— 只有"写日志"没人看。让每轮都跑的脚本顺带体检,
|
||||
机制问题才能在**下一次用户发言时**浮出来(而不是等人发现"AI 怎么老做不成事")。
|
||||
"""
|
||||
try:
|
||||
p = os.path.join(root, '.workbuddy', 'bash-guard.log')
|
||||
rows = [l for l in io.open(p, encoding='utf-8').read().split('\n') if 'DENY|' in l][-40:]
|
||||
except OSError:
|
||||
return ''
|
||||
if len(rows) < 3:
|
||||
return ''
|
||||
cnt = {}
|
||||
for l in rows:
|
||||
try:
|
||||
k = l.split('DENY|')[1].split('|')[0].strip()
|
||||
except IndexError:
|
||||
continue
|
||||
cnt[k] = cnt.get(k, 0) + 1
|
||||
if not cnt:
|
||||
return ''
|
||||
why, n = max(cnt.items(), key=lambda kv: kv[1])
|
||||
if n < 3:
|
||||
return ''
|
||||
return ('⚠️【门禁自检】最近 %d 次 Bash 里有 **%d 次**因「%s」被拦(同一规则重复命中)⇒ 先怀疑**误伤**,'
|
||||
'不是谁的操作有问题。处置:① 换等价限流写法;② 若确认误伤 ⇒ 把 `off` 写进 '
|
||||
'`.workbuddy/bash-guard-mode`(或 env `DSH_OUTPUT_GUARD_OFF=1`),并**主动上报用户**'
|
||||
'(规则该不该收窄是人的决定)。' % (len(rows), n, why))
|
||||
|
||||
|
||||
def path_health(root):
|
||||
"""**自动发现并上报**:hooks 指向的脚本、文档库位置,是否还在。
|
||||
|
||||
来历(2026-09-15 实测事故):文档库被整目录搬到 `_中间产物_待清理/` ⇒ 宿主 hooks 仍指向旧位置 ⇒
|
||||
**锁闸门静默失效**;当时唯一线索是"`lock-hook.log` 今天 0 条 PreToolUse",而**没人会主动去数**。
|
||||
⇒ 让每轮都跑的脚本顺带体检,机制失效能在**下一次用户发言时**自己浮出来。
|
||||
|
||||
判据(任一命中即报):① **从 `settings.json` 现读** hooks 的 command,抽出其中的 `.py` 逐个 `os.path.exists`
|
||||
(配置里是权威指向 ⇒ 能发现"指向了不存在的文件");② 文档库在两处候选位置**都不存在**。
|
||||
取不到配置 ⇒ 跳过该项(**fail-open**,绝不因体检本身误报)。
|
||||
"""
|
||||
bad = []
|
||||
cfg = os.path.join(os.environ.get('WORKBUDDY_CONFIG_DIR')
|
||||
or os.path.join(os.path.expanduser('~'), '.workbuddy'), 'settings.json')
|
||||
seen = 0
|
||||
try:
|
||||
d = json.load(io.open(cfg, encoding='utf-8'))
|
||||
for ev, arr in (d.get('hooks') or {}).items():
|
||||
for blk in (arr or []):
|
||||
for h in ((blk or {}).get('hooks') or []):
|
||||
c = h.get('command') or ''
|
||||
if c:
|
||||
seen += 1
|
||||
for m in re.finditer(r'([A-Za-z]:[\\/][^"\']*?\.py)', c):
|
||||
p = m.group(1)
|
||||
if not os.path.exists(p):
|
||||
bad.append('hooks「%s」指向的 `%s` 不存在' % (ev, os.path.basename(p)))
|
||||
except Exception:
|
||||
return '' # 读不到配置 ⇒ fail-open,绝不因体检本身误报
|
||||
if seen == 0:
|
||||
bad.append('hooks 配置里**没有任何 command 条目** ⇒ 钩子可能被清空 / 被整段覆盖')
|
||||
if not bad:
|
||||
return ''
|
||||
uniq = []
|
||||
for x in bad:
|
||||
if x not in uniq:
|
||||
uniq.append(x)
|
||||
return ('🚨【路径自检】%s ⇒ **机制可能已静默失效**。处置:**立刻上报用户**,并核对 `settings.json` 的 '
|
||||
'hooks 路径与文档库当前位置(2026-09-15 同类事故:文档库被整目录搬走,锁闸门失效一整天无人察觉)。'
|
||||
% ';'.join(uniq[:3]))
|
||||
|
||||
|
||||
def mode_of(root):
|
||||
try:
|
||||
m = io.open(os.path.join(root or '.', '.workbuddy', 'stop-guard-mode'), encoding='utf-8').read()
|
||||
except OSError:
|
||||
m = ''
|
||||
return 'inject' if 'inject' in m else 'probe'
|
||||
|
||||
|
||||
def user_prompt_mode(payload):
|
||||
"""UserPromptSubmit:probe=只记日志;inject=命中则注入上下文(不阻断提示词)。"""
|
||||
tp = str(payload.get('transcript_path') or '')
|
||||
if not _in_scope(tp):
|
||||
return
|
||||
if os.environ.get('DSH_STOP_GUARD_OFF'):
|
||||
return
|
||||
root0 = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or ''
|
||||
if root0 and os.path.exists(os.path.join(root0, '.workbuddy', 'stop-guard.disabled')):
|
||||
return
|
||||
mode = mode_of(root0)
|
||||
text = transcribe_last_assistant(tp)
|
||||
tail = tail_lines(text, 1) if text else ''
|
||||
hit = bool(tail) and not RE_QUOTE.search(tail) and bool(RE_BAN.search(tail))
|
||||
toks, ncalls, prev = session_budget(tp)
|
||||
note = budget_note(toks, ncalls, prev, root0 or '.', str(payload.get('session_id') or ''))
|
||||
gh = guard_health(root0 or '.')
|
||||
ph = path_health(root0 or '.')
|
||||
log(root0 or '.', 'invoked(user-prompt)|mode=%s|上轮收尾=征询句:%s|上下文=%s tok(+%s)|工具=%s 次|预算告警=%s|门禁自检=%s|路径自检=%s|%s'
|
||||
% (mode, hit, toks, (toks - prev) if (toks and prev) else '-', ncalls, bool(note), bool(gh), bool(ph),
|
||||
(tail.replace('\n', ' ')[:60] if tail else '(取不到上一轮文本)')))
|
||||
# ⚠️ 2026-09-15 用户选 C:**取消**「会话预算」注入 —— 当时的症结是"每轮都报、太吵"。
|
||||
# ★ 2026-09-16 恢复**分级注入**:`budget_note` 已改为「分级 + 同会话跨级才报一次」(见其 docstring),
|
||||
# 噪音症结已消解;且用户当日提出「上下文超 30 万要不要自动收口」⇒
|
||||
# **AI 必须先知道水位才谈得上收口** ⇒ 恢复注入(一级静默无关、二级建议、三级强制收口)。
|
||||
if mode == 'inject' and (hit or gh or ph or note):
|
||||
ctx = ''
|
||||
for seg in (CONTEXT if hit else '', note, gh, ph):
|
||||
if seg:
|
||||
ctx += ('\n\n' + seg) if ctx else seg
|
||||
_emit({'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit',
|
||||
'additionalContext': ctx}})
|
||||
|
||||
|
||||
def main():
|
||||
raw = _read_stdin_text() # ⚠️ 必须走 buffer:`-E` 下 sys.stdin 是 cp936(见 _read_stdin_text 注释)
|
||||
payload = None
|
||||
if raw.strip():
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except ValueError:
|
||||
payload = None
|
||||
_entry_log(payload, len(raw)) # ⚠️ 先留痕,再判作用域(否则"没被调用"与"静默失配"分不开)
|
||||
if payload is None:
|
||||
return
|
||||
if (payload.get('hook_event_name') or '') == 'UserPromptSubmit': # 第二方案分派
|
||||
return user_prompt_mode(payload)
|
||||
tp = str(payload.get('transcript_path') or '')
|
||||
if not _in_scope(tp): # 作用域外 → 放行
|
||||
return
|
||||
if os.environ.get('DSH_STOP_GUARD_OFF'): # 急停(环境变量)→ 放行
|
||||
return
|
||||
root0 = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or ''
|
||||
if root0 and os.path.exists(os.path.join(root0, '.workbuddy', 'stop-guard.disabled')):
|
||||
return # 急停(闸刀文件)→ 放行
|
||||
if payload.get('stop_hook_active'): # 防死循环 → 放行
|
||||
return
|
||||
text = transcribe_last_assistant(tp)
|
||||
if not text:
|
||||
return
|
||||
sid = str(payload.get('session_id') or '')
|
||||
if os.environ.get('DSH_SG_DEBUG'): # 调试:每次调用都留痕(用于验证宿主是否真的调用本钩子)
|
||||
log(root0 or '.', 'invoked|scope=%s|tail_active=%s' % (_in_scope(tp), bool(payload.get('stop_hook_active'))))
|
||||
if _rate_limited(root0, sid, peek=True): # 只查不记账
|
||||
return
|
||||
if os.environ.get('DSH_SG_LOG_ALL', '1') != '0': # ★本工作区内**每次调用都留痕**(可判定"有没有被调用")
|
||||
log(root0 or '.', 'invoked|tail_active=%s' % bool(payload.get('stop_hook_active')))
|
||||
tail = tail_lines(text, 1) # 只看**最后一行**:命中面越窄,误报越少
|
||||
if RE_QUOTE.search(tail): # 复述/引用规则 → 不是收尾提问
|
||||
return
|
||||
m = RE_BAN.search(tail)
|
||||
if not m:
|
||||
return
|
||||
root = (os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or '')
|
||||
_rate_limited(root0, sid) # 命中才记账(同一会话 10 分钟最多拦 1 次)
|
||||
log(root if os.path.isdir(root) else '.', 'stop-dialog-guard 命中:%s | 收尾:%s'
|
||||
% (m.group(0), tail.replace('\n', ' ')[:80]))
|
||||
_emit({'continue': False, 'reason': REASON})
|
||||
|
||||
|
||||
self_refresh()
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
# ⚠️ 钩子绝不能因自身故障干扰会话 ⇒ 仍放行,但**必须留痕**(A18:静默失败是负债;
|
||||
# 2026-09-15 实证:本文件的 `except: pass` 曾把 `NameError: out is not defined` 藏住半小时)
|
||||
try:
|
||||
import traceback
|
||||
_r = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or '.'
|
||||
log(_r, 'EXCEPTION|%s' % traceback.format_exc().strip().split('\n')[-1][:120])
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,288 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# ⚙️ 本文件由 `sync-scoped-guards.py` **自动生成**,请勿手改(改动会在下次同步时被覆盖)。
|
||||
# 来源:D:\github\dsh_shenxian\dsh-server-docs\scripts\skill-load-guard.py
|
||||
# 生成:2026-09-22 06:15:18 指纹:a70a4b4c90cb2069
|
||||
# 作用域:dsh-ai1net-desktop ← 由源项目的单工作区版本机械变换而来,其余逻辑逐字保留
|
||||
|
||||
import os as _os
|
||||
|
||||
|
||||
def _scopes():
|
||||
"""作用域标记(可用 env DSH_GUARD_SCOPES 覆盖,逗号分隔)。"""
|
||||
raw = _os.environ.get('DSH_GUARD_SCOPES') or 'dsh-ai1net-desktop'
|
||||
return tuple(s.strip() for s in raw.split(',') if s.strip())
|
||||
|
||||
|
||||
_SCOPES = _scopes()
|
||||
|
||||
|
||||
def _in_scope(s):
|
||||
return any(x in str(s) for x in _SCOPES)
|
||||
|
||||
|
||||
def self_refresh():
|
||||
"""源脚本变更 ⇒ 就地把本副本重新生成一次(失败则保留旧版,绝不写坏)。
|
||||
|
||||
⚠️ 前置廉价判断:**只在本工作区的会话里才真跑** —— 本副本可能被挂在全局 hooks 上
|
||||
(对所有工作区都会被执行),其他工作区直接 return,不 spawn 子进程、不留痕。
|
||||
"""
|
||||
try:
|
||||
_root = _os.environ.get('CODEBUDDY_PROJECT_DIR') or _os.getcwd()
|
||||
if 'dsh-ai1net-desktop' not in str(_root):
|
||||
return False
|
||||
import subprocess
|
||||
import sys as _sys
|
||||
r = subprocess.run([_sys.executable, _os.path.join(_os.path.dirname(_os.path.abspath(__file__)),
|
||||
'sync-scoped-guards.py'), '--quiet'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
|
||||
return r.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ── 以下为源脚本正文 ────────────────────────────────────────────────────────
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""skill-load-guard.py —— 「用户点名了方法 ⇒ 强制加载技能」的 UserPromptSubmit 钩子
|
||||
|
||||
为什么需要它
|
||||
────────────
|
||||
2026-09-16 复盘会话 `ddea70b7`(「确认guest用户数据迁移到106服务器」):
|
||||
用户 U6 明确说「D1–D6 先做哪些 **按照你的规划执行**,中间有问题**参考决策方法**」,
|
||||
但该会话 265 次工具调用里 **`Skill` 计数 = 0** —— 那份方法论**从未进入上下文**。
|
||||
AI 仍按常驻判据把两个**非门禁**问题("106 旧控制面要不要停" / "D1–D6 先做哪些")上抛给用户,
|
||||
而用户随后 45 秒 / 58 秒内自己给出了答案(Q1 的答案还超出了 AI 给的选项空间)。
|
||||
|
||||
⇒ 与 `stop-dialog-guard.py` 同族问题:**规则写了,但没有任何机制保证它在正确的时机被取用**。
|
||||
(那个钩子处理"回复收尾时的征询句",本钩子处理"用户点名时的方法加载",两者互补。)
|
||||
|
||||
本钩子 = 覆盖"用户点名"这条面:**每次用户提交时**扫输入,命中点名关键词 ⇒
|
||||
通过 `additionalContext` 注入一条**紧邻用户消息**的强制指令
|
||||
(位置比静态规则文件显著得多 —— 长会话里 CODEBUDDY.md 的硬要求容易被"稀释")。
|
||||
|
||||
安全设计(都不许省)
|
||||
────────────────────
|
||||
1. **自作用域**:只在 cwd 落在本工作区(`aliyun-dsh-server`)时生效,其他项目一律放行。
|
||||
2. **绝不添乱**:任何异常 → 静默放行(exit 0);本钩子**从不拦用户**,只在命中时**追加一段提醒**。
|
||||
3. **防跑飞**:同一会话 300 秒内最多注入一次。
|
||||
4. **急停双闸**(无需卸载/重启):env `DSH_SKILL_GUARD_OFF=1`,或 `<工作区>/.workbuddy/skill-guard.disabled`。
|
||||
5. **低频自证日志**:命中才写一行到 `<工作区>/.workbuddy/skill-load-guard.log` —— 用来回答"到底有没有触发"。
|
||||
6. **显式 UTF-8**:读写都走 `buffer`,不依赖 `PYTHONUTF8`(`-E` 会屏蔽它 ⇒ cp936 ⇒ 含中文静默炸)。
|
||||
|
||||
退出码:始终 0;决策通过 stdout 的 JSON 表达。
|
||||
安装(`settings.json` 的 hooks 段 · 与既有钩子同族,**并列挂在同一个 UserPromptSubmit 的 hooks 数组里**):
|
||||
"UserPromptSubmit": [{ "hooks": [
|
||||
{ "type": "command", "command": "\"<python>\" \"<此脚本>\"", "timeout": 10 },
|
||||
{ "type": "command", "command": "\"<python>\" -S -E \"<stop-dialog-guard.py>\"", "timeout": 10 }
|
||||
]}]
|
||||
⚠️ hooks 是**应用启动时快照** ⇒ 装完必须**完全重启 WorkBuddy**(关窗 ≠ 退出)才加载。
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
# ── 作用域(2026-09-22 由单值扩为**多值 + env 可覆盖**)─────────────────────────
|
||||
# 起因:用户 2026-09-22 拍板「B」—— 让本钩子覆盖本机全部会话区,而不是只管 aliyun-dsh-server。
|
||||
# 形态刻意与 `dsh-ai1net-desktop/.workbuddy/guard/sync-scoped-guards.py` 生成的副本**同形**
|
||||
# (`_scopes()` + `_in_scope()`),这样两侧可互换、副本的机械变换规则不再需要改写本文件。
|
||||
# ⛔ 不写死绝对路径:只比工作区**目录名**,换机器 / 改盘符都不受影响。
|
||||
# ⚠️ env `DSH_GUARD_SCOPES`(逗号分隔)可覆盖;设为空串 ⇒ 只走默认表。
|
||||
_SCOPES_DEFAULT = (
|
||||
'aliyun-dsh-server', # 平台主工作区(本脚本所在的项目)
|
||||
'dsh-ai1net-desktop', # 桌面壳 / 开源导出线
|
||||
)
|
||||
_log_rel = os.path.join('.workbuddy', 'skill-load-guard.log')
|
||||
|
||||
|
||||
def _workdirs():
|
||||
"""作用域标记元组(env `DSH_GUARD_SCOPES` 优先,逗号分隔)。"""
|
||||
raw = os.environ.get('DSH_GUARD_SCOPES')
|
||||
if raw is None:
|
||||
return _SCOPES_DEFAULT
|
||||
return tuple(s.strip() for s in raw.split(',') if s.strip())
|
||||
|
||||
|
||||
def _in_scope(s):
|
||||
"""目录名匹配即算命中(与副本同形;比绝对路径稳,不受盘符/用户名影响)。"""
|
||||
return any(x in str(s or '') for x in _workdirs())
|
||||
|
||||
|
||||
LOG_REL = _log_rel
|
||||
STATE_REL = os.path.join('.workbuddy', '.skill-load-guard.state')
|
||||
DISABLE_REL = os.path.join('.workbuddy', 'skill-guard.disabled')
|
||||
COOLDOWN = 300 # 同会话注入冷却(秒)
|
||||
|
||||
# 用户「点名方法 / 点名规则」的词表 —— 命中任一即注入。
|
||||
# ── 分组 1:点名「决策方法」(2026-09-16 立,最稳的一条)─────────────────────
|
||||
# ── 分组 2:点名「通用作业规则」(2026-09-22 用户拍板 B 后新增)─────────────────
|
||||
# 目的:`agent-operating-rules`(跨工作区通用规则技能)过去**完全靠 description 匹配**,
|
||||
# 是所有技能里最容易被漏的一个 —— 加三条窄词给它一条机制通道。
|
||||
TRIGGERS = (
|
||||
# 分组 1 —— 决策方法族(→ 加载 `dsh-decision-method`)
|
||||
'决策方法',
|
||||
'自行决策', '自主决策', '自己决策', '自己拿主意',
|
||||
'别问我', '不要问我', '不用问我', '按你的规划', '按你的判断',
|
||||
'参考决策', '决策方法论',
|
||||
# 分组 2 —— 作业规则族(→ 加载 `agent-operating-rules`)
|
||||
'按规则来', '按规则做', '按作业规则', '遵守规则', '按规矩来',
|
||||
)
|
||||
|
||||
# 命中词 → 该加载哪个技能(2026-09-22 加:从"只会推 dsh-decision-method"扩为按命中词分流)
|
||||
_LOAD_RULES = ('按规则来', '按规则做', '按作业规则', '遵守规则', '按规矩来')
|
||||
|
||||
|
||||
def _read_stdin():
|
||||
try:
|
||||
raw = sys.stdin.buffer.read()
|
||||
except Exception:
|
||||
return {}
|
||||
if not raw:
|
||||
return {}
|
||||
for enc in ('utf-8', 'utf-8-sig', 'gbk'):
|
||||
try:
|
||||
return json.loads(raw.decode(enc))
|
||||
except Exception:
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
def _emit(obj):
|
||||
try:
|
||||
sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False).encode('utf-8'))
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _workdir(obj):
|
||||
for k in ('cwd', 'project_dir', 'workspace', 'projectDir'):
|
||||
v = obj.get(k)
|
||||
if isinstance(v, str) and v:
|
||||
return v
|
||||
for k in ('CODEBUDDY_PROJECT_DIR', 'WORKSPACE_FOLDER', 'PWD'):
|
||||
v = os.environ.get(k)
|
||||
if v:
|
||||
return v
|
||||
# 兜底:本脚本位于 <工作区>/dsh-server-docs/scripts/
|
||||
try:
|
||||
return os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _log(workdir, line):
|
||||
try:
|
||||
p = os.path.join(workdir, LOG_REL)
|
||||
with io.open(p, 'a', encoding='utf-8', newline='\n') as f:
|
||||
f.write('%s %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), line))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _cooling(workdir, sid):
|
||||
"""同一会话 COOLDOWN 秒内不重复注入。"""
|
||||
try:
|
||||
p = os.path.join(workdir, STATE_REL)
|
||||
now = time.time()
|
||||
last_sid, last_ts = '', 0.0
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with io.open(p, 'r', encoding='utf-8') as f:
|
||||
parts = f.read().strip().split('\t')
|
||||
last_sid, last_ts = parts[0], float(parts[1] or 0)
|
||||
except Exception:
|
||||
pass
|
||||
if last_sid == sid and (now - last_ts) < COOLDOWN:
|
||||
return True
|
||||
with io.open(p, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write('%s\t%.3f\n' % (sid, now))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if os.environ.get('DSH_SKILL_GUARD_OFF') == '1':
|
||||
return
|
||||
obj = _read_stdin()
|
||||
if not obj:
|
||||
return
|
||||
|
||||
workdir = _workdir(obj)
|
||||
if not workdir or not _in_scope(workdir):
|
||||
return # 自作用域:表内工作区之外的会话一律放行
|
||||
if os.path.exists(os.path.join(workdir, DISABLE_REL)):
|
||||
return
|
||||
|
||||
prompt = ''
|
||||
for k in ('prompt', 'user_prompt', 'userPrompt', 'message', 'text'):
|
||||
v = obj.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
prompt = v
|
||||
break
|
||||
|
||||
sid = str(obj.get('session_id') or obj.get('sessionId') or 'unknown')
|
||||
# ★ 入口即留痕(2026-09-16 加)—— 否则"没命中就没日志",永远无法回答
|
||||
# "它到底有没有被宿主调用"。与 stop-dialog-guard.py 同款设计;每次用户提交一行。
|
||||
_log(workdir, 'entry|event=UserPromptSubmit|in_scope=True|sid=%s|prompt_len=%d'
|
||||
% (sid[:8], len(prompt)))
|
||||
if not prompt:
|
||||
return
|
||||
|
||||
hits = [w for w in TRIGGERS if w in prompt]
|
||||
if not hits:
|
||||
return
|
||||
|
||||
if _cooling(workdir, sid):
|
||||
return
|
||||
|
||||
seg = re.sub(r'\s+', ' ', prompt)
|
||||
pos = min(seg.find(w) for w in hits)
|
||||
excerpt = seg[max(0, pos - 40): pos + 80].strip()
|
||||
|
||||
# ── 按命中词分流(2026-09-22):规则族与决策族给不同指令 ──────────────────
|
||||
hit_rules = [w for w in hits if w in _LOAD_RULES]
|
||||
if hit_rules:
|
||||
head = '检测到用户本轮**点名了作业规则**(命中:%s)。' % '、'.join(hit_rules)
|
||||
body = (
|
||||
'⛔ **不要凭记忆代替、也不要以"我已经知道规则"为由跳过**:\n'
|
||||
' **先调用 Skill 工具加载 `agent-operating-rules`**(通用作业规则 · 含"什么时候自决 / 什么时候上抛 / 回复排版 / 说话方式 / 不越界 / 多棒接力"),\n'
|
||||
' 然后才开始作答。若本轮涉及功能需求判类型 / 该不该上抛,**同时加载 `dsh-feature-first`**。\n\n'
|
||||
'为什么强制:该技能是**跨工作区通用**的(不绑定任何具体项目),'
|
||||
'但它与其他技能一样**靠 description 匹配按需加载 —— 没有机制保证它在任何工作区都被取用**'
|
||||
'(它的 description 里已写「当你在任何一个工作区开始任务…」,仍可能漏)。'
|
||||
'本条注入即补上这个缺口。\n'
|
||||
'(若判定本轮确实与作业规则无关,可在作答中一句话说明后继续 —— 但**不要静默跳过加载**。)'
|
||||
)
|
||||
else:
|
||||
head = '检测到用户本轮**点名了方法**(命中:%s)。' % '、'.join(hits)
|
||||
body = (
|
||||
'⛔ **不要凭记忆代替、也不要以"我已经知道判据"为由跳过**:\n'
|
||||
' **先调用 Skill 工具加载 `dsh-decision-method`**(决策方法论 · 含 §4.5 规则冲突裁决顺序 + 上抛前三问),\n'
|
||||
' 然后才开始作答。若本轮涉及功能需求判类型 / 该不该上抛,**同时加载 `dsh-feature-first`**。\n\n'
|
||||
'为什么强制:2026-09-16 实测(会话 ddea70b7)—— 用户点名「参考决策方法」后,'
|
||||
'AI 全程 `Skill` 调用 **0 次**,仍按旧判据把两个**非门禁**问题上抛给用户,'
|
||||
'用户 45 / 58 秒后自己给出了答案。**规则写在文件里 ≠ 会在正确的时机被取用。**\n'
|
||||
'(若判定本轮确实与决策无关,可在作答中一句话说明后继续 —— 但**不要静默跳过加载**。)'
|
||||
)
|
||||
|
||||
ctx = '【技能加载闸门 · 机制层强制】%s\n' '原文片段:「…%s…」\n\n' '%s' % (head, excerpt, body)
|
||||
|
||||
_emit({'hookSpecificOutput': {
|
||||
'hookEventName': 'UserPromptSubmit',
|
||||
'additionalContext': ctx,
|
||||
}})
|
||||
_log(workdir, 'HIT sid=%s hits=%s' % (sid[:8], ','.join(hits)))
|
||||
|
||||
|
||||
self_refresh()
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,643 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# ⚙️ 本文件由 `sync-scoped-guards.py` **自动生成**,请勿手改(改动会在下次同步时被覆盖)。
|
||||
# 来源:D:\github\dsh_shenxian\dsh-server-docs\scripts\stop-dialog-guard.py
|
||||
# 生成:2026-09-22 06:15:18 指纹:569fc0e5d7e82f06
|
||||
# 作用域:dsh-ai1net-desktop ← 由源项目的单工作区版本机械变换而来,其余逻辑逐字保留
|
||||
|
||||
import os as _os
|
||||
|
||||
|
||||
def _scopes():
|
||||
"""作用域标记(可用 env DSH_GUARD_SCOPES 覆盖,逗号分隔)。"""
|
||||
raw = _os.environ.get('DSH_GUARD_SCOPES') or 'dsh-ai1net-desktop'
|
||||
return tuple(s.strip() for s in raw.split(',') if s.strip())
|
||||
|
||||
|
||||
_SCOPES = _scopes()
|
||||
|
||||
|
||||
def _in_scope(s):
|
||||
return any(x in str(s) for x in _SCOPES)
|
||||
|
||||
|
||||
def self_refresh():
|
||||
"""源脚本变更 ⇒ 就地把本副本重新生成一次(失败则保留旧版,绝不写坏)。
|
||||
|
||||
⚠️ 前置廉价判断:**只在本工作区的会话里才真跑** —— 本副本可能被挂在全局 hooks 上
|
||||
(对所有工作区都会被执行),其他工作区直接 return,不 spawn 子进程、不留痕。
|
||||
"""
|
||||
try:
|
||||
_root = _os.environ.get('CODEBUDDY_PROJECT_DIR') or _os.getcwd()
|
||||
if 'dsh-ai1net-desktop' not in str(_root):
|
||||
return False
|
||||
import subprocess
|
||||
import sys as _sys
|
||||
r = subprocess.run([_sys.executable, _os.path.join(_os.path.dirname(_os.path.abspath(__file__)),
|
||||
'sync-scoped-guards.py'), '--quiet'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
|
||||
return r.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ── 以下为源脚本正文 ────────────────────────────────────────────────────────
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""stop-dialog-guard.py —— 「禁止用征询句收尾」的 Stop 钩子(WorkBuddy / CodeBuddy)
|
||||
|
||||
为什么需要它
|
||||
────────────
|
||||
2026-09-15 实测:本工作区日志里 `tool=AskUserQuestion` 调用数 = 09-12: 43 / 09-13: 3 / **09-14: 0 / 09-15: 0**
|
||||
⇒ 既有「提问闸门」(PreToolUse + matcher ^AskUserQuestion$)**拦的是几乎不走的工具面**,
|
||||
而真实的上抛("要我接着做吗 / 请确认 / 说一声即可")发生在**正文里** —— 没有任何机制覆盖。
|
||||
|
||||
本钩子 = 覆盖那条面:**每次回复结束时**读 transcript 的**最后一条 assistant 文本**,
|
||||
只扫**收尾段**(最后两行有效内容)里的征询句式;命中 → 返回 `{"continue": false, "reason": …}`
|
||||
让 Agent **继续一轮并自我纠正**(把该自己做的事做掉,或改写成「需要你拍板」一节)。
|
||||
|
||||
安全设计(都不许省)
|
||||
────────────────────
|
||||
1. **自作用域**:只在 `transcript_path` 落在本工作区(`aliyun-dsh-server`)时生效,其他项目一律放行。
|
||||
2. **防死循环**:输入里的 `stop_hook_active == true` 时**不再阻拦**(官方语义:本次停止已由 stop hook 触发过)。
|
||||
3. **绝不添乱**:任何异常 → 静默放行(exit 0)。判定只在**收尾段**做,避免正文引用规则时误伤。
|
||||
4. **性能**:只读转录**末尾 256 KB**(实测整库最大转录 31.9 MB、全文读 14 MB ≈ 832 ms ⇒ 不可接受),只看 stdin + 该文件。
|
||||
5. **防跑飞**:同一会话 600 秒内最多拦**一次**。
|
||||
6. **急停双闸**(无需卸载/重启):env `DSH_STOP_GUARD_OFF=1`,或新建 `<工作区>/.workbuddy/stop-guard.disabled`。
|
||||
7. **低频自证日志**:命中才写一行(`<工作区>/.workbuddy/stop-dialog-guard.log`),用来回答"到底有没有触发"。
|
||||
|
||||
退出码:始终 0;决策通过 stdout 的 JSON 表达。
|
||||
安装(settings.json 的 hooks 段 · 见档案 73 / 99):
|
||||
"Stop": [{ "hooks": [{ "type": "command",
|
||||
"command": "\"<python>\" \"<此脚本>\"", "timeout": 10 }] }]
|
||||
⚠️ hooks 是**应用启动时快照** ⇒ 装完必须**完全重启 WorkBuddy**;桌面版无 /hooks 面板,等效。
|
||||
⛔ **安装命令不要给本脚本加 `-E`(或任何会屏蔽 PYTHONUTF8 的 flag)**:本机环境本就设了
|
||||
`PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8`,而 `-E` 会把它们**全部忽略** ⇒ stdin 回退 **cp936** ⇒
|
||||
含中文的 payload 解码即炸。本脚本现已改为走 `buffer` 显式 UTF-8(读写都加固),但**不要靠加固兜底**,
|
||||
装的时候也别再引入新雷。(2026-09-15 实测:`-S -E` 曾让本钩子"看起来从未被调用"整整一天。)
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
# ── 作用域(2026-09-22 由单值扩为**多值 + env 可覆盖**)─────────────────────────
|
||||
# 起因:用户 2026-09-22 拍板「B」—— 让本钩子覆盖本机全部会话区,而不是只管 aliyun-dsh-server。
|
||||
# 形态刻意与 `dsh-ai1net-desktop/.workbuddy/guard/sync-scoped-guards.py` 生成的副本**同形**
|
||||
# (`_scopes()` + `_in_scope()`),这样两侧可互换、副本的机械变换规则不再需要改写本文件。
|
||||
# ⛔ 不写死绝对路径:只比工作区**目录名**,换机器 / 改盘符都不受影响。
|
||||
# ⚠️ env `DSH_GUARD_SCOPES`(逗号分隔)可覆盖;设为空串 ⇒ 只走默认表。
|
||||
_SCOPES_DEFAULT = (
|
||||
'aliyun-dsh-server', # 平台主工作区(本脚本所在的项目)
|
||||
'dsh-ai1net-desktop', # 桌面壳 / 开源导出线
|
||||
)
|
||||
|
||||
|
||||
def _scopes():
|
||||
"""作用域标记元组(env `DSH_GUARD_SCOPES` 优先,逗号分隔)。"""
|
||||
raw = os.environ.get('DSH_GUARD_SCOPES')
|
||||
if raw is None:
|
||||
return _SCOPES_DEFAULT
|
||||
return tuple(s.strip() for s in raw.split(',') if s.strip())
|
||||
|
||||
|
||||
def _in_scope(s):
|
||||
"""目录名匹配即算命中(与副本同形;比绝对路径稳,不受盘符/用户名影响)。"""
|
||||
return any(x in str(s or '') for x in _scopes())
|
||||
|
||||
|
||||
LOG_REL = os.path.join('.workbuddy', 'stop-dialog-guard.log')
|
||||
# 兜底工作区:用于"每次调用必留痕"(万一宿主没给 cwd、也没设 CODEBUDDY_PROJECT_DIR)
|
||||
# 本脚本位于 <工作区>/dsh-server-docs/scripts/ ⇒ 往上三级即工作区
|
||||
WS_FALLBACK = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# 只扫「收尾段」:出现这些就是"把该自己做的事甩回给用户"
|
||||
PATTERNS = [
|
||||
r'要我(再|接着|继续|现在)?[^。!?\n]{0,20}吗',
|
||||
r'要不要我[^。!?\n]{0,20}',
|
||||
r'是否要我[^。!?\n]{0,20}',
|
||||
r'需要我[^。!?\n]{0,20}吗',
|
||||
r'请确认[^。!?\n]{0,16}',
|
||||
r'要不要(继续|现在做|我来)[^。!?\n]{0,20}',
|
||||
r'是否(继续|需要我)[^。!?\n]{0,20}',
|
||||
r'说一声即可',
|
||||
r'你看(怎么办|怎么弄|要不要)',
|
||||
r'你(决定|拍板)一下',
|
||||
]
|
||||
RE_BAN = re.compile('|'.join(PATTERNS))
|
||||
|
||||
REASON = (
|
||||
'⛔ 收尾句是**征询句**,但按本平台规则(`CODEBUDDY.md §1`「回话前自检」+ `dsh-feature-first §5.3` 铁律 3)'
|
||||
'先重判三问:① 命中**真门禁**吗(不可逆破坏性操作 / 边界外六类)?没命中 → **删掉这句,自己做完,改成陈述句**("我接着做 X");'
|
||||
'② 是不是在把已经定下来的事再问一遍?是 → 删;③ 这件事用户有客观可判的优劣吗?没有 → 才允许问,且**一轮只问这一句**,'
|
||||
'并写进 `dsh-feature-first §5.1` 结论骨架的「**需要你拍板**」一节 —— 该节必须是**整条回复的最后一节**、'
|
||||
'且**逐条编号**(有序段落)(2026-09-15 用户明令:「放在最后,别隐藏在回复内容中间」「按照有序段落展示」),'
|
||||
'用**陈述句**列"各候选的**优点 / 缺点** + 我的倾向",不要用征询句。'
|
||||
'⚠️ 上抛前先过**取舍筛** —— 某个候选**只有优点 / 只有缺点** ⇒ **自己拍掉、不要问**;'
|
||||
'且候选**竖排成段**(A / B / C 各占一行),⛔ 不横排、不做成表格的列(2026-09-15 用户明令)。'
|
||||
)
|
||||
|
||||
|
||||
TAIL_BYTES = 262144
|
||||
MAX_BYTES = 4194304 # 扩窗上限 4 MB(防"巨行"时无限读) # 只读末尾 256 KB(实测:整库最大转录 31.9 MB;全文读 14 MB = 832 ms/轮,不可接受)
|
||||
|
||||
|
||||
def transcribe_last_assistant(path):
|
||||
"""返回最后一条 assistant 文本(**从尾部向后分块读**;读不到返回 '')。
|
||||
|
||||
⚠️ 为什么不是"一次读末尾 256 KB":一条 assistant 记录可能本身就 > 256 KB
|
||||
(长回复 / 被回显的工具输出),此时尾窗会切在 JSON 行中间 ⇒ `json.loads` 失败 ⇒ **静默漏判**。
|
||||
做法:从尾部按 TAIL_BYTES 递增扩窗(上限 MAX_BYTES),**直到至少解析出一条 assistant 记录**。
|
||||
常见情形(小消息)只花一次 256 KB 读,成本可忽略。
|
||||
"""
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
return ''
|
||||
with io.open(path, 'rb') as f:
|
||||
window = TAIL_BYTES
|
||||
while True:
|
||||
start = max(0, size - window)
|
||||
f.seek(start)
|
||||
raw = f.read().decode('utf-8', 'replace')
|
||||
lines = raw.split('\n')
|
||||
if start > 0:
|
||||
lines = lines[1:] # 丢弃被截断的首行
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if rec.get('type') != 'message' or rec.get('role') != 'assistant':
|
||||
continue
|
||||
chunks = [c['text'] for c in (rec.get('content') or [])
|
||||
if isinstance(c, dict) and isinstance(c.get('text'), str)]
|
||||
chunks += [c for c in (rec.get('content') or []) if isinstance(c, str)]
|
||||
if chunks:
|
||||
return '\n'.join(chunks)
|
||||
if start == 0 or window >= MAX_BYTES:
|
||||
# 放行,但**留痕**(A18:静默失败是负债)——可能是一条 >MAX_BYTES 的巨型记录
|
||||
try:
|
||||
os.environ.setdefault('_DSH_SG_MISS', '1')
|
||||
r0 = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or ''
|
||||
if r0:
|
||||
log(r0, '未能解析(窗口 %d 字节仍无 assistant 记录)' % window)
|
||||
except Exception:
|
||||
pass
|
||||
return ''
|
||||
window = min(window * 4, MAX_BYTES)
|
||||
|
||||
|
||||
def tail_lines(text, n=2):
|
||||
out = [l.strip() for l in text.strip().split('\n') if l.strip()]
|
||||
return '\n'.join(out[-n:])
|
||||
|
||||
|
||||
# 转述/引用豁免:收尾行里带引号或"引用/规则/写着/禁"等词 ⇒ 是在复述规则,不是在问用户
|
||||
RE_QUOTE = re.compile(r'[「」“”"\']|引用|规则|写着|禁')
|
||||
|
||||
|
||||
RATE_WINDOW = 600 # 秒;同一会话两次「阻止停止」的最小间隔
|
||||
|
||||
|
||||
def _rate_limited(root, sid, peek=False):
|
||||
"""同一会话 RATE_WINDOW 秒内已拦过 ⇒ 本次直接放行(防连续多轮被拦)。"""
|
||||
if not root or not sid:
|
||||
return False
|
||||
p = os.path.join(root, '.workbuddy', 'cache', 'stop-guard-fires.json')
|
||||
try:
|
||||
d = json.loads(io.open(p, encoding='utf-8').read()) if os.path.exists(p) else {}
|
||||
except Exception:
|
||||
d = {}
|
||||
now = time.time()
|
||||
if now - float(d.get(sid, 0) or 0) < RATE_WINDOW:
|
||||
return True
|
||||
d = {k: v for k, v in d.items() if now - float(v or 0) < 86400} # 只留 1 天
|
||||
d[sid] = now
|
||||
try:
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
io.open(p, 'w', encoding='utf-8', newline='\n').write(json.dumps(d))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def log(root, detail):
|
||||
try:
|
||||
p = os.path.join(root, LOG_REL)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with io.open(p, 'a', encoding='utf-8') as f:
|
||||
f.write('%s\t%s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), detail))
|
||||
lines = io.open(p, encoding='utf-8').read().split('\n') # 上限 300 行,超出截半(防膨胀)
|
||||
if len(lines) > 300:
|
||||
io.open(p, 'w', encoding='utf-8', newline='\n').write('\n'.join(lines[-150:]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _entry_log(payload, raw_len):
|
||||
"""⚠️ **每次被调用必留痕**(含"payload 解析失败 / 未进作用域 / 被急停"三种静默情形)。
|
||||
|
||||
2026-09-15 教训:原实现只在**通过全部守卫之后**才写日志 ⇒ 日志缺失时**无法区分**
|
||||
「宿主根本没调用」与「调用了但被静默 return」—— 而这两者的处置**完全相反**
|
||||
(前者要卸载、后者要放宽作用域判据)。凡"要判有没有被调用"的探针,必须**入口即留痕**。
|
||||
"""
|
||||
try:
|
||||
p = payload if isinstance(payload, dict) else {}
|
||||
tp = str(p.get('transcript_path') or '')
|
||||
root = (os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE')
|
||||
or p.get('cwd') or WS_FALLBACK)
|
||||
log(str(root), 'entry|event=%s|cwd=%s|in_scope=%s|tp=%s|keys=%s|stdin_len=%s'
|
||||
% (p.get('hook_event_name') or '(parse-fail)', p.get('cwd') or '-',
|
||||
_in_scope(tp), (tp[-80:] if tp else '-'),
|
||||
(','.join(sorted(p.keys()))[:120] or '-'), raw_len))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _read_stdin_text():
|
||||
"""**显式按 UTF-8 读 stdin** —— 不要用 `sys.stdin.read()`。
|
||||
|
||||
⚠️ 2026-09-15 实测定位:本脚本的安装形态是 `python -S -E <脚本>`,而 **`-E` 会忽略
|
||||
`PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8`** ⇒ `sys.stdin.encoding` 回退成 **cp936**;
|
||||
钩子 payload 里**必然含中文**(用户的提示词)⇒ 文本模式读取抛
|
||||
`UnicodeDecodeError: 'gbk' codec can't decode byte 0x80` ⇒ **钩子静默不生效、日志为空**,
|
||||
表象却是"宿主好像没调用钩子"(实为本地炸在解码上,白排查一轮)。
|
||||
读 `buffer` 即与 flag / locale 完全无关。
|
||||
"""
|
||||
try:
|
||||
return sys.stdin.buffer.read().decode('utf-8', 'replace')
|
||||
except Exception:
|
||||
try:
|
||||
return sys.stdin.read()
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _emit(obj):
|
||||
"""**显式按 UTF-8 写 stdout**(同理:cp936 下 `ensure_ascii=False` 的中文 / `⛔` 会 UnicodeEncodeError)。"""
|
||||
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
|
||||
try:
|
||||
sys.stdout.buffer.write(data)
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception: # 极端兜底:退回文本写(可能丢非 GBK 字符,但不至于静默不输出)
|
||||
try:
|
||||
sys.stdout.write(data.decode('utf-8', 'replace'))
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 第二方案:`UserPromptSubmit`(2026-09-15 加)
|
||||
# 背景:本版 WorkBuddy **不调用 `Stop` 钩子**(实测:留痕已开、探针句已验证会命中、日志仍空)⇒ 改用
|
||||
# `UserPromptSubmit`(输入同样带 `transcript_path`,且能通过 `additionalContext` 注入上下文)。
|
||||
# 两级模式(**改一个文本文件即可切换,无需重启** —— 脚本内容每次调用现读):
|
||||
# probe :只写日志(零风险、可判定"有没有被调用")
|
||||
# inject :若**上一轮回复的收尾是征询句** ⇒ 注入一段上下文,让下一轮自我纠正
|
||||
# 模式文件:<工作区>/.workbuddy/stop-guard-mode (内容含 "inject" 即切到 inject,否则 probe)
|
||||
CONTEXT = (
|
||||
'⛔ 【上一轮收尾自检】你上一条回复的**最后一行是征询句**("要我…吗 / 要不要我 / 请确认 / 说一声即可"类),'
|
||||
'这属于本平台**被禁的形态**(`CODEBUDDY.md §1`「回话前自检」)。本轮的处置:'
|
||||
'① 若那件事本来就该你自己拍 —— **直接做完**,用陈述句交代;'
|
||||
'② 若确实命中真门禁(不可逆破坏性操作 / 边界外六类)—— 写进 `dsh-feature-first §5.1` 结论骨架的'
|
||||
'「**需要你拍板**」一节,该节必须是**整条回复的最后一节**、**逐条编号**,且**每个候选写明优点 / 缺点**、**候选竖排成段**(A / B / C 各占一行,⛔ 不横排、不做成表格的列)(陈述句,不要用征询句);'
|
||||
'⚠️ 若某候选**只有优点或只有缺点** ⇒ **那不该问**,自己拍掉;'
|
||||
'③ 顺带按红线 **R11** 复核:这个改动有没有让项目某一维度**净变差**。'
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 会话预算(2026-09-15 用户选 A 案 · 提交 03c8363):到 **12 万 token / 80 次工具调用** ⇒ 提醒开新会话
|
||||
# ⚠️ 2026-09-16 更正:本行原写「~15 万 token / ~120 次工具调用」,与代码值(`BUDGET_TOKENS=120000` /
|
||||
# `BUDGET_TOOLS=80`)**不符** —— 提交 `03c8363` 的 message 里就写的是 `120000/80`,代码从未用过 15 万/120
|
||||
# ⇒ 判定为注释笔误,已按代码更正(避免后人据注释误判告警点)。
|
||||
# 数值本身由 AI 在实现层自定(属 §1「性能与资源调参」= 边界内);**方案(A 案四招)才是用户 09-15 拍板的**。
|
||||
# 为什么加进本脚本、而不新装一个 hook:`settings.json` 的 hooks 是**应用启动时快照**(新增条目要重启),
|
||||
# 而**脚本内容每次调用现读** ⇒ 改这里即刻生效。数据源 = 转录 `type=function_call` → `message.usage.input_tokens`
|
||||
# (最近一条 usage 即"当前上下文体量",精确,不靠估算)。
|
||||
# 依据(实测某会话):上下文 5.2 万 → 59.2 万;累计 input 1.93 亿 / output 51.9 万(**371:1**);
|
||||
# 其中 33 次缓存失效,每次都把 ~50 万 token **按全价**重算 ⇒ 会话越长,单次失效越贵。
|
||||
# ★ 2026-09-16 改为**分级 + 去重**(原为单一阈值 120000/80,超了之后每轮都报 ⇒ 变成"狼来了")
|
||||
# 设计要点:**同一级别只报一次** ⇒ 跨级才再提醒,既不麻木也不失警。
|
||||
BUDGET_TOKENS = 120000 # 一级:轻提示(保留原名,兼容既有引用)
|
||||
BUDGET_STRONG = 200000 # 二级:建议收口
|
||||
BUDGET_FORCE = 300000 # 三级:**强制收口**(先落盘、出接续包,再开新会话)
|
||||
BUDGET_TOOLS = 80
|
||||
ALERT_LEVEL_REL = os.path.join('.workbuddy', '.budget-alert-level')
|
||||
|
||||
# 为什么三级不是"自动开新会话"(2026-09-16 与用户讨论后定):
|
||||
# ① **技术上做不到** —— hook 只有 `additionalContext`(注入)与 `permissionDecision`(拦工具)两种输出,
|
||||
# 事件只有 SessionStart / PreToolUse / UserPromptSubmit / Stop,**没有"创建/切换会话"的能力**;
|
||||
# AI 自身也只能在会话内行动。⇒ "自动开"这一半无法实现。
|
||||
# ② **设计上不该做** —— 新会话 = 上下文清零 ⇒ **在途状态全丢**;而"该带走什么"只有 AI 判断得了
|
||||
# ⇒ 顺序必须是 **先收口(落盘 + 接续包)→ 再由用户开新会话**,反了就是"突然失忆"。
|
||||
# ③ 因此本级**不做"自动开"**,做**"强制收口"**:把状态固化成文档,让下一个会话能无损接上。
|
||||
LV_PREFIX = {
|
||||
1: '',
|
||||
2: '⚠️ 上下文已过 **20 万**,本轮收口后建议开新会话。\n'
|
||||
' ⚠️ 本轮若你**登记了自动接续(automation)/ 要开新会话** ⇒ **必须在给用户的回复里用陈述句说明**(别悄悄做掉)。\n',
|
||||
3: ('🔴 **已过 30 万 ⇒ 进入强制收口模式**(先落盘、再接续):\n'
|
||||
' ① 把在途状态写进 `.workbuddy/memory/`(今日日志 + 必要的 MEMORY.md 条目);\n'
|
||||
' ② 产出**接续包**:目标 / 已完成 / 在途 / 未完成 / 下一步 / 关键决定 / 回滚点'
|
||||
'(模板见工作区根 `会话接续规范_20260916.md §3.1.1`);\n'
|
||||
' ③ **把接续登记成一次性 automation**(调 `automation_update`,+2 分钟触发)'
|
||||
'—— 这是"自动接续"唯一可用的通道(钩子不能建会话、不能建自动化);\n'
|
||||
' prompt **照 `会话接续规范 §3.2.1` 模板**:⓪ 先跑 `state.py` + 接续包路径 + 开机四步 + 工具调用上限;\n'
|
||||
' ⛔ prompt 里**不许复制任务细节**("细节的唯一来源是交接单/接续包";实测:抄细节的 prompt 会挤掉"开机四步",'
|
||||
'那轮跑了 40 次调用 / 9.37 分);\n'
|
||||
' 🔴 **prompt 必须带「接续包 md5」**(`md5sum <接续包路径>`)—— 新会话开工前会重算校验,'
|
||||
'不符即停、报告「口径已更新,需重新接续」(2026-09-16 实测:登记 17:01 → 触发 17:03,'
|
||||
'而原会话一路工作到 18:21、17:2x–17:29 还在改判断 ⇒ 新旧两张皮);\n'
|
||||
' 🔴 **登记后若你还要改接续包 / 改关键判断 ⇒ 回来撤销或重登记**这条 automation ——'
|
||||
'⛔ 不重登记 = 下一棒按旧口径开工且无从知道;\n'
|
||||
' ④ ★**必做 · 告知用户**:在**给用户的最后一条回复里**(用陈述句,不是征询句)写明这次自动接续 —— '
|
||||
'例如「已登记自动接续:约 N 分钟后自动开新会话继续(**不需要你操作**);接续点 = `<文件>`;'
|
||||
'若想自己开,口令 = `<state.py 口令>`」。\n'
|
||||
' ⛔ **这不是可选项**:新建会话 / 新建自动化是**用户可感知的状态变更**'
|
||||
'(提问闸门 A 类原话:「AI 会不会悄悄改他的设置」)—— 悄悄做掉不告知,'
|
||||
'用户会在会话列表里凭空看见多出一个会话而不知何来。\n'
|
||||
' 📌 2026-09-16 用户实测反馈:「**他在最后一个回复结尾没说这个事,导致我不知道**」⇒ 本条即由此而来。\n'
|
||||
' ⑤ ③ 若做不成(工具不可用等)⇒ 兜底同样要告知:「请开新会话,接续点在 X」,**由用户开**。\n'
|
||||
' ⛔ 不要在本会话继续开新任务 —— 每多跑一轮,成本按当前水位线性放大。\n'),
|
||||
}
|
||||
|
||||
|
||||
def _alert_level(tokens, ncalls):
|
||||
"""0=未超 1=轻 2=建议收口 3=强制收口"""
|
||||
t = tokens or 0
|
||||
if t >= BUDGET_FORCE:
|
||||
return 3
|
||||
if t >= BUDGET_STRONG:
|
||||
return 2
|
||||
if t >= BUDGET_TOKENS:
|
||||
return 1
|
||||
if ncalls is not None and ncalls >= BUDGET_TOOLS:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _last_level(root, sid):
|
||||
"""读「本会话」已报到的级别。**按 sid 区分** —— 否则新会话会被上一会话的状态挡住 ⇒ **漏报**。"""
|
||||
try:
|
||||
with io.open(os.path.join(root, ALERT_LEVEL_REL), encoding='utf-8') as f:
|
||||
s = (f.read() or '').strip()
|
||||
ps = s.split('\t')
|
||||
if len(ps) == 2 and ps[0] == sid:
|
||||
return int(ps[1] or 0)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _set_level(root, sid, lv):
|
||||
try:
|
||||
p = os.path.join(root, ALERT_LEVEL_REL)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with io.open(p, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write('%s\t%d' % (sid, lv))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def session_budget(path):
|
||||
"""返回 (当前上下文 token, 工具调用累计次数);读不到返回 (None, None)。"""
|
||||
try:
|
||||
if os.path.getsize(path) > 64 * 1024 * 1024:
|
||||
return None, None
|
||||
except OSError:
|
||||
return None, None
|
||||
last_in, n_calls, prev_in = None, 0, None
|
||||
try:
|
||||
with io.open(path, encoding='utf-8', errors='replace') as f:
|
||||
for ln in f:
|
||||
if '"function_call"' not in ln:
|
||||
continue
|
||||
n_calls += 1
|
||||
if '"usage"' not in ln:
|
||||
continue
|
||||
try:
|
||||
o = json.loads(ln)
|
||||
except ValueError:
|
||||
continue
|
||||
if o.get('type') != 'function_call':
|
||||
continue
|
||||
u = ((o.get('message') or {}).get('usage')) or {}
|
||||
v = u.get('input_tokens')
|
||||
if v:
|
||||
prev_in, last_in = last_in, int(v)
|
||||
except OSError:
|
||||
return None, None, None
|
||||
return last_in, n_calls, prev_in
|
||||
|
||||
|
||||
def budget_note(tokens, ncalls, prev=None, root='', sid=''):
|
||||
"""分级提醒;**同一级别只注入一次**(跨级才再报)—— 治"超阈值后每轮都报"导致的麻木。
|
||||
未超阈值时,单轮增量异常(≥4 万 token)仍提醒。"""
|
||||
# ★ 分级 + 去重(2026-09-16):本级已报过 ⇒ 静默(**按会话 id 区分**,新会话重新计数)
|
||||
lv = _alert_level(tokens, ncalls)
|
||||
if lv and lv <= _last_level(root, sid):
|
||||
return ''
|
||||
if lv:
|
||||
_set_level(root, sid, lv)
|
||||
over_t = tokens is not None and tokens >= BUDGET_TOKENS
|
||||
over_n = ncalls is not None and ncalls >= BUDGET_TOOLS
|
||||
grew = (tokens is not None and prev is not None and tokens - prev >= 40000) if not (over_t or over_n) else False
|
||||
if not (over_t or over_n or grew):
|
||||
return ''
|
||||
which = ('上下文 %s token' % tokens) if over_t else (
|
||||
'工具调用 %s 次' % ncalls) if over_n else ('**上一轮新增 %s token**' % (tokens - prev))
|
||||
return (
|
||||
LV_PREFIX.get(lv, '') +
|
||||
'💰 【会话预算告警】本会话已到 **%s**(一级阈值 %s token / %s 次工具调用)。'
|
||||
'代价机制(2026-09-15 实测):对话历史是**追加式**的 —— 一次工具调用的输出(`function_call_result`)'
|
||||
'**永久留在历史里、每轮全量重发**(某会话 553 轮 × 平均 42 万 = **2.33 亿 input**,output 仅 0.35%%)。'
|
||||
'**处置:本轮收口后开新会话**(新会话起点约 5 万 ⇒ 每轮降到 1/12);'
|
||||
'并把该记的状态写进记忆 + 按 `dsh-change-workflow` 交付门禁落交接。'
|
||||
'⚠️ 压增长的三条硬纪律:**❶ 大输出先落盘、只读关键行**(`> /tmp/x.txt` 后 `sed -n`);'
|
||||
'**❷ 命令层限流**(`| head -30` / `| cut -c1-120` / `grep -c` 代替 `grep`);'
|
||||
'**❸ 让脚本内部聚合、只 print 摘要** —— ⛔ 禁 `cat` 大文件、无 `head` 的 `grep -r`、`ls -laR`。'
|
||||
% (which, BUDGET_TOKENS, BUDGET_TOOLS)
|
||||
)
|
||||
|
||||
|
||||
def guard_health(root):
|
||||
"""**自动发现并上报**:读 `bash-guard.log`,同一规则重复命中 ≥3 次 ⇒ 提示"可能误伤"。
|
||||
|
||||
为什么要有:门禁自己不会喊疼 —— 只有"写日志"没人看。让每轮都跑的脚本顺带体检,
|
||||
机制问题才能在**下一次用户发言时**浮出来(而不是等人发现"AI 怎么老做不成事")。
|
||||
"""
|
||||
try:
|
||||
p = os.path.join(root, '.workbuddy', 'bash-guard.log')
|
||||
rows = [l for l in io.open(p, encoding='utf-8').read().split('\n') if 'DENY|' in l][-40:]
|
||||
except OSError:
|
||||
return ''
|
||||
if len(rows) < 3:
|
||||
return ''
|
||||
cnt = {}
|
||||
for l in rows:
|
||||
try:
|
||||
k = l.split('DENY|')[1].split('|')[0].strip()
|
||||
except IndexError:
|
||||
continue
|
||||
cnt[k] = cnt.get(k, 0) + 1
|
||||
if not cnt:
|
||||
return ''
|
||||
why, n = max(cnt.items(), key=lambda kv: kv[1])
|
||||
if n < 3:
|
||||
return ''
|
||||
return ('⚠️【门禁自检】最近 %d 次 Bash 里有 **%d 次**因「%s」被拦(同一规则重复命中)⇒ 先怀疑**误伤**,'
|
||||
'不是谁的操作有问题。处置:① 换等价限流写法;② 若确认误伤 ⇒ 把 `off` 写进 '
|
||||
'`.workbuddy/bash-guard-mode`(或 env `DSH_OUTPUT_GUARD_OFF=1`),并**主动上报用户**'
|
||||
'(规则该不该收窄是人的决定)。' % (len(rows), n, why))
|
||||
|
||||
|
||||
def path_health(root):
|
||||
"""**自动发现并上报**:hooks 指向的脚本、文档库位置,是否还在。
|
||||
|
||||
来历(2026-09-15 实测事故):文档库被整目录搬到 `_中间产物_待清理/` ⇒ 宿主 hooks 仍指向旧位置 ⇒
|
||||
**锁闸门静默失效**;当时唯一线索是"`lock-hook.log` 今天 0 条 PreToolUse",而**没人会主动去数**。
|
||||
⇒ 让每轮都跑的脚本顺带体检,机制失效能在**下一次用户发言时**自己浮出来。
|
||||
|
||||
判据(任一命中即报):① **从 `settings.json` 现读** hooks 的 command,抽出其中的 `.py` 逐个 `os.path.exists`
|
||||
(配置里是权威指向 ⇒ 能发现"指向了不存在的文件");② 文档库在两处候选位置**都不存在**。
|
||||
取不到配置 ⇒ 跳过该项(**fail-open**,绝不因体检本身误报)。
|
||||
"""
|
||||
bad = []
|
||||
cfg = os.path.join(os.environ.get('WORKBUDDY_CONFIG_DIR')
|
||||
or os.path.join(os.path.expanduser('~'), '.workbuddy'), 'settings.json')
|
||||
seen = 0
|
||||
try:
|
||||
d = json.load(io.open(cfg, encoding='utf-8'))
|
||||
for ev, arr in (d.get('hooks') or {}).items():
|
||||
for blk in (arr or []):
|
||||
for h in ((blk or {}).get('hooks') or []):
|
||||
c = h.get('command') or ''
|
||||
if c:
|
||||
seen += 1
|
||||
for m in re.finditer(r'([A-Za-z]:[\\/][^"\']*?\.py)', c):
|
||||
p = m.group(1)
|
||||
if not os.path.exists(p):
|
||||
bad.append('hooks「%s」指向的 `%s` 不存在' % (ev, os.path.basename(p)))
|
||||
except Exception:
|
||||
return '' # 读不到配置 ⇒ fail-open,绝不因体检本身误报
|
||||
if seen == 0:
|
||||
bad.append('hooks 配置里**没有任何 command 条目** ⇒ 钩子可能被清空 / 被整段覆盖')
|
||||
if not bad:
|
||||
return ''
|
||||
uniq = []
|
||||
for x in bad:
|
||||
if x not in uniq:
|
||||
uniq.append(x)
|
||||
return ('🚨【路径自检】%s ⇒ **机制可能已静默失效**。处置:**立刻上报用户**,并核对 `settings.json` 的 '
|
||||
'hooks 路径与文档库当前位置(2026-09-15 同类事故:文档库被整目录搬走,锁闸门失效一整天无人察觉)。'
|
||||
% ';'.join(uniq[:3]))
|
||||
|
||||
|
||||
def mode_of(root):
|
||||
try:
|
||||
m = io.open(os.path.join(root or '.', '.workbuddy', 'stop-guard-mode'), encoding='utf-8').read()
|
||||
except OSError:
|
||||
m = ''
|
||||
return 'inject' if 'inject' in m else 'probe'
|
||||
|
||||
|
||||
def user_prompt_mode(payload):
|
||||
"""UserPromptSubmit:probe=只记日志;inject=命中则注入上下文(不阻断提示词)。"""
|
||||
tp = str(payload.get('transcript_path') or '')
|
||||
if not _in_scope(tp):
|
||||
return
|
||||
if os.environ.get('DSH_STOP_GUARD_OFF'):
|
||||
return
|
||||
root0 = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or ''
|
||||
if root0 and os.path.exists(os.path.join(root0, '.workbuddy', 'stop-guard.disabled')):
|
||||
return
|
||||
mode = mode_of(root0)
|
||||
text = transcribe_last_assistant(tp)
|
||||
tail = tail_lines(text, 1) if text else ''
|
||||
hit = bool(tail) and not RE_QUOTE.search(tail) and bool(RE_BAN.search(tail))
|
||||
toks, ncalls, prev = session_budget(tp)
|
||||
note = budget_note(toks, ncalls, prev, root0 or '.', str(payload.get('session_id') or ''))
|
||||
gh = guard_health(root0 or '.')
|
||||
ph = path_health(root0 or '.')
|
||||
log(root0 or '.', 'invoked(user-prompt)|mode=%s|上轮收尾=征询句:%s|上下文=%s tok(+%s)|工具=%s 次|预算告警=%s|门禁自检=%s|路径自检=%s|%s'
|
||||
% (mode, hit, toks, (toks - prev) if (toks and prev) else '-', ncalls, bool(note), bool(gh), bool(ph),
|
||||
(tail.replace('\n', ' ')[:60] if tail else '(取不到上一轮文本)')))
|
||||
# ⚠️ 2026-09-15 用户选 C:**取消**「会话预算」注入 —— 当时的症结是"每轮都报、太吵"。
|
||||
# ★ 2026-09-16 恢复**分级注入**:`budget_note` 已改为「分级 + 同会话跨级才报一次」(见其 docstring),
|
||||
# 噪音症结已消解;且用户当日提出「上下文超 30 万要不要自动收口」⇒
|
||||
# **AI 必须先知道水位才谈得上收口** ⇒ 恢复注入(一级静默无关、二级建议、三级强制收口)。
|
||||
if mode == 'inject' and (hit or gh or ph or note):
|
||||
ctx = ''
|
||||
for seg in (CONTEXT if hit else '', note, gh, ph):
|
||||
if seg:
|
||||
ctx += ('\n\n' + seg) if ctx else seg
|
||||
_emit({'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit',
|
||||
'additionalContext': ctx}})
|
||||
|
||||
|
||||
def main():
|
||||
raw = _read_stdin_text() # ⚠️ 必须走 buffer:`-E` 下 sys.stdin 是 cp936(见 _read_stdin_text 注释)
|
||||
payload = None
|
||||
if raw.strip():
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except ValueError:
|
||||
payload = None
|
||||
_entry_log(payload, len(raw)) # ⚠️ 先留痕,再判作用域(否则"没被调用"与"静默失配"分不开)
|
||||
if payload is None:
|
||||
return
|
||||
if (payload.get('hook_event_name') or '') == 'UserPromptSubmit': # 第二方案分派
|
||||
return user_prompt_mode(payload)
|
||||
tp = str(payload.get('transcript_path') or '')
|
||||
if not _in_scope(tp): # 作用域外 → 放行
|
||||
return
|
||||
if os.environ.get('DSH_STOP_GUARD_OFF'): # 急停(环境变量)→ 放行
|
||||
return
|
||||
root0 = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or ''
|
||||
if root0 and os.path.exists(os.path.join(root0, '.workbuddy', 'stop-guard.disabled')):
|
||||
return # 急停(闸刀文件)→ 放行
|
||||
if payload.get('stop_hook_active'): # 防死循环 → 放行
|
||||
return
|
||||
text = transcribe_last_assistant(tp)
|
||||
if not text:
|
||||
return
|
||||
sid = str(payload.get('session_id') or '')
|
||||
if os.environ.get('DSH_SG_DEBUG'): # 调试:每次调用都留痕(用于验证宿主是否真的调用本钩子)
|
||||
log(root0 or '.', 'invoked|scope=%s|tail_active=%s' % (_in_scope(tp), bool(payload.get('stop_hook_active'))))
|
||||
if _rate_limited(root0, sid, peek=True): # 只查不记账
|
||||
return
|
||||
if os.environ.get('DSH_SG_LOG_ALL', '1') != '0': # ★本工作区内**每次调用都留痕**(可判定"有没有被调用")
|
||||
log(root0 or '.', 'invoked|tail_active=%s' % bool(payload.get('stop_hook_active')))
|
||||
tail = tail_lines(text, 1) # 只看**最后一行**:命中面越窄,误报越少
|
||||
if RE_QUOTE.search(tail): # 复述/引用规则 → 不是收尾提问
|
||||
return
|
||||
m = RE_BAN.search(tail)
|
||||
if not m:
|
||||
return
|
||||
root = (os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or '')
|
||||
_rate_limited(root0, sid) # 命中才记账(同一会话 10 分钟最多拦 1 次)
|
||||
log(root if os.path.isdir(root) else '.', 'stop-dialog-guard 命中:%s | 收尾:%s'
|
||||
% (m.group(0), tail.replace('\n', ' ')[:80]))
|
||||
_emit({'continue': False, 'reason': REASON})
|
||||
|
||||
|
||||
self_refresh()
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
# ⚠️ 钩子绝不能因自身故障干扰会话 ⇒ 仍放行,但**必须留痕**(A18:静默失败是负债;
|
||||
# 2026-09-15 实证:本文件的 `except: pass` 曾把 `NameError: out is not defined` 藏住半小时)
|
||||
try:
|
||||
import traceback
|
||||
_r = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or '.'
|
||||
log(_r, 'EXCEPTION|%s' % traceback.format_exc().strip().split('\n')[-1][:120])
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""sync-scoped-guards.py —— 由源项目 hook 脚本生成「本工作区作用域版」副本
|
||||
|
||||
为什么要它
|
||||
──────────
|
||||
源项目(`aliyun-dsh-server`)的 4 个 hook 脚本里,有 2 个把作用域**写死**成单个工作区:
|
||||
|
||||
SCOPE = 'aliyun-dsh-server'
|
||||
...
|
||||
if SCOPE not in tp: return # stop-dialog-guard.py:485 / :530
|
||||
if not workdir or SCOPE not in workdir: return # skill-load-guard.py:138
|
||||
|
||||
⇒ 本工作区(`dsh-ai1net-desktop`)被一律判为"作用域外",这两条钩子**静默空转**。
|
||||
|
||||
处置(用户 2026-09-17 定):**不改源项目脚本**(那是别人的 lane,且在全局执行锁保护下),
|
||||
而是**在本工作区新建一份副本**,并把**同步做成机械的、可自动的**:
|
||||
本脚本从源脚本现读源码 → 只替换「作用域」那一层 → 写出副本。
|
||||
|
||||
同步触发(三重,无需人工盯)
|
||||
────────────────────────────
|
||||
1. **自动**:副本每次被宿主调用时自检源脚本指纹;变了 ⇒ 调本脚本重新生成(见副本内 `_self_refresh`)。
|
||||
2. **手动**:`python sync-scoped-guards.py`(`--check` 只报状态,`--force` 无视指纹强制重生成)。
|
||||
3. 本脚本自身**永不修改源项目**:只读源文件、只写本工作区 `.workbuddy/guard/`。
|
||||
|
||||
变换规则(只动「作用域」这一层,其余逐字保留)
|
||||
──────────────────────────────────────────────
|
||||
1. 定义行 `SCOPE = '<任意单值>'` → `SCOPES = _scopes()`(并注入 `_scopes()` 定义)
|
||||
2. 判定式 `SCOPE not in <expr>` → `not _in_scope(<expr>)`
|
||||
3. 判定式 `SCOPE in <expr>` → `_in_scope(<expr>)`
|
||||
|
||||
叠加修正(本空间专有,幂等 —— 源脚本自修后本规则自动失效)
|
||||
────────────────────────────────────────────────────────────
|
||||
* **C1 · `session_budget()` 解包崩溃**:源脚本里该函数在「文件超大 / 文件不存在」两个分支
|
||||
`return None, None`(**2 个值**),而正常分支 `return last_in, n_calls, prev_in`(3 个值)、
|
||||
调用处按 3 个解包 ⇒ 转录文件缺失时抛 `ValueError: not enough values to unpack (expected 3, got 2)`。
|
||||
源项目里因作用域提前 return 而从未暴露;本空间作用域打通后即成为每轮可达路径。
|
||||
修正 = 把那两处补齐为 3 个值。
|
||||
(2026-09-17 实测:`in_scope=True` 后紧跟 `EXCEPTION|ValueError: not enough values to unpack`)
|
||||
|
||||
安全设计
|
||||
────────
|
||||
* **生成后再校验**:`ast.parse` 通过 + 变换后文本里**不得残留裸 `SCOPE` 标识符** ⇒ 才落盘。
|
||||
* **原子替换**:写 `.tmp` 后 `os.replace`(并发调用不会读到半截文件)。
|
||||
* **失败不动盘**:源文件缺失 / 变换失配 / 语法不过 ⇒ **保留旧副本 + 非零退出码**,绝不写坏。
|
||||
* **零副作用**:源脚本指纹只**读**;副本落在本工作区 `.workbuddy/guard/`。
|
||||
"""
|
||||
import ast
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
# ── 路径 ────────────────────────────────────────────────────────────────────
|
||||
HERE = os.path.dirname(os.path.abspath(__file__)) # <WS>/.workbuddy/guard
|
||||
WS_ROOT = os.path.dirname(os.path.dirname(HERE)) # <WS>
|
||||
SRC_DIR = os.environ.get('DSH_GUARD_SRC_DIR',
|
||||
r'D:\github\dsh_shenxian\dsh-server-docs\scripts')
|
||||
DST_DIR = HERE
|
||||
|
||||
# 本工作区的作用域标记(与副本内 `_in_scope()` 用的是同一个)
|
||||
WS_MARK = os.environ.get('DSH_GUARD_WS_MARK', 'dsh-ai1net-desktop')
|
||||
|
||||
# 需要生成副本的脚本(只列**含作用域门禁**的;bash-output-guard.py 无作用域限制 ⇒ 不复制)
|
||||
TARGETS = ('skill-load-guard.py', 'stop-dialog-guard.py')
|
||||
|
||||
STATE_NAME = '.guard-sync-state'
|
||||
STALE_HINT = ('⚠️【守卫副本自检】源脚本已变更,副本待同步。'
|
||||
'运行:`"<python>" "<此脚本>"`(或等下一次自动刷新)。')
|
||||
|
||||
_RE_DEF = re.compile(r"^SCOPE\s*=\s*'[^']*'[^\n]*$", re.M)
|
||||
_RE_NOT_IN = re.compile(r'\bSCOPE\s+not\s+in\s+([A-Za-z_][A-Za-z0-9_]*(?:\s*\([^()]*\))?)')
|
||||
_RE_IN = re.compile(r'\bSCOPE\s+in\s+([A-Za-z_][A-Za-z0-9_]*(?:\s*\([^()]*\))?)')
|
||||
_RE_MAIN = re.compile(r"^if __name__ == ['\"]__main__['\"]:", re.M)
|
||||
# C1:定位 session_budget 函数体(到下一个顶层 def 或文件末),只在其中补 return 值个数
|
||||
_RE_SB = re.compile(r'(def session_budget\(path\):.*?)(?=\ndef |\Z)', re.S)
|
||||
|
||||
_HEADER = '''# -*- coding: utf-8 -*-
|
||||
# ⚙️ 本文件由 `sync-scoped-guards.py` **自动生成**,请勿手改(改动会在下次同步时被覆盖)。
|
||||
# 来源:{src}
|
||||
# 生成:{ts} 指纹:{fp}
|
||||
# 作用域:{mark} ← 由源项目的单工作区版本机械变换而来,其余逻辑逐字保留
|
||||
|
||||
import os as _os
|
||||
|
||||
|
||||
def _scopes():
|
||||
"""作用域标记(可用 env DSH_GUARD_SCOPES 覆盖,逗号分隔)。"""
|
||||
raw = _os.environ.get('DSH_GUARD_SCOPES') or '{mark}'
|
||||
return tuple(s.strip() for s in raw.split(',') if s.strip())
|
||||
|
||||
|
||||
_SCOPES = _scopes()
|
||||
|
||||
|
||||
def _in_scope(s):
|
||||
return any(x in str(s) for x in _SCOPES)
|
||||
|
||||
|
||||
def self_refresh():
|
||||
"""源脚本变更 ⇒ 就地把本副本重新生成一次(失败则保留旧版,绝不写坏)。
|
||||
|
||||
⚠️ 前置廉价判断:**只在本工作区的会话里才真跑** —— 本副本可能被挂在全局 hooks 上
|
||||
(对所有工作区都会被执行),其他工作区直接 return,不 spawn 子进程、不留痕。
|
||||
"""
|
||||
try:
|
||||
_root = _os.environ.get('CODEBUDDY_PROJECT_DIR') or _os.getcwd()
|
||||
if '{mark}' not in str(_root):
|
||||
return False
|
||||
import subprocess
|
||||
import sys as _sys
|
||||
r = subprocess.run([_sys.executable, _os.path.join(_os.path.dirname(_os.path.abspath(__file__)),
|
||||
'sync-scoped-guards.py'), '--quiet'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
|
||||
return r.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ── 以下为源脚本正文 ────────────────────────────────────────────────────────
|
||||
'''
|
||||
|
||||
|
||||
def _fingerprint(path):
|
||||
try:
|
||||
h = hashlib.sha256()
|
||||
with io.open(path, 'rb') as f:
|
||||
for blk in iter(lambda: f.read(1 << 16), b''):
|
||||
h.update(blk)
|
||||
return h.hexdigest()[:16]
|
||||
except OSError:
|
||||
return ''
|
||||
|
||||
|
||||
def _read(path):
|
||||
with io.open(path, encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _transform(src_text, src_name, fp):
|
||||
"""把单作用域源码变换成多作用域版本;返回 (新文本, 错误信息)。
|
||||
|
||||
★ 2026-09-22 新增「已经原生多作用域」快路径
|
||||
────────────────────────────────────────────
|
||||
用户 2026-09-22 拍板「B」后,**源脚本自己就改成了多作用域形态**
|
||||
(`_SCOPES_DEFAULT` / `_scopes()` / `_in_scope()`),不再有 `SCOPE = '...'` 定义行。
|
||||
此时若仍按老规则强求 `_RE_DEF` 命中 ⇒ **必然失配、永远卡在"待同步"**。
|
||||
处置:**认得新形态就直接原样采用**(只做校验),并把作用域标记交给源脚本的
|
||||
`DSH_GUARD_SCOPES` env / `_SCOPES_DEFAULT` —— ⛔ 生成器不再改写这一层。
|
||||
"""
|
||||
native_multi = ('_SCOPES_DEFAULT' in src_text) or ('def _scopes(' in src_text)
|
||||
head = _HEADER.format(src=os.path.join(SRC_DIR, src_name),
|
||||
ts=time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
fp=fp, mark=WS_MARK)
|
||||
|
||||
if native_multi:
|
||||
body = src_text
|
||||
n_hook = len(_RE_MAIN.findall(body))
|
||||
if not n_hook:
|
||||
return None, "没找到 `if __name__ == '__main__':` 入口(无法挂载自动刷新)"
|
||||
# 只在尚未挂过 self_refresh() **调用**时挂一次(幂等)
|
||||
# ⚠️ 判据必须是"调用"而非"出现":`_HEADER` 里本身就含 `def self_refresh()`
|
||||
# ⇒ 若用裸 `'self_refresh()' not in body` 判断,在拼过 header 后必然为真、
|
||||
# 于是重复插桩。这里在拼 header **之前**判断 src_text 即可。
|
||||
if 'self_refresh()' not in body:
|
||||
body = _RE_MAIN.sub(lambda m: 'self_refresh()\n\n' + m.group(0), body, count=1)
|
||||
# ★ 2026-09-22 修:`_HEADER`(含 `self_refresh()` 定义)在**两个分支都要拼**。
|
||||
# 旧版只拼在 else 分支 ⇒ native_multi 走到的副本调用了一个**不存在的函数** ⇒
|
||||
# `NameError: name 'self_refresh' is not defined` ⇒ 副本整个崩掉(实测 RC=1)。
|
||||
body = head + body
|
||||
else:
|
||||
n_def = len(_RE_DEF.findall(src_text))
|
||||
if n_def == 0:
|
||||
return None, '没找到 `SCOPE = \'...\'` 定义行,且源脚本也不是原生多作用域形态'
|
||||
|
||||
body = _RE_DEF.sub('# 作用域定义已由生成器提升到文件头部(`_scopes()` / `_in_scope()`)', src_text)
|
||||
body = _RE_NOT_IN.sub(lambda m: 'not _in_scope(%s)' % m.group(1), body)
|
||||
body = _RE_IN.sub(lambda m: '_in_scope(%s)' % m.group(1), body)
|
||||
|
||||
# 叠加修正 C1:session_budget() 的 2 值 return 补齐为 3 值(幂等)
|
||||
body, n_c1 = _RE_SB.subn(lambda m: m.group(1).replace('return None, None\n',
|
||||
'return None, None, None\n'),
|
||||
body, count=1)
|
||||
|
||||
# 挂载自动刷新:在 `if __name__ == '__main__':` 之前插一行 —— 每次被宿主调用时自检源脚本
|
||||
body, n_hook = _RE_MAIN.subn(lambda m: 'self_refresh()\n\n' + m.group(0), body, count=1)
|
||||
if not n_hook:
|
||||
return None, "没找到 `if __name__ == '__main__':` 入口(无法挂载自动刷新)"
|
||||
|
||||
body = head + body
|
||||
|
||||
out = body
|
||||
|
||||
# 校验 ①:不得残留裸 SCOPE 标识符(除注释外)
|
||||
for i, ln in enumerate(out.split('\n'), 1):
|
||||
code = ln.split('#', 1)[0]
|
||||
if re.search(r'\bSCOPE\b', code):
|
||||
return None, '变换后第 %d 行仍残留裸 `SCOPE`:%s' % (i, ln.strip()[:80])
|
||||
|
||||
# 校验 ②:语法必须通过
|
||||
try:
|
||||
ast.parse(out)
|
||||
except SyntaxError as e:
|
||||
return None, '变换后语法检查失败:%s(第 %s 行)' % (e.msg, e.lineno)
|
||||
return out, ''
|
||||
|
||||
|
||||
def _atomic_write(path, text):
|
||||
tmp = path + '.tmp'
|
||||
with io.open(tmp, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write(text)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _load_state():
|
||||
import json
|
||||
try:
|
||||
return json.loads(_read(os.path.join(DST_DIR, STATE_NAME)))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_state(d):
|
||||
import json
|
||||
_atomic_write(os.path.join(DST_DIR, STATE_NAME), json.dumps(d, ensure_ascii=False, indent=1))
|
||||
|
||||
|
||||
def run(force=False, quiet=False):
|
||||
"""返回 (changed:int, errors:list, stale:list)。"""
|
||||
if not os.path.isdir(SRC_DIR):
|
||||
return 0, ['源目录不存在:%s' % SRC_DIR], []
|
||||
|
||||
state = _load_state()
|
||||
changed, errors, stale = 0, [], []
|
||||
out_lines = []
|
||||
|
||||
for name in TARGETS:
|
||||
sp = os.path.join(SRC_DIR, name)
|
||||
dp = os.path.join(DST_DIR, name)
|
||||
if not os.path.isfile(sp):
|
||||
errors.append('%s:源文件不存在' % name)
|
||||
continue
|
||||
fp = _fingerprint(sp)
|
||||
rec = state.get(name) or {}
|
||||
if not force and rec.get('src_fp') == fp and os.path.isfile(dp) \
|
||||
and _fingerprint(dp) == rec.get('dst_fp'):
|
||||
continue # 已是最新
|
||||
if rec and rec.get('src_fp') and rec.get('src_fp') != fp:
|
||||
stale.append(name) # 源变过(供注入提示用)
|
||||
|
||||
text, err = _transform(_read(sp), name, fp)
|
||||
if err:
|
||||
errors.append('%s:%s' % (name, err))
|
||||
continue
|
||||
_atomic_write(dp, text)
|
||||
state[name] = {'src_fp': fp, 'dst_fp': _fingerprint(dp), 'synced_at': time.strftime('%Y-%m-%d %H:%M:%S')}
|
||||
changed += 1
|
||||
out_lines.append(' ↻ %s ← %s(%s)' % (name, fp, '强制' if force else '已更新'))
|
||||
|
||||
if changed:
|
||||
_save_state(state)
|
||||
if not quiet:
|
||||
if out_lines:
|
||||
print('已同步 %d 份副本 → %s' % (changed, DST_DIR))
|
||||
print('\n'.join(out_lines))
|
||||
elif errors:
|
||||
# ★ 2026-09-22 修:有错误时**不得**报"副本均为最新"(旧版会同时打印
|
||||
# `❌ 变换失配` 和 `副本均为最新` ⇒ 自相矛盾,且 RC=0 让调用方以为成功)
|
||||
print('未同步任何副本(%d 份失败,见下)' % len(errors))
|
||||
else:
|
||||
print('副本均为最新(%d 份)' % len(TARGETS))
|
||||
for e in errors:
|
||||
print('❌ %s' % e, file=sys.stderr)
|
||||
return changed, errors, stale
|
||||
|
||||
|
||||
def main():
|
||||
args = set(sys.argv[1:])
|
||||
quiet = '--quiet' in args
|
||||
force = '--force' in args
|
||||
check = '--check' in args
|
||||
|
||||
if check:
|
||||
state = _load_state()
|
||||
for name in TARGETS:
|
||||
sp = os.path.join(SRC_DIR, name)
|
||||
dp = os.path.join(DST_DIR, name)
|
||||
fp = _fingerprint(sp)
|
||||
rec = state.get(name) or {}
|
||||
same = (rec.get('src_fp') == fp and os.path.isfile(dp))
|
||||
print('%-24s 源=%s 副本=%s %s' % (
|
||||
name, fp or '(缺失)', '存在' if os.path.isfile(dp) else '(缺失)',
|
||||
'✅ 最新' if same else ('🚨 待同步' if fp else '🚨 源缺失')))
|
||||
return 0
|
||||
|
||||
_, errors, stale = run(force=force, quiet=quiet)
|
||||
# ★ 2026-09-22 修:`stale` 是"源变过"的**过程标记**。若本次已成功重建该副本,
|
||||
# 就不该再提示"待同步"(旧版会在同步成功的同一轮里同时打印 ↻ 和"待同步",误导调用方)。
|
||||
if stale and not quiet:
|
||||
import json as _json
|
||||
try:
|
||||
_st = _json.loads(_read(os.path.join(DST_DIR, STATE_NAME)))
|
||||
except Exception:
|
||||
_st = {}
|
||||
pending = []
|
||||
for _n in stale:
|
||||
_rec = _st.get(_n) or {}
|
||||
_sp = os.path.join(SRC_DIR, _n)
|
||||
_dp = os.path.join(DST_DIR, _n)
|
||||
if _rec.get('src_fp') == _fingerprint(_sp) and os.path.isfile(_dp) \
|
||||
and _fingerprint(_dp) == _rec.get('dst_fp'):
|
||||
continue # 已重建 ⇒ 不再算待同步
|
||||
pending.append(_n)
|
||||
if pending:
|
||||
print('\n' + STALE_HINT)
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,10 @@
|
||||
# 工作区 `scripts/` 撤除说明(2026-09-24)
|
||||
|
||||
撤除理由:两个脚本都不可用,且工作区**没有** `handoff-guard.sh` 本体(在文档库 `07-scripts/`)。
|
||||
|
||||
| 文件 | 处置理由 |
|
||||
|---|---|
|
||||
| `_lock.sh` | **一次性临时脚本** —— 会话名写死为 `域名迁移-ai1net-切换`(09-19 任务已完成)。旧路径已修为 `07-scripts/`,但无保留价值 |
|
||||
| `docs-sync-check.sh` | 文档库 `07-scripts/docs-sync-check.sh` 的**陈旧重复**(2024 B vs 4516 B,缺运行态排除规则等 57 行改动) |
|
||||
|
||||
**今后约定**:脚本一律用**绝对路径**调文档库版(`D:/github/dsh_shenxian/dsh-server-docs/07-scripts/<name>`),工作区不保留副本 —— 避免副本漂移。
|
||||
@@ -0,0 +1,6 @@
|
||||
set -u
|
||||
if [ -f "$HOME/.workbuddy/.active-run" ]; then :; fi
|
||||
echo "=== 释放全局执行锁 ==="
|
||||
bash "D:/github/dsh_shenxian/dsh-server-docs/07-scripts/handoff-guard.sh" --release-exec "域名迁移-ai1net-切换" 2>&1 | tail -12
|
||||
echo "=== 复核(应显示无人占用)==="
|
||||
ME="域名迁移-ai1net-切换" bash "D:/github/dsh_shenxian/dsh-server-docs/07-scripts/handoff-guard.sh" 2>&1 | sed -n '/【1c】/,/【2】/p'
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# docs-sync-check.sh — 对账「本机文档镜像」与「服务器文档库」
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/docs-sync-check.sh # 报告双端差异
|
||||
# DOCS_REMOTE=bt-server bash scripts/docs-sync-check.sh
|
||||
#
|
||||
# 可覆盖的环境变量:
|
||||
# DOCS_LOCAL_DIR 本机镜像目录(默认 E:/ProgramData/AI技能/aliyun-dsh-server/dsh-server-docs)
|
||||
# DOCS_REMOTE ssh 别名或 host(默认 bt-server)
|
||||
# DOCS_REMOTE_DIR 服务器文档目录(默认 /opt/dsh/docs)
|
||||
#
|
||||
# 退出码:0 = 完全一致;1 = 存在差异;2 = 环境/连接错误
|
||||
set -uo pipefail
|
||||
|
||||
LOCAL_DIR="${DOCS_LOCAL_DIR:-E:/ProgramData/AI技能/aliyun-dsh-server/dsh-server-docs}"
|
||||
REMOTE="${DOCS_REMOTE:-bt-server}"
|
||||
REMOTE_DIR="${DOCS_REMOTE_DIR:-/opt/dsh/docs}"
|
||||
|
||||
[ -d "$LOCAL_DIR" ] || { echo "ERROR: 本机目录不存在: $LOCAL_DIR" >&2; exit 2; }
|
||||
|
||||
tmp_l="$(mktemp)"; tmp_r="$(mktemp)"
|
||||
trap 'rm -f "$tmp_l" "$tmp_r" 2>/dev/null || true' EXIT
|
||||
|
||||
hash_local() {
|
||||
( cd "$LOCAL_DIR" && find . -type f ! -path './.git/*' ! -name '.DS_Store' | LC_ALL=C sort | while read -r f; do
|
||||
printf '%s\t%s\n' "${f#./}" "$(md5sum "$f" | cut -d' ' -f1)"
|
||||
done )
|
||||
}
|
||||
|
||||
hash_remote() {
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE" \
|
||||
"cd '$REMOTE_DIR' 2>/dev/null && find . -type f ! -path './.git/*' ! -name '.DS_Store' | LC_ALL=C sort | while read -r f; do printf '%s\t%s\n' \"\${f#./}\" \"\$(md5sum \"\$f\" | cut -d' ' -f1)\"; done" \
|
||||
2>/dev/null
|
||||
}
|
||||
|
||||
hash_local > "$tmp_l"
|
||||
hash_remote > "$tmp_r"
|
||||
|
||||
if [ ! -s "$tmp_r" ]; then
|
||||
echo "ERROR: 无法读取服务器目录 $REMOTE:$REMOTE_DIR(ssh 失败或目录不存在)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
awk -F'\t' '
|
||||
NR==FNR { l[$1]=$2; next }
|
||||
{ r[$1]=$2 }
|
||||
END {
|
||||
same=0
|
||||
for (k in l) {
|
||||
if (k in r) { if (l[k]==r[k]) same++; else { diff++; print "⚠️ 内容不一致 " k } }
|
||||
else { only_l++; print "⬆️ 仅本地(待推送) " k }
|
||||
}
|
||||
for (k in r) if (!(k in l)) { only_r++; print "⬇️ 仅服务器(待拉取) " k }
|
||||
printf "\n—— 汇总 ——\n 一致: %d\n 内容不一致: %d\n 仅本地: %d\n 仅服务器: %d\n 本地文件总数: %d / 服务器文件总数: %d\n", same, diff+0, only_l+0, only_r+0, length(l), length(r)
|
||||
if ((diff+0)+(only_l+0)+(only_r+0) > 0) { print "\n结果: 存在差异 ❌"; exit 1 }
|
||||
print "\n结果: 双端一致 ✅"
|
||||
}
|
||||
' "$tmp_l" "$tmp_r"
|
||||
@@ -0,0 +1,133 @@
|
||||
# 接续入口 · StoryForge 插件线(2026-09-20 立)
|
||||
|
||||
> 🔴 **工作区**:E:/ProgramData/AI技能/dsh-plugin-forge —— ⚠️ **本文件是外来副本**(2026-09-21 19:37 由 forge 侧「两处同改」产生)。StoryForge 线自己的家是 `dsh-plugin-forge`,本副本**应迁移并删除**;`state.py` 已加归属校验(2026-09-21),会把它标成「**外来线**」并排除出 §2 / 口令。⛔ 今后**不再两处同改**。
|
||||
> **唯一执行依据 = 本文件 §0 最新行 + §2 首行(本棒)**。⛔ 勿重做已完成棒、⛔ 勿跳序。
|
||||
> 方案单一来源 = `E:\ProgramData\AI技能\dsh-plugin-forge\StoryForge改造为dsh插件_可行性与落地方案_20260920.md`(**v2**,只读 §二 / §三 / §四 / §七 / §九)
|
||||
> **as-built 说明(已建成什么样)= `E:\ProgramData\AI技能\dsh-plugin-forge\StoryForge插件_改造说明与数据落点_20260921.md`** —— 含**全部改造点**(上游 3 处 + 插件包 8 件 + 七条硬契约)、**平台数据库对接现状与路径**、**数据落点主表 + 铁律六条**、现状差距。⚠️ 该文件是**当前事实的唯一汇总面**,回答"改了什么 / 数据放哪"先看它
|
||||
> 单子落点 = `D:\github\dsh_shenxian\dsh-server-docs\交接单\StoryForge插件线-序*.md`(**权威**)|本线目录留一份同步副本
|
||||
> 目标一句话:把 StoryForge(上游 v3.9.1,AI 小说创作工具)**不 fork、只改 3 处**地做成 dsh 业务插件(形态 = 同源 iframe 内嵌)。
|
||||
> 🔴 **权威接续入口 = `E:/ProgramData/AI技能/dsh-plugin-forge/接续入口_StoryForge插件线_20260920.md`(StoryForge 线自己的工作区)**;**本工作区(aliyun-dsh-server)这份是残留副本 —— ⛔ 不要在这里改、⛔ 不要两处同改**,要改只改 forge 那份(归属三律:入口只允许一份)。
|
||||
|
||||
---
|
||||
|
||||
## §0 定序(线内串行,勿跳)
|
||||
|
||||
| 序 | 单子 | 状态 |
|
||||
|---|---|---|
|
||||
| **1** | `StoryForge插件线-序1-上游改造与插件骨架.md` | ✅ **已落**(2026-09-20 23:45 → 09-21 00:05 · 执行棒 `StoryForge插件线-序1执行棒`) |
|
||||
| **2** | `StoryForge插件线-序2-部署与实例验收.md` | 🟡 **部分执行**(2026-09-21 00:42 → 01:1x · 执行棒 `StoryForge插件线-序2执行棒`)—— **S0–S5 ✅ 全绿**:host 半区 `/storyforge/*` 前缀路由已落、本机等价验收 **`4/4 PASS`(50 断言 0 失败)**、`npm pack` **1,754,963 B** / md5 `dca573b57814de6c205b7be6d3772760`、候选池 **200 / `blocked=0`**(未用 trust)、**实机证明插件被加载并注册路由**(标记 ×4)、实装 md5 逐字节一致、属主 114801 无 root。🔴 **S6 卡在平台 `mine/apply` 的「重启+探活」腿**(终态 `failed`、平台已 `restoreProfile` 自动回滚 ⇒ 实例当前**未启用**;真机判据 1–4 **未验**);四条证据判定**与本插件无关**。⛔ **链条已暂停** —— 见 §1 末条 + §2 |
|
||||
| 3 | 序3 — LLM 代理与配额(`/storyforge/api/llm/*`,**含拍板项**) | ⛔ **未立项**,等用户拍板;🔴 **新增硬前置**:须先解 §2 记录的 S6 阻塞(平台启用链路探活腿),否则装上亦看不见效果 |
|
||||
| **跨线** | `对接单_平台侧-mine-apply重启探活腿异常_20260921.md`(本线 → 平台线) | ⏳ **已出,待平台侧评估**(2026-09-21 06:2x 规划棒2)。落点 = **本工作区**(按用户 2026-09-21 约定) |
|
||||
|
||||
**⛔ 全序遇到即停(写死)**:凡需决定「**模型 KEY 来源 / 每用户配额 / 是否由平台承担模型调用成本**」⇒ **立刻停下报告,不得自拍**(属 §1 边界外,归序3)。
|
||||
|
||||
**边界(全序通用)**:不 commit / 不 push;⛔ **本线文档只落本工作区,不写 `dsh-server-docs`**(因此**不涉及**共享锁);⛔ 不碰平台 `src/`;上传候选池 / 启用实例 / 重启**生产服务** / scp **到生产服务器** 属序2(序1 内禁止)。✅ **scp 到本机 Ubuntu 测试靶机不受此限**(见下)。
|
||||
|
||||
**🔴 验收环境(用户 2026-09-21 明令:「开发完成后在本机 ubuntu 上进行测试」)**
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 靶机 | **本机 WSL2 · Ubuntu 26.04.1 LTS**,hostname `User-2026QYRQXO` |
|
||||
| 连法 | `ssh -i ~/.ssh/id_ed25519_verifyenv -o IdentitiesOnly=yes [email protected]` ⚠️ **`id_ed25519` 会被拒**(`Permission denied (publickey,password)`) |
|
||||
| ⛔ 禁路 | `wsl.exe` **被沙箱 Program Blacklist 拦**(提示明确「不得重试/绕行」)⇒ **一律走 ssh** |
|
||||
| 环境 | **node v22.23.2(与生产服务器同版本)** / python3 3.14.4 / 32 核 / 23.8 GB |
|
||||
| 落地约定 | `tar czf - -C <插件包> . \| ssh … 'mkdir -p /root/sf-smoke-<HHMMSS> && tar xzf - -C …'` —— **每次新目录、不删旧**(照 PLAYBOOK §17「换新目录名别删」) |
|
||||
| 冒烟入口 | 插件包内 **`scripts/smoke.mjs`**(2026-09-21 新增,**可复跑**;`package.json` 的 `files` 不含它 ⇒ **不进 tgz**) |
|
||||
| 判据 | **Ubuntu 上必须 `34/34` 全绿**(退出码 0) |
|
||||
|
||||
📌 **2026-09-21 首次对照实测(同一脚本、同一产物)**:**Windows 32 过 / 2 败** ⟷ **Ubuntu 34 过 / 0 败**;败的恰是两条 ★ **大小写敏感**项(`/storyforge/INDEX.HTML` 与资产名全大写:Windows 返 **200**、Ubuntu 正确返 **404**)⇒ **这就是必须有 Linux 靶机的实证理由**:Windows 文件系统不区分大小写,会把这类缺陷判成绿的。
|
||||
|
||||
---
|
||||
|
||||
## §1 已落(只增不删,带证据)
|
||||
|
||||
- **2026-09-20 规划棒**:可行性方案 v2 出(形态 **候选 A 同源 iframe**;不 fork;关 SW;provider 收敛 deepseek;M1 入口 = 全屏/新标签页;锚点锁 `v3.9.1`)。落点 `E:\ProgramData\AI技能\dsh-plugin-forge\`。
|
||||
- **2026-09-20 23:45 序1 执行棒(收官)**:
|
||||
- 上游副本 `E:\github\storyforge` @ **`v3.9.1`** / sha **`7286497e`**(`clone` + `checkout` 均 rc=0)。
|
||||
- **3 处上游改动全部落地**并**加严**:关 SW(删 `VitePWA` + 注册改 no-op + `index.html` 清理改全主机执行)|provider 收敛(18 条中仅 `deepseek` 启用,其余「待启用」)|删 dev 代理(**实测 12 条**,非单子写的 15 条)并改同源 `/storyforge/api/llm/<provider>/...`。
|
||||
- `npm ci`(764 包 / rc=0,Node 22.22.2 **无 engine 报错**)→ `npm run build`(`tsc -b && vite build`,**rc=0**)。
|
||||
- 验收六条全绿:`v3.9.1` | 产物内 `https?://api\.` 与 `127.0.0.1:` **0 命中** | `dist/index.html` OK | 静态冒烟 `/storyforge/`=**200**、`sw.js`=**404** | `node --check lib/client.js` rc=0 | `exports.default` **0 命中**。
|
||||
- 插件包骨架落 `E:\ProgramData\AI技能\dsh-plugin-forge\dsh-plugin-storyforge\`(`package.json` / `cordis.patch.yml` / `lib/{index.js,client.js}` / `dist/` 110 文件 / `LICENSE`;总 6,510,823 B;`inject=[]`、`dshCompat=">=0.1.5 <0.2"`、version `0.1.0`)。
|
||||
- 证据全文 = 单子 **§八**(含 6 条「失败尝试与假阴性」);单子另加 **§九「交给 序2/序3 的输入」**(宿主待补路由三张表)。
|
||||
- ⚠️ **未做浏览器渲染验证**(本机无 Chromium)⇒ 只到 **L2(命令)+ L3(对账)**。
|
||||
- 🔴 **单子口径修正 3 处**(已在单子内就地标注):① 「coreutils 全部缺失」**不成立**(`dirname`/`ls`/`mkdir`/`md5sum` 全在 `/bin`,`handoff-guard.sh` 实跑成功)② dev 代理 **12 条**而非 15 条 ③「不再出现 `-proxy`」按**代码级**口径达成(注释内说明性提及有意保留)。
|
||||
- **2026-09-21 00:3x 序2 规划棒(立单完成)** —— 只读前置取证 + 出单,**未改插件包/上游/平台代码**。产物 = `dsh-server-docs/交接单/StoryForge插件线-序2-部署与实例验收.md`(8 段模板,`.lock-02` 已原子占号)+ `交接单/README.md §一` 新增序2 一行。⚠️ **当时仍按旧约定落文档库**;**2026-09-21 起本线文档改为只落本工作区**(本工作区已有同内容副本,含 §八 回填证据)。
|
||||
- 🔴 **本棒决定性取证(序2 执行棒的必读前置)**:
|
||||
- **路由 API 必须用 `kind`**:`E:\github\dsh-desktop-0.1.5rc1\packages\host\webserver\lib\index.js:176-178` 实测 `const table = route.kind === "exact" ? this.exact : this.prefixes;` ⇒ 既存插件(social-workbench / voxemw-cloud)写的 `exact: true` 在 0.1.5-rc.1 会落**前缀表**(语义漂移)⇒ 本包用 `kind:'prefix'`。
|
||||
- **`registerFallback` 只有一座**且被官方 `@deepseek-ai/dsh-host-frontend-static` 占用 ⇒ SPA 回退**必须写在自注册 prefix handler 内**,否则注册即 throw ⇒ 整包加载失败。
|
||||
- **回退只对「无扩展名」路径生效** ⇒ 带扩展名未命中一律 404,否则 `/storyforge/sw.js` 会回退成 HTML ⇒ SW 复活(M1 明令关闭)。
|
||||
- **宿主半区 `inject` 声明方式**:模块级 `const inject = ['webServer']` + `export { apply, inject }`(social-workbench `lib/index.js:216,218` 实证);与 `package.json` 的 `dsh.client.inject`(管 client 半区)**是两回事**。
|
||||
- **本序无需改平台 `src/`**:`src/supervisor/proxy.ts` 的 `targetPath` 默认 = `rawUrl`,**无路径白名单** ⇒ 未知前缀原样透传。
|
||||
- **缓存判据**:代理给 `no-cache` 的条件 = 路径以 `/plugins/` 或 `/assets/` 开头 **或** `content-type` 含 `text/html`(`proxy.ts:487-491`)⇒ SPA 深链响应**必须**是 `text/html`,否则拿不到 `no-cache`。
|
||||
- **投放链路**:`POST /api/plugins/business`(admin 会话,body `{file: base64, filename: *.tgz}`,同名整体替换,P0 命中默认 409 fail-closed)→ `POST /api/plugins/mine/apply`(`{plugins:[{id,enabled}]}`,**对已启用项是 noop** ⇒ 更新须「先停用再启用」)。admin 会话用 47 上 `node /opt/dshs/mksess.cjs`(**TTL 10 分钟,现建现用,用完删 `sessions` 行**)。
|
||||
- **内存基线**:每实例 基础 **448 MiB** / 浮动上限 **1024 MiB**;本包 6.21 MiB 静态文件 ⇒ 宿主侧 RSS 增量 ≈ 0(每请求读盘、不缓存)。
|
||||
- **2026-09-21 00:42 → 01:1x 序2 执行棒(部分收口 · 🔴 卡点已定性)** —— 单子 §五 S0→S7 走完,S0–S5 **全绿**,**S6 受阻**。证据全文 = 单子 **§八**(10 段已回填,含 8 条「失败尝试与假阴性」)。
|
||||
- **S0**:全局执行锁 `mkdir` 成功(`OWNER` = 本会话)+ `.doing-StoryForge序2` 已占。
|
||||
- **S1 前置 5 条全过**:`dsh 0.1.5-rc.1` / 域 `ai1net.com` | 🔴 **`kind` 决定性行实测在 `webserver/lib/index.js:177`**(单子写 176–178,**行号差 1**,内容逐字一致)| fallback 座位 `throw` 在 `:206`、占用方 `frontend-static/src/index.ts:124` | `inject` 声明法(social-workbench `:216/:218`)|包现状 `dist/` **110** / 总 **6,510,823 B** / `files` 字段**缺席**。**另证 `dist/sw.js` 不存在** ⇒ 判据 4 的 404 是真 404。
|
||||
- **S2 实现(改 4 文件,回滚点存包外 `_backup-storyforge-xu2/`)**:`lib/index.js` 全文重写(`export const inject = ["webServer"]` + `ctx.effect(() => server.register({kind:'prefix', path:'/storyforge', handler}))`,**保留**原标记写入)|**新增** `lib/static.js`(纯函数 `planRequest` + 写响应 `serve`;**唯一语义差异 = `fallbackOnMiss`**:回退只对无扩展名路径生效)|`package.json` 加 `files` + 改过期 `description` |`cordis.patch.yml` **仅注释**(原写「无 inject」序2 后与事实相反)。
|
||||
- **S2 验证 4/4 过**:`node --check` rc=0 ×3 | `exports.default` **0** | `kind:"prefix"` + `inject` 命中 | `files=['lib','dist','cordis.patch.yml','LICENSE']` / `0.1.0` / `client.inject=[]`。
|
||||
- **S3 本机等价验收 = `4/4 PASS`**(**50 断言 0 失败、退出码 0**;真起 `node:http` + 调 `serve()`,非纯模拟):① `/storyforge/` = 200 `text/html;charset=utf-8` **4689 B 逐字节 == dist/index.html** ② hashed JS = 200 `text/javascript` **710117 B == 包内同名文件** ③ `/storyforge/settings` = 200 **含 `text/html`**(回退体逐字节 == index.html)④ `/storyforge/sw.js` = **404** 且体为空(未回退成 HTML);旁证:多级深链 200/html、POST 405、HEAD 200 无体、带扩展名未命中 404、越界非 200。
|
||||
- **S4 打包**:`dsh-local-storyforge-0.1.0.tgz` = **1,754,963 B** / md5 **`dca573b57814de6c205b7be6d3772760`**;`package/dist/` = **110**(= 本机 dist 数);`package/{package.json,cordis.patch.yml,LICENSE}` + `lib/{index.js,static.js,client.js}` **6 条全中**;**无** `.bak`/`node_modules` 脏条目。
|
||||
- **S5 候选池 ✅**:`POST /api/plugins/business` = **200**、`ok:true`、`replaced:false`、`trustedOverride:false`、🔴 **`blocked=0` ⇒ 未触发 409、未用 `trust` 放行**(单子 §四 预警的 vendor 假阳性**未出现**);`compat.level = unknown`(**非 `incompatible`**):`"插件未声明也未直接 import 任何 @deepseek-ai 包(扫描 106 个 js 文件)"`;18 条 `warnings` **全落 `package/dist/assets/**`**、`lib/**` 零命中。池内 `tgzPath = /var/lib/dshs/business-plugins/_dsh-local_storyforge.tgz`(**条目保留**)。
|
||||
- **S6 实例启用 ⛔ 卡点**:靶 = admin 实例(uid **114801** / `w-47` / `dsh --profile web --port 20000`)。`mine/apply` 返 `taskId=397355a8a6d6fb78`,**终态 `failed`**(`安装失败,请重试或联系管理员`,93 s),阶段**恒停 `正在重启实例并探活…`**。**实装却成功**:`node_modules/@dsh-local/storyforge` 软链到位、`dist/index.html` md5 **`893e2034dd7e389e805b33310d051efb`** = 包内、属主全 **114801**、root 文件 **0**(R10 过)。平台随后 `restoreProfile` **自动回滚** ⇒ `bundles` 现**不含**本包。
|
||||
- 🔴 **关键判据:插件不是肇事方(四条独立证据)**:① 实例内标记 `.dsh/.dsh-storyforge.log` 显示 **`route registered kind=prefix path=/storyforge` ×4**(`16:53:54 / 16:54:07 / 16:54:19 / 16:54:31`)⇒ 真实例内**加载成功、注册成功、dist 解析正确**;② `dsh_instances` 该行 `last_error=null` / `exit_code=null` / `epoch` 未变 / heartbeat 实时 / scope 持续 active ⇒ **从未崩溃**;③ **代码未走到「隔离/自动禁用」分支**(阶段无 `实例未能启动…`、无 `正在定位问题插件(1/1)`,`audit_log` 无 `plugin_incident`、无 `apply_business_plugins`)⇒ 是 `restartProbe()` **抛异常**,非探活判定为坏;④ 同平台 `2026-09-14T12:03:16Z` **有**该分支正常留痕的先例(`plugin_incident reason:"实例启动后崩溃"`)⇒ 本次无痕 = 未走到那里。
|
||||
- ⚠️ **旁证(门户启用链路已长期不正常)**:`audit_log` 全表 `apply_business_plugins` **最后一条 = `2026-09-14T12:03:28Z`**;此后平台侧部署(含 09-20 MCN 线投放)实际走 **`ensure-biz-plugins.cjs` 直铺**(不写该审计)。
|
||||
- ⚠️ **另记两条环境噪声(与本序无关)**:失败窗口 `guest.ai1net.com` 对真实浏览器返 **503**;日志大量 `[relay-dialer] 拨 ops/w-106:21000 失败:refused: busy`。
|
||||
- ⛔ **未做(明示)**:**client 半区**(导航入口 + `<iframe src="/storyforge/">` 面板)本序 §一/§三/§六 均未纳入 ⇒ 目前「真机打开」只有**直达 URL** 一条路径。**真机判据 1–4 未验**。
|
||||
- 🧹 **清理已做**:临时 admin 会话已删(`delete from sessions where user_agent='poc-curl2'`,1 → 0);47 上临时脚本已清;池条目保留。
|
||||
- **2026-09-21 06:1x–06:4x 规划棒2(分诊 + 出跨线对接单)** —— 用户拍板走 **A**(平台侧修探活腿)。本棒**只读取证 + 出单**:⛔ 未改平台 `src/`、未改插件、**未写任何共享文件**。⚠️ 曾误把「文档要落文档库」当成前提,于是被 `guest实例503排障-20260921` 持的全局锁挡住;**用户 06:3x 纠正:本线文档落本工作区** ⇒ 与全局锁**无关**,该判断已回改(见 §2)。
|
||||
- **失败链路已逐跳取证(10 跳,全部带 `file:line`)**,写进对接单 §三,省去平台会话重新摸索:
|
||||
`business-plugins.ts:648-651`(`restartProbe` 两条 await **均未包裹**)→ `:665/676/691`(三处调用点)→ `:708-717`(兜底 catch)→ 🔴 `:543-551`(**`friendlyError` 把任何未知异常压成 `安装失败,请重试或联系管理员`**)→ `:718-732`(外层 catch 的 `console.error` **只在异常逃出内层时才打** ⇒ 本案例什么都没打);
|
||||
下半段:`orchestrator.ts:532-566`(`restartAndProbe` 自身不抛,但 **:544 `restartMain` / :546 `spawnWatchdog` 未包裹**)+ 集群模式 `remote-spawner.ts:388-396`(`hostFor` + `call` ↔ 取址在 `:235`,注释原文「**`agentBaseUrlOf()` 现在会抛**」)+ `reachability.ts:99-105`(抛 `via=relay 但解析不出落点`)+ `remote-user-fs.ts:200-202`(`writeHandoff` → `POST /fs/handoff`)。
|
||||
- **根因三候选(⛔ 明确写了"别凭候选表下结论,必须实测判定")**:① 取址抛错(**与档案 136 同族** ⇒ 若命中应**合并处理,不另起第二处判据**)|② HTTP 调用失败/超时(失败窗口内 `relay-dialer … refused: busy` 大量出现)|③ `restartMain`/`spawnWatchdog` 抛错。
|
||||
- **⏱ 一条矛盾留作实测题**:整次耗时 **93 s**;候选 ① 是**同步抛**,应在安装后数秒内失败 ⇒ 93 s 更像候选 ②。**由 S2 实测回答,不许猜。**
|
||||
- **🔴 关键发现(对接单 §二-2)**:失败路径**完全不落审计** —— `audit_log` 全表 `apply_business_plugins` **最后一条停在 `2026-09-14T12:03:28Z`** ⇒ 这也是「门户启用链路已长期不正常」的直接证据。
|
||||
- **对接单要求平台侧先做的一步 = 让异常可见**(内层 catch 出声 + 落审计 + 原始 message 带进 `task.error`):没有这一步,**后面任何修复都无法验证**。
|
||||
|
||||
---
|
||||
|
||||
## §2 🎯 本轮动作
|
||||
|
||||
### ✅ 已收官:**规划棒2**(2026-09-21 06:1x–06:2x)—— 分诊 + 出跨线对接单
|
||||
|
||||
**用户 2026-09-21 拍板**:走 **A 方案**(平台侧修 `mine/apply` 的「重启+探活」腿),并要求「生成对接文档让平台会话评估」。
|
||||
|
||||
**产出**:`E:\ProgramData\AI技能\dsh-plugin-forge\对接单_平台侧-mine-apply重启探活腿异常_20260921.md`(8 段)
|
||||
- 含**已取证的代码地图 10 跳**(`file:line`,见 §1 末条),省去平台会话重新摸索;
|
||||
- 含**根因三候选 + 区分方法**(⛔ 明确写了「别凭候选表下结论,必须 S2 实测判定」);
|
||||
- 含**必须先做的一步**:让异常可见(内层 catch 现在把真因压成一句通用文案,且不落审计);
|
||||
- 含验收 5 条 + **R8 影响面**(修这条腿通常要 `systemctl restart dshs` ⇒ 会中断在线用户,须先知会)。
|
||||
- ✅ **落点 = 本工作区** `E:\ProgramData\AI技能\dsh-plugin-forge\`(用户 2026-09-21 约定:**本线文档一律落本工作区**)⇒ **无需**写 `dsh-server-docs`、**无需**抢任何锁。
|
||||
> ⚠️ 本条**替代**此前「未落文档库 ⇒ 待锁释放后补」的写法 —— 那是**错误套用了「单子落文档库」的旧约定**,把本可零冲突的写作挡在别人的锁外面。平台会话按**绝对路径**读本单位即可。
|
||||
|
||||
### ⏭️ 本线下一项(⛔ 本棒**不预登记**)
|
||||
|
||||
| 序 | 内容 | 前置 |
|
||||
|---|---|---|
|
||||
| **0** | ✅ **已完成**(2026-09-21 06:5x):平台项目内本线文档已清零 —— 删 2 份单子(先备份)+ 摘 `交接单/README.md §一` 本线 2 行(46,215 → 40,480 B)+ 用官方脚本刷新 `docs-manifest.json`(StoryForge 命中 4 → **0**);`docs-audit.py` **RC=0 无 P0** | 回滚点 = 本工作区 `_backup_docs_revert_20260921/`(4 件)。⚠️ 两个残留:① 服务器镜像 `/opt/dsh/docs` **未 scp**(推送需用户明示)② carbon 单里两处提及本线 —— 属**别人的文件**,未动 |
|
||||
| **2-补** | **序2 补跑 S6**:在 w-47 的 admin 实例(uid `114801`)上补齐真机验收 1–4 | 🔴 **等平台侧修好探活腿 + 回执**;修好前登记也无用(撞同一堵墙) |
|
||||
| **2b** | **client 半区**:导航入口 + `<iframe src="/storyforge/">` 面板(序2 §一/§三/§六 均未纳入 ⇒ 目前真机打开只有直达 URL 一条路径) | ✅ **无前置、无拍板项** ⇒ 可独立开工 |
|
||||
| **3** | LLM 代理与配额(`/storyforge/api/llm/*`) | 🔴 **含拍板项**(模型 KEY 来源 / 每用户配额 / 是否由平台承担调用成本)⇒ 等用户拍板;且须先解 S6 |
|
||||
| **修-a** | **`lib/client.js` 头注释过时**:现写「导航入口与 `<iframe src="/storyforge/">` 面板属**序2**」,但**序2 明确未纳入** ⇒ 会误导下一棒(文件本身仍是 1,630 B 骨架,`inject=[]`) | ✅ 无前置、无拍板项,**本线自决** |
|
||||
| **修-b** | **`scripts/smoke.mjs` 在 Windows 上退出码 1**(两条 ★ 大小写项必然失败)⇒ 容易被误判成"产物坏了"。改法:把 ★ 平台相关项在**非 Linux** 上判 **SKIP**(不计失败),只在 Linux 计分并保留严格退出码 | ✅ 无前置、无拍板项,**本线自决** |
|
||||
| **纪律** | 序2b / 序3 改过 `lib/` 后重新 `npm pack` **必须升版本号**(pnpm 按版本复用同名 tgz,同号覆盖重发不安全 —— PLAYBOOK §9.3;候选池现为 `0.1.0`) | 无 |
|
||||
|
||||
### ⛔ 本棒未登记下一棒的原因(两条都命中 ⇒ 按登记门禁不登记)
|
||||
|
||||
① 下一棒(2-补 / 3)均被**外部窗口**阻塞(等平台修好 / 等用户拍板);
|
||||
② 收口时**仍有别的会话在跑**(`guest实例503排障-20260921` 自 06:16 持全局锁)。
|
||||
⇒ **链条暂停**:等平台回执或用户拍板后,再由**当时的会话**立棒。⛔ 不预登记队列。
|
||||
|
||||
### ✅ 上一棒:**序2 执行棒**(2026-09-21 00:42 → 01:1x)部分收口
|
||||
|
||||
见 §1 末条 + 单子 §八(10 段已回填,含 8 条「失败尝试与假阴性」)。**S0–S5 全绿**;**S6 受阻**(平台探活腿)⇒ 真机判据 1–4 **未验**、实例当前**未启用**。⛔ 未改平台 `src/`、未改上游、未 commit / 未 push。
|
||||
|
||||
> 📌 **A/B/C 三候选的完整优点/缺点**已随用户 2026-09-21 的拍板归档(选 **A**);三候选原文见本工作区 `.workbuddy/memory/2026-09-21.md` 与 `交接单_StoryForge插件线-序2_部署与实例验收_20260921.md`。⛔ 本段不再保留副本,避免第二真相源。
|
||||
|
||||
### 冷启动备忘(每一棒照做)
|
||||
|
||||
1. **开机第 0 步**:`export PATH="/bin:/usr/bin:$PATH"` —— **必须**,否则 `dirname`/`ls`/`mkdir`/`md5sum` 全报 `command not found`(真因是 `/bin` 不在 PATH,**不是 coreutils 缺失**,2026-09-21 自证)。
|
||||
⛔ **不抢全局执行锁**:本线文档与产物**全部落本工作区**,那些文件**没有第二方会碰** ⇒ **没有锁的对手方**;`dsh-server-docs/交接单/.exec-lock` 对本线**不适用**(用户 2026-09-21 三次纠正,判据已固化进 `MEMORY.md §六`)。本线唯一的"锁"是**排期铁律:同一时刻只挂一个接续棒**(见第 2 条)—— 它治的是"本线自己别开两个会话重复开工",**不是跨会话争抢**。
|
||||
🔴 **只有要动共享资源时**才需要那把锁,共三类:① 写 `dsh-server-docs`(本线**默认不做**)② 改平台代码 `D:\github\dsh_shenxian\src` ③ 服务器操作(scp / 重启 / 铺插件)。那时:`bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "<会话名>"`,抢不到 ⇒ **只报告 + 立刻停**(R9:⛔ 不删锁、不接管)。⚠️ `.exec-lock` 是**目录**(内含 `OWNER` 文本),**不是文件**。
|
||||
2. 收尾四件套:单子 §八 回填 → 本文件 §2 推进到下一序 → 追加当日 memory 小节(⛔ 不重写别人段落)→ 登记下一棒(`scheduledAt` = 收口 + 5~8 分钟,**同一时刻只挂一个**;含拍板项或被外部窗口阻塞则改为告知「链条已暂停」)。
|
||||
⚠️ **本线默认不持锁 ⇒ 收尾没有"释放锁"这一步**;仅当本次真的动过共享资源(第 1 条那三类)才 `--release-exec`。
|
||||
Reference in new issue
Block a user