@awak-app/simy-cli 0.1.1 → 0.1.2

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.
@@ -0,0 +1,100 @@
1
+ const HELP_LINES = [
2
+ "/new Start composing a new coding task",
3
+ "/repos Choose from authorized local Git repositories",
4
+ "/scan [path] Review and authorize a local repository scan",
5
+ "/repo <owner/name> Set the GitHub repository for the next task",
6
+ "/branch <name> Set the base branch for the next task",
7
+ "/executor <name> Select codex or claude",
8
+ "/attach <path> Add a local file path to the next task",
9
+ "/attachments clear Clear local attachment paths",
10
+ "/continue <guidance> Continue a waiting run with human guidance",
11
+ "/approve <summary> Approve the local design gate with a recorded note",
12
+ "/criteria <a | b> Replace acceptance criteria with explicit values",
13
+ "/checks <a, b> Set required CI/security check names",
14
+ "/recheck Refresh pull request readiness",
15
+ "/pause Pause the active executor process",
16
+ "/resume Resume a paused executor process",
17
+ "/stop Stop the selected run",
18
+ ];
19
+
20
+ export function parseConsoleCommand(value) {
21
+ const text = String(value || "").trim();
22
+ if (!text) return { type: "empty" };
23
+ if (!text.startsWith("/")) return { type: "guidance", message: text };
24
+
25
+ const separator = text.indexOf(" ");
26
+ const command = (separator === -1 ? text : text.slice(0, separator)).toLowerCase();
27
+ const argument = separator === -1 ? "" : text.slice(separator + 1).trim();
28
+ switch (command) {
29
+ case "/new":
30
+ return { type: "new" };
31
+ case "/repos":
32
+ return { type: "repositories" };
33
+ case "/scan":
34
+ return { type: "scan", root: argument || null };
35
+ case "/repo":
36
+ return requiredConfig("repository", argument, "/repo requires owner/name.");
37
+ case "/branch":
38
+ return requiredConfig("branch", argument, "/branch requires a branch name.");
39
+ case "/executor":
40
+ return argument === "codex" || argument === "claude"
41
+ ? { type: "configure", field: "backend", value: argument }
42
+ : { type: "error", message: "/executor requires codex or claude." };
43
+ case "/attach":
44
+ return requiredConfig("attachment", argument, "/attach requires a local file path.");
45
+ case "/attachments":
46
+ return argument === "clear"
47
+ ? { type: "configure", field: "clearAttachments", value: true }
48
+ : { type: "error", message: "/attachments supports only: clear" };
49
+ case "/continue":
50
+ return argument
51
+ ? { type: "guidance", message: argument }
52
+ : { type: "error", message: "/continue requires guidance." };
53
+ case "/approve":
54
+ return argument
55
+ ? { type: "decision", message: argument, designApproval: true }
56
+ : { type: "error", message: "/approve requires a design or approval summary." };
57
+ case "/criteria":
58
+ return listCommand("acceptanceCriteria", argument, "|");
59
+ case "/checks":
60
+ return listCommand("requiredChecks", argument, ",");
61
+ case "/recheck":
62
+ return { type: "recheck" };
63
+ case "/pause":
64
+ return { type: "pause" };
65
+ case "/resume":
66
+ return { type: "resume" };
67
+ case "/stop":
68
+ return { type: "stop" };
69
+ case "/help":
70
+ return { type: "help" };
71
+ default:
72
+ return { type: "error", message: `Unknown command: ${command}` };
73
+ }
74
+ }
75
+
76
+ function requiredConfig(field, value, message) {
77
+ return value ? { type: "configure", field, value } : { type: "error", message };
78
+ }
79
+
80
+ export function consoleHelpLines() {
81
+ return [...HELP_LINES];
82
+ }
83
+
84
+ function listCommand(field, argument, separator) {
85
+ const values = argument
86
+ .split(separator)
87
+ .map((item) => item.trim())
88
+ .filter(Boolean);
89
+ if (values.length === 0) {
90
+ return {
91
+ type: "error",
92
+ message: `/${field === "requiredChecks" ? "checks" : "criteria"} requires at least one value.`,
93
+ };
94
+ }
95
+ return {
96
+ type: "decision",
97
+ message: `Human updated ${field === "requiredChecks" ? "required checks" : "acceptance criteria"}.`,
98
+ [field]: values,
99
+ };
100
+ }
@@ -0,0 +1,25 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import { render } from "ink";
3
+
4
+ import { CodingLoopConsole } from "./app.js";
5
+
6
+ export async function runCodingLoopConsole(agent, options = {}) {
7
+ const instance = render(
8
+ jsx(CodingLoopConsole, {
9
+ agent,
10
+ onQuit: options.onQuit,
11
+ }),
12
+ {
13
+ stdin: options.stdin ?? process.stdin,
14
+ stdout: options.stdout ?? process.stdout,
15
+ stderr: options.stderr ?? process.stderr,
16
+ exitOnCtrlC: false,
17
+ patchConsole: false,
18
+ debug: false,
19
+ },
20
+ );
21
+ await instance.waitUntilExit();
22
+ }
23
+
24
+ export { CodingLoopConsole } from "./app.js";
25
+ export { consoleHelpLines, parseConsoleCommand } from "./commands.js";
package/src/index.js CHANGED
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  import { startAgent } from "./agent.js";
7
+ import { openAuthorizationUrl } from "./browser.js";
7
8
  import { DEFAULT_WEB_ORIGIN, resolveWebOrigin } from "./web-origin.js";
