@galda/cli 0.10.69 → 0.10.70

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, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath } from './lib.mjs';
24
- import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, 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, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, 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';
24
+ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession } from './lib.mjs';
25
25
  import { createSerialQueue } from './lib.mjs';
26
26
  import { isPrClosed } from './lib.mjs';
27
27
  import { collectProjectRules } from './lib.mjs';
@@ -611,6 +611,71 @@ function uniqueProjectId(name = 'project') {
611
611
  const logDir = join(DATA_DIR, 'chat-runs');
612
612
  mkdirSync(logDir, { recursive: true });
613
613
  const logFile = join(logDir, 'tasks.jsonl');
614
+ const internalQualityEventsFile = join(logDir, 'internal-quality-events.jsonl');
615
+ const internalQualityEventKeys = new Set();
616
+ function internalQualityKey(e) {
617
+ const subject = e.goalId != null ? `g${e.goalId}` : e.taskId != null ? `t${e.taskId}` : 'global';
618
+ const status = e.status ? `:${e.status}` : '';
619
+ return `${e.type}:${subject}${status}`;
620
+ }
621
+ function appendInternalQualityEvent(e, { once = true } = {}) {
622
+ const safe = {
623
+ type: String(e.type || ''),
624
+ at: e.at || new Date().toISOString(),
625
+ goalId: e.goalId ?? undefined,
626
+ taskId: e.taskId ?? undefined,
627
+ projectId: e.projectId ?? undefined,
628
+ status: e.status ?? undefined,
629
+ category: e.category ?? undefined,
630
+ agent: e.agent ?? undefined,
631
+ };
632
+ if (!safe.type) return;
633
+ const key = internalQualityKey(safe);
634
+ if (once && internalQualityEventKeys.has(key)) return;
635
+ internalQualityEventKeys.add(key);
636
+ try { appendFileSync(internalQualityEventsFile, JSON.stringify(safe) + '\n'); }
637
+ catch (err) { console.error(`[manager] internal quality event write failed (${err?.code || err})`); }
638
+ }
639
+ function readInternalQualityEvents() {
640
+ try {
641
+ return readFileSync(internalQualityEventsFile, 'utf8')
642
+ .split('\n')
643
+ .filter(Boolean)
644
+ .map((line) => JSON.parse(line));
645
+ } catch {
646
+ return [];
647
+ }
648
+ }
649
+ for (const e of readInternalQualityEvents()) internalQualityEventKeys.add(internalQualityKey(e));
650
+ function emitGoalQualityEvents(g) {
651
+ if (!g || g.id == null) return;
652
+ appendInternalQualityEvent({ type: 'goal_created', goalId: g.id, projectId: g.projectId, at: g.createdAt });
653
+ if (g.status === 'running' && g.startedAt) appendInternalQualityEvent({ type: 'goal_doing_started', goalId: g.id, projectId: g.projectId, at: g.startedAt });
654
+ if (g.status === 'review') appendInternalQualityEvent({ type: 'goal_review_reached', goalId: g.id, projectId: g.projectId });
655
+ if (['failed', 'blocked', 'interrupted', 'partial'].includes(g.status)) {
656
+ appendInternalQualityEvent({ type: 'goal_failed', goalId: g.id, projectId: g.projectId, status: g.status });
657
+ }
658
+ if (g.status === 'folded') appendInternalQualityEvent({ type: 'goal_folded', goalId: g.id, projectId: g.projectId });
659
+ if (g.status === 'reverted') appendInternalQualityEvent({ type: 'goal_reverted', goalId: g.id, projectId: g.projectId });
660
+ }
661
+ function emitTaskQualityEvents(t) {
662
+ if (!t || t.id == null) return;
663
+ appendInternalQualityEvent({ type: 'task_created', taskId: t.id, goalId: t.goalId, projectId: t.projectId, at: t.createdAt });
664
+ if (t.status === 'running') appendInternalQualityEvent({ type: 'task_doing_started', taskId: t.id, goalId: t.goalId, projectId: t.projectId, at: t.startedAt, agent: t.agent });
665
+ if (t.status === 'skipped') appendInternalQualityEvent({ type: 'task_skipped', taskId: t.id, goalId: t.goalId, projectId: t.projectId, at: t.finishedAt });
666
+ if (['done', 'failed', 'interrupted', 'skipped'].includes(t.status)) {
667
+ appendInternalQualityEvent({
668
+ type: 'task_finished',
669
+ taskId: t.id,
670
+ goalId: t.goalId,
671
+ projectId: t.projectId,
672
+ status: t.status,
673
+ category: classifyInternalQualityTaskError(t),
674
+ agent: t.agent,
675
+ at: t.finishedAt,
676
+ });
677
+ }
678
+ }
614
679
 
615
680
  // Per-project workflow columns (task 44: the Tasks-panel column editor).
616
681
  // Column names/order are cosmetic; every column still maps to one of the
@@ -791,7 +856,11 @@ function logAppend(line) {
791
856
  try { appendFileSync(logFile, line + '\n'); markSyncDirty(); return true; }
792
857
  catch (e) { console.error(`[manager] log write failed (${e?.code || e}) — state not persisted this write, UI still updated`); return false; }
793
858
  }
794
- function saveGoal(g) { logAppend(JSON.stringify({ ...g, kind: 'goal' })); sendGoalEvent({ ev: 'goal', goal: g }, g); }
859
+ function saveGoal(g) {
860
+ emitGoalQualityEvents(g);
861
+ logAppend(JSON.stringify({ ...g, kind: 'goal' }));
862
+ sendGoalEvent({ ev: 'goal', goal: g }, g);
863
+ }
795
864
  // Persist the orphan-goal reclassification computed right after replayQueueLog
796
865
  // above (deferred to here because saveGoal/send/sseClients don't exist yet at
797
866
  // that point in the module) — writes it to the restart-safe log so it settles
@@ -849,6 +918,7 @@ function readGoalMessages(goalId) {
849
918
  }
850
919
 
851
920
  function saveTask(t) {
921
+ emitTaskQualityEvents(t);
852
922
  // freeze BEFORE the destructure strips `activity`, and mark the task so the UI knows a
853
923
  // log exists without having to ask for one that was never written.
854
924
  if (freezeActivityLog(t)) t.activityLog = true;
@@ -1106,10 +1176,7 @@ function rebuildQueuesAfterAdopt() {
1106
1176
  reconcileQueuedGoalStarts(p.id);
1107
1177
  queues.get(p.id).waiting.push(...pending);
1108
1178
  pump(p.id);
1109
- for (const g of goals.filter((g) => g.projectId === p.id && g.status === 'stacked')) {
1110
- g.status = 'planning'; saveGoal(g);
1111
- planGoal(g).catch((e) => { g.status = 'failed'; g.prError = String(e).slice(0, 300); saveGoal(g); });
1112
- }
1179
+ startNextStacked(p.id);
1113
1180
  }
1114
1181
  }
1115
1182
 
@@ -1249,6 +1316,56 @@ function runWorkerAgent({ agent, ...args }) {
1249
1316
  return workerAgent(agent) === 'codex' ? runCodex(args) : runClaude(args);
1250
1317
  }
1251
1318
 
1319
+ function collectWorkerSkills(dir) {
1320
+ const home = homedir();
1321
+ return collectSkills([
1322
+ { dir: join(home, '.claude', 'skills'), source: 'user', kind: 'skill' },
1323
+ { dir: join(home, '.claude', 'commands'), source: 'user', kind: 'command' },
1324
+ { dir: join(dir, '.claude', 'skills'), source: 'project', kind: 'skill' },
1325
+ { dir: join(dir, '.claude', 'commands'), source: 'project', kind: 'command' },
1326
+ ]);
1327
+ }
1328
+
1329
+ function collectWorkerMcpSettings(dir) {
1330
+ const candidates = [
1331
+ '.mcp.json',
1332
+ '.claude/settings.json',
1333
+ '.claude/settings.local.json',
1334
+ '.codex/config.toml',
1335
+ ];
1336
+ return candidates
1337
+ .filter((name) => existsSync(join(dir, name)))
1338
+ .map((name) => `${name}: present`);
1339
+ }
1340
+
1341
+ function workerRunContext({ goal, task, agent, model, effort, permissionMode, cwd, resumeSession, plan }) {
1342
+ const projectRules = collectProjectRules(cwd, (dir, name) => {
1343
+ try { return readFileSync(join(dir, name), 'utf8'); } catch { return null; }
1344
+ }).map((f) => ({ name: f.name }));
1345
+ return {
1346
+ agentName: workerAgent(agent),
1347
+ model,
1348
+ effort,
1349
+ permissionMode,
1350
+ cwd,
1351
+ resumeSession,
1352
+ sessionMode: plan?.sessionMode,
1353
+ projectRules,
1354
+ skills: collectWorkerSkills(cwd),
1355
+ mcpSettings: collectWorkerMcpSettings(cwd),
1356
+ images: goal?.images ?? [],
1357
+ selectedSkill: task?.skill ?? goal?.skill ?? null,
1358
+ };
1359
+ }
1360
+
1361
+ function sendWorkerContextAct(task, context) {
1362
+ const rules = context.projectRules?.map((r) => r.name).join(', ') || 'CLAUDE.md/AGENTS.md not detected';
1363
+ const skills = context.skills?.length ? `${context.skills.length} available` : 'not detected';
1364
+ const mcp = context.mcpSettings?.join(', ') || 'not detected or not introspected';
1365
+ const session = context.resumeSession ? `resume ${context.resumeSession}` : (context.sessionMode || 'fresh');
1366
+ sendProcessAct(task, `Context inherited: agent=${context.agentName}, model=${context.model}, effort=${context.effort}, session=${session}, cwd=${context.cwd}, rules=${rules}, skills=${skills}, MCP/settings=${mcp}, images=${context.images?.length || 0}.`);
1367
+ }
1368
+
1252
1369
  // Track worker (and planner) children per goal so a goal can be cancelled by
1253
1370
  // SIGTERMing exactly its own child(ren) — never another goal's, and never a
1254
1371
  // user process. The child auto-unregisters on close.
@@ -1297,6 +1414,37 @@ function gitSync(cwd, args) {
1297
1414
  if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${(r.stderr || r.stdout || '').trim().slice(0, 200)}`);
1298
1415
  return r.stdout.trim();
1299
1416
  }
1417
+ function gitSyncOrNull(cwd, args) {
1418
+ try { return gitSync(cwd, args); } catch { return null; }
1419
+ }
1420
+ function gitStatusPaths(lines = []) {
1421
+ return [...new Set((lines ?? []).map((line) => String(line).slice(3).trim()).filter(Boolean))];
1422
+ }
1423
+ function newAttemptId(goal, task) {
1424
+ return `goal-${goal?.id ?? 'none'}-task-${task.id}-${Date.now()}-${randomBytes(3).toString('hex')}`;
1425
+ }
1426
+ function noteGoalTaskBaseline(goal, project, workDir, before) {
1427
+ if (!goal || goal.prBaseline) return;
1428
+ const repoRoot = gitSyncOrNull(workDir, ['rev-parse', '--show-toplevel']);
1429
+ const branch = repoRoot ? gitSyncOrNull(workDir, ['branch', '--show-current']) : null;
1430
+ const head = repoRoot ? gitSyncOrNull(workDir, ['rev-parse', 'HEAD']) : null;
1431
+ let aheadOfDefault = 0;
1432
+ if (repoRoot) {
1433
+ const defaultBranch = repoDefaultBranch(workDir);
1434
+ aheadOfDefault = Number(gitSyncOrNull(workDir, ['rev-list', '--count', `origin/${defaultBranch}..HEAD`])) || 0;
1435
+ }
1436
+ goal.prBaseline = {
1437
+ workDir,
1438
+ projectDir: project?.dir ?? null,
1439
+ sharedCheckout: goal.worktree === false || workDir === project?.dir,
1440
+ dirtyFiles: gitStatusPaths(before),
1441
+ branch,
1442
+ head,
1443
+ aheadOfDefault,
1444
+ capturedAt: new Date().toISOString(),
1445
+ };
1446
+ saveGoal(goal);
1447
+ }
1300
1448
  function repoDefaultBranch(dir) {
1301
1449
  try { return gitSync(dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).replace(/^origin\//, ''); }
1302
1450
  catch { return 'main'; }
@@ -1338,7 +1486,7 @@ function goalWorkDir(goal, project) {
1338
1486
  // at, which is what they want when they mean to watch it happen. On (the default)
1339
1487
  // keeps today's behaviour: a private copy per goal, so a run can never leave their
1340
1488
  // checkout half-edited.
1341
- if (goal?.worktree === false) return project.dir;
1489
+ if (resolveWorkspaceMode(goal?.workspaceMode ?? goal?.worktree) === 'direct-workspace') return project.dir;
1342
1490
  const cached = goalWorkDirs.get(goal.id);
1343
1491
  if (cached && existsSync(cached)) return cached;
1344
1492
  let repoRoot;
@@ -1358,6 +1506,15 @@ function goalWorkDir(goal, project) {
1358
1506
  goalWorkDirs.set(goal.id, wt);
1359
1507
  return wt;
1360
1508
  }
1509
+ function projectDirtyInfo(dir) {
1510
+ try {
1511
+ const raw = gitSync(dir, ['status', '--porcelain']);
1512
+ const paths = raw.split('\n').filter((line) => line.trim()).slice(0, 8);
1513
+ return paths.length ? { dirty: true, dirtyPaths: paths } : { dirty: false };
1514
+ } catch {
1515
+ return {};
1516
+ }
1517
+ }
1361
1518
  function removeGoalWorkDir(goal, project) {
1362
1519
  const wt = goalWorkDirs.get(goal.id);
1363
1520
  if (!wt) return;
@@ -1467,21 +1624,62 @@ function isProjectPaused(projectId) {
1467
1624
 
1468
1625
  // Goals stack up per project: while one goal is being planned or executed,
1469
1626
  // newer goals wait as 'stacked' (editable / deletable until picked up).
1470
- function goalActive(projectId) {
1471
- return goals.some((g) => g.projectId === projectId && ['planning', 'running'].includes(g.status));
1627
+ function projectParallelCap() {
1628
+ const slow = slowModeSettings();
1629
+ return slow.enabled ? Math.min(PROJECT_MAX_PARALLEL, slow.maxParallel) : PROJECT_MAX_PARALLEL;
1630
+ }
1631
+ function planningCount(projectId = null) {
1632
+ return goals.filter((g) => g.status === 'planning' && (projectId == null || g.projectId === projectId)).length;
1633
+ }
1634
+ function activeSlotCount(projectId = null) {
1635
+ if (projectId != null) return (queues.get(projectId)?.running.size ?? 0) + planningCount(projectId);
1636
+ return globalRunningCount() + planningCount();
1637
+ }
1638
+ function stackedStartSlots(projectId) {
1639
+ return Math.min(projectParallelCap() - activeSlotCount(projectId), GLOBAL_MAX_PARALLEL - activeSlotCount());
1640
+ }
1641
+ function startPlanningGoal(goal) {
1642
+ goal.status = 'planning';
1643
+ saveGoal(goal);
1644
+ planGoal(goal).catch((e) => {
1645
+ goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
1646
+ startNextStacked(goal.projectId);
1647
+ });
1472
1648
  }
1473
1649
  function startNextStacked(projectId) {
1474
1650
  if (isProjectPaused(projectId)) return; // paused → don't begin planning queued goals
1475
- if (goalActive(projectId)) return;
1476
- const next = goals.filter((g) => g.projectId === projectId && g.status === 'stacked')
1477
- .sort((a, b) => (b.prio ?? 0) - (a.prio ?? 0) || a.id - b.id)[0];
1478
- if (!next) return;
1479
- next.status = 'planning';
1480
- saveGoal(next);
1481
- planGoal(next).catch((e) => {
1482
- next.status = 'failed'; next.prError = String(e).slice(0, 300); saveGoal(next);
1483
- startNextStacked(projectId);
1484
- });
1651
+ let slots = stackedStartSlots(projectId);
1652
+ if (!canStartMore(0, slots)) return;
1653
+ const nextGoals = goals.filter((g) => g.projectId === projectId && g.status === 'stacked')
1654
+ .sort((a, b) => (b.prio ?? 0) - (a.prio ?? 0) || a.id - b.id);
1655
+ if (!nextGoals.length) return;
1656
+ for (const next of nextGoals) {
1657
+ if (slots <= 0) break;
1658
+ startPlanningGoal(next);
1659
+ slots--;
1660
+ }
1661
+ }
1662
+
1663
+ // Runtime queue invariant: persisted queued tasks are the source of truth. A live
1664
+ // board takeover, an interrupted request handler, or a process race must not leave
1665
+ // one saved as `queued` but absent from the in-memory waiting list. Repair that
1666
+ // drift before pumping, then fill every available planning slot from `stacked`.
1667
+ // Deliberately ignores `pending`: those are Later items, explicitly shelved until
1668
+ // the person taps Start.
1669
+ function reconcileAndPumpProject(projectId) {
1670
+ if (isProjectPaused(projectId)) return;
1671
+ const q = queues.get(projectId);
1672
+ if (!q) return;
1673
+ const known = new Set([...q.running, ...q.waiting].map((task) => task.id));
1674
+ const missing = tasks
1675
+ .filter((task) => task.projectId === projectId && task.status === 'queued' && !known.has(task.id))
1676
+ .sort((a, b) => a.num - b.num);
1677
+ if (missing.length) {
1678
+ q.waiting.push(...missing);
1679
+ console.log(`[manager] ${projectId}: repaired ${missing.length} queued task(s) missing from memory`);
1680
+ }
1681
+ startNextStacked(projectId);
1682
+ pump(projectId);
1485
1683
  }
1486
1684
 
1487
1685
  // Worker auth preflight state. The manager runs every worker by spawning a
@@ -1563,6 +1761,36 @@ async function relocalizeGeneralQuestion(goal, lang, res) {
1563
1761
 
1564
1762
  async function planGoal(goal) {
1565
1763
  const project = projects.find((p) => p.id === goal.projectId);
1764
+ const enqueueSingleTask = (entry = null) => {
1765
+ goal.question = null;
1766
+ goal.status = 'running';
1767
+ goal.entry = entry;
1768
+ const title = goalTaskTitle(goal.text);
1769
+ goal.plan = [title];
1770
+ saveGoal(goal);
1771
+ const base = tasks.filter((t) => t.projectId === goal.projectId).map((t) => t.num);
1772
+ const num = (base.length ? Math.max(...base) : 0) + 1;
1773
+ const task = buildGoalTask(goal, {
1774
+ id: nextId++, num,
1775
+ createdAt: new Date().toISOString(),
1776
+ });
1777
+ tasks.push(task);
1778
+ saveTask(task);
1779
+ queues.get(goal.projectId).waiting.push(task);
1780
+ pump(goal.projectId);
1781
+ };
1782
+
1783
+ // Stability roadmap 03/08: normal implementation requests bypass the intake
1784
+ // planner entirely. The user's original text, attachments, selected
1785
+ // Agent/Model/effort, and project settings already live on the goal and flow
1786
+ // straight into buildGoalTask()/runTask(). Only explicit Plan mode keeps the
1787
+ // planner hop for clarification/entry resolution before the worker receives
1788
+ // the same verbatim request.
1789
+ if (!shouldUsePlannerForGoal(goal)) {
1790
+ enqueueSingleTask(null);
1791
+ return;
1792
+ }
1793
+
1566
1794
  const prompt = [
1567
1795
  'あなたはユーザーの依頼を受けるプロダクトマネージャ。以下のユーザー依頼を、ユーザーから見た「成果物(トピック)」の単位で整理する。エンジニアリングの工程には分解しない。',
1568
1796
  '【言語】ユーザーが読む文字列(各タスクの title、確認の question / options)は、末尾の「依頼:」と同じ言語で書く。英語の依頼には英語で、日本語の依頼には日本語で返す(例: "test" → 英語、"テスト" → 日本語)。detail(worker への指示)は言語自由。',
@@ -1605,12 +1833,12 @@ async function planGoal(goal) {
1605
1833
  // SLOWER — the cost is claude cold-start + repo reading, not inference, and
1606
1834
  // haiku took more tool round-trips. The instant Planning… row is the real
1607
1835
  // "it's queued" feedback; detailed tasks follow in ~13s.)
1608
- const { result, sessionId } = await runWorkerAgent({ agent: goal.agent, prompt, cwd: workDir, tools: 'Read,Glob,Grep', model: goal.model, effort: goal.effort, onChild: (c) => trackGoalWorker(goal.id, c) });
1836
+ const { result, sessionId } = await runWorkerAgent({ agent: goal.agent, prompt, cwd: workDir, tools: 'Read,Glob,Grep', permissionMode: 'plan', model: goal.model, effort: goal.effort, onChild: (c) => trackGoalWorker(goal.id, c) });
1609
1837
  if (goal.cancelled || !goals.some((g) => g.id === goal.id)) return; // deleted/cancelled while planning
1610
1838
  // Warm handoff: the planner already read the repo in this session, so the
1611
1839
  // goal's workers resume it instead of cold-starting a fresh claude each
1612
1840
  // task (faster first token, no re-briefing tokens).
1613
- if (sessionId) goal.sessionId = sessionId;
1841
+ if (sessionId) setGoalAgentSession(goal, goal.agent, sessionId);
1614
1842
  // Smart intake: the planner may return a clarifying question instead of tasks
1615
1843
  // when the goal is genuinely ambiguous. Surface it as a "needsInput" goal (a
1616
1844
  // ❓ card in To Do) and wait for the answer — no tasks created yet, no worker.
@@ -1634,7 +1862,6 @@ async function planGoal(goal) {
1634
1862
  // decomposition the LLM may still emit into user-facing deliverables (Masa:
1635
1863
  // プログラムエンジニアのタスク単位で分解しないで — 1メッセージ=1トピック既定).
1636
1864
  plan.tasks = refinePlanTasks(plan.tasks, { goalText: goal.text });
1637
- goal.status = 'running';
1638
1865
  goal.entry = plan.entry;
1639
1866
  // Demoted planner (Masa 2026-07-21, P0b): the planner LLM still resolves the
1640
1867
  // entry URL, asks for clarification, and warms the session — but it no longer
@@ -1646,19 +1873,7 @@ async function planGoal(goal) {
1646
1873
  // what "correct" means for it, and it did so at random (see the prompt note
1647
1874
  // above). Nothing here sets one, so `entry` now only survives as the page a
1648
1875
  // future explicit, user-written condition would open.
1649
- const title = goalTaskTitle(goal.text);
1650
- goal.plan = [title];
1651
- saveGoal(goal);
1652
- const base = tasks.filter((t) => t.projectId === goal.projectId).map((t) => t.num);
1653
- const num = (base.length ? Math.max(...base) : 0) + 1;
1654
- const task = buildGoalTask(goal, {
1655
- id: nextId++, num,
1656
- createdAt: new Date().toISOString(),
1657
- });
1658
- tasks.push(task);
1659
- saveTask(task);
1660
- queues.get(goal.projectId).waiting.push(task);
1661
- pump(goal.projectId);
1876
+ enqueueSingleTask(plan.entry);
1662
1877
  }
1663
1878
 
1664
1879
  // Author the executable check once per task (the only LLM cost of
@@ -1712,8 +1927,8 @@ function pump(projectId) {
1712
1927
  // queued tasks are actually safe to start within that many slots: at most
1713
1928
  // one per goalId not already running, so a goal's own next task never
1714
1929
  // jumps ahead of its still-running sibling.
1715
- const projectCap = slow.enabled ? Math.min(PROJECT_MAX_PARALLEL, slow.maxParallel) : PROJECT_MAX_PARALLEL;
1716
- const slots = Math.min(projectCap - q.running.size, GLOBAL_MAX_PARALLEL - globalRunningCount());
1930
+ const projectCap = projectParallelCap();
1931
+ const slots = Math.min(projectCap - activeSlotCount(projectId), GLOBAL_MAX_PARALLEL - activeSlotCount());
1717
1932
  if (!canStartMore(0, slots)) return;
1718
1933
  if (slow.enabled) {
1719
1934
  const last = slowModeLastStart.get(projectId) ?? 0;
@@ -1846,6 +2061,7 @@ function queueBudgetContinuation(goal, task, budget) {
1846
2061
  goal.contextHandoff = buildContextHandoffSummary({ goal, tasks: tasks.filter((t) => t.goalId === goal.id) });
1847
2062
  goal.contextHandoffAt = now;
1848
2063
  goal.sessionId = null;
2064
+ goal.sessions = {};
1849
2065
  goal.runElapsedMs = 0;
1850
2066
  goal.runTokens = 0;
1851
2067
  goal.runAttempts = 0;
@@ -1975,17 +2191,17 @@ function resumeProject(projectId) {
1975
2191
  }
1976
2192
 
1977
2193
  // Slack風スレッド返信タスクの生成(/api/goals/:id/reply と /api/goals/:id/dismiss で共有)。
1978
- // runTask()のtask.reply分岐がgoal.sessionIdをresumeに渡して同じ会話を続ける。
2194
+ // runTask()のtask.reply分岐が各agentのsessionをresumeに渡して同じ会話を続ける。
1979
2195
  function applyReplyWorker(goal, requested = {}) {
1980
2196
  if (requested.agent == null) return { ok: true };
1981
2197
  if (!WORKER_AGENTS.includes(requested.agent)) return { ok: false, error: 'unsupported agent' };
1982
2198
  const agent = workerAgent(requested.agent);
1983
2199
  if (!AVAILABLE_AGENTS.includes(agent)) return { ok: false, error: `${agent} is not connected on this device` };
1984
2200
  const switchedAgent = agent !== goal.agent;
1985
- goal.agent = agent;
2201
+ if (switchedAgent) switchGoalAgentSession(goal, agent);
2202
+ else goal.agent = agent;
1986
2203
  goal.model = workerModel(agent, requested.model);
1987
2204
  goal.effort = workerEffort(agent, requested.effort);
1988
- if (switchedAgent) goal.sessionId = null;
1989
2205
  return { ok: true };
1990
2206
  }
1991
2207
 
@@ -2002,7 +2218,7 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
2002
2218
  usage: goal.runUsage,
2003
2219
  runTokens: goal.runTokens,
2004
2220
  runAttempts: goal.runAttempts,
2005
- previousSession: Boolean(goal.sessionId || goal.contextHandoff),
2221
+ previousSession: Boolean(goalSessionFor(goal, goal.agent) || goal.contextHandoff),
2006
2222
  });
2007
2223
  if (shouldFreshSessionForPlan(goal.executionPlan)) refreshGoalHandoff(goal);
2008
2224
  saveGoal(goal);
@@ -2011,6 +2227,8 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
2011
2227
  id: nextId++, num: (nums.length ? Math.max(...nums) : 0) + 1,
2012
2228
  goalId: goal.id, projectId: goal.projectId, reply: true, model: goal.model, effort: goal.effort, agent: goal.agent,
2013
2229
  title: text.replace(/\s+/g, ' ').slice(0, 60), detail: promptText.slice(0, 4000),
2230
+ workspaceMode: resolveWorkspaceMode(goal.workspaceMode ?? goal.worktree),
2231
+ worktree: goal.worktree === false ? false : undefined,
2014
2232
  passCondition: null, status: 'queued', createdAt: new Date().toISOString(), priority: '中',
2015
2233
  result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
2016
2234
  };
@@ -2029,8 +2247,8 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
2029
2247
  // follow-up reply to an already-running goal must stay DOING (its clock stands).
2030
2248
  if (goal.status === 'running' && ![...q.running].some((t) => t.goalId === goal.id)) {
2031
2249
  const slow = slowModeSettings();
2032
- const projectCap = slow.enabled ? Math.min(PROJECT_MAX_PARALLEL, slow.maxParallel) : PROJECT_MAX_PARALLEL;
2033
- const slots = Math.min(projectCap - q.running.size, GLOBAL_MAX_PARALLEL - globalRunningCount());
2250
+ const projectCap = projectParallelCap();
2251
+ const slots = Math.min(projectCap - activeSlotCount(goal.projectId), GLOBAL_MAX_PARALLEL - activeSlotCount());
2034
2252
  const runningGoalIds = [...q.running].map((t) => t.goalId);
2035
2253
  const eligible = q.waiting.filter((candidate) => workerCanStart({ agent: candidate.agent, auth: workerAuth }));
2036
2254
  const startsNow = workerCanStart({ agent: task.agent, auth: workerAuth })
@@ -2217,6 +2435,39 @@ function readRunDeclaration(task, workDir) {
2217
2435
  if (run.compare) sendProcessAct(task, `Worker declared a before/after pair: ${run.compare.join(' → ')}`);
2218
2436
  }
2219
2437
 
2438
+ // A direct/shared workspace can still contain a declaration left by an older
2439
+ // run (for example when that run was interrupted before readRunDeclaration).
2440
+ // Remove it before this task starts so only a file created by THIS worker can
2441
+ // name the review card or supply its Open it target.
2442
+ function clearStaleRunDeclaration(task, workDir) {
2443
+ const file = join(workDir, RUN_DECL_FILE);
2444
+ if (!existsSync(file)) return;
2445
+ try {
2446
+ unlinkSync(file);
2447
+ sendProcessAct(task, `Removed a stale ${RUN_DECL_FILE} before starting this task.`);
2448
+ } catch (err) {
2449
+ sendProcessAct(task, `Could not remove stale ${RUN_DECL_FILE}: ${String(err?.code ?? err).slice(0, 80)}`);
2450
+ }
2451
+ }
2452
+
2453
+ function inheritPreviousRunDeclaration(task, goalId) {
2454
+ if (task.run || !goalId) return;
2455
+ const prev = [...tasks].reverse().find((t) => t.goalId === goalId && t.id !== task.id && t.run && (t.run.cmd || t.run.url));
2456
+ if (!prev) return;
2457
+ const note = [
2458
+ 'Reusing the previous preview because this follow-up did not declare a new preview.',
2459
+ prev.run.note || prev.run.cmd || prev.run.url || '',
2460
+ ].filter(Boolean).join(' ');
2461
+ task.run = {
2462
+ ...prev.run,
2463
+ note: note.slice(0, 300),
2464
+ inherited: true,
2465
+ inheritedFromTaskId: prev.id,
2466
+ inheritedFromAttemptId: prev.attemptId ?? null,
2467
+ };
2468
+ sendProcessAct(task, `No new run declaration; explicitly reusing preview from task ${prev.id}.`);
2469
+ }
2470
+
2220
2471
  // A declared pair only survives if both pictures actually arrived. The card
2221
2472
  // draws a red box over whatever differs between the two, so a name matching no
2222
2473
  // collected shot would leave the reviewer half a comparison — show the plain
@@ -2343,12 +2594,22 @@ async function runTask(task) {
2343
2594
  saveTask(task);
2344
2595
  sendProcessAct(task, `Working on "${task.title}"${task.reply ? ' as a follow-up' : ''}.`);
2345
2596
 
2346
- // Everything the worker touches happens in the goal's own worktree (clean
2347
- // base from origin/main, isolated from other goals and the user's real dir).
2597
+ // The workspace mode is chosen at goal creation and then held through every
2598
+ // worker/review/PR step.
2348
2599
  const workDir = goal ? goalWorkDir(goal, project) : project.dir;
2349
2600
  const wproject = { ...project, dir: workDir };
2350
- sendProcessAct(task, `Prepared isolated worktree: ${workDir}`);
2601
+ const workspaceMode = resolveWorkspaceMode(task.workspaceMode ?? goal?.workspaceMode ?? goal?.worktree);
2602
+ sendProcessAct(task, workspaceMode === 'direct-workspace'
2603
+ ? `Using direct workspace: ${workDir}`
2604
+ : `Prepared isolated worktree: ${workDir}`);
2605
+ if (goal?.workspaceWarning) sendProcessAct(task, goal.workspaceWarning);
2606
+ clearStaleRunDeclaration(task, workDir);
2351
2607
  const before = await gitChanges(workDir);
2608
+ task.attemptId = task.attemptId || newAttemptId(goal, task);
2609
+ if (goal) {
2610
+ goal.latestAttemptId = task.attemptId;
2611
+ noteGoalTaskBaseline(goal, project, workDir, before);
2612
+ }
2352
2613
  sendProcessAct(task, `Checked starting git state (${before.length} changed file${before.length === 1 ? '' : 's'} before this task).`);
2353
2614
  // Plan mode makes no edits, so there is nothing to verify — never author a
2354
2615
  // check or boot an ephemeral instance for it.
@@ -2362,25 +2623,31 @@ async function runTask(task) {
2362
2623
  // same conversation, no re-briefing (and no wasted tokens).
2363
2624
  if (task.reply) {
2364
2625
  const plan = goal?.executionPlan;
2365
- const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goal?.sessionId ?? undefined);
2626
+ const runAgent = workerAgent(task.agent ?? goal?.agent);
2627
+ const runModel = task.model ?? goal?.model;
2628
+ const runEffort = task.effort ?? goal?.effort;
2629
+ const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goalSessionFor(goal, runAgent) ?? undefined);
2630
+ const replyPermissionMode = permissionModeFor(goal?.mode === 'plan' && goal?.planPhase !== 'executing' ? 'plan' : undefined);
2631
+ const runContext = workerRunContext({ goal, task, agent: runAgent, model: runModel, effort: runEffort, permissionMode: replyPermissionMode, cwd: workDir, resumeSession, plan });
2366
2632
  // A follow-up is a full worker run, so it needs the same deliverable contract as
2367
2633
  // the first attempt (artifacts, run declaration, tests, language). The old short
2368
2634
  // prompt omitted those rules, which let a reply finish with prose only and left
2369
2635
  // Review showing the original task's stale "Skipped." result.
2370
2636
  const prompt = [
2371
2637
  managerHandoffPrompt(goal, plan),
2372
- workerPrompt(task, goal, null, workerAgent(task.agent ?? goal?.agent)),
2638
+ workerPrompt(task, goal, null, runAgent, runContext),
2373
2639
  '(User follow-up on the goal you worked on. Apply it to the same goal and deliver the requested result.)',
2374
2640
  ].filter(Boolean).join('\n\n');
2375
2641
  const callT0 = Date.now();
2376
- sendProcessAct(task, `Starting ${workerAgent(task.agent ?? goal?.agent) === 'codex' ? 'Codex' : 'Claude Code'} worker with the existing goal context.`);
2642
+ sendWorkerContextAct(task, runContext);
2643
+ sendProcessAct(task, `Starting ${runAgent === 'codex' ? 'Codex' : 'Claude Code'} worker with the existing goal context.`);
2377
2644
  const r = await runWorkerAgent({
2378
- agent: task.agent ?? goal?.agent,
2645
+ agent: runAgent,
2379
2646
  prompt, cwd: workDir, tools: WORKER_TOOLS, resume: resumeSession,
2380
2647
  // A reply that revises a plan-review must stay plan mode (no edits); a
2381
2648
  // reply/rework on a normal goal stays acceptEdits as before.
2382
- permissionMode: permissionModeFor(goal?.mode === 'plan' && goal?.planPhase !== 'executing' ? 'plan' : undefined),
2383
- model: task.model ?? goal?.model, effort: task.effort ?? goal?.effort, onEvent: (line) => sendAct(task, line),
2649
+ permissionMode: replyPermissionMode,
2650
+ model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
2384
2651
  images: goal?.images,
2385
2652
  onTodos: (todos) => sendTodos(task, todos),
2386
2653
  onChild: (c) => trackGoalWorker(goal?.id, c),
@@ -2397,11 +2664,19 @@ async function runTask(task) {
2397
2664
  task.status = r.code === 0 ? 'done' : 'failed';
2398
2665
  task.result = r.result.slice(0, RESULT_MAX);
2399
2666
  task.usage = sumUsage([r.usage]);
2400
- if (r.sessionId && goal) { goal.sessionId = r.sessionId; saveGoal(goal); }
2667
+ if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
2668
+ // A follow-up owns its own evidence too. Previously only the initial task
2669
+ // consumed .manager-run.json, so replies either left the new declaration
2670
+ // behind or Review kept showing an older task's title/URL.
2671
+ readRunDeclaration(task, workDir);
2672
+ collectWorkerShots(task, workDir);
2673
+ reconcileComparePair(task);
2674
+ inheritPreviousRunDeclaration(task, goal?.id);
2401
2675
  const afterR = await gitChanges(workDir);
2402
2676
  task.changedFiles = afterR.filter((l) => !before.includes(l)).map((l) => l.slice(3));
2403
2677
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this follow-up.`);
2404
2678
  task.finishedAt = new Date().toISOString();
2679
+ task.resultAttemptId = task.attemptId;
2405
2680
  saveTask(task);
2406
2681
  // A rework (comment/dismiss) must re-enter the verify gate — re-test,
2407
2682
  // re-capture proof, update the SAME PR (openPR existingBranch) — and land
@@ -2429,14 +2704,19 @@ async function runTask(task) {
2429
2704
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2430
2705
  let callT0 = Date.now();
2431
2706
  const plan = goal?.executionPlan;
2432
- const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goal?.sessionId ?? undefined);
2433
- const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, null, workerAgent(task.agent ?? goal?.agent))].filter(Boolean).join('\n\n');
2434
- sendProcessAct(task, `Starting ${workerAgent(task.agent ?? goal?.agent) === 'codex' ? 'Codex' : 'Claude Code'} worker (attempt ${attempt}/${maxAttempts}).`);
2707
+ const runAgent = workerAgent(task.agent ?? goal?.agent);
2708
+ const runModel = task.model ?? goal?.model;
2709
+ const runEffort = task.effort ?? goal?.effort;
2710
+ const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goalSessionFor(goal, runAgent) ?? undefined);
2711
+ const runContext = workerRunContext({ goal, task, agent: runAgent, model: runModel, effort: runEffort, permissionMode, cwd: workDir, resumeSession, plan });
2712
+ const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, null, runAgent, runContext)].filter(Boolean).join('\n\n');
2713
+ sendWorkerContextAct(task, runContext);
2714
+ sendProcessAct(task, `Starting ${runAgent === 'codex' ? 'Codex' : 'Claude Code'} worker (attempt ${attempt}/${maxAttempts}).`);
2435
2715
  let r = await runWorkerAgent({
2436
- agent: task.agent ?? goal?.agent,
2716
+ agent: runAgent,
2437
2717
  prompt, cwd: workDir, tools: WORKER_TOOLS,
2438
2718
  resume: resumeSession, permissionMode,
2439
- model: task.model ?? goal?.model, effort: task.effort ?? goal?.effort, onEvent: (line) => sendAct(task, line),
2719
+ model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
2440
2720
  images: goal?.images,
2441
2721
  onTodos: (todos) => sendTodos(task, todos),
2442
2722
  onChild: (c) => trackGoalWorker(goal?.id, c),
@@ -2451,18 +2731,21 @@ async function runTask(task) {
2451
2731
  // "succeeded".
2452
2732
  let budget = trackGoalRunUsage(goal, Date.now() - callT0, r.usage);
2453
2733
  if (budget?.exceeded) { blockForBudget(goal, task, budget); return; }
2454
- if (r.code !== 0 && goal?.sessionId) {
2734
+ if (r.code !== 0 && goalSessionFor(goal, runAgent)) {
2455
2735
  // The warm session can expire or be cleaned up between tasks; one cold
2456
2736
  // retry distinguishes "resume broke" from "the task itself failed".
2457
2737
  sendAct(task, 'warm session unavailable — retrying with a fresh session…');
2458
- goal.sessionId = null; saveGoal(goal);
2738
+ if (goal?.sessions && typeof goal.sessions === 'object') delete goal.sessions[runAgent];
2739
+ if (goal) { goal.sessionId = null; goal.sessionAgent = runAgent; saveGoal(goal); }
2459
2740
  callT0 = Date.now();
2460
2741
  sendProcessAct(task, 'Starting a fresh worker session because the warm session could not resume.');
2742
+ const freshContext = workerRunContext({ goal, task, agent: runAgent, model: runModel, effort: runEffort, permissionMode, cwd: workDir, resumeSession: undefined, plan: { ...plan, sessionMode: 'cold' } });
2743
+ sendWorkerContextAct(task, freshContext);
2461
2744
  r = await runWorkerAgent({
2462
- agent: task.agent ?? goal?.agent,
2463
- prompt: workerPrompt(task, goal, null, workerAgent(task.agent ?? goal?.agent)), cwd: workDir, tools: WORKER_TOOLS,
2745
+ agent: runAgent,
2746
+ prompt: workerPrompt(task, goal, null, runAgent, freshContext), cwd: workDir, tools: WORKER_TOOLS,
2464
2747
  permissionMode,
2465
- model: task.model ?? goal?.model, effort: task.effort ?? goal?.effort, onEvent: (line) => sendAct(task, line),
2748
+ model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
2466
2749
  images: goal?.images,
2467
2750
  onTodos: (todos) => sendTodos(task, todos),
2468
2751
  onChild: (c) => trackGoalWorker(goal?.id, c),
@@ -2478,7 +2761,7 @@ async function runTask(task) {
2478
2761
  sendProcessAct(task, timedOut
2479
2762
  ? `Worker attempt ${attempt} was stopped at the ${Math.round(WORKER_TIMEOUT_MS / 60000)}-minute run limit (exit ${code}).`
2480
2763
  : `Worker attempt ${attempt} finished with exit code ${code}.`);
2481
- if (r.sessionId && goal) { goal.sessionId = r.sessionId; saveGoal(goal); }
2764
+ if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
2482
2765
  if (!entryUrl) break;
2483
2766
  try {
2484
2767
  if (!verify) {
@@ -2589,6 +2872,7 @@ async function runTask(task) {
2589
2872
  task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
2590
2873
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
2591
2874
  task.finishedAt = new Date().toISOString();
2875
+ task.resultAttemptId = task.attemptId;
2592
2876
  saveTask(task);
2593
2877
  sendProcessAct(task, `Task saved as ${task.status}.`);
2594
2878
 
@@ -2919,6 +3203,23 @@ function goalChangedFileUnion(goalId) {
2919
3203
  return [...new Set(tasks.filter((t) => t.goalId === goalId).flatMap((t) => t.changedFiles ?? []))]
2920
3204
  .filter((f) => f && !SCRATCH_FILE_RE.test(f));
2921
3205
  }
3206
+ function computeWorktreeChangeLocations(dir) {
3207
+ try {
3208
+ return parseGitUnifiedDiffLocations(gitOut(dir, ['diff', '--unified=0']) ?? '');
3209
+ } catch {
3210
+ return [];
3211
+ }
3212
+ }
3213
+ function foldCandidateShape(g) {
3214
+ return {
3215
+ id: g.id,
3216
+ changedFiles: goalChangedFileUnion(g.id),
3217
+ changeLocations: g.changeLocations ?? [],
3218
+ text: g.text,
3219
+ title: g.reviewSummary?.title ?? g.plan?.[0],
3220
+ summary: g.reviewSummary?.changed ?? '',
3221
+ };
3222
+ }
2922
3223
  const CONFLICT_CANDIDATE_STATUSES = ['running', 'review', 'blocked', 'partial', 'retesting'];
2923
3224
  function updateGoalConflicts(goal) {
2924
3225
  const files = goalChangedFileUnion(goal.id);
@@ -2949,6 +3250,10 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
2949
3250
  if (!siblings.length || !siblings.every((t) => ['done', 'failed', 'interrupted', 'skipped'].includes(t.status))) return;
2950
3251
  if (!['running', 'partial'].includes(goal.status)) { startNextStacked(goal.projectId); return; }
2951
3252
  goalAct(`Close-out started for goal-${goal.id}: ${siblings.length} task${siblings.length === 1 ? '' : 's'} ready for final checks.`);
3253
+ if (activityTask?.attemptId) {
3254
+ goal.closeoutAttemptId = activityTask.attemptId;
3255
+ goal.latestAttemptId = activityTask.attemptId;
3256
+ }
2952
3257
  // Plan mode: the worker produced a PLAN and made no edits. Land it in Review
2953
3258
  // as a plan-review (no PR, no proof gate — nothing changed) with the plan
2954
3259
  // body captured, so the reviewer sees "PLAN — approve to execute". Approving
@@ -3015,15 +3320,16 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
3015
3320
  // (that would be an instant loop, since the target still sits in review with
3016
3321
  // the overlapping files). The card then opens its own PR and both stay in
3017
3322
  // Review, flagged via conflictsWith — the visible collision Masa asked for.
3018
- const foldTarget = (conflicts.length && !goal.noFold)
3323
+ goal.changeLocations = computeWorktreeChangeLocations(wdir);
3324
+ const foldTarget = (autoFoldReviewsEnabled(project) && conflicts.length && !goal.noFold)
3019
3325
  ? goals.find((g) => g.id === pickFoldTarget(
3020
- { id: goal.id, changedFiles: goalChangedFileUnion(goal.id) },
3326
+ foldCandidateShape(goal),
3021
3327
  goals
3022
3328
  .filter((c) => c.id !== goal.id
3023
3329
  && c.projectId === goal.projectId
3024
3330
  && c.status === 'review'
3025
3331
  && (c.owner ?? 'owner') === (goal.owner ?? 'owner'))
3026
- .map((c) => ({ id: c.id, changedFiles: goalChangedFileUnion(c.id) })),
3332
+ .map(foldCandidateShape),
3027
3333
  ))
3028
3334
  : null;
3029
3335
  if (foldTarget) {
@@ -3051,6 +3357,7 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
3051
3357
  // time the PR is opened, not only on the review card (Masa 2026-07-16).
3052
3358
  goalAct('Writing the review summary from the worker report and diff.');
3053
3359
  goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
3360
+ if (activityTask?.attemptId) goal.reviewSummaryAttemptId = activityTask.attemptId;
3054
3361
  goalAct('Checking changed files and preparing the PR branch if code changed.');
3055
3362
  await createGoalPR(goal, prProject, siblings, reviewDefinition, activityTask);
3056
3363
  // goal 427 §4: classify the change's risk from the SAME changed-files/diff
@@ -3059,6 +3366,7 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
3059
3366
  // string/regex matching (classifyChangeRisk in lib.mjs).
3060
3367
  goal.risk = computeGoalRisk(goal, prProject, siblings);
3061
3368
  goal.summary = buildGoalSummary({ goalText: goal.text, tasks: siblings });
3369
+ if (activityTask?.attemptId) goal.summaryAttemptId = activityTask.attemptId;
3062
3370
  await verifyGate(goal, prProject, siblings, project, activityTask);
3063
3371
  // A high-risk change (secrets/auth/payment/db-migration/destructive) must
3064
3372
  // never slip past a human — verifyGate's nextGoalStatus never returns
@@ -3070,8 +3378,10 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
3070
3378
  if (goal.risk?.shouldPause && !['blocked', 'running'].includes(goal.status)) goal.status = 'review';
3071
3379
  if (['review', 'done', 'blocked', 'archived'].includes(goal.status)) {
3072
3380
  refreshGoalHandoff(goal);
3073
- if (goal.sessionId) {
3381
+ if (goalSessionFor(goal, goal.agent) && shouldFreshSessionForPlan(goal.executionPlan)) {
3382
+ if (goal.sessions && typeof goal.sessions === 'object') delete goal.sessions[goal.agent];
3074
3383
  goal.sessionId = null;
3384
+ goal.sessionAgent = goal.agent;
3075
3385
  goal.sessionClosedAt = new Date().toISOString();
3076
3386
  goal.executionPlan = {
3077
3387
  ...(goal.executionPlan ?? {}),
@@ -3098,6 +3408,13 @@ function computeGoalRisk(goal, project, siblings) {
3098
3408
  return { level: r.level, shouldPause: r.shouldPause, categories: r.categories, sensitiveFiles: r.sensitiveFiles };
3099
3409
  }
3100
3410
 
3411
+ function prSafetyIssue(goal, project, files) {
3412
+ const baseline = goal?.prBaseline;
3413
+ const currentBranch = gitSyncOrNull(project.dir, ['branch', '--show-current']);
3414
+ const lines = spawnSync('git', ['-C', project.dir, 'status', '--porcelain', '-uall'], { encoding: 'utf8' }).stdout?.split('\n').filter(Boolean) ?? [];
3415
+ return classifyPrSafety({ baseline, files, currentFiles: gitStatusPaths(lines), currentBranch });
3416
+ }
3417
+
3101
3418
  // The Manager-verified review gate (shared by goal completion and /reverify):
3102
3419
  // run OUR OWN tests, generate a plain-language headline, and require proof for a
3103
3420
  // UI change — capturing it (3 tries) if missing. Any failure → 'blocked' (shown
@@ -3270,7 +3587,9 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
3270
3587
  : gate.proofAdvisory === 'unconfirmed'
3271
3588
  ? 'proof は撮れましたが、依頼対象のUIを自動確認できませんでした — 目視で確認してください'
3272
3589
  : null;
3590
+ goal.verificationInfraError = verificationInfraErrorFrom({ testResult: goal.testResult, proofNote: goal.proofNote });
3273
3591
  if (goal.proofNote) goalAct(`Proof advisory (not blocking): ${goal.proofNote}`);
3592
+ if (goal.verificationInfraError) goalAct(`Verification infrastructure issue (not blocking): ${goal.verificationInfraError.detail}`);
3274
3593
  if (gate.blocked) {
3275
3594
  if (!testsFailing) {
3276
3595
  recordGoalFailure(goal, {
@@ -3314,6 +3633,14 @@ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT
3314
3633
  // round can't actually hit this — its files are a superset of the ones that
3315
3634
  // already got it a PR — but the guard is harmless either way.)
3316
3635
  if (!files.length) { goal.pr = null; goal.prError = null; saveGoal(goal); goalAct('No code changes detected, so PR creation is skipped.'); return; }
3636
+ const unsafe = prSafetyIssue(goal, project, files);
3637
+ if (unsafe) {
3638
+ goal.prError = unsafe.message.slice(0, 300);
3639
+ goal.prSafety = { code: unsafe.code, message: unsafe.message, at: new Date().toISOString() };
3640
+ goalAct(unsafe.message);
3641
+ saveGoal(goal);
3642
+ return;
3643
+ }
3317
3644
  // Rework: the goal already has an open PR from an earlier finish. Push onto
3318
3645
  // that same branch instead of opening a second PR for the goal (see openPR's
3319
3646
  // existingBranch/existingPrUrl mode in pr.mjs).
@@ -3365,6 +3692,12 @@ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT
3365
3692
  goal.pr = url;
3366
3693
  goal.prBranch = branch;
3367
3694
  goal.prError = null;
3695
+ goal.prErrorClass = null;
3696
+ goal.prRepair = null;
3697
+ goal.stalePr = null;
3698
+ goal.prSafety = null;
3699
+ if (activityTask?.attemptId) goal.prAttemptId = activityTask.attemptId;
3700
+ if (!goal.prAttemptId) goal.prAttemptId = goal.latestAttemptId ?? goalTasks.map((t) => t.attemptId || `task-${t.id}`).filter(Boolean).at(-1) ?? null;
3368
3701
  goalAct(`PR ready: ${url}`);
3369
3702
  } catch (e) {
3370
3703
  // A rework push failing shouldn't blow away an already-working PR url —
@@ -3451,6 +3784,16 @@ const server = createServer(async (req, res) => {
3451
3784
  return res.end(readFileSync(file));
3452
3785
  }
3453
3786
 
3787
+ if (url.pathname === '/api/internal/quality-metrics' && req.method === 'GET') {
3788
+ if (identityFor(req) !== 'owner') return json(res, 403, { error: 'owner/debug only' });
3789
+ return json(res, 200, computeInternalQualityMetrics({
3790
+ goals,
3791
+ tasks,
3792
+ events: readInternalQualityEvents(),
3793
+ nowMs: Date.now(),
3794
+ }));
3795
+ }
3796
+
3454
3797
  if (url.pathname === '/api/state') {
3455
3798
  await syncAllExternal();
3456
3799
  await syncGoalMerges();
@@ -3496,7 +3839,8 @@ const server = createServer(async (req, res) => {
3496
3839
  const projectsWithBranch = projects.map((p) => {
3497
3840
  let branch = null;
3498
3841
  try { branch = gitSync(p.dir, ['rev-parse', '--abbrev-ref', 'HEAD']); } catch { /* not a repo → no branch, and the bar shows none */ }
3499
- return branch ? { ...p, branch } : p;
3842
+ const dirty = projectDirtyInfo(p.dir);
3843
+ return { ...p, ...(branch ? { branch } : {}), ...dirty };
3500
3844
  });
3501
3845
  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() });
3502
3846
  }
@@ -3647,7 +3991,7 @@ const server = createServer(async (req, res) => {
3647
3991
  const model = CODEX_MODELS[0];
3648
3992
  for (const task of queued) { task.agent = 'codex'; task.model = model; task.sessionId = null; saveTask(task); }
3649
3993
  for (const goal of goals.filter((item) => goalIds.has(item.id))) {
3650
- goal.agent = 'codex'; goal.model = model; goal.sessionId = null; goal.startedAt = null; saveGoal(goal);
3994
+ switchGoalAgentSession(goal, 'codex'); goal.model = model; goal.startedAt = null; saveGoal(goal);
3651
3995
  }
3652
3996
  for (const projectId of new Set(queued.map((task) => task.projectId))) pump(projectId);
3653
3997
  return json(res, 200, { changed: queued.length, goals: [...goalIds] });
@@ -3811,7 +4155,7 @@ const server = createServer(async (req, res) => {
3811
4155
  req.on('data', (d) => { body += d; });
3812
4156
  req.on('end', async () => {
3813
4157
  try {
3814
- const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent, worktree } = JSON.parse(body);
4158
+ const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent, worktree, workspaceMode } = JSON.parse(body);
3815
4159
  const project = projects.find((p) => p.id === projectId);
3816
4160
  if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
3817
4161
  // Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
@@ -3926,9 +4270,11 @@ const server = createServer(async (req, res) => {
3926
4270
  // present would otherwise spawn a worker doomed to fail at launch.
3927
4271
  const goalAgent = pickAvailableAgent(workerAgent(agent), AVAILABLE_AGENTS);
3928
4272
  const priorFailureMemory = recentProjectFailureMemory(projectId, text.trim());
4273
+ const resolvedWorkspaceMode = resolveWorkspaceMode(workspaceMode ?? worktree);
4274
+ const workspaceDirty = resolvedWorkspaceMode === 'direct-workspace' ? projectDirtyInfo(project.dir) : {};
3929
4275
  const goal = {
3930
4276
  id: nextId++, projectId, text: text.trim().slice(0, 8000),
3931
- status: (needsFolder || needsReviewPref) ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'planning',
4277
+ status: (needsFolder || needsReviewPref) ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'stacked',
3932
4278
  question: folderQuestion || reviewPrefQuestion,
3933
4279
  reviewOnly: review ? true : undefined,
3934
4280
  note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
@@ -3941,11 +4287,16 @@ const server = createServer(async (req, res) => {
3941
4287
  mode: (!review && GOAL_MODES.includes(mode)) ? mode : 'auto',
3942
4288
  // SKILL: an optional user/project skill (or command) to invoke first.
3943
4289
  skill: (typeof skill === 'string' && skill.trim()) ? skill.trim().slice(0, 80) : null,
3944
- // WORKTREE (Masa 2026-07-22, the Claude app's own checkbox): true/undefined =
3945
- // a private copy per goal, which is what has always happened. Explicit false =
3946
- // work in the folder the person is looking at. Only an explicit false is
3947
- // stored, so every goal ever written keeps meaning what it meant.
3948
- worktree: worktree === false ? false : undefined,
4290
+ // WORKSPACE MODE: isolated-worktree is the default and uses a clean per-goal
4291
+ // checkout; direct-workspace is explicit and runs in the folder the person is
4292
+ // looking at, inheriting its current dirty/clean state. Keep the old
4293
+ // worktree:false wire contract too, so existing clients and old goals retain
4294
+ // their behavior while Review/PR can show the explicit mode name.
4295
+ workspaceMode: resolvedWorkspaceMode,
4296
+ worktree: resolvedWorkspaceMode === 'direct-workspace' ? false : undefined,
4297
+ workspaceWarning: workspaceDirty.dirty
4298
+ ? `Dirty workspace warning: direct workspace will inherit current uncommitted changes (${(workspaceDirty.dirtyPaths ?? []).join(', ')}).`
4299
+ : undefined,
3949
4300
  images: Array.isArray(images) ? images.slice(0, 6) : [],
3950
4301
  source: resolveGoalSource(source), sourceRef: null,
3951
4302
  executionPlan: buildExecutionPlan({
@@ -3968,11 +4319,7 @@ const server = createServer(async (req, res) => {
3968
4319
  // pending = deliberately shelved (Phase 2 material): never planned,
3969
4320
  // never queued, until POST /api/goals/:id/activate. review-only items
3970
4321
  // are terminal-until-human too, so they also skip planGoal.
3971
- if (runnable && !needsFolder && !needsReviewPref) {
3972
- planGoal(goal).catch((e) => {
3973
- goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
3974
- });
3975
- }
4322
+ if (runnable && !needsFolder && !needsReviewPref) startNextStacked(goal.projectId);
3976
4323
  json(res, 200, goal);
3977
4324
  } catch { json(res, 400, { error: 'bad json' }); }
3978
4325
  });
@@ -4065,6 +4412,7 @@ const server = createServer(async (req, res) => {
4065
4412
  if (!task) return json(res, 404, { error: 'task not found' });
4066
4413
  if (!requireTaskGoalOwnership(req, res, task)) return;
4067
4414
  if (!['interrupted', 'failed'].includes(task.status)) return json(res, 409, { error: 'only interrupted/failed tasks can be retried' });
4415
+ appendInternalQualityEvent({ type: 'task_retry_requested', taskId: task.id, goalId: task.goalId, projectId: task.projectId }, { once: false });
4068
4416
  task.status = 'queued'; task.result = null; task.secs = null; task.proof = null;
4069
4417
  saveTask(task);
4070
4418
  const goal = goals.find((g) => g.id === task.goalId);
@@ -4111,7 +4459,7 @@ const server = createServer(async (req, res) => {
4111
4459
  saveTask(task);
4112
4460
  }
4113
4461
  for (const goal of goals.filter((item) => goalIds.has(item.id))) {
4114
- goal.agent = agent; goal.model = model; goal.effort = effort; goal.sessionId = null; goal.startedAt = null;
4462
+ switchGoalAgentSession(goal, agent); goal.model = model; goal.effort = effort; goal.startedAt = null;
4115
4463
  saveGoal(goal);
4116
4464
  }
4117
4465
  pump(projectId);
@@ -4132,7 +4480,7 @@ const server = createServer(async (req, res) => {
4132
4480
  const queuedTasks = tasks.filter((task) => task.goalId === goal.id && task.status === 'queued');
4133
4481
  if (!queuedTasks.length) return json(res, 409, { error: 'this goal has no queued work' });
4134
4482
  const model = CODEX_MODELS[0];
4135
- goal.agent = 'codex'; goal.model = model; goal.sessionId = null; goal.startedAt = null;
4483
+ switchGoalAgentSession(goal, 'codex'); goal.model = model; goal.startedAt = null;
4136
4484
  saveGoal(goal);
4137
4485
  for (const task of queuedTasks) {
4138
4486
  task.agent = 'codex'; task.model = model; task.sessionId = null;
@@ -4156,9 +4504,26 @@ const server = createServer(async (req, res) => {
4156
4504
  if (!task) return json(res, 404, { error: 'task not found' });
4157
4505
  if (!requireTaskGoalOwnership(req, res, task)) return;
4158
4506
  if (!task.run) return json(res, 409, { error: 'no run declaration for this task' });
4507
+ const goal = goals.find((g) => g.id === task.goalId);
4159
4508
  startPreview(task)
4160
- .then((p) => json(res, 200, p))
4161
- .catch((e) => json(res, 500, { error: String(e.message ?? e).slice(0, 200) }));
4509
+ .then((p) => {
4510
+ if (goal?.verificationInfraError?.source === 'preview') {
4511
+ goal.verificationInfraError = null;
4512
+ saveGoal(goal);
4513
+ }
4514
+ json(res, 200, p);
4515
+ })
4516
+ .catch((e) => {
4517
+ const detail = String(e.message ?? e).slice(0, 200);
4518
+ if (goal && goal.status === 'review') {
4519
+ goal.verificationInfraError = buildVerificationInfraError({
4520
+ source: 'preview',
4521
+ detail: `Local preview could not start: ${detail}. The implementation result stays in Review; press 再検証 to rerun verification only, or try Open it again when the local environment is idle.`,
4522
+ });
4523
+ saveGoal(goal);
4524
+ }
4525
+ json(res, 500, { error: detail });
4526
+ });
4162
4527
  return;
4163
4528
  }
4164
4529
 
@@ -4334,6 +4699,7 @@ const server = createServer(async (req, res) => {
4334
4699
  goal.foldedInto = null;
4335
4700
  goal.noFold = true;
4336
4701
  goal.status = 'running'; // let finishGoalIfComplete re-run close-out (its tasks are all done → opens its own PR)
4702
+ appendInternalQualityEvent({ type: 'goal_fold_unfolded', goalId: goal.id, projectId: goal.projectId }, { once: false });
4337
4703
  saveGoal(goal);
4338
4704
  finishGoalIfComplete(goal, project).catch((e) => {
4339
4705
  goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
@@ -4349,8 +4715,8 @@ const server = createServer(async (req, res) => {
4349
4715
  if (!goal) return json(res, 404, { error: 'goal not found' });
4350
4716
  if (!requireGoalOwnership(req, res, goal)) return;
4351
4717
  if (goal.status !== 'pending') return json(res, 409, { error: 'only pending goals can be activated' });
4352
- goal.status = 'planning'; saveGoal(goal);
4353
- planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
4718
+ goal.status = 'stacked'; saveGoal(goal);
4719
+ startNextStacked(goal.projectId);
4354
4720
  return json(res, 200, goal);
4355
4721
  }
4356
4722
  const pendMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/pend$/);
@@ -4386,13 +4752,14 @@ const server = createServer(async (req, res) => {
4386
4752
  if (!proj) {
4387
4753
  proj = { id: uniqueProjectId(basename(dir)), name: basename(dir), dir };
4388
4754
  projects.push(proj); saveProjects();
4755
+ queues.set(proj.id, { running: new Set(), waiting: [] });
4389
4756
  }
4390
4757
  goal.projectId = proj.id;
4391
4758
  goal.question = null;
4392
4759
  goal.clarified = true;
4393
- goal.status = 'planning';
4760
+ goal.status = 'stacked';
4394
4761
  saveGoal(goal);
4395
- planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
4762
+ startNextStacked(goal.projectId);
4396
4763
  return json(res, 200, goal);
4397
4764
  };
4398
4765
  // Intent router (no free-text box is a dead-end). Before any per-kind
@@ -4515,17 +4882,17 @@ const server = createServer(async (req, res) => {
4515
4882
  goal.wantsPR = resolveWantsPR(undefined, goal.text, patch.defaultWantsPR);
4516
4883
  goal.question = null;
4517
4884
  goal.clarified = true;
4518
- goal.status = 'planning';
4885
+ goal.status = 'stacked';
4519
4886
  saveGoal(goal);
4520
- planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
4887
+ startNextStacked(goal.projectId);
4521
4888
  return json(res, 200, goal);
4522
4889
  }
4523
4890
  goal.text = `${goal.text}\n[確認: ${goal.question?.text ?? ''} → ${answer}]`;
4524
4891
  goal.clarified = true; // answered once → planGoal must never re-ask (persisted via saveGoal)
4525
4892
  goal.question = null;
4526
- goal.status = 'planning';
4893
+ goal.status = 'stacked';
4527
4894
  saveGoal(goal);
4528
- planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
4895
+ startNextStacked(goal.projectId);
4529
4896
  json(res, 200, goal);
4530
4897
  });
4531
4898
  return;
@@ -4627,6 +4994,7 @@ const server = createServer(async (req, res) => {
4627
4994
  goal.autoRework = { ...(goal.autoRework ?? {}), testFailures: 0, manualRetryAt: new Date().toISOString() };
4628
4995
  saveGoal(goal);
4629
4996
  const task = queueReplyTask(goal, prompt, { from: 'manager', via: 'retry' });
4997
+ appendInternalQualityEvent({ type: 'task_retry_requested', taskId: task.id, goalId: goal.id, projectId: goal.projectId }, { once: false });
4630
4998
  return json(res, 200, { goal, task, repairing: true });
4631
4999
  }
4632
5000
  const wdir = (goalWorkDirs.get(goal.id) && existsSync(goalWorkDirs.get(goal.id))) ? goalWorkDirs.get(goal.id) : project.dir;
@@ -4785,7 +5153,7 @@ async function judgeReplyIntent(goal, reply) {
4785
5153
 
4786
5154
  // 「聞いてるだけ」への返事 (ONE-CONVERSATION.md §1.3 answer)。
4787
5155
  //
4788
- // 答えるのは**作業した AI 本人**= goal.sessionId を resume する。状況一覧しか
5156
+ // 答えるのは**作業した AI 本人**=その agent の session を resume する。状況一覧しか
4789
5157
  // 知らない別AIでは「なぜそうしたか」に答えられない(Masa確定 2026-07-22)。
4790
5158
  // コードは1文字も変わらない: ツールは Read だけ、permissionMode は 'plan'
4791
5159
  // (Codex 側は read-only サンドボックスに落ちる)。タスクも作らないので看板も
@@ -4806,7 +5174,7 @@ async function answerGoalQuestion(goal, question) {
4806
5174
  const r = await runWorkerAgent({
4807
5175
  agent: goal.agent, model: goal.model, prompt, cwd,
4808
5176
  tools: 'Read', permissionMode: 'plan',
4809
- resume: goal.sessionId ?? undefined,
5177
+ resume: goalSessionFor(goal, goal.agent) ?? undefined,
4810
5178
  onChild: (c) => trackGoalWorker(goal.id, c),
4811
5179
  });
4812
5180
  const answer = String(r?.result ?? '').trim();
@@ -4873,7 +5241,7 @@ async function answerGoalQuestion(goal, question) {
4873
5241
  }
4874
5242
 
4875
5243
  // answer: 聞かれただけ。ゴールは review のまま、ワーカーも起こさない(§1.3)。
4876
- // 答えるのは**その作業をやった AI 本人**(goal.sessionId を resume)なので
5244
+ // 答えるのは**その作業をやった AI 本人**(agent別sessionを resume)なので
4877
5245
  // 「なぜそうしたか」に答えられる。HTTP は待たせない——答えは会話に1発言
4878
5246
  // 積まれ、SSE の goal イベント(messageCount)で画面に出る。
4879
5247
  if (result.answers) {
@@ -5095,27 +5463,22 @@ server.listen(PORT, '127.0.0.1', () => {
5095
5463
  setInterval(() => {
5096
5464
  send({ ev: 'ping', uiVersion: uiVersion() });
5097
5465
  syncGoalMerges().catch(() => {});
5098
- for (const p of projects) {
5099
- pump(p.id);
5100
- for (const g of goals.filter((g) => g.projectId === p.id && g.status === 'stacked')) {
5101
- g.status = 'planning'; saveGoal(g);
5102
- planGoal(g).catch((e) => { g.status = 'failed'; g.prError = String(e).slice(0, 300); saveGoal(g); });
5103
- }
5104
- }
5105
5466
  }, 30_000).unref();
5467
+ // Queue liveness is a product invariant, not something an SSE refresh or a
5468
+ // person pressing Retry should have to restore. Five seconds keeps recovery
5469
+ // quick without doing meaningful work while idle.
5470
+ setInterval(() => {
5471
+ for (const p of projects) reconcileAndPumpProject(p.id);
5472
+ }, 5_000).unref();
5106
5473
  for (const p of projects) {
5107
5474
  const pending = tasks.filter((t) => t.projectId === p.id && t.status === 'queued').sort((a, b) => a.num - b.num);
5108
5475
  const q = queues.get(p.id);
5109
5476
  reconcileQueuedGoalStarts(p.id);
5110
5477
  q.waiting.push(...pending);
5111
5478
  if (pending.length) console.log(`[manager] ${p.id}: re-queued ${pending.length} pending task(s) after restart`);
5112
- pump(p.id);
5113
5479
  // breakdown-first: any goal still sitting 'stacked' (legacy or crash
5114
- // leftovers) gets planned immediately so it can never rot in the queue
5115
- for (const g of goals.filter((g) => g.projectId === p.id && g.status === 'stacked')) {
5116
- g.status = 'planning'; saveGoal(g);
5117
- planGoal(g).catch((e) => { g.status = 'failed'; g.prError = String(e).slice(0, 300); saveGoal(g); });
5118
- }
5480
+ // leftovers) gets planned immediately so it can never rot in the queue.
5481
+ reconcileAndPumpProject(p.id);
5119
5482
  // A restart must not orphan an in-flight Ledger retest (v45 §5.3): the
5120
5483
  // intent is persisted as status 'retesting' in tasks.jsonl (replayQueueLog
5121
5484
  // passes it through untouched) — resume the job from run 1.