@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 +53 -0
- package/package.json +35 -0
- package/src/agent.js +270 -0
- package/src/index.js +47 -0
- package/src/orchestrator/audit.js +317 -0
- package/src/orchestrator/contract.js +125 -0
- package/src/orchestrator/evidence.js +184 -0
- package/src/orchestrator/execution-io.js +83 -0
- package/src/orchestrator/independent-audit.js +83 -0
- package/src/orchestrator/index.js +5 -0
- package/src/orchestrator/instruction.js +123 -0
- package/src/orchestrator/loop.js +341 -0
- package/src/orchestrator/result.js +91 -0
- package/src/orchestrator/risk.js +63 -0
- package/src/orchestrator/shared.js +60 -0
- package/src/runner.js +585 -0
- package/src/session-store.js +41 -0
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
|
+
}
|