@principles/pd-cli 1.152.17 → 1.152.18

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.
@@ -65,6 +65,33 @@ export declare function handleCandidateShow(opts: CandidateShowOptions): Promise
65
65
  * If ledger write succeeds but DB update fails, exits non-zero with clear error.
66
66
  */
67
67
  export declare function handleCandidateIntake(opts: CandidateIntakeOptions): Promise<void>;
68
+ interface CandidateReviewOptions {
69
+ candidateId: string;
70
+ workspace?: string;
71
+ json?: boolean;
72
+ /** Omit for the read-only proposal view; `reuse`/`create` enter decision mode. */
73
+ decide?: 'reuse' | 'create';
74
+ /** Required with `--decide reuse`. */
75
+ principleId?: string;
76
+ /** Required with `--decide` (rc-3): why the Owner resolved this candidate. */
77
+ reason?: string;
78
+ }
79
+ /**
80
+ * pd candidate review — PRI-917 PR3A Owner decision surface (SPEC §18).
81
+ *
82
+ * Phase 1 (read-only): shows the candidate and the deterministic Top-K reuse
83
+ * proposal (score, reasons/shared terms, and the existing Principle's text /
84
+ * triggerPattern / action / status) so the Owner can decide with the same
85
+ * information the gate will consume.
86
+ *
87
+ * Phase 2 (`--decide`): constructs the SPEC §11 verdict (actor from
88
+ * resolveOwnerIdentity — fail-closed when unresolvable) and injects it into
89
+ * CandidateIntakeService, which runs the EXISTING gate → appendReuseEvidence
90
+ * chain. The CLI is an interaction layer only: it never writes reuseEvidence,
91
+ * decision files, or any other state (SPEC §18 — the only durable authority
92
+ * is `Principle.reuseEvidence[]`).
93
+ */
94
+ export declare function handleCandidateReview(opts: CandidateReviewOptions): Promise<void>;
68
95
  /**
69
96
  * pd candidate audit --workspace <path> [--json]
70
97
  *
@@ -1 +1 @@
1
- {"version":3,"file":"candidate.d.ts","sourceRoot":"","sources":["../../src/commands/candidate.ts"],"names":[],"mappings":"AAaA,OAAO,EACL,mBAAmB,EAapB,MAAM,6BAA6B,CAAC;AAUrC,UAAU,oBAAoB;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,UAAU,oBAAoB;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,UAAU,sBAAsB;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,UAAU,qBAAqB;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,UAAU,sBAAsB;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAgED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,oCAAoC,CACxD,YAAY,EAAE,mBAAmB,EACjC,SAAS,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GACnD,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAqCxB;AAgDD,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,oBAAoB,GAAG,OAAO,CAAC,IAAI,CAAC,CAsCnF;AAID,UAAU,2BAA2B;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAaD,wBAAsB,0BAA0B,CAAC,IAAI,EAAE,2BAA2B,GAAG,OAAO,CAAC,IAAI,CAAC,CA8JjG;AAID,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,oBAAoB,GAAG,OAAO,CAAC,IAAI,CAAC,CAgDnF;AAID;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6IvF;AAID;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAkErF;AAID;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CA0GvF;AAID,UAAU,wBAAwB;IAChC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAiFD,wBAAsB,sCAAsC,CAAC,IAAI,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAsR1G;AAID,UAAU,qBAAqB;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+CrF"}
1
+ {"version":3,"file":"candidate.d.ts","sourceRoot":"","sources":["../../src/commands/candidate.ts"],"names":[],"mappings":"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"}
@@ -1,17 +1,23 @@
1
1
  /**
2
- * pd candidate commands — Principle candidate inspection, intake, audit, repair.
2
+ * pd candidate commands — Principle candidate inspection, intake, audit, repair, reuse review.
3
3
  *
4
4
  * Usage:
5
5
  * pd candidate list --task-id <taskId> --workspace <path> [--json]
6
6
  * pd candidate show <candidateId> --workspace <path> [--json]
7
7
  * pd candidate intake --candidate-id <id> [--workspace <path>] [--json] [--dry-run]
8
+ * pd candidate review --candidate-id <id> [--workspace <path>] [--json]
9
+ * pd candidate review --candidate-id <id> --decide reuse --principle-id <id> --reason "..." [--json]
10
+ * pd candidate review --candidate-id <id> --decide create --reason "..." [--json]
8
11
  * pd candidate audit --workspace <path> [--json]
9
12
  * pd candidate repair --candidate-id <id> --workspace <path> [--json]
10
13
  * pd candidate route --candidate-id <id> --workspace <path> [--json]
11
14
  */
