@galda/cli 0.10.40 → 0.10.42
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/app/index.html +698 -68
- package/engine/lib.mjs +311 -87
- package/engine/server.mjs +392 -73
- package/engine/shot.mjs +16 -1
- 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, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
|
|
24
|
-
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks,
|
|
24
|
+
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, isInconclusiveTestRun, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages } from './lib.mjs';
|
|
25
25
|
import { createSerialQueue } from './lib.mjs';
|
|
26
|
+
import { collectProjectRules } from './lib.mjs';
|
|
26
27
|
import { openPR } from './pr.mjs';
|
|
27
28
|
import { runVerification, exerciseUi } from './verify.mjs';
|
|
28
29
|
|
|
@@ -759,10 +760,43 @@ function readActivityLog(taskId) {
|
|
|
759
760
|
try { return readFileSync(activityLogFile(taskId), 'utf8').split('\n').filter(Boolean); }
|
|
760
761
|
catch { return []; }
|
|
761
762
|
}
|
|
763
|
+
|
|
764
|
+
// ---- the conversation on a goal (docs/design/ONE-CONVERSATION.md §1.1) -----
|
|
765
|
+
// Append-only JSONL per goal. Unlike the activity log (frozen once, at the end
|
|
766
|
+
// of a task) a conversation grows over the whole life of the goal, so it is
|
|
767
|
+
// appended as it happens. Never sent with /api/state — read on demand by
|
|
768
|
+
// GET /api/goals/:id/messages, so 100 cards don't drag 100 threads along.
|
|
769
|
+
// goal.messageCount is the only thing state carries: a number, not content, so
|
|
770
|
+
// the UI knows a thread exists without asking for one that was never written.
|
|
771
|
+
const goalMessagesFile = (goalId) => join(logDir, `messages-${goalId}.jsonl`);
|
|
772
|
+
function addGoalMessage(goal, { from, via, text, at } = {}) {
|
|
773
|
+
if (!goal) return null;
|
|
774
|
+
const msg = buildGoalMessage({ from, via, text, at });
|
|
775
|
+
if (!msg) return null;
|
|
776
|
+
try { appendFileSync(goalMessagesFile(goal.id), `${serializeGoalMessage(msg)}\n`); }
|
|
777
|
+
catch (e) { console.error(`[manager] goal message write failed for goal ${goal.id} (${e?.code || e})`); return null; }
|
|
778
|
+
goal.messageCount = (goal.messageCount ?? 0) + 1;
|
|
779
|
+
saveGoal(goal);
|
|
780
|
+
return msg;
|
|
781
|
+
}
|
|
782
|
+
function readGoalMessages(goalId) {
|
|
783
|
+
try { return parseGoalMessages(readFileSync(goalMessagesFile(goalId), 'utf8')); }
|
|
784
|
+
catch { return []; }
|
|
785
|
+
}
|
|
786
|
+
|
|
762
787
|
function saveTask(t) {
|
|
763
788
|
// freeze BEFORE the destructure strips `activity`, and mark the task so the UI knows a
|
|
764
789
|
// log exists without having to ask for one that was never written.
|
|
765
790
|
if (freezeActivityLog(t)) t.activityLog = true;
|
|
791
|
+
// The AI's side of the conversation (§1.1): what the worker reported back,
|
|
792
|
+
// verbatim. Once per task — saveTask runs on every status change, so the flag
|
|
793
|
+
// is set before the persist below and rides along in the log entry.
|
|
794
|
+
if (ACTIVITY_TERMINAL.has(t.status) && t.result && !t.messageLogged) {
|
|
795
|
+
t.messageLogged = true;
|
|
796
|
+
addGoalMessage(goals.find((g) => g.id === t.goalId), {
|
|
797
|
+
from: 'ai', via: t.reply ? 'thread' : 'composer', text: t.result,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
766
800
|
const { activity, todos, ...persist } = t; // activity + todos are ephemeral (live only)
|
|
767
801
|
logAppend(JSON.stringify({ ...persist, kind: 'task' }));
|
|
768
802
|
const goal = goals.find((g) => g.id === t.goalId);
|
|
@@ -1221,6 +1255,12 @@ function detectRepos(homeDir) {
|
|
|
1221
1255
|
// if worktrees can't be created (e.g. not a git repo).
|
|
1222
1256
|
const goalWorkDirs = new Map();
|
|
1223
1257
|
function goalWorkDir(goal, project) {
|
|
1258
|
+
// Masa 2026-07-22: the isolated copy is now a choice, the way the Claude app's
|
|
1259
|
+
// `worktree` checkbox is. Off = the work happens in the folder the person is looking
|
|
1260
|
+
// at, which is what they want when they mean to watch it happen. On (the default)
|
|
1261
|
+
// keeps today's behaviour: a private copy per goal, so a run can never leave their
|
|
1262
|
+
// checkout half-edited.
|
|
1263
|
+
if (goal?.worktree === false) return project.dir;
|
|
1224
1264
|
const cached = goalWorkDirs.get(goal.id);
|
|
1225
1265
|
if (cached && existsSync(cached)) return cached;
|
|
1226
1266
|
let repoRoot;
|
|
@@ -1432,13 +1472,21 @@ async function planGoal(goal) {
|
|
|
1432
1472
|
'- 各タスクの title は「ユーザーが頼んだこと」を、ユーザーの言葉・語彙をそのまま活かして短く書く。実装のやり方(「〜するように直した」「〜を計算する」等)や、完了報告の文(「〜した」「〜しました」)を title にしない。エンジニアリングの手順名(設計/実装/テスト等)にもしない。例: 依頼「一回たたむとsettingボタンがズレるバグ修正して」→ title「settingボタンがズレるバグの修正」(✕「アニメーションが終わってから歯車の位置を計算するように直した」)。',
|
|
1433
1473
|
'- バグ修正の依頼では、title に「どの問題を直すか」がわかるように書く(解決する問題=ユーザーの言葉)。依頼に直し方の方針が書かれていればそれも短く添えてよいが、無ければ問題だけでよい。工程・実装詳細は detail に書く。',
|
|
1434
1474
|
'- detail には worker への完全な指示を書く:そのworkerが1人で実装・テスト・proofまで完結させる。工程ごとの兄弟タスクは来ない前提で書くこと。',
|
|
1435
|
-
|
|
1475
|
+
// passCondition is gone from this prompt on purpose (Masa 2026-07-22). The
|
|
1476
|
+
// rule used to be "only when the user wrote one" — a rule this layer asked
|
|
1477
|
+
// for and could not hold: measured twice on the identical request with no
|
|
1478
|
+
// condition in it, the intake AI invented one on the first run and not on
|
|
1479
|
+
// the second. The run where it invented one then failed its own invented
|
|
1480
|
+
// check and forced a full rework (>100k tokens) that pushed the work
|
|
1481
|
+
// TOWARD the wrong target. What to look at is the working AI's to state
|
|
1482
|
+
// (`check` in .manager-run.json, #235) — the intake AI only read the
|
|
1483
|
+
// request, so it cannot know what "correct" is.
|
|
1436
1484
|
'- 検証対象のページがあれば entry に書く(プロジェクト相対のhtmlパス、または http URL。無ければ null)。依頼中に書かれていればそれを使い、無ければリポジトリを見て推測してよい。',
|
|
1437
1485
|
'- 依頼が曖昧で重要な前提が1つ欠けている(例: 対象・範囲・形式が不明)か、既存の作業と矛盾する場合「のみ」、タスクではなく確認質問を返す: {"question":"確認したい1点(短く)","options":["選択肢A","選択肢B"]}。ただし基本は合理的な仮定で進めること。質問は本当に必要な時だけ、1問に絞る。',
|
|
1438
1486
|
// Once the user has answered a clarification, NEVER ask again — re-asking looped
|
|
1439
1487
|
// the answered question back onto the board. Override the rule above for re-plans.
|
|
1440
1488
|
goal.clarified ? '【最重要】ユーザーは既にこの依頼への確認質問に回答済み(依頼文末尾に [確認: … → …] として反映済み)。これ以上、確認質問(question)を返してはならない。回答を前提に、必ずタスクへ分解して返すこと。' : '',
|
|
1441
|
-
'- 出力はJSONのみ(前後に文章を書かない)。通常: {"entry":"path|null","tasks":[{"title":"ユーザーから見た成果(依頼と同じ言語で短く)","detail":"workerへの完全な指示"
|
|
1489
|
+
'- 出力はJSONのみ(前後に文章を書かない)。通常: {"entry":"path|null","tasks":[{"title":"ユーザーから見た成果(依頼と同じ言語で短く)","detail":"workerへの完全な指示"}]} / 確認が要る時だけ: {"question":"...(依頼と同じ言語)","options":["...","..."]}',
|
|
1442
1490
|
'',
|
|
1443
1491
|
goal.priorFailureMemory?.length ? [
|
|
1444
1492
|
'過去の失敗メモリ(同じ失敗を避けること):',
|
|
@@ -1492,7 +1540,11 @@ async function planGoal(goal) {
|
|
|
1492
1540
|
// DECOMPOSES the request into tasks the worker must follow. The request goes
|
|
1493
1541
|
// to the worker verbatim as ONE task; the worker (Claude Code / Codex) decides
|
|
1494
1542
|
// its own breakdown and emits it as its own TodoWrite, which the board shows.
|
|
1495
|
-
// One card = one request.
|
|
1543
|
+
// One card = one request. The pass condition no longer rides along at all:
|
|
1544
|
+
// it was the last place where an AI that had only READ the request decided
|
|
1545
|
+
// what "correct" means for it, and it did so at random (see the prompt note
|
|
1546
|
+
// above). Nothing here sets one, so `entry` now only survives as the page a
|
|
1547
|
+
// future explicit, user-written condition would open.
|
|
1496
1548
|
const title = goalTaskTitle(goal.text);
|
|
1497
1549
|
goal.plan = [title];
|
|
1498
1550
|
saveGoal(goal);
|
|
@@ -1500,7 +1552,6 @@ async function planGoal(goal) {
|
|
|
1500
1552
|
const num = (base.length ? Math.max(...base) : 0) + 1;
|
|
1501
1553
|
const task = buildGoalTask(goal, {
|
|
1502
1554
|
id: nextId++, num,
|
|
1503
|
-
passCondition: plan.tasks[0]?.passCondition ?? null,
|
|
1504
1555
|
createdAt: new Date().toISOString(),
|
|
1505
1556
|
});
|
|
1506
1557
|
tasks.push(task);
|
|
@@ -1825,7 +1876,11 @@ function resumeProject(projectId) {
|
|
|
1825
1876
|
|
|
1826
1877
|
// Slack風スレッド返信タスクの生成(/api/goals/:id/reply と /api/goals/:id/dismiss で共有)。
|
|
1827
1878
|
// runTask()のtask.reply分岐がgoal.sessionIdをresumeに渡して同じ会話を続ける。
|
|
1828
|
-
function queueReplyTask(goal, text) {
|
|
1879
|
+
function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {}) {
|
|
1880
|
+
// Every follow-up on a goal is part of its conversation (§1.1). Recorded here
|
|
1881
|
+
// rather than at each call site so an internally-generated nudge (auto-retest
|
|
1882
|
+
// dismissal, fold-in) is kept too — with `from` saying whose words they are.
|
|
1883
|
+
addGoalMessage(goal, { from, via, text });
|
|
1829
1884
|
const failurePolicy = latestFailurePolicy(goal);
|
|
1830
1885
|
const promptText = failurePolicy ? `${failurePolicy.text}\n\n${text}` : text;
|
|
1831
1886
|
goal.executionPlan = buildExecutionPlan({
|
|
@@ -1863,6 +1918,38 @@ function queueReplyTask(goal, text) {
|
|
|
1863
1918
|
return task;
|
|
1864
1919
|
}
|
|
1865
1920
|
|
|
1921
|
+
// split (docs/design/ONE-CONVERSATION.md §1.3): 「それは別件」と返された時の受け皿。
|
|
1922
|
+
// 元のゴールには触らない——新しいチケットを1枚起こし、そこに「どこから生まれたか」だけ
|
|
1923
|
+
// 片方向の印(bornFrom)を残す。
|
|
1924
|
+
//
|
|
1925
|
+
// なぜ片方向か(Masa確定 2026-07-22): 必要なのは「なぜこのチケットが在るか」だけで、
|
|
1926
|
+
// 2つをまとめて管理したいわけではない。関係を双方向にすると「解除」の仕組みまで要り、
|
|
1927
|
+
// 作られないまま残る仕組みが1つ増える。片方向なら片方が消えても壊れない。
|
|
1928
|
+
//
|
|
1929
|
+
// 設定は親から引き継ぐ(プロジェクト・エージェント・モデル・effort)。別件とはいえ
|
|
1930
|
+
// 同じ場所で同じ人が続けている作業なので、ここで選び直させる理由が無い。
|
|
1931
|
+
function createSplitGoal(parent, text, identity) {
|
|
1932
|
+
const goal = {
|
|
1933
|
+
id: nextId++, projectId: parent.projectId, text: text.slice(0, 8000), status: 'planning',
|
|
1934
|
+
wantsPR: resolveWantsPR('auto', text, getReviewDefinition(parent.projectId).defaultWantsPR),
|
|
1935
|
+
model: parent.model, effort: parent.effort, agent: parent.agent, mode: 'auto',
|
|
1936
|
+
skill: null, images: [], source: resolveGoalSource('app'), sourceRef: null,
|
|
1937
|
+
executionPlan: buildExecutionPlan({ projectId: parent.projectId, text, previousSession: false }),
|
|
1938
|
+
plan: null, pr: undefined, createdAt: new Date().toISOString(),
|
|
1939
|
+
// 片方向の印。表示は app(新しいカード「#N の会話から生まれました」/元のカード
|
|
1940
|
+
// 「→ #M を切り出しました」)。engine は数字を1つ持つだけ。
|
|
1941
|
+
bornFrom: parent.id,
|
|
1942
|
+
owner: identity,
|
|
1943
|
+
};
|
|
1944
|
+
goals.push(goal);
|
|
1945
|
+
saveGoal(goal);
|
|
1946
|
+
addGoalMessage(goal, { from: 'you', via: 'review', text: goal.text });
|
|
1947
|
+
planGoal(goal).catch((e) => {
|
|
1948
|
+
goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
|
|
1949
|
+
});
|
|
1950
|
+
return goal;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1866
1953
|
function testOutputExcerpt(text) {
|
|
1867
1954
|
const lines = String(text ?? '').split('\n').map((l) => l.trimEnd()).filter(Boolean);
|
|
1868
1955
|
const interesting = lines.filter((l) =>
|
|
@@ -1990,7 +2077,24 @@ function readRunDeclaration(task, workDir) {
|
|
|
1990
2077
|
return;
|
|
1991
2078
|
}
|
|
1992
2079
|
task.run = { ...run, dir: workDir };
|
|
1993
|
-
|
|
2080
|
+
const how = run.cmd ?? run.url;
|
|
2081
|
+
if (how) sendProcessAct(task, `Worker declared how to see the result: ${how}`);
|
|
2082
|
+
if (run.title) sendProcessAct(task, `Worker named the work: ${run.title}`);
|
|
2083
|
+
if (run.compare) sendProcessAct(task, `Worker declared a before/after pair: ${run.compare.join(' → ')}`);
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// A declared pair only survives if both pictures actually arrived. The card
|
|
2087
|
+
// draws a red box over whatever differs between the two, so a name matching no
|
|
2088
|
+
// collected shot would leave the reviewer half a comparison — show the plain
|
|
2089
|
+
// screenshots and say why instead. Runs after the shots are collected.
|
|
2090
|
+
function reconcileComparePair(task) {
|
|
2091
|
+
const pair = task.run?.compare;
|
|
2092
|
+
if (!pair) return;
|
|
2093
|
+
const have = new Set((task.shots ?? []).map((s) => s.name));
|
|
2094
|
+
const missing = pair.filter((n) => !have.has(n));
|
|
2095
|
+
if (!missing.length) return;
|
|
2096
|
+
task.run = { ...task.run, compare: null };
|
|
2097
|
+
sendAct(task, `before/after pair ignored — no screenshot named ${missing.join(', ')}`);
|
|
1994
2098
|
}
|
|
1995
2099
|
|
|
1996
2100
|
// Take whatever the worker photographed out of the worktree (SHOT_DIR) and put
|
|
@@ -2161,12 +2265,14 @@ async function runTask(task) {
|
|
|
2161
2265
|
return;
|
|
2162
2266
|
}
|
|
2163
2267
|
|
|
2164
|
-
|
|
2268
|
+
// No `lastFailure` any more: it existed only to tell the next attempt why our
|
|
2269
|
+
// check had rejected the last one, and there is no next attempt now.
|
|
2270
|
+
let verify = null, verdict = null, code = 0, result = '', usages = [], timedOut = false;
|
|
2165
2271
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2166
2272
|
let callT0 = Date.now();
|
|
2167
2273
|
const plan = goal?.executionPlan;
|
|
2168
2274
|
const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goal?.sessionId ?? undefined);
|
|
2169
|
-
const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal,
|
|
2275
|
+
const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, null, workerAgent(task.agent ?? goal?.agent))].filter(Boolean).join('\n\n');
|
|
2170
2276
|
sendProcessAct(task, `Starting ${workerAgent(task.agent ?? goal?.agent) === 'codex' ? 'Codex' : 'Claude Code'} worker (attempt ${attempt}/${maxAttempts}).`);
|
|
2171
2277
|
let r = await runWorkerAgent({
|
|
2172
2278
|
agent: task.agent ?? goal?.agent,
|
|
@@ -2195,7 +2301,7 @@ async function runTask(task) {
|
|
|
2195
2301
|
sendProcessAct(task, 'Starting a fresh worker session because the warm session could not resume.');
|
|
2196
2302
|
r = await runWorkerAgent({
|
|
2197
2303
|
agent: task.agent ?? goal?.agent,
|
|
2198
|
-
prompt: workerPrompt(task, goal,
|
|
2304
|
+
prompt: workerPrompt(task, goal, null, workerAgent(task.agent ?? goal?.agent)), cwd: workDir, tools: WORKER_TOOLS,
|
|
2199
2305
|
permissionMode,
|
|
2200
2306
|
model: task.model ?? goal?.model, effort: task.effort ?? goal?.effort, onEvent: (line) => sendAct(task, line),
|
|
2201
2307
|
onTodos: (todos) => sendTodos(task, todos),
|
|
@@ -2234,12 +2340,14 @@ async function runTask(task) {
|
|
|
2234
2340
|
eph?.kill();
|
|
2235
2341
|
}
|
|
2236
2342
|
sendAct(task, `verify: ${verdict.pass ? 'PASS' : verdict.inconclusive ? 'INCONCLUSIVE (check could not run)' : 'FAIL'} — ${verdict.detail.slice(0, 120)}`);
|
|
2237
|
-
//
|
|
2238
|
-
//
|
|
2239
|
-
//
|
|
2240
|
-
//
|
|
2241
|
-
|
|
2242
|
-
|
|
2343
|
+
// Whatever the verdict, the worker is not sent back (Masa 2026-07-22).
|
|
2344
|
+
// A rework used to be triggered by a FAIL — and the check that produced
|
|
2345
|
+
// the FAIL is something this layer authored from the request, not from
|
|
2346
|
+
// the work. When it was wrong, the rework spent more than the whole
|
|
2347
|
+
// original run and taught the AI to chase the wrong target. The failing
|
|
2348
|
+
// check is reported in review instead; a human decides whether to ask
|
|
2349
|
+
// for another pass. (Inconclusive already stopped here, #487.)
|
|
2350
|
+
break;
|
|
2243
2351
|
} catch (e) {
|
|
2244
2352
|
sendAct(task, `verify: could not run — ${String(e.message ?? e).slice(0, 120)}`);
|
|
2245
2353
|
verdict = null;
|
|
@@ -2249,28 +2357,21 @@ async function runTask(task) {
|
|
|
2249
2357
|
|
|
2250
2358
|
if (goal?.cancelled) return; // deleted during the verify loop → don't resurrect the task
|
|
2251
2359
|
task.secs = Math.round((Date.now() - t0) / 1000);
|
|
2252
|
-
// Status decision, most-authoritative first:
|
|
2360
|
+
// Status decision, most-authoritative first (taskStatusAfterVerify in lib):
|
|
2253
2361
|
// 1. an independent verify PASS = verified success — wins even if the worker
|
|
2254
2362
|
// was force-stopped afterwards (the change is proven good).
|
|
2255
2363
|
// 2. a forced STOP (10-min timeout / SIGTERM, exit 143) = 'interrupted', NOT
|
|
2256
|
-
// 'failed' — it's a retryable halt, not "the worker's code was wrong".
|
|
2257
|
-
//
|
|
2258
|
-
//
|
|
2259
|
-
//
|
|
2260
|
-
//
|
|
2261
|
-
// verifier is at fault, not necessarily the change, #487) is NOT
|
|
2262
|
-
// authoritative: fall through to the exit code so a cleanly-finished
|
|
2263
|
-
// worker still reaches review for a human, instead of a false 'failed'.
|
|
2264
|
-
// 5. no verify = the exit code decides.
|
|
2364
|
+
// 'failed' — it's a retryable halt, not "the worker's code was wrong".
|
|
2365
|
+
// 3. otherwise the exit code decides. A check that ran and FAILED no longer
|
|
2366
|
+
// scores 'failed' (Masa 2026-07-22) — it is reported in review like an
|
|
2367
|
+
// inconclusive one. Our check can be wrong about the work; a cleanly
|
|
2368
|
+
// finished worker must not be marked failed by it.
|
|
2265
2369
|
const forcedStop = isForcedStop(code, timedOut);
|
|
2266
2370
|
// A verdict only counts as authoritative when the check actually judged the
|
|
2267
2371
|
// change. An inconclusive verdict (check threw / returned garbage) is treated
|
|
2268
|
-
// like "no verdict"
|
|
2372
|
+
// like "no verdict" for the proof below.
|
|
2269
2373
|
const conclusive = verdict && !verdict.inconclusive;
|
|
2270
|
-
task.status = verdict
|
|
2271
|
-
: forcedStop ? 'interrupted'
|
|
2272
|
-
: conclusive ? 'failed'
|
|
2273
|
-
: (code === 0 ? 'done' : 'failed');
|
|
2374
|
+
task.status = taskStatusAfterVerify({ verdict, code, timedOut });
|
|
2274
2375
|
// Freeze the worker's own final TodoWrite list (P0b): live `todos` are stripped
|
|
2275
2376
|
// on save, so snapshot them into `todosFinal` — persisted (blacklist saveTask /
|
|
2276
2377
|
// trimTaskActivityForState keep it) so the board/review can show what the AI
|
|
@@ -2323,6 +2424,7 @@ async function runTask(task) {
|
|
|
2323
2424
|
// deliverable, and it must not show up in the diff or the PR.
|
|
2324
2425
|
readRunDeclaration(task, workDir);
|
|
2325
2426
|
collectWorkerShots(task, workDir);
|
|
2427
|
+
reconcileComparePair(task);
|
|
2326
2428
|
const after = await gitChanges(workDir);
|
|
2327
2429
|
task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
|
|
2328
2430
|
sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
|
|
@@ -2364,8 +2466,11 @@ const testRunQueue = createSerialQueue();
|
|
|
2364
2466
|
// project's own `npm test` in the goal's worktree and parse node --test's
|
|
2365
2467
|
// "ℹ pass N / ℹ fail N" (or TAP "# pass/# fail"). Returns {ran,passed,failed,ok}.
|
|
2366
2468
|
function runTestsOnce(dir) {
|
|
2469
|
+
// The timeout is env-overridable so tests can force the killed-run path fast;
|
|
2470
|
+
// production keeps the 4-minute default.
|
|
2471
|
+
const runTimeout = Number(process.env.MANAGER_TEST_TIMEOUT_MS) || 240000;
|
|
2367
2472
|
return testRunQueue.run(() => new Promise((res) => {
|
|
2368
|
-
execFile('npm', ['test'], { cwd: dir, timeout:
|
|
2473
|
+
execFile('npm', ['test'], { cwd: dir, timeout: runTimeout, env: { ...process.env }, maxBuffer: 8 * 1024 * 1024 }, (e, out = '', err = '') => {
|
|
2369
2474
|
const text = `${out}\n${err}`;
|
|
2370
2475
|
const n = (re) => { const m = text.match(re); return m ? Number(m[1]) : null; };
|
|
2371
2476
|
const passed = n(/ℹ pass (\d+)/) ?? n(/# pass (\d+)/) ?? n(/(\d+) passing/) ?? 0;
|
|
@@ -2379,9 +2484,15 @@ function runTestsOnce(dir) {
|
|
|
2379
2484
|
const timeoutFlakes = (text.match(/did not report ready in time/g) || []).length;
|
|
2380
2485
|
const realAssertion = /AssertionError|Expected values to be|expected .+ (?:to |but )/i.test(text);
|
|
2381
2486
|
const failed = realAssertion ? failedTotal : Math.max(0, failedTotal - timeoutFlakes);
|
|
2487
|
+
// Was the run KILLED at the execFile timeout (machine too saturated to
|
|
2488
|
+
// finish), rather than exiting on its own? execFile sets killed=true and
|
|
2489
|
+
// signal='SIGTERM' on timeout. That is the inconclusive signal — a normal
|
|
2490
|
+
// non-zero exit (a small suite that printed `not ok` and exited 1) is NOT
|
|
2491
|
+
// killed and must still be treated as a real result (isInconclusiveTestRun).
|
|
2492
|
+
const timedOut = Boolean(e && (e.killed || e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT'));
|
|
2382
2493
|
// Capture the failing test NAMES too (not just the count) so the gate can
|
|
2383
2494
|
// diff them against the base's failures and block only on NEW ones.
|
|
2384
|
-
res({ passed, failed, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
|
|
2495
|
+
res({ passed, failed, timedOut, realAssertion, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
|
|
2385
2496
|
});
|
|
2386
2497
|
}));
|
|
2387
2498
|
}
|
|
@@ -2400,11 +2511,16 @@ async function runProjectTests(dir) {
|
|
|
2400
2511
|
r = {
|
|
2401
2512
|
passed: Math.max(r.passed, r2.passed),
|
|
2402
2513
|
failed: Math.min(r.failed, r2.failed),
|
|
2514
|
+
// Only an ALL-killed pair is inconclusive: if EITHER attempt exited on its
|
|
2515
|
+
// own (timedOut=false) the result is conclusive. realAssertion is OR-ed so
|
|
2516
|
+
// a real red in either run still fails closed.
|
|
2517
|
+
timedOut: Boolean(r.timedOut && r2.timedOut),
|
|
2518
|
+
realAssertion: r.realAssertion || r2.realAssertion,
|
|
2403
2519
|
detail: kept.detail || r.detail || r2.detail || '',
|
|
2404
2520
|
failingNames: kept.failingNames ?? r.failingNames ?? [],
|
|
2405
2521
|
};
|
|
2406
2522
|
}
|
|
2407
|
-
return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
|
|
2523
|
+
return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, timedOut: !!r.timedOut, realAssertion: !!r.realAssertion, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
|
|
2408
2524
|
}
|
|
2409
2525
|
|
|
2410
2526
|
// Failing tests on the BASE (pre-change) checkout — the set the review gate
|
|
@@ -2588,11 +2704,15 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
|
|
|
2588
2704
|
const lang = reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : (reviewDefinition.language || 'ja');
|
|
2589
2705
|
const changed = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].slice(0, 20);
|
|
2590
2706
|
const reports = siblings.filter((t) => !t.reply).map((t) => `- ${t.title}: ${(t.result ?? '').replace(/\s+/g, ' ').slice(0, 300)}`).join('\n');
|
|
2591
|
-
//
|
|
2592
|
-
//
|
|
2593
|
-
//
|
|
2707
|
+
// The name of the work, from the agent that did it (task.run.title). It wins
|
|
2708
|
+
// over both the summarizer and the request text: the summarizer only ever read
|
|
2709
|
+
// the same request the user typed, and the request text is the sentence the
|
|
2710
|
+
// title is supposed to be an answer to. Masa 2026-07-22.
|
|
2711
|
+
const declaredTitle = siblings.map((t) => t.run?.title).find(Boolean) ?? null;
|
|
2712
|
+
// fallback: the request text, still — but now only when nobody named the work
|
|
2713
|
+
// at all (an old worker, or one that wrote no declaration).
|
|
2594
2714
|
const fallback = {
|
|
2595
|
-
title: (goal.text ?? '').replace(/\s+/g, ' ').slice(0, 60),
|
|
2715
|
+
title: declaredTitle ?? (goal.text ?? '').replace(/\s+/g, ' ').slice(0, 60),
|
|
2596
2716
|
check: '',
|
|
2597
2717
|
changed: (goal.plan?.[0] ?? goal.text ?? '').replace(/\s+/g, ' ').slice(0, 80),
|
|
2598
2718
|
};
|
|
@@ -2600,7 +2720,8 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
|
|
|
2600
2720
|
const prompt = buildReviewSummaryPrompt({ lang, goalText: goal.text, changed, reports });
|
|
2601
2721
|
try {
|
|
2602
2722
|
const r = await runUtility({ prompt, cwd: dir, tools: 'Read' });
|
|
2603
|
-
|
|
2723
|
+
const summary = parseReviewSummary(r.result, fallback);
|
|
2724
|
+
return declaredTitle ? { ...summary, title: declaredTitle } : summary;
|
|
2604
2725
|
} catch { /* fall through */ }
|
|
2605
2726
|
return fallback;
|
|
2606
2727
|
}
|
|
@@ -2734,7 +2855,7 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
|
|
|
2734
2855
|
// The target re-opens: it has new work to do, and createGoalPR's rework path
|
|
2735
2856
|
// will push the result onto the PR branch it already owns.
|
|
2736
2857
|
foldTarget.status = 'running';
|
|
2737
|
-
queueReplyTask(foldTarget, goal.text.slice(0, 8000));
|
|
2858
|
+
queueReplyTask(foldTarget, goal.text.slice(0, 8000), { from: 'you', via: 'composer' });
|
|
2738
2859
|
saveGoal(foldTarget);
|
|
2739
2860
|
// The folded goal keeps its row (PRD §3.5: a submission is never dropped) —
|
|
2740
2861
|
// it becomes a pointer at the goal that now carries its work.
|
|
@@ -2840,6 +2961,16 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
2840
2961
|
// Manager-shaped repos.
|
|
2841
2962
|
const proofFailing = siblings.some((t) => t.proof && t.proof.pass === false);
|
|
2842
2963
|
let testsFailing = goal.testResult.ran && goal.testResult.failed > 0;
|
|
2964
|
+
// Inconclusive run (2026-07-23): the suite was killed before it could finish
|
|
2965
|
+
// (0 passed, no summary, no real assertion) — the machine was too saturated to
|
|
2966
|
+
// run the tests, not the code failing. Blocking a finished goal (with a PR) on
|
|
2967
|
+
// that stranded #602/#604. Treat it like the infra-flake discount above: don't
|
|
2968
|
+
// block, note it, and let a human re-run when the machine is idle.
|
|
2969
|
+
if (testsFailing && isInconclusiveTestRun(goal.testResult)) {
|
|
2970
|
+
testsFailing = false;
|
|
2971
|
+
goal.testResult.inconclusive = true;
|
|
2972
|
+
goalAct('Project tests could not complete — the suite was killed before finishing (0 passed, no real assertion), which means the machine was too saturated to run them, not a code failure. Treating as inconclusive, not blocking. Re-run the tests when the machine is idle to confirm.');
|
|
2973
|
+
}
|
|
2843
2974
|
// Baseline diff (Masa 2026-07-19): block ONLY on failures THIS change
|
|
2844
2975
|
// introduced. A test already red on the base — e.g. persist-failure on clean
|
|
2845
2976
|
// origin/main — is pre-existing noise and must not knock a proof-verified
|
|
@@ -3159,7 +3290,14 @@ const server = createServer(async (req, res) => {
|
|
|
3159
3290
|
price: await getBillingPrice(),
|
|
3160
3291
|
siteUrl: BILLING_API_URL || null,
|
|
3161
3292
|
};
|
|
3162
|
-
|
|
3293
|
+
// The branch each project is on — the Context Bar prints it next to the folder, and
|
|
3294
|
+
// it is read here rather than guessed (a branch nobody can see is not a branch).
|
|
3295
|
+
const projectsWithBranch = projects.map((p) => {
|
|
3296
|
+
let branch = null;
|
|
3297
|
+
try { branch = gitSync(p.dir, ['rev-parse', '--abbrev-ref', 'HEAD']); } catch { /* not a repo → no branch, and the bar shows none */ }
|
|
3298
|
+
return branch ? { ...p, branch } : p;
|
|
3299
|
+
});
|
|
3300
|
+
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() });
|
|
3163
3301
|
}
|
|
3164
3302
|
|
|
3165
3303
|
// Billing (docs/BILLING-LAUNCH-PLAN.md): activate the license key a user
|
|
@@ -3287,6 +3425,32 @@ const server = createServer(async (req, res) => {
|
|
|
3287
3425
|
return json(res, r.action === 'busy' ? 409 : 200, r);
|
|
3288
3426
|
}
|
|
3289
3427
|
|
|
3428
|
+
// The folders a person could actually point a project at: the git repos sitting
|
|
3429
|
+
// directly under HOME. A browser cannot open an OS folder dialog that yields a real
|
|
3430
|
+
// path (showDirectoryPicker hands back a handle, never a path), so a detected list
|
|
3431
|
+
// plus typing a path IS the picker — and the detection already existed here, feeding
|
|
3432
|
+
// the "which folder?" question card. This endpoint only exposes what it already
|
|
3433
|
+
// finds; nothing new is scanned, invented, or ranked.
|
|
3434
|
+
if (url.pathname === '/api/repos' && req.method === 'GET') {
|
|
3435
|
+
return json(res, 200, { repos: detectRepos(HOME), home: HOME });
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
// The project's working rules (ONE-CONVERSATION §1.5a): the settings panel
|
|
3439
|
+
// shows a read-only view of whichever of CLAUDE.md / AGENTS.md exist in the
|
|
3440
|
+
// project dir — the instructions the connected AI actually reads. The browser
|
|
3441
|
+
// can't touch the filesystem, so it reads them here. Filenames are fixed
|
|
3442
|
+
// (collectProjectRules), so `project.dir` is the only path input and there is
|
|
3443
|
+
// no traversal surface.
|
|
3444
|
+
const rulesMatch = url.pathname.match(/^\/api\/projects\/([\w-]+)\/rules$/);
|
|
3445
|
+
if (rulesMatch && req.method === 'GET') {
|
|
3446
|
+
const project = projects.find((p) => p.id === rulesMatch[1]);
|
|
3447
|
+
if (!project) return json(res, 404, { error: 'project not found' });
|
|
3448
|
+
const files = collectProjectRules(project.dir, (dir, name) => {
|
|
3449
|
+
try { return readFileSync(join(dir, name), 'utf8'); } catch { return null; }
|
|
3450
|
+
});
|
|
3451
|
+
return json(res, 200, { dir: project.dir, files });
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3290
3454
|
if (url.pathname === '/api/projects' && req.method === 'POST') {
|
|
3291
3455
|
let body = '';
|
|
3292
3456
|
req.on('data', (d) => { body += d; });
|
|
@@ -3337,6 +3501,21 @@ const server = createServer(async (req, res) => {
|
|
|
3337
3501
|
try {
|
|
3338
3502
|
const input = JSON.parse(body || '{}');
|
|
3339
3503
|
if (typeof input.name === 'string' && input.name.trim()) project.name = input.name.trim().slice(0, 80);
|
|
3504
|
+
// Moving a project to another folder. Refused loudly when the path is not
|
|
3505
|
+
// somewhere work can happen: a folder that does not exist, or is HOME itself,
|
|
3506
|
+
// or is not a git repository. Accepting it here would look like it worked and
|
|
3507
|
+
// then fail silently on the next goal — the worst of the two failures, and the
|
|
3508
|
+
// reason the "which folder?" card exists at all.
|
|
3509
|
+
if (input.dir !== undefined) {
|
|
3510
|
+
const dir = String(input.dir ?? '').trim();
|
|
3511
|
+
if (!dir) return json(res, 400, { error: 'folder is required' });
|
|
3512
|
+
const abs = dir.startsWith('~') ? join(HOME, dir.slice(1)) : resolve(dir);
|
|
3513
|
+
if (!existsSync(abs)) return json(res, 400, { error: 'no such folder', dir: abs });
|
|
3514
|
+
if (needsProjectFolder({ dir: abs, homeDir: HOME, isRepo: isGitRepo(abs) })) {
|
|
3515
|
+
return json(res, 400, { error: 'not a git repository', dir: abs });
|
|
3516
|
+
}
|
|
3517
|
+
project.dir = abs;
|
|
3518
|
+
}
|
|
3340
3519
|
saveProjects();
|
|
3341
3520
|
send({ ev: 'projects', projects });
|
|
3342
3521
|
return json(res, 200, { project, projects });
|
|
@@ -3383,7 +3562,7 @@ const server = createServer(async (req, res) => {
|
|
|
3383
3562
|
req.on('data', (d) => { body += d; });
|
|
3384
3563
|
req.on('end', async () => {
|
|
3385
3564
|
try {
|
|
3386
|
-
const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent } = JSON.parse(body);
|
|
3565
|
+
const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent, worktree } = JSON.parse(body);
|
|
3387
3566
|
const project = projects.find((p) => p.id === projectId);
|
|
3388
3567
|
if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
|
|
3389
3568
|
// Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
|
|
@@ -3401,6 +3580,32 @@ const server = createServer(async (req, res) => {
|
|
|
3401
3580
|
return json(res, 200, { kind: 'chat', reply: `${project.name} を再開しました。止めた作業を続きから進めます。` });
|
|
3402
3581
|
}
|
|
3403
3582
|
}
|
|
3583
|
+
// 宛先 (docs/design/ONE-CONVERSATION.md §1.2): 「#12 の件だけど…」と書けたら、
|
|
3584
|
+
// その番号のチケット宛て。決定論——我々の層は何も推測しない。
|
|
3585
|
+
//
|
|
3586
|
+
// Checked BEFORE the chat/task classifier on purpose: writing a number is an
|
|
3587
|
+
// explicit statement of where the words go, and a guess (however cheap) must
|
|
3588
|
+
// not be able to overrule it. "#12 これで合ってる?" reads as chat to the
|
|
3589
|
+
// heuristic; it is a follow-up on #12.
|
|
3590
|
+
//
|
|
3591
|
+
// Falls through silently when the number names nothing this person can act on
|
|
3592
|
+
// (unknown id, another project, someone else's goal, already finished). The
|
|
3593
|
+
// words then become a new ticket, which is what happens today anyway — a typo
|
|
3594
|
+
// must never make a request disappear.
|
|
3595
|
+
const identity = identityFor(req);
|
|
3596
|
+
if (!pending && !review) {
|
|
3597
|
+
const addr = parseGoalAddress(text);
|
|
3598
|
+
const target = addr && goals.find((g) => g.id === addr.goalId
|
|
3599
|
+
&& g.projectId === projectId
|
|
3600
|
+
&& goalVisibleTo(g, identity, MANAGER_OWNER_EMAIL)
|
|
3601
|
+
&& !['done', 'reverted', 'skipped', 'rejected'].includes(g.status));
|
|
3602
|
+
if (target) {
|
|
3603
|
+
// A follow-up on a goal already awaiting review re-opens work on it.
|
|
3604
|
+
if (target.status === 'review') { target.status = 'running'; saveGoal(target); }
|
|
3605
|
+
const replyTask = queueReplyTask(target, text.trim().slice(0, 8000), { from: 'you', via: 'composer' });
|
|
3606
|
+
return json(res, 200, { ...target, addressedTo: target.id, replyTaskId: replyTask.id });
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3404
3609
|
// Onboarding (docs/HANDOFF-ONBOARDING-conversational.md, SLICE 2): the
|
|
3405
3610
|
// composer is conversation-first. A chat/help/settings message ("what can
|
|
3406
3611
|
// you do?", "reply in English", a greeting) gets a conversational reply
|
|
@@ -3427,29 +3632,14 @@ const server = createServer(async (req, res) => {
|
|
|
3427
3632
|
if (parsed.intent === 'chat') return json(res, 200, { kind: 'chat', reply: parsed.reply.slice(0, 2000), lang: intentLang });
|
|
3428
3633
|
} catch { /* LLM unavailable → fall through to goal creation */ }
|
|
3429
3634
|
}
|
|
3430
|
-
//
|
|
3431
|
-
//
|
|
3432
|
-
//
|
|
3433
|
-
//
|
|
3434
|
-
//
|
|
3435
|
-
//
|
|
3436
|
-
//
|
|
3437
|
-
//
|
|
3438
|
-
const identity = identityFor(req);
|
|
3439
|
-
if (!pending && !review) {
|
|
3440
|
-
// Stage 2: only fold into a goal this identity can actually see —
|
|
3441
|
-
// otherwise a near-duplicate message from user B could silently
|
|
3442
|
-
// attach as a reply task onto someone else's private goal (and
|
|
3443
|
-
// leak that goal's text back to B in the response).
|
|
3444
|
-
const candidates = goals.filter((g) => g.projectId === projectId && g.sessionId && ['running', 'review'].includes(g.status) && goalVisibleTo(g, identity, MANAGER_OWNER_EMAIL));
|
|
3445
|
-
const overlap = findOverlappingGoal(text.trim(), candidates);
|
|
3446
|
-
if (overlap) {
|
|
3447
|
-
// A follow-up on a goal already awaiting review re-opens work on it.
|
|
3448
|
-
if (overlap.goal.status === 'review') { overlap.goal.status = 'running'; saveGoal(overlap.goal); }
|
|
3449
|
-
const replyTask = queueReplyTask(overlap.goal, text.trim().slice(0, 8000));
|
|
3450
|
-
return json(res, 200, { ...overlap.goal, mergedInto: overlap.goal.id, replyTaskId: replyTask.id, overlapScore: overlap.score });
|
|
3451
|
-
}
|
|
3452
|
-
}
|
|
3635
|
+
// (REMOVED 2026-07-22, ONE-CONVERSATION.md §1.2) The 0.72 auto-merge used to
|
|
3636
|
+
// sit here: any new request whose WORDS looked ~72% like an in-flight goal was
|
|
3637
|
+
// silently folded into it as a follow-up. That was our layer guessing "this is
|
|
3638
|
+
// probably the same thing" — the exact move this product exists to stop doing.
|
|
3639
|
+
// When it was right nobody noticed; when it was wrong the request vanished into
|
|
3640
|
+
// someone else's ticket. Replaced by the address above: the person says which
|
|
3641
|
+
// ticket, or it becomes its own.
|
|
3642
|
+
//
|
|
3453
3643
|
// Billing (docs/BILLING-LAUNCH-PLAN.md §2/§5): gate actual new-goal
|
|
3454
3644
|
// creation on the free tier / paid entitlement. Placed after the
|
|
3455
3645
|
// dedupe fold-in above on purpose — folding into an existing goal as
|
|
@@ -3502,6 +3692,11 @@ const server = createServer(async (req, res) => {
|
|
|
3502
3692
|
mode: (!review && GOAL_MODES.includes(mode)) ? mode : 'auto',
|
|
3503
3693
|
// SKILL: an optional user/project skill (or command) to invoke first.
|
|
3504
3694
|
skill: (typeof skill === 'string' && skill.trim()) ? skill.trim().slice(0, 80) : null,
|
|
3695
|
+
// WORKTREE (Masa 2026-07-22, the Claude app's own checkbox): true/undefined =
|
|
3696
|
+
// a private copy per goal, which is what has always happened. Explicit false =
|
|
3697
|
+
// work in the folder the person is looking at. Only an explicit false is
|
|
3698
|
+
// stored, so every goal ever written keeps meaning what it meant.
|
|
3699
|
+
worktree: worktree === false ? false : undefined,
|
|
3505
3700
|
images: Array.isArray(images) ? images.slice(0, 6) : [],
|
|
3506
3701
|
source: resolveGoalSource(source), sourceRef: null,
|
|
3507
3702
|
executionPlan: buildExecutionPlan({
|
|
@@ -3517,6 +3712,10 @@ const server = createServer(async (req, res) => {
|
|
|
3517
3712
|
};
|
|
3518
3713
|
goals.push(goal);
|
|
3519
3714
|
saveGoal(goal);
|
|
3715
|
+
// The conversation starts with what the person actually asked for, so a
|
|
3716
|
+
// thread opened later reads from the beginning instead of starting at
|
|
3717
|
+
// the first follow-up (§1.1: 入口が違っても1本の列).
|
|
3718
|
+
addGoalMessage(goal, { from: 'you', via: 'composer', text: goal.text, at: goal.createdAt });
|
|
3520
3719
|
// pending = deliberately shelved (Phase 2 material): never planned,
|
|
3521
3720
|
// never queued, until POST /api/goals/:id/activate. review-only items
|
|
3522
3721
|
// are terminal-until-human too, so they also skip planGoal.
|
|
@@ -3669,6 +3868,17 @@ const server = createServer(async (req, res) => {
|
|
|
3669
3868
|
return json(res, 200, { lines: task.activity?.length ? task.activity : readActivityLog(task.id) });
|
|
3670
3869
|
}
|
|
3671
3870
|
|
|
3871
|
+
// The conversation on one goal, read on demand (see addGoalMessage above).
|
|
3872
|
+
// Deliberately NOT part of /api/state: every card would drag its whole thread
|
|
3873
|
+
// along on every poll. Same "only when asked" shape as the implementation log.
|
|
3874
|
+
const messagesMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/messages$/);
|
|
3875
|
+
if (messagesMatch && req.method === 'GET') {
|
|
3876
|
+
const goal = goals.find((g) => g.id === Number(messagesMatch[1]));
|
|
3877
|
+
if (!goal) return json(res, 404, { error: 'goal not found' });
|
|
3878
|
+
if (!requireGoalOwnership(req, res, goal)) return;
|
|
3879
|
+
return json(res, 200, { messages: readGoalMessages(goal.id) });
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3672
3882
|
// close a stalled task without running it (failure-taxonomy: skipped)
|
|
3673
3883
|
const skipMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/skip$/);
|
|
3674
3884
|
if (skipMatch && req.method === 'POST') {
|
|
@@ -4008,7 +4218,7 @@ const server = createServer(async (req, res) => {
|
|
|
4008
4218
|
goal.blocked = null;
|
|
4009
4219
|
saveGoal(goal);
|
|
4010
4220
|
}
|
|
4011
|
-
json(res, 200, queueReplyTask(goal, text.trim()));
|
|
4221
|
+
json(res, 200, queueReplyTask(goal, text.trim(), { from: 'you', via: 'thread' }));
|
|
4012
4222
|
} catch { json(res, 400, { error: 'bad json' }); }
|
|
4013
4223
|
});
|
|
4014
4224
|
return;
|
|
@@ -4198,6 +4408,58 @@ const server = createServer(async (req, res) => {
|
|
|
4198
4408
|
return json(res, 200, payload);
|
|
4199
4409
|
}
|
|
4200
4410
|
|
|
4411
|
+
// 返事の行き先を AI 本人に決めてもらう (ONE-CONVERSATION.md §1.4)。
|
|
4412
|
+
//
|
|
4413
|
+
// 判定は1往復だけの軽い仕事なので、作業した本人ではなくユーティリティのモデルに
|
|
4414
|
+
// 聞く(答えを書く answer 本体は本人に聞く——あちらは中身を知っている必要がある)。
|
|
4415
|
+
// 呼べなかった時は 'unsure'。「たぶん continue」で走り出すより、人に1タップ選ばせる
|
|
4416
|
+
// ほうが安い。
|
|
4417
|
+
async function judgeReplyIntent(goal, reply) {
|
|
4418
|
+
let parsed;
|
|
4419
|
+
try {
|
|
4420
|
+
const r = await runUtility({ prompt: buildReplyIntentPrompt({ goalText: goal.text, reply }), cwd: ROOT, tools: 'Read' });
|
|
4421
|
+
parsed = parseReplyIntent(r.result);
|
|
4422
|
+
} catch { parsed = { outcome: 'unsure', read: false }; }
|
|
4423
|
+
// Could not ask at all → do what a reply has always done (continue). Blocking the
|
|
4424
|
+
// reply on four choices would leave a machine with no reachable agent unable to
|
|
4425
|
+
// send anything back, and an answer could not have been written there anyway.
|
|
4426
|
+
return parsed.read ? parsed.outcome : 'continue';
|
|
4427
|
+
}
|
|
4428
|
+
|
|
4429
|
+
// 「聞いてるだけ」への返事 (ONE-CONVERSATION.md §1.3 answer)。
|
|
4430
|
+
//
|
|
4431
|
+
// 答えるのは**作業した AI 本人**= goal.sessionId を resume する。状況一覧しか
|
|
4432
|
+
// 知らない別AIでは「なぜそうしたか」に答えられない(Masa確定 2026-07-22)。
|
|
4433
|
+
// コードは1文字も変わらない: ツールは Read だけ、permissionMode は 'plan'
|
|
4434
|
+
// (Codex 側は read-only サンドボックスに落ちる)。タスクも作らないので看板も
|
|
4435
|
+
// 動かない——answer は「何も動かさない」が仕様。
|
|
4436
|
+
// 失敗しても人が待ちぼうけにならないよう、理由を manager の発言として会話に残す。
|
|
4437
|
+
async function answerGoalQuestion(goal, question) {
|
|
4438
|
+
const project = projects.find((p) => p.id === goal.projectId);
|
|
4439
|
+
const cwd = project?.dir && existsSync(project.dir) ? project.dir : ROOT;
|
|
4440
|
+
const prompt = [
|
|
4441
|
+
'あなたが担当した作業について、依頼者から質問が来ています。',
|
|
4442
|
+
'コードは変更せず(読み取りしかできません)、質問にだけ簡潔に答えてください。',
|
|
4443
|
+
'これは作り直しの指示ではありません。答えだけを書いてください。',
|
|
4444
|
+
'',
|
|
4445
|
+
`依頼: ${String(goal.text ?? '').slice(0, 800)}`,
|
|
4446
|
+
`質問: ${question.slice(0, 1000)}`,
|
|
4447
|
+
].join('\n');
|
|
4448
|
+
try {
|
|
4449
|
+
const r = await runWorkerAgent({
|
|
4450
|
+
agent: goal.agent, model: goal.model, prompt, cwd,
|
|
4451
|
+
tools: 'Read', permissionMode: 'plan',
|
|
4452
|
+
resume: goal.sessionId ?? undefined,
|
|
4453
|
+
onChild: (c) => trackGoalWorker(goal.id, c),
|
|
4454
|
+
});
|
|
4455
|
+
const answer = String(r?.result ?? '').trim();
|
|
4456
|
+
if (answer) addGoalMessage(goal, { from: 'ai', via: 'review', text: answer });
|
|
4457
|
+
else addGoalMessage(goal, { from: 'manager', via: 'review', text: '答えが空でした。もう一度聞いてみてください。' });
|
|
4458
|
+
} catch (e) {
|
|
4459
|
+
addGoalMessage(goal, { from: 'manager', via: 'review', text: `答えられませんでした: ${String(e?.message ?? e).slice(0, 200)}` });
|
|
4460
|
+
}
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4201
4463
|
// レビュー詳細パネルのDismissボタン: 修正指示(空でも「差し戻し」扱い)をgoalのスレッド
|
|
4202
4464
|
// 返信としてworkerに渡し、goalをrunning(doing相当)へ戻す。
|
|
4203
4465
|
const dismissMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/dismiss$/);
|
|
@@ -4213,17 +4475,74 @@ const server = createServer(async (req, res) => {
|
|
|
4213
4475
|
saveGoal(goal);
|
|
4214
4476
|
return json(res, 200, { goal });
|
|
4215
4477
|
}
|
|
4216
|
-
|
|
4217
|
-
if (!result.ok) return json(res, 409, { error: result.error });
|
|
4478
|
+
if (goal.status !== 'review') return json(res, 409, { error: dismissGoal({ goalStatus: goal.status }).error });
|
|
4218
4479
|
let body = '';
|
|
4219
4480
|
req.on('data', (d) => { body += d; });
|
|
4220
|
-
req.on('end', () => {
|
|
4221
|
-
let text = '';
|
|
4222
|
-
try {
|
|
4481
|
+
req.on('end', async () => {
|
|
4482
|
+
let text = '', outcome = 'continue', auto = false;
|
|
4483
|
+
try {
|
|
4484
|
+
const parsed = JSON.parse(body || '{}');
|
|
4485
|
+
text = String(parsed.text ?? '').trim();
|
|
4486
|
+
if (REPLY_OUTCOMES.includes(parsed.outcome)) outcome = parsed.outcome;
|
|
4487
|
+
auto = parsed.outcome === 'auto';
|
|
4488
|
+
} catch {}
|
|
4489
|
+
|
|
4490
|
+
// outcome:'auto' = 決めるのは AI 本人 (§1.4)。人の言葉を1回だけ渡して、返って
|
|
4491
|
+
// きた行き先のとおりに配線する。我々はキーワードも類似度も見ない。
|
|
4492
|
+
// 迷ったと言われたら**ゴールに触らずに**選択肢を返す——ここで勝手に continue に
|
|
4493
|
+
// 倒すと、質問しただけの人のコードが動き出す(それが直したかった壊れ方)。
|
|
4494
|
+
if (auto) {
|
|
4495
|
+
if (!text) return json(res, 400, { error: 'text required' });
|
|
4496
|
+
const judged = await judgeReplyIntent(goal, text);
|
|
4497
|
+
if (judged === 'unsure') return json(res, 200, { goal, outcome: 'unsure', choices: REPLY_OUTCOMES });
|
|
4498
|
+
outcome = judged;
|
|
4499
|
+
}
|
|
4500
|
+
// 返事の行き先4種 (ONE-CONVERSATION.md §1.3). 既定は今までどおり continue なので、
|
|
4501
|
+
// outcome を送ってこない今の UI の挙動は1ミリも変わらない。
|
|
4502
|
+
const result = dismissGoal({ goalStatus: goal.status, outcome });
|
|
4503
|
+
if (!result.ok) return json(res, 409, { error: result.error });
|
|
4504
|
+
|
|
4505
|
+
// split: 元のゴールには触らない。新しいチケットが1枚生まれるだけ。
|
|
4506
|
+
if (result.splits) {
|
|
4507
|
+
if (!text) return json(res, 400, { error: 'text required to split' });
|
|
4508
|
+
// 新しいゴールが1枚増える=無料枠の対象。continue/rescope は増えないので通らない。
|
|
4509
|
+
const gate = await requireEntitlementGate(identityFor(req));
|
|
4510
|
+
if (!gate.allowed) return json(res, 402, { error: 'free-tier limit reached', blocked: gate.blocked });
|
|
4511
|
+
addGoalMessage(goal, { from: 'you', via: 'review', text });
|
|
4512
|
+
const born = createSplitGoal(goal, text, identityFor(req));
|
|
4513
|
+
return json(res, 200, { goal, outcome, splitInto: born.id, born });
|
|
4514
|
+
}
|
|
4515
|
+
|
|
4516
|
+
// answer: 聞かれただけ。ゴールは review のまま、ワーカーも起こさない(§1.3)。
|
|
4517
|
+
// 答えるのは**その作業をやった AI 本人**(goal.sessionId を resume)なので
|
|
4518
|
+
// 「なぜそうしたか」に答えられる。HTTP は待たせない——答えは会話に1発言
|
|
4519
|
+
// 積まれ、SSE の goal イベント(messageCount)で画面に出る。
|
|
4520
|
+
if (result.answers) {
|
|
4521
|
+
if (!text) return json(res, 400, { error: 'text required to answer' });
|
|
4522
|
+
addGoalMessage(goal, { from: 'you', via: 'review', text });
|
|
4523
|
+
json(res, 200, { goal, outcome, answering: true });
|
|
4524
|
+
answerGoalQuestion(goal, text);
|
|
4525
|
+
return;
|
|
4526
|
+
}
|
|
4527
|
+
|
|
4528
|
+
// rescope: チケットの文章が書き換わるのはここだけ。人が「そうじゃなくて、こう」と
|
|
4529
|
+
// 言い直した以上、古い依頼文を掲げたままにするのは嘘になる。元の言葉は会話に残る
|
|
4530
|
+
// (§1.1)ので失われない。
|
|
4531
|
+
// 断る判定はゴールに触る前に済ませる——400 を返しながら状態だけ動いていた、が
|
|
4532
|
+
// 一番たちの悪い壊れ方。
|
|
4533
|
+
if (result.rewritesText) {
|
|
4534
|
+
if (!text) return json(res, 400, { error: 'text required to rescope' });
|
|
4535
|
+
addGoalMessage(goal, { from: 'you', via: 'review', text });
|
|
4536
|
+
goal.text = text.slice(0, 8000);
|
|
4537
|
+
}
|
|
4223
4538
|
goal.status = result.status;
|
|
4224
4539
|
saveGoal(goal);
|
|
4225
|
-
|
|
4226
|
-
|
|
4540
|
+
// rescope はワーカーを起こさない——「いったん止めて考え直す」なので、走り出したら
|
|
4541
|
+
// 意味が無い。To Do の下で Start を待つ。
|
|
4542
|
+
if (!result.spawnsWorker) return json(res, 200, { goal, outcome });
|
|
4543
|
+
|
|
4544
|
+
const task = queueReplyTask(goal, text || '差し戻し。修正してください。', { from: text ? 'you' : 'manager', via: 'review' });
|
|
4545
|
+
json(res, 200, { goal, outcome, task });
|
|
4227
4546
|
});
|
|
4228
4547
|
return;
|
|
4229
4548
|
}
|