@galda/cli 0.10.46 → 0.10.48

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
@@ -2052,6 +2052,12 @@
2052
2052
  — no re-layout, no flash. */
2053
2053
  .redesign .rst.working{color:var(--green)}
2054
2054
  .redesign .rst.working .rdot{background:var(--green)}
2055
+ /* A "Needs you" goal opened as this card reads its state, grounded in the flagship list's
2056
+ state colours: a stopped/failed error is red (--danger); interrupted (resumable) is amber. */
2057
+ .redesign .rst.err{color:var(--danger)}
2058
+ .redesign .rst.err .rdot{background:var(--danger)}
2059
+ .redesign .rst.int{color:var(--amber)}
2060
+ .redesign .rst.int .rdot{background:var(--amber)}
2055
2061
  .sa-item.working{box-shadow:0 1px 2px rgba(20,23,31,.05),0 0 0 1px color-mix(in oklab,var(--green) 22%,var(--hair))}
2056
2062
  .replynote .wkline .wkdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 0 3px color-mix(in oklab,var(--green) 22%,transparent);margin-right:9px;vertical-align:middle;position:relative;top:-1px}
2057
2063
  .redesign .rtitle{font-family:var(--ui);font-size:18px;font-weight:680;letter-spacing:-.018em;line-height:1.3;margin:8px 0 0 !important;color:var(--ink)}
@@ -4664,7 +4670,7 @@ function renderTasks(){
4664
4670
  // inline expand.
4665
4671
  if (el.dataset.attn) {
4666
4672
  const g = goalOf(state.tasks.find((t) => t.id === id));
4667
- if (g) { goToGoalInChat(g.id); openGoalDetail(g.id); }
4673
+ if (g) { goToGoalInChat(g.id); openGoalOrCard(g.id); }
4668
4674
  return;
4669
4675
  }
4670
4676
  state.expanded.has(id) ? state.expanded.delete(id) : state.expanded.add(id);
@@ -4686,7 +4692,7 @@ function renderTasks(){
4686
4692
  : goal;
4687
4693
  goToGoalInChat(opened.id);
4688
4694
  if (opened.status === 'review') openReviewChecklist(opened.id);
4689
- else openGoalDetail(opened.id);
4695
+ else openGoalOrCard(opened.id);
4690
4696
  });
4691
4697
  }
