@gobing-ai/knowledge-kit 0.1.10 → 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 (46) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +128 -25
  3. package/package.json +1 -1
  4. package/plugins/kk/plugin.json +1 -1
  5. package/plugins/kk/scripts/daily-scheduled-run.ts +298 -0
  6. package/plugins/kk/scripts/daily-stages.ts +1057 -108
  7. package/plugins/kk/scripts/kk-workflow-stages.ts +76 -10
  8. package/plugins/kk/scripts/safety-artifact-check.ts +235 -0
  9. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +168 -121
  10. package/plugins/kk/workflows/kk-daily-safety.yaml +129 -50
  11. package/plugins/publishings/bilibili-pub/dist/index.js +2 -1
  12. package/plugins/publishings/bilibili-pub/dist/profile-check.js +2 -1
  13. package/plugins/publishings/cnblogs-pub/dist/index.js +2 -1
  14. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +2 -1
  15. package/plugins/publishings/csdn-pub/dist/index.js +2 -1
  16. package/plugins/publishings/csdn-pub/dist/profile-check.js +2 -1
  17. package/plugins/publishings/infoq-pub/dist/index.js +2 -1
  18. package/plugins/publishings/infoq-pub/dist/profile-check.js +2 -1
  19. package/plugins/publishings/jike-pub/dist/index.js +2 -1
  20. package/plugins/publishings/jike-pub/dist/profile-check.js +2 -1
  21. package/plugins/publishings/juejin-pub/dist/index.js +2 -1
  22. package/plugins/publishings/juejin-pub/dist/profile-check.js +2 -1
  23. package/plugins/publishings/podcast-pub/dist/index.js +46 -8
  24. package/plugins/publishings/podcast-pub/src/index.ts +62 -5
  25. package/plugins/publishings/shipinhao-pub/dist/index.js +2 -1
  26. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +2 -1
  27. package/plugins/publishings/sspai-pub/dist/index.js +2 -1
  28. package/plugins/publishings/sspai-pub/dist/profile-check.js +2 -1
  29. package/plugins/publishings/substack-pub/dist/index.js +2 -1
  30. package/plugins/publishings/substack-pub/dist/profile-check.js +2 -1
  31. package/plugins/publishings/surfdash-pub/dist/index.js +460 -52
  32. package/plugins/publishings/surfdash-pub/src/digest-check.ts +85 -19
  33. package/plugins/publishings/surfdash-pub/src/index.ts +589 -64
  34. package/plugins/publishings/wechatmp-pub/dist/index.js +933 -98
  35. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +60 -3
  36. package/plugins/publishings/wechatmp-pub/src/flow.ts +1125 -85
  37. package/plugins/publishings/wechatmp-pub/src/index.ts +112 -30
  38. package/plugins/publishings/wechatmp-pub/src/page.ts +105 -0
  39. package/plugins/publishings/weibo-pub/dist/index.js +2 -1
  40. package/plugins/publishings/weibo-pub/dist/profile-check.js +2 -1
  41. package/plugins/publishings/x-pub/dist/index.js +2 -1
  42. package/plugins/publishings/x-pub/dist/profile-check.js +2 -1
  43. package/plugins/publishings/xhs-pub/dist/index.js +2 -1
  44. package/plugins/publishings/xhs-pub/dist/profile-check.js +2 -1
  45. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +2 -1
  46. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +2 -1
@@ -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';
@@ -16,12 +41,21 @@ export { checkDigestFrontmatter, DIGEST_TAG, type DigestLocale } from './digest-
16
41
  const TARGET = 'surfdash';
17
42
 
18
43
  /** Management operations routed via `options.operation` (L1 R7; pattern from podcast-pub). */
19
- 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;
20
53
  type Operation = (typeof OPERATIONS)[number];
21
54
 
22
55
  /** Locales `scaffold-locale` can scaffold; LLM translation stays in the workflow's agent step. */
23
56
  const SCAFFOLD_LOCALES = ['en', 'ja'] as const;
24
57
  type ScaffoldLocale = (typeof SCAFFOLD_LOCALES)[number];
58
+ type SupportedLocale = 'zh' | 'en' | 'ja';
25
59
 
26
60
  /** Cover-image extensions accepted from `metadata.coverImage` (R4): anything else is rejected, not copied. */
27
61
  const COVER_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
