@gobing-ai/knowledge-kit 0.1.10 → 0.1.12
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/dist/index.js +2478 -1737
- package/package.json +1 -1
- package/plugins/generations/image-gen/README.md +108 -0
- package/plugins/generations/image-gen/assets/sample/cover__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/cover__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/daily-news-cover__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/free__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/illustration__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__clean-webapp-ui.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__cute.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__daily-news.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__editorial.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__fresh.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__minimalist.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__photorealistic.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__sketch.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__technical-diagram.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__vibrant.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__warm.jpg +0 -0
- package/plugins/generations/image-gen/assets/sample/xhs-card__xhs-knowledge.jpg +0 -0
- package/plugins/generations/image-gen/dist/index.js +102 -58
- package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +5 -21
- package/plugins/generations/image-gen/scripts/gen-samples.ts +208 -0
- package/plugins/generations/image-gen/src/compose/chromium.ts +2 -3
- package/plugins/generations/image-gen/src/presets.ts +6 -3
- package/plugins/generations/image-gen/src/providers/errors.ts +16 -1
- package/plugins/generations/image-gen/src/providers/minimax.ts +46 -17
- package/plugins/ingestions/web-search/src/index.ts +1 -1
- package/plugins/kk/README.md +18 -0
- package/plugins/kk/commands/image-extract.md +1 -0
- package/plugins/kk/commands/image-generate.md +1 -0
- package/plugins/kk/commands/tell-me.md +1 -0
- package/plugins/kk/commands/workflow-run.md +7 -1
- package/plugins/kk/decisions/extract/it-topics.yaml +24 -0
- package/plugins/kk/decisions/kk-itc.yaml +6 -4
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/publish-presets/daily-ai-voice-migration.json +146 -0
- package/plugins/kk/publish-presets/daily-ai-voice.json +11 -3
- package/plugins/kk/schedules/kk-collect.grok-bot.md.tmpl +69 -0
- package/plugins/kk/schedules/kk-daily-ai-voice3.cron.tmpl +18 -0
- package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +15 -5
- package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +9 -4
- package/plugins/kk/scripts/daily-scheduled-run.ts +298 -0
- package/plugins/kk/scripts/daily-stages.ts +1403 -156
- package/plugins/kk/scripts/kk-workflow-stages.ts +266 -10
- package/plugins/kk/scripts/safety-artifact-check.ts +277 -0
- package/plugins/kk/scripts/voice3-publish-window.ts +202 -0
- package/plugins/kk/skills/article-adapt/SKILL.md +1 -0
- package/plugins/kk/skills/audio-authoring/SKILL.md +1 -0
- package/plugins/kk/skills/content-judge/SKILL.md +1 -0
- package/plugins/kk/skills/content-safety/SKILL.md +1 -0
- package/plugins/kk/skills/explain-things/SKILL.md +1 -0
- package/plugins/kk/skills/image-authoring/SKILL.md +1 -0
- package/plugins/kk/skills/itc-generating/SKILL.md +1 -0
- package/plugins/kk/skills/publish/SKILL.md +1 -0
- package/plugins/kk/skills/smart-seo/SKILL.md +1 -0
- package/plugins/kk/skills/storm-research/SKILL.md +1 -0
- package/plugins/kk/skills/taste-unslop/SKILL.md +1 -0
- package/plugins/kk/skills/topic/SKILL.md +1 -0
- package/plugins/kk/skills/web-automation/SKILL.md +1 -0
- package/plugins/kk/skills/xhs-images/SKILL.md +1 -0
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +177 -128
- package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +161 -61
- package/plugins/kk/workflows/kk-daily-publish3.yaml +82 -26
- package/plugins/kk/workflows/kk-daily-safety.yaml +156 -69
- package/plugins/kk/workflows/kk-itc-author.yaml +75 -0
- package/plugins/kk/workflows/kk-itc-publish-tail.yaml +25 -3
- package/plugins/kk/workflows/kk-itc.yaml +36 -4
- package/plugins/publishings/bilibili-pub/dist/index.js +2 -1
- package/plugins/publishings/bilibili-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/cnblogs-pub/dist/index.js +2 -1
- package/plugins/publishings/cnblogs-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/csdn-pub/dist/index.js +2 -1
- package/plugins/publishings/csdn-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/infoq-pub/dist/index.js +2 -1
- package/plugins/publishings/infoq-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/jike-pub/dist/index.js +2 -1
- package/plugins/publishings/jike-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/juejin-pub/dist/index.js +2 -1
- package/plugins/publishings/juejin-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/linkedin-pub/dist/index.js +21333 -0
- package/plugins/publishings/linkedin-pub/dist/profile-check.js +21005 -0
- package/plugins/publishings/linkedin-pub/package.json +19 -0
- package/plugins/publishings/linkedin-pub/plugin.json +12 -0
- package/plugins/publishings/linkedin-pub/src/index.ts +705 -0
- package/plugins/publishings/linkedin-pub/src/profile-check.ts +17 -0
- package/plugins/publishings/linkedin-pub/tsconfig.json +4 -0
- package/plugins/publishings/podcast-pub/dist/index.js +46 -8
- package/plugins/publishings/podcast-pub/src/index.ts +62 -5
- package/plugins/publishings/shipinhao-pub/dist/index.js +2 -1
- package/plugins/publishings/shipinhao-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/sspai-pub/dist/index.js +2 -1
- package/plugins/publishings/sspai-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/substack-pub/dist/index.js +2 -1
- package/plugins/publishings/substack-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/surfdash-pub/dist/index.js +461 -53
- package/plugins/publishings/surfdash-pub/src/digest-check.ts +85 -19
- package/plugins/publishings/surfdash-pub/src/index.ts +592 -65
- package/plugins/publishings/threads-pub/dist/index.js +7 -0
- package/plugins/publishings/threads-pub/src/index.ts +12 -0
- package/plugins/publishings/wechatmp-pub/dist/index.js +936 -98
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +60 -3
- package/plugins/publishings/wechatmp-pub/src/flow.ts +1134 -86
- package/plugins/publishings/wechatmp-pub/src/index.ts +112 -30
- package/plugins/publishings/wechatmp-pub/src/page.ts +105 -0
- package/plugins/publishings/weibo-pub/dist/index.js +2 -1
- package/plugins/publishings/weibo-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/x-pub/dist/index.js +9 -1
- package/plugins/publishings/x-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/x-pub/src/index.ts +11 -0
- package/plugins/publishings/xhs-pub/dist/index.js +2 -1
- package/plugins/publishings/xhs-pub/dist/profile-check.js +2 -1
- package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +2 -1
- package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +2 -1
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
copyFileSync,
|
|
58
58
|
existsSync,
|
|
59
59
|
mkdirSync,
|
|
60
|
+
readdirSync,
|
|
60
61
|
readFileSync,
|
|
61
62
|
renameSync,
|
|
62
63
|
rmSync,
|
|
@@ -64,7 +65,7 @@ import {
|
|
|
64
65
|
unlinkSync,
|
|
65
66
|
writeFileSync,
|
|
66
67
|
} from 'node:fs';
|
|
67
|
-
import { basename, join, resolve } from 'node:path';
|
|
68
|
+
import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
68
69
|
import {
|
|
69
70
|
ADMISSION_CRITERIA,
|
|
70
71
|
applyTopicBoundary,
|
|
@@ -88,7 +89,6 @@ import {
|
|
|
88
89
|
PII_FINDING_CATEGORY,
|
|
89
90
|
pluginsArgs,
|
|
90
91
|
readJson,
|
|
91
|
-
readSafetyReviseCount,
|
|
92
92
|
readTopicBoundary,
|
|
93
93
|
requireJson,
|
|
94
94
|
requireSidecar,
|
|
@@ -135,11 +135,42 @@ export function dailyQcContent(args: string[]): void {
|
|
|
135
135
|
const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
|
|
136
136
|
const exclusions = join(workDir, '21-plan', `${runDate}_04_quality-control-content_exclusions.json`);
|
|
137
137
|
const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
|
|
138
|
+
const cachePath = `${candidates}.cache.json`;
|
|
139
|
+
const fingerprint = stageCacheFingerprint(planContent, {
|
|
140
|
+
qcCategories,
|
|
141
|
+
minQuality,
|
|
142
|
+
minImportance,
|
|
143
|
+
minUrgency,
|
|
144
|
+
minImpact,
|
|
145
|
+
minEpisodeItems,
|
|
146
|
+
minMinutes,
|
|
147
|
+
vdriver,
|
|
148
|
+
});
|
|
149
|
+
let recomputed = false;
|
|
138
150
|
timed(workDir, 'quality-control-content', () => {
|
|
139
|
-
|
|
151
|
+
// Voice1's standalone QC tests and old workspaces may carry a candidates artifact without
|
|
152
|
+
// the newer plan input or sidecar. Preserve that explicitly cache-only surface; voice2
|
|
153
|
+
// always has planContent, so its stale/malformed cache is recomputed below.
|
|
154
|
+
const legacyCandidatesCache = isFile(candidates) && !isFile(planContent) && !isFile(cachePath);
|
|
155
|
+
if (stageCacheMatches(candidates, cachePath, fingerprint) || legacyCandidatesCache) {
|
|
140
156
|
console.log(`candidates cached: ${candidates}`);
|
|
141
157
|
return;
|
|
142
158
|
}
|
|
159
|
+
if (isFile(candidates)) {
|
|
160
|
+
const descendants = authoredPlanDescendants(workDir, runDate);
|
|
161
|
+
if (descendants.length > 0) {
|
|
162
|
+
failWith(
|
|
163
|
+
1,
|
|
164
|
+
`QC cache changed after downstream artifacts were authored; refusing to pair new candidates with stale outputs (${descendants.join(', ')})`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
console.log(`candidates cache stale: ${candidates}`);
|
|
168
|
+
}
|
|
169
|
+
for (const stale of [exclusions, `${runDate}_04_quality-control-content_floor-refusal.json`].map((name) =>
|
|
170
|
+
name.startsWith('/') ? name : join(workDir, '21-plan', name),
|
|
171
|
+
)) {
|
|
172
|
+
if (isFile(stale)) unlinkSync(stale);
|
|
173
|
+
}
|
|
143
174
|
sted('write', annotated, () => writeJqText(annotated, metadataOf(readJson(planContent)).docs ?? null));
|
|
144
175
|
kk(
|
|
145
176
|
[
|
|
@@ -164,6 +195,9 @@ export function dailyQcContent(args: string[]): void {
|
|
|
164
195
|
},
|
|
165
196
|
},
|
|
166
197
|
);
|
|
198
|
+
if (!isFile(candidates)) failWith(1, `quality-control-content: ${candidates} not written`);
|
|
199
|
+
sted('write', cachePath, () => writeStageCache(cachePath, fingerprint));
|
|
200
|
+
recomputed = true;
|
|
167
201
|
});
|
|
168
202
|
// Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
|
|
169
203
|
// candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
|
|
@@ -212,7 +246,48 @@ export function dailyQcContent(args: string[]): void {
|
|
|
212
246
|
// already produced. 2026-10-22 cleared the floor on a 4-item pool (21.0 min estimated) that the
|
|
213
247
|
// topic boundary then cut to 3 (14.4 min), so the run paid for a full synthesis the measured
|
|
214
248
|
// gate refused. Covered here it also re-runs on a cached-rerun, which the timed block skipped.
|
|
215
|
-
applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver);
|
|
249
|
+
applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver, recomputed);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Cache only deterministic stage inputs. The source bytes are part of the key so a new collect
|
|
254
|
+
* pool or translated plan cannot inherit a prior run's refusal; the invocation parameters cover
|
|
255
|
+
* every threshold that changes the QC/floor result.
|
|
256
|
+
*/
|
|
257
|
+
function stageCacheFingerprint(sourcePath: string, inputs: Record<string, unknown>): string {
|
|
258
|
+
const source = isFile(sourcePath) ? readFileSync(sourcePath, 'utf-8') : '<missing>';
|
|
259
|
+
return createHash('sha256')
|
|
260
|
+
.update(JSON.stringify({ version: 1, source, inputs }))
|
|
261
|
+
.digest('hex');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function stageCacheMatches(outputPath: string, cachePath: string, fingerprint: string): boolean {
|
|
265
|
+
if (!isFile(outputPath) || !isFile(cachePath)) return false;
|
|
266
|
+
try {
|
|
267
|
+
const cache = JSON.parse(readFileSync(cachePath, 'utf-8')) as { version?: unknown; fingerprint?: unknown };
|
|
268
|
+
return cache.version === 1 && cache.fingerprint === fingerprint;
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function writeStageCache(path: string, fingerprint: string): void {
|
|
275
|
+
writeJsonText(path, { version: 1, fingerprint });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function authoredPlanDescendants(workDir: string, runDate: string): string[] {
|
|
279
|
+
const descendants: string[] = [];
|
|
280
|
+
for (const directory of ['22-article', '23-cover', '24-script', '30-audio', '31-publish']) {
|
|
281
|
+
const root = join(workDir, directory);
|
|
282
|
+
if (!existsSync(root)) continue;
|
|
283
|
+
// The directory contents are stage-owned artifacts. A date-prefixed file means that a
|
|
284
|
+
// previous non-empty run already authored downstream material that must not be silently
|
|
285
|
+
// paired with a newly generated plan.
|
|
286
|
+
for (const entry of readdirSync(root)) {
|
|
287
|
+
if (entry.startsWith(`${runDate}_`)) descendants.push(join(root, entry));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return descendants;
|
|
216
291
|
}
|
|
217
292
|
|
|
218
293
|
/**
|
|
@@ -261,18 +336,26 @@ function applyFloorGate(
|
|
|
261
336
|
runDate: string,
|
|
262
337
|
minMinutes: string,
|
|
263
338
|
vdriver: string,
|
|
339
|
+
clearStaleRefusal = false,
|
|
264
340
|
): void {
|
|
341
|
+
const refusal = join(workDir, '21-plan', `${runDate}_04_quality-control-content_floor-refusal.json`);
|
|
265
342
|
const floor = Number(minMinutes);
|
|
266
|
-
if (minMinutes === '' || !Number.isFinite(floor) || floor <= 0)
|
|
343
|
+
if (minMinutes === '' || !Number.isFinite(floor) || floor <= 0) {
|
|
344
|
+
if (clearStaleRefusal && isFile(refusal)) unlinkSync(refusal);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
267
347
|
const content = readJson(candidates);
|
|
268
348
|
const docs = metadataOf(content).docs;
|
|
269
|
-
if (!Array.isArray(docs) || docs.length === 0)
|
|
349
|
+
if (!Array.isArray(docs) || docs.length === 0) {
|
|
350
|
+
if (clearStaleRefusal && isFile(refusal)) unlinkSync(refusal);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
270
353
|
const { minutes: estimatedMinutes, seconds: estimatedSeconds, chars } = estimateSpokenMinutes(docs, vdriver);
|
|
271
354
|
if (estimatedSeconds >= floor * 60) {
|
|
355
|
+
if (clearStaleRefusal && isFile(refusal)) unlinkSync(refusal);
|
|
272
356
|
console.log(`floor gate: estimated ${estimatedMinutes} min from ${chars} chars — above the ${floor} min floor`);
|
|
273
357
|
return;
|
|
274
358
|
}
|
|
275
|
-
const refusal = join(workDir, '21-plan', `${runDate}_04_quality-control-content_floor-refusal.json`);
|
|
276
359
|
const ids = docs.map((doc) => String((doc as Record<string, unknown> | null)?.id ?? ''));
|
|
277
360
|
sted('write', refusal, () =>
|
|
278
361
|
writeJqText(refusal, {
|
|
@@ -632,13 +715,23 @@ function refuseScriptBelowFloor(
|
|
|
632
715
|
}
|
|
633
716
|
|
|
634
717
|
export function dailyEpisodeDuration(args: string[]): void {
|
|
635
|
-
const [workDir = '', runDate = '', minArg = '', maxArg = '', vdriver = ''] = args;
|
|
718
|
+
const [workDir = '', runDate = '', minArg = '', maxArg = '', vdriver = '', runId = ''] = args;
|
|
636
719
|
if (!workDir) fail('missing work_dir');
|
|
637
720
|
if (!runDate) fail('missing run_date');
|
|
638
721
|
const minMinutes = Number(minArg);
|
|
639
722
|
const maxMinutes = Number(maxArg);
|
|
640
723
|
if (!Number.isFinite(minMinutes) || minMinutes <= 0) fail(`invalid min_minutes: ${minArg || '(empty)'}`);
|
|
641
724
|
if (!Number.isFinite(maxMinutes) || maxMinutes < minMinutes) fail(`invalid max_minutes: ${maxArg || '(empty)'}`);
|
|
725
|
+
// 0329 F06: the publish path must not survive a refusal. The pool-gate token flips to EMPTY so
|
|
726
|
+
// this run's own remaining fan-out skips, and a stale bundle.json from an earlier run (whose
|
|
727
|
+
// runs[] a publish window could still consume) is dropped.
|
|
728
|
+
const invalidatePublishPath = (): void => {
|
|
729
|
+
if (runId !== '') {
|
|
730
|
+
const token = join('.spur', 'run', `${runId}-pool-gate.status`);
|
|
731
|
+
if (isFile(token)) sted('write', token, () => writeRaw(token, 'EMPTY\n'));
|
|
732
|
+
}
|
|
733
|
+
rmSync(join(workDir, 'bundle.json'), { force: true });
|
|
734
|
+
};
|
|
642
735
|
const contentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
|
|
643
736
|
if (!isFile(contentPath)) failWith(1, `episode-duration: audio content not found: ${contentPath}`);
|
|
644
737
|
let raw: unknown;
|
|
@@ -667,7 +760,8 @@ export function dailyEpisodeDuration(args: string[]): void {
|
|
|
667
760
|
// let the pool-gated publish fan package a sub-floor episode — so finish the emptying here.
|
|
668
761
|
if (isFile(refusal)) {
|
|
669
762
|
if (docs.length > 0) emptyCandidates(workDir, runDate);
|
|
670
|
-
|
|
763
|
+
invalidatePublishPath();
|
|
764
|
+
console.error(`episode-duration: duration refusal already recorded (${refusal}) — nothing to do`);
|
|
671
765
|
return;
|
|
672
766
|
}
|
|
673
767
|
const estimated = estimateSpokenMinutes(docs, vdriver);
|
|
@@ -683,6 +777,7 @@ export function dailyEpisodeDuration(args: string[]): void {
|
|
|
683
777
|
}),
|
|
684
778
|
);
|
|
685
779
|
emptyCandidates(workDir, runDate);
|
|
780
|
+
invalidatePublishPath();
|
|
686
781
|
console.error(
|
|
687
782
|
`episode-duration: episode ran ${shown} min (estimate ${estimated.minutes} min), below the ${minMinutes}–${maxMinutes} min requirement — candidates emptied, the run closes as no-content (see ${refusal})`,
|
|
688
783
|
);
|
|
@@ -1511,11 +1606,28 @@ export function dailyPlan(args: string[]): void {
|
|
|
1511
1606
|
// translation. Voice1 omits the arg and still materializes the file (its translate node has no
|
|
1512
1607
|
// skip guard and overwrites it).
|
|
1513
1608
|
const deferDocs = deferPlanDocs === 'true';
|
|
1609
|
+
const target = deferDocs ? planContent : planDocs;
|
|
1610
|
+
const targetCache = `${target}.cache.json`;
|
|
1611
|
+
const fingerprint = stageCacheFingerprint(blended, {
|
|
1612
|
+
runDate,
|
|
1613
|
+
planMaxItems,
|
|
1614
|
+
deferPlanDocs,
|
|
1615
|
+
});
|
|
1514
1616
|
timed(workDir, 'plan', () => {
|
|
1515
|
-
if (
|
|
1617
|
+
if (stageCacheMatches(target, targetCache, fingerprint)) {
|
|
1516
1618
|
console.log(`plan cached: ${deferDocs ? planContent : planDocs}`);
|
|
1517
1619
|
return;
|
|
1518
1620
|
}
|
|
1621
|
+
if (isFile(target)) {
|
|
1622
|
+
const descendants = authoredPlanDescendants(workDir, runDate);
|
|
1623
|
+
if (descendants.length > 0) {
|
|
1624
|
+
failWith(
|
|
1625
|
+
1,
|
|
1626
|
+
`plan cache changed after downstream artifacts were authored; refusing to pair a new plan with stale outputs (${descendants.join(', ')})`,
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
console.log(`plan cache stale: ${target}`);
|
|
1630
|
+
}
|
|
1519
1631
|
kk(
|
|
1520
1632
|
['executor', 'run', 'episode-plan-gen', '--in', blended, '--out', planContent, ...pluginsArgs(pluginsPath)],
|
|
1521
1633
|
{
|
|
@@ -1524,9 +1636,17 @@ export function dailyPlan(args: string[]): void {
|
|
|
1524
1636
|
);
|
|
1525
1637
|
const docs = metadataOf(readJson(planContent)).docs;
|
|
1526
1638
|
if (!Array.isArray(docs) || docs.length === 0) failWith(1, 'plan: content.metadata.docs missing or empty');
|
|
1527
|
-
if (deferDocs)
|
|
1528
|
-
|
|
1529
|
-
|
|
1639
|
+
if (deferDocs) {
|
|
1640
|
+
// A translated plan is a descendant of this content plan. Remove only the stale
|
|
1641
|
+
// translation when no authored downstream artifact exists; otherwise the guard above
|
|
1642
|
+
// fails closed and leaves the operator a reviewable workspace.
|
|
1643
|
+
if (isFile(planDocs)) unlinkSync(planDocs);
|
|
1644
|
+
if (isFile(`${planDocs}.cache.json`)) unlinkSync(`${planDocs}.cache.json`);
|
|
1645
|
+
} else {
|
|
1646
|
+
sted('write', planDocs, () => writeJsonText(planDocs, docs));
|
|
1647
|
+
if (!isFile(planDocs)) failWith(1, `plan: ${planDocs} not written after episode-plan-gen`);
|
|
1648
|
+
}
|
|
1649
|
+
sted('write', targetCache, () => writeStageCache(targetCache, fingerprint));
|
|
1530
1650
|
});
|
|
1531
1651
|
}
|
|
1532
1652
|
|
|
@@ -1769,12 +1889,13 @@ export function buildSocialContent(
|
|
|
1769
1889
|
|
|
1770
1890
|
/**
|
|
1771
1891
|
* Length budgets the social authoring prompt targets, enforced here as a backstop (task 0327 R3).
|
|
1772
|
-
* X counts CJK at 2 (`weighted`); weibo and
|
|
1892
|
+
* X counts CJK at 2 (`weighted`); weibo, threads and linkedin count characters.
|
|
1773
1893
|
*/
|
|
1774
1894
|
const SOCIAL_LIMITS: Record<string, SocialLimit> = {
|
|
1775
1895
|
x: { max: 280, weighted: true },
|
|
1776
1896
|
weibo: { max: 2000 },
|
|
1777
1897
|
threads: { max: 500 },
|
|
1898
|
+
linkedin: { max: 3000 },
|
|
1778
1899
|
};
|
|
1779
1900
|
|
|
1780
1901
|
/** Variant key → file name: `social:x` becomes `social_x.content.json`. */
|
|
@@ -1783,27 +1904,36 @@ function variantFileName(key: string): string {
|
|
|
1783
1904
|
}
|
|
1784
1905
|
|
|
1785
1906
|
/**
|
|
1786
|
-
* `daily-bundle-variants <workDir> <runDate> <pluginsPath
|
|
1787
|
-
* (task 0327 R2/R3).
|
|
1907
|
+
* `daily-bundle-variants <workDir> <runDate> <pluginsPath> [cardsEnabled] [coverEnabled]` — the
|
|
1908
|
+
* generate-side payload builder (task 0327 R2/R3).
|
|
1788
1909
|
*
|
|
1789
1910
|
* Everything the publish side used to assemble inline is produced here instead, once, as bundle
|
|
1790
1911
|
* variants under `30-variants/`. Links are the `{{article_url}}` / `{{podcast_url}}` placeholders,
|
|
1791
1912
|
* because at generate time nothing is published yet; `publish-matrix-send` resolves them from the
|
|
1792
1913
|
* tier-0 receipts. A variant whose source artifact is absent (channel disabled, cards never
|
|
1793
1914
|
* rendered, social copy not authored) is skipped with a log line, never invented.
|
|
1915
|
+
*
|
|
1916
|
+
* 0329 F16: the caller forwards the run's `cards_enabled` / `cover_enabled` switches — a disabled
|
|
1917
|
+
* producer's variant is a skip, not a stale re-read of a previous run's file — and every stale
|
|
1918
|
+
* variant file is wiped before writing, so `30-variants/` after this stage contains only what
|
|
1919
|
+
* THIS pass produced.
|
|
1794
1920
|
*/
|
|
1795
1921
|
export function dailyBundleVariants(args: string[]): void {
|
|
1796
|
-
const [workDir = '', runDate = '', _pluginsPath = ''] = args;
|
|
1922
|
+
const [workDir = '', runDate = '', _pluginsPath = '', cardsEnabledArg = '', coverEnabledArg = ''] = args;
|
|
1797
1923
|
timed(workDir, 'bundle-variants', () => {
|
|
1798
1924
|
const outDir = join(workDir, '30-variants');
|
|
1799
1925
|
sted('mkdir', outDir, () => mkdirSync(outDir, { recursive: true }));
|
|
1926
|
+
// 0329 F16: a rerun after a thinner day must not leave yesterday's variants behind.
|
|
1927
|
+
for (const entry of readdirSync(outDir)) {
|
|
1928
|
+
if (entry.endsWith('.content.json')) rmSync(join(outDir, entry), { force: true });
|
|
1929
|
+
}
|
|
1800
1930
|
const articlePath = join(workDir, '22-article', `${runDate}_06_article_content.json`);
|
|
1801
1931
|
const prepPath = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
|
|
1802
1932
|
const notesPath = join(workDir, '31-publish', `${runDate}_15_show-notes_show-notes.md`);
|
|
1803
1933
|
const cardsPath = join(workDir, '25-xhs', 'content.json');
|
|
1804
1934
|
const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
|
|
1805
1935
|
const socialDir = join(workDir, '26-social');
|
|
1806
|
-
const cover = isFile(coverPath) ? coverPath : undefined;
|
|
1936
|
+
const cover = coverEnabledArg !== 'false' && isFile(coverPath) ? coverPath : undefined;
|
|
1807
1937
|
const links = PLACEHOLDER_LINKS;
|
|
1808
1938
|
const written: string[] = [];
|
|
1809
1939
|
const skipped: string[] = [];
|
|
@@ -1857,9 +1987,12 @@ export function dailyBundleVariants(args: string[]): void {
|
|
|
1857
1987
|
);
|
|
1858
1988
|
}
|
|
1859
1989
|
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1990
|
+
if (cardsEnabledArg === 'false') skipped.push('cards (cards_enabled=false)');
|
|
1991
|
+
else {
|
|
1992
|
+
const cards = readJson(cardsPath);
|
|
1993
|
+
if (cards === null) skipped.push('cards (no rendered card series)');
|
|
1994
|
+
else write('cards@xhs-pub', buildXhsCardsContent(cards, { publishStatus: 'draft' }));
|
|
1995
|
+
}
|
|
1863
1996
|
|
|
1864
1997
|
for (const platform of Object.keys(SOCIAL_LIMITS)) {
|
|
1865
1998
|
const copyPath = join(socialDir, `${platform}.md`);
|
|
@@ -1915,19 +2048,40 @@ export function readKeyPointItems(filePath: string): string[] | undefined {
|
|
|
1915
2048
|
}
|
|
1916
2049
|
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1];
|
|
1917
2050
|
if (!block) return undefined;
|
|
1918
|
-
// Slice from `key_points` to the first top-level key (or block end), then
|
|
1919
|
-
// (`- `)
|
|
2051
|
+
// Slice from `key_points` to the first top-level key (or block end), then read the item lines
|
|
2052
|
+
// (`- `). Folded (`>-`) YAML items need their indented continuation lines joined before the
|
|
2053
|
+
// strict scheduled validator can check the `Label — sentence` contract.
|
|
1920
2054
|
const head = /^key_points:\n/m.exec(block);
|
|
1921
2055
|
if (!head) return undefined;
|
|
1922
2056
|
const body = block.slice(head.index + head[0].length);
|
|
1923
2057
|
const endMatch = /^\S/m.exec(body);
|
|
1924
2058
|
const list = endMatch ? body.slice(0, endMatch.index) : body;
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
2059
|
+
const items: string[] = [];
|
|
2060
|
+
let current: string | undefined;
|
|
2061
|
+
let folded = false;
|
|
2062
|
+
const flush = (): void => {
|
|
2063
|
+
if (current === undefined) return;
|
|
2064
|
+
// A quoted scalar's quotes are YAML syntax, not text — strip them so label lengths are real.
|
|
2065
|
+
items.push(current.replace(/^(['"])(.*)\1$/, '$2').trim());
|
|
2066
|
+
current = undefined;
|
|
2067
|
+
folded = false;
|
|
2068
|
+
};
|
|
2069
|
+
for (const line of list.split('\n')) {
|
|
2070
|
+
const item = /^[ \t]+- (.*)$/.exec(line);
|
|
2071
|
+
if (item !== null) {
|
|
2072
|
+
flush();
|
|
2073
|
+
current = item[1].trim();
|
|
2074
|
+
folded = current === '>' || current === '>-' || current === '|-' || current === '|';
|
|
2075
|
+
// The block indicator is YAML syntax, not text: the item starts on the next line.
|
|
2076
|
+
if (folded) current = '';
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
if (current === undefined || !folded) continue;
|
|
2080
|
+
const continuation = line.trim();
|
|
2081
|
+
if (continuation !== '') current = `${current} ${continuation}`;
|
|
2082
|
+
}
|
|
2083
|
+
flush();
|
|
2084
|
+
return items;
|
|
1931
2085
|
}
|
|
1932
2086
|
|
|
1933
2087
|
/** Count `key_points` list items in a Surfing post's frontmatter; undefined when absent/unreadable. */
|
|
@@ -2113,36 +2267,662 @@ export function dailyPublishPrep(args: string[]): void {
|
|
|
2113
2267
|
});
|
|
2114
2268
|
}
|
|
2115
2269
|
|
|
2270
|
+
type SurfdashDeliveryState = 'missing' | 'success' | 'failure' | 'uncertain';
|
|
2271
|
+
|
|
2272
|
+
interface SurfdashSendIntent {
|
|
2273
|
+
version: 1;
|
|
2274
|
+
target: 'surfdash-pub';
|
|
2275
|
+
runDate: string;
|
|
2276
|
+
payloadPath: string;
|
|
2277
|
+
payloadSha256: string;
|
|
2278
|
+
createdAt: string;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
function surfdashPayloadSha256(path: string): string {
|
|
2282
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
function surfdashNotSentReceipt(resultPath: string, intentPath: string, runDate: string, reason: string): void {
|
|
2286
|
+
const error = `surfdash publish was not sent: ${reason}`;
|
|
2287
|
+
sted('write', resultPath, () =>
|
|
2288
|
+
writeJsonText(resultPath, {
|
|
2289
|
+
ok: false,
|
|
2290
|
+
summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
|
|
2291
|
+
targets: [
|
|
2292
|
+
{
|
|
2293
|
+
target: 'surfdash-pub',
|
|
2294
|
+
ok: false,
|
|
2295
|
+
status: 'failed',
|
|
2296
|
+
error,
|
|
2297
|
+
metadata: { deliveryState: 'not-sent', runDate, intentPath },
|
|
2298
|
+
},
|
|
2299
|
+
],
|
|
2300
|
+
}),
|
|
2301
|
+
);
|
|
2302
|
+
console.error(error);
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
function readSurfdashSendIntent(
|
|
2306
|
+
path: string,
|
|
2307
|
+
): { kind: 'missing' } | { kind: 'valid'; value: SurfdashSendIntent } | { kind: 'invalid'; reason: string } {
|
|
2308
|
+
if (!isFile(path)) return { kind: 'missing' };
|
|
2309
|
+
const parsed = readJson(path);
|
|
2310
|
+
if (parsed === null) return { kind: 'invalid', reason: 'send intent is unreadable or invalid JSON' };
|
|
2311
|
+
if (
|
|
2312
|
+
parsed.version !== 1 ||
|
|
2313
|
+
parsed.target !== 'surfdash-pub' ||
|
|
2314
|
+
typeof parsed.runDate !== 'string' ||
|
|
2315
|
+
typeof parsed.payloadPath !== 'string' ||
|
|
2316
|
+
typeof parsed.createdAt !== 'string' ||
|
|
2317
|
+
typeof parsed.payloadSha256 !== 'string' ||
|
|
2318
|
+
!/^[0-9a-f]{64}$/.test(parsed.payloadSha256)
|
|
2319
|
+
) {
|
|
2320
|
+
return { kind: 'invalid', reason: 'send intent is malformed' };
|
|
2321
|
+
}
|
|
2322
|
+
return { kind: 'valid', value: parsed as unknown as SurfdashSendIntent };
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
function surfdashTargetEntries(result: Record<string, unknown> | null): Record<string, unknown>[] {
|
|
2326
|
+
const targets = result?.targets;
|
|
2327
|
+
if (!Array.isArray(targets)) return [];
|
|
2328
|
+
return targets.filter(
|
|
2329
|
+
(entry): entry is Record<string, unknown> =>
|
|
2330
|
+
entry !== null && typeof entry === 'object' && (entry as Record<string, unknown>).target === 'surfdash-pub',
|
|
2331
|
+
);
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
/** A push can have reached the remote even when the publisher reports a failed receipt. */
|
|
2335
|
+
function surfdashFailureMayHaveRemoteEffect(entry: Record<string, unknown>): boolean {
|
|
2336
|
+
return (
|
|
2337
|
+
entry.ok === false &&
|
|
2338
|
+
entry.status === 'failed' &&
|
|
2339
|
+
(entry.metadata as Record<string, unknown> | undefined)?.deliveryState !== 'not-sent'
|
|
2340
|
+
);
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
function surfdashTargetState(result: Record<string, unknown> | null): SurfdashDeliveryState {
|
|
2344
|
+
const matches = surfdashTargetEntries(result);
|
|
2345
|
+
if (matches.length === 0) return 'missing';
|
|
2346
|
+
// More than one receipt for the same target is ambiguous even when every row says success:
|
|
2347
|
+
// retrying could duplicate a remote post. Preserve the uncertainty until reconciliation.
|
|
2348
|
+
if (matches.length > 1) return 'uncertain';
|
|
2349
|
+
if (
|
|
2350
|
+
matches.some(
|
|
2351
|
+
(entry) =>
|
|
2352
|
+
(entry.metadata as Record<string, unknown> | undefined)?.deliveryState === 'uncertain' ||
|
|
2353
|
+
surfdashFailureMayHaveRemoteEffect(entry),
|
|
2354
|
+
)
|
|
2355
|
+
) {
|
|
2356
|
+
return 'uncertain';
|
|
2357
|
+
}
|
|
2358
|
+
const target = matches[0];
|
|
2359
|
+
if (target.ok === true && target.status === 'published') return 'success';
|
|
2360
|
+
if (target.ok === false && target.status === 'failed') return 'failure';
|
|
2361
|
+
return 'missing';
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
function writeSurfdashUncertainReceipt(
|
|
2365
|
+
resultPath: string,
|
|
2366
|
+
intentPath: string,
|
|
2367
|
+
runDate: string,
|
|
2368
|
+
payloadSha256: string | undefined,
|
|
2369
|
+
currentPayloadSha256: string | undefined,
|
|
2370
|
+
reason: string,
|
|
2371
|
+
identity: { postPath?: string; localeFingerprint?: string } = {},
|
|
2372
|
+
): void {
|
|
2373
|
+
const metadata: Record<string, unknown> = {
|
|
2374
|
+
deliveryState: 'uncertain',
|
|
2375
|
+
runDate,
|
|
2376
|
+
intentPath,
|
|
2377
|
+
...(payloadSha256 ? { payloadSha256 } : {}),
|
|
2378
|
+
...(currentPayloadSha256 ? { currentPayloadSha256 } : {}),
|
|
2379
|
+
...(identity.postPath ? { postPath: identity.postPath } : {}),
|
|
2380
|
+
...(identity.localeFingerprint ? { localeFingerprint: identity.localeFingerprint } : {}),
|
|
2381
|
+
};
|
|
2382
|
+
const error = `surfdash delivery is uncertain: ${reason}; reconcile the dated remote post/commit before clearing this uncertainty and retrying`;
|
|
2383
|
+
sted('write', resultPath, () =>
|
|
2384
|
+
writeJsonText(resultPath, {
|
|
2385
|
+
ok: false,
|
|
2386
|
+
summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
|
|
2387
|
+
targets: [
|
|
2388
|
+
{
|
|
2389
|
+
target: 'surfdash-pub',
|
|
2390
|
+
ok: false,
|
|
2391
|
+
status: 'failed',
|
|
2392
|
+
error,
|
|
2393
|
+
metadata,
|
|
2394
|
+
},
|
|
2395
|
+
],
|
|
2396
|
+
}),
|
|
2397
|
+
);
|
|
2398
|
+
console.error(error);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
const SURFDASH_LOCALES = ['zh', 'en', 'ja'] as const;
|
|
2402
|
+
type SurfdashLocale = (typeof SURFDASH_LOCALES)[number];
|
|
2403
|
+
|
|
2404
|
+
interface SurfdashLocaleValidationEvidence {
|
|
2405
|
+
runDate: string;
|
|
2406
|
+
postPath: string;
|
|
2407
|
+
localePaths: Record<SurfdashLocale, string>;
|
|
2408
|
+
localeDigests: Record<SurfdashLocale, string>;
|
|
2409
|
+
fingerprint: string;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
interface SurfdashPublishIdentity {
|
|
2413
|
+
postPath: string;
|
|
2414
|
+
payloadSha256: string;
|
|
2415
|
+
localeFingerprint: string;
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
function surfdashPublishIdentity(entry: Record<string, unknown> | undefined): SurfdashPublishIdentity | undefined {
|
|
2419
|
+
if (entry === undefined) return undefined;
|
|
2420
|
+
const metadata = metadataOf(entry);
|
|
2421
|
+
if (
|
|
2422
|
+
typeof metadata.postPath !== 'string' ||
|
|
2423
|
+
typeof metadata.payloadSha256 !== 'string' ||
|
|
2424
|
+
!/^[0-9a-f]{64}$/.test(metadata.payloadSha256) ||
|
|
2425
|
+
typeof metadata.localeFingerprint !== 'string' ||
|
|
2426
|
+
!/^[0-9a-f]{64}$/.test(metadata.localeFingerprint)
|
|
2427
|
+
) {
|
|
2428
|
+
return undefined;
|
|
2429
|
+
}
|
|
2430
|
+
return {
|
|
2431
|
+
postPath: metadata.postPath,
|
|
2432
|
+
payloadSha256: metadata.payloadSha256,
|
|
2433
|
+
localeFingerprint: metadata.localeFingerprint,
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
function localeSiblingPaths(zhPostPath: string): Record<SurfdashLocale, string> | undefined {
|
|
2438
|
+
if (!zhPostPath.includes('/zh/')) return undefined;
|
|
2439
|
+
const en = zhPostPath.replace('/zh/', '/en/');
|
|
2440
|
+
const ja = zhPostPath.replace('/zh/', '/ja/');
|
|
2441
|
+
if (en === zhPostPath || ja === zhPostPath) return undefined;
|
|
2442
|
+
return { zh: zhPostPath, en, ja };
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
function validateLocaleMarkers(workDir: string, runDate: string, zhPostPath: string, step: string): void {
|
|
2446
|
+
const localePaths = localeSiblingPaths(zhPostPath);
|
|
2447
|
+
if (localePaths === undefined) {
|
|
2448
|
+
failWith(1, `${step}: zh post path does not contain a /zh/ locale segment: ${zhPostPath}`);
|
|
2449
|
+
}
|
|
2450
|
+
for (const locale of ['en', 'ja'] as const) {
|
|
2451
|
+
const marker = join(workDir, '31-publish', `${runDate}_20_translate-localize_${locale}-post-path.txt`);
|
|
2452
|
+
const markerPath = isFile(marker) ? readFileSync(marker, 'utf-8').trim() : '';
|
|
2453
|
+
const expectedPath = localePaths[locale];
|
|
2454
|
+
if (markerPath === '' || !isAbsolute(markerPath) || markerPath !== expectedPath || !isFile(expectedPath)) {
|
|
2455
|
+
failWith(1, `${step}: ${locale} marker must name the absolute derived sibling ${expectedPath}: ${marker}`);
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
function surfdashLocaleValidationEvidence(
|
|
2461
|
+
zhPostPath: string,
|
|
2462
|
+
runDate: string,
|
|
2463
|
+
): { ok: true; evidence: SurfdashLocaleValidationEvidence } | { ok: false; reason: string } {
|
|
2464
|
+
const localePaths = localeSiblingPaths(zhPostPath);
|
|
2465
|
+
if (localePaths === undefined) {
|
|
2466
|
+
return { ok: false, reason: `zh post path does not contain a /zh/ locale segment: ${zhPostPath}` };
|
|
2467
|
+
}
|
|
2468
|
+
const localeDigests = {} as Record<SurfdashLocale, string>;
|
|
2469
|
+
for (const locale of SURFDASH_LOCALES) {
|
|
2470
|
+
const path = localePaths[locale];
|
|
2471
|
+
if (!isFile(path)) return { ok: false, reason: `${locale} locale file is missing: ${path}` };
|
|
2472
|
+
try {
|
|
2473
|
+
localeDigests[locale] = createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
2474
|
+
} catch (error: unknown) {
|
|
2475
|
+
return {
|
|
2476
|
+
ok: false,
|
|
2477
|
+
reason: `${locale} locale file cannot be fingerprinted: ${error instanceof Error ? error.message : String(error)}`,
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
const identity = { runDate, postPath: zhPostPath, localePaths, localeDigests };
|
|
2482
|
+
return {
|
|
2483
|
+
ok: true,
|
|
2484
|
+
evidence: {
|
|
2485
|
+
...identity,
|
|
2486
|
+
fingerprint: createHash('sha256').update(JSON.stringify(identity)).digest('hex'),
|
|
2487
|
+
},
|
|
2488
|
+
};
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
function validateSurfdashLocaleGate(
|
|
2492
|
+
validationPath: string,
|
|
2493
|
+
prepareResultPath: string,
|
|
2494
|
+
runDate: string,
|
|
2495
|
+
): { ok: true; evidence: SurfdashLocaleValidationEvidence } | { ok: false; reason: string } {
|
|
2496
|
+
const validation = isFile(validationPath) ? readJson(validationPath) : null;
|
|
2497
|
+
if (validation?.ok !== true || validation.target !== 'surfdash-pub' || validation.status !== 'validated') {
|
|
2498
|
+
return { ok: false, reason: `missing or invalid validation receipt: ${validationPath}` };
|
|
2499
|
+
}
|
|
2500
|
+
const metadata = metadataOf(validation);
|
|
2501
|
+
if (metadata.runDate !== runDate || typeof metadata.postPath !== 'string') {
|
|
2502
|
+
return { ok: false, reason: `validation receipt date/path does not match run ${runDate}` };
|
|
2503
|
+
}
|
|
2504
|
+
const prepared = isFile(prepareResultPath) ? readJson(prepareResultPath) : null;
|
|
2505
|
+
const preparedTargets = Array.isArray(prepared?.targets)
|
|
2506
|
+
? prepared.targets.filter(
|
|
2507
|
+
(entry): entry is Record<string, unknown> =>
|
|
2508
|
+
entry !== null &&
|
|
2509
|
+
typeof entry === 'object' &&
|
|
2510
|
+
(entry as Record<string, unknown>).target === 'surfdash-pub',
|
|
2511
|
+
)
|
|
2512
|
+
: [];
|
|
2513
|
+
if (
|
|
2514
|
+
preparedTargets.length !== 1 ||
|
|
2515
|
+
preparedTargets[0].ok !== true ||
|
|
2516
|
+
preparedTargets[0].status !== 'prepared' ||
|
|
2517
|
+
metadataOf(preparedTargets[0]).postPath !== metadata.postPath
|
|
2518
|
+
) {
|
|
2519
|
+
return { ok: false, reason: `current Surfdash prepare receipt is missing or does not match ${validationPath}` };
|
|
2520
|
+
}
|
|
2521
|
+
const current = surfdashLocaleValidationEvidence(metadata.postPath, runDate);
|
|
2522
|
+
if (!current.ok) return current;
|
|
2523
|
+
const recordedPaths = metadata.localePaths;
|
|
2524
|
+
const recordedDigests = metadata.localeDigests;
|
|
2525
|
+
if (
|
|
2526
|
+
recordedPaths === null ||
|
|
2527
|
+
typeof recordedPaths !== 'object' ||
|
|
2528
|
+
recordedDigests === null ||
|
|
2529
|
+
typeof recordedDigests !== 'object' ||
|
|
2530
|
+
typeof metadata.fingerprint !== 'string'
|
|
2531
|
+
) {
|
|
2532
|
+
return { ok: false, reason: `validation receipt fingerprint is missing or malformed: ${validationPath}` };
|
|
2533
|
+
}
|
|
2534
|
+
if (
|
|
2535
|
+
JSON.stringify(recordedPaths) !== JSON.stringify(current.evidence.localePaths) ||
|
|
2536
|
+
JSON.stringify(recordedDigests) !== JSON.stringify(current.evidence.localeDigests) ||
|
|
2537
|
+
metadata.fingerprint !== current.evidence.fingerprint
|
|
2538
|
+
) {
|
|
2539
|
+
return { ok: false, reason: `locale files changed after validation; refresh ${validationPath}` };
|
|
2540
|
+
}
|
|
2541
|
+
return current;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2116
2544
|
export function dailyPublishSurfdash(args: string[]): void {
|
|
2117
|
-
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
2545
|
+
const [workDir = '', runDate = '', pluginsPath = '', requireLocaleValidation = 'false'] = args;
|
|
2118
2546
|
const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
|
|
2119
2547
|
const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
|
|
2548
|
+
const intentPath = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_intent.json`);
|
|
2549
|
+
const prepareResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_result.json`);
|
|
2550
|
+
const validationPath = join(workDir, '31-publish', `${runDate}_24_translate-validate_result.json`);
|
|
2551
|
+
const preparedInput = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_prepared_content.json`);
|
|
2552
|
+
const transportResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_transport_result.json`);
|
|
2120
2553
|
timed(workDir, 'publish-surfdash', () => {
|
|
2121
|
-
|
|
2554
|
+
const priorResult = readJson(result);
|
|
2555
|
+
const priorTargets = surfdashTargetEntries(priorResult);
|
|
2556
|
+
const priorState = surfdashTargetState(priorResult);
|
|
2557
|
+
if (priorState === 'uncertain') {
|
|
2558
|
+
const priorTarget = priorTargets[0];
|
|
2559
|
+
if (
|
|
2560
|
+
priorTarget !== undefined &&
|
|
2561
|
+
(priorTarget.metadata as Record<string, unknown> | undefined)?.deliveryState !== 'uncertain'
|
|
2562
|
+
) {
|
|
2563
|
+
const priorError =
|
|
2564
|
+
typeof priorTarget.error === 'string'
|
|
2565
|
+
? priorTarget.error
|
|
2566
|
+
: 'publisher reported an ambiguous delivery failure';
|
|
2567
|
+
writeSurfdashUncertainReceipt(result, intentPath, runDate, undefined, undefined, priorError);
|
|
2568
|
+
}
|
|
2569
|
+
console.log(`publish-surfdash blocked: uncertain delivery is sticky in ${result}`);
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
// The two-phase voice2 workflow explicitly requires a successful local locale gate before
|
|
2574
|
+
// the publisher can invoke refine/CMS/git. Legacy voice1/manual callers omit the fourth
|
|
2575
|
+
// argument and retain their historical publish behavior.
|
|
2576
|
+
let currentIdentity: SurfdashPublishIdentity | undefined;
|
|
2577
|
+
let currentLocaleEvidence: SurfdashLocaleValidationEvidence | undefined;
|
|
2578
|
+
if (requireLocaleValidation === 'true') {
|
|
2579
|
+
const validation = validateSurfdashLocaleGate(validationPath, prepareResult, runDate);
|
|
2580
|
+
if (!validation.ok) {
|
|
2581
|
+
const detail = validation.reason;
|
|
2582
|
+
if (priorState === 'success') {
|
|
2583
|
+
writeSurfdashUncertainReceipt(
|
|
2584
|
+
result,
|
|
2585
|
+
intentPath,
|
|
2586
|
+
runDate,
|
|
2587
|
+
undefined,
|
|
2588
|
+
undefined,
|
|
2589
|
+
`current locale validation is not the one used for the cached success: ${detail}`,
|
|
2590
|
+
);
|
|
2591
|
+
} else {
|
|
2592
|
+
surfdashNotSentReceipt(result, intentPath, runDate, `blocked by locale validation: ${detail}`);
|
|
2593
|
+
}
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
currentLocaleEvidence = validation.evidence;
|
|
2597
|
+
if (!isFile(prep)) {
|
|
2598
|
+
const detail = `prepared payload is missing: ${prep}`;
|
|
2599
|
+
if (priorState === 'success') {
|
|
2600
|
+
writeSurfdashUncertainReceipt(result, intentPath, runDate, undefined, undefined, detail);
|
|
2601
|
+
} else {
|
|
2602
|
+
surfdashNotSentReceipt(result, intentPath, runDate, detail);
|
|
2603
|
+
}
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
currentIdentity = {
|
|
2607
|
+
postPath: validation.evidence.postPath,
|
|
2608
|
+
payloadSha256: surfdashPayloadSha256(prep),
|
|
2609
|
+
localeFingerprint: validation.evidence.fingerprint,
|
|
2610
|
+
};
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
if (priorState === 'success') {
|
|
2614
|
+
// Voice1 and manual callers omit the locale-validation argument and historically
|
|
2615
|
+
// treated a recorded success as a resume-safe cache hit. Voice2 supplies `true` and
|
|
2616
|
+
// must keep the exact identity binding below; do not make the legacy surface depend
|
|
2617
|
+
// on the new locale fingerprint receipt.
|
|
2618
|
+
if (requireLocaleValidation !== 'true') {
|
|
2619
|
+
console.log(`publish-surfdash cached: surfdash target ok in ${result}`);
|
|
2620
|
+
return;
|
|
2621
|
+
}
|
|
2622
|
+
const cachedIdentity = surfdashPublishIdentity(priorTargets[0]);
|
|
2623
|
+
if (
|
|
2624
|
+
currentIdentity === undefined ||
|
|
2625
|
+
cachedIdentity === undefined ||
|
|
2626
|
+
cachedIdentity.postPath !== currentIdentity.postPath ||
|
|
2627
|
+
cachedIdentity.payloadSha256 !== currentIdentity.payloadSha256 ||
|
|
2628
|
+
cachedIdentity.localeFingerprint !== currentIdentity.localeFingerprint
|
|
2629
|
+
) {
|
|
2630
|
+
writeSurfdashUncertainReceipt(
|
|
2631
|
+
result,
|
|
2632
|
+
intentPath,
|
|
2633
|
+
runDate,
|
|
2634
|
+
cachedIdentity?.payloadSha256,
|
|
2635
|
+
currentIdentity?.payloadSha256,
|
|
2636
|
+
'cached success has no exact postPath/payloadSha256/localeFingerprint identity for the current material',
|
|
2637
|
+
currentIdentity,
|
|
2638
|
+
);
|
|
2639
|
+
return;
|
|
2640
|
+
}
|
|
2122
2641
|
console.log(`publish-surfdash cached: surfdash target ok in ${result}`);
|
|
2123
2642
|
return;
|
|
2124
2643
|
}
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2644
|
+
|
|
2645
|
+
const intent = readSurfdashSendIntent(intentPath);
|
|
2646
|
+
if (intent.kind === 'invalid') {
|
|
2647
|
+
writeSurfdashUncertainReceipt(result, intentPath, runDate, undefined, undefined, intent.reason);
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
let payloadSha256: string | undefined = currentIdentity?.payloadSha256;
|
|
2652
|
+
if (intent.kind === 'valid') {
|
|
2653
|
+
if (intent.value.runDate !== runDate) {
|
|
2654
|
+
writeSurfdashUncertainReceipt(
|
|
2655
|
+
result,
|
|
2656
|
+
intentPath,
|
|
2657
|
+
runDate,
|
|
2658
|
+
intent.value.payloadSha256,
|
|
2659
|
+
undefined,
|
|
2660
|
+
`send intent run date ${intent.value.runDate} does not match requested run date ${runDate}`,
|
|
2661
|
+
);
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2664
|
+
// An existing intent binds this dated post to the original bytes. A changed payload is
|
|
2665
|
+
// never allowed to overwrite the same slug after an interrupted remote send.
|
|
2666
|
+
if (isFile(prep)) payloadSha256 = surfdashPayloadSha256(prep);
|
|
2667
|
+
if (payloadSha256 !== undefined && payloadSha256 !== intent.value.payloadSha256) {
|
|
2668
|
+
writeSurfdashUncertainReceipt(
|
|
2669
|
+
result,
|
|
2670
|
+
intentPath,
|
|
2671
|
+
runDate,
|
|
2672
|
+
intent.value.payloadSha256,
|
|
2673
|
+
payloadSha256,
|
|
2674
|
+
'prepared payload changed after the send intent',
|
|
2675
|
+
);
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
if (priorState === 'missing') {
|
|
2679
|
+
writeSurfdashUncertainReceipt(
|
|
2680
|
+
result,
|
|
2681
|
+
intentPath,
|
|
2682
|
+
runDate,
|
|
2683
|
+
intent.value.payloadSha256,
|
|
2684
|
+
payloadSha256,
|
|
2685
|
+
'no publish result was recorded after the prior send intent',
|
|
2686
|
+
);
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
if (payloadSha256 === undefined) {
|
|
2690
|
+
writeSurfdashUncertainReceipt(
|
|
2691
|
+
result,
|
|
2692
|
+
intentPath,
|
|
2693
|
+
runDate,
|
|
2694
|
+
intent.value.payloadSha256,
|
|
2695
|
+
undefined,
|
|
2696
|
+
'prepared payload is missing, so the original request cannot be replayed safely',
|
|
2697
|
+
);
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
} else {
|
|
2701
|
+
if (!isFile(prep)) failWith(1, `publish-surfdash: required input not found: ${prep}`);
|
|
2702
|
+
payloadSha256 = surfdashPayloadSha256(prep);
|
|
2703
|
+
const sendIntent: SurfdashSendIntent = {
|
|
2704
|
+
version: 1,
|
|
2705
|
+
target: 'surfdash-pub',
|
|
2706
|
+
runDate,
|
|
2707
|
+
payloadPath: prep,
|
|
2708
|
+
payloadSha256,
|
|
2709
|
+
createdAt: new Date().toISOString(),
|
|
2710
|
+
};
|
|
2711
|
+
sted('write', intentPath, () => writeJsonText(intentPath, sendIntent));
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
if (requireLocaleValidation === 'true' && currentIdentity !== undefined) {
|
|
2715
|
+
const prepared = requireJson(prep, 'publish-surfdash');
|
|
2716
|
+
sted('write', preparedInput, () =>
|
|
2717
|
+
writeJqText(preparedInput, {
|
|
2718
|
+
...prepared,
|
|
2719
|
+
options: {
|
|
2720
|
+
operation: 'publish-prepared',
|
|
2721
|
+
postPath: currentIdentity.postPath,
|
|
2722
|
+
runDate,
|
|
2723
|
+
expectedPostSha256: currentLocaleEvidence.localeDigests.zh,
|
|
2724
|
+
expectedLocaleFingerprint: currentIdentity.localeFingerprint,
|
|
2725
|
+
},
|
|
2726
|
+
}),
|
|
2727
|
+
);
|
|
2728
|
+
// `kk executor run` normally clears --out itself, but a discovery failure can return
|
|
2729
|
+
// before the executor owns that path. Remove any previous transport receipt here so a
|
|
2730
|
+
// stale success can never be promoted into this invocation's publish result.
|
|
2731
|
+
rmSync(transportResult, { force: true });
|
|
2732
|
+
kk(
|
|
2733
|
+
[
|
|
2734
|
+
'executor',
|
|
2735
|
+
'run',
|
|
2736
|
+
'surfdash-pub',
|
|
2737
|
+
'--in',
|
|
2738
|
+
preparedInput,
|
|
2739
|
+
'--out',
|
|
2740
|
+
transportResult,
|
|
2741
|
+
...pluginsArgs(pluginsPath),
|
|
2742
|
+
],
|
|
2743
|
+
{
|
|
2744
|
+
unset: NESTED_SPUR_UNSET,
|
|
2745
|
+
env: { SURFDASH_LOCALE: 'zh' },
|
|
2746
|
+
soft: true,
|
|
2747
|
+
},
|
|
2748
|
+
);
|
|
2749
|
+
const transported = readJson(transportResult);
|
|
2750
|
+
const identityMetadata = {
|
|
2751
|
+
postPath: currentIdentity.postPath,
|
|
2752
|
+
payloadSha256: currentIdentity.payloadSha256,
|
|
2753
|
+
localeFingerprint: currentIdentity.localeFingerprint,
|
|
2754
|
+
runDate,
|
|
2755
|
+
};
|
|
2756
|
+
if (transported?.ok === true) {
|
|
2757
|
+
sted('write', result, () =>
|
|
2758
|
+
writeJsonText(result, {
|
|
2759
|
+
ok: true,
|
|
2760
|
+
summary: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
|
|
2761
|
+
targets: [
|
|
2762
|
+
{
|
|
2763
|
+
target: 'surfdash-pub',
|
|
2764
|
+
ok: true,
|
|
2765
|
+
status: 'published',
|
|
2766
|
+
...(typeof transported.url === 'string' ? { url: transported.url } : {}),
|
|
2767
|
+
metadata: { ...metadataOf(transported), ...identityMetadata },
|
|
2768
|
+
},
|
|
2769
|
+
],
|
|
2770
|
+
}),
|
|
2771
|
+
);
|
|
2772
|
+
} else if (transported?.ok === false && metadataOf(transported).deliveryState === 'not-sent') {
|
|
2773
|
+
sted('write', result, () =>
|
|
2774
|
+
writeJsonText(result, {
|
|
2775
|
+
ok: false,
|
|
2776
|
+
summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
|
|
2777
|
+
targets: [
|
|
2778
|
+
{
|
|
2779
|
+
target: 'surfdash-pub',
|
|
2780
|
+
ok: false,
|
|
2781
|
+
status: 'failed',
|
|
2782
|
+
error:
|
|
2783
|
+
typeof transported.error === 'string'
|
|
2784
|
+
? transported.error
|
|
2785
|
+
: 'publisher rejected the prepared post',
|
|
2786
|
+
metadata: {
|
|
2787
|
+
...metadataOf(transported),
|
|
2788
|
+
...identityMetadata,
|
|
2789
|
+
deliveryState: 'not-sent',
|
|
2790
|
+
},
|
|
2791
|
+
},
|
|
2792
|
+
],
|
|
2793
|
+
}),
|
|
2794
|
+
);
|
|
2795
|
+
} else {
|
|
2796
|
+
writeSurfdashUncertainReceipt(
|
|
2797
|
+
result,
|
|
2798
|
+
intentPath,
|
|
2799
|
+
runDate,
|
|
2800
|
+
payloadSha256,
|
|
2801
|
+
payloadSha256,
|
|
2802
|
+
typeof transported?.error === 'string'
|
|
2803
|
+
? `publisher reported an ambiguous delivery failure: ${transported.error}`
|
|
2804
|
+
: 'no publish result was recorded after prepared dispatch',
|
|
2805
|
+
currentIdentity,
|
|
2806
|
+
);
|
|
2807
|
+
}
|
|
2808
|
+
} else {
|
|
2809
|
+
kk(
|
|
2810
|
+
[
|
|
2811
|
+
'executor',
|
|
2812
|
+
'fan-out',
|
|
2813
|
+
'--content',
|
|
2814
|
+
prep,
|
|
2815
|
+
'--target',
|
|
2816
|
+
'surfdash-pub',
|
|
2817
|
+
'--out',
|
|
2818
|
+
result,
|
|
2819
|
+
...pluginsArgs(pluginsPath),
|
|
2820
|
+
],
|
|
2821
|
+
{
|
|
2822
|
+
unset: NESTED_SPUR_UNSET,
|
|
2823
|
+
env: { SURFDASH_LOCALE: 'zh' },
|
|
2824
|
+
soft: true,
|
|
2825
|
+
},
|
|
2826
|
+
);
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
// A killed or unusable child can leave a real remote commit with no aggregate receipt. The
|
|
2830
|
+
// intent now makes that state explicit; the next invocation will refuse to dispatch again.
|
|
2831
|
+
const afterState = surfdashTargetState(readJson(result));
|
|
2832
|
+
if (afterState === 'missing' || afterState === 'uncertain') {
|
|
2833
|
+
const afterTarget = surfdashTargetEntries(readJson(result))[0];
|
|
2834
|
+
const afterError =
|
|
2835
|
+
afterState === 'uncertain' && afterTarget !== undefined && typeof afterTarget.error === 'string'
|
|
2836
|
+
? `publisher reported an ambiguous delivery failure: ${afterTarget.error}`
|
|
2837
|
+
: 'no publish result was recorded after dispatch';
|
|
2838
|
+
writeSurfdashUncertainReceipt(
|
|
2134
2839
|
result,
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2840
|
+
intentPath,
|
|
2841
|
+
runDate,
|
|
2842
|
+
payloadSha256,
|
|
2843
|
+
payloadSha256,
|
|
2844
|
+
afterError,
|
|
2845
|
+
currentIdentity,
|
|
2846
|
+
);
|
|
2847
|
+
}
|
|
2142
2848
|
console.log('publish-surfdash: surfdash target dispatched; classification happens after podcast publish');
|
|
2143
2849
|
});
|
|
2144
2850
|
}
|
|
2145
2851
|
|
|
2852
|
+
/**
|
|
2853
|
+
* Prepare the local zh post before locale scaffolding/translation. This stage deliberately stops
|
|
2854
|
+
* at the publisher's file-materialization seam; the normal `publish-surfdash` stage remains the
|
|
2855
|
+
* only operation allowed to invoke Surfdash's refine/CMS/git publishing path.
|
|
2856
|
+
*/
|
|
2857
|
+
export function dailyPrepareSurfdash(args: string[]): void {
|
|
2858
|
+
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
2859
|
+
const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
|
|
2860
|
+
const input = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_content.json`);
|
|
2861
|
+
const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_result.json`);
|
|
2862
|
+
const transportResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare-transport_result.json`);
|
|
2863
|
+
timed(workDir, 'prepare-surfdash', () => {
|
|
2864
|
+
try {
|
|
2865
|
+
if (!isFile(prep)) failWith(1, `prepare-surfdash: required input not found: ${prep}`);
|
|
2866
|
+
const content = requireJson(prep, 'prepare-surfdash');
|
|
2867
|
+
sted('write', input, () => writeJqText(input, { ...content, options: { operation: 'prepare' } }));
|
|
2868
|
+
kk(
|
|
2869
|
+
[
|
|
2870
|
+
'executor',
|
|
2871
|
+
'run',
|
|
2872
|
+
'surfdash-pub',
|
|
2873
|
+
'--in',
|
|
2874
|
+
input,
|
|
2875
|
+
'--out',
|
|
2876
|
+
transportResult,
|
|
2877
|
+
...pluginsArgs(pluginsPath),
|
|
2878
|
+
],
|
|
2879
|
+
{
|
|
2880
|
+
unset: NESTED_SPUR_UNSET,
|
|
2881
|
+
// The scheduled source post is always zh. Do not let a caller's inherited
|
|
2882
|
+
// locale select an en/ja materialization and then make the sibling gate
|
|
2883
|
+
// compare the wrong source tree.
|
|
2884
|
+
env: { SURFDASH_LOCALE: 'zh' },
|
|
2885
|
+
},
|
|
2886
|
+
);
|
|
2887
|
+
const prepared = readJson(transportResult);
|
|
2888
|
+
const postPath = metadataOf(prepared).postPath;
|
|
2889
|
+
if (prepared?.ok !== true || typeof postPath !== 'string') {
|
|
2890
|
+
failWith(1, `prepare-surfdash: publisher did not return a prepared zh post path: ${transportResult}`);
|
|
2891
|
+
}
|
|
2892
|
+
if (postPath === '' || !isFile(postPath)) {
|
|
2893
|
+
failWith(1, `prepare-surfdash: publisher did not return a prepared zh post path: ${result}`);
|
|
2894
|
+
}
|
|
2895
|
+
sted('write', result, () =>
|
|
2896
|
+
writeJqText(result, {
|
|
2897
|
+
ok: true,
|
|
2898
|
+
summary: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
|
|
2899
|
+
targets: [
|
|
2900
|
+
{
|
|
2901
|
+
target: 'surfdash-pub',
|
|
2902
|
+
ok: true,
|
|
2903
|
+
status: 'prepared',
|
|
2904
|
+
...(typeof prepared?.url === 'string' ? { url: prepared.url } : {}),
|
|
2905
|
+
metadata: metadataOf(prepared),
|
|
2906
|
+
},
|
|
2907
|
+
],
|
|
2908
|
+
}),
|
|
2909
|
+
);
|
|
2910
|
+
console.log(`prepare-surfdash: prepared zh post ${postPath}`);
|
|
2911
|
+
} catch (error: unknown) {
|
|
2912
|
+
if (error instanceof StageFailure && error.status === 127) throw error;
|
|
2913
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2914
|
+
sted('write', result, () =>
|
|
2915
|
+
writeJqText(result, {
|
|
2916
|
+
ok: false,
|
|
2917
|
+
summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
|
|
2918
|
+
targets: [{ target: 'surfdash-pub', ok: false, status: 'failed', error: message }],
|
|
2919
|
+
}),
|
|
2920
|
+
);
|
|
2921
|
+
console.error(message);
|
|
2922
|
+
}
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2146
2926
|
/**
|
|
2147
2927
|
* WeChat MP: drive the console via `wechatmp-pub`'s browser transport and save the article to the
|
|
2148
2928
|
* draft box (草稿箱). Draft by default; the 发表 flow runs only on an explicit launch-time
|
|
@@ -2327,6 +3107,30 @@ export function dailyQualityReport(args: string[]): void {
|
|
|
2327
3107
|
});
|
|
2328
3108
|
}
|
|
2329
3109
|
|
|
3110
|
+
/**
|
|
3111
|
+
* 0329 F23 — the verdict half of `quality`. The report only printed, so a failed audio qc sailed
|
|
3112
|
+
* on into show-notes, the bundle and the publish windows (2026-10-25: an episode at 41/100 was
|
|
3113
|
+
* packaged and queued for tier-0 sends). Reads the same `metadata.qc` as `daily-quality-report`
|
|
3114
|
+
* and fails loud when the verdict is missing or `passed` is false.
|
|
3115
|
+
*/
|
|
3116
|
+
export function dailyQualityVerdict(args: string[]): void {
|
|
3117
|
+
const [workDir = '', runDate = ''] = args;
|
|
3118
|
+
const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
|
|
3119
|
+
timed(workDir, 'quality-verdict', () => {
|
|
3120
|
+
if (!isFile(content)) failWith(1, `quality-verdict: no audio content: ${content}`);
|
|
3121
|
+
const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
|
|
3122
|
+
if (qc === undefined) failWith(1, `quality-verdict: no qc verdict in ${content}`);
|
|
3123
|
+
if (qc.passed !== true) {
|
|
3124
|
+
const issues = Array.isArray(qc.criticalIssues) ? qc.criticalIssues : [];
|
|
3125
|
+
failWith(
|
|
3126
|
+
1,
|
|
3127
|
+
`quality-verdict: audio qc failed (overallScore ${String(qc.overallScore ?? '?')}) — ${issues.map(String).join('; ') || 'no issue detail on the verdict'}`,
|
|
3128
|
+
);
|
|
3129
|
+
}
|
|
3130
|
+
console.log(`quality-verdict: audio qc passed (overallScore ${String(qc.overallScore ?? '?')})`);
|
|
3131
|
+
});
|
|
3132
|
+
}
|
|
3133
|
+
|
|
2330
3134
|
/**
|
|
2331
3135
|
* 0171 R3: Apple Podcasts reads the enclosure's ID3 tags, so the published MP3 carries
|
|
2332
3136
|
* title/artist/album/date. `-c copy` moves the audio frames without a re-encode, and the temp
|
|
@@ -2357,18 +3161,19 @@ export function dailyQualityReport(args: string[]): void {
|
|
|
2357
3161
|
* Fail-closed where it matters: a missing report, verdict or confidence still blocks.
|
|
2358
3162
|
*/
|
|
2359
3163
|
export function dailySafetyGate(args: string[]): void {
|
|
2360
|
-
const [workDir = '', runDate = '', maxReviseArg = '0'] = args;
|
|
3164
|
+
const [workDir = '', runDate = '', maxReviseArg = '0', scopeDir = ''] = args;
|
|
3165
|
+
const drafts = isDraftsScope(scopeDir);
|
|
2361
3166
|
const maxRevise = Number.parseInt(maxReviseArg, 10);
|
|
2362
|
-
const statusFile =
|
|
2363
|
-
const stampFile =
|
|
3167
|
+
const statusFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_status.txt');
|
|
3168
|
+
const stampFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_stamp.json');
|
|
2364
3169
|
timed(workDir, 'safety-judge', () => {
|
|
2365
|
-
const request = readJson(
|
|
2366
|
-
const report = readJson(
|
|
2367
|
-
const verdict = readJson(
|
|
3170
|
+
const request = readJson(scopeArtifact(workDir, scopeDir, runDate, 'safety-review_request.json'));
|
|
3171
|
+
const report = readJson(scopeArtifact(workDir, scopeDir, runDate, 'safety-review_report.json'));
|
|
3172
|
+
const verdict = readJson(scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_verdict.json'));
|
|
2368
3173
|
const platforms = Array.isArray(report?.platforms) ? (report.platforms as Record<string, unknown>[]) : [];
|
|
2369
3174
|
const decisions = platforms.map((platform) => platform?.decision);
|
|
2370
|
-
const digest =
|
|
2371
|
-
const revisions =
|
|
3175
|
+
const digest = scopeDigest(workDir, scopeDir, runDate);
|
|
3176
|
+
const revisions = readScopeCounter(workDir, scopeDir);
|
|
2372
3177
|
const platformFindings = (platform: Record<string, unknown>): Record<string, unknown>[] =>
|
|
2373
3178
|
Array.isArray(platform?.findings) ? (platform.findings as Record<string, unknown>[]) : [];
|
|
2374
3179
|
// 20261007 narrowing (operator decision): on a clean pass, only a finding the reviewer
|
|
@@ -2399,9 +3204,12 @@ export function dailySafetyGate(args: string[]): void {
|
|
|
2399
3204
|
// data-handling), which is where an independent reviewer can grade it.
|
|
2400
3205
|
const blockedPlatforms = platforms.filter((platform) => platform?.decision === 'BLOCK');
|
|
2401
3206
|
const excludedPlatforms = blockedPlatforms.map((platform) => String(platform?.platform ?? '')).filter(Boolean);
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
)
|
|
3207
|
+
// 20261104: a category-3 `note` is the reviewer recording that it checked and found NO PII —
|
|
3208
|
+
// counting it turned an admission-only BLOCK into a whole-run block. Any other disposition,
|
|
3209
|
+
// or none (legacy reports), still counts: fail-closed.
|
|
3210
|
+
const isPiiFinding = (finding: Record<string, unknown>): boolean =>
|
|
3211
|
+
Number(finding?.category) === PII_FINDING_CATEGORY && finding?.disposition !== 'note';
|
|
3212
|
+
const piiBlock = blockedPlatforms.some((platform) => platformFindings(platform).some(isPiiFinding));
|
|
2405
3213
|
// A BLOCK that names no finding is a contract violation, not a signal: there is nothing to
|
|
2406
3214
|
// remediate and nothing to exclude, so it blocks (fail-closed).
|
|
2407
3215
|
const unnamedBlock =
|
|
@@ -2465,7 +3273,7 @@ export function dailySafetyGate(args: string[]): void {
|
|
|
2465
3273
|
status = 'block';
|
|
2466
3274
|
const named = blockedPlatforms
|
|
2467
3275
|
.flatMap((platform) => platformFindings(platform))
|
|
2468
|
-
.filter(
|
|
3276
|
+
.filter(isPiiFinding)
|
|
2469
3277
|
.map((finding) => `${String(finding?.item ?? '?')}(category ${String(finding?.category)})`)
|
|
2470
3278
|
.join(', ');
|
|
2471
3279
|
const criteria = verdictFails.map((finding) => String(finding?.criterion)).join(', ');
|
|
@@ -2492,11 +3300,12 @@ export function dailySafetyGate(args: string[]): void {
|
|
|
2492
3300
|
if (status === 'pass' || status === 'escalate' || status === 'exclude') {
|
|
2493
3301
|
sted('write', stampFile, () => writeJsonText(stampFile, { digest, status, excludedPlatforms }));
|
|
2494
3302
|
}
|
|
2495
|
-
if (status === 'block' && !edited) {
|
|
3303
|
+
if (status === 'block' && drafts && !edited) {
|
|
2496
3304
|
// A quality block poisons the isFile() draft cache: every same-day retry would
|
|
2497
3305
|
// re-fail on unchanged bytes (20261002 dogfood — two judge-fail runs replayed until
|
|
2498
3306
|
// the drafts were moved by hand). Quarantine (mv, never rm) so the next run
|
|
2499
3307
|
// regenerates from scratch; the 09_* verdict artifacts stay in 24-script for audit.
|
|
3308
|
+
// Variants scope never quarantines: the bundle is regenerated, not hand-repaired.
|
|
2500
3309
|
const q = join(workDir, '24-script', `.quarantine-${runDate}-judge-block`);
|
|
2501
3310
|
mkdirSync(q, { recursive: true });
|
|
2502
3311
|
for (const path of safetyDraftPaths(workDir, runDate)) {
|
|
@@ -2537,10 +3346,27 @@ export function dailyPreflight(args: string[]): void {
|
|
|
2537
3346
|
String(match[1]),
|
|
2538
3347
|
),
|
|
2539
3348
|
);
|
|
3349
|
+
// 0329 F21: the `publish-matrix-*` stages never appear in kk-workflow-stages' switch —
|
|
3350
|
+
// `kk stage` routes them through apps/cli's publish-matrix-stage.ts — so without this
|
|
3351
|
+
// roster every workflow invoking them died as runner-stale.
|
|
3352
|
+
const PUBLISH_MATRIX_STAGES = [
|
|
3353
|
+
'publish-matrix-plan',
|
|
3354
|
+
'publish-matrix-check',
|
|
3355
|
+
'publish-matrix-send',
|
|
3356
|
+
'publish-matrix-classify',
|
|
3357
|
+
'publish-matrix-bundle',
|
|
3358
|
+
'publish-matrix-rss-gate',
|
|
3359
|
+
'publish-matrix-wait-until',
|
|
3360
|
+
'publish-matrix-validate',
|
|
3361
|
+
'publish-matrix-settle',
|
|
3362
|
+
] as const;
|
|
3363
|
+
for (const name of PUBLISH_MATRIX_STAGES) known.add(name);
|
|
2540
3364
|
const missing: string[] = [];
|
|
2541
3365
|
for (const file of files) {
|
|
2542
3366
|
if (!isFile(file)) failWith(1, `daily-preflight.workflow-missing: ${file}`);
|
|
2543
|
-
|
|
3367
|
+
// 0329 F21: accept both spellings — `kk stage <name>` and the ADR-037 canonical
|
|
3368
|
+
// `kk stage run <name>`.
|
|
3369
|
+
for (const match of readFileSync(file, 'utf-8').matchAll(/\bkk stage (?:run )?([a-z][a-z0-9-]*)/g)) {
|
|
2544
3370
|
const name = String(match[1]);
|
|
2545
3371
|
if (!known.has(name)) missing.push(`${name} (needed by ${basename(file)})`);
|
|
2546
3372
|
}
|
|
@@ -2571,11 +3397,12 @@ export function dailyPreflight(args: string[]): void {
|
|
|
2571
3397
|
* offending value on stderr. Never exits non-zero: routing belongs to the transitions.
|
|
2572
3398
|
*/
|
|
2573
3399
|
export function dailySafetyVerdict(args: string[]): void {
|
|
2574
|
-
const [workDir = '', runDate = ''] = args;
|
|
2575
|
-
const
|
|
2576
|
-
const
|
|
2577
|
-
const
|
|
2578
|
-
const
|
|
3400
|
+
const [workDir = '', runDate = '', scopeDir = ''] = args;
|
|
3401
|
+
const drafts = isDraftsScope(scopeDir);
|
|
3402
|
+
const statusFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_status.txt');
|
|
3403
|
+
const stampFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-judge_stamp.json');
|
|
3404
|
+
const decisionFile = scopeArtifact(workDir, scopeDir, runDate, 'safety-verdict.decision');
|
|
3405
|
+
const token = scopeArtifact(workDir, scopeDir, runDate, 'safety-verdict.status');
|
|
2579
3406
|
const ADMITTED = new Set(['pass', 'escalate', 'exclude']);
|
|
2580
3407
|
timed(workDir, 'safety-verdict-check', () => {
|
|
2581
3408
|
const status = isFile(statusFile) ? readFileSync(statusFile, 'utf-8').trim() : '';
|
|
@@ -2595,8 +3422,51 @@ export function dailySafetyVerdict(args: string[]): void {
|
|
|
2595
3422
|
} else if (!ADMITTED.has(verdict)) {
|
|
2596
3423
|
refusal = `kk-daily-safety.judge.decision-refused: kk-safety-verdict [${verdict}] must admit pass|escalate|exclude, so these drafts must not publish`;
|
|
2597
3424
|
}
|
|
3425
|
+
// 0331 variants scope: an admitted outcome also writes the evidence the publish matrix
|
|
3426
|
+
// freezes into the bundle — status, the exclusion record and the sha256 of every reviewed
|
|
3427
|
+
// variant's CURRENT bytes. Recomputed here (not copied from the stamp) so bytes edited
|
|
3428
|
+
// after the gate's verdict are caught at the last instant; a mismatch is a refusal, never
|
|
3429
|
+
// silently re-bound evidence.
|
|
3430
|
+
let evidence: Record<string, unknown> | null = null;
|
|
3431
|
+
if (refusal === '' && !drafts) {
|
|
3432
|
+
const stampJson = readJson(stampFile) as {
|
|
3433
|
+
digest?: unknown;
|
|
3434
|
+
status?: unknown;
|
|
3435
|
+
excludedPlatforms?: unknown;
|
|
3436
|
+
} | null;
|
|
3437
|
+
const manifest = readManifest(workDir);
|
|
3438
|
+
const hash = createHash('sha256');
|
|
3439
|
+
const variants: Record<string, string> = {};
|
|
3440
|
+
for (const key of Object.keys(manifest.variants).sort()) {
|
|
3441
|
+
const path = join(workDir, manifest.variants[key].path);
|
|
3442
|
+
if (!isFile(path)) {
|
|
3443
|
+
refusal = `kk-daily-safety.judge.verdict-refused: registered variant file is missing: ${path} — re-run the review on a fresh manifest`;
|
|
3444
|
+
break;
|
|
3445
|
+
}
|
|
3446
|
+
const sha = createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
3447
|
+
variants[key] = sha;
|
|
3448
|
+
hash.update(key).update('\0').update(readFileSync(path)).update('\0');
|
|
3449
|
+
}
|
|
3450
|
+
const digest = hash.digest('hex');
|
|
3451
|
+
if (refusal === '' && stampJson?.digest !== digest) {
|
|
3452
|
+
refusal = `kk-daily-safety.judge.verdict-refused: variant bytes changed after the review (reviewed ${String(stampJson?.digest ?? 'missing')}, on disk ${digest}) — re-run the variants safety review`;
|
|
3453
|
+
}
|
|
3454
|
+
if (refusal === '') {
|
|
3455
|
+
evidence = {
|
|
3456
|
+
status: stampJson?.status,
|
|
3457
|
+
digest,
|
|
3458
|
+
excludedPlatforms: Array.isArray(stampJson?.excludedPlatforms) ? stampJson.excludedPlatforms : [],
|
|
3459
|
+
variants,
|
|
3460
|
+
};
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
2598
3463
|
if (refusal !== '') console.error(refusal);
|
|
2599
3464
|
sted('write', token, () => writeRaw(token, refusal === '' ? 'accept' : 'refuse'));
|
|
3465
|
+
if (evidence !== null) {
|
|
3466
|
+
const evidencePath = join(scopeDirOf(workDir, scopeDir), 'safety.json');
|
|
3467
|
+
sted('write', evidencePath, () => writeJsonText(evidencePath, evidence));
|
|
3468
|
+
console.log(`safety-verdict: evidence written to ${evidencePath}`);
|
|
3469
|
+
}
|
|
2600
3470
|
});
|
|
2601
3471
|
}
|
|
2602
3472
|
|
|
@@ -2607,41 +3477,183 @@ export function dailySafetyVerdict(args: string[]): void {
|
|
|
2607
3477
|
* here fails inside the loop instead of at `wrap-docs`, after the run has moved on. A broken
|
|
2608
3478
|
* revision spends the budget too: an unparsable draft must not be retried forever.
|
|
2609
3479
|
*/
|
|
3480
|
+
// ---- 0331 scope machinery -------------------------------------------------
|
|
3481
|
+
// The nested kk-daily-safety machine serves two review surfaces: the classic drafts scope
|
|
3482
|
+
// (article + VoiceScript under 24-script) and the variants scope (final publish variants under
|
|
3483
|
+
// 30-variants, registered by daily-safety-manifest, artifacts under 4-safety-variants). The scope
|
|
3484
|
+
// is ONE trailing `scope_dir` argument: `24-script` (or empty) keeps every path byte-identical to
|
|
3485
|
+
// the pre-0331 behavior, any other value re-roots the artifact set and switches the digest to the
|
|
3486
|
+
// manifest-listed variant bytes.
|
|
3487
|
+
|
|
3488
|
+
export const SAFETY_VARIANTS_DIR = '4-safety-variants';
|
|
3489
|
+
|
|
3490
|
+
function isDraftsScope(scopeDir: string): boolean {
|
|
3491
|
+
const dir = scopeDir.trim();
|
|
3492
|
+
return dir === '' || dir === '24-script';
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
function scopeDirOf(workDir: string, scopeDir: string): string {
|
|
3496
|
+
return isDraftsScope(scopeDir) ? join(workDir, '24-script') : join(workDir, scopeDir.trim());
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
function scopeArtifact(workDir: string, scopeDir: string, runDate: string, name: string): string {
|
|
3500
|
+
return join(scopeDirOf(workDir, scopeDir), `${runDate}_09_${name}`);
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
/** The remediation budget is per scope: a variants review never inherits the drafts pass count. */
|
|
3504
|
+
function scopeCounter(workDir: string, scopeDir: string): string {
|
|
3505
|
+
return isDraftsScope(scopeDir)
|
|
3506
|
+
? join(workDir, SAFETY_REVISE_COUNT)
|
|
3507
|
+
: join(workDir, `${SAFETY_REVISE_COUNT}-${scopeDir.trim().replace(/[^\w-]/g, '_')}`);
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
function readScopeCounter(workDir: string, scopeDir: string): number {
|
|
3511
|
+
const path = scopeCounter(workDir, scopeDir);
|
|
3512
|
+
if (!isFile(path)) return 0;
|
|
3513
|
+
const value = Number.parseInt(readFileSync(path, 'utf-8'), 10);
|
|
3514
|
+
return Number.isNaN(value) ? 0 : value;
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
interface SafetyManifest {
|
|
3518
|
+
schemaVersion: number;
|
|
3519
|
+
runDate: string;
|
|
3520
|
+
variants: Record<string, { path: string; sha256: string }>;
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
function readManifest(workDir: string): SafetyManifest {
|
|
3524
|
+
const path = join(workDir, SAFETY_VARIANTS_DIR, 'manifest.json');
|
|
3525
|
+
const manifest = readJson(path) as SafetyManifest | null;
|
|
3526
|
+
if (manifest === null || !manifest.variants || typeof manifest.variants !== 'object') {
|
|
3527
|
+
failWith(
|
|
3528
|
+
1,
|
|
3529
|
+
`safety-variants: ${path} is missing or malformed — run "kk stage run daily-safety-manifest <work_dir> <run_date>" first`,
|
|
3530
|
+
);
|
|
3531
|
+
}
|
|
3532
|
+
return manifest;
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
/** Digest over the exact bytes under review: drafts = safetyDigest; variants = sorted manifest keys + current bytes. */
|
|
3536
|
+
function scopeDigest(workDir: string, scopeDir: string, runDate: string): string {
|
|
3537
|
+
if (isDraftsScope(scopeDir)) return safetyDigest(workDir, runDate);
|
|
3538
|
+
const manifest = readManifest(workDir);
|
|
3539
|
+
const hash = createHash('sha256');
|
|
3540
|
+
for (const key of Object.keys(manifest.variants).sort()) {
|
|
3541
|
+
const path = join(workDir, manifest.variants[key].path);
|
|
3542
|
+
if (!isFile(path)) {
|
|
3543
|
+
failWith(
|
|
3544
|
+
1,
|
|
3545
|
+
`safety-variants: registered variant file is missing: ${path} — re-run daily-bundle-variants and the manifest`,
|
|
3546
|
+
);
|
|
3547
|
+
}
|
|
3548
|
+
hash.update(key).update('\0').update(readFileSync(path)).update('\0');
|
|
3549
|
+
}
|
|
3550
|
+
return hash.digest('hex');
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
/** The `content` array the review request hands the agents: what to read, per scope. */
|
|
3554
|
+
function scopeContent(workDir: string, scopeDir: string, runDate: string): unknown[] {
|
|
3555
|
+
if (isDraftsScope(scopeDir)) return safetyDraftPaths(workDir, runDate);
|
|
3556
|
+
return Object.entries(readManifest(workDir).variants)
|
|
3557
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
3558
|
+
.map(([key, entry]) => ({ key, path: entry.path, sha256: entry.sha256 }));
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
/**
|
|
3562
|
+
* Register the run's final publish variants for the variants-scope safety review (0331): every
|
|
3563
|
+
* `30-variants/*.content.json` becomes one manifest entry keyed by its file name with `_` read as
|
|
3564
|
+
* the plugin.operation separator. The manifest is the review contract — the request digest, the
|
|
3565
|
+
* gate's edit check and the bundle's frozen evidence all resolve through it, so an empty or
|
|
3566
|
+
* missing variants directory fails here, before any reviewer spends tokens.
|
|
3567
|
+
*/
|
|
3568
|
+
export function dailySafetyManifest(args: string[]): void {
|
|
3569
|
+
const [workDir = '', runDate = ''] = args;
|
|
3570
|
+
timed(workDir, 'safety-manifest', () => {
|
|
3571
|
+
const dir = join(workDir, '30-variants');
|
|
3572
|
+
if (!existsSync(dir)) {
|
|
3573
|
+
failWith(1, `safety-manifest: ${dir} does not exist — run the bundle-variants stage first`);
|
|
3574
|
+
}
|
|
3575
|
+
const files = readdirSync(dir)
|
|
3576
|
+
.filter((file) => file.endsWith('.content.json'))
|
|
3577
|
+
.sort();
|
|
3578
|
+
if (files.length === 0) {
|
|
3579
|
+
failWith(1, `safety-manifest: no *.content.json under ${dir} — nothing to review; was the bundle built?`);
|
|
3580
|
+
}
|
|
3581
|
+
const variants: Record<string, { path: string; sha256: string }> = {};
|
|
3582
|
+
for (const file of files) {
|
|
3583
|
+
const text = readFileSync(join(dir, file), 'utf-8');
|
|
3584
|
+
variants[file.replace(/\.content\.json$/, '').replace(/_/g, ':')] = {
|
|
3585
|
+
path: `30-variants/${file}`,
|
|
3586
|
+
sha256: createHash('sha256').update(text).digest('hex'),
|
|
3587
|
+
};
|
|
3588
|
+
}
|
|
3589
|
+
mkdirSync(join(workDir, SAFETY_VARIANTS_DIR), { recursive: true });
|
|
3590
|
+
const manifestPath = join(workDir, SAFETY_VARIANTS_DIR, 'manifest.json');
|
|
3591
|
+
sted('write', manifestPath, () => writeJsonText(manifestPath, { schemaVersion: 1, runDate, variants }));
|
|
3592
|
+
console.log(`safety-manifest: ${files.length} variant(s) registered for review`);
|
|
3593
|
+
});
|
|
3594
|
+
}
|
|
3595
|
+
|
|
2610
3596
|
/**
|
|
2611
3597
|
* Open a content-safety review of the current drafts: drop every artifact of an earlier review
|
|
2612
3598
|
* (a retry must be re-reviewed on its final version) and write the request the two agent.run
|
|
2613
3599
|
* steps read. The program passes context only — platform policy lives in the kk:content-safety
|
|
2614
|
-
* skill's reference files, never here.
|
|
3600
|
+
* skill's reference files, never here. 0331: an optional trailing scope_dir re-roots the whole
|
|
3601
|
+
* artifact set for the variants review (see the scope machinery above).
|
|
2615
3602
|
*/
|
|
2616
3603
|
export function dailySafetyPrep(args: string[]): void {
|
|
2617
|
-
const [workDir = '', runDate = '', platforms = ''] = args;
|
|
3604
|
+
const [workDir = '', runDate = '', platforms = '', scopeDir = ''] = args;
|
|
3605
|
+
const drafts = isDraftsScope(scopeDir);
|
|
2618
3606
|
timed(workDir, 'safety-review', () => {
|
|
2619
|
-
const digest =
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
3607
|
+
const digest = scopeDigest(workDir, scopeDir, runDate);
|
|
3608
|
+
if (drafts) {
|
|
3609
|
+
// Structural snapshot of the VoiceScript BEFORE any remediation edit. `daily-safety-revise`
|
|
3610
|
+
// restores it when the remediation pass changes the item-section structure without a recorded
|
|
3611
|
+
// exclusion: the agent is allowed to drop a 播报形态 line, not an item the article kept,
|
|
3612
|
+
// because `daily-wrap-docs` derives the spoken item sections from `gap_ms: 1200` and fails the
|
|
3613
|
+
// run when they no longer match the episode's included items (2026-10-21 04:30 run: 3
|
|
3614
|
+
// segments removed → 5 sections vs a 6-item plan → `generate` refused at 05:03 and the whole
|
|
3615
|
+
// day published nothing).
|
|
3616
|
+
const [, scriptPath] = safetyDraftPaths(workDir, runDate);
|
|
3617
|
+
const snapshot = safetyPath(workDir, runDate, 'safety-prep_script-snapshot.yaml');
|
|
3618
|
+
if (isFile(scriptPath))
|
|
3619
|
+
sted('write', snapshot, () => writeRaw(snapshot, readFileSync(scriptPath, 'utf-8')));
|
|
3620
|
+
} else {
|
|
3621
|
+
// The remediation budget belongs to this scope's attempt, not to the day.
|
|
3622
|
+
const counter = scopeCounter(workDir, scopeDir);
|
|
3623
|
+
sted('write', counter, () => writeFileSync(counter, '0\n'));
|
|
3624
|
+
}
|
|
2630
3625
|
for (const name of [
|
|
2631
3626
|
'safety-review_report.json',
|
|
2632
3627
|
'safety-judge_verdict.json',
|
|
2633
3628
|
'safety-judge_status.txt',
|
|
2634
3629
|
'safety-judge_stamp.json',
|
|
3630
|
+
'safety-verdict.decision',
|
|
3631
|
+
'safety-verdict.status',
|
|
3632
|
+
// A previous review's evidence must never survive into a new request: the bundle
|
|
3633
|
+
// freezes exactly what the current request's verdict admits. Evidence is the one
|
|
3634
|
+
// unprefixed artifact, so the runDate-prefixed sweep would miss it.
|
|
3635
|
+
'safety.json',
|
|
2635
3636
|
]) {
|
|
2636
|
-
const stale =
|
|
3637
|
+
const stale =
|
|
3638
|
+
name === 'safety.json' && !drafts
|
|
3639
|
+
? join(scopeDirOf(workDir, scopeDir), name)
|
|
3640
|
+
: scopeArtifact(workDir, scopeDir, runDate, name);
|
|
2637
3641
|
if (isFile(stale)) unlinkSync(stale);
|
|
2638
3642
|
}
|
|
2639
|
-
const request =
|
|
3643
|
+
const request = scopeArtifact(workDir, scopeDir, runDate, 'safety-review_request.json');
|
|
2640
3644
|
const targets = platforms
|
|
2641
3645
|
.split(',')
|
|
2642
3646
|
.map((platform) => platform.trim())
|
|
2643
3647
|
.filter(Boolean);
|
|
2644
|
-
sted('write', request, () =>
|
|
3648
|
+
sted('write', request, () =>
|
|
3649
|
+
writeJsonText(request, {
|
|
3650
|
+
digest,
|
|
3651
|
+
platforms: targets,
|
|
3652
|
+
mode: 'detect-only',
|
|
3653
|
+
scope: drafts ? 'drafts' : 'variants',
|
|
3654
|
+
content: scopeContent(workDir, scopeDir, runDate),
|
|
3655
|
+
}),
|
|
3656
|
+
);
|
|
2645
3657
|
});
|
|
2646
3658
|
}
|
|
2647
3659
|
|
|
@@ -2675,8 +3687,30 @@ export function dailySafetyPrep(args: string[]): void {
|
|
|
2675
3687
|
* revision spends the budget too: an unparsable draft must not be retried forever.
|
|
2676
3688
|
*/
|
|
2677
3689
|
export function dailySafetyRevise(args: string[]): void {
|
|
2678
|
-
const [workDir = '', runDate = ''] = args;
|
|
3690
|
+
const [workDir = '', runDate = '', scopeDir = ''] = args;
|
|
3691
|
+
const drafts = isDraftsScope(scopeDir);
|
|
2679
3692
|
timed(workDir, 'safety-revise', () => {
|
|
3693
|
+
if (!drafts) {
|
|
3694
|
+
// Variants scope: every manifest-listed file must stay parseable Content with the
|
|
3695
|
+
// fields the publish half reads; the structural VoiceScript machinery does not apply.
|
|
3696
|
+
for (const entry of Object.values(readManifest(workDir).variants)) {
|
|
3697
|
+
const path = join(workDir, entry.path);
|
|
3698
|
+
const value = requireJson(path, 'safety-revise') as {
|
|
3699
|
+
title?: unknown;
|
|
3700
|
+
body?: unknown;
|
|
3701
|
+
};
|
|
3702
|
+
for (const field of ['title', 'body'] as const) {
|
|
3703
|
+
if (typeof value[field] !== 'string' || String(value[field]).trim() === '') {
|
|
3704
|
+
failWith(1, `safety-revise: variant ${path} ${field} must be a non-empty string`);
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
const counter = scopeCounter(workDir, scopeDir);
|
|
3709
|
+
const next = readScopeCounter(workDir, scopeDir) + 1;
|
|
3710
|
+
sted('write', counter, () => writeFileSync(counter, `${next}\n`));
|
|
3711
|
+
console.log(`safety-revise: remediation pass ${next} applied to manifest variants; re-validated`);
|
|
3712
|
+
return;
|
|
3713
|
+
}
|
|
2680
3714
|
const [articlePath, scriptPath] = safetyDraftPaths(workDir, runDate);
|
|
2681
3715
|
const article = requireJson(articlePath, 'safety-revise');
|
|
2682
3716
|
for (const field of ['title', 'body'] as const) {
|
|
@@ -2688,7 +3722,7 @@ export function dailySafetyRevise(args: string[]): void {
|
|
|
2688
3722
|
assertScriptContent(scriptPath);
|
|
2689
3723
|
restoreStructureIfBroken(workDir, runDate, scriptPath);
|
|
2690
3724
|
const path = join(workDir, SAFETY_REVISE_COUNT);
|
|
2691
|
-
const next =
|
|
3725
|
+
const next = readScopeCounter(workDir, scopeDir) + 1;
|
|
2692
3726
|
sted('write', path, () => writeFileSync(path, `${next}\n`));
|
|
2693
3727
|
console.log(`safety-revise: remediation pass ${next} applied; drafts re-validated`);
|
|
2694
3728
|
});
|
|
@@ -3063,22 +4097,29 @@ export function dailyShowNotes(args: string[]): void {
|
|
|
3063
4097
|
* deterministic mechanics — `scaffold-locale` for both target locales — while the
|
|
3064
4098
|
* workflow `translate` state's agent step owns the LLM translation of the drafts.
|
|
3065
4099
|
* Writes one marker file per locale (zh post path + en/ja draft paths); empty
|
|
3066
|
-
* marker = locale absent (callers skip). Reuses an existing on-disk draft
|
|
3067
|
-
*
|
|
4100
|
+
* marker = locale absent (callers skip). Reuses an existing on-disk draft only when its
|
|
4101
|
+
* source-hash sidecar matches the current zh post — a changed source must invalidate the
|
|
4102
|
+
* translation cache before the next unattended run.
|
|
3068
4103
|
*/
|
|
3069
4104
|
export function dailyTranslateLocalize(args: string[]): void {
|
|
3070
4105
|
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
3071
|
-
//
|
|
3072
|
-
//
|
|
3073
|
-
|
|
4106
|
+
// The prepare result is available before translation; the final publish result is retained as
|
|
4107
|
+
// a compatibility fallback for manual recovery of pre-two-phase workspaces.
|
|
4108
|
+
const prepareResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash-prepare_result.json`);
|
|
3074
4109
|
const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
|
|
3075
4110
|
const zhFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
|
|
3076
4111
|
timed(workDir, 'translate-localize', () => {
|
|
3077
|
-
const postPath =
|
|
4112
|
+
const postPath =
|
|
4113
|
+
firstPostPath(readJson(prepareResult), 'surfdash-pub') || firstPostPath(readJson(result), 'surfdash-pub');
|
|
3078
4114
|
const zh = postPath.endsWith('index.md') ? postPath : '';
|
|
3079
4115
|
sted('write', zhFile, () => writeRaw(zhFile, zh));
|
|
3080
4116
|
if (zh === '') {
|
|
3081
4117
|
for (const target of ['en', 'ja'] as const) {
|
|
4118
|
+
const sourceHashFile = join(
|
|
4119
|
+
workDir,
|
|
4120
|
+
'31-publish',
|
|
4121
|
+
`${runDate}_20_translate-localize_${target}-source-sha256.txt`,
|
|
4122
|
+
);
|
|
3082
4123
|
sted(
|
|
3083
4124
|
'write',
|
|
3084
4125
|
join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
|
|
@@ -3088,18 +4129,37 @@ export function dailyTranslateLocalize(args: string[]): void {
|
|
|
3088
4129
|
'',
|
|
3089
4130
|
),
|
|
3090
4131
|
);
|
|
4132
|
+
sted('remove', sourceHashFile, () => rmSync(sourceHashFile, { force: true }));
|
|
3091
4133
|
}
|
|
3092
4134
|
console.log('no zh post path — skip localize');
|
|
3093
4135
|
return;
|
|
3094
4136
|
}
|
|
4137
|
+
let sourceHash: string;
|
|
4138
|
+
try {
|
|
4139
|
+
sourceHash = createHash('sha256').update(readFileSync(zh)).digest('hex');
|
|
4140
|
+
} catch (error: unknown) {
|
|
4141
|
+
failWith(
|
|
4142
|
+
1,
|
|
4143
|
+
`translate-localize: cannot fingerprint zh source ${zh}: ${error instanceof Error ? error.message : String(error)}`,
|
|
4144
|
+
);
|
|
4145
|
+
}
|
|
3095
4146
|
for (const target of ['en', 'ja'] as const) {
|
|
3096
4147
|
const markerFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`);
|
|
3097
4148
|
const existing = zh.replace('/zh/', `/${target}/`);
|
|
3098
|
-
|
|
4149
|
+
const sourceHashFile = join(
|
|
4150
|
+
workDir,
|
|
4151
|
+
'31-publish',
|
|
4152
|
+
`${runDate}_20_translate-localize_${target}-source-sha256.txt`,
|
|
4153
|
+
);
|
|
4154
|
+
const cachedSourceHash = isFile(sourceHashFile) ? readFileSync(sourceHashFile, 'utf-8').trim() : '';
|
|
4155
|
+
if (isFile(existing) && cachedSourceHash === sourceHash) {
|
|
3099
4156
|
sted('write', markerFile, () => writeRaw(markerFile, existing));
|
|
3100
4157
|
console.log(`${target} draft cached: ${existing}`);
|
|
3101
4158
|
continue;
|
|
3102
4159
|
}
|
|
4160
|
+
if (isFile(existing)) {
|
|
4161
|
+
console.log(`${target} draft invalidated: zh source fingerprint changed or is missing`);
|
|
4162
|
+
}
|
|
3103
4163
|
const scaffold = join(workDir, '31-publish', `${runDate}_20_translate-localize_scaffold-${target}.json`);
|
|
3104
4164
|
const scaffoldResult = join(
|
|
3105
4165
|
workDir,
|
|
@@ -3107,20 +4167,254 @@ export function dailyTranslateLocalize(args: string[]): void {
|
|
|
3107
4167
|
`${runDate}_20_translate-localize_scaffold-${target}-result.json`,
|
|
3108
4168
|
);
|
|
3109
4169
|
sted('write', scaffold, () =>
|
|
3110
|
-
writeJqText(scaffold, {
|
|
4170
|
+
writeJqText(scaffold, {
|
|
4171
|
+
body: '',
|
|
4172
|
+
options: {
|
|
4173
|
+
operation: 'scaffold-locale',
|
|
4174
|
+
postPath: zh,
|
|
4175
|
+
target,
|
|
4176
|
+
...(isFile(existing) ? { force: true } : {}),
|
|
4177
|
+
},
|
|
4178
|
+
}),
|
|
4179
|
+
);
|
|
4180
|
+
try {
|
|
4181
|
+
kk([
|
|
4182
|
+
'executor',
|
|
4183
|
+
'run',
|
|
4184
|
+
'surfdash-pub',
|
|
4185
|
+
'--in',
|
|
4186
|
+
scaffold,
|
|
4187
|
+
'--out',
|
|
4188
|
+
scaffoldResult,
|
|
4189
|
+
...pluginsArgs(pluginsPath),
|
|
4190
|
+
]);
|
|
4191
|
+
const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
|
|
4192
|
+
const draft = typeof draftPath === 'string' ? draftPath : '';
|
|
4193
|
+
sted('write', markerFile, () => writeRaw(markerFile, draft));
|
|
4194
|
+
if (draft !== '') {
|
|
4195
|
+
sted('write', sourceHashFile, () => writeRaw(sourceHashFile, sourceHash));
|
|
4196
|
+
} else {
|
|
4197
|
+
sted('remove', sourceHashFile, () => rmSync(sourceHashFile, { force: true }));
|
|
4198
|
+
}
|
|
4199
|
+
} catch (error: unknown) {
|
|
4200
|
+
if (error instanceof StageFailure && error.status === 127) throw error;
|
|
4201
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4202
|
+
sted('write', markerFile, () => writeRaw(markerFile, ''));
|
|
4203
|
+
sted('remove', sourceHashFile, () => rmSync(sourceHashFile, { force: true }));
|
|
4204
|
+
console.error(`translate-localize: ${target} scaffold failed: ${message}`);
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
});
|
|
4208
|
+
}
|
|
4209
|
+
|
|
4210
|
+
/**
|
|
4211
|
+
* Run Surfdash's own refine command over the complete locale set before the validation fingerprint
|
|
4212
|
+
* is written. This is deliberately a separate local-only phase: the final publish FSM also runs
|
|
4213
|
+
* refine, but it must be idempotent by the time any CMS/git side effect is reachable.
|
|
4214
|
+
*/
|
|
4215
|
+
function normalizeTranslatedLocales(zhPostPath: string, failUnsafe = true): void {
|
|
4216
|
+
for (const locale of ['zh', 'en', 'ja'] as const) {
|
|
4217
|
+
const sibling = locale === 'zh' ? zhPostPath : zhPostPath.replace('/zh/', `/${locale}/`);
|
|
4218
|
+
if (!isFile(sibling)) continue;
|
|
4219
|
+
const raw = readFileSync(sibling, 'utf-8');
|
|
4220
|
+
const { markdown, remaining } = normalizeSeriesNames(raw, locale);
|
|
4221
|
+
if (failUnsafe && remaining.length > 0) {
|
|
4222
|
+
failWith(1, `series-name normalization left unsafe names in ${locale}: ${remaining.join(' / ')}`);
|
|
4223
|
+
}
|
|
4224
|
+
if (markdown !== raw) sted('write', sibling, () => writeRaw(sibling, markdown));
|
|
4225
|
+
}
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
/**
|
|
4229
|
+
* The translate agents run with cwd = this repo, and both executor sandboxes (codex workspace-write,
|
|
4230
|
+
* claude acceptEdits) refuse writes into the Surfdash checkout — on 2026-11-04 both exited 0 with
|
|
4231
|
+
* en/ja untouched. So they write `<runDate>_21_translate_<locale>.md` under the work dir and this
|
|
4232
|
+
* installs each over its scaffold. The output is consumed (renamed) so a rerun never clobbers an
|
|
4233
|
+
* operator's later repair of the installed post.
|
|
4234
|
+
*/
|
|
4235
|
+
function installTranslatedLocales(workDir: string, runDate: string, zhPostPath: string): void {
|
|
4236
|
+
for (const locale of ['en', 'ja'] as const) {
|
|
4237
|
+
const translated = join(workDir, '31-publish', `${runDate}_21_translate_${locale}.md`);
|
|
4238
|
+
if (!isFile(translated)) continue;
|
|
4239
|
+
const target = zhPostPath.replace('/zh/', `/${locale}/`);
|
|
4240
|
+
sted('write', target, () => writeRaw(target, readFileSync(translated, 'utf-8')));
|
|
4241
|
+
renameSync(translated, `${translated}.installed`);
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
|
|
4245
|
+
export function dailySurfdashRefine(args: string[]): void {
|
|
4246
|
+
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
4247
|
+
const marker = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
|
|
4248
|
+
const input = join(workDir, '31-publish', `${runDate}_24_surfdash-refine_content.json`);
|
|
4249
|
+
const result = join(workDir, '31-publish', `${runDate}_24_surfdash-refine_result.json`);
|
|
4250
|
+
timed(workDir, 'surfdash-refine', () => {
|
|
4251
|
+
try {
|
|
4252
|
+
const zhPostPath = isFile(marker) ? readFileSync(marker, 'utf-8').trim() : '';
|
|
4253
|
+
if (zhPostPath === '' || !zhPostPath.startsWith('/') || !isFile(zhPostPath)) {
|
|
4254
|
+
failWith(1, `surfdash-refine: zh marker must name an existing post: ${marker}`);
|
|
4255
|
+
}
|
|
4256
|
+
validateLocaleMarkers(workDir, runDate, zhPostPath, 'surfdash-refine');
|
|
4257
|
+
installTranslatedLocales(workDir, runDate, zhPostPath);
|
|
4258
|
+
// The validator is intentionally read-only. Repair known series spellings before
|
|
4259
|
+
// site-owned refine computes managed fields, otherwise the final publisher would
|
|
4260
|
+
// rewrite the refined bytes after the locale fingerprint was admitted.
|
|
4261
|
+
normalizeTranslatedLocales(zhPostPath);
|
|
4262
|
+
sted('write', input, () =>
|
|
4263
|
+
writeJqText(input, {
|
|
4264
|
+
body: '',
|
|
4265
|
+
format: 'markdown',
|
|
4266
|
+
options: { operation: 'refine-prepared', postPath: zhPostPath, runDate },
|
|
4267
|
+
}),
|
|
4268
|
+
);
|
|
4269
|
+
rmSync(result, { force: true });
|
|
4270
|
+
kk(['executor', 'run', 'surfdash-pub', '--in', input, '--out', result, ...pluginsArgs(pluginsPath)], {
|
|
4271
|
+
unset: NESTED_SPUR_UNSET,
|
|
4272
|
+
env: { SURFDASH_LOCALE: 'zh' },
|
|
4273
|
+
});
|
|
4274
|
+
const refined = readJson(result);
|
|
4275
|
+
if (refined?.ok !== true) {
|
|
4276
|
+
failWith(1, `surfdash-refine: publisher did not return a successful refine receipt: ${result}`);
|
|
4277
|
+
}
|
|
4278
|
+
sted('write', result, () =>
|
|
4279
|
+
writeJqText(result, {
|
|
4280
|
+
ok: true,
|
|
4281
|
+
summary: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
|
|
4282
|
+
targets: [
|
|
4283
|
+
{
|
|
4284
|
+
target: 'surfdash-pub',
|
|
4285
|
+
ok: true,
|
|
4286
|
+
status: 'refined',
|
|
4287
|
+
metadata: metadataOf(refined),
|
|
4288
|
+
},
|
|
4289
|
+
],
|
|
4290
|
+
}),
|
|
4291
|
+
);
|
|
4292
|
+
console.log(`surfdash-refine: site-owned refine completed for ${zhPostPath}`);
|
|
4293
|
+
} catch (error: unknown) {
|
|
4294
|
+
if (error instanceof StageFailure && error.status === 127) throw error;
|
|
4295
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4296
|
+
sted('write', result, () =>
|
|
4297
|
+
writeJqText(result, {
|
|
4298
|
+
ok: false,
|
|
4299
|
+
summary: { total: 1, succeeded: 0, failed: 1, skipped: 0 },
|
|
4300
|
+
targets: [{ target: 'surfdash-pub', ok: false, status: 'failed', error: message }],
|
|
4301
|
+
}),
|
|
4302
|
+
);
|
|
4303
|
+
console.error(`surfdash-refine: ${message}`);
|
|
4304
|
+
}
|
|
4305
|
+
});
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4308
|
+
/** Validates locale siblings without contacting Surfdash or mutating their bytes. */
|
|
4309
|
+
function validateTranslatedLocales(zhPostPath: string, step: 'translate-validate' | 'translate-sync'): void {
|
|
4310
|
+
const zhPoints = countKeyPoints(zhPostPath);
|
|
4311
|
+
for (const locale of ['zh', 'en', 'ja'] as const) {
|
|
4312
|
+
const sibling = locale === 'zh' ? zhPostPath : zhPostPath.replace('/zh/', `/${locale}/`);
|
|
4313
|
+
if (!isFile(sibling)) {
|
|
4314
|
+
if (locale !== 'zh' && step === 'translate-validate') {
|
|
4315
|
+
failWith(1, `${step}: ${locale} draft is missing: ${sibling}`);
|
|
4316
|
+
}
|
|
4317
|
+
continue;
|
|
4318
|
+
}
|
|
4319
|
+
const problems: string[] = [];
|
|
4320
|
+
const raw = readFileSync(sibling, 'utf-8');
|
|
4321
|
+
const { markdown, remaining } = normalizeSeriesNames(raw, locale);
|
|
4322
|
+
if (markdown !== raw) problems.push('series-name normalization is pending; run the refine preparation first');
|
|
4323
|
+
if (remaining.length > 0) {
|
|
4324
|
+
problems.push(`retired series names left after rewrite: ${remaining.join(' / ')}`);
|
|
4325
|
+
}
|
|
4326
|
+
const items = readKeyPointItems(sibling);
|
|
4327
|
+
if (step === 'translate-validate') {
|
|
4328
|
+
if (readFrontmatterScalar(sibling, 'refined') !== 'true') {
|
|
4329
|
+
problems.push('site-owned refine has not completed (refined: true is required)');
|
|
4330
|
+
}
|
|
4331
|
+
if (items === undefined || items.length === 0) {
|
|
4332
|
+
problems.push('key_points missing or malformed');
|
|
4333
|
+
} else {
|
|
4334
|
+
const malformed = items.filter((item) => {
|
|
4335
|
+
const separator = item.indexOf(' — ');
|
|
4336
|
+
return separator <= 0 || item.slice(separator + 3).trim() === '';
|
|
4337
|
+
});
|
|
4338
|
+
if (malformed.length > 0) problems.push('key_points missing or malformed');
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
if (locale !== 'zh') {
|
|
4342
|
+
if (readFrontmatterScalar(sibling, 'description') === undefined) {
|
|
4343
|
+
problems.push('description missing/empty');
|
|
4344
|
+
}
|
|
4345
|
+
const points = countKeyPoints(sibling);
|
|
4346
|
+
if (zhPoints !== undefined && points !== undefined && points !== zhPoints) {
|
|
4347
|
+
problems.push(`key_points count ${points} != zh ${zhPoints} (spliced or merged items)`);
|
|
4348
|
+
}
|
|
4349
|
+
// The scheduled path must not let an untranslated scaffold reach the strict CMS sync.
|
|
4350
|
+
if (step === 'translate-validate' && readFrontmatterScalar(sibling, 'draft') !== 'false') {
|
|
4351
|
+
problems.push('draft is still true — locale was scaffolded but not translated');
|
|
4352
|
+
}
|
|
4353
|
+
// Label discipline (same 2026-10-23 audit): a key_points item is `Label — sentence`
|
|
4354
|
+
// and the label is a short phrase — ≤14 chars for ja, ≤28 for en — never a
|
|
4355
|
+
// sentence and never an ellipsis-truncated fragment.
|
|
4356
|
+
if (items !== undefined) {
|
|
4357
|
+
const cap = locale === 'en' ? 28 : 14;
|
|
4358
|
+
const overLimit = items.filter((item) => {
|
|
4359
|
+
const separator = item.indexOf(' — ');
|
|
4360
|
+
if (separator < 0) return false;
|
|
4361
|
+
const label = item.slice(0, separator);
|
|
4362
|
+
return label.includes('…') || Array.from(label).length > cap;
|
|
4363
|
+
});
|
|
4364
|
+
if (overLimit.length > 0) {
|
|
4365
|
+
problems.push(
|
|
4366
|
+
`key_points label over ${cap} chars or …-truncated: ${overLimit.length} of ${items.length} items`,
|
|
4367
|
+
);
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
if (problems.length > 0) {
|
|
4372
|
+
failWith(
|
|
4373
|
+
1,
|
|
4374
|
+
`${step}: ${locale} draft failed the pre-sync structural check (${problems.join('; ')}) — repair ${sibling} per the translate stage contract before syncing`,
|
|
3111
4375
|
);
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
4376
|
+
}
|
|
4377
|
+
}
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
/** Local pre-CMS gate for the scheduled two-phase Surfdash flow. */
|
|
4381
|
+
export function dailyTranslateValidate(args: string[]): void {
|
|
4382
|
+
const [workDir = '', runDate = '', _pluginsPath = '', zhPathFile = ''] = args;
|
|
4383
|
+
const validationResult = join(workDir, '31-publish', `${runDate}_24_translate-validate_result.json`);
|
|
4384
|
+
timed(workDir, 'translate-validate', () => {
|
|
4385
|
+
const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8').trim() : '';
|
|
4386
|
+
try {
|
|
4387
|
+
if (zhPathFile.endsWith('.md') || zhPostPath === '' || !zhPostPath.startsWith('/') || !isFile(zhPostPath)) {
|
|
4388
|
+
failWith(
|
|
4389
|
+
1,
|
|
4390
|
+
`translate-validate: 4th argument must name an existing zh marker file (${runDate}_20_translate-localize_zh-post-path.txt)`,
|
|
4391
|
+
);
|
|
4392
|
+
}
|
|
4393
|
+
validateLocaleMarkers(workDir, runDate, zhPostPath, 'translate-validate');
|
|
4394
|
+
validateTranslatedLocales(zhPostPath, 'translate-validate');
|
|
4395
|
+
const evidence = surfdashLocaleValidationEvidence(zhPostPath, runDate);
|
|
4396
|
+
if (!evidence.ok) failWith(1, `translate-validate: ${evidence.reason}`);
|
|
4397
|
+
sted('write', validationResult, () =>
|
|
4398
|
+
writeJqText(validationResult, {
|
|
4399
|
+
ok: true,
|
|
4400
|
+
target: 'surfdash-pub',
|
|
4401
|
+
status: 'validated',
|
|
4402
|
+
metadata: evidence.evidence,
|
|
4403
|
+
}),
|
|
4404
|
+
);
|
|
4405
|
+
console.log(`translate-validate: locale files ready for final Surfdash publish (${zhPostPath})`);
|
|
4406
|
+
} catch (error: unknown) {
|
|
4407
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4408
|
+
sted('write', validationResult, () =>
|
|
4409
|
+
writeJqText(validationResult, {
|
|
4410
|
+
ok: false,
|
|
4411
|
+
target: 'surfdash-pub',
|
|
4412
|
+
status: 'rejected',
|
|
4413
|
+
error: message,
|
|
4414
|
+
metadata: { postPath: zhPostPath },
|
|
4415
|
+
}),
|
|
4416
|
+
);
|
|
4417
|
+
console.error(`translate-validate: ${message}`);
|
|
3124
4418
|
}
|
|
3125
4419
|
});
|
|
3126
4420
|
}
|
|
@@ -3146,58 +4440,11 @@ export function dailyTranslateSync(args: string[]): void {
|
|
|
3146
4440
|
`translate-sync: 4th argument must be the zh marker file (${runDate}_20_translate-localize_zh-post-path.txt), not the post itself — '${zhPathFile}' holds ${zhPostPath.includes('\n') ? 'a multi-line document' : `'${zhPostPath}'`}`,
|
|
3147
4441
|
);
|
|
3148
4442
|
}
|
|
3149
|
-
//
|
|
3150
|
-
|
|
3151
|
-
//
|
|
3152
|
-
//
|
|
3153
|
-
|
|
3154
|
-
// locale deterministically (URLs untouched) and any leftover it cannot make safe fails the
|
|
3155
|
-
// sync. The zh post rides the same sync, so it gets the same series-name pass.
|
|
3156
|
-
const zhPoints = countKeyPoints(zhPostPath);
|
|
3157
|
-
for (const locale of ['zh', 'en', 'ja'] as const) {
|
|
3158
|
-
const sibling = locale === 'zh' ? zhPostPath : zhPostPath.replace('/zh/', `/${locale}/`);
|
|
3159
|
-
if (!isFile(sibling)) continue;
|
|
3160
|
-
const problems: string[] = [];
|
|
3161
|
-
const raw = readFileSync(sibling, 'utf-8');
|
|
3162
|
-
const { markdown, remaining } = normalizeSeriesNames(raw, locale);
|
|
3163
|
-
if (markdown !== raw) sted('write', sibling, () => writeRaw(sibling, markdown));
|
|
3164
|
-
if (remaining.length > 0) {
|
|
3165
|
-
problems.push(`retired series names left after rewrite: ${remaining.join(' / ')}`);
|
|
3166
|
-
}
|
|
3167
|
-
if (locale !== 'zh') {
|
|
3168
|
-
if (readFrontmatterScalar(sibling, 'description') === undefined) {
|
|
3169
|
-
problems.push('description missing/empty');
|
|
3170
|
-
}
|
|
3171
|
-
const points = countKeyPoints(sibling);
|
|
3172
|
-
if (zhPoints !== undefined && points !== undefined && points !== zhPoints) {
|
|
3173
|
-
problems.push(`key_points count ${points} != zh ${zhPoints} (spliced or merged items)`);
|
|
3174
|
-
}
|
|
3175
|
-
// Label discipline (same 2026-10-23 audit): a key_points item is `Label — sentence`
|
|
3176
|
-
// and the label is a short phrase — ≤14 chars for ja, ≤28 for en — never a
|
|
3177
|
-
// sentence and never an ellipsis-truncated fragment.
|
|
3178
|
-
const items = readKeyPointItems(sibling);
|
|
3179
|
-
if (items !== undefined) {
|
|
3180
|
-
const cap = locale === 'en' ? 28 : 14;
|
|
3181
|
-
const overLimit = items.filter((item) => {
|
|
3182
|
-
const sep = item.indexOf(' — ');
|
|
3183
|
-
if (sep < 0) return false;
|
|
3184
|
-
const label = item.slice(0, sep);
|
|
3185
|
-
return label.includes('…') || Array.from(label).length > cap;
|
|
3186
|
-
});
|
|
3187
|
-
if (overLimit.length > 0) {
|
|
3188
|
-
problems.push(
|
|
3189
|
-
`key_points label over ${cap} chars or …-truncated: ${overLimit.length} of ${items.length} items`,
|
|
3190
|
-
);
|
|
3191
|
-
}
|
|
3192
|
-
}
|
|
3193
|
-
}
|
|
3194
|
-
if (problems.length > 0) {
|
|
3195
|
-
failWith(
|
|
3196
|
-
1,
|
|
3197
|
-
`translate-sync: ${locale} draft failed the pre-sync structural check (${problems.join('; ')}) — repair ${sibling} per the translate stage contract before syncing`,
|
|
3198
|
-
);
|
|
3199
|
-
}
|
|
3200
|
-
}
|
|
4443
|
+
// Preserve the legacy explicit sync repair behavior before the shared read-only gate.
|
|
4444
|
+
normalizeTranslatedLocales(zhPostPath, false);
|
|
4445
|
+
// The 2026-10-23 locale structural gate is shared with the scheduled two-phase validator.
|
|
4446
|
+
// This explicit recovery command still performs the real CMS sync after validation.
|
|
4447
|
+
validateTranslatedLocales(zhPostPath, 'translate-sync');
|
|
3201
4448
|
sted('write', sync, () =>
|
|
3202
4449
|
writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
|
|
3203
4450
|
);
|