@galda/cli 0.10.34 → 0.10.36

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/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, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
24
- import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, 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, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot } from './lib.mjs';
24
+ import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, 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, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot } from './lib.mjs';
25
25
  import { openPR } from './pr.mjs';
26
26
  import { runVerification, exerciseUi } from './verify.mjs';
27
27
 
@@ -657,11 +657,17 @@ if (process.env.MANAGER_SEED_RUNNING_FIXTURE === '1') {
657
657
  const goalId = nextId++, taskId = nextId++;
658
658
  goals.push({
659
659
  id: goalId, projectId: 'default', text: '[verify fixture] 実行中ゴール', status: 'running',
660
+ // startedAt is what a REAL running goal carries (server.mjs sets it when the
661
+ // first task starts — see runTask). Without it the board bucketed this as
662
+ // To Do while the list showed it under Doing (task-level) — the two views
663
+ // disagreed on a malformed fixture. Set it so the seed matches reality:
664
+ // both views place a running goal in Doing (Masa 2026-07-19).
665
+ startedAt: at,
660
666
  wantsPR: false, model: 'sonnet', images: [], plan: ['fixture task'], pr: undefined, createdAt: at,
661
667
  });
662
668
  tasks.push({
663
669
  id: taskId, num: 1, goalId, projectId: 'default', title: 'fixture task', detail: '', passCondition: '',
664
- model: 'sonnet', status: 'running', createdAt: at, result: null, changedFiles: [], secs: null, proof: null, usage: null,
670
+ model: 'sonnet', status: 'running', createdAt: at, startedAt: at, result: null, changedFiles: [], secs: null, proof: null, usage: null,
665
671
  activity: [
666
672
  'Read app/index.html', 'Grep taskBlock', 'Edit app/index.html', 'Read engine/lib.mjs',
667
673
  'Bash node --test engine/test/*.test.mjs', '✓ 12 tests passed', 'Edit engine/server.mjs',
@@ -1302,12 +1308,22 @@ function globalRunningCount() {
1302
1308
  return n;
1303
1309
  }
1304
1310
 
1311
+ // Per-project play/pause (Masa: Doing列の一時停止/再生). A paused project neither
1312
+ // starts new stacked goals nor pumps queued tasks — the two choke points below
1313
+ // (startNextStacked / pump) read this, so EVERY caller of them is covered by one
1314
+ // guard each. The flag lives on the project object (projects.json), so it
1315
+ // survives a restart; nothing auto-resumes a paused project.
1316
+ function isProjectPaused(projectId) {
1317
+ return !!projects.find((p) => p.id === projectId)?.paused;
1318
+ }
1319
+
1305
1320
  // Goals stack up per project: while one goal is being planned or executed,
1306
1321
  // newer goals wait as 'stacked' (editable / deletable until picked up).
1307
1322
  function goalActive(projectId) {
1308
1323
  return goals.some((g) => g.projectId === projectId && ['planning', 'running'].includes(g.status));
1309
1324
  }
1310
1325
  function startNextStacked(projectId) {
1326
+ if (isProjectPaused(projectId)) return; // paused → don't begin planning queued goals
1311
1327
  if (goalActive(projectId)) return;
1312
1328
  const next = goals.filter((g) => g.projectId === projectId && g.status === 'stacked')
1313
1329
  .sort((a, b) => (b.prio ?? 0) - (a.prio ?? 0) || a.id - b.id)[0];
@@ -1496,6 +1512,9 @@ function pump(projectId) {
1496
1512
  // to sign `claude` in and relaunch). Never spawn a worker that can only fail
1497
1513
  // with "Not logged in". Cleared automatically once a probe succeeds.
1498
1514
  if (!workerAuth.ok) return;
1515
+ // Project paused by the user (Doing列の一時停止 / chat) → hold this project's
1516
+ // queue exactly like the auth hold above. Resume flips the flag and pumps.
1517
+ if (isProjectPaused(projectId)) return;
1499
1518
  const q = queues.get(projectId);
1500
1519
  // sortQueueByPriority is a stable sort: it only ever moves a task ahead of
1501
1520
  // a lower-priority one, never disturbs relative order within the same
@@ -1729,6 +1748,48 @@ function rateLimitResumeSweep() {
1729
1748
  const rateLimitSweepTimer = setInterval(rateLimitResumeSweep, RATE_LIMIT_SWEEP_MS);
1730
1749
  rateLimitSweepTimer.unref?.();
1731
1750
 
1751
+ // Project play/pause (Masa: Doing列の一時停止ボタン + チャット). Pause STOPS the
1752
+ // project's in-flight Doing work and prevents new ToDo from starting; Resume
1753
+ // continues from where it left off. Modelled on the rate-limit pause above (a
1754
+ // SIGTERM'd worker becomes an 'interrupted' task with its sessionId preserved,
1755
+ // so runTask's retry/resume path re-continues the same conversation) — the only
1756
+ // differences are: it's user-driven (no resumeAt / auto-resume), and it flips
1757
+ // the persisted project.paused flag that gates pump()/startNextStacked().
1758
+ //
1759
+ // Race note: setting goal.status='blocked' HERE (synchronously) BEFORE the
1760
+ // SIGTERM'd worker's async exit runs means finishGoalIfComplete later sees a
1761
+ // non-'running'/'partial' goal and bails at its status guard without clobbering
1762
+ // the paused state — see finishGoalIfComplete's early return.
1763
+ function pauseProject(projectId) {
1764
+ const project = projects.find((p) => p.id === projectId);
1765
+ if (!project || project.paused) return false;
1766
+ project.paused = true;
1767
+ saveProjects();
1768
+ for (const g of goals.filter((g) => g.projectId === projectId && g.status === 'running')) {
1769
+ killGoalWorkers(g.id); // SIGTERM → task becomes 'interrupted' (resumable), not cancelled
1770
+ g.status = 'blocked';
1771
+ g.blocked = { kind: 'paused', reason: 'paused by user — resume to continue' };
1772
+ saveGoal(g);
1773
+ }
1774
+ send({ ev: 'projects', projects });
1775
+ return true;
1776
+ }
1777
+ function resumeProject(projectId) {
1778
+ const project = projects.find((p) => p.id === projectId);
1779
+ if (!project || !project.paused) return false;
1780
+ project.paused = false;
1781
+ saveProjects();
1782
+ // Re-queue each paused goal's interrupted tasks and flip it back to running
1783
+ // (reuses the proven rate-limit resume: continues from the preserved session).
1784
+ for (const g of goals.filter((g) => g.projectId === projectId && g.status === 'blocked' && g.blocked?.kind === 'paused')) {
1785
+ resumeRateLimitedGoal(g);
1786
+ }
1787
+ startNextStacked(projectId);
1788
+ pump(projectId);
1789
+ send({ ev: 'projects', projects });
1790
+ return true;
1791
+ }
1792
+
1732
1793
  // Slack風スレッド返信タスクの生成(/api/goals/:id/reply と /api/goals/:id/dismiss で共有)。
1733
1794
  // runTask()のtask.reply分岐がgoal.sessionIdをresumeに渡して同じ会話を続ける。
1734
1795
  function queueReplyTask(goal, text) {
@@ -1755,7 +1816,17 @@ function queueReplyTask(goal, text) {
1755
1816
  tasks.push(task);
1756
1817
  saveTask(task);
1757
1818
  queues.get(goal.projectId).waiting.push(task);
1758
- pump(goal.projectId);
1819
+ // Spawn the worker on the NEXT event-loop turn, not synchronously here. runTask() runs
1820
+ // straight through to its first await — and that stretch includes goalWorkDir()'s
1821
+ // `git worktree add` (measured 1.78s on a checkout with ~135 worktrees). Callers like
1822
+ // POST /api/goals/:id/dismiss and /reply invoke queueReplyTask from inside their HTTP
1823
+ // handler and send the 200 right after, so a synchronous pump() made the response wait on
1824
+ // the worktree — the review UI looked stuck on "review" for seconds while the Ledger had
1825
+ // already claimed "Feedback sent → back to To Do" (Masa 2026-07-19). setImmediate lets the
1826
+ // response flush first; the task is already queued and persisted, so nothing is lost if the
1827
+ // worker starts a turn later. (setImmediate, not nextTick/microtask: those run before the
1828
+ // socket write flushes, so the blocking git call would still delay the response.)
1829
+ setImmediate(() => pump(goal.projectId));
1759
1830
  return task;
1760
1831
  }
1761
1832
 
@@ -2192,8 +2263,12 @@ async function runTask(task) {
2192
2263
  } finally {
2193
2264
  eph?.kill();
2194
2265
  }
2195
- sendAct(task, `verify: ${verdict.pass ? 'PASS' : 'FAIL'} — ${verdict.detail.slice(0, 120)}`);
2196
- if (verdict.pass) break;
2266
+ sendAct(task, `verify: ${verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (check could not run)' : 'FAIL'} — ${verdict.detail.slice(0, 120)}`);
2267
+ // An inconclusive verdict = the check itself couldn't run (authored check
2268
+ // referenced hooks the app doesn't expose — #487). Retrying will just
2269
+ // throw again, so stop and let it route to human review rather than
2270
+ // burning attempts and scoring the change as failed.
2271
+ if (verdict.pass || verdict.inconclusive) break;
2197
2272
  lastFailure = verdict.detail;
2198
2273
  } catch (e) {
2199
2274
  sendAct(task, `verify: could not run — ${String(e.message ?? e).slice(0, 120)}`);
@@ -2212,11 +2287,19 @@ async function runTask(task) {
2212
2287
  // FAIL verdict on a killed (incomplete) worker isn't authoritative either,
2213
2288
  // so the stop reason takes precedence. (Mirrors the budget-breach path.)
2214
2289
  // 3. a verify that actually ran and FAILED = 'failed'.
2215
- // 4. no verify = the exit code decides.
2290
+ // 4. an INCONCLUSIVE verify (the check threw / returned garbage — the
2291
+ // verifier is at fault, not necessarily the change, #487) is NOT
2292
+ // authoritative: fall through to the exit code so a cleanly-finished
2293
+ // worker still reaches review for a human, instead of a false 'failed'.
2294
+ // 5. no verify = the exit code decides.
2216
2295
  const forcedStop = isForcedStop(code, timedOut);
2296
+ // A verdict only counts as authoritative when the check actually judged the
2297
+ // change. An inconclusive verdict (check threw / returned garbage) is treated
2298
+ // like "no verdict" everywhere below — status, proof, and snapshot capture.
2299
+ const conclusive = verdict && !verdict.inconclusive;
2217
2300
  task.status = verdict?.pass ? 'done'
2218
2301
  : forcedStop ? 'interrupted'
2219
- : verdict ? 'failed'
2302
+ : conclusive ? 'failed'
2220
2303
  : (code === 0 ? 'done' : 'failed');
2221
2304
  // Never leave the reason as a bare "(no output)": if the worker produced no
2222
2305
  // report text, result already carries workerExitReason(); make doubly sure a
@@ -2225,19 +2308,26 @@ async function runTask(task) {
2225
2308
  task.result = result.slice(0, 4000);
2226
2309
  task.usage = sumUsage(usages);
2227
2310
  if (verdict) {
2228
- task.result = `検証${verdict.pass ? 'PASS' : 'FAIL'}: ${verdict.detail}\n\n${task.result}`.slice(0, 4000);
2229
- task.proof = {
2230
- pass: verdict.pass,
2231
- detail: verdict.detail.slice(0, 500),
2232
- gif: verdict.gifPath?.split('/').pop() ?? null,
2233
- png: verdict.shotPath?.split('/').pop() ?? null,
2234
- mp4: verdict.videoPath?.split('/').pop() ?? null,
2235
- // STRONG proof: this came from an independent passCondition verification,
2236
- // not a generic auto-snapshot. Only this flag lets a force-stopped (interrupted)
2237
- // task count toward goal completion (isProofVerified) — a weak "UI capture"
2238
- // snapshot never auto-promotes an interrupted goal past human review.
2239
- verified: true,
2240
- };
2311
+ const label = verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (自動確認できず・要確認)' : 'FAIL';
2312
+ task.result = `検証${label}: ${verdict.detail}\n\n${task.result}`.slice(0, 4000);
2313
+ // Only attach an authoritative proof for a conclusive verdict. An
2314
+ // inconclusive one must NOT leave a pass:false proof — that would trip
2315
+ // verifyGate's proofFailing and re-block the goal through the back door
2316
+ // (#487). Fall through to the snapshot capture below instead.
2317
+ if (conclusive) {
2318
+ task.proof = {
2319
+ pass: verdict.pass,
2320
+ detail: verdict.detail.slice(0, 500),
2321
+ gif: verdict.gifPath?.split('/').pop() ?? null,
2322
+ png: verdict.shotPath?.split('/').pop() ?? null,
2323
+ mp4: verdict.videoPath?.split('/').pop() ?? null,
2324
+ // STRONG proof: this came from an independent passCondition verification,
2325
+ // not a generic auto-snapshot. Only this flag lets a force-stopped (interrupted)
2326
+ // task count toward goal completion (isProofVerified) — a weak "UI capture"
2327
+ // snapshot never auto-promotes an interrupted goal past human review.
2328
+ verified: true,
2329
+ };
2330
+ }
2241
2331
  }
2242
2332
  const after = await gitChanges(workDir);
2243
2333
  task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
@@ -2250,7 +2340,7 @@ async function runTask(task) {
2250
2340
  // baselineDir = the user's real project dir (unmodified, pre-goal code) —
2251
2341
  // when it's genuinely different from wproject.dir (a real worktree, not
2252
2342
  // the shared-dir fallback), also attempt a before/after pair.
2253
- if (!verdict && task.status === 'done' && shouldCaptureProof({ changedFiles: task.changedFiles, hasProof: Boolean(task.proof), canCapture: canSnapshot(wproject) })) {
2343
+ if (!conclusive && task.status === 'done' && shouldCaptureProof({ changedFiles: task.changedFiles, hasProof: Boolean(task.proof), canCapture: canSnapshot(wproject) })) {
2254
2344
  try {
2255
2345
  sendAct(task, 'proof: capturing UI change (screenshot + clip) of the edited app…');
2256
2346
  await captureSnapshotProof(task, wproject, { baselineDir: project.dir, goal });
@@ -2305,7 +2395,9 @@ function runTestsOnce(dir) {
2305
2395
  const timeoutFlakes = (text.match(/did not report ready in time/g) || []).length;
2306
2396
  const realAssertion = /AssertionError|Expected values to be|expected .+ (?:to |but )/i.test(text);
2307
2397
  const failed = realAssertion ? failedTotal : Math.max(0, failedTotal - timeoutFlakes);
2308
- res({ passed, failed, detail: testOutputExcerpt(text) });
2398
+ // Capture the failing test NAMES too (not just the count) so the gate can
2399
+ // diff them against the base's failures and block only on NEW ones.
2400
+ res({ passed, failed, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
2309
2401
  });
2310
2402
  });
2311
2403
  }
@@ -2320,13 +2412,80 @@ async function runProjectTests(dir) {
2320
2412
  // never blocked on a flaky test.
2321
2413
  if (r.failed > 0) {
2322
2414
  const r2 = await runTestsOnce(dir);
2415
+ const kept = r2.failed <= r.failed ? r2 : r; // the run that saw fewer failures
2323
2416
  r = {
2324
2417
  passed: Math.max(r.passed, r2.passed),
2325
2418
  failed: Math.min(r.failed, r2.failed),
2326
- detail: (r2.failed <= r.failed ? r2.detail : r.detail) || r.detail || r2.detail || '',
2419
+ detail: kept.detail || r.detail || r2.detail || '',
2420
+ failingNames: kept.failingNames ?? r.failingNames ?? [],
2327
2421
  };
2328
2422
  }
2329
- return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, detail: r.detail ?? '' };
2423
+ return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
2424
+ }
2425
+
2426
+ // Failing tests on the BASE (pre-change) checkout — the set the review gate
2427
+ // subtracts so a goal is blocked only on failures IT introduced, never on a
2428
+ // test that was already red (e.g. persist-failure on clean origin/main). Lazy
2429
+ // + cached by the base-ref sha in MANAGER_HOME: computed only when a goal's own
2430
+ // run is red, then reused across goals sharing the same base (main moves a few
2431
+ // times a day, so this is a handful of full runs a day, not one per goal).
2432
+ // Any failure to compute → [] = "unknown", and the caller falls back to the
2433
+ // old block-on-any-red behaviour (fail closed).
2434
+ //
2435
+ // 🔴 The base MUST be the ref the goal worktree was branched from —
2436
+ // origin/<default> (goalWorkDir: `git worktree add … origin/<br>`), NOT the
2437
+ // registry dir's working HEAD. Those diverge in the wild: a dogfood checkout
2438
+ // left on an old branch, or uncommitted local work. Measuring the baseline on a
2439
+ // stale HEAD runs a suite where today's red tests don't exist yet → an EMPTY
2440
+ // failing set → the gate falls closed and blocks EVERY goal on a red that was
2441
+ // actually pre-existing. (Masa 2026-07-19: the registry dir sat 612 commits
2442
+ // behind origin/main, so every goal Failed on "テスト失敗 1件" the baseline
2443
+ // should have excused.) So resolve origin/<default> and run the base suite in a
2444
+ // throwaway detached worktree of that exact commit — nested under
2445
+ // repoRoot/.manager-wt so node's upward node_modules resolution still finds the
2446
+ // repo's deps (the same trick goal worktrees rely on), and so we never mutate
2447
+ // the registry checkout (prod procs read it; it may be on any branch).
2448
+ const BASELINE_CACHE = join(DATA_DIR, 'test-baseline.json');
2449
+ const baselineInFlight = new Map(); // sha -> Promise<string[]> — dedupe concurrent goal closes
2450
+ async function getBaselineFailures(baseProject) {
2451
+ const dir = baseProject?.dir;
2452
+ if (!dir) return [];
2453
+ let repoRoot;
2454
+ try { repoRoot = gitSync(dir, ['rev-parse', '--show-toplevel']); } catch { return []; }
2455
+ const br = repoDefaultBranch(dir);
2456
+ try { gitSync(repoRoot, ['fetch', 'origin', br, '--quiet']); } catch { /* offline — use whatever origin/<br> we already have */ }
2457
+ // The base ref the goal worktree is cut from: origin/<br>, else the local
2458
+ // branch tip, else the working HEAD (offline single-checkout fallback).
2459
+ const sha = (gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${br}`])
2460
+ || gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', br])
2461
+ || gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', 'HEAD']) || '').trim();
2462
+ if (!sha) return [];
2463
+ try {
2464
+ const cached = JSON.parse(readFileSync(BASELINE_CACHE, 'utf8'));
2465
+ if (cached && cached.sha === sha && Array.isArray(cached.failingNames)) return cached.failingNames;
2466
+ } catch { /* no cache yet */ }
2467
+ if (baselineInFlight.has(sha)) return baselineInFlight.get(sha);
2468
+ const job = (async () => {
2469
+ const wt = join(repoRoot, '.manager-wt', `baseline-${sha.slice(0, 12)}`);
2470
+ let base = null;
2471
+ try {
2472
+ try { gitSync(repoRoot, ['worktree', 'remove', '--force', wt]); } catch { /* nothing to clean */ }
2473
+ gitSync(repoRoot, ['worktree', 'add', '--detach', wt, sha]);
2474
+ base = await runProjectTests(wt);
2475
+ } catch {
2476
+ // Worktree couldn't be made (odd repo / offline no-remote) — fall back to
2477
+ // the registry dir in place = the pre-fix behaviour. Never worse.
2478
+ try { base = await runProjectTests(dir); } catch { base = null; }
2479
+ } finally {
2480
+ try { gitSync(repoRoot, ['worktree', 'remove', '--force', wt]); } catch { /* best effort */ }
2481
+ }
2482
+ if (!base?.ran) return [];
2483
+ const failingNames = base.failingNames ?? [];
2484
+ try { writeFileSync(BASELINE_CACHE, JSON.stringify({ sha, failingNames, at: new Date().toISOString() })); } catch { /* best effort */ }
2485
+ return failingNames;
2486
+ })();
2487
+ baselineInFlight.set(sha, job);
2488
+ try { return await job; } finally { baselineInFlight.delete(sha); }
2330
2489
  }
2331
2490
 
2332
2491
  // ---- See all Ledger engine jobs (HANDOFF-v45 §5 / PRD §5.1) ----------------
@@ -2704,7 +2863,42 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2704
2863
  }
2705
2864
  const proofMissing = canCapture && uiTasks.length > 0 && !uiTasks.some((t) => t.proof);
2706
2865
  const proofFailing = canCapture && uiTasks.some((t) => t.proof && t.proof.pass === false);
2707
- const testsFailing = goal.testResult.ran && goal.testResult.failed > 0;
2866
+ let testsFailing = goal.testResult.ran && goal.testResult.failed > 0;
2867
+ // Baseline diff (Masa 2026-07-19): block ONLY on failures THIS change
2868
+ // introduced. A test already red on the base — e.g. persist-failure on clean
2869
+ // origin/main — is pre-existing noise and must not knock a proof-verified
2870
+ // goal (#474/#494) or even a no-op goal (#465) back to blocked/Failed. Only
2871
+ // consult the base when the goal's own run is red (keeps the green path free
2872
+ // and pays the base run at most a few times a day, cached by base sha). An
2873
+ // empty baseline = "unknown" → fall through to the old block-on-any-red.
2874
+ // Only trust a baseline diff when the base is a genuinely SEPARATE checkout
2875
+ // from the changed worktree. finishGoalIfComplete runs in the goal's own
2876
+ // worktree (prProject.dir === the worktree), so the base must be the user's
2877
+ // ORIGINAL project dir (on main ≈ origin/main, without this goal's change) —
2878
+ // resolved from the registry, not the worktree-scoped project we were handed.
2879
+ // When they'd be the same dir (shared-dir fallback, no isolated worktree) the
2880
+ // "base" already contains this change, so fall back to the old block-on-any-
2881
+ // red (fail closed).
2882
+ const baseDir = projects.find((p) => p.id === goal.projectId)?.dir;
2883
+ const canBaseline = baseDir && prProject?.dir && baseDir !== prProject.dir && existsSync(baseDir);
2884
+ if (testsFailing && canBaseline) {
2885
+ const baseline = await getBaselineFailures({ dir: baseDir });
2886
+ if (baseline.length) {
2887
+ const gate = classifyTestGate({ current: goal.testResult.failingNames ?? [], baseline });
2888
+ goal.testResult.newFailures = gate.newFailures;
2889
+ goal.testResult.preExisting = gate.preExisting;
2890
+ if (!gate.blocked) {
2891
+ testsFailing = false;
2892
+ goal.testResult.preExistingOnly = true;
2893
+ goalAct(`Project tests: ${gate.preExisting.length} failing test(s) were already red on the base (unrelated to this change), 0 new — not blocking. Pre-existing: ${gate.preExisting.slice(0, 3).join(' / ')}`);
2894
+ } else {
2895
+ goalAct(`Project tests: ${gate.newFailures.length} NEW failure(s) from this change (of ${goal.testResult.failed} total; ${gate.preExisting.length} pre-existing): ${gate.newFailures.slice(0, 3).join(' / ')}`);
2896
+ }
2897
+ }
2898
+ }
2899
+ // Count that drives the block message: the NEW failures when we could diff
2900
+ // against the base, else the raw total (fail-closed when the base is unknown).
2901
+ const blockingFailCount = goal.testResult.newFailures?.length ?? goal.testResult.failed;
2708
2902
  // §4補足: requireVerifyPass (opt-in, off by default) makes the gate strict —
2709
2903
  // EVERY task must carry a passing proof, not just UI tasks. Only ever tightens;
2710
2904
  // never conflicts with §6 (still can't reach Done without a human).
@@ -2722,16 +2916,19 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2722
2916
  if (testsFailing) {
2723
2917
  const failure = recordGoalFailure(goal, {
2724
2918
  kind: 'test',
2725
- reason: `テスト失敗 ${goal.testResult.failed} 件`,
2919
+ reason: `テスト失敗 ${blockingFailCount} 件`,
2726
2920
  changedFiles,
2727
2921
  testResult: goal.testResult,
2728
2922
  });
2923
+ const newFailList = goal.testResult.newFailures?.length
2924
+ ? `Failures introduced by this change (fix ONLY these — pre-existing reds are not your fault):\n- ${goal.testResult.newFailures.join('\n- ')}`
2925
+ : '';
2729
2926
  const used = Number(goal.autoRework?.testFailures ?? 0);
2730
2927
  if (used < MAX_AUTO_TEST_REWORKS) {
2731
2928
  goal.autoRework = {
2732
2929
  ...(goal.autoRework ?? {}),
2733
2930
  testFailures: used + 1,
2734
- lastReason: `テスト失敗 ${goal.testResult.failed} 件`,
2931
+ lastReason: `テスト失敗 ${blockingFailCount} 件`,
2735
2932
  lastAt: new Date().toISOString(),
2736
2933
  };
2737
2934
  goal.blocked = null;
@@ -2739,7 +2936,8 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2739
2936
  saveGoal(goal);
2740
2937
  goalAct(`Tests failed; queued automatic worker feedback (${used + 1}/${MAX_AUTO_TEST_REWORKS}) with the failing output.`);
2741
2938
  queueReplyTask(goal, [
2742
- `Manager auto-feedback: npm test failed (${goal.testResult.failed} failing).`,
2939
+ `Manager auto-feedback: npm test failed (${blockingFailCount} failing).`,
2940
+ newFailList,
2743
2941
  failure?.whatHappened ? `What happened: ${failure.whatHappened}` : '',
2744
2942
  failure?.yourCall ?? 'Please inspect the failure output below, fix the regression, and rerun the project tests until they pass.',
2745
2943
  failure?.nextPolicy ? `Prevention policy: ${failure.nextPolicy}` : '',
@@ -2751,23 +2949,32 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2751
2949
  return;
2752
2950
  }
2753
2951
  }
2754
- if (testsFailing || proofMissing || proofFailing || verifyFail || nothingVerifiable) {
2952
+ // Proof is advisory now (Masa 2026-07-19): proofMissing / proofFailing no
2953
+ // longer block — the work is done; a screenshot that could not be captured or
2954
+ // a pixel heuristic that could not auto-confirm the target is a NOTE on the
2955
+ // review, not a Failed goal (audit false-failure #3/#2). The proof artifact
2956
+ // (when there is one) is still attached for the human to look at. What still
2957
+ // blocks: newly-failing tests, a promised-but-undone task, opt-in verifyFail.
2958
+ const gate = classifyVerifyGate({ testsFailing, proofMissing, proofFailing, verifyFail, nothingVerifiable });
2959
+ goal.proofNote = gate.proofAdvisory === 'missing'
2960
+ ? 'UI変更ですが proof(スクショ/動画) を撮影できませんでした — 目視で確認してください'
2961
+ : gate.proofAdvisory === 'unconfirmed'
2962
+ ? 'proof は撮れましたが、依頼対象のUIを自動確認できませんでした — 目視で確認してください'
2963
+ : null;
2964
+ if (goal.proofNote) goalAct(`Proof advisory (not blocking): ${goal.proofNote}`);
2965
+ if (gate.blocked) {
2755
2966
  if (!testsFailing) {
2756
2967
  recordGoalFailure(goal, {
2757
- kind: (proofMissing || proofFailing) ? 'proof' : nothingVerifiable ? 'noop' : 'verify',
2758
- reason: proofMissing ? 'UI変更なのに proof(スクショ/動画) が取れていない'
2759
- : proofFailing ? 'UI proof は撮れたが、依頼対象のUIを確認できていない'
2760
- : nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
2968
+ kind: gate.kind,
2969
+ reason: nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
2761
2970
  : '検証(proof)が全タスク分そろっていない(requireVerifyPass)',
2762
2971
  changedFiles,
2763
2972
  testResult: goal.testResult,
2764
2973
  });
2765
2974
  }
2766
2975
  goal.blocked = {
2767
- kind: testsFailing ? 'test' : (proofMissing || proofFailing) ? 'proof' : nothingVerifiable ? 'noop' : 'verify',
2768
- reason: testsFailing ? `テスト失敗 ${goal.testResult.failed} 件(自動修正 ${Number(goal.autoRework?.testFailures ?? 0)} 回後も失敗)`
2769
- : proofMissing ? 'UI変更なのに proof(スクショ/動画) が取れていない'
2770
- : proofFailing ? 'UI proof は撮れたが、依頼対象のUIを確認できていない'
2976
+ kind: gate.kind,
2977
+ reason: testsFailing ? `テスト失敗 ${blockingFailCount} 件(自動修正 ${Number(goal.autoRework?.testFailures ?? 0)} 回後も失敗)`
2771
2978
  : nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
2772
2979
  : '検証(proof)が全タスク分そろっていない(requireVerifyPass)',
2773
2980
  };
@@ -2777,7 +2984,10 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2777
2984
  goal.blocked = null;
2778
2985
  if (goal.autoRework?.testFailures) goal.autoRework = { ...goal.autoRework, resolvedAt: new Date().toISOString() };
2779
2986
  goal.status = nextGoalStatus({ allDone, wantsPR: goal.wantsPR, allVerified, reviewDefinition });
2780
- goalAct(`Verification gate passed; moving goal to ${goal.status}.`);
2987
+ const preNote = goal.testResult?.preExistingOnly
2988
+ ? ` (${goal.testResult.preExisting?.length ?? 0} pre-existing test failure(s) ignored as unrelated to this change)`
2989
+ : '';
2990
+ goalAct(`Verification gate passed; moving goal to ${goal.status}.${preNote}${goal.proofNote ? ` [proof advisory: ${goal.proofNote}]` : ''}`);
2781
2991
  }
2782
2992
  }
2783
2993
 
@@ -3158,6 +3368,18 @@ const server = createServer(async (req, res) => {
3158
3368
  return;
3159
3369
  }
3160
3370
 
3371
+ // Per-project play/pause (Masa: Doing列の一時停止/再生・チャットからも同じ). POST
3372
+ // .../pause stops in-flight Doing work + holds the queue; .../resume continues
3373
+ // from where it left off. Broadcasts the projects change so every client's
3374
+ // toggle updates. Idempotent: pausing an already-paused project is a 200 no-op.
3375
+ const pauseMatch = url.pathname.match(/^\/api\/projects\/([\w-]+)\/(pause|resume)$/);
3376
+ if (pauseMatch && req.method === 'POST') {
3377
+ const project = projects.find((p) => p.id === pauseMatch[1]);
3378
+ if (!project) return json(res, 404, { error: 'project not found' });
3379
+ if (pauseMatch[2] === 'pause') pauseProject(project.id); else resumeProject(project.id);
3380
+ return json(res, 200, { project, projects, paused: !!project.paused });
3381
+ }
3382
+
3161
3383
  // proof artifacts: /proof/<taskId>/<file> (gif/png/mp4 from that task's proof dir)
3162
3384
  const proofMatch = url.pathname.match(/^\/proof\/(\d+)\/([\w.-]+)$/);
3163
3385
  if (proofMatch) {
@@ -3187,6 +3409,21 @@ const server = createServer(async (req, res) => {
3187
3409
  const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent } = JSON.parse(body);
3188
3410
  const project = projects.find((p) => p.id === projectId);
3189
3411
  if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
3412
+ // Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
3413
+ // (no LLM), checked BEFORE the intent router below: a short command-like
3414
+ // "止めて"/"再開" toggles THIS project's pause state and returns a chat reply
3415
+ // — it never becomes a To-Do. /later and /review skip this like the router.
3416
+ if (!pending && !review) {
3417
+ const pauseIntent = detectPauseIntent(text.trim());
3418
+ if (pauseIntent === 'pause') {
3419
+ pauseProject(projectId);
3420
+ return json(res, 200, { kind: 'chat', reply: `${project.name} を一時停止しました。動いていた作業を止め、新しいToDoも始めません。再生(▶)で続きから再開します。` });
3421
+ }
3422
+ if (pauseIntent === 'resume') {
3423
+ resumeProject(projectId);
3424
+ return json(res, 200, { kind: 'chat', reply: `${project.name} を再開しました。止めた作業を続きから進めます。` });
3425
+ }
3426
+ }
3190
3427
  // Onboarding (docs/HANDOFF-ONBOARDING-conversational.md, SLICE 2): the
3191
3428
  // composer is conversation-first. A chat/help/settings message ("what can
3192
3429
  // you do?", "reply in English", a greeting) gets a conversational reply
package/engine/verify.mjs CHANGED
@@ -129,10 +129,17 @@ export async function runVerification({ entryUrl, verify, outBase, record = true
129
129
  try {
130
130
  result = await verify(page, makeUiHelpers(page));
131
131
  if (!result || typeof result.pass !== 'boolean') {
132
- result = { pass: false, detail: `verify script returned an invalid result: ${JSON.stringify(result).slice(0, 200)}` };
132
+ // The check did not produce a verdict the VERIFIER is at fault, not
133
+ // necessarily the change. Mark inconclusive so the caller routes it to
134
+ // human review instead of scoring the worker's change as failed.
135
+ result = { pass: false, inconclusive: true, detail: `verify script returned an invalid result: ${JSON.stringify(result).slice(0, 200)}` };
133
136
  }
134
137
  } catch (e) {
135
- result = { pass: false, detail: `verify script threw: ${String(e.message ?? e).slice(0, 300)}` };
138
+ // The check threw (e.g. an authored check referencing renderTasks/
139
+ // window.state that the booted app doesn't expose — goal #487). It never
140
+ // reached a real judgement, so this is inconclusive, NOT "the change is
141
+ // broken". The caller must not mark the task failed on this alone.
142
+ result = { pass: false, inconclusive: true, detail: `verify script threw: ${String(e.message ?? e).slice(0, 300)}` };
136
143
  }
137
144
  await sleep(1200); // keep the outcome on screen in the video
138
145
  if (recorder) await recorder.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.34",
3
+ "version": "0.10.36",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {