@galda/cli 0.10.48 → 0.10.50

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
@@ -472,7 +472,17 @@
472
472
  scoped here, since every other .qsub stays a one-liner. */
473
473
  .replynote{white-space:normal;overflow:visible;margin-top:14px;padding-top:12px;border-top:1px solid var(--hair)}
474
474
  .replynote .qchips{margin-top:6px}
475
- .replynote .txr.note .m{color:var(--ink3)}
475
+ /* The .txr You/Claude rows are styled by `.redesign .txr` — which the See-all card
476
+ (.sa-item.redesign) provides, but the goal-detail overlay and review dialog do NOT.
477
+ There the same note rendered as unstyled run-in black text ("Youこんにちは" with no
478
+ gap/weight — Masa 2026-07-24 "弾"). Scope the row layout to .replynote too so the
479
+ reply thread reads the same wherever its box lives. */
480
+ .replynote .txr{display:flex;gap:12px;padding:5px 0;font-size:13px;line-height:1.55;align-items:baseline}
481
+ .replynote .txr .w{flex:0 0 46px;font-size:11px;color:var(--ink3)}
482
+ .replynote .txr.you .w,.replynote .txr.cl .w{color:var(--ink);font-weight:600}
483
+ .replynote .txr .m{flex:1;min-width:0;color:var(--ink2)}
484
+ .replynote .txr.cl .m{color:var(--ink)}
485
+ .replynote .txr.note .m{font-family:var(--mono);font-size:12px;color:var(--ink3)}
476
486
  .ob-unsent{color:rgb(var(--danger-rgb));cursor:pointer}
477
487
  [data-oresend]{cursor:pointer}
478
488
  .chip2{font-family:var(--mono);font-size:9.5px;letter-spacing:.05em;padding:2px 7px;border-radius:6px;border:1px solid var(--hair2);color:var(--ink3);flex:0 0 auto}
@@ -1329,13 +1339,17 @@
1329
1339
  color:var(--ink2);font:inherit;font-size:12.5px;white-space:nowrap;overflow:hidden;cursor:pointer;text-align:left;width:100%;position:relative}
1330
1340
  #fsRoot .fsproj:hover{background:var(--hover-s)}
1331
1341
  #fsRoot .fsproj.on{background:var(--hover);color:var(--ink)}
1332
- #fsRoot .fsproj i{width:4px;height:4px;border-radius:50%;background:var(--ink3);flex:0 0 4px}
1342
+ #fsRoot .fsproj i{width:4px;height:4px;border-radius:50%;background:var(--ink3);flex:0 0 4px;position:relative;top:1.5px} /* top:1.5px = optical nudge so the dot/running-ring centre sits on the name's x-height, not the line-box centre (Masa 2026-07-24, rail=D). Same shift for idle dot + running ring keeps the column aligned. */
1333
1343
  #fsRoot .fsproj.on i{background:var(--green)}
1334
1344
  /* running project = solid saturated green dot with a breathing glow — never dims to
1335
1345
  grey, independent of selection (HANDOFF-v45 §11, flagship .proj.run port) */
1336
- #fsRoot .fsproj.run i{background:var(--green);box-shadow:0 0 0 0 color-mix(in srgb,var(--green) 55%,transparent);animation:fsrundot 2s ease-in-out infinite}
1346
+ /* running project = a spinning broken green ring (matches the Doing marker · flagship #316).
1347
+ Was a pulsing dot (fsrundot). Centre WITHOUT transform (spin's rotate would override a centring
1348
+ translate → drift bottom-right) via top/left:50% + negative-half margin; concentric with the 4px dot. */
1349
+ #fsRoot .fsproj.run i{background:none;box-shadow:none;position:relative}
1350
+ #fsRoot .fsproj.run i::after{content:"";position:absolute;top:50%;left:50%;width:7px;height:7px;margin:-3.5px 0 0 -3.5px;box-sizing:border-box;border-radius:50%;border:1.3px solid #22c55e;border-top-color:transparent;animation:spin 1.8s linear infinite}
1337
1351
  @keyframes fsrundot{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--green) 50%,transparent)}50%{box-shadow:0 0 5px 1.5px color-mix(in srgb,var(--green) 35%,transparent)}}
