@principles/pd-cli 1.113.0 → 1.114.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.
@@ -4,8 +4,12 @@ import {
4
4
  ActivationDispatcher,
5
5
  PromptWriter,
6
6
  DeferArchiveWriter,
7
+ RuleHostWriter,
8
+ createProductionGateDeps,
7
9
  SqliteActivationStateStore,
8
10
  SqliteApprovalQueueStore,
11
+ SqlitePIArtifactStore,
12
+ isArtifactRevisionOf,
9
13
  } from '@principles/core/runtime-v2';
10
14
  import type { ActivationDecision, PIArtifactSnapshot, RolloutActivationDecision } from '@principles/core/runtime-v2';
11
15
  import type { PIArtifactRecord } from '@principles/core/runtime-v2';
@@ -146,10 +150,19 @@ export async function handleRuntimeActivationDispatch(opts: ActivationDispatchOp
146
150
 
147
151
  const activationStateStore = new SqliteActivationStateStore(stateManager.connection);
148
152
  const approvalQueueStore = new SqliteApprovalQueueStore(stateManager.connection);
153
+ // Wire all three MVP-Core writers, including RuleHostWriter for code_tool_hook.
154
+ // PRI-408: fixes P0 breakpoint where code_tool_hook channel could not activate.
149
155
  const dispatcher = new ActivationDispatcher(
150
156
  artifactReadModel,
151
157
  activationStateStore,
152
- { writers: [new PromptWriter(), new DeferArchiveWriter()], approvalQueueStore },
158
+ {
159
+ writers: [
160
+ new PromptWriter(),
161
+ new RuleHostWriter({ gateDeps: createProductionGateDeps() }),
162
+ new DeferArchiveWriter(),
163
+ ],
164
+ approvalQueueStore,
165
+ },
153
166
  );
154
167
 
155
168
  const result = await dispatcher.dispatch({
@@ -174,3 +187,448 @@ export async function handleRuntimeActivationDispatch(opts: ActivationDispatchOp
174
187
  await stateManager.close();
175
188
  }
176
189
  }
190
+
191
+ // ── Deactivate Command (PRI-408 Contract E) ─────────────────────────────────
192
+
193
+ interface ActivationDeactivateOptions {
194
+ workspace?: string;
195
+ activationId?: string;
196
+ json?: boolean;
197
+ }
198
+
199
+ export interface DeactivateResult {
200
+ ok: boolean;
201
+ activationId: string;
202
+ deactivatedAt?: string;
203
+ reason?: string;
204
+ nextAction?: string;
205
+ }
206
+
207
+ export async function handleRuntimeActivationDeactivate(opts: ActivationDeactivateOptions): Promise<void> {
208
+ if (!opts.activationId) {
209
+ const result: DeactivateResult = {
210
+ ok: false,
211
+ activationId: '',
212
+ reason: 'activation_id_required',
213
+ nextAction: 'Provide --activation-id <id> from `pd runtime activation list`',
214
+ };
215
+ if (opts.json) {
216
+ console.log(JSON.stringify(result, null, 2));
217
+ } else {
218
+ console.error(`Error: --activation-id is required`);
219
+ console.error(`Next action: ${result.nextAction}`);
220
+ }
221
+ process.exitCode = 1;
222
+ return;
223
+ }
224
+
225
+ const workspaceDir = opts.workspace ? path.resolve(opts.workspace) : resolveWorkspaceDir();
226
+ const stateManager = new RuntimeStateManager({ workspaceDir });
227
+
228
+ try {
229
+ await stateManager.initialize();
230
+ const activationStateStore = new SqliteActivationStateStore(stateManager.connection);
231
+ const deactivatedAt = new Date().toISOString();
232
+
233
+ // Idempotent deactivate: returns false if already deactivated or not found.
234
+ // Both cases are safe to call repeatedly (Contract E: rollback must be idempotent).
235
+ const success = await activationStateStore.deactivateActivation(opts.activationId, deactivatedAt);
236
+
237
+ const result: DeactivateResult = success
238
+ ? { ok: true, activationId: opts.activationId, deactivatedAt }
239
+ : {
240
+ ok: false,
241
+ activationId: opts.activationId,
242
+ reason: 'not_found_or_already_deactivated',
243
+ nextAction: 'Check activation ID with `pd runtime activation list`, or it may already be deactivated',
244
+ };
245
+
246
+ if (opts.json) {
247
+ // Strict JSON mode: exactly one parseable JSON object on stdout
248
+ console.log(JSON.stringify(result, null, 2));
249
+ } else {
250
+ if (success) {
251
+ console.log(`Deactivated: ${opts.activationId}`);
252
+ console.log(` deactivatedAt: ${deactivatedAt}`);
253
+ } else {
254
+ console.log(`Not deactivated: ${opts.activationId}`);
255
+ console.log(` reason: ${result.reason}`);
256
+ console.log(` nextAction: ${result.nextAction}`);
257
+ }
258
+ }
259
+
260
+ if (!success) {
261
+ process.exitCode = 1;
262
+ }
263
+ } catch (err: unknown) {
264
+ // P2 #5: initialize/DB exceptions must not break --json contract.
265
+ const errMsg = err instanceof Error ? err.message : String(err);
266
+ const result: DeactivateResult = {
267
+ ok: false,
268
+ activationId: opts.activationId,
269
+ reason: `initialize_failed: ${errMsg}`,
270
+ nextAction: 'Check workspace directory and DB integrity. Try `pd runtime diagnostics`.',
271
+ };
272
+ if (opts.json) {
273
+ console.log(JSON.stringify(result, null, 2));
274
+ } else {
275
+ console.error(`Error: ${result.reason}`);
276
+ console.error(`Next action: ${result.nextAction}`);
277
+ }
278
+ process.exitCode = 1;
279
+ } finally {
280
+ await stateManager.close();
281
+ }
282
+ }
283
+
284
+ // ── List Activations Command (PRI-408 Contract D — observability) ────────────
285
+
286
+ interface ActivationListOptions {
287
+ workspace?: string;
288
+ channel?: string;
289
+ includeDeactivated?: boolean;
290
+ json?: boolean;
291
+ }
292
+
293
+ export async function handleRuntimeActivationList(opts: ActivationListOptions): Promise<void> {
294
+ // P2 #5 fix: fail loud on invalid channel instead of silently listing all.
295
+ const VALID_CHANNELS = new Set(['prompt', 'code_tool_hook', undefined]);
296
+ if (opts.channel !== undefined && !VALID_CHANNELS.has(opts.channel)) {
297
+ const result = {
298
+ ok: false,
299
+ reason: `invalid_channel: ${opts.channel}`,
300
+ nextAction: 'Use one of: prompt, code_tool_hook, or omit --channel to list all',
301
+ };
302
+ if (opts.json) {
303
+ console.log(JSON.stringify(result, null, 2));
304
+ } else {
305
+ console.error(`Error: invalid channel "${opts.channel}"`);
306
+ console.error(`Next action: ${result.nextAction}`);
307
+ }
308
+ process.exitCode = 1;
309
+ return;
310
+ }
311
+
312
+ const workspaceDir = opts.workspace ? path.resolve(opts.workspace) : resolveWorkspaceDir();
313
+ const stateManager = new RuntimeStateManager({ workspaceDir });
314
+
315
+ try {
316
+ await stateManager.initialize();
317
+ const activationStateStore = new SqliteActivationStateStore(stateManager.connection);
318
+
319
+ // P2 #5 fix: pass includeDeactivated to the store so channel-specific queries
320
+ // also return deactivated records when requested. Previously the SQL hardcoded
321
+ // `WHERE deactivated_at IS NULL`, making --include-deactivated a no-op for
322
+ // channel-filtered queries.
323
+ let records;
324
+ if (opts.channel === 'prompt') {
325
+ records = await activationStateStore.listPromptActivations(opts.includeDeactivated ?? false);
326
+ } else if (opts.channel === 'code_tool_hook') {
327
+ records = await activationStateStore.listCodeToolHookActivations(opts.includeDeactivated ?? false);
328
+ } else {
329
+ records = await activationStateStore.listAllActivations();
330
+ }
331
+
332
+ // For listAllActivations, still apply the includeDeactivated filter at the
333
+ // caller level since listAllActivations() does not take the parameter.
334
+ const filtered = opts.includeDeactivated
335
+ ? records
336
+ : records.filter(r => r.deactivatedAt === null);
337
+
338
+ if (opts.json) {
339
+ // Strict JSON mode: exactly one parseable JSON object on stdout
340
+ console.log(JSON.stringify({ activations: filtered }, null, 2));
341
+ } else {
342
+ if (filtered.length === 0) {
343
+ console.log('No active activations found.');
344
+ } else {
345
+ for (const r of filtered) {
346
+ const status = r.deactivatedAt ? `[DEACTIVATED ${r.deactivatedAt}]` : '[ACTIVE]';
347
+ console.log(`${status} ${r.activationId}`);
348
+ console.log(` artifactId: ${r.artifactId}`);
349
+ console.log(` channel: ${r.channel}`);
350
+ console.log(` action: ${r.action}`);
351
+ console.log(` targetRef: ${r.targetRef}`);
352
+ console.log(` activatedAt: ${r.activatedAt}`);
353
+ console.log('');
354
+ }
355
+ }
356
+ }
357
+ } catch (err: unknown) {
358
+ // P2 #5: initialize/DB exceptions must not break --json contract.
359
+ const errMsg = err instanceof Error ? err.message : String(err);
360
+ const result = {
361
+ ok: false,
362
+ reason: `initialize_failed: ${errMsg}`,
363
+ nextAction: 'Check workspace directory and DB integrity. Try `pd runtime diagnostics`.',
364
+ };
365
+ if (opts.json) {
366
+ console.log(JSON.stringify(result, null, 2));
367
+ } else {
368
+ console.error(`Error: ${result.reason}`);
369
+ console.error(`Next action: ${result.nextAction}`);
370
+ }
371
+ process.exitCode = 1;
372
+ } finally {
373
+ await stateManager.close();
374
+ }
375
+ }
376
+
377
+ // ── Edit Pending Approval Command (P1 #2 fix — Owner edit entry point) ──────
378
+
379
+ interface ActivationEditOptions {
380
+ workspace?: string;
381
+ approvalId?: string;
382
+ newArtifactId?: string;
383
+ editReason?: string;
384
+ json?: boolean;
385
+ }
386
+
387
+ export interface EditApprovalResult {
388
+ ok: boolean;
389
+ approvalId?: string;
390
+ newArtifactId?: string;
391
+ previousArtifactId?: string;
392
+ editedAt?: string;
393
+ reason?: string;
394
+ nextAction?: string;
395
+ }
396
+
397
+ export async function handleRuntimeActivationEdit(opts: ActivationEditOptions): Promise<void> {
398
+ if (!opts.approvalId) {
399
+ const result: EditApprovalResult = {
400
+ ok: false,
401
+ reason: 'approval_id_required',
402
+ nextAction: 'Provide --approval-id <id> from Console approvals page',
403
+ };
404
+ if (opts.json) {
405
+ console.log(JSON.stringify(result, null, 2));
406
+ } else {
407
+ console.error('Error: --approval-id is required');
408
+ console.error(`Next action: ${result.nextAction}`);
409
+ }
410
+ process.exitCode = 1;
411
+ return;
412
+ }
413
+
414
+ if (!opts.newArtifactId) {
415
+ const result: EditApprovalResult = {
416
+ ok: false,
417
+ reason: 'new_artifact_id_required',
418
+ nextAction: 'Create a new artifact first (e.g. via pd candidate intake), then pass its ID with --new-artifact-id',
419
+ };
420
+ if (opts.json) {
421
+ console.log(JSON.stringify(result, null, 2));
422
+ } else {
423
+ console.error('Error: --new-artifact-id is required');
424
+ console.error(`Next action: ${result.nextAction}`);
425
+ }
426
+ process.exitCode = 1;
427
+ return;
428
+ }
429
+
430
+ if (!opts.editReason) {
431
+ const result: EditApprovalResult = {
432
+ ok: false,
433
+ reason: 'edit_reason_required',
434
+ nextAction: 'Provide --edit-reason explaining why the artifact is being revised',
435
+ };
436
+ if (opts.json) {
437
+ console.log(JSON.stringify(result, null, 2));
438
+ } else {
439
+ console.error('Error: --edit-reason is required');
440
+ console.error(`Next action: ${result.nextAction}`);
441
+ }
442
+ process.exitCode = 1;
443
+ return;
444
+ }
445
+
446
+ const workspaceDir = opts.workspace ? path.resolve(opts.workspace) : resolveWorkspaceDir();
447
+ const stateManager = new RuntimeStateManager({ workspaceDir });
448
+
449
+ try {
450
+ await stateManager.initialize();
451
+ const approvalStore = new SqliteApprovalQueueStore(stateManager.connection);
452
+ const artifactStore = new SqlitePIArtifactStore(stateManager.connection);
453
+ const now = new Date().toISOString();
454
+
455
+ // P1 #2: validate the new artifact before swapping the approval pointer.
456
+ // The new artifact must exist, be validated, and have lineage consistent
457
+ // with the original approval's artifact (same sourceTaskId).
458
+ const existingApproval = await approvalStore.getById(opts.approvalId);
459
+ if (!existingApproval) {
460
+ const result: EditApprovalResult = {
461
+ ok: false,
462
+ approvalId: opts.approvalId,
463
+ reason: 'not_found',
464
+ nextAction: 'Check the approval ID on Console approvals page',
465
+ };
466
+ if (opts.json) {
467
+ console.log(JSON.stringify(result, null, 2));
468
+ } else {
469
+ console.error(`Edit refused: ${result.reason}`);
470
+ console.error(`Next action: ${result.nextAction}`);
471
+ }
472
+ process.exitCode = 1;
473
+ await stateManager.close();
474
+ return;
475
+ }
476
+ if (existingApproval.status !== 'pending') {
477
+ const result: EditApprovalResult = {
478
+ ok: false,
479
+ approvalId: opts.approvalId,
480
+ reason: 'already_decided',
481
+ nextAction: `Approval is already decided (status: ${existingApproval.status}). Only pending approvals can be edited.`,
482
+ };
483
+ if (opts.json) {
484
+ console.log(JSON.stringify(result, null, 2));
485
+ } else {
486
+ console.error(`Edit refused: ${result.reason}`);
487
+ console.error(`Next action: ${result.nextAction}`);
488
+ }
489
+ process.exitCode = 1;
490
+ await stateManager.close();
491
+ return;
492
+ }
493
+
494
+ const newArtifact = await artifactStore.getArtifactById(opts.newArtifactId);
495
+ if (!newArtifact) {
496
+ const result: EditApprovalResult = {
497
+ ok: false,
498
+ approvalId: opts.approvalId,
499
+ reason: 'artifact_not_found',
500
+ nextAction: `Artifact ${opts.newArtifactId} does not exist. Create it first via pd candidate intake.`,
501
+ };
502
+ if (opts.json) {
503
+ console.log(JSON.stringify(result, null, 2));
504
+ } else {
505
+ console.error(`Edit refused: ${result.reason}`);
506
+ console.error(`Next action: ${result.nextAction}`);
507
+ }
508
+ process.exitCode = 1;
509
+ await stateManager.close();
510
+ return;
511
+ }
512
+ if (newArtifact.validationStatus !== 'validated') {
513
+ const result: EditApprovalResult = {
514
+ ok: false,
515
+ approvalId: opts.approvalId,
516
+ reason: `artifact_not_validated: ${newArtifact.validationStatus}`,
517
+ nextAction: `Artifact ${opts.newArtifactId} has validationStatus '${newArtifact.validationStatus}'. Run the production gate to validate it first.`,
518
+ };
519
+ if (opts.json) {
520
+ console.log(JSON.stringify(result, null, 2));
521
+ } else {
522
+ console.error(`Edit refused: ${result.reason}`);
523
+ console.error(`Next action: ${result.nextAction}`);
524
+ }
525
+ process.exitCode = 1;
526
+ await stateManager.close();
527
+ return;
528
+ }
529
+ const originalArtifact = await artifactStore.getArtifactById(existingApproval.artifactId);
530
+ if (!originalArtifact || !isArtifactRevisionOf(newArtifact, originalArtifact)) {
531
+ const result: EditApprovalResult = {
532
+ ok: false,
533
+ approvalId: opts.approvalId,
534
+ reason: 'artifact_lineage_mismatch',
535
+ nextAction: originalArtifact
536
+ ? `Artifact ${opts.newArtifactId} must reference ${originalArtifact.artifactId} or its source principle.`
537
+ : `Original artifact ${existingApproval.artifactId} is missing; restore it before editing this approval.`,
538
+ };
539
+ if (opts.json) {
540
+ console.log(JSON.stringify(result, null, 2));
541
+ } else {
542
+ console.error(`Edit refused: ${result.reason}`);
543
+ console.error(`Next action: ${result.nextAction}`);
544
+ }
545
+ process.exitCode = 1;
546
+ await stateManager.close();
547
+ return;
548
+ }
549
+
550
+ let editResult;
551
+ try {
552
+ editResult = await approvalStore.edit({
553
+ approvalId: opts.approvalId,
554
+ editedBy: 'operator',
555
+ newArtifactId: opts.newArtifactId,
556
+ editReason: opts.editReason,
557
+ now,
558
+ });
559
+ } catch (err: unknown) {
560
+ const errMsg = err instanceof Error ? err.message : String(err);
561
+ const result: EditApprovalResult = {
562
+ ok: false,
563
+ approvalId: opts.approvalId,
564
+ reason: `edit_failed: ${errMsg}`,
565
+ nextAction: 'Check workspace DB integrity and that the approval + new artifact exist',
566
+ };
567
+ if (opts.json) {
568
+ console.log(JSON.stringify(result, null, 2));
569
+ } else {
570
+ console.error(`Edit failed: ${result.reason}`);
571
+ console.error(`Next action: ${result.nextAction}`);
572
+ }
573
+ process.exitCode = 1;
574
+ return;
575
+ }
576
+
577
+ if (!editResult.ok) {
578
+ const result: EditApprovalResult = {
579
+ ok: false,
580
+ approvalId: opts.approvalId,
581
+ reason: editResult.error,
582
+ nextAction: editResult.error === 'not_found'
583
+ ? 'Check the approval ID on Console approvals page'
584
+ : `Approval is already decided (status: ${editResult.status ?? 'unknown'}). Only pending approvals can be edited.`,
585
+ };
586
+ if (opts.json) {
587
+ console.log(JSON.stringify(result, null, 2));
588
+ } else {
589
+ console.error(`Edit refused: ${result.reason}`);
590
+ console.error(`Next action: ${result.nextAction}`);
591
+ }
592
+ process.exitCode = 1;
593
+ return;
594
+ }
595
+
596
+ const result: EditApprovalResult = {
597
+ ok: true,
598
+ approvalId: editResult.record.approvalId,
599
+ newArtifactId: editResult.record.artifactId,
600
+ previousArtifactId: editResult.record.previousArtifactId ?? undefined,
601
+ editedAt: editResult.record.editedAt ?? now,
602
+ };
603
+
604
+ if (opts.json) {
605
+ console.log(JSON.stringify(result, null, 2));
606
+ } else {
607
+ console.log(`Approval edited: ${result.approvalId}`);
608
+ console.log(` newArtifactId: ${result.newArtifactId}`);
609
+ if (result.previousArtifactId) {
610
+ console.log(` previousArtifactId: ${result.previousArtifactId}`);
611
+ }
612
+ console.log(` editedAt: ${result.editedAt}`);
613
+ console.log('Next action: review the new artifact, then approve via Console or `pd runtime activation dispatch`');
614
+ }
615
+ } catch (err: unknown) {
616
+ // P2 #5: initialize/DB exceptions must not break --json contract.
617
+ const errMsg = err instanceof Error ? err.message : String(err);
618
+ const result: EditApprovalResult = {
619
+ ok: false,
620
+ approvalId: opts.approvalId,
621
+ reason: `initialize_failed: ${errMsg}`,
622
+ nextAction: 'Check workspace directory and DB integrity. Try `pd runtime diagnostics`.',
623
+ };
624
+ if (opts.json) {
625
+ console.log(JSON.stringify(result, null, 2));
626
+ } else {
627
+ console.error(`Error: ${result.reason}`);
628
+ console.error(`Next action: ${result.nextAction}`);
629
+ }
630
+ process.exitCode = 1;
631
+ } finally {
632
+ await stateManager.close();
633
+ }
634
+ }
package/src/index.ts CHANGED
@@ -47,6 +47,7 @@ import { handleRuntimeDiagnosticsExport } from './commands/runtime-diagnostics-e
47
47
  import { handleRuntimeRecoverySweep } from './commands/runtime-recovery.js';
48
48
  import { handleRuntimeRecoveryFailedTasks } from './commands/runtime-recovery-failed-tasks.js';
49
49
  import { handleRuntimeActivationDispatch } from './commands/runtime-activation.js';
50
+ import { handleRuntimeActivationDeactivate, handleRuntimeActivationList, handleRuntimeActivationEdit } from './commands/runtime-activation.js';
50
51
  import { handleProvenChannelBaseline } from './commands/proven-channel-baseline.js';
51
52
  import { handleDemoStoryA } from './commands/demo-story-a.js';
52
53
  import { handleRuntimeFeaturesStatus } from './commands/runtime-features.js';
@@ -577,7 +578,7 @@ const activationCmd = runtimeCmd
577
578
  activationCmd
578
579
  .command('dispatch')
579
580
  .description('Dispatch an activation for a rollout-reviewed artifact')
580
- .requiredOption('-a, --artifact-id <id>', 'PIArtifact ID to activate')
581
+ .option('-a, --artifact-id <id>', 'PIArtifact ID to activate')
581
582
  .option('-w, --workspace <path>', 'Workspace directory')
582
583
  .option('-c, --channel <channel>', 'Activation channel (prompt|defer_archive)', 'prompt')
583
584
  .option('--dry-run', 'Dry-run mode (default, no writes)')
@@ -594,6 +595,61 @@ activationCmd
594
595
  });
