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.
package/README.en.md ADDED
@@ -0,0 +1,169 @@
1
+ # dsh-maoying (Maoying)
2
+
3
+ [中文](README.md) | English
4
+
5
+ **Maoying** literally means "the brush" — in *Maoying Zhuan* (毛颖传, "The Biography of the Brush"), Han Yu wrote a writing brush as a courtier with a fief and a title. dsh-maoying fits that brush into DSH (DeepSeek Harness): **a brush that manages a crew of helpers and guards a stack of manuscripts** — a web-novel writing assistant plugin, fully original, released under MIT.
6
+
7
+ Once installed, the main agent becomes the **editor-in-chief of your novel**: it runs a seven-phase workflow with a six-step writing method, delegating outlining, drafting, review, lore-keeping and research to five helpers who each carry only their own tools. The whole novel is a structured markdown/YAML `novel/` directory in the workspace (character cards, foreshadowing state machine, timeline, clue board, narrative memory) — agents read and write files directly, **no database, no API layer**. "Show all unresolved foreshadowing" is one line of grep.
8
+
9
+ ## Features
10
+
11
+ - **Editor-in-chief + five helpers**: the main agent plans, connects, reviews and advances states; the outliner (outline), writer (write), reviewer (review), lore keeper (lore) and researcher (research) each carry their own tools. Trivial errands like finding a file or checking a foreshadow status, the chief handles itself with glob/grep.
12
+ - **novel/ as a file database**: character cards, worldbuilding & science settings with three-color tags, relations graph, foreshadow ledger, timeline, narrative memory, clue board — all grep-friendly flat-key/top-level-array YAML, human-editable, git-versioned, no migrations, no ops.
13
+ - **Seven-phase workflow**: idea → setting → outline → writing → review → consistency → export. The phase lives in the `phase` key of project.yml; the chief advances it at each milestone and reports to you.
14
+ - **Writing six-step method**: continuity check → outline → draft → review → revision → finalization, looping per chapter; paired with the Collab four-step co-writing flow (setting co-creation → volume planning → golden three chapters → handoff loop) it takes a new book from zero to rolling updates.
15
+ - **Foreshadowing state machine**: planted → progressing → resolved / abandoned, with planting and payoff chapter numbers, expected-payoff scheduling and priority tiers — a long serial never drops a thread; past 15 active foreshadowing entries the chief proactively suggests cleanup.
16
+ - **Context on demand**: the chief reads index.yml for the big picture, then pulls only what the chapter at hand needs (previous chapter's ending ≈1500 chars + its outline + on-stage character cards + active foreshadow excerpts) — never the whole book.
17
+ - **Narrow tool surface**: the chief sees only "role delegation + internal retrieval + coordination"; helpers carry their line's tools (reviewer read-only, researcher web+notes only) — less per-request tool overhead and context weight.
18
+ - **Research**: the research helper searches the web for historical/geographic/domain material, files notes under `novel/research/`, separating fact from common lore from legend, with sources — a dividend of the DSH architecture.
19
+ - **Creator panel**: a full-page web panel from the DSH sidebar — Overview / Chapters / Characters / Foreshadowing / Relations / Timeline / Worldbuilding / Index Board / Novel Settings; full-page chapter reading (outline side-by-side + five-dimension fields + status flow), form-based project.yml editing, UI language (中文/English), six font-size steps, and automatic new-version detection.
20
+ - **CLI fallback**: `maoying init / stats / index / export / brief / check` — six zero-dependency commands for scaffolding, stats, index rebuild, export, per-chapter writing cards and static checks.
21
+
22
+ ## Quick Start
23
+
24
+ Prerequisites: **Node.js ≥ 18** and an installed DSH host (**0.1.7+**, see "Compatibility").
25
+
26
+ The official plugin channel — one command, then restart:
27
+
28
+ ```bash
29
+ dsh plugin --profile web add dsh-maoying
30
+ ```
31
+
32
+ **Restart DSH (`dsh web`)**: at startup the plugin installs the preset into `~/.dsh/.agent-presets/maoying/` and writes the managed declaration block into `~/.dsh/cordis.patch.yml` — no buttons to press. Pick "**毛颖创作 (Maoying)**" in the preset selector of a new session; to make it default, select it in Settings → Agent Presets.
33
+
34
+ For a development checkout (you want to modify the plugin or preset sources), link the repo directly; changes take effect after a restart:
35
+
36
+ ```bash
37
+ git clone https://github.com/lifangjin/dsh-maoying.git
38
+ dsh plugin --profile web add link:"$PWD/dsh-maoying"
39
+ ```
40
+
41
+ Only want the command-line tool, without the plugin? The zero-dependency CLI works standalone via `npx`, or as a global command (both npm channels, independent of the plugin):
42
+
43
+ ```bash
44
+ npx maoying init # run without installing
45
+ npm install -g dsh-maoying # or install a global maoying command
46
+ ```
47
+
48
+ ## How to Use
49
+
50
+ With the preset selected, just talk to the chief; it routes by the workflow itself:
51
+
52
+ **Start a new book (Collab four-step co-writing)**:
53
+
54
+ > I want to write an Eastern fantasy where the protagonist's golden finger is hearing objects speak and appraising treasures to level up.
55
+
56
+ The chief aligns genre and tone with you, delegates core character cards and worldbuilding to the lore keeper, volume planning to the outliner, then has the golden three chapters (world hook, protagonist motivation, core conflict) written and delivered.
57
+
58
+ **Per-chapter writing (Writing six-step method)**:
59
+
60
+ > Can we start chapter 3?
61
+
62
+ The chief runs the continuity check, has the outliner produce the chapter outline (with the five narrative dimensions), and after your sign-off sends it to the writer, then the reviewer (checklist + four-dimension consistency scan); issues go back to the writer; once accepted, the lore keeper does finalization upkeep — you only call the shots at decision points.
63
+
64
+ **Consistency pass**:
65
+
66
+ > Run a consistency check over the first 20 chapters.
67
+
68
+ The chief advances the phase to consistency; the reviewer scans four dimensions — conflicts / timeline / foreshadowing / settings — and returns issues ranked by severity.
69
+
70
+ A reply of "ok / sure / go on" counts as confirmation; the chief advances directly without echoing it back.
71
+
72
+ ## Creator Panel
73
+
74
+ Open "**毛颖创作台**" at the bottom of the DSH sidebar — a full-page cockpit for the human eye, beyond what agents do:
75
+
76
+ - **Chapters**: status filters + the writing queue; click a chapter for full-page reading — back button, outline side-by-side (Markdown rendered), read-only manuscript, five narrative-dimension fields, one-click status flow.
77
+ - **Novel Settings**: form-based editing of project.yml — title / author / genre / planned length / language / workflow phase / co-writing mode / synopsis; saving preserves the file's existing comments.
78
+ - **Top bar**: current version always visible; a "new version ↗" badge links to the release page when npm has a newer one; the gear opens Settings — UI language (中文/English), six font-size steps (100%–150%), all applied immediately and remembered.
79
+
80
+ The panel only writes state (chapter status, project.yml, index.yml rebuilds), never prose — prose belongs to the author and the chief.
81
+
82
+ ## Co-writing Mode (Author Writes)
83
+
84
+ Not everyone wants AI ghostwriting. The `mode` key in project.yml decides who writes the prose — three modes in one line each: `ai-draft` (default) AI drafts via the Writing six-step method; `co-write` assigns a writer per chapter, and the chief asks who writes before touching the pen; `author-draft` the author writes the whole book, and Maoying only preps, reviews and keeps the books — never ghostwrites.
85
+
86
+ The author-draft loop — you just write, hand it over, and the chores are taken care of:
87
+
88
+ > Chapter 5 is done, here it is. (paste the text, or just say chapter X is done and point at a file)
89
+
90
+ After receiving a manuscript you get three things: a **review checklist** (issues only, no rewriting — applying them is up to you), a **ledger backfill** (foreshadowing / timeline / relations / memory / clue board recorded from what your text actually does, even off-plan changes, tagged `backfill`), and the **next chapter outline** (`novel/outlines/vXX-cXXX.md`, ready to write from).
91
+
92
+ Companion commands: `maoying brief <dir> <chapter>` packs a per-chapter writing card (outline + previous ending + character cards + active foreshadowing — carry it to any editor or AI), and `maoying check <dir>` runs a zero-model static check (frontmatter completeness, foreshadow chapter references, timeline ordering — no model calls; LLM review goes through the chief).
93
+
94
+ ## Methodology Skills
95
+
96
+ The plugin ships 4 web-novel methodology skills. At startup the installer drops four `maoying-*` directories under `~/.dsh/skills/` (i.e. `<home>/skills/`) where the host's skill panel lists them; the outliner, writer, reviewer and lore keeper load them by scenario — no manual steps:
97
+
98
+ | Skill | One-liner |
99
+ |---|---|
100
+ | maoying-golden-opening | Golden three chapters: role split of the first three chapters (world hook → motivation → conflict detonation), the "something happens in the first 300 words" rule, information-release discipline and a self-check list |
101
+ | maoying-beat-pacing | Payoff pacing: a payoff-type library, suppression-release ratios, chapter/arc/book pacing curves and chapter-end hook selection |
102
+ | maoying-review-checklist | Review checklist: six layers (language / pacing / characterization / logic / foreshadowing / spoilers) with P0/P1/P2 severity |
103
+ | maoying-foreshadow-discipline | Foreshadow management: register on planting, pacing & scheduling, the "surprising yet reasonable" payoff bar, and ledger cleanup thresholds |
104
+
105
+ The installer only touches `maoying-*` prefixed directories; your own skills under `~/.dsh/skills/` are untouched, byte for byte. After uninstalling the plugin, remove the skill directories manually (all four together):
106
+
107
+ ```bash
108
+ dsh plugin --profile web remove dsh-maoying
109
+ rm -rf "${DSH_HOME:-$HOME/.dsh}/skills/"maoying-*
110
+ ```
111
+
112
+ ## The novel/ Data Spec
113
+
114
+ The `novel/` directory is the plugin's entire database: project.yml (metadata + seven-phase state), volumes.yml, chapters/ (frontmatter five narrative dimensions + prose), characters/ (character cards), world/, science/ (three-color settings), relations.yml, foreshadows.yml (the state machine), timeline.yml, memories.yml (with appearance records and the promise ledger), clue-board.yml, research/, exports/. Full formats, examples and design rationale:
115
+
116
+ - Quick reference: `novel/README.md` after project init (template source: [templates/novel/README.md](templates/novel/README.md))
117
+ - Full spec: [docs/spec.md](docs/spec.md) (why not SQLite, five-dimension field definitions, foreshadow state-machine semantics, appearance-record conventions)
118
+
119
+ ## CLI
120
+
121
+ ```bash
122
+ maoying init [dir] # scaffold novel/ (default ./novel; existing files are always skipped, never overwritten)
123
+ maoying stats [dir] # per-chapter CJK counts, status breakdown, current phase
124
+ maoying index [dir] # rebuild index.yml from volumes.yml + chapters/ (derived file)
125
+ maoying export [dir] # export the book: --format md|txt|epub (default md), --out <dir> to change destination
126
+ maoying brief [dir] <chapter> # pack a per-chapter writing card (outline + previous ending + character cards + active foreshadowing); --out to write to disk, stdout by default
127
+ maoying check [dir] # static check (no model calls): frontmatter / enums / chapter numbers / foreshadow refs / timeline years / relation-card refs
128
+ ```
129
+
130
+ Export examples:
131
+
132
+ ```bash
133
+ maoying export novel # Markdown, into novel/exports/
134
+ maoying export novel --format txt # plain text
135
+ maoying export novel --format epub # EPUB 3 (zero-dependency packaging, importable into readers)
136
+ maoying export novel --format epub --out ~/Downloads # different destination
137
+ ```
138
+
139
+ The output directory defaults to `<dir>/exports/`, file name `<title>-<YYYYMMDD>.<ext>` (title from project.yml); chapter parsing shares the same logic as stats/index, so frontmatter never leaks into prose.
140
+
141
+ Zero dependencies (node:fs/path/url/crypto), runs from any cwd. Install one of three ways: `npm install -g dsh-maoying`, on-demand `npx maoying <command>`, or `npm link` on a dev machine (repo checkout, edits take effect immediately). With none of these, `node <plugin dir>/bin/maoying.mjs` is equivalent (a dsh plugin install also drops the package into the profile, so you can point there directly). In day-to-day writing the lore keeper rebuilds index.yml directly; the CLI is mainly for humans.
142
+
143
+ ## Uninstall
144
+
145
+ ```bash
146
+ dsh plugin --profile web remove dsh-maoying
147
+ rm -rf "${DSH_HOME:-$HOME/.dsh}/.agent-presets/maoying"
148
+ rm -rf "${DSH_HOME:-$HOME/.dsh}/skills/"maoying-*
149
+ ```
150
+
151
+ On DSH 0.1.7+ also delete the dsh-maoying managed block in `~/.dsh/cordis.patch.yml` (the whole section between `# --- dsh-maoying preset managed (auto-generated; do not edit) ---` and `# --- end dsh-maoying preset managed ---`, markers included), so no declaration lines point at a deleted directory. The `novel/` directory is your manuscript — uninstalling never touches it.
152
+
153
+ ## Compatibility
154
+
155
+ At startup the plugin reads the host version (`<home>/node_modules/@deepseek-ai/dsh/package.json`) and picks the delegation-line shape automatically — **after a host upgrade, a restart is all it takes; no reinstall**:
156
+
157
+ - **DSH 0.1.7 – 0.2.1-rc.1 → v1 inline shape**: the 5 role delegation lines inline persona and toolFilter; the managed block is 1 declaration line.
158
+ - **DSH 0.2.1-rc.2+ → v2 AgentPreset shape**: from that version dsh-tool-subagent rejects inline persona/toolFilter, so the plugin switches to [presets/maoying/agent.cordis.v2.yml](presets/maoying/agent.cordis.v2.yml) — the managed block writes 6 declaration lines (chief-in-chief preset + 5 role presets: outliner / writer / reviewer / lore keeper / researcher); the chief's delegation line points at the role presets via `agentPreset`, and each role's tool surface is carried by the preset's entity tool lines (reviewer read-only, researcher with web search).
159
+ - **Role presets appear in the preset selector** (the host has no hiding mechanism for presets); their names carry "(内部)" to mark them: in daily writing they are picked automatically via chief delegation, but you can also select a writer/researcher directly for a session without the chief.
160
+ - Both shapes share persona text byte-for-byte from one source ([test/composition.test.mjs](test/composition.test.mjs) guards it); if the host version can't be read, the plugin falls back to v1 conservatively.
161
+ - The ≤0.1.6 directory-scan track: not supported (the host dropped it).
162
+
163
+ ## License
164
+
165
+ [MIT](LICENSE) © 2026 lifangjin
166
+
167
+ ## Acknowledgments
168
+
169
+ - "Maoying" comes from Han Yu's *Maoying Zhuan* (毛颖传): a writing brush personified, with a fief and a title.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-maoying(毛颖)
2
2
 
3
+ 中文 | [English](README.en.md)
4
+
3
5
  毛颖者,笔也——韩愈作《毛颖传》,把一支毛笔写成有封地有爵位的臣子。dsh-maoying 把这支笔装进 DSH(DeepSeek Harness):**一支管着一班帮手、守着一摞书稿的毛笔**——网文创作辅助插件,全部实现原创,MIT 发布。
4
6
 
5
7
  装上之后,主 agent 变身**网文主编(创作总监)**:带七阶段工作流与六步写作法,把大纲、正文、审校、设定维护、素材考据分别委派给五个只带本行工具的帮手;整部小说就是工作区里的 `novel/` 结构化 markdown/YAML 目录(角色卡、伏笔状态机、时间线、线索板、叙事记忆)——agent 直接读写文件,**没有数据库,没有 API 层**。查「所有未回收伏笔」就是 grep 一行。
@@ -14,7 +16,8 @@
14
16
  - **上下文按需取**:主编先读 index.yml 概览,写到哪章读哪章(前章结尾约 1500 字 + 该章大纲 + 出场角色卡 + 活跃伏笔节选),不把整本书塞进上下文。
15
17
  - **工具面收窄**:主编只见「角色委派 + 内部检索 + 协调」白名单,帮手各带本行工具(审校只读、考据只带联网+落笔记);每次请求的工具开销与上下文占用随之下降。
16
18
  - **素材考据**:research 帮手联网检索历史/地理/专业领域素材,笔记落 `novel/research/`,区分史实/通说/演义并附来源——DSH 架构的红利。
17
- - **CLI 兜底**:`maoying init / stats / index` 三条零依赖命令管脚手架、字数统计与章节概览重建。
19
+ - **创作台面板**:DSH 侧栏一键打开的全页可视化面板——概览/章节/角色/伏笔/关系图/时间线/设定/索引板/小说设置九个 tab;章节整页阅读(章纲对照 + 五维 + 状态流转)、project.yml 表单化编辑、顶栏字号五档缩放与自动新版检测。
20
+ - **CLI 兜底**:`maoying init / stats / index / export / brief / check` 六条零依赖命令管脚手架、统计、索引重建、导出、写作卡打包与静态体检。
18
21
 
19
22
  ## 快速开始
20
23
 
@@ -66,6 +69,16 @@ npm install -g dsh-maoying # 或装成全局 maoying 命令
66
69
 
67
70
  用户回「ok / 可以 / 继续」即视为确认,主编直接推进,不复述确认。
68
71
 
72
+ ## 创作台面板
73
+
74
+ DSH 左侧栏底部点「**毛颖创作台**」打开全页面板(改文件、跑流程之外,给肉眼看的驾驶舱):
75
+
76
+ - **章节**:状态筛选 + 按单写作清单;点章节进整页阅读——返回按钮、章纲对照(Markdown 渲染)、只读正文、五维叙事字段、一键状态流转。
77
+ - **小说设置**:project.yml 的书名/作者/题材/篇幅/语言/工作流阶段/共创模式/简介表单化编辑,保存保留文件里原有注释。
78
+ - **顶栏**:当前版本号常驻;npm 上有新版时亮「有新版 ↗」徽章直连发布页;齿轮打开系统设置,界面语言中文/English 可切、字号六档(100%–150%)整体缩放,均立即生效并记忆。
79
+
80
+ 面板只写状态(章节状态、project.yml、index.yml 重建),不改正文——正文永远归作者与主编。
81
+
69
82
  ## 共创模式(作者亲笔)
70
83
 
71
84
  不是谁都想让 AI 代笔。project.yml 的 `mode` 键决定正文归谁写,三种模式一句话:`ai-draft`(默认)AI 代笔,走 Writing 六步法;`co-write` 逐章指派谁写,主编动笔前先问清本章归谁;`author-draft` 全书作者亲笔,毛颖只备料、审稿、记账,永不代写。
@@ -110,6 +123,8 @@ maoying init [dir] # 生成 novel/ 脚手架(默认 ./novel;已存在文
110
123
  maoying stats [dir] # 逐章 CJK 字数、状态分布、当前 phase
111
124
  maoying index [dir] # 从 volumes.yml + chapters/ 重建 index.yml(派生文件)
112
125
  maoying export [dir] # 导出全书:--format md|txt|epub(默认 md),--out <目录> 改输出位置
126
+ maoying brief [dir] <chapter> # 组装单章写作卡(章纲+前章结尾+角色卡+活跃伏笔);--out 落盘,缺省 stdout
127
+ maoying check [dir] # 静态体检(不调模型):frontmatter/枚举/章号/伏笔引用/时间线年份/关系卡引用
113
128
  ```
114
129
 
115
130
  export 示例:
@@ -137,7 +152,7 @@ rm -rf "${DSH_HOME:-$HOME/.dsh}/skills/"maoying-*
137
152
 
138
153
  ## 兼容面
139
154
 
140
- 插件启动时读宿主版本(`<home>/node_modules/@deepseek-ai/dsh/package.json`),按版本自动选择委派行形状——**宿主升级后重启即切换,无需重装插件**:
155
+ 插件启动时读宿主版本,按版本自动选择委派行形状——**宿主升级后重启即切换,无需重装插件**。版本探测是五级探针(argv[1] → 全 argv 扫描 → app.asar / desktop-runtime → Electron API → home 全局兜底),前四级锚定当前进程的真实宿主,`<home>/node_modules/@deepseek-ai/dsh/package.json` 那份 home 全局安装只做最后兜底(老 CLI 残留可能陈旧几个大版本);每级读到了什么可查创作台面板的 `host-debug` 端点(路径已脱敏):
141
156
 
142
157
  - **DSH 0.1.7 – 0.2.1-rc.1 → v1 内联形状**:5 个角色委派行直接内联 persona 与 toolFilter,托管块 1 条声明行。
143
158
  - **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 里的实体工具行承载(审校只读、考据带联网检索)。
package/bin/maoying.mjs CHANGED
@@ -17,15 +17,25 @@
17
17
  * 子命令出错 exit 1,中文报错。
18
18
  */
19
19
 
20
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
20
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
21
21
  import { randomUUID } from 'node:crypto'
22
22
  import path from 'node:path'
23
23
  import { fileURLToPath } from 'node:url'
24
24
 
25
25
  import { buildEpub } from '../lib/epub.mjs'
26
26
  // 解析层与面板共用(lib/novel-parse.mjs);parseFlatYamlArray 沿用旧名
27
- // parseSimpleYaml,保持本文件其余代码原样不动。
28
- import { buildBrief, buildIndexYaml, checkProject, collectChapters, parseFlatYamlArray as parseSimpleYaml } from '../lib/novel-parse.mjs'
27
+ // parseSimpleYaml,保持本文件其余代码原样不动。STATUSES / splitParagraphs
28
+ // 也改从 lib 取——状态机与分段逻辑只留 lib 一份,bin 不再各养一副拷贝。
29
+ import {
30
+ buildBrief,
31
+ buildIndexYaml,
32
+ CHAPTER_STATUSES as STATUSES, // 章节状态机(原 bin 本地 STATUSES,展示用中文标签随 lib 走)
33
+ checkProject,
34
+ collectChapters,
35
+ parseFlatYamlArray as parseSimpleYaml,
36
+ readTextOrNull, // 读文本文件读不到返回 null(原 bin 本地拷贝,lib 导出后改 import)
37
+ splitParagraphs, // 正文分段(原 bin 本地 bodyParagraphs 的同款实现)
38
+ } from '../lib/novel-parse.mjs'
29
39
 
30
40
  const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
31
41
  const TEMPLATE_DIR = path.join(PLUGIN_ROOT, 'templates', 'novel')
@@ -41,14 +51,6 @@ const PHASES = {
41
51
  export: '导出',
42
52
  }
43
53
 
44
- /** 章节状态机(frontmatter status 枚举;带中文标签,展示用)。 */
45
- const STATUSES = [
46
- ['pending', '待写'],
47
- ['writing', '写作中'],
48
- ['review', '审阅中'],
49
- ['accepted', '已定稿'],
50
- ]
51
-
52
54
  // ── 基础工具 ────────────────────────────────────────────────────────────────
53
55
 
54
56
  /** 中文报错 + exit 1。 */
@@ -57,12 +59,19 @@ function fail(message) {
57
59
  process.exit(1)
58
60
  }
59
61
 
60
- /** 读文本文件;读不到返回 null。 */
61
- function readTextOrNull(file) {
62
- try {
63
- return readFileSync(file, 'utf8')
64
- } catch {
65
- return null
62
+ /**
63
+ * 同名输出不静默覆盖:目标文件已存在时在扩展名前加序号 -2、-3……直到找到
64
+ * 未占用的名字。返回 { path, renamed };renamed 为 true 时调用方向 stderr
65
+ * 提示一句改写去向——绝不覆盖用户已有文件。
66
+ */
67
+ function dedupeWritePath(target) {
68
+ if (!existsSync(target)) return { path: target, renamed: false }
69
+ const dir = path.dirname(target)
70
+ const ext = path.extname(target)
71
+ const stem = path.basename(target, ext)
72
+ for (let n = 2; ; n++) {
73
+ const candidate = path.join(dir, `${stem}-${n}${ext}`)
74
+ if (!existsSync(candidate)) return { path: candidate, renamed: true }
66
75
  }
67
76
  }
68
77
 
@@ -94,7 +103,14 @@ function copyTemplateTree(srcDir, destDir, relBase = '') {
94
103
  function cmdInit(targetArg) {
95
104
  if (!existsSync(TEMPLATE_DIR)) fail(`模板目录缺失:${TEMPLATE_DIR}(插件包不完整?)`)
96
105
  const dest = path.resolve(targetArg ?? 'novel')
97
- const { created, skipped } = copyTemplateTree(TEMPLATE_DIR, dest)
106
+ // 落盘点兜底:目录建不出来 / 模板拷不动(权限、只读盘等)报中文错,不甩英文栈
107
+ let counts
108
+ try {
109
+ counts = copyTemplateTree(TEMPLATE_DIR, dest)
110
+ } catch (error) {
111
+ fail(`写入失败:${dest}(${error.message})`)
112
+ }
113
+ const { created, skipped } = counts
98
114
  console.log(`毛颖脚手架 → ${dest}`)
99
115
  for (const rel of created) console.log(` 创建 ${rel}`)
100
116
  for (const rel of skipped) console.log(` 跳过 ${rel}(已存在,未覆盖)`)
@@ -152,7 +168,12 @@ function cmdIndex(targetArg) {
152
168
  const volumesRaw = parseSimpleYaml(readTextOrNull(path.join(root, 'volumes.yml')))
153
169
  const { text, volumesCount, chaptersCount, orphansCount, totalWords } = buildIndexYaml(chapters, volumesRaw)
154
170
  const indexPath = path.join(root, 'index.yml')
155
- writeFileSync(indexPath, text, 'utf8')
171
+ // 落盘点兜底:写不进(权限/只读盘等)报中文错,不甩英文栈
172
+ try {
173
+ writeFileSync(indexPath, text, 'utf8')
174
+ } catch (error) {
175
+ fail(`写入失败:${indexPath}(${error.message})`)
176
+ }
156
177
  console.log(`已重建 ${indexPath}:${volumesCount} 卷、${chaptersCount} 章(总字数 ${totalWords})、未归卷 ${orphansCount} 章。`)
157
178
  }
158
179
 
@@ -190,26 +211,6 @@ function volumeNumber(volume) {
190
211
  return m === null ? null : Number(m[1])
191
212
  }
192
213
 
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
  /**
214
215
  * 章节展示信息(md/txt/epub 三格式共用):卷层、章标题、分段正文。
215
216
  * 章标题形如「卷X-章Y 章节标题」;卷 id 不合规范或章节无卷信息时退为
@@ -223,7 +224,7 @@ function chapterDisplay(ch, ordinal, volTitles) {
223
224
  volume: volNum !== null ? { num: volNum, title: volTitles.get(ch.volume) ?? '' } : null,
224
225
  heading: `${volPart} ${ch.title}`.trim(),
225
226
  body: ch.body,
226
- paragraphs: bodyParagraphs(ch.body),
227
+ paragraphs: splitParagraphs(ch.body),
227
228
  }
228
229
  }
229
230
 
@@ -296,6 +297,8 @@ function cmdExport(args) {
296
297
  }
297
298
  }
298
299
  if (!EXPORT_FORMATS.includes(format)) fail(`未知导出格式「${format}」——支持 ${EXPORT_FORMATS.join(' / ')}`)
300
+ // 显式拒绝空串:`--out=` 经 path.resolve 会静默落成 cwd,属意外行为
301
+ if (outArg !== null && outArg.trim() === '') fail('选项 --out 不能是空串——--out 取导出目录路径')
299
302
 
300
303
  const root = path.resolve(dirArg ?? 'novel')
301
304
  const chapters = collectChapters(root)
@@ -316,6 +319,9 @@ function cmdExport(args) {
316
319
  const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
317
320
  const outDir = path.resolve(outArg ?? path.join(root, 'exports'))
318
321
  const outPath = path.join(outDir, `${sanitizeTitleForFile(rawTitle)}-${stamp}.${format}`)
322
+ // 同名输出不静默覆盖:目标已存在时自动加序号(书名-20250101-2.epub)并提示
323
+ const target = dedupeWritePath(outPath)
324
+ if (target.renamed) console.error(`提示:${outPath} 已存在,本次导出改写到 ${target.path}`)
319
325
 
320
326
  let content
321
327
  if (format === 'md') {
@@ -331,10 +337,15 @@ function cmdExport(args) {
331
337
  })
332
338
  }
333
339
 
334
- mkdirSync(outDir, { recursive: true })
335
- writeFileSync(outPath, content)
340
+ // 落盘点兜底:目录建不出 / 文件写不进(权限、只读盘等)报中文错,不甩英文栈
341
+ try {
342
+ mkdirSync(outDir, { recursive: true })
343
+ writeFileSync(target.path, content)
344
+ } catch (error) {
345
+ fail(`写入失败:${target.path}(${error.message})`)
346
+ }
336
347
  const totalWords = chapters.reduce((sum, ch) => sum + ch.words, 0)
337
- console.log(`导出完成:${path.resolve(outPath)}`)
348
+ console.log(`导出完成:${path.resolve(target.path)}`)
338
349
  console.log(`格式 ${format};${chapters.length} 章,总字数 ${totalWords}(CJK 计数)。`)
339
350
  }
340
351
 
@@ -373,6 +384,8 @@ function cmdBrief(args) {
373
384
  if (dirArg === null && chapterArg === null) {
374
385
  fail('用法:maoying brief [dir] <chapter> [--out <文件>]——chapter 可写文件名(v01-c002.md)、stem(v01-c002)、「卷-章」(1-2)或裸章号(2)')
375
386
  }
387
+ // 显式拒绝空串:`--out=` 经 path.resolve 会静默落成 cwd,属意外行为
388
+ if (outArg !== null && outArg.trim() === '') fail('选项 --out 不能是空串——--out 取输出文件路径')
376
389
  // 只给一个位置参数时把它当章号用(dir 走默认 ./novel),这是常用路径
377
390
  const root = path.resolve(chapterArg === null ? 'novel' : dirArg)
378
391
  const brief = buildBrief(root, chapterArg ?? dirArg)
@@ -380,9 +393,17 @@ function cmdBrief(args) {
380
393
  for (const warning of brief.warnings) console.error(`提醒:${warning}`)
381
394
  if (outArg !== null) {
382
395
  const outPath = path.resolve(outArg)
383
- mkdirSync(path.dirname(outPath), { recursive: true })
384
- writeFileSync(outPath, brief.text, 'utf8')
385
- console.log(`写作卡已写入:${outPath}`)
396
+ // 同名输出不静默覆盖:目标已存在时自动加序号 -2、-3……改写到新路径
397
+ const target = dedupeWritePath(outPath)
398
+ if (target.renamed) console.error(`提示:${outPath} 已存在,本次写作卡改写到 ${target.path}`)
399
+ // 落盘点兜底:目录建不出 / 文件写不进(权限、只读盘等)报中文错,不甩英文栈
400
+ try {
401
+ mkdirSync(path.dirname(target.path), { recursive: true })
402
+ writeFileSync(target.path, brief.text, 'utf8')
403
+ } catch (error) {
404
+ fail(`写入失败:${target.path}(${error.message})`)
405
+ }
406
+ console.log(`写作卡已写入:${target.path}`)
386
407
  console.log('拿去写,写完丢回给主编收稿。')
387
408
  } else {
388
409
  process.stdout.write(brief.text)
@@ -434,18 +455,42 @@ const HELP_TEXT = `毛颖(maoying)—— 网文创作项目 CLI
434
455
  check 有 error 退出码 1,只有 warn 退出码 0。
435
456
  所有命令零依赖,任意 cwd 可用;出错时退出码 1。`
436
457
 
458
+ /** 只接受一个可选目录参数的子命令(目录参数不以 `-` 开头;--help/-h 由 main 先拦)。 */
459
+ const SINGLE_DIR_COMMANDS = ['init', 'stats', 'index', 'check']
460
+
461
+ /**
462
+ * 单参命令的目录参数防线:至多一个位置参数,且不以 `-` 开头——以前
463
+ * `maoying init --help` 把 --help 当目录名,静默建出 ./--help/。--help/-h 由
464
+ * main 在派发前拦截显示帮助;其余 `-` 开头参数报未知选项,多余位置参数报错
465
+ * (对照 export 的同款防线)。
466
+ */
467
+ function singleDirTarget(command, rest) {
468
+ let target = null
469
+ for (const arg of rest) {
470
+ if (arg.startsWith('-')) fail(`未知选项「${arg}」——${command} 只接受一个可选目录参数`)
471
+ if (target !== null) fail(`多余的位置参数「${arg}」——${command} 只接受一个目录参数`)
472
+ target = arg
473
+ }
474
+ return target
475
+ }
476
+
437
477
  function main(argv) {
438
478
  const [command, ...rest] = argv
439
479
  if (command === '--help' || command === '-h' || command === 'help') {
440
480
  console.log(HELP_TEXT)
441
481
  return
442
482
  }
443
- if (command === 'init') return cmdInit(rest[0])
444
- if (command === 'stats') return cmdStats(rest[0])
445
- if (command === 'index') return cmdIndex(rest[0])
483
+ // 单参子命令自身也认 --help/-h(防 init --help 被当目录名),显示帮助即止
484
+ if (SINGLE_DIR_COMMANDS.includes(command) && rest.some((a) => a === '--help' || a === '-h')) {
485
+ console.log(HELP_TEXT)
486
+ return
487
+ }
488
+ if (command === 'init') return cmdInit(singleDirTarget('init', rest))
489
+ if (command === 'stats') return cmdStats(singleDirTarget('stats', rest))
490
+ if (command === 'index') return cmdIndex(singleDirTarget('index', rest))
446
491
  if (command === 'export') return cmdExport(rest)
447
492
  if (command === 'brief') return cmdBrief(rest)
448
- if (command === 'check') return cmdCheck(rest[0])
493
+ if (command === 'check') return cmdCheck(singleDirTarget('check', rest))
449
494
  if (command === undefined) {
450
495
  console.error('maoying: 缺少子命令(init / stats / index / export / brief / check,--help 看用法)')
451
496
  process.exit(1)
@@ -37,16 +37,31 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
37
37
 
38
38
  | 路由 | 方法 | 入参 | 出参 |
39
39
  |---|---|---|---|
40
- | `client.css` | GET | — | text/css(lib/base.css 现读) |
40
+ | `client.css` | GET | — | text/css(lib/base.css 现读,no-cache) |
41
+ | `update-check` | GET | — | `{ ok, current, latest, updateAvailable, releaseUrl, checkedAt, error, source }`——新版检测(npm registry → npmmirror → GitHub 三级源,1h 缓存 + 7 天 stale-if-error,全败静默降级为 latest=null / error 非空) |
42
+ | `host-debug` | GET | — | `{ ok, hostVersion, trace, shape, registry, runtime }`——现场诊断(见下节) |
41
43
  | `workspaces` | GET | — | `{ ok, workspaces: [{ path, name, hasNovel }] }`——workspaceRegistry.list() 逐根探测 `<root>/novel/project.yml` |
42
44
  | `state` | GET | `path=<root>` | 全量快照(见 §3 契约) |
43
45
  | `chapter` | GET | `path`, `file`(chapters/ 下文件名) | `{ ok, file, frontmatter, body, outline }`——正文全文 + 对应章纲(`{ file, words, body }`,outlines/ 无对应文件时 null),章节抽屉用 |
44
46
  | `chapter-status` | POST | `{ path, file, status }` | `{ ok }`——frontmatter status 行原地替换;status 必须 ∈ pending/writing/review/accepted |
45
47
  | `foreshadow-status` | POST | `{ path, id, status }` | `{ ok }`——foreshadows.yml 对应条目 status 行替换;∈ planted/progressing/resolved/abandoned |
46
48
  | `phase` | POST | `{ path, phase }` | `{ ok }`——project.yml phase 行替换;七态枚举 |
49
+ | `project-save` | POST | `{ path, values }`(8 平键,title/author/genre/length/language/phase/mode/description) | `{ ok }`——mutateProject 逐键改写保行尾注释、缺键按模板补齐;phase/mode 越枚举与空书名 422 |
47
50
  | `rebuild-index` | POST | `{ path }` | `{ ok, chapters }`——照 CLI index 同一逻辑重建 index.yml |
48
51
 
49
- 写操作全部「行级原地替换」:只在 frontmatter/目标 yml 的对应行上做字符串手术,写前读、写后校验(替换计数 ≠1 即 409 拒写并报错),不做整文件重排——把对 git diff 的扰动压到最小,也与 agent 的写法兼容。
52
+ 共 11 条。写操作全部「行级原地替换」:只在 frontmatter/目标 yml 的对应行上做字符串手术,写前读、写后校验(替换计数 ≠1 即 409 拒写并报错),不做整文件重排——把对 git diff 的扰动压到最小,也与 agent 的写法兼容。
53
+
54
+ ### host-debug 端点(现场诊断)
55
+
56
+ 用途:面板「有新版不显示 / 形态判定可疑」时现场定位宿主版本读到的是哪一份。响应:
57
+
58
+ - `hostVersion`:五级探针(argv[1] → 全 argv 扫描 → app.asar / desktop-runtime → Electron API → home 全局兜底)命中的版本;全落空为 null。
59
+ - `trace`:每级探针一条轨迹 `{ probe, hit, via?, note? }`,能看到每级看到了什么、为什么落空。
60
+ - `shape`:托管块形态判定(v1/v2,读不到宿主版本保守 v1)。
61
+ - `registry`:预设注册表内窥(逐 preset 的 broken 激活诊断,尽力而为)。
62
+ - `runtime`:argv / execPath / cwd / electron / node。
63
+
64
+ 围栏与脱敏:与所有路由同一信任围栏(回环 + 可信 Host);出口统一 sanitizeDeep——trace.via、runtime.argv / execPath / cwd 等本机绝对路径一律换 `~`,诊断信息照给、路径不出门。
50
65
 
51
66
  ## 3. state 快照契约(前后端共同遵守,字段名冻结)
52
67
 
@@ -56,7 +71,7 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
56
71
  "root": "<workspace root>",
57
72
  "novelDir": "<root>/novel",
58
73
  "found": true, // false 时其余字段缺省,面板显示「未初始化」引导
59
- "project": { "title","author","genre","length","language","phase","description" },
74
+ "project": { "title","author","genre","length","language","phase","mode","description" }, // mode: ai-draft|co-write|author-draft(五期)
60
75
  "chapters": [{ "file","volume","num","title","status","words","summary",
61
76
  "cognitive_frame","emotional_anchor","world_texture",
62
77
  "suspense","interpersonal_tension" }], // 按 volume,num 升序;words 为 CJK 计数
@@ -88,12 +103,12 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
88
103
  - 会话中栏全页 overlay:`html[data-dsh-maoying-active]` 驱动,`dsh-panel-activate` 事件与 mnemon/taskboard/ssh 等全页面板互斥,点侧栏会话行自动关闭;
89
104
  - 顶部工作区选择器(workspaces 路由取数,hasNovel 的排前;无 novel 工作区显示引导卡:一句 `maoying init` 提示 + 复制按钮)。
90
105
 
91
- 八个 tab(顺序即默认排布):
106
+ 九个 tab(顺序即默认排布,第 9 个为后续迭代新增):
92
107
 
93
108
  1. **概览**:project 卡(书名大字 + 类型/篇幅/语言 pills + 阶段徽章 + 简介);统计卡(总字数/章节数/状态分布条);最近更新(chapters 按 mtime 前 5);warnings 黄条。
94
- 2. **章节**:表格(卷-章/标题/状态徽章/字数/梗概),状态筛选 pills;表格上方「按单写作」区块——有章纲且 status=pending 的章按序列出(章纲节选 + 状态徽章,共创模式的主工作清单);行点击开抽屉:GET chapter 显示正文(只读,等宽滚动)+ 章纲对照栏(左右分栏或折叠段)+ 复制文件路径按钮 + 五维字段卡 + 状态流转按钮(当前态 → 下一态一键,pending→writing→review→accepted)。
109
+ 2. **章节**:表格(卷-章/标题/状态徽章/字数/梗概),状态筛选 pills;表格上方「按单写作」区块——有章纲且 status=pending 的章按序列出(章纲节选 + 状态徽章,共创模式的主工作清单);行点击开整页阅读视图(0.1.12 起替代原右侧抽屉——fixed 侧弹层会被 DSH 侧边栏遮挡):返回按钮 + GET chapter 正文(只读,等宽滚动)+ 章纲对照栏(左右分栏或折叠段)+ 复制文件路径按钮 + 五维字段卡 + 状态流转按钮(当前态 → 下一态一键,pending→writing→review→accepted)。
95
110
  3. **角色**:卡墙(姓名/role 徽章四色/年龄性别/性格一句/动机一行),点击看全卡(含 arc/markers/background)。
96
- 4. **伏笔**:四列看板(埋设/推进/已回收/弃用),卡片含 priority 色点(high 红/normal 灰/low 淡)、`source: backfill` 的「回填」小徽章(账本回填来的条目,区别于写前规划)、埋设章→预期回收章、notes 悬浮;卡片按钮:推进到下一状态(planted→progressing→resolved;abandoned 只能从看板拖不动、按钮给「弃用」)。列头计数。
111
+ 4. **伏笔**:四列看板(埋设/推进/已回收/弃用),卡片含 priority 色点(high 红/normal 灰/low 淡)、`source: backfill` 的「回填」小徽章(账本回填来的条目,区别于写前规划)、埋设章→预期回收章、notes 全文直显(0.1.10 去掉两行截断);卡片按钮:推进到下一状态(planted→progressing→resolved;abandoned 只能从看板拖不动、按钮给「弃用」)。列头计数。
97
112
  5. **关系图**:canvas 力导向图——节点=角色(radius 随出场无关、固定 18px,名字 label 下方),边=relations(intensity 1-5 → 线宽 1-4px,type 标在边中点 hover 显示 description);拖拽节点、空白处滚轮缩放、双击复位;intensity 缺省 3。角色多于 30 个时只画与 relations 相连的节点,其余进「未入图」列表。
98
113
  6. **时间线**:垂直轴,importance 1-5 → 点大小/颜色深浅,year 左列右卡片。
99
114
  7. **设定**:world 按 category 分组列表 + science 三色标签(known 绿/extrapolation 黄/hypothesis 紫,标签下 references 链接列表)。
@@ -101,8 +116,19 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
101
116
 
102
117
  状态徽章色(全局统一):pending 灰 / writing 蓝 / review 橙 / accepted 绿;phase 七态沿用七色 pill。文案全中文、禁 emoji 图标(用 ui-primitives 图标或纯 CSS)。
103
118
 
119
+ ## 4.1 后续迭代记录(0.1.10 – 0.1.13)
120
+
121
+ - **MaskPortal**:抽屉/弹层统一 portal 到 `document.body`——面板容器挂在宿主会话中栏,中栏 transform/overflow 会把 `position: fixed` 劫持成相对自身并连同裁剪(桌面端「点了没反应」实测病灶);无 createPortal 的老宿主退回原地渲染。
122
+ - **顶栏版本区**:当前版本号常驻;`update-check` 路由(模仿 dsh-paoding:npm registry → npmmirror → GitHub 三级源,1h 缓存 + 7 天 stale-if-error,全败静默)发现新版时亮「有新版 ↗」徽章。
123
+ - **系统设置**:齿轮弹窗,字号六档(100/110/120/130/140/150%)——面板根与 portal 节点读同一 `--myt-zoom` 变量整体 zoom,localStorage 记忆。
124
+ - **界面语言**:系统设置可选中文/English——全部 UI 文案走 `I18N` 词典(zh/en 各 209 条,`tt`/`tf` 取词 + `{x}` 插值,zh 值与历史硬编码逐字一致保证中文界面零回归),切换立即生效整树重渲并 localStorage 记忆;语言自称(中文/日本語)与侧栏入口品牌名不翻译。
125
+ - **Markdown 渲染**:零依赖渲染器(先整体转义再变换),章纲对照与世界观/科学设定描述不再裸显 md 源码;正文手稿仍走 pre。
126
+ - **章节整页阅读**:替代右侧抽屉(侧边栏遮挡问题),返回按钮 + 章纲对照 + 五维 + 状态流转原位保留;阅读态隐藏 tab 条。
127
+ - **小说设置 tab**(第 9 个):project.yml 平键表单化编辑,`POST project-save` → `mutateProject` 逐键改写保行尾注释、缺键按模板补齐、phase/mode 越枚举与空书名 422。
128
+ - **字号六档改百分比**(0.1.13):档位从中文名(小/标准/大…)改为 100/110/120/130/140/150% 直读,旧 localStorage 档位值回落 100%。
129
+
104
130
  ## 5. 验收基线
105
131
 
106
132
  - Node 侧:临时 workspace(/tmp)+ 假 workspaceRegistry 不行——路由从 ctx.workspaceRegistry 取;单测直接 import lib/panel-core.mjs 的函数(parseStateSnapshot / mutateChapterStatus / mutateForeshadowStatus / mutatePhase / rebuildIndex)跑断言;路由层用 node 起一个最小 ctx 假件(webServer.register 收集 handler,workspaceRegistry 返回临时 root)模拟请求验证围栏 403 / 状态流转 / 防穿越。
107
- - 浏览器侧:`node --check` 不适用(browser bundle 用 var/require factory 语法)——语法用 `node --input-type=module -e "await import('...')"` 不可行;改用 esbuild?零依赖约束下用「提取 factory 体 + new Function 包裹」的冒烟脚本验证可解析不执行 DOM。至少:括号配平检查 + 手工浏览器验收由用户重启后做。
133
+ - 浏览器侧:`node --check` 不适用(browser bundle 用 var/require factory 语法)——冒烟脚本 `npm run smoke`(scripts/smoke-client.mjs,零依赖)已落地:new Function 包裹整份源码做编译检查 → stub window/document 求值截获 `__ModuleLoader__.load` → factory 传 react/UI 原语 stub 求值,断言导出面 `{ inject: ["slots"], apply }` 与 slots 注入 / disposer 折返语义。CI(Node 18/20/22 矩阵)与 `npm test` 同跑;真浏览器验收仍由用户重启后做。
108
134
  - 不写死任何真实路径;测试全在 /tmp;真实 ~/.dsh 不碰。