@zzclub/pipeline 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +44 -73
  2. package/package.json +2 -1
  3. package/src/adapter-types.ts +21 -0
  4. package/src/adapters/builtin-markdown-renderer.ts +7 -0
  5. package/src/cli.ts +5 -1
  6. package/src/commands/config.ts +47 -20
  7. package/src/commands/ingest-handoff.ts +1 -21
  8. package/src/commands/prepare-finalize.ts +2 -1
  9. package/src/commands/prepare.ts +22 -14
  10. package/src/commands/publish.ts +2 -3
  11. package/src/commands/reconcile.ts +3 -5
  12. package/src/commands/render.ts +2 -1
  13. package/src/commands/republish.ts +2 -3
  14. package/src/commands/reset.ts +9 -1
  15. package/src/commands/review.ts +3 -2
  16. package/src/commands/sync-blog.ts +2 -1
  17. package/src/commands/wechat-export.ts +134 -12
  18. package/src/commands/wechat-preview.ts +160 -0
  19. package/src/config.ts +21 -2
  20. package/src/imgx/runtime.ts +31 -3
  21. package/src/logger.test.ts +56 -0
  22. package/src/logger.ts +178 -0
  23. package/src/plugins.ts +2 -4
  24. package/src/providers/blog.ts +1 -2
  25. package/src/providers/index.ts +29 -1
  26. package/src/providers/wechat.ts +3 -1
  27. package/src/schema/config.account-name.test.ts +79 -0
  28. package/src/schema/config.ts +11 -3
  29. package/src/state.ts +24 -0
  30. package/src/task-manager.test.ts +57 -0
  31. package/src/task-manager.ts +23 -5
  32. package/src/wechat-preview/assets/browser-dist/editor-export.js +129 -73
  33. package/src/wechat-preview/browser/editor-export.css +5 -366
  34. package/src/wechat-preview/browser/editor-export.ts +7 -3
  35. package/src/wechat-preview/bundle-stale.test.ts +52 -0
  36. package/src/wechat-preview/index.ts +390 -83
  37. package/src/wechat-preview/server/client.ts +164 -0
  38. package/src/wechat-preview/server/http.ts +229 -0
  39. package/src/wechat-preview/server/index.ts +33 -0
  40. package/src/wechat-preview/server/local-file.test.ts +52 -0
  41. package/src/wechat-preview/server/local-file.ts +116 -0
  42. package/src/wechat-preview/server/paths.ts +80 -0
  43. package/src/wechat-preview/server/registry.test.ts +60 -0
  44. package/src/wechat-preview/server/registry.ts +118 -0
  45. package/src/wechat-preview/server/singleton.ts +118 -0
  46. package/src/wechat-preview/server/types.ts +61 -0
  47. package/src/wechat-preview/server/ui.ts +324 -0
  48. package/src/wechat-preview/themes.ts +8 -0
  49. package/src/wechat-preview/wechat-renderer.links.test.ts +44 -0
  50. package/src/wechat-preview/wechat-renderer.test.ts +2 -0
  51. package/src/wechat-preview/wechat-renderer.ts +52 -6
  52. package/src/wechat-preview.test.ts +14 -7
  53. package/src/workflow-materials.test.ts +52 -1
  54. package/src/workflow-materials.ts +12 -0
  55. package/src/workflow.test.ts +62 -0
  56. package/src/commands/analytics.test.ts +0 -156
  57. package/src/commands/analytics.ts +0 -240
  58. package/src/commands/topic.test.ts +0 -146
  59. package/src/commands/topic.ts +0 -717
  60. package/src/db.test.ts +0 -47
  61. package/src/db.ts +0 -95
  62. package/src/schema/analytics.test.ts +0 -54
  63. package/src/schema/analytics.ts +0 -19
  64. package/src/schema/topic.test.ts +0 -69
  65. package/src/schema/topic.ts +0 -55
package/README.md CHANGED
@@ -410,7 +410,7 @@ src/
410
410
  | `review` | Update content review status |
411
411
  | `abandon` | Mark one or more tasks as abandoned |
