triflux 10.9.12 → 10.9.13

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/bin/triflux.mjs CHANGED
@@ -289,6 +289,41 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
289
289
  },
290
290
  },
291
291
  },
292
+ synapse: {
293
+ usage: "tfx synapse status [--json] [--registry <path>]",
294
+ description: "Synapse v1 세션 레지스트리 조회 (활성 스웜 세션 목록)",
295
+ subcommands: {
296
+ status: "활성 세션 테이블 표시 (host/branch/dirty/state/task)",
297
+ },
298
+ options: [
299
+ {
300
+ name: "--json",
301
+ type: "boolean",
302
+ description: "구조화된 JSON 출력",
303
+ },
304
+ {
305
+ name: "--registry",
306
+ type: "string",
307
+ description: "registry 파일 경로 오버라이드",
308
+ },
309
+ ],
310
+ },
311
+ why: {
312
+ usage: "tfx why <path> [--json]",
313
+ description: "해당 경로의 마지막 커밋에서 X-Intent 트레일러 추출",
314
+ options: [
315
+ {
316
+ name: "path",
317
+ type: "string",
318
+ description: "intent를 조회할 파일 경로",
319
+ },
320
+ {
321
+ name: "--json",
322
+ type: "boolean",
323
+ description: "구조화된 JSON 출력",
324
+ },
325
+ ],
326
+ },
292
327
  hub: {
293
328
  usage: "tfx hub <start|stop|status|ensure> [--port N] [--json]",
294
329
  description: "tfx-hub 프로세스 제어",
@@ -5306,6 +5341,26 @@ async function main() {
5306
5341
  }
5307
5342
  return;
5308
5343
  }
5344
+ case "synapse": {
5345
+ const { cmdSynapseStatus } = await import(
5346
+ "../hub/team/synapse-cli.mjs"
5347
+ );
5348
+ const sub = cmdArgs[0] || "status";
5349
+ if (sub !== "status") {
5350
+ throw createCliError(`synapse 서브커맨드 미지원: ${sub}`, {
5351
+ exitCode: EXIT_ARG_ERROR,
5352
+ reason: "argError",
5353
+ fix: "tfx synapse status [--json] [--registry <path>]",
5354
+ });
5355
+ }
5356
+ await cmdSynapseStatus(cmdArgs.slice(1), { json: JSON_OUTPUT });
5357
+ return;
5358
+ }
5359
+ case "why": {
5360
+ const { cmdSynapseWhy } = await import("../hub/team/synapse-cli.mjs");
5361
+ await cmdSynapseWhy(cmdArgs, { json: JSON_OUTPUT });
5362
+ return;
5363
+ }
5309
5364
  case "version":
5310
5365
  case "--version":
5311
5366
  case "-v":
