dsh-maoying 0.1.18 → 0.1.20

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.
@@ -7,15 +7,26 @@
7
7
  * 永远是同一口径。五期(§9)在此追加:章纲 outlines/ 读取(readOutline /
8
8
  * listOutlines)、写作卡组装(buildBrief)与静态体检(checkProject)。
9
9
  *
10
- * 零依赖:只用 node:fs / node:path。YAML 解析走自带的极简子集
10
+ * 零依赖:只用 node:crypto / node:fs / node:path。YAML 解析走自带的极简子集
11
11
  * parseFlatYamlArray(本项目数据规范:yml 顶层只允许数组或平键,禁止深嵌套),
12
12
  * 与 dsh home 里的 yaml 包无关——yaml 包的真解析在 lib/yaml-loader.mjs 一侧,
13
13
  * 由 panel-core 自行惰性加载。
14
14
  */
15
15
 
16
+ import { createHash } from 'node:crypto'
16
17
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
17
18
  import path from 'node:path'
18
19
 
20
+ /**
21
+ * sha256 hex(小写):文本 → 指纹。全仓库唯一 hash 实现——面板「人工改动
22
+ * 保护」的各处基线(project_hash / 章节 chapter 的 hash / 章纲 outline.hash /
23
+ * style 的 hash)与各 save 路由的 base_hash 比对都从这一个口径出,避免多处
24
+ * 手搓摘要算法日后漂移(比如一方忘了 utf8 编码)。
25
+ */
26
+ export function sha256Hex(text) {
27
+ return createHash('sha256').update(text, 'utf8').digest('hex')
28
+ }
29
+
19
30
  /** 章节状态机(frontmatter status 枚举 + 中文标签;CLI 展示与面板共用)。 */
20
31
  export const CHAPTER_STATUSES = [
21
32
  ['pending', '待写'],
@@ -24,6 +35,13 @@ export const CHAPTER_STATUSES = [
24
35
  ['accepted', '已定稿'],
25
36
  ]
26
37
 
38
+ /**
39
+ * 伏笔超期阈值:planted 状态超过该章数未推进即提醒(checkProject 的伏笔超期
40
+ * warn 用)。数字出处:skills/maoying-foreshadow-discipline/SKILL.md「清理纪律」
41
+ * ——planted 超过 30 章未推进应升级(置 progressing 并规划推进)或 abandoned。
42
+ */
43
+ export const FORESHADOW_STALE_CHAPTERS = 30
44
+
27
45
  // CJK 计数范围:基本区 + 扩展 A + 扩展 B(\u{20000}-\u{2A6DF})。扩展 B 在增补
28
46
  // 平面,必须带 u 标志按码点匹配——网文生僻字(如「𠮷」)此前会被漏计。
29
47
  const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf\u{20000}-\u{2a6df}]/gu
@@ -785,6 +803,83 @@ function parseTimelineYear(year) {
785
803
  return m === null ? null : Number(m[0])
786
804
  }
787
805
 
