openfeel 1.2.0 → 1.2.3

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 (129) hide show
  1. package/README.en.md +49 -8
  2. package/README.md +46 -5
  3. package/README.zh-CN.md +49 -8
  4. package/bin/openfeel.js +3 -2
  5. package/dist/cli/index.d.ts +17 -3
  6. package/dist/cli/index.d.ts.map +1 -1
  7. package/dist/cli/index.js +99 -10
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/cli/project-option.d.ts +40 -0
  10. package/dist/cli/project-option.d.ts.map +1 -0
  11. package/dist/cli/project-option.js +140 -0
  12. package/dist/cli/project-option.js.map +1 -0
  13. package/dist/commands/archive.d.ts.map +1 -1
  14. package/dist/commands/archive.js +32 -2
  15. package/dist/commands/archive.js.map +1 -1
  16. package/dist/commands/config.d.ts.map +1 -1
  17. package/dist/commands/config.js +71 -7
  18. package/dist/commands/config.js.map +1 -1
  19. package/dist/commands/flow.d.ts.map +1 -1
  20. package/dist/commands/flow.js +490 -112
  21. package/dist/commands/flow.js.map +1 -1
  22. package/dist/commands/instructions.d.ts.map +1 -1
  23. package/dist/commands/instructions.js +13 -3
  24. package/dist/commands/instructions.js.map +1 -1
  25. package/dist/commands/knowledge.d.ts.map +1 -1
  26. package/dist/commands/knowledge.js +113 -17
  27. package/dist/commands/knowledge.js.map +1 -1
  28. package/dist/commands/plan.d.ts.map +1 -1
  29. package/dist/commands/plan.js +31 -7
  30. package/dist/commands/plan.js.map +1 -1
  31. package/dist/commands/project.d.ts +66 -0
  32. package/dist/commands/project.d.ts.map +1 -1
  33. package/dist/commands/project.js +122 -64
  34. package/dist/commands/project.js.map +1 -1
  35. package/dist/commands/req.d.ts +2 -1
  36. package/dist/commands/req.d.ts.map +1 -1
  37. package/dist/commands/req.js +219 -34
  38. package/dist/commands/req.js.map +1 -1
  39. package/dist/commands/setup.d.ts.map +1 -1
  40. package/dist/commands/setup.js +11 -3
  41. package/dist/commands/setup.js.map +1 -1
  42. package/dist/commands/shared/errors.d.ts +55 -4
  43. package/dist/commands/shared/errors.d.ts.map +1 -1
  44. package/dist/commands/shared/errors.js +108 -10
  45. package/dist/commands/shared/errors.js.map +1 -1
  46. package/dist/commands/shared/json-output.d.ts +60 -0
  47. package/dist/commands/shared/json-output.d.ts.map +1 -0
  48. package/dist/commands/shared/json-output.js +152 -0
  49. package/dist/commands/shared/json-output.js.map +1 -0
  50. package/dist/commands/stage.d.ts.map +1 -1
  51. package/dist/commands/stage.js +69 -7
  52. package/dist/commands/stage.js.map +1 -1
  53. package/dist/commands/view.d.ts.map +1 -1
  54. package/dist/commands/view.js +27 -0
  55. package/dist/commands/view.js.map +1 -1
  56. package/dist/core/config.d.ts +6 -4
  57. package/dist/core/config.d.ts.map +1 -1
  58. package/dist/core/config.js +6 -5
  59. package/dist/core/config.js.map +1 -1
  60. package/dist/core/errors.d.ts +100 -0
  61. package/dist/core/errors.d.ts.map +1 -0
  62. package/dist/core/errors.js +58 -0
  63. package/dist/core/errors.js.map +1 -0
  64. package/dist/core/flow-manager.d.ts +73 -9
  65. package/dist/core/flow-manager.d.ts.map +1 -1
  66. package/dist/core/flow-manager.js +211 -38
  67. package/dist/core/flow-manager.js.map +1 -1
  68. package/dist/core/fs/file-lock.d.ts +9 -0
  69. package/dist/core/fs/file-lock.d.ts.map +1 -1
  70. package/dist/core/fs/file-lock.js +27 -1
  71. package/dist/core/fs/file-lock.js.map +1 -1
  72. package/dist/core/i18n-data/en.d.ts.map +1 -1
  73. package/dist/core/i18n-data/en.js +84 -9
  74. package/dist/core/i18n-data/en.js.map +1 -1
  75. package/dist/core/i18n-data/zh-CN.d.ts.map +1 -1
  76. package/dist/core/i18n-data/zh-CN.js +84 -9
  77. package/dist/core/i18n-data/zh-CN.js.map +1 -1
  78. package/dist/core/managed-region.d.ts +16 -2
  79. package/dist/core/managed-region.d.ts.map +1 -1
  80. package/dist/core/managed-region.js +24 -3
  81. package/dist/core/managed-region.js.map +1 -1
  82. package/dist/core/metrics.d.ts +2 -0
  83. package/dist/core/metrics.d.ts.map +1 -1
  84. package/dist/core/metrics.js +15 -0
  85. package/dist/core/metrics.js.map +1 -1
  86. package/dist/core/model-config.d.ts +1 -1
  87. package/dist/core/model-config.d.ts.map +1 -1
  88. package/dist/core/model-config.js +2 -2
  89. package/dist/core/model-config.js.map +1 -1
  90. package/dist/core/opencode-config.js +1 -1
  91. package/dist/core/opencode-config.js.map +1 -1
  92. package/dist/core/pipeline-schema.d.ts +22 -0
  93. package/dist/core/pipeline-schema.d.ts.map +1 -1
  94. package/dist/core/pipeline-schema.js +24 -0
  95. package/dist/core/pipeline-schema.js.map +1 -1
  96. package/dist/core/plan/stage.d.ts +2 -1
  97. package/dist/core/plan/stage.d.ts.map +1 -1
  98. package/dist/core/plan/stage.js +7 -2
  99. package/dist/core/plan/stage.js.map +1 -1
  100. package/dist/core/req/aggregate.d.ts +21 -2
  101. package/dist/core/req/aggregate.d.ts.map +1 -1
  102. package/dist/core/req/aggregate.js +18 -5
  103. package/dist/core/req/aggregate.js.map +1 -1
  104. package/dist/core/req/body-file.d.ts.map +1 -1
  105. package/dist/core/req/body-file.js +7 -3
  106. package/dist/core/req/body-file.js.map +1 -1
  107. package/dist/core/req/requirement-ops.d.ts +44 -2
  108. package/dist/core/req/requirement-ops.d.ts.map +1 -1
  109. package/dist/core/req/requirement-ops.js +108 -3
  110. package/dist/core/req/requirement-ops.js.map +1 -1
  111. package/dist/core/req/types.d.ts +7 -1
  112. package/dist/core/req/types.d.ts.map +1 -1
  113. package/dist/core/req/types.js +0 -6
  114. package/dist/core/req/types.js.map +1 -1
  115. package/dist/core/runtime-log.d.ts +18 -0
  116. package/dist/core/runtime-log.d.ts.map +1 -1
  117. package/dist/core/runtime-log.js +72 -0
  118. package/dist/core/runtime-log.js.map +1 -1
  119. package/dist/core/session.d.ts +24 -0
  120. package/dist/core/session.d.ts.map +1 -0
  121. package/dist/core/session.js +107 -0
  122. package/dist/core/session.js.map +1 -0
  123. package/dist/core/template-loader.d.ts.map +1 -1
  124. package/dist/core/template-loader.js +3004 -6684
  125. package/dist/core/template-loader.js.map +1 -1
  126. package/dist/core/update.d.ts.map +1 -1
  127. package/dist/core/update.js +720 -35
  128. package/dist/core/update.js.map +1 -1
  129. package/package.json +1 -1
@@ -1,14 +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, PHASE_PATH_MAX_DEPTH } from '../core/flow-manager.js';
5
- import { PipelinePhaseSchema, PIPELINE_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
- import { MetricsStore } from '../core/metrics.js';
9
+ import { MetricsStore, PHASE_AGENT_MAP } from '../core/metrics.js';
10
+ import { runtimeLog } from '../core/runtime-log.js';
10
11
  import { addReviewEntry } from '../core/view/entry.js';
11
- import { handleAddStageError, handleConcurrentConflict } from './shared/errors.js';
12
+ import { failCli, failInternal, handleAddStageError, handleConcurrentConflict } from './shared/errors.js';
13
+ import { withSchemaVersion, isJsonMode, emitJson } from './shared/json-output.js'; // JSON 契约助手(stage-95 op-001/op-002)
12
14
  import { t, getCliLang } from '../core/i18n.js';
13
15
  import { ARCHIVE_SCHEMA_VERSION } from '../core/flow-archive/archive-file.js';
14
16
  import { listArchivableCandidates, listBlockingOpenRevsForVersion, splitForArchive } from '../core/flow-archive/grouping.js';
@@ -141,6 +143,109 @@ export function registerFlowCommand(program) {
141
143
  }
142
144
  }
143
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
+ });
144
249
  // flow overview — 全状态可视化(openfeel flow overview 的后端实现)
145
250
  flow
146
251
  .command('overview')
@@ -348,7 +453,7 @@ export function registerFlowCommand(program) {
348
453
  .command('phases')
349
454
  .description('列出全部合法 phase 及其流转映射(自描述)')
350
455
  .option('--json', 'Output { phases, transitions, advanceAccepted, schemaVersion } as JSON')
351
- .option('--mode <mode>', '显式指定工作流模式(full | fast;缺省经 --stage / 当前阶段派生)')
456
+ .option('--mode <mode>', '显式指定工作流模式(full | fast | general;缺省经 --stage / 当前阶段派生)')
352
457
  .option('--stage <id>', '按指定阶段的模式展示转移表(缺省 = 当前阶段)')
353
458
  .action((options) => {
354
459
  const projectPath = process.cwd();
@@ -359,8 +464,13 @@ export function registerFlowCommand(program) {
359
464
  if (options.mode !== undefined) {
360
465
  const allowed = getConfigFieldLegalValues('workflow_mode') ?? [];
361
466
  if (!allowed.includes(options.mode)) {
362
- console.error(t('stage.set.invalidValueTmpl', lang, { field: '工作流模式', value: options.mode, allowed: allowed.join(', ') }));
363
- 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
+ });
364
474
  return;
365
475
  }
366
476
  explicitMode = options.mode;
@@ -370,17 +480,15 @@ export function registerFlowCommand(program) {
370
480
  if (options.stage !== undefined) {
371
481
  const v = validateStageId(options.stage);
372
482
  if (!v.ok) {
373
- console.error(t('common.stageIdInvalidTmpl', lang, { input: options.stage }));
374
- console.error(t('common.stageIdSuggestTmpl', lang, { suggest: suggestStageId(projectPath, options.stage) }));
375
- process.exit(1);
483
+ const stderrLines = stageIdErrorLines(lang, projectPath, options.stage);
484
+ failCli({ code: 'INVALID_ARGUMENT', message: stderrLines[0], detail: { stage: options.stage }, stderrLines });
376
485
  return;
377
486
  }
378
487
  stageArg = normalizeStageId(options.stage) ?? options.stage;
379
488
  const known = new Set(Object.keys(mgr.isLoaded() ? mgr.getData().stages : {}).map((k) => normalizeStageId(k) ?? k));
380
489
  if (!known.has(stageArg) && !findStageStatusPath(projectPath, stageArg)) {
381
- console.error(t('common.stageIdInvalidTmpl', lang, { input: options.stage }));
382
- console.error(t('common.stageIdSuggestTmpl', lang, { suggest: suggestStageId(projectPath, options.stage) }));
383
- 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 });
384
492
  return;
385
493
  }
386
494
  }
@@ -399,7 +507,10 @@ export function registerFlowCommand(program) {
399
507
  if (options.json) {
400
508
  // advanceAccepted = 内置 15 phase(flow advance 的推进白名单),与 phases(运行时存在视图,可含自定义)显式区分
401
509
  // 既有三字段(phases/transitions/advanceAccepted)+ transitionsDiff 逐字保留;schemaVersion 与 workflowMode 追加于末位(B1-6 / stage-75 op-002)
402
- console.log(JSON.stringify({ schemaVersion: 1, phases, transitions, advanceAccepted: [...PIPELINE_PHASES], transitionsDiff, workflowMode: derivedMode }, null, 2));
510
+ // reviewTier 再追加于末位(stage-92 op-001;既有 6 键逐字保留、顺序不变)
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));
403
514
  return;
404
515
  }
405
516
  console.log(t('flow.phases.title', lang));
@@ -411,6 +522,9 @@ export function registerFlowCommand(program) {
411
522
  // 模式分节行(stage-75 op-002:模式 + 来源 + 查看指引)
412
523
  console.log(t('flow.mode.lineTmpl', lang, { mode: derivedMode, source: explicitMode ? '--mode' : resolved.source }));
413
524
  console.log(t('flow.phases.modeHint', lang));
525
+ // 审查档位分节行(stage-92 op-001:档位 + 来源(模式派生)+ 查看指引;档位仅由模式**派生展示**,不改变推进语义)
526
+ console.log(t('flow.phases.tierLineTmpl', lang, { tier: MODE_DERIVED_TIER[derivedMode], mode: derivedMode }));
527
+ console.log(t('flow.phases.tierHint', lang));
414
528
  console.log(t('flow.phases.transitionsLabel', lang) + ':');
415
529
  for (const [key, targets] of Object.entries(transitions)) {
416
530
  console.log(` ${key} → [${targets.join(', ')}]`);
@@ -451,18 +565,16 @@ export function registerFlowCommand(program) {
451
565
  if (options.stage !== undefined) {
452
566
  const v = validateStageId(options.stage);
453
567
  if (!v.ok) {
454
- console.error(t('common.stageIdInvalidTmpl', lang, { input: options.stage }));
455
- console.error(t('common.stageIdSuggestTmpl', lang, { suggest: suggestStageId(projectPath, options.stage) }));
456
- process.exit(1);
568
+ const stderrLines = stageIdErrorLines(lang, projectPath, options.stage);
569
+ failCli({ code: 'INVALID_ARGUMENT', message: stderrLines[0], detail: { stage: options.stage }, stderrLines });
457
570
  return;
458
571
  }
459
572
  key = normalizeStageId(options.stage) ?? options.stage;
460
573
  // 存在性:已注册(flow.json)或有 status.md;二者皆无 → exit 1(防 typo 静默落 config 层)
461
574
  const known = new Set(Object.keys(mgr.isLoaded() ? mgr.getData().stages : {}).map((k) => normalizeStageId(k) ?? k));
462
575
  if (!known.has(key) && !findStageStatusPath(projectPath, key)) {
463
- console.error(t('common.stageIdInvalidTmpl', lang, { input: options.stage }));
464
- console.error(t('common.stageIdSuggestTmpl', lang, { suggest: suggestStageId(projectPath, options.stage) }));
465
- 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 });
466
578
  return;
467
579
  }
468
580
  }
@@ -475,6 +587,7 @@ export function registerFlowCommand(program) {
475
587
  const modes = {
476
588
  full: mgr.getPipelineTransitions('full'),
477
589
  fast: mgr.getPipelineTransitions('fast'),
590
+ general: mgr.getPipelineTransitions('general'),
478
591
  };
479
592
  if (options.json) {
480
593
  // 未初始化亦保纯 JSON 单文档(stage 空串 + 无 stage 域级联解析值)
@@ -593,9 +706,10 @@ export function registerFlowCommand(program) {
593
706
  console.log(t('flow.stage.remove.okTmpl', lang, { stage: stageArg }));
594
707
  }
595
708
  catch (err) {
596
- // 并发写冲突:统一单点(i18n 文案 + 退出码 2);其余走通用错误
709
+ // 并发写冲突:统一单点(i18n 文案 + 退出码 2);JSON 模式置码返回 → 显式 return 防落入通用分支
597
710
  if (isFlowConcurrentError(err)) {
598
711
  handleConcurrentConflict(err, lang);
712
+ return;
599
713
  }
600
714
  const msg = err instanceof Error ? err.message : String(err);
601
715
  console.error(t('common.errorTmpl', lang, { msg }));
@@ -652,8 +766,10 @@ export function registerFlowCommand(program) {
652
766
  mgr.setStageDeps(stageId, deps);
653
767
  }
654
768
  catch (err) {
769
+ // 并发写冲突:统一单点(i18n 文案 + 退出码 2);JSON 模式置码返回 → 显式 return 防落入通用分支
655
770
  if (isFlowConcurrentError(err)) {
656
771
  handleConcurrentConflict(err, lang);
772
+ return;
657
773
  }
658
774
  const msg = err instanceof Error ? err.message : String(err);
659
775
  console.error(t('common.errorTmpl', lang, { msg }));
@@ -704,9 +820,10 @@ export function registerFlowCommand(program) {
704
820
  }));
705
821
  }
706
822
  catch (err) {
707
- // 并发写冲突:统一单点(i18n 文案 + 退出码 2);其余走通用错误
823
+ // 并发写冲突:统一单点(i18n 文案 + 退出码 2);JSON 模式置码返回 → 显式 return 防落入通用分支
708
824
  if (isFlowConcurrentError(err)) {
709
825
  handleConcurrentConflict(err, lang);
826
+ return;
710
827
  }
711
828
  const msg = err instanceof Error ? err.message : String(err);
712
829
  console.error(t('common.errorTmpl', lang, { msg }));
@@ -722,18 +839,49 @@ export function registerFlowCommand(program) {
722
839
  .option('--stage <id>', '阶段 ID(如 stage-03),必须指定')
723
840
  .option('--force', '强制执行(跳过非法 phase 校验和阶段跳跃检查,但不可绕过 REV 阻塞检查)')
724
841
  .option('--dry-run', '仅验证不执行修改(预览输出)。与 --force 组合时跳过校验但仍不执行修改')
842
+ .option('--review-tier <tier>', '审查档位(full / lean / skip;缺省=full(零裁剪);仅显式声明时裁剪审查关,不可绕过 REV 阻塞检查)')
843
+ .option('--json', '以 JSON 输出错误封装(仅失败时;成功输出保持人类文本)')
725
844
  .option('--quiet', '静默非错误输出(成功确认与 Git 警告均不打印)')
726
845
  .action((options) => {
727
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
+ };
728
856
  // 自定义 --stage 必选校验(提供中文错误提示)
729
857
  if (!options.stage) {
730
- console.error(t('flow.advance.errorNoStage', lang));
731
- process.exit(1);
858
+ const message = t('flow.advance.errorNoStage', lang);
859
+ failCli({ code: 'INVALID_ARGUMENT', message, detail: { required: '--stage' }, stderrLines: [message] });
860
+ return;
861
+ }
862
+ // 审查档位值域校验(stage-92 op-001;fail-fast 且**先于任何取值 / 路径解析 / autoRepair 写盘**)
863
+ // 复用 stage.set.invalidValueTmpl(与 flow phases --mode 同款,零新增键);非法 → exit 1 零写盘
864
+ let explicitTier;
865
+ if (options.reviewTier !== undefined) {
866
+ if (!REVIEW_TIERS.includes(options.reviewTier)) {
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
+ });
874
+ return;
875
+ }
876
+ explicitTier = options.reviewTier;
732
877
  }
878
+ // 生效档位:显式声明 ?? DEFAULT_EFFECTIVE_REVIEW_TIER(**不传 = 零裁剪**;派生档仅用于 `flow phases` 展示与提示,不改变推进语义)
879
+ const effTier = explicitTier ?? DEFAULT_EFFECTIVE_REVIEW_TIER;
733
880
  const mgr = createManager();
734
881
  if (!mgr.isLoaded()) {
735
- console.error(t('common.errorNoInit', lang));
736
- process.exit(1);
882
+ const message = t('common.errorNoInit', lang);
883
+ failCli({ code: 'NO_INIT', message, detail: null, stderrLines: [message] });
884
+ return;
737
885
  }
738
886
  // REV-005:命令层归一化(短名 → 全名),与 plan.ts / flow.ts 其它 stage 解析点同范式
739
887
  const stageArg = normalizeStageId(options.stage) ?? options.stage;
@@ -742,7 +890,8 @@ export function registerFlowCommand(program) {
742
890
  // B1 修复:dry-run 走预览模式(不写内存/不写盘),仅在非 dry-run 时 save()
743
891
  const repairResult = mgr.autoRepairInconsistency(stageArg, { dryRun: options.dryRun });
744
892
  if (repairResult.fixed) {
745
- console.log(options.dryRun
893
+ // JSON 模式下改走 stderr(失败时 stdout 仅保留一个 envelope)
894
+ info(options.dryRun
746
895
  ? t('flow.advance.autoRepairPreview', lang) + `: ${repairResult.detail}`
747
896
  : t('flow.advance.autoRepaired', lang) + `: ${repairResult.detail}`);
748
897
  if (!options.dryRun) {
@@ -756,23 +905,35 @@ export function registerFlowCommand(program) {
756
905
  for (const w of warnings) {
757
906
  console.warn(`[WARN] ${w}`);
758
907
  }
759
- console.log(t('flow.advance.warnAutoCorrect', lang));
908
+ // JSON 模式下改走 stderr(失败时 stdout 仅保留一个 envelope)
909
+ info(t('flow.advance.warnAutoCorrect', lang));
760
910
  }
761
911
  if (!valid) {
762
- console.error(t('flow.advance.errorInvalidFormat', lang));
763
- for (const err of errors) {
764
- console.error(` - ${err}`);
765
- }
766
- 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;
767
920
  }
768
921
  // 非法 phase 校验(P1):拒绝不在枚举中的目标 phase;--force 可跳过此项,但不可绕过 REV 阻塞检查
769
922
  if (!options.force) {
770
923
  const phaseResult = PipelinePhaseSchema.safeParse(options.to);
771
924
  if (!phaseResult.success) {
772
- console.error(t('flow.advance.errorInvalidPhaseTmpl', lang, { phase: options.to }));
773
- console.error(t('flow.advance.labelValidPhases', lang) + `: [${PIPELINE_PHASES.join(', ')}]`);
774
- console.error(t('flow.advance.hintUseForceFuzzy', lang));
775
- 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;
776
937
  }
777
938
  }
778
939
  // 阶段路径解析(B5-1):非 --force 时求「当前 phase → to」的唯一可达路径
@@ -781,35 +942,52 @@ export function registerFlowCommand(program) {
781
942
  // - 已在 to → no-op 成功
782
943
  let phasePath = [options.to];
783
944
  if (!options.force) {
784
- const pathResult = mgr.findPhasePath(stageArg, options.to);
945
+ const pathResult = mgr.findPhasePath(stageArg, options.to, effTier);
785
946
  if (pathResult.reason === 'no-path' || pathResult.reason === 'ambiguous' || pathResult.reason === 'depth-exceeded') {
786
947
  const data = mgr.getData();
787
948
  const currentPhase = data?.stages[stageArg]?.phase ?? t('common.unknown', lang);
788
- const availableTargets = mgr.getAvailablePhases(stageArg);
949
+ const availableTargets = mgr.getAvailablePhases(stageArg, effTier);
950
+ const stderrLines = [];
789
951
  if (pathResult.reason === 'ambiguous') {
790
- console.error(t('flow.advance.ambiguousTmpl', lang));
952
+ stderrLines.push(t('flow.advance.ambiguousTmpl', lang));
791
953
  }
792
954
  else if (pathResult.reason === 'depth-exceeded') {
793
- 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) }));
794
956
  }
795
957
  else {
796
- console.error(t('flow.advance.noPathTmpl', lang, {
958
+ // general(无流水线)→ 显式错误引导(防御路径:常规写路径不产生 general 阶段态;REV-90-005 / D-90-3)
959
+ if (mgr.resolveStageWorkflowMode(stageArg).value === 'general') {
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
+ });
967
+ return;
968
+ }
969
+ stderrLines.push(t('flow.advance.noPathTmpl', lang, {
797
970
  from: currentPhase,
798
971
  to: options.to,
799
972
  targets: availableTargets.length > 0 ? `[${availableTargets.join(', ')}]` : t('common.none', lang),
800
973
  }));
801
974
  }
802
975
  // 保留既有跳转诊断(可达目标)——与现状一致
803
- console.error(t('flow.advance.errorPhaseJumpTmpl', lang, { stage: stageArg, to: options.to }));
804
- 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 }));
805
978
  if (availableTargets.length > 0) {
806
- console.error(t('flow.advance.availableTargets', lang) + `: [${availableTargets.join(', ')}]`);
979
+ stderrLines.push(t('flow.advance.availableTargets', lang) + `: [${availableTargets.join(', ')}]`);
807
980
  }
808
981
  else {
809
- console.error(t('flow.advance.noAvailableTargets', lang));
982
+ stderrLines.push(t('flow.advance.noAvailableTargets', lang));
810
983
  }
811
- console.error(t('flow.advance.hintUseForce', lang));
812
- 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
+ });
813
991
  return;
814
992
  }
815
993
  if (pathResult.reason === 'already-at-target') {
@@ -829,6 +1007,14 @@ export function registerFlowCommand(program) {
829
1007
  console.warn(t('flow.advance.warnSkipReview', lang));
830
1008
  }
831
1009
  }
1010
+ // 审查档位协同警告(stage-92 op-001 / D-92-4):**仅显式声明**非全量档位时输出一行
1011
+ // 非阻断(退出码不变);`--review-tier full` 与缺省不告警 ⟹ 既有流程零输出回归
1012
+ if (explicitTier === 'lean' || explicitTier === 'skip') {
1013
+ console.warn(t('flow.advance.warnTierTmpl', lang, {
1014
+ tier: explicitTier,
1015
+ gates: TIER_PRUNED_PHASES[explicitTier].join(', '),
1016
+ }));
1017
+ }
832
1018
  // REV 闭环(命令层兜底):推进到 done 时检查 blocking REV(单步入口;多步循环每步后另复检,B5-3)
833
1019
  if (options.to === 'done' && options.stage) {
834
1020
  const blockingOpen = assertNoBlockingOpenRev(mgr, stageArg, lang);
@@ -836,9 +1022,13 @@ export function registerFlowCommand(program) {
836
1022
  if (options.force) {
837
1023
  console.warn(t('flow.advance.forceRevRefused', lang));
838
1024
  }
839
- console.error(t('flow.advance.blockingRevRefused', lang));
840
- console.error(t('flow.advance.blockingRevHint', lang));
841
- 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
+ });
842
1032
  return;
843
1033
  }
844
1034
  }
@@ -853,6 +1043,13 @@ export function registerFlowCommand(program) {
853
1043
  console.log(` ` + t('common.stage', lang) + `: ${stageArg}`);
854
1044
  console.log(` ` + t('flow.advance.dryRunFrom', lang) + `: ${startPhase}`);
855
1045
  console.log(` ` + t('flow.advance.dryRunTo', lang) + `: ${options.to}`);
1046
+ // 审查档位行(stage-92 op-001):仅显式声明时追加(缺省零输出回归);档位为空裁剪时 gates 显示「(无)」
1047
+ if (explicitTier !== undefined) {
1048
+ console.log(` ` + t('flow.advance.dryRunTierTmpl', lang, {
1049
+ tier: explicitTier,
1050
+ gates: TIER_PRUNED_PHASES[explicitTier].join(', ') || t('common.none', lang),
1051
+ }));
1052
+ }
856
1053
  if (phasePath.length > 1) {
857
1054
  const fullPath = [startPhase, ...phasePath].join(t('flow.advance.pathArrow', lang));
858
1055
  console.log(` ` + t('flow.advance.pathTitle', lang) + ` ${fullPath}`);
@@ -863,27 +1060,55 @@ export function registerFlowCommand(program) {
863
1060
  }
864
1061
  // 逐步推进(B5-2 / A3):唯一路径 → 依次调用 advanceStagePhase(保留每步校验/日志/checkpoint)
865
1062
  // 中间态不回滚(A3);失败 → 输出「已完成 / 失败点 / 剩余路径」+ exit 1
1063
+ // T1:recordRun 接线(命令层;--dry-run 已于上方提前 return——天然排除)
1064
+ const metricsStore = MetricsStore.getInstance();
1065
+ metricsStore.load(); // 读盘合并(防 save 覆盖历史记录);幂等
1066
+ const logLenBefore = mgr.getData()?.log.length ?? 0; // 基线回扫上界(排除本次写入;REV-86-002)
1067
+ let metricsRecorded = false;
1068
+ let prevStepDoneMs = 0; // 多步时上一步完成时刻(i>0 的窗口基线)
866
1069
  const totalSteps = phasePath.length;
867
1070
  let archivedAny = false;
868
1071
  for (let i = 0; i < totalSteps; i++) {
869
1072
  const next = phasePath[i];
870
1073
  let archived = false;
871
1074
  try {
872
- archived = mgr.advanceStagePhase(stageArg, next, 'cli');
1075
+ archived = mgr.advanceStagePhase(stageArg, next, 'cli', explicitTier ? { reviewTier: explicitTier } : undefined);
873
1076
  }
874
1077
  catch (err) {
1078
+ const stderrLines = [];
875
1079
  if (totalSteps > 1) {
876
1080
  const remaining = phasePath.slice(i).join(t('flow.advance.pathArrow', lang));
877
1081
  const doneTo = i > 0 ? phasePath[i - 1] : t('common.none', lang);
878
- 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 }));
879
1083
  }
880
1084
  const msg = err instanceof Error ? err.message : String(err);
881
- console.error(t('common.errorTmpl', lang, { msg }));
882
- 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
+ });
883
1093
  return;
