fraim-hub 2.0.339 → 2.0.341

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.
@@ -37,6 +37,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.findAvailablePortExcluding = exports.findAvailablePort = exports.AiHubServer = exports.HubConnectorStatusStore = exports.HostConfigStore = exports.DeploymentStore = void 0;
40
+ exports.normalizeProposalDecisions = normalizeProposalDecisions;
41
+ exports.applyProposalSetToRun = applyProposalSetToRun;
40
42
  exports.selfHealBrokenManagedAgents = selfHealBrokenManagedAgents;
41
43
  exports.configureFraimForHubAgent = configureFraimForHubAgent;
42
44
  exports.installAgentAndRefreshDetection = installAgentAndRefreshDetection;
@@ -45,6 +47,9 @@ exports.__setCatalogSyncSpawnForTests = __setCatalogSyncSpawnForTests;
45
47
  exports.__setAgentInstallTimeoutMsForTests = __setAgentInstallTimeoutMsForTests;
46
48
  exports.hubRunProcess = hubRunProcess;
47
49
  exports.buildOpenFileInvocation = buildOpenFileInvocation;
50
+ exports.buildHubRecoveryContinueMessage = buildHubRecoveryContinueMessage;
51
+ exports.buildHubCompactionRecoveryContinueMessage = buildHubCompactionRecoveryContinueMessage;
52
+ const handoff_contracts_1 = require("../core/handoff-contracts");
48
53
  const express_1 = __importDefault(require("express"));
49
54
  const path_1 = __importDefault(require("path"));
50
55
  const fs_1 = __importDefault(require("fs"));
@@ -53,8 +58,10 @@ const crypto_1 = require("crypto");
53
58
  const child_process_1 = require("child_process");
54
59
  const https_1 = __importDefault(require("https"));
55
60
  const native_voice_input_1 = require("./native-voice-input");
61
+ const proposal_set_contract_1 = require("../core/proposal-set-contract");
56
62
  const types_1 = require("../first-run/types");
57
63
  const local_provider_registry_1 = require("../cli/providers/local-provider-registry");
64
+ const learning_domains_1 = require("../config/learning-domains");
58
65
  const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
59
66
  const brand_store_1 = require("../core/brand-store");
60
67
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
@@ -976,12 +983,64 @@ function normalizeDeliveryActionResult(raw) {
976
983
  ...(typeof value.domainActionCompleted === 'boolean' ? { domainActionCompleted: value.domainActionCompleted } : {}),
977
984
  };
978
985
  }
986
+ /**
987
+ * Issue #1857. The proxy already rejects a malformed set and tells the agent which
988
+ * field to fix, so this is the second gate rather than the first: it protects the
989
+ * render from a payload that reached the Hub by some other path. Same fail-closed
990
+ * rule as normalizeReviewHandoff, because the failure this design exists to remove
991
+ * was a card rendering with its body missing rather than throwing.
992
+ */
993
+ /**
994
+ * Issue #1857 R18. A relayed decision is only useful if it names the proposal it
995
+ * decided; a title cannot, because `move-artifact-between-levels` states that
996
+ * titles never select updates. Anything without a usable proposalId and a known
997
+ * action is dropped here rather than forwarded, so the job never records a
998
+ * decision against a proposal the manager did not actually resolve.
999
+ */
1000
+ function normalizeProposalDecisions(raw) {
1001
+ if (!Array.isArray(raw))
1002
+ return [];
1003
+ const out = [];
1004
+ for (const entry of raw) {
1005
+ if (!entry || typeof entry !== 'object')
1006
+ continue;
1007
+ const value = entry;
1008
+ const proposalId = typeof value.proposalId === 'string' ? value.proposalId.trim() : '';
1009
+ const action = value.action === 'approve' || value.action === 'reject' ? value.action : null;
1010
+ if (!proposalId || !action)
1011
+ continue;
1012
+ out.push({
1013
+ proposalId,
1014
+ action,
1015
+ ...(Number.isInteger(value.revision) ? { revision: value.revision } : {}),
1016
+ ...(typeof value.level === 'string' && value.level.trim() ? { level: value.level.trim() } : {}),
1017
+ ...(typeof value.reason === 'string' && value.reason.trim() ? { reason: value.reason.trim() } : {}),
1018
+ });
1019
+ }
1020
+ return out;
1021
+ }
1022
+ function normalizeProposalSet(raw) {
1023
+ if ((0, proposal_set_contract_1.validateProposalSet)(raw))
1024
+ return null;
1025
+ if (!raw || typeof raw !== 'object')
1026
+ return null;
1027
+ const value = raw;
1028
+ return {
1029
+ runScope: value.runScope,
1030
+ // Issue #1857 R3: strongest evidence first, decided here rather than trusted
1031
+ // from the agent, so the order the manager reads does not depend on how a
1032
+ // model happened to sort its output.
1033
+ proposals: (0, proposal_set_contract_1.rankProposals)(value.proposals),
1034
+ ...(value.suppressed?.length ? { suppressed: value.suppressed } : {}),
1035
+ };
1036
+ }
979
1037
  function normalizeReviewHandoff(raw) {
980
1038
  if (!raw || typeof raw !== 'object')
981
1039
  return null;
982
1040
  const value = raw;
983
1041
  if (typeof value.reviewRequired !== 'boolean')
984
1042
  return null;
1043
+ const producingJobName = typeof value.producingJobName === 'string' ? value.producingJobName.trim() : '';
985
1044
  const artifacts = Array.isArray(value.artifacts)
986
1045
  ? value.artifacts.map((artifact, index) => normalizeReviewArtifact(artifact, index)).filter((artifact) => Boolean(artifact))
987
1046
  : [];
@@ -995,6 +1054,7 @@ function normalizeReviewHandoff(raw) {
995
1054
  : '';
996
1055
  if (!value.reviewRequired) {
997
1056
  return {
1057
+ ...(producingJobName ? { producingJobName } : {}),
998
1058
  reviewRequired: false,
999
1059
  reviewTarget: rawTarget ? { type: targetType || 'none', label: targetLabel || 'Completed work' } : null,
1000
1060
  artifacts,
@@ -1010,6 +1070,7 @@ function normalizeReviewHandoff(raw) {
1010
1070
  if (artifacts.length > 0)
1011
1071
  return null;
1012
1072
  return {
1073
+ ...(producingJobName ? { producingJobName } : {}),
1013
1074
  reviewRequired: true,
1014
1075
  reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
1015
1076
  artifacts: [],
@@ -1027,6 +1088,7 @@ function normalizeReviewHandoff(raw) {
1027
1088
  const fileArtifacts = artifacts.filter((artifact) => artifact.path && !artifact.url);
1028
1089
  if (targetType === 'artifact_set' && fileArtifacts.length === artifacts.length) {
1029
1090
  return {
1091
+ ...(producingJobName ? { producingJobName } : {}),
1030
1092
  reviewRequired: true,
1031
1093
  reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}` },
1032
1094
  artifacts: fileArtifacts,
@@ -1392,6 +1454,7 @@ function buildAgentSwitchHandoffSummary(conversation) {
1392
1454
  recentEvents: events,
1393
1455
  artifacts,
1394
1456
  reviewHandoff: conversation.reviewHandoff || null,
1457
+ proposalSet: conversation.proposalSet || null,
1395
1458
  pendingDeliveryActionId: conversation.pendingDeliveryActionId ?? null,
1396
1459
  selectedReviewAction: normalizeReviewAction(conversation.selectedReviewAction) || null,
1397
1460
  completedDeliveryActionId: conversation.completedDeliveryActionId ?? null,
@@ -1615,31 +1678,35 @@ function isCodexMissingReasoningResumeText(value) {
1615
1678
  return text.includes("provided without its required 'reasoning' item") ||
1616
1679
  text.includes('provided without its required "reasoning" item');
1617
1680
  }
1618
- // Issue #1447: a dropped or non-persisted review handoff must not look
1619
- // successful to the agent. Records the failure on the existing non-droppable
1620
- // hostError channel (the same `run.lastHostError`/`run.events` pattern the
1621
- // #1264/#1265 `detectUnrecognizedReviewHandoffCall` diagnostic uses) AND
1622
- // appends a system message directly to `run.messages` — the agent- and
1623
- // manager-visible thread — so the failure is visible immediately rather than
1624
- // only after a terminal non-zero exit (`pushTerminalFailureToThread` only
1625
- // fires then). The message names the reason and this run's identity so the
1626
- // agent can recover by resubmitting.
1627
- function failLoudlyOnDroppedReviewHandoff(run, reason) {
1628
- const message = `FRAIM review handoff was not applied to run ${run.id} (job "${run.jobId || 'unknown'}"): ${reason}. The Hub review surface does not reflect this submission — resubmit seekMentoring from this run's active job/phase.`;
1629
- run.lastHostError = message;
1630
- run.events.push((0, hosts_1.createHubEvent)('stderr', boundedEventText(message)));
1631
- // This runs outside recordHostEvent's own end-of-call cap, so cap here too —
1632
- // an agent stuck retrying a mismatched-job submission must not grow this
1633
- // array past the same bound every other event source respects.
1634
- capProjectedEvents(run);
1635
- run.messages.push((0, hosts_1.createHubMessage)('system', message));
1636
- }
1637
- // Issue #1447: applies a reviewHandoff carried on a same-job seekMentoring
1638
- // signal. Pure apply/normalize — it records no diagnostics itself. Fidelity
1639
- // to the submission is checked once, generically, by
1640
- // verifyReviewHandoffReflectsSubmission below, regardless of which of this
1641
- // function's branches (or the caller's cross-job guard) is why the stored
1642
- // result might not match.
1681
+ // Review ownership follows the submitted deliverable, independently of tracker identity.
1682
+ /**
1683
+ * Issue #1857. Mirrors applyReviewHandoffToRun's rule: an unnormalized payload
1684
+ * leaves prior run state untouched rather than replacing a good batch with a bad
1685
+ * one. Does not set pauseReason; that transition belongs to classifyExit at turn
1686
+ * exit, for the same reason it does for reviewHandoff.
1687
+ */
1688
+ function applyProposalSetToRun(run, proposalSet, onFailure) {
1689
+ const normalized = normalizeProposalSet(proposalSet);
1690
+ if (!normalized) {
1691
+ // Issue #1857. Prior state is left untouched, matching applyReviewHandoffToRun:
1692
+ // a batch the Hub cannot trust must not replace one it can.
1693
+ //
1694
+ // But silence here would be the very failure this issue exists to remove. The
1695
+ // proxy contract rejects a malformed set before it ever gets this far and tells
1696
+ // the agent which field to fix, so reaching this line means the payload arrived
1697
+ // by some path that check does not cover: an older client, a replay, a direct
1698
+ // post. In that case the manager would otherwise open an empty review surface
1699
+ // with nothing anywhere saying a batch was dropped, which is exactly how the
1700
+ // `## Pending Review` parser failed for a year.
1701
+ //
1702
+ // Report it on the same channel a rejected reviewHandoff uses (#1900), naming
1703
+ // the fields, so the run carries a recoverable error the agent can act on.
1704
+ const errors = (0, proposal_set_contract_1.validateProposalSet)(proposalSet) || ['the payload could not be normalized'];
1705
+ onFailure(`the learning review batch was not applied: ${errors.join('; ')}`);
1706
+ return;
1707
+ }
1708
+ run.proposalSet = normalized;
1709
+ }
1643
1710
  function applyReviewHandoffToRun(run, reviewHandoff) {
1644
1711
  const normalizedReviewHandoff = normalizeReviewHandoff(reviewHandoff);
1645
1712
  if (!normalizedReviewHandoff) {
@@ -1663,7 +1730,7 @@ function applyReviewHandoffToRun(run, reviewHandoff) {
1663
1730
  }
1664
1731
  // Issue #1447: a mismatched `pull_request` target, isolated for readability.
1665
1732
  function pullRequestTargetMismatch(stored, submittedTarget) {
1666
- const submittedUrl = typeof submittedTarget?.url === 'string' ? submittedTarget.url : undefined;
1733
+ const submittedUrl = (0, url_safety_1.safeHttpUrl)(submittedTarget?.url) || undefined;
1667
1734
  if (stored.reviewTarget?.type !== 'pull_request' || stored.reviewTarget.url !== submittedUrl) {
1668
1735
  return `stored reviewTarget (${JSON.stringify(stored.reviewTarget)}) does not match the submitted pull_request target (url: "${submittedUrl}")`;
1669
1736
  }
@@ -1680,7 +1747,7 @@ function artifactSetMismatch(stored, submitted) {
1680
1747
  const rawArtifacts = submitted.artifacts;
1681
1748
  const rawArtifactList = Array.isArray(rawArtifacts) ? rawArtifacts : [];
1682
1749
  const submittedPaths = rawArtifactList
1683
- .map((artifact) => (artifact && typeof artifact.path === 'string' ? artifact.path : null))
1750
+ .map((artifact) => (artifact && typeof artifact.path === 'string' ? artifact.path.trim() : null))
1684
1751
  .filter((path) => Boolean(path))
1685
1752
  .sort();
1686
1753
  const storedPaths = (stored.artifacts || [])
@@ -1693,8 +1760,7 @@ function artifactSetMismatch(stored, submitted) {
1693
1760
  return null;
1694
1761
  }
1695
1762
  // Issue #1447: one generic post-condition instead of enumerating each way a
1696
- // review handoff can fail to land. Whatever the reason — cross-job
1697
- // filtering, normalization rejecting the whole payload, or a per-artifact
1763
+ // review handoff can fail to land. Whether normalization rejects the whole payload or a per-artifact
1698
1764
  // normalization failure silently dropping one item while the handoff as a
1699
1765
  // whole still normalizes — this compares what was actually submitted against
1700
1766
  // what the run now holds and fails loudly on any structural mismatch. Only
@@ -1702,33 +1768,29 @@ function artifactSetMismatch(stored, submitted) {
1702
1768
  // issue's incident describes (a stale or absent handoff masquerading as a
1703
1769
  // successful submission); a `reviewRequired: false` payload carries no such
1704
1770
  // promise.
1705
- function verifyReviewHandoffReflectsSubmission(run, submitted, crossJobName) {
1771
+ function verifyReviewHandoffReflectsSubmission(run, submitted, onFailure) {
1706
1772
  if (!submitted || typeof submitted !== 'object' || submitted.reviewRequired !== true)
1707
1773
  return;
1708
- if (crossJobName) {
1709
- failLoudlyOnDroppedReviewHandoff(run, `a review handoff was submitted under job "${crossJobName}", but this run is tracking job "${run.jobId}" — the cross-job guard did not apply it`);
1710
- return;
1711
- }
1712
1774
  const stored = run.reviewHandoff;
1713
1775
  const submittedTarget = submitted.reviewTarget;
1714
1776
  if (!stored || stored.reviewRequired !== true) {
1715
- failLoudlyOnDroppedReviewHandoff(run, `evidence.reviewHandoff failed validation and was rejected (submitted reviewTarget: ${JSON.stringify(submittedTarget) || 'none'})`);
1777
+ onFailure(`evidence.reviewHandoff failed validation and was rejected (submitted reviewTarget: ${JSON.stringify(submittedTarget) || 'none'})`);
1716
1778
  return;
1717
1779
  }
1718
- const submittedType = submittedTarget && typeof submittedTarget.type === 'string' ? submittedTarget.type : undefined;
1780
+ const submittedType = submittedTarget && typeof submittedTarget.type === 'string' ? submittedTarget.type.trim() : undefined;
1719
1781
  const mismatch = submittedType === 'pull_request'
1720
1782
  ? pullRequestTargetMismatch(stored, submittedTarget)
1721
1783
  : artifactSetMismatch(stored, submitted);
1722
1784
  if (mismatch)
1723
- failLoudlyOnDroppedReviewHandoff(run, mismatch);
1785
+ onFailure(mismatch);
1724
1786
  }
1725
1787
  // Apply a parsed seekMentoring tool-use signal from the host stream to
1726
1788
  // the run state. Returns the updated currentPhase.
1727
- function applySeekMentoringSignal(run, signal) {
1789
+ function applySeekMentoringSignal(run, signal, onReviewFailure) {
1728
1790
  // Issue #347: filter cross-job pollution. The agent may call
1729
1791
  // seekMentoring for jobs OTHER than the one this run is tracking
1730
1792
  // (e.g., consulting `organizational-learning-synthesis` mid-run).
1731
- // Only apply signals whose job identity matches this run's.
1793
+ // Only tracker metadata requires matching job identity.
1732
1794
  // Issue #732: match on `jobName` — the stable job-name slug that equals
1733
1795
  // run.jobId — NOT `jobId`. get_fraim_job mints a fresh randomUUID as the
1734
1796
  // job id on every call and the agent echoes that UUID into
@@ -1739,33 +1801,30 @@ function applySeekMentoringSignal(run, signal) {
1739
1801
  const targetJobId = run.jobId;
1740
1802
  const callJobName = signal.jobName;
1741
1803
  const isCrossJob = Boolean(callJobName && targetJobId && callJobName !== targetJobId);
1742
- // Issue #1447: a genuine reviewHandoff must not vanish without a trace,
1743
- // whether the cross-job guard just below is why it was never applied, or
1744
- // applyReviewHandoffToRun rejected/truncated it. Apply first (skipped
1745
- // entirely for a foreign job, since that guard is still correct — a
1746
- // foreign job's data must never overwrite this run's tracked state), then
1747
- // verify unconditionally that the result actually reflects the submission.
1748
- if (signal.reviewHandoff) {
1749
- if (!isCrossJob)
1750
- applyReviewHandoffToRun(run, signal.reviewHandoff);
1751
- verifyReviewHandoffReflectsSubmission(run, signal.reviewHandoff, isCrossJob ? callJobName : null);
1752
- }
1753
- else if (!isCrossJob) {
1754
- // Issue #1501: No reviewHandoff in this same-job call. Clear any prior reviewHandoff so
1755
- // run.reviewHandoff always reflects the most recent seekMentoring intent,
1756
- // not a stale flag from an earlier submit phase.
1757
- run.reviewHandoff = null;
1758
- }
1759
- if (isCrossJob)
1804
+ // Issue #1857: the learning review batch, applied on the same terms as the
1805
+ // handoff above it. Placed before the early returns, because a review-pending
1806
+ // call that also reports a handoff failure still emitted a real batch, and a
1807
+ // dropped set is invisible: the surface renders nothing and says nothing.
1808
+ if (signal.proposalSet)
1809
+ applyProposalSetToRun(run, signal.proposalSet, onReviewFailure);
1810
+ if (signal.reviewHandoffError) {
1811
+ onReviewFailure(signal.reviewHandoffError);
1760
1812
  return;
1761
- // Issue #848: capture the issue number the call carries so a launched follow-on
1762
- // run can reference the originating issue, and persist any next-job recommendations.
1763
- if (signal.issueNumber)
1764
- run.issueNumber = signal.issueNumber;
1765
- if (signal.nextJobRecommendations) {
1766
- const normalized = normalizeNextJobRecommendations(signal.nextJobRecommendations);
1767
- if (normalized)
1768
- run.nextJobRecommendations = normalized;
1813
+ }
1814
+ else if (signal.reviewHandoff) {
1815
+ const error = (0, handoff_contracts_1.reviewHandoffNormalizationError)(signal.reviewHandoff);
1816
+ if (error) {
1817
+ onReviewFailure(error);
1818
+ return;
1819
+ }
1820
+ else {
1821
+ applyReviewHandoffToRun(run, { ...signal.reviewHandoff, producingJobName: callJobName });
1822
+ verifyReviewHandoffReflectsSubmission(run, signal.reviewHandoff, onReviewFailure);
1823
+ }
1824
+ }
1825
+ else {
1826
+ // The most recent mentoring intent owns review lifetime across all jobs.
1827
+ run.reviewHandoff = null;
1769
1828
  }
1770
1829
  if (signal.deliveryActionId !== undefined) {
1771
1830
  run.pendingDeliveryActionId = signal.deliveryActionId;
@@ -1785,6 +1844,17 @@ function applySeekMentoringSignal(run, signal) {
1785
1844
  run.pendingDeliveryActionId = null;
1786
1845
  }
1787
1846
  }
1847
+ // Delivery actions belong to the review, while tracker and issue metadata
1848
+ // remain scoped to the run's original job (including colliding phase names).
1849
+ if (isCrossJob)
1850
+ return;
1851
+ if (signal.issueNumber)
1852
+ run.issueNumber = signal.issueNumber;
1853
+ if (signal.nextJobRecommendations) {
1854
+ const normalized = normalizeNextJobRecommendations(signal.nextJobRecommendations);
1855
+ if (normalized)
1856
+ run.nextJobRecommendations = normalized;
1857
+ }
1788
1858
  if (signal.delegationLedger &&
1789
1859
  run.jobId === 'fully-delegate' &&
1790
1860
  signal.phaseStatus === 'complete' &&
@@ -2468,10 +2538,11 @@ function buildHubRecoveryContinueMessage(run, exitCode, attempt) {
2468
2538
  'The FRAIM Hub is automatically recovering this interrupted run.',
2469
2539
  'This is not a manager-authored instruction. Do not say the manager asked you to continue.',
2470
2540
  `Run id: ${run.id}`,
2471
- `Session id: ${run.sessionId || 'unknown'}`,
2541
+ `CLI host session id: ${run.sessionId || 'unknown'}`,
2472
2542
  `Recovery attempt: ${attempt}/${MAX_RECOVERY_ATTEMPTS}`,
2473
2543
  `Prior exit code: ${exitCode ?? 'unknown'}`,
2474
2544
  'Resume only if the tracked FRAIM phase is non-terminal and not waiting for human review or approval.',
2545
+ (0, manager_turns_1.buildHubPhaseRecoveryInstructions)(run.id),
2475
2546
  ].join('\n');
2476
2547
  }
2477
2548
  // Issue #1221: the terminal exit/recovery system events previously said only
@@ -2515,10 +2586,11 @@ function buildHubCompactionRecoveryContinueMessage(run, exitCode, attempt) {
2515
2586
  'The host compacted context or restarted its internal runtime while this run was still active.',
2516
2587
  'This is not a manager-authored instruction. Do not say the manager asked you to continue.',
2517
2588
  `Run id: ${run.id}`,
2518
- `Session id: ${run.sessionId || 'unknown'}`,
2589
+ `CLI host session id: ${run.sessionId || 'unknown'}`,
2519
2590
  `Compaction recovery attempt: ${attempt}/${MAX_RECOVERY_ATTEMPTS}`,
2520
2591
  `Prior exit code: ${exitCode ?? 'unknown'}`,
2521
2592
  'Resume only if the tracked FRAIM phase is non-terminal and not waiting for human review or approval.',
2593
+ (0, manager_turns_1.buildHubPhaseRecoveryInstructions)(run.id),
2522
2594
  ].join('\n');
2523
2595
  }
2524
2596
  function createHubCompactionRecoveryEvent(run, exitCode, attempt) {
@@ -3515,6 +3587,10 @@ class AiHubServer {
3515
3587
  project.droppedRecordedReason = this.droppedRecordedReason;
3516
3588
  }
3517
3589
  const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath, FULL_JOB_CATALOG_OPTIONS);
3590
+ // Issue #1891: build the custom-employee index once (O(1) per job, one disk walk total)
3591
+ // so custom ownership wins over catalog assignment. Follows the same pattern as
3592
+ // buildCustomJobOwnerIndex documented in the Issue #1090 comment above.
3593
+ const customJobOwners = buildCustomJobOwnerIndex(normalizedProjectPath ?? '');
3518
3594
  // Issue #566 (R7): jobs already carry `personalized` from catalog discovery
3519
3595
  // (true for the fraim/personalized-employee layer). The Hub renders a plain
3520
3596
  // "Personalized" marking from that flag — no author/attribution is tracked.
@@ -3522,7 +3598,7 @@ class AiHubServer {
3522
3598
  .filter((job) => !FRAIM_INTERNAL_JOB_IDS.has(job.id))
3523
3599
  .map((job) => ({
3524
3600
  ...job,
3525
- requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
3601
+ requiredPersonaKey: customJobOwners.get(job.id) ?? getProtectedPersonaForHubJob(job.id),
3526
3602
  }));
3527
3603
  // Issue #1247: expose internal jobs in a separate bootstrap field so next-job
3528
3604
  // recommendation chips can resolve and launch them. They are intentionally
@@ -3711,6 +3787,9 @@ class AiHubServer {
3711
3787
  env: {
3712
3788
  ...(launchContext.env || {}),
3713
3789
  FRAIM_HUB_RUN_ID: runId,
3790
+ // Each host turn is a new phase-arrival scope; MCP restarts within
3791
+ // that host inherit the same token and retain their valid starts.
3792
+ FRAIM_PHASE_RESUME_ID: (0, crypto_1.randomUUID)(),
3714
3793
  FRAIM_BROWSER_CONVERSATION_ID: conversationId,
3715
3794
  ...(page ? {
3716
3795
  FRAIM_BROWSER_PAGE_TARGET_ID: page.targetId,
@@ -4059,6 +4138,7 @@ class AiHubServer {
4059
4138
  eventLogRefs: run.eventLogRefs || [],
4060
4139
  artifacts: run.artifacts || [],
4061
4140
  reviewHandoff: run.reviewHandoff || null,
4141
+ proposalSet: run.proposalSet || null,
4062
4142
  pendingDeliveryActionId: run.pendingDeliveryActionId ?? null,
4063
4143
  selectedReviewAction: run.selectedReviewAction || null,
4064
4144
  completedDeliveryActionId: run.completedDeliveryActionId ?? null,
@@ -4444,6 +4524,7 @@ class AiHubServer {
4444
4524
  runDiscriminant: persistedRun?.runDiscriminant || undefined,
4445
4525
  artifacts: Array.isArray(conversation.artifacts) ? conversation.artifacts : [],
4446
4526
  reviewHandoff: conversation.reviewHandoff || null,
4527
+ proposalSet: conversation.proposalSet || null,
4447
4528
  pendingDeliveryActionId: conversation.pendingDeliveryActionId ?? null,
4448
4529
  selectedReviewAction: normalizeReviewAction(conversation.selectedReviewAction) || null,
4449
4530
  completedDeliveryActionId: conversation.completedDeliveryActionId ?? null,
@@ -5128,7 +5209,9 @@ class AiHubServer {
5128
5209
  // jobId (resolveHubJob would never match a UUID, leaving a freeform run
5129
5210
  // unpromoted and then dropping its phase signals).
5130
5211
  this.maybePromoteFreeformRunToJob(run, signal.jobName || signal.jobId);
5131
- applySeekMentoringSignal(run, signal);
5212
+ applySeekMentoringSignal(run, signal, (reason) => {
5213
+ this.recordHostEvent(run, run.hostId, { hostError: 'Review handoff was not applied: ' + reason }, 'stderr');
5214
+ });
5132
5215
  if (signal.phaseId === 'retrospective' && signal.phaseStatus === 'complete') {
5133
5216
  void this.releaseBrowserConversationPage(run.conversationId || run.id);
5134
5217
  }
@@ -5645,12 +5728,39 @@ class AiHubServer {
5645
5728
  const projectPath = resolveProjectPath(req);
5646
5729
  const entries = (0, learning_context_builder_1.readPreservedLearnings)(projectPath, userEmail, scope, level);
5647
5730
  const threshold = (0, learning_context_builder_1.getScoreThreshold)(projectPath);
5648
- return res.json({ scope, level, entries, threshold });
5731
+ // Issue #1755 R9: the domain catalogue travels with the corpus so the library's
5732
+ // domain filter and the re-scope selector offer every valid domain, not only the
5733
+ // ones that happen to have an entry already.
5734
+ return res.json({ scope, level, entries, threshold, domains: ['global', ...learning_domains_1.LEARNING_DOMAINS] });
5649
5735
  }
5650
5736
  catch (error) {
5651
5737
  return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not read learnings.' });
5652
5738
  }
5653
5739
  });
5740
+ // Issue #1857 R19: what the manager said no to, and why.
5741
+ //
5742
+ // A rejection is one of the three lifecycle states, and until now it was the
5743
+ // only one with no way to see it short of opening a markdown file by hand:
5744
+ // `readPreservedLearnings` reads family files, and a rejected claim is
5745
+ // deliberately not in one. A manager who cannot review their own refusals
5746
+ // cannot tell a claim that keeps coming back from one they already settled.
5747
+ //
5748
+ // Manager-only by construction. This is an HTTP read the Hub makes; nothing
5749
+ // in the employee's learning context calls `readRejectedProposals`, and
5750
+ // `test-1857-context-boundary.ts` pins that it never will. Feeding refused
5751
+ // claims back to a job is the influence R10 exists to prevent.
5752
+ this.app.get('/api/ai-hub/rejections', async (req, res) => {
5753
+ try {
5754
+ const userEmail = await this.resolveLearningIdentity('manager');
5755
+ if (!userEmail)
5756
+ return res.json({ entries: [] });
5757
+ const projectPath = resolveProjectPath(req);
5758
+ return res.json({ entries: (0, learning_context_builder_1.readRejectedProposals)(projectPath, userEmail) });
5759
+ }
5760
+ catch (error) {
5761
+ return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not read rejections.' });
5762
+ }
5763
+ });
5654
5764
  // #533 §3: add / edit / delete a single learning entry — writes the real file
5655
5765
  // at the section's level (machine for the Manager/Company tabs, project for the
5656
5766
  // project workspace). The action targets exactly the card's file.
@@ -5664,7 +5774,16 @@ class AiHubServer {
5664
5774
  if (!b.category || !VALID_CATEGORIES.includes(b.category))
5665
5775
  return res.status(400).json({ error: `category must be one of: ${VALID_CATEGORIES.join(', ')}` });
5666
5776
  const level = (b.level && VALID_LEVELS.includes(b.level)) ? b.level : 'machine';
5667
- const ref = { scope: b.scope, level, category: b.category };
5777
+ // Issue #1755: `domain` names the file the entry lives in today, `targetDomain`
5778
+ // the file it should live in after the write. Without the first, every write
5779
+ // resolved to the undecorated global file and a domain-scoped entry could not be
5780
+ // edited or deleted at all.
5781
+ const ref = {
5782
+ scope: b.scope,
5783
+ level,
5784
+ category: b.category,
5785
+ domain: (0, learning_context_builder_1.normalizeLearningDomain)(b.domain),
5786
+ };
5668
5787
  // Issue #750: writes to a personal-scope file need a resolved identity —
5669
5788
  // fail explicitly rather than writing to a guessed or empty-string file.
5670
5789
  const userEmail = await this.resolveLearningIdentity(b.scope);
@@ -5677,6 +5796,7 @@ class AiHubServer {
5677
5796
  severity: b.severity,
5678
5797
  title: b.title,
5679
5798
  body: b.body,
5799
+ targetDomain: b.targetDomain === undefined ? undefined : (0, learning_context_builder_1.normalizeLearningDomain)(b.targetDomain),
5680
5800
  });
5681
5801
  return res.json({ ok: true, path: result.path });
5682
5802
  }
@@ -6018,6 +6138,7 @@ class AiHubServer {
6018
6138
  personaKey: resolveConversationPersonaKey(conversation, bucketKey),
6019
6139
  artifacts: Array.isArray(conversation.artifacts) ? conversation.artifacts : [],
6020
6140
  reviewHandoff: conversation.reviewHandoff || null,
6141
+ proposalSet: conversation.proposalSet || null,
6021
6142
  delegation: conversation.delegation || null,
6022
6143
  delegationTaskId: conversation.delegationTaskId || null,
6023
6144
  nextJobRecommendations: null,
@@ -7218,6 +7339,25 @@ class AiHubServer {
7218
7339
  // Issue #1651 R7/R12: resolve staged attachments into persisted metadata
7219
7340
  // (attached to the manager's message) and a host-bound reference suffix.
7220
7341
  const { attachments, referenceSuffix: attachmentSuffix } = this.resolveMessageAttachments((0, conversation_store_1.conversationScopeKey)(run.scope, run.projectPath), run.conversationId || run.id, req.body.attachmentIds);
7342
+ // Issue #1857 R18. The manager's prose names each proposal by title, and
7343
+ // `move-artifact-between-levels` states that titles never select updates,
7344
+ // so the job would have to guess which revision a sentence referred to.
7345
+ // This carries the decisions as data alongside that prose, appended the
7346
+ // same way an attachment reference is: the manager still reads their own
7347
+ // words in the thread, and the job records against the id and revision.
7348
+ const proposalDecisions = normalizeProposalDecisions(req.body.proposalDecisions);
7349
+ const FENCE = '\u0060'.repeat(3);
7350
+ // The reason is the manager's own words, so it can contain anything a
7351
+ // person types, including a code fence. JSON escapes quotes and newlines
7352
+ // but not backticks, and a stray fence here would close the block early
7353
+ // and leave the rest of the decisions reading as prose. Escaping them
7354
+ // keeps the block one parseable unit; the agent unescapes nothing, because
7355
+ // a backtick inside a reason carries no meaning it needs.
7356
+ const decisionJson = JSON.stringify(proposalDecisions).split('\u0060').join('\\u0060');
7357
+ const decisionSuffix = proposalDecisions.length
7358
+ ? '\n\n<!-- fraim-proposal-decisions -->\n' + FENCE + 'json\n'
7359
+ + decisionJson + '\n' + FENCE
7360
+ : '';
7221
7361
  // No resumable session — start fresh using a handoff prompt so the agent
7222
7362
  // picks up from the preserved conversation state + manager coaching.
7223
7363
  const reviewApprovalSystemEventText = buildReviewApprovalSystemEventText(prepared.display || message);
@@ -7266,8 +7406,8 @@ class AiHubServer {
7266
7406
  this.persistRunConversation(startedFresh, startedFresh.conversationId || startedFresh.id);
7267
7407
  this.runRegistry.create(run, {});
7268
7408
  const freshLaunch = this.resolveLaunchAgent(run.configuredAgentId, run.hostId);
7269
- this.recordHubGeneratedEvent(run, 'agent_input', freshPayload.message + attachmentSuffix);
7270
- const freshChild = this.hostRuntime.startRun(run.hostId, run.projectPath, freshPayload.message + attachmentSuffix, {
7409
+ this.recordHubGeneratedEvent(run, 'agent_input', freshPayload.message + attachmentSuffix + decisionSuffix);
7410
+ const freshChild = this.hostRuntime.startRun(run.hostId, run.projectPath, freshPayload.message + attachmentSuffix + decisionSuffix, {
7271
7411
  onEvent: (event, channel) => {
7272
7412
  this.runRegistry.update(run.id, (current) => {
7273
7413
  if (event.sessionId) {
@@ -7353,8 +7493,8 @@ class AiHubServer {
7353
7493
  // them, so any other continueRun call site delivering the turn (or a
7354
7494
  // turn that exits before its first event) left the badge stuck
7355
7495
  // forever. See clearStaleDeliveryStatuses and handleRunExit.
7356
- this.recordHubGeneratedEvent(run, 'agent_input', message + attachmentSuffix);
7357
- const child = this.hostRuntime.continueRun(run.hostId, run.projectPath, run.sessionId, message + attachmentSuffix, {
7496
+ this.recordHubGeneratedEvent(run, 'agent_input', message + attachmentSuffix + decisionSuffix);
7497
+ const child = this.hostRuntime.continueRun(run.hostId, run.projectPath, run.sessionId, message + attachmentSuffix + decisionSuffix, {
7358
7498
  onEvent: (event, channel) => {
7359
7499
  if (run.hostId === 'codex' && reviewApprovalSystemEventText) {
7360
7500
  codexMissingReasoningResumeError = codexMissingReasoningResumeError ||