@galda/cli 0.10.88 → 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/CLAUDE.md CHANGED
@@ -105,6 +105,8 @@
105
105
  - Worker/API へは**必ず所有ドメイン `https://galda.app`(custom domain)経由**で参照する。`*.workers.dev` サブドメインを製品コードに焼き込まない(`MANAGER_BILLING_API_URL` 等の既定値は `https://galda.app`)。
106
106
  - **Cloudflare/wrangler にデプロイする前に `wrangler whoami` でデプロイ先アカウントを確認**し、Galda/Kodo 所有であること・workers.dev サブドメイン名に客先名や別プロジェクト名が出ていないことを確かめる。怪しければ止めて Masa に確認(本番デプロイは Masa 明示許可制)。
107
107
  - **リリースは4面**(① npm ② Fly ③ Worker ④ Masaの常駐インスタンス `~/manager-for-ai`:4400)。①だけ出して④を忘れると「直したのに直ってない」+relay相互kickループ再発の実例あり(2026-07-16/17)。全文=`docs/RELEASE-WORKFLOW.md`の「本番は1つじゃない」表。
108
+ - **常駐Managerを直接 `kill`/`launchctl kickstart -k` しない**(2026-07-28、実行中3 workerを同時中断した実例)。再起動は対象checkoutで
109
+ `node tools/safe-restart-manager.mjs` を使う。このコマンドはDoing/Finalizingが1件でもあれば拒否する。拒否時は完了を待って再実行し、Agent切替で回避しない。
108
110
 
109
111
  ## relay接続(`app.galda.app`)を伴う作業の掟(Masa明言 2026-07-17・全レーン必読)
110
112
 
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}
@@ -1558,6 +1547,14 @@
1558
1547
  line-height:1.15;padding:2.5px 10px;border-radius:7px;box-shadow:inset 0 0 0 1px var(--hair2)}
1559
1548
  #fsRoot .acts2 .errbtn:first-child{color:var(--ink2)}
1560
1549
  #fsRoot .errbtn:hover{background:var(--hover);color:var(--ink)}
