@principles/pd-cli 1.142.3 → 1.143.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 (57) hide show
  1. package/dist/commands/__tests__/telemetry-flag-wiring.test.d.ts +9 -0
  2. package/dist/commands/__tests__/telemetry-flag-wiring.test.d.ts.map +1 -0
  3. package/dist/commands/__tests__/telemetry-flag-wiring.test.js +76 -0
  4. package/dist/commands/__tests__/telemetry-flag-wiring.test.js.map +1 -0
  5. package/dist/commands/__tests__/version.test.d.ts +2 -0
  6. package/dist/commands/__tests__/version.test.d.ts.map +1 -0
  7. package/dist/commands/__tests__/version.test.js +134 -0
  8. package/dist/commands/__tests__/version.test.js.map +1 -0
  9. package/dist/commands/console.d.ts.map +1 -1
  10. package/dist/commands/console.js +29 -10
  11. package/dist/commands/console.js.map +1 -1
  12. package/dist/commands/pain-record.d.ts.map +1 -1
  13. package/dist/commands/pain-record.js +6 -15
  14. package/dist/commands/pain-record.js.map +1 -1
  15. package/dist/commands/runtime-activation.d.ts +29 -0
  16. package/dist/commands/runtime-activation.d.ts.map +1 -1
  17. package/dist/commands/runtime-activation.js +70 -31
  18. package/dist/commands/runtime-activation.js.map +1 -1
  19. package/dist/commands/runtime-internalization-enqueue-successors.d.ts.map +1 -1
  20. package/dist/commands/runtime-internalization-enqueue-successors.js +2 -2
  21. package/dist/commands/runtime-internalization-enqueue-successors.js.map +1 -1
  22. package/dist/commands/runtime-internalization-retry.d.ts.map +1 -1
  23. package/dist/commands/runtime-internalization-retry.js +14 -0
  24. package/dist/commands/runtime-internalization-retry.js.map +1 -1
  25. package/dist/commands/telemetry.d.ts +82 -0
  26. package/dist/commands/telemetry.d.ts.map +1 -0
  27. package/dist/commands/telemetry.js +268 -0
  28. package/dist/commands/telemetry.js.map +1 -0
  29. package/dist/commands/version.d.ts +11 -0
  30. package/dist/commands/version.d.ts.map +1 -0
  31. package/dist/commands/version.js +48 -0
  32. package/dist/commands/version.js.map +1 -0
  33. package/dist/index.js +35 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/services/version-report.d.ts +44 -0
  36. package/dist/services/version-report.d.ts.map +1 -0
  37. package/dist/services/version-report.js +152 -0
  38. package/dist/services/version-report.js.map +1 -0
  39. package/package.json +2 -1
  40. package/src/commands/__tests__/telemetry-flag-wiring.test.ts +84 -0
  41. package/src/commands/__tests__/version.test.ts +141 -0
  42. package/src/commands/console.ts +34 -11
  43. package/src/commands/pain-record.ts +6 -13
  44. package/src/commands/runtime-activation.ts +74 -22
  45. package/src/commands/runtime-internalization-enqueue-successors.ts +3 -2
  46. package/src/commands/runtime-internalization-retry.ts +14 -0
  47. package/src/commands/telemetry.ts +349 -0
  48. package/src/commands/version.ts +49 -0
  49. package/src/index.ts +37 -2
  50. package/src/services/version-report.ts +203 -0
  51. package/tests/commands/console-open.test.ts +55 -5
  52. package/tests/commands/pain-record.test.ts +26 -0
  53. package/tests/commands/runtime-activation-shadow-telemetry.test.ts +130 -0
  54. package/tests/commands/runtime-activation.test.ts +67 -1
  55. package/tests/commands/runtime-internalization-enqueue-successors.test.ts +6 -1
  56. package/tests/commands/telemetry.test.ts +190 -0
  57. package/tests/e2e/cli-full-flow.test.ts +38 -3
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Canonical version report builder (SPEC §12).
3
+ *
4
+ * `pd --version` prints one stable short text line; `pd version --json`
5
+ * exposes the full canonical report: productVersion, releaseId, components,
6
+ * bootstrapVersion, channel, source, generation, health, and the last
7
+ * transaction. The canonical product identity comes from the installation
8
+ * state under ~/.pd — never from a checkout's package.json.
9
+ *
10
+ * The record shapes here mirror the canonical contracts owned by
11
+ * create-principles-disciple/src/update/ (the deep ReleaseManager module);
12
+ * this reader is deliberately thin: it READS installation state and never
13
+ * performs update logic.
14
+ */
15
+
16
+ import * as fs from 'node:fs';
17
+ import * as os from 'node:os';
18
+ import * as path from 'node:path';
19
+
20
+ export type VersionReportSource = 'official-installer' | 'official-legacy-overlay' | 'unknown';
21
+
22
+ export interface VersionReport {
23
+ readonly productVersion: string;
24
+ readonly releaseId: string;
25
+ readonly components: Readonly<Record<string, string>>;
26
+ readonly bootstrapVersion: string;
27
+ readonly channel: 'stable' | 'candidate';
28
+ readonly source: VersionReportSource;
29
+ readonly generation: number;
30
+ readonly health: 'healthy' | 'degraded' | 'corrupt';
31
+ readonly lastTransaction: Readonly<{ id: string; kind: string; outcome: string }> | null;
32
+ }
33
+
34
+ export class VersionReportError extends Error {
35
+ readonly reason: string;
36
+ readonly nextAction: string;
37
+
38
+ constructor(reason: string, message: string, nextAction: string) {
39
+ super(message);
40
+ this.name = 'VersionReportError';
41
+ this.reason = reason;
42
+ this.nextAction = nextAction;
43
+ }
44
+ }
45
+
46
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
48
+ }
49
+
50
+ function readJsonIfPresent(filePath: string): Record<string, unknown> | null {
51
+ if (!fs.existsSync(filePath)) return null;
52
+ let value: unknown;
53
+ try {
54
+ value = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
55
+ } catch (error) {
56
+ throw new VersionReportError(
57
+ 'state_corrupt',
58
+ `Installation state file is not valid JSON: ${filePath} (${error instanceof Error ? error.message : String(error)})`,
59
+ 'Run the official installer recovery, or re-run the official installer to repair the installation record.',
60
+ );
61
+ }
62
+ if (!isPlainObject(value)) {
63
+ throw new VersionReportError(
64
+ 'state_corrupt',
65
+ `Installation state file is not a JSON object: ${filePath}`,
66
+ 'Run the official installer recovery, or re-run the official installer to repair the installation record.',
67
+ );
68
+ }
69
+ return value;
70
+ }
71
+
72
+ /** `pd --version` short stable text contract. */
73
+ export function formatShortVersion(report: Pick<VersionReport, 'productVersion' | 'releaseId'>): string {
74
+ return `Principles Disciple ${report.productVersion} (${report.releaseId.slice(0, 12)})`;
75
+ }
76
+
77
+ function buildLegacyOverlayReport(overlayDir: string, bootstrap: Record<string, unknown> | null): VersionReport {
78
+ // The current official installer copies the plugin package directly into
79
+ // the OpenClaw extension root. Older overlay bundles kept it under plugin/;
80
+ // accept that layout only when the canonical root manifest is absent.
81
+ const rootManifest = readJsonIfPresent(path.join(overlayDir, 'package.json'));
82
+ const pluginManifest = rootManifest ?? readJsonIfPresent(path.join(overlayDir, 'plugin', 'package.json'));
83
+ const version = pluginManifest?.version;
84
+ if (typeof version !== 'string' || version.length === 0) {
85
+ throw new VersionReportError(
86
+ 'legacy_overlay_manifest_invalid',
87
+ `The legacy overlay at ${overlayDir} has no readable plugin version.`,
88
+ 'Re-install PD with the official installer to migrate this installation into the supported layout.',
89
+ );
90
+ }
91
+ const bootstrapVersion = bootstrap?.bootstrapVersion;
92
+ return {
93
+ productVersion: version,
94
+ releaseId: '0'.repeat(64),
95
+ components: { plugin: version },
96
+ bootstrapVersion: typeof bootstrapVersion === 'string' ? bootstrapVersion : 'unknown',
97
+ channel: 'stable',
98
+ source: 'official-legacy-overlay',
99
+ generation: 0,
100
+ health: 'degraded',
101
+ lastTransaction: null,
102
+ };
103
+ }
104
+
105
+ function readLastTransaction(historyPath: string): VersionReport['lastTransaction'] {
106
+ if (!fs.existsSync(historyPath)) return null;
107
+ const lines = fs.readFileSync(historyPath, 'utf8').split('\n').filter((line) => line.trim().length > 0);
108
+ const lastLine = lines[lines.length - 1];
109
+ if (lastLine === undefined) return null;
110
+ let value: unknown;
111
+ try {
112
+ value = JSON.parse(lastLine) as unknown;
113
+ } catch (error) {
114
+ throw new VersionReportError(
115
+ 'state_corrupt',
116
+ `The last transaction record is not valid JSON: ${historyPath} (${error instanceof Error ? error.message : String(error)})`,
117
+ 'Run the official installer recovery to reconcile the transaction journal before trusting the installed version.',
118
+ );
119
+ }
120
+ if (!isPlainObject(value)) return null;
121
+ const { transactionId, kind, outcome } = value;
122
+ if (typeof transactionId !== 'string' || typeof kind !== 'string' || typeof outcome !== 'string') {
123
+ return null;
124
+ }
125
+ return { id: transactionId, kind, outcome };
126
+ }
127
+
128
+ /**
129
+ * Builds the canonical version report from the installation state. Throws
130
+ * VersionReportError with an installer next-action when no installation
131
+ * exists at all.
132
+ */
133
+ export function buildVersionReport(homeDir: string = os.homedir()): VersionReport {
134
+ const pdHome = path.join(homeDir, '.pd');
135
+ const active = readJsonIfPresent(path.join(pdHome, 'active.json'));
136
+ const bootstrap = readJsonIfPresent(path.join(pdHome, 'bootstrap', 'bootstrap.json'));
137
+ const installConfig = readJsonIfPresent(path.join(pdHome, 'install.json'));
138
+
139
+ const overlayDir = path.join(homeDir, '.openclaw', 'extensions', 'principles-disciple');
140
+
141
+ if (active === null && fs.existsSync(overlayDir)) {
142
+ return buildLegacyOverlayReport(overlayDir, bootstrap);
143
+ }
144
+ if (active === null && !fs.existsSync(pdHome)) {
145
+ throw new VersionReportError(
146
+ 'not_installed',
147
+ 'No PD installation was found under ~/.pd or the legacy overlay location.',
148
+ 'Install PD with the official installer (npx create-principles-disciple), then run pd version again.',
149
+ );
150
+ }
151
+ if (active === null) {
152
+ throw new VersionReportError(
153
+ 'active_record_missing',
154
+ 'The ~/.pd installation exists but has no active release record.',
155
+ 'Run the official installer to complete the installation, or re-run it to repair the record.',
156
+ );
157
+ }
158
+
159
+ const { generation, releaseId, productVersion } = active;
160
+ if (typeof generation !== 'number' || !Number.isSafeInteger(generation) || generation < 1
161
+ || typeof releaseId !== 'string' || releaseId.length === 0
162
+ || typeof productVersion !== 'string' || productVersion.length === 0) {
163
+ throw new VersionReportError(
164
+ 'active_record_corrupt',
165
+ 'The active release record under ~/.pd is malformed.',
166
+ 'Run the official installer recovery, or re-run the official installer to repair the installation record.',
167
+ );
168
+ }
169
+
170
+ const releaseDir = path.join(pdHome, 'releases', releaseId);
171
+ const releaseManifest = readJsonIfPresent(path.join(releaseDir, 'metadata.json'));
172
+ const releaseMetadataMatchesActive = releaseManifest !== null
173
+ && releaseManifest.productVersion === productVersion
174
+ && releaseManifest.releaseId === releaseId
175
+ && releaseManifest.metadataDigest === active.releaseMetadataDigest;
176
+ const health: VersionReport['health'] = releaseManifest === null
177
+ ? 'degraded'
178
+ : releaseMetadataMatchesActive ? 'healthy' : 'corrupt';
179
+
180
+ const components: Record<string, string> = {};
181
+ for (const component of ['plugin', 'console', 'core', 'pd-cli', 'host-runtime', 'install-layout']) {
182
+ const manifest = readJsonIfPresent(path.join(releaseDir, component, 'package.json'));
183
+ const version = manifest?.version;
184
+ if (typeof version === 'string') {
185
+ components[component] = version;
186
+ }
187
+ }
188
+
189
+ const bootstrapVersion = bootstrap?.bootstrapVersion;
190
+ const channelValue = installConfig?.channel;
191
+
192
+ return {
193
+ productVersion,
194
+ releaseId,
195
+ components,
196
+ bootstrapVersion: typeof bootstrapVersion === 'string' ? bootstrapVersion : 'unknown',
197
+ channel: channelValue === 'candidate' ? 'candidate' : 'stable',
198
+ source: 'official-installer',
199
+ generation,
200
+ health,
201
+ lastTransaction: readLastTransaction(path.join(pdHome, 'logs', 'history.jsonl')),
202
+ };
203
+ }
@@ -826,15 +826,22 @@ describe('CLI command wiring (pd console open)', () => {
826
826
  }
827
827
  });
828
828
 
829
- it('[::1] is accepted and normalized to ::1 (not refused)', () => {
829
+ it('[::1] is accepted and normalized to ::1 (not refused)', async (ctx) => {
830
+ // PRI-581 / ERR-111: IPv6 loopback is an optional host capability — a
831
+ // global TUN VPN / WFP filter driver can silently block ::1 connections
832
+ // (bind succeeds, connect returns EACCES) while the IPv6 stack itself is
833
+ // healthy. Probe-and-skip instead of hard-failing for 8s on such hosts.
834
+ if (!(await isIpv6LoopbackUsable())) {
835
+ ctx.skip({ reason: 'IPv6 loopback (::1) is filtered on this machine (VPN/WFP filter driver? — see ERR-111 / PRI-581)' });
836
+ return;
837
+ }
830
838
  // May spawn a long-lived server, use timeout.
831
839
  const out = runPd(['console', 'open', '--workspace', tmp, '--host', '[::1]', '--json', '--no-browser'], workspaceRoot, 8_000);
832
840
  if (out.trim() === '') {
833
841
  // The CLI was still in its 15s ready-poll when execFileSync timed out.
834
- // Known environment limitation: on Windows hosts where IPv6 loopback
835
- // connections are refused (EACCES), the health probe on ::1 never
836
- // succeeds. CI (Linux) is unaffected.
837
- throw new Error('CLI produced no JSON within 8s — ::1 health probe never succeeded (IPv6 loopback may be blocked on this machine)');
842
+ // The capability probe above passed, so this is a genuine failure —
843
+ // the health probe on ::1 never succeeded despite ::1 being reachable.
844
+ throw new Error('CLI produced no JSON within 8s — ::1 health probe never succeeded (IPv6 loopback probe passed; this is a real failure)');
838
845
  }
839
846
  const parsed = JSON.parse(out);
840
847
  // Should NOT be refused — [::1] is loopback
@@ -861,6 +868,49 @@ describe('CLI command wiring (pd console open)', () => {
861
868
  });
862
869
  });
863
870
 
871
+ /**
872
+ * Probe whether IPv6 loopback (::1) is actually usable on this machine.
873
+ * Mirrors the production path (TCP connect), not ICMP ping: on hosts where a
874
+ * VPN/WFP filter driver intercepts ::1 (bind OK + connect EACCES — ERR-111 /
875
+ * PRI-581), the console health probe on ::1 can never succeed, so the CLI
876
+ * test above must skip rather than hard-fail for environmental reasons.
877
+ */
878
+ function isIpv6LoopbackUsable(): Promise<boolean> {
879
+ return new Promise((resolve) => {
880
+ const server = net.createServer();
881
+ let settled = false;
882
+ const finish = (ok: boolean) => {
883
+ if (settled) return;
884
+ settled = true;
885
+ server.close();
886
+ resolve(ok);
887
+ };
888
+ const timer = setTimeout(() => finish(false), 1_500);
889
+ server.once('error', () => {
890
+ clearTimeout(timer);
891
+ finish(false);
892
+ });
893
+ server.listen(0, '::1', () => {
894
+ const addr = server.address();
895
+ const port = addr !== null && typeof addr === 'object' ? addr.port : 0;
896
+ if (port === 0) {
897
+ clearTimeout(timer);
898
+ finish(false);
899
+ return;
900
+ }
901
+ const socket = net.connect({ port, host: '::1' }, () => {
902
+ socket.destroy();
903
+ clearTimeout(timer);
904
+ finish(true);
905
+ });
906
+ socket.once('error', () => {
907
+ clearTimeout(timer);
908
+ finish(false);
909
+ });
910
+ });
911
+ });
912
+ }
913
+
864
914
  function runPd(args: string[], cwd: string, timeoutMs?: number): string {
865
915
  try {
866
916
  return execFileSync('node', [getBuiltPdCliPath(), ...args], {
@@ -55,6 +55,7 @@ vi.mock('@principles/core/runtime-v2', () => ({
55
55
  agentId: 'main',
56
56
  }),
57
57
  isRuntimeConfigError: vi.fn().mockReturnValue(false),
58
+ isBuiltinPiAiProvider: vi.fn().mockReturnValue(true),
58
59
  resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
59
60
  isFeatureEnabled: vi.fn().mockReturnValue(false),
60
61
  }));
@@ -76,6 +77,7 @@ vi.mock('../../src/services/pd-config-loader.js', () => ({
76
77
  }));
77
78
 
78
79
  import { handlePainRecord } from '../../src/commands/pain-record.js';
80
+ import { isBuiltinPiAiProvider } from '@principles/core/runtime-v2';
79
81
  import type { PainToPrincipleOutput, PainToPrincipleInput, FailureCategory } from '@principles/core/runtime-v2';
80
82
 
81
83
  // ── Helpers ─────────────────────────────────────────────────────────────────
@@ -478,4 +480,28 @@ describe('pd pain record', () => {
478
480
  errorSpy.mockRestore();
479
481
  exitSpy.mockRestore();
480
482
  });
483
+
484
+ // 9. PRI-621 PR2: the provider catalog is queried through @principles/core.
485
+ // A provider outside the builtin pi-ai catalog without baseUrl is reported
486
+ // as missing configuration — it must not be silently accepted.
487
+ it('reports baseUrl as missing configuration for a non-builtin provider', async () => {
488
+ mockRecordPainResult = makeFailedResult({
489
+ failureCategory: 'config_missing' as FailureCategory,
490
+ message: 'provider not in builtin catalog',
491
+ });
492
+ vi.mocked(isBuiltinPiAiProvider).mockReturnValue(false);
493
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
494
+ const exitSpy = mockProcessExit();
495
+
496
+ await handlePainRecord({ reason: 'test pain' });
497
+
498
+ const printed = errorSpy.mock.calls.map((c) => String(c[0])).join('\n');
499
+ expect(printed).toContain('Missing configuration:');
500
+ expect(printed).toContain('- baseUrl');
501
+ expect(exitSpy).toHaveBeenCalledWith(1);
502
+
503
+ vi.mocked(isBuiltinPiAiProvider).mockReturnValue(true);
504
+ errorSpy.mockRestore();
505
+ exitSpy.mockRestore();
506
+ });
481
507
  });
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import {
6
+ collectRuleCodeEventEntries,
7
+ readShadowSummaryForActivation,
8
+ } from '../../src/commands/runtime-activation.js';
9
+
10
+ /**
11
+ * PRI-577 regression tests — RuleCode shadow telemetry directory mismatch.
12
+ *
13
+ * Production writes rulehost_evaluated events to `{workspace}/.state/logs/` via the
14
+ * v1 EventLog component, while the reader used to scan only `{workspace}/.pd/logs/`
15
+ * (a directory no production code ever creates). These tests pin the real-world
16
+ * directory layout so a future refactor cannot silently re-break the channel.
17
+ */
18
+
19
+ const ACTIVATION_ID = 'act_code_test-rule';
20
+
21
+ function makeEventLine(eventOverrides: Record<string, unknown> = {}, dataOverrides: Record<string, unknown> = {}): string {
22
+ return JSON.stringify({
23
+ ts: '2026-08-21T00:47:00.469Z',
24
+ type: 'rulehost_evaluated',
25
+ category: 'evaluated',
26
+ data: {
27
+ toolName: 'write_file',
28
+ filePath: '<redacted>',
29
+ matched: true,
30
+ decision: 'block',
31
+ ruleId: 'rule-1',
32
+ activationId: ACTIVATION_ID,
33
+ activationMode: 'shadow',
34
+ ...dataOverrides,
35
+ },
36
+ ...eventOverrides,
37
+ });
38
+ }
39
+
40
+ describe('PRI-577 shadow telemetry dual-directory reading', () => {
41
+ let workspaceDir: string;
42
+
43
+ beforeEach(() => {
44
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pri577-ws-'));
45
+ });
46
+
47
+ afterEach(() => {
48
+ fs.rmSync(workspaceDir, { recursive: true, force: true });
49
+ });
50
+
51
+ function writeEventsFile(relLogsDir: string, date: string, lines: string[]): void {
52
+ const dir = path.join(workspaceDir, ...relLogsDir.split('/'));
53
+ fs.mkdirSync(dir, { recursive: true });
54
+ fs.writeFileSync(path.join(dir, `events_${date}.jsonl`), lines.join('\n') + '\n', 'utf8');
55
+ }
56
+
57
+ it('reads shadow metrics when events live only in legacy .state/logs (production layout)', () => {
58
+ writeEventsFile('.state/logs', '2026-08-21', [
59
+ makeEventLine(),
60
+ makeEventLine({}, { toolName: 'exec', filePath: 'x', matched: false, decision: 'allow' }),
61
+ ]);
62
+
63
+ const summary = readShadowSummaryForActivation(workspaceDir, ACTIVATION_ID);
64
+ expect(summary.observed).toBe(2);
65
+ expect(summary.matched).toBe(1);
66
+ expect(summary.wouldBlock).toBe(1);
67
+ expect(summary.firstObservedAt).not.toBeNull();
68
+ });
69
+
70
+ it('merges entries when both .pd/logs and .state/logs exist', () => {
71
+ writeEventsFile('.state/logs', '2026-08-21', [makeEventLine()]);
72
+ writeEventsFile('.pd/logs', '2026-08-22', [
73
+ makeEventLine({ ts: '2026-08-22T10:00:00.000Z' }),
74
+ ]);
75
+ const summary = readShadowSummaryForActivation(workspaceDir, ACTIVATION_ID);
76
+ expect(summary.observed).toBe(2);
77
+ expect(summary.lastObservedAt).toBe('2026-08-22T10:00:00.000Z');
78
+ });
79
+
80
+ it('keeps distinct events from same-named daily files and deduplicates exact copied lines', () => {
81
+ const copied = makeEventLine();
82
+ writeEventsFile('.state/logs', '2026-08-21', [
83
+ copied,
84
+ makeEventLine({}, { toolName: 'exec', filePath: 'x', matched: false, decision: 'allow' }),
85
+ ]);
86
+ writeEventsFile('.pd/logs', '2026-08-21', [copied]);
87
+
88
+ const summary = readShadowSummaryForActivation(workspaceDir, ACTIVATION_ID);
89
+ expect(summary.observed).toBe(2);
90
+ expect(summary.wouldAllow).toBe(1);
91
+ });
92
+
93
+ it('reports zero (not unavailable) when a log dir exists but has no matching events', () => {
94
+ writeEventsFile('.state/logs', '2026-08-21', [
95
+ JSON.stringify({ ts: '2026-08-21T00:00:00.000Z', type: 'other_event', category: 'x', data: {} }),
96
+ ]);
97
+
98
+ const summary = readShadowSummaryForActivation(workspaceDir, ACTIVATION_ID);
99
+ // Distinguishes "channel alive, no data" (zeros) from "no channel" (nulls).
100
+ expect(summary.observed).toBe(0);
101
+ expect(summary.matched).toBe(0);
102
+ });
103
+
104
+ it('returns unavailable summary only when neither candidate directory exists', () => {
105
+ const summary = readShadowSummaryForActivation(workspaceDir, ACTIVATION_ID);
106
+ expect(summary).toEqual({
107
+ observed: null, matched: null, wouldBlock: null, wouldAllow: null,
108
+ requireApproval: null, autoCorrect: null, errors: null, neutralControl: null,
109
+ firstObservedAt: null, lastObservedAt: null,
110
+ });
111
+ });
112
+
113
+ it('does not report an unreadable candidate path as a healthy zero-event channel', () => {
114
+ fs.mkdirSync(path.join(workspaceDir, '.state'), { recursive: true });
115
+ fs.writeFileSync(path.join(workspaceDir, '.state', 'logs'), 'not a directory');
116
+ const summary = readShadowSummaryForActivation(workspaceDir, ACTIVATION_ID);
117
+ expect(summary.observed).toBeNull();
118
+ });
119
+
120
+ it('excludes malformed telemetry lines instead of failing the whole scan', () => {
121
+ writeEventsFile('.state/logs', '2026-08-21', [
122
+ '{not-valid-json',
123
+ makeEventLine(),
124
+ ]);
125
+
126
+ const collected = collectRuleCodeEventEntries(workspaceDir);
127
+ expect(collected.entries.length).toBe(1);
128
+ expect(collected.sourceDirsFound).toBeGreaterThanOrEqual(1);
129
+ });
130
+ });
@@ -1,4 +1,8 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
2
6
 
3
7
  const mockRuleHostWriterConfigs = vi.hoisted(() => [] as Array<{ featureFlagProbe?: (flagId: string) => boolean }>);
4
8
  const mockFeatureFlags = vi.hoisted(() => ({
@@ -100,7 +104,7 @@ vi.mock('@principles/core/runtime-v2', async (importOriginal) => {
100
104
  }),
101
105
  RuleHostWriter: vi.fn().mockImplementation(function (config) {
102
106
  mockRuleHostWriterConfigs.push(config);
103
- return { channel: 'code_tool_hook' };
107
+ return { channel: 'code_tool_hook', canActivate: async () => ({ ok: true }) };
104
108
  }),
105
109
  resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
106
110
  };
@@ -1007,6 +1011,68 @@ describe('handleRuntimeActivationPromote', () => {
1007
1011
  expect(mockPromoteActivation).not.toHaveBeenCalled();
1008
1012
  expect(process.exitCode).toBe(1);
1009
1013
  });
1014
+
1015
+ it('P0-3: commit store failure emits structured refusal JSON and exit code 1', async () => {
1016
+ mockFeatureFlags.flags.rulecode_owner_live_decision.enabled = true;
1017
+ vi.stubEnv('PD_CONSOLE_TOKEN', 'configured-secret');
1018
+ vi.stubEnv('PD_OWNER_ID', 'owner-1');
1019
+ vi.stubEnv('PD_OWNER_CREDENTIAL_ID', 'credential-1');
1020
+
1021
+ const artifact = makeArtifact({
1022
+ artifactId: 'art-002',
1023
+ lineageArtifactIds: ['task-000'],
1024
+ contentJson: JSON.stringify({
1025
+ principleId: 'P_001',
1026
+ affectedTools: ['Bash'],
1027
+ goldenTrace: {
1028
+ traceId: 'trace-1',
1029
+ sourcePainId: 'pain-1',
1030
+ cases: [{ caseId: 'c1', kind: 'positive', toolName: 'Bash', params: {}, expectedDecision: 'allow' }],
1031
+ createdAt: '2026-01-01T00:00:00.000Z',
1032
+ },
1033
+ }),
1034
+ });
1035
+ mockGetArtifactById.mockResolvedValue(artifact);
1036
+ const artifactDigest = `sha256:${createHash('sha256').update(JSON.stringify(artifact), 'utf8').digest('hex')}`;
1037
+
1038
+ // PRI-577 fail-loud contract: readiness is unavailable without a readable
1039
+ // shadow telemetry source, so seed a real events file before promoting.
1040
+ const wsDir = mkdtempSync(path.join(tmpdir(), 'pd-cli-p03-'));
1041
+ mkdirSync(path.join(wsDir, '.pd', 'logs'), { recursive: true });
1042
+ writeFileSync(
1043
+ path.join(wsDir, '.pd', 'logs', 'events_20260821.jsonl'),
1044
+ `${JSON.stringify({ type: 'rulehost_evaluated', ts: '2026-08-21T00:00:00.000Z', data: { activationId: 'act-hook-1', activationMode: 'shadow', matched: true, decision: 'allow' } })}\n`,
1045
+ 'utf8',
1046
+ );
1047
+
1048
+ try {
1049
+ await handleRuntimeActivationPromote({
1050
+ workspace: wsDir,
1051
+ activationId: 'act-hook-1',
1052
+ confirm: true,
1053
+ json: true,
1054
+ artifactId: 'art-002',
1055
+ artifactDigest,
1056
+ controlVersion: 1,
1057
+ idempotencyKey: 'promote-commit-fail-1',
1058
+ reasonCode: 'controlled_rollout',
1059
+ note: 'Owner accepts limited evidence for a controlled rollout.',
1060
+ });
1061
+
1062
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]) as {
1063
+ ok: boolean; decision: string; reasonCode: string; summary?: string; nextAction?: string;
1064
+ };
1065
+ expect(output.ok).toBe(false);
1066
+ expect(output.decision).toBe('refused');
1067
+ expect(output.reasonCode).toBe('promotion_commit_failed');
1068
+ expect(output.summary).toContain('durable safety store');
1069
+ expect(output.nextAction).toContain('retry');
1070
+ expect(process.exitCode).toBe(1);
1071
+ expect(mockPromoteActivation).not.toHaveBeenCalled();
1072
+ } finally {
1073
+ try { rmSync(wsDir, { recursive: true, force: true }); } catch { /* best effort */ }
1074
+ }
1075
+ });
1010
1076
  });
1011
1077
 
1012
1078
  // P1 #2 fix: Edit pending approval command tests
@@ -13,7 +13,12 @@ vi.mock('../../src/resolve-workspace.js', () => ({
13
13
  resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
14
14
  }));
15
15
 
16
- vi.mock('@principles/core/runtime-v2', () => ({
16
+ // PRI-612: spread the real barrel exports so pure constants consumed by the
17
+ // command (PD_TASK_STATUSES) stay authentic; only the stateful classes are
18
+ // mocked. Previously a bare factory mock left new barrel exports undefined
19
+ // at runtime (ERR-083 vi.mock variant — 19 tests failed on CI).
20
+ vi.mock('@principles/core/runtime-v2', async (importOriginal) => ({
21
+ ...(await importOriginal<typeof import('@principles/core/runtime-v2')>()),
17
22
  RuntimeStateManager: vi.fn().mockImplementation(function (opts: Record<string, unknown>) {
18
23
  mockRuntimeStateManagerOpts(opts);
19
24
  return {