@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,16 @@
1
+ /**
2
+ * Names the configured ticket source. This is one decision with several
3
+ * consumers that MUST agree: main.ts names the source it wires into the fan-in
4
+ * and the sink router, while the UI builds `<provider>:issue:<repo>#<n>` URIs
5
+ * to resolve tickets against the index those same events registered. If the
6
+ * two ever disagreed, the UI would resolve against a provider nothing had
7
+ * registered, miss, and report live work items as missing.
8
+ *
9
+ * It lives here, and not inline at each call site, because adding a third
10
+ * source means changing this one function rather than finding every ternary —
11
+ * source-pluggability is the point of the adapter seams, so the choice must
12
+ * not be duplicated across them.
13
+ */
14
+ export function configuredTicketSource(config) {
15
+ return config.sources.github.enabled ? 'github' : 'fake-ticketing';
16
+ }
@@ -0,0 +1,39 @@
1
+ export const stageValues = [
2
+ 'queue',
3
+ 'done',
4
+ ];
5
+ export const doneRunnerSentinel = 'DONE';
6
+ export const blockedRunnerSentinel = 'BLOCKED';
7
+ export const failedRunnerSentinel = 'FAILED';
8
+ export const awaitingApprovalRunnerSentinel = 'AWAITING_APPROVAL';
9
+ export const runnerSentinelValues = [
10
+ doneRunnerSentinel,
11
+ blockedRunnerSentinel,
12
+ failedRunnerSentinel,
13
+ awaitingApprovalRunnerSentinel,
14
+ ];
15
+ export const terminalStageValues = ['done'];
16
+ export function isTerminalStage(stage) {
17
+ return terminalStageValues.includes(stage);
18
+ }
19
+ export const wakeStageLabelPrefix = 'wake:stage.';
20
+ export function stageLabelForStage(stage) {
21
+ return `${wakeStageLabelPrefix}${stage}`;
22
+ }
23
+ export function stageFromStageLabel(label, configuredStages = stageValues) {
24
+ if (!label.startsWith(wakeStageLabelPrefix)) {
25
+ return undefined;
26
+ }
27
+ const stage = label.slice(wakeStageLabelPrefix.length);
28
+ return new Set(configuredStages).has(stage) ? stage : undefined;
29
+ }
30
+ export function stageFromLabels(labels, configuredStages = stageValues) {
31
+ const stages = new Set();
32
+ for (const label of labels) {
33
+ const stage = stageFromStageLabel(label, configuredStages);
34
+ if (stage !== undefined) {
35
+ stages.add(stage);
36
+ }
37
+ }
38
+ return stages.size === 1 ? [...stages][0] : undefined;
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ import { stageLabelForStage } from './stages.js';
2
+ export const universalQueueStage = 'queue';
3
+ export const universalDoneStage = 'done';
4
+ export const workflowChangedBlockReason = 'workflow-changed';
5
+ export const builtInDefaultWorkflowDefinition = {
6
+ stages: {
7
+ refine: {
8
+ action: 'refine',
9
+ workspace: 'read-only',
10
+ tier: 'light',
11
+ onDone: 'implement',
12
+ },
13
+ implement: {
14
+ action: 'implement',
15
+ workspace: 'branch',
16
+ tier: 'standard',
17
+ onDone: 'done',
18
+ },
19
+ },
20
+ };
21
+ export function configuredWorkflowNames(config) {
22
+ return Object.keys(config.workflows);
23
+ }
24
+ export function defaultWorkflowName(config) {
25
+ const [name] = configuredWorkflowNames(config);
26
+ if (name === undefined) {
27
+ throw new Error('Wake config must define at least one workflow.');
28
+ }
29
+ return name;
30
+ }
31
+ export function workflowForProjection(projection, config) {
32
+ const workflowName = workflowNameForProjection(projection, config);
33
+ return config.workflows[workflowName] ?? null;
34
+ }
35
+ export function workflowNameForProjection(projection, config) {
36
+ const context = projection.context;
37
+ return (typeof context.workflow === 'string'
38
+ ? context.workflow
39
+ : defaultWorkflowName(config));
40
+ }
41
+ export function configuredStageNames(workflow) {
42
+ return Object.keys(workflow.stages);
43
+ }
44
+ export function workflowStageVocabulary(workflow) {
45
+ return [universalQueueStage, ...configuredStageNames(workflow), universalDoneStage];
46
+ }
47
+ export function stageLabelsForWorkflow(workflow) {
48
+ return workflowStageVocabulary(workflow).map((stage) => stageLabelForStage(stage));
49
+ }
50
+ export function isKnownWorkflowStage(stage, workflow) {
51
+ return workflowStageVocabulary(workflow).includes(stage);
52
+ }
53
+ export function entryStage(workflow) {
54
+ return workflow.entryStage ?? configuredStageNames(workflow)[0];
55
+ }
56
+ export function stageAfterQueue(workflow) {
57
+ return entryStage(workflow);
58
+ }
59
+ export function chooseAction(projection, workflow) {
60
+ const stage = projection.wake.stage === universalQueueStage
61
+ ? stageAfterQueue(workflow)
62
+ : projection.wake.stage;
63
+ const definition = workflow.stages[stage];
64
+ if (definition === undefined || stage === universalDoneStage) {
65
+ return null;
66
+ }
67
+ return {
68
+ action: definition.action ?? stage,
69
+ workspace: definition.workspace,
70
+ routing: {
71
+ ...(definition.tier === undefined ? {} : { tier: definition.tier }),
72
+ ...(definition.runner === undefined ? {} : { runner: definition.runner }),
73
+ },
74
+ stage,
75
+ };
76
+ }
77
+ export function nextStage(stage, sentinel, workflow) {
78
+ if (sentinel === 'BLOCKED' || sentinel === 'FAILED' || sentinel === 'AWAITING_APPROVAL') {
79
+ return null;
80
+ }
81
+ const runnableStage = stage === universalQueueStage ? stageAfterQueue(workflow) : stage;
82
+ return workflow.stages[runnableStage]?.onDone ?? null;
83
+ }
@@ -0,0 +1,5 @@
1
+ export const systemClock = {
2
+ now() {
3
+ return new Date();
4
+ },
5
+ };
@@ -0,0 +1,21 @@
1
+ export function createEventEnvelope(input) {
2
+ return {
3
+ schemaVersion: 1,
4
+ ...input,
5
+ };
6
+ }
7
+ /**
8
+ * Builds a source event with no workItemKey. Sources do not self-key; the
9
+ * resolver in tick-runner stamps the canonical key between poll and append
10
+ * (spec D1).
11
+ *
12
+ * The return type is spelled structurally rather than importing
13
+ * core/contracts' `UnkeyedEventEnvelope` — it is the identical type, and lib/
14
+ * must not depend on core/.
15
+ */
16
+ export function createUnkeyedEventEnvelope(input) {
17
+ return {
18
+ schemaVersion: 1,
19
+ ...input,
20
+ };
21
+ }
@@ -0,0 +1,23 @@
1
+ import { appendFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname } from 'node:path';
4
+ export async function writeJsonFile(path, value) {
5
+ await mkdir(dirname(path), { recursive: true });
6
+ const tempPath = `${path}.${randomUUID()}.tmp`;
7
+ try {
8
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
9
+ await rename(tempPath, path);
10
+ }
11
+ catch (error) {
12
+ await rm(tempPath, { force: true }).catch(() => { });
13
+ throw error;
14
+ }
15
+ }
16
+ export async function readJsonFile(path) {
17
+ const raw = await readFile(path, 'utf8');
18
+ return JSON.parse(raw);
19
+ }
20
+ export async function appendJsonLine(path, value) {
21
+ await mkdir(dirname(path), { recursive: true });
22
+ await appendFile(path, `${JSON.stringify(value)}\n`, 'utf8');
23
+ }
@@ -0,0 +1,118 @@
1
+ import { link, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ function parseLockMetadata(raw) {
5
+ try {
6
+ const parsed = JSON.parse(raw);
7
+ if (parsed === null || typeof parsed !== 'object') {
8
+ return null;
9
+ }
10
+ const record = parsed;
11
+ if (typeof record.pid !== 'number' ||
12
+ !Number.isInteger(record.pid) ||
13
+ typeof record.acquiredAt !== 'string') {
14
+ return null;
15
+ }
16
+ const acquiredAtMs = Date.parse(record.acquiredAt);
17
+ if (!Number.isFinite(acquiredAtMs)) {
18
+ return null;
19
+ }
20
+ return {
21
+ pid: record.pid,
22
+ acquiredAt: record.acquiredAt,
23
+ ...(typeof record.lockId === 'string' ? { lockId: record.lockId } : {}),
24
+ };
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function isPidAlive(pid) {
31
+ if (pid <= 0) {
32
+ return false;
33
+ }
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ }
38
+ catch (error) {
39
+ const code = error.code;
40
+ if (code === 'ESRCH') {
41
+ return false;
42
+ }
43
+ return true;
44
+ }
45
+ }
46
+ async function readLockMetadata(path) {
47
+ try {
48
+ return parseLockMetadata(await readFile(path, 'utf8'));
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ async function lockIsStale(path, staleAfterMs, now) {
55
+ const metadata = await readLockMetadata(path);
56
+ if (metadata === null) {
57
+ return true;
58
+ }
59
+ const ageMs = now.getTime() - Date.parse(metadata.acquiredAt);
60
+ return ageMs >= staleAfterMs || !isPidAlive(metadata.pid);
61
+ }
62
+ export async function acquireFileLock(path, options) {
63
+ await mkdir(dirname(path), { recursive: true });
64
+ const metadata = {
65
+ pid: process.pid,
66
+ acquiredAt: (options?.now ?? new Date()).toISOString(),
67
+ lockId: randomUUID(),
68
+ };
69
+ async function tryAcquire() {
70
+ const tempPath = join(dirname(path), `.tick-lock-${metadata.lockId}.tmp`);
71
+ await writeFile(tempPath, `${JSON.stringify(metadata)}\n`, {
72
+ encoding: 'utf8',
73
+ flag: 'wx',
74
+ });
75
+ try {
76
+ await link(tempPath, path);
77
+ }
78
+ finally {
79
+ await rm(tempPath, { force: true });
80
+ }
81
+ return {
82
+ acquired: true,
83
+ metadata,
84
+ async release() {
85
+ const current = await readLockMetadata(path);
86
+ if (current?.pid === metadata.pid &&
87
+ current.acquiredAt === metadata.acquiredAt &&
88
+ current.lockId === metadata.lockId) {
89
+ await rm(path, { force: true });
90
+ }
91
+ },
92
+ };
93
+ }
94
+ try {
95
+ return await tryAcquire();
96
+ }
97
+ catch (error) {
98
+ if (error.code === 'EEXIST') {
99
+ if (options?.staleAfterMs !== undefined &&
100
+ await lockIsStale(path, options.staleAfterMs, options.now ?? new Date())) {
101
+ await rm(path, { force: true });
102
+ try {
103
+ return await tryAcquire();
104
+ }
105
+ catch (retryError) {
106
+ if (retryError.code !== 'EEXIST') {
107
+ throw retryError;
108
+ }
109
+ }
110
+ }
111
+ return {
112
+ acquired: false,
113
+ async release() { },
114
+ };
115
+ }
116
+ throw error;
117
+ }
118
+ }
@@ -0,0 +1,44 @@
1
+ import { join } from 'node:path';
2
+ export function sanitizeRepo(repo) {
3
+ return repo.replace(/[\\/]/g, '__');
4
+ }
5
+ export function sanitizePathKey(value) {
6
+ return value.replace(/[^A-Za-z0-9._-]/g, '__');
7
+ }
8
+ export function createWakePaths(wakeRoot) {
9
+ return {
10
+ wakeRoot,
11
+ containerHomeRoot: join(wakeRoot, 'container-home'),
12
+ configFile: join(wakeRoot, 'config.json'),
13
+ ledgerFile: join(wakeRoot, 'ledger.json'),
14
+ pauseFile: join(wakeRoot, 'PAUSE'),
15
+ tickLockFile: join(wakeRoot, 'locks', 'tick.lock'),
16
+ runnerLockFile: join(wakeRoot, 'locks', 'runner.lock'),
17
+ issueFixtureFile: join(wakeRoot, 'fixtures', 'issues.json'),
18
+ workspaceRoot: join(wakeRoot, 'workspaces'),
19
+ transcriptsRoot: join(wakeRoot, 'transcripts'),
20
+ reposRoot: join(wakeRoot, 'repos'),
21
+ repoRoot: (repo) => join(wakeRoot, 'repos', sanitizeRepo(repo)),
22
+ sourceStateRoot: join(wakeRoot, 'sources'),
23
+ // Keyed on the minted work id, which is filename-safe by construction
24
+ // (src/lib/work-id.ts) — hence no sanitizePathKey here. No durable path
25
+ // embeds a provider, repo, or issue segment (spec §3).
26
+ workItemStateFile: (workId) => join(wakeRoot, 'state', `${workId}.json`),
27
+ archivedWorkItemStateFile: (workId) => join(wakeRoot, 'state', 'archive', `${workId}.json`),
28
+ sourceStateFile: (source, key) => join(wakeRoot, 'sources', sanitizePathKey(source), `${sanitizePathKey(key)}.json`),
29
+ runFile: (runId) => join(wakeRoot, 'runs', `${runId}.json`),
30
+ runDateFile: (date, runId) => join(wakeRoot, 'runs', 'by-date', date, `${runId}.json`),
31
+ eventFile: (date) => join(wakeRoot, 'events', `${date}.jsonl`),
32
+ eventEnvelopeFile: (eventId) => join(wakeRoot, 'events-by-id', `${sanitizePathKey(eventId)}.json`),
33
+ logFile: (date) => join(wakeRoot, 'logs', `${date}.log`),
34
+ // Workspaces and transcripts are ephemeral scratch rather than durable
35
+ // state, but they re-key to the work id anyway: they are 1:1 with a work
36
+ // item, not a ticket, and a ticket-shaped path here would preserve the
37
+ // second ticket-shaped identity this change exists to remove (spec §3).
38
+ workspaceDir: (workId) => join(wakeRoot, 'workspaces', workId),
39
+ transcriptWorkDir: (workId) => join(wakeRoot, 'transcripts', workId),
40
+ transcriptSessionDir: (workId, sessionKey) => join(wakeRoot, 'transcripts', workId, sanitizePathKey(sessionKey)),
41
+ resourceIndexRoot: join(wakeRoot, 'state', 'index'),
42
+ resourceIndexShardFile: (shard) => join(wakeRoot, 'state', 'index', `${shard}.json`),
43
+ };
44
+ }
@@ -0,0 +1,19 @@
1
+ import { ulid } from 'ulid';
2
+ /** Crockford base32 alphabet, as used by ULID: no I, L, O, or U. */
3
+ const WORK_ID_PATTERN = /^work-[0-9A-HJKMNP-TV-Z]{26}$/;
4
+ /**
5
+ * Mints a provider-independent work item identifier.
6
+ *
7
+ * Work ids name the work, never any surface that represents it: a ticket key
8
+ * is not a stable name for work (issue transfer renumbers it, and work can
9
+ * split or merge). See docs/superpowers/specs/2026-07-16-work-identity-correlation-design.md (D3).
10
+ *
11
+ * ULIDs sort chronologically as strings, so state/ listings are naturally
12
+ * ordered by mint time, and are filename-safe with no escaping.
13
+ */
14
+ export function createWorkId() {
15
+ return `work-${ulid()}`;
16
+ }
17
+ export function isWorkId(value) {
18
+ return WORK_ID_PATTERN.test(value);
19
+ }