@@ -33,6 +67,16 @@ interface SurfdashPubOptions {
33
67
  postPath?: unknown;
34
68
  /** Draft locale to scaffold; required for `scaffold-locale`. */
35
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;
36
80
  /** `patch`: podcast-hosted cover URL for `image`/`og_image` (empty → the local cover fallback). */
37
81
  coverUrl?: unknown;
38
82
  /** `patch`: podcast episode URL appended as the cross-link (empty → none). */
@@ -160,6 +204,21 @@ function fail(error: string): Result {
160
204
  return ResultSchema.parse({ ok: false, target: TARGET, error });
161
205
  }
162
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
+
163
222
  /**
164
223
  * Writes a Result JSON to `outputPath`, creating the directory as needed. Shared by the success
165
224
  * path and the invalid-input path so a malformed payload still produces the spec §2.5 `ok:false`
@@ -333,6 +392,7 @@ export function requireRefined(filePath: string): string | undefined {
333
392
  * undefined when ok / downgraded. `SURFDASH_DIGEST_CHECK=warn` echoes each problem and continues.
334
393
  */
335
394
  export function digestCheckGate(filePath: string, locale: DigestLocale): string | undefined {
395
+ if (process.env.SURFDASH_DIGEST_CHECK === 'off') return undefined;
336
396
  const problems = checkDigestFrontmatter(readFileSync(filePath, 'utf-8'), locale);
337
397
  if (problems.length === 0) return undefined;
338
398
  if (process.env.SURFDASH_DIGEST_CHECK === 'warn') {
@@ -342,6 +402,109 @@ export function digestCheckGate(filePath: string, locale: DigestLocale): string
342
402
  return `digest frontmatter check failed: ${problems.join('; ')}`;
343
403
  }
344
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
+
345
508
  /** Outcome of {@link pushPostGit} — the union keeps fail-loud errors typed at the call site. */
346
509
  export type GitPushOutcome = { ok: true; hash: string } | { ok: false; error: string };
347
510
 
@@ -352,8 +515,15 @@ export type GitPushOutcome = { ok: true; hash: string } | { ok: false; error: st
352
515
  * Committing with a pathspec (`git commit -- <path>`) keeps unrelated staged files
353
516
  * out of the publish commit. Fails loud on branch/commit/push errors.
354
517
  */
355
- export async function pushPostGit(surfdashRoot: string, relPostDir: string, message: string): Promise<GitPushOutcome> {
356
- 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);
357
527
  }
358
528
  /** Refuse a checkout that cannot deploy before anything is written (`deploy.yml` builds `main` only). */
359
529
  async function assertMainBranch(surfdashRoot: string): Promise<{ ok: true } | { ok: false; error: string }> {
@@ -504,8 +674,20 @@ async function scaffoldLocale(options: SurfdashPubOptions): Promise<Result> {
504
674
  }
505
675
 
506
676
  try {
677
+ const force = options.force === true;
507
678
  const spawned = await spawnCli(
508
- ['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
+ ],
509
691
  surfdashRoot,
510
692
  );
511
693
  if (spawned.exitCode !== 0) {
@@ -659,6 +841,12 @@ async function patchPost(options: SurfdashPubOptions): Promise<Result> {
659
841
  commitPaths.add(relative(surfdashRoot, assetsDir));
660
842
  }
661
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
+ }
662
850
  const commit = await commitPostPaths(
663
851
  surfdashRoot,
664
852
  [...commitPaths],
@@ -693,95 +881,336 @@ async function patchPost(options: SurfdashPubOptions): Promise<Result> {
693
881
  });
694
882
  }
695
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
+
696
908
  /**
697
- * Publish path (behavior unchanged since E3): maps Content into surfdash markdown under
698
- * `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.
699
912
  */
700
- async function publishPost(payload: PublishTransportPayload): Promise<Result> {
701
- 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
+ }
702
950
 
703
- // The publishing FSM requires the markdown at
704
- // content/posts/<collection>/<locale>/<slug>/index.md inside the surfdash site tree
705
- // (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) };
706
961
  const surfdashRoot = resolveSurfdashRoot();
707
962
  if (!surfdashRoot) {
708
- 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
+ };
709
1074
  }
710
1075
 
711
- const locale = process.env.SURFDASH_LOCALE ?? 'zh';
712
- // Fail loud on legacy locale codes ('cn' is the pre-migration name for zh) —
713
- // they'd write a content dir no route serves.
714
- if (!['zh', 'en', 'ja'].includes(locale)) {
715
- 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}'`) };
716
1079
  }
717
- // Date-stamped slug (metadata.runDate, e.g. 20260904) keeps same-title daily posts
718
- // from colliding; Chinese titles slugify to '' and fall back to the bare date.
1080
+ const locale = rawLocale as SupportedLocale;
719
1081
  const runDate = typeof payload.content.metadata?.runDate === 'string' ? payload.content.metadata.runDate : '';
720
1082
  const today = new Date().toISOString().slice(0, 10).replaceAll('-', '');
721
1083
  const dateStamp = runDate || today;
722
1084
  const titleSlug = slugify(payload.content.title ?? '');
723
1085
  const slug = titleSlug ? `${dateStamp}-${titleSlug}` : `${dateStamp}-post`;
724
- // Validate before touching disk (R4): a `metadata.runDate` or slug that escapes the articles
725
- // tree would write a post outside it, and a non-image `coverImage` would be copied into a post
726
- // that pushPostGit then publishes to a public repo.
727
1086
  if (runDate !== '' && !/^\d{8}$/.test(runDate)) {
728
- return fail(`metadata.runDate must be YYYYMMDD, got '${runDate}'`);
1087
+ return { ok: false, result: failNotSent(`metadata.runDate must be YYYYMMDD, got '${runDate}'`) };
729
1088
  }
730
1089
  const articlesRoot = resolve(surfdashRoot, 'content', 'posts', 'articles');
731
1090
  const localeRoot = resolve(articlesRoot, locale);
732
1091
  const postDir = resolve(localeRoot, slug);
733
1092
  if (dirname(localeRoot) !== articlesRoot || dirname(postDir) !== localeRoot) {
734
- return fail(`post path escapes ${articlesRoot}`);
1093
+ return { ok: false, result: failNotSent(`post path escapes ${articlesRoot}`) };
735
1094
  }
1095
+
736
1096
  const coverPath = payload.content.metadata?.coverImage;
737
- // R4 + 2026-10-24: a coverImage that does not exist must fail before anything is written —
738
- // the old silent skip published a coverless post while the run believed it had one.
739
1097
  if (typeof coverPath === 'string' && !existsSync(coverPath)) {
740
- return fail(`metadata.coverImage not found: ${coverPath}`);
1098
+ return { ok: false, result: failNotSent(`metadata.coverImage not found: ${coverPath}`) };
741
1099
  }
742
1100
  if (typeof coverPath === 'string' && !COVER_EXTS.has(extname(coverPath).toLowerCase())) {
743
- return fail(`metadata.coverImage must be one of ${[...COVER_EXTS].join(', ')}`);
1101
+ return {
1102
+ ok: false,
1103
+ result: failNotSent(`metadata.coverImage must be one of ${[...COVER_EXTS].join(', ')}`),
1104
+ };
744
1105
  }
745
- // Daily digest posts are the series' public face: a missing cover ships a blank OG card.
746
- // The check uses the same tag sources as mapContentToSurfingMarkdown (contentTags).
747
1106
  if (isDigestContent(payload.content) && typeof coverPath !== 'string') {
748
- return fail('digest post has no cover image (metadata.coverImage)');
1107
+ return { ok: false, result: failNotSent('digest post has no cover image (metadata.coverImage)') };
749
1108
  }
750
1109
 
751
- await createNodeFileSystem().ensureDir(postDir);
752
-
753
- // Cover image: metadata.coverImage (absolute local path) is copied into the post's
754
- // assets dir and referenced relatively — the surfdash PostSchema accepts ./assets/<file>.
755
- let imageFrontmatter: string | undefined;
756
- if (typeof coverPath === 'string') {
757
- const assetsDir = join(postDir, 'assets');
758
- await createNodeFileSystem().ensureDir(assetsDir);
759
- const coverName = `cover${extname(coverPath).toLowerCase()}`;
760
- await copyFile(coverPath, join(assetsDir, coverName));
761
- 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
+ };
762
1133
  }
