@atolis-hq/wake 0.1.0

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.
Files changed (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +140 -0
  3. package/dist/src/adapters/claude/claude-runner.js +388 -0
  4. package/dist/src/adapters/claude/prompt-templates.js +57 -0
  5. package/dist/src/adapters/codex/codex-runner.js +391 -0
  6. package/dist/src/adapters/cursor/cursor-runner.js +352 -0
  7. package/dist/src/adapters/docker/docker-cli.js +97 -0
  8. package/dist/src/adapters/fake/fake-artifact-verifier.js +14 -0
  9. package/dist/src/adapters/fake/fake-github-pull-request-activity-source.js +73 -0
  10. package/dist/src/adapters/fake/fake-resource-index.js +21 -0
  11. package/dist/src/adapters/fake/fake-runner.js +22 -0
  12. package/dist/src/adapters/fake/fake-ticketing-system.js +166 -0
  13. package/dist/src/adapters/fake/fake-workspace-manager.js +27 -0
  14. package/dist/src/adapters/fs/resource-index.js +84 -0
  15. package/dist/src/adapters/fs/self-update-ledger.js +17 -0
  16. package/dist/src/adapters/fs/state-store.js +364 -0
  17. package/dist/src/adapters/git/git-workspace-manager.js +168 -0
  18. package/dist/src/adapters/github/github-artifact-verifier.js +38 -0
  19. package/dist/src/adapters/github/github-auth.js +16 -0
  20. package/dist/src/adapters/github/github-client.js +100 -0
  21. package/dist/src/adapters/github/github-issues-work-source.js +410 -0
  22. package/dist/src/adapters/github/github-pull-request-activity-source.js +263 -0
  23. package/dist/src/adapters/http/ui-assets.js +302 -0
  24. package/dist/src/adapters/http/ui-data.js +389 -0
  25. package/dist/src/adapters/http/ui-server.js +151 -0
  26. package/dist/src/adapters/runner/cli-command.js +43 -0
  27. package/dist/src/adapters/runner/prompt-templates.js +76 -0
  28. package/dist/src/adapters/runner/runner-cli-adapter.js +112 -0
  29. package/dist/src/adapters/runner/runner-registry.js +141 -0
  30. package/dist/src/adapters/runner/stage-prompt.js +256 -0
  31. package/dist/src/adapters/runner/transcripts.js +25 -0
  32. package/dist/src/cli/correlate-command.js +62 -0
  33. package/dist/src/cli/init-command.js +11 -0
  34. package/dist/src/cli/locks-command.js +19 -0
  35. package/dist/src/cli/sandbox-command.js +191 -0
  36. package/dist/src/cli/sandbox-logging.js +30 -0
  37. package/dist/src/cli/sandbox-resume.js +77 -0
  38. package/dist/src/cli/scaffold-assets.js +173 -0
  39. package/dist/src/cli/self-update-command.js +158 -0
  40. package/dist/src/cli/startup-preflight.js +127 -0
  41. package/dist/src/cli/stop-command.js +42 -0
  42. package/dist/src/cli/ui-command.js +27 -0
  43. package/dist/src/config/defaults.js +11 -0
  44. package/dist/src/config/load-config.js +26 -0
  45. package/dist/src/core/contracts.js +1 -0
  46. package/dist/src/core/control-plane.js +127 -0
  47. package/dist/src/core/lifecycle-service.js +8 -0
  48. package/dist/src/core/policy-engine.js +200 -0
  49. package/dist/src/core/projection-updater.js +438 -0
  50. package/dist/src/core/quota-backoff.js +69 -0
  51. package/dist/src/core/sink-router.js +85 -0
  52. package/dist/src/core/tick-runner.js +1347 -0
  53. package/dist/src/domain/branch-naming.js +14 -0
  54. package/dist/src/domain/resource-uri.js +38 -0
  55. package/dist/src/domain/runner-routing.js +108 -0
  56. package/dist/src/domain/schema.js +685 -0
  57. package/dist/src/domain/sources.js +16 -0
  58. package/dist/src/domain/stages.js +39 -0
  59. package/dist/src/domain/types.js +1 -0
  60. package/dist/src/domain/workflows.js +83 -0
  61. package/dist/src/lib/clock.js +5 -0
  62. package/dist/src/lib/event-log.js +21 -0
  63. package/dist/src/lib/json-file.js +23 -0
  64. package/dist/src/lib/lock.js +118 -0
  65. package/dist/src/lib/paths.js +44 -0
  66. package/dist/src/lib/work-id.js +19 -0
  67. package/dist/src/main.js +523 -0
  68. package/dist/src/version.js +1 -0
  69. package/docker/Dockerfile +54 -0
  70. package/docker/entrypoint.sh +75 -0
  71. package/docker/log-command.sh +107 -0
  72. package/docker/setup.sh +72 -0
  73. package/package.json +52 -0
  74. package/prompts/implement.md +49 -0
  75. package/prompts/refine.md +44 -0
@@ -0,0 +1,685 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { z } from 'zod';
5
+ import { runnerSentinelValues, } from './stages.js';
6
+ import { correlationProvenanceSchema, correlationRelationSchema, correlationRoleSchema, resourceUriSchema, } from './resource-uri.js';
7
+ const isoTimestampSchema = z.string().datetime({ offset: true });
8
+ const identifierSchema = z.string().min(1);
9
+ export const reportedArtifactSchema = z.object({
10
+ kind: z.literal('pr'),
11
+ url: z.string().url(),
12
+ });
13
+ export const wakeArtifactsEnvelopeSchema = z.object({
14
+ artifacts: z.array(reportedArtifactSchema).default([]),
15
+ });
16
+ export const runnerSentinelSchema = z.enum(runnerSentinelValues);
17
+ export const defaultAgentIdentity = 'Wake';
18
+ export const defaultSmokePrompt = `This is ${defaultAgentIdentity}, reply with "hi ${defaultAgentIdentity} only"`;
19
+ const runnerFailureClassSchema = z.enum(['task', 'quota', 'infra']);
20
+ const modelOverridesSchema = z.object({
21
+ default: z.string().optional(),
22
+ }).catchall(z.string()).default({});
23
+ const claudeEffortSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max']);
24
+ const codexReasoningEffortSchema = z.enum(['low', 'medium', 'high']);
25
+ const cursorModeSchema = z.enum(['ask', 'agent']);
26
+ const claudeRunnerSettingsSchema = z.object({
27
+ command: z.string().default('claude'),
28
+ model: z.string().default('haiku'),
29
+ smokeModel: z.string().default('haiku'),
30
+ sessionName: z.string().default(defaultAgentIdentity),
31
+ remoteControlName: z.string().default(defaultAgentIdentity),
32
+ smokePrompt: z.string().default(defaultSmokePrompt),
33
+ timeoutMs: z.number().int().positive().default(30 * 60 * 1000),
34
+ remoteControl: z.object({
35
+ enabled: z.boolean().default(false),
36
+ }).default({ enabled: false }),
37
+ models: modelOverridesSchema.default({ default: 'haiku', implement: 'claude-sonnet-4-6' }),
38
+ effort: claudeEffortSchema.optional(),
39
+ });
40
+ const codexRunnerSettingsSchema = z.object({
41
+ command: z.string().default('codex'),
42
+ model: z.string().default('gpt-5.5'),
43
+ smokeModel: z.string().default('gpt-5.4-mini'),
44
+ smokePrompt: z.string().default(defaultSmokePrompt),
45
+ timeoutMs: z.number().int().positive().default(30 * 60 * 1000),
46
+ models: modelOverridesSchema.default({ default: 'gpt-5.5', implement: 'gpt-5.5' }),
47
+ reasoningEffort: codexReasoningEffortSchema.optional(),
48
+ });
49
+ const fakeRunnerEntrySchema = z.object({
50
+ kind: z.literal('fake'),
51
+ cli: z.string().default('Fake'),
52
+ });
53
+ const claudeRunnerEntrySchema = claudeRunnerSettingsSchema.extend({
54
+ kind: z.literal('claude'),
55
+ });
56
+ const codexRunnerEntrySchema = codexRunnerSettingsSchema.extend({
57
+ kind: z.literal('codex'),
58
+ });
59
+ const cursorRunnerSettingsSchema = z.object({
60
+ command: z.string().default('cursor'),
61
+ model: z.string().default('composer-2.5'),
62
+ smokeModel: z.string().default('auto'),
63
+ smokePrompt: z.string().default(defaultSmokePrompt),
64
+ timeoutMs: z.number().int().positive().default(30 * 60 * 1000),
65
+ models: modelOverridesSchema.default({ default: 'composer-2.5', implement: 'composer-2.5' }),
66
+ defaultMode: cursorModeSchema.optional(),
67
+ });
68
+ const cursorRunnerEntrySchema = cursorRunnerSettingsSchema.extend({
69
+ kind: z.literal('cursor'),
70
+ });
71
+ const runnerEntrySchema = z.discriminatedUnion('kind', [
72
+ fakeRunnerEntrySchema,
73
+ claudeRunnerEntrySchema,
74
+ codexRunnerEntrySchema,
75
+ cursorRunnerEntrySchema,
76
+ ]);
77
+ const stageRouteSchema = z.object({
78
+ action: identifierSchema.optional(),
79
+ tier: z.string().optional(),
80
+ runner: z.string().optional(),
81
+ });
82
+ const runnerRoutingSchema = z.object({
83
+ runnerName: z.string(),
84
+ runnerKind: z.enum(['fake', 'claude', 'codex', 'cursor']),
85
+ tier: z.string().optional(),
86
+ reason: z.string(),
87
+ });
88
+ const sinkEntrySchema = z.object({
89
+ kind: z.string().min(1),
90
+ subscribe: z.array(z.string().min(1)).default([]),
91
+ }).passthrough();
92
+ export const wakeResultEnvelopeSchema = z.object({
93
+ status: runnerSentinelSchema,
94
+ });
95
+ const stageHistoryEntrySchema = z.object({
96
+ stage: identifierSchema,
97
+ changedAt: isoTimestampSchema,
98
+ reason: z.string(),
99
+ });
100
+ const reviewThreadAnchorSchema = z.object({
101
+ path: z.string(),
102
+ line: z.number().int().positive().optional(),
103
+ });
104
+ const commentSnapshotSchema = z.object({
105
+ id: z.string(),
106
+ body: z.string(),
107
+ author: z.object({
108
+ login: z.string(),
109
+ }),
110
+ createdAt: isoTimestampSchema,
111
+ updatedAt: isoTimestampSchema,
112
+ isBotAuthored: z.boolean().default(false),
113
+ // Which correlated surface this comment came from; absent = the originating
114
+ // issue thread, keeping every pre-existing comment valid under this schema.
115
+ resourceUri: resourceUriSchema.optional(),
116
+ reviewThread: reviewThreadAnchorSchema.optional(),
117
+ });
118
+ const issueSnapshotSchema = z.object({
119
+ repo: z.string(),
120
+ number: z.number().int().positive(),
121
+ title: z.string(),
122
+ body: z.string(),
123
+ labels: z.array(z.string()),
124
+ assignees: z.array(z.string()),
125
+ isPullRequest: z.boolean().default(false),
126
+ state: z.enum(['open', 'closed']),
127
+ url: z.string().url(),
128
+ createdAt: isoTimestampSchema,
129
+ updatedAt: isoTimestampSchema,
130
+ });
131
+ export const sourceStateRecordSchema = z.object({
132
+ schemaVersion: z.literal(1),
133
+ source: z.string(),
134
+ key: z.string(),
135
+ lastSuccessfulPollAt: isoTimestampSchema,
136
+ });
137
+ export const eventEnvelopeSourceRefsSchema = z.object({
138
+ repo: z.string().optional(),
139
+ issueNumber: z.number().int().positive().optional(),
140
+ commentId: z.string().optional(),
141
+ reviewId: z.string().optional(),
142
+ runId: z.string().optional(),
143
+ sink: z.string().optional(),
144
+ sourceUrl: z.string().optional(),
145
+ // Per-event provenance: which external resource this one event came from.
146
+ // Item-level ownership lives only in the correlation registry, never here.
147
+ resourceUri: resourceUriSchema.optional(),
148
+ // An adapter-supplied hint: the resourceUri of the resource this one
149
+ // "belongs to" for correlation purposes (e.g. a PR review-thread comment's
150
+ // parent PR). Opaque to core — never parsed, only used as a fallback key
151
+ // for resourceIndex.resolve() when resourceUri itself misses the index.
152
+ // The adapter that emits an event is the only party allowed to know its
153
+ // own locator grammar; this keeps that knowledge out of core/.
154
+ parentResourceUri: resourceUriSchema.optional(),
155
+ });
156
+ /** Event type constants for the correlation registry (ADR 0001 §5). */
157
+ export const WORK_ITEM_CREATED_EVENT = 'wake.workitem.created';
158
+ export const CORRELATION_REGISTERED_EVENT = 'wake.correlation.registered';
159
+ export const CORRELATION_RETRACTED_EVENT = 'wake.correlation.retracted';
160
+ export const CORRELATION_PRIMARY_CONFLICT_EVENT = 'wake.correlation.primary-conflict';
161
+ /**
162
+ * Shared key for events whose resource failed mint qualification (spec D1').
163
+ * Never a real work item: `readIssueState(UNRESOLVED_WORK_ITEM_KEY)` always
164
+ * returns null, so these events are durable and inspectable via the event
165
+ * log but must never materialize a projection or an index entry. Exported
166
+ * from here (rather than only living in tick-runner.ts, which mints these
167
+ * events) so projection-updater.ts's fold can also recognize and skip it —
168
+ * an unqualified event's sourceEventType is still `ticket.upsert` /
169
+ * `fake.issue.upsert` / etc., which the fold would otherwise happily turn
170
+ * into a projection the first time it sees this key with no current record.
171
+ */
172
+ export const UNRESOLVED_WORK_ITEM_KEY = 'unresolved';
173
+ // The envelope's `workItemKey` already carries the identity; this payload is
174
+ // deliberately empty.
175
+ export const workItemCreatedPayloadSchema = z.object({});
176
+ export const correlationRegisteredPayloadSchema = z.object({
177
+ resourceUri: resourceUriSchema,
178
+ role: correlationRoleSchema,
179
+ relation: correlationRelationSchema,
180
+ provenance: correlationProvenanceSchema,
181
+ registeredBy: z.string().optional(),
182
+ });
183
+ export const correlationRetractedPayloadSchema = z.object({
184
+ resourceUri: resourceUriSchema,
185
+ });
186
+ // Warning event emitted when a second `primary` registration lands on an
187
+ // already-claimed URI (ADR 0001 §6). Fold behavior lives in the projection.
188
+ export const correlationPrimaryConflictPayloadSchema = z.object({
189
+ resourceUri: resourceUriSchema,
190
+ incumbentWorkItemKey: z.string(),
191
+ });
192
+ // Folded shape of a `wake.correlation.registered`/`wake.correlation.retracted`
193
+ // event, one entry per resourceUri currently held by this work item
194
+ // (ADR 0001 §5). `registeredAt` comes from the folding event's `occurredAt`.
195
+ export const correlatedResourceSchema = z.object({
196
+ resourceUri: resourceUriSchema,
197
+ role: correlationRoleSchema,
198
+ relation: correlationRelationSchema,
199
+ provenance: correlationProvenanceSchema,
200
+ registeredBy: z.string().optional(),
201
+ registeredAt: isoTimestampSchema,
202
+ });
203
+ export const eventEnvelopeSchema = z.object({
204
+ schemaVersion: z.literal(1),
205
+ eventId: z.string(),
206
+ workItemKey: z.string(),
207
+ streamScope: z.enum(['global-intake', 'work-item']),
208
+ direction: z.enum(['inbound', 'outbound', 'internal']),
209
+ sourceSystem: z.string(),
210
+ sourceEventType: z.string(),
211
+ sourceRefs: eventEnvelopeSourceRefsSchema,
212
+ occurredAt: isoTimestampSchema,
213
+ ingestedAt: isoTimestampSchema,
214
+ trigger: z.enum(['immediate', 'context-only']),
215
+ payload: z.record(z.string(), z.unknown()),
216
+ raw: z.record(z.string(), z.unknown()).optional(),
217
+ derivedHints: z.record(z.string(), z.unknown()).optional(),
218
+ });
219
+ export const issueStateRecordSchema = z.object({
220
+ schemaVersion: z.literal(1),
221
+ workItemKey: z.string(),
222
+ origin: z.string().optional(),
223
+ issue: issueSnapshotSchema,
224
+ comments: z.array(commentSnapshotSchema).default([]),
225
+ latestComment: commentSnapshotSchema.optional(),
226
+ wake: z.object({
227
+ stage: identifierSchema,
228
+ lastRunId: z.string().optional(),
229
+ sessionId: z.string().optional(),
230
+ sessionCli: z.string().optional(),
231
+ workspacePath: z.string().optional(),
232
+ blockReason: z.string().optional(),
233
+ syncedAt: isoTimestampSchema,
234
+ stageHistory: z.array(stageHistoryEntrySchema),
235
+ recentEventIds: z.array(z.string()).default([]),
236
+ expectedEcho: z.object({
237
+ commentIds: z.array(z.string()).default([]),
238
+ labels: z.array(z.string()).default([]),
239
+ }).default({ commentIds: [], labels: [] }),
240
+ }),
241
+ context: z.record(z.string(), z.unknown()).default({}),
242
+ correlatedResources: z.array(correlatedResourceSchema).default([]),
243
+ });
244
+ const runTokenUsageSchema = z.object({
245
+ inputTokens: z.number().nonnegative(),
246
+ outputTokens: z.number().nonnegative(),
247
+ cacheCreationInputTokens: z.number().nonnegative().optional(),
248
+ cacheReadInputTokens: z.number().nonnegative().optional(),
249
+ costUsd: z.number().nonnegative().optional(),
250
+ turns: z.number().nonnegative().optional(),
251
+ });
252
+ export const runRecordSchema = z.object({
253
+ schemaVersion: z.literal(1),
254
+ runId: z.string(),
255
+ // The work item this run belongs to. Required: run records are Wake-owned
256
+ // state that Wake itself writes, so the work id is always in hand at the
257
+ // write site, and an optional key would lie about runtime while re-admitting
258
+ // the ticket-shaped ambiguity minted identity exists to remove.
259
+ workItemKey: z.string(),
260
+ // Human-readable representation content only — the ticket this run was
261
+ // launched against (same reasoning as the projection's retained `issue`
262
+ // snapshot, spec §9). Never used to find the work item: a transferred issue
263
+ // gets a new repo/number while the work item and its runs persist (spec D3).
264
+ repo: z.string(),
265
+ issueNumber: z.number().int().positive(),
266
+ action: identifierSchema,
267
+ status: z.enum(['running', 'completed', 'awaiting-approval', 'blocked', 'failed', 'superseded']),
268
+ startedAt: isoTimestampSchema,
269
+ finishedAt: isoTimestampSchema.optional(),
270
+ sessionId: z.string().optional(),
271
+ sentinel: runnerSentinelSchema.optional(),
272
+ summary: z.string().optional(),
273
+ routing: runnerRoutingSchema.optional(),
274
+ tokenUsage: runTokenUsageSchema.optional(),
275
+ metadata: z.record(z.string(), z.unknown()).optional(),
276
+ });
277
+ const runnerHealthEntrySchema = z.object({
278
+ pausedUntil: isoTimestampSchema.optional(),
279
+ // 'reported' = CLI told us the real reset time; 'estimated' = exponential
280
+ // backoff guess. Only 'estimated' pauses are eligible for an early recovery
281
+ // probe (domain/runner-routing.ts) - a reported time is already trustworthy.
282
+ pausedUntilSource: z.enum(['reported', 'estimated']).optional(),
283
+ failureCount: z.number().int().nonnegative().default(0),
284
+ lastFailureAt: isoTimestampSchema.optional(),
285
+ });
286
+ const workflowWorkspaceSchema = z.enum(['none', 'read-only', 'branch']);
287
+ const workflowStageSchema = stageRouteSchema.extend({
288
+ workspace: workflowWorkspaceSchema,
289
+ onDone: identifierSchema,
290
+ });
291
+ const workflowDefinitionSchema = z.object({
292
+ entryStage: identifierSchema.optional(),
293
+ stages: z.record(identifierSchema, workflowStageSchema),
294
+ });
295
+ function defaultPromptsRoot() {
296
+ const sourceDir = dirname(fileURLToPath(import.meta.url));
297
+ for (const candidate of [
298
+ join(process.cwd(), 'prompts'),
299
+ join(sourceDir, '..', '..', 'prompts'),
300
+ join(sourceDir, '..', '..', '..', 'prompts'),
301
+ ]) {
302
+ if (existsSync(candidate)) {
303
+ return candidate;
304
+ }
305
+ }
306
+ let dir = sourceDir;
307
+ for (let depth = 0; depth < 8; depth += 1) {
308
+ if (existsSync(join(dir, 'package.json'))) {
309
+ return join(dir, 'prompts');
310
+ }
311
+ const parent = dirname(dir);
312
+ if (parent === dir) {
313
+ break;
314
+ }
315
+ dir = parent;
316
+ }
317
+ return join(process.cwd(), 'prompts');
318
+ }
319
+ function promptExists(promptsRoot, stageOrAction) {
320
+ return (existsSync(join(promptsRoot, `${stageOrAction}.md`)) ||
321
+ existsSync(join(promptsRoot, `${stageOrAction}.start.md`)) ||
322
+ existsSync(join(promptsRoot, `${stageOrAction}.resume.md`)));
323
+ }
324
+ function canReachDone(start, stages) {
325
+ const seen = new Set();
326
+ let current = start;
327
+ while (current !== 'done') {
328
+ if (seen.has(current)) {
329
+ return false;
330
+ }
331
+ seen.add(current);
332
+ const next = stages[current]?.onDone;
333
+ if (next === undefined) {
334
+ return false;
335
+ }
336
+ current = next;
337
+ }
338
+ return true;
339
+ }
340
+ export const ledgerSchema = z.object({
341
+ schemaVersion: z.literal(1),
342
+ // Legacy single-runner pause fields, superseded by `runners` (per-runner
343
+ // health, #67). Kept read-only so ledgers written before the fallback/rotation
344
+ // change still parse; nothing writes them anymore.
345
+ pausedUntil: isoTimestampSchema.optional(),
346
+ quotaFailureCount: z.number().int().nonnegative().optional(),
347
+ lastQuotaFailureAt: isoTimestampSchema.optional(),
348
+ runners: z.record(z.string(), runnerHealthEntrySchema).default({}),
349
+ });
350
+ export const wakeConfigSchema = z.object({
351
+ schemaVersion: z.literal(1).default(1),
352
+ paths: z.object({
353
+ wakeRoot: z.string(),
354
+ promptsRoot: z.string().optional(),
355
+ }),
356
+ sandbox: z.object({
357
+ image: z.string().min(1).default('wake-sandbox'),
358
+ // Base image name (no tag) that `wake sandbox self-update` appends a
359
+ // release tag to, e.g. "wake-sandbox:v0.0.80". `image` above stays the
360
+ // resolved ref that `build`/`up`/`update` actually run.
361
+ imageRepository: z.string().min(1).default('wake-sandbox'),
362
+ containerName: z.string().min(1).default('wake-sandbox'),
363
+ containerMountPath: z.string().min(1).default('/wake'),
364
+ containerHomeMountPath: z.string().min(1).default('/home/wake'),
365
+ start: z.object({
366
+ enabled: z.boolean().default(true),
367
+ }).default({ enabled: true }),
368
+ extraMounts: z.array(z.object({
369
+ source: z.string().min(1),
370
+ target: z.string().min(1),
371
+ readOnly: z.boolean().optional(),
372
+ })).default([]),
373
+ }).default({ image: 'wake-sandbox', imageRepository: 'wake-sandbox', containerName: 'wake-sandbox', containerMountPath: '/wake', containerHomeMountPath: '/home/wake', start: { enabled: true }, extraMounts: [] }),
374
+ dev: z.object({
375
+ repoRoot: z.string().optional(),
376
+ }).default({}),
377
+ scheduler: z.object({
378
+ intervalMs: z.number().int().positive().default(60 * 1000),
379
+ // Cap for the idle-cadence backoff (#81): consecutive idle ticks double the
380
+ // sleep, up to this ceiling, so a quiet repo doesn't poll every intervalMs.
381
+ maxIntervalMs: z.number().int().positive().default(10 * 60 * 1000),
382
+ }).default({ intervalMs: 60 * 1000, maxIntervalMs: 10 * 60 * 1000 }),
383
+ transcripts: z.object({
384
+ enabled: z.boolean().default(false),
385
+ retainAfterWorkspaceCleanup: z.boolean().default(false),
386
+ }).default({ enabled: false, retainAfterWorkspaceCleanup: false }),
387
+ runners: z.record(z.string(), runnerEntrySchema).default({
388
+ fake: { kind: 'fake', cli: 'Fake' },
389
+ 'claude-haiku': { kind: 'claude', command: 'claude', model: 'haiku', smokeModel: 'haiku', sessionName: defaultAgentIdentity, remoteControlName: defaultAgentIdentity, smokePrompt: defaultSmokePrompt, timeoutMs: 30 * 60 * 1000, remoteControl: { enabled: false }, models: { default: 'haiku' } },
390
+ 'claude-opus': { kind: 'claude', command: 'claude', model: 'claude-opus-4-8', smokeModel: 'haiku', sessionName: defaultAgentIdentity, remoteControlName: defaultAgentIdentity, smokePrompt: defaultSmokePrompt, timeoutMs: 30 * 60 * 1000, remoteControl: { enabled: false }, models: { default: 'claude-opus-4-8' } },
391
+ 'codex-mini': { kind: 'codex', command: 'codex', model: 'gpt-5.4-mini', smokeModel: 'gpt-5.4-mini', smokePrompt: defaultSmokePrompt, timeoutMs: 30 * 60 * 1000, models: { default: 'gpt-5.4-mini', implement: 'gpt-5.4-mini' } },
392
+ 'codex-flagship': { kind: 'codex', command: 'codex', model: 'gpt-5.5', smokeModel: 'gpt-5.4-mini', smokePrompt: defaultSmokePrompt, timeoutMs: 30 * 60 * 1000, models: { default: 'gpt-5.5', implement: 'gpt-5.5' } },
393
+ 'cursor-composer': { kind: 'cursor', command: 'cursor', model: 'composer-2.5', smokeModel: 'auto', smokePrompt: defaultSmokePrompt, timeoutMs: 30 * 60 * 1000, models: { default: 'composer-2.5', implement: 'composer-2.5' } },
394
+ }),
395
+ tiers: z.record(z.string(), z.array(z.string().min(1)).min(1)).default({
396
+ light: ['fake'],
397
+ standard: ['fake'],
398
+ deep: ['fake'],
399
+ }),
400
+ defaultTier: z.string().default('standard'),
401
+ workflows: z.record(identifierSchema, workflowDefinitionSchema).default({
402
+ default: {
403
+ stages: {
404
+ refine: {
405
+ action: 'refine',
406
+ workspace: 'read-only',
407
+ tier: 'light',
408
+ onDone: 'implement',
409
+ },
410
+ implement: {
411
+ action: 'implement',
412
+ workspace: 'branch',
413
+ tier: 'standard',
414
+ onDone: 'done',
415
+ },
416
+ },
417
+ },
418
+ }),
419
+ stages: z.record(z.string(), stageRouteSchema).default({
420
+ queue: { action: 'refine', tier: 'light' },
421
+ implement: { action: 'implement', tier: 'standard' },
422
+ }),
423
+ ui: z.object({
424
+ enabled: z.boolean().default(false),
425
+ port: z.number().int().positive().default(4317),
426
+ token: z.string().optional(),
427
+ tunnel: z.object({
428
+ enabled: z.boolean().default(false),
429
+ authToken: z.string().optional(),
430
+ }).default({ enabled: false }),
431
+ archiveFreshnessDays: z.number().int().nonnegative().default(5),
432
+ }).default({ enabled: false, port: 4317, tunnel: { enabled: false }, archiveFreshnessDays: 5 }),
433
+ sources: z.object({
434
+ github: z.object({
435
+ enabled: z.boolean().default(false),
436
+ repos: z.array(z.string().min(1)).default([]),
437
+ polling: z.object({
438
+ maxIssuesPerRepo: z.number().int().positive().default(25),
439
+ commentPageSize: z.number().int().positive().default(25),
440
+ lookbackMs: z.number().int().nonnegative().default(60_000),
441
+ }).default({ maxIssuesPerRepo: 25, commentPageSize: 25, lookbackMs: 60_000 }),
442
+ policy: z.object({
443
+ requiredLabels: z.array(z.string()).default([]),
444
+ ignoredLabels: z.array(z.string()).default([]),
445
+ requiredAssignees: z.array(z.string()).default([]),
446
+ }).default({ requiredLabels: [], ignoredLabels: [], requiredAssignees: [] }),
447
+ publication: z.object({
448
+ postStatusComments: z.boolean().default(true),
449
+ activeLabel: z.string().optional(),
450
+ }).default({ postStatusComments: true }),
451
+ pullRequests: z.object({
452
+ enabled: z.boolean().default(false),
453
+ maxPullRequestsPerRepo: z.number().int().positive().default(25),
454
+ commentPageSize: z.number().int().positive().default(25),
455
+ policy: z.object({
456
+ requiredAuthors: z.array(z.string()).default([]),
457
+ }).default({ requiredAuthors: [] }),
458
+ }).default({ enabled: false, maxPullRequestsPerRepo: 25, commentPageSize: 25, policy: { requiredAuthors: [] } }),
459
+ }).default({ enabled: false, repos: [], polling: { maxIssuesPerRepo: 25, commentPageSize: 25, lookbackMs: 60_000 }, policy: { requiredLabels: [], ignoredLabels: [], requiredAssignees: [] }, publication: { postStatusComments: true }, pullRequests: { enabled: false, maxPullRequestsPerRepo: 25, commentPageSize: 25, policy: { requiredAuthors: [] } } }),
460
+ }).default({ github: { enabled: false, repos: [], polling: { maxIssuesPerRepo: 25, commentPageSize: 25, lookbackMs: 60_000 }, policy: { requiredLabels: [], ignoredLabels: [], requiredAssignees: [] }, publication: { postStatusComments: true }, pullRequests: { enabled: false, maxPullRequestsPerRepo: 25, commentPageSize: 25, policy: { requiredAuthors: [] } } } }),
461
+ sinks: z.record(z.string(), sinkEntrySchema).default({}),
462
+ }).superRefine((config, ctx) => {
463
+ const promptsRoot = config.paths.promptsRoot ?? defaultPromptsRoot();
464
+ const workflowEntries = Object.entries(config.workflows);
465
+ if (workflowEntries.length === 0) {
466
+ ctx.addIssue({
467
+ code: z.ZodIssueCode.custom,
468
+ path: ['workflows'],
469
+ message: 'At least one workflow must be configured.',
470
+ });
471
+ }
472
+ for (const [workflowName, workflow] of workflowEntries) {
473
+ const stageNames = Object.keys(workflow.stages);
474
+ const stageSet = new Set(stageNames);
475
+ if (stageNames.length === 0) {
476
+ ctx.addIssue({
477
+ code: z.ZodIssueCode.custom,
478
+ path: ['workflows', workflowName, 'stages'],
479
+ message: 'Workflow must define at least one runnable stage.',
480
+ });
481
+ continue;
482
+ }
483
+ for (const reserved of ['queue', 'done']) {
484
+ if (stageSet.has(reserved)) {
485
+ ctx.addIssue({
486
+ code: z.ZodIssueCode.custom,
487
+ path: ['workflows', workflowName, 'stages', reserved],
488
+ message: `Workflow must not define implicit ${reserved} stage.`,
489
+ });
490
+ }
491
+ }
492
+ const actualEntryStage = workflow.entryStage ?? stageNames[0];
493
+ if (workflow.entryStage === 'queue') {
494
+ ctx.addIssue({
495
+ code: z.ZodIssueCode.custom,
496
+ path: ['workflows', workflowName, 'entryStage'],
497
+ message: 'Workflow entryStage must not be queue.',
498
+ });
499
+ }
500
+ if (actualEntryStage !== undefined && !stageSet.has(actualEntryStage)) {
501
+ ctx.addIssue({
502
+ code: z.ZodIssueCode.custom,
503
+ path: ['workflows', workflowName, 'entryStage'],
504
+ message: `Workflow entryStage "${actualEntryStage}" is not a configured stage.`,
505
+ });
506
+ }
507
+ for (const [stageName, stage] of Object.entries(workflow.stages)) {
508
+ if (stage.onDone === 'queue') {
509
+ ctx.addIssue({
510
+ code: z.ZodIssueCode.custom,
511
+ path: ['workflows', workflowName, 'stages', stageName, 'onDone'],
512
+ message: 'Workflow transitions must not target queue.',
513
+ });
514
+ }
515
+ if (stage.onDone !== 'done' && !stageSet.has(stage.onDone)) {
516
+ ctx.addIssue({
517
+ code: z.ZodIssueCode.custom,
518
+ path: ['workflows', workflowName, 'stages', stageName, 'onDone'],
519
+ message: `Workflow transition targets unknown stage "${stage.onDone}".`,
520
+ });
521
+ }
522
+ if (stage.action === undefined && !promptExists(promptsRoot, stageName)) {
523
+ ctx.addIssue({
524
+ code: z.ZodIssueCode.custom,
525
+ path: ['workflows', workflowName, 'stages', stageName, 'action'],
526
+ message: `Workflow stage "${stageName}" omits action but no prompts/${stageName}.md template exists.`,
527
+ });
528
+ }
529
+ }
530
+ if (actualEntryStage !== undefined && stageSet.has(actualEntryStage) && !canReachDone(actualEntryStage, workflow.stages)) {
531
+ ctx.addIssue({
532
+ code: z.ZodIssueCode.custom,
533
+ path: ['workflows', workflowName],
534
+ message: 'Workflow cannot reach done from its entry stage.',
535
+ });
536
+ }
537
+ }
538
+ });
539
+ export const claudePrintResultSchema = z.object({
540
+ type: z.string().optional(),
541
+ subtype: z.string().optional(),
542
+ result: z.string(),
543
+ session_id: z.string().optional(),
544
+ total_cost_usd: z.number().optional(),
545
+ num_turns: z.number().optional(),
546
+ usage: z.record(z.string(), z.unknown()).optional(),
547
+ }).passthrough();
548
+ export function parseIssueStateRecord(input) {
549
+ return issueStateRecordSchema.parse(input);
550
+ }
551
+ export function parseRunRecord(input) {
552
+ return runRecordSchema.parse(input);
553
+ }
554
+ export function parseEventEnvelope(input) {
555
+ return eventEnvelopeSchema.parse(input);
556
+ }
557
+ export function parseLedger(input) {
558
+ return ledgerSchema.parse(input);
559
+ }
560
+ export function parseWakeConfig(input) {
561
+ return structuredClone(wakeConfigSchema.parse(input));
562
+ }
563
+ export function parseSourceStateRecord(input) {
564
+ return sourceStateRecordSchema.parse(input);
565
+ }
566
+ export function parseClaudePrintResult(input) {
567
+ return claudePrintResultSchema.parse(input);
568
+ }
569
+ function synthesizeBodyFromEnvelope(envelope) {
570
+ const labels = {
571
+ DONE: 'Run completed.',
572
+ BLOCKED: 'Run blocked — needs input.',
573
+ AWAITING_APPROVAL: 'Ready for approval.',
574
+ FAILED: 'Run failed.',
575
+ };
576
+ return labels[envelope.status] ?? 'Run finished.';
577
+ }
578
+ export function parseRunnerResult(result) {
579
+ const wakeResultFencePattern = /^```(?:wake-result[^\n]*\n|[ \t]*\n[ \t]*wake-result[ \t]*\n)([\s\S]*?)^```[ \t]*$/gm;
580
+ let lastMatch = null;
581
+ let match;
582
+ while ((match = wakeResultFencePattern.exec(result)) !== null) {
583
+ lastMatch = match;
584
+ }
585
+ if (lastMatch !== null) {
586
+ try {
587
+ const rawContent = lastMatch[1] ?? 'null';
588
+ // Claude sometimes places the sentinel keyword inside the fence rather than after
589
+ // the closing fence. Strip a trailing sentinel line so JSON.parse sees only the JSON.
590
+ // The capture group includes the newline before the closing fence, so allow \n? after.
591
+ const jsonContent = rawContent.replace(/\n(?:DONE|BLOCKED|FAILED|AWAITING_APPROVAL)[ \t]*\n?$/, '') || rawContent;
592
+ const parsed = wakeResultEnvelopeSchema.safeParse(JSON.parse(jsonContent));
593
+ if (parsed.success) {
594
+ const proseBody = result.slice(0, lastMatch.index).trim();
595
+ return {
596
+ status: parsed.data.status,
597
+ body: proseBody || synthesizeBodyFromEnvelope(parsed.data),
598
+ envelope: 'structured',
599
+ result: parsed.data,
600
+ };
601
+ }
602
+ }
603
+ catch {
604
+ // Invalid structured trailers intentionally degrade to last-line parsing.
605
+ }
606
+ }
607
+ const offFenceEnvelopePattern = /^```wake-result[^\n]*\n```[ \t]*\n(\{[^\n]+\})[ \t]*$/gm;
608
+ let offFenceMatch = null;
609
+ while ((match = offFenceEnvelopePattern.exec(result)) !== null) {
610
+ offFenceMatch = match;
611
+ }
612
+ if (offFenceMatch !== null) {
613
+ try {
614
+ const parsed = wakeResultEnvelopeSchema.safeParse(JSON.parse(offFenceMatch[1] ?? 'null'));
615
+ if (parsed.success) {
616
+ return {
617
+ status: parsed.data.status,
618
+ body: result.slice(0, offFenceMatch.index).trim() || synthesizeBodyFromEnvelope(parsed.data),
619
+ envelope: 'structured',
620
+ result: parsed.data,
621
+ };
622
+ }
623
+ }
624
+ catch {
625
+ // Invalid off-fence trailers intentionally degrade to sentinel parsing.
626
+ }
627
+ }
628
+ const lines = result.split('\n');
629
+ const lastLine = lines
630
+ .map((line) => line.trim())
631
+ // Skip closing code fence lines — they appear as the last non-empty line when the
632
+ // sentinel is embedded inside the fenced block or when a plain ``` fence is used.
633
+ .filter((line) => line.length > 0 && line !== '```')
634
+ .at(-1);
635
+ const normalizedLastLine = lastLine?.replace(/^(?:\*\*|__)(.+)(?:\*\*|__)$/, '$1');
636
+ const parsed = runnerSentinelSchema.safeParse(normalizedLastLine);
637
+ if (!parsed.success) {
638
+ const body = result.trim();
639
+ return {
640
+ status: body.length === 0 ? 'FAILED' : 'BLOCKED',
641
+ body,
642
+ envelope: 'degraded',
643
+ };
644
+ }
645
+ let removed = false;
646
+ const body = lines
647
+ .slice()
648
+ .reverse()
649
+ .filter((line) => {
650
+ if (!removed && line.trim() === lastLine) {
651
+ removed = true;
652
+ return false;
653
+ }
654
+ return true;
655
+ })
656
+ .reverse()
657
+ .join('\n')
658
+ .trim();
659
+ return {
660
+ status: parsed.data,
661
+ body,
662
+ envelope: 'degraded',
663
+ };
664
+ }
665
+ export function parseRunnerArtifacts(result) {
666
+ const fencePattern = /^```wake-artifacts[^\n]*\n([\s\S]*?)^```[ \t]*$/gm;
667
+ let lastMatch = null;
668
+ let match;
669
+ while ((match = fencePattern.exec(result)) !== null) {
670
+ lastMatch = match;
671
+ }
672
+ if (lastMatch === null) {
673
+ return { artifacts: [] };
674
+ }
675
+ try {
676
+ const parsed = wakeArtifactsEnvelopeSchema.safeParse(JSON.parse(lastMatch[1] ?? '{}'));
677
+ return parsed.success ? parsed.data : { artifacts: [] };
678
+ }
679
+ catch {
680
+ return { artifacts: [] };
681
+ }
682
+ }
683
+ export function parseRunnerResultSentinel(result) {
684
+ return parseRunnerResult(result).status;
685
+ }