@gobing-ai/knowledge-kit 0.0.19 → 0.0.22

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 (69) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +161 -142
  3. package/package.json +1 -1
  4. package/plugins/generations/content-gen/dist/index.js +20 -3
  5. package/plugins/generations/core-facts-gen/dist/index.js +20 -3
  6. package/plugins/generations/daily-article-gen/dist/index.js +20 -3
  7. package/plugins/generations/dailynews-gen/dist/index.js +29 -4
  8. package/plugins/generations/dailynews-gen/src/script-builder.ts +19 -1
  9. package/plugins/generations/episode-plan-gen/dist/index.js +20 -3
  10. package/plugins/generations/image-gen/dist/index.js +147 -69
  11. package/plugins/generations/image-gen/src/presets.ts +2 -2
  12. package/plugins/generations/image-gen/src/providers/agnes.ts +6 -2
  13. package/plugins/generations/image-gen/src/providers/azure.ts +6 -2
  14. package/plugins/generations/image-gen/src/providers/dashscope.ts +10 -3
  15. package/plugins/generations/image-gen/src/providers/google.ts +70 -29
  16. package/plugins/generations/image-gen/src/providers/huggingface.ts +3 -0
  17. package/plugins/generations/image-gen/src/providers/jimeng.ts +9 -2
  18. package/plugins/generations/image-gen/src/providers/minimax.ts +10 -2
  19. package/plugins/generations/image-gen/src/providers/openai.ts +9 -3
  20. package/plugins/generations/image-gen/src/providers/openrouter.ts +16 -4
  21. package/plugins/generations/image-gen/src/providers/replicate.ts +13 -3
  22. package/plugins/generations/image-gen/src/providers/seedream.ts +4 -1
  23. package/plugins/generations/image-gen/src/providers/timeouts.ts +14 -0
  24. package/plugins/generations/image-gen/src/providers/types.ts +10 -0
  25. package/plugins/generations/image-gen/src/providers/zai.ts +4 -1
  26. package/plugins/generations/news-report-gen/dist/index.js +20 -3
  27. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +13 -5
  28. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +23 -15
  29. package/plugins/generations/voice-gen/dist/index.js +36 -10
  30. package/plugins/generations/voice-gen/src/voicebox-client.ts +18 -4
  31. package/plugins/generations/voice-gen/src/voicebox-server.ts +2 -1
  32. package/plugins/ingestions/aihot-ingest/dist/index.js +26 -6
  33. package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
  34. package/plugins/ingestions/aihot-ingest/src/client.ts +5 -0
  35. package/plugins/ingestions/aihot-ingest/src/rss.ts +12 -2
  36. package/plugins/ingestions/horizon-ingest/dist/index.js +20 -3
  37. package/plugins/ingestions/last30days-ingest/dist/index.js +20 -3
  38. package/plugins/ingestions/web-search/dist/index.js +22 -3
  39. package/plugins/ingestions/web-search/src/index.ts +4 -0
  40. package/plugins/kk/commands/workflow-run.md +20 -6
  41. package/plugins/kk/plugin.json +1 -1
  42. package/plugins/kk/scripts/itc-stages.ts +563 -0
  43. package/plugins/kk/scripts/kk-workflow-stages.ts +125 -18
  44. package/plugins/kk/scripts/render-md.ts +7 -1
  45. package/plugins/kk/skills/article-adapt/SKILL.md +85 -0
  46. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +26 -16
  47. package/plugins/kk/workflows/kk-itc.yaml +461 -12
  48. package/plugins/publishings/emdash-pub/dist/index.js +76 -19
  49. package/plugins/publishings/emdash-pub/src/index.ts +4 -0
  50. package/plugins/publishings/infoq-pub/dist/index.js +74 -21
  51. package/plugins/publishings/juejin-pub/dist/index.js +74 -21
  52. package/plugins/publishings/medium-pub/dist/index.js +77 -19
  53. package/plugins/publishings/medium-pub/src/index.ts +5 -0
  54. package/plugins/publishings/podcast-pub/dist/index.js +130 -34
  55. package/plugins/publishings/podcast-pub/src/index.ts +20 -5
  56. package/plugins/publishings/podcast-pub/src/map.ts +11 -0
  57. package/plugins/publishings/podcast-pub/src/show-notes.ts +66 -13
  58. package/plugins/publishings/qiita-pub/dist/index.js +75 -18
  59. package/plugins/publishings/qiita-pub/src/index.ts +4 -0
  60. package/plugins/publishings/substack-pub/dist/index.js +74 -21
  61. package/plugins/publishings/surfdash-pub/dist/index.js +99 -62
  62. package/plugins/publishings/surfdash-pub/src/index.ts +60 -19
  63. package/plugins/publishings/wechatmp-pub/dist/index.js +79 -22
  64. package/plugins/publishings/wechatmp-pub/src/index.ts +12 -3
  65. package/plugins/publishings/x-pub/dist/index.js +56 -37
  66. package/plugins/publishings/x-pub/src/index.ts +4 -0
  67. package/plugins/publishings/xhs-pub/dist/index.js +74 -21
  68. package/plugins/publishings/zenn-pub/dist/index.js +103 -25
  69. package/plugins/publishings/zenn-pub/src/index.ts +43 -9
@@ -35,7 +35,10 @@ Parse per the frozen rule:
35
35
  `--fixture` → force `fixture: "true"`.
