fraim 2.0.360-next.2 → 2.0.360

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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AIMentor = void 0;
4
+ const crypto_1 = require("crypto");
4
5
  const include_resolver_1 = require("./utils/include-resolver");
5
6
  const resolve_phase_edge_1 = require("./resolve-phase-edge");
6
7
  class AIMentor {
@@ -31,18 +32,53 @@ class AIMentor {
31
32
  throw new Error(`Phase "${args.currentPhase}" not found in job "${args.jobName}".`);
32
33
  }
33
34
  }
35
+ let response;
34
36
  if (args.status === 'starting') {
35
- return await this.generateStartingMessage(workflow, args.currentPhase, args.skipIncludes, args.requestInstructions);
37
+ response = await this.generateStartingMessage(workflow, args.currentPhase, args.skipIncludes, args.requestInstructions);
36
38
  }
37
39
  else if (args.status === 'complete') {
38
- return await this.generateCompletionMessage(workflow, args.currentPhase, args.findings, args.evidence, args.skipIncludes);
40
+ response = await this.generateCompletionMessage(workflow, args.currentPhase, args.findings, args.evidence, args.skipIncludes);
39
41
  }
40
42
  else {
41
43
  // Issue #1123: findings/evidence must reach the failure path too. The
42
44
  // MCP layer has always forwarded them for every status; only this hop
43
45
  // dropped them, which is why a failure edge could not be discriminated.
44
- return await this.generateHelpMessage(workflow, args.currentPhase, args.status, args.skipIncludes, args.findings, args.evidence);
46
+ response = await this.generateHelpMessage(workflow, args.currentPhase, args.status, args.skipIncludes, args.findings, args.evidence);
45
47
  }
48
+ response.workflowDigest = (0, crypto_1.createHash)('sha256').update(JSON.stringify({ metadata: workflow.metadata, phases: Array.from(workflow.phases.entries()), overview: workflow.overview })).digest('hex');
49
+ const phases = workflow.metadata.phases;
50
+ const member = (0, resolve_phase_edge_1.findRecurrenceForMember)(phases, args.currentPhase);
51
+ const definition = phases?.[args.currentPhase];
52
+ const edge = args.status === 'complete' && !(0, resolve_phase_edge_1.isRecurrencePhase)(definition) ? definition?.onSuccess
53
+ : args.status === 'failure' ? member?.node[member.role].onFailure ?? (!(0, resolve_phase_edge_1.isRecurrencePhase)(definition) ? definition?.onFailure : undefined) : undefined;
54
+ if (edge != null && !(0, resolve_phase_edge_1.resolvePhaseEdge)(edge, (0, resolve_phase_edge_1.resolveDiscriminant)(args.findings, args.evidence))) {
55
+ response.outcome = { kind: 'invalid', reason: 'The workflow edge has no matching valid target.' };
56
+ }
57
+ else if (response.nextPhase === resolve_phase_edge_1.WAITING_FOR_NEXT_INVOCATION) {
58
+ response.outcome = { kind: 'recurring_wait' };
59
+ }
60
+ else if (response.nextPhase === resolve_phase_edge_1.AWAITING_RECURRING_REVIEW) {
61
+ response.outcome = { kind: 'review_wait' };
62
+ }
63
+ else if (response.nextPhase && !workflow.isSimple && !phases?.[response.nextPhase]
64
+ && !workflow.phases.has(response.nextPhase) && !(0, resolve_phase_edge_1.findRecurrenceForMember)(phases, response.nextPhase)) {
65
+ response.outcome = { kind: 'invalid', reason: `Workflow target ${response.nextPhase} does not exist.` };
66
+ }
67
+ else if (response.nextPhase) {
68
+ response.outcome = { kind: 'next', phaseId: response.nextPhase };
69
+ }
70
+ else if (args.status === 'complete' && (workflow.isSimple || definition || member?.role === 'done')) {
71
+ response.outcome = { kind: 'terminal' };
72
+ }
73
+ else if (workflow.isSimple) {
74
+ response.outcome = { kind: 'next', phaseId: args.currentPhase };
75
+ }
76
+ else {
77
+ response.outcome = { kind: 'invalid', reason: 'Only a declared workflow leaf may terminate execution.' };
78
+ }
79
+ if (response.outcome.kind === 'invalid')
80
+ response.message = `Phase transition rejected: ${response.outcome.reason}`;
81
+ return response;
46
82
  }
47
83
  async getOrLoadJob(jobType) {
48
84
  if (this.jobCache.has(jobType)) {
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * - evidence.reviewHandoff (job's submission phase, status === "complete";
9
9
  * see isSubmitPhase for how that phase is found)
10
- * - evidence.nextJobRecommendations (retrospective phase completion)
10
+ * - evidence.nextJobRecommendations (optional at submission, or completion for jobs without submission)
11
11
  * - evidence.delegationLedger (create-delegation-graph phase)
12
12
  *
13
13
  * Lives in src/core so both the local MCP proxy and evals can import the same
@@ -507,6 +507,28 @@ function validateNextJobRecommendations(value) {
507
507
  if (typeof rec.contextSummary === 'string' && rec.contextSummary.length > 300) {
508
508
  errors.push(`evidence.nextJobRecommendations[${i}].contextSummary must be 300 characters or fewer`);
509
509
  }
510
+ if (rec.artifactIds !== undefined && (!Array.isArray(rec.artifactIds)
511
+ || rec.artifactIds.some(id => typeof id !== 'string' || !id.trim()))) {
512
+ errors.push(`evidence.nextJobRecommendations[${i}].artifactIds must be an array of non-empty artifact references`);
513
+ }
514
+ if (rec.prerequisites !== undefined) {
515
+ const kinds = new Set(['approved_artifact', 'delivered_artifact', 'selected_action_success', 'required_child_success', 'external_result']);
516
+ if (!Array.isArray(rec.prerequisites)) {
517
+ errors.push(`evidence.nextJobRecommendations[${i}].prerequisites must be an array`);
518
+ }
519
+ else {
520
+ rec.prerequisites.forEach((value, index) => {
521
+ const prerequisite = value && typeof value === 'object' && !Array.isArray(value) ? value : null;
522
+ if (!prerequisite || typeof prerequisite.kind !== 'string' || !kinds.has(prerequisite.kind)
523
+ || typeof prerequisite.reference !== 'string' || !prerequisite.reference.trim()) {
524
+ errors.push(`evidence.nextJobRecommendations[${i}].prerequisites[${index}] requires a supported kind and non-empty reference`);
525
+ }
526
+ });
527
+ }
528
+ }
529
+ if (['ready', 'readiness', 'readinessReason', 'candidateId', 'revisionId', 'sourceRunId', 'sourceConversationId', 'startedRunId'].some(key => key in rec)) {
530
+ errors.push(`evidence.nextJobRecommendations[${i}] must not claim readiness or server-owned revision/source/start identities; provide prerequisites and artifactIds instead`);
531
+ }
510
532
  });
511
533
  return errors.length > 0 ? errors : null;
512
534
  }