806
+ // ── AI 味统计(checkProject 第三组规则的实现;独立纯函数便于测试与复用)──────
807
+
808
+ /** 连续同式开头判定下限:连续 ≥ 3 句以相同前 2 字开头算句式重复。 */
809
+ export const STYLE_REPEAT_RUN = 3
810
+ /** 比喻词密度阈值:每千 CJK 字超过 2 处判 AI 味风险。 */
811
+ export const STYLE_METAPHOR_PER_KILO = 2
812
+ /** 句长波动检查的最少句数:句数不足时统计无意义,不查。 */
813
+ export const STYLE_SENTENCE_MIN = 20
814
+ /** 句长变异系数(标准差/均值)下限:低于它说明长短句交替不足。 */
815
+ export const STYLE_SENTENCE_CV_MIN = 0.3
816
+
817
+ /** AI 高频比喻词清单(字面计数,不做修辞判定)。 */
818
+ const METAPHOR_WORDS = ['仿佛', '宛如', '好似', '如同', '恍若', '好像']
819
+
820
+ /** 章纲「钩子」小节的标题行:#{1,3} 级、「钩子」或「章尾钩子」打头即算。 */
821
+ const OUTLINE_HOOK_RE = /^#{1,3}[ \t]*(?:章尾)?钩子/m
822
+
823
+ /**
824
+ * 章节正文的 AI 味统计(纯函数,不读文件、不调模型):按句切分(。!?…;,切分符不含逗号;
825
+ * 保留句文本),三条指标互相独立、命中才报,返回 warn 消息数组(level/file
826
+ * 由调用方挂):
827
+ * a. 连续同式开头——取最长的同前缀连续句段,达到 STYLE_REPEAT_RUN 报一次/章;
828
+ * b. 比喻词密度——METAPHOR_WORDS 总次数 / 千 CJK 字超过 STYLE_METAPHOR_PER_KILO
829
+ * (分母用 countCjk,与全库字数统计同口径);
830
+ * c. 句长波动——句数达 STYLE_SENTENCE_MIN 且变异系数(总体标准差/均值)
831
+ * 低于 STYLE_SENTENCE_CV_MIN。
832
+ */
833
+ export function checkChapterStyle(body) {
834
+ const text = String(body ?? '')
835
+ const messages = []
836
+ const sentences = text
837
+ .split(/[。!?…;]+/)
838
+ .map((s) => s.trim())
839
+ .filter((s) => s !== '')
840
+ if (sentences.length === 0) return messages
841
+ // a. 连续同式开头:一次线性扫描,记最长的同前缀连续段
842
+ let best = null
843
+ let curPrefix = null
844
+ let curRun = 0
845
+ for (const sentence of sentences) {
846
+ const prefix = sentence.slice(0, 2)
847
+ if (prefix === curPrefix) curRun++
848
+ else {
849
+ curPrefix = prefix
850
+ curRun = 1
851
+ }
852
+ if (best === null || curRun > best.run) best = { prefix, run: curRun }
853
+ }
854
+ if (best.run >= STYLE_REPEAT_RUN) {
855
+ messages.push(`疑似句式重复:连续 ${best.run} 句以「${best.prefix}」开头`)
856
+ }
857
+ // b. 比喻词密度:字面总次数 / 千 CJK 字,严格超过阈值才报
858
+ let metaphorCount = 0
859
+ for (const word of METAPHOR_WORDS) metaphorCount += text.split(word).length - 1
860
+ const kilo = countCjk(text) / 1000
861
+ if (kilo > 0 && metaphorCount / kilo > STYLE_METAPHOR_PER_KILO) {
862
+ messages.push(
863
+ `比喻词密度偏高:${metaphorCount} 处 / ${Number(kilo.toFixed(1))}千字(阈值 ${STYLE_METAPHOR_PER_KILO}/千字)——AI 味风险`,
864
+ )
865
+ }
866
+ // c. 句长波动:句数足够且变异系数过低才报(均值 0 只可能全空句,已被过滤)
867
+ if (sentences.length >= STYLE_SENTENCE_MIN) {
868
+ const lengths = sentences.map((s) => s.length)
869
+ const mean = lengths.reduce((sum, l) => sum + l, 0) / lengths.length
870
+ if (mean > 0) {
871
+ const variance = lengths.reduce((sum, l) => sum + (l - mean) ** 2, 0) / lengths.length
872
+ const cv = Math.sqrt(variance) / mean
873
+ if (cv < STYLE_SENTENCE_CV_MIN) {
874
+ messages.push(
875
+ `句长节奏过平:${sentences.length} 句变异系数 ${cv.toFixed(2)}(阈值 ${STYLE_SENTENCE_CV_MIN.toFixed(2)})——长短句交替不足`,
876
+ )
877
+ }
878
+ }
879
+ }
880
+ return messages
881
+ }
882
+
788
883
  /**
789
884
  * 静态体检(五期 §9.6):只查文件规范,不调模型。返回
790
885
  * { ok, problems: [{ level: 'error'|'warn', file, message }], summary }
@@ -802,6 +897,9 @@ export function checkProject(novelDir) {
802
897
 
803
898
  const stems = new Set()
804
899
  const nums = new Set()
900
+ // 全书已写章节的最大卷章序数(vol,num):伏笔超期检查的比对基准。规范文件名
901
+ // 优先;不合规范但 frontmatter 卷号章号可解析的章按 frontmatter 口径补入。
902
+ let maxWritten = null
805
903
  for (const file of chapterFiles) {
806
904
  const stem = file.slice(0, -3)
807
905
  stems.add(stem)
@@ -813,10 +911,12 @@ export function checkProject(novelDir) {
813
911
  }
814
912
  let meta = {}
815
913
  let fmRaw = null
914
+ let chapterBody = '' // AI 味统计要在循环尾用正文,try 块里的 parsed 出了作用域
816
915
  try {
817
916
  const parsed = parseChapterFile(readFileSync(path.join(chaptersDir, file), 'utf8'))
818
917
  meta = parsed.meta
819
918
  fmRaw = parsed.raw
919
+ chapterBody = parsed.body
820
920
  } catch {
821
921
  push('error', rel, '章节文件读取失败')
822
922
  continue
@@ -833,6 +933,16 @@ export function checkProject(novelDir) {
833
933
  push('error', rel, `status 越枚举:${meta.status}(合法值 pending|writing|review|accepted)`)
834
934
  }
835
935
  if (Number.isInteger(meta.num)) nums.add(meta.num)
936
+ // 汇总最大卷章序数:文件名可解析用文件名;否则退 frontmatter volume+num
937
+ // (裸章号口径的卷号记 0)。vol*1000+num 线性化比较——单卷章数远不到千。
938
+ const ordNum = key !== null ? key.num : (Number.isInteger(meta.num) ? meta.num : null)
939
+ if (ordNum !== null) {
940
+ const volMatch = /^v(\d+)$/.exec(typeof meta.volume === 'string' ? meta.volume.trim() : '')
941
+ const ordVol = key !== null ? key.vol : (volMatch !== null ? Number(volMatch[1]) : 0)
942
+ if (maxWritten === null || ordVol * 1000 + ordNum > maxWritten.vol * 1000 + maxWritten.num) {
943
+ maxWritten = { vol: ordVol, num: ordNum }
944
+ }
945
+ }
836
946
  const missingDims = FIVE_DIM_KEYS.filter((k) => meta[k] === undefined || meta[k] === '')
837
947
  if (missingDims.length > 0) push('warn', rel, `五维字段缺失:${missingDims.join('、')}`)
838
948
  if (meta.summary === undefined || meta.summary === '') push('warn', rel, 'summary 缺失(index.yml 与 stats 直接引用)')
@@ -854,6 +964,10 @@ export function checkProject(novelDir) {
854
964
  if (typeof meta.writer === 'string' && meta.writer !== '' && !CHAPTER_WRITERS.includes(meta.writer)) {
855
965
  push('error', rel, `writer 越枚举:${meta.writer}(合法值 author|ai)`)
856
966
  }
967
+ // AI 味统计(warn):只查有正文的章;指标与阈值见 checkChapterStyle
968
+ if (chapterBody.trim() !== '') {
969
+ for (const message of checkChapterStyle(chapterBody)) push('warn', rel, message)
970
+ }
857
971
  }
858
972
 
859
973
  // —— foreshadows.yml:状态枚举、id 去重、章节引用、状态-字段自洽 ——
@@ -906,6 +1020,23 @@ export function checkProject(novelDir) {
906
1020
  ) {
907
1021
  push('error', 'foreshadows.yml', `伏笔 ${label} 的 resolved_chapter(${resolvedRef})早于 planted_chapter(${f.planted_chapter})`)
908
1022
  }
1023
+ // 清理纪律(skills/maoying-foreshadow-discipline/SKILL.md「清理纪律」:
1024
+ // planted 超过 30 章未推进应升级推进或弃用):planted 且埋设章可解析时,
1025
+ // 与全书已写章节的最大卷章序数求间隔章数,超过阈值给 warn。
1026
+ // abandoned/resolved/progressing 不查,埋设章解析不出(非规范写法)跳过。
1027
+ // 与上面 expected_resolve 的反向提醒语义不同(那边查排期目标章是否已写出,
1028
+ // 这边查时间跨度),同一条伏笔可能双报——允许。
1029
+ if (f.status === 'planted' && plantedOrd !== null && maxWritten !== null) {
1030
+ // 带卷引用按 vol*1000+num 线性化相减(卷内即章号差,跨卷差值上千、必然
1031
+ // 超阈值);裸章号无卷信息,与最大章号直接相减(同 nums 的存在性口径)。
1032
+ const gap =
1033
+ plantedOrd.vol === 0
1034
+ ? maxWritten.num - plantedOrd.num
1035
+ : maxWritten.vol * 1000 + maxWritten.num - (plantedOrd.vol * 1000 + plantedOrd.num)
1036
+ if (gap > FORESHADOW_STALE_CHAPTERS) {
1037
+ push('warn', 'foreshadows.yml', `伏笔 ${label} 埋设于 ${f.planted_chapter},已 ${gap} 章未推进(阈值 ${FORESHADOW_STALE_CHAPTERS} 章)——确认是否遗忘`)
1038
+ }
1039
+ }
909
1040
  })
910
1041
 
911
1042
  // —— timeline.yml:年份可解析、不倒挂(warn:故事内纪年本就允许花写)——
@@ -939,6 +1070,17 @@ export function checkProject(novelDir) {
939
1070
  }
940
1071
  })
941
1072
 
1073
+ // —— outlines/*.md:章纲缺「钩子」小节(warn:章纲本身可选,写了就须带钩子节)——
1074
+ // 章纲纯 markdown 无 frontmatter;「钩子」或「章尾钩子」须独立成节写明
1075
+ // (#{1,3} 级标题行,见 OUTLINE_HOOK_RE)。空 outlines/ 目录(或缺目录)不报。
1076
+ const outlinesDir = path.join(novelDir, OUTLINES_DIR)
1077
+ for (const file of listMdFiles(outlinesDir)) {
1078
+ const text = readTextOrNull(path.join(outlinesDir, file))
1079
+ if (text !== null && !OUTLINE_HOOK_RE.test(text)) {
1080
+ push('warn', outlineFile(file.slice(0, -3)), '章纲缺「钩子」小节(## 钩子 或 ## 章尾钩子)——章尾钩子须独立成节写明')
1081
+ }
1082
+ }
1083
+
942
1084
  // —— characters/*.md:name 缺失是硬伤;重复键给 warn ——
943
1085
  for (const card of cards) {
944
1086
  if (typeof card.meta.name !== 'string' || card.meta.name.trim() === '') {
@@ -10,21 +10,42 @@
10
10
  * parseStateSnapshot(novelDir) docs/creator-panel.md §3 的全量快照
11
11
  * (五期起含 outlines 章纲清单)
12
12
  * readChapter(novelDir, file) 章节正文全文(抽屉用;五期起带对应章纲
13
- * outline 字段)
13
+ * outline 字段,二者各带原文 sha256 hash——
14
+ * 面板正文/章纲编辑保存的 base_hash 基线)
14
15
  * readOutlineFile(novelDir, file) 章纲全文(章纲弹层用;0.1.15 起按单
15
16
  * 写作的未建章章纲可看全文)
16
17
  * projectFileHash(novelDir) project.yml 原文 sha256 hex(快照
17
18
  * project_hash / 保存旧版检测共用基线)
19
+ * readStyleState(novelDir) 写作风格基准 novel/style.md 全文 + 指纹 +
20
+ * 插件模板全文(面板「写作风格」tab 取数,
21
+ * found:false 表示文件尚未创建、不是错误)
18
22
  *
19
23
  * 写入面(行级原地替换三件套:读原文 → 手术 → 写后校验替换计数 === 1;
20
24
  * 计数不符或目标行缺失一律 409 拒写不落盘,把对 git diff 的扰动压到最小。
21
- * createChapterFromOutline 是唯一例外——它从章纲落一个全新章节文件,不动
22
- * 任何既有文件的字节)
25
+ * createChapterFromOutline 是例外之一——它从章纲落一个全新章节文件,不动
26
+ * 任何既有文件的字节;saveStyleText 是例外之二——style.md 是纯 markdown
27
+ * 基准文档、无 frontmatter/行级键位可言,整文件覆写就是它的最小手术;
28
+ * mutateChapterBody 是例外之三——正文是自由形态文本,写原语是「frontmatter
29
+ * 围栏定位 + 围栏后整段替换」,围栏(含)之前逐字节保留;mutateOutlineBody
30
+ * 是例外之四——章纲纯 markdown,整文件替换。后四者写前都读原文比对
31
+ * baseHash(人工改动保护与 mutateProject 同口径))
23
32
  * mutateChapterStatus 章节 frontmatter 的 status 行
24
33
  * mutateForeshadowStatus foreshadows.yml 对应 id 条目块内的 status 行
25
34
  * mutatePhase project.yml 的 phase 行
26
35
  * rebuildIndex 复用 lib/novel-parse 的 buildIndexYaml 重建
27
36
  * createChapterFromOutline 从章纲建章(chapters/ 落 pending 空章,0.1.15)
37
+ * saveStyleText 写作风格基准整文件覆写(novel/style.md,0.1.19)
38
+ * mutateChapterBody 章节正文整段替换(面板正文编辑态保存)
39
+ * mutateOutlineBody 章纲整文件替换(面板章纲对照栏编辑态保存)
40
+ *
41
+ * 受管文件面(0.1.20「全量可编辑写面」:白名单内的账本 yml / md 实体卡 /
42
+ * 章纲 / 章节删除走通用通道;白名单与结构校验口径见文末该节注释)
43
+ * managedRelCheck rel 白名单校验(按操作分类)
44
+ * validateManagedText 保存/新建前的结构校验(解析失败/缺围栏 422)
45
+ * readManagedFile 读全文 + 指纹(file-source)
46
+ * saveManagedFile 整文件覆写 + 409 过期基线保护(file-save)
47
+ * createManagedFile 新建(已存在 409)(file-create)
48
+ * deleteManagedFile 删除(不存在 404,UI 层二次确认)(file-delete)
28
49
  *
29
50
  * 错误约定:本模块抛 PanelError(带 statusCode),路由层按 statusCode 回
30
51
  * JSON(403 拒绝 / 404 找不到 / 409 拒写 / 422 入参不合法);未带状态码的
@@ -35,11 +56,10 @@
35
56
  * 绝不让它炸掉整个快照。
36
57
  */
