@principles/pd-cli 1.133.0 → 1.134.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/dist/commands/__tests__/intent-flag-wiring.test.js +74 -3
  2. package/dist/commands/__tests__/intent-flag-wiring.test.js.map +1 -1
  3. package/dist/commands/__tests__/pain-retry-maxTokens-flag.test.d.ts +2 -0
  4. package/dist/commands/__tests__/pain-retry-maxTokens-flag.test.d.ts.map +1 -0
  5. package/dist/commands/__tests__/pain-retry-maxTokens-flag.test.js +60 -0
  6. package/dist/commands/__tests__/pain-retry-maxTokens-flag.test.js.map +1 -0
  7. package/dist/commands/__tests__/rulecode-flag-wiring.test.js +11 -4
  8. package/dist/commands/__tests__/rulecode-flag-wiring.test.js.map +1 -1
  9. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.js +9 -0
  10. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.js.map +1 -1
  11. package/dist/commands/admission-gate.d.ts +24 -0
  12. package/dist/commands/admission-gate.d.ts.map +1 -0
  13. package/dist/commands/admission-gate.js +29 -0
  14. package/dist/commands/admission-gate.js.map +1 -0
  15. package/dist/commands/candidate.d.ts.map +1 -1
  16. package/dist/commands/candidate.js +72 -6
  17. package/dist/commands/candidate.js.map +1 -1
  18. package/dist/commands/console.d.ts.map +1 -1
  19. package/dist/commands/console.js +40 -0
  20. package/dist/commands/console.js.map +1 -1
  21. package/dist/commands/demo-story-a.d.ts.map +1 -1
  22. package/dist/commands/demo-story-a.js +4 -2
  23. package/dist/commands/demo-story-a.js.map +1 -1
  24. package/dist/commands/diagnose.d.ts.map +1 -1
  25. package/dist/commands/diagnose.js +155 -8
  26. package/dist/commands/diagnose.js.map +1 -1
  27. package/dist/commands/errors-list.d.ts +25 -0
  28. package/dist/commands/errors-list.d.ts.map +1 -0
  29. package/dist/commands/errors-list.js +368 -0
  30. package/dist/commands/errors-list.js.map +1 -0
  31. package/dist/commands/health-codex.d.ts +7 -0
  32. package/dist/commands/health-codex.d.ts.map +1 -0
  33. package/dist/commands/health-codex.js +209 -0
  34. package/dist/commands/health-codex.js.map +1 -0
  35. package/dist/commands/health.d.ts +24 -0
  36. package/dist/commands/health.d.ts.map +1 -1
  37. package/dist/commands/health.js +48 -0
  38. package/dist/commands/health.js.map +1 -1
  39. package/dist/commands/intent.d.ts +21 -4
  40. package/dist/commands/intent.d.ts.map +1 -1
  41. package/dist/commands/intent.js +88 -27
  42. package/dist/commands/intent.js.map +1 -1
  43. package/dist/commands/pain-evidence.d.ts +6 -0
  44. package/dist/commands/pain-evidence.d.ts.map +1 -1
  45. package/dist/commands/pain-evidence.js +6 -0
  46. package/dist/commands/pain-evidence.js.map +1 -1
  47. package/dist/commands/pain-record.d.ts.map +1 -1
  48. package/dist/commands/pain-record.js +21 -0
  49. package/dist/commands/pain-record.js.map +1 -1
  50. package/dist/commands/pain-retry.d.ts +4 -0
  51. package/dist/commands/pain-retry.d.ts.map +1 -1
  52. package/dist/commands/pain-retry.js +327 -32
  53. package/dist/commands/pain-retry.js.map +1 -1
  54. package/dist/commands/proven-channel-baseline.d.ts.map +1 -1
  55. package/dist/commands/proven-channel-baseline.js +4 -2
  56. package/dist/commands/proven-channel-baseline.js.map +1 -1
  57. package/dist/commands/runtime-activation.d.ts +30 -0
  58. package/dist/commands/runtime-activation.d.ts.map +1 -1
  59. package/dist/commands/runtime-activation.js +567 -11
  60. package/dist/commands/runtime-activation.js.map +1 -1
  61. package/dist/commands/runtime-canary.d.ts.map +1 -1
  62. package/dist/commands/runtime-canary.js +32 -7
  63. package/dist/commands/runtime-canary.js.map +1 -1
  64. package/dist/commands/runtime-health-snapshot.d.ts.map +1 -1
  65. package/dist/commands/runtime-health-snapshot.js +4 -2
  66. package/dist/commands/runtime-health-snapshot.js.map +1 -1
  67. package/dist/commands/runtime-init.d.ts +43 -0
  68. package/dist/commands/runtime-init.d.ts.map +1 -0
  69. package/dist/commands/runtime-init.js +264 -0
  70. package/dist/commands/runtime-init.js.map +1 -0
  71. package/dist/commands/runtime-internalization-context-trace.d.ts +25 -0
  72. package/dist/commands/runtime-internalization-context-trace.d.ts.map +1 -0
  73. package/dist/commands/runtime-internalization-context-trace.js +374 -0
  74. package/dist/commands/runtime-internalization-context-trace.js.map +1 -0
  75. package/dist/commands/runtime-internalization-enqueue-successors.d.ts.map +1 -1
  76. package/dist/commands/runtime-internalization-enqueue-successors.js +3 -1
  77. package/dist/commands/runtime-internalization-enqueue-successors.js.map +1 -1
  78. package/dist/commands/runtime-internalization-integrity.d.ts.map +1 -1
  79. package/dist/commands/runtime-internalization-integrity.js +4 -2
  80. package/dist/commands/runtime-internalization-integrity.js.map +1 -1
  81. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  82. package/dist/commands/runtime-internalization-run-once.js +20 -5
  83. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  84. package/dist/commands/runtime-internalization-run-rulehost.d.ts +1 -0
  85. package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -1
  86. package/dist/commands/runtime-internalization-run-rulehost.js +149 -6
  87. package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -1
  88. package/dist/commands/runtime-uat.d.ts.map +1 -1
  89. package/dist/commands/runtime-uat.guard.test.js +13 -15
  90. package/dist/commands/runtime-uat.guard.test.js.map +1 -1
  91. package/dist/commands/runtime-uat.js +4 -3
  92. package/dist/commands/runtime-uat.js.map +1 -1
  93. package/dist/config-reader.js +1 -1
  94. package/dist/config-reader.js.map +1 -1
  95. package/dist/index.js +54 -78
  96. package/dist/index.js.map +1 -1
  97. package/dist/services/__tests__/evaluator-runner-deps.test.d.ts +2 -0
  98. package/dist/services/__tests__/evaluator-runner-deps.test.d.ts.map +1 -0
  99. package/dist/services/__tests__/evaluator-runner-deps.test.js +304 -0
  100. package/dist/services/__tests__/evaluator-runner-deps.test.js.map +1 -0
  101. package/dist/services/__tests__/rulehost-readiness.test.js.map +1 -1
  102. package/dist/services/__tests__/runtime-adapter-resolver.test.js.map +1 -1
  103. package/dist/services/config-doctor.d.ts.map +1 -1
  104. package/dist/services/config-doctor.js +9 -2
  105. package/dist/services/config-doctor.js.map +1 -1
  106. package/dist/services/pain-flood-simulation-runner.d.ts.map +1 -1
  107. package/dist/services/pain-flood-simulation-runner.js +9 -0
  108. package/dist/services/pain-flood-simulation-runner.js.map +1 -1
  109. package/dist/services/pd-config-loader.js +2 -2
  110. package/dist/services/pd-config-loader.js.map +1 -1
  111. package/dist/services/proven-channel-baseline-runner.d.ts.map +1 -1
  112. package/dist/services/proven-channel-baseline-runner.js +10 -2
  113. package/dist/services/proven-channel-baseline-runner.js.map +1 -1
  114. package/dist/services/quality-scorecard/data-extractor.js.map +1 -1
  115. package/dist/services/resolve-runtime-from-pd-config.js.map +1 -1
  116. package/dist/services/rulehost-pipeline-runner.d.ts +77 -1
  117. package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
  118. package/dist/services/rulehost-pipeline-runner.js +111 -7
  119. package/dist/services/rulehost-pipeline-runner.js.map +1 -1
  120. package/dist/services/rulehost-readiness.js.map +1 -1
  121. package/dist/services/runtime-adapter-resolver.d.ts +1 -0
  122. package/dist/services/runtime-adapter-resolver.d.ts.map +1 -1
  123. package/dist/services/runtime-adapter-resolver.js +3 -0
  124. package/dist/services/runtime-adapter-resolver.js.map +1 -1
  125. package/dist/services/synthetic-baseline-runner.d.ts.map +1 -1
  126. package/dist/services/synthetic-baseline-runner.js +11 -0
  127. package/dist/services/synthetic-baseline-runner.js.map +1 -1
  128. package/dist/utils/production-workspace-guard.d.ts +4 -4
  129. package/dist/utils/production-workspace-guard.d.ts.map +1 -1
  130. package/dist/utils/production-workspace-guard.js +19 -16
  131. package/dist/utils/production-workspace-guard.js.map +1 -1
  132. package/dist/utils/production-workspace-guard.test.js +54 -19
  133. package/dist/utils/production-workspace-guard.test.js.map +1 -1
  134. package/package.json +8 -5
  135. package/src/commands/__tests__/intent-flag-wiring.test.ts +88 -3
  136. package/src/commands/__tests__/pain-retry-maxTokens-flag.test.ts +73 -0
  137. package/src/commands/__tests__/rulecode-flag-wiring.test.ts +31 -24
  138. package/src/commands/__tests__/run-rulehost-flag-wiring.test.ts +12 -0
  139. package/src/commands/admission-gate.ts +30 -0
  140. package/src/commands/candidate.ts +74 -6
  141. package/src/commands/console.ts +40 -0
  142. package/src/commands/demo-story-a.ts +4 -2
  143. package/src/commands/diagnose.ts +158 -10
  144. package/src/commands/errors-list.ts +440 -0
  145. package/src/commands/health-codex.ts +249 -0
  146. package/src/commands/health.ts +60 -0
  147. package/src/commands/intent.ts +98 -29
  148. package/src/commands/pain-evidence.ts +6 -0
  149. package/src/commands/pain-record.ts +21 -0
  150. package/src/commands/pain-retry.ts +353 -35
  151. package/src/commands/proven-channel-baseline.ts +4 -2
  152. package/src/commands/runtime-activation.ts +657 -12
  153. package/src/commands/runtime-canary.ts +31 -5
  154. package/src/commands/runtime-health-snapshot.ts +4 -2
  155. package/src/commands/runtime-init.ts +305 -0
  156. package/src/commands/runtime-internalization-context-trace.ts +445 -0
  157. package/src/commands/runtime-internalization-enqueue-successors.ts +3 -1
  158. package/src/commands/runtime-internalization-integrity.ts +4 -2
  159. package/src/commands/runtime-internalization-run-once.ts +20 -5
  160. package/src/commands/runtime-internalization-run-rulehost.ts +159 -6
  161. package/src/commands/runtime-uat.guard.test.ts +14 -16
  162. package/src/commands/runtime-uat.ts +4 -3
  163. package/src/config-reader.ts +1 -1
  164. package/src/index.ts +69 -78
  165. package/src/services/__tests__/evaluator-runner-deps.test.ts +341 -0
  166. package/src/services/config-doctor.ts +9 -2
  167. package/src/services/pain-flood-simulation-runner.ts +9 -0
  168. package/src/services/pd-config-loader.ts +2 -2
  169. package/src/services/proven-channel-baseline-runner.ts +10 -3
  170. package/src/services/rulehost-pipeline-runner.ts +157 -6
  171. package/src/services/runtime-adapter-resolver.ts +4 -0
  172. package/src/services/synthetic-baseline-runner.ts +11 -0
  173. package/src/utils/production-workspace-guard.test.ts +62 -20
  174. package/src/utils/production-workspace-guard.ts +19 -16
  175. package/tests/bdd/cli-contract.steps.ts +343 -0
  176. package/tests/bdd/context-trace.steps.ts +229 -0
  177. package/tests/bdd/support/gherkin-loader.ts +147 -0
  178. package/tests/bdd/support/repo-root.ts +62 -0
  179. package/tests/bdd/support/vitest-bdd.ts +179 -0
  180. package/tests/commands/admission-gate-coverage.test.ts +79 -0
  181. package/tests/commands/candidate-audit-repair.test.ts +77 -0
  182. package/tests/commands/candidate-intake.test.ts +155 -10
  183. package/tests/commands/candidate-internalization-backfill.test.ts +30 -0
  184. package/tests/commands/cli-help-snapshot.test.ts +15 -1
  185. package/tests/commands/console-open.test.ts +19 -0
  186. package/tests/commands/diagnose.test.ts +619 -14
  187. package/tests/commands/errors-list.test.ts +678 -0
  188. package/tests/commands/health-codex.test.ts +243 -0
  189. package/tests/commands/health-host-wiring.test.ts +76 -0
  190. package/tests/commands/intent.test.ts +4 -4
  191. package/tests/commands/pain-record.test.ts +80 -0
  192. package/tests/commands/pain-retry.test.ts +94 -3
  193. package/tests/commands/run-rulehost-handler.test.ts +133 -0
  194. package/tests/commands/runtime-activation-approve-flag-wiring.test.ts +227 -0
  195. package/tests/commands/runtime-activation-deactivate-flag-wiring.test.ts +167 -0
  196. package/tests/commands/runtime-activation-dispatch-flag-wiring.test.ts +262 -0
  197. package/tests/commands/runtime-activation-list-flag-wiring.test.ts +193 -0
  198. package/tests/commands/runtime-activation-promote-flag-wiring.test.ts +222 -0
  199. package/tests/commands/runtime-activation.test.ts +1090 -5
  200. package/tests/commands/runtime-canary.test.ts +50 -0
  201. package/tests/commands/runtime-features.test.ts +2 -0
  202. package/tests/commands/runtime-init-empty-workspace.test.ts +234 -0
  203. package/tests/commands/runtime-init.test.ts +346 -0
  204. package/tests/commands/runtime-internalization-context-trace.test.ts +110 -0
  205. package/tests/commands/runtime-internalization.test.ts +2 -2
  206. package/tests/config-reader.test.ts +6 -5
  207. package/tests/e2e/cross-package-acceptance.test.ts +55 -13
  208. package/tests/services/mainline-snapshot-assembler.test.ts +6 -3
  209. package/tests/services/resolve-runtime-from-pd-config.test.ts +2 -0
  210. package/tests/services/rulehost-pipeline-runner.test.ts +1 -1
  211. package/vitest.config.ts +1 -1
