@principles/pd-cli 1.142.2 → 1.142.4

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 (53) 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/telemetry.d.ts +82 -0
  23. package/dist/commands/telemetry.d.ts.map +1 -0
  24. package/dist/commands/telemetry.js +268 -0
  25. package/dist/commands/telemetry.js.map +1 -0
  26. package/dist/commands/version.d.ts +11 -0
  27. package/dist/commands/version.d.ts.map +1 -0
  28. package/dist/commands/version.js +48 -0
  29. package/dist/commands/version.js.map +1 -0
  30. package/dist/index.js +35 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/services/version-report.d.ts +44 -0
  33. package/dist/services/version-report.d.ts.map +1 -0
  34. package/dist/services/version-report.js +152 -0
  35. package/dist/services/version-report.js.map +1 -0
  36. package/package.json +3 -2
  37. package/src/commands/__tests__/telemetry-flag-wiring.test.ts +84 -0
  38. package/src/commands/__tests__/version.test.ts +141 -0
  39. package/src/commands/console.ts +34 -11
  40. package/src/commands/pain-record.ts +6 -13
  41. package/src/commands/runtime-activation.ts +74 -22
  42. package/src/commands/runtime-internalization-enqueue-successors.ts +3 -2
  43. package/src/commands/telemetry.ts +349 -0
  44. package/src/commands/version.ts +49 -0
  45. package/src/index.ts +37 -2
  46. package/src/services/version-report.ts +203 -0
  47. package/tests/commands/console-open.test.ts +55 -5
  48. package/tests/commands/pain-record.test.ts +26 -0
  49. package/tests/commands/runtime-activation-shadow-telemetry.test.ts +130 -0
  50. package/tests/commands/runtime-activation.test.ts +67 -1
  51. package/tests/commands/runtime-internalization-enqueue-successors.test.ts +6 -1
  52. package/tests/commands/telemetry.test.ts +190 -0
  53. package/tests/e2e/cli-full-flow.test.ts +38 -3
@@ -23,7 +23,9 @@ import {
23
23
  SqliteActivationSafetyStore,
24
24
  collectOpenClawPromotionChecks,
25
25
  summarizeRuleCodeShadowEvents,
26
+ buildPromotionEvidenceSnapshot,
26
27
  } from '@principles/core/runtime-v2';
28
+ import { resolveOwnerIdentity, defaultOwnerIdentityHomeDir } from '@principles/core/runtime-v2';
27
29
  import type {
28
30
  ActivationDecision,
29
31
  PIArtifactSnapshot,
@@ -53,11 +55,65 @@ function unavailableShadowSummary(): PromotionEvidenceSnapshot['shadowSummary']
53
55
  };
54
56
  }
55
57
 
