@atolis-hq/wake 0.2.77 → 0.2.78

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.
@@ -95,10 +95,18 @@ export function createFakeTicketingSystem(options) {
95
95
  const workflowLabel = typeof input.event.payload.workflowLabel === 'string'
96
96
  ? input.event.payload.workflowLabel
97
97
  : undefined;
98
+ const frozenLabel = typeof input.event.payload.frozenLabel === 'string'
99
+ ? input.event.payload.frozenLabel
100
+ : undefined;
101
+ const scheduledLabel = typeof input.event.payload.scheduledLabel === 'string'
102
+ ? input.event.payload.scheduledLabel
103
+ : undefined;
98
104
  const labels = [
99
105
  ...currentLabels.filter((label) => !label.startsWith('wake:status.') &&
100
106
  !label.startsWith('wake:stage.') &&
101
- !label.startsWith('wake:workflow.')),
107
+ !label.startsWith('wake:workflow.') &&
108
+ label !== 'wake:frozen' &&
109
+ label !== 'wake:scheduled-workflow'),
102
110
  ...(statusLabel === undefined
103
111
  ? currentLabels.filter((label) => label.startsWith('wake:status.'))
104
112
  : [statusLabel]),
@@ -108,6 +116,8 @@ export function createFakeTicketingSystem(options) {
108
116
  ...(workflowLabel === undefined
109
117
  ? currentLabels.filter((label) => label.startsWith('wake:workflow.'))
110
118
  : [workflowLabel]),
119
+ ...(frozenLabel === undefined ? [] : [frozenLabel]),
120
+ ...(scheduledLabel === undefined ? [] : [scheduledLabel]),
111
121
  ];
112
122
  return [
113
123
  createEventEnvelope({
@@ -5,10 +5,12 @@ import { defaultAgentIdentity } from '../../domain/schema.js';
5
5
  import { buildResourceUri } from '../../domain/resource-uri.js';
6
6
  import { wakeStageLabelPrefix } from '../../domain/stages.js';
7
7
  import { wakeWorkflowLabelPrefix } from '../../domain/workflows.js';
8
+ import { FROZEN_WORK_ITEM_LABEL } from '../../domain/work-item-lifecycle.js';
8
9
  import { createEventEnvelope, createUnkeyedEventEnvelope } from '../../lib/event-log.js';
9
10
  import { createWakePaths } from '../../lib/paths.js';
10
11
  import { wakeVersion } from '../../version.js';
11
12
  import { buildResumeCommandForCli } from '../runner/runner-cli-adapter.js';
13
+ import { SCHEDULED_WORKFLOW_LABEL } from '../../domain/work-item-labels.js';
12
14
  const wakeStatusLabelPrefix = 'wake:status.';
13
15
  const pollOverlapMs = 60 * 60 * 1000;
14
16
  // Hidden marker appended to every comment Wake posts. `expectedEcho` normally
@@ -524,10 +526,22 @@ export function createGitHubIssuesWorkSource(deps) {
524
526
  const nextWorkflowLabel = typeof input.event.payload.workflowLabel === 'string'
525
527
  ? input.event.payload.workflowLabel
526
528
  : undefined;
529
+ // Unlike the three label families above, frozen/scheduled are single
530
+ // toggle labels: every call site now computes the full authoritative
531
+ // desired state via labelsForWorkItem, so their absence here means
532
+ // "should not be present" (not "leave unspecified as before").
533
+ const nextFrozenLabel = typeof input.event.payload.frozenLabel === 'string'
534
+ ? input.event.payload.frozenLabel
535
+ : undefined;
536
+ const nextScheduledLabel = typeof input.event.payload.scheduledLabel === 'string'
537
+ ? input.event.payload.scheduledLabel
538
+ : undefined;
527
539
  const nextLabels = [
528
540
  ...currentLabels.filter((label) => !label.startsWith(wakeStatusLabelPrefix) &&
529
541
  !label.startsWith(wakeStageLabelPrefix) &&
530
- !label.startsWith(wakeWorkflowLabelPrefix)),
542
+ !label.startsWith(wakeWorkflowLabelPrefix) &&
543
+ label !== FROZEN_WORK_ITEM_LABEL &&
544
+ label !== SCHEDULED_WORKFLOW_LABEL),
531
545
  ...(nextStatusLabel !== undefined
532
546
  ? [nextStatusLabel]
533
547
  : currentLabels.filter((label) => label.startsWith(wakeStatusLabelPrefix))),
@@ -537,6 +551,8 @@ export function createGitHubIssuesWorkSource(deps) {
537
551
  ...(nextWorkflowLabel !== undefined
538
552
  ? [nextWorkflowLabel]
539
553
  : currentLabels.filter((label) => label.startsWith(wakeWorkflowLabelPrefix))),
554
+ ...(nextFrozenLabel !== undefined ? [nextFrozenLabel] : []),
555
+ ...(nextScheduledLabel !== undefined ? [nextScheduledLabel] : []),
540
556
  ];
541
557
  const labelsChanged = nextLabels.length !== currentLabels.length ||
542
558
  !nextLabels.every((label, index) => label === currentLabels[index]);
@@ -564,6 +580,8 @@ export function createGitHubIssuesWorkSource(deps) {
564
580
  ...(nextStatusLabel !== undefined ? { statusLabel: nextStatusLabel } : {}),
565
581
  ...(nextStageLabel !== undefined ? { stageLabel: nextStageLabel } : {}),
566
582
  ...(nextWorkflowLabel !== undefined ? { workflowLabel: nextWorkflowLabel } : {}),
583
+ ...(nextFrozenLabel !== undefined ? { frozenLabel: nextFrozenLabel } : {}),
584
+ ...(nextScheduledLabel !== undefined ? { scheduledLabel: nextScheduledLabel } : {}),
567
585
  labels: nextLabels,
568
586
  providerEventType: 'github.issue.labels.updated',
569
587
  },
@@ -602,6 +620,8 @@ export function createGitHubIssuesWorkSource(deps) {
602
620
  input.event.payload.statusLabel,
603
621
  input.event.payload.stageLabel,
604
622
  input.event.payload.workflowLabel,
623
+ input.event.payload.frozenLabel,
624
+ input.event.payload.scheduledLabel,
605
625
  ].filter((label) => typeof label === 'string');
606
626
  if (expected.every((label) => currentLabels.includes(label))) {
607
627
  return [
@@ -477,7 +477,7 @@ function renderItemDetails(detail, boardItem) {
477
477
  el('dd', { text: value }),
478
478
  ])));
479
479
  }
480
- const isFrozen = typeof detail.item.context.frozenAt === 'string';
480
+ const isFrozen = detail.item.context.frozen !== undefined && detail.item.context.frozen !== null;
481
481
  const actionBar = el('div', { class: 'action-bar' });
482
482
  const lastRun = detail.runs.at(-1);
483
483
  if (lastRun && lastRun.sentinel === 'FAILED') {
@@ -6,11 +6,10 @@ import { createLabelsEvent } from '../../core/event-builders.js';
6
6
  import { createProjectionUpdater } from '../../core/projection-updater.js';
7
7
  import { CORRELATION_RETRACTED_EVENT, RETRY_REQUESTED_EVENT, RUN_REQUESTED_EVENT, WORK_ITEM_DELETED_EVENT, WORK_ITEM_FROZEN_EVENT, WORK_ITEM_UNFROZEN_EVENT, } from '../../domain/event-types.js';
8
8
  import { configuredTicketSource } from '../../domain/sources.js';
9
- import { stageLabelForStage } from '../../domain/stages.js';
10
9
  import { isWorkItemDeleted, isWorkItemFrozen } from '../../domain/work-item-lifecycle.js';
11
- import { workflowLabelForWorkflowName, workflowNameForProjection } from '../../domain/workflows.js';
12
10
  import { createEventEnvelope } from '../../lib/event-log.js';
13
11
  import { writeJsonFile } from '../../lib/json-file.js';
12
+ import { labelsForWorkItem } from '../../domain/work-item-labels.js';
14
13
  import { indexHtml } from './ui-assets.js';
15
14
  import { buildBoard, buildConfigView, buildEventsFeed, buildHealth, buildItemDetail, buildItemTranscripts, buildMetrics, buildRuns, buildStatus, buildWorkspaces, } from './ui-data.js';
16
15
  function sendJson(res, status, body) {
@@ -81,13 +80,10 @@ function buildUiWorkItemEvent(input) {
81
80
  }
82
81
  async function appendLabelSyncEvent(input) {
83
82
  const occurredAt = input.now().toISOString();
84
- const workflowName = workflowNameForProjection(input.item, input.config);
85
83
  const labelEvent = createLabelsEvent({
86
84
  projection: input.item,
87
85
  runId: `${input.action}-${input.item.workItemKey}-${input.now().getTime()}`,
88
- statusLabel: 'wake:status.pending',
89
- stageLabel: stageLabelForStage(input.item.wake.stage),
90
- workflowLabel: workflowLabelForWorkflowName(workflowName),
86
+ ...labelsForWorkItem(input.item, input.config),
91
87
  occurredAt,
92
88
  });
93
89
  const appended = await input.stateStore.appendEventEnvelope(labelEvent);
@@ -143,6 +143,8 @@ export function createLabelsEvent(input) {
143
143
  statusLabel: input.statusLabel,
144
144
  stageLabel: input.stageLabel,
145
145
  workflowLabel: input.workflowLabel,
146
+ ...(input.frozenLabel === undefined ? {} : { frozenLabel: input.frozenLabel }),
147
+ ...(input.scheduledLabel === undefined ? {} : { scheduledLabel: input.scheduledLabel }),
146
148
  origin: input.projection.origin ?? 'github',
147
149
  idempotencyKey: `${input.runId}:${labelKind}`,
148
150
  deliveryState: 'PENDING',
@@ -218,7 +218,17 @@ export function createPolicyEngine() {
218
218
  if (!approved && !changesRequested) {
219
219
  return null;
220
220
  }
221
- return { approved, pendingAction };
221
+ return {
222
+ approved,
223
+ pendingAction,
224
+ ...(changesRequested
225
+ ? {
226
+ changesRequested: true,
227
+ triggeringCommentId: latestHumanComment.id,
228
+ triggeringCommentBody: latestHumanComment.body,
229
+ }
230
+ : {}),
231
+ };
222
232
  },
223
233
  // Callers must try resolveApprovalTransition first and only fall back to
224
234
  // this when it returns null. resolveApprovalTransition doesn't check
@@ -247,6 +257,20 @@ export function createPolicyEngine() {
247
257
  }
248
258
  return reviewFeedbackAction;
249
259
  },
260
+ // A rejecting review watcher (or an already-folded /changes reply) sets
261
+ // context.status = 'changes-requested' with no fresh comment required —
262
+ // this is the gap #472 described: nothing distinguished a plan-review
263
+ // rejection from "never reviewed yet." Bounding (escalating to 'blocked'
264
+ // once config.retry.maxChangesRequestedRetries is exceeded) already
265
+ // happened at fold time, so this only has to check the current status.
266
+ resolveChangesRequestedAction(issue) {
267
+ if (!isAwaitingApproval(issue) || issue.context.status !== 'changes-requested') {
268
+ return null;
269
+ }
270
+ return typeof issue.context.pendingApprovalAction === 'string'
271
+ ? issue.context.pendingApprovalAction
272
+ : null;
273
+ },
250
274
  resolveCustomCommandRequest(issue, config) {
251
275
  return resolveCustomCommand(issue, config);
252
276
  },
@@ -274,6 +298,10 @@ export function createPolicyEngine() {
274
298
  if (approval !== null) {
275
299
  return { action: approval.pendingAction, workflow };
276
300
  }
301
+ const changesRequestedAction = this.resolveChangesRequestedAction(issue);
302
+ if (changesRequestedAction !== null) {
303
+ return { action: changesRequestedAction, workflow };
304
+ }
277
305
  const reviewAction = this.resolvePendingReviewFeedback(issue);
278
306
  if (reviewAction !== null) {
279
307
  return { action: reviewAction, workflow };
@@ -2,6 +2,7 @@ import { CORRELATION_PRIMARY_CONFLICT_EVENT, CORRELATION_REGISTERED_EVENT, CORRE
2
2
  import { UNRESOLVED_WORK_ITEM_KEY, parseIssueStateRecord } from '../domain/schema.js';
3
3
  import { doneRunnerSentinel, stageFromLabels } from '../domain/stages.js';
4
4
  import { FROZEN_WORK_ITEM_LABEL } from '../domain/work-item-lifecycle.js';
5
+ import { workItemStatusForRunOutcome } from '../domain/work-item-status.js';
5
6
  import { builtInDefaultWorkflowDefinition, defaultWorkflowName, selectWorkflowForEvent, workflowStageVocabulary, } from '../domain/workflows.js';
6
7
  import { isCustomCommandAction } from '../domain/custom-commands.js';
7
8
  import { createEventEnvelope } from '../lib/event-log.js';
@@ -25,6 +26,10 @@ function createProjectionFromIssueEvent(event, config) {
25
26
  const labels = Array.isArray(issue.labels)
26
27
  ? issue.labels
27
28
  : [];
29
+ // Only the scheduled-workflow-source's synthetic ticket carries a
30
+ // wake:schedule resourceUri; no other mint path sets this, so `scheduled`
31
+ // has a real backing context field instead of only ever living in a label.
32
+ const scheduled = event.sourceRefs.resourceUri?.split(':')[1] === 'schedule';
28
33
  return parseIssueStateRecord({
29
34
  schemaVersion: 1,
30
35
  workItemKey: event.workItemKey,
@@ -36,7 +41,7 @@ function createProjectionFromIssueEvent(event, config) {
36
41
  recentEventIds: [event.eventId],
37
42
  syncedAt: event.ingestedAt,
38
43
  },
39
- context: {},
44
+ context: scheduled ? { scheduled: true } : {},
40
45
  });
41
46
  }
42
47
  async function pinSelectedWorkflow(projection, event, ctx, config) {
@@ -174,6 +179,10 @@ async function applyEvent(current, event, ctx, config) {
174
179
  }
175
180
  return parseIssueStateRecord({
176
181
  ...current,
182
+ context: {
183
+ ...current.context,
184
+ status: 'working',
185
+ },
177
186
  wake: {
178
187
  ...current.wake,
179
188
  stage: payload.claimedStage,
@@ -193,6 +202,42 @@ async function applyEvent(current, event, ctx, config) {
193
202
  }
194
203
  if (event.sourceEventType === RUN_COMPLETED_EVENT) {
195
204
  const payload = event.payload;
205
+ // A rejecting plan-review/pr-review watcher sub-run (§5, trigger 1) or a
206
+ // human /changes reply (§5, trigger 2) folds onto the parent's context
207
+ // without touching wake.stage/lastRunId/session — same isolation
208
+ // guarantee PR #479 established for watcher runs generally. Checked
209
+ // before the watcherRun early-return below so a rejecting watcher run
210
+ // (which also carries watcherRun: true) still updates status/feedback,
211
+ // while every other watcher-run completion (e.g. an approving DONE
212
+ // verdict) still hits that early-return untouched.
213
+ if (payload.changesRequested === true) {
214
+ const currentChangesRequestedCount = typeof current.context.changesRequestedCount === 'number' &&
215
+ Number.isInteger(current.context.changesRequestedCount)
216
+ ? current.context.changesRequestedCount
217
+ : 0;
218
+ const nextChangesRequestedCount = currentChangesRequestedCount + 1;
219
+ const maxRetries = config?.retry.maxChangesRequestedRetries ?? Infinity;
220
+ const escalate = nextChangesRequestedCount > maxRetries;
221
+ return parseIssueStateRecord({
222
+ ...current,
223
+ context: {
224
+ ...current.context,
225
+ status: escalate ? 'blocked' : 'changes-requested',
226
+ changesRequestedCount: nextChangesRequestedCount,
227
+ ...(payload.reviewFeedbackBody === undefined
228
+ ? {}
229
+ : { changesRequestedFeedback: payload.reviewFeedbackBody }),
230
+ ...(payload.handledCommentId === undefined
231
+ ? {}
232
+ : { lastHandledCommentId: payload.handledCommentId }),
233
+ },
234
+ wake: {
235
+ ...current.wake,
236
+ syncedAt: event.ingestedAt,
237
+ recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
238
+ },
239
+ });
240
+ }
196
241
  if (payload.watcherRun === true) {
197
242
  return parseIssueStateRecord({
198
243
  ...current,
@@ -265,6 +310,19 @@ async function applyEvent(current, event, ctx, config) {
265
310
  ? { lastExternalSideEffects: payload.externalSideEffects }
266
311
  : {}),
267
312
  ...(payload.retrySafety !== undefined ? { lastRetrySafety: payload.retrySafety } : {}),
313
+ ...(payload.sentinel === undefined || isCompletedCustomCommand
314
+ ? {}
315
+ : {
316
+ status: workItemStatusForRunOutcome({
317
+ sentinel: payload.sentinel,
318
+ stage: payload.nextStage ?? current.wake.stage,
319
+ }),
320
+ }),
321
+ // A fresh DONE/AWAITING_APPROVAL cycle resolves whatever changes were
322
+ // previously requested — reset the loop counter and stored feedback.
323
+ ...(payload.sentinel === doneRunnerSentinel || payload.sentinel === 'AWAITING_APPROVAL'
324
+ ? { changesRequestedCount: 0, changesRequestedFeedback: undefined }
325
+ : {}),
268
326
  };
269
327
  if (payload.sentinel === 'BLOCKED' || payload.sentinel === 'FAILED') {
270
328
  nextContext.blockedFromStage = current.wake.stage;
@@ -359,8 +417,10 @@ async function applyEvent(current, event, ctx, config) {
359
417
  ...current,
360
418
  context: {
361
419
  ...current.context,
362
- deletedAt: event.occurredAt,
363
- deletedBy: typeof event.payload.requestedBy === 'string' ? event.payload.requestedBy : 'unknown',
420
+ deleted: {
421
+ at: event.occurredAt,
422
+ by: typeof event.payload.requestedBy === 'string' ? event.payload.requestedBy : 'unknown',
423
+ },
364
424
  },
365
425
  wake: {
366
426
  ...current.wake,
@@ -380,8 +440,10 @@ async function applyEvent(current, event, ctx, config) {
380
440
  },
381
441
  context: {
382
442
  ...current.context,
383
- frozenAt: event.occurredAt,
384
- frozenBy: typeof event.payload.requestedBy === 'string' ? event.payload.requestedBy : 'unknown',
443
+ frozen: {
444
+ at: event.occurredAt,
445
+ by: typeof event.payload.requestedBy === 'string' ? event.payload.requestedBy : 'unknown',
446
+ },
385
447
  },
386
448
  wake: {
387
449
  ...current.wake,
@@ -392,8 +454,7 @@ async function applyEvent(current, event, ctx, config) {
392
454
  }
393
455
  if (event.sourceEventType === WORK_ITEM_UNFROZEN_EVENT) {
394
456
  const nextContext = { ...current.context };
395
- delete nextContext.frozenAt;
396
- delete nextContext.frozenBy;
457
+ delete nextContext.frozen;
397
458
  return parseIssueStateRecord({
398
459
  ...current,
399
460
  issue: {
@@ -1,7 +1,6 @@
1
1
  import { createLabelsEvent } from './event-builders.js';
2
2
  import { RUN_COMPLETED_EVENT } from '../domain/event-types.js';
3
- import { stageLabelForStage } from '../domain/stages.js';
4
- import { workflowLabelForWorkflowName, workflowNameForProjection } from '../domain/workflows.js';
3
+ import { labelsForWorkItem } from '../domain/work-item-labels.js';
5
4
  import { createEventEnvelope } from '../lib/event-log.js';
6
5
  // Reconciles run records still marked `running` past the runner timeout: a
7
6
  // record whose work item has already moved on is superseded, otherwise it is
@@ -89,9 +88,7 @@ export function createStaleRunReconciler(deps) {
89
88
  await deps.deliverOutboundEvent(createLabelsEvent({
90
89
  projection,
91
90
  runId,
92
- statusLabel: 'wake:status.failed',
93
- stageLabel: stageLabelForStage(projection.wake.stage),
94
- workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(projection, deps.config)),
91
+ ...labelsForWorkItem(projection, deps.config),
95
92
  occurredAt: finishedAt,
96
93
  }));
97
94
  }
@@ -8,10 +8,11 @@ import { acquireFileLock } from '../lib/lock.js';
8
8
  import { CORRELATION_REGISTERED_EVENT, CORRELATION_PRIMARY_CONFLICT_EVENT, PR_AUTO_MERGE_ENABLED_EVENT, PR_REVIEW_APPROVED_EVENT, PUBLISH_INTENT_REQUESTED_EVENT, RUN_CLAIMED_EVENT, RUN_COMPLETED_EVENT, } from '../domain/event-types.js';
9
9
  import { parseRunnerArtifacts, parseRunnerResult } from '../domain/schema.js';
10
10
  import { maxConfiguredRunnerTimeoutMs, resolveRunnerRouting } from '../domain/runner-routing.js';
11
- import { awaitingApprovalRunnerSentinel, stageLabelForStage } from '../domain/stages.js';
12
- import { isWorkItemDeleted, isWorkItemRunnable } from '../domain/work-item-lifecycle.js';
11
+ import { awaitingApprovalRunnerSentinel } from '../domain/stages.js';
12
+ import { FROZEN_WORK_ITEM_LABEL, isWorkItemDeleted, isWorkItemRunnable, } from '../domain/work-item-lifecycle.js';
13
13
  import { isMeaningfulRuntimeEvent } from '../domain/runtime-events.js';
14
- import { chooseAction as chooseWorkflowAction, entryStage as workflowEntryStage, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection, workflowLabelForWorkflowName, workflowNameForProjection, } from '../domain/workflows.js';
14
+ import { chooseAction as chooseWorkflowAction, entryStage as workflowEntryStage, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection, workflowNameForProjection, } from '../domain/workflows.js';
15
+ import { labelsForWorkItem, SCHEDULED_WORKFLOW_LABEL, } from '../domain/work-item-labels.js';
15
16
  import { createEventEnvelope } from '../lib/event-log.js';
16
17
  import { branchNameForIssue } from '../domain/branch-naming.js';
17
18
  import { customCommandWorkspace, isCustomCommandAction } from '../domain/custom-commands.js';
@@ -205,23 +206,13 @@ export function createTickRunner(deps) {
205
206
  function isAwaitingApproval(projection) {
206
207
  return projection.context.lastRunSentinel === awaitingApprovalRunnerSentinel;
207
208
  }
208
- function statusLabelForStage(stage) {
209
- if (stage === 'done') {
210
- return 'wake:status.completed';
211
- }
212
- return 'wake:status.pending';
213
- }
214
- function statusLabelForOutcome(input) {
215
- if (input.sentinel === 'AWAITING_APPROVAL') {
216
- return 'wake:status.awaiting-approval';
217
- }
218
- if (input.sentinel === 'BLOCKED') {
219
- return 'wake:status.blocked';
220
- }
221
- if (input.sentinel === 'FAILED') {
222
- return 'wake:status.failed';
223
- }
224
- return statusLabelForStage(input.stage);
209
+ // Always reads the current projection at write time (never a threaded
210
+ // local like a stale `workflowName`/`claimedStage`), so a mid-run
211
+ // WORKFLOW_SELECTED_EVENT or freeze/schedule change can never be
212
+ // stamped with a label reflecting a snapshot from before it took effect.
213
+ async function currentLabelsForWorkItem(workItemKey, fallback) {
214
+ const projection = (await deps.stateStore.readIssueState(workItemKey)) ?? fallback;
215
+ return labelsForWorkItem(projection, deps.config);
225
216
  }
226
217
  function hasLabel(projection, label) {
227
218
  return projection.issue.labels.includes(label);
@@ -354,9 +345,7 @@ export function createTickRunner(deps) {
354
345
  await deliverOutboundEvent(createLabelsEvent({
355
346
  projection: input.projection,
356
347
  runId,
357
- statusLabel: 'wake:status.blocked',
358
- stageLabel: stageLabelForStage(input.projection.wake.stage),
359
- workflowLabel: workflowLabelForWorkflowName(input.workflowName),
348
+ ...(await currentLabelsForWorkItem(input.projection.workItemKey, input.projection)),
360
349
  occurredAt,
361
350
  }));
362
351
  return {
@@ -486,13 +475,51 @@ export function createTickRunner(deps) {
486
475
  await deliverOutboundEvent(createLabelsEvent({
487
476
  projection: input.projection,
488
477
  runId: input.approvalId,
489
- statusLabel: statusLabelForStage(nextStage),
490
- stageLabel: stageLabelForStage(nextStage),
491
- workflowLabel: workflowLabelForWorkflowName(input.workflowName),
478
+ ...(await currentLabelsForWorkItem(input.projection.workItemKey, input.projection)),
492
479
  occurredAt: input.approvedAt,
493
480
  }));
494
481
  return { nextStage };
495
482
  }
483
+ // Folds a human /changes reply onto the parent's context via the same
484
+ // `changesRequested` RUN_COMPLETED payload shape a rejecting review watcher
485
+ // uses (projection-updater.ts) — same fold, same bounding against
486
+ // config.retry.maxChangesRequestedRetries, so a human bouncing /changes
487
+ // repeatedly escalates to 'blocked' exactly like an auto-revise loop would.
488
+ async function applyChangesRequestedTransition(input) {
489
+ const currentCount = typeof input.projection.context.changesRequestedCount === 'number' &&
490
+ Number.isInteger(input.projection.context.changesRequestedCount)
491
+ ? input.projection.context.changesRequestedCount
492
+ : 0;
493
+ const escalated = currentCount + 1 > deps.config.retry.maxChangesRequestedRetries;
494
+ const event = createEventEnvelope({
495
+ eventId: `${input.requestId}-changes-requested`,
496
+ workItemKey: input.projection.workItemKey,
497
+ streamScope: 'work-item',
498
+ direction: 'internal',
499
+ sourceSystem: 'wake',
500
+ sourceEventType: RUN_COMPLETED_EVENT,
501
+ sourceRefs: {
502
+ repo: input.projection.issue.repo,
503
+ issueNumber: input.projection.issue.number,
504
+ runId: input.requestId,
505
+ },
506
+ occurredAt: input.requestedAt,
507
+ ingestedAt: input.requestedAt,
508
+ trigger: 'immediate',
509
+ payload: {
510
+ changesRequested: true,
511
+ runId: input.requestId,
512
+ reason: 'human:changes-requested',
513
+ ...(input.feedbackBody === undefined ? {} : { reviewFeedbackBody: input.feedbackBody }),
514
+ ...(input.triggeringCommentId === undefined
515
+ ? {}
516
+ : { handledCommentId: input.triggeringCommentId }),
517
+ },
518
+ });
519
+ await deps.stateStore.appendEventEnvelope(event);
520
+ await projectionUpdater.rebuildFromEvents([event]);
521
+ return { escalated };
522
+ }
496
523
  // Closes the loop on #82's review feedback: rather than scrape a PR link
497
524
  // out of the agent's free text, the agent emits a `wake-artifacts` fence
498
525
  // (domain/schema.ts's parseRunnerArtifacts) and Wake verifies each claim
@@ -585,6 +612,11 @@ export function createTickRunner(deps) {
585
612
  }
586
613
  return watch;
587
614
  }
615
+ // Runs against every open, non-deleted item every tick (not just ones with
616
+ // an eligible next action) so a stale label self-heals even on a parked
617
+ // item nothing else would otherwise re-check — this is also where
618
+ // wake:frozen/wake:scheduled-workflow get their only reconciliation
619
+ // coverage, since freeze/unfreeze no longer writes labels inline (§6).
588
620
  async function markPendingActionableIssues(projections) {
589
621
  const activeRunWorkItemKeys = new Set();
590
622
  const now = deps.clock.now();
@@ -594,24 +626,28 @@ export function createTickRunner(deps) {
594
626
  }
595
627
  }
596
628
  for (const projection of projections) {
597
- if (!isWorkItemRunnable(projection) || activeRunWorkItemKeys.has(projection.workItemKey)) {
629
+ if (isWorkItemDeleted(projection) || activeRunWorkItemKeys.has(projection.workItemKey)) {
598
630
  continue;
599
631
  }
600
- const statusLabel = statusLabelForStage(projection.wake.stage);
601
- const stageLabel = stageLabelForStage(projection.wake.stage);
602
- const workflowLabel = workflowLabelForWorkflowName(workflowNameForProjection(projection, deps.config));
603
- if (policy.resolveNextEligibleAction(projection, deps.config) === null ||
604
- (hasLabel(projection, statusLabel) &&
605
- hasLabel(projection, stageLabel) &&
606
- hasLabel(projection, workflowLabel))) {
632
+ const labels = labelsForWorkItem(projection, deps.config);
633
+ const matchesDesired = hasLabel(projection, labels.statusLabel) &&
634
+ hasLabel(projection, labels.stageLabel) &&
635
+ hasLabel(projection, labels.workflowLabel) &&
636
+ (labels.frozenLabel === undefined || hasLabel(projection, labels.frozenLabel)) &&
637
+ (labels.scheduledLabel === undefined || hasLabel(projection, labels.scheduledLabel));
638
+ // A toggle label (frozen/scheduled) whose desired state is "absent"
639
+ // still needs to be checked for stray presence — the family-prefix
640
+ // labels above don't need this since removing them is folded into
641
+ // "does the current one match" by construction.
642
+ const hasStrayToggleLabel = (labels.frozenLabel === undefined && hasLabel(projection, FROZEN_WORK_ITEM_LABEL)) ||
643
+ (labels.scheduledLabel === undefined && hasLabel(projection, SCHEDULED_WORKFLOW_LABEL));
644
+ if (matchesDesired && !hasStrayToggleLabel) {
607
645
  continue;
608
646
  }
609
647
  await deliverOutboundEvent(createLabelsEvent({
610
648
  projection,
611
649
  runId: `pending-${projection.workItemKey}-${deps.clock.now().getTime()}`,
612
- statusLabel,
613
- stageLabel,
614
- workflowLabel,
650
+ ...labels,
615
651
  occurredAt: eventStampNow(),
616
652
  }));
617
653
  }
@@ -786,9 +822,7 @@ export function createTickRunner(deps) {
786
822
  await deliverOutboundEvent(createLabelsEvent({
787
823
  projection,
788
824
  runId: eventId,
789
- statusLabel: 'wake:status.blocked',
790
- stageLabel: stageLabelForStage(projection.wake.stage),
791
- workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(projection, deps.config)),
825
+ ...(await currentLabelsForWorkItem(projection.workItemKey, projection)),
792
826
  occurredAt,
793
827
  }));
794
828
  parked = true;
@@ -842,9 +876,10 @@ export function createTickRunner(deps) {
842
876
  }
843
877
  const projections = await deps.stateStore.listIssueStates();
844
878
  await cleanupClosedIssueWorkspaces(projections);
845
- if (inboundEvents.length > 0) {
846
- await markPendingActionableIssues(projections);
847
- }
879
+ // Runs every tick, not only when this tick happened to poll a fresh
880
+ // inbound event — a stale label on a parked item (nothing else ever
881
+ // re-checks it once there's no next action) otherwise never self-heals.
882
+ await markPendingActionableIssues(projections);
848
883
  return {
849
884
  status: inboundEvents.length > 0 ? 'processed' : 'idle',
850
885
  };
@@ -1191,15 +1226,30 @@ export function createTickRunner(deps) {
1191
1226
  else {
1192
1227
  const approvalResolution = policy.resolveApprovalTransition(candidate);
1193
1228
  if (approvalResolution === null) {
1194
- const reviewAction = policy.resolvePendingReviewFeedback(candidate);
1195
- if (reviewAction === null) {
1196
- return { status: 'idle' };
1229
+ const changesRequestedAction = policy.resolveChangesRequestedAction(candidate);
1230
+ if (changesRequestedAction !== null) {
1231
+ action = changesRequestedAction;
1232
+ const workflowAction = chooseWorkflowAction(candidate, workflow);
1233
+ claimedStage = workflowAction?.stage ?? candidate.wake.stage;
1234
+ workspaceMode = workflowAction?.workspace ?? 'none';
1235
+ promptContextOverrides = {
1236
+ ...workflowAction?.promptContext,
1237
+ ...(typeof candidate.context.changesRequestedFeedback === 'string'
1238
+ ? { parentPendingReviewBody: candidate.context.changesRequestedFeedback }
1239
+ : {}),
1240
+ };
1241
+ }
1242
+ else {
1243
+ const reviewAction = policy.resolvePendingReviewFeedback(candidate);
1244
+ if (reviewAction === null) {
1245
+ return { status: 'idle' };
1246
+ }
1247
+ action = reviewAction;
1248
+ const workflowAction = chooseWorkflowAction(candidate, workflow);
1249
+ claimedStage = workflowAction?.stage ?? candidate.wake.stage;
1250
+ workspaceMode = workflowAction?.workspace ?? 'none';
1251
+ promptContextOverrides = workflowAction?.promptContext;
1197
1252
  }
1198
- action = reviewAction;
1199
- const workflowAction = chooseWorkflowAction(candidate, workflow);
1200
- claimedStage = workflowAction?.stage ?? candidate.wake.stage;
1201
- workspaceMode = workflowAction?.workspace ?? 'none';
1202
- promptContextOverrides = workflowAction?.promptContext;
1203
1253
  }
1204
1254
  else if (approvalResolution.approved) {
1205
1255
  const mergePolicy = approvedMergePolicyForStage(workflow.stages[candidate.wake.stage]);
@@ -1294,11 +1344,34 @@ export function createTickRunner(deps) {
1294
1344
  };
1295
1345
  }
1296
1346
  else {
1347
+ const changesRequestId = `changes-requested-${candidate.issue.number}-${deps.clock.now().getTime()}`;
1348
+ const requestedAt = deps.clock.now().toISOString();
1349
+ const changesRequestedTriggeringCommentId = approvalResolution.triggeringCommentId ?? latestHumanCommentId(candidate);
1350
+ const { escalated } = await applyChangesRequestedTransition({
1351
+ projection: candidate,
1352
+ requestId: changesRequestId,
1353
+ requestedAt,
1354
+ ...(approvalResolution.triggeringCommentBody === undefined
1355
+ ? {}
1356
+ : { feedbackBody: approvalResolution.triggeringCommentBody }),
1357
+ ...(changesRequestedTriggeringCommentId === undefined
1358
+ ? {}
1359
+ : { triggeringCommentId: changesRequestedTriggeringCommentId }),
1360
+ });
1361
+ if (escalated) {
1362
+ return { status: 'idle' };
1363
+ }
1297
1364
  action = approvalResolution.pendingAction;
1298
1365
  const workflowAction = chooseWorkflowAction(candidate, workflow);
1299
1366
  claimedStage = workflowAction?.stage ?? candidate.wake.stage;
1300
1367
  workspaceMode = workflowAction?.workspace ?? 'none';
1301
- promptContextOverrides = workflowAction?.promptContext;
1368
+ promptContextOverrides = {
1369
+ ...workflowAction?.promptContext,
1370
+ ...(approvalResolution.changesRequested === true &&
1371
+ approvalResolution.triggeringCommentBody !== undefined
1372
+ ? { parentPendingReviewBody: approvalResolution.triggeringCommentBody }
1373
+ : {}),
1374
+ };
1302
1375
  }
1303
1376
  }
1304
1377
  }
@@ -1480,9 +1553,7 @@ export function createTickRunner(deps) {
1480
1553
  await deliverOutboundEvent(createLabelsEvent({
1481
1554
  projection: candidate,
1482
1555
  runId,
1483
- statusLabel: 'wake:status.working',
1484
- stageLabel: stageLabelForStage(claimedStage),
1485
- workflowLabel: workflowLabelForWorkflowName(workflowName),
1556
+ ...(await currentLabelsForWorkItem(candidate.workItemKey, candidate)),
1486
1557
  occurredAt: eventStampNow(),
1487
1558
  }));
1488
1559
  }
@@ -1865,6 +1936,17 @@ export function createTickRunner(deps) {
1865
1936
  ...resultMetadata,
1866
1937
  },
1867
1938
  });
1939
+ // Computed before the payload so a rejecting review verdict (either
1940
+ // the PR-artifact-verified path or a plain onSuccess.approve watcher
1941
+ // with no PR) can fold context.status = 'changes-requested' on the
1942
+ // parent via the same RUN_COMPLETED event, instead of requiring a
1943
+ // separate marker comment round-trip (§5).
1944
+ const watcherSuccessPolicy = watcherRun && watcherDispatch !== null
1945
+ ? deps.config.workflows[watcherDispatch.parentWorkflowName]?.stages[watcherDispatch.parentStage]?.watch?.[watcherDispatch.watcherIndex]?.onSuccess
1946
+ : undefined;
1947
+ const isReviewRejection = watcherRun &&
1948
+ (sentinel === 'FAILED' || sentinel === 'BLOCKED') &&
1949
+ (prReviewTargetResourceUri !== null || watcherSuccessPolicy?.approve === true);
1868
1950
  const runCompletedEvent = createEventEnvelope({
1869
1951
  eventId: `${runId}-completed`,
1870
1952
  workItemKey: candidate.workItemKey,
@@ -1912,6 +1994,9 @@ export function createTickRunner(deps) {
1912
1994
  executionOutcome,
1913
1995
  ...(workflowOutcome !== undefined ? { workflowOutcome } : {}),
1914
1996
  ...(watcherRun ? { watcherRun: true, watcherTrigger: watcherTriggerForRun } : {}),
1997
+ ...(isReviewRejection
1998
+ ? { changesRequested: true, reviewFeedbackBody: parsedRunnerResult.body }
1999
+ : {}),
1915
2000
  },
1916
2001
  });
1917
2002
  await deps.stateStore.appendEventEnvelope(runCompletedEvent);
@@ -1920,12 +2005,7 @@ export function createTickRunner(deps) {
1920
2005
  await deliverOutboundEvent(createLabelsEvent({
1921
2006
  projection: candidate,
1922
2007
  runId,
1923
- statusLabel: statusLabelForOutcome({
1924
- sentinel,
1925
- stage: nextStage ?? claimedStage,
1926
- }),
1927
- stageLabel: stageLabelForStage(nextStage ?? claimedStage),
1928
- workflowLabel: workflowLabelForWorkflowName(workflowName),
2008
+ ...(await currentLabelsForWorkItem(candidate.workItemKey, candidate)),
1929
2009
  occurredAt: finishedAt,
1930
2010
  }));
1931
2011
  }
@@ -1947,9 +2027,6 @@ export function createTickRunner(deps) {
1947
2027
  // Watcher-dispatched runs own PR verdict delivery and correlation
1948
2028
  // registration regardless of the target workflow/action name.
1949
2029
  const pendingApprovalAction = candidate.context.pendingApprovalAction;
1950
- const watcherSuccessPolicy = watcherDispatch === null
1951
- ? undefined
1952
- : deps.config.workflows[watcherDispatch.parentWorkflowName]?.stages[watcherDispatch.parentStage]?.watch?.[watcherDispatch.watcherIndex]?.onSuccess;
1953
2030
  if (prReviewTargetResourceUri !== null &&
1954
2031
  (sentinel === 'DONE' || sentinel === 'FAILED')) {
1955
2032
  await deliverOutboundEvent({
@@ -2125,9 +2202,7 @@ export function createTickRunner(deps) {
2125
2202
  await deliverOutboundEvent(createLabelsEvent({
2126
2203
  projection: candidate,
2127
2204
  runId,
2128
- statusLabel: 'wake:status.failed',
2129
- stageLabel: stageLabelForStage(claimedStage),
2130
- workflowLabel: workflowLabelForWorkflowName(workflowName),
2205
+ ...(await currentLabelsForWorkItem(candidate.workItemKey, candidate)),
2131
2206
  occurredAt: finishedAt,
2132
2207
  }));
2133
2208
  }
@@ -7,6 +7,7 @@ import { runnerSentinelValues } from './stages.js';
7
7
  import { correlationProvenanceSchema, correlationRelationSchema, correlationRoleSchema, resourceUriSchema, } from './resource-uri.js';
8
8
  import { runtimeEventTypeValues } from './runtime-events.js';
9
9
  import { alwaysManualIgnoredLabels } from './manual-labels.js';
10
+ import { workItemStatusSchema } from './work-item-status.js';
10
11
  export { AUTONOMOUS_DECISION_AUDIT_EVENT, CORRELATION_PRIMARY_CONFLICT_EVENT, CORRELATION_REGISTERED_EVENT, CORRELATION_RETRACTED_EVENT, WORK_ITEM_CREATED_EVENT, } from './event-types.js';
11
12
  const isoTimestampSchema = z.string().datetime({ offset: true });
12
13
  const identifierSchema = z.string().min(1);
@@ -275,6 +276,61 @@ export const eventEnvelopeSchema = z.object({
275
276
  raw: z.record(z.string(), z.unknown()).optional(),
276
277
  derivedHints: z.record(z.string(), z.unknown()).optional(),
277
278
  });
279
+ export const failurePhaseSchema = z.enum([
280
+ 'workspace-validation',
281
+ 'workspace-prep',
282
+ 'process-starting',
283
+ 'running',
284
+ 'result-parsing',
285
+ 'publishing',
286
+ 'unknown',
287
+ ]);
288
+ export const externalSideEffectsSchema = z.enum(['none', 'confirmed', 'unknown']);
289
+ export const retrySafetySchema = z.enum([
290
+ 'SAFE_TO_RETRY',
291
+ 'SAFE_TO_RESUME',
292
+ 'REQUIRES_RECONCILIATION',
293
+ 'MANUAL_REVIEW_REQUIRED',
294
+ 'NOT_RETRYABLE',
295
+ ]);
296
+ // Typed `context` — every field is optional so an on-disk projection missing
297
+ // some/all of them (pre-dating this schema, or simply untouched since) still
298
+ // parses. See docs/superpowers/specs/2026-07-28-canonical-work-item-status-design.md §4/§8.
299
+ export const issueContextSchema = z.object({
300
+ // Retry/failure bookkeeping, folded from RUN_COMPLETED (projection-updater.ts).
301
+ failureCount: z.number().int().nonnegative().optional(),
302
+ lastRunAction: z.string().optional(),
303
+ lastCompletedAction: z.string().optional(),
304
+ lastExecutionOutcome: executionOutcomeSchema.optional(),
305
+ lastWorkflowOutcome: workflowOutcomeSchema.optional(),
306
+ lastFailurePhase: failurePhaseSchema.optional(),
307
+ lastProcessStarted: z.boolean().optional(),
308
+ lastWorkspaceChanged: z.boolean().optional(),
309
+ lastExternalSideEffects: externalSideEffectsSchema.optional(),
310
+ lastRetrySafety: retrySafetySchema.optional(),
311
+ blockedFromStage: z.string().optional(),
312
+ lastFailureClass: z.enum(['task', 'quota', 'infra']).optional(),
313
+ lastHandledCommentId: z.string().optional(),
314
+ lastRunSentinel: runnerSentinelSchema.optional(),
315
+ // Set while AWAITING_APPROVAL so the approval path knows which action to
316
+ // resume (or skip) once a human responds.
317
+ pendingApprovalAction: z.string().optional(),
318
+ pendingApprovalAllowAutoApproval: z.boolean().optional(),
319
+ // Workflow pinned at mint time (WORKFLOW_SELECTED_EVENT); re-read at every
320
+ // label write instead of trusting a threaded local (workflow-name-drift, §6).
321
+ workflow: z.string().optional(),
322
+ // Canonical status — see work-item-status.ts. Additive alongside
323
+ // lastRunSentinel, not a replacement for it (§3).
324
+ status: workItemStatusSchema.optional(),
325
+ changesRequestedCount: z.number().int().nonnegative().optional(),
326
+ // Feedback body threaded into the next auto-revise prompt as
327
+ // promptContextOverrides.parentPendingReviewBody (§5).
328
+ changesRequestedFeedback: z.string().optional(),
329
+ // Orthogonal facts — not folded into `status` (§2).
330
+ frozen: z.object({ at: isoTimestampSchema, by: z.string() }).optional(),
331
+ deleted: z.object({ at: isoTimestampSchema, by: z.string() }).optional(),
332
+ scheduled: z.boolean().optional(),
333
+ });
278
334
  export const issueStateRecordSchema = z.object({
279
335
  schemaVersion: z.literal(1),
280
336
  workItemKey: z.string(),
@@ -299,7 +355,7 @@ export const issueStateRecordSchema = z.object({
299
355
  })
300
356
  .default({ commentIds: [], labels: [] }),
301
357
  }),
302
- context: z.record(z.string(), z.unknown()).default({}),
358
+ context: issueContextSchema.default({}),
303
359
  correlatedResources: z.array(correlatedResourceSchema).default([]),
304
360
  });
305
361
  export const runInputSnapshotSchema = z.object({
@@ -338,23 +394,6 @@ const runLeaseSchema = z.object({
338
394
  lastRenewedAt: isoTimestampSchema,
339
395
  expiresAt: isoTimestampSchema,
340
396
  });
341
- export const failurePhaseSchema = z.enum([
342
- 'workspace-validation',
343
- 'workspace-prep',
344
- 'process-starting',
345
- 'running',
346
- 'result-parsing',
347
- 'publishing',
348
- 'unknown',
349
- ]);
350
- export const externalSideEffectsSchema = z.enum(['none', 'confirmed', 'unknown']);
351
- export const retrySafetySchema = z.enum([
352
- 'SAFE_TO_RETRY',
353
- 'SAFE_TO_RESUME',
354
- 'REQUIRES_RECONCILIATION',
355
- 'MANUAL_REVIEW_REQUIRED',
356
- 'NOT_RETRYABLE',
357
- ]);
358
397
  function legacyRunLifecycle(input) {
359
398
  if (input.lifecycle !== undefined) {
360
399
  return input;
@@ -649,8 +688,9 @@ const wakeConfigBaseSchema = z.object({
649
688
  retry: z
650
689
  .object({
651
690
  maxFailureRetries: z.number().int().positive().default(5),
691
+ maxChangesRequestedRetries: z.number().int().positive().default(5),
652
692
  })
653
- .default({ maxFailureRetries: 5 }),
693
+ .default({ maxFailureRetries: 5, maxChangesRequestedRetries: 5 }),
654
694
  runners: z.record(z.string(), runnerEntrySchema).default({
655
695
  fake: { kind: 'fake', cli: 'Fake' },
656
696
  'claude-haiku': {
@@ -0,0 +1,48 @@
1
+ import { stageLabelForStage } from './stages.js';
2
+ import { FROZEN_WORK_ITEM_LABEL } from './work-item-lifecycle.js';
3
+ import { workflowLabelForWorkflowName, workflowNameForProjection } from './workflows.js';
4
+ // The single place that translates the canonical, typed context fields
5
+ // (status/frozen/deleted/scheduled) into wake:* label strings. Lives in
6
+ // domain/ (not adapters/github/) so core/ can call it directly without
7
+ // importing a concrete adapter — the wake: naming convention is Wake's own,
8
+ // not GitHub-specific, matching stageLabelForStage/workflowLabelForWorkflowName
9
+ // which already live here for the same reason.
10
+ export const SCHEDULED_WORKFLOW_LABEL = 'wake:scheduled-workflow';
11
+ const statusLabelByStatus = {
12
+ queued: 'wake:status.pending',
13
+ working: 'wake:status.working',
14
+ 'awaiting-approval': 'wake:status.awaiting-approval',
15
+ 'changes-requested': 'wake:status.changes-requested',
16
+ blocked: 'wake:status.blocked',
17
+ done: 'wake:status.completed',
18
+ failed: 'wake:status.failed',
19
+ };
20
+ // A projection that predates context.status being folded (or hasn't had a
21
+ // run complete yet since this design shipped) has no status field — fall
22
+ // back to the same sentinel+stage heuristic the old statusLabelForOutcome
23
+ // used, so an untouched legacy item is translated to the *same* label it
24
+ // already carries (e.g. still-AWAITING_APPROVAL) instead of drifting to a
25
+ // wrong one just because context.status hasn't been folded onto it yet.
26
+ function legacyStatusFallback(projection) {
27
+ const sentinel = projection.context.lastRunSentinel;
28
+ if (sentinel === 'AWAITING_APPROVAL') {
29
+ return 'awaiting-approval';
30
+ }
31
+ if (sentinel === 'BLOCKED') {
32
+ return 'blocked';
33
+ }
34
+ if (sentinel === 'FAILED') {
35
+ return 'failed';
36
+ }
37
+ return projection.wake.stage === 'done' ? 'done' : 'queued';
38
+ }
39
+ export function labelsForWorkItem(projection, config) {
40
+ const statusLabel = statusLabelByStatus[projection.context.status ?? legacyStatusFallback(projection)];
41
+ return {
42
+ statusLabel,
43
+ stageLabel: stageLabelForStage(projection.wake.stage),
44
+ workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(projection, config)),
45
+ ...(projection.context.frozen !== undefined ? { frozenLabel: FROZEN_WORK_ITEM_LABEL } : {}),
46
+ ...(projection.context.scheduled === true ? { scheduledLabel: SCHEDULED_WORKFLOW_LABEL } : {}),
47
+ };
48
+ }
@@ -1,9 +1,9 @@
1
1
  export const FROZEN_WORK_ITEM_LABEL = 'wake:frozen';
2
2
  export function isWorkItemDeleted(item) {
3
- return typeof item.context.deletedAt === 'string';
3
+ return item.context.deleted !== undefined;
4
4
  }
5
5
  export function isWorkItemFrozen(item) {
6
- return typeof item.context.frozenAt === 'string';
6
+ return item.context.frozen !== undefined;
7
7
  }
8
8
  export function isWorkItemRunnable(item) {
9
9
  return !isWorkItemDeleted(item) && !isWorkItemFrozen(item);
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ // Canonical, typed status for a work item — folded once in
3
+ // projection-updater.ts, translated (never recomputed) everywhere else
4
+ // (e.g. GitHub labels via labelsForWorkItem). See
5
+ // docs/superpowers/specs/2026-07-28-canonical-work-item-status-design.md.
6
+ export const workItemStatusValues = [
7
+ 'queued',
8
+ 'working',
9
+ 'awaiting-approval',
10
+ 'changes-requested',
11
+ 'blocked',
12
+ 'done',
13
+ 'failed',
14
+ ];
15
+ export const workItemStatusSchema = z.enum(workItemStatusValues);
16
+ // Mirrors the sentinel/stage mapping that used to live only in
17
+ // tick-runner.ts's statusLabelForOutcome/statusLabelForStage. Folded onto
18
+ // context.status alongside (not replacing) context.lastRunSentinel.
19
+ export function workItemStatusForRunOutcome(input) {
20
+ if (input.sentinel === 'AWAITING_APPROVAL') {
21
+ return 'awaiting-approval';
22
+ }
23
+ if (input.sentinel === 'BLOCKED') {
24
+ return 'blocked';
25
+ }
26
+ if (input.sentinel === 'FAILED') {
27
+ return 'failed';
28
+ }
29
+ return input.stage === 'done' ? 'done' : 'queued';
30
+ }
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "gbf60f05";
127
+ export const wakeVersion = "gd1b7522";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.77",
3
+ "version": "0.2.78",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {