@galda/cli 0.10.71 → 0.10.73

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
@@ -213,7 +213,7 @@
213
213
 
214
214
  .errbar{position:absolute;left:50%;bottom:96px;transform:translateX(-50%);z-index:40;background:var(--errbg);backdrop-filter:blur(14px);
215
215
  border:1px solid rgba(var(--danger-rgb),.4);color:var(--danger);font-size:12.5px;border-radius:10px;padding:8px 14px;display:none}
216
- .errbar.ok{border-color:rgba(var(--green-rgb),.45);color:var(--green)} /* 07-20c: neutral/success toast (PR merged) */
216
+ .errbar.ok{background:rgba(var(--green-rgb),.12);border-color:rgba(var(--green-rgb),.45);color:var(--green)} /* 07-20c: neutral/success toast (PR merged) */
217
217
 
218
218
  /* composer */
219
219
  .composer-wrap{flex:0 0 auto;padding:8px 22px 18px}
@@ -296,21 +296,6 @@
296
296
  .ctxseg:hover{color:var(--ink)} /* glyph colour only — no surface (§5.5) */
297
297
  .ctxseg svg{width:13px;height:13px;flex:0 0 13px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
298
298
  .ctxseg b{font-weight:600;font-size:11.5px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;max-width:300px}
299
- .ctxdiv{width:1px;align-self:stretch;margin:5px 0;background:var(--hair);flex:0 0 1px}
300
- /* workspace mode — isolated worktree is the default; direct workspace is explicit.
301
- ON is --ink, never --green: in this product green means "it passed" (PR green,
302
- tests green, Token green), and a settings checkbox borrowing it makes that green cheap.
303
- The tick is knocked out in --panel (the surface it sits on), NOT --invink. */
304
- .ctxwt{font-size:11.5px;color:var(--ink2)}
305
- .ctxwt .ctxbox{width:11px;height:11px;flex:0 0 11px;border-radius:3px;border:1px solid var(--ink3);
306
- display:inline-block;position:relative;transition:background .12s,border-color .12s}
307
- .ctxwt[aria-checked="true"]{color:var(--ink2)}
308
- .ctxwt[aria-checked="true"] .ctxbox{background:var(--ink);border-color:var(--ink)}
309
- .ctxwt[aria-checked="true"] .ctxbox::after{content:"";position:absolute;left:3px;top:0;width:3px;height:7px;
310
- border:solid var(--panel);border-width:0 1.6px 1.6px 0;transform:rotate(42deg)}
311
- .ctxworkwarn{display:flex;align-items:center;gap:6px;margin:-3px 2px 6px;color:var(--amber);
312
- font:500 11.5px/1.35 var(--ui)}
313
- .ctxworkwarn[hidden]{display:none}
314
299
  .ctxmenu{min-width:230px;max-width:420px}
315
300
  /* The folder list is galda's own screen (Claude has none — it opens ONE folder; galda
316
301
  moves between repos). The one thing that actually goes wrong is that names alone look
@@ -1507,15 +1492,6 @@
1507
1492
  background:var(--ink);animation:ccblink 1.05s steps(1) infinite}
1508
1493
  @keyframes ccblink{50%{opacity:0}}
1509
1494
  @media (prefers-reduced-motion:reduce){#fsRoot .cc-caret{animation:none}}
1510
- /* "→ Added to To Do #NN" — shown quietly in the flow when a turn became a task.
1511
- Undo appears on hover only (§1.5-10). No filled surface. */
1512
- #fsRoot .cc-mark{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px;color:var(--ink3);padding:1px 0}
1513
- #fsRoot .cc-mark .cm-ar{color:var(--ink2);font-size:12.5px;line-height:1}
1514
- #fsRoot .cc-mark .cm-t{color:var(--ink2)}
1515
- #fsRoot .cc-mark .cm-no{color:var(--ink3);font-variant-numeric:tabular-nums}
1516
- #fsRoot .cc-mark .cm-undo{margin-left:2px;padding:0;border:0;background:none;font:inherit;color:var(--ink3);cursor:pointer;opacity:0;transition:opacity .15s,color .15s}
1517
- #fsRoot .cc-mark:hover .cm-undo{opacity:1}
1518
- #fsRoot .cc-mark .cm-undo:hover{color:var(--blue)}
1519
1495
  /* Claude Code CLI language (HANDOFF-v45 §12, flagship .cc-t port): gap:0 — the bullet's
1520
1496
  own margin-right does the spacing, not a flex gap (keeps ⏺ tight against the tool name
1521
1497
  the way the real CLI reads). */
@@ -2120,6 +2096,12 @@
2120
2096
  /* the note is body prose like every other section's — same 14px / −.012em / --ink as
2121
2097
  .rbody (Masa 2026-07-22: it was a shade lighter and read as a different kind of text) */
2122
2098
  .redesign .ropen .v{margin:4px 0 0 !important}
2099
+ /* A malformed .manager-run.json advisory (goal #733, Masa 2026-07-26): plain
2100
+ danger-red text, no box — this must not read as calm/resolved like a normal
2101
+ note (Masa: "めっちゃdoneっぽい"). Same --danger token .prfail already uses
2102
+ for a blocked PR, so it speaks the SAME "needs your attention" language the
2103
+ app already has, rather than inventing a new one. */
2104
+ .redesign .ropen.rdeclerr .v{color:var(--danger) !important}
2123
2105
  /* sections — Linear-docs bold sentence-case headings + high-contrast body.
2124
2106
  margins are !important: the app's pre-existing .lg / .sa-item rules load first and
2125
2107
  otherwise tie/beat these on specificity, flattening the Linear-docs rhythm. */
@@ -2128,6 +2110,10 @@
2128
2110
  /* body prose = the app's normal text: 14px / −.012em, matching .bubble (Masa 2026-07-20:
2129
2111
  13px read thinner/lighter for Japanese than the surrounding app). §2. */
2130
2112
  .redesign .rbody{font-size:14px;line-height:1.62;letter-spacing:-.012em;color:var(--ink);max-width:70ch;margin:0 !important}
2113
+ /* Request text is the human's own free-form message (goal #431): it can contain blank
2114
+ lines and numbered lists. esc() already neutralizes HTML, so pre-wrap just keeps the
2115
+ line breaks the person actually typed instead of HTML collapsing them into one line. */
2116
+ .redesign .rbody.rask{white-space:pre-wrap}
2131
2117
  .redesign .rinfra{padding:10px 12px;border:1px solid rgba(var(--amber-rgb),.35);border-radius:8px;background:rgba(var(--amber-rgb),.06)}
2132
2118
  .redesign .rinfra .rbody{color:var(--ink2)}
2133
2119
  .redesign .rinfra .retrybtn{margin-top:8px;margin-left:0}
@@ -2976,13 +2962,8 @@
2976
2962
  <div class="ctxchip ctxbrgrp" id="ctxbrgrp">
2977
2963
  <span class="ctxseg ctxbranch" id="ctxbranch" title="Branch this folder is on">
2978
2964
  <svg viewBox="0 0 24 24"><path d="M6 3v12M6 15a3 3 0 100 6 3 3 0 000-6zM18 9a3 3 0 100-6 3 3 0 000 6zM18 9v3a4 4 0 01-4 4H8"/></svg><span id="ctxbranchname"></span></span>
2979
- <span class="ctxdiv"></span>
2980
- <button type="button" class="ctxseg ctxwt" id="ctxwt" role="checkbox" aria-checked="true"
2981
- title="isolated worktree: default clean copy. direct workspace: inherits and edits this folder.">
2982
- <i class="ctxbox"></i><span id="ctxwtlabel">isolated worktree</span></button>
2983
2965
  </div>
2984
2966
  </div>
2985
- <div class="ctxworkwarn" id="ctxworkwarn" hidden></div>
2986
2967
  <!-- Conversation-first composer (SLICE 2, docs/HANDOFF-ONBOARDING-conversational.md):
2987
2968
  a chat/help/settings message gets a quiet reply HERE instead of
2988
2969
  becoming a To-Do. Reuses the .peraanswer surface; hidden until a
@@ -3369,8 +3350,20 @@ const savedTheme = (() => {
3369
3350
  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'], auth: null, expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, runningCounts: {}, parallelLimits: 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,
3370
3351
  // per-message send overrides set via slash-commands (reset after each send,
3371
3352
  // except model which is sticky in localStorage). palette = open command list.
3353
+ // Scoped per project (msgByProject) so a skill/mode picked in one project can't leak
3354
+ // into another one just switched to — state.msg is always the CURRENT project's slot;
3355
+ // switching projects must go through setActiveProject(), never a bare state.active = .
3372
3356
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
3357
+ msgByProject: {},
3373
3358
  stickyModel: localStorage.getItem('sel:model') || 'sonnet', stickyEffort: localStorage.getItem('sel:effort') || 'medium', palette: null };
3359
+ state.msgByProject[state.active] = state.msg;
3360
+ function emptyMsg(){ return { mode: null, model: null, effort: null, skill: null, later: false, review: false }; }
3361
+ function setActiveProject(id){
3362
+ if (id === state.active) return;
3363
+ state.msgByProject[state.active] = state.msg;
3364
+ state.active = id;
3365
+ state.msg = state.msgByProject[id] || (state.msgByProject[id] = emptyMsg());
3366
+ }
3374
3367
  window.state = state; // top-level const isn't a window property; external checks probe window.state
3375
3368
  const $ = (id) => document.getElementById(id);
3376
3369
 
@@ -3895,10 +3888,13 @@ function tokenBaselinePerRequirementLocal(history){
3895
3888
  return samples.reduce((a, b) => a + b, 0) / samples.length;
3896
3889
  }
3897
3890
  // Mirrors engine/lib.mjs estimateTokenOptimization() — see the comment there
3898
- // for why "saved X%" now compares against a real per-project baseline
3899
- // (basis: 'baseline') instead of always reusing this goal's own cache-hit
3900
- // ratio as a stand-in for "cheaper than usual" (basis: 'cache-weight',
3901
- // used only until there's enough finished-goal history).
3891
+ // for why a positive `baseline` percent is only labeled "saved" (basis:
3892
+ // 'baseline') when a real technique fired this run (cache reuse, a fresh
3893
+ // handoff, /compact, /clear). Without one, the same comparison ships as
3894
+ // basis: 'baseline-untied' a true fact (smaller/larger than usual) but
3895
+ // not a claim that anything the user did caused it (Masa 2026-07-26; a
3896
+ // small goal right after big ones scored a huge "saved" purely from the
3897
+ // size mix). With no history at all it's basis: 'no-baseline' (no number).
3902
3898
  function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null, requirementCount = 1, history = [] } = {}){
3903
3899
  const u = usage ?? {};
3904
3900
  const raw = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
@@ -3907,15 +3903,11 @@ function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, c
3907
3903
  const reqN = Math.max(1, Number(requirementCount) || 1);
3908
3904
  const perReq = weighted / reqN;
3909
3905
  const baseline = tokenBaselinePerRequirementLocal(history);
3910
- let percent, basis, savedTokens;
3906
+ let percent = null, basis = 'no-baseline', savedTokens = null;
3911
3907
  if (baseline != null) {
3912
3908
  percent = Math.max(-100, Math.min(100, Math.round(((baseline - perReq) / baseline) * 100)));
3913
3909
  basis = 'baseline';
3914
3910
  savedTokens = Math.max(0, Math.round((baseline - perReq) * reqN));
3915
- } else {
3916
- savedTokens = Math.max(0, raw - weighted);
3917
- percent = Math.round((savedTokens / raw) * 100);
3918
- basis = 'cache-weight';
3919
3911
  }
3920
3912
  const techniques = [];
3921
3913
  const cacheRatio = Math.round(((u.cache_read_input_tokens ?? 0) / raw) * 100);
@@ -3923,8 +3915,10 @@ function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, c
3923
3915
  if (usedHandoff || sessionMode === 'cold-handoff') techniques.push('fresh handoff instead of a bloated session');
3924
3916
  if (usedCompact || sessionMode === 'compact') techniques.push('/compact before continuing');
3925
3917
  if (usedClear) techniques.push('/clear between unrelated goals');
3926
- if (agentModel) techniques.push(`${agentModel} for this run`);
3918
+ const techniqueDriven = cacheRatio > 0 || usedHandoff || sessionMode === 'cold-handoff' || usedCompact || sessionMode === 'compact' || usedClear;
3919
+ if (basis === 'baseline' && !techniqueDriven) basis = 'baseline-untied';
3927
3920
  if (basis === 'baseline' && percent > 0) techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
3921
+ if (basis === 'baseline-untied') techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
3928
3922
  if (!techniques.length) techniques.push(`${Math.round(raw / 1000)}k tokens this run (no cache reuse yet)`);
3929
3923
  return { rawTokens: raw, weightedTokens: weighted, savedTokens, percent, basis, techniques: [...new Set(techniques)] };
3930
3924
  }
