@galda/cli 0.10.34 → 0.10.35

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.
@@ -118,8 +118,18 @@ await import(pathToFileURL(join(ROOT, 'engine', 'server.mjs')).href);
118
118
  // public build will default it to the deployed relay. See relay/DEPLOY.md.
119
119
  if (process.env.RELAY_URL) {
120
120
  const { spawn } = await import('node:child_process');
121
- const relay = spawn(process.execPath, [join(ROOT, 'engine', 'relay-client.mjs')], { stdio: 'inherit', env: process.env });
122
- const stop = () => { try { relay.kill(); } catch { /* already gone */ } };
121
+ const { superviseRelay } = await import(pathToFileURL(join(ROOT, 'engine', 'relay-supervisor.mjs')).href);
122
+ // Supervise the relay-client: if the CHILD exits (uncaught throw, single-
123
+ // instance lock refuse) we bring it back with backoff, otherwise the app goes
124
+ // silently offline ("Unsent"). A tight crash loop (a config/lock error) trips a
125
+ // circuit breaker so we stop hammering and say why. relay-client heals its own
126
+ // dropped socket; this heals the whole process dying. See engine/relay-supervisor.mjs.
127
+ const sup = superviseRelay({
128
+ spawn: () => spawn(process.execPath, [join(ROOT, 'engine', 'relay-client.mjs')], { stdio: 'inherit', env: process.env }),
129
+ log: ({ code, signal, delayMs }) => say(`relay-client exited (code ${code ?? 'n/a'}${signal ? `, ${signal}` : ''}) — restarting in ${Math.round(delayMs / 1000)}s`),
130
+ onGiveUp: () => say('relay-client keeps exiting immediately — giving up; run `npx @galda/cli --signin` or check RELAY_URL'),
131
+ });
132
+ const stop = () => sup.stop();
123
133
  process.on('exit', stop);
124
134
  for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stop(); process.exit(0); });
125
135
  say(`relay: connecting to ${process.env.RELAY_URL} — your fixed URL goes live once you sign in`);
package/engine/lib.mjs CHANGED
@@ -906,6 +906,73 @@ export function hasTestRelevantChanges(changedFiles) {
906
906
  return files.some((f) => !DOC_ONLY_RE.test(f));
907
907
  }
908
908
 
909
+ // Extract the set of FAILING test titles from a `node --test` (or TAP) run.
910
+ // The gate needs NAMES, not just a count, so it can tell a NEW failure (this
911
+ // change's fault) from one that was ALREADY red on the base (e.g. a known-red
912
+ // like persist-failure that fails even on clean origin/main — CLAUDE.md).
913
+ // node --test prints `✖ <title> (1234.5ms)` (both inline and again under the
914
+ // `✖ failing tests:` summary header, which carries no title and is skipped);
915
+ // TAP prints `not ok N - <title>` (a `# SKIP`/`# TODO` directive is not a
916
+ // failure). Titles are de-duplicated and sorted for a stable set-diff.
917
+ export function parseFailingTestNames(text) {
918
+ const out = new Set();
919
+ const stripMs = (s) => s.replace(/\s*\(\d[\d.]*m?s\)\s*$/i, '').trim();
920
+ for (const raw of String(text ?? '').split('\n')) {
921
+ const line = raw.trim();
922
+ if (!line) continue;
923
+ let m = line.match(/^[✖✗✘×]\s+(.*)$/);
924
+ if (m) {
925
+ const title = stripMs(m[1].trim());
926
+ if (!title || /^failing tests:?$/i.test(title)) continue; // summary header
927
+ out.add(title);
928
+ continue;
929
+ }
930
+ m = line.match(/^not ok\s+\d+\s*-?\s*(.*)$/i);
931
+ if (m) {
932
+ if (/#\s*(SKIP|TODO)\b/i.test(line)) continue; // directive, not a real failure
933
+ const title = stripMs(m[1].replace(/\s+#.*$/, '').trim());
934
+ if (title) out.add(title);
935
+ }
936
+ }
937
+ return [...out].sort();
938
+ }
939
+
940
+ // Compare the CURRENT run's failing tests against the BASE (pre-change) run's
941
+ // so the review gate blocks ONLY on failures this change introduced. A test
942
+ // that was already red on the base is pre-existing noise (unrelated to the
943
+ // goal) — it must not knock a proof-verified goal back to blocked/Failed.
944
+ // Callers should treat an EMPTY baseline as "unknown" and fall back to the old
945
+ // block-on-any-red behaviour (fail closed) — see verifyGate.
946
+ export function classifyTestGate({ current = [], baseline = [] } = {}) {
947
+ const base = new Set(baseline);
948
+ const cur = [...new Set((current ?? []).filter(Boolean))];
949
+ const newFailures = cur.filter((t) => !base.has(t)).sort();
950
+ const preExisting = cur.filter((t) => base.has(t)).sort();
951
+ return { newFailures, preExisting, blocked: newFailures.length > 0 };
952
+ }
953
+
954
+ // The verification-gate decision (Masa 2026-07-19: "検証/proof は Failed を作らない").
955
+ // PROOF IS ADVISORY: a UI goal whose screenshot could not be captured
956
+ // (proofMissing) or whose pixel heuristic could not auto-confirm the target
957
+ // (proofFailing) is NOT blocked — the work is done and reaches Review with a
958
+ // note; proof adds confidence, it never subtracts a correct result. What still
959
+ // blocks a goal from Review: newly-failing tests (testsFailing — the caller has
960
+ // already made this baseline-aware via classifyTestGate), a promised-but-undone
961
+ // task (nothingVerifiable = a passCondition with no file change and no proof),
962
+ // and the opt-in requireVerifyPass gate (verifyFail, off by default). Pure so
963
+ // the whole gate decision is unit-tested rather than living only inside the
964
+ // server's big async verifyGate.
965
+ export function classifyVerifyGate({
966
+ testsFailing = false, proofMissing = false, proofFailing = false,
967
+ nothingVerifiable = false, verifyFail = false,
968
+ } = {}) {
969
+ const blocked = Boolean(testsFailing || nothingVerifiable || verifyFail);
970
+ const kind = testsFailing ? 'test' : nothingVerifiable ? 'noop' : verifyFail ? 'verify' : null;
971
+ // Advisory note surfaced on the review even when the goal is NOT blocked.
972
+ const proofAdvisory = proofMissing ? 'missing' : proofFailing ? 'unconfirmed' : null;
973
+ return { blocked, kind, proofAdvisory };
974
+ }
975
+
909
976
  // Whether to ALSO capture a BASELINE ("before") shot for a before/after
910
977
  // comparison. Only worth attempting when the baseline is a genuinely
911
978
  // different checkout of the code than the one just captured as "after" — a
@@ -1907,7 +1974,42 @@ export const WORKFLOW_BUCKETS = ['todo', 'doing', 'review', 'done'];
1907
1974
  // Tools a `claude -p` worker may call (--allowedTools). TodoWrite must stay
1908
1975
  // in this list — it's how the UI's To-do section (sendTodos / SSE 'todos')
1909
1976
  // gets any data at all; without it workers can never emit {kind:'todos'}.
1910
- export const WORKER_TOOLS = 'Edit,Write,Read,Glob,Grep,Skill,TodoWrite,Bash(node:*),Bash(npm:*),Bash(npx:*)';
1977
+ //
1978
+ // Bash was locked to node/npm/npx, which produced the "works in Claude Code,
1979
+ // fails here" class: a goal that legitimately needs python, a build tool, or
1980
+ // even a read-only `git log` would just fail — an error a plain Claude Code
1981
+ // session never hits. Widened here to the common legitimate commands.
1982
+ //
1983
+ // Honest note on what this list does and does NOT buy: it is friction control,
1984
+ // not a security boundary. Bash(node:*) already permits arbitrary execution
1985
+ // (`node -e "..."` can touch the network, delete files, anything), so the old
1986
+ // narrow list contained nothing — it only blocked honest direct commands.
1987
+ // Real isolation would need an OS sandbox (codex runs with --sandbox; the
1988
+ // claude-code worker does not). Given that, the exclusions below are posture,
1989
+ // not a wall:
1990
+ // - git WRITE ops (commit/push/reset/checkout) are left off the fast path so
1991
+ // the worker does not fight the Manager, which owns git in the worktree
1992
+ // (the worker prompt already says "do not commit"). Read-only git is on.
1993
+ // - rm / curl / wget stay off the fast path. (They are reachable via node
1994
+ // anyway; keeping them non-first-class is a stated-posture choice. Whether
1995
+ // to simplify to a blanket `Bash` — technically equivalent risk — is a
1996
+ // customer-safety decision for Masa, not a silent flip here.)
1997
+ const WORKER_BASH = [
1998
+ // JS/TS runtimes and package managers
1999
+ 'node', 'npm', 'npx', 'pnpm', 'yarn', 'bun', 'deno',
2000
+ // other languages a task might actually need
2001
+ 'python', 'python3', 'pip', 'pip3', 'pytest', 'go', 'cargo', 'rustc',
2002
+ 'ruby', 'bundle', 'make',
2003
+ // build/test/lint runners invoked directly
2004
+ 'tsc', 'jest', 'vitest', 'eslint', 'prettier',
2005
+ // read-only git — inspect history/diffs like you would locally
2006
+ 'git status', 'git log', 'git diff', 'git show', 'git branch',
2007
+ 'git blame', 'git ls-files', 'git rev-parse',
2008
+ // read/inspect shell utilities (file EDITS still go through Edit/Write)
2009
+ 'cat', 'ls', 'head', 'tail', 'wc', 'find', 'grep', 'rg',
2010
+ 'sed', 'awk', 'sort', 'uniq', 'diff', 'echo', 'which', 'env', 'pwd', 'date',
2011
+ ].map((c) => `Bash(${c}:*)`).join(',');
2012
+ export const WORKER_TOOLS = `Edit,Write,Read,Glob,Grep,Skill,TodoWrite,${WORKER_BASH}`;
1911
2013
 
1912
2014
  // ---- SKILL picker ----------------------------------------------------------
1913
2015
  // Parse the YAML frontmatter of a SKILL.md / command .md and pull out `name`
@@ -0,0 +1,113 @@
1
+ // Launcher-side supervisor for the relay-client CHILD process.
2
+ //
3
+ // Why this exists (2026-07-18 incident): the launcher (bin/manager-for-ai.mjs)
4
+ // spawns engine/relay-client.mjs exactly ONCE and only kills it when the parent
5
+ // goes down — there was no path the other way (child dies -> bring it back).
6
+ // relay-client already self-heals a dropped SOCKET (backoff reconnect, capped at
7
+ // 15s), but when the whole PROCESS exits — an uncaught throw, or the single-
8
+ // instance lock refusing to start — nothing respawns it, so the user's app
9
+ // silently goes offline and every new goal shows "Unsent – tap to resend". This
10
+ // is the customer-facing half of the fix (the operator's own box is now kept up
11
+ // by a launchd KeepAlive job; npx users have no launchd, only this launcher).
12
+ //
13
+ // Split into a PURE policy reducer (relayRespawnDecision) and an impure driver
14
+ // (superviseRelay) that owns spawn()/timers/clock — same shape as
15
+ // relayReconnectDecision in engine/lib.mjs. The policy is unit-tested without any
16
+ // real process; the driver is integration-tested with a fake child.
17
+
18
+ // Fresh supervisor state. `delayMs` is the last backoff used; `quickExits` counts
19
+ // consecutive exits that happened inside the "quick-crash" window; `gaveUp` latches
20
+ // once the circuit breaker trips.
21
+ export const relaySupervisorInit = { delayMs: 0, quickExits: 0, gaveUp: false };
22
+
23
+ // Decide what to do when the supervised child exits. Pure — no clock, no I/O.
24
+ //
25
+ // state : previous supervisor state (start from relaySupervisorInit)
26
+ // event : { uptimeMs } — how long the child that just exited had been alive
27
+ // opts : tunables (defaults match the relay socket backoff feel)
28
+ // minMs floor delay for the first / a reset respawn (1s)
29
+ // maxMs ceiling the exponential backoff saturates at (15s)
30
+ // quickMs a child that lived less than this "crash-looped" (10s)
31
+ // limit consecutive quick exits before we give up (5)
32
+ //
33
+ // Returns { restart, delayMs, gaveUp, state }.
34
+ // - A child that lived past `quickMs` is treated as having made progress: the
35
+ // backoff and the quick-exit counter both reset (a healthy long run should not
36
+ // inherit a punishing delay from an earlier bad patch).
37
+ // - Consecutive quick exits escalate the delay (min, 2x, 4x, ... capped at max)
38
+ // and, once they reach `limit`, trip the circuit breaker so we stop hammering a
39
+ // config/lock error forever. The caller logs why and stands down.
40
+ export function relayRespawnDecision(state, event, opts = {}) {
41
+ const { minMs = 1000, maxMs = 15000, quickMs = 10000, limit = 5 } = opts;
42
+ const prev = Number(state?.delayMs);
43
+ const lastDelay = Number.isFinite(prev) ? prev : 0;
44
+ const uptimeMs = Number(event?.uptimeMs) || 0;
45
+ const quick = uptimeMs < quickMs;
46
+ const quickExits = quick ? (Number(state?.quickExits) || 0) + 1 : 0;
47
+
48
+ if (quickExits >= limit) {
49
+ return { restart: false, delayMs: 0, gaveUp: true, state: { delayMs: lastDelay, quickExits, gaveUp: true } };
50
+ }
51
+
52
+ const delayMs = quick
53
+ ? (lastDelay < minMs ? minMs : Math.min(lastDelay * 2, maxMs))
54
+ : minMs;
55
+ return { restart: true, delayMs, gaveUp: false, state: { delayMs, quickExits, gaveUp: false } };
56
+ }
57
+
58
+ // Drive a respawning child. Impure: owns spawn()/timers/clock, all injectable so
59
+ // the loop is testable with a fake child and fast delays.
60
+ //
61
+ // spawn () => ChildProcess-like (must emit 'exit' and have .kill())
62
+ // now () => ms clock (default Date.now)
63
+ // setTimer/clearTimer timer fns (default global setTimeout/clearTimeout)
64
+ // log ({ code, signal, delayMs }) => void — a respawn was scheduled
65
+ // onGiveUp () => void — circuit breaker tripped
66
+ // opts passed through to relayRespawnDecision
67
+ //
68
+ // Returns { stop, child, state } — stop() suppresses further respawns (normal
69
+ // shutdown) and kills the current child.
70
+ export function superviseRelay({
71
+ spawn,
72
+ now = () => Date.now(),
73
+ setTimer = setTimeout,
74
+ clearTimer = clearTimeout,
75
+ log = () => {},
76
+ onGiveUp = () => {},
77
+ opts,
78
+ } = {}) {
79
+ let shuttingDown = false;
80
+ let state = { ...relaySupervisorInit };
81
+ let child = null;
82
+ let startedAt = 0;
83
+ let timer = null;
84
+
85
+ const start = () => {
86
+ startedAt = now();
87
+ child = spawn();
88
+ child.on('exit', (code, signal) => {
89
+ child = null;
90
+ if (shuttingDown) return; // parent asked us to stop — don't fight it
91
+ const uptimeMs = now() - startedAt;
92
+ const decision = relayRespawnDecision(state, { uptimeMs }, opts);
93
+ state = decision.state;
94
+ if (!decision.restart) { onGiveUp(); return; }
95
+ log({ code, signal, delayMs: decision.delayMs });
96
+ timer = setTimer(start, decision.delayMs);
97
+ if (timer && typeof timer.unref === 'function') timer.unref();
98
+ });
99
+ };
100
+
101
+ const stop = () => {
102
+ shuttingDown = true;
103
+ if (timer) { clearTimer(timer); timer = null; }
104
+ try { child?.kill(); } catch { /* already gone */ }
105
+ };
106
+
107
+ start();
108
+ return {
109
+ stop,
110
+ get child() { return child; },
111
+ get state() { return state; },
112
+ };
113
+ }
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, 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
 
@@ -1755,7 +1755,17 @@ function queueReplyTask(goal, text) {
1755
1755
  tasks.push(task);
1756
1756
  saveTask(task);
1757
1757
  queues.get(goal.projectId).waiting.push(task);
1758
- pump(goal.projectId);
1758
+ // Spawn the worker on the NEXT event-loop turn, not synchronously here. runTask() runs
1759
+ // straight through to its first await — and that stretch includes goalWorkDir()'s
1760
+ // `git worktree add` (measured 1.78s on a checkout with ~135 worktrees). Callers like
1761
+ // POST /api/goals/:id/dismiss and /reply invoke queueReplyTask from inside their HTTP
1762
+ // handler and send the 200 right after, so a synchronous pump() made the response wait on
1763
+ // the worktree — the review UI looked stuck on "review" for seconds while the Ledger had
1764
+ // already claimed "Feedback sent → back to To Do" (Masa 2026-07-19). setImmediate lets the
1765
+ // response flush first; the task is already queued and persisted, so nothing is lost if the
1766
+ // worker starts a turn later. (setImmediate, not nextTick/microtask: those run before the
1767
+ // socket write flushes, so the blocking git call would still delay the response.)
1768
+ setImmediate(() => pump(goal.projectId));
1759
1769
  return task;
1760
1770
  }
1761
1771
 
@@ -2192,8 +2202,12 @@ async function runTask(task) {
2192
2202
  } finally {
2193
2203
  eph?.kill();
2194
2204
  }
2195
- sendAct(task, `verify: ${verdict.pass ? 'PASS' : 'FAIL'} — ${verdict.detail.slice(0, 120)}`);
2196
- if (verdict.pass) break;
2205
+ sendAct(task, `verify: ${verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (check could not run)' : 'FAIL'} — ${verdict.detail.slice(0, 120)}`);
2206
+ // An inconclusive verdict = the check itself couldn't run (authored check
2207
+ // referenced hooks the app doesn't expose — #487). Retrying will just
2208
+ // throw again, so stop and let it route to human review rather than
2209
+ // burning attempts and scoring the change as failed.
2210
+ if (verdict.pass || verdict.inconclusive) break;
2197
2211
  lastFailure = verdict.detail;
2198
2212
  } catch (e) {
2199
2213
  sendAct(task, `verify: could not run — ${String(e.message ?? e).slice(0, 120)}`);
@@ -2212,11 +2226,19 @@ async function runTask(task) {
2212
2226
  // FAIL verdict on a killed (incomplete) worker isn't authoritative either,
2213
2227
  // so the stop reason takes precedence. (Mirrors the budget-breach path.)
2214
2228
  // 3. a verify that actually ran and FAILED = 'failed'.
2215
- // 4. no verify = the exit code decides.
2229
+ // 4. an INCONCLUSIVE verify (the check threw / returned garbage — the
2230
+ // verifier is at fault, not necessarily the change, #487) is NOT
2231
+ // authoritative: fall through to the exit code so a cleanly-finished
2232
+ // worker still reaches review for a human, instead of a false 'failed'.
2233
+ // 5. no verify = the exit code decides.
2216
2234
  const forcedStop = isForcedStop(code, timedOut);
2235
+ // A verdict only counts as authoritative when the check actually judged the
2236
+ // change. An inconclusive verdict (check threw / returned garbage) is treated
2237
+ // like "no verdict" everywhere below — status, proof, and snapshot capture.
2238
+ const conclusive = verdict && !verdict.inconclusive;
2217
2239
  task.status = verdict?.pass ? 'done'
2218
2240
  : forcedStop ? 'interrupted'
2219
- : verdict ? 'failed'
2241
+ : conclusive ? 'failed'
2220
2242
  : (code === 0 ? 'done' : 'failed');
2221
2243
  // Never leave the reason as a bare "(no output)": if the worker produced no
2222
2244
  // report text, result already carries workerExitReason(); make doubly sure a
@@ -2225,19 +2247,26 @@ async function runTask(task) {
2225
2247
  task.result = result.slice(0, 4000);
2226
2248
  task.usage = sumUsage(usages);
2227
2249
  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
- };
2250
+ const label = verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (自動確認できず・要確認)' : 'FAIL';
2251
+ task.result = `検証${label}: ${verdict.detail}\n\n${task.result}`.slice(0, 4000);
2252
+ // Only attach an authoritative proof for a conclusive verdict. An
2253
+ // inconclusive one must NOT leave a pass:false proof — that would trip
2254
+ // verifyGate's proofFailing and re-block the goal through the back door
2255
+ // (#487). Fall through to the snapshot capture below instead.
2256
+ if (conclusive) {
2257
+ task.proof = {
2258
+ pass: verdict.pass,
2259
+ detail: verdict.detail.slice(0, 500),
2260
+ gif: verdict.gifPath?.split('/').pop() ?? null,
2261
+ png: verdict.shotPath?.split('/').pop() ?? null,
2262
+ mp4: verdict.videoPath?.split('/').pop() ?? null,
2263
+ // STRONG proof: this came from an independent passCondition verification,
2264
+ // not a generic auto-snapshot. Only this flag lets a force-stopped (interrupted)
2265
+ // task count toward goal completion (isProofVerified) — a weak "UI capture"
2266
+ // snapshot never auto-promotes an interrupted goal past human review.
2267
+ verified: true,
2268
+ };
2269
+ }
2241
2270
  }
2242
2271
  const after = await gitChanges(workDir);
2243
2272
  task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
@@ -2250,7 +2279,7 @@ async function runTask(task) {
2250
2279
  // baselineDir = the user's real project dir (unmodified, pre-goal code) —
2251
2280
  // when it's genuinely different from wproject.dir (a real worktree, not
2252
2281
  // 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) })) {
2282
+ if (!conclusive && task.status === 'done' && shouldCaptureProof({ changedFiles: task.changedFiles, hasProof: Boolean(task.proof), canCapture: canSnapshot(wproject) })) {
2254
2283
  try {
2255
2284
  sendAct(task, 'proof: capturing UI change (screenshot + clip) of the edited app…');
2256
2285
  await captureSnapshotProof(task, wproject, { baselineDir: project.dir, goal });
@@ -2305,7 +2334,9 @@ function runTestsOnce(dir) {
2305
2334
  const timeoutFlakes = (text.match(/did not report ready in time/g) || []).length;
2306
2335
  const realAssertion = /AssertionError|Expected values to be|expected .+ (?:to |but )/i.test(text);
2307
2336
  const failed = realAssertion ? failedTotal : Math.max(0, failedTotal - timeoutFlakes);
2308
- res({ passed, failed, detail: testOutputExcerpt(text) });
2337
+ // Capture the failing test NAMES too (not just the count) so the gate can
2338
+ // diff them against the base's failures and block only on NEW ones.
2339
+ res({ passed, failed, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
2309
2340
  });
2310
2341
  });
2311
2342
  }
@@ -2320,13 +2351,45 @@ async function runProjectTests(dir) {
2320
2351
  // never blocked on a flaky test.
2321
2352
  if (r.failed > 0) {
2322
2353
  const r2 = await runTestsOnce(dir);
2354
+ const kept = r2.failed <= r.failed ? r2 : r; // the run that saw fewer failures
2323
2355
  r = {
2324
2356
  passed: Math.max(r.passed, r2.passed),
2325
2357
  failed: Math.min(r.failed, r2.failed),
2326
- detail: (r2.failed <= r.failed ? r2.detail : r.detail) || r.detail || r2.detail || '',
2358
+ detail: kept.detail || r.detail || r2.detail || '',
2359
+ failingNames: kept.failingNames ?? r.failingNames ?? [],
2327
2360
  };
2328
2361
  }
2329
- return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, detail: r.detail ?? '' };
2362
+ return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
2363
+ }
2364
+
2365
+ // Failing tests on the BASE (pre-change) checkout — the set the review gate
2366
+ // subtracts so a goal is blocked only on failures IT introduced, never on a
2367
+ // test that was already red (e.g. persist-failure on clean origin/main). Lazy
2368
+ // + cached by the base's HEAD sha in MANAGER_HOME: computed only when a goal's
2369
+ // own run is red, then reused across goals sharing the same base (main moves a
2370
+ // few times a day, so this is a handful of full runs a day, not one per goal).
2371
+ // Any failure to compute → [] = "unknown", and the caller falls back to the
2372
+ // old block-on-any-red behaviour (fail closed).
2373
+ const BASELINE_CACHE = join(DATA_DIR, 'test-baseline.json');
2374
+ async function getBaselineFailures(baseProject) {
2375
+ const dir = baseProject?.dir;
2376
+ if (!dir) return [];
2377
+ let sha = '';
2378
+ try {
2379
+ const r = spawnSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
2380
+ if (r.status === 0) sha = String(r.stdout || '').trim();
2381
+ } catch { return []; }
2382
+ if (!sha) return [];
2383
+ try {
2384
+ const cached = JSON.parse(readFileSync(BASELINE_CACHE, 'utf8'));
2385
+ if (cached && cached.sha === sha && Array.isArray(cached.failingNames)) return cached.failingNames;
2386
+ } catch { /* no cache yet */ }
2387
+ let base;
2388
+ try { base = await runProjectTests(dir); } catch { return []; }
2389
+ if (!base?.ran) return [];
2390
+ const failingNames = base.failingNames ?? [];
2391
+ try { writeFileSync(BASELINE_CACHE, JSON.stringify({ sha, failingNames, at: new Date().toISOString() })); } catch { /* best effort */ }
2392
+ return failingNames;
2330
2393
  }
2331
2394
 
2332
2395
  // ---- See all Ledger engine jobs (HANDOFF-v45 §5 / PRD §5.1) ----------------
@@ -2704,7 +2767,42 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2704
2767
  }
2705
2768
  const proofMissing = canCapture && uiTasks.length > 0 && !uiTasks.some((t) => t.proof);
2706
2769
  const proofFailing = canCapture && uiTasks.some((t) => t.proof && t.proof.pass === false);
2707
- const testsFailing = goal.testResult.ran && goal.testResult.failed > 0;
2770
+ let testsFailing = goal.testResult.ran && goal.testResult.failed > 0;
2771
+ // Baseline diff (Masa 2026-07-19): block ONLY on failures THIS change
2772
+ // introduced. A test already red on the base — e.g. persist-failure on clean
2773
+ // origin/main — is pre-existing noise and must not knock a proof-verified
2774
+ // goal (#474/#494) or even a no-op goal (#465) back to blocked/Failed. Only
2775
+ // consult the base when the goal's own run is red (keeps the green path free
2776
+ // and pays the base run at most a few times a day, cached by base sha). An
2777
+ // empty baseline = "unknown" → fall through to the old block-on-any-red.
2778
+ // Only trust a baseline diff when the base is a genuinely SEPARATE checkout
2779
+ // from the changed worktree. finishGoalIfComplete runs in the goal's own
2780
+ // worktree (prProject.dir === the worktree), so the base must be the user's
2781
+ // ORIGINAL project dir (on main ≈ origin/main, without this goal's change) —
2782
+ // resolved from the registry, not the worktree-scoped project we were handed.
2783
+ // When they'd be the same dir (shared-dir fallback, no isolated worktree) the
2784
+ // "base" already contains this change, so fall back to the old block-on-any-
2785
+ // red (fail closed).
2786
+ const baseDir = projects.find((p) => p.id === goal.projectId)?.dir;
2787
+ const canBaseline = baseDir && prProject?.dir && baseDir !== prProject.dir && existsSync(baseDir);
2788
+ if (testsFailing && canBaseline) {
2789
+ const baseline = await getBaselineFailures({ dir: baseDir });
2790
+ if (baseline.length) {
2791
+ const gate = classifyTestGate({ current: goal.testResult.failingNames ?? [], baseline });
2792
+ goal.testResult.newFailures = gate.newFailures;
2793
+ goal.testResult.preExisting = gate.preExisting;
2794
+ if (!gate.blocked) {
2795
+ testsFailing = false;
2796
+ goal.testResult.preExistingOnly = true;
2797
+ 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(' / ')}`);
2798
+ } else {
2799
+ 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(' / ')}`);
2800
+ }
2801
+ }
2802
+ }
2803
+ // Count that drives the block message: the NEW failures when we could diff
2804
+ // against the base, else the raw total (fail-closed when the base is unknown).
2805
+ const blockingFailCount = goal.testResult.newFailures?.length ?? goal.testResult.failed;
2708
2806
  // §4補足: requireVerifyPass (opt-in, off by default) makes the gate strict —