36
36
  - Profile `kk-itc`: `--dir <path>` (default `$works_dir/kk-itc/<kebab>`), `--playbook generic|english|wechat` (default `generic`),
37
37
  `--research` (`true`|`false`, default `false`), `--judge` (`true`|`false`, default `false`),
38
- `--outline <a|b|c>` (default empty), `--writer itc-generating|topic` (default `itc-generating`).
38
+ `--outline <a|b|c>` (default empty), `--writer itc-generating|topic` (default `itc-generating`),
39
+ `--auto`, `--unslop true|false` (default `false`), `--max-revisions <N>` (default `2`),
40
+ `--cover`, `--inline-images`, `--targets <a,b>` (default empty), `--live`,
41
+ `--source-locale en|zh|ja` (default `en`; `--playbook wechat` without it implies `zh`).
39
42
  `--fixture` passed with `kk-itc` or `kk-solo-podcast` → exit 1 (`fixture is storm-only`).
40
43
  - Profile `kk-solo-podcast`: `--dir <path>` (default `$works_dir/kk-solo-podcast/<date>`), `--outline <a|b|c>`
41
44
  (default empty), `--duration <min>` (default `8`), `--language <code>` (default `en`),
@@ -48,7 +51,9 @@ Parse per the frozen rule:
48
51
 
49
52
  Resolve these before step 2: `NAME`, `TOPIC`, `INPUT_FILE` (empty in sentence mode), `FIXTURE`
50
53
  (`true`|`false`), `FORCE` (`true`|`false`), `DIR`, `PLAYBOOK`, `RESEARCH`, `JUDGE`, `OUTLINE`,
51
- `WRITER`, `DURATION` (`8`), `LANGUAGE` (`en`), `SCRIPT_APPROVED` (`true`|`false`),
54
+ `WRITER`, `AUTO` (`true`|`false`), `UNSLOP` (`true`|`false`), `MAX_REVISIONS` (`2`), `COVER`
55
+ (`true`|`false`), `INLINE_IMAGES` (`true`|`false`), `TARGETS`, `LIVE` (`true`|`false`),
56
+ `SOURCE_LOCALE` (`en`), `DURATION` (`8`), `LANGUAGE` (`en`), `SCRIPT_APPROVED` (`true`|`false`),
52
57
  `VOICE_PROFILE` (from `VOICEBOX_DEFAULT_PROFILE`, default empty).
53
58
 
54
59
  ## 2. Resolve the config
@@ -185,6 +190,14 @@ mkdir -p "$work_dir"
185
190
  `work_dir="${DIR:-$works_dir/kk-itc/$kebab}"`. Empty derived `kebab` → exit 1. An explicit
186
191
  `--dir` still wins, and the default never writes into the caller's cwd.
187
192
 
193
+ Binding (0153): `--auto` binds `auto=true` and defaults `research`, `judge` and `unslop` to
194
+ `true` unless the operator passed them explicitly. `--live` is never implied — `publish_live`
195
+ stays `false` unless `--live` is passed explicitly (ADR-022). `--playbook wechat` without
196
+ `--source-locale` binds `source_locale=zh`; an explicit `--source-locale` always wins.
197
+ `--targets <a,b>` binds `publish_targets`; `--cover` / `--inline-images` bind
198
+ `cover_enabled` / `inline_images` (`false` unless passed); `--max-revisions` binds
199
+ `max_revisions` (default `2`).
200
+
188
201
  ### Profile `kk-solo-podcast`
189
202
 
190
203
  `date` = today's date in `YYYY-MM-DD` (`$(date +%Y-%m-%d)`).
@@ -218,13 +231,14 @@ spur workflow run "$dest" --vars \
218
231
 
219
232
  ### Profile `kk-itc`
220
233
 
221
- Bind vars: `topic`, `input_file`, `work_dir`, `writer`, `playbook`, `research`, `judge`, `outline`,
222
- `force`, `rubric` (`tech-accuracy`), `agent`. The judge verdict lands at `$work_dir/.itc-verdict.json`
223
- (fixed path inside the workflow).
234
+ Bind vars: `topic`, `input_file`, `work_dir`, `writer`, `playbook`, `research`, `judge`, `auto`,
235
+ `unslop`, `max_revisions`, `outline`, `force`, `rubric` (`tech-accuracy`), `agent`,
236
+ `cover_enabled`, `inline_images`, `publish_targets`, `publish_live`, `source_locale`. The judge
237
+ verdict lands at `$work_dir/.itc-verdict.json` (fixed path inside the workflow).
224
238
 
225
239
  ```bash
226
240
  spur workflow run "$dest" --vars \
227
- "{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"writer\":\"$WRITER\",\"playbook\":\"$PLAYBOOK\",\"research\":\"$RESEARCH\",\"judge\":\"$JUDGE\",\"outline\":\"$OUTLINE\",\"force\":\"$FORCE\",\"rubric\":\"tech-accuracy\",\"agent\":\"$AGENT\"}"
241
+ "{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"writer\":\"$WRITER\",\"playbook\":\"$PLAYBOOK\",\"research\":\"$RESEARCH\",\"judge\":\"$JUDGE\",\"auto\":\"$AUTO\",\"unslop\":\"$UNSLOP\",\"max_revisions\":\"$MAX_REVISIONS\",\"outline\":\"$OUTLINE\",\"force\":\"$FORCE\",\"rubric\":\"tech-accuracy\",\"agent\":\"$AGENT\",\"cover_enabled\":\"$COVER\",\"inline_images\":\"$INLINE_IMAGES\",\"publish_targets\":\"$TARGETS\",\"publish_live\":\"$LIVE\",\"source_locale\":\"$SOURCE_LOCALE\"}"
228
242
  ```