@@ -657,7 +679,11 @@ evidence: {
657
679
  jobId: "<job-slug>",
658
680
  label: "<human-readable label>",
659
681
  reason: "<optional: why this job is recommended>",
660
- contextSummary: "<optional: one sentence context for the next agent>"
682
+ contextSummary: "<optional: one sentence context for the next agent>",
683
+ artifactIds: ["<optional: reference from the reviewed artifact manifest>"],
684
+ prerequisites: [{ kind: "approved_artifact", reference: "<artifact reference>" }]
685
+ // Other kinds: delivered_artifact, selected_action_success, required_child_success, external_result.
686
+ // Omit optional dependencies when none apply. Never emit readiness or server-owned identities.
661
687
  }
662
688
  // 0–3 entries; an empty array [] is valid
663
689
  ]
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.reviewDigest = reviewDigest;
4
+ exports.createReviewLifecycle = createReviewLifecycle;
5
+ exports.activateReviewJob = activateReviewJob;
6
+ exports.activeReview = activeReview;
7
+ exports.currentReviewTerminalReceipt = currentReviewTerminalReceipt;
8
+ exports.obligationDigest = obligationDigest;
9
+ exports.submitReviewRevision = submitReviewRevision;
10
+ exports.decideReviewRevision = decideReviewRevision;
11
+ exports.acceptReviewReceipt = acceptReviewReceipt;
12
+ exports.unsupportedReviewDeliveryActions = unsupportedReviewDeliveryActions;
13
+ exports.projectReviewLifecycle = projectReviewLifecycle;
14
+ exports.normalizeReviewCandidates = normalizeReviewCandidates;
15
+ exports.candidateReadiness = candidateReadiness;
16
+ const crypto_1 = require("crypto");
17
+ function reviewDigest(value) {
18
+ const canonical = (v) => Array.isArray(v) ? v.map(canonical)
19
+ : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).filter(([, x]) => x !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([k, x]) => [k, canonical(x)])) : v;
20
+ return (0, crypto_1.createHash)('sha256').update(JSON.stringify(canonical(value))).digest('hex');
21
+ }
22
+ function createReviewLifecycle(source) {
23
+ return { version: 1, sequence: 0, source, jobs: {}, reviewsByJobInstance: {}, operations: {}, consumedReceipts: [] };
24
+ }
25
+ function activateReviewJob(state, job) {
26
+ state.jobs[job.jobInstanceId] = { ...job, ...(state.jobs[job.jobInstanceId]?.manualClosure ? { manualClosure: state.jobs[job.jobInstanceId].manualClosure } : {}) };
27
+ state.activeJob = state.jobs[job.jobInstanceId];
28
+ }
29
+ function activeReview(state) {
30
+ return state?.activeJob ? state.reviewsByJobInstance[state.activeJob.jobInstanceId] : undefined;
31
+ }
32
+ function currentReviewTerminalReceipt(state) {
33
+ const job = state?.activeJob;
34
+ const review = activeReview(state);
35
+ const receipt = review?.terminalReceipt;
36
+ return receipt && job && receipt.sourceRunId === state?.source.runId && receipt.jobInstanceId === job.jobInstanceId
37
+ && receipt.jobName === job.jobName && receipt.workflowDigest === review?.workflowDigest
38
+ && receipt.sessionId === job.sessionId && receipt.executionEpoch === job.executionEpoch
39
+ && receipt.revisionId === review?.revisionId && receipt.obligationSetDigest === obligationDigest(review.obligations)
40
+ ? receipt : undefined;
41
+ }
42
+ function obligationDigest(obligations) {
43
+ // Verification outcomes change as delivery completes; membership and identity do not.
44
+ return reviewDigest(obligations.map(({ id, kind, subject, issueNumber, childRunId, childJobName, childJobInstanceId, childRevisionId }) => ({ id, kind, subject, issueNumber, childRunId, childJobName, childJobInstanceId, childRevisionId })).sort((a, b) => a.id.localeCompare(b.id)));
45
+ }
46
+ function submitReviewRevision(state, input, resubmission = false) {
47
+ const manifest = [...input.manifest].sort((a, b) => a.id.localeCompare(b.id));
48
+ const target = input.handoff.reviewTarget;
49
+ const revisionId = reviewDigest({ source: state.source, owner: input.jobInstanceId, jobName: input.jobName, workflowDigest: input.workflowDigest,
50
+ target: target ? { type: target.type, url: target.url || undefined } : undefined, manifest });
51
+ const old = state.reviewsByJobInstance[input.jobInstanceId];
52
+ const actionSetDigest = reviewDigest(input.handoff.reviewActions || []);
53
+ const candidates = input.candidates.map(c => {
54
+ const candidateId = reviewDigest({ revisionId, ...candidateIdentity(c) });
55
+ return { ...c, candidateId, revisionId, sourceRunId: state.source.runId, sourceConversationId: state.source.conversationId };
56
+ }).filter((c, index, all) => all.findIndex(other => other.candidateId === c.candidateId) === index).slice(0, 3);
57
+ if (old?.revisionId === revisionId && old.actionSetDigest === actionSetDigest) {
58
+ if (resubmission && old.decision?.status === 'changes_requested') {
59
+ old.history.push({ revisionId: old.revisionId, decision: old.decision, manifest: old.manifest, handoff: old.handoff });
60
+ delete old.decision;
61
+ delete old.terminalReceipt;
62
+ old.obligations = [];
63
+ }
64
+ old.handoff = input.handoff;
65
+ old.candidates = candidates.map(candidate => ({ ...candidate,
66
+ ...(old.candidates.find(prior => prior.candidateId === candidate.candidateId)?.startedRunId
67
+ ? { startedRunId: old.candidates.find(prior => prior.candidateId === candidate.candidateId).startedRunId } : {}),
68
+ }));
69
+ return old;
70
+ }
71
+ const review = {
72
+ jobInstanceId: input.jobInstanceId, jobName: input.jobName, workflowDigest: input.workflowDigest,
73
+ handoff: input.handoff, manifest, revisionId, actionSetDigest, candidates, obligations: [],
74
+ history: old ? [...old.history, { revisionId: old.revisionId, decision: old.decision, terminalReceipt: old.terminalReceipt, manifest: old.manifest, handoff: old.handoff }] : [],
75
+ };
76
+ state.reviewsByJobInstance[input.jobInstanceId] = review;
77
+ return review;
78
+ }
79
+ function decideReviewRevision(state, input, obligations = []) {
80
+ const review = activeReview(state);
81
+ if (state.activeJob?.manualClosure)
82
+ throw new Error('This job was closed manually. Start a new job to continue work.');
83
+ if (!review || review.revisionId !== input.revisionId || review.actionSetDigest !== input.actionSetDigest)
84
+ throw new Error('The reviewed result changed. Refresh before deciding.');
85
+ const action = review.handoff.reviewActions?.find(a => a.id === input.actionId);
86
+ if (!action || !['approve', 'approve_delivery', 'request_changes'].includes(action.kind) || !input.decisionId)
87
+ throw new Error('This review action is not available.');
88
+ const existingOperation = state.operations[input.decisionId];
89
+ if (existingOperation && (existingOperation.payloadDigest !== reviewDigest(input) || existingOperation.jobInstanceId !== review.jobInstanceId))
90
+ throw new Error('This decision identifier is already bound to another decision.');
91
+ if (existingOperation)
92
+ return { duplicate: true, review, operation: existingOperation };
93
+ if (review.decision) {
94
+ if (review.decision.decisionId === input.decisionId && review.decision.actionId === input.actionId)
95
+ return { duplicate: true, review, operation: state.operations[input.decisionId] };
96
+ throw new Error('This revision already has a review decision.');
97
+ }
98
+ review.decision = { ...input, status: action.kind === 'request_changes' ? 'changes_requested' : 'approved', at: new Date().toISOString(), action };
99
+ review.obligations = obligations;
100
+ delete review.terminalReceipt;
101
+ const operation = { id: input.decisionId, kind: 'approval', payloadDigest: reviewDigest(input), state: 'reserved', jobInstanceId: review.jobInstanceId, revisionId: review.revisionId };
102
+ state.operations[operation.id] = operation;
103
+ return { duplicate: false, review, operation };
104
+ }
105
+ function acceptReviewReceipt(state, receipt) {
106
+ if (state.consumedReceipts.includes(receipt.acceptanceId))
107
+ return false;
108
+ const review = state.reviewsByJobInstance[receipt.jobInstanceId];
109
+ const job = state.jobs[receipt.jobInstanceId];
110
+ if (!review || !job || receipt.sourceRunId !== state.source.runId || receipt.jobName !== review.jobName
111
+ || receipt.sessionId !== job.sessionId || receipt.executionEpoch !== job.executionEpoch
112
+ || receipt.workflowDigest !== review.workflowDigest || receipt.revisionId !== review.revisionId)
113
+ return false;
114
+ if (receipt.requestedStatus === 'complete' && receipt.outcome === 'terminal'
115
+ && receipt.obligationSetDigest === obligationDigest(review.obligations)) {
116
+ review.terminalReceipt = receipt;
117
+ state.consumedReceipts.push(receipt.acceptanceId);
118
+ return true;
119
+ }
120
+ return false;
121
+ }
122
+ function unsupportedReviewDeliveryActions(subject) {
123
+ return subject.split('+').filter(action => !['merge_pr', 'merge_pr_work_completion', 'cleanup_branch'].includes(action));
124
+ }
125
+ function projectReviewLifecycle(state) {
126
+ const job = state.activeJob;
127
+ const review = activeReview(state);
128
+ const view = { version: 1, sequence: state.sequence, jobInstanceId: job?.jobInstanceId,
129
+ revisionId: review?.revisionId, actionSetDigest: review?.actionSetDigest,
130
+ decisionStatus: review ? review.decision?.status || 'pending' : undefined, displayState: 'working' };
131
+ if (job?.manualClosure)
132
+ return { ...view, displayState: 'closed_manually', attentionReason: 'Closed manually. Delivery and unresolved obligations remain unverified.' };
133
+ if (job?.state === 'error')
134
+ return { ...view, displayState: 'error', attentionReason: 'The active job needs recovery.' };
135
+ if (job?.state === 'scheduled')
136
+ return { ...view, displayState: 'scheduled' };
137
+ if (job?.state === 'waiting')
138
+ return { ...view, displayState: 'waiting', attentionReason: 'The active job needs a decision.' };
139
+ if (!review)
140
+ return { ...view, displayState: job?.state === 'complete' ? 'complete' : 'working' };
141
+ if (review.verificationError)
142
+ return { ...view, displayState: 'error', attentionReason: review.verificationError };
143
+ if (!review.decision || review.decision.status === 'changes_requested')
144
+ return { ...view, displayState: 'waiting', attentionReason: review.decision ? 'Changes requested.' : 'Review the current result.' };
145
+ if (review.obligations.some(o => o.state === 'failed'))
146
+ return { ...view, displayState: 'error', attentionReason: 'Required delivery failed.' };
147
+ if (review.terminalReceipt && review.terminalReceipt.sessionId === job?.sessionId
148
+ && review.terminalReceipt.executionEpoch === job?.executionEpoch && review.terminalReceipt.revisionId === review.revisionId
149
+ && review.terminalReceipt.obligationSetDigest === obligationDigest(review.obligations)
150
+ && review.obligations.every(o => o.state === 'succeeded'))
151
+ return { ...view, displayState: 'complete' };
152
+ const unsupported = review.obligations.filter(o => o.kind === 'delivery' && o.state !== 'succeeded')
153
+ .flatMap(o => unsupportedReviewDeliveryActions(o.subject));
154
+ return { ...view, displayState: 'finalizing', ...(unsupported.length ? { attentionReason: `Cannot automatically verify ${[...new Set(unsupported)].join(', ')}. Mark complete can close the job manually without confirming delivery.` } : {}) };
155
+ }
156
+ function candidateIdentity(candidate) {
157
+ return { jobId: candidate.jobId,
158
+ prerequisites: [...new Set((candidate.prerequisites || []).map(p => JSON.stringify({ kind: p.kind, reference: p.reference })))].sort(),
159
+ artifactIds: [...new Set(candidate.artifactIds || [])].sort() };
160
+ }
161
+ function normalizeReviewCandidates(value) {
162
+ if (!Array.isArray(value))
163
+ return [];
164
+ const kinds = new Set(['approved_artifact', 'delivered_artifact', 'selected_action_success', 'required_child_success', 'external_result']);
165
+ return value.filter((c) => !!c && typeof c === 'object' && typeof c.jobId === 'string'
166
+ && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(c.jobId) && typeof c.label === 'string' && !!c.label.trim()
167
+ && (c.artifactIds === undefined || Array.isArray(c.artifactIds) && c.artifactIds.every((x) => typeof x === 'string'))
168
+ && (c.prerequisites === undefined || Array.isArray(c.prerequisites) && c.prerequisites.every((p) => p && kinds.has(p.kind) && typeof p.reference === 'string')))
169
+ .filter((c, index, all) => all.findIndex(other => reviewDigest(candidateIdentity(other)) === reviewDigest(candidateIdentity(c))) === index)
170
+ .slice(0, 3).map(c => ({ jobId: c.jobId, label: c.label, reason: typeof c.reason === 'string' ? c.reason : undefined,
171
+ contextSummary: typeof c.contextSummary === 'string' ? c.contextSummary : undefined, artifactIds: c.artifactIds, prerequisites: c.prerequisites }));
172
+ }
173
+ function candidateReadiness(review, candidate, available) {
174
+ if (!available)
175
+ return { readiness: 'unavailable', readinessReason: 'Unavailable in this project.' };
176
+ if (review.verificationError)
177
+ return { readiness: 'unavailable', readinessReason: review.verificationError };
178
+ if (review.decision?.status !== 'approved')
179
+ return { readiness: 'waiting', readinessReason: 'Waiting for approval of this result.' };
180
+ for (const p of candidate.prerequisites || []) {
181
+ const artifact = review.manifest.find(a => a.id === p.reference);
182
+ const obligation = review.obligations.find(o => o.id === p.reference || o.subject === p.reference);
183
+ const met = p.kind === 'approved_artifact' ? !!artifact
184
+ : p.kind === 'delivered_artifact' ? !!artifact
185
+ && review.obligations.some(o => o.kind === 'artifact' && o.subject === artifact.id && o.state === 'succeeded')
186
+ // Matching local bytes prove integrity, not publication or receipt at a destination.
187
+ // Delivery remains unknown until the selected action's owning verifier supplies proof.
188
+ && review.obligations.some(o => o.kind === 'delivery')
189
+ && review.obligations.filter(o => o.kind === 'delivery').every(o => o.state === 'succeeded' && !!o.evidenceRef)
190
+ : p.kind === 'selected_action_success' ? obligation?.kind === 'delivery' && obligation.state === 'succeeded'
191
+ : p.kind === 'required_child_success' ? obligation?.kind === 'child' && obligation.state === 'succeeded'
192
+ : false; // External facts require an owning provider verifier; absence is never Ready.
193
+ if (!met)
194
+ return { readiness: 'waiting', readinessReason: `Waiting for ${p.kind.replace(/_/g, ' ')}: ${p.reference}.` };
195
+ }
196
+ if (candidate.artifactIds?.some(id => !review.manifest.some(a => a.id === id)))
197
+ return { readiness: 'unavailable', readinessReason: 'A referenced artifact is unavailable.' };
198
+ return { readiness: 'ready', readinessReason: 'Ready' };
199
+ }
@@ -1,7 +1,28 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseReviewExecutionBinding = parseReviewExecutionBinding;
3
4
  exports.safeRunStateKey = safeRunStateKey;
4
5
  const crypto_1 = require("crypto");
6
+ /** Supplied only by the executing Hub's launch environment, never tool evidence. */
7
+ function parseReviewExecutionBinding(value) {
8
+ if (!value)
9
+ return undefined;
10
+ let binding;
11
+ try {
12
+ binding = JSON.parse(value);
13
+ }
14
+ catch {
15
+ throw new Error('Invalid Hub review execution binding.');
16
+ }
17
+ const fields = ['sourceRunId', 'jobInstanceId', 'executionEpoch'];
18
+ if (!binding || fields.some(key => typeof binding[key] !== 'string' || !binding[key] || binding[key].length > 512)
19
+ || ['revisionId', 'obligationSetDigest'].some(key => binding[key] !== undefined && (typeof binding[key] !== 'string' || !binding[key] || binding[key].length > 512))
20
+ || Boolean(binding.revisionId) !== Boolean(binding.obligationSetDigest)
21
+ || (binding.sessionId !== undefined && typeof binding.sessionId !== 'string')) {
22
+ throw new Error('Invalid Hub review execution binding.');
23
+ }
24
+ return binding;
25
+ }
5
26
  /** UUIDs retain their documented filenames; other IDs cannot become paths or device names. */
6
27
  function safeRunStateKey(id) {
7
28
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
@@ -16,6 +16,7 @@ const learning_source_record_1 = require("./learning-source-record");
16
16
  const artifact_paths_1 = require("../../registry/scripts/artifact-paths");
17
17
  const completion_state_1 = require("../../registry/scripts/learning/completion-state");
18
18
  const pack_home_1 = require("../cli/utils/pack-home");
19
+ const review_lifecycle_1 = require("../core/review-lifecycle");
19
20
  exports.ARTIFACT_RETENTION_CATEGORIES = [
20
21
  'learning_archive',
21
22
  'retrospectives',
@@ -32,6 +33,108 @@ exports.ARTIFACT_RETENTION_CATEGORIES = [
32
33
  'learning_usage',
33
34
  'hub_event_logs',
34
35
  ];
36
+ /** Read-only consumer of canonical conversation shard envelope v2 (not index headers).
37
+ * Keep this layout contract covered by store-written fixtures. The CLI package must
38
+ * not import Hub runtime or create another lifecycle writer/cache. Unknown or unreadable
39
+ * authority conservatively defers cleanup; a later healthy pass can apply retention.
40
+ */
41
+ function activeReviewReferences() {
42
+ const paths = new Set();
43
+ const root = node_path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-conversations');
44
+ try {
45
+ let rootStat;
46
+ try {
47
+ rootStat = node_fs_1.default.lstatSync(root);
48
+ }
49
+ catch (error) {
50
+ if (error.code === 'ENOENT')
51
+ return { paths, unknown: false };
52
+ throw error;
53
+ }
54
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
55
+ throw new Error('Unknown conversation root');
56
+ const records = [];
57
+ for (const bucket of node_fs_1.default.readdirSync(root, { withFileTypes: true })) {
58
+ if (!/^[a-f0-9]{16}$/.test(bucket.name))
59
+ continue;
60
+ if (!bucket.isDirectory() || bucket.isSymbolicLink())
61
+ throw new Error('Unreadable conversation bucket');
62
+ for (const file of node_fs_1.default.readdirSync(node_path_1.default.join(root, bucket.name), { withFileTypes: true })) {
63
+ if (!/^[a-f0-9]{40}\.json$/.test(file.name))
64
+ continue;
65
+ if (!file.isFile() || file.isSymbolicLink())
66
+ throw new Error('Unreadable conversation shard');
67
+ const envelope = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(root, bucket.name, file.name), 'utf8'));
68
+ if (envelope.version !== 2 || typeof envelope.conversation?.id !== 'string')
69
+ throw new Error('Unknown conversation schema');
70
+ records.push({ bucket: bucket.name, record: envelope.conversation });
71
+ }
72
+ }
73
+ const complete = (record) => {
74
+ const state = record.reviewLifecycle;
75
+ if (state && state.version !== 1)
76
+ throw new Error('Unknown review version');
77
+ return state ? (0, review_lifecycle_1.projectReviewLifecycle)(state).displayState === 'complete'
78
+ : record.status === 'completed' && record.pauseReason === 'done';
79
+ };
80
+ const protect = (manifest, home) => {
81
+ if (!Array.isArray(manifest) || !home || !node_path_1.default.isAbsolute(home))
82
+ throw new Error('Unresolved input references');
83
+ for (const artifact of manifest) {
84
+ if (typeof artifact.locator !== 'string' || !artifact.locator)
85
+ throw new Error('Unresolved artifact');
86
+ if (/^https?:\/\//i.test(artifact.locator))
87
+ continue;
88
+ const target = node_path_1.default.resolve(home, artifact.locator);
89
+ paths.add(pathKey(target));
90
+ if (node_fs_1.default.existsSync(target))
91
+ paths.add(pathKey(node_fs_1.default.realpathSync(target)));
92
+ }
93
+ };
94
+ for (const { bucket, record } of records) {
95
+ if (record.recommendationInput && !complete(record))
96
+ protect(record.recommendationInput.manifest, record.projectPath);
97
+ const state = record.reviewLifecycle;
98
+ if (!state)
99
+ continue;
100
+ if (state.version !== 1)
101
+ throw new Error('Unknown review version');
102
+ for (const review of Object.values(state.reviewsByJobInstance)) {
103
+ const job = state.jobs[review.jobInstanceId];
104
+ if (!job || (0, review_lifecycle_1.projectReviewLifecycle)({ ...state, activeJob: job }).displayState !== 'complete')
105
+ protect(review.manifest, state.source.projectPath);
106
+ }
107
+ for (const operation of Object.values(state.operations)) {
108
+ if (operation.kind !== 'recommendation')
109
+ continue;
110
+ const child = records.find(entry => entry.bucket === bucket && entry.record.id === operation.runId)?.record;
111
+ if (child && complete(child))
112
+ continue;
113
+ const review = state.reviewsByJobInstance[operation.jobInstanceId];
114
+ const manifest = operation.input?.manifest || (review?.revisionId === operation.revisionId
115
+ ? review.manifest : review?.history.find(previous => previous.revisionId === operation.revisionId)?.manifest);
116
+ protect(manifest, state.source.projectPath);
117
+ }
118
+ }
119
+ return { paths, unknown: false };
120
+ }
121
+ catch {
122
+ return { paths, unknown: true };
123
+ }
124
+ }
125
+ function reviewRetentionReason(candidate, references) {
126
+ if (references.unknown)
127
+ return 'Review input references are unavailable; retained until authority can be verified.';
128
+ try {
129
+ if (references.paths.has(pathKey(candidate.absolutePath))
130
+ || references.paths.has(pathKey(node_fs_1.default.realpathSync(candidate.absolutePath))))
131
+ return 'Active review or dependent assignment still requires this artifact.';
132
+ }
133
+ catch {
134
+ return 'Artifact location changed during cleanup; retained for a later pass.';
135
+ }
136
+ return undefined;
137
+ }
35
138
  function contentRoot(workspaceRoot) {
36
139
  const packContent = node_path_1.default.join(workspaceRoot, 'personalized-employee');
37
140
  return node_fs_1.default.existsSync(packContent) ? packContent : node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee');
@@ -424,6 +527,7 @@ function runArtifactCleanup(workspaceRoot, options) {
424
527
  const now = options.now ?? new Date();
425
528
  const retention = resolveArtifactRetentionConfig(workspaceRoot);
426
529
  const candidates = collectCandidates(workspaceRoot, options);
530
+ const reviewReferences = activeReviewReferences();
427
531
  const report = {
428
532
  generatedAt: now.toISOString(),
429
533
  apply: options.apply,
@@ -438,6 +542,9 @@ function runArtifactCleanup(workspaceRoot, options) {
438
542
  ? categoryValue === -1 || retention.values.feedback === -1 ? -1 : Math.max(categoryValue, retention.values.feedback)
439
543
  : categoryValue;
440
544
  let decision = shouldDelete(candidate, retentionValue, now);
545
+ const protectedReason = reviewRetentionReason(candidate, reviewReferences);
546
+ if (protectedReason)
547
+ decision = { delete: false, reason: protectedReason };
441
548
  let action;
442
549
  let error;
443
550
  if (!candidate.eligible) {
@@ -451,6 +558,10 @@ function runArtifactCleanup(workspaceRoot, options) {
451
558
  }
452
559
  else {
453
560
  try {
561
+ // A review or dependent assignment may have become active since planning.
562
+ const currentProtection = reviewRetentionReason(candidate, activeReviewReferences());
563
+ if (currentProtection)
564
+ decision = { delete: false, reason: currentProtection };
454
565
  if (candidate.recordHash && (!matchesConfiguredLocation(workspaceRoot, candidate) || !isConfinedRegularFile(workspaceRoot, candidate.absolutePath)
455
566
  || recordHash(node_fs_1.default.readFileSync(candidate.absolutePath, 'utf8')) !== candidate.recordHash
456
567
  || reservedSources(workspaceRoot).has(pathKey(candidate.absolutePath)))) {
@@ -468,7 +579,7 @@ function runArtifactCleanup(workspaceRoot, options) {
468
579
  || current.sidecarPath !== candidate.sidecarPath) {
469
580
  decision = { delete: false, reason: 'Archive changed or resumed after enumeration; retained for a later cleanup.' };
470
581
  }
471
- else {
582
+ else if (!currentProtection) {
472
583
  decision = shouldDelete({ ...candidate, ...current }, retentionValue, now);
473
584
  }
474
585
  }
@@ -1277,6 +1277,13 @@ class FraimLocalMCPServer {
1277
1277
  const temporary = `${path}.${(0, crypto_1.randomUUID)()}.tmp`;
1278
1278
  try {
1279
1279
  (0, fs_1.writeFileSync)(temporary, JSON.stringify(value), { encoding: 'utf8', mode: 0o600 });
1280
+ const descriptor = (0, fs_1.openSync)(temporary, 'r+');
1281
+ try {
1282
+ (0, fs_1.fsyncSync)(descriptor);
1283
+ }
1284
+ finally {
1285
+ (0, fs_1.closeSync)(descriptor);
1286
+ }
1280
1287
  (0, fs_1.renameSync)(temporary, path);
1281
1288
  }
1282
1289
  finally {
@@ -1301,6 +1308,9 @@ class FraimLocalMCPServer {
1301
1308
  return;
1302
1309
  try {
1303
1310
  const root = this.getRunStateDir();
1311
+ const hubRunId = process.env.FRAIM_HUB_RUN_ID;
1312
+ const runPath = hubRunId ? (0, path_1.join)(root, 'runs', `${(0, run_recovery_state_1.safeRunStateKey)(hubRunId)}.json`) : undefined;
1313
+ const previousPointer = runPath && (0, fs_1.existsSync)(runPath) ? JSON.parse((0, fs_1.readFileSync)(runPath, 'utf8')) : undefined;
1304
1314
  const jobId = typeof args.jobId === 'string' && args.jobId ? args.jobId : undefined;
1305
1315
  const stateFile = jobId ? `${(0, run_recovery_state_1.safeRunStateKey)(jobId)}.json`
1306
1316
  : `anonymous-${(0, run_recovery_state_1.safeRunStateKey)(JSON.stringify([sessionId, args.jobName]))}.json`;
@@ -1338,13 +1348,27 @@ class FraimLocalMCPServer {
1338
1348
  }
1339
1349
  const timestamp = new Date().toISOString();
1340
1350
  const accepted = acceptedNext !== undefined;
1351
+ const acceptedReceipts = previous?.acceptedReceipts || [];
1352
+ let receipt = acceptedNext?.receipt;
1353
+ let replay = false;
1354
+ if (receipt) {
1355
+ const existing = acceptedReceipts.find(item => item.acceptanceId === receipt.acceptanceId);
1356
+ if (existing) {
1357
+ receipt = existing;
1358
+ replay = true;
1359
+ }
1360
+ else {
1361
+ receipt = { ...receipt, acceptedAt: timestamp, sequence: Math.max(previousPointer?.sequence || 0, acceptedReceipts[acceptedReceipts.length - 1]?.sequence || 0) + 1 };
1362
+ acceptedReceipts.push(receipt);
1363
+ }
1364
+ }
1341
1365
  const recoveringTerminalReview = previous?.status === 'waiting_review' && previous.currentPhase === null
1342
1366
  && (args.status === 'starting' || args.status === 'incomplete');
1343
1367
  const currentPhase = recoveringTerminalReview ? null : accepted
1344
1368
  ? (args.status === 'complete' ? acceptedNext.nextPhase : acceptedNext.nextPhase || args.currentPhase)
1345
1369
  : previous ? previous.currentPhase : args.currentPhase;
1346
1370
  const completedPhases = previous?.completedPhases || [];
1347
- if (accepted && (args.status === 'complete' || args.status === 'failure')) {
1371
+ if (accepted && !replay && (args.status === 'complete' || args.status === 'failure')) {
1348
1372
  completedPhases.push({ phase: args.currentPhase, status: args.status, timestamp });
1349
1373
  }
1350
1374
  const parsedHandoff = [args.reviewHandoff, args.evidence?.reviewHandoff, args.findings?.reviewHandoff]
@@ -1372,24 +1396,111 @@ class FraimLocalMCPServer {
1372
1396
  startedPhases: accepted && args.status === 'starting'
1373
1397
  ? { ...previous?.startedPhases, [currentPhase || args.currentPhase]: timestamp }
1374
1398
  : previous?.startedPhases || {},
1375
- completedPhases,
1399
+ completedPhases, acceptedReceipts,
1400
+ executionBinding: receipt?.revisionId ? {
1401
+ sourceRunId: receipt.sourceRunId, jobInstanceId: receipt.jobInstanceId, executionEpoch: receipt.executionEpoch,
1402
+ sessionId: receipt.sessionId, revisionId: receipt.revisionId, obligationSetDigest: receipt.obligationSetDigest,
1403
+ } : previous?.executionBinding,
1376
1404
  };
1377
- this.writeRecoveryJson(statePath, state);
1378
1405
  const pointer = { jobId, sessionId, stateFile };
1379
1406
  this.writeRecoveryJson((0, path_1.join)(root, 'sessions', `${(0, run_recovery_state_1.safeRunStateKey)(sessionId)}.json`), pointer);
1380
- const hubRunId = process.env.FRAIM_HUB_RUN_ID;
1381
- if (hubRunId)
1382
- this.writeRecoveryJson((0, path_1.join)(root, 'runs', `${(0, run_recovery_state_1.safeRunStateKey)(hubRunId)}.json`), pointer);
1407
+ if (runPath) {
1408
+ const instanceId = jobId || `anonymous:${sessionId}:${args.jobName}`;
1409
+ this.writeRecoveryJson(runPath, { ...pointer, sequence: Math.max(previousPointer?.sequence || 0, receipt?.sequence || 0), jobInstances: { ...previousPointer?.jobInstances, [instanceId]: pointer } });
1410
+ }
1411
+ // The receipt-bearing state is the commit point. Discovery writes may
1412
+ // leave a missing/stale state on failure, but never an accepted receipt.
1413
+ this.writeRecoveryJson(statePath, replay && previous ? previous : state);
1414
+ return receipt;
1383
1415
  }
1384
1416
  catch (error) {
1385
1417
  this.log(`[phase-recovery] Could not persist recovery state: ${error.message}`);
1418
+ if (acceptedNext?.receipt && process.env.FRAIM_HUB_RUN_ID)
1419
+ throw new Error(`Accepted phase receipt could not be persisted: ${error.message}`);
1386
1420
  }
1387
1421
  }
1422
+ /** Created only after the response has passed finalization, from runtime authority. */
1423
+ persistAcceptedReceipt(args, sessionId, result, requestId) {
1424
+ if (args.status === 'starting' && args.requestInstructions === true)
1425
+ return undefined;
1426
+ if (!result.outcome || result.outcome.kind === 'invalid' || !result.workflowDigest)
1427
+ return undefined;
1428
+ const binding = (0, run_recovery_state_1.parseReviewExecutionBinding)(process.env.FRAIM_REVIEW_BINDING);
1429
+ const sourceRunId = binding?.sourceRunId || process.env.FRAIM_HUB_RUN_ID || '';
1430
+ const jobInstanceId = args.jobId || `anonymous:${sessionId}:${args.jobName}`;
1431
+ let ownBinding = binding?.jobInstanceId === jobInstanceId ? binding : undefined;
1432
+ if (!ownBinding && binding && process.env.FRAIM_REVIEW_BINDINGS) {
1433
+ let approved;
1434
+ try {
1435
+ approved = JSON.parse(process.env.FRAIM_REVIEW_BINDINGS);
1436
+ }
1437
+ catch {
1438
+ throw new Error('Invalid Hub approved review bindings.');
1439
+ }
1440
+ const selected = approved && Object.prototype.hasOwnProperty.call(approved, jobInstanceId) ? approved[jobInstanceId] : undefined;
1441
+ if (selected && typeof selected === 'object') {
1442
+ const prior = (0, run_recovery_state_1.parseReviewExecutionBinding)(JSON.stringify({ ...selected, executionEpoch: binding.executionEpoch }));
1443
+ if (prior && prior.jobInstanceId === jobInstanceId && prior.sourceRunId === binding.sourceRunId && prior.revisionId && prior.obligationSetDigest) {
1444
+ ownBinding = { sourceRunId: binding.sourceRunId, jobInstanceId, executionEpoch: binding.executionEpoch,
1445
+ revisionId: prior.revisionId, obligationSetDigest: prior.obligationSetDigest };
1446
+ }
1447
+ }
1448
+ }
1449
+ if (!ownBinding && binding && typeof args.jobId === 'string') {
1450
+ // A later job can return to an already approved instance in this source.
1451
+ // Recover only that instance's review identity; execution authority still
1452
+ // comes from the current host epoch and the current MCP session.
1453
+ try {
1454
+ const state = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(this.getRunStateDir(), `${(0, run_recovery_state_1.safeRunStateKey)(args.jobId)}.json`), 'utf8'));
1455
+ const prior = (0, run_recovery_state_1.parseReviewExecutionBinding)(JSON.stringify(state.executionBinding));
1456
+ if (prior && state.jobId === jobInstanceId && state.currentJobName === args.jobName && prior.jobInstanceId === jobInstanceId
1457
+ && prior.sourceRunId === binding.sourceRunId && prior.revisionId && prior.obligationSetDigest) {
1458
+ ownBinding = { sourceRunId: binding.sourceRunId, jobInstanceId, executionEpoch: binding.executionEpoch,
1459
+ revisionId: prior.revisionId, obligationSetDigest: prior.obligationSetDigest };
1460
+ }
1461
+ }
1462
+ catch { /* Missing owner history cannot supply approval authority. */ }
1463
+ }
1464
+ if (ownBinding?.sessionId && ownBinding.sessionId !== sessionId)
1465
+ throw new Error('Review execution session does not match its Hub binding.');
1466
+ if (!sessionId || sessionId === 'unknown') {
1467
+ if (ownBinding)
1468
+ throw new Error('Review execution requires an identifiable MCP session.');
1469
+ return undefined;
1470
+ }
1471
+ const executionEpoch = binding?.executionEpoch || process.env.FRAIM_EXECUTION_EPOCH || sessionId;
1472
+ const acceptanceId = (0, crypto_1.createHash)('sha256').update(JSON.stringify([sourceRunId, jobInstanceId, sessionId, executionEpoch, requestId, args.currentPhase, args.status, result.workflowDigest, result.outcome, ownBinding?.revisionId, ownBinding?.obligationSetDigest, args.evidence, args.findings])).digest('hex');
1473
+ const parseStructured = (value) => {
1474
+ if (typeof value !== 'string')
1475
+ return value;
1476
+ try {
1477
+ return JSON.parse(value);
1478
+ }
1479
+ catch {
1480
+ return undefined;
1481
+ }
1482
+ };
1483
+ const handoff = parseStructured(args.evidence?.reviewHandoff ?? args.findings?.reviewHandoff ?? args.reviewHandoff);
1484
+ const recommendations = parseStructured(args.evidence?.nextJobRecommendations ?? args.findings?.nextJobRecommendations ?? args.nextJobRecommendations);
1485
+ const receipt = {
1486
+ version: 1, acceptanceId, acceptedAt: new Date().toISOString(), sequence: 0,
1487
+ sourceRunId, jobInstanceId, jobName: args.jobName, workflowDigest: result.workflowDigest,
1488
+ sessionId, executionEpoch, phaseId: args.currentPhase, requestedStatus: args.status,
1489
+ outcome: result.outcome.kind, ...(result.nextPhase ? { nextPhase: result.nextPhase } : {}),
1490
+ ...(ownBinding ? { revisionId: ownBinding.revisionId, obligationSetDigest: ownBinding.obligationSetDigest } : {}),
1491
+ verifiedEvidenceRefs: [], ...(handoff ? { reviewHandoff: handoff } : {}),
1492
+ ...(recommendations ? { nextJobRecommendations: recommendations } : {}),
1493
+ };
1494
+ if (Buffer.byteLength(JSON.stringify(receipt), 'utf8') > 262144)
1495
+ throw new Error('Accepted phase receipt exceeds the 256 KiB limit.');
1496
+ return this.recordRecoveryState(args, sessionId, { nextPhase: result.nextPhase, receipt });
1497
+ }
1388
1498
  /** Called only after the response has passed every rejecting contract. */
1389
- acceptPhaseTracking(args, sessionId, nextPhase) {
1499
+ acceptPhaseTracking(args, sessionId, nextPhase, recoveryRecorded = false) {
1390
1500
  if (args.status === 'starting' && args.requestInstructions === true)
1391
1501
  return '';
1392
- this.recordRecoveryState(args, sessionId, { nextPhase });
1502
+ if (!recoveryRecorded)
1503
+ this.recordRecoveryState(args, sessionId, { nextPhase });
1393
1504
  if (!sessionId || sessionId === 'unknown' || !args.jobName || !args.currentPhase) {
1394
1505
  return args.status === 'complete' || args.status === 'failure'
1395
1506
  ? `Phase-start notice: phase ${args.currentPhase || '(unknown)'} cannot be matched to a run because its session or job identity is missing. Completion remains accepted.\n\n` : '';
@@ -2694,9 +2805,14 @@ class FraimLocalMCPServer {
2694
2805
  };
2695
2806
  }
2696
2807
  }
2808
+ let localWorkflowResolved = false;
2697
2809
  try {
2698
2810
  const mentor = this.getMentor(requestSessionId);
2699
2811
  const tutoringResponse = await mentor.handleMentoringRequest(args);
2812
+ localWorkflowResolved = true;
2813
+ if (tutoringResponse.outcome?.kind === 'invalid') {
2814
+ return { jsonrpc: '2.0', id: request.id, result: { isError: true, content: [{ type: 'text', text: tutoringResponse.message }] } };
2815
+ }
2700
2816
  this.log(`✅ Local seekMentoring succeeded for ${args.jobName}:${args.currentPhase}`);
2701
2817
  // Quality enforcement (Issue #251).
2702
2818
  //
@@ -2785,7 +2901,11 @@ class FraimLocalMCPServer {
2785
2901
  phases: handoffPhaseMap,
2786
2902
  repo: handoffRepoState,
2787
2903
  });
2788
- handoffErrors.push(...await this.validateNextJobRecommendationJobIds(args.evidence?.nextJobRecommendations, mentor));
2904
+ // Submission candidates are optional; the Hub filters unavailable jobs.
2905
+ // A malformed suggestion must not reject an otherwise valid review.
2906
+ if (!args.currentPhase.includes('submit') && !args.currentPhase.includes('submission')) {
2907
+ handoffErrors.push(...await this.validateNextJobRecommendationJobIds(args.evidence?.nextJobRecommendations, mentor));
2908
+ }
2789
2909
  // Issue #1857: enforced on its own terms rather than folded into
2790
2910
  // handoffErrors. `missingField` below reads handoffErrors[0], and the
2791
2911
  // reviewHandoff escape hatch keys off that, so a reviewHandoff error in
@@ -2864,7 +2984,11 @@ class FraimLocalMCPServer {
2864
2984
  const acceptedResponse = await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, tutoringResponse.message);
2865
2985
  if (acceptedResponse.error || acceptedResponse.result?.isError)
2866
2986
  return acceptedResponse;
2867
- const phaseStartWarning = this.acceptPhaseTracking(args, requestSessionId, tutoringResponse.nextPhase);
2987
+ const acceptedReceipt = this.persistAcceptedReceipt(args, requestSessionId, tutoringResponse, request.id);
2988
+ const phaseStartWarning = this.acceptPhaseTracking(args, requestSessionId, tutoringResponse.nextPhase, !!acceptedReceipt);
2989
+ acceptedResponse.result.structuredContent = { ...acceptedResponse.result.structuredContent,
2990
+ phaseAccepted: true, nextPhase: tutoringResponse.nextPhase, outcome: tutoringResponse.outcome,
2991
+ workflowDigest: tutoringResponse.workflowDigest, ...(acceptedReceipt ? { acceptedReceipt } : {}) };
2868
2992
  if (phaseStartWarning && acceptedResponse.result?.content?.[0]) {
2869
2993
  acceptedResponse.result.content[0].text = phaseStartWarning + acceptedResponse.result.content[0].text;
2870
2994
  }
@@ -2877,6 +3001,9 @@ class FraimLocalMCPServer {
2877
3001
  return acceptedResponse;
2878
3002
  }
2879
3003
  catch (error) {
3004
+ if (localWorkflowResolved) {
3005
+ return { jsonrpc: '2.0', id: request.id, result: { isError: true, content: [{ type: 'text', text: `Phase acceptance failed: ${error.message}` }] } };
3006
+ }
2880
3007
  this.log(`⚠️ Local seekMentoring failed: ${error.message}. Falling back to remote.`);
2881
3008
  // If local fails, we continue to proxy to the remote server
2882
3009
  }
@@ -2999,12 +3126,38 @@ class FraimLocalMCPServer {
2999
3126
  if (toolName === 'seekMentoring' && !processedResponse.error && !processedResponse.result?.isError
3000
3127
  && response.result?.structuredContent?.phaseAccepted === true) {
3001
3128
  const metadata = response.result.structuredContent;
3129
+ let acceptedReceipt;
3130
+ if (metadata.acceptedReceipt) {
3131
+ const remote = metadata.acceptedReceipt;
3132
+ if (remote.jobName !== args.jobName || remote.jobInstanceId !== (args.jobId || `anonymous:${requestSessionId}:${args.jobName}`)
3133
+ || remote.sessionId !== requestSessionId || remote.phaseId !== args.currentPhase || remote.requestedStatus !== args.status
3134
+ || !['next', 'terminal', 'recurring_wait', 'review_wait'].includes(remote.outcome)
3135
+ || typeof remote.workflowDigest !== 'string') {
3136
+ return { jsonrpc: '2.0', id: request.id, result: { isError: true, content: [{ type: 'text', text: 'Remote phase receipt did not match the executing request.' }] } };
3137
+ }
3138
+ try {
3139
+ acceptedReceipt = this.persistAcceptedReceipt(args, requestSessionId, {
3140
+ message: '', status: args.status, nextPhase: remote.nextPhase ?? null, workflowDigest: remote.workflowDigest,
3141
+ outcome: remote.outcome === 'next' ? { kind: 'next', phaseId: remote.nextPhase } : { kind: remote.outcome },
3142
+ }, request.id);
3143
+ }
3144
+ catch (error) {
3145
+ return { jsonrpc: '2.0', id: request.id, result: { isError: true, content: [{ type: 'text', text: `Phase acceptance failed: ${error.message}` }] } };
3146
+ }
3147
+ processedResponse.result.structuredContent = { ...processedResponse.result.structuredContent, ...(acceptedReceipt ? { acceptedReceipt } : {}) };
3148
+ }
3149
+ else if (process.env.FRAIM_REVIEW_BINDING && !(args.status === 'starting' && args.requestInstructions === true)) {
3150
+ const binding = (0, run_recovery_state_1.parseReviewExecutionBinding)(process.env.FRAIM_REVIEW_BINDING);
3151
+ if (binding?.revisionId && binding.jobInstanceId === args.jobId) {
3152
+ return { jsonrpc: '2.0', id: request.id, result: { isError: true, content: [{ type: 'text', text: 'Remote phase acceptance lacks a supported workflow receipt.' }] } };
3153
+ }
3154
+ }
3002
3155
  const textBlock = processedResponse.result?.content?.find((item) => item.type === 'text');
3003
3156
  if (textBlock) {
3004
3157
  const remoteWarning = metadata.phaseStartWarning;
3005
3158
  if (remoteWarning)
3006
3159
  textBlock.text = textBlock.text.replace(remoteWarning, '');
3007
- textBlock.text = this.acceptPhaseTracking(args, requestSessionId, metadata.nextPhase) + textBlock.text;
3160
+ textBlock.text = this.acceptPhaseTracking(args, requestSessionId, metadata.nextPhase, !!acceptedReceipt) + textBlock.text;
3008
3161
  }
3009
3162
  }
3010
3163
  this.log(`📤 ${injectedRequest.method} → ${processedResponse.error ? 'ERROR' : 'OK'}`);
@@ -369,7 +369,7 @@ Do not use this tool for other repositories or external project issue trackers.`
369
369
  },
370
370
  evidence: {
371
371
  type: 'object',
372
- description: 'Structured evidence or data collected (e.g., prospect counts, test results). Submit phases should put review artifacts in evidence.reviewHandoff rather than printing raw JSON to the user. Retrospective phases may put follow-on job recommendations in evidence.nextJobRecommendations (array of { jobId, label, reason?, contextSummary? }, max 3) so the work surface can offer them as next steps.',
372
+ description: 'Structured evidence or data collected (e.g., prospect counts, test results). Submit phases should put review artifacts in evidence.reviewHandoff rather than printing raw JSON to the user. Prepare optional follow-on candidates at submission in evidence.nextJobRecommendations (0-3 entries with jobId, label, reason?, contextSummary?, artifactIds?: string[], prerequisites?: {kind, reference}[]); Standalone jobs with an explicit recommendation step may emit there; the Hub retains submitted candidates through finalization. Prerequisite kinds: approved_artifact, delivered_artifact, selected_action_success, required_child_success, external_result. Reference actual artifacts/actions/children/provider evidence. Do not emit readiness or server-owned candidate/revision/source/start identities. Candidates are revealed only after persisted approval; selecting one prepares a draft, and only explicit Send starts a follow-on.',
373
373
  additionalProperties: true
374
374
  },
375
375
  workingDirectory: {
@@ -732,6 +732,8 @@ class McpService {
732
732
  findings: args.findings,
733
733
  requestInstructions: args.requestInstructions
734
734
  });
735
+ if (result.outcome?.kind === 'invalid')
736
+ return { isError: true, content: [{ type: 'text', text: result.message }] };
735
737
  // Handoff contract enforcement (Issue #916/#1157/#1276) fall-through
736
738
  // safety net, matching the quality-enforcement note just below.
737
739
  //
@@ -892,13 +894,26 @@ class McpService {
892
894
  console.error(`❌ McpService: Failed to upsert executionMode:`, e);
893
895
  }
894
896
  }
895
- return this.addRuleDelivery({
897
+ const finalized = await this.addRuleDelivery({
896
898
  content: [{
897
899
  type: 'text',
898
900
  text: trackingWarning + phaseStartWarning + result.message
899
901
  }],
900
902
  structuredContent: { phaseAccepted: true, nextPhase: result.nextPhase, phaseStartWarning }
901
903
  }, args.jobName, args);
904
+ if (!finalized.isError && !restoringInstructions && result.outcome && result.workflowDigest && sessionId) {
905
+ // Remote-only execution has no trusted Hub binding. The executing local
906
+ // proxy verifies this envelope and supplies its own launch binding.
907
+ const acceptedReceipt = {
908
+ version: 1, acceptanceId: (0, crypto_1.randomUUID)(), acceptedAt: new Date().toISOString(), sequence: 0,
909
+ sourceRunId: '', jobInstanceId: args.jobId || `anonymous:${sessionId}:${args.jobName}`,
910
+ jobName: args.jobName, workflowDigest: result.workflowDigest, sessionId,
911
+ executionEpoch: sessionId, phaseId: args.currentPhase, requestedStatus: args.status,
912
+ outcome: result.outcome.kind, ...(result.nextPhase ? { nextPhase: result.nextPhase } : {}), verifiedEvidenceRefs: [],
913
+ };
914
+ finalized.structuredContent = { ...finalized.structuredContent, acceptedReceipt, outcome: result.outcome, workflowDigest: result.workflowDigest };
915
+ }
916
+ return finalized;
902
917
  }
903
918
  async addRuleDelivery(response, jobName, phaseArgs) {
904
919
  if (response.isError || response.personaLock)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.360-next.2",
3
+ "version": "2.0.360",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {