dsh-offhours 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-offhours contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,93 @@
1
- # Temporary Holding Version
1
+ # dsh-offhours
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DeepSeek Harness 社区插件:把看板里的待办,在**低谷/夜间**自动做掉。
4
+
5
+ - 只做一件事:`todo` 卡 → 每卡一个独立 git worktree → headless worker 干活并自证 → 证据写回卡 → 合并回收。
6
+ - 不需要 cron/systemd:插件进程内按 interval 自己扫;也可以手动 `offhours run --once`。
7
+ - 你的数据只在本地:看板是 `KANBAN.json`,状态/日志在 `stateDir`,不上传。
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ dsh plugin --profile web add dsh-offhours
13
+ ```
14
+
15
+ 装完重启一次 `dsh web`(或让 HMR 生效),左侧出现「夜间跑批」面板。
16
+
17
+ ## 三步开始
18
+
19
+ ```bash
20
+ # 1) 在你要跑批的 git 仓库里初始化(写配置 + 建/关联特性 + 生成看板)
21
+ cd ~/code/myapp
22
+ offhours init --repo . --name myapp
23
+
24
+ # 2) 体检(依赖、路径、worker 权限)
25
+ offhours doctor
26
+
27
+ # 3) 建一张卡(自动盖当前会话;没有 session 的卡跑批不会捡)
28
+ offhours card add "给登录页补错误提示" \
29
+ --summary "目标:登录失败有提示。判据:npm test 通过。不做什么:不改后端。"
30
+ ```
31
+
32
+ 之后低谷时段自动跑;也可以用面板「立即跑」或 `offhours run --once`。
33
+
34
+ ## 配置
35
+
36
+ `~/.dsh/offhours/config.json`(`offhours init` 生成):
37
+
38
+ ```json
39
+ {
40
+ "stateDir": "~/.dsh/offhours/state",
41
+ "featuresRoot": "~/.dsh/offhours/features",
42
+ "dshBin": "dsh",
43
+ "dshProfile": "headless",
44
+ "scheduler": "plugin",
45
+ "repos": {
46
+ "myapp": { "path": "~/code/myapp", "baseBranch": "main" }
47
+ },
48
+ "boards": [
49
+ { "id": "myapp", "file": "~/code/myapp/KANBAN.json", "workdir": "~/code/myapp", "repos": ["myapp"] }
50
+ ],
51
+ "gates": { "maxCardsPerRun": 3, "maxCardsPerDay": 8, "maxCnyPerDay": 20 },
52
+ "schedule": { "enabled": true, "intervalMin": 5 },
53
+ "worker": { "permissionMode": "danger-full-access" }
54
+ }
55
+ ```
56
+
57
+ - `baseBranch` 支持 `main`/`master`/任意分支;不写则按 `origin/HEAD`、main、master 依次探测。
58
+ - `worker.permissionMode` 默认 `danger-full-access`:worker 要写 `<repo>/.git/worktrees` 才能提交。
59
+ 这是无人值守的结构性要求;不信任仓库内容时不要装这个插件。
60
+
61
+ ## 命令
62
+
63
+ | 命令 | 作用 |
64
+ |---|---|
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` | 看板列表 |
72
+
73
+ ## 依赖
74
+
75
+ Linux(WSL 可用):`bash`、`python3`、`git`、`jq`、`flock`、`dsh`;`zstd` 可选(成本统计)。
76
+ macOS 暂不支持(引擎用到 `flock`、`/proc`、GNU `readlink -f` 等);Windows 走 WSL。
77
+
78
+ ## 安全与边界
79
+
80
+ - worker 在独立 worktree 里工作,主检出由引擎的跑批前后侦测兜底;建议同时启用 `feature-guard` 钩子。
81
+ - 送审只留 5 类:选型不确定 / 影响演进 / 实施成本 / 条件做不成 / 政策风险。
82
+ - 卡里必须有可机械核对的判据(命令),否则 worker 不得报 done。
83
+
84
+ ## 开发
85
+
86
+ ```bash
87
+ node scripts/gen-client.mjs # 生成 lib/client.js(只替换注册 id)
88
+ node --test tests/node/*.test.mjs
89
+ bash tests/run-all.sh
90
+ npm pack
91
+ ```
92
+
93
+ MIT License。
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-offhours CLI —— 给社区用户的唯一入口(不依赖面板也能跑)。
4
+ *
5
+ * offhours init --repo ~/code/app 写配置 + 建/关联特性 + 生成看板
6
+ * offhours doctor 依赖/配置/权限体检(失败退出非 0)
7
+ * offhours card add "标题" --summary "..." 建卡(自动盖当前会话,跑批才捡得到)
8
+ * offhours card list 列卡
9
+ * offhours run [--once|--dry-run|--force] 手动跑一轮
10
+ * offhours status 状态摘要(JSON)
11
+ * offhours boards 看板列表
12
+ */
13
+ import { spawn, spawnSync } from 'node:child_process'
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
15
+ import { basename, join, resolve } from 'node:path'
16
+ import {
17
+ defaultConfig, defaultConfigPath, featureBin, loadConfig, nsPy, packageRoot,
18
+ resolveBoard, saveConfig, scanEnv, scanScript, scanEnv as _scanEnv,
19
+ } from '../lib/config.js'
20
+
21
+ const HELP = `dsh-offhours —— 夜间/低谷自动跑批(配一个 git 仓库就能用)
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
31
+
32
+ 全局:--config <path>(默认 ~/.dsh/offhours/config.json)
33
+
34
+ 约束:worker 需要 dsh、python3、bash、git、jq、flock;zstd 可选(成本统计)。
35
+ `
36
+
37
+ function parse(argv) {
38
+ const out = { _: [], flags: {} }
39
+ for (let i = 0; i < argv.length; i += 1) {
40
+ const a = argv[i]
41
+ if (a === '--config') { out.flags.config = argv[++i]; continue }
42
+ if (a.startsWith('--config=')) { out.flags.config = a.slice(9); continue }
43
+ if (a.startsWith('--')) {
44
+ const eq = a.indexOf('=')
45
+ if (eq > 0) out.flags[a.slice(2, eq)] = a.slice(eq + 1)
46
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) out.flags[a.slice(2)] = argv[++i]
47
+ else out.flags[a.slice(2)] = true
48
+ continue
49
+ }
50
+ out._.push(a)
51
+ }
52
+ return out
53
+ }
54
+
55
+ function die(msg, code = 1) {
56
+ console.error(`offhours: ${msg}`)
57
+ process.exit(code)
58
+ }
59
+
60
+ function loadOrDie(flags) {
61
+ const { config, path, exists } = loadConfig(flags.config)
62
+ if (!exists) die(`没有配置:${path}\n先运行:offhours init --repo <git仓库路径>`)
63
+ if (!config.boards.length) die(`配置里没有看板:${path}\n先运行:offhours init --repo <git仓库路径>`)
64
+ return { cfg: config, path }
65
+ }
66
+
67
+ function which(cmd) {
68
+ const r = spawnSync('bash', ['-lc', `command -v ${cmd}`], { encoding: 'utf8' })
69
+ return r.status === 0 ? String(r.stdout || '').trim() : ''
70
+ }
71
+
72
+ function detectBase(repo) {
73
+ const r = spawnSync('git', ['-C', repo, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { encoding: 'utf8' })
74
+ if (r.status === 0 && r.stdout.trim()) return r.stdout.trim().split('/').pop()
75
+ for (const b of ['main', 'master']) {
76
+ const q = spawnSync('git', ['-C', repo, 'show-ref', '--verify', '--quiet', `refs/heads/${b}`])
77
+ if (q.status === 0) return b
78
+ }
79
+ return 'main'
80
+ }
81
+
82
+ function cmdInit(flags) {
83
+ const repo = resolve(flags.repo || process.cwd())
84
+ if (!existsSync(join(repo, '.git'))) die(`不是 git 仓库:${repo}`)
85
+ const name = String(flags.name || basename(repo)).replace(/[^A-Za-z0-9_-]+/g, '-')
86
+ const boardFile = resolve(String(flags.board || join(repo, 'KANBAN.json')))
87
+ const cfgPath = resolve(flags.config || defaultConfigPath())
88
+ if (existsSync(cfgPath) && !flags.force) die(`配置已存在:${cfgPath}(要重建加 --force)`)
89
+ const base = String(flags['base-branch'] || detectBase(repo))
90
+ const feature = String(flags.feature || 'offhours')
91
+ const cfg = defaultConfig()
92
+ cfg.stateDir = resolve(String(flags['state-dir'] || join(cfg.stateDir)))
93
+ cfg.featuresRoot = resolve(String(flags['features-root'] || join(cfg.featuresRoot)))
94
+ cfg.dshHome = process.env.DSH_HOME || cfg.dshHome
95
+ cfg.repos = { [name]: { path: repo, baseBranch: base, remote: String(flags.remote || '') } }
96
+ cfg.boards = [{
97
+ id: name, label: name, file: boardFile, workdir: repo, repos: [name],
98
+ requireAuto: false, enabled: true, unit: '', envFile: '',
99
+ }]
100
+ if (String(flags.scheduler || 'plugin') === 'systemd') cfg.scheduler = 'systemd'
101
+ if (flags.interval) cfg.schedule.intervalMin = Number(flags.interval)
102
+ if (!existsSync(boardFile)) {
103
+ mkdirSync(join(boardFile, '..'), { recursive: true })
104
+ writeFileSync(boardFile, '{\n "version": 1,\n "cards": []\n}\n', 'utf8')
105
+ }
106
+ mkdirSync(cfg.featuresRoot, { recursive: true })
107
+ writeFileSync(join(cfg.featuresRoot, '.repos.json'),
108
+ JSON.stringify({ [name]: { path: repo, baseBranch: base } }, null, 2) + '\n', 'utf8')
109
+ saveConfig(cfgPath, cfg)
110
+
111
+ const env = {
112
+ ...process.env,
113
+ FEATURE_ROOT: cfg.featuresRoot,
114
+ DSH_SESSION_ID: String(flags.session || process.env.DSH_SESSION_ID || ''),
115
+ }
116
+ const fb = featureBin(cfg)
117
+ const args = ['new', feature, '--repos', name, '--from', base]
118
+ let r = spawnSync(fb, args, { env, encoding: 'utf8' })
119
+ if (r.status !== 0) r = spawnSync(fb, ['ensure', feature, '--repos', name], { env, encoding: 'utf8' })
120
+ const featOut = String((r.stdout || r.stderr || '').trim()).split('\n').slice(-2).join('\n')
121
+
122
+ console.log(`✓ 配置:${cfgPath}`)
123
+ console.log(`✓ 仓库:${name} → ${repo}(基准分支 ${base})`)
124
+ console.log(`✓ 看板:${boardFile}`)
125
+ console.log(`✓ 特性:${feature}(${r.status === 0 ? '已建立/已关联' : '建立失败,可稍后 feature attach'})`)
126
+ if (featOut) console.log(featOut)
127
+ console.log('')
128
+ console.log('下一步:')
129
+ console.log(` offhours doctor`)
130
+ console.log(` offhours card add "第一张卡" --summary "目标/判据/不做什么"`)
131
+ console.log(` offhours run --once`)
132
+ }
133
+
134
+ function cmdDoctor(flags) {
135
+ const { config: cfg, path } = loadConfig(flags.config)
136
+ const rows = []
137
+ const fail = []
138
+ const hard = (name, ok, detail = '') => {
139
+ rows.push([name, ok ? 'ok' : 'FAIL', detail])
140
+ if (!ok) fail.push(name)
141
+ }
142
+ hard('配置文件', existsSync(path), path)
143
+ hard('看板数量', cfg.boards.length > 0, `${cfg.boards.length} 个`)
144
+ for (const b of cfg.boards) {
145
+ hard(`看板 ${b.id}`, existsSync(b.file), b.file)
146
+ hard(`仓库 ${b.repos[0] || '?'}`, !!cfg.repos[b.repos[0]] && existsSync(cfg.repos[b.repos[0]].path),
147
+ (cfg.repos[b.repos[0]] || {}).path || '(未登记)')
148
+ }
149
+ for (const [name, bin] of [['python3', 'python3'], ['bash', 'bash'], ['git', 'git'], ['jq', 'jq'], ['flock', 'flock'], ['dsh', cfg.dshBin]]) {
150
+ const p = which(bin)
151
+ hard(name, !!p, p || '未找到')
152
+ }
153
+ if (!which('zstd')) rows.push(['zstd', 'warn', '未安装:成本统计会降级,其余功能可用'])
154
+ hard('scan.sh', existsSync(scanScript(cfg)), scanScript(cfg))
155
+ hard('ns.py', existsSync(nsPy(cfg)), nsPy(cfg))
156
+ hard('feature CLI', existsSync(featureBin(cfg)), featureBin(cfg))
157
+ if (process.platform !== 'linux') rows.push(['平台', 'warn', `${process.platform}:v1 只承诺 Linux(WSL 可用)`])
158
+ rows.push(['worker 权限', 'info', cfg.worker.permissionMode])
159
+ const width = Math.max(...rows.map((r) => r[0].length))
160
+ for (const [k, st, d] of rows) console.log(` ${k.padEnd(width)} ${String(st).padEnd(5)} ${d}`)
161
+ if (fail.length) die(`体检未通过:${fail.join('、')}`)
162
+ console.log('\n体检通过。')
163
+ }
164
+
165
+ function boardOf(cfg, flags) {
166
+ const b = resolveBoard(cfg, flags.board)
167
+ if (!b) die('没有可用看板')
168
+ return b
169
+ }
170
+
171
+ function cmdCard(args, flags) {
172
+ const { cfg } = loadOrDie(flags)
173
+ const board = boardOf(cfg, flags)
174
+ const sub = args[0] || 'list'
175
+ if (sub === 'list') {
176
+ const d = JSON.parse(readFileSync(board.file, 'utf8'))
177
+ const rows = (d.cards || []).map((c) => [c.id, c.status, (c.tags || []).join(','), c.title])
178
+ for (const r of rows) console.log(r.join('\t'))
179
+ return
180
+ }
181
+ if (sub !== 'add') die('用法:offhours card add "<标题>" --summary "..."')
182
+ const title = args.slice(1).join(' ').trim()
183
+ if (!title) die('card add 需要一个标题')
184
+ const pyArgs = [nsPy(cfg), 'card-add', board.file, title,
185
+ '--session', String(flags.session || process.env.DSH_SESSION_ID || ''),
186
+ ]
187
+ for (const k of ['summary', 'description', 'rationale', 'rejected', 'tags', 'status', 'run-timeout']) {
188
+ if (flags[k] !== undefined) pyArgs.push(`--${k}`, String(flags[k]))
189
+ }
190
+ if (flags['no-explain']) pyArgs.push('--no-explain')
191
+ const env = { ...process.env, DSH_SESSION_ID: String(flags.session || process.env.DSH_SESSION_ID || '') }
192
+ const r = spawnSync('python3', pyArgs, { env, encoding: 'utf8' })
193
+ process.stdout.write(r.stdout || '')
194
+ process.stderr.write(r.stderr || '')
195
+ if (r.status !== 0) process.exit(r.status || 1)
196
+ }
197
+
198
+ function cmdRun(args, flags) {
199
+ const { cfg } = loadOrDie(flags)
200
+ const board = boardOf(cfg, flags)
201
+ const scanArgs = []
202
+ if (flags.dry || flags['dry-run']) scanArgs.push('--dry-run')
203
+ else if (flags.force) scanArgs.push('--force')
204
+ if (flags.max) scanArgs.push(`--max-cards=${flags.max}`)
205
+ if (flags.only) scanArgs.push(`--only=${flags.only}`)
206
+ const env = scanEnv(cfg, board)
207
+ const child = spawn('bash', [scanScript(cfg), ...scanArgs], { env, stdio: 'inherit' })
208
+ child.on('exit', (code) => process.exit(code || 0))
209
+ }
210
+
211
+ function cmdStatus(flags) {
212
+ const { cfg } = loadOrDie(flags)
213
+ const board = boardOf(cfg, flags)
214
+ const read = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return null } }
215
+ const boardData = read(board.file) || { cards: [] }
216
+ const cards = Array.isArray(boardData.cards) ? boardData.cards : []
217
+ const hb = read(join(cfg.stateDir, 'heartbeat.json'))
218
+ const st = read(join(cfg.stateDir, `state-${board.id}.json`)) || {}
219
+ const out = {
220
+ ok: true,
221
+ board: { id: board.id, file: board.file, workdir: board.workdir },
222
+ counts: {
223
+ todo: cards.filter((c) => c.status === 'todo').length,
224
+ inProgress: cards.filter((c) => c.status === 'in_progress').length,
225
+ review: cards.filter((c) => c.status === 'review').length,
226
+ done: cards.filter((c) => c.status === 'done').length,
227
+ },
228
+ schedule: cfg.schedule,
229
+ heartbeat: hb,
230
+ state: st,
231
+ problems: cfg.problems || [],
232
+ }
233
+ console.log(JSON.stringify(out, null, 2))
234
+ }
235
+
236
+ function main() {
237
+ const { _, flags } = parse(process.argv.slice(2))
238
+ const cmd = _[0]
239
+ if (!cmd || flags.help || cmd === 'help') { console.log(HELP); return }
240
+ if (cmd === 'init') return cmdInit(flags)
241
+ if (cmd === 'doctor') return cmdDoctor(flags)
242
+ if (cmd === 'card') return cmdCard(_.slice(1), flags)
243
+ if (cmd === 'run') return cmdRun(_.slice(1), flags)
244
+ if (cmd === 'status') return cmdStatus(flags)
245
+ if (cmd === 'boards') {
246
+ const { cfg } = loadOrDie(flags)
247
+ for (const b of cfg.boards) console.log(`${b.id}\t${b.file}`)
248
+ return
249
+ }
250
+ die(`未知命令:${cmd}\n\n${HELP}`)
251
+ }
252
+
253
+ main()
@@ -0,0 +1,6 @@
1
+ # dsh-offhours 的插件层:profile 里插入一行即可;真正的配置在 config.json。
2
+ - insert:
3
+ - id: offhours
4
+ name: dsh-offhours
5
+ config:
6
+ configFile: ''