package/hub/server.mjs CHANGED
@@ -1486,6 +1486,17 @@ export async function startHub({
1486
1486
  "hub.public_dir",
1487
1487
  );
1488
1488
 
1489
+ /**
1490
+ * Hub 서버 정지 함수.
1491
+ *
1492
+ * Trade-off (F01 — 영구 poisoning 허용):
1493
+ * 첫 정지 호출에서 cleanup 파이프라인이 실패하면 stopPromise는 실패 상태로
1494
+ * 고정되고, 이후 모든 호출은 동일한 실패 promise를 반환합니다. stopPromise를
1495
+ * null로 리셋하면 재시도가 가능하지만, router sweeper / transports / pipe 등이
1496
+ * 이미 부분 해제된 상태에서 두 번째 close가 실행되면 use-after-close 및
1497
+ * race condition을 유발합니다. 실패한 stopFn은 프로세스 전체 재시작으로 복구해야
1498
+ * 하며, 이 동작은 의도된 설계입니다.
1499
+ */
1489
1500
  const stopFn = async () => {
1490
1501
  if (stopPromise) return stopPromise;
1491
1502
 
@@ -1682,9 +1693,9 @@ function cleanupStaleSpawnSessions(log) {
1682
1693
  const QUOTA_CACHE_PATH = join(CACHE_DIR, "broker-quota-cache.json");
1683
1694
 
1684
1695
  async function checkSingleAccountQuota(acct) {
1685
- const authPath = join(PID_DIR, acct.authFile);
1686
- if (!existsSync(authPath)) return { id: acct.id, status: "no_auth" };
1687
1696
  try {
1697
+ const authPath = join(PID_DIR, acct.authFile);
1698
+ if (!existsSync(authPath)) return { id: acct.id, status: "no_auth" };
1688
1699
  const auth = JSON.parse(readFileSync(authPath, "utf8"));
1689
1700
  if (acct.provider === "codex") {
1690
1701
  const token = auth.tokens?.access_token || auth.OPENAI_API_KEY || "";
@@ -1712,7 +1723,12 @@ async function checkSingleAccountQuota(acct) {
1712
1723
  async function refreshAllAccountQuotas() {
1713
1724
  const snap = brokerInstance?.snapshot() || [];
1714
1725
  const checks = snap.filter(a => a.authFile).map(a => checkSingleAccountQuota(a));
1715
- const results = await Promise.all(checks);
1726
+ const settled = await Promise.allSettled(checks);
1727
+ const results = settled.map((s, i) =>
1728
+ s.status === "fulfilled"
1729
+ ? s.value
1730
+ : { id: snap.filter(a => a.authFile)[i]?.id ?? "unknown", status: "error", message: String(s.reason?.message || s.reason).substring(0, 60) },
1731
+ );
1716
1732
  // 캐시 저장
1717
1733
  try {
1718
1734
  writeFileSync(QUOTA_CACHE_PATH, JSON.stringify({ ts: Date.now(), results }));
@@ -0,0 +1,286 @@
1
+ // hub/team/git-preflight.mjs — Pre-flight safety check for dangerous git ops.
2
+ // Blocks checkout/rebase/cherry-pick/reset/stash-pop/worktree-remove when they
3
+ // would conflict with other active Synapse sessions' dirty files or claimed
4
+ // leases. Fail-open by default: if registry/locks are unavailable, allow the op
5
+ // with a warning (worktree isolation is the baseline safety net).
6
+
7
+ /**
8
+ * Factory for git pre-flight check.
9
+ *
10
+ * @param {object} opts
11
+ * @param {{ getActive: () => Array<object> }} opts.registry — synapse-registry duck type
12
+ * @param {{ snapshot: () => Array<object> }} opts.locks — swarm-locks duck type
13
+ * @param {boolean} [opts.failOpen=true] — allow op when registry/locks throw or are missing
14
+ * @param {(level: string, msg: string, data?: object) => void} [opts.logger] — optional logger
15
+ * @returns {{
16
+ * check: Function,
17
+ * checkRebase: Function,
18
+ * checkCheckout: Function,
19
+ * checkCherryPick: Function,
20
+ * checkReset: Function,
21
+ * checkStashPop: Function,
22
+ * checkWorktreeRemove: Function,
23
+ * }}
24
+ */
25
+ export function createGitPreflight(opts = {}) {
26
+ const { registry, locks, failOpen = true, logger = null } = opts;
27
+
28
+ function log(level, msg, data) {
29
+ if (logger) {
30
+ try {
31
+ logger(level, msg, data);
32
+ } catch {
33
+ /* logger failure is non-fatal */
34
+ }
35
+ return;
36
+ }
37
+ if (level === "warn") {
38
+ console.warn(`[git-preflight] ${msg}`, data ?? "");
39
+ } else if (level === "error") {
40
+ console.error(`[git-preflight] ${msg}`, data ?? "");
41
+ }
42
+ }
43
+
44
+ // ── helpers ──────────────────────────────────────────────
45
+
46
+ function safeGetActive() {
47
+ try {
48
+ const value = registry?.getActive?.();
49
+ if (!Array.isArray(value)) return null;
50
+ return value;
51
+ } catch (err) {
52
+ log("warn", "registry.getActive threw", { error: err?.message });
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function safeSnapshot() {
58
+ try {
59
+ const value = locks?.snapshot?.();
60
+ if (!Array.isArray(value)) return null;
61
+ return value;
62
+ } catch (err) {
63
+ log("warn", "locks.snapshot threw", { error: err?.message });
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function failOpenDecision(reason) {
69
+ log("warn", "hub unavailable, failing open", { reason });
70
+ return { allowed: true, reason: "hub_unavailable_fail_open" };
71
+ }
72
+
73
+ function normalizeFiles(list) {
74
+ if (!Array.isArray(list)) return new Set();
75
+ return new Set(
76
+ list
77
+ .filter((f) => typeof f === "string" && f.length > 0)
78
+ .map((f) => f.replace(/\\/g, "/")),
79
+ );
80
+ }
81
+
82
+ function otherActiveSessions(active, sessionId) {
83
+ return active.filter((s) => s && s.sessionId && s.sessionId !== sessionId);
84
+ }
85
+
86
+ function otherLeases(snapshot, workerId) {
87
+ return snapshot.filter(
88
+ (entry) => entry && entry.workerId && entry.workerId !== workerId,
89
+ );
90
+ }
91
+
92
+ function buildConflict(file, session, holder) {
93
+ return {
94
+ file,
95
+ activeSession: session?.sessionId ?? holder ?? "unknown",
96
+ activeTask: session?.taskSummary ?? "",
97
+ ...(holder ? { leaseHolder: holder } : {}),
98
+ };
99
+ }
100
+
101
+ function recommend(conflicts) {
102
+ if (!conflicts.length) return "";
103
+ const first = conflicts[0];
104
+ const owner = first.leaseHolder || first.activeSession;
105
+ const task = first.activeTask
106
+ ? ` Active task: '${first.activeTask}'`
107
+ : "";
108
+ return `Wait for '${owner}' to finish, or coordinate via HITL.${task}`;
109
+ }
110
+
111
+ function blockedDecision(op, conflicts, extraReason) {
112
+ return {
113
+ allowed: false,
114
+ reason: extraReason || "overlap_with_active_session",
115
+ conflicts,
116
+ recommendation: recommend(conflicts),
117
+ op,
118
+ };
119
+ }
120
+
121
+ function allowedDecision() {
122
+ return { allowed: true };
123
+ }
124
+
125
+ // ── shared scanners ─────────────────────────────────────
126
+
127
+ function findDirtyFileConflicts(sessionId, workerId) {
128
+ const active = safeGetActive();
129
+ const snapshot = safeSnapshot();
130
+
131
+ if (active == null || snapshot == null) {
132
+ return failOpen ? { fallOpen: true } : { fallOpen: false, conflicts: [] };
133
+ }
134
+
135
+ const conflicts = [];
136
+ const seen = new Set();
137
+
138
+ for (const session of otherActiveSessions(active, sessionId)) {
139
+ const dirty = normalizeFiles(session.dirtyFiles);
140
+ for (const file of dirty) {
141
+ if (seen.has(file)) continue;
142
+ seen.add(file);
143
+ conflicts.push(buildConflict(file, session));
144
+ }
145
+ }
146
+
147
+ for (const lease of otherLeases(snapshot, workerId)) {
148
+ if (lease.leaseType && lease.leaseType !== "exclusive") continue;
149
+ const file = typeof lease.file === "string" ? lease.file : null;
150
+ if (!file || seen.has(file)) continue;
151
+ seen.add(file);
152
+ const sessionMeta = lease.sessionMeta || null;
153
+ conflicts.push(
154
+ buildConflict(
155
+ file,
156
+ sessionMeta
157
+ ? {
158
+ sessionId: sessionMeta.sessionId || lease.workerId,
159
+ taskSummary: sessionMeta.taskSummary || "",
160
+ }
161
+ : null,
162
+ lease.workerId,
163
+ ),
164
+ );
165
+ }
166
+
167
+ return { conflicts };
168
+ }
169
+
170
+ function findTargetFileConflicts(targetFiles, sessionId, workerId) {
171
+ const active = safeGetActive();
172
+ const snapshot = safeSnapshot();
173
+
174
+ if (active == null || snapshot == null) {
175
+ return failOpen ? { fallOpen: true } : { fallOpen: false, conflicts: [] };
176
+ }
177
+
178
+ const targets = normalizeFiles(targetFiles);
179
+ if (targets.size === 0) return { conflicts: [] };
180
+
181
+ const conflicts = [];
182
+ const seen = new Set();
183
+
184
+ for (const session of otherActiveSessions(active, sessionId)) {
185
+ const dirty = normalizeFiles(session.dirtyFiles);
186
+ for (const file of dirty) {
187
+ if (!targets.has(file) || seen.has(file)) continue;
188
+ seen.add(file);
189
+ conflicts.push(buildConflict(file, session));
190
+ }
191
+ }
192
+
193
+ for (const lease of otherLeases(snapshot, workerId)) {
194
+ if (lease.leaseType && lease.leaseType !== "exclusive") continue;
195
+ const file = typeof lease.file === "string" ? lease.file : null;
196
+ if (!file || !targets.has(file) || seen.has(file)) continue;
197
+ seen.add(file);
198
+ const sessionMeta = lease.sessionMeta || null;
199
+ conflicts.push(
200
+ buildConflict(
201
+ file,
202
+ sessionMeta
203
+ ? {
204
+ sessionId: sessionMeta.sessionId || lease.workerId,
205
+ taskSummary: sessionMeta.taskSummary || "",
206
+ }
207
+ : null,
208
+ lease.workerId,
209
+ ),
210
+ );
211
+ }
212
+
213
+ return { conflicts };
214
+ }
215
+
216
+ // ── public API ──────────────────────────────────────────
217
+
218
+ /**
219
+ * Core pre-flight check.
220
+ *
221
+ * @param {"checkout"|"rebase"|"cherry-pick"|"reset"|"stash-pop"|"worktree-remove"} op
222
+ * @param {{ targetFiles?: string[], branch?: string, ref?: string, worktreePath?: string }} args
223
+ * @param {{ sessionId: string, workerId?: string }} sessionContext
224
+ * @returns {{ allowed: boolean, reason?: string, conflicts?: object[], recommendation?: string, op?: string }}
225
+ */
226
+ function check(op, args = {}, sessionContext = {}) {
227
+ const sessionId = sessionContext.sessionId || "";
228
+ const workerId = sessionContext.workerId || sessionId;
229
+
230
+ switch (op) {
231
+ case "checkout":
232
+ case "rebase":
233
+ case "reset":
234
+ case "stash-pop": {
235
+ const res = findDirtyFileConflicts(sessionId, workerId);
236
+ if (res.fallOpen) return failOpenDecision(op);
237
+ if (res.conflicts.length > 0) return blockedDecision(op, res.conflicts);
238
+ return allowedDecision();
239
+ }
240
+ case "cherry-pick": {
241
+ const targets = Array.isArray(args.targetFiles) ? args.targetFiles : [];
242
+ const res = findTargetFileConflicts(targets, sessionId, workerId);
243
+ if (res.fallOpen) return failOpenDecision(op);
244
+ if (res.conflicts.length > 0) return blockedDecision(op, res.conflicts);
245
+ return allowedDecision();
246
+ }
247
+ case "worktree-remove": {
248
+ const active = safeGetActive();
249
+ if (active == null) return failOpenDecision(op);
250
+ const target = typeof args.worktreePath === "string"
251
+ ? args.worktreePath.replace(/\\/g, "/")
252
+ : "";
253
+ if (!target) return allowedDecision();
254
+ const conflicts = [];
255
+ for (const session of otherActiveSessions(active, sessionId)) {
256
+ const sessionPath = typeof session.worktreePath === "string"
257
+ ? session.worktreePath.replace(/\\/g, "/")
258
+ : "";
259
+ if (sessionPath && sessionPath === target) {
260
+ conflicts.push({
261
+ file: target,
262
+ activeSession: session.sessionId,
263
+ activeTask: session.taskSummary || "",
264
+ });
265
+ }
266
+ }
267
+ if (conflicts.length > 0) {
268
+ return blockedDecision(op, conflicts, "active_worktree");
269
+ }
270
+ return allowedDecision();
271
+ }
272
+ default:
273
+ return allowedDecision();
274
+ }
275
+ }
276
+
277
+ return Object.freeze({
278
+ check,
279
+ checkRebase: (args, ctx) => check("rebase", args, ctx),
280
+ checkCheckout: (args, ctx) => check("checkout", args, ctx),
281
+ checkCherryPick: (args, ctx) => check("cherry-pick", args, ctx),
282
+ checkReset: (args, ctx) => check("reset", args, ctx),
283
+ checkStashPop: (args, ctx) => check("stash-pop", args, ctx),
284
+ checkWorktreeRemove: (args, ctx) => check("worktree-remove", args, ctx),
285
+ });
286
+ }
@@ -0,0 +1,216 @@
1
+ // hub/team/swarm-intent.mjs — X-Intent helper utilities for semantic merge awareness
2
+
3
+ const INTENT_TRAILER_REGEX = /^X-Intent:\s*(.+)$/m;
4
+
5
+ function normalizeText(value) {
6
+ return typeof value === "string" ? value.trim() : "";
7
+ }
8
+
9
+ function normalizeTouches(touches) {
10
+ if (!Array.isArray(touches)) return null;
11
+ return touches
12
+ .filter((item) => typeof item === "string")
13
+ .map((item) => item.trim().replace(/\\/g, "/"))
14
+ .filter(Boolean);
15
+ }
16
+
17
+ function normalizeIntent(intentObj) {
18
+ if (!intentObj || typeof intentObj !== "object" || Array.isArray(intentObj)) {
19
+ return null;
20
+ }
21
+
22
+ const scope = normalizeText(intentObj.scope);
23
+ const action = normalizeText(intentObj.action);
24
+ const reason = normalizeText(intentObj.reason);
25
+ const touches = normalizeTouches(intentObj.touches);
26
+
27
+ if (!scope || !action || !reason || !touches) {
28
+ return null;
29
+ }
30
+
31
+ return {
32
+ scope,
33
+ action,
34
+ reason,
35
+ touches,
36
+ invariant: normalizeText(intentObj.invariant),
37
+ conflictsWith: normalizeText(intentObj.conflictsWith),
38
+ };
39
+ }
40
+
41
+ function fallbackIntent(llmResponse) {
42
+ const responseText = String(llmResponse ?? "");
43
+ return {
44
+ scope: "unknown",
45
+ action: "unknown",
46
+ reason: responseText.slice(0, 100),
47
+ touches: [],
48
+ invariant: "",
49
+ conflictsWith: "",
50
+ };
51
+ }
52
+
53
+ function includesEitherWay(a, b) {
54
+ if (!a || !b) return false;
55
+ return a.includes(b) || b.includes(a);
56
+ }
57
+
58
+ function hasConflict(intentA, intentB) {
59
+ const conflictsWith = normalizeText(intentA?.conflictsWith).toLowerCase();
60
+ const scope = normalizeText(intentB?.scope).toLowerCase();
61
+ const action = normalizeText(intentB?.action).toLowerCase();
62
+
63
+ if (!conflictsWith) return false;
64
+ return (
65
+ includesEitherWay(conflictsWith, scope) ||
66
+ includesEitherWay(conflictsWith, action)
67
+ );
68
+ }
69
+
70
+ function getTouchesOverlap(intentA, intentB) {
71
+ const touchesA = new Set(
72
+ (normalizeTouches(intentA?.touches) ?? []).map((item) =>
73
+ item.toLowerCase(),
74
+ ),
75
+ );
76
+ const touchesB = (normalizeTouches(intentB?.touches) ?? []).map((item) =>
77
+ item.toLowerCase(),
78
+ );
79
+ return touchesB.filter((item) => touchesA.has(item));
80
+ }
81
+
82
+ /**
83
+ * Build prompts for an LLM to generate an X-Intent JSON object.
84
+ * @param {string[]} changedFiles
85
+ * @param {string} commitDiff
86
+ * @param {string} taskContext
87
+ * @returns {{ systemPrompt: string, userPrompt: string }}
88
+ */
89
+ export function generateIntentPrompt(changedFiles, commitDiff, taskContext) {
90
+ const files = Array.isArray(changedFiles)
91
+ ? changedFiles.filter((filePath) => typeof filePath === "string")
92
+ : [];
93
+ const diffSnippet = String(commitDiff ?? "").slice(0, 4000);
94
+ const context = String(taskContext ?? "").trim();
95
+
96
+ return {
97
+ systemPrompt:
98
+ "You generate commit intent metadata for semantic merge awareness. Return only valid JSON with keys: scope, action, reason, touches, invariant, conflictsWith.",
99
+ userPrompt: [
100
+ "Create an intent object from the following commit context.",
101
+ "- scope/action/reason must be concise strings.",
102
+ "- touches must be an array of touched file paths.",
103
+ "- invariant/conflictsWith should be strings (empty if unknown).",
104
+ "- Return JSON only (no markdown).",
105
+ "",
106
+ `Task context:\n${context || "(none provided)"}`,
107
+ "",
108
+ `Changed files (${files.length}):\n${files.join("\n") || "(none)"}`,
109
+ "",
110
+ "Commit diff (first 4000 chars):",
111
+ diffSnippet || "(empty)",
112
+ ].join("\n"),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Format an intent object into an X-Intent commit trailer.
118
+ * @param {object} intentObj
119
+ * @returns {string}
120
+ */
121
+ export function formatIntentTrailer(intentObj) {
122
+ return `X-Intent: ${JSON.stringify(intentObj)}`;
123
+ }
124
+
125
+ /**
126
+ * Parse an X-Intent trailer from a commit message.
127
+ * @param {string} commitMessage
128
+ * @returns {object | null}
129
+ */
130
+ export function parseIntentTrailer(commitMessage) {
131
+ const message = String(commitMessage ?? "");
132
+ const match = message.match(INTENT_TRAILER_REGEX);
133
+ if (!match) return null;
134
+
135
+ try {
136
+ return JSON.parse(match[1]);
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Classify the relation between two intents.
144
+ * @param {object} intentA
145
+ * @param {object} intentB
146
+ * @returns {{ relation: "complementary" | "complementary-risky" | "contradictory" | "independent", reason: string }}
147
+ */
148
+ export function classifyIntentPair(intentA, intentB) {
149
+ if (hasConflict(intentA, intentB) || hasConflict(intentB, intentA)) {
150
+ return {
151
+ relation: "contradictory",
152
+ reason:
153
+ "conflictsWith on one intent matches the other intent scope/action",
154
+ };
155
+ }
156
+
157
+ const scopeA = normalizeText(intentA?.scope).toLowerCase();
158
+ const scopeB = normalizeText(intentB?.scope).toLowerCase();
159
+
160
+ if (scopeA && scopeB && scopeA === scopeB) {
161
+ const overlap = getTouchesOverlap(intentA, intentB);
162
+ if (overlap.length > 0) {
163
+ return {
164
+ relation: "complementary-risky",
165
+ reason: `same scope with overlapping touched files: ${overlap.join(", ")}`,
166
+ };
167
+ }
168
+
169
+ return {
170
+ relation: "complementary",
171
+ reason:
172
+ "same scope with no conflicting intent and no touched-file overlap",
173
+ };
174
+ }
175
+
176
+ return {
177
+ relation: "independent",
178
+ reason: "different scopes and no conflictsWith match detected",
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Build a validated intent object from raw LLM response text.
184
+ * @param {string} llmResponse
185
+ * @returns {{ scope: string, action: string, reason: string, touches: string[], invariant: string, conflictsWith: string }}
186
+ */
187
+ export function buildIntentFromLLMResponse(llmResponse) {
188
+ const responseText = String(llmResponse ?? "").trim();
189
+ const candidates = [];
190
+
191
+ const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)```/gi;
192
+ for (const match of responseText.matchAll(codeBlockRegex)) {
193
+ if (match[1]) candidates.push(match[1].trim());
194
+ }
195
+
196
+ candidates.push(responseText);
197
+
198
+ const firstBrace = responseText.indexOf("{");
199
+ const lastBrace = responseText.lastIndexOf("}");
200
+ if (firstBrace >= 0 && lastBrace > firstBrace) {
201
+ candidates.push(responseText.slice(firstBrace, lastBrace + 1));
202
+ }
203
+
204
+ for (const candidate of candidates) {
205
+ if (!candidate) continue;
206
+ try {
207
+ const parsed = JSON.parse(candidate);
208
+ const normalized = normalizeIntent(parsed);
209
+ if (normalized) return normalized;
210
+ } catch {
211
+ // ignore parse failures and continue trying other candidates
212
+ }
213
+ }
214
+
215
+ return fallbackIntent(responseText);
216
+ }