@galda/cli 0.10.39 → 0.10.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/server.mjs CHANGED
@@ -21,7 +21,7 @@ import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
24
- import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, 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';
24
+ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, taskStatusAfterVerify, 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, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages } from './lib.mjs';
25
25
  import { createSerialQueue } from './lib.mjs';
26
26
  import { openPR } from './pr.mjs';
27
27
  import { runVerification, exerciseUi } from './verify.mjs';
@@ -759,10 +759,43 @@ function readActivityLog(taskId) {
759
759
  try { return readFileSync(activityLogFile(taskId), 'utf8').split('\n').filter(Boolean); }
760
760
  catch { return []; }
761
761
  }
762
+
763
+ // ---- the conversation on a goal (docs/design/ONE-CONVERSATION.md §1.1) -----
764
+ // Append-only JSONL per goal. Unlike the activity log (frozen once, at the end
765
+ // of a task) a conversation grows over the whole life of the goal, so it is
766
+ // appended as it happens. Never sent with /api/state — read on demand by
767
+ // GET /api/goals/:id/messages, so 100 cards don't drag 100 threads along.
768
+ // goal.messageCount is the only thing state carries: a number, not content, so
769
+ // the UI knows a thread exists without asking for one that was never written.
770
+ const goalMessagesFile = (goalId) => join(logDir, `messages-${goalId}.jsonl`);
771
+ function addGoalMessage(goal, { from, via, text, at } = {}) {
772
+ if (!goal) return null;
773
+ const msg = buildGoalMessage({ from, via, text, at });
774
+ if (!msg) return null;
775
+ try { appendFileSync(goalMessagesFile(goal.id), `${serializeGoalMessage(msg)}\n`); }
776
+ catch (e) { console.error(`[manager] goal message write failed for goal ${goal.id} (${e?.code || e})`); return null; }
777
+ goal.messageCount = (goal.messageCount ?? 0) + 1;
778
+ saveGoal(goal);
779
+ return msg;
780
+ }
781
+ function readGoalMessages(goalId) {
782
+ try { return parseGoalMessages(readFileSync(goalMessagesFile(goalId), 'utf8')); }
783
+ catch { return []; }
784
+ }
785
+
762
786
  function saveTask(t) {
763
787
  // freeze BEFORE the destructure strips `activity`, and mark the task so the UI knows a
764
788
  // log exists without having to ask for one that was never written.
765
789
  if (freezeActivityLog(t)) t.activityLog = true;
790
+ // The AI's side of the conversation (§1.1): what the worker reported back,
791
+ // verbatim. Once per task — saveTask runs on every status change, so the flag
792
+ // is set before the persist below and rides along in the log entry.
793
+ if (ACTIVITY_TERMINAL.has(t.status) && t.result && !t.messageLogged) {
794
+ t.messageLogged = true;
795
+ addGoalMessage(goals.find((g) => g.id === t.goalId), {
796
+ from: 'ai', via: t.reply ? 'thread' : 'composer', text: t.result,
797
+ });
798
+ }
766
799
  const { activity, todos, ...persist } = t; // activity + todos are ephemeral (live only)
767
800
  logAppend(JSON.stringify({ ...persist, kind: 'task' }));
768
801
  const goal = goals.find((g) => g.id === t.goalId);
@@ -1080,54 +1113,49 @@ function runClaude({ prompt, cwd, tools, onEvent, onTodos, resume, model, effort
1080
1113
  });
1081
1114
  });
1082
1115
  }