37
58
 
38
- import { createHash } from 'node:crypto'
39
59
  import { mkdirSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
40
60
  import path from 'node:path'
41
61
 
42
- import { buildIndexYaml, canonicalVolume, chapterSortKey, collectChapters, countCjk, FIVE_DIM_KEYS, parseFlatYamlArray, parseScalarText, splitFrontmatter, stripInlineComment } from './novel-parse.mjs'
62
+ import { buildIndexYaml, canonicalVolume, chapterSortKey, collectChapters, countCjk, FIVE_DIM_KEYS, parseFlatYamlArray, parseScalarText, sha256Hex, splitFrontmatter, stripInlineComment } from './novel-parse.mjs'
43
63
  import { loadYamlLib, resolveDshHome } from './yaml-loader.mjs'
44
64
 
45
65
  /** 章节状态机(与 lib/novel-parse 的表同源:只取键)。 */
@@ -405,10 +425,7 @@ function readProject(novelDir, yamlLib, warnings) {
405
425
  }
406
426
  }
407
427
 
408
- /** sha256 hex(小写):文本 → 指纹。 */
409
- function sha256Hex(text) {
410
- return createHash('sha256').update(text, 'utf8').digest('hex')
411
- }
428
+ /** sha256 hex(小写):从 novel-parse 引全仓库唯一实现,本文件不再私有手搓。 */
412
429
 