229
243
 
230
244
  ### Profile `kk-solo-podcast`
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "kk",
3
- "version": "0.0.19",
3
+ "version": "0.0.22",
4
4
  "description": "knowledge-kit Claude Code plugin: skills, commands, subagents, hooks, and rules"
5
5
  }
@@ -0,0 +1,563 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Deterministic `itc-*` stage helpers for plugins/kk/workflows/kk-itc.yaml (F41; 0149 authoring
4
+ * tail, 0150 publish-tail seams, 0151 publish send).
5
+ *
6
+ * itc-outline-pick <work_dir>
7
+ * Validate the auto-mode writer's 2-outline/outline-recommendation.json {pick, rationale},
8
+ * then persist the picked letter to 2-outline/.auto-pick (the workflow binds it into
9
+ * ${vars.outline} via file.read.into-var). Exits 1 when the recommendation is missing or
10
+ * malformed, the pick is outside a|b|c, or the picked option file is missing.
11
+ *
12
+ * itc-report <work_dir> [max_revisions]
13
+ * Write 5-report/report.md and 5-report/report.json {outcome, verdict, revisions}. outcome
14
+ * is failed when the verdict is FAIL, the revision budget is exhausted (revisions >=
15
+ * max_revisions when given), or any publish target result is ok:false; `declined` when the
16
+ * operator answered no at publish-confirm (4-publish/.publish-declined). A missing/empty
17
+ * verdict counts as PASS — the workflow's dry-run convention. When a publish tail ran
18
+ * (4-publish/publish-plan.json exists), the report adds each target's ok, url or error and
19
+ * the exact `kk executor fan-out --retry-from` command per failed fan-out group. Exits 1
20
+ * only when a report or plan cannot be read/written.
21
+ *
22
+ * itc-materialize <work_dir> <publish_live>
23
+ * Materialize the final draft 3-draft-draft-article.md to 4-publish/content.json
24
+ * (title from the first H1 and removed from body; outline, references, cover image and
25
+ * brief frontmatter when present; metadata.published always a boolean). Exits 1 when the
26
+ * draft is missing, has no H1, or the body is empty.
27
+ *
28
+ * itc-publish-plan <work_dir> <targets> <source_locale> <publish_live>
29
+ * Split the publish targets into fan-out groups and browser targets and write
30
+ * 4-publish/publish-plan.json {live, sourceLocale, variants, fanout, browser}. Targets on
31
+ * the source-locale article profile share content.json (adapt:false); every other profile
32
+ * gets content.<variant>.json (adapt:true) and each variant carries the union of its
33
+ * targets' draft/live switches (V3). Exits 1, naming the target, for an unknown or
34
+ * non-article target, a target with no switch for the run's mode (x-pub in draft,
35
+ * wechatmp-pub live), or a target that needs a cover when 4-illustrations/cover.* is absent.
36
+ *
37
+ * itc-adapt-verify <work_dir>
38
+ * R3 follow-up check for the adapt state: every plan variant with adapt:true must have its
39
+ * content.<variant>.json present with a non-empty string title and body. Exits 1, naming the
40
+ * variant and file, otherwise.
41
+ *
42
+ * itc-publish-fanout <work_dir> [--retry]
43
+ * For each publish-plan.json fan-out group: stamp the variant's draft/live switches into the
44
+ * variant Content metadata (V3 — at send time, never via the adapt step), then spawn
45
+ * `kk executor fan-out --content <variant> --target ... --out fanout-result.<variant>.json`.
46
+ * Per-target failures are data (exit 0, ok:false rows in the aggregate); a nonzero stage
47
+ * exit is reserved for plan or I/O errors. With --retry, only groups whose aggregate has an
48
+ * ok:false row are re-sent, passing --retry-from so published targets stay skipped (V5 <=1).
49
+ *
50
+ * itc-publish-next <work_dir>
51
+ * Write the first browser target without a 4-publish/result.<target>.json into
52
+ * 4-publish/.next-browser (empty when none are pending) — the publish-agent file cursor
53
+ * that bounds the kk:publish loop by the plan's browser list (V5).
54
+ *
55
+ * Node/Bun builtins only (ADR-018, design V6). Imported by kk-workflow-stages.ts — same
56
+ * directory, so the sibling travels with whichever copy of the runner resolves; executed
57
+ * directly it dispatches on argv[2].
58
+ */
59
+ import { spawnSync } from 'node:child_process';
60
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
61
+ import { dirname, join } from 'node:path';
62
+
63
+ const VALID_PICKS = ['a', 'b', 'c'];
64
+
65
+ function fail(message: string): never {
66
+ console.error(message);
67
+ process.exit(1);
68
+ }
69
+
70
+ function readJson(path: string): unknown {
71
+ try {
72
+ return JSON.parse(readFileSync(path, 'utf-8'));
73
+ } catch {
74
+ return fail(`invalid JSON in ${path}`);
75
+ }
76
+ }
77
+
78
+ function writeJson(path: string, value: unknown): void {
79
+ try {
80
+ mkdirSync(dirname(path), { recursive: true });
81
+ writeFileSync(path, `${JSON.stringify(value, null, 4)}\n`);
82
+ } catch (error) {
83
+ fail(`cannot write ${path}: ${error instanceof Error ? error.message : String(error)}`);
84
+ }
85
+ }
86
+
87
+ /** Revision counter read; missing or non-numeric file -> undefined so callers can fall back. */
88
+ function readCount(path: string): number | undefined {
89
+ if (!existsSync(path)) return undefined;
90
+ const n = Number.parseInt(readFileSync(path, 'utf-8').trim(), 10);
91
+ return Number.isFinite(n) ? n : undefined;
92
+ }
93
+
94
+ export function itcOutlinePick(args: string[]): void {
95
+ const [workDir = ''] = args;
96
+ if (!workDir) fail('usage: itc-outline-pick <work_dir>');
97
+ const recPath = join(workDir, '2-outline', 'outline-recommendation.json');
98
+ if (!existsSync(recPath)) fail(`outline recommendation not found: ${recPath}`);
99
+ const rec = readJson(recPath) as { pick?: unknown };
100
+ const pick = typeof rec.pick === 'string' ? rec.pick : '';
101
+ if (!VALID_PICKS.includes(pick)) {
102
+ fail(`invalid pick in ${recPath}: ${JSON.stringify(rec.pick ?? null)} (expected a|b|c)`);
103
+ }
104
+ const optionPath = join(workDir, '2-outline', `outline-option-${pick}.md`);
105
+ if (!existsSync(optionPath)) fail(`picked outline option not found: ${optionPath}`);
106
+ // No trailing newline — the letter is bound into ${vars.outline} verbatim via file.read.into-var.
107
+ writeFileSync(join(workDir, '2-outline', '.auto-pick'), pick);
108
+ }
109
+
110
+ /** One per-target publish outcome row in the run report (design §4, R16). */
111
+ interface ReportTarget {
112
+ target: string;
113
+ transport: 'fanout' | 'browser';
114
+ ok: boolean;
115
+ url?: string;
116
+ error?: string;
117
+ }
118
+
119
+ /** `{ok, url?, error?}` out of a fan-out aggregate row or a browser plugin Result. */
120
+ function resultRow(entry: { ok?: unknown; url?: unknown; error?: unknown }): {
121
+ ok: boolean;
122
+ url?: string;
123
+ error?: string;
124
+ } {
125
+ const url = typeof entry.url === 'string' && entry.url ? entry.url : undefined;
126
+ const error = entry.error ? String(entry.error) : undefined;
127
+ return { ok: entry.ok === true, ...(url ? { url } : {}), ...(error ? { error } : {}) };
128
+ }
129
+
130
+ export function itcReport(args: string[]): void {
131
+ const [workDir = '', maxRevisions = ''] = args;
132
+ if (!workDir) fail('usage: itc-report <work_dir> [max_revisions]');
133
+ const verdictPath = join(workDir, '.itc-verdict.json');
134
+ const verdict = existsSync(verdictPath) ? ((readJson(verdictPath) as { verdict?: unknown }).verdict ?? '') : '';
135
+ // Auto runs count on .auto-revise-count; interactive runs keep the existing .revise-count.
136
+ const revisions = readCount(join(workDir, '.auto-revise-count')) ?? readCount(join(workDir, '.revise-count')) ?? 0;
137
+ // Exhaustion only fails the run when the last verdict still asks for another revision —
138
+ // a PASS on the final allowed revision is a done run.
139
+ const exhausted = verdict === 'NEEDS_REVISION' && maxRevisions !== '' && revisions >= Number(maxRevisions);
140
+
141
+ const publishDir = join(workDir, '4-publish');
142
+ const planPath = join(publishDir, 'publish-plan.json');
143
+ const declined = existsSync(join(publishDir, '.publish-declined'));
144
+ const hasPlan = existsSync(planPath);
145
+ const targets: ReportTarget[] = [];
146
+ const retryCommands: string[] = [];
147
+ if (hasPlan && !declined) {
148
+ const plan = readJson(planPath) as PublishPlan;
149
+ for (const group of plan.fanout) {
150
+ const resultPath = join(publishDir, `fanout-result.${group.variant}.json`);
151
+ if (!existsSync(resultPath)) {
152
+ // The fan-out never completed for this group — every target is an unresolved failure.
153
+ for (const target of group.targets) {
154
+ targets.push({
155
+ target,
156
+ transport: 'fanout',
157
+ ok: false,
158
+ error: `no ${join('4-publish', `fanout-result.${group.variant}.json`)}`,
159
+ });
160
+ }
161
+ continue;
162
+ }
163
+ const agg = readJson(resultPath) as { targets?: Array<{ target: string } & Record<string, unknown>> };
164
+ let failed = false;
165
+ for (const entry of agg.targets ?? []) {
166
+ const row = resultRow(entry);
167
+ if (!row.ok) failed = true;
168
+ targets.push({ target: entry.target, transport: 'fanout', ...row });
169
+ }
170
+ if (failed) {
171
+ const contentPath = join(publishDir, plan.variants[group.variant]?.file ?? '');
172
+ if (existsSync(contentPath)) {
173
+ retryCommands.push(
174
+ `kk executor fan-out --content ${contentPath} --retry-from ${resultPath} --out ${resultPath}`,
175
+ );
176
+ }
177
+ }
178
+ }
179
+ for (const browser of plan.browser) {
180
+ const resultPath = join(publishDir, `result.${browser.target}.json`);
181
+ const row = existsSync(resultPath)
182
+ ? resultRow(readJson(resultPath) as Record<string, unknown>)
183
+ : { ok: false, error: `no ${join('4-publish', `result.${browser.target}.json`)}` };
184
+ targets.push({ target: browser.target, transport: 'browser', ...row });
185
+ }
186
+ }
187
+
188
+ const publishFailed = targets.some((t) => !t.ok);
189
+ const outcome = verdict === 'FAIL' || exhausted || publishFailed ? 'failed' : declined ? 'declined' : 'done';
190
+ const reportDir = join(workDir, '5-report');
191
+ const jsonPath = join(reportDir, 'report.json');
192
+ const publish = hasPlan ? { targets, retryCommands } : undefined;
193
+ try {
194
+ mkdirSync(reportDir, { recursive: true });
195
+ writeFileSync(
196
+ jsonPath,
197
+ `${JSON.stringify({ outcome, verdict, revisions, ...(publish ? { publish } : {}) }, null, 4)}\n`,
198
+ );
199
+ writeFileSync(
200
+ join(reportDir, 'report.md'),
201
+ `# kk-itc run report\n\n- Outcome: ${outcome}\n- Verdict: ${verdict === '' ? 'PASS (empty)' : verdict}\n- Revisions: ${revisions}\n` +
202
+ (declined
203
+ ? '\n## Publish\n\n- Declined by the operator — nothing was sent.\n'
204
+ : targets.length > 0
205
+ ? `\n## Publish\n\n${targets.map((t) => `- ${t.target}: ${t.ok ? `ok${t.url ? ` ${t.url}` : ''}` : `FAILED${t.error ? ` ${t.error}` : ''}`}\n`).join('')}` +
206
+ (retryCommands.length > 0
207
+ ? `\nRetry the failed fan-out groups:\n\n${retryCommands.map((cmd) => ` ${cmd}`).join('\n')}\n`
208
+ : '')
209
+ : ''),
210
+ );
211
+ } catch (error) {
212
+ fail(`cannot write report at ${jsonPath}: ${error instanceof Error ? error.message : String(error)}`);
213
+ }
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // 0150: publish-tail seams — TARGET_PROFILES, itc-materialize, itc-publish-plan
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /** One row of the docs/design/kk-itc-end-to-end.md §5 target profile table. */
221
+ export interface TargetProfile {
222
+ transport: 'fanout' | 'browser';
223
+ /** `source` = the draft's own source_locale; anything else is fixed. */
224
+ locale: 'en' | 'zh' | 'ja' | 'source';
225
+ format: string;
226
+ cover: boolean;
227
+ /**
228
+ * V3 draft/live switch itc-publish-fanout writes into the variant Content metadata at send
229
+ * time. Returning null means the target has no switch for this run's mode and is rejected by
230
+ * name: x-pub's `operation: post` has no draft mode; wechatmp-pub refuses every live operation.
231
+ */
232
+ switch: (live: boolean) => Record<string, unknown> | null;
233
+ }
234
+
235
+ // Draft/live key per row, verified against each plugin's src/ (0150 R3):
236
+ // medium-pub metadata.publishStatus 'draft'|'public' (plugins/publishings/medium-pub/src/index.ts:49)
237
+ // emdash-pub metadata.published (plugins/publishings/emdash-pub/src/index.ts:254)
238
+ // surfdash-pub metadata.published (plugins/publishings/surfdash-pub/src/index.ts:93)
239
+ // zenn-pub metadata.published — defaults to LIVE when absent, so never omit (plugins/publishings/zenn-pub/src/index.ts:92)
240
+ // qiita-pub metadata.published, private is its inverse (plugins/publishings/qiita-pub/src/index.ts:70)
241
+ // wechatmp-pub metadata.operation, draft default; live refused (plugins/publishings/wechatmp-pub/src/index.ts:162,171)
242
+ // x-pub metadata.operation 'post' — no draft mode exists (plugins/publishings/x-pub/src/index.ts:64)
243
+ // substack/juejin/infoq/xhs always return a draft Result (plugins/publishings/substack-pub/src/index.ts:26);
244
+ // their live path is 0151's `kk:publish --live` agent flag, so the switch is documentation-only.
245
+ const publishedSwitch = (live: boolean) => ({ published: live });
246
+ const browserSwitch = (live: boolean): Record<string, unknown> => (live ? {} : { publishStatus: 'draft' });
247
+
248
+ export const TARGET_PROFILES: Record<string, TargetProfile> = {
249
+ 'medium-pub': {
250
+ transport: 'fanout',
251
+ locale: 'en',
252
+ format: 'article',
253
+ cover: false,
254
+ switch: (live) => ({ publishStatus: live ? 'public' : 'draft' }),
255
+ },
256
+ 'substack-pub': { transport: 'browser', locale: 'en', format: 'article', cover: false, switch: browserSwitch },
257
+ 'emdash-pub': { transport: 'fanout', locale: 'source', format: 'article', cover: false, switch: publishedSwitch },
258
+ 'surfdash-pub': { transport: 'fanout', locale: 'source', format: 'article', cover: false, switch: publishedSwitch },
259
+ 'zenn-pub': { transport: 'fanout', locale: 'ja', format: 'article', cover: false, switch: publishedSwitch },
260
+ 'qiita-pub': { transport: 'fanout', locale: 'ja', format: 'article', cover: false, switch: publishedSwitch },
261
+ 'wechatmp-pub': {
262
+ transport: 'fanout',
263
+ locale: 'zh',
264
+ format: 'wechat-article',
265
+ cover: true,
266
+ switch: (live) => (live ? null : { operation: 'draft' }),
267
+ },
268
+ 'juejin-pub': { transport: 'browser', locale: 'zh', format: 'article', cover: false, switch: browserSwitch },
269
+ 'infoq-pub': { transport: 'browser', locale: 'zh', format: 'article', cover: false, switch: browserSwitch },
270
+ 'xhs-pub': { transport: 'browser', locale: 'zh', format: 'short-note', cover: true, switch: browserSwitch },
271
+ 'x-pub': {
272
+ transport: 'fanout',
273
+ locale: 'en',
274
+ format: 'short-post',
275
+ cover: false,
276
+ switch: (live) => (live ? { operation: 'post' } : null),
277
+ },
278
+ };
279
+
280
+ const DRAFT_FILE = '3-draft-draft-article.md';
281
+
282
+ /** First `cover.*` in 4-illustrations as a workspace-relative path, or undefined. */
283
+ function findCover(workDir: string): string | undefined {
284
+ const dir = join(workDir, '4-illustrations');
285
+ if (!existsSync(dir)) return undefined;
286
+ const cover = readdirSync(dir).find((file) => file.startsWith('cover.'));
287
+ return cover ? join('4-illustrations', cover) : undefined;
288
+ }
289
+
290
+ /** `tags: [a, b]` / `a, b` frontmatter list value -> string[]. */
291
+ function splitList(value: string): string[] {
292
+ return value
293
+ .replace(/^\[|\]$/g, '')
294
+ .split(',')
295
+ .map((entry) => entry.trim())
296
+ .filter(Boolean);
297
+ }
298
+
299
+ /** Minimal flat frontmatter reader for brief.md `tags` / `keywords` / `description`. */
300
+ function readFrontmatter(path: string): Record<string, string> {
301
+ if (!existsSync(path)) return {};
302
+ const text = readFileSync(path, 'utf-8');
303
+ if (!text.startsWith('---')) return {};
304
+ const end = text.indexOf('\n---', 3);
305
+ if (end < 0) return {};
306
+ const out: Record<string, string> = {};
307
+ for (const line of text.slice(4, end).split('\n')) {
308
+ const m = /^([\w-]+):\s*(.+)$/.exec(line);
309
+ if (m?.[1] && m[2]) out[m[1]] = m[2].trim().replace(/^['"]|['"]$/g, '');
310
+ }
311
+ return out;
312
+ }
313
+
314
+ export function itcMaterialize(args: string[]): void {
315
+ const [workDir = '', liveArg = ''] = args;
316
+ if (!workDir) fail('usage: itc-materialize <work_dir> <publish_live>');
317
+ const live = liveArg === 'true';
318
+ const draftPath = join(workDir, DRAFT_FILE);
319
+ if (!existsSync(draftPath)) fail(`final draft not found: ${draftPath}`);
320
+ const lines = readFileSync(draftPath, 'utf-8').split('\n');
321
+ const h1 = lines.findIndex((line) => /^#\s+\S/.test(line));
322
+ if (h1 < 0) fail(`no H1 title in ${draftPath}`);
323
+ const title = lines[h1]?.replace(/^#\s+/, '').trim() ?? '';
324
+ const body = [...lines.slice(0, h1), ...lines.slice(h1 + 1)].join('\n').trim();
325
+ if (!body) fail(`empty body in ${draftPath}`);
326
+
327
+ const content: Record<string, unknown> = { title, format: 'markdown', body };
328
+ const outlinePath = join(workDir, '2-outline', 'outline-approved.md');
329
+ if (existsSync(outlinePath)) content.outline = readFileSync(outlinePath, 'utf-8').trim();
330
+
331
+ const docsPath = join(workDir, 'docs.json');
332
+ if (existsSync(docsPath)) {
333
+ const docs = readJson(docsPath) as Array<{ id?: unknown; title?: unknown; sourceUri?: unknown }>;
334
+ if (Array.isArray(docs)) {
335
+ content.references = docs.map((doc) => ({
336
+ ...(typeof doc.sourceUri === 'string' ? { url: doc.sourceUri } : {}),
337
+ ...(typeof doc.title === 'string' ? { title: doc.title } : {}),
338
+ ...(typeof doc.id === 'string' ? { cite: doc.id } : {}),
339
+ }));
340
+ }
341
+ }
342
+
343
+ const metadata: Record<string, unknown> = { published: live };
344
+ const cover = findCover(workDir);
345
+ if (cover) metadata.coverImage = cover;
346
+ const brief = readFrontmatter(join(workDir, 'brief.md'));
347
+ if (brief.tags) metadata.tags = splitList(brief.tags);
348
+ if (brief.keywords) metadata.keywords = splitList(brief.keywords);
349
+ if (brief.description) metadata.description = brief.description;
350
+ content.metadata = metadata;
351
+
352
+ writeJson(join(workDir, '4-publish', 'content.json'), content);
353
+ }
354
+
355
+ /** Plan shape consumed by the adapt / publish-fanout / publish-next states (design §4). */
356
+ export interface PublishPlan {
357
+ live: boolean;
358
+ sourceLocale: string;
359
+ variants: Record<
360
+ string,
361
+ { file: string; adapt: boolean; limits?: Record<string, unknown>; switches: Record<string, unknown> }
362
+ >;
363
+ fanout: Array<{ variant: string; targets: string[] }>;
364
+ browser: Array<{ target: string; variant: string }>;
365
+ }
366
+
367
+ export function itcPublishPlan(args: string[]): void {
368
+ const [workDir = '', targetsArg = '', sourceLocale = '', liveArg = ''] = args;
369
+ if (!workDir || !targetsArg || !sourceLocale) {
370
+ fail('usage: itc-publish-plan <work_dir> <targets> <source_locale> <publish_live>');
371
+ }
372
+ const live = liveArg === 'true';
373
+ const targets = [
374
+ ...new Set(
375
+ targetsArg
376
+ .split(',')
377
+ .map((t) => t.trim())
378
+ .filter(Boolean),
379
+ ),
380
+ ];
381
+ const hasCover = findCover(workDir) !== undefined;
382
+ const variants: PublishPlan['variants'] = {};
383
+ const fanout = new Map<string, string[]>();
384
+ const browser: PublishPlan['browser'] = [];
385
+ for (const target of targets) {
386
+ const profile = TARGET_PROFILES[target];
387
+ if (!profile) fail(`unsupported publish target: ${target} (unknown or not an article target)`);
388
+ const switchValue = profile.switch(live);
389
+ if (!switchValue) {
390
+ fail(
391
+ live
392
+ ? `target ${target} refuses a live publish`
393
+ : `target ${target} has no draft mode; it needs publish_live=true`,
394
+ );
395
+ }
396
+ if (profile.cover && !hasCover) {
397
+ fail(`target ${target} needs a cover image at 4-illustrations/cover.* in ${workDir}`);
398
+ }
399
+ const variant = `${profile.locale === 'source' ? sourceLocale : profile.locale}-${profile.format}`;
400
+ const sharesSourceArticle = variant === `${sourceLocale}-article`;
401
+ const entry = variants[variant] ?? {
402
+ file: sharesSourceArticle ? 'content.json' : `content.${variant}.json`,
403
+ adapt: !sharesSourceArticle,
404
+ limits: sharesSourceArticle ? undefined : {},
405
+ switches: {} as Record<string, unknown>,
406
+ };
407
+ Object.assign(entry.switches, switchValue);
408
+ variants[variant] = entry;
409
+ if (profile.transport === 'browser') browser.push({ target, variant });
410
+ else {
411
+ const group = fanout.get(variant);
412
+ if (group) group.push(target);
413
+ else fanout.set(variant, [target]);
414
+ }
415
+ }
416
+ const plan: PublishPlan = {
417
+ live,
418
+ sourceLocale,
419
+ variants,
420
+ // canonical group order: the plan file must not depend on target argument order
421
+ fanout: [...fanout]
422
+ .sort(([a], [b]) => (a < b ? -1 : 1))
423
+ .map(([variant, groupTargets]) => ({ variant, targets: groupTargets })),
424
+ browser,
425
+ };
426
+ writeJson(join(workDir, '4-publish', 'publish-plan.json'), plan);
427
+ }
428
+
429
+ /** R3 — the adapt state's follow-up check: every adapt:true variant file exists with title/body. */
430
+ export function itcAdaptVerify(args: string[]): void {
431
+ const [workDir = ''] = args;
432
+ if (!workDir) fail('usage: itc-adapt-verify <work_dir>');
433
+ const planPath = join(workDir, '4-publish', 'publish-plan.json');
434
+ if (!existsSync(planPath)) fail(`publish plan not found: ${planPath}`);
435
+ const plan = readJson(planPath) as PublishPlan;
436
+ const bad: string[] = [];
437
+ for (const [variant, entry] of Object.entries(plan.variants ?? {})) {
438
+ if (!entry?.adapt) continue;
439
+ const path = join(workDir, '4-publish', entry.file ?? '');
440
+ const content = existsSync(path) ? (readJson(path) as { title?: unknown; body?: unknown }) : undefined;
441
+ const okField = (v: unknown): boolean => typeof v === 'string' && v.trim().length > 0;
442
+ if (!content || !okField(content.title) || !okField(content.body)) {
443
+ bad.push(`${variant} (${entry.file})`);
444
+ }
445
+ }
446
+ if (bad.length > 0) {
447
+ fail(
448
+ `adapted variant file(s) missing or lacking a non-empty title/body in ${join(workDir, '4-publish')}: ` +
449
+ `${bad.join(', ')}. The adapt state (kk:article-adapt) must write every adapt:true variant`,
450
+ );
451
+ }
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // 0151: publish send — itc-publish-fanout, itc-publish-next
456
+ // ---------------------------------------------------------------------------
457
+
458
+ /** True when the fan-out aggregate is missing or carries at least one ok:false target row. */
459
+ function aggregateHasFailures(path: string): boolean {
460
+ if (!existsSync(path)) return true;
461
+ const agg = readJson(path) as { targets?: Array<{ ok?: unknown }> };
462
+ return (agg.targets ?? []).some((entry) => entry.ok !== true);
463
+ }
464
+
465
+ /** V3: the switch is stamped by deterministic code at send time — merged, never removed. */
466
+ function stampSwitches(contentPath: string, switches: Record<string, unknown>): void {
467
+ const content = readJson(contentPath) as { metadata?: Record<string, unknown> };
468
+ content.metadata = { ...content.metadata, ...switches };
469
+ writeJson(contentPath, content);
470
+ }
471
+
472
+ export function itcPublishFanout(args: string[]): void {
473
+ const [workDir = ''] = args;
474
+ const retry = args.includes('--retry');
475
+ if (!workDir) fail('usage: itc-publish-fanout <work_dir> [--retry]');
476
+ const publishDir = join(workDir, '4-publish');
477
+ const planPath = join(publishDir, 'publish-plan.json');
478
+ if (!existsSync(planPath)) fail(`publish plan not found: ${planPath}`);
479
+ const plan = readJson(planPath) as PublishPlan;
480
+ for (const group of plan.fanout) {
481
+ const resultPath = join(publishDir, `fanout-result.${group.variant}.json`);
482
+ if (retry && !aggregateHasFailures(resultPath)) continue; // V5: only failed groups are re-sent
483
+ const contentPath = join(publishDir, plan.variants[group.variant]?.file ?? '');
484
+ if (!existsSync(contentPath)) fail(`variant file not found: ${contentPath}`);
485
+ stampSwitches(contentPath, plan.variants[group.variant]?.switches ?? {});
486
+ const argv = [
487
+ 'executor',
488
+ 'fan-out',
489
+ '--content',
490
+ contentPath,
491
+ ...group.targets.flatMap((target) => ['--target', target]),
492
+ '--out',
493
+ resultPath,
494
+ ...(retry ? ['--retry-from', resultPath] : []),
495
+ ];
496
+ const res = spawnSync('kk', argv, { stdio: 'inherit', env: process.env });
497
+ if (res.error !== undefined || res.status === null || !existsSync(resultPath)) {
498
+ fail(
499
+ `fan-out did not write ${resultPath} for variant ${group.variant}: ${
500
+ res.error?.message ?? (res.status === null ? 'spawn failed' : `exit ${res.status}`)
501
+ }`,
502
+ );
503
+ }
504
+ // Exit 1 = target failures, kept as data in the aggregate; >1 is an input error (plan/I/O).
505
+ if (res.status > 1) fail(`fan-out input error for variant ${group.variant} (exit ${res.status})`);
506
+ console.log(`itc-publish-fanout ${group.variant}: exit ${res.status}`);
507
+ }
508
+ // Routing markers so the workflow guards stay one predicate over a result file (ADR-115):
509
+ // .fanout-failed non-empty = some aggregate carries ok:false; .next-browser = the publish-agent
510
+ // cursor (publish-agent recomputes it after each kk:publish step).
511
+ const failed = plan.fanout.some((group) =>
512
+ aggregateHasFailures(join(publishDir, `fanout-result.${group.variant}.json`)),
513
+ );
514
+ writeFileSync(join(publishDir, '.fanout-failed'), failed ? '1' : '');
515
+ writeNextBrowser(workDir, plan);
516
+ }
517
+
518
+ /** Rewrite 4-publish/.next-browser with the first browser target lacking a Result (empty = done). */
519
+ function writeNextBrowser(workDir: string, plan: PublishPlan): void {
520
+ const publishDir = join(workDir, '4-publish');
521
+ const pending = plan.browser.find(({ target }) => !existsSync(join(publishDir, `result.${target}.json`)));
522
+ // No trailing newline — the cursor is bound into ${vars.browser_target} via file.read.into-var.
523
+ writeFileSync(join(publishDir, '.next-browser'), pending?.target ?? '');
524
+ }
525
+
526
+ export function itcPublishNext(args: string[]): void {
527
+ const [workDir = ''] = args;
528
+ if (!workDir) fail('usage: itc-publish-next <work_dir>');
529
+ const planPath = join(workDir, '4-publish', 'publish-plan.json');
530
+ if (!existsSync(planPath)) fail(`publish plan not found: ${planPath}`);
531
+ writeNextBrowser(workDir, readJson(planPath) as PublishPlan);
532
+ }
533
+
534
+ if (import.meta.main) {
535
+ const [stage, ...rest] = process.argv.slice(2);
536
+ switch (stage) {
537
+ case 'itc-outline-pick':
538
+ itcOutlinePick(rest);
539
+ break;
540
+ case 'itc-report':
541
+ itcReport(rest);
542
+ break;
543
+ case 'itc-materialize':
544
+ itcMaterialize(rest);
545
+ break;
546
+ case 'itc-publish-plan':
547
+ itcPublishPlan(rest);
548
+ break;
549
+ case 'itc-adapt-verify':
550
+ itcAdaptVerify(rest);
551
+ break;
552
+ case 'itc-publish-fanout':
553
+ itcPublishFanout(rest);
554
+ break;
555
+ case 'itc-publish-next':
556
+ itcPublishNext(rest);
557
+ break;
558
+ default:
559
+ fail(
560
+ `unknown stage: ${stage ?? '(none)'} (expected itc-outline-pick|itc-materialize|itc-publish-plan|itc-adapt-verify|itc-publish-fanout|itc-publish-next|itc-report)`,
561
+ );
562
+ }
563
+ }