595
596
  });
596
597
 
598
+ // PRI-408 Contract E: Owner-initiated rollback/deactivate of an activation.
599
+ // Idempotent — calling twice on the same ID is safe and returns ok=false with reason.
600
+ activationCmd
601
+ .command('deactivate')
602
+ .description('Deactivate (rollback) an active activation — idempotent (PRI-408 Contract E)')
603
+ .option('-a, --activation-id <id>', 'Activation ID to deactivate')
604
+ .option('-w, --workspace <path>', 'Workspace directory')
605
+ .option('--json', 'Output raw JSON')
606
+ .action(async (opts) => {
607
+ await handleRuntimeActivationDeactivate({
608
+ workspace: opts.workspace,
609
+ activationId: opts.activationId,
610
+ json: opts.json,
611
+ });
612
+ });
613
+
614
+ // PRI-408 Contract D: Owner observability — list current activations.
615
+ activationCmd
616
+ .command('list')
617
+ .description('List activations (default: active only) — PRI-408 Contract D observability')
618
+ .option('-w, --workspace <path>', 'Workspace directory')
619
+ .option('-c, --channel <channel>', 'Filter by channel (prompt|code_tool_hook)')
620
+ .option('--include-deactivated', 'Include deactivated records in output')
621
+ .option('--json', 'Output raw JSON')
622
+ .action(async (opts) => {
623
+ await handleRuntimeActivationList({
624
+ workspace: opts.workspace,
625
+ channel: opts.channel,
626
+ includeDeactivated: opts.includeDeactivated,
627
+ json: opts.json,
628
+ });
629
+ });
630
+
631
+ // P1 #2 fix: Owner edit entry point — swap a pending approval's artifact.
632
+ // Required because ApprovalQueue.edit() was dead code with no CLI/OpenClaw entry.
633
+ // P2 #5: use .option() instead of .requiredOption() so missing-flag errors
634
+ // produce structured JSON output via the handler, not Commander's pre-handler exit.
635
+ activationCmd
636
+ .command('edit')
637
+ .description('Edit a pending approval to swap its artifact — P1 #2 owner edit entry point')
638
+ .option('-a, --approval-id <id>', 'Approval ID to edit (must be pending)')
639
+ .option('-n, --new-artifact-id <id>', 'New PIArtifact ID to swap to')
640
+ .option('-r, --edit-reason <text>', 'Reason for the edit')
641
+ .option('-w, --workspace <path>', 'Workspace directory')
642
+ .option('--json', 'Output raw JSON')
643
+ .action(async (opts) => {
644
+ await handleRuntimeActivationEdit({
645
+ workspace: opts.workspace,
646
+ approvalId: opts.approvalId,
647
+ newArtifactId: opts.newArtifactId,
648
+ editReason: opts.editReason,
649
+ json: opts.json,
650
+ });
651
+ });
652
+
597
653
  const diagnosticsCmd = runtimeCmd