1550
+ /* The review/attention dialog is rendered in #seeall, outside #fsRoot.
1551
+ Keep its recovery actions on the same designed component instead of
1552
+ falling back to the browser's unstyled native button. */
1553
+ #seeall .acts2{display:flex;gap:6px;margin-top:3px;align-items:center}
1554
+ #seeall .errbtn{border:0;background:transparent;color:var(--ink3);font-family:var(--mono);font-size:10px;letter-spacing:.02em;cursor:pointer;
1555
+ line-height:1.15;padding:2.5px 10px;border-radius:7px;box-shadow:inset 0 0 0 1px var(--hair2)}
1556
+ #seeall .acts2 .errbtn:first-child{color:var(--ink2)}
1557
+ #seeall .errbtn:hover{background:var(--hover);color:var(--ink)}
1561
1558
  /* AI question in the feed (q2 — §6.5: while current it lives in the chat feed).
1562
1559
  H18 / CDO HANDOFF-question-ui-2026-07-13: QUESTION card = hairline frame +
1563
1560
  2px --blue left rule + mono label + option chips + an always-on free-text
@@ -3029,9 +3026,6 @@
3029
3026
  reply exists (progressive disclosure — DESIGN-RULES §1.5-3, "存在
3030
3027
  しないものの器を先に見せない"). -->
3031
3028
  <div class="peraanswer" id="chatReply" hidden></div>
3032
- <!-- Feature A: when the composer is bound to the focused goal, this chip shows
3033
- which conversation you're replying to; ✕ detaches back to new-goal mode. -->
3034
- <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>
3035
3029
  <div class="composer">
3036
3030
  <div class="attachbar" id="attachbar" style="display:none"></div>
3037
3031
  <!-- Slash-command palette (Masa決定 2026-07-07: chips→slash). Floats
@@ -4359,7 +4353,6 @@ function mergeGoalTimeline(goals, externalActivity, projectId){
4359
4353
  function goToGoalInChat(goalId, bindComposer = true){
4360
4354
  state.selectedGoal = goalId;
4361
4355
  if (bindComposer) state.composerDetachedGoal = null;
4362
- renderReplyBind(); // Feature A: the composer's "Replying to #NNN" chip follows the focused goal — renderStream() alone skips it (only render() calls it)
4363
4356
  renderStream();
4364
4357
  const goalEl = document.querySelector(`#stream [data-goalmsg="${goalId}"]`);
4365
4358
  if (!goalEl) return;
@@ -5852,6 +5845,18 @@ function renderReviewAttach(){
5852
5845
  // 関わらず全部「直して」で、「なんでこうしたの?」と聞いただけでゴールが Doing に戻り
5853
5846
  // ワーカーが走っていた。人はモードを普段選ばない——AI が迷った時だけ選ぶ。
5854
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
+ }
5855
5860
  // The one waiting line's two labels. Local to the reply note on purpose — STATUS_LABEL
5856
5861
  // is the task/workflow vocabulary and this is neither (CDO §5).
5857
5862
  const REPLY_WAIT_ROUTING = 'Thinking…';
@@ -5859,9 +5864,9 @@ const REPLY_WAIT_ANSWERING = 'Answering…';
5859
5864
  function replyWorkerPayload(extra = {}){
5860
5865
  return { ...extra, agent: state.connectedApp, model: state.stickyModel, effort: state.stickyEffort };
5861
5866
  }
5862
- async function postReply(goalId, text, outcome = 'auto'){
5867
+ async function postReply(goalId, text, outcome = 'auto', targetProjectId = null){
5863
5868
  const r = await fetch(withKey(`/api/goals/${goalId}/dismiss`), { method: 'POST',
5864
- headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text, outcome })) });
5869
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text, outcome, targetProjectId })) });
5865
5870
  if (!r.ok) throw new Error();
5866
5871
  return r.json();
5867
5872
  }
@@ -6001,7 +6006,7 @@ function saCardThreadHtml(it){
6001
6006
  : it.rstatus === 'working' ? replyWorkingHtml()
6002
6007
  : it.rstatus === 'unsure' ? (replyNoteRowHtml('Which one is this?')
6003
6008
  + `<div class="qchips">${(it.rchoices || []).map((c) =>
6004
- `<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>`)
6005
6010
  : '';
6006
6011
  return rows + trailer;
6007
6012
  }
@@ -6046,7 +6051,7 @@ function askReplyDestination(anchorSel, choices){
6046
6051
  // The You row stays above the chips so "this" has something to point at (CDO Q5).
6047
6052
  setReplyNote(anchorSel, replyPastHtml() + said + replyNoteRowHtml('Which one is this?')
6048
6053
  + `<div class="qchips">${(choices || []).map((c) =>
6049
- `<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);
6050
6055
  });
6051
6056
  }
6052
6057
  // answer (§1.3): nothing was built, so there is no task to watch — the reply comes
@@ -6077,9 +6082,11 @@ async function replyToGoal(goalId, text, anchorSel){
6077
6082
  showReplySent(anchorSel, text, Number(goalId)); // before the round trip: the words stay, and it says it is thinking
6078
6083
  let body = await postReply(goalId, text, 'auto');
6079
6084
  if (body.outcome === 'unsure') {
6080
- const picked = await askReplyDestination(anchorSel, body.choices);
6085
+ const picked = await askReplyDestination(anchorSel, replyChoices(body));
6081
6086
  if (!picked) return null;
6082
- 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);
6083
6090
  }
6084
6091
  // seen + 1: the question itself was just recorded; the answer is the one after it.
6085
6092
  if (body.outcome === 'answer') showAnswerWhenItArrives(anchorSel, goalId, seen + 1);
@@ -6414,7 +6421,6 @@ $('gdRevisionInput').addEventListener('keydown', (e) => {
6414
6421
  function render(){
6415
6422
  renderProjects(); renderStream(); renderQueue(); renderTasks(); renderActPanel(); wireWfEditButtons();
6416
6423
  renderCtxBar(); // the folder above the composer follows the active project
6417
- renderReplyBind(); // Feature A: show/hide the "Replying to #NNN" chip for the focused goal
6418
6424
  renderFlagship();
6419
6425
  if (state.reviewOpenGoal != null) renderReviewChecklist();
6420
6426
  if (state.goalDetailOpen != null) renderGoalDetail();
@@ -7189,6 +7195,7 @@ const FAILURE_COPY = {
7189
7195
  infra: {
7190
7196
  title: 'Galda基盤エラー',
7191
7197
  timeout: 'Galda側の実行上限や待ち時間で止まりました。変更内容の失敗とは限りません。',
7198
+ interrupted: 'ローカルの実行が中断されました。変更内容やAgentの失敗とは限らないため、同じ作業を再開できます。',
7192
7199
  auth: '接続や認証確認で止まりました。接続を直してから再開できます。',
7193
7200
  relay: 'relay接続で止まりました。接続状態を確認してから再試行できます。',
7194
7201
  chrome: 'Chrome/ブラウザ確認で止まりました。画面確認環境の問題で、実装失敗とは限りません。',
@@ -7198,8 +7205,8 @@ const FAILURE_COPY = {
7198
7205
  unknown: 'Galdaの実行基盤で止まりました。ログを確認し、原因に応じて再試行できます。',
7199
7206
  },
7200
7207
  actions: {
7201
- retry: '再試行', resume: '再開', connect: 'Connect', check: '接続確認',
7202
- other: 'Agentで実行', log: 'ログ確認', tests: 'テスト再実行', pr: 'PR再試行',
7208
+ retry: 'Retry', resume: 'Resume', connect: 'Connect', check: 'Check connection',
7209
+ other: 'Try another Agent', log: 'View log', tests: 'Re-run tests', pr: 'Retry PR',
7203
7210
  },
7204
7211
  },
7205
7212
  en: {
@@ -7207,6 +7214,7 @@ const FAILURE_COPY = {
7207
7214
  infra: {
7208
7215
  title: 'Galda system issue',
7209
7216
  timeout: 'Galda stopped this at the run limit or a wait timed out. This is not necessarily a problem with the change.',
7217
+ interrupted: 'The local run was interrupted. This does not necessarily reflect a problem with the change or agent; resume the same work.',
7210
7218
  auth: 'Connection or authentication failed. Reconnect, then continue.',
7211
7219
  relay: 'The relay connection failed. Check the connection, then retry.',
7212
7220
  chrome: 'Chrome/browser verification failed. The UI check environment stopped, not necessarily the implementation.',
@@ -7227,17 +7235,23 @@ function failureLang(){
7227
7235
  function inferErrorClass(text, agent){
7228
7236
  const low = String(text || '').toLowerCase();
7229
7237
  const infra = (reason, actionIds) => ({ owner: 'galda-infra', reason, actionIds });
7230
- if (/run limit|実行上限|timed out|timeout|タイムアウト/.test(low)) return infra('timeout', ['retry', 'run-with-codex', 'view-log']);
7231
- if (/spawn|enoent|eacces|command not found|worker tool exited early|作業ツールが途中で終了/.test(low)) return infra('worker-start', ['retry', 'run-with-codex', 'view-log']);
7232
- if (/auth probe|preflight|not logged in|logged out|not authenticated|authentication|unauthorized|invalid api key|bad credentials|401\b|403\b|接続が必要|認証/.test(low)) return infra('auth', ['connect', 'check-connection', 'run-with-codex', 'view-log']);
7238
+ if (/run limit|実行上限|timed out|timeout|タイムアウト/.test(low)) return infra('timeout', ['retry', 'view-log']);
7239
+ if (/worker was interrupted|作業が途中で中断|exit 143|sigterm|sigint/.test(low)) return infra('interrupted', ['retry', 'view-log']);
7240
+ if (/spawn|enoent|eacces|command not found|worker tool exited early|作業ツールが途中で終了/.test(low)) return infra('worker-start', ['retry', 'view-log']);
7241
+ if (/auth probe|preflight|not logged in|logged out|not authenticated|authentication|unauthorized|invalid api key|bad credentials|401\b|403\b|接続が必要|認証/.test(low)) return infra('auth', ['connect', 'check-connection', 'view-log']);
7233
7242
  if (/relay|websocket|socket|econnreset|econnrefused|billing-api unreachable|cf access|cloudflare access/.test(low)) return infra('relay', ['retry', 'connect', 'view-log']);
7234
7243
  if (/chrome|chromium|puppeteer|target closed|browser|navigation timeout|waiting failed|screenshot|headless/.test(low)) return infra('chrome', ['retry', 'view-log']);
7235
7244
  if (/test runner|node --test|npm test|project tests|server exited \(code \d+\) before it was ready|did not report ready in time|test timed out after|tap|not ok \d+/.test(low)) return infra('test-runner', ['retry-tests', 'view-log']);
7236
7245
  if (/pr step failed|openpr|pull request|github|gh:|gh auth|git push|git fetch|git .*failed|pr作成|pr failed/.test(low)) return infra('pr', ['retry-pr', 'view-log']);
7237
- return { owner: 'agent', reason: agent === 'codex' ? 'codex' : 'claude-code', actionIds: ['retry', 'run-with-codex', 'view-log'] };
7246
+ return { owner: 'agent', reason: agent === 'codex' ? 'codex' : 'claude-code', actionIds: ['retry', 'view-log'] };
7238
7247
  }
7239
7248
  function taskErrorMeta(t, goal){
7240
- const meta = t.errorClass || inferErrorClass(t.result, t.agent || goal?.agent);
7249
+ // Reclassify interrupted tasks from their concrete exit text instead of
7250
+ // trusting stale persisted metadata. Older records labelled an external
7251
+ // SIGTERM/restart as an Agent failure and permanently carried a Codex switch.
7252
+ const inferred = inferErrorClass(t.result, t.agent || goal?.agent);
7253
+ const raw = t.status === 'interrupted' ? inferred : (t.errorClass || inferred);
7254
+ const meta = { ...raw, actionIds: (raw.actionIds || []).filter((id) => !['run-with-codex', 'run-with-claude'].includes(id)) };
7241
7255
  const lang = failureLang(), C = FAILURE_COPY[lang];
7242
7256
  const isInfra = meta.owner === 'galda-infra';
7243
7257
  return {
@@ -7251,20 +7265,16 @@ function goalBlockedErrorMeta(g){
7251
7265
  const raw = g.blocked?.errorClass || (g.blocked?.kind === 'test'
7252
7266
  ? { owner: 'galda-infra', reason: 'test-runner', actionIds: ['retry-tests', 'view-log'] }
7253
7267
  : inferErrorClass(g.blocked?.reason, g.agent));
7268
+ const meta = { ...raw, actionIds: (raw.actionIds || []).filter((id) => !['run-with-codex', 'run-with-claude'].includes(id)) };
7254
7269
  const lang = failureLang(), C = FAILURE_COPY[lang];
7255
- const isInfra = raw.owner === 'galda-infra';
7256
- return { ...raw, title: isInfra ? C.infra.title : C.agent.title, body: isInfra ? (C.infra[raw.reason] || C.infra.unknown) : C.agent.body, copy: C };
7270
+ const isInfra = meta.owner === 'galda-infra';
7271
+ return { ...meta, title: isInfra ? C.infra.title : C.agent.title, body: isInfra ? (C.infra[meta.reason] || C.infra.unknown) : C.agent.body, copy: C };
7257
7272
  }
7258
7273
  function errorButtonsHtml(meta, { taskId = null, goalId = null, interrupted = false, agent = null, pr = false } = {}){
7259
7274
  const C = meta.copy.actions;
7260
- const canCodex = goalId != null && agent !== 'codex' && (state.agents || []).includes('codex');
7261
7275
  const out = [];
7262
7276
  for (const id of meta.actionIds || []) {
7263
7277
  if (id === 'retry' && taskId != null) out.push(`<button class="errbtn" data-fsretry="${taskId}">${interrupted ? C.resume : C.retry}</button>`);
7264
- // This keeps the existing agent-switch endpoint, but says what it really does
7265
- // for an interrupted run: continue the saved goal with another agent rather
7266
- // than imply a brand-new, contextless task will be created.
7267
- else if (id === 'run-with-codex' && canCodex) out.push(`<button class="errbtn" data-fscodex="${goalId}">${interrupted ? (failureLang() === 'ja' ? 'Codexで再開' : 'Resume with Codex') : C.other}</button>`);
7268
7278
  else if (id === 'connect') out.push(`<button class="errbtn" data-fsconnectworker="1">${C.connect}</button>`);
7269
7279
  else if (id === 'check-connection') out.push(`<button class="errbtn" data-fscheckworker="1">${C.check}</button>`);
7270
7280
  else if (id === 'retry-tests' && goalId != null) out.push(`<button class="errbtn" data-fsreverify="${goalId}">${C.tests}</button>`);
@@ -9001,22 +9011,6 @@ function replyBinding(){
9001
9011
  if (REPLY_PLAIN.has(g.status)) return { goal: g, mode: 'plain' };
9002
9012
  return null;
9003
9013
  }
9004
- // The visible, reversible "Replying to #NNN ✕" chip above the composer — never
9005
- // silent (shown whenever bound), always detachable (✕ clears the binding).
9006
- function renderReplyBind(){
9007
- const el = $('replyBind'); if (!el) return;
9008
- const b = replyBinding();
9009
- el.hidden = !b;
9010
- const inp = $('input');
9011
- if (b) {
9012
- const n = goalReviewNumber({ goal: b.goal, tasks: state.tasks }) ?? b.goal.id;
9013
- const numEl = $('replyBindNum'); if (numEl) numEl.textContent = '#' + n;
9014
- const lblEl = $('replyBindLabel'); if (lblEl) lblEl.textContent = b.mode === 'answer' ? 'Answering' : 'Replying to';
9015
- if (inp) inp.placeholder = b.mode === 'answer' ? `Answering #${n}…` : `Reply to #${n}…`;
9016
- } else if (inp) {
9017
- inp.placeholder = 'Describe a goal… ( / for commands )';
9018
- }
9019
- }
9020
9014
  async function send(){
9021
9015
  const { text, model, effort, mode, skill, later, review } = resolveComposer($('input').value);
9022
9016
  if (!text) return;
@@ -10407,8 +10401,8 @@ async function saCall(ids, path, body){
10407
10401
  // (outcome:'auto', ONE-CONVERSATION §1.4) — this layer never guesses continue vs
10408
10402
  // question. Unlike saAct it does NOT show its outcome first: "Feedback sent" would
10409
10403
  // be a lie when the reply was a question.
10410
- async function saReplyCall(it, text, outcome){
10411
- 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 });
10412
10406
  if (!r?.ok) throw new Error('reply failed');
10413
10407
  return r.json().catch(() => null);
10414
10408
  }
@@ -10515,7 +10509,7 @@ async function saSendReply(it, i, text){
10515
10509
  try {
10516
10510
  let body = await saReplyCall(it, workerText, 'auto');
10517
10511
  if (body?.outcome === 'unsure') {
10518
- it.inflight = false; it.rstatus = 'unsure'; it.rchoices = body.choices || REPLY_OUTCOMES_CLIENT;
10512
+ it.inflight = false; it.rstatus = 'unsure'; it.rchoices = replyChoices(body);
10519
10513
  saPaintCard(goalId);
10520
10514
  const picked = await new Promise((resolve) => { it._routeResolve = resolve; });
10521
10515
  it._routeResolve = null;
@@ -10525,7 +10519,9 @@ async function saSendReply(it, i, text){
10525
10519
  saPaintCard(goalId); return;
10526
10520
  }
10527
10521
  it.inflight = true; it.rstatus = 'thinking'; saPaintCard(goalId);
10528
- 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);
10529
10525
  }
10530
10526
  it.inflight = false;
10531
10527
  if (body?.outcome === 'answer') { // a question: the AI answers, the goal never moves
@@ -11258,8 +11254,6 @@ $('filein').addEventListener('change', () => {
11258
11254
  for (const f of $('filein').files) uploadFile(f);
11259
11255
  $('filein').value = '';
11260
11256
  });
11261
- // Feature A: ✕ on the "Replying to #NNN" chip detaches → back to new-goal mode.
11262
- $('replyBindX').onclick = () => { state.composerDetachedGoal = state.selectedGoal; render(); };
11263
11257
  const IS_MOBILE = () => window.matchMedia('(max-width: 760px)').matches;
11264
11258
  // Phones are chat-first: the Tasks sheet starts FOLDED so the first view is the
11265
11259
  // conversation + composer (the sheet is an 88vw overlay — default-open buried
package/engine/lib.mjs CHANGED
@@ -1258,13 +1258,20 @@ export function classifyWorkerFailure({ agent = 'claude-code', result = '', err
1258
1258
  actionIds,
1259
1259
  });
1260
1260
  if (timedOut || /run limit|実行上限|timed out|timeout|タイムアウト/.test(low)) {
1261
- return infra('timeout', ['retry', 'run-with-codex', 'view-log']);
1261
+ return infra('timeout', ['retry', 'view-log']);
1262
+ }
1263
+ // SIGTERM/SIGINT means the local worker was stopped from outside the task
1264
+ // (for example while the local Manager was restarting). It says nothing
1265
+ // about either the requested change or the chosen agent, so never turn this
1266
+ // recovery path into a suggestion to switch agents.
1267
+ if (code === 143 || code === 130 || /worker was interrupted|作業が途中で中断|exit 143|sigterm|sigint/.test(low)) {
1268
+ return infra('interrupted', ['retry', 'view-log']);
1262
1269
  }
1263
1270
  if (/spawn|enoent|eacces|command not found|executable|worker tool exited early|作業ツールが途中で終了/.test(low)) {
1264
- return infra('worker-start', ['retry', 'run-with-codex', 'view-log']);
1271
+ return infra('worker-start', ['retry', 'view-log']);
1265
1272
  }
1266
1273
  if (/auth probe|preflight|not logged in|logged out|not authenticated|authentication|unauthorized|invalid api key|bad credentials|401\b|403\b|接続が必要|認証/.test(low)) {
1267
- return infra('auth', ['connect', 'check-connection', 'run-with-codex', 'view-log']);
1274
+ return infra('auth', ['connect', 'check-connection', 'view-log']);
1268
1275
  }
1269
1276
  if (/relay|websocket|socket|econnreset|econnrefused|billing-api unreachable|cf access|cloudflare access/.test(low)) {
1270
1277
  return infra('relay', ['retry', 'connect', 'view-log']);
@@ -1281,7 +1288,7 @@ export function classifyWorkerFailure({ agent = 'claude-code', result = '', err
1281
1288
  return {
1282
1289
  owner: 'agent',
1283
1290
  reason: agent === 'codex' ? 'codex' : 'claude-code',
1284
- actionIds: ['retry', agent === 'claude-code' ? 'run-with-codex' : 'run-with-claude', 'view-log'],
1291
+ actionIds: ['retry', 'view-log'],
1285
1292
  };
1286
1293
  }
1287
1294
 
@@ -2451,6 +2458,55 @@ export function dismissGoal({ goalStatus, outcome = 'continue' } = {}) {
2451
2458
  return { ok: true, outcome: 'continue', status: 'running', spawnsWorker: true };
2452
2459
  }
2453
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
+
2454
2510
  // 宛先(docs/design/ONE-CONVERSATION.md §1.2)
2455
2511
  //
2456
2512
  // チャットで「#12 の件だけど」と書けたら、そのチケット宛て。**決定論で、我々の推測は
@@ -3180,6 +3236,14 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3180
3236
  return [
3181
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.` : '',
3182
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') : '',
3183
3247
  goal?.priorFailureMemory?.length ? `\nMANAGER FAILURE MEMORY:\n${latestFailurePolicy(goal.priorFailureMemory)?.text}\nApply this prevention before starting.` : '',
3184
3248
  '',
3185
3249
  // The user's own words, verbatim and in full, are the source of truth.
@@ -3397,7 +3461,9 @@ export function parseRunDeclaration(text) {
3397
3461
  const compare = parseComparePair(raw.compare);
3398
3462
  const title = str(raw.title, 60);
3399
3463
  // Nothing to start, nothing to open, nothing named, and no pair to show = nothing to look at.
3400
- 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
+ }
3401
3467
  // Only http(s): the button opens this in the user's browser. A file:// (or
3402
3468
  // any other scheme) is a worker mistake with a specific fix — declare `file`
3403
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.88",
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": {