4692
4698
  for (const b of $('tasklist').querySelectorAll('.reqopen')) {
@@ -5878,6 +5884,13 @@ function openGoalDetail(goalId){
5878
5884
  renderGoalDetail();
5879
5885
  $('goalDetailOverlay').classList.add('show');
5880
5886
  }
5887
+ // "Needs you" (attention) goals open as the SAME review card (Masa 2026-07-24, Option A);
5888
+ // any other non-review goal keeps the goal-detail overlay.
5889
+ function openGoalOrCard(goalId){
5890
+ const g = state.goals.find((x) => x.id === goalId);
5891
+ if (g && ['blocked', 'partial', 'failed', 'interrupted'].includes(g.status) && typeof saOpen === 'function') { saOpen(goalId, true); return; }
5892
+ openGoalDetail(goalId);
5893
+ }
5881
5894
  function closeGoalDetail(){
5882
5895
  $('goalDetailOverlay').classList.remove('show');
5883
5896
  const goalId = state.goalDetailOpen;
@@ -6068,6 +6081,9 @@ async function sendDismiss(goalId, text){
6068
6081
  } catch { showErr('Dismiss failed.'); }
6069
6082
  $('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false;
6070
6083
  }
6084
+ // Residual goal-detail overlay path (non-review, non-attention goals — a done/running goal
6085
+ // opened for a note). "Needs you" goals no longer come here: they open the review card
6086
+ // (openGoalOrCard → saOpen solo), where the reply is the in-place "Working on it" thread.
6071
6087
  async function sendGoalInstruction(goalId, text){
6072
6088
  $('gdSend').disabled = true; $('gdDiscard').disabled = true;
6073
6089
  try {
@@ -6424,6 +6440,10 @@ function fsToggleLog(taskId){
6424
6440
  toggleGoalDetail(task.goalId);
6425
6441
  }
6426
6442
  function toggleGoalDetail(goalId){
6443
+ // "Needs you" (attention) goals open in the SAME review card, not the bespoke overlay
6444
+ // (Masa 2026-07-24, Option A). Everything else keeps the goal-detail overlay.
6445
+ const g = state.goals.find((x) => x.id === goalId);
6446
+ if (g && ['blocked', 'partial', 'failed', 'interrupted'].includes(g.status)) { saOpen(goalId, true); return; }
6427
6447
  const overlay = $('goalDetailOverlay');
6428
6448
  if (state.goalDetailOpen === goalId && overlay.classList.contains('show')) closeGoalDetail();
6429
6449
  else openGoalDetail(goalId);
@@ -6794,6 +6814,13 @@ function renderFsTodo(){
6794
6814
  // 一時停止したゴール(blocked kind:'paused')は Doing に「Paused」1行で留める(Masa 2026-07-19)。
6795
6815
  // Needs you 側(failed/blocked)へは出さず、その走行/中断タスク行も抑止して二重行を作らない。
6796
6816
  const pausedGoalIds = new Set(pgoals.filter((g) => g.status === 'blocked' && g.blocked?.kind === 'paused').map((g) => g.id));
6817
+ // A goal picked back up by a "Needs you" reply is `running` but may have no running WORK task
6818
+ // (only a reply task, which Doing excludes) — it used to vanish from the lane (Masa 2026-07-24).
6819
+ // Show it in Doing as a goal row, and stop its old stopped task from lingering under "Needs you".
6820
+ const runningTaskGoalIds = new Set(running.filter((t) => !t.reply).map((t) => t.goalId));
6821
+ const resumedGoalIds = new Set(pgoals.filter((g) => g.status === 'running' && g.startedAt).map((g) => g.id));
6822
+ const doingGoals = pgoals.filter((g) => g.status === 'running' && g.startedAt
6823
+ && !runningTaskGoalIds.has(g.id) && !pausedGoalIds.has(g.id));
6797
6824
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
6798
6825
  // LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
6799
6826
  // PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
@@ -6815,7 +6842,10 @@ function renderFsTodo(){
6815
6842
  running.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
6816
6843
  <div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('')
6817
6844
  // 一時停止したゴールは Doing に「Paused」1行(アンバー)で留める+Resume。走行/中断行は上と needsRows で抑止済み。
6818
- + pgoals.filter((g) => pausedGoalIds.has(g.id)).map((g) => `<div class="drow"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword int">Paused</span><button class="ab txt" data-fspause="1" title="Resume — 再開">Resume</button></div></div>`).join('');
6845
+ + pgoals.filter((g) => pausedGoalIds.has(g.id)).map((g) => `<div class="drow"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword int">Paused</span><button class="ab txt" data-fspause="1" title="Resume — 再開">Resume</button></div></div>`).join('')
6846
+ // A goal picked back up by a reply (running, no running work task yet): show it here so it
6847
+ // lands in Doing instead of vanishing. No % bar — nothing measurable is running yet.
6848
+ + doingGoals.map((g) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword" style="color:var(--green)">Working on it</span>${fsActs(g.id)}</div></div>`).join('');
6819
6849
  // NEEDS YOU: stopped/errored work that used to fold into Doing, now under its own caption so
6820
6850
  // it no longer reads as "still in flight" (Masa 2026-07-19). Three kinds share it, each
6821
6851
  // keeping the affordances it already had — nothing about the failed-task and blocked-goal
@@ -6832,7 +6862,7 @@ function renderFsTodo(){
6832
6862
  // both still call the same /retry endpoint, which already resumes the session when one
6833
6863
  // exists (server-side retry = "resume if possible, else re-run"; there is no separate
6834
6864
  // resume endpoint to call).
6835
- failed.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => { const isInt = t.status === 'interrupted';
6865
+ failed.filter((t) => !pausedGoalIds.has(t.goalId) && !resumedGoalIds.has(t.goalId)).map((t) => { const isInt = t.status === 'interrupted';
6836
6866
  const word = isInt ? 'Interrupted' : 'Failed', icon = isInt ? '⏸' : '✗', label = isInt ? 'Resume' : 'Retry';
6837
6867
  // don't restate the state word as the "reason" too (e.g. "Interrupted / Interrupted")
6838
6868
  // when the task has no distinct result text — the coloured word already said it, so
@@ -6873,8 +6903,8 @@ function renderFsTodo(){
6873
6903
  // goals) + Next up (queued) + Later (pending goals). "· next-up order" stays keyed off
6874
6904
  // queued.length specifically, since that suffix describes the Next-up sublist's own sort,
6875
6905
  // not the lane total.
6876
- const needsCount = failed.length + blocked.length + needsInput.length;
6877
- const todoTotal = running.length + needsCount + queued.length + later.length;
6906
+ const needsCount = failed.filter((t) => !resumedGoalIds.has(t.goalId)).length + blocked.length + needsInput.length;
6907
+ const todoTotal = running.length + doingGoals.length + needsCount + queued.length + later.length;
6878
6908
  // Free-tier pending counter (H22): on the free plan the header shows the wall
6879
6909
  // — pending goals out of the 5-slot cap — reddening at the cap and gone once
6880
6910
  // paid. Reads the SAME numbers the gate enforces (state.billing, never a local
@@ -8481,7 +8511,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
8481
8511
  // 4 archived / 5 reverted — session-local; the server round-trip is what
8482
8512
  // settles it (see saSync's "returned to review" reset).
8483
8513
  // ============================================================================
8484
- const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
8514
+ const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null, solo: null };
8485
8515
  window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
8486
8516
  const SA_SVG = {
8487
8517
  ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
@@ -8491,7 +8521,12 @@ const SA_SVG = {
8491
8521
  function saRows(){
8492
8522
  const gs = (state.goals || []).filter((g) => g.projectId === state.active);
8493
8523
  const ts = (state.tasks || []).filter((t) => t.projectId === state.active);
8494
- return buildReviewDigest({ goals: gs, tasks: ts }).map((r) => {
8524
+ return buildReviewDigest({ goals: gs, tasks: ts }).map((r) => saRowFromDigest(r, gs, ts));
8525
+ }
8526
+ // One digest-row → one See-all card item. Extracted from saRows (2026-07-24) so a single
8527
+ // "Needs you" goal can be opened as the SAME review card (saLedgerRows solo mode) — the card's
8528
+ // whole shape (Request/Result/Proof/Activity, empty sections auto-hidden) is reused verbatim.
8529
+ function saRowFromDigest(r, gs, ts){
8495
8530
  const g = gs.find((x) => x.id === r.goalIds[0]);
8496
8531
  const pt = ts.find((t) => r.goalIds.includes(t.goalId) && t.proof && (t.proof.png || t.proof.gif));
8497
8532
  // "What we did" (H23 §2, replaces the old Plan/Review-thread folds): one bullet
@@ -8616,7 +8651,44 @@ function saRows(){
8616
8651
  history: goalTokenHistoryLocal(gs, ts, g?.id),
8617
8652
  }),
8618
8653
  };
8619
- });
8654
+ }
8655
+ // A "Needs you" (attention) goal, shaped as a digest row so saRowFromDigest builds it the same
8656
+ // way as a review card. No PR-merge/plan (those are review-only); everything else falls out of
8657
+ // the goal + its tasks exactly as buildReviewDigest does for a review goal.
8658
+ function saSoloDigestRow(g, ts){
8659
+ const goalTasks = (ts ?? []).filter((t) => t.goalId === g.id);
8660
+ const proofTask = goalTasks.find((t) => t.proof);
8661
+ const checkLine = (g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').trim().slice(0, 66);
8662
+ return {
8663
+ goalIds: [g.id], reviewNumber: goalReviewNumber({ goal: g, tasks: ts }), checkLine,
8664
+ pr: g.pr ?? null, testResult: g.testResult ?? null,
8665
+ proof: proofTask?.proof ?? null, proofTaskId: proofTask?.id ?? null, plan: false, planText: null,
8666
+ };
8667
+ }
8668
+ // The See-all Ledger's cards. Normally the review digest (the "See all" batch stays review-only);
8669
+ // in solo mode (a single "Needs you" goal tapped) it is just that one goal's card, reusing the
8670
+ // exact review-card render (Masa 2026-07-24, Option A).
8671
+ function saLedgerRows(){
8672
+ if (SA.solo != null) {
8673
+ const gs = (state.goals || []).filter((g) => g.projectId === state.active);
8674
+ const ts = (state.tasks || []).filter((t) => t.projectId === state.active);
8675
+ const g = gs.find((x) => x.id === SA.solo);
8676
+ if (!g) return [];
8677
+ const row = saRowFromDigest(saSoloDigestRow(g, ts), gs, ts);
8678
+ // A stuck goal: surface WHY it stopped and what to do in the card's Result — the same
8679
+ // plain-language, language-following summary the old Attention panel showed
8680
+ // (summarizeAttention), mapped into the review card instead of a bespoke "What happened /
8681
+ // Your call" layout (Masa 2026-07-24). The Request section already shows the ask, so drop
8682
+ // the summary's "必要だったこと:/Needed:" lead to avoid repeating it.
8683
+ if (['blocked', 'partial', 'failed', 'interrupted'].includes(g.status)) {
8684
+ const s = summarizeAttention(g, ts.filter((t) => t.goalId === g.id));
8685
+ const why = String(s.happened || '').replace(/^\s*(必要だったこと:|Needed:)[^。.]*[。.]\s*/, '').trim();
8686
+ const merged = [why, s.decision].filter(Boolean).join('\n\n');
8687
+ if (merged) row.report = merged;
8688
+ }
8689
+ return [row];
8690
+ }
8691
+ return saRows();
8620
8692
  }
8621
8693
  // (P1, Masa 2026-07-21) saMetaRowHtml() was deleted here — the pre-redesign meta row,
8622
8694
  // uncalled since the review-card redesign. Its "Run locally" chip only ever opened a
@@ -8854,7 +8926,11 @@ function saWorkflowHtml(it){
8854
8926
  // merge→deploy, varying per person. There is no place to record such a pipeline yet,
8855
8927
  // so inventing one here would be this layer guessing someone's process. Left as a
8856
8928
  // decision, not faked.)
8857
- const steps = [['Implemented', 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['In review', 'now'], ['Merge', 'todo']];
8929
+ // A stopped / being-picked-back-up goal sits at Implemented it has NOT reached review, so
8930
+ // "In review" must not read as the current step (Masa 2026-07-24). Only a real review goal
8931
+ // marks Implemented done (✓) and In review as now (→).
8932
+ const atImplemented = it.st === 3 || ['blocked', 'partial', 'failed', 'interrupted', 'running'].includes(it.goal?.status);
8933
+ const steps = [['Implemented', atImplemented ? 'now' : 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['In review', atImplemented ? 'todo' : 'now'], ['Merge', 'todo']];
8858
8934
  return '<div class="rwf">' + steps.map(([s, st]) => `<div class="wf-s ${st}"><span class="mk">${st === 'ok' ? '✓' : st === 'now' ? '→' : '□'}</span>${s}</div>`).join('') + '</div>';
8859
8935
  }
8860
8936
  function saItemHtml(it, i){
@@ -9092,8 +9168,18 @@ function saItemHtml(it, i){
9092
9168
  // BELOW the box (the same .replynote surface the answer flow uses) — so sending only APPENDS
9093
9169
  // a line and never re-lays-out the card. It leaves Review only when the card is closed.
9094
9170
  const working = it.st === 3;
9171
+ // A "Needs you" (attention) goal opened as this card reads its real state, not "In review"
9172
+ // (Masa 2026-07-24). After a reply it becomes "Working on it" like any other card.
9173
+ const gstatus = it.goal?.status;
9174
+ const attn = !working && ['blocked', 'partial', 'failed', 'interrupted'].includes(gstatus);
9175
+ // Match the flagship list's state colours (Masa's Interrupt=amber / Failed=red rule): an
9176
+ // interrupted goal is amber+resumable, everything else stopped is a red error.
9177
+ const attnInt = gstatus === 'interrupted';
9178
+ const attnLabel = gstatus === 'interrupted' ? 'Interrupted' : gstatus === 'blocked' ? 'Blocked' : 'Failed';
9095
9179
  const rst = working
9096
9180
  ? '<span class="rst working"><span class="rdot"></span>Working on it</span>'
9181
+ : attn
9182
+ ? `<span class="rst ${attnInt ? 'int' : 'err'}"><span class="rdot"></span>${attnLabel}</span>`
9097
9183
  : '<span class="rst"><span class="rdot"></span>In review</span>';
9098
9184
  return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}${working ? ' working' : ''}" data-gid="${it.goalId}">
9099
9185
  <div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span>${rst}</div>
@@ -9112,7 +9198,7 @@ function saItemHtml(it, i){
9112
9198
  ${conversationSec}
9113
9199
  ${activitySec}
9114
9200
  ${S('Workflow', saWorkflowHtml(it))}
9115
- <div class="sa-act"><button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Dismiss</button><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" data-goal="${it.goalId}" placeholder="Reply, ask, or send back to the AI…"><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><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>
9201
+ <div class="sa-act">${attn ? '' : `<button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Dismiss</button>`}<div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" data-goal="${it.goalId}" placeholder="Reply, ask, or send back to the AI…"><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><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>
9116
9202
  </div>`;
9117
9203
  }
9118
9204
  // Scroll the See-all body so a card's top edge (.rhead) sits at the top of the visible
@@ -9180,10 +9266,15 @@ function saRender(){
9180
9266
  const S = $('seeall'); if (!S) return;
9181
9267
  const pending = SA.items.filter((x) => x.st === 0);
9182
9268
  const green = pending.filter((x) => !x.testResult || x.testResult.ok !== false).length;
9183
- const c = `${pending.length} ready · ${green === pending.length ? 'all green' : `${pending.length - green} failing`}`;
9269
+ // Solo mode = a single "Needs you" goal opened as this card. It is not a review batch, so the
9270
+ // header drops "Review / N ready / all green" and the Approve/Dismiss hints (Masa 2026-07-24).
9271
+ const solo = SA.solo != null;
9272
+ const hdTitle = solo ? 'Needs you' : 'Review';
9273
+ const c = solo ? '' : `${pending.length} ready · ${green === pending.length ? 'all green' : `${pending.length - green} failing`}`;
9184
9274
  const curIdx = pending.findIndex((x) => x.goalId === SA.cur);
9185
- const posLabel = pending.length && curIdx >= 0 ? `${curIdx + 1} / ${pending.length}` : '';
9186
- S.innerHTML = `<div class="sa-panel"><div class="sa-hd"><span class="t">Review</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">↑↓ MOVE · A APPROVE · D DISMISS · ESC CLOSE</span><button class="sa-x" data-saact="close:0">✕</button></div>
9275
+ const posLabel = solo ? '' : (pending.length && curIdx >= 0 ? `${curIdx + 1} / ${pending.length}` : '');
9276
+ const hdKeys = solo ? '↑↓ MOVE · ESC CLOSE' : '↑↓ MOVE · A APPROVE · D DISMISS · ESC CLOSE';
9277
+ S.innerHTML = `<div class="sa-panel"><div class="sa-hd"><span class="t">${hdTitle}</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">${hdKeys}</span><button class="sa-x" data-saact="close:0">✕</button></div>
9187
9278
  <div class="sa-body">${SA.items.length ? SA.items.map((it, i) => saItemHtml(it, i)).join('') : '<div class="sa-empty">Nothing waiting for review.</div>'}</div></div>`;
9188
9279
  // Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
9189
9280
  // track what's open per goal+section in SA.detOpen (persists across re-renders)
@@ -9237,7 +9328,7 @@ function saSync(){
9237
9328
  if (!SA.open) return;
9238
9329
  const ae = document.activeElement;
9239
9330
  if (ae && $('seeall').contains(ae) && ae.tagName === 'INPUT') return;
9240
- const rows = saRows();
9331
+ const rows = saLedgerRows();
9241
9332
  for (const row of rows) {
9242
9333
  const ex = SA.items.find((x) => x.goalId === row.goalId);
9243
9334
  // Preserve st===3 ("Working on it", 決定1-4): a reply held the card open in place; if a
@@ -9258,9 +9349,10 @@ async function saFetchDiffs(){
9258
9349
  }));
9259
9350
  if (SA.open) saSync();
9260
9351
  }
9261
- function saOpen(focusGoalId){
9352
+ function saOpen(focusGoalId, solo = false){
9262
9353
  SA.open = true;
9263
- SA.items = saRows().map((r) => ({ ...r, st: 0 }));
9354
+ SA.solo = solo ? focusGoalId : null; // solo = a single "Needs you" goal opened as one card
9355
+ SA.items = saLedgerRows().map((r) => ({ ...r, st: 0 }));
9264
9356
  SA.detOpen = new Set();
9265
9357
  // H14: focus a specific goal's card (single-review tap). Map the tapped goal
9266
9358
  // to the card that CONTAINS it (goals can group in the digest), then let
@@ -9289,7 +9381,7 @@ function saOpen(focusGoalId){
9289
9381
  saFetchDiffs();
9290
9382
  saEnhanceShotBoxes();
9291
9383
  }
9292
- function saClose(){ SA.open = false; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
9384
+ function saClose(){ SA.open = false; SA.solo = null; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
9293
9385
  // Continuous review (Masa 2026-07-08): move a persistent "current" card with
9294
9386
  // ↑↓ and auto-advance to the next pending after each verdict — same screen, same
9295
9387
  // Ledger, so approving flows without hunting for the next one.
@@ -9369,7 +9461,10 @@ async function saSendReply(it, i, text){
9369
9461
  // Append the line to the card's own thread immediately — this is what the person sees, and
9370
9462
  // its `seq` fixes its order regardless of when any round trip returns.
9371
9463
  it.rthread = [...(it.rthread || []), { who: 'you', text, seq }];
9372
- const followUp = it.st === 3 || it.inflight; // card already in conversation another instruction
9464
+ // A "Needs you" (attention) reply is a fix instruction, not a review verdict: it always goes
9465
+ // straight to /reply (no answer/unsure routing) and picks the goal back up (Masa 2026-07-24).
9466
+ const isAttn = ['blocked', 'partial', 'failed', 'interrupted'].includes(it.goal?.status);
9467
+ const followUp = it.st === 3 || it.inflight || isAttn; // card in conversation / attention → another instruction
9373
9468
 
9374
9469
  if (followUp) {
9375
9470
  it.st = 3; it.rstatus = 'working'; SA.cur = goalId;
@@ -9377,6 +9472,14 @@ async function saSendReply(it, i, text){
9377
9472
  try {
9378
9473
  const r = await fetch(withKey(`/api/goals/${goalId}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) });
9379
9474
  if (!r.ok) throw new Error('reply failed');
9475
+ if (isAttn) {
9476
+ // A Needs-you reply picks the goal back up → it belongs in Doing, not lingering in
9477
+ // "Needs you" or vanishing (Masa 2026-07-24). Flip it optimistically so the lane behind
9478
+ // the card updates on close; the SSE 'goal' event reconciles the same value.
9479
+ const g = state.goals.find((x) => x.id === goalId);
9480
+ if (g) { g.status = 'running'; g.blocked = null; if (!g.startedAt) g.startedAt = new Date().toISOString(); }
9481
+ render(); // saSync skips the focused card, so this only refreshes the lane behind it
9482
+ }
9380
9483
  } catch {
9381
9484
  it.rthread = it.rthread.filter((m) => m.seq !== seq); // roll back just this line
9382
9485
  if (!it.rthread.some((m) => m.who === 'you')) { it.st = 0; it.rstatus = null; }
@@ -9547,6 +9650,9 @@ document.addEventListener('keydown', (e) => {
9547
9650
  return saMove(e.key === 'ArrowDown' || e.key === 'j' ? 1 : -1);
9548
9651
  }
9549
9652
  if (!['a', 'A', 'd', 'D'].includes(e.key)) return;
9653
+ // A "Needs you" card (solo) has no Approve/Dismiss — the buttons are hidden, so the
9654
+ // keyboard shortcut must not fire an approve/reject on a stuck goal either (Masa 2026-07-24).
9655
+ if (SA.solo != null) return;
9550
9656
  // Act on the CURRENT card (fall back to the top pending), then auto-advance
9551
9657
  // focus to the next pending — so A A A blitzes down the queue with tempo.
9552
9658
  const it = SA.items.find((x) => x.goalId === SA.cur && x.st === 0) || SA.items.find((x) => x.st === 0);
package/app/theme.css CHANGED
@@ -21,7 +21,7 @@
21
21
  --tint:20,23,31;
22
22
  --hair:rgba(var(--tint),.10); --hair2:rgba(var(--tint),.16);
23
23
  --glass:rgb(255 255 255 / .72);
24
- --green-rgb:10,154,106; --blue-rgb:56,116,214; --amber-rgb:176,120,20; --danger-rgb:200,44,44;
24
+ --green-rgb:10,154,106; --blue-rgb:56,116,214; --amber-rgb:170,128,40; --danger-rgb:200,44,44;
25
25
  --purple-rgb:150,90,220; --glow-rgb:120,150,230;
26
26
  --green:rgb(var(--green-rgb)); --amber:rgb(var(--amber-rgb)); --blue:rgb(var(--blue-rgb)); --danger:rgb(var(--danger-rgb));
27
27
  --iri:linear-gradient(112deg,#b794f6 0%,#7cc4ff 38%,#5ce0c0 72%,#ffd27a 100%);
@@ -44,7 +44,7 @@ body[data-theme="banff"]{
44
44
  --tint:255,255,255;
45
45
  --hair:rgba(var(--tint),.09); --hair2:rgba(var(--tint),.16);
46
46
  --glass:rgba(12,13,17,.6);
47
- --green-rgb:79,215,156; --blue-rgb:124,196,255; --amber-rgb:224,180,92; --danger-rgb:240,115,111;
47
+ --green-rgb:79,215,156; --blue-rgb:124,196,255; --amber-rgb:228,198,120; --danger-rgb:240,115,111;
48
48
  --purple-rgb:183,148,246; --glow-rgb:146,180,255;
49
49
  --green:rgb(var(--green-rgb)); --amber:rgb(var(--amber-rgb)); --blue:rgb(var(--blue-rgb)); --danger:rgb(var(--danger-rgb));
50
50
  --on-accent:#0a0a0b; --invink:#0a0a0b;
@@ -62,7 +62,7 @@ body[data-theme="dark"],body[data-theme="midnight"],body[data-theme="cinema"]{
62
62
  --tint:255,255,255;
63
63
  --hair:rgba(var(--tint),.07); --hair2:rgba(var(--tint),.12);
64
64
  --glass:rgba(22,22,27,.55);
65
- --green-rgb:52,211,153; --blue-rgb:124,196,255; --amber-rgb:224,180,92; --danger-rgb:255,120,120;
65
+ --green-rgb:52,211,153; --blue-rgb:124,196,255; --amber-rgb:228,198,120; --danger-rgb:255,120,120;
66
66
  --purple-rgb:183,148,246; --glow-rgb:146,180,255;
67
67
  --on-accent:#0a0a0b; --invink:#0a0a0b;
68
68
  --send-bg:var(--iri); --send-ink:#0a0a0b;
@@ -92,7 +92,7 @@ body[data-theme="glass"]{
92
92
  --tint:255,255,255;
93
93
  --hair:rgba(255,255,255,.16); --hair2:rgba(255,255,255,.24);
94
94
  --glass:rgba(12,13,17,.6);
95
- --green-rgb:102,230,198; --blue-rgb:143,208,255; --amber-rgb:230,192,106; --danger-rgb:255,138,134;
95
+ --green-rgb:102,230,198; --blue-rgb:143,208,255; --amber-rgb:232,206,134; --danger-rgb:255,138,134;
96
96
  --purple-rgb:183,148,246; --glow-rgb:146,180,255;
97
97
  --green:rgb(var(--green-rgb)); --amber:rgb(var(--amber-rgb)); --blue:rgb(var(--blue-rgb)); --danger:rgb(var(--danger-rgb));
98
98
  --on-accent:#0a0a0b; --invink:#0a0a0b;
@@ -117,7 +117,7 @@ body[data-theme="gradient"]{
117
117
  --tint:20,23,31;
118
118
  --hair:rgba(var(--tint),.10); --hair2:rgba(var(--tint),.16);
119
119
  --glass:rgb(255 255 255 / .72);
120
- --green-rgb:79,215,156; --blue-rgb:37,99,235; --amber-rgb:184,134,11; --danger-rgb:240,115,111;
120
+ --green-rgb:79,215,156; --blue-rgb:37,99,235; --amber-rgb:178,140,40; --danger-rgb:240,115,111;
121
121
  --purple-rgb:150,90,220; --glow-rgb:120,150,230;
122
122
  --green:rgb(var(--green-rgb)); --amber:rgb(var(--amber-rgb)); --blue:rgb(var(--blue-rgb)); --danger:rgb(var(--danger-rgb));
123
123
  --on-accent:#0a0a0b; --invink:#ffffff;
package/engine/lib.mjs CHANGED
@@ -1086,7 +1086,15 @@ export function parseReviewSummary(raw, fallback = {}) {
1086
1086
  };
1087
1087
  }
1088
1088
 
1089
- const DOC_ONLY_RE = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/.*\.(md|mdx|txt)$|\.mdx?$|\.txt$/i;
1089
+ // A change is "doc-only" (skip the project test suite) when every file is
1090
+ // documentation. EVERYTHING under docs/ counts — not just .md: docs/ holds design
1091
+ // mockups (docs/design/**/*.html), handoffs, and images that the engine's tests
1092
+ // never import, so running the 58-file suite for them is pure waste. Before this,
1093
+ // only docs/**.md|mdx|txt was excused, so a CDO design-lab goal editing a single
1094
+ // docs/design/*.html re-ran the whole engine suite in its worktree and sat in
1095
+ // `running` ("Working on it") for 20+ minutes on a busy machine (dogfood 2026-07-24,
1096
+ // goal-632). Plus top-level README/CHANGELOG/LICENSE and any *.md/*.txt anywhere.
1097
+ const DOC_ONLY_RE = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/|\.mdx?$|\.txt$/i;
1090
1098
  export function hasTestRelevantChanges(changedFiles) {
1091
1099
  const files = (changedFiles ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
1092
1100
  if (!files.length) return true;
@@ -4126,6 +4134,30 @@ export function licenseTokenIdentity(token) {
4126
4134
  } catch { return ''; }
4127
4135
  }
4128
4136
 
4137
+ // Decode a license token's payload (unverified — signature is checked elsewhere,
4138
+ // offline via verifyLicenseToken with the baked-in public key). Returns null when
4139
+ // it can't be decoded. Used to read exp/iat/isPaying for the refresh scheduler.
4140
+ export function licenseTokenPayload(token) {
4141
+ if (!token || typeof token !== 'string') return null;
4142
+ const parts = token.split('.');
4143
+ if (parts.length !== 3) return null;
4144
+ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); }
4145
+ catch { return null; }
4146
+ }
4147
+
4148
+ // License tokens are short-lived (7 days) BY DESIGN — the engine is meant to fetch
4149
+ // a fresh one while online. Before this scheduler that fetch never happened, so a
4150
+ // paying user was silently logged out every 7 days (relay rejects the expired
4151
+ // token → the app shows "run npx @galda/cli" forever; dogfood 2026-07-24). Refresh
4152
+ // once the token is within `refreshWithinMs` of expiry (default 2 days) — INCLUDING
4153
+ // already-expired, so an agent that was offline past expiry still recovers on its
4154
+ // own without a manual sign-in (the Worker bounds how stale it may be). A token far
4155
+ // from expiry, or one that can't be decoded (not ours / malformed), is left alone.
4156
+ export function shouldRefreshLicense({ exp, now = Date.now(), refreshWithinMs = 2 * 24 * 3600 * 1000 } = {}) {
4157
+ if (typeof exp !== 'number' || !Number.isFinite(exp)) return false;
4158
+ return (exp * 1000 - now) < refreshWithinMs;
4159
+ }
4160
+
4129
4161
  // Pure reducer for the relay-client's reconnect decision. Returns the next state
4130
4162
  // and whether to dial the relay now. State: { stoodDown, standingIdentity }.
4131
4163
  // Events:
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 { needsProjectFolder, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
24
- import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, 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, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages } from './lib.mjs';
24
+ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, 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, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, 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, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages } from './lib.mjs';
25
25
  import { createSerialQueue } from './lib.mjs';
26
26
  import { collectProjectRules } from './lib.mjs';
27
27
  import { openPR } from './pr.mjs';
@@ -293,6 +293,46 @@ async function activateLicense(token) {
293
293
  return { ok: true, email: result.payload.email };
294
294
  }
295
295
 
296
+ // Keep the short-lived (7-day) license token fresh WHILE ONLINE so a paying user
297
+ // is never silently logged out when it expires. The token can only otherwise be
298
+ // re-minted by a full Google sign-in, so before this a $12/mo customer was
299
+ // dropped to "run npx @galda/cli" every 7 days (relay rejects the expired token;
300
+ // dogfood 2026-07-24). When the on-disk token is within ~2 days of expiry (or
301
+ // already expired but still refreshable), ask billing-api for a fresh one and
302
+ // persist it through activateLicense — which verifies the new token OFFLINE with
303
+ // the baked key before writing, so a bad/forged response can't wedge us. The
304
+ // relay-client watches license.token and reconnects the instant it changes, so
305
+ // the refresh re-authenticates the relay with NO user action. Best-effort: any
306
+ // failure (offline / 401 too-stale → needs sign-in / misconfig) leaves the
307
+ // current token untouched and retries next tick.
308
+ async function refreshLicenseIfNeeded() {
309
+ try {
310
+ if (!BILLING_API_URL || !existsSync(licenseFile)) return;
311
+ const token = readFileSync(licenseFile, 'utf8').trim();
312
+ const payload = licenseTokenPayload(token);
313
+ if (!payload || !shouldRefreshLicense({ exp: payload.exp })) return;
314
+ const controller = new AbortController();
315
+ const timer = setTimeout(() => controller.abort(), 10000);
316
+ let res;
317
+ try {
318
+ res = await fetch(`${BILLING_API_URL}/license/refresh`, {
319
+ method: 'POST', headers: { 'content-type': 'application/json' },
320
+ body: JSON.stringify({ token }), signal: controller.signal,
321
+ });
322
+ } finally { clearTimeout(timer); }
323
+ if (!res.ok) return; // 401 (too stale → a real sign-in is needed), 501 misconfig, 5xx — retry later
324
+ const body = await res.json().catch(() => null);
325
+ if (!body?.token || body.token === token) return; // nothing new
326
+ const r = await activateLicense(body.token);
327
+ if (r.ok) console.log(`[manager] license renewed for ${r.email} before expiry (no sign-in needed)`);
328
+ } catch { /* best-effort — keep the current token, retry next tick */ }
329
+ }
330
+ // Check on boot and twice a day. A 7-day token with a 2-day refresh window is
331
+ // renewed ~5 days in, long before the relay would reject it, as long as the
332
+ // engine is running at any point in that window.
333
+ refreshLicenseIfNeeded();
334
+ setInterval(refreshLicenseIfNeeded, 12 * 60 * 60 * 1000).unref?.();
335
+
296
336
  // The HTML shown in the popup after Google sign-in. It notifies the app window
297
337
  // (postMessage; the app validates origin+shape and refreshes) and closes
298
338
  // itself. Everything is inline + self-contained — this page can be opened
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.46",
3
+ "version": "0.10.48",
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": {