This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Agent-oriented content-publishing state machine for WeChat articles and newspic image sets.
- Runtime: Bun, not Node. All commands use
bun run src/cli.ts. - TypeScript: strict mode,
noUnusedLocals,noUnusedParameters, so the compiler rejects unused variables. - Module resolution:
"bundler", use ESM imports only, no CommonJS. - Test runner:
bun test, not Jest or Vitest. Main test file:src/workflow.test.ts. - Typecheck:
bun x tsc --noEmit, separate from tests.
# Run any CLI command directly from source (always up-to-date, no rebuild needed)
bun run src/cli.ts <command> [options]
# Global binaries run compiled bundle (must rebuild first: bun install --global .)
zzhub-pipeline <command> [options]
zzp <command> [options]
# Run all tests
bun test
# Run a specific test file
bun test src/workflow.test.ts
# Typecheck without building
bun x tsc --noEmit
# Build WeChat preview bundle, separate Vite config
bun run build:wechat-preview
# Release versioning
bun run release:patch # bug fix, 0.1.2 → 0.1.3
bun run release:major # new feature (0.x: 0.1.2 → 0.2.0 / 1.x+: breaking 1.2.3 → 2.0.0)
bun run release:minor # new feature (1.x+ only: 1.2.3 → 1.3.0)
# Publish to npm
npm publishImportant: bun run src/cli.ts always runs the latest source directly. The global zzp / zzhub-pipeline binary runs a compiled bundle at dist/cli.js (generated by bun install --global .), NOT raw source. The source .ts files in src/ are NOT live — changes require a rebuild.
强制性规则:任何代码修改完成后,必须立即执行
bun install --global .这是不可跳过的硬性步骤。全局
zzp二进制运行的是dist/cli.js编译产物,不执行bun install --global .会导致二进制使用过期代码,所有后续 CLI 操作都基于旧逻辑运行。即使是改一行代码、加一个字段、修一个 typo 也必须执行。编辑 .md 文档文件除外。
The only correct agent procedure is:
- Find the active task:
bun run src/cli.ts find-run --workspace {ws} --active --view agentOr list all active tasks:bun run src/cli.ts tasks --workspace {ws} --active --view agent - If no task exists, create one with
init. - Check current state:
bun run src/cli.ts status --state {state_path} --view agent - Read
next_actionfrom the output. - Execute only the action identified by
next_action.action. - Re-run
statusto confirm progress. - Repeat until
modeisdone.
Do not:
- Scan session history to guess the current task.
- Modify
workflow-state.jsondirectly. - Skip
statusorreconcileand assume the next step. - Execute multiple steps without checking status between them.
--view agent output includes:
next_action.action, the next step/action identifier.next_action.reason, why this action is needed.next_action.params, which may includestate_path,spawn,requires_research,source_body_path, andfeedback.gaps, missing materials.blockers, blocking issues.
Use --view agent for find-run, tasks, and status whenever an agent needs the next step.
buildTaskStatus() in src/task-manager.ts determines the next action by priority (1→12), each branch mutually exclusive:
mode=doneorphase=done→ completemode=failed→ reset-or-repair (checkpoint diagnose → reset)mode=handoff+ body_inputs pending → attach-body-imagesmode=handoff+ no body_inputs → resolve-handoff- Body not mounted (no
source_body_pathorasset_path) → attach-body - body_inputs pending → attach-body-images
- Metadata incomplete (title, slug, date) → prepare
content_review=needs_revision→ revise-contentcontent_review=unchecked→ review-contentcontent_review=passed+ noasset_path→ prepare-finalizeasset_pathexists + render needs progress → renderasset_pathexists + publish needs progress → publish- Fallback → prepare (re-prepare if nothing else matches)
Key invariants:
- C7 (content_review) only triggers when
asset_pathdoes not yet exist. - C9/C10 enter only after
asset_pathis guaranteed (written byprepare-finalize). attach-newspic-specis not in the decision tree — it's an optional command the agent may call before render.reconcileis not in the decision tree — it's a manual maintenance command.
Tasks are created two ways:
init — Agent directly initializes a new task:
bun run src/cli.ts init \
--workspace {workspace} \
--task-kind publish \
--content-form article \
--targets wechat \
--intent-text "发公众号文章给大号"Optional init flags:
--existing-draft-media-id MEDIA_ID— update an existing WeChat draft instead of creating a new one--note-id NOTE_ID— link to a Nezus note for publish result callback
ingest-handoff — External system hands off a JSON file:
bun run src/cli.ts ingest-handoff --file handoff.jsonTwo handoff JSON formats are supported:
// workflow_handoff — create new task
{
"workflow_handoff": {
"mode": "new",
"content_form": "article",
"body_path": "/abs/path/body.md",
"target_account": "default",
"title": "文章标题"
}
}
// workflow_handoff — resume existing task
{
"workflow_handoff": {
"mode": "resume",
"state_path": "/abs/path/workflow-state.json",
"body_path": "/abs/path/revised-body.md",
"review_policy": "trust_user"
}
}
// publish_handoff — simplified format (always creates new)
{
"publish_handoff": {
"content_form": "article",
"body_path": "/abs/path/body.md",
"target_account": "default",
"title": "文章标题"
}
}Resume mode compares core inputs (body, title, route, intent_text, explicit_constraints). If changed, resetDerivedState() resets all phases to pending while preserving run_id and workspace_root. If only review_policy changed to trust_user and a handoff.body_path exists, content_review is auto-set to passed.
- Prefer
updateState(path, mutate)for new work. Some existing commands still usereadStateandwriteStatedirectly. - Never write
workflow-state.jsonby hand. content_review.statusmust be"passed"beforerenderorpublishcan proceed.redo_hintis set byreset --mode redo.*and cleared byprepare-finalize, so leave it alone.- Body text is never stored in state. It lives in
{workspace}/.zzhub-media/tmp/{run_id}/. republishadds topublish_targetsandpublish.results, but does not changemode.
Temporary (prepare phase) Canonical (after prepare-finalize)
{workspace}/.zzhub-media/ {workspace}/posts/{date-slug}/
runs/{run_id}.json ←─────────────────── workflow-state.json
tmp/{run_id}/source-body.md ───────→ post.md
images/wechat/ ← article render images
images/newspic/ ← newspic render images
prepare-finalize solidifies temporary state into the canonical directory. After that, all commands auto-resolve posts/{date-slug}/workflow-state.json as the authoritative source.
Three active workflow routes exist:
wechat-article, WeChat public account article, HTML export.wechat-newspic, WeChat image message, multi-page PNG set.blog, Markdown sync to the configured blog project and publish command.
blog participates in the normal prepare → publish workflow and skips render when it is the only target. The legacy sync-blog command remains available for post-hoc sync; both paths write results to publish.results in state JSON.
The active pipeline is: prepare → render → publish → done (or failed). Routes that do not require render, such as blog-only publishing, go directly from prepare to publish.
channel-route— resolve route and target accountauthor-select— determine rewrite permission and style modeformat— text formatting rulesasset-meta— extract title/date/summarybody-inputs— scan body for illustration markershighlight-words— extract cover highlight words from title (inprepare-finalize)asset-save— write final files toposts/directory (inprepare-finalize)
stylepolishing is NOT insideprepare— it requires an LLM call, done by an external Worker.
image-plan— template selection (wechat-cover-split / poster-3-4 / longform-3-4)body-inputs— collect illustration images (may pause in longform mode for user input)adapter-render— call image render plugin- Record
render_assets, incrementrender_version
- Collect publish routes (primary + extras)
- Idempotency check (skip if content_version + render_version match)
provider.publish()— WeChat / COS / blog- Record publish results, set mode=done if all succeed
- Default output is raw JSON, which works well for agents, pipes, and redirects.
--view markdownrenders a structured markdown table.--view agentrenders agent-optimized markdown with an explicitnext_action.paramsblock.- Agents should use
--view agentforfind-run,tasks, andstatus. - TTY output is pretty-printed when stdout is a terminal, raw JSON otherwise.
NO_COLOR=1forces raw JSON.FORCE_COLOR=1forces pretty output.
src/
cli.ts # Entrypoint, dispatches to command registry
plugins.ts # Command registry, workflow and ops plugin groups
state.ts # WorkflowState type + CRUD, authoritative contract
schema/
state.ts # Zod schemas for state validation and defaults
config.ts # Zod schema for PipelineConfig
task-manager.ts # listTasks / findTask / getTaskByStatePath / buildTaskStatus
task-views.ts # markdown and agent view renderers
workflow-materials.ts# body path resolution, body-input reconciliation
routes.ts # deterministic routing, keyword match + account resolution
profiles.ts # authoring rules, rewrite_allowed, style_mode decision tree
text.ts # text formatting, frontmatter, markdown normalization
output.ts # TTY-aware output layer, printResult and pretty renderers
config.ts # config loading, env overrides, workspace path resolution
args.ts # argument parsing
spawn.ts # subprocess wrapper, PATH enhancement
adapter-types.ts # ImageRenderPlugin / MarkdownRenderPlugin interfaces
adapter-loader.ts # resolveImageRenderer / resolveMarkdownRenderer / runPluginDoctorChecks
runtime-paths.ts # asset path resolution (dev/compiled modes), font cache
adapters/ # built-in adapter implementations
commands/ # one file per CLI command
imgx/ # image rendering subsystem, Chrome headless + @napi-rs/canvas
providers/ # publish providers, wechat, cos, blog
wechat-preview/ # WeChat article HTML preview, Milkdown + themes
| Command | Description |
|---|---|
init |
Create run state from intent classification |
ingest-handoff |
Create or resume task from handoff JSON |
attach-body |
Attach source body file to task |
attach-body-images |
Attach body image markers to task |
attach-newspic-spec |
Attach or update newspic render intent |
prepare |
Route + author + format + metadata |
prepare-finalize |
Highlight words + asset save |
render |
Image plan + imgx render |
publish |
Execute publish routes |
reconcile |
Reconcile task materials and derived state |
checkpoint |
Read task state and validate current phase |
status |
Read task with gaps and next action |
find-run |
Find the best matching task |
tasks |
List tasks in workspace |
reset |
Reset phases for revision |
review |
Update content review status |
abandon |
Mark tasks as abandoned |
| Command | Description |
|---|---|
sync-blog |
Copy post.md to blog repo, publish, and record result in state JSON |
republish |
Publish completed task to additional accounts/platforms |
imgx |
Run bundled imgx renderer subcommands |
wechat-export |
Render markdown to WeChat HTML |
wechat-preview |
Run the local WeChat HTML preview server |
cos-upload |
Upload image to COS CDN |
config |
Read or update pipeline config |
doctor |
Inspect resolved paths and provider health |
hermes-metrics |
Show Hermes execution metrics per task |
wx-drafts |
List or get drafts from WeChat draft box |
wx-draft-delete |
Delete a draft from WeChat draft box |
bun run src/cli.ts reset --state {state_path} --mode content # full re-prepare
bun run src/cli.ts reset --state {state_path} --mode redo.style # redo style polish only
bun run src/cli.ts reset --state {state_path} --mode redo.format # redo formatting only
bun run src/cli.ts reset --state {state_path} --mode redo.metadata # redo metadata only
bun run src/cli.ts reset --state {state_path} --mode redo.route # redo route parsing only
bun run src/cli.ts reset --state {state_path} --mode render # redo render
bun run src/cli.ts reset --state {state_path} --mode publish # redo publish
bun run src/cli.ts reset --state {state_path} --mode full # abandon task (mode=failed)Each mode sets redo_hint so the agent knows which sub-step to resume from.
Config file location on macOS: ~/Library/Application Support/zzhub-pipeline/config.json
Override with ZZHUB_PIPELINE_CONFIG=/abs/path/config.json
Other env overrides: ZZHUB_PIPELINE_WORKSPACE_ROOT, ZZHUB_PIPELINE_POSTS_DIR, ZZHUB_PIPELINE_BLOG_ROOT.
Tests isolate config with process.env.ZZHUB_PIPELINE_CONFIG = <tmp path>.
Config structure: paths, services, commands, wx.accounts, cos, plugins, imgx.
Rendering requires headless Chrome. findChrome() in src/imgx/runtime.ts probes these paths in order:
/Applications/Google Chrome.app/Contents/MacOS/Google Chrome/Applications/Chromium.app/Contents/MacOS/Chromiumgoogle-chromefrom PATHchromiumfrom PATH
Chrome must be installed for render and wechat-export. Checked by the builtin markdown renderer adapter's doctor() method.
Viewport inset quirk: Chrome CLI --window-size=900,1200 does not guarantee innerHeight=1200. The runtime measures the real inset, over-captures, then crops. If a footer looks clipped, check this layer first, not the template CSS.
Rendering is pluggable via config.plugins. Two adapter interfaces exist:
ImageRenderPlugin— replaces imgx for image rendering (poster, longform, cover)MarkdownRenderPlugin— replaces wechat-preview for WeChat HTML export
Config override (local file path or npm package):
{
"plugins": {
"imageRenderer": "./my-image-renderer.js",
"markdownRenderer": "./my-md-renderer.js"
}
}When unset, built-in adapters (builtin-imgx, builtin-wechat-preview) are used.
Key files:
src/adapter-types.ts— interfaces (ImageRenderPlugin,MarkdownRenderPlugin,PipelinePluginDoctorCheck)src/adapter-loader.ts—resolveImageRenderer(),resolveMarkdownRenderer(),runPluginDoctorChecks()src/adapters/builtin-image-renderer.ts— wraps imgxsrc/adapters/builtin-markdown-renderer.ts— wraps wechat-preview
Runtime dependency checks:
@napi-rs/canvas— checked by image rendererdoctor(), lazy-loaded inpretext-runtime.ts- Chrome — checked by markdown renderer
doctor(), error includes install guidance - CJK fonts — bundled locally and checked by
ensureFonts()inruntime-paths.ts; rendering never downloads fonts. Cover themes also accept local system font names.
Two pagination modes coexist:
- Auto-flow, the default, fills pages in order.
- Spec-driven, enabled when
page_specsis present innewspic_render, follows the spec.
pagination_mode values: single (poster-3-4 card) or multi (longform-3-4 album).
To pin text to a page, add explicit markers in the body and provide page_specs:
【第一页】
...
【第二页】
...
【Page 1】 is also accepted. Without page_specs, these markers are ignored.
target_fill_ratio is clamped to 0.35 to 0.95, with 0.8 as the default. Page-level spec takes priority over the top-level value.
- Based on Chrome headless and
@napi-rs/canvas - Templates:
longform-3-4(newspic longform),poster-3-4(single-page card),wechat-cover-split(article cover) - Themes:
paper-sage(default account),linen-news(ancientoneaccount) - Geometry auto-derived from header, footer, padding; can be overridden via CLI flags
- Pagination done in-process via
@chenglou/pretext, no Chrome dump needed - Both
renderandwechat-exportrequire Chrome
- Milkdown-based markdown to WeChat HTML conversion
- Multi-theme support
- Build command:
bun run build:wechat-preview wechat-exportuses this preview style directly- Local preview server:
wechat-preview serve(defaulthttp://127.0.0.1:18765) wechat-exportregisters success/failure entries on the server by default (--no-previewto skip)- Bundle dirty detection: source newer than dist → auto rebuild; rebuild failure falls back to existing dist with
bundle_stale
Typography is owned by @zzclub/milkdown-article-style on npm
(article.css + tokens-default.css). Pipeline export and Nezus Milkdown both
depend on it so writing preview matches WeChat output. Edit CSS only in that
package (repo: milkdown-article-style); do not fork rules into this repo.
"@zzclub/milkdown-article-style": "^0.1.0"The export entry imports @zzclub/milkdown-article-style/article.css and
bundles it into browser-dist/editor-export.js at build time. The runtime
wechat-export command loads the built bundle, not the package CSS file.
Editing the package CSS has no effect on export until you rebuild (or until auto dirty rebuild succeeds):
bun run build:wechat-previewThis also applies when ensureBundle() auto-builds on first export (if manifest.json is missing) or when sources are newer than dist. Prefer explicit rebuild after CSS changes.
| Provider | Route | Description |
|---|---|---|
wechat-article |
Create WeChat article draft | |
wechat-newspic |
Send image message | |
| cos | - | Tencent Cloud COS image CDN |
| blog | - | Markdown sync to blog repo |
Package name: @zzclub/pipeline, publishConfig.access is public.
files includes the source tree: src/.
CLI entry: src/cli.ts (runs directly via Bun shebang).
Assets are resolved from the source tree at runtime via runtime-paths.ts.
changelogen bumps differently for 0.x vs 1.x+:
| Scenario | Current | Command | Result |
|---|---|---|---|
| Bug fix | any | release:patch |
0.1.2 → 0.1.3 / 1.2.3 → 1.2.4 |
| New feature (0.x) | 0.1.2 | release:major |
0.1.2 → 0.2.0 |
| New feature (1.x+) | 1.2.3 | release:minor |
1.2.3 → 1.3.0 |
| Breaking (1.x+) | 1.2.3 | release:major |
1.2.3 → 2.0.0 |
At 0.x, release:major bumps the middle digit (minor), release:minor behaves like patch. After 1.x, standard semver applies.
- Create
src/commands/<name>.tsexportingasync function <camelName>(args: string[]): Promise<void>. - Register it in
src/plugins.tsunder the correct plugin group. - The CLI auto-discovers registered commands, so no other wiring is needed.
Key patterns:
- Parse args with
parseArgsfromsrc/args.ts. - Output with
printResult(data, renderer)fromsrc/output.ts, never rawconsole.log. - Prefer
updateState(path, mutate)fromsrc/state.tsfor new stateful work; existing commands may still usereadStateandwriteStatedirectly. - Load config through
loadConfig()fromsrc/config.ts.
For understanding the codebase, read in this order:
src/cli.ts— entrypoint and command dispatchsrc/plugins.ts— command registrysrc/state.ts— WorkflowState type and CRUD operationssrc/task-manager.ts— task listing, finding, status reportingsrc/routes.ts— deterministic routingsrc/profiles.ts— authoring rules and style mode decisionsrc/workflow-materials.ts— body path resolution, material reconciliationsrc/commands/prepare.ts— prepare command implementationsrc/commands/prepare-finalize.ts— finalize implementationsrc/commands/render.ts— render command implementationsrc/commands/publish.ts— publish command implementation