413
430
  /**
414
431
  * project.yml 原文指纹(sha256 hex):面板「人工改动保护」的基线。快照带出
@@ -426,6 +443,69 @@ export function projectFileHash(novelDir) {
426
443
  return sha256Hex(text)
427
444
  }
428
445
 
446
+ /**
447
+ * 插件内置写作风格模板(templates/novel/style.md)全文:readStyleState 的
448
+ * template 字段原料——style.md 尚未创建时,面板用它在编辑器里预填默认基线。
449
+ * 每次现读(35 行小文件,不值得缓存);读不到返回 null,面板据此禁用
450
+ * 「用默认基线创建」按钮,不炸面板。
451
+ */
452
+ function styleTemplateText() {
453
+ try {
454
+ return readFileSync(new URL('../templates/novel/style.md', import.meta.url), 'utf8')
455
+ } catch {
456
+ return null
457
+ }
458
+ }
459
+
460
+ /**
461
+ * 写作风格基准读取(0.1.19,面板「写作风格」tab 取数):novel/style.md 存在
462
+ * 则读全文 + sha256 指纹(hash 即保存路由 base_hash 的基线,口径与
463
+ * projectFileHash 一致);不存在返回 found:false、text/hash 为 null——文件
464
+ * 尚未创建是常态不是错误,路由照 200 回,客户端空态引导创建。读失败不炸
465
+ * (照 projectFileHash 的宽容口径,读不动一律按不存在处理)。template 恒为
466
+ * 插件模板全文(读不到为 null)。
467
+ */
468
+ export function readStyleState(novelDir) {
469
+ const template = styleTemplateText()
470
+ let text = null
471
+ try {
472
+ text = readFileSync(path.join(path.resolve(novelDir), 'style.md'), 'utf8')
473
+ } catch {
474
+ return { found: false, text: null, hash: null, template }
475
+ }
476
+ return { found: true, text, hash: sha256Hex(text), template }
477
+ }
478
+
479
+ /**
480
+ * 写作风格基准保存(0.1.19,面板「写作风格」tab 的编辑态):novel/style.md
481
+ * 整文件覆写——纯 markdown 基准文档,无行级键位可言,整文件替换即最小手术
482
+ * (「行级原地替换」纪律的例外,与 agent 的写法兼容:写前读、原子写)。
483
+ *
484
+ * 校验:text 非 string 422。人工改动保护与 mutateProject 同口径:options.
485
+ * baseHash 是面板载入编辑器时的原文指纹,与写入前现读不一致 → 409 拒写;
486
+ * 文件不存在且 baseHash 非 null 同样 409(调用方声称基于既有版本改,磁盘却
487
+ * 无文件,宁可不写);未传 baseHash 跳过检查(创建路径:文件不存在时直接
488
+ * 落盘)。空文本允许保存(空文件 = 无风格基准,agent 侧优雅降级)。
489
+ * 返回写后新指纹 hash,客户端下一轮 base_hash 直接取它。
490
+ */
491
+ export function saveStyleText(novelDir, text, options = {}) {
492
+ if (typeof text !== 'string') fail(422, 'text 必须是字符串(style.md 全文)')
493
+ const target = path.join(path.resolve(novelDir), 'style.md')
494
+ let current = null
495
+ try {
496
+ current = readFileSync(target, 'utf8')
497
+ } catch {
498
+ current = null // 不存在(读不动也按不存在处理,宽容口径同 readStyleState)
499
+ }
500
+ const baseHash = typeof options.baseHash === 'string' ? options.baseHash.trim().toLowerCase() : ''
501
+ if (baseHash !== '' && (current === null || baseHash !== sha256Hex(current))) {
502
+ fail(409, 'style.md 已被外部修改,请刷新面板后基于最新内容重改再存')
503
+ }
504
+ writeAtomic(target, text)
505
+ // 返回写后指纹:客户端下一轮保存的 base_hash 直接取它,不必再拉一次快照。
506
+ return { ok: true, hash: sha256Hex(text) }
507
+ }
508
+
429
509
  /**
430
510
  * 全量快照(docs/creator-panel.md §3,字段名冻结)。novelDir 为工作区的
431
511
  * novel 目录;project.yml 不在 = 未初始化(found: false,其余字段缺省)。
@@ -606,8 +686,10 @@ export function parseStateSnapshot(novelDir, options = {}) {
606
686
  /**
607
687
  * 章纲定位(readChapter 的 outline 字段,五期):outlines/ 下与章节同 stem
608
688
  * 的文件。章纲纯 markdown 无 frontmatter,words 为全文 CJK 计数、body 为
609
- * 原文;无对应文件(含 outlines/ 目录不存在)返回 null——无章纲是常态,
610
- * 不告警。stem 已过 safeFileName,拼接结果不可能越出 novel 根。
689
+ * 原文、hash 为原文 sha256(面板章纲编辑保存的 base_hash 基线,与 body 同源
690
+ * 同刻读出——拆开读会有被外部改动的窗口);无对应文件(含 outlines/ 目录不
691
+ * 存在)返回 null——无章纲是常态,不告警。stem 已过 safeFileName,拼接结果
692
+ * 不可能越出 novel 根。
611
693
  */
