@galda/cli 0.10.94 → 0.10.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/engine/completion-contract.mjs +38 -0
- package/engine/server.mjs +159 -6
- package/package.json +1 -1
|
@@ -40,8 +40,46 @@ export const COMPLETION_CONTRACT_PROMPT = [
|
|
|
40
40
|
'- The latest explicit user instruction can revise an older product decision or code comment. Treat old comments as context, not proof that the requested change is impossible. If the change has consequences, explain them and implement the latest request; only a real safety, permission, legal, or external-system constraint is a blocker.',
|
|
41
41
|
'- A test must assert the observable outcome the user requested. Never replace the request with a tooltip, explanation, or a test that permanently preserves the opposite behavior.',
|
|
42
42
|
'- Do not report a change request as complete with “cannot fix”, “直せません”, or equivalent. If a real external blocker remains, state the concrete blocker and leave the work explicitly unfinished instead of presenting it for Review as completed.',
|
|
43
|
+
'- You own the routing decision. When the run is not complete, append exactly one single-line receipt: MANAGER_OUTCOME: {"outcome":"needs-input|blocked|rebind-workspace","summary":"short reason","question":"question for the user when needed","options":["optional choice"],"workingDirectory":"absolute path for rebind-workspace"}. Do not emit this receipt for completed work. Galda transports this declaration verbatim; it does not reinterpret your prose.',
|
|
43
44
|
].join('\n');
|
|
44
45
|
|
|
46
|
+
const MANAGER_OUTCOMES = new Set(['needs-input', 'blocked', 'rebind-workspace']);
|
|
47
|
+
|
|
48
|
+
// A transport receipt written by the worker, not a Manager inference. Keep the
|
|
49
|
+
// schema deliberately tiny: the agent decides what happened; Galda only checks
|
|
50
|
+
// that the envelope is safe to route.
|
|
51
|
+
export function parseManagerOutcome(raw) {
|
|
52
|
+
const m = String(raw ?? '').match(/MANAGER_OUTCOME:\s*(\{[^\n]+\})/);
|
|
53
|
+
if (!m) return null;
|
|
54
|
+
let value;
|
|
55
|
+
try { value = JSON.parse(m[1]); } catch { return null; }
|
|
56
|
+
if (!MANAGER_OUTCOMES.has(value?.outcome)) return null;
|
|
57
|
+
const summary = typeof value.summary === 'string' ? value.summary.trim().slice(0, 500) : '';
|
|
58
|
+
const question = typeof value.question === 'string' ? value.question.trim().slice(0, 300) : '';
|
|
59
|
+
const options = Array.isArray(value.options)
|
|
60
|
+
? value.options.map((x) => typeof x === 'string' ? x.trim().slice(0, 60) : '').filter(Boolean).slice(0, 4)
|
|
61
|
+
: [];
|
|
62
|
+
const workingDirectory = typeof value.workingDirectory === 'string' ? value.workingDirectory.trim().slice(0, 1000) : '';
|
|
63
|
+
if (value.outcome === 'needs-input' && !question) return null;
|
|
64
|
+
if (value.outcome === 'blocked' && !summary) return null;
|
|
65
|
+
if (value.outcome === 'rebind-workspace' && !workingDirectory.startsWith('/')) return null;
|
|
66
|
+
return { outcome: value.outcome, summary, question, options, workingDirectory };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Existing requirement evidence is also an explicit worker declaration. A
|
|
70
|
+
// worker cannot say `partial`/`not-addressed` and simultaneously be routed to
|
|
71
|
+
// Review as complete. This is schema consistency, not semantic judgment.
|
|
72
|
+
export function incompleteEvidenceOutcome(evidence) {
|
|
73
|
+
const incomplete = (evidence?.requirements ?? []).filter((r) => ['partial', 'not-addressed'].includes(r?.status));
|
|
74
|
+
if (!incomplete.length) return null;
|
|
75
|
+
return {
|
|
76
|
+
outcome: 'blocked',
|
|
77
|
+
summary: incomplete.map((r) => r.evidence || `${r.id}: ${r.status}`).filter(Boolean).join('; ').slice(0, 500)
|
|
78
|
+
|| 'The worker declared one or more requirements incomplete.',
|
|
79
|
+
question: '', options: [], workingDirectory: '',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
45
83
|
export function completionContractIssue({ request = '', followUp = '', result = '', status = '', changedFiles = [] } = {}) {
|
|
46
84
|
if (status !== 'done') return null;
|
|
47
85
|
const requested = `${request}\n${followUp}`;
|
package/engine/server.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { createSerialQueue } from './lib.mjs';
|
|
|
26
26
|
import { parseRequirementEvidence, unmappedChangedFiles } from './lib.mjs';
|
|
27
27
|
import { isPrClosed } from './lib.mjs';
|
|
28
28
|
import { collectProjectRules } from './lib.mjs';
|
|
29
|
-
import { COMPLETION_CONTRACT_PROMPT, completionContractIssue } from './completion-contract.mjs';
|
|
29
|
+
import { COMPLETION_CONTRACT_PROMPT, completionContractIssue, parseManagerOutcome, incompleteEvidenceOutcome } from './completion-contract.mjs';
|
|
30
30
|
import { FALLBACK_CODEX_MODELS, loadCodexModels } from './codex-models.mjs';
|
|
31
31
|
import { CLAUDE_MODELS } from './claude-models.mjs';
|
|
32
32
|
import { classifyWorkerPreflight, shouldProbeClaudeAuth, workerCanStart } from './worker-availability.mjs';
|
|
@@ -1425,8 +1425,19 @@ function gitStatusPaths(lines = []) {
|
|
|
1425
1425
|
function newAttemptId(goal, task) {
|
|
1426
1426
|
return `goal-${goal?.id ?? 'none'}-task-${task.id}-${Date.now()}-${randomBytes(3).toString('hex')}`;
|
|
1427
1427
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1428
|
+
// `refresh` re-measures a baseline that already exists. Without it the snapshot
|
|
1429
|
+
// is write-once: whatever the shared checkout happened to look like when the
|
|
1430
|
+
// goal's FIRST task started is what classifyPrSafety judges forever. So a goal
|
|
1431
|
+
// that began while some unrelated file was dirty is permanently unable to
|
|
1432
|
+
// produce a PR — "PR creation skipped: this goal started in a dirty shared
|
|
1433
|
+
// workspace" re-emits on every close-out even after the workspace was cleaned
|
|
1434
|
+
// minutes later, and no amount of asking can fix it. Measured on the operator's
|
|
1435
|
+
// instance 2026-07-25..29: goals #729 #733 #731 are all stuck this way.
|
|
1436
|
+
// Re-measuring is only correct while there is no PR yet — once a branch exists
|
|
1437
|
+
// it is the reference, and moving the baseline under it would compare the work
|
|
1438
|
+
// against the wrong starting point.
|
|
1439
|
+
function noteGoalTaskBaseline(goal, project, workDir, before, { refresh = false } = {}) {
|
|
1440
|
+
if (!goal || (goal.prBaseline && !refresh)) return;
|
|
1430
1441
|
const repoRoot = gitSyncOrNull(workDir, ['rev-parse', '--show-toplevel']);
|
|
1431
1442
|
const branch = repoRoot ? gitSyncOrNull(workDir, ['branch', '--show-current']) : null;
|
|
1432
1443
|
const head = repoRoot ? gitSyncOrNull(workDir, ['rev-parse', 'HEAD']) : null;
|
|
@@ -2299,6 +2310,66 @@ function pauseGoalForApproval(goal, task, request) {
|
|
|
2299
2310
|
return true;
|
|
2300
2311
|
}
|
|
2301
2312
|
|
|
2313
|
+
// The worker decides where its result belongs. Galda does not classify prose;
|
|
2314
|
+
// it only validates and transports the explicit receipt (or the worker's
|
|
2315
|
+
// existing partial/not-addressed evidence) into the corresponding board state.
|
|
2316
|
+
function routeWorkerOutcome(goal, task, project) {
|
|
2317
|
+
if (!goal || !task || task.status !== 'done') return false;
|
|
2318
|
+
const outcome = parseManagerOutcome(task.result) || incompleteEvidenceOutcome(task.requirementEvidence);
|
|
2319
|
+
if (!outcome) return false; // backwards-compatible completed run
|
|
2320
|
+
task.managerOutcome = outcome;
|
|
2321
|
+
task.finishedAt = new Date().toISOString();
|
|
2322
|
+
|
|
2323
|
+
if (outcome.outcome === 'rebind-workspace') {
|
|
2324
|
+
const rebound = rebindGoalWorkspace(goal, project, outcome.workingDirectory);
|
|
2325
|
+
if (rebound.ok) {
|
|
2326
|
+
task.status = 'skipped';
|
|
2327
|
+
task.result = `${task.result}\n\n[Galda: continuing in ${rebound.dir} with a fresh session]`.slice(0, RESULT_MAX);
|
|
2328
|
+
saveTask(task);
|
|
2329
|
+
goal.status = 'running';
|
|
2330
|
+
goal.startedAt = null;
|
|
2331
|
+
goal.question = null;
|
|
2332
|
+
goal.blocked = null;
|
|
2333
|
+
saveGoal(goal);
|
|
2334
|
+
const continuation = queueReplyTask(goal, [
|
|
2335
|
+
`Continue the same request in the newly bound working directory: ${rebound.dir}`,
|
|
2336
|
+
outcome.summary,
|
|
2337
|
+
'Inspect the files already present there and complete the original request. Do not repeat the old sandbox diagnosis.',
|
|
2338
|
+
].filter(Boolean).join('\n'), { from: 'manager', via: 'system' });
|
|
2339
|
+
continuation.workspaceRebindContinuation = true;
|
|
2340
|
+
saveTask(continuation);
|
|
2341
|
+
sendProcessAct(task, `Worker requested a workspace rebind; continuing in ${rebound.dir} with a fresh session.`);
|
|
2342
|
+
return true;
|
|
2343
|
+
}
|
|
2344
|
+
// An unsafe/unavailable path is not silently retried in the old sandbox.
|
|
2345
|
+
outcome.outcome = 'needs-input';
|
|
2346
|
+
outcome.question = rebound.error;
|
|
2347
|
+
outcome.options = [];
|
|
2348
|
+
outcome.summary = rebound.error;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// This turn ended successfully as a routing hand-off, not as a failed unit
|
|
2352
|
+
// of implementation. Keeping it `interrupted` would poison the eventual
|
|
2353
|
+
// closeout even after the user answers and the continuation completes.
|
|
2354
|
+
task.status = 'skipped';
|
|
2355
|
+
saveTask(task);
|
|
2356
|
+
goal.startedAt = null;
|
|
2357
|
+
if (outcome.outcome === 'needs-input') {
|
|
2358
|
+
goal.status = 'needsInput';
|
|
2359
|
+
goal.blocked = null;
|
|
2360
|
+
goal.question = { kind: 'worker', text: outcome.question, options: outcome.options };
|
|
2361
|
+
sendProcessAct(task, `Worker asked for input: ${outcome.question}`);
|
|
2362
|
+
} else {
|
|
2363
|
+
goal.status = 'blocked';
|
|
2364
|
+
goal.question = null;
|
|
2365
|
+
goal.blocked = { kind: 'worker-outcome', reason: outcome.summary };
|
|
2366
|
+
sendProcessAct(task, `Worker declared the work blocked: ${outcome.summary}`);
|
|
2367
|
+
}
|
|
2368
|
+
saveGoal(goal);
|
|
2369
|
+
startNextStacked(goal.projectId);
|
|
2370
|
+
return true;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2302
2373
|
// split (docs/design/ONE-CONVERSATION.md §1.3): 「それは別件」と返された時の受け皿。
|
|
2303
2374
|
// 元のゴールには触らない——新しいチケットを1枚起こし、そこに「どこから生まれたか」だけ
|
|
2304
2375
|
// 片方向の印(bornFrom)を残す。
|
|
@@ -2731,7 +2802,9 @@ async function runTask(task) {
|
|
|
2731
2802
|
task.attemptId = task.attemptId || newAttemptId(goal, task);
|
|
2732
2803
|
if (goal) {
|
|
2733
2804
|
goal.latestAttemptId = task.attemptId;
|
|
2734
|
-
|
|
2805
|
+
// A reply is a fresh attempt: re-measure the workspace so a baseline taken
|
|
2806
|
+
// when the checkout happened to be dirty cannot outlive the mess itself.
|
|
2807
|
+
noteGoalTaskBaseline(goal, project, workDir, before, { refresh: !!task.reply && !goal.pr });
|
|
2735
2808
|
}
|
|
2736
2809
|
sendProcessAct(task, `Checked starting git state (${before.length} changed file${before.length === 1 ? '' : 's'} before this task).`);
|
|
2737
2810
|
// Plan mode makes no edits, so there is nothing to verify — never author a
|
|
@@ -2808,6 +2881,9 @@ async function runTask(task) {
|
|
|
2808
2881
|
task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
|
|
2809
2882
|
task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
|
|
2810
2883
|
sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this follow-up.`);
|
|
2884
|
+
// An explicit worker receipt takes precedence over Galda's legacy prose
|
|
2885
|
+
// guard. The agent owns this decision; Manager only transports it.
|
|
2886
|
+
if (routeWorkerOutcome(goal, task, project)) return;
|
|
2811
2887
|
const completionIssue = completionContractIssue({
|
|
2812
2888
|
request: goal?.text,
|
|
2813
2889
|
followUp: task.detail,
|
|
@@ -3030,6 +3106,9 @@ async function runTask(task) {
|
|
|
3030
3106
|
task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
|
|
3031
3107
|
task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
|
|
3032
3108
|
sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
|
|
3109
|
+
// An explicit worker receipt takes precedence over Galda's legacy prose
|
|
3110
|
+
// guard. The agent owns this decision; Manager only transports it.
|
|
3111
|
+
if (routeWorkerOutcome(goal, task, project)) return;
|
|
3033
3112
|
// task 450: taskStatusAfterVerify decided 'done' from `code` alone, before
|
|
3034
3113
|
// changedFiles existed to check against. Don't trust a clean exit code by
|
|
3035
3114
|
// itself — a worker that changed nothing and left a cut-off-looking result
|
|
@@ -3886,7 +3965,15 @@ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT
|
|
|
3886
3965
|
if (!shouldCreateGoalPR(goal, files)) {
|
|
3887
3966
|
goal.pr = null;
|
|
3888
3967
|
goal.prBranch = null;
|
|
3889
|
-
|
|
3968
|
+
// Two different situations used to leave the same silent hole. "No PR was
|
|
3969
|
+
// asked for" is nothing to explain — clear the error. But "a PR WAS asked
|
|
3970
|
+
// for and none exists" is a fact the person needs, and nulling prError here
|
|
3971
|
+
// erased the only field the Review card renders a reason from, so the card
|
|
3972
|
+
// showed a PR-less goal with no explanation at all (goals #725 #751 on the
|
|
3973
|
+
// operator's instance: status done, wantsPR true, pr null, prError empty).
|
|
3974
|
+
goal.prError = goal.wantsPR === true
|
|
3975
|
+
? 'PRを作成できませんでした: 変更されたファイルが1つもありません(実装が行われていない可能性があります)/ Cannot open a PR: this run produced no file changes.'
|
|
3976
|
+
: null;
|
|
3890
3977
|
saveGoal(goal);
|
|
3891
3978
|
goalAct(goal.wantsPR === false
|
|
3892
3979
|
? 'Deliverable is No PR, so PR creation is skipped even though files changed.'
|
|
@@ -5122,6 +5209,18 @@ const server = createServer(async (req, res) => {
|
|
|
5122
5209
|
let answer = '';
|
|
5123
5210
|
try { answer = String(JSON.parse(body || '{}').answer ?? '').trim(); } catch {}
|
|
5124
5211
|
if (!answer) return json(res, 400, { error: 'answer required' });
|
|
5212
|
+
// A worker-authored question is a transport pause, not a new planning
|
|
5213
|
+
// cycle. Preserve the worker session and pass the user's answer straight
|
|
5214
|
+
// back to it; Galda must not reinterpret or rewrite the answer.
|
|
5215
|
+
if (goal.question?.kind === 'worker') {
|
|
5216
|
+
goal.question = null;
|
|
5217
|
+
goal.blocked = null;
|
|
5218
|
+
goal.status = 'running';
|
|
5219
|
+
goal.startedAt = null;
|
|
5220
|
+
saveGoal(goal);
|
|
5221
|
+
const replyTask = queueReplyTask(goal, answer, { from: 'you', via: 'question' });
|
|
5222
|
+
return json(res, 200, { ...goal, replyTaskId: replyTask.id });
|
|
5223
|
+
}
|
|
5125
5224
|
// Bind this goal to a project rooted at `dir` and start planning. Shared by
|
|
5126
5225
|
// the "picked a repo" and "git-init'd a new folder" paths below.
|
|
5127
5226
|
const bindAndPlan = (dir) => {
|
|
@@ -5313,6 +5412,20 @@ const server = createServer(async (req, res) => {
|
|
|
5313
5412
|
goal.blocked = null;
|
|
5314
5413
|
saveGoal(goal);
|
|
5315
5414
|
}
|
|
5415
|
+
// "PR出して" has to count here too. This is the route EVERY follow-up reply
|
|
5416
|
+
// takes (the review card switches to it once a card is in conversation) and
|
|
5417
|
+
// the one MCP manager_reply uses, so until now a PR asked for on the second
|
|
5418
|
+
// reply — or from an agent session — could never take effect: the deliverable
|
|
5419
|
+
// was only ever upgraded in /dismiss. Measured on the operator's instance
|
|
5420
|
+
// 2026-07-25..29: 「あれ、prいるよね?」then「PR作成して」(#790) and
|
|
5421
|
+
// 「問題ないのでpr出して!」then「あれ、問題ないのでpr作ってください」(#800) —
|
|
5422
|
+
// both asked twice and got no PR either time.
|
|
5423
|
+
// Upgrade only: this can turn the deliverable on, never off, so a later
|
|
5424
|
+
// unrelated reply cannot silently withdraw a PR the person already asked for.
|
|
5425
|
+
if (resolveWantsPR('auto', text, false) && goal.wantsPR !== true) {
|
|
5426
|
+
goal.wantsPR = true;
|
|
5427
|
+
saveGoal(goal);
|
|
5428
|
+
}
|
|
5316
5429
|
json(res, 200, queueReplyTask(goal, text.trim(), { from: 'you', via: 'thread' }));
|
|
5317
5430
|
} catch { json(res, 400, { error: 'bad json' }); }
|
|
5318
5431
|
});
|
|
@@ -5391,6 +5504,41 @@ const server = createServer(async (req, res) => {
|
|
|
5391
5504
|
return json(res, 200, goal);
|
|
5392
5505
|
}
|
|
5393
5506
|
|
|
5507
|
+
// PR再試行 / Retry PR. The Review card has offered this button all along and it
|
|
5508
|
+
// has never had a route to call — fsRetryPrGoal POSTs here, the server had no
|
|
5509
|
+
// match for it, and the press 404'd and showed an error. So the one repair the
|
|
5510
|
+
// failure message advertises ("commit/stash/revert those unrelated changes,
|
|
5511
|
+
// then rerun") was unreachable from the product: the person cleaned the
|
|
5512
|
+
// workspace and had no way to say "now try again".
|
|
5513
|
+
//
|
|
5514
|
+
// Deliberately narrow — this only re-runs PR creation. It starts no worker and
|
|
5515
|
+
// changes no code, so it is safe to press repeatedly: either the PR appears or
|
|
5516
|
+
// the same honest reason comes back.
|
|
5517
|
+
const retryPrMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/retry-pr$/);
|
|
5518
|
+
if (retryPrMatch && req.method === 'POST') {
|
|
5519
|
+
const goal = goals.find((g) => g.id === Number(retryPrMatch[1]));
|
|
5520
|
+
if (!goal) return json(res, 404, { error: 'goal not found' });
|
|
5521
|
+
if (!requireGoalOwnership(req, res, goal)) return;
|
|
5522
|
+
if (goal.pr) return json(res, 409, { error: 'this goal already has a pull request' });
|
|
5523
|
+
const project = projects.find((p) => p.id === goal.projectId);
|
|
5524
|
+
if (!project) return json(res, 404, { error: 'project not found' });
|
|
5525
|
+
// Asking to retry the PR IS asking for a PR — a goal that was created as
|
|
5526
|
+
// No PR but whose owner now presses this wants one.
|
|
5527
|
+
goal.wantsPR = true;
|
|
5528
|
+
// The frozen dirty-workspace verdict is exactly what this button exists to
|
|
5529
|
+
// clear. Re-measure against the workspace as it is NOW, which is the state
|
|
5530
|
+
// the person just repaired.
|
|
5531
|
+
const wdir = (goalWorkDirs.get(goal.id) && existsSync(goalWorkDirs.get(goal.id))) ? goalWorkDirs.get(goal.id) : project.dir;
|
|
5532
|
+
goal.prBaseline = null;
|
|
5533
|
+
goal.prSafety = null;
|
|
5534
|
+
goal.prError = null;
|
|
5535
|
+
noteGoalTaskBaseline(goal, project, wdir, await gitChanges(wdir));
|
|
5536
|
+
const siblings = tasks.filter((t) => t.goalId === goal.id);
|
|
5537
|
+
await createGoalPR(goal, { ...project, dir: wdir }, siblings, getReviewDefinition(goal.projectId), null);
|
|
5538
|
+
saveGoal(goal);
|
|
5539
|
+
return json(res, 200, { goal, pr: goal.pr ?? null, prError: goal.prError ?? null });
|
|
5540
|
+
}
|
|
5541
|
+
|
|
5394
5542
|
// Archive: retire an UNFINISHED goal (partial/failed/interrupted/blocked)
|
|
5395
5543
|
// cleanly — spawning no rework. DELETE only accepts stacked/pending, and
|
|
5396
5544
|
// dismiss on a partial goal queues a "差し戻し" rework task, so a goal that
|
|
@@ -5678,7 +5826,12 @@ async function answerGoalQuestion(goal, question) {
|
|
|
5678
5826
|
// Manager close-out below owns commit/push/PR creation. Keep this
|
|
5679
5827
|
// monotonic: an explicit later PR request upgrades a No-PR/auto goal,
|
|
5680
5828
|
// while ordinary feedback never silently downgrades an existing PR goal.
|
|
5681
|
-
|
|
5829
|
+
// Not gated on spawnsWorker: what the person wants DELIVERED is independent
|
|
5830
|
+
// of where their words were routed. A PR asked for while parking the goal
|
|
5831
|
+
// (rescope) or while asking a question (answer) is still a PR they asked
|
|
5832
|
+
// for, and it must survive until close-out rather than being dropped
|
|
5833
|
+
// because this particular reply happened not to wake a worker.
|
|
5834
|
+
if (resolveWantsPR('auto', text, false)) goal.wantsPR = true;
|
|
5682
5835
|
goal.status = result.status;
|
|
5683
5836
|
saveGoal(goal);
|
|
5684
5837
|
// rescope はワーカーを起こさない——「いったん止めて考え直す」なので、走り出したら
|
package/package.json
CHANGED