412
412
 
413
- `ops` 组,12 个命令:
413
+ `ops` 组,11 个命令:
414
414
 
415
415
  | 命令 | 说明 |
416
416
  | --- | --- |
@@ -418,14 +418,13 @@ src/
418
418
  | `republish` | Publish a completed task to an additional account or platform |
419
419
  | `imgx` | Run bundled imgx renderer subcommands |
420
420
  | `wechat-export` | Render markdown to WeChat HTML with bundled preview styles |
421
+ | `wechat-preview` | Local singleton server to preview exported WeChat HTML |
421
422
  | `cos-upload` | Upload a local image to configured COS CDN |
422
423
  | `config` | Read or update pipeline config |
423
424
  | `doctor` | Inspect resolved paths and provider health |
424
425
  | `hermes-metrics` | Show Hermes execution metrics per task |
425
426
  | `wx-drafts` | List or get drafts from WeChat draft box |
426
427
  | `wx-draft-delete` | Delete a draft from WeChat draft box |
427
- | `topic` | 选题管理(添加、列表、更新、排期、复盘、放弃) |
428
- | `analytics` | 发布数据录入和分析 |
429
428
 
430
429
  ## 常用命令
431
430
 
@@ -520,75 +519,6 @@ bun run src/cli.ts reset --state {state_path} --mode full # 完全放弃
520
519
 
521
520
  每种模式均会设置 `redo_hint`,确保 Agent 恢复时知道从哪个子步骤开始。
522
521
 
523
- ### 选题管理
524
-
525
- 使用 `topic` 命令管理内容选题的完整生命周期:
526
-
527
- ```bash
528
- # 添加选题
529
- bun run src/cli.ts topic add \
530
- --workspace {workspace} \
531
- --title "AI 工具推荐" \
532
- --priority high \
533
- --tags "AI,工具"
534
-
535
- # 列出选题
536
- bun run src/cli.ts topic list --workspace {workspace}
537
- bun run src/cli.ts topic list --workspace {workspace} --status backlog --priority high
538
-
539
- # 更新选题(AI 评估)
540
- bun run src/cli.ts topic update \
541
- --workspace {workspace} \
542
- --topic {topic_id} \
543
- --ai-score 85 \
544
- --ai-reason "基于热点趋势和受众匹配度"
545
-
546
- # 排期
547
- bun run src/cli.ts topic schedule \
548
- --workspace {workspace} \
549
- --topic {topic_id} \
550
- --scheduled-date 2026-06-15 \
551
- --target-account default
552
-
553
- # 复盘(发布后)
554
- bun run src/cli.ts topic retro \
555
- --workspace {workspace} \
556
- --topic {topic_id} \
557
- --performance good \
558
- --lessons "标题悬念效果好" \
559
- --metrics-snapshot '{"reads": 1500, "likes": 45}'
560
-
561
- # 放弃选题
562
- bun run src/cli.ts topic abandon \
563
- --workspace {workspace} \
564
- --topic {topic_id} \
565
- --reason "话题过时"
566
- ```
567
-
568
- 选题状态流转:`backlog` → `evaluating` → `scheduled` → `in_progress` → `published`(或 `abandoned`)
569
-
570
- ### 数据分析
571
-
572
- 使用 `analytics` 命令录入和分析发布后的数据:
573
-
574
- ```bash
575
- # 录入发布数据
576
- bun run src/cli.ts analytics record \
577
- --state {workspace}/posts/{date-slug}/workflow-state.json \
578
- --reads 1500 \
579
- --likes 45 \
580
- --favorites 23 \
581
- --shares 12 \
582
- --comments 8 \
583
- --notes "标题效果好,转化率高"
584
-
585
- # 列出历史数据
586
- bun run src/cli.ts analytics list --workspace {workspace}
587
- bun run src/cli.ts analytics list --workspace {workspace} --days 30 --sort reads
588
- ```
589
-
590
- 数据存储在 `{workspace}/zzhub.db`(SQLite 数据库),支持复杂查询和统计分析。
591
-
592
522
  ### Blog 同步