612
694
  function readOutline(novelDir, stem) {
613
695
  let text = null
@@ -616,20 +698,22 @@ function readOutline(novelDir, stem) {
616
698
  } catch {
617
699
  return null
618
700
  }
619
- return { file: `${stem}.md`, words: countCjk(text), body: text }
701
+ return { file: `${stem}.md`, words: countCjk(text), body: text, hash: sha256Hex(text) }
620
702
  }
621
703
 
622
704
  /**
623
705
  * 章节全文(抽屉用):路径校验同写路由;frontmatter 以对象返回(yaml 可用
624
- * 走真解析,否则平键回退),body 为原文正文;outline 为同 stem 章纲
625
- * ({ file, words, body },无对应章纲时 null),章节抽屉的章纲对照栏取数。
706
+ * 走真解析,否则平键回退),body 为原文正文,hash 为章节文件原文 sha256
707
+ * (人工改动保护:正文编辑态保存时作 base_hash 回传,外部改动 409 拒写);
708
+ * outline 为同 stem 章纲({ file, words, body, hash },无对应章纲时 null),
709
+ * 章节抽屉的章纲对照栏取数。
626
710
  */
627
711
  export function readChapter(novelDir, file, options = {}) {
628
712
  const { name, target } = locateChapter(novelDir, file)
629
713
  const text = readTarget(target, `章节 ${name}`)
630
714
  const { meta, body } = parseFrontmatterMeta(text, yamlLibOf(options))
631
715
  const outline = readOutline(novelDir, name.replace(/\.md$/i, ''))
632
- return { ok: true, file: name, frontmatter: meta, body, outline }
716
+ return { ok: true, file: name, frontmatter: meta, body, hash: sha256Hex(text), outline }
633
717
  }
634
718
 
