@galda/cli 0.10.83 → 0.10.85

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,8 +21,9 @@ import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { needsProjectFolder, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath } from './lib.mjs';
24
- import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession } from './lib.mjs';
24
+ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildReviewAttemptLedger, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
25
25
  import { createSerialQueue } from './lib.mjs';
26
+ import { parseRequirementEvidence, unmappedChangedFiles } from './lib.mjs';
26
27
  import { isPrClosed } from './lib.mjs';
27
28
  import { collectProjectRules } from './lib.mjs';
28
29
  import { FALLBACK_CODEX_MODELS, loadCodexModels } from './codex-models.mjs';
@@ -1397,7 +1398,7 @@ function cancelGoalWork(goal) {
1397
1398
  killGoalWorkers(goal.id);
1398
1399
  const q = queues.get(goal.projectId);
1399
1400
  if (q) q.waiting = q.waiting.filter((t) => t.goalId !== goal.id);
1400
- for (const t of tasks.filter((t) => t.goalId === goal.id && ['queued', 'running', 'interrupted'].includes(t.status))) {
1401
+ for (const t of tasks.filter((t) => t.goalId === goal.id && ['queued', 'running', 'interrupted', 'awaitingApproval'].includes(t.status))) {
1401
1402
  t.status = 'cancelled'; t.finishedAt = new Date().toISOString(); saveTask(t);
1402
1403
  }
1403
1404
  }
@@ -2208,11 +2209,17 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
2208
2209
  const task = {
2209
2210
  id: nextId++, num: (nums.length ? Math.max(...nums) : 0) + 1,
2210
2211
  goalId: goal.id, projectId: goal.projectId, reply: true, model: goal.model, effort: goal.effort, agent: goal.agent,
2211
- title: text.replace(/\s+/g, ' ').slice(0, 60), detail: promptText.slice(0, 4000),
2212
+ title: text.replace(/\s+/g, ' ').slice(0, 60), detail: promptText.slice(0, 4000), replyFrom: from,
2212
2213
  workspaceMode: resolveWorkspaceMode(goal.workspaceMode ?? goal.worktree),
2213
2214
  worktree: goal.worktree === false ? false : undefined,
2214
2215
  passCondition: null, status: 'queued', createdAt: new Date().toISOString(), priority: '中',
2215
2216
  result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
2217
+ userRequirementHistory: tasks.filter((t) => t.goalId === goal.id && (!t.reply || t.replyFrom === 'you')).slice(-12).map((t) => ({ text: t.reply ? t.detail : t.title, status: t.status })),
2218
+ // Transport the prior attempts as facts; the worker, not Manager, decides
2219
+ // what to implement in response to the new user reply.
2220
+ requirementHistory: tasks.filter((t) => t.goalId === goal.id).slice(-12).map((t) => ({
2221
+ text: t.reply ? t.detail : t.title, status: t.status, reply: !!t.reply,
2222
+ })),
2216
2223
  };
2217
2224
  tasks.push(task);
2218
2225
  saveTask(task);
@@ -2252,6 +2259,24 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
2252
2259
  return task;
2253
2260
  }
2254
2261
 
2262
+ function pauseGoalForApproval(goal, task, request) {
2263
+ if (!goal || !task || !request) return false;
2264
+ const requestedAt = new Date().toISOString();
2265
+ goal.status = 'needsApproval';
2266
+ goal.startedAt = null;
2267
+ goal.pauseReason = 'approval_required';
2268
+ goal.approvalRequest = { ...request, requestedAt, pausedTaskId: task.id, external: false };
2269
+ goal.approvalDecision = null;
2270
+ task.status = 'awaitingApproval';
2271
+ task.result = request.question;
2272
+ task.finishedAt = null;
2273
+ saveTask(task);
2274
+ saveGoal(goal);
2275
+ sendProcessAct(task, `Paused for your approval: ${request.question}`);
2276
+ startNextStacked(goal.projectId);
2277
+ return true;
2278
+ }
2279
+
2255
2280
  // split (docs/design/ONE-CONVERSATION.md §1.3): 「それは別件」と返された時の受け皿。
2256
2281
  // 元のゴールには触らない——新しいチケットを1枚起こし、そこに「どこから生まれたか」だけ
2257
2282
  // 片方向の印(bornFrom)を残す。
@@ -2723,8 +2748,11 @@ async function runTask(task) {
2723
2748
  task.secs = Math.round((Date.now() - t0) / 1000);
2724
2749
  task.status = r.code === 0 ? 'done' : 'failed';
2725
2750
  task.result = r.result.slice(0, RESULT_MAX);
2751
+ task.requirementEvidence = parseRequirementEvidence(task.result);
2726
2752
  task.usage = sumUsage([r.usage]);
2727
2753
  if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
2754
+ const approval = parseApprovalRequest(r.result);
2755
+ if (approval && pauseGoalForApproval(goal, task, approval)) return;
2728
2756
  // A follow-up owns its own evidence too. Previously only the initial task
2729
2757
  // consumed .manager-run.json, so replies either left the new declaration
2730
2758
  // behind or Review kept showing an older task's title/URL.
@@ -2734,6 +2762,8 @@ async function runTask(task) {
2734
2762
  inheritPreviousRunDeclaration(task, goal?.id);
2735
2763
  const afterR = await gitChanges(workDir);
2736
2764
  task.changedFiles = afterR.filter((l) => !before.includes(l)).map((l) => l.slice(3));
2765
+ task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
2766
+ task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
2737
2767
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this follow-up.`);
2738
2768
  task.finishedAt = new Date().toISOString();
2739
2769
  task.resultAttemptId = task.attemptId;
@@ -2760,7 +2790,7 @@ async function runTask(task) {
2760
2790
 
2761
2791
  // No `lastFailure` any more: it existed only to tell the next attempt why our
2762
2792
  // check had rejected the last one, and there is no next attempt now.
2763
- let verify = null, verdict = null, code = 0, result = '', usages = [], timedOut = false;
2793
+ let verify = null, verdict = null, code = 0, result = '', usages = [], timedOut = false, approval = null;
2764
2794
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2765
2795
  let callT0 = Date.now();
2766
2796
  const plan = goal?.executionPlan;
@@ -2822,6 +2852,8 @@ async function runTask(task) {
2822
2852
  ? `Worker attempt ${attempt} was stopped at the ${Math.round(WORKER_TIMEOUT_MS / 60000)}-minute run limit (exit ${code}).`
2823
2853
  : `Worker attempt ${attempt} finished with exit code ${code}.`);
2824
2854
  if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
2855
+ approval = parseApprovalRequest(result);
2856
+ if (approval) break;
2825
2857
  if (!entryUrl) break;
2826
2858
  try {
2827
2859
  if (!verify) {
@@ -2859,6 +2891,11 @@ async function runTask(task) {
2859
2891
  }
2860
2892
 
2861
2893
  if (goal?.cancelled) return; // deleted during the verify loop → don't resurrect the task
2894
+ if (approval) {
2895
+ task.secs = Math.round((Date.now() - t0) / 1000);
2896
+ task.usage = sumUsage(usages);
2897
+ if (pauseGoalForApproval(goal, task, approval)) return;
2898
+ }
2862
2899
  task.secs = Math.round((Date.now() - t0) / 1000);
2863
2900
  // Status decision, most-authoritative first (taskStatusAfterVerify in lib):
2864
2901
  // 1. an independent verify PASS = verified success — wins even if the worker
@@ -2885,6 +2922,7 @@ async function runTask(task) {
2885
2922
  // force-stopped task states WHY so the Attention card explains itself.
2886
2923
  if (forcedStop && (!result || result === '(no output)')) result = workerExitReason(code, timedOut, WORKER_TIMEOUT_MS);
2887
2924
  task.result = result.slice(0, RESULT_MAX);
2925
+ task.requirementEvidence = parseRequirementEvidence(task.result);
2888
2926
  task.usage = sumUsage(usages);
2889
2927
  if (verdict) {
2890
2928
  const label = verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (自動確認できず・要確認)' : 'FAIL';
@@ -2930,6 +2968,8 @@ async function runTask(task) {
2930
2968
  reconcileComparePair(task);
2931
2969
  const after = await gitChanges(workDir);
2932
2970
  task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
2971
+ task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
2972
+ task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
2933
2973
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
2934
2974
  // task 450: taskStatusAfterVerify decided 'done' from `code` alone, before
2935
2975
  // changedFiles existed to check against. Don't trust a clean exit code by
@@ -2991,11 +3031,26 @@ function testNodePath(dir, dependencySourceDir = null) {
2991
3031
  .filter(Boolean).filter(existsSync);
2992
3032
  return [...new Set([...local, ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(sep) : [])])].join(sep);
2993
3033
  }
3034
+ function testEnvironmentPreflight(dir, dependencySourceDir = null) {
3035
+ const nodePath = testNodePath(dir, dependencySourceDir);
3036
+ const sources = nodePath ? nodePath.split(process.platform === 'win32' ? ';' : ':') : [];
3037
+ const hasPuppeteer = sources.some((p) => existsSync(join(p, 'puppeteer-core', 'package.json')));
3038
+ const npm = spawnSync('npm', ['--version'], { encoding: 'utf8' });
3039
+ const checks = [
3040
+ { id: 'node', ok: Boolean(process.execPath), detail: process.version },
3041
+ { id: 'npm', ok: npm.status === 0, detail: npm.status === 0 ? npm.stdout.trim() : 'npm command unavailable' },
3042
+ { id: 'dependencies', ok: sources.length > 0, detail: sources.length ? `${sources.length} node_modules source(s)` : 'node_modules not found' },
3043
+ { id: 'puppeteer', ok: hasPuppeteer, detail: hasPuppeteer ? 'puppeteer-core available' : 'puppeteer-core missing; run npm install in the project directory' },
3044
+ ];
3045
+ return { ok: checks.every((c) => c.ok), checks };
3046
+ }
2994
3047
  function runTestsOnce(dir, dependencySourceDir = null) {
2995
3048
  // The timeout is env-overridable so tests can force the killed-run path fast;
2996
3049
  // production keeps the 4-minute default.
2997
3050
  const runTimeout = Number(process.env.MANAGER_TEST_TIMEOUT_MS) || 240000;
2998
3051
  return testRunQueue.run(() => new Promise((res) => {
3052
+ const preflight = testEnvironmentPreflight(dir, dependencySourceDir);
3053
+ if (!preflight.ok) return res({ passed: 0, failed: 0, preflight, timedOut: false, realAssertion: false, infraOnly: true, infraFlakes: 0, detail: preflight.checks.filter((c) => !c.ok).map((c) => c.detail).join('\n'), failingNames: [] });
2999
3054
  const nodePath = testNodePath(dir, dependencySourceDir);
3000
3055
  execFile('npm', ['test'], { cwd: dir, timeout: runTimeout, env: { ...process.env, ...(nodePath ? { NODE_PATH: nodePath } : {}) }, maxBuffer: 8 * 1024 * 1024 }, (e, out = '', err = '') => {
3001
3056
  const text = `${out}\n${err}`;
@@ -3025,7 +3080,7 @@ function runTestsOnce(dir, dependencySourceDir = null) {
3025
3080
  const timedOut = Boolean(e && (e.killed || e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT'));
3026
3081
  // Capture the failing test NAMES too (not just the count) so the gate can
3027
3082
  // diff them against the base's failures and block only on NEW ones.
3028
- res({ passed, failed, timedOut, realAssertion, infraOnly, infraFlakes, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
3083
+ res({ passed, failed, preflight, timedOut, realAssertion, infraOnly, infraFlakes, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
3029
3084
  });
3030
3085
  }));
3031
3086
  }
@@ -3054,11 +3109,12 @@ async function runProjectTests(dir, dependencySourceDir = null) {
3054
3109
  // the gate should see, so no good goal is blocked on a saturated boot.
3055
3110
  infraOnly: !!kept.infraOnly,
3056
3111
  infraFlakes: kept.infraFlakes ?? 0,
3112
+ preflight: kept.preflight ?? r.preflight ?? null,
3057
3113
  detail: kept.detail || r.detail || r2.detail || '',
3058
3114
  failingNames: kept.failingNames ?? r.failingNames ?? [],
3059
3115
  };
3060
3116
  }
3061
- return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, timedOut: !!r.timedOut, realAssertion: !!r.realAssertion, infraOnly: !!r.infraOnly, infraFlakes: r.infraFlakes ?? 0, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
3117
+ return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, timedOut: !!r.timedOut, realAssertion: !!r.realAssertion, infraOnly: !!r.infraOnly, infraFlakes: r.infraFlakes ?? 0, preflight: r.preflight ?? null, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
3062
3118
  }
3063
3119
 
3064
3120
  // Failing tests on the BASE (pre-change) checkout — the set the review gate
@@ -3241,7 +3297,11 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
3241
3297
  // An explicit ja/en on the project's review definition always overrides.
3242
3298
  const lang = reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : (reviewDefinition.language || 'ja');
3243
3299
  const changed = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].slice(0, 20);
3244
- const reports = siblings.filter((t) => !t.reply).map((t) => `- ${t.title}: ${(t.result ?? '').replace(/\s+/g, ' ').slice(0, 300)}`).join('\n');
3300
+ // A reply is a new implementation attempt, not just conversation. Excluding
3301
+ // it here made the second Review render the first attempt's stale Result,
3302
+ // even when the worker had completed the user's latest feedback.
3303
+ const reports = siblings.map((t) => `${t.reply ? '[follow-up] ' : ''}- ${t.title}: ${(t.result ?? '').replace(/\s+/g, ' ').slice(0, 300)}`).join('\n');
3304
+ const latestFeedback = [...siblings].reverse().find((t) => t.reply && t.detail)?.detail ?? '';
3245
3305
  // The name of the work, from the agent that did it (task.run.title). It wins
3246
3306
  // over both the summarizer and the request text: the summarizer only ever read
3247
3307
  // the same request the user typed, and the request text is the sentence the
@@ -3255,7 +3315,7 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
3255
3315
  changed: (goal.plan?.[0] ?? goal.text ?? '').replace(/\s+/g, ' ').slice(0, 80),
3256
3316
  };
3257
3317
  if (!changed.length && !reports) return fallback;
3258
- const prompt = buildReviewSummaryPrompt({ lang, goalText: goal.text, changed, reports });
3318
+ const prompt = buildReviewSummaryPrompt({ lang, goalText: goal.text, changed, reports, latestFeedback });
3259
3319
  try {
3260
3320
  const r = await runUtility({ prompt, cwd: dir, tools: 'Read' });
3261
3321
  const summary = parseReviewSummary(r.result, fallback);
@@ -3438,6 +3498,7 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
3438
3498
  // after) so the PR body's "確認ポイント" headline is available the first
3439
3499
  // time the PR is opened, not only on the review card (Masa 2026-07-16).
3440
3500
  goalAct('Writing the review summary from the worker report and diff.');
3501
+ goal.reviewAttempt = buildReviewAttemptLedger({ goal, tasks: siblings });
3441
3502
  goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
3442
3503
  if (activityTask?.attemptId) goal.reviewSummaryAttemptId = activityTask.attemptId;
3443
3504
  goalAct('Checking changed files and preparing the PR branch if code changed.');
@@ -3519,7 +3580,7 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
3519
3580
  const testGateOn = resolveTestGate({ reviewDefinition, env: process.env, force: forceTests });
3520
3581
  if (testGateOn && hasTestRelevantChanges(changedFiles)) {
3521
3582
  goalAct(`Running project tests in the goal worktree (${changedFiles.length} changed file${changedFiles.length === 1 ? '' : 's'}).`);
3522
- goal.testResult = await runProjectTests(wdir, project.dir);
3583
+ goal.testResult = await runProjectTests(wdir, baseProject?.dir);
3523
3584
  goalAct(`Project tests finished: ${goal.testResult.failed ?? 0} failed, ${goal.testResult.passed ?? 0} passed.`);
3524
3585
  } else if (!testGateOn) {
3525
3586
  goal.testResult = { ran: false, skipped: true, skippedReason: 'test gate off (opt-in: requireTests / MANAGER_VERIFY_GATE=on)' };
@@ -3571,6 +3632,11 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
3571
3632
  if (goal.testResult.ran && goal.testResult.infraOnly && !goal.testResult.inconclusive) {
3572
3633
  goalAct(`Project tests: all ${goal.testResult.infraFlakes || goal.testResult.failed || ''} failure(s) were infra flakes (a real server could not boot / a wait timed out under machine load), not code regressions — not blocking. Re-run when the machine is idle to confirm.`.replace(/\s{2,}/g, ' '));
3573
3634
  }
3635
+ if (goal.testResult.ran && goal.testResult.preflight && !goal.testResult.preflight.ok) {
3636
+ const missing = goal.testResult.preflight.checks.filter((c) => !c.ok).map((c) => c.id).join(', ');
3637
+ goal.testResult.inconclusive = true;
3638
+ goalAct(`Project tests were not started: verification preflight failed (${missing}). This is an environment issue, not an implementation failure. Fix the listed dependency or tool, then re-run verification.`);
3639
+ }
3574
3640
  // Baseline diff (Masa 2026-07-19): block ONLY on failures THIS change
3575
3641
  // introduced. A test already red on the base — e.g. persist-failure on clean
3576
3642
  // origin/main — is pre-existing noise and must not knock a proof-verified
@@ -3942,6 +4008,23 @@ const server = createServer(async (req, res) => {
3942
4008
  return json(res, 200, { projects: projectsWithBranch, goals: visibleGoals, tasks: trimTaskActivityForState(visibleTasks), models: MODELS, agentModels: { 'claude-code': MODELS, codex: CODEX_MODELS }, agentEfforts: { 'claude-code': CLAUDE_EFFORTS, codex: CODEX_EFFORTS }, agents: AVAILABLE_AGENTS, connectCommand, queueOrder, externalActivity, workflowColumns: projectColumns, reviewDefinitions: projectReviewDefs, runningCounts, parallelLimits, uiVersion: uiVersion(), billing, auth: publicAuth() });
3943
4009
  }
3944
4010
 
4011
+ // Read-only stability snapshot: separates implementation failures from
4012
+ // verification infrastructure so operational trends can be monitored without
4013
+ // scraping human-facing cards. The browser dashboard can consume this later.
4014
+ if (url.pathname === '/api/stability' && req.method === 'GET') {
4015
+ const since = Date.now() - 24 * 60 * 60 * 1000;
4016
+ const recentGoals = goals.filter((g) => Date.parse(g.createdAt ?? '') >= since);
4017
+ const count = (fn) => recentGoals.filter(fn).length;
4018
+ return json(res, 200, {
4019
+ since: new Date(since).toISOString(), generatedAt: new Date().toISOString(), total: recentGoals.length,
4020
+ implementationFailures: count((g) => ['failed', 'partial'].includes(g.status) && !g.verificationInfraError),
4021
+ verificationInfrastructure: count((g) => Boolean(g.verificationInfraError)),
4022
+ timeoutsOrInconclusive: count((g) => Boolean(g.testResult?.inconclusive)),
4023
+ workerHolds: count((g) => ['rate-limit', 'budget'].includes(g.blocked?.kind)),
4024
+ readyForReview: count((g) => g.status === 'review'),
4025
+ });
4026
+ }
4027
+
3945
4028
  // Billing (docs/BILLING-LAUNCH-PLAN.md): activate the license key a user
3946
4029
  // copies from galda.app after paying. GET reports whether one is on file
3947
4030
  // (for the settings panel); POST verifies+persists a newly pasted token.
@@ -4276,14 +4359,14 @@ const server = createServer(async (req, res) => {
4276
4359
  req.on('data', (d) => { body += d; });
4277
4360
  req.on('end', async () => {
4278
4361
  try {
4279
- const { projectId, text, pr, existingPr, images, model, effort, source, pending, review, note, mode, skill, agent, worktree, workspaceMode } = JSON.parse(body);
4362
+ const { projectId, text, pr, existingPr, images, model, effort, source, pending, review, approval, action, scope, resumeInstruction, note, mode, skill, agent, worktree, workspaceMode } = JSON.parse(body);
4280
4363
  const project = projects.find((p) => p.id === projectId);
4281
4364
  if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
4282
4365
  // Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
4283
4366
  // (no LLM), checked BEFORE the intent router below: a short command-like
4284
4367
  // "止めて"/"再開" toggles THIS project's pause state and returns a chat reply
4285
4368
  // — it never becomes a To-Do. /later and /review skip this like the router.
4286
- if (!pending && !review) {
4369
+ if (!pending && !review && !approval) {
4287
4370
  const pauseIntent = detectPauseIntent(text.trim());
4288
4371
  if (pauseIntent === 'pause') {
4289
4372
  pauseProject(projectId);
@@ -4307,7 +4390,7 @@ const server = createServer(async (req, res) => {
4307
4390
  // words then become a new ticket, which is what happens today anyway — a typo
4308
4391
  // must never make a request disappear.
4309
4392
  const identity = identityFor(req);
4310
- if (!pending && !review) {
4393
+ if (!pending && !review && !approval) {
4311
4394
  const addr = parseGoalAddress(text);
4312
4395
  const target = addr && goals.find((g) => g.id === addr.goalId
4313
4396
  && g.projectId === projectId
@@ -4331,7 +4414,7 @@ const server = createServer(async (req, res) => {
4331
4414
  // anything else gets one Haiku round-trip that both classifies and writes
4332
4415
  // the reply. LLM failure falls back to 'task' — never drop a real
4333
4416
  // implementation ask (the folder guard below still prevents no-ops).
4334
- if (!pending && !review && classifyComposerIntentHeuristic(text.trim()) === 'chat') {
4417
+ if (!pending && !review && !approval && classifyComposerIntentHeuristic(text.trim()) === 'chat') {
4335
4418
  // Only a HIGH-confidence chat/help/settings message reaches the LLM —
4336
4419
  // to write the reply, and to let the model veto back to 'task' if the
4337
4420
  // heuristic over-fired. Everything else (clear tasks AND ambiguous
@@ -4374,7 +4457,7 @@ const server = createServer(async (req, res) => {
4374
4457
  // reusing the needsInput clarifying card; the answer (POST /answer)
4375
4458
  // rebinds this goal to the chosen repo, then planning proceeds. Skipped
4376
4459
  // for pending/review items (they never spawn a worker).
4377
- const runnable = !pending && !review;
4460
+ const runnable = !pending && !review && !approval;
4378
4461
  const needsFolder = runnable && needsProjectFolder({ dir: project.dir, homeDir: HOME, isRepo: isGitRepo(project.dir) });
4379
4462
  const lang = /[぀-ヿ㐀-鿿]/.test(text) ? 'ja' : 'en';
4380
4463
  const folderQuestion = needsFolder ? buildFolderQuestion(detectRepos(HOME), { lang }) : undefined;
@@ -4399,17 +4482,27 @@ const server = createServer(async (req, res) => {
4399
4482
  if (review && existingPr && !importedPr) return json(res, 400, { error: 'existingPr must be a GitHub pull request URL' });
4400
4483
  const goal = {
4401
4484
  id: nextId++, projectId, text: text.trim().slice(0, 8000),
4402
- status: (needsFolder || needsReviewPref) ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'stacked',
4485
+ status: (needsFolder || needsReviewPref) ? 'needsInput' : approval ? 'needsApproval' : review ? 'review' : pending ? 'pending' : 'stacked',
4403
4486
  question: folderQuestion || reviewPrefQuestion,
4487
+ pauseReason: approval ? 'approval_required' : undefined,
4488
+ approvalRequest: approval ? {
4489
+ type: 'approval_required',
4490
+ action: String(action || 'continue').trim().slice(0, 120),
4491
+ question: text.trim().slice(0, 500),
4492
+ scope: Array.isArray(scope) ? scope.map((x) => String(x).trim().slice(0, 300)).filter(Boolean).slice(0, 20) : [],
4493
+ resumeInstruction: String(resumeInstruction || '').trim().slice(0, 1000),
4494
+ requestedAt: new Date().toISOString(), external: true,
4495
+ } : undefined,
4496
+ approvalDecision: null,
4404
4497
  reviewOnly: review ? true : undefined,
4405
4498
  note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
4406
- wantsPR: review ? Boolean(importedPr) : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
4499
+ wantsPR: (review || approval) ? Boolean(importedPr) : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
4407
4500
  model: workerModel(goalAgent, model),
4408
4501
  effort: workerEffort(goalAgent, effort),
4409
4502
  agent: goalAgent,
4410
4503
  // MODE (Auto/Plan): plan = propose a plan first, approve to execute.
4411
4504
  // review-only goals never run a worker, so mode is moot → 'auto'.
4412
- mode: (!review && GOAL_MODES.includes(mode)) ? mode : 'auto',
4505
+ mode: (!review && !approval && GOAL_MODES.includes(mode)) ? mode : 'auto',
4413
4506
  // SKILL: an optional user/project skill (or command) to invoke first.
4414
4507
  skill: (typeof skill === 'string' && skill.trim()) ? skill.trim().slice(0, 80) : null,
4415
4508
  // WORKSPACE MODE: isolated-worktree is the default and uses a clean per-goal
@@ -4855,6 +4948,69 @@ const server = createServer(async (req, res) => {
4855
4948
  }
4856
4949
 
4857
4950
  // Slack-style thread reply on a goal: resumes the goal's worker session
4951
+ // A Needs you decision is not a Review verdict. It either resumes the same
4952
+ // goal/session with an explicit permission boundary, or ends the paused work.
4953
+ const approvalMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/approval$/);
4954
+ if (approvalMatch && req.method === 'POST') {
4955
+ const goal = goals.find((g) => g.id === Number(approvalMatch[1]));
4956
+ if (!goal) return json(res, 404, { error: 'goal not found' });
4957
+ if (!requireGoalOwnership(req, res, goal)) return;
4958
+ if (goal.status !== 'needsApproval' || !goal.approvalRequest) return json(res, 409, { error: 'goal is not awaiting approval' });
4959
+ let body = '';
4960
+ req.on('data', (d) => { body += d; });
4961
+ req.on('end', () => {
4962
+ let decision = '', instruction = '';
4963
+ try {
4964
+ const parsed = JSON.parse(body || '{}');
4965
+ decision = String(parsed.decision ?? '').trim();
4966
+ instruction = String(parsed.instruction ?? '').trim().slice(0, 2000);
4967
+ } catch { return json(res, 400, { error: 'bad json' }); }
4968
+ if (!['proceed', 'edit', 'cancel'].includes(decision)) return json(res, 400, { error: 'decision must be proceed, edit, or cancel' });
4969
+ if (decision === 'edit' && !instruction) return json(res, 400, { error: 'instruction required for edit' });
4970
+ const decidedAt = new Date().toISOString();
4971
+ goal.approvalDecision = { decision, instruction: instruction || undefined, decidedAt };
4972
+ const request = goal.approvalRequest;
4973
+
4974
+ // MCP/external agents cannot be resumed by this process. Persist the
4975
+ // decision for manager_status polling and close this human-only card.
4976
+ if (request.external) {
4977
+ goal.status = decision === 'cancel' ? 'skipped' : 'done';
4978
+ goal.pauseReason = null;
4979
+ saveGoal(goal);
4980
+ startNextStacked(goal.projectId);
4981
+ return json(res, 200, goal);
4982
+ }
4983
+
4984
+ const paused = tasks.find((t) => t.id === request.pausedTaskId && t.goalId === goal.id);
4985
+ if (decision === 'cancel') {
4986
+ const q = queues.get(goal.projectId);
4987
+ if (q) q.waiting = q.waiting.filter((t) => t.goalId !== goal.id);
4988
+ for (const t of tasks.filter((t) => t.goalId === goal.id && ['queued', 'awaitingApproval'].includes(t.status))) {
4989
+ t.status = 'cancelled'; t.finishedAt = decidedAt; saveTask(t);
4990
+ }
4991
+ goal.status = 'skipped'; goal.pauseReason = null; goal.startedAt = null;
4992
+ saveGoal(goal); startNextStacked(goal.projectId);
4993
+ return json(res, 200, goal);
4994
+ }
4995
+
4996
+ if (paused) {
4997
+ paused.status = 'done';
4998
+ paused.finishedAt = decidedAt;
4999
+ paused.result = `Approval received: ${request.question}`;
5000
+ saveTask(paused);
5001
+ }
5002
+ const scopeText = request.scope?.length ? `\n許可された範囲:\n- ${request.scope.join('\n- ')}` : '';
5003
+ const resumeText = decision === 'edit'
5004
+ ? `承認条件が更新されました。次の指示の範囲で同じ作業を再開し、最後まで完了してください。\n${instruction}`
5005
+ : `${request.resumeInstruction || `「${request.action}」の実行が承認されました。同じ作業を再開し、最後まで完了してください。`}${scopeText}`;
5006
+ goal.status = 'running'; goal.pauseReason = null; goal.startedAt = null;
5007
+ saveGoal(goal);
5008
+ const replyTask = queueReplyTask(goal, resumeText, { from: 'you', via: 'approval' });
5009
+ return json(res, 200, { ...goal, replyTaskId: replyTask.id });
5010
+ });
5011
+ return;
5012
+ }
5013
+
4858
5014
  // Smart intake: answer a planner clarifying question → fold it into the goal
4859
5015
  // and re-plan (no new worker; same goal continues to decomposition).
4860
5016
  const answerMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/answer$/);
@@ -5028,6 +5184,7 @@ const server = createServer(async (req, res) => {
5028
5184
  const goal = goals.find((g) => g.id === Number(replyMatch[1]));
5029
5185
  if (!goal) return json(res, 404, { error: 'goal not found' });
5030
5186
  if (!requireGoalOwnership(req, res, goal)) return;
5187
+ if (goal.status === 'needsApproval') return json(res, 409, { error: 'goal is awaiting an approval decision — use /approval' });
5031
5188
  if (['stacked', 'planning'].includes(goal.status)) return json(res, 409, { error: 'goal not started yet — edit it instead' });
5032
5189
  let body = '';
5033
5190
  req.on('data', (d) => { body += d; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.83",
3
+ "version": "0.10.85",
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": {