@awak-app/simy-cli 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.
package/src/runner.js ADDED
@@ -0,0 +1,585 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { access } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ import {
8
+ collectPrEvidence,
9
+ createCodingLoopSnapshot,
10
+ parseStructuredMarker,
11
+ parseStructuredResult,
12
+ recheckPrReadiness,
13
+ runCodingLoop,
14
+ } from "./orchestrator/index.js";
15
+ import {
16
+ EXECUTION_IO_POLICY_VERSION,
17
+ executionTextSha256,
18
+ redactExecutionLogs,
19
+ redactExecutionText,
20
+ } from "./orchestrator/execution-io.js";
21
+
22
+ const execFileAsync = promisify(execFile);
23
+ const TERMINAL_STATES = new Set([
24
+ "merge_ready",
25
+ "pr_ready_for_review",
26
+ "failed",
27
+ "blocked",
28
+ "waiting_human",
29
+ ]);
30
+
31
+ export class LocalRunRegistry {
32
+ #runs = new Map();
33
+
34
+ create(run) {
35
+ if (this.#runs.has(run.id)) throw new Error(`Run ${run.id} already exists.`);
36
+ this.#runs.set(run.id, run);
37
+ }
38
+
39
+ get(runId) {
40
+ return this.#runs.get(runId) ?? null;
41
+ }
42
+
43
+ has(runId) {
44
+ return this.#runs.has(runId);
45
+ }
46
+ }
47
+
48
+ export function createRun({ runId, request, session, apiOrigin }) {
49
+ const emitter = new EventEmitter();
50
+ return {
51
+ id: runId,
52
+ request,
53
+ session,
54
+ apiOrigin,
55
+ status: "queued",
56
+ lastOutput: "",
57
+ startedAt: null,
58
+ completedAt: null,
59
+ child: null,
60
+ emitter,
61
+ pendingLedgerUpdate: null,
62
+ ledgerUpdateRunning: false,
63
+ snapshot: createCodingLoopSnapshot({
64
+ runId,
65
+ request,
66
+ metadata: {
67
+ local_orchestrator_version: 1,
68
+ local_output_persisted: false,
69
+ },
70
+ }),
71
+ };
72
+ }
73
+
74
+ export async function startLocalCodingRun(
75
+ run,
76
+ { executeAttempt, executeIndependentAudit, collectEvidence } = {},
77
+ ) {
78
+ if (run.child || TERMINAL_STATES.has(run.status) || run.startedAt) return;
79
+ run.startedAt = new Date().toISOString();
80
+
81
+ let repositoryPath;
82
+ try {
83
+ repositoryPath = await resolveRepositoryPath(run.request);
84
+ } catch (error) {
85
+ const message = error instanceof Error ? error.message : "Local repository could not be resolved.";
86
+ run.lastOutput = message;
87
+ run.snapshot.final_audit = preflightAudit(message);
88
+ run.snapshot.events.push({
89
+ state: "waiting_human",
90
+ message: "Local repository requires configuration.",
91
+ detail: { code: "local_repository_not_found" },
92
+ occurred_at: new Date().toISOString(),
93
+ });
94
+ await updateRun(run, "waiting_human");
95
+ return;
96
+ }
97
+
98
+ const executor =
99
+ executeAttempt ||
100
+ ((context) =>
101
+ executeProcessAttempt({
102
+ ...context,
103
+ repositoryPath,
104
+ run,
105
+ }));
106
+ const independentAuditor =
107
+ executeIndependentAudit ||
108
+ ((context) =>
109
+ executeProcessAttempt({
110
+ ...context,
111
+ repositoryPath,
112
+ run,
113
+ marker: "SIMY_AUDIT_JSON:",
114
+ }));
115
+ const evidenceCollector =
116
+ collectEvidence ||
117
+ ((attempt) =>
118
+ collectPrEvidence({
119
+ charter: run.snapshot.charter,
120
+ attempt,
121
+ repositoryPath,
122
+ }));
123
+
124
+ try {
125
+ await runCodingLoop({
126
+ snapshot: run.snapshot,
127
+ executeAttempt: executor,
128
+ executeIndependentAudit: independentAuditor,
129
+ collectEvidence: evidenceCollector,
130
+ onUpdate: async (snapshot) => {
131
+ run.snapshot = snapshot;
132
+ await updateRun(run, snapshot.state);
133
+ },
134
+ });
135
+ } catch (error) {
136
+ const message = error instanceof Error ? error.message : "Local orchestration failed.";
137
+ emitOutput(run, message);
138
+ run.snapshot.events.push({
139
+ state: "failed",
140
+ message: "Local orchestration failed.",
141
+ detail: { error: message },
142
+ occurred_at: new Date().toISOString(),
143
+ });
144
+ await updateRun(run, "failed");
145
+ }
146
+ }
147
+
148
+ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
149
+ if (!run?.snapshot?.attempts?.length) return run?.snapshot || null;
150
+ if (!["pr_ready_for_review", "merge_ready"].includes(run.status)) return run.snapshot;
151
+ const repositoryPath = await resolveRepositoryPath(run.request);
152
+ const evidenceCollector =
153
+ collectEvidence ||
154
+ ((attempt) =>
155
+ collectPrEvidence({
156
+ charter: run.snapshot.charter,
157
+ attempt,
158
+ repositoryPath,
159
+ }));
160
+ await recheckPrReadiness({
161
+ snapshot: run.snapshot,
162
+ collectEvidence: evidenceCollector,
163
+ onUpdate: async (snapshot) => {
164
+ run.snapshot = snapshot;
165
+ await updateRun(run, snapshot.state);
166
+ },
167
+ });
168
+ return run.snapshot;
169
+ }
170
+
171
+ async function executeProcessAttempt({
172
+ backend,
173
+ instruction,
174
+ repositoryPath,
175
+ run,
176
+ marker = "SIMY_RESULT_JSON:",
177
+ }) {
178
+ const command = resolveBackendCommand({ backend, instruction, repositoryPath });
179
+ const startedAt = new Date().toISOString();
180
+ const stdout = [];
181
+ const stderr = [];
182
+
183
+ return new Promise((resolve) => {
184
+ let settled = false;
185
+ const child = command.spawn(command.bin, command.args, {
186
+ cwd: repositoryPath,
187
+ env: { ...process.env, ...command.env },
188
+ stdio: ["ignore", "pipe", "pipe"],
189
+ });
190
+ run.child = child;
191
+
192
+ const collect = (target, chunk) => {
193
+ const text = chunk.toString("utf8");
194
+ target.push(text);
195
+ emitOutput(run, text);
196
+ };
197
+ child.stdout?.on("data", (chunk) => collect(stdout, chunk));
198
+ child.stderr?.on("data", (chunk) => collect(stderr, chunk));
199
+
200
+ const finish = ({ exitCode = null, error = null } = {}) => {
201
+ if (settled) return;
202
+ settled = true;
203
+ run.child = null;
204
+ const rawStdout = stdout.join("");
205
+ const assistantText = extractAssistantText(rawStdout);
206
+ const parse =
207
+ marker === "SIMY_RESULT_JSON:"
208
+ ? parseStructuredResult
209
+ : (value) => parseStructuredMarker(value, marker);
210
+ const result = parse(assistantText);
211
+ resolve({
212
+ exitCode,
213
+ error,
214
+ result: Object.keys(result).length > 0 ? result : parse(rawStdout),
215
+ logs: [...stdout, ...stderr]
216
+ .join("")
217
+ .split(/\r?\n/)
218
+ .map((line) => line.trim())
219
+ .filter(Boolean),
220
+ startedAt,
221
+ finishedAt: new Date().toISOString(),
222
+ });
223
+ };
224
+
225
+ child.on("error", (error) => finish({ error: error.message }));
226
+ child.on("close", (code) => finish({ exitCode: code }));
227
+ });
228
+ }
229
+
230
+ function resolveBackendCommand({ backend, instruction, repositoryPath }) {
231
+ if (backend === "claude") {
232
+ const override = process.env.SIMY_CLAUDE_COMMAND;
233
+ if (override) return shellCommand(override, repositoryPath, instruction);
234
+ return {
235
+ bin: "claude",
236
+ args: ["-p", instruction, "--output-format", "stream-json", "--verbose"],
237
+ env: {},
238
+ spawn,
239
+ };
240
+ }
241
+
242
+ const override = process.env.SIMY_CODEX_COMMAND;
243
+ if (override) return shellCommand(override, repositoryPath, instruction);
244
+ return {
245
+ bin: "codex",
246
+ args: ["exec", "--json", instruction],
247
+ env: {},
248
+ spawn,
249
+ };
250
+ }
251
+
252
+ function shellCommand(command, cwd, instruction) {
253
+ return {
254
+ bin: process.platform === "win32" ? "cmd.exe" : "sh",
255
+ args: process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command],
256
+ cwd,
257
+ env: { SIMY_CODING_LOOP_REQUIREMENT: instruction },
258
+ spawn,
259
+ };
260
+ }
261
+
262
+ async function resolveRepositoryPath(request) {
263
+ const repository = String(request.repository || "").replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
264
+ const repoName = repository.split("/").filter(Boolean).at(-1);
265
+ if (!repoName) throw new Error("The coding loop did not specify a valid GitHub repository.");
266
+
267
+ const roots = [request.local_path, process.env.SIMY_REPO_ROOT, process.cwd()].filter(Boolean);
268
+ const candidates = new Set();
269
+ for (const root of roots) {
270
+ const resolved = path.resolve(String(root));
271
+ candidates.add(resolved);
272
+ candidates.add(path.join(resolved, repoName));
273
+ }
274
+
275
+ for (const candidate of candidates) {
276
+ if (!(await pathExists(candidate))) continue;
277
+ const remote = await gitRemote(candidate);
278
+ if (remote === repository.toLowerCase()) return candidate;
279
+ }
280
+
281
+ throw new Error(
282
+ `Repository ${repository} was not found below ${process.env.SIMY_REPO_ROOT || process.cwd()}. ` +
283
+ "Start simy from the repository or its workspace root, or set SIMY_REPO_ROOT.",
284
+ );
285
+ }
286
+
287
+ async function gitRemote(cwd) {
288
+ try {
289
+ const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], { cwd });
290
+ return normalizeGitHubRemote(stdout);
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ function normalizeGitHubRemote(value) {
297
+ return String(value || "")
298
+ .trim()
299
+ .replace(/^git@github\.com:/, "")
300
+ .replace(/^ssh:\/\/git@github\.com\//, "")
301
+ .replace(/^https?:\/\/github\.com\//, "")
302
+ .replace(/\.git$/, "")
303
+ .toLowerCase();
304
+ }
305
+
306
+ async function pathExists(value) {
307
+ try {
308
+ await access(value);
309
+ return true;
310
+ } catch {
311
+ return false;
312
+ }
313
+ }
314
+
315
+ function extractAssistantText(raw) {
316
+ const messages = [];
317
+ for (const line of String(raw || "").split(/\r?\n/)) {
318
+ if (!line.trim().startsWith("{")) continue;
319
+ try {
320
+ const item = JSON.parse(line);
321
+ if (item.type === "result" && typeof item.result === "string") messages.push(item.result);
322
+ if (item.type === "item.completed" && item.item?.type === "agent_message") {
323
+ if (typeof item.item.text === "string") messages.push(item.item.text);
324
+ }
325
+ if (item.type === "assistant" && typeof item.message?.content === "string") {
326
+ messages.push(item.message.content);
327
+ }
328
+ } catch {
329
+ // Non-JSON executor output is still available to the fallback parser.
330
+ }
331
+ }
332
+ return messages.join("\n");
333
+ }
334
+
335
+ function emitOutput(run, text) {
336
+ for (const line of String(text).split(/\r?\n/)) {
337
+ if (!line.trim()) continue;
338
+ run.lastOutput = line;
339
+ run.emitter.emit("event", {
340
+ type: "output",
341
+ run_id: run.id,
342
+ backend: run.request.backend,
343
+ text: line,
344
+ occurred_at: new Date().toISOString(),
345
+ });
346
+ }
347
+ }
348
+
349
+ async function updateRun(run, state) {
350
+ run.status = state;
351
+ run.snapshot.state = state;
352
+ run.snapshot.updated_at = new Date().toISOString();
353
+ run.completedAt = TERMINAL_STATES.has(state) ? run.snapshot.updated_at : null;
354
+
355
+ run.emitter.emit("event", { type: "run", run: toLedgerSnapshot(run.snapshot) });
356
+ const latestEvent = run.snapshot.events.at(-1);
357
+ run.emitter.emit("event", {
358
+ type: "status",
359
+ run_id: run.id,
360
+ state: toCompatibleCodingLoopState(state),
361
+ message: latestEvent?.message || "Local SIMY CLI status update.",
362
+ detail: {
363
+ ...(latestEvent?.detail || {}),
364
+ pr_lifecycle_state: state,
365
+ },
366
+ last_agent_output: run.lastOutput,
367
+ occurred_at: run.snapshot.updated_at,
368
+ });
369
+ queueLedgerUpdate(run);
370
+ }
371
+
372
+ function queueLedgerUpdate(run) {
373
+ if (!run.session?.token || !run.apiOrigin) return;
374
+ run.pendingLedgerUpdate = {
375
+ state: toCompatibleCodingLoopState(run.status),
376
+ completedAt: run.completedAt,
377
+ snapshot: toLedgerSnapshot(run.snapshot),
378
+ };
379
+ if (run.ledgerUpdateRunning) return;
380
+ run.ledgerUpdateRunning = true;
381
+ void drainLedgerUpdates(run);
382
+ }
383
+
384
+ async function drainLedgerUpdates(run) {
385
+ try {
386
+ while (run.pendingLedgerUpdate) {
387
+ const update = run.pendingLedgerUpdate;
388
+ run.pendingLedgerUpdate = null;
389
+ await persistRunStatus(run, update);
390
+ }
391
+ } finally {
392
+ run.ledgerUpdateRunning = false;
393
+ if (run.pendingLedgerUpdate) queueLedgerUpdate(run);
394
+ }
395
+ }
396
+
397
+ async function persistRunStatus(run, update) {
398
+ const url = new URL(`/api/local-cli/runs/${encodeURIComponent(run.id)}/status`, run.apiOrigin);
399
+ try {
400
+ const response = await fetch(url, {
401
+ method: "POST",
402
+ signal: AbortSignal.timeout(5000),
403
+ headers: {
404
+ Authorization: `Bearer ${run.session.token}`,
405
+ "Content-Type": "application/json",
406
+ },
407
+ body: JSON.stringify({
408
+ state: update.state,
409
+ last_agent_output: redactExecutionText(run.lastOutput, { maxChars: 2_000 }).text || null,
410
+ completed_at: update.completedAt,
411
+ run: update.snapshot,
412
+ }),
413
+ });
414
+ if (!response.ok) {
415
+ emitOutput(run, `SIMY ledger update failed with HTTP ${response.status}.`);
416
+ }
417
+ } catch {
418
+ // The local snapshot remains authoritative while the CLI is running.
419
+ }
420
+ }
421
+
422
+ export function toLedgerSnapshot(snapshot) {
423
+ return {
424
+ ...snapshot,
425
+ state: toCompatibleCodingLoopState(snapshot.state),
426
+ attempts: snapshot.attempts.map(redactedAttemptForLedger),
427
+ events: snapshot.events.map((item) => ({
428
+ ...item,
429
+ state: toCompatibleCodingLoopState(item.state),
430
+ detail: redactedEventDetail(item.detail, item.state),
431
+ })),
432
+ metadata: {
433
+ ...(snapshot.metadata || {}),
434
+ pr_lifecycle_state: snapshot.state,
435
+ last_agent_output: null,
436
+ local_output_persisted: true,
437
+ execution_io_persistence: "redacted",
438
+ execution_io_policy_version: EXECUTION_IO_POLICY_VERSION,
439
+ },
440
+ };
441
+ }
442
+
443
+ function redactedAttemptForLedger(attempt) {
444
+ const instruction = redactExecutionText(attempt.instruction);
445
+ const executorLogs = redactExecutionLogs(attempt.executor_logs);
446
+ const logText = executorLogs.lines.join("\n");
447
+ return {
448
+ ...attempt,
449
+ instruction: instruction.text,
450
+ instruction_sha256: executionTextSha256(instruction.text),
451
+ executor_logs: executorLogs.lines,
452
+ executor_logs_sha256: executionTextSha256(logText),
453
+ io_redaction: {
454
+ policy_version: EXECUTION_IO_POLICY_VERSION,
455
+ instruction_redacted: instruction.redacted,
456
+ instruction_truncated: instruction.truncated,
457
+ executor_logs_redacted: executorLogs.redacted,
458
+ executor_logs_truncated: executorLogs.truncated,
459
+ },
460
+ raw_output: structuredResultForLedger(attempt.raw_output),
461
+ observed_evidence: observedEvidenceForLedger(attempt.observed_evidence),
462
+ };
463
+ }
464
+
465
+ function redactedEventDetail(value, lifecycleState) {
466
+ const detail = value && typeof value === "object" ? value : {};
467
+ const result = {};
468
+ for (const [key, item] of Object.entries(detail)) {
469
+ if (["instruction", "reinstruction", "prompt", "last_agent_output"].includes(key)) {
470
+ result[key] = redactExecutionText(item, { maxChars: 24_000 }).text;
471
+ } else if (["executor_logs", "stdout", "stderr"].includes(key)) {
472
+ const logs = Array.isArray(item) ? item : [item];
473
+ result[key] = redactExecutionLogs(logs).lines;
474
+ } else {
475
+ result[key] = item;
476
+ }
477
+ }
478
+ result.pr_lifecycle_state = lifecycleState;
479
+ return result;
480
+ }
481
+
482
+ function observedEvidenceForLedger(value) {
483
+ if (!value || typeof value !== "object") return null;
484
+ const local = value.local && typeof value.local === "object" ? value.local : {};
485
+ const github = value.github && typeof value.github === "object" ? value.github : {};
486
+ return {
487
+ collected_at: value.collected_at || null,
488
+ local: {
489
+ available: local.available === true,
490
+ head_sha: local.head_sha || null,
491
+ branch_name: local.branch_name || null,
492
+ commit_headline: local.commit_headline || null,
493
+ working_tree_clean: local.working_tree_clean === true,
494
+ changed_files: stringArrayForLedger(local.changed_files),
495
+ },
496
+ github: {
497
+ available: github.available === true,
498
+ url: github.url || null,
499
+ number: Number.isInteger(github.number) ? github.number : null,
500
+ title: github.title || null,
501
+ repository: github.repository || null,
502
+ is_draft: github.is_draft === true,
503
+ mergeable: github.mergeable || null,
504
+ merge_state_status: github.merge_state_status || null,
505
+ review_decision: github.review_decision || null,
506
+ approvals: stringArrayForLedger(github.approvals),
507
+ head_branch: github.head_branch || null,
508
+ head_sha: github.head_sha || null,
509
+ base_branch: github.base_branch || null,
510
+ checks: Array.isArray(github.checks) ? github.checks : [],
511
+ checks_present: github.checks_present === true,
512
+ checks_passing: github.checks_passing === true,
513
+ required_checks_present: github.required_checks_present === true,
514
+ },
515
+ };
516
+ }
517
+
518
+ function stringArrayForLedger(value) {
519
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
520
+ }
521
+
522
+ export function toCompatibleCodingLoopState(state) {
523
+ switch (state) {
524
+ case "risk_classifying":
525
+ return "chartering";
526
+ case "independent_auditing":
527
+ case "checking_pr":
528
+ return "auditing";
529
+ case "pr_ready_for_review":
530
+ return "waiting_human";
531
+ case "merge_ready":
532
+ return "done";
533
+ default:
534
+ return state;
535
+ }
536
+ }
537
+
538
+ function structuredResultForLedger(value) {
539
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
540
+ const allowed = new Set([
541
+ "outcome_kind",
542
+ "summary",
543
+ "branch_name",
544
+ "commit_sha",
545
+ "commit_message_headline",
546
+ "pr_url",
547
+ "pr_number",
548
+ "pr_title",
549
+ "pr_base_branch",
550
+ "tests_run",
551
+ "tests_passed",
552
+ "ui_evidence_path",
553
+ "unrelated_changes_detected",
554
+ "secret_scan_passed",
555
+ "changed_files",
556
+ "residual_risks",
557
+ "blocked",
558
+ ]);
559
+ return Object.fromEntries(Object.entries(value).filter(([key]) => allowed.has(key)));
560
+ }
561
+
562
+ function preflightAudit(message) {
563
+ const finding = {
564
+ code: "LOCAL_REPOSITORY_NOT_FOUND",
565
+ title: "Local repository not found",
566
+ severity: "blocker",
567
+ target: "repository",
568
+ explanation: message,
569
+ repairability: "manual",
570
+ auto_fix_hint: null,
571
+ };
572
+ return {
573
+ passed: false,
574
+ summary: "Local execution requires a verified repository checkout.",
575
+ evaluations: [],
576
+ findings: [finding],
577
+ auto_repairable: false,
578
+ requires_human: true,
579
+ reinstruction: null,
580
+ evidence_artifacts: [],
581
+ release_gate: null,
582
+ work_log_signals: [],
583
+ work_log_interventions: [],
584
+ };
585
+ }
@@ -0,0 +1,41 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ const SESSION_PATH = join(homedir(), ".simy", "session.json");
6
+ const SESSION_TTL_MS = 48 * 60 * 60 * 1000;
7
+
8
+ export function sessionPath() {
9
+ return SESSION_PATH;
10
+ }
11
+
12
+ export function expiresAtFromNow(now = Date.now()) {
13
+ return new Date(now + SESSION_TTL_MS).toISOString();
14
+ }
15
+
16
+ export async function readSession() {
17
+ try {
18
+ const raw = await readFile(SESSION_PATH, "utf8");
19
+ const parsed = JSON.parse(raw);
20
+ if (!parsed || typeof parsed !== "object") return null;
21
+ if (typeof parsed.token !== "string" || typeof parsed.expires_at !== "string") return null;
22
+ return parsed;
23
+ } catch (err) {
24
+ if (err && err.code === "ENOENT") return null;
25
+ throw err;
26
+ }
27
+ }
28
+
29
+ export function isSessionValid(session, now = Date.now()) {
30
+ if (!session?.expires_at) return false;
31
+ const expiresAt = Date.parse(session.expires_at);
32
+ return Number.isFinite(expiresAt) && expiresAt > now;
33
+ }
34
+
35
+ export async function writeSession(session) {
36
+ await mkdir(dirname(SESSION_PATH), { recursive: true, mode: 0o700 });
37
+ await writeFile(SESSION_PATH, `${JSON.stringify(session, null, 2)}\n`, {
38
+ encoding: "utf8",
39
+ mode: 0o600,
40
+ });
41
+ }