story-setup:网文写作工具集基础设施部署 你负责把网文写作工具集部署到用户项目目录:已适配的 CLI 使用专用 hooks/agents/config;NarraFork、Web AI、自定义 Agent 等使用通用文件模式。 保留用户配置;受管文件按清单更新,共同维护的文件按管理块合并。 作者第一次部署的顺序:判断当前宿主 → 确认项目根 → 按宿主部署 → 验收 → 告诉作者下一步。本文件写公共流程;各宿主的部署清单、算法、校验和报告提示见 Phase 2 对应文件, 只读本次目标宿主的那一份 。 选择模式 - 参数为 ,或用户只要求检查部署、诊断环境、排查 agent 不可用时:完整读取 references/diagnostics.md,按其中流程仅检查并报告;不进入下面的部署流程。 - 用户要求安装、更新或修复时:执行下面的部署流程。检查后已明确授权的修复沿用本文件与宿主部署文件的部署与合并规则。 --- Phase 1:判断宿主与项目 先自检参考目录 :以正在执行的本 所在目录为准,列出与它同级的 下的子目录,核对下面 9 个名字是否都在 且都非空 —— 、 、 、 、 、 、 、 、 ;同级 (8 份宿主部署文件)、 、 、 、 、 、 与 也必须存在(宿主部署步骤、Claude/Codex/Antigravity hooks 合并、Antigravity…

\\n'——dash 下第一项报错、后两项直接是语法错误,\n# 会让此后每次 git commit 都失败(退 1、不产生提交),与本块「advisory only, never blocks」\n# 的契约正好相反。文件自身的 shebang 也用 /bin/sh:create 模式下整文件被拷成 hook,\n# 无 /bin/bash 的宿主(Alpine/busybox、NixOS)上 git 会直接 fatal 而不是跳过。\n# 收尾的 `|| true` 是最后一道保险:宿主开着 set -e 时也绝不会因为本块挡掉提交。\n(\nset -eu\n# LC_ALL=C:模式里的全角「 」「:」是多字节 UTF-8,GBK 区域下 grep 会判无效序列\n# 退 2,! 取反后对每个设定文件误报缺字段。强制 C 区域按字节匹配(同\n# validate-story-commit.sh,issue #164 约定:LC_ALL=C + 交替代替字符组)。\nexport LC_ALL=C\nROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)\nWARNINGS=\"\"\n# POSIX sh 没有

story-setup:网文写作工具集基础设施部署 你负责把网文写作工具集部署到用户项目目录:已适配的 CLI 使用专用 hooks/agents/config;NarraFork、Web AI、自定义 Agent 等使用通用文件模式。 保留用户配置;受管文件按清单更新,共同维护的文件按管理块合并。 作者第一次部署的顺序:判断当前宿主 → 确认项目根 → 按宿主部署 → 验收 → 告诉作者下一步。本文件写公共流程;各宿主的部署清单、算法、校验和报告提示见 Phase 2 对应文件, 只读本次目标宿主的那一份 。 选择模式 - 参数为 ,或用户只要求检查部署、诊断环境、排查 agent 不可用时:完整读取 references/diagnostics.md,按其中流程仅检查并报告;不进入下面的部署流程。 - 用户要求安装、更新或修复时:执行下面的部署流程。检查后已明确授权的修复沿用本文件与宿主部署文件的部署与合并规则。 --- Phase 1:判断宿主与项目 先自检参考目录 :以正在执行的本 所在目录为准,列出与它同级的 下的子目录,核对下面 9 个名字是否都在 且都非空 —— 、 、 、 、 、 、 、 、 ;同级 (8 份宿主部署文件)、 、 、 、 、 、 与 也必须存在(宿主部署步骤、Claude/Codex/Antigravity hooks 合并、Antigravity…

\\n',换行常量只能这么写。\nNL='\n'\n\n# 暂存的 md 列表一次性取回:POSIX sh 无 read -d '',接不了 -z 的 NUL 流,改用换行分隔\n# (只有文件名自带换行才会错行,极罕见且仅影响 advisory 文案)。也不能写成\n# `git … | while`:管道会把 while 放进子 shell,攒好的 WARNINGS 出不来。先存变量,\n# 再用 here-doc 喂 while,循环仍跑在当前 shell 里。\nSTAGED=$(git -c core.quotepath=false diff --cached --relative --name-only --diff-filter=ACM -- . 2>/dev/null || true)\n\nwhile IFS= read -r file; do\n [ -n \"$file\" ] || continue\n case \"$file\" in\n *.md) ;;\n *) continue ;;\n esac\n\n FULL_PATH=\"$ROOT/$file\"\n [ -f \"$FULL_PATH\" ] || continue\n\n # 匹配语义与警告文案对齐 JS core(story_hook_core.js stagedMarkdownWarnings):冒号/空白用\n # 交替而非含全角字符的方括号字符组(C/GBK 区域下字符组会被拆成单字节、漏匹配);name 字段\n # grep -i 大小写不敏感。\n case \"$file\" in\n 正文.md|*/正文.md|正文/*|*/正文/*)\n HARDCODED=$(grep -nE \"(身高|体重|年龄)([[:space:]]| )*(:|:)([[:space:]]| )*[0-9]+\" \"$FULL_PATH\" 2>/dev/null || true)\n if [ -n \"$HARDCODED\" ]; then\n WARNINGS=\"$WARNINGS$NL⚠ $file: 正文硬编码角色属性,应引用设定文件:$NL$HARDCODED\"\n fi\n ;;\n esac\n\n # 只查角色卡:设定/ 下还住着 关系.md、题材定位.md、文风.md、世界观/*、势力/* 这类项目级\n # 设定件,它们本来就没有 名字/姓名 字段,整棵目录一刀切会每次提交刷一屏假警告、把上面的\n # 硬编码真警告埋掉(判定口径同 validate-story-commit.sh)。\n IS_CHARACTER_SHEET=false\n case \"$file\" in\n 设定/角色/*|*/设定/角色/*|设定/人物/*|*/设定/人物/*)\n IS_CHARACTER_SHEET=true\n ;;\n 设定/*/*|*/设定/*/*)\n # 世界观/势力/报告/原理/人物关系 等非角色子目录:整目录跳过\n ;;\n 设定/*|*/设定/*)\n case \"${file##*/}\" in\n 关系.md|题材定位.md|题材正文提示卡.md|文风.md|世界规则.md|世界观.md|金手指.md|背景设定.md) ;;\n *) IS_CHARACTER_SHEET=true ;;\n esac\n ;;\n esac\n if [ \"$IS_CHARACTER_SHEET\" = true ] &&\n ! grep -qiE \"^([[:space:]]| )*(名字|姓名|名称|name)([[:space:]]| )*(:|:)\" \"$FULL_PATH\" 2>/dev/null; then\n WARNINGS=\"$WARNINGS$NL⚠ $file: 设定文件缺少 name/名字 必填字段。\"\n fi\ndone \u003c\u003cSTORY_STAGED_EOF\n$STAGED\nSTORY_STAGED_EOF\n\nif [ -n \"$WARNINGS\" ]; then\n echo \"=== Story Commit Warnings(advisory only)===\"\n # printf '%s' 而非 '%b'/echo -e:$WARNINGS 里嵌着 grep -n 抓出的作者正文原文,\n # 里面的 `\\c`(Windows 路径 C:\\code 就带)会终止输出、把后面的警告全吞掉。\n printf '%s\\n' \"$WARNINGS\"\n echo \"=== End Warnings ===\"\nfi\n\n) || true\n### story-hooks: END ###\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":4440,"content_sha256":"b4a810bf0fb218ccb71d933eea83de8a0b0890611383748d7df29ec32749c9a7"},{"filename":"references/opencode/story_hook_core.js","content":"\"use strict\"\n\nconst fs = require(\"node:fs\")\nconst path = require(\"node:path\")\nconst { spawnSync } = require(\"node:child_process\")\n\nfunction existingDir(value) {\n if (typeof value !== \"string\" || !value.trim()) return null\n try {\n const resolved = fs.realpathSync(path.resolve(value))\n return fs.statSync(resolved).isDirectory() ? resolved : null\n } catch {\n return null\n }\n}\n\nfunction safeRelative(root, target) {\n try {\n const rel = path.relative(path.resolve(root), path.resolve(target))\n return rel && !rel.startsWith(\"..\") ? rel.split(path.sep).join(\"/\") : String(target)\n } catch {\n return String(target)\n }\n}\n\nfunction resolveTarget(root, target, base = root) {\n const normalized = String(target || \"\").replace(/\\\\/g, \"/\")\n return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base || root, normalized)\n}\n\nfunction firstLine(file) {\n try {\n return fs.readFileSync(file, \"utf8\").split(/\\r?\\n/, 1)[0].trim()\n } catch {\n return \"\"\n }\n}\n\nfunction findFirst(base, maxDepth, predicate) {\n // maxDepth 与 `find -maxdepth N` 一致:root 的直属条目深度为 1,深度 N 的条目可见,N+1 不可见。\n if (maxDepth \u003c= 0) return null\n let entries = []\n try {\n entries = fs.readdirSync(base, { withFileTypes: true })\n } catch {\n return null\n }\n for (const entry of entries) {\n if (entry.name.startsWith(\".\") || entry.name === \"node_modules\") continue\n const full = path.join(base, entry.name)\n if (predicate(full, entry)) return full\n }\n if (maxDepth === 1) return null\n for (const entry of entries) {\n if (!entry.isDirectory() || entry.name.startsWith(\".\") || entry.name === \"node_modules\") continue\n const found = findFirst(path.join(base, entry.name), maxDepth - 1, predicate)\n if (found) return found\n }\n return null\n}\n\nfunction discoverActiveBook(root) {\n const declared = firstLine(path.join(root, \".active-book\"))\n if (declared) {\n const candidate = existingDir(resolveTarget(root, declared))\n if (candidate) {\n // root 也要按 realpath 比:existingDir 已把 candidate 解到真实路径,若这里用未解析的\n // root,项目根位于 symlink 下(macOS /tmp、/var,或软链的家目录/工作目录)时 rel 会\n // 假性以 \"..\" 开头,合法的 .active-book 被静默丢弃。bash 用 pwd -P、python 用\n // root.resolve(),此处对齐两端。\n const rel = path.relative(existingDir(root) || path.resolve(root), candidate)\n if (!rel.startsWith(\"..\") && !path.isAbsolute(rel)) return candidate\n }\n }\n const tracking = findFirst(root, 4, (_full, entry) => entry.isDirectory() && entry.name === \"追踪\")\n if (tracking) return path.dirname(tracking)\n const body = findFirst(root, 4, (_full, entry) => entry.isDirectory() && entry.name === \"正文\")\n if (body) return path.dirname(body)\n const bodyFile = findFirst(root, 4, (_full, entry) => entry.isFile() && entry.name === \"正文.md\")\n return bodyFile ? path.dirname(bodyFile) : null\n}\n\nfunction discoverAllBooks(root) {\n const books = new Map()\n function walk(base, depth) {\n if (depth \u003c= 0) return\n let entries = []\n try { entries = fs.readdirSync(base, { withFileTypes: true }) } catch { return }\n for (const entry of entries) {\n if (entry.name.startsWith(\".\") || entry.name === \"node_modules\") continue\n const full = path.join(base, entry.name)\n if (entry.isDirectory() && (entry.name === \"追踪\" || entry.name === \"正文\")) {\n books.set(path.dirname(full), path.dirname(full))\n } else if (entry.isFile() && entry.name === \"正文.md\") {\n books.set(path.dirname(full), path.dirname(full))\n }\n }\n if (depth === 1) return\n for (const entry of entries) {\n if (!entry.isDirectory() || entry.name.startsWith(\".\") || entry.name === \"node_modules\") continue\n walk(path.join(base, entry.name), depth - 1)\n }\n }\n walk(root, 4)\n return [...books.values()]\n}\n\nfunction trackingCheckpointIssue(book, requireState = false, expectedLastCommitted = null) {\n const state = path.join(book, \"追踪\", \"_tracking-state.json\")\n if (!fs.existsSync(state)) {\n return requireState\n ? `追踪/_tracking-state.json 缺失;已有正文项目走 /story-import 的「旧追踪项目迁移」重建追踪(不必重跑全书拆解),新书先用 tracking_commit.py init 初始化`\n : null\n }\n let document\n try {\n document = JSON.parse(fs.readFileSync(state, \"utf8\"))\n } catch {\n return `追踪/_tracking-state.json 无法解析;停止写正文并重新 /story-import,不能猜测或手补状态`\n }\n if (!document || typeof document !== \"object\" || Array.isArray(document) || document.schema_version !== 4) {\n return `追踪/_tracking-state.json 不是当前 schema_version=4;停止写正文并重新 /story-import,不保留旧结构兼容路径`\n }\n if (!Number.isInteger(document.state_revision)) {\n return `追踪/_tracking-state.json 缺少整数 state_revision;停止写正文并重新 /story-import`\n }\n const context = path.join(book, \"追踪\", \"上下文.md\")\n let contextRevision = null\n try {\n const match = fs.readFileSync(context, \"utf8\").match(/状态修订:(\\d+)/)\n if (match) contextRevision = Number(match[1])\n } catch {}\n if (contextRevision !== document.state_revision) {\n const shown = contextRevision === null ? \"缺失\" : contextRevision\n return `追踪/上下文.md 状态修订 ${shown} 与 _tracking-state.json 的 ${document.state_revision} 不一致;重新提交该章的 mode=revision 事务重建派生视图(expected_state_revision 取 追踪/_tracking-state.json 的 state_revision 字段(check 失败时不输出 JSON))`\n }\n if (expectedLastCommitted !== null) {\n if (!Number.isInteger(document.last_committed_chapter)) {\n return `追踪/_tracking-state.json 缺少整数 last_committed_chapter;停止写正文并重新 /story-import`\n }\n // 章号已在追踪范围内 = 回炉/改名/留原稿备份,不是首建新章:文件名新但章节早已提交过,\n // 顺序校验对它恒为假(workflow-revision 的「备份原稿」步骤必然命中),跳过。\n if (expectedLastCommitted \u003c document.last_committed_chapter) return null\n if (document.last_committed_chapter !== expectedLastCommitted) {\n return `追踪已提交到第${document.last_committed_chapter}章,首建第${expectedLastCommitted + 1}章前必须先提交第${expectedLastCommitted}章追踪事务`\n }\n }\n return null\n}\n\nfunction continuityFindings(root) {\n const messages = []\n for (const book of discoverAllBooks(root)) {\n const bodyDir = path.join(book, \"正文\")\n let chapters = []\n try {\n chapters = fs.readdirSync(bodyDir)\n .filter((file) => /^第.*章.*\\.md$/.test(file))\n .map((file) => path.join(bodyDir, file))\n } catch {}\n\n const context = path.join(book, \"追踪\", \"上下文.md\")\n const checkpointIssue = trackingCheckpointIssue(book, chapters.length > 0)\n if (checkpointIssue) {\n messages.push(`[continuity] ${safeRelative(root, book)}:${checkpointIssue}。`)\n }\n if (chapters.length && fs.existsSync(context)) {\n try {\n const newest = Math.max(...chapters.map((file) => fs.statSync(file).mtimeMs))\n const contextTime = fs.statSync(context).mtimeMs\n if (newest > contextTime + 1000) {\n const latest = chapters.reduce((left, right) => fs.statSync(left).mtimeMs > fs.statSync(right).mtimeMs ? left : right)\n messages.push(`[continuity] ${safeRelative(root, book)}:正文已更新到「${path.basename(latest)}」但续写状态卡更早——为该章提交 tracking_commit.py 事务、check 通过后再续写,禁止分别手改 上下文.md/伏笔.md。`)\n }\n } catch {}\n }\n\n // 续写状态卡预算:上下文.md 由事务工具整份重建,硬上限 12288 字节。\n if (fs.existsSync(context)) {\n try {\n const contextSize = fs.statSync(context).size\n if (contextSize > 12288) {\n messages.push(`[continuity] ${safeRelative(root, book)}:追踪/上下文.md 已 ${contextSize} 字节,超出续写状态卡预算 12288 字节——提交一份 mode=revision 事务让 tracking_commit.py 整份重建,不要手改也不要继续追加。`)\n }\n } catch {}\n }\n\n const titles = new Map()\n for (const chapter of chapters) {\n const match = path.basename(chapter, \".md\").match(/^第0*\\d+章[_\\-  ]+(.+)$/)\n if (!match) continue\n const title = match[1].trim()\n if (title) titles.set(title, [...(titles.get(title) || []), path.basename(chapter)])\n }\n for (const [title, files] of titles.entries()) {\n if (files.length > 1) {\n messages.push(`[continuity] ${safeRelative(root, book)}:${files.length} 章标题重复「${title}」(${files.join(\"、\").slice(0, 60)}),建议改名。`)\n }\n }\n }\n return messages\n}\n\nfunction readShellWord(value, start) {\n let word = \"\"\n let quote = \"\"\n let escaped = false\n let started = false\n let index = start\n for (; index \u003c value.length; index++) {\n const ch = value[index]\n if (escaped) {\n word += ch\n escaped = false\n started = true\n continue\n }\n if (ch === \"\\\\\" && quote !== \"'\") {\n word += ch\n escaped = true\n started = true\n continue\n }\n if (quote) {\n if (ch === quote) quote = \"\"\n else word += ch\n started = true\n continue\n }\n if (ch === '\"' || ch === \"'\") {\n quote = ch\n started = true\n continue\n }\n if ([\" \", \"\\t\", \"\\r\", \"\\n\", \";\", \"&\", \"|\", \"\u003c\", \">\", \"(\", \")\"].includes(ch)) break\n word += ch\n started = true\n }\n return { word: started ? word : \"\", next: index }\n}\n\nfunction readHeredocDelimiter(value, start) {\n let word = \"\"\n let quote = \"\"\n let escaped = false\n let started = false\n let index = start\n for (; index \u003c value.length; index++) {\n const ch = value[index]\n if (escaped) {\n word += ch\n escaped = false\n started = true\n continue\n }\n if (ch === \"\\\\\" && quote !== \"'\") {\n const next = value[index + 1] || \"\"\n if (quote === '\"' && ![\"$\", \"`\", '\"', \"\\\\\", \"\\n\"].includes(next)) {\n word += ch\n } else {\n escaped = true\n }\n started = true\n continue\n }\n if (quote) {\n if (ch === quote) quote = \"\"\n else word += ch\n started = true\n continue\n }\n if (ch === '\"' || ch === \"'\") {\n quote = ch\n started = true\n continue\n }\n if ([\" \", \"\\t\", \"\\r\", \"\\n\", \";\", \"&\", \"|\", \"\u003c\", \">\", \"(\", \")\"].includes(ch)) break\n word += ch\n started = true\n }\n return { word: started ? word : \"\", next: index }\n}\n\nfunction heredocDeclarations(line) {\n const declarations = []\n let quote = \"\"\n let escaped = false\n for (let index = 0; index \u003c line.length; index++) {\n const ch = line[index]\n if (escaped) {\n escaped = false\n continue\n }\n if (ch === \"\\\\\" && quote !== \"'\") {\n escaped = true\n continue\n }\n if (quote) {\n if (ch === quote) quote = \"\"\n continue\n }\n if (ch === '\"' || ch === \"'\") {\n quote = ch\n continue\n }\n if (ch !== \"\u003c\" || line[index + 1] !== \"\u003c\" || line[index - 1] === \"\u003c\" || line[index + 2] === \"\u003c\") continue\n let cursor = index + 2\n let stripTabs = false\n if (line[cursor] === \"-\") {\n stripTabs = true\n cursor++\n }\n while (line[cursor] === \" \" || line[cursor] === \"\\t\") cursor++\n const parsed = readHeredocDelimiter(line, cursor)\n if (parsed.word) declarations.push({ delimiter: parsed.word, stripTabs })\n index = Math.max(index, parsed.next - 1)\n }\n return declarations\n}\n\nfunction maskHeredocBodies(command) {\n const pending = []\n return String(command).split(\"\\n\").map((line) => {\n if (pending.length) {\n const current = pending[0]\n const comparable = current.stripTabs ? line.replace(/^\\t+/, \"\") : line\n if (comparable === current.delimiter) {\n pending.shift()\n return line\n }\n return \" \".repeat(line.length)\n }\n pending.push(...heredocDeclarations(line))\n return line\n }).join(\"\\n\")\n}\n\nfunction commandWordIndex(words) {\n let index = 0\n while (index \u003c words.length) {\n while (index \u003c words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index]) || words[index] === \"noglob\")) index++\n if (words[index] === \"command\") {\n index++\n while (index \u003c words.length) {\n const option = words[index]\n if (option === \"--\") { index++; break }\n if (option === \"-v\" || option === \"-V\" || /^-[p]*[vV]/.test(option)) return words.length\n if (option === \"-p\" || /^-p+$/.test(option)) { index++; continue }\n break\n }\n continue\n }\n if (words[index] === \"env\") {\n index++\n while (index \u003c words.length) {\n const option = words[index]\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(option) || [\"-i\", \"--ignore-environment\"].includes(option)) {\n index++\n continue\n }\n if (option === \"-u\" || option === \"--unset\") {\n index += 2\n continue\n }\n if (option.startsWith(\"--unset=\") || (/^-u.+/.test(option) && option !== \"-u\")) {\n index++\n continue\n }\n if (option === \"--\") index++\n break\n }\n continue\n }\n break\n }\n return index\n}\n\nfunction nestedShellCommand(args) {\n const valueOptions = new Set([\"-o\", \"+o\", \"-O\", \"+O\"])\n for (let index = 0; index \u003c args.length; index++) {\n const option = args[index]\n if (option === \"--\") return \"\"\n if (option === \"-c\" || (/^-[^-]+$/.test(option) && option.slice(1).includes(\"c\"))) {\n return args[index + 1] || \"\"\n }\n if (valueOptions.has(option)) {\n index++\n continue\n }\n if (!option.startsWith(\"-\") && !option.startsWith(\"+\")) break\n }\n return \"\"\n}\n\nfunction commandSubstitutions(command) {\n const value = String(command)\n const substitutions = []\n let quote = \"\"\n let escaped = false\n for (let index = 0; index \u003c value.length; index++) {\n const ch = value[index]\n if (escaped) {\n escaped = false\n continue\n }\n if (ch === \"\\\\\" && quote !== \"'\") {\n escaped = true\n continue\n }\n if (quote === \"'\") {\n if (ch === \"'\") quote = \"\"\n continue\n }\n if (ch === '\"') {\n quote = quote === '\"' ? \"\" : '\"'\n continue\n }\n if (!quote && ch === \"'\") {\n quote = \"'\"\n continue\n }\n if (ch === \"$\" && value[index + 1] === \"(\" && value[index + 2] !== \"(\") {\n let depth = 1\n let innerQuote = \"\"\n let innerEscaped = false\n let end = index + 2\n for (; end \u003c value.length; end++) {\n const inner = value[end]\n if (innerEscaped) { innerEscaped = false; continue }\n if (inner === \"\\\\\" && innerQuote !== \"'\") { innerEscaped = true; continue }\n if (innerQuote) {\n if (inner === innerQuote) innerQuote = \"\"\n continue\n }\n if (inner === '\"' || inner === \"'\") { innerQuote = inner; continue }\n if (inner === \"(\") depth++\n else if (inner === \")\" && --depth === 0) break\n }\n if (depth === 0) {\n substitutions.push(value.slice(index + 2, end))\n index = end\n }\n continue\n }\n if (ch === \"`\") {\n let end = index + 1\n let tickEscaped = false\n for (; end \u003c value.length; end++) {\n const inner = value[end]\n if (tickEscaped) { tickEscaped = false; continue }\n if (inner === \"\\\\\") { tickEscaped = true; continue }\n if (inner === \"`\") break\n }\n if (end \u003c value.length) {\n substitutions.push(value.slice(index + 1, end))\n index = end\n }\n }\n }\n return substitutions\n}\n\n// keepAll:命令里有 cd 时,相对目标要先接上 cd 目录再判是不是正文(`cd 正文 && cat > 第3章.md`),\n// 所以先全部取出,由调用方接好目录后再过滤。\nfunction redirectTargets(command, keepAll = false) {\n const value = String(command)\n const targets = []\n let quote = \"\"\n let escaped = false\n for (let index = 0; index \u003c value.length; index++) {\n const ch = value[index]\n if (escaped) { escaped = false; continue }\n if (ch === \"\\\\\" && quote !== \"'\") { escaped = true; continue }\n if (quote) {\n if (ch === quote) quote = \"\"\n continue\n }\n if (ch === '\"' || ch === \"'\") { quote = ch; continue }\n if (ch !== \">\") continue\n let cursor = index + (value[index + 1] === \">\" ? 2 : 1)\n if (value[cursor] === \"|\" || value[cursor] === \"&\") cursor++\n while (value[cursor] === \" \" || value[cursor] === \"\\t\") cursor++\n const parsed = readShellWord(value, cursor)\n if (keepAll || parsed.word.includes(\"正文\")) targets.push(parsed.word)\n index = Math.max(index, parsed.next - 1)\n }\n return targets\n}\n\nfunction writeOperands(command, args) {\n const operands = []\n const valueOptions = command === \"touch\"\n ? new Set([\"-d\", \"--date\", \"-r\", \"--reference\", \"-t\", \"--time\"])\n : new Set()\n let options = true\n for (let i = 0; i \u003c args.length; i++) {\n const arg = args[i]\n if (options && arg === \"--\") {\n options = false\n continue\n }\n if (options && valueOptions.has(arg)) {\n i++\n continue\n }\n if (options && [...valueOptions].some((option) => option.startsWith(\"--\") && arg.startsWith(`${option}=`))) continue\n if (options && arg.startsWith(\"-\") && arg !== \"-\") continue\n operands.push(arg)\n }\n return operands\n}\n\nfunction commandBasename(value) {\n const parts = String(value || \"\").split(/[\\\\/]/)\n return parts[parts.length - 1]\n}\n\n// 目录形态的落盘目标一律用 \"/\" 拼:path.join 在 Windows 产出反斜杠,会让三端 parity 的\n// 逐字比较在 Windows 上错开(resolveTarget 之后也会把 \\ 归一成 /,这里先统一即可)。\nfunction joinPosix(directory, name) {\n return `${String(directory).replace(/[\\\\/]+$/, \"\")}/${name}`\n}\n\nfunction copyLikeTargets(command, args) {\n const positionals = []\n let targetDirectory = \"\"\n let directoryOnly = false\n let options = true\n for (let i = 0; i \u003c args.length; i++) {\n const arg = args[i]\n if (options && arg === \"--\") {\n options = false\n continue\n }\n if (options && (arg === \"-t\" || arg === \"--target-directory\")) {\n targetDirectory = args[++i] || \"\"\n continue\n }\n if (options && arg.startsWith(\"--target-directory=\")) {\n targetDirectory = arg.slice(\"--target-directory=\".length)\n continue\n }\n if (options && command === \"install\" && (arg === \"-d\" || arg === \"--directory\")) {\n directoryOnly = true\n continue\n }\n if (options && arg.startsWith(\"-\") && arg !== \"-\") continue\n positionals.push(arg)\n }\n if (directoryOnly || !positionals.length) return []\n if (targetDirectory) {\n return positionals.map((source) => joinPosix(targetDirectory, commandBasename(source)))\n }\n if (positionals.length \u003c 2) return []\n const destination = positionals[positionals.length - 1]\n const normalized = destination.replace(/\\\\/g, \"/\")\n if (normalized.endsWith(\"/\") || normalized.split(\"/\").pop() === \"正文\") {\n return positionals.slice(0, -1).map((source) => joinPosix(destination, commandBasename(source)))\n }\n return [destination]\n}\n\nfunction extractProseTargets(command, depth = 0) {\n const targets = []\n const scannable = maskHeredocBodies(command)\n if (depth \u003c 8) {\n for (const nested of commandSubstitutions(scannable)) {\n targets.push(...extractProseTargets(nested, depth + 1))\n }\n }\n // `cd 书目录 && cat > 正文/...`:相对写入目标要接在 cd 之后的目录上,否则守卫按会话目录去找细纲,\n // 把有细纲的章误报成缺细纲。命令里没有 cd 时保持整条命令扫描重定向的原行为。\n // `>|`、`>&file`、`&>` 是重定向,不是管道或后台符;先统一成 `>`,免得切段时把目标切丢。\n const segments = shellSegments(scannable.replace(/&>/g, \" >\").replace(/>\\|/g, \">\").replace(/>&(?!\\d)/g, \">\"))\n const parsed = segments.map((raw) => {\n const words = shellWords(beforeShellRedirection(raw))\n const commandIndex = commandWordIndex(words)\n return { raw, name: commandBasename(words[commandIndex]), args: words.slice(commandIndex + 1) }\n })\n const hasCd = parsed.some((item) => item.name === \"cd\")\n if (!hasCd) targets.push(...redirectTargets(scannable))\n let cwd = \"\"\n const isAbsolute = (value) => /^([\\\\/~]|[A-Za-z]:[\\\\/])/.test(value)\n const underCwd = (value) => (cwd && !isAbsolute(value) ? joinPosix(cwd, value) : value)\n for (const { raw, name: commandName, args: commandArgs } of parsed) {\n // 引号感知分词(同 shellWords):/\\s+/ 会把 cp draft.md \"my book/正文/第1章.md\" 的目标切碎,\n // 末位取到 book/正文/第1章.md —— 判到另一本书上(那本有细纲就直接放行)。\n if (commandName === \"cd\") {\n const directory = commandArgs.find((arg) => !arg.startsWith(\"-\"))\n if (directory) cwd = isAbsolute(directory) || !cwd ? directory : joinPosix(cwd, directory)\n continue\n }\n if (hasCd) targets.push(...redirectTargets(raw, true).map(underCwd).filter((target) => target.includes(\"正文\")))\n if ([\"sh\", \"bash\", \"dash\", \"ksh\", \"zsh\"].includes(commandName)) {\n const nested = nestedShellCommand(commandArgs)\n if (nested) targets.push(...extractProseTargets(nested, depth + 1))\n }\n if (commandName === \"tee\" || commandName === \"touch\") {\n for (const destination of writeOperands(commandName, commandArgs).map(underCwd)) {\n if (destination.includes(\"正文\")) targets.push(destination)\n }\n }\n if (commandName === \"cp\" || commandName === \"mv\" || commandName === \"install\") {\n for (const destination of copyLikeTargets(commandName, commandArgs).map(underCwd)) {\n if (destination.includes(\"正文\")) targets.push(destination)\n }\n }\n }\n return [...new Set(targets.filter(Boolean))]\n}\n\n// apply_patch 目标抽取。只认 Add/Update 会漏掉 `*** Move to:`——它是 Update File 段的子指令\n// (apply_patch 的改名/搬家形态),落盘路径是**目的地**,源路径搬完就不存在了。此前\n// `*** Update File: draft.md` + `*** Move to: 书/正文/第9章.md` 只抽到 draft.md:细纲门放行\n// (draft.md 不是正文),写后兜底网也扫的是已经不存在的源 —— 一份没细纲的草稿能直接搬进 正文/。\n// 故 Move 用目的地**顶替**同段的源目标(不是追加:源已不在,拿它去查会误伤/空扫)。\n// Delete File 一律不入表(两端一致):删除不是写入,proseBlockReason 对已存在的正文本就放行、\n// 删完文件也不在了没东西可扫,认它只会给「删稿」误报;但 Delete 段也能带 Move to(搬走后删源),\n// 那条 Move 的目的地照样要进表,故 Delete 只清掉待顶替的源槽位。\nfunction extractPatchTargets(patchText) {\n const targets = []\n let sourceIndex = -1\n for (const line of String(patchText).split(/\\r?\\n/)) {\n // apply_patch grammar 的控制行必须从第 0 列开始;diff 上下文行固定以空格开头。\n // 先 trim 会把正文里的 ` *** Move to: notes.md` 伪装成搬家指令,顶掉真实扫描目标。\n const file = line.match(/^\\*\\*\\* (Add|Update|Delete) File: (.+)$/)\n if (file) {\n if (file[1] === \"Delete\") {\n sourceIndex = -1\n continue\n }\n targets.push(file[2].trim())\n sourceIndex = targets.length - 1\n continue\n }\n const move = line.match(/^\\*\\*\\* Move to: (.+)$/)\n if (move) {\n const destination = move[1].trim()\n if (!destination) continue\n if (sourceIndex >= 0) targets[sourceIndex] = destination\n else targets.push(destination)\n sourceIndex = -1\n }\n }\n return targets\n}\n\n// 未展开的 shell 变量:$VAR / ${VAR} / $(cmd)。与 codex UNEXPANDED_SHELL_VAR 同式。\nconst UNEXPANDED_SHELL_VAR = /\\$(\\{[A-Za-z_][A-Za-z0-9_]*\\}|[A-Za-z_][A-Za-z0-9_]*|\\()/\n\n// 「去味:跳过」豁免标记:文件首 6 行内的 `\u003c!-- 去味:跳过 -->`,冒号全角半角都认,注释内可有\n// 空格/Tab;裸写不在注释里的不算。codex py _DESLOP_SKIP_MARKER、bash guard、storyctl DESLOP_SKIP 同一语法。\nconst DESLOP_SKIP_MARKER = /\u003c!--[ \\t]*去味[ \\t]*(:|:)[ \\t]*跳过[ \\t]*-->/\nfunction hasDeslopSkipMarker(text) {\n return DESLOP_SKIP_MARKER.test(text.split(/\\r?\\n/).slice(0, 6).join(\"\\n\"))\n}\n\n// 细纲「实质为空」:去掉文件头 BOM 与每行行首的 Markdown 标题标记(空格/Tab 后接的 #)后,\n// 剩下的非空白字符(空白 = ASCII 空白 + 全角空格)不足 OUTLINE_MIN_CHARS 个码点。标题文字照算\n// (`## 核心事件:…` 这种把内容写在标题行上的细纲是写了东西的)。只量写没写东西,\n// 不查 v0.8 细纲字段——旧书的细纲格式各异,照样放行。读不了(权限/是目录)或不是合法 UTF-8\n// (GBK 等旧编码,各端解码计数口径不同)都按非空放行(宁可漏拦)。\n// codex py _outline_is_empty 与 guard-outline-before-prose.sh outline_is_empty 同口径,\n// 由 test-prose-net-parity.sh C/D 段锁 parity。\nconst OUTLINE_MIN_CHARS = 30\nfunction outlineIsEmpty(file) {\n let text\n try {\n const bytes = fs.readFileSync(file)\n text = bytes.toString(\"utf8\")\n if (!Buffer.from(text, \"utf8\").equals(bytes)) return false\n } catch {\n return false\n }\n if (text.startsWith(\"\\uFEFF\")) text = text.slice(1)\n const body = text.split(\"\\n\").map((line) => line.replace(/^[ \\t]*#+/, \"\")).join(\"\")\n return Array.from(body.replace(/[ \\t\\r\\n\\f\\v\\u3000]/g, \"\")).length \u003c OUTLINE_MIN_CHARS\n}\n\nfunction proseBlockReason(root, absolute) {\n const base = path.basename(absolute)\n const parent = path.basename(path.dirname(absolute))\n if (base === \"正文.md\") {\n if (fs.existsSync(absolute)) return null\n const book = path.dirname(absolute)\n if (fs.existsSync(path.join(root, \"拆文库\", path.basename(book)))) return null\n if (!fs.existsSync(path.join(book, \"设定.md\"))) return null\n if (!fs.existsSync(path.join(book, \"小节大纲.md\"))) {\n return `⛔ 写正文被拦截:${safeRelative(root, absolute)} 缺少同目录 小节大纲.md。先按 story-short-write 完成「小节大纲.md」再写正文。`\n }\n return null\n }\n if (parent !== \"正文\" || !/^第.*章.*\\.md$/.test(base)) return null\n const match = base.match(/^第0*(\\d+)章/)\n if (!match) return null\n const chapter = match[1]\n const book = path.dirname(path.dirname(absolute))\n const state = path.join(book, \"追踪\", \"_tracking-state.json\")\n // 这是守卫的 canonical case:agent 可能在任何脚手架存在前就首建 {书}/正文/第N章.md。\n // 是否“像一本书”不能作为放行条件;相对路径误判应在宿主 adapter 按 cwd 正确解析,而不是\n // 让核心守卫 fail open。\n // story-import 在复制既有正文、尚未执行 tracking init 的窗口可以写;一旦 state 存在,\n // 即进入当前追踪协议,不再因为保留了 拆文库/ 分析资产而永久绕过守卫。\n if (fs.existsSync(path.join(root, \"拆文库\", path.basename(book))) && !fs.existsSync(state)) return null\n const exists = fs.existsSync(absolute)\n const outlineDir = path.join(book, \"大纲\")\n let found = false\n if (!exists) {\n let outlines = []\n try {\n outlines = fs.readdirSync(outlineDir).filter((file) => {\n const candidate = file.match(/^细纲_第0*(\\d+)章.*\\.md$/)\n return candidate && candidate[1] === chapter\n }).sort()\n } catch {}\n found = outlines.length > 0\n // 同章有多份细纲(补零差异/带标题)时任一份写了内容就放行。\n if (found && outlines.every((file) => outlineIsEmpty(path.join(outlineDir, file)))) {\n return `⛔ 写正文被拦截:第 ${chapter} 章的细纲(${safeRelative(root, path.join(outlineDir, outlines[0]))})是空的(不计 # 号和空白不到 ${OUTLINE_MIN_CHARS} 字)。先按 story-long-write 单章流程把细纲写完整(这章发生什么、主角做什么选择),再写正文。`\n }\n if (!found) {\n // `cat > \"$PROJ/正文/第001章.md\"` 这类目标里的 shell 变量守卫展开不了,书目录按字面拼出来\n // 必然找不到细纲。仍然拦(fail closed),但如实说路径没解析出来,不谎报「缺少细纲」。\n if (UNEXPANDED_SHELL_VAR.test(safeRelative(root, absolute)) && !fs.existsSync(book)) {\n return `⛔ 写正文被拦截:写入路径含未展开的 shell 变量(${safeRelative(root, absolute)}),守卫无法确认对应细纲。改用字面项目路径(如 书名/正文/第${chapter.padStart(3, \"0\")}章_标题.md)重新写入。`\n }\n return `⛔ 写正文被拦截:第 ${chapter} 章缺少细纲(${safeRelative(root, outlineDir)}/细纲_第${chapter.padStart(3, \"0\")}章.md)。先按 story-long-write 单章流程补建细纲再写正文。`\n }\n }\n const checkpointIssue = trackingCheckpointIssue(book, true, exists ? null : Number(chapter) - 1)\n if (checkpointIssue) {\n return `⛔ 写正文被拦截:${safeRelative(root, book)} 的${checkpointIssue}。`\n }\n if (exists) return null\n // 欠账门(无状态):写第 N 章(首建)前,上一章有未清毒句式且未标「去味:跳过」豁免时先清再写。\n // 判据现算自上一章文件本身,不落任何状态文件;找不到上一章/读取失败一律放行(宁可漏拦不可误伤)。\n // js↔py 文案由 check-hook-regex-sync.sh 锁同步,判定由 test-prose-net-parity.sh Part E 锁 parity。\n const prevNum = Number(chapter) - 1\n if (prevNum >= 1) {\n let prevFile = null\n try {\n // readdir 顺序在 ext4/overlayfs 上是哈希序:不排序就可能挑中同章号的原稿备份\n // (workflow-revision 的「备份原稿」产物),拿早已被改写掉的旧文本报欠账。\n // 显式排除 _原稿_ 备份并排序,保证四端与各文件系统上取到同一个「上一章」。\n const candidates = fs.readdirSync(path.dirname(absolute))\n .filter((file) => {\n const pm = file.match(/^第0*(\\d+)章.*\\.md$/)\n return pm && Number(pm[1]) === prevNum && !file.includes(\"_原稿_\")\n })\n .sort()\n if (candidates.length) prevFile = path.join(path.dirname(absolute), candidates[0])\n } catch {}\n if (prevFile) {\n let prevText = null\n try { prevText = fs.readFileSync(prevFile, \"utf8\") } catch {}\n if (prevText !== null && !hasDeslopSkipMarker(prevText)) {\n const hits = toxicPhraseFindings(prevText, loadStyleWhitelist(prevFile)).filter((line) => line.startsWith(\"第\"))\n if (hits.length) {\n const shown = hits.slice(0, 6)\n const more = hits.length - shown.length\n let reason = `⛔ 写正文被拦截:上一章(${path.basename(prevFile)})有 ${hits.length} 处未清毒句式欠账,先清零再写第 ${chapter} 章;用户显式豁免时在上一章标题行下加 \u003c!-- 去味:跳过 --> 后重试。\\n${shown.join(\"\\n\")}`\n if (more > 0) reason += `\\n(另有 ${more} 处,${toxicRescanHint(prevFile)})`\n return reason\n }\n }\n }\n }\n return null\n}\n\n// 收尾标点集与深扫 oracle check-degeneration.js 的 findTruncation 对齐([。!?!?…”\"』」))】]):\n// 】 是章尾系统播报模板的收束符(agent-references/long-chapter-hooks.md 章尾实战模板一/四),ASCII \"\n// 是 normalize-punctuation.js --quote-mode ascii 的合法收引号,两者都不该被判「疑似截断」。\nconst TERMINAL = new Set(Array.from(\"。!?…”』」))!?.~—】\\\"\"))\n// 保留导出以兼容 adapter。\nconst QUOTE_OPENERS = new Set([\"「\", \"“\", \"‘\", \"『\", '\"'])\nconst SOFT_PATTERNS = [\n // 型号后缀(AI语言模型/AI助手/人工智能语言模型/AI模型/AI大模型)必须可选吃掉:否则前视断言\n // 紧跟在「AI」后面看到的是「语」/「助」/「模」,最典型的退化开场整类漏检。\n [/作为(一个)?(AI|人工智能|大?语言模型|智能助手|聊天助手)(?:语言模型|大?模型|助手|机器人)?(?=,|,|。|、|;|;|:|:|!|!|?|\\?|\\s|)|\\)|」|』|\"|】|我|无法|不能|没法|$)/, \"AI 自指\"],\n [/^(Sure|Certainly|Here'?s|As an AI|I (?:cannot|can't|am unable|apologize))/, \"英文 AI 腔\"],\n [/我(无法|不能)(继续(写|创作|生成|下去|输出)?|生成(内容|文本|正文)?|创作|续写|写作|完成(这个|本)?(章|篇|创作|请求)?)/, \"生成拒绝语\"],\n]\nconst HARD_PATTERNS = [\n [/[((](此处|以下|这里|下文|后续)?[^))]{0,10}(省略|略去|略过)[^))]{0,10}[))]/, \"占位符(括号省略)\"],\n [/(TODO|占位符|placeholder|待补充|此处待填|此处待补)/, \"占位符\"],\n [/(细纲|情节点|卷纲|功能标签|目标情绪|字数目标|章首钩子|章尾钩子|任务描述)/, \"工程词泄漏\"],\n [/�/, \"乱码(替换字符)\"],\n]\n\nfunction skippableLine(line) {\n return !line || line.startsWith(\"#\") || line === \"---\" || /^[-—=*·•\\s]+$/.test(line)\n}\n\n// ── 毒句式(确定性 AI 句式指纹,写后正文网热路径)─────────────────────────────\n// 与 check-ai-patterns.js 的同名新规则统一规格:只收确定性、低误报的句式;密度型/\n// advisory 检测归 check-ai-patterns.js 深扫,不进这张每次写正文都跑的网。全部正则\n// 线性扫描、量词有界,无回溯灾难。台词/弹幕/系统播报不算:逐行把成对引号段等长\n// 问号占位(占位天然截断各规则的字符类,规则不会跨引号拼出假命中;见\n// maskQuotedSpans 为何用问号而不是句号),占位后仍残留引号字符(跨行对话/未闭合)\n// 的行整行跳过。js↔py 同构实现(codex\n// story_codex_hook.py)由 scripts/check-hook-regex-sync.sh(常量表逐字锁)与\n// scripts/test-prose-net-parity.sh(fixture 逐字 diff)锁 parity,文案以本核为准。\n// 单引号须成对;词内撇号(don't、O’Connor)不作为开闭引号。\nconst TOXIC_QUOTE_SPANS = [/「[^」]*」/g, /『[^』]*』/g, /【[^】]*】/g, /“[^”]*”/g, /(?\u003c![A-Za-z0-9_])‘(?:[^’]|(?\u003c=[A-Za-z0-9_])’(?=[A-Za-z0-9_]))*(?!(?\u003c=[A-Za-z0-9_])’[A-Za-z0-9_])’/g, /\"[^\"]*\"/g, /(?\u003c![A-Za-z0-9_])'(?:[^']|(?\u003c=[A-Za-z0-9_])'(?=[A-Za-z0-9_]))*(?!(?\u003c=[A-Za-z0-9_])'[A-Za-z0-9_])'/g]\nconst TOXIC_QUOTE_CHARS = new Set(Array.from(\"「」『』【】“”‘’\\\"'\"))\n// 分句起点边界(前一字符属于它才认「是A,不是B」的分句首「是」);同时用作确认语的右边界。\nconst TOXIC_CLAUSE_BOUNDARY = new Set(Array.from(\",,。.!!??;;::、…—~ \\t \"))\n// 疑问尾(是吗/是吧/是嘛)与确认语(是的/是啊/是呀/是呢+边界)里的「是」不是对比句系动词;\n// 排除逻辑移植自 check-ai-patterns.js 的 TAG_PARTICLES / AFFIRMATION_TAG_PARTICLES。\nconst TOXIC_TAG_PARTICLES = new Set([\"吗\", \"吧\", \"嘛\"])\nconst TOXIC_AFFIRM_PARTICLES = new Set([\"的\", \"啊\", \"呀\", \"呢\"])\nconst TOXIC_TRAILER_WINDOW = 600\n// 不收 em-dash(check-ai-patterns.js 里它也是 blocking)是有意的:破折号是标点,长篇 chapter check\n// --fix-punctuation / normalize-punctuation.js 会确定性整理,短篇收尾同样先跑标点整理;这张网只推回\n// 需要模型动笔改写的句式。每次写正文都把破折号当硬信号推回只会制造噪声,漏网的仍被 chapter check 拦在提交前。\nconst TOXIC_SENTENCE_PATTERNS = [\n [/声音(?:并)?不[大高响亮][^。!?!?\\n]{0,16}[却但偏]/g, \"voice-contrast\", \"删「不X…却Y」反差腔,直接写具体效果或动作。\"],\n [/(?:没有[^。!?!?\\n,,]{1,12}[,,]){2}/g, \"negation-parade\", \"「没有…,没有…」排比删到只剩一个或全删,改写正面在场的细节。\"],\n [/是[^。!?!?\\n,,]{1,12}[,,]\\s*(?:而)?不是[^。!?!?\\n]{1,20}/g, \"reverse-not-is\", \"删否定铺垫,直接写肯定项,或改成动作细节。\"],\n [/不是[^。!?!?\\n]{1,16}[,,]\\s*(?:而)?是/g, \"not-is-comparison\", \"删否定铺垫,直接写肯定项,或改成动作细节。\"],\n]\n// 「正式拉开序幕/帷幕」是场内事件的报幕式陈述,不是叙述者预告,lookbehind 排除(同 check-ai-patterns.js)。\nconst TOXIC_TRAILER_PATTERN = /没人知道|谁也不知道|谁也没想到|殊不知|(?:这)?才刚刚开(?:始|头)|正(?:朝着|向着)[^。!?!?\\n]{0,24}(?:压|涌|袭|逼)(?:了?过去|了?过来|来)|(?\u003c!正式)拉开(?:序幕|帷幕)|即将(?:开始|来临|降临)/\n// 章尾状态总结体:与 trailer-ending 共用文末窗口,盖章过去而非预告将来(同 check-ai-patterns.js)。\n// 收的都是 banned-words 已按名禁掉的形态;不收「(这|那)一刻…终于明白」——真人叙述里那是正常认知\n// 节拍,短篇第一人称审判句还是卖点。各分支要求落在句末断言位,避免吃进条件从句/动补/成语/及物用法/否定认知。\nconst TOXIC_TRAILER_SUMMARY_PATTERN = /这一(?:夜|天|刻|战|年|局|役)[,,]?[^。!?!?,,\\n]{0,6}(?\u003c!命中)(?\u003c!是)注定[^。!?!?\\n]{0,8}[。!]|就这样[,,][^。!?!?,,\\n]{0,8}(?:一切|全部)[^。!?!?,,\\n]{0,4}(?:结束了|落幕|收场)[。!]|这一切[,,]?[^。!?!?,,\\n]{0,6}(?:都)?(?:说明|意味着|结束了)(?!的)(?:(?!什么)[^。!?!?\\n]){0,6}[。!]|(?:新的篇章|新的旅程|崭新的篇章|新的人生)[^。!?!?\\n]{0,6}(?:开始|拉开|展开)|命运[^。!?!?\\n]{0,6}齿轮/\n// 「是A,不是B」的反问尾巴(…,不是吗/么/吧)不算对比句;取匹配段最后一个「不是」后的首字判断。\nconst TOXIC_REVERSE_TAIL = /.*[,,]\\s*(?:而)?不是([^。!?!?\\n]*)$/\n\n// 占位字符用「?」而不是「。」:占位既要截断各规则的 [^。!?!?…] 否定类(?与句号在每条规则的\n// 否定类里等效),又不能落在任何规则的接受位。句号占位会替 trailer-summary 的句末 [。!] 伪造出\n// 终止符,让「这一战注定是「血屠」的开端,…」这类引号里放代号/绰号的叙述行被误报,且报出的\n// 『这一战注定是。』在原文里 grep 不到。占位长度不变,故 trailer 窗口切点不漂移。\nfunction maskQuotedSpans(line) {\n let out = line\n for (const spans of TOXIC_QUOTE_SPANS) out = out.replace(spans, (m) => \"?\".repeat(m.length))\n return out\n}\n\n// 「是不是」疑问、翻转「是」后跟疑问尾/确认语 → 不算「不是A,(而)是B」对比句。\nfunction toxicNotIsExcluded(line, matched, start) {\n if (start > 0 && line[start - 1] === \"是\") return true\n const end = start + matched.length\n const c1 = line[end] || \"\"\n const c2 = line[end + 1] || \"\"\n if (TOXIC_TAG_PARTICLES.has(c1)) return true\n if (TOXIC_AFFIRM_PARTICLES.has(c1) && (c2 === \"\" || TOXIC_CLAUSE_BOUNDARY.has(c2))) return true\n return false\n}\n\n// 只认分句首的「是A,不是B」:句中「但是/还是/只是/他是…」的「是」一律不算(either-or\n// 「不是/就是/也是」与全部「X是」连词/副词合成词都被分句首判定排除);「是的,不是…」\n// 确认语开头、「是不是…」问句起头、「…,不是吗/么/吧」反问尾巴不算(同 check-ai-patterns.js)。\nfunction toxicReverseNotIsExcluded(line, matched, start) {\n const prev = start > 0 ? line[start - 1] : \"\"\n if (prev !== \"\" && !TOXIC_CLAUSE_BOUNDARY.has(prev)) return true\n if (line.slice(start + 1, start + 3) === \"不是\") return true\n const c1 = line[start + 1] || \"\"\n const c2 = line[start + 2] || \"\"\n if ((TOXIC_TAG_PARTICLES.has(c1) || TOXIC_AFFIRM_PARTICLES.has(c1)) && (c2 === \"\" || TOXIC_CLAUSE_BOUNDARY.has(c2))) return true\n const tail = matched.match(TOXIC_REVERSE_TAIL)\n const t1 = tail && tail[1] ? tail[1][0] : \"\"\n if (t1 === \"吗\" || t1 === \"么\" || t1 === \"吧\") return true\n return false\n}\n\n// 每行只报第一条命中的句式规则(复扫到净哲学:改完一处再扫下一处)。\nfunction matchToxicSentence(line) {\n for (const [regex, label, fix] of TOXIC_SENTENCE_PATTERNS) {\n regex.lastIndex = 0\n let match\n while ((match = regex.exec(line)) !== null) {\n if (label === \"not-is-comparison\" && toxicNotIsExcluded(line, match[0], match.index)) continue\n if (label === \"reverse-not-is\" && toxicReverseNotIsExcluded(line, match[0], match.index)) continue\n return [label, fix, match[0]]\n }\n }\n return null\n}\n\nfunction codePointSlice(value, start, end) {\n return Array.from(value).slice(start, end).join(\"\")\n}\n\n// Book-local only: never inherit an unrelated workspace or another book's choices.\nfunction loadStyleWhitelist(file) {\n const parent = path.dirname(path.resolve(file));\n const book = path.basename(parent) === '正文' ? path.dirname(parent) : parent;\n try {\n return fs.readFileSync(path.join(book, '.deslop-whitelist'), 'utf8')\n .split(/\\r?\\n/).map(line => line.trim())\n .filter(line => line && !line.startsWith('#'))\n .sort((a, b) => b.length - a.length);\n } catch (error) {\n if (error.code === 'ENOENT') return [];\n throw error;\n }\n}\n\nfunction styleSpans(text, whitelist) {\n const spans = [];\n for (const literal of whitelist) {\n for (let at = text.indexOf(literal); at !== -1; at = text.indexOf(literal, at + 1)) {\n spans.push([at, at + literal.length]);\n }\n }\n return spans;\n}\n\nfunction maskStyleText(text, whitelist) {\n const chars = text.split('');\n for (const [start, end] of styleSpans(text, whitelist)) {\n chars.fill('?', start, end);\n }\n return chars.join('');\n}\n\n\n// 毒句式末行的完整复扫指引:长篇分章正文({书}/正文/第N章*.md)指 storyctl chapter check——长篇写作\n// 流程只认这一个检查入口;其余(短篇 正文.md、调用方没给路径)指 check-ai-patterns.js。\n// codex py toxic_rescan_hint 同文案,由 test-prose-net-parity.sh C 段锁 parity。\nfunction toxicRescanHint(absolute) {\n const match = absolute ? path.basename(absolute).match(/^第0*(\\d+)章.*\\.md$/) : null\n if (match && path.basename(path.dirname(absolute)) === \"正文\") {\n return `完整检查:{PYTHON} \u003cstory-long-write>/scripts/storyctl.py chapter check --project \u003c书目录> --chapter ${match[1]}`\n }\n return \"完整扫描:node \u003cskill>/scripts/check-ai-patterns.js --check \u003c正文文件>\"\n}\n\n// HTML 注释(如 `\u003c!-- 去味:跳过 -->`、作者备注)是元信息不是正文,与 check-ai-patterns.js scanDocument\n// 同规则:注释段(含 `\u003c!--`、`-->`)换成等长(按码点)空格,可跨行;到文末都没闭合的 `\u003c!--` 不算注释,\n// 只抹掉这四个字符、后面照常当正文(漏写 `-->` 不能把后面整章藏起来)。codex py _mask_html_comment_lines 同构。\nfunction maskHtmlCommentLines(lines) {\n const unclosed = new Set()\n for (;;) {\n const out = []\n let open = false\n let openedAt = null\n lines.forEach((line, index) => {\n let text = \"\"\n let cursor = 0\n while (cursor \u003c line.length) {\n if (open) {\n const close = line.indexOf(\"-->\", cursor)\n const end = close === -1 ? line.length : close + 3\n text += \" \".repeat(Array.from(line.slice(cursor, end)).length)\n cursor = end\n if (close !== -1) open = false\n continue\n }\n const start = line.indexOf(\"\u003c!--\", cursor)\n if (start === -1) {\n text += line.slice(cursor)\n break\n }\n text += line.slice(cursor, start) + \" \"\n cursor = start + 4\n const key = `${index}:${start}`\n if (unclosed.has(key)) continue\n open = true\n openedAt = key\n }\n out.push(text)\n })\n if (!(open && openedAt !== null)) return out\n unclosed.add(openedAt)\n }\n}\n\nfunction toxicPhraseFindings(text, whitelist = [], rescan = toxicRescanHint(\"\")) {\n const findings = []\n const content = []\n maskHtmlCommentLines(text.split(\"\\n\")).forEach((raw, index) => {\n const line = raw.trim()\n if (skippableLine(line)) return\n const masked = maskQuotedSpans(maskStyleText(line, whitelist))\n for (const ch of masked) {\n if (TOXIC_QUOTE_CHARS.has(ch)) return\n }\n content.push([index + 1, masked])\n })\n for (const [lineNo, masked] of content) {\n const hit = matchToxicSentence(masked)\n if (hit) findings.push(`第${lineNo}行 毒句式[${hit[0]}]:『${codePointSlice(hit[2], 0, 20)}』——${hit[1]}`)\n }\n // trailer-ending 只扫文末 600 字窗口(引号占位后按行累计,边界行整行计入)。\n let acc = 0\n let cut = content.length\n while (cut > 0 && acc \u003c TOXIC_TRAILER_WINDOW) {\n cut -= 1\n acc += Array.from(content[cut][1]).length\n }\n for (let i = cut; i \u003c content.length; i++) {\n const [lineNo, masked] = content[i]\n const match = masked.match(TOXIC_TRAILER_PATTERN)\n if (match) findings.push(`第${lineNo}行 毒句式[trailer-ending]:『${codePointSlice(match[0], 0, 20)}』——删章尾预告腔,用正在发生的动作或画面收章。`)\n const summary = masked.match(TOXIC_TRAILER_SUMMARY_PATTERN)\n if (summary) findings.push(`第${lineNo}行 毒句式[trailer-summary]:『${codePointSlice(summary[0], 0, 20)}』——删章尾状态总结句,细纲的结尾设定要落成最后的具体动作、画面或台词。`)\n }\n if (findings.length) findings.push(`毒句式是确定性 AI 指纹:本章须清零后再继续。${rescan}`)\n return findings\n}\n\nfunction proseNetFindings(text, whitelist = [], rescan = toxicRescanHint(\"\")) {\n const findings = []\n const content = []\n text.split(\"\\n\").forEach((raw, index) => {\n const line = raw.trim()\n if (skippableLine(line)) return\n const lineNo = index + 1\n content.push([lineNo, line])\n let hit = false\n // 只豁免成对引号内的内容,继续检查引号外叙述。\n const outsideQuotes = maskQuotedSpans(line)\n for (const [regex, label] of SOFT_PATTERNS) {\n const match = outsideQuotes.match(regex)\n if (match) {\n findings.push(`第${lineNo}行 元信息泄漏(${label}):「${codePointSlice(match[0], 0, 20)}」`)\n hit = true\n break\n }\n }\n if (hit) return\n for (const [regex, label] of HARD_PATTERNS) {\n const match = line.match(regex)\n if (match) {\n findings.push(`第${lineNo}行 ${label}:「${codePointSlice(match[0], 0, 20)}」`)\n break\n }\n }\n })\n for (let i = 1; i \u003c content.length; i++) {\n const previous = content[i - 1][1]\n const [lineNo, current] = content[i]\n const characters = Array.from(current)\n if (previous === current && characters.length >= 8) findings.push(`第${lineNo}行 紧邻复读:整行与上一行完全相同「${characters.slice(0, 20).join(\"\")}」`)\n }\n if (content.length) {\n const [lineNo, last] = content[content.length - 1]\n if (!TERMINAL.has(Array.from(last).pop())) findings.push(`第${lineNo}行 疑似截断:结尾「…${codePointSlice(last, -12)}」未以标点收束`)\n }\n // 「去味:跳过」豁免与欠账门同判据(文件首 6 行):标记在场时跳过毒句式推回,\n // 其余网(元信息/占位/复读/截断)照常——否则按拦截提示加标记的那次 Edit 会把\n // 已豁免的毒句式再次当硬信号推回。\n if (!hasDeslopSkipMarker(text)) {\n findings.push(...toxicPhraseFindings(text, whitelist, rescan))\n }\n return findings\n}\n\nfunction isProsePath(absolute) {\n const base = path.basename(absolute)\n const parent = path.basename(path.dirname(absolute))\n if (base === \"正文.md\") return fs.existsSync(path.join(path.dirname(absolute), \"设定.md\"))\n if (parent !== \"正文\" || !/^第.*章.*\\.md$/.test(base)) return false\n const book = path.dirname(path.dirname(absolute))\n // 大纲/追踪/设定 must be directories; 设定.md a file — matches the bash oracle\n // check-prose-after-write.sh (`[ -d 大纲 ] || … || [ -f 设定.md ]`).\n return [\"大纲\", \"追踪\", \"设定\"].some((name) => existingDir(path.join(book, name))) || fs.existsSync(path.join(book, \"设定.md\"))\n}\n\nfunction duplicateTitleFindings(absolute) {\n const bodyDir = path.dirname(absolute)\n if (path.basename(bodyDir) !== \"正文\") return []\n const titles = new Map()\n try {\n for (const file of fs.readdirSync(bodyDir)) {\n const match = file.replace(/\\.md$/, \"\").match(/^第0*\\d+章[_\\-  ]+(.+)$/)\n if (!match) continue\n const title = match[1].trim()\n if (title) titles.set(title, [...(titles.get(title) || []), file])\n }\n } catch {}\n const findings = []\n for (const [title, files] of titles.entries()) {\n if (files.length > 1) findings.push(`${files.length} 章标题重复「${title}」(${files.join(\"、\").slice(0, 60)}),建议改名。`)\n }\n return findings\n}\n\nfunction proseAfterWrite(root, absolute) {\n if (!fs.existsSync(absolute) || !isProsePath(absolute)) return \"\"\n const findings = []\n try {\n const bytes = fs.statSync(absolute).size\n if (bytes \u003c 200) findings.push(`【落盘】正文仅 ${bytes} 字节,疑似未写完/落盘失败(quota/超时中断?),请核对并补写。`)\n const text = fs.readFileSync(absolute, \"utf8\")\n findings.push(...proseNetFindings(text, loadStyleWhitelist(absolute), toxicRescanHint(absolute)))\n } catch {\n return \"\"\n }\n findings.push(...duplicateTitleFindings(absolute))\n if (!findings.length) return \"\"\n return `=== 正文兜底检测(${safeRelative(root, absolute)})===\\n轻量确定性网自动复扫(模型无关,防主会话漏跑收尾)。按类型处理后复扫到净:\\n${findings.join(\"\\n\")}`\n}\n\n// 线性手写分词,不用带歧义交替的正则:旧式 /\"(?:\\\\.|[^\"])*\"|'[^']*'|[^\\s]+/ 里 \\\\. 与 [^\"] 都能吃\n// 反斜杠,而调用方先按 [;&|\\n] 拆段会拆开引号内的分隔符、留下一个不闭合的 \",此时每个反斜杠让\n// 搜索空间翻倍——`git commit -m \"fix: 转义覆盖 \\\\n \\\\r … | see README\"` 这种 130 字命令实测烧掉\n// 27s CPU,超过宿主 hook 的 timeoutMs(zcode 15000ms)被杀。逐字符扫描:引号内原样取字(成对\n// 引号剥掉,不闭合就取到段尾),ASCII 空白(空格/Tab/CR/LF)分词——U+3000 不是 shell 分词符,\n// 故不切。不解 \\ 转义:resolveTarget 把 \\ 当路径分隔符(Windows 路径)。\nfunction shellWords(segment) {\n const words = []\n let current = \"\"\n let started = false\n let quote = \"\"\n let escaped = false\n for (const ch of String(segment)) {\n if (escaped) {\n current += ch\n escaped = false\n started = true\n continue\n }\n if (ch === \"\\\\\" && quote !== \"'\") {\n current += ch\n escaped = true\n started = true\n continue\n }\n if (quote) {\n if (ch === quote) quote = \"\"\n else current += ch\n continue\n }\n if (ch === '\"' || ch === \"'\") {\n quote = ch\n started = true\n continue\n }\n if (ch === \" \" || ch === \"\\t\" || ch === \"\\r\" || ch === \"\\n\") {\n if (started) words.push(current)\n current = \"\"\n started = false\n continue\n }\n started = true\n current += ch\n }\n if (started) words.push(current)\n return words\n}\n\nfunction shellSegments(command) {\n const segments = []\n let current = \"\"\n let quote = \"\"\n let escaped = false\n for (const ch of String(command)) {\n if (escaped) {\n current += ch\n escaped = false\n continue\n }\n if (ch === \"\\\\\" && quote !== \"'\") {\n current += ch\n escaped = true\n continue\n }\n if (quote) {\n current += ch\n if (ch === quote) quote = \"\"\n continue\n }\n if (ch === '\"' || ch === \"'\") {\n quote = ch\n current += ch\n continue\n }\n if (ch === \";\" || ch === \"&\" || ch === \"|\" || ch === \"\\n\") {\n if (current) segments.push(current)\n current = \"\"\n continue\n }\n current += ch\n }\n if (current) segments.push(current)\n return segments\n}\n\nfunction beforeShellRedirection(segment) {\n let current = \"\"\n let quote = \"\"\n let escaped = false\n for (const ch of String(segment)) {\n if (escaped) {\n current += ch\n escaped = false\n continue\n }\n if (ch === \"\\\\\" && quote !== \"'\") {\n current += ch\n escaped = true\n continue\n }\n if (quote) {\n current += ch\n if (ch === quote) quote = \"\"\n continue\n }\n if (ch === '\"' || ch === \"'\") {\n quote = ch\n current += ch\n continue\n }\n if (ch === \"\u003c\" || ch === \">\") {\n return current.replace(/\\d+$/, \"\")\n }\n current += ch\n }\n return current\n}\n\nfunction isGitCommitCommand(command) {\n const valueOptions = new Set([\"-C\", \"-c\", \"--git-dir\", \"--work-tree\", \"--namespace\", \"--exec-path\", \"--super-prefix\", \"--config-env\"])\n // Flatten subshell/brace grouping to spaces so `(git commit)` / `{ git commit; }` still expose\n // the git verb; split on separators; skip leading shell wrappers and control words\n // (then/do/else/elif) so a commit inside if/for/while is detected. Mirrors the Claude bash\n // oracle validate-story-commit.sh and codex is_git_commit_command.\n for (const rawSegment of String(command).replace(/\\r/g, \"\").replace(/[(){}]/g, \" \").split(/[;&|\\n]+/)) {\n const words = shellWords(rawSegment)\n let i = 0\n while (i \u003c words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || [\"command\", \"noglob\", \"then\", \"do\", \"else\", \"elif\"].includes(words[i]))) i++\n if (words[i] === \"env\") {\n i++\n while (i \u003c words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || [\"-i\", \"--ignore-environment\"].includes(words[i]))) i++\n }\n if (words[i] !== \"git\") continue\n i++\n while (i \u003c words.length) {\n const token = words[i]\n if (token === \"commit\") return true\n if (valueOptions.has(token)) { i += 2; continue }\n if ([...valueOptions].some((option) => option.startsWith(\"--\") && token.startsWith(`${option}=`))) { i++; continue }\n if (token.startsWith(\"-\")) { i++; continue }\n break\n }\n }\n return false\n}\n\n// 设定/ 直属的项目级设定件:artifact-protocols.md 规定的 关系.md(正文是「# 角色关系图」)、\n// 题材定位.md,以及 文风.md、题材正文提示卡.md 等,它们本来就没有 名字/姓名 字段。\nconst SETTING_NON_CHARACTER_FILES = new Set([\"关系.md\", \"题材定位.md\", \"题材正文提示卡.md\", \"文风.md\", \"世界规则.md\", \"世界观.md\", \"金手指.md\", \"背景设定.md\"])\n\n// 只查角色卡:整棵 设定/ 一刀切会让每次碰设定的提交都刷一屏假警告,把同框的\n// 「正文硬编码角色属性」真警告埋掉。判定口径与 validate-story-commit.sh / opencode\n// pre-commit.sh 的 case 分支一一对齐(bash↔js↔py 四端同口径,别单边改回一刀切):\n// ① 设定/角色|人物 子目录内的文件 → 角色卡;\n// ② 其余 设定/\u003c子目录>/ → 整目录跳过(世界观/势力/报告/原理/人物关系 等);\n// ③ 设定/ 直属的扁平文件 → 除已知项目级设定件外都算角色卡(主角.md/配角.md/反派.md 等自定义命名)。\n// bash 的 `*` 跨 `/` 匹配,`设定/角色/*|*/设定/角色/*` 等价于「路径里存在某个 设定 目录段满足该\n// 分支」,所以两趟扫描(先全路径找分支①,再全路径找分支②)而不是只看第一个 设定 段就定分支——\n// 后者在 设定/其他/设定/角色/x.md 这类嵌套路径上会与 bash 判定分叉。\nfunction isCharacterSheetPath(relative) {\n const segments = relative.split(\"/\")\n const last = segments.length - 1\n // 分支①:某个 设定 段紧跟 角色/人物,且其下还有文件段\n for (let i = 0; i + 1 \u003c last; i++) {\n if (segments[i] === \"设定\" && (segments[i + 1] === \"角色\" || segments[i + 1] === \"人物\")) return true\n }\n // 分支②:某个 设定 段后还有 ≥2 段,即落在非角色子目录里\n for (let i = 0; i + 1 \u003c last; i++) {\n if (segments[i] === \"设定\") return false\n }\n // 分支③:设定 直属扁平文件(分支②已排掉更深的路径,设定 段只能是倒数第二段)\n return last >= 1 && segments[last - 1] === \"设定\" && !SETTING_NON_CHARACTER_FILES.has(segments[last])\n}\n\nfunction stagedMarkdownWarnings(root) {\n let output\n try {\n output = spawnSync(\"git\", [\"-C\", root, \"-c\", \"core.quotepath=false\", \"diff\", \"--cached\", \"--relative\", \"--name-only\", \"--diff-filter=ACM\", \"-z\", \"--\", \".\"], {\n encoding: \"buffer\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n // Windows: without this, git flashes a console window every time the hook runs.\n windowsHide: true,\n })\n if (output.status !== 0 || !output.stdout) return \"\"\n } catch {\n return \"\"\n }\n const warnings = []\n for (const relative of output.stdout.toString(\"utf8\").split(\"\\0\").filter(Boolean)) {\n if (!relative.endsWith(\".md\")) continue\n const full = path.join(root, relative)\n let text = \"\"\n try { text = fs.readFileSync(full, \"utf8\") } catch { continue }\n if (relative === \"正文.md\" || relative.includes(\"/正文.md\") || relative.startsWith(\"正文/\") || relative.includes(\"/正文/\")) {\n const hits = []\n text.split(/\\r?\\n/).forEach((line, index) => {\n if (/(身高|体重|年龄)[\\s ]*(:|:)[\\s ]*[0-9]+/.test(line)) hits.push(`${index + 1}:${line}`)\n })\n if (hits.length) warnings.push(`⚠ ${relative}: 正文硬编码角色属性,应引用设定文件:\\n${hits.join(\"\\n\")}`)\n }\n if (isCharacterSheetPath(relative) && !/^[\\s ]*(名字|姓名|名称|name)[\\s ]*(:|:)/im.test(text)) {\n warnings.push(`⚠ ${relative}: 设定文件缺少 name/名字 必填字段。`)\n }\n }\n return warnings.length ? `=== Story Commit Warnings(advisory only)===\\n${warnings.join(\"\\n\")}\\n=== End Warnings ===` : \"\"\n}\n\nmodule.exports = {\n existingDir,\n safeRelative,\n resolveTarget,\n firstLine,\n findFirst,\n discoverActiveBook,\n discoverAllBooks,\n trackingCheckpointIssue,\n continuityFindings,\n extractProseTargets,\n extractPatchTargets,\n proseBlockReason,\n isProsePath,\n duplicateTitleFindings,\n proseAfterWrite,\n shellWords,\n isGitCommitCommand,\n stagedMarkdownWarnings,\n TERMINAL,\n QUOTE_OPENERS,\n SOFT_PATTERNS,\n HARD_PATTERNS,\n skippableLine,\n proseNetFindings,\n maskQuotedSpans,\n toxicPhraseFindings,\n toxicRescanHint,\n loadStyleWhitelist,\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":58748,"content_sha256":"adf7df161b2a326b073224b4a445a7920fa7301bb74c33d69c002ca71dc5d5c6"},{"filename":"references/templates/settings-hooks.json","content":"{\n \"hooks\": {\n \"SessionStart\": [\n {\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"bash \\\"$CLAUDE_PROJECT_DIR\\\"/.claude/hooks/session-start.sh\",\n \"timeout\": 10\n },\n {\n \"type\": \"command\",\n \"command\": \"bash \\\"$CLAUDE_PROJECT_DIR\\\"/.claude/hooks/detect-story-gaps.sh\",\n \"timeout\": 10\n }\n ]\n }\n ],\n \"SessionEnd\": [\n {\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"bash \\\"$CLAUDE_PROJECT_DIR\\\"/.claude/hooks/session-end.sh\",\n \"timeout\": 5\n }\n ]\n }\n ],\n \"PreToolUse\": [\n {\n \"matcher\": \"Bash\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"bash \\\"$CLAUDE_PROJECT_DIR\\\"/.claude/hooks/validate-story-commit.sh\",\n \"timeout\": 15,\n \"if\": \"Bash(git commit*)\"\n }\n ]\n },\n {\n \"matcher\": \"Bash|Write|Edit|MultiEdit\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"bash \\\"$CLAUDE_PROJECT_DIR\\\"/.claude/hooks/guard-outline-before-prose.sh\",\n \"timeout\": 10\n }\n ]\n }\n ],\n \"PostToolUse\": [\n {\n \"matcher\": \"Write|Edit|MultiEdit\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"bash \\\"$CLAUDE_PROJECT_DIR\\\"/.claude/hooks/check-prose-after-write.sh\",\n \"timeout\": 15\n }\n ]\n }\n ],\n \"PreCompact\": [\n {\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"bash \\\"$CLAUDE_PROJECT_DIR\\\"/.claude/hooks/pre-compact.sh\",\n \"timeout\": 10\n }\n ]\n }\n ],\n \"PostCompact\": [\n {\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"bash \\\"$CLAUDE_PROJECT_DIR\\\"/.claude/hooks/post-compact.sh\",\n \"timeout\": 10\n }\n ]\n }\n ]\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":2025,"content_sha256":"320f060a02d466c0e53e74d2ef9bce48f46703443ee21574a73f57120e090eb7"},{"filename":"scripts/copy-path-safety.py","content":"#!/usr/bin/env python3\n\"\"\"Classify a recursive deployment copy before any filesystem mutation.\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport sys\nfrom pathlib import Path\nfrom typing import Dict, Optional, Tuple\n\n\ndef canonical_path(raw_path: str) -> Path:\n \"\"\"Return an absolute path with existing symlinks resolved.\"\"\"\n\n return Path(os.path.realpath(os.path.abspath(os.path.expanduser(raw_path))))\n\n\ndef stat_path(path: Path) -> Tuple[Optional[bool], Optional[OSError]]:\n try:\n path.stat()\n except FileNotFoundError:\n return False, None\n except OSError as exc:\n return None, exc\n return True, None\n\n\ndef is_descendant(path: Path, parent: Path) -> bool:\n path_key = os.path.normcase(str(path))\n parent_key = os.path.normcase(str(parent))\n if path_key == parent_key:\n return False\n try:\n return os.path.commonpath((path_key, parent_key)) == parent_key\n except ValueError:\n # Different Windows drives cannot contain one another.\n return False\n\n\ndef classify_copy(source_raw: str, target_raw: str) -> Dict[str, object]:\n source = canonical_path(source_raw)\n target = canonical_path(target_raw)\n result: Dict[str, object] = {\n \"source_realpath\": str(source),\n \"target_realpath\": str(target),\n \"copy_allowed\": False,\n }\n\n source_exists, source_error = stat_path(source)\n if source_error is not None:\n result[\"status\"] = \"filesystem_identity_error\"\n result[\"error\"] = str(source_error)\n return result\n if not source_exists:\n result[\"status\"] = \"source_missing\"\n return result\n\n source_key = os.path.normcase(str(source))\n target_key = os.path.normcase(str(target))\n if source_key == target_key:\n result[\"status\"] = \"same\"\n return result\n\n target_exists, target_error = stat_path(target)\n if target_error is not None:\n result[\"status\"] = \"filesystem_identity_error\"\n result[\"error\"] = str(target_error)\n return result\n if target_exists:\n try:\n same_object = os.path.samefile(source, target)\n except OSError as exc:\n result[\"status\"] = \"filesystem_identity_error\"\n result[\"error\"] = str(exc)\n return result\n if same_object:\n result[\"status\"] = \"same\"\n return result\n\n if is_descendant(target, source):\n result[\"status\"] = \"unsafe_target_within_source\"\n return result\n\n result[\"status\"] = \"safe\"\n result[\"copy_allowed\"] = True\n return result\n\n\ndef main() -> int:\n parser = argparse.ArgumentParser(\n description=\"Resolve symlinks and reject recursive deployment copies.\"\n )\n parser.add_argument(\"source\")\n parser.add_argument(\"target\")\n args = parser.parse_args()\n\n result = classify_copy(args.source, args.target)\n print(json.dumps(result, ensure_ascii=False, sort_keys=True))\n return status_exit_code(str(result[\"status\"]))\n\n\ndef status_exit_code(status: str) -> int:\n return 0 if status in {\"safe\", \"same\"} else 2\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":3155,"content_sha256":"62baeea5bd1adf72f6d30d2cd97d2d055cd1006629ac288dc0fad98c2dc990cd"},{"filename":"scripts/deploy-antigravity-skills.py","content":"#!/usr/bin/env python3\n\"\"\"Materialize oh-story skills into a project-local Antigravity skill root.\n\nOnly the 14 known oh-story directories are replaced. Unknown user skills are\npreserved. An existing ``.agents/skills`` symlink is never followed for writes;\nit can be materialized only after the caller explicitly opts in.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport os\nimport shutil\nimport tempfile\nfrom pathlib import Path\n\n\nKNOWN_SKILLS = (\n \"browser-cdp\",\n \"story\",\n \"story-cover\",\n \"story-deslop\",\n \"story-import\",\n \"story-long-analyze\",\n \"story-long-scan\",\n \"story-long-write\",\n \"story-publish\",\n \"story-review\",\n \"story-setup\",\n \"story-short-analyze\",\n \"story-short-scan\",\n \"story-short-write\",\n)\n\n\nclass DeployError(ValueError):\n pass\n\n\ndef copy_entry(source: Path, destination: Path, *, dereference: bool) -> None:\n if source.is_dir() and (dereference or not source.is_symlink()):\n shutil.copytree(source, destination, symlinks=not dereference)\n elif source.is_symlink() and not dereference:\n destination.symlink_to(os.readlink(source), target_is_directory=source.is_dir())\n else:\n shutil.copy2(source, destination, follow_symlinks=dereference)\n\n\ndef validate_source(source: Path) -> None:\n if not source.is_dir():\n raise DeployError(f\"source skill root is missing: {source}\")\n for name in KNOWN_SKILLS:\n skill = source / name\n if not skill.is_dir() or not (skill / \"SKILL.md\").is_file():\n raise DeployError(f\"source skill is incomplete: {skill}\")\n\n\ndef deploy(source: Path, destination: Path, *, migrate_symlink: bool) -> str:\n source = source.resolve()\n validate_source(source)\n destination = destination.absolute()\n\n if destination.exists() and not destination.is_symlink() and destination.samefile(source):\n return \"same-object no-op\"\n if destination.is_symlink() and not migrate_symlink:\n raise DeployError(\n f\"destination is a symlink: {destination}; rerun only after explicit symlink migration approval\"\n )\n if destination.exists() and not destination.is_dir():\n raise DeployError(f\"destination must be a directory: {destination}\")\n\n destination.parent.mkdir(parents=True, exist_ok=True)\n staging = Path(tempfile.mkdtemp(prefix=f\".{destination.name}.staging-\", dir=destination.parent))\n backup = destination.with_name(f\"{destination.name}.backup-{os.getpid()}\")\n original_link = os.readlink(destination) if destination.is_symlink() else None\n existing_root = destination.resolve() if destination.is_symlink() else destination\n moved_existing = False\n try:\n if existing_root.exists():\n if not existing_root.is_dir():\n raise DeployError(f\"symlink target must be a directory: {existing_root}\")\n for entry in existing_root.iterdir():\n copy_entry(entry, staging / entry.name, dereference=False)\n\n for name in KNOWN_SKILLS:\n target = staging / name\n if target.is_symlink() or target.exists():\n if target.is_dir() and not target.is_symlink():\n shutil.rmtree(target)\n else:\n target.unlink()\n copy_entry(source / name, target, dereference=True)\n\n if backup.exists() or backup.is_symlink():\n raise DeployError(f\"stale deployment backup exists: {backup}\")\n if destination.is_symlink():\n destination.unlink()\n elif destination.exists():\n destination.rename(backup)\n moved_existing = True\n staging.rename(destination)\n if moved_existing:\n shutil.rmtree(backup)\n return \"materialized\"\n except Exception:\n if not destination.exists() and not destination.is_symlink():\n if moved_existing and backup.exists():\n backup.rename(destination)\n elif original_link is not None:\n destination.symlink_to(original_link, target_is_directory=True)\n raise\n finally:\n if staging.exists():\n shutil.rmtree(staging)\n if backup.exists():\n shutil.rmtree(backup)\n\n\ndef main() -> int:\n parser = argparse.ArgumentParser()\n parser.add_argument(\"--source\", required=True, type=Path)\n parser.add_argument(\"--dest\", required=True, type=Path)\n parser.add_argument(\"--migrate-symlink\", action=\"store_true\")\n args = parser.parse_args()\n print(deploy(args.source, args.dest, migrate_symlink=args.migrate_symlink))\n return 0\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4654,"content_sha256":"bc910ba26520ab2639bbb49f0d9e00a126b41cf74916e987284630a23ac1bcdd"},{"filename":"scripts/generate-antigravity-agents.mjs","content":"#!/usr/bin/env node\n\"use strict\"\n\n// Generate Antigravity 2.0 Markdown agents from the Claude Markdown source of\n// truth. Antigravity and Claude both use Markdown bodies, but their frontmatter,\n// tool names, model tiers, and deployed reference roots differ.\n\nimport fs from \"node:fs\"\nimport path from \"node:path\"\nimport process from \"node:process\"\nimport { fileURLToPath } from \"node:url\"\n\nconst SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))\nconst DEFAULT_SOURCE = path.resolve(SCRIPT_DIR, \"../references/templates/agents\")\nconst DEFAULT_DEST = path.resolve(SCRIPT_DIR, \"../references/antigravity/agents\")\n\nconst TOOL_MAP = new Map([\n [\"Read\", [\"view_file\"]],\n [\"Glob\", [\"find_by_name\"]],\n [\"Grep\", [\"grep_search\"]],\n [\"Write\", [\"write_to_file\"]],\n [\"Edit\", [\"replace_file_content\", \"multi_replace_file_content\"]],\n [\"Bash\", [\"run_command\"]],\n])\nconst CAPABILITY_FIELDS = new Set([\"tools\", \"disallowedTools\"])\nconst CAPABILITY_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]*$/\n\nfunction fail(message) {\n process.stderr.write(`generate-antigravity-agents: ${message}\\n`)\n process.exit(2)\n}\n\nfunction parseArgs(argv) {\n const result = { source: DEFAULT_SOURCE, dest: DEFAULT_DEST }\n for (let index = 0; index \u003c argv.length; index++) {\n const token = argv[index]\n if (token === \"--source\" || token === \"--dest\") {\n const value = argv[++index]\n if (!value) fail(`${token} requires a path`)\n result[token.slice(2)] = path.resolve(value)\n } else {\n fail(`unknown argument: ${token}`)\n }\n }\n return result\n}\n\nfunction parseFrontmatter(text, source) {\n const normalized = text.replace(/^\\uFEFF/, \"\").replace(/\\r\\n?/g, \"\\n\")\n if (!normalized.startsWith(\"---\\n\")) fail(`${source}: missing frontmatter`)\n const end = normalized.indexOf(\"\\n---\\n\", 4)\n if (end \u003c 0) fail(`${source}: unterminated frontmatter`)\n const raw = normalized.slice(4, end)\n const body = normalized.slice(end + 5).replace(/^\\s+/, \"\")\n const data = {}\n const lines = raw.split(/\\r?\\n/)\n for (let index = 0; index \u003c lines.length; index++) {\n const match = lines[index].match(/^([A-Za-z_][A-Za-z0-9_-]*):(?:\\s*(.*))?$/)\n if (!match) continue\n const [, key, rawValue = \"\"] = match\n if (rawValue.trim() === \"|\") {\n const block = []\n while (index + 1 \u003c lines.length && /^(?:\\s|$)/.test(lines[index + 1])) {\n const line = lines[++index]\n block.push(line.startsWith(\" \") ? line.slice(2) : line.trimStart())\n }\n data[key] = block.join(\"\\n\").trim()\n } else {\n const value = rawValue.trim()\n data[key] = CAPABILITY_FIELDS.has(key)\n ? value\n : value.replace(/^['\"]|['\"]$/g, \"\")\n }\n }\n return { data, body }\n}\n\nfunction parseCapabilityList(value, field, source) {\n if (value === undefined) return []\n if (!value) fail(`${source}: ${field}: empty or block-style capability declarations are unsupported`)\n const match = String(value).match(/^\\[\\s*(.*?)\\s*\\]$/)\n if (!match) fail(`${source}: ${field}: expected an inline list like [Read, Glob]`)\n if (!match[1].trim()) return []\n return match[1].split(\",\").map((rawItem) => {\n let item = rawItem.trim()\n if (!item) fail(`${source}: ${field}: empty capability-list item`)\n if (item[0] === '\"' || item[0] === \"'\") {\n const quote = item[0]\n if (item.length \u003c 2 || item.at(-1) !== quote || item.slice(1, -1).includes(quote)) {\n fail(`${source}: ${field}: malformed quoted capability ${JSON.stringify(item)}`)\n }\n item = item.slice(1, -1)\n } else if (item.includes('\"') || item.includes(\"'\")) {\n fail(`${source}: ${field}: malformed quoted capability ${JSON.stringify(item)}`)\n }\n if (!CAPABILITY_NAME_RE.test(item)) {\n fail(`${source}: ${field}: invalid capability name ${JSON.stringify(item)}`)\n }\n if (!TOOL_MAP.has(item)) {\n fail(`${source}: ${field}: unsupported Antigravity capability ${JSON.stringify(item)}`)\n }\n return item\n })\n}\n\nfunction parseInlineList(value) {\n const match = String(value || \"\").match(/^\\[(.*)\\]$/)\n if (!match) return []\n return match[1].split(\",\").map((item) => item.trim().replace(/^['\"]|['\"]$/g, \"\")).filter(Boolean)\n}\n\nfunction adaptBody(body, name, tools) {\n let adapted = body\n .replaceAll(\".claude/skills/story-setup/references/agent-references/\", \".agents/skills/story-setup/references/agent-references/\")\n .replaceAll(\"当前 Claude 部署\", \"当前 Antigravity 部署\")\n .replaceAll(\"Claude Code subagent\", \"Antigravity custom subagent\")\n .replaceAll(\"subagent_type\", \"TypeName\")\n\n for (const [source, targets] of TOOL_MAP) {\n adapted = adapted.replace(new RegExp(`\\\\b${source}\\\\b`, \"g\"), targets.join(\"/\"))\n }\n\n if (name === \"story-researcher\") {\n adapted = adapted\n .replace(\"WebSearch/webReader 作为兜底。\", \"CDP 不可用时返回结构化缺口,由父会话完成联网检索。\")\n .replace(\"**核心原则:CDP 优先,WebSearch 兜底。**\", \"**核心原则:只在 CDP 可用时研究;否则交回父会话。**\")\n .replace(\"CDP 能打开真实页面拿到完整正文;WebSearch 只返回摘要节选,信息量远不如全文。\", \"Antigravity custom subagent 只通过 `run_command` 使用 CDP;不假定存在额外联网工具。\")\n .replace(\"3. WebSearch / webReader → 兜底(CDP 不可用或页面打不开时)\", \"3. CDP 不可用或页面打不开 → 返回结构化 research gap,由父会话继续\")\n .replaceAll(\"直接降级到 WebSearch/webReader\", \"返回结构化 research gap,由父会话继续\")\n .replaceAll(\"降级到 WebSearch/webReader 兜底\", \"返回结构化 research gap,由父会话继续\")\n .replaceAll(\"降级到 WebSearch\", \"返回结构化 research gap,由父会话继续\")\n .replace(\n /### 第四步:WebSearch\\/webReader(兜底)[\\s\\S]*?(?=\\n### 第五步:整理输出)/,\n \"### 第四步:CDP 不可用时交回父会话\\n\\n返回 `status: \\\"failed\\\"`,在 `gaps` 中说明 CDP 不可用或页面提取失败,并给出建议检索词与候选来源类型。不要自行声称已调用联网兜底,也不要编造内容。\\n\",\n )\n .replace(\"{google | bing | websearch}\", \"{google | bing}\")\n }\n\n const researchNote = name === \"story-researcher\"\n ? \"- This custom subagent has no WebSearch/webReader tool. Prefer CDP through `run_command`; if CDP is unavailable, return a structured research gap to the parent agent and never claim that web fallback ran.\\n\"\n : \"\"\n\n return `${adapted.trimEnd()}\\n\\n---\\n\\nAntigravity adaptation notes:\\n` +\n `- Call this agent through \\`invoke_subagent\\` with \\`TypeName: \"${name}\"\\`.\\n` +\n \"- If the Antigravity runtime does not expose custom subagents, fall back to solo/direct execution and report the fallback.\\n\" +\n \"- Stay within this agent's role boundary; return adjacent work to the parent agent.\\n\" +\n \"- Use only `.agents/skills/story-setup/references/agent-references/`; if it is missing, report an incomplete deployment instead of probing other CLI roots.\\n\" +\n \"- Antigravity tool names and frontmatter are authoritative; do not assume Claude-only fields exist.\\n\" +\n researchNote\n}\n\nfunction renderAgent(sourceFile) {\n const text = fs.readFileSync(sourceFile, \"utf8\")\n const { data, body } = parseFrontmatter(text, sourceFile)\n const name = data.name || path.basename(sourceFile, \".md\")\n if (name !== path.basename(sourceFile, \".md\") || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) {\n fail(`${sourceFile}: unsafe or mismatched agent name ${JSON.stringify(name)}`)\n }\n if (!data.description) fail(`${sourceFile}: missing description`)\n if (data.tools === undefined) fail(`${sourceFile}: tools: Antigravity requires an explicit tool list`)\n const sourceTools = parseCapabilityList(data.tools, \"tools\", sourceFile)\n const deniedTools = new Set(parseCapabilityList(data.disallowedTools, \"disallowedTools\", sourceFile))\n const effectiveSourceTools = sourceTools.filter((tool) => !deniedTools.has(tool))\n const tools = [...new Set(effectiveSourceTools.flatMap((tool) => TOOL_MAP.get(tool)))]\n if (!tools.length) fail(`${sourceFile}: no supported Antigravity tools mapped`)\n const sourceModel = String(data.model || \"\").toLowerCase()\n const model = sourceModel === \"haiku\" ? \"flash\" : \"pro\"\n const sourceSkills = parseInlineList(data.skills)\n const description = name === \"story-researcher\"\n ? String(data.description).replace(\"WebSearch/webReader 作为兜底\", \"CDP 不可用时返回缺口,由父会话联网检索\")\n : String(data.description)\n\n const frontmatter = [\n \"---\",\n `name: ${name}`,\n \"description: |\",\n ...description.split(\"\\n\").map((line) => ` ${line}`),\n \"tools:\",\n ...tools.map((tool) => ` - ${tool}`),\n \"mainAgent: false\",\n \"subagent: true\",\n `model: ${model}`,\n \"commandExecutionPolicy: sandbox\",\n ]\n if (sourceSkills.length) {\n frontmatter.push(\"skills:\", ...sourceSkills.map((skill) => ` - skills/${skill}`))\n }\n frontmatter.push(\"---\", \"\")\n return `${frontmatter.join(\"\\n\")}\\n${adaptBody(body, name, tools)}`\n}\n\nfunction prepareManagedDirectory(target) {\n try {\n const info = fs.lstatSync(target)\n if (info.isSymbolicLink() || !info.isDirectory()) {\n fs.rmSync(target, { recursive: true, force: true })\n }\n } catch (error) {\n if (error?.code !== \"ENOENT\") throw error\n }\n fs.mkdirSync(target, { recursive: true })\n}\n\nfunction publish(rendered, destination) {\n if (fs.existsSync(destination) && fs.lstatSync(destination).isSymbolicLink()) {\n fail(`destination directory must not be a symlink: ${destination}`)\n }\n if (fs.existsSync(destination) && !fs.statSync(destination).isDirectory()) {\n fail(`destination must be a directory: ${destination}`)\n }\n fs.mkdirSync(path.dirname(destination), { recursive: true })\n const staging = fs.mkdtempSync(path.join(path.dirname(destination), `.${path.basename(destination)}.staging-`))\n const existed = fs.existsSync(destination)\n const replacement = `${destination}.replacement-${process.pid}`\n const backup = `${destination}.backup-${process.pid}`\n try {\n if (existed) {\n for (const entry of fs.readdirSync(destination)) {\n fs.cpSync(path.join(destination, entry), path.join(staging, entry), { recursive: true, force: true })\n }\n }\n for (const [relative, content] of rendered) {\n const name = relative.split(path.sep)[0]\n fs.rmSync(path.join(staging, `${name}.md`), { recursive: true, force: true })\n const agentDir = path.join(staging, name)\n prepareManagedDirectory(agentDir)\n const target = path.join(staging, relative)\n fs.rmSync(target, { recursive: true, force: true })\n fs.writeFileSync(target, content, \"utf8\")\n }\n fs.rmSync(replacement, { recursive: true, force: true })\n fs.rmSync(backup, { recursive: true, force: true })\n fs.renameSync(staging, replacement)\n if (existed) fs.renameSync(destination, backup)\n fs.renameSync(replacement, destination)\n fs.rmSync(backup, { recursive: true, force: true })\n } catch (error) {\n if (!fs.existsSync(destination) && fs.existsSync(backup)) fs.renameSync(backup, destination)\n throw error\n } finally {\n fs.rmSync(staging, { recursive: true, force: true })\n fs.rmSync(replacement, { recursive: true, force: true })\n fs.rmSync(backup, { recursive: true, force: true })\n }\n}\n\nfunction main() {\n const { source, dest } = parseArgs(process.argv.slice(2))\n if (!fs.existsSync(source) || !fs.statSync(source).isDirectory()) fail(`source directory missing: ${source}`)\n const sources = fs.readdirSync(source).filter((name) => name.endsWith(\".md\")).sort()\n if (!sources.length) fail(`source directory contains no Markdown agents: ${source}`)\n const rendered = new Map(sources.map((filename) => [\n path.join(path.basename(filename, \".md\"), \"agent.md\"),\n renderAgent(path.join(source, filename)),\n ]))\n publish(rendered, dest)\n process.stdout.write(`Generated ${rendered.size} Antigravity agents in ${dest}\\n`)\n}\n\nmain()\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":12021,"content_sha256":"43c152ec6d50d0621ad0b87570a68fe14bb690f7f20d561f928002cbe3732f50"},{"filename":"scripts/merge-antigravity-hooks.py","content":"#!/usr/bin/env python3\n\"\"\"Merge the managed oh-story Antigravity hook group atomically.\n\nAntigravity's workspace hooks file is a top-level mapping of named hook groups.\nstory-setup owns only the ``oh-story`` key and preserves every user group.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport stat\nimport tempfile\nfrom pathlib import Path\nfrom typing import Any\n\n\nclass MergeError(ValueError):\n pass\n\n\ndef load_object(path: Path, *, missing_ok: bool) -> dict[str, Any]:\n if missing_ok and not path.exists():\n return {}\n try:\n value = json.loads(path.read_text(encoding=\"utf-8-sig\"))\n except (OSError, json.JSONDecodeError) as exc:\n raise MergeError(f\"cannot read valid JSON object from {path}: {exc}\") from exc\n if not isinstance(value, dict):\n raise MergeError(f\"expected a JSON object in {path}\")\n return value\n\n\ndef merge(existing: dict[str, Any], template: dict[str, Any]) -> dict[str, Any]:\n managed = template.get(\"oh-story\")\n if not isinstance(managed, dict):\n raise MergeError(\"template must contain an object-valued oh-story group\")\n result = dict(existing)\n result[\"oh-story\"] = managed\n return result\n\n\ndef atomic_write(path: Path, value: dict[str, Any]) -> None:\n path.parent.mkdir(parents=True, exist_ok=True)\n mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644\n descriptor, temporary = tempfile.mkstemp(prefix=f\".{path.name}.\", dir=path.parent)\n try:\n with os.fdopen(descriptor, \"w\", encoding=\"utf-8\", newline=\"\\n\") as handle:\n json.dump(value, handle, ensure_ascii=False, indent=2)\n handle.write(\"\\n\")\n handle.flush()\n os.fsync(handle.fileno())\n os.chmod(temporary, mode)\n os.replace(temporary, path)\n finally:\n try:\n os.unlink(temporary)\n except FileNotFoundError:\n pass\n\n\ndef main() -> int:\n parser = argparse.ArgumentParser()\n parser.add_argument(\"existing\", type=Path)\n parser.add_argument(\"template\", type=Path)\n args = parser.parse_args()\n existing = load_object(args.existing, missing_ok=True)\n template = load_object(args.template, missing_ok=False)\n atomic_write(args.existing, merge(existing, template))\n return 0\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":2360,"content_sha256":"48d8a6d8caf0a9a8fd5137d150604e8bb704e66bb2845d6abebbcc8df8b25f6c"},{"filename":"scripts/merge-claude-settings.py","content":"#!/usr/bin/env python3\n\"\"\"Merge current story-setup Claude hooks without preserving stale matchers.\n\nOnly registrations whose command invokes a known story-setup hook script are\nmanaged. They are removed from their historical event/matcher block before the\ncurrent template is appended; unrelated hooks and top-level settings survive.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport copy\nimport json\nimport os\nimport stat\nimport sys\nimport tempfile\nfrom pathlib import Path\nfrom typing import Any\n\n\nMANAGED_HOOK_SCRIPTS = frozenset(\n {\n \"check-prose-after-write.sh\",\n \"detect-story-gaps.sh\",\n \"guard-outline-before-prose.sh\",\n \"post-compact.sh\",\n \"pre-compact.sh\",\n \"session-end.sh\",\n \"session-start.sh\",\n \"validate-story-commit.sh\",\n }\n)\n\n\nclass MergeError(ValueError):\n pass\n\n\ndef is_story_setup_hook(hook: object) -> bool:\n if not isinstance(hook, dict) or not isinstance(hook.get(\"command\"), str):\n return False\n normalized = hook[\"command\"].replace(\"\\\\\", \"/\")\n return any(f\"/.claude/hooks/{name}\" in normalized for name in MANAGED_HOOK_SCRIPTS)\n\n\ndef require_hooks(document: object, label: str) -> dict[str, Any]:\n if not isinstance(document, dict):\n raise MergeError(f\"{label} root must be a JSON object\")\n hooks = document.get(\"hooks\", {})\n if not isinstance(hooks, dict):\n raise MergeError(f\"{label}.hooks must be a JSON object\")\n return hooks\n\n\ndef strip_managed_registrations(hooks: dict[str, Any]) -> dict[str, Any]:\n cleaned: dict[str, Any] = {}\n for event, raw_blocks in hooks.items():\n if not isinstance(raw_blocks, list):\n cleaned[event] = copy.deepcopy(raw_blocks)\n continue\n blocks: list[Any] = []\n for raw_block in raw_blocks:\n if not isinstance(raw_block, dict) or not isinstance(raw_block.get(\"hooks\"), list):\n blocks.append(copy.deepcopy(raw_block))\n continue\n block = copy.deepcopy(raw_block)\n block[\"hooks\"] = [hook for hook in block[\"hooks\"] if not is_story_setup_hook(hook)]\n if not block[\"hooks\"]:\n continue\n blocks.append(block)\n if blocks:\n cleaned[event] = blocks\n return cleaned\n\n\ndef merge_documents(existing: object, template: object) -> dict[str, Any]:\n existing_hooks = require_hooks(existing, \"existing\")\n template_hooks = require_hooks(template, \"template\")\n for event, blocks in template_hooks.items():\n if not isinstance(blocks, list):\n raise MergeError(f\"template.hooks.{event} must be an array\")\n\n result = copy.deepcopy(existing)\n assert isinstance(result, dict)\n merged = strip_managed_registrations(existing_hooks)\n for event, blocks in template_hooks.items():\n if event in merged and not isinstance(merged[event], list):\n raise MergeError(f\"existing.hooks.{event} must be an array\")\n merged.setdefault(event, []).extend(copy.deepcopy(blocks))\n result[\"hooks\"] = merged\n return result\n\n\ndef read_json(path: Path, *, missing_ok: bool = False) -> object:\n if missing_ok and not path.exists():\n return {}\n try:\n return json.loads(path.read_text(encoding=\"utf-8\"))\n except (OSError, json.JSONDecodeError) as exc:\n raise MergeError(f\"unable to read {path}: {exc}\") from exc\n\n\ndef atomic_write_json(path: Path, document: object) -> None:\n path.parent.mkdir(parents=True, exist_ok=True)\n mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644\n payload = json.dumps(document, ensure_ascii=False, indent=2) + \"\\n\"\n fd, temporary_name = tempfile.mkstemp(prefix=f\".{path.name}.\", suffix=\".tmp\", dir=path.parent)\n temporary = Path(temporary_name)\n try:\n with os.fdopen(fd, \"w\", encoding=\"utf-8\", newline=\"\\n\") as handle:\n handle.write(payload)\n handle.flush()\n os.fsync(handle.fileno())\n os.chmod(temporary, mode)\n os.replace(temporary, path)\n finally:\n temporary.unlink(missing_ok=True)\n\n\ndef main() -> int:\n parser = argparse.ArgumentParser(description=__doc__)\n parser.add_argument(\"--existing\", type=Path, required=True)\n parser.add_argument(\"--template\", type=Path, required=True)\n parser.add_argument(\"--output\", type=Path, required=True)\n args = parser.parse_args()\n try:\n atomic_write_json(\n args.output,\n merge_documents(read_json(args.existing, missing_ok=True), read_json(args.template)),\n )\n except MergeError as exc:\n print(f\"ERROR: {exc}\", file=sys.stderr)\n return 2\n return 0\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4743,"content_sha256":"b5ff9ff1045dcd4b64f8e4fcf604f68ea72ec65a41fc92a031c4f73bb6f19932"},{"filename":"scripts/merge-codex-hooks.py","content":"#!/usr/bin/env python3\n\"\"\"Merge current story-setup Codex hooks without preserving stale registrations.\n\nExisting project hook configuration is user-owned. Only command hooks that invoke\nstory-setup's known Python hook or shared launchers are replaced; unrelated hooks\nand top-level configuration are preserved verbatim.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport copy\nimport json\nimport os\nimport stat\nimport sys\nimport tempfile\nfrom pathlib import Path\nfrom typing import Any\n\n\nMANAGED_COMMAND_MARKERS = (\n \".codex/hooks/story_codex_hook.py\",\n \".codex/hooks/run-story-hook.sh\",\n \".codex/hooks/run-story-hook.cmd\",\n)\n\n\nclass MergeError(ValueError):\n pass\n\n\ndef normalized_command(value: object) -> str:\n return value.lower().replace(\"\\\\\", \"/\") if isinstance(value, str) else \"\"\n\n\ndef is_story_setup_hook(hook: object) -> bool:\n if not isinstance(hook, dict):\n return False\n commands = (\n normalized_command(hook.get(\"command\")),\n normalized_command(hook.get(\"commandWindows\")),\n )\n return any(marker in command for marker in MANAGED_COMMAND_MARKERS for command in commands)\n\n\ndef require_hooks(document: object, label: str) -> dict[str, Any]:\n if not isinstance(document, dict):\n raise MergeError(f\"{label} root must be a JSON object\")\n hooks = document.get(\"hooks\", {})\n if not isinstance(hooks, dict):\n raise MergeError(f\"{label}.hooks must be a JSON object\")\n return hooks\n\n\ndef strip_managed_registrations(hooks: dict[str, Any]) -> dict[str, Any]:\n cleaned: dict[str, Any] = {}\n for event, raw_blocks in hooks.items():\n if not isinstance(raw_blocks, list):\n cleaned[event] = copy.deepcopy(raw_blocks)\n continue\n\n blocks: list[Any] = []\n for raw_block in raw_blocks:\n if not isinstance(raw_block, dict) or not isinstance(raw_block.get(\"hooks\"), list):\n blocks.append(copy.deepcopy(raw_block))\n continue\n kept_hooks = [\n copy.deepcopy(hook)\n for hook in raw_block[\"hooks\"]\n if not is_story_setup_hook(hook)\n ]\n if not kept_hooks:\n continue\n block = copy.deepcopy(raw_block)\n block[\"hooks\"] = kept_hooks\n blocks.append(block)\n if blocks:\n cleaned[event] = blocks\n return cleaned\n\n\ndef merge_documents(existing: object, template: object) -> dict[str, Any]:\n existing_hooks = require_hooks(existing, \"existing\")\n template_hooks = require_hooks(template, \"template\")\n for event, blocks in template_hooks.items():\n if not isinstance(blocks, list):\n raise MergeError(f\"template.hooks.{event} must be an array\")\n\n result = copy.deepcopy(existing)\n assert isinstance(result, dict) # narrowed by require_hooks\n merged_hooks = strip_managed_registrations(existing_hooks)\n for event, blocks in template_hooks.items():\n # 只在与模板事件撞名时要求用户侧是数组:未知事件的非数组值由\n # strip_managed_registrations 原样保留(保留用户已有配置的契约),\n # 撞名却无法 extend 时给出 MergeError 诊断而不是 AttributeError traceback。\n if event in merged_hooks and not isinstance(merged_hooks[event], list):\n raise MergeError(f\"existing.hooks.{event} must be an array\")\n merged_hooks.setdefault(event, []).extend(copy.deepcopy(blocks))\n result[\"hooks\"] = merged_hooks\n return result\n\n\ndef read_json(path: Path, *, missing_ok: bool = False) -> object:\n if missing_ok and not path.exists():\n return {}\n try:\n return json.loads(path.read_text(encoding=\"utf-8\"))\n except (OSError, json.JSONDecodeError) as exc:\n raise MergeError(f\"unable to read {path}: {exc}\") from exc\n\n\ndef atomic_write_json(path: Path, document: object) -> None:\n path.parent.mkdir(parents=True, exist_ok=True)\n previous_mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644\n payload = json.dumps(document, ensure_ascii=False, indent=2) + \"\\n\"\n fd, temporary_name = tempfile.mkstemp(prefix=f\".{path.name}.\", suffix=\".tmp\", dir=path.parent)\n temporary = Path(temporary_name)\n try:\n with os.fdopen(fd, \"w\", encoding=\"utf-8\", newline=\"\\n\") as handle:\n handle.write(payload)\n handle.flush()\n os.fsync(handle.fileno())\n os.chmod(temporary, previous_mode)\n os.replace(temporary, path)\n finally:\n temporary.unlink(missing_ok=True)\n\n\ndef build_parser() -> argparse.ArgumentParser:\n parser = argparse.ArgumentParser(description=__doc__)\n parser.add_argument(\"--existing\", type=Path, required=True)\n parser.add_argument(\"--template\", type=Path, required=True)\n parser.add_argument(\"--output\", type=Path, required=True)\n return parser\n\n\ndef main() -> int:\n args = build_parser().parse_args()\n try:\n existing = read_json(args.existing, missing_ok=True)\n template = read_json(args.template)\n atomic_write_json(args.output, merge_documents(existing, template))\n except MergeError as exc:\n print(f\"ERROR: {exc}\", file=sys.stderr)\n return 2\n return 0\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5316,"content_sha256":"18b12315d3e0dde5a19a91c0a2e4c17daf2e3689fe6ff9d258e31e76374dbef8"},{"filename":"scripts/merge-project-instructions.py","content":"#!/usr/bin/env python3\n\"\"\"Merge one host's oh-story block into a project instruction file.\n\nCLAUDE.md and the shared root AGENTS.md carry one managed block per host:\n\n \u003c!-- oh-story:codex 开始:重新部署时整块替换,自己的内容写在块外 -->\n ...rendered template...\n \u003c!-- oh-story:codex 结束 -->\n\nRedeploying replaces only this host's block; text outside every block and\nother hosts' blocks stay byte-identical. Files written before blocks existed\nare migrated once: the oh-story title line, its intro and the sections whose\nheadings the current template owns are removed and the block takes their place.\nA file counts as an old deployment only when an oh-story title line sits\noutside every block; once another host's block exists, only the headings unique\nto this host are still treated as leftovers. Anything else is the author's own\nfile and the block is appended.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport re\nimport sys\nimport tempfile\nfrom pathlib import Path\n\n\nHOSTS = (\"claude-code\", \"opencode\", \"codex\", \"zcode\", \"openclaw\", \"reasonix\", \"generic\")\n# 旧模板里有、现模板已删的受管节;迁移旧文件时一并清掉。\nRETIRED_HEADINGS = frozenset({\"协作规则\"})\n# 名字太常见的节,旧文件里只有带 oh-story 痕迹才算受管,避免误删作者自己的同名节。\nGENERIC_HEADINGS = frozenset({\"Skill 路由表\", \"文件结构\", \"写作约束\"})\nLEGACY_SIGNALS = (\"story-\", \"拆文库\", \"追踪/\", \"细纲\", \"作者记忆\", \"oh-story\")\n# 各宿主模板共有的节;文件里已有别的宿主的块、块外又没有旧标题时,这些节归作者。\nSHARED_HEADINGS = frozenset({\"Skill 路由表\", \"文件结构\", \"写作约束\", \"与作者协作\", \"Compact 后恢复上下文\"})\nTITLE_SIGNAL = \"网文写作工具集\"\nLEGACY_MARKER = \"\u003c!-- managed-by: story-setup -->\"\nHEADING_RE = re.compile(r\"^##\\s+(.+?)\\s*$\")\n\n\nclass MergeError(ValueError):\n pass\n\n\ndef begin_marker(host: str) -> str:\n return f\"\u003c!-- oh-story:{host} 开始:重新部署时整块替换,自己的内容写在块外 -->\"\n\n\ndef end_marker(host: str) -> str:\n return f\"\u003c!-- oh-story:{host} 结束 -->\"\n\n\ndef block_re(host: str) -> re.Pattern[str]:\n return re.compile(\n r\"^\u003c!-- oh-story:\" + re.escape(host) + r\" 开始[^\\n]*-->\\n.*?^\u003c!-- oh-story:\"\n + re.escape(host) + r\" 结束 -->[ \\t]*$\",\n re.S | re.M,\n )\n\n\nANY_BEGIN_RE = re.compile(r\"^\u003c!-- oh-story:([a-z-]+) 开始[^\\n]*-->$\", re.M)\n\n\ndef render_block(template: str, host: str, project_name: str) -> str:\n body = template.replace(\"\\r\\n\", \"\\n\").replace(\"{项目名}\", project_name).strip(\"\\n\")\n if \"\u003c!-- oh-story:\" in body:\n raise MergeError(\"template must not carry its own oh-story markers\")\n return f\"{begin_marker(host)}\\n{body}\\n{end_marker(host)}\"\n\n\ndef managed_headings(template: str) -> set[str]:\n headings = {m.group(1) for m in map(HEADING_RE.match, template.splitlines()) if m}\n return headings | set(RETIRED_HEADINGS)\n\n\ndef protected_spans(text: str) -> list[tuple[int, int]]:\n \"\"\"Character spans of every existing oh-story block (any host).\"\"\"\n spans = []\n for match in ANY_BEGIN_RE.finditer(text):\n host = match.group(1)\n end = re.compile(r\"^\u003c!-- oh-story:\" + re.escape(host) + r\" 结束 -->[ \\t]*$\", re.M).search(text, match.end())\n if end is None:\n raise MergeError(f\"unterminated oh-story:{host} block\")\n spans.append((match.start(), end.end()))\n return spans\n\n\ndef title_re(template: str) -> re.Pattern[str] | None:\n \"\"\"The template's own H1, with the project name as a wildcard.\"\"\"\n first = next((line for line in template.splitlines() if line.startswith(\"# \")), \"\")\n if TITLE_SIGNAL not in first:\n return None\n parts = [re.escape(part) for part in first.strip().split(\"{项目名}\")]\n return re.compile(\"^\" + \".*\".join(parts) + r\"\\s*$\")\n\n\ndef strip_legacy(text: str, template: str) -> tuple[str, int | None, list[str]]:\n \"\"\"Remove unmarked oh-story content owned by this template.\n\n Returns the new text, the offset where the block should go (None when\n nothing legacy was found) and the removed section titles.\n \"\"\"\n headings = managed_headings(template)\n own_title = title_re(template)\n spans = protected_spans(text)\n lines = text.split(\"\\n\")\n offsets = []\n pos = 0\n for line in lines:\n offsets.append(pos)\n pos += len(line) + 1\n\n def inside_block(index: int) -> bool:\n return any(start \u003c= offsets[index] \u003c end for start, end in spans)\n\n # 旧部署的凭据:块外有 oh-story 标题行(任一宿主)或旧版托管标记。\n outside = [line for i, line in enumerate(lines) if not inside_block(i)]\n legacy_deploy = any(\n (line.startswith(\"# \") and TITLE_SIGNAL in line) or line.strip() == LEGACY_MARKER for line in outside\n )\n if not legacy_deploy:\n if not spans:\n return text, None, []\n # 已有别的宿主的块:共有节在先迁移的宿主那里清过了,剩下的只可能是本宿主独有的节。\n headings = headings - SHARED_HEADINGS - RETIRED_HEADINGS\n\n # 把块外的行切成「本宿主标题行 + 引言」与「## 节」两类片段。\n pieces: list[tuple[str, int, int]] = [] # (title, start_line, end_line_exclusive)\n index = 0\n while index \u003c len(lines):\n if inside_block(index):\n index += 1\n continue\n line = lines[index]\n if own_title is not None and own_title.match(line):\n end = index + 1\n while end \u003c len(lines) and not lines[end].startswith(\"#\") and not inside_block(end):\n end += 1\n pieces.append((\"#title\", index, end))\n index = end\n continue\n heading = HEADING_RE.match(line)\n if heading:\n end = index + 1\n while end \u003c len(lines) and not lines[end].startswith(\"# \") and not HEADING_RE.match(lines[end]) and not inside_block(end):\n end += 1\n pieces.append((heading.group(1), index, end))\n index = end\n continue\n index += 1\n\n removed_ranges: list[tuple[int, int]] = []\n removed_titles: list[str] = []\n for title, start, end in pieces:\n body = \"\\n\".join(lines[start:end])\n if title == \"#title\":\n removed_ranges.append((start, end))\n removed_titles.append(lines[start].lstrip(\"# \").strip())\n elif title in headings and (\n title not in GENERIC_HEADINGS or any(signal in body for signal in LEGACY_SIGNALS)\n ):\n removed_ranges.append((start, end))\n removed_titles.append(title)\n if not removed_ranges:\n return text, None, []\n drop = set()\n for start, end in removed_ranges:\n drop.update(range(start, end))\n first = min(start for start, _ in removed_ranges)\n kept_before = [line for i, line in enumerate(lines[:first]) if i not in drop]\n kept_after = [line for i, line in enumerate(lines) if i >= first and i not in drop]\n head = \"\\n\".join(kept_before)\n insert_at = len(head) + (1 if kept_before else 0)\n new_text = head + (\"\\n\" if kept_before else \"\") + \"\\n\".join(kept_after)\n return new_text, insert_at, removed_titles\n\n\ndef tidy(text: str) -> str:\n text = re.sub(r\"\\n{3,}\", \"\\n\\n\", text)\n return text.strip(\"\\n\") + \"\\n\"\n\n\ndef merge(existing: str, template: str, host: str, project_name: str) -> tuple[str, dict]:\n block = render_block(template, host, project_name)\n crlf = existing.count(\"\\r\\n\") > existing.count(\"\\n\") / 2 if existing else False\n text = existing.replace(\"\\r\\n\", \"\\n\")\n report: dict = {\"host\": host, \"removed_legacy_sections\": []}\n if not text.strip():\n report[\"status\"] = \"created\"\n merged = block + \"\\n\"\n else:\n protected_spans(text) # 先校验已有块都闭合\n pattern = block_re(host)\n matches = list(pattern.finditer(text))\n if len(matches) > 1:\n raise MergeError(f\"more than one oh-story:{host} block\")\n if matches:\n report[\"status\"] = \"replaced\"\n merged = tidy(text[: matches[0].start()] + block + text[matches[0].end():])\n else:\n stripped, insert_at, removed = strip_legacy(text, template)\n if insert_at is None:\n report[\"status\"] = \"appended\"\n merged = tidy(text.rstrip(\"\\n\") + \"\\n\\n\" + block)\n else:\n report[\"status\"] = \"migrated\"\n report[\"removed_legacy_sections\"] = removed\n before, after = stripped[:insert_at], stripped[insert_at:]\n merged = tidy(\n (before.rstrip(\"\\n\") + \"\\n\\n\" if before.strip() else \"\")\n + block\n + (\"\\n\\n\" + after.lstrip(\"\\n\") if after.strip() else \"\")\n )\n if crlf:\n merged = merged.replace(\"\\n\", \"\\r\\n\")\n return merged, report\n\n\ndef atomic_write(path: Path, text: str) -> None:\n path.parent.mkdir(parents=True, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f\".{path.name}.\", suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\", encoding=\"utf-8\", newline=\"\") as handle:\n handle.write(text)\n os.replace(tmp, path)\n except BaseException:\n try:\n os.unlink(tmp)\n except OSError:\n pass\n raise\n\n\ndef main(argv: list[str] | None = None) -> int:\n parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])\n parser.add_argument(\"--host\", required=True, choices=HOSTS)\n parser.add_argument(\"--template\", required=True, type=Path)\n parser.add_argument(\"--existing\", required=True, type=Path, help=\"project CLAUDE.md / AGENTS.md (may be absent)\")\n parser.add_argument(\"--output\", required=True, type=Path)\n parser.add_argument(\"--project-name\", required=True)\n args = parser.parse_args(argv)\n try:\n template = args.template.read_text(encoding=\"utf-8\")\n existing = args.existing.read_text(encoding=\"utf-8\") if args.existing.is_file() else \"\"\n merged, report = merge(existing, template, args.host, args.project_name)\n unchanged = merged == existing and args.output.resolve() == args.existing.resolve()\n if not unchanged:\n atomic_write(args.output, merged)\n except (OSError, UnicodeDecodeError, MergeError) as error:\n print(json.dumps({\"status\": \"error\", \"error\": str(error)}))\n return 1\n # 只输出 ASCII(中文转义),避免 Windows 控制台编码把报告打成乱码。\n print(json.dumps(report))\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10735,"content_sha256":"f378c55ba6fec379f662d752f0cae9786f8d6f480b8ddd38388429f047812acb"},{"filename":"UPGRADING.md","content":"# 升级指南\n\n## 当前版本\n\n助手版本 38(v0.9.2)。已有项目更新技能包后,在项目根重跑 `/story-setup`(Codex 用 `$story-setup`,Antigravity 用 `/skills` 或直接点名),再新开会话。\n\n- `setup_skill_version: 1.3.6`\n- `agents_version: 38`\n\n`.story-deployed` 字段不全,或 `agents_version` 不是整数、低于 38,都算待更新,重跑 story-setup 即可。项目里的 `agents_version` 大于 38,说明本机技能包比项目旧:先更新 oh-story-claudecode,不要用旧包覆盖。各版本改了什么见仓库根目录 `CHANGELOG.md`。\n\n## 升级步骤\n\n1. 更新技能包:命令行装的重跑 `npx skills add zenstory-ai/oh-story-claudecode -y -g`,插件市场或 Plugin Management 装的在面板里更新。\n2. 在写作项目根重跑 `/story-setup`(Codex 用 `$story-setup`)。\n3. 新开会话,让新的助手和 hook 生效;Codex 提示要信任 hook 时,在 `/hooks` 里信任一下。\n4. 正在写的长篇:确认每本书有 `追踪/_tracking-state.json`。没有就按下方「追踪模型迁移」重建,否则写下一章会被拦。\n5. 拆文成果或细纲不满足当前格式时,按检查提示定点补齐,不因单项缺口整本重拆。\n\n## 本次更新必须重跑 story-setup(v0.9.2,助手版本 38)\n\n- 新增发布资料技能 `story-publish`。重跑 story-setup 后,项目里才会有这个技能(OpenClaw、Reasonix、通用模式复制到项目 `skills/`)、项目指令里的「发布资料」入口,以及 OpenCode、ZCode 的 `/story-publish` 命令。\n- 书稿、设定、追踪和作者记忆不用迁移。\n\n## v0.9.1 升级说明(助手版本 37)\n\n- 更新技能包后重跑 story-setup,并新开会话,让新版助手与 hook 生效。\n- ZCode 用户须先更新并启用 `oh-story` 插件,再从**更新后的插件副本**运行 story-setup,覆盖项目 `.zcode/skills` 快照;不要用项目里的旧 story-setup 重部署。独立安装的 Skills 不会注册插件角色。\n- ZCode 若仍命中项目旧快照,先把受管目录 `.zcode/skills/story-setup` 移出 `.zcode/skills` 留作备份,再从新版插件运行 story-setup;部署完成后该目录由新版重新生成。不必删除其他项目 Skills。\n- Windows 用户重跑后会刷新项目 hook,避免继续使用旧版入口。\n\n## v0.9.0 升级说明(助手版本 36)\n\n- 写手、架构师、角色设计、一致性检查、查资料、资料研究这些助手的模板都改了。参考资料包里的排纲、开篇、人物、题材和情绪方法按使用环节重新整理,删掉了重复和过时的几份;写手读的技法与去 AI 味方法对齐。不重跑的话,项目里的旧助手会去找已经删掉的资料。\n- 项目指令(`CLAUDE.md`、`AGENTS.md`)改成每个编程工具一个管理块,从 `\u003c!-- oh-story:{工具} 开始` 到 `\u003c!-- oh-story:{工具} 结束 -->`。第一次重跑会把旧版 oh-story 写进去的段落换成管理块,你自己写的段落留在块外;以后重跑只替换块里的内容。几个工具共用 `AGENTS.md` 时各占一块,互不覆盖。\n- Claude Code 的 `.claude/rules/` 去掉了和写作流程打架的写死条目(章首、章尾钩子必须从固定类型里选、每章必须有情绪变化、固定段数和字数的警戒线),卷纲、细纲字段改以写作流程的模板为准,旧细纲缺字段照旧不拦。\n- 一致性检查和查资料助手也能处理短篇项目(`设定.md`、`小节大纲.md`、`正文.md`)。资料研究助手发现浏览器调试端口没开时,把情况交回主会话,由主会话问你要不要启动浏览器。\n- 书稿、设定、大纲、追踪和作者记忆的数据格式不变,不用迁移。\n- 之前导入的长篇,卷纲里的剧情单元是一张表,写正文时无法按单元读取这些旧单元。续写新章不受影响:写第 N 章会先按单元卡写法补上新单元。要重写导入时的旧章,先让 `/story-long-write` 把那一卷的旧单元改写成单元卡。\n\n## 从 v0.8.5 及更早升上来\n\n重跑一次 story-setup 就会装上当前版本,中间版本不用逐个补。下面是写书时会碰到的变化:\n\n- 长篇写后检查需要 Node.js 18+:缺 Node 时这一章不能提交,装好后重跑检查。长篇拆文需要 Python 3。\n- 改已经提交过的章,写作流程会先生成这一章的修订草稿再提交,不直接改追踪文件。\n- 细纲可以加一行「字数范围:2000-2600」,填入你给的范围;没写就按默认上下浮动。老细纲、老卷纲照常可用,排纲时才按新格式补。\n- 写正文前细纲不能是空壳(去掉 `#` 号和空白不到 30 字会被拦);标了 `\u003c!-- 去味:跳过 -->` 的章不会因为这行标记被判有问题。\n- 扫榜每次写进 `扫榜/{日期}/` 新目录,旧目录不用动。\n- 换了编程工具后重跑 story-setup,会问你要不要把当前工具一起装上。\n- 项目里旧的 `.claude/agent-memory/` 不再使用,可以删掉。\n\n## OpenCode 只支持 2.x(v0.7.11)\n\nOpenCode 2.0 起发布在 npm 包 `@opencode/cli`(`opencode-ai` 停在 1.x)。本适配只支持 2.x:1.x 的插件 loader 读不了新版 `story-hooks.ts`,在 1.x 上会没有写正文守卫与写后兜底;1.x 也不认 agents 的 `permissions:` 规则,只读 agent 会失去权限限制。story-setup 在版本是 1.x 或无法确定时停止部署。\n\n1. 升级 OpenCode:先卸载旧包 `npm rm -g opencode-ai`,再 `npm i -g @opencode/cli`(或 `curl -fsSL https://opencode.ai/v2/install | bash`),确认 `opencode --version` 为 2.x。两个包都提供 `opencode` 命令,不先卸载旧包时 PATH 上可能仍是 1.x。\n2. 更新技能包后,在写作项目根重跑 story-setup:替换 `.opencode/plugins/story-hooks.ts` 与 `.opencode/agents/`(改为 2.x 原生 `permissions:` 规则列表,已配的 `model:` 会保留),并从 `opencode.json` / `opencode.jsonc` 删除旧的 story-hooks 插件注册。\n\n## 插件打包身份迁移(v0.7.9 同版本修复)\n\nClaude Code / ZCode 市场改为单一 `oh-story` 插件,仍包含全部 13 个 Skills。该迁移始于 v0.7.9 的同版本修复,仍使用旧插件身份的用户需手动迁移;`npx skills` 安装无需迁移。卸载前备份要保留的插件数据,以下操作仅针对旧插件记录,保留写作项目及 story-setup 部署文件。\n\n### Claude Code\n\n1. 先在受影响的写作项目目录中列出已安装插件,按记录中的 `id` 与 `scope` 核对实际旧身份;`project` / `local` 记录属于各自项目,多个项目须分别核对:\n\n ```bash\n claude plugin list --json\n ```\n\n 只处理列表中确实存在、marketplace 为 `oh-story-skills` 的以下旧 ID:\n\n ```text\n browser-cdp@oh-story-skills\n story@oh-story-skills\n story-cover@oh-story-skills\n story-deslop@oh-story-skills\n story-import@oh-story-skills\n story-long-analyze@oh-story-skills\n story-long-scan@oh-story-skills\n story-long-write@oh-story-skills\n story-review@oh-story-skills\n story-setup@oh-story-skills\n story-short-analyze@oh-story-skills\n story-short-scan@oh-story-skills\n story-short-write@oh-story-skills\n ```\n\n2. **刷新 catalog 前**,逐个卸载实际存在的旧 ID,并使用列表中原有的 `user`、`project` 或 `local` scope。以下以 user-scope 的 `story` 插件为例:\n\n ```bash\n claude plugin uninstall story@oh-story-skills --scope user --keep-data\n ```\n\n `--keep-data` 保留该旧插件身份的数据,但不会把它迁移到新的 `oh-story` 身份。同一旧 ID 若出现在多个 scope,每个 scope 分别执行。\n\n3. 所有实际旧身份卸载完后,刷新已有 catalog;若本机尚未添加该 catalog,则添加仓库:\n\n ```bash\n claude plugin marketplace update oh-story-skills\n # 仅在 catalog 尚不存在时:\n claude plugin marketplace add https://github.com/zenstory-ai/oh-story-claudecode\n ```\n\n4. 在原 scope 安装统一 bundle;多个 scope 分别安装:\n\n ```bash\n claude plugin install oh-story@oh-story-skills --scope user\n ```\n\n5. 新开 Claude Code 会话,使用 `/oh-story:story-setup` 或 `/oh-story:story dashboard`。命令与 manifest 格式见 [Claude Code 插件参考](https://code.claude.com/docs/en/plugins-reference)。\n\n### ZCode\n\n在 Plugin Management 中卸载受影响的旧条目,刷新市场;必要时重新添加本仓库,再安装一个 `oh-story`。沿用界面中的市场名(`oh-story-skills` 或 `oh-story-zcode`),避免重复安装。若无法卸载,保留数据备份,附 ZCode 版本与界面现象联系官方支持,不要清空全部缓存或手改缓存 JSON。操作说明见 [ZCode 插件文档](https://zcode.z.ai/en/docs/plugin)。\n\n## 项目里多出的嵌套目录\n\n旧版部署可能在项目里留下嵌套目录:`agent-references/agent-references/`(可能多层)或 `skills/story-setup/skills/`,严重时会撑满磁盘。新版部署会拒绝这种复制并自动删掉残留。\n\nOpenClaw、Reasonix 和通用模式把 skill 放在项目 `skills/` 里,重跑时执行的是项目里那份旧 skill,无法自动清理:先手动删掉上面两类目录,再用更新后的技能包覆盖项目 `skills/` 下的 14 个目录。\n\n## 追踪模型迁移(v0.7.2 及更早的长篇项目必读)\n\n长篇追踪以 `追踪/_tracking-state.json` 为准,由写作流程的提交工具统一写入;续写状态卡、逐章记录、角色快照、伏笔表和两份时间线都由工具整份生成,不手改。\n\n判断与后果:\n\n| 情况 | 表现 |\n|------|------|\n| `追踪/_tracking-state.json` 存在且 `check` 通过 | 正常,无需处理 |\n| 缺 `_tracking-state.json` 但已有正文 | 日更停止;Claude Code、Codex、OpenCode、ZCode、Antigravity 上写正文会被 hook 直接拦下,其他工具由写作流程自己停下 |\n| 存在但派生视图被手改 | `check` 报 `derived view differs from _tracking-state.json` |\n\n迁移**不需要重跑全书拆解**:正文、`设定/`、`大纲/`、`拆文库/` 都不受影响,只重建 `追踪/`。执行 `/story-import` 的「旧追踪项目迁移」——数出最后完整章号 `N`,从旧追踪文件与最近几章正文重建当前状态,构造 `last_chapter=N` 的初始化事务跑 `tracking_commit.py init`。旧追踪结构会被按原样整体移入 `追踪/_旧追踪存档/`,不删除、不参与解析。\n\n退役结构:`_tracking-meta.json`、`时间线/事件库.json` 及更早追踪文件不再被解析,`commit` 与 `check` 遇到会直接拒绝。\n\n之后写作时,追踪只通过写作流程提交;生成文件被手改后,用该章的修订提交整份重建。\n\n## 作者记忆两级存储迁移(v0.7.11 之前的项目)\n\n作者记忆从「一个工作区一份」改成两级:工作区 `.story/作者记忆/` 只存全局、题材、流程条目(编号 `AP`),每本书目录下的 `.story/作者记忆/` 存这本书的条目(编号 `BP`),记忆随书归档、迁移。同时不再从反复修改或成稿推断偏好:`record` 拒绝 `repeated_correction` / `inferred_pattern` 来源,存量条目照常可读、可确认、可退役。\n\n下方命令中的 `{所用写作 skill 根}` 指 `story-long-write` 或 `story-short-write` 的安装目录。\n\n迁移时按以下三点处理:\n\n1. 新的本书偏好必须传 `--book-root {书目录}` 才能写入;旧 skill 副本没带这个参数会报错,重跑 `/story-setup` 让脚本与文档同版。\n2. 升级前写进工作区的本书条目**不再被 `query` 返回**(全局、题材、流程条目不受影响),直到搬进书目录;它们仍在工作区 `作者画像.md` 里可见。\n3. 对每本书运行一次即可整批搬家,幂等、中途失败直接重跑:\n\n ```text\n {PYTHON} {所用写作 skill 根}/scripts/author_memory_commit.py migrate --workspace {工作区} --book-root {工作区}/长篇/{书名}\n ```\n\n 断言、原话证据、确认次数、重要度原样保留;与全局条目的冲突候选在迁移后退回待确认。也可以对 agent 说「整理作者记忆」,迁移会列为默认提案项。\n\n**单书布局**(`正文/`、`追踪/` 直接在工作区根):书级数据存储在 `{工作区}/.story/作者记忆/书级/`,迁移时 `--book-root` 就传工作区本身:\n\n ```text\n {PYTHON} {所用写作 skill 根}/scripts/author_memory_commit.py migrate --workspace {工作区} --book-root {工作区}\n ```\n\n书名默认取存量本书条目里的书名;存量里有多本书的条目时用 `--book {书名}` 指定。用过 main 上预发布版本、查询时报 `project-level state must not carry state.book` 的工作区,带 `--book-root {工作区}` 运行任一命令即自动修复,不丢条目。\n\n## 长篇拆文旧成果(v0.7.10 及更早拆的书)\n\n- v0.7.10 及更早的拆文库如果原文以楔子/序章开头,续拆会先停下询问(旧版不认楔子,直接续跑会错一章)。只拆到黄金三章的库选「楔子单独成章重拆」代价最小;已有逐章摘要的库推荐「楔子并入第一章,按旧章号继续」。\n- `拆文报告.md` 小节结构变了(去掉「质量评估」,「证据边界与待核事项」改为「还不确定的地方」),旧报告不会自动改写,重跑 Stage 5 才生效。\n- 人物关系图改为 `人物关系图/人物关系图.md`(文字清单保留中文,支持 Mermaid 的查看器可显示关系图),有中文字体时另出 PNG;以前生成的拼音版 PNG 不会自动删除,可手动删掉。\n- 拆文进度文件 `_progress.md` 只认 `schema_version: 2`;更早格式的进度文件不能续拆,按提示从头拆。\n\n## 导入项目的自对标清理\n\n旧版 `story-import` 可能把作者自己的导入书误建成 `对标/{当前书名}/`,甚至把本书设定登记成“主对标”。升级不会自动删除用户文件,按以下边界人工核对:\n\n1. 保留 `拆文库/{导入书名}/`;它是本书导入分析和重建工程的数据源,不是错误目录。\n2. 以项目根 `设定/` 为本书正式设定。若 `对标/{当前书名}/` 的内容确认只是从本书 `设定/` 或 `拆文库/{导入书名}/` 复制而来,且没有人工补充,再删除这个误建目录。\n3. 清理 `设定/题材定位.md` 中把当前书登记为主/副对标的字段;真实外部对标登记不动。\n4. 若某个 `对标/{外部书名}/` 目录名看似外部作品,但内容实际来自当前书,删除这份错误视图,再从真正的 `拆文库/{对标书名}/` 重新同步;不要改名冒充修复。\n5. 重新运行 `/story-setup`(Codex 用 `$story-setup`)并新开会话,让当前助手模板生效。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":14858,"content_sha256":"b42acb521b78eb6774f78557e824b899d773f6855a544896ab444ff1d0c9d76e"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"story-setup:网文写作工具集基础设施部署","type":"text"}]},{"type":"paragraph","content":[{"text":"你负责把网文写作工具集部署到用户项目目录:已适配的 CLI 使用专用 hooks/agents/config;NarraFork、Web AI、自定义 Agent 等使用通用文件模式。","type":"text"}]},{"type":"paragraph","content":[{"text":"保留用户配置;受管文件按清单更新,共同维护的文件按管理块合并。","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"作者第一次部署的顺序:判断当前宿主 → 确认项目根 → 按宿主部署 → 验收 → 告诉作者下一步。本文件写公共流程;各宿主的部署清单、算法、校验和报告提示见 Phase 2 对应文件,","type":"text"},{"text":"只读本次目标宿主的那一份","type":"text","marks":[{"type":"strong"}]},{"text":"。","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"选择模式","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"参数为 ","type":"text"},{"text":"check","type":"text","marks":[{"type":"code_inline"}]},{"text":",或用户只要求检查部署、诊断环境、排查 agent 不可用时:完整读取 ","type":"text"},{"text":"references/diagnostics.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/diagnostics.md","title":null}}]},{"text":",按其中流程仅检查并报告;不进入下面的部署流程。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"用户要求安装、更新或修复时:执行下面的部署流程。检查后已明确授权的修复沿用本文件与宿主部署文件的部署与合并规则。","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Phase 1:判断宿主与项目","type":"text"}]},{"type":"paragraph","content":[{"text":"先自检参考目录","type":"text","marks":[{"type":"strong"}]},{"text":":以正在执行的本 ","type":"text"},{"text":"SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 所在目录为准,列出与它同级的 ","type":"text"},{"text":"references/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 下的子目录,核对下面 9 个名字是否都在","type":"text"},{"text":"且都非空","type":"text","marks":[{"type":"strong"}]},{"text":"——","type":"text"},{"text":"agent-references","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"templates","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"opencode","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"codex","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"antigravity","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"zcode","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"openclaw","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"reasonix","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"generic","type":"text","marks":[{"type":"code_inline"}]},{"text":";同级 ","type":"text"},{"text":"references/deploy-*.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"(8 份宿主部署文件)、","type":"text"},{"text":"scripts/merge-claude-settings.py","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"scripts/merge-codex-hooks.py","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"scripts/merge-antigravity-hooks.py","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"scripts/generate-antigravity-agents.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"scripts/deploy-antigravity-skills.py","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"scripts/merge-project-instructions.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" 与 ","type":"text"},{"text":"scripts/copy-path-safety.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" 也必须存在(宿主部署步骤、Claude/Codex/Antigravity hooks 合并、Antigravity Skills 物化与 agent 生成、项目指令合并、递归复制安全检查依赖它们)。有缺即 skill 包没装全,","type":"text"},{"text":"立即停止,不写任何部署文件","type":"text","marks":[{"type":"strong"}]},{"text":",报告里区分「缺目录」「目录为空」和「缺脚本或部署文件」,并给修复指令:「story-setup 参考资料包不完整,缺 {路径}。按你的安装方式重装 oh-story-claudecode(命令行装的重跑 ","type":"text"},{"text":"npx skills add zenstory-ai/oh-story-claudecode -y -g","type":"text","marks":[{"type":"code_inline"}]},{"text":",marketplace / Plugin Management 装的在面板里重装),再执行 /story-setup。」","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"判据是「有没有 ","type":"text"},{"text":"SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"」:只看正在执行的 ","type":"text"},{"text":"SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 同级的 ","type":"text"},{"text":"references/","type":"text","marks":[{"type":"code_inline"}]},{"text":"。项目内 ","type":"text"},{"text":".claude/skills/story-setup/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".codex/skills/story-setup/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 和 OpenCode 的 ","type":"text"},{"text":"skills/story-setup/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 只有 ","type":"text"},{"text":"references/agent-references/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、不含 ","type":"text"},{"text":"SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":",不会是执行目录,也不要拿它们核对。Antigravity / ZCode / OpenClaw / Reasonix / generic 的项目副本是整份 skill 拷贝、自带 ","type":"text"},{"text":"SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":",9 个子目录本就齐全,照常核对即可。","type":"text"}]}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"检查当前目录是否已部署过(存在 ","type":"text"},{"text":".story-deployed","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"agents_version","type":"text","marks":[{"type":"code_inline"}]},{"text":" 缺失、非整数或小于 ","type":"text"},{"text":"38","type":"text","marks":[{"type":"code_inline"}]},{"text":" → 标记为待更新,继续执行当前部署","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"agents_version: 38","type":"text","marks":[{"type":"code_inline"}]},{"text":" → 使用 AskUserQuestion 确认是否重新部署;提示里写明重新部署只用","type":"text"},{"text":"当前本地 skill 包","type":"text","marks":[{"type":"strong"}]},{"text":"刷新项目文件,要拿 skill 本身的新版本得先更新 oh-story-claudecode(","type":"text"},{"text":"npx skills add","type":"text","marks":[{"type":"code_inline"}]},{"text":" 或 marketplace),再回来重跑","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"agents_version","type":"text","marks":[{"type":"code_inline"}]},{"text":" 大于 ","type":"text"},{"text":"38","type":"text","marks":[{"type":"code_inline"}]},{"text":" → 当前 story-setup 比项目部署旧;停止以避免降级覆盖,提示先更新 oh-story-claudecode,不写任何部署文件","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"同时读 ","type":"text"},{"text":"target_cli","type":"text","marks":[{"type":"code_inline"}]},{"text":" 字段。","type":"text"},{"text":"已部署项目以 sentinel 里的值为准","type":"text","marks":[{"type":"strong"}]},{"text":":非空时(逗号分隔的多端组合原样保留)这些端照常重新部署,不再让作者选;第 4 步仍判断当前宿主:判断出来且不在其中(作者换了软件)→ AskUserQuestion 问「这个文件夹之前是给 {已部署宿主名} 装的,你现在用的是 {当前宿主名},要一起装上吗?」(「一起装上(推荐)」「只更新原来的」),选前者就加进 ","type":"text"},{"text":"target_cli","type":"text","marks":[{"type":"code_inline"}]},{"text":";判断不出不问。字段缺失或为空才走第 4-6 步。用户明确要求增删目标端时,用 AskUserQuestion 在现有值基础上改,改完的值写回 sentinel。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"target_cli","type":"text","marks":[{"type":"code_inline"}]},{"text":" 不含 opencode、但项目里有 ","type":"text"},{"text":".opencode/plugins/story-hooks.ts","type":"text","marks":[{"type":"code_inline"}]},{"text":" 或 ","type":"text"},{"text":".opencode/agents/","type":"text","marks":[{"type":"code_inline"}]},{"text":"(多端部署时 OpenCode 曾被版本门拦下)→ 用 AskUserQuestion 问是否把 OpenCode 加回来;选加回则先过 ","type":"text"},{"text":"references/deploy-opencode.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/deploy-opencode.md","title":null}}]},{"text":" 的「部署前置」,通过后写回 ","type":"text"},{"text":"target_cli","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"检查是否有书名目录(包含 ","type":"text"},{"text":"追踪/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 子目录的目录,或用户自定义结构)","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"有 → 识别为长篇项目,显示当前项目信息","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"无 → 识别为新项目或短篇项目","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"检查 ","type":"text"},{"text":".active-book","type":"text","marks":[{"type":"code_inline"}]},{"text":" 文件是否存在:存在 → 显示当前活跃书目;不存在 → 跳过","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"判断当前宿主(能判断就不问作者)","type":"text","marks":[{"type":"strong"}]},{"text":"。依次看,第一条能定下来的就用:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"你自身的运行环境:系统提示、可用工具名或 skill 调用语法已表明你运行在 Claude Code、Codex、OpenCode、Google Antigravity(含命令行 ","type":"text"},{"text":"agy","type":"text","marks":[{"type":"code_inline"}]},{"text":")、ZCode、OpenClaw 或 Reasonix 里;","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"正在执行的本 ","type":"text"},{"text":"SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的安装位置带有宿主专属目录(如 ","type":"text"},{"text":".claude/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".codex/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".zcode/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".gemini/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".openclaw/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".opencode/","type":"text","marks":[{"type":"code_inline"}]},{"text":");","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"判断不出(例如网页版 AI、自建 Agent,或以上信号互相矛盾)→ 第 5 步的项目标记只当候选,交第 6 步问作者。","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"看项目里已有哪些宿主的标记:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":".claude/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 或 ","type":"text"},{"text":"CLAUDE.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"target_cli = claude-code","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"opencode.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"opencode.jsonc","type":"text","marks":[{"type":"code_inline"}]},{"text":" 或 ","type":"text"},{"text":".opencode/","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"target_cli = opencode","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":".codex/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".codex/config.toml","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".codex/agents/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".codex/hooks.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"AGENTS.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 中的 Codex 段 → ","type":"text"},{"text":"target_cli = codex","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":".agents/hooks.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".agents/agents/","type":"text","marks":[{"type":"code_inline"}]},{"text":",或 ","type":"text"},{"text":".agents/rules/oh-story.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 中的 Antigravity 标记 → ","type":"text"},{"text":"target_cli = antigravity","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":".zcode/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".zcode/config.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"zcode.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".zcode/skills/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".zcode/commands/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"AGENTS.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 中的 ZCode 段 → ","type":"text"},{"text":"target_cli = zcode","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"openclaw.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":".openclaw/","type":"text","marks":[{"type":"code_inline"}]},{"text":",或 ","type":"text"},{"text":"AGENTS.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 中的 OpenClaw 段(标题行含 ","type":"text"},{"text":"网文写作工具集(OpenClaw)","type":"text","marks":[{"type":"code_inline"}]},{"text":")→ ","type":"text"},{"text":"target_cli = openclaw","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":".reasonix/","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"reasonix-plugin.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"REASONIX.md","type":"text","marks":[{"type":"code_inline"}]},{"text":",或 ","type":"text"},{"text":"AGENTS.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 中的 Reasonix 段(标题行含 ","type":"text"},{"text":"网文写作工具集(Reasonix)","type":"text","marks":[{"type":"code_inline"}]},{"text":")→ ","type":"text"},{"text":"target_cli = reasonix","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"AGENTS.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 中的通用段(标题行含 ","type":"text"},{"text":"网文写作工具集(通用 Agent / Web AI)","type":"text","marks":[{"type":"code_inline"}]},{"text":")→ ","type":"text"},{"text":"target_cli = generic","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"CLAUDE.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"AGENTS.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 里的 ","type":"text"},{"text":"\u003c!-- oh-story:{宿主} 开始","type":"text","marks":[{"type":"code_inline"}]},{"text":" 管理块 → ","type":"text"},{"text":"target_cli","type":"text","marks":[{"type":"code_inline"}]},{"text":" 含该宿主","type":"text"}]}]}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"后三类只认各端","type":"text"},{"text":"互斥","type":"text","marks":[{"type":"strong"}]},{"text":"的标记:","type":"text"},{"text":"skills/*/SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的 ","type":"text"},{"text":"metadata.openclaw","type":"text","marks":[{"type":"code_inline"}]},{"text":" 不作 OpenClaw 信号(14 个 skill 都带,三条 skills-only 路径部署出的 ","type":"text"},{"text":"skills/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 一样);","type":"text"},{"text":".agents/skills/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 由 Antigravity、Codex 与 Reasonix 共用,也不单独作准。","type":"text"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"定下 ","type":"text"},{"text":"target_cli","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"第 4 步定出了当前宿主 → ","type":"text"},{"text":"target_cli","type":"text","marks":[{"type":"code_inline"}]},{"text":" 就是它。项目里另有其他宿主的标记时,用 AskUserQuestion 问一句白话:「这个文件夹之前也给 {其他宿主名} 装过写作工具,这次要一起更新吗?」选项:「只装 {当前宿主名}(推荐)」「一起更新」。作者一开始就说要装多个时照办。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"定不出、项目标记恰好只有一个宿主 → AskUserQuestion 问「上次是给 {宿主名} 装的,这次还是它吗?」,是就用它,不是按下一条问。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"定不出、其余情况 → 用 AskUserQuestion 问:「你现在是在哪个软件里跟我对话?」选项用产品名加一句说明:Claude Code(Anthropic 的命令行 / 桌面版)、Codex(OpenAI 的命令行)、OpenCode、Google Antigravity(含命令行 agy)、ZCode、OpenClaw、Reasonix(DeepSeek 的命令行)、网页版 AI 或其他工具(NarraFork、自建 Agent 等)、好几个都要用。提问工具一次放不下这么多选项时,把项目标记里出现过的放前面,其余让作者在「其他」里直接写名字。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"作者的答案按「Claude Code → ","type":"text"},{"text":"claude-code","type":"text","marks":[{"type":"code_inline"}]},{"text":"、网页版 AI 或其他工具 → ","type":"text"},{"text":"generic","type":"text","marks":[{"type":"code_inline"}]},{"text":"、其余取小写产品名」换成 ","type":"text"},{"text":"target_cli","type":"text","marks":[{"type":"code_inline"}]},{"text":";多端为 ","type":"text"},{"text":"claude-code,opencode,codex,antigravity,zcode,openclaw,reasonix,generic","type":"text","marks":[{"type":"code_inline"}]},{"text":" 中选中的端。","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"确认项目根(通常不问)","type":"text","marks":[{"type":"strong"}]},{"text":":项目根就是当前工作目录。只有当前目录是用户主目录、磁盘根目录、系统目录,或就是本 skill 包自身的安装/源码目录时,才用一句白话问作者:「写作工具要装在哪个文件夹?一般就是放书稿的那个文件夹。」其他情况直接部署,在安装报告的「部署明细」里写明装在哪。","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Phase 2:部署基础设施","type":"text"}]},{"type":"paragraph","content":[{"text":"Phase 2 幂等:复制、写入与各宿主合并重复执行结果一致。受管目录中只覆盖 oh-story 自己的 14 个 skill、7 个助手、hook 与规则文件,保留同目录里作者自己的文件;不覆盖书稿、设定、大纲、追踪和 ","type":"text"},{"text":".active-book","type":"text","marks":[{"type":"code_inline"}]},{"text":"。工具、权限或网络问题中断后,从头重跑即可,不另清理半成品;","type":"text"},{"text":"create only if absent","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的用户状态文件(见清单 Owner class)不会被覆盖。","type":"text"}]},{"type":"paragraph","content":[{"text":"两列基准目录不同","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"},{"text":"Source path","type":"text","marks":[{"type":"code_inline"}]},{"text":" 是包内来源,story-setup 内的路径以当前 ","type":"text"},{"text":"SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 所在目录定位;","type":"text"},{"text":"Target path","type":"text","marks":[{"type":"code_inline"}]},{"text":" 相对用户项目根。执行每一行(以及各宿主部署算法里的每个递归复制步骤)之前,先把通配符具体化为单个源/目标,再用本 ","type":"text"},{"text":"SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" 同级的 ","type":"text"},{"text":"scripts/copy-path-safety.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" 检查。该脚本按 ","type":"text"},{"text":"Path.resolve","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"realpath","type":"text","marks":[{"type":"code_inline"}]},{"text":" 语义跟随已有 symlink,并在两侧都存在时用 ","type":"text"},{"text":"samefile","type":"text","marks":[{"type":"code_inline"}]},{"text":" 核对文件系统对象;","type":"text"},{"text":"只转绝对路径或比较字符串不算检查完成","type":"text","marks":[{"type":"strong"}]},{"text":"。读取其 JSON:","type":"text"},{"text":"status: same","type":"text","marks":[{"type":"code_inline"}]},{"text":" 时 no-op,禁止复制;仅 ","type":"text"},{"text":"copy_allowed: true","type":"text","marks":[{"type":"code_inline"}]},{"text":" 时可以复制;","type":"text"},{"text":"source_missing","type":"text","marks":[{"type":"code_inline"}]},{"text":"、","type":"text"},{"text":"unsafe_target_within_source","type":"text","marks":[{"type":"code_inline"}]},{"text":" 或 ","type":"text"},{"text":"filesystem_identity_error","type":"text","marks":[{"type":"code_inline"}]},{"text":" 必须停止该步骤并报告。无法运行脚本时只能用当前环境的文件系统 API 做完全相同的 canonical realpath、same-object 与 target-descendant 检查;无法确认就停止,不得尝试复制。OpenClaw / Reasonix / generic 的项目副本是整份 skill 拷贝,重跑时执行的就是项目里那份;Reasonix / Codex 还可能经 ","type":"text"},{"text":".agents/skills → ../skills","type":"text","marks":[{"type":"code_inline"}]},{"text":" symlink 加载,路径文本不同也可能指向同一目录,照字面复制会把目录嵌进自身并撑满磁盘。","type":"text"}]},{"type":"paragraph","content":[{"text":"部署前清理自嵌套残留","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"},{"text":"{.claude,.codex,.zcode}/skills/story-setup/references/agent-references/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 与项目根 ","type":"text"},{"text":"skills/story-setup/references/agent-references/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 里若多出 ","type":"text"},{"text":"agent-references/","type":"text","marks":[{"type":"code_inline"}]},{"text":" 层(可能嵌了多层),以及 ","type":"text"},{"text":"skills/story-setup/skills/","type":"text","marks":[{"type":"code_inline"}]},{"text":",整段删掉再部署,并在安装报告里列出删掉的路径。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 1:按宿主部署","type":"text"}]},{"type":"paragraph","content":[{"text":"对 ","type":"text"},{"text":"target_cli","type":"text","marks":[{"type":"code_inline"}]},{"text":" 里的每个宿主,","type":"text"},{"text":"完整读取","type":"text","marks":[{"type":"strong"}]},{"text":"对应部署文件,按其中的部署清单(机械可检查)与算法依次执行;不读其他宿主的文件。","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"target_cli","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"部署文件","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"claude-code","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/deploy-claude-code.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/deploy-claude-code.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"opencode","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/deploy-opencode.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/deploy-opencode.md","title":null}}]},{"text":"(先过其中「部署前置」版本门)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"codex","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/deploy-codex.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/deploy-codex.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"antigravity","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/deploy-antigravity.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/deploy-antigravity.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"zcode","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/deploy-zcode.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/deploy-zcode.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"openclaw","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/deploy-openclaw.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/deploy-openclaw.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"reasonix","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/deploy-reasonix.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/deploy-reasonix.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"generic","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/deploy-generic.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/deploy-generic.md","title":null}}]}]}]}]}]},{"type":"paragraph","content":[{"text":"多端部署时几个宿主共用根 ","type":"text"},{"text":"AGENTS.md","type":"text","marks":[{"type":"code_inline"}]},{"text":",各占一个管理块(见下方「项目指令合并」);某一端被自己的前置条件拦下(如 OpenCode 版本门、Antigravity 缺 Node)时,按该端部署文件的规定处理,其他端照常部署。","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 2:创建部署标记","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"仅为本次成功部署的目标创建或更新 ","type":"text"},{"text":".story-deployed","type":"text","marks":[{"type":"code_inline"}]},{"text":"(sentinel file);全部目标停止时保留旧标记,不创建新标记。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"写入以下字段(YAML ","type":"text"},{"text":"key: value","type":"text","marks":[{"type":"code_inline"}]},{"text":" 格式,hook 用 ","type":"text"},{"text":"references/templates/hooks/lib/sentinel.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" 读取):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"deployed_at: \u003cdate -u +\"%Y-%m-%dT%H:%M:%SZ\">\nagents_version: 38\nsetup_skill_version: 1.3.6\ntarget_cli: claude-code(或 opencode、codex、antigravity、zcode、openclaw、reasonix、generic,或其任意组合)\nresolver_strategy: project-local-skill-reference\nreferences_dir: .claude/skills/story-setup/references/agent-references(Codex 写 .codex/skills/...;Antigravity 写 .agents/skills/...;ZCode 写 .zcode/skills/...;OpenCode / OpenClaw / Reasonix / generic 写 skills/...;多端用逗号分隔)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"此文件供 session-start.sh 和写作 skill 检测部署状态,避免重复提示","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"宿主部署文件里「部署标记补充」要求的额外标记一并创建(如 Claude Code 的重启确认标记)","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Phase 3:验证安装","type":"text"}]},{"type":"paragraph","content":[{"text":"仅检查模式复用第 1–2 项,跳过第 3 项,且其中要求实际执行 hook 或写入 fixture 的子项改为只做静态校验(文件存在、语法有效、注册项齐全),不运行会写入项目的 hook,也不创建部署标记。","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"对 ","type":"text"},{"text":"target_cli","type":"text","marks":[{"type":"code_inline"}]},{"text":" 里的每个宿主,执行其部署文件的「验证」一节。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"验证部署标记:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"检查 ","type":"text"},{"text":".story-deployed","type":"text","marks":[{"type":"code_inline"}]},{"text":" 存在,字段齐全,版本号与 Step 2 的字段块一致","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"输出安装报告。读者是不懂编程的作者,按这个顺序写:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"先写「现在可以做什么」","type":"text","marks":[{"type":"strong"}]},{"text":":用写书的话列本次部署后真正可用的事(如「可以开新书、续写:说 /story-long-write」「可以拆一本对标书」),端的限制如实翻译(如「这个工具里审稿由我一个人完成,没有分工助手」)。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"再写「你还需要做的事」","type":"text","marks":[{"type":"strong"}]},{"text":":逐条可照做,如「新开一个会话」「在 Codex 里打开 /hooks,把 oh-story 的几条信任一下」「先安装 Node」;没有就写「无需其他操作」。这两段不出现脚本名、字段名、状态名或文件路径;各宿主部署文件「安装报告必须提示」的内容先翻译进这两段。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"最后是简短的「部署明细」","type":"text","marks":[{"type":"strong"}]},{"text":":装在哪个文件夹、已部署文件、已合并的配置、删掉的残留路径、宿主部署文件要求的摘要(如 OpenCode 模型配置)和技术原因,放在报告末尾。","type":"text"}]}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"模板占位符","type":"text"}]},{"type":"paragraph","content":[{"text":"只替换 ","type":"text"},{"text":"{项目名}","type":"text","marks":[{"type":"code_inline"}]},{"text":":用作者给的书名或项目名,没给就用当前目录名,去掉花括号。模板里其余花括号写法(如 ","type":"text"},{"text":"{书名}/正文/","type":"text","marks":[{"type":"code_inline"}]},{"text":")表示「任意一本书的目录」,原样保留。","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"项目指令合并(CLAUDE.md / AGENTS.md)","type":"text"}]},{"type":"paragraph","content":[{"text":"每个宿主在项目指令文件里占一个管理块:","type":"text"},{"text":"\u003c!-- oh-story:{宿主} 开始 … -->","type":"text","marks":[{"type":"code_inline"}]},{"text":" 到 ","type":"text"},{"text":"\u003c!-- oh-story:{宿主} 结束 -->","type":"text","marks":[{"type":"code_inline"}]},{"text":",块里是渲染后的模板;Claude Code 写 ","type":"text"},{"text":"CLAUDE.md","type":"text","marks":[{"type":"code_inline"}]},{"text":",其余宿主共用根 ","type":"text"},{"text":"AGENTS.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"。按 ","type":"text"},{"text":"python3","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"python","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"py","type":"text","marks":[{"type":"code_inline"}]},{"text":" 的顺序探测出可用的 Python 记为 ","type":"text"},{"text":"PYBIN","type":"text","marks":[{"type":"code_inline"}]},{"text":",运行:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"text"},"content":[{"text":"\"$PYBIN\" \"{story-setup skill目录}/scripts/merge-project-instructions.py\" --host {宿主} --template {该宿主模板} --existing {项目指令文件} --output {项目指令文件} --project-name {项目名}","type":"text"}]},{"type":"paragraph","content":[{"text":"规则(无法运行脚本时照此手工合并):","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"文件不存在或为空 → 只写这一块。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"已有本宿主的块 → 整块替换;块外文字和别的宿主的块一字不动。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"没有本宿主的块、但块外有旧版「网文写作工具集」标题行(旧部署)→ 删掉本宿主的旧标题行、引言及与模板同名的节(含已退役的「协作规则」;「Skill 路由表」「文件结构」「写作约束」只在内容带 oh-story 痕迹时才删),在原位置写入这一块;作者自己的节保留在块外。已有别的宿主的块、块外没有旧标题行时,只删本宿主独有的节(如「Codex 项目约定」)。","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"以上都不是 → 把这一块追加到文件末尾。","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"把脚本输出的旧节名写进安装报告的「部署明细」。","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"参考资料","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"文件","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"用途","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/deploy-*.md","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"8 份宿主部署文件:各自的部署清单、算法、验证与安装报告提示,按 Phase 2 Step 1 只读目标宿主那份","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references/diagnostics.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/diagnostics.md","title":null}}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"仅检查模式","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UPGRADING.md","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"写给作者:各版本要不要重跑、哪些旧数据要迁移","type":"text"}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"流程衔接","type":"text"}]},{"type":"paragraph","content":[{"text":"流水线:","type":"text","marks":[{"type":"strong"}]},{"text":" 部署 ","type":"text"},{"text":"位置:","type":"text","marks":[{"type":"strong"}]},{"text":" 初始化(最前置)","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"时机","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"跳转到","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"命令","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"部署完成,开始写作","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"story-long-write / story-short-write","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"/story-long-write","type":"text","marks":[{"type":"code_inline"}]},{"text":" 或 ","type":"text"},{"text":"/story-short-write","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"导入已有小说做拆解","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"story-import","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"/story-import","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"需要浏览器登录态(扫榜/拆文取原文)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"browser-cdp","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"/browser-cdp","type":"text","marks":[{"type":"code_inline"}]},{"text":";generic 需平台允许本地脚本/浏览器控制","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"各端调用语法:Claude ","type":"text"},{"text":"/名","type":"text","marks":[{"type":"code_inline"}]},{"text":"、Codex/ZCode ","type":"text"},{"text":"$名","type":"text","marks":[{"type":"code_inline"}]},{"text":"、Antigravity 通过 ","type":"text"},{"text":"/skills","type":"text","marks":[{"type":"code_inline"}]},{"text":" 浏览或直接点名、OpenClaw ","type":"text"},{"text":"/skill 名","type":"text","marks":[{"type":"code_inline"}]},{"text":"、Reasonix / generic 直接点名 skill。","type":"text"}]}]},"metadata":{"date":"2026-11-02","name":"story-setup","author":"@skillopedia","source":{"stars":8267,"repo_name":"oh-story-claudecode","origin_url":"https://github.com/zenstory-ai/oh-story-claudecode/blob/HEAD/skills/story-setup/SKILL.md","repo_owner":"zenstory-ai","body_sha256":"e3cc7361632824d5de0711226efc05f44b72952a4e9932466d8478affd2617d3","cluster_key":"11a7eba9a87a9c33f4140aaf98dae6fd15526efe62e2f0fbd48f23697fd83289","clean_bundle":{"format":"clean-skill-bundle-v1","source":"zenstory-ai/oh-story-claudecode/skills/story-setup/SKILL.md","text_bytes":798076,"source_commit":"3fde437b1af762f0b9c383c6e1a309f84d99ba83","skills_sh_rank":1635,"attachment_count":100,"text_attachments":100,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/story-setup/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"1.3.6","category":"education-research","category_label":"Education"},"exact_dupes_collapsed_into_this":0},"version":"1.3.6","category":"education-research","metadata":{"openclaw":{"source":"https://github.com/zenstory-ai/oh-story-claudecode"}},"import_tag":"skills-sh-top20k-2026-11","description":"网文写作工具集基础设施部署与检查。为 Claude Code / OpenCode / Codex / Google Antigravity / ZCode / OpenClaw / Reasonix 提供内置适配;Web AI / 通用 Agent 可走 skills + AGENTS.md 文件模式。触发方式:/story-setup、$story-setup、「准备写书」「帮我搭一下环境」「配置写作项目」「检查写作环境」。"}},"renderedAt":1793755400080}

story-setup:网文写作工具集基础设施部署 你负责把网文写作工具集部署到用户项目目录:已适配的 CLI 使用专用 hooks/agents/config;NarraFork、Web AI、自定义 Agent 等使用通用文件模式。 保留用户配置;受管文件按清单更新,共同维护的文件按管理块合并。 作者第一次部署的顺序:判断当前宿主 → 确认项目根 → 按宿主部署 → 验收 → 告诉作者下一步。本文件写公共流程;各宿主的部署清单、算法、校验和报告提示见 Phase 2 对应文件, 只读本次目标宿主的那一份 。 选择模式 - 参数为 ,或用户只要求检查部署、诊断环境、排查 agent 不可用时:完整读取 references/diagnostics.md,按其中流程仅检查并报告;不进入下面的部署流程。 - 用户要求安装、更新或修复时:执行下面的部署流程。检查后已明确授权的修复沿用本文件与宿主部署文件的部署与合并规则。 --- Phase 1:判断宿主与项目 先自检参考目录 :以正在执行的本 所在目录为准,列出与它同级的 下的子目录,核对下面 9 个名字是否都在 且都非空 —— 、 、 、 、 、 、 、 、 ;同级 (8 份宿主部署文件)、 、 、 、 、 、 与 也必须存在(宿主部署步骤、Claude/Codex/Antigravity hooks 合并、Antigravity…