@atolis-hq/wake 0.2.84 → 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.
- package/dist/src/adapters/claude/claude-runner.js +4 -4
- package/dist/src/adapters/http/ui-data.js +4 -0
- package/dist/src/adapters/runner/stage-prompt.js +20 -13
- package/dist/src/core/event-builders.js +4 -4
- package/dist/src/core/policy-engine.js +13 -2
- package/dist/src/core/projection-updater.js +27 -18
- package/dist/src/core/tick-runner.js +46 -27
- package/dist/src/domain/schema.js +15 -7
- package/dist/src/domain/stages.js +2 -2
- package/dist/src/domain/work-item-status.js +4 -1
- package/dist/src/domain/workflows.js +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/prompts/plan-review.md +6 -4
- package/prompts/pr-review.md +4 -2
|
@@ -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(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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') {
|
|
@@ -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
|
}
|
|
@@ -82,19 +82,24 @@ function parseFrontmatterMaxTurns(input) {
|
|
|
82
82
|
}
|
|
83
83
|
return parsed;
|
|
84
84
|
}
|
|
85
|
-
|
|
86
|
-
|
|
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(
|
|
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
|
-
'-
|
|
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.
|
|
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
|
-
|
|
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 === '
|
|
83
|
-
? '
|
|
84
|
-
: input.sentinel === '
|
|
85
|
-
? '
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
265
|
+
const isFailed = sentinel === 'FAILED';
|
|
257
266
|
const isCompletedCustomCommand = payload.action !== undefined &&
|
|
258
|
-
|
|
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
|
-
:
|
|
280
|
+
: sentinel === doneRunnerSentinel || sentinel === rejectedRunnerSentinel
|
|
272
281
|
? 0
|
|
273
282
|
: currentFailureCount,
|
|
274
283
|
...(payload.handledCommentId === undefined
|
|
275
284
|
? {}
|
|
276
285
|
: { lastHandledCommentId: payload.handledCommentId }),
|
|
277
|
-
...(
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
321
|
+
...(sentinel === undefined || isCompletedCustomCommand
|
|
314
322
|
? {}
|
|
315
323
|
: {
|
|
316
324
|
status: workItemStatusForRunOutcome({
|
|
317
|
-
sentinel:
|
|
325
|
+
sentinel: sentinel,
|
|
318
326
|
stage: payload.nextStage ?? current.wake.stage,
|
|
327
|
+
approvalGated,
|
|
319
328
|
}),
|
|
320
329
|
}),
|
|
321
|
-
// A fresh DONE
|
|
330
|
+
// A fresh DONE cycle (gated or not) resolves whatever changes were
|
|
322
331
|
// previously requested — reset the loop counter and stored feedback.
|
|
323
|
-
...(
|
|
332
|
+
...(sentinel === doneRunnerSentinel
|
|
324
333
|
? { changesRequestedCount: 0, changesRequestedFeedback: undefined }
|
|
325
334
|
: {}),
|
|
326
335
|
};
|
|
327
|
-
if (
|
|
336
|
+
if (sentinel === 'BLOCKED' || sentinel === 'FAILED') {
|
|
328
337
|
nextContext.blockedFromStage = current.wake.stage;
|
|
329
338
|
}
|
|
330
|
-
else if (
|
|
339
|
+
else if (sentinel !== undefined) {
|
|
331
340
|
delete nextContext.blockedFromStage;
|
|
332
341
|
}
|
|
333
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1759
|
-
//
|
|
1760
|
-
//
|
|
1761
|
-
//
|
|
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
|
|
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
|
-
:
|
|
1777
|
+
: approvalGated
|
|
1769
1778
|
? null
|
|
1770
|
-
:
|
|
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 === '
|
|
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
|
-
?
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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
|
-
?
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
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
|
-
|
|
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 === '
|
|
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' ||
|
|
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
|
|
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:
|
|
315
|
-
// Set while
|
|
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:
|
|
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
|
|
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 === '
|
|
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 === '
|
|
154
|
+
if (sentinel === 'BLOCKED' || sentinel === 'FAILED' || sentinel === 'REJECTED') {
|
|
155
155
|
return null;
|
|
156
156
|
}
|
|
157
157
|
const runnableStage = stage === universalQueueStage ? stageAfterQueue(workflow) : stage;
|
package/dist/src/version.js
CHANGED
package/package.json
CHANGED
package/prompts/plan-review.md
CHANGED
|
@@ -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 `
|
|
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 `
|
|
45
|
-
line, matching your actual verdict). A response without this
|
|
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}}
|
package/prompts/pr-review.md
CHANGED
|
@@ -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 `
|
|
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.
|