@galda/cli 0.10.118 → 0.10.119

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
@@ -328,6 +328,11 @@
328
328
  hairline-separated from the detected list, matching .appopt grammar. */
329
329
  .ctxmenu .appopt.ctxopen{flex-direction:row;align-items:center;gap:7px;border-top:1px solid var(--hair);margin-top:4px;padding-top:8px}
330
330
  .ctxmenu .appopt.ctxopen svg{width:14px;height:14px;flex:0 0 14px;stroke:currentColor;stroke-width:1.7;fill:none;stroke-linecap:round;stroke-linejoin:round}
331
+ /* Working: the folder icon becomes a spinner in its own place, so the row
332
+ itself says a chooser is opening rather than only the line of text below it.
333
+ Reuses @keyframes spin — no new motion vocabulary (DESIGN-RULES §5.5). */
334
+ .ctxmenu .appopt.ctxopen .ctxspin{width:12px;height:12px;flex:0 0 14px;margin:1px;border-radius:50%;
335
+ border:1.5px solid var(--ink3);border-top-color:transparent;animation:spin 1s linear infinite}
331
336
  .ctxmenu .appopt.ctxopen .nm{color:var(--ink);font-size:12.5px;line-height:1.3}
332
337
  .appopt{display:flex;align-items:center;gap:7px;height:28px;padding:0 8px;border:0;border-radius:6px;background:transparent;
333
338
  color:var(--ink2);font:inherit;font-size:12px;cursor:pointer;text-align:left;width:100%}
@@ -529,15 +534,18 @@
529
534
  .badge.upnext{color:var(--invink);background:rgba(var(--tint),.75);border:0;font-weight:700}
530
535
  .retrybtn{margin-left:6px;padding:1.5px 9px;border:1px solid var(--hair2);border-radius:6px;background:transparent;color:var(--ink);font-size:10.5px;cursor:pointer}
531
536
  .retrybtn.skip{color:var(--ink3)} /* Dismiss reads as the secondary/clear action next to Retry */
532
- /* Queue priority badge (To do rows only) — click opens a 高/中/低 popup.
533
- No new hues: borrows the existing --amber attention accent, 中/低 stay
534
- on the neutral ink scale, so priority never reads as another rainbow. */
537
+ /* Queue priority badge (To do rows only) — click opens a High/Medium/Low popup.
538
+ No new hues: high borrows the existing --amber attention accent, medium/low
539
+ stay on the neutral ink scale, so priority never reads as another rainbow.
540
+ One mono glyph in a 22px square, unchanged since it held 高: the square is
541
+ the shape, H/M/L is what fits in it, and the whole word is in the menu where
542
+ there is room for it. */
535
543
  .priosel{position:relative;flex:0 0 auto;align-self:flex-start;margin-top:1px}
536
544
  .priobtn{width:22px;height:20px;border:1px solid var(--hair2);border-radius:6px;background:transparent;
537
545
  color:var(--ink3);font-family:var(--mono);font-size:10.5px;cursor:pointer;display:grid;place-items:center}
538
546
  .priobtn:hover{color:var(--ink);border-color:rgba(var(--tint),.2)}
539
- .priobtn.prio-高{color:var(--amber);border-color:rgba(var(--amber-rgb),.4)}
540
- .priobtn.prio-中{color:var(--ink2)}
547
+ .priobtn.prio-high{color:var(--amber);border-color:rgba(var(--amber-rgb),.4)}
548
+ .priobtn.prio-medium{color:var(--ink2)}
541
549
  .priomenu{position:absolute;top:calc(100% + 4px);right:0;min-width:64px;background:var(--surface);border:1px solid var(--hair2);
542
550
  border-radius:9px;padding:4px;display:none;flex-direction:column;gap:1px;box-shadow:0 10px 28px rgba(0,0,0,.5);z-index:50}
543
551
  .priomenu.show{display:flex}
@@ -2923,13 +2931,31 @@
2923
2931
  /* ---- iOS no-zoom: 16px inputs (extends the shared 16px rule to flagship-only fields) ---- */
2924
2932
  body[data-layout="flagship"] .sa-chat,
2925
2933
  body[data-layout="flagship"] #fsAskbar input{font-size:16px}