2709
2807
  // EVERY task must carry a passing proof, not just UI tasks. Only ever tightens;
2710
2808
  // never conflicts with §6 (still can't reach Done without a human).
@@ -2722,16 +2820,19 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2722
2820
  if (testsFailing) {
2723
2821
  const failure = recordGoalFailure(goal, {
2724
2822
  kind: 'test',
2725
- reason: `テスト失敗 ${goal.testResult.failed} 件`,
2823
+ reason: `テスト失敗 ${blockingFailCount} 件`,
2726
2824
  changedFiles,
2727
2825
  testResult: goal.testResult,
2728
2826
  });
2827
+ const newFailList = goal.testResult.newFailures?.length
2828
+ ? `Failures introduced by this change (fix ONLY these — pre-existing reds are not your fault):\n- ${goal.testResult.newFailures.join('\n- ')}`
2829
+ : '';
2729
2830
  const used = Number(goal.autoRework?.testFailures ?? 0);
2730
2831
  if (used < MAX_AUTO_TEST_REWORKS) {
2731
2832
  goal.autoRework = {
2732
2833
  ...(goal.autoRework ?? {}),
2733
2834
  testFailures: used + 1,
2734
- lastReason: `テスト失敗 ${goal.testResult.failed} 件`,
2835
+ lastReason: `テスト失敗 ${blockingFailCount} 件`,
2735
2836
  lastAt: new Date().toISOString(),
2736
2837
  };
2737
2838
  goal.blocked = null;
@@ -2739,7 +2840,8 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2739
2840
  saveGoal(goal);
2740
2841
  goalAct(`Tests failed; queued automatic worker feedback (${used + 1}/${MAX_AUTO_TEST_REWORKS}) with the failing output.`);
2741
2842
  queueReplyTask(goal, [
2742
- `Manager auto-feedback: npm test failed (${goal.testResult.failed} failing).`,
2843
+ `Manager auto-feedback: npm test failed (${blockingFailCount} failing).`,
2844
+ newFailList,
2743
2845
  failure?.whatHappened ? `What happened: ${failure.whatHappened}` : '',
2744
2846
  failure?.yourCall ?? 'Please inspect the failure output below, fix the regression, and rerun the project tests until they pass.',
2745
2847
  failure?.nextPolicy ? `Prevention policy: ${failure.nextPolicy}` : '',
@@ -2751,23 +2853,32 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2751
2853
  return;
2752
2854
  }
2753
2855
  }
2754
- if (testsFailing || proofMissing || proofFailing || verifyFail || nothingVerifiable) {
2856
+ // Proof is advisory now (Masa 2026-07-19): proofMissing / proofFailing no
2857
+ // longer block — the work is done; a screenshot that could not be captured or
2858
+ // a pixel heuristic that could not auto-confirm the target is a NOTE on the
2859
+ // review, not a Failed goal (audit false-failure #3/#2). The proof artifact
2860
+ // (when there is one) is still attached for the human to look at. What still
2861
+ // blocks: newly-failing tests, a promised-but-undone task, opt-in verifyFail.
2862
+ const gate = classifyVerifyGate({ testsFailing, proofMissing, proofFailing, verifyFail, nothingVerifiable });
2863
+ goal.proofNote = gate.proofAdvisory === 'missing'
2864
+ ? 'UI変更ですが proof(スクショ/動画) を撮影できませんでした — 目視で確認してください'
2865
+ : gate.proofAdvisory === 'unconfirmed'
2866
+ ? 'proof は撮れましたが、依頼対象のUIを自動確認できませんでした — 目視で確認してください'
2867
+ : null;
2868
+ if (goal.proofNote) goalAct(`Proof advisory (not blocking): ${goal.proofNote}`);
2869
+ if (gate.blocked) {
2755
2870
  if (!testsFailing) {
2756
2871
  recordGoalFailure(goal, {
2757
- kind: (proofMissing || proofFailing) ? 'proof' : nothingVerifiable ? 'noop' : 'verify',
2758
- reason: proofMissing ? 'UI変更なのに proof(スクショ/動画) が取れていない'
2759
- : proofFailing ? 'UI proof は撮れたが、依頼対象のUIを確認できていない'
2760
- : nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
2872
+ kind: gate.kind,
2873
+ reason: nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
2761
2874
  : '検証(proof)が全タスク分そろっていない(requireVerifyPass)',
2762
2875
  changedFiles,
2763
2876
  testResult: goal.testResult,
2764
2877
  });
2765
2878
  }
2766
2879
  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を確認できていない'
2880
+ kind: gate.kind,
2881
+ reason: testsFailing ? `テスト失敗 ${blockingFailCount} 件(自動修正 ${Number(goal.autoRework?.testFailures ?? 0)} 回後も失敗)`
2771
2882
  : nothingVerifiable ? '検証条件つきのタスクなのに、ファイル変更も proof も無い(約束した検証が行われていない)'
2772
2883
  : '検証(proof)が全タスク分そろっていない(requireVerifyPass)',
2773
2884
  };
@@ -2777,7 +2888,10 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2777
2888
  goal.blocked = null;
2778
2889
  if (goal.autoRework?.testFailures) goal.autoRework = { ...goal.autoRework, resolvedAt: new Date().toISOString() };
2779
2890
  goal.status = nextGoalStatus({ allDone, wantsPR: goal.wantsPR, allVerified, reviewDefinition });
2780
- goalAct(`Verification gate passed; moving goal to ${goal.status}.`);
2891
+ const preNote = goal.testResult?.preExistingOnly
2892
+ ? ` (${goal.testResult.preExisting?.length ?? 0} pre-existing test failure(s) ignored as unrelated to this change)`
2893
+ : '';
2894
+ goalAct(`Verification gate passed; moving goal to ${goal.status}.${preNote}${goal.proofNote ? ` [proof advisory: ${goal.proofNote}]` : ''}`);
2781
2895
  }
2782
2896
  }
2783
2897
 
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.35",
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": {