269 lines
13 KiB
Python
269 lines
13 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""resident-rules.py —— 「常驻规则」的可移植化:快照 / 校验 / 注入 / 环境自检
|
|||
|
|
|
|||
|
|
为什么需要它
|
|||
|
|
────────────
|
|||
|
|
项目常驻规则写在**工作区**的 `CODEBUDDY.md`(每会话自动注入 ⇒ 动作前必然生效)。
|
|||
|
|
但它是**工作区文件**:换电脑 / 换工作区路径 ⇒ 规则直接消失,而"技能"才是**可移植层**。
|
|||
|
|
本脚本 + 同目录 `references/常驻规则-快照.md` 把两者接起来:
|
|||
|
|
|
|||
|
|
CODEBUDDY.md(工作区·权威·动作前生效)
|
|||
|
|
│ --snapshot(抽取 §1/§3/§4/§5/§6/§8 全文)
|
|||
|
|
▼
|
|||
|
|
references/常驻规则-快照.md(技能内·可移植·随技能走)
|
|||
|
|
│ --check / --inject(到新环境的 CODEBUDDY.md)
|
|||
|
|
▼
|
|||
|
|
新环境的 CODEBUDDY.md
|
|||
|
|
|
|||
|
|
设计红线(避免造"第二真相源")
|
|||
|
|
──────────────────────────────
|
|||
|
|
1. **权威方向单向**:`CODEBUDDY.md` 是权威,快照是**它的副本**(`--snapshot` 生成,不手改)。
|
|||
|
|
2. **默认只报不改**:`--check` 只报告差异(退出码 1)—— 与文档库「体检只报不改」同规。
|
|||
|
|
3. **注入只动标记块**:`--inject` 只替换 `<!-- BEGIN resident-rules … --> … <!-- END -->` 之间的内容;
|
|||
|
|
首次注入**不自动建块**(除非 `--init`),避免与人工撰写的内容重复成两处。
|
|||
|
|
4. **区分环境无关 / 环境相关**:环境相关项(绝对路径 / IP / hooks 路径 / 工具位置)
|
|||
|
|
注入后**必须按新环境核对** —— 脚本给「待核清单」,不假装能自动配好。
|
|||
|
|
|
|||
|
|
用法
|
|||
|
|
────
|
|||
|
|
python3 resident-rules.py --snapshot # 由 CODEBUDDY.md 重生成技能内快照
|
|||
|
|
python3 resident-rules.py --check [--goal <CODEBUDDY.md>] # 校验目标环境(默认只报,rc=1 = 有差异)
|
|||
|
|
python3 resident-rules.py --env-check # 环境自检:路径 / hooks / 锁脚本 / 工作区
|
|||
|
|
python3 resident-rules.py --inject [--goal <CODEBUDDY.md>] [--init] # 注入(需已存在标记块)
|
|||
|
|
退出码:0 = 一致/成功;1 = 有差异(未改任何文件);2 = 用法/环境异常
|
|||
|
|
"""
|
|||
|
|
import io
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import re
|
|||
|
|
import sys
|
|||
|
|
|
|||
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|||
|
|
SKILL = os.path.dirname(HERE)
|
|||
|
|
|
|||
|
|
# 🔴 2026-10-02 修(**同款事故复发**:路径靠"层级"推导 ⇒ 静默写到错地方,校验还全绿)
|
|||
|
|
# 事故:本脚本实际在 `<技能>/references/dsh-env-bootstrap/`,**比设计假设多一层** ⇒
|
|||
|
|
# `SNAP = SKILL/references/…` 算成 `…/dsh-local-env/references/references/常驻规则-快照.md`
|
|||
|
|
# (**双层 references**)⇒ `--check` 报「缺少快照」、`--snapshot` 则会在别处**新建一份**,
|
|||
|
|
# 而真身留在 `bootstrap/` 里 ⇒ **两份快照开始分叉**,且两边看起来都正常。
|
|||
|
|
# 🔴 与 `wb-result-hook.py` 那次(脚本一移,`dirname(dirname())` 就指错 ⇒ 台账静默写到别处,
|
|||
|
|
# 而"测试全绿、rc=0")**是同一个病** ⇒ 教训:**路径靠内容标志锚定,⛔ 绝不靠脚本在第几层**。
|
|||
|
|
# ⇒ 现在:**认内容,不认层级** —— 本目录找同名快照;没有就上溯找;都没有才把新快照落**本目录**。
|
|||
|
|
_SNAP_NAME = '常驻规则-快照.md'
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _resolve_snap():
|
|||
|
|
"""快照路径=**认内容**(同名文件),⛔ 不认"我在第几层"。返回找到的路径,或 None。"""
|
|||
|
|
cur = HERE
|
|||
|
|
for _ in range(5): # 覆盖 skills/<name>/references/<sub>/ 等实际摆法
|
|||
|
|
for cand in (os.path.join(cur, _SNAP_NAME),
|
|||
|
|
os.path.join(cur, 'references', _SNAP_NAME)):
|
|||
|
|
if os.path.isfile(cand):
|
|||
|
|
return cand
|
|||
|
|
parent = os.path.dirname(cur)
|
|||
|
|
if parent == cur:
|
|||
|
|
break
|
|||
|
|
cur = parent
|
|||
|
|
return None # 真没有 ⇒ 报「缺少快照」,由 --snapshot 在本目录新建
|
|||
|
|
|
|||
|
|
|
|||
|
|
_SNAP_FOUND = _resolve_snap()
|
|||
|
|
# 找不到 ⇒ 落**本脚本同目录**(⛔ 不再是双层 references),生成后与本脚本同处,最省心
|
|||
|
|
SNAP = _SNAP_FOUND or os.path.join(HERE, _SNAP_NAME)
|
|||
|
|
DEFAULT_GOAL = r'E:/ProgramData/AIProject/ai1net-dsh-server/CODEBUDDY.md'
|
|||
|
|
|
|||
|
|
# 要随技能走的章节("动作前必须生效"的那些 + 事故级事实)
|
|||
|
|
SECTIONS = ['## 1. 提问判据', '## 3. 红线', '## 4. 提交边界', '## 5. 规划与执行分离',
|
|||
|
|
'## 6. 并发纪律', '## 8. ']
|
|||
|
|
|
|||
|
|
# ★骨架锚点:这几条丢了就等于规则没了 —— 校验时逐条查存在性
|
|||
|
|
#
|
|||
|
|
# 🔴 2026-10-02 改(**假红真因**):原表是**整句字面匹配**,而权威文件会改写措辞 ⇒
|
|||
|
|
# 反复报「✗ 缺失」→ 诱导人**反复重生成快照**,⛔ 而重生成解决不了关键词不匹配(纯属白忙)。
|
|||
|
|
# 实测:权威写 `R7 只管`(表里 `只适用于`)/ 锁以 `域锁…不带 --domains 退化独占` 表达
|
|||
|
|
# (表里 `先抢全局执行锁`)⇒ 四条假红,**而这些规则实际都在**。
|
|||
|
|
# ⇒ 修法:**每条锚点改成「稳定语义锚」**(选不会被改写掉的那个词/标记),⛔ 不再匹配整句。
|
|||
|
|
# 仍认不到的(如 U27 权威侧确实没有)**照实报缺** —— 宁可少一条,⛔ 也不能把"规则在"报成"不在"。
|
|||
|
|
ANCHORS = [
|
|||
|
|
('A1', '超过现有判断方法边界'),
|
|||
|
|
('A2', '征询句'),
|
|||
|
|
('A3', '四件或七件套必跑', '占位'), # 由下方 canary 覆盖
|
|||
|
|
('R7', '禁未经确认的批量'),
|
|||
|
|
# 🔴 `R7 只管` ⇄ 旧 `只适用于` 是同一规则的两种写法 ⇒ 锚点取**两者共有的**「不是我的 lane」
|
|||
|
|
('R7-边界', '不是我的 lane'),
|
|||
|
|
('R9', '人工删锁'),
|
|||
|
|
('R11', '正向迭代'),
|
|||
|
|
# 🔴 U27「目标不打折,路径取最小代价」在权威文件里**换了编号与措辞**:旧编号 U27 ⇒ 现行
|
|||
|
|
# `要解决问题,不将就妥协`(降级/延期/静默兜底都不算解决)+ R11。**实质规则仍在**,
|
|||
|
|
# ⛔ 不是被删了 ⇒ 锚到现行那句的稳定词,⛔ 不靠放宽判据把它"弄绿"。
|
|||
|
|
('U27', '不将就妥协'),
|
|||
|
|
# 🔴 现行锁模型=「域锁为主 / 不带 --domains ⇒ 退化独占」;旧口径叫「全局执行锁」⇒ 锚到真正稳定的那句
|
|||
|
|
('L1', '退化独占'),
|
|||
|
|
('L2', '锁的生命周期'),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
MARK_BEGIN = '<!-- BEGIN resident-rules (generated by skills/dsh-env-bootstrap · 勿手改块内) -->'
|
|||
|
|
MARK_END = '<!-- END resident-rules -->'
|
|||
|
|
|
|||
|
|
# 环境相关项:注入/换机后**必须按新环境核对**(不是"自动配好")
|
|||
|
|
ENV_ITEMS = [
|
|||
|
|
('工作区根', r'E:/ProgramData/AIProject/ai1net-dsh-server'),
|
|||
|
|
('文档库', r'D:/github/dsh_shenxian/dsh-server-docs'),
|
|||
|
|
('代码仓', r'D:/github/dsh_shenxian'),
|
|||
|
|
('备份目录', r'/opt/dsh/backups'),
|
|||
|
|
('服务器', 'bt-server(47.77.182.89,SSH **22**;别名里的 32022 已失效)'),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def rd(p):
|
|||
|
|
try:
|
|||
|
|
return io.open(p, encoding='utf-8', newline='').read()
|
|||
|
|
except OSError:
|
|||
|
|
return ''
|
|||
|
|
|
|||
|
|
|
|||
|
|
def section_of(text, head):
|
|||
|
|
"""取 `head` 章节到下一个同级 `## ` 之间的内容(含标题行)。"""
|
|||
|
|
i = text.index(head)
|
|||
|
|
m = re.search(r'^## ', text[i + len(head):], re.M)
|
|||
|
|
return text[i:i + len(head) + (m.start() if m else len(text) - i - len(head))].rstrip() + '\n'
|
|||
|
|
|
|||
|
|
|
|||
|
|
def snapshot(src):
|
|||
|
|
t = rd(src)
|
|||
|
|
if not t:
|
|||
|
|
raise SystemExit('ERROR: 读不到源文件 %s' % src)
|
|||
|
|
parts = []
|
|||
|
|
for h in SECTIONS:
|
|||
|
|
if h == '## 3. 红线':
|
|||
|
|
h = '## 3. 红线 R1' # 实际标题含编号,用前缀匹配
|
|||
|
|
i = t.find(h)
|
|||
|
|
if i < 0:
|
|||
|
|
continue
|
|||
|
|
j = t.find('\n## 4.', i)
|
|||
|
|
parts.append(t[i:j if j > 0 else len(t)].rstrip() + '\n')
|
|||
|
|
continue
|
|||
|
|
if t.find(h) < 0:
|
|||
|
|
parts.append('<!-- 源文件里没有 `%s` 章节 -->\n' % h)
|
|||
|
|
continue
|
|||
|
|
parts.append(section_of(t, h))
|
|||
|
|
body = '\n---\n\n'.join(parts)
|
|||
|
|
head = ('# 常驻规则 · 可移植快照(由 `scripts/resident-rules.py --snapshot` 生成,勿手改)\n\n'
|
|||
|
|
'> **这是「工作区 `CODEBUDDY.md` 关键章节」的副本**,随技能走 ⇒ 换电脑 / 换工作区也能带走。\n'
|
|||
|
|
'> ⚠️ **权威方向单向**:`CODEBUDDY.md` 是权威,本文件是它的副本(重生成用 `--snapshot`)。\n'
|
|||
|
|
'> ⚠️ **环境相关项**(绝对路径 / IP / hooks 路径 / 工具位置)注入后**必须按新环境核对**:见文末「待核清单」。\n\n'
|
|||
|
|
'## 骨架锚点(校验用;丢了就等于规则没了)\n\n')
|
|||
|
|
anch = '\n'.join('- `%s` — %s' % (k, v) for k, v, *_ in
|
|||
|
|
[(a[0], a[1]) for a in ANCHORS]) + '\n'
|
|||
|
|
tail = ('\n---\n\n## 待核清单(换环境后逐项核对,脚本不代改)\n\n'
|
|||
|
|
+ '\n'.join('- [ ] **%s** %s' % (k, v) for k, v in ENV_ITEMS) + '\n')
|
|||
|
|
out = head + anch + '\n---\n\n' + body + tail
|
|||
|
|
os.makedirs(os.path.dirname(SNAP), exist_ok=True)
|
|||
|
|
io.open(SNAP, 'w', encoding='utf-8', newline='\n').write(out)
|
|||
|
|
return len(out), len(parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def check(goal):
|
|||
|
|
t = rd(goal)
|
|||
|
|
if not t:
|
|||
|
|
print('ERROR: 读不到目标 %s' % goal)
|
|||
|
|
return 2
|
|||
|
|
snap = rd(SNAP)
|
|||
|
|
if not snap:
|
|||
|
|
print('ERROR: 缺少技能内快照(先跑 --snapshot)')
|
|||
|
|
return 2
|
|||
|
|
print('=== 常驻规则校验|目标 = %s ===' % goal)
|
|||
|
|
bad = 0
|
|||
|
|
for a in ANCHORS:
|
|||
|
|
k, phrase = a[0], a[1]
|
|||
|
|
if phrase.startswith('四件或七件套'):
|
|||
|
|
ok = ('七件套' in t) or ('四件套' in t)
|
|||
|
|
else:
|
|||
|
|
ok = phrase in t
|
|||
|
|
print(' %-10s %s %s' % (k, '✓' if ok else '✗ 缺失', phrase[:40]))
|
|||
|
|
bad += 0 if ok else 1
|
|||
|
|
sec_ok = sum(1 for h in ('## 1. 提问判据', '## 3. 红线', '## 4. 提交边界',
|
|||
|
|
'## 5. 规划与执行分离', '## 6. 并发纪律') if h in t)
|
|||
|
|
print(' 章节存在性:%d/5' % sec_ok)
|
|||
|
|
bad += 5 - sec_ok
|
|||
|
|
print('结论:%s' % ('✅ 关键规则齐备' if bad == 0 else '⚠️ 有 %d 处缺失/漂移 ⇒ 用 --inject(或人工补齐)' % bad))
|
|||
|
|
return 1 if bad else 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def env_check():
|
|||
|
|
print('=== 环境自检(换机后逐项确认)===')
|
|||
|
|
bad = 0
|
|||
|
|
for k, v in ENV_ITEMS:
|
|||
|
|
if k in ('服务器', '备份目录'):
|
|||
|
|
print(' %-8s %-52s (需 ssh 侧核对)' % (k, v))
|
|||
|
|
continue
|
|||
|
|
ex = os.path.exists(v)
|
|||
|
|
print(' %-8s %-52s %s' % (k, v, '✓ 存在' if ex else '✗ 不存在'))
|
|||
|
|
bad += 0 if ex else 1
|
|||
|
|
# hooks 里的绝对路径(工作区搬迁后最常见的坑)
|
|||
|
|
st = r'E:/ProgramData/.workbuddy/settings.json'
|
|||
|
|
if not os.path.exists(st):
|
|||
|
|
st = os.path.expanduser('~/.workbuddy/settings.json')
|
|||
|
|
try:
|
|||
|
|
h = json.loads(rd(st)).get('hooks', {})
|
|||
|
|
except Exception:
|
|||
|
|
h = {}
|
|||
|
|
cmds = [x.get('command', '') for v in h.values() for it in v for x in it.get('hooks', [])
|
|||
|
|
if x.get('type') == 'command']
|
|||
|
|
print(' hooks 命令 %d 条:' % len(cmds))
|
|||
|
|
for c in cmds:
|
|||
|
|
paths = re.findall(r'[A-Za-z]:/[^\s"]+?\.(?:py|sh|exe)', c)
|
|||
|
|
miss = [q for q in paths if not os.path.exists(q.replace('/', os.sep))]
|
|||
|
|
flag = '✗ 路径失效' if miss else '✓'
|
|||
|
|
print(' %s %s' % (flag, c[:100]))
|
|||
|
|
bad += 1 if miss else 0
|
|||
|
|
return 1 if bad else 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def inject(goal, init=False):
|
|||
|
|
t = rd(goal)
|
|||
|
|
if not t:
|
|||
|
|
print('ERROR: 读不到目标 %s' % goal)
|
|||
|
|
return 2
|
|||
|
|
snap = rd(SNAP)
|
|||
|
|
if not snap:
|
|||
|
|
print('ERROR: 缺少技能内快照(先跑 --snapshot)')
|
|||
|
|
return 2
|
|||
|
|
body = snap.split('## 待核清单', 1)[0]
|
|||
|
|
block = MARK_BEGIN + '\n' + body.strip() + '\n' + MARK_END + '\n'
|
|||
|
|
if MARK_BEGIN in t:
|
|||
|
|
new = re.sub(re.escape(MARK_BEGIN) + r'.*?' + re.escape(MARK_END) + r'\n?',
|
|||
|
|
block, t, flags=re.S)
|
|||
|
|
io.open(goal, 'w', encoding='utf-8', newline='').write(new)
|
|||
|
|
print('✓ 已替换标记块内内容(块外人工内容未动)')
|
|||
|
|
return 0
|
|||
|
|
if not init:
|
|||
|
|
print('⚠️ 目标里没有标记块 ⇒ **不自动注入**(避免与人工撰写的内容重复成两处)。\n'
|
|||
|
|
' 要建立标记块并注入:加 --init(会**追加**到目标文末,不动既有内容)')
|
|||
|
|
return 1
|
|||
|
|
io.open(goal, 'w', encoding='utf-8', newline='').write(t.rstrip('\n') + '\n\n' + block)
|
|||
|
|
print('✓ 已追加标记块(既有内容未动);建议随后人工去重并 `--check`')
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main(argv):
|
|||
|
|
goal = DEFAULT_GOAL
|
|||
|
|
if '--goal' in argv:
|
|||
|
|
goal = argv[argv.index('--goal') + 1]
|
|||
|
|
if '--snapshot' in argv:
|
|||
|
|
n, k = snapshot(goal)
|
|||
|
|
print('✓ 已由 %s 生成快照:%d 字符 / %d 个章节 → %s' % (goal, n, k, os.path.relpath(SNAP, SKILL)))
|
|||
|
|
return 0
|
|||
|
|
if '--env-check' in argv:
|
|||
|
|
return env_check()
|
|||
|
|
if '--inject' in argv:
|
|||
|
|
return inject(goal, init=('--init' in argv))
|
|||
|
|
if '--check' in argv:
|
|||
|
|
return check(goal)
|
|||
|
|
print(__doc__)
|
|||
|
|
return 2
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
raise SystemExit(main(sys.argv[1:]))
|