@gobing-ai/knowledge-kit 0.1.9 → 0.1.11

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 (72) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +132 -25
  3. package/package.json +1 -1
  4. package/plugins/generations/content-gen/dist/index.js +4 -0
  5. package/plugins/generations/core-facts-gen/dist/index.js +4 -0
  6. package/plugins/generations/daily-article-gen/dist/index.js +4 -0
  7. package/plugins/generations/dailynews-gen/dist/index.js +4 -0
  8. package/plugins/generations/episode-plan-gen/dist/index.js +102 -1
  9. package/plugins/generations/episode-plan-gen/src/index.ts +2 -4
  10. package/plugins/generations/gemini-tts/dist/index.js +4 -0
  11. package/plugins/generations/image-gen/dist/index.js +4 -0
  12. package/plugins/generations/news-report-gen/dist/index.js +4 -0
  13. package/plugins/generations/voice-gen/dist/index.js +4 -0
  14. package/plugins/generations/voxcpm-tts/dist/index.js +4 -0
  15. package/plugins/ingestions/aihot-ingest/dist/index.js +4 -0
  16. package/plugins/ingestions/horizon-ingest/dist/index.js +4 -0
  17. package/plugins/ingestions/last30days-ingest/dist/index.js +11 -3
  18. package/plugins/ingestions/last30days-ingest/src/index.ts +14 -3
  19. package/plugins/ingestions/web-search/dist/index.js +4 -0
  20. package/plugins/kk/plugin.json +1 -1
  21. package/plugins/kk/scripts/daily-scheduled-run.ts +298 -0
  22. package/plugins/kk/scripts/daily-stages.ts +1083 -49
  23. package/plugins/kk/scripts/kk-workflow-stages.ts +76 -10
  24. package/plugins/kk/scripts/safety-artifact-check.ts +235 -0
  25. package/plugins/kk/scripts/sentences.ts +134 -0
  26. package/plugins/kk/scripts/series-names.ts +124 -0
  27. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +194 -113
  28. package/plugins/kk/workflows/kk-daily-safety.yaml +160 -36
  29. package/plugins/publishings/bilibili-pub/dist/index.js +6 -1
  30. package/plugins/publishings/bilibili-pub/dist/profile-check.js +2 -1
  31. package/plugins/publishings/cnblogs-pub/dist/index.js +6 -1
  32. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +2 -1
  33. package/plugins/publishings/csdn-pub/dist/index.js +6 -1
  34. package/plugins/publishings/csdn-pub/dist/profile-check.js +6 -1
  35. package/plugins/publishings/emdash-pub/dist/index.js +4 -0
  36. package/plugins/publishings/infoq-pub/dist/index.js +6 -1
  37. package/plugins/publishings/infoq-pub/dist/profile-check.js +6 -1
  38. package/plugins/publishings/jike-pub/dist/index.js +6 -1
  39. package/plugins/publishings/jike-pub/dist/profile-check.js +6 -1
  40. package/plugins/publishings/juejin-pub/dist/index.js +6 -1
  41. package/plugins/publishings/juejin-pub/dist/profile-check.js +6 -1
  42. package/plugins/publishings/medium-pub/dist/index.js +4 -0
  43. package/plugins/publishings/podcast-pub/dist/index.js +151 -16
  44. package/plugins/publishings/podcast-pub/src/index.ts +62 -5
  45. package/plugins/publishings/podcast-pub/src/seo.ts +2 -4
  46. package/plugins/publishings/podcast-pub/src/show-notes.ts +3 -8
  47. package/plugins/publishings/qiita-pub/dist/index.js +4 -0
  48. package/plugins/publishings/shipinhao-pub/dist/index.js +6 -1
  49. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +2 -1
  50. package/plugins/publishings/sspai-pub/dist/index.js +6 -1
  51. package/plugins/publishings/sspai-pub/dist/profile-check.js +2 -1
  52. package/plugins/publishings/substack-pub/dist/index.js +6 -1
  53. package/plugins/publishings/substack-pub/dist/profile-check.js +6 -1
  54. package/plugins/publishings/surfdash-pub/dist/index.js +691 -63
  55. package/plugins/publishings/surfdash-pub/src/digest-check.ts +284 -0
  56. package/plugins/publishings/surfdash-pub/src/index.ts +656 -84
  57. package/plugins/publishings/surfdash-pub/src/patch.ts +44 -7
  58. package/plugins/publishings/threads-pub/dist/index.js +4 -0
  59. package/plugins/publishings/wechatmp-pub/dist/index.js +937 -98
  60. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +64 -3
  61. package/plugins/publishings/wechatmp-pub/src/flow.ts +1125 -85
  62. package/plugins/publishings/wechatmp-pub/src/index.ts +112 -30
  63. package/plugins/publishings/wechatmp-pub/src/page.ts +105 -0
  64. package/plugins/publishings/weibo-pub/dist/index.js +6 -1
  65. package/plugins/publishings/weibo-pub/dist/profile-check.js +6 -1
  66. package/plugins/publishings/x-pub/dist/index.js +6 -1
  67. package/plugins/publishings/x-pub/dist/profile-check.js +6 -1
  68. package/plugins/publishings/xhs-pub/dist/index.js +6 -1
  69. package/plugins/publishings/xhs-pub/dist/profile-check.js +6 -1
  70. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +6 -1
  71. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +2 -1
  72. package/plugins/publishings/zenn-pub/dist/index.js +4 -0
@@ -1,3 +1,28 @@
1
+ /**
2
+ * surfdash-pub plugin — publishing transport for the Surfdash site.
3
+ *
4
+ * Listen-link backfill:
5
+ * Runs `bun run sd seo listen-link --backfill --write` before the site gate and commit
6
+ * in both the main publish path (when auto-push is on) and the patch path. Any modified
7
+ * files under `content/posts` are included in the scoped commit pathspec.
8
+ *
9
+ * Site gate:
10
+ * Before committing and pushing post content to surfdash `main` (both in the main publish
11
+ * path and the patch path), `runSiteGate` executes `bun run sd check digest` and
12
+ * `bun run check:content` in the surfdash checkout. The surfdash site gate is the authoritative
13
+ * validation suite before deployment.
14
+ *
15
+ * Environment variables:
16
+ * - `SURFDASH_LISTEN_BACKFILL`:
17
+ * - `off`: skips the listen-link backfill command.
18
+ * - `SURFDASH_DIGEST_CHECK`:
19
+ * - unset / default: fail-loud on gate failure; commit and push are blocked.
20
+ * - `warn`: echoes gate failures via `echoError` prefixed with `site gate (warn):` and continues to push.
21
+ * - `off`: bypasses the gate check.
22
+ * - `SURFDASH_AUTO_PUSH`:
23
+ * - '0': disables auto-commit and push (used in tests and offline runs).
24
+ */
25
+ import { createHash } from 'node:crypto';
1
26
  import { existsSync, readFileSync } from 'node:fs';
2
27
  import { copyFile, cp, writeFile } from 'node:fs/promises';
3
28
  import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
