@atolis-hq/wake 0.2.83 → 0.2.85

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.
@@ -116,11 +116,11 @@ const CLAUDE_CLI_NAME = 'Claude';
116
116
  // second attempt at the task, so it never needs more than one turn.
117
117
  const ENVELOPE_REPAIR_MAX_TURNS = 1;
118
118
  const ENVELOPE_REPAIR_TIMEOUT_MS = 60_000;
119
- function buildEnvelopeRepairPrompt(skipApproval) {
119
+ function buildEnvelopeRepairPrompt(reviewShaped) {
120
120
  return [
121
121
  'Your previous reply did not end with the required `wake-result` envelope, so it could not be parsed.',
122
122
  'Reply with ONLY a fenced `wake-result` JSON block containing a `status` field, then repeat that status word on its own line after the closing fence.',
123
- `The status must be exactly one of: ${sentinelListForApproval(skipApproval)}, reflecting the outcome of your previous turn.`,
123
+ `The status must be exactly one of: ${sentinelListForApproval(reviewShaped)}, reflecting the outcome of your previous turn.`,
124
124
  'Do not repeat, summarize, or redo any of your previous work — this reply is parsed automatically and anything besides the envelope is discarded.',
125
125
  ].join('\n');
126
126
  }
@@ -160,7 +160,7 @@ function mergeTokenUsage(base, extra) {
160
160
  async function attemptEnvelopeRepair(input) {
161
161
  const args = buildClaudePrintArgs({
162
162
  model: input.model,
163
- prompt: buildEnvelopeRepairPrompt(input.skipApproval),
163
+ prompt: buildEnvelopeRepairPrompt(input.reviewShaped),
164
164
  sessionName: input.sessionName,
165
165
  resumeSessionId: input.sessionId,
166
166
  maxTurns: ENVELOPE_REPAIR_MAX_TURNS,
@@ -421,7 +421,7 @@ export function createClaudeRunner(options) {
421
421
  model,
422
422
  sessionName,
423
423
  sessionId: parsed.session_id,
424
- skipApproval: stagePrompt.skipApproval,
424
+ reviewShaped: stagePrompt.reviewShaped,
425
425
  timeoutMs: Math.min(options.settings.timeoutMs, ENVELOPE_REPAIR_TIMEOUT_MS),
426
426
  });
427
427
  if (repair !== undefined && parseRunnerResult(repair.text).envelope !== 'missing') {
@@ -89,6 +89,7 @@ export const indexHtml = `<!DOCTYPE html>
89
89
  .card-error { border-left: 3px solid #ff8f7f; }
90
90
  .chip { display: inline-block; background: #2c313a; border-radius: 4px; padding: 0.05rem 0.35rem; font-size: 0.68rem; margin-right: 0.2rem; }
91
91
  .chip-label { background: transparent; border: 1px solid #3a4150; color: #9aa2ad; margin-bottom: 0.2rem; }
92
+ .chip-meta { border: 1px solid #3a4150; color: #cbd5e1; margin-bottom: 0.2rem; }
92
93
  table { border-collapse: collapse; width: 100%; font-size: 0.8rem; }
93
94
  th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid #2c313a; }
94
95
  th { color: #9aa2ad; font-weight: 600; }
@@ -435,12 +436,18 @@ function renderCardSummaryNodes(item) {
435
436
  const pillClass = 'pill ' + (COND_PILL[item.condition] || 'pill-finished');
436
437
  const statsText = item.totalRuns + ' runs | ' + fmtCost(item.totalCostUsd) + ' | ' + fmtCompact(item.totalTokens) + ' tokens';
437
438
  const isFinished = item.condition === 'finished';
439
+ const displayStatus = item.displayStatus || item.lastRunStatus || item.stage;
440
+ const metaPills = [
441
+ el('span', { class: pillClass, text: displayStatus.replace(/-/g, ' ') }),
442
+ ...(item.isFrozen ? [el('span', { class: 'pill pill-frozen', text: 'Frozen' })] : []),
443
+ el('span', { class: 'chip chip-meta', text: item.workflow }),
444
+ el('span', { class: 'chip chip-meta', text: item.stage }),
445
+ ];
446
+ if (!isFinished) {
447
+ metaPills.push(el('span', { class: 'chip chip-meta', text: fmtMs(item.timeInStageMs) + ' in stage' }));
448
+ }
438
449
  return [
439
- el('div', { class: 'meta', style: 'display:flex;align-items:center;gap:0.3rem;flex-wrap:wrap;margin-top:0.2rem;' }, [
440
- el('span', { class: pillClass, text: item.condition }),
441
- ...(item.isFrozen ? [el('span', { class: 'pill pill-frozen', text: 'Frozen' })] : []),
442
- document.createTextNode('| ' + item.workflow + ' | ' + item.stage + (isFinished ? '' : ' | ' + fmtMs(item.timeInStageMs) + ' in stage')),
443
- ]),
450
+ el('div', { class: 'meta', style: 'display:flex;align-items:center;gap:0.3rem;flex-wrap:wrap;margin-top:0.2rem;' }, metaPills),
444
451
  el('div', { class: 'card-stats', text: statsText }),
445
452
  ...(nonWakeLabels.length > 0
446
453
  ? [el('div', { class: 'meta', style: 'margin-top:0.2rem;' }, nonWakeLabels.map((label) => el('span', { class: 'chip chip-label', text: label })))]
@@ -37,6 +37,10 @@ function deriveCondition(item, lastRun, config) {
37
37
  if (lastRun?.status === 'blocked' ||
38
38
  lastRun?.status === 'awaiting-approval' ||
39
39
  lastRun?.sentinel === 'BLOCKED' ||
40
+ // A run record can never be written with this sentinel value again
41
+ // (ADR 0002), but a pre-existing run record on disk still can be —
42
+ // legacyTolerantRunnerSentinelSchema keeps it readable, so this branch
43
+ // stays to classify it correctly rather than falling through to 'ready'.
40
44
  lastRun?.sentinel === 'AWAITING_APPROVAL') {
41
45
  return { condition: 'needs-human', reason: `sentinel ${lastRun?.sentinel ?? stage}` };
42
46
  }
@@ -57,6 +61,9 @@ function timeInStageMs(item, now) {
57
61
  const lastChange = item.wake.stageHistory.at(-1)?.changedAt ?? item.wake.syncedAt;
58
62
  return now.getTime() - Date.parse(lastChange);
59
63
  }
64
+ function displayStatusForCard(item) {
65
+ return item.context.status ?? 'queued';
66
+ }
60
67
  function activeChildRunsForItem(item, runs, now) {
61
68
  return runs
62
69
  .filter((run) => run.workItemKey === item.workItemKey &&
@@ -145,6 +152,7 @@ export async function buildBoard(input) {
145
152
  totalRuns: runTotals.totalRuns,
146
153
  totalTokens: runTotals.totalTokens,
147
154
  totalCostUsd: runTotals.totalCostUsd,
155
+ displayStatus: displayStatusForCard(item),
148
156
  lastRunAction: lastRun?.action,
149
157
  lastRunSentinel: lastRun?.sentinel,
150
158
  lastRunStatus: lastRun?.status,
@@ -82,19 +82,24 @@ function parseFrontmatterMaxTurns(input) {
82
82
  }
83
83
  return parsed;
84
84
  }
85
- export function sentinelListForApproval(skipApproval) {
86
- return skipApproval ? 'DONE, BLOCKED, FAILED' : 'AWAITING_APPROVAL, BLOCKED, FAILED';
85
+ // The agent never chooses whether its stage is approval-gated — that's pure
86
+ // policy (ADR 0002), derived from skipApproval and applied by Wake to a
87
+ // DONE reply regardless of what the agent writes. `reviewShaped` is the only
88
+ // axis that changes the agent-facing vocabulary: stages whose role is to
89
+ // render a verdict on some target artifact (pr-review, plan-review) also
90
+ // need REJECTED to report "evaluated, verdict is negative, known corrective
91
+ // next step" — distinct from BLOCKED ("I can't decide at all").
92
+ export function sentinelListForApproval(reviewShaped) {
93
+ return reviewShaped ? 'DONE, REJECTED, BLOCKED, FAILED' : 'DONE, BLOCKED, FAILED';
87
94
  }
88
- function sentinelInstructionsForApproval(skipApproval) {
89
- if (skipApproval) {
90
- return [
91
- '- DONE: the stage objective is complete.',
92
- '- BLOCKED: you need clarification from a human or cannot proceed safely.',
93
- '- FAILED: something prevented you from completing this stage at all.',
94
- ].join('\n');
95
- }
95
+ function sentinelInstructionsForApproval(reviewShaped) {
96
96
  return [
97
- '- AWAITING_APPROVAL: the stage objective is complete but requires human sign-off before Wake proceeds. Wake will notify the human automatically — do not post a GitHub comment yourself.',
97
+ '- DONE: the stage objective is complete.',
98
+ ...(reviewShaped
99
+ ? [
100
+ '- REJECTED: you evaluated the target and it does not meet the bar — explain what needs to change. Wake routes this back to a corrective stage automatically; you do not need to ask a human what to do next.',
101
+ ]
102
+ : []),
98
103
  '- BLOCKED: you need clarification from a human or cannot proceed safely.',
99
104
  '- FAILED: something prevented you from completing this stage at all.',
100
105
  ].join('\n');
@@ -124,7 +129,7 @@ function buildHarnessPrompt(input) {
124
129
  if (input.preExistingUncommittedChanges) {
125
130
  lines.push('', 'Pre-existing uncommitted changes notice:', 'This workspace already has uncommitted changes, left over from a previous interrupted attempt at this same issue (e.g. a crash or usage-limit cutoff). They are your own prior partial work, not an unknown or unsafe state - review them with `git status`/`git diff` and continue, amend, or discard as appropriate, then commit when ready.');
126
131
  }
127
- lines.push('', 'Result envelope ABI:', 'Respond concisely. End your response with a fenced `wake-result` JSON block, then on its own line after the closing fence repeat the status word for degraded-mode fallback.', `The JSON \`status\` and final line must be exactly one of: ${sentinelListForApproval(input.skipApproval)}.`, sentinelInstructionsForApproval(input.skipApproval), 'The JSON object must contain only the `status` field. Do not add other fields.', 'This envelope is mandatory, not optional formatting: an automated parser reads only your final lines, and a reply that omits it is discarded and treated as BLOCKED regardless of the work you actually completed.');
132
+ lines.push('', 'Result envelope ABI:', 'Respond concisely. End your response with a fenced `wake-result` JSON block, then on its own line after the closing fence repeat the status word for degraded-mode fallback.', `The JSON \`status\` and final line must be exactly one of: ${sentinelListForApproval(input.reviewShaped)}.`, sentinelInstructionsForApproval(input.reviewShaped), 'The JSON object must contain only the `status` field. Do not add other fields.', 'This envelope is mandatory, not optional formatting: an automated parser reads only your final lines, and a reply that omits it is discarded and treated as BLOCKED regardless of the work you actually completed.');
128
133
  if (input.prTrackingEnabled) {
129
134
  lines.push('', 'Artifact reporting:', 'If you created a pull request during this stage, report it before the result envelope by adding a fenced `wake-artifacts` JSON block:', '```wake-artifacts', '{ "artifacts": [{ "kind": "pr", "url": "<the PR URL>" }] }', '```', 'Only report a PR you actually created in this run. Omit the block entirely if you created no PR.');
130
135
  }
@@ -196,6 +201,7 @@ export async function buildStagePrompt(input) {
196
201
  Object.assign(context, input.contextOverrides);
197
202
  }
198
203
  const skipApproval = template.frontmatter.skipApproval === 'true';
204
+ const reviewShaped = template.frontmatter.sentinelVocabulary === 'review';
199
205
  const allowAutoApproval = !skipApproval && template.frontmatter.allowAutoApproval === 'true';
200
206
  const permissionMode = template.frontmatter.permissionMode;
201
207
  const commentsToAddress = newCommentsSinceLastRun(input.projection);
@@ -239,7 +245,7 @@ export async function buildStagePrompt(input) {
239
245
  return {
240
246
  prompt: `${renderedTemplate}\n\n${untrustedDataBlock}\n\n${envelopeReminder}`,
241
247
  harnessPrompt: buildHarnessPrompt({
242
- skipApproval,
248
+ reviewShaped,
243
249
  prTrackingEnabled: input.config?.sources.github.enabled === true &&
244
250
  input.config?.sources.github.pullRequests.enabled === true,
245
251
  ...(input.mergeConflictDetected === true ? { mergeConflictDetected: true } : {}),
@@ -255,6 +261,7 @@ export async function buildStagePrompt(input) {
255
261
  value: template.frontmatter.maxTurns,
256
262
  }),
257
263
  skipApproval,
264
+ reviewShaped,
258
265
  allowAutoApproval,
259
266
  ...(permissionMode === undefined ? {} : { permissionMode }),
260
267
  };
@@ -79,10 +79,10 @@ export function createPublishIntentEvent(input) {
79
79
  ingestedAt: input.occurredAt,
80
80
  trigger: 'context-only',
81
81
  payload: {
82
- kind: input.sentinel === 'BLOCKED'
83
- ? 'question'
84
- : input.sentinel === 'AWAITING_APPROVAL'
85
- ? 'approval-request'
82
+ kind: input.sentinel === 'DONE' && input.approvalGated === true
83
+ ? 'approval-request'
84
+ : input.sentinel === 'BLOCKED'
85
+ ? 'question'
86
86
  : input.sentinel === 'FAILED'
87
87
  ? 'failure'
88
88
  : 'status-update',
@@ -1,11 +1,19 @@
1
- import { awaitingApprovalRunnerSentinel, failedRunnerSentinel } from '../domain/stages.js';
1
+ import { failedRunnerSentinel } from '../domain/stages.js';
2
2
  import { resolveCustomCommand } from '../domain/custom-commands.js';
3
3
  import { builtInDefaultWorkflowDefinition, chooseAction as chooseWorkflowAction, isKnownWorkflowStage, selectWorkflowForEvent, workflowForProjection, } from '../domain/workflows.js';
4
4
  import { alwaysManualIgnoredLabels } from '../domain/manual-labels.js';
5
5
  import { autoApprovalLabel } from './approval-intents.js';
6
6
  function isAwaitingApproval(issue) {
7
7
  const context = issue.context;
8
- return context.lastRunSentinel === awaitingApprovalRunnerSentinel;
8
+ // pendingApprovalAction persists through a changes-requested review round
9
+ // within the same gated cycle (that fold path never touches it), so it's
10
+ // the faithful "is this item currently gated on human sign-off" signal —
11
+ // context.status alone flips between 'awaiting-approval' and
12
+ // 'changes-requested' within the same gated cycle and can't be used here.
13
+ // The lastRunSentinel fallback tolerates a projection folded before
14
+ // pendingApprovalAction was reliably set on every gated DONE.
15
+ return (typeof context.pendingApprovalAction === 'string' ||
16
+ context.lastRunSentinel === 'AWAITING_APPROVAL');
9
17
  }
10
18
  function belowFailureRetryLimit(issue, config) {
11
19
  if (config === undefined) {
@@ -137,6 +145,9 @@ export function createPolicyEngine() {
137
145
  }
138
146
  return false;
139
147
  }
148
+ if (lastRunSentinel === 'REJECTED') {
149
+ return false;
150
+ }
140
151
  if (lastRunSentinel === 'BLOCKED') {
141
152
  return false;
142
153
  }
@@ -1,6 +1,6 @@
1
1
  import { CORRELATION_PRIMARY_CONFLICT_EVENT, CORRELATION_REGISTERED_EVENT, CORRELATION_RETRACTED_EVENT, RETRY_REQUESTED_EVENT, RUN_REQUESTED_EVENT, RUN_CLAIMED_EVENT, RUN_COMPLETED_EVENT, WORKFLOW_SELECTED_EVENT, WORK_ITEM_DELETED_EVENT, WORK_ITEM_FROZEN_EVENT, WORK_ITEM_UNFROZEN_EVENT, WORKSPACE_CLEANED_EVENT, } from '../domain/event-types.js';
2
2
  import { UNRESOLVED_WORK_ITEM_KEY, parseIssueStateRecord } from '../domain/schema.js';
3
- import { doneRunnerSentinel, stageFromLabels } from '../domain/stages.js';
3
+ import { doneRunnerSentinel, rejectedRunnerSentinel, stageFromLabels } from '../domain/stages.js';
4
4
  import { FROZEN_WORK_ITEM_LABEL } from '../domain/work-item-lifecycle.js';
5
5
  import { workItemStatusForRunOutcome } from '../domain/work-item-status.js';
6
6
  import { builtInDefaultWorkflowDefinition, defaultWorkflowName, selectWorkflowForEvent, workflowStageVocabulary, } from '../domain/workflows.js';
@@ -202,6 +202,15 @@ async function applyEvent(current, event, ctx, config) {
202
202
  }
203
203
  if (event.sourceEventType === RUN_COMPLETED_EVENT) {
204
204
  const payload = event.payload;
205
+ // A historical event stream may still carry the retired AWAITING_APPROVAL
206
+ // sentinel (pre-ADR-0002). Normalize it here, once, so every branch below
207
+ // and the strict runnerSentinelSchema validation on new writes to
208
+ // context.lastRunSentinel only ever see the current four-value
209
+ // vocabulary — replaying an old event stream must still land on the same
210
+ // practical state (approval-gated) as it did before this change.
211
+ const isLegacyAwaitingApproval = payload.sentinel === 'AWAITING_APPROVAL';
212
+ const sentinel = isLegacyAwaitingApproval ? doneRunnerSentinel : payload.sentinel;
213
+ const approvalGated = payload.approvalGated === true || isLegacyAwaitingApproval;
205
214
  // A rejecting plan-review/pr-review watcher sub-run (§5, trigger 1) or a
206
215
  // human /changes reply (§5, trigger 2) folds onto the parent's context
207
216
  // without touching wake.stage/lastRunId/session — same isolation
@@ -253,9 +262,9 @@ async function applyEvent(current, event, ctx, config) {
253
262
  // the in-progress session after a human replies.
254
263
  const isForwardProgression = payload.nextStage !== undefined && payload.nextStage !== current.wake.stage;
255
264
  const stageChanged = payload.nextStage !== undefined && payload.nextStage !== current.wake.stage;
256
- const isFailed = payload.sentinel === 'FAILED';
265
+ const isFailed = sentinel === 'FAILED';
257
266
  const isCompletedCustomCommand = payload.action !== undefined &&
258
- payload.sentinel === doneRunnerSentinel &&
267
+ sentinel === doneRunnerSentinel &&
259
268
  config !== undefined &&
260
269
  isCustomCommandAction(payload.action, config);
261
270
  const shouldClearSession = isForwardProgression || isFailed;
@@ -268,29 +277,28 @@ async function applyEvent(current, event, ctx, config) {
268
277
  lastFailureClass: payload.failureClass,
269
278
  failureCount: payload.failureClass !== undefined
270
279
  ? currentFailureCount + 1
271
- : payload.sentinel === doneRunnerSentinel || payload.sentinel === 'AWAITING_APPROVAL'
280
+ : sentinel === doneRunnerSentinel || sentinel === rejectedRunnerSentinel
272
281
  ? 0
273
282
  : currentFailureCount,
274
283
  ...(payload.handledCommentId === undefined
275
284
  ? {}
276
285
  : { lastHandledCommentId: payload.handledCommentId }),
277
- ...(payload.sentinel === undefined || isCompletedCustomCommand
278
- ? {}
279
- : { lastRunSentinel: payload.sentinel }),
286
+ ...(sentinel === undefined || isCompletedCustomCommand ? {} : { lastRunSentinel: sentinel }),
280
287
  ...(payload.action === undefined || isCompletedCustomCommand
281
288
  ? {}
282
289
  : { lastRunAction: payload.action }),
283
- ...(payload.sentinel === doneRunnerSentinel &&
290
+ ...(sentinel === doneRunnerSentinel &&
284
291
  payload.action !== undefined &&
285
292
  !isCompletedCustomCommand
286
293
  ? { lastCompletedAction: payload.action }
287
294
  : {}),
288
295
  // Remembered so the approval path knows which action to resume or
289
- // skip when a human posts /approved.
290
- ...(payload.sentinel === 'AWAITING_APPROVAL' && payload.action !== undefined
296
+ // skip when a human posts /approved. Set only for an approval-gated
297
+ // DONE REJECTED/BLOCKED/FAILED never gate on human sign-off this way.
298
+ ...(sentinel === doneRunnerSentinel && approvalGated && payload.action !== undefined
291
299
  ? { pendingApprovalAction: payload.action }
292
300
  : {}),
293
- ...(payload.sentinel === 'AWAITING_APPROVAL'
301
+ ...(sentinel === doneRunnerSentinel && approvalGated
294
302
  ? { pendingApprovalAllowAutoApproval: payload.allowAutoApproval === true }
295
303
  : {}),
296
304
  ...(payload.executionOutcome !== undefined
@@ -310,27 +318,28 @@ async function applyEvent(current, event, ctx, config) {
310
318
  ? { lastExternalSideEffects: payload.externalSideEffects }
311
319
  : {}),
312
320
  ...(payload.retrySafety !== undefined ? { lastRetrySafety: payload.retrySafety } : {}),
313
- ...(payload.sentinel === undefined || isCompletedCustomCommand
321
+ ...(sentinel === undefined || isCompletedCustomCommand
314
322
  ? {}
315
323
  : {
316
324
  status: workItemStatusForRunOutcome({
317
- sentinel: payload.sentinel,
325
+ sentinel: sentinel,
318
326
  stage: payload.nextStage ?? current.wake.stage,
327
+ approvalGated,
319
328
  }),
320
329
  }),
321
- // A fresh DONE/AWAITING_APPROVAL cycle resolves whatever changes were
330
+ // A fresh DONE cycle (gated or not) resolves whatever changes were
322
331
  // previously requested — reset the loop counter and stored feedback.
323
- ...(payload.sentinel === doneRunnerSentinel || payload.sentinel === 'AWAITING_APPROVAL'
332
+ ...(sentinel === doneRunnerSentinel
324
333
  ? { changesRequestedCount: 0, changesRequestedFeedback: undefined }
325
334
  : {}),
326
335
  };
327
- if (payload.sentinel === 'BLOCKED' || payload.sentinel === 'FAILED') {
336
+ if (sentinel === 'BLOCKED' || sentinel === 'FAILED') {
328
337
  nextContext.blockedFromStage = current.wake.stage;
329
338
  }
330
- else if (payload.sentinel !== undefined) {
339
+ else if (sentinel !== undefined) {
331
340
  delete nextContext.blockedFromStage;
332
341
  }
333
- if (payload.sentinel !== 'AWAITING_APPROVAL' && !isCompletedCustomCommand) {
342
+ if (!(sentinel === doneRunnerSentinel && approvalGated) && !isCompletedCustomCommand) {
334
343
  delete nextContext.pendingApprovalAction;
335
344
  delete nextContext.pendingApprovalAllowAutoApproval;
336
345
  }
@@ -8,7 +8,6 @@ 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 } from '../domain/stages.js';
12
11
  import { FROZEN_WORK_ITEM_LABEL, isWorkItemDeleted, isWorkItemRunnable, } from '../domain/work-item-lifecycle.js';
13
12
  import { isMeaningfulRuntimeEvent } from '../domain/runtime-events.js';
14
13
  import { chooseAction as chooseWorkflowAction, entryStage as workflowEntryStage, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection, workflowNameForProjection, } from '../domain/workflows.js';
@@ -204,7 +203,15 @@ export function createTickRunner(deps) {
204
203
  });
205
204
  const ownerInstanceId = `instance-${process.pid}-${Date.now()}`;
206
205
  function isAwaitingApproval(projection) {
207
- return projection.context.lastRunSentinel === awaitingApprovalRunnerSentinel;
206
+ // pendingApprovalAction persists through a changes-requested review round
207
+ // within the same gated cycle (that fold path never touches it), so it's
208
+ // the faithful "is this item currently gated on human sign-off" signal —
209
+ // context.status alone flips between 'awaiting-approval' and
210
+ // 'changes-requested' within the same gated cycle and can't be used here.
211
+ // The lastRunSentinel fallback tolerates a projection folded before
212
+ // pendingApprovalAction was reliably set on every gated DONE.
213
+ return (typeof projection.context.pendingApprovalAction === 'string' ||
214
+ projection.context.lastRunSentinel === 'AWAITING_APPROVAL');
208
215
  }
209
216
  // Always reads the current projection at write time (never a threaded
210
217
  // local like a stale `workflowName`/`claimedStage`), so a mid-run
@@ -890,8 +897,10 @@ export function createTickRunner(deps) {
890
897
  }
891
898
  function watcherStatus(projection) {
892
899
  const sentinel = projection.context.lastRunSentinel;
893
- if (sentinel === 'AWAITING_APPROVAL')
900
+ if (isAwaitingApproval(projection))
894
901
  return 'awaiting-approval';
902
+ if (sentinel === 'REJECTED')
903
+ return 'rejected';
895
904
  if (sentinel === 'BLOCKED')
896
905
  return 'blocked';
897
906
  if (sentinel === 'FAILED')
@@ -1755,19 +1764,21 @@ export function createTickRunner(deps) {
1755
1764
  clearInterval(leaseRenewalTimer);
1756
1765
  leaseRenewalTimer = undefined;
1757
1766
  const parsedRunnerResult = parseRunnerResult(runnerResult.result);
1758
- const rawSentinel = parsedRunnerResult.status;
1759
- // Coerce DONE AWAITING_APPROVAL when the stage requires human sign-off.
1760
- // An agent that writes DONE but was told not to skip approval has violated
1761
- // the protocol; treat it as AWAITING_APPROVAL so the gate is enforced.
1767
+ const sentinel = parsedRunnerResult.status;
1768
+ // The approval gate is pure policy, never the agent's word choice
1769
+ // a DONE on a stage configured with skipApproval: false is gated
1770
+ // regardless of what the agent wrote (ADR 0002).
1762
1771
  const skipApproval = runnerResult.metadata?.skipApproval;
1763
- const sentinel = rawSentinel === 'DONE' && skipApproval === false ? 'AWAITING_APPROVAL' : rawSentinel;
1772
+ const approvalGated = sentinel === 'DONE' && skipApproval === false;
1764
1773
  // A canceled run must not advance the stage regardless of what the
1765
1774
  // runner echoed back — the snapshot it acted on was superseded.
1766
1775
  const nextStage = cancellationReason !== null
1767
1776
  ? null
1768
- : isLateralReadOnlyAction(action, deps.config) && sentinel === 'DONE'
1777
+ : approvalGated
1769
1778
  ? null
1770
- : lifecycle.nextStageFromSentinel(claimedStage, sentinel, workflow);
1779
+ : isLateralReadOnlyAction(action, deps.config) && sentinel === 'DONE'
1780
+ ? null
1781
+ : lifecycle.nextStageFromSentinel(claimedStage, sentinel, workflow);
1771
1782
  const finishedAt = deps.clock.now().toISOString();
1772
1783
  let workspaceBookkeeping;
1773
1784
  if (workspacePath !== undefined) {
@@ -1810,7 +1821,6 @@ export function createTickRunner(deps) {
1810
1821
  sourceRevision,
1811
1822
  watcherTrigger: watcherTriggerForRun,
1812
1823
  verifiedTargetResourceUri: prReviewTargetResourceUri,
1813
- rawSentinel,
1814
1824
  },
1815
1825
  outcome: {
1816
1826
  sentinel,
@@ -1818,7 +1828,7 @@ export function createTickRunner(deps) {
1818
1828
  ? 'uncertain'
1819
1829
  : sentinel === 'DONE'
1820
1830
  ? 'approved'
1821
- : sentinel === 'FAILED'
1831
+ : sentinel === 'REJECTED'
1822
1832
  ? 'changes-requested'
1823
1833
  : 'uncertain',
1824
1834
  reasoning: parsedRunnerResult.body,
@@ -1895,11 +1905,13 @@ export function createTickRunner(deps) {
1895
1905
  const workflowOutcome = cancellationReason !== null
1896
1906
  ? undefined
1897
1907
  : sentinel === 'DONE'
1898
- ? 'DONE'
1899
- : sentinel === 'BLOCKED'
1900
- ? 'BLOCKED'
1901
- : sentinel === 'AWAITING_APPROVAL'
1902
- ? 'AWAITING_APPROVAL'
1908
+ ? approvalGated
1909
+ ? 'AWAITING_APPROVAL'
1910
+ : 'DONE'
1911
+ : sentinel === 'REJECTED'
1912
+ ? 'CHANGES_REQUESTED'
1913
+ : sentinel === 'BLOCKED'
1914
+ ? 'BLOCKED'
1903
1915
  : undefined;
1904
1916
  await transitionRunLifecycle('FINALISING');
1905
1917
  const finalisingRecord = (await deps.stateStore.readRunRecord(runId));
@@ -1917,11 +1929,13 @@ export function createTickRunner(deps) {
1917
1929
  ...finalisingRecord,
1918
1930
  lifecycle: 'TERMINAL',
1919
1931
  status: sentinel === 'DONE'
1920
- ? 'completed'
1921
- : sentinel === 'BLOCKED'
1922
- ? 'blocked'
1923
- : sentinel === 'AWAITING_APPROVAL'
1924
- ? 'awaiting-approval'
1932
+ ? approvalGated
1933
+ ? 'awaiting-approval'
1934
+ : 'completed'
1935
+ : sentinel === 'REJECTED'
1936
+ ? 'rejected'
1937
+ : sentinel === 'BLOCKED'
1938
+ ? 'blocked'
1925
1939
  : 'failed',
1926
1940
  finishedAt,
1927
1941
  sessionId: runnerResult.session_id,
@@ -1947,7 +1961,7 @@ export function createTickRunner(deps) {
1947
1961
  ? deps.config.workflows[watcherDispatch.parentWorkflowName]?.stages[watcherDispatch.parentStage]?.watch?.[watcherDispatch.watcherIndex]?.onSuccess
1948
1962
  : undefined;
1949
1963
  const isReviewRejection = watcherRun &&
1950
- (sentinel === 'FAILED' || sentinel === 'BLOCKED') &&
1964
+ sentinel === 'REJECTED' &&
1951
1965
  (prReviewTargetResourceUri !== null || watcherSuccessPolicy?.approve === true);
1952
1966
  const runCompletedEvent = createEventEnvelope({
1953
1967
  eventId: `${runId}-completed`,
@@ -1967,8 +1981,8 @@ export function createTickRunner(deps) {
1967
1981
  payload: {
1968
1982
  action,
1969
1983
  sentinel,
1984
+ approvalGated,
1970
1985
  allowAutoApproval: runnerResult.metadata?.allowAutoApproval === true,
1971
- ...(rawSentinel !== sentinel ? { rawSentinel } : {}),
1972
1986
  ...(nextStage !== null ? { nextStage } : {}),
1973
1987
  runId,
1974
1988
  sessionId: runnerResult.session_id,
@@ -2018,6 +2032,7 @@ export function createTickRunner(deps) {
2018
2032
  runnerResult,
2019
2033
  parsedRunnerResult,
2020
2034
  sentinel,
2035
+ approvalGated,
2021
2036
  occurredAt: finishedAt,
2022
2037
  startedAt: nowIso,
2023
2038
  ...(workspacePath === undefined ? {} : { workspacePath }),
@@ -2030,7 +2045,7 @@ export function createTickRunner(deps) {
2030
2045
  // registration regardless of the target workflow/action name.
2031
2046
  const pendingApprovalAction = candidate.context.pendingApprovalAction;
2032
2047
  if (prReviewTargetResourceUri !== null &&
2033
- (sentinel === 'DONE' || sentinel === 'FAILED')) {
2048
+ (sentinel === 'DONE' || sentinel === 'REJECTED')) {
2034
2049
  await deliverOutboundEvent({
2035
2050
  ...publishIntent,
2036
2051
  sourceRefs: {
@@ -2049,10 +2064,14 @@ export function createTickRunner(deps) {
2049
2064
  }
2050
2065
  else if (watcherDispatch !== null &&
2051
2066
  watcherSuccessPolicy?.approve === true &&
2052
- (sentinel === 'DONE' || sentinel === 'FAILED' || sentinel === 'BLOCKED')) {
2067
+ (sentinel === 'DONE' ||
2068
+ sentinel === 'REJECTED' ||
2069
+ sentinel === 'FAILED' ||
2070
+ sentinel === 'BLOCKED')) {
2053
2071
  // No PR surface to carry the verdict comment: the child's sentinel
2054
2072
  // is its verdict. Publish the review body for every verdict so the
2055
- // human sees why; only DONE resolves the parent's pending gate.
2073
+ // human sees why; only an ungated DONE resolves the parent's
2074
+ // pending gate (checked below via isAwaitingApproval).
2056
2075
  await deliverOutboundEvent(publishIntent);
2057
2076
  const approvalId = `${runId}-parent-approval`;
2058
2077
  const parentWorkflow = deps.config.workflows[watcherDispatch.parentWorkflowName];
@@ -19,6 +19,14 @@ export const wakeArtifactsEnvelopeSchema = z.object({
19
19
  artifacts: z.array(reportedArtifactSchema).default([]),
20
20
  });
21
21
  export const runnerSentinelSchema = z.enum(runnerSentinelValues);
22
+ // A projection persisted before ADR 0002 can still have context.lastRunSentinel
23
+ // = "AWAITING_APPROVAL" on disk. That field is read (not just replayed) on
24
+ // every ordinary parseIssueStateRecord call, not only during an explicit
25
+ // event-log rebuild, so the strict current-vocabulary schema above would
26
+ // reject an untouched legacy projection outright. This schema stays
27
+ // permissive to that one retired value so existing on-disk state keeps
28
+ // loading until it's naturally refreshed by a new run.
29
+ const legacyTolerantRunnerSentinelSchema = z.enum([...runnerSentinelValues, 'AWAITING_APPROVAL']);
22
30
  export const executionOutcomeValues = [
23
31
  'COMPLETED',
24
32
  'STARTUP_FAILED',
@@ -311,9 +319,9 @@ export const issueContextSchema = z.object({
311
319
  blockedFromStage: z.string().optional(),
312
320
  lastFailureClass: z.enum(['task', 'quota', 'infra']).optional(),
313
321
  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.
322
+ lastRunSentinel: legacyTolerantRunnerSentinelSchema.optional(),
323
+ // Set while approval-gated (context.status === 'awaiting-approval') so the
324
+ // approval path knows which action to resume (or skip) once a human responds.
317
325
  pendingApprovalAction: z.string().optional(),
318
326
  pendingApprovalAllowAutoApproval: z.boolean().optional(),
319
327
  // Workflow pinned at mint time (WORKFLOW_SELECTED_EVENT); re-read at every
@@ -427,6 +435,7 @@ export const runRecordSchema = z.preprocess((input) => {
427
435
  'running',
428
436
  'completed',
429
437
  'awaiting-approval',
438
+ 'rejected',
430
439
  'blocked',
431
440
  'failed',
432
441
  'superseded',
@@ -434,7 +443,7 @@ export const runRecordSchema = z.preprocess((input) => {
434
443
  startedAt: isoTimestampSchema,
435
444
  finishedAt: isoTimestampSchema.optional(),
436
445
  sessionId: z.string().optional(),
437
- sentinel: runnerSentinelSchema.optional(),
446
+ sentinel: legacyTolerantRunnerSentinelSchema.optional(),
438
447
  executionOutcome: executionOutcomeSchema.optional(),
439
448
  workflowOutcome: workflowOutcomeSchema.optional(),
440
449
  failurePhase: failurePhaseSchema.optional(),
@@ -1097,8 +1106,8 @@ export function parseClaudePrintResult(input) {
1097
1106
  function synthesizeBodyFromEnvelope(envelope) {
1098
1107
  const labels = {
1099
1108
  DONE: 'Run completed.',
1109
+ REJECTED: 'Run rejected — needs changes.',
1100
1110
  BLOCKED: 'Run blocked — needs input.',
1101
- AWAITING_APPROVAL: 'Ready for approval.',
1102
1111
  FAILED: 'Run failed.',
1103
1112
  };
1104
1113
  return labels[envelope.status] ?? 'Run finished.';
@@ -1116,8 +1125,7 @@ export function parseRunnerResult(result) {
1116
1125
  // Claude sometimes places the sentinel keyword inside the fence rather than after
1117
1126
  // the closing fence. Strip a trailing sentinel line so JSON.parse sees only the JSON.
1118
1127
  // The capture group includes the newline before the closing fence, so allow \n? after.
1119
- const jsonContent = rawContent.replace(/\n(?:DONE|BLOCKED|FAILED|AWAITING_APPROVAL)[ \t]*\n?$/, '') ||
1120
- rawContent;
1128
+ const jsonContent = rawContent.replace(/\n(?:DONE|REJECTED|BLOCKED|FAILED)[ \t]*\n?$/, '') || rawContent;
1121
1129
  const parsed = wakeResultEnvelopeSchema.safeParse(JSON.parse(jsonContent));
1122
1130
  if (parsed.success) {
1123
1131
  const proseBody = result.slice(0, lastMatch.index).trim();
@@ -1,13 +1,13 @@
1
1
  export const stageValues = ['queue', 'done'];
2
2
  export const doneRunnerSentinel = 'DONE';
3
+ export const rejectedRunnerSentinel = 'REJECTED';
3
4
  export const blockedRunnerSentinel = 'BLOCKED';
4
5
  export const failedRunnerSentinel = 'FAILED';
5
- export const awaitingApprovalRunnerSentinel = 'AWAITING_APPROVAL';
6
6
  export const runnerSentinelValues = [
7
7
  doneRunnerSentinel,
8
+ rejectedRunnerSentinel,
8
9
  blockedRunnerSentinel,
9
10
  failedRunnerSentinel,
10
- awaitingApprovalRunnerSentinel,
11
11
  ];
12
12
  export const terminalStageValues = ['done'];
13
13
  export function isTerminalStage(stage) {
@@ -17,9 +17,12 @@ export const workItemStatusSchema = z.enum(workItemStatusValues);
17
17
  // tick-runner.ts's statusLabelForOutcome/statusLabelForStage. Folded onto
18
18
  // context.status alongside (not replacing) context.lastRunSentinel.
19
19
  export function workItemStatusForRunOutcome(input) {
20
- if (input.sentinel === 'AWAITING_APPROVAL') {
20
+ if (input.sentinel === 'DONE' && input.approvalGated === true) {
21
21
  return 'awaiting-approval';
22
22
  }
23
+ if (input.sentinel === 'REJECTED') {
24
+ return 'changes-requested';
25
+ }
23
26
  if (input.sentinel === 'BLOCKED') {
24
27
  return 'blocked';
25
28
  }
@@ -151,7 +151,7 @@ export function chooseAction(projection, workflow) {
151
151
  };
152
152
  }
153
153
  export function nextStage(stage, sentinel, workflow) {
154
- if (sentinel === 'BLOCKED' || sentinel === 'FAILED' || sentinel === 'AWAITING_APPROVAL') {
154
+ if (sentinel === 'BLOCKED' || sentinel === 'FAILED' || sentinel === 'REJECTED') {
155
155
  return null;
156
156
  }
157
157
  const runnableStage = stage === universalQueueStage ? stageAfterQueue(workflow) : stage;
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "gf3111aa";
127
+ export const wakeVersion = "g47d31f8";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.83",
3
+ "version": "0.2.85",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -3,6 +3,7 @@ permissionMode: default
3
3
  allowedTools: Read, Glob, Grep, Bash(git fetch), Bash(git status), Bash(gh issue view *), Bash(gh api repos/*/issues/*), WebSearch, WebFetch
4
4
  maxTurns: 30
5
5
  skipApproval: true
6
+ sentinelVocabulary: review
6
7
  ---
7
8
  You are Wake, in the PLAN-REVIEW workflow for work item {{workItemKey}}.
8
9
  {{toolCapabilityNote}}
@@ -30,8 +31,9 @@ Write your assessment as your response body — it is posted to the issue for th
30
31
 
31
32
  Verdict mapping:
32
33
  - Use `DONE` only when you are confident the plan should be approved; Wake resolves the pending approval and advances the stage.
33
- - Use `FAILED` when the plan needs changes; explain the required changes clearly.
34
+ - Use `REJECTED` when the plan needs changes; explain the required changes clearly. Wake routes this back to a corrective stage automatically — you do not need to ask what happens next.
34
35
  - Use `BLOCKED` when the decision needs human judgment.
36
+ - Use `FAILED` only when something prevented you from completing the review itself (e.g. you couldn't retrieve the plan text or the issue) — not when the plan's contents are the problem.
35
37
 
36
38
  Do not state your verdict in prose alone (e.g. "Verdict: DONE") — Wake does
37
39
  not parse prose. End your response with the Wake result envelope, exactly:
@@ -41,8 +43,8 @@ not parse prose. End your response with the Wake result envelope, exactly:
41
43
  ```
42
44
  DONE
43
45
 
44
- (substituting `FAILED` or `BLOCKED` for both the JSON value and the trailing
45
- line, matching your actual verdict). A response without this exact block is
46
- treated as `BLOCKED`, even if your prose says otherwise.
46
+ (substituting `REJECTED`, `BLOCKED`, or `FAILED` for both the JSON value and
47
+ the trailing line, matching your actual verdict). A response without this
48
+ exact block is treated as `BLOCKED`, even if your prose says otherwise.
47
49
 
48
50
  {{feedbackCommandNote}}
@@ -3,6 +3,7 @@ permissionMode: default
3
3
  allowedTools: Bash(gh pr view *), Bash(gh pr diff *), Bash(gh pr checks *), Bash(gh pr list *), Bash(gh run view *), Bash(gh api repos/*/pulls*), Bash(gh api repos/*/commits/*), Bash(gh issue view *), Bash(git status), Bash(git log *), Bash(git diff *), Read, Glob, Grep
4
4
  maxTurns: 50
5
5
  skipApproval: true
6
+ sentinelVocabulary: review
6
7
  ---
7
8
  You are Wake, in the PR-REVIEW workflow for work item {{workItemKey}}.
8
9
 
@@ -28,8 +29,9 @@ Review judgment:
28
29
 
29
30
  Verdict mapping:
30
31
  - Use `DONE` only when you are confident the PR is safe to merge.
31
- - Use `FAILED` when the PR needs changes; explain the required changes clearly.
32
- - Use `BLOCKED` when you cannot determine a safe verdict.
32
+ - Use `REJECTED` when the PR needs changes; explain the required changes clearly. Wake routes this back to the author automatically — you do not need to ask what happens next.
33
+ - Use `BLOCKED` when you cannot determine a safe verdict (e.g. you can't find exactly one plausible PR for this work item).
34
+ - Use `FAILED` only when something prevented you from completing the review itself (e.g. no GitHub access, the diff couldn't be retrieved) — not when the PR's contents are the problem.
33
35
 
34
36
  Safety rules:
35
37
  - Do not merge, approve via GitHub review, enable auto-merge, edit labels, push commits, or perform any administrative GitHub mutation.