598
654
  .command('diagnostics')
599
655
  .description('Control plane diagnostic bundle operations');
@@ -42,6 +42,8 @@ import {
42
42
  runAdversarialLoop,
43
43
  evaluateInRefinerSandbox,
44
44
  DEFAULT_MAX_ROUNDS,
45
+ SqliteApprovalQueueStore,
46
+ getChannelRiskLevel,
45
47
  } from '@principles/core/runtime-v2';
46
48
  import type {
47
49
  AdversarialLoopResult,
@@ -49,6 +51,7 @@ import type {
49
51
  PeerRunnerResult,
50
52
  RefinerRuleHostGateDeps,
51
53
  PIArtifactStore,
54
+ ApprovalRecord,
52
55
  } from '@principles/core/runtime-v2';
53
56
  /* eslint-disable @typescript-eslint/no-use-before-define -- helpers declared after main, matching codebase convention */
54
57
  import { compileDemoRule } from './demo-rule-compiler.js';
@@ -168,6 +171,14 @@ export interface RuleHostPipelineResult {
168
171
  readonly ruleArtifactId: string | null;
169
172
  /** Principle artifact ID (always present when scribe ran). */
170
173
  readonly principleArtifactId: string | null;
174
+ /**
175
+ * Approval ID when the candidate was auto-enqueued into the ApprovalQueue.
176
+ * Present when decision='candidate_ready_for_owner_review' and the pipeline
177
+ * successfully enqueued the candidate for owner review (P1 #1 fix).
178
+ * Null when the candidate was not enqueued (text_principle_only, rejected,
179
+ * or enqueue failed — check degradationReason for details).
180
+ */
181
+ readonly approvalId: string | null;
171
182
  /** Structured reason when decision is not candidate_ready_for_owner_review. */
172
183
  readonly degradationReason?: string;
173
184
  }
@@ -366,6 +377,44 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
366
377
  ? 'candidate_ready_for_owner_review' as const
367
378
  : 'generation_rejected' as const;
368
379
 
380
+ // P1 #1 fix: auto-enqueue the candidate into the ApprovalQueue so the
381
+ // owner can review it. Without this, the pipeline produces a candidate
382
+ // artifact but it never enters the approval queue — the production chain
383
+ // is broken at step 2→3. Tests manually called enqueue(); production did not.
384
+ let approvalId: string | null = null;
385
+ if (pipelineDecision === 'candidate_ready_for_owner_review' && loopResult.ruleArtifactId) {
386
+ try {
387
+ const approvalStore = new SqliteApprovalQueueStore(stateManager.connection);
388
+ const riskLevel = getChannelRiskLevel(channel);
389
+ const enqueuedRecord: ApprovalRecord = await approvalStore.enqueue({
390
+ artifactId: loopResult.ruleArtifactId,
391
+ channel,
392
+ riskLevel,
393
+ summary: `RuleHost pipeline candidate for pain ${opts.painId}`,
394
+ triggerReason: `adversarial_loop_approved: pain=${opts.painId}, rule=${loopResult.ruleArtifactId}`,
395
+ }, new Date().toISOString());
396
+ const { approvalId: enqueuedApprovalId } = enqueuedRecord;
397
+ approvalId = enqueuedApprovalId;
398
+ onProgress('adversarial_loop', 'succeeded', `auto-enqueued as approval ${approvalId}`);
399
+ } catch (err: unknown) {
400
+ // Enqueue failed — the candidate artifact exists but is not in the
401
+ // approval queue. Degrade gracefully with a structured reason (ERR-002).
402
+ const enqueueErr = err instanceof Error ? err.message : String(err);
403
+ const degradeReason = `candidate_approved_but_enqueue_failed: ${enqueueErr}. Manual enqueue required: pd runtime activation dispatch --artifact-id ${loopResult.ruleArtifactId} --channel ${channel}`;
404
+ return {
405
+ decision: pipelineDecision,
406
+ painId: opts.painId,
407
+ stages,
408
+ scribeTaskId,
409
+ adversarialLoop: loopResult,
410
+ ruleArtifactId: loopResult.ruleArtifactId,
411
+ principleArtifactId: loopResult.principleArtifactId,
412
+ approvalId: null,
413
+ degradationReason: degradeReason,
414
+ };
415
+ }
416
+ }
417
+
369
418
  return {
370
419
  decision: pipelineDecision,
371
420
  painId: opts.painId,
@@ -374,6 +423,7 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
374
423
  adversarialLoop: loopResult,
375
424
  ruleArtifactId: loopResult.ruleArtifactId,
376
425
  principleArtifactId: loopResult.principleArtifactId,
426
+ approvalId,
377
427
  degradationReason: loopResult.degradationReason,
378
428
  };
379
429
  } finally {
@@ -533,6 +583,7 @@ function rejectedResult(painId: string, stages: RuleHostPipelineStage[], degrada
533
583
  scribeTaskId: null,
534
584
  ruleArtifactId: null,
535
585
  principleArtifactId: null,
586
+ approvalId: null,
536
587
  degradationReason,
537
588
  };
538
589
  }
@@ -567,6 +618,7 @@ async function textPrincipleOnlyResult(
567
618
  scribeTaskId,
568
619
  ruleArtifactId: null,
569
620
  principleArtifactId: principleArt?.artifactId ?? null,
621
+ approvalId: null,
570
622
  degradationReason: `code_rule_capability_off: ${disabledReason}`,
571
623
  };
572
624
  } catch (error: unknown) {
@@ -578,6 +630,7 @@ async function textPrincipleOnlyResult(
578
630
  scribeTaskId,
579
631
  ruleArtifactId: null,
580
632
  principleArtifactId: null,
633
+ approvalId: null,
581
634
  degradationReason: `code_rule_capability_off: ${disabledReason}; principle_artifact_lookup_failed: ${message}`,
582
635
  };
583
636
  }
@@ -62,4 +62,18 @@ describe('CLI command tree structure', () => {
62
62
  const output = runPdHelp(['runtime', '--help']);
63
63
  expect(output).toMatch(/health\s/);
64
64
  });
65
+
66
+ it('activation edit command exists under runtime activation (pd runtime activation edit --help)', () => {
67
+ const output = runPdHelp(['runtime', 'activation', 'edit', '--help']);
68
+ expect(output).toContain('--approval-id');
69
+ expect(output).toContain('--new-artifact-id');
70
+ expect(output).toContain('--edit-reason');
71
+ expect(output).toContain('--workspace');
72
+ expect(output).toContain('--json');
73
+ });
74
+
75
+ it('activation subcommand list includes edit (pd runtime activation --help)', () => {
76
+ const output = runPdHelp(['runtime', 'activation', '--help']);
77
+ expect(output).toMatch(/edit\s/);
78
+ });
65
79
  });