@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,127 @@
1
+ export function createControlPlane(deps) {
2
+ let running = true;
3
+ let lastStatus;
4
+ let lastIntakeStatus;
5
+ let lastRunnerStatus;
6
+ let consecutiveIdleTicks = 0;
7
+ let consecutiveIntakeIdleTicks = 0;
8
+ let consecutiveRunnerIdleTicks = 0;
9
+ const maxIntervalMs = deps.maxIntervalMs ?? deps.intervalMs * 16;
10
+ function nextSleepMs(consecutiveTicks) {
11
+ const backoffMs = deps.intervalMs * 2 ** Math.min(consecutiveTicks, 20);
12
+ return Math.min(backoffMs, maxIntervalMs);
13
+ }
14
+ async function isPaused() {
15
+ return Boolean(await deps.isPaused());
16
+ }
17
+ function logStatus(message, status, last) {
18
+ if (status !== last) {
19
+ deps.logger.info(message);
20
+ }
21
+ return status;
22
+ }
23
+ async function runLoopOnce(input) {
24
+ if (await isPaused()) {
25
+ const status = 'paused';
26
+ if (input.label === 'intake') {
27
+ lastIntakeStatus = logStatus('[wake] intake status=paused', status, lastIntakeStatus);
28
+ }
29
+ else {
30
+ lastRunnerStatus = logStatus('[wake] runner status=paused', status, lastRunnerStatus);
31
+ }
32
+ return { status: 'paused' };
33
+ }
34
+ const result = await input.run();
35
+ const status = result?.status ?? 'unknown';
36
+ if (input.label === 'intake') {
37
+ lastIntakeStatus = logStatus(`[wake] intake status=${status}`, status, lastIntakeStatus);
38
+ }
39
+ else {
40
+ lastRunnerStatus = logStatus(`[wake] runner status=${status}`, status, lastRunnerStatus);
41
+ }
42
+ return result;
43
+ }
44
+ async function startLoop(input) {
45
+ while (running) {
46
+ let result;
47
+ try {
48
+ result = await runLoopOnce({ run: input.run, label: input.label });
49
+ }
50
+ catch (error) {
51
+ deps.logger.error(error instanceof Error ? error.message : String(error));
52
+ }
53
+ if (!running) {
54
+ break;
55
+ }
56
+ const status = result?.status;
57
+ if (status === 'processed') {
58
+ input.setIdleTicks(0);
59
+ }
60
+ else {
61
+ await deps.sleep(nextSleepMs(input.getIdleTicks()));
62
+ input.setIdleTicks(input.getIdleTicks() + 1);
63
+ }
64
+ }
65
+ }
66
+ return {
67
+ stop() {
68
+ running = false;
69
+ },
70
+ async runOnce() {
71
+ if (await isPaused()) {
72
+ if (lastStatus !== 'paused') {
73
+ deps.logger.info('[wake] status=paused');
74
+ lastStatus = 'paused';
75
+ }
76
+ return { status: 'paused' };
77
+ }
78
+ const result = await deps.tickRunner.runTick();
79
+ const status = result?.status ?? 'unknown';
80
+ if (status !== lastStatus) {
81
+ deps.logger.info(`[wake] status=${status}`);
82
+ lastStatus = status;
83
+ }
84
+ return result;
85
+ },
86
+ async start() {
87
+ if (deps.tickRunner.runIntakeTick !== undefined &&
88
+ deps.tickRunner.runRunnerTick !== undefined) {
89
+ await Promise.all([
90
+ startLoop({
91
+ run: deps.tickRunner.runIntakeTick,
92
+ label: 'intake',
93
+ getIdleTicks: () => consecutiveIntakeIdleTicks,
94
+ setIdleTicks: (value) => { consecutiveIntakeIdleTicks = value; },
95
+ }),
96
+ startLoop({
97
+ run: deps.tickRunner.runRunnerTick,
98
+ label: 'runner',
99
+ getIdleTicks: () => consecutiveRunnerIdleTicks,
100
+ setIdleTicks: (value) => { consecutiveRunnerIdleTicks = value; },
101
+ }),
102
+ ]);
103
+ return;
104
+ }
105
+ while (running) {
106
+ let result;
107
+ try {
108
+ result = await this.runOnce();
109
+ }
110
+ catch (error) {
111
+ deps.logger.error(error instanceof Error ? error.message : String(error));
112
+ }
113
+ if (!running) {
114
+ break;
115
+ }
116
+ const status = result?.status;
117
+ if (status === 'processed') {
118
+ consecutiveIdleTicks = 0;
119
+ }
120
+ else {
121
+ await deps.sleep(nextSleepMs(consecutiveIdleTicks));
122
+ consecutiveIdleTicks += 1;
123
+ }
124
+ }
125
+ },
126
+ };
127
+ }
@@ -0,0 +1,8 @@
1
+ import { builtInDefaultWorkflowDefinition, nextStage } from '../domain/workflows.js';
2
+ export function createLifecycleService() {
3
+ return {
4
+ nextStageFromSentinel(stage, sentinel, workflow = builtInDefaultWorkflowDefinition) {
5
+ return nextStage(stage, sentinel, workflow);
6
+ },
7
+ };
8
+ }
@@ -0,0 +1,200 @@
1
+ import { awaitingApprovalRunnerSentinel, failedRunnerSentinel, } from '../domain/stages.js';
2
+ import { builtInDefaultWorkflowDefinition, chooseAction as chooseWorkflowAction } from '../domain/workflows.js';
3
+ function isAwaitingApproval(issue) {
4
+ const context = issue.context;
5
+ return context.lastRunSentinel === awaitingApprovalRunnerSentinel;
6
+ }
7
+ // Commands are matched as a token at the start of a (trimmed) line, not as a
8
+ // substring anywhere in the body — so "I have *not* /approved this yet" or a
9
+ // quoted reply containing /approved does not approve the gate.
10
+ const approvedCommandPattern = /^\/approved\b/i;
11
+ const changesCommandPattern = /^\/changes\b/i;
12
+ const questionCommandPattern = /^\/question\b/i;
13
+ function matchesCommand(body, pattern) {
14
+ return body
15
+ .split(/\r?\n/)
16
+ .some((line) => pattern.test(line.trim()));
17
+ }
18
+ function labelsAndAssigneesQualify(input) {
19
+ if (input.requiredLabels.length === 0 && input.requiredAssignees.length === 0) {
20
+ return false;
21
+ }
22
+ const labels = new Set(input.labels);
23
+ const assignees = new Set(input.assignees);
24
+ if (input.requiredLabels.some((label) => !labels.has(label))) {
25
+ return false;
26
+ }
27
+ if (input.ignoredLabels.some((label) => labels.has(label))) {
28
+ return false;
29
+ }
30
+ if (input.requiredAssignees.length > 0 && !input.requiredAssignees.some((login) => assignees.has(login))) {
31
+ return false;
32
+ }
33
+ return true;
34
+ }
35
+ function latestUnhandledHumanComment(issue) {
36
+ const context = issue.context;
37
+ const handledCommentId = typeof context.lastHandledCommentId === 'string'
38
+ ? context.lastHandledCommentId
39
+ : undefined;
40
+ // Only consider human comments that appear after the last bot comment.
41
+ // A human /approved posted before Wake's approval-request comment must not
42
+ // be re-consumed as approval for a later awaiting-approval cycle.
43
+ const lastBotIndex = issue.comments.reduce((acc, c, i) => (c.isBotAuthored ? i : acc), -1);
44
+ const humanCommentsAfterBot = issue.comments
45
+ .slice(lastBotIndex + 1)
46
+ .filter((c) => !c.isBotAuthored);
47
+ const latestHumanComment = humanCommentsAfterBot.at(-1);
48
+ if (latestHumanComment === undefined || latestHumanComment.id === handledCommentId) {
49
+ return undefined;
50
+ }
51
+ return latestHumanComment;
52
+ }
53
+ export function createPolicyEngine() {
54
+ return {
55
+ isEligible(issue, config) {
56
+ if (issue.issue.state !== 'open') {
57
+ return false;
58
+ }
59
+ // Defense-in-depth: the issues source filters PR-shaped items at poll
60
+ // time, so no NEW projection can ever have isPullRequest: true. But a
61
+ // pre-existing state/<workId>.json written by a pre-this-branch
62
+ // version of Wake could still hold isPullRequest: true, since the old
63
+ // fold created projections regardless of eligibility.
64
+ if (issue.issue.isPullRequest) {
65
+ return false;
66
+ }
67
+ return labelsAndAssigneesQualify({
68
+ labels: issue.issue.labels,
69
+ assignees: issue.issue.assignees,
70
+ requiredLabels: config.sources.github.policy.requiredLabels,
71
+ ignoredLabels: config.sources.github.policy.ignoredLabels,
72
+ requiredAssignees: config.sources.github.policy.requiredAssignees,
73
+ });
74
+ },
75
+ needsWakeAction(issue, workflow = builtInDefaultWorkflowDefinition) {
76
+ const context = issue.context;
77
+ const handledCommentId = typeof context.lastHandledCommentId === 'string'
78
+ ? context.lastHandledCommentId
79
+ : undefined;
80
+ const lastCompletedAction = typeof context.lastCompletedAction === 'string'
81
+ ? context.lastCompletedAction
82
+ : undefined;
83
+ const lastRunSentinel = typeof context.lastRunSentinel === 'string'
84
+ ? context.lastRunSentinel
85
+ : undefined;
86
+ const lastFailureClass = typeof context.lastFailureClass === 'string'
87
+ ? context.lastFailureClass
88
+ : undefined;
89
+ if (issue.wake.lastRunId === undefined) {
90
+ return true;
91
+ }
92
+ if (issue.latestComment !== undefined &&
93
+ !issue.latestComment.isBotAuthored &&
94
+ issue.latestComment.id !== handledCommentId) {
95
+ return true;
96
+ }
97
+ if (isAwaitingApproval(issue)) {
98
+ return false;
99
+ }
100
+ if (lastRunSentinel === failedRunnerSentinel && lastFailureClass !== 'quota') {
101
+ return false;
102
+ }
103
+ if (lastRunSentinel === 'BLOCKED') {
104
+ return false;
105
+ }
106
+ if (lastFailureClass === 'quota') {
107
+ return true;
108
+ }
109
+ const workflowAction = chooseWorkflowAction(issue, workflow);
110
+ return workflowAction !== null && lastCompletedAction !== workflowAction.action;
111
+ },
112
+ chooseAction(issue, workflow = builtInDefaultWorkflowDefinition) {
113
+ return chooseWorkflowAction(issue, workflow)?.action ?? null;
114
+ },
115
+ chooseRetryActionAfterHumanReply(issue) {
116
+ const context = issue.context;
117
+ const failed = context.lastRunSentinel === failedRunnerSentinel;
118
+ const blocked = context.lastRunSentinel === 'BLOCKED';
119
+ if (failed && context.lastFailureClass === 'quota') {
120
+ return typeof context.lastRunAction === 'string' ? context.lastRunAction : null;
121
+ }
122
+ if (!blocked && !failed) {
123
+ return null;
124
+ }
125
+ if (latestUnhandledHumanComment(issue) === undefined) {
126
+ return null;
127
+ }
128
+ return typeof context.lastRunAction === 'string' ? context.lastRunAction : null;
129
+ },
130
+ resolveApprovalTransition(issue) {
131
+ if (!isAwaitingApproval(issue)) {
132
+ return null;
133
+ }
134
+ const context = issue.context;
135
+ const pendingAction = typeof context.pendingApprovalAction === 'string'
136
+ ? context.pendingApprovalAction
137
+ : undefined;
138
+ // No new human comment since the last handled one; stay idle instead of
139
+ // falling through to the LLM while awaiting explicit approval feedback.
140
+ const latestHumanComment = latestUnhandledHumanComment(issue);
141
+ if (latestHumanComment === undefined) {
142
+ return null;
143
+ }
144
+ if (pendingAction === undefined) {
145
+ return null;
146
+ }
147
+ const approved = matchesCommand(latestHumanComment.body, approvedCommandPattern);
148
+ const changesRequested = matchesCommand(latestHumanComment.body, changesCommandPattern);
149
+ const questionAsked = matchesCommand(latestHumanComment.body, questionCommandPattern);
150
+ // Neither an explicit /approved, /changes, nor /question: treat this as
151
+ // conversation, not a decision. Stay idle rather than re-running the
152
+ // pending action off the back of an unmarked clarifying question (S2).
153
+ // The comment stays unhandled, so it's reconsidered on the next tick and
154
+ // by a human who follows up with an explicit command.
155
+ if (!approved && !changesRequested && !questionAsked) {
156
+ return null;
157
+ }
158
+ return { approved, pendingAction };
159
+ },
160
+ qualifiesForMint(unresolved, config) {
161
+ const resourceUri = unresolved.sourceRefs.resourceUri;
162
+ if (resourceUri === undefined) {
163
+ return false;
164
+ }
165
+ const kind = resourceUri.split(':')[1];
166
+ if (kind === 'issue') {
167
+ // Real github source stamps payload.ticket (sourceEventType
168
+ // 'ticket.upsert'); the fake ticketing harness stamps payload.issue
169
+ // (sourceEventType 'fake.issue.upsert') — the same dual-key
170
+ // recognition projection-updater.ts's createProjectionFromIssueEvent
171
+ // already applies when folding these into a projection. Qualification
172
+ // must accept both or the fake never qualifies anything, which would
173
+ // silently defeat every fixture that exercises minting through it.
174
+ const ticket = (unresolved.payload.ticket ?? unresolved.payload.issue);
175
+ if (ticket === undefined) {
176
+ return false;
177
+ }
178
+ return labelsAndAssigneesQualify({
179
+ labels: Array.isArray(ticket.labels) ? ticket.labels : [],
180
+ assignees: Array.isArray(ticket.assignees) ? ticket.assignees : [],
181
+ requiredLabels: config.sources.github.policy.requiredLabels,
182
+ ignoredLabels: config.sources.github.policy.ignoredLabels,
183
+ requiredAssignees: config.sources.github.policy.requiredAssignees,
184
+ });
185
+ }
186
+ if (kind === 'pr') {
187
+ if (!config.sources.github.pullRequests.enabled) {
188
+ return false;
189
+ }
190
+ const pr = unresolved.payload.pr;
191
+ const requiredAuthors = config.sources.github.pullRequests.policy.requiredAuthors;
192
+ if (requiredAuthors.length === 0 || typeof pr?.author !== 'string') {
193
+ return false;
194
+ }
195
+ return requiredAuthors.includes(pr.author);
196
+ }
197
+ return false;
198
+ },
199
+ };
200
+ }