@@ -32,6 +32,27 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
32
32
  console.error('Error: --reason <text> is required');
33
33
  console.error('Usage: pd pain record --reason <text> [--score N] [--source manual] [--workspace <path>] [--session <id>] [--json]');
34
34
  process.exit(1);
35
+ return;
36
+ }
37
+
38
+ // Bug-P fix: enforce reason length limit to prevent oversized data entering the pipeline.
39
+ // Consistent with pain-flood-simulation.ts (MAX_REASON_LENGTH = 500) and synthetic-baseline.ts.
40
+ // CodeRabbit review fix: emit JSON on --json and stop execution after exit (cli-1, cli-2, cli-5).
41
+ const MAX_REASON_LENGTH = 500;
42
+ if (opts.reason.length > MAX_REASON_LENGTH) {
43
+ if (opts.json) {
44
+ console.log(JSON.stringify({
45
+ status: 'failed',
46
+ reason: 'reason_too_long',
47
+ message: `--reason must be at most ${MAX_REASON_LENGTH} characters (got ${opts.reason.length})`,
48
+ nextAction: 'Shorten the reason text or split it into multiple pain records.',
49
+ }, null, 2));
50
+ } else {
51
+ console.error(`Error: --reason must be at most ${MAX_REASON_LENGTH} characters (got ${opts.reason.length})`);
52
+ console.error('Next action: shorten the reason text or split into multiple pain records.');
53
+ }
54
+ process.exit(1);
55
+ return;
35
56
  }
