@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
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
bullets,
|
|
6
|
+
evidenceKind,
|
|
7
|
+
hasAllowedPrefix,
|
|
8
|
+
stringArray,
|
|
9
|
+
} from "./shared.js";
|
|
10
|
+
|
|
11
|
+
export async function auditAttempt(charter, attempt) {
|
|
12
|
+
const checks = [];
|
|
13
|
+
const addCheck = (id, label, passed, detail, severity = "blocker", repairability = "auto") => {
|
|
14
|
+
checks.push({ id, label, passed, severity, detail, repairability, evidence_refs: [] });
|
|
15
|
+
};
|
|
16
|
+
const local = attempt.observed_evidence?.local || {};
|
|
17
|
+
|
|
18
|
+
addCheck(
|
|
19
|
+
"executor_succeeded",
|
|
20
|
+
"Local executor completed",
|
|
21
|
+
attempt.status === "completed",
|
|
22
|
+
attempt.status === "completed" ? "Executor exited successfully." : attempt.summary,
|
|
23
|
+
);
|
|
24
|
+
addCheck(
|
|
25
|
+
"pr_url_present",
|
|
26
|
+
"PR URL recorded",
|
|
27
|
+
Boolean(attempt.pr_url),
|
|
28
|
+
attempt.pr_url || "Missing PR URL.",
|
|
29
|
+
);
|
|
30
|
+
addCheck(
|
|
31
|
+
"branch_recorded",
|
|
32
|
+
"Feature branch recorded",
|
|
33
|
+
Boolean(attempt.branch_name),
|
|
34
|
+
attempt.branch_name || "Missing feature branch.",
|
|
35
|
+
);
|
|
36
|
+
addCheck(
|
|
37
|
+
"commit_recorded",
|
|
38
|
+
"Commit SHA recorded",
|
|
39
|
+
Boolean(attempt.commit_sha),
|
|
40
|
+
attempt.commit_sha || "Missing commit SHA.",
|
|
41
|
+
);
|
|
42
|
+
addCheck(
|
|
43
|
+
"reported_base_branch_locked",
|
|
44
|
+
"Executor reported the required base branch",
|
|
45
|
+
attempt.pr_base_branch === charter.base_branch,
|
|
46
|
+
`Expected ${charter.base_branch}; observed ${attempt.pr_base_branch || "missing"}.`,
|
|
47
|
+
);
|
|
48
|
+
addCheck(
|
|
49
|
+
"local_git_evidence_available",
|
|
50
|
+
"Local Git evidence is available",
|
|
51
|
+
local.available === true,
|
|
52
|
+
local.errors?.join("; ") || "Local Git evidence collected.",
|
|
53
|
+
"blocker",
|
|
54
|
+
"manual",
|
|
55
|
+
);
|
|
56
|
+
addCheck(
|
|
57
|
+
"commit_matches_local_head",
|
|
58
|
+
"Reported commit matches local HEAD",
|
|
59
|
+
Boolean(local.head_sha && attempt.commit_sha && local.head_sha === attempt.commit_sha),
|
|
60
|
+
`Reported ${attempt.commit_sha || "missing"}; local ${local.head_sha || "missing"}.`,
|
|
61
|
+
);
|
|
62
|
+
addCheck(
|
|
63
|
+
"branch_matches_local_checkout",
|
|
64
|
+
"Reported branch matches the local checkout",
|
|
65
|
+
Boolean(local.branch_name && attempt.branch_name && local.branch_name === attempt.branch_name),
|
|
66
|
+
`Reported ${attempt.branch_name || "missing"}; local ${local.branch_name || "missing"}.`,
|
|
67
|
+
);
|
|
68
|
+
addCheck(
|
|
69
|
+
"working_tree_clean",
|
|
70
|
+
"Working tree is clean after implementation",
|
|
71
|
+
local.working_tree_clean === true,
|
|
72
|
+
local.status_lines?.join(", ") || "Working tree clean.",
|
|
73
|
+
);
|
|
74
|
+
const missingExpectedTests = charter.expected_tests.filter(
|
|
75
|
+
(command) => !attempt.tests_run.includes(command),
|
|
76
|
+
);
|
|
77
|
+
addCheck(
|
|
78
|
+
"tests_recorded_and_passing",
|
|
79
|
+
"Expected tests are recorded as passing",
|
|
80
|
+
attempt.tests_run.length > 0 &&
|
|
81
|
+
attempt.tests_passed === true &&
|
|
82
|
+
missingExpectedTests.length === 0,
|
|
83
|
+
missingExpectedTests.length > 0
|
|
84
|
+
? `Missing expected tests: ${missingExpectedTests.join(", ")}`
|
|
85
|
+
: attempt.tests_run.join(", ") || "No passing test command recorded.",
|
|
86
|
+
);
|
|
87
|
+
addCheck(
|
|
88
|
+
"commit_headline_prefix",
|
|
89
|
+
"Observed commit headline uses an allowed prefix",
|
|
90
|
+
hasAllowedPrefix(local.commit_headline),
|
|
91
|
+
local.commit_headline || "Local commit headline missing.",
|
|
92
|
+
);
|
|
93
|
+
const changedFiles = stringArray(local.changed_files);
|
|
94
|
+
const reportedFiles = stringArray(attempt.raw_output.changed_files);
|
|
95
|
+
addCheck(
|
|
96
|
+
"changed_files_match_git",
|
|
97
|
+
"Reported changed files match the local diff",
|
|
98
|
+
local.available === true && sameStrings(changedFiles, reportedFiles),
|
|
99
|
+
`Reported: ${reportedFiles.join(", ") || "none"}; local: ${changedFiles.join(", ") || "none"}.`,
|
|
100
|
+
);
|
|
101
|
+
const unintendedArtifacts = changedFiles.filter((file) =>
|
|
102
|
+
/(^|\/)(\.evidence|evidence|screenshots?|videos?)(\/|$)|\.(png|jpe?g|webp|gif|mp4|mov|webm)$/i.test(
|
|
103
|
+
file,
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
addCheck(
|
|
107
|
+
"no_unintended_artifacts",
|
|
108
|
+
"No local evidence artifacts committed",
|
|
109
|
+
unintendedArtifacts.length === 0,
|
|
110
|
+
unintendedArtifacts.length > 0
|
|
111
|
+
? `Unexpected artifacts: ${unintendedArtifacts.join(", ")}`
|
|
112
|
+
: "No committed local evidence artifacts reported.",
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const evidenceRequired = charter.expected_evidence.includes("ui_evidence_path");
|
|
116
|
+
const evidenceArtifact = await inspectEvidence(charter, attempt.ui_evidence_path);
|
|
117
|
+
if (evidenceRequired || attempt.ui_evidence_path) {
|
|
118
|
+
addCheck(
|
|
119
|
+
"evidence_exists_local_only",
|
|
120
|
+
"Local evidence exists under the required root",
|
|
121
|
+
Boolean(
|
|
122
|
+
evidenceArtifact?.exists &&
|
|
123
|
+
evidenceArtifact.under_required_root &&
|
|
124
|
+
evidenceArtifact.size_bytes > 0,
|
|
125
|
+
),
|
|
126
|
+
evidenceArtifact?.summary || "Local evidence path missing.",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const findings = checks
|
|
131
|
+
.filter((check) => !check.passed)
|
|
132
|
+
.map((check) => ({
|
|
133
|
+
code: check.id.toUpperCase(),
|
|
134
|
+
title: check.label,
|
|
135
|
+
severity: check.severity,
|
|
136
|
+
target: check.id,
|
|
137
|
+
explanation: check.detail,
|
|
138
|
+
repairability:
|
|
139
|
+
check.id === "executor_succeeded" && attempt.raw_output.blocked
|
|
140
|
+
? "manual"
|
|
141
|
+
: check.repairability,
|
|
142
|
+
auto_fix_hint:
|
|
143
|
+
check.id === "executor_succeeded" && attempt.raw_output.blocked
|
|
144
|
+
? null
|
|
145
|
+
: `Repair ${check.label.toLowerCase()} and report fresh evidence.`,
|
|
146
|
+
}));
|
|
147
|
+
const passed = findings.length === 0;
|
|
148
|
+
const requiresHuman = findings.some((finding) => finding.repairability === "manual");
|
|
149
|
+
const implementationGate = {
|
|
150
|
+
passed,
|
|
151
|
+
summary: passed
|
|
152
|
+
? "Implementation evidence passed local verification."
|
|
153
|
+
: "Implementation evidence failed local verification.",
|
|
154
|
+
checks,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
passed,
|
|
159
|
+
summary: passed
|
|
160
|
+
? "Implementation is ready for independent audit."
|
|
161
|
+
: "Implementation did not satisfy local evidence gates.",
|
|
162
|
+
evaluations: [
|
|
163
|
+
{
|
|
164
|
+
kind: "intent_fit",
|
|
165
|
+
passed,
|
|
166
|
+
score:
|
|
167
|
+
checks.length === 0 ? 1 : checks.filter((check) => check.passed).length / checks.length,
|
|
168
|
+
summary: passed
|
|
169
|
+
? "Structured evidence matches the charter."
|
|
170
|
+
: "Structured evidence is incomplete.",
|
|
171
|
+
findings,
|
|
172
|
+
evidence_refs: [attempt.pr_url, attempt.commit_sha, attempt.ui_evidence_path].filter(
|
|
173
|
+
Boolean,
|
|
174
|
+
),
|
|
175
|
+
reviewer_id: "simy_cli_deterministic_auditor.v1",
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
findings,
|
|
179
|
+
auto_repairable: !passed && !requiresHuman,
|
|
180
|
+
requires_human: requiresHuman,
|
|
181
|
+
reinstruction: passed
|
|
182
|
+
? null
|
|
183
|
+
: `Preserve the original requirement and repair these findings:\n${bullets(
|
|
184
|
+
findings.map(
|
|
185
|
+
(finding) => `${finding.code}: ${finding.auto_fix_hint || finding.explanation}`,
|
|
186
|
+
),
|
|
187
|
+
)}`,
|
|
188
|
+
evidence_artifacts: evidenceArtifact ? [evidenceArtifact] : [],
|
|
189
|
+
implementation_gate: implementationGate,
|
|
190
|
+
release_gate: null,
|
|
191
|
+
work_log_signals: [],
|
|
192
|
+
work_log_interventions: [],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function evaluatePrReadiness(charter, attempt) {
|
|
197
|
+
const github = attempt.observed_evidence?.github || {};
|
|
198
|
+
const local = attempt.observed_evidence?.local || {};
|
|
199
|
+
const independentAudit = attempt.independent_audit;
|
|
200
|
+
const checks = [
|
|
201
|
+
readinessCheck("implementation_gate", attempt.audit?.passed === true, "Implementation gate must pass."),
|
|
202
|
+
readinessCheck(
|
|
203
|
+
"independent_ai_audit",
|
|
204
|
+
independentAudit?.passed === true,
|
|
205
|
+
independentAudit?.summary || "Independent AI audit has not passed.",
|
|
206
|
+
),
|
|
207
|
+
readinessCheck(
|
|
208
|
+
"github_evidence",
|
|
209
|
+
github.available === true,
|
|
210
|
+
github.error || "GitHub PR evidence is unavailable.",
|
|
211
|
+
),
|
|
212
|
+
readinessCheck(
|
|
213
|
+
"pr_url_matches",
|
|
214
|
+
Boolean(github.url && attempt.pr_url && github.url === attempt.pr_url),
|
|
215
|
+
`Reported ${attempt.pr_url || "missing"}; GitHub reports ${github.url || "missing"}.`,
|
|
216
|
+
),
|
|
217
|
+
readinessCheck("pr_not_draft", github.available && github.is_draft === false, "PR is still draft."),
|
|
218
|
+
readinessCheck(
|
|
219
|
+
"pr_title_prefix",
|
|
220
|
+
hasAllowedPrefix(github.title),
|
|
221
|
+
github.title || "GitHub PR title is missing.",
|
|
222
|
+
),
|
|
223
|
+
readinessCheck(
|
|
224
|
+
"pr_repository_matches",
|
|
225
|
+
normalizeRepository(github.repository) === normalizeRepository(charter.repository),
|
|
226
|
+
`Expected ${charter.repository}; GitHub reports ${github.repository || "missing"}.`,
|
|
227
|
+
),
|
|
228
|
+
readinessCheck(
|
|
229
|
+
"head_sha_matches",
|
|
230
|
+
Boolean(github.head_sha && local.head_sha && github.head_sha === local.head_sha),
|
|
231
|
+
`GitHub head ${github.head_sha || "missing"}; local head ${local.head_sha || "missing"}.`,
|
|
232
|
+
),
|
|
233
|
+
readinessCheck(
|
|
234
|
+
"head_branch_matches",
|
|
235
|
+
Boolean(github.head_branch && local.branch_name && github.head_branch === local.branch_name),
|
|
236
|
+
`GitHub branch ${github.head_branch || "missing"}; local branch ${local.branch_name || "missing"}.`,
|
|
237
|
+
),
|
|
238
|
+
readinessCheck(
|
|
239
|
+
"base_branch_matches",
|
|
240
|
+
github.base_branch === charter.base_branch,
|
|
241
|
+
`Expected ${charter.base_branch}; GitHub reports ${github.base_branch || "missing"}.`,
|
|
242
|
+
),
|
|
243
|
+
readinessCheck(
|
|
244
|
+
"pr_mergeable",
|
|
245
|
+
github.mergeable === "MERGEABLE" && github.merge_state_status === "CLEAN",
|
|
246
|
+
`Mergeable ${github.mergeable || "unknown"}; state ${github.merge_state_status || "unknown"}.`,
|
|
247
|
+
),
|
|
248
|
+
readinessCheck("status_checks_present", github.checks_present === true, "No PR status checks found."),
|
|
249
|
+
readinessCheck("status_checks_passing", github.checks_passing === true, "PR checks are pending or failing."),
|
|
250
|
+
readinessCheck(
|
|
251
|
+
"required_checks_present",
|
|
252
|
+
github.required_checks_present === true,
|
|
253
|
+
`Missing required checks: ${charter.required_checks.join(", ") || "none configured"}.`,
|
|
254
|
+
),
|
|
255
|
+
readinessCheck(
|
|
256
|
+
"human_peer_approval",
|
|
257
|
+
!charter.require_human_approval || github.human_approved === true,
|
|
258
|
+
"Human peer approval is still required.",
|
|
259
|
+
),
|
|
260
|
+
];
|
|
261
|
+
const ready = checks.every((check) => check.passed);
|
|
262
|
+
return {
|
|
263
|
+
ready,
|
|
264
|
+
state: ready ? "merge_ready" : "pr_ready_for_review",
|
|
265
|
+
summary: ready
|
|
266
|
+
? "PR has independently verified evidence, passing checks, and required approval."
|
|
267
|
+
: "Implementation is complete, but the PR is not merge-ready.",
|
|
268
|
+
checks,
|
|
269
|
+
pending_reasons: checks.filter((check) => !check.passed).map((check) => check.detail),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readinessCheck(id, passed, detail) {
|
|
274
|
+
return { id, passed: Boolean(passed), detail };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sameStrings(left, right) {
|
|
278
|
+
const a = [...new Set(left)].sort();
|
|
279
|
+
const b = [...new Set(right)].sort();
|
|
280
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function normalizeRepository(value) {
|
|
284
|
+
return String(value || "")
|
|
285
|
+
.trim()
|
|
286
|
+
.replace(/^https?:\/\/github\.com\//, "")
|
|
287
|
+
.replace(/\.git$/, "")
|
|
288
|
+
.toLowerCase();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function inspectEvidence(charter, evidencePath) {
|
|
292
|
+
if (!evidencePath) return null;
|
|
293
|
+
const resolved = path.resolve(evidencePath);
|
|
294
|
+
const root = charter.ui_evidence_root ? path.resolve(charter.ui_evidence_root) : null;
|
|
295
|
+
let fileStat = null;
|
|
296
|
+
try {
|
|
297
|
+
fileStat = await stat(resolved);
|
|
298
|
+
} catch {
|
|
299
|
+
// Missing evidence is represented in the returned artifact.
|
|
300
|
+
}
|
|
301
|
+
const underRoot = Boolean(
|
|
302
|
+
root && (resolved === root || resolved.startsWith(`${root}${path.sep}`)),
|
|
303
|
+
);
|
|
304
|
+
return {
|
|
305
|
+
path: resolved,
|
|
306
|
+
kind: fileStat?.isDirectory() ? "directory" : evidenceKind(resolved),
|
|
307
|
+
exists: Boolean(fileStat),
|
|
308
|
+
under_required_root: underRoot,
|
|
309
|
+
size_bytes: fileStat?.size ?? 0,
|
|
310
|
+
file_count: fileStat?.isFile() ? 1 : 0,
|
|
311
|
+
git_tracked: false,
|
|
312
|
+
summary:
|
|
313
|
+
fileStat && underRoot && fileStat.size > 0
|
|
314
|
+
? "Evidence exists under the required local root."
|
|
315
|
+
: "Evidence is missing, empty, or outside the required local root.",
|
|
316
|
+
};
|
|
317
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
clampAttempts,
|
|
5
|
+
cleanString,
|
|
6
|
+
event,
|
|
7
|
+
looksLikeUiTask,
|
|
8
|
+
stringArray,
|
|
9
|
+
} from "./shared.js";
|
|
10
|
+
import { classifyRisk } from "./risk.js";
|
|
11
|
+
|
|
12
|
+
export function createCodingLoopSnapshot({ runId, request, metadata = {} }) {
|
|
13
|
+
const now = new Date().toISOString();
|
|
14
|
+
const baseBranch = cleanString(request.base_branch) || "dev";
|
|
15
|
+
const expectedEvidence = stringArray(request.expected_evidence);
|
|
16
|
+
if (looksLikeUiTask(request.requirement) && !expectedEvidence.includes("ui_evidence_path")) {
|
|
17
|
+
expectedEvidence.push("ui_evidence_path");
|
|
18
|
+
}
|
|
19
|
+
const acceptanceCriteria = stringArray(request.acceptance_criteria);
|
|
20
|
+
const mustNot = stringArray(request.must_not);
|
|
21
|
+
const risk = classifyRisk(request);
|
|
22
|
+
const designSummary = cleanString(request.design_summary);
|
|
23
|
+
const designApprovedBy = cleanString(request.design_review_approved_by);
|
|
24
|
+
const designEvidenceUrl = cleanString(request.design_review_url);
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
id: runId,
|
|
28
|
+
state: "queued",
|
|
29
|
+
metadata: { ...metadata, execution_location: "local_cli" },
|
|
30
|
+
charter: {
|
|
31
|
+
id: `${runId}_charter`,
|
|
32
|
+
requirement: cleanString(request.requirement),
|
|
33
|
+
repository: cleanString(request.repository),
|
|
34
|
+
backend: request.backend === "claude" ? "claude" : "codex",
|
|
35
|
+
audit_backend:
|
|
36
|
+
request.audit_backend === "claude" || request.audit_backend === "codex"
|
|
37
|
+
? request.audit_backend
|
|
38
|
+
: request.backend === "claude"
|
|
39
|
+
? "claude"
|
|
40
|
+
: "codex",
|
|
41
|
+
base_branch: baseBranch,
|
|
42
|
+
max_attempts: clampAttempts(request.max_attempts),
|
|
43
|
+
ui_evidence_root: cleanString(request.ui_evidence_root),
|
|
44
|
+
acceptance_criteria:
|
|
45
|
+
acceptanceCriteria.length > 0
|
|
46
|
+
? acceptanceCriteria
|
|
47
|
+
: [
|
|
48
|
+
"The requested change is implemented without unrelated scope.",
|
|
49
|
+
"Relevant tests pass and are recorded.",
|
|
50
|
+
`A pull request targets ${baseBranch}.`,
|
|
51
|
+
],
|
|
52
|
+
acceptance_criteria_source: acceptanceCriteria.length > 0 ? "explicit" : "default",
|
|
53
|
+
expected_tests: stringArray(request.expected_tests),
|
|
54
|
+
expected_evidence: expectedEvidence,
|
|
55
|
+
required_checks: stringArray(request.required_checks),
|
|
56
|
+
require_human_approval:
|
|
57
|
+
risk.requires_design_review || request.require_human_approval !== false,
|
|
58
|
+
must_not:
|
|
59
|
+
mustNot.length > 0
|
|
60
|
+
? mustNot
|
|
61
|
+
: [
|
|
62
|
+
"commit local evidence artifacts",
|
|
63
|
+
`target a branch other than ${baseBranch}`,
|
|
64
|
+
"include unrelated changes",
|
|
65
|
+
],
|
|
66
|
+
proposal_id: cleanString(request.proposal_id) || null,
|
|
67
|
+
risk,
|
|
68
|
+
design_review: {
|
|
69
|
+
required: risk.requires_design_review,
|
|
70
|
+
summary: designSummary || null,
|
|
71
|
+
approved_by: designApprovedBy || null,
|
|
72
|
+
evidence_url: designEvidenceUrl || null,
|
|
73
|
+
status: risk.requires_design_review
|
|
74
|
+
? designSummary && designApprovedBy && designEvidenceUrl
|
|
75
|
+
? "approved"
|
|
76
|
+
: "missing"
|
|
77
|
+
: "not_required",
|
|
78
|
+
},
|
|
79
|
+
thread_state: {
|
|
80
|
+
original_request: cleanString(request.requirement),
|
|
81
|
+
user_goal: cleanString(request.requirement),
|
|
82
|
+
non_goals: ["Unrelated refactors", "Server-side execution of repository code"],
|
|
83
|
+
expected_finish_line: `A verified pull request targeting ${baseBranch}.`,
|
|
84
|
+
artifacts_required: ["pr_url", "commit_sha", "tests_run", ...expectedEvidence],
|
|
85
|
+
failure_count: 0,
|
|
86
|
+
assumptions: [],
|
|
87
|
+
},
|
|
88
|
+
prompt_policy_report: buildPromptPolicyReport(request, risk),
|
|
89
|
+
created_at: now,
|
|
90
|
+
},
|
|
91
|
+
attempts: [],
|
|
92
|
+
events: [
|
|
93
|
+
event("queued", "Coding loop queued for local orchestration.", {
|
|
94
|
+
execution_location: "local_cli",
|
|
95
|
+
}),
|
|
96
|
+
],
|
|
97
|
+
final_audit: null,
|
|
98
|
+
implementation_gate_passed: false,
|
|
99
|
+
done_gate_passed: false,
|
|
100
|
+
created_at: now,
|
|
101
|
+
updated_at: now,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function newRunId() {
|
|
106
|
+
return `coding_loop_${randomUUID()}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildPromptPolicyReport(request, risk) {
|
|
110
|
+
const ui = looksLikeUiTask(request.requirement);
|
|
111
|
+
const matched = [
|
|
112
|
+
"coding.purpose_lock",
|
|
113
|
+
"coding.release_management",
|
|
114
|
+
"coding.completion_self_audit",
|
|
115
|
+
...(ui ? ["coding.browser_verification"] : []),
|
|
116
|
+
];
|
|
117
|
+
return {
|
|
118
|
+
registry_id: "simy_cli_coding_prompt_policy.v1",
|
|
119
|
+
matched_policy_ids: matched,
|
|
120
|
+
matched_triggers: matched.map((id) => id.slice("coding.".length)),
|
|
121
|
+
risk_level: risk.level,
|
|
122
|
+
risk_tags: risk.tags,
|
|
123
|
+
reasons: matched.map((id) => `${id}: selected by local coding policy`),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
import { cleanString, stringArray } from "./shared.js";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const SUCCESS_CONCLUSIONS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
|
|
8
|
+
const FAILURE_CONCLUSIONS = new Set([
|
|
9
|
+
"ACTION_REQUIRED",
|
|
10
|
+
"CANCELLED",
|
|
11
|
+
"FAILURE",
|
|
12
|
+
"STALE",
|
|
13
|
+
"TIMED_OUT",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export async function collectPrEvidence({
|
|
17
|
+
charter,
|
|
18
|
+
attempt,
|
|
19
|
+
repositoryPath,
|
|
20
|
+
runCommand = execFileAsync,
|
|
21
|
+
}) {
|
|
22
|
+
const local = await collectLocalGitEvidence({
|
|
23
|
+
baseBranch: charter.base_branch,
|
|
24
|
+
repositoryPath,
|
|
25
|
+
runCommand,
|
|
26
|
+
});
|
|
27
|
+
const github = await collectGitHubEvidence({ charter, attempt, repositoryPath, runCommand });
|
|
28
|
+
return {
|
|
29
|
+
collected_at: new Date().toISOString(),
|
|
30
|
+
local,
|
|
31
|
+
github,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function collectLocalGitEvidence({ baseBranch, repositoryPath, runCommand }) {
|
|
36
|
+
const [head, branch, headline, status, changed] = await Promise.all([
|
|
37
|
+
command(runCommand, "git", ["rev-parse", "HEAD"], repositoryPath),
|
|
38
|
+
command(runCommand, "git", ["branch", "--show-current"], repositoryPath),
|
|
39
|
+
command(runCommand, "git", ["log", "-1", "--pretty=%s"], repositoryPath),
|
|
40
|
+
command(runCommand, "git", ["status", "--porcelain"], repositoryPath),
|
|
41
|
+
changedFiles(runCommand, baseBranch, repositoryPath),
|
|
42
|
+
]);
|
|
43
|
+
const statusLines = lines(status.stdout);
|
|
44
|
+
return {
|
|
45
|
+
available: head.ok && branch.ok && headline.ok && status.ok && changed.ok,
|
|
46
|
+
head_sha: cleanString(head.stdout) || null,
|
|
47
|
+
branch_name: cleanString(branch.stdout) || null,
|
|
48
|
+
commit_headline: cleanString(headline.stdout) || null,
|
|
49
|
+
working_tree_clean: status.ok && statusLines.length === 0,
|
|
50
|
+
status_lines: statusLines,
|
|
51
|
+
changed_files: lines(changed.stdout),
|
|
52
|
+
errors: [head, branch, headline, status, changed]
|
|
53
|
+
.filter((item) => !item.ok)
|
|
54
|
+
.map((item) => item.error),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function changedFiles(runCommand, baseBranch, repositoryPath) {
|
|
59
|
+
const refs = [`origin/${baseBranch}`, baseBranch];
|
|
60
|
+
for (const ref of refs) {
|
|
61
|
+
const result = await command(
|
|
62
|
+
runCommand,
|
|
63
|
+
"git",
|
|
64
|
+
["diff", "--name-only", "--diff-filter=ACMR", `${ref}...HEAD`],
|
|
65
|
+
repositoryPath,
|
|
66
|
+
);
|
|
67
|
+
if (result.ok) return result;
|
|
68
|
+
}
|
|
69
|
+
return { ok: false, stdout: "", error: `Could not diff against ${baseBranch}.` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function collectGitHubEvidence({ charter, attempt, repositoryPath, runCommand }) {
|
|
73
|
+
const target = attempt.pr_url || (attempt.pr_number ? String(attempt.pr_number) : "");
|
|
74
|
+
if (!target) return unavailable("PR URL or number was not reported.");
|
|
75
|
+
const fields = [
|
|
76
|
+
"url",
|
|
77
|
+
"number",
|
|
78
|
+
"title",
|
|
79
|
+
"isDraft",
|
|
80
|
+
"mergeable",
|
|
81
|
+
"mergeStateStatus",
|
|
82
|
+
"reviewDecision",
|
|
83
|
+
"headRefName",
|
|
84
|
+
"headRefOid",
|
|
85
|
+
"headRepository",
|
|
86
|
+
"headRepositoryOwner",
|
|
87
|
+
"baseRefName",
|
|
88
|
+
"reviews",
|
|
89
|
+
"statusCheckRollup",
|
|
90
|
+
].join(",");
|
|
91
|
+
const result = await command(
|
|
92
|
+
runCommand,
|
|
93
|
+
"gh",
|
|
94
|
+
["pr", "view", target, "--json", fields],
|
|
95
|
+
repositoryPath,
|
|
96
|
+
);
|
|
97
|
+
if (!result.ok) return unavailable(result.error);
|
|
98
|
+
|
|
99
|
+
let payload;
|
|
100
|
+
try {
|
|
101
|
+
payload = JSON.parse(result.stdout);
|
|
102
|
+
} catch {
|
|
103
|
+
return unavailable("GitHub PR evidence was not valid JSON.");
|
|
104
|
+
}
|
|
105
|
+
const checks = normalizeChecks(payload.statusCheckRollup);
|
|
106
|
+
const requiredChecks = stringArray(charter.required_checks);
|
|
107
|
+
const checkNames = new Set(checks.map((item) => item.name));
|
|
108
|
+
const approvals = Array.isArray(payload.reviews)
|
|
109
|
+
? payload.reviews
|
|
110
|
+
.filter((item) => cleanString(item?.state).toUpperCase() === "APPROVED")
|
|
111
|
+
.map((item) => cleanString(item?.author?.login))
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
: [];
|
|
114
|
+
return {
|
|
115
|
+
available: true,
|
|
116
|
+
url: cleanString(payload.url) || null,
|
|
117
|
+
number: Number.isInteger(payload.number) ? payload.number : null,
|
|
118
|
+
title: cleanString(payload.title) || null,
|
|
119
|
+
is_draft: payload.isDraft === true,
|
|
120
|
+
mergeable: cleanString(payload.mergeable) || null,
|
|
121
|
+
merge_state_status: cleanString(payload.mergeStateStatus) || null,
|
|
122
|
+
review_decision: cleanString(payload.reviewDecision) || null,
|
|
123
|
+
approvals,
|
|
124
|
+
human_approved: cleanString(payload.reviewDecision) === "APPROVED" || approvals.length > 0,
|
|
125
|
+
head_branch: cleanString(payload.headRefName) || null,
|
|
126
|
+
head_sha: cleanString(payload.headRefOid) || null,
|
|
127
|
+
repository:
|
|
128
|
+
cleanString(payload.headRepositoryOwner?.login) && cleanString(payload.headRepository?.name)
|
|
129
|
+
? `${cleanString(payload.headRepositoryOwner.login)}/${cleanString(payload.headRepository.name)}`
|
|
130
|
+
: null,
|
|
131
|
+
base_branch: cleanString(payload.baseRefName) || null,
|
|
132
|
+
checks,
|
|
133
|
+
checks_present: checks.length > 0,
|
|
134
|
+
checks_passing:
|
|
135
|
+
checks.length > 0 && checks.every((item) => item.state === "success"),
|
|
136
|
+
required_checks_present: requiredChecks.every((name) => checkNames.has(name)),
|
|
137
|
+
error: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeChecks(value) {
|
|
142
|
+
if (!Array.isArray(value)) return [];
|
|
143
|
+
return value.map((item) => {
|
|
144
|
+
const name = cleanString(item?.name || item?.context || item?.workflowName) || "unknown";
|
|
145
|
+
const status = cleanString(item?.status || item?.state).toUpperCase();
|
|
146
|
+
const conclusion = cleanString(item?.conclusion || item?.state).toUpperCase();
|
|
147
|
+
let state = "pending";
|
|
148
|
+
if (SUCCESS_CONCLUSIONS.has(conclusion)) state = "success";
|
|
149
|
+
if (FAILURE_CONCLUSIONS.has(conclusion)) state = "failure";
|
|
150
|
+
if (status === "COMPLETED" && !conclusion) state = "failure";
|
|
151
|
+
return { name, status: status || null, conclusion: conclusion || null, state };
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function command(runCommand, file, args, cwd) {
|
|
156
|
+
try {
|
|
157
|
+
const result = await runCommand(file, args, { cwd, maxBuffer: 1024 * 1024 });
|
|
158
|
+
return { ok: true, stdout: cleanString(result?.stdout), error: null };
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
stdout: cleanString(error?.stdout),
|
|
163
|
+
error: cleanString(error?.stderr || error?.message) || `${file} failed.`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function lines(value) {
|
|
169
|
+
return String(value || "")
|
|
170
|
+
.split(/\r?\n/)
|
|
171
|
+
.map((item) => item.trim())
|
|
172
|
+
.filter(Boolean);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function unavailable(error) {
|
|
176
|
+
return {
|
|
177
|
+
available: false,
|
|
178
|
+
checks: [],
|
|
179
|
+
checks_present: false,
|
|
180
|
+
checks_passing: false,
|
|
181
|
+
required_checks_present: false,
|
|
182
|
+
error,
|
|
183
|
+
};
|
|
184
|
+
}
|