@gobing-ai/knowledge-kit 0.1.8 → 0.1.9

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 (30) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/board/collect/index.js +725 -1101
  3. package/dist/board/daily-voice/index.js +722 -101
  4. package/dist/board/itc/index.js +13 -0
  5. package/dist/index.js +1792 -102
  6. package/package.json +1 -1
  7. package/plugins/ingestions/horizon-ingest/dist/index.js +24 -1
  8. package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
  9. package/plugins/kk/config.example.yaml +8 -0
  10. package/plugins/kk/plugin.json +1 -1
  11. package/plugins/kk/publish-presets/daily-ai-voice.json +123 -0
  12. package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +20 -0
  13. package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +17 -0
  14. package/plugins/kk/scripts/daily-stages.ts +580 -78
  15. package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
  16. package/plugins/kk/scripts/topic-boundary.json +18 -83
  17. package/plugins/kk/workflows/kk-collect.yaml +2 -0
  18. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +75 -6
  19. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
  20. package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
  21. package/plugins/publishings/podcast-pub/README.md +65 -0
  22. package/plugins/publishings/podcast-pub/dist/index.js +1709 -274
  23. package/plugins/publishings/podcast-pub/plugin.json +1 -1
  24. package/plugins/publishings/podcast-pub/src/backfill.ts +465 -0
  25. package/plugins/publishings/podcast-pub/src/index.ts +211 -34
  26. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +32 -3
  27. package/plugins/publishings/podcast-pub/src/seo.ts +1081 -0
  28. package/plugins/publishings/podcast-pub/src/show-notes.ts +71 -6
  29. package/plugins/publishings/surfdash-pub/dist/index.js +66 -5
  30. package/plugins/publishings/surfdash-pub/src/index.ts +94 -10
@@ -12,6 +12,14 @@
12
12
  */
13
13
 
14
14
  import type { Doc } from '@gobing-ai/kk-core';
15
+ import {
16
+ type PlanItem,
17
+ type PodcastSeoConfig,
18
+ SEO_DEFAULTS,
19
+ showNotesSummaryParagraph,
20
+ type TimelineStyle,
21
+ warn,
22
+ } from './seo';
15
23
 
16
24
  /** Escapes text content and attribute values (`&`, `<`, `>`, `"`). */
