@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/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # SIMY CLI
2
+
3
+ Local execution agent for SIMY coding loops.
4
+
5
+ ```bash
6
+ npx @awak-app/simy-cli
7
+ npm install -g @awak-app/simy-cli
8
+ simy
9
+ simy --daemon
10
+ ```
11
+
12
+ The CLI starts a loopback HTTP agent on a random available port, keeps a
13
+ 48-hour local session, and owns the coding-loop state machine when the SIMY
14
+ web UI sends a backend-issued launch challenge. It builds the requirement
15
+ charter, runs Codex or Claude Code, audits structured completion evidence, and
16
+ re-instructs the executor within the configured attempt budget.
17
+
18
+ Structured run snapshots and process events are persisted remotely. Executor
19
+ instructions and the latest bounded log lines are redacted at both the CLI and
20
+ Web ingestion boundaries before they are persisted with SHA-256 integrity
21
+ hashes. Raw transcripts, environment values, and unbounded stdout are never
22
+ stored remotely; live raw output is streamed directly from localhost to the
23
+ active Web UI. The persisted snapshot records the redaction policy and whether
24
+ input or output was redacted or truncated.
25
+
26
+ The local PR lifecycle is intentionally separate from deployment:
27
+
28
+ 1. Classify request risk and build a requirement charter.
29
+ 2. Require explicit acceptance criteria plus an approved design summary for high-risk work.
30
+ 3. Run the coding executor and verify its result against local Git evidence.
31
+ 4. Run a separate AI audit session without granting it an implementation role.
32
+ 5. Mark the result `pr_ready_for_review` while GitHub checks or peer approval are pending.
33
+ 6. Mark it `merge_ready` only after the observed PR head/base, CI checks, merge state,
34
+ and human approval all pass.
35
+
36
+ The Web UI can refresh review and CI evidence with
37
+ `POST /v1/coding-loop/:run_id/recheck`. Passing the local implementation gate
38
+ does not by itself make a PR merge-ready.
39
+
40
+ Start `simy` from the selected repository or a workspace containing it. Set
41
+ `SIMY_REPO_ROOT` when repositories live under a different root. The CLI
42
+ verifies the checkout's GitHub `origin` before starting an executor.
43
+
44
+ ## Publishing
45
+
46
+ Publishing is handled by GitHub Actions in `.github/workflows/publish.yml`.
47
+
48
+ - Every push tag matching `v*.*.*` runs checks and publishes to npm.
49
+ - Manual workflow runs perform the same checks; set `publish=true` to publish.
50
+ - The repository must define an `NPM_TOKEN` secret with publish access to
51
+ `@awak-app/simy-cli`.
52
+ - The package check inspects the final npm tarball contents and fails if any
53
+ source map file would be published.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@awak-app/simy-cli",
3
+ "version": "0.1.0",
4
+ "description": "Local SIMY coding-loop agent for Codex and Claude Code.",
5
+ "type": "module",
6
+ "bin": {
7
+ "simy": "src/index.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "start": "node ./src/index.js",
15
+ "test": "node --test",
16
+ "check": "npm run check:syntax && npm test && npm run check:package",
17
+ "check:syntax": "node --check ./src/index.js && node --check ./src/agent.js && node --check ./src/session-store.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./scripts/check-package-contents.js",
18
+ "check:package": "node ./scripts/check-package-contents.js"
19
+ },
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/SpecialistDoctors-Inc/simy-cli.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/SpecialistDoctors-Inc/simy-cli/issues"
29
+ },
30
+ "homepage": "https://github.com/SpecialistDoctors-Inc/simy-cli#readme",
31
+ "license": "UNLICENSED",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }
package/src/agent.js ADDED
@@ -0,0 +1,270 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes } from "node:crypto";
3
+
4
+ import {
5
+ createRun,
6
+ LocalRunRegistry,
7
+ recheckLocalCodingRun,
8
+ startLocalCodingRun,
9
+ toCompatibleCodingLoopState,
10
+ toLedgerSnapshot,
11
+ } from "./runner.js";
12
+ import {
13
+ expiresAtFromNow,
14
+ isSessionValid,
15
+ readSession,
16
+ sessionPath,
17
+ writeSession,
18
+ } from "./session-store.js";
19
+
20
+ const DEFAULT_WEB_ORIGIN = "http://localhost:3000";
21
+ const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$|^https:\/\/app\.simy\.one$/;
22
+
23
+ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
24
+ const apiOrigin = process.env.SIMY_API_ORIGIN || DEFAULT_WEB_ORIGIN;
25
+ const registry = new LocalRunRegistry();
26
+ const authNonce = randomBytes(16).toString("base64url");
27
+ let session = await readSession();
28
+
29
+ const server = createServer(async (req, res) => {
30
+ try {
31
+ applyCors(req, res);
32
+ if (req.method === "OPTIONS") {
33
+ res.writeHead(204).end();
34
+ return;
35
+ }
36
+
37
+ const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
38
+ if (req.method === "GET" && url.pathname === "/v1/health") {
39
+ json(res, 200, {
40
+ ok: true,
41
+ daemon,
42
+ session_valid: isSessionValid(session),
43
+ session_expires_at: session?.expires_at ?? null,
44
+ });
45
+ return;
46
+ }
47
+ if (req.method === "GET" && url.pathname === "/v1/capabilities") {
48
+ json(res, 200, await capabilities());
49
+ return;
50
+ }
51
+ if (req.method === "POST" && url.pathname === "/v1/auth/complete") {
52
+ const body = await readJson(req);
53
+ if (body.nonce !== authNonce) {
54
+ json(res, 401, { error: "invalid nonce" });
55
+ return;
56
+ }
57
+ session = {
58
+ token: String(body.token || ""),
59
+ device_id: typeof body.device_id === "string" ? body.device_id : null,
60
+ api_origin: typeof body.api_origin === "string" ? body.api_origin : apiOrigin,
61
+ expires_at: typeof body.expires_at === "string" ? body.expires_at : expiresAtFromNow(),
62
+ };
63
+ await writeSession(session);
64
+ json(res, 200, { ok: true, expires_at: session.expires_at });
65
+ return;
66
+ }
67
+ if (req.method === "POST" && url.pathname === "/v1/coding-loop/start") {
68
+ if (!isSessionValid(session)) {
69
+ json(res, 401, { error: "simy session expired; run simy again" });
70
+ return;
71
+ }
72
+ const body = await readJson(req);
73
+ const verified = await verifyLaunchChallenge({
74
+ apiOrigin: session.api_origin || apiOrigin,
75
+ token: session.token,
76
+ runId: String(body.run_id || ""),
77
+ challenge: String(body.challenge || ""),
78
+ });
79
+ if (!verified.ok) {
80
+ json(res, 403, { error: verified.error || "launch challenge rejected" });
81
+ return;
82
+ }
83
+ const runId = String(body.run_id || "");
84
+ if (!runId) {
85
+ json(res, 400, { error: "run_id is required" });
86
+ return;
87
+ }
88
+ if (registry.has(runId)) {
89
+ json(res, 409, { error: "coding loop run already exists" });
90
+ return;
91
+ }
92
+ const run = createRun({
93
+ runId,
94
+ request: {
95
+ backend: body.backend === "claude" ? "claude" : "codex",
96
+ audit_backend:
97
+ body.audit_backend === "claude" || body.audit_backend === "codex"
98
+ ? body.audit_backend
99
+ : null,
100
+ requirement: String(body.requirement || ""),
101
+ repository: String(body.repository || ""),
102
+ local_path: typeof body.local_path === "string" ? body.local_path : null,
103
+ base_branch: typeof body.base_branch === "string" ? body.base_branch : "dev",
104
+ max_attempts: body.max_attempts,
105
+ ui_evidence_root:
106
+ typeof body.ui_evidence_root === "string" ? body.ui_evidence_root : "",
107
+ acceptance_criteria: Array.isArray(body.acceptance_criteria)
108
+ ? body.acceptance_criteria
109
+ : [],
110
+ expected_tests: Array.isArray(body.expected_tests) ? body.expected_tests : [],
111
+ expected_evidence: Array.isArray(body.expected_evidence)
112
+ ? body.expected_evidence
113
+ : [],
114
+ required_checks: Array.isArray(body.required_checks) ? body.required_checks : [],
115
+ require_human_approval: body.require_human_approval !== false,
116
+ design_summary:
117
+ typeof body.design_summary === "string" ? body.design_summary : "",
118
+ design_review_approved_by:
119
+ typeof body.design_review_approved_by === "string"
120
+ ? body.design_review_approved_by
121
+ : "",
122
+ design_review_url:
123
+ typeof body.design_review_url === "string" ? body.design_review_url : "",
124
+ must_not: Array.isArray(body.must_not) ? body.must_not : [],
125
+ proposal_id: typeof body.proposal_id === "string" ? body.proposal_id : null,
126
+ },
127
+ session,
128
+ apiOrigin: session.api_origin || apiOrigin,
129
+ });
130
+ registry.create(run);
131
+ void startLocalCodingRun(run);
132
+ json(res, 202, { ok: true, run_id: run.id });
133
+ return;
134
+ }
135
+
136
+ const recheckMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/recheck$/);
137
+ if (req.method === "POST" && recheckMatch) {
138
+ const run = registry.get(decodeURIComponent(recheckMatch[1]));
139
+ if (!run) {
140
+ json(res, 404, { error: "run not found" });
141
+ return;
142
+ }
143
+ if (!["pr_ready_for_review", "merge_ready"].includes(run.status)) {
144
+ json(res, 409, { error: "run is not ready for PR evidence recheck" });
145
+ return;
146
+ }
147
+ const snapshot = await recheckLocalCodingRun(run);
148
+ json(res, 200, { ok: true, run_id: run.id, state: snapshot.state });
149
+ return;
150
+ }
151
+
152
+ const streamMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/stream$/);
153
+ if (req.method === "GET" && streamMatch) {
154
+ streamRun(res, registry.get(decodeURIComponent(streamMatch[1])));
155
+ return;
156
+ }
157
+
158
+ json(res, 404, { error: "not found" });
159
+ } catch (err) {
160
+ json(res, 500, { error: err instanceof Error ? err.message : "internal error" });
161
+ }
162
+ });
163
+
164
+ await new Promise((resolve) => server.listen(requestedPort, "127.0.0.1", resolve));
165
+ const address = server.address();
166
+ const port = typeof address === "object" && address ? address.port : requestedPort;
167
+ console.log(`SIMY local agent listening on http://127.0.0.1:${port}`);
168
+
169
+ if (!isSessionValid(session)) {
170
+ const loginUrl = new URL("/local-cli/connect", apiOrigin);
171
+ loginUrl.searchParams.set("port", String(port));
172
+ loginUrl.searchParams.set("nonce", authNonce);
173
+ console.log(`Sign in to SIMY: ${loginUrl.toString()}`);
174
+ console.log(`Session file: ${sessionPath()}`);
175
+ }
176
+
177
+ return { server, port };
178
+ }
179
+
180
+ async function capabilities() {
181
+ const [codex, claude] = await Promise.all([commandAvailable("codex"), commandAvailable("claude")]);
182
+ return {
183
+ backends: { codex, claude },
184
+ session_ttl_hours: 48,
185
+ };
186
+ }
187
+
188
+ async function commandAvailable(command) {
189
+ const { spawn } = await import("node:child_process");
190
+ return new Promise((resolve) => {
191
+ let settled = false;
192
+ let timeout;
193
+ const child = spawn(command, ["--version"], { stdio: "ignore" });
194
+ const finish = (available) => {
195
+ if (settled) return;
196
+ settled = true;
197
+ if (timeout) clearTimeout(timeout);
198
+ resolve(available);
199
+ };
200
+ timeout = setTimeout(() => {
201
+ child.kill("SIGTERM");
202
+ finish(false);
203
+ }, 1500);
204
+ child.on("error", () => finish(false));
205
+ child.on("close", (code) => finish(code === 0));
206
+ });
207
+ }
208
+
209
+ async function verifyLaunchChallenge({ apiOrigin, token, runId, challenge }) {
210
+ try {
211
+ const response = await fetch(new URL("/api/local-cli/challenges/verify", apiOrigin), {
212
+ method: "POST",
213
+ headers: {
214
+ Authorization: `Bearer ${token}`,
215
+ "Content-Type": "application/json",
216
+ },
217
+ body: JSON.stringify({ run_id: runId, challenge }),
218
+ });
219
+ if (response.ok) return { ok: true };
220
+ const payload = await response.json().catch(() => null);
221
+ return { ok: false, error: payload?.error ? String(payload.error) : `HTTP ${response.status}` };
222
+ } catch (err) {
223
+ return { ok: false, error: err instanceof Error ? err.message : "challenge check failed" };
224
+ }
225
+ }
226
+
227
+ function streamRun(res, run) {
228
+ if (!run) {
229
+ json(res, 404, { error: "run not found" });
230
+ return;
231
+ }
232
+ res.writeHead(200, {
233
+ "Content-Type": "text/event-stream",
234
+ "Cache-Control": "no-cache, no-transform",
235
+ Connection: "keep-alive",
236
+ });
237
+ const send = (event) => res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
238
+ const listener = (event) => send(event);
239
+ run.emitter.on("event", listener);
240
+ send({ type: "run", run: toLedgerSnapshot(run.snapshot) });
241
+ send({
242
+ type: "status",
243
+ run_id: run.id,
244
+ state: toCompatibleCodingLoopState(run.status),
245
+ pr_lifecycle_state: run.status,
246
+ last_agent_output: run.lastOutput,
247
+ });
248
+ res.on("close", () => run.emitter.off("event", listener));
249
+ }
250
+
251
+ function applyCors(req, res) {
252
+ const origin = req.headers.origin;
253
+ if (origin && ALLOWED_ORIGIN_RE.test(origin)) {
254
+ res.setHeader("Access-Control-Allow-Origin", origin);
255
+ res.setHeader("Vary", "Origin");
256
+ }
257
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
258
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
259
+ }
260
+
261
+ function json(res, status, payload) {
262
+ res.writeHead(status, { "Content-Type": "application/json" });
263
+ res.end(JSON.stringify(payload));
264
+ }
265
+
266
+ async function readJson(req) {
267
+ let raw = "";
268
+ for await (const chunk of req) raw += chunk;
269
+ return raw ? JSON.parse(raw) : {};
270
+ }
package/src/index.js ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { startAgent } from "./agent.js";
7
+
8
+ const args = new Set(process.argv.slice(2));
9
+
10
+ if (args.has("--help") || args.has("-h")) {
11
+ console.log(`Usage:
12
+ simy Start the local SIMY agent
13
+ simy --daemon Start it in the background
14
+
15
+ Options:
16
+ --daemon, --deamon Run detached in the background
17
+ --port <port> Bind a specific localhost port
18
+ `);
19
+ process.exit(0);
20
+ }
21
+
22
+ const daemon = args.has("--daemon") || args.has("--deamon");
23
+ const port = readPort(process.argv.slice(2));
24
+
25
+ if (daemon && process.env.SIMY_DAEMON_CHILD !== "1") {
26
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
27
+ detached: true,
28
+ stdio: "ignore",
29
+ env: { ...process.env, SIMY_DAEMON_CHILD: "1" },
30
+ });
31
+ child.unref();
32
+ console.log("SIMY local agent started in the background.");
33
+ process.exit(0);
34
+ }
35
+
36
+ await startAgent({ requestedPort: port, daemon });
37
+
38
+ function readPort(argv) {
39
+ const index = argv.indexOf("--port");
40
+ if (index === -1) return 0;
41
+ const value = Number(argv[index + 1]);
42
+ if (!Number.isInteger(value) || value < 1024 || value > 65535) {
43
+ console.error("--port must be an integer between 1024 and 65535");
44
+ process.exit(2);
45
+ }
46
+ return value;
47
+ }