2926
- /* ---- Ledger (#seeall) — full-bleed sheet + no-clip action row ---- */
2934
+ /* ---- Ledger (#seeall) — Claude Code-style mobile reading sheet.
2935
+ A tapped Review should land at the top of the phone viewport, with the
2936
+ content column using the width instead of spending it on desktop gutters. ---- */
2937
+ body[data-layout="flagship"] #seeall.open{place-items:start center;z-index:120;pointer-events:auto}
2927
2938
  body[data-layout="flagship"] #seeall .sa-panel{width:100%!important;max-width:100%!important;
2928
- margin-top:0;max-height:100%;border-radius:0}
2929
- body[data-layout="flagship"] #seeall .sa-hd{padding:14px 16px 10px}
2939
+ margin-top:calc(env(safe-area-inset-top) + 6px)!important;
2940
+ max-height:calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 12px)!important;
2941
+ border-radius:18px 18px 0 0!important}
2942
+ body[data-layout="flagship"] #seeall .sa-hd{padding:14px 18px 10px!important}
2930
2943
  body[data-layout="flagship"] #seeall .sa-hd .keys{display:none} /* keyboard shortcuts — no keyboard on a phone */
2931
- body[data-layout="flagship"] #seeall .sa-body{padding:12px 14px 16px}
2944
+ body[data-layout="flagship"] #seeall .sa-body{padding:10px 8px 16px}
2932
2945
  body[data-layout="flagship"] #seeall .sa-item{width:100%}
2946
+ body[data-layout="flagship"] #seeall .sa-item.redesign{padding:22px 18px 28px!important}
2947
+ body[data-layout="flagship"] #seeall .sa-body>.sa-item.redesign+.sa-item.redesign{padding-top:30px!important}
2948
+ body[data-layout="flagship"] #seeall .sa-panel .sa-hd:has(~ .sa-body .sa-item.redesign){padding:14px 18px 10px!important}
2949
+ body[data-layout="flagship"] #seeall .revrail.show{width:9px;margin-right:7px}
2950
+ body[data-layout="flagship"] #seeall .revrail button{font-size:0;padding:6px 0}
2951
+ body[data-layout="flagship"] #seeall .revrail button .bar{width:3px;height:18px}
2952
+ body[data-layout="flagship"] #seeall .revrail button.on .bar{height:28px}
2953
+ body[data-layout="flagship"] #seeall .redesign .rtitle{font-size:17px;line-height:1.32;letter-spacing:0}
2954
+ body[data-layout="flagship"] #seeall .redesign .rsec{margin-top:22px!important}
2955
+ body[data-layout="flagship"] #seeall .redesign .rsec-h{font-size:15px;letter-spacing:0}
2956
+ body[data-layout="flagship"] #seeall .redesign .rbody,
2957
+ body[data-layout="flagship"] #seeall .redesign .rreport{font-size:15px;line-height:1.72;letter-spacing:0;max-width:none}
2958
+ body[data-layout="flagship"] #seeall .redesign .rreport code{font-size:13px}
2933
2959
  body[data-layout="flagship"] #seeall .sa-act{flex-wrap:wrap;row-gap:8px}
2934
2960
  body[data-layout="flagship"] #seeall .sa-chatwrap{flex:1 1 100%;order:3;min-width:0}
2935
2961
  body[data-layout="flagship"] #seeall .sa-app,
@@ -2963,8 +2989,15 @@
2963
2989
  max-width:62%;justify-content:center;margin:0}
2964
2990
  /* Review/To Do opener (right) = plain ☰ icon (Masa 2026-07-18: ボタン化はやめ、三本線だけ・
2965
2991
  タップで To Do/Review ドロワー). The base .fshamb rule above already renders the ☰ svg. */
2966
- body[data-layout="flagship"] #fsRoot .fshamb .fshamb-lb,
2967
- body[data-layout="flagship"] #fsRoot .fshamb .fshamb-n{display:none}
2992
+ body[data-layout="flagship"] #fsRoot .fshamb .fshamb-lb{display:none}
2993
+ /* Masa 2026-07-31: たまったレビューが右上から分からない ☰ の右肩に赤ドット。
2994
+ 件数はJS(renderFsReview)がfshamb-nに書き込み済みだったが display:none で常に非表示だった。
2995
+ 件数の有無だけをJSからのtext contentで判定して出し分ける(空なら消える)。 */
2996
+ body[data-layout="flagship"] #fsRoot .fshamb{position:relative}
2997
+ body[data-layout="flagship"] #fsRoot .fshamb .fshamb-n{display:none;position:absolute;top:2px;right:2px;
2998
+ min-width:9px;height:9px;padding:0;border-radius:50%;background:var(--danger);
2999
+ box-shadow:0 0 0 2px var(--panel);font-size:0;line-height:0;color:transparent}
3000
+ body[data-layout="flagship"] #fsRoot .fshamb .fshamb-n:not(:empty){display:block}
2968
3001
  /* Top bar = chromeless on mobile for ALL themes (Masa 2026-07-18: Midnight で上に変なフレーム
2969
3002
  が出ていた → Gradient と同じく枠/面なしに). Gradient already did this; extend to every theme. */
2970
3003
  body[data-layout="flagship"] #fsRoot .fsrail{background:transparent;box-shadow:none}
@@ -2984,9 +3017,10 @@
2984
3017
  margin:0 0 4px;padding:0 2px 10px;gap:9px;border-bottom:1px solid var(--hair)}
2985
3018
  /* H25③: See-all ledger → scrim + inset panel (a full-bleed sheet let translucent themes show through). */
2986
3019
  body[data-layout="flagship"] #seeall{background:rgba(9,12,20,.55)}
3020
+ body[data-layout="flagship"] #seeall.open{z-index:120}
2987
3021
  body[data-layout="flagship"] #seeall .sa-panel{width:96%!important;max-width:96%!important;
2988
3022
  max-height:calc(100dvh - env(safe-area-inset-top) - 22px)!important;
2989
- margin-top:calc(env(safe-area-inset-top) + 10px)!important;border-radius:16px!important}
3023
+ margin-top:calc(env(safe-area-inset-top) + 6px)!important;border-radius:18px!important}
2990
3024
  }
2991
3025
  /* Billing UI (H16/H22) — self-serve plan. Tokens only; red is a line/label,
2992
3026
  never a fill (DESIGN-RULES §5.6), and it is --danger (the app's real token —
@@ -3710,18 +3744,34 @@ function pickColumnLabel(columns, bucket, fallback){
3710
3744
  }
3711
3745
  function columnsFor(projectId){ return state.workflowColumns[projectId] ?? DEFAULT_WORKFLOW_COLUMNS; }
3712
3746
 
3713
- // Mirrors engine/lib.mjs TASK_PRIORITIES — kept in sync manually, see the
3714
- // comment on DEFAULT_WORKFLOW_COLUMNS above. Queue execution order is
3715
- // 高→中→低, same-priority ties keep creation/drag order (server.mjs's
3716
- // sortQueueByPriority) — this badge is only for To do (queued) rows, where
3717
- // that ordering actually matters.
3718
- const TASK_PRIORITIES = ['高', '中', '低'];
3747
+ // Mirrors engine/lib.mjs TASK_PRIORITIES / normalizePriority — kept in sync
3748
+ // manually, see the comment on DEFAULT_WORKFLOW_COLUMNS above. Queue execution
3749
+ // order is high→medium→low, same-priority ties keep creation/drag order
3750
+ // (server.mjs's sortQueueByPriority) — this badge is only for To do (queued)
3751
+ // rows, where that ordering actually matters.
3752
+ //
3753
+ // The values were 高/中/低 until 2026-07-31. Nothing migrates what is already on
3754
+ // disk, so every task written before then still says 高 and must still render as
3755
+ // what it is: reading only the new list would have shown it as Medium — the board
3756
+ // lying about the order the queue really runs in.
3757
+ const TASK_PRIORITIES = ['high', 'medium', 'low'];
3758
+ const LEGACY_PRIORITIES = { 高: 'high', 中: 'medium', 低: 'low' };
3759
+ // One glyph for the 22px square, the whole word for the menu that has room.
3760
+ const PRIORITY_GLYPH = { high: 'H', medium: 'M', low: 'L' };
3761
+ const PRIORITY_NAME = { high: 'High', medium: 'Medium', low: 'Low' };
3762
+ function normalizePriority(value){
3763
+ const v = String(value ?? '').trim();
3764
+ if (TASK_PRIORITIES.includes(v)) return v;
3765
+ if (LEGACY_PRIORITIES[v]) return LEGACY_PRIORITIES[v];
3766
+ const lower = v.toLowerCase();
3767
+ return TASK_PRIORITIES.includes(lower) ? lower : 'medium';
3768
+ }
3719
3769
  function prioBadge(t){
3720
- const p = TASK_PRIORITIES.includes(t.priority) ? t.priority : '中';
3770
+ const p = normalizePriority(t.priority);
3721
3771
  return `<div class="priosel" data-priosel="${t.id}">
3722
- <button type="button" class="priobtn prio-${p}" data-priobtn="${t.id}" title="Change priority">${p}</button>
3772
+ <button type="button" class="priobtn prio-${p}" data-priobtn="${t.id}" title="Change priority — ${PRIORITY_NAME[p]}">${PRIORITY_GLYPH[p]}</button>
3723
3773
  <div class="priomenu" data-priomenu="${t.id}">${TASK_PRIORITIES.map((x) =>
3724
- `<button type="button" class="prioopt${x === p ? ' active' : ''}" data-priotask="${t.id}" data-val="${x}">${x}</button>`).join('')}</div>
3774
+ `<button type="button" class="prioopt${x === p ? ' active' : ''}" data-priotask="${t.id}" data-val="${x}">${PRIORITY_NAME[x]}</button>`).join('')}</div>
3725
3775
  </div>`;
3726
3776
  }
3727
3777
 
@@ -9018,6 +9068,12 @@ function connectSSE(){
9018
9068
  // fsActiveContext() uses to pick which running goal is "now" when several run
9019
9069
  // in parallel (§1.5-1: never anchor on stale work when something is live).
9020
9070
  else if (m.ev === 'act') { (state.act[m.taskId] ??= []).push(m.line); state.act[m.taskId] = state.act[m.taskId].slice(-1000); state.actAt[m.taskId] = Date.now(); scheduleActRender(); return; }
9071
+ // The agent talking, as opposed to the agent working. Everything above this
9072
+ // line is machinery (a status, a tool call, a to-do list); this is a sentence
9073
+ // somebody wrote to the person waiting, so it goes where the person is
9074
+ // looking — the same turn treatment a chat reply gets, because it is the same
9075
+ // thing. Masa's friend waited three minutes for exactly this and gave up.
9076
+ else if (m.ev === 'say') { sayFromAgent(m); return; }
9021
9077
  else if (m.ev === 'todos') state.todos[m.taskId] = m.todos ?? [];
9022
9078
  else if (m.ev === 'columns') state.workflowColumns[m.projectId] = m.columns;
9023
9079
  else if (m.ev === 'review-definition') state.reviewDefinitions[m.projectId] = m.reviewDefinition;
@@ -9323,6 +9379,25 @@ function typeChatTurn(turn){
9323
9379
  if (turn.revealed >= full.length) { turn.done = true; stopChatTyper(); renderFsFeed(); }
9324
9380
  }, 16);
9325
9381
  }
9382
+ // A worker's opening words (engine: `ev:'say'`). Rendered as a spoken turn, typed
9383
+ // out, exactly like the one-shot chat reply — Galda writes nothing of its own
9384
+ // here and picks nothing: the sentence is the agent's, and this only puts it
9385
+ // where it can be read. The board layout has no centre feed, so it stays in the
9386
+ // run log there rather than being invented somewhere new.
9387
+ function sayFromAgent({ goalId, text, agent }){
9388
+ const body = String(text ?? '').trim();
9389
+ if (!body || state.layout !== 'flagship') return;
9390
+ const goal = state.goals.find((g) => g.id === goalId);
9391
+ const projectId = goal?.projectId ?? state.active;
9392
+ const turns = (state.chatTurns[projectId] ||= []);
9393
+ // A reconnecting stream can hand us the same opening twice; a person reading it
9394
+ // twice would think the agent had started over.
9395
+ if (turns.some((t) => t.role === 'clawd' && t.text === body)) return;
9396
+ const turn = { role: 'clawd', text: body, revealed: 0, done: false, agent, ts: Date.now() };
9397
+ turns.push(turn);
9398
+ renderFsFeed();
9399
+ if (projectId === state.active) typeChatTurn(turn); else { turn.revealed = body.length; turn.done = true; }
9400
+ }
9326
9401
  // A new send starts: finish any in-flight reply cleanly (complete the model turn +
9327
9402
  // drop the caret, hide the board bar) so a half-typed reply never lingers.
9328
9403
  function finalizeChatTyping(){
@@ -11510,7 +11585,7 @@ function renderCtxBar(){
11510
11585
  .join('');
11511
11586
  // Redrawing the menu must not hand back a second chooser: carry the disabled
11512
11587
  // state that setFolderPickerBusy() put on the live button.
11513
- const openRow = `<button type="button" class="appopt ctxopen${folderPickerBusy ? ' busy' : ''}" id="ctxopenfolder"${folderPickerBusy ? ' disabled' : ''} title="Open the OS folder chooser"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><path d="M12 11v6M9 14h6"/></svg><span class="nm">Open folder…</span></button>`;
11588
+ const openRow = `<button type="button" class="appopt ctxopen${folderPickerBusy ? ' busy' : ''}" id="ctxopenfolder"${folderPickerBusy ? ' disabled' : ''} title="Open the OS folder chooser">${folderRowInner(folderPickerBusy)}</button>`;
11514
11589
  menu.innerHTML = `${opts}${openRow}<div class="ctxmsg" id="ctxdirmsg"></div>`;
11515
11590
  }
11516
11591
  // Workspace mode is fixed to the isolated worktree (clean separate checkout). The
@@ -11562,17 +11637,37 @@ async function setProjectFolder(dir){
11562
11637
  // request stays in flight for as long as the dialog is up, so "a request is out"
11563
11638
  // is exactly "a dialog is open" — no timer to guess with.
11564
11639
  let folderPickerBusy = false;
11640
+ const FOLDER_GLYPH = '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><path d="M12 11v6M9 14h6"/></svg>';
11641
+ // While a chooser is open the icon spins in its own place. Waiting with nothing
11642
+ // moving is what "it did nothing" looks like — and it is what made Masa's friend
11643
+ // tap ten times (2026-07-31). One source for the row's contents, so the live swap
11644
+ // and the next redraw cannot disagree.
11645
+ function folderRowInner(busy){
11646
+ return `${busy ? '<span class="ctxspin" aria-hidden="true"></span>' : FOLDER_GLYPH}<span class="nm">Open folder…</span>`;
11647
+ }
11565
11648
  function setFolderPickerBusy(busy){
11566
11649
  folderPickerBusy = busy;
11567
11650
  const btn = $('ctxopenfolder');
11568
- if (btn) { btn.disabled = busy; btn.classList.toggle('busy', busy); }
11569
- }
11651
+ if (btn) { btn.disabled = busy; btn.classList.toggle('busy', busy); btn.innerHTML = folderRowInner(busy); }
11652
+ }
11653
+ // How long to wait before saying the dialog might not be where they are looking.
11654
+ // The engine opens it immediately; on a Mac where the engine was started by launchd
11655
+ // it can open behind everything, and there is nothing on screen to say so — which is
11656
+ // the other half of what happened to Masa's friend. Long enough not to nag someone
11657
+ // who is simply choosing a folder.
11658
+ const FOLDER_CHOOSER_SLOW_MS = 6000;
11570
11659
  async function openFolderNative(){
11571
11660
  if (folderPickerBusy) return;
11572
11661
  const msg = $('ctxdirmsg');
11573
11662
  const say = (t, bad) => { if (msg) { msg.textContent = t; msg.classList.toggle('bad', Boolean(bad)); } };
11574
11663
  setFolderPickerBusy(true);
11575
11664
  say('Opening the folder chooser…');
11665
+ // Still open after a while: say where it might be, and point at the way out that
11666
+ // does not need it. The row stays disabled — a second chooser is the bug this
11667
+ // guard exists to prevent, and offering one here would re-create it.
11668
+ const slow = setTimeout(() => {
11669
+ if (folderPickerBusy) say('Still waiting — the chooser may be behind your other windows. You can also pick a folder from the list above.');
11670
+ }, FOLDER_CHOOSER_SLOW_MS);
11576
11671
  try {
11577
11672
  const r = await fetch(withKey('/api/choose-folder'), { method: 'POST' });
11578
11673
  const body = await r.json().catch(() => ({}));
@@ -11581,7 +11676,7 @@ async function openFolderNative(){
11581
11676
  if (body.unsupported) { say('This engine can’t open a folder dialog on this system.', true); return; }
11582
11677
  say(body.error || 'Could not open the folder chooser.', true);
11583
11678
  } catch { say('Could not reach the engine.', true); }
11584
- finally { setFolderPickerBusy(false); }
11679
+ finally { clearTimeout(slow); setFolderPickerBusy(false); }
11585
11680
  }
11586
11681
  $('ctxdirbtn').onclick = (e) => {
11587
11682
  e.stopPropagation();
@@ -12517,7 +12612,13 @@ window.__tbink = (i) => { document.body.dataset.tbink = String(((i | 0) % 3 + 3)
12517
12612
  raf = 0;
12518
12613
  if (document.hidden) { clearFx(); return; }
12519
12614
  const t = (performance.now() - t0) / 1000, d = Math.min(2, devicePixelRatio || 1);
12520
- fc.setTransform(d, 0, 0, d, 0, 0); fc.clearRect(0, 0, innerWidth, innerHeight);
12615
+ // Clear in backing-store pixels *before* installing the CSS-pixel transform.
12616
+ // Mobile Safari can change its visual viewport as a question answer removes a
12617
+ // card (or as the browser chrome expands). Clearing in transformed viewport
12618
+ // coordinates then leaves pixels from the send ring's former location behind.
12619
+ // The canvas's own dimensions are the only stable full-clear bounds.
12620
+ clearFx();
12621
+ fc.setTransform(d, 0, 0, d, 0, 0);
12521
12622
  if (MODE < 10) tick(t); // TB edge anim (optional, off by default = 10)
12522
12623
  drawSendRing(t); // send ring (always on while btn mode = 0, independent of TB edge anim)
12523
12624
  raf = requestAnimationFrame(loop);
package/engine/lib.mjs CHANGED
@@ -1869,8 +1869,8 @@ export function reorderQueue(waiting, ids) {
1869
1869
  return [...orderByIds(queued, ids), ...rest];
1870
1870
  }
1871
1871
 
1872
- // Priority queue ordering: '' runs before '' before ''. A task with no
1873
- // priority (or an unrecognized value) is treated as '', matching the
1872
+ // Priority queue ordering: 'high' runs before 'medium' before 'low'. A task with
1873
+ // no priority (or an unrecognized value) is treated as 'medium', matching the
1874
1874
  // default new tasks are created with. Array.prototype.sort is a stable sort
1875
1875
  // (guaranteed by the spec), so this never disturbs the relative order tasks
1876
1876
  // already have within the same priority — that incoming order already
@@ -1879,12 +1879,39 @@ export function reorderQueue(waiting, ids) {
1879
1879
  // for everything it doesn't touch). Callers should feed this the array
1880
1880
  // *after* applying a drag reorder, so a drag only ever re-ranks within a
1881
1881
  // priority band rather than fighting it.
1882
- export const TASK_PRIORITIES = ['高', '中', '低'];
1883
- const PRIORITY_WEIGHT = { 高: 0, 中: 1, 低: 2 };
1882
+ //
1883
+ // The values were 高/中/低 until 2026-07-31 (Masa: the UI writes its own labels
1884
+ // in English — DESIGN-RULES §5.5). They are stored, sorted on, and sent over the
1885
+ // API, so the old ones cannot simply be swapped out: every task written before
1886
+ // today still says 高 on disk, and a browser tab left open on the old app will
1887
+ // keep PUTting 高 for as long as it stays open. Both are accepted forever and
1888
+ // normalised on the way in; nothing has to be migrated, and nothing that already
1889
+ // exists loses its place in the queue.
1890
+ export const TASK_PRIORITIES = ['high', 'medium', 'low'];
1891
+ const LEGACY_PRIORITIES = { 高: 'high', 中: 'medium', 低: 'low' };
1892
+ export const DEFAULT_PRIORITY = 'medium';
1893
+
1894
+ export function normalizePriority(value) {
1895
+ const v = String(value ?? '').trim();
1896
+ if (TASK_PRIORITIES.includes(v)) return v;
1897
+ if (LEGACY_PRIORITIES[v]) return LEGACY_PRIORITIES[v];
1898
+ const lower = v.toLowerCase();
1899
+ if (TASK_PRIORITIES.includes(lower)) return lower; // 'High' from a hand-written call
1900
+ return DEFAULT_PRIORITY;
1901
+ }
1902
+
1903
+ // Whether a value is one this product accepts at all — the API's gate. Deliberately
1904
+ // wider than TASK_PRIORITIES: an old client sending 高 is not making a mistake.
1905
+ export function isAcceptedPriority(value) {
1906
+ const v = String(value ?? '').trim();
1907
+ return TASK_PRIORITIES.includes(v) || Boolean(LEGACY_PRIORITIES[v]) || TASK_PRIORITIES.includes(v.toLowerCase());
1908
+ }
1909
+
1910
+ const PRIORITY_WEIGHT = { high: 0, medium: 1, low: 2 };
1884
1911
  export function sortQueueByPriority(tasks) {
1885
1912
  return tasks
1886
1913
  .map((t, i) => ({ t, i }))
1887
- .sort((a, b) => (PRIORITY_WEIGHT[a.t.priority] ?? 1) - (PRIORITY_WEIGHT[b.t.priority] ?? 1) || a.i - b.i)
1914
+ .sort((a, b) => (PRIORITY_WEIGHT[normalizePriority(a.t.priority)] ?? 1) - (PRIORITY_WEIGHT[normalizePriority(b.t.priority)] ?? 1) || a.i - b.i)
1888
1915
  .map(({ t }) => t);
1889
1916
  }
1890
1917
 
@@ -3270,7 +3297,7 @@ export function buildGoalTask(goal, { id, num, passCondition = null, createdAt }
3270
3297
  mode: goal.mode ?? 'auto', skill: goal.skill ?? null,
3271
3298
  workspaceMode: resolveWorkspaceMode(goal.workspaceMode ?? goal.worktree),
3272
3299
  worktree: goal.worktree === false ? false : undefined,
3273
- status: 'queued', createdAt, priority: '中',
3300
+ status: 'queued', createdAt, priority: DEFAULT_PRIORITY,
3274
3301
  result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
3275
3302
  };
3276
3303
  }
package/engine/server.mjs CHANGED
@@ -21,7 +21,7 @@ import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { bootBanner, bootDestination, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath, hostReadiness, 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, buildQueueWaits, 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, REQUIREMENT_EVIDENCE_FILE, parseRequirementEvidenceDocument, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
24
+ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildReviewAttemptLedger, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, DEFAULT_PRIORITY, normalizePriority, isAcceptedPriority, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, buildQueueWaits, 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, REQUIREMENT_EVIDENCE_FILE, parseRequirementEvidenceDocument, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
25
25
  import { migrateRequirementModel, validateRequirementModel } from './requirement-model.mjs';
26
26
  import { buildManagerVerificationChecks, buildRequirementVerificationEvidence, mergeRequirementVerificationEvidence } from './requirement-verification.mjs';
27
27
  import { readRelayPref, writeRelayPref, resolveRelayEnabled } from './relay-pref.mjs';
@@ -2357,7 +2357,7 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
2357
2357
  title: text.replace(/\s+/g, ' ').slice(0, 60), detail: promptText.slice(0, 4000), replyFrom: from,
2358
2358
  workspaceMode: resolveWorkspaceMode(goal.workspaceMode ?? goal.worktree),
2359
2359
  worktree: goal.worktree === false ? false : undefined,
2360
- passCondition: null, status: 'queued', createdAt: new Date().toISOString(), priority: '中',
2360
+ passCondition: null, status: 'queued', createdAt: new Date().toISOString(), priority: DEFAULT_PRIORITY,
2361
2361
  result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
2362
2362
  userRequirementHistory: tasks.filter((t) => t.goalId === goal.id && (!t.reply || t.replyFrom === 'you')).slice(-12).map((t) => ({ text: t.reply ? t.detail : t.title, status: t.status })),
2363
2363
  // Transport the prior attempts as facts; the worker, not Manager, decides
@@ -4921,11 +4921,22 @@ const server = createServer(async (req, res) => {
4921
4921
  // guarded here.
4922
4922
  const runnable = !pending && !review && !approval;
4923
4923
  const lang = /[぀-ヿ㐀-鿿]/.test(text) ? 'ja' : 'en';
4924
- // Task 5 (PR運用ヒアリング): ask once per project, before its first
4925
- // runnable goal whether this project wants PRs.
4926
- const needsReviewPref = runnable && needsReviewPreference({ askedBefore: project.reviewPrefAsked });
4927
- const reviewPrefQuestion = needsReviewPref ? buildReviewPreferenceQuestion({ lang }) : undefined;
4928
- if (needsReviewPref) { project.reviewPrefAsked = true; saveProjects(); }
4924
+ // Galda no longer asks "do you want PRs?" (Masa 2026-07-31). It used to ask once
4925
+ // per project, before the first runnable goal, WITHOUT looking at what was asked —
4926
+ // needsReviewPreference() was `return !askedBefore`. A first-time user typing
4927
+ // 「テスト」 got interrogated about pull requests, and because the folder question
4928
+ // had claimed the needsInput slot on message 1, it landed on message 2, out of
4929
+ // nowhere. (That folder question is gone too, as of #642 — this is the other half
4930
+ // of the same interrogation.)
4931
+ //
4932
+ // Nothing needs the answer: resolveWantsPR() already falls back to
4933
+ // `!preferenceKnown && isImplementationRequest(text)`, i.e. a PR for work that looks
4934
+ // like implementation and none for 「テスト」 — which is the behaviour the question
4935
+ // was there to obtain. Projects that already answered keep their setting, and an
4936
+ // agent that genuinely needs to know can ask for itself: the question layer belongs
4937
+ // to Claude Code / Codex, not to Galda.
4938
+ const needsReviewPref = false;
4939
+ const reviewPrefQuestion = undefined;
4929
4940
  // Clamp to whatever agent CLIs are actually installed on this machine
4930
4941
  // (connected-agents Phase A) — a requested/default agent that isn't
4931
4942
  // present would otherwise spawn a worker doomed to fail at launch.
@@ -5317,7 +5328,7 @@ const server = createServer(async (req, res) => {
5317
5328
  return;
5318
5329
  }
5319
5330
 
5320
- // change a queued task's priority (高/中/低) — re-sorts its project's queue
5331
+ // change a queued task's priority (high/medium/low) — re-sorts its project's queue
5321
5332
  // so a bumped-up task actually runs sooner, not just displays that way.
5322
5333
  const priorityMatch = url.pathname.match(/^\/api\/tasks\/(\d+)\/priority$/);
5323
5334
  if (priorityMatch && req.method === 'PUT') {
@@ -5329,8 +5340,11 @@ const server = createServer(async (req, res) => {
5329
5340
  req.on('end', () => {
5330
5341
  try {
5331
5342
  const { priority } = JSON.parse(body);
5332
- if (!TASK_PRIORITIES.includes(priority)) return json(res, 400, { error: `priority must be one of: ${TASK_PRIORITIES.join(', ')}` });
5333
- task.priority = priority;
5343
+ // A browser left open on yesterday's app still sends 高/中/低. Refusing it
5344
+ // would break a tab nobody reloaded, for a rename that is ours, so the old
5345
+ // words are accepted and stored as the new ones.
5346
+ if (!isAcceptedPriority(priority)) return json(res, 400, { error: `priority must be one of: ${TASK_PRIORITIES.join(', ')}` });
5347
+ task.priority = normalizePriority(priority);
5334
5348
  saveTask(task);
5335
5349
  const q = queues.get(task.projectId);
5336
5350
  if (q) { q.waiting = sortQueueByPriority(q.waiting); for (const t of q.waiting) saveTask(t); }
@@ -5506,9 +5520,26 @@ const server = createServer(async (req, res) => {
5506
5520
  const existing = projects.find((p) => p.dir === dir);
5507
5521
  let proj = existing;
5508
5522
  if (!proj) {
5509
- proj = { id: uniqueProjectId(basename(dir)), name: basename(dir), dir };
5510
- projects.push(proj); saveProjects();
5511
- queues.set(proj.id, { running: new Set(), waiting: [] });
5523
+ // The free tier allows one project, and POST /api/projects enforces that — but
5524
+ // this path never did, so every folder someone named here minted another one
5525
+ // (measured 2026-07-31: a first-time user on the free tier ended up with three).
5526
+ // At the cap, "pick a folder" means "this is the folder I work in", so point the
5527
+ // project someone already has at it instead of inventing a second.
5528
+ const atCap = checkFreeTierLimit({ ...computeFreeTierUsage(goal.owner ?? 'owner'), projectCount: projects.length + 1 }).reason === 'project-limit';
5529
+ // Only ever the goal's OWN project. A folder question only fires when that
5530
+ // project's dir is HOME or not a repo, so it is by definition the unusable one
5531
+ // and pointing it at the chosen folder is what the person just asked for. Never
5532
+ // repoint somebody else's project to make room.
5533
+ const reusable = atCap ? projects.find((p) => p.id === goal.projectId) : null;
5534
+ if (reusable) {
5535
+ reusable.dir = dir;
5536
+ saveProjects();
5537
+ proj = reusable;
5538
+ } else {
5539
+ proj = { id: uniqueProjectId(basename(dir)), name: basename(dir), dir };
5540
+ projects.push(proj); saveProjects();
5541
+ queues.set(proj.id, { running: new Set(), waiting: [] });
5542
+ }
5512
5543
  }
5513
5544
  goal.projectId = proj.id;
5514
5545
  goal.question = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.118",
3
+ "version": "0.10.119",
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": {