@galda/cli 0.10.8 → 0.10.10

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/app/index.html CHANGED
@@ -2387,7 +2387,7 @@
2387
2387
  <div class="perahead" style="border-bottom:1px solid var(--hair)"><div class="peratitle">Settings</div><span style="flex:1"></span><button class="mbtn ghost" id="setClose">Close</button></div>
2388
2388
  <div style="padding:16px">
2389
2389
  <div class="setrow"><div><div class="setlabel">Content language</div><div class="setsub">UI is English; this sets the language of AI summaries and PR bodies.</div></div>
2390
- <div class="segbtn" id="langSeg"><button data-lang="en">English</button><button data-lang="ja">日本語</button></div></div>
2390
+ <div class="segbtn" id="langSeg"><button data-lang="auto">Auto</button><button data-lang="en">English</button><button data-lang="ja">日本語</button></div></div>
2391
2391
  </div>
2392
2392
  </div></div>
2393
2393
  <!-- Slack-style centered delete confirmation, replacing native confirm() for every goal
@@ -2586,7 +2586,7 @@ function prioBadge(t){
2586
2586
  // Mirrors engine/lib.mjs DEFAULT_REVIEW_DEFINITION — kept in sync manually,
2587
2587
  // see the comment on DEFAULT_WORKFLOW_COLUMNS above.
2588
2588
  const DEFAULT_REVIEW_DEFINITION = {
2589
- requirePR: true, requireVerifyPass: false, description: '', defaultWantsPR: false, language: 'ja',
2589
+ requirePR: true, requireVerifyPass: false, description: '', defaultWantsPR: false, language: 'auto',
2590
2590
  prSections: { summary: true, whatChanged: true, howToReview: true, tests: true, screenshots: true, risk: true },
2591
2591
  reviewCard: { screenshots: true, beforeAfter: false },
2592
2592
  };
@@ -2598,7 +2598,10 @@ function reviewDefinitionFor(projectId){ return state.reviewDefinitions[projectI
2598
2598
  // review-definitions.json entries are never re-validated on server load), so
2599
2599
  // merge with the defaults key-by-key here rather than assuming they exist.
2600
2600
  function reviewCardConfigFor(projectId){ return { ...DEFAULT_REVIEW_DEFINITION.reviewCard, ...(reviewDefinitionFor(projectId).reviewCard ?? {}) }; }
2601
- function reviewLangFor(projectId){ return reviewDefinitionFor(projectId).language === 'en' ? 'en' : 'ja'; }
2601
+ // 'auto'|'ja'|'en' verbatim 'auto' (default) means the Manager matches each
2602
+ // goal's own request language, same as the planner/worker; ja/en pin the
2603
+ // project to one language regardless of what a given goal was typed in.
2604
+ function reviewLangFor(projectId){ const l = reviewDefinitionFor(projectId).language; return (l === 'en' || l === 'ja') ? l : 'auto'; }
2602
2605
 
2603
2606
  function esc(s){ const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
2604
2607
 
@@ -3911,6 +3914,7 @@ function renderRdEditor(){
3911
3914
 
3912
3915
  <div class="rdsub">出力言語 / Output language</div>
3913
3916
  <label class="rdrow"><select id="rdLanguage">
3917
+ <option value="auto"${state.rdDraft.language === 'auto' ? ' selected' : ''}>Auto (match request)</option>
3914
3918
  <option value="ja"${state.rdDraft.language === 'ja' ? ' selected' : ''}>日本語</option>
3915
3919
  <option value="en"${state.rdDraft.language === 'en' ? ' selected' : ''}>English</option>
3916
3920
  </select>
@@ -7419,6 +7423,7 @@ function gearSetHTML(){
7419
7423
  }
7420
7424
  return `
7421
7425
  <div class="srow"><div class="sl">Language<span class="sd">Review checklist language for this project</span></div>
7426
+ <button class="optchip${lang === 'auto' ? ' on' : ''}" data-gslang="auto">Auto</button>
7422
7427
  <button class="optchip${lang === 'en' ? ' on' : ''}" data-gslang="en">EN</button>
7423
7428
  <button class="optchip${lang === 'ja' ? ' on' : ''}" data-gslang="ja">JA</button></div>
7424
7429
  ${billingRow}
@@ -86,6 +86,13 @@ process.env.MANAGER_OPEN_BROWSER = process.env.MANAGER_OPEN_BROWSER ?? '1';
86
86
  // and serves the whole API (/connect, /checkout, /api/*). See [[no-a2c-tech]].
87
87
  process.env.MANAGER_BILLING_API_URL = process.env.MANAGER_BILLING_API_URL ?? 'https://galda.app';
88
88
  process.env.RELAY_URL = process.env.RELAY_URL ?? 'wss://app.galda.app/agent';
89
+ // The named app the user actually works in (over the relay). The CLI opens THIS,
90
+ // not the localhost board, in the hosted flow. Override for a self-hosted app.
91
+ process.env.MANAGER_APP_URL = process.env.MANAGER_APP_URL ?? 'https://app.galda.app';
92
+ // `npx @galda/cli --signin` forces the Google sign-in even if a license already
93
+ // exists — the way to switch to a different account (a stale token otherwise
94
+ // binds you to the previous account).
95
+ if (process.argv.includes('--signin') || process.argv.includes('--login')) process.env.MANAGER_FORCE_SIGNIN = '1';
89
96
  // Choose a free port now and pin it for BOTH the server and the relay-client, so
90
97
  // a busy 4400 (another manager) no longer stops onboarding — no MANAGER_PORT by hand.
91
98
  const wantedPort = Number(process.env.MANAGER_PORT ?? 4400);
package/engine/lib.mjs CHANGED
@@ -260,7 +260,7 @@ function primaryFailureCause({ usage = null, blockedKind = null, changedFiles =
260
260
 
261
261
  function failurePhase(kind) {
262
262
  if (kind === 'budget' || kind === 'rate-limit') return 'worker-run';
263
- if (kind === 'test' || kind === 'proof' || kind === 'verify') return 'manager-verification';
263
+ if (kind === 'test' || kind === 'proof' || kind === 'verify' || kind === 'noop') return 'manager-verification';
264
264
  return 'execution';
265
265
  }
266
266
 
@@ -297,6 +297,12 @@ function failureSummaryFor({ kind, goalText, testResult, budgetReason, proofMiss
297
297
  yourCall: '不足している proof / 検証結果をそろえ、判断できる状態にしてから再提出します。',
298
298
  };
299
299
  }
300
+ if (kind === 'noop') {
301
+ return {
302
+ whatHappened: `必要だった作業「${needed}」は検証条件つきのタスクを含みますが、worker はファイル変更もproofも無いまま完了報告しました。`,
303
+ yourCall: '約束した検証(passCondition)が実行されなかった理由を確認し、必要なら worker にやり直しを指示します。',
304
+ };
305
+ }
300
306
  return {
301
307
  whatHappened: budgetReason ? String(budgetReason).slice(0, 180) : `必要だった作業「${needed}」が途中で止まりました。`,
302
308
  yourCall: '停止地点のログを読んで、同じ失敗を避ける最小ステップで再開します。',
@@ -1099,7 +1105,11 @@ export function resolveGoalSource(input) {
1099
1105
  // no explicit signal — it decides whether *this project* opens PRs by
1100
1106
  // default, separately from whether a PR-wanting goal must go through review.
1101
1107
  // `language` is the output language for the Manager's review-facing text
1102
- // (e.g. summarizeForReview's plain-language headline) — 'ja' or 'en' only.
1108
+ // (e.g. summarizeForReview's plain-language headline) — 'auto' (match the
1109
+ // language the goal's own request was written in, same as the planner/worker —
1110
+ // see detectRequestLanguage) or an explicit 'ja'/'en' override for a project
1111
+ // that always wants one fixed language regardless of what any given goal was
1112
+ // typed in.
1103
1113
  // `prSections` toggles which sections createGoalPR includes in the PR body;
1104
1114
  // `reviewCard` toggles what the review checklist card shows for each task.
1105
1115
  export const DEFAULT_REVIEW_DEFINITION = {
@@ -1107,11 +1117,19 @@ export const DEFAULT_REVIEW_DEFINITION = {
1107
1117
  requireVerifyPass: false,
1108
1118
  description: '',
1109
1119
  defaultWantsPR: false,
1110
- language: 'ja',
1120
+ language: 'auto',
1111
1121
  prSections: { summary: true, whatChanged: true, howToReview: true, tests: true, screenshots: true, risk: true },
1112
1122
  reviewCard: { screenshots: true, beforeAfter: false },
1113
1123
  };
1114
1124
 
1125
+ // Detects whether text was written in Japanese (kana/kanji present) or not, so
1126
+ // generated replies can match the language the request was written in
1127
+ // ("test" → English, "テスト" → 日本語) — worker close-out summaries, and (via
1128
+ // reviewDefinition.language === 'auto') review headlines/PR bodies.
1129
+ export function detectRequestLanguage(text) {
1130
+ return /[぀-ヿ㐀-鿿]/.test(text || '') ? 'ja' : 'en';
1131
+ }
1132
+
1115
1133
  // Normalize a nested boolean-flag object (prSections/reviewCard) key by key,
1116
1134
  // so a partial payload — or one missing the whole sub-object — can't
1117
1135
  // silently disable flags it never mentioned; each key falls back to its own
@@ -1138,7 +1156,7 @@ export function validateReviewDefinition(input) {
1138
1156
  requireVerifyPass: input.requireVerifyPass !== undefined ? !!input.requireVerifyPass : DEFAULT_REVIEW_DEFINITION.requireVerifyPass,
1139
1157
  description: input.description !== undefined ? String(input.description).slice(0, 500) : DEFAULT_REVIEW_DEFINITION.description,
1140
1158
  defaultWantsPR: input.defaultWantsPR !== undefined ? !!input.defaultWantsPR : DEFAULT_REVIEW_DEFINITION.defaultWantsPR,
1141
- language: (input.language === 'ja' || input.language === 'en') ? input.language : DEFAULT_REVIEW_DEFINITION.language,
1159
+ language: (input.language === 'ja' || input.language === 'en' || input.language === 'auto') ? input.language : DEFAULT_REVIEW_DEFINITION.language,
1142
1160
  prSections: normalizeBoolMap(input.prSections, DEFAULT_REVIEW_DEFINITION.prSections),
1143
1161
  reviewCard: normalizeBoolMap(input.reviewCard, DEFAULT_REVIEW_DEFINITION.reviewCard),
1144
1162
  },
@@ -1160,6 +1178,20 @@ export function nextGoalStatus({ allDone, wantsPR, allVerified = true, reviewDef
1160
1178
  return allDone ? 'review' : 'partial';
1161
1179
  }
1162
1180
 
1181
+ // 段5 検証ゲート (Masa 2026-07-15: "proof無しは完了と言わない"): a task that carries
1182
+ // an EXPLICIT passCondition is a case where the user asked for proof — but if the
1183
+ // worker changed zero files, isUiChange() never matches (empty changedFiles), so
1184
+ // uiTasks stays empty and the existing proofMissing check (scoped to UI tasks)
1185
+ // never fires: the promised verification silently never happened. This is
1186
+ // deliberately narrower than "any 0-file goal" — a goal with NO passCondition
1187
+ // (an investigation/report-only ask) legitimately has nothing to prove and must
1188
+ // still reach Review for a human to read (see goal-guardrails-integration.test.mjs
1189
+ // (a2)/(a3): budget/cache-guardrail continuations correctly land in Review with
1190
+ // zero file changes because nothing was ever promised to be verified).
1191
+ export function isNothingVerifiable({ changedFiles = [], hasPassCondition = false, hasAnyProof = false } = {}) {
1192
+ return hasPassCondition && changedFiles.length === 0 && !hasAnyProof;
1193
+ }
1194
+
1163
1195
  // チェックリストUIで要件が全件Approveされたら goal を review→done へ進める。
1164
1196
  // 以前は「PRありgoalはマージが唯一の完了トリガー」として弾いていたが、それだと
1165
1197
  // 承認しても done が永続化されず次のrefreshで review に戻る不具合になっていた
@@ -2452,3 +2484,17 @@ export function resolveFolderAnswer(answer, folders = []) {
2452
2484
  if (a.startsWith('/') || a.startsWith('~')) return { dir: a };
2453
2485
  return null;
2454
2486
  }
2487
+
2488
+ // Analytics identity (A+): a signed-in install scopes usage events to the
2489
+ // ACCOUNT (its email, which the billing Worker hashes into the same account_id
2490
+ // the accounts table carries) so per-user app activity joins WITHOUT a cookie;
2491
+ // otherwise the anonymous per-install id. Pure for unit-testing.
2492
+ export function pickAnalyticsUid(accountEmail, installId) {
2493
+ return accountEmail || installId;
2494
+ }
2495
+
2496
+ // Fire the free_exhausted funnel event only the first time a non-paying install
2497
+ // is actually blocked by the free-tier cap.
2498
+ export function shouldEmitFreeExhausted(alreadyEmitted, blocked) {
2499
+ return !alreadyEmitted && Boolean(blocked);
2500
+ }
package/engine/server.mjs CHANGED
@@ -21,7 +21,7 @@ import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer } from './lib.mjs';
24
- import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted } from './lib.mjs';
24
+ import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable } from './lib.mjs';
25
25
  import { openPR } from './pr.mjs';
26
26
  import { runVerification, exerciseUi } from './verify.mjs';
27
27
 
@@ -54,7 +54,7 @@ function maybeEmitSetupCompleted(req) {
54
54
  _setupClientSeen = true;
55
55
  if (!shouldEmitSetupCompleted({ isMcpClient: true, alreadySeen: false, flagExists: existsSync(SETUP_FLAG) })) return;
56
56
  try { writeFileSync(SETUP_FLAG, String(Date.now())); } catch { /* best effort — still emit */ }
57
- emitEvent('signup', ANALYTICS_UID, { source: 'mcp-connect' });
57
+ emitEvent('signup', analyticsUid(), { source: 'mcp-connect' });
58
58
  }
59
59
  // Per-worker run cap. Env-configurable so it can be tuned operationally and the
60
60
  // timeout→'interrupted' path is testable (a test sets a short value + a slow
@@ -152,6 +152,15 @@ const MANAGER_OWNER_EMAIL = process.env.MANAGER_OWNER_EMAIL ?? '';
152
152
  // still works standalone (e.g. for Collaborators / dev) with no billing-api
153
153
  // configured at all.
154
154
  const BILLING_API_URL = (process.env.MANAGER_BILLING_API_URL ?? '').replace(/\/$/, '');
155
+ // The user's real UI in the hosted flow is the NAMED app (app.galda.app),
156
+ // reached over the relay — never the localhost board (Masa 2026-07-15:
157
+ // 「npm→ターミナル→app.galda.appに戻る / localhostでなくアプリ名で開くべき」).
158
+ // The localhost board stays the entry only for local/dev (no billing) runs.
159
+ const APP_URL = (process.env.MANAGER_APP_URL ?? '').replace(/\/$/, '');
160
+ // Force the sign-in flow even when a license already exists — so a user can
161
+ // switch to a different Google account (a stale token from a previous account
162
+ // otherwise silently binds the relay to the wrong account). Set by `--signin`.
163
+ const FORCE_SIGNIN = process.env.MANAGER_FORCE_SIGNIN === '1';
155
164
  const entitlementCache = new Map(); // billing email -> { isPaying, checkedAt }
156
165
 
157
166
  // License token (local-first identity, docs/BILLING-LAUNCH-PLAN.md): a signed
@@ -166,6 +175,14 @@ const entitlementCache = new Map(); // billing email -> { isPaying, checkedAt }
166
175
  const licenseFile = join(DATA_DIR, 'license.token');
167
176
  let licenseState = { email: null, isPaying: false, verifiedAt: 0 }; // in-memory cache of the last successful verify (isPaying is the token's snapshot; the live gate re-checks)
168
177
 
178
+ // Analytics identity (A+): once signed in, scope usage events to the ACCOUNT
179
+ // (the billing Worker hashes this email → the same account_id the accounts table
180
+ // carries, so per-user app activity joins WITHOUT a cookie). Not signed in →
181
+ // the anonymous per-install id. Raw email never lands in usage_events (the
182
+ // Worker pseudonymizes it server-side).
183
+ function analyticsUid() { return pickAnalyticsUid(licenseState.email, ANALYTICS_UID); }
184
+ let _freeExhaustedEmitted = false;
185
+
169
186
  // One-time nonces for the "Sign in with Google" loopback (GET /api/signin-url →
170
187
  // billing /connect → Google → billing → GET /oauth/callback). The engine issues
171
188
  // the nonce, round-trips it through the flow, and requires it back — so a random
@@ -216,12 +233,17 @@ function signinResultPage(outcome) {
216
233
  const body = outcome.ok
217
234
  ? `<h1>Signed in</h1><p>You're signed in as <b>${esc(outcome.email)}</b>. This tab will close automatically.</p>`
218
235
  : `<h1>Sign-in didn't complete</h1><p>${esc(outcome.error)}</p><p class="muted">Close this tab and click “Sign in with Google” again in the app.</p>`;
236
+ // When the sign-in was opened FROM the app tab (window.opener present), tell
237
+ // it and close. When it was opened by the CLI at startup (no opener — `open`
238
+ // can't script-close it), don't strand the user on a localhost page: send them
239
+ // to the named app so the flow is npm → terminal → app.galda.app.
240
+ const goApp = outcome.ok && APP_URL ? JSON.stringify(APP_URL) : 'null';
219
241
  return `<!doctype html><meta charset="utf-8"><title>Galda</title>` +
220
242
  `<body style="font:15px/1.6 -apple-system,system-ui,sans-serif;max-width:32rem;margin:12vh auto;padding:0 1.5rem;color:#111">` +
221
243
  `<style>h1{font-size:1.35rem;margin:0 0 .5rem}.muted{color:#888;font-size:.9rem}b{font-weight:600}</style>` +
222
244
  body +
223
- `<script>try{if(window.opener)window.opener.postMessage(${payload},'*')}catch(e){}` +
224
- (outcome.ok ? `setTimeout(function(){try{window.close()}catch(e){}},1200);` : '') +
245
+ `<script>var opened=false;try{if(window.opener){window.opener.postMessage(${payload},'*');opened=true;}}catch(e){}` +
246
+ (outcome.ok ? `var app=${goApp};setTimeout(function(){if(opened){try{window.close()}catch(e){}}else if(app){location.replace(app)}else{try{window.close()}catch(e){}}},900);` : '') +
225
247
  `</script></body>`;
226
248
  }
227
249
 
@@ -348,7 +370,7 @@ async function requireEntitlementGate(identity) {
348
370
  const usage = { ...existing, pendingCount: existing.pendingCount + 1, cumulativeCount: existing.cumulativeCount + 1 };
349
371
  if (!checkFreeTierLimit(usage).blocked) return { allowed: true, blocked: null };
350
372
  const billingEmail = licenseState.email;
351
- if (!billingEmail) return resolveEntitlement({ isOwner: false, isPaying: false, usage });
373
+ if (!billingEmail) { const r = resolveEntitlement({ isOwner: false, isPaying: false, usage }); noteFreeExhausted(r); return r; }
352
374
  const cachedState = resolveCachedEntitlement({ cached: entitlementCache.get(billingEmail) ?? null });
353
375
  let isPaying = cachedState.isPaying;
354
376
  if (cachedState.needsRefresh) {
@@ -358,7 +380,17 @@ async function requireEntitlementGate(identity) {
358
380
  entitlementCache.set(billingEmail, { isPaying: fresh, checkedAt: Date.now() });
359
381
  }
360
382
  }
361
- return resolveEntitlement({ isOwner: false, isPaying, usage });
383
+ const result = resolveEntitlement({ isOwner: false, isPaying, usage });
384
+ noteFreeExhausted(result);
385
+ return result;
386
+ }
387
+ // Emit one free_exhausted funnel event the first time a non-paying install is
388
+ // blocked by the free-tier cap (the funnel dedupes by distinct account/install,
389
+ // so once-per-session is enough).
390
+ function noteFreeExhausted(result) {
391
+ if (!shouldEmitFreeExhausted(_freeExhaustedEmitted, result?.blocked)) return;
392
+ _freeExhaustedEmitted = true;
393
+ emitEvent('free_exhausted', analyticsUid());
362
394
  }
363
395
  // The acting identity for a request: 'owner' for key-auth or
364
396
  // MANAGER_OWNER_EMAIL, else the verified Google email itself. Stamped onto
@@ -578,12 +610,12 @@ function emitTaskAnalytics(t) {
578
610
  if (!t || t.id == null) return;
579
611
  if (!_analyticsEmitted.has(`created:${t.id}`)) {
580
612
  _analyticsEmitted.add(`created:${t.id}`);
581
- emitEvent('task_created', ANALYTICS_UID, { source: 'engine' });
613
+ emitEvent('task_created', analyticsUid(), { source: 'engine' });
582
614
  }
583
615
  const ev = _TERMINAL_EVENT[t.status];
584
616
  if (ev && !_analyticsEmitted.has(`${t.status}:${t.id}`)) {
585
617
  _analyticsEmitted.add(`${t.status}:${t.id}`);
586
- emitEvent(ev, ANALYTICS_UID, { status: t.status, source: 'engine' });
618
+ emitEvent(ev, analyticsUid(), { status: t.status, source: 'engine' });
587
619
  postHistory(t);
588
620
  }
589
621
  }
@@ -1342,15 +1374,15 @@ function testOutputExcerpt(text) {
1342
1374
  }
1343
1375
 
1344
1376
  function workerPrompt(task, goal, lastFailure) {
1345
- // Report back to the human in the SAME language they wrote the goal in (Masa
1346
- // 2026-07-15: "撃った言語で返す"). JP characters → Japanese, otherwise English.
1347
- const replyLang = /[぀-ヿ㐀-鿿]/.test(goal?.text || task?.title || '') ? '日本語' : 'English';
1348
1377
  // Optional skill the user attached to this goal. Empirically (scratch worker,
1349
1378
  // haiku, custom fixture skill) the reliably-firing form is an explicit Skill-tool
1350
1379
  // instruction as the FIRST line; a bare "/name" first line did not consistently
1351
1380
  // trigger the skill in `claude -p`. See commit body for the run evidence.
1352
1381
  const skill = task?.skill ?? goal?.skill;
1353
1382
  const agentName = workerAgent(task?.agent ?? goal?.agent) === 'codex' ? 'Codex' : 'Claude Code';
1383
+ // Report back to the human in the SAME language they wrote the goal in (Masa
1384
+ // 2026-07-15: "撃った言語で返す"). JP characters → Japanese, otherwise English.
1385
+ const replyLang = detectRequestLanguage(goal?.text || task?.title || '') === 'ja' ? '日本語' : 'English';
1354
1386
  return [
1355
1387
  skill ? `FIRST, use the /${skill} skill via the Skill tool (invoke Skill with the "${skill}" skill) before doing anything else. Then carry out the task below.` : '',
1356
1388
  `You are a ${agentName} worker managed by "Manager for AI". Do ONLY the task below inside this project directory.`,
@@ -2012,7 +2044,11 @@ function computeGoalDiff(goal, project) {
2012
2044
  // plain-language review headline in the user's language: what to CHECK and what
2013
2045
  // CHANGED. Never trusts the worker to self-format.
2014
2046
  async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
2015
- const lang = reviewDefinition.language || 'ja';
2047
+ // 'auto' (default) matches the language the goal's own request was written
2048
+ // in, same as the planner/worker (Masa 2026-07-15 follow-up to a8d69fe: "the
2049
+ // review-column headline still defaults to ja — align it the same way").
2050
+ // An explicit ja/en on the project's review definition always overrides.
2051
+ const lang = reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : (reviewDefinition.language || 'ja');
2016
2052
  const changed = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].slice(0, 20);
2017
2053
  const reports = siblings.filter((t) => !t.reply).map((t) => `- ${t.title}: ${(t.result ?? '').replace(/\s+/g, ' ').slice(0, 300)}`).join('\n');
2018
2054
  const fallback = { check: '', changed: (goal.plan?.[0] ?? goal.text ?? '').replace(/\s+/g, ' ').slice(0, 80) };
@@ -2221,6 +2257,16 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2221
2257
  // EVERY task must carry a passing proof, not just UI tasks. Only ever tightens;
2222
2258
  // never conflicts with §6 (still can't reach Done without a human).
2223
2259
  const verifyFail = reviewDefinition.requireVerifyPass && !allVerified;
2260
+ // 段5 検証ゲート厳格化 (Masa 2026-07-15: "proof無しは完了と言わない"): passCondition
2261
+ // を約束したタスクなのに 0 ファイル変更で proof も無い=約束した検証が実行されずに
2262
+ // すり抜けたサイン(isUiChange は changedFiles が空だと絶対に一致しないため、既存の
2263
+ // proofMissing(UIタスク限定)はこのケースを検知できない)。passCondition が無い調査
2264
+ // 系ゴール(0ファイルが正当な完了)は対象外 — 人間が読んで判断すれば足りる。
2265
+ const nothingVerifiable = isNothingVerifiable({
2266
+ changedFiles,
2267
+ hasPassCondition: siblings.some((t) => t.passCondition),
2268
+ hasAnyProof: siblings.some((t) => t.proof),
2269
+ });
2224
2270
  if (testsFailing) {
2225
2271
  const failure = recordGoalFailure(goal, {
2226
2272
  kind: 'test',
@@ -2253,22 +2299,24 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2253
2299
  return;
2254
2300
  }
2255
2301
  }
2256
- if (testsFailing || proofMissing || proofFailing || verifyFail) {
2302
+ if (testsFailing || proofMissing || proofFailing || verifyFail || nothingVerifiable) {
2257
2303
  if (!testsFailing) {
2258
2304
  recordGoalFailure(goal, {
2259
- kind: (proofMissing || proofFailing) ? 'proof' : 'verify',
2305
+ kind: (proofMissing || proofFailing) ? 'proof' : nothingVerifiable ? 'noop' : 'verify',
2260
2306
  reason: proofMissing ? 'UI変更なのに proof(スクショ/動画) が取れていない'
2261
2307
  : proofFailing ? 'UI proof は撮れたが、依頼対象のUIを確認できていない'
2308
+ : nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
2262
2309
  : '検証(proof)が全タスク分そろっていない(requireVerifyPass)',
2263
2310
  changedFiles,
2264
2311
  testResult: goal.testResult,
2265
2312
  });
2266
2313
  }
2267
2314
  goal.blocked = {
2268
- kind: testsFailing ? 'test' : (proofMissing || proofFailing) ? 'proof' : 'verify',
2315
+ kind: testsFailing ? 'test' : (proofMissing || proofFailing) ? 'proof' : nothingVerifiable ? 'noop' : 'verify',
2269
2316
  reason: testsFailing ? `テスト失敗 ${goal.testResult.failed} 件(自動修正 ${Number(goal.autoRework?.testFailures ?? 0)} 回後も失敗)`
2270
2317
  : proofMissing ? 'UI変更なのに proof(スクショ/動画) が取れていない'
2271
2318
  : proofFailing ? 'UI proof は撮れたが、依頼対象のUIを確認できていない'
2319
+ : nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
2272
2320
  : '検証(proof)が全タスク分そろっていない(requireVerifyPass)',
2273
2321
  };
2274
2322
  goal.status = 'blocked';
@@ -2317,9 +2365,11 @@ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT
2317
2365
  }
2318
2366
  const ownerRes = spawnSync('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], { cwd: project.dir, encoding: 'utf8' });
2319
2367
  const owner = ownerRes.status === 0 ? ownerRes.stdout.trim() : null;
2320
- // PR body in the USER's language (reviewDefinition.language, default ja)
2321
- // not forced English/bilingual (Masa: 「ユーザーの言語で」).
2322
- const lang = reviewDefinition.language || 'ja';
2368
+ // PR body in the USER's language default 'auto' matches the goal's own
2369
+ // request language (same as planner/worker); an explicit ja/en override on
2370
+ // the project's review definition always wins. Not forced English/bilingual
2371
+ // (Masa: 「ユーザーの言語で」).
2372
+ const lang = reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : (reviewDefinition.language || 'ja');
2323
2373
  const L = (en, ja) => (lang === 'en' ? en : ja);
2324
2374
  const bodyFor = (branch) => [
2325
2375
  ...(proofItems.length && owner ? [
@@ -3428,15 +3478,22 @@ server.on('error', (e) => {
3428
3478
  server.listen(PORT, '127.0.0.1', () => {
3429
3479
  console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
3430
3480
  if (process.env.MANAGER_OPEN_BROWSER === '1' && process.platform === 'darwin') {
3431
- // First run in the hosted flow (no license yet, billing worker configured):
3432
- // open the one-click Google device-link straight away instead of the local
3433
- // ?key board, so a non-engineer never sees a localhost URL or an access key
3434
- // they sign in once and their hosted board comes alive (relay-client rebinds
3435
- // via its license.token watcher). Otherwise open the local board as before.
3481
+ // Hosted flow (billing worker + named app configured): the user lives in
3482
+ // app.galda.app, driven over the relay they must NEVER be dropped on a
3483
+ // localhost board or an access key (Masa 2026-07-15: 「npm→ターミナル→
3484
+ // app.galda.appに戻る」). Not signed in (or switching accounts via --signin)
3485
+ // one-click Google sign-in; already signed in straight to the app (the
3486
+ // board comes alive as relay-client rebinds via its license.token watcher).
3487
+ // The localhost ?key board stays the entry ONLY for local/dev (no billing).
3436
3488
  let openUrl = `http://localhost:${PORT}/?key=${ACCESS_KEY}`;
3437
- if (BILLING_API_URL && !existsSync(licenseFile)) {
3438
- openUrl = `${BILLING_API_URL}/connect?port=${PORT}&state=${encodeURIComponent(newSigninNonce())}`;
3439
- console.log('[manager] first run: opening one-click sign-in (no local key needed) → ' + openUrl);
3489
+ if (BILLING_API_URL && APP_URL) {
3490
+ const signedIn = existsSync(licenseFile) && !FORCE_SIGNIN;
3491
+ openUrl = signedIn
3492
+ ? APP_URL
3493
+ : `${BILLING_API_URL}/connect?port=${PORT}&state=${encodeURIComponent(newSigninNonce())}`;
3494
+ console.log(signedIn
3495
+ ? `[manager] opening your app → ${APP_URL}`
3496
+ : `[manager] ${FORCE_SIGNIN ? 'switching account' : 'first run'}: opening one-click sign-in → ${openUrl}`);
3440
3497
  }
3441
3498
  spawn('open', ['-g', openUrl], { stdio: 'ignore' }).unref();
3442
3499
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.8",
3
+ "version": "0.10.10",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to your Claude Code, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {