openfeel 1.2.1 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.en.md +34 -1
  2. package/README.md +34 -1
  3. package/README.zh-CN.md +34 -1
  4. package/dist/cli/index.d.ts +17 -3
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +96 -8
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/cli/project-option.d.ts +40 -0
  9. package/dist/cli/project-option.d.ts.map +1 -0
  10. package/dist/cli/project-option.js +140 -0
  11. package/dist/cli/project-option.js.map +1 -0
  12. package/dist/commands/archive.d.ts.map +1 -1
  13. package/dist/commands/archive.js +45 -4
  14. package/dist/commands/archive.js.map +1 -1
  15. package/dist/commands/config.d.ts.map +1 -1
  16. package/dist/commands/config.js +103 -9
  17. package/dist/commands/config.js.map +1 -1
  18. package/dist/commands/flow.d.ts.map +1 -1
  19. package/dist/commands/flow.js +438 -125
  20. package/dist/commands/flow.js.map +1 -1
  21. package/dist/commands/instructions.d.ts.map +1 -1
  22. package/dist/commands/instructions.js +13 -3
  23. package/dist/commands/instructions.js.map +1 -1
  24. package/dist/commands/knowledge.d.ts.map +1 -1
  25. package/dist/commands/knowledge.js +113 -17
  26. package/dist/commands/knowledge.js.map +1 -1
  27. package/dist/commands/lint.d.ts.map +1 -1
  28. package/dist/commands/lint.js +78 -39
  29. package/dist/commands/lint.js.map +1 -1
  30. package/dist/commands/model.d.ts.map +1 -1
  31. package/dist/commands/model.js +86 -8
  32. package/dist/commands/model.js.map +1 -1
  33. package/dist/commands/plan.d.ts.map +1 -1
  34. package/dist/commands/plan.js +45 -7
  35. package/dist/commands/plan.js.map +1 -1
  36. package/dist/commands/project.d.ts +66 -0
  37. package/dist/commands/project.d.ts.map +1 -1
  38. package/dist/commands/project.js +122 -64
  39. package/dist/commands/project.js.map +1 -1
  40. package/dist/commands/req.d.ts.map +1 -1
  41. package/dist/commands/req.js +23 -11
  42. package/dist/commands/req.js.map +1 -1
  43. package/dist/commands/roadmap.d.ts.map +1 -1
  44. package/dist/commands/roadmap.js +21 -2
  45. package/dist/commands/roadmap.js.map +1 -1
  46. package/dist/commands/setup.d.ts.map +1 -1
  47. package/dist/commands/setup.js +11 -3
  48. package/dist/commands/setup.js.map +1 -1
  49. package/dist/commands/shared/errors.d.ts +55 -4
  50. package/dist/commands/shared/errors.d.ts.map +1 -1
  51. package/dist/commands/shared/errors.js +108 -10
  52. package/dist/commands/shared/errors.js.map +1 -1
  53. package/dist/commands/shared/json-output.d.ts +60 -0
  54. package/dist/commands/shared/json-output.d.ts.map +1 -0
  55. package/dist/commands/shared/json-output.js +152 -0
  56. package/dist/commands/shared/json-output.js.map +1 -0
  57. package/dist/commands/stage.d.ts.map +1 -1
  58. package/dist/commands/stage.js +62 -6
  59. package/dist/commands/stage.js.map +1 -1
  60. package/dist/commands/view.d.ts.map +1 -1
  61. package/dist/commands/view.js +27 -0
  62. package/dist/commands/view.js.map +1 -1
  63. package/dist/core/archive/merge.d.ts +16 -1
  64. package/dist/core/archive/merge.d.ts.map +1 -1
  65. package/dist/core/archive/merge.js +27 -4
  66. package/dist/core/archive/merge.js.map +1 -1
  67. package/dist/core/config.js +2 -2
  68. package/dist/core/errors.d.ts +105 -0
  69. package/dist/core/errors.d.ts.map +1 -0
  70. package/dist/core/errors.js +61 -0
  71. package/dist/core/errors.js.map +1 -0
  72. package/dist/core/flow-manager.d.ts +13 -0
  73. package/dist/core/flow-manager.d.ts.map +1 -1
  74. package/dist/core/flow-manager.js +22 -0
  75. package/dist/core/flow-manager.js.map +1 -1
  76. package/dist/core/fs/file-lock.d.ts +9 -0
  77. package/dist/core/fs/file-lock.d.ts.map +1 -1
  78. package/dist/core/fs/file-lock.js +27 -1
  79. package/dist/core/fs/file-lock.js.map +1 -1
  80. package/dist/core/i18n-data/en.d.ts.map +1 -1
  81. package/dist/core/i18n-data/en.js +53 -3
  82. package/dist/core/i18n-data/en.js.map +1 -1
  83. package/dist/core/i18n-data/zh-CN.d.ts.map +1 -1
  84. package/dist/core/i18n-data/zh-CN.js +53 -3
  85. package/dist/core/i18n-data/zh-CN.js.map +1 -1
  86. package/dist/core/pipeline-schema.d.ts +7 -0
  87. package/dist/core/pipeline-schema.d.ts.map +1 -1
  88. package/dist/core/pipeline-schema.js +7 -0
  89. package/dist/core/pipeline-schema.js.map +1 -1
  90. package/dist/core/plan/roadmap.d.ts +29 -0
  91. package/dist/core/plan/roadmap.d.ts.map +1 -1
  92. package/dist/core/plan/roadmap.js +95 -0
  93. package/dist/core/plan/roadmap.js.map +1 -1
  94. package/dist/core/req/aggregate.d.ts +19 -1
  95. package/dist/core/req/aggregate.d.ts.map +1 -1
  96. package/dist/core/req/aggregate.js +18 -5
  97. package/dist/core/req/aggregate.js.map +1 -1
  98. package/dist/core/template-loader.d.ts.map +1 -1
  99. package/dist/core/template-loader.js +48 -14
  100. package/dist/core/template-loader.js.map +1 -1
  101. package/dist/core/update.d.ts.map +1 -1
  102. package/dist/core/update.js +44 -10
  103. package/dist/core/update.js.map +1 -1
  104. package/package.json +1 -1
@@ -1,15 +1,16 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { existsSync, copyFileSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
- import { FlowManager, isFlowConcurrentError, normalizeAgentName, detectFillState, MODE_DERIVED_TIER, PHASE_PATH_MAX_DEPTH } from '../core/flow-manager.js';
5
- import { PipelinePhaseSchema, PIPELINE_PHASES, REVIEW_TIERS, TIER_PRUNED_PHASES } from '../core/pipeline-schema.js';
4
+ import { FlowManager, isFlowConcurrentError, normalizeAgentName, detectFillState, MODE_DERIVED_TIER, listStageBlockingOpenReviews, PHASE_PATH_MAX_DEPTH } from '../core/flow-manager.js';
5
+ import { PipelinePhaseSchema, PIPELINE_PHASES, REVIEW_TIERS, TIER_PRUNED_PHASES, DEFAULT_EFFECTIVE_REVIEW_TIER } from '../core/pipeline-schema.js';
6
6
  import { validateStageId, suggestStageId, normalizeStageId, findStageStatusPath, isArchivedVersionOf } from '../core/plan/path.js';
7
7
  import { getConfigFieldLegalValues, setConfigValue } from '../core/config.js';
8
8
  import { parse as parseYaml } from 'yaml';
9
9
  import { MetricsStore, PHASE_AGENT_MAP } from '../core/metrics.js';
10
10
  import { runtimeLog } from '../core/runtime-log.js';
11
11
  import { addReviewEntry } from '../core/view/entry.js';
12
- import { handleAddStageError, handleConcurrentConflict } from './shared/errors.js';
12
+ import { failCli, failInternal, failRefusal, handleAddStageError, handleConcurrentConflict } from './shared/errors.js';
13
+ import { withSchemaVersion, isJsonMode, emitJson } from './shared/json-output.js'; // JSON 契约助手(stage-95 op-001/op-002)
13
14
  import { t, getCliLang } from '../core/i18n.js';
14
15
  import { ARCHIVE_SCHEMA_VERSION } from '../core/flow-archive/archive-file.js';
15
16
  import { listArchivableCandidates, listBlockingOpenRevsForVersion, splitForArchive } from '../core/flow-archive/grouping.js';
@@ -142,6 +143,109 @@ export function registerFlowCommand(program) {
142
143
  }
143
144
  }