763
- const markdown = mapContentToSurfingMarkdown(payload.content, {
764
- image: imageFrontmatter,
765
- 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
+ },
766
1165
  });
767
- 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
+ : {};
768
1182
 
769
1183
  try {
770
- await createNodeFileSystem().writeFile(filePath, markdown);
771
1184
  const spawned = await spawnCli([postsurfingBin, 'publish', filePath], surfdashRoot);
772
1185
 
773
1186
  if (spawned.exitCode !== 0) {
774
- return fail(
1187
+ return failUncertain(
775
1188
  `postsurfing CLI failed (exit ${spawned.exitCode}): ${withEmdashTokenHint(cliErrorText(spawned))}`,
776
1189
  );
777
1190
  }
778
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
+
779
1208
  // Contract check (ADR-008): the site, not this plugin, owns SEO metadata.
780
1209
  const notRefined = requireRefined(filePath);
781
- if (notRefined) return fail(notRefined);
1210
+ if (notRefined) return failUncertain(notRefined);
782
1211
  // Digest contract (2026-10-24): the refined values themselves must hold, before any push.
783
- const digestProblem = digestCheckGate(filePath, locale as DigestLocale);
784
- if (digestProblem) return fail(digestProblem);
1212
+ const digestProblem = digestCheckGate(filePath, locale);
1213
+ if (digestProblem) return failUncertain(digestProblem);
785
1214
  const refined = readRefinedSnapshot(filePath);
786
1215
 
787
1216
  // Deploy seam: commit + push the post so deploy.yml builds it, then wait for
@@ -790,8 +1219,6 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
790
1219
  // URL shape mirrors surfdash's localePathPrefix (surfdash/src/lib/seo.ts):
791
1220
  // en is served at the site root, zh/ja carry a /<locale> prefix. Composing
792
1221
  // /<locale>/posts/<slug> unconditionally 404s en posts.
793
- const baseUrl = (process.env.SURFDASH_URL ?? 'https://surfing.salty.vip').replace(/\/$/, '');
794
- const articleUrl = `${baseUrl}${locale === 'en' ? '' : `/${locale}`}/posts/${slug}`;
795
1222
  if (process.env.SURFDASH_AUTO_PUSH === '0') {
796
1223
  return ResultSchema.parse({
797
1224
  ok: true,
@@ -800,12 +1227,26 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
800
1227
  message: 'Published via postsurfing CLI (auto-push disabled)',
801
1228
  // Absolute post path lets downstream steps (locale translation + CMS sync)
802
1229
  // derive the surfdash checkout root without extra configuration.
803
- 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
+ },
804
1236
  });
805
1237
  }
806
- const relPostDir = join('content', 'posts', 'articles', locale, slug);
807
- const pushed = await pushPostGit(surfdashRoot, relPostDir, `content: daily AI news ${dateStamp} (zh)`);
808
- 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);
809
1250
  const live = await awaitArticleLive(articleUrl);