8
9
 
9
10
  const argv = process.argv.slice(2);
@@ -13,9 +14,12 @@ if (args.has("--help") || args.has("-h")) {
13
14
  console.log(`Usage:
14
15
  simy Start the local SIMY agent
15
16
  simy --daemon Start it in the background
17
+ simy --no-tui Start the foreground HTTP agent without the interactive console
16
18
 
17
19
  Options:
18
20
  --daemon, --deamon Run detached in the background
21
+ --no-tui Disable the interactive Coding Loop console
22
+ --no-open Do not open the browser when authorization is required
19
23
  --port <port> Bind a specific localhost port
20
24
  --host <url> Connect to a SIMY Web origin (default: ${DEFAULT_WEB_ORIGIN})
21
25
  `);
@@ -23,6 +27,7 @@ Options:
23
27
  }
24
28
 
25
29
  const daemon = args.has("--daemon") || args.has("--deamon");
30
+ const interactive = !daemon && !args.has("--no-tui") && process.stdin.isTTY && process.stdout.isTTY;
26
31
  const port = readPort(argv);
27
32
  let webOrigin;
28
33
  try {
@@ -43,7 +48,18 @@ if (daemon && process.env.SIMY_DAEMON_CHILD !== "1") {
43
48
  process.exit(0);
44
49
  }
45
50
 
46
- await startAgent({ requestedPort: port, daemon, webOrigin });
51
+ const agent = await startAgent({ requestedPort: port, daemon, webOrigin, quiet: interactive });
52
+ if (agent.loginUrl && !args.has("--no-open")) {
53
+ await openAuthorizationUrl(agent.loginUrl);
54
+ }
55
+ if (interactive) {
56
+ const { runCodingLoopConsole } = await import("./console/index.js");
57
+ try {
58
+ await runCodingLoopConsole(agent);
59
+ } finally {
60
+ await closeServer(agent.server);
61
+ }
62
+ }
47
63
 
48
64
  function readPort(argv) {
49
65
  const index = argv.indexOf("--port");
@@ -65,3 +81,8 @@ function readOption(argv, name) {
65
81
  }
66
82
  return value;
67
83
  }
84
+
85
+ function closeServer(server) {
86
+ if (!server.listening) return Promise.resolve();
87
+ return new Promise((resolve) => server.close(resolve));
88
+ }
@@ -0,0 +1,270 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, readdir, realpath, rm, stat } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export const MAX_ATTACHMENT_COUNT = 5;
7
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
8
+ export const MAX_ATTACHMENTS_TOTAL_BYTES = 50 * 1024 * 1024;
9
+ const RUN_RETENTION_MS = 24 * 60 * 60 * 1000;
10
+ const SAFE_RUN_ID = /^coding_loop_[A-Za-z0-9_-]+$/;
11
+ const ALLOWED_EXACT_MIME_TYPES = new Set([
12
+ "application/json",
13
+ "application/octet-stream",
14
+ "application/pdf",
15
+ "application/rtf",
16
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
17
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
18
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
19
+ "application/zip",
20
+ "application/x-yaml",
21
+ "image/gif",
22
+ "image/jpeg",
23
+ "image/png",
24
+ "image/svg+xml",
25
+ "image/webp",
26
+ ]);
27
+
28
+ export function runsRoot(override) {
29
+ return path.resolve(
30
+ override || process.env.SIMY_RUNS_ROOT || path.join(os.homedir(), ".simy", "runs"),
31
+ );
32
+ }
33
+
34
+ export async function stageRunAttachments({ runId, attachments, manifest, root }) {
35
+ if (!SAFE_RUN_ID.test(runId)) throw new Error("invalid coding loop run_id");
36
+ if (!Array.isArray(attachments) || attachments.length === 0) return [];
37
+ if (attachments.length > MAX_ATTACHMENT_COUNT) {
38
+ throw new Error(`at most ${MAX_ATTACHMENT_COUNT} attachments are allowed`);
39
+ }
40
+ if (manifest !== undefined && (!Array.isArray(manifest) || manifest.length !== attachments.length)) {
41
+ throw new Error("attachment manifest does not match uploaded files");
42
+ }
43
+
44
+ const runRoot = path.join(runsRoot(root), runId);
45
+ const inputsRoot = path.join(runRoot, "inputs");
46
+ await mkdir(runsRoot(root), { recursive: true, mode: 0o700 });
47
+ await mkdir(runRoot, { mode: 0o700 });
48
+ await mkdir(inputsRoot, { mode: 0o700 });
49
+ const staged = [];
50
+ let totalBytes = 0;
51
+
52
+ try {
53
+ for (const [index, attachment] of attachments.entries()) {
54
+ const name = safeAttachmentName(attachment.name);
55
+ const mimeType = normalizeMimeType(attachment.type);
56
+ if (!isAllowedMimeType(mimeType)) {
57
+ throw new Error(`attachment ${name} has unsupported MIME type ${mimeType || "unknown"}`);
58
+ }
59
+ const bytes = Buffer.from(await attachment.arrayBuffer());
60
+ if (bytes.length > MAX_ATTACHMENT_BYTES) {
61
+ throw new Error(`attachment ${name} exceeds the 20 MB limit`);
62
+ }
63
+ totalBytes += bytes.length;
64
+ if (totalBytes > MAX_ATTACHMENTS_TOTAL_BYTES) {
65
+ throw new Error("attachments exceed the 50 MB total limit");
66
+ }
67
+
68
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
69
+ if (manifest) verifyManifestEntry(manifest[index], { name, mimeType, size: bytes.length, sha256 });
70
+
71
+ const id = `attachment_${randomUUID()}`;
72
+ const localPath = path.join(inputsRoot, `${id}-${name}`);
73
+ const handle = await open(localPath, "wx", 0o600);
74
+ try {
75
+ await handle.writeFile(bytes);
76
+ } finally {
77
+ await handle.close();
78
+ }
79
+ staged.push({
80
+ id,
81
+ name,
82
+ mime_type: mimeType,
83
+ size_bytes: bytes.length,
84
+ sha256,
85
+ local_path: localPath,
86
+ integrity_status: "verified",
87
+ staged_at: new Date().toISOString(),
88
+ executor_handoff_status: "pending",
89
+ delivered_at: null,
90
+ cleanup_status: "pending",
91
+ cleaned_at: null,
92
+ cleanup_error: null,
93
+ storage: "managed_copy",
94
+ });
95
+ }
96
+ return staged;
97
+ } catch (error) {
98
+ await rm(runRoot, { recursive: true, force: true });
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ export async function cleanupRunAttachments(attachments) {
104
+ const localPath = attachments?.find(
105
+ (item) => item?.storage === "managed_copy" && typeof item?.local_path === "string",
106
+ )?.local_path;
107
+ if (!localPath) return;
108
+ await rm(path.dirname(path.dirname(localPath)), { recursive: true, force: true });
109
+ }
110
+
111
+ export async function referenceLocalAttachmentPaths(values) {
112
+ const paths = [...new Set((values || []).map((value) => path.resolve(String(value))))];
113
+ if (paths.length > MAX_ATTACHMENT_COUNT) {
114
+ throw new Error(`at most ${MAX_ATTACHMENT_COUNT} attachments are allowed`);
115
+ }
116
+ let totalBytes = 0;
117
+ const attachments = [];
118
+ for (const value of paths) {
119
+ const resolved = await realpath(value);
120
+ const details = await stat(resolved);
121
+ if (!details.isFile()) throw new Error(`${value} is not a file`);
122
+ if (details.size > MAX_ATTACHMENT_BYTES) {
123
+ throw new Error(`${path.basename(resolved)} exceeds the 20 MB limit`);
124
+ }
125
+ totalBytes += details.size;
126
+ if (totalBytes > MAX_ATTACHMENTS_TOTAL_BYTES) {
127
+ throw new Error("attachments exceed the 50 MB total limit");
128
+ }
129
+ const bytes = await readFile(resolved);
130
+ attachments.push({
131
+ id: `attachment_${randomUUID()}`,
132
+ name: safeAttachmentName(path.basename(resolved)),
133
+ mime_type: inferMimeType(resolved),
134
+ size_bytes: details.size,
135
+ sha256: createHash("sha256").update(bytes).digest("hex"),
136
+ local_path: resolved,
137
+ integrity_status: "verified",
138
+ staged_at: new Date().toISOString(),
139
+ executor_handoff_status: "pending",
140
+ delivered_at: null,
141
+ cleanup_status: "not_required",
142
+ cleaned_at: null,
143
+ cleanup_error: null,
144
+ storage: "source_path",
145
+ });
146
+ }
147
+ return attachments;
148
+ }
149
+
150
+ export async function cleanupExpiredRuns({ root, now = Date.now() } = {}) {
151
+ const base = runsRoot(root);
152
+ let entries;
153
+ try {
154
+ entries = await readdir(base, { withFileTypes: true });
155
+ } catch (error) {
156
+ if (error?.code === "ENOENT") return;
157
+ throw error;
158
+ }
159
+ await Promise.all(
160
+ entries
161
+ .filter((entry) => entry.isDirectory() && SAFE_RUN_ID.test(entry.name))
162
+ .map(async (entry) => {
163
+ const target = path.join(base, entry.name);
164
+ const details = await stat(target);
165
+ if (now - details.mtimeMs > RUN_RETENTION_MS) {
166
+ await rm(target, { recursive: true, force: true });
167
+ }
168
+ }),
169
+ );
170
+ }
171
+
172
+ export function attachmentDescriptorForLedger(attachment) {
173
+ return {
174
+ id: attachment.id,
175
+ name: attachment.name,
176
+ mime_type: attachment.mime_type,
177
+ size_bytes: attachment.size_bytes,
178
+ sha256: attachment.sha256,
179
+ integrity_status: attachment.integrity_status || "verified",
180
+ staged_at: attachment.staged_at || null,
181
+ executor_handoff_status: attachment.executor_handoff_status || "pending",
182
+ delivered_at: attachment.delivered_at || null,
183
+ cleanup_status: attachment.cleanup_status || "pending",
184
+ cleaned_at: attachment.cleaned_at || null,
185
+ cleanup_error: attachment.cleanup_error || null,
186
+ };
187
+ }
188
+
189
+ export function markAttachmentsDelivered(attachments, occurredAt = new Date().toISOString()) {
190
+ for (const attachment of attachments || []) {
191
+ attachment.executor_handoff_status = "delivered";
192
+ attachment.delivered_at = occurredAt;
193
+ }
194
+ }
195
+
196
+ export function markAttachmentsCleaned(attachments, occurredAt = new Date().toISOString()) {
197
+ for (const attachment of attachments || []) {
198
+ if (attachment.storage === "source_path") continue;
199
+ attachment.cleanup_status = "cleaned";
200
+ attachment.cleaned_at = occurredAt;
201
+ attachment.cleanup_error = null;
202
+ }
203
+ }
204
+
205
+ export function markAttachmentsCleanupFailed(attachments, error) {
206
+ const message =
207
+ error instanceof Error ? error.message : String(error || "attachment cleanup failed");
208
+ for (const attachment of attachments || []) {
209
+ if (attachment.storage === "source_path") continue;
210
+ attachment.cleanup_status = "failed";
211
+ attachment.cleanup_error = message;
212
+ }
213
+ }
214
+
215
+ function safeAttachmentName(value) {
216
+ const name = String(value || "")
217
+ .normalize("NFKC")
218
+ .replace(/[\u0000-\u001f\u007f]/g, "")
219
+ .trim();
220
+ if (!name || name !== path.basename(name) || name === "." || name === "..") {
221
+ throw new Error("attachment has an invalid file name");
222
+ }
223
+ if (name.length > 180) throw new Error("attachment file name exceeds 180 characters");
224
+ return name;
225
+ }
226
+
227
+ function normalizeMimeType(value) {
228
+ return String(value || "application/octet-stream")
229
+ .split(";", 1)[0]
230
+ .trim()
231
+ .toLowerCase();
232
+ }
233
+
234
+ function isAllowedMimeType(value) {
235
+ return value.startsWith("text/") || ALLOWED_EXACT_MIME_TYPES.has(value);
236
+ }
237
+
238
+ function inferMimeType(value) {
239
+ const extension = path.extname(value).toLowerCase();
240
+ return (
241
+ {
242
+ ".gif": "image/gif",
243
+ ".jpeg": "image/jpeg",
244
+ ".jpg": "image/jpeg",
245
+ ".json": "application/json",
246
+ ".md": "text/markdown",
247
+ ".pdf": "application/pdf",
248
+ ".png": "image/png",
249
+ ".svg": "image/svg+xml",
250
+ ".txt": "text/plain",
251
+ ".webp": "image/webp",
252
+ ".yaml": "application/x-yaml",
253
+ ".yml": "application/x-yaml",
254
+ ".zip": "application/zip",
255
+ }[extension] || "application/octet-stream"
256
+ );
257
+ }
258
+
259
+ function verifyManifestEntry(value, actual) {
260
+ if (
261
+ !value ||
262
+ typeof value !== "object" ||
263
+ value.name !== actual.name ||
264
+ normalizeMimeType(value.mime_type) !== actual.mimeType ||
265
+ value.size_bytes !== actual.size ||
266
+ String(value.sha256 || "").toLowerCase() !== actual.sha256
267
+ ) {
268
+ throw new Error(`attachment integrity check failed for ${actual.name}`);
269
+ }
270
+ }
@@ -64,6 +64,7 @@ export function createCodingLoopSnapshot({ runId, request, metadata = {} }) {
64
64
  "include unrelated changes",
65
65
  ],
66
66
  proposal_id: cleanString(request.proposal_id) || null,
67
+ attachments: Array.isArray(request.attachments) ? request.attachments : [],
67
68
  risk,
68
69
  design_review: {
69
70
  required: risk.requires_design_review,
@@ -1,6 +1,16 @@
1
1
  import { bullets, cleanString, section } from "./shared.js";
2
2
 
3
3
  export function buildIndependentAuditInstruction(charter, attempt) {
4
+ const localEvidence = attempt.observed_evidence?.local || {};
5
+ const githubEvidence = attempt.observed_evidence?.github || {};
6
+ const evidenceArtifact = attempt.audit?.evidence_artifacts?.[0] || null;
7
+ const passingChecks = Array.isArray(githubEvidence.checks)
8
+ ? githubEvidence.checks
9
+ .filter((check) => check?.state === "success" || check?.conclusion === "SUCCESS")
10
+ .map((check) => cleanString(check.name))
11
+ .filter(Boolean)
12
+ : [];
13
+
4
14
  return [
5
15
  section(
6
16
  "role",
@@ -17,6 +27,21 @@ export function buildIndependentAuditInstruction(charter, attempt) {
17
27
  `Commit SHA: ${attempt.observed_evidence?.local?.head_sha || attempt.commit_sha || "unknown"}`,
18
28
  ].join("\n"),
19
29
  ),
30
+ section(
31
+ "verifiable_evidence",
32
+ [
33
+ `Local UI evidence path: ${attempt.ui_evidence_path || "not reported"}`,
34
+ `Local evidence verification: ${evidenceArtifact?.summary || "not available"}`,
35
+ `Reported tests:\n${bullets(attempt.tests_run || [])}`,
36
+ `Observed local branch: ${localEvidence.branch_name || "unknown"}`,
37
+ `Observed local HEAD: ${localEvidence.head_sha || "unknown"}`,
38
+ `Observed changed files:\n${bullets(localEvidence.changed_files || [])}`,
39
+ `Observed PR: ${githubEvidence.url || attempt.pr_url || "unknown"}`,
40
+ `Observed passing GitHub checks:\n${bullets(passingChecks)}`,
41
+ "The UI evidence is intentionally local-only and must not be committed. Inspect the reported path directly; do not fail it merely because screenshots are absent from the Git diff.",
42
+ "Treat reported commands and paths as leads, not proof. Verify the files, command-visible assertions, Git state, and relevant screenshots before accepting them.",
43
+ ].join("\n"),
44
+ ),
20
45
  section(
21
46
  "audit_scope",
22
47
  [
@@ -2,4 +2,4 @@ export { createCodingLoopSnapshot, newRunId } from "./contract.js";
2
2
  export { collectPrEvidence } from "./evidence.js";
3
3
  export { buildIndependentAuditInstruction } from "./independent-audit.js";
4
4
  export { recheckPrReadiness, runCodingLoop } from "./loop.js";
5
- export { parseStructuredMarker, parseStructuredResult } from "./result.js";
5
+ export { mergeTokenUsage, parseStructuredMarker, parseStructuredResult } from "./result.js";
@@ -24,6 +24,21 @@ export function buildCodingInstruction(
24
24
  section("must_not", bullets(charter.must_not)),
25
25
  ];
26
26
 
27
+ if (charter.attachments?.length > 0) {
28
+ sections.push(
29
+ section(
30
+ "local_input_attachments",
31
+ [
32
+ "These read-only files were supplied by the user. Inspect them as part of the requirement. Do not move, rename, modify, or commit them.",
33
+ ...charter.attachments.map(
34
+ (attachment) =>
35
+ `- ${attachment.name} (${attachment.mime_type}, ${attachment.size_bytes} bytes, sha256 ${attachment.sha256}): ${attachment.local_path}`,
36
+ ),
37
+ ].join("\n"),
38
+ ),
39
+ );
40
+ }
41
+
27
42
  if (previousAttempt || previousFindings.length > 0) {
28
43
  sections.push(
29
44
  section(
@@ -77,7 +92,10 @@ export function buildCodingInstruction(
77
92
  return sections.join("\n\n");
78
93
  }
79
94
 
80
- export function buildPromptInterventions(charter, { attemptNumber, previousFindings }) {
95
+ export function buildPromptInterventions(
96
+ charter,
97
+ { attemptNumber, previousFindings, humanGuidance = "" },
98
+ ) {
81
99
  const now = new Date().toISOString();
82
100
  const rows = [
83
101
  {
@@ -115,6 +133,14 @@ export function buildPromptInterventions(charter, { attemptNumber, previousFindi
115
133
  evidence_required: ["fixed_finding_codes", "rerun_report"],
116
134
  });
117
135
  }
136
+ if (humanGuidance.trim()) {
137
+ rows.push({
138
+ trigger: "human_guidance",
139
+ title: "Human guidance",
140
+ prompt: humanGuidance.trim(),
141
+ evidence_required: ["guidance_addressed"],
142
+ });
143
+ }
118
144
  return rows.map((row) => ({
119
145
  id: `${charter.id}-${row.trigger}-${attemptNumber}`,
120
146
  ...row,