@allocator-one/rcl 4.5.8 → 4.5.9

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 (76) hide show
  1. package/README.md +5 -4
  2. package/dist/consensus/semantic-deduper.d.ts +2 -1
  3. package/dist/consensus/semantic-deduper.js +21 -10
  4. package/dist/consensus/semantic-deduper.js.map +1 -1
  5. package/dist/converge/attempt-budget.d.ts +11 -0
  6. package/dist/converge/attempt-budget.js +119 -61
  7. package/dist/converge/attempt-budget.js.map +1 -1
  8. package/dist/converge/delivery-reconciliation.d.ts +2 -1
  9. package/dist/converge/delivery-reconciliation.js +17 -9
  10. package/dist/converge/delivery-reconciliation.js.map +1 -1
  11. package/dist/converge/launch-guard.d.ts +3 -0
  12. package/dist/converge/launch-guard.js +53 -17
  13. package/dist/converge/launch-guard.js.map +1 -1
  14. package/dist/converge/recovered-production.d.ts +14 -0
  15. package/dist/converge/recovered-production.js +61 -0
  16. package/dist/converge/recovered-production.js.map +1 -0
  17. package/dist/converge/recovery-launch.js +82 -35
  18. package/dist/converge/recovery-launch.js.map +1 -1
  19. package/dist/converge/run-state.d.ts +2 -0
  20. package/dist/converge/run-state.js +26 -12
  21. package/dist/converge/run-state.js.map +1 -1
  22. package/dist/converge/semantic-state.js +18 -4
  23. package/dist/converge/semantic-state.js.map +1 -1
  24. package/dist/coordination/registry-lock.d.ts +2 -0
  25. package/dist/coordination/registry-lock.js +42 -13
  26. package/dist/coordination/registry-lock.js.map +1 -1
  27. package/dist/evidence/claim-recovery/delivery.d.ts +25 -0
  28. package/dist/evidence/claim-recovery/delivery.js +100 -0
  29. package/dist/evidence/claim-recovery/delivery.js.map +1 -0
  30. package/dist/evidence/claim-recovery/public-adoption.d.ts +31 -0
  31. package/dist/evidence/claim-recovery/public-adoption.js +177 -0
  32. package/dist/evidence/claim-recovery/public-adoption.js.map +1 -0
  33. package/dist/evidence/claim-recovery/public-manifest.d.ts +114 -0
  34. package/dist/evidence/claim-recovery/public-manifest.js +11 -0
  35. package/dist/evidence/claim-recovery/public-manifest.js.map +1 -0
  36. package/dist/evidence/claim-recovery/public-model.d.ts +85 -0
  37. package/dist/evidence/claim-recovery/public-model.js +177 -0
  38. package/dist/evidence/claim-recovery/public-model.js.map +1 -0
  39. package/dist/evidence/original-run/journal.d.ts +10 -0
  40. package/dist/evidence/original-run/journal.js +55 -3
  41. package/dist/evidence/original-run/journal.js.map +1 -1
  42. package/dist/evidence/original-run/lock.js +22 -9
  43. package/dist/evidence/original-run/lock.js.map +1 -1
  44. package/dist/evidence/recover-claim.d.ts +21 -0
  45. package/dist/evidence/recover-claim.js +504 -0
  46. package/dist/evidence/recover-claim.js.map +1 -0
  47. package/dist/index.js +228 -136
  48. package/dist/index.js.map +1 -1
  49. package/dist/report/assembly.d.ts +2 -0
  50. package/dist/report/assembly.js +1 -0
  51. package/dist/report/assembly.js.map +1 -1
  52. package/dist/report/consensus-assembly.d.ts +2 -0
  53. package/dist/report/consensus-assembly.js +6 -3
  54. package/dist/report/consensus-assembly.js.map +1 -1
  55. package/dist/report/reviewer-artifact.js +3 -1
  56. package/dist/report/reviewer-artifact.js.map +1 -1
  57. package/dist/report/run-header.js +2 -0
  58. package/dist/report/run-header.js.map +1 -1
  59. package/dist/telemetry/deliver.d.ts +8 -0
  60. package/dist/telemetry/deliver.js +55 -8
  61. package/dist/telemetry/deliver.js.map +1 -1
  62. package/dist/telemetry/events.js +2 -0
  63. package/dist/telemetry/events.js.map +1 -1
  64. package/dist/telemetry/outbox.d.ts +20 -0
  65. package/dist/telemetry/outbox.js +324 -103
  66. package/dist/telemetry/outbox.js.map +1 -1
  67. package/dist/telemetry/reviewer-delivery.d.ts +95 -3
  68. package/dist/telemetry/reviewer-delivery.js +600 -12
  69. package/dist/telemetry/reviewer-delivery.js.map +1 -1
  70. package/dist/telemetry/sink.d.ts +14 -0
  71. package/dist/telemetry/sink.js +44 -2
  72. package/dist/telemetry/sink.js.map +1 -1
  73. package/dist/telemetry/terminal-reviewer-delivery.d.ts +154 -5
  74. package/dist/telemetry/terminal-reviewer-delivery.js +196 -21
  75. package/dist/telemetry/terminal-reviewer-delivery.js.map +1 -1
  76. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -47,7 +47,9 @@ import { resolveFinding, buildDiscussPrompts, runDiscussion, loadContextDocs, }
47
47
  import { DEFAULT_CONVERGE_ATTEMPT_CAP, claimConvergeAttempt, ConvergeAttemptBudgetExceededError, convergeAttemptErrorExitCode, ConvergeAttemptStateError, resolveGitCommonDir, } from './converge/attempt-budget.js';
48
48
  import { DEFAULT_CONVERGE_ROUND_CAP, HARD_CONVERGE_ROUND_CAP, processRoundReport, recordVerdicts, findingGatingReason, ConvergeRoundCapError, ConvergeRunStateError, } from './converge/run-state.js';
49
49
  import { applyRoundGap, previewRoundGap } from './converge/round-gap.js';
50
+ import { withNativeTarget } from './converge/target-ownership.js';
50
51
  import { guardReviewLaunch, ReviewLaunchRefused } from './converge/launch-guard.js';
52
+ import { selectCurrentRecoveredProduction } from './converge/recovered-production.js';
51
53
  import { createBoundFixRecovery } from './converge/bound-fix-recovery.js';
52
54
  import { openReadSink } from './telemetry/read-sink.js';
53
55
  import { reconcileFlushedRun } from './converge/delivery-reconciliation.js';
@@ -110,7 +112,7 @@ program.hook('preAction', async (_thisCommand, actionCommand) => {
110
112
  // Reads and explicit repairs must not flush unrelated evidence, even in preview.
111
113
  if (actionCommand.parent?.name() === 'evidence' && (name === 'show' || name === 'status'))
112
114
  return;
113
- if (name === 'converge-rejected' || name === 'converge-stale' || name === 'converge-gap' || name === 'converge-reconcile-history' || name === 'recover-run' || name === 'recover-finding' || name === 'retriage-finding' || name === 'telemetry' || actionCommand.parent?.name() === 'telemetry' || name.includes('worker'))
115
+ if (name === 'converge-rejected' || name === 'converge-stale' || name === 'converge-gap' || name === 'converge-reconcile-history' || name === 'recover-run' || name === 'recover-claim' || name === 'recover-finding' || name === 'retriage-finding' || name === 'telemetry' || actionCommand.parent?.name() === 'telemetry' || name.includes('worker'))
114
116
  return;
115
117
  const flags = actionCommand.opts();
116
118
  if (flags.telemetry === false || (process.env['RCL_TELEMETRY'] ?? '').trim().toLowerCase() === 'off')
@@ -234,7 +236,7 @@ program
234
236
  .option('--round <n>', 'Converge round number (or RCL_CONVERGE_ROUND)')
235
237
  .option('--attempt <n>', 'Converge attempt number (or RCL_CONVERGE_ATTEMPT)')
236
238
  .option('--start-over', 'Start an explicitly requested fresh review with a new normal budget; retain all prior evidence and spending')
237
- .option('--guarded-converge', 'Validate and claim inside this review process; derive the round from native state')
239
+ .option('--guarded-converge', 'Validate and claim inside this review process, or consume an exact recovered-v3 --attempt; derive the round from native state')
238
240
  .option('--bound-fix-recovery <run-id>', 'Review unchanged inputs once live Harness evidence proves a retained bound fix obligation after this native dismissal-only run')
239
241
  .option('--launch-intent <intent>', 'Guarded intent: review, stop-upstream, stop-review, or retry-delivery')
240
242
  .option('--retry-report <path>', 'Original legacy report proving an inconclusive launch; the new inputs may differ; requires --retry-reason')
@@ -329,24 +331,31 @@ program
329
331
  throw new ConvergeAttemptStateError('maxAttempts (--max-attempts) must be a positive safe integer.');
330
332
  }
331
333
  }
