@galda/cli 0.10.38 → 0.10.40

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
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { createServer } from 'node:http';
15
15
  import { spawn, execFile, spawnSync } from 'node:child_process';
16
- import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, readdirSync, unlinkSync } from 'node:fs';
16
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, readdirSync, unlinkSync, rmSync } from 'node:fs';
17
17
  import { emitEvent } from './analytics-client.mjs';
18
18
  import { randomBytes, createHash, randomUUID } from 'node:crypto';
19
19
  import { resolve, dirname, join, basename } from 'node:path';
@@ -21,7 +21,8 @@ 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, 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, 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';
24
+ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, 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, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, 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, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames } from './lib.mjs';
25
+ import { createSerialQueue } from './lib.mjs';
25
26
  import { openPR } from './pr.mjs';
26
27
  import { runVerification, exerciseUi } from './verify.mjs';
27
28
 
@@ -63,6 +64,12 @@ function maybeEmitSetupCompleted(req) {
63
64
  // timeout→'interrupted' path is testable (a test sets a short value + a slow
64
65
  // fake worker instead of waiting 10 real minutes).
65
66
  const WORKER_TIMEOUT_MS = Number(process.env.MANAGER_WORKER_TIMEOUT_MS) || 15 * 60 * 1000;
67
+ // The worker's own words ARE the deliverable for every request that doesn't
68
+ // produce something runnable (research, summaries, answers to a question). The
69
+ // old 4000-char cap silently ate the middle of exactly those answers, so the
70
+ // review card showed a truncated report and called it evidence. Keep a cap —
71
+ // this lands in persisted state — but one that fits a real answer.
72
+ const RESULT_MAX = 20000;
66
73
  const MODELS = ['sonnet', 'opus', 'haiku'];
67
74
  const CODEX_MODELS = ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex-spark'];
68
75
  const WORKER_AGENTS = ['claude-code', 'codex'];
@@ -729,7 +736,33 @@ for (const fix of orphanFixes) {
729
736
  const g = goals.find((x) => x.id === fix.id);
730
737
  if (g) { console.log(`[manager] goal ${g.id}: reaped as orphaned (no live worker after restart) → interrupted`); saveGoal(g); }
731
738
  }
739
+ // The implementation log (P1.5, Masa 2026-07-22).
740
+ //
741
+ // The tool-call stream — ⏺ Read / Edit / Bash and their results — existed only while the
742
+ // worker ran: sendAct() kept it on the task, streamed it, and saveTask() then dropped it
743
+ // on the floor. So the moment a task finished (or the page reloaded), there was no way to
744
+ // see what the AI had actually done. Claude Code's own app keeps that history and Masa
745
+ // reads it; we threw ours away.
746
+ //
747
+ // Frozen to a file rather than into the state log on purpose: 1000 lines × 500 chars is
748
+ // up to ~500KB per task, and the state log is replayed in full on every boot. The file is
749
+ // read on demand by GET /api/tasks/:id/activity — the same "only when asked" shape as
750
+ // /preview. Nothing is summarised: what the worker did is what gets stored.
751
+ const ACTIVITY_TERMINAL = new Set(['done', 'failed', 'interrupted', 'skipped']);
752
+ const activityLogFile = (taskId) => join(logDir, `activity-${taskId}.log`);
753
+ function freezeActivityLog(t) {
754
+ if (!ACTIVITY_TERMINAL.has(t.status) || !t.activity?.length) return false;
755
+ try { writeFileSync(activityLogFile(t.id), t.activity.join('\n')); return true; }
756
+ catch (e) { console.error(`[manager] activity log write failed for task ${t.id} (${e?.code || e})`); return false; }
757
+ }
758
+ function readActivityLog(taskId) {
759
+ try { return readFileSync(activityLogFile(taskId), 'utf8').split('\n').filter(Boolean); }
760
+ catch { return []; }
761
+ }
732
762
  function saveTask(t) {
763
+ // freeze BEFORE the destructure strips `activity`, and mark the task so the UI knows a
764
+ // log exists without having to ask for one that was never written.
765
+ if (freezeActivityLog(t)) t.activityLog = true;
733
766
  const { activity, todos, ...persist } = t; // activity + todos are ephemeral (live only)
734
767
  logAppend(JSON.stringify({ ...persist, kind: 'task' }));
735
768
  const goal = goals.find((g) => g.id === t.goalId);
@@ -1047,54 +1080,49 @@ function runClaude({ prompt, cwd, tools, onEvent, onTodos, resume, model, effort
1047
1080
  });
1048
1081
  });
1049
1082
  }
1050
- function parseCodexUsage(usage) {
1051
- if (!usage) return null;
1052
- return {
1053
- input_tokens: usage.input_tokens ?? usage.inputTokens ?? 0,
1054
- output_tokens: usage.output_tokens ?? usage.outputTokens ?? 0,
1055
- cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
1056
- cache_read_input_tokens: usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0,
1057
- };
1058
- }
1059
- function runCodex({ prompt, cwd, onEvent, permissionMode = 'acceptEdits', model, effort, onChild }) {
1083
+ // The Codex half of the worker bridge. Same contract as runClaude, because
1084
+ // nothing downstream may care which agent ran: live activity, the agent's own
1085
+ // to-dos (the board), the thread id to talk to it again, usage, final message.
1086
+ function runCodex({ prompt, cwd, onEvent, onTodos, resume, permissionMode = 'acceptEdits', model, effort, onChild }) {
1060
1087
  return new Promise((done) => {
1061
1088
  const sandbox = permissionMode === 'plan' ? 'read-only' : 'workspace-write';
1062
- const args = ['exec', '--json', '-m', workerModel('codex', model), '-c', `model_reasoning_effort="${workerEffort('codex', effort)}"`, '--sandbox', sandbox, '--skip-git-repo-check', '--cd', cwd, '-'];
1089
+ const args = buildCodexArgs({ model: workerModel('codex', model), effort: workerEffort('codex', effort), sandbox, cwd, resume });
1063
1090
  const child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
1064
1091
  onChild?.(child);
1065
1092
  child.stdin.end(prompt);
1066
- let result = '', err = '', buf = '', usage = null, timedOut = false, lastAct = '';
1093
+ let result = '', err = '', buf = '', sessionId = null, usage = null, timedOut = false, lastAct = '';
1067
1094
  const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, WORKER_TIMEOUT_MS);
1068
1095
  child.stdout.on('data', (d) => {
1069
1096
  buf += d;
1070
1097
  const lines = buf.split('\n'); buf = lines.pop();
1071
1098
  for (const line of lines) {
1072
1099
  if (!line.trim()) continue;
1073
- try {
1074
- const ev = JSON.parse(line);
1075
- if (ev.type === 'item.completed') {
1076
- const item = ev.item ?? {};
1077
- if (item.type === 'agent_message' && item.text) {
1078
- result = item.text;
1079
- onEvent?.(`✎ ${item.text.trim().replace(/\s+/g, ' ').slice(0, 500)}`);
1080
- } else if (item.type === 'error' && item.message) onEvent?.(`Error ${item.message}`);
1081
- else if (item.type === 'command_execution' && item.command) { lastAct = `Run ${item.command}`; onEvent?.(lastAct); }
1082
- } else if (ev.type === 'turn.completed') {
1083
- usage = parseCodexUsage(ev.usage);
1084
- } else if (ev.type === 'error' && ev.message) {
1085
- err += `${ev.message}\n`;
1086
- onEvent?.(`Error ${ev.message}`);
1087
- }
1088
- } catch {
1089
- if (line.trim()) lastAct = line.trim().slice(0, 240);
1090
- onEvent?.(line.trim().slice(0, 240));
1100
+ const evs = parseCodexEvents(line);
1101
+ // A line we cannot parse is still the worker talking — show it rather
1102
+ // than swallow it (how this ran before the bridge existed).
1103
+ if (!evs.length && !line.trim().startsWith('{')) {
1104
+ lastAct = line.trim().slice(0, 240);
1105
+ onEvent?.(lastAct);
1106
+ continue;
1107
+ }
1108
+ for (const ev of evs) {
1109
+ if (ev.kind === 'session') sessionId = ev.id;
1110
+ else if (ev.kind === 'usage') usage = ev.usage;
1111
+ else if (ev.kind === 'todos') onTodos?.(ev.todos);
1112
+ else if (ev.kind === 'message') {
1113
+ result = ev.text;
1114
+ onEvent?.(`✎ ${ev.text.trim().replace(/\s+/g, ' ').slice(0, 500)}`);
1115
+ } else if (ev.kind === 'error') {
1116
+ err += `${ev.text}\n`;
1117
+ onEvent?.(`Error ${ev.text}`);
1118
+ } else { lastAct = ev.text; onEvent?.(ev.text); }
1091
1119
  }
1092
1120
  }
1093
1121
  });
1094
1122
  child.stderr.on('data', (d) => { err += d; });
1095
1123
  child.on('close', (code) => {
1096
1124
  clearTimeout(timer);
1097
- done({ code, timedOut, result: workerResultText({ result, err, code, timedOut, lastAct, timeoutMs: WORKER_TIMEOUT_MS }), sessionId: null, usage });
1125
+ done({ code, timedOut, result: workerResultText({ result, err, code, timedOut, lastAct, timeoutMs: WORKER_TIMEOUT_MS }), sessionId, usage });
1098
1126
  });
1099
1127
  });
1100
1128
  }
@@ -1401,7 +1429,9 @@ async function planGoal(goal) {
1401
1429
  '- 複数タスクに分けるのは、依頼に「明確に別々の成果物・トピック」が含まれる時「だけ」(例:「音声入力を追加して、あとビジュアル作成も」→ 音声入力 / ビジュアル作成 の2つ)。工程での分割は絶対にしない。',
1402
1430
  '- 被っている・言い換えているだけの依頼は、自分で理解して1つにまとめる。',
1403
1431
  '- タスク数は最小に。既定1個、多くても distinct な依頼の数だけ(現実的に1〜3、最大4)。依存順に並べる。',
1404
- '- 各タスクの title は「ユーザーから見た成果(ユーザーの言葉のまま・短く)」にする。エンジニアリングの手順名(設計/実装/テスト等)にしない。detail には worker への完全な指示を書く:そのworkerが1人で実装・テスト・proofまで完結させる。工程ごとの兄弟タスクは来ない前提で書くこと。',
1432
+ '- 各タスクの title は「ユーザーが頼んだこと」を、ユーザーの言葉・語彙をそのまま活かして短く書く。実装のやり方(「〜するように直した」「〜を計算する」等)や、完了報告の文(「〜した」「〜しました」)を title にしない。エンジニアリングの手順名(設計/実装/テスト等)にもしない。例: 依頼「一回たたむとsettingボタンがズレるバグ修正して」→ title「settingボタンがズレるバグの修正」(✕「アニメーションが終わってから歯車の位置を計算するように直した」)。',
1433
+ '- バグ修正の依頼では、title に「どの問題を直すか」がわかるように書く(解決する問題=ユーザーの言葉)。依頼に直し方の方針が書かれていればそれも短く添えてよいが、無ければ問題だけでよい。工程・実装詳細は detail に書く。',
1434
+ '- detail には worker への完全な指示を書く:そのworkerが1人で実装・テスト・proofまで完結させる。工程ごとの兄弟タスクは来ない前提で書くこと。',
1405
1435
  '- passCondition はユーザーが依頼文で明示的に検証条件(「成功条件:」「〜したら〜になる」等)を書いた場合のみ設定する。見た目の調整・文言変更・スタイル変更のタスクには付けない(null)。検証は高価なので、明示された時だけ。',
1406
1436
  '- 検証対象のページがあれば entry に書く(プロジェクト相対のhtmlパス、または http URL。無ければ null)。依頼中に書かれていればそれを使い、無ければリポジトリを見て推測してよい。',
1407
1437
  '- 依頼が曖昧で重要な前提が1つ欠けている(例: 対象・範囲・形式が不明)か、既存の作業と矛盾する場合「のみ」、タスクではなく確認質問を返す: {"question":"確認したい1点(短く)","options":["選択肢A","選択肢B"]}。ただし基本は合理的な仮定で進めること。質問は本当に必要な時だけ、1問に絞る。',
@@ -1457,22 +1487,25 @@ async function planGoal(goal) {
1457
1487
  plan.tasks = refinePlanTasks(plan.tasks, { goalText: goal.text });
1458
1488
  goal.status = 'running';
1459
1489
  goal.entry = plan.entry;
1460
- goal.plan = plan.tasks.map((t) => t.title);
1490
+ // Demoted planner (Masa 2026-07-21, P0b): the planner LLM still resolves the
1491
+ // entry URL, asks for clarification, and warms the session — but it no longer
1492
+ // DECOMPOSES the request into tasks the worker must follow. The request goes
1493
+ // to the worker verbatim as ONE task; the worker (Claude Code / Codex) decides
1494
+ // its own breakdown and emits it as its own TodoWrite, which the board shows.
1495
+ // One card = one request. A user-written pass condition still rides along.
1496
+ const title = goalTaskTitle(goal.text);
1497
+ goal.plan = [title];
1461
1498
  saveGoal(goal);
1462
1499
  const base = tasks.filter((t) => t.projectId === goal.projectId).map((t) => t.num);
1463
- let num = (base.length ? Math.max(...base) : 0);
1464
- for (const p of plan.tasks) {
1465
- const task = {
1466
- id: nextId++, num: ++num, goalId: goal.id, projectId: goal.projectId,
1467
- title: p.title, detail: p.detail, passCondition: p.passCondition, model: goal.model, effort: goal.effort, agent: goal.agent,
1468
- mode: goal.mode ?? 'auto', skill: goal.skill ?? null,
1469
- status: 'queued', createdAt: new Date().toISOString(), priority: '中',
1470
- result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
1471
- };
1472
- tasks.push(task);
1473
- saveTask(task);
1474
- queues.get(goal.projectId).waiting.push(task);
1475
- }
1500
+ const num = (base.length ? Math.max(...base) : 0) + 1;
1501
+ const task = buildGoalTask(goal, {
1502
+ id: nextId++, num,
1503
+ passCondition: plan.tasks[0]?.passCondition ?? null,
1504
+ createdAt: new Date().toISOString(),
1505
+ });
1506
+ tasks.push(task);
1507
+ saveTask(task);
1508
+ queues.get(goal.projectId).waiting.push(task);
1476
1509
  pump(goal.projectId);
1477
1510
  }
1478
1511
 
@@ -1838,44 +1871,8 @@ function testOutputExcerpt(text) {
1838
1871
  return chosen.join('\n').slice(-3000);
1839
1872
  }
1840
1873
 
1841
- function workerPrompt(task, goal, lastFailure) {
1842
- // Optional skill the user attached to this goal. Empirically (scratch worker,
1843
- // haiku, custom fixture skill) the reliably-firing form is an explicit Skill-tool
1844
- // instruction as the FIRST line; a bare "/name" first line did not consistently
1845
- // trigger the skill in `claude -p`. See commit body for the run evidence.
1846
- const skill = task?.skill ?? goal?.skill;
1847
- const agentName = workerAgent(task?.agent ?? goal?.agent) === 'codex' ? 'Codex' : 'Claude Code';
1848
- // Report back to the human in the SAME language they wrote the goal in (Masa
1849
- // 2026-07-15: "撃った言語で返す"). JP characters → Japanese, otherwise English.
1850
- const replyLang = detectRequestLanguage(goal?.text || task?.title || '') === 'ja' ? '日本語' : 'English';
1851
- return [
1852
- skill ? `FIRST, use the /${skill} skill via the Skill tool (invoke Skill with the "${skill}" skill) before doing anything else. Then carry out the task below.` : '',
1853
- `You are a ${agentName} worker managed by "Manager for AI". Do ONLY the task below inside this project directory.`,
1854
- goal?.priorFailureMemory?.length ? `\nMANAGER FAILURE MEMORY:\n${latestFailurePolicy(goal.priorFailureMemory)?.text}\nApply this prevention before starting.` : '',
1855
- '',
1856
- `TASK ${task.num}: ${task.title}`,
1857
- task.detail ? `DETAIL: ${task.detail}` : '',
1858
- goal?.images?.length ? `ATTACHED IMAGES (absolute paths, use the Read tool to view them): ${goal.images.join(' ')}` : '',
1859
- task.passCondition ? `\nEXPLICIT PASS CONDITION (verified externally by a headless browser after you finish — NOT by you): ${task.passCondition}` : '',
1860
- lastFailure ? `\nPREVIOUS ATTEMPT FAILED EXTERNAL VERIFICATION: ${lastFailure}\nYour previous changes are on disk. Diagnose and fix.` : '',
1861
- goal ? `\n(全体ゴールの一部です: ${goal.text.slice(0, 500)})` : '',
1862
- '',
1863
- 'RULES:',
1864
- '- Edit only files inside this directory. Keep changes minimal.',
1865
- '- Keep the work bounded: inspect only files needed for this task; avoid broad repository scans unless they are necessary.',
1866
- '- If the task is larger than one focused patch, make the smallest safe change and report the next step instead of expanding scope.',
1867
- "- You may use the user's installed skills (Skill tool, e.g. /galda-doc) when one clearly fits the task.",
1868
- // Token-frugality + the 10-min run cap: the worker used to run the WHOLE suite
1869
- // (npm test) repeatedly, which the Manager ALSO runs at close-out — double work
1870
- // that regularly overran the cap (→ a SIGTERM 'interrupted'). Write the test,
1871
- // run only the RELEVANT file once, and leave the full-suite + verification to
1872
- // the Manager. (Masa: worker が自前でテスト全回し→止まる、が最大の無駄。)',
1873
- '- 変更に対応するテストを書く。確認は該当テストファイル1つだけを1回実行する(例: node --test path/to/only-this.test.mjs)。プロジェクト全体の npm test を繰り返し回さない — 全体テストと最終検証は Manager 側が実行する。テストが適用できない変更は理由と手動確認手順を書く。',
1874
- '- 検証しすぎない: 自分の変更が動くと確認できたら、それ以上ログを漁ったり再検証を重ねたりせず、すぐに報告して終える(時間と token の無駄を避ける)。',
1875
- '- Do not commit (git is handled by the Manager).',
1876
- `- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
1877
- ].filter(Boolean).join('\n');
1878
- }
1874
+ // workerPrompt moved to lib.mjs (pure + unit-tested). Caller resolves the
1875
+ // agent id via workerAgent(); lib keeps the presentation.
1879
1876
 
1880
1877
  // A fresh ephemeral instance starts with zero history, but some pass
1881
1878
  // conditions are about completion-time behavior (e.g. task 46: a goal
@@ -1963,148 +1960,121 @@ function entryUrlFor(goal, project) {
1963
1960
  return resolveEntryUrl({ entry: goal?.entry, projectDir: project.dir, managerRoot: ROOT, port: PORT, accessKey: ACCESS_KEY });
1964
1961
  }
1965
1962
 
1966
- function canSnapshot(project) {
1967
- return existsSync(join(project.dir, 'engine', 'server.mjs')) && existsSync(join(project.dir, 'app', 'index.html'));
1968
- }
1963
+ // ---- EVIDENCE: what the worker made, actually reachable ---------------------
1964
+ // Everything that used to live here (proofIntentFor / verifyManagerProofTarget /
1965
+ // captureSnapshotProof) staged a photograph: it booted an ephemeral copy of the
1966
+ // project and screenshotted the MANAGER'S OWN flagship board, picking WHICH
1967
+ // region to shoot by keyword-matching the request text. That produced pictures
1968
+ // unrelated to the change (#496) and, because canSnapshot() demanded
1969
+ // engine/server.mjs + app/index.html, it never ran at all outside this repo.
1970
+ //
1971
+ // The replacement asks the only AI in the pipeline — the worker that just did
1972
+ // the work — how a human can run and see the result, and then does exactly
1973
+ // that. This layer never guesses the command and never classifies the request.
1969
1974
 
1970
- function proofIntentFor(goal, task) {
1971
- const text = `${goal?.text ?? ''}\n${goal?.plan?.join?.('\n') ?? ''}\n${task?.title ?? ''}\n${task?.detail ?? ''}`.toLowerCase();
1972
- if (/retry|resume|view log|archive|失敗|エラー|blocked|interrupted|attention|to do|todo/.test(text)) {
1973
- return 'attention-actions';
1975
+ // Consume the worker's run declaration from the worktree root. It is a message
1976
+ // to the Manager, not part of the deliverable, so it is deleted after reading:
1977
+ // it must not reach the diff or the PR.
1978
+ //
1979
+ // Silence is a valid answer. A request with nothing to run (research, a
1980
+ // summary, an answer to a question) leaves no declaration and gets no "run it"
1981
+ // button — there, the worker's own words are the whole deliverable.
1982
+ function readRunDeclaration(task, workDir) {
1983
+ const file = join(workDir, RUN_DECL_FILE);
1984
+ let raw = null;
1985
+ try { raw = readFileSync(file, 'utf8'); } catch { return; }
1986
+ try { unlinkSync(file); } catch { /* best effort — never fail a task over cleanup */ }
1987
+ const run = parseRunDeclaration(raw);
1988
+ if (!run) {
1989
+ sendAct(task, `run declaration ignored — ${RUN_DECL_FILE} was not usable JSON`);
1990
+ return;
1974
1991
  }
1975
- if (/review|approve|dismiss|proof|スクショ|画像|動画/.test(text)) return 'review';
1976
- return 'general';
1992
+ task.run = { ...run, dir: workDir };
1993
+ sendProcessAct(task, `Worker declared how to see the result: ${run.cmd ?? run.url}`);
1977
1994
  }
1978
1995
 
1979
- async function verifyManagerProofTarget(page, ui, { goal = null, task = null } = {}) {
1980
- const intent = proofIntentFor(goal, task);
1981
- const root = await page.waitForSelector('#fsRoot', { timeout: 1500 }).catch(() => null);
1982
- if (!root) {
1983
- await exerciseUi(page, ui);
1984
- return { pass: true, detail: 'UI change capture (auto, unverified)' };
1985
- }
1986
- await page.evaluate(() => {
1987
- localStorage.setItem('layout', 'flagship');
1988
- if (window.state?.projects?.some((p) => p.id === 'default')) window.state.active = 'default';
1989
- window.render?.();
1990
- });
1991
- await ui.sleep(300);
1992
-
1993
- if (intent === 'attention-actions') {
1994
- await page.waitForSelector('#fsTodoSec .drow.err .acts2', { timeout: 5000 });
1995
- await page.evaluate(() => document.querySelector('#fsTodoSec .drow.err')?.scrollIntoView({ block: 'center', inline: 'nearest' }));
1996
- await ui.sleep(250);
1997
- const metrics = await page.evaluate(() => {
1998
- const rect = (el) => {
1999
- const r = el?.getBoundingClientRect?.();
2000
- return r ? { x: r.x, y: r.y, width: r.width, height: r.height, top: r.top, bottom: r.bottom, right: r.right, left: r.left } : null;
2001
- };
2002
- const root = document.querySelector('#fsRoot');
2003
- const feed = document.querySelector('#fsFeedWrap') || document.querySelector('#fsFeed')?.parentElement;
2004
- const lane = document.querySelector('#fsLane');
2005
- const row = document.querySelector('#fsTodoSec .drow.err');
2006
- const msg = row?.querySelector('.errline .msg');
2007
- const acts = row?.querySelector('.acts2');
2008
- const buttonText = [...(acts?.querySelectorAll('button') ?? [])].map((b) => b.textContent.trim()).join(' ');
2009
- const rootR = rect(root), feedR = rect(feed), laneR = rect(lane), msgR = rect(msg), actsR = rect(acts);
2010
- return {
2011
- buttonText,
2012
- hasRetry: /Retry|Resume/.test(buttonText),
2013
- hasLog: /View log/.test(buttonText),
2014
- hasArchive: /Archive/.test(buttonText),
2015
- actionGap: msgR && actsR ? Math.round(actsR.top - msgR.bottom) : null,
2016
- laneWidth: Math.round(laneR?.width ?? 0),
2017
- feedWidth: Math.round(feedR?.width ?? 0),
2018
- rootWidth: Math.round(rootR?.width ?? 0),
2019
- laneVisible: Boolean(laneR && laneR.width >= 260 && laneR.right <= window.innerWidth + 2),
2020
- };
2021
- });
2022
- const actionClose = metrics.actionGap == null || metrics.actionGap <= 18;
2023
- const laneSane = metrics.laneVisible && metrics.feedWidth > 420 && metrics.laneWidth > 260;
2024
- return {
2025
- pass: Boolean(metrics.hasRetry && metrics.hasLog && actionClose && laneSane),
2026
- detail: `Manager UI proof target: failed-row actions visible (${metrics.buttonText || 'none'}), action gap ${metrics.actionGap ?? 'n/a'}px, lane ${metrics.laneWidth}px/feed ${metrics.feedWidth}px`,
2027
- };
1996
+ // Take whatever the worker photographed out of the worktree (SHOT_DIR) and put
1997
+ // it where the Manager already serves images from, so the pictures are reachable
1998
+ // at a URL instead of sitting in a temp checkout that gets deleted.
1999
+ //
2000
+ // Moved, not copied, and moved BEFORE the diff is computed: a screenshot is the
2001
+ // worker talking to us about the deliverable, not the deliverable, and it must
2002
+ // not turn up as a binary file in the pull request.
2003
+ //
2004
+ // This function does not know what a good picture is. It never shoots anything
2005
+ // itself, and an empty SHOT_DIR is a normal outcome — plenty of requests have
2006
+ // nothing to look at.
2007
+ function collectWorkerShots(task, workDir) {
2008
+ const dir = join(workDir, SHOT_DIR);
2009
+ let names = [];
2010
+ try { names = collectShotNames(readdirSync(dir)); } catch { return; }
2011
+ const outDir = join(logDir, 'uploads');
2012
+ mkdirSync(outDir, { recursive: true });
2013
+ const shots = [];
2014
+ for (const name of names) {
2015
+ const safe = name.replace(/[^\w.-]/g, '_').slice(-80);
2016
+ const file = `${Date.now()}-shot-${task.id}-${safe}`;
2017
+ try {
2018
+ writeFileSync(join(outDir, file), readFileSync(join(dir, name)));
2019
+ shots.push({ name, url: `/upload/${file}`, path: join(outDir, file) });
2020
+ } catch { /* one unreadable shot must not cost the task its other shots */ }
2028
2021
  }
2029
-
2030
- await exerciseUi(page, ui);
2031
- const metrics = await page.evaluate(() => {
2032
- const lane = document.querySelector('#fsLane')?.getBoundingClientRect();
2033
- const feed = (document.querySelector('#fsFeedWrap') || document.querySelector('#fsFeed')?.parentElement)?.getBoundingClientRect();
2034
- return { laneWidth: Math.round(lane?.width ?? 0), feedWidth: Math.round(feed?.width ?? 0) };
2035
- });
2036
- return { pass: true, detail: `Manager UI capture (${intent}); lane ${metrics.laneWidth}px/feed ${metrics.feedWidth}px` };
2022
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
2023
+ if (!shots.length) return;
2024
+ task.shots = [...(task.shots ?? []), ...shots];
2025
+ sendProcessAct(task, `Worker attached ${shots.length} screenshot${shots.length === 1 ? '' : 's'} of the result.`);
2037
2026
  }
2038
2027
 
2039
- // Zero-LLM screenshot + short clip of an ephemeral instance of the project's
2040
- // current code, stored as the task's proof. Used automatically for UI tasks
2041
- // without an explicit pass condition, and on demand via
2042
- // POST /api/tasks/:id/snapshot to backfill old "No capture yet" reviews.
2028
+ // Live previews, started on demand by POST /api/tasks/:id/preview.
2043
2029
  //
2044
- // The clip is no longer an idle page (a 0-second clip of nothing moving said
2045
- // nothing a screenshot didn't) exerciseUi() moves the cursor and scrolls
2046
- // the live app during the capture, so the GIF/mp4 actually SHOW the change
2047
- // instead of a static landing shot. The `detail` text is deliberately NOT
2048
- // "snapshot"-worded: app/index.html's proofMediaHtml() keys its PNG-only /
2049
- // no-video display off /snapshot|スナップショット/i in proof.detail (that
2050
- // heuristic exists for the old idle capture) — this capture has real motion
2051
- // worth showing, so it earns the same GIF+tap-to-play-mp4 treatment as a
2052
- // verified run.
2053
- //
2054
- // opts.baselineDir: when given and it's a genuinely different checkout than
2055
- // `project.dir` (see shouldCaptureBaseline never fabricate a before/after
2056
- // from the same directory), best-effort capture a BASELINE screenshot too
2057
- // (single attempt, screenshot-only, never throws) so the review card can
2058
- // show before/after. Reviewer UI already supports this via
2059
- // proof.beforePng/beforeGif (beforeAfterMediaHtml in app/index.html).
2060
- async function captureSnapshotProof(task, project, opts = {}) {
2061
- const eph = await startEphemeralApp(project.dir);
2062
- if (!eph) throw new Error('ephemeral instance failed to boot');
2063
- try {
2064
- const proofDir = join(logDir, `proof-${task.id}`);
2065
- mkdirSync(proofDir, { recursive: true });
2066
- const managerApp = canSnapshot(project);
2067
- const snap = await runVerification({
2068
- entryUrl: eph.url,
2069
- verify: async (page, ui) => managerApp
2070
- ? verifyManagerProofTarget(page, ui, { goal: opts.goal, task })
2071
- : (await exerciseUi(page, ui), { pass: true, detail: 'UI change capture (auto, unverified)' }),
2072
- outBase: join(proofDir, 'snapshot'),
2073
- viewport: managerApp ? { width: 1440, height: 900, deviceScaleFactor: 2 } : undefined,
2074
- });
2075
- task.proof = {
2076
- pass: snap.pass, detail: snap.detail || 'UI change capture (auto, unverified)',
2077
- gif: snap.gifPath?.split('/').pop() ?? null,
2078
- png: snap.shotPath?.split('/').pop() ?? null,
2079
- mp4: snap.videoPath?.split('/').pop() ?? null,
2080
- };
2081
- const baselineDir = opts.baselineDir;
2082
- if (shouldCaptureBaseline({ baselineDir, afterDir: project.dir, canCaptureBaseline: baselineDir ? canSnapshot({ dir: baselineDir }) : false })) {
2083
- let beph = null;
2084
- try {
2085
- beph = await startEphemeralApp(baselineDir);
2086
- if (beph) {
2087
- const bsnap = await runVerification({
2088
- entryUrl: beph.url,
2089
- verify: async (page, ui) => managerApp
2090
- ? verifyManagerProofTarget(page, ui, { goal: opts.goal, task })
2091
- : (await exerciseUi(page, ui), { pass: true, detail: 'baseline (before)' }),
2092
- outBase: join(proofDir, 'before'),
2093
- viewport: managerApp ? { width: 1440, height: 900, deviceScaleFactor: 2 } : undefined,
2094
- record: false, // comparison shot only — not worth a second video encode
2095
- });
2096
- task.proof.beforePng = bsnap.shotPath?.split('/').pop() ?? null;
2097
- }
2098
- } catch (e) {
2099
- // Before/after is a nice-to-have on top of the mandatory "after"
2100
- // capture above (already saved) — never let a flaky baseline boot
2101
- // fail the whole capture. Report it and move on.
2102
- sendAct(task, `proof: baseline (before) capture skipped — ${String(e.message ?? e).slice(0, 100)}`);
2103
- } finally { beph?.kill(); }
2030
+ // On demand rather than always-on: a preview costs a real process, and most
2031
+ // finished goals are never opened. Idle ones are reaped, so a reviewer who
2032
+ // opens twenty cards doesn't leave twenty servers behind.
2033
+ const previews = new Map(); // taskId -> { proc, url, expires }
2034
+ const PREVIEW_IDLE_MS = 10 * 60 * 1000;
2035
+ const PREVIEW_BOOT_TRIES = 60; // × 250ms = 15s
2036
+
2037
+ function installPreviewReaper() {
2038
+ if (installPreviewReaper.done) return;
2039
+ installPreviewReaper.done = true;
2040
+ setInterval(() => {
2041
+ for (const [id, p] of previews) {
2042
+ if (p.expires > Date.now()) continue;
2043
+ try { p.proc.kill(); } catch { /* already gone */ }
2044
+ previews.delete(id);
2104
2045
  }
2105
- saveTask(task);
2106
- return task;
2107
- } finally { eph.kill(); }
2046
+ }, 60 * 1000).unref?.();
2047
+ const killAll = () => { for (const p of previews.values()) { try { p.proc.kill(); } catch { /* already gone */ } } };
2048
+ process.on('exit', killAll);
2049
+ }
2050
+
2051
+ async function startPreview(task) {
2052
+ installPreviewReaper();
2053
+ const { cmd, url, dir } = task.run;
2054
+ const live = previews.get(task.id);
2055
+ if (live && live.proc.exitCode === null) {
2056
+ live.expires = Date.now() + PREVIEW_IDLE_MS;
2057
+ return { url: live.url, reused: true };
2058
+ }
2059
+ // Declared a URL but no command: the thing is already reachable (an app the
2060
+ // worker left running, a hosted page). Nothing to start.
2061
+ if (!cmd) return { url, reused: false, started: false };
2062
+ const proc = spawn(cmd, { cwd: dir, shell: true, stdio: 'ignore' });
2063
+ const entry = { proc, url: url ?? null, expires: Date.now() + PREVIEW_IDLE_MS };
2064
+ previews.set(task.id, entry);
2065
+ proc.on('close', () => { if (previews.get(task.id) === entry) previews.delete(task.id); });
2066
+ // No URL to poll — we started it and say so; the note tells the human where
2067
+ // to look.
2068
+ if (!url) return { url: null, reused: false, started: true };
2069
+ for (let i = 0; i < PREVIEW_BOOT_TRIES; i++) {
2070
+ await new Promise((r) => setTimeout(r, 250));
2071
+ if (proc.exitCode !== null) throw new Error(`run command exited with code ${proc.exitCode} before ${url} answered`);
2072
+ // Any HTTP answer means it's up; a 404 on the root is still a live server.
2073
+ try { await fetch(url); return { url, reused: false, started: true }; } catch { /* not up yet */ }
2074
+ }
2075
+ try { proc.kill(); } catch { /* already gone */ }
2076
+ previews.delete(task.id);
2077
+ throw new Error(`${url} did not answer within 15s of running: ${cmd}`);
2108
2078
  }
2109
2079
 
2110
2080
  async function runTask(task) {
@@ -2163,7 +2133,7 @@ async function runTask(task) {
2163
2133
  sendProcessAct(task, `Worker finished with exit code ${r.code}.`);
2164
2134
  task.secs = Math.round((Date.now() - t0) / 1000);
2165
2135
  task.status = r.code === 0 ? 'done' : 'failed';
2166
- task.result = r.result.slice(0, 4000);
2136
+ task.result = r.result.slice(0, RESULT_MAX);
2167
2137
  task.usage = sumUsage([r.usage]);
2168
2138
  if (r.sessionId && goal) { goal.sessionId = r.sessionId; saveGoal(goal); }
2169
2139
  const afterR = await gitChanges(workDir);
@@ -2196,7 +2166,7 @@ async function runTask(task) {
2196
2166
  let callT0 = Date.now();
2197
2167
  const plan = goal?.executionPlan;
2198
2168
  const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goal?.sessionId ?? undefined);
2199
- const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, lastFailure)].filter(Boolean).join('\n\n');
2169
+ const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, lastFailure, workerAgent(task.agent ?? goal?.agent))].filter(Boolean).join('\n\n');
2200
2170
  sendProcessAct(task, `Starting ${workerAgent(task.agent ?? goal?.agent) === 'codex' ? 'Codex' : 'Claude Code'} worker (attempt ${attempt}/${maxAttempts}).`);
2201
2171
  let r = await runWorkerAgent({
2202
2172
  agent: task.agent ?? goal?.agent,
@@ -2225,7 +2195,7 @@ async function runTask(task) {
2225
2195
  sendProcessAct(task, 'Starting a fresh worker session because the warm session could not resume.');
2226
2196
  r = await runWorkerAgent({
2227
2197
  agent: task.agent ?? goal?.agent,
2228
- prompt: workerPrompt(task, goal, lastFailure), cwd: workDir, tools: WORKER_TOOLS,
2198
+ prompt: workerPrompt(task, goal, lastFailure, workerAgent(task.agent ?? goal?.agent)), cwd: workDir, tools: WORKER_TOOLS,
2229
2199
  permissionMode,
2230
2200
  model: task.model ?? goal?.model, effort: task.effort ?? goal?.effort, onEvent: (line) => sendAct(task, line),
2231
2201
  onTodos: (todos) => sendTodos(task, todos),
@@ -2301,15 +2271,20 @@ async function runTask(task) {
2301
2271
  : forcedStop ? 'interrupted'
2302
2272
  : conclusive ? 'failed'
2303
2273
  : (code === 0 ? 'done' : 'failed');
2274
+ // Freeze the worker's own final TodoWrite list (P0b): live `todos` are stripped
2275
+ // on save, so snapshot them into `todosFinal` — persisted (blacklist saveTask /
2276
+ // trimTaskActivityForState keep it) so the board/review can show what the AI
2277
+ // actually did after it finishes, not just while it runs.
2278
+ task.todosFinal = Array.isArray(task.todos) ? task.todos : [];
2304
2279
  // Never leave the reason as a bare "(no output)": if the worker produced no
2305
2280
  // report text, result already carries workerExitReason(); make doubly sure a
2306
2281
  // force-stopped task states WHY so the Attention card explains itself.
2307
2282
  if (forcedStop && (!result || result === '(no output)')) result = workerExitReason(code, timedOut, WORKER_TIMEOUT_MS);
2308
- task.result = result.slice(0, 4000);
2283
+ task.result = result.slice(0, RESULT_MAX);
2309
2284
  task.usage = sumUsage(usages);
2310
2285
  if (verdict) {
2311
2286
  const label = verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (自動確認できず・要確認)' : 'FAIL';
2312
- task.result = `検証${label}: ${verdict.detail}\n\n${task.result}`.slice(0, 4000);
2287
+ task.result = `検証${label}: ${verdict.detail}\n\n${task.result}`.slice(0, RESULT_MAX);
2313
2288
  // Only attach an authoritative proof for a conclusive verdict. An
2314
2289
  // inconclusive one must NOT leave a pass:false proof — that would trip
2315
2290
  // verifyGate's proofFailing and re-block the goal through the back door
@@ -2329,28 +2304,28 @@ async function runTask(task) {
2329
2304
  };
2330
2305
  }
2331
2306
  }
2307
+ // Evidence = the thing the worker actually made, reachable by the human.
2308
+ //
2309
+ // What used to be here (after changedFiles): an auto "proof" snapshot. It
2310
+ // booted an ephemeral app and screenshotted the MANAGER'S OWN flagship board
2311
+ // — never the deliverable — and canSnapshot() required engine/server.mjs +
2312
+ // app/index.html, so it only ever ran when the project was this repo. In
2313
+ // every customer project it was dead code, and in ours it produced a picture
2314
+ // unrelated to the change (#496: "3案見せて" → a screenshot of the board).
2315
+ // Staging a photo ourselves is the one thing this layer must not do, so it is
2316
+ // gone rather than fixed.
2317
+ //
2318
+ // What replaces it: whatever the worker itself declared as the way to see the
2319
+ // result. No classification of the request, no per-project knowledge here.
2320
+ //
2321
+ // Runs BEFORE changedFiles is computed because it consumes (reads + removes)
2322
+ // the declaration file — it's a message to the Manager, not part of the
2323
+ // deliverable, and it must not show up in the diff or the PR.
2324
+ readRunDeclaration(task, workDir);
2325
+ collectWorkerShots(task, workDir);
2332
2326
  const after = await gitChanges(workDir);
2333
2327
  task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
2334
2328
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
2335
-
2336
- // A UI change with no explicit pass condition used to finish with no
2337
- // evidence at all ("UI変更のはずなのに写真が撮られてない"). Capture a cheap
2338
- // zero-LLM snapshot (screenshot + short clip) from an ephemeral instance
2339
- // of the edited app so the review card always has something to look at.
2340
- // baselineDir = the user's real project dir (unmodified, pre-goal code) —
2341
- // when it's genuinely different from wproject.dir (a real worktree, not
2342
- // the shared-dir fallback), also attempt a before/after pair.
2343
- if (!conclusive && task.status === 'done' && shouldCaptureProof({ changedFiles: task.changedFiles, hasProof: Boolean(task.proof), canCapture: canSnapshot(wproject) })) {
2344
- try {
2345
- sendAct(task, 'proof: capturing UI change (screenshot + clip) of the edited app…');
2346
- await captureSnapshotProof(task, wproject, { baselineDir: project.dir, goal });
2347
- sendProcessAct(task, `Proof capture finished: ${task.proof?.pass === false ? 'target check failed' : 'screenshot/clip attached'}.`);
2348
- if (task.proof && task.proof.pass === false) {
2349
- task.status = 'failed';
2350
- task.result = `proof FAIL: ${task.proof.detail}\n\n${task.result ?? ''}`.slice(0, 4000);
2351
- }
2352
- } catch (e) { sendAct(task, `proof: capture failed — ${String(e.message ?? e).slice(0, 100)}`); }
2353
- }
2354
2329
  task.finishedAt = new Date().toISOString();
2355
2330
  saveTask(task);
2356
2331
  sendProcessAct(task, `Task saved as ${task.status}.`);
@@ -2376,11 +2351,20 @@ async function runTask(task) {
2376
2351
  }
2377
2352
  }
2378
2353
 
2354
+ // Machine-wide lock around actually running `npm test` (see createSerialQueue
2355
+ // in lib.mjs): the suite spawns real headless Chrome + real servers, so two
2356
+ // goals verifying at once compete for the same finite CPU/ports/tmp-dirs and
2357
+ // throw a DIFFERENT flaky failure each time — which then never converges
2358
+ // under the baseline-diff gate (postmortem: goal #552/#487, 2026-07-21; both
2359
+ // goals' actual requested feature was already correct). Only the expensive
2360
+ // test-run itself is serialized — planning/working on other goals is
2361
+ // unaffected, they just queue for their verification turn.
2362
+ const testRunQueue = createSerialQueue();
2379
2363
  // Manager-run tests (never trust the worker's "all green" claim): run the
2380
2364
  // project's own `npm test` in the goal's worktree and parse node --test's
2381
2365
  // "ℹ pass N / ℹ fail N" (or TAP "# pass/# fail"). Returns {ran,passed,failed,ok}.
2382
2366
  function runTestsOnce(dir) {
2383
- return new Promise((res) => {
2367
+ return testRunQueue.run(() => new Promise((res) => {
2384
2368
  execFile('npm', ['test'], { cwd: dir, timeout: 240000, env: { ...process.env }, maxBuffer: 8 * 1024 * 1024 }, (e, out = '', err = '') => {
2385
2369
  const text = `${out}\n${err}`;
2386
2370
  const n = (re) => { const m = text.match(re); return m ? Number(m[1]) : null; };
@@ -2399,7 +2383,7 @@ function runTestsOnce(dir) {
2399
2383
  // diff them against the base's failures and block only on NEW ones.
2400
2384
  res({ passed, failed, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
2401
2385
  });
2402
- });
2386
+ }));
2403
2387
  }
2404
2388
  async function runProjectTests(dir) {
2405
2389
  let hasTest = false;
@@ -2839,28 +2823,22 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2839
2823
  goalAct('Writing the review summary from the worker report and diff.');
2840
2824
  goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
2841
2825
  }
2842
- const uiTasks = siblings.filter((t) => isUiChange(t.changedFiles));
2843
- // Only require/capture proof for projects we can actually boot & screenshot
2844
- // (Manager-shaped). A generic web repo that edits a stylesheet can't be
2845
- // captured it must still reach Review as a report, not be trapped in
2846
- // 'blocked' forever (PRD §4A/§6#1: block only what a human can't judge, not
2847
- // what the tool can't photograph).
2848
- const canCapture = canSnapshot(prProject);
2849
- if (shouldCaptureProof({ changedFiles: uiTasks.flatMap((t) => t.changedFiles ?? []), hasProof: uiTasks.some((t) => t.proof), canCapture })) {
2850
- const t = uiTasks[uiTasks.length - 1];
2851
- goalAct('UI changed and no proof is attached yet, so Manager is capturing proof now.');
2852
- for (let i = 0; i < 3 && !t.proof; i++) {
2853
- try {
2854
- goalAct(`Proof capture attempt ${i + 1}/3.`);
2855
- await captureSnapshotProof(t, prProject, { baselineDir: baseProject?.dir, goal });
2856
- } catch (e) {
2857
- goalAct(`Proof capture attempt ${i + 1}/3 failed: ${String(e.message ?? e).slice(0, 140)}`);
2858
- }
2859
- }
2860
- saveTask(t);
2861
- }
2862
- const proofMissing = canCapture && uiTasks.length > 0 && !uiTasks.some((t) => t.proof);
2863
- const proofFailing = canCapture && uiTasks.some((t) => t.proof && t.proof.pass === false);
2826
+ // The goal-level proof retry loop (3 attempts to screenshot the Manager's own
2827
+ // board) is gone with the rest of the staged-photo machinery — see the note
2828
+ // where the per-task capture used to be. Nothing here photographs anything
2829
+ // now; the human gets the worker's run declaration and its own report.
2830
+ //
2831
+ // proofMissing is retired with it, and deliberately not replaced. It meant
2832
+ // "a UI task carries no auto-snapshot" — with no auto-snapshot left, keeping
2833
+ // it would block every UI goal forever, and blocking a goal because WE failed
2834
+ // to take a picture is exactly the Failed-manufacturing this product decided
2835
+ // against (検証は薄く任意・Failed を作らない).
2836
+ const proofMissing = false;
2837
+ // A genuinely FAILED verification still blocks: this only ever comes from an
2838
+ // explicit passCondition run (task.proof.verified), which is the opt-in strong
2839
+ // path we keep. No canSnapshot() gate that gate silently limited this to
2840
+ // Manager-shaped repos.
2841
+ const proofFailing = siblings.some((t) => t.proof && t.proof.pass === false);
2864
2842
  let testsFailing = goal.testResult.ran && goal.testResult.failed > 0;
2865
2843
  // Baseline diff (Masa 2026-07-19): block ONLY on failures THIS change
2866
2844
  // introduced. A test already red on the base — e.g. persist-failure on clean
@@ -2903,9 +2881,10 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2903
2881
  const verifyFail = reviewDefinition.requireVerifyPass && !allVerified;
2904
2882
  // 段5 検証ゲート厳格化 (Masa 2026-07-15: "proof無しは完了と言わない"): passCondition
2905
2883
  // を約束したタスクなのに 0 ファイル変更で proof も無い=約束した検証が実行されずに
2906
- // すり抜けたサイン(isUiChange は changedFiles が空だと絶対に一致しないため、既存の
2907
- // proofMissing(UIタスク限定)はこのケースを検知できない)。passCondition が無い調査
2908
- // 系ゴール(0ファイルが正当な完了)は対象外 — 人間が読んで判断すれば足りる。
2884
+ // すり抜けたサイン。passCondition が無い調査系ゴール(0ファイルが正当な完了)は
2885
+ // 対象外 — 人間が読んで判断すれば足りる。
2886
+ // これは「約束した強検証が走らなかった」検知であって、写真の有無ではない
2887
+ // (P1で自動撮影と proofMissing は撤去済み)。
2909
2888
  const nothingVerifiable = isNothingVerifiable({
2910
2889
  changedFiles,
2911
2890
  hasPassCondition: siblings.some((t) => t.passCondition),
@@ -3659,21 +3638,37 @@ const server = createServer(async (req, res) => {
3659
3638
  return json(res, 200, task);
3660
3639
  }
3661
3640
 
3662
- // backfill a snapshot proof for a finished task (old reviews predate
3663
- // automatic UI snapshots and show "No capture yet" otherwise)
3664
- const snapMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/snapshot$/);
3665
- if (snapMatch && req.method === 'POST') {
3666
- const task = tasks.find((t) => t.id === Number(snapMatch[1]));
3641
+ // Start what the worker said to start, and hand back the URL to look at.
3642
+ //
3643
+ // This is the "動かして見られる状態" the review card offers. It replaces the
3644
+ // old POST /api/tasks/:id/snapshot (backfill a staged screenshot), which is
3645
+ // gone along with the rest of the photo-staging.
3646
+ //
3647
+ // On demand, not always-on: a goal's preview costs a process, and most goals
3648
+ // are never opened. Started here, reaped when idle.
3649
+ const previewMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/preview$/);
3650
+ if (previewMatch && req.method === 'POST') {
3651
+ const task = tasks.find((t) => t.id === Number(previewMatch[1]));
3667
3652
  if (!task) return json(res, 404, { error: 'task not found' });
3668
3653
  if (!requireTaskGoalOwnership(req, res, task)) return;
3669
- const project = projects.find((p) => p.id === task.projectId);
3670
- if (!project || !canSnapshot(project)) return json(res, 409, { error: 'project cannot be snapshotted' });
3671
- captureSnapshotProof(task, project)
3672
- .then(() => json(res, 200, task))
3654
+ if (!task.run) return json(res, 409, { error: 'no run declaration for this task' });
3655
+ startPreview(task)
3656
+ .then((p) => json(res, 200, p))
3673
3657
  .catch((e) => json(res, 500, { error: String(e.message ?? e).slice(0, 200) }));
3674
3658
  return;
3675
3659
  }
3676
3660
 
3661
+ // The implementation log for one task, read on demand (see freezeActivityLog above).
3662
+ // A task still running answers from memory — the same lines the SSE stream is feeding
3663
+ // the page — so opening a card mid-run shows the work in progress, not an empty box.
3664
+ const activityMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/activity$/);
3665
+ if (activityMatch && req.method === 'GET') {
3666
+ const task = tasks.find((t) => t.id === Number(activityMatch[1]));
3667
+ if (!task) return json(res, 404, { error: 'task not found' });
3668
+ if (!requireTaskGoalOwnership(req, res, task)) return;
3669
+ return json(res, 200, { lines: task.activity?.length ? task.activity : readActivityLog(task.id) });
3670
+ }
3671
+
3677
3672
  // close a stalled task without running it (failure-taxonomy: skipped)
3678
3673
  const skipMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/skip$/);
3679
3674
  if (skipMatch && req.method === 'POST') {