@xqyz/xq-cli 0.2.1 → 0.3.0

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,22 @@
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ### WorkBuddy
14
+
15
+ - 修复计划模式默认值:未提供 `planMode` 时明确使用快速(`--plan-mode 0`),用户明确选择规划时保留 `--plan-mode 1`,不再发生模式被错误回退的问题。
16
+ - 准备发布 WorkBuddy Plugin/Connector `0.9.4`:恢复配置页“计划模式=快速/规划”选择,并将用户选择原样传递给 xq-cli。
17
+ - 统一 Skill、安装脚本、MCP Server 和 Connector 的版本指向,避免继续加载 `0.9.3` 的快速模式固定包。
18
+ - 新增 [版本与迭代记录](./docs/VERSION-HISTORY.md),记录 xq-cli、Plugin、Connector 的功能、依赖、测试和发布结果。
19
+
20
+ ## [0.3.0] - 2026-08-14
21
+
22
+ ### xq-cli
23
+
24
+ - 新增 `xq-cli plan` 规划模式命令链,覆盖补充/修正信息、确认生成/重新生成大纲、AI 调整大纲候选、确认采用候选、生成最终目录、重新生成目录、目录查看/修改和正文续写。
25
+ - 补充/修正与 AI 调整结果先保存在本地任务状态,只有在用户明确确认 `plan confirm-outline` 或 `plan directory-generate` / `plan directory-regenerate` 后才会触发对应后台写入或生成请求。
26
+ - AI 大纲候选不再生成本地伪章节 ID;最终保存请求与前端一致,仅提交章节内容、主题、重要度和续写标记,交由后台分配节点 ID。
27
+ - 增加规划模式模拟 API 回归测试,覆盖意见提交、AI 候选不提前落库、候选确认和最终目录生成的顺序。
28
+
13
29
  ## [0.2.1] - 2026-08-11
14
30
 
15
31
  ### 安全性
package/README.md CHANGED
@@ -7,6 +7,7 @@ Project execution rules for agents live in [AGENTS.md](./AGENTS.md). That file d
7
7
  Project maintenance documents:
8
8
 
9
9
  - [Version changelog](./CHANGELOG.md)
10
+ - [Version and iteration history](./docs/VERSION-HISTORY.md)
10
11
  - [Release process](./docs/RELEASING.md)
11
12
 
12
13
  ## Install from npm
@@ -53,7 +54,8 @@ npm install -g .\xqyz-xq-cli-0.2.0.tgz
53
54
  ```bash
54
55
  xq-cli login
55
56
  xq-cli login --name your-account --password your-password
56
- xq-cli login --base-url https://xqai.atest.qianlima.com/api --browser
57
+ xq-cli login --api-key xq_sk_xxx
58
+ xq-cli login --browser
57
59
  xq-cli wizard
58
60
  xq-cli wizard --file D:\bids\tender.docx
59
61
  xq-cli init --file D:\bids\tender.docx --wait
@@ -72,9 +74,9 @@ xq-cli export --cid <cid> --interactive --out D:\output
72
74
  xq-cli export --cid <cid> --out D:\output
73
75
  ```
74
76
 
75
- Fresh installs default to the production API address `https://ai.bidfile.qianlima.com/api`. Use `--base-url` only when you need to target another environment such as `https://xqai.atest.qianlima.com/api`.
77
+ Fresh installs default to the production API address `https://ai.bidfile.qianlima.com/api`. Use `--base-url` only when you need to target another API endpoint.
76
78
 
77
- `login` now defaults to browser authorization. If you explicitly pass `--name` and `--password`, the CLI falls back to password login.
79
+ `login` now defaults to browser authorization. If you explicitly pass `--name` and `--password`, the CLI falls back to password login. If you pass `--api-key`, it saves the API key locally and uses it for later requests.
78
80
 
79
81
  `directory view` reads the complete generated directory tree without triggering outline, directory, or content generation. Add `--json` for the raw API structure, or `--out <file-or-directory>` to save a UTF-8 JSON document.
80
82
 
@@ -128,6 +130,39 @@ xq-cli outline update --cid <cid> --file D:\output\outline.json --yes --allow-de
128
130
 
129
131
  The update command automatically calculates `updateFlag` by comparing the edited file with the latest server outline. Changes to chapter titles or themes may cause the backend to regenerate affected child directories asynchronously.
130
132
 
133
+ ## 完整规划模式流程
134
+
135
+ 规划模式不是一次命令跑到底,而是把前端的“智能解读 → 大纲 → 目录 → 正文”拆成可恢复的阶段。先用普通 `outline --plan-mode planning` 启动任务;拿到 `cid` 后,用 `plan` 子命令推进后续阶段:
136
+
137
+ ```bash
138
+ # 1. 补充或修正招标文件理解(只写入本地待提交区,不会请求后台)
139
+ xq-cli plan status --cid <cid> --json
140
+ xq-cli plan feedback --cid <cid> --text "补充项目实施边界" --quote "原文片段"
141
+ xq-cli plan correction --cid <cid> --file D:\output\corrections.json
142
+
143
+ # 2. 用户确认后,提交意见并生成/重新生成大纲
144
+ xq-cli plan confirm-outline --cid <cid> --yes --wait --json
145
+
146
+ # 3. 查看大纲;需要 AI 调整时先生成候选,不会立即改后台
147
+ xq-cli plan outline-view --cid <cid> --json
148
+ xq-cli plan outline-ai-adjust --cid <cid> --prompt "拆分实施方案和质量保障" --out D:\output
149
+ xq-cli plan outline-ai-apply --cid <cid> --file D:\output\outline-ai-<cid>.json --yes
150
+
151
+ # 4. 用户确认候选大纲后,保存大纲并生成最终目录(不会生成正文)
152
+ xq-cli plan directory-generate --cid <cid> --yes --wait
153
+
154
+ # 5. 查看/编辑目录;如果需要重新生成目录,必须明确确认
155
+ xq-cli plan directory-view --cid <cid> --json
156
+ xq-cli plan directory-update --cid <cid> --file D:\output\directory.json --dry-run
157
+ xq-cli plan directory-regenerate --cid <cid> --yes --wait
158
+
159
+ # 6. 目录确认后才开始正文和导出
160
+ xq-cli plan write --cid <cid> --wait
161
+ xq-cli export --cid <cid> --out D:\output
162
+ ```
163
+
164
+ 要点:`plan status` 是恢复和重跑前的权威入口;`plan feedback` / `plan correction` 只写本地暂存,不会重复请求后台;`plan outline-ai-adjust` 会请求 AI 但只返回候选,`plan outline-ai-apply` 仍只写本地候选且不带伪造章节 ID。只有 `plan confirm-outline`、`plan directory-generate` / `plan directory-regenerate` 明确确认后才会产生后台写入或启动阶段;首次目录必须使用 `directory-generate`,已有目录覆盖才使用 `directory-regenerate`。
165
+
131
166
  ## Interactive menus
132
167
 
133
168
  Use the full wizard if you want a CLI flow close to the frontend configuration panel:
@@ -155,6 +190,55 @@ xq-cli outline --cid <cid> --interactive --wait
155
190
  xq-cli export --cid <cid> --interactive --out D:\output
