@atolis-hq/wake 0.1.7 → 0.1.8

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.
@@ -1,4 +1,5 @@
1
1
  import { awaitingApprovalRunnerSentinel, failedRunnerSentinel } from '../domain/stages.js';
2
+ import { resolveCustomCommand } from '../domain/custom-commands.js';
2
3
  import { builtInDefaultWorkflowDefinition, chooseAction as chooseWorkflowAction, } from '../domain/workflows.js';
3
4
  function isAwaitingApproval(issue) {
4
5
  const context = issue.context;
@@ -10,12 +11,10 @@ function isAwaitingApproval(issue) {
10
11
  const approvedCommandPattern = /^\/approved\b/i;
11
12
  const changesCommandPattern = /^\/changes\b/i;
12
13
  const questionCommandPattern = /^\/question\b/i;
13
- const codeReviewCommandPattern = /^\/codereview\b/i;
14
14
  // The action Wake runs when a correlated PR gets new reviewer feedback while
15
15
  // the work item is awaiting approval. Not configurable per workflow: it's a
16
16
  // lateral response to a PR surface, not a workflow stage.
17
17
  const reviewFeedbackAction = 'revise';
18
- const codeReviewAction = 'codereview';
19
18
  function matchesCommand(body, pattern) {
20
19
  return body.split(/\r?\n/).some((line) => pattern.test(line.trim()));
21
20
  }
@@ -173,13 +172,8 @@ export function createPolicyEngine() {
173
172
  }
174
173
  return reviewFeedbackAction;
175
174
  },
176
- resolveCodeReviewRequest(issue) {
177
- const latestHumanComment = latestUnhandledHumanComment(issue);
178
- if (latestHumanComment === undefined ||
179
- !matchesCommand(latestHumanComment.body, codeReviewCommandPattern)) {
180
- return null;
181
- }
182
- return codeReviewAction;
175
+ resolveCustomCommandRequest(issue, config) {
176
+ return resolveCustomCommand(issue, config);
183
177
  },
184
178
  qualifiesForMint(unresolved, config) {
185
179
  const resourceUri = unresolved.sourceRefs.resourceUri;
@@ -1,6 +1,7 @@
1
1
  import { CORRELATION_PRIMARY_CONFLICT_EVENT, CORRELATION_REGISTERED_EVENT, CORRELATION_RETRACTED_EVENT, UNRESOLVED_WORK_ITEM_KEY, parseIssueStateRecord, } from '../domain/schema.js';
2
2
  import { doneRunnerSentinel, stageFromLabels } from '../domain/stages.js';
3
3
  import { builtInDefaultWorkflowDefinition, defaultWorkflowName, workflowStageVocabulary, } from '../domain/workflows.js';
4
+ import { isCustomCommandAction } from '../domain/custom-commands.js';
4
5
  import { createEventEnvelope } from '../lib/event-log.js';
5
6
  function stringArrayFromPayload(value) {
6
7
  return Array.isArray(value)
@@ -150,7 +151,10 @@ async function applyEvent(current, event, ctx, config) {
150
151
  const isForwardProgression = payload.nextStage !== undefined && payload.nextStage !== current.wake.stage;
151
152
  const stageChanged = payload.nextStage !== undefined && payload.nextStage !== current.wake.stage;
152
153
  const isFailed = payload.sentinel === 'FAILED';
153
- const isCompletedCodeReview = payload.action === 'codereview' && payload.sentinel === doneRunnerSentinel;
154
+ const isCompletedCustomCommand = payload.action !== undefined &&
155
+ payload.sentinel === doneRunnerSentinel &&
156
+ config !== undefined &&
157
+ isCustomCommandAction(payload.action, config);
154
158
  const shouldClearSession = isForwardProgression || isFailed;
155
159
  return parseIssueStateRecord({
156
160
  ...current,
@@ -160,15 +164,15 @@ async function applyEvent(current, event, ctx, config) {
160
164
  ...(payload.handledCommentId === undefined
161
165
  ? {}
162
166
  : { lastHandledCommentId: payload.handledCommentId }),
163
- ...(payload.sentinel === undefined || isCompletedCodeReview
167
+ ...(payload.sentinel === undefined || isCompletedCustomCommand
164
168
  ? {}
165
169
  : { lastRunSentinel: payload.sentinel }),
166
- ...(payload.action === undefined || isCompletedCodeReview
170
+ ...(payload.action === undefined || isCompletedCustomCommand
167
171
  ? {}
168
172
  : { lastRunAction: payload.action }),
169
173
  ...(payload.sentinel === doneRunnerSentinel &&
170
174
  payload.action !== undefined &&
171
- !isCompletedCodeReview
175
+ !isCompletedCustomCommand
172
176
  ? { lastCompletedAction: payload.action }
173
177
  : {}),
174
178
  // Remembered so the approval path knows which action to resume or
@@ -12,6 +12,7 @@ import { awaitingApprovalRunnerSentinel, stageLabelForStage } from '../domain/st
12
12
  import { chooseAction as chooseWorkflowAction, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection, workflowNameForProjection, } from '../domain/workflows.js';
13
13
  import { createEventEnvelope } from '../lib/event-log.js';
14
14
  import { branchNameForIssue } from '../domain/branch-naming.js';
15
+ import { customCommandWorkspace, isCustomCommandAction } from '../domain/custom-commands.js';
15
16
  import { resolveQuotaPauseUntil } from './quota-backoff.js';
16
17
  function latestHumanCommentId(candidate) {
17
18
  const human = candidate.comments.filter((c) => !c.isBotAuthored);
@@ -36,8 +37,11 @@ function latestHumanCommentId(candidate) {
36
37
  function isReviewThreadResourceUri(resourceUri) {
37
38
  return resourceUri.split(':')[1] === 'pr-review-thread';
38
39
  }
39
- function isLateralReadOnlyAction(action) {
40
- return action === 'codereview';
40
+ function isLateralReadOnlyAction(action, config) {
41
+ return isCustomCommandAction(action, config);
42
+ }
43
+ function workspaceModeForCustomCommand(action, config) {
44
+ return customCommandWorkspace(action, config);
41
45
  }
42
46
  function isFreshTriggeringComment(candidate) {
43
47
  const context = candidate.context;
@@ -212,11 +216,11 @@ export function createTickRunner(deps) {
212
216
  return false;
213
217
  }
214
218
  if (isAwaitingApproval(projection)) {
215
- return (policy.resolveCodeReviewRequest(projection) !== null ||
219
+ return (policy.resolveCustomCommandRequest(projection, deps.config) !== null ||
216
220
  policy.resolveApprovalTransition(projection) !== null ||
217
221
  policy.resolvePendingReviewFeedback(projection) !== null);
218
222
  }
219
- const nextAction = policy.resolveCodeReviewRequest(projection) ??
223
+ const nextAction = policy.resolveCustomCommandRequest(projection, deps.config)?.action ??
220
224
  policy.chooseAction(projection, workflow) ??
221
225
  policy.chooseRetryActionAfterHumanReply(projection);
222
226
  return nextAction !== null && policy.needsWakeAction(projection, workflow);
@@ -952,7 +956,7 @@ export function createTickRunner(deps) {
952
956
  if (isAwaitingApproval(issue)) {
953
957
  return policy.needsWakeAction(issue, workflow);
954
958
  }
955
- const nextAction = policy.resolveCodeReviewRequest(issue) ??
959
+ const nextAction = policy.resolveCustomCommandRequest(issue, deps.config)?.action ??
956
960
  policy.chooseAction(issue, workflow) ??
957
961
  policy.chooseRetryActionAfterHumanReply(issue);
958
962
  return nextAction !== null && policy.needsWakeAction(issue, workflow);
@@ -966,14 +970,16 @@ export function createTickRunner(deps) {
966
970
  }
967
971
  const workflowName = workflowNameForProjection(candidate, deps.config);
968
972
  let action;
973
+ let command;
969
974
  let claimedStage = candidate.wake.stage;
970
975
  let workspaceMode = 'none';
971
976
  if (isAwaitingApproval(candidate)) {
972
- const codeReviewRequest = policy.resolveCodeReviewRequest(candidate);
973
- if (codeReviewRequest !== null) {
974
- action = codeReviewRequest;
977
+ const customCommandRequest = policy.resolveCustomCommandRequest(candidate, deps.config);
978
+ if (customCommandRequest !== null) {
979
+ action = customCommandRequest.action;
980
+ command = customCommandRequest.command;
975
981
  claimedStage = candidate.wake.stage;
976
- workspaceMode = 'read-only';
982
+ workspaceMode = customCommandRequest.workspace;
977
983
  }
978
984
  else {
979
985
  const approvalResolution = policy.resolveApprovalTransition(candidate);
@@ -1054,7 +1060,7 @@ export function createTickRunner(deps) {
1054
1060
  // scratch (#258 follow-up incident: a FAILED `revise` run fell
1055
1061
  // back to a full fresh `implement` run and lost the PR-feedback
1056
1062
  // context).
1057
- const nextAction = policy.resolveCodeReviewRequest(candidate) ??
1063
+ const nextAction = policy.resolveCustomCommandRequest(candidate, deps.config)?.action ??
1058
1064
  policy.chooseRetryActionAfterHumanReply(candidate) ??
1059
1065
  workflowAction?.action ??
1060
1066
  null;
@@ -1062,10 +1068,10 @@ export function createTickRunner(deps) {
1062
1068
  return { status: 'idle' };
1063
1069
  }
1064
1070
  action = nextAction;
1071
+ command = policy.resolveCustomCommandRequest(candidate, deps.config)?.command;
1065
1072
  claimedStage = workflowAction?.stage ?? candidate.wake.stage;
1066
- workspaceMode = isLateralReadOnlyAction(action)
1067
- ? 'read-only'
1068
- : (workflowAction?.workspace ?? 'none');
1073
+ workspaceMode =
1074
+ workspaceModeForCustomCommand(action, deps.config) ?? workflowAction?.workspace ?? 'none';
1069
1075
  }
1070
1076
  // Resolve routing (with sideways fallback across quota-paused runners,
1071
1077
  // #67) before claiming a run, so a fully-paused tier costs nothing more
@@ -1076,6 +1082,7 @@ export function createTickRunner(deps) {
1076
1082
  stage: claimedStage,
1077
1083
  action,
1078
1084
  workflowName,
1085
+ ...(command === undefined ? {} : { command }),
1079
1086
  now: tickStartedAt,
1080
1087
  ...(ledgerAtStart === null ? {} : { ledger: ledgerAtStart }),
1081
1088
  });
@@ -1162,7 +1169,7 @@ export function createTickRunner(deps) {
1162
1169
  // the protocol; treat it as AWAITING_APPROVAL so the gate is enforced.
1163
1170
  const skipApproval = runnerResult.metadata?.skipApproval;
1164
1171
  const sentinel = rawSentinel === 'DONE' && skipApproval === false ? 'AWAITING_APPROVAL' : rawSentinel;
1165
- const nextStage = isLateralReadOnlyAction(action) && sentinel === 'DONE'
1172
+ const nextStage = isLateralReadOnlyAction(action, deps.config) && sentinel === 'DONE'
1166
1173
  ? null
1167
1174
  : lifecycle.nextStageFromSentinel(claimedStage, sentinel, workflow);
1168
1175
  const finishedAt = deps.clock.now().toISOString();
@@ -0,0 +1,58 @@
1
+ export const reservedCommandNames = ['approved', 'changes', 'question'];
2
+ function latestUnhandledHumanComment(issue) {
3
+ const context = issue.context;
4
+ const handledCommentId = typeof context.lastHandledCommentId === 'string' ? context.lastHandledCommentId : undefined;
5
+ const lastBotIndex = issue.comments.reduce((acc, c, i) => (c.isBotAuthored ? i : acc), -1);
6
+ const humanCommentsAfterBot = issue.comments
7
+ .slice(lastBotIndex + 1)
8
+ .filter((c) => !c.isBotAuthored);
9
+ const latestHumanComment = humanCommentsAfterBot.at(-1);
10
+ if (latestHumanComment === undefined || latestHumanComment.id === handledCommentId) {
11
+ return undefined;
12
+ }
13
+ return latestHumanComment;
14
+ }
15
+ function commandNameFromBody(body) {
16
+ for (const line of body.split(/\r?\n/)) {
17
+ const match = /^\/([A-Za-z0-9_.-]+)\b/.exec(line.trim());
18
+ if (match?.[1] !== undefined) {
19
+ return match[1].toLowerCase();
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+ export function customCommandAction(command, config) {
25
+ const entry = Object.entries(config.commands).find(([name]) => name.toLowerCase() === command.toLowerCase());
26
+ if (entry === undefined) {
27
+ return undefined;
28
+ }
29
+ const [name, definition] = entry;
30
+ return definition.action ?? name;
31
+ }
32
+ export function isCustomCommandAction(action, config) {
33
+ return Object.entries(config.commands).some(([name, definition]) => (definition.action ?? name) === action);
34
+ }
35
+ export function customCommandWorkspace(action, config) {
36
+ return Object.entries(config.commands).find(([name, definition]) => (definition.action ?? name) === action)?.[1].workspace;
37
+ }
38
+ export function resolveCustomCommand(issue, config) {
39
+ const latestHumanComment = latestUnhandledHumanComment(issue);
40
+ if (latestHumanComment === undefined) {
41
+ return null;
42
+ }
43
+ const command = commandNameFromBody(latestHumanComment.body);
44
+ if (command === null) {
45
+ return null;
46
+ }
47
+ const definitionEntry = Object.entries(config.commands).find(([name]) => name.toLowerCase() === command);
48
+ if (definitionEntry === undefined) {
49
+ return null;
50
+ }
51
+ const [configuredName, definition] = definitionEntry;
52
+ return {
53
+ action: definition.action ?? configuredName,
54
+ command: configuredName,
55
+ comment: latestHumanComment,
56
+ workspace: definition.workspace,
57
+ };
58
+ }
@@ -13,6 +13,11 @@ export function maxConfiguredRunnerTimeoutMs(config) {
13
13
  activeRunnerNames.add(stageRoute.runner);
14
14
  }
15
15
  }
16
+ for (const commandRoute of Object.values(config.commands)) {
17
+ if (commandRoute.runner !== undefined) {
18
+ activeRunnerNames.add(commandRoute.runner);
19
+ }
20
+ }
16
21
  const registryTimeouts = [...activeRunnerNames]
17
22
  .map((name) => config.runners[name])
18
23
  .map((entry) => (entry === undefined || entry.kind === 'fake' ? undefined : entry.timeoutMs))
@@ -57,6 +62,23 @@ export function resolveRunnerRouting(input) {
57
62
  if (workflow === undefined) {
58
63
  throw new Error(`Unknown workflow "${workflowName}".`);
59
64
  }
65
+ const commandRouteEntry = input.command === undefined
66
+ ? Object.entries(input.config.commands).find(([name, route]) => (route.action ?? name) === input.action)
67
+ : Object.entries(input.config.commands).find(([name]) => name.toLowerCase() === input.command?.toLowerCase());
68
+ const commandRoute = commandRouteEntry?.[1];
69
+ if (commandRoute?.runner !== undefined) {
70
+ const runnerName = commandRoute.runner;
71
+ const entry = input.config.runners[runnerName];
72
+ if (entry === undefined) {
73
+ throw new Error(`Command ${input.action} pins unknown runner "${runnerName}".`);
74
+ }
75
+ return {
76
+ runnerName,
77
+ runnerKind: entry.kind,
78
+ ...(commandRoute.tier === undefined ? {} : { tier: commandRoute.tier }),
79
+ reason: `command ${input.action} pins runner ${runnerName}`,
80
+ };
81
+ }
60
82
  const stageRoute = workflow.stages[input.stage];
61
83
  if (stageRoute?.runner !== undefined) {
62
84
  const entry = input.config.runners[stageRoute.runner];
@@ -70,7 +92,7 @@ export function resolveRunnerRouting(input) {
70
92
  reason: `stage ${input.stage} pins runner ${stageRoute.runner}`,
71
93
  };
72
94
  }
73
- const tier = stageRoute?.tier ?? input.config.defaultTier;
95
+ const tier = commandRoute?.tier ?? stageRoute?.tier ?? input.config.defaultTier;
74
96
  const candidates = input.config.tiers[tier];
75
97
  if (candidates === undefined || candidates.length === 0) {
76
98
  throw new Error(`Stage ${input.stage} routes to unknown or empty tier "${tier}".`);
@@ -101,8 +123,10 @@ export function resolveRunnerRouting(input) {
101
123
  ? `tier ${tier} recovery probe on ${runnerName} (estimated pause not yet elapsed)`
102
124
  : isFallback
103
125
  ? `tier ${tier} fell back to ${runnerName} (higher-priority candidates paused)`
104
- : stageRoute?.tier === undefined
105
- ? `defaultTier ${tier} selected runner ${runnerName}`
106
- : `stage ${input.stage} tier ${tier} selected runner ${runnerName}`,
126
+ : commandRoute?.tier !== undefined
127
+ ? `command ${input.action} tier ${tier} selected runner ${runnerName}`
128
+ : stageRoute?.tier === undefined
129
+ ? `defaultTier ${tier} selected runner ${runnerName}`
130
+ : `stage ${input.stage} tier ${tier} selected runner ${runnerName}`,
107
131
  };
108
132
  }
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { z } from 'zod';
5
+ import { reservedCommandNames } from './custom-commands.js';
5
6
  import { runnerSentinelValues } from './stages.js';
6
7
  import { correlationProvenanceSchema, correlationRelationSchema, correlationRoleSchema, resourceUriSchema, } from './resource-uri.js';
7
8
  const isoTimestampSchema = z.string().datetime({ offset: true });
@@ -308,6 +309,9 @@ const workflowStageSchema = stageRouteSchema.extend({
308
309
  workspace: workflowWorkspaceSchema,
309
310
  onDone: identifierSchema,
310
311
  });
312
+ const customCommandSchema = stageRouteSchema.extend({
313
+ workspace: workflowWorkspaceSchema.default('read-only'),
314
+ });
311
315
  const workflowDefinitionSchema = z.object({
312
316
  entryStage: identifierSchema.optional(),
313
317
  stages: z.record(identifierSchema, workflowStageSchema),
@@ -511,6 +515,13 @@ export const wakeConfigSchema = z
511
515
  },
512
516
  },
513
517
  }),
518
+ commands: z.record(identifierSchema, customCommandSchema).default({
519
+ codereview: {
520
+ action: 'codereview',
521
+ workspace: 'read-only',
522
+ tier: 'standard',
523
+ },
524
+ }),
514
525
  stages: z.record(z.string(), stageRouteSchema).default({
515
526
  queue: { action: 'refine', tier: 'light' },
516
527
  implement: { action: 'implement', tier: 'standard' },
@@ -615,6 +626,7 @@ export const wakeConfigSchema = z
615
626
  .superRefine((config, ctx) => {
616
627
  const promptsRoot = config.paths.promptsRoot ?? defaultPromptsRoot();
617
628
  const workflowEntries = Object.entries(config.workflows);
629
+ const commandEntries = Object.entries(config.commands);
618
630
  if (workflowEntries.length === 0) {
619
631
  ctx.addIssue({
620
632
  code: z.ZodIssueCode.custom,
@@ -690,6 +702,22 @@ export const wakeConfigSchema = z
690
702
  });
691
703
  }
692
704
  }
705
+ for (const [commandName, command] of commandEntries) {
706
+ if (reservedCommandNames.includes(commandName.toLowerCase())) {
707
+ ctx.addIssue({
708
+ code: z.ZodIssueCode.custom,
709
+ path: ['commands', commandName],
710
+ message: `Command "/${commandName}" is reserved for Wake approval control.`,
711
+ });
712
+ }
713
+ if (command.action === undefined && !promptExists(promptsRoot, commandName)) {
714
+ ctx.addIssue({
715
+ code: z.ZodIssueCode.custom,
716
+ path: ['commands', commandName, 'action'],
717
+ message: `Command "/${commandName}" omits action but no prompts/${commandName}.md template exists.`,
718
+ });
719
+ }
720
+ }
693
721
  });
694
722
  export const claudePrintResultSchema = z
695
723
  .object({
@@ -1 +1 @@
1
- export const wakeVersion = "0.1.7+gaa68b14";
1
+ export const wakeVersion = "0.1.8+g8f32cc9";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {