@exaudeus/workrail 3.17.0 → 3.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +13 -0
  2. package/dist/application/services/validation-engine.js +7 -11
  3. package/dist/application/services/workflow-compiler.js +9 -11
  4. package/dist/console/assets/index-DMaX2-CW.js +28 -0
  5. package/dist/console/assets/index-ibLhWBmX.css +1 -0
  6. package/dist/console/index.html +2 -2
  7. package/dist/infrastructure/storage/workflow-resolution.js +6 -6
  8. package/dist/manifest.json +55 -55
  9. package/dist/mcp/handlers/v2-advance-core/assessment-consequences.d.ts +1 -1
  10. package/dist/mcp/handlers/v2-advance-core/assessment-consequences.js +14 -11
  11. package/dist/mcp/handlers/v2-advance-core/assessment-validation.d.ts +5 -3
  12. package/dist/mcp/handlers/v2-advance-core/assessment-validation.js +109 -87
  13. package/dist/mcp/handlers/v2-advance-core/input-validation.d.ts +0 -4
  14. package/dist/mcp/handlers/v2-advance-core/input-validation.js +1 -3
  15. package/dist/mcp/handlers/v2-advance-core/outcome-blocked.js +8 -3
  16. package/dist/mcp/handlers/v2-advance-core/outcome-success.js +8 -3
  17. package/dist/mcp/handlers/v2-execution/replay.js +4 -4
  18. package/dist/mcp/output-schemas.d.ts +12 -12
  19. package/dist/mcp/output-schemas.js +10 -11
  20. package/dist/mcp-server.js +0 -0
  21. package/dist/types/workflow-source.js +1 -1
  22. package/dist/v2/durable-core/domain/observation-builder.d.ts +0 -3
  23. package/dist/v2/durable-core/domain/observation-builder.js +1 -3
  24. package/dist/v2/durable-core/domain/prompt-renderer.js +9 -1
  25. package/dist/v2/infra/local/session-summary-provider/index.js +1 -2
  26. package/dist/v2/projections/resume-ranking.d.ts +0 -1
  27. package/dist/v2/usecases/console-routes.js +65 -17
  28. package/dist/v2/usecases/console-service.js +4 -14
  29. package/dist/v2/usecases/console-types.d.ts +15 -1
  30. package/dist/v2/usecases/worktree-service.d.ts +1 -0
  31. package/dist/v2/usecases/worktree-service.js +143 -15
  32. package/package.json +3 -2
  33. package/spec/authoring-spec.json +3 -3
  34. package/spec/workflow.schema.json +1 -2
  35. package/workflows/coding-task-workflow-agentic.lean.v2.json +132 -1
  36. package/workflows/mr-review-workflow.agentic.v2.json +24 -10
  37. package/workflows/workflow-for-workflows.json +558 -448
  38. package/dist/console/assets/index-BZNM03t1.css +0 -1
  39. package/dist/console/assets/index-BwJelCXK.js +0 -28
@@ -8,6 +8,7 @@ const express_1 = __importDefault(require("express"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const fs_1 = __importDefault(require("fs"));
10
10
  const worktree_service_js_1 = require("./worktree-service.js");
11
+ const workflow_js_1 = require("../../types/workflow.js");
11
12
  const dev_mode_js_1 = require("../../mcp/dev-mode.js");
12
13
  function watchSessionsDir(sessionsDir, onChanged) {
13
14
  try {
@@ -71,6 +72,22 @@ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuff
71
72
  }, 200);
72
73
  }
73
74
  const stopWatcher = watchSessionsDir(consoleService.getSessionsDir(), broadcastChange);
75
+ let enrichmentBroadcastTimer = null;
76
+ (0, worktree_service_js_1.setEnrichmentCompleteCallback)(() => {
77
+ if (enrichmentBroadcastTimer !== null)
78
+ clearTimeout(enrichmentBroadcastTimer);
79
+ enrichmentBroadcastTimer = setTimeout(() => {
80
+ enrichmentBroadcastTimer = null;
81
+ for (const client of sseClients) {
82
+ try {
83
+ client.write('data: {"type":"worktrees-updated"}\n\n');
84
+ }
85
+ catch {
86
+ sseClients.delete(client);
87
+ }
88
+ }
89
+ }, 2000);
90
+ });
74
91
  app.get('/api/v2/workspace/events', (req, res) => {
75
92
  res.setHeader('Content-Type', 'text/event-stream');
76
93
  res.setHeader('Cache-Control', 'no-cache');
@@ -97,35 +114,66 @@ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuff
97
114
  result.match((data) => res.json({ success: true, data }), (error) => res.status(500).json({ success: false, error: error.message }));
98
115
  });
99
116
  let cwdRepoRootPromise = null;
100
- const REPO_ROOTS_TTL_MS = 60000;
101
- const REPO_ROOT_SESSION_STALENESS_MS = 30 * 24 * 60 * 60 * 1000;
102
117
  let cachedRepoRoots = [];
103
118
  let repoRootsExpiresAt = 0;
119
+ const REPO_ROOTS_TTL_MS = 60000;
120
+ async function discoverMainRepoRoots() {
121
+ const dataDir = process.env['WORKRAIL_DATA_DIR']
122
+ ?? path_1.default.join(process.env.HOME ?? '/tmp', '.workrail', 'data');
123
+ const rootsFile = path_1.default.join(dataDir, 'workflow-sources', 'remembered-roots.json');
124
+ let workspacePaths = [];
125
+ try {
126
+ const raw = await fs_1.default.promises.readFile(rootsFile, 'utf8');
127
+ const parsed = JSON.parse(raw);
128
+ workspacePaths = (parsed.roots ?? []).map((r) => r.path).filter(Boolean);
129
+ }
130
+ catch {
131
+ return [];
132
+ }
133
+ const resolved = await Promise.all(workspacePaths.map((p) => (0, worktree_service_js_1.resolveRepoRoot)(p)));
134
+ const roots = new Set(resolved.filter((r) => r !== null));
135
+ return [...roots];
136
+ }
137
+ const WORKTREES_REQUEST_TIMEOUT_MS = 12000;
104
138
  app.get('/api/v2/worktrees', async (_req, res) => {
139
+ let timeoutId = null;
140
+ const timeoutPromise = new Promise((_, reject) => {
141
+ timeoutId = setTimeout(() => reject(new Error('worktrees scan timeout')), WORKTREES_REQUEST_TIMEOUT_MS);
142
+ });
105
143
  try {
106
144
  const sessionResult = await consoleService.getSessionList();
107
145
  const sessions = sessionResult.isOk() ? sessionResult.value.sessions : [];
108
146
  const activeSessions = (0, worktree_service_js_1.buildActiveSessionCounts)(sessions);
147
+ cwdRepoRootPromise ?? (cwdRepoRootPromise = (0, worktree_service_js_1.resolveRepoRoot)(process.cwd()));
109
148
  if (Date.now() > repoRootsExpiresAt) {
110
- cwdRepoRootPromise ?? (cwdRepoRootPromise = (0, worktree_service_js_1.resolveRepoRoot)(process.cwd()));
111
- const cwdRoot = await cwdRepoRootPromise;
112
- const cutoffMs = Date.now() - REPO_ROOT_SESSION_STALENESS_MS;
113
- const rawRoots = sessions
114
- .filter(s => s.lastModifiedMs >= cutoffMs)
115
- .map(s => s.repoRoot)
116
- .filter((r) => r !== null);
117
- const resolvedRoots = await Promise.all(rawRoots.map(r => (0, worktree_service_js_1.resolveRepoRoot)(r)));
118
- const repoRootSet = new Set(resolvedRoots.filter((r) => r !== null));
149
+ const [cwdRoot, discovered] = await Promise.all([
150
+ cwdRepoRootPromise,
151
+ discoverMainRepoRoots(),
152
+ ]);
153
+ const repoRootsSet = new Set(discovered);
119
154
  if (cwdRoot !== null)
120
- repoRootSet.add(cwdRoot);
121
- cachedRepoRoots = [...repoRootSet];
155
+ repoRootsSet.add(cwdRoot);
156
+ cachedRepoRoots = [...repoRootsSet];
122
157
  repoRootsExpiresAt = Date.now() + REPO_ROOTS_TTL_MS;
123
158
  }
124
- const data = await (0, worktree_service_js_1.getWorktreeList)(cachedRepoRoots, activeSessions);
159
+ const repoRoots = cachedRepoRoots;
160
+ const data = await Promise.race([
161
+ (0, worktree_service_js_1.getWorktreeList)(repoRoots, activeSessions),
162
+ timeoutPromise,
163
+ ]);
164
+ if (timeoutId !== null)
165
+ clearTimeout(timeoutId);
125
166
  res.json({ success: true, data });
126
167
  }
127
168
  catch (e) {
128
- res.status(500).json({ success: false, error: e instanceof Error ? e.message : String(e) });
169
+ if (timeoutId !== null)
170
+ clearTimeout(timeoutId);
171
+ if (e instanceof Error && e.message === 'worktrees scan timeout') {
172
+ res.json({ success: true, data: { repos: [] } });
173
+ }
174
+ else {
175
+ res.status(500).json({ success: false, error: e instanceof Error ? e.message : String(e) });
176
+ }
129
177
  }
130
178
  });
131
179
  app.get('/api/v2/sessions/:sessionId', async (req, res) => {
@@ -162,7 +210,7 @@ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuff
162
210
  description: definition.description,
163
211
  version: definition.version,
164
212
  tags: tagEntry?.tags ?? [],
165
- source,
213
+ source: (0, workflow_js_1.toWorkflowSourceInfo)(source),
166
214
  ...(definition.about !== undefined ? { about: definition.about } : {}),
167
215
  ...(definition.examples?.length ? { examples: [...definition.examples] } : {}),
168
216
  };
@@ -194,7 +242,7 @@ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuff
194
242
  description: definition.description,
195
243
  version: definition.version,
196
244
  tags: tagEntry?.tags ?? [],
197
- source,
245
+ source: (0, workflow_js_1.toWorkflowSourceInfo)(source),
198
246
  stepCount: definition.steps.length,
199
247
  ...(definition.about !== undefined ? { about: definition.about } : {}),
200
248
  ...(definition.examples?.length ? { examples: [...definition.examples] } : {}),
@@ -16,7 +16,10 @@ const run_execution_trace_js_1 = require("../projections/run-execution-trace.js"
16
16
  const constants_js_1 = require("../durable-core/constants.js");
17
17
  const index_js_1 = require("../durable-core/ids/index.js");
18
18
  const MAX_SESSIONS_TO_LOAD = 500;
19
- const DORMANCY_THRESHOLD_MS = 3 * 24 * 60 * 60 * 1000;
19
+ const DORMANCY_THRESHOLD_MS = (() => {
20
+ const override = parseInt(process.env['WORKRAIL_DORMANCY_THRESHOLD_MS'] ?? '', 10);
21
+ return Number.isFinite(override) && override > 0 ? override : 60 * 60 * 1000;
22
+ })();
20
23
  class ConsoleService {
21
24
  constructor(ports) {
22
25
  this.ports = ports;
@@ -337,16 +340,6 @@ function extractGitBranch(events) {
337
340
  }
338
341
  return null;
339
342
  }
340
- function extractRepoRoot(events) {
341
- for (const e of events) {
342
- if (e.kind !== constants_js_1.EVENT_KIND.OBSERVATION_RECORDED)
343
- continue;
344
- if (e.data.key === 'repo_root') {
345
- return e.data.value.value;
346
- }
347
- }
348
- return null;
349
- }
350
343
  function truncateTitle(text, maxLen = 120) {
351
344
  if (text.length <= maxLen)
352
345
  return text;
@@ -373,7 +366,6 @@ function projectSessionSummary(sessionId, truth, completionByRunId, workflowName
373
366
  const gapsRes = sortedEventsRes.isOk() ? (0, gaps_js_1.projectGapsV2)(sortedEventsRes.value) : (0, neverthrow_2.err)(sortedEventsRes.error);
374
367
  const sessionTitle = sortedEventsRes.isOk() ? deriveSessionTitle(sortedEventsRes.value) : null;
375
368
  const gitBranch = extractGitBranch(events);
376
- const repoRoot = extractRepoRoot(events);
377
369
  const runs = Object.values(dag.runsById);
378
370
  const run = runs[0];
379
371
  if (!run) {
@@ -393,7 +385,6 @@ function projectSessionSummary(sessionId, truth, completionByRunId, workflowName
393
385
  hasUnresolvedGaps: false,
394
386
  recapSnippet: null,
395
387
  gitBranch,
396
- repoRoot,
397
388
  lastModifiedMs,
398
389
  };
399
390
  }
@@ -436,7 +427,6 @@ function projectSessionSummary(sessionId, truth, completionByRunId, workflowName
436
427
  hasUnresolvedGaps,
437
428
  recapSnippet,
438
429
  gitBranch,
439
- repoRoot,
440
430
  lastModifiedMs,
441
431
  };
442
432
  }
@@ -16,7 +16,6 @@ export interface ConsoleSessionSummary {
16
16
  readonly hasUnresolvedGaps: boolean;
17
17
  readonly recapSnippet: string | null;
18
18
  readonly gitBranch: string | null;
19
- readonly repoRoot: string | null;
20
19
  readonly lastModifiedMs: number;
21
20
  }
22
21
  export interface ConsoleSessionListResponse {
@@ -112,6 +111,20 @@ export interface ChangedFile {
112
111
  readonly status: FileChangeStatus;
113
112
  readonly path: string;
114
113
  }
114
+ export interface WorktreeEnrichment {
115
+ readonly headHash: string;
116
+ readonly headMessage: string;
117
+ readonly headTimestampMs: number;
118
+ readonly changedCount: number;
119
+ readonly changedFiles: readonly ChangedFile[];
120
+ readonly aheadCount: number;
121
+ readonly unpushedCommits: readonly {
122
+ readonly hash: string;
123
+ readonly message: string;
124
+ }[];
125
+ readonly isMerged: boolean;
126
+ readonly description: string;
127
+ }
115
128
  export interface ConsoleWorktreeSummary {
116
129
  readonly path: string;
117
130
  readonly name: string;
@@ -129,6 +142,7 @@ export interface ConsoleWorktreeSummary {
129
142
  readonly isMerged: boolean;
130
143
  readonly activeSessionCount: number;
131
144
  readonly description?: string;
145
+ readonly enrichment: WorktreeEnrichment | null;
132
146
  }
133
147
  export interface ConsoleRepoWorktrees {
134
148
  readonly repoName: string;
@@ -7,4 +7,5 @@ export declare function buildActiveSessionCounts(sessions: ReadonlyArray<{
7
7
  gitBranch: string | null;
8
8
  status: ConsoleSessionStatus;
9
9
  }>): ActiveSessionsByBranch;
10
+ export declare function setEnrichmentCompleteCallback(cb: () => void): void;
10
11
  export declare function getWorktreeList(repoRoots: readonly string[], activeSessions: ActiveSessionsByBranch): Promise<ConsoleWorktreeListResponse>;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resolveRepoRoot = resolveRepoRoot;
4
4
  exports.buildActiveSessionCounts = buildActiveSessionCounts;
5
+ exports.setEnrichmentCompleteCallback = setEnrichmentCompleteCallback;
5
6
  exports.getWorktreeList = getWorktreeList;
6
7
  const child_process_1 = require("child_process");
7
8
  const util_1 = require("util");
@@ -9,7 +10,12 @@ const path_1 = require("path");
9
10
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
10
11
  const GIT_TIMEOUT_MS = 5000;
11
12
  function isExecError(e) {
12
- return e instanceof Error && 'killed' in e;
13
+ if (!(e instanceof Error))
14
+ return false;
15
+ if ('killed' in e)
16
+ return true;
17
+ const sys = e.syscall ?? '';
18
+ return sys.startsWith('spawn');
13
19
  }
14
20
  async function git(cwd, args) {
15
21
  try {
@@ -54,7 +60,7 @@ function acquireEnrichmentSlot() {
54
60
  resolve();
55
61
  }
56
62
  else {
57
- enrichmentQueue.push(() => { activeEnrichments++; resolve(); });
63
+ enrichmentQueue.push(resolve);
58
64
  }
59
65
  });
60
66
  }
@@ -67,6 +73,29 @@ function releaseEnrichmentSlot() {
67
73
  activeEnrichments--;
68
74
  }
69
75
  }
76
+ const MAX_BACKGROUND_ENRICHMENTS = 16;
77
+ let activeBackgroundEnrichments = 0;
78
+ const backgroundEnrichmentQueue = [];
79
+ function acquireBackgroundSlot() {
80
+ return new Promise((resolve) => {
81
+ if (activeBackgroundEnrichments < MAX_BACKGROUND_ENRICHMENTS) {
82
+ activeBackgroundEnrichments++;
83
+ resolve();
84
+ }
85
+ else {
86
+ backgroundEnrichmentQueue.push(resolve);
87
+ }
88
+ });
89
+ }
90
+ function releaseBackgroundSlot() {
91
+ const next = backgroundEnrichmentQueue.shift();
92
+ if (next) {
93
+ next();
94
+ }
95
+ else {
96
+ activeBackgroundEnrichments--;
97
+ }
98
+ }
70
99
  function parseFileStatus(xy) {
71
100
  if (xy === '??')
72
101
  return 'untracked';
@@ -122,8 +151,8 @@ async function enrichWorktree(wt) {
122
151
  wt.branch !== 'main' &&
123
152
  mergedBranchesRaw !== null &&
124
153
  mergedBranchesRaw.split('\n').some(line => line.trim() === wt.branch);
125
- const branchDescription = descriptionRaw?.trim() ?? '';
126
- return { headHash, headMessage, headTimestampMs, changedCount, changedFiles, aheadCount, unpushedCommits, isMerged, branchDescription };
154
+ const description = descriptionRaw?.trim() ?? '';
155
+ return { headHash, headMessage, headTimestampMs, changedCount, changedFiles, aheadCount, unpushedCommits, isMerged, description };
127
156
  }
128
157
  async function resolveRepoRoot(path) {
129
158
  const commonDir = await git(path, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
@@ -131,18 +160,39 @@ async function resolveRepoRoot(path) {
131
160
  return null;
132
161
  return commonDir.replace(/\/\.git\/?$/, '') || null;
133
162
  }
134
- async function enrichRepo(repoRoot, activeSessions) {
163
+ async function buildFastWorktrees(repoRoot) {
164
+ const porcelain = await git(repoRoot, ['worktree', 'list', '--porcelain']);
165
+ if (porcelain === null)
166
+ return null;
167
+ const rawWorktrees = parseWorktreePorcelain(porcelain);
168
+ return rawWorktrees.map((wt) => ({
169
+ path: wt.path,
170
+ name: (0, path_1.basename)(wt.path),
171
+ branch: wt.branch,
172
+ headHash: wt.head.slice(0, 7),
173
+ headMessage: '',
174
+ headTimestampMs: 0,
175
+ changedCount: 0,
176
+ changedFiles: [],
177
+ aheadCount: 0,
178
+ unpushedCommits: [],
179
+ isMerged: false,
180
+ activeSessionCount: 0,
181
+ enrichment: null,
182
+ }));
183
+ }
184
+ async function enrichRepo(repoRoot) {
135
185
  const porcelain = await git(repoRoot, ['worktree', 'list', '--porcelain']);
136
186
  if (porcelain === null)
137
187
  return null;
138
188
  const rawWorktrees = parseWorktreePorcelain(porcelain);
139
189
  const results = await Promise.allSettled(rawWorktrees.map(async (wt) => {
140
- await acquireEnrichmentSlot();
190
+ await acquireBackgroundSlot();
141
191
  try {
142
192
  return await enrichWorktree(wt);
143
193
  }
144
194
  finally {
145
- releaseEnrichmentSlot();
195
+ releaseBackgroundSlot();
146
196
  }
147
197
  }));
148
198
  const worktrees = rawWorktrees.flatMap((wt, i) => {
@@ -152,6 +202,17 @@ async function enrichRepo(repoRoot, activeSessions) {
152
202
  return [];
153
203
  }
154
204
  const e = result.value;
205
+ const enrichment = {
206
+ headHash: e.headHash,
207
+ headMessage: e.headMessage,
208
+ headTimestampMs: e.headTimestampMs,
209
+ changedCount: e.changedCount,
210
+ changedFiles: e.changedFiles,
211
+ aheadCount: e.aheadCount,
212
+ unpushedCommits: e.unpushedCommits,
213
+ isMerged: e.isMerged,
214
+ description: e.description,
215
+ };
155
216
  return [{
156
217
  path: wt.path,
157
218
  name: (0, path_1.basename)(wt.path),
@@ -164,13 +225,12 @@ async function enrichRepo(repoRoot, activeSessions) {
164
225
  aheadCount: e.aheadCount,
165
226
  unpushedCommits: e.unpushedCommits,
166
227
  isMerged: e.isMerged,
167
- activeSessionCount: wt.branch ? (activeSessions.counts.get(wt.branch) ?? 0) : 0,
168
- ...(e.branchDescription ? { description: e.branchDescription } : {}),
228
+ activeSessionCount: 0,
229
+ ...(e.description ? { description: e.description } : {}),
230
+ enrichment,
169
231
  }];
170
232
  });
171
233
  return [...worktrees].sort((a, b) => {
172
- if (b.activeSessionCount !== a.activeSessionCount)
173
- return b.activeSessionCount - a.activeSessionCount;
174
234
  if (b.changedCount !== a.changedCount)
175
235
  return b.changedCount - a.changedCount;
176
236
  return b.headTimestampMs - a.headTimestampMs;
@@ -185,12 +245,20 @@ function buildActiveSessionCounts(sessions) {
185
245
  }
186
246
  return { counts };
187
247
  }
188
- async function getWorktreeList(repoRoots, activeSessions) {
248
+ const WORKTREE_CACHE_TTL_MS = 45000;
249
+ let worktreeCache = null;
250
+ let backgroundEnrichmentInFlight = false;
251
+ const BACKGROUND_ENRICHMENT_TIMEOUT_MS = 120000;
252
+ let onEnrichmentComplete = null;
253
+ function setEnrichmentCompleteCallback(cb) {
254
+ onEnrichmentComplete = cb;
255
+ }
256
+ async function scanRepos(repoRoots) {
189
257
  const repoResults = await Promise.allSettled(repoRoots.map(async (repoRoot) => {
190
- const worktrees = await enrichRepo(repoRoot, activeSessions);
258
+ const worktrees = await enrichRepo(repoRoot);
191
259
  return { repoRoot, worktrees };
192
260
  }));
193
- const repos = repoResults.flatMap((result) => {
261
+ return repoResults.flatMap((result) => {
194
262
  if (result.status === 'rejected') {
195
263
  console.warn(`[WorktreeService] Failed to enrich repo:`, result.reason);
196
264
  return [];
@@ -204,7 +272,33 @@ async function getWorktreeList(repoRoots, activeSessions) {
204
272
  worktrees,
205
273
  }];
206
274
  });
207
- const sortedRepos = [...repos].sort((a, b) => {
275
+ }
276
+ async function runBackgroundEnrichment(repoRoots, repoRootsKey) {
277
+ try {
278
+ const enriched = await Promise.race([
279
+ scanRepos(repoRoots),
280
+ new Promise((_, reject) => setTimeout(() => reject(new Error('background enrichment timeout')), BACKGROUND_ENRICHMENT_TIMEOUT_MS)),
281
+ ]);
282
+ if (worktreeCache?.repoRootsKey === repoRootsKey) {
283
+ worktreeCache = { ...worktreeCache, enrichedRepos: enriched };
284
+ onEnrichmentComplete?.();
285
+ }
286
+ }
287
+ catch {
288
+ }
289
+ finally {
290
+ backgroundEnrichmentInFlight = false;
291
+ }
292
+ }
293
+ function applyActiveSessionsAndSort(repos, activeSessions) {
294
+ const reposWithActiveSessions = repos.map((repo) => ({
295
+ ...repo,
296
+ worktrees: repo.worktrees.map((wt) => ({
297
+ ...wt,
298
+ activeSessionCount: wt.branch ? (activeSessions.counts.get(wt.branch) ?? 0) : 0,
299
+ })),
300
+ }));
301
+ const sortedRepos = [...reposWithActiveSessions].sort((a, b) => {
208
302
  const aActive = a.worktrees.some(w => w.activeSessionCount > 0) ? 0 : 1;
209
303
  const bActive = b.worktrees.some(w => w.activeSessionCount > 0) ? 0 : 1;
210
304
  if (aActive !== bActive)
@@ -213,3 +307,37 @@ async function getWorktreeList(repoRoots, activeSessions) {
213
307
  });
214
308
  return { repos: sortedRepos };
215
309
  }
310
+ async function getWorktreeList(repoRoots, activeSessions) {
311
+ const repoRootsKey = [...repoRoots].sort().join(',');
312
+ const nowMs = Date.now();
313
+ const isCacheValid = worktreeCache !== null &&
314
+ worktreeCache.repoRootsKey === repoRootsKey &&
315
+ nowMs - worktreeCache.cachedAtMs < WORKTREE_CACHE_TTL_MS;
316
+ if (!isCacheValid) {
317
+ const fastRepoResults = await Promise.allSettled(repoRoots.map(async (repoRoot) => {
318
+ const worktrees = await buildFastWorktrees(repoRoot);
319
+ return { repoRoot, worktrees };
320
+ }));
321
+ const fastRepos = fastRepoResults.flatMap((result) => {
322
+ if (result.status === 'rejected')
323
+ return [];
324
+ const { repoRoot, worktrees } = result.value;
325
+ if (!worktrees || worktrees.length === 0)
326
+ return [];
327
+ return [{ repoName: (0, path_1.basename)(repoRoot), repoRoot, worktrees }];
328
+ });
329
+ worktreeCache = {
330
+ unenrichedRepos: fastRepos,
331
+ enrichedRepos: null,
332
+ cachedAtMs: nowMs,
333
+ repoRootsKey,
334
+ };
335
+ if (!backgroundEnrichmentInFlight) {
336
+ backgroundEnrichmentInFlight = true;
337
+ void runBackgroundEnrichment(repoRoots, repoRootsKey);
338
+ }
339
+ }
340
+ const cache = worktreeCache;
341
+ const repos = cache.enrichedRepos ?? cache.unenrichedRepos;
342
+ return applyActiveSessionsAndSort(repos, activeSessions);
343
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exaudeus/workrail",
3
- "version": "3.17.0",
3
+ "version": "3.18.1",
4
4
  "description": "Step-by-step workflow enforcement for AI agents via MCP",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,7 +28,8 @@
28
28
  "web"
29
29
  ],
30
30
  "scripts": {
31
- "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true});\" && tsc -p tsconfig.build.json && npm run console:build",
31
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true});\" && tsc -p tsconfig.build.json && npm run console:build && node -e \"require('fs').chmodSync('dist/mcp-server.js',0o755)\"",
32
+ "prepack": "node -e \"require('fs').chmodSync('dist/mcp-server.js',0o755)\"",
32
33
  "console:build": "cd console && npm install && npm run build",
33
34
  "console:dev": "cd console && npm run dev",
34
35
  "build:all": "npm run build",
@@ -998,11 +998,11 @@
998
998
  "step.assessmentRefs",
999
999
  "step.assessmentConsequences"
1000
1000
  ],
1001
- "rule": "V1 supports exactly one assessmentRef per step and at most one assessmentConsequences entry per step. Use anyEqualsLevel as the trigger -- the engine checks all submitted dimensions and fires if any equals that level.",
1002
- "why": "anyEqualsLevel is the only trigger form. It works for both single-dimension and multi-dimension assessments without requiring the author to choose between two forms.",
1001
+ "rule": "A step may declare one or more assessmentRefs and at most one assessmentConsequences entry. When assessmentConsequences is present, at least one ref is required. Use anyEqualsLevel as the trigger -- the engine checks all submitted dimensions across all referenced assessments and fires if any equals that level.",
1002
+ "why": "Multiple refs allow composing separate orthogonal assessment definitions (e.g. quality-gate + coverage-gate) behind a single blocking consequence, without forcing unrelated dimensions into one monolithic definition.",
1003
1003
  "enforcement": ["schema"],
1004
1004
  "checks": [
1005
- "No more than one assessmentRefs entry per step.",
1005
+ "At least one assessmentRefs entry when assessmentConsequences is present.",
1006
1006
  "No more than one assessmentConsequences entry per step.",
1007
1007
  "The consequence uses anyEqualsLevel to declare which level blocks -- not a named dimension."
1008
1008
  ],
@@ -456,14 +456,13 @@
456
456
  },
457
457
  "assessmentRefs": {
458
458
  "type": "array",
459
- "description": "References to workflow-level assessment definitions expected for this step. V1 supports exactly one assessmentRef per step.",
459
+ "description": "References to workflow-level assessment definitions expected for this step. When assessmentConsequences is present, at least one ref is required; multiple refs are supported and the consequence fires if any dimension across any referenced assessment equals the trigger level.",
460
460
  "items": {
461
461
  "type": "string",
462
462
  "minLength": 1,
463
463
  "maxLength": 64
464
464
  },
465
465
  "minItems": 1,
466
- "maxItems": 1,
467
466
  "uniqueItems": true
468
467
  },
469
468
  "assessmentConsequences": {