@galda/cli 0.10.83 → 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/CLAUDE.md +16 -0
- package/app/index.html +293 -91
- package/app/theme.css +6 -6
- package/engine/lib.mjs +127 -5
- package/engine/mcp.mjs +33 -1
- package/engine/server.mjs +174 -17
- 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
|
|
@@ -2991,11 +3031,26 @@ function testNodePath(dir, dependencySourceDir = null) {
|
|
|
2991
3031
|
.filter(Boolean).filter(existsSync);
|
|
2992
3032
|
return [...new Set([...local, ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(sep) : [])])].join(sep);
|
|
2993
3033
|
}
|
|
3034
|
+
function testEnvironmentPreflight(dir, dependencySourceDir = null) {
|
|
3035
|
+
const nodePath = testNodePath(dir, dependencySourceDir);
|
|
3036
|
+
const sources = nodePath ? nodePath.split(process.platform === 'win32' ? ';' : ':') : [];
|
|
3037
|
+
const hasPuppeteer = sources.some((p) => existsSync(join(p, 'puppeteer-core', 'package.json')));
|
|
3038
|
+
const npm = spawnSync('npm', ['--version'], { encoding: 'utf8' });
|
|
3039
|
+
const checks = [
|
|
3040
|
+
{ id: 'node', ok: Boolean(process.execPath), detail: process.version },
|
|
3041
|
+
{ id: 'npm', ok: npm.status === 0, detail: npm.status === 0 ? npm.stdout.trim() : 'npm command unavailable' },
|
|
3042
|
+
{ id: 'dependencies', ok: sources.length > 0, detail: sources.length ? `${sources.length} node_modules source(s)` : 'node_modules not found' },
|
|
3043
|
+
{ id: 'puppeteer', ok: hasPuppeteer, detail: hasPuppeteer ? 'puppeteer-core available' : 'puppeteer-core missing; run npm install in the project directory' },
|
|
3044
|
+
];
|
|
3045
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
3046
|
+
}
|
|
2994
3047
|
function runTestsOnce(dir, dependencySourceDir = null) {
|
|
2995
3048
|
// The timeout is env-overridable so tests can force the killed-run path fast;
|
|
2996
3049
|
// production keeps the 4-minute default.
|
|
2997
3050
|
const runTimeout = Number(process.env.MANAGER_TEST_TIMEOUT_MS) || 240000;
|
|
2998
3051
|
return testRunQueue.run(() => new Promise((res) => {
|
|
3052
|
+
const preflight = testEnvironmentPreflight(dir, dependencySourceDir);
|
|
3053
|
+
if (!preflight.ok) return res({ passed: 0, failed: 0, preflight, timedOut: false, realAssertion: false, infraOnly: true, infraFlakes: 0, detail: preflight.checks.filter((c) => !c.ok).map((c) => c.detail).join('\n'), failingNames: [] });
|
|
2999
3054
|
const nodePath = testNodePath(dir, dependencySourceDir);
|
|
3000
3055
|
execFile('npm', ['test'], { cwd: dir, timeout: runTimeout, env: { ...process.env, ...(nodePath ? { NODE_PATH: nodePath } : {}) }, maxBuffer: 8 * 1024 * 1024 }, (e, out = '', err = '') => {
|
|
3001
3056
|
const text = `${out}\n${err}`;
|
|
@@ -3025,7 +3080,7 @@ function runTestsOnce(dir, dependencySourceDir = null) {
|
|
|
3025
3080
|
const timedOut = Boolean(e && (e.killed || e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT'));
|
|
3026
3081
|
// Capture the failing test NAMES too (not just the count) so the gate can
|
|
3027
3082
|
// diff them against the base's failures and block only on NEW ones.
|
|
3028
|
-
res({ passed, failed, timedOut, realAssertion, infraOnly, infraFlakes, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
|
|
3083
|
+
res({ passed, failed, preflight, timedOut, realAssertion, infraOnly, infraFlakes, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
|
|
3029
3084
|
});
|
|
3030
3085
|
}));
|
|
3031
3086
|
}
|
|
@@ -3054,11 +3109,12 @@ async function runProjectTests(dir, dependencySourceDir = null) {
|
|
|
3054
3109
|
// the gate should see, so no good goal is blocked on a saturated boot.
|
|
3055
3110
|
infraOnly: !!kept.infraOnly,
|
|
3056
3111
|
infraFlakes: kept.infraFlakes ?? 0,
|
|
3112
|
+
preflight: kept.preflight ?? r.preflight ?? null,
|
|
3057
3113
|
detail: kept.detail || r.detail || r2.detail || '',
|
|
3058
3114
|
failingNames: kept.failingNames ?? r.failingNames ?? [],
|
|
3059
3115
|
};
|
|
3060
3116
|
}
|
|
3061
|
-
return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, timedOut: !!r.timedOut, realAssertion: !!r.realAssertion, infraOnly: !!r.infraOnly, infraFlakes: r.infraFlakes ?? 0, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
|
|
3117
|
+
return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, timedOut: !!r.timedOut, realAssertion: !!r.realAssertion, infraOnly: !!r.infraOnly, infraFlakes: r.infraFlakes ?? 0, preflight: r.preflight ?? null, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
|
|
3062
3118
|
}
|
|
3063
3119
|
|
|
3064
3120
|
// Failing tests on the BASE (pre-change) checkout — the set the review gate
|
|
@@ -3241,7 +3297,11 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
|
|
|
3241
3297
|
// An explicit ja/en on the project's review definition always overrides.
|
|
3242
3298
|
const lang = reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : (reviewDefinition.language || 'ja');
|
|
3243
3299
|
const changed = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].slice(0, 20);
|
|
3244
|
-
|
|
3300
|
+
// A reply is a new implementation attempt, not just conversation. Excluding
|
|
3301
|
+
// it here made the second Review render the first attempt's stale Result,
|
|
3302
|
+
// even when the worker had completed the user's latest feedback.
|
|
3303
|
+
const reports = siblings.map((t) => `${t.reply ? '[follow-up] ' : ''}- ${t.title}: ${(t.result ?? '').replace(/\s+/g, ' ').slice(0, 300)}`).join('\n');
|
|
3304
|
+
const latestFeedback = [...siblings].reverse().find((t) => t.reply && t.detail)?.detail ?? '';
|
|
3245
3305
|
// The name of the work, from the agent that did it (task.run.title). It wins
|
|
3246
3306
|
// over both the summarizer and the request text: the summarizer only ever read
|
|
3247
3307
|
// the same request the user typed, and the request text is the sentence the
|
|
@@ -3255,7 +3315,7 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
|
|
|
3255
3315
|
changed: (goal.plan?.[0] ?? goal.text ?? '').replace(/\s+/g, ' ').slice(0, 80),
|
|
3256
3316
|
};
|
|
3257
3317
|
if (!changed.length && !reports) return fallback;
|
|
3258
|
-
const prompt = buildReviewSummaryPrompt({ lang, goalText: goal.text, changed, reports });
|
|
3318
|
+
const prompt = buildReviewSummaryPrompt({ lang, goalText: goal.text, changed, reports, latestFeedback });
|
|
3259
3319
|
try {
|
|
3260
3320
|
const r = await runUtility({ prompt, cwd: dir, tools: 'Read' });
|
|
3261
3321
|
const summary = parseReviewSummary(r.result, fallback);
|
|
@@ -3438,6 +3498,7 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
|
|
|
3438
3498
|
// after) so the PR body's "確認ポイント" headline is available the first
|
|
3439
3499
|
// time the PR is opened, not only on the review card (Masa 2026-07-16).
|
|
3440
3500
|
goalAct('Writing the review summary from the worker report and diff.');
|
|
3501
|
+
goal.reviewAttempt = buildReviewAttemptLedger({ goal, tasks: siblings });
|
|
3441
3502
|
goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
|
|
3442
3503
|
if (activityTask?.attemptId) goal.reviewSummaryAttemptId = activityTask.attemptId;
|
|
3443
3504
|
goalAct('Checking changed files and preparing the PR branch if code changed.');
|
|
@@ -3519,7 +3580,7 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
3519
3580
|
const testGateOn = resolveTestGate({ reviewDefinition, env: process.env, force: forceTests });
|
|
3520
3581
|
if (testGateOn && hasTestRelevantChanges(changedFiles)) {
|
|
3521
3582
|
goalAct(`Running project tests in the goal worktree (${changedFiles.length} changed file${changedFiles.length === 1 ? '' : 's'}).`);
|
|
3522
|
-
goal.testResult = await runProjectTests(wdir,
|
|
3583
|
+
goal.testResult = await runProjectTests(wdir, baseProject?.dir);
|
|
3523
3584
|
goalAct(`Project tests finished: ${goal.testResult.failed ?? 0} failed, ${goal.testResult.passed ?? 0} passed.`);
|
|
3524
3585
|
} else if (!testGateOn) {
|
|
3525
3586
|
goal.testResult = { ran: false, skipped: true, skippedReason: 'test gate off (opt-in: requireTests / MANAGER_VERIFY_GATE=on)' };
|
|
@@ -3571,6 +3632,11 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
3571
3632
|
if (goal.testResult.ran && goal.testResult.infraOnly && !goal.testResult.inconclusive) {
|
|
3572
3633
|
goalAct(`Project tests: all ${goal.testResult.infraFlakes || goal.testResult.failed || ''} failure(s) were infra flakes (a real server could not boot / a wait timed out under machine load), not code regressions — not blocking. Re-run when the machine is idle to confirm.`.replace(/\s{2,}/g, ' '));
|
|
3573
3634
|
}
|
|
3635
|
+
if (goal.testResult.ran && goal.testResult.preflight && !goal.testResult.preflight.ok) {
|
|
3636
|
+
const missing = goal.testResult.preflight.checks.filter((c) => !c.ok).map((c) => c.id).join(', ');
|
|
3637
|
+
goal.testResult.inconclusive = true;
|
|
3638
|
+
goalAct(`Project tests were not started: verification preflight failed (${missing}). This is an environment issue, not an implementation failure. Fix the listed dependency or tool, then re-run verification.`);
|
|
3639
|
+
}
|
|
3574
3640
|
// Baseline diff (Masa 2026-07-19): block ONLY on failures THIS change
|
|
3575
3641
|
// introduced. A test already red on the base — e.g. persist-failure on clean
|
|
3576
3642
|
// origin/main — is pre-existing noise and must not knock a proof-verified
|
|
@@ -3942,6 +4008,23 @@ const server = createServer(async (req, res) => {
|
|
|
3942
4008
|
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() });
|
|
3943
4009
|
}
|
|
3944
4010
|
|
|
4011
|
+
// Read-only stability snapshot: separates implementation failures from
|
|
4012
|
+
// verification infrastructure so operational trends can be monitored without
|
|
4013
|
+
// scraping human-facing cards. The browser dashboard can consume this later.
|
|
4014
|
+
if (url.pathname === '/api/stability' && req.method === 'GET') {
|
|
4015
|
+
const since = Date.now() - 24 * 60 * 60 * 1000;
|
|
4016
|
+
const recentGoals = goals.filter((g) => Date.parse(g.createdAt ?? '') >= since);
|
|
4017
|
+
const count = (fn) => recentGoals.filter(fn).length;
|
|
4018
|
+
return json(res, 200, {
|
|
4019
|
+
since: new Date(since).toISOString(), generatedAt: new Date().toISOString(), total: recentGoals.length,
|
|
4020
|
+
implementationFailures: count((g) => ['failed', 'partial'].includes(g.status) && !g.verificationInfraError),
|
|
4021
|
+
verificationInfrastructure: count((g) => Boolean(g.verificationInfraError)),
|
|
4022
|
+
timeoutsOrInconclusive: count((g) => Boolean(g.testResult?.inconclusive)),
|
|
4023
|
+
workerHolds: count((g) => ['rate-limit', 'budget'].includes(g.blocked?.kind)),
|
|
4024
|
+
readyForReview: count((g) => g.status === 'review'),
|
|
4025
|
+
});
|
|
4026
|
+
}
|
|
4027
|
+
|
|
3945
4028
|
// Billing (docs/BILLING-LAUNCH-PLAN.md): activate the license key a user
|
|
3946
4029
|
// copies from galda.app after paying. GET reports whether one is on file
|
|
3947
4030
|
// (for the settings panel); POST verifies+persists a newly pasted token.
|
|
@@ -4276,14 +4359,14 @@ const server = createServer(async (req, res) => {
|
|
|
4276
4359
|
req.on('data', (d) => { body += d; });
|
|
4277
4360
|
req.on('end', async () => {
|
|
4278
4361
|
try {
|
|
4279
|
-
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);
|
|
4280
4363
|
const project = projects.find((p) => p.id === projectId);
|
|
4281
4364
|
if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
|
|
4282
4365
|
// Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
|
|
4283
4366
|
// (no LLM), checked BEFORE the intent router below: a short command-like
|
|
4284
4367
|
// "止めて"/"再開" toggles THIS project's pause state and returns a chat reply
|
|
4285
4368
|
// — it never becomes a To-Do. /later and /review skip this like the router.
|
|
4286
|
-
if (!pending && !review) {
|
|
4369
|
+
if (!pending && !review && !approval) {
|
|
4287
4370
|
const pauseIntent = detectPauseIntent(text.trim());
|
|
4288
4371
|
if (pauseIntent === 'pause') {
|
|
4289
4372
|
pauseProject(projectId);
|
|
@@ -4307,7 +4390,7 @@ const server = createServer(async (req, res) => {
|
|
|
4307
4390
|
// words then become a new ticket, which is what happens today anyway — a typo
|
|
4308
4391
|
// must never make a request disappear.
|
|
4309
4392
|
const identity = identityFor(req);
|
|
4310
|
-
if (!pending && !review) {
|
|
4393
|
+
if (!pending && !review && !approval) {
|
|
4311
4394
|
const addr = parseGoalAddress(text);
|
|
4312
4395
|
const target = addr && goals.find((g) => g.id === addr.goalId
|
|
4313
4396
|
&& g.projectId === projectId
|
|
@@ -4331,7 +4414,7 @@ const server = createServer(async (req, res) => {
|
|
|
4331
4414
|
// anything else gets one Haiku round-trip that both classifies and writes
|
|
4332
4415
|
// the reply. LLM failure falls back to 'task' — never drop a real
|
|
4333
4416
|
// implementation ask (the folder guard below still prevents no-ops).
|
|
4334
|
-
if (!pending && !review && classifyComposerIntentHeuristic(text.trim()) === 'chat') {
|
|
4417
|
+
if (!pending && !review && !approval && classifyComposerIntentHeuristic(text.trim()) === 'chat') {
|
|
4335
4418
|
// Only a HIGH-confidence chat/help/settings message reaches the LLM —
|
|
4336
4419
|
// to write the reply, and to let the model veto back to 'task' if the
|
|
4337
4420
|
// heuristic over-fired. Everything else (clear tasks AND ambiguous
|
|
@@ -4374,7 +4457,7 @@ const server = createServer(async (req, res) => {
|
|
|
4374
4457
|
// reusing the needsInput clarifying card; the answer (POST /answer)
|
|
4375
4458
|
// rebinds this goal to the chosen repo, then planning proceeds. Skipped
|
|
4376
4459
|
// for pending/review items (they never spawn a worker).
|
|
4377
|
-
const runnable = !pending && !review;
|
|
4460
|
+
const runnable = !pending && !review && !approval;
|
|
4378
4461
|
const needsFolder = runnable && needsProjectFolder({ dir: project.dir, homeDir: HOME, isRepo: isGitRepo(project.dir) });
|
|
4379
4462
|
const lang = /[-ヿ㐀-鿿]/.test(text) ? 'ja' : 'en';
|
|
4380
4463
|
const folderQuestion = needsFolder ? buildFolderQuestion(detectRepos(HOME), { lang }) : undefined;
|
|
@@ -4399,17 +4482,27 @@ const server = createServer(async (req, res) => {
|
|
|
4399
4482
|
if (review && existingPr && !importedPr) return json(res, 400, { error: 'existingPr must be a GitHub pull request URL' });
|
|
4400
4483
|
const goal = {
|
|
4401
4484
|
id: nextId++, projectId, text: text.trim().slice(0, 8000),
|
|
4402
|
-
status: (needsFolder || needsReviewPref) ? 'needsInput' : review ? 'review' : pending ? 'pending' : 'stacked',
|
|
4485
|
+
status: (needsFolder || needsReviewPref) ? 'needsInput' : approval ? 'needsApproval' : review ? 'review' : pending ? 'pending' : 'stacked',
|
|
4403
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,
|
|
4404
4497
|
reviewOnly: review ? true : undefined,
|
|
4405
4498
|
note: review && typeof note === 'string' && note.trim() ? note.trim().slice(0, 8000) : undefined,
|
|
4406
|
-
wantsPR: review ? Boolean(importedPr) : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
|
|
4499
|
+
wantsPR: (review || approval) ? Boolean(importedPr) : resolveWantsPR(pr, text, getReviewDefinition(projectId).defaultWantsPR),
|
|
4407
4500
|
model: workerModel(goalAgent, model),
|
|
4408
4501
|
effort: workerEffort(goalAgent, effort),
|
|
4409
4502
|
agent: goalAgent,
|
|
4410
4503
|
// MODE (Auto/Plan): plan = propose a plan first, approve to execute.
|
|
4411
4504
|
// review-only goals never run a worker, so mode is moot → 'auto'.
|
|
4412
|
-
mode: (!review && GOAL_MODES.includes(mode)) ? mode : 'auto',
|
|
4505
|
+
mode: (!review && !approval && GOAL_MODES.includes(mode)) ? mode : 'auto',
|
|
4413
4506
|
// SKILL: an optional user/project skill (or command) to invoke first.
|
|
4414
4507
|
skill: (typeof skill === 'string' && skill.trim()) ? skill.trim().slice(0, 80) : null,
|
|
4415
4508
|
// WORKSPACE MODE: isolated-worktree is the default and uses a clean per-goal
|
|
@@ -4855,6 +4948,69 @@ const server = createServer(async (req, res) => {
|
|
|
4855
4948
|
}
|
|
4856
4949
|
|
|
4857
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
|
+
|
|
4858
5014
|
// Smart intake: answer a planner clarifying question → fold it into the goal
|
|
4859
5015
|
// and re-plan (no new worker; same goal continues to decomposition).
|
|
4860
5016
|
const answerMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/answer$/);
|
|
@@ -5028,6 +5184,7 @@ const server = createServer(async (req, res) => {
|
|
|
5028
5184
|
const goal = goals.find((g) => g.id === Number(replyMatch[1]));
|
|
5029
5185
|
if (!goal) return json(res, 404, { error: 'goal not found' });
|
|
5030
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' });
|
|
5031
5188
|
if (['stacked', 'planning'].includes(goal.status)) return json(res, 409, { error: 'goal not started yet — edit it instead' });
|
|
5032
5189
|
let body = '';
|
|
5033
5190
|
req.on('data', (d) => { body += d; });
|
package/package.json
CHANGED