@@ -3932,6 +3926,10 @@ function tokenOptimizationHtml(opt){
3932
3926
  if (!opt) return '';
3933
3927
  const techniques = opt.techniques?.length ? opt.techniques.slice(0, 4).join(' / ') : 'this run';
3934
3928
  const pct = Number(opt.percent);
3929
+ if (opt.basis === 'baseline-untied') {
3930
+ const dir = pct > 0 ? 'smaller' : 'larger';
3931
+ return `<div class="rvtokenopt">${esc(`Token usage: this run was ${dir} than your recent average (${techniques}) — no cache/compact/handoff technique behind it`)}</div>`;
3932
+ }
3935
3933
  const cmp = opt.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
3936
3934
  const text = pct > 0
3937
3935
  ? `Token optimized: saved about ${pct}% ${cmp} (${techniques})`
@@ -3943,7 +3941,7 @@ function renderProjects(){
3943
3941
  const busy = state.tasks.filter((t) => t.projectId === p.id && ['running','queued'].includes(t.status)).length;
3944
3942
  return `<button class="proj ${p.id === state.active ? 'active' : ''}" data-id="${p.id}">${esc(p.name)}${busy ? `<span class="n">${busy}</span>` : ''}</button>`;
3945
3943
  }).join('');
3946
- for (const b of document.querySelectorAll('.proj')) b.onclick = () => { state.active = b.dataset.id; render(); };
3944
+ for (const b of document.querySelectorAll('.proj')) b.onclick = () => { setActiveProject(b.dataset.id); render(); };
3947
3945
  }
3948
3946
 
3949
3947
  function proofMediaHtml(taskId, proof, altLabel){
@@ -4366,8 +4364,9 @@ function renderStream(){
4366
4364
  inp.addEventListener('keydown', async (e) => {
4367
4365
  if (e.key !== 'Enter' || e.isComposing || !inp.value.trim()) return;
4368
4366
  const text = inp.value.trim(); inp.value = ''; inp.disabled = true;
4369
- const r = await fetch(withKey(`/api/goals/${inp.dataset.goal}/reply`), { method: 'POST',
4370
- headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text })) });
4367
+ let r; try { r = await fetch(withKey(`/api/goals/${inp.dataset.goal}/reply`), { method: 'POST',
4368
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text })) }); }
4369
+ catch { showErr('Reply failed — the goal may not have started yet.'); inp.disabled = false; return; }
4371
4370
  if (!r.ok) showErr('Reply failed — the goal may not have started yet.');
4372
4371
  inp.disabled = false;
4373
4372
  });
@@ -4492,18 +4491,22 @@ function renderQueue(){
4492
4491
  const text = document.getElementById(`qedit-${id}`).value.trim();
4493
4492
  const pr = document.getElementById(`qedit-pr-${id}`).dataset.val;
4494
4493
  if (text) {
4495
- const r = await fetch(withKey(`/api/goals/${id}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, pr }) });
4496
- if (r.ok) upsert(state.goals, await r.json());
4494
+ try {
4495
+ const r = await fetch(withKey(`/api/goals/${id}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, pr }) });
4496
+ if (r.ok) upsert(state.goals, await r.json()); else showErr('Save failed.');
4497
+ } catch { showErr('Save failed.'); }
4497
4498
  }
4498
4499
  state.editing = null; render();
4499
4500
  };
4500
4501
  for (const b of q.querySelectorAll('.qp')) b.onclick = async () => {
4501
- const r = await fetch(withKey(`/api/goals/${b.dataset.id}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promote: true }) });
4502
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.id}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promote: true }) }); }
4503
+ catch { showErr('Too late — this goal already started.'); render(); return; }
4502
4504
  if (r.ok) upsert(state.goals, await r.json()); else showErr('Too late — this goal already started.');
4503
4505
  render();
4504
4506
  };
4505
4507
  for (const b of q.querySelectorAll('.qd')) b.onclick = async () => {
4506
- const r = await fetch(withKey(`/api/goals/${b.dataset.id}`), { method: 'DELETE' });
4508
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.id}`), { method: 'DELETE' }); }
4509
+ catch { showErr('Too late — this goal already started.'); await refresh(); render(); return; }
4507
4510
  if (r.ok) state.goals = state.goals.filter((g) => g.id !== Number(b.dataset.id));
4508
4511
  else { showErr('Too late — this goal already started.'); await refresh(); }
4509
4512
  render();
