dsh-offhours 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -13
- package/bin/offhours.mjs +33 -16
- package/feature-worktrees/README.md +361 -0
- package/lib/client.js +29 -1
- package/mechanism/README.md +266 -0
- package/package.json +3 -2
- package/panel/client.js +29 -1
- package/panel/cordis.patch.yml +3 -0
- package/panel/index.js +18 -1
- package/panel/locale/en.json +5 -1
- package/panel/locale/zh.json +5 -1
- package/skills/offhours/SKILL.md +6 -5
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
DeepSeek Harness 社区插件:把看板里的待办,在**低谷/夜间**自动做掉。
|
|
4
4
|
|
|
5
5
|
- 只做一件事:`todo` 卡 → 每卡一个独立 git worktree → headless worker 干活并自证 → 证据写回卡 → 合并回收。
|
|
6
|
-
- 不需要 cron/systemd:插件进程内按 interval 自己扫;也可以手动 `offhours run --once`。
|
|
6
|
+
- 不需要 cron/systemd:插件进程内按 interval 自己扫;也可以手动 `npx dsh-offhours run --once`。
|
|
7
7
|
- 你的数据只在本地:看板是 `KANBAN.json`,状态/日志在 `stateDir`,不上传。
|
|
8
8
|
|
|
9
9
|
## 安装
|
|
@@ -14,26 +14,32 @@ dsh plugin --profile web add dsh-offhours
|
|
|
14
14
|
|
|
15
15
|
装完重启一次 `dsh web`(或让 HMR 生效),左侧出现「夜间跑批」面板。
|
|
16
16
|
|
|
17
|
+
> 为什么下面的命令写 `npx dsh-offhours`:安装插件不会把 CLI 加进全局 PATH;`npx` 会直接运行刚装好的同一个入口(首次会从 npm 取包)。如果你已经全局安装,可以简写成 `offhours`。
|
|
18
|
+
|
|
17
19
|
## 三步开始
|
|
18
20
|
|
|
19
21
|
```bash
|
|
20
22
|
# 1) 在你要跑批的 git 仓库里初始化(写配置 + 建/关联特性 + 生成看板)
|
|
21
23
|
cd ~/code/myapp
|
|
22
|
-
offhours init --repo . --name myapp
|
|
24
|
+
npx dsh-offhours init --repo . --name myapp
|
|
23
25
|
|
|
24
26
|
# 2) 体检(依赖、路径、worker 权限)
|
|
25
|
-
offhours doctor
|
|
27
|
+
npx dsh-offhours doctor
|
|
26
28
|
|
|
27
29
|
# 3) 建一张卡(自动盖当前会话;没有 session 的卡跑批不会捡)
|
|
28
|
-
offhours card add "给登录页补错误提示" \
|
|
30
|
+
npx dsh-offhours card add "给登录页补错误提示" \
|
|
29
31
|
--summary "目标:登录失败有提示。判据:npm test 通过。不做什么:不改后端。"
|
|
30
32
|
```
|
|
31
33
|
|
|
32
|
-
之后低谷时段自动跑;也可以用面板「立即跑」或 `offhours run --once`。
|
|
34
|
+
之后低谷时段自动跑;也可以用面板「立即跑」或 `npx dsh-offhours run --once`。
|
|
35
|
+
|
|
36
|
+
如果打开面板看到「先完成一次性初始化」,说明配置还没生成:在项目仓库执行上面的 `init`,刷新面板即可;不需要重装插件。
|
|
37
|
+
|
|
38
|
+
装完/升级插件后记得重启一次 `dsh web`,面板才会加载新代码。
|
|
33
39
|
|
|
34
40
|
## 配置
|
|
35
41
|
|
|
36
|
-
`~/.dsh/offhours/config.json`(`offhours init` 生成):
|
|
42
|
+
`~/.dsh/offhours/config.json`(`npx dsh-offhours init` 生成):
|
|
37
43
|
|
|
38
44
|
```json
|
|
39
45
|
{
|
|
@@ -62,13 +68,15 @@ offhours card add "给登录页补错误提示" \
|
|
|
62
68
|
|
|
63
69
|
| 命令 | 作用 |
|
|
64
70
|
|---|---|
|
|
65
|
-
| `offhours init --repo .` | 初始化配置/看板/特性 |
|
|
66
|
-
| `offhours doctor` | 依赖与权限体检(失败退出非 0) |
|
|
67
|
-
| `offhours card add "<标题>" --summary "<目标/判据/边界>"` | 建卡并盖章当前会话 |
|
|
68
|
-
| `offhours card list` | 列卡 |
|
|
69
|
-
| `offhours run --once` / `--dry-run` | 手动跑一轮 / 只看会挑哪张 |
|
|
70
|
-
| `offhours status` | 看板计数、心跳、最近状态(JSON) |
|
|
71
|
-
| `offhours boards` | 看板列表 |
|
|
71
|
+
| `npx dsh-offhours init --repo .` | 初始化配置/看板/特性 |
|
|
72
|
+
| `npx dsh-offhours doctor` | 依赖与权限体检(失败退出非 0) |
|
|
73
|
+
| `npx dsh-offhours card add "<标题>" --summary "<目标/判据/边界>"` | 建卡并盖章当前会话 |
|
|
74
|
+
| `npx dsh-offhours card list` | 列卡 |
|
|
75
|
+
| `npx dsh-offhours run --once` / `--dry-run` | 手动跑一轮 / 只看会挑哪张 |
|
|
76
|
+
| `npx dsh-offhours status` | 看板计数、心跳、最近状态(JSON) |
|
|
77
|
+
| `npx dsh-offhours boards` | 看板列表 |
|
|
78
|
+
|
|
79
|
+
> 不想走 npx:装完后 CLI 也在 profile 的 `node_modules/.bin/offhours`(例如 `~/.dsh/profiles/web/node_modules/.bin/offhours`),可以直接用绝对路径调用。
|
|
72
80
|
|
|
73
81
|
## 依赖
|
|
74
82
|
|
package/bin/offhours.mjs
CHANGED
|
@@ -20,15 +20,16 @@ import {
|
|
|
20
20
|
|
|
21
21
|
const HELP = `dsh-offhours —— 夜间/低谷自动跑批(配一个 git 仓库就能用)
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
offhours init --repo <git仓库路径> [--name 名称] [--board KANBAN.json] [--feature 特性名]
|
|
25
|
-
offhours doctor
|
|
26
|
-
offhours card add "<标题>" --summary "<目标/判据>" [--tags auto] [--board id]
|
|
27
|
-
offhours card list [--board id]
|
|
28
|
-
offhours run [--once] [--dry-run] [--force] [--board id]
|
|
29
|
-
offhours status [--board id]
|
|
30
|
-
offhours boards
|
|
23
|
+
装完插件后不需要全局安装 CLI,用 npx 调用即可:
|
|
24
|
+
npx dsh-offhours init --repo <git仓库路径> [--name 名称] [--board KANBAN.json] [--feature 特性名]
|
|
25
|
+
npx dsh-offhours doctor
|
|
26
|
+
npx dsh-offhours card add "<标题>" --summary "<目标/判据>" [--tags auto] [--board id]
|
|
27
|
+
npx dsh-offhours card list [--board id]
|
|
28
|
+
npx dsh-offhours run [--once] [--dry-run] [--force] [--board id]
|
|
29
|
+
npx dsh-offhours status [--board id]
|
|
30
|
+
npx dsh-offhours boards
|
|
31
31
|
|
|
32
|
+
如果你已经全局安装了本包,也可以把 npx dsh-offhours 换成 offhours。
|
|
32
33
|
全局:--config <path>(默认 ~/.dsh/offhours/config.json)
|
|
33
34
|
|
|
34
35
|
约束:worker 需要 dsh、python3、bash、git、jq、flock;zstd 可选(成本统计)。
|
|
@@ -53,14 +54,14 @@ function parse(argv) {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
function die(msg, code = 1) {
|
|
56
|
-
console.error(`offhours: ${msg}`)
|
|
57
|
+
console.error(`dsh-offhours: ${msg}`)
|
|
57
58
|
process.exit(code)
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
function loadOrDie(flags) {
|
|
61
62
|
const { config, path, exists } = loadConfig(flags.config)
|
|
62
|
-
if (!exists) die(`没有配置:${path}\n先运行:offhours init --repo
|
|
63
|
-
if (!config.boards.length) die(`配置里没有看板:${path}\n先运行:offhours init --repo
|
|
63
|
+
if (!exists) die(`没有配置:${path}\n先运行:npx dsh-offhours init --repo <你的 git 仓库>`)
|
|
64
|
+
if (!config.boards.length) die(`配置里没有看板:${path}\n先运行:npx dsh-offhours init --repo <你的 git 仓库>`)
|
|
64
65
|
return { cfg: config, path }
|
|
65
66
|
}
|
|
66
67
|
|
|
@@ -104,8 +105,13 @@ function cmdInit(flags) {
|
|
|
104
105
|
writeFileSync(boardFile, '{\n "version": 1,\n "cards": []\n}\n', 'utf8')
|
|
105
106
|
}
|
|
106
107
|
mkdirSync(cfg.featuresRoot, { recursive: true })
|
|
108
|
+
// 合并已有登记表:init 一个新仓库不能把用户之前登记的仓库冲掉。
|
|
109
|
+
let registry = {}
|
|
110
|
+
try { registry = JSON.parse(readFileSync(join(cfg.featuresRoot, '.repos.json'), 'utf8')) || {} } catch { /* 首次初始化 */ }
|
|
111
|
+
if (!registry || typeof registry !== 'object' || Array.isArray(registry)) registry = {}
|
|
112
|
+
registry[name] = { path: repo, baseBranch: base }
|
|
107
113
|
writeFileSync(join(cfg.featuresRoot, '.repos.json'),
|
|
108
|
-
JSON.stringify(
|
|
114
|
+
JSON.stringify(registry, null, 2) + '\n', 'utf8')
|
|
109
115
|
saveConfig(cfgPath, cfg)
|
|
110
116
|
|
|
111
117
|
const env = {
|
|
@@ -118,6 +124,13 @@ function cmdInit(flags) {
|
|
|
118
124
|
let r = spawnSync(fb, args, { env, encoding: 'utf8' })
|
|
119
125
|
if (r.status !== 0) r = spawnSync(fb, ['ensure', feature, '--repos', name], { env, encoding: 'utf8' })
|
|
120
126
|
const featOut = String((r.stdout || r.stderr || '').trim()).split('\n').slice(-2).join('\n')
|
|
127
|
+
// feature CLI 可能把登记表改写成字符串;补回 baseBranch,且不丢已有仓库。
|
|
128
|
+
try {
|
|
129
|
+
let reg2 = JSON.parse(readFileSync(join(cfg.featuresRoot, '.repos.json'), 'utf8')) || {}
|
|
130
|
+
if (!reg2 || typeof reg2 !== 'object' || Array.isArray(reg2)) reg2 = {}
|
|
131
|
+
reg2[name] = { path: repo, baseBranch: base }
|
|
132
|
+
writeFileSync(join(cfg.featuresRoot, '.repos.json'), JSON.stringify(reg2, null, 2) + '\n', 'utf8')
|
|
133
|
+
} catch { /* 保留 feature 的写法 */ }
|
|
121
134
|
|
|
122
135
|
console.log(`✓ 配置:${cfgPath}`)
|
|
123
136
|
console.log(`✓ 仓库:${name} → ${repo}(基准分支 ${base})`)
|
|
@@ -126,13 +139,17 @@ function cmdInit(flags) {
|
|
|
126
139
|
if (featOut) console.log(featOut)
|
|
127
140
|
console.log('')
|
|
128
141
|
console.log('下一步:')
|
|
129
|
-
console.log(
|
|
130
|
-
console.log(
|
|
131
|
-
console.log(
|
|
142
|
+
console.log(' npx dsh-offhours doctor')
|
|
143
|
+
console.log(' npx dsh-offhours card add "第一张卡" --summary "目标/判据/不做什么"')
|
|
144
|
+
console.log(' npx dsh-offhours run --once')
|
|
132
145
|
}
|
|
133
146
|
|
|
134
147
|
function cmdDoctor(flags) {
|
|
135
|
-
const { config: cfg, path } = loadConfig(flags.config)
|
|
148
|
+
const { config: cfg, path, exists } = loadConfig(flags.config)
|
|
149
|
+
if (!exists) {
|
|
150
|
+
console.log(`未配置:${path}`)
|
|
151
|
+
console.log('先运行:npx dsh-offhours init --repo <你的 git 仓库>')
|
|
152
|
+
}
|
|
136
153
|
const rows = []
|
|
137
154
|
const fail = []
|
|
138
155
|
const hard = (name, ok, detail = '') => {
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# feature-worktrees —— 特性 worktree:会话主动关联 + 生命周期 + 提交强制
|
|
2
|
+
|
|
3
|
+
> 起因(用户 2026-10-13):`/root/hemou` 里堆了近 20 个 `devloop-round-N` 分支,
|
|
4
|
+
> 仓库目录名是工程名、分支名是轮次号,**看不出在开哪个特性,也没法并行管理**;
|
|
5
|
+
> 且要求「每次 hook 执行的 commit 都落在这个特性对应的 worktree 上」,并且要**跨工程**。
|
|
6
|
+
> 第二轮追加要求:钩子要**关联一个处理 worktree 的 skill**,会话动手前必须**主动关联**到 worktree,
|
|
7
|
+
> 并补齐三组生命周期操作——基础(新建/切换/恢复)、上下文恢复、回收(合并主干 + 回收 worker)。
|
|
8
|
+
|
|
9
|
+
## 0. 模型:worker ↔ worktree
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
会话(worker)──关联(按会话,DSH_SESSION_ID)──▶ 特性 ──┬─ 工程 A 的 worktree(feat/<特性名>)
|
|
13
|
+
└─ 工程 B 的 worktree(feat/<特性名>)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **关联是按会话的**:`/root/features/.sessions/<会话id>.json`。两个会话可以各开各的特性,互不抢
|
|
17
|
+
(全局 `.active` 只是兜底/展示——第一版用它做判据,实测两个会话会互相抢)。
|
|
18
|
+
- **位置即关联**:会话 cwd 落在某 worktree 内时,等于已关联该特性,无需额外动作。
|
|
19
|
+
- **动手前先关联**:不关联又去改「已登记工程的主检出」,钩子会拦下并告诉你该去哪(见 §3)。
|
|
20
|
+
- 规范随技能下发:`~/.dsh/skills/feature-worktree/`(主机级,本机所有会话的 skill 目录里都能看到)。
|
|
21
|
+
|
|
22
|
+
## 1. 布局(A 方案:特性在前、工程在后)
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
/root/features/
|
|
26
|
+
.sessions/<会话id>.json 会话 ↔ 特性 绑定(谁在哪个 worktree 上干活)
|
|
27
|
+
.repos.json 工程主检出登记表 {工程名: 主检出路径}
|
|
28
|
+
.active 兜底/展示用的当前特性(不再是判据)
|
|
29
|
+
<特性名>/
|
|
30
|
+
feature.json 元数据(参与工程 / 主检出 / worktree / 分支 / 状态)
|
|
31
|
+
journal.md 工作日志(追加型:这个 worktree 干了什么、下一手是什么)
|
|
32
|
+
<工程名>/ 该工程在本特性下的 git worktree(分支统一 feat/<特性名>)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- **特性名是人可读的 kebab-case**(如 `ai-coding-doc-consistency`),替代 `devloop-round-N`。
|
|
36
|
+
- `ls /root/features` 就是「我在开哪些特性」的答案;一个特性跨多个工程 = 同一目录下多个子目录。
|
|
37
|
+
- 工程主检出留在 `/root/<工程名>`,只用于主干与合并。
|
|
38
|
+
|
|
39
|
+
## 2. 命令(`feature`,装在 /root/.local/bin):四组操作
|
|
40
|
+
|
|
41
|
+
### 2.1 关联(动手前的第一步)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
feature status # 我这个会话现在关联在哪、那个 worktree 什么状态
|
|
45
|
+
feature attach <特性名> [工程名] # 关联(写 .sessions/<会话id>.json)
|
|
46
|
+
feature switch <特性名> [工程名] # 切换:解除旧关联再关联新的
|
|
47
|
+
feature detach # 解除关联
|
|
48
|
+
feature new <特性名> --repos a,b # 新建 worktree,**并自动关联本会话**
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 2.2 基础:新建 / 切换 / 恢复
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
feature new <特性名> --repos <工程名,...> [--title "..."] [--from <ref>]
|
|
55
|
+
feature resume <特性名> [工程名] # 恢复:关联 + 打印该 worktree 的完整上下文
|
|
56
|
+
feature list [--all|--branches] # 在用特性 / 含已回收 / 附未收编分支(✓=已并入 main)
|
|
57
|
+
feature path <特性名> [工程名] # 打印 worktree 路径
|
|
58
|
+
feature check --tool edit --file <p> # 用与钩子**同一套**规则判定(放行 0 / 阻断 2)
|
|
59
|
+
feature repo add <工程名> <主检出路径>;feature repo list;feature doctor
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 2.3 上下文恢复:这个 worktree 干了什么
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
feature context [特性名] [--full] # 分支/相对 main 的提交与改动规模/未提交/最近自动存档/日志/相关笔记与看板
|
|
66
|
+
feature journal <特性名> "<一句话>" # 追加工作日志(意图与结论;事实以 git log 为准)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 2.4 回收:验收后合并回主干、回收 worker
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
feature recycle <特性名> # 只打印计划(预检 + 将执行的动作)
|
|
73
|
+
feature recycle <特性名> --yes [--push] [--delete-branch] [--keep-worktree] [--no-redirect]
|
|
74
|
+
feature close <特性名> [--delete-branch] [--force] # 放弃:只拆 worktree,不合并
|
|
75
|
+
feature prune <特性名|--all> [--purge] [--yes] # 清理已回收路标 / 墓碑
|
|
76
|
+
feature relink <特性名|--all> [--yes] # 给已回收特性补/重建路标
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`recycle` 的动作:预检(worktree 干净、主检出干净)→ 主检出 `checkout main` →
|
|
80
|
+
`merge --no-ff feat/<特性名>` → 移除 worktree **并在原地留路标**(见 §2.6)→(可选)删分支/推主干 →
|
|
81
|
+
标记 `status=recycled`(记 `recycledAt`/`mergeCommit`/`retiredTo`)→ **解除相关会话关联**。
|
|
82
|
+
冲突时 `merge --abort` 并要求人工处理;默认**不推主干**。
|
|
83
|
+
|
|
84
|
+
`close`(放弃)刻意**不留路标**——代码没合进 main,指向 main 等于把人带错地方;它留的是
|
|
85
|
+
`CLOSED.md` 墓碑:代码在哪个分支、`git -C <主检出> show <分支>:<路径>` 怎么读出来。
|
|
86
|
+
|
|
87
|
+
### 2.6 路标:回收后旧路径仍可读(2026-10-15)
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
/root/features/<特性>/<工程> → /root/<工程> (符号链接 = 只读门牌)
|
|
91
|
+
旧路径(会话引用过) 代码现在住的地方(合并后的 main)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**起因(用户现场)**:会话是长命的,而回收原来是把 worktree 目录**拆掉**——对话里已经写下、
|
|
95
|
+
`@` 引用过、甚至当会话工作区用的那些绝对路径一夜之间全变死链(本机 `facet-constitution` /
|
|
96
|
+
`facet-contract` 两个已回收特性当时正处于该状态,任何开在那里的会话都读不到自己的文件)。
|
|
97
|
+
修法不是事后补救,而是改「回收」的语义:**搬家并留下门牌**,而不是销毁。
|
|
98
|
+
|
|
99
|
+
| 动作 | 命令 | 保证 |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| 读旧路径 | 直接 `cat` / `@路径` / `grep` | 放行(内容 = 主检出当前状态) |
|
|
102
|
+
| 写旧路径 | `edit`/`write`/`git commit` | **拦**(规则 2c);git 会把路标解析回主检出 |
|
|
103
|
+
| 清理 | `feature prune <特性名> --yes` | 只 `rm` 符号链接;目标是真实目录就拒绝 |
|
|
104
|
+
| 补回 | `feature relink <特性名\|--all> --yes` | 早期回收/被 prune 删掉的路标重新补上 |
|
|
105
|
+
| 连记录清掉 | `feature prune <特性名> --purge --yes` | journal 保留为 `<特性名>.history-<时间>.md` |
|
|
106
|
+
|
|
107
|
+
`retiredTo`/`retiredAt` 同时记进 `feature.json`(注册表事实),路标本身是磁盘事实——`list --all`、
|
|
108
|
+
`context`、`doctor`、`audit` 都按「已回收 + 路标 → 主检出」显示,而不是报 `[缺失]`。
|
|
109
|
+
`session-start.sh` 也不再把这些特性报成「在用特性」:只报在用的,已回收的另起一行并说明写到那里会被拦。
|
|
110
|
+
|
|
111
|
+
### 2.7 未值守:按需创建 worktree + 权限预检(2026-10-15)
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
feature ensure <特性名> --repos <工程名,...> [--title ...] # 幂等入口:不在就建、在就核对+关联本会话
|
|
115
|
+
feature preflight [--repos <工程名,...>] [--quiet] # 权限体检:免批准就能建 worktree 吗
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**要解决的事**(用户现场):会话一旦需要 worktree 就该**按需创建**,而且那两处目录必须提前具备权限;
|
|
119
|
+
不能让「未值守跑批」跑到一半才发现要人批准。当天 23:16 的真实失败正是两连击:
|
|
120
|
+
`/root/features is read-only`(沙箱)→ 改用 DSH 自带 `create_worktree` → `unknown option: --no-lazy-fetch`。
|
|
121
|
+
|
|
122
|
+
**为什么 `workspace-write` 下这件事结构上做不到**(不是配置没调好):
|
|
123
|
+
|
|
124
|
+
| 建一个 worktree 要写的地方 | 在不在会话工作区内 |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| `/root/features/<特性>/<工程>/`(新目录 + 检出) | ✗ |
|
|
127
|
+
| `<repo>/.git/worktrees/<名>/` 与共享 git 元数据(连 worktree 里的 `git commit` 也写这里) | ✗ |
|
|
128
|
+
|
|
129
|
+
而 `workspace-write` 只有**一个**可写根:`dsh-sandbox-local` 的 bwrap 档是
|
|
130
|
+
`--ro-bind / /` + 仅 `--bind <workspaceRoot>` + `--tmpfs /tmp`(`writableRoots` = 工作区 + `/tmp`
|
|
131
|
+
+ tmpdir,只有这三项);`dsh-sandbox-policy` 也只认 `mode`/`workspaceRoot`,没有额外可写路径白名单。
|
|
132
|
+
**实测**(用 `bwrap` 复刻同一配方):`workspace-write` → 特性根不可写、git 元数据不可写;
|
|
133
|
+
`danger-full-access` → 可写。
|
|
134
|
+
|
|
135
|
+
**本机的处置**(放开沙箱,边界改由 `feature-guard` 承担):
|
|
136
|
+
|
|
137
|
+
| 对象 | 生效点 | 是否需要重启 |
|
|
138
|
+
| --- | --- | --- |
|
|
139
|
+
| 交互/网页会话(dsh-web) | `/etc/systemd/system/dsh-web.service.d/30-permission-mode.conf` 的 `Environment=DSH_PERMISSION_MODE=danger-full-access` | 需要(`daemon-reload` + 重启 `dsh-web`) |
|
|
140
|
+
| 未值守 worker(nightshift 用 `dsh --profile headless`) | `/root/.dsh/profiles/headless/cordis.patch.yml` 里对 `sandbox-policy` 的覆盖 | 不需要(每次启动组合 profile) |
|
|
141
|
+
|
|
142
|
+
`dsh-base` 用同一个变量决定沙箱模式**与** approval:`danger-full-access ⇒ approval never`,
|
|
143
|
+
所以这一处同时消掉「需要批准」这个死结。放开后的边界:feature-guard 钩子(登记工程主检出上的
|
|
144
|
+
写与提交一律拦、已回收路标只读)、`scan.sh` 的机械约束、以及 `feature preflight` 这个可执行体检。
|
|
145
|
+
|
|
146
|
+
`preflight` 做的是**真探针而不是推断**:真写 `/root/features`、真写 `<repo>/.git`、真跑一次
|
|
147
|
+
`git worktree add --no-checkout` 再 `remove`;失败会打印确切修法。它已并入 `feature doctor`。
|
|
148
|
+
|
|
149
|
+
**DSH 自带的 `create_worktree` 工具已在本机停用**(web profile patch 里 `- id: tool-worktree /
|
|
150
|
+
disabled: true`):它硬要求 git ≥2.45 并传 `--no-lazy-fetch`(本机 git 2.43.0 必失败),而且它的
|
|
151
|
+
目录布局绕过 `/root/features/<特性>/<工程>` 的注册表与守卫。按需建一律走 `feature ensure` / `feature new`。
|
|
152
|
+
|
|
153
|
+
### 2.5 运维:体检与「把主检出里的滞留改动搬走」
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
feature audit # 只读体检(可复跑):主检出分支/未提交、各 worktree 的改动与领先落后、
|
|
157
|
+
# 未推送、已并入 main / 被取代(给出确切回收命令)、孤儿 worktree
|
|
158
|
+
feature rescue <特性名> [--project 工程] # 只打印计划
|
|
159
|
+
feature rescue <特性名> --yes # 把主检出里滞留的未提交改动搬进该特性的 worktree
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`rescue` 的存在理由:主检出里可能压着**换制度之前**留下的未提交改动,而主检出现在
|
|
163
|
+
**不许改也不许提交**(规则 3)。做法是 `stash push -u`(含未跟踪)→ 在目标 worktree
|
|
164
|
+
`stash apply`(stash 是**仓库级**的,跨 worktree 可用)→ **保留 stash 作备份**,核实后由人
|
|
165
|
+
`git stash drop`。前置:目标 worktree 必须干净(否则两堆改动会打架,它会拒绝并要求先处理)。
|
|
166
|
+
|
|
167
|
+
## 3. 强制规则(`feature-guard.sh`,主机级 PreToolUse 钩子)
|
|
168
|
+
|
|
169
|
+
判定顺序(**宁可放行,唯一例外是登记工程主检出上的写与提交**):
|
|
170
|
+
|
|
171
|
+
1. `FEATURE_ALLOW_MAIN=1`(或 60 分钟内创建过的 `/root/features/.allow-main`)→ 放行(人工豁免)
|
|
172
|
+
2. 目标路径已在某个**在用**特性 worktree 内 → 放行(cwd/路径优先,多特性并行互不干扰)
|
|
173
|
+
2b. **命令的有效目标目录**(最后一个 `-C` / `cd`,取不到才退回会话 cwd)落在在用特性 worktree 内 → 放行
|
|
174
|
+
2c. 目标是**已回收特性留下的路标**(原 worktree 路径 → 主检出的符号链接)→ 读放行,写/提交 **`exit 2`**
|
|
175
|
+
阻断,并说明「这是只读门牌、代码已在 main、要改就开新特性」。(2026-10-15 随路标一起加的;
|
|
176
|
+
它不是多余的:规则 2 只认在用特性,路标因此不会走 2 的放行分支。)
|
|
177
|
+
3. 否则,若目标仓库根是**某个特性的主检出**,且本次是「写」(`edit`/`write`)或「会产生 commit 的
|
|
178
|
+
git 子命令」(`commit|merge|rebase|cherry-pick|revert|am`)→ **`exit 2` 阻断**,并分别列出
|
|
179
|
+
**在用**候选 worktree(`*` 标本会话关联、`·` 标 `.active`)与**已回收**特性(路标可读、不是干活的地方)
|
|
180
|
+
4. 其余(非登记工程、只读命令、没有登记特性)→ 放行
|
|
181
|
+
|
|
182
|
+
**判据与 `.active` 无关**:只要一个工程被纳入特性体系,它的主检出就不再是干活的地方;
|
|
183
|
+
**已回收特性也仍然「占着」这条纪律**(它的代码就住在 main 里),所以回收之后再想在主检出上直接
|
|
184
|
+
commit,仍然会被拦——要改就 `feature new` 开新特性。`.active` 只用于「同一工程有多个特性时挑首选」
|
|
185
|
+
与展示。特性 `close`(放弃、删掉注册记录)之后主检出即恢复可写。
|
|
186
|
+
|
|
187
|
+
> **规则 2b 是 2026-10-13 现场补的缺陷修复**:第一版把会话 cwd 也塞进候选集并「任一候选命中主检出
|
|
188
|
+
> 即拦」,于是当会话工作区是主检出时,`cd <worktree> && git commit` 与 `git -C <worktree> commit`
|
|
189
|
+
> 被**误判**成主检出提交(另一个会话实测被拦 3 次,只能靠 `FEATURE_ALLOW_MAIN=1` 绕过)。
|
|
190
|
+
> 正解是**判定命令的有效目标**,而不是「候选里任一命中」——见 §9 的现场记录。
|
|
191
|
+
|
|
192
|
+
### 明确不做的(负向保证)
|
|
193
|
+
|
|
194
|
+
- **不是沙箱**:`FEATURE_ALLOW_MAIN` 与哨兵文件都是**可见、可审计**的逃生门,防的是「工作
|
|
195
|
+
落错地方」这类误操作,不防恶意。哨兵文件默认 60 分钟过期,避免忘了删导致长期失效。
|
|
196
|
+
- **路标只保证读、且只覆盖被跟踪的内容**:它是符号链接,`git` 会解析回主检出,所以对它的写与提交
|
|
197
|
+
被规则 2c 挡住,不会变成绕过制度的口子;worktree 里**被忽略/未跟踪**的临时产物(build 输出等)
|
|
198
|
+
随 `worktree remove` 一起消失——预检只保证「没有未提交的跟踪改动」。`prune` 只 `rm` 符号链接,
|
|
199
|
+
发现目标是真实目录就拒绝。
|
|
200
|
+
- **拦不住 bash 直接写文件**:`echo >`、`sed -i`、heredoc 落盘不在拦截范围(要拦就得解析 shell
|
|
201
|
+
语义,误报代价不可接受)。但那条路产出的改动**仍然会在提交时被规则 3 挡住**,所以
|
|
202
|
+
「任何 commit 只落在特性 worktree」这条硬要求不受影响。
|
|
203
|
+
- **不自动搬移改动**:git 的每个 worktree 有自己的索引与 HEAD,在 A 处暂存的改动**无法**提交到
|
|
204
|
+
B 处。所以设计是「拦住并要求在正确的 worktree 里干活」,而不是「事后把提交改投别处」。
|
|
205
|
+
|
|
206
|
+
## 4. 安装与接线
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
bash /root/plugins/feature-worktrees/install.sh
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
装到(**符号链接**,事实源只有本目录一处):
|
|
213
|
+
`/root/.local/bin/feature`、`/root/.dsh/hooks/{feature,feature-guard,pre-tool,post-edit}*.sh`、
|
|
214
|
+
`/root/.dsh/hooks/dsh-hooks.json`。
|
|
215
|
+
|
|
216
|
+
再让 profile 指向主机级配置(`/root/.dsh/profiles/web/cordis.patch.yml` 的 `hooks-claude-code` 行):
|
|
217
|
+
|
|
218
|
+
```yaml
|
|
219
|
+
configPath: /root/.dsh/hooks/dsh-hooks.json
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
> 为什么把钩子配置从 `/root/hemou/scripts/dsh-hooks.json` 迁到主机级:本能力是**跨工程**的,
|
|
223
|
+
> 主机级行为寄生在某个项目仓库里,那个仓库一移动/清理就全失效。
|
|
224
|
+
|
|
225
|
+
## 5. 钩子链(PreToolUse / PostToolUse)
|
|
226
|
+
|
|
227
|
+
| 时机 | 顺序 | 作用 |
|
|
228
|
+
| --- | --- | --- |
|
|
229
|
+
| `SessionStart` | `session-start.sh` | **首轮之前注入 2–3 行规则**(未关联给「先关联」指引、已关联报出归属、cwd 已在**在用** worktree 内则不打扰;cwd 落在**已回收路标**里或绑定指向已回收特性时,改报「旧路径可读、这里不能干活」)——让会话**主动**关联,而不是被拦出来。只用 `feature_slugs_active`:已回收的不再被报成「在用特性」 |
|
|
230
|
+
| PreToolUse | `feature-guard.sh` → `<repo>/scripts/devloop-hook-commit-guard.sh` | 特性守卫;仓库级提交前守门(hemou 有) |
|
|
231
|
+
| PostToolUse | `<repo>/scripts/devloop-hook-build.sh` → `autosave-hook.sh` | 编辑 `.go` 后 build+vet;语法自洽的改动自动提交并推 gitea |
|
|
232
|
+
|
|
233
|
+
**字段解析纪律**:钩子命令只把 stdin 的 JSON **原样转交**,所有字段一律在各脚本内用 `jq` 取。
|
|
234
|
+
2026-10-11 那版把整段 JSON 当路径传参(`git -C "$p"`、`bash script "$p"`),使三个钩子
|
|
235
|
+
**全部静默失效**——autosave 自 2026-10-12 00:38 起再无记录,`/root/hemou` 里 CLAUDE.md、
|
|
236
|
+
KANBAN.json 一直没被提交。本目录的 `dsh-hooks.json` 顶部的 `_history` 记着这条教训。
|
|
237
|
+
|
|
238
|
+
## 6. autosave 链修复的三个层次(2026-10-13 逐层实测定位)
|
|
239
|
+
|
|
240
|
+
这条链此前**每一层都错**,而每一层都表现为「静默 exit 0」,所以只能靠端到端实测抓:
|
|
241
|
+
|
|
242
|
+
| # | 层次 | 症状 | 修法 |
|
|
243
|
+
| --- | --- | --- | --- |
|
|
244
|
+
| 1 | **入口解析** | 调用方把整段 JSON 当路径传参 → `git -C '<json>'` 必失败 | `autosave-hook.sh` 用 `jq` 解析(兼容 JSON / 目录 / 首行三种输入) |
|
|
245
|
+
| 2 | **仓库归属** | 只看 `.cwd`,而 `cwd` 是**会话工作区**;跨工程编辑时文件在别的工程里 → 无感 | 一律**优先 `.tool_input.file_path`**,其次 `.cwd`;路径逐级上溯到存在的目录(`git -C` 只吃目录) |
|
|
246
|
+
| 3 | **执行目录** | 解析出了仓库根,却没 `cd` 过去 → 引擎用自己的 cwd 找仓库,第一行就退出 | `( cd "$ROOT" && timeout 300 bash "$ENGINE" --push --quiet )` |
|
|
247
|
+
|
|
248
|
+
另外两条**属于平台行为、不是缺陷**,但会让验证产生错觉,必须知道:
|
|
249
|
+
|
|
250
|
+
- **20 秒去抖**:`autosave-unit.sh` 的 `AUTOSAVE_DEBOUNCE=20`(按仓库计,戳在 `<repo>/.devloop/autosave.last`)。
|
|
251
|
+
连续编辑时只有第一次会提交——所以「刚写完没提交」不等于坏了。
|
|
252
|
+
- **`--quiet` 吞输出**:引擎经钩子调用时输出被丢弃,排查要手动 `AUTOSAVE_ENGINE_LOG=1` 跑。
|
|
253
|
+
|
|
254
|
+
## 7. 已知边界
|
|
255
|
+
|
|
256
|
+
- `ls /root/features` 只覆盖「已登记工程」;未登记仓库不受任何约束(故意如此:主机运维、
|
|
257
|
+
临时脚本目录不该被拦)。
|
|
258
|
+
- worktree 是新目录 ⇒ 在 DSH 里是**新的工作区**。要在某特性下开工,请把会话建在该 worktree
|
|
259
|
+
目录(或命令里 `cd` 过去)。
|
|
260
|
+
- **回收不销毁路径,但也不冻结内容**:路标指向的是**活的主检出**,所以旧路径读到的是「现在」的
|
|
261
|
+
代码,不是回收那一刻的快照;主检出后来往前走,旧路径跟着变。要冻结的历史版本只有 git 能给
|
|
262
|
+
(`git -C <主检出> show <mergeCommit>:<路径>`)。
|
|
263
|
+
- 路标是符号链接 ⇒ 用 `rm -rf <路标>/`(**带尾斜杠**)会顺着链接删到主检出里去。清理请走
|
|
264
|
+
`feature prune`(它只 `rm` 链接本身,且发现目标是真实目录就拒绝)。这是刻意接受的残余风险:
|
|
265
|
+
换来的是 0 成本、内容永远正确的旧路径可读性。
|
|
266
|
+
- `/root/plugins`(本目录)被删除会让钩子失效——重跑 `install.sh` 即恢复;`feature doctor` 会报出来。
|
|
267
|
+
- **已知误报**:判定「命令里有没有会产生 commit 的 git 子命令」是文本匹配。已经排除
|
|
268
|
+
「`git` 出现在字符串字面量里」(如 `echo 'git merge main'`)这一类,但把命令当纯文本打印
|
|
269
|
+
且恰好不带引号边界的写法仍可能被误拦。这是刻意选 fail-safe 方向:宁可多拦,不可漏过真正
|
|
270
|
+
落在主检出的提交。真被误拦时用 `FEATURE_ALLOW_MAIN=1` 显式豁免(会留在记录里)。
|
|
271
|
+
- **不自动搬移改动**(同 §3 负向保证):拦的是「在错的地方干活」,不是「事后改投提交」。
|
|
272
|
+
- **文件沙箱在本机是关的**(§2.7):`danger-full-access` 是 worktree 工作流的**必要条件**,不是偏好。
|
|
273
|
+
放开后 DSH 那层 bwrap 围栏不再拦写;制度边界只剩 `feature-guard`(登记工程的主检出与已回收路标)
|
|
274
|
+
与 agent 自身纪律——**没被任何登记工程覆盖的路径(如 `/root/ops`、`/etc`)在会话里是可写的**。
|
|
275
|
+
这是本机为「未值守按需建 worktree」付出的代价;回退方法是把两个生效点改回 `workspace-write`
|
|
276
|
+
(见 §2.7 表),代价是 worktree 又只能靠人批准。
|
|
277
|
+
|
|
278
|
+
## 8. 自测
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
bash /root/plugins/feature-worktrees/selftest.sh --live # 114 条:22 沙箱规则 + 4 活体 + 19 生命周期 + 3 首轮注入 + 12 营救/审计/有效目标 + 25 路标/墓碑/prune/relink + 15 预检/ensure + 14 反向
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
自测写成文件而不是一行命令,是因为守卫靠命令文本判定,而测试描述里天然含 `git merge`
|
|
285
|
+
这类词——文件化之后实际执行的命令只剩 `bash selftest.sh`,既避开自触发,也让回归可复跑。
|
|
286
|
+
另一条纪律:自测里 `SID_TEST` 固定并 `export DSH_SESSION_ID` —— `new`/`ensure` 会**自动关联本会话**,
|
|
287
|
+
不固定就会把跑测试的真实会话绑到沙箱特性上(实测让 `doctor` 假失败)。
|
|
288
|
+
|
|
289
|
+
## 9. 现场记录:由真实使用暴露的问题(2026-10-13)
|
|
290
|
+
|
|
291
|
+
另一个会话(`一物万象`,cwd 在主检出)真实使用这套机制时,暴露了两件事;两条都已修并进回归:
|
|
292
|
+
|
|
293
|
+
| # | 现象(现场) | 根因 | 修法 |
|
|
294
|
+
| --- | --- | --- | --- |
|
|
295
|
+
| 1 | 在 worktree 里提交**被守卫拦了 3 次**,最后只能写 `FEATURE_ALLOW_MAIN=1` 绕过 | 第一版对 bash 命令取「候选集」(`-C`/`cd` 目标 **加上会话 cwd**)并**任一命中主检出即拦**;会话工作区正是主检出时,目标其实是 worktree 也照样被拦 | 改为判定**命令的有效目标**(最后一个 `-C`/`cd`;取不到才退回 cwd),并新增规则 2b(有效目标/仓库根本身落在特性 worktree 内即放行) |
|
|
296
|
+
| 2 | 主检出里压着 5 项未提交改动(含 2 篇只此一份的 Agent Note),既改不动也提交不了 | 制度切换前留下的在途改动没有搬运通道 | 新增 `feature rescue`(stash → 在 worktree apply,stash 留备份)与 `feature audit`(只读体检,给出确切回收命令) |
|
|
297
|
+
|
|
298
|
+
**教训(写进纪律)**:钩子的判据必须是「**这次操作实际作用在哪个仓库**」,而不是「相关路径里有没有
|
|
299
|
+
主检出」。前者是事实,后者是启发式——启发式一旦与会话 cwd 耦合,就会把**正确的操作**拦下来,
|
|
300
|
+
而误拦比漏拦更伤:它会训练人去找绕过通道(现场确实发生了)。
|
|
301
|
+
|
|
302
|
+
## 10. 现场记录:回收把会话的引用一起销毁(2026-10-15)
|
|
303
|
+
|
|
304
|
+
用户现场提出:**「一旦 worktree 的代码 merge 到主分支、自己回收了,DSH 会话里所引用的文件就没法访问了。」**
|
|
305
|
+
本机当时可验证:`facet-constitution`、`facet-contract` 两个特性已回收,`feature list --all` 显示
|
|
306
|
+
`[缺失]`——它们的 worktree 目录被 `git worktree remove` 拆掉,任何曾把会话工作区建在那里(或只是
|
|
307
|
+
在对话里 `@` 过某个文件)的会话,路径全部变成死链。这不是「会话坏了」,是**回收的语义错了**:
|
|
308
|
+
它把「合并完成」和「销毁路径」绑在了一起,而路径正是会话与代码之间的唯一纽带。
|
|
309
|
+
|
|
310
|
+
| 决策 | 内容 | 为什么不是别的 |
|
|
311
|
+
| --- | --- | --- |
|
|
312
|
+
| 路标 = 指向主检出的符号链接 | 拆 worktree 后在原路径 `ln -sfn <主检出>` | 0 成本、内容永远等于「代码现在住的地方」;`git` 会把路径解析回主检出,所以既有的规则 3 天然继续兜住写与提交 |
|
|
313
|
+
| 只保证读 | 新增守卫规则 2c:路标上的写/提交一律拦;读放行 | 路标要能读才有用;但让它可写就等于给主检出开了一个看起来像 worktree 的后门 |
|
|
314
|
+
| 记录 A 面 B 面 | `feature.json` 记 `retiredTo/retiredAt` + 特性目录写 `REDIRECT.md` | 注册表事实与磁盘事实都要有,`list/context/doctor/audit` 才不必靠猜路径形状 |
|
|
315
|
+
| 清理与补回各自成命令 | `feature prune`(只删链接)/ `feature relink`(补回) | 清理必须有「不删真实目录」的硬保证;补回让**已经**失联的旧特性(本期两个)也能修,而不必重新回收 |
|
|
316
|
+
| 放弃 = 墓碑 | `feature close` 写 `CLOSED.md`,**不**留路标 | 代码不在 main 里,指向 main 是撒谎;给的是「在哪个分支、怎么读出来」 |
|
|
317
|
+
|
|
318
|
+
**被否掉的方案**:① 回收后重建一个 detached 快照 worktree(`git worktree add --detach <merge-sha>`)
|
|
319
|
+
——路径是真的、`rm -rf` 也伤不到主检出,但每个特性要一份完整检出(hemou 工作区 15G)、内容会与后续
|
|
320
|
+
main 脱节、还要多一套 prune 记账;② 把 `--keep-worktree` 变默认——路径最忠实,可它仍是注册 worktree,
|
|
321
|
+
`list/doctor/audit` 会一直把它当「在用」,且分支合并后就开始漂移;③ 只在会话侧把绑定改指主检出——
|
|
322
|
+
治不了已经写在对话历史里的旧绝对路径。
|
|
323
|
+
|
|
324
|
+
**代价(明写在 §7)**:路标读的是**活的主检出**,不是回收那一刻的快照;并且 `rm -rf <路标>/`
|
|
325
|
+
(带尾斜杠)会顺着符号链接删到主检出——所以清理通道只认 `feature prune`。
|
|
326
|
+
|
|
327
|
+
回归:`selftest.sh` 新增 31 例(路标可读 / 写被拦 / `prune` 不伤主检出 / `relink` 补回 / 墓碑),
|
|
328
|
+
共 99 例全过;本机两个已回收特性用 `feature relink --all --yes` 补回了路标(实测旧路径可读、
|
|
329
|
+
`edit` 被拦)。
|
|
330
|
+
|
|
331
|
+
## 11. 现场记录:按需建 worktree 在未值守时不可用(2026-10-15)
|
|
332
|
+
|
|
333
|
+
用户现场提出(原话):**「一旦会话产生了,需要相应的 worktree 时,它按需去创建对应的 worktree,
|
|
334
|
+
并且这个 worktree 所属的目录应该是具有权限的。所以权限的问题一定要先处理好,而不是等到执行定时任务
|
|
335
|
+
的时候,才发现这个 worktree 还没好需要审批,导致在空闲时候执行待办任务时,还是没法执行。」**
|
|
336
|
+
当天的现场证据是一条两连击的失败链:某会话要建 worktree → 先撞 `/root/features is read-only`
|
|
337
|
+
(文件沙箱拒绝,弹审批)→ 改用 DSH 自带 `create_worktree` → 撞
|
|
338
|
+
`worktree command failed (129): unknown option: --no-lazy-fetch`(本机 git 2.43.0 < 插件要求的 2.45)。
|
|
339
|
+
|
|
340
|
+
| 决策 | 内容 | 为什么不是别的 |
|
|
341
|
+
| --- | --- | --- |
|
|
342
|
+
| 部署默认给足权限 | dsh-web 走 systemd drop-in `Environment=DSH_PERMISSION_MODE=danger-full-access`;nightshift 走 headless profile 里 `sandbox-policy` 的同类覆盖 | 建 worktree 要写会话工作区之外的**两处**,而 `workspace-write` 只有一个可写根(bwrap:`--ro-bind / /` + 仅 `--bind <工作区>`),且策略层没有额外可写路径白名单 ⇒ 无更窄的表达 |
|
|
343
|
+
| 边界交给钩子 | 放开沙箱后,靠 `feature-guard`(主检出写与提交一律拦、已回收路标只读) | 该钩子是主机级、覆盖所有会话且可审计;本机全部会话本来就跑在 root 下,unix 权限不是边界 |
|
|
344
|
+
| 把结论做成体检 | 新增 `feature preflight`:真写特性根、真写 `<repo>/.git`、真跑 `git worktree add --no-checkout`;并入 `feature doctor` | 「权限是否就绪」必须是**可执行、可复跑**的事实,而不是口头保证;未值守会话也要能自己判死 |
|
|
345
|
+
| 幂等入口 | 新增 `feature ensure <特性名> --repos <工程名>`:不在就建、在就核对+关联 | 未值守会话不能提问,确定性的一条命令胜过 `new`/`attach` 之间猜 |
|
|
346
|
+
| 停用 DSH 的 worktree 工具 | web profile patch:`- id: tool-worktree / disabled: true` | 本机必失败(git 版本),且其目录布局绕过 `/root/features` 注册表与守卫;留着只会浪费 schema token 并把会话引向死路 |
|
|
347
|
+
|
|
348
|
+
**被否掉的方案**:① **只给固定跑批会话开权限**——部署默认仍是 `workspace-write`,任何新会话/回落
|
|
349
|
+
会话照样撞墙,属治标;② **宿主机预建 worktree 来回避权限**——预建进程本身仍要写那两处,只是把问题
|
|
350
|
+
挪到另一个进程,而且「按需创建」正是用户要的语义;③ **升级 git 到 2.45 以启用 `create_worktree`**——
|
|
351
|
+
即便可用,它的布局与特性注册表不一致,制度会碎成两套;④ **给沙箱加额外可写路径白名单**——上游
|
|
352
|
+
`dsh-sandbox-policy` 只有 `mode`/`workspaceRoot`,要改 DSH 本体,代价与收益不成比例。
|
|
353
|
+
|
|
354
|
+
**代价(已写进 §7)**:本机 DSH 的 bwrap 围栏实际关闭,未被登记工程覆盖的路径(`/root/ops`、`/etc` 等)
|
|
355
|
+
在会话里亦可写;回退路径是改回 `workspace-write`(代价:worktree 只能靠人批准)。
|
|
356
|
+
|
|
357
|
+
回归:`selftest.sh` 新增 15 例(`preflight` 通过/按工程跑/不存在工程失败/不留残留、`doctor` 含体检且
|
|
358
|
+
全绿、`ensure` 幂等/新建/自动关联/无 `--repos` 拒绝/已回收拒绝),并把 `SID_TEST` 固定导出以消除
|
|
359
|
+
「自测把真会话绑到沙箱特性」的假失败;共 **114 例全过**。端到端另验:真实 `dsh --profile headless`
|
|
360
|
+
worker(nightshift 用的就是它)在无人值守条件下跑 `feature preflight` 全绿;`bwrap` 复刻实验证明
|
|
361
|
+
`workspace-write` 下特性根与 git 元数据均不可写。
|
package/lib/client.js
CHANGED
|
@@ -1001,6 +1001,7 @@ window.__ModuleLoader__.load({
|
|
|
1001
1001
|
const reviews = Array.isArray(data.reviews) ? data.reviews : []
|
|
1002
1002
|
const running = Array.isArray(data.running) ? data.running : []
|
|
1003
1003
|
const recent = data.recent || {}
|
|
1004
|
+
const setup = data.setup || {}
|
|
1004
1005
|
const problems = (Array.isArray(data.problems) ? data.problems : []).filter(
|
|
1005
1006
|
// 「定时器当前是 active」是正常状态,不该显示成提示(旧版宿主会多带一个换行)
|
|
1006
1007
|
(p) => !/当前是\s*active\b/.test(String(p)),
|
|
@@ -1065,7 +1066,7 @@ window.__ModuleLoader__.load({
|
|
|
1065
1066
|
h('span', { style: { fontSize: 12, color: T.textSub } }, t('boardLabel')),
|
|
1066
1067
|
...(Array.isArray(snap.data && snap.data.boards) && snap.data.boards.length
|
|
1067
1068
|
? snap.data.boards
|
|
1068
|
-
: [
|
|
1069
|
+
: []
|
|
1069
1070
|
).map((b) =>
|
|
1070
1071
|
h(
|
|
1071
1072
|
Button,
|
|
@@ -1086,6 +1087,33 @@ window.__ModuleLoader__.load({
|
|
|
1086
1087
|
h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 4 } }, chips),
|
|
1087
1088
|
),
|
|
1088
1089
|
|
|
1090
|
+
setup.configured === false
|
|
1091
|
+
? h(
|
|
1092
|
+
'div',
|
|
1093
|
+
{
|
|
1094
|
+
style: {
|
|
1095
|
+
...cardStyle, borderColor: T.brand, fontSize: 12, lineHeight: 1.65,
|
|
1096
|
+
display: 'flex', flexDirection: 'column', gap: 6,
|
|
1097
|
+
},
|
|
1098
|
+
},
|
|
1099
|
+
h('div', { style: { fontWeight: 700, fontSize: 13, color: T.text } }, t('setupTitle')),
|
|
1100
|
+
h('div', { style: { color: T.textSub } }, t('setupBody')),
|
|
1101
|
+
h(
|
|
1102
|
+
'code',
|
|
1103
|
+
{
|
|
1104
|
+
style: {
|
|
1105
|
+
fontSize: 12, padding: '7px 9px', borderRadius: 8, background: T.fill,
|
|
1106
|
+
border: `1px solid ${T.borderStrong}`, wordBreak: 'break-all',
|
|
1107
|
+
},
|
|
1108
|
+
},
|
|
1109
|
+
setup.initCommand || 'npx dsh-offhours init --repo .',
|
|
1110
|
+
),
|
|
1111
|
+
h('div', { style: { color: T.textSub } }, t('setupAfter')),
|
|
1112
|
+
setup.configPath
|
|
1113
|
+
? h('div', { style: { color: T.textSub, wordBreak: 'break-all' } }, `${t('setupConfigPath')} ${setup.configPath}`)
|
|
1114
|
+
: null,
|
|
1115
|
+
)
|
|
1116
|
+
: null,
|
|
1089
1117
|
snap.error
|
|
1090
1118
|
? h('div', { style: { ...cardStyle, borderColor: T.danger, color: T.danger, fontSize: 12 } }, snap.error)
|
|
1091
1119
|
: null,
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# nightshift — 低谷 + 电脑空闲时,把看板待办真的做掉
|
|
2
|
+
|
|
3
|
+
## 一句话
|
|
4
|
+
|
|
5
|
+
**每 5 分钟在本地做一次零 token 的判定**;只有「DeepSeek 低谷 ∧ 这台机器空闲 ∧ 有可跑的待办 ∧ 不在冷却 ∧ 未超预算」全满足时,才启动**一轮** worker(`dsh --profile headless`),在一个窗口内连续做最多 `MAX_CARDS_PER_RUN` 张卡,每张卡**必须留下验证证据**才算完成,结果写回看板并留可见回执,跑完**删掉 worker 会话**。
|
|
6
|
+
|
|
7
|
+
## 判定链(全部本地,零 token)
|
|
8
|
+
|
|
9
|
+
| 步 | 条件 | 不满足时 |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| 1 | 没有 `DISABLED` 文件(一键停) | 一行日志后退出 |
|
|
12
|
+
| 2 | 单实例锁(flock) | 退出 |
|
|
13
|
+
| 3 | 低谷:北京时间工作日 09–12 / 14–18 为高峰,其余(含周末/节假日全天)空闲 | 退出 |
|
|
14
|
+
| 4 | 剩余窗口 ≥ `MIN_WINDOW_LEFT`(默认 20 分钟) | 退出(不跨进高峰) |
|
|
15
|
+
| 5 | 机器空闲:最近 `IDLE_MIN`(**5**) 分钟**无任何会话活动** ∧ load1 ≤ `LOAD_MAX`(2.0) ∧ CPU busy ≤ `CPU_MAX`(40%) ∧ `loginctl` IdleHint 非 no | 退出 |
|
|
16
|
+
| 6 | 冷却 `COOLDOWN_MIN`(10) 分钟 | 退出 |
|
|
17
|
+
| 7 | 日预算:`MAX_CARDS_PER_DAY`(8) 卡 / `MAX_MINUTES_PER_DAY`(120) 分钟 | 拒绝并在回执写明 |
|
|
18
|
+
| 8 | 有可跑的卡:`status=todo` 且 tags **不含 `hold`**;带 `auto` 的优先;**决策类卡**(tags 含 `decision-needed/human-review/blocking/legal/needs-direction/blocked*`,或标题以「拍板/人审/要不要」开头)**不交给无人值守** | 跳过,并在回执/日志里列出这些卡 |
|
|
19
|
+
|
|
20
|
+
worker 的单卡超时 = `min(RUN_TIMEOUT, 剩余窗口 − 60s)`。
|
|
21
|
+
|
|
22
|
+
## 契约:worker 只干活,脚本写看板
|
|
23
|
+
|
|
24
|
+
worker **不碰**看板文件、也没有 board 工具;它必须在回复末尾给出四行:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
KANBAN-RESULT: done|review|failed
|
|
28
|
+
KANBAN-SUMMARY: <一行:做了什么 / 送审或失败原因>
|
|
29
|
+
KANBAN-EVIDENCE: <实际跑过的验证命令与观察结果>
|
|
30
|
+
KANBAN-NOTE: <review 时:不确定点 + 建议 + 备选;否则「无」>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`ns.py` 据此落状态(**确定性、带防丢更新**):
|
|
34
|
+
|
|
35
|
+
- `done` **且证据非空** → 卡置 done,`summary` 末尾附「证据:…」,缺 `rationale/rejected` 自动补;
|
|
36
|
+
- `done` 但**没有证据** → 转**待审**并写明「缺验证证据」;
|
|
37
|
+
- `review` → 置**待审**,把「不确定点+建议+备选」写进 description;
|
|
38
|
+
- `failed` → 回 todo 并计一次尝试;连续 `MAX_ATTEMPTS`(2) 次 → 转**待审**(附最后错误);worker 超时同理;
|
|
39
|
+
- 每轮都更新一张 **「夜间跑批回执(自动维护)」**(done + hold,自己永不被捡起):`summary` 是最近一轮,`description` 保留最近 10 轮;所以「昨晚跑没跑、为什么没跑」在看板上一眼可见。
|
|
40
|
+
|
|
41
|
+
## 为什么不用 dsh 的 cron 插件
|
|
42
|
+
|
|
43
|
+
1. **模型不对**:它「到点就往会话里塞提示词」——不看机器是否空闲、也不管看板有没有活,到点就发 LLM 请求。
|
|
44
|
+
2. **有致命定时器溢出 bug**(本机实测 2026-10-15):它把「下次触发」的毫秒数直接交给 `setTimeout`;超过 32 位上限(≈24.8 天)时 Node 会压成 **1 毫秒** → 立即重发 → 死循环。我用「只匹配一年后某天」的 cron 模拟一次性任务正好踩中:**22:00:26–22:03:17 创建了 195 个会话**(相邻间隔中位数 0.8 秒),直到停掉 dsh-web 才终止;按低谷价约 **1.40 元**。
|
|
45
|
+
⇒ 禁令:不要用「很远才匹配的 cron」当一次性任务;不要在进程外改插件存储(内存定时链不会被取消)。
|
|
46
|
+
|
|
47
|
+
## 前提:headless profile 必须装好这些东西(升级 dsh 后要跟着同步!)
|
|
48
|
+
|
|
49
|
+
worker 跑的是 `dsh --profile headless`,它的能力**不是** web profile 的白拿:
|
|
50
|
+
|
|
51
|
+
- `dsh-peak-block` 作为 **profile 层**(`bundles` 里)自动挂载 → 高峰期 worker 内的对话请求被硬拦。
|
|
52
|
+
- `@deepseek-ai/dsh-hooks-claude-code` **不声明 dsh.bundle**,所以是普通依赖 + 在 `/root/.dsh/profiles/headless/cordis.patch.yml` 里**显式插入一行**(configPath 指向主机级 `/root/.dsh/hooks/dsh-hooks.json`)→ 带来 **feature-guard**。
|
|
53
|
+
- 该 hooks 包的 **9 个 peer 依赖必须与运行时同版本**装在 headless profile 里(`@deepseek-ai/dsh-{agent,llm,session,tools,subagent,working-directory,session-projection,hook-protocol}` + `@deepseek-ai/cordis`)。缺任何一个,插件会 `failed to import`,而且**只在 stderr 里留一行 warning**——本次就是这样被骗过去的(见下面「踩过的坑」)。
|
|
54
|
+
|
|
55
|
+
## 安装 / 卸载
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
/root/ops/nightshift/install.sh # ops 板:service + timer(每 5 分钟,零 token)
|
|
59
|
+
/root/ops/nightshift/install.sh --hemou # 额外生成 hemou 单元(默认 disabled,要你手动 enable)
|
|
60
|
+
/root/ops/nightshift/install.sh --uninstall # 停用并移除
|
|
61
|
+
systemctl list-timers nightshift.timer # 看下一次扫描
|
|
62
|
+
tail -f /root/ops/nightshift/nightshift.log # 扫描与每轮结果
|
|
63
|
+
python3 /root/ops/nightshift/ns.py status # 状态:上次决策/今日计数/尝试次数/剩余窗口
|
|
64
|
+
python3 /root/ops/nightshift/ns.py triage /root/hemou/KANBAN.json # 零 token 可跑性报告
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**hemou 板启用前**:先跑 `triage`(零 token),给不该被无人值守碰的卡打 `hold`,再 `systemctl enable --now nightshift-hemou.timer`。默认只服务 ops 板。
|
|
68
|
+
|
|
69
|
+
## 测试
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python3 /root/ops/nightshift/selftest.py # 零 token:判定/状态机/证据/计次/回执/防丢更新 + P1 回归
|
|
73
|
+
cd mechanism && NIGHTSHIFT_HOME="$PWD" bash verify-all.sh # 测 worktree 当前代码
|
|
74
|
+
/root/ops/nightshift/verify-all.sh --token <面板token> # 测活路径;期望 0 失败
|
|
75
|
+
/root/ops/nightshift/scan.sh --dry-run # 只判定(我在用时必须跳过)
|
|
76
|
+
/root/ops/nightshift/scan.sh --force --dry-run # 跳过时段/空闲,只看会跑哪张
|
|
77
|
+
/root/ops/nightshift/scan.sh --force --max-cards=1 --only=<cardId> # 真跑一轮(花钱,慎用)
|
|
78
|
+
# 高峰硬拦负向测试(期望:worker 被拦、卡回 todo、几乎不花钱)
|
|
79
|
+
NIGHTSHIFT_OVERLAY=/root/ops/nightshift/test-force-peak.yml \
|
|
80
|
+
/root/ops/nightshift/scan.sh --force --max-cards=1 --only=<cardId>
|
|
81
|
+
```
|
|
82
|
+
P1 的不变量、配置键、原因码与回滚见 `docs/p1-hardening.md`。
|
|
83
|
+
|
|
84
|
+
## 旋钮(systemd unit 的 `Environment=`)
|
|
85
|
+
|
|
86
|
+
| 变量 | 默认 | 含义 |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| `NIGHTSHIFT_BOARD` / `NIGHTSHIFT_WORKDIR` | `/root/ops/KANBAN.json` / `/root/ops` | 目标看板 / worker 工作目录(也决定文件沙箱边界) |
|
|
89
|
+
| `IDLE_MIN` / `LOAD_MAX` / `CPU_MAX` | **5** / 2.0 / 40 | 空闲判定(`IDLE_MIN`=5 表示"最近 5 分钟没有任何会话活动"就算人离开了;2026-10-16 用户选定,原为 15) |
|
|
90
|
+
| `RUN_TIMEOUT` | 1200 | 单卡上限(还会被剩余窗口收紧) |
|
|
91
|
+
| `MAX_CARDS_PER_RUN` | 3 | 一次扫描最多做几张 |
|
|
92
|
+
| `MIN_WINDOW_LEFT` | 1200 | 剩余窗口不足就不开跑 |
|
|
93
|
+
| `COOLDOWN_MIN` | 10 | 两次扫描真正开跑之间的最短间隔 |
|
|
94
|
+
| `MAX_CARDS_PER_DAY` / `MAX_MINUTES_PER_DAY` | 8 / 120 | 日预算硬上限(`day.json`) |
|
|
95
|
+
| `MAX_ATTEMPTS` | 2 | 同卡失败/超时多少次后转待审 |
|
|
96
|
+
| `KEEP_RUNS` / `LOG_MAX_KB` | 200 / 1024 | `runs/` 与日志保留上限 |
|
|
97
|
+
| `NIGHTSHIFT_OVERLAY` | 空 | 需要额外 profile 补丁时才设(负向测试用) |
|
|
98
|
+
|
|
99
|
+
## 产物与痕迹
|
|
100
|
+
|
|
101
|
+
- `nightshift.log`(轮转 `.1`)、`state.json`(上次运行/尝试次数)、`day.json`(当日预算用量)
|
|
102
|
+
- `runs/<时间>-<卡id>.{out,err,transcript.jsonl}`:worker 的输出、stderr、**完整会话转录**(会话目录随后被删除,所以界面不留垃圾)
|
|
103
|
+
- 看板上的「夜间跑批回执(自动维护)」卡
|
|
104
|
+
|
|
105
|
+
## 怎么把「已确认的任务」放进待办队列
|
|
106
|
+
|
|
107
|
+
**跑批读的就是本工作区的看板**(`NIGHTSHIFT_BOARD`,默认 `/root/ops/KANBAN.json`)。所以「放进待办」=**往看板加一张 `status=todo` 的卡**。三个入口:
|
|
108
|
+
|
|
109
|
+
| 入口 | 怎么做 | 适合 |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| **跟会话说一句**(最常用) | 「把 X 记进待办 / 任务确认了:做 Y」→ 会话调 `board_add`,按 `kanban-use` 技能的纪律写全 `title`+`rationale`(+`rejected`) | 你正在对话里,顺手记 |
|
|
112
|
+
| **GUI 看板页** | 左侧「Smoothly Kanban」→ 页面上的新建/添加卡片 | 你只想手动记一条 |
|
|
113
|
+
| **命令行(本仓库脚本)** | `python3 /root/ops/nightshift/add-card.py --title "…" --why "…" [--reject "…"] [--tags auto]`;开发任务用 `--dev --feature <特性名> --project <工程> --accept "<验收标准>"`;`--list` 看未完成卡 | 你在终端里,或想脚本化 |
|
|
114
|
+
|
|
115
|
+
**需要 skill 吗?** 技能 `kanban-use` 已经存在(每个会话的目录里都有),它管的是**卡怎么写才完整**(为什么/放弃了什么/完成时三字段齐备)——它是给模型看的纪律,不是运行时自动化。**「把卡做掉」的自动化就是 nightshift 本身**(systemd timer 每 5 分钟零 token 判定 → 低谷+空闲才跑)。中间**不需要额外的库**。
|
|
116
|
+
|
|
117
|
+
**无人值守要能把一张开发卡做掉,卡里必须有这四样**(缺了就会被转「待审」——2026-10-16 00:17 真实发生过:那张卡因为「未写特性名、所指工程不是 feature 登记工程」被 worker 拒绝猜并送审):
|
|
118
|
+
|
|
119
|
+
1. **特性名 / 工程**(`feature` / `project`,或等价的仓库路径)——worker 才知道该 attach/new 哪个 worktree;
|
|
120
|
+
2. **验收标准**(跑什么命令、看到什么算过)——它要拿这个当 `KANBAN-EVIDENCE`;
|
|
121
|
+
3. **边界**(不许动什么、要不要 `hold`);
|
|
122
|
+
4. **标签**:`auto` = 明确可无人值守;`hold` = 任何情况都不许无人值守碰;都不标也**会**被跑(口径是 `todo` 且非 `hold`),只是优先做 `auto`。
|
|
123
|
+
|
|
124
|
+
> 记不住就直接用 `--dev` 模式:它会把「特性/工程、验收、边界」按上面四样生成进 description,并在缺 `--feature`/`--accept` 时**直接拒绝**(而不是让你放一张做不成的卡进去)。
|
|
125
|
+
|
|
126
|
+
## 多个看板 = 多个实例(ops / hemou)
|
|
127
|
+
|
|
128
|
+
看板是**按工作区**分开的,所以跑批也是按看板各起一个实例(各自 systemd 单元),互不干扰:
|
|
129
|
+
|
|
130
|
+
| 实例 | 看板 | 单元 | 门槛 | 状态文件 |
|
|
131
|
+
| --- | --- | --- | --- | --- |
|
|
132
|
+
| ops | `/root/ops/KANBAN.json` | `nightshift.timer` | `todo` 且非 `hold`(默认口径) | `state-ops.json` / `day-ops.json` |
|
|
133
|
+
| hemou | `/root/hemou/KANBAN.json` | `nightshift-hemou.timer` | **`NIGHTSHIFT_REQUIRE_AUTO=1`:只认 `auto`** | `state-hemou.json` / `day-hemou.json` |
|
|
134
|
+
|
|
135
|
+
**为什么 hemou 用"只认 auto"**:hemou 板是**显式约定**——作者只在"明确可无人值守"时才打 `auto`,其余待办属于别的线或等人拍板(`todo=36` 里只有 7 张 `auto`)。按"非 hold 即可跑"的口径会连带吃掉另外 5 张(含需要设备的验证卡、需要拍板的决策卡),那是白烧 token。
|
|
136
|
+
|
|
137
|
+
**隔离要点(2026-10-16 修)**:预算、冷却、尝试计数**按看板分开**(`state-<板>.json` / `day-<板>.json`);日志共用一份但每行带 `[ops]` / `[hemou]` 前缀;`.lock` 是**全局**的——两个实例互斥(不会同时烧钱)。
|
|
138
|
+
|
|
139
|
+
**事后侦测的白名单**:`NIGHTSHIFT_GUARD_IGNORE`(默认 `KANBAN.json`)——看板文件本身每次写都会变(还有别的会话的 autosave),若不排除,hemou 实例会**每轮都误报"主检出被改动"**而把卡全部转待审。
|
|
140
|
+
|
|
141
|
+
## 界面:左侧「夜间跑批」面板跟随你看的看板
|
|
142
|
+
|
|
143
|
+
面板(`dsh-nightshift-panel`)顶部有**看板切换器**(`ops` / `hemou`),选择记在浏览器本地;标题下面显示当前看板的**文件路径**。切到 hemou 就能直接看到那 36 张待办与 7 张 `auto`。
|
|
144
|
+
|
|
145
|
+
面板的数据是零 token 的:状态条(低谷/定时器/下次扫描/冷却/今日预算)、**待办队列**(带 `auto 优先` / `永不自动跑` 标注)、**正在跑**、**待审(等你确认)**,以及 暂停 / 立即扫描一次 / 刷新 三个按钮——「立即扫描」会带**当前看板**的环境去跑扫描(修复前它会永远扫 ops,这是 2026-10-16 修的第二个坑)。
|
|
146
|
+
|
|
147
|
+
> 面板的宿主半改动需要**重启一次 dsh-web** 才生效;客户端半刷新页面即可。
|
|
148
|
+
|
|
149
|
+
## 事后侦测:替代 feature-guard 的兜底(2026-10-15 实测后确定)
|
|
150
|
+
|
|
151
|
+
**为什么跑批路径不能用 feature-guard 钩子**:headless profile 一旦加载 `@deepseek-ai/dsh-hooks-claude-code`,
|
|
152
|
+
**任何工具调用都会崩**:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
dsh: UNKNOWN: Cannot read properties of undefined (reading 'prepare')
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
根因:该插件的 peer 依赖让 profile 里多出一份 `@deepseek-ai/dsh-tools`,而工具调度器是按
|
|
159
|
+
`Symbol("@deepseek-ai/dsh-tools.scheduler")` 挂在 tools 服务实例上的 —— 两份副本 ⇒ Symbol 身份不一致 ⇒ `undefined`。
|
|
160
|
+
**同机同卡 A/B**:systemd 上下文里「带钩子行」失败(`runs/20261015-234324-*.err`)、「禁用钩子行」成功(23:45:50 那轮 done);
|
|
161
|
+
而在继承了 `DSH_*` 环境的 shell 里一直没复现 —— 也就是说它**只会在真正的无人值守路径上炸**。
|
|
162
|
+
|
|
163
|
+
**兜底做法(侦测,不是阻止)**:每轮前后对 `NIGHTSHIFT_GUARD_REPOS`(默认 `/root/hemou`)取
|
|
164
|
+
`git status --porcelain --untracked-files=all` + HEAD 指纹;一变就把该卡转**待审**,并把**具体变化路径**写进卡片说明
|
|
165
|
+
(例:`/root/hemou 新增改动:?? nightshift-probe3.txt`)。已端到端实测(worker 真往主检出写了文件 → 卡转待审并指名路径)。
|
|
166
|
+
注意两点:① 它**不阻止**写入,只保证「主检出被动了你一定看得到」;② 别的会话/看板自身的正常写入也会触发它(会按路径如实列出,由你判断)。
|
|
167
|
+
|
|
168
|
+
## 左菜单面板(dsh-nightshift-panel)
|
|
169
|
+
|
|
170
|
+
GUI 左侧「Global panels」里有 **夜间跑批** 面板(与 Plugins / Smoothly Kanban / Automation tasks 并列)。面板显示:
|
|
171
|
+
|
|
172
|
+
- 状态条:低谷/高峰、定时器是否 active、**下次扫描时间**、是否在冷却、**今日预算用量**(0/8 张卡 · 0/120 分钟);
|
|
173
|
+
- **待办队列**:`todo` 卡(带 `auto 优先` / `永不自动跑`(=hold) 标记);
|
|
174
|
+
- **正在跑**:当前 `in_progress` 的卡;
|
|
175
|
+
- **待审(等你确认)**:`review` 卡 —— 这就是「不确定、等你拍板」的那些任务;
|
|
176
|
+
- 操作:**暂停**(= 建 `DISABLED` 文件)、**立即扫描一次**(按正常门控,不强制)、**刷新**;每 30 秒自动刷新。
|
|
177
|
+
|
|
178
|
+
源码在工作区 `/root/ops/dsh-nightshift-panel/`,已作为 `@local/dsh-nightshift-panel`(`link:` 安装)进入 web profile 的 bundles;
|
|
179
|
+
装完或升级后**硬刷新页面**(Cmd/Ctrl+Shift+R)。卸载:用 `plugin_manager` 移除该 bundle。
|
|
180
|
+
|
|
181
|
+
## 待审 → 讨论确认 → 回到待办(闭环)
|
|
182
|
+
|
|
183
|
+
送审卡里带着 worker 的**编号问题清单**(①…②…,每条附【建议】与【备选】)。确认有三条入口,**结论一律写回卡片本身**(这是关键:下一个 worker 才不会重复问):
|
|
184
|
+
|
|
185
|
+
| 入口 | 怎么做 |
|
|
186
|
+
| --- | --- |
|
|
187
|
+
| **面板(推荐)** | 左侧「夜间跑批」→ 待审区,每张卡下有「结论 / 执行要点」两个输入框 + **「确认 → 回到待办」** 按钮;带 `hold` 的卡可勾选「同时解除 hold」 |
|
|
188
|
+
| **会话里讨论** | 直接说「确认 <卡标题>:①准 ②改B …」——会话(或我)把它落成 `ns.py confirm` |
|
|
189
|
+
| **命令行** | `python3 /root/ops/nightshift/ns.py confirm <看板> <卡id> "<结论>" "<执行要点>" [--by X] [--unhold]`(加 `--dry` 只看不改) |
|
|
190
|
+
|
|
191
|
+
**机械行为**(`ns.py confirm`,零 token):① 在 description 追加 `【确认 <时间> · by <谁>】结论:… / 执行要点:…`,并写明「worker 不得就同一问题再次送审」;② 状态 `review → todo`;③ **清空 attempts**(否则历史失败计数会立刻把它推回待审)。
|
|
192
|
+
|
|
193
|
+
**护栏**:只有 `review` 的卡能确认(`todo/done` 会被拒——不许绕过讨论);结论至少 8 个字(太短下一个 worker 没法照做);带 `hold` 的卡默认**保留 hold**(跑批仍不捡它),要放行必须显式勾选/`--unhold`——hold 是"绝不允许无人值守"的安全默认,不能被"确认"顺手推翻。
|
|
194
|
+
|
|
195
|
+
**worker 侧的对应义务**(已写进提示词):见到 `【确认 …】` 块就照办、**不得就同一问题再次送审**(只有确认块没覆盖到的新问题才允许再问);送审时必须写编号清单 ①…②… 并附建议与备选,让人能一句话批掉。
|
|
196
|
+
|
|
197
|
+
## 「绝不重复会话风暴」的机械保障(2026-10-16 用户硬要求)
|
|
198
|
+
|
|
199
|
+
| 机制 | 机械事实 | 证据 |
|
|
200
|
+
| --- | --- | --- |
|
|
201
|
+
| **worker 不能造会话** | headless profile 里 `subagent` / `subagent-spawn-in-process` / `workflow-ptc` / `tool-workflow` 按 id 禁用;`schedule`(会话级定时提醒) / `ralph` / `agent-team` / `cron` 本来就不在 | 运行时探针:worker 只有 19 个工具,上述名字出现 0 次 |
|
|
202
|
+
| **不能自己排下一次** | 已摘除 cron 插件;headless 无 schedule ⇒ 无"自己触发自己"的通道 | `dsh --profile headless --dump-config` + 探针 |
|
|
203
|
+
| **机械熔断** | 每次 tick 先数"最近 `STORM_MIN`(15) 分钟内写入过的会话数",> `STORM_MAX`(10) ⇒ 拒开工 + 写 `DISABLED` 自动暂停 + 日志 + 回执 | 触发路径实测(`STORM_MAX=0`);昨天那种 3 分钟 195 会话会在第一个 tick 被拦 |
|
|
204
|
+
| **每 tick 上限** | timer 每 5 分钟一次、两次真跑之间冷却 10 分钟、一轮最多 3 张、每板每天 8 张 / 120 分钟 | `systemctl list-timers` + `day-<板>.json` |
|
|
205
|
+
| **两实例互斥** | 全局 flock:ops 与 hemou 不会同时跑(日志里会写"另一个跑批仍在运行,跳过") | 实测日志 |
|
|
206
|
+
| **零 token 判定** | tick 本身只做本地文件/命令判断;不满足条件时一行日志退出,**一个 token 不花** | 日志里大量"跳过"行 + 今天只有 3 次真跑 |
|
|
207
|
+
|
|
208
|
+
**残余通道(诚实)**:worker 有 `bash`,理论上能自己调 `dsh` 再开会话;工具层堵不死(沙箱因另一会话的需要已放宽为 `danger-full-access`)。兜底=熔断 + 日预算 + 冷却 + 互斥 + 事后侦测。
|
|
209
|
+
|
|
210
|
+
## 代码去向:worker 独占 worktree + 完成时合并回源会话(2026-10-16 用户要求)
|
|
211
|
+
|
|
212
|
+
默认 **关闭**(`NIGHTSHIFT_ISOLATED_WORKTREE=1` 打开),行为对比:
|
|
213
|
+
|
|
214
|
+
| | 关闭(默认,今天的行为) | 打开 |
|
|
215
|
+
| --- | --- | --- |
|
|
216
|
+
| worker 在哪干活 | **源会话共享的特性 worktree**(例如 `/root/features/facet-client/hemou`) | **自己的独占 worktree** `/root/features/ns-<卡id>/<工程>/`(分支 `feat/ns-<卡id>`,从源特性分支 HEAD 拉出) |
|
|
217
|
+
| 完成时 | 就地提交(不推送) | 提交 → `git -C <源worktree> merge --no-ff feat/ns-<卡id>` → 在卡里写 `【代码去向】… @ <merge commit>` |
|
|
218
|
+
| 送审/失败 | 就地留下改动(可能污染源 worktree) | **不合并**,保留 worker worktree + 分支,卡上写明路径 |
|
|
219
|
+
| 源会话没关联特性 | worker 自己 `feature ensure`(可能自起名) | **直接送审**:"无法确定代码去向"(不自起特性名) |
|
|
220
|
+
|
|
221
|
+
实现:`ns.py prep-worktree <board> <卡id> [--go]`(读 `/root/features/.sessions/<源会话id>.json` → `feature ensure <源特性>` → `feature new ns-<卡id> --from feat/<源特性>` → 打印 `WORKDIR=`)、`ns.py merge-back <board> <卡id> <done|review|failed|auto> [--go]`;`scan.sh` 在 `run_one` 开头准备、`apply` 之后按 `auto`(读卡当前状态)决定是否合并。
|
|
222
|
+
**为什么必须走 feature 工具**:`feature-guard` 的放行判据是"路径命中**已注册的**特性 worktree"(`feature_of_path` 读 `feature.json` 的 `.repos[].path`)⇒ 手工 `git worktree add` 出来的目录**不在注册表里,提交会被拦**。
|
|
223
|
+
**红线**:不推送、不 `feature recycle`(合主干是人的验收决定)、worker 只写自己那块 worktree。
|
|
224
|
+
|
|
225
|
+
## 并行(2026-10-16 用户要求)
|
|
226
|
+
|
|
227
|
+
**已生效(L1 跨板并行)**:
|
|
228
|
+
- 锁由**全局一把**改为**每板一把**:`.lock-ops` / `.lock-hemou` ⇒ ops 与 hemou 可同时跑(实测:占住 ops 锁时 hemou 照常、ops 被挡)。
|
|
229
|
+
- **全局 worker 槽位**:`SLOTS=slots/`(mkdir 原子占位)、`MAX_WORKERS=${NIGHTSHIFT_MAX_WORKERS:-3}`(跨板总并发上限);拿不到槽位就结束本轮;启动前自动清理过期槽(超过 `RUN_TIMEOUT+20min`)。
|
|
230
|
+
- 并行正确性前提:临时结果文件 `last-result-<board>.txt` **每板一份**(原为全局一份,会互相覆盖)。
|
|
231
|
+
|
|
232
|
+
**待做(L2 同板多卡并行)**:前置条件是 `NIGHTSHIFT_ISOLATED_WORKTREE=1`(每卡独占 worktree,否则抢 git index)+ 特性注册表串行化(`flock $FEATURES_ROOT/.ns-feature.lock`)+ 每卡一份结果文件 + 每启动一个 worker 前复检 load/CPU;上限用 `MAX_PARALLEL_PER_BOARD`(建议默认 2)。日预算与熔断**仍是权威上限**——并行只提高窗口内速率,不提高总额度。
|
|
233
|
+
|
|
234
|
+
## 改完要重启 dsh-web 时(2026-10-16 事故与铁律)
|
|
235
|
+
|
|
236
|
+
**事故**:改完宿主半插件后用 `systemd-run --on-active=60 …` 定时重启 `dsh-web`,结果那个时刻正有会话回合在跑 ⇒ 重启把承载会话的进程杀了 ⇒ 用户看到"**跑着跑着突然中断**"。当晚发生两次(21:19、21:54),每次都让一个未完成的大回合当场消失。
|
|
237
|
+
|
|
238
|
+
**铁律**:`dsh-web` 承载**所有会话回合**,任何重启都必须**等机器空闲**:
|
|
239
|
+
```bash
|
|
240
|
+
/root/ops/nightshift/restart-web-when-idle.sh [等待上限秒=1800] [报告文件]
|
|
241
|
+
```
|
|
242
|
+
它用 `ns.py idle <NEED_MIN=2> 2.0 40` 判定"最近 2 分钟没有任何会话活动",满足才重启;等不到就**放弃重启**(宁可改动晚生效,也绝不打断会话)。实测:不空闲时 5 秒即放弃、dsh-web 未被重启 ✓。
|
|
243
|
+
|
|
244
|
+
**第三条经验(2026-10-16 第二次中断的真因)**:同一个文件**不要混用两种写入机制**——我先把 P3 用 bash+python 写进 `index.js`,随后又用 edit 工具改同一文件;edit 的版本快照没看到 bash 的写入,写回时把 P3 的改动**整体回滚**了(只有之后用 bash 写的 P4 幸存),直到看运行态报错才发现。规则:**一个文件从头到尾只用一个机制**(要么全 bash/python,要么全 edit/write);改完必须用 grep/`node --check` 复核标记是否真的在文件里,而不是相信"脚本打印了成功"。
|
|
245
|
+
|
|
246
|
+
**另外两条经验**:
|
|
247
|
+
- 面板(`@local/dsh-nightshift-panel`)的**客户端**是每请求读文件 ⇒ 只改文案/样式**只需刷新页面**,不用重启;只有**宿主半**(`index.js`)改动才需要重启。
|
|
248
|
+
- 看板插件(`dsh-kanban`)的**客户端是启动时装配**的 ⇒ 改了要重启才生效(同样走上面的空闲重启)。
|
|
249
|
+
|
|
250
|
+
## 边界与诚实清单
|
|
251
|
+
|
|
252
|
+
- 时段判定只按工作日+时段,**中国法定节假日会被当成高峰**(保守方向:少跑、不多花钱)。
|
|
253
|
+
- 「空闲」是代理指标(会话静默 + 负载 + CPU + loginctl)。`xprintidle` 本机没有。
|
|
254
|
+
- worker 的 `workspace-write` 沙箱本该挡住工作目录之外的写入,**但 headless profile 现在被另一会话设成了 `danger-full-access`**
|
|
255
|
+
(为的是让无人值守会话能按需建 feature worktree,理由见该 profile 的 `cordis.patch.yml` 注释)——所以**沙箱这条也不再有兜底**。
|
|
256
|
+
跑批路径上唯一还起作用的是上面那套**事后侦测**(+ 提示词纪律)。
|
|
257
|
+
- 一轮一张卡是刻意保守;吞吐由预算、冷却、可用窗口共同决定。**这台机器夜里常关机**(2026-10-15:01:25 关、08:06 开),实际可用窗口 ≈ 08:06–09:00 / 12:00–14:00 / 18:00–23:00。
|
|
258
|
+
- 看板注入的指引文案(BOARD_GUIDANCE)已与本文件口径对齐,但**需要重启一次 dsh-web 才生效**。
|
|
259
|
+
- `ns.py` 的写看板是「读-改-写 + 写前指纹核对 + 重放」,能避免覆盖你的编辑;极端连续并发下会放弃本次写入(记日志),不会硬覆盖。
|
|
260
|
+
|
|
261
|
+
## 踩过的坑(都留在 Agent Note 里)
|
|
262
|
+
|
|
263
|
+
1. **`--dump-config` 不等于激活**:用绝对路径 `insert` 的 peak-block / hooks 两行在 headless 里都是 `failed to import`(只打一行 warning),我当时据此以为"能挂"——错。后来改成 profile 层 + 显式依赖才真生效。
|
|
264
|
+
2. **hooks 包的 9 个 peer 缺一个就整包 import 失败**;装 peer 时**必须带确切版本**(我漏传版本,pnpm 装了 `0.0.1-rc.1`,于是报 `does not provide an export named 'selectHookGroups'`)。
|
|
265
|
+
3. **`--only=` 空值会静默退化成"随便选一张"**(曾因此把真实卡跑了一遍);现在空值直接报错退出。
|
|
266
|
+
4. **`pkill -f "dsh --profile headless"` 会匹配到自己的命令行**,把自己那条 bash 杀掉;用 `[d]sh` 或 pgrep 精确匹配。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-offhours",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "DSH 社区插件:把看板待办在低谷/夜间自动做掉(挑卡 → 独占 worktree → worker → 证据 → 合并回收),只依赖配置里的 git 仓库。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"./package.json": "./package.json"
|
|
12
12
|
},
|
|
13
13
|
"bin": {
|
|
14
|
-
"offhours": "./bin/offhours.mjs"
|
|
14
|
+
"offhours": "./bin/offhours.mjs",
|
|
15
|
+
"dsh-offhours": "./bin/offhours.mjs"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
17
18
|
"bin/offhours.mjs",
|
package/panel/client.js
CHANGED
|
@@ -1001,6 +1001,7 @@ window.__ModuleLoader__.load({
|
|
|
1001
1001
|
const reviews = Array.isArray(data.reviews) ? data.reviews : []
|
|
1002
1002
|
const running = Array.isArray(data.running) ? data.running : []
|
|
1003
1003
|
const recent = data.recent || {}
|
|
1004
|
+
const setup = data.setup || {}
|
|
1004
1005
|
const problems = (Array.isArray(data.problems) ? data.problems : []).filter(
|
|
1005
1006
|
// 「定时器当前是 active」是正常状态,不该显示成提示(旧版宿主会多带一个换行)
|
|
1006
1007
|
(p) => !/当前是\s*active\b/.test(String(p)),
|
|
@@ -1065,7 +1066,7 @@ window.__ModuleLoader__.load({
|
|
|
1065
1066
|
h('span', { style: { fontSize: 12, color: T.textSub } }, t('boardLabel')),
|
|
1066
1067
|
...(Array.isArray(snap.data && snap.data.boards) && snap.data.boards.length
|
|
1067
1068
|
? snap.data.boards
|
|
1068
|
-
: [
|
|
1069
|
+
: []
|
|
1069
1070
|
).map((b) =>
|
|
1070
1071
|
h(
|
|
1071
1072
|
Button,
|
|
@@ -1086,6 +1087,33 @@ window.__ModuleLoader__.load({
|
|
|
1086
1087
|
h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 4 } }, chips),
|
|
1087
1088
|
),
|
|
1088
1089
|
|
|
1090
|
+
setup.configured === false
|
|
1091
|
+
? h(
|
|
1092
|
+
'div',
|
|
1093
|
+
{
|
|
1094
|
+
style: {
|
|
1095
|
+
...cardStyle, borderColor: T.brand, fontSize: 12, lineHeight: 1.65,
|
|
1096
|
+
display: 'flex', flexDirection: 'column', gap: 6,
|
|
1097
|
+
},
|
|
1098
|
+
},
|
|
1099
|
+
h('div', { style: { fontWeight: 700, fontSize: 13, color: T.text } }, t('setupTitle')),
|
|
1100
|
+
h('div', { style: { color: T.textSub } }, t('setupBody')),
|
|
1101
|
+
h(
|
|
1102
|
+
'code',
|
|
1103
|
+
{
|
|
1104
|
+
style: {
|
|
1105
|
+
fontSize: 12, padding: '7px 9px', borderRadius: 8, background: T.fill,
|
|
1106
|
+
border: `1px solid ${T.borderStrong}`, wordBreak: 'break-all',
|
|
1107
|
+
},
|
|
1108
|
+
},
|
|
1109
|
+
setup.initCommand || 'npx dsh-offhours init --repo .',
|
|
1110
|
+
),
|
|
1111
|
+
h('div', { style: { color: T.textSub } }, t('setupAfter')),
|
|
1112
|
+
setup.configPath
|
|
1113
|
+
? h('div', { style: { color: T.textSub, wordBreak: 'break-all' } }, `${t('setupConfigPath')} ${setup.configPath}`)
|
|
1114
|
+
: null,
|
|
1115
|
+
)
|
|
1116
|
+
: null,
|
|
1089
1117
|
snap.error
|
|
1090
1118
|
? h('div', { style: { ...cardStyle, borderColor: T.danger, color: T.danger, fontSize: 12 } }, snap.error)
|
|
1091
1119
|
: null,
|
package/panel/index.js
CHANGED
|
@@ -322,6 +322,7 @@ async function buildStatus(boardArg) {
|
|
|
322
322
|
if (Number.isFinite(n)) windowLeft = n
|
|
323
323
|
} catch { /* 忽略:面板降级为不显示余量 */ }
|
|
324
324
|
const COOLDOWN_MIN = cooldownMinFor(board)
|
|
325
|
+
const configured = !!(CFG._path && existsSync(CFG._path) && Array.isArray(CFG.boards) && CFG.boards.length > 0)
|
|
325
326
|
const out = {
|
|
326
327
|
ok: true,
|
|
327
328
|
now: fmtLocal(now),
|
|
@@ -341,6 +342,13 @@ async function buildStatus(boardArg) {
|
|
|
341
342
|
running: [],
|
|
342
343
|
recent: { log: [], receipt: null, runs: [] },
|
|
343
344
|
problems: [],
|
|
345
|
+
setup: {
|
|
346
|
+
configured,
|
|
347
|
+
configPath: CFG._path || '',
|
|
348
|
+
initCommand: 'npx dsh-offhours init --repo .',
|
|
349
|
+
doctorCommand: 'npx dsh-offhours doctor',
|
|
350
|
+
restartHint: '安装或更新插件后重启 dsh,面板才会加载新代码。',
|
|
351
|
+
},
|
|
344
352
|
}
|
|
345
353
|
|
|
346
354
|
// SCHEDULER-REPORT:按配置的调度方式如实报告(systemd / 插件内 interval)。
|
|
@@ -379,7 +387,7 @@ async function buildStatus(boardArg) {
|
|
|
379
387
|
}
|
|
380
388
|
} catch { out.problems.push('systemctl 查询失败(定时器状态未知)') }
|
|
381
389
|
}
|
|
382
|
-
if (CFG.scheduler === 'plugin' && out.timer.active === 'unknown') {
|
|
390
|
+
if (configured && CFG.scheduler === 'plugin' && out.timer.active === 'unknown') {
|
|
383
391
|
out.problems.push('插件内调度器没有心跳(确认 dsh 进程在跑,或 schedule.enabled=false)')
|
|
384
392
|
}
|
|
385
393
|
|
|
@@ -544,6 +552,15 @@ async function buildStatus(boardArg) {
|
|
|
544
552
|
out.recent.runs = []
|
|
545
553
|
}
|
|
546
554
|
|
|
555
|
+
if (!configured) {
|
|
556
|
+
// 未配置时只展示 setup 卡片;不要把空 path 触发的“看板读不到/冷却未知”当错误吓用户。
|
|
557
|
+
out.problems = []
|
|
558
|
+
out.todos = []
|
|
559
|
+
out.reviews = []
|
|
560
|
+
out.running = []
|
|
561
|
+
out.recent = { log: [], receipt: null, runs: [] }
|
|
562
|
+
out.timer.raw = 'unconfigured'
|
|
563
|
+
}
|
|
547
564
|
return out
|
|
548
565
|
}
|
|
549
566
|
|
package/panel/locale/en.json
CHANGED
|
@@ -77,6 +77,10 @@
|
|
|
77
77
|
"liveEmpty": "No output yet",
|
|
78
78
|
"liveFollow": "auto-follow",
|
|
79
79
|
"runningNowText": "Running now:",
|
|
80
|
-
"openWorkerSession": "Open worker session"
|
|
80
|
+
"openWorkerSession": "Open worker session",
|
|
81
|
+
"setupTitle": "One-time setup",
|
|
82
|
+
"setupBody": "Run this inside the git repository you want to batch:",
|
|
83
|
+
"setupAfter": "Then reload or restart dsh. The board appears here; work only starts off-peak while the machine is idle.",
|
|
84
|
+
"setupConfigPath": "Config file:"
|
|
81
85
|
}
|
|
82
86
|
}
|
package/panel/locale/zh.json
CHANGED
|
@@ -77,6 +77,10 @@
|
|
|
77
77
|
"liveEmpty": "还没有输出(等它开始吐字)",
|
|
78
78
|
"liveFollow": "自动跟随最新",
|
|
79
79
|
"runningNowText": "正在跑批:",
|
|
80
|
-
"openWorkerSession": "打开处理会话(对话/轨迹)"
|
|
80
|
+
"openWorkerSession": "打开处理会话(对话/轨迹)",
|
|
81
|
+
"setupTitle": "先完成一次性初始化",
|
|
82
|
+
"setupBody": "在要跑批的 git 仓库里运行:",
|
|
83
|
+
"setupAfter": "然后刷新或重启 dsh;左侧会出现看板,只有低谷且机器空闲时才会自动跑。",
|
|
84
|
+
"setupConfigPath": "配置文件:"
|
|
81
85
|
}
|
|
82
86
|
}
|
package/skills/offhours/SKILL.md
CHANGED
|
@@ -17,10 +17,11 @@ description: "夜间/低谷自动跑批(dsh-offhours):把看板待办在
|
|
|
17
17
|
## 建卡(唯一正确入口)
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
offhours card add "<标题>" --summary "<目标 + 判据(命令) + 涉及面 + 不做什么>"
|
|
20
|
+
npx dsh-offhours card add "<标题>" --summary "<目标 + 判据(命令) + 涉及面 + 不做什么>"
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
- 必须在 dsh 会话里执行:它会用 `DSH_SESSION_ID` 盖章 `sourceSessionId`;
|
|
24
|
+
- CLI 没进全局 PATH:统一用 `npx dsh-offhours ...`(或 profile 里的 `node_modules/.bin/offhours`)。
|
|
24
25
|
**没有 sourceSessionId 的卡跑批永远不会捡**(不是 bug,是隔离要求)。
|
|
25
26
|
- 用 dsh-kanban 的 `board_add` 建的卡不带 session,只适合人工看板,不适合跑批。
|
|
26
27
|
- 判据必须是命令,不是形容词;一张卡 = 一个可独立合并的成果。
|
|
@@ -28,10 +29,10 @@ offhours card add "<标题>" --summary "<目标 + 判据(命令) + 涉及面 +
|
|
|
28
29
|
## 查状态 / 手动跑
|
|
29
30
|
|
|
30
31
|
```bash
|
|
31
|
-
offhours doctor # 依赖、配置、权限体检(先跑这个)
|
|
32
|
-
offhours status # 看板计数 + 心跳 + 最近状态(JSON)
|
|
33
|
-
offhours run --once # 手动跑一轮(生产调度器之外)
|
|
34
|
-
offhours run --dry-run # 只看会挑哪张,不启动 worker
|
|
32
|
+
npx dsh-offhours doctor # 依赖、配置、权限体检(先跑这个)
|
|
33
|
+
npx dsh-offhours status # 看板计数 + 心跳 + 最近状态(JSON)
|
|
34
|
+
npx dsh-offhours run --once # 手动跑一轮(生产调度器之外)
|
|
35
|
+
npx dsh-offhours run --dry-run # 只看会挑哪张,不启动 worker
|
|
35
36
|
```
|
|
36
37
|
|
|
37
38
|
面板:dsh Web 左侧「夜间跑批」;`/offhours` 命令也可查状态。
|