@gobing-ai/knowledge-kit 0.0.31 → 0.0.33

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 (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +32320 -15021
  3. package/package.json +4 -4
  4. package/plugins/generations/content-gen/dist/index.js +10 -9
  5. package/plugins/generations/core-facts-gen/dist/index.js +13 -12
  6. package/plugins/generations/daily-article-gen/dist/index.js +14 -13
  7. package/plugins/generations/daily-article-gen/src/index.ts +1 -1
  8. package/plugins/generations/dailynews-gen/dist/index.js +10 -9
  9. package/plugins/generations/episode-plan-gen/dist/index.js +58 -15
  10. package/plugins/generations/episode-plan-gen/src/index.ts +76 -6
  11. package/plugins/generations/gemini-tts/README.md +11 -0
  12. package/plugins/generations/gemini-tts/dist/index.js +10 -9
  13. package/plugins/generations/image-gen/dist/index.js +154 -151
  14. package/plugins/generations/image-gen/src/compose/chromium.ts +60 -42
  15. package/plugins/generations/news-report-gen/dist/index.js +13 -12
  16. package/plugins/generations/voice-gen/dist/index.js +10 -9
  17. package/plugins/ingestions/aihot-ingest/dist/index.js +13 -12
  18. package/plugins/ingestions/horizon-ingest/dist/index.js +13 -12
  19. package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
  20. package/plugins/ingestions/last30days-ingest/dist/index.js +13 -12
  21. package/plugins/ingestions/web-search/dist/index.js +11 -10
  22. package/plugins/kk/README.md +38 -0
  23. package/plugins/kk/decisions/extract/ai-news.yaml +21 -0
  24. package/plugins/kk/decisions/kk-items.yaml +113 -0
  25. package/plugins/kk/plugin.json +1 -1
  26. package/plugins/kk/schedules/cron.tmpl +15 -0
  27. package/plugins/kk/schedules/launchd.plist.tmpl +77 -0
  28. package/plugins/kk/scripts/daily-stages.ts +1533 -0
  29. package/plugins/kk/scripts/kk-workflow-stages.ts +268 -1228
  30. package/plugins/kk/skills/content-judge/SKILL.md +12 -2
  31. package/plugins/kk/skills/content-judge/references/rubrics.md +8 -2
  32. package/plugins/kk/skills/content-safety/SKILL.md +60 -18
  33. package/plugins/kk/skills/web-automation/SKILL.md +100 -0
  34. package/plugins/kk/skills/web-automation/references/browser-launch.md +86 -0
  35. package/plugins/kk/workflows/kk-collect.yaml +258 -0
  36. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +74 -11
  37. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +655 -0
  38. package/plugins/kk/workflows/kk-daily-safety.yaml +170 -0
  39. package/plugins/publishings/emdash-pub/dist/index.js +10 -9
  40. package/plugins/publishings/infoq-pub/dist/index.js +8695 -211
  41. package/plugins/publishings/infoq-pub/package.json +3 -1
  42. package/plugins/publishings/infoq-pub/plugin.json +2 -1
  43. package/plugins/publishings/infoq-pub/src/index.ts +358 -29
  44. package/plugins/publishings/infoq-pub/src/profile-check.ts +21 -0
  45. package/plugins/publishings/juejin-pub/dist/index.js +8746 -211
  46. package/plugins/publishings/juejin-pub/package.json +3 -1
  47. package/plugins/publishings/juejin-pub/plugin.json +2 -1
  48. package/plugins/publishings/juejin-pub/src/index.ts +345 -29
  49. package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
  50. package/plugins/publishings/medium-pub/dist/index.js +10 -9
  51. package/plugins/publishings/podcast-pub/dist/index.js +10 -9
  52. package/plugins/publishings/qiita-pub/dist/index.js +10 -9
  53. package/plugins/publishings/substack-pub/dist/index.js +8687 -211
  54. package/plugins/publishings/substack-pub/package.json +3 -1
  55. package/plugins/publishings/substack-pub/plugin.json +2 -1
  56. package/plugins/publishings/substack-pub/src/index.ts +302 -29
  57. package/plugins/publishings/substack-pub/src/profile-check.ts +76 -0
  58. package/plugins/publishings/surfdash-pub/dist/index.js +10 -9
  59. package/plugins/publishings/wechatmp-pub/dist/index.js +1315 -352
  60. package/plugins/publishings/wechatmp-pub/plugin.json +1 -0
  61. package/plugins/publishings/wechatmp-pub/src/enrich.ts +229 -0
  62. package/plugins/publishings/wechatmp-pub/src/flow.ts +896 -0
  63. package/plugins/publishings/wechatmp-pub/src/index.ts +223 -520
  64. package/plugins/publishings/wechatmp-pub/src/modes.ts +403 -0
  65. package/plugins/publishings/wechatmp-pub/src/page.ts +260 -0
  66. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +84 -38
  67. package/plugins/publishings/x-pub/dist/index.js +8707 -223
  68. package/plugins/publishings/x-pub/package.json +3 -1
  69. package/plugins/publishings/x-pub/plugin.json +2 -1
  70. package/plugins/publishings/x-pub/src/index.ts +315 -23
  71. package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
  72. package/plugins/publishings/xhs-pub/dist/index.js +897 -354
  73. package/plugins/publishings/xhs-pub/plugin.json +2 -1
  74. package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
  75. package/plugins/publishings/xhs-pub/src/index.ts +108 -596
  76. package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
  77. package/plugins/publishings/xhs-pub/src/page.ts +254 -0
  78. package/plugins/publishings/xhs-pub/src/profile-check.ts +29 -17
  79. package/plugins/publishings/zenn-pub/dist/index.js +10 -9
@@ -7,9 +7,11 @@
7
7
  },
8
8
  "dependencies": {
9
9
  "@gobing-ai/kk-core": "workspace:*",
10
+ "@gobing-ai/ts-browser-automation": "catalog:",
10
11
  "@gobing-ai/publish-harness": "workspace:*",
11
12
  "@gobing-ai/ts-runtime": "catalog:",
12
- "@gobing-ai/ts-utils": "catalog:"
13
+ "@gobing-ai/ts-utils": "catalog:",
14
+ "playwright": "catalog:"
13
15
  },
14
16
  "devDependencies": {
15
17
  "@types/bun": "1.3.14"
@@ -2,6 +2,7 @@
2
2
  "name": "substack-pub",
3
3
  "kind": "publish",
4
4
  "entry": "./dist/index.js",
5
+ "check": "./src/profile-check.ts",
5
6
  "version": "1.0.0",
6
- "description": "Publish plugin for Substack via browser/agent transport"
7
+ "description": "Publish plugin for Substack (substack.com): post drafts through the ProseMirror editor via script browser transport (ts-browser-automation)"
7
8
  }
@@ -6,63 +6,336 @@ import {
6
6
  FakeBrowserTransport,
7
7
  type PublishTransport,
8
8
  type PublishTransportPayload,
9
- resolveBrowserProfileDir,
9
+ trySelectors,
10
10
  } from '@gobing-ai/publish-harness';
11
+ import {
12
+ type ChannelBrowserConfig,
13
+ checkChannelSession,
14
+ expectOutcome,
15
+ kkBrowserLauncher,
16
+ markdownToHtml,
17
+ pasteHtml,
18
+ resolveChannelProfileDir,
19
+ resolveSelector,
20
+ } from '@gobing-ai/publish-harness/browser';
21
+ import {
22
+ type BrowserLauncher,
23
+ BrowserProfileMissingError,
24
+ type IsAuthenticatedPredicate,
25
+ LoginTimeoutError,
26
+ withProfilePage,
27
+ } from '@gobing-ai/ts-browser-automation';
11
28
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
12
29
  import { echoError } from '@gobing-ai/ts-utils';
30
+ import type { Page } from 'playwright';
13
31
 
14
- const CHANNEL = 'substack';
32
+ /** Channel slug: names the Result target and resolves the `SUBSTACK_PROFILE_DIR` tier. */
33
+ export const CHANNEL = 'substack';
15
34
  const TARGET = 'substack';
