@atolis-hq/wake 0.2.34 → 0.2.35

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.
@@ -172,6 +172,9 @@ export function createClaudeRunner(options) {
172
172
  projection: input.projection,
173
173
  mode: isResume ? 'resume' : 'start',
174
174
  config: input.config,
175
+ ...(input.promptContextOverrides === undefined
176
+ ? {}
177
+ : { contextOverrides: input.promptContextOverrides }),
175
178
  ...(input.mergeConflictDetected === true ? { mergeConflictDetected: true } : {}),
176
179
  ...(input.upstreamChanges === undefined ? {} : { upstreamChanges: input.upstreamChanges }),
177
180
  });
@@ -215,7 +215,14 @@ export function createCodexRunner(options) {
215
215
  ...(input.workspaceMode === undefined ? {} : { workspaceMode: input.workspaceMode }),
216
216
  ...(input.mergeConflictDetected === true ? { mergeConflictDetected: true } : {}),
217
217
  ...(input.upstreamChanges === undefined ? {} : { upstreamChanges: input.upstreamChanges }),
218
- ...(toolCapabilityNote !== undefined ? { contextOverrides: { toolCapabilityNote } } : {}),
218
+ ...(toolCapabilityNote !== undefined || input.promptContextOverrides !== undefined
219
+ ? {
220
+ contextOverrides: {
221
+ ...input.promptContextOverrides,
222
+ ...(toolCapabilityNote === undefined ? {} : { toolCapabilityNote }),
223
+ },
224
+ }
225
+ : {}),
219
226
  });
220
227
  const model = resolveModel({
221
228
  action: input.action,
@@ -173,7 +173,14 @@ export function createCursorRunner(options) {
173
173
  ...(input.workspaceMode === undefined ? {} : { workspaceMode: input.workspaceMode }),
174
174
  ...(input.mergeConflictDetected === true ? { mergeConflictDetected: true } : {}),
175
175
  ...(input.upstreamChanges === undefined ? {} : { upstreamChanges: input.upstreamChanges }),
176
- ...(toolCapabilityNote !== undefined ? { contextOverrides: { toolCapabilityNote } } : {}),
176
+ ...(toolCapabilityNote !== undefined || input.promptContextOverrides !== undefined
177
+ ? {
178
+ contextOverrides: {
179
+ ...input.promptContextOverrides,
180
+ ...(toolCapabilityNote === undefined ? {} : { toolCapabilityNote }),
181
+ },
182
+ }
183
+ : {}),
177
184
  });
178
185
  const model = resolveModel({ action: input.action, settings: options.settings });
179
186
  const cwd = input.workspacePath ?? options.cwd;
@@ -1,4 +1,5 @@
1
1
  import { defaultAgentIdentity } from '../../domain/schema.js';
2
+ import { alwaysManualIgnoredLabels } from '../../core/policy-engine.js';
2
3
  import { chooseAction, workflowForProjection } from '../../domain/workflows.js';
3
4
  import { branchNameForIssue } from '../git/git-workspace-manager.js';
4
5
  import { loadPromptTemplate, renderPromptTemplate } from './prompt-templates.js';
@@ -165,6 +166,17 @@ export async function buildStagePrompt(input) {
165
166
  isStart: mode === 'start',
166
167
  isResume: mode === 'resume',
167
168
  };
169
+ if (input.action === 'triage-assign' && input.config !== undefined) {
170
+ const ignoredLabels = [
171
+ ...new Set([
172
+ ...alwaysManualIgnoredLabels,
173
+ ...input.config.sources.github.policy.ignoredLabels,
174
+ ]),
175
+ ];
176
+ context.triageIgnoredLabels = ignoredLabels;
177
+ context.triageIgnoredLabelsJson = JSON.stringify(ignoredLabels);
178
+ context.triageReposJson = JSON.stringify(input.config.sources.github.repos);
179
+ }
168
180
  if (resolvedWorkspaceMode === 'branch') {
169
181
  context.branch = branchNameForIssue(input.projection.issue.number);
170
182
  }
@@ -1,6 +1,7 @@
1
1
  import { awaitingApprovalRunnerSentinel, 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
+ export const alwaysManualIgnoredLabels = ['security', 'wake:manual', 'wake:always-manual'];
4
5
  function isAwaitingApproval(issue) {
5
6
  const context = issue.context;
6
7
  return context.lastRunSentinel === awaitingApprovalRunnerSentinel;
@@ -36,7 +37,8 @@ function labelsAndAssigneesQualify(input) {
36
37
  if (input.requiredLabels.some((label) => !labels.has(label))) {
37
38
  return false;
38
39
  }
39
- if (input.ignoredLabels.some((label) => labels.has(label))) {
40
+ const ignoredLabels = new Set([...input.ignoredLabels, ...alwaysManualIgnoredLabels]);
41
+ if ([...ignoredLabels].some((label) => labels.has(label))) {
40
42
  return false;
41
43
  }
42
44
  if (input.requiredAssignees.length > 0 &&
@@ -67,6 +69,9 @@ export function createPolicyEngine() {
67
69
  if (issue.issue.state !== 'open') {
68
70
  return false;
69
71
  }
72
+ if (issue.issue.labels.some((label) => alwaysManualIgnoredLabels.includes(label))) {
73
+ return false;
74
+ }
70
75
  const context = issue.context;
71
76
  if (config.workflowSelectors.length > 0) {
72
77
  return typeof context.workflow === 'string';
@@ -240,6 +245,10 @@ export function createPolicyEngine() {
240
245
  return false;
241
246
  }
242
247
  const kind = resourceUri.split(':')[1];
248
+ if (kind === 'schedule') {
249
+ const workflow = unresolved.payload.workflow;
250
+ return typeof workflow === 'string' && config.workflows[workflow] !== undefined;
251
+ }
243
252
  if (config.workflowSelectors.length > 0) {
244
253
  return selectWorkflowForEvent(unresolved, config) !== null;
245
254
  }
@@ -0,0 +1,199 @@
1
+ import { createUnkeyedEventEnvelope } from '../lib/event-log.js';
2
+ import { buildResourceUri } from '../domain/resource-uri.js';
3
+ import { readJsonFile, writeJsonFile } from '../lib/json-file.js';
4
+ import { createWakePaths } from '../lib/paths.js';
5
+ import { isMissingPathError } from '../lib/state-health.js';
6
+ const syntheticRepo = 'wake/internal';
7
+ const syntheticIssueNumber = 1;
8
+ function parseCronField(raw, min, max) {
9
+ const allowed = new Set();
10
+ for (const part of raw.split(',')) {
11
+ const trimmed = part.trim();
12
+ if (trimmed.length === 0) {
13
+ throw new Error(`invalid cron field "${raw}"`);
14
+ }
15
+ const stepMatch = /^(.*)\/(\d+)$/.exec(trimmed);
16
+ const base = stepMatch?.[1] ?? trimmed;
17
+ const step = stepMatch === null ? 1 : Number(stepMatch[2]);
18
+ if (!Number.isInteger(step) || step <= 0) {
19
+ throw new Error(`invalid cron step "${trimmed}"`);
20
+ }
21
+ const range = base === '*'
22
+ ? [min, max]
23
+ : (() => {
24
+ const match = /^(\d+)(?:-(\d+))?$/.exec(base);
25
+ if (match === null) {
26
+ throw new Error(`invalid cron segment "${trimmed}"`);
27
+ }
28
+ const start = Number(match[1]);
29
+ const end = match[2] === undefined ? start : Number(match[2]);
30
+ if (start < min || end > max || start > end) {
31
+ throw new Error(`cron segment "${trimmed}" is outside ${min}-${max}`);
32
+ }
33
+ return [start, end];
34
+ })();
35
+ for (let value = range[0]; value <= range[1]; value += step) {
36
+ allowed.add(value);
37
+ }
38
+ }
39
+ return { matches: (value) => allowed.has(value) };
40
+ }
41
+ function parseCron(cron) {
42
+ const fields = cron.trim().split(/\s+/);
43
+ if (fields.length !== 5) {
44
+ throw new Error(`cron schedule must have five fields: ${cron}`);
45
+ }
46
+ return [
47
+ parseCronField(fields[0], 0, 59),
48
+ parseCronField(fields[1], 0, 23),
49
+ parseCronField(fields[2], 1, 31),
50
+ parseCronField(fields[3], 1, 12),
51
+ parseCronField(fields[4], 0, 6),
52
+ ];
53
+ }
54
+ function matchesCron(date, cron) {
55
+ return (cron[0].matches(date.getUTCMinutes()) &&
56
+ cron[1].matches(date.getUTCHours()) &&
57
+ cron[2].matches(date.getUTCDate()) &&
58
+ cron[3].matches(date.getUTCMonth() + 1) &&
59
+ cron[4].matches(date.getUTCDay()));
60
+ }
61
+ function floorToMinute(date) {
62
+ const copy = new Date(date);
63
+ copy.setUTCSeconds(0, 0);
64
+ return copy;
65
+ }
66
+ function previousMatchingSlot(input) {
67
+ const parsed = parseCron(input.cron);
68
+ const slot = floorToMinute(input.now);
69
+ const afterMs = input.after === undefined ? undefined : Date.parse(input.after);
70
+ const lookbackMinutes = 366 * 24 * 60;
71
+ for (let checked = 0; checked < lookbackMinutes; checked += 1) {
72
+ const slotIso = slot.toISOString();
73
+ if (afterMs !== undefined && slot.getTime() <= afterMs) {
74
+ return null;
75
+ }
76
+ if (matchesCron(slot, parsed)) {
77
+ return slotIso;
78
+ }
79
+ slot.setUTCMinutes(slot.getUTCMinutes() - 1);
80
+ }
81
+ return null;
82
+ }
83
+ function triggerStateFile(wakeRoot, workflow) {
84
+ return `${createWakePaths(wakeRoot).dataRoot}/triggers/${workflow}.json`;
85
+ }
86
+ async function readTriggerState(wakeRoot, workflow) {
87
+ try {
88
+ const raw = await readJsonFile(triggerStateFile(wakeRoot, workflow));
89
+ if (raw === null || typeof raw !== 'object') {
90
+ return null;
91
+ }
92
+ const record = raw;
93
+ return record.schemaVersion === 1 &&
94
+ record.workflow === workflow &&
95
+ typeof record.lastFiredSlot === 'string' &&
96
+ typeof record.updatedAt === 'string'
97
+ ? {
98
+ schemaVersion: 1,
99
+ workflow,
100
+ lastFiredSlot: record.lastFiredSlot,
101
+ updatedAt: record.updatedAt,
102
+ }
103
+ : null;
104
+ }
105
+ catch (error) {
106
+ if (isMissingPathError(error)) {
107
+ return null;
108
+ }
109
+ throw error;
110
+ }
111
+ }
112
+ function scheduledEventId(workflow, slot) {
113
+ return `scheduled-workflow-${workflow}-${slot.replace(/[^a-z0-9]+/gi, '-')}`;
114
+ }
115
+ function scheduledResourceUri(workflow, slot) {
116
+ return buildResourceUri('wake', 'schedule', `${workflow}@${slot}`);
117
+ }
118
+ function syntheticTicket(input) {
119
+ const eventId = scheduledEventId(input.workflow, input.slot);
120
+ const resourceUri = scheduledResourceUri(input.workflow, input.slot);
121
+ const url = `https://wake.local/schedules/${encodeURIComponent(input.workflow)}/${encodeURIComponent(input.slot)}`;
122
+ return createUnkeyedEventEnvelope({
123
+ eventId,
124
+ streamScope: 'global-intake',
125
+ direction: 'inbound',
126
+ sourceSystem: 'wake',
127
+ sourceEventType: 'ticket.upsert',
128
+ sourceRefs: {
129
+ repo: syntheticRepo,
130
+ issueNumber: syntheticIssueNumber,
131
+ sourceUrl: url,
132
+ resourceUri,
133
+ },
134
+ occurredAt: input.slot,
135
+ ingestedAt: input.now,
136
+ trigger: 'immediate',
137
+ payload: {
138
+ ticket: {
139
+ repo: syntheticRepo,
140
+ number: syntheticIssueNumber,
141
+ title: `Scheduled workflow: ${input.workflow}`,
142
+ body: `Wake fired scheduled workflow "${input.workflow}" for cron slot ${input.slot}.`,
143
+ labels: ['wake:scheduled-workflow', `wake:workflow.${input.workflow}`],
144
+ assignees: [],
145
+ isPullRequest: false,
146
+ state: 'open',
147
+ url,
148
+ createdAt: input.slot,
149
+ updatedAt: input.slot,
150
+ },
151
+ workflow: input.workflow,
152
+ trigger: {
153
+ kind: 'schedule',
154
+ slot: input.slot,
155
+ idempotencyKey: eventId,
156
+ },
157
+ providerEventType: 'wake.schedule.fired',
158
+ },
159
+ derivedHints: {
160
+ workflow: input.workflow,
161
+ },
162
+ });
163
+ }
164
+ export function createScheduledWorkflowSource(deps) {
165
+ return {
166
+ async pollEvents() {
167
+ const events = [];
168
+ const now = deps.now();
169
+ const nowIso = now.toISOString();
170
+ for (const [workflow, definition] of Object.entries(deps.config.workflows)) {
171
+ const cron = definition.trigger?.schedule?.cron;
172
+ if (cron === undefined) {
173
+ continue;
174
+ }
175
+ const state = await readTriggerState(deps.config.paths.wakeRoot, workflow);
176
+ const slot = previousMatchingSlot({
177
+ cron,
178
+ now,
179
+ ...(state?.lastFiredSlot === undefined ? {} : { after: state.lastFiredSlot }),
180
+ });
181
+ if (slot === null) {
182
+ continue;
183
+ }
184
+ const eventId = scheduledEventId(workflow, slot);
185
+ if ((await deps.stateStore.readEventEnvelope(eventId)) !== null) {
186
+ await writeJsonFile(triggerStateFile(deps.config.paths.wakeRoot, workflow), {
187
+ schemaVersion: 1,
188
+ workflow,
189
+ lastFiredSlot: slot,
190
+ updatedAt: nowIso,
191
+ });
192
+ continue;
193
+ }
194
+ events.push(syntheticTicket({ workflow, slot, now: nowIso }));
195
+ }
196
+ return events;
197
+ },
198
+ };
199
+ }
@@ -602,6 +602,14 @@ export function createTickRunner(deps) {
602
602
  runId,
603
603
  routing,
604
604
  workspaceMode,
605
+ ...(action === 'triage-assign'
606
+ ? {
607
+ promptContextOverrides: {
608
+ triageCapacityAvailable: true,
609
+ triageWipCapDescription: 'Wake has no active running work item at triage start; do not assign more than one issue.',
610
+ },
611
+ }
612
+ : {}),
605
613
  ...(workspacePath === undefined ? {} : { workspacePath }),
606
614
  ...(mergeConflictDetected ? { mergeConflictDetected: true } : {}),
607
615
  ...(upstreamChanges === undefined ? {} : { upstreamChanges }),
@@ -375,11 +375,18 @@ const workflowStageSchema = stageRouteSchema.extend({
375
375
  workspace: workflowWorkspaceSchema,
376
376
  onDone: identifierSchema,
377
377
  });
378
+ const workflowTriggerScheduleSchema = z.object({
379
+ cron: z.string().min(1),
380
+ });
381
+ const workflowTriggerSchema = z.object({
382
+ schedule: workflowTriggerScheduleSchema.optional(),
383
+ });
378
384
  const customCommandSchema = stageRouteSchema.extend({
379
385
  workspace: workflowWorkspaceSchema.default('read-only'),
380
386
  });
381
387
  const workflowDefinitionSchema = z.object({
382
388
  entryStage: identifierSchema.optional(),
389
+ trigger: workflowTriggerSchema.optional(),
383
390
  stages: z.record(identifierSchema, workflowStageSchema),
384
391
  });
385
392
  const workflowSelectorMatchSchema = z
@@ -605,6 +612,21 @@ const wakeConfigBaseSchema = z.object({
605
612
  },
606
613
  },
607
614
  },
615
+ triage: {
616
+ trigger: {
617
+ schedule: {
618
+ cron: '*/10 * * * *',
619
+ },
620
+ },
621
+ stages: {
622
+ assign: {
623
+ action: 'triage-assign',
624
+ workspace: 'none',
625
+ tier: 'light',
626
+ onDone: 'done',
627
+ },
628
+ },
629
+ },
608
630
  }),
609
631
  workflowSelectors: z.array(workflowSelectorSchema).default([]),
610
632
  commands: z.record(identifierSchema, customCommandSchema).default({
@@ -102,6 +102,10 @@ function selectorMatches(selector, input) {
102
102
  return true;
103
103
  }
104
104
  export function selectWorkflowForEvent(event, config) {
105
+ const hintedWorkflow = event.payload.workflow;
106
+ if (typeof hintedWorkflow === 'string' && config.workflows[hintedWorkflow] !== undefined) {
107
+ return hintedWorkflow;
108
+ }
105
109
  const input = workflowSelectorInputFromEvent(event);
106
110
  const selector = config.workflowSelectors.find((candidate) => selectorMatches(candidate, input));
107
111
  return selector?.workflow ?? null;
package/dist/src/main.js CHANGED
@@ -31,6 +31,7 @@ import { loadWakeConfig } from './config/load-config.js';
31
31
  import { createActiveRunRecovery } from './core/active-run-recovery.js';
32
32
  import { createControlPlane } from './core/control-plane.js';
33
33
  import { createOutboundSinkRouter, createWorkSourceFanIn } from './core/sink-router.js';
34
+ import { createScheduledWorkflowSource } from './core/scheduled-workflow-source.js';
34
35
  import { createTickRunner } from './core/tick-runner.js';
35
36
  import { systemClock } from './lib/clock.js';
36
37
  import { createDetachedProcessLogSink } from './lib/detached-process-logging.js';
@@ -521,6 +522,14 @@ export async function buildRuntime(args) {
521
522
  })
522
523
  : null;
523
524
  const workSource = createWorkSourceFanIn([
525
+ {
526
+ source: 'wake-schedule',
527
+ pollEvents: createScheduledWorkflowSource({
528
+ config,
529
+ stateStore,
530
+ now: () => systemClock.now(),
531
+ }).pollEvents,
532
+ },
524
533
  {
525
534
  source: sourceName,
526
535
  pollEvents: ticketingSystem.pollEvents,
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "gacd501e";
127
+ export const wakeVersion = "g60727fc";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.34",
3
+ "version": "0.2.35",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -0,0 +1,33 @@
1
+ ---
2
+ stage: assign
3
+ permissionMode: acceptEdits
4
+ allowedTools: Bash(gh *), Bash(jq *), Read
5
+ extraArgs:
6
+ maxTurns: 30
7
+ skipApproval: true
8
+ ---
9
+
10
+ You are Wake, in the TRIAGE ASSIGN stage for {{workItemKey}}.
11
+
12
+ This stage may inspect the broad GitHub backlog and assign at most one issue to Wake.
13
+ {{toolCapabilityNote}}
14
+
15
+ Wake-side guardrails for this run:
16
+
17
+ - Capacity available: {{triageCapacityAvailable}}
18
+ - Do not assign more than one issue.
19
+ - Do not inspect or assign issues carrying any of these always-manual labels:
20
+ {{triageIgnoredLabelsJson}}
21
+ - Configured repositories:
22
+ {{triageReposJson}}
23
+
24
+ Use `gh issue list` and `gh issue view` only against the configured repositories.
25
+ Filter out every always-manual label in the GitHub query before viewing candidate
26
+ details. If no suitable issue remains, report DONE without assigning anything.
27
+
28
+ When you choose a candidate, assign it to the authenticated Wake GitHub user with
29
+ `gh issue edit <number> --repo <owner/repo> --add-assignee @me`.
30
+
31
+ Wake will provide the schedule trigger item below in a delimited untrusted data
32
+ block. It is an audit record, not the backlog to triage.
33
+