dsh-maoying 0.0.0-stage → 0.1.0

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/bin/maoying.mjs +456 -0
  4. package/cordis.patch.yml +6 -0
  5. package/docs/creator-panel.md +108 -0
  6. package/docs/design.md +206 -0
  7. package/docs/spec.md +133 -0
  8. package/icon.svg +8 -0
  9. package/index.mjs +840 -0
  10. package/lib/base.css +686 -0
  11. package/lib/client.js +1366 -0
  12. package/lib/epub.mjs +238 -0
  13. package/lib/novel-parse.mjs +840 -0
  14. package/lib/panel-core.mjs +660 -0
  15. package/lib/yaml-loader.mjs +42 -0
  16. package/package.json +57 -4
  17. package/presets/maoying/agent.cordis.v2.yml +493 -0
  18. package/presets/maoying/agent.cordis.yml +340 -0
  19. package/presets/maoying/delegation-labels.mjs +72 -0
  20. package/presets/maoying/preset.yml +7 -0
  21. package/presets/maoying/restrict.mjs +154 -0
  22. package/skills/maoying-beat-pacing/SKILL.md +37 -0
  23. package/skills/maoying-foreshadow-discipline/SKILL.md +40 -0
  24. package/skills/maoying-golden-opening/SKILL.md +41 -0
  25. package/skills/maoying-review-checklist/SKILL.md +52 -0
  26. package/templates/novel/README.md +207 -0
  27. package/templates/novel/chapters/.gitkeep +0 -0
  28. package/templates/novel/characters/.gitkeep +0 -0
  29. package/templates/novel/clue-board.yml +8 -0
  30. package/templates/novel/exports/.gitkeep +0 -0
  31. package/templates/novel/foreshadows.yml +11 -0
  32. package/templates/novel/memories.yml +7 -0
  33. package/templates/novel/outlines/.gitkeep +0 -0
  34. package/templates/novel/project.yml +10 -0
  35. package/templates/novel/relations.yml +9 -0
  36. package/templates/novel/research/.gitkeep +0 -0
  37. package/templates/novel/science/.gitkeep +0 -0
  38. package/templates/novel/timeline.yml +8 -0
  39. package/templates/novel/volumes.yml +7 -0
  40. package/templates/novel/world/.gitkeep +0 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lifangjin
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,147 @@
1
- # Temporary Holding Version
1
+ # dsh-maoying(毛颖)
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
+ 毛颖者,笔也——韩愈作《毛颖传》,把一支毛笔写成有封地有爵位的臣子。dsh-maoying 把这支笔装进 DSH(DeepSeek Harness):**一支管着一班帮手、守着一摞书稿的毛笔**——网文创作辅助插件,全部实现原创,MIT 发布。
4
+
5
+ 装上之后,主 agent 变身**网文主编(创作总监)**:带七阶段工作流与六步写作法,把大纲、正文、审校、设定维护、素材考据分别委派给五个只带本行工具的帮手;整部小说就是工作区里的 `novel/` 结构化 markdown/YAML 目录(角色卡、伏笔状态机、时间线、线索板、叙事记忆)——agent 直接读写文件,**没有数据库,没有 API 层**。查「所有未回收伏笔」就是 grep 一行。
6
+
7
+ ## 功能特性
8
+
9
+ - **主编 + 五帮手**:主 agent 当主编,只做规划、衔接、验收、状态推进;大纲师(outline)、写手(write)、审校(review)、设定管理员(lore)、素材调研员(research)各带本行工具随叫随到。找文件、查伏笔状态这类顺手小事主编自己 glob/grep 就办,不绕委派。
10
+ - **novel/ 文件数据库**:角色卡、世界观、科学设定三色标注、关系图、伏笔账本、时间线、叙事记忆、线索板,全部是 grep 友好的平键/顶层数组 YAML——人可直接改,git 可版本化,无迁移无运维。
11
+ - **七阶段工作流**:idea 选题 → setting 设定 → outline 大纲 → writing 写作 → review 审阅 → consistency 一致性 → export 导出。阶段记在 project.yml 的 phase 键,每完成一个里程碑主编推进并简报。
12
+ - **Writing 六步法**:衔接检查 → 出大纲 → 写正文 → 审稿 → 修改 → 定稿维护,逐章循环;配合 Collab 四步共创(设定共创 → 分卷规划 → 黄金三章 → 交接循环)把新书从零带到滚动更新。
13
+ - **伏笔状态机**:planted(埋设)→ progressing(推进)→ resolved(回收)/ abandoned(弃用),埋设与回收记章节号、预期回收章节排期、优先级分档——长篇不断线,活跃伏笔超 15 条主编主动提醒清理。
14
+ - **上下文按需取**:主编先读 index.yml 概览,写到哪章读哪章(前章结尾约 1500 字 + 该章大纲 + 出场角色卡 + 活跃伏笔节选),不把整本书塞进上下文。
15
+ - **工具面收窄**:主编只见「角色委派 + 内部检索 + 协调」白名单,帮手各带本行工具(审校只读、考据只带联网+落笔记);每次请求的工具开销与上下文占用随之下降。
16
+ - **素材考据**:research 帮手联网检索历史/地理/专业领域素材,笔记落 `novel/research/`,区分史实/通说/演义并附来源——DSH 架构的红利。
17
+ - **CLI 兜底**:`maoying init / stats / index` 三条零依赖命令管脚手架、字数统计与章节概览重建。
18
+
19
+ ## 快速开始
20
+
21
+ 前置:**Node.js ≥ 18**、已装好的 DSH host(**0.1.7+**,见「兼容面」)。
22
+
23
+ 官方插件通道——一条命令,装完重启即用:
24
+
25
+ ```bash
26
+ dsh plugin --profile web add dsh-maoying
27
+ ```
28
+
29
+ **重启 DSH(`dsh web`)**:插件启动时自动把预设装进 `~/.dsh/.agent-presets/maoying/`,并在 `~/.dsh/cordis.patch.yml` 写好声明行托管块,无需再点任何按钮。新会话的预设选择器里选「**毛颖创作 (Maoying)**」即可开写;要设为默认,在 Settings → Agent Presets 里选。
30
+
31
+ 开发检出(想改插件或预设源码)用 link 形态直连仓库,改动经重启生效:
32
+
33
+ ```bash
34
+ git clone https://github.com/lifangjin/dsh-maoying.git
35
+ dsh plugin --profile web add link:"$PWD/dsh-maoying"
36
+ ```
37
+
38
+ ## 怎么用
39
+
40
+ 选好预设后,对主编说话即可,它按工作流自己路由:
41
+
42
+ **开新书(Collab 四步共创)**:
43
+
44
+ > 我想写一本东方玄幻,主角靠「听懂器物说话」的金手指鉴宝升级。
45
+
46
+ 主编与你对齐题材与基调,委派设定管理员建核心角色卡与世界观条目,委派大纲师做分卷规划,然后把黄金三章(立世界钩子、立主角动机、立核心冲突)写出来交付。
47
+
48
+ **逐章写作(Writing 六步法)**:
49
+
50
+ > 第三章可以开写了吗?
51
+
52
+ 主编做衔接检查,委派大纲师出本章大纲(含五维叙事字段),过目后派写手写正文、审校审稿(清单 + 四维一致性扫描),有问题回写手修,通过后置 accepted 并委派设定管理员做定稿维护——你只需要在决策点拍板。
53
+
54
+ **集中审查**:
55
+
56
+ > 前 20 章做一次一致性检查。
57
+
58
+ 主编推进 phase 到 consistency,审校扫描冲突/时间线/伏笔/设定四维,问题按严重度排好交回写手。
59
+
60
+ 用户回「ok / 可以 / 继续」即视为确认,主编直接推进,不复述确认。
61
+
62
+ ## 共创模式(作者亲笔)
63
+
64
+ 不是谁都想让 AI 代笔。project.yml 的 `mode` 键决定正文归谁写,三种模式一句话:`ai-draft`(默认)AI 代笔,走 Writing 六步法;`co-write` 逐章指派谁写,主编动笔前先问清本章归谁;`author-draft` 全书作者亲笔,毛颖只备料、审稿、记账,永不代写。
65
+
66
+ author-draft 下的协作循环——你只管写,交稿即接管杂务:
67
+
68
+ > 第 5 章写完了,贴给你。(正文直接粘进对话,或者说一声第 X 章写完、指个文件)
69
+
70
+ 主编收稿后你会拿到三样东西:**审校意见清单**(只挑问题、不代改,改不改随你)、**账本回填**(伏笔/时间线/关系/记忆/线索五本账按你正文实际发生的记,计划外的改动也能认领,来源标 `backfill`)、**下一章章纲**(`novel/outlines/vXX-cXXX.md`,拿着就能写)。圣域纪律:`chapters/` 正文唯作者可改,agent 至多更新 frontmatter 的 summary 与五维字段,且先建议后动手。
71
+
72
+ 配套命令:`maoying brief <dir> <章号>` 打包写作卡(章纲 + 前章结尾 + 角色卡 + 活跃伏笔,带去任意编辑器或其他 AI),`maoying check <dir>` 做零模型静态体检(frontmatter 完整性、伏笔章号引用、时间线乱序等,不调模型——LLM 审校走主编)。
73
+
74
+ ## 方法论技能
75
+
76
+ 随插件带 4 个网文方法论技能,插件启动时安装器把它们落盘到 `~/.dsh/skills/`(即 `<home>/skills/`)下的 `maoying-*` 四个目录,宿主技能面板可直接看到;大纲师、写手、审校、设定管理员按场景自动加载,你不用手动操作:
77
+
78
+ | 技能 | 一句话说明 |
79
+ |---|---|
80
+ | maoying-golden-opening | 黄金三章:前三章职能分工(世界钩子 → 动机确立 → 冲突引爆)、前 300 字出事原则、信息释放纪律与自检清单 |
81
+ | maoying-beat-pacing | 爽点节奏:爽点类型库、压抑-释放配比、单章/中/大三级节奏曲线与章尾钩子选型 |
82
+ | maoying-review-checklist | 审校清单:语言/节奏/人设/逻辑/伏笔/剧透六层逐项检查,按 P0/P1/P2 三档严重度输出 |
83
+ | maoying-foreshadow-discipline | 伏笔管理:埋设即登记、推进节奏排期、回收「意外 + 合理」标准与台账清理阈值 |
84
+
85
+ 安装器只认 `maoying-*` 前缀目录,`~/.dsh/skills/` 下你自己的技能一个字节不动。卸载插件后技能目录需手动清理(四目录一起删):
86
+
87
+ ```bash
88
+ dsh plugin --profile web remove dsh-maoying
89
+ rm -rf "${DSH_HOME:-$HOME/.dsh}/skills/"maoying-*
90
+ ```
91
+
92
+ ## novel/ 数据规范
93
+
94
+ `novel/` 目录是整个插件的数据库:project.yml(元信息与七阶段 phase)、volumes.yml、chapters/(frontmatter 五维叙事字段 + 正文)、characters/(角色卡)、world/、science/(三色设定)、relations.yml、foreshadows.yml(伏笔状态机)、timeline.yml、memories.yml(含出场记录与承诺账)、clue-board.yml、research/、exports/。全部格式、示例与设计理由见:
95
+
96
+ - 速查版:项目初始化后的 `novel/README.md`(模板源 [templates/novel/README.md](templates/novel/README.md))
97
+ - 完整版:[docs/spec.md](docs/spec.md)(为什么不用 SQLite、五维字段定义、伏笔状态机语义、出场记录约定)
98
+
99
+ ## CLI
100
+
101
+ ```bash
102
+ maoying init [dir] # 生成 novel/ 脚手架(默认 ./novel;已存在文件一律跳过,绝不覆盖)
103
+ maoying stats [dir] # 逐章 CJK 字数、状态分布、当前 phase
104
+ maoying index [dir] # 从 volumes.yml + chapters/ 重建 index.yml(派生文件)
105
+ maoying export [dir] # 导出全书:--format md|txt|epub(默认 md),--out <目录> 改输出位置
106
+ ```
107
+
108
+ export 示例:
109
+
110
+ ```bash
111
+ maoying export novel # Markdown,落到 novel/exports/
112
+ maoying export novel --format txt # 纯文本
113
+ maoying export novel --format epub # EPUB 3(零依赖打包,可直接导入阅读器)
114
+ maoying export novel --format epub --out ~/Downloads # 换输出目录
115
+ ```
116
+
117
+ 输出目录默认 `<dir>/exports/`,文件名 `<书名>-<YYYYMMDD>.<扩展名>`(书名取 project.yml 的 title);章节解析与 stats/index 同一套逻辑,frontmatter 不会混进正文。
118
+
119
+ 零依赖(node:fs/path/url/crypto),任意 cwd 可用;npm 全局安装或 `pnpm link` 后可直接 `maoying` 调用,否则 `node <插件目录>/bin/maoying.mjs` 等价。日常写作中 index.yml 由设定管理员直接重建,CLI 主要给人用。
120
+
121
+ ## 卸载
122
+
123
+ ```bash
124
+ dsh plugin --profile web remove dsh-maoying
125
+ rm -rf "${DSH_HOME:-$HOME/.dsh}/.agent-presets/maoying"
126
+ rm -rf "${DSH_HOME:-$HOME/.dsh}/skills/"maoying-*
127
+ ```
128
+
129
+ 宿主是 DSH 0.1.7+ 时再补一步:删去 `~/.dsh/cordis.patch.yml` 里的 dsh-maoying 托管块(`# --- dsh-maoying preset managed (auto-generated; do not edit) ---` 到 `# --- end dsh-maoying preset managed ---` 之间的整段,含两行标记),免得留下指向已删目录的声明行。`novel/` 目录是你的书稿,卸载插件不会动它。
130
+
131
+ ## 兼容面
132
+
133
+ 插件启动时读宿主版本(`<home>/node_modules/@deepseek-ai/dsh/package.json`),按版本自动选择委派行形状——**宿主升级后重启即切换,无需重装插件**:
134
+
135
+ - **DSH 0.1.7 – 0.2.1-rc.1 → v1 内联形状**:5 个角色委派行直接内联 persona 与 toolFilter,托管块 1 条声明行。
136
+ - **DSH 0.2.1-rc.2+ → v2 AgentPreset 形状**:该版本起 dsh-tool-subagent 拒绝内联 persona/toolFilter,插件改用 [presets/maoying/agent.cordis.v2.yml](presets/maoying/agent.cordis.v2.yml)——托管块写 6 条声明行(主编 preset + 大纲师/写手/审校/设定管理员/素材调研员 5 张角色 preset),主编委派行以 `agentPreset` 指向角色 preset,角色的工具面由 preset 里的实体工具行承载(审校只读、考据带联网检索)。
137
+ - **角色预设会出现在预设选择器**(宿主对预设没有隐藏机制),name 带「(内部)」标明:日常写作由主编委派自动选用,不经主编、想单独开一个写手/考据会话时直接选中即可。
138
+ - 两种形状的 persona 文本同源逐字一致([test/composition.test.mjs](test/composition.test.mjs) 逐字节把关);宿主版本读不到时保守按 v1 处理。
139
+ - ≤0.1.6 的目录扫描轨道:不做(宿主已淘汰)。
140
+
141
+ ## 许可
142
+
143
+ [MIT](LICENSE) © 2026 lifangjin
144
+
145
+ ## 致谢
146
+
147
+ - 「毛颖」得名于韩愈《毛颖传》:毛笔拟人,有封地有爵位。
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * maoying — 毛颖网文创作项目 CLI(init / stats / index / export / brief / check)
4
+ *
5
+ * 零依赖:只用 node:fs / node:path / node:url / node:crypto,模板目录经
6
+ * import.meta.url 定位,任意 cwd 可用。novel/ 目录的数据规范见
7
+ * templates/novel/README.md(或已初始化项目里的 novel/README.md)。
8
+ *
9
+ * maoying init [dir] 生成 novel/ 脚手架(默认 ./novel;已存在文件跳过,绝不覆盖)
10
+ * maoying stats [dir] 逐章字数(CJK 计数)、状态分布、当前 phase
11
+ * maoying index [dir] 从 volumes.yml + chapters/ 重建 index.yml(派生文件)
12
+ * maoying export [dir] 导出全书:--format md|txt|epub(默认 md)、--out <目录>
13
+ * maoying brief [dir] <chapter> [--out <文件>]
14
+ * 组装某章写作卡(章纲+前章结尾+出场角色卡+活跃伏笔)
15
+ * maoying check [dir] 静态体检(不调模型):frontmatter/枚举/章号/伏笔引用等
16
+ *
17
+ * 子命令出错 exit 1,中文报错。
18
+ */
19
+
20
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
21
+ import { randomUUID } from 'node:crypto'
22
+ import path from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+
25
+ import { buildEpub } from '../lib/epub.mjs'
26
+ // 解析层与面板共用(lib/novel-parse.mjs);parseFlatYamlArray 沿用旧名
27
+ // parseSimpleYaml,保持本文件其余代码原样不动。
28
+ import { buildBrief, buildIndexYaml, checkProject, collectChapters, parseFlatYamlArray as parseSimpleYaml } from '../lib/novel-parse.mjs'
29
+
30
+ const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
31
+ const TEMPLATE_DIR = path.join(PLUGIN_ROOT, 'templates', 'novel')
32
+
33
+ /** 七阶段工作流(与 project.yml 的 phase 枚举一致)。 */
34
+ const PHASES = {
35
+ idea: '选题',
36
+ setting: '设定',
37
+ outline: '大纲',
38
+ writing: '写作',
39
+ review: '审阅',
40
+ consistency: '一致性',
41
+ export: '导出',
42
+ }
43
+
44
+ /** 章节状态机(frontmatter status 枚举;带中文标签,展示用)。 */
45
+ const STATUSES = [
46
+ ['pending', '待写'],
47
+ ['writing', '写作中'],
48
+ ['review', '审阅中'],
49
+ ['accepted', '已定稿'],
50
+ ]
51
+
52
+ // ── 基础工具 ────────────────────────────────────────────────────────────────
53
+
54
+ /** 中文报错 + exit 1。 */
55
+ function fail(message) {
56
+ console.error(`maoying: ${message}`)
57
+ process.exit(1)
58
+ }
59
+
60
+ /** 读文本文件;读不到返回 null。 */
61
+ function readTextOrNull(file) {
62
+ try {
63
+ return readFileSync(file, 'utf8')
64
+ } catch {
65
+ return null
66
+ }
67
+ }
68
+
69
+ // ── init ────────────────────────────────────────────────────────────────────
70
+
71
+ /** 递归复制模板树;已存在文件跳过(绝不覆盖),返回 {created, skipped} 相对路径清单。 */
72
+ function copyTemplateTree(srcDir, destDir, relBase = '') {
73
+ const created = []
74
+ const skipped = []
75
+ mkdirSync(destDir, { recursive: true })
76
+ for (const entry of readdirSync(srcDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
77
+ const rel = relBase === '' ? entry.name : `${relBase}/${entry.name}`
78
+ const src = path.join(srcDir, entry.name)
79
+ const dst = path.join(destDir, entry.name)
80
+ if (entry.isDirectory()) {
81
+ const sub = copyTemplateTree(src, dst, rel)
82
+ created.push(...sub.created)
83
+ skipped.push(...sub.skipped)
84
+ } else if (existsSync(dst)) {
85
+ skipped.push(rel)
86
+ } else {
87
+ copyFileSync(src, dst)
88
+ created.push(rel)
89
+ }
90
+ }
91
+ return { created, skipped }
92
+ }
93
+
94
+ function cmdInit(targetArg) {
95
+ if (!existsSync(TEMPLATE_DIR)) fail(`模板目录缺失:${TEMPLATE_DIR}(插件包不完整?)`)
96
+ const dest = path.resolve(targetArg ?? 'novel')
97
+ const { created, skipped } = copyTemplateTree(TEMPLATE_DIR, dest)
98
+ console.log(`毛颖脚手架 → ${dest}`)
99
+ for (const rel of created) console.log(` 创建 ${rel}`)
100
+ for (const rel of skipped) console.log(` 跳过 ${rel}(已存在,未覆盖)`)
101
+ console.log(`完成:新建 ${created.length} 个文件,跳过 ${skipped.length} 个。`)
102
+ console.log('数据规范见 novel/README.md;动手前先填 novel/project.yml。')
103
+ }
104
+
105
+ // ── stats ───────────────────────────────────────────────────────────────────
106
+
107
+ function cmdStats(targetArg) {
108
+ const root = path.resolve(targetArg ?? 'novel')
109
+ const chapters = collectChapters(root)
110
+ if (chapters === null) fail(`找不到 ${path.join(root, 'chapters')}——先跑 maoying init ${root}`)
111
+
112
+ const project = parseSimpleYaml(readTextOrNull(path.join(root, 'project.yml')))
113
+ const phase = project !== null && typeof project.phase === 'string' && project.phase !== '' ? project.phase : null
114
+ const phaseText = phase === null ? '未设置(缺 project.yml 或 phase 键)' : `${phase}(${PHASES[phase] ?? '未知阶段'})`
115
+
116
+ console.log(`小说统计:${root}`)
117
+ console.log(`阶段:${phaseText}`)
118
+ console.log('')
119
+
120
+ if (chapters.length === 0) {
121
+ console.log('章节(0):chapters/ 下还没有章节文件')
122
+ } else {
123
+ console.log(`章节(${chapters.length}):`)
124
+ for (const ch of chapters) {
125
+ const statusLabel = STATUSES.find(([s]) => s === ch.status)?.[1] ?? ch.status
126
+ // 文件名合规范时 stem(v01-c001)即唯一标识;不合规范再补 meta 卷号/章号
127
+ const id = ch.sortKey !== null ? ch.stem : `${ch.volume || 'v??'} c${String(ch.num ?? 0).padStart(3, '0')}`
128
+ console.log(` ${ch.stem.padEnd(10)} ${id !== ch.stem ? `(${id}) ` : ''}${ch.title} [${statusLabel}] ${ch.words} 字`)
129
+ }
130
+ }
131
+
132
+ const counts = Object.fromEntries(STATUSES.map(([s]) => [s, 0]))
133
+ let total = 0
134
+ for (const ch of chapters) {
135
+ counts[ch.status] += 1
136
+ total += ch.words
137
+ }
138
+ console.log('')
139
+ console.log(`状态分布:${STATUSES.map(([s, label]) => `${s}(${label})${counts[s]}`).join(' · ')}`)
140
+ console.log(`总字数:${total}(CJK 计数)`)
141
+ }
142
+
143
+ // ── index ───────────────────────────────────────────────────────────────────
144
+
145
+ // index.yml 的拼装逻辑在 lib/novel-parse.mjs 的 buildIndexYaml(与创作台
146
+ // rebuild-index 路由共用),这里只负责读卷表、落盘与汇报。
147
+
148
+ function cmdIndex(targetArg) {
149
+ const root = path.resolve(targetArg ?? 'novel')
150
+ const chapters = collectChapters(root)
151
+ if (chapters === null) fail(`找不到 ${path.join(root, 'chapters')}——先跑 maoying init ${root}`)
152
+ const volumesRaw = parseSimpleYaml(readTextOrNull(path.join(root, 'volumes.yml')))
153
+ const { text, volumesCount, chaptersCount, orphansCount, totalWords } = buildIndexYaml(chapters, volumesRaw)
154
+ const indexPath = path.join(root, 'index.yml')
155
+ writeFileSync(indexPath, text, 'utf8')
156
+ console.log(`已重建 ${indexPath}:${volumesCount} 卷、${chaptersCount} 章(总字数 ${totalWords})、未归卷 ${orphansCount} 章。`)
157
+ }
158
+
159
+ // ── export ──────────────────────────────────────────────────────────────────
160
+
161
+ /** 支持的导出格式(--format 取值)。 */
162
+ const EXPORT_FORMATS = ['md', 'txt', 'epub']
163
+
164
+ /**
165
+ * 导出文件名里的书名清洗:路径非法字符 /\\:*?"<>| 一律替换掉,再去首尾
166
+ * 空白与点(Windows 上尾点会吞扩展名)。清完为空兜回「未命名书稿」。
167
+ */
168
+ function sanitizeTitleForFile(title) {
169
+ const cleaned = String(title ?? '')
170
+ .replace(/[/\\:*?"<>|]/g, '')
171
+ .replace(/^[.\s]+|[.\s]+$/g, '')
172
+ return cleaned !== '' ? cleaned : '未命名书稿'
173
+ }
174
+
175
+ /** 卷标题映射:volumes.yml 的 id → title;文件缺失/不合规返回空映射。 */
176
+ function volumeTitleMap(root) {
177
+ const raw = parseSimpleYaml(readTextOrNull(path.join(root, 'volumes.yml')))
178
+ const list = Array.isArray(raw) ? raw.filter((v) => v !== null && typeof v === 'object' && !Array.isArray(v)) : []
179
+ const map = new Map()
180
+ for (const v of list) {
181
+ const id = String(v.id ?? '')
182
+ if (id !== '' && typeof v.title === 'string' && v.title !== '') map.set(id, v.title)
183
+ }
184
+ return map
185
+ }
186
+
187
+ /** 卷号还原:'v01' → 1;不合规范的卷 id 返回 null(该卷跳过卷层)。 */
188
+ function volumeNumber(volume) {
189
+ const m = /^v(\d+)$/.exec(String(volume ?? ''))
190
+ return m === null ? null : Number(m[1])
191
+ }
192
+
193
+ /**
194
+ * 正文分段:空行为段界;段内多行去首尾空白后直接拼接(中文行间不加空格)。
195
+ * md/txt 的正文与 epub 的 <p> 共用这一套。
196
+ */
197
+ function bodyParagraphs(body) {
198
+ const paragraphs = []
199
+ let current = []
200
+ for (const rawLine of String(body ?? '').split(/\r?\n/)) {
201
+ const line = rawLine.trim()
202
+ if (line === '') {
203
+ if (current.length > 0) paragraphs.push(current.join(''))
204
+ current = []
205
+ } else {
206
+ current.push(line)
207
+ }
208
+ }
209
+ if (current.length > 0) paragraphs.push(current.join(''))
210
+ return paragraphs
211
+ }
212
+
213
+ /**
214
+ * 章节展示信息(md/txt/epub 三格式共用):卷层、章标题、分段正文。
215
+ * 章标题形如「卷X-章Y 章节标题」;卷 id 不合规范或章节无卷信息时退为
216
+ * 「章Y 章节标题」,卷层记 null(导出时跳过卷层)。章号缺省用全书序号。
217
+ */
218
+ function chapterDisplay(ch, ordinal, volTitles) {
219
+ const volNum = volumeNumber(ch.volume)
220
+ const chNum = Number.isInteger(ch.num) ? ch.num : ordinal
221
+ const volPart = volNum !== null ? `卷${volNum}-章${chNum}` : `章${chNum}`
222
+ return {
223
+ volume: volNum !== null ? { num: volNum, title: volTitles.get(ch.volume) ?? '' } : null,
224
+ heading: `${volPart} ${ch.title}`.trim(),
225
+ body: ch.body,
226
+ paragraphs: bodyParagraphs(ch.body),
227
+ }
228
+ }
229
+
230
+ /** 按相邻同卷分组(保持章序):[{ volume, chapters: [display] }],无卷组 volume 为 null。 */
231
+ function groupByVolume(chapters, volTitles) {
232
+ const groups = []
233
+ let current = null
234
+ chapters.forEach((ch, i) => {
235
+ const display = chapterDisplay(ch, i + 1, volTitles)
236
+ const rawVolume = ch.volume ?? ''
237
+ if (current === null || current.rawVolume !== rawVolume) {
238
+ current = { rawVolume, volume: display.volume, chapters: [] }
239
+ groups.push(current)
240
+ }
241
+ current.chapters.push(display)
242
+ })
243
+ return groups
244
+ }
245
+
246
+ /** 卷层标题:`卷1 卷名`;无卷名只留 `卷1`。 */
247
+ function volumeHeading(volume) {
248
+ return volume.title !== '' ? `卷${volume.num} ${volume.title}` : `卷${volume.num}`
249
+ }
250
+
251
+ /** md:`# 书名` + 卷层 `## 卷N 卷名` + 每章 `### 卷X-章Y 标题` + 正文原文。 */
252
+ function renderMarkdown(title, groups) {
253
+ const lines = [`# ${title}`, '']
254
+ for (const group of groups) {
255
+ if (group.volume !== null) lines.push(`## ${volumeHeading(group.volume)}`, '')
256
+ for (const display of group.chapters) {
257
+ lines.push(`### ${display.heading}`, '', display.body.trim(), '')
258
+ }
259
+ }
260
+ return `${lines.join('\n').replace(/\n+$/, '')}\n`
261
+ }
262
+
263
+ /** txt:纯文本;书名/卷名/章标题独立成行(章标题前空一行),段间空行。 */
264
+ function renderText(title, groups) {
265
+ const lines = [title]
266
+ for (const group of groups) {
267
+ if (group.volume !== null) lines.push('', volumeHeading(group.volume))
268
+ for (const display of group.chapters) {
269
+ lines.push('', display.heading, '', display.paragraphs.join('\n\n'))
270
+ }
271
+ }
272
+ return `${lines.join('\n').replace(/\n+$/, '')}\n`
273
+ }
274
+
275
+ function cmdExport(args) {
276
+ let dirArg = null
277
+ let format = 'md'
278
+ let outArg = null
279
+ for (let i = 0; i < args.length; i++) {
280
+ const arg = args[i]
281
+ if (arg === '--format' || arg === '--out') {
282
+ const value = args[++i]
283
+ if (value === undefined) fail(`选项 ${arg} 缺参数——--format 取 md|txt|epub,--out 取输出目录`)
284
+ if (arg === '--format') format = value
285
+ else outArg = value
286
+ } else if (arg.startsWith('--format=')) {
287
+ format = arg.slice('--format='.length)
288
+ } else if (arg.startsWith('--out=')) {
289
+ outArg = arg.slice('--out='.length)
290
+ } else if (arg.startsWith('-')) {
291
+ fail(`未知选项「${arg}」——export 只认 --format md|txt|epub 与 --out <目录>`)
292
+ } else if (dirArg === null) {
293
+ dirArg = arg
294
+ } else {
295
+ fail(`多余的位置参数「${arg}」——export 只接受一个项目目录`)
296
+ }
297
+ }
298
+ if (!EXPORT_FORMATS.includes(format)) fail(`未知导出格式「${format}」——支持 ${EXPORT_FORMATS.join(' / ')}`)
299
+
300
+ const root = path.resolve(dirArg ?? 'novel')
301
+ const chapters = collectChapters(root)
302
+ if (chapters === null) fail(`找不到 ${path.join(root, 'chapters')}——先跑 maoying init ${root}`)
303
+ if (chapters.length === 0) fail('没有可导出的章节')
304
+
305
+ const project = parseSimpleYaml(readTextOrNull(path.join(root, 'project.yml')))
306
+ const rawTitle =
307
+ project !== null && typeof project.title === 'string' && project.title.trim() !== '' ? project.title.trim() : ''
308
+ const title = rawTitle !== '' ? rawTitle : '未命名书稿'
309
+ const author =
310
+ project !== null && typeof project.author === 'string' && project.author.trim() !== ''
311
+ ? project.author.trim()
312
+ : '佚名'
313
+
314
+ const groups = groupByVolume(chapters, volumeTitleMap(root))
315
+ const now = new Date()
316
+ const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
317
+ const outDir = path.resolve(outArg ?? path.join(root, 'exports'))
318
+ const outPath = path.join(outDir, `${sanitizeTitleForFile(rawTitle)}-${stamp}.${format}`)
319
+
320
+ let content
321
+ if (format === 'md') {
322
+ content = renderMarkdown(title, groups)
323
+ } else if (format === 'txt') {
324
+ content = renderText(title, groups)
325
+ } else {
326
+ content = buildEpub({
327
+ title,
328
+ author,
329
+ uuid: randomUUID(),
330
+ chapters: groups.flatMap((group) => group.chapters),
331
+ })
332
+ }
333
+
334
+ mkdirSync(outDir, { recursive: true })
335
+ writeFileSync(outPath, content)
336
+ const totalWords = chapters.reduce((sum, ch) => sum + ch.words, 0)
337
+ console.log(`导出完成:${path.resolve(outPath)}`)
338
+ console.log(`格式 ${format};${chapters.length} 章,总字数 ${totalWords}(CJK 计数)。`)
339
+ }
340
+
341
+ // ── brief ───────────────────────────────────────────────────────────────────
342
+
343
+ /**
344
+ * maoying brief [dir] <chapter> [--out <文件>]
345
+ *
346
+ * 组装某章写作卡(章纲 + 前章结尾 + 出场角色卡 + 活跃伏笔 + 本章状态),
347
+ * 给作者带去任意编辑器/其他 AI。不带 --out 时写卡打到 stdout,提示与提醒
348
+ * 走 stderr,重定向出去的就是干净的卡;带 --out 落盘。组装逻辑在
349
+ * lib/novel-parse.mjs 的 buildBrief(与面板共用解析层)。
350
+ */
351
+ function cmdBrief(args) {
352
+ let dirArg = null
353
+ let chapterArg = null
354
+ let outArg = null
355
+ for (let i = 0; i < args.length; i++) {
356
+ const arg = args[i]
357
+ if (arg === '--out') {
358
+ const value = args[++i]
359
+ if (value === undefined) fail('选项 --out 缺参数——--out 取输出文件路径')
360
+ outArg = value
361
+ } else if (arg.startsWith('--out=')) {
362
+ outArg = arg.slice('--out='.length)
363
+ } else if (arg.startsWith('-')) {
364
+ fail(`未知选项「${arg}」——brief 只认 --out <文件>`)
365
+ } else if (chapterArg !== null) {
366
+ fail(`多余的位置参数「${arg}」——brief 只接受 [dir] <chapter> 两个位置参数`)
367
+ } else if (dirArg === null) {
368
+ dirArg = arg
369
+ } else {
370
+ chapterArg = arg
371
+ }
372
+ }
373
+ if (dirArg === null && chapterArg === null) {
374
+ fail('用法:maoying brief [dir] <chapter> [--out <文件>]——chapter 可写文件名(v01-c002.md)、stem(v01-c002)、「卷-章」(1-2)或裸章号(2)')
375
+ }
376
+ // 只给一个位置参数时把它当章号用(dir 走默认 ./novel),这是常用路径
377
+ const root = path.resolve(chapterArg === null ? 'novel' : dirArg)
378
+ const brief = buildBrief(root, chapterArg ?? dirArg)
379
+ if (!brief.ok) fail(brief.reason)
380
+ for (const warning of brief.warnings) console.error(`提醒:${warning}`)
381
+ if (outArg !== null) {
382
+ const outPath = path.resolve(outArg)
383
+ mkdirSync(path.dirname(outPath), { recursive: true })
384
+ writeFileSync(outPath, brief.text, 'utf8')
385
+ console.log(`写作卡已写入:${outPath}`)
386
+ console.log('拿去写,写完丢回给主编收稿。')
387
+ } else {
388
+ process.stdout.write(brief.text)
389
+ console.error('—— 写作卡到这儿为止。拿去写,写完丢回给主编收稿。')
390
+ }
391
+ }
392
+
393
+ // ── check ───────────────────────────────────────────────────────────────────
394
+
395
+ /**
396
+ * maoying check [dir]
397
+ *
398
+ * 静态体检:只查文件规范,不调模型(LLM 审校走主编)。检查逻辑在
399
+ * lib/novel-parse.mjs 的 checkProject。有 error 退出码 1,只有 warn 退出码 0。
400
+ */
401
+ function cmdCheck(targetArg) {
402
+ const root = path.resolve(targetArg ?? 'novel')
403
+ const result = checkProject(root)
404
+ console.log(`静态体检:${root}(只查文件规范,不调模型)`)
405
+ if (result.problems.length === 0) {
406
+ console.log('未发现问题。')
407
+ } else {
408
+ for (const p of result.problems) {
409
+ console.log(`${p.level === 'error' ? '[错误]' : '[提醒]'} ${p.file}:${p.message}`)
410
+ }
411
+ }
412
+ const { chapters, outlines, foreshadows, problems } = result.summary
413
+ console.log(`汇总:${chapters} 章、${outlines} 章纲、${foreshadows} 条伏笔;错误 ${problems.error} 个,提醒 ${problems.warn} 个。`)
414
+ if (!result.ok) process.exit(1)
415
+ }
416
+
417
+ // ── 入口 ────────────────────────────────────────────────────────────────────
418
+
419
+ const HELP_TEXT = `毛颖(maoying)—— 网文创作项目 CLI
420
+
421
+ 用法:
422
+ maoying init [dir] 生成 novel/ 脚手架(默认 ./novel;已存在的文件一律跳过,绝不覆盖)
423
+ maoying stats [dir] 逐章字数(CJK 计数)、状态分布、当前阶段
424
+ maoying index [dir] 从 volumes.yml + chapters/ 重建 index.yml(派生文件)
425
+ maoying export [dir] 导出全书:--format md|txt|epub(默认 md),--out <目录> 改输出位置
426
+ maoying brief [dir] <chapter>
427
+ 组装某章写作卡(章纲+前章结尾+出场角色卡+活跃伏笔);--out <文件> 落盘,缺省打到 stdout
428
+ maoying check [dir] 静态体检(不调模型):frontmatter 完整性、枚举值、章号卷号一致、伏笔章号引用、时间线年份、关系卡引用
429
+
430
+ 说明:
431
+ novel/ 目录的数据规范见 templates/novel/README.md(或项目内 novel/README.md)。
432
+ export 输出目录默认 <dir>/exports/,文件名 <书名>-<YYYYMMDD>.<扩展名>。
433
+ brief 的 chapter 可写文件名(v01-c002.md)、stem(v01-c002)、「卷-章」(1-2)或裸章号(2)。
434
+ check 有 error 退出码 1,只有 warn 退出码 0。
435
+ 所有命令零依赖,任意 cwd 可用;出错时退出码 1。`
436
+
437
+ function main(argv) {
438
+ const [command, ...rest] = argv
439
+ if (command === '--help' || command === '-h' || command === 'help') {
440
+ console.log(HELP_TEXT)
441
+ return
442
+ }
443
+ if (command === 'init') return cmdInit(rest[0])
444
+ if (command === 'stats') return cmdStats(rest[0])
445
+ if (command === 'index') return cmdIndex(rest[0])
446
+ if (command === 'export') return cmdExport(rest)
447
+ if (command === 'brief') return cmdBrief(rest)
448
+ if (command === 'check') return cmdCheck(rest[0])
449
+ if (command === undefined) {
450
+ console.error('maoying: 缺少子命令(init / stats / index / export / brief / check,--help 看用法)')
451
+ process.exit(1)
452
+ }
453
+ fail(`未知子命令「${command}」——可用:init / stats / index / export / brief / check(--help 看用法)`)
454
+ }
455
+
456
+ main(process.argv.slice(2))
@@ -0,0 +1,6 @@
1
+ # dsh-maoying bundle patch — `dsh plugin --profile <name> add dsh-maoying` 时由
2
+ # reconcilePlugins 并入该 profile 的 dsh.profile.bundles,boot 时并入补丁树。
3
+ # name: dsh-maoying 指向 pnpm 装进 profile 的本包(main: index.mjs)。
4
+ - insert:
5
+ - id: maoying-installer
6
+ name: dsh-maoying