@galda/cli 0.10.89 → 0.10.90

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 CHANGED
@@ -294,17 +294,6 @@
294
294
  .gearpop{}, so out here it was invalid at computed-value time and fell back to
295
295
  transparent — measured rgba(0,0,0,0). The chips were never filled to begin with. */
296
296
  .ctxbar{display:flex;align-items:center;gap:6px;padding:0 2px 7px;background:none;border:0}
297
- /* Feature A: "Replying to #NNN ✕" chip — neutral hairline pill above the composer
298
- (real tokens only → theme-adaptive; the ✕ hover moves glyph colour only, §5.5). */
299
- .replybind{display:flex;align-items:center;gap:6px;max-width:var(--fscenterw,760px);width:100%;margin:0 auto 6px;padding:0 2px}
300
- .replybind[hidden]{display:none}
301
- .replybind .rb-seg{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;
302
- border:1px solid var(--hair);background:var(--panel);font:500 12px/1 var(--ui);color:var(--ink2)}
303
- .replybind .rb-seg b{color:var(--ink);font-weight:600;font-family:var(--mono);font-variant-numeric:tabular-nums}
304
- .replybind .rb-x{display:grid;place-items:center;width:24px;height:24px;border:0;border-radius:7px;background:transparent;
305
- color:var(--ink3);cursor:pointer;transition:color .12s;flex:0 0 24px}
306
- .replybind .rb-x:hover{color:var(--ink)}
307
- .replybind .rb-x svg{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
308
297
  .ctxchip{display:inline-flex;align-items:center;height:26px;padding:0;gap:0;border-radius:8px;
309
298
  border:1px solid var(--hair);background:transparent;position:relative;min-width:0}
310
299
  .ctxsel{position:relative}
@@ -3037,9 +3026,6 @@
3037
3026
  reply exists (progressive disclosure — DESIGN-RULES §1.5-3, "存在
3038
3027
  しないものの器を先に見せない"). -->
3039
3028
  <div class="peraanswer" id="chatReply" hidden></div>
3040
- <!-- Feature A: when the composer is bound to the focused goal, this chip shows
3041
- which conversation you're replying to; ✕ detaches back to new-goal mode. -->
3042
- <div class="replybind" id="replyBind" hidden><span class="rb-seg"><span id="replyBindLabel">Replying to</span> <b id="replyBindNum"></b></span><button type="button" class="rb-x" id="replyBindX" title="New goal instead" aria-label="Detach"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
3043
3029
  <div class="composer">
3044
3030
  <div class="attachbar" id="attachbar" style="display:none"></div>
3045
3031
  <!-- Slash-command palette (Masa決定 2026-07-07: chips→slash). Floats
@@ -4367,7 +4353,6 @@ function mergeGoalTimeline(goals, externalActivity, projectId){
4367
4353
  function goToGoalInChat(goalId, bindComposer = true){
4368
4354
  state.selectedGoal = goalId;
4369
4355
  if (bindComposer) state.composerDetachedGoal = null;
4370
- renderReplyBind(); // Feature A: the composer's "Replying to #NNN" chip follows the focused goal — renderStream() alone skips it (only render() calls it)
4371
4356
  renderStream();
4372
4357
  const goalEl = document.querySelector(`#stream [data-goalmsg="${goalId}"]`);
4373
4358
  if (!goalEl) return;
@@ -5860,6 +5845,18 @@ function renderReviewAttach(){
5860
5845
  // 関わらず全部「直して」で、「なんでこうしたの?」と聞いただけでゴールが Doing に戻り
5861
5846
  // ワーカーが走っていた。人はモードを普段選ばない——AI が迷った時だけ選ぶ。
5862
5847
  const REPLY_CHOICE_LABEL = { continue: 'Fix this', rescope: 'Rethink it', split: 'Separate ticket', answer: 'Just a question' };
5848
+ function replyChoiceLabel(choice){
5849
+ if (choice?.startsWith('project:')) {
5850
+ const id = choice.slice('project:'.length);
5851
+ return `Implement in ${state.projects?.find((project) => project.id === id)?.name || id}`;
5852
+ }
5853
+ return REPLY_CHOICE_LABEL[choice] ?? choice;
5854
+ }
5855
+ function replyChoices(body){
5856
+ const routes = body?.choices || REPLY_OUTCOMES_CLIENT;
5857
+ const targets = (body?.projectChoices || []).filter((project) => !project.current).map((project) => `project:${project.id}`);
5858
+ return [...routes, ...targets];
5859
+ }
5863
5860
  // The one waiting line's two labels. Local to the reply note on purpose — STATUS_LABEL
5864
5861
  // is the task/workflow vocabulary and this is neither (CDO §5).
5865
5862
  const REPLY_WAIT_ROUTING = 'Thinking…';
@@ -5867,9 +5864,9 @@ const REPLY_WAIT_ANSWERING = 'Answering…';
5867
5864
  function replyWorkerPayload(extra = {}){
5868
5865
  return { ...extra, agent: state.connectedApp, model: state.stickyModel, effort: state.stickyEffort };
5869
5866
  }
5870
- async function postReply(goalId, text, outcome = 'auto'){
5867
+ async function postReply(goalId, text, outcome = 'auto', targetProjectId = null){
5871
5868
  const r = await fetch(withKey(`/api/goals/${goalId}/dismiss`), { method: 'POST',
5872
- headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text, outcome })) });
5869
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text, outcome, targetProjectId })) });
5873
5870
  if (!r.ok) throw new Error();
5874
5871
  return r.json();
5875
5872
  }
@@ -6009,7 +6006,7 @@ function saCardThreadHtml(it){
6009
6006
  : it.rstatus === 'working' ? replyWorkingHtml()
6010
6007
  : it.rstatus === 'unsure' ? (replyNoteRowHtml('Which one is this?')
6011
6008
  + `<div class="qchips">${(it.rchoices || []).map((c) =>
6012
- `<button type="button" class="qchip" data-rc="${esc(c)}" data-rcgoal="${it.goalId}">${esc(REPLY_CHOICE_LABEL[c] ?? c)}</button>`).join('')}</div>`)
6009
+ `<button type="button" class="qchip" data-rc="${esc(c)}" data-rcgoal="${it.goalId}">${esc(replyChoiceLabel(c))}</button>`).join('')}</div>`)
6013
6010
  : '';
6014
6011
  return rows + trailer;
6015
6012
  }
@@ -6054,7 +6051,7 @@ function askReplyDestination(anchorSel, choices){
6054
6051
  // The You row stays above the chips so "this" has something to point at (CDO Q5).
6055
6052
  setReplyNote(anchorSel, replyPastHtml() + said + replyNoteRowHtml('Which one is this?')
6056
6053
  + `<div class="qchips">${(choices || []).map((c) =>
6057
- `<button type="button" class="qchip" data-rc="${esc(c)}">${esc(REPLY_CHOICE_LABEL[c] ?? c)}</button>`).join('')}</div>`, resolve);
6054
+ `<button type="button" class="qchip" data-rc="${esc(c)}">${esc(replyChoiceLabel(c))}</button>`).join('')}</div>`, resolve);
6058
6055
  });
6059
6056
  }
6060
6057
  // answer (§1.3): nothing was built, so there is no task to watch — the reply comes
@@ -6085,9 +6082,11 @@ async function replyToGoal(goalId, text, anchorSel){
6085
6082
  showReplySent(anchorSel, text, Number(goalId)); // before the round trip: the words stay, and it says it is thinking
6086
6083
  let body = await postReply(goalId, text, 'auto');
6087
6084
  if (body.outcome === 'unsure') {
6088
- const picked = await askReplyDestination(anchorSel, body.choices);
6085
+ const picked = await askReplyDestination(anchorSel, replyChoices(body));
6089
6086
  if (!picked) return null;
6090
- body = await postReply(goalId, text, picked);
6087
+ body = picked.startsWith('project:')
6088
+ ? await postReply(goalId, text, 'split', picked.slice('project:'.length))
6089
+ : await postReply(goalId, text, picked);
6091
6090
  }
6092
6091
  // seen + 1: the question itself was just recorded; the answer is the one after it.
6093
6092
  if (body.outcome === 'answer') showAnswerWhenItArrives(anchorSel, goalId, seen + 1);
@@ -6422,7 +6421,6 @@ $('gdRevisionInput').addEventListener('keydown', (e) => {
6422
6421
  function render(){
6423
6422
  renderProjects(); renderStream(); renderQueue(); renderTasks(); renderActPanel(); wireWfEditButtons();
6424
6423
  renderCtxBar(); // the folder above the composer follows the active project
6425
- renderReplyBind(); // Feature A: show/hide the "Replying to #NNN" chip for the focused goal
6426
6424
  renderFlagship();
6427
6425
  if (state.reviewOpenGoal != null) renderReviewChecklist();
6428
6426
  if (state.goalDetailOpen != null) renderGoalDetail();
@@ -9013,22 +9011,6 @@ function replyBinding(){
9013
9011
  if (REPLY_PLAIN.has(g.status)) return { goal: g, mode: 'plain' };
9014
9012
  return null;
9015
9013
  }
9016
- // The visible, reversible "Replying to #NNN ✕" chip above the composer — never
9017
- // silent (shown whenever bound), always detachable (✕ clears the binding).
9018
- function renderReplyBind(){
9019
- const el = $('replyBind'); if (!el) return;
9020
- const b = replyBinding();
9021
- el.hidden = !b;
9022
- const inp = $('input');
9023
- if (b) {
9024
- const n = goalReviewNumber({ goal: b.goal, tasks: state.tasks }) ?? b.goal.id;
9025
- const numEl = $('replyBindNum'); if (numEl) numEl.textContent = '#' + n;
9026
- const lblEl = $('replyBindLabel'); if (lblEl) lblEl.textContent = b.mode === 'answer' ? 'Answering' : 'Replying to';
9027
- if (inp) inp.placeholder = b.mode === 'answer' ? `Answering #${n}…` : `Reply to #${n}…`;
9028
- } else if (inp) {
9029
- inp.placeholder = 'Describe a goal… ( / for commands )';
9030
- }
9031
- }
9032
9014
  async function send(){
9033
9015
  const { text, model, effort, mode, skill, later, review } = resolveComposer($('input').value);
9034
9016
  if (!text) return;
@@ -10419,8 +10401,8 @@ async function saCall(ids, path, body){
10419
10401
  // (outcome:'auto', ONE-CONVERSATION §1.4) — this layer never guesses continue vs
10420
10402
  // question. Unlike saAct it does NOT show its outcome first: "Feedback sent" would
10421
10403
  // be a lie when the reply was a question.
10422
- async function saReplyCall(it, text, outcome){
10423
- const r = await saCall(it.ids, 'dismiss', { text, outcome });
10404
+ async function saReplyCall(it, text, outcome, targetProjectId = null){
10405
+ const r = await saCall(it.ids, 'dismiss', { text, outcome, targetProjectId });
10424
10406
  if (!r?.ok) throw new Error('reply failed');
10425
10407
  return r.json().catch(() => null);
10426
10408
  }
@@ -10527,7 +10509,7 @@ async function saSendReply(it, i, text){
10527
10509
  try {
10528
10510
  let body = await saReplyCall(it, workerText, 'auto');
10529
10511
  if (body?.outcome === 'unsure') {
10530
- it.inflight = false; it.rstatus = 'unsure'; it.rchoices = body.choices || REPLY_OUTCOMES_CLIENT;
10512
+ it.inflight = false; it.rstatus = 'unsure'; it.rchoices = replyChoices(body);
10531
10513
  saPaintCard(goalId);
10532
10514
  const picked = await new Promise((resolve) => { it._routeResolve = resolve; });
10533
10515
  it._routeResolve = null;
@@ -10537,7 +10519,9 @@ async function saSendReply(it, i, text){
10537
10519
  saPaintCard(goalId); return;
10538
10520
  }
10539
10521
  it.inflight = true; it.rstatus = 'thinking'; saPaintCard(goalId);
10540
- body = await saReplyCall(it, workerText, picked);
10522
+ body = picked.startsWith('project:')
10523
+ ? await saReplyCall(it, workerText, 'split', picked.slice('project:'.length))
10524
+ : await saReplyCall(it, workerText, picked);
10541
10525
  }
10542
10526
  it.inflight = false;
10543
10527
  if (body?.outcome === 'answer') { // a question: the AI answers, the goal never moves
@@ -11270,8 +11254,6 @@ $('filein').addEventListener('change', () => {
11270
11254
  for (const f of $('filein').files) uploadFile(f);
11271
11255
  $('filein').value = '';
11272
11256
  });
11273
- // Feature A: ✕ on the "Replying to #NNN" chip detaches → back to new-goal mode.
11274
- $('replyBindX').onclick = () => { state.composerDetachedGoal = state.selectedGoal; render(); };
11275
11257
  const IS_MOBILE = () => window.matchMedia('(max-width: 760px)').matches;
11276
11258
  // Phones are chat-first: the Tasks sheet starts FOLDED so the first view is the
11277
11259
  // conversation + composer (the sheet is an 88vw overlay — default-open buried
package/engine/lib.mjs CHANGED
@@ -2458,6 +2458,55 @@ export function dismissGoal({ goalStatus, outcome = 'continue' } = {}) {
2458
2458
  return { ok: true, outcome: 'continue', status: 'running', spawnsWorker: true };
2459
2459
  }
2460
2460
 
2461
+ // `outcome:auto` is the contract used by every review reply box in the app.
2462
+ // Keep the routing decision in one place: the reply either asks a question,
2463
+ // requests a change, rescopes the ticket, starts separate work, or explicitly
2464
+ // remains unsure. Returning unsure is safe because the UI already presents
2465
+ // these exact choices without moving the goal.
2466
+ export const REPLY_INTENTS = [...REPLY_OUTCOMES, 'unsure'];
2467
+ export function buildReplyIntentPrompt({ goalText, reply, currentProject = null, projects = [] } = {}) {
2468
+ const projectLines = (projects ?? []).map((project) =>
2469
+ `- ${project.id}: ${project.name || project.id} | root=${project.dir || '(unknown)'} | scope=${project.note || '(not specified)'}`);
2470
+ return [
2471
+ 'You route replies in Agent Manager: a person delegated a coding task, an AI did it, and it came back for review. The person has now replied.',
2472
+ 'Decide what the reply ASKS FOR — exactly one of:',
2473
+ '- "continue": fix or change the work that was just done.',
2474
+ '- "rescope": stop and rethink; the request itself should now say something different.',
2475
+ '- "split": a separate piece of work that should not touch this one.',
2476
+ '- "answer": a question about the work. Nothing should be built or changed.',
2477
+ '- "unsure": you genuinely cannot tell. Prefer this over guessing.',
2478
+ 'Judge the full meaning. A question followed by a requested change is "continue".',
2479
+ 'Also decide which project must receive the requested work.',
2480
+ '- Keep targetProjectId equal to the current project for ordinary fixes to the reviewed work.',
2481
+ '- If the current project is design/docs/prototype-only and the reply asks to implement or launch the chosen design in the real product, select the matching production/code project.',
2482
+ '- Never infer a project that is not listed. If the target is ambiguous, use outcome "unsure" and targetProjectId null.',
2483
+ 'Output ONLY one line of raw JSON: {"outcome":"continue|rescope|split|answer|unsure","targetProjectId":"listed-id-or-null"}.',
2484
+ '',
2485
+ `Current project: ${currentProject?.id || '(unknown)'}`,
2486
+ 'Available projects:',
2487
+ ...(projectLines.length ? projectLines : ['- (none)']),
2488
+ '',
2489
+ 'The request that was worked on:',
2490
+ String(goalText ?? '').slice(0, 2000),
2491
+ '',
2492
+ 'Their reply:',
2493
+ String(reply ?? '').slice(0, 4000),
2494
+ ].join('\n');
2495
+ }
2496
+
2497
+ export function parseReplyIntent(raw) {
2498
+ const unread = { outcome: 'unsure', read: false };
2499
+ if (!raw || typeof raw !== 'string') return unread;
2500
+ const m = raw.match(/\{[\s\S]*\}/);
2501
+ if (!m) return unread;
2502
+ let obj;
2503
+ try { obj = JSON.parse(m[0]); } catch { return unread; }
2504
+ if (!REPLY_INTENTS.includes(obj?.outcome)) return unread;
2505
+ const targetProjectId = typeof obj.targetProjectId === 'string' && obj.targetProjectId.trim()
2506
+ ? obj.targetProjectId.trim() : null;
2507
+ return { outcome: obj.outcome, targetProjectId, read: true };
2508
+ }
2509
+
2461
2510
  // 宛先(docs/design/ONE-CONVERSATION.md §1.2)
2462
2511
  //
2463
2512
  // チャットで「#12 の件だけど」と書けたら、そのチケット宛て。**決定論で、我々の推測は
@@ -3187,6 +3236,14 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3187
3236
  return [
3188
3237
  skill ? `FIRST, use the /${skill} skill via the Skill tool (invoke Skill with the "${skill}" skill) before doing anything else. Then carry out the task below.` : '',
3189
3238
  `You are a ${displayName} worker managed by "Manager for AI". Do the request below inside this project directory.`,
3239
+ task?.projectContext ? [
3240
+ 'PROJECT BOUNDARY (source of truth):',
3241
+ `- Project: ${task.projectContext.name || task.projectContext.id}`,
3242
+ `- Root: ${task.projectContext.dir}`,
3243
+ task.projectContext.note ? `- Scope/note: ${task.projectContext.note}` : '',
3244
+ '- Your edits and claims apply only to this project. Never call a design/docs/prototype change a production implementation.',
3245
+ '- If the request requires another project, do not pretend it was implemented here; report the exact project mismatch.',
3246
+ ].filter(Boolean).join('\n') : '',
3190
3247
  goal?.priorFailureMemory?.length ? `\nMANAGER FAILURE MEMORY:\n${latestFailurePolicy(goal.priorFailureMemory)?.text}\nApply this prevention before starting.` : '',
3191
3248
  '',
3192
3249
  // The user's own words, verbatim and in full, are the source of truth.
@@ -3404,7 +3461,9 @@ export function parseRunDeclaration(text) {
3404
3461
  const compare = parseComparePair(raw.compare);
3405
3462
  const title = str(raw.title, 60);
3406
3463
  // Nothing to start, nothing to open, nothing named, and no pair to show = nothing to look at.
3407
- if (!cmd && !url && !file && !compare && !title) return null;
3464
+ if (!cmd && !url && !file && !compare && !title) {
3465
+ return runDeclError('no-open-target', 'declaration contains no command, URL, artifact file, comparison, or title');
3466
+ }
3408
3467
  // Only http(s): the button opens this in the user's browser. A file:// (or
3409
3468
  // any other scheme) is a worker mistake with a specific fix — declare `file`
3410
3469
  // instead — not a JSON parse failure, so it gets its own reason.
package/engine/server.mjs CHANGED
@@ -21,7 +21,7 @@ import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { needsProjectFolder, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath } from './lib.mjs';
24
- import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildReviewAttemptLedger, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
24
+ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildReviewAttemptLedger, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
25
25
  import { createSerialQueue } from './lib.mjs';
26
26
  import { parseRequirementEvidence, unmappedChangedFiles } from './lib.mjs';
27
27
  import { isPrClosed } from './lib.mjs';
@@ -2288,13 +2288,15 @@ function pauseGoalForApproval(goal, task, request) {
2288
2288
  //
2289
2289
  // 設定は親から引き継ぐ(プロジェクト・エージェント・モデル・effort)。別件とはいえ
2290
2290
  // 同じ場所で同じ人が続けている作業なので、ここで選び直させる理由が無い。
2291
- function createSplitGoal(parent, text, identity) {
2291
+ function createSplitGoal(parent, text, identity, targetProjectId = parent.projectId) {
2292
+ const targetProject = projects.find((project) => project.id === targetProjectId);
2293
+ if (!targetProject) throw new Error(`unknown target project: ${targetProjectId}`);
2292
2294
  const goal = {
2293
- id: nextId++, projectId: parent.projectId, text: text.slice(0, 8000), status: 'planning',
2294
- wantsPR: resolveWantsPR('auto', text, getReviewDefinition(parent.projectId).defaultWantsPR),
2295
+ id: nextId++, projectId: targetProjectId, text: text.slice(0, 8000), status: 'planning',
2296
+ wantsPR: resolveWantsPR('auto', text, getReviewDefinition(targetProjectId).defaultWantsPR),
2295
2297
  model: parent.model, effort: parent.effort, agent: parent.agent, mode: 'auto',
2296
2298
  skill: null, images: [], source: resolveGoalSource('app'), sourceRef: null,
2297
- executionPlan: buildExecutionPlan({ projectId: parent.projectId, text, previousSession: false }),
2299
+ executionPlan: buildExecutionPlan({ projectId: targetProjectId, text, previousSession: false }),
2298
2300
  plan: null, pr: undefined, createdAt: new Date().toISOString(),
2299
2301
  // 片方向の印。表示は app(新しいカード「#N の会話から生まれました」/元のカード
2300
2302
  // 「→ #M を切り出しました」)。engine は数字を1つ持つだけ。
@@ -2444,6 +2446,13 @@ function readRunDeclaration(task, workDir) {
2444
2446
  // never broken) and wrote the exact same file:// url a second time. `run` is
2445
2447
  // `{ error, message }` here, distinct from the `null` case below.
2446
2448
  if (run && run.error) {
2449
+ // Valid JSON with only note/check means there is simply nothing to open.
2450
+ // That is common for explanation-only follow-ups and must not manufacture
2451
+ // an "unreadable record" error in Review.
2452
+ if (run.error === 'no-open-target') {
2453
+ sendAct(task, 'run declaration ignored — this result has nothing to open.');
2454
+ return;
2455
+ }
2447
2456
  task.runDeclError = { reason: run.error, message: run.message, snippet: String(raw ?? '').slice(0, 200) };
2448
2457
  sendAct(task, `run declaration ignored — ${run.message}`);
2449
2458
  return;
@@ -2673,6 +2682,7 @@ async function startPreview(task) {
2673
2682
  async function runTask(task) {
2674
2683
  const project = projects.find((p) => p.id === task.projectId);
2675
2684
  const goal = goals.find((g) => g.id === task.goalId);
2685
+ task.projectContext = project ? { id: project.id, name: project.name, dir: project.dir, note: project.note } : null;
2676
2686
  task.status = 'running'; task.startedAt = new Date().toISOString();
2677
2687
  // The goal's "started" clock is set by the first task that actually runs — the
2678
2688
  // signal the board uses to read a 'running' goal as DOING rather than QUEUED
@@ -5447,6 +5457,30 @@ const server = createServer(async (req, res) => {
5447
5457
  // 「聞いてるだけ」への返事 (ONE-CONVERSATION.md §1.3 answer)。
5448
5458
  //
5449
5459
  // 答えるのは**作業した AI 本人**=その agent の session を resume する。状況一覧しか
5460
+ // Resolve the app's `outcome:auto` contract before changing goal state. The
5461
+ // utility call is classification-only; the goal's own agent/session still
5462
+ // writes an answer or performs the requested change. If routing is unavailable,
5463
+ // return unsure so a customer chooses explicitly instead of accidentally
5464
+ // starting a ten-minute implementation worker for a question.
5465
+ async function judgeReplyIntent(goal, reply) {
5466
+ try {
5467
+ const currentProject = projects.find((project) => project.id === goal.projectId) ?? null;
5468
+ const r = await runUtility({
5469
+ prompt: buildReplyIntentPrompt({ goalText: goal.text, reply, currentProject, projects }),
5470
+ cwd: ROOT,
5471
+ tools: 'Read',
5472
+ });
5473
+ const parsed = parseReplyIntent(r.result);
5474
+ if (!parsed.read) return { outcome: 'unsure', targetProjectId: null };
5475
+ if (parsed.targetProjectId && !projects.some((project) => project.id === parsed.targetProjectId)) {
5476
+ return { outcome: 'unsure', targetProjectId: null };
5477
+ }
5478
+ return { outcome: parsed.outcome, targetProjectId: parsed.targetProjectId };
5479
+ } catch {
5480
+ return { outcome: 'unsure', targetProjectId: null };
5481
+ }
5482
+ }
5483
+
5450
5484
  // 知らない別AIでは「なぜそうしたか」に答えられない(Masa確定 2026-07-22)。
5451
5485
  // コードは1文字も変わらない: ツールは Read だけ、permissionMode は 'plan'
5452
5486
  // (Codex 側は read-only サンドボックスに落ちる)。タスクも作らないので看板も
@@ -5497,20 +5531,29 @@ async function answerGoalQuestion(goal, question) {
5497
5531
  let body = '';
5498
5532
  req.on('data', (d) => { body += d; });
5499
5533
  req.on('end', async () => {
5500
- let text = '', outcome = 'continue', requested = {};
5534
+ let text = '', outcome = 'continue', requested = {}, auto = false;
5501
5535
  try {
5502
5536
  requested = JSON.parse(body || '{}');
5503
5537
  text = String(requested.text ?? '').trim();
5504
5538
  if (REPLY_OUTCOMES.includes(requested.outcome)) outcome = requested.outcome;
5539
+ auto = requested.outcome === 'auto';
5505
5540
  } catch {}
5506
5541
  const worker = applyReplyWorker(goal, requested);
5507
5542
  if (!worker.ok) return json(res, 409, { error: worker.error });
5508
5543
 
5509
- // A review reply is transport, not a classification request. Legacy clients
5510
- // send outcome:'auto'; it deliberately means the deterministic default:
5511
- // preserve the exact words and resume this goal's agent session. Specialized
5512
- // outcomes work only when an explicit UI action sends one of REPLY_OUTCOMES.
5513
- if (requested.outcome === 'auto' && !text) return json(res, 400, { error: 'text required' });
5544
+ if (auto) {
5545
+ if (!text) return json(res, 400, { error: 'text required' });
5546
+ const routed = await judgeReplyIntent(goal, text);
5547
+ outcome = routed.outcome;
5548
+ requested.targetProjectId = routed.targetProjectId;
5549
+ if (outcome === 'unsure') return json(res, 200, {
5550
+ goal,
5551
+ outcome,
5552
+ choices: REPLY_OUTCOMES,
5553
+ projectChoices: projects.map((project) => ({ id: project.id, name: project.name, current: project.id === goal.projectId })),
5554
+ });
5555
+ if (requested.targetProjectId && requested.targetProjectId !== goal.projectId) outcome = 'split';
5556
+ }
5514
5557
  // 返事の行き先4種 (ONE-CONVERSATION.md §1.3). 既定は今までどおり continue なので、
5515
5558
  // outcome を送ってこない今の UI の挙動は1ミリも変わらない。
5516
5559
  const result = dismissGoal({ goalStatus: goal.status, outcome });
@@ -5523,8 +5566,23 @@ async function answerGoalQuestion(goal, question) {
5523
5566
  const gate = await requireEntitlementGate(identityFor(req));
5524
5567
  if (!gate.allowed) return json(res, 402, { error: 'free-tier limit reached', blocked: gate.blocked });
5525
5568
  addGoalMessage(goal, { from: 'you', via: 'review', text });
5526
- const born = createSplitGoal(goal, text, identityFor(req));
5527
- return json(res, 200, { goal, outcome, splitInto: born.id, born });
5569
+ const targetProjectId = requested.targetProjectId || goal.projectId;
5570
+ if (!projects.some((project) => project.id === targetProjectId)) return json(res, 400, { error: 'unknown target project' });
5571
+ const crossProject = targetProjectId !== goal.projectId;
5572
+ const latest = [...tasks].reverse().find((task) => task.goalId === goal.id && task.result);
5573
+ const handoffText = crossProject ? [
5574
+ `[Cross-project implementation handoff from #${goal.id}]`,
5575
+ `Original request: ${goal.text}`,
5576
+ goal.reviewSummary?.summary ? `Reviewed result: ${goal.reviewSummary.summary}` : '',
5577
+ latest?.result ? `Latest worker result: ${String(latest.result).slice(0, 3000)}` : '',
5578
+ `User's implementation request: ${text}`,
5579
+ ].filter(Boolean).join('\n\n') : text;
5580
+ const born = createSplitGoal(goal, handoffText, identityFor(req), targetProjectId);
5581
+ if (crossProject) {
5582
+ const target = projects.find((project) => project.id === targetProjectId);
5583
+ addGoalMessage(goal, { from: 'manager', via: 'review', text: `Implementation was routed to ${target?.name || targetProjectId} (#${born.id}) instead of editing this project's review.` });
5584
+ }
5585
+ return json(res, 200, { goal, outcome, splitInto: born.id, born, targetProjectId });
5528
5586
  }
5529
5587
 
5530
5588
  // answer: 聞かれただけ。ゴールは review のまま、ワーカーも起こさない(§1.3)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.89",
3
+ "version": "0.10.90",
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": {