@awak-app/simy-cli 0.1.3 → 0.1.4
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 +39 -5
- package/package.json +14 -4
- package/src/agent.js +204 -23
- package/src/backend-executable.js +92 -6
- package/src/console/app.js +9 -7
- package/src/console/index.js +5 -4
- package/src/desktop-executor.js +137 -0
- package/src/index.js +3 -3
- package/src/local-attachments.js +2 -2
- package/src/orchestrator/audit.js +101 -7
- package/src/orchestrator/budget.js +112 -0
- package/src/orchestrator/contract.js +175 -27
- package/src/orchestrator/independent-audit.js +18 -4
- package/src/orchestrator/index.js +1 -0
- package/src/orchestrator/instruction.js +41 -5
- package/src/orchestrator/loop.js +181 -7
- package/src/orchestrator/presentation.js +2 -2
- package/src/orchestrator/problem-solving.js +129 -0
- package/src/orchestrator/recovery.js +271 -0
- package/src/orchestrator/result.js +2 -0
- package/src/orchestrator/retry.js +140 -0
- package/src/orchestrator/shared.js +87 -5
- package/src/repository-inventory.js +87 -0
- package/src/runner.js +76 -94
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
const RECOVERABLE_STATES = new Set(["waiting_human", "blocked", "failed"]);
|
|
2
|
+
|
|
3
|
+
export function recoveryContractForEvent({ snapshot, state, message, detail = {} }) {
|
|
4
|
+
if (!RECOVERABLE_STATES.has(state)) return null;
|
|
5
|
+
if (isRecoveryContract(detail.recovery)) return detail.recovery;
|
|
6
|
+
|
|
7
|
+
const reasonCode = recoveryReasonCode(state, message, detail);
|
|
8
|
+
const attemptNumber = positiveInteger(detail.attempt_number);
|
|
9
|
+
const actions = recoveryActions(reasonCode);
|
|
10
|
+
return {
|
|
11
|
+
schema_version: 1,
|
|
12
|
+
state,
|
|
13
|
+
reason_code: reasonCode,
|
|
14
|
+
user_message: recoveryMessage(reasonCode),
|
|
15
|
+
primary_action_id: actions[0].id,
|
|
16
|
+
actions,
|
|
17
|
+
technical_details: {
|
|
18
|
+
event_code: clean(detail.code) || null,
|
|
19
|
+
attempt_number: attemptNumber,
|
|
20
|
+
max_attempts: positiveInteger(snapshot?.charter?.max_attempts),
|
|
21
|
+
token_budget: positiveInteger(snapshot?.charter?.token_budget),
|
|
22
|
+
retry_budget: nonNegativeInteger(snapshot?.charter?.retry_budget),
|
|
23
|
+
tokens_used: nonNegativeInteger(detail?.budget?.tokens_used),
|
|
24
|
+
retries_used: nonNegativeInteger(detail?.budget?.retries_used),
|
|
25
|
+
finding_codes: strings(detail.finding_codes),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function recoveryContractForPreflight({ key, summary, details = {} }) {
|
|
31
|
+
const updateCommand = clean(details.update_command);
|
|
32
|
+
if (key === "backend") {
|
|
33
|
+
const reasonCode =
|
|
34
|
+
details.status === "outdated"
|
|
35
|
+
? "executor_outdated"
|
|
36
|
+
: details.status === "unknown_version"
|
|
37
|
+
? "executor_version_unknown"
|
|
38
|
+
: "executor_missing";
|
|
39
|
+
const actions = [
|
|
40
|
+
{
|
|
41
|
+
id: "update_executor",
|
|
42
|
+
type: "run_command",
|
|
43
|
+
label: "Update the executor",
|
|
44
|
+
description: "Run this command in Terminal, then check again.",
|
|
45
|
+
command: updateCommand || null,
|
|
46
|
+
payload: {},
|
|
47
|
+
},
|
|
48
|
+
checkAgainAction(),
|
|
49
|
+
];
|
|
50
|
+
return preflightContract(reasonCode, summary, actions, {
|
|
51
|
+
installed_version: clean(details.installed_version) || null,
|
|
52
|
+
minimum_version: clean(details.minimum_version) || null,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (key === "repository") {
|
|
56
|
+
const actions = [
|
|
57
|
+
{
|
|
58
|
+
id: "choose_repository",
|
|
59
|
+
type: "select_repository",
|
|
60
|
+
label: "Choose the local repository",
|
|
61
|
+
description: "Select the checkout that should receive this task, then check again.",
|
|
62
|
+
command: null,
|
|
63
|
+
payload: {},
|
|
64
|
+
},
|
|
65
|
+
checkAgainAction(),
|
|
66
|
+
];
|
|
67
|
+
return preflightContract("repository_unavailable", summary, actions);
|
|
68
|
+
}
|
|
69
|
+
const actions = [
|
|
70
|
+
{
|
|
71
|
+
id: "reconnect_cli",
|
|
72
|
+
type: "reconnect_cli",
|
|
73
|
+
label: "Reconnect SIMY CLI",
|
|
74
|
+
description: "Open SIMY CLI and sign in again, then check the environment.",
|
|
75
|
+
command: "simy",
|
|
76
|
+
payload: {},
|
|
77
|
+
},
|
|
78
|
+
checkAgainAction(),
|
|
79
|
+
];
|
|
80
|
+
return preflightContract("cli_session_unavailable", summary, actions);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isRecoveryContract(value) {
|
|
84
|
+
return Boolean(
|
|
85
|
+
value &&
|
|
86
|
+
typeof value === "object" &&
|
|
87
|
+
!Array.isArray(value) &&
|
|
88
|
+
value.schema_version === 1 &&
|
|
89
|
+
typeof value.reason_code === "string" &&
|
|
90
|
+
typeof value.user_message === "string" &&
|
|
91
|
+
typeof value.primary_action_id === "string" &&
|
|
92
|
+
Array.isArray(value.actions) &&
|
|
93
|
+
value.actions.length > 0,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function recoveryReasonCode(state, message, detail) {
|
|
98
|
+
const code = clean(detail.code);
|
|
99
|
+
if (code === "local_repository_not_authorized") return "repository_authorization_required";
|
|
100
|
+
if (code === "local_execution_interrupted") return "local_execution_interrupted";
|
|
101
|
+
if (code === "retry_circuit_open" || detail.reason === "identical_commit_and_findings") {
|
|
102
|
+
return "retry_strategy_unchanged";
|
|
103
|
+
}
|
|
104
|
+
if (code === "token_budget_exhausted") return "token_budget_exhausted";
|
|
105
|
+
if (code === "retry_budget_exhausted") return "retry_budget_exhausted";
|
|
106
|
+
const findings = strings(detail.finding_codes);
|
|
107
|
+
if (
|
|
108
|
+
findings.some((finding) =>
|
|
109
|
+
[
|
|
110
|
+
"EXPLICIT_ACCEPTANCE_CRITERIA_REQUIRED",
|
|
111
|
+
"DESIGN_REVIEW_REQUIRED",
|
|
112
|
+
"REQUIRED_CI_CHECKS_MISSING",
|
|
113
|
+
].includes(finding),
|
|
114
|
+
)
|
|
115
|
+
) {
|
|
116
|
+
return "requirements_incomplete";
|
|
117
|
+
}
|
|
118
|
+
if (state === "waiting_human") return "human_input_required";
|
|
119
|
+
if (state === "blocked" && /(?:attempt|retry) budget/i.test(clean(message))) {
|
|
120
|
+
return "retry_budget_exhausted";
|
|
121
|
+
}
|
|
122
|
+
if (state === "blocked") return "automatic_progress_blocked";
|
|
123
|
+
if (/attachment cleanup/i.test(clean(message))) return "attachment_cleanup_failed";
|
|
124
|
+
return "local_orchestration_failed";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function recoveryMessage(reasonCode) {
|
|
128
|
+
const messages = {
|
|
129
|
+
repository_authorization_required:
|
|
130
|
+
"SIMY needs permission to find the selected repository before it can continue.",
|
|
131
|
+
local_execution_interrupted:
|
|
132
|
+
"The local executor stopped during a previous CLI session and is ready for your direction.",
|
|
133
|
+
retry_strategy_unchanged:
|
|
134
|
+
"SIMY stopped repeating the same unsuccessful result. Tell it what to try differently.",
|
|
135
|
+
requirements_incomplete:
|
|
136
|
+
"SIMY needs the missing requirement or approval before implementation can start.",
|
|
137
|
+
human_input_required: "SIMY needs your decision or clarification before it can continue.",
|
|
138
|
+
token_budget_exhausted:
|
|
139
|
+
"This run used its token budget. Start a new run if you want SIMY to continue with a fresh limit.",
|
|
140
|
+
retry_budget_exhausted:
|
|
141
|
+
"This run used its automatic retry budget. Start a new run to continue with a fresh limit.",
|
|
142
|
+
automatic_progress_blocked:
|
|
143
|
+
"SIMY cannot continue automatically. Review the issue and tell it how to proceed.",
|
|
144
|
+
attachment_cleanup_failed:
|
|
145
|
+
"SIMY could not finish cleaning up a local attachment. Review the details before retrying.",
|
|
146
|
+
local_orchestration_failed:
|
|
147
|
+
"The local executor stopped unexpectedly. Give SIMY guidance to retry or start a new run.",
|
|
148
|
+
};
|
|
149
|
+
return messages[reasonCode] || messages.automatic_progress_blocked;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function recoveryActions(reasonCode) {
|
|
153
|
+
if (reasonCode === "repository_authorization_required") {
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
id: "approve_repository_scan",
|
|
157
|
+
type: "resolve_hil",
|
|
158
|
+
label: "Allow repository scan",
|
|
159
|
+
description: "Review the folders SIMY will scan, then allow it to find the checkout.",
|
|
160
|
+
command: null,
|
|
161
|
+
payload: { request_kind: "local_repository_scan" },
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
if (reasonCode === "requirements_incomplete") {
|
|
166
|
+
return [
|
|
167
|
+
guidanceAction(
|
|
168
|
+
"provide_missing_requirements",
|
|
169
|
+
"Add the missing information",
|
|
170
|
+
"Send the required acceptance criteria, review approval, or check names to SIMY.",
|
|
171
|
+
),
|
|
172
|
+
newRunAction(),
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
if (reasonCode === "retry_strategy_unchanged") {
|
|
176
|
+
return [
|
|
177
|
+
guidanceAction(
|
|
178
|
+
"change_strategy",
|
|
179
|
+
"Tell SIMY what to try differently",
|
|
180
|
+
"Describe a new hypothesis, implementation approach, or evidence source before continuing.",
|
|
181
|
+
),
|
|
182
|
+
newRunAction(),
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
if (reasonCode === "token_budget_exhausted" || reasonCode === "retry_budget_exhausted") {
|
|
186
|
+
return [newRunAction()];
|
|
187
|
+
}
|
|
188
|
+
if (reasonCode === "local_execution_interrupted") {
|
|
189
|
+
return [
|
|
190
|
+
guidanceAction(
|
|
191
|
+
"continue_interrupted_run",
|
|
192
|
+
"Continue this run",
|
|
193
|
+
"Confirm what SIMY should do next in the restored run.",
|
|
194
|
+
),
|
|
195
|
+
newRunAction(),
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
if (reasonCode === "human_input_required") {
|
|
199
|
+
return [
|
|
200
|
+
guidanceAction(
|
|
201
|
+
"provide_guidance",
|
|
202
|
+
"Give SIMY guidance",
|
|
203
|
+
"Answer the open question or explain what must change before continuing.",
|
|
204
|
+
),
|
|
205
|
+
newRunAction(),
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
return [
|
|
209
|
+
guidanceAction(
|
|
210
|
+
"retry_with_guidance",
|
|
211
|
+
"Retry with guidance",
|
|
212
|
+
"Explain what SIMY should change before retrying this run.",
|
|
213
|
+
),
|
|
214
|
+
newRunAction(),
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function guidanceAction(id, label, description) {
|
|
219
|
+
return { id, type: "send_guidance", label, description, command: null, payload: {} };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function newRunAction() {
|
|
223
|
+
return {
|
|
224
|
+
id: "start_new_run",
|
|
225
|
+
type: "start_new_run",
|
|
226
|
+
label: "Start a new run",
|
|
227
|
+
description: "Keep this record and begin again with a revised task.",
|
|
228
|
+
command: null,
|
|
229
|
+
payload: {},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function checkAgainAction() {
|
|
234
|
+
return {
|
|
235
|
+
id: "check_again",
|
|
236
|
+
type: "retry_preflight",
|
|
237
|
+
label: "Check again",
|
|
238
|
+
description: "Run the local environment check again after completing the recovery step.",
|
|
239
|
+
command: null,
|
|
240
|
+
payload: {},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function preflightContract(reasonCode, summary, actions, technicalDetails = {}) {
|
|
245
|
+
return {
|
|
246
|
+
schema_version: 1,
|
|
247
|
+
state: "blocked",
|
|
248
|
+
reason_code: reasonCode,
|
|
249
|
+
user_message: clean(summary) || "The local environment is not ready.",
|
|
250
|
+
primary_action_id: actions[0].id,
|
|
251
|
+
actions,
|
|
252
|
+
technical_details: technicalDetails,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function clean(value) {
|
|
257
|
+
return typeof value === "string" ? value.trim() : "";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function strings(value) {
|
|
261
|
+
return Array.isArray(value) ? value.map(clean).filter(Boolean) : [];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function positiveInteger(value) {
|
|
265
|
+
const number = Number.parseInt(String(value ?? ""), 10);
|
|
266
|
+
return Number.isInteger(number) && number > 0 ? number : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function nonNegativeInteger(value) {
|
|
270
|
+
return Number.isInteger(value) && value >= 0 ? value : null;
|
|
271
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cleanString, stringArray } from "./shared.js";
|
|
2
|
+
import { normalizeProblemSolving } from "./problem-solving.js";
|
|
2
3
|
|
|
3
4
|
export function buildAttempt({ attemptNumber, charter, instruction, promptInterventions, execution }) {
|
|
4
5
|
const result = execution.result && typeof execution.result === "object" ? execution.result : {};
|
|
@@ -26,6 +27,7 @@ export function buildAttempt({ attemptNumber, charter, instruction, promptInterv
|
|
|
26
27
|
tests_passed: typeof result.tests_passed === "boolean" ? result.tests_passed : null,
|
|
27
28
|
ui_evidence_path: cleanString(result.ui_evidence_path) || null,
|
|
28
29
|
summary,
|
|
30
|
+
problem_solving: normalizeProblemSolving(result.problem_solving),
|
|
29
31
|
prompt_interventions: promptInterventions,
|
|
30
32
|
work_log_signals: [],
|
|
31
33
|
work_log_interventions: [],
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { cleanString, stringArray } from "./shared.js";
|
|
4
|
+
|
|
5
|
+
const RETRY_FINGERPRINT_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
export function evaluateRetryCircuit({ attempt, previousAttempt, findings }) {
|
|
8
|
+
const fingerprint = buildRetryFingerprint(attempt, findings);
|
|
9
|
+
attempt.retry_fingerprint = fingerprint;
|
|
10
|
+
const previousFingerprint = previousAttempt
|
|
11
|
+
? previousAttempt.retry_fingerprint ||
|
|
12
|
+
buildRetryFingerprint(previousAttempt, findingsForAttempt(previousAttempt))
|
|
13
|
+
: null;
|
|
14
|
+
return {
|
|
15
|
+
open: Boolean(previousFingerprint?.digest && previousFingerprint.digest === fingerprint.digest),
|
|
16
|
+
fingerprint,
|
|
17
|
+
previous_fingerprint: previousFingerprint,
|
|
18
|
+
previous_attempt_number: previousAttempt?.attempt_number ?? null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildRetryFingerprint(attempt, findings) {
|
|
23
|
+
const commitSha =
|
|
24
|
+
cleanString(attempt?.observed_evidence?.local?.head_sha) ||
|
|
25
|
+
cleanString(attempt?.commit_sha) ||
|
|
26
|
+
null;
|
|
27
|
+
const normalizedFindings = normalizeFindings(findings);
|
|
28
|
+
const normalizedEvidence = normalizeEvidence(attempt);
|
|
29
|
+
const findingPayload = stableJson(normalizedFindings);
|
|
30
|
+
const evidencePayload = stableJson(normalizedEvidence);
|
|
31
|
+
const payload = stableJson({
|
|
32
|
+
version: RETRY_FINGERPRINT_VERSION,
|
|
33
|
+
commit_sha: commitSha,
|
|
34
|
+
findings: normalizedFindings,
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
version: RETRY_FINGERPRINT_VERSION,
|
|
38
|
+
digest: sha256(payload),
|
|
39
|
+
commit_sha: commitSha,
|
|
40
|
+
finding_codes: normalizedFindings.map((finding) => finding.code),
|
|
41
|
+
findings_sha256: sha256(findingPayload),
|
|
42
|
+
evidence_sha256: sha256(evidencePayload),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findingsForAttempt(attempt) {
|
|
47
|
+
return [
|
|
48
|
+
...(Array.isArray(attempt?.audit?.findings) ? attempt.audit.findings : []),
|
|
49
|
+
...(Array.isArray(attempt?.independent_audit?.findings)
|
|
50
|
+
? attempt.independent_audit.findings
|
|
51
|
+
: []),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeFindings(findings) {
|
|
56
|
+
return (Array.isArray(findings) ? findings : [])
|
|
57
|
+
.filter((finding) => finding && typeof finding === "object")
|
|
58
|
+
.map((finding) => ({
|
|
59
|
+
passed:
|
|
60
|
+
finding.passed === true ? true : finding.passed === false ? false : null,
|
|
61
|
+
code: cleanString(finding.code) || null,
|
|
62
|
+
severity: cleanString(finding.severity).toLowerCase() || null,
|
|
63
|
+
target: cleanString(finding.target) || null,
|
|
64
|
+
repairability: cleanString(finding.repairability).toLowerCase() || null,
|
|
65
|
+
}))
|
|
66
|
+
.sort(compareStableValues);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeEvidence(attempt) {
|
|
70
|
+
const local = attempt?.observed_evidence?.local || {};
|
|
71
|
+
const github = attempt?.observed_evidence?.github || {};
|
|
72
|
+
return {
|
|
73
|
+
reported: {
|
|
74
|
+
branch_name: cleanString(attempt?.branch_name) || null,
|
|
75
|
+
commit_sha: cleanString(attempt?.commit_sha) || null,
|
|
76
|
+
pr_url: cleanString(attempt?.pr_url) || null,
|
|
77
|
+
pr_number: Number.isInteger(attempt?.pr_number) ? attempt.pr_number : null,
|
|
78
|
+
tests_run: sortedStrings(attempt?.tests_run),
|
|
79
|
+
tests_passed:
|
|
80
|
+
typeof attempt?.tests_passed === "boolean" ? attempt.tests_passed : null,
|
|
81
|
+
ui_evidence_path: cleanString(attempt?.ui_evidence_path) || null,
|
|
82
|
+
changed_files: sortedStrings(attempt?.raw_output?.changed_files),
|
|
83
|
+
},
|
|
84
|
+
local: {
|
|
85
|
+
available: local.available === true,
|
|
86
|
+
head_sha: cleanString(local.head_sha) || null,
|
|
87
|
+
branch_name: cleanString(local.branch_name) || null,
|
|
88
|
+
commit_headline: cleanString(local.commit_headline) || null,
|
|
89
|
+
working_tree_clean: local.working_tree_clean === true,
|
|
90
|
+
changed_files: sortedStrings(local.changed_files),
|
|
91
|
+
status_lines: sortedStrings(local.status_lines),
|
|
92
|
+
errors: sortedStrings(local.errors),
|
|
93
|
+
},
|
|
94
|
+
github: {
|
|
95
|
+
available: github.available === true,
|
|
96
|
+
url: cleanString(github.url) || null,
|
|
97
|
+
number: Number.isInteger(github.number) ? github.number : null,
|
|
98
|
+
title: cleanString(github.title) || null,
|
|
99
|
+
is_draft: github.is_draft === true,
|
|
100
|
+
mergeable: cleanString(github.mergeable) || null,
|
|
101
|
+
merge_state_status: cleanString(github.merge_state_status) || null,
|
|
102
|
+
review_decision: cleanString(github.review_decision) || null,
|
|
103
|
+
approvals: sortedStrings(github.approvals),
|
|
104
|
+
head_branch: cleanString(github.head_branch) || null,
|
|
105
|
+
head_sha: cleanString(github.head_sha) || null,
|
|
106
|
+
repository: cleanString(github.repository) || null,
|
|
107
|
+
base_branch: cleanString(github.base_branch) || null,
|
|
108
|
+
checks: normalizeChecks(github.checks),
|
|
109
|
+
error: cleanString(github.error) || null,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeChecks(checks) {
|
|
115
|
+
return (Array.isArray(checks) ? checks : [])
|
|
116
|
+
.filter((check) => check && typeof check === "object")
|
|
117
|
+
.map((check) => ({
|
|
118
|
+
name: cleanString(check.name) || null,
|
|
119
|
+
state: cleanString(check.state) || null,
|
|
120
|
+
status: cleanString(check.status) || null,
|
|
121
|
+
conclusion: cleanString(check.conclusion) || null,
|
|
122
|
+
}))
|
|
123
|
+
.sort(compareStableValues);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sortedStrings(values) {
|
|
127
|
+
return [...new Set(stringArray(values))].sort();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function compareStableValues(left, right) {
|
|
131
|
+
return stableJson(left).localeCompare(stableJson(right));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function stableJson(value) {
|
|
135
|
+
return JSON.stringify(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sha256(value) {
|
|
139
|
+
return createHash("sha256").update(value).digest("hex");
|
|
140
|
+
}
|
|
@@ -1,10 +1,47 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
+
import { recoveryContractForEvent } from "./recovery.js";
|
|
4
|
+
|
|
3
5
|
const ALLOWED_PREFIXES = ["feat:", "chore:", "fix:", "refactor:", "infra:"];
|
|
4
6
|
const MAX_ATTEMPTS = 5;
|
|
7
|
+
const PROCESS_MESSAGE_KEY_BY_STATE = Object.freeze({
|
|
8
|
+
queued: "agenticLoop.process.queued",
|
|
9
|
+
risk_classifying: "agenticLoop.process.riskClassifying",
|
|
10
|
+
chartering: "agenticLoop.process.chartering",
|
|
11
|
+
dispatching: "agenticLoop.process.dispatching",
|
|
12
|
+
coding: "agenticLoop.process.coding",
|
|
13
|
+
collecting_evidence: "agenticLoop.process.collectingEvidence",
|
|
14
|
+
auditing: "agenticLoop.process.auditing",
|
|
15
|
+
independent_auditing: "agenticLoop.process.independentAuditing",
|
|
16
|
+
checking_pr: "agenticLoop.process.checkingPr",
|
|
17
|
+
re_instructing: "agenticLoop.process.reInstructing",
|
|
18
|
+
pr_ready_for_review: "agenticLoop.process.readyForReview",
|
|
19
|
+
merge_ready: "agenticLoop.process.mergeReady",
|
|
20
|
+
waiting_human: "agenticLoop.process.waitingHuman",
|
|
21
|
+
blocked: "agenticLoop.process.blocked",
|
|
22
|
+
failed: "agenticLoop.process.failed",
|
|
23
|
+
stopped: "agenticLoop.process.stopped",
|
|
24
|
+
done: "agenticLoop.process.done",
|
|
25
|
+
});
|
|
26
|
+
const PROCESS_MESSAGE_PARAM_KEYS = Object.freeze([
|
|
27
|
+
"attempt_number",
|
|
28
|
+
"backend",
|
|
29
|
+
"previous_backend",
|
|
30
|
+
"next_backend",
|
|
31
|
+
"interrupted_state",
|
|
32
|
+
]);
|
|
33
|
+
const PROCESS_MESSAGE_KEY_BY_CODE = Object.freeze({
|
|
34
|
+
local_execution_interrupted: "agenticLoop.process.executionInterrupted",
|
|
35
|
+
local_repository_not_authorized: "agenticLoop.process.repositoryRequired",
|
|
36
|
+
local_repository_not_found: "agenticLoop.process.repositoryRequired",
|
|
37
|
+
retry_circuit_open: "agenticLoop.process.retryStrategyNeeded",
|
|
38
|
+
});
|
|
5
39
|
|
|
6
40
|
export function appendEvent(snapshot, state, message, detail = {}) {
|
|
7
|
-
snapshot
|
|
41
|
+
const recovery = recoveryContractForEvent({ snapshot, state, message, detail });
|
|
42
|
+
snapshot.events.push(
|
|
43
|
+
event(state, message, recovery ? { ...detail, recovery } : detail),
|
|
44
|
+
);
|
|
8
45
|
}
|
|
9
46
|
|
|
10
47
|
export function bullets(items) {
|
|
@@ -22,7 +59,35 @@ export function cleanString(value) {
|
|
|
22
59
|
}
|
|
23
60
|
|
|
24
61
|
export function event(state, message, detail = {}) {
|
|
25
|
-
return {
|
|
62
|
+
return {
|
|
63
|
+
state,
|
|
64
|
+
message,
|
|
65
|
+
detail: processMessageDetail(state, detail),
|
|
66
|
+
occurred_at: new Date().toISOString(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function processMessageDetail(state, detail = {}) {
|
|
71
|
+
const normalized = detail && typeof detail === "object" && !Array.isArray(detail) ? detail : {};
|
|
72
|
+
if (typeof normalized.message_key === "string" && normalized.message_key.trim()) {
|
|
73
|
+
return { ...normalized };
|
|
74
|
+
}
|
|
75
|
+
const messageKey =
|
|
76
|
+
PROCESS_MESSAGE_KEY_BY_CODE[normalized.code] ?? PROCESS_MESSAGE_KEY_BY_STATE[state];
|
|
77
|
+
if (!messageKey) return { ...normalized };
|
|
78
|
+
const messageParams = Object.fromEntries(
|
|
79
|
+
PROCESS_MESSAGE_PARAM_KEYS.flatMap((key) => {
|
|
80
|
+
const value = normalized[key];
|
|
81
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
|
82
|
+
? [[key, value]]
|
|
83
|
+
: [];
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
return {
|
|
87
|
+
...normalized,
|
|
88
|
+
message_key: messageKey,
|
|
89
|
+
message_params: messageParams,
|
|
90
|
+
};
|
|
26
91
|
}
|
|
27
92
|
|
|
28
93
|
export function evidenceKind(value) {
|
|
@@ -40,9 +105,26 @@ export function hasAllowedPrefix(value) {
|
|
|
40
105
|
}
|
|
41
106
|
|
|
42
107
|
export function looksLikeUiTask(value) {
|
|
43
|
-
return
|
|
44
|
-
|
|
45
|
-
|
|
108
|
+
return visualEvidenceSurface(value) !== null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function visualEvidenceSurface(value) {
|
|
112
|
+
const text = cleanString(value);
|
|
113
|
+
if (
|
|
114
|
+
/\b(browser|chrome|firefox|edge)\s+extension\b|\bextension\s+(popup|manifest|service worker)\b|拡張機能|浏览器扩展|瀏覽器擴充/i.test(
|
|
115
|
+
text,
|
|
116
|
+
)
|
|
117
|
+
) {
|
|
118
|
+
return "browser_extension";
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
/\b(ui|ux|page|screen|component|frontend|browser|css|tailwind|react|next)\b|画面|ページ|页面|界面|按钮|表示/i.test(
|
|
122
|
+
text,
|
|
123
|
+
)
|
|
124
|
+
) {
|
|
125
|
+
return "web_ui";
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
46
128
|
}
|
|
47
129
|
|
|
48
130
|
export async function publish(snapshot, state, onUpdate) {
|
|
@@ -112,6 +112,7 @@ export async function writeRepositoryInventory(
|
|
|
112
112
|
branch: item.branch,
|
|
113
113
|
default_branch: item.default_branch,
|
|
114
114
|
local_path: item.local_path,
|
|
115
|
+
...(item.capabilities?.length ? { capabilities: item.capabilities } : {}),
|
|
115
116
|
})),
|
|
116
117
|
},
|
|
117
118
|
null,
|
|
@@ -127,12 +128,14 @@ export function mergeRepositoryInventory(...inventories) {
|
|
|
127
128
|
const repository = normalizeGitHubRemote(item?.repository);
|
|
128
129
|
const localPath = String(item?.local_path || item?.localPath || "").trim();
|
|
129
130
|
if (!repository || !localPath) continue;
|
|
131
|
+
const capabilities = uniqueCapabilities(item?.capabilities);
|
|
130
132
|
byPath.set(resolve(localPath), {
|
|
131
133
|
repository,
|
|
132
134
|
branch: String(item?.branch || "").trim() || "dev",
|
|
133
135
|
default_branch:
|
|
134
136
|
String(item?.default_branch || item?.defaultBranch || "").trim() || "dev",
|
|
135
137
|
local_path: resolve(localPath),
|
|
138
|
+
...(capabilities.length ? { capabilities } : {}),
|
|
136
139
|
});
|
|
137
140
|
}
|
|
138
141
|
return [...byPath.values()].sort((left, right) =>
|
|
@@ -158,17 +161,88 @@ async function inspectGitRepository(directory) {
|
|
|
158
161
|
]);
|
|
159
162
|
const repository = normalizeGitHubRemote(remote);
|
|
160
163
|
if (!repository) return null;
|
|
164
|
+
const capabilities = await inspectRepositoryCapabilities(directory);
|
|
161
165
|
return {
|
|
162
166
|
repository,
|
|
163
167
|
branch: String(branch || "").trim() || "dev",
|
|
164
168
|
default_branch: await detectDefaultBranch(directory),
|
|
165
169
|
local_path: String(root || "").trim(),
|
|
170
|
+
...(capabilities.length ? { capabilities } : {}),
|
|
166
171
|
};
|
|
167
172
|
} catch {
|
|
168
173
|
return null;
|
|
169
174
|
}
|
|
170
175
|
}
|
|
171
176
|
|
|
177
|
+
async function inspectRepositoryCapabilities(directory) {
|
|
178
|
+
const capabilities = [];
|
|
179
|
+
const rootEntries = await safeDirectoryNames(directory);
|
|
180
|
+
const packageJson = await readJsonFile(join(directory, "package.json"));
|
|
181
|
+
const packageNames = Object.keys({
|
|
182
|
+
...(packageJson?.dependencies || {}),
|
|
183
|
+
...(packageJson?.devDependencies || {}),
|
|
184
|
+
});
|
|
185
|
+
const pythonConfig = await readTextFile(join(directory, "pyproject.toml"));
|
|
186
|
+
const extensionManifest =
|
|
187
|
+
(await readJsonFile(join(directory, "extension", "manifest.json"))) ||
|
|
188
|
+
(await readJsonFile(join(directory, "manifest.json")));
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
packageNames.some((name) => ["next", "react-dom", "vue", "svelte"].includes(name)) ||
|
|
192
|
+
[...rootEntries].some((name) => /^next\.config\./.test(name))
|
|
193
|
+
) {
|
|
194
|
+
capabilities.push("web_ui");
|
|
195
|
+
}
|
|
196
|
+
if ([2, 3].includes(extensionManifest?.manifest_version)) {
|
|
197
|
+
capabilities.push("browser_extension");
|
|
198
|
+
}
|
|
199
|
+
if (rootEntries.has("pubspec.yaml")) capabilities.push("mobile_ui");
|
|
200
|
+
if (rootEntries.has("supabase") || rootEntries.has("migrations")) {
|
|
201
|
+
capabilities.push("database_backend");
|
|
202
|
+
}
|
|
203
|
+
if (
|
|
204
|
+
packageJson?.bin ||
|
|
205
|
+
packageNames.some((name) => ["commander", "ink", "yargs", "oclif"].includes(name))
|
|
206
|
+
) {
|
|
207
|
+
capabilities.push("cli");
|
|
208
|
+
}
|
|
209
|
+
if (/\b(langgraph|langchain|crewai|autogen|openai-agents)\b/i.test(pythonConfig)) {
|
|
210
|
+
capabilities.push("agent_runtime");
|
|
211
|
+
}
|
|
212
|
+
if (
|
|
213
|
+
/\b(fastapi|django|flask)\b/i.test(pythonConfig) ||
|
|
214
|
+
packageNames.some((name) => ["express", "fastify", "hono", "koa"].includes(name))
|
|
215
|
+
) {
|
|
216
|
+
capabilities.push("backend_api");
|
|
217
|
+
}
|
|
218
|
+
return uniqueCapabilities(capabilities);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function safeDirectoryNames(directory) {
|
|
222
|
+
try {
|
|
223
|
+
return new Set((await readdir(directory, { withFileTypes: true })).map((entry) => entry.name));
|
|
224
|
+
} catch {
|
|
225
|
+
return new Set();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function readJsonFile(filePath) {
|
|
230
|
+
try {
|
|
231
|
+
const parsed = JSON.parse(await readFile(filePath, "utf8"));
|
|
232
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function readTextFile(filePath) {
|
|
239
|
+
try {
|
|
240
|
+
return await readFile(filePath, "utf8");
|
|
241
|
+
} catch {
|
|
242
|
+
return "";
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
172
246
|
async function detectDefaultBranch(directory) {
|
|
173
247
|
try {
|
|
174
248
|
const { stdout } = await execFileAsync(
|
|
@@ -207,6 +281,19 @@ function uniqueStrings(values) {
|
|
|
207
281
|
return [...new Set((Array.isArray(values) ? values : []).map(String).filter(Boolean))];
|
|
208
282
|
}
|
|
209
283
|
|
|
284
|
+
function uniqueCapabilities(values) {
|
|
285
|
+
const allowed = new Set([
|
|
286
|
+
"web_ui",
|
|
287
|
+
"browser_extension",
|
|
288
|
+
"mobile_ui",
|
|
289
|
+
"database_backend",
|
|
290
|
+
"backend_api",
|
|
291
|
+
"agent_runtime",
|
|
292
|
+
"cli",
|
|
293
|
+
]);
|
|
294
|
+
return [...new Set((Array.isArray(values) ? values : []).filter((value) => allowed.has(value)))].sort();
|
|
295
|
+
}
|
|
296
|
+
|
|
210
297
|
function emptyInventory() {
|
|
211
298
|
return { version: INVENTORY_VERSION, authorizedRoots: [], repositories: [], scannedAt: null };
|
|
212
299
|
}
|