@galda/cli 0.10.16 → 0.10.19

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/engine/lib.mjs CHANGED
@@ -5,6 +5,62 @@ import { pathToFileURL } from 'node:url';
5
5
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
6
  import { createPublicKey, verify as verifyRsaSignature } from 'node:crypto';
7
7
 
8
+ // Worker agents Galda can drive. Galda is NOT tied to Claude Code — it runs on
9
+ // whichever agent CLI you have. Neither is auto-installed and codex is never even
10
+ // executed to detect it (running the codex binary can trip macOS XProtect — a
11
+ // false-positive that looks like Galda shipped malware); its presence is detected
12
+ // by a plain PATH lookup (isCommandOnPath). An agent is offered iff its CLI is
13
+ // installed, so a Codex-only machine (no claude) still gets a working board.
14
+ export const SUPPORTED_WORKER_AGENTS = ['claude-code', 'codex'];
15
+
16
+ // Canonical order, filtered to what's actually installed on this machine.
17
+ export function resolveConnectedAgents({ claude = false, codex = false } = {}) {
18
+ const agents = [];
19
+ if (claude) agents.push('claude-code');
20
+ if (codex) agents.push('codex');
21
+ return agents;
22
+ }
23
+
24
+ // Clamp a requested agent to one that's actually available — so a stale UI
25
+ // default (e.g. 'claude-code' on a Codex-only machine) still runs on the agent
26
+ // the user has, instead of spawning a `claude` that isn't there.
27
+ export function pickAvailableAgent(requested, available = []) {
28
+ const list = Array.isArray(available) ? available.filter(Boolean) : [];
29
+ if (requested && list.includes(requested)) return requested;
30
+ return list[0] ?? 'claude-code';
31
+ }
32
+
33
+ // Is `cmd` an executable on PATH? Detected WITHOUT running it (pure fs lookup) —
34
+ // this is how codex is detected so Galda never executes the binary. On Windows a
35
+ // bare `codex` resolves via PATHEXT (.cmd/.exe/…); pass pathExt + sep=';' there.
36
+ export function isCommandOnPath(cmd, { pathEnv = '', sep = ':', pathExt = '', existsFn } = {}) {
37
+ if (!cmd) return false;
38
+ const exists = existsFn || existsSync;
39
+ const dirs = String(pathEnv).split(sep).filter(Boolean);
40
+ const exts = ['', ...String(pathExt).split(';').map((e) => e.trim()).filter(Boolean)];
41
+ for (const dir of dirs) {
42
+ for (const ext of exts) {
43
+ try { if (exists(join(dir, cmd + ext))) return true; } catch { /* unreadable dir — skip */ }
44
+ }
45
+ }
46
+ return false;
47
+ }
48
+
49
+ // Galda makes small internal LLM calls (composer-intent classification, proof-card
50
+ // summaries, board Q&A). These prefer Claude Code + haiku (cheap + fast), but must
51
+ // still work on a Codex-only machine — so they run on whatever agent is available,
52
+ // NOT a hardcoded `claude` that isn't installed. Pure so the routing is testable.
53
+ export function pickUtilityAgent(available = []) {
54
+ const list = Array.isArray(available) ? available.filter(Boolean) : [];
55
+ if (list.includes('claude-code')) return 'claude-code';
56
+ return list[0] ?? 'claude-code';
57
+ }
58
+ // The cheapest/fastest model for each agent's small utility calls. (runCodex
59
+ // re-validates against the real Codex model list, so an unknown value is safe.)
60
+ export function utilityModel(agent) {
61
+ return agent === 'codex' ? 'gpt-5.4-mini' : 'haiku';
62
+ }
63
+
8
64
  // Build the URL a headless-browser verifier should open for a goal's entry
9
65
  // point. Plain http(s) entries pass through unchanged. A relative path is
10
66
  // normally opened as a bare file:// page — but the Manager's own UI
@@ -206,23 +262,71 @@ export function buildContextHandoffSummary({ goal = null, tasks = [] } = {}) {
206
262
  ].filter(Boolean).join('\n').slice(0, 4000);
207
263
  }
208
264
 