144
145
  });
146
+ // flow next — 当前阶段调度决策(只读;P0-1)
147
+ flow
148
+ .command('next')
149
+ .description('显示当前阶段的调度决策(负责 Agent / 合法下一相位 / 建议命令 / 守卫 / 阻塞项;只读)')
150
+ .option('--json', 'Output as JSON (machine-readable, single JSON document)')
151
+ .action(() => {
152
+ const lang = getCliLang(process.cwd());
153
+ const mgr = createManager();
154
+ if (!mgr.isLoaded()) {
155
+ const message = t('common.errorNoInit', lang);
156
+ failCli({ code: 'NO_INIT', message, detail: null, stderrLines: [message] });
157
+ return;
158
+ }
159
+ const data = mgr.getData();
160
+ const rawStage = data.pipeline.current.stage || '';
161
+ // 双键回退(与 core resolveCurrentPhase / advanceStagePhase 同范式):归一化键优先,其次原始键
162
+ const normalized = rawStage ? (normalizeStageId(rawStage) ?? rawStage) : '';
163
+ const activeKey = rawStage
164
+ ? (data.stages[normalized] ? normalized : (data.stages[rawStage] ? rawStage : null))
165
+ : null;
166
+ let phase = null;
167
+ let phaseLabel = null;
168
+ let responsibleAgent = 'feel'; // 无活跃阶段显式约定
169
+ let workflowMode = null;
170
+ let reviewTier = null;
171
+ let effectiveReviewTier = null;
172
+ let legalNextPhases = [];
173
+ let suggestedCommand = null;
174
+ const guards = [];
175
+ let blockingReviews = [];
176
+ if (activeKey) {
177
+ const currentPhase = data.stages[activeKey].phase;
178
+ phase = currentPhase;
179
+ phaseLabel = mgr.getPhaseLabels(lang)[currentPhase] ?? currentPhase;
180
+ responsibleAgent = mgr.getResponsibleAgent(currentPhase);
181
+ workflowMode = mgr.resolveStageWorkflowMode(activeKey).value;
182
+ reviewTier = MODE_DERIVED_TIER[workflowMode];
183
+ effectiveReviewTier = DEFAULT_EFFECTIVE_REVIEW_TIER;
184
+ legalNextPhases = mgr.getAvailablePhases(activeKey);
185
+ suggestedCommand = legalNextPhases.length > 0
186
+ ? `openfeel flow advance --stage ${activeKey} --to ${legalNextPhases[0]}`
187
+ : null;
188
+ blockingReviews = listStageBlockingOpenReviews(data.reviews, activeKey).map((r) => ({
189
+ id: r.id, title: r.title, priority: r.priority, op: r.op,
190
+ }));
191
+ // 守卫顺序契约固定(§12.1):auto-advance → blocking → 无目标 → 档位差
192
+ if (mgr.resolveEffectiveConfig()['auto_advance']?.value === 'disabled') {
193
+ guards.push(t('flow.next.guardAutoAdvanceDisabled', lang));
194
+ }
195
+ if (blockingReviews.length > 0) {
196
+ guards.push(t('flow.next.guardBlockingReviewsTmpl', lang, { n: String(blockingReviews.length) }));
197
+ }
198
+ if (legalNextPhases.length === 0) {
199
+ guards.push(t('flow.next.guardNoLegalTargets', lang));
200
+ }
201
+ if (reviewTier !== effectiveReviewTier) {
202
+ guards.push(t('flow.next.guardTierDiffTmpl', lang, { display: reviewTier, effective: effectiveReviewTier }));
203
+ }
204
+ }
205
+ else {
206
+ guards.push(t('flow.next.guardNoActiveStage', lang));
207
+ }
208
+ const doc = withSchemaVersion({
209
+ stage: activeKey ?? (rawStage || null),
210
+ phase,
211
+ phaseLabel,
212
+ responsibleAgent,
213
+ workflowMode,
214
+ reviewTier,
215
+ effectiveReviewTier,
216
+ legalNextPhases,
217
+ suggestedCommand,
218
+ guards,
219
+ blockingReviews,
220
+ });
221
+ // JSON 模式单点(stage-95 不变量 2):命令层只读 isJsonMode()
222
+ if (isJsonMode()) {
223
+ emitJson(doc);
224
+ return;
225
+ }
226
+ // 人类输出(逐行标签;空值统一 t('common.none', lang))
227
+ console.log(t('flow.next.title', lang));
228
+ console.log(` ${t('flow.next.stageLabel', lang)}: ${doc.stage ?? t('common.none', lang)}`);
229
+ console.log(` ${t('flow.next.phaseLabel', lang)}: ${doc.phase ?? t('common.none', lang)}${doc.phaseLabel ? ` (${doc.phaseLabel})` : ''}`);
230
+ console.log(` ${t('flow.next.responsibleAgentLabel', lang)}: ${doc.responsibleAgent}`);
231
+ console.log(` ${t('flow.next.workflowModeLabel', lang)}: ${doc.workflowMode ?? t('common.none', lang)}`);
232
+ console.log(` ${t('flow.next.displayTierLabel', lang)}: ${doc.reviewTier ?? t('common.none', lang)}`);
233
+ console.log(` ${t('flow.next.effectiveTierLabel', lang)}: ${doc.effectiveReviewTier ?? t('common.none', lang)}`);
234
+ console.log(` ${t('flow.next.legalNextPhasesLabel', lang)}: ${doc.legalNextPhases.length > 0 ? `[${doc.legalNextPhases.join(', ')}]` : t('common.none', lang)}`);
235
+ console.log(` ${t('flow.next.suggestedCommandLabel', lang)}: ${doc.suggestedCommand ?? t('common.none', lang)}`);
236
+ if (doc.guards.length > 0) {
237
+ console.log(` ${t('flow.next.guardsLabel', lang)}:`);
238
+ for (const g of doc.guards) {
239
+ console.log(` - ${g}`);
240
+ }
241
+ }
242
+ if (doc.blockingReviews.length > 0) {
243
+ console.log(` ${t('flow.next.blockingReviewsLabel', lang)}:`);
244
+ for (const r of doc.blockingReviews) {
245
+ console.log(` - ${t('flow.next.blockingReviewItemTmpl', lang, { id: r.id, title: r.title, priority: r.priority, op: r.op })}`);
246
+ }
247
+ }
248
+ });
145
249
  // flow overview — 全状态可视化(openfeel flow overview 的后端实现)
146
250
  flow
147
251
  .command('overview')
@@ -360,8 +464,13 @@ export function registerFlowCommand(program) {
360
464
  if (options.mode !== undefined) {
361
465
  const allowed = getConfigFieldLegalValues('workflow_mode') ?? [];
362
466
  if (!allowed.includes(options.mode)) {
363
- console.error(t('stage.set.invalidValueTmpl', lang, { field: '工作流模式', value: options.mode, allowed: allowed.join(', ') }));
364
- process.exit(1);
467
+ const message = t('stage.set.invalidValueTmpl', lang, { field: '工作流模式', value: options.mode, allowed: allowed.join(', ') });
468
+ failCli({
469
+ code: 'INVALID_ARGUMENT',
470
+ message,
471
+ detail: { field: 'workflowMode', value: options.mode, allowed },
472
+ stderrLines: [message],
473
+ });
365
474
  return;
366
475
  }
367
476
  explicitMode = options.mode;
@@ -371,17 +480,15 @@ export function registerFlowCommand(program) {
371
480
  if (options.stage !== undefined) {
372
481
  const v = validateStageId(options.stage);
373
482
  if (!v.ok) {
374
- console.error(t('common.stageIdInvalidTmpl', lang, { input: options.stage }));
375
- console.error(t('common.stageIdSuggestTmpl', lang, { suggest: suggestStageId(projectPath, options.stage) }));
376
- process.exit(1);
483
+ const stderrLines = stageIdErrorLines(lang, projectPath, options.stage);
484
+ failCli({ code: 'INVALID_ARGUMENT', message: stderrLines[0], detail: { stage: options.stage }, stderrLines });
377
485
  return;
378
486
  }
379
487
  stageArg = normalizeStageId(options.stage) ?? options.stage;
380
488
  const known = new Set(Object.keys(mgr.isLoaded() ? mgr.getData().stages : {}).map((k) => normalizeStageId(k) ?? k));
381
489
  if (!known.has(stageArg) && !findStageStatusPath(projectPath, stageArg)) {
382
- console.error(t('common.stageIdInvalidTmpl', lang, { input: options.stage }));
383
- console.error(t('common.stageIdSuggestTmpl', lang, { suggest: suggestStageId(projectPath, options.stage) }));
384
- process.exit(1);
490
+ const stderrLines = stageIdErrorLines(lang, projectPath, options.stage);
491
+ failCli({ code: 'STAGE_NOT_FOUND', message: stderrLines[0], detail: { stage: options.stage }, stderrLines });
385
492
  return;
386
493
  }
387
494
  }
@@ -401,7 +508,9 @@ export function registerFlowCommand(program) {
401
508
  // advanceAccepted = 内置 15 phase(flow advance 的推进白名单),与 phases(运行时存在视图,可含自定义)显式区分
402
509
  // 既有三字段(phases/transitions/advanceAccepted)+ transitionsDiff 逐字保留;schemaVersion 与 workflowMode 追加于末位(B1-6 / stage-75 op-002)
403
510
  // reviewTier 再追加于末位(stage-92 op-001;既有 6 键逐字保留、顺序不变)
404
- console.log(JSON.stringify({ schemaVersion: 1, phases, transitions, advanceAccepted: [...PIPELINE_PHASES], transitionsDiff, workflowMode: derivedMode, reviewTier: MODE_DERIVED_TIER[derivedMode] }, null, 2));
511
+ // stage-96 op-002:末位再追加 displayReviewTier(与 reviewTier 同值显式名)与 effectiveReviewTier(推进缺省档位,恒 full)
512
+ const displayTier = MODE_DERIVED_TIER[derivedMode];
513
+ console.log(JSON.stringify({ schemaVersion: 1, phases, transitions, advanceAccepted: [...PIPELINE_PHASES], transitionsDiff, workflowMode: derivedMode, reviewTier: displayTier, displayReviewTier: displayTier, effectiveReviewTier: DEFAULT_EFFECTIVE_REVIEW_TIER }, null, 2));
405
514
  return;
406
515
  }
407
516
  console.log(t('flow.phases.title', lang));
@@ -456,18 +565,16 @@ export function registerFlowCommand(program) {
456
565
  if (options.stage !== undefined) {
457
566
  const v = validateStageId(options.stage);
458
567
  if (!v.ok) {
459
- console.error(t('common.stageIdInvalidTmpl', lang, { input: options.stage }));
460
- console.error(t('common.stageIdSuggestTmpl', lang, { suggest: suggestStageId(projectPath, options.stage) }));
461
- process.exit(1);
568
+ const stderrLines = stageIdErrorLines(lang, projectPath, options.stage);
569
+ failCli({ code: 'INVALID_ARGUMENT', message: stderrLines[0], detail: { stage: options.stage }, stderrLines });
462
570
  return;
463
571
  }
464
572
  key = normalizeStageId(options.stage) ?? options.stage;
465
573
  // 存在性:已注册(flow.json)或有 status.md;二者皆无 → exit 1(防 typo 静默落 config 层)
466
574
  const known = new Set(Object.keys(mgr.isLoaded() ? mgr.getData().stages : {}).map((k) => normalizeStageId(k) ?? k));
467
575
  if (!known.has(key) && !findStageStatusPath(projectPath, key)) {
468
- console.error(t('common.stageIdInvalidTmpl', lang, { input: options.stage }));
469
- console.error(t('common.stageIdSuggestTmpl', lang, { suggest: suggestStageId(projectPath, options.stage) }));
470
- process.exit(1);
576
+ const stderrLines = stageIdErrorLines(lang, projectPath, options.stage);
577
+ failCli({ code: 'STAGE_NOT_FOUND', message: stderrLines[0], detail: { stage: options.stage }, stderrLines });
471
578
  return;
472
579
  }
473
580
  }
@@ -599,9 +706,10 @@ export function registerFlowCommand(program) {
599
706
  console.log(t('flow.stage.remove.okTmpl', lang, { stage: stageArg }));
600
707
  }
601
708
  catch (err) {
602
- // 并发写冲突:统一单点(i18n 文案 + 退出码 2);其余走通用错误
709
+ // 并发写冲突:统一单点(i18n 文案 + 退出码 2);JSON 模式置码返回 → 显式 return 防落入通用分支
603
710
  if (isFlowConcurrentError(err)) {
604
711
  handleConcurrentConflict(err, lang);
712
+ return;
605
713
  }
606
714
  const msg = err instanceof Error ? err.message : String(err);
607
715
  console.error(t('common.errorTmpl', lang, { msg }));
@@ -658,8 +766,10 @@ export function registerFlowCommand(program) {
658
766
  mgr.setStageDeps(stageId, deps);
659
767
  }
660
768
  catch (err) {
769
+ // 并发写冲突:统一单点(i18n 文案 + 退出码 2);JSON 模式置码返回 → 显式 return 防落入通用分支
661
770
  if (isFlowConcurrentError(err)) {
662
771
  handleConcurrentConflict(err, lang);
772
+ return;
663
773
  }
664
774
  const msg = err instanceof Error ? err.message : String(err);
665
775
  console.error(t('common.errorTmpl', lang, { msg }));
@@ -710,9 +820,10 @@ export function registerFlowCommand(program) {
710
820
  }));
711
821
  }
712
822
  catch (err) {
713
- // 并发写冲突:统一单点(i18n 文案 + 退出码 2);其余走通用错误
823
+ // 并发写冲突:统一单点(i18n 文案 + 退出码 2);JSON 模式置码返回 → 显式 return 防落入通用分支
714
824
  if (isFlowConcurrentError(err)) {
715
825
  handleConcurrentConflict(err, lang);
826
+ return;
716
827
  }
717
828
  const msg = err instanceof Error ? err.message : String(err);
718
829
  console.error(t('common.errorTmpl', lang, { msg }));
@@ -728,32 +839,49 @@ export function registerFlowCommand(program) {
728
839
  .option('--stage <id>', '阶段 ID(如 stage-03),必须指定')
729
840
  .option('--force', '强制执行(跳过非法 phase 校验和阶段跳跃检查,但不可绕过 REV 阻塞检查)')
730
841
  .option('--dry-run', '仅验证不执行修改(预览输出)。与 --force 组合时跳过校验但仍不执行修改')
731
- .option('--review-tier <tier>', '审查档位(full / lean / skip;缺省=按模式派生;仅显式声明时裁剪审查关,不可绕过 REV 阻塞检查)')
842
+ .option('--review-tier <tier>', '审查档位(full / lean / skip;缺省=full(零裁剪);仅显式声明时裁剪审查关,不可绕过 REV 阻塞检查)')
843
+ .option('--json', '以 JSON 输出错误封装(仅失败时;成功输出保持人类文本)')
732
844
  .option('--quiet', '静默非错误输出(成功确认与 Git 警告均不打印)')
733
845
  .action((options) => {
734
846
  const lang = getCliLang(process.cwd());
847
+ // JSON 模式(--json 为错误封装开关)下,失败前的人类进展行改走 stderr,保证 stdout 仅一个 envelope;
848
+ // 成功路径的人类输出保持 console.log(advance 成功不 JSON 化,契约 §7/§8)。
849
+ const info = (line) => {
850
+ if (isJsonMode()) {
851
+ console.error(line);
852
+ return;
853
+ }
854
+ console.log(line);
855
+ };
735
856
  // 自定义 --stage 必选校验(提供中文错误提示)
736
857
  if (!options.stage) {
737
- console.error(t('flow.advance.errorNoStage', lang));
738
- process.exit(1);
858
+ const message = t('flow.advance.errorNoStage', lang);
859
+ failCli({ code: 'INVALID_ARGUMENT', message, detail: { required: '--stage' }, stderrLines: [message] });
860
+ return;
739
861
  }
740
862
  // 审查档位值域校验(stage-92 op-001;fail-fast 且**先于任何取值 / 路径解析 / autoRepair 写盘**)
741
863
  // 复用 stage.set.invalidValueTmpl(与 flow phases --mode 同款,零新增键);非法 → exit 1 零写盘
742
864
  let explicitTier;
743
865
  if (options.reviewTier !== undefined) {
744
866
  if (!REVIEW_TIERS.includes(options.reviewTier)) {
745
- console.error(t('stage.set.invalidValueTmpl', lang, { field: '审查档位', value: options.reviewTier, allowed: REVIEW_TIERS.join(', ') }));
746
- process.exit(1);
867
+ const message = t('stage.set.invalidValueTmpl', lang, { field: '审查档位', value: options.reviewTier, allowed: REVIEW_TIERS.join(', ') });
868
+ failCli({
869
+ code: 'INVALID_ARGUMENT',
870
+ message,
871
+ detail: { field: 'reviewTier', value: options.reviewTier, allowed: REVIEW_TIERS },
872
+ stderrLines: [message],
873
+ });
747
874
  return;
748
875
  }
749
876
  explicitTier = options.reviewTier;
750
877
  }
751
- // 生效档位:显式声明 ?? full(**不传 = 零裁剪**;派生档仅用于 `flow phases` 展示与提示,不改变推进语义)
752
- const effTier = explicitTier ?? 'full';
878
+ // 生效档位:显式声明 ?? DEFAULT_EFFECTIVE_REVIEW_TIER(**不传 = 零裁剪**;派生档仅用于 `flow phases` 展示与提示,不改变推进语义)
879
+ const effTier = explicitTier ?? DEFAULT_EFFECTIVE_REVIEW_TIER;
753
880
  const mgr = createManager();
754
881
  if (!mgr.isLoaded()) {
755
- console.error(t('common.errorNoInit', lang));
756
- process.exit(1);
882
+ const message = t('common.errorNoInit', lang);
883
+ failCli({ code: 'NO_INIT', message, detail: null, stderrLines: [message] });
884
+ return;
757
885
  }
758
886
  // REV-005:命令层归一化(短名 → 全名),与 plan.ts / flow.ts 其它 stage 解析点同范式
759
887
  const stageArg = normalizeStageId(options.stage) ?? options.stage;
@@ -762,7 +890,8 @@ export function registerFlowCommand(program) {
762
890
  // B1 修复:dry-run 走预览模式(不写内存/不写盘),仅在非 dry-run 时 save()
763
891
  const repairResult = mgr.autoRepairInconsistency(stageArg, { dryRun: options.dryRun });
764
892
  if (repairResult.fixed) {
765
- console.log(options.dryRun
893
+ // JSON 模式下改走 stderr(失败时 stdout 仅保留一个 envelope)
894
+ info(options.dryRun
766
895
  ? t('flow.advance.autoRepairPreview', lang) + `: ${repairResult.detail}`
767
896
  : t('flow.advance.autoRepaired', lang) + `: ${repairResult.detail}`);
768
897
  if (!options.dryRun) {
@@ -776,23 +905,35 @@ export function registerFlowCommand(program) {
776
905
  for (const w of warnings) {
777
906
  console.warn(`[WARN] ${w}`);
778
907
  }
779
- console.log(t('flow.advance.warnAutoCorrect', lang));
908
+ // JSON 模式下改走 stderr(失败时 stdout 仅保留一个 envelope)
909
+ info(t('flow.advance.warnAutoCorrect', lang));
780
910
  }
781
911
  if (!valid) {
782
- console.error(t('flow.advance.errorInvalidFormat', lang));
783
- for (const err of errors) {
784
- console.error(` - ${err}`);
785
- }
786
- process.exit(1);
912
+ const message = t('flow.advance.errorInvalidFormat', lang);
913
+ failCli({
914
+ code: 'INVALID_ARGUMENT',
915
+ message,
916
+ detail: { errors },
917
+ stderrLines: [message, ...errors.map((e) => ' - ' + e)],
918
+ });
919
+ return;
787
920
  }
788
921
  // 非法 phase 校验(P1):拒绝不在枚举中的目标 phase;--force 可跳过此项,但不可绕过 REV 阻塞检查
789
922
  if (!options.force) {
790
923
  const phaseResult = PipelinePhaseSchema.safeParse(options.to);
791
924
  if (!phaseResult.success) {
792
- console.error(t('flow.advance.errorInvalidPhaseTmpl', lang, { phase: options.to }));
793
- console.error(t('flow.advance.labelValidPhases', lang) + `: [${PIPELINE_PHASES.join(', ')}]`);
794
- console.error(t('flow.advance.hintUseForceFuzzy', lang));
795
- process.exit(1);
925
+ const message = t('flow.advance.errorInvalidPhaseTmpl', lang, { phase: options.to });
926
+ failCli({
927
+ code: 'PHASE_TRANSITION_ILLEGAL',
928
+ message,
929
+ detail: { stage: stageArg, to: options.to, allowed: [...PIPELINE_PHASES] },
930
+ stderrLines: [
931
+ message,
932
+ t('flow.advance.labelValidPhases', lang) + `: [${PIPELINE_PHASES.join(', ')}]`,
933
+ t('flow.advance.hintUseForceFuzzy', lang),
934
+ ],
935
+ });
936
+ return;
796
937
  }
797
938
  }
798
939
  // 阶段路径解析(B5-1):非 --force 时求「当前 phase → to」的唯一可达路径
@@ -806,36 +947,47 @@ export function registerFlowCommand(program) {
806
947
  const data = mgr.getData();
807
948
  const currentPhase = data?.stages[stageArg]?.phase ?? t('common.unknown', lang);
808
949
  const availableTargets = mgr.getAvailablePhases(stageArg, effTier);
950
+ const stderrLines = [];
809
951
  if (pathResult.reason === 'ambiguous') {
810
- console.error(t('flow.advance.ambiguousTmpl', lang));
952
+ stderrLines.push(t('flow.advance.ambiguousTmpl', lang));
811
953
  }
812
954
  else if (pathResult.reason === 'depth-exceeded') {
813
- console.error(t('flow.advance.depthExceededTmpl', lang, { max: String(PHASE_PATH_MAX_DEPTH) }));
955
+ stderrLines.push(t('flow.advance.depthExceededTmpl', lang, { max: String(PHASE_PATH_MAX_DEPTH) }));
814
956
  }
815
957
  else {
816
958
  // general(无流水线)→ 显式错误引导(防御路径:常规写路径不产生 general 阶段态;REV-90-005 / D-90-3)
817
959
  if (mgr.resolveStageWorkflowMode(stageArg).value === 'general') {
818
- console.error(t('flow.advance.generalNoPipeline', lang, { stage: stageArg }));
819
- process.exit(1);
960
+ const message = t('flow.advance.generalNoPipeline', lang, { stage: stageArg });
961
+ failCli({
962
+ code: 'PHASE_PATH_NOT_FOUND',
963
+ message,
964
+ detail: { stage: stageArg, from: currentPhase, to: options.to, reason: pathResult.reason, targets: availableTargets },
965
+ stderrLines: [message],
966
+ });
820
967
  return;
821
968
  }
822
- console.error(t('flow.advance.noPathTmpl', lang, {
969
+ stderrLines.push(t('flow.advance.noPathTmpl', lang, {
823
970
  from: currentPhase,
824
971
  to: options.to,
825
972
  targets: availableTargets.length > 0 ? `[${availableTargets.join(', ')}]` : t('common.none', lang),
826
973
  }));
827
974
  }
828
975
  // 保留既有跳转诊断(可达目标)——与现状一致
829
- console.error(t('flow.advance.errorPhaseJumpTmpl', lang, { stage: stageArg, to: options.to }));
830
- console.error(t('flow.advance.currentPhaseTmpl', lang, { phase: currentPhase }));
976
+ stderrLines.push(t('flow.advance.errorPhaseJumpTmpl', lang, { stage: stageArg, to: options.to }));
977
+ stderrLines.push(t('flow.advance.currentPhaseTmpl', lang, { phase: currentPhase }));
831
978
  if (availableTargets.length > 0) {
832
- console.error(t('flow.advance.availableTargets', lang) + `: [${availableTargets.join(', ')}]`);
979
+ stderrLines.push(t('flow.advance.availableTargets', lang) + `: [${availableTargets.join(', ')}]`);
833
980
  }
834
981
  else {
835
- console.error(t('flow.advance.noAvailableTargets', lang));
982
+ stderrLines.push(t('flow.advance.noAvailableTargets', lang));
836
983
  }
837
- console.error(t('flow.advance.hintUseForce', lang));
838
- process.exit(1);
984
+ stderrLines.push(t('flow.advance.hintUseForce', lang));
985
+ failCli({
986
+ code: 'PHASE_PATH_NOT_FOUND',
987
+ message: stderrLines[0],
988
+ detail: { stage: stageArg, from: currentPhase, to: options.to, reason: pathResult.reason, targets: availableTargets },
989
+ stderrLines,
990
+ });
839
991
  return;
840
992
  }
841
993
  if (pathResult.reason === 'already-at-target') {
@@ -870,9 +1022,13 @@ export function registerFlowCommand(program) {
870
1022
  if (options.force) {
871
1023
  console.warn(t('flow.advance.forceRevRefused', lang));
872
1024
  }
873
- console.error(t('flow.advance.blockingRevRefused', lang));
874
- console.error(t('flow.advance.blockingRevHint', lang));
875
- process.exit(1);
1025
+ const message = t('flow.advance.blockingRevRefused', lang);
1026
+ failCli({
1027
+ code: 'BLOCKING_REVIEWS',
1028
+ message,
1029
+ detail: { stage: stageArg, to: 'done', reviews: blockingOpen.map((r) => r.id) },
1030
+ stderrLines: [message, t('flow.advance.blockingRevHint', lang)],
1031
+ });
876
1032
  return;
877
1033
  }
878
1034
  }
@@ -919,14 +1075,21 @@ export function registerFlowCommand(program) {
919
1075
  archived = mgr.advanceStagePhase(stageArg, next, 'cli', explicitTier ? { reviewTier: explicitTier } : undefined);
920
1076
  }
921
1077
  catch (err) {
1078
+ const stderrLines = [];
922
1079
  if (totalSteps > 1) {
923
1080
  const remaining = phasePath.slice(i).join(t('flow.advance.pathArrow', lang));
924
1081
  const doneTo = i > 0 ? phasePath[i - 1] : t('common.none', lang);
925
- console.error(t('flow.advance.partialFailTmpl', lang, { done: doneTo, failed: next, remaining }));
1082
+ stderrLines.push(t('flow.advance.partialFailTmpl', lang, { done: doneTo, failed: next, remaining }));
926
1083
  }
927
1084
  const msg = err instanceof Error ? err.message : String(err);
928
- console.error(t('common.errorTmpl', lang, { msg }));
929
- process.exit(1);
1085
+ const message = t('common.errorTmpl', lang, { msg });
1086
+ stderrLines.push(message);
1087
+ // 多步循环中的意外异常归内部错误(D-95-11:原 exit 1 → exit 3,登记为分类补全)
1088
+ failInternal(err, {
1089
+ message,
1090
+ stderrLines,
1091
+ detail: { stage: stageArg, failed: next, remaining: phasePath.slice(i) },
1092
+ });
930
1093
  return;
931
1094
  }
932
1095
  archivedAny = archivedAny || archived;
@@ -965,11 +1128,16 @@ export function registerFlowCommand(program) {
965
1128
  const blockingOpen = assertNoBlockingOpenRev(mgr, stageArg, lang);
966
1129
  if (blockingOpen.length > 0) {
967
1130
  const remainingPath = phasePath.slice(i + 1);
968
- console.error(t('flow.advance.blockedByRevTmpl', lang, {
1131
+ const message = t('flow.advance.blockedByRevTmpl', lang, {
969
1132
  revs: blockingOpen.map((r) => r.id).join(', '),
970
1133
  remaining: remainingPath.length > 0 ? remainingPath.join(t('flow.advance.pathArrow', lang)) : t('common.none', lang),
971
- }));
972
- process.exit(1);
1134
+ });
1135
+ failCli({
1136
+ code: 'BLOCKING_REVIEWS',
1137
+ message,
1138
+ detail: { stage: stageArg, to: 'done', reviews: blockingOpen.map((r) => r.id) },
1139
+ stderrLines: [message],
1140
+ });
973
1141
  return;
974
1142
  }
975
1143
  }
@@ -1102,14 +1270,14 @@ export function registerFlowCommand(program) {
1102
1270
  }
1103
1271
  }
1104
1272
  if (options.json) {
1105
- // 复用 op-001 口径:顶层对象 + schemaVersion:1
1273
+ // 复用 op-001 口径:顶层对象 + schemaVersion:1;warning 恒在(非 empty 为 null)
1106
1274
  const ops = items.map((it) => ({
1107
1275
  stage: it.stage,
1108
1276
  opId: it.opId,
1109
1277
  title: it.title,
1110
1278
  state: it.state,
1111
1279
  fill: it.fill,
1112
- ...(it.fill === 'empty' ? { warning: t('flow.ops.emptyWarningTmpl', lang, { opId: it.opId }) } : {}),
1280
+ warning: it.fill === 'empty' ? t('flow.ops.emptyWarningTmpl', lang, { opId: it.opId }) : null,
1113
1281
  }));
1114
1282
  console.log(JSON.stringify({ schemaVersion: 1, ops }, null, 2));
1115
1283
  return;
@@ -1138,14 +1306,29 @@ export function registerFlowCommand(program) {
1138
1306
  .command('log')
1139
1307
  .description('显示最近操作日志')
1140
1308
  .option('--last <n>', '显示最近 n 条(默认 10)', '10')
1309
+ .option('--json', t('help.flow.log.json', getCliLang(process.cwd())))
1141
1310
  .action((options) => {
1142
1311
  const lang = getCliLang(process.cwd());
1143
1312
  const mgr = createManager();
1313
+ const n = Math.max(1, parseInt(options.last, 10) || 10);
1314
+ // JSON 分支须为首行纯 JSON 单文档;空日志与未初始化同形(entries: [])
1315
+ if (options.json) {
1316
+ const entries = mgr.isLoaded() && mgr.getData() ? mgr.getData().log.slice(-n) : [];
1317
+ emitJson(withSchemaVersion({
1318
+ count: entries.length,
1319
+ entries: entries.map((e) => ({
1320
+ time: e.time,
1321
+ agent: normalizeAgentName(e.agent),
1322
+ action: e.action,
1323
+ detail: e.detail,
1324
+ })),
1325
+ }));
1326
+ return;
1327
+ }
1144
1328
  if (!mgr.isLoaded()) {
1145
1329
  console.log(t('flow.log.noInit', lang));
1146
1330
  return;
1147
1331
  }
1148
- const n = Math.max(1, parseInt(options.last, 10) || 10);
1149
1332
  const data = mgr.getData();
1150
1333
  if (!data || data.log.length === 0) {
1151
1334
  console.log(t('flow.log.noLogs', lang));
@@ -1328,24 +1511,37 @@ export function registerFlowCommand(program) {
1328
1511
  .command('retry')
1329
1512
  .description('查询操作的重试状态')
1330
1513
  .requiredOption('--op <id>', '操作 ID(如 stage-01.op-001)')
1514
+ .option('--json', t('help.flow.retry.json', getCliLang(process.cwd())))
1331
1515
  .action((options) => {
1332
1516
  const lang = getCliLang(process.cwd());
1333
1517
  const mgr = createManager();
1334
1518
  if (!mgr.isLoaded()) {
1335
- console.error(t('common.errorNoInit', lang));
1336
- process.exit(1);
1519
+ failRefusal({
1520
+ code: 'NO_INIT',
1521
+ message: t('common.errorNoInit', lang),
1522
+ detail: { reason: 'no-init' },
1523
+ });
1524
+ return;
1337
1525
  }
1338
1526
  // 通过 getData() 获取 op 的 max_attempts
1339
1527
  const data = mgr.getData();
1340
1528
  if (!data) {
1341
- console.error(t('flow.retry.errorNoData', lang));
1342
- process.exit(1);
1529
+ failRefusal({
1530
+ code: 'NO_INIT',
1531
+ message: t('flow.retry.errorNoData', lang),
1532
+ detail: { reason: 'no-data' },
1533
+ });
1534
+ return;
1343
1535
  }
1344
1536
  // 解析 opId 查找 op
1345
1537
  const dotIdx = options.op.lastIndexOf('.');
1346
1538
  if (dotIdx === -1) {
1347
- console.error(t('common.invalidOpId', lang));
1348
- process.exit(1);
1539
+ failRefusal({
1540
+ code: 'INVALID_ARGUMENT',
1541
+ message: t('common.invalidOpId', lang),
1542
+ detail: { reason: 'invalid-op-id', op: options.op },
1543
+ });
1544
+ return;
1349
1545
  }
1350
1546
  const rawStageId = options.op.substring(0, dotIdx);
1351
1547
  const opLocalId = options.op.substring(dotIdx + 1);
@@ -1353,19 +1549,39 @@ export function registerFlowCommand(program) {
1353
1549
  const stageId = normalizeStageId(rawStageId) ?? rawStageId;
1354
1550
  const stage = data.stages[stageId] ?? data.stages[rawStageId];
1355
1551
  if (!stage) {
1356
- console.error(t('flow.retry.errorStageNotFoundTmpl', lang, { stage: stageId }));
1357
- process.exit(1);
1552
+ failRefusal({
1553
+ code: 'STAGE_NOT_FOUND',
1554
+ message: t('flow.retry.errorStageNotFoundTmpl', lang, { stage: stageId }),
1555
+ detail: { reason: 'stage-not-found', stage: stageId },
1556
+ });
1557
+ return;
1358
1558
  }
1359
1559
  const op = stage.ops[opLocalId];
1360
1560
  if (!op) {
1361
- console.error(t('flow.retry.errorOpNotFoundTmpl', lang, { op: options.op }));
1362
- process.exit(1);
1561
+ failRefusal({
1562
+ code: 'INVALID_ARGUMENT',
1563
+ message: t('flow.retry.errorOpNotFoundTmpl', lang, { op: options.op }),
1564
+ detail: { reason: 'op-not-found', op: options.op },
1565
+ });
1566
+ return;
1363
1567
  }
1364
1568
  const retryCount = mgr.getRetryCount(options.op);
1569
+ // JSON 与人类输出共享同一业务判定(attempts / exhausted 计算单点)
1570
+ const exhausted = retryCount >= op.max_attempts;
1571
+ if (options.json) {
1572
+ emitJson(withSchemaVersion({
1573
+ op: options.op,
1574
+ attempts: retryCount,
1575
+ maxAttempts: op.max_attempts,
1576
+ exhausted,
1577
+ state: op.state,
1578
+ }));
1579
+ return;
1580
+ }
1365
1581
  console.log(t('common.op', lang) + `: ${options.op}`);
1366
1582
  console.log(t('common.status', lang) + `: ${op.state}`);
1367
1583
  console.log(t('flow.retry.attemptCount', lang) + `: ${retryCount} / ${op.max_attempts}`);
1368
- if (retryCount >= op.max_attempts) {
1584
+ if (exhausted) {
1369
1585
  console.log(t('flow.retry.exhausted', lang));
1370
1586
  }
1371
1587
  });
@@ -1513,10 +1729,20 @@ export function registerFlowCommand(program) {
1513
1729
  .command('list')
1514
1730
  .description('列出所有(或指定阶段的)Checkpoint 快照')
1515
1731
  .argument('[stage]', '阶段 ID(可选),如 v5.3-stage-01')
1516
- .action((stage) => {
1732
+ .option('--json', t('help.flow.checkpoint.list.json', getCliLang(process.cwd())))
1733
+ .action((stage, options) => {
1517
1734
  const lang = getCliLang(process.cwd());
1518
1735
  const mgr = createManager();
1519
1736
  const snapshots = mgr.listCheckpoints(stage);
1737
+ // JSON 分支:未初始化与空集合同形(checkpoints: [])
1738
+ if (options.json) {
1739
+ emitJson(withSchemaVersion({
1740
+ stage: stage ?? null,
1741
+ checkpoints: snapshots,
1742
+ count: snapshots.length,
1743
+ }));
1744
+ return;
1745
+ }
1520
1746
  if (snapshots.length === 0) {
1521
1747
  console.log(stage
1522
1748
  ? t('flow.checkpoint.noSnapshotsStageTmpl', lang, { stage })
@@ -1600,18 +1826,19 @@ export function registerFlowCommand(program) {
1600
1826
  const projectPath = process.cwd();
1601
1827
  const lang = getCliLang(projectPath);
1602
1828
  const mgr = createManager();
1603
- // --json 失败出口统一:纯 JSON 单文档 + exit 1(不混人类文案、不走 stderr)
1604
- const failJson = (payload) => {
1605
- console.log(JSON.stringify({ schemaVersion: 1, ...payload }, null, 2));
1606
- process.exitCode = 1;
1829
+ // --json 失败出口统一(legacy 形态):纯 JSON 单文档、不混人类文案;
1830
+ // `errorKind` 恒为末尾键,`error` 恒 string、既有键序零改动(REV-95-002 / D-95-09)
1831
+ const failJson = (payload, errorKind = 'refusal', exitCode = 1) => {
1832
+ console.log(JSON.stringify({ schemaVersion: 1, ...payload, errorKind }, null, 2));
1833
+ process.exitCode = exitCode;
1607
1834
  };
1608
1835
  if (!mgr.isLoaded()) {
1609
1836
  if (options.json) {
1610
1837
  failJson({ error: 'no-init' });
1611
1838
  return;
1612
1839
  }
1613
- console.error(t('common.errorNoInit', lang));
1614
- process.exitCode = 1;
1840
+ const message = t('common.errorNoInit', lang);
1841
+ failCli({ code: 'NO_INIT', message, detail: null, stderrLines: [message] });
1615
1842
  return;
1616
1843
  }
1617
1844
  // 版本归一(REV-79-004):去 v 再加 v;非法 → exit 1 零写盘
@@ -1621,8 +1848,8 @@ export function registerFlowCommand(program) {
1621
1848
  failJson({ error: 'invalid-version', input: versionInput });
1622
1849
  return;
1623
1850
  }
1624
- console.error(t('flow.archive.invalidVersionTmpl', lang, { input: versionInput }));
1625
- process.exitCode = 1;
1851
+ const message = t('flow.archive.invalidVersionTmpl', lang, { input: versionInput });
1852
+ failCli({ code: 'INVALID_ARGUMENT', message, detail: { input: versionInput }, stderrLines: [message] });
1626
1853
  return;
1627
1854
  }
1628
1855
  const data = mgr.getData();
@@ -1633,8 +1860,8 @@ export function registerFlowCommand(program) {
1633
1860
  failJson({ error: 'version-not-active', version });
1634
1861
  return;
1635
1862
  }
1636
- console.error(t('flow.archive.versionNotActiveTmpl', lang, { version }));
1637
- process.exitCode = 1;
1863
+ const message = t('flow.archive.versionNotActiveTmpl', lang, { version });
1864
+ failCli({ code: 'INVALID_ARGUMENT', message, detail: { version }, stderrLines: [message] });
1638
1865
  return;
1639
1866
  }
1640
1867
  // 判据门:全 done(candidate.allDone 空集守卫)+ 无 blocking open REV(全仓唯一谓词,REV-79-002)
@@ -1642,23 +1869,38 @@ export function registerFlowCommand(program) {
1642
1869
  const blockingOpen = listBlockingOpenRevsForVersion(data, version);
1643
1870
  if (!candidate.allDone || blockingOpen.length > 0) {
1644
1871
  if (options.json) {
1645
- failJson({ error: 'gate-rejected', version, notDone, blockingOpen: blockingOpen.map((r) => r.id) });
1872
+ // 判据明细(D-97-03-04):legacy 五键序保留,三新键插在 blockingOpen 后、errorKind 前
1873
+ failJson({
1874
+ error: 'gate-rejected',
1875
+ version,
1876
+ notDone,
1877
+ blockingOpen: blockingOpen.map((r) => r.id),
1878
+ eligible: false,
1879
+ unfinishedStages: notDone.map((id) => ({ id, phase: data.stages[id]?.phase ?? null })),
1880
+ blockingReviews: blockingOpen.map((r) => ({ id: r.id, title: r.title, priority: r.priority })),
1881
+ });
1646
1882
  return;
1647
1883
  }
1884
+ const stderrLines = [];
1648
1885
  if (notDone.length > 0) {
1649
- console.error(t('flow.archive.gateNotDoneTitleTmpl', lang, { version, n: String(notDone.length) }));
1886
+ stderrLines.push(t('flow.archive.gateNotDoneTitleTmpl', lang, { version, n: String(notDone.length) }));
1650
1887
  for (const id of notDone) {
1651
- console.error(t('flow.archive.gateItemTmpl', lang, { item: id + ' [' + (data.stages[id]?.phase ?? '') + ']' }));
1888
+ stderrLines.push(t('flow.archive.gateItemTmpl', lang, { item: id + ' [' + (data.stages[id]?.phase ?? '') + ']' }));
1652
1889
  }
1653
1890
  }
1654
1891
  if (blockingOpen.length > 0) {
1655
- console.error(t('flow.archive.gateBlockingRevTitleTmpl', lang, { version, n: String(blockingOpen.length) }));
1892
+ stderrLines.push(t('flow.archive.gateBlockingRevTitleTmpl', lang, { version, n: String(blockingOpen.length) }));
1656
1893
  for (const r of blockingOpen) {
1657
- console.error(t('flow.archive.gateItemTmpl', lang, { item: r.id + ': ' + r.title + ' (priority=' + r.priority + ')' }));
1894
+ stderrLines.push(t('flow.archive.gateItemTmpl', lang, { item: r.id + ': ' + r.title + ' (priority=' + r.priority + ')' }));
1658
1895
  }
1659
1896
  }
1660
- console.error(t('flow.archive.gateRejected', lang));
1661
- process.exitCode = 1;
1897
+ stderrLines.push(t('flow.archive.gateRejected', lang));
1898
+ failCli({
1899
+ code: 'ARCHIVE_GATE_REJECTED',
1900
+ message: stderrLines[stderrLines.length - 1],
1901
+ detail: { version, notDone, blockingOpen: blockingOpen.map((r) => r.id) },
1902
+ stderrLines,
1903
+ });
1662
1904
  return;
1663
1905
  }
1664
1906
  // 切分(纯函数;禁命令层第二套解析/切分)
@@ -1678,7 +1920,11 @@ export function registerFlowCommand(program) {
1678
1920
  };
1679
1921
  const preview = applyArchivePipelineDisposition(previewData, version);
1680
1922
  if (options.json) {
1681
- console.log(JSON.stringify({ schemaVersion: 1, version, stages: counts.stages, reviews: counts.reviews, log: counts.log, dryRun: true }, null, 2));
1923
+ // 成功 9 键(D-97-03-03):dry-run 只读 counts,零写盘
1924
+ console.log(JSON.stringify({
1925
+ schemaVersion: 1, version, stages: counts.stages, reviews: counts.reviews, log: counts.log,
1926
+ dryRun: true, eligible: true, unfinishedStages: [], blockingReviews: [],
1927
+ }, null, 2));
1682
1928
  return;
1683
1929
  }
1684
1930
  console.log(t('flow.archive.previewTmpl', lang, { version, stages: String(counts.stages), reviews: String(counts.reviews), log: String(counts.log) }));
@@ -1694,13 +1940,14 @@ export function registerFlowCommand(program) {
1694
1940
  writeArchiveFile(projectPath, file);
1695
1941
  }
1696
1942
  catch (err) {
1697
- // 写盘失败(REV-79-013):内存未改动(移除/索引/审计均在其后),无需回滚;exit 1 零 flow.json 变更
1943
+ // 写盘失败(REV-79-013):内存未改动(移除/索引/审计均在其后),无需回滚;exit 3 零 flow.json 变更
1944
+ // 分类补全(D-95-09):写盘失败用户无法通过修正输入解决 → internal / exit 3
1698
1945
  if (options.json) {
1699
- failJson({ error: 'archive-write-failed', version });
1946
+ failJson({ error: 'archive-write-failed', version }, 'internal', 3);
1700
1947
  return;
1701
1948
  }
1702
- console.error(t('common.errorTmpl', lang, { msg: err instanceof Error ? err.message : String(err) }));
1703
- process.exitCode = 1;
1949
+ const message = t('common.errorTmpl', lang, { msg: err instanceof Error ? err.message : String(err) });
1950
+ failCli({ code: 'ARCHIVE_WRITE_FAILED', message, detail: { version }, stderrLines: [message] });
1704
1951
  return;
1705
1952
  }
1706
1953
  // ② 内存移除(stages / reviews / log 三段)
@@ -1727,7 +1974,11 @@ export function registerFlowCommand(program) {
1727
1974
  return;
1728
1975
  }
1729
1976
  if (options.json) {
1730
- console.log(JSON.stringify({ schemaVersion: 1, version, stages: counts.stages, reviews: counts.reviews, log: counts.log, dryRun: false }, null, 2));
1977
+ // 成功 9 键(D-97-03-03):保持在 mgr.save() 成功之后输出(顺序不变)
1978
+ console.log(JSON.stringify({
1979
+ schemaVersion: 1, version, stages: counts.stages, reviews: counts.reviews, log: counts.log,
1980
+ dryRun: false, eligible: true, unfinishedStages: [], blockingReviews: [],
1981
+ }, null, 2));
1731
1982
  return;
1732
1983
  }
1733
1984
  if (added) {
@@ -1794,30 +2045,33 @@ export function registerFlowCommand(program) {
1794
2045
  const version = normalizeArchiveVersion(versionInput);
1795
2046
  if (!version) {
1796
2047
  if (json) {
1797
- console.log(JSON.stringify({ schemaVersion: 1, error: 'invalid-version', input: versionInput, available }, null, 2));
2048
+ // legacy 形态:error 恒 string、既有键序不变,errorKind 恒末尾
2049
+ console.log(JSON.stringify({ schemaVersion: 1, error: 'invalid-version', input: versionInput, available, errorKind: 'refusal' }, null, 2));
1798
2050
  process.exitCode = 1;
1799
2051
  return;
1800
2052
  }
1801
- console.error(t('flow.archive.invalidVersionTmpl', lang, { input: versionInput }));
1802
- process.exitCode = 1;
2053
+ const message = t('flow.archive.invalidVersionTmpl', lang, { input: versionInput });
2054
+ failCli({ code: 'INVALID_ARGUMENT', message, detail: { input: versionInput }, stderrLines: [message] });
1803
2055
  return;
1804
2056
  }
1805
2057
  const file = readArchiveFile(projectPath, version);
1806
2058
  if (!file) {
1807
2059
  if (json) {
1808
- console.log(JSON.stringify({ schemaVersion: 1, error: 'not-found', version, available }, null, 2));
2060
+ // legacy 形态:error 恒 string、既有键序不变,errorKind 恒末尾
2061
+ console.log(JSON.stringify({ schemaVersion: 1, error: 'not-found', version, available, errorKind: 'refusal' }, null, 2));
1809
2062
  process.exitCode = 1;
1810
2063
  return;
1811
2064
  }
1812
- console.error(t('flow.archive.show.notFoundTmpl', lang, {
2065
+ const message = t('flow.archive.show.notFoundTmpl', lang, {
1813
2066
  version, versions: available.length > 0 ? available.join(', ') : t('common.none', lang),
1814
- }));
1815
- process.exitCode = 1;
2067
+ });
2068
+ failCli({ code: 'INVALID_ARGUMENT', message, detail: { version }, stderrLines: [message] });
1816
2069
  return;
1817
2070
  }
1818
2071
  // --json = 全量载荷(ArchiveFile 原文自带 schemaVersion:1,含 log / reviews)——显式请求才取全量(REV-79-003)
2072
+ // G-1(stage-95 op-001):命令层 withSchemaVersion 包装,保证 schemaVersion 恒为首键;既有键相对顺序不变
1819
2073
  if (json) {
1820
- console.log(JSON.stringify(file, null, 2));
2074
+ console.log(JSON.stringify(withSchemaVersion(file), null, 2));
1821
2075
  return;
1822
2076
  }
1823
2077
  console.log(t('flow.archive.show.titleTmpl', lang, { version, at: typeof file.archived_at === 'string' ? file.archived_at : '' }));
@@ -1863,10 +2117,9 @@ export function registerFlowCommand(program) {
1863
2117
  reconciled = mgr.reconcileStatusMd({ dryRun: options.dryRun });
1864
2118
  }
1865
2119
  catch (err) {
1866
- // 写盘失败 → 明确错误 + 非 0 退出
2120
+ // 写盘失败 → 内部错误(D-95-11:原 exit 1 → exit 3,登记为分类补全);非 JSON 文案逐字不变
1867
2121
  const msg = err instanceof Error ? err.message : String(err);
1868
- console.error(t('common.errorTmpl', lang, { msg }));
1869
- process.exit(1);
2122
+ failInternal(err, { stderrLines: [t('common.errorTmpl', lang, { msg })] });
1870
2123
  return;
1871
2124
  }
1872
2125
  }
@@ -1879,11 +2132,8 @@ export function registerFlowCommand(program) {
1879
2132
  ...archiveItems.map((i) => ({ name: i.section, status: i.status, detail: i.message })),
1880
2133
  ...reqItems.map((i) => ({ name: i.section, status: i.status, detail: i.message })),
1881
2134
  ];
1882
- const payload = { schemaVersion: 1, ok: report.ok, items };
1883
- // 与 op-001 的 health --json 合并字段(--fix 时追加 reconciled)
1884
- if (reconciled) {
1885
- payload.reconciled = reconciled;
1886
- }
2135
+ // D-95-02:reconciled 恒在(非 --fix 时 null),位置固定于 items 之后
2136
+ const payload = { schemaVersion: 1, ok: report.ok, items, reconciled: reconciled ?? null };
1887
2137
  console.log(JSON.stringify(payload, null, 2));
1888
2138
  if (!report.ok) {
1889
2139
  process.exit(1);
@@ -1947,14 +2197,42 @@ export function registerFlowCommand(program) {
1947
2197
  flow
1948
2198
  .command('recover')
1949
2199
  .description('跨会话上下文恢复:输出流水线状态、阻塞原因和待处理任务')
1950
- .action(() => {
2200
+ .option('--json', t('help.flow.recover.json', getCliLang(process.cwd())))
2201
+ .action((options) => {
1951
2202
  const lang = getCliLang(process.cwd());
1952
2203
  const mgr = createManager();
1953
2204
  if (!mgr.isLoaded()) {
2205
+ // JSON 分支:未初始化 exit 0 + initialized:false + 空值(镜像人类 exit 0)
2206
+ if (options.json) {
2207
+ emitJson(withSchemaVersion({
2208
+ initialized: false,
2209
+ phase: null,
2210
+ currentOp: null,
2211
+ stageStatus: null,
2212
+ blockedBy: null,
2213
+ pendingTasks: [],
2214
+ stageStats: {},
2215
+ }));
2216
+ return;
2217
+ }
1954
2218
  console.log(t('common.noInit', lang));
1955
2219
  return;
1956
2220
  }
1957
2221
  const recovery = mgr.recoverContext(lang);
2222
+ const stageStats = mgr.getAllStageStats();
2223
+ // JSON 分支(键序:initialized,phase,currentOp,stageStatus,blockedBy,pendingTasks,stageStats)
2224
+ if (options.json) {
2225
+ emitJson(withSchemaVersion({
2226
+ initialized: true,
2227
+ phase: recovery.phase,
2228
+ currentOp: recovery.currentOp,
2229
+ stageStatus: recovery.stageStatus,
2230
+ blockedBy: recovery.blockedBy === '' ? null : recovery.blockedBy,
2231
+ pendingTasks: recovery.pendingTasks,
2232
+ stageStats,
2233
+ }));
2234
+ return;
2235
+ }
1958
2236
  console.log('');
1959
2237
  console.log(t('flow.recover.title', lang));
1960
2238
  console.log('');
@@ -2171,9 +2449,10 @@ export function registerFlowCommand(program) {
2171
2449
  }
2172
2450
  }
2173
2451
  catch (err) {
2174
- // 并发写冲突:统一单点(i18n 文案 + 退出码 2)
2452
+ // 并发写冲突:统一单点(i18n 文案 + 退出码 2);JSON 模式置码返回 → 显式 return 防落入通用分支
2175
2453
  if (isFlowConcurrentError(err)) {
2176
2454
  handleConcurrentConflict(err, lang);
2455
+ return;
2177
2456
  }
2178
2457
  // 失败:统一错误模板 + 非 0 退出(T39)
2179
2458
  const msg = err instanceof Error ? err.message : String(err);
@@ -2254,6 +2533,18 @@ function findPhaseEnteredAtMs(mgr, stageArg, fromPhase, logLenBefore) {
2254
2533
  function createManager() {
2255
2534
  return new FlowManager(process.cwd());
2256
2535
  }
2536
+ /**
2537
+ * `--stage` 非法 / 不存在错误的 stderr 两行(逐字保持既有文案与顺序)。
2538
+ * @param lang 语言标识
2539
+ * @param projectPath 项目根
2540
+ * @param input 用户输入的阶段 ID
2541
+ */
2542
+ function stageIdErrorLines(lang, projectPath, input) {
2543
+ return [
2544
+ t('common.stageIdInvalidTmpl', lang, { input }),
2545
+ t('common.stageIdSuggestTmpl', lang, { suggest: suggestStageId(projectPath, input) }),
2546
+ ];
2547
+ }
2257
2548
  /**
2258
2549
  * 断言不存在阻塞中的 open REV(REV 闭环兜底,B5-3 / REV-52-001)。
2259
2550
  * 命中 → 打印列出并返回命中的 REV 列表(调用方决定退出码与文案);无命中 → 空数组。
@@ -2267,8 +2558,8 @@ function assertNoBlockingOpenRev(mgr, stage, lang) {
2267
2558
  return [];
2268
2559
  }
2269
2560
  // REV-007:补 `.` 分隔符(与 core flow-manager.ts 的 stageId + '.' 范式一致),避免 stageId 前缀重叠误拦
2270
- const stageReviews = mgr.getReviewItems().filter((r) => r.op === stage || r.op.startsWith(stage + '.'));
2271
- const blockingOpen = stageReviews.filter((r) => r.blocking !== false && r.status === 'open');
2561
+ // 单点委托:阶段域 + blocking !== false + status === 'open' 判定归核心 listStageBlockingOpenReviews(禁第二份)
2562
+ const blockingOpen = listStageBlockingOpenReviews(mgr.getReviewItems(), stage);
2272
2563
  if (blockingOpen.length > 0) {
2273
2564
  console.warn(t('flow.advance.revBlockedTitleTmpl', lang, { n: String(blockingOpen.length) }));
2274
2565
  for (const rev of blockingOpen) {
@@ -2313,9 +2604,21 @@ function collectOpStates(stage) {
2313
2604
  }
2314
2605
  return result;
2315
2606
  }
2607
+ /**
2608
+ * 由 ops (opId→state) 派生汇总:total = 键数;byState 仅含观测到的状态(无零值键)。
2609
+ * 键序 = 状态首次出现序;空 ops → { total: 0, byState: {} }。
2610
+ */
2611
+ function summarizeOpStates(ops) {
2612
+ const byState = {};
2613
+ for (const state of Object.values(ops)) {
2614
+ byState[state] = (byState[state] ?? 0) + 1;
2615
+ }
2616
+ return { total: Object.keys(ops).length, byState };
2617
+ }
2316
2618
  /**
2317
2619
  * 构建 `flow status --json` 的结构化输出(B1-1)。
2318
2620
  * 顶层对象 + schemaVersion:1;stages 为数组,ops 为 opId→state 映射;counts 为阶段计数。
2621
+ * stage-96 op-002:stages[] 末位追加 5 个调度属性(既有 5 键键序/类型逐字不变)。
2319
2622
  */
2320
2623
  function buildStatusJson(mgr) {
2321
2624
  const data = mgr.getData();
@@ -2327,13 +2630,23 @@ function buildStatusJson(mgr) {
2327
2630
  counts: { total: 0, done: 0, active: 0 },
2328
2631
  };
2329
2632
  }
2330
- const stages = Object.entries(data.stages).map(([id, stage]) => ({
2331
- id,
2332
- phase: stage.phase,
2333
- status: stage.status,
2334
- deps: stage.deps ?? [],
2335
- ops: collectOpStates(stage),
2336
- }));
2633
+ const stages = Object.entries(data.stages).map(([id, stage]) => {
2634
+ const ops = collectOpStates(stage);
2635
+ // 模式单点解析:逐阶段经 resolveStageWorkflowMode(同一 manager 快照;D-96-12 不缓存)
2636
+ const workflowMode = mgr.resolveStageWorkflowMode(id).value;
2637
+ return {
2638
+ id,
2639
+ phase: stage.phase,
2640
+ status: stage.status,
2641
+ deps: stage.deps ?? [],
2642
+ ops,
2643
+ workflowMode,
2644
+ requirementId: stage.requirement_id ?? null,
2645
+ reviewTier: MODE_DERIVED_TIER[workflowMode],
2646
+ effectiveReviewTier: DEFAULT_EFFECTIVE_REVIEW_TIER,
2647
+ opsSummary: summarizeOpStates(ops),
2648
+ };
2649
+ });
2337
2650
  const total = stages.length;
2338
2651
  const done = stages.filter((s) => s.phase === 'done').length;
2339
2652
  return {