@exaudeus/workrail 3.40.0 → 3.41.0

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 (75) hide show
  1. package/dist/cli/commands/init.js +0 -3
  2. package/dist/cli-worktrain.js +8 -0
  3. package/dist/cli.js +0 -18
  4. package/dist/config/app-config.d.ts +0 -16
  5. package/dist/config/app-config.js +0 -14
  6. package/dist/config/config-file.js +0 -3
  7. package/dist/console-ui/assets/index-CQt4UhPB.js +28 -0
  8. package/dist/console-ui/assets/index-DGj8EsFR.css +1 -0
  9. package/dist/console-ui/index.html +2 -2
  10. package/dist/coordinators/pr-review.d.ts +17 -0
  11. package/dist/coordinators/pr-review.js +164 -0
  12. package/dist/daemon/daemon-events.d.ts +9 -1
  13. package/dist/daemon/soul-template.d.ts +2 -2
  14. package/dist/daemon/soul-template.js +11 -1
  15. package/dist/daemon/workflow-runner.d.ts +14 -1
  16. package/dist/daemon/workflow-runner.js +395 -25
  17. package/dist/di/container.js +1 -25
  18. package/dist/di/tokens.d.ts +0 -3
  19. package/dist/di/tokens.js +0 -3
  20. package/dist/engine/engine-factory.js +0 -1
  21. package/dist/infrastructure/console-defaults.d.ts +1 -0
  22. package/dist/infrastructure/console-defaults.js +4 -0
  23. package/dist/infrastructure/session/index.d.ts +0 -1
  24. package/dist/infrastructure/session/index.js +1 -3
  25. package/dist/manifest.json +87 -103
  26. package/dist/mcp/handlers/session.d.ts +1 -0
  27. package/dist/mcp/handlers/session.js +61 -13
  28. package/dist/mcp/server.js +1 -18
  29. package/dist/mcp/transports/http-entry.js +0 -2
  30. package/dist/mcp/transports/stdio-entry.js +1 -2
  31. package/dist/mcp/types.d.ts +0 -2
  32. package/dist/trigger/daemon-console.d.ts +2 -0
  33. package/dist/trigger/daemon-console.js +1 -1
  34. package/dist/trigger/trigger-listener.d.ts +2 -0
  35. package/dist/trigger/trigger-listener.js +3 -1
  36. package/dist/trigger/trigger-router.d.ts +4 -3
  37. package/dist/trigger/trigger-router.js +4 -3
  38. package/dist/trigger/trigger-store.js +17 -4
  39. package/dist/v2/usecases/console-routes.d.ts +2 -1
  40. package/dist/v2/usecases/console-routes.js +29 -5
  41. package/dist/v2/usecases/console-service.js +14 -0
  42. package/dist/v2/usecases/console-types.d.ts +1 -0
  43. package/docs/authoring.md +16 -16
  44. package/docs/design/coordinator-message-queue-drain-plan.md +241 -0
  45. package/docs/design/coordinator-message-queue-drain-review.md +120 -0
  46. package/docs/design/coordinator-message-queue-drain.md +289 -0
  47. package/docs/design/shaping-workflow-external-research.md +119 -0
  48. package/docs/discovery/late-bound-goals-impl-plan.md +147 -0
  49. package/docs/discovery/late-bound-goals-review.md +82 -0
  50. package/docs/discovery/late-bound-goals.md +118 -0
  51. package/docs/discovery/steer-endpoint-design-candidates.md +288 -0
  52. package/docs/discovery/steer-endpoint-design-review-findings.md +104 -0
  53. package/docs/discovery/steer-endpoint-implementation-plan.md +284 -0
  54. package/docs/ideas/backlog.md +292 -0
  55. package/docs/ideas/design-candidates-console-session-tree-impl.md +64 -0
  56. package/docs/ideas/design-candidates-session-tree-view.md +196 -0
  57. package/docs/ideas/design-review-findings-console-session-tree-impl.md +75 -0
  58. package/docs/ideas/design-review-findings-session-tree-view.md +88 -0
  59. package/docs/ideas/implementation_plan_session_tree_view.md +238 -0
  60. package/package.json +2 -1
  61. package/spec/authoring-spec.json +16 -16
  62. package/spec/shape.schema.json +178 -0
  63. package/spec/workflow-tags.json +232 -47
  64. package/workflows/coding-task-workflow-agentic.json +491 -480
  65. package/workflows/wr.shaping.json +182 -0
  66. package/dist/console-ui/assets/index-8dh0Psu-.css +0 -1
  67. package/dist/console-ui/assets/index-CXWCAonr.js +0 -28
  68. package/dist/infrastructure/session/DashboardHeartbeat.d.ts +0 -8
  69. package/dist/infrastructure/session/DashboardHeartbeat.js +0 -39
  70. package/dist/infrastructure/session/DashboardLockRelease.d.ts +0 -2
  71. package/dist/infrastructure/session/DashboardLockRelease.js +0 -29
  72. package/dist/infrastructure/session/HttpServer.d.ts +0 -60
  73. package/dist/infrastructure/session/HttpServer.js +0 -912
  74. package/workflows/coding-task-workflow-agentic.lean.v2.json +0 -648
  75. package/workflows/coding-task-workflow-agentic.v2.json +0 -324
@@ -3,6 +3,7 @@ import express from 'express';
3
3
  import type { V2ToolContext } from '../mcp/types.js';
4
4
  import type { TriggerStoreError } from './trigger-store.js';
5
5
  import { TriggerRouter, type RunWorkflowFn } from './trigger-router.js';
6
+ import type { SteerRegistry } from '../daemon/workflow-runner.js';
6
7
  import type { WorkspaceConfig } from './types.js';
7
8
  import type { DaemonEventEmitter } from '../daemon/daemon-events.js';
8
9
  import type { FetchFn } from './adapters/gitlab-poller.js';
@@ -17,6 +18,7 @@ export type TriggerListenerError = TriggerStoreError | {
17
18
  export interface TriggerListenerHandle {
18
19
  readonly port: number;
19
20
  readonly router: TriggerRouter;
21
+ readonly steerRegistry: SteerRegistry;
20
22
  stop(): Promise<void>;
21
23
  }
22
24
  export interface StartTriggerListenerOptions {
@@ -183,8 +183,9 @@ async function startTriggerListener(ctx, options) {
183
183
  const notificationService = (notifyMacOs || (notifyWebhook !== undefined && notifyWebhook !== ''))
184
184
  ? new notification_service_js_1.NotificationService({ macOs: notifyMacOs, webhookUrl: notifyWebhook })
185
185
  : undefined;
186
+ const steerRegistry = new Map();
186
187
  const runWorkflowFn = options.runWorkflowFn ?? workflow_runner_js_1.runWorkflow;
187
- const router = new trigger_router_js_1.TriggerRouter(triggerIndex, ctx, apiKey, runWorkflowFn, undefined, maxConcurrentSessions, options.emitter, notificationService);
188
+ const router = new trigger_router_js_1.TriggerRouter(triggerIndex, ctx, apiKey, runWorkflowFn, undefined, maxConcurrentSessions, options.emitter, notificationService, steerRegistry);
188
189
  const app = createTriggerApp(router);
189
190
  const allTriggers = [...triggerIndex.values()];
190
191
  const polledEventStore = new polled_event_store_js_1.PolledEventStore(env);
@@ -219,6 +220,7 @@ async function startTriggerListener(ctx, options) {
219
220
  resolve({
220
221
  port: actualPort,
221
222
  router,
223
+ steerRegistry,
222
224
  stop: async () => {
223
225
  pollingScheduler.stop();
224
226
  return new Promise((res, rej) => {
@@ -1,4 +1,4 @@
1
- import type { WorkflowTrigger, WorkflowRunResult } from '../daemon/workflow-runner.js';
1
+ import type { WorkflowTrigger, WorkflowRunResult, SteerRegistry } from '../daemon/workflow-runner.js';
2
2
  import type { V2ToolContext } from '../mcp/types.js';
3
3
  import type { TriggerDefinition, WebhookEvent } from './types.js';
4
4
  import type { ExecFn } from './delivery-action.js';
@@ -20,7 +20,7 @@ export type RouteResult = {
20
20
  readonly _tag: 'error';
21
21
  readonly error: RouteError;
22
22
  };
23
- export type RunWorkflowFn = (trigger: WorkflowTrigger, ctx: V2ToolContext, apiKey: string, daemonRegistry?: import('../v2/infra/in-memory/daemon-registry/index.js').DaemonRegistry, emitter?: DaemonEventEmitter) => Promise<WorkflowRunResult>;
23
+ export type RunWorkflowFn = (trigger: WorkflowTrigger, ctx: V2ToolContext, apiKey: string, daemonRegistry?: import('../v2/infra/in-memory/daemon-registry/index.js').DaemonRegistry, emitter?: DaemonEventEmitter, steerRegistry?: SteerRegistry) => Promise<WorkflowRunResult>;
24
24
  export declare function interpolateGoalTemplate(template: string, staticGoal: string, payload: Readonly<Record<string, unknown>>, triggerId: string): string;
25
25
  export declare class TriggerRouter {
26
26
  private readonly index;
@@ -33,7 +33,8 @@ export declare class TriggerRouter {
33
33
  private readonly _maxConcurrentSessions;
34
34
  private readonly emitter;
35
35
  private readonly notificationService;
36
- constructor(index: ReadonlyMap<string, TriggerDefinition>, ctx: V2ToolContext, apiKey: string, runWorkflowFn: RunWorkflowFn, execFn?: ExecFn, maxConcurrentSessions?: number, emitter?: DaemonEventEmitter, notificationService?: NotificationService);
36
+ private readonly steerRegistry;
37
+ constructor(index: ReadonlyMap<string, TriggerDefinition>, ctx: V2ToolContext, apiKey: string, runWorkflowFn: RunWorkflowFn, execFn?: ExecFn, maxConcurrentSessions?: number, emitter?: DaemonEventEmitter, notificationService?: NotificationService, steerRegistry?: SteerRegistry);
37
38
  get activeSessions(): number;
38
39
  get maxConcurrentSessions(): number;
39
40
  route(event: WebhookEvent): RouteResult;
@@ -183,7 +183,7 @@ class Semaphore {
183
183
  }
184
184
  const DEFAULT_MAX_CONCURRENT_SESSIONS = 3;
185
185
  class TriggerRouter {
186
- constructor(index, ctx, apiKey, runWorkflowFn, execFn, maxConcurrentSessions, emitter, notificationService) {
186
+ constructor(index, ctx, apiKey, runWorkflowFn, execFn, maxConcurrentSessions, emitter, notificationService, steerRegistry) {
187
187
  this.index = index;
188
188
  this.ctx = ctx;
189
189
  this.apiKey = apiKey;
@@ -192,6 +192,7 @@ class TriggerRouter {
192
192
  this.execFn = execFn ?? execFileAsync;
193
193
  this.emitter = emitter;
194
194
  this.notificationService = notificationService;
195
+ this.steerRegistry = steerRegistry;
195
196
  const requested = maxConcurrentSessions ?? DEFAULT_MAX_CONCURRENT_SESSIONS;
196
197
  const cap = Number.isNaN(requested) ? DEFAULT_MAX_CONCURRENT_SESSIONS : requested;
197
198
  if (cap < 1) {
@@ -260,7 +261,7 @@ class TriggerRouter {
260
261
  await this.semaphore.acquire();
261
262
  let result;
262
263
  try {
263
- result = await this.runWorkflowFn(workflowTrigger, this.ctx, this.apiKey, undefined, this.emitter);
264
+ result = await this.runWorkflowFn(workflowTrigger, this.ctx, this.apiKey, undefined, this.emitter, this.steerRegistry);
264
265
  }
265
266
  finally {
266
267
  this.semaphore.release();
@@ -321,7 +322,7 @@ class TriggerRouter {
321
322
  await this.semaphore.acquire();
322
323
  let result;
323
324
  try {
324
- result = await this.runWorkflowFn(workflowTrigger, this.ctx, this.apiKey, undefined, this.emitter);
325
+ result = await this.runWorkflowFn(workflowTrigger, this.ctx, this.apiKey, undefined, this.emitter, this.steerRegistry);
325
326
  }
326
327
  finally {
327
328
  this.semaphore.release();
@@ -426,7 +426,6 @@ function validateAndResolveTrigger(raw, env, workspaces = {}) {
426
426
  const requiredStringFields = [
427
427
  'provider',
428
428
  'workflowId',
429
- 'goal',
430
429
  ];
431
430
  for (const field of requiredStringFields) {
432
431
  const v = raw[field];
@@ -491,7 +490,21 @@ function validateAndResolveTrigger(raw, env, workspaces = {}) {
491
490
  return secretResult;
492
491
  hmacSecret = secretResult.value;
493
492
  }
494
- const goalTemplate = raw.goalTemplate?.trim();
493
+ const LATE_BOUND_GOAL_SENTINEL = 'Autonomous task';
494
+ let resolvedGoal;
495
+ let resolvedGoalTemplate = raw.goalTemplate?.trim();
496
+ if (!raw.goal?.trim()) {
497
+ resolvedGoal = LATE_BOUND_GOAL_SENTINEL;
498
+ if (!resolvedGoalTemplate) {
499
+ resolvedGoalTemplate = '{{$.goal}}';
500
+ console.log(`[TriggerStore] Trigger "${rawId}" has no static goal or goalTemplate -- ` +
501
+ `defaulting to goalTemplate: "{{$.goal}}" (goal taken from webhook payload). ` +
502
+ `Fallback goal if payload has no goal field: "${LATE_BOUND_GOAL_SENTINEL}".`);
503
+ }
504
+ }
505
+ else {
506
+ resolvedGoal = raw.goal.trim();
507
+ }
495
508
  const referenceUrlsRaw = raw.referenceUrls?.trim();
496
509
  const referenceUrls = referenceUrlsRaw
497
510
  ? referenceUrlsRaw.split(/\s+/).filter(Boolean)
@@ -712,13 +725,13 @@ function validateAndResolveTrigger(raw, env, workspaces = {}) {
712
725
  provider,
713
726
  workflowId: raw.workflowId.trim(),
714
727
  workspacePath: resolvedWorkspacePath,
715
- goal: raw.goal.trim(),
728
+ goal: resolvedGoal,
716
729
  concurrencyMode,
717
730
  ...(hmacSecret !== undefined ? { hmacSecret } : {}),
718
731
  ...(raw.contextMapping !== undefined
719
732
  ? { contextMapping: assembleContextMapping(raw.contextMapping) }
720
733
  : {}),
721
- ...(goalTemplate ? { goalTemplate } : {}),
734
+ ...(resolvedGoalTemplate ? { goalTemplate: resolvedGoalTemplate } : {}),
722
735
  ...(referenceUrls !== undefined && referenceUrls.length > 0 ? { referenceUrls } : {}),
723
736
  ...(agentConfig !== undefined ? { agentConfig } : {}),
724
737
  ...(callbackUrl !== undefined ? { callbackUrl } : {}),
@@ -4,4 +4,5 @@ import type { WorkflowService } from '../../application/services/workflow-servic
4
4
  import type { ToolCallTimingRingBuffer } from '../../mcp/tool-call-timing.js';
5
5
  import type { TriggerRouter } from '../../trigger/trigger-router.js';
6
6
  import type { V2ToolContext } from '../../mcp/types.js';
7
- export declare function mountConsoleRoutes(app: Application, consoleService: ConsoleService, workflowService?: WorkflowService, timingRingBuffer?: ToolCallTimingRingBuffer, toolCallsPerfFile?: string, serverVersion?: string, v2ToolContext?: V2ToolContext, triggerRouter?: TriggerRouter): () => void;
7
+ import type { SteerRegistry } from '../../daemon/workflow-runner.js';
8
+ export declare function mountConsoleRoutes(app: Application, consoleService: ConsoleService, workflowService?: WorkflowService, timingRingBuffer?: ToolCallTimingRingBuffer, toolCallsPerfFile?: string, serverVersion?: string, v2ToolContext?: V2ToolContext, triggerRouter?: TriggerRouter, steerRegistry?: SteerRegistry): () => void;
@@ -45,6 +45,7 @@ const worktree_service_js_1 = require("./worktree-service.js");
45
45
  const workflow_js_1 = require("../../types/workflow.js");
46
46
  const dev_mode_js_1 = require("../../mcp/dev-mode.js");
47
47
  const workflow_runner_js_1 = require("../../daemon/workflow-runner.js");
48
+ const assert_never_js_1 = require("../../runtime/assert-never.js");
48
49
  const start_js_1 = require("../../mcp/handlers/v2-execution/start.js");
49
50
  const v2_token_ops_js_1 = require("../../mcp/handlers/v2-token-ops.js");
50
51
  function watchSessionsDir(sessionsDir, onChanged) {
@@ -90,7 +91,7 @@ function loadWorkflowTags() {
90
91
  return { version: 0, tags: [], workflows: {} };
91
92
  }
92
93
  }
93
- function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuffer, toolCallsPerfFile, serverVersion, v2ToolContext, triggerRouter) {
94
+ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuffer, toolCallsPerfFile, serverVersion, v2ToolContext, triggerRouter, steerRegistry) {
94
95
  const sseClients = new Set();
95
96
  let sseDebounceTimer = null;
96
97
  function broadcastChange() {
@@ -590,18 +591,21 @@ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuff
590
591
  triggerRouter.dispatch(trigger);
591
592
  }
592
593
  else {
593
- void (0, workflow_runner_js_1.runWorkflow)(trigger, v2ToolContext, apiKey ?? '').then((result) => {
594
+ void (0, workflow_runner_js_1.runWorkflow)(trigger, v2ToolContext, apiKey ?? '', undefined, undefined, steerRegistry).then((result) => {
594
595
  if (result._tag === 'success') {
595
596
  console.log(`[ConsoleRoutes] Auto dispatch completed: workflowId=${workflowId} stopReason=${result.stopReason}`);
596
597
  }
598
+ else if (result._tag === 'delivery_failed') {
599
+ console.log(`[ConsoleRoutes] Auto dispatch delivery failed: workflowId=${workflowId}`);
600
+ }
597
601
  else if (result._tag === 'timeout') {
598
602
  console.log(`[ConsoleRoutes] Auto dispatch timed out: workflowId=${workflowId}`);
599
603
  }
600
- else if (result._tag === 'delivery_failed') {
601
- console.log(`[ConsoleRoutes] Auto dispatch delivery failed: workflowId=${workflowId}`);
604
+ else if (result._tag === 'error') {
605
+ console.log(`[ConsoleRoutes] Auto dispatch failed: workflowId=${workflowId} error=${result.message}`);
602
606
  }
603
607
  else {
604
- console.log(`[ConsoleRoutes] Auto dispatch failed: workflowId=${workflowId} error=${result.message}`);
608
+ (0, assert_never_js_1.assertNever)(result);
605
609
  }
606
610
  });
607
611
  }
@@ -621,6 +625,26 @@ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuff
621
625
  }));
622
626
  res.json({ success: true, data: { triggers } });
623
627
  });
628
+ app.post('/api/v2/sessions/:sessionId/steer', express_1.default.json(), (req, res) => {
629
+ if (!steerRegistry) {
630
+ res.status(503).json({ success: false, error: 'Steer not available (not a daemon context).' });
631
+ return;
632
+ }
633
+ const { sessionId } = req.params;
634
+ const body = req.body;
635
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
636
+ if (!text) {
637
+ res.status(400).json({ success: false, error: 'text is required and must be a non-empty string.' });
638
+ return;
639
+ }
640
+ const callback = steerRegistry.get(sessionId);
641
+ if (!callback) {
642
+ res.status(404).json({ success: false, error: 'Session not found or not a daemon session.' });
643
+ return;
644
+ }
645
+ callback(text);
646
+ res.json({ success: true });
647
+ });
624
648
  const consoleDist = resolveConsoleDist();
625
649
  if (consoleDist) {
626
650
  app.use('/console', express_1.default.static(consoleDist, {
@@ -585,6 +585,17 @@ function extractRepoRoot(events) {
585
585
  }
586
586
  return workspacePathFallback;
587
587
  }
588
+ function extractParentSessionId(events) {
589
+ for (const e of events) {
590
+ if (e.kind === constants_js_1.EVENT_KIND.SESSION_CREATED) {
591
+ const parentId = e.data.parentSessionId;
592
+ if (typeof parentId === 'string' && parentId.length > 0)
593
+ return parentId;
594
+ return null;
595
+ }
596
+ }
597
+ return null;
598
+ }
588
599
  function truncateTitle(text, maxLen = 120) {
589
600
  if (text.length <= maxLen)
590
601
  return text;
@@ -612,6 +623,7 @@ function projectSessionSummary(sessionId, truth, completionByRunId, workflowName
612
623
  const sessionTitle = sortedEventsRes.isOk() ? deriveSessionTitle(sortedEventsRes.value) : null;
613
624
  const gitBranch = extractGitBranch(events);
614
625
  const repoRoot = extractRepoRoot(events);
626
+ const parentSessionId = extractParentSessionId(events);
615
627
  const isAutonomous = (() => {
616
628
  if (!sortedEventsRes.isOk())
617
629
  return false;
@@ -643,6 +655,7 @@ function projectSessionSummary(sessionId, truth, completionByRunId, workflowName
643
655
  lastModifiedMs,
644
656
  isAutonomous,
645
657
  isLive,
658
+ parentSessionId,
646
659
  };
647
660
  }
648
661
  const workflow = run.workflow;
@@ -688,6 +701,7 @@ function projectSessionSummary(sessionId, truth, completionByRunId, workflowName
688
701
  lastModifiedMs,
689
702
  isAutonomous,
690
703
  isLive,
704
+ parentSessionId,
691
705
  };
692
706
  }
693
707
  function projectSessionDetail(sessionId, truth, completionByRunId, stepLabels, workflowNames, skippedStepsMap = {}) {
@@ -20,6 +20,7 @@ export interface ConsoleSessionSummary {
20
20
  readonly lastModifiedMs: number;
21
21
  readonly isAutonomous: boolean;
22
22
  readonly isLive: boolean;
23
+ readonly parentSessionId: string | null;
23
24
  }
24
25
  export interface ConsoleSessionListResponse {
25
26
  readonly sessions: readonly ConsoleSessionSummary[];
package/docs/authoring.md CHANGED
@@ -42,7 +42,7 @@ Canonical current rules for authoring good WorkRail workflows. workflow.schema.j
42
42
  **Source refs**
43
43
  - `spec/workflow.schema.json` (schema) — Legal structure and supported fields.
44
44
  - `src/application/services/validation-engine.ts` (runtime) — Validator-enforced authoring rules.
45
- - `workflows/coding-task-workflow-agentic.lean.v2.json` (example) — Current modern example.
45
+ - `workflows/coding-task-workflow-agentic.json` (example) — Current modern example.
46
46
 
47
47
  ### validate-early-and-often
48
48
  - **Level**: required
@@ -141,7 +141,7 @@ Canonical current rules for authoring good WorkRail workflows. workflow.schema.j
141
141
  - Part A / Part B / Rules: ... when the structure adds ceremony rather than clarity
142
142
 
143
143
  **Example refs**
144
- - `workflows/coding-task-workflow-agentic.lean.v2.json` — See the sharpened user-voiced prompts in the current lean coding workflow.
144
+ - `workflows/coding-task-workflow-agentic.json` — See the sharpened user-voiced prompts in the current lean coding workflow.
145
145
 
146
146
  ### protocol-footers-stay-explicit
147
147
  - **Level**: required
@@ -160,10 +160,10 @@ Canonical current rules for authoring good WorkRail workflows. workflow.schema.j
160
160
  - Replacing exact capture requirements with vague summary prose
161
161
 
162
162
  **Example refs**
163
- - `workflows/coding-task-workflow-agentic.lean.v2.json` — Uses compact Capture footers and explicit loop-control wording.
163
+ - `workflows/coding-task-workflow-agentic.json` — Uses compact Capture footers and explicit loop-control wording.
164
164
 
165
165
  **Source refs**
166
- - `workflows/coding-task-workflow-agentic.lean.v2.json` (example) — Uses explicit capture footers and shape-preserving loop outputs.
166
+ - `workflows/coding-task-workflow-agentic.json` (example) — Uses explicit capture footers and shape-preserving loop outputs.
167
167
 
168
168
 
169
169
  ## Prompt composition
@@ -185,11 +185,11 @@ Canonical current rules for authoring good WorkRail workflows. workflow.schema.j
185
185
  - Encoding runtime logic in prose when promptFragments or templates are the right mechanism
186
186
 
187
187
  **Example refs**
188
- - `workflows/coding-task-workflow-agentic.lean.v2.json` — Uses prompt fragments and context templates to keep prompts slimmer at render time.
188
+ - `workflows/coding-task-workflow-agentic.json` — Uses prompt fragments and context templates to keep prompts slimmer at render time.
189
189
 
190
190
  **Source refs**
191
191
  - `docs/authoring.md` (documentation) — Documents context templates and prompt fragments.
192
- - `workflows/coding-task-workflow-agentic.lean.v2.json` (example) — Uses prompt fragments to slim mode-specific prompt branches.
192
+ - `workflows/coding-task-workflow-agentic.json` (example) — Uses prompt fragments to slim mode-specific prompt branches.
193
193
 
194
194
  ### templates-are-for-simple-substitution
195
195
  - **Level**: recommended
@@ -405,11 +405,11 @@ Canonical current rules for authoring good WorkRail workflows. workflow.schema.j
405
405
  - Prompt text says to stop, but the example output only permits continue
406
406
 
407
407
  **Example refs**
408
- - `workflows/coding-task-workflow-agentic.lean.v2.json` — Current loop decision steps show shape-only output examples.
408
+ - `workflows/coding-task-workflow-agentic.json` — Current loop decision steps show shape-only output examples.
409
409
 
410
410
  **Source refs**
411
411
  - `scripts/validate-workflows-registry.ts` (validator) — Registry validation should preserve semantically correct discoverable workflows.
412
- - `workflows/coding-task-workflow-agentic.lean.v2.json` (example) — Current loop decision prompts show shape-only output examples.
412
+ - `workflows/coding-task-workflow-agentic.json` (example) — Current loop decision prompts show shape-only output examples.
413
413
 
414
414
  ### loops-need-real-exit-rules
415
415
  - **Level**: required
@@ -445,7 +445,7 @@ Canonical current rules for authoring good WorkRail workflows. workflow.schema.j
445
445
  - `contextAuditNeeded = true|false` without an explicit rubric
446
446
 
447
447
  **Example refs**
448
- - `workflows/coding-task-workflow-agentic.lean.v2.json` — Phase 0 uses a context-clarity rubric instead of a vibes-only confidence flag.
448
+ - `workflows/coding-task-workflow-agentic.json` — Phase 0 uses a context-clarity rubric instead of a vibes-only confidence flag.
449
449
 
450
450
 
451
451
  ## Confirmation discipline
@@ -466,7 +466,7 @@ Canonical current rules for authoring good WorkRail workflows. workflow.schema.j
466
466
  - Using requireConfirmation as a substitute for clear loop or rigor policy
467
467
 
468
468
  **Source refs**
469
- - `workflows/coding-task-workflow-agentic.lean.v2.json` (example) — Uses confirmation for real review barriers like MultiPR checkpoints.
469
+ - `workflows/coding-task-workflow-agentic.json` (example) — Uses confirmation for real review barriers like MultiPR checkpoints.
470
470
 
471
471
 
472
472
  ## Assessment gates
@@ -544,7 +544,7 @@ Canonical current rules for authoring good WorkRail workflows. workflow.schema.j
544
544
  - Treating named builder or researcher roles as alternate owners
545
545
 
546
546
  **Source refs**
547
- - `workflows/coding-task-workflow-agentic.lean.v2.json` (example) — Delegation checkpoints keep the main agent as the synthesizer and decision-maker.
547
+ - `workflows/coding-task-workflow-agentic.json` (example) — Delegation checkpoints keep the main agent as the synthesizer and decision-maker.
548
548
 
549
549
  ### batched-checkpoints-over-ad-hoc-optionality
550
550
  - **Level**: recommended
@@ -563,7 +563,7 @@ Canonical current rules for authoring good WorkRail workflows. workflow.schema.j
563
563
  - Optional challenge wording at high-value decision points
564
564
 
565
565
  **Example refs**
566
- - `workflows/coding-task-workflow-agentic.lean.v2.json` — Uses explicit challenge, audit, and verification barriers.
566
+ - `workflows/coding-task-workflow-agentic.json` — Uses explicit challenge, audit, and verification barriers.
567
567
 
568
568
 
569
569
  ## Subagent synthesis and claim adoption
@@ -601,10 +601,10 @@ Canonical current rules for authoring good WorkRail workflows. workflow.schema.j
601
601
  - Using delegated findings as blockers or green lights without verification
602
602
 
603
603
  **Example refs**
604
- - `workflows/coding-task-workflow-agentic.lean.v2.json` — Major synthesis checkpoints use Confirmed / Plausible / Rejected for decision-driving findings.
604
+ - `workflows/coding-task-workflow-agentic.json` — Major synthesis checkpoints use Confirmed / Plausible / Rejected for decision-driving findings.
605
605
 
606
606
  **Source refs**
607
- - `workflows/coding-task-workflow-agentic.lean.v2.json` (example) — Major synthesis checkpoints use Confirmed / Plausible / Rejected for adopted claims.
607
+ - `workflows/coding-task-workflow-agentic.json` (example) — Major synthesis checkpoints use Confirmed / Plausible / Rejected for adopted claims.
608
608
 
609
609
 
610
610
  ## Discouraged legacy patterns
@@ -670,7 +670,7 @@ Canonical current rules for authoring good WorkRail workflows. workflow.schema.j
670
670
  - Keeping a canonical example ref after the workflow has drifted into a legacy style
671
671
 
672
672
  **Example refs**
673
- - `workflows/coding-task-workflow-agentic.lean.v2.json` — Current example of modern prompt composition, delegation barriers, and loop semantics.
673
+ - `workflows/coding-task-workflow-agentic.json` — Current example of modern prompt composition, delegation barriers, and loop semantics.
674
674
 
675
675
 
676
676
  ## Validation
@@ -728,7 +728,7 @@ Canonical current rules for authoring good WorkRail workflows. workflow.schema.j
728
728
  - Verification steps check one artifact while planning updates a different one
729
729
 
730
730
  **Example refs**
731
- - `workflows/coding-task-workflow-agentic.lean.v2.json` — Uses explicit spec vs implementation-plan ownership.
731
+ - `workflows/coding-task-workflow-agentic.json` — Uses explicit spec vs implementation-plan ownership.
732
732
 
733
733
 
734
734
  ## Planned guidance
@@ -0,0 +1,241 @@
1
+ # Implementation Plan: Coordinator Message Queue Drain
2
+
3
+ ## 1. Problem Statement
4
+
5
+ `worktrain tell "<message>"` appends to `~/.workrail/message-queue.jsonl` but the PR review
6
+ coordinator (`runPrReviewCoordinator`) never reads this file. Messages sent from a phone,
7
+ terminal, or automation (e.g., "stop", "skip-pr 42") are silently ignored. The coordinator
8
+ must drain this queue at the start of each cycle and act on actionable messages before spawning
9
+ any agent.
10
+
11
+ ## 2. Acceptance Criteria
12
+
13
+ AC1. When `stop` appears as the first meaningful word in a queued message (matched by
14
+ `/^\s*stop\b/i`), the coordinator exits cleanly without reviewing any PR, and appends an
15
+ outbox notification that includes the full triggering message text and timestamp.
16
+
17
+ AC2. When `skip-pr N` appears in a queued message (matched by `/\bskip[- ]pr[\s#]+(\d+)/i`),
18
+ PR #N is removed from the list before Stage 1 review dispatch. An outbox notification is
19
+ appended confirming the skip.
20
+
21
+ AC3. When `add-pr N` appears in a queued message (matched by `/\badd[- ]pr[\s#]+(\d+)/i`),
22
+ PR #N is added to the list (with Set dedup to prevent duplicates). An outbox notification
23
+ is appended confirming the addition.
24
+
25
+ AC4. Messages that match no recognized pattern are skipped silently (treated as notes).
26
+
27
+ AC5. After draining, the cursor in `~/.workrail/message-queue-cursor.json` is updated so
28
+ processed messages are not re-processed on the next coordinator invocation.
29
+
30
+ AC6. If `~/.workrail/message-queue.jsonl` does not exist (ENOENT), the drain returns a no-op
31
+ result and the coordinator proceeds normally.
32
+
33
+ AC7. Malformed JSONL lines (unparseable JSON) are skipped without crashing the coordinator.
34
+ A stderr warning is emitted for each skipped malformed line.
35
+
36
+ AC8. All drain I/O (readFile, appendFile, homedir, joinPath, now, generateId) is injected via
37
+ `CoordinatorDeps`. No direct `fs` imports are added to `pr-review.ts`.
38
+
39
+ AC9. Unit tests for `drainMessageQueue()` use fake deps (in-memory file map). No real filesystem
40
+ access in tests.
41
+
42
+ ## 3. Non-Goals
43
+
44
+ - No `reprioritize` message kind in this PR
45
+ - No workspace routing (workspaceHint matching) -- all messages are consumed regardless of hint
46
+ - No structured `kind` field on `QueuedMessage` (Candidate C) -- that is a follow-up issue
47
+ - No truncation or compaction of consumed messages (queue remains append-only)
48
+ - No real-time / `--watch` mode
49
+ - No multi-coordinator fan-out (single coordinator consumes the queue)
50
+ - No integration test (unit tests with fakes are sufficient)
51
+
52
+ ## 4. Philosophy-Driven Constraints
53
+
54
+ - Errors as data: `drainMessageQueue` returns `DrainResult`, never throws
55
+ - All I/O injected: `CoordinatorDeps` gains `readFile` and `appendFile`; zero direct fs imports
56
+ - Immutability: `DrainResult` and all new interfaces are fully readonly
57
+ - Prefer fakes over mocks: tests use in-memory fake deps
58
+ - Validate at boundaries: JSONL parsing, ENOENT, cursor desync handled at the read boundary
59
+ - Document WHY: function header explains the cursor pattern and text-matching tradeoff
60
+
61
+ ## 5. Invariants
62
+
63
+ I1. `message-queue.jsonl` is never written or truncated by the coordinator (append-only)
64
+ I2. The coordinator drains the queue BEFORE Stage 1 (PR discovery) -- never mid-agent-run
65
+ I3. `stop: true` in `DrainResult` takes absolute precedence; coordinator must check stop before
66
+ acting on `skipPrNumbers` or `addPrNumbers`
67
+ I4. The cursor advances only AFTER successful outbox writes (best-effort; cursor write failure
68
+ does not block drain -- same pattern as worktrain-inbox.ts)
69
+ I5. ENOENT on message-queue.jsonl = no messages = coordinator proceeds normally (not an error)
70
+ I6. Cursor desync guard: if `cursor > totalLines`, reset to 0 (queue was wiped)
71
+
72
+ ## 6. Selected Approach & Rationale
73
+
74
+ **Selected: Candidate B** -- `drainMessageQueue()` pure function with cursor + text parsing.
75
+
76
+ **Rationale:** Direct adaptation of the `worktrain-inbox.ts` cursor pattern (already tested, same
77
+ `InboxCursor` shape `{ lastReadCount: number }`). Additive to `CoordinatorDeps`. Text parsing is
78
+ narrow (`^\\s*stop\\b`) and consistent with how `parseFindingsFromNotes()` works in the same file.
79
+
80
+ **Runner-up: Candidate C** (structured `kind` field on `QueuedMessage`). Loses because it
81
+ requires a schema change to the public CLI interface (`worktrain tell`), which is out of scope.
82
+ Filed as a follow-up.
83
+
84
+ ## 7. Vertical Slices
85
+
86
+ ### Slice 1: Extend `CoordinatorDeps` and add `DrainResult` type
87
+
88
+ **Files:** `src/coordinators/pr-review.ts`
89
+
90
+ **Work:**
91
+ - Add `readFile: (path: string) => Promise<string>` to `CoordinatorDeps`
92
+ - Add `appendFile: (path: string, content: string) => Promise<void>` to `CoordinatorDeps`
93
+ - Add `mkdir: (path: string, options: { recursive: boolean }) => Promise<string | undefined>` to `CoordinatorDeps`
94
+ - Define `DrainResult` interface (readonly: stop, stopReason, skipPrNumbers, addPrNumbers, messagesProcessed)
95
+
96
+ **Done when:** TypeScript compiles with new interface fields. No runtime behavior change yet.
97
+
98
+ **Note:** Updating fake deps in `coordinator-pr-review.test.ts` is part of this slice (compile-
99
+ time requirement).
100
+
101
+ ---
102
+
103
+ ### Slice 2: Implement `drainMessageQueue()`
104
+
105
+ **Files:** `src/coordinators/pr-review.ts`
106
+
107
+ **Work:**
108
+ - New exported function `drainMessageQueue(deps, workrailDir)` -- deps is the coordinator deps
109
+ subset; workrailDir defaults to `deps.joinPath(deps.homedir(), '.workrail')`
110
+ - Reads `message-queue.jsonl` (ENOENT -> return empty result)
111
+ - Reads cursor from `message-queue-cursor.json` (missing/corrupt -> 0)
112
+ - Applies cursor desync guard (cursor > totalLines -> reset to 0)
113
+ - Parses new lines (slice from cursor), skips malformed with stderr warning
114
+ - For each parsed `QueuedMessage`:
115
+ - `^\\s*stop\\b/i` match -> set stop=true, record stopReason=message.message
116
+ - `/\\bskip[- ]pr[\\s#]+([0-9]+)/i` match -> add to skipSet
117
+ - `/\\badd[- ]pr[\\s#]+([0-9]+)/i` match -> add to addSet
118
+ - Otherwise: skip (informational note)
119
+ - After processing all new messages:
120
+ - For each actionable message: appendFile to outbox.jsonl with confirmation text
121
+ - Append stderr `[INFO coord:drain kind=... message="..." ts=...]` per actionable message
122
+ - Update cursor file (non-fatal on failure)
123
+ - Return `DrainResult`
124
+
125
+ **Done when:** Function exists, TypeScript compiles, unit tests pass.
126
+
127
+ ---
128
+
129
+ ### Slice 3: Integrate drain into `runPrReviewCoordinator()`
130
+
131
+ **Files:** `src/coordinators/pr-review.ts`
132
+
133
+ **Work:**
134
+ - Call `drainMessageQueue(deps)` at the top of `runPrReviewCoordinator()` (before Stage 1 log)
135
+ - Check `drainResult.stop` immediately:
136
+ - If true: log stop reason, write report (empty/aborted), return early with all zeros
137
+ - Apply `drainResult.skipPrNumbers` to remove PRs from the discovered list (after Stage 1)
138
+ - Apply `drainResult.addPrNumbers` to add PRs to the list (with Set dedup, before Stage 1)
139
+ - Log drain activity: `[drain] processed N messages, skip=[...], add=[...]` if messagesProcessed > 0
140
+
141
+ **Done when:** Integration passes existing coordinator unit tests + new drain integration test.
142
+
143
+ ---
144
+
145
+ ### Slice 4: Wire new deps in `cli-worktrain.ts`
146
+
147
+ **Files:** `src/cli-worktrain.ts`
148
+
149
+ **Work:**
150
+ - Add `readFile: (p: string) => fs.promises.readFile(p, 'utf-8')` to CoordinatorDeps wiring
151
+ - Add `appendFile: (p: string, content: string) => fs.promises.appendFile(p, content, 'utf-8')`
152
+ to CoordinatorDeps wiring
153
+ - Add `mkdir: (p: string, opts: { recursive: boolean }) => fs.promises.mkdir(p, opts)` to
154
+ CoordinatorDeps wiring
155
+
156
+ **Done when:** `worktrain run pr-review --dry-run` compiles and runs without error.
157
+
158
+ ---
159
+
160
+ ### Slice 5: Unit tests for `drainMessageQueue()`
161
+
162
+ **Files:** `tests/unit/coordinator-pr-review.test.ts`
163
+
164
+ **Work:**
165
+ - Add `readFile` and `appendFile` to the existing fake CoordinatorDeps helper
166
+ - New `describe('drainMessageQueue')` block covering:
167
+ - ENOENT -> returns empty DrainResult (messagesProcessed=0, stop=false)
168
+ - Stop message at start of message text -> stop=true, stopReason set
169
+ - Stop NOT triggered when 'stop' appears mid-sentence ("please stop overthinking" -- note: this
170
+ still fires with `^\\s*stop` since it doesn't start the message; test confirms this is the
171
+ designed behavior)
172
+ - skip-pr with PR number -> skipPrNumbers contains the number
173
+ - add-pr with PR number -> addPrNumbers contains the number
174
+ - Malformed JSONL lines skipped, messagesProcessed counts only valid lines
175
+ - Cursor advances after drain
176
+ - Cursor desync guard resets to 0 when cursor > totalLines
177
+ - Multiple messages: stop takes precedence regardless of order in queue
178
+ - Note-only messages: no action, cursor advances, messagesProcessed = N
179
+
180
+ **Done when:** All new tests pass; no existing tests broken.
181
+
182
+ ## 8. Test Design
183
+
184
+ **Strategy:** Fake deps only (in-memory Map for files, Set for dirs). No real filesystem.
185
+
186
+ **Key test helpers:**
187
+ ```ts
188
+ interface FakeDrainFs {
189
+ files: Map<string, string>;
190
+ }
191
+
192
+ function makeDrainDeps(fs: FakeDrainFs): Pick<CoordinatorDeps, 'readFile' | 'appendFile' | 'mkdir' | 'homedir' | 'joinPath' | 'now' | 'generateId' | 'stderr'>
193
+ ```
194
+
195
+ **Critical test cases:**
196
+ - `stop` as sole message: stop=true, outbox has triggering text
197
+ - `skip-pr 42` after a note: skipPrNumbers=[42], messagesProcessed=2
198
+ - Two `skip-pr` for same PR: deduplicated in Set (skipPrNumbers=[42] not [42, 42])
199
+ - Cursor = 5, file has 5 lines: messagesProcessed=0 (all previously read)
200
+ - Cursor = 10, file has 5 lines: cursor reset to 0, all 5 processed
201
+
202
+ ## 9. Risk Register
203
+
204
+ | Risk | Likelihood | Impact | Mitigation |
205
+ |---|---|---|---|
206
+ | `stop` false positive on note message | Low | Medium | `^\\s*stop\\b` anchor; outbox shows triggering text |
207
+ | Cursor file write failure | Very Low | Low | Non-fatal; next run re-reads from 0 (desync reset) |
208
+ | Outbox write failure during stop | Very Low | Low | Non-fatal; stderr log is backup |
209
+ | `readFile`/`appendFile` not wired in cli-worktrain.ts | Low | High | Slice 4 is explicit; TypeScript will catch missing fields at compile time |
210
+
211
+ ## 10. PR Packaging Strategy
212
+
213
+ Single PR on branch `feat/coordinator-message-queue`. All 5 slices in one PR -- they are
214
+ tightly coupled (type change -> function -> integration -> wiring -> tests). Separating them
215
+ would create a non-compiling intermediate state.
216
+
217
+ ## 11. Philosophy Alignment Per Slice
218
+
219
+ | Slice | Principle | Status |
220
+ |---|---|---|
221
+ | 1 | Immutability by default | Satisfied -- all new fields are readonly |
222
+ | 1 | Explicit domain types | Tension -- DrainResult uses boolean stop not a discriminated union; documented |
223
+ | 2 | Errors are data | Satisfied -- DrainResult is a value; ENOENT returns empty result |
224
+ | 2 | Dependency injection | Satisfied -- all I/O via injected deps |
225
+ | 2 | Validate at boundaries | Satisfied -- malformed JSONL skipped at parse boundary |
226
+ | 3 | Determinism over cleverness | Satisfied -- same queue + cursor = same result |
227
+ | 4 | Compose with small pure functions | Satisfied -- drainMessageQueue is pure at logic level |
228
+ | 5 | Prefer fakes over mocks | Satisfied -- fake deps, no vi.mock() |
229
+
230
+ ## 12. Follow-Up Tickets
231
+
232
+ 1. **Add `kind` field to `QueuedMessage` for structured dispatch** (Candidate C) -- unblocks
233
+ automated tooling writing to the message queue without text fragility.
234
+ 2. **`worktrain tell --help` should list recognized coordinator command patterns** -- discovery
235
+ for users who don't know what command words the coordinator recognizes.
236
+
237
+ ## Summary
238
+
239
+ - `estimatedPRCount`: 1
240
+ - `unresolvedUnknownCount`: 0
241
+ - `planConfidenceBand`: High