@atolis-hq/wake 0.2.35 → 0.2.36

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.
@@ -21,6 +21,8 @@ function belowFailureRetryLimit(issue, config) {
21
21
  // quoted reply containing /approved does not approve the gate.
22
22
  const approvedCommandPattern = /^\/approved\b/i;
23
23
  const changesCommandPattern = /^\/changes\b/i;
24
+ const prReviewApprovalMarker = '<!-- wake:pr-review-approved -->';
25
+ const prReviewChangesMarker = '<!-- wake:pr-review-changes-requested -->';
24
26
  // The action Wake runs when a correlated PR gets new reviewer feedback while
25
27
  // the work item is awaiting approval. Not configurable per workflow: it's a
26
28
  // lateral response to a PR surface, not a workflow stage.
@@ -63,6 +65,16 @@ function latestUnhandledHumanComment(issue) {
63
65
  }
64
66
  return latestHumanComment;
65
67
  }
68
+ function latestUnhandledComment(issue) {
69
+ const context = issue.context;
70
+ const handledCommentId = typeof context.lastHandledCommentId === 'string' ? context.lastHandledCommentId : undefined;
71
+ const lastBotIndex = issue.comments.reduce((acc, c, i) => (c.isBotAuthored ? i : acc), -1);
72
+ const latest = issue.comments.slice(lastBotIndex).at(-1);
73
+ if (latest === undefined || latest.id === handledCommentId) {
74
+ return undefined;
75
+ }
76
+ return latest;
77
+ }
66
78
  export function createPolicyEngine() {
67
79
  return {
68
80
  isEligible(issue, config) {
@@ -159,10 +171,16 @@ export function createPolicyEngine() {
159
171
  // No new human comment since the last handled one; stay idle instead of
160
172
  // falling through to the LLM while awaiting explicit approval feedback.
161
173
  const latestHumanComment = latestUnhandledHumanComment(issue);
162
- if (latestHumanComment === undefined) {
174
+ const latestComment = latestUnhandledComment(issue);
175
+ if (pendingAction === undefined) {
163
176
  return null;
164
177
  }
165
- if (pendingAction === undefined) {
178
+ if (latestComment?.isBotAuthored === true &&
179
+ latestComment.resourceUri !== undefined &&
180
+ latestComment.body.includes(prReviewApprovalMarker)) {
181
+ return { approved: true, pendingAction };
182
+ }
183
+ if (latestHumanComment === undefined) {
166
184
  return null;
167
185
  }
168
186
  const approved = matchesCommand(latestHumanComment.body, approvedCommandPattern);
@@ -188,6 +206,12 @@ export function createPolicyEngine() {
188
206
  return null;
189
207
  }
190
208
  const latestHumanComment = latestUnhandledHumanComment(issue);
209
+ const latestComment = latestUnhandledComment(issue);
210
+ if (latestComment?.isBotAuthored === true &&
211
+ latestComment.resourceUri !== undefined &&
212
+ latestComment.body.includes(prReviewChangesMarker)) {
213
+ return reviewFeedbackAction;
214
+ }
191
215
  // resourceUri is set only on comments folded from a correlated PR/review
192
216
  // surface (schema.ts's commentSnapshotSchema: "absent = the originating
193
217
  // issue thread"). A comment on that surface is itself the deliberate
@@ -192,6 +192,16 @@ async function applyEvent(current, event, ctx, config) {
192
192
  }
193
193
  if (event.sourceEventType === 'wake.run.completed') {
194
194
  const payload = event.payload;
195
+ if (payload.watcherRun === true) {
196
+ return parseIssueStateRecord({
197
+ ...current,
198
+ wake: {
199
+ ...current.wake,
200
+ syncedAt: event.ingestedAt,
201
+ recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
202
+ },
203
+ });
204
+ }
195
205
  // Clear the session when the stage moves forward (new action needed) or the
196
206
  // run failed outright. Keep it for BLOCKED so the same action can resume
197
207
  // the in-progress session after a human replies.
@@ -63,7 +63,7 @@ function floorToMinute(date) {
63
63
  copy.setUTCSeconds(0, 0);
64
64
  return copy;
65
65
  }
66
- function previousMatchingSlot(input) {
66
+ export function previousMatchingSlot(input) {
67
67
  const parsed = parseCron(input.cron);
68
68
  const slot = floorToMinute(input.now);
69
69
  const afterMs = input.after === undefined ? undefined : Date.parse(input.after);
@@ -1,22 +1,27 @@
1
+ import { join } from 'node:path';
1
2
  import { createLifecycleService } from './lifecycle-service.js';
2
3
  import { createPolicyEngine } from './policy-engine.js';
3
4
  import { createProjectionUpdater } from './projection-updater.js';
4
5
  import { acquireFileLock } from '../lib/lock.js';
5
- import { CORRELATION_REGISTERED_EVENT, parseRunnerArtifacts, parseRunnerResult, } from '../domain/schema.js';
6
+ import { CORRELATION_REGISTERED_EVENT, CORRELATION_PRIMARY_CONFLICT_EVENT, parseRunnerArtifacts, parseRunnerResult, } from '../domain/schema.js';
6
7
  import { maxConfiguredRunnerTimeoutMs, resolveRunnerRouting } from '../domain/runner-routing.js';
7
8
  import { awaitingApprovalRunnerSentinel, stageLabelForStage } from '../domain/stages.js';
8
- import { chooseAction as chooseWorkflowAction, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection, workflowLabelForWorkflowName, workflowNameForProjection, } from '../domain/workflows.js';
9
+ import { chooseAction as chooseWorkflowAction, entryStage as workflowEntryStage, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection, workflowLabelForWorkflowName, workflowNameForProjection, } from '../domain/workflows.js';
9
10
  import { createEventEnvelope } from '../lib/event-log.js';
10
11
  import { branchNameForIssue } from '../domain/branch-naming.js';
11
12
  import { customCommandWorkspace, isCustomCommandAction } from '../domain/custom-commands.js';
12
13
  import { resolveQuotaPauseUntil } from './quota-backoff.js';
13
14
  import { createLabelsEvent, createPublishIntentEvent } from './event-builders.js';
15
+ import { previousMatchingSlot } from './scheduled-workflow-source.js';
14
16
  import { createOutbox } from './outbox.js';
15
17
  import { createEventResolver } from './event-resolver.js';
16
18
  import { createStaleRunReconciler } from './stale-run-reconciler.js';
17
19
  import { createWorkspaceCleanup } from './workspace-cleanup.js';
18
20
  import { createRunLease, isRunLeaseExpired, renewRunLease, runLeaseRenewalIntervalMs, } from './run-lease.js';
19
21
  import { currentProcessIdentity, processIdentityMatches } from '../lib/process-identity.js';
22
+ import { readJsonFile, writeJsonFile } from '../lib/json-file.js';
23
+ import { isMissingPathError } from '../lib/state-health.js';
24
+ const prReviewApprovalMarker = '<!-- wake:pr-review-approved -->';
20
25
  function latestHumanCommentId(candidate) {
21
26
  const human = candidate.comments.filter((c) => !c.isBotAuthored);
22
27
  return human.at(-1)?.id;
@@ -317,6 +322,201 @@ export function createTickRunner(deps) {
317
322
  await lock.release();
318
323
  }
319
324
  }
325
+ function watcherStatus(projection) {
326
+ const sentinel = projection.context.lastRunSentinel;
327
+ if (sentinel === 'AWAITING_APPROVAL')
328
+ return 'awaiting-approval';
329
+ if (sentinel === 'BLOCKED')
330
+ return 'blocked';
331
+ if (sentinel === 'FAILED')
332
+ return 'failed';
333
+ if (sentinel === 'DONE')
334
+ return 'done';
335
+ return 'pending';
336
+ }
337
+ function watcherKey(input) {
338
+ return [
339
+ input.workItemKey,
340
+ input.parentWorkflowName,
341
+ input.parentStage,
342
+ String(input.watcherIndex),
343
+ ]
344
+ .join('__')
345
+ .replace(/[^A-Za-z0-9._-]/g, '_');
346
+ }
347
+ function watcherStateFile(key) {
348
+ return join(deps.stateStore.paths.dataRoot, 'watchers', `${key}.json`);
349
+ }
350
+ async function readWatcherState(key) {
351
+ try {
352
+ const raw = await readJsonFile(watcherStateFile(key));
353
+ if (raw === null || typeof raw !== 'object')
354
+ return null;
355
+ const record = raw;
356
+ return record.schemaVersion === 1 &&
357
+ record.key === key &&
358
+ typeof record.updatedAt === 'string'
359
+ ? {
360
+ schemaVersion: 1,
361
+ key,
362
+ ...(typeof record.lastDispatchedEventId === 'string'
363
+ ? { lastDispatchedEventId: record.lastDispatchedEventId }
364
+ : {}),
365
+ ...(typeof record.lastDispatchedSlot === 'string'
366
+ ? { lastDispatchedSlot: record.lastDispatchedSlot }
367
+ : {}),
368
+ updatedAt: record.updatedAt,
369
+ }
370
+ : null;
371
+ }
372
+ catch (error) {
373
+ if (isMissingPathError(error))
374
+ return null;
375
+ throw error;
376
+ }
377
+ }
378
+ async function writeWatcherState(key, patch) {
379
+ const current = await readWatcherState(key);
380
+ await writeJsonFile(watcherStateFile(key), {
381
+ schemaVersion: 1,
382
+ key,
383
+ ...(current?.lastDispatchedEventId === undefined
384
+ ? {}
385
+ : { lastDispatchedEventId: current.lastDispatchedEventId }),
386
+ ...(current?.lastDispatchedSlot === undefined
387
+ ? {}
388
+ : { lastDispatchedSlot: current.lastDispatchedSlot }),
389
+ ...patch,
390
+ updatedAt: deps.clock.now().toISOString(),
391
+ });
392
+ }
393
+ async function nextWatcherDispatch(projections, now) {
394
+ for (const projection of projections) {
395
+ const parentWorkflow = workflowForProjection(projection, deps.config);
396
+ if (parentWorkflow === null)
397
+ continue;
398
+ const parentWorkflowName = workflowNameForProjection(projection, deps.config);
399
+ const stage = parentWorkflow.stages[projection.wake.stage];
400
+ if (stage === undefined)
401
+ continue;
402
+ for (const [watcherIndex, watcher] of (stage.watch ?? []).entries()) {
403
+ if (!watcher.while.status.includes(watcherStatus(projection)))
404
+ continue;
405
+ const key = watcherKey({
406
+ workItemKey: projection.workItemKey,
407
+ parentWorkflowName,
408
+ parentStage: projection.wake.stage,
409
+ watcherIndex,
410
+ });
411
+ const state = await readWatcherState(key);
412
+ if (watcher.on !== undefined) {
413
+ const events = await deps.stateStore.listRecentEventEnvelopes({
414
+ workItemKey: projection.workItemKey,
415
+ direction: 'internal',
416
+ limit: 200,
417
+ });
418
+ const matchingEvents = events
419
+ .filter((event) => watcher.on.event.includes(event.sourceEventType))
420
+ .sort((left, right) => left.ingestedAt.localeCompare(right.ingestedAt));
421
+ const cursorIndex = state?.lastDispatchedEventId === undefined
422
+ ? -1
423
+ : matchingEvents.findIndex((event) => event.eventId === state.lastDispatchedEventId);
424
+ const newest = matchingEvents.slice(cursorIndex + 1).at(-1);
425
+ if (newest !== undefined) {
426
+ return {
427
+ projection,
428
+ parentWorkflowName,
429
+ parentStage: projection.wake.stage,
430
+ watcherIndex,
431
+ targetWorkflowName: watcher.workflow,
432
+ trigger: { kind: 'event', eventId: newest.eventId },
433
+ };
434
+ }
435
+ }
436
+ if (watcher.schedule !== undefined) {
437
+ const slot = previousMatchingSlot({
438
+ cron: watcher.schedule.cron,
439
+ now,
440
+ ...(state?.lastDispatchedSlot === undefined ? {} : { after: state.lastDispatchedSlot }),
441
+ });
442
+ if (slot !== null) {
443
+ return {
444
+ projection,
445
+ parentWorkflowName,
446
+ parentStage: projection.wake.stage,
447
+ watcherIndex,
448
+ targetWorkflowName: watcher.workflow,
449
+ trigger: { kind: 'schedule', slot },
450
+ };
451
+ }
452
+ }
453
+ }
454
+ }
455
+ return null;
456
+ }
457
+ async function resolvePrReviewTarget(input) {
458
+ if (deps.artifactVerifier === undefined) {
459
+ return null;
460
+ }
461
+ const { artifacts } = parseRunnerArtifacts(input.runnerResult.result);
462
+ for (const artifact of artifacts) {
463
+ const verified = await deps.artifactVerifier.verify(artifact, {
464
+ branch: branchNameForIssue(input.projection.issue.number),
465
+ repo: input.projection.issue.repo,
466
+ });
467
+ if (verified === null) {
468
+ continue;
469
+ }
470
+ const incumbent = await deps.resourceIndex.resolve(verified.resourceUri);
471
+ if (incumbent !== undefined && incumbent !== input.projection.workItemKey) {
472
+ const conflict = createEventEnvelope({
473
+ eventId: `${input.runId}-pr-review-primary-conflict`,
474
+ workItemKey: input.projection.workItemKey,
475
+ streamScope: 'work-item',
476
+ direction: 'internal',
477
+ sourceSystem: 'wake',
478
+ sourceEventType: CORRELATION_PRIMARY_CONFLICT_EVENT,
479
+ sourceRefs: { runId: input.runId },
480
+ occurredAt: input.occurredAt,
481
+ ingestedAt: input.occurredAt,
482
+ trigger: 'context-only',
483
+ payload: {
484
+ resourceUri: verified.resourceUri,
485
+ incumbentWorkItemKey: incumbent,
486
+ },
487
+ });
488
+ const appended = await deps.stateStore.appendEventEnvelope(conflict);
489
+ await projectionUpdater.rebuildFromEvents([appended]);
490
+ return null;
491
+ }
492
+ if (incumbent === undefined) {
493
+ const event = createEventEnvelope({
494
+ eventId: `${input.runId}-pr-review-artifact-${verified.resourceUri.replace(/[^a-z0-9]+/gi, '-')}`,
495
+ workItemKey: input.projection.workItemKey,
496
+ streamScope: 'work-item',
497
+ direction: 'internal',
498
+ sourceSystem: 'wake',
499
+ sourceEventType: CORRELATION_REGISTERED_EVENT,
500
+ sourceRefs: { runId: input.runId },
501
+ occurredAt: input.occurredAt,
502
+ ingestedAt: input.occurredAt,
503
+ trigger: 'context-only',
504
+ payload: {
505
+ resourceUri: verified.resourceUri,
506
+ role: 'implementation',
507
+ relation: 'primary',
508
+ provenance: 'agent-reported',
509
+ registeredBy: input.runId,
510
+ idempotencyKey: `${input.runId}:pr-review-artifact-registration:${verified.resourceUri}`,
511
+ },
512
+ });
513
+ const appended = await deps.stateStore.appendEventEnvelope(event);
514
+ await projectionUpdater.rebuildFromEvents([appended]);
515
+ }
516
+ return verified.resourceUri;
517
+ }
518
+ return null;
519
+ }
320
520
  async function runRunnerTick() {
321
521
  const lock = await acquireFileLock(deps.stateStore.paths.runnerLockFile);
322
522
  if (!lock.acquired) {
@@ -335,7 +535,12 @@ export function createTickRunner(deps) {
335
535
  if (await parkConfigDriftedProjections(projections)) {
336
536
  return { status: 'processed' };
337
537
  }
338
- let candidate = projections.find((issue) => policy.resolveNextEligibleAction(issue, deps.config) !== null);
538
+ const watcherDispatch = await nextWatcherDispatch(projections, tickStartedAt);
539
+ let candidate = watcherDispatch?.projection;
540
+ let watcherStateKeyForRun;
541
+ let watcherTriggerForRun;
542
+ const watcherRun = watcherDispatch !== null;
543
+ candidate ??= projections.find((issue) => policy.resolveNextEligibleAction(issue, deps.config) !== null);
339
544
  if (candidate === undefined) {
340
545
  return { status: 'idle' };
341
546
  }
@@ -357,19 +562,41 @@ export function createTickRunner(deps) {
357
562
  candidate = (await deps.stateStore.readIssueState(candidate.workItemKey)) ?? candidate;
358
563
  }
359
564
  }
360
- if (policy.resolveNextEligibleAction(candidate, deps.config) === null) {
565
+ if (!watcherRun && policy.resolveNextEligibleAction(candidate, deps.config) === null) {
361
566
  return { status: 'idle' };
362
567
  }
363
568
  const workflow = workflowForProjection(candidate, deps.config);
364
569
  if (workflow === null) {
365
570
  return { status: 'idle' };
366
571
  }
367
- const workflowName = workflowNameForProjection(candidate, deps.config);
572
+ let workflowName = workflowNameForProjection(candidate, deps.config);
368
573
  let action;
369
574
  let command;
370
575
  let claimedStage = candidate.wake.stage;
371
576
  let workspaceMode = 'none';
372
- if (isAwaitingApproval(candidate)) {
577
+ if (watcherDispatch !== null) {
578
+ const targetWorkflow = deps.config.workflows[watcherDispatch.targetWorkflowName];
579
+ if (targetWorkflow === undefined) {
580
+ return { status: 'idle' };
581
+ }
582
+ const entryStageName = workflowEntryStage(targetWorkflow);
583
+ const entryStage = targetWorkflow.stages[entryStageName];
584
+ if (entryStage === undefined) {
585
+ return { status: 'idle' };
586
+ }
587
+ action = entryStage.action ?? entryStageName;
588
+ claimedStage = entryStageName;
589
+ workspaceMode = entryStage.workspace;
590
+ workflowName = watcherDispatch.targetWorkflowName;
591
+ watcherStateKeyForRun = watcherKey({
592
+ workItemKey: watcherDispatch.projection.workItemKey,
593
+ parentWorkflowName: watcherDispatch.parentWorkflowName,
594
+ parentStage: watcherDispatch.parentStage,
595
+ watcherIndex: watcherDispatch.watcherIndex,
596
+ });
597
+ watcherTriggerForRun = watcherDispatch.trigger;
598
+ }
599
+ else if (isAwaitingApproval(candidate)) {
373
600
  const customCommandRequest = policy.resolveCustomCommandRequest(candidate, deps.config);
374
601
  if (customCommandRequest !== null) {
375
602
  action = customCommandRequest.action;
@@ -508,6 +735,13 @@ export function createTickRunner(deps) {
508
735
  workerProcessStartedAt: workerIdentity.processStartedAt,
509
736
  metadata: {
510
737
  sourceRevision,
738
+ ...(watcherRun
739
+ ? {
740
+ watcher: true,
741
+ watcherWorkflow: workflowName,
742
+ watcherTrigger: watcherTriggerForRun,
743
+ }
744
+ : {}),
511
745
  },
512
746
  };
513
747
  await deps.stateStore.writeRunRecord(runningRecord);
@@ -536,13 +770,19 @@ export function createTickRunner(deps) {
536
770
  payload: {
537
771
  action,
538
772
  priorStage: candidate.wake.stage,
539
- claimedStage,
773
+ ...(watcherRun ? {} : { claimedStage }),
540
774
  sourceRevision,
775
+ ...(watcherRun ? { watcherRun: true, watcherTrigger: watcherTriggerForRun } : {}),
541
776
  },
542
777
  });
543
778
  await deps.stateStore.appendEventEnvelope(claimedEvent);
544
779
  await transitionRunLifecycle('CLAIMED');
545
780
  await projectionUpdater.rebuildFromEvents([claimedEvent]);
781
+ if (watcherStateKeyForRun !== undefined && watcherTriggerForRun !== undefined) {
782
+ await writeWatcherState(watcherStateKeyForRun, watcherTriggerForRun.kind === 'event'
783
+ ? { lastDispatchedEventId: watcherTriggerForRun.eventId }
784
+ : { lastDispatchedSlot: watcherTriggerForRun.slot });
785
+ }
546
786
  await deliverOutboundEvent(createLabelsEvent({
547
787
  projection: candidate,
548
788
  runId,
@@ -594,9 +834,19 @@ export function createTickRunner(deps) {
594
834
  const recentEvents = await deps.stateStore.listEventEnvelopesForWorkItem(candidate.workItemKey, 6);
595
835
  await transitionRunLifecycle('RUNNING');
596
836
  startLeaseRenewal();
837
+ const runnerProjection = watcherRun
838
+ ? {
839
+ ...candidate,
840
+ wake: {
841
+ ...candidate.wake,
842
+ sessionId: undefined,
843
+ sessionCli: undefined,
844
+ },
845
+ }
846
+ : candidate;
597
847
  const runnerResult = await deps.runner.run({
598
848
  action,
599
- projection: candidate,
849
+ projection: runnerProjection,
600
850
  recentEvents,
601
851
  config: deps.config,
602
852
  runId,
@@ -639,7 +889,16 @@ export function createTickRunner(deps) {
639
889
  ? null
640
890
  : lifecycle.nextStageFromSentinel(claimedStage, sentinel, workflow);
641
891
  const finishedAt = deps.clock.now().toISOString();
642
- if (workspaceMode === 'branch') {
892
+ let prReviewTargetResourceUri = null;
893
+ if (watcherRun && action === 'pr-review') {
894
+ prReviewTargetResourceUri = await resolvePrReviewTarget({
895
+ projection: candidate,
896
+ runId,
897
+ runnerResult,
898
+ occurredAt: finishedAt,
899
+ });
900
+ }
901
+ else if (workspaceMode === 'branch') {
643
902
  await registerReportedArtifacts({
644
903
  projection: candidate,
645
904
  runId,
@@ -764,21 +1023,24 @@ export function createTickRunner(deps) {
764
1023
  envelope: parsedRunnerResult.envelope,
765
1024
  executionOutcome,
766
1025
  ...(workflowOutcome !== undefined ? { workflowOutcome } : {}),
1026
+ ...(watcherRun ? { watcherRun: true, watcherTrigger: watcherTriggerForRun } : {}),
767
1027
  },
768
1028
  });
769
1029
  await deps.stateStore.appendEventEnvelope(runCompletedEvent);
770
1030
  await projectionUpdater.rebuildFromEvents([runCompletedEvent]);
771
- await deliverOutboundEvent(createLabelsEvent({
772
- projection: candidate,
773
- runId,
774
- statusLabel: statusLabelForOutcome({
775
- sentinel,
776
- stage: nextStage ?? claimedStage,
777
- }),
778
- stageLabel: stageLabelForStage(nextStage ?? claimedStage),
779
- workflowLabel: workflowLabelForWorkflowName(workflowName),
780
- occurredAt: finishedAt,
781
- }));
1031
+ if (!watcherRun) {
1032
+ await deliverOutboundEvent(createLabelsEvent({
1033
+ projection: candidate,
1034
+ runId,
1035
+ statusLabel: statusLabelForOutcome({
1036
+ sentinel,
1037
+ stage: nextStage ?? claimedStage,
1038
+ }),
1039
+ stageLabel: stageLabelForStage(nextStage ?? claimedStage),
1040
+ workflowLabel: workflowLabelForWorkflowName(workflowName),
1041
+ occurredAt: finishedAt,
1042
+ }));
1043
+ }
782
1044
  const publishIntent = createPublishIntentEvent({
783
1045
  projection: candidate,
784
1046
  runId,
@@ -793,7 +1055,32 @@ export function createTickRunner(deps) {
793
1055
  ? { previousFailureClass: candidate.context.lastFailureClass }
794
1056
  : {}),
795
1057
  });
796
- if (shouldPublishRunResult({
1058
+ if (watcherRun && action === 'pr-review') {
1059
+ if (prReviewTargetResourceUri !== null &&
1060
+ (sentinel === 'DONE' || sentinel === 'FAILED')) {
1061
+ await deliverOutboundEvent({
1062
+ ...publishIntent,
1063
+ sourceRefs: {
1064
+ ...publishIntent.sourceRefs,
1065
+ resourceUri: prReviewTargetResourceUri,
1066
+ },
1067
+ payload: {
1068
+ ...publishIntent.payload,
1069
+ kind: sentinel === 'DONE' ? 'approval-request' : 'status-update',
1070
+ body: sentinel === 'DONE'
1071
+ ? `${parsedRunnerResult.body}\n\n${prReviewApprovalMarker}`
1072
+ : `${parsedRunnerResult.body}\n\n<!-- wake:pr-review-changes-requested -->`,
1073
+ idempotencyKey: `${runId}:pr-review-verdict-comment`,
1074
+ },
1075
+ });
1076
+ }
1077
+ else {
1078
+ await suppressOutboundEvent(publishIntent, {
1079
+ suppressedPublishReason: 'pr-review-no-actionable-verdict',
1080
+ });
1081
+ }
1082
+ }
1083
+ else if (shouldPublishRunResult({
797
1084
  failureClass: runnerResult.failureClass,
798
1085
  previousFailureClass: candidate.context.lastFailureClass,
799
1086
  })) {
@@ -371,12 +371,26 @@ const runnerHealthEntrySchema = z.object({
371
371
  lastFailureAt: isoTimestampSchema.optional(),
372
372
  });
373
373
  const workflowWorkspaceSchema = z.enum(['none', 'read-only', 'branch']);
374
+ const workflowTriggerScheduleSchema = z.object({
375
+ cron: z.string().min(1),
376
+ });
374
377
  const workflowStageSchema = stageRouteSchema.extend({
375
378
  workspace: workflowWorkspaceSchema,
376
379
  onDone: identifierSchema,
377
- });
378
- const workflowTriggerScheduleSchema = z.object({
379
- cron: z.string().min(1),
380
+ watch: z
381
+ .array(z.object({
382
+ while: z.object({
383
+ status: z.array(z.string().min(1)).min(1),
384
+ }),
385
+ on: z
386
+ .object({
387
+ event: z.array(z.string().min(1)).min(1),
388
+ })
389
+ .optional(),
390
+ schedule: workflowTriggerScheduleSchema.optional(),
391
+ workflow: identifierSchema,
392
+ }))
393
+ .optional(),
380
394
  });
381
395
  const workflowTriggerSchema = z.object({
382
396
  schedule: workflowTriggerScheduleSchema.optional(),
@@ -831,6 +845,22 @@ export const wakeConfigSchema = wakeConfigBaseSchema.superRefine((config, ctx) =
831
845
  message: `Workflow stage "${stageName}" omits action but no prompts/${stageName}.md template exists.`,
832
846
  });
833
847
  }
848
+ for (const [index, watcher] of (stage.watch ?? []).entries()) {
849
+ if (watcher.on === undefined && watcher.schedule === undefined) {
850
+ ctx.addIssue({
851
+ code: z.ZodIssueCode.custom,
852
+ path: ['workflows', workflowName, 'stages', stageName, 'watch', index],
853
+ message: 'Watcher must declare at least one of on or schedule.',
854
+ });
855
+ }
856
+ if (config.workflows[watcher.workflow] === undefined) {
857
+ ctx.addIssue({
858
+ code: z.ZodIssueCode.custom,
859
+ path: ['workflows', workflowName, 'stages', stageName, 'watch', index, 'workflow'],
860
+ message: `Watcher targets unknown workflow "${watcher.workflow}".`,
861
+ });
862
+ }
863
+ }
834
864
  }
835
865
  if (actualEntryStage !== undefined &&
836
866
  stageSet.has(actualEntryStage) &&
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "g60727fc";
127
+ export const wakeVersion = "g45bda3a";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.35",
3
+ "version": "0.2.36",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -0,0 +1,29 @@
1
+ ---
2
+ permissionMode: default
3
+ allowedTools: Bash(gh pr view *), Bash(gh pr diff *), Bash(gh pr checks *), Bash(gh run view *), Bash(gh api repos/*/pulls/*), Bash(gh api repos/*/commits/*), Bash(git status), Bash(git log *), Bash(git diff *), Read, Glob, Grep
4
+ maxTurns: 8
5
+ skipApproval: true
6
+ ---
7
+ You are Wake, in the PR-REVIEW workflow for work item {{workItemKey}}.
8
+
9
+ Objective:
10
+ - Identify the pull request for this work item using read-only GitHub commands.
11
+ - Review the PR's diff, tests/checks, and surrounding code for correctness.
12
+ - Report the PR you examined using a `wake-artifacts` block.
13
+ - End with the Wake result envelope.
14
+
15
+ Verdict mapping:
16
+ - Use `DONE` only when you are confident the PR is safe to merge.
17
+ - Use `FAILED` when the PR needs changes; explain the required changes clearly.
18
+ - Use `BLOCKED` when you cannot determine a safe verdict.
19
+
20
+ Safety rules:
21
+ - Do not merge, approve via GitHub review, enable auto-merge, edit labels, push commits, or perform any administrative GitHub mutation.
22
+ - Do not use any `gh` subcommand other than the read-only commands allowed for this prompt.
23
+ - If you cannot find exactly one plausible PR for this work item, report `BLOCKED`.
24
+ - If the PR you reviewed is not reported in the `wake-artifacts` block, Wake will ignore the verdict.
25
+
26
+ Artifact reporting:
27
+ ```wake-artifacts
28
+ { "artifacts": [{ "kind": "pr", "url": "<the PR URL>" }] }
29
+ ```