@principles/pd-cli 1.73.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 (298) hide show
  1. package/README.md +90 -0
  2. package/dist/commands/artifact.d.ts +14 -0
  3. package/dist/commands/artifact.d.ts.map +1 -0
  4. package/dist/commands/artifact.js +67 -0
  5. package/dist/commands/artifact.js.map +1 -0
  6. package/dist/commands/candidate.d.ts +83 -0
  7. package/dist/commands/candidate.d.ts.map +1 -0
  8. package/dist/commands/candidate.js +891 -0
  9. package/dist/commands/candidate.js.map +1 -0
  10. package/dist/commands/central-sync.d.ts +10 -0
  11. package/dist/commands/central-sync.d.ts.map +1 -0
  12. package/dist/commands/central-sync.js +32 -0
  13. package/dist/commands/central-sync.js.map +1 -0
  14. package/dist/commands/console.d.ts +9 -0
  15. package/dist/commands/console.d.ts.map +1 -0
  16. package/dist/commands/console.js +114 -0
  17. package/dist/commands/console.js.map +1 -0
  18. package/dist/commands/context.d.ts +7 -0
  19. package/dist/commands/context.d.ts.map +1 -0
  20. package/dist/commands/context.js +55 -0
  21. package/dist/commands/context.js.map +1 -0
  22. package/dist/commands/demo-story-a.d.ts +12 -0
  23. package/dist/commands/demo-story-a.d.ts.map +1 -0
  24. package/dist/commands/demo-story-a.js +175 -0
  25. package/dist/commands/demo-story-a.js.map +1 -0
  26. package/dist/commands/diagnose.d.ts +35 -0
  27. package/dist/commands/diagnose.d.ts.map +1 -0
  28. package/dist/commands/diagnose.js +390 -0
  29. package/dist/commands/diagnose.js.map +1 -0
  30. package/dist/commands/evolution-tasks-list.d.ts +15 -0
  31. package/dist/commands/evolution-tasks-list.d.ts.map +1 -0
  32. package/dist/commands/evolution-tasks-list.js +34 -0
  33. package/dist/commands/evolution-tasks-list.js.map +1 -0
  34. package/dist/commands/evolution-tasks-show.d.ts +14 -0
  35. package/dist/commands/evolution-tasks-show.d.ts.map +1 -0
  36. package/dist/commands/evolution-tasks-show.js +52 -0
  37. package/dist/commands/evolution-tasks-show.js.map +1 -0
  38. package/dist/commands/flow.d.ts +7 -0
  39. package/dist/commands/flow.d.ts.map +1 -0
  40. package/dist/commands/flow.js +57 -0
  41. package/dist/commands/flow.js.map +1 -0
  42. package/dist/commands/health.d.ts +16 -0
  43. package/dist/commands/health.d.ts.map +1 -0
  44. package/dist/commands/health.js +150 -0
  45. package/dist/commands/health.js.map +1 -0
  46. package/dist/commands/history.d.ts +11 -0
  47. package/dist/commands/history.d.ts.map +1 -0
  48. package/dist/commands/history.js +50 -0
  49. package/dist/commands/history.js.map +1 -0
  50. package/dist/commands/legacy-cleanup.d.ts +27 -0
  51. package/dist/commands/legacy-cleanup.d.ts.map +1 -0
  52. package/dist/commands/legacy-cleanup.js +171 -0
  53. package/dist/commands/legacy-cleanup.js.map +1 -0
  54. package/dist/commands/legacy-import.d.ts +7 -0
  55. package/dist/commands/legacy-import.d.ts.map +1 -0
  56. package/dist/commands/legacy-import.js +86 -0
  57. package/dist/commands/legacy-import.js.map +1 -0
  58. package/dist/commands/pain-record.d.ts +10 -0
  59. package/dist/commands/pain-record.d.ts.map +1 -0
  60. package/dist/commands/pain-record.js +162 -0
  61. package/dist/commands/pain-record.js.map +1 -0
  62. package/dist/commands/proven-channel-baseline.d.ts +12 -0
  63. package/dist/commands/proven-channel-baseline.d.ts.map +1 -0
  64. package/dist/commands/proven-channel-baseline.js +97 -0
  65. package/dist/commands/proven-channel-baseline.js.map +1 -0
  66. package/dist/commands/remediation-output.d.ts +40 -0
  67. package/dist/commands/remediation-output.d.ts.map +1 -0
  68. package/dist/commands/remediation-output.js +23 -0
  69. package/dist/commands/remediation-output.js.map +1 -0
  70. package/dist/commands/run.d.ts +10 -0
  71. package/dist/commands/run.d.ts.map +1 -0
  72. package/dist/commands/run.js +68 -0
  73. package/dist/commands/run.js.map +1 -0
  74. package/dist/commands/runtime-activation.d.ts +11 -0
  75. package/dist/commands/runtime-activation.d.ts.map +1 -0
  76. package/dist/commands/runtime-activation.js +150 -0
  77. package/dist/commands/runtime-activation.js.map +1 -0
  78. package/dist/commands/runtime-canary.d.ts +30 -0
  79. package/dist/commands/runtime-canary.d.ts.map +1 -0
  80. package/dist/commands/runtime-canary.js +343 -0
  81. package/dist/commands/runtime-canary.js.map +1 -0
  82. package/dist/commands/runtime-diagnostics-export.d.ts +20 -0
  83. package/dist/commands/runtime-diagnostics-export.d.ts.map +1 -0
  84. package/dist/commands/runtime-diagnostics-export.js +177 -0
  85. package/dist/commands/runtime-diagnostics-export.js.map +1 -0
  86. package/dist/commands/runtime-features.d.ts +26 -0
  87. package/dist/commands/runtime-features.d.ts.map +1 -0
  88. package/dist/commands/runtime-features.js +70 -0
  89. package/dist/commands/runtime-features.js.map +1 -0
  90. package/dist/commands/runtime-gfi-snapshot.d.ts +7 -0
  91. package/dist/commands/runtime-gfi-snapshot.d.ts.map +1 -0
  92. package/dist/commands/runtime-gfi-snapshot.js +101 -0
  93. package/dist/commands/runtime-gfi-snapshot.js.map +1 -0
  94. package/dist/commands/runtime-health-snapshot.d.ts +7 -0
  95. package/dist/commands/runtime-health-snapshot.d.ts.map +1 -0
  96. package/dist/commands/runtime-health-snapshot.js +93 -0
  97. package/dist/commands/runtime-health-snapshot.js.map +1 -0
  98. package/dist/commands/runtime-idle-trigger.d.ts +12 -0
  99. package/dist/commands/runtime-idle-trigger.d.ts.map +1 -0
  100. package/dist/commands/runtime-idle-trigger.js +102 -0
  101. package/dist/commands/runtime-idle-trigger.js.map +1 -0
  102. package/dist/commands/runtime-internalization-enqueue-successors.d.ts +9 -0
  103. package/dist/commands/runtime-internalization-enqueue-successors.d.ts.map +1 -0
  104. package/dist/commands/runtime-internalization-enqueue-successors.js +393 -0
  105. package/dist/commands/runtime-internalization-enqueue-successors.js.map +1 -0
  106. package/dist/commands/runtime-internalization-integrity-repair.d.ts +9 -0
  107. package/dist/commands/runtime-internalization-integrity-repair.d.ts.map +1 -0
  108. package/dist/commands/runtime-internalization-integrity-repair.js +54 -0
  109. package/dist/commands/runtime-internalization-integrity-repair.js.map +1 -0
  110. package/dist/commands/runtime-internalization-integrity.d.ts +7 -0
  111. package/dist/commands/runtime-internalization-integrity.d.ts.map +1 -0
  112. package/dist/commands/runtime-internalization-integrity.js +53 -0
  113. package/dist/commands/runtime-internalization-integrity.js.map +1 -0
  114. package/dist/commands/runtime-internalization-queue.d.ts +7 -0
  115. package/dist/commands/runtime-internalization-queue.d.ts.map +1 -0
  116. package/dist/commands/runtime-internalization-queue.js +85 -0
  117. package/dist/commands/runtime-internalization-queue.js.map +1 -0
  118. package/dist/commands/runtime-internalization-run-once.d.ts +12 -0
  119. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -0
  120. package/dist/commands/runtime-internalization-run-once.js +546 -0
  121. package/dist/commands/runtime-internalization-run-once.js.map +1 -0
  122. package/dist/commands/runtime-internalization-wake-once.d.ts +8 -0
  123. package/dist/commands/runtime-internalization-wake-once.d.ts.map +1 -0
  124. package/dist/commands/runtime-internalization-wake-once.js +72 -0
  125. package/dist/commands/runtime-internalization-wake-once.js.map +1 -0
  126. package/dist/commands/runtime-pain-flood-simulation.d.ts +10 -0
  127. package/dist/commands/runtime-pain-flood-simulation.d.ts.map +1 -0
  128. package/dist/commands/runtime-pain-flood-simulation.js +104 -0
  129. package/dist/commands/runtime-pain-flood-simulation.js.map +1 -0
  130. package/dist/commands/runtime-pruning.d.ts +45 -0
  131. package/dist/commands/runtime-pruning.d.ts.map +1 -0
  132. package/dist/commands/runtime-pruning.js +355 -0
  133. package/dist/commands/runtime-pruning.js.map +1 -0
  134. package/dist/commands/runtime-recovery.d.ts +9 -0
  135. package/dist/commands/runtime-recovery.d.ts.map +1 -0
  136. package/dist/commands/runtime-recovery.js +94 -0
  137. package/dist/commands/runtime-recovery.js.map +1 -0
  138. package/dist/commands/runtime-synthetic-baseline.d.ts +7 -0
  139. package/dist/commands/runtime-synthetic-baseline.d.ts.map +1 -0
  140. package/dist/commands/runtime-synthetic-baseline.js +59 -0
  141. package/dist/commands/runtime-synthetic-baseline.js.map +1 -0
  142. package/dist/commands/runtime-uat.d.ts +52 -0
  143. package/dist/commands/runtime-uat.d.ts.map +1 -0
  144. package/dist/commands/runtime-uat.js +274 -0
  145. package/dist/commands/runtime-uat.js.map +1 -0
  146. package/dist/commands/runtime.d.ts +20 -0
  147. package/dist/commands/runtime.d.ts.map +1 -0
  148. package/dist/commands/runtime.js +256 -0
  149. package/dist/commands/runtime.js.map +1 -0
  150. package/dist/commands/samples-list.d.ts +11 -0
  151. package/dist/commands/samples-list.d.ts.map +1 -0
  152. package/dist/commands/samples-list.js +37 -0
  153. package/dist/commands/samples-list.js.map +1 -0
  154. package/dist/commands/samples-review.d.ts +14 -0
  155. package/dist/commands/samples-review.d.ts.map +1 -0
  156. package/dist/commands/samples-review.js +22 -0
  157. package/dist/commands/samples-review.js.map +1 -0
  158. package/dist/commands/task.d.ts +14 -0
  159. package/dist/commands/task.d.ts.map +1 -0
  160. package/dist/commands/task.js +92 -0
  161. package/dist/commands/task.js.map +1 -0
  162. package/dist/commands/trace.d.ts +19 -0
  163. package/dist/commands/trace.d.ts.map +1 -0
  164. package/dist/commands/trace.js +154 -0
  165. package/dist/commands/trace.js.map +1 -0
  166. package/dist/commands/trajectory.d.ts +11 -0
  167. package/dist/commands/trajectory.d.ts.map +1 -0
  168. package/dist/commands/trajectory.js +47 -0
  169. package/dist/commands/trajectory.js.map +1 -0
  170. package/dist/index.d.ts +9 -0
  171. package/dist/index.d.ts.map +1 -0
  172. package/dist/index.js +736 -0
  173. package/dist/index.js.map +1 -0
  174. package/dist/legacy/legacy-import.d.ts +15 -0
  175. package/dist/legacy/legacy-import.d.ts.map +1 -0
  176. package/dist/legacy/legacy-import.js +141 -0
  177. package/dist/legacy/legacy-import.js.map +1 -0
  178. package/dist/legacy/session-history-import.d.ts +26 -0
  179. package/dist/legacy/session-history-import.d.ts.map +1 -0
  180. package/dist/legacy/session-history-import.js +151 -0
  181. package/dist/legacy/session-history-import.js.map +1 -0
  182. package/dist/principle-tree-ledger-adapter.d.ts +12 -0
  183. package/dist/principle-tree-ledger-adapter.d.ts.map +1 -0
  184. package/dist/principle-tree-ledger-adapter.js +12 -0
  185. package/dist/principle-tree-ledger-adapter.js.map +1 -0
  186. package/dist/resolve-workspace.d.ts +12 -0
  187. package/dist/resolve-workspace.d.ts.map +1 -0
  188. package/dist/resolve-workspace.js +20 -0
  189. package/dist/resolve-workspace.js.map +1 -0
  190. package/dist/services/demo-story-a-runner.d.ts +8 -0
  191. package/dist/services/demo-story-a-runner.d.ts.map +1 -0
  192. package/dist/services/demo-story-a-runner.js +369 -0
  193. package/dist/services/demo-story-a-runner.js.map +1 -0
  194. package/dist/services/feature-flag-loader.d.ts +6 -0
  195. package/dist/services/feature-flag-loader.d.ts.map +1 -0
  196. package/dist/services/feature-flag-loader.js +54 -0
  197. package/dist/services/feature-flag-loader.js.map +1 -0
  198. package/dist/services/pain-flood-simulation-runner.d.ts +10 -0
  199. package/dist/services/pain-flood-simulation-runner.d.ts.map +1 -0
  200. package/dist/services/pain-flood-simulation-runner.js +289 -0
  201. package/dist/services/pain-flood-simulation-runner.js.map +1 -0
  202. package/dist/services/proven-channel-baseline-runner.d.ts +12 -0
  203. package/dist/services/proven-channel-baseline-runner.d.ts.map +1 -0
  204. package/dist/services/proven-channel-baseline-runner.js +114 -0
  205. package/dist/services/proven-channel-baseline-runner.js.map +1 -0
  206. package/dist/services/synthetic-baseline-runner.d.ts +8 -0
  207. package/dist/services/synthetic-baseline-runner.d.ts.map +1 -0
  208. package/dist/services/synthetic-baseline-runner.js +251 -0
  209. package/dist/services/synthetic-baseline-runner.js.map +1 -0
  210. package/package.json +35 -0
  211. package/src/commands/artifact.ts +82 -0
  212. package/src/commands/candidate.ts +1117 -0
  213. package/src/commands/central-sync.ts +44 -0
  214. package/src/commands/console.ts +121 -0
  215. package/src/commands/context.ts +72 -0
  216. package/src/commands/demo-story-a.ts +195 -0
  217. package/src/commands/diagnose.ts +452 -0
  218. package/src/commands/evolution-tasks-list.ts +44 -0
  219. package/src/commands/evolution-tasks-show.ts +60 -0
  220. package/src/commands/flow.ts +60 -0
  221. package/src/commands/health.ts +189 -0
  222. package/src/commands/history.ts +63 -0
  223. package/src/commands/legacy-cleanup.ts +206 -0
  224. package/src/commands/legacy-import.ts +104 -0
  225. package/src/commands/pain-record.ts +167 -0
  226. package/src/commands/proven-channel-baseline.ts +113 -0
  227. package/src/commands/remediation-output.ts +66 -0
  228. package/src/commands/run.ts +89 -0
  229. package/src/commands/runtime-activation.ts +176 -0
  230. package/src/commands/runtime-canary.ts +371 -0
  231. package/src/commands/runtime-diagnostics-export.ts +229 -0
  232. package/src/commands/runtime-features.ts +103 -0
  233. package/src/commands/runtime-gfi-snapshot.ts +135 -0
  234. package/src/commands/runtime-health-snapshot.ts +106 -0
  235. package/src/commands/runtime-internalization-enqueue-successors.ts +479 -0
  236. package/src/commands/runtime-internalization-integrity-repair.ts +69 -0
  237. package/src/commands/runtime-internalization-integrity.ts +63 -0
  238. package/src/commands/runtime-internalization-queue.ts +106 -0
  239. package/src/commands/runtime-internalization-run-once.ts +658 -0
  240. package/src/commands/runtime-internalization-wake-once.ts +87 -0
  241. package/src/commands/runtime-pain-flood-simulation.ts +121 -0
  242. package/src/commands/runtime-pruning.ts +438 -0
  243. package/src/commands/runtime-recovery.ts +107 -0
  244. package/src/commands/runtime-synthetic-baseline.ts +70 -0
  245. package/src/commands/runtime-uat.ts +339 -0
  246. package/src/commands/runtime.ts +281 -0
  247. package/src/commands/samples-list.ts +43 -0
  248. package/src/commands/samples-review.ts +32 -0
  249. package/src/commands/task.ts +130 -0
  250. package/src/commands/trace.ts +174 -0
  251. package/src/commands/trajectory.ts +64 -0
  252. package/src/index.ts +829 -0
  253. package/src/legacy/legacy-import.ts +179 -0
  254. package/src/legacy/session-history-import.ts +231 -0
  255. package/src/principle-tree-ledger-adapter.ts +13 -0
  256. package/src/resolve-workspace.ts +20 -0
  257. package/src/services/demo-story-a-runner.ts +472 -0
  258. package/src/services/feature-flag-loader.ts +73 -0
  259. package/src/services/pain-flood-simulation-runner.ts +354 -0
  260. package/src/services/proven-channel-baseline-runner.ts +150 -0
  261. package/src/services/synthetic-baseline-runner.ts +291 -0
  262. package/tests/commands/candidate-audit-repair.test.ts +338 -0
  263. package/tests/commands/candidate-intake.test.ts +589 -0
  264. package/tests/commands/candidate-internalization-backfill.test.ts +480 -0
  265. package/tests/commands/candidate-internalize.test.ts +272 -0
  266. package/tests/commands/candidate-route.test.ts +328 -0
  267. package/tests/commands/candidate-show.test.ts +95 -0
  268. package/tests/commands/cli-command-tree.test.ts +64 -0
  269. package/tests/commands/context.test.ts +114 -0
  270. package/tests/commands/demo-story-a.test.ts +255 -0
  271. package/tests/commands/diagnose.test.ts +792 -0
  272. package/tests/commands/health.test.ts +330 -0
  273. package/tests/commands/pain-record.test.ts +316 -0
  274. package/tests/commands/plugin-config-resolution-cutover.test.ts +220 -0
  275. package/tests/commands/proven-channel-baseline.test.ts +441 -0
  276. package/tests/commands/runtime-activation.test.ts +168 -0
  277. package/tests/commands/runtime-canary.test.ts +369 -0
  278. package/tests/commands/runtime-diagnostics-export.test.ts +170 -0
  279. package/tests/commands/runtime-features.test.ts +114 -0
  280. package/tests/commands/runtime-health-snapshot.test.ts +357 -0
  281. package/tests/commands/runtime-internalization-enqueue-successors.test.ts +803 -0
  282. package/tests/commands/runtime-internalization-integrity-repair.test.ts +169 -0
  283. package/tests/commands/runtime-internalization-integrity.test.ts +102 -0
  284. package/tests/commands/runtime-internalization-queue.test.ts +252 -0
  285. package/tests/commands/runtime-internalization-run-once.test.ts +1318 -0
  286. package/tests/commands/runtime-internalization-wake-once.test.ts +170 -0
  287. package/tests/commands/runtime-internalization.test.ts +52 -0
  288. package/tests/commands/runtime-pain-flood-simulation.test.ts +418 -0
  289. package/tests/commands/runtime-pruning.test.ts +693 -0
  290. package/tests/commands/runtime-recovery.test.ts +96 -0
  291. package/tests/commands/runtime-synthetic-baseline.test.ts +249 -0
  292. package/tests/commands/runtime-uat.test.ts +397 -0
  293. package/tests/commands/runtime.test.ts +262 -0
  294. package/tests/commands/trace.test.ts +314 -0
  295. package/tests/e2e/candidate-intake-e2e.test.ts +316 -0
  296. package/tests/services/feature-flag-loader.test.ts +207 -0
  297. package/tests/services/proven-channel-baseline-runner.test.ts +30 -0
  298. package/tsconfig.json +26 -0
@@ -0,0 +1,330 @@
1
+ /**
2
+ * pd health command unit tests.
3
+ *
4
+ * Tests the health command's external contract via mocked read models.
5
+ * Tests JSON/text output formatting, exit code behavior, and error handling.
6
+ * Covers both the no-database path and the state.db path (PainChainReadModel).
7
+ */
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
+
10
+ const {
11
+ mockPruningGetHealthSummary,
12
+ mockAuditCandidateLedgerConsistency,
13
+ mockGetLastSuccessfulChain,
14
+ mockPainChainClose,
15
+ mockExistsSync,
16
+ mockDbPrepare,
17
+ mockDbAll,
18
+ mockDbClose,
19
+ } = vi.hoisted(() => {
20
+ return {
21
+ mockPruningGetHealthSummary: vi.fn(),
22
+ mockAuditCandidateLedgerConsistency: vi.fn(),
23
+ mockGetLastSuccessfulChain: vi.fn(),
24
+ mockPainChainClose: vi.fn().mockResolvedValue(undefined),
25
+ mockExistsSync: vi.fn(),
26
+ mockDbPrepare: vi.fn(),
27
+ mockDbAll: vi.fn(),
28
+ mockDbClose: vi.fn(),
29
+ };
30
+ });
31
+
32
+ vi.mock('@principles/core/runtime-v2', () => ({
33
+ PruningReadModel: vi.fn().mockImplementation(function () {
34
+ return { getHealthSummary: mockPruningGetHealthSummary };
35
+ }),
36
+ PainChainReadModel: vi.fn().mockImplementation(function () {
37
+ return { getLastSuccessfulChain: mockGetLastSuccessfulChain, close: mockPainChainClose };
38
+ }),
39
+ auditCandidateLedgerConsistency: mockAuditCandidateLedgerConsistency,
40
+ getLedgerFilePathPublic: vi.fn().mockReturnValue('/fake/workspace/.state/principle_training_state.json'),
41
+ }));
42
+
43
+ vi.mock('../../src/resolve-workspace.js', () => ({
44
+ resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
45
+ }));
46
+
47
+ vi.mock('fs', () => ({
48
+ existsSync: mockExistsSync,
49
+ }));
50
+
51
+ vi.mock('better-sqlite3', () => {
52
+ return {
53
+ default: vi.fn().mockImplementation(() => {
54
+ return {
55
+ prepare: mockDbPrepare.mockReturnValue({ all: mockDbAll }),
56
+ close: mockDbClose,
57
+ };
58
+ }),
59
+ };
60
+ });
61
+
62
+ import { handleHealth } from '../../src/commands/health.js';
63
+
64
+ const WS = '/fake/workspace';
65
+
66
+ function healthyPruningSummary() {
67
+ return {
68
+ totalPrinciples: 5,
69
+ byStatus: { probation: 3, active: 2 },
70
+ };
71
+ }
72
+
73
+ function healthyLastChain() {
74
+ return {
75
+ painId: 'pain_001',
76
+ taskId: 'diagnosis_pain_001',
77
+ runId: 'run_001',
78
+ artifactId: 'art_001',
79
+ candidateIds: ['c1'],
80
+ ledgerEntryIds: ['l1'],
81
+ status: 'succeeded',
82
+ latencyMs: {
83
+ painToTask: 100,
84
+ taskToRun: 200,
85
+ runToArtifact: 50,
86
+ },
87
+ failureCategory: null,
88
+ checkedAt: '2026-05-03T12:00:00.000Z',
89
+ missingLinks: [],
90
+ };
91
+ }
92
+
93
+ function mockProcessExit() {
94
+ return vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
95
+ }
96
+
97
+ describe('handleHealth', () => {
98
+ let consoleLogSpy: ReturnType<typeof vi.spyOn>;
99
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
100
+
101
+ beforeEach(() => {
102
+ vi.clearAllMocks();
103
+ process.exitCode = undefined;
104
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
105
+ consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
106
+ mockExistsSync.mockReturnValue(false);
107
+ });
108
+
109
+ afterEach(() => {
110
+ consoleLogSpy.mockRestore();
111
+ consoleWarnSpy.mockRestore();
112
+ });
113
+
114
+ describe('without state.db', () => {
115
+ it('outputs health report with pruning summary and audit results', async () => {
116
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
117
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
118
+
119
+ await handleHealth({ workspace: WS, json: true });
120
+
121
+ expect(consoleLogSpy).toHaveBeenCalled();
122
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
123
+ expect(jsonOutput.generatedAt).toBeDefined();
124
+ expect(jsonOutput.workspace).toBe(WS);
125
+ expect(jsonOutput.ledger.totalPrinciples).toBe(5);
126
+ expect(jsonOutput.ledger.byStatus).toEqual({ probation: 3, active: 2 });
127
+ expect(jsonOutput.candidateLedgerConsistency.status).toBe('ok');
128
+ expect(jsonOutput.candidateLedgerConsistency.missing).toBe(0);
129
+ expect(jsonOutput.pdStateDb.exists).toBe(false);
130
+ });
131
+
132
+ it('outputs readable text format', async () => {
133
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
134
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
135
+
136
+ await handleHealth({ workspace: WS, json: false });
137
+
138
+ const allOutput = consoleLogSpy.mock.calls.map(c => c.join(' ')).join('\n');
139
+ expect(allOutput).toContain(`workspace: ${WS}`);
140
+ expect(allOutput).toContain('ledger.totalPrinciples: 5');
141
+ expect(allOutput).toContain('candidateLedgerConsistency.status: ok');
142
+ expect(allOutput).toContain('pdStateDb.exists: false');
143
+ });
144
+
145
+ it('reports zero candidate and task counts', async () => {
146
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
147
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
148
+
149
+ await handleHealth({ workspace: WS, json: false });
150
+
151
+ const allOutput = consoleLogSpy.mock.calls.map(c => c.join(' ')).join('\n');
152
+ expect(allOutput).toContain('candidates.total: 0');
153
+ expect(allOutput).toContain('tasks.total: 0');
154
+ });
155
+
156
+ it('reports degraded consistency with exit code 1', async () => {
157
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
158
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 2 });
159
+ const exitSpy = mockProcessExit();
160
+
161
+ await handleHealth({ workspace: WS, json: false });
162
+
163
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('DEGRADED'));
164
+ expect(exitSpy).toHaveBeenCalledWith(1);
165
+
166
+ exitSpy.mockRestore();
167
+ });
168
+
169
+ it('outputs degraded consistency JSON with exit code 1', async () => {
170
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
171
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 2 });
172
+ const exitSpy = mockProcessExit();
173
+
174
+ await handleHealth({ workspace: WS, json: true });
175
+
176
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
177
+ expect(jsonOutput.candidateLedgerConsistency.status).toBe('degraded');
178
+ expect(jsonOutput.candidateLedgerConsistency.missing).toBe(2);
179
+ expect(exitSpy).toHaveBeenCalledWith(1);
180
+
181
+ exitSpy.mockRestore();
182
+ });
183
+
184
+ it('does not include lastSuccessfulChain when no database exists', async () => {
185
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
186
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
187
+
188
+ await handleHealth({ workspace: WS, json: true });
189
+
190
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
191
+ expect(jsonOutput.lastSuccessfulChain).toBeUndefined();
192
+ });
193
+ });
194
+
195
+ describe('with state.db', () => {
196
+ beforeEach(() => {
197
+ mockExistsSync.mockImplementation((p: string) => {
198
+ if (p.includes('state.db')) return true;
199
+ return false;
200
+ });
201
+ mockDbPrepare.mockReturnValue({ all: mockDbAll });
202
+ mockDbAll.mockReturnValue([]);
203
+ });
204
+
205
+ it('reads candidate counts from database', async () => {
206
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
207
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
208
+ mockDbAll
209
+ .mockReturnValueOnce([{ total: 3, status: 'consumed' }, { total: 2, status: 'pending' }])
210
+ .mockReturnValueOnce([]);
211
+
212
+ await handleHealth({ workspace: WS, json: true });
213
+
214
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
215
+ expect(jsonOutput.pdStateDb.exists).toBe(true);
216
+ expect(jsonOutput.candidates.total).toBe(5);
217
+ expect(jsonOutput.candidates.consumed).toBe(3);
218
+ expect(jsonOutput.candidates.pending).toBe(2);
219
+ });
220
+
221
+ it('reads task counts from database', async () => {
222
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
223
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
224
+ mockDbAll
225
+ .mockReturnValueOnce([])
226
+ .mockReturnValueOnce([{ total: 4, status: 'succeeded' }, { total: 1, status: 'failed' }]);
227
+
228
+ await handleHealth({ workspace: WS, json: true });
229
+
230
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
231
+ expect(jsonOutput.tasks.total).toBe(5);
232
+ expect(jsonOutput.tasks.byStatus).toEqual({ succeeded: 4, failed: 1 });
233
+ });
234
+
235
+ it('includes lastSuccessfulChain from PainChainReadModel', async () => {
236
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
237
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
238
+ mockGetLastSuccessfulChain.mockResolvedValue(healthyLastChain());
239
+
240
+ await handleHealth({ workspace: WS, json: true });
241
+
242
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
243
+ expect(jsonOutput.lastSuccessfulChain).toBeDefined();
244
+ expect(jsonOutput.lastSuccessfulChain.taskId).toBe('diagnosis_pain_001');
245
+ expect(jsonOutput.lastSuccessfulChain.runId).toBe('run_001');
246
+ expect(jsonOutput.lastSuccessfulChain.artifactId).toBe('art_001');
247
+ expect(jsonOutput.lastSuccessfulChain.candidateIds).toEqual(['c1']);
248
+ expect(jsonOutput.lastSuccessfulChain.ledgerEntryIds).toEqual(['l1']);
249
+ });
250
+
251
+ it('computes totalMs latency from chain trace', async () => {
252
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
253
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
254
+ mockGetLastSuccessfulChain.mockResolvedValue(healthyLastChain());
255
+
256
+ await handleHealth({ workspace: WS, json: true });
257
+
258
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
259
+ expect(jsonOutput.lastSuccessfulChain.latencyMs).toBeDefined();
260
+ expect(jsonOutput.lastSuccessfulChain.latencyMs.totalMs).toBe(350);
261
+ });
262
+
263
+ it('closes PainChainReadModel after use', async () => {
264
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
265
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
266
+ mockGetLastSuccessfulChain.mockResolvedValue(healthyLastChain());
267
+
268
+ await handleHealth({ workspace: WS, json: true });
269
+
270
+ expect(mockPainChainClose).toHaveBeenCalled();
271
+ });
272
+
273
+ it('closes database after use', async () => {
274
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
275
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
276
+ mockGetLastSuccessfulChain.mockResolvedValue(healthyLastChain());
277
+
278
+ await handleHealth({ workspace: WS, json: true });
279
+
280
+ expect(mockDbClose).toHaveBeenCalled();
281
+ });
282
+
283
+ it('sets partialHealth when PainChainReadModel throws', async () => {
284
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
285
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
286
+ mockGetLastSuccessfulChain.mockRejectedValue(new Error('Chain read error'));
287
+
288
+ await handleHealth({ workspace: WS, json: true });
289
+
290
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
291
+ expect(jsonOutput.partialHealth).toBe(true);
292
+ expect(jsonOutput.lastSuccessfulChain).toBeUndefined();
293
+ });
294
+
295
+ it('sets partialHealth when database read throws', async () => {
296
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
297
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
298
+ mockDbPrepare.mockImplementation(() => { throw new Error('Corrupt DB'); });
299
+
300
+ await handleHealth({ workspace: WS, json: true });
301
+
302
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
303
+ expect(jsonOutput.partialHealth).toBe(true);
304
+ });
305
+
306
+ it('handles missing lastSuccessfulChain gracefully', async () => {
307
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
308
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
309
+ mockGetLastSuccessfulChain.mockResolvedValue(undefined);
310
+
311
+ await handleHealth({ workspace: WS, json: true });
312
+
313
+ const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0]);
314
+ expect(jsonOutput.lastSuccessfulChain).toBeUndefined();
315
+ });
316
+
317
+ it('includes lastSuccessfulChain in text output', async () => {
318
+ mockPruningGetHealthSummary.mockReturnValue(healthyPruningSummary());
319
+ mockAuditCandidateLedgerConsistency.mockResolvedValue({ missingLedgerCount: 0 });
320
+ mockGetLastSuccessfulChain.mockResolvedValue(healthyLastChain());
321
+
322
+ await handleHealth({ workspace: WS, json: false });
323
+
324
+ const allOutput = consoleLogSpy.mock.calls.map(c => c.join(' ')).join('\n');
325
+ expect(allOutput).toContain('lastSuccessfulChain:');
326
+ expect(allOutput).toContain('taskId:');
327
+ expect(allOutput).toContain('diagnosis_pain_001');
328
+ });
329
+ });
330
+ });
@@ -0,0 +1,316 @@
1
+ /**
2
+ * pd pain record command unit tests.
3
+ *
4
+ * Tests the CLI adapter layer: validation, service delegation, output formatting.
5
+ * PainToPrincipleService is mocked — its own contract is tested separately.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+
9
+ // ── Mock setup ──────────────────────────────────────────────────────────────
10
+
11
+ let mockRecordPainResult: PainToPrincipleOutput;
12
+ let lastRecordPainInput: PainToPrincipleInput | null = null;
13
+
14
+ vi.mock('../../src/resolve-workspace.js', () => ({
15
+ resolveWorkspaceDir: vi.fn().mockReturnValue('/tmp/fake-workspace'),
16
+ }));
17
+
18
+ vi.mock('@principles/core/runtime-v2', () => ({
19
+ PainToPrincipleService: vi.fn().mockImplementation(function() {
20
+ return {
21
+ recordPain: vi.fn(async (input: PainToPrincipleInput) => {
22
+ lastRecordPainInput = input;
23
+ return mockRecordPainResult;
24
+ }),
25
+ };
26
+ }),
27
+ PrincipleTreeLedgerAdapter: vi.fn().mockImplementation(function() { return {}; }),
28
+ resolveRuntimeConfig: vi.fn().mockReturnValue({
29
+ runtimeKind: 'pi-ai',
30
+ provider: 'test-provider',
31
+ model: 'test-model',
32
+ apiKeyEnv: 'TEST_KEY',
33
+ timeoutMs: 300000,
34
+ agentId: 'main',
35
+ }),
36
+ isRuntimeConfigError: vi.fn().mockReturnValue(false),
37
+ }));
38
+
39
+ import { handlePainRecord } from '../../src/commands/pain-record.js';
40
+ import type { PainToPrincipleOutput, PainToPrincipleInput, FailureCategory } from '@principles/core/runtime-v2';
41
+
42
+ // ── Helpers ─────────────────────────────────────────────────────────────────
43
+
44
+ function mockProcessExit() {
45
+ return vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
46
+ }
47
+
48
+ const SUCCEEDED_RESULT: PainToPrincipleOutput = {
49
+ status: 'succeeded',
50
+ painId: 'manual_123_abc',
51
+ taskId: 'diagnosis_manual_123_abc',
52
+ runId: 'run-001',
53
+ artifactId: 'art-001',
54
+ candidateIds: ['c1'],
55
+ ledgerEntryIds: ['l1'],
56
+ observabilityWarnings: [],
57
+ latencyMs: 42,
58
+ };
59
+
60
+ function makeFailedResult(overrides?: Partial<PainToPrincipleOutput>): PainToPrincipleOutput {
61
+ return {
62
+ status: 'failed',
63
+ painId: 'manual_123_abc',
64
+ taskId: 'diagnosis_manual_123_abc',
65
+ candidateIds: [],
66
+ ledgerEntryIds: [],
67
+ message: 'something went wrong',
68
+ observabilityWarnings: [],
69
+ failureCategory: 'runtime_unavailable' as FailureCategory,
70
+ latencyMs: 10,
71
+ ...overrides,
72
+ };
73
+ }
74
+
75
+ function makeSkippedResult(overrides?: Partial<PainToPrincipleOutput>): PainToPrincipleOutput {
76
+ return {
77
+ status: 'skipped',
78
+ painId: 'manual_123_abc',
79
+ taskId: 'diagnosis_manual_123_abc',
80
+ candidateIds: [],
81
+ ledgerEntryIds: [],
82
+ message: 'already leased',
83
+ observabilityWarnings: [],
84
+ latencyMs: 5,
85
+ ...overrides,
86
+ };
87
+ }
88
+
89
+ // ── Tests ───────────────────────────────────────────────────────────────────
90
+
91
+ describe('pd pain record', () => {
92
+ beforeEach(() => {
93
+ vi.clearAllMocks();
94
+ mockRecordPainResult = { ...SUCCEEDED_RESULT };
95
+ lastRecordPainInput = null;
96
+ });
97
+
98
+ // 1. --reason required
99
+ it('exits 1 when --reason is missing', async () => {
100
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
101
+ const exitSpy = mockProcessExit();
102
+
103
+ await handlePainRecord({ reason: undefined });
104
+
105
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--reason'));
106
+ expect(exitSpy).toHaveBeenCalledWith(1);
107
+
108
+ errorSpy.mockRestore();
109
+ exitSpy.mockRestore();
110
+ });
111
+
112
+ // 2. --score out of range
113
+ it('exits 1 when --score is out of range', async () => {
114
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
115
+ const exitSpy = mockProcessExit();
116
+
117
+ await handlePainRecord({ reason: 'test', score: 150 });
118
+
119
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--score'));
120
+ expect(exitSpy).toHaveBeenCalledWith(1);
121
+
122
+ errorSpy.mockRestore();
123
+ exitSpy.mockRestore();
124
+ });
125
+
126
+ // 3. Happy path --json output
127
+ it('outputs JSON with all fields on success (--json)', async () => {
128
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
129
+ const exitSpy = mockProcessExit();
130
+
131
+ await handlePainRecord({ reason: 'test pain', json: true });
132
+
133
+ expect(logSpy).toHaveBeenCalled();
134
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
135
+ expect(jsonOutput.status).toBe('succeeded');
136
+ expect(jsonOutput.painId).toBe('manual_123_abc');
137
+ expect(jsonOutput.taskId).toBe('diagnosis_manual_123_abc');
138
+ expect(jsonOutput.runId).toBe('run-001');
139
+ expect(jsonOutput.artifactId).toBe('art-001');
140
+ expect(jsonOutput.candidateIds).toEqual(['c1']);
141
+ expect(jsonOutput.ledgerEntryIds).toEqual(['l1']);
142
+ expect(jsonOutput.observabilityWarnings).toEqual([]);
143
+ expect(jsonOutput.latencyMs).toBe(42);
144
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
145
+
146
+ logSpy.mockRestore();
147
+ exitSpy.mockRestore();
148
+ });
149
+
150
+ // 4. Happy path text output
151
+ it('outputs human-readable summary on success (text)', async () => {
152
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
153
+ const exitSpy = mockProcessExit();
154
+
155
+ await handlePainRecord({ reason: 'test pain' });
156
+
157
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[OK]'));
158
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('manual_123_abc'));
159
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
160
+
161
+ logSpy.mockRestore();
162
+ exitSpy.mockRestore();
163
+ });
164
+
165
+ // 5. Failed exits 1 with --json
166
+ it('exits 1 on failed status (--json)', async () => {
167
+ mockRecordPainResult = makeFailedResult();
168
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
169
+ const exitSpy = mockProcessExit();
170
+
171
+ await handlePainRecord({ reason: 'test pain', json: true });
172
+
173
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
174
+ expect(jsonOutput.status).toBe('failed');
175
+ expect(exitSpy).toHaveBeenCalledWith(1);
176
+
177
+ logSpy.mockRestore();
178
+ exitSpy.mockRestore();
179
+ });
180
+
181
+ // 6. Failed exits 1 (text)
182
+ it('exits 1 on failed status (text)', async () => {
183
+ mockRecordPainResult = makeFailedResult();
184
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
185
+ const exitSpy = mockProcessExit();
186
+
187
+ await handlePainRecord({ reason: 'test pain' });
188
+
189
+ const firstErrorArg = errorSpy.mock.calls[0]?.[0] ?? '';
190
+ expect(firstErrorArg).toContain('[FAIL]');
191
+ expect(exitSpy).toHaveBeenCalledWith(1);
192
+
193
+ errorSpy.mockRestore();
194
+ exitSpy.mockRestore();
195
+ });
196
+
197
+ // 7. config_missing shows diagnostic guidance
198
+ it('shows diagnostic guidance on config_missing failure', async () => {
199
+ mockRecordPainResult = makeFailedResult({
200
+ failureCategory: 'config_missing' as FailureCategory,
201
+ message: 'API key not found in env',
202
+ });
203
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
204
+ const exitSpy = mockProcessExit();
205
+
206
+ await handlePainRecord({ reason: 'test pain' });
207
+
208
+ const allErrorOutput = errorSpy.mock.calls.map(c => c.join(' ')).join('\n');
209
+ expect(allErrorOutput).toContain('Error: Pain signal failed');
210
+ expect(exitSpy).toHaveBeenCalledWith(1);
211
+
212
+ errorSpy.mockRestore();
213
+ exitSpy.mockRestore();
214
+ });
215
+
216
+ // 7a. config_missing with RuntimeConfigError exits non-zero (text)
217
+ it('exits 1 on config_missing with RuntimeConfigError (text)', async () => {
218
+ mockRecordPainResult = makeFailedResult({
219
+ failureCategory: 'config_missing' as FailureCategory,
220
+ message: 'API key not found in env',
221
+ });
222
+ const { isRuntimeConfigError, resolveRuntimeConfig } = await import('@principles/core/runtime-v2');
223
+ vi.mocked(isRuntimeConfigError).mockReturnValueOnce(true);
224
+ vi.mocked(resolveRuntimeConfig).mockReturnValueOnce({
225
+ ok: false,
226
+ reason: 'missing_openclaw_mode',
227
+ message: 'runtimeKind is openclaw-cli but no mode specified',
228
+ nextAction: 'Provide exactly one mode',
229
+ });
230
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
231
+ const exitSpy = mockProcessExit();
232
+
233
+ await handlePainRecord({ reason: 'test pain' });
234
+
235
+ expect(exitSpy).toHaveBeenCalledWith(1);
236
+
237
+ errorSpy.mockRestore();
238
+ exitSpy.mockRestore();
239
+ });
240
+
241
+ // 7b. config_missing with RuntimeConfigError outputs JSON (--json)
242
+ it('outputs JSON with configError on config_missing + RuntimeConfigError (--json)', async () => {
243
+ mockRecordPainResult = makeFailedResult({
244
+ failureCategory: 'config_missing' as FailureCategory,
245
+ message: 'API key not found in env',
246
+ });
247
+ const { isRuntimeConfigError, resolveRuntimeConfig } = await import('@principles/core/runtime-v2');
248
+ vi.mocked(isRuntimeConfigError).mockReturnValueOnce(true);
249
+ vi.mocked(resolveRuntimeConfig).mockReturnValueOnce({
250
+ ok: false,
251
+ reason: 'missing_openclaw_mode',
252
+ message: 'runtimeKind is openclaw-cli but no mode specified',
253
+ nextAction: 'Provide exactly one mode',
254
+ });
255
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
256
+ const exitSpy = mockProcessExit();
257
+
258
+ await handlePainRecord({ reason: 'test pain', json: true });
259
+
260
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
261
+ expect(jsonOutput.status).toBe('failed');
262
+ expect(jsonOutput.failureCategory).toBe('config_missing');
263
+ expect(jsonOutput.configError).toBeDefined();
264
+ expect(jsonOutput.configError.reason).toBe('missing_openclaw_mode');
265
+ expect(jsonOutput.configError.nextAction).toBe('Provide exactly one mode');
266
+ expect(exitSpy).toHaveBeenCalledWith(1);
267
+
268
+ logSpy.mockRestore();
269
+ exitSpy.mockRestore();
270
+ });
271
+
272
+ // 8. skipped status outputs [SKIP] and does not exit 1
273
+ it('outputs [SKIP] on skipped status (text)', async () => {
274
+ mockRecordPainResult = makeSkippedResult();
275
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
276
+ const exitSpy = mockProcessExit();
277
+
278
+ await handlePainRecord({ reason: 'test pain' });
279
+
280
+ const allOutput = logSpy.mock.calls.map(c => c.join(' ')).join(' ');
281
+ expect(allOutput).toContain('[SKIP]');
282
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
283
+
284
+ logSpy.mockRestore();
285
+ exitSpy.mockRestore();
286
+ });
287
+
288
+ // 9. skipped status with --json does not exit 1
289
+ it('outputs skipped status in JSON without exit 1', async () => {
290
+ mockRecordPainResult = makeSkippedResult();
291
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
292
+ const exitSpy = mockProcessExit();
293
+
294
+ await handlePainRecord({ reason: 'test pain', json: true });
295
+
296
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
297
+ expect(jsonOutput.status).toBe('skipped');
298
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
299
+
300
+ logSpy.mockRestore();
301
+ exitSpy.mockRestore();
302
+ });
303
+
304
+ // 10. recordPain called with correct arguments
305
+ it('passes correct arguments to recordPain', async () => {
306
+ await handlePainRecord({ reason: 'test pain', score: 90, source: 'ci' });
307
+
308
+ expect(lastRecordPainInput).toBeTruthy();
309
+ expect(lastRecordPainInput!.painType).toBe('user_frustration');
310
+ expect(lastRecordPainInput!.source).toBe('ci');
311
+ expect(lastRecordPainInput!.reason).toBe('test pain');
312
+ expect(lastRecordPainInput!.score).toBe(90);
313
+ expect(lastRecordPainInput!.sessionId).toBe('cli');
314
+ expect(lastRecordPainInput!.agentId).toBe('pd-cli');
315
+ });
316
+ });