@@ -6,18 +31,31 @@ import { type Content, ContentSchema, type Result, ResultSchema } from '@gobing-
6
31
  import type { PublishTransport, PublishTransportPayload } from '@gobing-ai/publish-harness';
7
32
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
8
33
  import { echoError } from '@gobing-ai/ts-utils';
34
+ // The plugin's public digest-contract surface (2026-10-24).
35
+ import { checkDigestFrontmatter, DIGEST_TAG, type DigestLocale } from './digest-check';
9
36
  import { localeIndexPath, PATCH_LOCALES, type PatchPostOutcome, patchPostFiles } from './patch';
10
37
 
38
+ export { checkDigestFrontmatter, DIGEST_TAG, type DigestLocale } from './digest-check';
39
+
11
40
  /** Result target name for this plugin. */
12
41
  const TARGET = 'surfdash';
13
42
 
14
43
  /** Management operations routed via `options.operation` (L1 R7; pattern from podcast-pub). */
15
- const OPERATIONS = ['publish', 'scaffold-locale', 'sync', 'patch'] as const;
44
+ const OPERATIONS = [
45
+ 'publish',
46
+ 'prepare',
47
+ 'refine-prepared',
48
+ 'publish-prepared',
49
+ 'scaffold-locale',
50
+ 'sync',
51
+ 'patch',
52
+ ] as const;
16
53
  type Operation = (typeof OPERATIONS)[number];
17
54
 
18
55
  /** Locales `scaffold-locale` can scaffold; LLM translation stays in the workflow's agent step. */
19
56
  const SCAFFOLD_LOCALES = ['en', 'ja'] as const;
20
57
  type ScaffoldLocale = (typeof SCAFFOLD_LOCALES)[number];
58
+ type SupportedLocale = 'zh' | 'en' | 'ja';
21
59
 
22
60
  /** Cover-image extensions accepted from `metadata.coverImage` (R4): anything else is rejected, not copied. */
23
61
  const COVER_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
@@ -29,6 +67,16 @@ interface SurfdashPubOptions {
29
67
  postPath?: unknown;
30
68
  /** Draft locale to scaffold; required for `scaffold-locale`. */
31
69
  target?: unknown;
70
+ /** Rebuild an existing locale draft after the zh source fingerprint changes. */
71
+ force?: unknown;
72
+ /** `publish-prepared`: YYYYMMDD run identity bound to the validated locale files. */
73
+ runDate?: unknown;
74
+ /** `refine-prepared`: ask the site-owned orchestrator to refine all three local locale files. */
75
+ // The plugin never writes `refined: true` itself; the site command owns all managed fields.
76
+ /** `publish-prepared`: SHA-256 of the prepared zh post, checked before postsurfing runs. */
77
+ expectedPostSha256?: unknown;
78
+ /** `publish-prepared`: fingerprint of the prepared zh/en/ja files, checked before postsurfing runs. */
79
+ expectedLocaleFingerprint?: unknown;
32
80
  /** `patch`: podcast-hosted cover URL for `image`/`og_image` (empty → the local cover fallback). */
33
81
  coverUrl?: unknown;
34
82
  /** `patch`: podcast episode URL appended as the cross-link (empty → none). */
@@ -76,28 +124,14 @@ export function normalizeTag(raw: string): string {
76
124
  }
77
125
 
78
126
  /**
79
- * Maps Content object into Surfing frontmatter and body per the publish spec.
127
+ * The content's tag sources (metadata.tags, keywords, subCategory) as normalized frontmatter tags.
128
+ *
129
+ * `slugify` drops non-ASCII, so a Chinese tag (`硬件发布`) collapsed to '' and shipped as an empty
130
+ * YAML tag — the site renders it as a blank chip (seen on 20261011-ai-2026-10-11). Case variants
131
+ * also collapsed to the same slug twice (`AI` + `ai`), so the set dedupes. `normalizeTag` preserves
132
+ * `sub-category:<slug>` colon prefixes so sub-category series tags survive into frontmatter.
80
133
  */
81
- export function mapContentToSurfingMarkdown(content: Content, options: MapContentOptions = {}): string {
82
- let title = content.title;
83
- if (!title) {
84
- const match = content.body.match(/^#\s+(.+)$/m);
85
- title = match?.[1] ? match[1].trim() : 'Untitled Post';
86
- }
87
-
88
- let description: string | undefined;
89
- if (typeof content.metadata?.description === 'string') {
90
- description = content.metadata.description;
91
- } else {
92
- const paragraphs = content.body
93
- .split(/\n\s*\n/)
94
- .map((p) => p.replace(/^#+\s+.*$/, '').trim())
95
- .filter((p) => p.length > 0);
96
- if (paragraphs.length > 0) {
97
- description = paragraphs[0];
98
- }
99
- }
100
-
134
+ export function contentTags(content: Content): string[] {
101
135
  const rawTags: unknown[] = Array.isArray(content.metadata?.tags)
102
136
  ? [...content.metadata.tags]
103
137
  : Array.isArray(content.metadata?.keywords)
@@ -107,17 +141,33 @@ export function mapContentToSurfingMarkdown(content: Content, options: MapConten
107
141
  const subRaw = content.metadata.subCategory.trim();
108
142
  rawTags.push(/^sub-category\s*:/i.test(subRaw) ? subRaw : `sub-category:${subRaw}`);
109
143
  }
110
- // `slugify` drops non-ASCII, so a Chinese tag (`硬件发布`) collapsed to '' and shipped as an empty
111
- // YAML tag — the site renders it as a blank chip (seen on 20261011-ai-2026-10-11). Case variants
112
- // also collapsed to the same slug twice (`AI` + `ai`), so the set dedupes. `normalizeTag` preserves
113
- // `sub-category:<slug>` colon prefixes so sub-category series tags survive into frontmatter.
114
- const tags = [
144
+ return [
115
145
  ...new Set(
116
146
  rawTags
117
147
  .filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
118
148
  .map((t: string) => normalizeTag(t)),
119
149
  ),
120
150
  ].filter((tag) => tag.length > 0);
151
+ }
152
+
153
+ /**
154
+ * Maps Content object into Surfing frontmatter and body per the publish spec.
155
+ */
156
+ export function mapContentToSurfingMarkdown(content: Content, options: MapContentOptions = {}): string {
157
+ let title = content.title;
158
+ if (!title) {
159
+ const match = content.body.match(/^#\s+(.+)$/m);
160
+ title = match?.[1] ? match[1].trim() : 'Untitled Post';
161
+ }
162
+
163
+ // ADR-008: the site's refine step owns the digest/SEO description; the generator's
164
+ // first-paragraph fallback shipped the raw lede as SEO metadata before refine existed.
165
+ const description =
166
+ typeof content.metadata?.description === 'string' && content.metadata.description.length > 0
167
+ ? content.metadata.description
168
+ : '';
169
+
170
+ const tags = contentTags(content);
121
171
 
122
172
  const isoDate = options.publishDate ?? new Date().toISOString().slice(0, 10);
123
173
  const draft = typeof content.metadata?.published === 'boolean' ? !content.metadata.published : false;
@@ -133,7 +183,7 @@ export function mapContentToSurfingMarkdown(content: Content, options: MapConten
133
183
  // supplies identity.
134
184
  const frontmatterObj: Record<string, unknown> = {
135
185
  title,
136
- description: description ?? '',
186
+ description,
137
187
  tags,
138
188
  publishDate: isoDate,
139
189
  draft,
@@ -144,11 +194,31 @@ export function mapContentToSurfingMarkdown(content: Content, options: MapConten
144
194
  return `${yamlLines.join('\n')}\n\n${content.body.trim()}\n`;
145
195
  }
146
196
 
197
+ /** True when the content's tag sources (contentTags) mark it as a daily digest post. */
198
+ function isDigestContent(content: Content): boolean {
199
+ return contentTags(content).some((tag) => tag.includes(DIGEST_TAG));
200
+ }
201
+
147
202
  /** `Result` failure helper: ok false + error (podcast-pub pattern). */
148
203
  function fail(error: string): Result {
149
204
  return ResultSchema.parse({ ok: false, target: TARGET, error });
150
205
  }
151
206
 
207
+ type DeliveryState = 'not-sent' | 'uncertain';
208
+
209
+ /** A failed publish is replay-safe only when it was rejected before postsurfing was invoked. */
210
+ function failWithDeliveryState(error: string, deliveryState: DeliveryState): Result {
211
+ return ResultSchema.parse({ ok: false, target: TARGET, error, metadata: { deliveryState } });
212
+ }
213
+
214
+ function failNotSent(error: string): Result {
215
+ return failWithDeliveryState(error, 'not-sent');
216
+ }
217
+
218
+ function failUncertain(error: string): Result {
219
+ return failWithDeliveryState(error, 'uncertain');
220
+ }
221
+
152
222
  /**
153
223
  * Writes a Result JSON to `outputPath`, creating the directory as needed. Shared by the success
154
224
  * path and the invalid-input path so a malformed payload still produces the spec §2.5 `ok:false`
@@ -250,6 +320,7 @@ async function spawnCli(cmd: string[], cwd: string): Promise<CliSpawnResult> {
250
320
  cwd,
251
321
  stdout: 'pipe',
252
322
  stderr: 'pipe',
323
+ env: process.env,
253
324
  });
254
325
  const [exitCode, stdout, stderr] = await Promise.all([
255
326
  proc.exited,
@@ -315,6 +386,125 @@ export function requireRefined(filePath: string): string | undefined {
315
386
  return `surfdash refine step did not run for ${filePath} (no \`refined: true\`): the publishing pipeline is broken — the post would ship with generator-supplied SEO metadata`;
316
387
  }
317
388
 
389
+ /**
390
+ * Post-refine digest contract gate (2026-10-24): `requireRefined` proves refine ran; this proves
391
+ * the refined values still satisfy the digest contract. Returns the fail-loud error text, or
392
+ * undefined when ok / downgraded. `SURFDASH_DIGEST_CHECK=warn` echoes each problem and continues.
393
+ */
394
+ export function digestCheckGate(filePath: string, locale: DigestLocale): string | undefined {
395
+ if (process.env.SURFDASH_DIGEST_CHECK === 'off') return undefined;
396
+ const problems = checkDigestFrontmatter(readFileSync(filePath, 'utf-8'), locale);
397
+ if (problems.length === 0) return undefined;
398
+ if (process.env.SURFDASH_DIGEST_CHECK === 'warn') {
399
+ for (const problem of problems) echoError(`digest check (warn): ${problem}`);
400
+ return undefined;
401
+ }
402
+ return `digest frontmatter check failed: ${problems.join('; ')}`;
403
+ }
404
+
405
+ /**
406
+ * Helper to parse file paths from `git status --porcelain` output.
407
+ */
408
+ function parsePorcelainPaths(stdout: string): string[] {
409
+ return stdout
410
+ .split('\n')
411
+ .map((line) => line.trimEnd())
412
+ .filter((line) => line.length > 3)
413
+ .map((line) => {
414
+ const raw = line.slice(3).trim();
415
+ const arrowIdx = raw.indexOf(' -> ');
416
+ const target = arrowIdx >= 0 ? raw.slice(arrowIdx + 4).trim() : raw;
417
+ if (target.startsWith('"') && target.endsWith('"')) {
418
+ return target.slice(1, -1);
419
+ }
420
+ return target;
421
+ })
422
+ .filter((p) => p.length > 0);
423
+ }
424
+
425
+ /**
426
+ * Runs the surfdash listen-link backfill command (`bun run sd seo listen-link --backfill --write`)
427
+ * with cwd = `surfdashRoot` via `spawnCli`.
428
+ *
429
+ * Scans recent non-draft digest posts and links the podcast episode URL when live.
430
+ * Best-effort: non-zero exit is reported via `echoError` prefixed with `listen-link backfill:`
431
+ * and never throws or fails publish/patch.
432
+ *
433
+ * Returns relative paths under `content/posts` modified by the backfill, by diffing
434
+ * `git status --porcelain -- content/posts` before and after the run.
435
+ * Skipped when `SURFDASH_LISTEN_BACKFILL=off`.
436
+ */
437
+ export async function runListenLinkBackfill(surfdashRoot: string): Promise<string[]> {
438
+ if (process.env.SURFDASH_LISTEN_BACKFILL === 'off') {
439
+ return [];
440
+ }
441
+
442
+ const before = await spawnCli(['git', 'status', '--porcelain', '--', 'content/posts'], surfdashRoot);
443
+ const beforePaths = new Set(parsePorcelainPaths(before.stdout));
444
+
445
+ const spawned = await spawnCli(['bun', 'run', 'sd', 'seo', 'listen-link', '--backfill', '--write'], surfdashRoot);
446
+ if (spawned.exitCode !== 0) {
447
+ echoError(`listen-link backfill: ${cliErrorText(spawned)}`);
448
+ }
449
+
450
+ const after = await spawnCli(['git', 'status', '--porcelain', '--', 'content/posts'], surfdashRoot);
451
+ const afterPaths = parsePorcelainPaths(after.stdout);
452
+
453
+ return [...new Set(afterPaths.filter((p) => !beforePaths.has(p)))];
454
+ }
455
+
456
+ /** Outcome of {@link runSiteGate}. */
457
+ export type SiteGateOutcome = { ok: true } | { ok: false; error: string };
458
+
459
+ /** Commands executed by the surfdash site gate before commit and push to main. */
460
+ const SITE_GATE_COMMANDS: readonly (readonly string[])[] = [
461
+ ['bun', 'run', 'sd', 'check', 'digest'],
462
+ ['bun', 'run', 'check:content'],
463
+ ];
464
+
465
+ /**
466
+ * Extracts a failing post hint (path or identifier) from CLI error output if detectable.
467
+ */
468
+ export function extractFailingPostHint(text: string): string | undefined {
469
+ const match = text.match(/(?:content[\\/])?posts[\\/][^\s:)]+/);
470
+ if (!match) return undefined;
471
+ return match[0].replace(/[.,;]+$/, '');
472
+ }
473
+
474
+ /**
475
+ * Runs the surfdash site gates (`bun run sd check digest` then `bun run check:content`)
476
+ * with cwd = `surfdashRoot` via `spawnCli`.
477
+ *
478
+ * If either command exits non-zero:
479
+ * - With `SURFDASH_DIGEST_CHECK=warn`: echoes each line of the failure via `echoError`
480
+ * prefixed with `site gate (warn):` and continues.
481
+ * - With `SURFDASH_DIGEST_CHECK=off`: skips the gate completely.
482
+ * - Default (unset or any other value): fails loud with command, trimmed output, and post hint.
483
+ */
484
+ export async function runSiteGate(surfdashRoot: string): Promise<SiteGateOutcome> {
485
+ if (process.env.SURFDASH_DIGEST_CHECK === 'off') {
486
+ return { ok: true };
487
+ }
488
+
489
+ for (const cmd of SITE_GATE_COMMANDS) {
490
+ const spawned = await spawnCli([...cmd], surfdashRoot);
491
+ if (spawned.exitCode !== 0) {
492
+ const output = cliErrorText(spawned);
493
+ const postHint = extractFailingPostHint(output);
494
+ const hint = postHint ? ` [failing post: ${postHint}]` : '';
495
+ const error = `site gate failed: ${cmd.join(' ')} (exit ${spawned.exitCode})${hint}: ${output}`;
496
+ if (process.env.SURFDASH_DIGEST_CHECK === 'warn') {
497
+ for (const line of error.split('\n')) {
498
+ echoError(`site gate (warn): ${line}`);
499
+ }
500
+ continue;
501
+ }
502
+ return { ok: false, error };
503
+ }
504
+ }
505
+ return { ok: true };
506
+ }
507
+
318
508
  /** Outcome of {@link pushPostGit} — the union keeps fail-loud errors typed at the call site. */
319
509
  export type GitPushOutcome = { ok: true; hash: string } | { ok: false; error: string };
320
510
 
@@ -325,8 +515,15 @@ export type GitPushOutcome = { ok: true; hash: string } | { ok: false; error: st
325
515
  * Committing with a pathspec (`git commit -- <path>`) keeps unrelated staged files
326
516
  * out of the publish commit. Fails loud on branch/commit/push errors.
327
517
  */
328
- export async function pushPostGit(surfdashRoot: string, relPostDir: string, message: string): Promise<GitPushOutcome> {
329
- return commitPostPaths(surfdashRoot, [relPostDir], message);
518
+ export async function pushPostGit(
519
+ surfdashRoot: string,
520
+ relPostDir: string | string[],
521
+ message: string,
522
+ extraPaths: string[] = [],
523
+ ): Promise<GitPushOutcome> {
524
+ const rawPaths = Array.isArray(relPostDir) ? [...relPostDir, ...extraPaths] : [relPostDir, ...extraPaths];
525
+ const paths = [...new Set(rawPaths)];
526
+ return commitPostPaths(surfdashRoot, paths, message);
330
527
  }
331
528
  /** Refuse a checkout that cannot deploy before anything is written (`deploy.yml` builds `main` only). */
332
529
  async function assertMainBranch(surfdashRoot: string): Promise<{ ok: true } | { ok: false; error: string }> {
@@ -477,8 +674,20 @@ async function scaffoldLocale(options: SurfdashPubOptions): Promise<Result> {
477
674
  }
478
675
 
479
676
  try {
677
+ const force = options.force === true;
480
678
  const spawned = await spawnCli(
481
- ['bun', 'run', 'sd', 'translate', postPath.postPath, '--source', 'zh', '--target', target.locale],
679
+ [
680
+ 'bun',
681
+ 'run',
682
+ 'sd',
683
+ 'translate',
684
+ postPath.postPath,
685
+ '--source',
686
+ 'zh',
687
+ '--target',
688
+ target.locale,
689
+ ...(force ? ['--force'] : []),
690
+ ],
482
691
  surfdashRoot,
483
692
  );
484
693
  if (spawned.exitCode !== 0) {
@@ -563,6 +772,13 @@ async function syncPost(options: SurfdashPubOptions): Promise<Result> {
563
772
  `publish-orchestrator sync failed (exit ${spawned.exitCode}): ${withEmdashTokenHint(cliErrorText(spawned))}`,
564
773
  );
565
774
  }
775
+ // Digest contract (2026-10-24): verify the synced post's frontmatter for its own locale;
776
+ // paths outside content/posts/articles/<zh|en|ja>/ carry no digest series to check.
777
+ const localeInPath = postPath.postPath.match(/[\\/]content[\\/]posts[\\/]articles[\\/](zh|en|ja)[\\/]/);
778
+ if (localeInPath) {
779
+ const digestProblem = digestCheckGate(postPath.postPath, localeInPath[1] as DigestLocale);
780
+ if (digestProblem) return fail(digestProblem);
781
+ }
566
782
  return ResultSchema.parse({
567
783
  ok: true,
568
784
  target: TARGET,
@@ -625,6 +841,12 @@ async function patchPost(options: SurfdashPubOptions): Promise<Result> {
625
841
  commitPaths.add(relative(surfdashRoot, assetsDir));
626
842
  }
627
843
  }
844
+ if (process.env.SURFDASH_AUTO_PUSH !== '0') {
845
+ const backfilled = await runListenLinkBackfill(surfdashRoot);
846
+ for (const p of backfilled) commitPaths.add(p);
847
+ const gate = await runSiteGate(surfdashRoot);
848
+ if (!gate.ok) return fail(gate.error);
849
+ }
628
850
  const commit = await commitPostPaths(
629
851
  surfdashRoot,
630
852
  [...commitPaths],
@@ -659,82 +881,336 @@ async function patchPost(options: SurfdashPubOptions): Promise<Result> {
659
881
  });
660
882
  }
661
883
 
884
+ interface PreparedPost {
885
+ surfdashRoot: string;
886
+ locale: SupportedLocale;
887
+ dateStamp: string;
888
+ slug: string;
889
+ filePath: string;
890
+ relPostDir: string;
891
+ /** Prepared daily publishes carry all translated sibling directories into the deploy commit. */
892
+ relPostDirs?: string[];
893
+ articleUrl: string;
894
+ }
895
+
896
+ interface PreparedLocaleIdentity {
897
+ runDate: string;
898
+ postPath: string;
899
+ localePaths: Record<SupportedLocale, string>;
900
+ localeDigests: Record<SupportedLocale, string>;
901
+ fingerprint: string;
902
+ }
903
+
904
+ function fileSha256(filePath: string): string {
905
+ return createHash('sha256').update(readFileSync(filePath)).digest('hex');
906
+ }
907
+
662
908
  /**
663
- * Publish path (behavior unchanged since E3): maps Content into surfdash markdown under
664
- * `content/posts/<collection>/<locale>/<slug>/index.md` and invokes the postsurfing CLI.
909
+ * Computes the exact identity used by the daily locale gate. This is intentionally derived from
910
+ * bytes on disk immediately before the remote publishing FSM starts; a cached result must never
911
+ * satisfy a different slug, payload, or translated-locale set.
665
912
  */
666
- async function publishPost(payload: PublishTransportPayload): Promise<Result> {
667
- const postsurfingBin = process.env.POSTSURFING_BIN ?? 'postsurfing';
913
+ function preparedLocaleIdentity(
914
+ postPath: string,
915
+ runDate: string,
916
+ ): { ok: true; value: PreparedLocaleIdentity } | { ok: false; error: string } {
917
+ const enPath = deriveLocaleDraftPath(postPath, 'en');
918
+ const jaPath = deriveLocaleDraftPath(postPath, 'ja');
919
+ if (enPath === undefined || jaPath === undefined) {
920
+ return { ok: false, error: `prepared zh post path must contain a "/zh/" locale segment: ${postPath}` };
921
+ }
922
+ const localePaths: Record<SupportedLocale, string> = { zh: postPath, en: enPath, ja: jaPath };
923
+ const localeDigests = {} as Record<SupportedLocale, string>;
924
+ for (const locale of ['zh', 'en', 'ja'] as const) {
925
+ const path = localePaths[locale];
926
+ if (!existsSync(path)) return { ok: false, error: `${locale} locale file is missing: ${path}` };
927
+ try {
928
+ localeDigests[locale] = fileSha256(path);
929
+ } catch (error: unknown) {
930
+ return {
931
+ ok: false,
932
+ error: `${locale} locale file cannot be fingerprinted: ${error instanceof Error ? error.message : String(error)}`,
933
+ };
934
+ }
935
+ }
936
+ const identity = { runDate, postPath, localePaths, localeDigests };
937
+ return {
938
+ ok: true,
939
+ value: { ...identity, fingerprint: createHash('sha256').update(JSON.stringify(identity)).digest('hex') },
940
+ };
941
+ }
942
+
943
+ interface PreparedLocaleSet {
944
+ surfdashRoot: string;
945
+ runDate: string;
946
+ postPath: string;
947
+ slug: string;
948
+ localePaths: Record<SupportedLocale, string>;
949
+ }
668
950
 
669
- // The publishing FSM requires the markdown at
670
- // content/posts/<collection>/<locale>/<slug>/index.md inside the surfdash site tree
671
- // (its refine step rejects other layouts); the root resolution lives in resolveSurfdashRoot().
951
+ /**
952
+ * Resolves the exact three-file daily post set without writing anything. Both the local refine
953
+ * phase and the final prepared publish use this guard so they cannot drift to different slugs or
954
+ * locale siblings between validation and dispatch.
955
+ */
956
+ function resolvePreparedLocaleSet(
957
+ options: SurfdashPubOptions,
958
+ ): { ok: true; value: PreparedLocaleSet } | { ok: false; result: Result } {
959
+ const postPath = resolvePostPath(options);
960
+ if ('error' in postPath) return { ok: false, result: failNotSent(postPath.error) };
672
961
  const surfdashRoot = resolveSurfdashRoot();
673
962
  if (!surfdashRoot) {
674
- return fail('SURFDASH_ROOT is required when POSTSURFING_BIN is not a path inside the surfdash checkout');
963
+ return {
964
+ ok: false,
965
+ result: failNotSent(
966
+ 'SURFDASH_ROOT is required when POSTSURFING_BIN is not a path inside the surfdash checkout',
967
+ ),
968
+ };
969
+ }
970
+ const outsideRoot = postPathOutsideRoot(postPath.postPath, surfdashRoot);
971
+ if (outsideRoot) return { ok: false, result: failNotSent(outsideRoot) };
972
+ if (!existsSync(postPath.postPath))
973
+ return { ok: false, result: failNotSent(`prepared post does not exist: ${postPath.postPath}`) };
974
+ const runDate = options.runDate;
975
+ if (typeof runDate !== 'string' || !/^\d{8}$/.test(runDate)) {
976
+ return { ok: false, result: failNotSent(`options.runDate must be YYYYMMDD, got '${String(runDate)}'`) };
977
+ }
978
+ const articlesRoot = resolve(surfdashRoot, 'content', 'posts', 'articles');
979
+ const relativePost = relative(articlesRoot, postPath.postPath);
980
+ const parts = relativePost.split(sep);
981
+ const slug = parts[1];
982
+ if (parts.length !== 3 || parts[0] !== 'zh' || parts[2] !== 'index.md' || slug === undefined || slug === '') {
983
+ return {
984
+ ok: false,
985
+ result: failNotSent(
986
+ `options.postPath must be content/posts/articles/zh/<slug>/index.md: ${postPath.postPath}`,
987
+ ),
988
+ };
989
+ }
990
+ const enPath = deriveLocaleDraftPath(postPath.postPath, 'en');
991
+ const jaPath = deriveLocaleDraftPath(postPath.postPath, 'ja');
992
+ if (enPath === undefined || jaPath === undefined) {
993
+ return {
994
+ ok: false,
995
+ result: failNotSent(`prepared zh post path must contain a "/zh/" locale segment: ${postPath.postPath}`),
996
+ };
997
+ }
998
+ const localePaths: Record<SupportedLocale, string> = { zh: postPath.postPath, en: enPath, ja: jaPath };
999
+ for (const locale of ['zh', 'en', 'ja'] as const) {
1000
+ if (!existsSync(localePaths[locale])) {
1001
+ return { ok: false, result: failNotSent(`${locale} locale file is missing: ${localePaths[locale]}`) };
1002
+ }
1003
+ }
1004
+ return { ok: true, value: { surfdashRoot, runDate, postPath: postPath.postPath, slug, localePaths } };
1005
+ }
1006
+
1007
+ function resolvePreparedPost(
1008
+ options: SurfdashPubOptions,
1009
+ ): { ok: true; prepared: PreparedPost; identity: PreparedLocaleIdentity } | { ok: false; result: Result } {
1010
+ const resolved = resolvePreparedLocaleSet(options);
1011
+ if (!resolved.ok) return resolved;
1012
+ const { surfdashRoot, runDate, postPath: preparedPath, slug } = resolved.value;
1013
+ const expectedPostSha256 = options.expectedPostSha256;
1014
+ if (typeof expectedPostSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(expectedPostSha256)) {
1015
+ return { ok: false, result: failNotSent('options.expectedPostSha256 must be a lowercase SHA-256 digest') };
1016
+ }
1017
+ const expectedLocaleFingerprint = options.expectedLocaleFingerprint;
1018
+ if (typeof expectedLocaleFingerprint !== 'string' || !/^[0-9a-f]{64}$/.test(expectedLocaleFingerprint)) {
1019
+ return {
1020
+ ok: false,
1021
+ result: failNotSent('options.expectedLocaleFingerprint must be a lowercase SHA-256 digest'),
1022
+ };
1023
+ }
1024
+ const identity = preparedLocaleIdentity(preparedPath, runDate);
1025
+ if (!identity.ok) return { ok: false, result: failNotSent(identity.error) };
1026
+ if (identity.value.localeDigests.zh !== expectedPostSha256) {
1027
+ return {
1028
+ ok: false,
1029
+ result: failNotSent(`prepared zh post bytes changed after validation: ${preparedPath}`),
1030
+ };
1031
+ }
1032
+ if (identity.value.fingerprint !== expectedLocaleFingerprint) {
1033
+ return {
1034
+ ok: false,
1035
+ result: failNotSent(
1036
+ 'prepared locale files changed after validation; refresh the locale gate before publishing',
1037
+ ),
1038
+ };
1039
+ }
1040
+ const baseUrl = (process.env.SURFDASH_URL ?? 'https://surfing.salty.vip').replace(/\/$/, '');
1041
+ return {
1042
+ ok: true,
1043
+ prepared: {
1044
+ surfdashRoot,
1045
+ locale: 'zh',
1046
+ dateStamp: runDate,
1047
+ slug,
1048
+ filePath: preparedPath,
1049
+ relPostDir: join('content', 'posts', 'articles', 'zh', slug),
1050
+ relPostDirs: ['zh', 'en', 'ja'].map((locale) => join('content', 'posts', 'articles', locale, slug)),
1051
+ articleUrl: `${baseUrl}/zh/posts/${slug}`,
1052
+ },
1053
+ identity: identity.value,
1054
+ };
1055
+ }
1056
+
1057
+ type PrepareOutcome = { ok: true; prepared: PreparedPost } | { ok: false; result: Result };
1058
+
1059
+ /**
1060
+ * Materializes the local post without invoking the external publishing FSM.
1061
+ *
1062
+ * Locale scaffolding and translation must happen before Surfdash's strict sync gate runs. The
1063
+ * normal `publish` operation uses the same preparation path before refine, CMS sync and deploy.
1064
+ */
1065
+ async function preparePost(payload: PublishTransportPayload): Promise<PrepareOutcome> {
1066
+ const surfdashRoot = resolveSurfdashRoot();
1067
+ if (!surfdashRoot) {
1068
+ return {
1069
+ ok: false,
1070
+ result: failNotSent(
1071
+ 'SURFDASH_ROOT is required when POSTSURFING_BIN is not a path inside the surfdash checkout',
1072
+ ),
1073
+ };
675
1074
  }
676
1075
 
677
- const locale = process.env.SURFDASH_LOCALE ?? 'zh';
678
- // Fail loud on legacy locale codes ('cn' is the pre-migration name for zh) —
679
- // they'd write a content dir no route serves.
680
- if (!['zh', 'en', 'ja'].includes(locale)) {
681
- return fail(`SURFDASH_LOCALE must be one of zh/en/ja, got '${locale}'`);
1076
+ const rawLocale = process.env.SURFDASH_LOCALE ?? 'zh';
1077
+ if (!['zh', 'en', 'ja'].includes(rawLocale)) {
1078
+ return { ok: false, result: failNotSent(`SURFDASH_LOCALE must be one of zh/en/ja, got '${rawLocale}'`) };
682
1079
  }
683
- // Date-stamped slug (metadata.runDate, e.g. 20260904) keeps same-title daily posts
684
- // from colliding; Chinese titles slugify to '' and fall back to the bare date.
1080
+ const locale = rawLocale as SupportedLocale;
685
1081
  const runDate = typeof payload.content.metadata?.runDate === 'string' ? payload.content.metadata.runDate : '';
686
1082
  const today = new Date().toISOString().slice(0, 10).replaceAll('-', '');
687
1083
  const dateStamp = runDate || today;
688
1084
  const titleSlug = slugify(payload.content.title ?? '');
689
1085
  const slug = titleSlug ? `${dateStamp}-${titleSlug}` : `${dateStamp}-post`;
690
- // Validate before touching disk (R4): a `metadata.runDate` or slug that escapes the articles
691
- // tree would write a post outside it, and a non-image `coverImage` would be copied into a post
692
- // that pushPostGit then publishes to a public repo.
693
1086
  if (runDate !== '' && !/^\d{8}$/.test(runDate)) {
694
- return fail(`metadata.runDate must be YYYYMMDD, got '${runDate}'`);
1087
+ return { ok: false, result: failNotSent(`metadata.runDate must be YYYYMMDD, got '${runDate}'`) };
695
1088
  }
696
1089
  const articlesRoot = resolve(surfdashRoot, 'content', 'posts', 'articles');
697
1090
  const localeRoot = resolve(articlesRoot, locale);
698
1091
  const postDir = resolve(localeRoot, slug);
699
1092
  if (dirname(localeRoot) !== articlesRoot || dirname(postDir) !== localeRoot) {
700
- return fail(`post path escapes ${articlesRoot}`);
1093
+ return { ok: false, result: failNotSent(`post path escapes ${articlesRoot}`) };
701
1094
  }
1095
+
702
1096
  const coverPath = payload.content.metadata?.coverImage;
703
- if (typeof coverPath === 'string' && existsSync(coverPath) && !COVER_EXTS.has(extname(coverPath).toLowerCase())) {
704
- return fail(`metadata.coverImage must be one of ${[...COVER_EXTS].join(', ')}`);
1097
+ if (typeof coverPath === 'string' && !existsSync(coverPath)) {
1098
+ return { ok: false, result: failNotSent(`metadata.coverImage not found: ${coverPath}`) };
1099
+ }
1100
+ if (typeof coverPath === 'string' && !COVER_EXTS.has(extname(coverPath).toLowerCase())) {
1101
+ return {
1102
+ ok: false,
1103
+ result: failNotSent(`metadata.coverImage must be one of ${[...COVER_EXTS].join(', ')}`),
1104
+ };
1105
+ }
1106
+ if (isDigestContent(payload.content) && typeof coverPath !== 'string') {
1107
+ return { ok: false, result: failNotSent('digest post has no cover image (metadata.coverImage)') };
705
1108
  }
706
1109
 
707
- await createNodeFileSystem().ensureDir(postDir);
708
-
709
- // Cover image: metadata.coverImage (absolute local path) is copied into the post's
710
- // assets dir and referenced relatively — the surfdash PostSchema accepts ./assets/<file>.
711
- let imageFrontmatter: string | undefined;
712
- if (typeof coverPath === 'string' && existsSync(coverPath)) {
713
- const assetsDir = join(postDir, 'assets');
714
- await createNodeFileSystem().ensureDir(assetsDir);
715
- const coverName = `cover${extname(coverPath).toLowerCase()}`;
716
- await copyFile(coverPath, join(assetsDir, coverName));
717
- imageFrontmatter = `./assets/${coverName}`;
1110
+ const filePath = join(postDir, 'index.md');
1111
+ try {
1112
+ await createNodeFileSystem().ensureDir(postDir);
1113
+ let imageFrontmatter: string | undefined;
1114
+ if (typeof coverPath === 'string') {
1115
+ const assetsDir = join(postDir, 'assets');
1116
+ await createNodeFileSystem().ensureDir(assetsDir);
1117
+ const coverName = `cover${extname(coverPath).toLowerCase()}`;
1118
+ await copyFile(coverPath, join(assetsDir, coverName));
1119
+ imageFrontmatter = `./assets/${coverName}`;
1120
+ }
1121
+ const markdown = mapContentToSurfingMarkdown(payload.content, {
1122
+ image: imageFrontmatter,
1123
+ publishDate: runDate ? `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}` : undefined,
1124
+ });
1125
+ await createNodeFileSystem().writeFile(filePath, markdown);
1126
+ } catch (err: unknown) {
1127
+ return {
1128
+ ok: false,
1129
+ result: failNotSent(
1130
+ `Surfdash post preparation failed: ${err instanceof Error ? err.message : String(err)}`,
1131
+ ),
1132
+ };
718
1133
  }
719
- const markdown = mapContentToSurfingMarkdown(payload.content, {
720
- image: imageFrontmatter,
721
- publishDate: runDate ? `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}` : undefined,
1134
+
1135
+ const baseUrl = (process.env.SURFDASH_URL ?? 'https://surfing.salty.vip').replace(/\/$/, '');
1136
+ const articleUrl = `${baseUrl}${locale === 'en' ? '' : `/${locale}`}/posts/${slug}`;
1137
+ return {
1138
+ ok: true,
1139
+ prepared: {
1140
+ surfdashRoot,
1141
+ locale,
1142
+ dateStamp,
1143
+ slug,
1144
+ filePath,
1145
+ relPostDir: join('content', 'posts', 'articles', locale, slug),
1146
+ articleUrl,
1147
+ },
1148
+ };
1149
+ }
1150
+
1151
+ /** `prepare` operation: write the local post and return its path for locale scaffolding. */
1152
+ async function preparePostResult(payload: PublishTransportPayload): Promise<Result> {
1153
+ const outcome = await preparePost(payload);
1154
+ if (!outcome.ok) return outcome.result;
1155
+ return ResultSchema.parse({
1156
+ ok: true,
1157
+ target: TARGET,
1158
+ message: `Prepared ${outcome.prepared.locale} post at ${outcome.prepared.filePath}`,
1159
+ metadata: {
1160
+ postPath: outcome.prepared.filePath,
1161
+ articleUrl: outcome.prepared.articleUrl,
1162
+ dateStamp: outcome.prepared.dateStamp,
1163
+ slug: outcome.prepared.slug,
1164
+ },
722
1165
  });
723
- const filePath = join(postDir, 'index.md');
1166
+ }
1167
+
1168
+ /** Invokes the remote publishing FSM for an already-materialized post path. */
1169
+ async function publishPreparedFile(
1170
+ prepared: PreparedPost,
1171
+ identity?: Pick<PreparedLocaleIdentity, 'runDate' | 'fingerprint'> & { postSha256?: string },
1172
+ ): Promise<Result> {
1173
+ const postsurfingBin = process.env.POSTSURFING_BIN ?? 'postsurfing';
1174
+ const { surfdashRoot, locale, dateStamp, filePath, relPostDir, relPostDirs, articleUrl } = prepared;
1175
+ const identityMetadata = identity
1176
+ ? {
1177
+ runDate: identity.runDate,
1178
+ localeFingerprint: identity.fingerprint,
1179
+ ...(identity.postSha256 ? { postSha256: identity.postSha256 } : {}),
1180
+ }
1181
+ : {};
724
1182
 
725
1183
  try {
726
- await createNodeFileSystem().writeFile(filePath, markdown);
727
1184
  const spawned = await spawnCli([postsurfingBin, 'publish', filePath], surfdashRoot);
728
1185
 
729
1186
  if (spawned.exitCode !== 0) {
730
- return fail(
1187
+ return failUncertain(
731
1188
  `postsurfing CLI failed (exit ${spawned.exitCode}): ${withEmdashTokenHint(cliErrorText(spawned))}`,
732
1189
  );
733
1190
  }
734
1191
 
1192
+ // The real Surfdash FSM runs its own refine step before CMS sync. The daily workflow runs
1193
+ // site-owned refine locally first, so this must be idempotent; otherwise the remote phase
1194
+ // would silently mutate the bytes whose fingerprint admitted this publish. Treat any
1195
+ // mutation as uncertain because CMS/git may already have side effects.
1196
+ if (identity !== undefined) {
1197
+ const afterRemote = preparedLocaleIdentity(filePath, identity.runDate);
1198
+ if (!afterRemote.ok)
1199
+ return failUncertain(`prepared locale files changed after remote publish: ${afterRemote.error}`);
1200
+ if (
1201
+ afterRemote.value.fingerprint !== identity.fingerprint ||
1202
+ (identity.postSha256 !== undefined && afterRemote.value.localeDigests.zh !== identity.postSha256)
1203
+ ) {
1204
+ return failUncertain('prepared locale files changed after remote publish; reconcile before retrying');
1205
+ }
1206
+ }
1207
+
735
1208
  // Contract check (ADR-008): the site, not this plugin, owns SEO metadata.
736
1209
  const notRefined = requireRefined(filePath);
737
- if (notRefined) return fail(notRefined);
1210
+ if (notRefined) return failUncertain(notRefined);
1211
+ // Digest contract (2026-10-24): the refined values themselves must hold, before any push.
1212
+ const digestProblem = digestCheckGate(filePath, locale);
1213
+ if (digestProblem) return failUncertain(digestProblem);
738
1214
  const refined = readRefinedSnapshot(filePath);
739
1215
 
740
1216
  // Deploy seam: commit + push the post so deploy.yml builds it, then wait for
@@ -743,8 +1219,6 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
743
1219
  // URL shape mirrors surfdash's localePathPrefix (surfdash/src/lib/seo.ts):
744
1220
  // en is served at the site root, zh/ja carry a /<locale> prefix. Composing
745
1221
  // /<locale>/posts/<slug> unconditionally 404s en posts.
746
- const baseUrl = (process.env.SURFDASH_URL ?? 'https://surfing.salty.vip').replace(/\/$/, '');
747
- const articleUrl = `${baseUrl}${locale === 'en' ? '' : `/${locale}`}/posts/${slug}`;
748
1222
  if (process.env.SURFDASH_AUTO_PUSH === '0') {
749
1223
  return ResultSchema.parse({
750
1224
  ok: true,
@@ -753,12 +1227,26 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
753
1227
  message: 'Published via postsurfing CLI (auto-push disabled)',
754
1228
  // Absolute post path lets downstream steps (locale translation + CMS sync)
755
1229
  // derive the surfdash checkout root without extra configuration.
756
- metadata: { postPath: filePath, refinedTitle: refined?.title, refinedCanonical: refined?.canonical },
1230
+ metadata: {
1231
+ postPath: filePath,
1232
+ ...identityMetadata,
1233
+ refinedTitle: refined?.title,
1234
+ refinedCanonical: refined?.canonical,
1235
+ },
757
1236
  });
758
1237
  }
759
- const relPostDir = join('content', 'posts', 'articles', locale, slug);
760
- const pushed = await pushPostGit(surfdashRoot, relPostDir, `content: daily AI news ${dateStamp} (zh)`);
761
- if (!pushed.ok) return fail(pushed.error);
1238
+ const backfilled = await runListenLinkBackfill(surfdashRoot);
1239
+ const gate = await runSiteGate(surfdashRoot);
1240
+ if (!gate.ok) return failUncertain(gate.error);
1241
+ const commitPaths = relPostDirs ?? [relPostDir];
1242
+ const commitLocales: SupportedLocale[] = relPostDirs === undefined ? [locale] : ['zh', 'en', 'ja'];
1243
+ const pushed = await pushPostGit(
1244
+ surfdashRoot,
1245
+ commitPaths,
1246
+ `content: daily AI news ${dateStamp} (${commitLocales.join(', ')})`,
1247
+ backfilled,
1248
+ );
1249
+ if (!pushed.ok) return failUncertain(pushed.error);
762
1250
  const live = await awaitArticleLive(articleUrl);
763
1251
 
764
1252
  return ResultSchema.parse({
@@ -768,16 +1256,95 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
768
1256
  message: live
769
1257
  ? 'Published, committed, pushed, and live on the site'
770
1258
  : 'Published and pushed; URL not live within the verify window — check the surfdash deploy workflow',
771
- metadata: { postPath: filePath, gitCommit: pushed.hash, live, refinedTitle: refined?.title },
1259
+ metadata: {
1260
+ postPath: filePath,
1261
+ ...identityMetadata,
1262
+ gitCommit: pushed.hash,
1263
+ live,
1264
+ refinedTitle: refined?.title,
1265
+ },
772
1266
  });
773
1267
  } catch (err: unknown) {
774
- return fail(`Postsurfing transport error: ${err instanceof Error ? err.message : String(err)}`);
1268
+ return failUncertain(`Postsurfing transport error: ${err instanceof Error ? err.message : String(err)}`);
775
1269
  }
776
1270
  }
777
1271
 
1272
+ /**
1273
+ * Publish path (behavior unchanged since E3): maps Content into surfdash markdown under
1274
+ * `content/posts/<collection>/<locale>/<slug>/index.md` and invokes the postsurfing CLI.
1275
+ */
1276
+ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
1277
+ const outcome = await preparePost(payload);
1278
+ if (!outcome.ok) return outcome.result;
1279
+ return publishPreparedFile(outcome.prepared);
1280
+ }
1281
+
1282
+ /**
1283
+ * Runs the site-owned refine command over zh/en/ja without CMS, git, or remote effects. The daily
1284
+ * workflow fingerprints only after this operation; the real publish FSM can then revisit refine
1285
+ * idempotently instead of rewriting a validated locale set during its remote phase.
1286
+ */
1287
+ async function refinePreparedPost(options: SurfdashPubOptions): Promise<Result> {
1288
+ const resolved = resolvePreparedLocaleSet(options);
1289
+ if (!resolved.ok) return resolved.result;
1290
+ const orchestrator = join(resolved.value.surfdashRoot, 'scripts', 'publish-orchestrator.ts');
1291
+ if (!existsSync(orchestrator)) return failNotSent(`Surfdash refine orchestrator is missing: ${orchestrator}`);
1292
+ try {
1293
+ for (const locale of ['zh', 'en', 'ja'] as const) {
1294
+ const filePath = resolved.value.localePaths[locale];
1295
+ const spawned = await spawnCli(
1296
+ [process.execPath, orchestrator, 'refine', filePath],
1297
+ resolved.value.surfdashRoot,
1298
+ );
1299
+ if (spawned.exitCode !== 0) {
1300
+ return failNotSent(
1301
+ `Surfdash refine failed for ${locale} (exit ${spawned.exitCode}): ${cliErrorText(spawned)}`,
1302
+ );
1303
+ }
1304
+ const notRefined = requireRefined(filePath);
1305
+ if (notRefined) return failNotSent(notRefined);
1306
+ const digestProblem = digestCheckGate(filePath, locale);
1307
+ if (digestProblem) return failNotSent(digestProblem);
1308
+ }
1309
+ } catch (error: unknown) {
1310
+ return failNotSent(
1311
+ `Surfdash refine transport error: ${error instanceof Error ? error.message : String(error)}`,
1312
+ );
1313
+ }
1314
+ const identity = preparedLocaleIdentity(resolved.value.postPath, resolved.value.runDate);
1315
+ if (!identity.ok) return failNotSent(identity.error);
1316
+ return ResultSchema.parse({
1317
+ ok: true,
1318
+ target: TARGET,
1319
+ message: 'Refined zh/en/ja via the Surfdash-owned orchestrator',
1320
+ metadata: {
1321
+ postPath: resolved.value.postPath,
1322
+ runDate: resolved.value.runDate,
1323
+ localePaths: identity.value.localePaths,
1324
+ localeDigests: identity.value.localeDigests,
1325
+ localeFingerprint: identity.value.fingerprint,
1326
+ },
1327
+ });
1328
+ }
1329
+
1330
+ /**
1331
+ * Final daily voice2 operation. Unlike `publish`, this never maps Content or rewrites the prepared
1332
+ * zh post. It verifies the exact validated bytes and locale fingerprint immediately before invoking
1333
+ * postsurfing, so a regenerated same-day payload cannot silently replace the validated material.
1334
+ */
1335
+ async function publishPreparedPost(options: SurfdashPubOptions): Promise<Result> {
1336
+ const outcome = resolvePreparedPost(options);
1337
+ if (!outcome.ok) return outcome.result;
1338
+ return publishPreparedFile(outcome.prepared, {
1339
+ runDate: outcome.identity.runDate,
1340
+ fingerprint: outcome.identity.fingerprint,
1341
+ postSha256: outcome.identity.localeDigests.zh,
1342
+ });
1343
+ }
1344
+
778
1345
  /**
779
1346
  * FileCli transport invoking postsurfing CLI for `publish` (default) and the
780
- * `scaffold-locale` / `sync` surfdash mechanics via `options.operation` (L1 R7).
1347
+ * prepared-path / `scaffold-locale` / `sync` surfdash mechanics via `options.operation` (L1 R7).
781
1348
  */
782
1349
  export class PostsurfingFileCliTransport implements PublishTransport {
783
1350
  public readonly kind = 'file-cli';
@@ -792,6 +1359,12 @@ export class PostsurfingFileCliTransport implements PublishTransport {
792
1359
  if ('error' in routed) return fail(routed.error);
793
1360
 
794
1361
  switch (routed.operation) {
1362
+ case 'prepare':
1363
+ return preparePostResult(payload);
1364
+ case 'refine-prepared':
1365
+ return refinePreparedPost(options);
1366
+ case 'publish-prepared':
1367
+ return publishPreparedPost(options);
795
1368
  case 'scaffold-locale':
796
1369
  return scaffoldLocale(options);
797
1370
  case 'sync':
@@ -818,10 +1391,9 @@ export function getPublishTransport(): PublishTransport {
818
1391
  /**
819
1392
  * Processes input Content and writes output Result via transport.
820
1393
  *
821
- * The input JSON may carry a top-level `options` object (`operation`, `postPath`,
822
- * `target`) alongside the Content fields; it is forwarded to the transport so
823
- * `kk executor run` callers can select scaffold-locale/sync without extra flags
824
- * (podcast-pub pattern, 0096).
1394
+ * The input JSON may carry a top-level `options` object (`operation`, `postPath`, `target`, and
1395
+ * prepared-path identity fields) alongside the Content fields; it is forwarded to the transport so
1396
+ * `kk executor run` callers can select locale/publish mechanics without extra flags (0096).
825
1397
  */
826
1398
  export async function processPublishIO(
827
1399
  inputPath: string,