@@ -4718,15 +4721,19 @@ function renderTasks(){
4718
4721
  const id = Number(el.dataset.task);
4719
4722
  const rb = e.target.closest('[data-retrybtn]');
4720
4723
  if (rb) {
4721
- const r = await fetch(withKey(`/api/tasks/${rb.dataset.retrybtn}/retry`), { method: 'POST' });
4722
- if (!r.ok) showErr('Retry failed.'); else await refresh();
4724
+ try {
4725
+ const r = await fetch(withKey(`/api/tasks/${rb.dataset.retrybtn}/retry`), { method: 'POST' });
4726
+ if (!r.ok) showErr('Retry failed.'); else await refresh();
4727
+ } catch { showErr('Retry failed.'); }
4723
4728
  return;
4724
4729
  }
4725
4730
  const sb = e.target.closest('[data-skipbtn]');
4726
4731
  if (sb) {
4727
- const r = await fetch(withKey(`/api/tasks/${sb.dataset.skipbtn}/skip`), { method: 'POST',
4728
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reason: 'Dismissed from Attention.' }) });
4729
- if (!r.ok) showErr('Dismiss failed.'); else await refresh();
4732
+ try {
4733
+ const r = await fetch(withKey(`/api/tasks/${sb.dataset.skipbtn}/skip`), { method: 'POST',
4734
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reason: 'Dismissed from Attention.' }) });
4735
+ if (!r.ok) showErr('Dismiss failed.'); else await refresh();
4736
+ } catch { showErr('Dismiss failed.'); }
4730
4737
  return;
4731
4738
  }
4732
4739
  // Attention rows open the same detail dialog as Review (tap anywhere but
@@ -4770,11 +4777,13 @@ function renderTasks(){
4770
4777
  b.onclick = async (e) => { e.stopPropagation();
4771
4778
  const bx = b.getBoundingClientRect(); // capture before await/re-render — burst FROM the ✓ button
4772
4779
  let ok = true, mergeGoal = null;
4773
- for (const id of b.dataset.qapprove.split(',')) {
4774
- const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
4775
- if (!r.ok){ ok = false; break; }
4776
- const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
4777
- }
4780
+ try {
4781
+ for (const id of b.dataset.qapprove.split(',')) {
4782
+ const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
4783
+ if (!r.ok){ ok = false; break; }
4784
+ const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
4785
+ }
4786
+ } catch { ok = false; }
4778
4787
  if (!ok){ showErr('Approve failed.'); return; }
4779
4788
  await refresh();
4780
4789
  if (mergeGoal){ confirmMerge(mergeGoal); return; } // merge instead of confetti
@@ -4784,19 +4793,24 @@ function renderTasks(){
4784
4793
  // 07-20c: the inline ✕ is a pure Reject — park the goal in the Rejected column (/reject),
4785
4794
  // no auto-rework. Rework is asked for via chat.
4786
4795
  b.onclick = async (e) => { e.stopPropagation();
4787
- let r; for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
4796
+ let r; try { for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' }); }
4797
+ catch { showErr('Dismiss failed.'); return; }
4788
4798
  qrefresh(r, 'Dismiss failed.'); };
4789
4799
  }
4790
4800
  // Archive: retire an unfinished goal cleanly (no rework spawned). The escape
4791
4801
  // hatch for half-finished (partial/blocked) items so Attention stays drainable.
4792
4802
  for (const b of $('tasklist').querySelectorAll('[data-archivegoal]')) {
4793
4803
  b.onclick = async (e) => { e.stopPropagation();
4794
- qrefresh(await fetch(withKey(`/api/goals/${b.dataset.archivegoal}/archive`), { method: 'POST' }), 'Archive failed.'); };
4804
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.archivegoal}/archive`), { method: 'POST' }); }
4805
+ catch { showErr('Archive failed.'); return; }
4806
+ qrefresh(r, 'Archive failed.'); };
4795
4807
  }
4796
4808
  // Manager-verified gate: re-run tests + re-capture proof for a blocked goal.
4797
4809
  for (const b of $('tasklist').querySelectorAll('[data-goalreverify]')) {
4798
4810
  b.onclick = async (e) => { e.stopPropagation(); b.disabled = true; b.textContent = '再検証中…';
4799
- qrefresh(await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' }), '再検証に失敗'); };
4811
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' }); }
4812
+ catch { showErr('再検証に失敗'); return; }
4813
+ qrefresh(r, '再検証に失敗'); };
4800
4814
  }
4801
4815
  for (const b of $('tasklist').querySelectorAll('[data-qreply]')) {
4802
4816
  b.onclick = (e) => { e.stopPropagation();
@@ -4839,14 +4853,16 @@ function renderTasks(){
4839
4853
  for (const b of $('tasklist').querySelectorAll('[data-pstart]')) {
4840
4854
  b.onclick = async (e) => {
4841
4855
  e.stopPropagation();
4842
- const r = await fetch(withKey(`/api/goals/${b.dataset.pstart}/activate`), { method: 'POST' });
4856
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.pstart}/activate`), { method: 'POST' }); }
4857
+ catch { showErr('Start failed.'); return; }
4843
4858
  if (!r.ok) showErr('Start failed.'); else await refresh();
4844
4859
  };
4845
4860
  }
4846
4861
  for (const b of $('tasklist').querySelectorAll('[data-pdel]')) {
4847
4862
  b.onclick = async (e) => {
4848
4863
  e.stopPropagation();
4849
- const r = await fetch(withKey(`/api/goals/${b.dataset.pdel}`), { method: 'DELETE' });
4864
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.pdel}`), { method: 'DELETE' }); }
4865
+ catch { showErr('Delete failed.'); return; }
4850
4866
  if (!r.ok) showErr('Delete failed.'); else await refresh();
4851
4867
  };
4852
4868
  }
@@ -4858,13 +4874,15 @@ function renderTasks(){
4858
4874
  e.stopPropagation();
4859
4875
  const preview = b.closest('.goalhead')?.querySelector('.gt')?.textContent || '';
4860
4876
  if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
4861
- const r = await fetch(withKey(`/api/goals/${b.dataset.ghdel}`), { method: 'DELETE' });
4877
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.ghdel}`), { method: 'DELETE' }); }
4878
+ catch { showErr('Cannot delete a running/finished goal — reply to append instead'); return; }
4862
4879
  if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh();
4863
4880
  };
4864
4881
  // Smart intake: answer a planner clarifying question (chip or free text) → re-plan.
4865
4882
  const answerQ = async (id, answer) => {
4866
4883
  if (!answer) return;
4867
- const r = await fetch(withKey(`/api/goals/${id}/answer`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) });
4884
+ let r; try { r = await fetch(withKey(`/api/goals/${id}/answer`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) }); }
4885
+ catch { showErr('Failed to send answer'); return; }
4868
4886
  if (!r.ok) showErr('Failed to send answer'); else await refresh();
4869
4887
  };
4870
4888
  for (const b of $('tasklist').querySelectorAll('[data-qanswer]')) b.onclick = (e) => { e.stopPropagation(); answerQ(Number(b.dataset.qanswer), b.dataset.answer); };
@@ -4961,7 +4979,8 @@ function openGoalheadReply(id, btn){
4961
4979
  const snd = document.createElement('button'); snd.className = 'qbtn ok'; snd.textContent = '↑';
4962
4980
  wrap.appendChild(inp); wrap.appendChild(snd); head.insertAdjacentElement('afterend', wrap); inp.focus();
4963
4981
  const send = async () => { const t = inp.value.trim(); if (!t) return;
4964
- const r = await fetch(withKey(`/api/goals/${id}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text: t })) });
4982
+ let r; try { r = await fetch(withKey(`/api/goals/${id}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text: t })) }); }
4983
+ catch { showErr('未開始のゴールは編集を使ってください'); return; }
4965
4984
  if (!r.ok) showErr('未開始のゴールは編集を使ってください'); await refresh(); };
4966
4985
  inp.onkeydown = (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); send(); } if (e.key === 'Escape') wrap.remove(); };
4967
4986
  snd.onclick = send;
@@ -4984,9 +5003,11 @@ function wirePriority(){
4984
5003
  b.onclick = async (e) => {
4985
5004
  e.stopPropagation();
4986
5005
  b.closest('.priomenu')?.classList.remove('show');
4987
- const r = await fetch(withKey(`/api/tasks/${b.dataset.priotask}/priority`), { method: 'PUT',
4988
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ priority: b.dataset.val }) });
4989
- if (!r.ok) showErr('優先度の変更に失敗しました。'); else await refresh();
5006
+ try {
5007
+ const r = await fetch(withKey(`/api/tasks/${b.dataset.priotask}/priority`), { method: 'PUT',
5008
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ priority: b.dataset.val }) });
5009
+ if (!r.ok) showErr('優先度の変更に失敗しました。'); else await refresh();
5010
+ } catch { showErr('優先度の変更に失敗しました。'); }
4990
5011
  };
4991
5012
  }
4992
5013
  }
@@ -5182,26 +5203,31 @@ $('wfHeaderEdit').onclick = (e) => { e.stopPropagation(); openWfEditor(); };
5182
5203
  $('wfoverlay').addEventListener('click', (e) => { if (e.target === $('wfoverlay')) $('wfoverlay').classList.remove('show'); });
5183
5204
  $('wfsave').onclick = async () => {
5184
5205
  const columns = state.wfDraft.map((c) => ({ key: c.key, label: c.label.trim(), bucket: c.bucket }));
5185
- const r = await fetch(withKey(`/api/projects/${state.active}/columns`), { method: 'PUT',
5186
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ columns }) });
5187
- const data = await r.json().catch(() => ({}));
5188
- if (!r.ok) {
5189
- $('wferr').textContent = data.error || 'Save failed.';
5190
- $('wferr').style.display = 'block';
5191
- return;
5192
- }
5193
- const rdr = await fetch(withKey(`/api/projects/${state.active}/review-definition`), { method: 'PUT',
5194
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reviewDefinition: state.rdDraft }) });
5195
- const rdData = await rdr.json().catch(() => ({}));
5196
- if (!rdr.ok) {
5197
- $('wferr').textContent = rdData.error || 'Save failed.';
5206
+ try {
5207
+ const r = await fetch(withKey(`/api/projects/${state.active}/columns`), { method: 'PUT',
5208
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ columns }) });
5209
+ const data = await r.json().catch(() => ({}));
5210
+ if (!r.ok) {
5211
+ $('wferr').textContent = data.error || 'Save failed.';
5212
+ $('wferr').style.display = 'block';
5213
+ return;
5214
+ }
5215
+ const rdr = await fetch(withKey(`/api/projects/${state.active}/review-definition`), { method: 'PUT',
5216
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reviewDefinition: state.rdDraft }) });
5217
+ const rdData = await rdr.json().catch(() => ({}));
5218
+ if (!rdr.ok) {
5219
+ $('wferr').textContent = rdData.error || 'Save failed.';
5220
+ $('wferr').style.display = 'block';
5221
+ return;
5222
+ }
5223
+ state.workflowColumns[state.active] = data.columns;
5224
+ state.reviewDefinitions[state.active] = rdData.reviewDefinition;
5225
+ $('wfoverlay').classList.remove('show');
5226
+ render();
5227
+ } catch {
5228
+ $('wferr').textContent = 'Save failed.';
5198
5229
  $('wferr').style.display = 'block';
5199
- return;
5200
5230
  }
5201
- state.workflowColumns[state.active] = data.columns;
5202
- state.reviewDefinitions[state.active] = rdData.reviewDefinition;
5203
- $('wfoverlay').classList.remove('show');
5204
- render();
5205
5231
  };
5206
5232
 
5207
5233
  function wireWfEditButtons(){
@@ -5426,7 +5452,8 @@ function renderReviewChecklist(){
5426
5452
  e.stopPropagation();
5427
5453
  b.disabled = true;
5428
5454
  b.textContent = '再検証中…';
5429
- const r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' });
5455
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' }); }
5456
+ catch { showErr('再検証に失敗'); b.disabled = false; return; }
5430
5457
  if (r.ok) await refresh(); else showErr('再検証に失敗');
5431
5458
  };
5432
5459
  }
@@ -5438,9 +5465,10 @@ function renderReviewChecklist(){
5438
5465
  if (!text) return;
5439
5466
  const t = reqs.find((r) => r.id === taskId);
5440
5467
  b.disabled = true; input.disabled = true;
5441
- const r = await fetch(withKey(`/api/goals/${goalId}/reply`), { method: 'POST',
5468
+ let r; try { r = await fetch(withKey(`/api/goals/${goalId}/reply`), { method: 'POST',
5442
5469
  headers: { 'content-type': 'application/json' },
5443
- body: JSON.stringify(replyWorkerPayload({ text: `[Fix request for requirement #${t?.num ?? ''} "${t?.title ?? ''}"] ${text}` })) });
5470
+ body: JSON.stringify(replyWorkerPayload({ text: `[Fix request for requirement #${t?.num ?? ''} "${t?.title ?? ''}"] ${text}` })) }); }
5471
+ catch { showErr('Failed to send the fix request.'); b.disabled = false; input.disabled = false; return; }
5444
5472
  if (r.ok) { input.value = ''; input.placeholder = 'Sent — the worker will pick it up'; }
5445
5473
  else showErr('Failed to send the fix request.');
5446
5474
  b.disabled = false; input.disabled = false;
@@ -6308,7 +6336,16 @@ const FS_ICONS = {
6308
6336
  // per-project count icons (HANDOFF-v45 §10): To Do = rounded square, Review = Scan
6309
6337
  // (magnifier) — both stroked, NEUTRAL ink (never green); shape is the only distinction.
6310
6338
  const FS_ICO_TD = '<svg class="pcg" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="3.5"/></svg>';
6311
- const FS_ICO_SCAN = '<svg class="pcg" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="5.5"/><path d="M19 19l-4.5-4.5"/></svg>';
6339
+ // Eye, not magnifying glass or checkmark (Masa 2026-07-26): the magnifying-glass
6340
+ // read as "search". The first replacement — a square with a checkmark inside,
6341
+ // matching FS_ICO_TD's (To Do) frame — read as "done" instead ("あれ、これだと
6342
+ // めっちゃdoneっぽいわ"): a checkmark is universally a completion signal, and
6343
+ // pairing it with To Do's exact square made it worse, not better. An eye carries
6344
+ // neither "search" nor "done" — it says "look at this", which is what Review
6345
+ // actually is. Standard minimal eye glyph (outline + pupil), legible at the real
6346
+ // 11px render size (verified: a smaller pupil or added detail merges into a
6347
+ // blob at that size — this is the simplest shape that still reads as an eye).
6348
+ const FS_ICO_SCAN = '<svg class="pcg" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
6312
6349
  // hover-only Reply / Edit / Delete on a To Do row (§1.5-10; flagship ACTS port)
6313
6350
  function fsActs(goalId){
6314
6351
  if (goalId == null || !state.goals.some((g) => g.id === goalId)) return '';
@@ -6352,7 +6389,7 @@ function renderFsRail(){
6352
6389
  el.innerHTML = html;
6353
6390
  for (const b of el.querySelectorAll('[data-fsproj]')) b.onclick = (e) => {
6354
6391
  if (e.target.closest('.pname.editing') || b.querySelector('.pname.editing')) return;
6355
- state.active = b.dataset.fsproj; document.body.classList.remove('projectsopen'); render();
6392
+ setActiveProject(b.dataset.fsproj); document.body.classList.remove('projectsopen'); render();
6356
6393
  };
6357
6394
  const add = $('fsProjAdd'); if (add) add.onclick = createProject;
6358
6395
  }
@@ -6523,11 +6560,6 @@ function renderFsFeed(){
6523
6560
  let t = '';
6524
6561
  if (turn.role === 'you') {
6525
6562
  t += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
6526
- if (turn.markGoalId != null && !turn.undone) {
6527
- const mg = state.goals.find((x) => x.id === turn.markGoalId);
6528
- const n = mg ? goalReviewNumber({ goal: mg, tasks: state.tasks }) : turn.markGoalId;
6529
- 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>`;
6530
- }
6531
6563
  } else { // Claude Code reply — speaker line first (You → speaker → reply), then the text
6532
6564
  const app = connectedAppMeta(turn.agent);
6533
6565
  t += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
@@ -6580,24 +6612,12 @@ function renderFsFeed(){
6580
6612
  // Delete a "needs your answer" card outright (restores 「Needs your answer」カードを削除
6581
6613
  // できるように — a question you don't want to answer had no discard path in flagship).
6582
6614
  for (const b of feed.querySelectorAll('[data-fsanswerdel]')) b.onclick = (e) => { e.stopPropagation(); fsTaskDelete(Number(b.dataset.fsanswerdel)); };
6583
- // Pattern A (H23): Undo a "→ Added to To Do" marker — cancel the created goal
6584
- // and drop the mark. A just-created (planning) goal is deletable; if it already
6585
- // started the server returns non-OK (canDeleteGoal) and we say so quietly.
6586
- for (const b of feed.querySelectorAll('[data-ccundo]')) b.onclick = async (e) => {
6587
- e.stopPropagation();
6588
- const gid = Number(b.dataset.ccundo);
6589
- const r = await fetch(withKey(`/api/goals/${gid}`), { method: 'DELETE' });
6590
- if (!r.ok) { showErr('Could not undo — the task may already be running'); return; }
6591
- const turn = (state.chatTurns[state.active] || []).find((t) => t.markGoalId === gid);
6592
- if (turn) turn.undone = true;
6593
- state.goals = state.goals.filter((g) => g.id !== gid);
6594
- render();
6595
- };
6596
6615
  }
6597
6616
  async function fsAnswerQuestion(goalId, answer){
6598
6617
  if (!answer) return;
6599
- const r = await fetch(withKey(`/api/goals/${goalId}/answer`), { method: 'POST',
6600
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) });
6618
+ let r; try { r = await fetch(withKey(`/api/goals/${goalId}/answer`), { method: 'POST',
6619
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) }); }
6620
+ catch { showErr('Failed to send answer'); return; }
6601
6621
  if (!r.ok) showErr('Failed to send answer'); else await refresh();
6602
6622
  }
6603
6623
  async function fsRetryTask(id){
@@ -6974,17 +6994,20 @@ function renderFsReview(){
6974
6994
  // "Also carries" section (saItemHtml + the seeall handler).
6975
6995
  for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
6976
6996
  let ok = true, mergeGoal = null;
6977
- for (const id of b.dataset.fsapprove.split(',')) {
6978
- const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
6979
- if (!r.ok){ ok = false; break; }
6980
- const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
6981
- }
6997
+ try {
6998
+ for (const id of b.dataset.fsapprove.split(',')) {
6999
+ const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
7000
+ if (!r.ok){ ok = false; break; }
7001
+ const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
7002
+ }
7003
+ } catch { ok = false; }
6982
7004
  if (!ok){ showErr('Approve failed.'); return; }
6983
7005
  await refresh();
6984
7006
  if (mergeGoal) confirmMerge(mergeGoal); else celebrateApprove(); };
6985
7007
  for (const b of el.querySelectorAll('[data-fsdismiss]')) b.onclick = async (e) => { e.stopPropagation();
6986
7008
  // 07-20c: the review-card ✕ is a pure Reject → Rejected column (/reject), no auto-rework.
6987
- let r; for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
7009
+ let r; try { for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' }); }
7010
+ catch { showErr('Dismiss failed.'); return; }
6988
7011
  if (r && r.ok) await refresh(); else showErr('Dismiss failed.'); };
6989
7012
  }
6990
7013
  // Shared To Do bucketing (list rows AND the kanban board read the same groups —
@@ -7275,14 +7298,16 @@ function renderFsTodo(){
7275
7298
  if (Number.isFinite(id)) fsOpenGoalDetail(id);
7276
7299
  };
7277
7300
  for (const b of el.querySelectorAll('[data-fsreopen]')) b.onclick = async (e) => { e.stopPropagation();
7278
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsreopen}/reopen`), { method: 'POST' });
7301
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreopen}/reopen`), { method: 'POST' }); }
7302
+ catch { showErr('Reopen failed.'); return; }
7279
7303
  if (!r.ok) showErr('Reopen failed.'); else await refresh(); };
7280
7304
  for (const b of el.querySelectorAll('[data-fsrejdel]')) b.onclick = async (e) => { e.stopPropagation();
7281
7305
  const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
7282
7306
  // Delete on a rejected goal = revert: it closes the PR + drops the worktree, so a rejected
7283
7307
  // goal that had a PR doesn't leave an orphan PR behind (DELETE would 409 — rejected isn't deletable).
7284
7308
  if (!(await confirmDelete({ title: 'Discard this rejected goal?', preview }))) return;
7285
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsrejdel}/revert`), { method: 'POST' });
7309
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsrejdel}/revert`), { method: 'POST' }); }
7310
+ catch { showErr('Delete failed.'); return; }
7286
7311
  if (!r.ok) showErr('Delete failed.'); else await refresh(); };
7287
7312
  const more = el.querySelector('#fsTodoMore');
7288
7313
  if (more) more.onclick = () => { fsUI.todoMore = !fsUI.todoMore; renderFsTodo(); };
@@ -7301,45 +7326,53 @@ function renderFsTodo(){
7301
7326
  for (const b of el.querySelectorAll('[data-fsglog]')) b.onclick = () => toggleGoalDetail(Number(b.dataset.fsglog));
7302
7327
  for (const b of el.querySelectorAll('[data-fsreverify]')) b.onclick = async () => {
7303
7328
  b.disabled = true; b.textContent = 'Starting…';
7304
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsreverify}/reverify`), { method: 'POST' });
7329
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreverify}/reverify`), { method: 'POST' }); }
7330
+ catch { showErr(document.documentElement.lang === 'ja' ? 'Retryに失敗しました' : 'Retry failed.'); b.disabled = false; b.textContent = 'Retry'; return; }
7305
7331
  if (!r.ok) { showErr(document.documentElement.lang === 'ja' ? 'Retryに失敗しました' : 'Retry failed.'); b.disabled = false; b.textContent = 'Retry'; }
7306
7332
  else await refresh();
7307
7333
  };
7308
7334
  for (const b of el.querySelectorAll('[data-fsarchive]')) b.onclick = async () => {
7309
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsarchive}/archive`), { method: 'POST' });
7335
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsarchive}/archive`), { method: 'POST' }); }
7336
+ catch { showErr('Archive failed.'); return; }
7310
7337
  if (!r.ok) showErr('Archive failed.'); else await refresh();
7311
7338
  };
7312
7339
  // LATER row actions (PRD §8.2): Start queues the shelved goal, Delete discards it.
7313
7340
  for (const b of el.querySelectorAll('[data-fspstart]')) b.onclick = async (e) => { e.stopPropagation();
7314
- const r = await fetch(withKey(`/api/goals/${b.dataset.fspstart}/activate`), { method: 'POST' });
7341
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fspstart}/activate`), { method: 'POST' }); }
7342
+ catch { showErr('Start failed.'); return; }
7315
7343
  if (!r.ok) showErr('Start failed.'); else await refresh(); };