36
57
 
37
58
  if (opts.score !== undefined && (isNaN(opts.score) || opts.score < 0 || opts.score > 100)) {
@@ -36,13 +36,23 @@ import {
36
36
  isFeatureEnabled,
37
37
  SPLIT_PIPELINE_TOTAL_TIMEOUT_MS,
38
38
  PrincipleTreeLedgerAdapter,
39
+ SqliteDeadLetterStore,
40
+ PainSignalBridge,
41
+ type PainDetectedData,
42
+ type PainSignalBridgeResult,
43
+ type DeadLetterRow,
39
44
  } from '@principles/core/runtime-v2';
40
45
  import type { PDRuntimeAdapter, RuntimeConfig, OutputLanguage } from '@principles/core/runtime-v2';
46
+ import type { Command } from 'commander';
41
47
  import { loadPdConfig, computeFlagsFromLoadResult } from '../services/pd-config-loader.js';
42
48
  import { resolveRuntimeFromPdConfig } from '../services/resolve-runtime-from-pd-config.js';
49
+ import { createHash } from 'node:crypto';
50
+ /** Layer 0 content-hash (design §6.1); injected so diag writers can attach predecessorSummary hashes. */
51
+ const contentHashFn = (input: string): string => createHash('sha256').update(input).digest('hex');
43
52
  import type { PDTaskStatus } from '@principles/core/runtime-v2';
44
53
  import { readOutputLanguageFromWorkspace } from '../config-reader.js';
45
54
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
55
+ import { checkAdmissionGate } from './admission-gate.js';
46
56
  import * as path from 'path';
47
57
 
48
58
  // ── Types ──────────────────────────────────────────────────────────────────────
@@ -61,6 +71,7 @@ interface PainRetryOptions {
61
71
  apiKeyEnv?: string;
62
72
  baseUrl?: string;
63
73
  maxRetries?: number;
74
+ maxTokens?: number;
64
75
  timeoutMs?: number;
65
76
  }
66
77
 
@@ -92,8 +103,116 @@ function readNonBlankString(value: unknown): string | null {
92
103
  return typeof value === 'string' && value.trim().length > 0 ? value : null;
93
104
  }
94
105
 
106
+ /**
107
+ * Validate that an unknown value (read from dead_letter_pains.pain_data) has
108
+ * the shape of PainDetectedData.
109
+ *
110
+ * rc-1: Treat parsed JSON as unknown.
111
+ * rc-2: No `as` bypass — use typeof checks and literal narrowing.
112
+ * rc-3: Required fields (painId, painType, source, reason) fail loud.
113
+ * rc-5: Use Object.hasOwn for field presence checks on untrusted objects.
114
+ */
115
+ function parsePainDetectedData(value: unknown):
116
+ | { valid: true; data: PainDetectedData }
117
+ | { valid: false; error: string } {
118
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
119
+ return {
120
+ valid: false,
121
+ error: `painData is not an object (got ${value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value})`,
122
+ };
123
+ }
124
+ const obj = value as Record<string, unknown>;
125
+
126
+ // Detect dead-letter error envelopes from SqliteDeadLetterStore parse/serialize failures.
127
+ if (Object.hasOwn(obj, '__deadLetterParseError') || Object.hasOwn(obj, '__deadLetterSerializeError')) {
128
+ return {
129
+ valid: false,
130
+ error: `painData contains dead-letter error envelope: ${JSON.stringify(obj)}`,
131
+ };
132
+ }
133
+
134
+ // Required string fields
135
+ if (typeof obj.painId !== 'string' || obj.painId.trim().length === 0) {
136
+ return { valid: false, error: `painData.painId is not a non-blank string (got ${JSON.stringify(obj.painId)})` };
137
+ }
138
+ if (typeof obj.source !== 'string') {
139
+ return { valid: false, error: `painData.source is not a string (got ${JSON.stringify(obj.source)})` };
140
+ }
141
+ if (typeof obj.reason !== 'string') {
142
+ return { valid: false, error: `painData.reason is not a string (got ${JSON.stringify(obj.reason)})` };
143
+ }
144
+
145
+ // painType: narrow to literal union without `as`.
146
+ const painTypeRaw = obj.painType;
147
+ if (painTypeRaw !== 'tool_failure' && painTypeRaw !== 'dispatch_error'
148
+ && painTypeRaw !== 'subagent_error' && painTypeRaw !== 'user_frustration') {
149
+ return { valid: false, error: `painData.painType is not a valid pain type (got ${JSON.stringify(painTypeRaw)})` };
150
+ }
151
+ // After the narrowing check above, TypeScript infers painTypeRaw as the literal union.
152
+ const painType: PainDetectedData['painType'] = painTypeRaw === 'dispatch_error' ? 'tool_failure' : painTypeRaw;
153
+
154
+ // Build the typed object field-by-field. Optional fields are added only when present and valid.
155
+ const data: PainDetectedData = {
156
+ painId: obj.painId,
157
+ painType,
158
+ source: obj.source,
159
+ reason: obj.reason,
160
+ };
161
+
162
+ if (Object.hasOwn(obj, 'score')) {
163
+ if (typeof obj.score !== 'number' || !Number.isFinite(obj.score)) {
164
+ return { valid: false, error: `painData.score is not a finite number (got ${JSON.stringify(obj.score)})` };
165
+ }
166
+ data.score = obj.score;
167
+ }
168
+ if (Object.hasOwn(obj, 'sessionId')) {
169
+ if (typeof obj.sessionId !== 'string') return { valid: false, error: 'painData.sessionId is not a string' };
170
+ data.sessionId = obj.sessionId;
171
+ }
172
+ if (Object.hasOwn(obj, 'agentId')) {
173
+ if (typeof obj.agentId !== 'string') return { valid: false, error: 'painData.agentId is not a string' };
174
+ data.agentId = obj.agentId;
175
+ }
176
+ if (Object.hasOwn(obj, 'taskId')) {
177
+ if (typeof obj.taskId !== 'string') return { valid: false, error: 'painData.taskId is not a string' };
178
+ data.taskId = obj.taskId;
179
+ }
180
+ if (Object.hasOwn(obj, 'traceId')) {
181
+ if (typeof obj.traceId !== 'string') return { valid: false, error: 'painData.traceId is not a string' };
182
+ data.traceId = obj.traceId;
183
+ }
184
+ if (Object.hasOwn(obj, 'provenance')) {
185
+ const prov = obj.provenance;
186
+ if (prov !== 'openclaw_context_bound' && prov !== 'owner_reported_no_host_trace' && prov !== 'automatic_hook') {
187
+ return { valid: false, error: `painData.provenance is not a known literal (got ${JSON.stringify(prov)})` };
188
+ }
189
+ data.provenance = prov;
190
+ }
191
+ if (Object.hasOwn(obj, 'evidence')) {
192
+ if (!Array.isArray(obj.evidence)) {
193
+ return { valid: false, error: 'painData.evidence is not an array' };
194
+ }
195
+ // Validate each entry has the PainEvidenceEntry shape { sourceRef: string, note: string }.
196
+ const evidence: { sourceRef: string; note: string }[] = [];
197
+ for (let i = 0; i < obj.evidence.length; i++) {
198
+ const entry = obj.evidence[i];
199
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
200
+ return { valid: false, error: `painData.evidence[${i}] is not an object` };
201
+ }
202
+ const e = entry as Record<string, unknown>;
203
+ if (typeof e.sourceRef !== 'string' || typeof e.note !== 'string') {
204
+ return { valid: false, error: `painData.evidence[${i}] missing string fields sourceRef/note` };
205
+ }
206
+ evidence.push({ sourceRef: e.sourceRef, note: e.note });
207
+ }
208
+ data.evidence = evidence;
209
+ }
210
+
211
+ return { valid: true, data };
212
+ }
213
+
95
214
  /** Output a refused/not_found result, respecting --json mode. Exits with code 1. */