35
+ /** Login/account URLs that mean "not authenticated" — the editor never renders behind them. */
36
+ export const LOGIN_URL_RE = /(\/sign-in|substack\.com\/account\/login)/;
37
+
38
+ /**
39
+ * Selectors seeded from the wt script (`substack-playwright.ts:84-136`); each list is tried in
40
+ * order. Measured live 2026-10-07 (0193): title `textarea[placeholder="Title"]` and body
41
+ * `.ProseMirror[contenteditable="true"]` both hit, harness paste verified; both subtitle
42
+ * candidates miss — the current editor UI has no subtitle field, so they are kept as
43
+ * last-known and only resolve when `metadata.subtitle` is set. `send` is Substack's
44
+ * `Send to everyone now` — it emails every subscriber — so both live controls (E91-D5) are
45
+ * reached only from the `public` branch, after the draft branch has already returned.
46
+ */
47
+ export const SELECTORS = {
48
+ title: ['[data-testid="title-input"]', 'textarea[placeholder="Title"]', 'input[placeholder="Title"]'],
49
+ subtitle: ['[data-testid="subtitle-input"]', 'textarea[placeholder="Add a subtitle..."]'],
50
+ body: ['.ProseMirror[contenteditable="true"]'],
51
+ continue: ['[data-testid="continue-button"]', 'button:has-text("Continue")'],
52
+ send: [
53
+ '[data-testid="publish-button"]',
54
+ 'button:has-text("Send to everyone now")',
55
+ 'button:has-text("Publish now")',
56
+ ],
57
+ } as const;
58
+
59
+ const STEP_TIMEOUT_MS = 30_000;
60
+ const LOGIN_TIMEOUT_MS = 180_000;
61
+ /** Operator recovery command every session failure names — never a cookie, page HTML or stack. */
62
+ const LOGIN_INSTRUCTION = 'bun plugins/publishings/substack-pub/src/profile-check.ts login';
63
+ /** Substack autosaves and rewrites the editor URL onto `/publish/post/<id>`, the whole draft signal. */
64
+ const DRAFT_URL_RE = /\/publish\/post\/(\d+)/;
65
+ /** A live post lands on `/p/<slug>`; the publish queue is `/publish/posts/published`. */
66
+ const LIVE_URL_RE = /\/p\/[^/?#]+|\/publish\/posts\/published/;
67
+ /** Both places the publication URL can come from, named as one thing in every refusal. */
68
+ const PUBLICATION_URL_SOURCES = 'SUBSTACK_PUBLICATION_URL (or --publication <url>)';
69
+
70
+ /**
71
+ * The publication's post editor: `${base}/publish/post`, where the base is the publication URL
72
+ * without its trailing `/` or `/publish` (seed `substack-playwright.ts:237-244`). The legacy
73
+ * `/publish/post/new` route autosaves locally ("Saved") but never becomes a post entity, so the
74
+ * URL never rewrites to `/publish/post/<id>` and the draft signal never fires — measured live
75
+ * 2026-10-07 (0193 A/B: `new` no rewrite, bare route rewrote to `/publish/post/219348810`).
76
+ */
77
+ export function editorUrl(publicationUrl: string): string {
78
+ const base = publicationUrl
79
+ .trim()
80
+ .replace(/\/$/, '')
81
+ .replace(/\/publish$/, '');
82
+ return `${base}/publish/post`;
83
+ }
84
+
85
+ /**
86
+ * Resolves and validates the run's publication URL (R1, R5): `explicit` — a transport
87
+ * `options.publicationUrl` or the CLI's `--publication` — wins over `SUBSTACK_PUBLICATION_URL`,
88
+ * and a blank explicit value stays blank rather than falling through to the environment. Returns
89
+ * the failure message naming both sources when the tier is unset or is not an `https:` URL, so a
90
+ * publish run and a profile CLI refuse a bad URL identically, before any browser opens.
91
+ */
92
+ export function resolvePublicationUrl(explicit?: string): { url: string } | { error: string } {
93
+ const trimmed = (explicit ?? process.env.SUBSTACK_PUBLICATION_URL)?.trim() ?? '';
94
+ if (trimmed.length === 0) {
95
+ return { error: `substack publication URL is required: set ${PUBLICATION_URL_SOURCES}` };
96
+ }
97
+ let parsed: URL;
98
+ try {
99
+ parsed = new URL(trimmed);
100
+ } catch {
101
+ return { error: `substack publication URL is not a URL: '${trimmed}' — set ${PUBLICATION_URL_SOURCES}` };
102
+ }
103
+ if (parsed.protocol !== 'https:') {
104
+ return { error: `substack publication URL must be https:, got '${trimmed}' — set ${PUBLICATION_URL_SOURCES}` };
105
+ }
106
+ // Nested editor paths are rejected (residual 0197): `…/publish/posts` used to pass validation
107
+ // and then be mangled by `editorUrl` (`…/publish/posts/publish/post`), so a misconfigured
108
+ // run reported an expired session instead of a configuration error. A trailing `/publish` is
109
+ // accepted and stripped by `editorUrl` — that is the documented shape.
110
+ const trimmedPath = parsed.pathname.replace(/\/+$/, '');
111
+ if (trimmedPath.length > 0 && trimmedPath !== '/publish') {
112
+ return {
113
+ error: `substack publication URL must be the publication root, optionally with a trailing /publish (e.g. https://name.substack.com), got '${trimmed}' — set ${PUBLICATION_URL_SOURCES}`,
114
+ };
115
+ }
116
+ return { url: trimmed };
117
+ }
118
+
119
+ /**
120
+ * Substack-owned readiness predicate: the editor is reachable (no login redirect) and the title
121
+ * field is attached. Shared by the publish preflight and the profile-session CLI (R1, R5).
122
+ */
123
+ export const isAuthenticated: IsAuthenticatedPredicate = async (page) =>
124
+ !LOGIN_URL_RE.test(page.url()) && (await trySelectors(page, SELECTORS.title, { visible: false })).found;
125
+
126
+ /** Site-owned config for the shared session check and operator CLI (0183). */
127
+ export function channelConfig(publicationUrl: string): ChannelBrowserConfig {
128
+ return {
129
+ channel: CHANNEL,
130
+ homeUrl: editorUrl(publicationUrl),
131
+ isAuthenticated,
132
+ loginTimeoutMs: LOGIN_TIMEOUT_MS,
133
+ };
134
+ }
16
135
 
17
136
  function fail(error: string): Result {
18
137
  return ResultSchema.parse({ ok: false, target: TARGET, error });
19
138
  }
20
139
 
21
- function draftId(content: Content): string {
22
- const title = content.title ?? 'untitled';
23
- return `${CHANNEL}-draft-${title.length}`;
140
+ function errorMessage(error: unknown): string {
141
+ return error instanceof Error ? error.message : String(error);
24
142
  }
25
143
 
26
- function draftResult(content: Content, driver: BrowserDriver, profileDir: string): Result {
27
- return ResultSchema.parse({
28
- ok: true,
29
- target: TARGET,
30
- id: draftId(content),
31
- message: `${TARGET} draft prepared (unpublished)`,
32
- metadata: { driver, publishStatus: 'draft', profileDir },
33
- });
144
+ /** Session failures whose one-liner must name the recovery command (R5). */
145
+ function needsLogin(error: unknown): boolean {
146
+ return error instanceof LoginTimeoutError || error instanceof BrowserProfileMissingError;
147
+ }
148
+
149
+ /**
150
+ * Declared modes (ADR-029 dispatch; task 0205 R2): Substack’s console flow is the publication-scoped post draft. An unknown operation is refused by
151
+ * name before a profile is resolved or a browser opens.
152
+ */
153
+ export const SUBSTACK_OPERATIONS = ['post'] as const;
154
+ export type SubstackOperation = (typeof SUBSTACK_OPERATIONS)[number];
155
+
156
+ /** Exit-early resolution of the requested operation; a default Content without one resolves to `post`. */
157
+ export function resolveOperation(
158
+ options?: Record<string, unknown>,
159
+ content?: Content,
160
+ ): { operation: SubstackOperation; error?: undefined } | { operation?: undefined; error: string } {
161
+ const raw = options?.operation ?? content?.metadata?.operation;
162
+ if (raw === undefined) return { operation: 'post' };
163
+ if (typeof raw !== 'string' || !(SUBSTACK_OPERATIONS as readonly string[]).includes(raw)) {
164
+ return {
165
+ error: `unsupported operation '${String(raw)}' for substack: expected one of ${SUBSTACK_OPERATIONS.join(', ')}`,
166
+ };
167
+ }
168
+ return { operation: raw as SubstackOperation };
34
169
  }
35
170
 
36
171
  export class SubstackBrowserTransport implements PublishTransport {
37
172
  public readonly kind = 'browser';
38
173
  public readonly driver: BrowserDriver;
39
- private readonly loginTimeoutMs: number;
40
- private readonly sessionReady: boolean;
174
+ private readonly launcher: BrowserLauncher;
175
+ private readonly stepTimeoutMs: number;
41
176
 
42
- constructor(driver: BrowserDriver = 'agent', opts?: { loginTimeoutMs?: number; sessionReady?: boolean }) {
177
+ constructor(driver: BrowserDriver = 'script', deps: { launcher?: BrowserLauncher; stepTimeoutMs?: number } = {}) {
43
178
  this.driver = driver;
44
- this.loginTimeoutMs = opts?.loginTimeoutMs ?? 120_000;
45
- this.sessionReady = opts?.sessionReady ?? false;
179
+ this.launcher = deps.launcher ?? kkBrowserLauncher;
180
+ this.stepTimeoutMs = deps.stepTimeoutMs ?? STEP_TIMEOUT_MS;
46
181
  }
47
182
 
48
183
  public async publish(payload: PublishTransportPayload): Promise<Result> {
49
- const override =
50
- typeof payload.options?.profileDir === 'string'
51
- ? payload.options.profileDir
52
- : (process.env.SUBSTACK_PROFILE_DIR ?? process.env.KK_BROWSER_PROFILE_DIR);
53
- const profileDir = resolveBrowserProfileDir(CHANNEL, override);
54
- if (!this.sessionReady) {
55
- return fail(
56
- `login timeout after ${this.loginTimeoutMs}ms: no authenticated ${CHANNEL} session in ${profileDir}`,
184
+ if (this.driver !== 'script') {
185
+ return fail(`unsupported driver '${this.driver}' for ${TARGET}`);
186
+ }
187
+ const mode = resolveOperation(payload.options, payload.content);
188
+ if (mode.error !== undefined) return fail(mode.error);
189
+ const requested = payload.content.metadata?.publishStatus;
190
+ if (requested !== undefined && requested !== 'draft' && requested !== 'public') {
191
+ return fail(`unknown publishStatus '${String(requested)}' (expected absent, 'draft' or 'public')`);
192
+ }
193
+ // Pre-validation, before any browser opens: the editor URL is publication-scoped, so an
194
+ // unset or non-https publication URL has no safe default to fall back to (R1).
195
+ const requestedUrl =
196
+ typeof payload.options?.publicationUrl === 'string' ? payload.options.publicationUrl : undefined;
197
+ const publication = resolvePublicationUrl(requestedUrl);
198
+ if ('error' in publication) return fail(publication.error);
199
+
200
+ const override = typeof payload.options?.profileDir === 'string' ? payload.options.profileDir : undefined;
201
+ const profileDir = resolveChannelProfileDir(CHANNEL, override);
202
+
203
+ // ADR-025 preflight: an unauthenticated or absent profile must never produce a headed
204
+ // visit, so the read-only headless check runs first and a refusal stays a fast refusal.
205
+ const session = await checkChannelSession(channelConfig(publication.url), {
206
+ profileDir,
207
+ launcher: this.launcher,
208
+ headedFallback: false,
209
+ timeoutMs: this.stepTimeoutMs,
210
+ });
211
+ if (session.status !== 'verified') {
212
+ return fail(`${CHANNEL} session preflight refused: ${session.detail} — run: ${LOGIN_INSTRUCTION}`);
213
+ }
214
+
215
+ const publishStatus = requested === 'public' ? 'public' : 'draft';
216
+ try {
217
+ return await withProfilePage(
218
+ {
219
+ profileDir,
220
+ url: editorUrl(publication.url),
221
+ isAuthenticated,
222
+ // Headed by default (Substack bot-walls headless loads); the headless preflight
223
+ // above is the unattended pass, `options.headless === true` the opt-in.
224
+ headless: payload.options?.headless === true,
225
+ launcher: this.launcher,
226
+ // The session is already preflight-verified, so the post-goto confirmation is
227
+ // a bounded step like every other one, not the 180s interactive login wait.
228
+ timeoutMs: this.stepTimeoutMs,
229
+ },
230
+ async ({ page }) => this.runEditorFlow(page, payload.content, publishStatus, profileDir),
57
231
  );
232
+ } catch (error) {
233
+ const hint = needsLogin(error) ? ` — run: ${LOGIN_INSTRUCTION}` : '';
234
+ return fail(`${errorMessage(error)}${hint}`);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * The site-owned editor flow: title → optional subtitle → ProseMirror HTML paste → the draft
240
+ * signal, then — for a live run only — `Continue` → `Send to everyone now` → the live signal.
241
+ * Substack autosaves the draft, so the draft needs no button click and both outcomes come
242
+ * from an `expectOutcome` probe (E91-D6): a wait without an observed signal is never a success.
243
+ */
244
+ private async runEditorFlow(
245
+ page: Page,
246
+ content: Content,
247
+ publishStatus: 'draft' | 'public',
248
+ profileDir: string,
249
+ ): Promise<Result> {
250
+ const metadata = { driver: this.driver, publishStatus, profileDir };
251
+
252
+ const titleSelector = await resolveSelector(page, SELECTORS.title, {
253
+ timeoutMs: this.stepTimeoutMs,
254
+ label: 'title input',
255
+ });
256
+ await page
257
+ .locator(titleSelector)
258
+ .first()
259
+ .fill(content.title ?? '');
260
+
261
+ const subtitle = content.metadata?.subtitle;
262
+ if (typeof subtitle === 'string' && subtitle.length > 0) {
263
+ const subtitleSelector = await resolveSelector(page, SELECTORS.subtitle, {
264
+ timeoutMs: this.stepTimeoutMs,
265
+ label: 'subtitle input',
266
+ });
267
+ await page.locator(subtitleSelector).first().fill(subtitle);
58
268
  }
59
- return draftResult(payload.content, this.driver, profileDir);
269
+
270
+ const bodySelector = await resolveSelector(page, SELECTORS.body, {
271
+ timeoutMs: this.stepTimeoutMs,
272
+ label: 'body editor',
273
+ });
274
+ // Substack's editor is ProseMirror rich text: the body goes in as rendered HTML through the
275
+ // shared paste, never as plain markdown text (E91-D4).
276
+ await pasteHtml(page, bodySelector, markdownToHtml(content.body), { timeoutMs: this.stepTimeoutMs });
277
+
278
+ const draft = await expectOutcome(() => probeDraft(page), {
279
+ timeoutMs: this.stepTimeoutMs,
280
+ label: `substack draft signal in '${bodySelector}'`,
281
+ });
282
+ if (!draft.ok) return fail(draft.error);
283
+
284
+ // The probe only ever returns a `/publish/post/<id>` URL, so this capture always resolves.
285
+ const id = DRAFT_URL_RE.exec(draft.evidence)?.[1];
286
+ if (publishStatus === 'draft') {
287
+ return ResultSchema.parse({
288
+ ok: true,
289
+ target: TARGET,
290
+ ...(id === undefined ? {} : { id }),
291
+ url: draft.evidence,
292
+ message: `${TARGET} draft saved (unpublished) in profile ${profileDir}`,
293
+ metadata,
294
+ });
295
+ }
296
+
297
+ const continueSelector = await resolveSelector(page, SELECTORS.continue, {
298
+ timeoutMs: this.stepTimeoutMs,
299
+ label: 'continue control',
300
+ });
301
+ await page.locator(continueSelector).first().click();
302
+ const sendSelector = await resolveSelector(page, SELECTORS.send, {
303
+ timeoutMs: this.stepTimeoutMs,
304
+ label: 'send control',
305
+ });
306
+ await page.locator(sendSelector).first().click();
307
+ const live = await expectOutcome(() => probeLive(page), {
308
+ timeoutMs: this.stepTimeoutMs,
309
+ label: `substack publish '${sendSelector}'`,
310
+ });
311
+ if (!live.ok) return fail(live.error);
312
+
313
+ return ResultSchema.parse({
314
+ ok: true,
315
+ target: TARGET,
316
+ ...(id === undefined ? {} : { id }),
317
+ url: live.evidence,
318
+ message: `${TARGET} published live in profile ${profileDir}`,
319
+ metadata,
320
+ });
60
321
  }
61
322
  }
62
323
 
63
- export function getPublishTransport(driver: BrowserDriver = 'agent'): PublishTransport {
324
+ /** Draft evidence is URL-only by design: Substack's autosave rewrites `/publish/post/<id>`. */
325
+ async function probeDraft(page: Page): Promise<string | null> {
326
+ const url = page.url();
327
+ return DRAFT_URL_RE.test(url) ? url : null;
328
+ }
329
+
330
+ /** Live evidence is URL-only too: `/p/<slug>` (the published post) or the publish queue. */
331
+ async function probeLive(page: Page): Promise<string | null> {
332
+ const url = page.url();
333
+ return LIVE_URL_RE.test(url) ? url : null;
334
+ }
335
+
336
+ export function getPublishTransport(driver: BrowserDriver = 'script'): PublishTransport {
64
337
  if (process.env.KNOWLEDGE_KIT_PUBLISH_TRANSPORT === 'fake') {
65
- return new FakeBrowserTransport(driver);
338
+ return new FakeBrowserTransport('script');
66
339
  }
67
340
  return new SubstackBrowserTransport(driver);
68
341
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Substack profile-session capability (R5, AC3).
3
+ *
4
+ * `login` opens the publication's editor in a headed browser and resolves only once Substack's
5
+ * readiness predicate passes; `check` reopens the profile headlessly and reports `verified` or
6
+ * `refused`. Arg parsing, profile precedence (`--profile` > `SUBSTACK_PROFILE_DIR` >
7
+ * `KK_BROWSER_PROFILE_DIR` > `~/.config/kk/browser-profiles/substack`) and the exit codes
8
+ * (0 verified, 1 refused, 2 usage error) all live in the shared `runProfileCli` (0183), so the
9
+ * publish preflight and this CLI apply the same gate.
10
+ *
11
+ * The publication is publication-scoped, so it comes from `--publication <url>` or
12
+ * `SUBSTACK_PUBLICATION_URL`; `--publication` is stripped before `runProfileCli` parses the rest
13
+ * (the shared CLI rejects a flag it does not know), and a missing or non-https value is a usage
14
+ * error that opens no browser.
15
+ *
16
+ * Operator dogfood (plan step 6, handed to 0193): run `profile-check.ts login`, then `check`, on
17
+ * the same profile, and re-measure the editor URL, the selectors and the draft signal.
18
+ */
19
+ import { type ChannelBrowserConfig, type ProfileCliDeps, runProfileCli } from '@gobing-ai/publish-harness/browser';
20
+ import { echo } from '@gobing-ai/ts-utils';
21
+ import { channelConfig, resolvePublicationUrl } from './index';
22
+
23
+ const PUBLICATION_FLAG = '--publication';
24
+ const USAGE = 'usage: substack profile-check [login|check] [--publication <url>] [--profile <dir>] [--timeout <ms>]';
25
+
26
+ /**
27
+ * Splits `--publication <url>` / `--publication=<url>` out of the operator argv; `rest` keeps the
28
+ * `login|check` positional and the `--profile` / `--timeout` flags `runProfileCli` owns. A flag
29
+ * with no value stays an empty string, so it is refused as a blank publication URL instead of
30
+ * silently falling through to the environment.
31
+ */
32
+ export function splitPublicationFlag(argv: readonly string[]): { publicationUrl?: string; rest: string[] } {
33
+ const rest: string[] = [];
34
+ let publicationUrl: string | undefined;
35
+ for (let index = 0; index < argv.length; index += 1) {
36
+ const arg = argv[index] ?? '';
37
+ if (arg === PUBLICATION_FLAG) {
38
+ publicationUrl = argv[index + 1] ?? '';
39
+ index += 1;
40
+ continue;
41
+ }
42
+ if (arg.startsWith(`${PUBLICATION_FLAG}=`)) {
43
+ publicationUrl = arg.slice(PUBLICATION_FLAG.length + 1);
44
+ continue;
45
+ }
46
+ rest.push(arg);
47
+ }
48
+ return { publicationUrl, rest };
49
+ }
50
+
51
+ /**
52
+ * `login|check` for one publication: the shared CLI over `channelConfig(publicationUrl)`.
53
+ * Exit codes: 0 verified (or logged in), 1 refused, 2 usage error — the publication URL is
54
+ * resolved (and refused) before anything launches.
55
+ */
56
+ export async function main(argv: readonly string[] = Bun.argv.slice(2), deps: ProfileCliDeps = {}): Promise<number> {
57
+ const stdout =
58
+ deps.stdout ??
59
+ ((line: string) => {
60
+ echo(line);
61
+ });
62
+ const { publicationUrl, rest } = splitPublicationFlag(argv);
63
+ const publication = resolvePublicationUrl(publicationUrl);
64
+ if ('error' in publication) {
65
+ stdout(`substack profile-check: ${publication.error} — ${USAGE}`);
66
+ // Last line is the ADR-032 verdict so `kk plugin check` can name the missing variable.
67
+ stdout(JSON.stringify({ status: 'error', detail: publication.error }));
68
+ return 2;
69
+ }
70
+ const config: ChannelBrowserConfig = channelConfig(publication.url);
71
+ return runProfileCli(config, [...rest], { ...deps, stdout });
72
+ }
73
+
74
+ if (import.meta.main) {
75
+ process.exit(await main());
76
+ }
@@ -14665,7 +14665,7 @@ var init_zod = __esm(() => {
14665
14665
  init_external();
14666
14666
  });
14667
14667
 
14668
- // ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.11/node_modules/@gobing-ai/ts-utils/dist/errors.js
14668
+ // ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.18/node_modules/@gobing-ai/ts-utils/dist/errors.js
14669
14669
  var ErrorCode;
14670
14670
  var init_errors3 = __esm(() => {
14671
14671
  ErrorCode = {
@@ -14676,7 +14676,7 @@ var init_errors3 = __esm(() => {
14676
14676
  };
14677
14677
  });
14678
14678
 
14679
- // ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.11/node_modules/@gobing-ai/ts-utils/dist/api-response.js
14679
+ // ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.18/node_modules/@gobing-ai/ts-utils/dist/api-response.js
14680
14680
  var API_ERROR_CODES, ERROR_CODE_TO_HTTP, CLIENT_SAFE_CODES;
14681
14681
  var init_api_response = __esm(() => {
14682
14682
  init_errors3();
@@ -14699,13 +14699,13 @@ var init_api_response = __esm(() => {
14699
14699
  CLIENT_SAFE_CODES = new Set([ErrorCode.NotFound, ErrorCode.Validation, ErrorCode.Conflict]);
14700
14700
  });
14701
14701
 
14702
- // ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.11/node_modules/@gobing-ai/ts-utils/dist/cursor.js
14702
+ // ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.18/node_modules/@gobing-ai/ts-utils/dist/cursor.js
14703
14703
  var init_cursor = () => {};
14704
14704
 
14705
- // ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.11/node_modules/@gobing-ai/ts-utils/dist/event-severity.js
14705
+ // ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.18/node_modules/@gobing-ai/ts-utils/dist/event-severity.js
14706
14706
  var init_event_severity = () => {};
14707
14707
 
14708
- // ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.11/node_modules/@gobing-ai/ts-utils/dist/output.js
14708
+ // ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.18/node_modules/@gobing-ai/ts-utils/dist/output.js
14709
14709
  function processStream(name) {
14710
14710
  const proc = globalThis.process;
14711
14711
  const stream = proc?.[name];
@@ -14723,7 +14723,7 @@ function echoError(message, target = defaultStderrTarget ?? processStream("stder
14723
14723
  }
14724
14724
  var defaultStderrTarget;
14725
14725
 
14726
- // ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.11/node_modules/@gobing-ai/ts-utils/dist/index.js
14726
+ // ../../node_modules/.bun/@gobing-ai+ts-utils@0.5.18/node_modules/@gobing-ai/ts-utils/dist/index.js
14727
14727
  var init_dist = __esm(() => {
14728
14728
  init_api_response();
14729
14729
  init_cursor();
@@ -21721,7 +21721,7 @@ var init_dist2 = __esm(() => {
21721
21721
  $visitAsync = visit.visitAsync;
21722
21722
  });
21723
21723
 
21724
- // ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.11/node_modules/@gobing-ai/ts-runtime/dist/config.js
21724
+ // ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.18+ea28d47622d670ff/node_modules/@gobing-ai/ts-runtime/dist/config.js
21725
21725
  var configSchema;
21726
21726
  var init_config = __esm(() => {
21727
21727
  init_dist2();
@@ -21745,7 +21745,7 @@ var init_config = __esm(() => {
21745
21745
  });
21746
21746
  });
21747
21747
 
21748
- // ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.11/node_modules/@gobing-ai/ts-runtime/dist/file-system-node.js
21748
+ // ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.18+ea28d47622d670ff/node_modules/@gobing-ai/ts-runtime/dist/file-system-node.js
21749
21749
  import { appendFileSync, cpSync, createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
21750
21750
  import { dirname, resolve as resolvePath } from "path";
21751
21751
  function createNodeFileSystem(root, paths) {
@@ -22037,11 +22037,12 @@ var PluginManifestSchema = exports_external.looseObject({
22037
22037
  name: exports_external.string().min(1),
22038
22038
  kind: PluginKindSchema,
22039
22039
  entry: exports_external.string().min(1),
22040
+ check: exports_external.string().min(1).optional(),
22040
22041
  version: exports_external.string().optional(),
22041
22042
  description: exports_external.string().optional(),
22042
22043
  operations: exports_external.record(exports_external.string(), PluginOperationSchema).optional()
22043
22044
  });
22044
- // ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.11/node_modules/@gobing-ai/ts-runtime/dist/index.js
22045
+ // ../../node_modules/.bun/@gobing-ai+ts-runtime@0.5.18+ea28d47622d670ff/node_modules/@gobing-ai/ts-runtime/dist/index.js
22045
22046
  init_file_system_node();
22046
22047
  init_config();
22047
22048