332
- const claim = await claimConvergeAttempt({
333
- gitCommonDir: await resolveGitCommonDir(),
334
- target: opts.target,
335
- maxAttempts,
334
+ const gitCommonDir = await resolveGitCommonDir();
335
+ const claim = await withNativeTarget(gitCommonDir, opts.target, async (ownership) => {
336
+ const committed = await claimConvergeAttempt({
337
+ gitCommonDir,
338
+ target: opts.target,
339
+ maxAttempts,
340
+ ownership,
341
+ });
342
+ await reportConvergeEvents([
343
+ buildEvent({
344
+ kind: 'attempt_claimed',
345
+ convergeTarget: committed.target,
346
+ attempt: committed.attempt,
347
+ // The claim's local state path and process id stay on this machine.
348
+ payload: { attempt: committed.attempt, cap: committed.cap,
349
+ ...(committed.cycle ? { cycle_id: committed.cycle.id } : {}) },
350
+ }),
351
+ // An explicit --max-attempts is consent evidence, whatever it was before.
352
+ ...(maxAttempts !== undefined
353
+ ? [buildEvent({ kind: 'cap_changed', convergeTarget: committed.target, attempt: committed.attempt,
354
+ payload: { kind: 'attempts', to: committed.cap } })]
355
+ : []),
356
+ ]);
357
+ return committed;
336
358
  });
337
- await reportConvergeEvents([
338
- buildEvent({
339
- kind: 'attempt_claimed',
340
- convergeTarget: claim.target,
341
- attempt: claim.attempt,
342
- // The claim's local state path and process id stay on this machine.
343
- payload: { attempt: claim.attempt, cap: claim.cap, ...(claim.cycle ? { cycle_id: claim.cycle.id } : {}) },
344
- }),
345
- // An explicit --max-attempts is consent evidence, whatever it was before.
346
- ...(maxAttempts !== undefined
347
- ? [buildEvent({ kind: 'cap_changed', convergeTarget: claim.target, attempt: claim.attempt, payload: { kind: 'attempts', to: claim.cap } })]
348
- : []),
349
- ]);
350
359
  if (opts.json) {
351
360
  console.log(JSON.stringify(claim));
352
361
  }
@@ -596,10 +605,12 @@ program
596
605
  }
597
606
  }
598
607
  let report;
608
+ let reportJson;
599
609
  let reportSha256;
600
610
  try {
601
611
  const source = await readFile(opts.report);
602
- report = JSON.parse(source.toString('utf8'));
612
+ reportJson = source.toString('utf8');
613
+ report = JSON.parse(reportJson);
603
614
  reportSha256 = sha256(source);
604
615
  }