1083
- function parseCodexUsage(usage) {
1084
- if (!usage) return null;
1085
- return {
1086
- input_tokens: usage.input_tokens ?? usage.inputTokens ?? 0,
1087
- output_tokens: usage.output_tokens ?? usage.outputTokens ?? 0,
1088
- cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
1089
- cache_read_input_tokens: usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0,
1090
- };
1091
- }
1092
- function runCodex({ prompt, cwd, onEvent, permissionMode = 'acceptEdits', model, effort, onChild }) {
1116
+ // The Codex half of the worker bridge. Same contract as runClaude, because
1117
+ // nothing downstream may care which agent ran: live activity, the agent's own
1118
+ // to-dos (the board), the thread id to talk to it again, usage, final message.
1119
+ function runCodex({ prompt, cwd, onEvent, onTodos, resume, permissionMode = 'acceptEdits', model, effort, onChild }) {
1093
1120
  return new Promise((done) => {
1094
1121
  const sandbox = permissionMode === 'plan' ? 'read-only' : 'workspace-write';
1095
- const args = ['exec', '--json', '-m', workerModel('codex', model), '-c', `model_reasoning_effort="${workerEffort('codex', effort)}"`, '--sandbox', sandbox, '--skip-git-repo-check', '--cd', cwd, '-'];
1122
+ const args = buildCodexArgs({ model: workerModel('codex', model), effort: workerEffort('codex', effort), sandbox, cwd, resume });
1096
1123
  const child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
1097
1124
  onChild?.(child);
1098
1125
  child.stdin.end(prompt);
1099
- let result = '', err = '', buf = '', usage = null, timedOut = false, lastAct = '';
1126
+ let result = '', err = '', buf = '', sessionId = null, usage = null, timedOut = false, lastAct = '';
1100
1127
  const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, WORKER_TIMEOUT_MS);
1101
1128
  child.stdout.on('data', (d) => {
1102
1129
  buf += d;
1103
1130
  const lines = buf.split('\n'); buf = lines.pop();
1104
1131
  for (const line of lines) {
1105
1132
  if (!line.trim()) continue;
1106
- try {
1107
- const ev = JSON.parse(line);
1108
- if (ev.type === 'item.completed') {
1109
- const item = ev.item ?? {};
1110
- if (item.type === 'agent_message' && item.text) {
1111
- result = item.text;
1112
- onEvent?.(`✎ ${item.text.trim().replace(/\s+/g, ' ').slice(0, 500)}`);
1113
- } else if (item.type === 'error' && item.message) onEvent?.(`Error ${item.message}`);
1114
- else if (item.type === 'command_execution' && item.command) { lastAct = `Run ${item.command}`; onEvent?.(lastAct); }
1115
- } else if (ev.type === 'turn.completed') {
1116
- usage = parseCodexUsage(ev.usage);
1117
- } else if (ev.type === 'error' && ev.message) {
1118
- err += `${ev.message}\n`;
1119
- onEvent?.(`Error ${ev.message}`);
1120
- }
1121
- } catch {
1122
- if (line.trim()) lastAct = line.trim().slice(0, 240);
1123
- onEvent?.(line.trim().slice(0, 240));
1133
+ const evs = parseCodexEvents(line);
1134
+ // A line we cannot parse is still the worker talking — show it rather
1135
+ // than swallow it (how this ran before the bridge existed).
1136
+ if (!evs.length && !line.trim().startsWith('{')) {
1137
+ lastAct = line.trim().slice(0, 240);
1138
+ onEvent?.(lastAct);
1139
+ continue;
1140
+ }
1141
+ for (const ev of evs) {
1142
+ if (ev.kind === 'session') sessionId = ev.id;
1143
+ else if (ev.kind === 'usage') usage = ev.usage;
1144
+ else if (ev.kind === 'todos') onTodos?.(ev.todos);
1145
+ else if (ev.kind === 'message') {
1146
+ result = ev.text;
1147
+ onEvent?.(`✎ ${ev.text.trim().replace(/\s+/g, ' ').slice(0, 500)}`);
1148
+ } else if (ev.kind === 'error') {
1149
+ err += `${ev.text}\n`;
1150
+ onEvent?.(`Error ${ev.text}`);
1151
+ } else { lastAct = ev.text; onEvent?.(ev.text); }
1124
1152
  }
1125
1153
  }
1126
1154
  });
1127
1155
  child.stderr.on('data', (d) => { err += d; });
1128
1156
  child.on('close', (code) => {
1129
1157
  clearTimeout(timer);
1130
- done({ code, timedOut, result: workerResultText({ result, err, code, timedOut, lastAct, timeoutMs: WORKER_TIMEOUT_MS }), sessionId: null, usage });
1158
+ done({ code, timedOut, result: workerResultText({ result, err, code, timedOut, lastAct, timeoutMs: WORKER_TIMEOUT_MS }), sessionId, usage });
1131
1159
  });
1132
1160
  });
1133
1161
  }
@@ -1226,6 +1254,12 @@ function detectRepos(homeDir) {
1226
1254
  // if worktrees can't be created (e.g. not a git repo).
1227
1255
  const goalWorkDirs = new Map();
1228
1256
  function goalWorkDir(goal, project) {
1257
+ // Masa 2026-07-22: the isolated copy is now a choice, the way the Claude app's
1258
+ // `worktree` checkbox is. Off = the work happens in the folder the person is looking
1259
+ // at, which is what they want when they mean to watch it happen. On (the default)
1260
+ // keeps today's behaviour: a private copy per goal, so a run can never leave their
1261
+ // checkout half-edited.
1262
+ if (goal?.worktree === false) return project.dir;
1229
1263
  const cached = goalWorkDirs.get(goal.id);
1230
1264
  if (cached && existsSync(cached)) return cached;
1231
1265
  let repoRoot;
@@ -1437,13 +1471,21 @@ async function planGoal(goal) {
1437
1471
  '- 各タスクの title は「ユーザーが頼んだこと」を、ユーザーの言葉・語彙をそのまま活かして短く書く。実装のやり方(「〜するように直した」「〜を計算する」等)や、完了報告の文(「〜した」「〜しました」)を title にしない。エンジニアリングの手順名(設計/実装/テスト等)にもしない。例: 依頼「一回たたむとsettingボタンがズレるバグ修正して」→ title「settingボタンがズレるバグの修正」(✕「アニメーションが終わってから歯車の位置を計算するように直した」)。',
1438
1472
  '- バグ修正の依頼では、title に「どの問題を直すか」がわかるように書く(解決する問題=ユーザーの言葉)。依頼に直し方の方針が書かれていればそれも短く添えてよいが、無ければ問題だけでよい。工程・実装詳細は detail に書く。',
1439
1473
  '- detail には worker への完全な指示を書く:そのworkerが1人で実装・テスト・proofまで完結させる。工程ごとの兄弟タスクは来ない前提で書くこと。',
1440
- '- passCondition はユーザーが依頼文で明示的に検証条件(「成功条件:」「〜したら〜になる」等)を書いた場合のみ設定する。見た目の調整・文言変更・スタイル変更のタスクには付けない(null)。検証は高価なので、明示された時だけ。',
1474
+ // passCondition is gone from this prompt on purpose (Masa 2026-07-22). The
1475
+ // rule used to be "only when the user wrote one" — a rule this layer asked
1476
+ // for and could not hold: measured twice on the identical request with no
1477
+ // condition in it, the intake AI invented one on the first run and not on
1478
+ // the second. The run where it invented one then failed its own invented
1479
+ // check and forced a full rework (>100k tokens) that pushed the work
1480
+ // TOWARD the wrong target. What to look at is the working AI's to state
1481
+ // (`check` in .manager-run.json, #235) — the intake AI only read the
1482
+ // request, so it cannot know what "correct" is.
1441
1483
  '- 検証対象のページがあれば entry に書く(プロジェクト相対のhtmlパス、または http URL。無ければ null)。依頼中に書かれていればそれを使い、無ければリポジトリを見て推測してよい。',
1442
1484
  '- 依頼が曖昧で重要な前提が1つ欠けている(例: 対象・範囲・形式が不明)か、既存の作業と矛盾する場合「のみ」、タスクではなく確認質問を返す: {"question":"確認したい1点(短く)","options":["選択肢A","選択肢B"]}。ただし基本は合理的な仮定で進めること。質問は本当に必要な時だけ、1問に絞る。',
1443
1485
  // Once the user has answered a clarification, NEVER ask again — re-asking looped
1444
1486
  // the answered question back onto the board. Override the rule above for re-plans.
1445
1487
  goal.clarified ? '【最重要】ユーザーは既にこの依頼への確認質問に回答済み(依頼文末尾に [確認: … → …] として反映済み)。これ以上、確認質問(question)を返してはならない。回答を前提に、必ずタスクへ分解して返すこと。' : '',
1446
- '- 出力はJSONのみ(前後に文章を書かない)。通常: {"entry":"path|null","tasks":[{"title":"ユーザーから見た成果(依頼と同じ言語で短く)","detail":"workerへの完全な指示","passCondition":"検証条件|null"}]} / 確認が要る時だけ: {"question":"...(依頼と同じ言語)","options":["...","..."]}',
1488
+ '- 出力はJSONのみ(前後に文章を書かない)。通常: {"entry":"path|null","tasks":[{"title":"ユーザーから見た成果(依頼と同じ言語で短く)","detail":"workerへの完全な指示"}]} / 確認が要る時だけ: {"question":"...(依頼と同じ言語)","options":["...","..."]}',
1447
1489
  '',
1448
1490
  goal.priorFailureMemory?.length ? [
1449
1491
  '過去の失敗メモリ(同じ失敗を避けること):',
@@ -1497,7 +1539,11 @@ async function planGoal(goal) {
1497
1539
  // DECOMPOSES the request into tasks the worker must follow. The request goes
1498
1540
  // to the worker verbatim as ONE task; the worker (Claude Code / Codex) decides
1499
1541
  // its own breakdown and emits it as its own TodoWrite, which the board shows.
1500
- // One card = one request. A user-written pass condition still rides along.
1542
+ // One card = one request. The pass condition no longer rides along at all:
1543
+ // it was the last place where an AI that had only READ the request decided
1544
+ // what "correct" means for it, and it did so at random (see the prompt note
1545
+ // above). Nothing here sets one, so `entry` now only survives as the page a
1546
+ // future explicit, user-written condition would open.
1501
1547
  const title = goalTaskTitle(goal.text);
1502
1548
  goal.plan = [title];
1503
1549
  saveGoal(goal);
@@ -1505,7 +1551,6 @@ async function planGoal(goal) {
1505
1551
  const num = (base.length ? Math.max(...base) : 0) + 1;
1506
1552
  const task = buildGoalTask(goal, {
1507
1553
  id: nextId++, num,
1508
- passCondition: plan.tasks[0]?.passCondition ?? null,
1509
1554
  createdAt: new Date().toISOString(),
1510
1555
  });
1511
1556
  tasks.push(task);
@@ -1830,7 +1875,11 @@ function resumeProject(projectId) {
1830
1875
 
1831
1876
  // Slack風スレッド返信タスクの生成(/api/goals/:id/reply と /api/goals/:id/dismiss で共有)。
1832
1877
  // runTask()のtask.reply分岐がgoal.sessionIdをresumeに渡して同じ会話を続ける。
1833
- function queueReplyTask(goal, text) {
1878
+ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {}) {
1879
+ // Every follow-up on a goal is part of its conversation (§1.1). Recorded here
1880
+ // rather than at each call site so an internally-generated nudge (auto-retest
1881
+ // dismissal, fold-in) is kept too — with `from` saying whose words they are.
1882
+ addGoalMessage(goal, { from, via, text });
1834
1883
  const failurePolicy = latestFailurePolicy(goal);
1835
1884
  const promptText = failurePolicy ? `${failurePolicy.text}\n\n${text}` : text;
1836
1885
  goal.executionPlan = buildExecutionPlan({
@@ -1868,6 +1917,38 @@ function queueReplyTask(goal, text) {
1868
1917
  return task;
1869
1918
  }
1870
1919
 
1920
+ // split (docs/design/ONE-CONVERSATION.md §1.3): 「それは別件」と返された時の受け皿。
1921
+ // 元のゴールには触らない——新しいチケットを1枚起こし、そこに「どこから生まれたか」だけ
1922
+ // 片方向の印(bornFrom)を残す。
1923
+ //
1924
+ // なぜ片方向か(Masa確定 2026-07-22): 必要なのは「なぜこのチケットが在るか」だけで、
1925
+ // 2つをまとめて管理したいわけではない。関係を双方向にすると「解除」の仕組みまで要り、
1926
+ // 作られないまま残る仕組みが1つ増える。片方向なら片方が消えても壊れない。
1927
+ //
1928
+ // 設定は親から引き継ぐ(プロジェクト・エージェント・モデル・effort)。別件とはいえ
1929
+ // 同じ場所で同じ人が続けている作業なので、ここで選び直させる理由が無い。
1930
+ function createSplitGoal(parent, text, identity) {
1931
+ const goal = {
1932
+ id: nextId++, projectId: parent.projectId, text: text.slice(0, 8000), status: 'planning',
1933
+ wantsPR: resolveWantsPR('auto', text, getReviewDefinition(parent.projectId).defaultWantsPR),
1934
+ model: parent.model, effort: parent.effort, agent: parent.agent, mode: 'auto',
1935
+ skill: null, images: [], source: resolveGoalSource('app'), sourceRef: null,
1936
+ executionPlan: buildExecutionPlan({ projectId: parent.projectId, text, previousSession: false }),
1937
+ plan: null, pr: undefined, createdAt: new Date().toISOString(),
1938
+ // 片方向の印。表示は app(新しいカード「#N の会話から生まれました」/元のカード
1939
+ // 「→ #M を切り出しました」)。engine は数字を1つ持つだけ。
1940
+ bornFrom: parent.id,
1941
+ owner: identity,
1942
+ };
1943
+ goals.push(goal);
1944
+ saveGoal(goal);
1945
+ addGoalMessage(goal, { from: 'you', via: 'review', text: goal.text });
1946
+ planGoal(goal).catch((e) => {
1947
+ goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
1948
+ });
1949
+ return goal;
1950
+ }
1951
+
1871
1952
  function testOutputExcerpt(text) {
1872
1953
  const lines = String(text ?? '').split('\n').map((l) => l.trimEnd()).filter(Boolean);
1873
1954
  const interesting = lines.filter((l) =>
@@ -1995,7 +2076,24 @@ function readRunDeclaration(task, workDir) {
1995
2076
  return;
1996
2077
  }
1997
2078
  task.run = { ...run, dir: workDir };
1998
- sendProcessAct(task, `Worker declared how to see the result: ${run.cmd ?? run.url}`);
2079
+ const how = run.cmd ?? run.url;
2080
+ if (how) sendProcessAct(task, `Worker declared how to see the result: ${how}`);
2081
+ if (run.title) sendProcessAct(task, `Worker named the work: ${run.title}`);
2082
+ if (run.compare) sendProcessAct(task, `Worker declared a before/after pair: ${run.compare.join(' → ')}`);
2083
+ }
2084
+
2085
+ // A declared pair only survives if both pictures actually arrived. The card
2086
+ // draws a red box over whatever differs between the two, so a name matching no
2087
+ // collected shot would leave the reviewer half a comparison — show the plain
2088
+ // screenshots and say why instead. Runs after the shots are collected.
2089
+ function reconcileComparePair(task) {
2090
+ const pair = task.run?.compare;
2091
+ if (!pair) return;
2092
+ const have = new Set((task.shots ?? []).map((s) => s.name));
2093
+ const missing = pair.filter((n) => !have.has(n));
2094
+ if (!missing.length) return;
2095
+ task.run = { ...task.run, compare: null };
2096
+ sendAct(task, `before/after pair ignored — no screenshot named ${missing.join(', ')}`);
1999
2097
  }
2000
2098
 
2001
2099
  // Take whatever the worker photographed out of the worktree (SHOT_DIR) and put
@@ -2166,12 +2264,14 @@ async function runTask(task) {
2166
2264
  return;
2167
2265
  }
2168
2266
 
2169
- let verify = null, verdict = null, lastFailure = null, code = 0, result = '', usages = [], timedOut = false;
2267
+ // No `lastFailure` any more: it existed only to tell the next attempt why our
2268
+ // check had rejected the last one, and there is no next attempt now.
2269
+ let verify = null, verdict = null, code = 0, result = '', usages = [], timedOut = false;
2170
2270
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2171
2271
  let callT0 = Date.now();
2172
2272
  const plan = goal?.executionPlan;
2173
2273
  const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goal?.sessionId ?? undefined);
2174
- const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, lastFailure, workerAgent(task.agent ?? goal?.agent))].filter(Boolean).join('\n\n');
2274
+ const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, null, workerAgent(task.agent ?? goal?.agent))].filter(Boolean).join('\n\n');
2175
2275
  sendProcessAct(task, `Starting ${workerAgent(task.agent ?? goal?.agent) === 'codex' ? 'Codex' : 'Claude Code'} worker (attempt ${attempt}/${maxAttempts}).`);
2176
2276
  let r = await runWorkerAgent({
2177
2277
  agent: task.agent ?? goal?.agent,
@@ -2200,7 +2300,7 @@ async function runTask(task) {
2200
2300
  sendProcessAct(task, 'Starting a fresh worker session because the warm session could not resume.');
2201
2301
  r = await runWorkerAgent({
2202
2302
  agent: task.agent ?? goal?.agent,
2203
- prompt: workerPrompt(task, goal, lastFailure, workerAgent(task.agent ?? goal?.agent)), cwd: workDir, tools: WORKER_TOOLS,
2303
+ prompt: workerPrompt(task, goal, null, workerAgent(task.agent ?? goal?.agent)), cwd: workDir, tools: WORKER_TOOLS,
2204
2304
  permissionMode,
2205
2305
  model: task.model ?? goal?.model, effort: task.effort ?? goal?.effort, onEvent: (line) => sendAct(task, line),
2206
2306
  onTodos: (todos) => sendTodos(task, todos),
@@ -2239,12 +2339,14 @@ async function runTask(task) {
2239
2339
  eph?.kill();
2240
2340
  }
2241
2341
  sendAct(task, `verify: ${verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (check could not run)' : 'FAIL'} — ${verdict.detail.slice(0, 120)}`);
2242
- // An inconclusive verdict = the check itself couldn't run (authored check
2243
- // referenced hooks the app doesn't expose#487). Retrying will just
2244
- // throw again, so stop and let it route to human review rather than
2245
- // burning attempts and scoring the change as failed.
2246
- if (verdict.pass || verdict.inconclusive) break;
2247
- lastFailure = verdict.detail;
2342
+ // Whatever the verdict, the worker is not sent back (Masa 2026-07-22).
2343
+ // A rework used to be triggered by a FAIL and the check that produced
2344
+ // the FAIL is something this layer authored from the request, not from
2345
+ // the work. When it was wrong, the rework spent more than the whole
2346
+ // original run and taught the AI to chase the wrong target. The failing
2347
+ // check is reported in review instead; a human decides whether to ask
2348
+ // for another pass. (Inconclusive already stopped here, #487.)
2349
+ break;
2248
2350
  } catch (e) {
2249
2351
  sendAct(task, `verify: could not run — ${String(e.message ?? e).slice(0, 120)}`);
2250
2352
  verdict = null;
@@ -2254,28 +2356,21 @@ async function runTask(task) {
2254
2356
 
2255
2357
  if (goal?.cancelled) return; // deleted during the verify loop → don't resurrect the task
2256
2358
  task.secs = Math.round((Date.now() - t0) / 1000);
2257
- // Status decision, most-authoritative first:
2359
+ // Status decision, most-authoritative first (taskStatusAfterVerify in lib):
2258
2360
  // 1. an independent verify PASS = verified success — wins even if the worker
2259
2361
  // was force-stopped afterwards (the change is proven good).
2260
2362
  // 2. a forced STOP (10-min timeout / SIGTERM, exit 143) = 'interrupted', NOT
2261
- // 'failed' — it's a retryable halt, not "the worker's code was wrong". A
2262
- // FAIL verdict on a killed (incomplete) worker isn't authoritative either,
2263
- // so the stop reason takes precedence. (Mirrors the budget-breach path.)
2264
- // 3. a verify that actually ran and FAILED = 'failed'.
2265
- // 4. an INCONCLUSIVE verify (the check threw / returned garbage — the
2266
- // verifier is at fault, not necessarily the change, #487) is NOT
2267
- // authoritative: fall through to the exit code so a cleanly-finished
2268
- // worker still reaches review for a human, instead of a false 'failed'.
2269
- // 5. no verify = the exit code decides.
2363
+ // 'failed' — it's a retryable halt, not "the worker's code was wrong".
2364
+ // 3. otherwise the exit code decides. A check that ran and FAILED no longer
2365
+ // scores 'failed' (Masa 2026-07-22) it is reported in review like an
2366
+ // inconclusive one. Our check can be wrong about the work; a cleanly
2367
+ // finished worker must not be marked failed by it.
2270
2368
  const forcedStop = isForcedStop(code, timedOut);
2271
2369
  // A verdict only counts as authoritative when the check actually judged the
2272
2370
  // change. An inconclusive verdict (check threw / returned garbage) is treated
2273
- // like "no verdict" everywhere below — status, proof, and snapshot capture.
2371
+ // like "no verdict" for the proof below.
2274
2372
  const conclusive = verdict && !verdict.inconclusive;
2275
- task.status = verdict?.pass ? 'done'
2276
- : forcedStop ? 'interrupted'
2277
- : conclusive ? 'failed'
2278
- : (code === 0 ? 'done' : 'failed');
2373
+ task.status = taskStatusAfterVerify({ verdict, code, timedOut });
2279
2374
  // Freeze the worker's own final TodoWrite list (P0b): live `todos` are stripped
2280
2375
  // on save, so snapshot them into `todosFinal` — persisted (blacklist saveTask /
2281
2376
  // trimTaskActivityForState keep it) so the board/review can show what the AI
@@ -2328,6 +2423,7 @@ async function runTask(task) {
2328
2423
  // deliverable, and it must not show up in the diff or the PR.
2329
2424
  readRunDeclaration(task, workDir);
2330
2425
  collectWorkerShots(task, workDir);
2426
+ reconcileComparePair(task);
2331
2427
  const after = await gitChanges(workDir);
2332
2428
  task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
2333
2429
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
@@ -2593,11 +2689,15 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
2593
2689
  const lang = reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : (reviewDefinition.language || 'ja');
2594
2690
  const changed = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].slice(0, 20);
2595
2691
  const reports = siblings.filter((t) => !t.reply).map((t) => `- ${t.title}: ${(t.result ?? '').replace(/\s+/g, ' ').slice(0, 300)}`).join('\n');
2596
- // fallback = current behavior: title/changed derived from the goal's own text,
2597
- // so a goal with no summary (AI failed or nothing to summarize) still shows a
2598
- // meaningful title/changed and never a blank or mis-phrased "…の修正".
2692
+ // The name of the work, from the agent that did it (task.run.title). It wins
2693
+ // over both the summarizer and the request text: the summarizer only ever read
2694
+ // the same request the user typed, and the request text is the sentence the
2695
+ // title is supposed to be an answer to. Masa 2026-07-22.
2696
+ const declaredTitle = siblings.map((t) => t.run?.title).find(Boolean) ?? null;
2697
+ // fallback: the request text, still — but now only when nobody named the work
2698
+ // at all (an old worker, or one that wrote no declaration).
2599
2699
  const fallback = {
2600
- title: (goal.text ?? '').replace(/\s+/g, ' ').slice(0, 60),
2700
+ title: declaredTitle ?? (goal.text ?? '').replace(/\s+/g, ' ').slice(0, 60),
2601
2701
  check: '',
2602
2702
  changed: (goal.plan?.[0] ?? goal.text ?? '').replace(/\s+/g, ' ').slice(0, 80),
2603
2703
  };
@@ -2605,7 +2705,8 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
2605
2705
  const prompt = buildReviewSummaryPrompt({ lang, goalText: goal.text, changed, reports });
2606
2706
  try {
2607
2707
  const r = await runUtility({ prompt, cwd: dir, tools: 'Read' });
2608
- return parseReviewSummary(r.result, fallback);
2708
+ const summary = parseReviewSummary(r.result, fallback);
2709
+ return declaredTitle ? { ...summary, title: declaredTitle } : summary;
2609
2710
  } catch { /* fall through */ }
2610
2711
  return fallback;
2611
2712
  }
@@ -2739,7 +2840,7 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
2739
2840
  // The target re-opens: it has new work to do, and createGoalPR's rework path
2740
2841
  // will push the result onto the PR branch it already owns.
2741
2842
  foldTarget.status = 'running';
2742
- queueReplyTask(foldTarget, goal.text.slice(0, 8000));
2843
+ queueReplyTask(foldTarget, goal.text.slice(0, 8000), { from: 'you', via: 'composer' });
2743
2844
  saveGoal(foldTarget);
2744
2845
  // The folded goal keeps its row (PRD §3.5: a submission is never dropped) —
2745
2846
  // it becomes a pointer at the goal that now carries its work.
@@ -3164,7 +3265,14 @@ const server = createServer(async (req, res) => {
3164
3265
  price: await getBillingPrice(),
3165
3266
  siteUrl: BILLING_API_URL || null,
3166
3267
  };
3167
- return json(res, 200, { projects, 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() });
3268
+ // The branch each project is on the Context Bar prints it next to the folder, and
3269
+ // it is read here rather than guessed (a branch nobody can see is not a branch).
3270
+ const projectsWithBranch = projects.map((p) => {
3271
+ let branch = null;
3272
+ try { branch = gitSync(p.dir, ['rev-parse', '--abbrev-ref', 'HEAD']); } catch { /* not a repo → no branch, and the bar shows none */ }
3273
+ return branch ? { ...p, branch } : p;
3274
+ });
3275
+ 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() });
3168
3276
  }
3169
3277
 
3170
3278
  // Billing (docs/BILLING-LAUNCH-PLAN.md): activate the license key a user
@@ -3292,6 +3400,16 @@ const server = createServer(async (req, res) => {
3292
3400
  return json(res, r.action === 'busy' ? 409 : 200, r);
3293
3401
  }
3294
3402
 
3403
+ // The folders a person could actually point a project at: the git repos sitting
3404
+ // directly under HOME. A browser cannot open an OS folder dialog that yields a real
3405
+ // path (showDirectoryPicker hands back a handle, never a path), so a detected list
3406
+ // plus typing a path IS the picker — and the detection already existed here, feeding
3407
+ // the "which folder?" question card. This endpoint only exposes what it already
3408
+ // finds; nothing new is scanned, invented, or ranked.
3409
+ if (url.pathname === '/api/repos' && req.method === 'GET') {
3410
+ return json(res, 200, { repos: detectRepos(HOME), home: HOME });
3411
+ }
3412
+
3295
3413
  if (url.pathname === '/api/projects' && req.method === 'POST') {
3296
3414
  let body = '';
3297
3415
  req.on('data', (d) => { body += d; });
@@ -3342,6 +3460,21 @@ const server = createServer(async (req, res) => {
3342
3460
  try {
3343
3461
  const input = JSON.parse(body || '{}');
3344
3462
  if (typeof input.name === 'string' && input.name.trim()) project.name = input.name.trim().slice(0, 80);
3463
+ // Moving a project to another folder. Refused loudly when the path is not
3464
+ // somewhere work can happen: a folder that does not exist, or is HOME itself,
3465
+ // or is not a git repository. Accepting it here would look like it worked and
3466
+ // then fail silently on the next goal — the worst of the two failures, and the
3467
+ // reason the "which folder?" card exists at all.
3468
+ if (input.dir !== undefined) {
3469
+ const dir = String(input.dir ?? '').trim();
3470
+ if (!dir) return json(res, 400, { error: 'folder is required' });
3471
+ const abs = dir.startsWith('~') ? join(HOME, dir.slice(1)) : resolve(dir);
3472
+ if (!existsSync(abs)) return json(res, 400, { error: 'no such folder', dir: abs });
3473
+ if (needsProjectFolder({ dir: abs, homeDir: HOME, isRepo: isGitRepo(abs) })) {
3474
+ return json(res, 400, { error: 'not a git repository', dir: abs });
3475
+ }
3476
+ project.dir = abs;
3477
+ }
3345
3478
  saveProjects();
3346
3479
  send({ ev: 'projects', projects });
3347
3480
  return json(res, 200, { project, projects });
@@ -3388,7 +3521,7 @@ const server = createServer(async (req, res) => {
3388
3521
  req.on('data', (d) => { body += d; });
3389
3522
  req.on('end', async () => {
3390
3523
  try {
3391
- const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent } = JSON.parse(body);
3524
+ const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent, worktree } = JSON.parse(body);
3392
3525
  const project = projects.find((p) => p.id === projectId);
3393
3526
  if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
3394
3527
  // Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
@@ -3406,6 +3539,32 @@ const server = createServer(async (req, res) => {
3406
3539
  return json(res, 200, { kind: 'chat', reply: `${project.name} を再開しました。止めた作業を続きから進めます。` });
3407
3540
  }
3408
3541
  }
3542
+ // 宛先 (docs/design/ONE-CONVERSATION.md §1.2): 「#12 の件だけど…」と書けたら、
3543
+ // その番号のチケット宛て。決定論——我々の層は何も推測しない。
3544
+ //
3545
+ // Checked BEFORE the chat/task classifier on purpose: writing a number is an
3546
+ // explicit statement of where the words go, and a guess (however cheap) must
3547
+ // not be able to overrule it. "#12 これで合ってる?" reads as chat to the
3548
+ // heuristic; it is a follow-up on #12.
3549
+ //
3550
+ // Falls through silently when the number names nothing this person can act on
3551
+ // (unknown id, another project, someone else's goal, already finished). The
3552
+ // words then become a new ticket, which is what happens today anyway — a typo
3553
+ // must never make a request disappear.
3554
+ const identity = identityFor(req);
3555
+ if (!pending && !review) {
3556
+ const addr = parseGoalAddress(text);
3557
+ const target = addr && goals.find((g) => g.id === addr.goalId
3558
+ && g.projectId === projectId
3559
+ && goalVisibleTo(g, identity, MANAGER_OWNER_EMAIL)
3560
+ && !['done', 'reverted', 'skipped', 'rejected'].includes(g.status));
3561
+ if (target) {
3562
+ // A follow-up on a goal already awaiting review re-opens work on it.
3563
+ if (target.status === 'review') { target.status = 'running'; saveGoal(target); }
3564
+ const replyTask = queueReplyTask(target, text.trim().slice(0, 8000), { from: 'you', via: 'composer' });
3565
+ return json(res, 200, { ...target, addressedTo: target.id, replyTaskId: replyTask.id });
3566
+ }
3567
+ }
3409
3568
  // Onboarding (docs/HANDOFF-ONBOARDING-conversational.md, SLICE 2): the
3410
3569
  // composer is conversation-first. A chat/help/settings message ("what can
3411
3570
  // you do?", "reply in English", a greeting) gets a conversational reply
@@ -3432,29 +3591,14 @@ const server = createServer(async (req, res) => {
3432
3591
  if (parsed.intent === 'chat') return json(res, 200, { kind: 'chat', reply: parsed.reply.slice(0, 2000), lang: intentLang });
3433
3592
  } catch { /* LLM unavailable → fall through to goal creation */ }
3434
3593
  }
3435
- // Dedupe (Masa: 被ったやつもまとめて): if this near-duplicates a goal
3436
- // already in flight (running/review with a live worker session), fold it
3437
- // in as a thread follow-up (context kept via session resume) instead of
3438
- // piling up a redundant parallel goal. Conservative HIGH-confidence
3439
- // text overlap only, against session-bearing active goals so a
3440
- // genuinely distinct ask always becomes its own goal, and the user's
3441
- // text is never dropped (it becomes a reply task either way). Skipped for
3442
- // pending (shelved) and review-only (human-only) items.
3443
- const identity = identityFor(req);
3444
- if (!pending && !review) {
3445
- // Stage 2: only fold into a goal this identity can actually see —
3446
- // otherwise a near-duplicate message from user B could silently
3447
- // attach as a reply task onto someone else's private goal (and
3448
- // leak that goal's text back to B in the response).
3449
- const candidates = goals.filter((g) => g.projectId === projectId && g.sessionId && ['running', 'review'].includes(g.status) && goalVisibleTo(g, identity, MANAGER_OWNER_EMAIL));
3450
- const overlap = findOverlappingGoal(text.trim(), candidates);
3451
- if (overlap) {
3452
- // A follow-up on a goal already awaiting review re-opens work on it.
3453
- if (overlap.goal.status === 'review') { overlap.goal.status = 'running'; saveGoal(overlap.goal); }
3454
- const replyTask = queueReplyTask(overlap.goal, text.trim().slice(0, 8000));
3455
- return json(res, 200, { ...overlap.goal, mergedInto: overlap.goal.id, replyTaskId: replyTask.id, overlapScore: overlap.score });
3456
- }
3457
- }
3594
+ // (REMOVED 2026-07-22, ONE-CONVERSATION.md §1.2) The 0.72 auto-merge used to
3595
+ // sit here: any new request whose WORDS looked ~72% like an in-flight goal was
3596
+ // silently folded into it as a follow-up. That was our layer guessing "this is
3597
+ // probably the same thing" the exact move this product exists to stop doing.
3598
+ // When it was right nobody noticed; when it was wrong the request vanished into
3599
+ // someone else's ticket. Replaced by the address above: the person says which
3600
+ // ticket, or it becomes its own.
3601
+ //
3458
3602
  // Billing (docs/BILLING-LAUNCH-PLAN.md §2/§5): gate actual new-goal
3459
3603
  // creation on the free tier / paid entitlement. Placed after the
3460
3604
  // dedupe fold-in above on purpose — folding into an existing goal as
@@ -3507,6 +3651,11 @@ const server = createServer(async (req, res) => {
3507
3651
  mode: (!review && GOAL_MODES.includes(mode)) ? mode : 'auto',
3508
3652
  // SKILL: an optional user/project skill (or command) to invoke first.
3509
3653
  skill: (typeof skill === 'string' && skill.trim()) ? skill.trim().slice(0, 80) : null,
3654
+ // WORKTREE (Masa 2026-07-22, the Claude app's own checkbox): true/undefined =
3655
+ // a private copy per goal, which is what has always happened. Explicit false =
3656
+ // work in the folder the person is looking at. Only an explicit false is
3657
+ // stored, so every goal ever written keeps meaning what it meant.
3658
+ worktree: worktree === false ? false : undefined,
3510
3659
  images: Array.isArray(images) ? images.slice(0, 6) : [],
3511
3660
  source: resolveGoalSource(source), sourceRef: null,
3512
3661
  executionPlan: buildExecutionPlan({
@@ -3522,6 +3671,10 @@ const server = createServer(async (req, res) => {
3522
3671
  };
3523
3672
  goals.push(goal);
3524
3673
  saveGoal(goal);
3674
+ // The conversation starts with what the person actually asked for, so a
3675
+ // thread opened later reads from the beginning instead of starting at
3676
+ // the first follow-up (§1.1: 入口が違っても1本の列).
3677
+ addGoalMessage(goal, { from: 'you', via: 'composer', text: goal.text, at: goal.createdAt });
3525
3678
  // pending = deliberately shelved (Phase 2 material): never planned,
3526
3679
  // never queued, until POST /api/goals/:id/activate. review-only items
3527
3680
  // are terminal-until-human too, so they also skip planGoal.
@@ -3674,6 +3827,17 @@ const server = createServer(async (req, res) => {
3674
3827
  return json(res, 200, { lines: task.activity?.length ? task.activity : readActivityLog(task.id) });
3675
3828
  }
3676
3829
 
3830
+ // The conversation on one goal, read on demand (see addGoalMessage above).
3831
+ // Deliberately NOT part of /api/state: every card would drag its whole thread
3832
+ // along on every poll. Same "only when asked" shape as the implementation log.
3833
+ const messagesMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/messages$/);
3834
+ if (messagesMatch && req.method === 'GET') {
3835
+ const goal = goals.find((g) => g.id === Number(messagesMatch[1]));
3836
+ if (!goal) return json(res, 404, { error: 'goal not found' });
3837
+ if (!requireGoalOwnership(req, res, goal)) return;
3838
+ return json(res, 200, { messages: readGoalMessages(goal.id) });
3839
+ }
3840
+
3677
3841
  // close a stalled task without running it (failure-taxonomy: skipped)
3678
3842
  const skipMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/skip$/);
3679
3843
  if (skipMatch && req.method === 'POST') {
@@ -4013,7 +4177,7 @@ const server = createServer(async (req, res) => {
4013
4177
  goal.blocked = null;
4014
4178
  saveGoal(goal);
4015
4179
  }
4016
- json(res, 200, queueReplyTask(goal, text.trim()));
4180
+ json(res, 200, queueReplyTask(goal, text.trim(), { from: 'you', via: 'thread' }));
4017
4181
  } catch { json(res, 400, { error: 'bad json' }); }
4018
4182
  });
4019
4183
  return;
@@ -4203,6 +4367,58 @@ const server = createServer(async (req, res) => {
4203
4367
  return json(res, 200, payload);
4204
4368
  }
4205
4369
 
4370
+ // 返事の行き先を AI 本人に決めてもらう (ONE-CONVERSATION.md §1.4)。
4371
+ //
4372
+ // 判定は1往復だけの軽い仕事なので、作業した本人ではなくユーティリティのモデルに
4373
+ // 聞く(答えを書く answer 本体は本人に聞く——あちらは中身を知っている必要がある)。
4374
+ // 呼べなかった時は 'unsure'。「たぶん continue」で走り出すより、人に1タップ選ばせる
4375
+ // ほうが安い。
4376
+ async function judgeReplyIntent(goal, reply) {
4377
+ let parsed;
4378
+ try {
4379
+ const r = await runUtility({ prompt: buildReplyIntentPrompt({ goalText: goal.text, reply }), cwd: ROOT, tools: 'Read' });
4380
+ parsed = parseReplyIntent(r.result);
4381
+ } catch { parsed = { outcome: 'unsure', read: false }; }
4382
+ // Could not ask at all → do what a reply has always done (continue). Blocking the
4383
+ // reply on four choices would leave a machine with no reachable agent unable to
4384
+ // send anything back, and an answer could not have been written there anyway.
4385
+ return parsed.read ? parsed.outcome : 'continue';
4386
+ }
4387
+
4388
+ // 「聞いてるだけ」への返事 (ONE-CONVERSATION.md §1.3 answer)。
4389
+ //
4390
+ // 答えるのは**作業した AI 本人**= goal.sessionId を resume する。状況一覧しか
4391
+ // 知らない別AIでは「なぜそうしたか」に答えられない(Masa確定 2026-07-22)。
4392
+ // コードは1文字も変わらない: ツールは Read だけ、permissionMode は 'plan'
4393
+ // (Codex 側は read-only サンドボックスに落ちる)。タスクも作らないので看板も
4394
+ // 動かない——answer は「何も動かさない」が仕様。
4395
+ // 失敗しても人が待ちぼうけにならないよう、理由を manager の発言として会話に残す。
4396
+ async function answerGoalQuestion(goal, question) {
4397
+ const project = projects.find((p) => p.id === goal.projectId);
4398
+ const cwd = project?.dir && existsSync(project.dir) ? project.dir : ROOT;
4399
+ const prompt = [
4400
+ 'あなたが担当した作業について、依頼者から質問が来ています。',
4401
+ 'コードは変更せず(読み取りしかできません)、質問にだけ簡潔に答えてください。',
4402
+ 'これは作り直しの指示ではありません。答えだけを書いてください。',
4403
+ '',
4404
+ `依頼: ${String(goal.text ?? '').slice(0, 800)}`,
4405
+ `質問: ${question.slice(0, 1000)}`,
4406
+ ].join('\n');
4407
+ try {
4408
+ const r = await runWorkerAgent({
4409
+ agent: goal.agent, model: goal.model, prompt, cwd,
4410
+ tools: 'Read', permissionMode: 'plan',
4411
+ resume: goal.sessionId ?? undefined,
4412
+ onChild: (c) => trackGoalWorker(goal.id, c),
4413
+ });
4414
+ const answer = String(r?.result ?? '').trim();
4415
+ if (answer) addGoalMessage(goal, { from: 'ai', via: 'review', text: answer });
4416
+ else addGoalMessage(goal, { from: 'manager', via: 'review', text: '答えが空でした。もう一度聞いてみてください。' });
4417
+ } catch (e) {
4418
+ addGoalMessage(goal, { from: 'manager', via: 'review', text: `答えられませんでした: ${String(e?.message ?? e).slice(0, 200)}` });
4419
+ }
4420
+ }
4421
+
4206
4422
  // レビュー詳細パネルのDismissボタン: 修正指示(空でも「差し戻し」扱い)をgoalのスレッド
4207
4423
  // 返信としてworkerに渡し、goalをrunning(doing相当)へ戻す。
4208
4424
  const dismissMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/dismiss$/);
@@ -4218,17 +4434,74 @@ const server = createServer(async (req, res) => {
4218
4434
  saveGoal(goal);
4219
4435
  return json(res, 200, { goal });
4220
4436
  }
4221
- const result = dismissGoal({ goalStatus: goal.status });
4222
- if (!result.ok) return json(res, 409, { error: result.error });
4437
+ if (goal.status !== 'review') return json(res, 409, { error: dismissGoal({ goalStatus: goal.status }).error });
4223
4438
  let body = '';
4224
4439
  req.on('data', (d) => { body += d; });
4225
- req.on('end', () => {
4226
- let text = '';
4227
- try { text = String(JSON.parse(body || '{}').text ?? '').trim(); } catch {}
4440
+ req.on('end', async () => {
4441
+ let text = '', outcome = 'continue', auto = false;
4442
+ try {
4443
+ const parsed = JSON.parse(body || '{}');
4444
+ text = String(parsed.text ?? '').trim();
4445
+ if (REPLY_OUTCOMES.includes(parsed.outcome)) outcome = parsed.outcome;
4446
+ auto = parsed.outcome === 'auto';
4447
+ } catch {}
4448
+
4449
+ // outcome:'auto' = 決めるのは AI 本人 (§1.4)。人の言葉を1回だけ渡して、返って
4450
+ // きた行き先のとおりに配線する。我々はキーワードも類似度も見ない。
4451
+ // 迷ったと言われたら**ゴールに触らずに**選択肢を返す——ここで勝手に continue に
4452
+ // 倒すと、質問しただけの人のコードが動き出す(それが直したかった壊れ方)。
4453
+ if (auto) {
4454
+ if (!text) return json(res, 400, { error: 'text required' });
4455
+ const judged = await judgeReplyIntent(goal, text);
4456
+ if (judged === 'unsure') return json(res, 200, { goal, outcome: 'unsure', choices: REPLY_OUTCOMES });
4457
+ outcome = judged;
4458
+ }
4459
+ // 返事の行き先4種 (ONE-CONVERSATION.md §1.3). 既定は今までどおり continue なので、
4460
+ // outcome を送ってこない今の UI の挙動は1ミリも変わらない。
4461
+ const result = dismissGoal({ goalStatus: goal.status, outcome });
4462
+ if (!result.ok) return json(res, 409, { error: result.error });
4463
+
4464
+ // split: 元のゴールには触らない。新しいチケットが1枚生まれるだけ。
4465
+ if (result.splits) {
4466
+ if (!text) return json(res, 400, { error: 'text required to split' });
4467
+ // 新しいゴールが1枚増える=無料枠の対象。continue/rescope は増えないので通らない。
4468
+ const gate = await requireEntitlementGate(identityFor(req));
4469
+ if (!gate.allowed) return json(res, 402, { error: 'free-tier limit reached', blocked: gate.blocked });
4470
+ addGoalMessage(goal, { from: 'you', via: 'review', text });
4471
+ const born = createSplitGoal(goal, text, identityFor(req));
4472
+ return json(res, 200, { goal, outcome, splitInto: born.id, born });
4473
+ }
4474
+
4475
+ // answer: 聞かれただけ。ゴールは review のまま、ワーカーも起こさない(§1.3)。
4476
+ // 答えるのは**その作業をやった AI 本人**(goal.sessionId を resume)なので
4477
+ // 「なぜそうしたか」に答えられる。HTTP は待たせない——答えは会話に1発言
4478
+ // 積まれ、SSE の goal イベント(messageCount)で画面に出る。
4479
+ if (result.answers) {
4480
+ if (!text) return json(res, 400, { error: 'text required to answer' });
4481
+ addGoalMessage(goal, { from: 'you', via: 'review', text });
4482
+ json(res, 200, { goal, outcome, answering: true });
4483
+ answerGoalQuestion(goal, text);
4484
+ return;
4485
+ }
4486
+
4487
+ // rescope: チケットの文章が書き換わるのはここだけ。人が「そうじゃなくて、こう」と
4488
+ // 言い直した以上、古い依頼文を掲げたままにするのは嘘になる。元の言葉は会話に残る
4489
+ // (§1.1)ので失われない。
4490
+ // 断る判定はゴールに触る前に済ませる——400 を返しながら状態だけ動いていた、が
4491
+ // 一番たちの悪い壊れ方。
4492
+ if (result.rewritesText) {
4493
+ if (!text) return json(res, 400, { error: 'text required to rescope' });
4494
+ addGoalMessage(goal, { from: 'you', via: 'review', text });
4495
+ goal.text = text.slice(0, 8000);
4496
+ }
4228
4497
  goal.status = result.status;
4229
4498
  saveGoal(goal);
4230
- const task = queueReplyTask(goal, text || '差し戻し。修正してください。');
4231
- json(res, 200, { goal, task });
4499
+ // rescope はワーカーを起こさない——「いったん止めて考え直す」なので、走り出したら
4500
+ // 意味が無い。To Do の下で Start を待つ。
4501
+ if (!result.spawnsWorker) return json(res, 200, { goal, outcome });
4502
+
4503
+ const task = queueReplyTask(goal, text || '差し戻し。修正してください。', { from: text ? 'you' : 'manager', via: 'review' });
4504
+ json(res, 200, { goal, outcome, task });
4232
4505
  });
4233
4506
  return;
4234
4507
  }