884
1094
  }
885
1095
  archivedAny = archivedAny || archived;
886
1096
  mgr.save();
1097
+ // T1:对刚退出的 fromPhase 记一条 run(窗口 = 进入该 phase 的转换 → 本次退出)
1098
+ const exitedPhase = i > 0 ? phasePath[i - 1] : startPhase;
1099
+ const agentName = PHASE_AGENT_MAP[exitedPhase];
1100
+ if (agentName) {
1101
+ const baseMs = i > 0 ? prevStepDoneMs : findPhaseEnteredAtMs(mgr, stageArg, exitedPhase, logLenBefore);
1102
+ if (baseMs !== null && baseMs > 0) {
1103
+ metricsStore.recordRun(agentName, Date.now() - baseMs, 'success', 0);
1104
+ metricsRecorded = true;
1105
+ }
1106
+ else {
1107
+ // 无基线 → 跳过不记(不写 0 值污染 avg);debug 留痕(默认关,不扰输出)
1108
+ runtimeLog('debug', 'metrics skip (no baseline): stage=' + stageArg + ' phase=' + exitedPhase);
1109
+ }
1110
+ }
1111
+ prevStepDoneMs = Date.now();
887
1112
  // 归档 commit 必须在 flow.json save 之后执行,确保 commit 包含本次 phase 变更
888
1113
  if (archived) {
889
1114
  mgr.autoCommitOnDone(stageArg);
@@ -903,15 +1128,24 @@ export function registerFlowCommand(program) {
903
1128
  const blockingOpen = assertNoBlockingOpenRev(mgr, stageArg, lang);
904
1129
  if (blockingOpen.length > 0) {
905
1130
  const remainingPath = phasePath.slice(i + 1);
906
- console.error(t('flow.advance.blockedByRevTmpl', lang, {
1131
+ const message = t('flow.advance.blockedByRevTmpl', lang, {
907
1132
  revs: blockingOpen.map((r) => r.id).join(', '),
908
1133
  remaining: remainingPath.length > 0 ? remainingPath.join(t('flow.advance.pathArrow', lang)) : t('common.none', lang),
909
- }));
910
- 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
+ });
911
1141
  return;
912
1142
  }
913
1143
  }
914
1144
  }
1145
+ // T1:仅本命令内有记录时落盘一次(低并发非关键路径;推进失败提前 exit 则不 save)
1146
+ if (metricsRecorded) {
1147
+ metricsStore.save();
1148
+ }
915
1149
  // 成功确认(单步与多步统一);--quiet 完全静默
916
1150
  if (!options.quiet) {
917
1151
  console.log(t('flow.advance.okTmpl', lang, { stage: stageArg, to: options.to }));
@@ -1036,14 +1270,14 @@ export function registerFlowCommand(program) {
1036
1270
  }
1037
1271
  }