635
719
  /**
@@ -700,6 +784,78 @@ export function createChapterFromOutline(novelDir, file) {
700
784
  return { ok: true, file: `${stem}.md`, title }
701
785
  }
702
786
 
787
+ // ── 正文/章纲写原语(面板编辑态保存):自由形态 body,无行级键位可手术 ─────
788
+
789
+ /**
790
+ * 章节正文保存(面板章节阅读页编辑态):frontmatter 围栏(含闭合 --- 行)
791
+ * 之前逐字节保留,围栏后整段替换——正文是自由形态文本,任何行级手术都会
792
+ * 凭空改写作者的文字,整段替换才是最小干预;对 git diff 的扰动天然只落在
793
+ * 正文段内。校验与拒写:
794
+ * - file 只认 chapters/ 下纯文件名(locateChapter 同款双保险,穿越 403);
795
+ * - body 非 string 422;章节缺 frontmatter 围栏 422(先补齐再编辑);
796
+ * - 章节不存在 404;
797
+ * - options.baseHash 是编辑态载入时的原文指纹(chapter 路由的 hash),
798
+ * 归一(trim + 小写)后与写入前现读不一致 → 409 拒写不落盘(人工改动
799
+ * 保护,与 mutateProject 同口径);未传跳过检查(旧调用方兼容)。
800
+ * 正文末尾无换行补一个 \n( POSIX 文本文件惯例,textarea 草稿常缺尾换行)。
801
+ * 返回 { ok, hash }——写后新指纹,客户端下一轮 base_hash 直接取它。
802
+ */
803
+ export function mutateChapterBody(novelDir, file, body, options = {}) {
804
+ if (typeof body !== 'string') fail(422, 'body 必须是字符串(章节正文全文)')
805
+ const { name, target } = locateChapter(novelDir, file)
806
+ const text = readTarget(target, `章节 ${name}`)
807
+ const fence = splitFrontmatter(text)
808
+ if (fence.raw === null) fail(422, `章节 ${name} 缺少 frontmatter 围栏,请先补齐再在面板编辑正文`)
809
+ // 旧版检测在读原文之后、落笔之前:指纹不一致就地 409,一个字节都不动。
810
+ const baseHash = typeof options.baseHash === 'string' ? options.baseHash.trim().toLowerCase() : ''
811
+ if (baseHash !== '' && baseHash !== sha256Hex(text)) {
812
+ fail(409, `章节文件已被外部修改,请刷新后基于最新内容重改再存:${name}`)
813
+ }
814
+ // 围栏闭合行从原文原样切出(与 splitFrontmatter 的闭合正则同形)——
815
+ // CRLF/行尾空白等字节一字不动,改写它的刀不属于正文编辑。
816
+ const cut = fence.index + fence.raw.length
817
+ const closeM = /\r?\n---[ \t]*(?:\r?\n|$)/.exec(text.slice(cut))
818
+ let preserved = text.slice(0, cut + closeM[0].length)
819
+ // 退化形状:闭合 --- 是文件末行且无换行(罕见),补一个换行防正文黏上围栏行。
820
+ if (!preserved.endsWith('\n')) preserved += '\n'
821
+ const next = preserved + (body.endsWith('\n') ? body : `${body}\n`)
822
+ writeAtomic(target, next)
823
+ // 返回写后指纹:客户端下一轮保存的 base_hash 直接取它,不必重拉章节。
824
+ return { ok: true, hash: sha256Hex(next) }
825
+ }
826
+
827
+ /**
828
+ * 章纲保存(面板章节阅读页章纲对照栏编辑态):章纲纯 markdown 无
829
+ * frontmatter(spec §outlines),整文件替换即最小手术(与 saveStyleText
830
+ * 同一判断)。file 接受章节文件名或 stem(v01-c001.md / v01-c001 皆可——
831
+ * 调用方手里是章节上下文,不强迫换算),校验与 readOutlineFile 同口径:
832
+ * safeFileName(穿越 403)+ chapterSortKey(stem 不合 vXX-cXXX 规范 422);
833
+ * 目标恒为 outlines/<stem>.md,章纲不存在 404(v1 不提供面板新建章纲)。
834
+ * baseHash 人工改动保护与 mutateChapterBody 同口径,409 文案点名章纲。
835
+ * 返回 { ok, hash }——写后新指纹。
836
+ */
837
+ export function mutateOutlineBody(novelDir, chapterFile, body, options = {}) {
838
+ if (typeof body !== 'string') fail(422, 'body 必须是字符串(章纲全文)')
839
+ const name = safeFileName(chapterFile, 'file')
840
+ const stem = name.replace(/\.md$/i, '')
841
+ if (chapterSortKey(stem) === null) fail(422, `非章纲文件名(stem 不合 vXX-cXXX 规范):${name}`)
842
+ const target = path.join(path.resolve(novelDir), 'outlines', `${stem}.md`)
843
+ let current = null
844
+ try {
845
+ current = readFileSync(target, 'utf8')
846
+ } catch (err) {
847
+ if (err?.code === 'ENOENT') fail(404, `章纲不存在:outlines/${stem}.md`)
848
+ fail(500, `章纲读取失败(${err?.code ?? err?.message ?? err})`)
849
+ }
850
+ const baseHash = typeof options.baseHash === 'string' ? options.baseHash.trim().toLowerCase() : ''
851
+ if (baseHash !== '' && baseHash !== sha256Hex(current)) {
852
+ fail(409, `章纲文件已被外部修改,请刷新后基于最新内容重改再存:outlines/${stem}.md`)
853
+ }
854
+ const next = body.endsWith('\n') ? body : `${body}\n`
855
+ writeAtomic(target, next)
856
+ return { ok: true, hash: sha256Hex(next) }
857
+ }
858
+
703
859
  // ── 写操作:行级原地替换 ────────────────────────────────────────────────────
704
860
 
705
861
  /**
@@ -899,3 +1055,184 @@ export function rebuildIndex(novelDir) {
899
1055
  writeAtomic(path.join(novelDir, 'index.yml'), text)
900
1056
  return { ok: true, chapters: chaptersCount }
901
1057
  }
1058
+
1059
+ // ── 受管文件(0.1.20「全量可编辑写面」):通用读/存/建/删 ────────────────────
1060
+ //
1061
+ // 边界口径(docs/creator-panel.md §0):语义规则(状态机、必填章号、memories
1062
+ // 不改旧账等)是 agent 纪律,不是人工编辑的禁区。人工编辑走「保存时结构校验
1063
+ // (解析失败/缺围栏 422 拒存)+ 保存后语义体检(check 黄条 / CLI check)+
1064
+ // git 回滚兜底」;所以这里的结构校验只管「面板存得进、快照就读得出」这一层,
1065
+ // 其余语义一律不问——留给 check。
1066
+ //
1067
+ // 白名单按操作分类、集中一处定义(只此一份,路由层不做二次判断):
1068
+ // - 读/存/建/删皆可:六个账本 yml + 四个 md 实体目录(slug 卡)+ outlines
1069
+ // 章纲(stem 复用 chapterSortKey)。md 实体卡与账本是快照与 agent 共享的
1070
+ // 数据源,人工改它们正是本次开放的目的。
1071
+ // - 仅删:chapters/<vXX-cXXX>.md。正文有 chapter-save 专用路由(frontmatter
1072
+ // 围栏保护 + 状态机语义在那边),通用存若放开会绕过这层;删除是建章/重写
1073
+ // 的前置动作,必须开放,且 chapter-create 一条路即可重建。
1074
+ // - 绝不进白名单:index.yml(派生文件,rebuild-index 覆盖,编辑无意义且
1075
+ // 误导——改完下次重建就被冲掉)、project.yml(有 project-save 表单路由,
1076
+ // 枚举/注释保留逻辑在那边)、novel/style.md(有 style-save 专用路由)。
1077
+ // 这三个不在 yml 六件套里,靠枚举穷举天然挡住;chapters 通用存靠操作分类
1078
+ // 挡住。rel 不在白名单一律 403——防穿越与防越权同码,不给探测回显差异。
1079
+
1080
+ /** 受管 md 实体目录(<slug>.md,slug 全小写)。 */
1081
+ const MANAGED_MD_DIRS = ['characters', 'world', 'science', 'research']
1082
+
1083
+ /** 受管顶层账本 yml(六个,快照同源读取)。 */
1084
+ const MANAGED_YML_FILES = ['relations.yml', 'timeline.yml', 'memories.yml', 'clue-board.yml', 'foreshadows.yml', 'volumes.yml']
1085
+
1086
+ /** md 实体卡 slug 规范:全小写字母/数字,`-` 连接,不以 `-` 开头。 */
1087
+ const MANAGED_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/
1088
+
1089
+ /**
1090
+ * rel 白名单校验:op ∈ read|save|create|delete。校验不过(缺参/slug 或 stem
1091
+ * 不合规范)422,rel 不在白名单(含穿越形状与仅删目录的非删操作)403。返回
1092
+ * { dir, name, kind }——kind 供 validateManagedText 分派结构校验口径。
1093
+ */
1094
+ export function managedRelCheck(rel, op) {
1095
+ if (typeof rel !== 'string' || rel.trim() === '') fail(422, '缺少 rel 参数(novel 根内的相对路径)')
1096
+ if (rel.includes('\\')) fail(403, `rel 含反斜杠,已拒绝:${rel}`) // Windows 分隔符一律不认,形状先钉死
1097
+ const segments = rel.split('/')
1098
+ if (segments.length > 2) fail(403, `rel 超出「目录/文件名」一层深度,已拒绝:${rel}`)
1099
+ const dir = segments.length === 2 ? segments[0] : ''
1100
+ const name = segments[segments.length - 1]
1101
+ if (dir === '' && MANAGED_YML_FILES.includes(name)) return { dir: '', name, kind: 'yml' }
1102
+ if (dir !== '' && MANAGED_MD_DIRS.includes(dir)) {
1103
+ if (!/\.md$/i.test(name) || !MANAGED_SLUG_RE.test(name.slice(0, -3))) {
1104
+ fail(422, `文件名不合 slug 规范(${MANAGED_SLUG_RE.source} + .md):${rel}`)
1105
+ }
1106
+ return { dir, name, kind: 'md' }
1107
+ }
1108
+ if (dir === 'outlines') {
1109
+ const stem = name.replace(/\.md$/i, '')
1110
+ if (!/\.md$/i.test(name) || chapterSortKey(stem) === null) {
1111
+ fail(422, `章纲文件名不合 vXX-cXXX 规范:${rel}`)
1112
+ }
1113
+ return { dir, name, kind: 'outline' }
1114
+ }
1115
+ if (dir === 'chapters') {
1116
+ // 仅删:正文存/读各有专用路由(chapter-save / chapter),通用通道只放
1117
+ // 删除——放开通用存会绕过 frontmatter 围栏保护与状态机语义。
1118
+ if (op !== 'delete') fail(403, `chapters/ 只开放删除(正文编辑走 chapter-save),已拒绝:${rel}`)
1119
+ const stem = name.replace(/\.md$/i, '')
1120
+ if (!/\.md$/i.test(name) || chapterSortKey(stem) === null) {
1121
+ fail(422, `章节文件名不合 vXX-cXXX 规范:${rel}`)
1122
+ }
1123
+ return { dir, name, kind: 'chapter' }
1124
+ }
1125
+ fail(403, `rel 不在受管文件白名单内,已拒绝:${rel}`)
1126
+ }
1127
+
1128
+ /** 白名单校验之后再来一道 resolve 包含校验:拼接结果必须落在 novel 根内
1129
+ * (双保险防符号形状的意外,与 locateChapter 同纪律)。 */
1130
+ function managedTargetOf(novelDir, rel, op) {
1131
+ const check = managedRelCheck(rel, op)
1132
+ const root = path.resolve(novelDir)
1133
+ const target = path.resolve(root, rel)
1134
+ const within = path.relative(root, target)
1135
+ if (within === '' || within.startsWith('..') || path.isAbsolute(within)) {
1136
+ fail(403, `受管路径越界,已拒绝:${rel}`)
1137
+ }
1138
+ return { ...check, target }
1139
+ }
1140
+
1141
+ /**
1142
+ * 读受管文件(面板编辑态打开时取数):不存在 404。hash 为原文 sha256,作
1143
+ * file-save 的 base_hash 基线——与 chapter 路由同口径(读与指纹同刻同源)。
1144
+ */
1145
+ export function readManagedFile(novelDir, rel) {
1146
+ const { target } = managedTargetOf(novelDir, rel, 'read')
1147
+ const text = readTarget(target, `源文件 ${rel}`)
1148
+ return { ok: true, rel, text, hash: sha256Hex(text) }
1149
+ }
1150
+
1151
+ /**
1152
+ * 受管文本的结构校验(保存/新建共一道门,服务端只验形状):
1153
+ * - 六个账本 yml:按快照同源口径解析(yamlLib 可用走真解析,否则
1154
+ * parseFlatYamlArray 极简回退——两条路必须同一结论,否则面板存得进、快照
1155
+ * 读不出);必须解析为顶层数组且条目全是平键对象(空数组/空文件允许)。
1156
+ * 快照会把 null/裸标量/数组条目/空对象条目静默丢弃——存得进却读不出等于
1157
+ * 丢数据,这里一并 422 带原因。
1158
+ * - 四个 md 实体目录:必须含成对 frontmatter 围栏(其他语义一律不管,留给
1159
+ * check);outlines 章纲纯 markdown 无围栏,跳过此检查。
1160
+ */
1161
+ export function validateManagedText(rel, text, yamlLib) {
1162
+ const { kind } = managedRelCheck(rel, 'save')
1163
+ if (kind === 'outline') return
1164
+ if (kind === 'yml') {
1165
+ // 空文件(含纯注释/空白)按空表放行:与快照「文件缺失按空表」同一宽容。
1166
+ if (String(text).trim() === '') return
1167
+ let data = null
1168
+ if (yamlLib) {
1169
+ try {
1170
+ data = yamlLib.parse(text)
1171
+ } catch {
1172
+ data = null // 真解析不认——与快照同款回退,不因 yaml 在场而更严
1173
+ }
1174
+ }
1175
+ if (data === null || data === undefined) data = parseFlatYamlArray(text)
1176
+ if (!Array.isArray(data)) fail(422, `${rel} 顶层不是数组——受管账本 yml 只接受「- key: value」条目列表`)
1177
+ data.forEach((item, index) => {
1178
+ const plain = item !== null && typeof item === 'object' && !Array.isArray(item) && Object.keys(item).length > 0
1179
+ if (!plain) fail(422, `${rel} 第 ${index + 1} 条不是平键对象(快照会静默丢弃该条,请删掉或补全后再存)`)
1180
+ })
1181
+ return
1182
+ }
1183
+ // md 实体卡:只验围栏成对,卡内键值/枚举全不问(角色 role 越枚举这类语义
1184
+ // 由 check 标黄条,人工编辑不因此被挡)。
1185
+ if (splitFrontmatter(text).raw === null) fail(422, `${rel} 缺少成对的 frontmatter 围栏(--- … ---),请补齐后再存`)
1186
+ }
1187
+
1188
+ /**
1189
+ * 存受管文件(编辑态保存):目标不存在 404(新建走 file-create,一条路一个
1190
+ * 语义);text 非 string 422;base_hash 过期 409 拒写不落盘(人工改动保护,
1191
+ * 与 project-save 同口径文案);结构校验 422 带原因。写前读、原子写,尾无
1192
+ * 换行补一个 \n(POSIX 文本文件惯例,与正文/章纲保存同惯例)。返回写后新
1193
+ * 指纹 hash,客户端下一轮 base_hash 直接取它。
1194
+ */
1195
+ export function saveManagedFile(novelDir, rel, text, options = {}) {
1196
+ if (typeof text !== 'string') fail(422, 'text 必须是字符串(文件全文)')
1197
+ validateManagedText(rel, text, yamlLibOf(options))
1198
+ const { target } = managedTargetOf(novelDir, rel, 'save')
1199
+ const current = readTarget(target, `源文件 ${rel}`)
1200
+ const baseHash = typeof options.baseHash === 'string' ? options.baseHash.trim().toLowerCase() : ''
1201
+ if (baseHash !== '' && baseHash !== sha256Hex(current)) {
1202
+ fail(409, `${rel} 已被外部修改,请刷新后基于最新内容重改再存`)
1203
+ }
1204
+ const next = text.endsWith('\n') ? text : `${text}\n`
1205
+ writeAtomic(target, next)
1206
+ return { ok: true, rel, hash: sha256Hex(next) }
1207
+ }
1208
+
1209
+ /**
1210
+ * 新建受管文件:已存在 409(先到先建,绝不覆盖——与 chapter-create 同纪律);
1211
+ * 结构校验同 saveManagedText(新角色卡/新章纲由客户端模板预填,服务端只验
1212
+ * 形状)。目录缺席先建(新 slug 落在还没建过的目录、测试夹具都可能出现)。
1213
+ */
1214
+ export function createManagedFile(novelDir, rel, text, options = {}) {
1215
+ if (typeof text !== 'string') fail(422, 'text 必须是字符串(文件全文)')
1216
+ const { target } = managedTargetOf(novelDir, rel, 'create')
1217
+ if (existsSync(target)) fail(409, `${rel} 已存在——不覆盖既有文件,改名或改走编辑`)
1218
+ validateManagedText(rel, text, yamlLibOf(options))
1219
+ const next = text.endsWith('\n') ? text : `${text}\n`
1220
+ mkdirSync(path.dirname(target), { recursive: true })
1221
+ writeAtomic(target, next)
1222
+ return { ok: true, rel, hash: sha256Hex(next) }
1223
+ }
1224
+
1225
+ /**
1226
+ * 删除受管文件:不存在 404。不可逆操作,服务端不做二次确认(UI 层强制确认
1227
+ * 弹层),白名单上它是最严的一档(见 managedRelCheck)。
1228
+ */
1229
+ export function deleteManagedFile(novelDir, rel) {
1230
+ const { target } = managedTargetOf(novelDir, rel, 'delete')
1231
+ try {
1232
+ unlinkSync(target)
1233
+ } catch (err) {
1234
+ if (err?.code === 'ENOENT') fail(404, `源文件不存在:${rel}`)
1235
+ fail(500, `源文件删除失败(${err?.code ?? err?.message ?? err})`)
1236
+ }
1237
+ return { ok: true, rel }
1238
+ }