@exaudeus/workrail 3.7.2 → 3.7.4

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.
@@ -7,6 +7,7 @@ const enumerate_sessions_js_1 = require("../../../usecases/enumerate-sessions.js
7
7
  const session_health_js_1 = require("../../../projections/session-health.js");
8
8
  const run_dag_js_1 = require("../../../projections/run-dag.js");
9
9
  const node_outputs_js_1 = require("../../../projections/node-outputs.js");
10
+ const run_context_js_1 = require("../../../projections/run-context.js");
10
11
  const snapshot_state_js_1 = require("../../../durable-core/projections/snapshot-state.js");
11
12
  const index_js_1 = require("../../../durable-core/ids/index.js");
12
13
  const constants_js_1 = require("../../../durable-core/constants.js");
@@ -17,6 +18,7 @@ const EMPTY_OBSERVATIONS = {
17
18
  gitBranch: null,
18
19
  repoRootHash: null,
19
20
  };
21
+ const TITLE_CONTEXT_KEYS = ['goal', 'taskDescription', 'mrTitle', 'prTitle', 'ticketTitle', 'problem'];
20
22
  class LocalSessionSummaryProviderV2 {
21
23
  constructor(ports) {
22
24
  this.ports = ports;
@@ -99,6 +101,7 @@ function projectSessionSummary(sessionId, truth, mtimeMs) {
99
101
  recapSnippet,
100
102
  observations: extractObservations(truth.events),
101
103
  workflow,
104
+ sessionTitle: deriveSessionTitle(truth.events, bestRun.run.runId),
102
105
  lastModifiedMs: mtimeMs,
103
106
  pendingStepId: null,
104
107
  isComplete: false,
@@ -145,6 +148,80 @@ function extractWorkflowIdentity(events, runId) {
145
148
  workflowHash: (0, index_js_1.asWorkflowHash)((0, index_js_1.asSha256Digest)(event.data.workflowHash)),
146
149
  };
147
150
  }
151
+ function deriveSessionTitle(events, runId) {
152
+ const contextRes = (0, run_context_js_1.projectRunContextV2)(events);
153
+ if (contextRes.isOk()) {
154
+ const runCtx = contextRes.value.byRunId[runId];
155
+ if (runCtx) {
156
+ for (const key of TITLE_CONTEXT_KEYS) {
157
+ const val = runCtx.context[key];
158
+ if (typeof val === 'string' && val.trim().length > 0) {
159
+ return truncateTitle(val.trim());
160
+ }
161
+ }
162
+ }
163
+ }
164
+ return extractTitleFromFirstRecap(events);
165
+ }
166
+ function extractTitleFromFirstRecap(events) {
167
+ const outputsRes = (0, node_outputs_js_1.projectNodeOutputsV2)(events);
168
+ if (outputsRes.isErr())
169
+ return null;
170
+ let rootNodeId = null;
171
+ let minIndex = Infinity;
172
+ for (const e of events) {
173
+ if (e.kind === constants_js_1.EVENT_KIND.NODE_CREATED && e.eventIndex < minIndex) {
174
+ minIndex = e.eventIndex;
175
+ rootNodeId = e.scope.nodeId;
176
+ }
177
+ }
178
+ if (!rootNodeId)
179
+ return null;
180
+ const nodeOutputs = outputsRes.value.nodesById[rootNodeId];
181
+ if (!nodeOutputs)
182
+ return null;
183
+ const recaps = nodeOutputs.currentByChannel[constants_js_1.OUTPUT_CHANNEL.RECAP];
184
+ const first = recaps?.[0];
185
+ if (!first || first.payload.payloadKind !== constants_js_1.PAYLOAD_KIND.NOTES)
186
+ return null;
187
+ return extractDescriptiveText(first.payload.notesMarkdown);
188
+ }
189
+ function extractDescriptiveText(markdown) {
190
+ const lines = markdown
191
+ .split('\n')
192
+ .map((line) => line.trim())
193
+ .filter((line) => line.length > 0);
194
+ for (const line of lines) {
195
+ if (/^#{1,3}\s/.test(line))
196
+ continue;
197
+ if (/^[-_*]{3,}$/.test(line))
198
+ continue;
199
+ if (line.startsWith('|'))
200
+ continue;
201
+ const boldLabel = line.match(/^\*{2}[^*]+\*{2}:?\s*(.*)/);
202
+ if (boldLabel) {
203
+ const value = boldLabel[1]?.trim();
204
+ if (value && value.length > 10)
205
+ return truncateTitle(value);
206
+ continue;
207
+ }
208
+ const listBoldLabel = line.match(/^-\s+\*{2}[^*]+\*{2}:?\s*(.*)/);
209
+ if (listBoldLabel) {
210
+ const value = listBoldLabel[1]?.trim();
211
+ if (value && value.length > 10)
212
+ return truncateTitle(value);
213
+ continue;
214
+ }
215
+ if (line.length > 10)
216
+ return truncateTitle(line);
217
+ }
218
+ return null;
219
+ }
220
+ function truncateTitle(text, maxLen = 120) {
221
+ if (text.length <= maxLen)
222
+ return text;
223
+ return text.slice(0, maxLen - 1) + '…';
224
+ }
148
225
  function collectAncestorNodeIds(nodesById, nodeId, remainingDepth) {
149
226
  if (remainingDepth === 0)
150
227
  return [nodeId];
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LocalWorkspaceAnchorV2 = void 0;
4
4
  const neverthrow_1 = require("neverthrow");
5
5
  const child_process_1 = require("child_process");
6
+ const crypto_1 = require("crypto");
6
7
  const util_1 = require("util");
7
8
  const url_1 = require("url");
8
9
  const execAsync = (0, util_1.promisify)(child_process_1.exec);
@@ -32,6 +33,13 @@ class LocalWorkspaceAnchorV2 {
32
33
  }
33
34
  async runGitCommands(cwd) {
34
35
  const anchors = [];
36
+ const repoRoot = await this.gitCommand('git rev-parse --show-toplevel', cwd);
37
+ if (!repoRoot)
38
+ return anchors;
39
+ const repoRootHash = hashRepoRoot(repoRoot);
40
+ if (repoRootHash) {
41
+ anchors.push({ key: 'repo_root_hash', value: repoRootHash });
42
+ }
35
43
  const branch = await this.gitCommand('git rev-parse --abbrev-ref HEAD', cwd);
36
44
  if (branch && branch !== 'HEAD') {
37
45
  anchors.push({ key: 'git_branch', value: branch });
@@ -57,6 +65,21 @@ class LocalWorkspaceAnchorV2 {
57
65
  }
58
66
  }
59
67
  exports.LocalWorkspaceAnchorV2 = LocalWorkspaceAnchorV2;
68
+ function hashRepoRoot(repoRoot) {
69
+ try {
70
+ const normalized = repoRoot.trim();
71
+ if (!normalized)
72
+ return null;
73
+ const digest = createSha256Hex(normalized);
74
+ return `sha256:${digest}`;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ function createSha256Hex(input) {
81
+ return (0, crypto_1.createHash)('sha256').update(input).digest('hex');
82
+ }
60
83
  function uriToFsPath(uri) {
61
84
  if (!uri.startsWith('file://'))
62
85
  return null;
@@ -28,32 +28,33 @@ export interface HealthySessionSummary {
28
28
  readonly recapSnippet: RecapSnippet | null;
29
29
  readonly observations: SessionObservations;
30
30
  readonly workflow: IdentifiedWorkflow;
31
+ readonly sessionTitle: string | null;
31
32
  readonly lastModifiedMs: number | null;
32
33
  readonly pendingStepId: string | null;
33
34
  readonly isComplete: boolean;
34
35
  }
35
- export type WhyMatched = 'matched_exact_id' | 'matched_head_sha' | 'matched_branch' | 'matched_notes' | 'matched_notes_partial' | 'matched_workflow_id' | 'recency_fallback';
36
+ export type WhyMatched = 'matched_exact_id' | 'matched_head_sha' | 'matched_repo_root' | 'matched_branch' | 'matched_notes' | 'matched_notes_partial' | 'matched_workflow_id' | 'recency_fallback';
36
37
  export type TierAssignment = {
37
38
  readonly tier: 0;
38
39
  readonly kind: 'matched_exact_id';
39
40
  readonly matchField: 'runId' | 'sessionId';
40
41
  } | {
41
42
  readonly tier: 1;
42
- readonly kind: 'matched_head_sha';
43
+ readonly kind: 'matched_notes';
43
44
  } | {
44
45
  readonly tier: 2;
45
- readonly kind: 'matched_branch';
46
- readonly matchType: 'exact' | 'prefix';
46
+ readonly kind: 'matched_notes_partial';
47
+ readonly matchRatio: number;
47
48
  } | {
48
49
  readonly tier: 3;
49
- readonly kind: 'matched_notes';
50
+ readonly kind: 'matched_workflow_id';
50
51
  } | {
51
52
  readonly tier: 4;
52
- readonly kind: 'matched_notes_partial';
53
- readonly matchRatio: number;
53
+ readonly kind: 'matched_head_sha';
54
54
  } | {
55
55
  readonly tier: 5;
56
- readonly kind: 'matched_workflow_id';
56
+ readonly kind: 'matched_branch';
57
+ readonly matchType: 'exact' | 'prefix';
57
58
  } | {
58
59
  readonly tier: 6;
59
60
  readonly kind: 'recency_fallback';
@@ -66,10 +67,13 @@ export declare function fuzzyQueryTokenMatchRatio(queryTokens: ReadonlySet<strin
66
67
  export interface ResumeQuery {
67
68
  readonly gitHeadSha?: string;
68
69
  readonly gitBranch?: string;
70
+ readonly repoRootHash?: string;
71
+ readonly sameWorkspaceOnly?: boolean;
69
72
  readonly freeTextQuery?: string;
70
73
  readonly runId?: string;
71
74
  readonly sessionId?: string;
72
75
  }
76
+ export declare function computeQueryRelevanceScore(summary: HealthySessionSummary, query: ResumeQuery): number;
73
77
  export declare function assignTier(summary: HealthySessionSummary, query: ResumeQuery): TierAssignment;
74
78
  export interface RankedResumeCandidate {
75
79
  readonly sessionId: SessionId;
@@ -81,9 +85,13 @@ export interface RankedResumeCandidate {
81
85
  readonly lastActivityEventIndex: number;
82
86
  readonly workflowHash: WorkflowHash;
83
87
  readonly workflowId: WorkflowId;
88
+ readonly sessionTitle: string | null;
89
+ readonly gitBranch: string | null;
84
90
  readonly pendingStepId: string | null;
85
91
  readonly isComplete: boolean;
86
92
  readonly lastModifiedMs: number | null;
93
+ readonly confidence: 'strong' | 'medium' | 'weak';
94
+ readonly matchExplanation: string;
87
95
  }
88
96
  export declare const MAX_RESUME_CANDIDATES = 5;
89
97
  export declare function rankResumeCandidates(summaries: readonly HealthySessionSummary[], query: ResumeQuery): readonly RankedResumeCandidate[];
@@ -7,6 +7,7 @@ exports.allQueryTokensMatch = allQueryTokensMatch;
7
7
  exports.queryTokenMatchRatio = queryTokenMatchRatio;
8
8
  exports.fuzzyTokenMatch = fuzzyTokenMatch;
9
9
  exports.fuzzyQueryTokenMatchRatio = fuzzyQueryTokenMatchRatio;
10
+ exports.computeQueryRelevanceScore = computeQueryRelevanceScore;
10
11
  exports.assignTier = assignTier;
11
12
  exports.rankResumeCandidates = rankResumeCandidates;
12
13
  const constants_js_1 = require("../durable-core/constants.js");
@@ -71,6 +72,159 @@ function fuzzyQueryTokenMatchRatio(queryTokens, candidateTokens) {
71
72
  return matched / queryTokens.size;
72
73
  }
73
74
  const MIN_PARTIAL_MATCH_RATIO = 0.4;
75
+ const LOW_SIGNAL_QUERY_TOKENS = new Set([
76
+ 'task',
77
+ 'dev',
78
+ 'work',
79
+ 'workflow',
80
+ 'coding',
81
+ 'feature',
82
+ 'phase',
83
+ 'implement',
84
+ 'implementation',
85
+ 'review',
86
+ 'fix',
87
+ 'bug',
88
+ ]);
89
+ function tokenSpecificityWeight(token) {
90
+ if (token.length <= 2)
91
+ return 0.2;
92
+ if (LOW_SIGNAL_QUERY_TOKENS.has(token))
93
+ return 0.35;
94
+ return Math.min(2.5, 1 + Math.max(0, token.length - 4) * 0.18);
95
+ }
96
+ function weightedFuzzyQueryTokenMatchRatio(queryTokens, candidateTokens) {
97
+ if (queryTokens.size === 0 || candidateTokens.size === 0)
98
+ return 0;
99
+ let matchedWeight = 0;
100
+ let totalWeight = 0;
101
+ for (const qt of queryTokens) {
102
+ const weight = tokenSpecificityWeight(qt);
103
+ totalWeight += weight;
104
+ if (candidateTokens.has(qt) || fuzzyTokenMatch(qt, candidateTokens)) {
105
+ matchedWeight += weight;
106
+ }
107
+ }
108
+ return totalWeight === 0 ? 0 : matchedWeight / totalWeight;
109
+ }
110
+ function repoScopeMatches(summary, query) {
111
+ return Boolean(query.repoRootHash &&
112
+ summary.observations.repoRootHash &&
113
+ query.repoRootHash === summary.observations.repoRootHash);
114
+ }
115
+ function shouldKeepSummary(summary, query) {
116
+ if (!query.sameWorkspaceOnly)
117
+ return true;
118
+ if (!query.repoRootHash)
119
+ return true;
120
+ return repoScopeMatches(summary, query);
121
+ }
122
+ function buildSearchableSessionText(summary) {
123
+ return [summary.sessionTitle, summary.recapSnippet]
124
+ .filter((part) => Boolean(part && part.trim().length > 0))
125
+ .join('\n\n');
126
+ }
127
+ function collectMatchReasons(summary, query, tier) {
128
+ const reasons = [tierToWhyMatched(tier)];
129
+ if (repoScopeMatches(summary, query) && !reasons.includes('matched_repo_root')) {
130
+ reasons.push('matched_repo_root');
131
+ }
132
+ if (query.gitHeadSha &&
133
+ summary.observations.gitHeadSha === query.gitHeadSha &&
134
+ !reasons.includes('matched_head_sha')) {
135
+ reasons.push('matched_head_sha');
136
+ }
137
+ if (query.gitBranch &&
138
+ summary.observations.gitBranch &&
139
+ (summary.observations.gitBranch === query.gitBranch ||
140
+ summary.observations.gitBranch.startsWith(query.gitBranch) ||
141
+ query.gitBranch.startsWith(summary.observations.gitBranch)) &&
142
+ !reasons.includes('matched_branch')) {
143
+ reasons.push('matched_branch');
144
+ }
145
+ return reasons;
146
+ }
147
+ function buildPreviewSnippet(summary, query) {
148
+ const previewSource = buildSearchableSessionText(summary);
149
+ if (!previewSource)
150
+ return '';
151
+ const queryTokens = query.freeTextQuery ? [...normalizeToTokens(query.freeTextQuery)] : [];
152
+ if (queryTokens.length === 0)
153
+ return summary.recapSnippet ?? previewSource;
154
+ const lower = previewSource.toLowerCase();
155
+ let bestIndex = -1;
156
+ for (const token of queryTokens) {
157
+ if (token.length < 3)
158
+ continue;
159
+ const idx = lower.indexOf(token);
160
+ if (idx !== -1 && (bestIndex === -1 || idx < bestIndex))
161
+ bestIndex = idx;
162
+ }
163
+ if (bestIndex === -1)
164
+ return summary.recapSnippet ?? previewSource;
165
+ const start = Math.max(0, bestIndex - 100);
166
+ const end = Math.min(previewSource.length, bestIndex + 180);
167
+ const slice = previewSource.slice(start, end).trim();
168
+ const prefix = start > 0 ? '...' : '';
169
+ const suffix = end < previewSource.length ? '...' : '';
170
+ return `${prefix}${slice}${suffix}`;
171
+ }
172
+ function deriveConfidence(tier, reasons) {
173
+ if (tier.kind === 'matched_exact_id' || tier.kind === 'matched_notes')
174
+ return 'strong';
175
+ if (tier.kind === 'matched_notes_partial' || tier.kind === 'matched_workflow_id')
176
+ return 'medium';
177
+ if (reasons.includes('matched_repo_root') || reasons.includes('matched_head_sha') || reasons.includes('matched_branch')) {
178
+ return 'medium';
179
+ }
180
+ return 'weak';
181
+ }
182
+ function buildMatchExplanation(tier, reasons, summary) {
183
+ const parts = [];
184
+ switch (tier.kind) {
185
+ case 'matched_exact_id':
186
+ parts.push(`Exact ${tier.matchField} match`);
187
+ break;
188
+ case 'matched_notes':
189
+ parts.push('Strong text match against session title/notes');
190
+ break;
191
+ case 'matched_notes_partial':
192
+ parts.push(`Partial text match (${Math.round(tier.matchRatio * 100)}%) against session title/notes`);
193
+ break;
194
+ case 'matched_workflow_id':
195
+ parts.push('Matched workflow type');
196
+ break;
197
+ case 'matched_head_sha':
198
+ parts.push('Matched current git commit');
199
+ break;
200
+ case 'matched_branch':
201
+ parts.push(tier.matchType === 'exact' ? 'Matched current git branch' : 'Partially matched current git branch');
202
+ break;
203
+ case 'recency_fallback':
204
+ parts.push('Recent session with no stronger explicit match');
205
+ break;
206
+ }
207
+ if (reasons.includes('matched_repo_root'))
208
+ parts.push('same workspace');
209
+ if (summary.isComplete)
210
+ parts.push('completed');
211
+ return parts.join('; ');
212
+ }
213
+ function computeQueryRelevanceScore(summary, query) {
214
+ const queryTokens = query.freeTextQuery ? normalizeToTokens(query.freeTextQuery) : null;
215
+ const repoBonus = repoScopeMatches(summary, query) ? 0.2 : 0;
216
+ if (!queryTokens || queryTokens.size === 0)
217
+ return repoBonus;
218
+ const sessionText = buildSearchableSessionText(summary);
219
+ const sessionTextRatio = sessionText
220
+ ? weightedFuzzyQueryTokenMatchRatio(queryTokens, normalizeToTokens(sessionText))
221
+ : 0;
222
+ const workflowRatio = weightedFuzzyQueryTokenMatchRatio(queryTokens, normalizeToTokens(String(summary.workflow.workflowId)));
223
+ const branchRatio = summary.observations.gitBranch
224
+ ? weightedFuzzyQueryTokenMatchRatio(queryTokens, normalizeToTokens(summary.observations.gitBranch))
225
+ : 0;
226
+ return Math.max(sessionTextRatio + repoBonus, workflowRatio * 0.75 + repoBonus, branchRatio * 0.5 + repoBonus);
227
+ }
74
228
  function assignTier(summary, query) {
75
229
  if (query.runId && summary.runId === query.runId) {
76
230
  return { tier: 0, kind: 'matched_exact_id', matchField: 'runId' };
@@ -78,37 +232,38 @@ function assignTier(summary, query) {
78
232
  if (query.sessionId && String(summary.sessionId) === query.sessionId) {
79
233
  return { tier: 0, kind: 'matched_exact_id', matchField: 'sessionId' };
80
234
  }
235
+ const queryTokens = query.freeTextQuery ? normalizeToTokens(query.freeTextQuery) : null;
236
+ if (queryTokens && queryTokens.size > 0) {
237
+ const sessionText = buildSearchableSessionText(summary);
238
+ if (sessionText) {
239
+ const sessionTextTokens = normalizeToTokens(sessionText);
240
+ if (allQueryTokensMatch(queryTokens, sessionTextTokens)) {
241
+ return { tier: 1, kind: 'matched_notes' };
242
+ }
243
+ const ratio = weightedFuzzyQueryTokenMatchRatio(queryTokens, sessionTextTokens);
244
+ if (ratio >= MIN_PARTIAL_MATCH_RATIO) {
245
+ return { tier: 2, kind: 'matched_notes_partial', matchRatio: ratio };
246
+ }
247
+ }
248
+ const workflowTokens = normalizeToTokens(String(summary.workflow.workflowId));
249
+ if (allQueryTokensMatch(queryTokens, workflowTokens)) {
250
+ return { tier: 3, kind: 'matched_workflow_id' };
251
+ }
252
+ const workflowRatio = weightedFuzzyQueryTokenMatchRatio(queryTokens, workflowTokens);
253
+ if (workflowRatio >= MIN_PARTIAL_MATCH_RATIO) {
254
+ return { tier: 3, kind: 'matched_workflow_id' };
255
+ }
256
+ }
81
257
  if (query.gitHeadSha && summary.observations.gitHeadSha === query.gitHeadSha) {
82
- return { tier: 1, kind: 'matched_head_sha' };
258
+ return { tier: 4, kind: 'matched_head_sha' };
83
259
  }
84
260
  if (query.gitBranch && summary.observations.gitBranch) {
85
261
  if (summary.observations.gitBranch === query.gitBranch) {
86
- return { tier: 2, kind: 'matched_branch', matchType: 'exact' };
262
+ return { tier: 5, kind: 'matched_branch', matchType: 'exact' };
87
263
  }
88
264
  if (summary.observations.gitBranch.startsWith(query.gitBranch) ||
89
265
  query.gitBranch.startsWith(summary.observations.gitBranch)) {
90
- return { tier: 2, kind: 'matched_branch', matchType: 'prefix' };
91
- }
92
- }
93
- const queryTokens = query.freeTextQuery ? normalizeToTokens(query.freeTextQuery) : null;
94
- if (queryTokens && queryTokens.size > 0 && summary.recapSnippet) {
95
- const noteTokens = normalizeToTokens(summary.recapSnippet);
96
- if (allQueryTokensMatch(queryTokens, noteTokens)) {
97
- return { tier: 3, kind: 'matched_notes' };
98
- }
99
- const ratio = fuzzyQueryTokenMatchRatio(queryTokens, noteTokens);
100
- if (ratio >= MIN_PARTIAL_MATCH_RATIO) {
101
- return { tier: 4, kind: 'matched_notes_partial', matchRatio: ratio };
102
- }
103
- }
104
- if (queryTokens && queryTokens.size > 0 && summary.workflow.kind === 'identified') {
105
- const workflowTokens = normalizeToTokens(String(summary.workflow.workflowId));
106
- if (allQueryTokensMatch(queryTokens, workflowTokens)) {
107
- return { tier: 5, kind: 'matched_workflow_id' };
108
- }
109
- const ratio = fuzzyQueryTokenMatchRatio(queryTokens, workflowTokens);
110
- if (ratio >= MIN_PARTIAL_MATCH_RATIO) {
111
- return { tier: 5, kind: 'matched_workflow_id' };
266
+ return { tier: 5, kind: 'matched_branch', matchType: 'prefix' };
112
267
  }
113
268
  }
114
269
  return { tier: 6, kind: 'recency_fallback' };
@@ -126,16 +281,25 @@ function tierToWhyMatched(tier) {
126
281
  }
127
282
  exports.MAX_RESUME_CANDIDATES = 5;
128
283
  function rankResumeCandidates(summaries, query) {
129
- const withTier = summaries.map((summary) => ({
284
+ const withTier = summaries
285
+ .filter((summary) => shouldKeepSummary(summary, query))
286
+ .map((summary) => ({
130
287
  summary,
131
288
  tier: assignTier(summary, query),
289
+ queryScore: computeQueryRelevanceScore(summary, query),
132
290
  }));
133
291
  const sorted = [...withTier].sort((a, b) => {
134
292
  if (a.tier.tier !== b.tier.tier)
135
293
  return a.tier.tier - b.tier.tier;
294
+ const aSameRepo = repoScopeMatches(a.summary, query);
295
+ const bSameRepo = repoScopeMatches(b.summary, query);
296
+ if (aSameRepo !== bSameRepo)
297
+ return aSameRepo ? -1 : 1;
136
298
  if (a.summary.isComplete !== b.summary.isComplete) {
137
299
  return a.summary.isComplete ? 1 : -1;
138
300
  }
301
+ if (a.queryScore !== b.queryScore)
302
+ return b.queryScore - a.queryScore;
139
303
  if (a.tier.kind === 'matched_notes_partial' && b.tier.kind === 'matched_notes_partial') {
140
304
  if (a.tier.matchRatio !== b.tier.matchRatio)
141
305
  return b.tier.matchRatio - a.tier.matchRatio;
@@ -146,18 +310,25 @@ function rankResumeCandidates(summaries, query) {
146
310
  return actB - actA;
147
311
  return String(a.summary.sessionId).localeCompare(String(b.summary.sessionId));
148
312
  });
149
- return sorted.slice(0, exports.MAX_RESUME_CANDIDATES).map(({ summary, tier }) => ({
150
- sessionId: summary.sessionId,
151
- runId: summary.runId,
152
- preferredTipNodeId: summary.preferredTip.nodeId,
153
- snippet: summary.recapSnippet ?? '',
154
- whyMatched: [tierToWhyMatched(tier)],
155
- tierAssignment: tier,
156
- lastActivityEventIndex: summary.preferredTip.lastActivityEventIndex,
157
- workflowHash: summary.workflow.workflowHash,
158
- workflowId: summary.workflow.workflowId,
159
- pendingStepId: summary.pendingStepId,
160
- isComplete: summary.isComplete,
161
- lastModifiedMs: summary.lastModifiedMs,
162
- }));
313
+ return sorted.slice(0, exports.MAX_RESUME_CANDIDATES).map(({ summary, tier }) => {
314
+ const whyMatched = collectMatchReasons(summary, query, tier);
315
+ return {
316
+ sessionId: summary.sessionId,
317
+ runId: summary.runId,
318
+ preferredTipNodeId: summary.preferredTip.nodeId,
319
+ snippet: buildPreviewSnippet(summary, query),
320
+ whyMatched,
321
+ tierAssignment: tier,
322
+ lastActivityEventIndex: summary.preferredTip.lastActivityEventIndex,
323
+ workflowHash: summary.workflow.workflowHash,
324
+ workflowId: summary.workflow.workflowId,
325
+ sessionTitle: summary.sessionTitle,
326
+ gitBranch: summary.observations.gitBranch,
327
+ pendingStepId: summary.pendingStepId,
328
+ isComplete: summary.isComplete,
329
+ lastModifiedMs: summary.lastModifiedMs,
330
+ confidence: deriveConfidence(tier, whyMatched),
331
+ matchExplanation: buildMatchExplanation(tier, whyMatched, summary),
332
+ };
333
+ });
163
334
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exaudeus/workrail",
3
- "version": "3.7.2",
3
+ "version": "3.7.4",
4
4
  "description": "Step-by-step workflow enforcement for AI agents via MCP",
5
5
  "license": "MIT",
6
6
  "repository": {