7316
7344
  const startNextLater = el.querySelector('[data-fsstartnextlater]');
7317
7345
  if (startNextLater) startNextLater.onclick = async (e) => { e.stopPropagation();
7318
7346
  startNextLater.disabled = true;
7319
- const r = await fetch(withKey(`/api/goals/${startNextLater.dataset.fsstartnextlater}/activate`), { method: 'POST' });
7347
+ let r; try { r = await fetch(withKey(`/api/goals/${startNextLater.dataset.fsstartnextlater}/activate`), { method: 'POST' }); }
7348
+ catch { showErr(document.documentElement.lang === 'ja' ? '開始できませんでした' : 'Could not start the next item.'); startNextLater.disabled = false; return; }
7320
7349
  if (!r.ok) { showErr(document.documentElement.lang === 'ja' ? '開始できませんでした' : 'Could not start the next item.'); startNextLater.disabled = false; }
7321
7350
  else await refresh(); };
7322
7351
  for (const b of el.querySelectorAll('[data-fspdel]')) b.onclick = async (e) => { e.stopPropagation();
7323
7352
  const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
7324
7353
  if (!(await confirmDelete({ title: 'Delete this shelved goal?', preview }))) return;
7325
- const r = await fetch(withKey(`/api/goals/${b.dataset.fspdel}`), { method: 'DELETE' });
7354
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fspdel}`), { method: 'DELETE' }); }
7355
+ catch { showErr('Delete failed.'); return; }
7326
7356
  if (!r.ok) showErr('Delete failed.'); else await refresh(); };
7327
7357
  // hover-toolbar actions → same endpoints as the SL1 goalhead toolbar
7328
7358
  for (const b of el.querySelectorAll('[data-fsreply]')) b.onclick = (e) => { e.stopPropagation();
7329
7359
  fsInlineInput(b.closest('.trow'), '', 'Reply to this goal…', async (text) => {
7330
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsreply}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text })) });
7360
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreply}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text })) }); }
7361
+ catch { showErr('未開始のゴールは編集を使ってください'); return; }
7331
7362
  if (!r.ok) showErr('未開始のゴールは編集を使ってください'); await refresh();
7332
7363
  }); };
7333
7364
  for (const b of el.querySelectorAll('[data-fsedit]')) b.onclick = (e) => { e.stopPropagation();
7334
7365
  const g = state.goals.find((x) => x.id === Number(b.dataset.fsedit));
7335
7366
  fsInlineInput(b.closest('.trow'), g?.text ?? '', 'Edit the goal…', async (text) => {
7336
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsedit}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) });
7367
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsedit}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) }); }
7368
+ catch { showErr('Cannot edit a running/finished goal — reply to append instead'); return; }
7337
7369
  if (!r.ok) showErr('Cannot edit a running/finished goal — reply to append instead'); await refresh();
7338
7370
  }); };
7339
7371
  for (const b of el.querySelectorAll('[data-fsdel]')) b.onclick = async (e) => { e.stopPropagation();
7340
7372
  const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
7341
7373
  if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
7342
- const r = await fetch(withKey(`/api/goals/${b.dataset.fsdel}`), { method: 'DELETE' });
7374
+ let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsdel}`), { method: 'DELETE' }); }
7375
+ catch { showErr('Cannot delete a running/finished goal — reply to append instead'); return; }
7343
7376
  if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh(); };
7344
7377
  }