1038
1272
  if (options.json) {
1039
- // 复用 op-001 口径:顶层对象 + schemaVersion:1
1273
+ // 复用 op-001 口径:顶层对象 + schemaVersion:1;warning 恒在(非 empty 为 null)
1040
1274
  const ops = items.map((it) => ({
1041
1275
  stage: it.stage,
1042
1276
  opId: it.opId,
1043
1277
  title: it.title,
1044
1278
  state: it.state,
1045
1279
  fill: it.fill,
1046
- ...(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,
1047
1281
  }));
1048
1282
  console.log(JSON.stringify({ schemaVersion: 1, ops }, null, 2));
1049
1283
  return;
@@ -1072,14 +1306,29 @@ export function registerFlowCommand(program) {
1072
1306
  .command('log')
1073
1307
  .description('显示最近操作日志')
1074
1308
  .option('--last <n>', '显示最近 n 条(默认 10)', '10')
1309
+ .option('--json', t('help.flow.log.json', getCliLang(process.cwd())))
1075
1310
  .action((options) => {
1076
1311
  const lang = getCliLang(process.cwd());
1077
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
+ }
1078
1328
  if (!mgr.isLoaded()) {
1079
1329
  console.log(t('flow.log.noInit', lang));
1080
1330
  return;
1081
1331
  }
1082
- const n = Math.max(1, parseInt(options.last, 10) || 10);
1083
1332
  const data = mgr.getData();
1084
1333
  if (!data || data.log.length === 0) {
1085
1334
  console.log(t('flow.log.noLogs', lang));
@@ -1447,10 +1696,20 @@ export function registerFlowCommand(program) {
1447
1696
  .command('list')
1448
1697
  .description('列出所有(或指定阶段的)Checkpoint 快照')
1449
1698
  .argument('[stage]', '阶段 ID(可选),如 v5.3-stage-01')
1450
- .action((stage) => {
1699
+ .option('--json', t('help.flow.checkpoint.list.json', getCliLang(process.cwd())))
1700
+ .action((stage, options) => {
1451
1701
  const lang = getCliLang(process.cwd());
1452
1702
  const mgr = createManager();
1453
1703
  const snapshots = mgr.listCheckpoints(stage);
1704
+ // JSON 分支:未初始化与空集合同形(checkpoints: [])
1705
+ if (options.json) {
1706
+ emitJson(withSchemaVersion({
1707
+ stage: stage ?? null,
1708
+ checkpoints: snapshots,
1709
+ count: snapshots.length,
1710
+ }));
1711
+ return;
1712
+ }
1454
1713
  if (snapshots.length === 0) {
1455
1714
  console.log(stage
1456
1715
  ? t('flow.checkpoint.noSnapshotsStageTmpl', lang, { stage })
@@ -1534,18 +1793,19 @@ export function registerFlowCommand(program) {
1534
1793
  const projectPath = process.cwd();
1535
1794
  const lang = getCliLang(projectPath);
1536
1795
  const mgr = createManager();
1537
- // --json 失败出口统一:纯 JSON 单文档 + exit 1(不混人类文案、不走 stderr)
1538
- const failJson = (payload) => {
1539
- console.log(JSON.stringify({ schemaVersion: 1, ...payload }, null, 2));
1540
- process.exitCode = 1;
1796
+ // --json 失败出口统一(legacy 形态):纯 JSON 单文档、不混人类文案;
1797
+ // `errorKind` 恒为末尾键,`error` 恒 string、既有键序零改动(REV-95-002 / D-95-09)
1798
+ const failJson = (payload, errorKind = 'refusal', exitCode = 1) => {
1799
+ console.log(JSON.stringify({ schemaVersion: 1, ...payload, errorKind }, null, 2));
1800
+ process.exitCode = exitCode;
1541
1801
  };
1542
1802
  if (!mgr.isLoaded()) {
1543
1803
  if (options.json) {
1544
1804
  failJson({ error: 'no-init' });
1545
1805
  return;
1546
1806
  }
1547
- console.error(t('common.errorNoInit', lang));
1548
- process.exitCode = 1;
1807
+ const message = t('common.errorNoInit', lang);
1808
+ failCli({ code: 'NO_INIT', message, detail: null, stderrLines: [message] });
1549
1809
  return;
1550
1810
  }
1551
1811
  // 版本归一(REV-79-004):去 v 再加 v;非法 → exit 1 零写盘
@@ -1555,8 +1815,8 @@ export function registerFlowCommand(program) {
1555
1815
  failJson({ error: 'invalid-version', input: versionInput });
1556
1816
  return;
1557
1817
  }
1558
- console.error(t('flow.archive.invalidVersionTmpl', lang, { input: versionInput }));
1559
- process.exitCode = 1;
1818
+ const message = t('flow.archive.invalidVersionTmpl', lang, { input: versionInput });
1819
+ failCli({ code: 'INVALID_ARGUMENT', message, detail: { input: versionInput }, stderrLines: [message] });
1560
1820
  return;
1561
1821
  }
1562
1822
  const data = mgr.getData();
@@ -1567,8 +1827,8 @@ export function registerFlowCommand(program) {
1567
1827
  failJson({ error: 'version-not-active', version });
1568
1828
  return;
1569
1829
  }
1570
- console.error(t('flow.archive.versionNotActiveTmpl', lang, { version }));
1571
- process.exitCode = 1;
1830
+ const message = t('flow.archive.versionNotActiveTmpl', lang, { version });
1831
+ failCli({ code: 'INVALID_ARGUMENT', message, detail: { version }, stderrLines: [message] });
1572
1832
  return;
1573
1833
  }
1574
1834
  // 判据门:全 done(candidate.allDone 空集守卫)+ 无 blocking open REV(全仓唯一谓词,REV-79-002)
@@ -1576,23 +1836,38 @@ export function registerFlowCommand(program) {
1576
1836
  const blockingOpen = listBlockingOpenRevsForVersion(data, version);
1577
1837
  if (!candidate.allDone || blockingOpen.length > 0) {
1578
1838
  if (options.json) {
1579
- failJson({ error: 'gate-rejected', version, notDone, blockingOpen: blockingOpen.map((r) => r.id) });
1839
+ // 判据明细(D-97-03-04):legacy 五键序保留,三新键插在 blockingOpen 后、errorKind 前
1840
+ failJson({
1841
+ error: 'gate-rejected',
1842
+ version,
1843
+ notDone,
1844
+ blockingOpen: blockingOpen.map((r) => r.id),
1845
+ eligible: false,
1846
+ unfinishedStages: notDone.map((id) => ({ id, phase: data.stages[id]?.phase ?? null })),
1847
+ blockingReviews: blockingOpen.map((r) => ({ id: r.id, title: r.title, priority: r.priority })),
1848
+ });
1580
1849
  return;
1581
1850
  }
1851
+ const stderrLines = [];
1582
1852
  if (notDone.length > 0) {
1583
- console.error(t('flow.archive.gateNotDoneTitleTmpl', lang, { version, n: String(notDone.length) }));
1853
+ stderrLines.push(t('flow.archive.gateNotDoneTitleTmpl', lang, { version, n: String(notDone.length) }));
1584
1854
  for (const id of notDone) {
1585
- console.error(t('flow.archive.gateItemTmpl', lang, { item: id + ' [' + (data.stages[id]?.phase ?? '') + ']' }));
1855
+ stderrLines.push(t('flow.archive.gateItemTmpl', lang, { item: id + ' [' + (data.stages[id]?.phase ?? '') + ']' }));
1586
1856
  }
1587
1857
  }
1588
1858
  if (blockingOpen.length > 0) {
1589
- console.error(t('flow.archive.gateBlockingRevTitleTmpl', lang, { version, n: String(blockingOpen.length) }));
1859
+ stderrLines.push(t('flow.archive.gateBlockingRevTitleTmpl', lang, { version, n: String(blockingOpen.length) }));
1590
1860
  for (const r of blockingOpen) {
1591
- console.error(t('flow.archive.gateItemTmpl', lang, { item: r.id + ': ' + r.title + ' (priority=' + r.priority + ')' }));
1861
+ stderrLines.push(t('flow.archive.gateItemTmpl', lang, { item: r.id + ': ' + r.title + ' (priority=' + r.priority + ')' }));
1592
1862
  }
1593
1863
  }
1594
- console.error(t('flow.archive.gateRejected', lang));
1595
- process.exitCode = 1;
1864
+ stderrLines.push(t('flow.archive.gateRejected', lang));
1865
+ failCli({
1866
+ code: 'ARCHIVE_GATE_REJECTED',
1867
+ message: stderrLines[stderrLines.length - 1],
1868
+ detail: { version, notDone, blockingOpen: blockingOpen.map((r) => r.id) },
1869
+ stderrLines,
1870
+ });
1596
1871
  return;
1597
1872
  }
1598
1873
  // 切分(纯函数;禁命令层第二套解析/切分)
@@ -1612,7 +1887,11 @@ export function registerFlowCommand(program) {
1612
1887
  };
1613
1888
  const preview = applyArchivePipelineDisposition(previewData, version);
1614
1889
  if (options.json) {
1615
- console.log(JSON.stringify({ schemaVersion: 1, version, stages: counts.stages, reviews: counts.reviews, log: counts.log, dryRun: true }, null, 2));
1890
+ // 成功 9 键(D-97-03-03):dry-run 只读 counts,零写盘
1891
+ console.log(JSON.stringify({
1892
+ schemaVersion: 1, version, stages: counts.stages, reviews: counts.reviews, log: counts.log,
1893
+ dryRun: true, eligible: true, unfinishedStages: [], blockingReviews: [],
1894
+ }, null, 2));
1616
1895
  return;
1617
1896
  }
1618
1897
  console.log(t('flow.archive.previewTmpl', lang, { version, stages: String(counts.stages), reviews: String(counts.reviews), log: String(counts.log) }));
@@ -1628,13 +1907,14 @@ export function registerFlowCommand(program) {
1628
1907
  writeArchiveFile(projectPath, file);
1629
1908
  }
1630
1909
  catch (err) {
1631
- // 写盘失败(REV-79-013):内存未改动(移除/索引/审计均在其后),无需回滚;exit 1 零 flow.json 变更
1910
+ // 写盘失败(REV-79-013):内存未改动(移除/索引/审计均在其后),无需回滚;exit 3 零 flow.json 变更
1911
+ // 分类补全(D-95-09):写盘失败用户无法通过修正输入解决 → internal / exit 3
1632
1912
  if (options.json) {
1633
- failJson({ error: 'archive-write-failed', version });
1913
+ failJson({ error: 'archive-write-failed', version }, 'internal', 3);
1634
1914
  return;
1635
1915
  }
1636
- console.error(t('common.errorTmpl', lang, { msg: err instanceof Error ? err.message : String(err) }));
1637
- process.exitCode = 1;
1916
+ const message = t('common.errorTmpl', lang, { msg: err instanceof Error ? err.message : String(err) });
1917
+ failCli({ code: 'ARCHIVE_WRITE_FAILED', message, detail: { version }, stderrLines: [message] });
1638
1918
  return;
1639
1919
  }
1640
1920
  // ② 内存移除(stages / reviews / log 三段)
@@ -1661,7 +1941,11 @@ export function registerFlowCommand(program) {
1661
1941
  return;
1662
1942
  }
1663
1943
  if (options.json) {
1664
- console.log(JSON.stringify({ schemaVersion: 1, version, stages: counts.stages, reviews: counts.reviews, log: counts.log, dryRun: false }, null, 2));
1944
+ // 成功 9 键(D-97-03-03):保持在 mgr.save() 成功之后输出(顺序不变)
1945
+ console.log(JSON.stringify({
1946
+ schemaVersion: 1, version, stages: counts.stages, reviews: counts.reviews, log: counts.log,
1947
+ dryRun: false, eligible: true, unfinishedStages: [], blockingReviews: [],
1948
+ }, null, 2));
1665
1949
  return;
1666
1950
  }
1667
1951
  if (added) {
@@ -1728,30 +2012,33 @@ export function registerFlowCommand(program) {
1728
2012
  const version = normalizeArchiveVersion(versionInput);
1729
2013
  if (!version) {
1730
2014
  if (json) {
1731
- console.log(JSON.stringify({ schemaVersion: 1, error: 'invalid-version', input: versionInput, available }, null, 2));
2015
+ // legacy 形态:error 恒 string、既有键序不变,errorKind 恒末尾
2016
+ console.log(JSON.stringify({ schemaVersion: 1, error: 'invalid-version', input: versionInput, available, errorKind: 'refusal' }, null, 2));
1732
2017
  process.exitCode = 1;
1733
2018
  return;
1734
2019
  }
1735
- console.error(t('flow.archive.invalidVersionTmpl', lang, { input: versionInput }));
1736
- process.exitCode = 1;
2020
+ const message = t('flow.archive.invalidVersionTmpl', lang, { input: versionInput });
2021
+ failCli({ code: 'INVALID_ARGUMENT', message, detail: { input: versionInput }, stderrLines: [message] });
1737
2022
  return;
1738
2023
  }
1739
2024
  const file = readArchiveFile(projectPath, version);
1740
2025
  if (!file) {
1741
2026
  if (json) {
1742
- console.log(JSON.stringify({ schemaVersion: 1, error: 'not-found', version, available }, null, 2));
2027
+ // legacy 形态:error 恒 string、既有键序不变,errorKind 恒末尾
2028
+ console.log(JSON.stringify({ schemaVersion: 1, error: 'not-found', version, available, errorKind: 'refusal' }, null, 2));
1743
2029
  process.exitCode = 1;
1744
2030
  return;
1745
2031
  }
1746
- console.error(t('flow.archive.show.notFoundTmpl', lang, {
2032
+ const message = t('flow.archive.show.notFoundTmpl', lang, {
1747
2033
  version, versions: available.length > 0 ? available.join(', ') : t('common.none', lang),
1748
- }));
1749
- process.exitCode = 1;
2034
+ });
2035
+ failCli({ code: 'INVALID_ARGUMENT', message, detail: { version }, stderrLines: [message] });
1750
2036
  return;
1751
2037
  }
1752
2038
  // --json = 全量载荷(ArchiveFile 原文自带 schemaVersion:1,含 log / reviews)——显式请求才取全量(REV-79-003)
2039
+ // G-1(stage-95 op-001):命令层 withSchemaVersion 包装,保证 schemaVersion 恒为首键;既有键相对顺序不变
1753
2040
  if (json) {
1754
- console.log(JSON.stringify(file, null, 2));
2041
+ console.log(JSON.stringify(withSchemaVersion(file), null, 2));
1755
2042
  return;
1756
2043
  }
1757
2044
  console.log(t('flow.archive.show.titleTmpl', lang, { version, at: typeof file.archived_at === 'string' ? file.archived_at : '' }));
@@ -1797,10 +2084,9 @@ export function registerFlowCommand(program) {
1797
2084
  reconciled = mgr.reconcileStatusMd({ dryRun: options.dryRun });
1798
2085
  }
1799
2086
  catch (err) {
1800
- // 写盘失败 → 明确错误 + 非 0 退出
2087
+ // 写盘失败 → 内部错误(D-95-11:原 exit 1 → exit 3,登记为分类补全);非 JSON 文案逐字不变
1801
2088
  const msg = err instanceof Error ? err.message : String(err);
1802
- console.error(t('common.errorTmpl', lang, { msg }));
1803
- process.exit(1);
2089
+ failInternal(err, { stderrLines: [t('common.errorTmpl', lang, { msg })] });
1804
2090
  return;
1805
2091
  }
1806
2092
  }
@@ -1813,11 +2099,8 @@ export function registerFlowCommand(program) {
1813
2099
  ...archiveItems.map((i) => ({ name: i.section, status: i.status, detail: i.message })),
1814
2100
  ...reqItems.map((i) => ({ name: i.section, status: i.status, detail: i.message })),
1815
2101
  ];
1816
- const payload = { schemaVersion: 1, ok: report.ok, items };
1817
- // 与 op-001 的 health --json 合并字段(--fix 时追加 reconciled)
1818
- if (reconciled) {
1819
- payload.reconciled = reconciled;
1820
- }
2102
+ // D-95-02:reconciled 恒在(非 --fix 时 null),位置固定于 items 之后
2103
+ const payload = { schemaVersion: 1, ok: report.ok, items, reconciled: reconciled ?? null };
1821
2104
  console.log(JSON.stringify(payload, null, 2));
1822
2105
  if (!report.ok) {
1823
2106
  process.exit(1);
@@ -1881,14 +2164,42 @@ export function registerFlowCommand(program) {
1881
2164
  flow
1882
2165
  .command('recover')
1883
2166
  .description('跨会话上下文恢复:输出流水线状态、阻塞原因和待处理任务')
1884
- .action(() => {
2167
+ .option('--json', t('help.flow.recover.json', getCliLang(process.cwd())))
2168
+ .action((options) => {
1885
2169
  const lang = getCliLang(process.cwd());
1886
2170
  const mgr = createManager();
1887
2171
  if (!mgr.isLoaded()) {
2172
+ // JSON 分支:未初始化 exit 0 + initialized:false + 空值(镜像人类 exit 0)
2173
+ if (options.json) {
2174
+ emitJson(withSchemaVersion({
2175
+ initialized: false,
2176
+ phase: null,
2177
+ currentOp: null,
2178
+ stageStatus: null,
2179
+ blockedBy: null,
2180
+ pendingTasks: [],
2181
+ stageStats: {},
2182
+ }));
2183
+ return;
2184
+ }
1888
2185
  console.log(t('common.noInit', lang));
1889
2186
  return;
1890
2187
  }
1891
2188
  const recovery = mgr.recoverContext(lang);
2189
+ const stageStats = mgr.getAllStageStats();
2190
+ // JSON 分支(键序:initialized,phase,currentOp,stageStatus,blockedBy,pendingTasks,stageStats)
2191
+ if (options.json) {
2192
+ emitJson(withSchemaVersion({
2193
+ initialized: true,
2194
+ phase: recovery.phase,
2195
+ currentOp: recovery.currentOp,
2196
+ stageStatus: recovery.stageStatus,
2197
+ blockedBy: recovery.blockedBy === '' ? null : recovery.blockedBy,
2198
+ pendingTasks: recovery.pendingTasks,
2199
+ stageStats,
2200
+ }));
2201
+ return;
2202
+ }
1892
2203
  console.log('');
1893
2204
  console.log(t('flow.recover.title', lang));
1894
2205
  console.log('');
@@ -2105,9 +2416,10 @@ export function registerFlowCommand(program) {
2105
2416
  }
2106
2417
  }
2107
2418
  catch (err) {
2108
- // 并发写冲突:统一单点(i18n 文案 + 退出码 2)
2419
+ // 并发写冲突:统一单点(i18n 文案 + 退出码 2);JSON 模式置码返回 → 显式 return 防落入通用分支
2109
2420
  if (isFlowConcurrentError(err)) {
2110
2421
  handleConcurrentConflict(err, lang);
2422
+ return;
2111
2423
  }
2112
2424
  // 失败:统一错误模板 + 非 0 退出(T39)
2113
2425
  const msg = err instanceof Error ? err.message : String(err);
@@ -2152,10 +2464,54 @@ function hasExplicitProjectDefault(projectPath, key) {
2152
2464
  return false;
2153
2465
  }
2154
2466
  }
2467
+ /**
2468
+ * 求「进入 fromPhase 的那次转换」的时间戳(T1 duration 基线;REV-86-002 口径)。
2469
+ * 回扫范围 [0, logLenBefore)——本次命令写入之前的存量条目(天然排除本次新写入);
2470
+ * 按 detail.to === fromPhase 匹配 advance/reset 转换条目;plan_pending 无转换条目时兜底 register_stage;
2471
+ * 无基线返回 null(调用方跳过不记——不写 0 值)。
2472
+ */
2473
+ function findPhaseEnteredAtMs(mgr, stageArg, fromPhase, logLenBefore) {
2474
+ const log = mgr.getData()?.log ?? [];
2475
+ const upper = Math.min(logLenBefore, log.length);
2476
+ for (let k = upper - 1; k >= 0; k--) {
2477
+ const e = log[k];
2478
+ if (e.action !== 'advance_stage_phase' && e.action !== 'reset_stage_phase') {
2479
+ continue;
2480
+ }
2481
+ if (e.detail.stageName !== stageArg || e.detail.to !== fromPhase) {
2482
+ continue;
2483
+ }
2484
+ const ms = Date.parse(e.time);
2485
+ return Number.isNaN(ms) ? null : ms;
2486
+ }
2487
+ if (fromPhase === 'plan_pending') {
2488
+ for (let k = upper - 1; k >= 0; k--) {
2489
+ const e = log[k];
2490
+ if (e.action !== 'register_stage' || e.detail.stageName !== stageArg) {
2491
+ continue;
2492
+ }
2493
+ const ms = Date.parse(e.time);
2494
+ return Number.isNaN(ms) ? null : ms;
2495
+ }
2496
+ }
2497
+ return null;
2498
+ }
2155
2499
  /** 创建 FlowManager 实例(使用当前工作目录) */
2156
2500
  function createManager() {
2157
2501
  return new FlowManager(process.cwd());
2158
2502
  }
2503
+ /**
2504
+ * `--stage` 非法 / 不存在错误的 stderr 两行(逐字保持既有文案与顺序)。
2505
+ * @param lang 语言标识
2506
+ * @param projectPath 项目根
2507
+ * @param input 用户输入的阶段 ID
2508
+ */
2509
+ function stageIdErrorLines(lang, projectPath, input) {
2510
+ return [
2511
+ t('common.stageIdInvalidTmpl', lang, { input }),
2512
+ t('common.stageIdSuggestTmpl', lang, { suggest: suggestStageId(projectPath, input) }),
2513
+ ];
2514
+ }
2159
2515
  /**
2160
2516
  * 断言不存在阻塞中的 open REV(REV 闭环兜底,B5-3 / REV-52-001)。
2161
2517
  * 命中 → 打印列出并返回命中的 REV 列表(调用方决定退出码与文案);无命中 → 空数组。
@@ -2169,8 +2525,8 @@ function assertNoBlockingOpenRev(mgr, stage, lang) {
2169
2525
  return [];
2170
2526
  }
2171
2527
  // REV-007:补 `.` 分隔符(与 core flow-manager.ts 的 stageId + '.' 范式一致),避免 stageId 前缀重叠误拦
2172
- const stageReviews = mgr.getReviewItems().filter((r) => r.op === stage || r.op.startsWith(stage + '.'));
2173
- const blockingOpen = stageReviews.filter((r) => r.blocking !== false && r.status === 'open');
2528
+ // 单点委托:阶段域 + blocking !== false + status === 'open' 判定归核心 listStageBlockingOpenReviews(禁第二份)
2529
+ const blockingOpen = listStageBlockingOpenReviews(mgr.getReviewItems(), stage);
2174
2530
  if (blockingOpen.length > 0) {
2175
2531
  console.warn(t('flow.advance.revBlockedTitleTmpl', lang, { n: String(blockingOpen.length) }));
2176
2532
  for (const rev of blockingOpen) {
@@ -2215,9 +2571,21 @@ function collectOpStates(stage) {
2215
2571
  }
2216
2572
  return result;
2217
2573
  }
2574
+ /**
2575
+ * 由 ops (opId→state) 派生汇总:total = 键数;byState 仅含观测到的状态(无零值键)。
2576
+ * 键序 = 状态首次出现序;空 ops → { total: 0, byState: {} }。
2577
+ */
2578
+ function summarizeOpStates(ops) {
2579
+ const byState = {};
2580
+ for (const state of Object.values(ops)) {
2581
+ byState[state] = (byState[state] ?? 0) + 1;
2582
+ }
2583
+ return { total: Object.keys(ops).length, byState };
2584
+ }
2218
2585
  /**
2219
2586
  * 构建 `flow status --json` 的结构化输出(B1-1)。
2220
2587
  * 顶层对象 + schemaVersion:1;stages 为数组,ops 为 opId→state 映射;counts 为阶段计数。
2588
+ * stage-96 op-002:stages[] 末位追加 5 个调度属性(既有 5 键键序/类型逐字不变)。
2221
2589
  */
2222
2590
  function buildStatusJson(mgr) {
2223
2591
  const data = mgr.getData();
@@ -2229,13 +2597,23 @@ function buildStatusJson(mgr) {
2229
2597
  counts: { total: 0, done: 0, active: 0 },
2230
2598
  };
2231
2599
  }
2232
- const stages = Object.entries(data.stages).map(([id, stage]) => ({
2233
- id,
2234
- phase: stage.phase,
2235
- status: stage.status,
2236
- deps: stage.deps ?? [],
2237
- ops: collectOpStates(stage),
2238
- }));
2600
+ const stages = Object.entries(data.stages).map(([id, stage]) => {
2601
+ const ops = collectOpStates(stage);
2602
+ // 模式单点解析:逐阶段经 resolveStageWorkflowMode(同一 manager 快照;D-96-12 不缓存)
2603
+ const workflowMode = mgr.resolveStageWorkflowMode(id).value;
2604
+ return {
2605
+ id,
2606
+ phase: stage.phase,
2607
+ status: stage.status,
2608
+ deps: stage.deps ?? [],
2609
+ ops,
2610
+ workflowMode,
2611
+ requirementId: stage.requirement_id ?? null,
2612
+ reviewTier: MODE_DERIVED_TIER[workflowMode],
2613
+ effectiveReviewTier: DEFAULT_EFFECTIVE_REVIEW_TIER,
2614
+ opsSummary: summarizeOpStates(ops),
2615
+ };
2616
+ });
2239
2617
  const total = stages.length;
2240
2618
  const done = stages.filter((s) => s.phase === 'done').length;
2241
2619
  return {