@galda/cli 0.10.84 → 0.10.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/server.mjs CHANGED
@@ -21,8 +21,9 @@ import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { needsProjectFolder, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath } from './lib.mjs';
24
- import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession } from './lib.mjs';
24
+ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildReviewAttemptLedger, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
25
25
  import { createSerialQueue } from './lib.mjs';
26
+ import { parseRequirementEvidence, unmappedChangedFiles } from './lib.mjs';
26
27
  import { isPrClosed } from './lib.mjs';
27
28
  import { collectProjectRules } from './lib.mjs';
28
29
  import { FALLBACK_CODEX_MODELS, loadCodexModels } from './codex-models.mjs';
@@ -1397,7 +1398,7 @@ function cancelGoalWork(goal) {
1397
1398
  killGoalWorkers(goal.id);
1398
1399
  const q = queues.get(goal.projectId);
1399
1400
  if (q) q.waiting = q.waiting.filter((t) => t.goalId !== goal.id);
1400
- for (const t of tasks.filter((t) => t.goalId === goal.id && ['queued', 'running', 'interrupted'].includes(t.status))) {
1401
+ for (const t of tasks.filter((t) => t.goalId === goal.id && ['queued', 'running', 'interrupted', 'awaitingApproval'].includes(t.status))) {
1401
1402
  t.status = 'cancelled'; t.finishedAt = new Date().toISOString(); saveTask(t);
1402
1403
  }
1403
1404
  }
@@ -2208,11 +2209,17 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
2208
2209
  const task = {
2209
2210
  id: nextId++, num: (nums.length ? Math.max(...nums) : 0) + 1,
2210
2211
  goalId: goal.id, projectId: goal.projectId, reply: true, model: goal.model, effort: goal.effort, agent: goal.agent,
2211
- title: text.replace(/\s+/g, ' ').slice(0, 60), detail: promptText.slice(0, 4000),
2212
+ title: text.replace(/\s+/g, ' ').slice(0, 60), detail: promptText.slice(0, 4000), replyFrom: from,
2212
2213
  workspaceMode: resolveWorkspaceMode(goal.workspaceMode ?? goal.worktree),
2213
2214
  worktree: goal.worktree === false ? false : undefined,
2214
2215
  passCondition: null, status: 'queued', createdAt: new Date().toISOString(), priority: '中',
2215
2216
  result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
2217
+ userRequirementHistory: tasks.filter((t) => t.goalId === goal.id && (!t.reply || t.replyFrom === 'you')).slice(-12).map((t) => ({ text: t.reply ? t.detail : t.title, status: t.status })),
2218
+ // Transport the prior attempts as facts; the worker, not Manager, decides
2219
+ // what to implement in response to the new user reply.
2220
+ requirementHistory: tasks.filter((t) => t.goalId === goal.id).slice(-12).map((t) => ({
2221
+ text: t.reply ? t.detail : t.title, status: t.status, reply: !!t.reply,
2222
+ })),
2216
2223
  };
2217
2224
  tasks.push(task);
2218
2225
  saveTask(task);
@@ -2252,6 +2259,24 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
2252
2259
  return task;
2253
2260
  }
2254
2261
 
2262
+ function pauseGoalForApproval(goal, task, request) {
2263
+ if (!goal || !task || !request) return false;
2264
+ const requestedAt = new Date().toISOString();
2265
+ goal.status = 'needsApproval';
2266
+ goal.startedAt = null;
2267
+ goal.pauseReason = 'approval_required';
2268
+ goal.approvalRequest = { ...request, requestedAt, pausedTaskId: task.id, external: false };
2269
+ goal.approvalDecision = null;
2270
+ task.status = 'awaitingApproval';
2271
+ task.result = request.question;
2272
+ task.finishedAt = null;
2273
+ saveTask(task);
2274
+ saveGoal(goal);
2275
+ sendProcessAct(task, `Paused for your approval: ${request.question}`);
2276
+ startNextStacked(goal.projectId);
2277
+ return true;
2278
+ }
2279
+
2255
2280
  // split (docs/design/ONE-CONVERSATION.md §1.3): 「それは別件」と返された時の受け皿。
2256
2281
  // 元のゴールには触らない——新しいチケットを1枚起こし、そこに「どこから生まれたか」だけ
2257
2282
  // 片方向の印(bornFrom)を残す。
@@ -2723,8 +2748,11 @@ async function runTask(task) {
2723
2748
  task.secs = Math.round((Date.now() - t0) / 1000);
2724
2749
  task.status = r.code === 0 ? 'done' : 'failed';
2725
2750
  task.result = r.result.slice(0, RESULT_MAX);
2751
+ task.requirementEvidence = parseRequirementEvidence(task.result);
2726
2752
  task.usage = sumUsage([r.usage]);
2727
2753
  if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
2754
+ const approval = parseApprovalRequest(r.result);
2755
+ if (approval && pauseGoalForApproval(goal, task, approval)) return;
2728
2756
  // A follow-up owns its own evidence too. Previously only the initial task
2729
2757
  // consumed .manager-run.json, so replies either left the new declaration
2730
2758
  // behind or Review kept showing an older task's title/URL.
@@ -2734,6 +2762,8 @@ async function runTask(task) {
2734
2762
  inheritPreviousRunDeclaration(task, goal?.id);
2735
2763
  const afterR = await gitChanges(workDir);
2736
2764
  task.changedFiles = afterR.filter((l) => !before.includes(l)).map((l) => l.slice(3));
2765
+ task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
2766
+ task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
2737
2767
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this follow-up.`);
2738
2768
  task.finishedAt = new Date().toISOString();
2739
2769
  task.resultAttemptId = task.attemptId;
@@ -2760,7 +2790,7 @@ async function runTask(task) {
2760
2790
 
2761
2791
  // No `lastFailure` any more: it existed only to tell the next attempt why our
2762
2792
  // check had rejected the last one, and there is no next attempt now.
2763
- let verify = null, verdict = null, code = 0, result = '', usages = [], timedOut = false;
2793
+ let verify = null, verdict = null, code = 0, result = '', usages = [], timedOut = false, approval = null;
2764
2794
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2765
2795
  let callT0 = Date.now();
2766
2796
  const plan = goal?.executionPlan;
@@ -2822,6 +2852,8 @@ async function runTask(task) {
2822
2852
  ? `Worker attempt ${attempt} was stopped at the ${Math.round(WORKER_TIMEOUT_MS / 60000)}-minute run limit (exit ${code}).`
2823
2853
  : `Worker attempt ${attempt} finished with exit code ${code}.`);
2824
2854
  if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
2855
+ approval = parseApprovalRequest(result);
2856
+ if (approval) break;
2825
2857
  if (!entryUrl) break;
2826
2858
  try {
2827
2859
  if (!verify) {
@@ -2859,6 +2891,11 @@ async function runTask(task) {
2859
2891
  }
2860
2892
 
2861
2893
  if (goal?.cancelled) return; // deleted during the verify loop → don't resurrect the task
2894
+ if (approval) {
2895
+ task.secs = Math.round((Date.now() - t0) / 1000);
2896
+ task.usage = sumUsage(usages);
2897
+ if (pauseGoalForApproval(goal, task, approval)) return;
2898
+ }
2862
2899
  task.secs = Math.round((Date.now() - t0) / 1000);
2863
2900
  // Status decision, most-authoritative first (taskStatusAfterVerify in lib):
2864
2901
  // 1. an independent verify PASS = verified success — wins even if the worker
@@ -2885,6 +2922,7 @@ async function runTask(task) {
2885
2922
  // force-stopped task states WHY so the Attention card explains itself.
2886
2923
  if (forcedStop && (!result || result === '(no output)')) result = workerExitReason(code, timedOut, WORKER_TIMEOUT_MS);
2887
2924
  task.result = result.slice(0, RESULT_MAX);
2925
+ task.requirementEvidence = parseRequirementEvidence(task.result);
2888
2926
  task.usage = sumUsage(usages);
2889
2927
  if (verdict) {
2890
2928
  const label = verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (自動確認できず・要確認)' : 'FAIL';
@@ -2930,6 +2968,8 @@ async function runTask(task) {
2930
2968
  reconcileComparePair(task);
2931
2969
  const after = await gitChanges(workDir);
2932
2970
  task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
2971
+ task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
2972
+ task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
2933
2973
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
2934
2974
  // task 450: taskStatusAfterVerify decided 'done' from `code` alone, before
2935
2975
  // changedFiles existed to check against. Don't trust a clean exit code by
@@ -3458,6 +3498,7 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
3458
3498
  // after) so the PR body's "確認ポイント" headline is available the first
3459
3499
  // time the PR is opened, not only on the review card (Masa 2026-07-16).
3460
3500
  goalAct('Writing the review summary from the worker report and diff.');
3501
+ goal.reviewAttempt = buildReviewAttemptLedger({ goal, tasks: siblings });
3461
3502
  goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
3462
3503
  if (activityTask?.attemptId) goal.reviewSummaryAttemptId = activityTask.attemptId;
3463
3504
  goalAct('Checking changed files and preparing the PR branch if code changed.');
@@ -4318,14 +4359,14 @@ const server = createServer(async (req, res) => {
4318
4359
  req.on('data', (d) => { body += d; });
4319
4360
  req.on('end', async () => {
4320
4361
  try {
4321
- const { projectId, text, pr, existingPr, images, model, effort, source, pending, review, note, mode, skill, agent, worktree, workspaceMode } = JSON.parse(body);
4362
+ const { projectId, text, pr, existingPr, images, model, effort, source, pending, review, approval, action, scope, resumeInstruction, note, mode, skill, agent, worktree, workspaceMode } = JSON.parse(body);
4322
4363
  const project = projects.find((p) => p.id === projectId);
4323
4364
  if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
4324
4365
  // Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
4325
4366
  // (no LLM), checked BEFORE the intent router below: a short command-like
4326
4367
  // "止めて"/"再開" toggles THIS project's pause state and returns a chat reply
4327
4368
  // — it never becomes a To-Do. /later and /review skip this like the router.
4328
- if (!pending && !review) {
4369
+ if (!pending && !review && !approval) {
4329
4370
  const pauseIntent = detectPauseIntent(text.trim());
4330
4371
  if (pauseIntent === 'pause') {
4331
4372
  pauseProject(projectId);
@@ -4349,7 +4390,7 @@ const server = createServer(async (req, res) => {
4349
4390
  // words then become a new ticket, which is what happens today anyway — a typo
4350
4391
  // must never make a request disappear.
4351
4392
  const identity = identityFor(req);
4352
- if (!pending && !review) {
4393
+ if (!pending && !review && !approval) {
4353
4394
  const addr = parseGoalAddress(text);
4354
4395
  const target = addr && goals.find((g) => g.id === addr.goalId
4355
4396
  && g.projectId === projectId
@@ -4373,7 +4414,7 @@ const server = createServer(async (req, res) => {
4373
4414
  // anything else gets one Haiku round-trip that both classifies and writes
4374
4415
  // the reply. LLM failure falls back to 'task' — never drop a real
4375
4416
  // implementation ask (the folder guard below still prevents no-ops).
4376
- if (!pending && !review && classifyComposerIntentHeuristic(text.trim()) === 'chat') {
4417
+ if (!pending && !review && !approval && classifyComposerIntentHeuristic(text.trim()) === 'chat') {
4377
4418
  // Only a HIGH-confidence chat/help/settings message reaches the LLM —
4378
4419
  // to write the reply, and to let the model veto back to 'task' if the
4379
4420
  // heuristic over-fired. Everything else (clear tasks AND ambiguous
@@ -4416,7 +4457,7 @@ const server = createServer(async (req, res) => {
4416
4457
  // reusing the needsInput clarifying card; the answer (POST /answer)
4417
4458
  // rebinds this goal to the chosen repo, then planning proceeds. Skipped
4418
4459
  // for pending/review items (they never spawn a worker).
4419
- const runnable = !pending && !review;
4460
+ const runnable = !pending && !review && !approval;
4420
4461
  const needsFolder = runnable && needsProjectFolder({ dir: project.dir, homeDir: HOME, isRepo: isGitRepo(project.dir) });
4421
4462
  const lang = /[぀-ヿ㐀-鿿]/.test(text) ? 'ja' : 'en';
4422
4463
  const folderQuestion = needsFolder ? buildFolderQuestion(detectRepos(HOME), { lang }) : undefined;
@@ -4441,17 +4482,27 @@ const server = createServer(async (req, res) => {
4441
4482
  if (review && existingPr && !importedPr) return json(res, 400, { error: 'existingPr must be a GitHub pull request URL' });
4442
4483
  const goal = {
4443
4484
  id: nextId++, projectId, text: text.trim().slice(0, 8000),
4444
- status: (needsFolder || needsReviewPref) ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'stacked',
4485
+ status: (needsFolder || needsReviewPref) ? 'needsInput' : approval ? 'needsApproval' : review ? 'review' : pending ? 'pending' : 'stacked',
4445
4486
  question: folderQuestion || reviewPrefQuestion,
4487
+ pauseReason: approval ? 'approval_required' : undefined,
4488
+ approvalRequest: approval ? {
4489
+ type: 'approval_required',
4490
+ action: String(action || 'continue').trim().slice(0, 120),
4491
+ question: text.trim().slice(0, 500),
4492
+ scope: Array.isArray(scope) ? scope.map((x) => String(x).trim().slice(0, 300)).filter(Boolean).slice(0, 20) : [],
4493
+ resumeInstruction: String(resumeInstruction || '').trim().slice(0, 1000),
4494
+ requestedAt: new Date().toISOString(), external: true,
4495
+ } : undefined,
4496
+ approvalDecision: null,
4446
4497
  reviewOnly: review ? true : undefined,
4447
4498
  note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
4448
- wantsPR: review ? Boolean(importedPr) : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
4499
+ wantsPR: (review || approval) ? Boolean(importedPr) : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
4449
4500
  model: workerModel(goalAgent, model),
4450
4501
  effort: workerEffort(goalAgent, effort),
4451
4502
  agent: goalAgent,
4452
4503
  // MODE (Auto/Plan): plan = propose a plan first, approve to execute.
4453
4504
  // review-only goals never run a worker, so mode is moot → 'auto'.
4454
- mode: (!review && GOAL_MODES.includes(mode)) ? mode : 'auto',
4505
+ mode: (!review && !approval && GOAL_MODES.includes(mode)) ? mode : 'auto',
4455
4506
  // SKILL: an optional user/project skill (or command) to invoke first.
4456
4507
  skill: (typeof skill === 'string' && skill.trim()) ? skill.trim().slice(0, 80) : null,
4457
4508
  // WORKSPACE MODE: isolated-worktree is the default and uses a clean per-goal
@@ -4897,6 +4948,69 @@ const server = createServer(async (req, res) => {
4897
4948
  }
4898
4949
 
4899
4950
  // Slack-style thread reply on a goal: resumes the goal's worker session
4951
+ // A Needs you decision is not a Review verdict. It either resumes the same
4952
+ // goal/session with an explicit permission boundary, or ends the paused work.
4953
+ const approvalMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/approval$/);
4954
+ if (approvalMatch && req.method === 'POST') {
4955
+ const goal = goals.find((g) => g.id === Number(approvalMatch[1]));
4956
+ if (!goal) return json(res, 404, { error: 'goal not found' });
4957
+ if (!requireGoalOwnership(req, res, goal)) return;
4958
+ if (goal.status !== 'needsApproval' || !goal.approvalRequest) return json(res, 409, { error: 'goal is not awaiting approval' });
4959
+ let body = '';
4960
+ req.on('data', (d) => { body += d; });
4961
+ req.on('end', () => {
4962
+ let decision = '', instruction = '';
4963
+ try {
4964
+ const parsed = JSON.parse(body || '{}');
4965
+ decision = String(parsed.decision ?? '').trim();
4966
+ instruction = String(parsed.instruction ?? '').trim().slice(0, 2000);
4967
+ } catch { return json(res, 400, { error: 'bad json' }); }
4968
+ if (!['proceed', 'edit', 'cancel'].includes(decision)) return json(res, 400, { error: 'decision must be proceed, edit, or cancel' });
4969
+ if (decision === 'edit' && !instruction) return json(res, 400, { error: 'instruction required for edit' });
4970
+ const decidedAt = new Date().toISOString();
4971
+ goal.approvalDecision = { decision, instruction: instruction || undefined, decidedAt };
4972
+ const request = goal.approvalRequest;
4973
+
4974
+ // MCP/external agents cannot be resumed by this process. Persist the
4975
+ // decision for manager_status polling and close this human-only card.
4976
+ if (request.external) {
4977
+ goal.status = decision === 'cancel' ? 'skipped' : 'done';
4978
+ goal.pauseReason = null;
4979
+ saveGoal(goal);
4980
+ startNextStacked(goal.projectId);
4981
+ return json(res, 200, goal);
4982
+ }
4983
+
4984
+ const paused = tasks.find((t) => t.id === request.pausedTaskId && t.goalId === goal.id);
4985
+ if (decision === 'cancel') {
4986
+ const q = queues.get(goal.projectId);
4987
+ if (q) q.waiting = q.waiting.filter((t) => t.goalId !== goal.id);
4988
+ for (const t of tasks.filter((t) => t.goalId === goal.id && ['queued', 'awaitingApproval'].includes(t.status))) {
4989
+ t.status = 'cancelled'; t.finishedAt = decidedAt; saveTask(t);
4990
+ }
4991
+ goal.status = 'skipped'; goal.pauseReason = null; goal.startedAt = null;
4992
+ saveGoal(goal); startNextStacked(goal.projectId);
4993
+ return json(res, 200, goal);
4994
+ }
4995
+
4996
+ if (paused) {
4997
+ paused.status = 'done';
4998
+ paused.finishedAt = decidedAt;
4999
+ paused.result = `Approval received: ${request.question}`;
5000
+ saveTask(paused);
5001
+ }
5002
+ const scopeText = request.scope?.length ? `\n許可された範囲:\n- ${request.scope.join('\n- ')}` : '';
5003
+ const resumeText = decision === 'edit'
5004
+ ? `承認条件が更新されました。次の指示の範囲で同じ作業を再開し、最後まで完了してください。\n${instruction}`
5005
+ : `${request.resumeInstruction || `「${request.action}」の実行が承認されました。同じ作業を再開し、最後まで完了してください。`}${scopeText}`;
5006
+ goal.status = 'running'; goal.pauseReason = null; goal.startedAt = null;
5007
+ saveGoal(goal);
5008
+ const replyTask = queueReplyTask(goal, resumeText, { from: 'you', via: 'approval' });
5009
+ return json(res, 200, { ...goal, replyTaskId: replyTask.id });
5010
+ });
5011
+ return;
5012
+ }
5013
+
4900
5014
  // Smart intake: answer a planner clarifying question → fold it into the goal
4901
5015
  // and re-plan (no new worker; same goal continues to decomposition).
4902
5016
  const answerMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/answer$/);
@@ -5070,6 +5184,7 @@ const server = createServer(async (req, res) => {
5070
5184
  const goal = goals.find((g) => g.id === Number(replyMatch[1]));
5071
5185
  if (!goal) return json(res, 404, { error: 'goal not found' });
5072
5186
  if (!requireGoalOwnership(req, res, goal)) return;
5187
+ if (goal.status === 'needsApproval') return json(res, 409, { error: 'goal is awaiting an approval decision — use /approval' });
5073
5188
  if (['stacked', 'planning'].includes(goal.status)) return json(res, 409, { error: 'goal not started yet — edit it instead' });
5074
5189
  let body = '';
5075
5190
  req.on('data', (d) => { body += d; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.84",
3
+ "version": "0.10.85",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {