@galda/cli 0.10.89 → 0.10.91

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);
@@ -6237,13 +6236,14 @@ function summarizeAttention(goal, tasks){
6237
6236
  decision = ja
6238
6237
  ? 'worker に失敗したテスト出力を確認させ、回帰を修正して、プロジェクトのテストを再実行するよう指示してください。'
6239
6238
  : 'Ask the worker to inspect the failing test output, fix the regression, and rerun the project tests.';
6240
- } else if (/interrupted/i.test(goal.status || '') || /interrupted/i.test(compact)) {
6239
+ } else if (failed?.status === 'interrupted' || /interrupted/i.test(goal.status || '') || /interrupted/i.test(compact)) {
6240
+ const noReport = !compact;
6241
6241
  happened = ja
6242
- ? `必要だったこと: ${need}。${taskName ? `「${taskName}」の作業中に、` : ''}実行が中断されました: ${compact.slice(0, 140) || '詳細ログは取得できませんでした'}。`
6243
- : `Needed: ${need}. The run was interrupted${taskName ? ` while working on "${taskName}"` : ''}: ${compact.slice(0, 140) || 'no detailed log was captured'}.`;
6242
+ ? `必要だったこと: ${need}。${taskName ? `「${taskName}」の作業中に、` : ''}${noReport ? 'Managerとworkerの接続が完了報告前に切れました。run limitだったとは確認できません' : `実行が中断されました: ${compact.slice(0, 140)}`}。`
6243
+ : `Needed: ${need}. ${noReport ? 'The Manager lost its worker connection before a final report was saved; this is not confirmed as a run-limit stop' : `The run was interrupted${taskName ? ` while working on "${taskName}"` : ''}: ${compact.slice(0, 140)}`}.`;
6244
6244
  decision = ja
6245
- ? '具体的な次の一手を指定して Resume するか、不要なら Archive してください。'
6246
- : 'Resume with a concrete next step, or archive it if the work is no longer needed.';
6245
+ ? '依頼を言い直したり範囲を狭めたりせず、そのまま Resume できます。既存の作業ファイルから続行します。'
6246
+ : 'Resume the same request without rewording or narrowing it. Work continues from the existing files.';
6247
6247
  } else if (/blocked/i.test(goal.status || '') || compact) {
6248
6248
  happened = ja
6249
6249
  ? `必要だったこと: ${need}。Review に進む前に停止しました${compact ? `: ${compact.slice(0, 160)}` : ''}。`
@@ -6422,7 +6422,6 @@ $('gdRevisionInput').addEventListener('keydown', (e) => {
6422
6422
  function render(){
6423
6423
  renderProjects(); renderStream(); renderQueue(); renderTasks(); renderActPanel(); wireWfEditButtons();
6424
6424
  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
6425
  renderFlagship();
6427
6426
  if (state.reviewOpenGoal != null) renderReviewChecklist();
6428
6427
  if (state.goalDetailOpen != null) renderGoalDetail();
@@ -6507,7 +6506,12 @@ function renderFsRail(){
6507
6506
  const running = pgoals.some((g) => fsBoardBucket(g, ptasks) === 'doing'
6508
6507
  && !(g.status === 'blocked' && g.blocked?.kind === 'paused'));
6509
6508
  const td = ptasks.filter((t) => ['running', 'queued'].includes(t.status)).length;
6510
- const rev = buildReviewDigest({ goals: pgoals, tasks: ptasks }).length;
6509
+ // The rail's eye icon is the review/attention count. Needs-you goals
6510
+ // (needsInput/needsApproval/blocked, minus Paused) block on the person the
6511
+ // same way an open Review does, so they belong in the same count (Masa
6512
+ // task 854: "needs youも目のアイコンのマークにいれれますか?").
6513
+ const needsCount = pgoals.filter((g) => fsBoardBucket(g, ptasks) === 'needs').length;
6514
+ const rev = buildReviewDigest({ goals: pgoals, tasks: ptasks }).length + needsCount;
6511
6515
  const pcount = `<span class="pcount" title="${rev} review${rev === 1 ? '' : 's'} ready · ${td} to-do">`
6512
6516
  + `<span class="pc-item rev${rev ? '' : ' zero'}">${FS_ICO_SCAN}${rev}</span>`
6513
6517
  + `<span class="pc-item td">${FS_ICO_TD}${td}</span></span>`;
@@ -7251,7 +7255,9 @@ function taskErrorMeta(t, goal){
7251
7255
  // Reclassify interrupted tasks from their concrete exit text instead of
7252
7256
  // trusting stale persisted metadata. Older records labelled an external
7253
7257
  // SIGTERM/restart as an Agent failure and permanently carried a Codex switch.
7254
- const inferred = inferErrorClass(t.result, t.agent || goal?.agent);
7258
+ const inferred = t.status === 'interrupted' && !String(t.result || '').trim()
7259
+ ? { owner: 'galda-infra', reason: 'interrupted', actionIds: ['retry', 'view-log'] }
7260
+ : inferErrorClass(t.result, t.agent || goal?.agent);
7255
7261
  const raw = t.status === 'interrupted' ? inferred : (t.errorClass || inferred);
7256
7262
  const meta = { ...raw, actionIds: (raw.actionIds || []).filter((id) => !['run-with-codex', 'run-with-claude'].includes(id)) };
7257
7263
  const lang = failureLang(), C = FAILURE_COPY[lang];
@@ -9013,22 +9019,6 @@ function replyBinding(){
9013
9019
  if (REPLY_PLAIN.has(g.status)) return { goal: g, mode: 'plain' };
9014
9020
  return null;
9015
9021
  }
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
9022
  async function send(){
9033
9023
  const { text, model, effort, mode, skill, later, review } = resolveComposer($('input').value);
9034
9024
  if (!text) return;
@@ -9264,7 +9254,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
9264
9254
  // 4 archived / 5 reverted — session-local; the server round-trip is what
9265
9255
  // settles it (see saSync's "returned to review" reset).
9266
9256
  // ============================================================================
9267
- const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), diffOpen: new Set(), attachments: {}, focusGid: null, focusScrolled: false, focusTimer: null, cur: null, solo: null };
9257
+ const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), diffOpen: new Set(), attachments: {}, focusGid: null, focusScrolled: false, focusTimer: null, cur: null, solo: null, lastMarkup: null };
9268
9258
  function saReplyAttachmentsHtml(goalId){
9269
9259
  return (SA.attachments[goalId] || []).map((a, i) => `<span class="sa-replythumb"><img src="${esc(a.url)}" alt="Attached image"><button type="button" data-saarm="${goalId}:${i}" aria-label="Remove image">×</button></span>`).join('');
9270
9260
  }
@@ -9954,7 +9944,9 @@ function saItemHtml(it, i){
9954
9944
  const tests = it.testResult?.ran
9955
9945
  ? (it.testResult.ok ? 'Verification completed before the interruption.' : 'Verification remains incomplete or failed.')
9956
9946
  : 'Verification has not completed yet.';
9957
- const reason = String(t.result || 'The worker reached its run limit before finishing.').replace(/\s+/g, ' ').slice(0, 240);
9947
+ const reason = String(t.result || (failureLang() === 'ja'
9948
+ ? 'Managerとworkerの接続が完了報告前に切れました。run limitだったとは確認できません。作業ファイルを保持したままResumeできます。'
9949
+ : 'The Manager lost its worker connection before a final report was saved. This is not confirmed as a run-limit stop. Resume keeps the existing work files.')).replace(/\s+/g, ' ').slice(0, 240);
9958
9950
  return `<div class="rinfra"><p class="rbody v"><b>Interrupted — resume available</b><br>${esc(reason)}</p>`
9959
9951
  + (last ? `<p class="rbody v">Last recorded step: ${esc(String(last).slice(0, 220))}</p>` : '')
9960
9952
  + (files.length ? `<p class="rbody v">Changed so far: ${esc(files.join(' · '))}</p>` : '')
@@ -10238,6 +10230,9 @@ function saRender(){
10238
10230
  const previousBody = S.querySelector('.sa-body');
10239
10231
  const previousScrollTop = previousBody?.scrollTop ?? 0;
10240
10232
  const restoreScroll = Boolean(previousBody) && SA.focusGid == null;
10233
+ const focusedReplyGoal = document.activeElement?.matches?.('#seeall .sa-chat')
10234
+ ? document.activeElement.dataset.goal
10235
+ : null;
10241
10236
  const pending = SA.items.filter((x) => x.st === 0);
10242
10237
  const green = pending.filter((x) => !x.testResult || x.testResult.ok !== false).length;
10243
10238
  // Solo mode = a single "Needs you" goal opened as this card. It is not a review batch, so the
@@ -10248,8 +10243,16 @@ function saRender(){
10248
10243
  const curIdx = pending.findIndex((x) => x.goalId === SA.cur);
10249
10244
  const posLabel = solo ? '' : (pending.length && curIdx >= 0 ? `${curIdx + 1} / ${pending.length}` : '');
10250
10245
  const hdKeys = solo ? '↑↓ MOVE · ESC CLOSE' : '↑↓ MOVE · A APPROVE · D DISMISS · ESC CLOSE';
10251
- S.innerHTML = `<div class="sa-panel"><div class="sa-hd"><span class="t">${hdTitle}</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">${hdKeys}</span><button class="sa-x" data-saact="close:0">✕</button></div>
10246
+ const markup = `<div class="sa-panel"><div class="sa-hd"><span class="t">${hdTitle}</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">${hdKeys}</span><button class="sa-x" data-saact="close:0">✕</button></div>
10252
10247
  <div class="sa-body">${SA.items.length ? SA.items.map((it, i) => saItemHtml(it, i)).join('') : '<div class="sa-empty">Nothing waiting for review.</div>'}</div></div>`;
10248
+ // Most SSE goal/task events are unrelated to the card the person is reading.
10249
+ // Replacing #seeall with identical markup destroyed and recreated every node,
10250
+ // producing a blank flash and resetting browser-owned state. Keep the current
10251
+ // dialog DOM when its semantic markup did not change; the board behind it can
10252
+ // still refresh normally.
10253
+ if (SA.lastMarkup === markup && S.querySelector('.sa-panel')) return;
10254
+ SA.lastMarkup = markup;
10255
+ S.innerHTML = markup;
10253
10256
  const body = S.querySelector('.sa-body');
10254
10257
  if (body && restoreScroll) body.scrollTop = previousScrollTop;
10255
10258
  // Once the person scrolls, their position wins over the short opening-focus
@@ -10303,6 +10306,10 @@ function saRender(){
10303
10306
  if (!SA.focusTimer) SA.focusTimer = setTimeout(() => { SA.focusGid = null; SA.focusTimer = null; }, 1400);
10304
10307
  });
10305
10308
  }
10309
+ // A relevant status change may legitimately rebuild the card. Restore the
10310
+ // empty reply box's focus afterwards; non-empty input is protected earlier
10311
+ // by saSync and is never rebuilt at all.
10312
+ if (focusedReplyGoal != null) S.querySelector(`.sa-chat[data-goal="${focusedReplyGoal}"]`)?.focus({ preventScroll: true });
10306
10313
  }
10307
10314
  // keep the open Ledger in sync with server state (SSE/refresh → render() → here).
10308
10315
  // An item whose goal has RETURNED to 'review' (retest ×3 green, rework done,
@@ -10350,6 +10357,7 @@ async function saFetchDiffs(){
10350
10357
  }
10351
10358
  function saOpen(focusGoalId, solo = false){
10352
10359
  SA.open = true;
10360
+ SA.lastMarkup = null; // a newly opened/focused dialog always gets a clean first paint
10353
10361
  SA.solo = solo ? focusGoalId : null; // solo = a single "Needs you" goal opened as one card
10354
10362
  SA.items = saLedgerRows().map((r) => ({ ...r, st: 0 }));
10355
10363
  SA.detOpen = new Set();
@@ -10380,7 +10388,7 @@ function saOpen(focusGoalId, solo = false){
10380
10388
  saFetchDiffs();
10381
10389
  saEnhanceShotBoxes();
10382
10390
  }
10383
- function saClose(){ SA.open = false; SA.solo = null; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
10391
+ function saClose(){ SA.open = false; SA.solo = null; SA.focusGid = null; SA.lastMarkup = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
10384
10392
  // Continuous review (Masa 2026-07-08): move a persistent "current" card with
10385
10393
  // ↑↓ and auto-advance to the next pending after each verdict — same screen, same
10386
10394
  // Ledger, so approving flows without hunting for the next one.
@@ -10419,8 +10427,8 @@ async function saCall(ids, path, body){
10419
10427
  // (outcome:'auto', ONE-CONVERSATION §1.4) — this layer never guesses continue vs
10420
10428
  // question. Unlike saAct it does NOT show its outcome first: "Feedback sent" would
10421
10429
  // 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 });
10430
+ async function saReplyCall(it, text, outcome, targetProjectId = null){
10431
+ const r = await saCall(it.ids, 'dismiss', { text, outcome, targetProjectId });
10424
10432
  if (!r?.ok) throw new Error('reply failed');
10425
10433
  return r.json().catch(() => null);
10426
10434
  }
@@ -10527,7 +10535,7 @@ async function saSendReply(it, i, text){
10527
10535
  try {
10528
10536
  let body = await saReplyCall(it, workerText, 'auto');
10529
10537
  if (body?.outcome === 'unsure') {
10530
- it.inflight = false; it.rstatus = 'unsure'; it.rchoices = body.choices || REPLY_OUTCOMES_CLIENT;
10538
+ it.inflight = false; it.rstatus = 'unsure'; it.rchoices = replyChoices(body);
10531
10539
  saPaintCard(goalId);
10532
10540
  const picked = await new Promise((resolve) => { it._routeResolve = resolve; });
10533
10541
  it._routeResolve = null;
@@ -10537,7 +10545,9 @@ async function saSendReply(it, i, text){
10537
10545
  saPaintCard(goalId); return;
10538
10546
  }
10539
10547
  it.inflight = true; it.rstatus = 'thinking'; saPaintCard(goalId);
10540
- body = await saReplyCall(it, workerText, picked);
10548
+ body = picked.startsWith('project:')
10549
+ ? await saReplyCall(it, workerText, 'split', picked.slice('project:'.length))
10550
+ : await saReplyCall(it, workerText, picked);
10541
10551
  }
10542
10552
  it.inflight = false;
10543
10553
  if (body?.outcome === 'answer') { // a question: the AI answers, the goal never moves
@@ -11270,8 +11280,6 @@ $('filein').addEventListener('change', () => {
11270
11280
  for (const f of $('filein').files) uploadFile(f);
11271
11281
  $('filein').value = '';
11272
11282
  });
11273
- // Feature A: ✕ on the "Replying to #NNN" chip detaches → back to new-goal mode.
11274
- $('replyBindX').onclick = () => { state.composerDetachedGoal = state.selectedGoal; render(); };
11275
11283
  const IS_MOBILE = () => window.matchMedia('(max-width: 760px)').matches;
11276
11284
  // Phones are chat-first: the Tasks sheet starts FOLDED so the first view is the
11277
11285
  // conversation + composer (the sheet is an 88vw overlay — default-open buried
package/engine/lib.mjs CHANGED
@@ -46,6 +46,19 @@ export function isCommandOnPath(cmd, { pathEnv = '', sep = ':', pathExt = '', ex
46
46
  return false;
47
47
  }
48
48
 
49
+ // A directory mention becomes an execution target only when the person labels
50
+ // it as a working/development directory. Bare paths remain ordinary context so
51
+ // a worker is never silently moved just because a filename appeared in prose.
52
+ export function parseRequestedWorkspaceDir(text) {
53
+ const source = String(text ?? '');
54
+ const marker = /(?:working\s*directory|workdir|作業(?:ディレクトリ|場所)|開発場所)\s*(?:を|は|:|:|=|=)?\s*/i;
55
+ const found = source.match(marker);
56
+ if (!found) return null;
57
+ const rest = source.slice((found.index ?? 0) + found[0].length);
58
+ const path = rest.match(/\/(?:[A-Za-z0-9._~@%+\-]+\/)*[A-Za-z0-9._~@%+\-]+\/?/);
59
+ return path ? path[0].replace(/\/+$/, '') || '/' : null;
60
+ }
61
+
49
62
  // Galda makes small internal LLM calls (composer-intent classification, proof-card
50
63
  // summaries, board Q&A). These prefer Claude Code + haiku (cheap + fast), but must
51
64
  // still work on a Codex-only machine — so they run on whatever agent is available,
@@ -2458,6 +2471,55 @@ export function dismissGoal({ goalStatus, outcome = 'continue' } = {}) {
2458
2471
  return { ok: true, outcome: 'continue', status: 'running', spawnsWorker: true };
2459
2472
  }
2460
2473
 
2474
+ // `outcome:auto` is the contract used by every review reply box in the app.
2475
+ // Keep the routing decision in one place: the reply either asks a question,
2476
+ // requests a change, rescopes the ticket, starts separate work, or explicitly
2477
+ // remains unsure. Returning unsure is safe because the UI already presents
2478
+ // these exact choices without moving the goal.
2479
+ export const REPLY_INTENTS = [...REPLY_OUTCOMES, 'unsure'];
2480
+ export function buildReplyIntentPrompt({ goalText, reply, currentProject = null, projects = [] } = {}) {
2481
+ const projectLines = (projects ?? []).map((project) =>
2482
+ `- ${project.id}: ${project.name || project.id} | root=${project.dir || '(unknown)'} | scope=${project.note || '(not specified)'}`);
2483
+ return [
2484
+ '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.',
2485
+ 'Decide what the reply ASKS FOR — exactly one of:',
2486
+ '- "continue": fix or change the work that was just done.',
2487
+ '- "rescope": stop and rethink; the request itself should now say something different.',
2488
+ '- "split": a separate piece of work that should not touch this one.',
2489
+ '- "answer": a question about the work. Nothing should be built or changed.',
2490
+ '- "unsure": you genuinely cannot tell. Prefer this over guessing.',
2491
+ 'Judge the full meaning. A question followed by a requested change is "continue".',
2492
+ 'Also decide which project must receive the requested work.',
2493
+ '- Keep targetProjectId equal to the current project for ordinary fixes to the reviewed work.',
2494
+ '- 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.',
2495
+ '- Never infer a project that is not listed. If the target is ambiguous, use outcome "unsure" and targetProjectId null.',
2496
+ 'Output ONLY one line of raw JSON: {"outcome":"continue|rescope|split|answer|unsure","targetProjectId":"listed-id-or-null"}.',
2497
+ '',
2498
+ `Current project: ${currentProject?.id || '(unknown)'}`,
2499
+ 'Available projects:',
2500
+ ...(projectLines.length ? projectLines : ['- (none)']),
2501
+ '',
2502
+ 'The request that was worked on:',
2503
+ String(goalText ?? '').slice(0, 2000),
2504
+ '',
2505
+ 'Their reply:',
2506
+ String(reply ?? '').slice(0, 4000),
2507
+ ].join('\n');
2508
+ }
2509
+
2510
+ export function parseReplyIntent(raw) {
2511
+ const unread = { outcome: 'unsure', read: false };
2512
+ if (!raw || typeof raw !== 'string') return unread;
2513
+ const m = raw.match(/\{[\s\S]*\}/);
2514
+ if (!m) return unread;
2515
+ let obj;
2516
+ try { obj = JSON.parse(m[0]); } catch { return unread; }
2517
+ if (!REPLY_INTENTS.includes(obj?.outcome)) return unread;
2518
+ const targetProjectId = typeof obj.targetProjectId === 'string' && obj.targetProjectId.trim()
2519
+ ? obj.targetProjectId.trim() : null;
2520
+ return { outcome: obj.outcome, targetProjectId, read: true };
2521
+ }
2522
+
2461
2523
  // 宛先(docs/design/ONE-CONVERSATION.md §1.2)
2462
2524
  //
2463
2525
  // チャットで「#12 の件だけど」と書けたら、そのチケット宛て。**決定論で、我々の推測は
@@ -2904,12 +2966,10 @@ export function buildEphemeralSeedProjects(cwd) {
2904
2966
  // goal/task written once (e.g. a memo the user wants to survive a restart)
2905
2967
  // is never lost: later lines for the same id override earlier ones (last
2906
2968
  // write wins), and a goal write with `deleted:true` removes it. Mid-flight
2907
- // work can't resume across a restart, so it's marked retryable rather than
2908
- // silently dropped: a 'running' task becomes 'interrupted', a 'planning'
2909
- // goal goes back to 'stacked', and a 'running' goal with no queued task left
2910
- // settles via nextGoalStatus() a wantsPR goal lands in 'review' just like
2911
- // the normal completion path, even if the crash happened before the PR got
2912
- // created (the caller re-attempts createGoalPR for those on restart).
2969
+ // work gets one bounded automatic recovery: a 'running' task becomes 'queued'
2970
+ // once and resumes from the files/session already on disk. A second interrupted
2971
+ // boot stays visible for human retry so a crash loop cannot relaunch forever.
2972
+ // A 'planning' goal still goes back to 'stacked'.
2913
2973
  // `reviewDefinitions` (projectId -> definition, as saved by
2914
2974
  // validateReviewDefinition) lets that settling honor each project's own
2915
2975
  // review definition (task 45); a project with none configured falls back to
@@ -2926,7 +2986,21 @@ export function replayQueueLog(rawText, reviewDefinitions = {}) {
2926
2986
  } catch { /* skip a corrupt line rather than lose the whole log */ }
2927
2987
  }
2928
2988
  for (const t of tasks) {
2929
- if (t.status === 'running') t.status = 'interrupted';
2989
+ if (t.status !== 'running') continue;
2990
+ // A Manager restart is an infrastructure interruption, not a request for
2991
+ // human judgment. Resume the same task once from the files/session already
2992
+ // on disk. The bounded counter prevents a crash-looping task from being
2993
+ // relaunched forever; a second interrupted boot remains visibly retryable.
2994
+ const recoveries = Number(t.restartRecoveries ?? 0);
2995
+ if (recoveries < 1) {
2996
+ t.status = 'queued';
2997
+ t.restartRecoveries = recoveries + 1;
2998
+ t.recoveredFromRestart = true;
2999
+ t.startedAt = null;
3000
+ t.finishedAt = null;
3001
+ } else {
3002
+ t.status = 'interrupted';
3003
+ }
2930
3004
  }
2931
3005
  for (const g of goals) {
2932
3006
  if (g.status === 'planning') g.status = 'stacked';
@@ -3187,6 +3261,14 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3187
3261
  return [
3188
3262
  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
3263
  `You are a ${displayName} worker managed by "Manager for AI". Do the request below inside this project directory.`,
3264
+ task?.projectContext ? [
3265
+ 'PROJECT BOUNDARY (source of truth):',
3266
+ `- Project: ${task.projectContext.name || task.projectContext.id}`,
3267
+ `- Root: ${task.projectContext.dir}`,
3268
+ task.projectContext.note ? `- Scope/note: ${task.projectContext.note}` : '',
3269
+ '- Your edits and claims apply only to this project. Never call a design/docs/prototype change a production implementation.',
3270
+ '- If the request requires another project, do not pretend it was implemented here; report the exact project mismatch.',
3271
+ ].filter(Boolean).join('\n') : '',
3190
3272
  goal?.priorFailureMemory?.length ? `\nMANAGER FAILURE MEMORY:\n${latestFailurePolicy(goal.priorFailureMemory)?.text}\nApply this prevention before starting.` : '',
3191
3273
  '',
3192
3274
  // The user's own words, verbatim and in full, are the source of truth.
@@ -3404,7 +3486,9 @@ export function parseRunDeclaration(text) {
3404
3486
  const compare = parseComparePair(raw.compare);
3405
3487
  const title = str(raw.title, 60);
3406
3488
  // 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;
3489
+ if (!cmd && !url && !file && !compare && !title) {
3490
+ return runDeclError('no-open-target', 'declaration contains no command, URL, artifact file, comparison, or title');
3491
+ }
3408
3492
  // Only http(s): the button opens this in the user's browser. A file:// (or
3409
3493
  // any other scheme) is a worker mistake with a specific fix — declare `file`
3410
3494
  // instead — not a JSON parse failure, so it gets its own reason.
@@ -0,0 +1,196 @@
1
+ export const REQUIREMENT_MODEL_VERSION = 1;
2
+
3
+ const CLAIM_STATUSES = new Set(['addressed', 'partial', 'not-addressed']);
4
+ const COVERAGE_STATUSES = new Set(['unverified', 'verified', 'failed', 'not-applicable']);
5
+
6
+ function cleanText(value) {
7
+ return typeof value === 'string' ? value.trim() : '';
8
+ }
9
+
10
+ function stablePart(value) {
11
+ return String(value ?? 'unknown').replace(/[^a-zA-Z0-9_-]+/g, '-');
12
+ }
13
+
14
+ function requirementId(goalId, messageId) {
15
+ return `req-${stablePart(goalId)}-${stablePart(messageId)}`;
16
+ }
17
+
18
+ function attemptId(task) {
19
+ return cleanText(task?.attemptId) || `attempt-task-${stablePart(task?.id)}`;
20
+ }
21
+
22
+ function sourceForTask(task) {
23
+ if (!task?.reply) return 'user';
24
+ if (task.replyFrom === 'you') return 'user';
25
+ if (task.replyFrom === 'manager') return 'manager-policy';
26
+ return 'legacy-unknown';
27
+ }
28
+
29
+ function messageText(task) {
30
+ return cleanText(task?.reply ? task.detail : (task?.detail || task?.title));
31
+ }
32
+
33
+ function normalizeRequirement(value) {
34
+ const id = cleanText(value?.id);
35
+ const text = cleanText(value?.text);
36
+ if (!id || !text) return null;
37
+ return {
38
+ id,
39
+ source: cleanText(value.source) || 'legacy-unknown',
40
+ messageId: value.messageId == null ? null : String(value.messageId),
41
+ text,
42
+ ...(Array.isArray(value.span) && value.span.length === 2 ? { span: value.span } : {}),
43
+ ...(value.derivedFrom ? { derivedFrom: value.derivedFrom } : {}),
44
+ ...(value.supersedes ? { supersedes: value.supersedes } : {}),
45
+ };
46
+ }
47
+
48
+ function normalizeAttempt(value) {
49
+ const id = cleanText(value?.id);
50
+ if (!id) return null;
51
+ return {
52
+ id,
53
+ taskId: value.taskId ?? null,
54
+ status: cleanText(value.status) || 'unknown',
55
+ createdAt: value.createdAt ?? null,
56
+ finishedAt: value.finishedAt ?? null,
57
+ sessionId: value.sessionId ?? null,
58
+ worktree: value.worktree ?? null,
59
+ };
60
+ }
61
+
62
+ function normalizeEvidence(value) {
63
+ const requirementId = cleanText(value?.requirementId);
64
+ const attemptId = cleanText(value?.attemptId);
65
+ if (!requirementId || !attemptId) return null;
66
+ const claim = CLAIM_STATUSES.has(value.claim) ? value.claim : 'not-addressed';
67
+ const coverage = COVERAGE_STATUSES.has(value.coverage) ? value.coverage : 'unverified';
68
+ return {
69
+ requirementId,
70
+ attemptId,
71
+ taskId: value.taskId ?? null,
72
+ claim,
73
+ coverage,
74
+ evidence: cleanText(value.evidence),
75
+ files: [...new Set(Array.isArray(value.files) ? value.files.filter((item) => typeof item === 'string' && item) : [])],
76
+ };
77
+ }
78
+
79
+ function taskRequirementIds(task, requirements, legacyMap) {
80
+ const explicit = Array.isArray(task?.activeRequirementIds)
81
+ ? task.activeRequirementIds.filter((id) => requirements.some((requirement) => requirement.id === id))
82
+ : [];
83
+ if (explicit.length) return [...new Set(explicit)];
84
+
85
+ const own = legacyMap[`task-${task?.id}`];
86
+ if (own) return [own];
87
+ return requirements.length ? [requirements[0].id] : [];
88
+ }
89
+
90
+ /**
91
+ * Convert legacy goal/task records into the canonical requirement model.
92
+ * This function is pure and idempotent. It never removes legacy fields, so a
93
+ * rollback can continue reading the original records.
94
+ */
95
+ export function migrateRequirementModel(goal, goalTasks = []) {
96
+ const requirements = (Array.isArray(goal?.requirements) ? goal.requirements : [])
97
+ .map(normalizeRequirement).filter(Boolean);
98
+ const attempts = (Array.isArray(goal?.attempts) ? goal.attempts : [])
99
+ .map(normalizeAttempt).filter(Boolean);
100
+ const evidence = (Array.isArray(goal?.requirementEvidence) ? goal.requirementEvidence : [])
101
+ .map(normalizeEvidence).filter(Boolean);
102
+ const legacyRequirementMap = { ...(goal?.legacyRequirementMap || {}) };
103
+
104
+ if (!requirements.length && cleanText(goal?.text)) {
105
+ const id = requirementId(goal.id, 'goal');
106
+ requirements.push({ id, source: 'user', messageId: `goal-${goal.id}`, text: cleanText(goal.text) });
107
+ legacyRequirementMap.goal = id;
108
+ }
109
+
110
+ for (const task of goalTasks) {
111
+ const text = messageText(task);
112
+ const shouldCreateRequirement = text && (task.reply ? sourceForTask(task) !== 'manager-policy' : !requirements.length);
113
+ if (shouldCreateRequirement) {
114
+ const legacyId = `task-${task.id}`;
115
+ const id = legacyRequirementMap[legacyId] || requirementId(goal?.id, legacyId);
116
+ if (!requirements.some((requirement) => requirement.id === id)) {
117
+ requirements.push({ id, source: sourceForTask(task), messageId: legacyId, text });
118
+ }
119
+ legacyRequirementMap[legacyId] = id;
120
+ }
121
+
122
+ const id = attemptId(task);
123
+ if (!attempts.some((attempt) => attempt.id === id)) {
124
+ attempts.push(normalizeAttempt({
125
+ id,
126
+ taskId: task.id,
127
+ status: task.status,
128
+ createdAt: task.createdAt,
129
+ finishedAt: task.finishedAt,
130
+ sessionId: task.sessionId ?? task.agentSessionId,
131
+ worktree: task.worktree,
132
+ }));
133
+ }
134
+
135
+ const requirementIds = taskRequirementIds(task, requirements, legacyRequirementMap);
136
+ for (const claim of task?.requirementEvidence?.requirements ?? []) {
137
+ const mappedId = legacyRequirementMap[claim.id] || (requirements.some((requirement) => requirement.id === claim.id) ? claim.id : null);
138
+ const targetIds = mappedId ? [mappedId] : requirementIds;
139
+ for (const targetId of targetIds) {
140
+ const row = normalizeEvidence({
141
+ requirementId: targetId,
142
+ attemptId: id,
143
+ taskId: task.id,
144
+ claim: claim.status,
145
+ coverage: 'unverified',
146
+ evidence: claim.evidence,
147
+ files: claim.files,
148
+ });
149
+ if (row && !evidence.some((item) => item.requirementId === row.requirementId && item.attemptId === row.attemptId && item.taskId === row.taskId)) {
150
+ evidence.push(row);
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ const tasks = goalTasks.map((task) => ({
157
+ ...task,
158
+ activeRequirementIds: taskRequirementIds(task, requirements, legacyRequirementMap),
159
+ }));
160
+
161
+ return {
162
+ goal: {
163
+ ...goal,
164
+ requirementModelVersion: REQUIREMENT_MODEL_VERSION,
165
+ requirements,
166
+ attempts,
167
+ requirementEvidence: evidence,
168
+ legacyRequirementMap,
169
+ },
170
+ tasks,
171
+ };
172
+ }
173
+
174
+ export function validateRequirementModel(goal, goalTasks = []) {
175
+ const errors = [];
176
+ const requirementIds = new Set();
177
+ const attemptIds = new Set();
178
+ for (const requirement of goal?.requirements ?? []) {
179
+ if (!requirement?.id || requirementIds.has(requirement.id)) errors.push(`invalid requirement id: ${requirement?.id ?? ''}`);
180
+ requirementIds.add(requirement?.id);
181
+ }
182
+ for (const attempt of goal?.attempts ?? []) {
183
+ if (!attempt?.id || attemptIds.has(attempt.id)) errors.push(`invalid attempt id: ${attempt?.id ?? ''}`);
184
+ attemptIds.add(attempt?.id);
185
+ }
186
+ for (const task of goalTasks) {
187
+ for (const id of task?.activeRequirementIds ?? []) {
188
+ if (!requirementIds.has(id)) errors.push(`task ${task.id} references unknown requirement ${id}`);
189
+ }
190
+ }
191
+ for (const row of goal?.requirementEvidence ?? []) {
192
+ if (!requirementIds.has(row?.requirementId)) errors.push(`evidence references unknown requirement ${row?.requirementId ?? ''}`);
193
+ if (!attemptIds.has(row?.attemptId)) errors.push(`evidence references unknown attempt ${row?.attemptId ?? ''}`);
194
+ }
195
+ return { ok: errors.length === 0, errors };
196
+ }
package/engine/server.mjs CHANGED
@@ -20,8 +20,8 @@ import { resolve, dirname, join, basename } from 'node:path';
20
20
  import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
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';
23
+ import { needsProjectFolder, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath, parseRequestedWorkspaceDir } 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';
@@ -1524,6 +1524,27 @@ function removeGoalWorkDir(goal, project) {
1524
1524
  try { const repoRoot = gitSync(project.dir, ['rev-parse', '--show-toplevel']); gitSync(repoRoot, ['worktree', 'remove', '--force', wt]); } catch { /* best effort */ }
1525
1525
  }
1526
1526
 
1527
+ function projectForGoal(goal, project) {
1528
+ return goal?.workspaceRoot ? { ...project, dir: goal.workspaceRoot } : project;
1529
+ }
1530
+
1531
+ function rebindGoalWorkspace(goal, project, requestedDir) {
1532
+ const abs = resolve(requestedDir);
1533
+ if (!existsSync(abs)) return { ok: false, error: `Working directory is unavailable: ${abs}. Reconnect or choose the folder before starting work.` };
1534
+ try { if (!statSync(abs).isDirectory()) return { ok: false, error: `Working directory is not a folder: ${abs}` }; } catch { return { ok: false, error: `Working directory cannot be read: ${abs}` }; }
1535
+ if (!isGitRepo(abs)) return { ok: false, error: `Working directory is not a Git repository: ${abs}` };
1536
+ removeGoalWorkDir(goal, project);
1537
+ goal.workspaceRoot = abs;
1538
+ // Claude Code's sandbox is fixed when a session starts. Never resume a
1539
+ // session that was launched from the previous project directory.
1540
+ goal.sessions = {};
1541
+ goal.sessionId = null;
1542
+ goal.sessionAgent = null;
1543
+ goal.contextHandoff = null;
1544
+ saveGoal(goal);
1545
+ return { ok: true, dir: abs };
1546
+ }
1547
+
1527
1548
  // External commit sync: pick up commits made outside this Manager (Claude
1528
1549
  // Code sessions run directly in the terminal, manual commits, other tools)
1529
1550
  // so they show up alongside Manager-run tasks for the same project. The
@@ -2288,13 +2309,15 @@ function pauseGoalForApproval(goal, task, request) {
2288
2309
  //
2289
2310
  // 設定は親から引き継ぐ(プロジェクト・エージェント・モデル・effort)。別件とはいえ
2290
2311
  // 同じ場所で同じ人が続けている作業なので、ここで選び直させる理由が無い。
2291
- function createSplitGoal(parent, text, identity) {
2312
+ function createSplitGoal(parent, text, identity, targetProjectId = parent.projectId) {
2313
+ const targetProject = projects.find((project) => project.id === targetProjectId);
2314
+ if (!targetProject) throw new Error(`unknown target project: ${targetProjectId}`);
2292
2315
  const goal = {
2293
- id: nextId++, projectId: parent.projectId, text: text.slice(0, 8000), status: 'planning',
2294
- wantsPR: resolveWantsPR('auto', text, getReviewDefinition(parent.projectId).defaultWantsPR),
2316
+ id: nextId++, projectId: targetProjectId, text: text.slice(0, 8000), status: 'planning',
2317
+ wantsPR: resolveWantsPR('auto', text, getReviewDefinition(targetProjectId).defaultWantsPR),
2295
2318
  model: parent.model, effort: parent.effort, agent: parent.agent, mode: 'auto',
2296
2319
  skill: null, images: [], source: resolveGoalSource('app'), sourceRef: null,
2297
- executionPlan: buildExecutionPlan({ projectId: parent.projectId, text, previousSession: false }),
2320
+ executionPlan: buildExecutionPlan({ projectId: targetProjectId, text, previousSession: false }),
2298
2321
  plan: null, pr: undefined, createdAt: new Date().toISOString(),
2299
2322
  // 片方向の印。表示は app(新しいカード「#N の会話から生まれました」/元のカード
2300
2323
  // 「→ #M を切り出しました」)。engine は数字を1つ持つだけ。
@@ -2444,6 +2467,13 @@ function readRunDeclaration(task, workDir) {
2444
2467
  // never broken) and wrote the exact same file:// url a second time. `run` is
2445
2468
  // `{ error, message }` here, distinct from the `null` case below.
2446
2469
  if (run && run.error) {
2470
+ // Valid JSON with only note/check means there is simply nothing to open.
2471
+ // That is common for explanation-only follow-ups and must not manufacture
2472
+ // an "unreadable record" error in Review.
2473
+ if (run.error === 'no-open-target') {
2474
+ sendAct(task, 'run declaration ignored — this result has nothing to open.');
2475
+ return;
2476
+ }
2447
2477
  task.runDeclError = { reason: run.error, message: run.message, snippet: String(raw ?? '').slice(0, 200) };
2448
2478
  sendAct(task, `run declaration ignored — ${run.message}`);
2449
2479
  return;
@@ -2673,6 +2703,7 @@ async function startPreview(task) {
2673
2703
  async function runTask(task) {
2674
2704
  const project = projects.find((p) => p.id === task.projectId);
2675
2705
  const goal = goals.find((g) => g.id === task.goalId);
2706
+ task.projectContext = project ? { id: project.id, name: project.name, dir: project.dir, note: project.note } : null;
2676
2707
  task.status = 'running'; task.startedAt = new Date().toISOString();
2677
2708
  // The goal's "started" clock is set by the first task that actually runs — the
2678
2709
  // signal the board uses to read a 'running' goal as DOING rather than QUEUED
@@ -2687,8 +2718,9 @@ async function runTask(task) {
2687
2718
 
2688
2719
  // The workspace mode is chosen at goal creation and then held through every
2689
2720
  // worker/review/PR step.
2690
- const workDir = goal ? goalWorkDir(goal, project) : project.dir;
2691
- const wproject = { ...project, dir: workDir };
2721
+ const effectiveProject = goal ? projectForGoal(goal, project) : project;
2722
+ const workDir = goal ? goalWorkDir(goal, effectiveProject) : effectiveProject.dir;
2723
+ const wproject = { ...effectiveProject, dir: workDir };
2692
2724
  const workspaceMode = resolveWorkspaceMode(task.workspaceMode ?? goal?.workspaceMode ?? goal?.worktree);
2693
2725
  sendProcessAct(task, workspaceMode === 'direct-workspace'
2694
2726
  ? `Using direct workspace: ${workDir}`
@@ -2707,7 +2739,11 @@ async function runTask(task) {
2707
2739
  const planMode = goal?.mode === 'plan' && goal?.planPhase !== 'executing';
2708
2740
  const permissionMode = permissionModeFor(planMode ? 'plan' : (task.mode ?? goal?.mode));
2709
2741
  const entryUrl = (task.passCondition && !planMode) ? entryUrlFor(goal, wproject) : null;
2710
- const maxAttempts = entryUrl ? 3 : 1;
2742
+ // One continuation is reserved for a worker that reaches the infrastructure
2743
+ // time cap. Long research/report tasks commonly need just over one window;
2744
+ // resume their saved session automatically instead of asking the customer to
2745
+ // discover and press Retry. Normal failures still stop after one attempt.
2746
+ const maxAttempts = entryUrl ? 3 : 2;
2711
2747
  const t0 = Date.now();
2712
2748
 
2713
2749
  // Thread replies resume the goal's Claude Code session — same context,
@@ -2872,6 +2908,10 @@ async function runTask(task) {
2872
2908
  if (r.sessionId && goal) { setGoalAgentSession(goal, runAgent, r.sessionId); saveGoal(goal); }
2873
2909
  approval = parseApprovalRequest(result);
2874
2910
  if (approval) break;
2911
+ if (timedOut && attempt < maxAttempts) {
2912
+ sendProcessAct(task, 'Run limit reached — automatically continuing the same task from its saved session.');
2913
+ continue;
2914
+ }
2875
2915
  if (!entryUrl) break;
2876
2916
  try {
2877
2917
  if (!verify) {
@@ -5224,6 +5264,13 @@ const server = createServer(async (req, res) => {
5224
5264
  if (!text?.trim()) return json(res, 400, { error: 'text required' });
5225
5265
  const worker = applyReplyWorker(goal, requested);
5226
5266
  if (!worker.ok) return json(res, 409, { error: worker.error });
5267
+ const requestedDir = parseRequestedWorkspaceDir(text);
5268
+ if (requestedDir) {
5269
+ const project = projects.find((p) => p.id === goal.projectId);
5270
+ if (!project) return json(res, 404, { error: 'project not found' });
5271
+ const rebound = rebindGoalWorkspace(goal, project, requestedDir);
5272
+ if (!rebound.ok) return json(res, 409, rebound);
5273
+ }
5227
5274
  // Reply picks a parked goal back up into the work queue. 'review' MUST be
5228
5275
  // here: a completed goal awaiting your approve/reject is 'review', and a
5229
5276
  // reply to it means "not done — keep working" (Masa 2026-07-24: replied to
@@ -5447,6 +5494,30 @@ const server = createServer(async (req, res) => {
5447
5494
  // 「聞いてるだけ」への返事 (ONE-CONVERSATION.md §1.3 answer)。
5448
5495
  //
5449
5496
  // 答えるのは**作業した AI 本人**=その agent の session を resume する。状況一覧しか
5497
+ // Resolve the app's `outcome:auto` contract before changing goal state. The
5498
+ // utility call is classification-only; the goal's own agent/session still
5499
+ // writes an answer or performs the requested change. If routing is unavailable,
5500
+ // return unsure so a customer chooses explicitly instead of accidentally
5501
+ // starting a ten-minute implementation worker for a question.
5502
+ async function judgeReplyIntent(goal, reply) {
5503
+ try {
5504
+ const currentProject = projects.find((project) => project.id === goal.projectId) ?? null;
5505
+ const r = await runUtility({
5506
+ prompt: buildReplyIntentPrompt({ goalText: goal.text, reply, currentProject, projects }),
5507
+ cwd: ROOT,
5508
+ tools: 'Read',
5509
+ });
5510
+ const parsed = parseReplyIntent(r.result);
5511
+ if (!parsed.read) return { outcome: 'unsure', targetProjectId: null };
5512
+ if (parsed.targetProjectId && !projects.some((project) => project.id === parsed.targetProjectId)) {
5513
+ return { outcome: 'unsure', targetProjectId: null };
5514
+ }
5515
+ return { outcome: parsed.outcome, targetProjectId: parsed.targetProjectId };
5516
+ } catch {
5517
+ return { outcome: 'unsure', targetProjectId: null };
5518
+ }
5519
+ }
5520
+
5450
5521
  // 知らない別AIでは「なぜそうしたか」に答えられない(Masa確定 2026-07-22)。
5451
5522
  // コードは1文字も変わらない: ツールは Read だけ、permissionMode は 'plan'
5452
5523
  // (Codex 側は read-only サンドボックスに落ちる)。タスクも作らないので看板も
@@ -5497,20 +5568,29 @@ async function answerGoalQuestion(goal, question) {
5497
5568
  let body = '';
5498
5569
  req.on('data', (d) => { body += d; });
5499
5570
  req.on('end', async () => {
5500
- let text = '', outcome = 'continue', requested = {};
5571
+ let text = '', outcome = 'continue', requested = {}, auto = false;
5501
5572
  try {
5502
5573
  requested = JSON.parse(body || '{}');
5503
5574
  text = String(requested.text ?? '').trim();
5504
5575
  if (REPLY_OUTCOMES.includes(requested.outcome)) outcome = requested.outcome;
5576
+ auto = requested.outcome === 'auto';
5505
5577
  } catch {}
5506
5578
  const worker = applyReplyWorker(goal, requested);
5507
5579
  if (!worker.ok) return json(res, 409, { error: worker.error });
5508
5580
 
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' });
5581
+ if (auto) {
5582
+ if (!text) return json(res, 400, { error: 'text required' });
5583
+ const routed = await judgeReplyIntent(goal, text);
5584
+ outcome = routed.outcome;
5585
+ requested.targetProjectId = routed.targetProjectId;
5586
+ if (outcome === 'unsure') return json(res, 200, {
5587
+ goal,
5588
+ outcome,
5589
+ choices: REPLY_OUTCOMES,
5590
+ projectChoices: projects.map((project) => ({ id: project.id, name: project.name, current: project.id === goal.projectId })),
5591
+ });
5592
+ if (requested.targetProjectId && requested.targetProjectId !== goal.projectId) outcome = 'split';
5593
+ }
5514
5594
  // 返事の行き先4種 (ONE-CONVERSATION.md §1.3). 既定は今までどおり continue なので、
5515
5595
  // outcome を送ってこない今の UI の挙動は1ミリも変わらない。
5516
5596
  const result = dismissGoal({ goalStatus: goal.status, outcome });
@@ -5523,8 +5603,23 @@ async function answerGoalQuestion(goal, question) {
5523
5603
  const gate = await requireEntitlementGate(identityFor(req));
5524
5604
  if (!gate.allowed) return json(res, 402, { error: 'free-tier limit reached', blocked: gate.blocked });
5525
5605
  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 });
5606
+ const targetProjectId = requested.targetProjectId || goal.projectId;
5607
+ if (!projects.some((project) => project.id === targetProjectId)) return json(res, 400, { error: 'unknown target project' });
5608
+ const crossProject = targetProjectId !== goal.projectId;
5609
+ const latest = [...tasks].reverse().find((task) => task.goalId === goal.id && task.result);
5610
+ const handoffText = crossProject ? [
5611
+ `[Cross-project implementation handoff from #${goal.id}]`,
5612
+ `Original request: ${goal.text}`,
5613
+ goal.reviewSummary?.summary ? `Reviewed result: ${goal.reviewSummary.summary}` : '',
5614
+ latest?.result ? `Latest worker result: ${String(latest.result).slice(0, 3000)}` : '',
5615
+ `User's implementation request: ${text}`,
5616
+ ].filter(Boolean).join('\n\n') : text;
5617
+ const born = createSplitGoal(goal, handoffText, identityFor(req), targetProjectId);
5618
+ if (crossProject) {
5619
+ const target = projects.find((project) => project.id === targetProjectId);
5620
+ addGoalMessage(goal, { from: 'manager', via: 'review', text: `Implementation was routed to ${target?.name || targetProjectId} (#${born.id}) instead of editing this project's review.` });
5621
+ }
5622
+ return json(res, 200, { goal, outcome, splitInto: born.id, born, targetProjectId });
5528
5623
  }
5529
5624
 
5530
5625
  // answer: 聞かれただけ。ゴールは review のまま、ワーカーも起こさない(§1.3)。
@@ -5735,6 +5830,13 @@ server.on('error', (e) => {
5735
5830
  });
5736
5831
 
5737
5832
  server.listen(PORT, '127.0.0.1', () => {
5833
+ // Persist the bounded recovery counter before pumping. If auth is offline
5834
+ // and the machine restarts again while the task is still queued, the ledger
5835
+ // must remember that one automatic recovery was already attempted.
5836
+ for (const task of tasks.filter((t) => t.recoveredFromRestart)) {
5837
+ delete task.recoveredFromRestart;
5838
+ saveTask(task);
5839
+ }
5738
5840
  PORT = server.address().port; // the OS-assigned one when MANAGER_PORT=0
5739
5841
  try { server6.listen(PORT, '::1'); } catch { /* IPv6 loopback unavailable */ }
5740
5842
  console.log(`[manager] Manager for AI → http://localhost:${PORT}/?key=${ACCESS_KEY}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.89",
3
+ "version": "0.10.91",
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": {