@zzclub/pipeline 0.9.0 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzclub/pipeline",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zzp": "./src/cli.ts",
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import type { PipelineConfig } from "./schema/config";
10
+ import { isAbsolute, resolve } from "path";
11
+ import { pathToFileURL } from "url";
10
12
  import type {
11
13
  ImageRenderPlugin,
12
14
  MarkdownRenderPlugin,
@@ -49,7 +51,10 @@ function validateMarkdownRenderPlugin(plugin: unknown, specifier: string): Markd
49
51
 
50
52
  async function importPlugin(specifier: string): Promise<unknown> {
51
53
  try {
52
- const mod = await import(specifier);
54
+ const importSpecifier = specifier.startsWith(".") || isAbsolute(specifier)
55
+ ? pathToFileURL(resolve(specifier)).href
56
+ : specifier;
57
+ const mod = await import(importSpecifier);
53
58
  // Support both default exports and named `plugin` exports
54
59
  return mod.default ?? mod.plugin ?? mod;
55
60
  } catch (err) {
@@ -123,11 +123,12 @@ describe("analytics commands", () => {
123
123
 
124
124
  test("filters by days", async () => {
125
125
  const statePath = join(workspace, "state.json");
126
+ const today = new Date().toISOString().slice(0, 10);
126
127
  await writeFile(statePath, JSON.stringify({
127
128
  run_id: "run_001",
128
- metadata: { title: "Article", date: "2026-06-11" },
129
+ metadata: { title: "Article", date: today },
129
130
  route: { primary: "wechat-article" },
130
- publish: { results: { wechat: { status: "success", published_at: "2026-06-11T10:00:00Z" } } },
131
+ publish: { results: { wechat: { status: "success", published_at: `${today}T10:00:00Z` } } },
131
132
  }));
132
133
 
133
134
  await recordAnalytics({ statePath, reads: 1000 });
@@ -1,5 +1,5 @@
1
1
  import { readFile } from "fs/promises";
2
- import { dirname } from "path";
2
+ import { dirname, isAbsolute, resolve } from "path";
3
3
  import { ensureDb } from "../db";
4
4
  import { AnalyticsSchema, type Analytics } from "../schema/analytics";
5
5
  import { parseArgs, requireArg, optionalArg } from "../args";
@@ -26,10 +26,27 @@ function resolveWorkspace(statePath: string): string {
26
26
  export async function recordAnalytics(
27
27
  input: RecordAnalyticsInput,
28
28
  ): Promise<Analytics> {
29
- const stateContent = await readFile(input.statePath, "utf-8");
30
- const state = JSON.parse(stateContent);
29
+ const requestedPath = resolve(input.statePath);
30
+ let statePath = requestedPath;
31
+ let state = JSON.parse(await readFile(statePath, "utf-8"));
32
+ if (typeof state.state_path === "string" && state.state_path.trim()) {
33
+ const candidatePath = isAbsolute(state.state_path)
34
+ ? resolve(state.state_path)
35
+ : resolve(dirname(statePath), state.state_path);
36
+ if (candidatePath !== statePath) {
37
+ try {
38
+ state = JSON.parse(await readFile(candidatePath, "utf-8"));
39
+ statePath = candidatePath;
40
+ } catch (error) {
41
+ const code = error instanceof Error && "code" in error
42
+ ? String((error as NodeJS.ErrnoException).code)
43
+ : "";
44
+ if (code !== "ENOENT") throw error;
45
+ }
46
+ }
47
+ }
31
48
 
32
- const workspace = resolveWorkspace(input.statePath);
49
+ const workspace = state.workspace_root || resolveWorkspace(statePath);
33
50
  const db = ensureDb(workspace);
34
51
 
35
52
  try {
@@ -2,7 +2,13 @@ import { readFile } from "fs/promises";
2
2
 
3
3
  import { parseArgs, optionalArg, requireArg } from "../args";
4
4
  import { printResult, renderTaskShape } from "../output";
5
- import { readState, writeState, type BodyInputReceived } from "../state";
5
+ import {
6
+ readResolvedState,
7
+ reenterPublish,
8
+ reenterRender,
9
+ writeState,
10
+ type BodyInputReceived,
11
+ } from "../state";
6
12
  import { getTaskByStatePath } from "../task-manager";
7
13
  import { reconcileStateArtifacts } from "../workflow-materials";
8
14
 
@@ -52,12 +58,15 @@ Options:
52
58
  return;
53
59
  }
54
60
 
55
- const statePath = requireArg(parsed, "state", "state JSON path");
61
+ const requestedStatePath = requireArg(parsed, "state", "state JSON path");
56
62
  const imagesFile = requireArg(parsed, "images-file", "JSON file with marker/path data");
57
63
  const scope = optionalArg(parsed, "scope");
58
64
  const layout = optionalArg(parsed, "layout");
59
65
 
60
- const state = await readState(statePath);
66
+ const resolved = await readResolvedState(requestedStatePath);
67
+ const statePath = resolved.path;
68
+ const state = resolved.state;
69
+ const prepareWasDone = state.phase.prepare.status === "done";
61
70
  const raw = JSON.parse(await readFile(imagesFile, "utf-8")) as unknown;
62
71
  const incoming = parseReceivedImages(raw);
63
72
  const merged = new Map(state.images.body_inputs.received.map((item) => [item.marker, item.path]));
@@ -74,6 +83,17 @@ Options:
74
83
  }
75
84
 
76
85
  await reconcileStateArtifacts(state);
86
+ if (prepareWasDone && state.images.body_inputs.scope === "newspic-longform") {
87
+ reenterRender(state);
88
+ } else if (
89
+ prepareWasDone &&
90
+ state.images.body_inputs.scope === "article" &&
91
+ (state.phase.current === "publish" ||
92
+ state.phase.current === "done" ||
93
+ state.phase.publish.status === "done")
94
+ ) {
95
+ reenterPublish(state);
96
+ }
77
97
  await writeState(statePath, state);
78
98
 
79
99
  const task = await getTaskByStatePath(statePath);
@@ -4,7 +4,7 @@ import { extname, join, resolve } from "path";
4
4
  import { parseArgs, optionalArg, requireArg } from "../args";
5
5
  import { printResult, renderTaskShape } from "../output";
6
6
  import { resolveWorkspacePaths } from "../config";
7
- import { readState, writeState } from "../state";
7
+ import { readResolvedState, reenterPrepare, writeState } from "../state";
8
8
  import { getTaskByStatePath } from "../task-manager";
9
9
  import { reconcileStateArtifacts } from "../workflow-materials";
10
10
 
@@ -48,7 +48,7 @@ Options:
48
48
  return;
49
49
  }
50
50
 
51
- const statePath = requireArg(parsed, "state", "state JSON path");
51
+ const requestedStatePath = requireArg(parsed, "state", "state JSON path");
52
52
  const bodyPath = optionalArg(parsed, "body");
53
53
  const bodyText = optionalArg(parsed, "body-text");
54
54
  if (!bodyPath && bodyText === undefined) {
@@ -58,13 +58,22 @@ Options:
58
58
  throw new Error("Use either --body or --body-text, not both");
59
59
  }
60
60
 
61
- const state = await readState(statePath);
61
+ const resolved = await readResolvedState(requestedStatePath);
62
+ const statePath = resolved.path;
63
+ const state = resolved.state;
62
64
  state.source_body_path = await stageManagedBodyFile(
63
65
  state.workspace_root,
64
66
  state.run_id,
65
67
  bodyPath ?? null,
66
68
  bodyText ?? null,
67
69
  );
70
+ reenterPrepare(state, {
71
+ clearFormattedBody: true,
72
+ resetReview: true,
73
+ });
74
+ if (state.handoff.review_policy === "trust_user") {
75
+ state.content_review = { status: "passed", feedback: null };
76
+ }
68
77
  await reconcileStateArtifacts(state);
69
78
  await writeState(statePath, state);
70
79
 
@@ -2,7 +2,12 @@ import { readFile } from "fs/promises";
2
2
 
3
3
  import { parseArgs, requireArg } from "../args";
4
4
  import { printResult, renderTaskShape } from "../output";
5
- import { normalizeNewspicRenderSpec, readState, writeState } from "../state";
5
+ import {
6
+ normalizeNewspicRenderSpec,
7
+ readResolvedState,
8
+ reenterRender,
9
+ writeState,
10
+ } from "../state";
6
11
  import { getTaskByStatePath } from "../task-manager";
7
12
  import { reconcileStateArtifacts } from "../workflow-materials";
8
13
 
@@ -20,13 +25,19 @@ Options:
20
25
  return;
21
26
  }
22
27
 
23
- const statePath = requireArg(parsed, "state", "state JSON path");
28
+ const requestedStatePath = requireArg(parsed, "state", "state JSON path");
24
29
  const specPath = requireArg(parsed, "file", "newspic render spec file");
25
30
 
26
- const state = await readState(statePath);
31
+ const resolved = await readResolvedState(requestedStatePath);
32
+ const statePath = resolved.path;
33
+ const state = resolved.state;
34
+ const prepareWasDone = state.phase.prepare.status === "done";
27
35
  const spec = JSON.parse(await readFile(specPath, "utf-8")) as unknown;
28
36
  state.intent.newspic_render = normalizeNewspicRenderSpec(spec);
29
37
  await reconcileStateArtifacts(state);
38
+ if (prepareWasDone) {
39
+ reenterRender(state);
40
+ }
30
41
  await writeState(statePath, state);
31
42
 
32
43
  const task = await getTaskByStatePath(statePath);
@@ -67,6 +67,7 @@ Options:
67
67
  wx: mergedWx,
68
68
  cos: { ...config.cos, ...(importedObj.cos ?? {}) },
69
69
  plugins: { ...config.plugins, ...(importedObj.plugins ?? {}) },
70
+ imgx: { ...config.imgx, ...(importedObj.imgx ?? {}) },
70
71
  };
71
72
  // Validate through Zod schema — strips unknown fields, applies defaults
72
73
  const merged = PipelineConfigSchema.parse(raw);
@@ -15,7 +15,7 @@ import {
15
15
  defaultState,
16
16
  generateRunId,
17
17
  getRunStatePath,
18
- readState,
18
+ readResolvedState,
19
19
  writeState,
20
20
  type ContentForm,
21
21
  type HandoffAuthoringPolicy,
@@ -473,10 +473,16 @@ Options:
473
473
  handoff.mode === "resume"
474
474
  ? await resolveExistingStatePath(workspace, handoff)
475
475
  : getRunStatePath(workspace, generateRunId());
476
- const state =
477
- handoff.mode === "resume"
478
- ? await readState(statePath)
479
- : buildNewState(workspace, statePath, statePath.split("/").pop()?.replace(/\.json$/, "") ?? generateRunId());
476
+ const resolvedExisting = handoff.mode === "resume"
477
+ ? await readResolvedState(statePath)
478
+ : null;
479
+ const effectiveStatePath = resolvedExisting?.path ?? statePath;
480
+ const state = resolvedExisting?.state ??
481
+ buildNewState(
482
+ workspace,
483
+ effectiveStatePath,
484
+ effectiveStatePath.split("/").pop()?.replace(/\.json$/, "") ?? generateRunId(),
485
+ );
480
486
 
481
487
  if (handoff.mode === "new" && !state.run_id) {
482
488
  state.run_id = generateRunId();
@@ -484,7 +490,7 @@ Options:
484
490
  }
485
491
 
486
492
  await applyHandoffToState(state, handoff);
487
- await writeState(state.state_path, state);
493
+ await writeState(state.state_path || effectiveStatePath, state);
488
494
 
489
495
  const task = await getTaskByStatePath(state.state_path);
490
496
  printResult({
@@ -28,17 +28,19 @@ import { parseArgs, requireArg, optionalArg, flagArg } from "../args";
28
28
  import { printResult, renderInit } from "../output";
29
29
  import { loadConfig, resolveWorkspaceRoot } from "../config";
30
30
  import { resolveFullRoute } from "../routes";
31
+ import {
32
+ parseAccountName,
33
+ parseContentForm,
34
+ parseContentOrigin,
35
+ parsePublishTargets,
36
+ parseTaskKind,
37
+ } from "../publish-targets";
31
38
  import {
32
39
  defaultState,
33
40
  generateRunId,
34
41
  getRunStatePath,
35
42
  normalizeNewspicRenderSpec,
36
43
  writeState,
37
- type ContentForm,
38
- type ContentOrigin,
39
- type PublishTarget,
40
- type Target,
41
- type TaskKind,
42
44
  } from "../state";
43
45
 
44
46
  export async function init(args: string[]): Promise<void> {
@@ -67,10 +69,10 @@ Options:
67
69
  return;
68
70
  }
69
71
 
70
- const taskKind = requireArg(parsed, "task-kind", "task kind") as TaskKind;
71
- const contentForm = requireArg(parsed, "content-form", "content form") as ContentForm;
72
+ const taskKind = parseTaskKind(requireArg(parsed, "task-kind", "task kind"));
73
+ const contentForm = parseContentForm(requireArg(parsed, "content-form", "content form"));
72
74
  const targetsRaw = requireArg(parsed, "targets", "publish targets");
73
- const contentOrigin = requireArg(parsed, "content-origin", "content origin") as ContentOrigin;
75
+ const contentOrigin = parseContentOrigin(requireArg(parsed, "content-origin", "content origin"));
74
76
  const intentText = optionalArg(parsed, "intent-text") ?? "";
75
77
  const accountOverride = optionalArg(parsed, "account");
76
78
  const styleHint = optionalArg(parsed, "style-hint") ?? null;
@@ -80,31 +82,13 @@ Options:
80
82
  const config = loadConfig();
81
83
  const workspace = resolveWorkspaceRoot(optionalArg(parsed, "workspace"), config);
82
84
 
83
- const targets = targetsRaw.split(",").map((t) => t.trim()) as Target[];
84
-
85
- // Parse multi-target format: "route@account,route@account,..."
86
- // Also supports RoutePrimary values (wechat-article, wechat-newspic, blog)
87
- // which need to be mapped to Target enum (wechat, blog) for intent.targets.
88
- const publishTargets: PublishTarget[] = [];
89
- const targetParts = targetsRaw.split(",").map((t) => t.trim());
90
- const defaultAccount = accountOverride || "default";
91
- const mappedTargets: Target[] = [];
92
-
93
- for (const part of targetParts) {
94
- const atIdx = part.indexOf("@");
95
- const route = atIdx !== -1 ? part.slice(0, atIdx).trim() : part;
96
- const account = atIdx !== -1 ? part.slice(atIdx + 1).trim() : defaultAccount;
97
- publishTargets.push({ route: route as any, account });
98
- // Map RoutePrimary to Target enum: wechat-* → "wechat", blog → "blog"
99
- mappedTargets.push(route.startsWith("wechat") ? "wechat" : route as Target);
100
- }
101
-
102
- // Single target: leave publish_targets empty (backward compat)
103
- // Multi-target: populate publish_targets
104
- const isMultiTarget = publishTargets.length > 1 || targetsRaw.includes("@");
105
-
106
- // Use mapped targets (Target enum) for intent, not raw RoutePrimary values
107
- const intentTargets = mappedTargets.length > 0 ? mappedTargets : targets;
85
+ const defaultAccount = parseAccountName(accountOverride ?? "default");
86
+ const parsedTargets = parsePublishTargets(targetsRaw, {
87
+ contentForm,
88
+ defaultAccount,
89
+ });
90
+ const publishTargets = parsedTargets.targets;
91
+ const intentTargets = parsedTargets.intentTargets;
108
92
 
109
93
  const runId = generateRunId();
110
94
  const statePath = getRunStatePath(workspace, runId);
@@ -121,16 +105,16 @@ Options:
121
105
  state.state_path = statePath;
122
106
  state.mode = "active";
123
107
  state.route = resolveFullRoute(intentText, {
124
- account: accountOverride,
108
+ account: accountOverride ? defaultAccount : undefined,
125
109
  contentForm,
126
110
  targets: intentTargets,
127
111
  });
128
112
 
129
113
  // Set publish_targets if multi-target
130
- if (isMultiTarget && publishTargets.length > 0) {
114
+ if (parsedTargets.explicit && publishTargets.length > 0) {
131
115
  state.publish_targets = publishTargets;
132
116
  // First target becomes primary route
133
- state.route.primary = publishTargets[0].route as any;
117
+ state.route.primary = publishTargets[0].route;
134
118
  state.route.account = publishTargets[0].account;
135
119
  }
136
120
 
@@ -15,7 +15,7 @@
15
15
  * Output: asset_path, canonical state_path
16
16
  */
17
17
 
18
- import { access, copyFile, readFile, writeFile, mkdir } from "fs/promises";
18
+ import { copyFile, readFile, writeFile, mkdir, rename, rm } from "fs/promises";
19
19
  import { basename, dirname, isAbsolute, join, resolve } from "path";
20
20
  import { parseArgs, requireArg, optionalArg } from "../args";
21
21
  import { printResult, renderPrepareFinalize } from "../output";
@@ -26,7 +26,8 @@ import {
26
26
  resolveWorkspaceRoot,
27
27
  } from "../config";
28
28
  import {
29
- readState,
29
+ acquireStateOperationLock,
30
+ readResolvedState,
30
31
  writeState,
31
32
  getCanonicalStatePath,
32
33
  } from "../state";
@@ -38,15 +39,6 @@ import {
38
39
  removePageMarkers,
39
40
  } from "../text";
40
41
 
41
- async function pathExists(path: string): Promise<boolean> {
42
- try {
43
- await access(path);
44
- return true;
45
- } catch {
46
- return false;
47
- }
48
- }
49
-
50
42
  function isExternalAssetRef(value: string): boolean {
51
43
  return /^(https?:|data:|blob:|\/\/)/i.test(value);
52
44
  }
@@ -140,13 +132,16 @@ async function materializeInlineAssets(options: {
140
132
  };
141
133
 
142
134
  let rewritten = options.body.replace(
143
- /!\[([^\]]*)\]\((<)?([^)]+?)(>)?\)/g,
144
- (match, alt, open, src) => {
145
- const targetRelativePath = registerAsset(String(src ?? ""));
135
+ /!\[([^\]]*)\]\((?:<([^>]+)>|([^\s)]+))(\s+(?:"[^"]*"|'[^']*'))?\)/g,
136
+ (match, alt, angleSrc, bareSrc, titleSuffix) => {
137
+ const targetRelativePath = registerAsset(String(angleSrc ?? bareSrc ?? ""));
146
138
  if (!targetRelativePath) {
147
139
  return match;
148
140
  }
149
- return `![${alt}](${open ? "<" : ""}${formatRelativeAssetRef(targetRelativePath)}${open ? ">" : ""})`;
141
+ const rewrittenSource = angleSrc
142
+ ? `<${formatRelativeAssetRef(targetRelativePath)}>`
143
+ : formatRelativeAssetRef(targetRelativePath);
144
+ return `![${alt}](${rewrittenSource}${titleSuffix ?? ""})`;
150
145
  },
151
146
  );
152
147
 
@@ -193,11 +188,16 @@ Options:
193
188
  return;
194
189
  }
195
190
 
196
- const statePath = requireArg(parsed, "state", "state JSON path");
191
+ const requestedStatePath = requireArg(parsed, "state", "state JSON path");
197
192
  const explicitBodyPath = optionalArg(parsed, "body");
198
193
  const workspaceOverride = optionalArg(parsed, "workspace");
199
194
 
200
- const state = await readState(statePath);
195
+ const initialResolved = await readResolvedState(requestedStatePath);
196
+ const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
197
+ try {
198
+ const resolved = await readResolvedState(initialResolved.path);
199
+ const statePath = resolved.path;
200
+ const state = resolved.state;
201
201
  if (state.content_review.status !== "passed") {
202
202
  throw new Error(
203
203
  `content_review must be passed before prepare-finalize (current: ${state.content_review.status})`,
@@ -219,6 +219,18 @@ Options:
219
219
  const config = loadConfig();
220
220
  const workspace = resolveWorkspaceRoot(workspaceOverride ?? state.workspace_root, config);
221
221
  const workspacePaths = resolveWorkspacePaths(workspace, config);
222
+ state.workspace_root = workspace;
223
+
224
+ const missingMetadata = [
225
+ ["metadata.title", state.metadata.title],
226
+ ["metadata.slug", state.metadata.slug],
227
+ ["metadata.date", state.metadata.date],
228
+ ].filter(([, value]) => !value);
229
+ if (missingMetadata.length > 0) {
230
+ throw new Error(
231
+ `prepare-finalize requires complete metadata: ${missingMetadata.map(([field]) => field).join(", ")}`,
232
+ );
233
+ }
222
234
 
223
235
  // ── Step 1: Channel route 2nd pass — highlight_words ──
224
236
  // If highlight_words were already set explicitly via `prepare --highlight-words`,
@@ -240,45 +252,52 @@ Options:
240
252
  const baseTitle = state.metadata.title;
241
253
  let nextSlug = baseSlug;
242
254
  let nextTitle = baseTitle;
243
- let relativePath = renderPostsRelativePath(config, {
255
+ const baseRelativePath = renderPostsRelativePath(config, {
244
256
  date: state.metadata.date,
245
257
  slug: nextSlug,
246
258
  title: nextTitle,
247
259
  });
248
- let candidatePath = join(workspacePaths.postsRoot, relativePath);
249
- let suffix = 2;
260
+ let suffix = 1;
250
261
 
251
- while (await pathExists(candidatePath)) {
252
- if (patternUsesSlug) {
262
+ while (true) {
263
+ if (suffix > 1 && patternUsesSlug) {
253
264
  nextSlug = `${baseSlug}-v${suffix}`;
254
- relativePath = renderPostsRelativePath(config, {
255
- date: state.metadata.date,
256
- slug: nextSlug,
257
- title: baseTitle,
258
- });
259
- } else if (patternUsesTitle) {
265
+ } else if (suffix > 1 && patternUsesTitle) {
260
266
  nextTitle = `${baseTitle}-v${suffix}`;
261
- relativePath = renderPostsRelativePath(config, {
262
- date: state.metadata.date,
263
- slug: baseSlug,
264
- title: nextTitle,
265
- });
266
- } else {
267
- relativePath = appendSuffixToRelativePath(relativePath, suffix);
268
267
  }
269
- candidatePath = join(workspacePaths.postsRoot, relativePath);
270
- suffix += 1;
268
+ const relativePath = suffix === 1
269
+ ? baseRelativePath
270
+ : patternUsesSlug || patternUsesTitle
271
+ ? renderPostsRelativePath(config, {
272
+ date: state.metadata.date,
273
+ slug: nextSlug,
274
+ title: nextTitle,
275
+ })
276
+ : appendSuffixToRelativePath(baseRelativePath, suffix);
277
+ const candidatePath = join(workspacePaths.postsRoot, relativePath);
278
+ await mkdir(dirname(candidatePath), { recursive: true });
279
+ try {
280
+ await mkdir(candidatePath);
281
+ assetPath = candidatePath;
282
+ break;
283
+ } catch (error) {
284
+ const code = error instanceof Error && "code" in error
285
+ ? String((error as NodeJS.ErrnoException).code)
286
+ : "";
287
+ if (code !== "EEXIST") {
288
+ throw error;
289
+ }
290
+ suffix += 1;
291
+ }
271
292
  }
272
293
 
273
294
  if (patternUsesSlug && nextSlug !== state.metadata.slug) {
274
295
  state.metadata.slug = nextSlug;
275
296
  }
276
- assetPath = candidatePath;
297
+ } else {
298
+ await mkdir(assetPath, { recursive: true });
277
299
  }
278
300
 
279
- // Create directories
280
- await mkdir(assetPath, { recursive: true });
281
-
282
301
  const bodySourceDir = dirname(state.source_body_path ?? bodyPath);
283
302
  body = await materializeInlineAssets({
284
303
  body,
@@ -312,7 +331,14 @@ Options:
312
331
  // Write post.md
313
332
  const postContent = `${frontmatter}\n\n${removePageMarkers(body)}`;
314
333
  const postPath = join(assetPath, "post.md");
315
- await writeFile(postPath, postContent, "utf-8");
334
+ const postTempPath = `${postPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
335
+ try {
336
+ await writeFile(postTempPath, postContent, "utf-8");
337
+ await rename(postTempPath, postPath);
338
+ } catch (error) {
339
+ await rm(postTempPath, { force: true }).catch(() => undefined);
340
+ throw error;
341
+ }
316
342
 
317
343
  // Update state
318
344
  state.asset_path = assetPath;
@@ -322,6 +348,7 @@ Options:
322
348
  state.phase.prepare = { status: "done", error: null };
323
349
  state.phase.current = state.intent.requires.render ? "render" :
324
350
  state.intent.requires.publish ? "publish" : "done";
351
+ state.mode = state.phase.current === "done" ? "done" : "active";
325
352
 
326
353
  // Clear redo_hint — prepare sub-sequence is complete
327
354
  state.redo_hint = null;
@@ -333,7 +360,9 @@ Options:
333
360
  await writeState(state.state_path, state);
334
361
 
335
362
  // Also update the temp run state to point to canonical
336
- await writeState(statePath, state);
363
+ if (resolve(statePath) !== resolve(state.state_path)) {
364
+ await writeState(statePath, state);
365
+ }
337
366
 
338
367
  // Output summary
339
368
  const output = {
@@ -345,4 +374,7 @@ Options:
345
374
  content_version: state.artifacts.content_version,
346
375
  };
347
376
  printResult(output, renderPrepareFinalize);
377
+ } finally {
378
+ await releaseOperationLock();
379
+ }
348
380
  }