@atolis-hq/wake 0.1.7 → 0.1.9

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.
@@ -210,7 +210,7 @@ export function formatWakeComment(payload, controlPlaneUrl) {
210
210
  const header = `**${name}** _(Wake ${wakeVersion}${details.length > 0 ? ` · ${details.join(' · ')}` : ''})_`;
211
211
  const sections = [wakeCommentMarker, header, body];
212
212
  if (kind === 'approval-request') {
213
- sections.push('_To approve this work, reply with `/approved`. To request changes, reply with `/changes` followed by your feedback. To ask a question without requesting changes, reply with `/question` followed by your question._');
213
+ sections.push('_To approve this work, reply with `/approved`. To request changes, reply with `/changes` followed by your feedback. To ask a question without requesting changes, reply with `/ask` followed by your question._');
214
214
  }
215
215
  if (sessionId !== undefined) {
216
216
  const resumeCommandArgs = cli === undefined
@@ -2,7 +2,6 @@ import { defaultAgentIdentity } from '../../domain/schema.js';
2
2
  import { chooseAction, workflowForProjection } from '../../domain/workflows.js';
3
3
  import { branchNameForIssue } from '../git/git-workspace-manager.js';
4
4
  import { loadPromptTemplate, renderPromptTemplate } from './prompt-templates.js';
5
- const questionCommandPattern = /^\/question\b/i;
6
5
  function reviewCommentApiId(comment) {
7
6
  // github-pull-request-activity-source.ts composites review-comment ids as
8
7
  // `pr-review-comment-<id>`; strip that prefix back off to recover the raw
@@ -51,22 +50,6 @@ function previousCommentsThroughLastRun(projection) {
51
50
  const cursorIndex = projection.comments.findIndex((comment) => comment.id === handledCommentId);
52
51
  return cursorIndex === -1 ? [] : projection.comments.slice(0, cursorIndex + 1);
53
52
  }
54
- function matchesCommand(body, pattern) {
55
- return body.split(/\r?\n/).some((line) => pattern.test(line.trim()));
56
- }
57
- function latestQuestionCommandNote(input) {
58
- const { comments, successSentinel } = input;
59
- const latestHumanComment = comments.at(-1);
60
- if (latestHumanComment === undefined ||
61
- !matchesCommand(latestHumanComment.body, questionCommandPattern)) {
62
- return '';
63
- }
64
- return [
65
- 'The latest actionable command is `/question`.',
66
- `Answer the question in the resumed session context. Do not make code changes solely because of this command; if the answer does not require changes, leave the work in its current state and report ${successSentinel}.`,
67
- '',
68
- ].join('\n');
69
- }
70
53
  function parseFrontmatterList(value) {
71
54
  if (value === undefined || value.trim().length === 0) {
72
55
  return [];
@@ -203,13 +186,7 @@ export async function buildStagePrompt(input) {
203
186
  const permissionMode = template.frontmatter.permissionMode;
204
187
  const commentsToAddress = newCommentsSinceLastRun(input.projection);
205
188
  const priorComments = previousCommentsThroughLastRun(input.projection);
206
- context.feedbackCommandNote =
207
- mode === 'resume'
208
- ? latestQuestionCommandNote({
209
- comments: commentsToAddress,
210
- successSentinel: skipApproval ? 'DONE' : 'AWAITING_APPROVAL',
211
- })
212
- : '';
189
+ context.feedbackCommandNote = '';
213
190
  const commentSections = mode === 'resume'
214
191
  ? [
215
192
  {
@@ -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;
@@ -9,13 +10,10 @@ function isAwaitingApproval(issue) {
9
10
  // quoted reply containing /approved does not approve the gate.
10
11
  const approvedCommandPattern = /^\/approved\b/i;
11
12
  const changesCommandPattern = /^\/changes\b/i;
12
- const questionCommandPattern = /^\/question\b/i;
13
- const codeReviewCommandPattern = /^\/codereview\b/i;
14
13
  // The action Wake runs when a correlated PR gets new reviewer feedback while
15
14
  // the work item is awaiting approval. Not configurable per workflow: it's a
16
15
  // lateral response to a PR surface, not a workflow stage.
17
16
  const reviewFeedbackAction = 'revise';
18
- const codeReviewAction = 'codereview';
19
17
  function matchesCommand(body, pattern) {
20
18
  return body.split(/\r?\n/).some((line) => pattern.test(line.trim()));
21
19
  }
@@ -141,13 +139,12 @@ export function createPolicyEngine() {
141
139
  }
142
140
  const approved = matchesCommand(latestHumanComment.body, approvedCommandPattern);
143
141
  const changesRequested = matchesCommand(latestHumanComment.body, changesCommandPattern);
144
- const questionAsked = matchesCommand(latestHumanComment.body, questionCommandPattern);
145
- // Neither an explicit /approved, /changes, nor /question: treat this as
142
+ // Neither an explicit /approved nor /changes: treat this as
146
143
  // conversation, not a decision. Stay idle rather than re-running the
147
144
  // pending action off the back of an unmarked clarifying question (S2).
148
145
  // The comment stays unhandled, so it's reconsidered on the next tick and
149
146
  // by a human who follows up with an explicit command.
150
- if (!approved && !changesRequested && !questionAsked) {
147
+ if (!approved && !changesRequested) {
151
148
  return null;
152
149
  }
153
150
  return { approved, pendingAction };
@@ -155,7 +152,7 @@ export function createPolicyEngine() {
155
152
  // Callers must try resolveApprovalTransition first and only fall back to
156
153
  // this when it returns null. resolveApprovalTransition doesn't check
157
154
  // resourceUri, so a PR-surface comment that happens to carry an explicit
158
- // /approved, /changes, or /question command is deliberately still routed
155
+ // /approved or /changes command is deliberately still routed
159
156
  // there — this function only ever sees comments resolveApprovalTransition
160
157
  // already passed on (plain PR feedback with no command).
161
158
  resolvePendingReviewFeedback(issue) {
@@ -173,13 +170,8 @@ export function createPolicyEngine() {
173
170
  }
174
171
  return reviewFeedbackAction;
175
172
  },
176
- resolveCodeReviewRequest(issue) {
177
- const latestHumanComment = latestUnhandledHumanComment(issue);
178
- if (latestHumanComment === undefined ||
179
- !matchesCommand(latestHumanComment.body, codeReviewCommandPattern)) {
180
- return null;
181
- }
182
- return codeReviewAction;
173
+ resolveCustomCommandRequest(issue, config) {
174
+ return resolveCustomCommand(issue, config);
183
175
  },
184
176
  qualifiesForMint(unresolved, config) {
185
177
  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;
@@ -123,7 +127,7 @@ export function createTickRunner(deps) {
123
127
  // was already replied to.
124
128
  //
125
129
  // Never threads a pr-review-thread surface specifically: this is
126
- // Wake's own status/approval-request/question card, a milestone
130
+ // Wake's own status, approval-request, or question card: a milestone
127
131
  // message, not a targeted reply to one inline comment — burying it
128
132
  // as a reply deep in a single review thread makes it easy to miss.
129
133
  // Omitting resourceUri here falls back to sourceOrigin in
@@ -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'];
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,18 @@ export const wakeConfigSchema = z
511
515
  },
512
516
  },
513
517
  }),
518
+ commands: z.record(identifierSchema, customCommandSchema).default({
519
+ ask: {
520
+ action: 'ask',
521
+ workspace: 'read-only',
522
+ tier: 'light',
523
+ },
524
+ codereview: {
525
+ action: 'codereview',
526
+ workspace: 'read-only',
527
+ tier: 'standard',
528
+ },
529
+ }),
514
530
  stages: z.record(z.string(), stageRouteSchema).default({
515
531
  queue: { action: 'refine', tier: 'light' },
516
532
  implement: { action: 'implement', tier: 'standard' },
@@ -615,6 +631,7 @@ export const wakeConfigSchema = z
615
631
  .superRefine((config, ctx) => {
616
632
  const promptsRoot = config.paths.promptsRoot ?? defaultPromptsRoot();
617
633
  const workflowEntries = Object.entries(config.workflows);
634
+ const commandEntries = Object.entries(config.commands);
618
635
  if (workflowEntries.length === 0) {
619
636
  ctx.addIssue({
620
637
  code: z.ZodIssueCode.custom,
@@ -690,6 +707,22 @@ export const wakeConfigSchema = z
690
707
  });
691
708
  }
692
709
  }
710
+ for (const [commandName, command] of commandEntries) {
711
+ if (reservedCommandNames.includes(commandName.toLowerCase())) {
712
+ ctx.addIssue({
713
+ code: z.ZodIssueCode.custom,
714
+ path: ['commands', commandName],
715
+ message: `Command "/${commandName}" is reserved for Wake approval control.`,
716
+ });
717
+ }
718
+ if (command.action === undefined && !promptExists(promptsRoot, commandName)) {
719
+ ctx.addIssue({
720
+ code: z.ZodIssueCode.custom,
721
+ path: ['commands', commandName, 'action'],
722
+ message: `Command "/${commandName}" omits action but no prompts/${commandName}.md template exists.`,
723
+ });
724
+ }
725
+ }
693
726
  });
694
727
  export const claudePrintResultSchema = z
695
728
  .object({
@@ -1 +1 @@
1
- export const wakeVersion = "0.1.7+gaa68b14";
1
+ export const wakeVersion = "0.1.9+g764182b";
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.9",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/prompts/ask.md ADDED
@@ -0,0 +1,36 @@
1
+ ---
2
+ stage: ask
3
+ permissionMode: default
4
+ allowedTools: Read, Glob, Grep, Bash(git fetch), Bash(git status), Bash(git diff *), Bash(git log *), Bash(gh *), WebSearch, WebFetch
5
+ extraArgs:
6
+ maxTurns: 40
7
+ skipApproval: true
8
+ ---
9
+
10
+ {{#if isStart}}
11
+ You are Wake, running a read-only ASK action for {{workItemKey}}.
12
+
13
+ The operator asked a question with `/ask` on the issue or on a correlated pull
14
+ request. Answer the question using the ticket context, comments, correlated PR
15
+ context present in this prompt, repository contents, and any read-only external
16
+ sources needed for accuracy.
17
+ {{else}}
18
+ Resuming the read-only ASK action for {{workItemKey}}.
19
+ {{/if}}
20
+
21
+ {{toolCapabilityNote}}
22
+
23
+ The current working directory is a read-only clone of {{repo}}.
24
+
25
+ Response requirements:
26
+
27
+ - Do not edit files, stage changes, commit, push, open pull requests, apply
28
+ labels, or move lifecycle state.
29
+ - Answer only the question asked in the latest `/ask` command.
30
+ - If the answer does not require code changes, leave the work in its current
31
+ state and report DONE.
32
+ - If you cannot answer safely from the available context, report BLOCKED with
33
+ the specific missing information.
34
+
35
+ Wake will provide the issue data and comments below in a delimited untrusted
36
+ data block.