@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.
- package/README.md +45 -4
- package/package.json +16 -3
- package/src/agent.js +713 -41
- package/src/backend-executable.js +44 -0
- package/src/browser.js +59 -0
- package/src/console/app.js +1042 -0
- package/src/console/commands.js +100 -0
- package/src/console/index.js +25 -0
- package/src/index.js +22 -1
- package/src/local-attachments.js +270 -0
- package/src/orchestrator/contract.js +1 -0
- package/src/orchestrator/independent-audit.js +25 -0
- package/src/orchestrator/index.js +1 -1
- package/src/orchestrator/instruction.js +27 -1
- package/src/orchestrator/loop.js +61 -25
- package/src/orchestrator/presentation.js +189 -0
- package/src/orchestrator/result.js +11 -0
- package/src/provider-stream.js +310 -0
- package/src/repository-inventory.js +186 -0
- package/src/run-registry.js +44 -0
- package/src/runner.js +525 -64
- package/src/web-api.js +66 -0
- package/src/workspace-context.js +37 -0
|
@@ -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(
|
|
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,
|