156
191
  ```
157
192
 
193
+ ## Agent / Codex usage
194
+
195
+ When another agent (Codex, OpenCLI, or an MCP wrapper) invokes `xq-cli`, it
196
+ must treat bid generation as a stateful sequence rather than a single command:
197
+
198
+ ```text
199
+ init (source file) -> outline (confirmed config + plan mode) -> review
200
+ -> directory review (planning mode) -> write -> export
201
+ ```
202
+
203
+ `--plan-mode` belongs on `outline`, not on `write` or `export`:
204
+
205
+ - `0` / `quick`: enter outline generation quickly; use when the user requests
206
+ a quick first run or explicitly chooses 快速.
207
+ - `1` / `planning`: enable the backend's fuller planning phase; it can take
208
+ longer, so use `--wait` and do not shorten the polling timeout. Use the
209
+ dedicated `plan` commands to collect corrections, review AI candidates,
210
+ generate/re-generate the directory, and stop at every confirmation gate.
211
+
212
+ The backend may report `plan_mode_phase=0..3` (`analysis`, `outline`,
213
+ `directory`, `content`) while polling. This is progress information, not a
214
+ subcommand. If the CLI returns `reference_selection_required` or
215
+ `supplement_required`, stop and ask the user for the required choice/input;
216
+ never invent it. `outline view` is read-only and safe for verification.
217
+
218
+ For planning mode, the agent must pause at three human confirmation gates:
219
+
220
+ 1. collect any `plan feedback` / `plan correction`, then call
221
+ `plan confirm-outline` only after the user confirms generation;
222
+ 2. show the outline. `plan outline-ai-adjust` returns an editable candidate,
223
+ and `plan outline-ai-apply` accepts it locally without writing the backend;
224
+ 3. after the user confirms the final outline, call `plan directory-generate`,
225
+ show the directory, and wait for final directory confirmation before
226
+ `plan write`.
227
+
228
+ Use `plan directory-regenerate` only after an explicit request to replace the
229
+ existing directory. It is not a retry/status command. Fast mode has only the
230
+ outline confirmation gate and can then proceed to `write`.
231
+
232
+ For non-TTY or JSON automation, do not silently choose generation settings.
233
+ Ask for the missing configuration, print the final summary, wait for a clear
234
+ confirmation (`确认`, `开始`, or `就按这个跑`), then run the commands. Validate
235
+ enum values with `xq-cli choices outline --json` and
236
+ `xq-cli choices export --json` before constructing arguments. See
237
+ [`AGENTS.md`](./AGENTS.md) for the complete confirmation order and value map.
238
+ For the complete meaning of every outline/export choice, defaults, image-style
239
+ dependencies, EPC/multi-bid rules, margins, colors, and `--style-json`, see
240
+ [`docs/AGENT-PARAMETER-GUIDE.md`](./docs/AGENT-PARAMETER-GUIDE.md).
241
+
158
242
  ## Config choices
159
243
 
160
244
  `xq-cli` now exposes the same major bid-book generation settings that the frontend outline/download flow uses.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xqyz/xq-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for xique bid-book task workflows",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/cli.mjs CHANGED
@@ -205,6 +205,12 @@ const PLAN_MODE_OPTIONS = [
205
205
  { value: 0, label: 'quick', description: 'quick mode', aliases: ['0', 'quick'] },
206
206
  { value: 1, label: 'planning', description: 'planning mode', aliases: ['1', 'planning', 'plan'] },
207
207
  ];
208
+ const PLAN_MODE_PHASE_LABELS = {
209
+ 0: 'analysis',
210
+ 1: 'outline',
211
+ 2: 'directory',
212
+ 3: 'content',
213
+ };
208
214
  const LAYOUT_OPTIONS = [
209
215
  { value: 0, label: 'normal', description: 'regular layout', aliases: ['0', 'normal', 'default'] },
210
216
  { value: 1, label: 'table', description: 'full table layout', aliases: ['1', 'table'] },
@@ -338,6 +344,12 @@ async function main() {
338
344
  case 'parse-status':
339
345
  await handleParseStatus(parsed, state);
340
346
  return;
347
+ case 'period-status':
348
+ await handlePeriodStatus(parsed, state);
349
+ return;
350
+ case 'period-confirm':
351
+ await handlePeriodConfirm(parsed, state);
352
+ return;
341
353
  case 'outline':
342
354
  if (isOutlineAction(parsed, ['view', 'show'])) {
343
355
  await handleOutlineView(parsed, state);
@@ -354,7 +366,9 @@ async function main() {
354
366
  await handleOutlineUpdate(parsed, state);
355
367
  return;
356
368
  case 'directory':
357
- if (!parsed._[1] || isOutlineAction(parsed, ['view', 'show'])) {
369
+ if (isOutlineAction(parsed, ['update', 'edit'])) {
370
+ await handleDirectoryUpdate(parsed, state);
371
+ } else if (!parsed._[1] || isOutlineAction(parsed, ['view', 'show'])) {
358
372
  await handleDirectoryView(parsed, state);
359
373
  } else {
360
374
  throw new Error(`Unknown directory action: ${parsed._[1]}`);
@@ -363,6 +377,12 @@ async function main() {
363
377
  case 'directory-view':
364
378
  await handleDirectoryView(parsed, state);
365
379
  return;
380
+ case 'directory-prepare':
381
+ await handleDirectoryPrepare(parsed, state);
382
+ return;
383
+ case 'directory-update':
384
+ await handleDirectoryUpdate(parsed, state);
385
+ return;
366
386
  case 'write':
367
387
  await handleWrite(parsed, state);
368
388
  return;
@@ -381,6 +401,9 @@ async function main() {
381
401
  case 'wizard':
382
402
  await handleWizard(parsed, state);
383
403
  return;
404
+ case 'plan':
405
+ await handlePlan(parsed, state);
406
+ return;
384
407
  default:
385
408
  throw new Error(`Unsupported command: ${command}. Run "xq-cli help" to see available commands.`);
386
409
  }
@@ -391,24 +414,41 @@ function printHelp() {
391
414
  xq-cli: xique brand task CLI
392
415
 
393
416
  Commands
394
- login Save xique account token locally
417
+ login Save xique API key or account token locally
395
418
  init Upload files and initialize a task
396
419
  parse-status Query or wait for parse completion
420
+ period-status Query engineering project period confirmation status
421
+ period-confirm Save confirmed engineering project period
397
422
  outline Pre-set task params and trigger outline generation
398
423
  outline view View the current outline without triggering generation
399
424
  outline update Update the current outline from an edited JSON file
400
425
  directory view View the complete generated directory tree without triggering generation
426
+ directory update Apply an edited directory tree JSON file
427
+ directory-prepare Prepare/wait for the generated directory without starting body generation
401
428
  write Charge if needed, enter content phase, and trigger content generation
429
+ plan Run an explicit planning-mode phase (feedback, outline, AI review, directory)
402
430
  status Query task status
403
431
  choices Print selectable outline/export config values
404
432
  gallery-status Check whether My Gallery contains selectable images
405
433
  wizard Run init -> outline -> write -> export with interactive menus
406
434
  export Download the generated docx
407
435
 
436
+ Plan actions
437
+ feedback|supplement|correction Stage user additions/corrections locally
438
+ confirm-outline Submit staged feedback and generate the outline
439
+ outline-view View the current outline
440
+ outline-ai-adjust Ask AI for an outline candidate (no server write)
441
+ outline-ai-apply Accept a candidate locally for the directory step
442
+ directory-generate Save the confirmed outline and generate the final directory
443
+ directory-regenerate Explicitly replace and regenerate an existing directory
444
+ directory-view|directory-update Review or edit the directory
445
+
408
446
  Examples
409
447
  xq-cli login
410
448
  xq-cli login --name your-account --password your-password
411
- xq-cli login --base-url https://xqai.atest.qianlima.com/api --browser
449
+ xq-cli login --api-key xq_sk_xxx
450
+ xq-cli login --browser
451
+ $env:XQ_API_KEY="xq_sk_xxx"
412
452
  xq-cli init --file D:\\bids\\tender.docx --wait
413
453
  xq-cli parse-status --cid <cid> --uuid <uuid> --wait
414
454
  xq-cli choices
@@ -429,6 +469,14 @@ Examples
429
469
  xq-cli outline update --cid <cid> --file D:\\output\\outline.json --yes
430
470
  xq-cli directory view --cid <cid>
431
471
  xq-cli directory view --cid <cid> --out D:\\output\\directory.json
472
+ xq-cli directory update --cid <cid> --file D:\\output\\directory.json --yes
473
+ xq-cli plan status --cid <cid> --json
474
+ xq-cli plan feedback --cid <cid> --text "Add the missing service boundary"
475
+ xq-cli plan confirm-outline --cid <cid> --yes --wait
476
+ xq-cli plan outline-ai-adjust --cid <cid> --prompt "Split chapter 2 into implementation and quality control" --out D:\\output\\
477
+ xq-cli plan outline-ai-apply --cid <cid> --file D:\\output\\outline-ai-<cid>.json --yes
478
+ xq-cli plan directory-generate --cid <cid> --yes --wait
479
+ xq-cli plan directory-regenerate --cid <cid> --yes --wait
432
480
  xq-cli write --cid <cid> --type 1 --wait
433
481
  xq-cli status --cid <cid> --content
434
482
  xq-cli export --cid <cid> --interactive --out D:\\output\\
@@ -442,6 +490,7 @@ Common options
442
490
  --interval-sec <n> Poll interval in seconds, default 10
443
491
  --reference-type <0|1|2> Outline reference choice: 0=merge, 1=format only, 2=score only
444
492
  --browser Force browser authorization login; browser is the default when credentials are not provided
493
+ --api-key <key> Save a user API key locally and verify it with /user/info
445
494
  --interactive Open prompt menus for the current command
446
495
  --dry-run Preview an outline update without saving it
447
496
  --yes Confirm an outline update without an interactive prompt
@@ -504,6 +553,33 @@ async function handleLogin(args, state) {
504
553
  throw new Error('Missing base URL. Pass --base-url <url>.');
505
554
  }
506
555
  const sourceFrom = stringOption(args, 'sourceFrom', DEFAULT_SOURCE_FROM);
556
+ const apiKey = normalizeApiKey(stringOption(args, 'apiKey'));
557
+ if (apiKey) {
558
+ const result = await loginWithApiKey(baseUrl, apiKey);
559
+ saveLoginState(state, {
560
+ baseUrl,
561
+ name: result.data?.username || result.data?.userName || result.data?.user?.username || state.user?.name || '',
562
+ sourceFrom,
563
+ result,
564
+ apiKey,
565
+ authType: 'api-key',
566
+ user: result.data || {},
567
+ });
568
+ outputResult(args, {
569
+ command: 'login',
570
+ mode: 'api-key',
571
+ authType: 'api-key',
572
+ baseUrl,
573
+ username: state.user.username,
574
+ apiKeySaved: true,
575
+ }, [
576
+ `login success: ${state.user.username}`,
577
+ 'mode: api-key',
578
+ `baseUrl: ${baseUrl}`,
579
+ `config: ${CONFIG_FILE}`,
580
+ ]);
581
+ return;
582
+ }
507
583
  const loginMode = resolveLoginMode(args);
508
584
  const result = loginMode === 'browser'
509
585
  ? await loginWithBrowser(baseUrl, sourceFrom, args)
@@ -519,6 +595,7 @@ async function handleLogin(args, state) {
519
595
  outputResult(args, {
520
596
  command: 'login',
521
597
  mode: loginMode,
598
+ authType: loginMode === 'browser' ? 'token' : 'token',
522
599
  baseUrl,
523
600
  username: state.user.username,
524
601
  tokenSaved: true,
@@ -534,11 +611,19 @@ function resolveLoginMode(args) {
534
611
  if (booleanOption(args, 'browser')) {
535
612
  return 'browser';
536
613
  }
614
+ if (stringOption(args, 'apiKey')) {
615
+ return 'api-key';
616
+ }
537
617
  const hasName = stringOption(args, 'name');
538
618
  const hasPassword = stringOption(args, 'password');
539
619
  return hasName || hasPassword ? 'password' : 'browser';
540
620
  }
541
621
 
622
+ async function loginWithApiKey(baseUrl, apiKey) {
623
+ const client = createClient(baseUrl, apiKey);
624
+ return getJson(client, '/user/info');
625
+ }
626
+
542
627
  async function loginWithPassword(baseUrl, sourceFrom, args) {
543
628
  const name = requiredOption(args, 'name');
544
629
  const password = requiredOption(args, 'password');
@@ -680,6 +765,72 @@ async function handleParseStatus(args, state) {
680
765
  ]);
681
766
  }
682
767
 
768
+ async function handlePeriodStatus(args, state) {
769
+ const client = createAuthorizedClient(args, state);
770
+ const cid = resolveCid(args, state);
771
+ const response = await getJson(client, `/engineering/checkPeriod/${encodeURIComponent(cid)}`, {}, { retries: DEFAULT_RETRY_COUNT });
772
+ const data = response?.data || {};
773
+ saveTaskSnapshot(state, cid, {
774
+ periodStatus: data.status,
775
+ contractPeriod: data.contractPeriod,
776
+ contractStartDate: data.contractStartDate,
777
+ contractEndDate: data.contractEndDate,
778
+ });
779
+ const payload = { command: 'period-status', cid, ...data };
780
+ outputResult(args, payload, [
781
+ `period status: cid=${cid}`,
782
+ `status: ${data.status ?? '-'}`,
783
+ data.status === 2
784
+ ? `confirmation required: period=${data.contractPeriod ?? '-'}, start=${data.contractStartDate ?? '-'}, end=${data.contractEndDate ?? '-'}`
785
+ : data.status === 1
786
+ ? 'period confirmation not required or already confirmed'
787
+ : 'period extraction is still running',
788
+ ]);
789
+ return payload;
790
+ }
791
+
792
+ async function handlePeriodConfirm(args, state) {
793
+ const client = createAuthorizedClient(args, state);
794
+ const cid = resolveCid(args, state);
795
+ const contractPeriod = numberOption(args, 'contractPeriod');
796
+ if (!Number.isInteger(contractPeriod) || contractPeriod <= 0) {
797
+ throw new Error('Option --contract-period expects a positive integer.');
798
+ }
799
+ const periodMode = numberOption(args, 'periodMode', 2);
800
+ if (![1, 2].includes(periodMode)) {
801
+ throw new Error('Option --period-mode expects 1 (known dates) or 2 (unknown dates).');
802
+ }
803
+ const contractStartDate = periodMode === 1
804
+ ? requiredOption(args, 'contractStartDate')
805
+ : 'Day1';
806
+ const contractEndDate = periodMode === 1
807
+ ? requiredOption(args, 'contractEndDate')
808
+ : `Day${contractPeriod}`;
809
+ const response = await postJson(client, '/engineering/resetPeriod', {
810
+ cid,
811
+ contractPeriod,
812
+ contractStartDate,
813
+ contractEndDate,
814
+ periodMode,
815
+ });
816
+ saveTaskSnapshot(state, cid, {
817
+ periodStatus: 2,
818
+ contractPeriod,
819
+ contractStartDate,
820
+ contractEndDate,
821
+ periodMode,
822
+ });
823
+ const payload = { command: 'period-confirm', cid, contractPeriod, contractStartDate, contractEndDate, periodMode, response };
824
+ outputResult(args, payload, [
825
+ `period confirmed: cid=${cid}`,
826
+ `contract period: ${contractPeriod}`,
827
+ `start: ${contractStartDate}`,
828
+ `end: ${contractEndDate}`,
829
+ `period mode: ${periodMode === 1 ? 'known dates' : 'unknown dates'}`,
830
+ ]);
831
+ return payload;
832
+ }
833
+
683
834
  function handleChoices(args) {
684
835
  const scope = normalizeChoicesScope(args._[1] || 'all');
685
836
  const payload = buildChoicesPayload(scope);
@@ -763,6 +914,336 @@ async function handleWizard(args, state) {
763
914
  return resultPayload;
764
915
  }
765
916
 
917
+ async function handlePlan(args, state) {
918
+ const action = String(args?._?.[1] || 'status').trim().toLowerCase();
919
+ switch (action) {
920
+ case 'status':
921
+ case 'show':
922
+ await handlePlanStatus(args, state);
923
+ return;
924
+ case 'feedback':
925
+ case 'supplement':
926
+ case 'correction':
927
+ await handlePlanFeedback(args, state);
928
+ return;
929
+ case 'confirm-outline':
930
+ case 'outline-confirm':
931
+ case 'generate-outline':
932
+ case 'regenerate-outline':
933
+ await handlePlanOutlineConfirmation(args, state);
934
+ return;
935
+ case 'outline-ai-adjust':
936
+ case 'ai-adjust':
937
+ case 'ai-outline':
938
+ await handlePlanAiOutlineAdjust(args, state);
939
+ return;
940
+ case 'outline-ai-apply':
941
+ case 'ai-apply':
942
+ await handlePlanAiOutlineApply(args, state);
943
+ return;
944
+ case 'outline-view':
945
+ await handleOutlineView({ ...args, _: ['outline', 'view'] }, state);
946
+ return;
947
+ case 'outline-update':
948
+ await handleOutlineUpdate({ ...args, _: ['outline', 'update'] }, state);
949
+ return;
950
+ case 'directory-generate':
951
+ case 'generate-directory':
952
+ await handlePlanDirectoryGenerate(args, state, false);
953
+ return;
954
+ case 'directory-regenerate':
955
+ case 'regenerate-directory':
956
+ await handlePlanDirectoryGenerate(args, state, true);
957
+ return;
958
+ case 'directory-view':
959
+ await handleDirectoryView({ ...args, _: ['directory', 'view'] }, state);
960
+ return;
961
+ case 'directory-update':
962
+ await handleDirectoryUpdate({ ...args, _: ['directory', 'update'] }, state);
963
+ return;
964
+ case 'write':
965
+ case 'generate-content':
966
+ await handleWrite({ ...args, _: ['write'] }, state);
967
+ return;
968
+ default:
969
+ throw new Error(`Unknown plan action: ${action}. Run "xq-cli help" to see planning-mode commands.`);
970
+ }
971
+ }
972
+
973
+ async function handlePlanStatus(args, state) {
974
+ const client = createAuthorizedClient(args, state);
975
+ const cid = resolveCid(args, state);
976
+ const [taskResponse, outlineResponse, contentResponse] = await Promise.all([
977
+ getJson(client, `/task/getTaskDetail/${encodeURIComponent(cid)}`, {}, { retries: DEFAULT_RETRY_COUNT }),
978
+ fetchOutlineDetail(client, cid),
979
+ fetchContentDetail(client, cid),
980
+ ]);
981
+ const taskDetail = taskResponse?.data || {};
982
+ const outlineDetail = outlineResponse?.data || {};
983
+ const contentDetail = contentResponse?.data || {};
984
+ const phase = normalizePlanModePhase(taskDetail.plan_mode_phase);
985
+ const payload = {
986
+ command: 'plan-status',
987
+ cid,
988
+ planMode: normalizePlanModeValue(taskDetail.planMode),
989
+ planModePhase: phase,
990
+ phaseLabel: phase === null ? null : PLAN_MODE_PHASE_LABELS[phase],
991
+ taskDetail,
992
+ outlineDetail,
993
+ contentDetail,
994
+ pendingFeedback: normalizePlanFeedback(state.tasks?.[cid]?.planFeedback).length,
995
+ };
996
+ saveTaskSnapshot(state, cid, pickTaskSnapshot({ ...taskDetail, ...contentDetail }));
997
+ outputResult(args, payload, [
998
+ `plan status: cid=${cid}`,
999
+ `planMode: ${describeEnumValue(PLAN_MODE_OPTIONS, normalizePlanModeValue(taskDetail.planMode) ?? taskDetail.planMode)}`,
1000
+ `phase: ${formatPlanModePhase(phase)}`,
1001
+ `outline: ${outlineDetail.outline_status ? 'ready' : 'not-ready'} (${countOutlineRoots({ outlineDetail })} root chapters)`,
1002
+ `directory: task_status=${contentDetail.task_status ?? taskDetail.task_status ?? '-'}, directory_status=${contentDetail.directory_portion_status ?? taskDetail.directory_portion_status ?? '-'}`,
1003
+ `pending feedback: ${payload.pendingFeedback}`,
1004
+ ]);
1005
+ return payload;
1006
+ }
1007
+
1008
+ async function handlePlanFeedback(args, state) {
1009
+ const cid = resolveCid(args, state);
1010
+ const task = state.tasks?.[cid] || {};
1011
+ if (booleanOption(args, 'clear')) {
1012
+ saveTaskSnapshot(state, cid, { planFeedback: [] });
1013
+ const payload = { command: 'plan-feedback', cid, cleared: true, feedback: [] };
1014
+ outputResult(args, payload, [`plan feedback cleared: cid=${cid}`]);
1015
+ return payload;
1016
+ }
1017
+
1018
+ const feedback = readPlanFeedbackArgs(args);
1019
+ if (feedback.length === 0) {
1020
+ throw new Error('Plan feedback requires --text <message>, --correction <message>, or --file <feedback.json>.');
1021
+ }
1022
+ const merged = mergePlanFeedback(normalizePlanFeedback(task.planFeedback), feedback);
1023
+ saveTaskSnapshot(state, cid, {
1024
+ planFeedback: merged,
1025
+ // New feedback means the accepted AI candidate is no longer based on
1026
+ // the latest analysis and must not be reused accidentally.
1027
+ planAiOutlineCandidate: undefined,
1028
+ });
1029
+ const payload = {
1030
+ command: 'plan-feedback',
1031
+ cid,
1032
+ added: feedback,
1033
+ pendingFeedback: merged,
1034
+ };
1035
+ outputResult(args, payload, [
1036
+ `plan feedback saved locally: cid=${cid}`,
1037
+ `pending feedback: ${merged.length}`,
1038
+ 'feedback is not sent to the backend yet; it will be included only after plan confirm-outline is explicitly confirmed.',
1039
+ ]);
1040
+ return payload;
1041
+ }
1042
+
1043
+ async function handlePlanOutlineConfirmation(args, state) {
1044
+ const client = createAuthorizedClient(args, state);
1045
+ const cid = resolveCid(args, state);
1046
+ const snapshot = await hydrateTaskSnapshot(client, state, cid);
1047
+ const params = buildOutlineParams(snapshot, { ...args, planMode: 'planning' });
1048
+ const regenerating = booleanOption(args, 'regenerate') || normalizePlanModePhase(snapshot.plan_mode_phase) >= 1;
1049
+ const feedback = normalizePlanFeedback(state.tasks?.[cid]?.planFeedback);
1050
+
1051
+ await confirmPlanAction(args, regenerating ? 'regenerate outline' : 'confirm and generate outline', [
1052
+ `config: ${buildOutlineConfigSummary(params)}`,
1053
+ `pending feedback: ${feedback.length}`,
1054
+ ]);
1055
+
1056
+ await postJson(client, '/task/preSet', params);
1057
+ const phaseResponse = await setPlanModePhase(client, cid, 1);
1058
+ const response = await postJson(client, '/task/generateDirectoryByCid', compactObject({
1059
+ cid,
1060
+ chatList: feedback.length > 0
1061
+ ? feedback.map(item => ({ quote_paragraph: item.quote, user_comments: item.text }))
1062
+ : undefined,
1063
+ triggerType: 1,
1064
+ }));
1065
+ saveTaskSnapshot(state, cid, {
1066
+ ...pickTaskSnapshot(params),
1067
+ planModePhase: 1,
1068
+ planFeedback: [],
1069
+ planAiOutlineCandidate: undefined,
1070
+ planOutlineConfirmedAt: new Date().toISOString(),
1071
+ planOutlineRegeneratedAt: regenerating ? new Date().toISOString() : undefined,
1072
+ });
1073
+
1074
+ const waitResult = booleanOption(args, 'wait')
1075
+ ? await waitForOutlineReady(client, state, cid, snapshot.uuid, args)
1076
+ : null;
1077
+ const payload = {
1078
+ command: 'plan-confirm-outline',
1079
+ cid,
1080
+ regenerating,
1081
+ config: pickOutlineConfig(params),
1082
+ feedback,
1083
+ planModePhase: phaseResponse,
1084
+ response,
1085
+ waitResult,
1086
+ };
1087
+ outputResult(args, payload, [
1088
+ `plan outline ${regenerating ? 'regeneration' : 'generation'} started: cid=${cid}`,
1089
+ `phase: ${formatPlanModePhase(phaseResponse)}`,
1090
+ `feedback submitted: ${feedback.length}`,
1091
+ waitResult?.phase === 'ready'
1092
+ ? `outline ready: ${countOutlineRoots(waitResult)} root chapters; review or use plan outline-ai-adjust before generating the final directory.`
1093
+ : 'outline generation started; query plan status or rerun with --wait to review it when ready.',
1094
+ ]);
1095
+ return payload;
1096
+ }
1097
+
1098
+ async function handlePlanAiOutlineAdjust(args, state) {
1099
+ const client = createAuthorizedClient(args, state);
1100
+ const cid = resolveCid(args, state);
1101
+ const prompt = requiredOption(args, 'prompt').trim();
1102
+ if (!prompt) {
1103
+ throw new Error('Option --prompt cannot be empty.');
1104
+ }
1105
+ const task = state.tasks?.[cid] || {};
1106
+ const response = await postJson(client, '/outline/chat', compactObject({
1107
+ cid,
1108
+ prompt,
1109
+ conversationId: stringOption(args, 'conversationId', task.planAiConversationId),
1110
+ }));
1111
+ const result = response?.data?.result || response?.result || {};
1112
+ const conversationId = result.conversationId || response?.data?.conversationId || response?.conversationId || '';
1113
+ if (conversationId) {
1114
+ saveTaskSnapshot(state, cid, { planAiConversationId: conversationId });
1115
+ }
1116
+ const rows = extractPlanAiOutlineRows(result.answer);
1117
+ const candidate = rows.length > 0
1118
+ ? buildPlanAiOutlineCandidate(cid, prompt, conversationId, rows)
1119
+ : null;
1120
+ const savedPath = candidate && stringOption(args, 'out')
1121
+ ? writePlanAiOutlineFile(stringOption(args, 'out'), cid, candidate)
1122
+ : null;
1123
+ const payload = {
1124
+ command: 'plan-outline-ai-adjust',
1125
+ cid,
1126
+ prompt,
1127
+ conversationId: conversationId || null,
1128
+ responseType: result.type ?? null,
1129
+ message: rows.length === 0 ? normalizeText(result.answer) : null,
1130
+ candidate,
1131
+ savedPath,
1132
+ };
1133
+ outputResult(args, payload, [
1134
+ `plan AI response: cid=${cid}, type=${result.type ?? '-'}`,
1135
+ rows.length > 0
1136
+ ? `AI outline candidate: ${rows.length} chapter(s), not saved to the backend.`
1137
+ : `AI message: ${normalizeText(result.answer) || 'no outline candidate returned'}`,
1138
+ savedPath
1139
+ ? `candidate saved: ${savedPath}`
1140
+ : (rows.length > 0 ? 'hint: pass --out <file-or-directory> to save the candidate before applying it.' : ''),
1141
+ ].filter(Boolean));
1142
+ return payload;
1143
+ }
1144
+
1145
+ async function handlePlanAiOutlineApply(args, state) {
1146
+ const cid = resolveCid(args, state);
1147
+ const filePath = path.resolve(requiredOption(args, 'file'));
1148
+ const document = readOutlineEditFile(filePath);
1149
+ validateOutlineEditCid(document, cid, filePath);
1150
+ const rows = buildPlanAiOutlineUpdateRows(extractOutlineRows(document));
1151
+ if (booleanOption(args, 'dryRun')) {
1152
+ const payload = { command: 'plan-outline-ai-apply', cid, file: filePath, dryRun: true, applied: false, candidate: { cid, outLine: rows } };
1153
+ outputResult(args, payload, [
1154
+ `AI outline apply preview: cid=${cid}`,
1155
+ `chapters: ${rows.length}`,
1156
+ 'dry-run only: the candidate was not accepted locally or sent to the backend.',
1157
+ ]);
1158
+ return payload;
1159
+ }
1160
+ await confirmPlanAction(args, 'accept AI outline candidate', [
1161
+ `candidate file: ${filePath}`,
1162
+ `chapters: ${rows.length}`,
1163
+ 'the candidate remains local until the final-directory step is explicitly confirmed.',
1164
+ ]);
1165
+ saveTaskSnapshot(state, cid, {
1166
+ planAiOutlineCandidate: {
1167
+ format: 'xq-cli-plan-ai-outline@1',
1168
+ cid,
1169
+ acceptedAt: new Date().toISOString(),
1170
+ sourceFile: filePath,
1171
+ outLine: rows,
1172
+ },
1173
+ });
1174
+ const payload = { command: 'plan-outline-ai-apply', cid, file: filePath, dryRun: false, applied: true, candidate: { cid, outLine: rows } };
1175
+ outputResult(args, payload, [
1176
+ `AI outline candidate accepted locally: cid=${cid}`,
1177
+ 'the backend outline is unchanged. Review it, then run xq-cli plan directory-generate --cid <cid> --yes to save the candidate and generate the directory.',
1178
+ ]);
1179
+ return payload;
1180
+ }
1181
+
1182
+ async function handlePlanDirectoryGenerate(args, state, regenerating) {
1183
+ const client = createAuthorizedClient(args, state);
1184
+ const cid = resolveCid(args, state);
1185
+ const snapshot = await hydrateTaskSnapshot(client, state, cid);
1186
+ ensurePlanningTask(snapshot);
1187
+ const currentPhase = normalizePlanModePhase(snapshot.plan_mode_phase);
1188
+ if (regenerating && (currentPhase === null || currentPhase < 2)) {
1189
+ throw new Error('The task has not reached the directory phase yet. Use plan directory-generate for the first final-directory generation.');
1190
+ }
1191
+ if (!regenerating && currentPhase !== null && currentPhase >= 2) {
1192
+ throw new Error('This task has already entered the directory phase. Use plan directory-regenerate for an explicit replacement request.');
1193
+ }
1194
+ const outlineResponse = await fetchOutlineDetail(client, cid);
1195
+ const outlineDetail = outlineResponse?.data || {};
1196
+ const outlineSelection = resolvePlanDirectoryOutline(args, state, cid, outlineDetail);
1197
+ const rows = outlineSelection.rows;
1198
+ await confirmPlanAction(args, regenerating ? 'regenerate final directory' : 'generate final directory', [
1199
+ `outline chapters: ${rows.length}`,
1200
+ `outline source: ${outlineSelection.source}`,
1201
+ regenerating
1202
+ ? 'the existing directory can be replaced after the outline is saved.'
1203
+ : 'the outline will be saved and the backend will start final-directory generation.',
1204
+ ]);
1205
+
1206
+ if (!regenerating) {
1207
+ await postJson(client, '/task/checkPageScope', {
1208
+ cid,
1209
+ updateStatus: 1,
1210
+ outLine: rows,
1211
+ });
1212
+ }
1213
+ const outlineUpdate = await postJson(client, '/c/updateOutLine', {
1214
+ cid,
1215
+ outLine: rows,
1216
+ source: outlineSelection.source === 'AI candidate' ? 1 : 2,
1217
+ });
1218
+ const planModePhase = await setPlanModePhase(client, cid, 2);
1219
+ saveTaskSnapshot(state, cid, {
1220
+ planModePhase: 2,
1221
+ outlineUpdatedAt: new Date().toISOString(),
1222
+ directoryGenerationRequestedAt: new Date().toISOString(),
1223
+ directoryRegenerationRequestedAt: regenerating ? new Date().toISOString() : undefined,
1224
+ planAiOutlineCandidate: undefined,
1225
+ });
1226
+ const directoryWaitResult = booleanOption(args, 'wait')
1227
+ ? await ensureDirectoryReadyForWrite(client, state, cid, args)
1228
+ : null;
1229
+ const payload = {
1230
+ command: regenerating ? 'plan-directory-regenerate' : 'plan-directory-generate',
1231
+ cid,
1232
+ regenerating,
1233
+ outlineUpdate,
1234
+ planModePhase,
1235
+ directoryWaitResult,
1236
+ };
1237
+ outputResult(args, payload, [
1238
+ `final directory ${regenerating ? 'regeneration' : 'generation'} requested: cid=${cid}`,
1239
+ `phase: ${formatPlanModePhase(planModePhase)}`,
1240
+ directoryWaitResult?.phase === 'ready'
1241
+ ? 'directory ready for review; body generation has not started.'
1242
+ : 'the backend is generating the directory; query plan status or rerun with --wait.',
1243
+ ]);
1244
+ return payload;
1245
+ }
1246
+
766
1247
  async function handleOutline(args, state) {
767
1248
  validateOutlineReferenceTypeOption(args);
768
1249
  const client = createAuthorizedClient(args, state);
@@ -775,6 +1256,26 @@ async function handleOutline(args, state) {
775
1256
  const effectiveArgs = booleanOption(args, 'interactive')
776
1257
  ? await collectOutlineInteractiveArgs(args, outlineSnapshot)
777
1258
  : args;
1259
+ const periodResult = booleanOption(effectiveArgs, 'wait')
1260
+ ? await ensurePeriodReady(client, state, cid, effectiveArgs)
1261
+ : null;
1262
+ if (periodResult?.phase === 'confirmation_required') {
1263
+ const resultPayload = {
1264
+ command: 'outline',
1265
+ cid,
1266
+ uuid: outlineSnapshot.uuid,
1267
+ periodResult,
1268
+ waitResult: { phase: 'period_confirmation_required', periodResult },
1269
+ };
1270
+ outputResult(args, resultPayload, [
1271
+ `outline blocked: engineering period confirmation required, cid=${cid}`,
1272
+ `contract period: ${periodResult.contractPeriod ?? '-'}`,
1273
+ `start: ${periodResult.contractStartDate ?? '-'}`,
1274
+ `end: ${periodResult.contractEndDate ?? '-'}`,
1275
+ `hint: run xq-cli period-confirm --cid ${cid} --contract-period <days> --period-mode <1|2>, then rerun outline for the same cid.`,
1276
+ ]);
1277
+ return resultPayload;
1278
+ }
778
1279
  const params = buildOutlineParams(outlineSnapshot, effectiveArgs);
779
1280
 
780
1281
  const triggerMode = await triggerOutlineGeneration(client, outlineSnapshot, params);
@@ -825,6 +1326,7 @@ async function handleOutline(args, state) {
825
1326
  base: params.base,
826
1327
  config: pickOutlineConfig(params),
827
1328
  triggerMode,
1329
+ periodResult,
828
1330
  waitResult,
829
1331
  };
830
1332
  outputResult(args, resultPayload, [
@@ -933,6 +1435,7 @@ async function handleOutlineUpdate(args, state) {
933
1435
  const response = await postJson(client, '/c/updateOutLine', requestData);
934
1436
  saveTaskSnapshot(state, cid, {
935
1437
  outlineUpdatedAt: new Date().toISOString(),
1438
+ planAiOutlineCandidate: undefined,
936
1439
  });
937
1440
  const resultPayload = {
938
1441
  ...baseResult,
@@ -977,6 +1480,87 @@ async function handleDirectoryView(args, state) {
977
1480
  return resultPayload;
978
1481
  }
979
1482
 
1483
+ async function handleDirectoryPrepare(args, state) {
1484
+ const client = createAuthorizedClient(args, state);
1485
+ const cid = resolveCid(args, state);
1486
+ const directoryWaitResult = await ensureDirectoryReadyForWrite(client, state, cid, args);
1487
+ const payload = { command: 'directory-prepare', cid, directoryWaitResult };
1488
+ outputResult(args, payload, [
1489
+ `directory prepare: cid=${cid}`,
1490
+ `phase: ${directoryWaitResult.phase}`,
1491
+ `directory status: ${directoryWaitResult.contentDetail?.directory_portion_status ?? '-'}`,
1492
+ directoryWaitResult.phase === 'ready'
1493
+ ? 'directory ready for user review; body generation has not started.'
1494
+ : 'directory is not ready; continue querying the same task.',
1495
+ ]);
1496
+ return payload;
1497
+ }
1498
+
1499
+ async function handleDirectoryUpdate(args, state) {
1500
+ const client = createAuthorizedClient(args, state);
1501
+ const cid = resolveCid(args, state);
1502
+ const filePath = path.resolve(requiredOption(args, 'file'));
1503
+ ensureFileExists(filePath);
1504
+ let document;
1505
+ try {
1506
+ document = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
1507
+ } catch {
1508
+ throw new Error(`Failed to parse directory JSON: ${filePath}`);
1509
+ }
1510
+ const documentCid = document && !Array.isArray(document) ? String(document.cid || '').trim() : '';
1511
+ if (documentCid && documentCid !== cid) {
1512
+ throw new Error(`Directory file cid (${documentCid}) does not match --cid (${cid}): ${filePath}`);
1513
+ }
1514
+ const directories = Array.isArray(document)
1515
+ ? document
1516
+ : (Array.isArray(document?.directoryNodes) ? document.directoryNodes : null);
1517
+ if (!directories || directories.length === 0) {
1518
+ throw new Error('Directory JSON must be an array or contain a non-empty directoryNodes array.');
1519
+ }
1520
+ const requestPreview = { cid, directories };
1521
+ const resultPayload = {
1522
+ command: 'directory-update',
1523
+ cid,
1524
+ file: filePath,
1525
+ dryRun: booleanOption(args, 'dryRun'),
1526
+ applied: false,
1527
+ requestPreview,
1528
+ };
1529
+ if (booleanOption(args, 'dryRun')) {
1530
+ outputResult(args, resultPayload, [
1531
+ `directory update preview: cid=${cid}`,
1532
+ `roots: ${directories.length}`,
1533
+ 'dry-run only: no server data was changed.',
1534
+ 'hint: rerun with --yes to apply this update.',
1535
+ ]);
1536
+ return resultPayload;
1537
+ }
1538
+ const confirmed = booleanOption(args, 'yes') || await confirmDirectoryUpdate(args, cid, directories);
1539
+ if (!confirmed) {
1540
+ outputResult(args, { ...resultPayload, reason: 'cancelled' }, [`directory update cancelled: cid=${cid}`]);
1541
+ return { ...resultPayload, reason: 'cancelled' };
1542
+ }
1543
+ const response = await postJson(client, '/task/updateDirectory', requestPreview);
1544
+ const payload = { ...resultPayload, applied: true, response };
1545
+ saveTaskSnapshot(state, cid, { directoryUpdatedAt: new Date().toISOString() });
1546
+ outputResult(args, payload, [
1547
+ `directory update accepted: cid=${cid}`,
1548
+ 'note: directory changes may trigger asynchronous content regeneration.',
1549
+ `next: xq-cli directory view --cid ${cid}`,
1550
+ ]);
1551
+ return payload;
1552
+ }
1553
+
1554
+ async function confirmDirectoryUpdate(args, cid, directories) {
1555
+ if (!supportsInteractivePrompt()) {
1556
+ throw new Error('Directory update changes server data. Review it with --dry-run, then rerun with --yes.');
1557
+ }
1558
+ return runPromptSession(args, async context => {
1559
+ context.promptStream.write(`\nDirectory roots=${directories.length}\n`);
1560
+ return promptYesNo(context, `Apply this directory update to cid=${cid}`, false);
1561
+ });
1562
+ }
1563
+
980
1564
  async function confirmOutlineUpdate(args, cid, diff) {
981
1565
  if (!supportsInteractivePrompt()) {
982
1566
  throw new Error('Outline update changes server data. Review it with --dry-run, then rerun with --yes.');
@@ -1125,6 +1709,8 @@ async function handleStatus(args, state) {
1125
1709
  `status: cid=${cid}`,
1126
1710
  `title: ${detail.data.title || detail.data.projectName || '-'}`,
1127
1711
  `task_status: ${detail.data.task_status ?? '-'}`,
1712
+ `planMode: ${describeEnumValue(PLAN_MODE_OPTIONS, normalizePlanModeValue(detail.data.planMode) ?? detail.data.planMode)}`,
1713
+ `plan_mode_phase: ${formatPlanModePhase(detail.data.plan_mode_phase)}`,
1128
1714
  `viewStep: ${contentDetail?.data?.viewStep ?? detail.data.viewStep ?? '-'}`,
1129
1715
  `percentage: ${contentDetail?.data?.percentage ?? detail.data.percentage ?? '-'}`,
1130
1716
  ]);
@@ -1263,6 +1849,10 @@ function buildOutlineParams(snapshot, args) {
1263
1849
  async function triggerOutlineGeneration(client, snapshot, params) {
1264
1850
  await postJson(client, '/task/preSet', params);
1265
1851
 
1852
+ if (Number(params.planMode) === 1) {
1853
+ await setPlanModePhase(client, params.cid, 1);
1854
+ }
1855
+
1266
1856
  const hasTaskStatus = snapshot.task_status !== undefined && snapshot.task_status !== null;
1267
1857
  if (hasTaskStatus && Number(snapshot.task_status) !== 0) {
1268
1858
  await getJson(client, '/proxy/multiBiddingChoice', compactObject({
@@ -1277,6 +1867,241 @@ async function triggerOutlineGeneration(client, snapshot, params) {
1277
1867
  return 'generateByRequirement';
1278
1868
  }
1279
1869
 
1870
+ async function setPlanModePhase(client, cid, planModePhase) {
1871
+ const response = await postJson(client, '/task/setPlanModePhase', { cid, planModePhase });
1872
+ const returned = normalizePlanModePhase(response?.data?.planModePhase);
1873
+ return returned === null ? planModePhase : returned;
1874
+ }
1875
+
1876
+ function ensurePlanningTask(snapshot) {
1877
+ if (Number(normalizePlanModeValue(snapshot?.planMode)) !== 1) {
1878
+ throw new Error('This command requires a planning-mode task. Start the outline with --plan-mode planning first.');
1879
+ }
1880
+ }
1881
+
1882
+ async function confirmPlanAction(args, action, details = []) {
1883
+ if (booleanOption(args, 'yes')) {
1884
+ return true;
1885
+ }
1886
+ if (!canPromptForInput()) {
1887
+ throw new Error(`Plan action "${action}" changes the task or starts generation. Review the result, then rerun with --yes.`);
1888
+ }
1889
+ return runPromptSession(args, async context => {
1890
+ context.promptStream.write(`\nPlan action: ${action}\n`);
1891
+ details.filter(Boolean).forEach(detail => context.promptStream.write(` ${detail}\n`));
1892
+ return promptYesNo(context, `Continue with ${action}`, false);
1893
+ });
1894
+ }
1895
+
1896
+ function readPlanFeedbackArgs(args) {
1897
+ const entries = [];
1898
+ const quote = normalizePlanText(stringOption(args, 'quote'));
1899
+ const inputValues = [
1900
+ ...ensureArray(args.text),
1901
+ ...ensureArray(args.correction),
1902
+ ...ensureArray(args.feedback),
1903
+ ];
1904
+ inputValues.forEach(value => {
1905
+ const text = normalizePlanText(value);
1906
+ if (text) {
1907
+ entries.push({ quote, text });
1908
+ }
1909
+ });
1910
+
1911
+ const fileValue = stringOption(args, 'file');
1912
+ if (!fileValue) {
1913
+ return entries;
1914
+ }
1915
+ const filePath = path.resolve(fileValue);
1916
+ ensureFileExists(filePath);
1917
+ let parsed;
1918
+ try {
1919
+ parsed = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
1920
+ } catch {
1921
+ throw new Error(`Failed to parse plan feedback JSON: ${filePath}`);
1922
+ }
1923
+ const rows = Array.isArray(parsed)
1924
+ ? parsed
1925
+ : (parsed?.feedback || parsed?.corrections || parsed?.chatList || []);
1926
+ if (!Array.isArray(rows)) {
1927
+ throw new Error('Plan feedback JSON must be an array or contain feedback, corrections, or chatList.');
1928
+ }
1929
+ rows.forEach((row, index) => {
1930
+ if (typeof row === 'string') {
1931
+ const text = normalizePlanText(row);
1932
+ if (text) entries.push({ quote: '', text });
1933
+ return;
1934
+ }
1935
+ if (!row || typeof row !== 'object' || Array.isArray(row)) {
1936
+ throw new Error(`Plan feedback item ${index + 1} must be a string or JSON object.`);
1937
+ }
1938
+ const text = normalizePlanText(row.text ?? row.content ?? row.user_comments);
1939
+ if (!text) {
1940
+ throw new Error(`Plan feedback item ${index + 1} has no text/content/user_comments value.`);
1941
+ }
1942
+ entries.push({ quote: normalizePlanText(row.quote ?? row.quote_paragraph), text });
1943
+ });
1944
+ return entries;
1945
+ }
1946
+
1947
+ function normalizePlanFeedback(value) {
1948
+ if (!Array.isArray(value)) {
1949
+ return [];
1950
+ }
1951
+ return value
1952
+ .map(item => ({
1953
+ quote: normalizePlanText(item?.quote ?? item?.quote_paragraph),
1954
+ text: normalizePlanText(item?.text ?? item?.content ?? item?.user_comments),
1955
+ }))
1956
+ .filter(item => Boolean(item.text));
1957
+ }
1958
+
1959
+ function mergePlanFeedback(current, additions) {
1960
+ const seen = new Set();
1961
+ return [...current, ...additions].filter(item => {
1962
+ const normalized = {
1963
+ quote: normalizePlanText(item?.quote),
1964
+ text: normalizePlanText(item?.text),
1965
+ };
1966
+ if (!normalized.text) {
1967
+ return false;
1968
+ }
1969
+ const key = `${normalized.quote}\n${normalized.text}`;
1970
+ if (seen.has(key)) {
1971
+ return false;
1972
+ }
1973
+ seen.add(key);
1974
+ return true;
1975
+ }).map(item => ({
1976
+ quote: normalizePlanText(item.quote),
1977
+ text: normalizePlanText(item.text),
1978
+ }));
1979
+ }
1980
+
1981
+ function extractPlanAiOutlineRows(answer) {
1982
+ const candidates = [
1983
+ answer?.modifiedChapters,
1984
+ answer?.outline,
1985
+ answer?.outLine,
1986
+ Array.isArray(answer) ? answer : null,
1987
+ ];
1988
+ const rows = candidates.find(Array.isArray);
1989
+ return Array.isArray(rows) ? rows : [];
1990
+ }
1991
+
1992
+ function buildPlanAiOutlineCandidate(cid, prompt, conversationId, rows) {
1993
+ // The frontend's AI-apply path submits text/theme/important without IDs.
1994
+ // Let the backend assign IDs when it saves the accepted final outline;
1995
+ // synthetic local IDs would otherwise be mistaken for database node IDs.
1996
+ const candidateRows = buildPlanAiOutlineUpdateRows(rows);
1997
+ return {
1998
+ format: 'xq-cli-plan-ai-outline@1',
1999
+ cid,
2000
+ generatedAt: new Date().toISOString(),
2001
+ prompt,
2002
+ conversationId: conversationId || undefined,
2003
+ outLine: candidateRows,
2004
+ };
2005
+ }
2006
+
2007
+ function buildPlanAiOutlineUpdateRows(rows, options = {}) {
2008
+ if (!Array.isArray(rows) || rows.length === 0) {
2009
+ throw new Error('AI outline candidate must contain at least one chapter.');
2010
+ }
2011
+ return rows.map((row, index) => {
2012
+ if (!row || typeof row !== 'object' || Array.isArray(row)) {
2013
+ throw new Error(`AI outline chapter ${index + 1} must be a JSON object.`);
2014
+ }
2015
+ const text = normalizePlanText(row.text ?? row.title);
2016
+ if (!text) {
2017
+ throw new Error(`AI outline chapter ${index + 1} has an empty text field.`);
2018
+ }
2019
+ const theme = Array.isArray(row.theme)
2020
+ ? row.theme.map(normalizePlanText).filter(Boolean).join('\n')
2021
+ : normalizePlanText(row.theme ?? row.content);
2022
+ const important = normalizePlanImportant(row.important, index);
2023
+ const sequelFlag = normalizePlanSequelFlag(row.sequelFlag, index);
2024
+ const id = options.preserveIds ? normalizePlanText(row.id) : '';
2025
+ return compactObject({
2026
+ id: id || undefined,
2027
+ text,
2028
+ theme,
2029
+ updateFlag: Number(row.updateFlag) === 1 ? 1 : 0,
2030
+ important,
2031
+ sequelFlag,
2032
+ });
2033
+ });
2034
+ }
2035
+
2036
+ function buildPlanServerOutlineRows(rows) {
2037
+ return buildPlanAiOutlineUpdateRows(rows);
2038
+ }
2039
+
2040
+ function resolvePlanDirectoryOutline(args, state, cid, outlineDetail) {
2041
+ const explicitFile = stringOption(args, 'outlineFile');
2042
+ if (explicitFile) {
2043
+ const filePath = path.resolve(explicitFile);
2044
+ const document = readOutlineEditFile(filePath);
2045
+ validateOutlineEditCid(document, cid, filePath);
2046
+ return {
2047
+ source: 'outline file',
2048
+ rows: buildPlanAiOutlineUpdateRows(extractOutlineRows(document)),
2049
+ };
2050
+ }
2051
+
2052
+ const candidate = state.tasks?.[cid]?.planAiOutlineCandidate;
2053
+ if (candidate?.outLine) {
2054
+ return {
2055
+ source: 'AI candidate',
2056
+ rows: buildPlanAiOutlineUpdateRows(candidate.outLine),
2057
+ };
2058
+ }
2059
+
2060
+ if (!isOutlineReadyForCli(outlineDetail)) {
2061
+ throw new Error('The outline is not ready. Confirm and wait for the outline before generating the final directory.');
2062
+ }
2063
+ return {
2064
+ source: 'server outline',
2065
+ rows: buildPlanServerOutlineRows(getOutlineRows(outlineDetail)),
2066
+ };
2067
+ }
2068
+
2069
+ function normalizePlanImportant(value, index) {
2070
+ if (value === undefined || value === null || value === '') {
2071
+ return 2;
2072
+ }
2073
+ const parsed = Number(value);
2074
+ if (parsed !== 1 && parsed !== 2) {
2075
+ throw new Error(`Outline important for chapter ${index + 1} must be 1 or 2.`);
2076
+ }
2077
+ return parsed;
2078
+ }
2079
+
2080
+ function normalizePlanSequelFlag(value, index) {
2081
+ if (value === undefined || value === null || value === '') {
2082
+ return undefined;
2083
+ }
2084
+ const parsed = Number(value);
2085
+ if (parsed !== 0 && parsed !== 1) {
2086
+ throw new Error(`Outline sequelFlag for chapter ${index + 1} must be 0 or 1.`);
2087
+ }
2088
+ return parsed;
2089
+ }
2090
+
2091
+ function normalizePlanText(value) {
2092
+ return value === undefined || value === null ? '' : String(value).trim();
2093
+ }
2094
+
2095
+ function writePlanAiOutlineFile(outputOption, cid, document) {
2096
+ const resolvedOutput = path.resolve(outputOption);
2097
+ const outputIsDirectory = !path.extname(resolvedOutput);
2098
+ const fileName = `outline-ai-${normalizeDownloadFileName(cid) || 'task'}.json`;
2099
+ const finalPath = outputIsDirectory ? path.join(resolvedOutput, fileName) : resolvedOutput;
2100
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
2101
+ fs.writeFileSync(finalPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
2102
+ return finalPath;
2103
+ }
2104
+
1280
2105
  function buildFileMetadata(files, args) {
1281
2106
  const typeValues = ensureArray(args.type);
1282
2107
  const purposeValues = ensureArray(args.purpose);
@@ -1335,6 +2160,32 @@ async function pollParseUntilReady(client, state, cid, uuid, args) {
1335
2160
  });
1336
2161
  }
1337
2162
 
2163
+ async function ensurePeriodReady(client, state, cid, args) {
2164
+ return pollUntil(async () => {
2165
+ const response = await getJson(client, `/engineering/checkPeriod/${encodeURIComponent(cid)}`, {}, { retries: DEFAULT_RETRY_COUNT });
2166
+ const data = response?.data || {};
2167
+ saveTaskSnapshot(state, cid, {
2168
+ periodStatus: data.status,
2169
+ contractPeriod: data.contractPeriod,
2170
+ contractStartDate: data.contractStartDate,
2171
+ contractEndDate: data.contractEndDate,
2172
+ });
2173
+ const status = Number(data.status);
2174
+ if (status === 1) {
2175
+ return { done: true, data: { phase: 'ready', ...data }, progressLabel: 'period_status=1' };
2176
+ }
2177
+ if (status === 2) {
2178
+ return { done: true, data: { phase: 'confirmation_required', ...data }, progressLabel: 'period_status=2' };
2179
+ }
2180
+ return { done: false, data: null, progressLabel: `period_status=${data.status ?? '-'}` };
2181
+ }, {
2182
+ intervalMs: secondsToMs(numberOption(args, 'intervalSec', DEFAULT_TASK_INTERVAL_MS / 1000)),
2183
+ timeoutMs: secondsToMs(numberOption(args, 'timeoutSec', DEFAULT_TIMEOUT_MS / 1000)),
2184
+ quiet: booleanOption(args, 'json'),
2185
+ waitingText: 'waiting engineering period extraction',
2186
+ });
2187
+ }
2188
+
1338
2189
  function isParseReadyForNextStep(success) {
1339
2190
  const parsed = Number(success);
1340
2191
  return parsed === 1 || parsed === 4;
@@ -1371,6 +2222,7 @@ async function waitForOutlineReady(client, state, cid, uuid, args) {
1371
2222
 
1372
2223
  const outlineData = outlineDetail?.data || {};
1373
2224
  const hasOutline = isOutlineReadyForCli(outlineData);
2225
+ const planModePhase = normalizePlanModePhase(taskDetail.data?.plan_mode_phase);
1374
2226
  const referenceData = referenceStatus?.data || {};
1375
2227
  const referencePhase = normalizeReferenceStatus(referenceData.status);
1376
2228
  if (referencePhase !== null) {
@@ -1492,14 +2344,14 @@ async function waitForOutlineReady(client, state, cid, uuid, args) {
1492
2344
  return {
1493
2345
  done: false,
1494
2346
  data: null,
1495
- progressLabel: `success=${parseSuccess}, task_status=${taskDetail.data?.task_status ?? '-'}, directory_status=${taskDetail.data?.directory_portion_status ?? '-'}, outline_roots=${countOutlineRoots({ outlineDetail: outlineData })}, outline_status=${outlineData.outline_status ? 1 : 0}, reference_status=${referencePhase ?? '-'}`,
2347
+ progressLabel: `success=${parseSuccess}, task_status=${taskDetail.data?.task_status ?? '-'}, plan_mode_phase=${formatPlanModePhase(planModePhase)}, directory_status=${taskDetail.data?.directory_portion_status ?? '-'}, outline_roots=${countOutlineRoots({ outlineDetail: outlineData })}, outline_status=${outlineData.outline_status ? 1 : 0}, reference_status=${referencePhase ?? '-'}`,
1496
2348
  };
1497
2349
  }
1498
2350
 
1499
2351
  return {
1500
2352
  done: false,
1501
2353
  data: null,
1502
- progressLabel: `task_status=${taskDetail.data?.task_status ?? '-'}, directory_status=${taskDetail.data?.directory_portion_status ?? '-'}, outline_roots=${countOutlineRoots({ outlineDetail: outlineData })}, outline_status=${outlineData.outline_status ? 1 : 0}, reference_status=${referencePhase ?? '-'}`,
2354
+ progressLabel: `task_status=${taskDetail.data?.task_status ?? '-'}, plan_mode_phase=${formatPlanModePhase(planModePhase)}, directory_status=${taskDetail.data?.directory_portion_status ?? '-'}, outline_roots=${countOutlineRoots({ outlineDetail: outlineData })}, outline_status=${outlineData.outline_status ? 1 : 0}, reference_status=${referencePhase ?? '-'}`,
1503
2355
  };
1504
2356
  }, {
1505
2357
  intervalMs: secondsToMs(numberOption(args, 'intervalSec', DEFAULT_TASK_INTERVAL_MS / 1000)),
@@ -1958,6 +2810,30 @@ function normalizeReferenceStatus(value) {
1958
2810
  return Number.isNaN(parsed) ? null : parsed;
1959
2811
  }
1960
2812
 
2813
+ function normalizePlanModeValue(value) {
2814
+ if (value === undefined || value === null || value === '') {
2815
+ return null;
2816
+ }
2817
+ const parsed = Number(value);
2818
+ return Number.isNaN(parsed) ? String(value).trim() : parsed;
2819
+ }
2820
+
2821
+ function normalizePlanModePhase(value) {
2822
+ if (value === undefined || value === null || value === '') {
2823
+ return null;
2824
+ }
2825
+ const parsed = Number(value);
2826
+ return Number.isInteger(parsed) && parsed >= 0 && parsed <= 3 ? parsed : null;
2827
+ }
2828
+
2829
+ function formatPlanModePhase(value) {
2830
+ const normalized = normalizePlanModePhase(value);
2831
+ if (normalized === null) {
2832
+ return '-';
2833
+ }
2834
+ return `${normalized}:${PLAN_MODE_PHASE_LABELS[normalized] || normalized}`;
2835
+ }
2836
+
1961
2837
  function parseOutlineReferenceType(value) {
1962
2838
  if (value === undefined || value === null || value === '') {
1963
2839
  return null;
@@ -2777,13 +3653,17 @@ function collectIssueHints(issues, args) {
2777
3653
  }
2778
3654
 
2779
3655
  function saveLoginState(state, options) {
2780
- const username = options.result.data?.user?.username || options.name || '';
3656
+ const resultData = options.result?.data || {};
3657
+ const userInfo = options.user || resultData?.user || resultData || {};
3658
+ const username = userInfo.username || userInfo.userName || resultData?.userName || options.name || '';
2781
3659
  state.baseUrl = options.baseUrl;
2782
- state.token = options.result.data.token;
3660
+ state.authType = options.authType || (options.apiKey ? 'api-key' : 'token');
3661
+ state.apiKey = state.authType === 'api-key' ? String(options.apiKey || '') : '';
3662
+ state.token = state.authType === 'token' ? String(resultData.token || options.token || '') : '';
2783
3663
  state.user = {
2784
3664
  name: options.name || username,
2785
3665
  username,
2786
- userId: options.result.data?.user?.userId || '',
3666
+ userId: userInfo.userId || userInfo.id || resultData?.userId || '',
2787
3667
  sourceFrom: options.sourceFrom,
2788
3668
  loggedInAt: new Date().toISOString(),
2789
3669
  };
@@ -2968,13 +3848,15 @@ function createAuthorizedClient(args, state) {
2968
3848
  if (!baseUrl) {
2969
3849
  throw new Error('Missing base URL. Run login first or pass --base-url.');
2970
3850
  }
2971
- if (!state.token) {
2972
- throw new Error('Missing local token. Run login first.');
3851
+ const credential = resolveAuthCredential(args, state);
3852
+ if (!credential.value) {
3853
+ throw new Error('Missing local API key or token. Run login first.');
2973
3854
  }
2974
- return createClient(baseUrl, state.token);
3855
+ return createClient(baseUrl, credential.value);
2975
3856
  }
2976
3857
 
2977
3858
  function createClient(baseUrl, token = '') {
3859
+ const authorization = normalizeAuthorizationCredential(token);
2978
3860
  return axios.create({
2979
3861
  baseURL: baseUrl,
2980
3862
  timeout: 1_200_000,
@@ -2982,15 +3864,47 @@ function createClient(baseUrl, token = '') {
2982
3864
  maxContentLength: Infinity,
2983
3865
  httpAgent: HTTP_AGENT,
2984
3866
  httpsAgent: HTTPS_AGENT,
2985
- headers: token
3867
+ headers: authorization
2986
3868
  ? {
2987
- Authorization: token,
3869
+ Authorization: authorization,
2988
3870
  }
2989
3871
  : {},
2990
3872
  validateStatus: () => true,
2991
3873
  });
2992
3874
  }
2993
3875
 
3876
+ function resolveAuthCredential(args, state) {
3877
+ const envApiKey = normalizeApiKey(process.env.XQ_API_KEY);
3878
+ if (envApiKey) {
3879
+ return { value: envApiKey, type: 'api-key-env' };
3880
+ }
3881
+ const stateApiKey = normalizeApiKey(state?.apiKey);
3882
+ if (stateApiKey) {
3883
+ return { value: stateApiKey, type: 'api-key-config' };
3884
+ }
3885
+ const stateToken = normalizeApiKey(state?.token);
3886
+ if (stateToken) {
3887
+ return { value: stateToken, type: 'token' };
3888
+ }
3889
+ return { value: '', type: 'missing' };
3890
+ }
3891
+
3892
+ function normalizeAuthorizationCredential(value) {
3893
+ const normalized = String(value || '').trim();
3894
+ if (!normalized) {
3895
+ return '';
3896
+ }
3897
+ if (normalized.toLowerCase().startsWith('bearer ')) {
3898
+ return normalized;
3899
+ }
3900
+ return `Bearer ${normalized}`;
3901
+ }
3902
+
3903
+ function normalizeApiKey(value) {
3904
+ const normalized = String(value || '').trim();
3905
+ return normalized || '';
3906
+ }
3907
+
2994
3908
  async function getJson(client, url, params = {}, options = {}) {
2995
3909
  const response = await requestWithRetry(async () => {
2996
3910
  return client.get(url, { params });
@@ -3466,7 +4380,9 @@ function normalizeBaseUrl(value) {
3466
4380
  function createDefaultState() {
3467
4381
  return {
3468
4382
  baseUrl: DEFAULT_BASE_URL,
4383
+ apiKey: '',
3469
4384
  token: '',
4385
+ authType: '',
3470
4386
  user: {},
3471
4387
  lastCid: '',
3472
4388
  tasks: {},
@@ -3484,7 +4400,9 @@ function readState() {
3484
4400
  ...defaultState,
3485
4401
  ...parsed,
3486
4402
  baseUrl: normalizeBaseUrl(parsed?.baseUrl || defaultState.baseUrl),
4403
+ apiKey: normalizeApiKey(parsed?.apiKey),
3487
4404
  token: parsed?.token || '',
4405
+ authType: parsed?.authType || (parsed?.apiKey ? 'api-key' : parsed?.token ? 'token' : ''),
3488
4406
  user: parsed?.user && typeof parsed.user === 'object' ? parsed.user : {},
3489
4407
  lastCid: parsed?.lastCid || '',
3490
4408
  tasks: parsed?.tasks && typeof parsed.tasks === 'object' ? parsed.tasks : {},
@@ -3525,6 +4443,7 @@ function pickTaskSnapshot(source) {
3525
4443
  base: source.base,
3526
4444
  pageScopeCode: source.pageScopeCode,
3527
4445
  task_status: source.task_status,
4446
+ plan_mode_phase: source.plan_mode_phase,
3528
4447
  viewStep: source.viewStep,
3529
4448
  percentage: source.percentage,
3530
4449
  content_portion_status: source.content_portion_status,