@galda/cli 0.10.117 → 0.10.118

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
@@ -1598,6 +1598,7 @@
1598
1598
  #fsRoot .cc-ask-lb{font-family:var(--mono);font-size:8.5px;letter-spacing:.11em;text-transform:uppercase;
1599
1599
  color:var(--blue);margin-bottom:6px}
1600
1600
  #fsRoot .cc-ask-q{font-size:12.5px;font-weight:500;color:var(--ink);line-height:1.35;margin-bottom:10px}
1601
+ #fsRoot .cc-ask-note{font-size:12px;color:var(--amber,#e0a03a);line-height:1.35;margin-bottom:6px}
1601
1602
  #fsRoot .cc-ask .qchips{margin:0}
1602
1603
  /* Free-text answer, always shown alongside chips (Masa 2026-07-08「最初から出そう」):
1603
1604
  input + the shared rainbow send ring. Restores 質問カードの自由記述回答欄. */
@@ -3530,10 +3531,13 @@ function captureFocusedAnswerInput(container, attr){
3530
3531
  if (!el || !container || !container.contains(el) || !el.hasAttribute(attr)) return null;
3531
3532
  return { id: el.getAttribute(attr), value: el.value, start: el.selectionStart, end: el.selectionEnd };
3532
3533
  }
3533
- function restoreFocusedAnswerInput(container, attr, saved){
3534
+ function restoreFocusedAnswerInput(container, attr, saved, shouldRestoreValue){
3534
3535
  if (!saved || !container) return;
3535
3536
  const el = container.querySelector(`[${attr}="${saved.id}"]`);
3536
3537
  if (!el) return;
3538
+ // Focus always comes back; the TEXT only when the caller still wants it there.
3539
+ const keep = typeof shouldRestoreValue === 'function' ? shouldRestoreValue(saved.id) : true;
3540
+ if (!keep) { el.value = ''; el.focus(); return; }
3537
3541
  el.value = saved.value;
3538
3542
  el.focus();
3539
3543
  try { el.setSelectionRange(saved.start, saved.end); } catch { /* input type may not support it */ }
@@ -6699,8 +6703,19 @@ $('gdSend').onclick = () => {
6699
6703
  const text = $('gdRevisionInput').value.trim();
6700
6704
  if (goalId == null || !text) return;
6701
6705
  const goal = state.goals.find((g) => g.id === goalId);
6702
- if (goal?.status === 'review') sendDismiss(goalId, text);
6703
- else sendGoalInstruction(goalId, text);
6706
+ if (goal?.status === 'review') { sendDismiss(goalId, text); return; }
6707
+ // A goal that is ASKING gets an ANSWER. This box says "Answer the question…" for
6708
+ // needsInput, and it used to post to /reply, where the server accepted it, returned 200
6709
+ // and changed nothing — the question stayed up and nobody was told (a first-time user
6710
+ // hit exactly this, 2026-07-31). /answer is the route that hands the words back to the
6711
+ // agent that asked.
6712
+ if (goal?.status === 'needsInput') {
6713
+ $('gdRevisionInput').value = '';
6714
+ closeGoalDetail();
6715
+ fsAnswerQuestion(goalId, text);
6716
+ return;
6717
+ }
6718
+ sendGoalInstruction(goalId, text);
6704
6719
  };
6705
6720
  // Feedback 8/9: Enter in the goal-detail revision box sends (like the button) —
6706
6721
  // which is what moves the goal Review→To Do for rework. Was button-only before.
@@ -6985,9 +7000,15 @@ function renderFsFeed(){
6985
7000
  // it deleted the GOAL, not the question, from an unlabelled card — and an
6986
7001
  // irreversible action does not belong beside a text box in a feed you are
6987
7002
  // skim-reading. Deleting still lives on the card and in the right lane.
7003
+ // The server marks an answer it could not use. Say it as its own line: it used to be
7004
+ // concatenated into the question text, so the card re-rendered looking identical and
7005
+ // read as "ignored" rather than "I could not use that" (2026-07-31).
7006
+ const notUsed = g.question.notUnderstood === true;
7007
+ const qText = notUsed ? String(g.question.text).replace(/^[^\n]*\n+/, '') : g.question.text;
6988
7008
  askHtml += `<div class="cc-ask"><div class="cc-ask-lb">Question`
6989
7009
  + `<span class="cc-ask-of">#${esc(String(g.id))}${title ? ` ${esc(title)}` : ''}</span></div>`
6990
- + `<div class="cc-ask-q">${esc(g.question.text)}</div>`
7010
+ + (notUsed ? `<div class="cc-ask-note">I could not use that answer — give me a full path.</div>` : '')
7011
+ + `<div class="cc-ask-q">${esc(qText)}</div>`
6991
7012
  + (chips ? `<div class="qchips">${chips}</div>` : '')
6992
7013
  + `<div class="answ-inline"><input type="text" data-fsansinput="${g.id}" placeholder="Type your answer…" aria-label="Type your answer">`
6993
7014
  + `<button type="button" class="sa-send" data-fsanssend="${g.id}" title="Send answer" aria-label="Send answer"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div></div>`;
@@ -7058,7 +7079,10 @@ function renderFsFeed(){
7058
7079
  const savedFsAns = captureFocusedAnswerInput(feed, 'data-fsansinput');
7059
7080
  feed.innerHTML = hasWork ? out : '';
7060
7081
  if (nearBottom) sc.scrollTop = sc.scrollHeight;
7061
- restoreFocusedAnswerInput(feed, 'data-fsansinput', savedFsAns);
7082
+ // Not restored when the server just rejected it: putting the same words back is what
7083
+ // made a rejected answer look like nothing had happened at all.
7084
+ restoreFocusedAnswerInput(feed, 'data-fsansinput', savedFsAns, (id) =>
7085
+ !(state.goals.find((g) => String(g.id) === String(id))?.question?.notUnderstood === true));
7062
7086
  window.__fsCineSync?.(); // Cinema dynamic rule: content growth may reach the band region
7063
7087
  for (const b of feed.querySelectorAll('[data-fsretry]')) b.onclick = () => fsRetryTask(b.dataset.fsretry);
7064
7088
  for (const b of feed.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
package/engine/server.mjs CHANGED
@@ -975,7 +975,15 @@ const workerOpenedWith = new Set();
975
975
  function sayFromWorker(goal, task, text) {
976
976
  if (!goal || !task || workerOpenedWith.has(task.id)) return;
977
977
  workerOpenedWith.add(task.id);
978
- addGoalMessage(goal, { from: 'ai', via: task.reply ? 'thread' : 'composer', text });
978
+ const msg = addGoalMessage(goal, { from: 'ai', via: task.reply ? 'thread' : 'composer', text });
979
+ if (!msg) return;
980
+ // Say it out loud, now. The conversation on a goal is read on demand (a board
981
+ // with 100 cards must not drag 100 threads along), so a message written to it
982
+ // is invisible until someone opens that card — and the whole point of this one
983
+ // is that the person is sitting in front of the composer waiting to hear
984
+ // something. The text rides on the event: no fetch, and nothing for the client
985
+ // to work out about which messages it has already shown.
986
+ sendGoalEvent({ ev: 'say', goalId: goal.id, taskId: task.id, agent: task.agent ?? goal.agent, text: msg.text, at: msg.at }, goal);
979
987
  }
980
988
 
981
989
  function readGoalMessages(goalId) {
@@ -5491,15 +5499,7 @@ const server = createServer(async (req, res) => {
5491
5499
  // A worker-authored question is a transport pause, not a new planning
5492
5500
  // cycle. Preserve the worker session and pass the user's answer straight
5493
5501
  // back to it; Galda must not reinterpret or rewrite the answer.
5494
- if (goal.question?.kind === 'worker') {
5495
- goal.question = null;
5496
- goal.blocked = null;
5497
- goal.status = 'running';
5498
- goal.startedAt = null;
5499
- saveGoal(goal);
5500
- const replyTask = queueReplyTask(goal, answer, { from: 'you', via: 'question' });
5501
- return json(res, 200, { ...goal, replyTaskId: replyTask.id });
5502
- }
5502
+ if (goal.question?.kind === 'worker') return json(res, 200, deliverWorkerAnswer(goal, answer));
5503
5503
  // Bind this goal to a project rooted at `dir` and start planning. Shared by
5504
5504
  // the "picked a repo" and "git-init'd a new folder" paths below.
5505
5505
  const bindAndPlan = (dir) => {
@@ -5559,6 +5559,11 @@ const server = createServer(async (req, res) => {
5559
5559
  goal.question = buildFolderQuestion(detectRepos(HOME), { lang });
5560
5560
  }
5561
5561
  goal.question.text = notUnderstoodNote(lang) + goal.question.text;
5562
+ // Machine-readable, because the note above is concatenated into the question text
5563
+ // and the card re-renders looking identical — which reads as "ignored", not as
5564
+ // "I could not use that" (measured 2026-07-31). The app clears the box and shows
5565
+ // this as its own line instead of relying on someone re-reading the paragraph.
5566
+ goal.question.notUnderstood = true;
5562
5567
  saveGoal(goal);
5563
5568
  return json(res, 200, goal);
5564
5569
  }
@@ -5654,12 +5659,47 @@ const server = createServer(async (req, res) => {
5654
5659
  return;
5655
5660
  }
5656
5661
 
5662
+ // Hand a person's answer back to the worker that asked the question, unchanged. The
5663
+ // worker's session is preserved, so it can answer, ask again, or carry on — which is the
5664
+ // whole point: the question layer is Claude Code's / Codex's, not Galda's (Masa
5665
+ // 2026-07-31). Shared by /answer and /reply so that WHICH box the person typed into
5666
+ // cannot change whether their words arrive.
5667
+ function deliverWorkerAnswer(goal, answer) {
5668
+ goal.question = null;
5669
+ goal.blocked = null;
5670
+ goal.status = 'running';
5671
+ goal.startedAt = null;
5672
+ saveGoal(goal);
5673
+ const replyTask = queueReplyTask(goal, answer, { from: 'you', via: 'question' });
5674
+ return { ...goal, replyTaskId: replyTask.id };
5675
+ }
5657
5676
  const replyMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/reply$/);
5658
5677
  if (replyMatch && req.method === 'POST') {
5659
5678
  const goal = goals.find((g) => g.id === Number(replyMatch[1]));
5660
5679
  if (!goal) return json(res, 404, { error: 'goal not found' });
5661
5680
  if (!requireGoalOwnership(req, res, goal)) return;
5662
5681
  if (goal.status === 'needsApproval') return json(res, 409, { error: 'goal is awaiting an approval decision — use /approval' });
5682
+ // A goal that is ASKING something used to accept a reply here, return 200, and change
5683
+ // nothing: the question stayed on screen, the goal stayed in needsInput, and the person
5684
+ // was told nothing (measured 2026-07-31 — a first-time user typed into a box labelled
5685
+ // "Answer the question…" and it silently went nowhere). Two honest outcomes instead:
5686
+ // • the worker asked → deliver it, exactly as /answer would. Which box someone typed
5687
+ // into must not decide whether their words reach the agent.
5688
+ // • Galda's own card → say so, so the UI can fail loudly instead of pretending.
5689
+ if (goal.status === 'needsInput') {
5690
+ if (goal.question?.kind === 'worker') {
5691
+ let body = '';
5692
+ req.on('data', (d) => { body += d; });
5693
+ req.on('end', () => {
5694
+ let text = '';
5695
+ try { text = String(JSON.parse(body || '{}').text ?? '').trim(); } catch {}
5696
+ if (!text) return json(res, 400, { error: 'text required' });
5697
+ return json(res, 200, deliverWorkerAnswer(goal, text));
5698
+ });
5699
+ return;
5700
+ }
5701
+ return json(res, 409, { error: 'goal is waiting on an answer — use /answer' });
5702
+ }
5663
5703
  if (['stacked', 'planning'].includes(goal.status)) return json(res, 409, { error: 'goal not started yet — edit it instead' });
5664
5704
  let body = '';
5665
5705
  req.on('data', (d) => { body += d; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.117",
3
+ "version": "0.10.118",
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": {