593
523
 
594
524
  ```bash
@@ -630,6 +560,47 @@ bun run src/cli.ts wechat-export \
630
560
  `--preview-shell-out` 生成最终发布 HTML 的精确预览:预览页直接嵌入 `--out`
631
561
  中的内联样式片段,不再加载 Milkdown 编辑器或复制浏览器 bundle。
632
562
 
563
+ 默认会将导出结果登记到本地 **单例预览服务**(`http://127.0.0.1:18765`),可在浏览器
564
+ 侧栏切换多篇产物,无需发到微信草稿箱。服务未启动时会自动拉起;失败导出也会登记并展示
565
+ `error_kind` / timeout / Chrome stderr 等调试信息。
566
+
567
+ ```bash
568
+ # 显式启动预览服务(全局只允许一个实例;重复启动会复用)
569
+ bun run src/cli.ts wechat-preview serve --open
570
+
571
+ # 导出并打开该条目
572
+ bun run src/cli.ts wechat-export \
573
+ --markdown /abs/path/body.md \
574
+ --out /tmp/article.html \
575
+ --account default \
576
+ --open
577
+
578
+ # 调试中间产物
579
+ bun run src/cli.ts wechat-export \
580
+ --markdown /abs/path/body.md \
581
+ --out /tmp/article.html \
582
+ --debug-dir /tmp/wx-debug \
583
+ --timeout-ms 20000
584
+
585
+ # 关闭预览登记
586
+ bun run src/cli.ts wechat-export ... --no-preview
587
+ ```
588
+
589
+ | 子命令 | 说明 |
590
+ | --- | --- |
591
+ | `wechat-preview serve` | 启动/复用单例服务 |
592
+ | `wechat-preview status` | 是否在运行 |
593
+ | `wechat-preview open` | 打开 dashboard |
594
+ | `wechat-preview list` | 列出条目 |
595
+ | `wechat-preview clear` | 清空条目 |
596
+
597
+ 环境变量:`ZZHUB_WECHAT_PREVIEW_PORT`(默认 18765)、`ZZHUB_WECHAT_PREVIEW_HOST`、
598
+ `ZZHUB_WECHAT_PREVIEW_DIR`(锁与条目落盘目录)。发布草稿时默认也会登记一条
599
+ `[publish] ...` 预览;设 `ZZHUB_WECHAT_PREVIEW_ON_PUBLISH=0` 可关闭。
600
+
601
+ 源码(CSS/browser 入口)新于 dist 时会自动尝试 rebuild;失败则回退已有 bundle 并标记
602
+ `bundle_stale`。手动重建:`bun run build:wechat-preview`。
603
+
633
604
  #### 自定义 CSS
634
605
 
635
606
  可以通过账号配置或 CLI 覆盖内置样式:
@@ -854,7 +825,7 @@ bun run src/cli.ts init ... --note-id NOTE_ID
854
825
  - 构建命令:`bun run build:wechat-preview`
855
826
  - `wechat-export` 和微信草稿发布使用同一份最终内联 HTML
856
827
 
857
- `src/wechat-preview/browser/editor-export.css` 会通过 Vite 以 raw CSS 打入浏览器
828
+ `@zzclub/milkdown-article-style`(`article.css`)会通过 Vite 以 raw CSS 打入浏览器
858
829
  bundle。修改该 CSS 或浏览器 renderer 后必须执行:
859
830
 
860
831
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzclub/pipeline",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zzp": "./src/cli.ts",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@chenglou/pretext": "^0.0.5",
28
+ "@zzclub/milkdown-article-style": "0.1.1",
28
29
  "image-size": "^2.0.2",
29
30
  "pngjs": "^7.0.0",
30
31
  "zod": "^4.4.2"
@@ -143,6 +143,12 @@ export interface MarkdownRenderInput {
143
143
  editorVars?: Record<string, string>;
144
144
  exportTheme?: Record<string, string>;
145
145
  };
146
+
147
+ /** Chrome virtual-time-budget in ms */
148
+ timeoutMs?: number;
149
+
150
+ /** Write intermediate artifacts for debugging */
151
+ debugDir?: string;
146
152
  }
147
153
 
148
154
  /**
@@ -164,6 +170,21 @@ export interface MarkdownRenderOutput {
164
170
 
165
171
  /** Path to preview shell HTML (if generated) */
166
172
  previewShellPath?: string;
173
+
174
+ /** Wall-clock render duration */
175
+ durationMs?: number;
176
+
177
+ /** Whether the browser bundle was rebuilt during this render */
178
+ bundleRebuilt?: boolean;
179
+
180
+ /** Whether the browser bundle was stale before render */
181
+ bundleStale?: boolean;
182
+
183
+ /** Debug directory if requested */
184
+ debugDir?: string;
185
+
186
+ /** Semantic HTML from Milkdown before WeChat finalize (when available) */
187
+ semanticHtml?: string;
167
188
  }
168
189
 
169
190
  export interface MarkdownRenderPlugin {
@@ -51,6 +51,8 @@ export const builtinMarkdownRenderer: MarkdownRenderPlugin = {
51
51
  previewShellOutPath: input.previewShellOutPath,
52
52
  customCss: input.customCss,
53
53
  themeOverrides: input.themeOverrides,
54
+ timeoutMs: input.timeoutMs,
55
+ debugDir: input.debugDir ?? undefined,
54
56
  });
55
57
 
56
58
  return {
@@ -59,6 +61,11 @@ export const builtinMarkdownRenderer: MarkdownRenderPlugin = {
59
61
  account: result.account,
60
62
  previewStyle: result.previewStyle,
61
63
  previewShellPath: result.previewShellPath,
64
+ durationMs: result.durationMs,
65
+ bundleRebuilt: result.bundleRebuilt,
66
+ bundleStale: result.bundleStale,
67
+ debugDir: result.debugDir,
68
+ semanticHtml: result.semanticHtml,
62
69
  };
63
70
  },
64
71
  };
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFileSync } from "fs";
4
4
  import { dirname, join } from "path";
5
5
  import { fileURLToPath } from "url";
6
+ import { getDailyLogPath, runWithCommandLog } from "./logger";
6
7
  import { formatUsage, getCommandRegistry } from "./plugins";
7
8
 
8
9
  const COMMANDS = getCommandRegistry();
@@ -36,10 +37,13 @@ async function main() {
36
37
  }
37
38
 
38
39
  try {
39
- await command.handler(args.slice(1));
40
+ await runWithCommandLog(cmd, args.slice(1), async () => {
41
+ await command.handler(args.slice(1));
42
+ });
40
43
  } catch (err) {
41
44
  const msg = err instanceof Error ? err.message : String(err);
42
45
  console.error(`[zzhub-pipeline ${cmd}] Error: ${msg}`);
46
+ console.error(`[zzhub-pipeline ${cmd}] Full log: ${getDailyLogPath()}`);
43
47
  process.exit(1);
44
48
  }
45
49
  }
@@ -5,13 +5,17 @@ import {
5
5
  configSummary,
6
6
  getConfigValue,
7
7
  loadConfig,
8
- PipelineConfigSchema,
8
+ normalizeConfig,
9
9
  redactConfig,
10
10
  redactConfigValue,
11
11
  saveConfig,
12
12
  setConfigValue,
13
13
  } from "../config";
14
14
 
15
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+
15
19
  export async function configCommand(args: string[]): Promise<void> {
16
20
  const parsed = parseArgs(args);
17
21
 
@@ -26,6 +30,11 @@ Options:
26
30
  --export Print full config as JSON (secrets redacted; use --raw to show all)
27
31
  --import Path to a JSON file to merge into current config
28
32
  --raw Show secrets unredacted (use with --export)
33
+
34
+ Examples:
35
+ zzhub-pipeline config --key wx.defaultAccount
36
+ zzhub-pipeline config --key wx.accounts.default.name --value "大号(早早集市)"
37
+ zzhub-pipeline config --export
29
38
  `.trim());
30
39
  return;
31
40
  }
@@ -50,27 +59,45 @@ Options:
50
59
  throw new Error(`Failed to read import file: ${err instanceof Error ? err.message : String(err)}`);
51
60
  }
52
61
 
53
- // Merge: existing values win, imported fills gaps
54
- const importedObj = (typeof imported === "object" && imported !== null ? imported : {}) as Record<string, unknown>;
55
- const mergedWx = {
56
- ...config.wx,
57
- ...(importedObj.wx as Record<string, unknown> ?? {}),
58
- accounts: {
59
- ...config.wx.accounts,
60
- ...((importedObj.wx as Record<string, unknown>)?.accounts as Record<string, unknown> ?? {}),
61
- },
62
- };
62
+ // Merge: top-level shallow; wx.accounts deep-merge per key so partial imports
63
+ // keep existing fields (e.g. display `name`) unless overridden.
64
+ const importedObj = isPlainObject(imported) ? imported : {};
65
+ const importedWx = isPlainObject(importedObj.wx) ? importedObj.wx : {};
66
+ const importedAccounts = isPlainObject(importedWx.accounts) ? importedWx.accounts : {};
67
+ const mergedAccounts: Record<string, unknown> = { ...config.wx.accounts };
68
+ for (const [key, value] of Object.entries(importedAccounts)) {
69
+ const accountKey = key.trim();
70
+ if (!accountKey) continue;
71
+ const existing = isPlainObject(mergedAccounts[accountKey])
72
+ ? mergedAccounts[accountKey]
73
+ : {};
74
+ const incoming = isPlainObject(value) ? value : {};
75
+ mergedAccounts[accountKey] = { ...existing, ...incoming };
76
+ }
63
77
  const raw = {
64
- paths: { ...config.paths, ...(importedObj.paths ?? {}) },
65
- services: { ...config.services, ...(importedObj.services ?? {}) },
66
- commands: { ...config.commands, ...(importedObj.commands ?? {}) },
67
- wx: mergedWx,
68
- cos: { ...config.cos, ...(importedObj.cos ?? {}) },
69
- plugins: { ...config.plugins, ...(importedObj.plugins ?? {}) },
70
- imgx: { ...config.imgx, ...(importedObj.imgx ?? {}) },
78
+ paths: { ...config.paths, ...(isPlainObject(importedObj.paths) ? importedObj.paths : {}) },
79
+ services: {
80
+ ...config.services,
81
+ ...(isPlainObject(importedObj.services) ? importedObj.services : {}),
82
+ },
83
+ commands: {
84
+ ...config.commands,
85
+ ...(isPlainObject(importedObj.commands) ? importedObj.commands : {}),
86
+ },
87
+ wx: {
88
+ ...config.wx,
89
+ ...importedWx,
90
+ accounts: mergedAccounts,
91
+ },
92
+ cos: { ...config.cos, ...(isPlainObject(importedObj.cos) ? importedObj.cos : {}) },
93
+ plugins: {
94
+ ...config.plugins,
95
+ ...(isPlainObject(importedObj.plugins) ? importedObj.plugins : {}),
96
+ },
97
+ imgx: { ...config.imgx, ...(isPlainObject(importedObj.imgx) ? importedObj.imgx : {}) },
71
98
  };
72
- // Validate through Zod schema strips unknown fields, applies defaults
73
- const merged = PipelineConfigSchema.parse(raw);
99
+ // Soft-fill known account display names + Zod defaults / strip unknowns
100
+ const merged = normalizeConfig(raw);
74
101
 
75
102
  saveConfig(merged);
76
103
  printResult(configSummary(merged), renderConfig);
@@ -6,16 +6,12 @@ import { loadConfig, resolveWorkspacePaths, resolveWorkspaceRoot } from "../conf
6
6
  import { printResult } from "../output";
7
7
  import { resolveFullRoute } from "../routes";
8
8
  import {
9
- defaultArtifacts,
10
9
  defaultContentReview,
11
- defaultImages,
12
- defaultMetadata,
13
- defaultPhase,
14
- defaultPublish,
15
10
  defaultState,
16
11
  generateRunId,
17
12
  getRunStatePath,
18
13
  readResolvedState,
14
+ resetDerivedState,
19
15
  writeState,
20
16
  type ContentForm,
21
17
  type HandoffAuthoringPolicy,
@@ -302,22 +298,6 @@ function routesEqual(a: WorkflowState["route"], b: WorkflowState["route"]): bool
302
298
  );
303
299
  }
304
300
 
305
- function resetDerivedState(state: WorkflowState): void {
306
- state.mode = "active";
307
- state.phase = defaultPhase();
308
- state.asset_path = "";
309
- state.formatted_body_path = null;
310
- state.metadata = {
311
- ...defaultMetadata(),
312
- title: state.metadata.title,
313
- };
314
- state.artifacts = defaultArtifacts();
315
- state.images = defaultImages();
316
- state.publish = defaultPublish();
317
- state.content_review = defaultContentReview();
318
- state.redo_hint = null;
319
- }
320
-
321
301
  async function resolveExistingStatePath(
322
302
  workspaceRoot: string,
323
303
  handoff: ResolvedWorkflowHandoff,
@@ -31,6 +31,7 @@ import {
31
31
  writeState,
32
32
  getCanonicalStatePath,
33
33
  } from "../state";
34
+ import { loadTaskState } from "../task-manager";
34
35
  import {
35
36
  extractHighlightWords,
36
37
  buildFrontmatter,
@@ -195,7 +196,7 @@ Options:
195
196
  const initialResolved = await readResolvedState(requestedStatePath);
196
197
  const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
197
198
  try {
198
- const resolved = await readResolvedState(initialResolved.path);
199
+ const resolved = await loadTaskState(initialResolved.path);
199
200
  const statePath = resolved.path;
200
201
  const state = resolved.state;
201
202
  if (state.content_review.status !== "passed") {
@@ -31,10 +31,10 @@ import { printResult, renderPrepare } from "../output";
31
31
  import { resolveWorkspacePaths } from "../config";
32
32
  import {
33
33
  defaultBodyInputs,
34
- readResolvedState,
35
34
  reenterPrepare,
36
35
  writeState,
37
36
  } from "../state";
37
+ import { loadTaskState } from "../task-manager";
38
38
  import { resolveFullRoute } from "../routes";
39
39
  import { resolveAuthoring, hasStyleRequest } from "../profiles";
40
40
  import { parseAccountName, parseRoutePrimary } from "../publish-targets";
@@ -131,7 +131,7 @@ Options:
131
131
  : undefined;
132
132
 
133
133
  // Read state and body
134
- const resolved = await readResolvedState(requestedStatePath);
134
+ const resolved = await loadTaskState(requestedStatePath);
135
135
  const statePath = resolved.path;
136
136
  const state = resolved.state;
137
137
  const intentTextOverride = optionalArg(parsed, "intent-text");
@@ -144,9 +144,18 @@ Options:
144
144
  }
145
145
  const bodyRaw = await readFile(resolvedBodyPath, "utf-8");
146
146
  const cleanBody = stripFrontmatter(bodyRaw);
147
+ const incomingBody = Boolean(bodyPath);
148
+ // A newly supplied body satisfies a pending writer/style revision. Clearing
149
+ // these hints prevents status from looping back to revise-content after the
150
+ // body has already been handed in via prepare --body. (format/asset-meta/
151
+ // channel-route hints are cleared below after prepare runs their steps.)
152
+ const effectiveRedoHint = incomingBody &&
153
+ (state.redo_hint === "writer" || state.redo_hint === "style")
154
+ ? null
155
+ : state.redo_hint;
147
156
  reenterPrepare(state, {
148
- redoHint: state.redo_hint,
149
- resetReview: Boolean(bodyPath),
157
+ redoHint: effectiveRedoHint,
158
+ resetReview: incomingBody,
150
159
  });
151
160
  if (bodyPath && state.handoff.review_policy === "trust_user") {
152
161
  state.content_review = { status: "passed", feedback: null };
@@ -180,16 +189,15 @@ Options:
180
189
  state.route.highlight_words = highlightWordsOverride;
181
190
  }
182
191
 
183
- // Re-derive requires.render and requires.publish based on resolved route.
184
- // requires.research is intentional (set at init from user request), not touched here.
185
- // Same for render/publish: if the orchestrator set them at init, keep that value.
186
- // wechat-article always needs render regardless.
187
- state.intent.requires.render =
188
- state.intent.requires.render ||
189
- route.primary === "wechat-article";
190
- state.intent.requires.publish =
191
- state.intent.requires.publish ||
192
- state.intent.task_kind === "publish";
192
+ // Re-derive requires.render and requires.publish deterministically from the
193
+ // resolved route/task kind rather than OR-accumulating: a flag set true for a
194
+ // previous route must not linger after the route changes (e.g. switching a
195
+ // task to blog-only would otherwise keep requires.render=true forever).
196
+ // requires.research is intentional (set at init from the user request) and is
197
+ // intentionally not recomputed here.
198
+ const allRoutes = [route.primary, ...route.extras];
199
+ state.intent.requires.render = allRoutes.some((r) => r !== "blog");
200
+ state.intent.requires.publish = state.intent.task_kind === "publish";
193
201
 
194
202
  // ── Step 2: Author select ──
195
203
  const styleRequest =
@@ -33,7 +33,7 @@ import {
33
33
  executePublishTargets,
34
34
  upsertPublishResult,
35
35
  } from "../providers/publish-core";
36
- import { reconcileStateArtifacts } from "../workflow-materials";
36
+ import { loadTaskState } from "../task-manager";
37
37
 
38
38
  export async function publish(args: string[]): Promise<void> {
39
39
  const parsed = parseArgs(args);
@@ -60,10 +60,9 @@ Options:
60
60
  const initialResolved = await readResolvedState(requestedStatePath);
61
61
  const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
62
62
  try {
63
- const resolved = await readResolvedState(initialResolved.path);
63
+ const resolved = await loadTaskState(initialResolved.path);
64
64
  const statePath = resolved.path;
65
65
  const state = resolved.state;
66
- await reconcileStateArtifacts(state);
67
66
  const config = loadConfig();
68
67
  const workspacePaths = resolveWorkspacePaths(state.workspace_root, config);
69
68
 
@@ -1,8 +1,7 @@
1
1
  import { parseArgs, requireArg } from "../args";
2
2
  import { printResult, renderTaskShape } from "../output";
3
- import { readResolvedState, writeState } from "../state";
4
- import { getTaskByStatePath } from "../task-manager";
5
- import { reconcileStateArtifacts } from "../workflow-materials";
3
+ import { writeState } from "../state";
4
+ import { getTaskByStatePath, loadTaskState } from "../task-manager";
6
5
 
7
6
  export async function reconcile(args: string[]): Promise<void> {
8
7
  const parsed = parseArgs(args);
@@ -18,10 +17,9 @@ Options:
18
17
  }
19
18
 
20
19
  const requestedStatePath = requireArg(parsed, "state", "state JSON path");
21
- const resolved = await readResolvedState(requestedStatePath);
20
+ const resolved = await loadTaskState(requestedStatePath);
22
21
  const statePath = resolved.path;
23
22
  const state = resolved.state;
24
- await reconcileStateArtifacts(state);
25
23
  await writeState(statePath, state);
26
24
 
27
25
  const task = await getTaskByStatePath(statePath);
@@ -31,6 +31,7 @@ import {
31
31
  type NewspicRenderSpec,
32
32
  type WorkflowState,
33
33
  } from "../state";
34
+ import { loadTaskState } from "../task-manager";
34
35
  import {
35
36
  stripFrontmatter,
36
37
  generateCoverTitle,
@@ -66,7 +67,7 @@ Options:
66
67
  const initialResolved = await readResolvedState(requestedStatePath);
67
68
  const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
68
69
  try {
69
- const resolved = await readResolvedState(initialResolved.path);
70
+ const resolved = await loadTaskState(initialResolved.path);
70
71
  const statePath = resolved.path;
71
72
  const state = resolved.state;
72
73
 
@@ -35,7 +35,7 @@ import {
35
35
  executePublishTargets,
36
36
  upsertPublishResult,
37
37
  } from "../providers/publish-core";
38
- import { reconcileStateArtifacts } from "../workflow-materials";
38
+ import { loadTaskState } from "../task-manager";
39
39
 
40
40
  export async function republish(args: string[]): Promise<void> {
41
41
  const parsed = parseArgs(args);
@@ -75,10 +75,9 @@ Examples:
75
75
  const initialResolved = await readResolvedState(requestedStatePath);
76
76
  const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
77
77
  try {
78
- const resolved = await readResolvedState(initialResolved.path);
78
+ const resolved = await loadTaskState(initialResolved.path);
79
79
  const statePath = resolved.path;
80
80
  const state = resolved.state;
81
- await reconcileStateArtifacts(state);
82
81
  const config = loadConfig();
83
82
  const workspacePaths = resolveWorkspacePaths(state.workspace_root, config);
84
83
 
@@ -22,6 +22,7 @@
22
22
  import { parseArgs, requireArg } from "../args";
23
23
  import { printResult, renderReset } from "../output";
24
24
  import {
25
+ acquireStateOperationLock,
25
26
  defaultContentReview,
26
27
  readResolvedState,
27
28
  reenterPrepare,
@@ -29,6 +30,7 @@ import {
29
30
  reenterRender,
30
31
  writeState,
31
32
  } from "../state";
33
+ import { loadTaskState } from "../task-manager";
32
34
 
33
35
  type ResetMode =
34
36
  | "content"
@@ -84,7 +86,10 @@ Modes:
84
86
  );
85
87
  }
86
88
 
87
- const resolved = await readResolvedState(requestedStatePath);
89
+ const initialResolved = await readResolvedState(requestedStatePath);
90
+ const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
91
+ try {
92
+ const resolved = await loadTaskState(initialResolved.path);
88
93
  const statePath = resolved.path;
89
94
  const state = resolved.state;
90
95
 
@@ -149,4 +154,7 @@ Modes:
149
154
  };
150
155
 
151
156
  printResult(output, renderReset);
157
+ } finally {
158
+ await releaseOperationLock();
159
+ }
152
160
  }
@@ -17,7 +17,8 @@
17
17
 
18
18
  import { parseArgs, requireArg, optionalArg } from "../args";
19
19
  import { printResult, renderReview } from "../output";
20
- import { readResolvedState, writeState, type ContentReviewStatus } from "../state";
20
+ import { writeState, type ContentReviewStatus } from "../state";
21
+ import { loadTaskState } from "../task-manager";
21
22
 
22
23
  const VALID_STATUSES: ContentReviewStatus[] = [
23
24
  "passed",
@@ -59,7 +60,7 @@ Statuses:
59
60
  );
60
61
  }
61
62
 
62
- const resolved = await readResolvedState(requestedStatePath);
63
+ const resolved = await loadTaskState(requestedStatePath);
63
64
  const statePath = resolved.path;
64
65
  const state = resolved.state;
65
66
 
@@ -6,6 +6,7 @@ import {
6
6
  readResolvedState,
7
7
  updateState,
8
8
  } from "../state";
9
+ import { loadTaskState } from "../task-manager";
9
10
  import { publishBlogRoute } from "../providers/blog";
10
11
 
11
12
  export async function syncBlog(args: string[]): Promise<void> {
@@ -31,7 +32,7 @@ Options:
31
32
  const initialResolved = await readResolvedState(requestedStatePath);
32
33
  const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
33
34
  try {
34
- const resolved = await readResolvedState(initialResolved.path);
35
+ const resolved = await loadTaskState(initialResolved.path);
35
36
  const statePath = resolved.path;
36
37
  const state = resolved.state;
37
38