810
1251
 
811
1252
  return ResultSchema.parse({
@@ -815,16 +1256,95 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
815
1256
  message: live
816
1257
  ? 'Published, committed, pushed, and live on the site'
817
1258
  : 'Published and pushed; URL not live within the verify window — check the surfdash deploy workflow',
818
- 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
+ },
819
1266
  });
820
1267
  } catch (err: unknown) {
821
- 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)}`);
1269
+ }
1270
+ }
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
+ );
822
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
+ });
823
1343
  }
824
1344
 
825
1345
  /**
826
1346
  * FileCli transport invoking postsurfing CLI for `publish` (default) and the
827
- * `scaffold-locale` / `sync` surfdash mechanics via `options.operation` (L1 R7).
1347
+ * prepared-path / `scaffold-locale` / `sync` surfdash mechanics via `options.operation` (L1 R7).
828
1348
  */
829
1349
  export class PostsurfingFileCliTransport implements PublishTransport {
830
1350
  public readonly kind = 'file-cli';
@@ -839,6 +1359,12 @@ export class PostsurfingFileCliTransport implements PublishTransport {
839
1359
  if ('error' in routed) return fail(routed.error);
840
1360
 
841
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);
842
1368
  case 'scaffold-locale':
843
1369
  return scaffoldLocale(options);
844
1370
  case 'sync':
@@ -865,10 +1391,9 @@ export function getPublishTransport(): PublishTransport {
865
1391
  /**
866
1392
  * Processes input Content and writes output Result via transport.
867
1393
  *
868
- * The input JSON may carry a top-level `options` object (`operation`, `postPath`,
869
- * `target`) alongside the Content fields; it is forwarded to the transport so
870
- * `kk executor run` callers can select scaffold-locale/sync without extra flags
871
- * (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).
872
1397
  */
873
1398
  export async function processPublishIO(
874
1399
  inputPath: string,