1338
- @media (prefers-reduced-motion:reduce){#fsRoot .fsproj.run i{animation:none}}
1352
+ @media (prefers-reduced-motion:reduce){#fsRoot .fsproj.run i::after{animation:none}}
1339
1353
  #fsRoot .fsproj .pname{min-width:0;overflow:hidden;text-overflow:ellipsis}
1340
1354
  #fsRoot .fsproj .num{margin-left:auto;font-size:10px;color:var(--ink3);flex:0 0 auto}
1341
1355
  /* per-project counts (HANDOFF-v45 §10): To Do = rounded-square icon, Review = Scan
@@ -2276,10 +2290,13 @@
2276
2290
  #fsRoot .trow .tt{color:color-mix(in srgb,var(--ink) 84%,var(--panel))} /* §13.2 soften To Do row name (not pure black); left chat feed keeps --ink */
2277
2291
  #fsRoot .trow:hover{background:var(--hover-s)}
2278
2292
  #fsRoot .trow.now{font-weight:500}
2279
- #fsRoot .trow .st{width:13px;height:13px;border-radius:50%;border:1.5px solid var(--hair2);flex:0 0 13px}
2280
- #fsRoot .trow .st.run{border-color:var(--green);background:radial-gradient(circle,rgba(var(--green-rgb),.4),transparent 70%)}
2281
- #fsRoot .trow .st.run.bad{border-color:var(--danger);background:radial-gradient(circle,rgba(var(--danger-rgb),.42),transparent 70%)}
2282
- #fsRoot .trow .st.run.int{border-color:var(--amber);background:radial-gradient(circle,rgba(var(--amber-rgb),.4),transparent 70%)}
2293
+ #fsRoot .trow .st{width:13px;height:13px;border-radius:50%;border:1.5px solid var(--hair2);flex:0 0 13px;position:relative;top:1px} /* top:1px = optical nudge so the whole status column (To Do/Doing/Failed/Done) centres on the row text, not the line-box (Masa 2026-07-24, board=C) */
2294
+ /* Doing marker = a broken green ring that spins (pattern B · flagship #316). Running only —
2295
+ .bad (failed) / .int (paused) stay static: no dead motion (DESIGN-RULES §5.5). Reuses @keyframes spin. */
2296
+ #fsRoot .trow .st.run{border-color:var(--green);border-top-color:transparent;background:none;box-shadow:0 0 6px color-mix(in srgb,var(--green) 40%,transparent);animation:spin 1.8s linear infinite;animation-delay:var(--spd,0ms)} /* --spd = wall-clock phase (set in render()) so a re-created board marker doesn't restart the spin at 0deg */
2297
+ #fsRoot .trow .st.run.bad{border-color:var(--danger);background:radial-gradient(circle,rgba(var(--danger-rgb),.42),transparent 70%);animation:none;box-shadow:none}
2298
+ #fsRoot .trow .st.run.int{border-color:var(--amber);background:radial-gradient(circle,rgba(var(--amber-rgb),.4),transparent 70%);animation:none;box-shadow:none}
2299
+ @media (prefers-reduced-motion:reduce){#fsRoot .trow .st.run{animation:none}}
2283
2300
  /* Doing-failure state word (HANDOFF-v45 §11): colour TEXT only, no badge/pill surface —
2284
2301
  Failed = red, Interrupted = amber. */
2285
2302
  #fsRoot .trow .stword{font-size:10.5px;font-weight:600;color:var(--danger);flex:0 0 auto}
@@ -2380,7 +2397,7 @@
2380
2397
  /* GOTCHA (HANDOFF-v45 §11): the rule above wins over a plain .fsproj.run i by
2381
2398
  specificity and greys the running dot out on the Gradient wallpaper rail — this
2382
2399
  exception restores solid green so "running" never dims to grey. */
2383
- body[data-theme="gradient"] #fsRoot .fsproj.run i{background:#22c55e}
2400
+ body[data-theme="gradient"] #fsRoot .fsproj.run i{background:none}
2384
2401
  body[data-theme="gradient"] #fsRoot .fsproj .num,body[data-theme="gradient"] #fsRoot .fsproj .pcount{color:rgba(20,23,31,.6)}
2385
2402
  body[data-theme="gradient"] #fsRoot .fsproj:not(.on):hover{background:rgba(255,255,255,.34)}
2386
2403
  body[data-theme="gradient"] #fsRoot .fsproj.on{background:rgba(255,255,255,.5);color:var(--ink)}
@@ -2517,7 +2534,7 @@
2517
2534
  body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj{color:rgba(255,255,255,.85);text-shadow:0 1px 3px rgba(0,0,0,.35)}
2518
2535
  body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj i{background:rgba(255,255,255,.6)}
2519
2536
  /* same GOTCHA exception as the light Gradient rail, dark variant (HANDOFF-v45 §11) */
2520
- body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj.run i{background:#22c55e}
2537
+ body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj.run i{background:none}
2521
2538
  body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj .num,body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj .pcount{color:rgba(255,255,255,.6)}
2522
2539
  body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj.on{background:rgba(255,255,255,.15);color:#fff}
2523
2540
  body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj:not(.on):hover{background:rgba(255,255,255,.1)}
@@ -3323,7 +3340,7 @@ const savedTheme = (() => {
3323
3340
  if (v !== s) localStorage.setItem('theme', v);
3324
3341
  return v;
3325
3342
  })();
3326
- const state = { repos: null, repoHome: '', /* GET /api/repos — the folders a project can point at (Context Bar) */ projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: 'default', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, boardskin: localStorage.getItem('boardskin') || 'auto', summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null, projectRules: {}, rulesOpen: false,
3343
+ const state = { repos: null, repoHome: '', /* GET /api/repos — the folders a project can point at (Context Bar) */ projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: localStorage.getItem('sel:project') || 'default', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, boardskin: localStorage.getItem('boardskin') || 'auto', summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null, projectRules: {}, rulesOpen: false,
3327
3344
  // per-message send overrides set via slash-commands (reset after each send,
3328
3345
  // except model which is sticky in localStorage). palette = open command list.
3329
3346
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
@@ -6142,6 +6159,13 @@ function render(){
6142
6159
  if (typeof SA !== 'undefined' && SA.open) saSync(); // keep the open See all Ledger live
6143
6160
  const bv = $('botver'); if (bv && state.uiVersion) bv.textContent = 'build ' + String(state.uiVersion).slice(0, 8);
6144
6161
  paintReplyNote(); // a reply's answer/question survives the re-render it caused
6162
+ // Persist the selected project so a reload (a real UI ship, or any reconnect)
6163
+ // restores the user's view instead of snapping back to 'default' (Masa
6164
+ // 2026-07-24 "勝手にデフォルトのプロジェクトに戻される"). Only store an id that
6165
+ // currently exists as a project, so onboarding/transient sentinels
6166
+ // ('__onboard') are never saved and the restore always lands on a real one;
6167
+ // an id that later disappears self-heals via the guards in refresh()/'projects'.
6168
+ if (state.projects.some((p) => p.id === state.active)) localStorage.setItem('sel:project', state.active);
6145
6169
  }
6146
6170
 
6147
6171
  // ============================================================================
@@ -6181,6 +6205,11 @@ function renderFlagship(){
6181
6205
  if (state.layout !== 'flagship') return;
6182
6206
  const root = $('fsRoot'); if (!root) return;
6183
6207
  root.dataset.rv = String(fsUI.rv);
6208
+ // Wall-clock phase for the spinning markers: any ring re-created this render (the board
6209
+ // re-renders often on progress %) starts at the angle a continuous one would be at now,
6210
+ // instead of snapping back to 0deg — no "jump". The rail ring is guarded separately and
6211
+ // doesn't read --spd, so changing it here can't disturb the persistent rail ring. (Masa 2026-07-24)
6212
+ root.style.setProperty('--spd', (-(Date.now() % 1800)) + 'ms');
6184
6213
  renderFsRail(); renderFsFeed(); renderFsLane();
6185
6214
  }
6186
6215
  function renderFsRail(){
@@ -6188,7 +6217,7 @@ function renderFsRail(){
6188
6217
  const activeProject = state.projects.find((p) => p.id === state.active);
6189
6218
  const activeLabel = $('fsProjBtnName');
6190
6219
  if (activeLabel) activeLabel.textContent = activeProject?.name ?? 'Projects';
6191
- el.innerHTML = state.projects.map((p) => {
6220
+ const html = state.projects.map((p) => {
6192
6221
  const ptasks = state.tasks.filter((t) => t.projectId === p.id);
6193
6222
  const pgoals = state.goals.filter((g) => g.projectId === p.id);
6194
6223
  // running dot (HANDOFF-v45 §11): a REAL running task, independent of selection —
@@ -6202,6 +6231,11 @@ function renderFsRail(){
6202
6231
  return `<button class="fsproj${p.id === state.active ? ' on' : ''}${running ? ' run' : ''}" data-fsproj="${p.id}"><i></i><span class="pname">${esc(p.name)}</span>${pcount}</button>`;
6203
6232
  }).join('')
6204
6233
  + `<button class="fsprojadd" id="fsProjAdd" title="New project"><span class="pa-plus"></span><span class="pa-label">New project</span></button>`;
6234
+ // Skip the DOM swap when the rail is unchanged: re-creating .fsproj.run would restart the
6235
+ // spinning ring's CSS animation — a visible "jump" on every SSE-driven re-render (Masa 2026-07-24).
6236
+ if (html === renderFsRail._html && el.childElementCount) return;
6237
+ renderFsRail._html = html;
6238
+ el.innerHTML = html;
6205
6239
  for (const b of el.querySelectorAll('[data-fsproj]')) b.onclick = (e) => {
6206
6240
  if (e.target.closest('.pname.editing') || b.querySelector('.pname.editing')) return;
6207
6241
  state.active = b.dataset.fsproj; document.body.classList.remove('projectsopen'); render();
@@ -6250,6 +6284,14 @@ function fsActiveContext(){
6250
6284
  // Per-project last non-empty center feed — the whiteout guard (see below): keeps
6251
6285
  // what the user was looking at when a render pass momentarily produces no lines.
6252
6286
  const _fsLastFeed = {};
6287
+ // H-CENTERLANE decision 1: pure merge-sort of timed HTML blocks (the focused task's
6288
+ // activity block + each chat turn) so the center lane renders in the order things
6289
+ // actually happened. Stable sort (spec-guaranteed since ES2019) keeps same-ts blocks
6290
+ // in their original relative order. Pure/no DOM — covered directly in
6291
+ // engine/test/center-lane-timeline.test.mjs.
6292
+ function fsMergeTimelineBlocks(blocks){
6293
+ return [...blocks].sort((a, b) => a.ts - b.ts).map((b) => b.html).join('');
6294
+ }
6253
6295
  function renderFsFeed(){
6254
6296
  if (state.layout !== 'flagship') return;
6255
6297
  const feed = $('fsFeed'); if (!feed) return;
@@ -6294,7 +6336,12 @@ function renderFsFeed(){
6294
6336
  } else { nd.hidden = true; nd.innerHTML = ''; }
6295
6337
  }
6296
6338
  let html = '';
6339
+ // H-CENTERLANE decision 1: the focused task's activity block is anchored at its
6340
+ // startedAt so it can be merge-sorted against chat turns below (state.act carries
6341
+ // no per-line timestamp, so the whole block — not each line — is the sort unit).
6342
+ let focusTs = 0;
6297
6343
  if (focus) {
6344
+ focusTs = focus.startedAt ? Date.parse(focus.startedAt) : 0;
6298
6345
  const lines = state.act[focus.id] ?? [];
6299
6346
  const lineLimit = fsUI.logOpen.has(focus.id) || focus.status === 'running' || ['running', 'partial'].includes(goal?.status) ? 1000 : 80;
6300
6347
  html += lines.slice(-lineLimit).map(fsFeedRow).join('');
@@ -6361,29 +6408,39 @@ function renderFsFeed(){
6361
6408
  // whole-feed re-render (SSE act events) can't drop them. Order per turn:
6362
6409
  // You → (mark, if it became a task) and, for a reply, speaker line → reply.
6363
6410
  const turns = (state.chatTurns[state.active] || []).slice(-40);
6364
- let chatHtml = '';
6411
+ // H-CENTERLANE decision 1: each turn becomes its own timed block (ts, html) so it
6412
+ // can be merge-sorted against the focused task's activity block below, instead of
6413
+ // always rendering as one chat block stapled after the task feed regardless of
6414
+ // when either actually happened.
6415
+ const chatBlocks = [];
6365
6416
  for (const turn of turns) {
6417
+ let t = '';
6366
6418
  if (turn.role === 'you') {
6367
- chatHtml += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
6419
+ t += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
6368
6420
  if (turn.markGoalId != null && !turn.undone) {
6369
6421
  const mg = state.goals.find((x) => x.id === turn.markGoalId);
6370
6422
  const n = mg ? goalReviewNumber({ goal: mg, tasks: state.tasks }) : turn.markGoalId;
6371
- chatHtml += `<div class="cc-mark"><span class="cm-ar">→</span><span class="cm-t">Added to To Do</span><span class="cm-no">#${n}</span><button class="cm-undo" type="button" data-ccundo="${turn.markGoalId}">Undo</button></div>`;
6423
+ t += `<div class="cc-mark"><span class="cm-ar">→</span><span class="cm-t">Added to To Do</span><span class="cm-no">#${n}</span><button class="cm-undo" type="button" data-ccundo="${turn.markGoalId}">Undo</button></div>`;
6372
6424
  }
6373
6425
  } else { // Claude Code reply — speaker line first (You → speaker → reply), then the text
6374
6426
  const app = connectedAppMeta(turn.agent);
6375
- chatHtml += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
6427
+ t += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
6376
6428
  const shown = esc((turn.text || '').slice(0, turn.revealed || 0));
6377
- chatHtml += turn.done
6429
+ t += turn.done
6378
6430
  ? `<div class="cc-say">${shown}</div>`
6379
6431
  : `<div class="cc-say"><span id="ccTyping">${shown}</span><span class="cc-caret"></span></div>`;
6380
6432
  }
6433
+ chatBlocks.push({ ts: turn.ts ?? 0, html: t });
6381
6434
  }
6382
6435
  const pgoals = state.goals.filter((g) => g.projectId === state.active);
6383
6436
  const ptasks = state.tasks.filter((t) => t.projectId === state.active);
6384
6437
  const hasWork = pgoals.length > 0 || ptasks.length > 0 || turns.length > 0;
6385
- // task feed (with its own speaker line) conversation turns → question (bottom)
6386
- let out = html + chatHtml + askHtml;
6438
+ // H-CENTERLANE decision 1: interleave the focused task's activity block with the
6439
+ // chat turns in the order they actually happened (stable sort keeps same-ts items
6440
+ // in their original relative order); the question card(s) stay pinned at the very
6441
+ // end (H18/H19 — a pending question must never be lost mid-scroll).
6442
+ const timelineBlocks = html ? [{ ts: focusTs, html }, ...chatBlocks] : chatBlocks;
6443
+ let out = fsMergeTimelineBlocks(timelineBlocks) + askHtml;
6387
6444
  if (out) _fsLastFeed[state.active] = out;
6388
6445
  else if (hasWork) out = _fsLastFeed[state.active] || `<div class="cc-c">⎿ ${esc(running.length ? 'working…' : 'no active task')}</div>`;
6389
6446
  $('fsRoot').classList.toggle('clawdhero', !hasWork);
@@ -6800,7 +6857,10 @@ function fsBucketTodo(){
6800
6857
  needsInput: pgoals.filter((g) => g.status === 'needsInput'), // a clarifying question is waiting on the user (board's "Needs you")
6801
6858
  reviewGoals: pgoals.filter((g) => g.status === 'review'),
6802
6859
  queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
6803
- doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status) && !closedGoalIds.has(t.goalId)).sort((a, b) => b.num - a.num),
6860
+ // A reply task's completion is already visible inline in the center-lane timeline
6861
+ // (H-CENTERLANE decision 3) — showing it again as an independent Done row is the
6862
+ // same event twice. `queued` already guards `!t.reply` above; mirror it here.
6863
+ doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status) && !closedGoalIds.has(t.goalId) && !t.reply).sort((a, b) => b.num - a.num),
6804
6864
  later: pgoals.filter((g) => g.status === 'pending'),
6805
6865
  rejected: pgoals.filter((g) => g.status === 'rejected'), // 07-20c: Reject parks goals here (own fold, like Done)
6806
6866
  };
@@ -6927,7 +6987,7 @@ function renderFsTodo(){
6927
6987
  <button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
6928
6988
  </div>
6929
6989
  ${doneAll.length ? `<div class="doneline${fsUI.doneOpen ? ' open' : ''}" id="fsDoneLine">✓ Done<span class="num">${doneAll.length}</span><span class="fold">▾</span></div>
6930
- <div id="fsDonelist"${fsUI.doneOpen ? '' : ' hidden'}>${doneAll.slice(0, 3).map((t) => `<div class="trow up dim done"><span class="st ok"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span></div>`).join('')}</div>` : ''}`;
6990
+ <div id="fsDonelist"${fsUI.doneOpen ? '' : ' hidden'}>${doneAll.map((t) => `<div class="trow up dim done"><span class="st ok"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span></div>`).join('')}</div>` : ''}`;
6931
6991
  el.classList.toggle('folded', !!fsUI.todoFold);
6932
6992
  $('fsTodoHd').onclick = () => { fsUI.todoFold = !fsUI.todoFold; el.classList.toggle('folded', fsUI.todoFold); };
6933
6993
  const qadd = el.querySelector('.qadd[data-fsnew]'); // + New → focus the composer (the real create path)
@@ -8199,7 +8259,7 @@ async function postOutboxItem(item){
8199
8259
  // .peraanswer bar. Never land()/upsert(): a kind:'chat' response has no id.
8200
8260
  outbox.remove(item.oid);
8201
8261
  if (state.layout === 'flagship') {
8202
- const turn = { role: 'clawd', text: String(goal.reply || ''), revealed: 0, done: false, agent: item.agent };
8262
+ const turn = { role: 'clawd', text: String(goal.reply || ''), revealed: 0, done: false, agent: item.agent, ts: Date.now() };
8203
8263
  (state.chatTurns[item.projectId] ||= []).push(turn);
8204
8264
  renderFsFeed();
8205
8265
  typeChatTurn(turn);
@@ -9976,7 +10036,7 @@ async function argItems(cmd){
9976
10036
  // Read the raw input → decide what the palette shows (null = closed).
9977
10037
  function paletteState(val){
9978
10038
  if (val[0] !== '/') return null;
9979
- const m = val.match(/^\/(\w*)(\s)?([\s\S]*)$/);
10039
+ const m = val.match(/^\/([\w-]*)(\s)?([\s\S]*)$/); // allow '-' so hyphenated skill names filter (english-practice)
9980
10040
  if (!m) return null;
9981
10041
  const word = m[1], hadSpace = m[2] != null, rest = m[3];
9982
10042
  if (!hadSpace) return { level: 'top', query: word }; // still typing the command name
@@ -9999,11 +10059,18 @@ function renderPalette(){
9999
10059
  if (!p || !el) return;
10000
10060
  const head = p.level === 'top' ? 'Commands' : '/' + p.cmd;
10001
10061
  if (!p.items.length) { el.innerHTML = `<div class="sphead">${esc(head)}</div><div class="empty">No match</div>`; el.classList.add('show'); return; }
10002
- el.innerHTML = `<div class="sphead">${esc(head)}</div>` + p.items.map((it, i) => {
10003
- const name = p.level === 'top' ? '/' + it.cmd + (it.arg ? ' …' : '') : it.v;
10004
- const desc = p.level === 'top' ? it.desc : (it.d || '');
10005
- return `<button type="button" class="spopt${i === p.sel ? ' on' : ''}" data-i="${i}" role="option"><span class="spn">${esc(name)}</span><span class="spd">${esc(desc)}</span></button>`;
10006
- }).join('');
10062
+ let html = '', lastGroup = null;
10063
+ p.items.forEach((it, i) => {
10064
+ // group headers: top-level splits Commands vs Skills; arg-level shows the "/cmd" head once.
10065
+ if (p.level === 'top') {
10066
+ const group = it.skill ? 'Skills' : 'Commands';
10067
+ if (group !== lastGroup) { html += `<div class="sphead">${group}</div>`; lastGroup = group; }
10068
+ } else if (i === 0) { html += `<div class="sphead">${esc('/' + p.cmd)}</div>`; }
10069
+ const name = p.level === 'top' ? (it.skill ? '/' + it.skill : '/' + it.cmd + (it.arg ? ' …' : '')) : it.v;
10070
+ const desc = p.level === 'top' ? (it.desc || '') : (it.d || '');
10071
+ html += `<button type="button" class="spopt${i === p.sel ? ' on' : ''}" data-i="${i}" role="option"><span class="spn">${esc(name)}</span><span class="spd">${esc(desc)}</span></button>`;
10072
+ });
10073
+ el.innerHTML = html;
10007
10074
  el.classList.add('show');
10008
10075
  for (const b of el.querySelectorAll('[data-i]')) b.onmousedown = (e) => { e.preventDefault(); state.palette.sel = Number(b.dataset.i); acceptPalette(); };
10009
10076
  }
@@ -10015,6 +10082,13 @@ async function refreshPalette(){
10015
10082
  if (info.level === 'top') {
10016
10083
  const q = info.query.toLowerCase();
10017
10084
  items = SLASH_CMDS.filter((s) => !q || s.cmd.startsWith(q));
10085
+ // Once the user types after "/", also offer their registered skills (type-to-filter, so a
10086
+ // bare "/" stays a short command list). Selecting one runs the goal with that skill first.
10087
+ if (q) {
10088
+ const skills = (await loadSkills()).filter((s) => s.name.toLowerCase().includes(q));
10089
+ if ($('input').value !== val) return; // input changed while awaiting skills
10090
+ items = items.concat(skills.map((s) => ({ skill: s.name, desc: `${s.kind || 'skill'}${s.description ? ' · ' + s.description : ''}`.trim() })));
10091
+ }
10018
10092
  } else {
10019
10093
  const all = await argItems(info.cmd);
10020
10094
  if ($('input').value !== val) return; // input changed while awaiting skills
@@ -10035,7 +10109,8 @@ function acceptPalette(){
10035
10109
  const p = state.palette; if (!p) return false;
10036
10110
  const item = p.items[p.sel]; if (!item) return true;
10037
10111
  if (p.level === 'top') {
10038
- if (item.arg) { $('input').value = '/' + item.cmd + ' '; refreshPalette(); }
10112
+ if (item.skill) { applySlash('skill', item.skill); $('input').value = $('input').value.replace(/^\s*\/\S*\s*/, ''); closePalette(); } // hyphen-safe strip of the "/skillname" token
10113
+ else if (item.arg) { $('input').value = '/' + item.cmd + ' '; refreshPalette(); }
10039
10114
  else { applySlash(item.cmd, null); $('input').value = stripLeadingCmd($('input').value); closePalette(); }
10040
10115
  } else {
10041
10116
  applySlash(p.cmd, item.v);
package/engine/server.mjs CHANGED
@@ -728,13 +728,22 @@ if (process.env.MANAGER_SEED_RUNNING_FIXTURE === '1') {
728
728
  // res -> identity (see identityFor), captured at connection time so every
729
729
  // SSE push can be scoped per-connection without re-deriving auth per event.
730
730
  const sseClients = new Map();
731
- // UI hot-reload: open tabs compare this stamp (index.html mtime + boot
732
- // time) via /api/state and the 30s SSE ping, and reload themselves when a
733
- // new UI ships — no manual reload after a hot-copy or a restart.
734
- const BOOT_STAMP = Date.now();
731
+ // UI hot-reload: open tabs compare this stamp (index.html mtime only) via
732
+ // /api/state and the 30s SSE ping, and reload themselves when a new UI ships
733
+ // — no manual reload after a hot-copy.
734
+ //
735
+ // It used to also fold in a per-process BOOT_STAMP (Date.now() at boot) so a
736
+ // bare restart would force a reload too. That backfired on the always-on
737
+ // resident: every relay-supervisor respawn / hot-copy restart, and every
738
+ // reconnect that landed on a *different* engine process (each with its own
739
+ // BOOT_STAMP), changed uiVersion and yanked a `location.reload()` on the 30s
740
+ // ping — the "reloads every 30-60s and resets to the default project" report
741
+ // (Masa 2026-07-24). mtime alone means a reload fires ONLY when app/index.html
742
+ // actually changes, and every process serving the same file agrees on the
743
+ // stamp, so restarts and multi-process relay churn no longer reload the tab.
735
744
  function uiVersion() {
736
- try { return `${Math.round(statSync(join(ROOT, 'app', 'index.html')).mtimeMs)}-${BOOT_STAMP}`; }
737
- catch { return String(BOOT_STAMP); }
745
+ try { return String(Math.round(statSync(join(ROOT, 'app', 'index.html')).mtimeMs)); }
746
+ catch { return '0'; }
738
747
  }
739
748
 
740
749
  // Broadcast to every connected client, unscoped — only for events that carry
@@ -4330,7 +4339,16 @@ const server = createServer(async (req, res) => {
4330
4339
  try {
4331
4340
  const { text } = JSON.parse(body);
4332
4341
  if (!text?.trim()) return json(res, 400, { error: 'text required' });
4333
- if (['partial', 'failed', 'interrupted', 'blocked'].includes(goal.status)) {
4342
+ // Reply picks a parked goal back up into the work queue. 'review' MUST be
4343
+ // here: a completed goal awaiting your approve/reject is 'review', and a
4344
+ // reply to it means "not done — keep working" (Masa 2026-07-24: replied to
4345
+ // an in-review card and it sat stuck in Review with no worker activity).
4346
+ // Without this flip the reply task was queued but the goal never left the
4347
+ // Review lane, so nothing looked like it was happening. Un-parking mirrors
4348
+ // the already-working 'blocked'/'failed'/etc. "Needs you" replies: the goal
4349
+ // flips to running and, since it still carries startedAt from its first run,
4350
+ // shows in Doing as "Working on it" — then the worker resumes its session.
4351
+ if (['review', 'partial', 'failed', 'interrupted', 'blocked'].includes(goal.status)) {
4334
4352
  goal.status = 'running';
4335
4353
  goal.blocked = null;
4336
4354
  saveGoal(goal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.48",
3
+ "version": "0.10.50",
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": {