@principles/pd-cli 1.152.7 → 1.152.8

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 (67) hide show
  1. package/dist/commands/__tests__/runtime-probe-config.test.js +2 -1
  2. package/dist/commands/__tests__/runtime-probe-config.test.js.map +1 -1
  3. package/dist/commands/__tests__/version.test.js +104 -0
  4. package/dist/commands/__tests__/version.test.js.map +1 -1
  5. package/dist/commands/diagnose.d.ts.map +1 -1
  6. package/dist/commands/diagnose.js +3 -6
  7. package/dist/commands/diagnose.js.map +1 -1
  8. package/dist/commands/pain-retry.d.ts.map +1 -1
  9. package/dist/commands/pain-retry.js +3 -6
  10. package/dist/commands/pain-retry.js.map +1 -1
  11. package/dist/commands/runtime-activation.d.ts.map +1 -1
  12. package/dist/commands/runtime-activation.js +9 -3
  13. package/dist/commands/runtime-activation.js.map +1 -1
  14. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  15. package/dist/commands/runtime-internalization-run-once.js +5 -11
  16. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  17. package/dist/commands/runtime-internalization-run-rulehost.d.ts +14 -0
  18. package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -1
  19. package/dist/commands/runtime-internalization-run-rulehost.js +67 -15
  20. package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -1
  21. package/dist/index.js +0 -12
  22. package/dist/index.js.map +1 -1
  23. package/dist/services/__tests__/rulehost-readiness.test.js +0 -3
  24. package/dist/services/__tests__/rulehost-readiness.test.js.map +1 -1
  25. package/dist/services/pain-flood-simulation-runner.d.ts.map +1 -1
  26. package/dist/services/pain-flood-simulation-runner.js +0 -4
  27. package/dist/services/pain-flood-simulation-runner.js.map +1 -1
  28. package/dist/services/rulehost-pipeline-runner.d.ts +90 -21
  29. package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
  30. package/dist/services/rulehost-pipeline-runner.js +328 -24
  31. package/dist/services/rulehost-pipeline-runner.js.map +1 -1
  32. package/dist/services/synthetic-baseline-runner.d.ts.map +1 -1
  33. package/dist/services/synthetic-baseline-runner.js +0 -4
  34. package/dist/services/synthetic-baseline-runner.js.map +1 -1
  35. package/dist/services/version-report.d.ts +16 -0
  36. package/dist/services/version-report.d.ts.map +1 -1
  37. package/dist/services/version-report.js +37 -2
  38. package/dist/services/version-report.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/commands/__tests__/runtime-probe-config.test.ts +2 -1
  41. package/src/commands/__tests__/version.test.ts +110 -0
  42. package/src/commands/diagnose.ts +3 -6
  43. package/src/commands/pain-retry.ts +3 -6
  44. package/src/commands/runtime-activation.ts +9 -3
  45. package/src/commands/runtime-internalization-run-once.ts +5 -11
  46. package/src/commands/runtime-internalization-run-rulehost.ts +69 -15
  47. package/src/index.ts +0 -13
  48. package/src/services/__tests__/rulehost-readiness.test.ts +0 -3
  49. package/src/services/pain-flood-simulation-runner.ts +0 -4
  50. package/src/services/rulehost-pipeline-runner.ts +368 -35
  51. package/src/services/synthetic-baseline-runner.ts +0 -4
  52. package/src/services/version-report.ts +52 -2
  53. package/tests/commands/config-doctor.test.ts +0 -2
  54. package/tests/commands/pain-evidence.test.ts +1 -1
  55. package/tests/commands/run-rulehost-handler.test.ts +43 -0
  56. package/tests/commands/runtime-features.test.ts +0 -1
  57. package/tests/commands/runtime-internalization-run-once-language.test.ts +0 -1
  58. package/tests/e2e/cross-package-acceptance.test.ts +13 -3
  59. package/tests/services/pd-config-loader.test.ts +0 -1
  60. package/tests/services/rulehost-pipeline-runner.test.ts +378 -12
  61. package/dist/commands/runtime-internalization-context-trace.d.ts +0 -25
  62. package/dist/commands/runtime-internalization-context-trace.d.ts.map +0 -1
  63. package/dist/commands/runtime-internalization-context-trace.js +0 -374
  64. package/dist/commands/runtime-internalization-context-trace.js.map +0 -1
  65. package/src/commands/runtime-internalization-context-trace.ts +0 -445
  66. package/tests/bdd/context-trace.steps.ts +0 -229
  67. package/tests/commands/runtime-internalization-context-trace.test.ts +0 -110
@@ -54,6 +54,8 @@ import {
54
54
  // runners already receive (EP-07: canonical resolved value, not raw input).
55
55
  resolveOutputLanguage,
56
56
  buildArtificerHostSemanticContext,
57
+ // PRI-804(a): ledger identity lookup for the source_principle_id backfill.
58
+ PrincipleTreeLedgerAdapter,
57
59
  } from '@principles/core/runtime-v2';
58
60
  import type {
59
61
  AdversarialLoopResult,
@@ -70,23 +72,12 @@ import type {
70
72
  SeedArtificerRepairParams,
71
73
  EvaluatorValidator,
72
74
  } from '@principles/core/runtime-v2';
73
- import { createHash } from 'node:crypto';
75
+ import * as path from 'node:path';
74
76
  import { loadPdConfig } from './pd-config-loader.js';
75
77
  import { createEvaluatorRuntimeContext } from '@principles/host-runtime';
76
78
  /* eslint-disable @typescript-eslint/no-use-before-define -- helpers declared after main, matching codebase convention */
77
79
  import { compileDemoRule } from './demo-rule-compiler.js';
78
80
 
79
- /**
80
- * Layer 0 content-hash function for the internalization progressive
81
- * disclosure (design §6.1). Core never imports `node:crypto`, so the plugin/CLI
82
- * layer injects the algorithm. Consumed only when the
83
- * `artifact_summary_redundancy` flag is on; harmless otherwise.
84
- *
85
- * Exported so other CLI command modules (e.g. `runtime-internalization-run-once`)
86
- * can reuse the same algorithm without each re-importing `node:crypto`.
87
- */
88
- export const contentHashFn = (input: string): string => createHash('sha256').update(input).digest('hex');
89
-
90
81
  // ── Types ────────────────────────────────────────────────────────────────────
91
82
 
92
83
  /**
@@ -191,8 +182,8 @@ export interface RuleHostPipelineStage {
191
182
  * rule artifact exists and is WAITING for owner review. This is NOT owner
192
183
  * approval — it means the candidate is ready for the owner to review.
193
184
  * - `text_principle_only`: code-rule capability OFF (artificer or evaluator
194
- * disabled). No rule artifact; a text principle artifact is produced for
195
- * prompt-channel fallback.
185
+ * disabled). No rule artifact; a text principle artifact is produced for the
186
+ * prompt channel and enqueued into the existing approval queue (PRI-804).
196
187
  * - `generation_rejected`: pipeline failed (no dreamer task, stage failure, or
197
188
  * evaluator rejected the candidate). No rule artifact.
198
189
  */
@@ -210,10 +201,10 @@ export interface RuleHostPipelineResult {
210
201
  readonly principleArtifactId: string | null;
211
202
  /**
212
203
  * Approval ID when the candidate was auto-enqueued into the ApprovalQueue.
213
- * Present when decision='candidate_ready_for_owner_review' and the pipeline
214
- * successfully enqueued the candidate for owner review (P1 #1 fix).
215
- * Null when the candidate was not enqueued (text_principle_only, rejected,
216
- * or enqueue failed — check degradationReason for details).
204
+ * Present for decision='candidate_ready_for_owner_review' (rule artifact,
205
+ * code_tool_hook channel) AND for decision='text_principle_only' (principle
206
+ * artifact, prompt channel — PRI-804 wiring). Null when the enqueue itself
207
+ * failed — check degradationReason for the structured reason.
217
208
  */
218
209
  readonly approvalId: string | null;
219
210
  /** Structured reason when decision is not candidate_ready_for_owner_review. */
@@ -331,7 +322,7 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
331
322
  stages.push({ name: 'dreamer', taskId: dreamerSeedTaskId, status: 'succeeded' });
332
323
  } else {
333
324
  const dreamerRunner = new DreamerRunner(
334
- { stateManager, runtimeAdapter: agentAdapters.dreamer, eventEmitter, validator: new DefaultDreamerValidator(), artifactStore, contentHashFn },
325
+ { stateManager, runtimeAdapter: agentAdapters.dreamer, eventEmitter, validator: new DefaultDreamerValidator(), artifactStore },
335
326
  runnerOptsFor(agentAdapters.dreamer),
336
327
  );
337
328
  const dreamerResult = await runStage(dreamerRunner, dreamerSeedTaskId, { maxStageRetries, pollIntervalMs });
@@ -348,7 +339,7 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
348
339
  const philosopherTaskId = `${correlation}-philosopher-${Date.now().toString(36)}`;
349
340
  await createInternalizationTask(stateManager, philosopherTaskId, 'philosopher', [dreamerSeedTaskId], channel, timeoutMs);
350
341
  const philosopherRunner = new PhilosopherRunner(
351
- { stateManager, runtimeAdapter: agentAdapters.philosopher, eventEmitter, validator: new DefaultPhilosopherValidator(), artifactStore, contentHashFn },
342
+ { stateManager, runtimeAdapter: agentAdapters.philosopher, eventEmitter, validator: new DefaultPhilosopherValidator(), artifactStore },
352
343
  runnerOptsFor(agentAdapters.philosopher),
353
344
  );
354
345
  const philosopherResult = await runStage(philosopherRunner, philosopherTaskId, { maxStageRetries, pollIntervalMs });
@@ -364,7 +355,7 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
364
355
  const scribeTaskId = `${correlation}-scribe-${Date.now().toString(36)}`;
365
356
  await createInternalizationTask(stateManager, scribeTaskId, 'scribe', [philosopherTaskId], channel, timeoutMs);
366
357
  const scribeRunner = new ScribeRunner(
367
- { stateManager, runtimeAdapter: agentAdapters.scribe, eventEmitter, validator: new DefaultScribeValidator(), artifactStore, contentHashFn },
358
+ { stateManager, runtimeAdapter: agentAdapters.scribe, eventEmitter, validator: new DefaultScribeValidator(), artifactStore },
368
359
  runnerOptsFor(agentAdapters.scribe),
369
360
  );
370
361
  const scribeResult = await runStage(scribeRunner, scribeTaskId, { maxStageRetries, pollIntervalMs });
@@ -375,6 +366,29 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
375
366
  }
376
367
  onProgress('scribe', 'succeeded');
377
368
 
369
+ // EP002-R4 follow-up #2 + Owner Decision Experience v1 Phase A: backfill
370
+ // the LEDGER principle UUID on the scribe artifact for BOTH paths (rule +
371
+ // text). Downstream identity resolution (extractPrincipleId's column-first
372
+ // order) then binds the rule artifact, the approval grouping, and the
373
+ // post-approve ledger upgrade to the ledger UUID instead of falling back
374
+ // to principleDraft.title — the title namespace is what left rule-channel
375
+ // approvals stranded as title-keyed groups the Console detail page cannot
376
+ // reach.
377
+ //
378
+ // Phase A (SPEC §9.1): the binding outcome is STRUCTURED. An unverified
379
+ // binding no longer silently continues into Owner approval publication —
380
+ // the enqueue sites below refuse to create an approval subject for an
381
+ // artifact whose stable ledger identity is not established, while the
382
+ // generation chain and its artifacts are preserved for later replay.
383
+ const identityBinding = await backfillScribeIdentity({
384
+ stateManager, artifactStore, scribeTaskId, dreamerTaskId: dreamerSeedTaskId,
385
+ workspaceDir: opts.workspaceDir, now: new Date().toISOString(),
386
+ });
387
+ if (scribeIdentityNote(identityBinding) !== '') {
388
+ const scribeStage = stages.find((s) => s.name === 'scribe');
389
+ if (scribeStage) (scribeStage as { reason?: string }).reason = scribeIdentityNote(identityBinding).replace(/^; /, '');
390
+ }
391
+
378
392
  // ── Atomic capability branching ──
379
393
  // Per user correction (2026-06-18): ArtificerL2 + Evaluator are atomic.
380
394
  // When OFF (or not provided), skip the adversarial loop entirely and
@@ -383,7 +397,14 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
383
397
  if (!capabilityEnabled) {
384
398
  stages.push({ name: 'adversarial_loop', status: 'skipped', reason: capabilityDisabledReason });
385
399
  onProgress('adversarial_loop', 'skipped', capabilityDisabledReason);
386
- return await textPrincipleOnlyResult({ painId: opts.painId, stages, scribeTaskId, disabledReason: capabilityDisabledReason, artifactStore });
400
+ // PRI-804: text principles enter the existing prompt-channel approval
401
+ // queue (Owner governance, same gate as RuleCode) instead of dead-ending.
402
+ const approvalStore = new SqliteApprovalQueueStore(stateManager.connection);
403
+ return await textPrincipleOnlyResult({
404
+ painId: opts.painId, stages, scribeTaskId, disabledReason: capabilityDisabledReason,
405
+ artifactStore, approvalStore, now: new Date().toISOString(),
406
+ identityBinding,
407
+ });
387
408
  }
388
409
 
389
410
  // ── Stage: adversarial loop (artificer↔evaluator) ──
@@ -429,7 +450,7 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
429
450
  const artificerRunner = new ArtificerRunner(
430
451
  {
431
452
  stateManager, runtimeAdapter: capability.artificerAdapter, eventEmitter, validator: new DefaultArtificerValidator(), artifactStore,
432
- behaviorExamplePack: opts.behaviorExamplePack, contentHashFn,
453
+ behaviorExamplePack: opts.behaviorExamplePack,
433
454
  },
434
455
  {
435
456
  ...runnerOptsFor(capability.artificerAdapter),
@@ -488,8 +509,32 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
488
509
  // owner can review it. Without this, the pipeline produces a candidate
489
510
  // artifact but it never enters the approval queue — the production chain
490
511
  // is broken at step 2→3. Tests manually called enqueue(); production did not.
512
+ //
513
+ // Owner Decision Experience v1 Phase A (SPEC §9.1.4/§9.1.6): creating an
514
+ // Owner approval subject is a governance publication — it requires a
515
+ // verified stable ledger identity. When the chain's candidate could not be
516
+ // bound to exactly one existing ledger principle, the rule artifact must
517
+ // NOT enter the approvable set, even though the artifact itself (and every
518
+ // intermediate artifact) is preserved. Recovery is replay: after the
519
+ // ledger/identity repair, re-running the pipeline re-attempts the binding
520
+ // idempotently and then publishes.
491
521
  let approvalId: string | null = null;
492
522
  if (pipelineDecision === 'candidate_ready_for_owner_review' && loopResult.ruleArtifactId) {
523
+ const ruleIdentity = await resolveRuleArtifactIdentity(artifactStore, loopResult.ruleArtifactId, identityBinding);
524
+ if (ruleIdentity === null) {
525
+ const reasonCode = scribeIdentityReasonCode(identityBinding);
526
+ return {
527
+ decision: pipelineDecision,
528
+ painId: opts.painId,
529
+ stages,
530
+ scribeTaskId,
531
+ adversarialLoop: loopResult,
532
+ ruleArtifactId: loopResult.ruleArtifactId,
533
+ principleArtifactId: loopResult.principleArtifactId,
534
+ approvalId: null,
535
+ degradationReason: `identity_binding_unverified: ${reasonCode}. Rule artifact NOT enqueued for Owner approval (governance publication boundary). Artifacts preserved; repair the ledger identity and re-run this pipeline to publish.`,
536
+ };
537
+ }
493
538
  try {
494
539
  const approvalStore = new SqliteApprovalQueueStore(stateManager.connection);
495
540
  const riskLevel = getChannelRiskLevel(channel);
@@ -657,11 +702,6 @@ export interface CreateEvaluatorRunnerDepsInputs {
657
702
  readonly artifactStore: PIArtifactStore;
658
703
  /** Workspace directory containing `.pd/config.yaml` (flag source). */
659
704
  readonly workspaceDir: string;
660
- /**
661
- * Layer 0 content-hash function (design §6.1). Optional — defaults to the
662
- * module-level sha256-hex `contentHashFn`. Allow override for tests.
663
- */
664
- readonly contentHashFn?: (input: string) => string;
665
705
  }
666
706
 
667
707
  /**
@@ -686,10 +726,6 @@ export function createEvaluatorRunnerDeps(inputs: CreateEvaluatorRunnerDepsInput
686
726
  eventEmitter,
687
727
  validator,
688
728
  artifactStore,
689
- // Layer 0 (design §6.1): inject the content-hash function so the evaluator
690
- // writer can attach a `predecessorSummary.contentHash` for staleness
691
- // detection. Defaults to the module-level sha256-hex constant.
692
- contentHashFn: inputs.contentHashFn ?? contentHashFn,
693
729
  isRepairLoopEnabled: (): boolean => {
694
730
  // rc-9: never throw on malformed config — fail safe to false so the
695
731
  // legacy (non-repair) path runs. The malformed config is already
@@ -846,16 +882,313 @@ interface TextPrincipleOnlyParams {
846
882
  readonly scribeTaskId: string;
847
883
  readonly disabledReason: string;
848
884
  readonly artifactStore: PIArtifactStore;
885
+ /** PRI-804: approval queue used to enqueue the text principle (prompt channel). */
886
+ readonly approvalStore: SqliteApprovalQueueStore;
887
+ readonly now: string;
888
+ /** Phase A: structured identity outcome from the shared post-scribe backfill. */
889
+ readonly identityBinding: ScribeIdentityBinding;
890
+ }
891
+
892
+ /**
893
+ * Owner Decision Experience v1 Phase A (SPEC §9.1) — structured ledger
894
+ * identity binding outcome for the internalization chain.
895
+ *
896
+ * `bound`/`already_bound` mean the scribe principle artifact carries a
897
+ * source_principle_id that was READ BACK from the PI store after the write
898
+ * and, when the chain candidate resolved, that it equals the unique existing
899
+ * ledger principle. Every other status is a publication blocker: the reason
900
+ * codes follow the SPEC's suggested vocabulary
901
+ * (candidate_unresolved / ledger_missing / ledger_ambiguous /
902
+ * identity_conflict / binding_write_failed).
903
+ */
904
+ export type ScribeIdentityBinding =
905
+ | { status: 'bound'; principleId: string }
906
+ | { status: 'already_bound'; principleId: string }
907
+ | { status: 'no_principle_artifact' }
908
+ | { status: 'candidate_unresolved' }
909
+ | { status: 'ledger_missing'; candidateId: string }
910
+ | { status: 'ledger_ambiguous'; candidateId: string; principleIds: string[] }
911
+ | { status: 'identity_conflict'; existingPrincipleId: string; resolvedPrincipleId: string }
912
+ | { status: 'bound_target_missing'; principleId: string }
913
+ | { status: 'binding_write_failed'; errorMessage: string }
914
+ | { status: 'binding_readback_failed'; expectedPrincipleId: string; errorMessage: string };
915
+
916
+ /** Only these two statuses authorize governance publication (approval enqueue). */
917
+ export function isIdentityBindingVerified(
918
+ binding: ScribeIdentityBinding,
919
+ ): binding is Extract<ScribeIdentityBinding, { status: 'bound' | 'already_bound' }> {
920
+ return binding.status === 'bound' || binding.status === 'already_bound';
921
+ }
922
+
923
+ /** Stable machine reason code for observability/logs (SPEC §9.1 vocabulary). */
924
+ export function scribeIdentityReasonCode(binding: ScribeIdentityBinding): string {
925
+ switch (binding.status) {
926
+ case 'bound':
927
+ case 'already_bound':
928
+ return 'bound';
929
+ case 'no_principle_artifact':
930
+ return 'no_principle_artifact';
931
+ case 'candidate_unresolved':
932
+ return 'candidate_unresolved';
933
+ case 'ledger_missing':
934
+ return 'ledger_missing';
935
+ case 'ledger_ambiguous':
936
+ return 'ledger_ambiguous';
937
+ case 'identity_conflict':
938
+ return 'identity_conflict';
939
+ case 'bound_target_missing':
940
+ return 'identity_conflict';
941
+ case 'binding_write_failed':
942
+ return 'binding_write_failed';
943
+ case 'binding_readback_failed':
944
+ return 'binding_write_failed';
945
+ }
946
+ }
947
+
948
+ /** Human/telemetry note for the scribe stage; '' on a verified binding. */
949
+ export function scribeIdentityNote(binding: ScribeIdentityBinding): string {
950
+ switch (binding.status) {
951
+ case 'bound':
952
+ case 'already_bound':
953
+ return '';
954
+ case 'no_principle_artifact':
955
+ return '; source_principle_id_backfill_skipped: no principle artifact from scribe';
956
+ case 'candidate_unresolved':
957
+ return '; source_principle_id_backfill_skipped: no candidateId on the dreamer seed (candidate_unresolved)';
958
+ case 'ledger_missing':
959
+ return `; source_principle_id_backfill_skipped: candidate ${binding.candidateId} matches no ledger principle (ledger_missing)`;
960
+ case 'ledger_ambiguous':
961
+ return `; source_principle_id_backfill_skipped: candidate ${binding.candidateId} matches ${binding.principleIds.length} ledger principles (ledger_ambiguous)`;
962
+ case 'identity_conflict':
963
+ return `; source_principle_id_backfill_refused: artifact already bound to ${binding.existingPrincipleId}, chain resolves to ${binding.resolvedPrincipleId} (identity_conflict)`;
964
+ case 'bound_target_missing':
965
+ return `; source_principle_id_backfill_refused: artifact bound to ${binding.principleId} which is absent from the ledger (identity_conflict)`;
966
+ case 'binding_write_failed':
967
+ return `; source_principle_id_backfill_failed: ${binding.errorMessage}`;
968
+ case 'binding_readback_failed':
969
+ return `; source_principle_id_backfill_unverified: read-back mismatch for ${binding.expectedPrincipleId}: ${binding.errorMessage}`;
970
+ }
971
+ }
972
+
973
+ /**
974
+ * PRI-804(a) + Phase A: resolve the UNIQUE ledger principle for the chain.
975
+ *
976
+ * The ledger principle is the identity the Console groups approvals by and the
977
+ * one `upgradeLedgerPrinciple` activates after approval. The only durable link
978
+ * from the internalization chain to the ledger is the candidateId carried in
979
+ * the dreamer task's diagnosticJson (set by the intake bridge) — the ledger
980
+ * entry stores it in derivedFromPainIds. Phase A hardens the lookup:
981
+ * `listForCandidate` (plural, same adapter the intake bridge uses — one
982
+ * authority, no second truth) rejects zero and multi matches instead of
983
+ * silently taking the first hit.
984
+ */
985
+ async function resolveLedgerIdentity(
986
+ stateManager: RuntimeStateManager,
987
+ dreamerTaskId: string,
988
+ workspaceDir: string,
989
+ ): Promise<ScribeIdentityBinding | { status: 'resolved'; principleId: string }> {
990
+ let candidateId: string | undefined;
991
+ try {
992
+ const dreamerTask = await stateManager.getTask(dreamerTaskId);
993
+ if (typeof dreamerTask?.diagnosticJson === 'string') {
994
+ const parsed: unknown = JSON.parse(dreamerTask.diagnosticJson);
995
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
996
+ && Object.hasOwn(parsed, 'candidateId')) {
997
+ const stored = Reflect.get(parsed, 'candidateId');
998
+ if (typeof stored === 'string' && stored.length > 0) candidateId = stored;
999
+ }
1000
+ }
1001
+ } catch {
1002
+ return { status: 'candidate_unresolved' }; // malformed dreamer diagnosticJson
1003
+ }
1004
+ if (!candidateId) return { status: 'candidate_unresolved' };
1005
+ let matches: { id: string }[];
1006
+ try {
1007
+ const ledger = new PrincipleTreeLedgerAdapter({ stateDir: path.join(workspaceDir, '.state') });
1008
+ matches = ledger.listForCandidate(candidateId);
1009
+ } catch {
1010
+ // Ledger unreadable cannot be distinguished from "no ledger" here — the
1011
+ // write attempt below will surface the concrete error; treat as missing
1012
+ // with the skip note (same observable class as before Phase A).
1013
+ return { status: 'ledger_missing', candidateId };
1014
+ }
1015
+ if (matches.length === 0) return { status: 'ledger_missing', candidateId };
1016
+ if (matches.length > 1) {
1017
+ return { status: 'ledger_ambiguous', candidateId, principleIds: matches.map((m) => m.id) };
1018
+ }
1019
+ const [unique] = matches;
1020
+ if (unique === undefined) return { status: 'ledger_missing', candidateId };
1021
+ return { status: 'resolved', principleId: unique.id };
1022
+ }
1023
+
1024
+ interface BackfillIdentityParams {
1025
+ readonly stateManager: RuntimeStateManager;
1026
+ readonly artifactStore: PIArtifactStore;
1027
+ readonly scribeTaskId: string;
1028
+ readonly dreamerTaskId: string;
1029
+ readonly workspaceDir: string;
1030
+ readonly now: string;
1031
+ }
1032
+
1033
+ /**
1034
+ * EP002-R4 follow-up #2 + Phase A: backfill the scribe principle artifact's
1035
+ * source_principle_id with the LEDGER principle UUID (shared by the rule and
1036
+ * text paths — runs right after the scribe stage succeeds). Every downstream
1037
+ * identity resolution reads this column FIRST (extractPrincipleId /
1038
+ * EvaluatorRunner.extractPrincipleIdFromArtifact), so this single write is
1039
+ * what binds approvals, Console grouping, ledger upgrades, and rule
1040
+ * artifacts to the ledger UUID instead of the title namespace.
1041
+ *
1042
+ * Phase A contract changes vs the note-only version:
1043
+ * - the outcome is structured (ScribeIdentityBinding), not a free string;
1044
+ * - an existing binding that DISAGREES with the resolved ledger target is an
1045
+ * identity_conflict — never overwritten;
1046
+ * - a successful write is verified by reading the artifact back from the
1047
+ * PI store (log success alone is not binding evidence — SPEC §9.1);
1048
+ * - a pre-existing binding is only trusted when its target exists in the
1049
+ * ledger read (listForCandidate is not applicable — we check presence by
1050
+ * resolving through the same ledger load).
1051
+ *
1052
+ * Still never throws — identity enrichment must not break the generation
1053
+ * chain; it only gates the later governance publication.
1054
+ */
1055
+ export async function backfillScribeIdentity(params: BackfillIdentityParams): Promise<ScribeIdentityBinding> {
1056
+ const { stateManager, artifactStore, scribeTaskId, dreamerTaskId, workspaceDir, now } = params;
1057
+ try {
1058
+ const arts = await artifactStore.listBySourceTaskId(scribeTaskId);
1059
+ const principleArt = arts.find((a) => a.artifactKind === 'principle');
1060
+ if (!principleArt) return { status: 'no_principle_artifact' };
1061
+ const resolution = await resolveLedgerIdentity(stateManager, dreamerTaskId, workspaceDir);
1062
+ if (principleArt.sourcePrincipleId) {
1063
+ // Already bound (rerun). Idempotent success only when the binding is
1064
+ // consistent with what the chain resolves to now.
1065
+ if (resolution.status === 'resolved') {
1066
+ if (resolution.principleId === principleArt.sourcePrincipleId) {
1067
+ return { status: 'already_bound', principleId: principleArt.sourcePrincipleId };
1068
+ }
1069
+ return {
1070
+ status: 'identity_conflict',
1071
+ existingPrincipleId: principleArt.sourcePrincipleId,
1072
+ resolvedPrincipleId: resolution.principleId,
1073
+ };
1074
+ }
1075
+ if (resolution.status === 'ledger_ambiguous') return resolution;
1076
+ // Chain candidate unresolved/missing on this replay — the durable
1077
+ // binding still stands only if its target is a real ledger principle.
1078
+ const ledger = new PrincipleTreeLedgerAdapter({ stateDir: path.join(workspaceDir, '.state') });
1079
+ if (ledger.hasPrinciple(principleArt.sourcePrincipleId)) {
1080
+ return { status: 'already_bound', principleId: principleArt.sourcePrincipleId };
1081
+ }
1082
+ return { status: 'bound_target_missing', principleId: principleArt.sourcePrincipleId };
1083
+ }
1084
+ if (resolution.status !== 'resolved') return resolution;
1085
+ const ledgerPrincipleId = resolution.principleId;
1086
+ await artifactStore.upsertArtifact({ ...principleArt, sourcePrincipleId: ledgerPrincipleId, updatedAt: now });
1087
+ // Read-back verification: the PI store must return the bound ID.
1088
+ try {
1089
+ const readBack = await artifactStore.getArtifactById(principleArt.artifactId);
1090
+ if (readBack?.sourcePrincipleId !== ledgerPrincipleId) {
1091
+ return {
1092
+ status: 'binding_readback_failed',
1093
+ expectedPrincipleId: ledgerPrincipleId,
1094
+ errorMessage: `read-back sourcePrincipleId=${readBack?.sourcePrincipleId ?? 'null'}`,
1095
+ };
1096
+ }
1097
+ } catch (readErr: unknown) {
1098
+ const msg = readErr instanceof Error ? readErr.message : String(readErr);
1099
+ return { status: 'binding_readback_failed', expectedPrincipleId: ledgerPrincipleId, errorMessage: msg };
1100
+ }
1101
+ return { status: 'bound', principleId: ledgerPrincipleId };
1102
+ } catch (err: unknown) {
1103
+ const msg = err instanceof Error ? err.message : String(err);
1104
+ return { status: 'binding_write_failed', errorMessage: msg };
1105
+ }
1106
+ }
1107
+
1108
+ /**
1109
+ * Phase A (SPEC §9.1.6): before the rule artifact enters the approvable set,
1110
+ * verify the chain's ledger identity is VERIFIED and the rule artifact carries
1111
+ * exactly that binding. A chain without a verified unique ledger target must
1112
+ * not publish, even if the artifact itself carries some id (e.g. a legacy
1113
+ * title-namespace value the evaluator would have echoed). Returns the
1114
+ * verified principle id, or null when publication must be refused.
1115
+ */
1116
+ async function resolveRuleArtifactIdentity(
1117
+ artifactStore: PIArtifactStore,
1118
+ ruleArtifactId: string,
1119
+ identityBinding: ScribeIdentityBinding,
1120
+ ): Promise<string | null> {
1121
+ if (!isIdentityBindingVerified(identityBinding)) return null;
1122
+ let artifactSourcePrincipleId: string | undefined;
1123
+ try {
1124
+ const ruleArt = await artifactStore.getArtifactById(ruleArtifactId);
1125
+ artifactSourcePrincipleId = ruleArt?.sourcePrincipleId;
1126
+ } catch {
1127
+ return null;
1128
+ }
1129
+ if (artifactSourcePrincipleId === undefined || artifactSourcePrincipleId !== identityBinding.principleId) return null;
1130
+ return artifactSourcePrincipleId;
849
1131
  }
850
1132
 
851
1133
  async function textPrincipleOnlyResult(
852
1134
  params: TextPrincipleOnlyParams,
853
1135
  ): Promise<RuleHostPipelineResult> {
854
- const { painId, stages, scribeTaskId, disabledReason, artifactStore } = params;
1136
+ const { painId, stages, scribeTaskId, disabledReason, artifactStore, approvalStore, now, identityBinding } = params;
855
1137
  // Look up the principle artifact produced by the scribe stage.
856
1138
  try {
857
1139
  const arts = await artifactStore.listBySourceTaskId(scribeTaskId);
858
1140
  const principleArt = arts.find((a) => a.artifactKind === 'principle');
1141
+ // Identity backfill already ran on the shared post-scribe step
1142
+ // (backfillScribeIdentity) for BOTH paths; nothing to do here.
1143
+ // On the text path the evaluator never runs, so nothing else flips the
1144
+ // validation status — mark it validated here (same store API the
1145
+ // evaluator uses; scribe task success already implies its validator
1146
+ // passed — peer-runner contract: output_invalid tasks never succeed).
1147
+ let identityNote = '';
1148
+ if (principleArt && principleArt.validationStatus !== 'validated') {
1149
+ try {
1150
+ await artifactStore.updateValidationStatus(principleArt.artifactId, 'validated');
1151
+ } catch (err: unknown) {
1152
+ const msg = err instanceof Error ? err.message : String(err);
1153
+ identityNote = `; validation_status_mark_failed: ${msg}`;
1154
+ }
1155
+ }
1156
+ // PRI-804: a text principle is a prompt-channel intervention and follows the
1157
+ // same Owner governance as RuleCode (Owner decision 2026-09-15): enqueue it
1158
+ // into the EXISTING approval queue so the Owner can approve it in Console.
1159
+ // Deterministic approval id (apr_prompt_<artifactId>) + INSERT OR IGNORE make
1160
+ // replays idempotent. Enqueue failure degrades observably (rc-9), never
1161
+ // throwing away the artifact.
1162
+ //
1163
+ // Phase A governance publication boundary: an enqueue is only allowed when
1164
+ // the artifact's ledger identity binding is VERIFIED. Unverified bindings
1165
+ // keep the artifact (and its validation status) but must not create an
1166
+ // Owner approval subject; recovery is a replay after identity repair.
1167
+ let approvalId: string | null = null;
1168
+ let enqueueNote = '';
1169
+ if (!principleArt) {
1170
+ enqueueNote = '; approval_enqueue_skipped: no principle artifact from scribe';
1171
+ } else if (!isIdentityBindingVerified(identityBinding)) {
1172
+ const reasonCode = scribeIdentityReasonCode(identityBinding);
1173
+ enqueueNote = `; approval_enqueue_skipped: identity_binding_unverified (${reasonCode}). Principle artifact preserved; repair the ledger identity and re-run this pipeline to publish.`;
1174
+ } else if (identityBinding.principleId !== principleArt.sourcePrincipleId) {
1175
+ enqueueNote = `; approval_enqueue_skipped: identity_binding_unverified (identity_conflict: chain resolves to ${identityBinding.principleId}, artifact carries ${principleArt.sourcePrincipleId ?? 'no binding'}). Principle artifact preserved; repair the ledger identity and re-run this pipeline to publish.`;
1176
+ } else {
1177
+ try {
1178
+ const record = await approvalStore.enqueue({
1179
+ artifactId: principleArt.artifactId,
1180
+ channel: 'prompt',
1181
+ riskLevel: getChannelRiskLevel('prompt'),
1182
+ summary: `Text principle candidate for pain ${painId}`,
1183
+ triggerReason: `text_principle_only: pain=${painId}, principle=${principleArt.artifactId}; code-rule capability off (${disabledReason})`,
1184
+ }, now);
1185
+ const { approvalId: enqueuedApprovalId } = record;
1186
+ approvalId = enqueuedApprovalId;
1187
+ } catch (err: unknown) {
1188
+ const msg = err instanceof Error ? err.message : String(err);
1189
+ enqueueNote = `; approval_enqueue_failed: ${msg}. Manual enqueue required: pd activation dispatch --artifact-id ${principleArt.artifactId} --channel prompt`;
1190
+ }
1191
+ }
859
1192
  return {
860
1193
  decision: 'text_principle_only',
861
1194
  painId,
@@ -863,8 +1196,8 @@ async function textPrincipleOnlyResult(
863
1196
  scribeTaskId,
864
1197
  ruleArtifactId: null,
865
1198
  principleArtifactId: principleArt?.artifactId ?? null,
866
- approvalId: null,
867
- degradationReason: `code_rule_capability_off: ${disabledReason}`,
1199
+ approvalId,
1200
+ degradationReason: `code_rule_capability_off: ${disabledReason}${identityNote}${enqueueNote}`,
868
1201
  };
869
1202
  } catch (error: unknown) {
870
1203
  const message = error instanceof Error ? error.message : String(error);
@@ -21,7 +21,6 @@ import { auditCandidateLedgerConsistency } from '@principles/core/runtime-v2';
21
21
  import { OperatorHealthReadModel } from '@principles/core/runtime-v2';
22
22
  import { createInternalizationQueueReadModel } from '@principles/core/runtime-v2';
23
23
  import { createPITaskDiagnosticJson } from '@principles/core/runtime-v2';
24
- import { contentHashFn } from './rulehost-pipeline-runner.js';
25
24
  import {
26
25
  computeOverallStatus,
27
26
  boundedEvidence,
@@ -149,7 +148,6 @@ export async function runSyntheticBaseline(opts: SyntheticBaselineRunnerOptions)
149
148
  artifactStore: stateManager.piArtifactStore,
150
149
  validator: new DefaultDiagRootCauseValidator(),
151
150
  contextAssembler,
152
- contentHashFn,
153
151
  },
154
152
  {
155
153
  owner: 'synthetic-baseline',
@@ -166,7 +164,6 @@ export async function runSyntheticBaseline(opts: SyntheticBaselineRunnerOptions)
166
164
  eventEmitter,
167
165
  artifactStore: stateManager.piArtifactStore,
168
166
  validator: new DefaultDiagDistillerValidator(),
169
- contentHashFn,
170
167
  },
171
168
  {
172
169
  owner: 'synthetic-baseline',
@@ -183,7 +180,6 @@ export async function runSyntheticBaseline(opts: SyntheticBaselineRunnerOptions)
183
180
  eventEmitter,
184
181
  artifactStore: stateManager.piArtifactStore,
185
182
  committer,
186
- contentHashFn,
187
183
  },
188
184
  {
189
185
  owner: 'synthetic-baseline',
@@ -19,10 +19,27 @@ import * as path from 'node:path';
19
19
 
20
20
  export type VersionReportSource = 'official-installer' | 'official-legacy-overlay' | 'unknown';
21
21
 
22
+ /**
23
+ * Where the component versions in a report were read from (SPEC §12, additive
24
+ * field). The installed canonical runtime is the live truth
25
+ * (`~/.pd/runtime/<component>/package.json`); the release cache under
26
+ * `~/.pd/releases/<id>/` remains the fallback for layouts without a runtime
27
+ * directory, and the legacy overlay reports its own bundle.
28
+ */
29
+ export type VersionComponentsSource = 'runtime' | 'release-cached' | 'legacy-overlay';
30
+
22
31
  export interface VersionReport {
23
32
  readonly productVersion: string;
24
33
  readonly releaseId: string;
25
34
  readonly components: Readonly<Record<string, string>>;
35
+ /**
36
+ * Which layout the component versions were read from. Additive diagnostic
37
+ * ahead of the canonical SPEC §12 contract update (the authoritative copy
38
+ * lives in create-principles-disciple/src/update/product-identity.ts):
39
+ * optional so older consumers of this mirror stay compatible; the builder
40
+ * always emits it.
41
+ */
42
+ readonly componentsSource?: VersionComponentsSource;
26
43
  readonly bootstrapVersion: string;
27
44
  readonly channel: 'stable' | 'candidate';
28
45
  readonly source: VersionReportSource;
@@ -93,6 +110,7 @@ function buildLegacyOverlayReport(overlayDir: string, bootstrap: Record<string,
93
110
  productVersion: version,
94
111
  releaseId: '0'.repeat(64),
95
112
  components: { plugin: version },
113
+ componentsSource: 'legacy-overlay',
96
114
  bootstrapVersion: typeof bootstrapVersion === 'string' ? bootstrapVersion : 'unknown',
97
115
  channel: 'stable',
98
116
  source: 'official-legacy-overlay',
@@ -177,9 +195,40 @@ export function buildVersionReport(homeDir: string = os.homedir()): VersionRepor
177
195
  ? 'degraded'
178
196
  : releaseMetadataMatchesActive ? 'healthy' : 'corrupt';
179
197
 
198
+ // The live canonical runtime is the primary component-version source; the
199
+ // release cache stays as the fallback for layouts without a runtime
200
+ // directory (and per component when the runtime copy is absent). Component
201
+ // versions are read, never judged: independently versioned components are
202
+ // not corruption, and health remains bound to the active-identity
203
+ // verification above (a runtime full of readable package versions alone
204
+ // never makes the report healthy). A malformed component manifest degrades
205
+ // to an omitted component — this command exists to diagnose broken
206
+ // installs, so one corrupt manifest must not kill the whole report.
207
+ const runtimeDir = path.join(pdHome, 'runtime');
208
+ const componentsBaseDir = fs.existsSync(runtimeDir) ? runtimeDir : releaseDir;
209
+ const componentsSource: VersionReport['componentsSource'] = componentsBaseDir === runtimeDir
210
+ ? 'runtime'
211
+ : 'release-cached';
212
+ const readComponentManifest = (component: string): Record<string, unknown> | null => {
213
+ try {
214
+ return readJsonIfPresent(path.join(componentsBaseDir, component, 'package.json'));
215
+ } catch {
216
+ return null; // malformed live manifest: omit the component, keep the report
217
+ }
218
+ };
180
219
  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'));
220
+ for (const component of ['plugin', 'console', 'core', 'pd-cli', 'host-runtime', 'install-layout', 'codex-adapter', 'release-manager']) {
221
+ let manifest = readComponentManifest(component);
222
+ if (manifest === null && componentsBaseDir === runtimeDir) {
223
+ // Per-component fallback: a partially populated runtime dir (partial
224
+ // sync) must not silently drop components that the release cache can
225
+ // still report.
226
+ try {
227
+ manifest = readJsonIfPresent(path.join(releaseDir, component, 'package.json'));
228
+ } catch {
229
+ manifest = null;
230
+ }
231
+ }
183
232
  const version = manifest?.version;
184
233
  if (typeof version === 'string') {
185
234
  components[component] = version;
@@ -193,6 +242,7 @@ export function buildVersionReport(homeDir: string = os.homedir()): VersionRepor
193
242
  productVersion,
194
243
  releaseId,
195
244
  components,
245
+ componentsSource,
196
246
  bootstrapVersion: typeof bootstrapVersion === 'string' ? bootstrapVersion : 'unknown',
197
247
  channel: channelValue === 'candidate' ? 'candidate' : 'stable',
198
248
  source: 'official-installer',
@@ -61,7 +61,6 @@ function makeValidConfigYaml(): string {
61
61
  evaluator: { enabled: false },
62
62
  rolloutReviewer: { enabled: false },
63
63
  correctionObserver: { enabled: false },
64
- empathyObserver: { enabled: false },
65
64
  },
66
65
  },
67
66
  ui: { diagnostics: { mode: 'simple' } },
@@ -127,7 +126,6 @@ describe('Internal agent runtime binding readiness', () => {
127
126
  evaluator: { enabled: false },
128
127
  rolloutReviewer: { enabled: false },
129
128
  correctionObserver: { enabled: false },
130
- empathyObserver: { enabled: false },
131
129
  },
132
130
  },
133
131
  });
@@ -130,7 +130,7 @@ describe('parseTriggerDecisions', () => {
130
130
  // ── Integration: Real Log File Fixture ─────────────────────────────────────
131
131
 
132
132
  describe('real SYSTEM log fixture', () => {
133
- const tmpDir = path.join(os.tmpdir(), `pd-test-evidence-${Date.now()}`);
133
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-test-evidence-'));
134
134
  const logDir = path.join(tmpDir, 'memory', 'logs');
135
135
 
136
136
  beforeEach(() => {