17
25
  function escapeHtml(value: string): string {
@@ -133,7 +141,7 @@ function isGeneratedTimeline(block: string): boolean {
133
141
  * input. `ponytail`: no JS — timestamp deep-linking depends on the theme's player
134
142
  * API; add when microfeed exposes one.
135
143
  */
136
- function renderTimelineHtml(docs: Doc[], offsets: number[], articleUrl: string): string {
144
+ function renderTimelineHtml(docs: Doc[], offsets: number[], articleUrl: string, style: TimelineStyle): string {
137
145
  const rows = docs
138
146
  .map((d, i) => {
139
147
  const title = escapeHtml(d.title ?? `条目 ${i + 1}`);
@@ -152,11 +160,15 @@ function renderTimelineHtml(docs: Doc[], offsets: number[], articleUrl: string):
152
160
  const articleTail = isHttpUrl(articleUrl)
153
161
  ? `<p>完整图文版:<a href="${escapeHtml(articleUrl)}" target="_blank" rel="noopener">${escapeHtml('本期完整文章')}</a></p>`
154
162
  : `<p>${escapeHtml('本期完整文章')}</p>`;
163
+ const styleBlock = style === 'omit' ? '' : `${TIMELINE_STYLE}\n`;
164
+ // `after` (default) keeps the style inside the markers but after the list, so content_text
165
+ // does not open on CSS. `before` is the pre-D9 position, for a byte-identical legacy render.
166
+ const before = style === 'before' ? styleBlock : '';
167
+ const after = style === 'after' ? styleBlock : '';
155
168
  return `<!--kk:timeline-->
156
- ${TIMELINE_STYLE}
157
- <div class="kk-tl">${rows}
169
+ ${before}<div class="kk-tl">${rows}
158
170
  </div>
159
- ${articleTail}
171
+ ${after}${articleTail}
160
172
  <!--/kk:timeline-->`;
161
173
  }
162
174
 
@@ -277,6 +289,22 @@ export interface ShowNotesRenderOptions {
277
289
  * matched monotonically against segment transcriptions; otherwise `--:--`.
278
290
  */
279
291
  audioSegments?: Array<{ duration?: number; transcription?: string }>;
292
+ /** R4. Omit to keep the default (summary on). `false` reproduces the pre-D9 body. */
293
+ summaryParagraph?: boolean;
294
+ /** R4. `after` moves `<style>` below the timeline; `before` is the legacy position; `omit` drops it. */
295
+ timelineStyle?: TimelineStyle;
296
+ seriesName?: string;
297
+ showName?: string;
298
+ descriptionMin?: number;
299
+ descriptionMax?: number;
300
+ descriptionSource?: PodcastSeoConfig['descriptionSource'];
301
+ descriptionPattern?: string;
302
+ /** Playback seconds for the `(约 N 分钟)` clause. Segment durations are the fallback. */
303
+ durationSeconds?: number;
304
+ /** Link text for the summary cross-link. Falls back to 本期完整文章. */
305
+ surfingTitle?: string;
306
+ refinedDescription?: string;
307
+ summaryClauses?: string[];
280
308
  }
281
309
 
282
310
  /** Per-item 1–2-sentence briefing: `metadata.brief` first, body sentences as fallback. */
@@ -298,7 +326,7 @@ function itemBrief(d: Doc): string {
298
326
  * a `[来源:<label>](<sourceUri>)` link per item, and the full-article footer.
299
327
  * Downstream HTML rendering stays with {@link renderShowNotesHtml}.
300
328
  */
301
- export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOptions): string {
329
+ export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOptions, warnings?: string[]): string {
302
330
  const slugify = (t: string): string => {
303
331
  const s = t
304
332
  .toLowerCase()
@@ -323,6 +351,9 @@ export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOption
323
351
  postSlug.length > 0
324
352
  ? `${baseUrl}${localePrefix}/posts/${postSlug}`
325
353
  : `${baseUrl}/zh/posts/${opts.runDate}-${slugify(opts.title)}`;
354
+ if (opts.briefMode === true && postSlug.length === 0) {
355
+ warn('brief mode requested but ARTICLE_POST_PATH is empty; using legacy layout', warnings);
356
+ }
326
357
  const briefMode = opts.briefMode === true && postSlug.length > 0;
327
358
  const sourceLabel = (d: Doc): string => {
328
359
  const m = d.metadata ?? {};
@@ -335,10 +366,44 @@ export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOption
335
366
  };
336
367
  const offsets = computeAudioOffsets(docs, opts.audioSegments);
337
368
  const lines: string[] = [`# ${opts.title}`, ''];
369
+ const summaryOn = opts.summaryParagraph !== false;
370
+ if (summaryOn) {
371
+ const segmentSeconds = opts.audioSegments?.reduce((sum, segment) => sum + (segment.duration ?? 0), 0);
372
+ const durationSeconds =
373
+ opts.durationSeconds ?? (segmentSeconds && segmentSeconds > 0 ? segmentSeconds : undefined);
374
+ const plan: PlanItem[] = docs.map((doc) => {
375
+ const title = doc.title?.trim() ?? '';
376
+ const brief = itemBrief(doc);
377
+ return brief.length > 0 ? { title, brief } : { title };
378
+ });
379
+ const config: PodcastSeoConfig = {
380
+ ...SEO_DEFAULTS,
381
+ ...(opts.seriesName ? { seriesName: opts.seriesName } : {}),
382
+ ...(opts.showName ? { showName: opts.showName } : {}),
383
+ ...(opts.descriptionMin !== undefined ? { descriptionMin: opts.descriptionMin } : {}),
384
+ ...(opts.descriptionMax !== undefined ? { descriptionMax: opts.descriptionMax } : {}),
385
+ ...(opts.descriptionSource ? { descriptionSource: opts.descriptionSource } : {}),
386
+ ...(opts.descriptionPattern ? { descriptionPattern: opts.descriptionPattern } : {}),
387
+ summaryParagraph: true,
388
+ timelineStyle: opts.timelineStyle ?? 'after',
389
+ };
390
+ const summary = showNotesSummaryParagraph({
391
+ config,
392
+ runDate: opts.runDate,
393
+ ...(durationSeconds !== undefined ? { durationSeconds } : {}),
394
+ plan,
395
+ ...(opts.refinedDescription ? { refinedDescription: opts.refinedDescription } : {}),
396
+ ...(opts.summaryClauses ? { summaryClauses: opts.summaryClauses } : {}),
397
+ articleUrl,
398
+ linkText: opts.surfingTitle?.trim() || '本期完整文章',
399
+ });
400
+ for (const line of summary.warnings) warn(line.replace(/^WARN podcast-pub:\s*/, ''), warnings);
401
+ lines.push(summary.markdown, '');
402
+ }
338
403
  if (briefMode) {
339
404
  // Timeline layout (2026-09-16 feedback): real audio offsets left, title + briefing
340
405
  // right, article link last — the whole brief body is one pre-built HTML block.
341
- lines.push(renderTimelineHtml(docs, offsets, articleUrl), '');
406
+ lines.push(renderTimelineHtml(docs, offsets, articleUrl, opts.timelineStyle ?? 'after'), '');
342
407
  } else {
343
408
  docs.forEach((d, i) => {
344
409
  const body = (d.body ?? '').trim();
@@ -12473,15 +12473,12 @@ function mapContentToSurfingMarkdown(content, options = {}) {
12473
12473
  ...new Set(rawTags.filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => normalizeTag(t)))
12474
12474
  ].filter((tag) => tag.length > 0);
12475
12475
  const isoDate = options.publishDate ?? new Date().toISOString().slice(0, 10);
12476
- const firstRefUrl = content.references?.[0]?.url;
12477
- const canonical = typeof firstRefUrl === "string" ? firstRefUrl : typeof content.metadata?.canonicalUrl === "string" ? content.metadata.canonicalUrl : undefined;
12478
12476
  const draft = typeof content.metadata?.published === "boolean" ? !content.metadata.published : false;
12479
12477
  const frontmatterObj = {
12480
12478
  title,
12481
12479
  description: description ?? "",
12482
12480
  tags,
12483
12481
  publishDate: isoDate,
12484
- ...canonical ? { canonical } : {},
12485
12482
  draft,
12486
12483
  ...options.image ? { image: options.image, og_image: options.image } : {}
12487
12484
  };
@@ -12568,6 +12565,35 @@ function cliErrorText(spawned) {
12568
12565
  function withEmdashTokenHint(errText) {
12569
12566
  return /INVALID_TOKEN|API error 401/.test(errText) ? `${errText} \u2014 hint: regenerate the EmDash token (surfing.salty.vip admin console) and update EMDASH_API_TOKEN in ~/.config/kk/config.yaml` : errText;
12570
12567
  }
12568
+ function readRefinedSnapshot(filePath) {
12569
+ if (!existsSync2(filePath))
12570
+ return;
12571
+ const raw = readFileSync2(filePath, "utf-8");
12572
+ const block = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
12573
+ const fm = block?.[1] ?? "";
12574
+ if (!fm || !/^refined:\s*true$/m.test(fm))
12575
+ return;
12576
+ const read = (key) => {
12577
+ const m = fm.match(new RegExp(`^${key}:\\s*(?:>-?
12578
+ ((?:[ \\t]+.*
12579
+ ?)+)|(.*))$`, "m"));
12580
+ if (!m)
12581
+ return;
12582
+ const value = (m[1] ?? m[2] ?? "").replace(/\n[ \t]+/g, " ").trim();
12583
+ return value.replace(/^["']|["']$/g, "") || undefined;
12584
+ };
12585
+ return {
12586
+ title: read("title"),
12587
+ seoTitle: read("seo_title"),
12588
+ description: read("description"),
12589
+ canonical: read("canonical")
12590
+ };
12591
+ }
12592
+ function requireRefined(filePath) {
12593
+ if (readRefinedSnapshot(filePath))
12594
+ return;
12595
+ return `surfdash refine step did not run for ${filePath} (no \`refined: true\`): the publishing pipeline is broken \u2014 the post would ship with generator-supplied SEO metadata`;
12596
+ }
12571
12597
  async function pushPostGit(surfdashRoot, relPostDir, message) {
12572
12598
  return commitPostPaths(surfdashRoot, [relPostDir], message);
12573
12599
  }
@@ -12584,12 +12610,40 @@ async function assertMainBranch(surfdashRoot) {
12584
12610
  }
12585
12611
  return { ok: true };
12586
12612
  }
12613
+ async function fastForwardToOrigin(surfdashRoot) {
12614
+ const fetched = await spawnCli(["git", "fetch", "--quiet", "origin", "main"], surfdashRoot);
12615
+ if (fetched.exitCode !== 0)
12616
+ return { ok: true };
12617
+ const counts = await spawnCli(["git", "rev-list", "--left-right", "--count", "origin/main...HEAD"], surfdashRoot);
12618
+ if (counts.exitCode !== 0)
12619
+ return { ok: true };
12620
+ const [behindRaw = "0", aheadRaw = "0"] = counts.stdout.trim().split(/\s+/);
12621
+ const behind = Number(behindRaw);
12622
+ const ahead = Number(aheadRaw);
12623
+ if (!Number.isFinite(behind) || !Number.isFinite(ahead) || behind === 0)
12624
+ return { ok: true };
12625
+ if (ahead > 0) {
12626
+ return {
12627
+ ok: false,
12628
+ error: `surfdash checkout diverged from origin/main (${ahead} local, ${behind} remote commit(s)): reconcile it by hand before publishing`
12629
+ };
12630
+ }
12631
+ const merged = await spawnCli(["git", "merge", "--ff-only", "--quiet", "origin/main"], surfdashRoot);
12632
+ if (merged.exitCode !== 0) {
12633
+ return { ok: false, error: `surfdash fast-forward to origin/main failed: ${cliErrorText(merged)}` };
12634
+ }
12635
+ echoError(`surfdash: fast-forwarded ${behind} commit(s) to origin/main before writing`);
12636
+ return { ok: true };
12637
+ }
12587
12638
  async function commitPostPaths(surfdashRoot, relPaths, message, push = true) {
12588
12639
  if (relPaths.length === 0)
12589
12640
  return { ok: true, hash: "unchanged" };
12590
12641
  const branch = await assertMainBranch(surfdashRoot);
12591
12642
  if (!branch.ok)
12592
12643
  return branch;
12644
+ const base = await fastForwardToOrigin(surfdashRoot);
12645
+ if (!base.ok)
12646
+ return base;
12593
12647
  const add = await spawnCli(["git", "add", "--", ...relPaths], surfdashRoot);
12594
12648
  if (add.exitCode !== 0)
12595
12649
  return { ok: false, error: `surfdash git add failed: ${cliErrorText(add)}` };
@@ -12843,6 +12897,10 @@ async function publishPost(payload) {
12843
12897
  if (spawned.exitCode !== 0) {
12844
12898
  return fail(`postsurfing CLI failed (exit ${spawned.exitCode}): ${withEmdashTokenHint(cliErrorText(spawned))}`);
12845
12899
  }
12900
+ const notRefined = requireRefined(filePath);
12901
+ if (notRefined)
12902
+ return fail(notRefined);
12903
+ const refined = readRefinedSnapshot(filePath);
12846
12904
  const baseUrl = (process.env.SURFDASH_URL ?? "https://surfing.salty.vip").replace(/\/$/, "");
12847
12905
  const articleUrl = `${baseUrl}${locale === "en" ? "" : `/${locale}`}/posts/${slug}`;
12848
12906
  if (process.env.SURFDASH_AUTO_PUSH === "0") {
@@ -12851,7 +12909,7 @@ async function publishPost(payload) {
12851
12909
  target: TARGET,
12852
12910
  url: articleUrl,
12853
12911
  message: "Published via postsurfing CLI (auto-push disabled)",
12854
- metadata: { postPath: filePath }
12912
+ metadata: { postPath: filePath, refinedTitle: refined?.title, refinedCanonical: refined?.canonical }
12855
12913
  });
12856
12914
  }
12857
12915
  const relPostDir = join2("content", "posts", "articles", locale, slug);
@@ -12864,7 +12922,7 @@ async function publishPost(payload) {
12864
12922
  target: TARGET,
12865
12923
  url: articleUrl,
12866
12924
  message: live ? "Published, committed, pushed, and live on the site" : "Published and pushed; URL not live within the verify window \u2014 check the surfdash deploy workflow",
12867
- metadata: { postPath: filePath, gitCommit: pushed.hash, live }
12925
+ metadata: { postPath: filePath, gitCommit: pushed.hash, live, refinedTitle: refined?.title }
12868
12926
  });
12869
12927
  } catch (err) {
12870
12928
  return fail(`Postsurfing transport error: ${err instanceof Error ? err.message : String(err)}`);
@@ -12944,6 +13002,7 @@ export {
12944
13002
  PostsurfingFileCliTransport,
12945
13003
  awaitArticleLive,
12946
13004
  commitPostPaths,
13005
+ fastForwardToOrigin,
12947
13006
  getPublishTransport,
12948
13007
  main2 as main,
12949
13008
  mapContentToSurfingMarkdown,
@@ -12951,6 +13010,8 @@ export {
12951
13010
  normalizeTag,
12952
13011
  processPublishIO,
12953
13012
  pushPostGit,
13013
+ readRefinedSnapshot,
13014
+ requireRefined,
12954
13015
  slugify2 as slugify,
12955
13016
  withEmdashTokenHint
12956
13017
  };
@@ -120,23 +120,22 @@ export function mapContentToSurfingMarkdown(content: Content, options: MapConten
120
120
  ].filter((tag) => tag.length > 0);
121
121
 
122
122
  const isoDate = options.publishDate ?? new Date().toISOString().slice(0, 10);
123
- const firstRefUrl = content.references?.[0]?.url;
124
- const canonical =
125
- typeof firstRefUrl === 'string'
126
- ? firstRefUrl
127
- : typeof content.metadata?.canonicalUrl === 'string'
128
- ? content.metadata.canonicalUrl
129
- : undefined;
130
123
  const draft = typeof content.metadata?.published === 'boolean' ? !content.metadata.published : false;
131
124
 
132
125
  // Strict surfdash PostSchema (surfdash/src/content/schema.ts): publishDate (ISO date) is
133
126
  // required; unknown keys (`date`, `canonical_url`, `published`) are rejected.
127
+ //
128
+ // `canonical` is deliberately NOT written here. The canonical URL is the site's own route
129
+ // for this slug, which only the site can compute (locale prefix + collection segment); the
130
+ // previous `references[0].url` fallback pointed the canonical at an unrelated *external*
131
+ // source and would have dropped the post out of the sitemap if surfdash's refine step had
132
+ // not overwritten it. See docs/00_ADR.md ADR-008: the generator supplies content, the site
133
+ // supplies identity.
134
134
  const frontmatterObj: Record<string, unknown> = {
135
135
  title,
136
136
  description: description ?? '',
137
137
  tags,
138
138
  publishDate: isoDate,
139
- ...(canonical ? { canonical } : {}),
140
139
  draft,
141
140
  ...(options.image ? { image: options.image, og_image: options.image } : {}),
142
141
  };
@@ -273,6 +272,49 @@ export function withEmdashTokenHint(errText: string): string {
273
272
  : errText;
274
273
  }
275
274
 
275
+ /**
276
+ * Post-conditions the site guarantees after its publishing pipeline ran.
277
+ *
278
+ * The publisher does not derive SEO metadata any more (ADR-008): surfdash's refine step owns
279
+ * `refined`, `seo_title`, `canonical` and the digest title/description. Verifying the marker
280
+ * here turns a silently-regressed site pipeline into a loud publisher failure instead of a post
281
+ * that ships with a date-only title — which is exactly how the 2026-10-21 regression went
282
+ * unnoticed.
283
+ */
284
+ export interface RefinedSnapshot {
285
+ title?: string;
286
+ seoTitle?: string;
287
+ description?: string;
288
+ canonical?: string;
289
+ }
290
+
291
+ /** Reads the frontmatter scalar fields the site owns; undefined when refine never ran. */
292
+ export function readRefinedSnapshot(filePath: string): RefinedSnapshot | undefined {
293
+ if (!existsSync(filePath)) return undefined;
294
+ const raw = readFileSync(filePath, 'utf-8');
295
+ const block = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
296
+ const fm = block?.[1] ?? '';
297
+ if (!fm || !/^refined:\s*true$/m.test(fm)) return undefined;
298
+ const read = (key: string): string | undefined => {
299
+ const m = fm.match(new RegExp(`^${key}:\\s*(?:>-?\n((?:[ \\t]+.*\n?)+)|(.*))$`, 'm'));
300
+ if (!m) return undefined;
301
+ const value = (m[1] ?? m[2] ?? '').replace(/\n[ \t]+/g, ' ').trim();
302
+ return value.replace(/^["']|["']$/g, '') || undefined;
303
+ };
304
+ return {
305
+ title: read('title'),
306
+ seoTitle: read('seo_title'),
307
+ description: read('description'),
308
+ canonical: read('canonical'),
309
+ };
310
+ }
311
+
312
+ /** Error text when the site pipeline did not mark the post refined, else undefined. */
313
+ export function requireRefined(filePath: string): string | undefined {
314
+ if (readRefinedSnapshot(filePath)) return undefined;
315
+ 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
+ }
317
+
276
318
  /** Outcome of {@link pushPostGit} — the union keeps fail-loud errors typed at the call site. */
277
319
  export type GitPushOutcome = { ok: true; hash: string } | { ok: false; error: string };
278
320
 
@@ -300,6 +342,41 @@ async function assertMainBranch(surfdashRoot: string): Promise<{ ok: true } | {
300
342
  return { ok: true };
301
343
  }
302
344
 
345
+ /**
346
+ * Fast-forward the checkout onto `origin/main` before anything is written (2026-10-22).
347
+ *
348
+ * Three consecutive publish days failed with `! [rejected] main -> main (fetch first)`: this
349
+ * checkout sat behind a remote another machine had pushed to, so the publish commit landed on a
350
+ * stale base and the push was rejected — after the article had already been written and committed,
351
+ * and after the podcast episode had already published, so the run closed `partial`/`failed` while a
352
+ * real episode was live. A fast-forward is lossless (HEAD moves only when local carries no commit of
353
+ * its own), which is exactly the case here; a DIVERGED checkout is the operator's call and fails
354
+ * loud with both counts. An unreachable remote is not fatal: the fetch error is swallowed so the
355
+ * push below reports the real reason.
356
+ */
357
+ export async function fastForwardToOrigin(surfdashRoot: string): Promise<{ ok: true } | { ok: false; error: string }> {
358
+ const fetched = await spawnCli(['git', 'fetch', '--quiet', 'origin', 'main'], surfdashRoot);
359
+ if (fetched.exitCode !== 0) return { ok: true };
360
+ const counts = await spawnCli(['git', 'rev-list', '--left-right', '--count', 'origin/main...HEAD'], surfdashRoot);
361
+ if (counts.exitCode !== 0) return { ok: true };
362
+ const [behindRaw = '0', aheadRaw = '0'] = counts.stdout.trim().split(/\s+/);
363
+ const behind = Number(behindRaw);
364
+ const ahead = Number(aheadRaw);
365
+ if (!Number.isFinite(behind) || !Number.isFinite(ahead) || behind === 0) return { ok: true };
366
+ if (ahead > 0) {
367
+ return {
368
+ ok: false,
369
+ error: `surfdash checkout diverged from origin/main (${ahead} local, ${behind} remote commit(s)): reconcile it by hand before publishing`,
370
+ };
371
+ }
372
+ const merged = await spawnCli(['git', 'merge', '--ff-only', '--quiet', 'origin/main'], surfdashRoot);
373
+ if (merged.exitCode !== 0) {
374
+ return { ok: false, error: `surfdash fast-forward to origin/main failed: ${cliErrorText(merged)}` };
375
+ }
376
+ echoError(`surfdash: fast-forwarded ${behind} commit(s) to origin/main before writing`);
377
+ return { ok: true };
378
+ }
379
+
303
380
  /**
304
381
  * Commits several paths of the surfdash checkout and pushes them to `main` — the plural form of
305
382
  * {@link pushPostGit} the post-patch needs (it writes the posts and any copied cover). Same
@@ -315,6 +392,8 @@ export async function commitPostPaths(
315
392
  if (relPaths.length === 0) return { ok: true, hash: 'unchanged' };
316
393
  const branch = await assertMainBranch(surfdashRoot);
317
394
  if (!branch.ok) return branch;
395
+ const base = await fastForwardToOrigin(surfdashRoot);
396
+ if (!base.ok) return base;
318
397
  const add = await spawnCli(['git', 'add', '--', ...relPaths], surfdashRoot);
319
398
  if (add.exitCode !== 0) return { ok: false, error: `surfdash git add failed: ${cliErrorText(add)}` };
320
399
  const staged = await spawnCli(['git', 'diff', '--cached', '--quiet', '--', ...relPaths], surfdashRoot);
@@ -653,6 +732,11 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
653
732
  );
654
733
  }
655
734
 
735
+ // Contract check (ADR-008): the site, not this plugin, owns SEO metadata.
736
+ const notRefined = requireRefined(filePath);
737
+ if (notRefined) return fail(notRefined);
738
+ const refined = readRefinedSnapshot(filePath);
739
+
656
740
  // Deploy seam: commit + push the post so deploy.yml builds it, then wait for
657
741
  // the live URL — downstream show-notes advertise a real page, not a 404.
658
742
  // Tests (and offline runs) opt out via SURFDASH_AUTO_PUSH=0.
@@ -669,7 +753,7 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
669
753
  message: 'Published via postsurfing CLI (auto-push disabled)',
670
754
  // Absolute post path lets downstream steps (locale translation + CMS sync)
671
755
  // derive the surfdash checkout root without extra configuration.
672
- metadata: { postPath: filePath },
756
+ metadata: { postPath: filePath, refinedTitle: refined?.title, refinedCanonical: refined?.canonical },
673
757
  });
674
758
  }
675
759
  const relPostDir = join('content', 'posts', 'articles', locale, slug);
@@ -684,7 +768,7 @@ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
684
768
  message: live
685
769
  ? 'Published, committed, pushed, and live on the site'
686
770
  : 'Published and pushed; URL not live within the verify window — check the surfdash deploy workflow',
687
- metadata: { postPath: filePath, gitCommit: pushed.hash, live },
771
+ metadata: { postPath: filePath, gitCommit: pushed.hash, live, refinedTitle: refined?.title },
688
772
  });
689
773
  } catch (err: unknown) {
690
774
  return fail(`Postsurfing transport error: ${err instanceof Error ? err.message : String(err)}`);