96
- function refuseExit(opts: PainRetryOptions, payload: { status?: string; painId: string; taskId?: string; reason: string; message?: string; nextAction: string }): never {
215
+ function refuseExit(opts: PainRetryOptions, payload: { status?: string; painId: string; taskId?: string; reason: string; message?: string; nextAction: string }): void {
97
216
  if (opts.json) {
98
217
  console.log(JSON.stringify({
99
218
  status: payload.status ?? 'refused',
@@ -117,8 +236,9 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
117
236
 
118
237
  // Step 1: Resolve painId → taskId
119
238
  const resolution = resolveTaskIdFromPainId(opts.painId);
239
+ // eslint-disable-next-line no-restricted-syntax -- 'in' required for discriminated union narrowing (taskId vs reason/nextAction)
120
240
  if ('reason' in resolution) {
121
- refuseExit(opts, { painId: opts.painId, reason: resolution.reason, nextAction: resolution.nextAction });
241
+ return refuseExit(opts, { painId: opts.painId, reason: resolution.reason, nextAction: resolution.nextAction });
122
242
  }
123
243
 
124
244
  const { taskId } = resolution;
@@ -130,19 +250,39 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
130
250
  await stateManager.initialize();
131
251
 
132
252
  const task = await stateManager.getTask(taskId);
253
+
254
+ // Dead letter replay: when no task exists, check if the pain signal was
255
+ // persisted to dead_letter_pains (rc-9: no silent failure in pain.ts).
256
+ // If found, replay it through PainSignalBridge.onPainDetected.
257
+ let deadLetterReplay: { data: PainDetectedData; row: DeadLetterRow } | null = null;
133
258
  if (!task) {
134
- refuseExit(opts, {
135
- status: 'not_found',
136
- painId: opts.painId,
137
- taskId,
138
- reason: 'task_not_found',
139
- message: `No task found for painId '${opts.painId}' (looked for taskId '${taskId}')`,
140
- nextAction: `Verify the painId is correct. Use 'pd task list --kind diagnostician' to see all diagnostician tasks.`,
141
- });
259
+ const dlStore = new SqliteDeadLetterStore(stateManager.connection);
260
+ const dlRow = dlStore.getByPainId(opts.painId);
261
+ if (!dlRow) {
262
+ return refuseExit(opts, {
263
+ status: 'not_found',
264
+ painId: opts.painId,
265
+ taskId,
266
+ reason: 'task_not_found',
267
+ message: `No task found for painId '${opts.painId}' (looked for taskId '${taskId}') and no dead letter found`,
268
+ nextAction: `Verify the painId is correct. Use 'pd task list --kind diagnostician' to see all diagnostician tasks.`,
269
+ });
270
+ }
271
+ const parseResult = parsePainDetectedData(dlRow.painData);
272
+ if (!parseResult.valid) {
273
+ return refuseExit(opts, {
274
+ painId: opts.painId,
275
+ taskId,
276
+ reason: 'dead_letter_corrupt',
277
+ message: `Dead letter for painId '${opts.painId}' has corrupt painData: ${parseResult.error}`,
278
+ nextAction: `The dead letter row cannot be replayed. Inspect the dead_letter_pains table directly: SELECT * FROM dead_letter_pains WHERE pain_id = '${opts.painId}'`,
279
+ });
280
+ }
281
+ deadLetterReplay = { data: parseResult.data, row: dlRow };
142
282
  }
143
283
 
144
- if (task.taskKind !== 'diagnostician') {
145
- refuseExit(opts, {
284
+ if (task && task.taskKind !== 'diagnostician') {
285
+ return refuseExit(opts, {
146
286
  painId: opts.painId,
147
287
  taskId,
148
288
  reason: 'wrong_task_kind',
@@ -151,11 +291,13 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
151
291
  });
152
292
  }
153
293
 
154
- const previousTaskStatus = task.status;
155
- const previousLastError = task.lastError ?? null;
294
+ // For dead letter replay there is no prior task; synthesize a status so the
295
+ // existing logging/output paths have a non-null value to print.
296
+ const previousTaskStatus: PDTaskStatus | 'dead_lettered' = task ? task.status : 'dead_lettered';
297
+ const previousLastError = task ? (task.lastError ?? null) : null;
156
298
 
157
- if (task.status === 'succeeded' && !opts.force) {
158
- refuseExit(opts, {
299
+ if (task && task.status === 'succeeded' && !opts.force) {
300
+ return refuseExit(opts, {
159
301
  painId: opts.painId,
160
302
  taskId,
161
303
  reason: 'already_succeeded',
@@ -164,8 +306,8 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
164
306
  });
165
307
  }
166
308
 
167
- if (!RETRYABLE_STATUSES.has(task.status) && task.status !== 'succeeded') {
168
- refuseExit(opts, {
309
+ if (task && !RETRYABLE_STATUSES.has(task.status) && task.status !== 'succeeded') {
310
+ return refuseExit(opts, {
169
311
  painId: opts.painId,
170
312
  taskId,
171
313
  reason: 'status_not_retryable',
@@ -178,7 +320,7 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
178
320
  // P1 fix: --openclaw-local and --openclaw-gateway are mutually exclusive.
179
321
  // Must output JSON when --json is set (CLI operator gate).
180
322
  if (opts.openclawLocal && opts.openclawGateway) {
181
- refuseExit(opts, {
323
+ return refuseExit(opts, {
182
324
  painId: opts.painId,
183
325
  taskId,
184
326
  reason: 'conflicting_flags',
@@ -200,7 +342,7 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
200
342
  }
201
343
 
202
344
  if (!runtimeKind) {
203
- refuseExit(opts, {
345
+ return refuseExit(opts, {
204
346
  painId: opts.painId,
205
347
  taskId,
206
348
  reason: 'missing_runtime',
@@ -216,14 +358,14 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
216
358
  const resolved = resolveRuntimeFromPdConfig(workspaceDir);
217
359
  const configResult = resolved.result;
218
360
  if (isRuntimeConfigError(configResult)) {
219
- refuseExit(opts, { painId: opts.painId, taskId, reason: configResult.reason, message: configResult.message, nextAction: configResult.nextAction });
361
+ return refuseExit(opts, { painId: opts.painId, taskId, reason: configResult.reason, message: configResult.message, nextAction: configResult.nextAction });
220
362
  }
221
363
  const { openclawMode } = configResult;
222
364
  // CLI flags override config (PRI-393)
223
365
  const flagMode = opts.openclawLocal ? 'local' as const : opts.openclawGateway ? 'gateway' as const : undefined;
224
366
  const effectiveMode = flagMode ?? openclawMode;
225
367
  if (!effectiveMode) {
226
- refuseExit(opts, {
368
+ return refuseExit(opts, {
227
369
  painId: opts.painId,
228
370
  taskId,
229
371
  reason: 'missing_openclaw_mode',
@@ -279,6 +421,7 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
279
421
  const apiKeyEnv = opts.apiKeyEnv ?? policyConfig?.apiKeyEnv;
280
422
  const baseUrl = opts.baseUrl ?? policyConfig?.baseUrl;
281
423
  const maxRetries = opts.maxRetries ?? policyConfig?.maxRetries;
424
+ const maxTokens = opts.maxTokens ?? policyConfig?.maxTokens;
282
425
  const effectiveTimeoutMs = opts.timeoutMs ?? policyConfig?.timeoutMs;
283
426
 
284
427
  // Validate required string fields: must be non-blank strings
@@ -287,7 +430,7 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
287
430
  if (readNonBlankString(model) === null) missing.push('model');
288
431
  if (readNonBlankString(apiKeyEnv) === null) missing.push('apiKeyEnv');
289
432
  if (missing.length > 0) {
290
- refuseExit(opts, {
433
+ return refuseExit(opts, {
291
434
  painId: opts.painId,
292
435
  taskId,
293
436
  reason: `missing_required_config: ${missing.join(', ')}`,
@@ -304,12 +447,15 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
304
447
  if (effectiveTimeoutMs !== undefined && effectiveTimeoutMs !== null && !(Number.isFinite(effectiveTimeoutMs) && effectiveTimeoutMs > 0)) {
305
448
  invalidNumeric.push(`timeoutMs (got: ${effectiveTimeoutMs})`);
306
449
  }
450
+ if (maxTokens !== undefined && maxTokens !== null && !(Number.isFinite(maxTokens) && maxTokens > 0)) {
451
+ invalidNumeric.push(`maxTokens (got: ${maxTokens})`);
452
+ }
307
453
  if (invalidNumeric.length > 0) {
308
- refuseExit(opts, {
454
+ return refuseExit(opts, {
309
455
  painId: opts.painId,
310
456
  taskId,
311
457
  reason: `invalid_numeric_config: ${invalidNumeric.join(', ')}`,
312
- message: `Invalid numeric pi-ai config: ${invalidNumeric.join(', ')}. maxRetries must be a non-negative integer; timeoutMs must be a positive number.`,
458
+ message: `Invalid numeric pi-ai config: ${invalidNumeric.join(', ')}. maxRetries must be a non-negative integer; timeoutMs and maxTokens must be positive numbers.`,
313
459
  nextAction: 'Fix the numeric values and retry.',
314
460
  });
315
461
  }
@@ -319,7 +465,7 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
319
465
  const validModel = readNonBlankString(model);
320
466
  const validApiKeyEnv = readNonBlankString(apiKeyEnv);
321
467
  if (validProvider === null || validModel === null || validApiKeyEnv === null) {
322
- refuseExit(opts, {
468
+ return refuseExit(opts, {
323
469
  painId: opts.painId,
324
470
  taskId,
325
471
  reason: 'internal_validation_error',
@@ -329,7 +475,7 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
329
475
  }
330
476
 
331
477
  if (!process.env[validApiKeyEnv]) {
332
- refuseExit(opts, {
478
+ return refuseExit(opts, {
333
479
  painId: opts.painId,
334
480
  taskId,
335
481
  reason: 'missing_api_key',
@@ -344,11 +490,12 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
344
490
  apiKeyEnv: validApiKeyEnv,
345
491
  baseUrl,
346
492
  maxRetries,
493
+ maxTokens,
347
494
  timeoutMs: effectiveTimeoutMs,
348
495
  workspace: workspaceDir,
349
496
  });
350
497
  } else {
351
- refuseExit(opts, {
498
+ return refuseExit(opts, {
352
499
  painId: opts.painId,
353
500
  taskId,
354
501
  reason: `unknown_runtime: '${runtimeKind}'`,
@@ -378,21 +525,37 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
378
525
  const featureFlags = computeFlagsFromLoadResult(configLoadResult);
379
526
  const isSplitPipeline = isFeatureEnabled(featureFlags, 'diagnostician_split_pipeline');
380
527
  const pipelineTimeoutMs = isSplitPipeline ? SPLIT_PIPELINE_TOTAL_TIMEOUT_MS : 300_000;
528
+ // BUG-1 (PRI-442): extract effectiveConfig so ADR-0019 LLM rate-limit
529
+ // degradation (isDegradationEnabled in base-peer-runner) can read the
530
+ // diagnostician_llm_degradation feature flag. Without this, the runners
531
+ // receive no effectiveConfig and degradation silently never fires.
532
+ // CR-1 (CodeRabbit P2, rc-9): warn when config load failed so the
533
+ // fallback to defaults is observable — no silent degradation.
534
+ if (!configLoadResult.ok) {
535
+ const errSummary = configLoadResult.errors
536
+ .map((e) => `${e.path}: ${e.reason}`)
537
+ .join('; ');
538
+ console.warn(
539
+ `[pd pain retry] .pd/config.yaml at ${configLoadResult.configPath} is malformed — falling back to default feature flags. Errors: ${errSummary}. Next action: ${configLoadResult.errors[0]?.nextAction ?? 'fix config errors and retry'}`,
540
+ );
541
+ }
542
+ const effectiveConfig = configLoadResult.ok ? configLoadResult.effective : configLoadResult.defaults;
381
543
 
382
544
  let runner: DiagnosticianRunnerLike;
383
545
  if (isSplitPipeline) {
384
546
  const resolvedKind = typeof runtimeAdapter.kind === 'function' ? runtimeAdapter.kind() : runtimeKind;
547
+ const perStageTimeoutMs = pipelineTimeoutMs / 3;
385
548
  const rootCauseRunner = new DiagRootCauseRunner(
386
- { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagRootCauseValidator(), contextAssembler },
387
- { owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage },
549
+ { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagRootCauseValidator(), contextAssembler, contentHashFn },
550
+ { owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
388
551
  );
389
552
  const distillerRunner = new DiagDistillerRunner(
390
- { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagDistillerValidator() },
391
- { owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage },
553
+ { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagDistillerValidator(), contentHashFn },
554
+ { owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
392
555
  );
393
556
  const routerRunner = new DiagRouterRunner(
394
- { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, committer },
395
- { owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage },
557
+ { stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, committer, contentHashFn },
558
+ { owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
396
559
  );
397
560
 
398
561
  runner = new SplitDiagnosticianRunner({
@@ -401,12 +564,120 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
401
564
  routerRunner,
402
565
  stateManager,
403
566
  committer,
404
- perStageTimeoutMs: pipelineTimeoutMs / 3,
567
+ perStageTimeoutMs,
405
568
  });
406
569
  } else {
407
570
  runner = new DisabledDiagnosticianRunner();
408
571
  }
409
572
 
573
+ // ── Dead letter replay branch ──────────────────────────────────────────
574
+ // When task was null but a dead letter was found, replay the pain signal
575
+ // through PainSignalBridge.onPainDetected instead of calling diagnoseRun.
576
+ // On success/failure, markRetried updates the dead_letter_pains row.
577
+ if (deadLetterReplay) {
578
+ const dlStore = new SqliteDeadLetterStore(stateManager.connection);
579
+ if (!opts.json) {
580
+ console.log(`\nReplaying dead letter pain: ${opts.painId}`);
581
+ console.log(` Task ID: ${taskId}`);
582
+ console.log(` Dead Letter ID: ${deadLetterReplay.row.id}`);
583
+ console.log(` Failed At: ${deadLetterReplay.row.failedAt}`);
584
+ console.log(` Retry Count: ${deadLetterReplay.row.retryCount}`);
585
+ console.log(` Runtime: ${runtimeKind}`);
586
+ console.log(` Workspace: ${workspaceDir}\n`);
587
+ }
588
+
589
+ const ledgerAdapter = new PrincipleTreeLedgerAdapter({ stateDir: path.join(workspaceDir, '.state') });
590
+ const intakeService = new CandidateIntakeService({ stateManager, ledgerAdapter });
591
+ const bridge = new PainSignalBridge({
592
+ stateManager,
593
+ runner,
594
+ intakeService,
595
+ ledgerAdapter,
596
+ owner: 'pd-cli-pain-retry-dead-letter',
597
+ workspaceDir,
598
+ });
599
+
600
+ let bridgeResult: PainSignalBridgeResult;
601
+ try {
602
+ bridgeResult = await bridge.onPainDetected(deadLetterReplay.data);
603
+ } catch (err: unknown) {
604
+ const errorMessage = err instanceof Error ? err.message : String(err);
605
+ const markFail = dlStore.markRetried(opts.painId, false);
606
+ if (opts.json) {
607
+ console.log(JSON.stringify({
608
+ status: 'failed',
609
+ painId: opts.painId,
610
+ taskId,
611
+ source: 'dead_letter',
612
+ deadLetterId: deadLetterReplay.row.id,
613
+ runtimeKind,
614
+ errorCategory: 'execution_failed',
615
+ message: errorMessage,
616
+ markRetriedOk: markFail.ok,
617
+ nextAction: 'Replay threw; retry_count incremented. Adjust parameters and run pd pain retry --pain-id again.',
618
+ }, null, 2));
619
+ } else {
620
+ console.error(`error: ${errorMessage}`);
621
+ console.error(`nextAction: Replay threw; retry_count incremented. Adjust parameters and try again.`);
622
+ }
623
+ process.exit(1);
624
+ return;
625
+ }
626
+
627
+ const success = bridgeResult.status === 'succeeded';
628
+ const markResult = dlStore.markRetried(opts.painId, success);
629
+
630
+ if (opts.json) {
631
+ // cli-1-strict-json: exactly one parseable JSON object on stdout.
632
+ console.log(JSON.stringify({
633
+ status: success ? 'succeeded' : 'failed',
634
+ painId: opts.painId,
635
+ taskId,
636
+ source: 'dead_letter',
637
+ deadLetterId: deadLetterReplay.row.id,
638
+ runtimeKind,
639
+ bridgeStatus: bridgeResult.status,
640
+ candidateIds: bridgeResult.candidateIds,
641
+ ledgerEntryIds: bridgeResult.ledgerEntryIds,
642
+ markRetriedOk: markResult.ok,
643
+ message: bridgeResult.message ?? null,
644
+ nextAction: success
645
+ ? (bridgeResult.candidateIds.length > 0
646
+ ? `Dead letter replayed. Internalize candidates:\n ${bridgeResult.candidateIds.map((id) => `pd candidate internalize --candidate-id ${id} --workspace "${workspaceDir}"`).join('\n ')}`
647
+ : 'Dead letter replayed. No candidates generated.')
648
+ : `Replay did not succeed (status=${bridgeResult.status}). The dead letter remains available for future retry.`,
649
+ }, null, 2));
650
+ if (!success) {
651
+ process.exit(1);
652
+ }
653
+ return;
654
+ }
655
+
656
+ // Text output
657
+ console.log(`\nReplay ${success ? 'succeeded' : 'failed'}:`);
658
+ console.log(` Pain ID: ${opts.painId}`);
659
+ console.log(` Task ID: ${taskId}`);
660
+ console.log(` Dead Letter ID: ${deadLetterReplay.row.id}`);
661
+ console.log(` Bridge Status: ${bridgeResult.status}`);
662
+ if (bridgeResult.message) {
663
+ console.log(` Message: ${bridgeResult.message}`);
664
+ }
665
+ if (success) {
666
+ console.log(` Candidates: ${bridgeResult.candidateIds.length}`);
667
+ if (bridgeResult.candidateIds.length > 0) {
668
+ console.log(` Ledger Entries: ${bridgeResult.ledgerEntryIds.length}`);
669
+ }
670
+ }
671
+ console.log(` Mark Retried: ${markResult.ok ? 'ok' : `FAILED: ${markResult.error}`}`);
672
+ if (!success) {
673
+ console.log(`\n Next Action:`);
674
+ console.log(` Replay did not succeed. The dead letter remains available for future retry.`);
675
+ process.exit(1);
676
+ }
677
+ return;
678
+ }
679
+
680
+ // ── Normal retry branch ─────────────────────────────────────────────────
410
681
  if (!opts.json) {
411
682
  console.log(`\nRetrying diagnosis for pain: ${opts.painId}`);
412
683
  console.log(` Task ID: ${taskId}`);
@@ -463,6 +734,23 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
463
734
  const intakeService = new CandidateIntakeService({ stateManager, ledgerAdapter });
464
735
 
465
736
  for (const candidate of candidates) {
737
+ // PRI-503: admission gate check — refuse non-admitted candidates before
738
+ // intake, mirroring candidate.ts (intake/repair/backfill). Without this,
739
+ // `pd pain retry` could bypass the gate and intake unreviewed candidates.
740
+ // cli-5-failure-no-mutation: refusal path does not call intake(), does
741
+ // not update candidate status, does not write ledger.
742
+ // cli-6-output-next-action: refusal result carries admissionBlock.nextAction.
743
+ const admissionBlock = checkAdmissionGate(candidate);
744
+ if (admissionBlock) {
745
+ intakeFailed = true;
746
+ intakeResults.push({
747
+ candidateId: candidate.candidateId,
748
+ status: 'intake_failed',
749
+ error: `Admission gate refused: ${admissionBlock.reason}`,
750
+ nextAction: admissionBlock.nextAction,
751
+ });
752
+ continue;
753
+ }
466
754
  try {
467
755
  const entry = await intakeService.intake(candidate.candidateId);
468
756
  if (candidate.status !== 'consumed') {
@@ -548,6 +836,7 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
548
836
 
549
837
  if (intakeFailed) {
550
838
  process.exit(1);
839
+ return;
551
840
  }
552
841
  } catch (error: unknown) {
553
842
  const message = error instanceof Error ? error.message : String(error);
@@ -568,7 +857,36 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
568
857
  console.error(`error: ${message} (${errorCategory})`);
569
858
  }
570
859
  process.exit(1);
860
+ return;
571
861
  } finally {
572
862
  await stateManager.close();
573
863
  }
574
864
  }
865
+
866
+ /** Register the production `pd pain retry` command and its complete option contract. */
867
+ export function registerPainRetryCommand(
868
+ painCmd: Command,
869
+ handler: (opts: PainRetryOptions) => Promise<void> = handlePainRetry,
870
+ ): Command {
871
+ return painCmd
872
+ .command('retry')
873
+ .description('Retry a failed diagnosis by pain ID')
874
+ .requiredOption('-p, --pain-id <painId>', 'Pain ID to retry diagnosis for')
875
+ .option('-w, --workspace <path>', 'Workspace directory')
876
+ .option('-r, --runtime <kind>', "Runtime kind: 'openclaw-cli', 'test-double', 'pi-ai'")
877
+ .option('--openclaw-local', 'Use local OpenClaw (mutually exclusive with --openclaw-gateway)')
878
+ .option('--openclaw-gateway', 'Use gateway OpenClaw (mutually exclusive with --openclaw-local)')
879
+ .option('-a, --agent <agentId>', 'Agent ID to invoke')
880
+ .option('--provider <name>', 'LLM provider (e.g., openrouter) — for pi-ai, falls back to policy')
881
+ .option('--model <id>', 'Model ID (e.g., anthropic/claude-sonnet-4) — for pi-ai, falls back to policy')
882
+ .option('--apiKeyEnv <name>', 'Env var name for API key — for pi-ai, falls back to policy')
883
+ .option('--baseUrl <url>', 'Custom base URL — for pi-ai, falls back to policy')
884
+ .option('--maxRetries <n>', 'Max retry attempts for LLM failures — for pi-ai, falls back to policy', parseInt)
885
+ .option('--maxTokens <n>', 'Max output tokens (max_tokens) for pi-ai LLM calls — for pi-ai, falls back to policy', parseInt)
886
+ .option('--timeoutMs <ms>', 'Timeout in milliseconds — for pi-ai, falls back to policy', parseInt)
887
+ .option('--force', 'Allow retry of already-succeeded tasks')
888
+ .option('--json', 'Output raw JSON')
889
+ .action(async (opts: PainRetryOptions) => {
890
+ await handler(opts);
891
+ });
892
+ }
@@ -101,8 +101,10 @@ export async function handleProvenChannelBaseline(opts: ProvenChannelBaselineCli
101
101
  }
102
102
 
103
103
  if (summary.status !== 'passed') {
104
- console.error('');
105
- console.error(`FAIL: status=${summary.status}`);
104
+ if (!opts.json) {
105
+ console.error('');
106
+ console.error(`FAIL: status=${summary.status}`);
107
+ }
106
108
  process.exitCode = 1;
107
109
  }
108
110
  } finally {