@galda/cli 0.10.111 → 0.10.112

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
@@ -6431,6 +6431,38 @@ function prefersJapaneseText(...values){
6431
6431
  return values.some((v) => /[\u3040-\u30ff\u3400-\u9fff]/.test(String(v || '')));
6432
6432
  }
6433
6433
  function summarizeAttention(goal, tasks){
6434
+ // A pending question / approval request is the one case where we already HAVE the
6435
+ // worker's own words (engine writes goal.question = {kind,text,options} and
6436
+ // goal.approvalRequest). Show them verbatim. The board's question card already does
6437
+ // this (.qq at the question-card render), and the two paths must not disagree about
6438
+ // the same field — the template built below by pattern-matching the log is a last
6439
+ // resort for goals that stopped without asking anything (PRD §0.1.1-5).
6440
+ if (goal.status === 'needsInput' && goal.question?.text) {
6441
+ const opts = (goal.question.options || []).map((o) => String(o).trim()).filter(Boolean);
6442
+ const jq = prefersJapaneseText(goal.question.text, goal.text);
6443
+ return {
6444
+ label: jq ? '質問' : 'Question',
6445
+ happened: String(goal.question.text).trim(),
6446
+ decision: opts.length
6447
+ ? (jq ? `次のどれかで答えてください: ${opts.join(' / ')}` : `Answer with one of: ${opts.join(' / ')}`)
6448
+ : (jq ? '下の入力欄に答えを書いて送ってください。' : 'Type your answer in the box below and send it.'),
6449
+ };
6450
+ }
6451
+ if (goal.status === 'needsApproval') {
6452
+ const a = goal.approvalRequest || {};
6453
+ const ask = String(a.question || a.action || '').trim();
6454
+ if (ask) {
6455
+ const scope = (a.scope || []).map((s) => String(s).trim()).filter(Boolean);
6456
+ const jq = prefersJapaneseText(ask, goal.text);
6457
+ return {
6458
+ label: jq ? '許可の依頼' : 'Approval requested',
6459
+ happened: scope.length ? `${ask}\n\n${jq ? '対象' : 'Scope'}: ${scope.join(' · ')}` : ask,
6460
+ decision: jq
6461
+ ? 'Proceed で許可、Cancel で断ります。範囲を変えたい場合は Edit scope。'
6462
+ : 'Proceed to allow it, Cancel to refuse. Use Edit scope to narrow what it may touch.',
6463
+ };
6464
+ }
6465
+ }
6434
6466
  const fm = goal.failureMemory?.[goal.failureMemory.length - 1];
6435
6467
  if (fm?.whatHappened || fm?.yourCall) {
6436
6468
  return {
@@ -6483,14 +6515,14 @@ function summarizeAttention(goal, tasks){
6483
6515
  }
6484
6516
  return { happened: happened.replace(/\.+$/, '.'), decision };
6485
6517
  }
6486
- function renderGoalBrief({ goal, tasks, isReview, isAttention }){
6518
+ function renderGoalBrief({ goal, tasks, isReview, isAttention, isAsking }){
6487
6519
  const brief = $('gdBrief');
6488
- if (!isReview && !isAttention) { brief.classList.remove('show'); brief.innerHTML = ''; return; }
6520
+ if (!isReview && !isAttention && !isAsking) { brief.classList.remove('show'); brief.innerHTML = ''; return; }
6489
6521
  let rows;
6490
- if (isAttention) {
6522
+ if (isAttention || isAsking) {
6491
6523
  const s = summarizeAttention(goal, tasks);
6492
6524
  rows = [
6493
- ['What happened', s.happened],
6525
+ [s.label || 'What happened', s.happened],
6494
6526
  ['Your call', s.decision],
6495
6527
  ];
6496
6528
  } else {
@@ -6513,9 +6545,17 @@ function renderGoalDetail(){
6513
6545
  const isReview = goal.status === 'review';
6514
6546
  const isAttention = ['blocked', 'partial', 'failed', 'interrupted'].includes(goal.status)
6515
6547
  || tasks.some((t) => ['failed', 'interrupted'].includes(t.status));
6516
- $('gdTitle').textContent = isReview ? 'Review' : 'Attention';
6548
+ // A goal waiting on an answer/approval is not "stuck" — it is asking. It used to
6549
+ // fall through both flags, so the brief was hidden entirely and the panel showed the
6550
+ // original request with no sign of what the worker actually asked (Masa 2026-07-30).
6551
+ // Kept separate from isAttention so the Discard button's condition does not change.
6552
+ const isAsking = ['needsInput', 'needsApproval'].includes(goal.status);
6553
+ $('gdTitle').textContent = isReview ? 'Review'
6554
+ : goal.status === 'needsInput' ? 'Question'
6555
+ : goal.status === 'needsApproval' ? 'Approval'
6556
+ : 'Attention';
6517
6557
  $('gdRequirement').textContent = goal.text ?? '';
6518
- renderGoalBrief({ goal, tasks, isReview, isAttention });
6558
+ renderGoalBrief({ goal, tasks, isReview, isAttention, isAsking });
6519
6559
  // review-only items carry no worker tasks — show the author's note (or a
6520
6560
  // plain hint) instead of "No worker reports yet.", and any attached images
6521
6561
  // as the thing to look at.
@@ -6544,10 +6584,22 @@ function renderGoalDetail(){
6544
6584
  $('gdLog').innerHTML = logLines.length
6545
6585
  ? `<div class="gdlog">${logLines.map((s) => `<div class="gdlogline">${esc(s)}</div>`).join('')}</div>`
6546
6586
  : '<div class="gdempty">No log captured yet.</div>';
6587
+ // A goal that is only ASKING has not produced anything yet, so "No worker reports
6588
+ // yet." / "No proof available yet." / "No log captured yet." are three empty boxes
6589
+ // that push the actual question down and out (Masa 2026-07-30: "高さが少なくてみづらい").
6590
+ // §0.1.1-5: do not show the container for something that does not exist. Scoped to the
6591
+ // asking states so Review — where "no proof" is itself a finding — is unchanged.
6592
+ const hideIfEmpty = isAsking && !tasks.length;
6593
+ for (const [id, empty] of [['gdWhatWasDone', !tasks.length && !goal.note], ['gdProof', !proofTasks.length && !reviewImgs.length], ['gdLog', !logLines.length]]) {
6594
+ const sec = $(id).closest('.gdsec');
6595
+ if (sec) sec.style.display = hideIfEmpty && empty ? 'none' : '';
6596
+ }
6547
6597
  $('gdApprove').style.display = isReview ? '' : 'none';
6548
6598
  $('gdDismiss').style.display = isReview ? '' : 'none';
6549
6599
  $('gdDiscard').style.display = isAttention ? '' : 'none';
6550
- $('gdRevisionInput').placeholder = isReview ? 'Write revision instructions…' : 'Tell the worker what to try next…';
6600
+ $('gdRevisionInput').placeholder = isReview ? 'Write revision instructions…'
6601
+ : goal.status === 'needsInput' ? 'Answer the question…'
6602
+ : 'Tell the worker what to try next…';
6551
6603
  $('gdSend').title = isReview ? 'Send back' : 'Send';
6552
6604
  }
6553
6605
  $('gdClose').onclick = closeGoalDetail;
@@ -9559,7 +9611,12 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
9559
9611
  // 4 archived / 5 reverted — session-local; the server round-trip is what
9560
9612
  // settles it (see saSync's "returned to review" reset).
9561
9613
  // ============================================================================
9562
- const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, msgCounts: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), diffOpen: new Set(), attachments: {}, focusGid: null, focusScrolled: false, focusTimer: null, cur: null, solo: null, lastMarkup: null };
9614
+ // `draft` holds the half-typed reply per goal. It exists because the text used to live
9615
+ // ONLY in the DOM, so the sole way to keep it was for saSync() to refuse to run at all
9616
+ // while the box had text — which froze the To Do / Doing / Review status updates behind
9617
+ // the dialog too. With the text in SA it survives a rebuild on its own, and saSync no
9618
+ // longer has to stop (2026-07-30).
9619
+ const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, msgCounts: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), diffOpen: new Set(), attachments: {}, draft: {}, focusGid: null, focusScrolled: false, focusTimer: null, cur: null, solo: null, lastMarkup: null };
9563
9620
  // Same convention as the existing ?dev=1 devbar flag — off unless someone opts in by URL.
9564
9621
  const SA_DEV = new URLSearchParams(location.search).get('dev') === '1';
9565
9622
  // task-971: 白黒(モノクロ)版のレールを ?railmono=1 で見比べられるようにする。
@@ -9837,7 +9894,10 @@ function saLedgerRows(){
9837
9894
  // (summarizeAttention), mapped into the review card instead of a bespoke "What happened /
9838
9895
  // Your call" layout (Masa 2026-07-24). The Request section already shows the ask, so drop
9839
9896
  // the summary's "必要だったこと:/Needed:" lead to avoid repeating it.
9840
- if (['blocked', 'partial', 'failed', 'interrupted'].includes(g.status)) {
9897
+ // needsInput/needsApproval are included so this path shows the worker's verbatim
9898
+ // question too — summarizeAttention returns it as-is for those. Both renders of the
9899
+ // same goal.question must say the same thing (PRD §0.1.1-5).
9900
+ if (['blocked', 'partial', 'failed', 'interrupted', 'needsInput', 'needsApproval'].includes(g.status)) {
9841
9901
  const s = summarizeAttention(g, ts.filter((t) => t.goalId === g.id));
9842
9902
  const why = String(s.happened || '').replace(/^\s*(必要だったこと:|Needed:)[^。.]*[。.]\s*/, '').trim();
9843
9903
  const merged = [why, s.decision].filter(Boolean).join('\n\n');
@@ -10547,7 +10607,7 @@ function saItemHtml(it, i){
10547
10607
  ${conversationSec}
10548
10608
  ${activitySec}
10549
10609
  ${S('Workflow', saWorkflowHtml(it))}
10550
- <div class="sa-act">${attn ? '' : `<div class="sa-approve-wrap"><button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button>${delivery}</div><button class="sa-dis" data-saact="dis:${i}">Dismiss</button>`}<div class="sa-replybox"><div class="sa-replyattach${SA.attachments[it.goalId]?.length ? ' has' : ''}" data-saattach="${it.goalId}">${saReplyAttachmentsHtml(it.goalId)}</div><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" data-goal="${it.goalId}" placeholder="Reply, ask, or paste an image…"><button class="sa-send" data-saact="send:${i}" title="Send"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
10610
+ <div class="sa-act">${attn ? '' : `<div class="sa-approve-wrap"><button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button>${delivery}</div><button class="sa-dis" data-saact="dis:${i}">Dismiss</button>`}<div class="sa-replybox"><div class="sa-replyattach${SA.attachments[it.goalId]?.length ? ' has' : ''}" data-saattach="${it.goalId}">${saReplyAttachmentsHtml(it.goalId)}</div><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" data-goal="${it.goalId}" placeholder="Reply, ask, or paste an image…" value="${esc(SA.draft[it.goalId] ?? '')}"><button class="sa-send" data-saact="send:${i}" title="Send"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
10551
10611
  </div>`;
10552
10612
  }
10553
10613
  // Scroll the See-all body so a card's top edge (.rhead) sits at the top of the visible
@@ -10695,9 +10755,12 @@ function saRender(){
10695
10755
  }) || cards.find((el) => el.getBoundingClientRect().bottom > bodyRect.top);
10696
10756
  if (anchor) { anchorGid = anchor.dataset.gid; anchorOffset = anchor.getBoundingClientRect().top - bodyRect.top; }
10697
10757
  }
10698
- const focusedReplyGoal = document.activeElement?.matches?.('#seeall .sa-chat')
10699
- ? document.activeElement.dataset.goal
10700
- : null;
10758
+ // Remember WHERE the caret was too, not just which box had focus: the text now comes
10759
+ // back from SA.draft, and putting it back without the caret would jump the cursor to
10760
+ // the end mid-word on every SSE tick.
10761
+ const activeChat = document.activeElement?.matches?.('#seeall .sa-chat') ? document.activeElement : null;
10762
+ const focusedReplyGoal = activeChat?.dataset.goal ?? null;
10763
+ const focusedCaret = activeChat ? { start: activeChat.selectionStart, end: activeChat.selectionEnd } : null;
10701
10764
  const pending = SA.items.filter((x) => x.st === 0);
10702
10765
  const green = pending.filter((x) => !x.testResult || x.testResult.ok !== false).length;
10703
10766
  // Solo mode = a single "Needs you" goal opened as this card. It is not a review batch, so the
@@ -10828,10 +10891,24 @@ function saRender(){
10828
10891
  if (!SA.focusTimer) SA.focusTimer = setTimeout(() => { SA.focusGid = null; SA.focusTimer = null; }, 1400);
10829
10892
  });
10830
10893
  }
10831
- // A relevant status change may legitimately rebuild the card. Restore the
10832
- // empty reply box's focus afterwards; non-empty input is protected earlier
10833
- // by saSync and is never rebuilt at all.
10834
- if (focusedReplyGoal != null) S.querySelector(`.sa-chat[data-goal="${focusedReplyGoal}"]`)?.focus({ preventScroll: true });
10894
+ // Every reply box carries its draft back from SA.draft via the value attribute, so a
10895
+ // rebuild no longer loses what was typed. Keep SA.draft current as the person types —
10896
+ // this is what lets saSync() run during typing instead of freezing.
10897
+ for (const inp of S.querySelectorAll('.sa-chat[data-goal]')) {
10898
+ inp.addEventListener('input', () => {
10899
+ const gid = inp.dataset.goal;
10900
+ if (inp.value) SA.draft[gid] = inp.value; else delete SA.draft[gid];
10901
+ });
10902
+ }
10903
+ // A relevant status change may legitimately rebuild the card. Put focus AND the caret
10904
+ // back where they were.
10905
+ if (focusedReplyGoal != null) {
10906
+ const inp = S.querySelector(`.sa-chat[data-goal="${focusedReplyGoal}"]`);
10907
+ if (inp) {
10908
+ inp.focus({ preventScroll: true });
10909
+ if (focusedCaret) { try { inp.setSelectionRange(focusedCaret.start, focusedCaret.end); } catch { /* not selectable */ } }
10910
+ }
10911
+ }
10835
10912
  }
10836
10913
  // keep the open Ledger in sync with server state (SSE/refresh → render() → here).
10837
10914
  // An item whose goal has RETURNED to 'review' (retest ×3 green, rework done,
@@ -10839,10 +10916,11 @@ function saRender(){
10839
10916
  // blocks the rebuild (same "vanishes mid-type" guard as renderFsLane).
10840
10917
  function saSync(){
10841
10918
  if (!SA.open) return;
10842
- const ae = document.activeElement;
10843
- // Do not erase text while the reviewer is typing. Once Send clears the box,
10844
- // focus alone must not freeze To Do / Doing / Review status updates.
10845
- if (ae && $('seeall').contains(ae) && ae.tagName === 'INPUT' && ae.value) return;
10919
+ // No typing guard here any more. It used to `return` whenever the focused input had
10920
+ // text, which protected the draft by freezing EVERYTHING status changes behind the
10921
+ // dialog included, so a card could sit on "Working on it" for as long as someone was
10922
+ // composing a reply. The draft now lives in SA.draft and is restored with its caret by
10923
+ // saRender, so the sync is free to run (2026-07-30).
10846
10924
  // Working cards are intentionally absent from the Review ledger rows after the
10847
10925
  // server moves them to To Do / Doing. Keep their held dialog snapshot wired to
10848
10926
  // live goal/task state so the status changes in place instead of disappearing.
@@ -10878,11 +10956,15 @@ async function saFetchDiffs(){
10878
10956
  if (SA.open) saSync();
10879
10957
  }
10880
10958
  function saOpen(focusGoalId, solo = false){
10959
+ const wasOpen = SA.open;
10881
10960
  SA.open = true;
10882
10961
  SA.lastMarkup = null; // a newly opened/focused dialog always gets a clean first paint
10883
10962
  SA.solo = solo ? focusGoalId : null; // solo = a single "Needs you" goal opened as one card
10884
10963
  SA.items = saLedgerRows().map((r) => ({ ...r, st: 0 }));
10885
- SA.detOpen = new Set();
10964
+ // Only a FRESH open starts with everything folded. saOpen also runs when the person taps
10965
+ // a different card while the dialog is already up, and clearing the set there closed the
10966
+ // Activity / Conversation folds they had just opened on the other cards.
10967
+ if (!wasOpen) SA.detOpen = new Set();
10886
10968
  // H14: focus a specific goal's card (single-review tap). Map the tapped goal
10887
10969
  // to the card that CONTAINS it (goals can group in the digest), then let
10888
10970
  // saRender center it + flash a blue ring. Real data keys by goal id.
@@ -11015,8 +11097,9 @@ async function saSendReply(it, i, text){
11015
11097
  text = displayText;
11016
11098
  await saEnsureThread(it); // fold in any existing history BEFORE this reply (so it isn't hidden)
11017
11099
  const seq = saNextSeq(); // reserve send order NOW, so a late-resolving route keeps its place
11018
- // Clear the box in place — keep it FOCUSED, not blurred (saSync skips a rebuild while an
11019
- // input in #seeall has focus, which is what keeps an SSE tick from flashing the panel).
11100
+ // Clear the box in place — keep it FOCUSED, not blurred. The draft must be dropped from
11101
+ // SA.draft as well, or the next rebuild would re-populate the box from the sent text.
11102
+ delete SA.draft[goalId];
11020
11103
  document.querySelectorAll(`#seeall .sa-item[data-gid="${goalId}"] .sa-chat`).forEach((inp) => { inp.value = ''; });
11021
11104
  delete SA.attachments[goalId]; saPaintAttachments(goalId);
11022
11105
  // Append the line to the card's own thread immediately — this is what the person sees, and
package/engine/lib.mjs CHANGED
@@ -1396,6 +1396,17 @@ export function shouldAutoScroll(scrollTop, scrollHeight, clientHeight, threshol
1396
1396
  return scrollHeight - clientHeight - scrollTop <= threshold;
1397
1397
  }
1398
1398
 
1399
+ // Should an Enter keydown submit an input (send chat / save / answer)? Only when
1400
+ // the user is NOT mid-IME-composition. Pressing Enter to CONFIRM a Japanese
1401
+ // conversion candidate must commit the text, not send the message. Browsers flag
1402
+ // this via `isComposing` (and legacy `keyCode === 229`); every submit-on-Enter
1403
+ // handler in app/index.html shares this guard so IME confirm never mis-sends.
1404
+ export function shouldSubmitOnEnter(event = {}) {
1405
+ if (event.key !== 'Enter') return false;
1406
+ if (event.isComposing || event.keyCode === 229) return false;
1407
+ return true;
1408
+ }
1409
+
1399
1410
  // Decide the scrollTop to apply *after* a re-render replaces #stream's HTML
1400
1411
  // (task 93). `pre` is the element's geometry captured BEFORE the innerHTML
1401
1412
  // swap; `newScrollHeight` is its height AFTER. If the user was near the bottom
package/engine/server.mjs CHANGED
@@ -3032,7 +3032,6 @@ async function runTask(task) {
3032
3032
  task.changedFiles = afterR.filter((l) => !before.includes(l)).map((l) => l.slice(3));
3033
3033
  task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
3034
3034
  notePreviewScope(task);
3035
- task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
3036
3035
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this follow-up.`);
3037
3036
  // An explicit worker receipt takes precedence over Galda's legacy prose
3038
3037
  // guard. The agent owns this decision; Manager only transports it.
@@ -3258,7 +3257,6 @@ async function runTask(task) {
3258
3257
  task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
3259
3258
  task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
3260
3259
  notePreviewScope(task);
3261
- task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
3262
3260
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
3263
3261
  // An explicit worker receipt takes precedence over Galda's legacy prose
3264
3262
  // guard. The agent owns this decision; Manager only transports it.
@@ -3679,6 +3677,12 @@ function goalChangedFileUnion(goalId) {
3679
3677
  }
3680
3678
  function computeWorktreeChangeLocations(dir) {
3681
3679
  try {
3680
+ // `git diff` alone never reports untracked files, so goals that only add
3681
+ // new files would get changeLocations=[] and could never match by
3682
+ // position for auto-fold. `add -N` (intent-to-add) registers the paths
3683
+ // without staging their content, which is enough for them to show up as
3684
+ // normal diff hunks.
3685
+ gitOut(dir, ['add', '-N', '-A']);
3682
3686
  return parseGitUnifiedDiffLocations(gitOut(dir, ['diff', '--unified=0']) ?? '');
3683
3687
  } catch {
3684
3688
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.111",
3
+ "version": "0.10.112",
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": {