@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.
@@ -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
- function noteGoalTaskBaseline(goal, project, workDir, before) {
1429
- if (!goal || goal.prBaseline) return;
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
- noteGoalTaskBaseline(goal, project, workDir, before);
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
- goal.prError = null;
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
- if (result.spawnsWorker && resolveWantsPR('auto', text, false)) goal.wantsPR = true;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.94",
3
+ "version": "0.10.96",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {