@unimatrix27/ralph-harness 1.0.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 (103) hide show
  1. package/CONTRIBUTING.md +89 -0
  2. package/README.md +401 -0
  3. package/dist/bin/ralph-bootstrap-aws.d.ts +3 -0
  4. package/dist/bin/ralph-bootstrap-aws.d.ts.map +1 -0
  5. package/dist/bin/ralph-bootstrap-aws.js +43 -0
  6. package/dist/bin/ralph-bootstrap-aws.js.map +1 -0
  7. package/dist/bin/ralph-fire.d.ts +3 -0
  8. package/dist/bin/ralph-fire.d.ts.map +1 -0
  9. package/dist/bin/ralph-fire.js +59 -0
  10. package/dist/bin/ralph-fire.js.map +1 -0
  11. package/dist/bin/ralph-gsm.d.ts +3 -0
  12. package/dist/bin/ralph-gsm.d.ts.map +1 -0
  13. package/dist/bin/ralph-gsm.js +93 -0
  14. package/dist/bin/ralph-gsm.js.map +1 -0
  15. package/dist/bin/ralph-orchestrate.d.ts +3 -0
  16. package/dist/bin/ralph-orchestrate.d.ts.map +1 -0
  17. package/dist/bin/ralph-orchestrate.js +20 -0
  18. package/dist/bin/ralph-orchestrate.js.map +1 -0
  19. package/dist/bin/ralph-sync-credential.d.ts +3 -0
  20. package/dist/bin/ralph-sync-credential.d.ts.map +1 -0
  21. package/dist/bin/ralph-sync-credential.js +44 -0
  22. package/dist/bin/ralph-sync-credential.js.map +1 -0
  23. package/dist/bin/ralph-sync-github-pat.d.ts +3 -0
  24. package/dist/bin/ralph-sync-github-pat.d.ts.map +1 -0
  25. package/dist/bin/ralph-sync-github-pat.js +93 -0
  26. package/dist/bin/ralph-sync-github-pat.js.map +1 -0
  27. package/dist/bin/ralph-tail-logs.d.ts +3 -0
  28. package/dist/bin/ralph-tail-logs.d.ts.map +1 -0
  29. package/dist/bin/ralph-tail-logs.js +72 -0
  30. package/dist/bin/ralph-tail-logs.js.map +1 -0
  31. package/dist/bin/ralph-validate-config.d.ts +3 -0
  32. package/dist/bin/ralph-validate-config.d.ts.map +1 -0
  33. package/dist/bin/ralph-validate-config.js +41 -0
  34. package/dist/bin/ralph-validate-config.js.map +1 -0
  35. package/dist/lib/aws-bootstrap.d.ts +53 -0
  36. package/dist/lib/aws-bootstrap.d.ts.map +1 -0
  37. package/dist/lib/aws-bootstrap.js +438 -0
  38. package/dist/lib/aws-bootstrap.js.map +1 -0
  39. package/dist/lib/aws-clients.d.ts +17 -0
  40. package/dist/lib/aws-clients.d.ts.map +1 -0
  41. package/dist/lib/aws-clients.js +25 -0
  42. package/dist/lib/aws-clients.js.map +1 -0
  43. package/dist/lib/claude-runner.d.ts +21 -0
  44. package/dist/lib/claude-runner.d.ts.map +1 -0
  45. package/dist/lib/claude-runner.js +101 -0
  46. package/dist/lib/claude-runner.js.map +1 -0
  47. package/dist/lib/credential-syncer.d.ts +27 -0
  48. package/dist/lib/credential-syncer.d.ts.map +1 -0
  49. package/dist/lib/credential-syncer.js +116 -0
  50. package/dist/lib/credential-syncer.js.map +1 -0
  51. package/dist/lib/ec2-orchestrator.d.ts +38 -0
  52. package/dist/lib/ec2-orchestrator.d.ts.map +1 -0
  53. package/dist/lib/ec2-orchestrator.js +469 -0
  54. package/dist/lib/ec2-orchestrator.js.map +1 -0
  55. package/dist/lib/env-loader.d.ts +18 -0
  56. package/dist/lib/env-loader.d.ts.map +1 -0
  57. package/dist/lib/env-loader.js +120 -0
  58. package/dist/lib/env-loader.js.map +1 -0
  59. package/dist/lib/fire-launcher.d.ts +59 -0
  60. package/dist/lib/fire-launcher.d.ts.map +1 -0
  61. package/dist/lib/fire-launcher.js +320 -0
  62. package/dist/lib/fire-launcher.js.map +1 -0
  63. package/dist/lib/gh-runner.d.ts +13 -0
  64. package/dist/lib/gh-runner.d.ts.map +1 -0
  65. package/dist/lib/gh-runner.js +50 -0
  66. package/dist/lib/gh-runner.js.map +1 -0
  67. package/dist/lib/github-state-mutator.d.ts +11 -0
  68. package/dist/lib/github-state-mutator.d.ts.map +1 -0
  69. package/dist/lib/github-state-mutator.js +179 -0
  70. package/dist/lib/github-state-mutator.js.map +1 -0
  71. package/dist/lib/phase-result-schemas.d.ts +88 -0
  72. package/dist/lib/phase-result-schemas.d.ts.map +1 -0
  73. package/dist/lib/phase-result-schemas.js +180 -0
  74. package/dist/lib/phase-result-schemas.js.map +1 -0
  75. package/dist/lib/post-hoc-agent-stuck-checker.d.ts +26 -0
  76. package/dist/lib/post-hoc-agent-stuck-checker.d.ts.map +1 -0
  77. package/dist/lib/post-hoc-agent-stuck-checker.js +142 -0
  78. package/dist/lib/post-hoc-agent-stuck-checker.js.map +1 -0
  79. package/dist/lib/prompt-renderer.d.ts +4 -0
  80. package/dist/lib/prompt-renderer.d.ts.map +1 -0
  81. package/dist/lib/prompt-renderer.js +30 -0
  82. package/dist/lib/prompt-renderer.js.map +1 -0
  83. package/dist/lib/security-runner.d.ts +7 -0
  84. package/dist/lib/security-runner.d.ts.map +1 -0
  85. package/dist/lib/security-runner.js +53 -0
  86. package/dist/lib/security-runner.js.map +1 -0
  87. package/dist/lib/structured-log-emitter.d.ts +53 -0
  88. package/dist/lib/structured-log-emitter.d.ts.map +1 -0
  89. package/dist/lib/structured-log-emitter.js +122 -0
  90. package/dist/lib/structured-log-emitter.js.map +1 -0
  91. package/dist/lib/target-config-schema.d.ts +28 -0
  92. package/dist/lib/target-config-schema.d.ts.map +1 -0
  93. package/dist/lib/target-config-schema.js +157 -0
  94. package/dist/lib/target-config-schema.js.map +1 -0
  95. package/dist/lib/user-data-renderer.d.ts +20 -0
  96. package/dist/lib/user-data-renderer.d.ts.map +1 -0
  97. package/dist/lib/user-data-renderer.js +75 -0
  98. package/dist/lib/user-data-renderer.js.map +1 -0
  99. package/lib/cloud-init/system-setup.sh +338 -0
  100. package/package.json +55 -0
  101. package/prompts/discovery.md +182 -0
  102. package/prompts/implementation.md +161 -0
  103. package/prompts/review.md +135 -0
