@galda/cli 0.10.84 → 0.10.86
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/CLAUDE.md +16 -0
- package/app/index.html +281 -96
- package/app/theme.css +6 -6
- package/engine/lib.mjs +116 -4
- package/engine/mcp.mjs +33 -1
- package/engine/server.mjs +127 -12
- package/package.json +1 -1
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