@principles/pd-cli 1.132.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 (212) 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/scripts/migrate-illegal-expected-decision.ts +252 -0
  136. package/src/commands/__tests__/intent-flag-wiring.test.ts +88 -3
  137. package/src/commands/__tests__/pain-retry-maxTokens-flag.test.ts +73 -0
  138. package/src/commands/__tests__/rulecode-flag-wiring.test.ts +31 -24
  139. package/src/commands/__tests__/run-rulehost-flag-wiring.test.ts +12 -0
  140. package/src/commands/admission-gate.ts +30 -0
  141. package/src/commands/candidate.ts +74 -6
  142. package/src/commands/console.ts +40 -0
  143. package/src/commands/demo-story-a.ts +4 -2
  144. package/src/commands/diagnose.ts +158 -10
  145. package/src/commands/errors-list.ts +440 -0
  146. package/src/commands/health-codex.ts +249 -0
  147. package/src/commands/health.ts +60 -0
  148. package/src/commands/intent.ts +98 -29
  149. package/src/commands/pain-evidence.ts +6 -0
  150. package/src/commands/pain-record.ts +21 -0
  151. package/src/commands/pain-retry.ts +353 -35
  152. package/src/commands/proven-channel-baseline.ts +4 -2
  153. package/src/commands/runtime-activation.ts +657 -12
  154. package/src/commands/runtime-canary.ts +31 -5
  155. package/src/commands/runtime-health-snapshot.ts +4 -2
  156. package/src/commands/runtime-init.ts +305 -0
  157. package/src/commands/runtime-internalization-context-trace.ts +445 -0
  158. package/src/commands/runtime-internalization-enqueue-successors.ts +3 -1
  159. package/src/commands/runtime-internalization-integrity.ts +4 -2
  160. package/src/commands/runtime-internalization-run-once.ts +20 -5
  161. package/src/commands/runtime-internalization-run-rulehost.ts +159 -6
  162. package/src/commands/runtime-uat.guard.test.ts +14 -16
  163. package/src/commands/runtime-uat.ts +4 -3
  164. package/src/config-reader.ts +1 -1
  165. package/src/index.ts +69 -78
  166. package/src/services/__tests__/evaluator-runner-deps.test.ts +341 -0
  167. package/src/services/config-doctor.ts +9 -2
  168. package/src/services/pain-flood-simulation-runner.ts +9 -0
  169. package/src/services/pd-config-loader.ts +2 -2
  170. package/src/services/proven-channel-baseline-runner.ts +10 -3
  171. package/src/services/rulehost-pipeline-runner.ts +157 -6
  172. package/src/services/runtime-adapter-resolver.ts +4 -0
  173. package/src/services/synthetic-baseline-runner.ts +11 -0
  174. package/src/utils/production-workspace-guard.test.ts +62 -20
  175. package/src/utils/production-workspace-guard.ts +19 -16
  176. package/tests/bdd/cli-contract.steps.ts +343 -0
  177. package/tests/bdd/context-trace.steps.ts +229 -0
  178. package/tests/bdd/support/gherkin-loader.ts +147 -0
  179. package/tests/bdd/support/repo-root.ts +62 -0
  180. package/tests/bdd/support/vitest-bdd.ts +179 -0
  181. package/tests/commands/admission-gate-coverage.test.ts +79 -0
  182. package/tests/commands/candidate-audit-repair.test.ts +77 -0
  183. package/tests/commands/candidate-intake.test.ts +155 -10
  184. package/tests/commands/candidate-internalization-backfill.test.ts +30 -0
  185. package/tests/commands/cli-help-snapshot.test.ts +15 -1
  186. package/tests/commands/console-open.test.ts +19 -0
  187. package/tests/commands/diagnose.test.ts +619 -14
  188. package/tests/commands/errors-list.test.ts +678 -0
  189. package/tests/commands/health-codex.test.ts +243 -0
  190. package/tests/commands/health-host-wiring.test.ts +76 -0
  191. package/tests/commands/intent.test.ts +4 -4
  192. package/tests/commands/pain-record.test.ts +80 -0
  193. package/tests/commands/pain-retry.test.ts +94 -3
  194. package/tests/commands/run-rulehost-handler.test.ts +133 -0
  195. package/tests/commands/runtime-activation-approve-flag-wiring.test.ts +227 -0
  196. package/tests/commands/runtime-activation-deactivate-flag-wiring.test.ts +167 -0
  197. package/tests/commands/runtime-activation-dispatch-flag-wiring.test.ts +262 -0
  198. package/tests/commands/runtime-activation-list-flag-wiring.test.ts +193 -0
  199. package/tests/commands/runtime-activation-promote-flag-wiring.test.ts +222 -0
  200. package/tests/commands/runtime-activation.test.ts +1090 -5
  201. package/tests/commands/runtime-canary.test.ts +50 -0
  202. package/tests/commands/runtime-features.test.ts +2 -0
  203. package/tests/commands/runtime-init-empty-workspace.test.ts +234 -0
  204. package/tests/commands/runtime-init.test.ts +346 -0
  205. package/tests/commands/runtime-internalization-context-trace.test.ts +110 -0
  206. package/tests/commands/runtime-internalization.test.ts +2 -2
  207. package/tests/config-reader.test.ts +6 -5
  208. package/tests/e2e/cross-package-acceptance.test.ts +55 -13
  209. package/tests/services/mainline-snapshot-assembler.test.ts +6 -3
  210. package/tests/services/resolve-runtime-from-pd-config.test.ts +2 -0
  211. package/tests/services/rulehost-pipeline-runner.test.ts +1 -1
  212. package/vitest.config.ts +1 -1
@@ -103,6 +103,7 @@ export interface ResolveAdapterOptions {
103
103
  apiKeyEnv?: string;
104
104
  baseUrl?: string;
105
105
  maxRetries?: number;
106
+ maxTokens?: number;
106
107
  };
107
108
  /**
108
109
  * CLI override for openclaw mode (from --openclaw-local / --openclaw-gateway flags).
@@ -215,6 +216,7 @@ export function resolveRuntimeAdapterFromConfig(opts: ResolveAdapterOptions): PD
215
216
  const apiKeyEnv = opts.piAiOverrides?.apiKeyEnv ?? configFields.apiKeyEnv;
216
217
  const baseUrl = opts.piAiOverrides?.baseUrl ?? configFields.baseUrl;
217
218
  const maxRetries = opts.piAiOverrides?.maxRetries ?? configFields.maxRetries;
219
+ const maxTokens = opts.piAiOverrides?.maxTokens ?? configFields.maxTokens;
218
220
  const adapterTimeoutMs = opts.timeoutMs ?? configFields.timeoutMs;
219
221
 
220
222
  // PRI-431 Step 1d + PR review fix: manual missing-field check on MERGED values
@@ -283,9 +285,11 @@ export function resolveRuntimeAdapterFromConfig(opts: ResolveAdapterOptions): PD
283
285
  model: String(model),
284
286
  apiKeyEnv: String(apiKeyEnv),
285
287
  maxRetries,
288
+ maxTokens,
286
289
  timeoutMs: adapterTimeoutMs,
287
290
  baseUrl,
288
291
  workspace: opts.workspaceDir,
292
+ ...(configFields.systemPrompt ? { systemPrompt: configFields.systemPrompt } : {}),
289
293
  });
290
294
  }
291
295
 
@@ -21,6 +21,7 @@ import { auditCandidateLedgerConsistency } from '@principles/core/runtime-v2';
21
21
  import { OperatorHealthReadModel } from '@principles/core/runtime-v2';
22
22
  import { createInternalizationQueueReadModel } from '@principles/core/runtime-v2';
23
23
  import { createPITaskDiagnosticJson } from '@principles/core/runtime-v2';
24
+ import { contentHashFn } from './rulehost-pipeline-runner.js';
24
25
  import {
25
26
  computeOverallStatus,
26
27
  boundedEvidence,
@@ -148,6 +149,7 @@ export async function runSyntheticBaseline(opts: SyntheticBaselineRunnerOptions)
148
149
  artifactStore: stateManager.piArtifactStore,
149
150
  validator: new DefaultDiagRootCauseValidator(),
150
151
  contextAssembler,
152
+ contentHashFn,
151
153
  },
152
154
  {
153
155
  owner: 'synthetic-baseline',
@@ -164,6 +166,7 @@ export async function runSyntheticBaseline(opts: SyntheticBaselineRunnerOptions)
164
166
  eventEmitter,
165
167
  artifactStore: stateManager.piArtifactStore,
166
168
  validator: new DefaultDiagDistillerValidator(),
169
+ contentHashFn,
167
170
  },
168
171
  {
169
172
  owner: 'synthetic-baseline',
@@ -180,6 +183,7 @@ export async function runSyntheticBaseline(opts: SyntheticBaselineRunnerOptions)
180
183
  eventEmitter,
181
184
  artifactStore: stateManager.piArtifactStore,
182
185
  committer,
186
+ contentHashFn,
183
187
  },
184
188
  {
185
189
  owner: 'synthetic-baseline',
@@ -214,6 +218,13 @@ export async function runSyntheticBaseline(opts: SyntheticBaselineRunnerOptions)
214
218
  painType: 'tool_failure',
215
219
  source: 'synthetic-baseline',
216
220
  reason: 'Synthetic baseline deterministic pain signal',
221
+ // Synthetic baseline is a deterministic smoke tool — it has no real host
222
+ // session to harvest evidence from. Provide a synthetic evidence entry so
223
+ // the PEAT-B1 admission gate (painEvidenceAdmission, default-on) does not
224
+ // short-circuit the pain before diagnosis. Without this, the baseline
225
+ // reports `short_circuited: input evidence empty` and every downstream
226
+ // stage fails, even though the pipeline itself is healthy.
227
+ evidence: [{ sourceRef: `synthetic:${painId}`, note: 'Synthetic baseline deterministic evidence anchor' }],
217
228
  });
218
229
 
219
230
  const painPassed = bridgeResult.status === 'succeeded';
@@ -5,23 +5,27 @@ import {
5
5
  isProductionWorkspace,
6
6
  guardUatWorkspace,
7
7
  getSafeUatWorkspacePath,
8
+ resolveWorkspacePath,
8
9
  formatGuardRefusal,
9
10
  type GuardRefusal,
10
11
  } from './production-workspace-guard.js';
11
12
 
13
+ // Use os.homedir()-derived paths so the tests work cross-platform
14
+ // (previously hardcoded Windows paths like D:\.openclaw\workspace which on
15
+ // Linux resolve to <cwd>/D:\.openclaw\workspace — meaningless).
16
+ const HOMEDIR_WORKSPACE = path.join(os.homedir(), '.openclaw', 'workspace');
17
+
12
18
  const PRODUCTION_PATHS = [
13
- 'D:\\.openclaw\\workspace',
14
- 'C:\\.openclaw\\workspace',
15
- 'C:\\Users\\Administrator\\.openclaw\\workspace',
16
- path.join(os.homedir(), '.openclaw', 'workspace'),
19
+ HOMEDIR_WORKSPACE,
17
20
  ];
18
21
 
19
22
  const SAFE_PATHS = [
20
- 'D:\\.openclaw\\workspace-test',
21
- 'D:\\.openclaw\\workspace-backup',
23
+ path.join(os.homedir(), '.openclaw', 'workspace-test'),
24
+ path.join(os.homedir(), '.openclaw', 'workspace-backup'),
22
25
  path.join(os.tmpdir(), 'pd-uat-workspace'),
23
26
  path.join(os.tmpdir(), 'pd-test-any'),
24
- 'C:\\completely-unrelated\\work',
27
+ // A path that is clearly unrelated on every platform
28
+ path.join(os.tmpdir(), 'completely-unrelated-work'),
25
29
  ];
26
30
 
27
31
  describe('isProductionWorkspace', () => {
@@ -32,18 +36,15 @@ describe('isProductionWorkspace', () => {
32
36
  expect(isProductionWorkspace(path.resolve(safePath))).toBe(false);
33
37
  });
34
38
  it('detects descendant', () => {
35
- // Use a platform-appropriate descendant path so the test works on both
36
- // Windows (where D:\... resolves as-is) and Linux CI (where D:\... is
37
- // treated as a relative path and resolves under cwd).
38
- const prodPath = path.resolve(path.join(os.homedir(), '.openclaw', 'workspace'));
39
+ const prodPath = path.resolve(HOMEDIR_WORKSPACE);
39
40
  const descendantPath = path.join(prodPath, 'sub', 'child');
40
41
  expect(isProductionWorkspace(descendantPath)).toBe(true);
41
42
  });
42
43
  it('rejects sibling workspace-test (ERR-030)', () => {
43
- expect(isProductionWorkspace(path.resolve('D:\\.openclaw\\workspace-test'))).toBe(false);
44
+ expect(isProductionWorkspace(path.resolve(path.join(os.homedir(), '.openclaw', 'workspace-test')))).toBe(false);
44
45
  });
45
46
  it('rejects sibling workspace-backup (ERR-030)', () => {
46
- expect(isProductionWorkspace(path.resolve('D:\\.openclaw\\workspace-backup'))).toBe(false);
47
+ expect(isProductionWorkspace(path.resolve(path.join(os.homedir(), '.openclaw', 'workspace-backup')))).toBe(false);
47
48
  });
48
49
  });
49
50
 
@@ -58,7 +59,7 @@ describe('guardUatWorkspace', () => {
58
59
  }
59
60
  });
60
61
  it('refuses descendant', () => {
61
- const prodPath = path.resolve(path.join(os.homedir(), '.openclaw', 'workspace'));
62
+ const prodPath = path.resolve(HOMEDIR_WORKSPACE);
62
63
  const descendantPath = path.join(prodPath, 'subdir');
63
64
  expect(guardUatWorkspace(descendantPath, 'test').refused).toBe(true);
64
65
  });
@@ -68,8 +69,10 @@ describe('guardUatWorkspace', () => {
68
69
  const r = guardUatWorkspace(safePath, 'test');
69
70
  expect(r.refused).toBe(false);
70
71
  });
71
- it.each(['D:\\.openclaw\\workspace-test', 'D:\\.openclaw\\workspace-backup'])(
72
- 'allows sibling: %s (ERR-030)', (p) => {
72
+ it.each([
73
+ path.join(os.homedir(), '.openclaw', 'workspace-test'),
74
+ path.join(os.homedir(), '.openclaw', 'workspace-backup'),
75
+ ])('allows sibling: %s (ERR-030)', (p) => {
73
76
  expect(guardUatWorkspace(p, 'test').refused).toBe(false);
74
77
  });
75
78
  });
@@ -77,7 +80,7 @@ describe('guardUatWorkspace', () => {
77
80
 
78
81
  describe('JSON output (EP-04)', () => {
79
82
  it('outputs single object with reason and nextAction', () => {
80
- const r = guardUatWorkspace('D:\\.openclaw\\workspace', 'test');
83
+ const r = guardUatWorkspace(HOMEDIR_WORKSPACE, 'test');
81
84
  const json = formatGuardRefusal(r as GuardRefusal, 'test', true);
82
85
  const parsed = JSON.parse(json);
83
86
  expect(parsed).toMatchObject({
@@ -87,7 +90,7 @@ describe('JSON output (EP-04)', () => {
87
90
  expect(Array.isArray(parsed)).toBe(false);
88
91
  });
89
92
  it('no console prefixes in JSON', () => {
90
- const r = guardUatWorkspace('D:\\.openclaw\\workspace', 'test');
93
+ const r = guardUatWorkspace(HOMEDIR_WORKSPACE, 'test');
91
94
  const json = formatGuardRefusal(r as GuardRefusal, 'test', true);
92
95
  expect(json).not.toContain('[pd-cli]');
93
96
  expect(json).not.toContain('ERROR:');
@@ -97,7 +100,7 @@ describe('JSON output (EP-04)', () => {
97
100
 
98
101
  describe('text output (EP-03)', () => {
99
102
  it('includes reason and nextAction', () => {
100
- const r = guardUatWorkspace('D:\\.openclaw\\workspace', 'test');
103
+ const r = guardUatWorkspace(HOMEDIR_WORKSPACE, 'test');
101
104
  const text = formatGuardRefusal(r as GuardRefusal, 'test', false);
102
105
  expect(text).toContain('Reason:');
103
106
  expect(text).toContain('Next Action:');
@@ -112,4 +115,43 @@ describe('getSafeUatWorkspacePath', () => {
112
115
  expect(p1).toContain(os.tmpdir());
113
116
  expect(p1).toContain('pd-uat-workspace');
114
117
  });
115
- });
118
+ });
119
+
120
+ describe('resolveWorkspacePath', () => {
121
+ it('resolves an absolute path as-is', () => {
122
+ const abs = path.resolve('/tmp/my-workspace');
123
+ expect(resolveWorkspacePath(abs)).toBe(abs);
124
+ });
125
+
126
+ it('resolves a relative path against cwd', () => {
127
+ const result = resolveWorkspacePath('relative/path');
128
+ const expected = path.resolve(process.cwd(), 'relative/path');
129
+ expect(result).toBe(expected);
130
+ });
131
+
132
+ it('defaults to cwd when no input is provided', () => {
133
+ const result = resolveWorkspacePath(undefined);
134
+ expect(result).toBe(path.resolve(process.cwd()));
135
+ });
136
+ });
137
+
138
+ describe('PD_PRODUCTION_WORKSPACE env var (module-load-time)', () => {
139
+ // NOTE: PRODUCTION_WORKSPACE_PATHS is built once at module load time from
140
+ // process.env.PD_PRODUCTION_WORKSPACE. Setting the env var after the module
141
+ // is loaded does NOT change behavior. These tests verify that the default
142
+ // production path (HOMEDIR_WORKSPACE) is correctly protected, which is the
143
+ // primary regression risk.
144
+
145
+ it('guardUatWorkspace refuses the default production workspace', () => {
146
+ const r = guardUatWorkspace(HOMEDIR_WORKSPACE, 'test');
147
+ expect(r.refused).toBe(true);
148
+ if (r.refused) {
149
+ expect(r.isProduction).toBe(true);
150
+ expect(r.workspace).toBe(path.resolve(HOMEDIR_WORKSPACE));
151
+ }
152
+ });
153
+
154
+ it('isProductionWorkspace returns true for the default production path', () => {
155
+ expect(isProductionWorkspace(path.resolve(HOMEDIR_WORKSPACE))).toBe(true);
156
+ });
157
+ });
@@ -3,10 +3,10 @@
3
3
  *
4
4
  * Prevents UAT/runtime test commands from writing to production workspaces.
5
5
  *
6
- * Production workspaces are:
7
- * - D:\.openclaw\workspace
8
- * - C:\Users\Administrator\.openclaw\workspace
9
- * - And the default workspace resolved by OpenClaw configuration
6
+ * Production workspaces:
7
+ * - Default: ~/.openclaw/workspace (cross-platform)
8
+ * - Additional paths declared via PD_PRODUCTION_WORKSPACE env var
9
+ * (use path.delimiter `;` on Windows, `:` on Unix — to separate multiple paths)
10
10
  *
11
11
  * This module follows ERR-030 (path prefix matching must use segment boundaries)
12
12
  * and EP-03/EP-04 (fail loud with structured reason and nextAction).
@@ -20,19 +20,22 @@ import { existsSync } from 'fs';
20
20
 
21
21
  /**
22
22
  * List of production workspace paths that should be protected from UAT/test writes.
23
- * These are the default paths where PD is typically installed and used for real work.
23
+ * Built from: the universal default (~/.openclaw/workspace) plus any paths
24
+ * declared via PD_PRODUCTION_WORKSPACE.
24
25
  */
25
- const PRODUCTION_WORKSPACE_PATHS = [
26
- // Windows default
27
- path.resolve('D:\\.openclaw\\workspace'),
28
- path.resolve('C:\\.openclaw\\workspace'),
29
- path.resolve('C:\\Users\\Administrator\\.openclaw\\workspace'),
30
- path.resolve('C:\\Users\\Admin\\.openclaw\\workspace'),
31
- // Unix-like defaults
32
- path.resolve(path.join(os.homedir(), '.openclaw', 'workspace')),
33
- // macOS-specific
34
- path.resolve(path.join(os.homedir(), '.openclaw', 'workspace')),
35
- ];
26
+ function buildProductionWorkspacePaths(): string[] {
27
+ const paths = [path.resolve(path.join(os.homedir(), '.openclaw', 'workspace'))];
28
+ const extra = process.env.PD_PRODUCTION_WORKSPACE;
29
+ if (extra) {
30
+ for (const p of extra.split(path.delimiter)) {
31
+ const trimmed = p.trim();
32
+ if (trimmed) paths.push(path.resolve(trimmed));
33
+ }
34
+ }
35
+ return paths;
36
+ }
37
+
38
+ const PRODUCTION_WORKSPACE_PATHS = buildProductionWorkspacePaths();
36
39
 
37
40
  // ── Resolution helpers ───────────────────────────────────────────────────────
38
41
 
@@ -0,0 +1,343 @@
1
+ /**
2
+ * BDD step definitions for CLI strict JSON output contract (cli-1-strict-json).
3
+ *
4
+ * Approach: Command + mocks (NOT real CLI subprocess execution).
5
+ * Reuses the exact same mock pattern as tests/commands/pain-retry.test.ts:
6
+ * - vi.hoisted() + vi.mock() to stub @principles/core/runtime-v2 and friends
7
+ * - Import handlePainRetry directly and call it in-process
8
+ * - Capture console.log via vi.spyOn to verify stdout JSON contract
9
+ *
10
+ * Rationale: pd pain retry needs a real workspace + pain-id + config to execute
11
+ * as a subprocess. Testing the JSON output contract in-process with mocks is
12
+ * more reliable and follows the established pattern in this repo.
13
+ */
14
+ import { vi, expect } from 'vitest';
15
+ import { Command } from 'commander';
16
+ import { readFileSync } from 'node:fs';
17
+ import { createStepRegistry, defineFeature } from './support/vitest-bdd.js';
18
+ import { resolveFeaturePath } from './support/repo-root.js';
19
+
20
+ // ── Mocks (mirrors tests/commands/pain-retry.test.ts) ──────────────────────────
21
+
22
+ const {
23
+ MockRuntimeStateManager,
24
+ mockGetTask,
25
+ mockGetCandidatesByTaskId,
26
+ mockUpdateCandidateStatus,
27
+ mockGetRunsByTask,
28
+ } = vi.hoisted(() => {
29
+ const mockGetTask = vi.fn().mockResolvedValue(null);
30
+ const mockGetCandidatesByTaskId = vi.fn().mockResolvedValue([]);
31
+ const mockUpdateCandidateStatus = vi.fn().mockResolvedValue(undefined);
32
+ const mockGetRunsByTask = vi.fn().mockResolvedValue([]);
33
+
34
+ class MockRuntimeStateManager {
35
+ initialize = vi.fn().mockResolvedValue(undefined);
36
+ close = vi.fn().mockResolvedValue(undefined);
37
+ getTask = mockGetTask;
38
+ getCandidatesByTaskId = mockGetCandidatesByTaskId;
39
+ updateCandidateStatus = mockUpdateCandidateStatus;
40
+ getRunsByTask = mockGetRunsByTask;
41
+ connection = {} as Record<string, unknown>;
42
+ taskStore = {};
43
+ runStore = {};
44
+ }
45
+ return {
46
+ MockRuntimeStateManager,
47
+ mockGetTask,
48
+ mockGetCandidatesByTaskId,
49
+ mockUpdateCandidateStatus,
50
+ mockGetRunsByTask,
51
+ };
52
+ }, { validateType: true });
53
+
54
+ const { mockIntake, MockCandidateIntakeService } = vi.hoisted(() => {
55
+ const mockIntake = vi.fn();
56
+ function MockCandidateIntakeService(this: unknown) {
57
+ return { intake: mockIntake };
58
+ }
59
+ MockCandidateIntakeService.prototype = {};
60
+ return { mockIntake, MockCandidateIntakeService };
61
+ });
62
+
63
+ const { MockPrincipleTreeLedgerAdapter } = vi.hoisted(() => {
64
+ function MockPrincipleTreeLedgerAdapter(this: unknown) {
65
+ return {};
66
+ }
67
+ MockPrincipleTreeLedgerAdapter.prototype = {};
68
+ return { MockPrincipleTreeLedgerAdapter };
69
+ });
70
+
71
+ const { mockRun, mockResolveRuntimeConfig, mockResolveRuntimeFromPdConfig } = vi.hoisted(() => {
72
+ const mockRun = vi.fn().mockResolvedValue({
73
+ status: 'succeeded',
74
+ taskId: 'diagnosis_test-pain-1',
75
+ runId: 'run-retry-1',
76
+ contextHash: 'abc123',
77
+ });
78
+ const mockResolveRuntimeConfig = vi.fn().mockReturnValue({
79
+ runtimeKind: 'pi-ai',
80
+ provider: 'test-provider',
81
+ model: 'test-model',
82
+ apiKeyEnv: 'TEST_KEY',
83
+ timeoutMs: 300000,
84
+ agentId: 'main',
85
+ });
86
+ const mockResolveRuntimeFromPdConfig = vi.fn().mockReturnValue({
87
+ result: {
88
+ runtimeKind: 'pi-ai',
89
+ provider: 'test-provider',
90
+ model: 'test-model',
91
+ apiKeyEnv: 'TEST_KEY',
92
+ timeoutMs: 300000,
93
+ agentId: 'main',
94
+ },
95
+ legacyWarnings: [],
96
+ configSource: '.pd/config.yaml',
97
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
98
+ });
99
+ return { mockRun, mockResolveRuntimeConfig, mockResolveRuntimeFromPdConfig };
100
+ });
101
+
102
+ vi.mock('../../src/resolve-workspace.js', () => ({
103
+ resolveWorkspaceDir: vi.fn().mockReturnValue('/tmp/fake-workspace'),
104
+ }));
105
+
106
+ vi.mock('@principles/core/runtime-v2', () => {
107
+ return {
108
+ RuntimeStateManager: vi.fn().mockImplementation(function () {
109
+ return new MockRuntimeStateManager();
110
+ }),
111
+ SqliteHistoryQuery: vi.fn().mockImplementation(function () { return {}; }),
112
+ SqliteContextAssembler: vi.fn().mockImplementation(function () { return {}; }),
113
+ SqliteDiagnosticianCommitter: vi.fn().mockImplementation(function () { return {}; }),
114
+ SqliteTrajectoryLocator: vi.fn().mockImplementation(function () { return {}; }),
115
+ SqliteSourceTraceLocator: vi.fn().mockImplementation(function () { return {}; }),
116
+ // Dead-letter store mock: getByPainId returns null so the implementation
117
+ // produces status='not_found' with reason='task_not_found' for the
118
+ // "失败命令的 --json 输出含 reason 和 nextAction" scenario.
119
+ SqliteDeadLetterStore: vi.fn().mockImplementation(function () {
120
+ return { getByPainId: vi.fn().mockReturnValue(null) };
121
+ }),
122
+ PainSignalBridge: vi.fn().mockImplementation(function () { return {}; }),
123
+ StoreEventEmitter: vi.fn().mockImplementation(function () { return {}; }),
124
+ storeEmitter: { emitTelemetry: vi.fn() },
125
+ SplitDiagnosticianRunner: vi.fn().mockImplementation(function () { return {}; }),
126
+ DiagRootCauseRunner: vi.fn().mockImplementation(function () { return {}; }),
127
+ DiagDistillerRunner: vi.fn().mockImplementation(function () { return {}; }),
128
+ DiagRouterRunner: vi.fn().mockImplementation(function () { return {}; }),
129
+ DefaultDiagRootCauseValidator: vi.fn().mockImplementation(function () { return {}; }),
130
+ DefaultDiagDistillerValidator: vi.fn().mockImplementation(function () { return {}; }),
131
+ DisabledDiagnosticianRunner: vi.fn().mockImplementation(function () { return {}; }),
132
+ TestDoubleRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
133
+ OpenClawCliRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
134
+ PiAiRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
135
+ SPLIT_PIPELINE_TOTAL_TIMEOUT_MS: 300000,
136
+ PDRuntimeError: class PDRuntimeError extends Error {
137
+ constructor(public category: string, message: string) {
138
+ super(message);
139
+ this.name = 'PDRuntimeError';
140
+ }
141
+ },
142
+ CandidateIntakeService: MockCandidateIntakeService,
143
+ resolveRuntimeConfig: mockResolveRuntimeConfig,
144
+ isRuntimeConfigError: vi.fn().mockReturnValue(false),
145
+ isFeatureEnabled: vi.fn().mockReturnValue(true),
146
+ resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
147
+ validatePdConfig: vi.fn().mockReturnValue({ valid: true, errors: [] }),
148
+ computeEffectivePdConfig: vi.fn().mockReturnValue({ config: {}, source: 'defaults', warnings: [] }),
149
+ computeFeatureFlagsFromConfig: vi.fn().mockReturnValue({}),
150
+ redactPdConfig: vi.fn().mockImplementation((c) => c),
151
+ run: mockRun,
152
+ status: vi.fn(),
153
+ PrincipleTreeLedgerAdapter: MockPrincipleTreeLedgerAdapter,
154
+ };
155
+ });
156
+
157
+ vi.mock('../../src/config-reader.js', () => ({
158
+ readOutputLanguageFromWorkspace: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
159
+ }));
160
+
161
+ vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
162
+ resolveRuntimeFromPdConfig: mockResolveRuntimeFromPdConfig,
163
+ }));
164
+
165
+ import { registerPainRetryCommand } from '../../src/commands/pain-retry.js';
166
+
167
+ // ── Test data ──────────────────────────────────────────────────────────────────
168
+
169
+ const RETRY_WAIT_TASK = {
170
+ taskId: 'diagnosis_pain-001',
171
+ taskKind: 'diagnostician',
172
+ status: 'retry_wait' as const,
173
+ attemptCount: 1,
174
+ maxAttempts: 3,
175
+ lastError: 'output_invalid',
176
+ leaseOwner: null,
177
+ leaseExpiresAt: null,
178
+ resultRef: null,
179
+ createdAt: new Date().toISOString(),
180
+ updatedAt: new Date().toISOString(),
181
+ };
182
+
183
+ // ── Helpers ────────────────────────────────────────────────────────────────────
184
+
185
+ /** Reset all mocks to a clean default state for the next scenario. */
186
+ function resetMocksForScenario(painId: string): void {
187
+ vi.clearAllMocks();
188
+
189
+ mockResolveRuntimeConfig.mockReturnValue({
190
+ runtimeKind: 'pi-ai',
191
+ provider: 'test-provider',
192
+ model: 'test-model',
193
+ apiKeyEnv: 'TEST_KEY',
194
+ timeoutMs: 300000,
195
+ agentId: 'main',
196
+ });
197
+ mockResolveRuntimeFromPdConfig.mockReturnValue({
198
+ result: {
199
+ runtimeKind: 'pi-ai',
200
+ provider: 'test-provider',
201
+ model: 'test-model',
202
+ apiKeyEnv: 'TEST_KEY',
203
+ timeoutMs: 300000,
204
+ agentId: 'main',
205
+ },
206
+ legacyWarnings: [],
207
+ configSource: '.pd/config.yaml',
208
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
209
+ });
210
+ mockGetCandidatesByTaskId.mockResolvedValue([]);
211
+ mockUpdateCandidateStatus.mockResolvedValue(undefined);
212
+ mockGetRunsByTask.mockResolvedValue([]);
213
+ mockIntake.mockReset();
214
+
215
+ if (painId === 'nonexistent') {
216
+ // Failure scenario: task not found
217
+ mockGetTask.mockResolvedValue(null);
218
+ } else {
219
+ // Success scenario: retry_wait task → succeeded
220
+ mockGetTask.mockResolvedValue({
221
+ ...RETRY_WAIT_TASK,
222
+ taskId: `diagnosis_${painId}`,
223
+ });
224
+ mockRun.mockResolvedValue({
225
+ status: 'succeeded',
226
+ taskId: `diagnosis_${painId}`,
227
+ runId: 'run-retry-1',
228
+ contextHash: 'abc123',
229
+ });
230
+ }
231
+ }
232
+
233
+ // ── Step definitions ───────────────────────────────────────────────────────────
234
+
235
+ const registry = createStepRegistry();
236
+
237
+ registry.given('一个可用的 pd-cli 可执行文件', () => {
238
+ if (typeof registerPainRetryCommand !== 'function') {
239
+ throw new Error('pd-cli pain retry registration not available');
240
+ }
241
+ });
242
+
243
+ registry.when(/operator 执行 "(.+)"/, async (ctx, command: string) => {
244
+ const argv = command.trim().split(/\s+/);
245
+ if (argv.shift() !== 'pd') {
246
+ throw new Error(`expected command to start with pd: ${command}`);
247
+ }
248
+ const painIdIndex = argv.indexOf('--pain-id');
249
+ const painId = painIdIndex >= 0 ? argv[painIdIndex + 1] : '';
250
+ resetMocksForScenario(painId);
251
+
252
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
253
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
254
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
255
+
256
+ try {
257
+ const program = new Command().name('pd').exitOverride();
258
+ registerPainRetryCommand(program.command('pain'));
259
+ await program.parseAsync(['node', 'pd', ...argv]);
260
+ } finally {
261
+ expect(exitSpy).not.toHaveBeenCalledWith(0);
262
+ }
263
+
264
+ const allLogCalls = logSpy.mock.calls.map((c) => String(c[0]));
265
+ const jsonCall = allLogCalls.find((s) => {
266
+ try { JSON.parse(s); return true; } catch { return false; }
267
+ });
268
+ // cli-1: 统计 stdout 上可解析为 JSON 的调用数,用于 Then 步骤验证"恰好一个"
269
+ const jsonCallCount = allLogCalls.filter((s) => {
270
+ try { JSON.parse(s); return true; } catch { return false; }
271
+ }).length;
272
+ const stdout = allLogCalls.join('\n');
273
+ const stderr = errorSpy.mock.calls.map((c) => String(c[0])).join('\n');
274
+ ctx.state.cliResult = { stdout, stderr, jsonCallCount, allLogCallCount: allLogCalls.length };
275
+
276
+ // Restore spies so Then steps don't capture new calls
277
+ logSpy.mockRestore();
278
+ errorSpy.mockRestore();
279
+ exitSpy.mockRestore();
280
+ });
281
+
282
+ registry.then('stdout 是严格的单一 JSON 对象', (ctx) => {
283
+ const result = ctx.state.cliResult as {
284
+ stdout: string;
285
+ jsonCallCount: number;
286
+ allLogCallCount: number;
287
+ };
288
+ const out = result.stdout.trim();
289
+ // Must start with { and parse as a non-null object
290
+ expect(out.startsWith('{')).toBe(true);
291
+ const parsed = JSON.parse(out);
292
+ expect(typeof parsed).toBe('object');
293
+ expect(parsed).not.toBeNull();
294
+ expect(result.allLogCallCount).toBe(1);
295
+ expect(result.jsonCallCount).toBe(1);
296
+ });
297
+
298
+ registry.then('该 JSON 对象可以被 JSON.parse 解析', (ctx) => {
299
+ const result = ctx.state.cliResult as { stdout: string };
300
+ expect(() => JSON.parse(result.stdout.trim())).not.toThrow();
301
+ });
302
+
303
+ registry.then('stdout 不包含任何 banner 或 heading', (ctx) => {
304
+ const result = ctx.state.cliResult as { stdout: string };
305
+ const out = result.stdout.trim();
306
+ expect(out.startsWith('===')).toBe(false);
307
+ expect(out.startsWith('#')).toBe(false);
308
+ expect(out.startsWith('PD CLI')).toBe(false);
309
+ expect(out.startsWith('pd ')).toBe(false);
310
+ // Strict JSON output must start with {
311
+ expect(out.startsWith('{')).toBe(true);
312
+ });
313
+
314
+ registry.then('该 JSON 对象的 status 字段表示失败', (ctx) => {
315
+ const result = ctx.state.cliResult as { stdout: string };
316
+ const parsed = JSON.parse(result.stdout.trim());
317
+ expect(parsed).toHaveProperty('status');
318
+ expect(parsed.status).not.toBe('succeeded');
319
+ });
320
+
321
+ registry.then('该 JSON 对象包含 reason 字段', (ctx) => {
322
+ const result = ctx.state.cliResult as { stdout: string };
323
+ const parsed = JSON.parse(result.stdout.trim());
324
+ expect(parsed).toHaveProperty('reason');
325
+ expect(typeof parsed.reason).toBe('string');
326
+ expect(parsed.reason.length).toBeGreaterThan(0);
327
+ });
328
+
329
+ registry.then('该 JSON 对象包含 nextAction 字段', (ctx) => {
330
+ const result = ctx.state.cliResult as { stdout: string };
331
+ const parsed = JSON.parse(result.stdout.trim());
332
+ expect(parsed).toHaveProperty('nextAction');
333
+ expect(typeof parsed.nextAction).toBe('string');
334
+ expect(parsed.nextAction.length).toBeGreaterThan(0);
335
+ });
336
+
337
+ // ── Load and register feature ──────────────────────────────────────────────────
338
+
339
+ const featureText = readFileSync(
340
+ resolveFeaturePath('docs/specs/features/cli/json-output.feature'),
341
+ 'utf8'
342
+ );
343
+ defineFeature(featureText, registry);