@zwbigi/ink-xy 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +2 -2
- package/.next/build-manifest.json +2 -2
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +1 -1
- package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/api/inkos/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/install/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/search/route.js.nft.json +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +1 -1
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/server/app-paths-manifest.json +2 -2
- package/.next/server/chunks/162.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/trace +4 -4
- package/.next/trace-build +1 -1
- package/bin/pi-web.js +4 -1
- package/inkos/.env.example +20 -0
- package/inkos/.node-version +1 -0
- package/inkos/.nvmrc +1 -0
- package/inkos/CHANGELOG.md +787 -0
- package/inkos/CONTRIBUTING.md +89 -0
- package/inkos/LICENSE +661 -0
- package/inkos/README.en.md +483 -0
- package/inkos/README.ja.md +461 -0
- package/inkos/README.md +272 -0
- package/inkos/assets/15qun.jpg +0 -0
- package/inkos/assets/41777702961_.pic.jpg +0 -0
- package/inkos/assets/inkos-short-demo-cover.png +0 -0
- package/inkos/assets/inkos-text.svg +40 -0
- package/inkos/assets/logo.svg +47 -0
- package/inkos/assets/screenshot-chapters.png +0 -0
- package/inkos/assets/screenshot-pipeline.png +0 -0
- package/inkos/assets/screenshot-state.png +0 -0
- package/inkos/assets/screenshot-terminal.png +0 -0
- package/inkos/assets/wechat-group-v8.jpg +0 -0
- package/inkos/package.json +42 -0
- package/inkos/packages/cli/package.json +74 -0
- package/inkos/packages/cli/src/__tests__/analytics.test.ts +154 -0
- package/inkos/packages/cli/src/__tests__/cli-integration.test.ts +1031 -0
- package/inkos/packages/cli/src/__tests__/daemon.test.ts +93 -0
- package/inkos/packages/cli/src/__tests__/doctor.test.ts +36 -0
- package/inkos/packages/cli/src/__tests__/interact-command.test.ts +142 -0
- package/inkos/packages/cli/src/__tests__/interaction-tools.test.ts +107 -0
- package/inkos/packages/cli/src/__tests__/llm-overrides.test.ts +25 -0
- package/inkos/packages/cli/src/__tests__/localization.test.ts +121 -0
- package/inkos/packages/cli/src/__tests__/progress-text.test.ts +92 -0
- package/inkos/packages/cli/src/__tests__/project-bootstrap.test.ts +71 -0
- package/inkos/packages/cli/src/__tests__/publish-package.test.ts +272 -0
- package/inkos/packages/cli/src/__tests__/revision-command.test.ts +82 -0
- package/inkos/packages/cli/src/__tests__/runtime-requirements.test.ts +89 -0
- package/inkos/packages/cli/src/__tests__/short-fiction-command.test.ts +48 -0
- package/inkos/packages/cli/src/__tests__/studio-runtime.test.ts +142 -0
- package/inkos/packages/cli/src/__tests__/studio.test.ts +87 -0
- package/inkos/packages/cli/src/__tests__/tui-activity-state.test.ts +20 -0
- package/inkos/packages/cli/src/__tests__/tui-agent-session.test.ts +213 -0
- package/inkos/packages/cli/src/__tests__/tui-chat-depth.test.ts +27 -0
- package/inkos/packages/cli/src/__tests__/tui-chat-draft.test.ts +44 -0
- package/inkos/packages/cli/src/__tests__/tui-command.test.ts +86 -0
- package/inkos/packages/cli/src/__tests__/tui-composer-caret.test.ts +46 -0
- package/inkos/packages/cli/src/__tests__/tui-composer-display.test.ts +40 -0
- package/inkos/packages/cli/src/__tests__/tui-dashboard.test.tsx +219 -0
- package/inkos/packages/cli/src/__tests__/tui-effects-i18n.test.ts +29 -0
- package/inkos/packages/cli/src/__tests__/tui-i18n.test.ts +22 -0
- package/inkos/packages/cli/src/__tests__/tui-input-chrome.test.ts +10 -0
- package/inkos/packages/cli/src/__tests__/tui-input-history.test.ts +40 -0
- package/inkos/packages/cli/src/__tests__/tui-layout.test.ts +55 -0
- package/inkos/packages/cli/src/__tests__/tui-local-commands.test.ts +47 -0
- package/inkos/packages/cli/src/__tests__/tui-session-store.test.ts +81 -0
- package/inkos/packages/cli/src/__tests__/tui-setup-i18n.test.ts +31 -0
- package/inkos/packages/cli/src/__tests__/tui-slash-autocomplete.test.ts +33 -0
- package/inkos/packages/cli/src/commands/agent.ts +65 -0
- package/inkos/packages/cli/src/commands/analytics.ts +77 -0
- package/inkos/packages/cli/src/commands/audit.ts +52 -0
- package/inkos/packages/cli/src/commands/book.ts +260 -0
- package/inkos/packages/cli/src/commands/compose.ts +50 -0
- package/inkos/packages/cli/src/commands/config.ts +328 -0
- package/inkos/packages/cli/src/commands/consolidate.ts +50 -0
- package/inkos/packages/cli/src/commands/daemon.ts +121 -0
- package/inkos/packages/cli/src/commands/detect.ts +125 -0
- package/inkos/packages/cli/src/commands/doctor.ts +391 -0
- package/inkos/packages/cli/src/commands/draft.ts +43 -0
- package/inkos/packages/cli/src/commands/eval.ts +217 -0
- package/inkos/packages/cli/src/commands/export.ts +45 -0
- package/inkos/packages/cli/src/commands/fanfic.ts +183 -0
- package/inkos/packages/cli/src/commands/genre.ts +160 -0
- package/inkos/packages/cli/src/commands/import.ts +158 -0
- package/inkos/packages/cli/src/commands/init.ts +47 -0
- package/inkos/packages/cli/src/commands/interact.ts +109 -0
- package/inkos/packages/cli/src/commands/plan.ts +54 -0
- package/inkos/packages/cli/src/commands/radar.ts +60 -0
- package/inkos/packages/cli/src/commands/review.ts +253 -0
- package/inkos/packages/cli/src/commands/revise.ts +58 -0
- package/inkos/packages/cli/src/commands/short-fiction.ts +294 -0
- package/inkos/packages/cli/src/commands/status.ts +138 -0
- package/inkos/packages/cli/src/commands/studio.ts +194 -0
- package/inkos/packages/cli/src/commands/style.ts +99 -0
- package/inkos/packages/cli/src/commands/tui.ts +18 -0
- package/inkos/packages/cli/src/commands/update.ts +45 -0
- package/inkos/packages/cli/src/commands/write.ts +324 -0
- package/inkos/packages/cli/src/index.ts +5 -0
- package/inkos/packages/cli/src/interaction/tools.ts +49 -0
- package/inkos/packages/cli/src/localization.ts +215 -0
- package/inkos/packages/cli/src/program.ts +106 -0
- package/inkos/packages/cli/src/progress-text.ts +85 -0
- package/inkos/packages/cli/src/project-bootstrap.ts +175 -0
- package/inkos/packages/cli/src/runtime-requirements.ts +135 -0
- package/inkos/packages/cli/src/tui/__tests__/markdown.test.ts +64 -0
- package/inkos/packages/cli/src/tui/activity-state.ts +41 -0
- package/inkos/packages/cli/src/tui/agent-input.ts +264 -0
- package/inkos/packages/cli/src/tui/ansi.ts +72 -0
- package/inkos/packages/cli/src/tui/app.ts +130 -0
- package/inkos/packages/cli/src/tui/chat-depth.ts +22 -0
- package/inkos/packages/cli/src/tui/chat-draft.ts +42 -0
- package/inkos/packages/cli/src/tui/composer-caret.ts +22 -0
- package/inkos/packages/cli/src/tui/composer-display.ts +26 -0
- package/inkos/packages/cli/src/tui/dashboard-model.ts +164 -0
- package/inkos/packages/cli/src/tui/dashboard.tsx +544 -0
- package/inkos/packages/cli/src/tui/effects.ts +542 -0
- package/inkos/packages/cli/src/tui/i18n.ts +278 -0
- package/inkos/packages/cli/src/tui/input-history.ts +69 -0
- package/inkos/packages/cli/src/tui/local-commands.ts +55 -0
- package/inkos/packages/cli/src/tui/markdown.ts +64 -0
- package/inkos/packages/cli/src/tui/session-store.ts +6 -0
- package/inkos/packages/cli/src/tui/setup.ts +397 -0
- package/inkos/packages/cli/src/tui/slash-autocomplete.ts +62 -0
- package/inkos/packages/cli/src/tui/theme.ts +17 -0
- package/inkos/packages/cli/src/utils.ts +222 -0
- package/inkos/packages/cli/tsconfig.json +9 -0
- package/inkos/packages/core/genres/cozy.md +43 -0
- package/inkos/packages/core/genres/cultivation.md +42 -0
- package/inkos/packages/core/genres/dungeon-core.md +40 -0
- package/inkos/packages/core/genres/horror.md +51 -0
- package/inkos/packages/core/genres/isekai.md +43 -0
- package/inkos/packages/core/genres/litrpg.md +43 -0
- package/inkos/packages/core/genres/other.md +24 -0
- package/inkos/packages/core/genres/progression.md +41 -0
- package/inkos/packages/core/genres/romantasy.md +45 -0
- package/inkos/packages/core/genres/sci-fi.md +42 -0
- package/inkos/packages/core/genres/system-apocalypse.md +40 -0
- package/inkos/packages/core/genres/tower-climber.md +41 -0
- package/inkos/packages/core/genres/urban.md +53 -0
- package/inkos/packages/core/genres/xianxia.md +46 -0
- package/inkos/packages/core/genres/xuanhuan.md +64 -0
- package/inkos/packages/core/package.json +61 -0
- package/inkos/packages/core/src/__tests__/agent-max-tokens-policy.test.ts +29 -0
- package/inkos/packages/core/src/__tests__/agent-session.test.ts +866 -0
- package/inkos/packages/core/src/__tests__/agent-system-prompt.test.ts +167 -0
- package/inkos/packages/core/src/__tests__/agent-tools-params.test.ts +197 -0
- package/inkos/packages/core/src/__tests__/agent-tools.test.ts +421 -0
- package/inkos/packages/core/src/__tests__/ai-tells.test.ts +90 -0
- package/inkos/packages/core/src/__tests__/architect-phase5-consolidated.test.ts +445 -0
- package/inkos/packages/core/src/__tests__/architect-phase5.test.ts +455 -0
- package/inkos/packages/core/src/__tests__/architect-phase7.test.ts +210 -0
- package/inkos/packages/core/src/__tests__/architect.test.ts +859 -0
- package/inkos/packages/core/src/__tests__/audit-parse.test.ts +78 -0
- package/inkos/packages/core/src/__tests__/book-id.test.ts +26 -0
- package/inkos/packages/core/src/__tests__/book-session-store.test.ts +447 -0
- package/inkos/packages/core/src/__tests__/book-session.test.ts +113 -0
- package/inkos/packages/core/src/__tests__/chapter-analyzer.test.ts +574 -0
- package/inkos/packages/core/src/__tests__/chapter-memo-parser.test.ts +247 -0
- package/inkos/packages/core/src/__tests__/chapter-persistence.test.ts +198 -0
- package/inkos/packages/core/src/__tests__/chapter-review-cycle.test.ts +294 -0
- package/inkos/packages/core/src/__tests__/chapter-splitter.test.ts +156 -0
- package/inkos/packages/core/src/__tests__/chapter-state-recovery.test.ts +235 -0
- package/inkos/packages/core/src/__tests__/chapter-truth-validation.test.ts +253 -0
- package/inkos/packages/core/src/__tests__/composer.test.ts +627 -0
- package/inkos/packages/core/src/__tests__/config-loader.test.ts +325 -0
- package/inkos/packages/core/src/__tests__/config-migration.test.ts +102 -0
- package/inkos/packages/core/src/__tests__/consolidator.test.ts +32 -0
- package/inkos/packages/core/src/__tests__/context-filter.test.ts +60 -0
- package/inkos/packages/core/src/__tests__/context-transform.test.ts +108 -0
- package/inkos/packages/core/src/__tests__/continuity.test.ts +391 -0
- package/inkos/packages/core/src/__tests__/detection-insights.test.ts +59 -0
- package/inkos/packages/core/src/__tests__/detector.test.ts +86 -0
- package/inkos/packages/core/src/__tests__/draft-directive-parser.test.ts +386 -0
- package/inkos/packages/core/src/__tests__/edit-controller.test.ts +190 -0
- package/inkos/packages/core/src/__tests__/effective-llm-config.test.ts +486 -0
- package/inkos/packages/core/src/__tests__/fanfic-dimensions.test.ts +58 -0
- package/inkos/packages/core/src/__tests__/fanfic-models.test.ts +69 -0
- package/inkos/packages/core/src/__tests__/governed-working-set.test.ts +155 -0
- package/inkos/packages/core/src/__tests__/hook-arbiter.test.ts +124 -0
- package/inkos/packages/core/src/__tests__/hook-governance.test.ts +228 -0
- package/inkos/packages/core/src/__tests__/hook-health.test.ts +166 -0
- package/inkos/packages/core/src/__tests__/hook-ledger-validator.test.ts +236 -0
- package/inkos/packages/core/src/__tests__/hook-promotion.test.ts +192 -0
- package/inkos/packages/core/src/__tests__/hook-stale-detection.test.ts +136 -0
- package/inkos/packages/core/src/__tests__/index-notify-lazy.test.ts +20 -0
- package/inkos/packages/core/src/__tests__/interaction-chat-tokens.test.ts +170 -0
- package/inkos/packages/core/src/__tests__/interaction-models.test.ts +155 -0
- package/inkos/packages/core/src/__tests__/interaction-nl-router.test.ts +223 -0
- package/inkos/packages/core/src/__tests__/interaction-runtime.test.ts +633 -0
- package/inkos/packages/core/src/__tests__/interaction-tools.test.ts +343 -0
- package/inkos/packages/core/src/__tests__/length-metrics.test.ts +82 -0
- package/inkos/packages/core/src/__tests__/length-normalizer.test.ts +331 -0
- package/inkos/packages/core/src/__tests__/list-models.test.ts +109 -0
- package/inkos/packages/core/src/__tests__/llm-env.test.ts +31 -0
- package/inkos/packages/core/src/__tests__/logger.test.ts +175 -0
- package/inkos/packages/core/src/__tests__/long-span-fatigue.test.ts +160 -0
- package/inkos/packages/core/src/__tests__/memory-retrieval.test.ts +1303 -0
- package/inkos/packages/core/src/__tests__/models.test.ts +918 -0
- package/inkos/packages/core/src/__tests__/outline-paths.test.ts +97 -0
- package/inkos/packages/core/src/__tests__/path-safety.test.ts +22 -0
- package/inkos/packages/core/src/__tests__/persisted-governed-plan.test.ts +134 -0
- package/inkos/packages/core/src/__tests__/phase5-cleanup.test.ts +393 -0
- package/inkos/packages/core/src/__tests__/phase5-hotfix.test.ts +288 -0
- package/inkos/packages/core/src/__tests__/phase7-hotfix.test.ts +614 -0
- package/inkos/packages/core/src/__tests__/pipeline-agent.test.ts +354 -0
- package/inkos/packages/core/src/__tests__/pipeline-runner-memory-sync.test.ts +317 -0
- package/inkos/packages/core/src/__tests__/pipeline-runner.test.ts +5200 -0
- package/inkos/packages/core/src/__tests__/planner-context.test.ts +137 -0
- package/inkos/packages/core/src/__tests__/planner-prompts-ratio.test.ts +11 -0
- package/inkos/packages/core/src/__tests__/planner-prompts.test.ts +171 -0
- package/inkos/packages/core/src/__tests__/planner.test.ts +362 -0
- package/inkos/packages/core/src/__tests__/planning-materials.test.ts +90 -0
- package/inkos/packages/core/src/__tests__/polisher.test.ts +189 -0
- package/inkos/packages/core/src/__tests__/post-write-validator.test.ts +291 -0
- package/inkos/packages/core/src/__tests__/probe.test.ts +77 -0
- package/inkos/packages/core/src/__tests__/project-interaction.test.ts +241 -0
- package/inkos/packages/core/src/__tests__/provider.test.ts +953 -0
- package/inkos/packages/core/src/__tests__/providers-group.test.ts +34 -0
- package/inkos/packages/core/src/__tests__/providers-lookup.test.ts +81 -0
- package/inkos/packages/core/src/__tests__/providers-schema.test.ts +158 -0
- package/inkos/packages/core/src/__tests__/proxy-fetch.test.ts +75 -0
- package/inkos/packages/core/src/__tests__/revise-foundation.test.ts +514 -0
- package/inkos/packages/core/src/__tests__/reviser.test.ts +859 -0
- package/inkos/packages/core/src/__tests__/runtime-state-store.test.ts +388 -0
- package/inkos/packages/core/src/__tests__/scheduler.test.ts +123 -0
- package/inkos/packages/core/src/__tests__/secrets-migration.test.ts +71 -0
- package/inkos/packages/core/src/__tests__/secrets.test.ts +95 -0
- package/inkos/packages/core/src/__tests__/sensitive-words.test.ts +88 -0
- package/inkos/packages/core/src/__tests__/service-presets-regression.test.ts +73 -0
- package/inkos/packages/core/src/__tests__/service-resolver-regression.test.ts +75 -0
- package/inkos/packages/core/src/__tests__/service-resolver.test.ts +228 -0
- package/inkos/packages/core/src/__tests__/session-transcript-restore.test.ts +1311 -0
- package/inkos/packages/core/src/__tests__/session-transcript.test.ts +195 -0
- package/inkos/packages/core/src/__tests__/settler-delta-parser.test.ts +133 -0
- package/inkos/packages/core/src/__tests__/short-fiction-public.test.ts +241 -0
- package/inkos/packages/core/src/__tests__/spot-fix-patches.test.ts +104 -0
- package/inkos/packages/core/src/__tests__/state-manager.test.ts +1298 -0
- package/inkos/packages/core/src/__tests__/state-projections.test.ts +130 -0
- package/inkos/packages/core/src/__tests__/state-reducer.test.ts +372 -0
- package/inkos/packages/core/src/__tests__/state-validator-agent.test.ts +165 -0
- package/inkos/packages/core/src/__tests__/state-validator.test.ts +122 -0
- package/inkos/packages/core/src/__tests__/style-analyzer.test.ts +61 -0
- package/inkos/packages/core/src/__tests__/temperature-constraints.test.ts +57 -0
- package/inkos/packages/core/src/__tests__/v13-hotfix-round4.test.ts +343 -0
- package/inkos/packages/core/src/__tests__/verify-service.test.ts +77 -0
- package/inkos/packages/core/src/__tests__/webhook.test.ts +91 -0
- package/inkos/packages/core/src/__tests__/writer-parser.test.ts +348 -0
- package/inkos/packages/core/src/__tests__/writer-prompts.test.ts +269 -0
- package/inkos/packages/core/src/__tests__/writer.test.ts +1360 -0
- package/inkos/packages/core/src/agent/agent-session.ts +737 -0
- package/inkos/packages/core/src/agent/agent-system-prompt.ts +199 -0
- package/inkos/packages/core/src/agent/agent-tools.ts +835 -0
- package/inkos/packages/core/src/agent/context-transform.ts +85 -0
- package/inkos/packages/core/src/agent/index.ts +14 -0
- package/inkos/packages/core/src/agents/ai-tells.ts +161 -0
- package/inkos/packages/core/src/agents/architect.ts +1291 -0
- package/inkos/packages/core/src/agents/base.ts +100 -0
- package/inkos/packages/core/src/agents/chapter-analyzer.ts +634 -0
- package/inkos/packages/core/src/agents/composer.ts +469 -0
- package/inkos/packages/core/src/agents/consolidator.ts +218 -0
- package/inkos/packages/core/src/agents/continuity.ts +824 -0
- package/inkos/packages/core/src/agents/detection-insights.ts +72 -0
- package/inkos/packages/core/src/agents/detector.ts +224 -0
- package/inkos/packages/core/src/agents/en-prompt-sections.ts +129 -0
- package/inkos/packages/core/src/agents/fanfic-canon-importer.ts +146 -0
- package/inkos/packages/core/src/agents/fanfic-dimensions.ts +87 -0
- package/inkos/packages/core/src/agents/fanfic-prompt-sections.ts +109 -0
- package/inkos/packages/core/src/agents/foundation-reviewer.ts +204 -0
- package/inkos/packages/core/src/agents/length-normalizer.ts +218 -0
- package/inkos/packages/core/src/agents/observer-prompts.ts +127 -0
- package/inkos/packages/core/src/agents/planner-context.ts +297 -0
- package/inkos/packages/core/src/agents/planner-prompts.ts +404 -0
- package/inkos/packages/core/src/agents/planner.ts +783 -0
- package/inkos/packages/core/src/agents/polisher.ts +153 -0
- package/inkos/packages/core/src/agents/post-write-validator.ts +873 -0
- package/inkos/packages/core/src/agents/radar-source.ts +123 -0
- package/inkos/packages/core/src/agents/radar.ts +120 -0
- package/inkos/packages/core/src/agents/reviser.ts +701 -0
- package/inkos/packages/core/src/agents/rules-reader.ts +155 -0
- package/inkos/packages/core/src/agents/sensitive-words.ts +142 -0
- package/inkos/packages/core/src/agents/settler-delta-parser.ts +53 -0
- package/inkos/packages/core/src/agents/settler-parser.ts +38 -0
- package/inkos/packages/core/src/agents/settler-prompts.ts +230 -0
- package/inkos/packages/core/src/agents/short-fiction.ts +429 -0
- package/inkos/packages/core/src/agents/state-validator.ts +322 -0
- package/inkos/packages/core/src/agents/style-analyzer.ts +93 -0
- package/inkos/packages/core/src/agents/writer-parser.ts +178 -0
- package/inkos/packages/core/src/agents/writer-prompts.ts +899 -0
- package/inkos/packages/core/src/agents/writer.ts +1450 -0
- package/inkos/packages/core/src/index.ts +392 -0
- package/inkos/packages/core/src/interaction/book-session-store.ts +226 -0
- package/inkos/packages/core/src/interaction/draft-directive-parser.ts +266 -0
- package/inkos/packages/core/src/interaction/edit-controller.ts +270 -0
- package/inkos/packages/core/src/interaction/events.ts +41 -0
- package/inkos/packages/core/src/interaction/export-artifact.ts +151 -0
- package/inkos/packages/core/src/interaction/intents.ts +63 -0
- package/inkos/packages/core/src/interaction/modes.ts +13 -0
- package/inkos/packages/core/src/interaction/nl-router.ts +258 -0
- package/inkos/packages/core/src/interaction/project-control.ts +150 -0
- package/inkos/packages/core/src/interaction/project-session-store.ts +81 -0
- package/inkos/packages/core/src/interaction/project-tools.ts +704 -0
- package/inkos/packages/core/src/interaction/request-router.ts +5 -0
- package/inkos/packages/core/src/interaction/runtime.ts +1167 -0
- package/inkos/packages/core/src/interaction/session-transcript-legacy.ts +113 -0
- package/inkos/packages/core/src/interaction/session-transcript-restore.ts +607 -0
- package/inkos/packages/core/src/interaction/session-transcript-schema.ts +76 -0
- package/inkos/packages/core/src/interaction/session-transcript.ts +189 -0
- package/inkos/packages/core/src/interaction/session.ts +226 -0
- package/inkos/packages/core/src/interaction/truth-authority.ts +45 -0
- package/inkos/packages/core/src/llm/config-migration.ts +58 -0
- package/inkos/packages/core/src/llm/cover-providers.ts +45 -0
- package/inkos/packages/core/src/llm/provider.ts +1331 -0
- package/inkos/packages/core/src/llm/providers/endpoints/ai360.ts +42 -0
- package/inkos/packages/core/src/llm/providers/endpoints/anthropic.ts +82 -0
- package/inkos/packages/core/src/llm/providers/endpoints/astronCodingPlan.ts +30 -0
- package/inkos/packages/core/src/llm/providers/endpoints/baichuan.ts +28 -0
- package/inkos/packages/core/src/llm/providers/endpoints/bailian.ts +65 -0
- package/inkos/packages/core/src/llm/providers/endpoints/bailianCodingPlan.ts +30 -0
- package/inkos/packages/core/src/llm/providers/endpoints/custom.ts +22 -0
- package/inkos/packages/core/src/llm/providers/endpoints/deepseek.ts +35 -0
- package/inkos/packages/core/src/llm/providers/endpoints/giteeai.ts +41 -0
- package/inkos/packages/core/src/llm/providers/endpoints/githubCopilot.ts +43 -0
- package/inkos/packages/core/src/llm/providers/endpoints/glmCodingPlan.ts +28 -0
- package/inkos/packages/core/src/llm/providers/endpoints/google.ts +51 -0
- package/inkos/packages/core/src/llm/providers/endpoints/hunyuan.ts +42 -0
- package/inkos/packages/core/src/llm/providers/endpoints/infiniai.ts +72 -0
- package/inkos/packages/core/src/llm/providers/endpoints/internlm.ts +28 -0
- package/inkos/packages/core/src/llm/providers/endpoints/kimiCode.ts +23 -0
- package/inkos/packages/core/src/llm/providers/endpoints/kimiCodingPlan.ts +24 -0
- package/inkos/packages/core/src/llm/providers/endpoints/kkaiapi.ts +56 -0
- package/inkos/packages/core/src/llm/providers/endpoints/longcat.ts +25 -0
- package/inkos/packages/core/src/llm/providers/endpoints/minimax.ts +39 -0
- package/inkos/packages/core/src/llm/providers/endpoints/minimaxCodingPlan.ts +28 -0
- package/inkos/packages/core/src/llm/providers/endpoints/mistral.ts +40 -0
- package/inkos/packages/core/src/llm/providers/endpoints/modelscope.ts +30 -0
- package/inkos/packages/core/src/llm/providers/endpoints/moonshot.ts +39 -0
- package/inkos/packages/core/src/llm/providers/endpoints/newapi.ts +21 -0
- package/inkos/packages/core/src/llm/providers/endpoints/ollama.ts +73 -0
- package/inkos/packages/core/src/llm/providers/endpoints/openai.ts +77 -0
- package/inkos/packages/core/src/llm/providers/endpoints/opencodeCodingPlan.ts +30 -0
- package/inkos/packages/core/src/llm/providers/endpoints/openrouter.ts +87 -0
- package/inkos/packages/core/src/llm/providers/endpoints/ppio.ts +86 -0
- package/inkos/packages/core/src/llm/providers/endpoints/qiniu.ts +32 -0
- package/inkos/packages/core/src/llm/providers/endpoints/sensenova.ts +45 -0
- package/inkos/packages/core/src/llm/providers/endpoints/siliconcloud.ts +126 -0
- package/inkos/packages/core/src/llm/providers/endpoints/spark.ts +33 -0
- package/inkos/packages/core/src/llm/providers/endpoints/stepfun.ts +35 -0
- package/inkos/packages/core/src/llm/providers/endpoints/tencentcloud.ts +25 -0
- package/inkos/packages/core/src/llm/providers/endpoints/volcengine.ts +52 -0
- package/inkos/packages/core/src/llm/providers/endpoints/volcengineCodingPlan.ts +42 -0
- package/inkos/packages/core/src/llm/providers/endpoints/wenxin.ts +106 -0
- package/inkos/packages/core/src/llm/providers/endpoints/xai.ts +34 -0
- package/inkos/packages/core/src/llm/providers/endpoints/xiaomimimo.ts +26 -0
- package/inkos/packages/core/src/llm/providers/endpoints/zeroone.ts +34 -0
- package/inkos/packages/core/src/llm/providers/endpoints/zhipu.ts +61 -0
- package/inkos/packages/core/src/llm/providers/index.ts +71 -0
- package/inkos/packages/core/src/llm/providers/lookup.ts +70 -0
- package/inkos/packages/core/src/llm/providers/probe.ts +35 -0
- package/inkos/packages/core/src/llm/providers/types.ts +89 -0
- package/inkos/packages/core/src/llm/providers/verify.ts +104 -0
- package/inkos/packages/core/src/llm/secrets.ts +77 -0
- package/inkos/packages/core/src/llm/service-presets.ts +215 -0
- package/inkos/packages/core/src/llm/service-resolver.ts +91 -0
- package/inkos/packages/core/src/models/book-rules.ts +126 -0
- package/inkos/packages/core/src/models/book.ts +70 -0
- package/inkos/packages/core/src/models/chapter.ts +42 -0
- package/inkos/packages/core/src/models/detection.ts +25 -0
- package/inkos/packages/core/src/models/genre-profile.ts +36 -0
- package/inkos/packages/core/src/models/input-governance.ts +99 -0
- package/inkos/packages/core/src/models/length-governance.ts +46 -0
- package/inkos/packages/core/src/models/project.ts +161 -0
- package/inkos/packages/core/src/models/runtime-state.ts +144 -0
- package/inkos/packages/core/src/models/state.ts +52 -0
- package/inkos/packages/core/src/models/style-profile.ts +15 -0
- package/inkos/packages/core/src/notify/dispatcher.ts +96 -0
- package/inkos/packages/core/src/notify/feishu.ts +34 -0
- package/inkos/packages/core/src/notify/telegram.ts +25 -0
- package/inkos/packages/core/src/notify/webhook.ts +58 -0
- package/inkos/packages/core/src/notify/wechat-work.ts +22 -0
- package/inkos/packages/core/src/pipeline/agent.ts +691 -0
- package/inkos/packages/core/src/pipeline/chapter-persistence.ts +79 -0
- package/inkos/packages/core/src/pipeline/chapter-review-cycle.ts +324 -0
- package/inkos/packages/core/src/pipeline/chapter-state-recovery.ts +236 -0
- package/inkos/packages/core/src/pipeline/chapter-truth-validation.ts +145 -0
- package/inkos/packages/core/src/pipeline/detection-runner.ts +164 -0
- package/inkos/packages/core/src/pipeline/persisted-governed-plan.ts +216 -0
- package/inkos/packages/core/src/pipeline/runner.ts +3438 -0
- package/inkos/packages/core/src/pipeline/scheduler.ts +411 -0
- package/inkos/packages/core/src/pipeline/short-fiction-runner.ts +801 -0
- package/inkos/packages/core/src/prompts/index.ts +1 -0
- package/inkos/packages/core/src/prompts/short-fiction.ts +273 -0
- package/inkos/packages/core/src/state/manager.ts +560 -0
- package/inkos/packages/core/src/state/memory-db.ts +359 -0
- package/inkos/packages/core/src/state/runtime-state-store.ts +164 -0
- package/inkos/packages/core/src/state/state-bootstrap.ts +657 -0
- package/inkos/packages/core/src/state/state-projections.ts +255 -0
- package/inkos/packages/core/src/state/state-reducer.ts +260 -0
- package/inkos/packages/core/src/state/state-validator.ts +117 -0
- package/inkos/packages/core/src/utils/analytics.ts +92 -0
- package/inkos/packages/core/src/utils/book-id.ts +31 -0
- package/inkos/packages/core/src/utils/cadence-policy.ts +46 -0
- package/inkos/packages/core/src/utils/chapter-cadence.ts +211 -0
- package/inkos/packages/core/src/utils/chapter-memo-parser.ts +157 -0
- package/inkos/packages/core/src/utils/chapter-splitter.ts +80 -0
- package/inkos/packages/core/src/utils/config-loader.ts +29 -0
- package/inkos/packages/core/src/utils/context-assembly.ts +98 -0
- package/inkos/packages/core/src/utils/context-filter.ts +190 -0
- package/inkos/packages/core/src/utils/effective-llm-config.ts +529 -0
- package/inkos/packages/core/src/utils/governed-context.ts +101 -0
- package/inkos/packages/core/src/utils/governed-working-set.ts +395 -0
- package/inkos/packages/core/src/utils/hook-arbiter.ts +332 -0
- package/inkos/packages/core/src/utils/hook-governance.ts +199 -0
- package/inkos/packages/core/src/utils/hook-health.ts +189 -0
- package/inkos/packages/core/src/utils/hook-ledger-validator.ts +277 -0
- package/inkos/packages/core/src/utils/hook-lifecycle.ts +224 -0
- package/inkos/packages/core/src/utils/hook-policy.ts +115 -0
- package/inkos/packages/core/src/utils/hook-promotion.ts +313 -0
- package/inkos/packages/core/src/utils/hook-stale-detection.ts +168 -0
- package/inkos/packages/core/src/utils/length-metrics.ts +123 -0
- package/inkos/packages/core/src/utils/llm-endpoint-auth.ts +40 -0
- package/inkos/packages/core/src/utils/llm-env.ts +74 -0
- package/inkos/packages/core/src/utils/logger.ts +123 -0
- package/inkos/packages/core/src/utils/long-span-fatigue.ts +545 -0
- package/inkos/packages/core/src/utils/memory-retrieval.ts +527 -0
- package/inkos/packages/core/src/utils/narrative-control.ts +177 -0
- package/inkos/packages/core/src/utils/outline-paths.ts +275 -0
- package/inkos/packages/core/src/utils/path-safety.ts +11 -0
- package/inkos/packages/core/src/utils/planning-materials.ts +185 -0
- package/inkos/packages/core/src/utils/pov-filter.ts +149 -0
- package/inkos/packages/core/src/utils/proxy-fetch.ts +44 -0
- package/inkos/packages/core/src/utils/runtime-writer.ts +41 -0
- package/inkos/packages/core/src/utils/spot-fix-patches.ts +189 -0
- package/inkos/packages/core/src/utils/story-markdown.ts +346 -0
- package/inkos/packages/core/src/utils/web-search.ts +82 -0
- package/inkos/packages/core/src/utils/writing-methodology.ts +164 -0
- package/inkos/packages/core/tsconfig.json +8 -0
- package/inkos/packages/core/vitest.config.ts +7 -0
- package/inkos/packages/studio/components.json +25 -0
- package/inkos/packages/studio/index.html +13 -0
- package/inkos/packages/studio/package.json +72 -0
- package/inkos/packages/studio/postcss.config.js +3 -0
- package/inkos/packages/studio/src/App.test.ts +25 -0
- package/inkos/packages/studio/src/App.tsx +280 -0
- package/inkos/packages/studio/src/api/__tests__/normalize-base-url.test.ts +40 -0
- package/inkos/packages/studio/src/api/book-create.test.ts +104 -0
- package/inkos/packages/studio/src/api/book-create.ts +94 -0
- package/inkos/packages/studio/src/api/errors.ts +17 -0
- package/inkos/packages/studio/src/api/index.ts +30 -0
- package/inkos/packages/studio/src/api/lib/run-store.ts +177 -0
- package/inkos/packages/studio/src/api/lib/sse.ts +50 -0
- package/inkos/packages/studio/src/api/phase5-hotfix.test.ts +335 -0
- package/inkos/packages/studio/src/api/safety.ts +6 -0
- package/inkos/packages/studio/src/api/server.test.ts +3162 -0
- package/inkos/packages/studio/src/api/server.ts +3666 -0
- package/inkos/packages/studio/src/api/v13-hotfix-round4.test.ts +226 -0
- package/inkos/packages/studio/src/app-state.test.ts +8 -0
- package/inkos/packages/studio/src/app-state.ts +1 -0
- package/inkos/packages/studio/src/components/ConfirmDialog.tsx +95 -0
- package/inkos/packages/studio/src/components/ServiceConfigSourceCard.tsx +139 -0
- package/inkos/packages/studio/src/components/ServiceQuickLinks.tsx +65 -0
- package/inkos/packages/studio/src/components/Sidebar.tsx +652 -0
- package/inkos/packages/studio/src/components/ai-elements/code-block.tsx +562 -0
- package/inkos/packages/studio/src/components/ai-elements/confirmation.tsx +174 -0
- package/inkos/packages/studio/src/components/ai-elements/message.tsx +360 -0
- package/inkos/packages/studio/src/components/ai-elements/prompt-input.tsx +1457 -0
- package/inkos/packages/studio/src/components/ai-elements/reasoning.tsx +226 -0
- package/inkos/packages/studio/src/components/ai-elements/shimmer.tsx +77 -0
- package/inkos/packages/studio/src/components/ai-elements/tool.tsx +173 -0
- package/inkos/packages/studio/src/components/chat/BookSidebar.tsx +291 -0
- package/inkos/packages/studio/src/components/chat/ChatMessage.tsx +39 -0
- package/inkos/packages/studio/src/components/chat/QuickActions.tsx +73 -0
- package/inkos/packages/studio/src/components/chat/ToolExecutionSteps.tsx +320 -0
- package/inkos/packages/studio/src/components/chat/__tests__/ToolExecutionSteps.test.ts +114 -0
- package/inkos/packages/studio/src/components/chat-utils.ts +56 -0
- package/inkos/packages/studio/src/components/chatbar-state.test.ts +69 -0
- package/inkos/packages/studio/src/components/sidebar/ChaptersSection.tsx +66 -0
- package/inkos/packages/studio/src/components/sidebar/CharacterSection.tsx +129 -0
- package/inkos/packages/studio/src/components/sidebar/FoundationSection.tsx +61 -0
- package/inkos/packages/studio/src/components/sidebar/ProgressSection.tsx +124 -0
- package/inkos/packages/studio/src/components/sidebar/SidebarCard.tsx +30 -0
- package/inkos/packages/studio/src/components/sidebar/SummarySection.tsx +89 -0
- package/inkos/packages/studio/src/components/ui/alert.tsx +76 -0
- package/inkos/packages/studio/src/components/ui/badge.tsx +52 -0
- package/inkos/packages/studio/src/components/ui/button-group.tsx +87 -0
- package/inkos/packages/studio/src/components/ui/button.tsx +58 -0
- package/inkos/packages/studio/src/components/ui/collapsible.tsx +19 -0
- package/inkos/packages/studio/src/components/ui/command.tsx +194 -0
- package/inkos/packages/studio/src/components/ui/dialog.tsx +158 -0
- package/inkos/packages/studio/src/components/ui/dropdown-menu.tsx +266 -0
- package/inkos/packages/studio/src/components/ui/hover-card.tsx +51 -0
- package/inkos/packages/studio/src/components/ui/input-group.tsx +158 -0
- package/inkos/packages/studio/src/components/ui/input.tsx +20 -0
- package/inkos/packages/studio/src/components/ui/select.tsx +199 -0
- package/inkos/packages/studio/src/components/ui/separator.tsx +23 -0
- package/inkos/packages/studio/src/components/ui/spinner.tsx +10 -0
- package/inkos/packages/studio/src/components/ui/textarea.tsx +18 -0
- package/inkos/packages/studio/src/components/ui/tooltip.tsx +66 -0
- package/inkos/packages/studio/src/constants/service-groups.ts +29 -0
- package/inkos/packages/studio/src/hooks/use-api.test.ts +93 -0
- package/inkos/packages/studio/src/hooks/use-api.ts +189 -0
- package/inkos/packages/studio/src/hooks/use-book-activity.test.ts +129 -0
- package/inkos/packages/studio/src/hooks/use-book-activity.ts +180 -0
- package/inkos/packages/studio/src/hooks/use-colors.ts +27 -0
- package/inkos/packages/studio/src/hooks/use-hash-route.test.ts +101 -0
- package/inkos/packages/studio/src/hooks/use-hash-route.ts +89 -0
- package/inkos/packages/studio/src/hooks/use-i18n.ts +289 -0
- package/inkos/packages/studio/src/hooks/use-session-events.ts +64 -0
- package/inkos/packages/studio/src/hooks/use-sse.test.ts +52 -0
- package/inkos/packages/studio/src/hooks/use-sse.ts +92 -0
- package/inkos/packages/studio/src/hooks/use-theme.test.ts +31 -0
- package/inkos/packages/studio/src/hooks/use-theme.ts +75 -0
- package/inkos/packages/studio/src/index.css +323 -0
- package/inkos/packages/studio/src/lib/error-copy.test.ts +34 -0
- package/inkos/packages/studio/src/lib/error-copy.ts +37 -0
- package/inkos/packages/studio/src/lib/utils.ts +6 -0
- package/inkos/packages/studio/src/main.tsx +10 -0
- package/inkos/packages/studio/src/pages/Analytics.tsx +80 -0
- package/inkos/packages/studio/src/pages/BookCreate.tsx +895 -0
- package/inkos/packages/studio/src/pages/BookDetail.tsx +652 -0
- package/inkos/packages/studio/src/pages/ChapterReader.tsx +266 -0
- package/inkos/packages/studio/src/pages/ChatPage.tsx +521 -0
- package/inkos/packages/studio/src/pages/DaemonControl.tsx +116 -0
- package/inkos/packages/studio/src/pages/Dashboard.tsx +379 -0
- package/inkos/packages/studio/src/pages/DoctorView.tsx +82 -0
- package/inkos/packages/studio/src/pages/GenreManager.tsx +464 -0
- package/inkos/packages/studio/src/pages/ImportManager.tsx +216 -0
- package/inkos/packages/studio/src/pages/LanguageSelector.tsx +74 -0
- package/inkos/packages/studio/src/pages/LogViewer.tsx +82 -0
- package/inkos/packages/studio/src/pages/RadarView.tsx +157 -0
- package/inkos/packages/studio/src/pages/ServiceDetailPage.tsx +393 -0
- package/inkos/packages/studio/src/pages/ServiceListPage.tsx +463 -0
- package/inkos/packages/studio/src/pages/StyleManager.tsx +225 -0
- package/inkos/packages/studio/src/pages/TruthFiles.tsx +194 -0
- package/inkos/packages/studio/src/pages/chat-page-state.test.ts +206 -0
- package/inkos/packages/studio/src/pages/chat-page-state.ts +112 -0
- package/inkos/packages/studio/src/pages/page-state.test.ts +258 -0
- package/inkos/packages/studio/src/pages/service-detail-state.test.ts +294 -0
- package/inkos/packages/studio/src/pages/service-detail-state.ts +234 -0
- package/inkos/packages/studio/src/pages/style-manager-state.test.ts +22 -0
- package/inkos/packages/studio/src/pages/truth-files-state.test.ts +61 -0
- package/inkos/packages/studio/src/shared/contracts.ts +143 -0
- package/inkos/packages/studio/src/store/chat/__tests__/message-parts.test.ts +172 -0
- package/inkos/packages/studio/src/store/chat/index.ts +3 -0
- package/inkos/packages/studio/src/store/chat/initialState.ts +8 -0
- package/inkos/packages/studio/src/store/chat/message-policy.test.ts +16 -0
- package/inkos/packages/studio/src/store/chat/message-policy.ts +5 -0
- package/inkos/packages/studio/src/store/chat/parts-builder.ts +187 -0
- package/inkos/packages/studio/src/store/chat/selectors.ts +13 -0
- package/inkos/packages/studio/src/store/chat/slices/create/action.ts +10 -0
- package/inkos/packages/studio/src/store/chat/slices/create/initialState.ts +9 -0
- package/inkos/packages/studio/src/store/chat/slices/message/action.ts +417 -0
- package/inkos/packages/studio/src/store/chat/slices/message/initialState.ts +10 -0
- package/inkos/packages/studio/src/store/chat/slices/message/runtime.test.ts +21 -0
- package/inkos/packages/studio/src/store/chat/slices/message/runtime.ts +233 -0
- package/inkos/packages/studio/src/store/chat/slices/message/stream-events.ts +272 -0
- package/inkos/packages/studio/src/store/chat/store.ts +11 -0
- package/inkos/packages/studio/src/store/chat/types.ts +169 -0
- package/inkos/packages/studio/src/store/service/index.ts +2 -0
- package/inkos/packages/studio/src/store/service/store.ts +123 -0
- package/inkos/packages/studio/src/store/service/types.ts +50 -0
- package/inkos/packages/studio/tsconfig.json +24 -0
- package/inkos/packages/studio/tsconfig.server.json +11 -0
- package/inkos/packages/studio/vite.config.ts +34 -0
- package/inkos/packages/studio/vitest.config.ts +14 -0
- package/inkos/pnpm-lock.yaml +9569 -0
- package/inkos/pnpm-workspace.yaml +2 -0
- package/inkos/scripts/prepare-package-for-publish.mjs +135 -0
- package/inkos/scripts/restore-package-json.mjs +31 -0
- package/inkos/scripts/set-package-versions.mjs +74 -0
- package/inkos/scripts/verify-no-workspace-protocol.mjs +140 -0
- package/inkos/skills/SKILL.md +654 -0
- package/inkos/tsconfig.json +19 -0
- package/package.json +4 -3
- /package/.next/static/{F2hMZMf1IyCVAWpkbtRz7 → -3vIrBZXdQ0rp7Wa3Kz40}/_buildManifest.js +0 -0
- /package/.next/static/{F2hMZMf1IyCVAWpkbtRz7 → -3vIrBZXdQ0rp7Wa3Kz40}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,1298 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, writeFile, readFile, mkdir, stat } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { StateManager } from "../state/manager.js";
|
|
6
|
+
import type { BookConfig } from "../models/book.js";
|
|
7
|
+
import type { ChapterMeta } from "../models/chapter.js";
|
|
8
|
+
|
|
9
|
+
describe("StateManager", () => {
|
|
10
|
+
let tempDir: string;
|
|
11
|
+
let manager: StateManager;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
tempDir = await mkdtemp(join(tmpdir(), "inkos-test-"));
|
|
15
|
+
manager = new StateManager(tempDir);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// -------------------------------------------------------------------------
|
|
23
|
+
// BookConfig persistence
|
|
24
|
+
// -------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
describe("saveBookConfig / loadBookConfig", () => {
|
|
27
|
+
const bookConfig: BookConfig = {
|
|
28
|
+
id: "test-book",
|
|
29
|
+
title: "Test Novel",
|
|
30
|
+
platform: "tomato",
|
|
31
|
+
genre: "xuanhuan",
|
|
32
|
+
status: "active",
|
|
33
|
+
targetChapters: 200,
|
|
34
|
+
chapterWordCount: 3000,
|
|
35
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
36
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
it("round-trips a BookConfig through save and load", async () => {
|
|
40
|
+
await manager.saveBookConfig("test-book", bookConfig);
|
|
41
|
+
const loaded = await manager.loadBookConfig("test-book");
|
|
42
|
+
expect(loaded).toEqual(bookConfig);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("creates the book directory on save", async () => {
|
|
46
|
+
await manager.saveBookConfig("new-book", {
|
|
47
|
+
...bookConfig,
|
|
48
|
+
id: "new-book",
|
|
49
|
+
});
|
|
50
|
+
const dirStat = await stat(manager.bookDir("new-book"));
|
|
51
|
+
expect(dirStat.isDirectory()).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("throws when loading a non-existent book", async () => {
|
|
55
|
+
await expect(manager.loadBookConfig("nope")).rejects.toThrow();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// -------------------------------------------------------------------------
|
|
60
|
+
// ChapterIndex persistence
|
|
61
|
+
// -------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
describe("saveChapterIndex / loadChapterIndex", () => {
|
|
64
|
+
const chapters: ReadonlyArray<ChapterMeta> = [
|
|
65
|
+
{
|
|
66
|
+
number: 1,
|
|
67
|
+
title: "Ch1",
|
|
68
|
+
status: "drafted",
|
|
69
|
+
wordCount: 3000,
|
|
70
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
71
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
72
|
+
auditIssues: [],
|
|
73
|
+
lengthWarnings: [],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
number: 2,
|
|
77
|
+
title: "Ch2",
|
|
78
|
+
status: "drafting",
|
|
79
|
+
wordCount: 0,
|
|
80
|
+
createdAt: "2026-01-02T00:00:00Z",
|
|
81
|
+
updatedAt: "2026-01-02T00:00:00Z",
|
|
82
|
+
auditIssues: ["pacing issue"],
|
|
83
|
+
lengthWarnings: [],
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
it("round-trips chapter index through save and load", async () => {
|
|
88
|
+
await manager.saveChapterIndex("book-a", chapters);
|
|
89
|
+
const loaded = await manager.loadChapterIndex("book-a");
|
|
90
|
+
expect(loaded).toEqual(chapters);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns empty array when no index exists", async () => {
|
|
94
|
+
const loaded = await manager.loadChapterIndex("nonexistent");
|
|
95
|
+
expect(loaded).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("creates the chapters directory on save", async () => {
|
|
99
|
+
await manager.saveChapterIndex("book-b", []);
|
|
100
|
+
const dirStat = await stat(
|
|
101
|
+
join(manager.bookDir("book-b"), "chapters"),
|
|
102
|
+
);
|
|
103
|
+
expect(dirStat.isDirectory()).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// -------------------------------------------------------------------------
|
|
108
|
+
// getNextChapterNumber
|
|
109
|
+
// -------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
describe("getNextChapterNumber", () => {
|
|
112
|
+
it("returns 1 for an empty book (no chapters)", async () => {
|
|
113
|
+
const next = await manager.getNextChapterNumber("empty-book");
|
|
114
|
+
expect(next).toBe(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns the first missing chapter when the chapter index has gaps", async () => {
|
|
118
|
+
const chapters: ReadonlyArray<ChapterMeta> = [
|
|
119
|
+
{
|
|
120
|
+
number: 1,
|
|
121
|
+
title: "Ch1",
|
|
122
|
+
status: "published",
|
|
123
|
+
wordCount: 3000,
|
|
124
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
125
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
126
|
+
auditIssues: [],
|
|
127
|
+
lengthWarnings: [],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
number: 5,
|
|
131
|
+
title: "Ch5",
|
|
132
|
+
status: "drafted",
|
|
133
|
+
wordCount: 2800,
|
|
134
|
+
createdAt: "2026-01-05T00:00:00Z",
|
|
135
|
+
updatedAt: "2026-01-05T00:00:00Z",
|
|
136
|
+
auditIssues: [],
|
|
137
|
+
lengthWarnings: [],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
number: 3,
|
|
141
|
+
title: "Ch3",
|
|
142
|
+
status: "approved",
|
|
143
|
+
wordCount: 3100,
|
|
144
|
+
createdAt: "2026-01-03T00:00:00Z",
|
|
145
|
+
updatedAt: "2026-01-03T00:00:00Z",
|
|
146
|
+
auditIssues: [],
|
|
147
|
+
lengthWarnings: [],
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
await manager.saveChapterIndex("book-x", chapters);
|
|
151
|
+
const next = await manager.getNextChapterNumber("book-x");
|
|
152
|
+
expect(next).toBe(2);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("returns 2 when only chapter 1 exists", async () => {
|
|
156
|
+
const chapters: ReadonlyArray<ChapterMeta> = [
|
|
157
|
+
{
|
|
158
|
+
number: 1,
|
|
159
|
+
title: "Ch1",
|
|
160
|
+
status: "drafted",
|
|
161
|
+
wordCount: 3000,
|
|
162
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
163
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
164
|
+
auditIssues: [],
|
|
165
|
+
lengthWarnings: [],
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
await manager.saveChapterIndex("book-y", chapters);
|
|
169
|
+
const next = await manager.getNextChapterNumber("book-y");
|
|
170
|
+
expect(next).toBe(2);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("uses durable story progress when chapter index lags behind persisted chapter files", async () => {
|
|
174
|
+
const bookId = "stale-index-book";
|
|
175
|
+
const bookDir = manager.bookDir(bookId);
|
|
176
|
+
const chaptersDir = join(bookDir, "chapters");
|
|
177
|
+
const storyDir = join(bookDir, "story");
|
|
178
|
+
await mkdir(chaptersDir, { recursive: true });
|
|
179
|
+
await mkdir(storyDir, { recursive: true });
|
|
180
|
+
await Promise.all([
|
|
181
|
+
manager.saveChapterIndex(bookId, [
|
|
182
|
+
{
|
|
183
|
+
number: 1,
|
|
184
|
+
title: "Ch1",
|
|
185
|
+
status: "ready-for-review",
|
|
186
|
+
wordCount: 3000,
|
|
187
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
188
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
189
|
+
auditIssues: [],
|
|
190
|
+
lengthWarnings: [],
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
number: 2,
|
|
194
|
+
title: "Ch2",
|
|
195
|
+
status: "ready-for-review",
|
|
196
|
+
wordCount: 3000,
|
|
197
|
+
createdAt: "2026-01-02T00:00:00Z",
|
|
198
|
+
updatedAt: "2026-01-02T00:00:00Z",
|
|
199
|
+
auditIssues: [],
|
|
200
|
+
lengthWarnings: [],
|
|
201
|
+
},
|
|
202
|
+
]),
|
|
203
|
+
writeFile(
|
|
204
|
+
join(chaptersDir, "0003_Lantern_Vault.md"),
|
|
205
|
+
"# Chapter 3: Lantern Vault\n\nPersisted body.",
|
|
206
|
+
"utf-8",
|
|
207
|
+
),
|
|
208
|
+
writeFile(
|
|
209
|
+
join(storyDir, "current_state.md"),
|
|
210
|
+
[
|
|
211
|
+
"# Current State",
|
|
212
|
+
"",
|
|
213
|
+
"| Field | Value |",
|
|
214
|
+
"| --- | --- |",
|
|
215
|
+
"| Current Chapter | 3 |",
|
|
216
|
+
"| Current Goal | Enter the vault without alerting the wardens |",
|
|
217
|
+
"",
|
|
218
|
+
].join("\n"),
|
|
219
|
+
"utf-8",
|
|
220
|
+
),
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
const next = await manager.getNextChapterNumber(bookId);
|
|
224
|
+
|
|
225
|
+
expect(next).toBe(4);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("ignores non-contiguous poisoned chapter numbers when calculating the next chapter", async () => {
|
|
229
|
+
const bookId = "poisoned-next-chapter-book";
|
|
230
|
+
const bookDir = manager.bookDir(bookId);
|
|
231
|
+
const chaptersDir = join(bookDir, "chapters");
|
|
232
|
+
const storyDir = join(bookDir, "story");
|
|
233
|
+
const stateDir = join(storyDir, "state");
|
|
234
|
+
await mkdir(chaptersDir, { recursive: true });
|
|
235
|
+
await mkdir(stateDir, { recursive: true });
|
|
236
|
+
|
|
237
|
+
const indexedChapters: ReadonlyArray<ChapterMeta> = [
|
|
238
|
+
...Array.from({ length: 12 }, (_, index) => ({
|
|
239
|
+
number: index + 1,
|
|
240
|
+
title: `Ch${index + 1}`,
|
|
241
|
+
status: "ready-for-review" as const,
|
|
242
|
+
wordCount: 3000,
|
|
243
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
244
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
245
|
+
auditIssues: [],
|
|
246
|
+
lengthWarnings: [],
|
|
247
|
+
})),
|
|
248
|
+
{
|
|
249
|
+
number: 142,
|
|
250
|
+
title: "Poisoned Ch142",
|
|
251
|
+
status: "audit-failed",
|
|
252
|
+
wordCount: 3200,
|
|
253
|
+
createdAt: "2026-01-13T00:00:00Z",
|
|
254
|
+
updatedAt: "2026-01-13T00:00:00Z",
|
|
255
|
+
auditIssues: [],
|
|
256
|
+
lengthWarnings: [],
|
|
257
|
+
},
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
await manager.saveChapterIndex(bookId, indexedChapters);
|
|
261
|
+
await Promise.all([
|
|
262
|
+
...Array.from({ length: 12 }, (_, index) => writeFile(
|
|
263
|
+
join(chaptersDir, `${String(index + 1).padStart(4, "0")}_Ch${index + 1}.md`),
|
|
264
|
+
`# Chapter ${index + 1}\n\nStable body.`,
|
|
265
|
+
"utf-8",
|
|
266
|
+
)),
|
|
267
|
+
writeFile(
|
|
268
|
+
join(chaptersDir, "0142_Poisoned.md"),
|
|
269
|
+
"# Chapter 142\n\nPoisoned body.",
|
|
270
|
+
"utf-8",
|
|
271
|
+
),
|
|
272
|
+
writeFile(
|
|
273
|
+
join(storyDir, "current_state.md"),
|
|
274
|
+
[
|
|
275
|
+
"# Current State",
|
|
276
|
+
"",
|
|
277
|
+
"| Field | Value |",
|
|
278
|
+
"| --- | --- |",
|
|
279
|
+
"| Current Chapter | 12 |",
|
|
280
|
+
"| Current Goal | Enter the next true chapter cleanly |",
|
|
281
|
+
"",
|
|
282
|
+
].join("\n"),
|
|
283
|
+
"utf-8",
|
|
284
|
+
),
|
|
285
|
+
writeFile(
|
|
286
|
+
join(storyDir, "pending_hooks.md"),
|
|
287
|
+
[
|
|
288
|
+
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
|
|
289
|
+
"| --- | --- | --- | --- | --- | --- | --- |",
|
|
290
|
+
"| H001 | 1 | mystery | progressing | 《三体》游戏内第141号文明继续展开 | Reveal the true enemy | Narrative text must not drive chapter progress |",
|
|
291
|
+
"",
|
|
292
|
+
].join("\n"),
|
|
293
|
+
"utf-8",
|
|
294
|
+
),
|
|
295
|
+
writeFile(
|
|
296
|
+
join(storyDir, "chapter_summaries.md"),
|
|
297
|
+
[
|
|
298
|
+
"| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
|
|
299
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
300
|
+
...Array.from({ length: 12 }, (_, index) =>
|
|
301
|
+
`| ${index + 1} | Ch${index + 1} | Lin Yue | Event ${index + 1} | Shift ${index + 1} | Hook ${index + 1} | tense | mainline |`),
|
|
302
|
+
"| 142 | Poisoned Ch142 | Lin Yue | Poisoned event | Poisoned shift | Poisoned hook | tense | mainline |",
|
|
303
|
+
"",
|
|
304
|
+
].join("\n"),
|
|
305
|
+
"utf-8",
|
|
306
|
+
),
|
|
307
|
+
writeFile(join(stateDir, "manifest.json"), JSON.stringify({
|
|
308
|
+
schemaVersion: 2,
|
|
309
|
+
language: "en",
|
|
310
|
+
lastAppliedChapter: 141,
|
|
311
|
+
projectionVersion: 1,
|
|
312
|
+
migrationWarnings: [],
|
|
313
|
+
}, null, 2), "utf-8"),
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
const next = await manager.getNextChapterNumber(bookId);
|
|
317
|
+
|
|
318
|
+
expect(next).toBe(13);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// -------------------------------------------------------------------------
|
|
323
|
+
// listBooks
|
|
324
|
+
// -------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
describe("listBooks", () => {
|
|
327
|
+
it("returns empty array when no books directory exists", async () => {
|
|
328
|
+
const books = await manager.listBooks();
|
|
329
|
+
expect(books).toEqual([]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("returns book IDs for directories with book.json", async () => {
|
|
333
|
+
const bookConfig: BookConfig = {
|
|
334
|
+
id: "alpha",
|
|
335
|
+
title: "Alpha",
|
|
336
|
+
platform: "tomato",
|
|
337
|
+
genre: "urban",
|
|
338
|
+
status: "active",
|
|
339
|
+
targetChapters: 100,
|
|
340
|
+
chapterWordCount: 3000,
|
|
341
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
342
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
343
|
+
};
|
|
344
|
+
await manager.saveBookConfig("alpha", bookConfig);
|
|
345
|
+
await manager.saveBookConfig("beta", { ...bookConfig, id: "beta", title: "Beta" });
|
|
346
|
+
|
|
347
|
+
// Create a decoy directory without book.json
|
|
348
|
+
await mkdir(join(manager.booksDir, "not-a-book"), { recursive: true });
|
|
349
|
+
|
|
350
|
+
const books = await manager.listBooks();
|
|
351
|
+
expect(books).toContain("alpha");
|
|
352
|
+
expect(books).toContain("beta");
|
|
353
|
+
expect(books).not.toContain("not-a-book");
|
|
354
|
+
expect(books).toHaveLength(2);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// -------------------------------------------------------------------------
|
|
359
|
+
// snapshotState / restoreState
|
|
360
|
+
// -------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
describe("snapshotState / restoreState", () => {
|
|
363
|
+
const bookId = "snap-book";
|
|
364
|
+
|
|
365
|
+
beforeEach(async () => {
|
|
366
|
+
const storyDir = join(manager.bookDir(bookId), "story");
|
|
367
|
+
await mkdir(storyDir, { recursive: true });
|
|
368
|
+
await writeFile(
|
|
369
|
+
join(storyDir, "current_state.md"),
|
|
370
|
+
"# State at ch1",
|
|
371
|
+
"utf-8",
|
|
372
|
+
);
|
|
373
|
+
await writeFile(
|
|
374
|
+
join(storyDir, "particle_ledger.md"),
|
|
375
|
+
"# Ledger at ch1",
|
|
376
|
+
"utf-8",
|
|
377
|
+
);
|
|
378
|
+
await writeFile(
|
|
379
|
+
join(storyDir, "pending_hooks.md"),
|
|
380
|
+
"# Hooks at ch1",
|
|
381
|
+
"utf-8",
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("snapshots current state files to a numbered directory", async () => {
|
|
386
|
+
await manager.snapshotState(bookId, 1);
|
|
387
|
+
|
|
388
|
+
const snapshotDir = join(
|
|
389
|
+
manager.bookDir(bookId),
|
|
390
|
+
"story",
|
|
391
|
+
"snapshots",
|
|
392
|
+
"1",
|
|
393
|
+
);
|
|
394
|
+
const state = await readFile(
|
|
395
|
+
join(snapshotDir, "current_state.md"),
|
|
396
|
+
"utf-8",
|
|
397
|
+
);
|
|
398
|
+
expect(state).toBe("# State at ch1");
|
|
399
|
+
|
|
400
|
+
const ledger = await readFile(
|
|
401
|
+
join(snapshotDir, "particle_ledger.md"),
|
|
402
|
+
"utf-8",
|
|
403
|
+
);
|
|
404
|
+
expect(ledger).toBe("# Ledger at ch1");
|
|
405
|
+
|
|
406
|
+
const hooks = await readFile(
|
|
407
|
+
join(snapshotDir, "pending_hooks.md"),
|
|
408
|
+
"utf-8",
|
|
409
|
+
);
|
|
410
|
+
expect(hooks).toBe("# Hooks at ch1");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("copies structured runtime state into snapshot/state when present", async () => {
|
|
414
|
+
const stateDir = manager.stateDir(bookId);
|
|
415
|
+
await mkdir(stateDir, { recursive: true });
|
|
416
|
+
await writeFile(
|
|
417
|
+
join(stateDir, "manifest.json"),
|
|
418
|
+
JSON.stringify({
|
|
419
|
+
schemaVersion: 2,
|
|
420
|
+
language: "en",
|
|
421
|
+
lastAppliedChapter: 1,
|
|
422
|
+
projectionVersion: 1,
|
|
423
|
+
migrationWarnings: [],
|
|
424
|
+
}, null, 2),
|
|
425
|
+
"utf-8",
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
await manager.snapshotState(bookId, 1);
|
|
429
|
+
|
|
430
|
+
const snapshotManifest = await readFile(
|
|
431
|
+
join(manager.bookDir(bookId), "story", "snapshots", "1", "state", "manifest.json"),
|
|
432
|
+
"utf-8",
|
|
433
|
+
);
|
|
434
|
+
expect(snapshotManifest).toContain("\"schemaVersion\": 2");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("restores state from a previous snapshot", async () => {
|
|
438
|
+
await manager.snapshotState(bookId, 1);
|
|
439
|
+
|
|
440
|
+
// Modify the current state files
|
|
441
|
+
const storyDir = join(manager.bookDir(bookId), "story");
|
|
442
|
+
await writeFile(
|
|
443
|
+
join(storyDir, "current_state.md"),
|
|
444
|
+
"# State at ch2 (modified)",
|
|
445
|
+
"utf-8",
|
|
446
|
+
);
|
|
447
|
+
await writeFile(
|
|
448
|
+
join(storyDir, "particle_ledger.md"),
|
|
449
|
+
"# Ledger at ch2 (modified)",
|
|
450
|
+
"utf-8",
|
|
451
|
+
);
|
|
452
|
+
await writeFile(
|
|
453
|
+
join(storyDir, "pending_hooks.md"),
|
|
454
|
+
"# Hooks at ch2 (modified)",
|
|
455
|
+
"utf-8",
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
const restored = await manager.restoreState(bookId, 1);
|
|
459
|
+
expect(restored).toBe(true);
|
|
460
|
+
|
|
461
|
+
// Verify restored content
|
|
462
|
+
const state = await readFile(
|
|
463
|
+
join(storyDir, "current_state.md"),
|
|
464
|
+
"utf-8",
|
|
465
|
+
);
|
|
466
|
+
expect(state).toBe("# State at ch1");
|
|
467
|
+
|
|
468
|
+
const ledger = await readFile(
|
|
469
|
+
join(storyDir, "particle_ledger.md"),
|
|
470
|
+
"utf-8",
|
|
471
|
+
);
|
|
472
|
+
expect(ledger).toBe("# Ledger at ch1");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("removes live optional truth files that are absent from the snapshot", async () => {
|
|
476
|
+
const storyDir = join(manager.bookDir(bookId), "story");
|
|
477
|
+
await rm(join(storyDir, "particle_ledger.md"));
|
|
478
|
+
await manager.snapshotState(bookId, 1);
|
|
479
|
+
|
|
480
|
+
await writeFile(
|
|
481
|
+
join(storyDir, "particle_ledger.md"),
|
|
482
|
+
"# Ledger added after snapshot",
|
|
483
|
+
"utf-8",
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const restored = await manager.restoreState(bookId, 1);
|
|
487
|
+
expect(restored).toBe(true);
|
|
488
|
+
await expect(stat(join(storyDir, "particle_ledger.md"))).rejects.toThrow();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("restores structured runtime state files from snapshot/state", async () => {
|
|
492
|
+
const stateDir = manager.stateDir(bookId);
|
|
493
|
+
await mkdir(stateDir, { recursive: true });
|
|
494
|
+
await writeFile(
|
|
495
|
+
join(stateDir, "manifest.json"),
|
|
496
|
+
JSON.stringify({
|
|
497
|
+
schemaVersion: 2,
|
|
498
|
+
language: "en",
|
|
499
|
+
lastAppliedChapter: 1,
|
|
500
|
+
projectionVersion: 1,
|
|
501
|
+
migrationWarnings: [],
|
|
502
|
+
}, null, 2),
|
|
503
|
+
"utf-8",
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
await manager.snapshotState(bookId, 1);
|
|
507
|
+
await writeFile(
|
|
508
|
+
join(stateDir, "manifest.json"),
|
|
509
|
+
JSON.stringify({
|
|
510
|
+
schemaVersion: 2,
|
|
511
|
+
language: "en",
|
|
512
|
+
lastAppliedChapter: 9,
|
|
513
|
+
projectionVersion: 1,
|
|
514
|
+
migrationWarnings: [],
|
|
515
|
+
}, null, 2),
|
|
516
|
+
"utf-8",
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
const restored = await manager.restoreState(bookId, 1);
|
|
520
|
+
expect(restored).toBe(true);
|
|
521
|
+
|
|
522
|
+
const manifest = await readFile(join(stateDir, "manifest.json"), "utf-8");
|
|
523
|
+
expect(manifest).toContain("\"lastAppliedChapter\": 1");
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("returns false when restoring from non-existent snapshot", async () => {
|
|
527
|
+
const restored = await manager.restoreState(bookId, 999);
|
|
528
|
+
expect(restored).toBe(false);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("rewrite chapter 2 then getNextChapterNumber returns 2", async () => {
|
|
532
|
+
const rwBookId = "rewrite-book";
|
|
533
|
+
const chapDir = join(manager.bookDir(rwBookId), "chapters");
|
|
534
|
+
const storyDir = join(manager.bookDir(rwBookId), "story");
|
|
535
|
+
await mkdir(chapDir, { recursive: true });
|
|
536
|
+
await mkdir(storyDir, { recursive: true });
|
|
537
|
+
|
|
538
|
+
// Simulate 3 chapters written
|
|
539
|
+
await writeFile(join(chapDir, "0001_ch1.md"), "# Chapter 1\nContent 1", "utf-8");
|
|
540
|
+
await writeFile(join(chapDir, "0002_ch2.md"), "# Chapter 2\nContent 2", "utf-8");
|
|
541
|
+
await writeFile(join(chapDir, "0003_ch3.md"), "# Chapter 3\nContent 3", "utf-8");
|
|
542
|
+
const mkEntry = (n: number) => ({
|
|
543
|
+
number: n, title: `Ch${n}`, status: "approved" as const, wordCount: 100,
|
|
544
|
+
createdAt: "", updatedAt: "", auditIssues: [] as string[], lengthWarnings: [] as string[],
|
|
545
|
+
});
|
|
546
|
+
const fullIndex = [mkEntry(1), mkEntry(2), mkEntry(3)];
|
|
547
|
+
await manager.saveChapterIndex(rwBookId, fullIndex);
|
|
548
|
+
|
|
549
|
+
// Snapshot state at chapter 1 (before chapter 2)
|
|
550
|
+
await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
|
|
551
|
+
await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
|
|
552
|
+
await manager.snapshotState(rwBookId, 1);
|
|
553
|
+
|
|
554
|
+
// Simulate rewrite of chapter 2: trim index, delete ch2+ch3, restore state
|
|
555
|
+
const trimmed = fullIndex.filter((ch) => ch.number < 2);
|
|
556
|
+
await manager.saveChapterIndex(rwBookId, trimmed);
|
|
557
|
+
const { rm } = await import("node:fs/promises");
|
|
558
|
+
await rm(join(chapDir, "0002_ch2.md"));
|
|
559
|
+
await rm(join(chapDir, "0003_ch3.md"));
|
|
560
|
+
await manager.restoreState(rwBookId, 1);
|
|
561
|
+
|
|
562
|
+
// Next chapter should be 2, not 4
|
|
563
|
+
const next = await manager.getNextChapterNumber(rwBookId);
|
|
564
|
+
expect(next).toBe(2);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("rewrite restore drops poisoned live structured state when the snapshot only has markdown truth files", async () => {
|
|
568
|
+
const rwBookId = "rewrite-book-markdown-only";
|
|
569
|
+
const chapDir = join(manager.bookDir(rwBookId), "chapters");
|
|
570
|
+
const storyDir = join(manager.bookDir(rwBookId), "story");
|
|
571
|
+
const stateDir = join(storyDir, "state");
|
|
572
|
+
await mkdir(chapDir, { recursive: true });
|
|
573
|
+
await mkdir(storyDir, { recursive: true });
|
|
574
|
+
|
|
575
|
+
await writeFile(join(chapDir, "0001_ch1.md"), "# Chapter 1\nContent 1", "utf-8");
|
|
576
|
+
await writeFile(join(chapDir, "0002_ch2.md"), "# Chapter 2\nContent 2", "utf-8");
|
|
577
|
+
await writeFile(join(chapDir, "0003_ch3.md"), "# Chapter 3\nContent 3", "utf-8");
|
|
578
|
+
const mkEntry = (n: number) => ({
|
|
579
|
+
number: n, title: `Ch${n}`, status: "approved" as const, wordCount: 100,
|
|
580
|
+
createdAt: "", updatedAt: "", auditIssues: [] as string[], lengthWarnings: [] as string[],
|
|
581
|
+
});
|
|
582
|
+
const fullIndex = [mkEntry(1), mkEntry(2), mkEntry(3)];
|
|
583
|
+
await manager.saveChapterIndex(rwBookId, fullIndex);
|
|
584
|
+
|
|
585
|
+
await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
|
|
586
|
+
await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
|
|
587
|
+
await manager.snapshotState(rwBookId, 1);
|
|
588
|
+
|
|
589
|
+
await mkdir(stateDir, { recursive: true });
|
|
590
|
+
await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
|
|
591
|
+
schemaVersion: 2,
|
|
592
|
+
language: "en",
|
|
593
|
+
lastAppliedChapter: 4,
|
|
594
|
+
projectionVersion: 1,
|
|
595
|
+
migrationWarnings: [],
|
|
596
|
+
}, null, 2), "utf-8");
|
|
597
|
+
await writeFile(join(stateDir, "current_state.json"), JSON.stringify({
|
|
598
|
+
chapter: 3,
|
|
599
|
+
facts: [],
|
|
600
|
+
}, null, 2), "utf-8");
|
|
601
|
+
|
|
602
|
+
const trimmed = fullIndex.filter((ch) => ch.number < 2);
|
|
603
|
+
await manager.saveChapterIndex(rwBookId, trimmed);
|
|
604
|
+
const { rm } = await import("node:fs/promises");
|
|
605
|
+
await rm(join(chapDir, "0002_ch2.md"));
|
|
606
|
+
await rm(join(chapDir, "0003_ch3.md"));
|
|
607
|
+
await manager.restoreState(rwBookId, 1);
|
|
608
|
+
|
|
609
|
+
const next = await manager.getNextChapterNumber(rwBookId);
|
|
610
|
+
expect(next).toBe(2);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// -------------------------------------------------------------------------
|
|
615
|
+
// acquireBookLock
|
|
616
|
+
// -------------------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
describe("acquireBookLock", () => {
|
|
619
|
+
it("acquires a lock and returns a release function", async () => {
|
|
620
|
+
// Ensure book directory exists
|
|
621
|
+
await mkdir(manager.bookDir("lock-book"), { recursive: true });
|
|
622
|
+
|
|
623
|
+
const release = await manager.acquireBookLock("lock-book");
|
|
624
|
+
expect(typeof release).toBe("function");
|
|
625
|
+
|
|
626
|
+
// Lock file should exist
|
|
627
|
+
const lockPath = join(manager.bookDir("lock-book"), ".write.lock");
|
|
628
|
+
const lockStat = await stat(lockPath);
|
|
629
|
+
expect(lockStat.isFile()).toBe(true);
|
|
630
|
+
|
|
631
|
+
// Release the lock
|
|
632
|
+
await release();
|
|
633
|
+
|
|
634
|
+
// Lock file should be gone
|
|
635
|
+
await expect(stat(lockPath)).rejects.toThrow();
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it("throws when lock is already held", async () => {
|
|
639
|
+
await mkdir(manager.bookDir("lock-book-2"), { recursive: true });
|
|
640
|
+
|
|
641
|
+
const release = await manager.acquireBookLock("lock-book-2");
|
|
642
|
+
|
|
643
|
+
await expect(
|
|
644
|
+
manager.acquireBookLock("lock-book-2"),
|
|
645
|
+
).rejects.toThrow(/is locked/);
|
|
646
|
+
|
|
647
|
+
await release();
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("allows re-acquiring lock after release", async () => {
|
|
651
|
+
await mkdir(manager.bookDir("lock-book-3"), { recursive: true });
|
|
652
|
+
|
|
653
|
+
const release1 = await manager.acquireBookLock("lock-book-3");
|
|
654
|
+
await release1();
|
|
655
|
+
|
|
656
|
+
const release2 = await manager.acquireBookLock("lock-book-3");
|
|
657
|
+
expect(typeof release2).toBe("function");
|
|
658
|
+
await release2();
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("allows only one concurrent lock claimant", async () => {
|
|
662
|
+
await mkdir(manager.bookDir("lock-book-4"), { recursive: true });
|
|
663
|
+
|
|
664
|
+
const results = await Promise.allSettled([
|
|
665
|
+
manager.acquireBookLock("lock-book-4"),
|
|
666
|
+
manager.acquireBookLock("lock-book-4"),
|
|
667
|
+
]);
|
|
668
|
+
|
|
669
|
+
const fulfilled = results.filter((result) => result.status === "fulfilled");
|
|
670
|
+
const rejected = results.filter((result) => result.status === "rejected");
|
|
671
|
+
|
|
672
|
+
for (const result of fulfilled) {
|
|
673
|
+
await result.value();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
expect(fulfilled).toHaveLength(1);
|
|
677
|
+
expect(rejected).toHaveLength(1);
|
|
678
|
+
expect(String(rejected[0]?.reason)).toMatch(/is locked/);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it("reclaims same-process stale lock when no active write is in progress", async () => {
|
|
682
|
+
await mkdir(manager.bookDir("lock-book-self"), { recursive: true });
|
|
683
|
+
const lockPath = join(manager.bookDir("lock-book-self"), ".write.lock");
|
|
684
|
+
// Simulate a stale lock left by our own process (e.g. after a failed pipeline)
|
|
685
|
+
await writeFile(lockPath, `pid:${process.pid} ts:${Date.now() - 60000}`, "utf-8");
|
|
686
|
+
|
|
687
|
+
// Should auto-reclaim since our process knows it's not actively writing this book
|
|
688
|
+
const release = await manager.acquireBookLock("lock-book-self");
|
|
689
|
+
expect(typeof release).toBe("function");
|
|
690
|
+
|
|
691
|
+
const lockData = await readFile(lockPath, "utf-8");
|
|
692
|
+
expect(lockData).toContain(`pid:${process.pid}`);
|
|
693
|
+
|
|
694
|
+
await release();
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("reclaims a stale lock when the recorded pid is no longer alive", async () => {
|
|
698
|
+
await mkdir(manager.bookDir("lock-book-5"), { recursive: true });
|
|
699
|
+
const lockPath = join(manager.bookDir("lock-book-5"), ".write.lock");
|
|
700
|
+
await writeFile(lockPath, "pid:424242 ts:123", "utf-8");
|
|
701
|
+
|
|
702
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation((((pid: number) => {
|
|
703
|
+
if (pid === 424242) {
|
|
704
|
+
const error = new Error("no such process") as NodeJS.ErrnoException;
|
|
705
|
+
error.code = "ESRCH";
|
|
706
|
+
throw error;
|
|
707
|
+
}
|
|
708
|
+
return true;
|
|
709
|
+
}) as unknown) as typeof process.kill);
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
const release = await manager.acquireBookLock("lock-book-5");
|
|
713
|
+
const lockData = await readFile(lockPath, "utf-8");
|
|
714
|
+
|
|
715
|
+
expect(typeof release).toBe("function");
|
|
716
|
+
expect(lockData).toContain(`pid:${process.pid}`);
|
|
717
|
+
|
|
718
|
+
await release();
|
|
719
|
+
} finally {
|
|
720
|
+
killSpy.mockRestore();
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// -------------------------------------------------------------------------
|
|
726
|
+
// Path helpers
|
|
727
|
+
// -------------------------------------------------------------------------
|
|
728
|
+
|
|
729
|
+
describe("path helpers", () => {
|
|
730
|
+
it("booksDir points to <projectRoot>/books", () => {
|
|
731
|
+
expect(manager.booksDir).toBe(join(tempDir, "books"));
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("bookDir returns <booksDir>/<bookId>", () => {
|
|
735
|
+
expect(manager.bookDir("my-book")).toBe(
|
|
736
|
+
join(tempDir, "books", "my-book"),
|
|
737
|
+
);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it("stateDir returns <bookDir>/story/state", () => {
|
|
741
|
+
expect(manager.stateDir("my-book")).toBe(
|
|
742
|
+
join(tempDir, "books", "my-book", "story", "state"),
|
|
743
|
+
);
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// -------------------------------------------------------------------------
|
|
748
|
+
// Input governance control docs
|
|
749
|
+
// -------------------------------------------------------------------------
|
|
750
|
+
|
|
751
|
+
describe("ensureControlDocuments", () => {
|
|
752
|
+
it("creates author intent, current focus, and runtime directory", async () => {
|
|
753
|
+
await manager.ensureControlDocuments(
|
|
754
|
+
"control-book",
|
|
755
|
+
"# Initial Brief\n\nKeep the focus on mentor conflict.\n",
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
const storyDir = join(manager.bookDir("control-book"), "story");
|
|
759
|
+
const authorIntent = await readFile(
|
|
760
|
+
join(storyDir, "author_intent.md"),
|
|
761
|
+
"utf-8",
|
|
762
|
+
);
|
|
763
|
+
const currentFocus = await readFile(
|
|
764
|
+
join(storyDir, "current_focus.md"),
|
|
765
|
+
"utf-8",
|
|
766
|
+
);
|
|
767
|
+
const runtimeStat = await stat(join(storyDir, "runtime"));
|
|
768
|
+
|
|
769
|
+
expect(authorIntent).toContain("mentor conflict");
|
|
770
|
+
expect(currentFocus).toContain("Current Focus");
|
|
771
|
+
expect(runtimeStat.isDirectory()).toBe(true);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("creates Phase 5 outline/ and roles/ directories", async () => {
|
|
775
|
+
await manager.ensureControlDocuments("phase5-book");
|
|
776
|
+
|
|
777
|
+
const storyDir = join(manager.bookDir("phase5-book"), "story");
|
|
778
|
+
const outlineStat = await stat(join(storyDir, "outline"));
|
|
779
|
+
const rolesMajorStat = await stat(join(storyDir, "roles", "主要角色"));
|
|
780
|
+
const rolesMinorStat = await stat(join(storyDir, "roles", "次要角色"));
|
|
781
|
+
|
|
782
|
+
expect(outlineStat.isDirectory()).toBe(true);
|
|
783
|
+
expect(rolesMajorStat.isDirectory()).toBe(true);
|
|
784
|
+
expect(rolesMinorStat.isDirectory()).toBe(true);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("bootstraps and returns safe defaults for legacy books", async () => {
|
|
788
|
+
const storyDir = join(manager.bookDir("legacy-book"), "story");
|
|
789
|
+
await mkdir(storyDir, { recursive: true });
|
|
790
|
+
await writeFile(
|
|
791
|
+
join(storyDir, "story_bible.md"),
|
|
792
|
+
"# Story Bible\n\nLegacy books may not have control docs yet.\n",
|
|
793
|
+
"utf-8",
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
const controlDocs = await manager.loadControlDocuments("legacy-book");
|
|
797
|
+
|
|
798
|
+
expect(controlDocs.authorIntent).toContain("# Author Intent");
|
|
799
|
+
expect(controlDocs.currentFocus).toContain("# Current Focus");
|
|
800
|
+
expect(controlDocs.runtimeDir).toBe(join(storyDir, "runtime"));
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("creates localized Chinese defaults for Chinese books", async () => {
|
|
804
|
+
await manager.saveBookConfig("zh-book", {
|
|
805
|
+
id: "zh-book",
|
|
806
|
+
title: "中文书",
|
|
807
|
+
platform: "tomato",
|
|
808
|
+
genre: "other",
|
|
809
|
+
status: "outlining",
|
|
810
|
+
targetChapters: 100,
|
|
811
|
+
chapterWordCount: 2200,
|
|
812
|
+
language: "zh",
|
|
813
|
+
createdAt: "2026-03-24T00:00:00Z",
|
|
814
|
+
updatedAt: "2026-03-24T00:00:00Z",
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
await manager.ensureControlDocuments("zh-book");
|
|
818
|
+
|
|
819
|
+
const storyDir = join(manager.bookDir("zh-book"), "story");
|
|
820
|
+
const authorIntent = await readFile(
|
|
821
|
+
join(storyDir, "author_intent.md"),
|
|
822
|
+
"utf-8",
|
|
823
|
+
);
|
|
824
|
+
const currentFocus = await readFile(
|
|
825
|
+
join(storyDir, "current_focus.md"),
|
|
826
|
+
"utf-8",
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
expect(authorIntent).toContain("# 作者意图");
|
|
830
|
+
expect(currentFocus).toContain("# 当前聚焦");
|
|
831
|
+
expect(currentFocus).not.toContain("# Current Focus");
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("bootstraps structured runtime state from legacy markdown truth files", async () => {
|
|
835
|
+
const bookId = "runtime-state-book";
|
|
836
|
+
const storyDir = join(manager.bookDir(bookId), "story");
|
|
837
|
+
await mkdir(storyDir, { recursive: true });
|
|
838
|
+
await Promise.all([
|
|
839
|
+
writeFile(
|
|
840
|
+
join(storyDir, "current_state.md"),
|
|
841
|
+
[
|
|
842
|
+
"# Current State",
|
|
843
|
+
"",
|
|
844
|
+
"| Field | Value |",
|
|
845
|
+
"| --- | --- |",
|
|
846
|
+
"| Current Chapter | 3 |",
|
|
847
|
+
"| Current Goal | Trace the mentor debt |",
|
|
848
|
+
"",
|
|
849
|
+
].join("\n"),
|
|
850
|
+
"utf-8",
|
|
851
|
+
),
|
|
852
|
+
writeFile(
|
|
853
|
+
join(storyDir, "pending_hooks.md"),
|
|
854
|
+
[
|
|
855
|
+
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
|
|
856
|
+
"| --- | --- | --- | --- | --- | --- | --- |",
|
|
857
|
+
"| mentor-debt | 1 | relationship | open | 3 | 10 | Still unresolved |",
|
|
858
|
+
"",
|
|
859
|
+
].join("\n"),
|
|
860
|
+
"utf-8",
|
|
861
|
+
),
|
|
862
|
+
writeFile(
|
|
863
|
+
join(storyDir, "chapter_summaries.md"),
|
|
864
|
+
[
|
|
865
|
+
"| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
|
|
866
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
867
|
+
"| 3 | River Ledger | Lin Yue | He checks the old ledger | Debt sharpens | mentor-debt advanced | tense | mainline |",
|
|
868
|
+
"",
|
|
869
|
+
].join("\n"),
|
|
870
|
+
"utf-8",
|
|
871
|
+
),
|
|
872
|
+
]);
|
|
873
|
+
|
|
874
|
+
await manager.ensureRuntimeState(bookId, 3);
|
|
875
|
+
|
|
876
|
+
const manifest = await readFile(join(manager.stateDir(bookId), "manifest.json"), "utf-8");
|
|
877
|
+
const currentState = await readFile(join(manager.stateDir(bookId), "current_state.json"), "utf-8");
|
|
878
|
+
|
|
879
|
+
expect(manifest).toContain("\"schemaVersion\": 2");
|
|
880
|
+
expect(currentState).toContain("\"chapter\": 3");
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it("does not treat future hook start chapters as lastAppliedChapter during bootstrap", async () => {
|
|
884
|
+
const bookId = "runtime-state-future-hooks-book";
|
|
885
|
+
const storyDir = join(manager.bookDir(bookId), "story");
|
|
886
|
+
await mkdir(storyDir, { recursive: true });
|
|
887
|
+
await Promise.all([
|
|
888
|
+
writeFile(
|
|
889
|
+
join(storyDir, "current_state.md"),
|
|
890
|
+
[
|
|
891
|
+
"# Current State",
|
|
892
|
+
"",
|
|
893
|
+
"| Field | Value |",
|
|
894
|
+
"| --- | --- |",
|
|
895
|
+
"| Current Chapter | 1 |",
|
|
896
|
+
"| Current Goal | Survive the harbor fallout |",
|
|
897
|
+
"",
|
|
898
|
+
].join("\n"),
|
|
899
|
+
"utf-8",
|
|
900
|
+
),
|
|
901
|
+
writeFile(
|
|
902
|
+
join(storyDir, "pending_hooks.md"),
|
|
903
|
+
[
|
|
904
|
+
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
|
|
905
|
+
"| --- | --- | --- | --- | --- | --- | --- |",
|
|
906
|
+
"| long-payoff-1 | 108 | mystery | open | 1 | 108 | Future payoff anchor |",
|
|
907
|
+
"| long-payoff-2 | 181 | relationship | open | 1 | 181 | Even later payoff anchor |",
|
|
908
|
+
"",
|
|
909
|
+
].join("\n"),
|
|
910
|
+
"utf-8",
|
|
911
|
+
),
|
|
912
|
+
writeFile(
|
|
913
|
+
join(storyDir, "chapter_summaries.md"),
|
|
914
|
+
[
|
|
915
|
+
"| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
|
|
916
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
917
|
+
"| 1 | Harbor Ash | Lin Yue | He survives the harbor fallout | The debt line opens | long-payoff-1 seeded | tense | opening |",
|
|
918
|
+
"",
|
|
919
|
+
].join("\n"),
|
|
920
|
+
"utf-8",
|
|
921
|
+
),
|
|
922
|
+
]);
|
|
923
|
+
|
|
924
|
+
await manager.ensureRuntimeState(bookId, 1);
|
|
925
|
+
|
|
926
|
+
const manifest = JSON.parse(
|
|
927
|
+
await readFile(join(manager.stateDir(bookId), "manifest.json"), "utf-8"),
|
|
928
|
+
) as { lastAppliedChapter: number };
|
|
929
|
+
|
|
930
|
+
expect(manifest.lastAppliedChapter).toBe(1);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it("does not treat narrative digits inside hook markdown as runtime chapter progress during bootstrap", async () => {
|
|
934
|
+
const bookId = "runtime-state-narrative-digit-book";
|
|
935
|
+
const storyDir = join(manager.bookDir(bookId), "story");
|
|
936
|
+
await mkdir(storyDir, { recursive: true });
|
|
937
|
+
await Promise.all([
|
|
938
|
+
writeFile(
|
|
939
|
+
join(storyDir, "current_state.md"),
|
|
940
|
+
[
|
|
941
|
+
"# Current State",
|
|
942
|
+
"",
|
|
943
|
+
"| Field | Value |",
|
|
944
|
+
"| --- | --- |",
|
|
945
|
+
"| Current Chapter | 12 |",
|
|
946
|
+
"| Current Goal | Continue after the imported twelfth chapter |",
|
|
947
|
+
"",
|
|
948
|
+
].join("\n"),
|
|
949
|
+
"utf-8",
|
|
950
|
+
),
|
|
951
|
+
writeFile(
|
|
952
|
+
join(storyDir, "pending_hooks.md"),
|
|
953
|
+
[
|
|
954
|
+
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
|
|
955
|
+
"| --- | --- | --- | --- | --- | --- | --- |",
|
|
956
|
+
"| H001 | 1 | mystery | progressing | 《三体》游戏内第141号文明展开到墨子时代 | Reveal the threat | Narrative prose, not chapter metadata |",
|
|
957
|
+
"",
|
|
958
|
+
].join("\n"),
|
|
959
|
+
"utf-8",
|
|
960
|
+
),
|
|
961
|
+
writeFile(
|
|
962
|
+
join(storyDir, "chapter_summaries.md"),
|
|
963
|
+
[
|
|
964
|
+
"| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
|
|
965
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
966
|
+
...Array.from({ length: 12 }, (_, index) =>
|
|
967
|
+
`| ${index + 1} | Ch${index + 1} | Lin Yue | Event ${index + 1} | Shift ${index + 1} | Hook ${index + 1} | tense | mainline |`),
|
|
968
|
+
"",
|
|
969
|
+
].join("\n"),
|
|
970
|
+
"utf-8",
|
|
971
|
+
),
|
|
972
|
+
]);
|
|
973
|
+
|
|
974
|
+
await manager.ensureRuntimeState(bookId, 12);
|
|
975
|
+
|
|
976
|
+
const manifest = JSON.parse(
|
|
977
|
+
await readFile(join(manager.stateDir(bookId), "manifest.json"), "utf-8"),
|
|
978
|
+
) as { lastAppliedChapter: number };
|
|
979
|
+
const hooks = JSON.parse(
|
|
980
|
+
await readFile(join(manager.stateDir(bookId), "hooks.json"), "utf-8"),
|
|
981
|
+
) as { hooks: Array<{ hookId: string; lastAdvancedChapter: number }> };
|
|
982
|
+
|
|
983
|
+
expect(manifest.lastAppliedChapter).toBe(12);
|
|
984
|
+
expect(hooks.hooks[0]?.hookId).toBe("H001");
|
|
985
|
+
expect(hooks.hooks[0]?.lastAdvancedChapter).toBe(0);
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it("repairs poisoned manifest chapter when it runs ahead of persisted runtime state", async () => {
|
|
989
|
+
const bookId = "runtime-state-poisoned-book";
|
|
990
|
+
const storyDir = join(manager.bookDir(bookId), "story");
|
|
991
|
+
const stateDir = join(storyDir, "state");
|
|
992
|
+
await mkdir(stateDir, { recursive: true });
|
|
993
|
+
await Promise.all([
|
|
994
|
+
writeFile(
|
|
995
|
+
join(storyDir, "current_state.md"),
|
|
996
|
+
[
|
|
997
|
+
"# Current State",
|
|
998
|
+
"",
|
|
999
|
+
"| Field | Value |",
|
|
1000
|
+
"| --- | --- |",
|
|
1001
|
+
"| Current Chapter | 2 |",
|
|
1002
|
+
"| Current Goal | Reach the ledger vault |",
|
|
1003
|
+
"",
|
|
1004
|
+
].join("\n"),
|
|
1005
|
+
"utf-8",
|
|
1006
|
+
),
|
|
1007
|
+
writeFile(
|
|
1008
|
+
join(storyDir, "pending_hooks.md"),
|
|
1009
|
+
[
|
|
1010
|
+
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
|
|
1011
|
+
"| --- | --- | --- | --- | --- | --- | --- |",
|
|
1012
|
+
"| vault-ledger | 1 | mystery | progressing | 2 | 4 | Ledger trail remains open |",
|
|
1013
|
+
"",
|
|
1014
|
+
].join("\n"),
|
|
1015
|
+
"utf-8",
|
|
1016
|
+
),
|
|
1017
|
+
writeFile(
|
|
1018
|
+
join(storyDir, "chapter_summaries.md"),
|
|
1019
|
+
[
|
|
1020
|
+
"| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
|
|
1021
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
1022
|
+
"| 1 | Harbor Ash | Lin Yue | Survives the harbor fallout | Debt line opens | vault-ledger seeded | tense | opening |",
|
|
1023
|
+
"| 2 | Lantern Wharf | Lin Yue | Tracks the ledger to the wharf | Goal narrows to the vault | vault-ledger advanced | wary | investigation |",
|
|
1024
|
+
"",
|
|
1025
|
+
].join("\n"),
|
|
1026
|
+
"utf-8",
|
|
1027
|
+
),
|
|
1028
|
+
writeFile(join(stateDir, "manifest.json"), JSON.stringify({
|
|
1029
|
+
schemaVersion: 2,
|
|
1030
|
+
language: "en",
|
|
1031
|
+
lastAppliedChapter: 3,
|
|
1032
|
+
projectionVersion: 1,
|
|
1033
|
+
migrationWarnings: [],
|
|
1034
|
+
}, null, 2), "utf-8"),
|
|
1035
|
+
writeFile(join(stateDir, "current_state.json"), JSON.stringify({
|
|
1036
|
+
chapter: 2,
|
|
1037
|
+
facts: [
|
|
1038
|
+
{
|
|
1039
|
+
subject: "protagonist",
|
|
1040
|
+
predicate: "Current Goal",
|
|
1041
|
+
object: "Reach the ledger vault",
|
|
1042
|
+
validFromChapter: 2,
|
|
1043
|
+
validUntilChapter: null,
|
|
1044
|
+
sourceChapter: 2,
|
|
1045
|
+
},
|
|
1046
|
+
],
|
|
1047
|
+
}, null, 2), "utf-8"),
|
|
1048
|
+
writeFile(join(stateDir, "hooks.json"), JSON.stringify({
|
|
1049
|
+
hooks: [
|
|
1050
|
+
{
|
|
1051
|
+
hookId: "vault-ledger",
|
|
1052
|
+
startChapter: 1,
|
|
1053
|
+
type: "mystery",
|
|
1054
|
+
status: "progressing",
|
|
1055
|
+
lastAdvancedChapter: 2,
|
|
1056
|
+
expectedPayoff: "4",
|
|
1057
|
+
notes: "Persisted structured hook state",
|
|
1058
|
+
},
|
|
1059
|
+
],
|
|
1060
|
+
}, null, 2), "utf-8"),
|
|
1061
|
+
writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
|
|
1062
|
+
rows: [
|
|
1063
|
+
{
|
|
1064
|
+
chapter: 1,
|
|
1065
|
+
title: "Harbor Ash",
|
|
1066
|
+
characters: "Lin Yue",
|
|
1067
|
+
events: "Survives the harbor fallout",
|
|
1068
|
+
stateChanges: "Debt line opens",
|
|
1069
|
+
hookActivity: "vault-ledger seeded",
|
|
1070
|
+
mood: "tense",
|
|
1071
|
+
chapterType: "opening",
|
|
1072
|
+
},
|
|
1073
|
+
{
|
|
1074
|
+
chapter: 2,
|
|
1075
|
+
title: "Lantern Wharf",
|
|
1076
|
+
characters: "Lin Yue",
|
|
1077
|
+
events: "Tracks the ledger to the wharf",
|
|
1078
|
+
stateChanges: "Goal narrows to the vault",
|
|
1079
|
+
hookActivity: "vault-ledger advanced",
|
|
1080
|
+
mood: "wary",
|
|
1081
|
+
chapterType: "investigation",
|
|
1082
|
+
},
|
|
1083
|
+
],
|
|
1084
|
+
}, null, 2), "utf-8"),
|
|
1085
|
+
]);
|
|
1086
|
+
|
|
1087
|
+
await manager.ensureRuntimeState(bookId, 2);
|
|
1088
|
+
|
|
1089
|
+
const manifest = JSON.parse(
|
|
1090
|
+
await readFile(join(stateDir, "manifest.json"), "utf-8"),
|
|
1091
|
+
) as { lastAppliedChapter: number };
|
|
1092
|
+
const currentState = JSON.parse(
|
|
1093
|
+
await readFile(join(stateDir, "current_state.json"), "utf-8"),
|
|
1094
|
+
) as { chapter: number; facts: Array<{ object: string }> };
|
|
1095
|
+
const hooks = JSON.parse(
|
|
1096
|
+
await readFile(join(stateDir, "hooks.json"), "utf-8"),
|
|
1097
|
+
) as { hooks: Array<{ lastAdvancedChapter: number }> };
|
|
1098
|
+
const summaries = JSON.parse(
|
|
1099
|
+
await readFile(join(stateDir, "chapter_summaries.json"), "utf-8"),
|
|
1100
|
+
) as { rows: Array<{ chapter: number; title: string }> };
|
|
1101
|
+
|
|
1102
|
+
expect(manifest.lastAppliedChapter).toBe(2);
|
|
1103
|
+
expect(currentState.chapter).toBe(2);
|
|
1104
|
+
expect(currentState.facts[0]?.object).toBe("Reach the ledger vault");
|
|
1105
|
+
expect(hooks.hooks[0]?.lastAdvancedChapter).toBe(2);
|
|
1106
|
+
expect(summaries.rows.map((row) => row.chapter)).toEqual([1, 2]);
|
|
1107
|
+
expect(summaries.rows.at(-1)?.title).toBe("Lantern Wharf");
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
it("normalizes emphasized hook ids when bootstrapping structured runtime state from markdown", async () => {
|
|
1111
|
+
const bookId = "runtime-state-emphasized-hook-book";
|
|
1112
|
+
const storyDir = join(manager.bookDir(bookId), "story");
|
|
1113
|
+
await mkdir(storyDir, { recursive: true });
|
|
1114
|
+
await Promise.all([
|
|
1115
|
+
writeFile(
|
|
1116
|
+
join(storyDir, "current_state.md"),
|
|
1117
|
+
[
|
|
1118
|
+
"# Current State",
|
|
1119
|
+
"",
|
|
1120
|
+
"| Field | Value |",
|
|
1121
|
+
"| --- | --- |",
|
|
1122
|
+
"| Current Chapter | 3 |",
|
|
1123
|
+
"| Current Goal | Follow the ledger trail |",
|
|
1124
|
+
"",
|
|
1125
|
+
].join("\n"),
|
|
1126
|
+
"utf-8",
|
|
1127
|
+
),
|
|
1128
|
+
writeFile(
|
|
1129
|
+
join(storyDir, "pending_hooks.md"),
|
|
1130
|
+
[
|
|
1131
|
+
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
|
|
1132
|
+
"| --- | --- | --- | --- | --- | --- | --- |",
|
|
1133
|
+
"| **H009** | 3 | mystery | open | 3 | 9 | Bold markdown leaked into hook id |",
|
|
1134
|
+
"",
|
|
1135
|
+
].join("\n"),
|
|
1136
|
+
"utf-8",
|
|
1137
|
+
),
|
|
1138
|
+
writeFile(
|
|
1139
|
+
join(storyDir, "chapter_summaries.md"),
|
|
1140
|
+
[
|
|
1141
|
+
"| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
|
|
1142
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
1143
|
+
"| 3 | Lantern Wharf | Lin Yue | Follows the ledger trail | Goal narrows to the ledger trail | H009 advanced | wary | investigation |",
|
|
1144
|
+
"",
|
|
1145
|
+
].join("\n"),
|
|
1146
|
+
"utf-8",
|
|
1147
|
+
),
|
|
1148
|
+
]);
|
|
1149
|
+
|
|
1150
|
+
await manager.ensureRuntimeState(bookId, 3);
|
|
1151
|
+
|
|
1152
|
+
const hooks = JSON.parse(
|
|
1153
|
+
await readFile(join(manager.stateDir(bookId), "hooks.json"), "utf-8"),
|
|
1154
|
+
) as { hooks: Array<{ hookId: string }> };
|
|
1155
|
+
|
|
1156
|
+
expect(hooks.hooks.map((hook) => hook.hookId)).toEqual(["H009"]);
|
|
1157
|
+
});
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
// -------------------------------------------------------------------------
|
|
1161
|
+
// rollbackToChapter — reject a chapter and discard downstream state
|
|
1162
|
+
// -------------------------------------------------------------------------
|
|
1163
|
+
|
|
1164
|
+
describe("rollbackToChapter", () => {
|
|
1165
|
+
const bookId = "rollback-book";
|
|
1166
|
+
|
|
1167
|
+
async function setupRollbackBook(): Promise<void> {
|
|
1168
|
+
await manager.saveBookConfig(bookId, {
|
|
1169
|
+
id: bookId,
|
|
1170
|
+
title: "Rollback Test",
|
|
1171
|
+
platform: "tomato",
|
|
1172
|
+
genre: "xuanhuan",
|
|
1173
|
+
status: "active",
|
|
1174
|
+
targetChapters: 10,
|
|
1175
|
+
chapterWordCount: 3000,
|
|
1176
|
+
createdAt: "2026-03-31T00:00:00Z",
|
|
1177
|
+
updatedAt: "2026-03-31T00:00:00Z",
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
const bookDir = manager.bookDir(bookId);
|
|
1181
|
+
const storyDir = join(bookDir, "story");
|
|
1182
|
+
const chaptersDir = join(bookDir, "chapters");
|
|
1183
|
+
const runtimeDir = join(storyDir, "runtime");
|
|
1184
|
+
await mkdir(runtimeDir, { recursive: true });
|
|
1185
|
+
await mkdir(chaptersDir, { recursive: true });
|
|
1186
|
+
|
|
1187
|
+
// Write initial state (chapter 0 baseline)
|
|
1188
|
+
await writeFile(join(storyDir, "current_state.md"), "# State\n\n- Initial state.\n", "utf-8");
|
|
1189
|
+
await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n", "utf-8");
|
|
1190
|
+
await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n", "utf-8");
|
|
1191
|
+
await manager.snapshotState(bookId, 0);
|
|
1192
|
+
|
|
1193
|
+
// Write chapter 1 state + file
|
|
1194
|
+
await writeFile(join(storyDir, "current_state.md"), "# State\n\n- After chapter 1.\n", "utf-8");
|
|
1195
|
+
await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n- hook-2\n", "utf-8");
|
|
1196
|
+
await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n\n| 1 | Title 1 |\n", "utf-8");
|
|
1197
|
+
await writeFile(join(chaptersDir, "0001_Title_One.md"), "# Chapter 1\n\nContent 1.", "utf-8");
|
|
1198
|
+
await manager.snapshotState(bookId, 1);
|
|
1199
|
+
|
|
1200
|
+
// Write chapter 2 state + file
|
|
1201
|
+
await writeFile(join(storyDir, "current_state.md"), "# State\n\n- After chapter 2.\n", "utf-8");
|
|
1202
|
+
await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n- hook-2\n- hook-3\n", "utf-8");
|
|
1203
|
+
await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n\n| 1 | Title 1 |\n| 2 | Title 2 |\n", "utf-8");
|
|
1204
|
+
await writeFile(join(chaptersDir, "0002_Title_Two.md"), "# Chapter 2\n\nContent 2.", "utf-8");
|
|
1205
|
+
await writeFile(join(runtimeDir, "chapter-002.intent.md"), "intent 2", "utf-8");
|
|
1206
|
+
await manager.snapshotState(bookId, 2);
|
|
1207
|
+
|
|
1208
|
+
// Write chapter 3 state + file
|
|
1209
|
+
await writeFile(join(storyDir, "current_state.md"), "# State\n\n- After chapter 3.\n", "utf-8");
|
|
1210
|
+
await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n- hook-2\n- hook-3\n- hook-4\n", "utf-8");
|
|
1211
|
+
await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n\n| 1 | Title 1 |\n| 2 | Title 2 |\n| 3 | Title 3 |\n", "utf-8");
|
|
1212
|
+
await writeFile(join(chaptersDir, "0003_Title_Three.md"), "# Chapter 3\n\nContent 3.", "utf-8");
|
|
1213
|
+
await writeFile(join(runtimeDir, "chapter-003.intent.md"), "intent 3", "utf-8");
|
|
1214
|
+
await manager.snapshotState(bookId, 3);
|
|
1215
|
+
|
|
1216
|
+
// Save index with all 3 chapters
|
|
1217
|
+
const now = "2026-03-31T00:00:00Z";
|
|
1218
|
+
await manager.saveChapterIndex(bookId, [
|
|
1219
|
+
{ number: 1, title: "Title One", status: "approved", wordCount: 100, createdAt: now, updatedAt: now, auditIssues: [], lengthWarnings: [] },
|
|
1220
|
+
{ number: 2, title: "Title Two", status: "ready-for-review", wordCount: 100, createdAt: now, updatedAt: now, auditIssues: [], lengthWarnings: [] },
|
|
1221
|
+
{ number: 3, title: "Title Three", status: "audit-failed", wordCount: 100, createdAt: now, updatedAt: now, auditIssues: ["pacing"], lengthWarnings: [] },
|
|
1222
|
+
]);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
it("restores state to the target chapter and removes subsequent chapters", async () => {
|
|
1226
|
+
await setupRollbackBook();
|
|
1227
|
+
|
|
1228
|
+
const discarded = await manager.rollbackToChapter(bookId, 1);
|
|
1229
|
+
|
|
1230
|
+
expect(discarded).toEqual([2, 3]);
|
|
1231
|
+
|
|
1232
|
+
// State should be restored to chapter 1 snapshot
|
|
1233
|
+
const bookDir = manager.bookDir(bookId);
|
|
1234
|
+
const state = await readFile(join(bookDir, "story", "current_state.md"), "utf-8");
|
|
1235
|
+
expect(state).toContain("After chapter 1");
|
|
1236
|
+
expect(state).not.toContain("After chapter 3");
|
|
1237
|
+
|
|
1238
|
+
const hooks = await readFile(join(bookDir, "story", "pending_hooks.md"), "utf-8");
|
|
1239
|
+
expect(hooks).toContain("hook-2");
|
|
1240
|
+
expect(hooks).not.toContain("hook-4");
|
|
1241
|
+
|
|
1242
|
+
// Chapter index should only have chapter 1
|
|
1243
|
+
const index = await manager.loadChapterIndex(bookId);
|
|
1244
|
+
expect(index).toHaveLength(1);
|
|
1245
|
+
expect(index[0]!.number).toBe(1);
|
|
1246
|
+
expect(index[0]!.status).toBe("approved");
|
|
1247
|
+
|
|
1248
|
+
// Chapter files for 2 and 3 should be deleted
|
|
1249
|
+
const chaptersDir = join(bookDir, "chapters");
|
|
1250
|
+
const { readdir: rd } = await import("node:fs/promises");
|
|
1251
|
+
const remaining = (await rd(chaptersDir)).filter((f) => f.endsWith(".md"));
|
|
1252
|
+
expect(remaining).toEqual(["0001_Title_One.md"]);
|
|
1253
|
+
|
|
1254
|
+
// Snapshots for 2 and 3 should be deleted
|
|
1255
|
+
const snapshotsDir = join(bookDir, "story", "snapshots");
|
|
1256
|
+
const snapshots = await rd(snapshotsDir);
|
|
1257
|
+
expect(snapshots.sort()).toEqual(["0", "1"]);
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
it("rolls back to chapter 0 (initial state) when rejecting chapter 1", async () => {
|
|
1261
|
+
await setupRollbackBook();
|
|
1262
|
+
|
|
1263
|
+
const discarded = await manager.rollbackToChapter(bookId, 0);
|
|
1264
|
+
|
|
1265
|
+
expect(discarded).toEqual([1, 2, 3]);
|
|
1266
|
+
|
|
1267
|
+
const bookDir = manager.bookDir(bookId);
|
|
1268
|
+
const state = await readFile(join(bookDir, "story", "current_state.md"), "utf-8");
|
|
1269
|
+
expect(state).toContain("Initial state");
|
|
1270
|
+
|
|
1271
|
+
const index = await manager.loadChapterIndex(bookId);
|
|
1272
|
+
expect(index).toHaveLength(0);
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
it("throws when the target snapshot does not exist", async () => {
|
|
1276
|
+
await setupRollbackBook();
|
|
1277
|
+
|
|
1278
|
+
await expect(manager.rollbackToChapter(bookId, 99)).rejects.toThrow("Cannot restore snapshot");
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
it("removes sqlite memory files when rolling back", async () => {
|
|
1282
|
+
await setupRollbackBook();
|
|
1283
|
+
|
|
1284
|
+
const storyDir = join(manager.bookDir(bookId), "story");
|
|
1285
|
+
await Promise.all([
|
|
1286
|
+
writeFile(join(storyDir, "memory.db"), "stale db", "utf-8"),
|
|
1287
|
+
writeFile(join(storyDir, "memory.db-shm"), "stale shm", "utf-8"),
|
|
1288
|
+
writeFile(join(storyDir, "memory.db-wal"), "stale wal", "utf-8"),
|
|
1289
|
+
]);
|
|
1290
|
+
|
|
1291
|
+
await manager.rollbackToChapter(bookId, 1);
|
|
1292
|
+
|
|
1293
|
+
await expect(stat(join(storyDir, "memory.db"))).rejects.toThrow();
|
|
1294
|
+
await expect(stat(join(storyDir, "memory.db-shm"))).rejects.toThrow();
|
|
1295
|
+
await expect(stat(join(storyDir, "memory.db-wal"))).rejects.toThrow();
|
|
1296
|
+
});
|
|
1297
|
+
});
|
|
1298
|
+
});
|