12
15
  import { randomUUID } from 'crypto';
13
16
  import * as path from 'path';
14
- import { RuntimeStateManager, SqliteConnection, candidateList, candidateShow, CandidateIntakeService, CandidateIntakeError, INTAKE_ERROR_CODES, decideInternalizationRoute, buildDreamerSeedFromCandidate, findExistingDreamerTask, parseSeedSourcePainId, PrincipleTreeLedgerAdapter, } from '@principles/core/runtime-v2';
17
+ import { RuntimeStateManager, SqliteConnection, candidateList, candidateShow, CandidateIntakeService, CandidateIntakeError, INTAKE_ERROR_CODES, decideInternalizationRoute, buildDreamerSeedFromCandidate, findExistingDreamerTask, parseSeedSourcePainId, PrincipleTreeLedgerAdapter,
18
+ // PRI-917 PR3A: the review surface consumes the SAME proposal/semantics
19
+ // machinery as the intake gate (audit C4 — one implementation, no drift).
20
+ buildReuseProposal, buildReuseShortlist, extractIntakeRecommendation, isPrincipleLedgerEligibleKind, resolveOwnerIdentity, defaultOwnerIdentityHomeDir, } from '@principles/core/runtime-v2';
15
21
  import { loadLedger, getLedgerFilePathPublic } from '@principles/core/principle-tree-ledger';
16
22
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
17
23
  import { resolvePromptFullPipelineSeedMode } from '../services/pd-config-loader.js';
@@ -380,6 +386,180 @@ export async function handleCandidateShow(opts) {
380
386
  }
381
387
  }
382
388
  // ── Intake ───────────────────────────────────────────────────────────────────
