@xqyz/xq-cli 0.3.5 → 0.3.6

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/CHANGELOG.md CHANGED
@@ -10,6 +10,76 @@
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ### 正文阶段安全闸门复核
14
+
15
+ - 大纲和目录实际保存前强制读取喜鹊后台权威任务状态,不再因为本机没有任务缓存而绕过“正文已开始/已完成”保护。
16
+ - 状态校验放在 `--dry-run` 和用户确认之后、写接口之前,保留离线差异预览能力,同时覆盖确认后发生的并发状态变化。
17
+ - 新增回归测试:无本地缓存的已完成任务不会调用大纲或目录保存接口;纯预览不会额外依赖任务详情接口。
18
+ - 该修复当前属于未发布改动;npm 上的 `@xqyz/xq-cli@0.3.5` 和已发布 WorkBuddy Plugin `0.10.14` 尚未包含它,发布前必须递增 xq-cli 补丁版本并同步 Plugin 依赖。
19
+
20
+ ### WorkBuddy Plugin 0.10.14 / SkillHub 1.0.11 发布记录
21
+
22
+ - 安装器不再把“`mcp.json` 已写入”误报为已升级:会固定写入 `0.10.14`,启动同一版本 MCP 完成 `initialize` 烟囱验证;验证失败会恢复原配置并输出 Node/npm 与 MCP 错误。
23
+ - SkillHub 更新规则明确区分“Skill 已复制”和“WorkBuddy 已加载”:旧 Plugin、旧 xq-cli、缺失版本或 MCP 启动失败都必须执行升级,完全重启 WorkBuddy 后再次核对实际 `pluginVersion`、`cliVersion` 和登录状态。
24
+ - macOS/Linux 安装路径改用兼容 BSD/macOS 的版本选择逻辑,去除 `sort -V` 和 npx 参数分隔符依赖,统一使用 `--yes --package <包> xique-workbuddy-mcp`;MCP 启动超时提高到 180 秒。
25
+ - Windows MCP 启动验证会正确引用带空格的 `npx.cmd` / `*.bat` 路径,系统 Node 位于 `C:\Program Files` 时也能完成握手。
26
+ - Connector、插件市场清单、SkillHub Skill 和安装脚本统一指向 WorkBuddy Plugin `0.10.14`;xq-cli 继续固定 `0.3.5`。
27
+ - `@xqyz/workbuddy-plugin-xique@0.10.14` 已发布到 npm,`latest` 已指向 `0.10.14`;已生成 `output/skillhub-xique-bid-1.0.11.zip`,SkillHub 仍待手动上传。
28
+
29
+ ### WorkBuddy Plugin 0.10.13 / SkillHub 1.0.10 发布记录
30
+
31
+ - 图片设置分组默认展开,用户打开配置卡即可选择图片来源和流程图样式;图片尺寸、页边距仍默认收起。
32
+ - Plugin、Connector、SkillHub Skill 和本机 WorkBuddy Skill 已同步到本轮版本基线。
33
+ - npm Plugin 已发布到 `latest`;SkillHub 上传包只在本机生成,不通过浏览器代发布,由发布人手动上传生成的 zip。
34
+
35
+ ### WorkBuddy 旧版本升级校验
36
+
37
+ - `xique_runtime_status` 新增实际加载的 `pluginVersion`,用于区分“配置文件已写入”和“WorkBuddy 已加载新 MCP”。
38
+ - SkillHub 更新时检测到旧版 Plugin 会执行固定版本升级;普通生成请求不会静默修改本机环境。
39
+ - 安装后必须完全重启 WorkBuddy,并复核 `pluginVersion`、`cliVersion`、运行状态和登录状态,未通过复核不得报告安装成功。
40
+ - 增加旧 `0.10.11` 配置升级到 `0.10.12` 的回归测试,保留其他 MCP、环境变量和备份文件。
41
+
42
+ ### SkillHub 1.0.9 上传包准备
43
+
44
+ - 保持既有 Skill slug `xique-bid` 和显示名称“喜鹊标书”,不创建第二个同功能 Skill。
45
+ - 上传内容同步旧 Plugin 版本探测、升级授权、重启后版本复核和失败停止重试规则。
46
+ - 发布前必须确认 npm 已存在 `@xqyz/workbuddy-plugin-xique@0.10.12`,再上传 SkillHub `1.0.9`。
47
+
48
+ ### WorkBuddy 配置卡布局优化
49
+
50
+ - 图片设置(来源、流程图样式和图片尺寸)与页边距改为默认收起的分组;默认值继续随配置提交,用户只有需要自定义排版时才展开。
51
+ - 模板5的编号起始值和完整编号规则紧跟导出模板显示,选择模板5后自动展开,避免用户误以为是无关的后置导出参数。
52
+ - MCP App 调整分组卡片、响应式网格和标题层级,减少长页面中的重复边框并改善窄屏可读性。
53
+ - 增加配置分组、排序和默认收起状态的回归测试。
54
+
55
+ ### SkillHub 1.0.8 上传包准备
56
+
57
+ - 已生成 `output/skillhub-xique-bid-1.0.8.zip`,保持既有 Skill slug `xique-bid` 和显示名称“喜鹊标书”。
58
+ - 上传包同步本轮折叠分组、模板5编号紧邻展开和布局优化规则;正式发布前必须先发布 npm Plugin `0.10.12`。
59
+
60
+ ### WorkBuddy 配置卡补齐导出设置
61
+
62
+ - 首次“喜鹊标书配置”卡增加图片来源、页边距和图片尺寸字段,默认值与喜鹊前端一致,提交后会随导出配置保存。
63
+ - 选择“模板5|自定义编号”后动态展开完整可视化编号设置:五级标题的编号格式、目录级别、字体、字号、对齐、加粗、分页/换行,正文四级编号及正文样式、起始编号。
64
+ - 模板1-4 不显示也不要求填写自定义编号字段;模板5生成的结构化配置继续通过 `customNumbering` 传递,不要求用户输入 JSON。
65
+ - 增加配置卡字段、模板切换显隐、默认值和完整配置数量回归测试。
66
+
67
+ ### WorkBuddy 配置卡稳定性修复
68
+
69
+ - `xique_generation_status` 在解析完成进入 `bid-config` 或旧任务 `special-config` 时只返回权威阶段状态,不再直接返回没有 MCP App 元数据的配置卡结果。
70
+ - Skill 会在状态进入配置阶段后立即显式调用带 UI 元数据的 `xique_bid_configuration` / `xique_special_project_selection`,避免 WorkBuddy 先显示普通文字、用户追问后才出现卡片。
71
+ - 增加回归测试,确认同一 `runId` 下真实多标包仍能在显式配置卡中正常展示。
72
+
73
+ ## [0.3.5 / WorkBuddy Plugin 0.10.12] - 2026-08-20(npm 已发布)
74
+
75
+ ### 配置卡首次渲染修复
76
+
77
+ - `xique_generation_status` 进入 `bid-config` 或旧任务 `special-config` 时只返回权威状态,不再直接返回没有 MCP App UI 元数据的 `selectionUi`。
78
+ - Skill、命令和 Connector 指引改为在状态进入配置阶段后立即显式调用带 UI 元数据的配置工具,避免 WorkBuddy 首次只显示状态文本、用户追问后才出现配置卡。
79
+ - 新增回归测试验证状态查询不产生卡片,但显式 `xique_bid_configuration` 仍能展示同一解析任务的真实多标包信息。
80
+ - `xique_runtime_status` 返回实际加载的 `pluginVersion`;旧版本升级必须在重启 WorkBuddy 后复核版本、CLI 和登录状态。
81
+ - 已发布 `@xqyz/workbuddy-plugin-xique@0.10.12`,npm `latest` 已指向该版本。
82
+
13
83
  ## [0.3.5 / WorkBuddy Plugin 0.10.11] - 2026-08-20(待发布)
