dsh-maoying 0.1.11 → 0.1.13

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.
@@ -28,7 +28,7 @@
28
28
  * 绝不让它炸掉整个快照。
29
29
  */
30
30
 
31
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
31
+ import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
32
32
  import path from 'node:path'
33
33
 
34
34
  import { buildIndexYaml, chapterSortKey, collectChapters, countCjk, parseFlatYamlArray, parseScalarText, splitFrontmatter, stripInlineComment } from './novel-parse.mjs'
@@ -176,6 +176,22 @@ function joinLines(lines, originalText) {
176
176
  return lines.join(eol)
177
177
  }
178
178
 
179
+ /**
180
+ * 原子落盘:先写同目录临时文件,再 renameSync 原子替换——truncate+write
181
+ * 直写期间并发读会看到半截文件,rename 在同一文件系统上不存在中间态。
182
+ * 临时文件藏在同目录(保证 rename 跨设备语义成立),失败时扫尾后原样上抛。
183
+ */
184
+ function writeAtomic(target, data) {
185
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`)
186
+ try {
187
+ writeFileSync(tmp, data, 'utf8')
188
+ renameSync(tmp, target)
189
+ } catch (err) {
190
+ try { unlinkSync(tmp) } catch { /* 临时文件没立起来就没什么可扫 */ }
191
+ throw err
192
+ }
193
+ }
194
+
179
195
  // ── frontmatter 解析(yaml 可用走真解析,否则正则平键 + 条目块)───────────
180
196
 
181
197
  /**
@@ -184,7 +200,7 @@ function joinLines(lines, originalText) {
184
200
  * 一层列表);规范禁止深嵌套,超出形状的行原样忽略。
185
201
  */
186
202
  function parseFrontmatterFallback(raw) {
187
- const meta = {}
203
+ const meta = Object.create(null) // 无原型对象:__proto__ 键只进自身属性,改写不了原型
188
204
  let listKey = null
189
205
  for (const line of raw.split(/\r?\n/)) {
190
206
  const clean = stripInlineComment(line).replace(/\s+$/, '')
@@ -376,6 +392,7 @@ function readProject(novelDir, yamlLib, warnings) {
376
392
  length: strOrNull(data?.length),
377
393
  language: strOrNull(data?.language),
378
394
  phase: strOrNull(data?.phase),
395
+ mode: strOrNull(data?.mode),
379
396
  description: strOrNull(data?.description),
380
397
  }
381
398
  }
@@ -565,6 +582,20 @@ export function readChapter(novelDir, file, options = {}) {
565
582
 
566
583
  // ── 写操作:行级原地替换 ────────────────────────────────────────────────────
567
584
 
585
+ /**
586
+ * 保注释赋值刀:把一行 `缩进key: 旧值 # 行尾注释` 的值换成 newValue,行尾
587
+ * 注释(连同值与注释间的空白)原样保留。值/注释切分复用 novel-parse 的
588
+ * stripInlineComment——引号内的 # 不算注释(`长#夜` 是合法标量),避开
589
+ * `(.*?)` 在值内第一个 # 提前截断、同值连存两次产出非法 YAML 的刀法损坏。
590
+ * 行须命中 headRe(捕获组 1 = `缩进key:`),未命中返回 null 由调用方兜底。
591
+ */
592
+ function rewriteValueKeepComment(line, headRe, newValue) {
593
+ const body = stripInlineComment(line).replace(/\s+$/, '')
594
+ const head = headRe.exec(body)
595
+ if (head === null) return null
596
+ return `${head[1]} ${newValue}${line.slice(body.length)}`
597
+ }
598
+
568
599
  /**
569
600
  * 章节状态流转:只动 frontmatter 围栏内的 status 行,其余字节原样保留。
570
601
  * status 出枚举 422;文件不存在 404;无围栏 / status 行缺失或多处 409 拒写。
@@ -575,11 +606,15 @@ export function mutateChapterStatus(novelDir, file, status) {
575
606
  const text = readTarget(target, `章节 ${name}`)
576
607
  const fence = splitFrontmatter(text)
577
608
  if (fence.raw === null) fail(409, `${name} 没有 frontmatter 围栏,无处替换 status 行`)
578
- const replaced = replaceLineOnce(fence.raw, /^status:[^\n]*?$/, `status: ${status}`, {
579
- what: `${name} frontmatter 的 status 行`,
580
- })
609
+ const replaced = replaceLineOnce(
610
+ fence.raw,
611
+ /^status:[^\n]*?$/,
612
+ // 保注释刀法:只换值,用户写在行尾的注释原样保留(与 mutatePhase 一致)
613
+ (line) => rewriteValueKeepComment(line, /^(status:)/, status) ?? line,
614
+ { what: `${name} frontmatter 的 status 行` },
615
+ )
581
616
  const next = text.slice(0, fence.index) + replaced + text.slice(fence.index + fence.raw.length)
582
- writeFileSync(target, next, 'utf8')
617
+ writeAtomic(target, next)
583
618
  return { ok: true, file: name, status }
584
619
  }
585
620
 
@@ -617,8 +652,9 @@ export function mutateForeshadowStatus(novelDir, id, status) {
617
652
  }
618
653
  }
619
654
  if (hits !== 1) fail(409, `foreshadows.yml 条目 ${id} 的 status 行应恰有一处,实得 ${hits} 处——已拒绝写入`)
620
- lines[hit] = lines[hit].replace(/^([ \t]*status:)[ \t]*.*$/, `$1 ${status}`)
621
- writeFileSync(target, joinLines(lines, text), 'utf8')
655
+ // 保注释刀法:只换值,行尾注释原样保留(headRe 弱于上方计数正则,兜底仅为防御)
656
+ lines[hit] = rewriteValueKeepComment(lines[hit], /^([ \t]*status:)/, status) ?? lines[hit]
657
+ writeAtomic(target, joinLines(lines, text))
622
658
  return { ok: true, id, status }
623
659
  }
624
660
 
@@ -636,10 +672,80 @@ export function mutatePhase(novelDir, phase) {
636
672
  const next = text.replace(new RegExp(phaseRe.source, 'm'), (line) =>
637
673
  line.replace(/^(phase:[ \t]*)(.*?)([ \t]*(?:#.*)?)$/, (_m, key, _value, comment) => `${key}${phase}${comment ?? ''}`),
638
674
  )
639
- writeFileSync(target, next, 'utf8')
675
+ writeAtomic(target, next)
640
676
  return { ok: true, phase }
641
677
  }
642
678
 
679
+ const PROJECT_MODE_KEYS = ['ai-draft', 'co-write', 'author-draft']
680
+
681
+ /**
682
+ * 小说设置:project.yml 平键批量改写(创作台「小说设置」tab)。逐键做
683
+ * 「单行值替换、行尾注释原样保留」(与 mutatePhase 同一保注释刀法);文件里
684
+ * 没有的键按模板顺序补在末尾(带模板注释)。校验:phase/mode 越枚举 422、
685
+ * title 空白 422。值统一经 yamlScalar 规范成可回读的 YAML 标量。
686
+ */
687
+ export function mutateProject(novelDir, values) {
688
+ if (values === null || typeof values !== 'object' || Array.isArray(values)) {
689
+ fail(422, 'values 必须是键值对象')
690
+ }
691
+ const phase = typeof values.phase === 'string' ? values.phase : ''
692
+ if (!PHASE_KEYS.includes(phase)) fail(422, `phase 非法「${phase}」——七态只认 ${PHASE_KEYS.join(' / ')}`)
693
+ const mode = typeof values.mode === 'string' ? values.mode : ''
694
+ if (!PROJECT_MODE_KEYS.includes(mode)) fail(422, `mode 非法「${mode}」——只认 ${PROJECT_MODE_KEYS.join(' / ')}`)
695
+ const title = typeof values.title === 'string' ? values.title.trim() : ''
696
+ if (title === '') fail(422, 'title 不能为空——书稿总得有个名字')
697
+ const language = typeof values.language === 'string' && values.language.trim() !== '' ? values.language.trim() : 'zh'
698
+ const textFields = ['author', 'genre', 'length', 'description']
699
+ const next = { title, author: '', genre: '', length: '', language, phase, mode, description: '' }
700
+ for (const k of textFields) next[k] = typeof values[k] === 'string' ? values[k].trim() : ''
701
+
702
+ const target = path.join(novelDir, 'project.yml')
703
+ const text = readTarget(target, 'project.yml')
704
+ const lines = text.split(/\r?\n/)
705
+ const pending = new Map(Object.entries(next))
706
+ for (let i = 0; i < lines.length; i++) {
707
+ const m = /^([ \t]*)([A-Za-z][A-Za-z0-9_-]*):/.exec(lines[i])
708
+ if (!m || !pending.has(m[2])) continue
709
+ const [, indent, key] = m
710
+ // 保注释刀法:值/注释切分走 stripInlineComment(引号内 # 不算注释),
711
+ // 注释原样保留在行尾;键行形状异常时原样保留,宁缺毋写坏
712
+ lines[i] = rewriteValueKeepComment(lines[i], /^([ \t]*[A-Za-z][A-Za-z0-9_-]*:)/, yamlScalar(next[key])) ?? lines[i]
713
+ pending.delete(key)
714
+ }
715
+ // 模板补齐顺序:缺失键插在首个非注释内容行前,保持可读
716
+ if (pending.size > 0) {
717
+ const header = ['# project.yml —— 书稿元信息(平键,无嵌套)。phase 是七阶段工作流的状态机指针,', '# 每完成一个里程碑由主编推进并简报用户;mode 决定正文归谁写。']
718
+ const missing = [...pending.entries()].map(([k, v]) => `${k}: ${yamlScalar(v)}`)
719
+ let insertAt = lines.findIndex((l) => l.trim() !== '' && !l.trim().startsWith('#'))
720
+ if (insertAt === -1) insertAt = lines.length
721
+ if (lines.every((l) => l.trim() === '' || l.trim().startsWith('#'))) {
722
+ lines.splice(insertAt, 0, ...header, ...missing)
723
+ } else {
724
+ lines.splice(insertAt, 0, ...missing)
725
+ }
726
+ }
727
+ writeAtomic(target, joinLines(lines, text))
728
+ return { ok: true }
729
+ }
730
+
731
+ /** YAML 平标量:安全字符裸写,含特殊字符(冒号空格/井号/引号/首尾空格等)
732
+ * 时双引号包裹并转义;空串写成 ""。 */
733
+ function yamlScalar(v) {
734
+ const s = String(v ?? '')
735
+ if (s === '') return '""'
736
+ // 类型漂移防护:形如布尔/数字/null 的字符串(《2046》这类书名)裸写会被
737
+ // YAML 回读成 number/boolean/null,strOrNull 置 null 后面板显示「—」——
738
+ // 一律双引号包裹,保住「回读仍是原字符串」(与 novel-parse 的 flowScalar 同口径)。
739
+ if (/^(true|false|null|~|yes|no|on|off|-?\d+|-?\d*\.\d+)$/i.test(s)) {
740
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
741
+ }
742
+ if (/^[\w\u4e00-\u9fff\u3000-\u303f\uff00-\uffef.,;:!?()()【】《》「」『』·、—…·//%+×\- ]+$/.test(s)
743
+ && !/(^|[\s:])#/.test(s) && !/:\s/.test(s) && !/^[-?:,\[\]{}#&*!|>'"%@`]/.test(s) && !/\s$/.test(s)) {
744
+ return s
745
+ }
746
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
747
+ }
748
+
643
749
  /**
644
750
  * 重建 index.yml(与 CLI `maoying index` 同一实现):chapters/ 缺失 404;
645
751
  * 成功返回章节数。
@@ -655,6 +761,6 @@ export function rebuildIndex(novelDir) {
655
761
  volumesRaw = null // volumes.yml 缺失/读不动:按无分卷重建,章节照收
656
762
  }
657
763
  const { text, chaptersCount } = buildIndexYaml(chapters, volumesRaw)
658
- writeFileSync(path.join(novelDir, 'index.yml'), text, 'utf8')
764
+ writeAtomic(path.join(novelDir, 'index.yml'), text)
659
765
  return { ok: true, chapters: chaptersCount }
660
766
  }
package/lib/version.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * 版本提示 —— 检测线上是否有比本地更新的版本(创作台面板用)。
3
3
  *
4
- * 同构移植自 dsh-paoding tools/lib/version.mjs(用户指定模仿其机制)。检测源
5
- * 三级:npm registry 的 /latest 端点优先(无认证无限流,发布即可见);失败再
6
- * 试 npmmirror 镜像(官方源超时 / 被墙时国内几秒内可达,分钟级同步延迟对
7
- * 「有没有新版」这个判断无伤大雅);最后 GitHub releases/latest 兜底。
4
+ * 同构移植自 dsh-paoding tools/lib/version.mjs(用户指定模仿其机制)。检测
5
+ * 源三级:npm registry 与 npmmirror 镜像两路并行竞速(manifest 形状与数据
6
+ * 一致,先成功者胜,官方源超时 / 被墙时国内几秒内可达的镜像顶上,分钟级
7
+ * 同步延迟对「有没有新版」这个判断无伤大雅);两路 npm 源皆败再串行走
8
+ * GitHub releases/latest 兜底(独立数据源)。
8
9
  *
9
10
  * 零依赖纯 Node(>=18):不引 cordis / npm 依赖,fetch 可注入(缺省用
10
11
  * globalThis.fetch),超时用 AbortSignal.timeout()。所有失败路径都不抛错,
@@ -57,25 +58,47 @@ export async function currentVersion() {
57
58
  return { version: FALLBACK_VERSION, source: 'fallback' }
58
59
  }
59
60
 
60
- /** 拆版本号:剥前缀 v/V,按 '.' 切数值段,`-` 后为预发布后缀(原样保留)。 */
61
+ /**
62
+ * 拆版本号:剥前缀 v/V,按 '.' 切段,`-` 后为预发布后缀(原样保留)。核心段
63
+ * 必须纯数字才转数值,非纯数字段(如 '12abc'、空串)一律按 0——parseInt 的
64
+ * 前缀解析会把 '12abc' 当成 12,这里显式拒绝。
65
+ */
61
66
  function parseVersion(value) {
62
67
  let s = String(value ?? '').trim()
63
68
  if (s.startsWith('v') || s.startsWith('V')) s = s.slice(1)
64
69
  const dash = s.indexOf('-')
65
70
  const coreText = dash === -1 ? s : s.slice(0, dash)
66
71
  const pre = dash === -1 ? '' : s.slice(dash + 1)
67
- const core = coreText.split('.').map((n) => {
68
- const x = parseInt(n, 10)
69
- return Number.isFinite(x) ? x : 0
70
- })
72
+ const core = coreText.split('.').map((n) => (/^\d+$/.test(n) ? Number(n) : 0))
71
73
  return { core, pre }
72
74
  }
73
75
 
76
+ /**
77
+ * 预发布段逐段比较:按 '.' 切段,纯数字段转数值比(rc.10 必须大于 rc.2,
78
+ * 字符串序会误判 'rc.10' < 'rc.2'),非数字段按字典序;段数不齐缺段视为
79
+ * 空段,空段小于非空段(semver 口径:rc.1 < rc.1.0)。
80
+ */
81
+ function comparePre(a, b) {
82
+ const sa = a.split('.')
83
+ const sb = b.split('.')
84
+ const len = Math.max(sa.length, sb.length)
85
+ for (let i = 0; i < len; i++) {
86
+ const x = sa[i] ?? ''
87
+ const y = sb[i] ?? ''
88
+ if (x === y) continue
89
+ if (x === '') return -1 // 缺段(空)< 非空
90
+ if (y === '') return 1
91
+ if (/^\d+$/.test(x) && /^\d+$/.test(y)) return Number(x) < Number(y) ? -1 : 1
92
+ return x < y ? -1 : 1 // 非数字段(含数字段与非数字段混排)按字典序
93
+ }
94
+ return 0
95
+ }
96
+
74
97
  /**
75
98
  * 版本比较(可测纯函数):a < b 返回 -1,相等返回 0,a > b 返回 1。
76
99
  * 简化语义(不追求完整 semver):剥前缀 v/V 后按 '.' 逐段数值比较,缺段补 0;
77
100
  * 核心段相同时带预发布后缀(如 0.3.0-rc.1)视为低于无后缀同版本,两个都有
78
- * 预发布后缀则按字符串比较。
101
+ * 预发布后缀则按 '.' 切段逐段比较(数字段数值比、其余字典序,见 comparePre)。
79
102
  */
80
103
  export function compareVersions(a, b) {
81
104
  const pa = parseVersion(a)
@@ -89,7 +112,7 @@ export function compareVersions(a, b) {
89
112
  if (pa.pre !== pb.pre) {
90
113
  if (pa.pre === '') return 1 // 无后缀 > 有后缀
91
114
  if (pb.pre === '') return -1
92
- return pa.pre < pb.pre ? -1 : pa.pre > pb.pre ? 1 : 0
115
+ return comparePre(pa.pre, pb.pre)
93
116
  }
94
117
  return 0
95
118
  }
@@ -190,38 +213,67 @@ export async function fetchLatestFromMirror(opts) {
190
213
  }
191
214
 
192
215
  /**
193
- * 组合结果(面板路由专用):带 1 小时内存缓存,过期才重新请求;请求失败时
194
- * 若上次成功结果在 lastGood 窗口(7 天,见 LASTGOOD_TTL_MS)内则原样复用
195
- * (stale-if-error),否则返回带 error 的降级结果(latest 置 null、
196
- * updateAvailable 恒 false——调用方静默)。当前版本不可知(FALLBACK_VERSION
197
- * 兜底)时 updateAvailable 恒 false:0.0.0 对任何 latest 都「更小」,比较必
198
- * 误报新版,升级提示整体抑制。检测源三级:npm registry 优先,失败再试
199
- * npmmirror 镜像,最后 GitHub release;三路皆败才算本次检测失败。成功结果的
200
- * source 字段记录命中源。
201
- * @returns {Promise<{current: string, latest: string|null, updateAvailable: boolean,
202
- * releaseUrl: string|null, checkedAt: string, error: string|null,
203
- * source: 'npm'|'mirror'|'github'|null}>}
216
+ * npm 双源并行竞速:registry 与 mirror 同时发起,谁先拿到有效版本(ok:true)
217
+ * 就用谁——两源 manifest 形状与数据一致(mirror 同步自官方源),成功语义
218
+ * 等价,先到先用不改变判定结果;单路超时同为 timeoutMs,npm 阶段最坏耗时
219
+ * 仍由 timeoutMs 封顶(原三级串行最坏 3×timeout 收敛为 npm 并行 1× +
220
+ * GitHub 串行 1×)。失败语义与旧串行链保持一致:registry 错误排前、mirror
221
+ * 排后(对应 failures[0] / failures[1]),两路皆败才由调用方串行走 GitHub
222
+ * 兜底。两路请求都提前挂好回调,先到即决出胜负,后到的结果作废但不产生
223
+ * 未处理异常。
204
224
  */
205
- export async function getVersionInfo({ fetchImpl, timeoutMs = 3000, now } = {}) {
206
- const nowMs = typeof now === 'function' ? now() : typeof now === 'number' ? now : Date.now()
207
- if (cache.info && nowMs - cache.at < CACHE_TTL_MS) return cache.info
225
+ function raceLatestFromNpm(opts) {
226
+ return new Promise((resolve) => {
227
+ const errors = [null, null] // [registry 错误, mirror 错误],全败时透出排障
228
+ let settled = 0 // 已落定的路数
229
+ let winner = false // 是否已决出胜者
230
+ const watch = (promise, index) => {
231
+ promise.then(
232
+ (hit) => {
233
+ if (winner) return // 已有先到者,本路结果作废
234
+ if (hit && hit.ok) {
235
+ winner = true
236
+ resolve({ ok: true, hit })
237
+ } else {
238
+ errors[index] = hit?.error ?? '未知失败'
239
+ }
240
+ },
241
+ (err) => {
242
+ errors[index] = String(err?.message ?? err)
243
+ },
244
+ ).finally(() => {
245
+ settled += 1
246
+ if (!winner && settled === 2) resolve({ ok: false, errors })
247
+ })
248
+ }
249
+ // registry 先挂监听:两路同步落定时胜者确定性偏向官方源
250
+ watch(fetchLatestFromRegistry(opts), 0)
251
+ watch(fetchLatestFromMirror(opts), 1)
252
+ })
253
+ }
254
+
255
+ /**
256
+ * 实际执行一轮检测(缓存 TTL 与并发去重之外的检测主体,供 getVersionInfo
257
+ * 调度):npm registry 与 npmmirror 镜像并行竞速(见 raceLatestFromNpm),
258
+ * 两路皆败才串行请求 GitHub release 兜底;三路皆败按 lastGood / 降级收敛。
259
+ */
260
+ async function detectLatest({ fetchImpl, timeoutMs, nowMs }) {
208
261
  const { version: current, source: currentSource } = await currentVersion()
209
- // 三级链:① npm registry(官方源);② npmmirror 镜像(官方源不可达时的
210
- // 国内快路);③ GitHub releases/latest(镜像也挂时的独立数据源)。
211
- const failures = []
212
- let hit = await fetchLatestFromRegistry({ fetchImpl, timeoutMs })
213
- if (!hit.ok) {
214
- failures.push(hit.error)
215
- hit = await fetchLatestFromMirror({ fetchImpl, timeoutMs })
216
- }
217
- if (!hit.ok) {
218
- failures.push(hit.error)
262
+ let hit
263
+ let failures = []
264
+ const npm = await raceLatestFromNpm({ fetchImpl, timeoutMs })
265
+ if (npm.ok) {
266
+ hit = npm.hit
267
+ } else {
268
+ failures = npm.errors
219
269
  const rel = await fetchLatestRelease({ fetchImpl, timeoutMs })
220
270
  hit = rel.ok && rel.tag
221
271
  ? { ok: true, latest: rel.tag, releaseUrl: rel.url, source: 'github' }
222
272
  : { ok: false, error: rel.ok ? 'GitHub release 尚未发布(404)' : rel.error }
223
273
  }
224
- if (hit && hit.ok) {
274
+ // hit 必为对象(竞速胜者或 GitHub 兜底结果,失败也已收敛为结果对象),
275
+ // 无需再判真值。
276
+ if (hit.ok) {
225
277
  const info = {
226
278
  current,
227
279
  latest: hit.latest ?? null,
@@ -252,3 +304,37 @@ export async function getVersionInfo({ fetchImpl, timeoutMs = 3000, now } = {})
252
304
  source: null,
253
305
  }
254
306
  }
307
+
308
+ // in-flight 去重:并发的 getVersionInfo 调用(面板多处同时拉取)复用同一
309
+ // 次检测 promise,只发一轮外网请求;promise 落定即清空,下次按缓存 TTL 走。
310
+ // 注意并发窗口内各调用共享第一次调用注入的 now 语义(checkedAt 取先到者)。
311
+ let inflight = null
312
+
313
+ /**
314
+ * 组合结果(面板路由专用):带 1 小时内存缓存,过期才重新请求;请求失败时
315
+ * 若上次成功结果在 lastGood 窗口(7 天,见 LASTGOOD_TTL_MS)内则原样复用
316
+ * (stale-if-error),否则返回带 error 的降级结果(latest 置 null、
317
+ * updateAvailable 恒 false——调用方静默)。当前版本不可知(FALLBACK_VERSION
318
+ * 兜底)时 updateAvailable 恒 false:0.0.0 对任何 latest 都「更小」,比较必
319
+ * 误报新版,升级提示整体抑制。检测源:npm registry 与 npmmirror 镜像并行
320
+ * 竞速(先成功者胜),两路皆败才串行请求 GitHub release 兜底;三路皆败才
321
+ * 算本次检测失败。并发调用经 in-flight 去重复用同一次检测。成功结果的
322
+ * source 字段记录命中源。所有返回值均为缓存对象的浅拷贝——缓存要供最长
323
+ * 7 天的 lastGood 复用,调用方原地改写返回对象不得污染缓存。
324
+ * @returns {Promise<{current: string, latest: string|null, updateAvailable: boolean,
325
+ * releaseUrl: string|null, checkedAt: string, error: string|null,
326
+ * source: 'npm'|'mirror'|'github'|null}>}
327
+ */
328
+ export async function getVersionInfo({ fetchImpl, timeoutMs = 3000, now } = {}) {
329
+ const nowMs = typeof now === 'function' ? now() : typeof now === 'number' ? now : Date.now()
330
+ // TTL 内直接复用缓存;一律返回浅拷贝,隔离调用方的原地改写。
331
+ if (cache.info && nowMs - cache.at < CACHE_TTL_MS) return { ...cache.info }
332
+ // in-flight 去重:并发调用共享同一次检测;浅拷贝统一在返回前做。
333
+ if (!inflight) {
334
+ inflight = detectLatest({ fetchImpl, timeoutMs, nowMs }).finally(() => {
335
+ inflight = null
336
+ })
337
+ }
338
+ const info = await inflight
339
+ return { ...info }
340
+ }
@@ -15,10 +15,18 @@ import { fileURLToPath } from 'node:url'
15
15
  /** 插件包根(本文件位于 <包根>/lib/ 下,向上一级即包根)。 */
16
16
  const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
17
17
 
18
- /** DSH_HOME 环境变量优先,缺省 ~/.dsh。 */
18
+ /**
19
+ * DSH_HOME 环境变量优先(支持 ~ 前缀:环境变量不经过 shell 展开,`~/xxx`
20
+ * 或裸 `~` 在这里手动展开为真实家目录,避免拼出相对当前目录的「字面 ~」
21
+ * 不可用路径),缺省 ~/.dsh。
22
+ */
19
23
  export function resolveDshHome(env = process.env) {
20
24
  const fromEnv = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
21
- return fromEnv !== '' ? fromEnv : path.join(os.homedir(), '.dsh')
25
+ if (fromEnv === '') return path.join(os.homedir(), '.dsh')
26
+ if (fromEnv === '~' || fromEnv.startsWith('~/')) {
27
+ return path.join(os.homedir(), fromEnv.slice(1))
28
+ }
29
+ return fromEnv
22
30
  }
23
31
 
24
32
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-maoying",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "毛颖 — DSH 网文创作辅助插件:主编 + 五帮手编排(大纲/写手/审校/设定/考据),novel/ 结构化文件为数据库",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,12 +14,16 @@
14
14
  "bin": {
15
15
  "maoying": "bin/maoying.mjs"
16
16
  },
17
+ "scripts": {
18
+ "test": "node --test test/*.test.mjs",
19
+ "smoke": "node scripts/smoke-client.mjs"
20
+ },
17
21
  "engines": {
18
22
  "node": ">=18",
19
23
  "dsh": ">=0.1.7 <0.3"
20
24
  },
21
25
  "peerDependencies": {
22
- "@deepseek-ai/dsh": ">=0.1.7"
26
+ "@deepseek-ai/dsh": ">=0.1.7 <0.3"
23
27
  },
24
28
  "dependencies": {},
25
29
  "files": [
@@ -32,6 +36,7 @@
32
36
  "docs/",
33
37
  "icon.svg",
34
38
  "README.md",
39
+ "README.en.md",
35
40
  "LICENSE",
36
41
  "cordis.patch.yml"
37
42
  ],
@@ -353,7 +353,8 @@
353
353
  tailChars: 1024
354
354
  # ── 角色 preset:审校(one-shot,只读)────────────────────────────────────
355
355
  # persona 逐字复制 v1 delegation-review;tool-fs 收成 read/readImage 两开、
356
- # write/edit 关闭——v1 只读 toolFilter 的实体化;无 write/edit/bash/skill。
356
+ # write/edit 关闭——v1 只读 toolFilter 的实体化;无 write/edit/bash;带
357
+ # skill 仅为加载审校清单等只读方法论技能,只读不改不变。
357
358
  - id: preset-maoying-review
358
359
  name: "@deepseek-ai/dsh-agent-preset"
359
360
  config:
@@ -389,6 +390,10 @@
389
390
  name: "@deepseek-ai/dsh-tool-fs-search"
390
391
  config:
391
392
  sampleOverCapGlobResults: false
393
+ - id: skill-filesystem
394
+ name: "@deepseek-ai/dsh-skill-filesystem"
395
+ - id: tool-skill
396
+ name: "@deepseek-ai/dsh-tool-skill"
392
397
  - id: tool-ask-user
393
398
  name: "@deepseek-ai/dsh-tool-ask-user"
394
399
  - id: tool-todo
@@ -125,8 +125,8 @@
125
125
  name: '@deepseek-ai/dsh-tool-jobs'
126
126
 
127
127
  # ── skills ──────────────────────────────────────────────────────────────────
128
- # 主编经 restrict 的 registry deny 看不到技能目录;写手/大纲师/设定管理员保留
129
- # skill(二期方法论技能包落点)。
128
+ # 主编经 restrict 的 registry deny 看不到技能目录;写手/大纲师/审校/设定管理员
129
+ # 保留 skill(二期方法论技能包落点;审校只读不改,skill 仅用于加载审校清单)。
130
130
 
131
131
  - id: skill-filesystem
132
132
  name: '@deepseek-ai/dsh-skill-filesystem'
@@ -276,6 +276,9 @@
276
276
  - read_image
277
277
  - glob
278
278
  - grep
279
+ # skill:只用于按 persona 加载 maoying-review-checklist 等只读方法论
280
+ # 技能;审校仍无 write/edit/bash,只读不改的约束不变。
281
+ - skill
279
282
  - todo_write
280
283
  - ask_user_question
281
284
 
@@ -13,13 +13,12 @@
13
13
  * - 内部检索(热路径,不绕委派):glob / grep / read / read_image / bash
14
14
  * - 协调:todo_write / ask_user_question / goal_* / job_* / exit_plan_mode
15
15
  *
16
- * 主编看不到(请求成本归零):web_search / web_fetch(考据委派 research)、
17
- * write / edit(落盘一律经角色或主编亲自验收后少量进行——主编保写权,
18
- * 见下方差异说明)、skill 目录(registry deny 堵 ~1.3k 注入)、以及未列出的
19
- * 一切通用委派与工作流工具。
16
+ * 主编看不到(白名单外收窄——只为省请求成本,不是安全边界):web_search /
17
+ * web_fetch(考据委派 research)、skill 目录(registry deny 堵 ~1.3k 注入)、
18
+ * 以及未列出的一切通用委派与工作流工具。
20
19
  *
21
- * 说明:主编白名单保留 write/edit——主编需要亲自做小修小补
22
- * (改 phase、补 frontmatter、微调摘要),这些活不值得委派一次。
20
+ * write / edit 保留在白名单(见 MAIN_AGENT_ALLOW 尾段):主编需要亲自做小修
21
+ * 小补(改 phase、补 frontmatter、微调摘要),这些活不值得委派一次。
23
22
  *
24
23
  * Host 工具(mcp__*、插件工具)一律不硬编码——是否挂载是检测期事实,
25
24
  * 硬编码会让未装对应插件的宿主直接出错。本文件只留 dsh 核心工具。
@@ -36,7 +36,7 @@ description: 网文章节审校清单——语言/节奏/人设/逻辑/伏笔/
36
36
  ### 5. 伏笔层(对照 foreshadows.yml)
37
37
  - 本章埋设的伏笔是否已登记(未登记记 P1,报给主编转设定管理员);
38
38
  - 本章推进/回收的伏笔与台账状态一致;回收是否超出预期但合乎前文;
39
- - 活跃伏笔是否有间隔性提醒(长线伏笔超过 15 章未提及记 P2 提醒)。
39
+ - 活跃伏笔是否有间隔性提醒(到限未动记 P2 提醒:high 超 10 章、normal 超 15 章、low 超 30 章,口径同 maoying-foreshadow-discipline)。
40
40
 
41
41
  ### 6. 剧透层
42
42
  - 本章是否提前泄露后期反转(对照章节大纲与已定稿章节的既有走向);核心反转的铺垫应可回读成立但当时不点破。
@@ -149,7 +149,7 @@ references: # 依据来源,一行一个(已知设定 id 或现
149
149
 
150
150
  ## foreshadows.yml
151
151
 
152
- 伏笔账本(状态机语义见 docs/spec.md 或主编人设)。埋设与回收必须记章节号;活跃伏笔(planted/progressing)超过 15 条时应提醒清理。
152
+ 伏笔账本(状态机语义见上文「总原则」的伏笔状态枚举)。埋设与回收必须记章节号;活跃伏笔(planted/progressing)超过 15 条时应提醒清理。
153
153
 
154
154
  ```yaml
155
155
  - id: fs-001 # 编号不复用
@@ -170,7 +170,7 @@ references: # 依据来源,一行一个(已知设定 id 或现
170
170
  世界内时间线(故事内纪年,不是写作顺序),按故事内时间排序。
171
171
 
172
172
  ```yaml
173
- - year: 玄历三年冬 # 故事内纪年
173
+ - year: 玄历1024年冬 # 故事内纪年,须含数字(check 按数字解析)
174
174
  title: 北境雪灾
175
175
  description: 补充说明(可空)
176
176
  importance: 4 # 1-5,5 = 改写主线走向的大事
@@ -204,4 +204,4 @@ references: # 依据来源,一行一个(已知设定 id 或现
204
204
 
205
205
  ## exports/
206
206
 
207
- 导出产物(MD/TXT/EPUB 等在一期之后),init 只建目录。
207
+ 导出产物:`maoying export` 的默认落盘点(--format md|txt|epub,--out 可改到别处),init 只建目录。
@@ -4,6 +4,7 @@
4
4
  # title 伏笔一句话
5
5
  # status planted|progressing|resolved|abandoned
6
6
  # priority high|normal|low
7
+ # source 可选:plan|backfill(缺省 plan;backfill = 共创收稿从作者正文回填认领)
7
8
  # planted_chapter 埋设章节号(必填)
8
9
  # expected_resolve 预期回收章节号
9
10
  # resolved_chapter 实际回收章节号(resolved 时必填)
@@ -1,6 +1,6 @@
1
1
  # timeline.yml —— 世界内时间线(顶层数组;故事内纪年,按故事内时间排序)
2
2
  # 字段:
3
- # year 故事内纪年(如 玄历三年冬)
3
+ # year 故事内纪年,须含数字(如 玄历1024年冬——check 按数字解析,纯汉字纪年会提醒)
4
4
  # title 事件一句话
5
5
  # description 补充说明(可空)
6
6
  # importance 重要度 1-5(5 = 改写主线走向的大事)