@@ -0,0 +1,27 @@
1
+ import { type AwsClients } from "./aws-clients.js";
2
+ export declare const MODULE_PREFIX = "credential-syncer";
3
+ export declare const DEFAULTS: {
4
+ readonly ssmKey: "/ralph/claude-oauth-credential";
5
+ readonly kmsAlias: "alias/ralph";
6
+ readonly keychainService: "Claude Code-credentials";
7
+ };
8
+ export type CredentialSyncerExitCode = 1 | 3 | 4;
9
+ export declare class CredentialSyncerError extends Error {
10
+ readonly code: CredentialSyncerExitCode;
11
+ constructor(code: CredentialSyncerExitCode, message: string);
12
+ }
13
+ export declare function moduleErr(message: string): string;
14
+ export declare function moduleInfo(message: string): string;
15
+ export type Logger = (line: string) => void;
16
+ export type SecurityReader = (service: string) => string;
17
+ export interface SyncCredentialOptions {
18
+ clients: AwsClients;
19
+ ssmKey?: string;
20
+ kmsAlias?: string;
21
+ keychainService?: string;
22
+ region?: string;
23
+ info?: Logger;
24
+ readKeychain?: SecurityReader;
25
+ }
26
+ export declare function syncCredential(opts: SyncCredentialOptions): Promise<void>;
27
+ //# sourceMappingURL=credential-syncer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-syncer.d.ts","sourceRoot":"","sources":["../../src/lib/credential-syncer.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAM/D,eAAO,MAAM,aAAa,sBAAsB,CAAC;AAEjD,eAAO,MAAM,QAAQ;;;;CAIX,CAAC;AAEX,MAAM,MAAM,wBAAwB,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;AAEjD,qBAAa,qBAAsB,SAAQ,KAAK;aAE5B,IAAI,EAAE,wBAAwB;gBAA9B,IAAI,EAAE,wBAAwB,EAC9C,OAAO,EAAE,MAAM;CAKlB;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,MAAM,MAAM,MAAM,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;AAM5C,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;AAEzD,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,UAAU,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,cAAc,CAAC;CAC/B;AAMD,wBAAsB,cAAc,CAClC,IAAI,EAAE,qBAAqB,GAC1B,OAAO,CAAC,IAAI,CAAC,CA6Ff"}
@@ -0,0 +1,116 @@
1
+ // credential-syncer — extract the macOS Keychain `Claude Code-credentials`
2
+ // entry and write it into the SSM SecureString that the EC2 worker reads at
3
+ // launch. Re-run after every desktop `claude /login`.
4
+ //
5
+ // Public surface:
6
+ // syncCredential(opts)
7
+ //
8
+ // Errors throw CredentialSyncerError with a code field that the CLI maps to
9
+ // its exit code.
10
+ //
11
+ // Exit codes (must match the bash port at lib/credential-syncer.sh —
12
+ // preserved across the port so existing operator runbooks/automation read
13
+ // the same numbers):
14
+ // 0 success (no error thrown)
15
+ // 2 usage error (CLI only)
16
+ // 3 Keychain entry missing, empty, or not JSON
17
+ // 4 AWS credentials not configured
18
+ // 1 any other propagated failure
19
+ //
20
+ // Security:
21
+ // - The credential is read into a single string variable, then passed to
22
+ // the SSM SDK as the request body of PutParameter. It never appears on
23
+ // argv (the SDK marshals it as JSON inside the HTTPS request).
24
+ // - We never log, echo, or include the credential value in any error
25
+ // message. The "not valid JSON" error message also omits the bytes (so
26
+ // a malformed credential doesn't leak via stderr).
27
+ // - The Keychain read goes through src/lib/security-runner — that wrapper
28
+ // is the single point that touches the credential bytes via subprocess.
29
+ import { PutParameterCommand } from "@aws-sdk/client-ssm";
30
+ import { GetCallerIdentityCommand } from "@aws-sdk/client-sts";
31
+ import { AWS_REGION } from "./aws-clients.js";
32
+ import { SecurityRunnerError, readGenericPassword, } from "./security-runner.js";
33
+ export const MODULE_PREFIX = "credential-syncer";
34
+ export const DEFAULTS = {
35
+ ssmKey: "/ralph/claude-oauth-credential",
36
+ kmsAlias: "alias/ralph",
37
+ keychainService: "Claude Code-credentials",
38
+ };
39
+ export class CredentialSyncerError extends Error {
40
+ code;
41
+ constructor(code, message) {
42
+ super(message);
43
+ this.code = code;
44
+ this.name = "CredentialSyncerError";
45
+ }
46
+ }
47
+ export function moduleErr(message) {
48
+ return `${MODULE_PREFIX}: error: ${message}`;
49
+ }
50
+ export function moduleInfo(message) {
51
+ return `${MODULE_PREFIX}: ${message}`;
52
+ }
53
+ const defaultInfo = (line) => process.stdout.write(`${line}\n`);
54
+ // syncCredential — read the Keychain entry, validate it parses as JSON, and
55
+ // upload it to the SSM SecureString at <ssmKey>. Always overwrites because
56
+ // the parameter is created with a placeholder by aws-bootstrap and must be
57
+ // updated in place.
58
+ export async function syncCredential(opts) {
59
+ const { clients, ssmKey = DEFAULTS.ssmKey, kmsAlias = DEFAULTS.kmsAlias, keychainService = DEFAULTS.keychainService, region = AWS_REGION, info = defaultInfo, readKeychain = readGenericPassword, } = opts;
60
+ // 1. AWS auth precheck — exits 4 with a useful message if the operator
61
+ // forgot to authenticate. Cheaper to fail here than after the Keychain
62
+ // read because the Keychain read can pop a UI prompt on macOS.
63
+ try {
64
+ await clients.sts.send(new GetCallerIdentityCommand({}));
65
+ }
66
+ catch (err) {
67
+ const detail = err instanceof Error ? err.message : String(err);
68
+ throw new CredentialSyncerError(4, moduleErr(`AWS credentials not configured. Run 'aws configure' or set AWS_PROFILE. (${detail})`));
69
+ }
70
+ // 2. Read the Keychain entry. Fail with code 3 on missing/empty/non-JSON.
71
+ let cred;
72
+ try {
73
+ cred = readKeychain(keychainService);
74
+ }
75
+ catch (err) {
76
+ if (err instanceof SecurityRunnerError) {
77
+ throw new CredentialSyncerError(3, moduleErr(`Keychain entry '${keychainService}' not found. Log into the Claude desktop app first (claude /login).`));
78
+ }
79
+ throw err;
80
+ }
81
+ if (cred.length === 0) {
82
+ throw new CredentialSyncerError(3, moduleErr(`Keychain entry '${keychainService}' is empty.`));
83
+ }
84
+ try {
85
+ JSON.parse(cred);
86
+ }
87
+ catch {
88
+ // Do NOT include the credential bytes in the error message — the bash
89
+ // port is explicit about this and the bats test asserts non-leakage of
90
+ // even a malformed value.
91
+ throw new CredentialSyncerError(3, moduleErr(`Keychain entry '${keychainService}' is not valid JSON. Re-login via the Claude desktop app and retry.`));
92
+ }
93
+ // 3. Upload via SSM PutParameter. The SDK serializes the value into the
94
+ // request body — never argv. The Overwrite flag matches the bash port:
95
+ // aws-bootstrap creates the parameter with a placeholder, we overwrite
96
+ // it on every run. Type+KeyId need not be repeated when overwriting,
97
+ // but we set them so a fresh sync against a non-bootstrapped account
98
+ // still works (defensive — usually the operator runs bootstrap-aws
99
+ // first).
100
+ info(moduleInfo(`uploading credential to ${ssmKey} (region=${region}, kms=${kmsAlias})`));
101
+ try {
102
+ await clients.ssm.send(new PutParameterCommand({
103
+ Name: ssmKey,
104
+ Type: "SecureString",
105
+ KeyId: kmsAlias,
106
+ Value: cred,
107
+ Overwrite: true,
108
+ }));
109
+ }
110
+ catch (err) {
111
+ const detail = err instanceof Error ? err.message : String(err);
112
+ throw new CredentialSyncerError(1, moduleErr(`ssm put-parameter failed for ${ssmKey}: ${detail}`));
113
+ }
114
+ info(moduleInfo(`uploaded credential to ${ssmKey}`));
115
+ }
116
+ //# sourceMappingURL=credential-syncer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-syncer.js","sourceRoot":"","sources":["../../src/lib/credential-syncer.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,38 @@
1
+ import { type RunClaudeOptions } from "./claude-runner.js";
2
+ import { type Phase, type Sink } from "./structured-log-emitter.js";
3
+ export declare const MODULE_PREFIX = "ec2-orchestrator";
4
+ export declare class OrchestratorError extends Error {
5
+ readonly exitCode: number;
6
+ constructor(exitCode: number, message: string);
7
+ }
8
+ export interface OrchestratorConfig {
9
+ targetRepo: string;
10
+ awsRegion: string;
11
+ workDir: string;
12
+ defaultBranch: string;
13
+ configPath: string;
14
+ launchTag: string;
15
+ outDir: string;
16
+ discoveryPromptPath: string;
17
+ implementationPromptPath: string;
18
+ reviewPromptPath: string;
19
+ reviewWaitSec: number;
20
+ }
21
+ export declare function resolveOrchestratorConfig(env: NodeJS.ProcessEnv, packageRoot: string): OrchestratorConfig;
22
+ type ClaudeRunner = (prompt: string, opts?: RunClaudeOptions) => Promise<{
23
+ exitCode: number;
24
+ }>;
25
+ type Sleep = (ms: number) => Promise<void>;
26
+ export interface RunOptions {
27
+ env?: NodeJS.ProcessEnv;
28
+ packageRoot?: string;
29
+ claude?: ClaudeRunner;
30
+ sleep?: Sleep;
31
+ sink?: Sink;
32
+ clock?: () => Date;
33
+ runSystemSetup?: (workDir: string) => Promise<void>;
34
+ }
35
+ export declare function mergeSetupEnvFile(path: string, env: NodeJS.ProcessEnv): number;
36
+ export declare function run(opts?: RunOptions): Promise<number>;
37
+ export type { Phase };
38
+ //# sourceMappingURL=ec2-orchestrator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ec2-orchestrator.d.ts","sourceRoot":"","sources":["../../src/lib/ec2-orchestrator.ts"],"names":[],"mappings":"AAsCA,OAAO,EAAa,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAUtE,OAAO,EAML,KAAK,KAAK,EACV,KAAK,IAAI,EACV,MAAM,6BAA6B,CAAC;AAMrC,eAAO,MAAM,aAAa,qBAAqB,CAAC;AAEhD,qBAAa,iBAAkB,SAAQ,KAAK;aACd,QAAQ,EAAE,MAAM;gBAAhB,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;CAI9D;AAKD,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,mBAAmB,EAAE,MAAM,CAAC;IAC5B,wBAAwB,EAAE,MAAM,CAAC;IACjC,gBAAgB,EAAE,MAAM,CAAC;IACzB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,MAAM,CAAC,UAAU,EACtB,WAAW,EAAE,MAAM,GAClB,kBAAkB,CA0BpB;AAkCD,KAAK,YAAY,GAAG,CAClB,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,gBAAgB,KACpB,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEnC,KAAK,KAAK,GAAG,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAG3C,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAGxB,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IAEnB,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACrD;AA2GD,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,CAAC,UAAU,GACrB,MAAM,CA2BR;AA2ED,wBAAsB,GAAG,CAAC,IAAI,GAAE,UAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CA0PhE;AAGD,YAAY,EAAE,KAAK,EAAE,CAAC"}
@@ -0,0 +1,469 @@
1
+ // ec2-orchestrator — TS port of iteration-1's lib/ec2-orchestrator.sh.
2
+ //
3
+ // Entry point for the discovery → implementation → review state machine
4
+ // running on a freshly bootstrapped EC2 worker. Phase markers,
5
+ // CloudWatch-bound stdout/stderr, and the contract files under /tmp/ralph
6
+ // are all preserved byte-identical to iteration 1.
7
+ //
8
+ // Public surface:
9
+ // run(opts) — full state machine. Returns an exit code (0/1/2/3) that
10
+ // the bin entry passes to process.exit.
11
+ // resolveOrchestratorConfig(env) — pure resolver, exposed for tests.
12
+ //
13
+ // Reads from env (set by the cloud-init bootstrap):
14
+ // RALPH_TARGET_REPO owner/repo (required)
15
+ // RALPH_AWS_REGION informational
16
+ // RALPH_WORK_DIR absolute path to the fresh clone
17
+ // RALPH_DEFAULT_BRANCH resolved default branch of the target repo
18
+ // RALPH_CONFIG path to the validated .ralph/config.yaml
19
+ // RALPH_LAUNCH_TAG per-launch identifier embedded in PR bodies
20
+ //
21
+ // With overridable defaults:
22
+ // RALPH_OUT_DIR /tmp/ralph
23
+ // RALPH_DISCOVERY_PROMPT <package>/prompts/discovery.md
24
+ // RALPH_IMPLEMENTATION_PROMPT <package>/prompts/implementation.md
25
+ // RALPH_REVIEW_PROMPT <package>/prompts/review.md
26
+ // RALPH_REVIEW_WAIT_SEC 600
27
+ //
28
+ // Exit codes:
29
+ // 0 discovery completed (NONE/ALL_BLOCKED) OR full chain completed
30
+ // 1 claude invocation failed
31
+ // 2 missing required env (target repo)
32
+ // 3 contract violation (missing/invalid output, unknown status)
33
+ import { readFileSync } from "node:fs";
34
+ import { mkdir, readFile } from "node:fs/promises";
35
+ import { dirname, resolve as resolvePath } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import { runClaude } from "./claude-runner.js";
38
+ import { parseDecision, parseImplResult, parseReviewResult, } from "./phase-result-schemas.js";
39
+ import { render } from "./prompt-renderer.js";
40
+ import { Emitter, outcome as outcomeMarker, pickedIssue as pickedIssueMarker, realClock, stdoutSink, } from "./structured-log-emitter.js";
41
+ import { validate as validateTargetConfig, } from "./target-config-schema.js";
42
+ export const MODULE_PREFIX = "ec2-orchestrator";
43
+ export class OrchestratorError extends Error {
44
+ exitCode;
45
+ constructor(exitCode, message) {
46
+ super(message);
47
+ this.exitCode = exitCode;
48
+ this.name = "OrchestratorError";
49
+ }
50
+ }
51
+ const DEFAULT_REVIEW_WAIT_SEC = 600;
52
+ const DEFAULT_OUT_DIR = "/tmp/ralph";
53
+ export function resolveOrchestratorConfig(env, packageRoot) {
54
+ const targetRepo = env.RALPH_TARGET_REPO ?? "";
55
+ if (targetRepo.length === 0) {
56
+ throw new OrchestratorError(2, `${MODULE_PREFIX}: error: RALPH_TARGET_REPO is required`);
57
+ }
58
+ const promptsRoot = resolvePath(packageRoot, "prompts");
59
+ return {
60
+ targetRepo,
61
+ awsRegion: env.RALPH_AWS_REGION ?? "",
62
+ workDir: env.RALPH_WORK_DIR ?? "",
63
+ defaultBranch: env.RALPH_DEFAULT_BRANCH ?? "",
64
+ configPath: env.RALPH_CONFIG ?? "",
65
+ launchTag: env.RALPH_LAUNCH_TAG ?? "",
66
+ outDir: env.RALPH_OUT_DIR ?? DEFAULT_OUT_DIR,
67
+ discoveryPromptPath: env.RALPH_DISCOVERY_PROMPT ?? resolvePath(promptsRoot, "discovery.md"),
68
+ implementationPromptPath: env.RALPH_IMPLEMENTATION_PROMPT ??
69
+ resolvePath(promptsRoot, "implementation.md"),
70
+ reviewPromptPath: env.RALPH_REVIEW_PROMPT ?? resolvePath(promptsRoot, "review.md"),
71
+ reviewWaitSec: parseReviewWait(env.RALPH_REVIEW_WAIT_SEC),
72
+ };
73
+ }
74
+ function parseReviewWait(raw) {
75
+ if (!raw || raw.length === 0)
76
+ return DEFAULT_REVIEW_WAIT_SEC;
77
+ const n = Number.parseInt(raw, 10);
78
+ if (!Number.isFinite(n) || n < 0)
79
+ return DEFAULT_REVIEW_WAIT_SEC;
80
+ return n;
81
+ }
82
+ // loadTargetConfig — best-effort YAML read. Returns undefined when the
83
+ // path is empty or the file is missing — the orchestrator emits empty
84
+ // strings for the unresolved {{...}} keys in that case (matching the
85
+ // bash port's `yq … // ""` fallback).
86
+ async function loadTargetConfig(configPath) {
87
+ if (configPath.length === 0)
88
+ return undefined;
89
+ let raw;
90
+ try {
91
+ raw = await readFile(configPath, "utf8");
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ try {
97
+ return validateTargetConfig(raw);
98
+ }
99
+ catch {
100
+ // We do not block the orchestrator on a malformed config — the
101
+ // operator's `ralph-validate-config` CLI is the authoritative gate.
102
+ // Empty values just leave the prompts with literal `{{KEY}}`
103
+ // placeholders, which is greppable in CloudWatch.
104
+ return undefined;
105
+ }
106
+ }
107
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
108
+ const here = dirname(fileURLToPath(import.meta.url));
109
+ // runSystemSetupDefault — invoke the OS-level cloud-init script that ships
110
+ // inside the npm package at lib/cloud-init/system-setup.sh. Best-effort:
111
+ // if the file is missing (running outside the published package, e.g. a
112
+ // dev `tsx` invocation against the source tree), we no-op. The post-
113
+ // condition the orchestrator depends on is "claude CLI + gh + repo clone
114
+ // + .ralph/config.yaml validated" — operators who run from source are
115
+ // expected to have set those up themselves.
116
+ async function runSystemSetupDefault(workDir) {
117
+ const { spawn } = await import("node:child_process");
118
+ const candidates = [
119
+ // dist build → lib/cloud-init/system-setup.sh next to dist
120
+ resolvePath(here, "..", "..", "lib", "cloud-init", "system-setup.sh"),
121
+ // src tree
122
+ resolvePath(here, "..", "..", "..", "lib", "cloud-init", "system-setup.sh"),
123
+ ];
124
+ for (const path of candidates) {
125
+ try {
126
+ const { existsSync } = await import("node:fs");
127
+ if (!existsSync(path))
128
+ continue;
129
+ const child = spawn("bash", [path], {
130
+ stdio: "inherit",
131
+ cwd: workDir.length > 0 ? workDir : process.cwd(),
132
+ env: process.env,
133
+ });
134
+ const rc = await new Promise((res) => {
135
+ child.on("close", (code) => res(code ?? -1));
136
+ child.on("error", () => res(-1));
137
+ });
138
+ if (rc !== 0) {
139
+ throw new OrchestratorError(rc, `${MODULE_PREFIX}: system-setup.sh exited ${rc}`);
140
+ }
141
+ return;
142
+ }
143
+ catch (err) {
144
+ if (err instanceof OrchestratorError)
145
+ throw err;
146
+ // continue to the next candidate
147
+ }
148
+ }
149
+ // No system-setup.sh present — skip silently.
150
+ }
151
+ function buildDiscoveryContext(b) {
152
+ return {
153
+ RALPH_TARGET_REPO: b.config.targetRepo,
154
+ RALPH_DEFAULT_BRANCH: b.config.defaultBranch,
155
+ RALPH_WORK_DIR: b.config.workDir,
156
+ RALPH_BUILD_CMD: b.target?.build_cmd ?? "",
157
+ RALPH_TEST_CMD: b.target?.test_cmd ?? "",
158
+ RALPH_BRANCH_PREFIX: b.target?.branch_prefix ?? "",
159
+ PROMPT_EXTENSION: b.target?.prompt_extensions?.discovery ?? "",
160
+ };
161
+ }
162
+ function buildImplContext(b) {
163
+ return {
164
+ RALPH_TARGET_REPO: b.config.targetRepo,
165
+ RALPH_DEFAULT_BRANCH: b.config.defaultBranch,
166
+ RALPH_WORK_DIR: b.config.workDir,
167
+ RALPH_BUILD_CMD: b.target?.build_cmd ?? "",
168
+ RALPH_TEST_CMD: b.target?.test_cmd ?? "",
169
+ RALPH_BRANCH_PREFIX: b.target?.branch_prefix ?? "",
170
+ RALPH_AGENT_STUCK_LABEL: b.target?.agent_stuck_label ?? "agent-stuck",
171
+ RALPH_LAUNCH_TAG: b.config.launchTag,
172
+ PROMPT_EXTENSION: b.target?.prompt_extensions?.implementation ?? "",
173
+ };
174
+ }
175
+ function buildReviewContext(b, issue, pr, prBranch) {
176
+ return {
177
+ RALPH_TARGET_REPO: b.config.targetRepo,
178
+ RALPH_DEFAULT_BRANCH: b.config.defaultBranch,
179
+ RALPH_WORK_DIR: b.config.workDir,
180
+ RALPH_BUILD_CMD: b.target?.build_cmd ?? "",
181
+ RALPH_TEST_CMD: b.target?.test_cmd ?? "",
182
+ RALPH_ISSUE_NUMBER: String(issue),
183
+ RALPH_PR_NUMBER: String(pr),
184
+ RALPH_PR_BRANCH: prBranch,
185
+ RALPH_REVIEW_BOT_USERNAME: b.target?.review_bot.username ?? "",
186
+ RALPH_REVIEW_BOT_SOURCE: b.target?.review_bot.source ?? "",
187
+ PROMPT_EXTENSION: b.target?.prompt_extensions?.review ?? "",
188
+ };
189
+ }
190
+ function readContractText(path) {
191
+ return readFileSync(path, "utf8");
192
+ }
193
+ // mergeSetupEnvFile — reads a `KEY=VALUE` env file written by
194
+ // system-setup.sh and merges it into the supplied env object. Missing
195
+ // file is a no-op. Each line is trimmed; lines without `=` are skipped.
196
+ // Values can be unquoted or wrapped in double quotes.
197
+ export function mergeSetupEnvFile(path, env) {
198
+ let raw;
199
+ try {
200
+ raw = readFileSync(path, "utf8");
201
+ }
202
+ catch {
203
+ return 0;
204
+ }
205
+ let count = 0;
206
+ for (const rawLine of raw.split(/\r?\n/)) {
207
+ const line = rawLine.trim();
208
+ if (line.length === 0 || line.startsWith("#"))
209
+ continue;
210
+ const eq = line.indexOf("=");
211
+ if (eq <= 0)
212
+ continue;
213
+ const key = line.slice(0, eq).trim();
214
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
215
+ continue;
216
+ let value = line.slice(eq + 1);
217
+ if (value.length >= 2 &&
218
+ ((value.startsWith('"') && value.endsWith('"')) ||
219
+ (value.startsWith("'") && value.endsWith("'")))) {
220
+ value = value.slice(1, -1);
221
+ }
222
+ env[key] = value;
223
+ count += 1;
224
+ }
225
+ return count;
226
+ }
227
+ function readDecisionFile(outDir) {
228
+ return parseDecision(readContractText(resolvePath(outDir, "decision.json")));
229
+ }
230
+ function verifyDiscoveryOutputs(outDir) {
231
+ for (const f of [
232
+ "decision.json",
233
+ "issue.json",
234
+ "crafted-prompt.md",
235
+ "milestone-log.json",
236
+ ]) {
237
+ try {
238
+ readContractText(resolvePath(outDir, f));
239
+ }
240
+ catch (err) {
241
+ const detail = err instanceof Error ? err.message : String(err);
242
+ throw new OrchestratorError(3, `${MODULE_PREFIX}: discovery did not write ${outDir}/${f}: ${detail}`);
243
+ }
244
+ }
245
+ // decision.json must parse — done via readDecisionFile when consumed.
246
+ }
247
+ function readImplFile(outDir) {
248
+ try {
249
+ return parseImplResult(readContractText(resolvePath(outDir, "impl-result.json")));
250
+ }
251
+ catch (err) {
252
+ if (err instanceof Error) {
253
+ throw new OrchestratorError(3, `${MODULE_PREFIX}: ${err.message}`);
254
+ }
255
+ throw err;
256
+ }
257
+ }
258
+ function readReviewFile(outDir) {
259
+ try {
260
+ return parseReviewResult(readContractText(resolvePath(outDir, "review-result.json")));
261
+ }
262
+ catch (err) {
263
+ if (err instanceof Error) {
264
+ throw new OrchestratorError(3, `${MODULE_PREFIX}: ${err.message}`);
265
+ }
266
+ throw err;
267
+ }
268
+ }
269
+ async function runClaudePhase(rendered, claude, startedAtMs) {
270
+ const r = await claude(rendered);
271
+ const elapsedSec = Math.floor((Date.now() - startedAtMs) / 1000);
272
+ return { exitCode: r.exitCode, durationSec: elapsedSec };
273
+ }
274
+ // run — full discovery → implementation → review state machine. Returns
275
+ // the exit code that `ralph-orchestrate` should pass to process.exit.
276
+ export async function run(opts = {}) {
277
+ const env = opts.env ?? process.env;
278
+ const sink = opts.sink ?? stdoutSink;
279
+ const clock = opts.clock ?? realClock;
280
+ const claude = opts.claude ?? runClaude;
281
+ const sleep = opts.sleep ?? realSleep;
282
+ const packageRoot = opts.packageRoot ?? resolvePath(here, "..", "..");
283
+ let config;
284
+ try {
285
+ config = resolveOrchestratorConfig(env, packageRoot);
286
+ }
287
+ catch (err) {
288
+ if (err instanceof OrchestratorError) {
289
+ process.stderr.write(`${err.message}\n`);
290
+ return err.exitCode;
291
+ }
292
+ throw err;
293
+ }
294
+ const info = (line) => sink(`${MODULE_PREFIX}: ${line}`);
295
+ info("ralph-harness orchestrator");
296
+ info(`target=${config.targetRepo} work_dir=${config.workDir} default_branch=${config.defaultBranch} config=${config.configPath} out=${config.outDir} launch_tag=${config.launchTag}`);
297
+ await mkdir(config.outDir, { recursive: true });
298
+ const runSystemSetup = opts.runSystemSetup ?? runSystemSetupDefault;
299
+ await runSystemSetup(config.workDir);
300
+ // system-setup.sh writes RALPH_WORK_DIR / RALPH_DEFAULT_BRANCH /
301
+ // RALPH_CONFIG / RALPH_LAUNCH_TAG into /tmp/ralph/setup.env (one
302
+ // KEY=VALUE per line). Merge those values into env so the rest of the
303
+ // orchestrator sees them. We re-resolve the config afterwards.
304
+ mergeSetupEnvFile(resolvePath(config.outDir, "setup.env"), env);
305
+ config = resolveOrchestratorConfig(env, packageRoot);
306
+ const target = await loadTargetConfig(config.configPath);
307
+ const bundle = { config, target };
308
+ const emitter = new Emitter(sink, clock);
309
+ // ---- Discovery ----
310
+ const discoveryTemplate = await readFile(config.discoveryPromptPath, "utf8");
311
+ const discoveryRendered = render(discoveryTemplate, buildDiscoveryContext(bundle));
312
+ emitter.start({ phase: "discovery", target: config.targetRepo });
313
+ const discoveryStart = Date.now();
314
+ const discoveryResult = await runClaudePhase(discoveryRendered, claude, discoveryStart);
315
+ emitter.end({
316
+ phase: "discovery",
317
+ durationSec: discoveryResult.durationSec,
318
+ });
319
+ if (discoveryResult.exitCode !== 0) {
320
+ process.stderr.write(`${MODULE_PREFIX}: error: claude discovery exited ${discoveryResult.exitCode}\n`);
321
+ return 1;
322
+ }
323
+ try {
324
+ verifyDiscoveryOutputs(config.outDir);
325
+ }
326
+ catch (err) {
327
+ if (err instanceof OrchestratorError) {
328
+ process.stderr.write(`${err.message}\n`);
329
+ return err.exitCode;
330
+ }
331
+ throw err;
332
+ }
333
+ let decision;
334
+ try {
335
+ decision = readDecisionFile(config.outDir);
336
+ }
337
+ catch (err) {
338
+ process.stderr.write(`${MODULE_PREFIX}: error: ${err instanceof Error ? err.message : String(err)}\n`);
339
+ return 3;
340
+ }
341
+ if (decision.status === "NONE") {
342
+ info("discovery returned NONE — no eligible candidates");
343
+ sink(outcomeMarker({ kind: "no_work" }));
344
+ return 0;
345
+ }
346
+ if (decision.status === "ALL_BLOCKED") {
347
+ info("discovery returned ALL_BLOCKED — every candidate has unsatisfied blockers");
348
+ sink(outcomeMarker({ kind: "all_blocked" }));
349
+ return 0;
350
+ }
351
+ // PICKED branch
352
+ const issue = decision.issue;
353
+ info(`discovery picked issue #${issue}`);
354
+ sink(pickedIssueMarker(issue));
355
+ // ---- Implementation ----
356
+ const implTemplate = await readFile(config.implementationPromptPath, "utf8");
357
+ const craftedPath = resolvePath(config.outDir, "crafted-prompt.md");
358
+ let crafted;
359
+ try {
360
+ crafted = await readFile(craftedPath, "utf8");
361
+ }
362
+ catch (err) {
363
+ const detail = err instanceof Error ? err.message : String(err);
364
+ process.stderr.write(`${MODULE_PREFIX}: error: crafted-prompt.md not found: ${detail}\n`);
365
+ return 3;
366
+ }
367
+ const implRendered = `${render(implTemplate, buildImplContext(bundle))}\n\n---\n\n## Crafted context from discovery\n\n${crafted}`;
368
+ emitter.start({ phase: "implementation", issue });
369
+ const implStart = Date.now();
370
+ const implResult = await runClaudePhase(implRendered, claude, implStart);
371
+ let impl;
372
+ try {
373
+ impl = readImplFile(config.outDir);
374
+ }
375
+ catch (err) {
376
+ if (err instanceof OrchestratorError) {
377
+ // We still want to emit the PHASE_END marker before bailing, with
378
+ // status=unknown.
379
+ emitter.end({
380
+ phase: "implementation",
381
+ durationSec: implResult.durationSec,
382
+ issue,
383
+ status: "unknown",
384
+ });
385
+ process.stderr.write(`${err.message}\n`);
386
+ return err.exitCode;
387
+ }
388
+ throw err;
389
+ }
390
+ emitter.end({
391
+ phase: "implementation",
392
+ durationSec: implResult.durationSec,
393
+ issue,
394
+ status: impl.status,
395
+ });
396
+ if (implResult.exitCode !== 0) {
397
+ process.stderr.write(`${MODULE_PREFIX}: error: claude implementation exited ${implResult.exitCode}\n`);
398
+ return 1;
399
+ }
400
+ if (impl.status === "AGENT_STUCK") {
401
+ info(`implementation reported agent_stuck for issue #${issue}`);
402
+ sink(outcomeMarker({ kind: "agent_stuck", issue }));
403
+ return 0;
404
+ }
405
+ // PR_OPENED branch
406
+ const prNumber = impl.pr_number;
407
+ const prBranch = impl.branch;
408
+ info(`implementation opened PR #${prNumber} for issue #${issue}`);
409
+ // ---- Review ----
410
+ if (config.reviewWaitSec > 0) {
411
+ info(`review: sleeping ${config.reviewWaitSec}s for review bot window`);
412
+ await sleep(config.reviewWaitSec * 1_000);
413
+ }
414
+ const reviewTemplate = await readFile(config.reviewPromptPath, "utf8");
415
+ const reviewRendered = render(reviewTemplate, buildReviewContext(bundle, issue, prNumber, prBranch));
416
+ emitter.start({ phase: "review", issue, pr: prNumber });
417
+ const reviewStart = Date.now();
418
+ const reviewResult = await runClaudePhase(reviewRendered, claude, reviewStart);
419
+ let review;
420
+ try {
421
+ review = readReviewFile(config.outDir);
422
+ }
423
+ catch (err) {
424
+ if (err instanceof OrchestratorError) {
425
+ emitter.end({
426
+ phase: "review",
427
+ durationSec: reviewResult.durationSec,
428
+ issue,
429
+ pr: prNumber,
430
+ status: "unknown",
431
+ });
432
+ process.stderr.write(`${err.message}\n`);
433
+ return err.exitCode;
434
+ }
435
+ throw err;
436
+ }
437
+ emitter.end({
438
+ phase: "review",
439
+ durationSec: reviewResult.durationSec,
440
+ issue,
441
+ pr: prNumber,
442
+ status: review.status,
443
+ });
444
+ if (reviewResult.exitCode !== 0) {
445
+ process.stderr.write(`${MODULE_PREFIX}: error: claude review exited ${reviewResult.exitCode}\n`);
446
+ return 1;
447
+ }
448
+ if (review.status === "NO_REVIEW") {
449
+ info("review: no verdict from configured review bot; no caveman log appended");
450
+ sink(outcomeMarker({
451
+ kind: "pr_opened",
452
+ issue,
453
+ pr: prNumber,
454
+ review: "none",
455
+ }));
456
+ return 0;
457
+ }
458
+ // REVISION_APPLIED → caveman log append handled by the review call's
459
+ // own exit (slice 9 contract). The orchestrator just records the OUTCOME.
460
+ info(`review: revision applied to PR #${prNumber}`);
461
+ sink(outcomeMarker({
462
+ kind: "pr_opened",
463
+ issue,
464
+ pr: prNumber,
465
+ review: "revised",
466
+ }));
467
+ return 0;
468
+ }
469
+ //# sourceMappingURL=ec2-orchestrator.js.map