389
+ // ── Shared intake/decision finalizers (PRI-917 PR3A) ─────────────────────────
390
+ /**
391
+ * PRI-917 PR3A (surface audit C2): unify the candidate terminal state after a
392
+ * successful ledger write OR a recorded reuse resolution — always through the
393
+ * guarded {@link updateCandidateStatus} (optimistic pending→consumed +
394
+ * consumed_at), never bypassing the existing mechanism (SPEC §13: the
395
+ * candidate still reaches `consumed`).
396
+ *
397
+ * Returns 'already_consumed' when the candidate was consumed before this call
398
+ * (e.g. an INV-R07 replay). Exits non-zero on a guarded-transition failure —
399
+ * the ledger/LEDGER fact is already durable, so the inconsistency must be
400
+ * loud (same posture as the pre-PR3A intake path).
401
+ */
402
+ async function markCandidateConsumed(stateManager, candidateId, ledgerFact) {
403
+ const candidate = await stateManager.getCandidate(candidateId);
404
+ if (candidate?.status === 'consumed') {
405
+ return 'already_consumed';
406
+ }
407
+ try {
408
+ await updateCandidateStatus({ stateManager, candidateId, targetStatus: 'consumed', expectedCurrentStatus: 'pending' });
409
+ }
410
+ catch (err) {
411
+ const msg = `The ledger fact is durable (${ledgerFact}) but the candidate status update failed: ${err instanceof Error ? err.message : String(err)}. ` +
412
+ `Candidate ${candidateId} may be in inconsistent state.`;
413
+ console.error(`ERROR: ${msg}`);
414
+ process.exit(1);
415
+ }
416
+ return 'consumed';
417
+ }
418
+ /**
419
+ * PRI-917 PR3A (surface audit C1/C2): report a `reuse_selected` intake result
420
+ * as the SUCCESS it is — resolution + selected Principle + evidence, exit 0 —
421
+ * and unify the candidate terminal state (C2). Used by BOTH `pd candidate
422
+ * intake` (replay path) and `pd candidate review --decide reuse` (fresh
423
+ * decision). The CLI prints the durable fact; it does not create one.
424
+ */
425
+ async function reportReuseResolution(opts) {
426
+ const { stateManager, candidateId, result, json } = opts;
427
+ if (!result.selectedPrincipleId || !result.reuseEvidence) {
428
+ // Adapter/service contract violation — never print a hollow resolution.
429
+ throw new CandidateIntakeError(INTAKE_ERROR_CODES.INPUT_INVALID, `reuse_selected result for candidate ${candidateId} is missing selectedPrincipleId/reuseEvidence (contract violation)`, { candidateId, reason: 'reuse_resolution_contract_violation' });
430
+ }
431
+ const consumedState = await markCandidateConsumed(stateManager, candidateId, `reuse evidence on ${result.selectedPrincipleId}`);
432
+ const replayed = result.reuseProposal === undefined;
433
+ const resolution = {
434
+ candidateId,
435
+ status: 'reused',
436
+ selectedPrincipleId: result.selectedPrincipleId,
437
+ reuseEvidence: result.reuseEvidence,
438
+ replayed,
439
+ candidateStatus: consumedState,
440
+ message: result.message,
441
+ };
442
+ if (json) {
443
+ console.log(JSON.stringify(resolution, null, 2));
444
+ return;
445
+ }
446
+ console.log(`\nPrinciple Candidate Reuse: ${candidateId}\n`);
447
+ console.log(` Candidate: ${candidateId}`);
448
+ console.log(` Reused Principle: ${result.selectedPrincipleId}`);
449
+ console.log(` Pain: ${result.reuseEvidence.painId}`);
450
+ console.log(` Decided By: ${result.reuseEvidence.actor.kind} ${result.reuseEvidence.actor.id}`);
451
+ console.log(` Decided At: ${result.reuseEvidence.decidedAt}`);
452
+ console.log(` Reason: ${result.reuseEvidence.reason}`);
453
+ console.log(` Candidate Status: ${consumedState === 'already_consumed' ? 'already consumed (INV-R07 replay)' : 'consumed'}`);
454
+ console.log(' Evidence: recorded on the Principle\'s reuseEvidence (append-only)\n');
455
+ }
456
+ /**
457
+ * Decision mode of {@link handleCandidateReview}: validate the CLI contract
458
+ * (rc-3), resolve the Owner identity (fail-closed), then run the EXISTING
459
+ * intake chain with the §11 verdict injected. No CLI-side persistence.
460
+ */
461
+ async function executeOwnerDecision(deps) {
462
+ const { opts, stateManager, ledgerAdapter, stateDir } = deps;
463
+ const failCli = (message, nextAction) => {
464
+ if (opts.json) {
465
+ console.log(JSON.stringify({ candidateId: opts.candidateId, status: 'refused', decision: opts.decide, error: message, nextAction }, null, 2));
466
+ }
467
+ else {
468
+ console.error(`ERROR: ${message}`);
469
+ console.error(` Next Action: ${nextAction}`);
470
+ }
471
+ process.exit(1);
472
+ };
473
+ if (typeof opts.reason !== 'string' || opts.reason.trim() === '') {
474
+ failCli('--reason is required with --decide (the evidence entry must record why the Owner resolved this candidate)', 'Re-run with --reason "..." (non-empty).');
475
+ }
476
+ if (opts.decide === 'reuse' && (typeof opts.principleId !== 'string' || opts.principleId.trim() === '')) {
477
+ failCli('--principle-id is required with --decide reuse', 'Re-run with --principle-id <id from the review proposal>.');
478
+ }
479
+ // Owner identity: env > ~/.pd/owner.json > none (ADR-0022 resolver).
480
+ const identity = resolveOwnerIdentity(process.env, defaultOwnerIdentityHomeDir());
481
+ if ((identity.source !== 'env' && identity.source !== 'file') || !identity.ownerId) {
482
+ failCli(`Owner identity is required to record a ${opts.decide} decision (identity source: ${identity.source})${identity.error ? `: ${identity.error}` : ''}`, 'Register the Owner identity file (~/.pd/owner.json) or set PD_OWNER_ID and PD_OWNER_CREDENTIAL_ID together, then retry.');
483
+ }
484
+ const decision = opts.decide === 'reuse'
485
+ ? {
486
+ decision: 'reuse',
487
+ selectedPrincipleId: opts.principleId,
488
+ actor: { kind: 'owner', id: identity.ownerId },
489
+ reason: opts.reason,
490
+ decidedAt: new Date().toISOString(),
491
+ }
492
+ : { decision: 'create' };
493
+ // INV-R08: verify the selected principle is in the current proposal.
494
+ // This prevents a stale or empty proposal from silently degrading reuse
495
+ // into create.
496
+ if (opts.decide === 'reuse') {
497
+ const candidate = await stateManager.getCandidate(opts.candidateId);
498
+ const artifact = candidate ? await stateManager.getArtifact(candidate.artifactId) : null;
499
+ const extracted = candidate ? extractIntakeRecommendation(candidate, artifact) : null;
500
+ if (!candidate || !extracted || !extracted.ok) {
501
+ failCli('Cannot build the reuse proposal for this candidate', 'Run `pd candidate review --candidate-id <id>` first.');
502
+ return;
503
+ }
504
+ const proposal = buildReuseProposal(opts.candidateId, buildReuseShortlist({
505
+ text: extracted.recommendation.text || candidate.description || '',
506
+ triggerPattern: extracted.recommendation.triggerPattern ?? '',
507
+ action: extracted.recommendation.action ?? '',
508
+ }, stateDir, { recommendationKind: candidate.rawRecommendationKind }));
509
+ if (!proposal.candidates.some((c) => c.principleId === opts.principleId)) {
510
+ failCli(`Principle ${opts.principleId} is not in the current reuse proposal`, 'Re-run `pd candidate review` to see the current proposal.');
511
+ }
512
+ }
513
+ const service = new CandidateIntakeService({
514
+ stateManager,
515
+ ledgerAdapter,
516
+ // The verdict is fixed by the CLI invocation; the intake gate still
517
+ // validates it (rc-1/rc-2/rc-3) and checks the selection against the
518
+ // proposal (INV-R08) — the CLI cannot smuggle a decision past the gate.
519
+ reuseDecision: () => decision,
520
+ reuseStateDir: stateDir,
521
+ });
522
+ const intakeResult = await service.intake(opts.candidateId);
523
+ if (intakeResult.outcome === 'refused' && intakeResult.reason === 'reuse_selected') {
524
+ await reportReuseResolution({ stateManager, candidateId: opts.candidateId, result: intakeResult, json: opts.json === true });
525
+ return;
526
+ }
527
+ if (intakeResult.outcome === 'refused') {
528
+ // A non-principle kind or unknown kind cannot carry a reuse decision.
529
+ throw new CandidateIntakeError(INTAKE_ERROR_CODES.INPUT_INVALID, `Principle Ledger write refused: ${intakeResult.message}`, { candidateId: opts.candidateId, reason: intakeResult.reason });
530
+ }
531
+ // create verdict (or gate absent shapes) — same durable outcome as intake.
532
+ const { entry } = intakeResult;
533
+ const consumedState = await markCandidateConsumed(stateManager, opts.candidateId, entry.id);
534
+ if (consumedState === 'already_consumed') {
535
+ // Consumed before this call — the ledger entry is identical by intake
536
+ // idempotency; report it like `pd candidate intake` does.
537
+ const infoMessage = `Candidate ${opts.candidateId} was already consumed. Ledger entry: ${entry.id}`;
538
+ if (opts.json) {
539
+ console.log(JSON.stringify({ candidateId: opts.candidateId, ledgerEntryId: entry.id, status: 'already_consumed', message: infoMessage }, null, 2));
540
+ }
541
+ else {
542
+ console.log(infoMessage);
543
+ }
544
+ return;
545
+ }
546
+ const result = {
547
+ candidateId: opts.candidateId,
548
+ decision: opts.decide,
549
+ ledgerEntryId: entry.id,
550
+ status: 'consumed',
551
+ };
552
+ if (opts.json) {
553
+ console.log(JSON.stringify(result, null, 2));
554
+ return;
555
+ }
556
+ console.log(`\nPrinciple Candidate Decision: ${opts.candidateId}\n`);
557
+ console.log(` Decision: ${opts.decide}`);
558
+ console.log(` Candidate: ${opts.candidateId}`);
559
+ console.log(` Ledger Entry: ${entry.id}`);
560
+ console.log(` Status: consumed\n`);
561
+ console.log('Decision complete.\n');
562
+ }
383
563
  /**
384
564
  * pd candidate intake --candidate-id <id> [--workspace <path>] [--json] [--dry-run]
385
565
  *
@@ -465,12 +645,22 @@ export async function handleCandidateIntake(opts) {
465
645
  // candidate id, so a Principle Ledger write boundary refusal is surfaced
466
646
  // loudly (never silently swallowed) instead of being treated as success.
467
647
  const intakeResult = await service.intake(opts.candidateId);
648
+ if (intakeResult.outcome === 'refused' && intakeResult.reason === 'reuse_selected') {
649
+ // PRI-917 PR3A (surface audit C1/C2): a recorded reuse resolution is a
650
+ // SUCCESS, not a failure — the evidence is already durable on the
651
+ // selected Principle (fresh decision) or was recorded earlier (INV-R07
652
+ // replay via intake step 2b, which is how this branch is reachable from
653
+ // the gate-less intake command). Report the resolution, unify the
654
+ // candidate terminal state with the automatic paths (SPEC §13), exit 0.
655
+ await reportReuseResolution({ stateManager, candidateId: opts.candidateId, result: intakeResult, json: opts.json === true });
656
+ return;
657
+ }
468
658
  if (intakeResult.outcome === 'refused') {
469
659
  throw new CandidateIntakeError(INTAKE_ERROR_CODES.INPUT_INVALID, `Principle Ledger write refused: ${intakeResult.message}`, { candidateId: opts.candidateId, reason: intakeResult.reason });
470
660
  }
471
661
  const { entry } = intakeResult;
472
- // Check if already consumed before this call
473
- if (candidate?.status === 'consumed') {
662
+ const consumedState = await markCandidateConsumed(stateManager, opts.candidateId, entry.id);
663
+ if (consumedState === 'already_consumed') {
474
664
  const infoMessage = `Candidate ${opts.candidateId} was already consumed. Ledger entry: ${entry.id}`;
475
665
  if (opts.json) {
476
666
  console.log(JSON.stringify({
@@ -485,16 +675,6 @@ export async function handleCandidateIntake(opts) {
485
675
  }
486
676
  return;
487
677
  }
488
- // Update DB status — this must succeed; if it fails, exit non-zero
489
- try {
490
- await updateCandidateStatus({ stateManager, candidateId: opts.candidateId, targetStatus: 'consumed', expectedCurrentStatus: 'pending' });
491
- }
492
- catch (err) {
493
- const msg = `Ledger write succeeded (entry ${entry.id}) but DB status update failed: ${err instanceof Error ? err.message : String(err)}. ` +
494
- `Candidate ${opts.candidateId} may be in inconsistent state.`;
495
- console.error(`ERROR: ${msg}`);
496
- process.exit(1);
497
- }
498
678
  const result = {
499
679
  candidateId: opts.candidateId,
500
680
  ledgerEntryId: entry.id,
@@ -526,6 +706,170 @@ export async function handleCandidateIntake(opts) {
526
706
  await stateManager.close();
527
707
  }
528
708
  }
709
+ /**
710
+ * pd candidate review — PRI-917 PR3A Owner decision surface (SPEC §18).
711
+ *
712
+ * Phase 1 (read-only): shows the candidate and the deterministic Top-K reuse
713
+ * proposal (score, reasons/shared terms, and the existing Principle's text /
714
+ * triggerPattern / action / status) so the Owner can decide with the same
715
+ * information the gate will consume.
716
+ *
717
+ * Phase 2 (`--decide`): constructs the SPEC §11 verdict (actor from
718
+ * resolveOwnerIdentity — fail-closed when unresolvable) and injects it into
719
+ * CandidateIntakeService, which runs the EXISTING gate → appendReuseEvidence
720
+ * chain. The CLI is an interaction layer only: it never writes reuseEvidence,
721
+ * decision files, or any other state (SPEC §18 — the only durable authority
722
+ * is `Principle.reuseEvidence[]`).
723
+ */
724
+ export async function handleCandidateReview(opts) {
725
+ const workspaceDir = resolveWorkspaceDir(opts.workspace);
726
+ const stateManager = new RuntimeStateManager({ workspaceDir });
727
+ try {
728
+ await stateManager.initialize();
729
+ const stateDir = path.join(workspaceDir, '.state');
730
+ const ledgerAdapter = new PrincipleTreeLedgerAdapter({ stateDir });
731
+ const candidate = await stateManager.getCandidate(opts.candidateId);
732
+ if (!candidate) {
733
+ console.error(`Candidate not found: ${opts.candidateId}`);
734
+ process.exit(1);
735
+ return;
736
+ }
737
+ if (opts.decide === 'reuse' || opts.decide === 'create') {
738
+ // PRI-442 Stage 4: admission gate check — same as handleCandidateIntake.
739
+ // Prevents CLI from bypassing the production admission gate.
740
+ const admissionBlock = checkAdmissionGate(candidate);
741
+ if (admissionBlock) {
742
+ const payload = {
743
+ candidateId: opts.candidateId,
744
+ status: 'refused',
745
+ admissionDecision: admissionBlock.decision,
746
+ reason: admissionBlock.reason,
747
+ nextAction: admissionBlock.nextAction,
748
+ };
749
+ if (opts.json) {
750
+ console.log(JSON.stringify(payload, null, 2));
751
+ }
752
+ else {
753
+ console.error(`Admission gate refused candidate ${opts.candidateId}: ${admissionBlock.decision}`);
754
+ console.error(` Reason: ${admissionBlock.reason}`);
755
+ console.error(` Next Action: ${admissionBlock.nextAction}`);
756
+ }
757
+ process.exit(1);
758
+ return;
759
+ }
760
+ // opts.decide is narrowed here — no cast needed to build the decision-mode shape.
761
+ await executeOwnerDecision({ opts: { ...opts, decide: opts.decide }, stateManager, ledgerAdapter, stateDir });
762
+ return;
763
+ }
764
+ // ── Read-only proposal view ──
765
+ const rawKindEligible = isPrincipleLedgerEligibleKind(candidate.rawRecommendationKind);
766
+ if (!rawKindEligible) {
767
+ // Fail closed like the intake boundary: a non-principle candidate never
768
+ // reaches the reuse gate, so reviewing a proposal for it would lie.
769
+ const rawKind = typeof candidate.rawRecommendationKind === 'string' ? candidate.rawRecommendationKind : null;
770
+ const payload = {
771
+ candidateId: opts.candidateId,
772
+ recommendationKindEligible: false,
773
+ rawRecommendationKind: rawKind,
774
+ message: `Candidate ${opts.candidateId} has recommendation_kind '${rawKind ?? 'absent'}' which does not target the Principle Ledger; there is no reuse proposal to review.`,
775
+ nextAction: 'Run `pd candidate route --candidate-id <id>` for this candidate\'s internalization route instead.',
776
+ };
777
+ if (opts.json) {
778
+ console.log(JSON.stringify(payload, null, 2));
779
+ return;
780
+ }
781
+ console.log(`\nPrinciple Candidate Review: ${opts.candidateId}\n`);
782
+ console.log(` Kind: ${rawKind ?? 'absent'} (not Principle-Ledger eligible)`);
783
+ console.log(` ${payload.message}`);
784
+ console.log(` Next Action: ${payload.nextAction}\n`);
785
+ return;
786
+ }
787
+ const artifact = await stateManager.getArtifact(candidate.artifactId);
788
+ if (!artifact) {
789
+ console.error(`Artifact not found for candidate: ${opts.candidateId} (artifact ${candidate.artifactId})`);
790
+ process.exit(1);
791
+ return;
792
+ }
793
+ // C4: the SAME extraction intake uses (4b/4c) — never a second parser.
794
+ const extracted = extractIntakeRecommendation(candidate, artifact);
795
+ if (!extracted.ok) {
796
+ throw extracted.error;
797
+ }
798
+ const { recommendation } = extracted;
799
+ const shortlist = buildReuseShortlist({
800
+ text: recommendation.text || candidate.description || '',
801
+ triggerPattern: recommendation.triggerPattern ?? '',
802
+ action: recommendation.action ?? '',
803
+ }, stateDir, { recommendationKind: candidate.rawRecommendationKind });
804
+ const proposal = buildReuseProposal(opts.candidateId, shortlist);
805
+ // Enrich each entry with the existing Principle's durable content.
806
+ const ledger = loadLedger(stateDir);
807
+ const entries = proposal.candidates.map((entry) => {
808
+ const principle = ledger.tree.principles[entry.principleId];
809
+ return {
810
+ principleId: entry.principleId,
811
+ score: entry.score,
812
+ reasons: entry.reasons,
813
+ existingPrinciple: principle
814
+ ? { text: principle.text, triggerPattern: principle.triggerPattern, action: principle.action, status: principle.status }
815
+ : { text: '', triggerPattern: '', action: '', status: 'missing' },
816
+ };
817
+ });
818
+ const nextAction = entries.length > 0
819
+ ? `pd candidate review --candidate-id ${opts.candidateId} --decide reuse --principle-id <id> --reason "..." (or --decide create --reason "...")`
820
+ : `No credible reuse proposal — intake will create a new Principle: pd candidate intake --candidate-id ${opts.candidateId} --workspace "${workspaceDir}"`;
821
+ if (opts.json) {
822
+ console.log(JSON.stringify({
823
+ candidateId: opts.candidateId,
824
+ title: candidate.title,
825
+ description: candidate.description,
826
+ status: candidate.status,
827
+ taskId: candidate.taskId,
828
+ recommendationKindEligible: true,
829
+ claim: { text: recommendation.text || candidate.description || '', triggerPattern: recommendation.triggerPattern ?? '', action: recommendation.action ?? '' },
830
+ proposal: { status: proposal.status, eligibleCount: proposal.eligibleCount, candidates: entries },
831
+ nextAction,
832
+ }, null, 2));
833
+ return;
834
+ }
835
+ console.log(`\nPrinciple Candidate Review: ${opts.candidateId}\n`);
836
+ console.log(` Title: ${candidate.title}`);
837
+ console.log(` Description: ${candidate.description}`);
838
+ console.log(` Status: ${candidate.status}`);
839
+ console.log(` Task: ${candidate.taskId}`);
840
+ console.log(` Claim: ${recommendation.text || candidate.description || ''}`);
841
+ console.log(` Trigger: ${recommendation.triggerPattern ?? ''}`);
842
+ console.log(` Action: ${recommendation.action ?? ''}`);
843
+ console.log(`\n Reuse Proposal (${entries.length} of ${proposal.eligibleCount} eligible, Top-3):\n`);
844
+ if (entries.length === 0) {
845
+ console.log(' No credible existing Principle found — the intake gate will create a new one.\n');
846
+ }
847
+ entries.forEach((entry, index) => {
848
+ console.log(` ${index + 1}. ${entry.principleId} (score ${entry.score.toFixed(3)})`);
849
+ for (const reason of entry.reasons) {
850
+ console.log(` reason: ${reason}`);
851
+ }
852
+ console.log(` text: ${entry.existingPrinciple.text}`);
853
+ console.log(` trigger: ${entry.existingPrinciple.triggerPattern}`);
854
+ console.log(` action: ${entry.existingPrinciple.action}`);
855
+ console.log(` status: ${entry.existingPrinciple.status}`);
856
+ });
857
+ console.log(`\n Next Action: ${nextAction}\n`);
858
+ }
859
+ catch (err) {
860
+ if (err instanceof CandidateIntakeError || err.name === 'CandidateIntakeError') {
861
+ const e = err;
862
+ console.error(`Review failed [${e.code ?? 'unknown'}]: ${e.message}`);
863
+ }
864
+ else {
865
+ console.error(`Review failed: ${String(err)}`);
866
+ }
867
+ process.exit(1);
868
+ }
869
+ finally {
870
+ await stateManager.close();
871
+ }
872
+ }
529
873
  // ── Audit ─────────────────────────────────────────────────────────────────────
530
874
  /**
531
875
  * pd candidate audit --workspace <path> [--json]