14
84
 
15
85
  ### 文件解析与多标包统一配置
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xqyz/xq-cli",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "CLI for xique bid-book task workflows",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/cli.mjs CHANGED
@@ -23,6 +23,11 @@ import {
23
23
  buildDirectoryViewDocument,
24
24
  formatDirectoryLines,
25
25
  } from './directory-view.mjs';
26
+ import {
27
+ assertContentCompleted,
28
+ assertContentGenerationAllowed,
29
+ assertPreContentMutationAllowed,
30
+ } from './task-safety.mjs';
26
31
 
27
32
  const APP_DIR = path.join(os.homedir(), '.xq-opencli');
28
33
  const CONFIG_FILE = path.join(APP_DIR, 'config.json');
@@ -816,6 +821,8 @@ async function handlePeriodStatus(args, state) {
816
821
  async function handlePeriodConfirm(args, state) {
817
822
  const client = createAuthorizedClient(args, state);
818
823
  const cid = resolveCid(args, state);
824
+ const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
825
+ assertPreContentMutationAllowed(authoritativeState, '确认工期');
819
826
  const contractPeriod = numberOption(args, 'contractPeriod');
820
827
  if (!Number.isInteger(contractPeriod) || contractPeriod <= 0) {
821
828
  throw new Error('Option --contract-period expects a positive integer.');
@@ -939,6 +946,16 @@ async function handleWizard(args, state) {
939
946
  }
940
947
 
941
948
  async function handlePlan(args, state) {
949
+ const action = String(args?._?.[1] || 'status').trim().toLowerCase();
950
+ const readOnly = new Set(['status', 'show', 'outline-view', 'directory-view']);
951
+ if (!readOnly.has(action)) {
952
+ const cid = resolveCid(args, state);
953
+ return withGenerationLock(cid, 'generation', () => handlePlanUnlocked(args, state));
954
+ }
955
+ return handlePlanUnlocked(args, state);
956
+ }
957
+
958
+ async function handlePlanUnlocked(args, state) {
942
959
  const action = String(args?._?.[1] || 'status').trim().toLowerCase();
943
960
  switch (action) {
944
961
  case 'status':
@@ -1081,6 +1098,8 @@ async function handlePlanFeedback(args, state) {
1081
1098
  async function handlePlanReferenceSelection(args, state) {
1082
1099
  const client = createAuthorizedClient(args, state);
1083
1100
  const cid = resolveCid(args, state);
1101
+ const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
1102
+ assertPreContentMutationAllowed(authoritativeState, '提交响应依据');
1084
1103
  const referenceType = parseOutlineReferenceType(stringOption(args, 'referenceType'));
1085
1104
  if (referenceType === null) {
1086
1105
  throw new Error('plan reference-select requires --reference-type 0, 1, 2, merge, format, or score.');
@@ -1140,6 +1159,8 @@ async function handlePlanReferenceSelection(args, state) {
1140
1159
  async function handlePlanOutlineConfirmation(args, state) {
1141
1160
  const client = createAuthorizedClient(args, state);
1142
1161
  const cid = resolveCid(args, state);
1162
+ const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
1163
+ assertPreContentMutationAllowed(authoritativeState, '确认或重新生成大纲');
1143
1164
  const snapshot = await hydrateTaskSnapshot(client, state, cid);
1144
1165
  const params = buildOutlineParams(snapshot, { ...args, planMode: 'planning' });
1145
1166
  const regenerating = booleanOption(args, 'regenerate') || normalizePlanModePhase(snapshot.plan_mode_phase) >= 1;
@@ -1195,6 +1216,8 @@ async function handlePlanOutlineConfirmation(args, state) {
1195
1216
  async function handlePlanAiOutlineAdjust(args, state) {
1196
1217
  const client = createAuthorizedClient(args, state);
1197
1218
  const cid = resolveCid(args, state);
1219
+ const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
1220
+ assertPreContentMutationAllowed(authoritativeState, '请求 AI 调整大纲');
1198
1221
  const prompt = requiredOption(args, 'prompt').trim();
1199
1222
  if (!prompt) {
1200
1223
  throw new Error('Option --prompt cannot be empty.');
@@ -1279,6 +1302,8 @@ async function handlePlanAiOutlineApply(args, state) {
1279
1302
  async function handlePlanDirectoryGenerate(args, state, regenerating) {
1280
1303
  const client = createAuthorizedClient(args, state);
1281
1304
  const cid = resolveCid(args, state);
1305
+ const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
1306
+ assertPreContentMutationAllowed(authoritativeState, regenerating ? '重新生成目录' : '生成目录');
1282
1307
  const snapshot = await hydrateTaskSnapshot(client, state, cid);
1283
1308
  ensurePlanningTask(snapshot);
1284
1309
  const currentPhase = normalizePlanModePhase(snapshot.plan_mode_phase);
@@ -1342,9 +1367,16 @@ async function handlePlanDirectoryGenerate(args, state, regenerating) {
1342
1367
  }
1343
1368
 
1344
1369
  async function handleOutline(args, state) {
1370
+ const cid = resolveCid(args, state);
1371
+ return withGenerationLock(cid, 'generation', () => handleOutlineUnlocked(args, state));
1372
+ }
1373
+
1374
+ async function handleOutlineUnlocked(args, state) {
1345
1375
  validateOutlineReferenceTypeOption(args);
1346
1376
  const client = createAuthorizedClient(args, state);
1347
1377
  const cid = resolveCid(args, state);
1378
+ const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
1379
+ assertPreContentMutationAllowed(authoritativeState, '重新生成大纲');
1348
1380
  const snapshot = await hydrateTaskSnapshot(client, state, cid);
1349
1381
  const outlineSnapshot = {
1350
1382
  ...snapshot,
@@ -1536,6 +1568,11 @@ async function handleOutlineUpdate(args, state) {
1536
1568
  return resultPayload;
1537
1569
  }
1538
1570
 
1571
+ // Re-check the backend immediately before the write. Do not rely on the
1572
+ // local task cache: a fresh machine may have no snapshot, and another
1573
+ // process may have started body generation after the preview was read.
1574
+ const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
1575
+ assertPreContentMutationAllowed(authoritativeState, '修改大纲');
1539
1576
  const response = await postJson(client, '/c/updateOutLine', requestData);
1540
1577
  saveTaskSnapshot(state, cid, {
1541
1578
  outlineUpdatedAt: new Date().toISOString(),
@@ -1644,6 +1681,10 @@ async function handleDirectoryUpdate(args, state) {
1644
1681
  outputResult(args, { ...resultPayload, reason: 'cancelled' }, [`directory update cancelled: cid=${cid}`]);
1645
1682
  return { ...resultPayload, reason: 'cancelled' };
1646
1683
  }
1684
+ // The local state file is only a cache. Always use the backend status for
1685
+ // the final mutation so completed/in-flight content cannot be overwritten.
1686
+ const authoritativeState = await fetchAuthoritativeTaskState(client, cid);
1687
+ assertPreContentMutationAllowed(authoritativeState, '修改目录');
1647
1688
  const response = await postJson(client, '/task/updateDirectory', requestPreview);
1648
1689
  const payload = { ...resultPayload, applied: true, response };
1649
1690
  saveTaskSnapshot(state, cid, { directoryUpdatedAt: new Date().toISOString() });
@@ -1713,8 +1754,17 @@ function writeDirectoryViewFile(outputOption, cid, document) {
1713
1754
  }
1714
1755
 
1715
1756
  async function handleWrite(args, state) {
1757
+ const cid = resolveCid(args, state);
1758
+ return withGenerationLock(cid, 'generation', () => handleWriteUnlocked(args, state));
1759
+ }
1760
+
1761
+ async function handleWriteUnlocked(args, state) {
1716
1762
  const client = createAuthorizedClient(args, state);
1717
1763
  const cid = resolveCid(args, state);
1764
+ // Check before directory polling so a completed/in-flight task cannot
1765
+ // trigger a sequel request while a repeated write is being rejected.
1766
+ const earlyTaskState = await fetchAuthoritativeTaskState(client, cid);
1767
+ assertContentGenerationAllowed(earlyTaskState, '生成正文');
1718
1768
  const type = numberOption(args, 'type', 1);
1719
1769
  const directoryWaitResult = await ensureDirectoryReadyForWrite(client, state, cid, args);
1720
1770
 
@@ -1738,6 +1788,7 @@ async function handleWrite(args, state) {
1738
1788
  const contentDetail = {
1739
1789
  data: directoryWaitResult.contentDetail,
1740
1790
  };
1791
+ assertContentGenerationAllowed(contentDetail.data, '生成正文');
1741
1792
  saveTaskSnapshot(state, cid, pickTaskSnapshot(contentDetail.data));
1742
1793
 
1743
1794
  const writePreparation = await prepareContentGeneration(client, state, cid, contentDetail, args);
@@ -1755,6 +1806,17 @@ async function handleWrite(args, state) {
1755
1806
  if (booleanOption(args, 'wait')) {
1756
1807
  status = await pollContentUntilReady(client, state, cid, args);
1757
1808
  }
1809
+ if (status && status.task_status !== undefined) {
1810
+ try {
1811
+ assertContentCompleted(status, '确认正文完成');
1812
+ saveTaskSnapshot(state, cid, {
1813
+ contentGenerationCompletedAt: new Date().toISOString(),
1814
+ });
1815
+ } catch {
1816
+ // The poller is the authoritative completion check; do not turn a
1817
+ // successful backend response into a local-only failure marker.
1818
+ }
1819
+ }
1758
1820
 
1759
1821
  const resultPayload = {
1760
1822
  command: 'write',
@@ -1823,7 +1885,14 @@ async function handleStatus(args, state) {
1823
1885
  async function handleExport(args, state) {
1824
1886
  const client = createAuthorizedClient(args, state);
1825
1887
  const cid = resolveCid(args, state);
1826
- const snapshot = await hydrateTaskSnapshot(client, state, cid);
1888
+ const cachedSnapshot = await hydrateTaskSnapshot(client, state, cid);
1889
+ const authoritativeState = await fetchAuthoritativeTaskState(client, cid, { content: true });
1890
+ assertContentCompleted(authoritativeState, '导出');
1891
+ const snapshot = {
1892
+ ...cachedSnapshot,
1893
+ ...pickTaskSnapshot(authoritativeState),
1894
+ };
1895
+ saveTaskSnapshot(state, cid, snapshot);
1827
1896
  const effectiveArgs = booleanOption(args, 'interactive')
1828
1897
  ? await collectExportInteractiveArgs(args, snapshot)
1829
1898
  : args;
@@ -1859,7 +1928,12 @@ async function handleExport(args, state) {
1859
1928
  };
1860
1929
  const response = await postBinary(client, '/c/downWithStyle', requestData);
1861
1930
  const fileName = parseFileName(response.headers['content-disposition']) || `${taskTitle}.docx`;
1862
- const savedPath = writeDownloadFile(effectiveArgs.out, fileName, response.data);
1931
+ const savedPath = writeDownloadFile(effectiveArgs.out, fileName, response.data, {
1932
+ protectedPaths: [
1933
+ CONFIG_FILE,
1934
+ ...(Array.isArray(snapshot.localFiles) ? snapshot.localFiles : []),
1935
+ ],
1936
+ });
1863
1937
  saveTaskSnapshot(state, cid, {
1864
1938
  tableColor,
1865
1939
  tableStyle,
@@ -1905,6 +1979,24 @@ async function hydrateTaskSnapshot(client, state, cid) {
1905
1979
  return snapshot;
1906
1980
  }
1907
1981
 
1982
+ async function fetchAuthoritativeTaskState(client, cid, options = {}) {
1983
+ const taskResponse = await getJson(
1984
+ client,
1985
+ `/task/getTaskDetail/${encodeURIComponent(cid)}`,
1986
+ {},
1987
+ { retries: DEFAULT_RETRY_COUNT },
1988
+ );
1989
+ const taskData = taskResponse?.data || {};
1990
+ if (!options.content) {
1991
+ return taskData;
1992
+ }
1993
+ const contentResponse = await fetchContentDetail(client, cid);
1994
+ return {
1995
+ ...taskData,
1996
+ ...(contentResponse?.data || {}),
1997
+ };
1998
+ }
1999
+
1908
2000
  function buildOutlineParams(snapshot, args) {
1909
2001
  const pageScopeCode = enumOption(args, ['pageScope', 'pageScopeCode'], PAGE_SCOPE_OPTIONS, snapshot.pageScopeCode ?? DEFAULT_PAGE_SCOPE_CODE);
1910
2002
  const base = enumOption(args, 'base', BASE_OPTIONS, snapshot.base ?? DEFAULT_BASE);
@@ -2854,6 +2946,7 @@ async function ensureDirectoryReadyForWrite(client, state, cid, args) {
2854
2946
 
2855
2947
  async function prepareContentGeneration(client, state, cid, contentDetailResponse, args) {
2856
2948
  const contentDetail = contentDetailResponse?.data || {};
2949
+ assertContentGenerationAllowed(contentDetail, '生成正文');
2857
2950
  const viewStep = Number(contentDetail.viewStep);
2858
2951
  const taskStatus = Number(contentDetail.task_status);
2859
2952
  const contentPortionStatus = Number(contentDetail.content_portion_status);
@@ -4706,7 +4799,15 @@ function readState() {
4706
4799
 
4707
4800
  function writeState(state) {
4708
4801
  fs.mkdirSync(APP_DIR, { recursive: true });
4709
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(state, null, 2), 'utf8');
4802
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(state, null, 2), {
4803
+ encoding: 'utf8',
4804
+ mode: 0o600,
4805
+ });
4806
+ try {
4807
+ fs.chmodSync(CONFIG_FILE, 0o600);
4808
+ } catch {
4809
+ // Windows ACLs may ignore POSIX mode bits; keep the user-scoped path.
4810
+ }
4710
4811
  }
4711
4812
 
4712
4813
  function saveTaskSnapshot(state, cid, patch) {
@@ -4723,6 +4824,55 @@ function saveTaskSnapshot(state, cid, patch) {
4723
4824
  return state.tasks[cid];
4724
4825
  }
4725
4826
 
4827
+ async function withGenerationLock(cid, operation, work) {
4828
+ const lockDirectory = path.join(APP_DIR, 'generation-locks');
4829
+ fs.mkdirSync(lockDirectory, { recursive: true });
4830
+ const lockPath = path.join(
4831
+ lockDirectory,
4832
+ encodeURIComponent(String(cid)) + '-' + encodeURIComponent(String(operation)) + '.lock',
4833
+ );
4834
+ let descriptor;
4835
+ try {
4836
+ descriptor = fs.openSync(lockPath, 'wx', 0o600);
4837
+ fs.writeFileSync(descriptor, JSON.stringify({
4838
+ cid: String(cid),
4839
+ operation: String(operation),
4840
+ pid: process.pid,
4841
+ createdAt: new Date().toISOString(),
4842
+ }), 'utf8');
4843
+ } catch (error) {
4844
+ if (error?.code === 'EEXIST') {
4845
+ try {
4846
+ const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
4847
+ if (ageMs > 7 * 24 * 60 * 60 * 1000) {
4848
+ fs.unlinkSync(lockPath);
4849
+ return withGenerationLock(cid, operation, work);
4850
+ }
4851
+ } catch {
4852
+ // Keep the fail-closed lock decision below when the lock cannot be inspected.
4853
+ }
4854
+ throw new Error('[GENERATION_LOCKED] 当前任务已有同一生成操作正在执行;请查询 status,不要并发重试。');
4855
+ }
4856
+ throw error;
4857
+ } finally {
4858
+ if (descriptor !== undefined) {
4859
+ fs.closeSync(descriptor);
4860
+ }
4861
+ }
4862
+
4863
+ try {
4864
+ return await work();
4865
+ } finally {
4866
+ try {
4867
+ fs.unlinkSync(lockPath);
4868
+ } catch (error) {
4869
+ if (error?.code !== 'ENOENT') {
4870
+ // A stale lock is safer than hiding the original generation result.
4871
+ }
4872
+ }
4873
+ }
4874
+ }
4875
+
4726
4876
  function pickTaskSnapshot(source) {
4727
4877
  return compactObject({
4728
4878
  cid: source.cid,
@@ -4771,6 +4921,8 @@ function pickTaskSnapshot(source) {
4771
4921
  outlineTriggeredAt: source.outlineTriggeredAt,
4772
4922
  triggerMode: source.triggerMode,
4773
4923
  writeTriggeredAt: source.writeTriggeredAt,
4924
+ contentGenerationStartedAt: source.contentGenerationStartedAt,
4925
+ contentGenerationCompletedAt: source.contentGenerationCompletedAt,
4774
4926
  });
4775
4927
  }
4776
4928
 
@@ -4801,10 +4953,21 @@ function normalizeDownloadFileName(value) {
4801
4953
  return String(value).replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_').trim();
4802
4954
  }
4803
4955
 
4804
- function writeDownloadFile(outputOption, fileName, fileContent) {
4956
+ function writeDownloadFile(outputOption, fileName, fileContent, options = {}) {
4805
4957
  const resolvedOutput = outputOption ? path.resolve(outputOption) : process.cwd();
4806
4958
  const outputIsDirectory = !path.extname(resolvedOutput);
4807
4959
  const finalPath = outputIsDirectory ? path.join(resolvedOutput, fileName) : resolvedOutput;
4960
+ const protectedPaths = (options.protectedPaths || [])
4961
+ .filter(Boolean)
4962
+ .map(value => path.resolve(String(value)));
4963
+ const normalizedFinalPath = path.resolve(finalPath);
4964
+ if (protectedPaths.some(value => value === normalizedFinalPath)) {
4965
+ throw new Error('[UNSAFE_OUTPUT_PATH] 输出文件不能覆盖源文件或 xq-cli 凭据/状态文件。请指定新的输出目录。');
4966
+ }
4967
+ if (normalizedFinalPath === path.resolve(APP_DIR)
4968
+ || normalizedFinalPath.startsWith(path.resolve(APP_DIR) + path.sep)) {
4969
+ throw new Error('[UNSAFE_OUTPUT_PATH] 输出文件不能写入 xq-cli 内部状态目录。请指定用户输出目录。');
4970
+ }
4808
4971
  fs.mkdirSync(path.dirname(finalPath), { recursive: true });
4809
4972
  fs.writeFileSync(finalPath, Buffer.from(fileContent));
4810
4973
  return finalPath;
@@ -0,0 +1,127 @@
1
+ const CONTENT_VIEW_STEP = 101;
2
+
3
+ export class TaskSafetyError extends Error {
4
+ constructor(code, message, details = {}) {
5
+ super('[' + code + '] ' + message);
6
+ this.name = 'TaskSafetyError';
7
+ this.code = code;
8
+ this.details = details;
9
+ }
10
+ }
11
+
12
+ function finiteNumber(value) {
13
+ const parsed = Number(value);
14
+ return Number.isFinite(parsed) ? parsed : null;
15
+ }
16
+
17
+ /**
18
+ * Normalize the backend's mixed task/content status fields into a conservative
19
+ * decision. A missing status is intentionally not treated as "not started".
20
+ */
21
+ export function classifyContentState(detail = {}) {
22
+ const taskStatus = finiteNumber(detail.task_status);
23
+ const contentPortionStatus = finiteNumber(detail.content_portion_status);
24
+ const viewStep = finiteNumber(detail.viewStep);
25
+ const percentage = finiteNumber(detail.percentage);
26
+ const completedChapter = finiteNumber(detail.completedChapter);
27
+ const totalChapter = finiteNumber(detail.totalChapter);
28
+ const statusKnown = [taskStatus, contentPortionStatus, viewStep, percentage, completedChapter, totalChapter]
29
+ .some(value => value !== null);
30
+ const complete = percentage !== null && percentage >= 100
31
+ || taskStatus === 5
32
+ || (totalChapter !== null && totalChapter > 0
33
+ && completedChapter !== null && completedChapter >= totalChapter)
34
+ || (taskStatus === 4 && contentPortionStatus === 1);
35
+ const failed = taskStatus === 7 || contentPortionStatus === 7;
36
+ const started = complete
37
+ || viewStep === CONTENT_VIEW_STEP
38
+ || (taskStatus !== null && (taskStatus === 4 || taskStatus === 5))
39
+ || (contentPortionStatus !== null && contentPortionStatus > 0)
40
+ || (percentage !== null && percentage > 0)
41
+ || (completedChapter !== null && completedChapter > 0);
42
+
43
+ return {
44
+ statusKnown,
45
+ complete,
46
+ failed,
47
+ started,
48
+ inFlight: started && !complete && !failed,
49
+ taskStatus,
50
+ contentPortionStatus,
51
+ viewStep,
52
+ percentage,
53
+ completedChapter,
54
+ totalChapter,
55
+ };
56
+ }
57
+
58
+ export function assertContentGenerationAllowed(detail, operation = '生成正文') {
59
+ const state = classifyContentState(detail);
60
+ if (!state.statusKnown) {
61
+ throw new TaskSafetyError(
62
+ 'CONTENT_STATUS_UNKNOWN',
63
+ '无法确认当前任务正文状态,已拒绝' + operation + '。请先查询 status,确认后台状态后再重试。',
64
+ { state },
65
+ );
66
+ }
67
+ if (state.complete) {
68
+ throw new TaskSafetyError(
69
+ 'CONTENT_ALREADY_COMPLETED',
70
+ '该任务正文已经生成完成,禁止再次生成;如需新文件请使用 export 重新导出。',
71
+ { state },
72
+ );
73
+ }
74
+ if (state.failed) {
75
+ throw new TaskSafetyError(
76
+ 'CONTENT_GENERATION_FAILED',
77
+ '该任务的正文生成已失败,已拒绝自动重试以避免重复扣费或覆盖结果;请查询 status 后创建新的任务。',
78
+ { state },
79
+ );
80
+ }
81
+ if (state.started) {
82
+ throw new TaskSafetyError(
83
+ 'CONTENT_GENERATION_IN_PROGRESS',
84
+ '该任务正文已经启动或正在生成,禁止重复触发;请继续查询 status。',
85
+ { state },
86
+ );
87
+ }
88
+ return state;
89
+ }
90
+
91
+ export function assertContentCompleted(detail, operation = '导出') {
92
+ const state = classifyContentState(detail);
93
+ if (!state.statusKnown) {
94
+ throw new TaskSafetyError(
95
+ 'CONTENT_STATUS_UNKNOWN',
96
+ '无法确认当前任务正文状态,已拒绝' + operation + '。请先查询 status。',
97
+ { state },
98
+ );
99
+ }
100
+ if (!state.complete) {
101
+ throw new TaskSafetyError(
102
+ 'CONTENT_NOT_COMPLETED',
103
+ '正文尚未生成完成,不能' + operation + '。请继续查询 status。',
104
+ { state },
105
+ );
106
+ }
107
+ return state;
108
+ }
109
+
110
+ export function assertPreContentMutationAllowed(detail, operation) {
111
+ const state = classifyContentState(detail);
112
+ if (!state.statusKnown) {
113
+ throw new TaskSafetyError(
114
+ 'TASK_STATUS_UNKNOWN',
115
+ '无法确认当前任务阶段,已拒绝' + operation + '。请先查询 status。',
116
+ { state },
117
+ );
118
+ }
119
+ if (state.complete || state.started || state.failed) {
120
+ throw new TaskSafetyError(
121
+ 'TASK_CONTENT_PHASE_LOCKED',
122
+ '正文阶段已经开始或已经结束,禁止' + operation + ';只能查看状态或重新导出。',
123
+ { state },
124
+ );
125
+ }
126
+ return state;
127
+ }