605
616
  catch (err) {
@@ -626,48 +637,69 @@ program
626
637
  if (reportRunId !== undefined && runId === undefined) {
627
638
  console.error(chalk.yellow(`Report run ${reportRunId} ${reportTarget === undefined ? 'carries no converge target' : `belongs to converge target ${reportTarget}`}, not ${opts.target}; the round keeps no run id.`));
628
639
  }
629
- const result = await processRoundReport({
630
- gitCommonDir: await resolveGitCommonDir(),
631
- target: opts.target,
632
- round,
633
- findings: report.findings,
634
- reportSha256,
635
- cycleId: report.run?.cycle_id,
636
- ...(maxRounds !== undefined ? { maxRounds } : {}),
637
- ...(runId !== undefined ? { runId } : {}),
638
- });
639
- const classified = result.findings.map((f) => ({
640
- identity: f.identity,
641
- status: f.status,
642
- gating: findingGatingReason(f.finding),
643
- severity: f.finding.severity,
644
- file: f.finding.file,
645
- startLine: f.finding.startLine,
646
- endLine: f.finding.endLine,
647
- title: f.finding.title,
648
- ...(f.suppressReason ? { suppressReason: f.suppressReason } : {}),
649
- }));
650
- const actionable = classified.filter((f) => (f.status === 'new' || f.status === 'regating') && f.gating !== 'none');
651
- await reportConvergeEvents([
652
- buildEvent({
653
- kind: 'round_processed',
654
- convergeTarget: opts.target,
640
+ const gitCommonDir = await resolveGitCommonDir();
641
+ const target = opts.target;
642
+ const { result, classified, actionable } = await withNativeTarget(gitCommonDir, target, async (ownership) => {
643
+ const existing = await loadConvergeRunState(gitCommonDir, target);
644
+ const recoveredReport = existing?.version === 3 || report.run?.gating?.bound_classification_protocol !== undefined;
645
+ const result = await processRoundReport({
646
+ gitCommonDir,
647
+ target,
655
648
  round,
649
+ findings: recoveredReport
650
+ ? [...report.findings, ...(report.belowThresholdFindings ?? [])]
651
+ : report.findings,
652
+ ...(recoveredReport ? { evidence: { reportJson } } : {}),
653
+ reportSha256,
654
+ cycleId: report.run?.cycle_id,
655
+ ownership,
656
+ ...(maxRounds !== undefined ? { maxRounds } : {}),
656
657
  ...(runId !== undefined ? { runId } : {}),
657
- payload: {
658
+ });
659
+ const classified = result.findings.map((f) => ({
660
+ identity: f.identity,
661
+ status: f.status,
662
+ gating: f.sighting?.gating ?? findingGatingReason(f.finding),
663
+ severity: f.finding.severity,
664
+ file: f.finding.file,
665
+ startLine: f.finding.startLine,
666
+ endLine: f.finding.endLine,
667
+ title: f.finding.title,
668
+ ...(f.suppressReason ? { suppressReason: f.suppressReason } : {}),
669
+ }));
670
+ const actionable = classified.filter((f) => (f.status === 'new' || f.status === 'regating') && f.gating !== 'none');
671
+ await reportConvergeEvents([
672
+ buildEvent({
673
+ kind: 'round_processed',
674
+ convergeTarget: target,
658
675
  round,
659
- round_cap: result.roundCap,
660
- counts: result.counts,
661
- actionable_gating: actionable.length,
662
- // Which identity each sighting was matched to, so the server
663
- // can apply standing verdicts to keys that moved (IO-12601).
664
- identities: roundIdentities(result.findings),
665
- },
666
- }),
667
- ...(maxRounds !== undefined
668
- ? [buildEvent({ kind: 'cap_changed', convergeTarget: opts.target, round, payload: { kind: 'rounds', to: result.roundCap } })]
669
- : []),
670
- ]);
676
+ ...(runId !== undefined ? { runId } : {}),
677
+ payload: {
678
+ round,
679
+ round_cap: result.roundCap,
680
+ counts: result.counts,
681
+ actionable_gating: result.actionableIdentities?.length ?? actionable.length,
682
+ ...(result.classificationVersion
683
+ ? {
684
+ classification_version: result.classificationVersion,
685
+ report_json_sha256: result.reportBinding.reportSha256,
686
+ ...(result.legacyPendingIdentities
687
+ ? { legacy_pending_identities: result.legacyPendingIdentities }
688
+ : {}),
689
+ }
690
+ : {}),
691
+ // Which identity each sighting was matched to, so the server
692
+ // can apply standing verdicts to keys that moved (IO-12601).
693
+ identities: roundIdentities(result.findings),
694
+ },
695
+ }),
696
+ ...(maxRounds !== undefined
697
+ ? [buildEvent({ kind: 'cap_changed', convergeTarget: target, round,
698
+ payload: { kind: 'rounds', to: result.roundCap } })]
699
+ : []),
700
+ ]);
701
+ return { result, classified, actionable };
702
+ });
671
703
  if (opts.json) {
672
704
  console.log(JSON.stringify({
673
705
  target: opts.target,
@@ -675,8 +707,11 @@ program
675
707
  roundCap: result.roundCap,
676
708
  reviewerHealth: blockingHealthJson(health),
677
709
  counts: result.counts,
678
- actionableGating: actionable.length,
710
+ actionableGating: result.actionableIdentities?.length ?? actionable.length,
679
711
  findings: classified,
712
+ ...(result.recoveryProjection
713
+ ? { recoveryProjection: result.recoveryProjection }
714
+ : {}),
680
715
  }, null, 2));
681
716
  return;
682
717
  }
@@ -684,7 +719,7 @@ program
684
719
  console.log(`Round ${round}/${result.roundCap} for ${opts.target}: ` +
685
720
  `${result.counts.new} new, ${result.counts.repeat} repeat, ` +
686
721
  `${result.counts.suppressed} suppressed, ${result.counts.regating} regating · ` +
687
- `${actionable.length} actionable gating finding(s)`);
722
+ `${result.actionableIdentities?.length ?? actionable.length} actionable gating finding(s)`);
688
723
  for (const f of actionable) {
689
724
  console.log(` [${f.status}] ${f.identity} ${f.file}:${f.startLine} — ${f.title}`);
690
725
  }
@@ -778,68 +813,74 @@ program
778
813
  if (new Set(verdicts.map(({ key }) => key)).size !== verdicts.length) {
779
814
  throw new ConvergeRunStateError('Pass each finding identity only once, as either fixed or dismissed.');
780
815
  }
781
- const { entries: updated, resolution, runId: roundRun } = await recordVerdicts({
782
- gitCommonDir: await resolveGitCommonDir(),
783
- target: opts.target,
784
- round,
785
- verdicts,
786
- runId: opts.runId,
787
- });
788
- // Feed the cross-run precision history (RCL-27) — fail-soft, the
789
- // verdicts above are already durably recorded.
790
- try {
791
- const ts = new Date().toISOString();
792
- await appendOutcomes(updated
793
- .filter((e) => e.verdict !== undefined && e.models.length > 0)
794
- .map((e) => ({
795
- ts,
796
- verdict: e.verdict,
797
- models: e.models,
798
- severity: e.verdictSeverity ?? e.severity,
799
- target: opts.target,
800
- findingKey: e.key,
801
- source: 'live',
802
- })));
803
- }
804
- catch (err) {
805
- // Advisory history; verdict recording must not fail over it —
806
- // but say so, or a broken store silently stops learning.
807
- console.warn(`Model-stats store unavailable (outcomes not recorded): ${String(err)}`);
808
- }
809
- await reportConvergeEvents([
810
- buildEvent({
811
- kind: 'verdicts_recorded',
812
- convergeTarget: opts.target,
816
+ const target = opts.target;
817
+ const gitCommonDir = await resolveGitCommonDir();
818
+ const { entries: updated, resolution, runId: roundRun } = await withNativeTarget(gitCommonDir, target, async (ownership) => {
819
+ const recorded = await recordVerdicts({
820
+ gitCommonDir,
821
+ target,
813
822
  round,
814
- ...(roundRun !== undefined ? { runId: roundRun } : {}),
815
- payload: {
816
- verdicts: updated.map((e) => ({
817
- identity_key: e.key,
818
- verdict: e.verdict,
819
- // Verdict reasons are user-authored prose: scrubbed like every other free text that leaves the machine.
820
- ...(e.verdictReason !== undefined ? { reason: scrubText(e.verdictReason, 500) } : {}),
821
- severity: e.verdictSeverity ?? e.severity,
822
- models: e.models,
823
- })),
824
- },
825
- }),
826
- ...(resolution
827
- ? [
828
- buildEvent({
829
- kind: 'resolution',
830
- convergeTarget: opts.target,
831
- round,
832
- ...(roundRun !== undefined ? { runId: roundRun } : {}),
833
- payload: {
834
- status: resolution.status,
835
- actionable: resolution.actionable,
836
- unresolved: resolution.unresolved.length,
837
- fixed_this_round: resolution.fixedThisRound,
838
- },
839
- }),
840
- ]
841
- : []),
842
- ]);
823
+ verdicts,
824
+ runId: opts.runId,
825
+ ownership,
826
+ });
827
+ // Feed the cross-run precision history (RCL-27) — fail-soft, the
828
+ // verdicts above are already durably recorded.
829
+ try {
830
+ const ts = new Date().toISOString();
831
+ await appendOutcomes(recorded.entries
832
+ .filter((e) => e.verdict !== undefined && e.models.length > 0)
833
+ .map((e) => ({
834
+ ts,
835
+ verdict: e.verdict,
836
+ models: e.models,
837
+ severity: e.verdictSeverity ?? e.severity,
838
+ target,
839
+ findingKey: e.key,
840
+ source: 'live',
841
+ })));
842
+ }
843
+ catch (err) {
844
+ // Advisory history; verdict recording must not fail over it —
845
+ // but say so, or a broken store silently stops learning.
846
+ console.warn(`Model-stats store unavailable (outcomes not recorded): ${String(err)}`);
847
+ }
848
+ await reportConvergeEvents([
849
+ buildEvent({
850
+ kind: 'verdicts_recorded',
851
+ convergeTarget: target,
852
+ round,
853
+ ...(recorded.runId !== undefined ? { runId: recorded.runId } : {}),
854
+ payload: {
855
+ verdicts: recorded.entries.map((e) => ({
856
+ identity_key: e.key,
857
+ verdict: e.verdict,
858
+ // Verdict reasons are user-authored prose: scrubbed like every other free text that leaves the machine.
859
+ ...(e.verdictReason !== undefined ? { reason: scrubText(e.verdictReason, 500) } : {}),
860
+ severity: e.verdictSeverity ?? e.severity,
861
+ models: e.models,
862
+ })),
863
+ },
864
+ }),
865
+ ...(recorded.resolution
866
+ ? [
867
+ buildEvent({
868
+ kind: 'resolution',
869
+ convergeTarget: target,
870
+ round,
871
+ ...(recorded.runId !== undefined ? { runId: recorded.runId } : {}),
872
+ payload: {
873
+ status: recorded.resolution.status,
874
+ actionable: recorded.resolution.actionable,
875
+ unresolved: recorded.resolution.unresolved.length,
876
+ fixed_this_round: recorded.resolution.fixedThisRound,
877
+ },
878
+ }),
879
+ ]
880
+ : []),
881
+ ]);
882
+ return recorded;
883
+ });
843
884
  if (opts.json) {
844
885
  console.log(JSON.stringify({
845
886
  target: opts.target,
@@ -886,19 +927,31 @@ const telemetry = program
886
927
  .description('Evidence delivery to Harness: credentials, queued deliveries and retained rejected reports');
887
928
  telemetry
888
929
  .command('recover-reviewer')
889
- .description('Deliver one exact retained terminal reviewer report without restarting reviewers or verification')
890
- .requiredOption('--target <target>', 'Exact guarded convergence target')
891
- .requiredOption('--run <id>', 'Exact retained terminal run UUID')
930
+ .description('Recover one terminal reviewer delivery without restarting reviewers')
931
+ .option('--preview', 'Write a pinned manifest without changing the server')
932
+ .option('--apply', 'Apply one exact reviewed recovery manifest')
933
+ .option('--resume', 'Resume the same journaled recovery operation')
934
+ .option('--manifest <path>', 'Immutable recovery manifest path; required for preview, apply or resume')
935
+ .option('--manifest-sha256 <sha256>', 'Required exact manifest digest for apply or resume')
936
+ .option('--target <target>', 'Exact guarded convergence target (preview or legacy no-outbox delivery)')
937
+ .option('--run <id>', 'Exact retained terminal run UUID (preview or legacy no-outbox delivery)')
892
938
  .option('--json', 'Output JSON')
893
939
  .action(async (opts) => {
894
940
  try {
895
941
  const runtime = await createTelemetryRuntime({ rclVersion: RCL_VERSION });
896
- const result = await deliverTerminalReviewerRun(runtime, { target: opts.target, runId: opts.run });
942
+ const result = await deliverTerminalReviewerRun(runtime, { preview: opts.preview, apply: opts.apply, resume: opts.resume,
943
+ manifest: opts.manifest, manifestSha256: opts.manifestSha256, target: opts.target, runId: opts.run });
897
944
  if (opts.json)
898
945
  console.log(JSON.stringify(result, null, 2));
946
+ else if (!('status' in result)) {
947
+ const legacy = result;
948
+ console.log(`${legacy.outcome.line}\nReport ${legacy.reportSha256}; reviewer artifact ${legacy.reviewerArtifactSha256}`);
949
+ }
950
+ else if (result.status === 'prepared')
951
+ console.log(`Recovery manifest ${result.manifest}\nSHA-256 ${result.manifest_sha256}\nOperation ${result.operation_id}`);
899
952
  else
900
- console.log(`${result.outcome.line}\nReport ${result.reportSha256}; reviewer artifact ${result.reviewerArtifactSha256}`);
901
- process.exitCode = result.outcome.exitCode;
953
+ console.log(`Reviewer recovery complete for ${result.run_id}\nJournal ${result.journal}\nAcknowledgement ${result.recovery_acknowledgement}`);
954
+ process.exitCode = !('status' in result) ? result.outcome.exitCode : 0;
902
955
  }
903
956
  catch (error) {
904
957
  const message = scrubText(error instanceof Error ? error.message : String(error), 300);
@@ -1054,6 +1107,22 @@ evidenceCmd
1054
1107
  .action(async (runId, opts) => {
1055
1108
  process.exitCode = await runEvidenceShow(runId ?? '', opts, evidenceDeps());
1056
1109
  });
1110
+ evidenceCmd
1111
+ .command('recover-claim')
1112
+ .description('Preview, apply or resume an evidence-backed claim correction on the same target')
1113
+ .option('--preview', 'Read immutable evidence and write an exclusive preview manifest')
1114
+ .option('--apply', 'Apply the explicitly pinned manifest')
1115
+ .option('--resume', 'Resume the same operation after independently reading accepted receipts')
1116
+ .option('--selection <path>', 'Explicit source and claim selection for preview')
1117
+ .option('--adopt-manifest <path>', 'Existing operation manifest to adopt during preview')
1118
+ .option('--adopt-manifest-sha256 <sha256>', 'Exact digest of the existing operation manifest')
1119
+ .requiredOption('--manifest <path>', 'Exclusive preview manifest or existing operation manifest')
1120
+ .option('--manifest-sha256 <sha256>', 'Exact reviewed manifest digest for apply or resume')
1121
+ .option('--json', 'Print machine-readable operation status')
1122
+ .action(async (opts) => {
1123
+ const { runPublicClaimRecovery } = await import('./evidence/recover-claim.js');
1124
+ process.exitCode = await runPublicClaimRecovery(opts, evidenceDeps());
1125
+ });
1057
1126
  evidenceCmd
1058
1127
  .command('recover-run')
1059
1128
  .description('Preview, apply or resume delivery of one immutable original asserted run; no review, events or native accounting')
@@ -1360,7 +1429,20 @@ async function prepareCouncil(spinner, opts, fallbackRoles, attestation) {
1360
1429
  const startedAt = new Date();
1361
1430
  // Validate the converge context first: a bad --round must fail before any
1362
1431
  // model time is spent, not after the council has run.
1363
- const converge = resolveConvergeContext({ convergeTarget: opts.convergeTarget, round: opts.round, attempt: opts.attempt }, reviewConvergeEnvironment(opts));
1432
+ let converge = resolveConvergeContext({ convergeTarget: opts.convergeTarget, round: opts.round, attempt: opts.attempt }, reviewConvergeEnvironment(opts));
1433
+ if (!opts.startOver && opts.guardedConverge && converge && converge.round === undefined) {
1434
+ const state = await loadConvergeRunState(await resolveGitCommonDir(), converge.target);
1435
+ converge = { ...converge, round: Math.max(0, ...(state?.rounds.map(entry => entry.round) ?? [])) + 1 };
1436
+ }
1437
+ const recoveredProduction = converge && !opts.startOver
1438
+ ? await selectCurrentRecoveredProduction(converge)
1439
+ : undefined;
1440
+ if (recoveredProduction && !opts.guardedConverge) {
1441
+ throw new ReviewLaunchRefused('recovery_guard_required', 'Recovered-v3 production requires --guarded-converge so the provider call and report admission remain bound to one durable attempt.');
1442
+ }
1443
+ if (opts.guardedConverge && converge?.attempt !== undefined && !recoveredProduction) {
1444
+ throw new ReviewLaunchRefused('preclaimed_attempt_ineligible', 'An explicit guarded attempt may only hand off the exact standalone claim for recovered-v3 production.');
1445
+ }
1364
1446
  if (!opts.exportPendingPackage)
1365
1447
  await fetchHarnessKeys(spinner, attestation?.credential);
1366
1448
  const config = await loadConfig(opts.config, undefined, { preserveDefaultRoster: opts.guardedConverge });
@@ -1554,7 +1636,10 @@ async function prepareCouncil(spinner, opts, fallbackRoles, attestation) {
1554
1636
  ...(pendingClaimPlan ? { pendingClaimPlan } : {}),
1555
1637
  explicit: explicitReviewers !== undefined,
1556
1638
  coreModels: models,
1557
- ...(converge ? { converge } : {}),
1639
+ ...(converge ? { converge: { ...converge, ...(recoveredProduction ? { recovery_source: {
1640
+ version: 1, native_sha256: recoveredProduction.nativeSha256,
1641
+ } } : {}) } } : {}),
1642
+ ...(recoveredProduction ? { recoveredProduction } : {}),
1558
1643
  startedAt,
1559
1644
  };
1560
1645
  }
@@ -1585,8 +1670,10 @@ async function discoverCycleReview(target, opts) {
1585
1670
  const active = opts.startOver ? undefined : await loadConvergeRunState(common, targetKey);
1586
1671
  if (!opts.startOver && !active?.cycle)
1587
1672
  return opts;
1588
- if (opts.attempt !== undefined || (opts.startOver && opts.round !== undefined) || opts.attest)
1673
+ const recoveredPreclaim = active?.version === 3 && opts.guardedConverge && opts.attempt !== undefined;
1674
+ if ((opts.attempt !== undefined && !recoveredPreclaim) || (opts.startOver && opts.round !== undefined) || opts.attest) {
1589
1675
  throw new Error('A fresh review assigns its own ordinals and needs an ordinary actor credential');
1676
+ }
1590
1677
  if (opts.telemetry === false)
1591
1678
  throw new Error('Fresh review cycles require Harness evidence');
1592
1679
  const paths = opts.jsonFile ? {} : await freshReviewOutputPaths(common);
@@ -1681,8 +1768,8 @@ async function runReview(target, opts) {
1681
1768
  const converge = resolveConvergeContext(opts, reviewConvergeEnvironment(opts));
1682
1769
  if (!converge)
1683
1770
  throw new ReviewLaunchRefused('target_required', 'A guarded launch requires --converge-target.');
1684
- if (converge.attempt !== undefined || opts.attest) {
1685
- throw new ReviewLaunchRefused('incompatible_launch', 'Guarded review claims its own attempt; do not preclaim, pass --attempt, or combine it with --attest.');
1771
+ if (opts.attest) {
1772
+ throw new ReviewLaunchRefused('incompatible_launch', 'Guarded review cannot be combined with --attest.');
1686
1773
  }
1687
1774
  }
1688
1775
  // Exactly one review source: a positional target, --staged, or --working-tree
@@ -2122,6 +2209,7 @@ async function executeCouncil(spinner, prepared, diff, opts, extra, preparedWork
2122
2209
  target: prepared.converge.target,
2123
2210
  startOver: opts.startOver, cycleRemote,
2124
2211
  boundFixRecovery,
2212
+ recoverySource: prepared.converge.recovery_source,
2125
2213
  headSha: extra.target.headSha ?? '',
2126
2214
  inputSha256: guardedInputSha256(guardedInput),
2127
2215
  round: prepared.converge.round,
@@ -2130,6 +2218,7 @@ async function executeCouncil(spinner, prepared, diff, opts, extra, preparedWork
2130
2218
  ...(opts.retryReport ? { legacyRetry: { reportPath: opts.retryReport, config, roster,
2131
2219
  historicalPlan: prepared.historicalRetryPlan === true } } : {}),
2132
2220
  maxAttempts: opts.maxAttempts === undefined ? undefined : Number(opts.maxAttempts),
2221
+ consumeAttempt: prepared.recoveredProduction ? prepared.converge.attempt : undefined,
2133
2222
  maxRounds: opts.maxRounds === undefined ? undefined : Number(opts.maxRounds),
2134
2223
  validate: async () => {
2135
2224
  validateLaunchProviders(roster.map(entry => entry.provider));
@@ -2147,7 +2236,7 @@ async function executeCouncil(spinner, prepared, diff, opts, extra, preparedWork
2147
2236
  baseSha: retained.baseSha } };
2148
2237
  },
2149
2238
  onClaim: async (claim) => {
2150
- if (opts.telemetry !== false)
2239
+ if (!claim.preclaimed && opts.telemetry !== false)
2151
2240
  await reportConvergeEvents([buildEvent({
2152
2241
  kind: 'attempt_claimed', convergeTarget: claim.target, attempt: claim.attempt,
2153
2242
  payload: { attempt: claim.attempt, cap: claim.cap, ...(claim.cycle ? { cycle_id: claim.cycle.id } : {}),
@@ -2444,7 +2533,10 @@ async function executeCouncil(spinner, prepared, diff, opts, extra, preparedWork
2444
2533
  startedAt: extra.pendingResume
2445
2534
  ? new Date(extra.pendingResume.launch.startedAtMs)
2446
2535
  : prepared.startedAt,
2447
- ...(prepared.converge ? { converge: { target: prepared.converge.target, round: prepared.converge.round, attempt: prepared.converge.attempt }, cycleId: prepared.converge.cycleId } : {}),
2536
+ ...(prepared.converge ? { converge: {
2537
+ target: prepared.converge.target, round: prepared.converge.round, attempt: prepared.converge.attempt,
2538
+ ...(prepared.converge.recovery_source ? { recovery_source: prepared.converge.recovery_source } : {}),
2539
+ }, cycleId: prepared.converge.cycleId } : {}),
2448
2540
  };
2449
2541
  const assemblyDependencies = {
2450
2542
  onStage: postReviewStage,
@@ -2501,7 +2593,7 @@ async function executeCouncil(spinner, prepared, diff, opts, extra, preparedWork
2501
2593
  else
2502
2594
  result = await assembleCompletedReview({
2503
2595
  chunkReviews, arrivedAsync, asyncLaunched, startTime, roleMap, config, diff,
2504
- gatingConfig: prepared.gatingConfig, modelWeights, run: runInput,
2596
+ gatingConfig: prepared.gatingConfig, recoveredProduction: prepared.recoveredProduction, modelWeights, run: runInput,
2505
2597
  }, assemblyDependencies);
2506
2598
  const { run } = result;
2507
2599
  // The same derivation converge-report and the server apply to this report.