@galda/cli 0.10.95 → 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';
@@ -2310,6 +2310,66 @@ function pauseGoalForApproval(goal, task, request) {
2310
2310
  return true;
2311
2311
  }
2312
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
+
2313
2373
  // split (docs/design/ONE-CONVERSATION.md §1.3): 「それは別件」と返された時の受け皿。
2314
2374
  // 元のゴールには触らない——新しいチケットを1枚起こし、そこに「どこから生まれたか」だけ
2315
2375
  // 片方向の印(bornFrom)を残す。
@@ -2821,6 +2881,9 @@ async function runTask(task) {
2821
2881
  task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
2822
2882
  task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
2823
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;
2824
2887
  const completionIssue = completionContractIssue({
2825
2888
  request: goal?.text,
2826
2889
  followUp: task.detail,
@@ -3043,6 +3106,9 @@ async function runTask(task) {
3043
3106
  task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
3044
3107
  task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
3045
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;
3046
3112
  // task 450: taskStatusAfterVerify decided 'done' from `code` alone, before
3047
3113
  // changedFiles existed to check against. Don't trust a clean exit code by
3048
3114
  // itself — a worker that changed nothing and left a cut-off-looking result
@@ -5143,6 +5209,18 @@ const server = createServer(async (req, res) => {
5143
5209
  let answer = '';
5144
5210
  try { answer = String(JSON.parse(body || '{}').answer ?? '').trim(); } catch {}
5145
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
+ }
5146
5224
  // Bind this goal to a project rooted at `dir` and start planning. Shared by
5147
5225
  // the "picked a repo" and "git-init'd a new folder" paths below.
5148
5226
  const bindAndPlan = (dir) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.95",
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": {