@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.
@@ -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.events.push(event(state, message, detail));
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 { state, message, detail, occurred_at: new Date().toISOString() };
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 /\b(ui|ux|page|screen|component|frontend|browser|css|tailwind|react|next)\b|画面|页面|界面|按钮|表示/i.test(
44
- cleanString(value),
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
  }