@gobing-ai/knowledge-kit 0.0.19 → 0.0.21
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/package.json +1 -1
- package/plugins/generations/dailynews-gen/dist/index.js +9 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +19 -1
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +13 -5
- package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +23 -15
- package/plugins/ingestions/aihot-ingest/dist/index.js +1 -1
- package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
- package/plugins/ingestions/aihot-ingest/src/rss.ts +12 -2
- package/plugins/kk/commands/workflow-run.md +20 -6
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/itc-stages.ts +563 -0
- package/plugins/kk/scripts/kk-workflow-stages.ts +93 -14
- package/plugins/kk/skills/article-adapt/SKILL.md +85 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +26 -16
- package/plugins/kk/workflows/kk-itc.yaml +461 -12
|
@@ -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
|
+
}
|