209
- export function estimateTokenOptimization({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null } = {}) {
265
+ // task 6 (2026-07-16): "saved X%" used to be a proxy for cache-hit ratio
266
+ // this goal's own cache-read tokens discounted 0.1x, compared against this
267
+ // same goal's raw total. That's real math, but it has nothing to do with
268
+ // "cheaper than usual": a goal with heavy caching always scores high even if
269
+ // it was worse than the norm, and a goal with no caching always scores 0
270
+ // even if it finished in a third of the tokens via a tight plan. Below,
271
+ // `tokenBaselinePerRequirement` turns a list of past finished goals into a
272
+ // moving average of tokens-per-requirement, so `estimateTokenOptimization`
273
+ // can compare THIS goal against that real baseline when enough history
274
+ // exists (`basis: 'baseline'`) — and only falls back to the old cache-ratio
275
+ // proxy (`basis: 'cache-weight'`) when there isn't enough history yet.
276
+ const TOKEN_BASELINE_MIN_HISTORY = 3;
277
+ const TOKEN_BASELINE_MAX_HISTORY = 20;
278
+
279
+ export function tokenBaselinePerRequirement(history = []) {
280
+ const samples = (history ?? [])
281
+ .map((h) => {
282
+ if (typeof h === 'number') return h;
283
+ const tokens = Number(h?.weightedTokens);
284
+ const reqN = Math.max(1, Number(h?.requirementCount) || 1);
285
+ return Number.isFinite(tokens) ? tokens / reqN : null;
286
+ })
287
+ .filter((n) => Number.isFinite(n) && n > 0)
288
+ .slice(-TOKEN_BASELINE_MAX_HISTORY);
289
+ if (samples.length < TOKEN_BASELINE_MIN_HISTORY) return null;
290
+ return samples.reduce((a, b) => a + b, 0) / samples.length;
291
+ }
292
+
293
+ export function estimateTokenOptimization({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null, requirementCount = 1, history = [] } = {}) {
210
294
  const u = usage ?? {};
211
295
  const raw = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
296
+ if (raw <= 0) return null;
212
297
  const weighted = weightedTokens != null && Number.isFinite(Number(weightedTokens))
213
298
  ? Number(weightedTokens)
214
299
  : usageBudgetTokens(u, { cacheReadWeight });
215
- const saved = Math.max(0, raw - weighted);
216
- const percent = raw > 0 ? Math.round((saved / raw) * 100) : 0;
300
+ const reqN = Math.max(1, Number(requirementCount) || 1);
301
+ const perReq = weighted / reqN;
302
+ const baseline = tokenBaselinePerRequirement(history);
303
+
304
+ let percent, basis, savedTokens;
305
+ if (baseline != null) {
306
+ percent = Math.max(-100, Math.min(100, Math.round(((baseline - perReq) / baseline) * 100)));
307
+ basis = 'baseline';
308
+ savedTokens = Math.max(0, Math.round((baseline - perReq) * reqN));
309
+ } else {
310
+ savedTokens = Math.max(0, raw - weighted);
311
+ percent = Math.round((savedTokens / raw) * 100);
312
+ basis = 'cache-weight';
313
+ }
314
+
315
+ // "why": always at least one concrete, data-derived sentence — never a
316
+ // generic "session planning" filler with nothing behind it.
217
317
  const techniques = [];
218
- if ((u.cache_read_input_tokens ?? 0) > 0) techniques.push('cache-read weighting');
219
- if (['compact', 'cold-handoff'].includes(sessionMode)) techniques.push(sessionMode === 'compact' ? 'compact handoff' : 'fresh handoff');
220
- if (usedHandoff) techniques.push('AI handoff');
221
- if (usedCompact) techniques.push('/compact');
222
- if (usedClear) techniques.push('/clear');
223
- if (agentModel) techniques.push(`model ${agentModel}`);
224
- if (!percent && !techniques.length) return null;
225
- return { rawTokens: raw, weightedTokens: weighted, savedTokens: saved, percent, techniques: [...new Set(techniques)] };
318
+ const cacheRatio = Math.round(((u.cache_read_input_tokens ?? 0) / raw) * 100);
319
+ if (cacheRatio > 0) techniques.push(`${cacheRatio}% of tokens served from cache`);
320
+ if (usedHandoff || sessionMode === 'cold-handoff') techniques.push('fresh handoff instead of a bloated session');
321
+ if (usedCompact || sessionMode === 'compact') techniques.push('/compact before continuing');
322
+ if (usedClear) techniques.push('/clear between unrelated goals');
323
+ if (agentModel) techniques.push(`${agentModel} for this run`);
324
+ if (basis === 'baseline' && percent > 0) {
325
+ techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
326
+ }
327
+ if (!techniques.length) techniques.push(`${Math.round(raw / 1000)}k tokens this run (no cache reuse yet)`);
328
+
329
+ return { rawTokens: raw, weightedTokens: weighted, savedTokens, percent, basis, techniques: [...new Set(techniques)] };
226
330
  }
227
331
 
228
332
  export function estimateFailureCauseMix({ usage = null, blockedKind = null, changedFiles = [], testResult = null, attentionHadDetail = true, taskCount = 1 } = {}) {
@@ -1170,6 +1274,72 @@ export function detectRequestLanguage(text) {
1170
1274
  return /[぀-ヿ㐀-鿿]/.test(text || '') ? 'ja' : 'en';
1171
1275
  }
1172
1276
 
1277
+ // SLICE 2 intent routing (docs/HANDOFF-ONBOARDING-conversational.md): the single
1278
+ // composer is conversation-first — a chat/help/settings message ("what can you
1279
+ // do?", "reply in English", a greeting) must get a conversational reply, NOT a
1280
+ // To-Do; only implementation requests become goals. These are the pure,
1281
+ // testable pieces; the server wires them into POST /api/tasks.
1282
+ //
1283
+ // A dependency-free heuristic that short-circuits only the OBVIOUS cases so most
1284
+ // sends skip the LLM classifier:
1285
+ // 'task' — a clear implementation request (imperative verb / code token) and
1286
+ // no chat signal → skip the LLM, go straight to goal creation.
1287
+ // 'chat' — a clear help/settings/greeting message and no task signal.
1288
+ // 'unsure' — ambiguous (both signals, or neither) → defer to the LLM.
1289
+ // It is deliberately conservative about declaring 'task' (the only branch that
1290
+ // skips the LLM): a message carrying BOTH a task verb and a help/settings signal
1291
+ // is 'unsure', not 'task', so a help question phrased with "add"/"fix" isn't
1292
+ // silently turned into a goal.
1293
+ const INTENT_TASK_RE = /\b(fix|add|implement|create|build|refactor|remove|delete|rename|migrate|debug|integrate|revert|deploy)\b|実装|直し|直す|なおして|修正|作って|作成|追加|変更|削除|対応して|リファクタ|置き換え|バグ|不具合/i;
1294
+ const INTENT_CODE_RE = /\.(?:js|mjs|cjs|ts|tsx|jsx|css|scss|html|py|go|rs|java|rb|php|json|md|yml|yaml|sh|sql)\b|`[^`]+`|\b(?:function|const|class|import)\b|#\d+/;
1295
+ const INTENT_CHAT_META_RE = /何ができ|なにができ|何が出来|使い方|どう使|どうやって使|使える\?|説明して|教えて(?:ください|くれ|ほしい)?\s*[??]?$|about this (?:tool|app)|what (?:can|do|are|is|does) (?:you|it|this|i)|what'?s this|what can this|how (?:do|does|can) (?:i|this|you|it)|how to use|who are you|^\s*help\s*[??]?$|そもそも|初めて/i;
1296
+ const INTENT_CHAT_SETTINGS_RE = /英語で|日本語で|(?:reply|respond|answer|talk|speak|write)\s+(?:back\s+)?in\s+\w|語で(?:返|答|話|書)|言語/i;
1297
+ const INTENT_GREETING_RE = /^\s*(?:hi|hey|hello|yo|sup|thanks|thank you|thx|good (?:morning|evening|afternoon))\b[\s.!?]*$|^\s*(?:こんにち(?:は|わ)|こんばん(?:は|わ)|おはよう|はじめまして|やあ|ありがと(?:う)?|よろしく)[\s。!?!?]*$/i;
1298
+
1299
+ export function classifyComposerIntentHeuristic(text) {
1300
+ const t = (text || '').trim();
1301
+ if (!t) return 'chat';
1302
+ const hasTask = INTENT_TASK_RE.test(t) || INTENT_CODE_RE.test(t);
1303
+ const hasChat = INTENT_CHAT_META_RE.test(t) || INTENT_CHAT_SETTINGS_RE.test(t) || INTENT_GREETING_RE.test(t);
1304
+ if (hasTask && !hasChat) return 'task'; // pure implementation request → skip LLM
1305
+ if (hasChat && !hasTask) return 'chat'; // pure help/settings/greeting
1306
+ return 'unsure'; // both or neither → let the LLM decide
1307
+ }
1308
+
1309
+ // The one prompt that BOTH classifies and (for chat) writes the reply in the
1310
+ // requester's language — one Haiku round-trip, not two. The model returns a
1311
+ // single line of JSON: {"intent":"chat|task","reply":"…"}.
1312
+ export function buildIntentPrompt(text, lang) {
1313
+ const language = lang === 'ja' ? '日本語' : 'English';
1314
+ return [
1315
+ 'You are the intent router for Agent Manager: a tool where a user delegates a coding task in plain language, an AI implements it, and it comes back as a reviewed pull request with proof.',
1316
+ 'Classify the user message below into exactly one intent:',
1317
+ '- "task": a request to implement, change, fix, build, or remove something in code (even if vague, e.g. "add dark mode").',
1318
+ '- "chat": anything that is NOT an implementation request — questions about what this tool can do or how to use it, greetings, thanks, small talk, or a settings/preference request (e.g. "reply in English").',
1319
+ 'When in doubt between the two, prefer "task".',
1320
+ `If intent is "chat", also write "reply": a short, warm, helpful answer in ${language} (at most 4 sentences). If the user asks what this can do or how to start, briefly explain: describe the change you want in plain language, and it will ask which folder/repo to work in, implement it, and return a PR with proof. If intent is "task", set "reply" to "".`,
1321
+ 'Output ONLY one line of raw JSON with keys "intent" and "reply". No markdown, no code fence, no extra text.',
1322
+ '',
1323
+ 'User message:',
1324
+ String(text || '').slice(0, 4000),
1325
+ ].join('\n');
1326
+ }
1327
+
1328
+ // Tolerant parser for the router's reply — never throws. Extracts the first JSON
1329
+ // object; on any failure it fails safe to 'task' (so a garbled classification
1330
+ // can never silently swallow a real implementation request). Reply is clamped.
1331
+ export function parseIntentResponse(raw) {
1332
+ const fallback = { intent: 'task', reply: '' };
1333
+ if (!raw || typeof raw !== 'string') return fallback;
1334
+ const m = raw.match(/\{[\s\S]*\}/);
1335
+ if (!m) return fallback;
1336
+ let obj;
1337
+ try { obj = JSON.parse(m[0]); } catch { return fallback; }
1338
+ const intent = obj && obj.intent === 'chat' ? 'chat' : 'task';
1339
+ const reply = intent === 'chat' && typeof obj.reply === 'string' ? obj.reply.trim().slice(0, 2000) : '';
1340
+ return { intent, reply };
1341
+ }
1342
+
1173
1343
  // Normalize a nested boolean-flag object (prSections/reviewCard) key by key,
1174
1344
  // so a partial payload — or one missing the whole sub-object — can't
1175
1345
  // silently disable flags it never mentioned; each key falls back to its own
@@ -1600,6 +1770,94 @@ export function reconcileOrphanGoals(goals, tasks) {
1600
1770
  return out;
1601
1771
  }
1602
1772
 
1773
+ // ---- cross-device board sync (P3, docs/design/CROSS-DEVICE-SYNC-PLAN.md) ----
1774
+ // The "board" is the portable snapshot of one install's local-first state that
1775
+ // the sync Worker (P2, PUT/GET /sync/board) stores per account, so a second
1776
+ // device signed into the same account inherits the work on boot. These are the
1777
+ // pure, unit-tested decision helpers; engine/server.mjs owns the actual disk +
1778
+ // network I/O and calls into these.
1779
+ export const BOARD_SNAPSHOT_VERSION = 1;
1780
+
1781
+ // Assemble the snapshot server.mjs pushes. tasksLog is the raw text of
1782
+ // chat-runs/tasks.jsonl (the append-only heart of the board — goals + tasks);
1783
+ // the other three are the small JSON state files. Defensive about missing
1784
+ // pieces so a partial local state still produces a valid, replayable board.
1785
+ export function buildBoardSnapshot({ projects, tasksLog, workflowColumns, reviewDefinitions } = {}) {
1786
+ return {
1787
+ v: BOARD_SNAPSHOT_VERSION,
1788
+ projects: Array.isArray(projects) ? projects : [],
1789
+ tasksLog: typeof tasksLog === 'string' ? tasksLog : '',
1790
+ workflowColumns: workflowColumns && typeof workflowColumns === 'object' ? workflowColumns : {},
1791
+ reviewDefinitions: reviewDefinitions && typeof reviewDefinitions === 'object' ? reviewDefinitions : {},
1792
+ };
1793
+ }
1794
+
1795
+ // Validate/normalize a board pulled from the store before we let it overwrite
1796
+ // local state. Returns the normalized pieces, or null if it's malformed or a
1797
+ // version we don't understand (caller must then leave local state untouched).
1798
+ export function validateBoardSnapshot(board) {
1799
+ if (!board || typeof board !== 'object') return null;
1800
+ if (board.v !== BOARD_SNAPSHOT_VERSION) return null;
1801
+ if (!Array.isArray(board.projects)) return null;
1802
+ if (typeof board.tasksLog !== 'string') return null;
1803
+ return {
1804
+ projects: board.projects,
1805
+ tasksLog: board.tasksLog,
1806
+ workflowColumns: board.workflowColumns && typeof board.workflowColumns === 'object' ? board.workflowColumns : {},
1807
+ reviewDefinitions: board.reviewDefinitions && typeof board.reviewDefinitions === 'object' ? board.reviewDefinitions : {},
1808
+ };
1809
+ }
1810
+
1811
+ // A board carries real work only if its task log has content — projects.json
1812
+ // always has at least the 'default' project, so it can't be the emptiness
1813
+ // signal. Used to decide whether a pull would discard un-synced local work.
1814
+ export function boardIsEmpty(snapshot) {
1815
+ if (!snapshot) return true;
1816
+ return !snapshot.tasksLog || !snapshot.tasksLog.trim();
1817
+ }
1818
+
1819
+ // Monotonic optimistic-lock rev: pull only when the store is strictly ahead of
1820
+ // what this device last saw. Equal/older → nothing to adopt (we're current or
1821
+ // we're the writer). Missing/garbage revs coerce to 0.
1822
+ export function shouldPull({ localRev, remoteRev }) {
1823
+ const l = Number(localRev) || 0;
1824
+ const r = Number(remoteRev) || 0;
1825
+ return r > l;
1826
+ }
1827
+
1828
+ // Boot-time decision: should we hydrate from the remote board, and does doing
1829
+ // so throw away un-synced local work? clobbersLocal is the danger case — a
1830
+ // device that has never synced (localRev 0) but already has local goals, while
1831
+ // another device has pushed a board. Cross-device is takeover, not concurrent
1832
+ // offline editing (a non-goal per the plan): last-writer wins, but the caller
1833
+ // logs a warning when clobbersLocal so it's never silent.
1834
+ export function decideBoardPull({ localRev, remoteRev, localHasWork, remoteHasWork }) {
1835
+ const pull = shouldPull({ localRev, remoteRev });
1836
+ const clobbersLocal = pull && Boolean(localHasWork) && Boolean(remoteHasWork) && (Number(localRev) || 0) === 0;
1837
+ return { pull, clobbersLocal };
1838
+ }
1839
+
1840
+ // Live (mid-session) takeover decision (P4). A boot pull runs before any queue
1841
+ // or worker exists, so it can always hydrate; a *live* pull runs on an
1842
+ // already-serving instance, where hydrateFromBoard rewrites the append-only log
1843
+ // wholesale and reassigns goals/tasks. That is only safe when this device has
1844
+ // no in-flight work — a running/planning goal owns live worker children whose
1845
+ // completion would then write against goals that no longer exist. So this gate
1846
+ // refuses when `busy`, and otherwise reuses decideBoardPull for the "is the
1847
+ // store actually newer?" question. Returns a single `action`:
1848
+ // 'disabled' — sync off (no billing/token, or store un-provisioned)
1849
+ // 'busy' — this device has active work; refuse (caller returns 409)
1850
+ // 'invalid' — remote board missing/unparseable; nothing safe to adopt
1851
+ // 'current' — remote is not ahead; already up to date
1852
+ // 'pull' — adopt the remote board (clobbersLocal flags a last-writer wipe)
1853
+ export function liveTakeoverDecision({ syncEnabled, busy, remoteValid, localRev, remoteRev, localHasWork, remoteHasWork }) {
1854
+ if (!syncEnabled) return { action: 'disabled' };
1855
+ if (busy) return { action: 'busy' };
1856
+ if (!remoteValid) return { action: 'invalid' };
1857
+ const { pull, clobbersLocal } = decideBoardPull({ localRev, remoteRev, localHasWork, remoteHasWork });
1858
+ return pull ? { action: 'pull', clobbersLocal } : { action: 'current' };
1859
+ }
1860
+
1603
1861
  // The four states the engine actually tracks per goal/task (see
1604
1862
  // goalGroupStatus/goalChip above). A project's workflow columns are a
1605
1863
  // display-layer relabeling/reordering on top of these — every column must
@@ -1857,7 +2115,7 @@ export function shouldShowGoalSummary(goal) {
1857
2115
  // Mirrored in app/index.html (buildReviewSummary) since the inline browser
1858
2116
  // script can't import this module. No LLM — composed from goal.plan / task
1859
2117
  // titles / task状態.
1860
- export function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}) {
2118
+ export function buildReviewSummary({ goal, tasks, approvedN = 0, tokenHistory = [] } = {}) {
1861
2119
  const reqs = (tasks ?? []).filter((t) => !t.reply);
1862
2120
  const total = reqs.length;
1863
2121
  const done = reqs.filter((t) => ['done', 'skipped'].includes(t.status)).length;
@@ -1886,10 +2144,26 @@ export function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}) {
1886
2144
  usedCompact: goal?.executionPlan?.sessionMode === 'compact',
1887
2145
  usedClear: Boolean(goal?.sessionClosedAt),
1888
2146
  agentModel: goal?.model,
2147
+ requirementCount: total,
2148
+ history: tokenHistory,
1889
2149
  }),
1890
2150
  };
1891
2151
  }
1892
2152
 
2153
+ // task 6 (2026-07-16): turns a project's finished goals into the `history`
2154
+ // list `estimateTokenOptimization` needs for its baseline comparison — one
2155
+ // {weightedTokens, requirementCount} sample per finished goal, oldest-first,
2156
+ // excluding the goal currently being summarized. Mirrored in app/index.html
2157
+ // (goalTokenHistory) since the inline script can't import this module.
2158
+ export function goalTokenHistory({ goals = [], tasks = [], excludeGoalId = null } = {}) {
2159
+ return (goals ?? [])
2160
+ .filter((g) => g && g.id !== excludeGoalId && GOAL_COMPLETE_STATUSES.includes(g.status) && g.runTokens != null)
2161
+ .map((g) => ({
2162
+ weightedTokens: g.runTokens,
2163
+ requirementCount: (tasks ?? []).filter((t) => t.goalId === g.id && !t.reply).length,
2164
+ }));
2165
+ }
2166
+
1893
2167
  // Task 114: the persistent bottom-right Log box (formerly the running-task-only
1894
2168
  // #actpanel) defaults to collapsed (1 line) and expands on click. Plain boolean
1895
2169
  // flip, but pulled out as a pure function so the click handler has a unit test
@@ -2575,6 +2849,188 @@ export function isFolderInitConfirmed(answer, question = {}) {
2575
2849
  return /^(yes|y|はい|ok|する|始める)$/i.test(a);
2576
2850
  }
2577
2851
 
2852
+ // Onboarding funnel (task 5, PR運用ヒアリング): before a project's very first
2853
+ // runnable goal actually starts work, ask once whether this project wants
2854
+ // PRs (Masa: PR使う人/使わない人で欲しい挙動が違うのに今はサイレントに
2855
+ // defaultWantsPR:false が適用され、設定パネルを開かないと気づけない). Reuses
2856
+ // the same needsInput clarifying-card mechanism as the folder question
2857
+ // (kind:'reviewPref' instead of 'folder') so no new UI surface is needed.
2858
+ // `askedBefore` is a per-project flag (project.reviewPrefAsked) set the
2859
+ // moment the question is asked — so a project is asked at most once ever,
2860
+ // even if the user ignores the card and later goals get created for the
2861
+ // same project while it's still pending.
2862
+ export function needsReviewPreference({ askedBefore } = {}) {
2863
+ return !askedBefore;
2864
+ }
2865
+
2866
+ // Builds the "PR使いますか?" clarifying question. options[0] = wants PR,
2867
+ // options[1] = no PR (direct). Order matters — resolveReviewPreferenceAnswer
2868
+ // below keys off these exact indices/labels.
2869
+ export function buildReviewPreferenceQuestion({ lang = 'ja' } = {}) {
2870
+ const text = lang === 'en'
2871
+ ? 'Before the first task: do you want changes shipped as a PR to review, or applied directly?'
2872
+ : '最初のタスクの前に:実装はPRでレビューしますか、それとも直接反映しますか?';
2873
+ const options = lang === 'en'
2874
+ ? ['PR — open a pull request to review', 'No PR — apply directly (still lands in Review to check)']
2875
+ : ['PRを作ってレビューする', 'PRなしで直接反映する(Reviewでの確認はある)'];
2876
+ return { text, options, kind: 'reviewPref', lang };
2877
+ }
2878
+
2879
+ // Resolve a review-preference answer into a partial review-definition patch
2880
+ // (merged into the project's stored definition by the caller). Matches by
2881
+ // exact option label; an unrecognized/typed answer defaults to wanting a PR
2882
+ // — the original pre-task-5 default — rather than silently turning PRs off.
2883
+ export function resolveReviewPreferenceAnswer(answer, question) {
2884
+ const noPrOption = question?.options?.[1];
2885
+ const wantsPR = String(answer ?? '').trim() !== String(noPrOption ?? '');
2886
+ return { defaultWantsPR: wantsPR };
2887
+ }
2888
+
2889
+ // --- Intent router: no free-text field is a dead-end ----------------------
2890
+ // Every needsInput card has a free-text box. A reply typed there may be the
2891
+ // answer the card expects — OR a meta-instruction ("日本語で教えて", "別のを提案して").
2892
+ // The old code coerced anything unrecognized into a literal (`dir = resolved
2893
+ // ?.dir || answer`), so a Japanese instruction became a folder NAMED "日本語で
2894
+ // 教えて". The router below decides, WITHOUT coercion, which of three paths a
2895
+ // reply takes; the server fulfils each (rebuild card / relocalize / ask model).
2896
+ // All pure + tested; the server supplies filesystem/LLM I/O.
2897
+
2898
+ // Does this string look like a filesystem path (vs prose)? Mirrors the accept
2899
+ // rule resolveFolderAnswer already used (absolute / home-relative), plus
2900
+ // explicit-relative and any embedded slash. Prose like "日本語で教えて" has no
2901
+ // slash → not a path → never silently turned into a folder.
2902
+ export function looksLikePath(s) {
2903
+ const a = String(s ?? '').trim();
2904
+ if (!a) return false;
2905
+ return a.startsWith('/') || a.startsWith('~') || a.startsWith('./') || a.startsWith('../') || a.includes('/');
2906
+ }
2907
+
2908
+ // Detect a reply whose PRIMARY intent is to switch the conversation language,
2909
+ // e.g. "日本語で教えて" / "in English please". Returns 'ja' | 'en' | null.
2910
+ // Deliberately narrow: only fires on short, language-focused messages, so a
2911
+ // real task that merely mentions "英語のドキュメント" isn't misread as a switch.
2912
+ // The cards render in ja/en; any other target language returns null here and is
2913
+ // left for the model (which can restate in any language).
2914
+ export function detectLanguageRequest(text) {
2915
+ const a = String(text ?? '').trim();
2916
+ if (!a || a.length > 40) return null;
2917
+ const lo = a.toLowerCase();
2918
+ const wantsJa = /(日本語|にほんご|ニホンゴ|japanese|nihongo)/.test(lo);
2919
+ const wantsEn = /(英語|えいご|エイゴ|english|eigo)/.test(lo);
2920
+ if (wantsJa && !wantsEn) return 'ja';
2921
+ if (wantsEn && !wantsJa) return 'en';
2922
+ return null;
2923
+ }
2924
+
2925
+ // Decide how a free-text answer to a needsInput card should be handled, WITHOUT
2926
+ // coercing prose into a literal. `question` is goal.question ({kind, options,
2927
+ // lang, folders, yes, no, …}). Returns one of:
2928
+ // { route: 'accept' } — a clean answer; caller runs the card's resolver
2929
+ // { route: 'relocalize', lang } — a language switch; caller re-renders the SAME
2930
+ // question in `lang` (never loses the user's place)
2931
+ // { route: 'escalate' } — free-form intent that isn't a clean answer;
2932
+ // caller hands it to the model / re-asks safely
2933
+ export function routeQuestionAnswer(answer, question = {}) {
2934
+ const a = String(answer ?? '').trim();
2935
+ if (!a) return { route: 'escalate' };
2936
+
2937
+ // Picking an offered option verbatim is always a clean answer (any kind).
2938
+ const options = Array.isArray(question.options) ? question.options : [];
2939
+ if (options.some((o) => o === a)) return { route: 'accept' };
2940
+
2941
+ // A language-switch request → relocalize. Suppress only when the card is
2942
+ // KNOWN to already be in that language (question.lang set and equal); a card
2943
+ // without a declared lang (general planner clarify) always relocalizes since
2944
+ // we can't prove it's already there — the model restating is harmless.
2945
+ const lang = detectLanguageRequest(a);
2946
+ if (lang && lang !== question.lang) return { route: 'relocalize', lang };
2947
+
2948
+ switch (question.kind) {
2949
+ case 'folder': {
2950
+ // A picked label, an exact known dir, or a path-like reply is a real answer.
2951
+ // Bare prose is NOT — escalate instead of naming a folder after it.
2952
+ if ((question.folders || []).some((f) => f.label === a || f.dir === a)) return { route: 'accept' };
2953
+ return looksLikePath(a) ? { route: 'accept' } : { route: 'escalate' };
2954
+ }
2955
+ case 'folder-init': {
2956
+ // Yes / an explicit decline are both clean answers; ambiguous prose escalates.
2957
+ if (isFolderInitConfirmed(a, question)) return { route: 'accept' };
2958
+ if (question.no && a === question.no) return { route: 'accept' };
2959
+ if (/^(no|n|いいえ|やめる|キャンセル|cancel)$/i.test(a)) return { route: 'accept' };
2960
+ return { route: 'escalate' };
2961
+ }
2962
+ default:
2963
+ // General planner clarify card: any prose is a legitimate clarification —
2964
+ // the existing path folds it into goal.text and re-plans (the model sees
2965
+ // it). Keep that; only language switches diverted above.
2966
+ return { route: 'accept' };
2967
+ }
2968
+ }
2969
+
2970
+ // A short localized note prepended to a re-asked card when the reply couldn't be
2971
+ // understood as an answer — so the user learns what the box expects instead of
2972
+ // silently looping. Folded into the card's existing `text` (no new UI field).
2973
+ export function notUnderstoodNote(lang = 'ja') {
2974
+ return lang === 'en'
2975
+ ? 'I couldn’t read that as a folder — type a path (e.g. /Users/you/proj) or pick one above. '
2976
+ : 'それはフォルダとして解釈できませんでした。パス(例: /Users/you/proj)を入力するか、上から選んでください。';
2977
+ }
2978
+
2979
+ // Parse the model's answer when we ask it to restate a pending clarify question
2980
+ // in another language (general cards have no builder to rebuild from). Expects
2981
+ // JSON {"text": "...", "options": ["..."]}. Returns {text, options} shaped like
2982
+ // a clarify card, or null when the model didn't return usable JSON (caller keeps
2983
+ // the original card). Options are capped to the same bounds as planGoal.
2984
+ export function parseRelocalizedQuestion(raw) {
2985
+ try {
2986
+ const m = String(raw ?? '').match(/\{[\s\S]*\}/);
2987
+ if (!m) return null;
2988
+ const o = JSON.parse(m[0]);
2989
+ const text = String(o?.text ?? o?.question ?? '').trim().slice(0, 300);
2990
+ if (!text) return null;
2991
+ const options = Array.isArray(o?.options) ? o.options.slice(0, 4).map((x) => String(x).slice(0, 60)) : [];
2992
+ return { text, options };
2993
+ } catch {
2994
+ return null;
2995
+ }
2996
+ }
2997
+
2998
+ // --- Worker auth preflight -------------------------------------------------
2999
+ // The manager runs every worker by spawning a standalone `claude` (server.mjs
3000
+ // runClaude), which inherits the LAUNCHING SHELL's login. If that shell has no
3001
+ // usable `claude` credentials, every task fails late with "Not logged in ·
3002
+ // Please run /login" (root cause of Masa's all-tasks-failed report). A boot
3003
+ // preflight probes once so the server can raise a banner and hold the queue
3004
+ // instead of failing task after task. Pure so we can test the classification
3005
+ // without spawning a process. NOTE: `claude -p` exits 0 even when logged out,
3006
+ // so we classify on the TEXT, not the exit code.
3007
+ export function classifyAuthProbe({ result = '', code = 0 } = {}) {
3008
+ const text = String(result ?? '');
3009
+ if (/not logged in|please run\s*\/login|\brun \/login\b/i.test(text)) return { ok: false, reason: 'logged-out' };
3010
+ if (/invalid api key|authentication_error|\bunauthorized\b|not authenticated|401\b/i.test(text)) return { ok: false, reason: 'bad-credentials' };
3011
+ if (code !== 0) return { ok: false, reason: 'probe-failed' };
3012
+ return { ok: true, reason: null };
3013
+ }
3014
+
3015
+ // One-line, actionable banner text for an auth-preflight failure. Tells the user
3016
+ // exactly how to fix it: sign the standalone CLI in, then RELAUNCH the manager
3017
+ // from that shell (the worker inherits that shell's login). Localized; the app
3018
+ // chrome is English (DESIGN-RULES) so the client mirrors the 'en' strings.
3019
+ export function authBannerText(reason, lang = 'ja') {
3020
+ const en = {
3021
+ 'bad-credentials': 'Worker sign-in failed. Check ANTHROPIC_API_KEY, or run `claude` and /login, then relaunch the manager from that terminal.',
3022
+ 'probe-failed': 'Couldn’t verify `claude` sign-in. Make sure the `claude` CLI is installed and signed in, then relaunch the manager.',
3023
+ 'logged-out': 'Not signed in to `claude`. Run `claude` in a terminal and /login, then relaunch the manager from that same terminal. Tasks are paused until then.',
3024
+ };
3025
+ const ja = {
3026
+ 'bad-credentials': 'worker のサインインに失敗しました。ANTHROPIC_API_KEY を確認するか、ターミナルで `claude` を起動して /login し、そのターミナルから manager を再起動してください。',
3027
+ 'probe-failed': '`claude` のサインインを確認できませんでした。`claude` CLI がインストール済みでログイン済みか確認し、manager を再起動してください。',
3028
+ 'logged-out': 'ターミナルで `claude` にログインしてから起動してください(`claude` → /login 後、同じターミナルで manager を再起動)。ログインするまでタスクは実行しません。',
3029
+ };
3030
+ const table = lang === 'en' ? en : ja;
3031
+ return table[reason] || table['logged-out'];
3032
+ }
3033
+
2578
3034
  // Analytics identity (A+): a signed-in install scopes usage events to the
2579
3035
  // ACCOUNT (its email, which the billing Worker hashes into the same account_id
2580
3036
  // the accounts table carries) so per-user app activity joins WITHOUT a cookie;
@@ -2588,3 +3044,71 @@ export function pickAnalyticsUid(accountEmail, installId) {
2588
3044
  export function shouldEmitFreeExhausted(alreadyEmitted, blocked) {
2589
3045
  return !alreadyEmitted && Boolean(blocked);
2590
3046
  }
3047
+
3048
+ // --- relay stand-down: kill the two-device mutual-kick loop --------------------
3049
+ // The relay binds ONE agent socket per account (idForEmail(email)); a newer
3050
+ // connection evicts the older one with close code 4000. License tokens are
3051
+ // SHORT-LIVED (7d) and the app re-fetches a fresh one while online, so the SAME
3052
+ // email's license.token string CHANGES over time. Without care, that re-mint is
3053
+ // indistinguishable from a sign-in and makes the evicted (stood-down) instance
3054
+ // re-dial → it evicts the other device → which later re-mints and re-dials →
3055
+ // forever. The user sees app.galda.app reload-looping (the "無限ループ").
3056
+ //
3057
+ // Fix: once we're evicted (4000) we go stoodDown and remember WHICH identity we
3058
+ // were bound to. A token change for the SAME identity (a re-mint of the account
3059
+ // already live on the other device) must NOT re-dial. Only a genuinely DIFFERENT
3060
+ // identity (the user switched accounts on this device) or an explicit takeover
3061
+ // (the "switch to this device" button, P4) re-dials. Newest instance wins, and
3062
+ // the loser stays down through any number of re-mints.
3063
+
3064
+ // Pull the account identity out of a compact license token WITHOUT verifying the
3065
+ // signature — we only need a stable local key to compare "same account?". Format
3066
+ // is b64url(header).b64url(payload).b64url(sig) with payload.email (see
3067
+ // workers/billing-api/src/license.mjs). Returns '' when it can't be decoded.
3068
+ export function licenseTokenIdentity(token) {
3069
+ if (!token || typeof token !== 'string') return '';
3070
+ const parts = token.split('.');
3071
+ if (parts.length !== 3) return '';
3072
+ try {
3073
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
3074
+ const email = payload?.email;
3075
+ return typeof email === 'string' ? email.trim().toLowerCase() : '';
3076
+ } catch { return ''; }
3077
+ }
3078
+
3079
+ // Pure reducer for the relay-client's reconnect decision. Returns the next state
3080
+ // and whether to dial the relay now. State: { stoodDown, standingIdentity }.
3081
+ // Events:
3082
+ // { type: 'evicted', identity } — relay closed us with 4000 (superseded)
3083
+ // { type: 'dropped' } — a normal connection drop (network etc.)
3084
+ // { type: 'tokenChanged', identity }— license.token appeared/changed on disk
3085
+ // { type: 'takeover' } — user explicitly claimed this device (P4)
3086
+ export function relayReconnectDecision(state, event) {
3087
+ const s = { stoodDown: Boolean(state?.stoodDown), standingIdentity: state?.standingIdentity ?? null };
3088
+ switch (event?.type) {
3089
+ case 'evicted':
3090
+ // A newer instance of THIS account took the slot — stand down and record
3091
+ // whose slot we lost, so a later re-mint of the same account won't re-dial.
3092
+ return { state: { stoodDown: true, standingIdentity: event.identity ?? s.standingIdentity }, dial: false, reason: 'superseded' };
3093
+ case 'dropped':
3094
+ // Ordinary drop: reconnect, UNLESS we already stood down (never re-enter
3095
+ // the ring while another device holds the slot).
3096
+ return { state: s, dial: !s.stoodDown, reason: s.stoodDown ? 'stood-down' : 'reconnect' };
3097
+ case 'tokenChanged': {
3098
+ const id = event.identity || '';
3099
+ if (s.stoodDown && id && id === s.standingIdentity) {
3100
+ // Same account re-minted its token — the other device is still it. Stay
3101
+ // down; this is the exact re-dial that caused the infinite loop.
3102
+ return { state: s, dial: false, reason: 'same-account-remint' };
3103
+ }
3104
+ // A different account (real account switch) or first sign-in → clear the
3105
+ // stand-down and dial with the new identity.
3106
+ return { state: { stoodDown: false, standingIdentity: null }, dial: true, reason: 'identity-changed' };
3107
+ }
3108
+ case 'takeover':
3109
+ // User pressed "switch to this device" — reclaim the slot on purpose.
3110
+ return { state: { stoodDown: false, standingIdentity: null }, dial: true, reason: 'takeover' };
3111
+ default:
3112
+ return { state: s, dial: false, reason: 'noop' };
3113
+ }
3114
+ }