7345
7378
  async function fsCreateGoal(text, pending){
@@ -7457,7 +7490,7 @@ async function createProject(){
7457
7490
  if (!r.ok) throw new Error();
7458
7491
  const body = await r.json();
7459
7492
  state.projects = body.projects ?? [...state.projects, body.project].filter(Boolean);
7460
- state.active = body.project.id;
7493
+ setActiveProject(body.project.id);
7461
7494
  document.body.classList.remove('projectsopen');
7462
7495
  render();
7463
7496
  } catch { showErr('New project failed.'); }
@@ -7489,7 +7522,7 @@ async function deleteProject(p){
7489
7522
  const body = await r.json().catch(() => ({}));
7490
7523
  if (!r.ok) throw new Error(body.error || 'Delete project failed.');
7491
7524
  state.projects = body.projects ?? state.projects.filter((x) => x.id !== p.id);
7492
- if (state.active === p.id) state.active = state.projects[0]?.id ?? state.active;
7525
+ if (state.active === p.id) setActiveProject(state.projects[0]?.id ?? state.active);
7493
7526
  document.body.classList.remove('projectsopen');
7494
7527
  render();
7495
7528
  } catch (err) {
@@ -7524,21 +7557,24 @@ $('fsProjects').addEventListener('dblclick', (e) => {
7524
7557
  async function fsTaskRename(span, goalId){
7525
7558
  if (!goalId) return;
7526
7559
  inlineFsRename(span, async (v) => {
7527
- const r = await fetch(withKey(`/api/goals/${goalId}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: v }) });
7560
+ let r; try { r = await fetch(withKey(`/api/goals/${goalId}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: v }) }); }
7561
+ catch { showErr('Cannot rename a running/finished goal — reply to append instead'); return; }
7528
7562
  if (!r.ok) showErr('Cannot rename a running/finished goal — reply to append instead');
7529
7563
  await refresh();
7530
7564
  });
7531
7565
  }
7532
7566
  async function fsTaskArchive(goalId){
7533
7567
  if (!goalId) return;
7534
- const r = await fetch(withKey(`/api/goals/${goalId}/archive`), { method: 'POST' });
7568
+ let r; try { r = await fetch(withKey(`/api/goals/${goalId}/archive`), { method: 'POST' }); }
7569
+ catch { showErr('Archive failed.'); return; }
7535
7570
  if (!r.ok) showErr('Archive failed.'); else await refresh();
7536
7571
  }
7537
7572
  async function fsTaskDelete(goalId){
7538
7573
  if (!goalId) return;
7539
7574
  const preview = state.goals.find((x) => x.id === goalId)?.text || '';
7540
7575
  if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
7541
- const r = await fetch(withKey(`/api/goals/${goalId}`), { method: 'DELETE' });
7576
+ let r; try { r = await fetch(withKey(`/api/goals/${goalId}`), { method: 'DELETE' }); }
7577
+ catch { showErr('Cannot delete a running/finished goal — reply to append instead'); return; }
7542
7578
  if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh();
7543
7579
  }
7544
7580
  $('fsTodoSec').addEventListener('contextmenu', (e) => {
@@ -7572,7 +7608,7 @@ function fsSwitcherOpen(){
7572
7608
  function fsSwitcherHi(){ [...fsCtx.querySelectorAll('button')].forEach((b, i) => b.classList.toggle('hi', i === fsCtxSel)); }
7573
7609
  function fsSelectProjByIndex(idx){
7574
7610
  const rows = [...document.querySelectorAll('#fsProjects .fsproj')]; if (idx < 0 || idx >= rows.length) return;
7575
- state.active = rows[idx].dataset.fsproj; render();
7611
+ setActiveProject(rows[idx].dataset.fsproj); render();
7576
7612
  }
7577
7613
  document.addEventListener('keydown', (e) => {
7578
7614
  if (fsCtx.classList.contains('open') && fsCtxSwitcher) {
@@ -8312,7 +8348,7 @@ async function refresh(){
8312
8348
  state.workflowColumns = s.workflowColumns ?? {};
8313
8349
  state.reviewDefinitions = s.reviewDefinitions ?? {};
8314
8350
  checkUiVersion(s.uiVersion);
8315
- if (!state.projects.some((p) => p.id === state.active)) state.active = state.projects[0]?.id;
8351
+ if (!state.projects.some((p) => p.id === state.active)) setActiveProject(state.projects[0]?.id);
8316
8352
  render();
8317
8353
  } catch { /* server briefly down; SSE reconnect will retry */ }
8318
8354
  }
@@ -8397,7 +8433,7 @@ function connectSSE(){
8397
8433
  else if (m.ev === 'review-definition') state.reviewDefinitions[m.projectId] = m.reviewDefinition;
8398
8434
  else if (m.ev === 'projects') {
8399
8435
  state.projects = m.projects ?? state.projects;
8400
- if (!state.projects.some((p) => p.id === state.active)) state.active = state.projects[0]?.id;
8436
+ if (!state.projects.some((p) => p.id === state.active)) setActiveProject(state.projects[0]?.id);
8401
8437
  }
8402
8438
  else if (m.ev === 'ping') { checkUiVersion(m.uiVersion); return; }
8403
8439
  else if (m.ev === 'auth') { renderAuthBanner(m.auth); return; } // selected worker's sign-in preflight result
@@ -8433,7 +8469,7 @@ function bootDisconnected(info){
8433
8469
  applyTheme(); applyCust();
8434
8470
  // seed a minimal board so it renders like the real empty state
8435
8471
  state.projects = [{ id: '__onboard', name: 'Your project' }];
8436
- state.active = '__onboard';
8472
+ setActiveProject('__onboard');
8437
8473
  state.billing = { licensed: false, email };
8438
8474
  try { render(); } catch (e) { console.warn('disconnected render', e); }
8439
8475
  const heroSpan = document.querySelector('#fsClawdHero .hstat');
@@ -8649,15 +8685,6 @@ async function postOutboxItem(item){
8649
8685
  outbox.land(item.oid, goal.id);
8650
8686
  upsert(state.goals, goal);
8651
8687
  if (recentlySignedOut && !state.billing?.licensed) { recentlySignedOut = false; showSignInNudge(); }
8652
- // Pattern A (H23): this send became a real To-Do — mark the most recent
8653
- // unmarked user turn "→ Added to To Do #NN" in the feed. Skip a needsInput
8654
- // goal (it's still asking; shown as the question card at the feed bottom).
8655
- if (goal.status !== 'needsInput') {
8656
- const turns = state.chatTurns[item.projectId] || [];
8657
- for (let k = turns.length - 1; k >= 0; k--) {
8658
- if (turns[k].role === 'you' && turns[k].markGoalId == null) { turns[k].markGoalId = goal.id; break; }
8659
- }
8660
- }
8661
8688
  } catch {
8662
8689
  outbox.mark(item.oid, 'unsent');
8663
8690
  } finally {
@@ -8872,16 +8899,13 @@ async function send(){
8872
8899
  projectId: state.active, text, pr: 'auto', model, effort, agent: state.connectedApp,
8873
8900
  mode, skill: skill || null,
8874
8901
  workspaceMode: workspaceMode(),
8875
- // Keep the old wire contract too: only direct sends worktree:false.
8876
- worktree: worktreeOn() ? undefined : false,
8877
8902
  pending: later, review,
8878
8903
  images: attach.map((a) => a.path), imageUrls: attach.map((a) => a.url),
8879
8904
  status: 'sending', ts: Date.now(),
8880
8905
  };
8881
8906
  outbox.add(item); // saved locally BEFORE any network call
8882
8907
  // Pattern A (H23): the user's own turn enters the center feed immediately, so
8883
- // the composer reads like a conversation. If this send becomes a To-Do, a
8884
- // .cc-mark is attached to this turn later; if it's chat, a reply turn follows.
8908
+ // the composer reads like a conversation; if it's chat, a reply turn follows.
8885
8909
  (state.chatTurns[state.active] ||= []).push({ role: 'you', text, ts: Date.now() });
8886
8910
  state.attach = [];
8887
8911
  // mode/skill/later/review are per-message → clear after send (model stays sticky).
@@ -9010,9 +9034,12 @@ function renderSummaryPera(){
9010
9034
  body.querySelector('[data-peradetail]') && (body.querySelector('[data-peradetail]').onclick = () => { state.peraDetailOpen = !state.peraDetailOpen; renderSummaryPera(); });
9011
9035
  }
9012
9036
  async function peraAct(ids, kind){
9013
- for (const id of String(ids).split(',')) {
9014
- await fetch(withKey(`/api/goals/${id}/${kind}`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: kind === 'dismiss' ? JSON.stringify({ text: '' }) : undefined });
9015
- }
9037
+ try {
9038
+ for (const id of String(ids).split(',')) {
9039
+ const r = await fetch(withKey(`/api/goals/${id}/${kind}`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: kind === 'dismiss' ? JSON.stringify({ text: '' }) : undefined });
9040
+ if (!r.ok) throw new Error();
9041
+ }
9042
+ } catch { showErr(kind === 'approve' ? 'Approve failed.' : 'Dismiss failed.'); return; }
9016
9043
  if (kind === 'approve') celebrateApprove(); // confetti from the ペライチ too
9017
9044
  state.peraSpot = 0;
9018
9045
  await refresh();
@@ -9130,6 +9157,7 @@ function saRowFromDigest(r, gs, ts){
9130
9157
  goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
9131
9158
  did, siblings, risk: g?.risk ?? null,
9132
9159
  verificationInfraError: g?.verificationInfraError ?? r.verificationInfraError ?? null,
9160
+ prNote: g?.prNote ?? null,
9133
9161
  note: g?.retest?.passed ? g.retest.note : null,
9134
9162
  pr: r.pr,
9135
9163
  // P0b (Masa 2026-07-21): the AI's own TodoWrite breakdown for the "Steps" section
@@ -9177,6 +9205,15 @@ function saRowFromDigest(r, gs, ts){
9177
9205
  run: (() => {
9178
9206
  return latestGoalRunForOutcome(g?.id, ts, outcome);
9179
9207
  })(),
9208
+ // The worker DID try to declare something but it came out unusable (goal #733,
9209
+ // 2026-07-26: a Write followed by an Edit broke the JSON mid-edit; Review showed
9210
+ // no "Open it" section and no explanation, indistinguishable from "nothing to
9211
+ // run"). engine/server.mjs's readRunDeclaration sets task.runDeclError in that
9212
+ // case; only shown when there's no successful `run` to display instead.
9213
+ runDeclError: (() => {
9214
+ const t = ts.find((x) => r.goalIds.includes(x.goalId) && x.runDeclError);
9215
+ return t?.runDeclError ?? null;
9216
+ })(),
9180
9217
  // Explicit review-summary display fields (spec §11: the card's shape follows the
9181
9218
  // task). A goal's reviewSummary may carry them directly (e.g. a conversational task
9182
9219
  // that reports an Answer + its Activity, or a ticket whose test count / diff totals
@@ -9569,7 +9606,8 @@ function saItemHtml(it, i){
9569
9606
  const fDel = it.metaDel ?? (hasGitDiff ? `−${d.del}` : clientDiff ? `−${cDel}` : null);
9570
9607
  const chips = [];
9571
9608
  if (it.pr) chips.push(`<a class="mx mlink pr" href="${esc(it.pr)}" target="_blank" rel="noopener" title="Open pull request"><span class="mk">PR</span> <b>#${esc(prNum)}</b> <span class="mmut">Open</span>${arw}</a>`);
9572
- if (it.tokenOpt && tokPct > 0) chips.push(`<button type="button" class="mx mtok" data-saact="tok:${i}"><span class="mk">Token</span> <b>${tokPct}%</b> <span class="mmut">saved</span> <i class="tokchevron">›</i></button>`);
9609
+ const tokUntied = it.tokenOpt?.basis === 'baseline-untied';
9610
+ if (it.tokenOpt && tokPct > 0) chips.push(`<button type="button" class="mx mtok" data-saact="tok:${i}"><span class="mk">Token</span> <b>${tokPct}%</b> <span class="mmut">${tokUntied ? 'smaller' : 'saved'}</span> <i class="tokchevron">›</i></button>`);
9573
9611
  if (testsLabel) chips.push(`<span class="mx"><span class="mk">Tests</span> <b class="ok">${esc(testsLabel)}</b></span>`);
9574
9612
  // Files: hide-empty — only with changed files AND totals (a bare-count chip reads odd).
9575
9613
  if (nf && fAdd) chips.push(`<span class="mx"><span class="mk">Files</span> <b>${nf}</b> <span class="mmut">· <i class="a">${esc(fAdd)}</i> <i class="d">${esc(fDel)}</i></span></span>`);
@@ -9585,7 +9623,7 @@ function saItemHtml(it, i){
9585
9623
  if (it.recording) chips.push(it.recordingUrl
9586
9624
  ? `<a class="mx mlink" href="${esc(it.recordingUrl)}" target="_blank" rel="noopener" title="Watch the recording"><span class="mk">Verify</span> <b>recording</b>${arw}</a>`
9587
9625
  : `<a class="mx mlink" data-saact="rec:${i}" style="cursor:pointer" title="Watch the recording"><span class="mk">Verify</span> <b>recording</b>${arw}</a>`);
9588
- const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
9626
+ const tokCmp = tokUntied ? 'vs your recent average (no technique behind it)' : it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
9589
9627
  const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
9590
9628
  const metaDetail = (it.tokenOpt && tokPct > 0) ? `<div class="rmeta-detail" id="tokdetail${i}"><span class="k">How this run used tokens</span><span class="v">${esc(tokCmp)}${tokTech ? ` · ${esc(tokTech)}` : ''}</span></div>` : '';
9591
9629
  // §1.5-3: with Open it moved into the body, a card with no PR / tokens / tests has no
@@ -9603,11 +9641,20 @@ function saItemHtml(it, i){
9603
9641
  // failure reason land after the press. Nothing declared → no section (§1.5-3). ──
9604
9642
  // it.run?.compare/title alone (no cmd/url) has nothing a press can start — a
9605
9643
  // compare-only or title-only declaration must not draw the "Start it" button.
9644
+ // it.runDeclError (2026-07-26, goal #733): the worker DID try to declare an
9645
+ // artifact/command, but .manager-run.json came out unusable (e.g. a Write then
9646
+ // an Edit broke the JSON mid-edit) — this must not look identical to "nothing
9647
+ // to run" (§1.5-3 no empty box still applies: don't invent a fake Open it, but
9648
+ // don't stay silent about a real attempt either). Same honest-advisory shape as
9649
+ // prRepairBody below. A reply is the repair path: the worker rewrites the file.
9650
+ const runDeclErrorBody = (!((it.run?.cmd || it.run?.url)) && it.runDeclError)
9651
+ ? `<div class="ropen rdeclerr"><p class="rbody v">${failureLang() === 'ja' ? 'AIが開けるものを用意しようとしましたが、記録が読み取れませんでした。返信して直させてください。' : 'The AI tried to declare something to open here, but the record was unreadable. Reply to have it rewritten.'}</p></div>`
9652
+ : '';
9606
9653
  const openBody = (it.run?.cmd || it.run?.url)
9607
9654
  ? `<div class="ropen" id="runlocal${i}"><button type="button" class="olink" data-saact="preview:${i}" id="prevlink${i}">${esc(it.run.url || 'Start it')}<span class="ar">↗</span></button><p class="rbody v">${esc(it.run.note || it.run.cmd || 'Starts when you press it.')}</p></div>`
9608
9655
  : it.pr
9609
9656
  ? `<div class="ropen"><a class="olink" href="${esc(it.pr)}" target="_blank" rel="noopener">${esc(it.pr)}<span class="ar">↗</span></a><p class="rbody v">Review and merge this pull request to finish the item.</p></div>`
9610
- : '';
9657
+ : runDeclErrorBody;
9611
9658
  const prRepairBody = it.goal?.prError
9612
9659
  ? `<div class="prfail"><b>${esc(it.goal.prRepair?.title || 'PR blocked')}</b> <span class="dim">${esc(it.goal.prError)}</span>${it.goal.prRepair?.steps?.length ? `<ul>${it.goal.prRepair.steps.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : ''}${it.goal.prRepair?.dirtyFiles?.length ? `<div class="dim">dirty: ${it.goal.prRepair.dirtyFiles.map(esc).join(' · ')}</div>` : ''}<div class="acts2"><button class="errbtn" data-saact="retrypr:${i}">${failureLang() === 'ja' ? 'PR再試行' : 'Retry PR'}</button><button class="errbtn" data-saact="glog:${i}">${failureLang() === 'ja' ? 'ログ確認' : 'View log'}</button></div></div>`
9613
9660
  : '';
@@ -9693,6 +9740,15 @@ function saItemHtml(it, i){
9693
9740
  const riskHtml = risk?.shouldPause
9694
9741
  ? `<div class="lg-risk"><svg viewBox="0 0 24 24"><path d="M12 8v5M12 16.5v.5"/><path d="M10.3 3.9 2.4 18a1.9 1.9 0 0 0 1.7 2.9h15.8a1.9 1.9 0 0 0 1.7-2.9L13.7 3.9a1.9 1.9 0 0 0-3.4 0z"/></svg>Needs your review — touches a sensitive area${risk.categories?.length ? `: ${esc(risk.categories.map((c) => c.label).join(', '))}` : ''}</div>`
9695
9742
  : '';
9743
+ // ── PR-requested-but-no-code note (goal #751, Masa 2026-07-26): a wantsPR goal whose
9744
+ // worker ended with zero changed files and no proof — most often a lane-conflict
9745
+ // report ("空き次第自動再開します") that could not actually resume, since a `claude -p`
9746
+ // run is one-shot. Advisory only (isNothingVerifiableForPR never blocks — Failedを作ら
9747
+ // ない方針を尊重), so the reviewer sees it and decides instead of it silently reading
9748
+ // as a normal completed PR review. ──
9749
+ const prNoteHtml = it.prNote
9750
+ ? `<div class="lg-risk"><svg viewBox="0 0 24 24"><path d="M12 8v5M12 16.5v.5"/><path d="M10.3 3.9 2.4 18a1.9 1.9 0 0 0 1.7 2.9h15.8a1.9 1.9 0 0 0 1.7-2.9L13.7 3.9a1.9 1.9 0 0 0-3.4 0z"/></svg>${esc(it.prNote)}</div>`
9751
+ : '';
9696
9752
  // ── Activity (spec §3): ONE Linear-docs pulldown. The "Activity" heading is plain; the
9697
9753
  // chevron sits on the FIRST line (the opening "You …" row); tapping it opens the rest
9698
9754
  // below, offset-indented. The review Discussion folds in at the bottom of the body. The
@@ -9784,6 +9840,7 @@ function saItemHtml(it, i){
9784
9840
  ${S('Proof', proofInner)}
9785
9841
  ${S('What changed', changedBody)}
9786
9842
  ${riskHtml}
9843
+ ${prNoteHtml}
9787
9844
  ${conversationSec}
9788
9845
  ${activitySec}
9789
9846
  ${S('Workflow', saWorkflowHtml(it))}
@@ -10039,13 +10096,32 @@ function saMoveFeedbackOutOfReview(it, body){
10039
10096
  if (task?.id) upsert(state.tasks, task);
10040
10097
  const at = SA.items.findIndex((row) => row.goalId === it.goalId);
10041
10098
  const next = at >= 0 ? (SA.items[at + 1] || SA.items[at - 1]) : null;
10099
+ // Was the reviewer actually looking at the card that just left Review (vs having already
10100
+ // scrolled past it to read another one)? If it's fully above the visible scroll area, the
10101
+ // reviewer is reading something else — snapping the view to the next card's top would yank
10102
+ // it out from under them (Masa 2026-07-26: replying to the card above and having the one
10103
+ // you're reading jump to the top was reported as stressful).
10104
+ const bodyEl = document.querySelector('#seeall .sa-body');
10105
+ const cardEl = saCardEl(it.goalId);
10106
+ const priorScrollTop = bodyEl?.scrollTop ?? 0;
10107
+ const removedHeight = cardEl ? cardEl.getBoundingClientRect().height : 0;
10108
+ const cardAboveViewport = !!(cardEl && bodyEl && cardEl.getBoundingClientRect().bottom <= bodyEl.getBoundingClientRect().top + 1);
10042
10109
  if (at >= 0) SA.items.splice(at, 1);
10043
10110
  render();
10044
10111
  if (SA.solo === it.goalId || !SA.items.length) { saRender(); saClose(); return; }
10045
10112
  SA.cur = next?.goalId ?? SA.items[0]?.goalId ?? null;
10046
- SA.focusGid = SA.cur;
10047
- SA.focusScrolled = false;
10048
- saRender();
10113
+ if (cardAboveViewport) {
10114
+ // Keep reading where we were: compensate for the removed card's height instead of
10115
+ // forcing a scroll-to-top of whatever is now "current".
10116
+ SA.focusGid = null;
10117
+ saRender();
10118
+ const newBody = document.querySelector('#seeall .sa-body');
10119
+ if (newBody) newBody.scrollTop = Math.max(0, priorScrollTop - removedHeight);
10120
+ } else {
10121
+ SA.focusGid = SA.cur;
10122
+ SA.focusScrolled = false;
10123
+ saRender();
10124
+ }
10049
10125
  }
10050
10126
  function saCardEl(goalId){ return document.querySelector(`#seeall .sa-item[data-gid="${goalId}"]`); }
10051
10127
  // Flip one card to its "Working on it" head without redrawing anything else (決定2).
@@ -10229,7 +10305,8 @@ $('seeall').addEventListener('click', async (e) => {
10229
10305
  // SA.items[i] lookup below.
10230
10306
  if (k === 'foldopen') return openReviewChecklist(Number(v));
10231
10307
  if (k === 'foldundo') {
10232
- const r = await fetch(withKey(`/api/goals/${v}/unfold`), { method: 'POST' });
10308
+ let r; try { r = await fetch(withKey(`/api/goals/${v}/unfold`), { method: 'POST' }); }
10309
+ catch { showErr('Undo failed.'); return; }
10233
10310
  if (r && r.ok) await refresh(); else showErr('Undo failed.');
10234
10311
  return;
10235
10312
  }
@@ -10242,7 +10319,8 @@ $('seeall').addEventListener('click', async (e) => {
10242
10319
  if (k === 'reverify') {
10243
10320
  b.disabled = true;
10244
10321
  b.textContent = '再検証中…';
10245
- const r = await fetch(withKey(`/api/goals/${it.goalId}/reverify`), { method: 'POST' });
10322
+ let r; try { r = await fetch(withKey(`/api/goals/${it.goalId}/reverify`), { method: 'POST' }); }
10323
+ catch { showErr('再検証に失敗'); b.disabled = false; return; }
10246
10324
  if (r.ok) await refresh(); else showErr('再検証に失敗');
10247
10325
  return;
10248
10326
  }
@@ -10427,7 +10505,6 @@ function renderCtxBar(){
10427
10505
  const grp = $('ctxbrgrp');
10428
10506
  if (grp) grp.style.display = branch ? '' : 'none';
10429
10507
  const bn = $('ctxbranchname'); if (bn) bn.textContent = branch;
10430
- renderWorktreeChip();
10431
10508
  const menu = $('ctxdirmenu'); if (!menu) return;
10432
10509
  const cur = dir;
10433
10510
  const opts = (state.repos ?? [])
@@ -10439,31 +10516,12 @@ function renderCtxBar(){
10439
10516
  const openRow = `<button type="button" class="appopt ctxopen" id="ctxopenfolder" 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>`;
10440
10517
  menu.innerHTML = `${opts}${openRow}<div class="ctxpath"><input type="text" id="ctxdirin" placeholder="~/path/to/repo" aria-label="Folder path" spellcheck="false"><button type="button" class="appopt" id="ctxdiruse" style="width:auto">Use</button></div><div class="ctxmsg" id="ctxdirmsg"></div>`;
10441
10518
  }
10442
- // Workspace mode. isolated worktree is the default clean copy; direct workspace inherits
10443
- // and edits the current folder. Sticky per project, like model and effort, because it is
10444
- // a working habit rather than a per-message thought.
10445
- function worktreeKey(){ return `sel:worktree:${state.active}`; }
10446
- function worktreeOn(){ return localStorage.getItem(worktreeKey()) !== '0'; }
10447
- function workspaceMode(){ return worktreeOn() ? 'isolated-worktree' : 'direct-workspace'; }
10448
- function goalWorkspaceMode(g){ return (g?.workspaceMode === 'direct-workspace' || g?.worktree === false) ? 'direct-workspace' : 'isolated-worktree'; }
10449
- function workspaceModeText(g){ return goalWorkspaceMode(g) === 'direct-workspace' ? 'direct workspace' : 'isolated worktree'; }
10450
- function renderWorktreeChip(){
10451
- const el = $('ctxwt'); if (!el) return;
10452
- const isolated = worktreeOn();
10453
- const proj = state.projects.find((p) => p.id === state.active);
10454
- el.setAttribute('aria-checked', isolated ? 'true' : 'false');
10455
- const label = $('ctxwtlabel');
10456
- if (label) label.textContent = isolated ? 'isolated worktree' : 'direct workspace';
10457
- el.title = isolated
10458
- ? 'isolated worktree: default clean copy; this folder is not edited.'
10459
- : 'direct workspace: inherits the current state and edits this folder.';
10460
- const warn = $('ctxworkwarn');
10461
- if (warn) {
10462
- const dirty = !isolated && !!proj?.dirty;
10463
- warn.hidden = !dirty;
10464
- warn.textContent = dirty ? `Dirty workspace warning: direct workspace will edit ${folderName(proj?.dir) || 'this folder'} as it is now.` : '';
10465
- }
10466
- }
10519
+ // Workspace mode is fixed to the isolated worktree (clean separate checkout). The
10520
+ // direct-workspace escape hatch was removed after two accidents where a stray click
10521
+ // edited a shared checkout directly; there was no real upside to keep it around for.
10522
+ function workspaceMode(){ return 'isolated-worktree'; }
10523
+ function goalWorkspaceMode(){ return 'isolated-worktree'; }
10524
+ function workspaceModeText(){ return 'isolated worktree'; }
10467
10525
  // Asked for once, and only when the menu is first opened — the same "only when you look
10468
10526
  // at it" rule the implementation log and the conversation follow.
10469
10527
  async function loadRepos(){
@@ -10524,11 +10582,6 @@ $('ctxdirbtn').onclick = (e) => {
10524
10582
  menu.classList.toggle('show');
10525
10583
  if (opening) { renderCtxBar(); loadRepos(); } // no-op once boot's fetch has landed
10526
10584
  };
10527
- $('ctxwt').onclick = (e) => {
10528
- e.stopPropagation();
10529
- localStorage.setItem(worktreeKey(), worktreeOn() ? '0' : '1');
10530
- renderWorktreeChip();
10531
- };
10532
10585
  $('ctxdirmenu').addEventListener('click', (e) => {
10533
10586
  e.stopPropagation();
10534
10587
  if (e.target.closest('#ctxopenfolder')) { openFolderNative(); return; }
@@ -11677,6 +11730,7 @@ function buildGearPop(){
11677
11730
  if (!(await confirmDelete({ title: 'Log out of Google?', body: 'You can sign back in anytime.', confirmLabel: 'Log out', confirmDanger: false }))) return;
11678
11731
  signOutBtn.disabled = true;
11679
11732
  try { await fetch(withKey('/api/signout'), { method: 'POST' }); recentlySignedOut = true; }
11733
+ catch {}
11680
11734
  finally { await refresh(); if ($('gearPop')) buildGearPop(); }
11681
11735
  };
11682
11736
  const rulesBtn = pop.querySelector('[data-grules]');