56
- function readShadowSummary(workspaceDir: string, activationId: string): PromotionEvidenceSnapshot['shadowSummary'] {
57
- const logsDir = path.join(workspaceDir, '.pd', 'logs'); if (!fs.existsSync(logsDir)) return unavailableShadowSummary();
58
+ /**
59
+ * PRI-577: RuleCode event telemetry candidate directories, in priority order.
60
+ *
61
+ * Runtime V2 convention is `.pd/logs`, but the v1 EventLog writer
62
+ * (openclaw-plugin `src/core/event-log.ts`) still emits `events_*.jsonl` under
63
+ * `.state/logs`. No production code ever created `.pd/logs`, so scanning only
64
+ * that path made every shadow metric report "unavailable" while 2500+ real
65
+ * evaluations sat unread in `.state/logs`. Readers scan both candidates until
66
+ * the writer migrates (ERR-031: both readers derive from this same list).
67
+ */
68
+ export const RULECODE_EVENT_LOG_CANDIDATE_DIRS: readonly string[] = ['.pd/logs', '.state/logs'];
69
+
70
+ export interface CollectedRuleCodeEventEntries {
71
+ entries: unknown[];
72
+ /** Number of candidate directories that actually exist on disk. */
73
+ sourceDirsFound: number;
74
+ }
75
+
76
+ /**
77
+ * Collect rulehost telemetry entries from all candidate log directories.
78
+ * Malformed lines are excluded individually. A directory counts as a source
79
+ * only after it can be enumerated, so an unreadable path cannot be mistaken
80
+ * for a healthy channel with zero events (ERR-002).
81
+ *
82
+ * Exact lines copied between candidate directories are deduplicated by
83
+ * priority. Different events in same-named daily files are retained.
84
+ */
85
+ export function collectRuleCodeEventEntries(workspaceDir: string): CollectedRuleCodeEventEntries {
58
86
  const entries: unknown[] = [];
59
- try { for (const file of fs.readdirSync(logsDir).filter(name => /^events_.*\.jsonl$/.test(name)).sort().slice(-7)) for (const line of fs.readFileSync(path.join(logsDir, file), 'utf8').split('\n').filter(Boolean)) { try { entries.push(JSON.parse(line) as unknown); } catch { /* exclude malformed telemetry */ } } }
60
- catch { return unavailableShadowSummary(); }
87
+ let sourceDirsFound = 0;
88
+ const higherPriorityLines = new Set<string>();
89
+ for (const candidate of RULECODE_EVENT_LOG_CANDIDATE_DIRS) {
90
+ const logsDir = path.join(workspaceDir, ...candidate.split('/'));
91
+ if (!fs.existsSync(logsDir)) continue;
92
+ try {
93
+ const files = fs.readdirSync(logsDir)
94
+ .filter(name => /^events_.*\.jsonl$/.test(name))
95
+ .sort()
96
+ .slice(-7);
97
+ sourceDirsFound += 1;
98
+ const currentSourceLines: string[] = [];
99
+ for (const file of files) {
100
+ const lines = fs.readFileSync(path.join(logsDir, file), 'utf8').split('\n').filter(Boolean);
101
+ for (const line of lines) {
102
+ currentSourceLines.push(line);
103
+ if (higherPriorityLines.has(line)) continue;
104
+ try { entries.push(JSON.parse(line) as unknown); } catch { /* exclude malformed telemetry */ }
105
+ }
106
+ }
107
+ for (const line of currentSourceLines) higherPriorityLines.add(line);
108
+ } catch { /* unreadable directory contributes no entries */ }
109
+ }
110
+ return { entries, sourceDirsFound };
111
+ }
112
+
113
+ /** Exported for PRI-577 regression tests and reuse by promote evidence assembly. */
114
+ export function readShadowSummaryForActivation(workspaceDir: string, activationId: string): PromotionEvidenceSnapshot['shadowSummary'] {
115
+ const { entries, sourceDirsFound } = collectRuleCodeEventEntries(workspaceDir);
116
+ if (sourceDirsFound === 0) return unavailableShadowSummary();
61
117
  return summarizeRuleCodeShadowEvents(entries, activationId);
62
118
  }
63
119
 
@@ -445,8 +501,9 @@ export async function handleRuntimeActivationPromote(opts: ActivationPromoteOpti
445
501
  try {
446
502
  const workspaceDir = opts.workspace ? path.resolve(opts.workspace) : resolveWorkspaceDir();
447
503
  const flags = computeFlagsFromLoadResult(loadPdConfig(workspaceDir));
448
- const ownerId = process.env.PD_OWNER_ID?.trim();
449
- const credentialId = process.env.PD_OWNER_CREDENTIAL_ID?.trim();
504
+ // ADR-0022 (PRI-578): single resolver — env > ~/.pd/owner.json > none
505
+ const identity = resolveOwnerIdentity(process.env, defaultOwnerIdentityHomeDir());
506
+ const { ownerId, credentialId } = identity;
450
507
  const consoleToken = process.env.PD_CONSOLE_TOKEN?.trim();
451
508
  const operatorId = process.env.USERNAME?.trim() || process.env.USER?.trim();
452
509
  const actor = ownerId && credentialId && consoleToken
@@ -498,22 +555,17 @@ export async function handleRuntimeActivationPromote(opts: ActivationPromoteOpti
498
555
  validateProductionArtifact: value => writer.canActivate(value),
499
556
  });
500
557
  },
501
- buildEvidenceSnapshot: (checks, artifact) => {
502
- const createdAt = new Date().toISOString();
503
- const artifactDigest = artifact
504
- ? `sha256:${createHash('sha256').update(JSON.stringify(artifact), 'utf8').digest('hex')}`
505
- : request.expectedArtifactDigest;
506
- const snapshotBody = JSON.stringify({ artifactDigest, checks, createdAt });
507
- return {
508
- snapshotId: `snapshot-${randomUUID()}`,
509
- snapshotDigest: `sha256:${createHash('sha256').update(snapshotBody, 'utf8').digest('hex')}`,
510
- artifactDigest,
511
- lineageRefs: artifact ? [artifact.sourceTaskId, ...artifact.lineageArtifactIds] : [],
512
- hostRuntimeVersion: 'openclaw-legacy@1', safetyGateResults: checks,
513
- shadowSummary: readShadowSummary(workspaceDir, activationId),
514
- configurationVersion: 'pd-config-current',
515
- redaction: { version: 'v1', rawParametersStored: false }, createdAt,
516
- };
558
+ buildEvidenceSnapshot: (checks, artifact, evaluationId) => {
559
+ return buildPromotionEvidenceSnapshot({
560
+ activationId,
561
+ evaluationId,
562
+ checks,
563
+ artifact,
564
+ expectedArtifactDigest: request.expectedArtifactDigest,
565
+ ownerIdentity: actor,
566
+ hostRuntimeVersion: 'openclaw-legacy@1',
567
+ shadowSummary: readShadowSummaryForActivation(workspaceDir, activationId),
568
+ });
517
569
  },
518
570
  newEvaluationId: () => `readiness-${randomUUID()}`,
519
571
  });
@@ -4,8 +4,9 @@ import {
4
4
  InternalizationOrchestrator,
5
5
  isPeerRunnerKind,
6
6
  hydratePITaskRecord,
7
+ PD_TASK_STATUSES,
7
8
  } from '@principles/core/runtime-v2';
8
- import type { CommitNextTaskResult } from '@principles/core/runtime-v2';
9
+ import type { CommitNextTaskResult, PDTaskStatus } from '@principles/core/runtime-v2';
9
10
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
10
11
 
11
12
  interface EnqueueSuccessorsOptions {
@@ -236,7 +237,7 @@ interface SuccessorIndexEntry {
236
237
  async function buildSuccessorIndex(
237
238
  stateManager: RuntimeStateManager,
238
239
  ): Promise<Map<string, SuccessorIndexEntry>> {
239
- const allStatuses: ('pending' | 'retry_wait' | 'succeeded' | 'leased' | 'failed' | 'needs_human_review')[] = ['pending', 'retry_wait', 'succeeded', 'leased', 'failed', 'needs_human_review'];
240
+ const allStatuses: PDTaskStatus[] = [...PD_TASK_STATUSES];
240
241
  const index = new Map<string, SuccessorIndexEntry>();
241
242
  const results = await Promise.all(
242
243
  allStatuses.map(status => stateManager.listTasks({ status })),
@@ -0,0 +1,349 @@
1
+ /**
2
+ * pd telemetry — Anonymous Product Telemetry v1 control plane
3
+ * (PRI-597, SPEC §40-§43).
4
+ *
5
+ * Consent lives at the machine level (~/.pd/product-telemetry.json via
6
+ * host-runtime); measurement (daily ID, dedup, retry, lock) is
7
+ * workspace-scoped. The release feature flag (anonymous_product_telemetry)
8
+ * is an INDEPENDENT gate read from the resolved workspace's .pd/config.yaml.
9
+ *
10
+ * Commands:
11
+ * pd telemetry status — gates, consent, eligibility, bounded export status
12
+ * pd telemetry enable — explicit consent (default dry-run; --confirm to write)
13
+ * pd telemetry disable — deny consent + delete local identity (default dry-run)
14
+ * pd telemetry reset — rotate/delete identity (default dry-run)
15
+ * pd telemetry preview — exact outbound payload; never sends
16
+ *
17
+ * --json outputs exactly one parseable object; failures carry reason +
18
+ * nextAction; the telemetry secret is never printed.
19
+ */
20
+
21
+ import * as path from 'path';
22
+ import type { Command } from 'commander';
23
+ import {
24
+ createProductTelemetryService,
25
+ PREVIEW_BANNER,
26
+ type ProductTelemetryStatusView,
27
+ } from '@principles/host-runtime';
28
+ import { resolveWorkspaceDir } from '../resolve-workspace.js';
29
+ import { emitError, emitFlagConflict, emitResult } from '../services/cli-output.js';
30
+
31
+ // ── Output types ─────────────────────────────────────────────────────────────
32
+
33
+ export interface TelemetryStatusOutput {
34
+ status: 'ok' | 'degraded' | 'failed';
35
+ command: 'telemetry:status';
36
+ consent: string;
37
+ consentVersion: string;
38
+ hasSecret: boolean;
39
+ flagEnabled: boolean | null;
40
+ flagSource: string | null;
41
+ environmentSuppressed: boolean;
42
+ suppressionReasons: string[];
43
+ canExport: boolean;
44
+ blockers: string[];
45
+ lastAttemptedAt?: string;
46
+ lastSucceededAt?: string;
47
+ lastFailureCode?: string;
48
+ nextRetryAt?: string;
49
+ endpoint: string;
50
+ workspaceDir?: string;
51
+ nextAction?: string;
52
+ reason?: string;
53
+ }
54
+
55
+ export interface TelemetryMutationOutput {
56
+ status: 'ok' | 'failed';
57
+ command: 'telemetry:enable' | 'telemetry:disable' | 'telemetry:reset';
58
+ dryRun: boolean;
59
+ applied: boolean;
60
+ consent?: string;
61
+ /** What the applied/dry-run mutation changes, in plain terms. */
62
+ effect: string;
63
+ reason?: string;
64
+ nextAction?: string;
65
+ }
66
+
67
+ export interface TelemetryPreviewOutput {
68
+ status: 'ok';
69
+ command: 'telemetry:preview';
70
+ banner: string;
71
+ snapshot: Record<string, unknown>;
72
+ gates: {
73
+ flagEnabled: boolean | null;
74
+ consent: string;
75
+ environmentSuppressed: boolean;
76
+ suppressionReasons: string[];
77
+ canExport: boolean;
78
+ blockers: string[];
79
+ };
80
+ notes: string[];
81
+ secretEphemeral: boolean;
82
+ nextAction?: string;
83
+ }
84
+
85
+ // ── Text formatting ──────────────────────────────────────────────────────────
86
+
87
+ function formatStatusText(output: TelemetryStatusOutput): string {
88
+ const lines: string[] = [];
89
+ lines.push('PD Anonymous Product Telemetry');
90
+ lines.push(`consent: ${output.consent} (v${output.consentVersion})`);
91
+ lines.push(`feature flag: ${output.flagEnabled === null ? 'unknown (no workspace)' : output.flagEnabled ? 'on' : 'off'}${output.flagSource ? ` (${output.flagSource})` : ''}`);
92
+ lines.push(`environment: ${output.environmentSuppressed ? `suppressed (${output.suppressionReasons.join(', ')})` : 'eligible'}`);
93
+ lines.push(`can export: ${output.canExport ? 'yes' : `no (${output.blockers.join(', ')})`}`);
94
+ lines.push(`local secret: ${output.hasSecret ? 'present' : 'absent'}`);
95
+ if (output.lastAttemptedAt) lines.push(`last attempted: ${output.lastAttemptedAt}`);
96
+ if (output.lastSucceededAt) lines.push(`last succeeded: ${output.lastSucceededAt}`);
97
+ if (output.lastFailureCode) lines.push(`last failure: ${output.lastFailureCode}`);
98
+ if (output.nextRetryAt) lines.push(`next retry: ${output.nextRetryAt}`);
99
+ lines.push(`collector: ${output.endpoint}`);
100
+ lines.push('');
101
+ lines.push('Collects: PD version, anonymous tri-state milestones (true/false/unavailable), coarse reliability.');
102
+ lines.push('Never collects: conversations, code/files, Principle content, Pain content.');
103
+ if (output.nextAction) lines.push(`Next action: ${output.nextAction}`);
104
+ return lines.join('\n');
105
+ }
106
+
107
+ function formatMutationText(output: TelemetryMutationOutput): string {
108
+ const lines: string[] = [];
109
+ lines.push(`PD telemetry ${output.command.split(':')[1]} ${output.applied ? 'applied' : '(dry-run — not applied)'}`);
110
+ lines.push(output.effect);
111
+ if (!output.applied) lines.push('Re-run with --confirm to apply.');
112
+ if (output.nextAction) lines.push(`Next action: ${output.nextAction}`);
113
+ return lines.join('\n');
114
+ }
115
+
116
+ function formatPreviewText(output: TelemetryPreviewOutput): string {
117
+ const lines: string[] = [];
118
+ lines.push(output.secretEphemeral
119
+ ? 'PD Telemetry Preview — exact payload shape (dailyTelemetryId is provisional until enabled)'
120
+ : 'PD Telemetry Preview — exact outbound payload');
121
+ lines.push('');
122
+ lines.push(JSON.stringify(output.snapshot, null, 2));
123
+ lines.push('');
124
+ lines.push(`Collected: PD version, anonymous tri-state milestones (true/false/unavailable), coarse reliability.`);
125
+ lines.push(`Never collected: conversations, code/files, Principle content, Pain content.`);
126
+ lines.push(`Gates: flag=${output.gates.flagEnabled ?? 'unresolved'} consent=${output.gates.consent} environment=${output.gates.environmentSuppressed ? 'suppressed' : 'eligible'} → wouldExport=${output.gates.canExport}`);
127
+ if (output.notes.length > 0) {
128
+ lines.push('Notes:');
129
+ for (const note of output.notes) lines.push(` [!] ${note}`);
130
+ }
131
+ lines.push('');
132
+ lines.push(`>>> ${PREVIEW_BANNER}`);
133
+ return lines.join('\n');
134
+ }
135
+
136
+ // ── Shared helpers ───────────────────────────────────────────────────────────
137
+
138
+ interface TelemetryOptions {
139
+ workspace?: string;
140
+ json?: boolean;
141
+ confirm?: boolean;
142
+ dryRun?: boolean;
143
+ }
144
+
145
+ function resolveWorkspaceOrNull(opts: TelemetryOptions): string | undefined {
146
+ if (opts.workspace) return path.resolve(opts.workspace);
147
+ try {
148
+ return resolveWorkspaceDir();
149
+ } catch {
150
+ // Consent/identity commands are machine-scope; a workspace is only needed
151
+ // to report the workspace-scope flag gate. Absent workspace = flag unknown.
152
+ return undefined;
153
+ }
154
+ }
155
+
156
+ function makeService() {
157
+ return createProductTelemetryService({});
158
+ }
159
+
160
+ function checkDryRunConfirmMutex(opts: TelemetryOptions, json: boolean): boolean {
161
+ if (opts.dryRun === true && opts.confirm === true) {
162
+ process.exitCode = emitFlagConflict({ json });
163
+ return false;
164
+ }
165
+ return true;
166
+ }
167
+
168
+ // ── Handlers ─────────────────────────────────────────────────────────────────
169
+
170
+ export async function handleTelemetryStatus(opts: TelemetryOptions): Promise<void> {
171
+ const service = makeService();
172
+ const workspaceDir = resolveWorkspaceOrNull(opts);
173
+ const result = service.getStatus(workspaceDir);
174
+ if (!result.ok) {
175
+ process.exitCode = emitError(new Error(result.reason), { json: opts.json ?? false, nextAction: result.nextAction });
176
+ return;
177
+ }
178
+ const view: ProductTelemetryStatusView = result.view;
179
+ const output: TelemetryStatusOutput = {
180
+ status: 'ok',
181
+ command: 'telemetry:status',
182
+ consent: view.consent,
183
+ consentVersion: view.consentVersion,
184
+ hasSecret: view.hasSecret,
185
+ flagEnabled: view.flagEnabled,
186
+ flagSource: view.flagSource,
187
+ environmentSuppressed: view.environmentSuppressed,
188
+ suppressionReasons: view.suppressionReasons,
189
+ canExport: view.canExport,
190
+ blockers: view.blockers,
191
+ ...(view.lastAttemptedAt !== undefined ? { lastAttemptedAt: view.lastAttemptedAt } : {}),
192
+ ...(view.lastSucceededAt !== undefined ? { lastSucceededAt: view.lastSucceededAt } : {}),
193
+ ...(view.lastFailureCode !== undefined ? { lastFailureCode: view.lastFailureCode } : {}),
194
+ ...(view.nextRetryAt !== undefined ? { nextRetryAt: view.nextRetryAt } : {}),
195
+ endpoint: view.endpoint,
196
+ ...(workspaceDir !== undefined ? { workspaceDir } : {}),
197
+ ...(view.nextAction !== undefined ? { nextAction: view.nextAction } : {}),
198
+ };
199
+ emitResult(output, { json: opts.json ?? false, formatText: formatStatusText });
200
+ }
201
+
202
+ type MutationKind = 'enable' | 'disable' | 'reset';
203
+
204
+ const MUTATION_EFFECTS: Record<MutationKind, string> = {
205
+ enable: 'Records explicit telemetry consent (granted) and creates a local random secret for daily unlinkable IDs. Nothing is exported unless the feature flag and environment eligibility also allow it.',
206
+ disable: 'Records consent denied and deletes the local telemetry secret and export status. Future telemetry export requests: 0.',
207
+ reset: 'Deletes the local telemetry secret and export status. If consent remains granted, a fresh secret is generated — future daily IDs are unrelated to previous ones.',
208
+ };
209
+
210
+ const MUTATION_NEXT_ACTIONS: Record<MutationKind, string> = {
211
+ enable: 'Run "pd telemetry preview" to inspect the exact outbound payload.',
212
+ disable: 'Telemetry is fully off. Re-enable anytime with "pd telemetry enable --confirm".',
213
+ reset: 'Previous daily IDs can no longer be derived. See "pd telemetry status".',
214
+ };
215
+
216
+ export async function handleTelemetryMutation(kind: MutationKind, opts: TelemetryOptions): Promise<void> {
217
+ const json = opts.json ?? false;
218
+ if (!checkDryRunConfirmMutex(opts, json)) return;
219
+ const applied = opts.confirm === true;
220
+ const service = makeService();
221
+
222
+ if (!applied) {
223
+ const output: TelemetryMutationOutput = {
224
+ status: 'ok',
225
+ command: `telemetry:${kind}`,
226
+ dryRun: true,
227
+ applied: false,
228
+ effect: MUTATION_EFFECTS[kind],
229
+ nextAction: 'Re-run with --confirm to apply.',
230
+ };
231
+ emitResult(output, { json, formatText: formatMutationText });
232
+ return;
233
+ }
234
+
235
+ const result =
236
+ kind === 'enable' ? service.enable() : kind === 'disable' ? service.disable() : service.reset();
237
+ if (!result.ok) {
238
+ process.exitCode = emitError(new Error(result.reason), { json, nextAction: result.nextAction });
239
+ return;
240
+ }
241
+ const output: TelemetryMutationOutput = {
242
+ status: 'ok',
243
+ command: `telemetry:${kind}`,
244
+ dryRun: false,
245
+ applied: true,
246
+ consent: result.consent,
247
+ effect: MUTATION_EFFECTS[kind],
248
+ nextAction: MUTATION_NEXT_ACTIONS[kind],
249
+ };
250
+ emitResult(output, { json, formatText: formatMutationText });
251
+ }
252
+
253
+ export async function handleTelemetryPreview(opts: TelemetryOptions): Promise<void> {
254
+ let workspaceDir: string;
255
+ if (opts.workspace) {
256
+ workspaceDir = path.resolve(opts.workspace);
257
+ } else {
258
+ try {
259
+ workspaceDir = resolveWorkspaceDir();
260
+ } catch (error) {
261
+ const message = error instanceof Error ? error.message : String(error);
262
+ process.exitCode = emitError(new Error(`workspace_unresolvable: ${message}`), {
263
+ json: opts.json ?? false,
264
+ nextAction: 'Pass --workspace <path> or run from a PD workspace',
265
+ });
266
+ return;
267
+ }
268
+ }
269
+ const service = makeService();
270
+ const preview = service.preview(workspaceDir);
271
+ const output: TelemetryPreviewOutput = {
272
+ status: 'ok',
273
+ command: 'telemetry:preview',
274
+ banner: PREVIEW_BANNER,
275
+ snapshot: preview.snapshot,
276
+ gates: {
277
+ flagEnabled: preview.gates.flagEnabled,
278
+ consent: preview.gates.consent,
279
+ environmentSuppressed: preview.gates.environmentSuppressed,
280
+ suppressionReasons: preview.gates.suppressionReasons,
281
+ canExport: preview.gates.canExport,
282
+ blockers: preview.gates.blockers,
283
+ },
284
+ notes: preview.notes,
285
+ secretEphemeral: preview.secretEphemeral,
286
+ };
287
+ emitResult(output, { json: opts.json ?? false, formatText: formatPreviewText });
288
+ }
289
+
290
+ // ── Registration (used by index.ts and wiring tests) ─────────────────────────
291
+
292
+ export function registerTelemetryCommand(parent: Command): Command {
293
+ const telemetry = parent
294
+ .command('telemetry')
295
+ .description('Anonymous product telemetry control (opt-in, privacy-preserving)');
296
+
297
+ telemetry
298
+ .command('status')
299
+ .description('Show telemetry consent, gates, eligibility, and bounded export status')
300
+ .option('-w, --workspace <path>', 'Workspace directory (for the feature-flag gate)')
301
+ .option('--json', 'Output raw JSON')
302
+ .action(async (opts) => {
303
+ await handleTelemetryStatus({ workspace: opts.workspace, json: opts.json });
304
+ });
305
+
306
+ telemetry
307
+ .command('enable')
308
+ .description('Grant explicit telemetry consent (default OFF until enabled)')
309
+ .option('--dry-run', 'Show the effect without writing (default)')
310
+ .option('--confirm', 'Apply the consent change (required to write)')
311
+ .option('-w, --workspace <path>', 'Workspace directory (reporting only)')
312
+ .option('--json', 'Output raw JSON')
313
+ .action(async (opts) => {
314
+ await handleTelemetryMutation('enable', { workspace: opts.workspace, json: opts.json, confirm: opts.confirm, dryRun: opts.dryRun });
315
+ });
316
+
317
+ telemetry
318
+ .command('disable')
319
+ .description('Deny consent and delete the local telemetry identity (zero future exports)')
320
+ .option('--dry-run', 'Show the effect without writing (default)')
321
+ .option('--confirm', 'Apply the change (required to write)')
322
+ .option('-w, --workspace <path>', 'Workspace directory (reporting only)')
323
+ .option('--json', 'Output raw JSON')
324
+ .action(async (opts) => {
325
+ await handleTelemetryMutation('disable', { workspace: opts.workspace, json: opts.json, confirm: opts.confirm, dryRun: opts.dryRun });
326
+ });
327
+
328
+ telemetry
329
+ .command('reset')
330
+ .description('Delete and rotate the local telemetry identity (unlink from previous daily IDs)')
331
+ .option('--dry-run', 'Show the effect without writing (default)')
332
+ .option('--confirm', 'Apply the change (required to write)')
333
+ .option('-w, --workspace <path>', 'Workspace directory (reporting only)')
334
+ .option('--json', 'Output raw JSON')
335
+ .action(async (opts) => {
336
+ await handleTelemetryMutation('reset', { workspace: opts.workspace, json: opts.json, confirm: opts.confirm, dryRun: opts.dryRun });
337
+ });
338
+
339
+ telemetry
340
+ .command('preview')
341
+ .description('Show the exact outbound telemetry payload (nothing is sent)')
342
+ .option('-w, --workspace <path>', 'Workspace directory')
343
+ .option('--json', 'Output raw JSON')
344
+ .action(async (opts) => {
345
+ await handleTelemetryPreview({ workspace: opts.workspace, json: opts.json });
346
+ });
347
+
348
+ return telemetry;
349
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * pd version command (SPEC §12).
3
+ *
4
+ * Human output prints the short stable version text; `--json` emits exactly
5
+ * one parseable JSON object with the full canonical report (productVersion,
6
+ * releaseId, components, bootstrapVersion, channel, source, generation,
7
+ * health, lastTransaction).
8
+ */
9
+
10
+ import type { Command } from 'commander';
11
+ import { buildVersionReport, formatShortVersion, VersionReportError } from '../services/version-report.js';
12
+
13
+ async function runVersion(json: boolean): Promise<void> {
14
+ try {
15
+ const report = buildVersionReport();
16
+ if (json) {
17
+ console.log(JSON.stringify(report, null, 2));
18
+ return;
19
+ }
20
+ console.log(formatShortVersion(report));
21
+ } catch (error) {
22
+ if (error instanceof VersionReportError) {
23
+ if (json) {
24
+ console.log(JSON.stringify({
25
+ ok: false,
26
+ reason: error.reason,
27
+ message: error.message,
28
+ nextAction: error.nextAction,
29
+ }, null, 2));
30
+ } else {
31
+ console.error(error.message);
32
+ console.error(`Next: ${error.nextAction}`);
33
+ }
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ export function registerVersionCommand(program: Command): void {
42
+ program
43
+ .command('version')
44
+ .description('Show the canonical PD product version and installation state')
45
+ .option('--json', 'Emit the full canonical version report as one JSON object', false)
46
+ .action(async (opts: Record<string, unknown>) => {
47
+ await runVersion(opts.json === true);
48
+ });
49
+ }
package/src/index.ts CHANGED
@@ -15,6 +15,8 @@ import { handleSamplesReview } from './commands/samples-review.js';
15
15
  import { handleEvolutionTasksList } from './commands/evolution-tasks-list.js';
16
16
  import { handleEvolutionTasksShow } from './commands/evolution-tasks-show.js';
17
17
  import { registerHealthCommand } from './commands/health.js';
18
+ import { registerVersionCommand } from './commands/version.js';
19
+ import { buildVersionReport, formatShortVersion, VersionReportError } from './services/version-report.js';
18
20
  import { handleTaskShow, registerTaskListCommand } from './commands/task.js';
19
21
  import { handleRunList, handleRunShow } from './commands/run.js';
20
22
  import { handleTrajectoryLocate } from './commands/trajectory.js';
@@ -68,6 +70,7 @@ import { registerRulecodeCommand } from './commands/rulecode.js';
68
70
  import { registerIntentCommand } from './commands/intent.js';
69
71
  import { registerErrorsListCommand } from './commands/errors-list.js';
70
72
  import { registerPrinciplesCommand } from './commands/principles-stats.js';
73
+ import { registerTelemetryCommand } from './commands/telemetry.js';
71
74
 
72
75
  import { createRequire } from 'module';
73
76
  const require = createRequire(import.meta.url);
@@ -75,12 +78,38 @@ const pkg = require('../package.json') as { version: string };
75
78
 
76
79
  const program = new Command();
77
80
 
81
+ // SPEC §12: `pd --version` prints the canonical product version when a
82
+ // supported installation exists; a development checkout falls back to the
83
+ // CLI package version with an explicit marker instead of impersonating an
84
+ // installed release.
85
+ function handleVersionFlag(args: readonly string[]): boolean {
86
+ if (!args.includes('--version') && !args.includes('-V')) return false;
87
+ try {
88
+ console.log(formatShortVersion(buildVersionReport()));
89
+ return true;
90
+ } catch (error) {
91
+ if (error instanceof VersionReportError && error.reason === 'not_installed') {
92
+ console.log(`Principles Disciple ${pkg.version} (development-checkout)`);
93
+ return true;
94
+ }
95
+ if (error instanceof VersionReportError) {
96
+ console.error(error.message);
97
+ console.error(`Next: ${error.nextAction}`);
98
+ process.exitCode = 1;
99
+ return true;
100
+ }
101
+ throw error;
102
+ }
103
+ }
104
+
78
105
  program
79
106
  .name('pd')
80
107
  .description('PD CLI — Pain recording, sample management, and evolution tasks')
81
- .version(pkg.version)
108
+ .option('-V, --version', 'output the canonical PD product version')
82
109
  .enablePositionalOptions();
83
110
 
111
+ registerVersionCommand(program);
112
+
84
113
  const painCmd = program
85
114
  .command('pain')
86
115
  .description('Pain signal management');
@@ -166,6 +195,10 @@ tasksCmd
166
195
 
167
196
  registerHealthCommand(program);
168
197
 
198
+ // ── Anonymous Product Telemetry v1 control plane (PRI-595~603) ────────────────
199
+
200
+ registerTelemetryCommand(program);
201
+
169
202
  // ── Runtime v2 task/run commands ──────────────────────────────────────────────鈹€鈹€鈹€鈹€鈹€鈹€
170
203
 
171
204
  const rtTaskCmd = program
@@ -1098,4 +1131,6 @@ qualityCmd
1098
1131
  await handleQualityScorecard(opts);
1099
1132
  });
1100
1133
 
1101
- program.parse();
1134
+ if (!handleVersionFlag(process.argv.slice(2))) {
1135
+ program.parse();
1136
+ }