@galda/cli 0.10.72 → 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). */
@@ -2134,6 +2110,10 @@
2134
2110
  /* body prose = the app's normal text: 14px / −.012em, matching .bubble (Masa 2026-07-20:
2135
2111
  13px read thinner/lighter for Japanese than the surrounding app). §2. */
2136
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}
2137
2117
  .redesign .rinfra{padding:10px 12px;border:1px solid rgba(var(--amber-rgb),.35);border-radius:8px;background:rgba(var(--amber-rgb),.06)}
2138
2118
  .redesign .rinfra .rbody{color:var(--ink2)}
2139
2119
  .redesign .rinfra .retrybtn{margin-top:8px;margin-left:0}
@@ -2982,13 +2962,8 @@
2982
2962
  <div class="ctxchip ctxbrgrp" id="ctxbrgrp">
2983
2963
  <span class="ctxseg ctxbranch" id="ctxbranch" title="Branch this folder is on">
2984
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>
2985
- <span class="ctxdiv"></span>
2986
- <button type="button" class="ctxseg ctxwt" id="ctxwt" role="checkbox" aria-checked="true"
2987
- title="isolated worktree: default clean copy. direct workspace: inherits and edits this folder.">
2988
- <i class="ctxbox"></i><span id="ctxwtlabel">isolated worktree</span></button>
2989
2965
  </div>
2990
2966
  </div>
2991
- <div class="ctxworkwarn" id="ctxworkwarn" hidden></div>
2992
2967
  <!-- Conversation-first composer (SLICE 2, docs/HANDOFF-ONBOARDING-conversational.md):
2993
2968
  a chat/help/settings message gets a quiet reply HERE instead of
2994
2969
  becoming a To-Do. Reuses the .peraanswer surface; hidden until a
@@ -3375,8 +3350,20 @@ const savedTheme = (() => {
3375
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,
3376
3351
  // per-message send overrides set via slash-commands (reset after each send,
3377
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 = .
3378
3356
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
3357
+ msgByProject: {},
3379
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
+ }
3380
3367
  window.state = state; // top-level const isn't a window property; external checks probe window.state
3381
3368
  const $ = (id) => document.getElementById(id);
3382
3369
 
@@ -3901,10 +3888,13 @@ function tokenBaselinePerRequirementLocal(history){
3901
3888
  return samples.reduce((a, b) => a + b, 0) / samples.length;
3902
3889
  }
3903
3890
  // Mirrors engine/lib.mjs estimateTokenOptimization() — see the comment there
3904
- // for why "saved X%" now compares against a real per-project baseline
3905
- // (basis: 'baseline') instead of always reusing this goal's own cache-hit
3906
- // ratio as a stand-in for "cheaper than usual" (basis: 'cache-weight',
3907
- // 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).
3908
3898
  function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null, requirementCount = 1, history = [] } = {}){
3909
3899
  const u = usage ?? {};
3910
3900
  const raw = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
@@ -3913,15 +3903,11 @@ function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, c
3913
3903
  const reqN = Math.max(1, Number(requirementCount) || 1);
3914
3904
  const perReq = weighted / reqN;
3915
3905
  const baseline = tokenBaselinePerRequirementLocal(history);
3916
- let percent, basis, savedTokens;
3906
+ let percent = null, basis = 'no-baseline', savedTokens = null;
3917
3907
  if (baseline != null) {
3918
3908
  percent = Math.max(-100, Math.min(100, Math.round(((baseline - perReq) / baseline) * 100)));
3919
3909
  basis = 'baseline';
3920
3910
  savedTokens = Math.max(0, Math.round((baseline - perReq) * reqN));
3921
- } else {
3922
- savedTokens = Math.max(0, raw - weighted);
3923
- percent = Math.round((savedTokens / raw) * 100);
3924
- basis = 'cache-weight';
3925
3911
  }
3926
3912
  const techniques = [];
3927
3913
  const cacheRatio = Math.round(((u.cache_read_input_tokens ?? 0) / raw) * 100);
@@ -3929,8 +3915,10 @@ function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, c
3929
3915
  if (usedHandoff || sessionMode === 'cold-handoff') techniques.push('fresh handoff instead of a bloated session');
3930
3916
  if (usedCompact || sessionMode === 'compact') techniques.push('/compact before continuing');
3931
3917
  if (usedClear) techniques.push('/clear between unrelated goals');
3932
- 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';
3933
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`);
3934
3922
  if (!techniques.length) techniques.push(`${Math.round(raw / 1000)}k tokens this run (no cache reuse yet)`);
3935
3923
  return { rawTokens: raw, weightedTokens: weighted, savedTokens, percent, basis, techniques: [...new Set(techniques)] };
3936
3924
  }
@@ -3938,6 +3926,10 @@ function tokenOptimizationHtml(opt){
3938
3926
  if (!opt) return '';
3939
3927
  const techniques = opt.techniques?.length ? opt.techniques.slice(0, 4).join(' / ') : 'this run';
3940
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
+ }
3941
3933
  const cmp = opt.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
3942
3934
  const text = pct > 0
3943
3935
  ? `Token optimized: saved about ${pct}% ${cmp} (${techniques})`
@@ -3949,7 +3941,7 @@ function renderProjects(){
3949
3941
  const busy = state.tasks.filter((t) => t.projectId === p.id && ['running','queued'].includes(t.status)).length;
3950
3942
  return `<button class="proj ${p.id === state.active ? 'active' : ''}" data-id="${p.id}">${esc(p.name)}${busy ? `<span class="n">${busy}</span>` : ''}</button>`;
3951
3943
  }).join('');
3952
- 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(); };
3953
3945
  }
3954
3946
 
3955
3947
  function proofMediaHtml(taskId, proof, altLabel){
@@ -4372,8 +4364,9 @@ function renderStream(){
4372
4364
  inp.addEventListener('keydown', async (e) => {
4373
4365
  if (e.key !== 'Enter' || e.isComposing || !inp.value.trim()) return;
4374
4366
  const text = inp.value.trim(); inp.value = ''; inp.disabled = true;
4375
- const r = await fetch(withKey(`/api/goals/${inp.dataset.goal}/reply`), { method: 'POST',
4376
- 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; }
4377
4370
  if (!r.ok) showErr('Reply failed — the goal may not have started yet.');
4378
4371
  inp.disabled = false;
4379
4372
  });
@@ -4498,18 +4491,22 @@ function renderQueue(){
4498
4491
  const text = document.getElementById(`qedit-${id}`).value.trim();
4499
4492
  const pr = document.getElementById(`qedit-pr-${id}`).dataset.val;
4500
4493
  if (text) {
4501
- const r = await fetch(withKey(`/api/goals/${id}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, pr }) });
4502
- 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.'); }
4503
4498
  }
4504
4499
  state.editing = null; render();
4505
4500
  };
4506
4501
  for (const b of q.querySelectorAll('.qp')) b.onclick = async () => {
4507
- 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; }
4508
4504
  if (r.ok) upsert(state.goals, await r.json()); else showErr('Too late — this goal already started.');
4509
4505
  render();
4510
4506
  };
4511
4507
  for (const b of q.querySelectorAll('.qd')) b.onclick = async () => {
4512
- 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; }
4513
4510
  if (r.ok) state.goals = state.goals.filter((g) => g.id !== Number(b.dataset.id));
4514
4511
  else { showErr('Too late — this goal already started.'); await refresh(); }
4515
4512
  render();
@@ -4724,15 +4721,19 @@ function renderTasks(){
4724
4721
  const id = Number(el.dataset.task);
4725
4722
  const rb = e.target.closest('[data-retrybtn]');
4726
4723
  if (rb) {
4727
- const r = await fetch(withKey(`/api/tasks/${rb.dataset.retrybtn}/retry`), { method: 'POST' });
4728
- 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.'); }
4729
4728
  return;
4730
4729
  }
4731
4730
  const sb = e.target.closest('[data-skipbtn]');
4732
4731
  if (sb) {
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();
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.'); }
4736
4737
  return;
4737
4738
  }
4738
4739
  // Attention rows open the same detail dialog as Review (tap anywhere but
@@ -4776,11 +4777,13 @@ function renderTasks(){
4776
4777
  b.onclick = async (e) => { e.stopPropagation();
4777
4778
  const bx = b.getBoundingClientRect(); // capture before await/re-render — burst FROM the ✓ button
4778
4779
  let ok = true, mergeGoal = null;
4779
- for (const id of b.dataset.qapprove.split(',')) {
4780
- const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
4781
- if (!r.ok){ ok = false; break; }
4782
- const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
4783
- }
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; }
4784
4787
  if (!ok){ showErr('Approve failed.'); return; }
4785
4788
  await refresh();
4786
4789
  if (mergeGoal){ confirmMerge(mergeGoal); return; } // merge instead of confetti
@@ -4790,19 +4793,24 @@ function renderTasks(){
4790
4793
  // 07-20c: the inline ✕ is a pure Reject — park the goal in the Rejected column (/reject),
4791
4794
  // no auto-rework. Rework is asked for via chat.
4792
4795
  b.onclick = async (e) => { e.stopPropagation();
4793
- 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; }
4794
4798
  qrefresh(r, 'Dismiss failed.'); };
4795
4799
  }
4796
4800
  // Archive: retire an unfinished goal cleanly (no rework spawned). The escape
4797
4801
  // hatch for half-finished (partial/blocked) items so Attention stays drainable.
4798
4802
  for (const b of $('tasklist').querySelectorAll('[data-archivegoal]')) {
4799
4803
  b.onclick = async (e) => { e.stopPropagation();
4800
- 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.'); };
4801
4807
  }
4802
4808
  // Manager-verified gate: re-run tests + re-capture proof for a blocked goal.
4803
4809
  for (const b of $('tasklist').querySelectorAll('[data-goalreverify]')) {
4804
4810
  b.onclick = async (e) => { e.stopPropagation(); b.disabled = true; b.textContent = '再検証中…';
4805
- 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, '再検証に失敗'); };
4806
4814
  }
4807
4815
  for (const b of $('tasklist').querySelectorAll('[data-qreply]')) {
4808
4816
  b.onclick = (e) => { e.stopPropagation();
@@ -4845,14 +4853,16 @@ function renderTasks(){
4845
4853
  for (const b of $('tasklist').querySelectorAll('[data-pstart]')) {
4846
4854
  b.onclick = async (e) => {
4847
4855
  e.stopPropagation();
4848
- 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; }
4849
4858
  if (!r.ok) showErr('Start failed.'); else await refresh();
4850
4859
  };
4851
4860
  }
4852
4861
  for (const b of $('tasklist').querySelectorAll('[data-pdel]')) {
4853
4862
  b.onclick = async (e) => {
4854
4863
  e.stopPropagation();
4855
- 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; }
4856
4866
  if (!r.ok) showErr('Delete failed.'); else await refresh();
4857
4867
  };
4858
4868
  }
@@ -4864,13 +4874,15 @@ function renderTasks(){
4864
4874
  e.stopPropagation();
4865
4875
  const preview = b.closest('.goalhead')?.querySelector('.gt')?.textContent || '';
4866
4876
  if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
4867
- 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; }
4868
4879
  if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh();
4869
4880
  };
4870
4881
  // Smart intake: answer a planner clarifying question (chip or free text) → re-plan.
4871
4882
  const answerQ = async (id, answer) => {
4872
4883
  if (!answer) return;
4873
- 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; }
4874
4886
  if (!r.ok) showErr('Failed to send answer'); else await refresh();
4875
4887
  };
4876
4888
  for (const b of $('tasklist').querySelectorAll('[data-qanswer]')) b.onclick = (e) => { e.stopPropagation(); answerQ(Number(b.dataset.qanswer), b.dataset.answer); };
@@ -4967,7 +4979,8 @@ function openGoalheadReply(id, btn){
4967
4979
  const snd = document.createElement('button'); snd.className = 'qbtn ok'; snd.textContent = '↑';
4968
4980
  wrap.appendChild(inp); wrap.appendChild(snd); head.insertAdjacentElement('afterend', wrap); inp.focus();
4969
4981
  const send = async () => { const t = inp.value.trim(); if (!t) return;
4970
- 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; }
4971
4984
  if (!r.ok) showErr('未開始のゴールは編集を使ってください'); await refresh(); };
4972
4985
  inp.onkeydown = (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); send(); } if (e.key === 'Escape') wrap.remove(); };
4973
4986
  snd.onclick = send;
@@ -4990,9 +5003,11 @@ function wirePriority(){
4990
5003
  b.onclick = async (e) => {
4991
5004
  e.stopPropagation();
4992
5005
  b.closest('.priomenu')?.classList.remove('show');
4993
- const r = await fetch(withKey(`/api/tasks/${b.dataset.priotask}/priority`), { method: 'PUT',
4994
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ priority: b.dataset.val }) });
4995
- 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('優先度の変更に失敗しました。'); }
4996
5011
  };
4997
5012
  }
4998
5013
  }
@@ -5188,26 +5203,31 @@ $('wfHeaderEdit').onclick = (e) => { e.stopPropagation(); openWfEditor(); };
5188
5203
  $('wfoverlay').addEventListener('click', (e) => { if (e.target === $('wfoverlay')) $('wfoverlay').classList.remove('show'); });
5189
5204
  $('wfsave').onclick = async () => {
5190
5205
  const columns = state.wfDraft.map((c) => ({ key: c.key, label: c.label.trim(), bucket: c.bucket }));
5191
- const r = await fetch(withKey(`/api/projects/${state.active}/columns`), { method: 'PUT',
5192
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ columns }) });
5193
- const data = await r.json().catch(() => ({}));
5194
- if (!r.ok) {
5195
- $('wferr').textContent = data.error || 'Save failed.';
5196
- $('wferr').style.display = 'block';
5197
- return;
5198
- }
5199
- const rdr = await fetch(withKey(`/api/projects/${state.active}/review-definition`), { method: 'PUT',
5200
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reviewDefinition: state.rdDraft }) });
5201
- const rdData = await rdr.json().catch(() => ({}));
5202
- if (!rdr.ok) {
5203
- $('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.';
5204
5229
  $('wferr').style.display = 'block';
5205
- return;
5206
5230
  }
5207
- state.workflowColumns[state.active] = data.columns;
5208
- state.reviewDefinitions[state.active] = rdData.reviewDefinition;
5209
- $('wfoverlay').classList.remove('show');
5210
- render();
5211
5231
  };
5212
5232
 
5213
5233
  function wireWfEditButtons(){
@@ -5432,7 +5452,8 @@ function renderReviewChecklist(){
5432
5452
  e.stopPropagation();
5433
5453
  b.disabled = true;
5434
5454
  b.textContent = '再検証中…';
5435
- 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; }
5436
5457
  if (r.ok) await refresh(); else showErr('再検証に失敗');
5437
5458
  };
5438
5459
  }
@@ -5444,9 +5465,10 @@ function renderReviewChecklist(){
5444
5465
  if (!text) return;
5445
5466
  const t = reqs.find((r) => r.id === taskId);
5446
5467
  b.disabled = true; input.disabled = true;
5447
- 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',
5448
5469
  headers: { 'content-type': 'application/json' },
5449
- 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; }
5450
5472
  if (r.ok) { input.value = ''; input.placeholder = 'Sent — the worker will pick it up'; }
5451
5473
  else showErr('Failed to send the fix request.');
5452
5474
  b.disabled = false; input.disabled = false;
@@ -6367,7 +6389,7 @@ function renderFsRail(){
6367
6389
  el.innerHTML = html;
6368
6390
  for (const b of el.querySelectorAll('[data-fsproj]')) b.onclick = (e) => {
6369
6391
  if (e.target.closest('.pname.editing') || b.querySelector('.pname.editing')) return;
6370
- state.active = b.dataset.fsproj; document.body.classList.remove('projectsopen'); render();
6392
+ setActiveProject(b.dataset.fsproj); document.body.classList.remove('projectsopen'); render();
6371
6393
  };
6372
6394
  const add = $('fsProjAdd'); if (add) add.onclick = createProject;
6373
6395
  }
@@ -6538,11 +6560,6 @@ function renderFsFeed(){
6538
6560
  let t = '';
6539
6561
  if (turn.role === 'you') {
6540
6562
  t += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
6541
- if (turn.markGoalId != null && !turn.undone) {
6542
- const mg = state.goals.find((x) => x.id === turn.markGoalId);
6543
- const n = mg ? goalReviewNumber({ goal: mg, tasks: state.tasks }) : turn.markGoalId;
6544
- 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>`;
6545
- }
6546
6563
  } else { // Claude Code reply — speaker line first (You → speaker → reply), then the text
6547
6564
  const app = connectedAppMeta(turn.agent);
6548
6565
  t += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
@@ -6595,24 +6612,12 @@ function renderFsFeed(){
6595
6612
  // Delete a "needs your answer" card outright (restores 「Needs your answer」カードを削除
6596
6613
  // できるように — a question you don't want to answer had no discard path in flagship).
6597
6614
  for (const b of feed.querySelectorAll('[data-fsanswerdel]')) b.onclick = (e) => { e.stopPropagation(); fsTaskDelete(Number(b.dataset.fsanswerdel)); };
6598
- // Pattern A (H23): Undo a "→ Added to To Do" marker — cancel the created goal
6599
- // and drop the mark. A just-created (planning) goal is deletable; if it already
6600
- // started the server returns non-OK (canDeleteGoal) and we say so quietly.
6601
- for (const b of feed.querySelectorAll('[data-ccundo]')) b.onclick = async (e) => {
6602
- e.stopPropagation();
6603
- const gid = Number(b.dataset.ccundo);
6604
- const r = await fetch(withKey(`/api/goals/${gid}`), { method: 'DELETE' });
6605
- if (!r.ok) { showErr('Could not undo — the task may already be running'); return; }
6606
- const turn = (state.chatTurns[state.active] || []).find((t) => t.markGoalId === gid);
6607
- if (turn) turn.undone = true;
6608
- state.goals = state.goals.filter((g) => g.id !== gid);
6609
- render();
6610
- };
6611
6615
  }
6612
6616
  async function fsAnswerQuestion(goalId, answer){
6613
6617
  if (!answer) return;
6614
- const r = await fetch(withKey(`/api/goals/${goalId}/answer`), { method: 'POST',
6615
- 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; }
6616
6621
  if (!r.ok) showErr('Failed to send answer'); else await refresh();
6617
6622
  }
6618
6623
  async function fsRetryTask(id){
@@ -6989,17 +6994,20 @@ function renderFsReview(){
6989
6994
  // "Also carries" section (saItemHtml + the seeall handler).
6990
6995
  for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
6991
6996
  let ok = true, mergeGoal = null;
6992
- for (const id of b.dataset.fsapprove.split(',')) {
6993
- const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
6994
- if (!r.ok){ ok = false; break; }
6995
- const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
6996
- }
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; }
6997
7004
  if (!ok){ showErr('Approve failed.'); return; }
6998
7005
  await refresh();
6999
7006
  if (mergeGoal) confirmMerge(mergeGoal); else celebrateApprove(); };
7000
7007
  for (const b of el.querySelectorAll('[data-fsdismiss]')) b.onclick = async (e) => { e.stopPropagation();
7001
7008
  // 07-20c: the review-card ✕ is a pure Reject → Rejected column (/reject), no auto-rework.
7002
- 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; }
7003
7011
  if (r && r.ok) await refresh(); else showErr('Dismiss failed.'); };
7004
7012
  }
7005
7013
  // Shared To Do bucketing (list rows AND the kanban board read the same groups —
@@ -7290,14 +7298,16 @@ function renderFsTodo(){
7290
7298
  if (Number.isFinite(id)) fsOpenGoalDetail(id);
7291
7299
  };
7292
7300
  for (const b of el.querySelectorAll('[data-fsreopen]')) b.onclick = async (e) => { e.stopPropagation();
7293
- 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; }
7294
7303
  if (!r.ok) showErr('Reopen failed.'); else await refresh(); };
7295
7304
  for (const b of el.querySelectorAll('[data-fsrejdel]')) b.onclick = async (e) => { e.stopPropagation();
7296
7305
  const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
7297
7306
  // Delete on a rejected goal = revert: it closes the PR + drops the worktree, so a rejected
7298
7307
  // goal that had a PR doesn't leave an orphan PR behind (DELETE would 409 — rejected isn't deletable).
7299
7308
  if (!(await confirmDelete({ title: 'Discard this rejected goal?', preview }))) return;
7300
- 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; }
7301
7311
  if (!r.ok) showErr('Delete failed.'); else await refresh(); };
7302
7312
  const more = el.querySelector('#fsTodoMore');
7303
7313
  if (more) more.onclick = () => { fsUI.todoMore = !fsUI.todoMore; renderFsTodo(); };
@@ -7316,45 +7326,53 @@ function renderFsTodo(){
7316
7326
  for (const b of el.querySelectorAll('[data-fsglog]')) b.onclick = () => toggleGoalDetail(Number(b.dataset.fsglog));
7317
7327
  for (const b of el.querySelectorAll('[data-fsreverify]')) b.onclick = async () => {
7318
7328
  b.disabled = true; b.textContent = 'Starting…';
7319
- 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; }
7320
7331
  if (!r.ok) { showErr(document.documentElement.lang === 'ja' ? 'Retryに失敗しました' : 'Retry failed.'); b.disabled = false; b.textContent = 'Retry'; }
7321
7332
  else await refresh();
7322
7333
  };
7323
7334
  for (const b of el.querySelectorAll('[data-fsarchive]')) b.onclick = async () => {
7324
- 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; }
7325
7337
  if (!r.ok) showErr('Archive failed.'); else await refresh();
7326
7338
  };
7327
7339
  // LATER row actions (PRD §8.2): Start queues the shelved goal, Delete discards it.
7328
7340
  for (const b of el.querySelectorAll('[data-fspstart]')) b.onclick = async (e) => { e.stopPropagation();
7329
- 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; }
7330
7343
  if (!r.ok) showErr('Start failed.'); else await refresh(); };
7331
7344
  const startNextLater = el.querySelector('[data-fsstartnextlater]');
7332
7345
  if (startNextLater) startNextLater.onclick = async (e) => { e.stopPropagation();
7333
7346
  startNextLater.disabled = true;
7334
- 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; }
7335
7349
  if (!r.ok) { showErr(document.documentElement.lang === 'ja' ? '開始できませんでした' : 'Could not start the next item.'); startNextLater.disabled = false; }
7336
7350
  else await refresh(); };
7337
7351
  for (const b of el.querySelectorAll('[data-fspdel]')) b.onclick = async (e) => { e.stopPropagation();
7338
7352
  const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
7339
7353
  if (!(await confirmDelete({ title: 'Delete this shelved goal?', preview }))) return;
7340
- 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; }
7341
7356
  if (!r.ok) showErr('Delete failed.'); else await refresh(); };
7342
7357
  // hover-toolbar actions → same endpoints as the SL1 goalhead toolbar
7343
7358
  for (const b of el.querySelectorAll('[data-fsreply]')) b.onclick = (e) => { e.stopPropagation();
7344
7359
  fsInlineInput(b.closest('.trow'), '', 'Reply to this goal…', async (text) => {
7345
- 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; }
7346
7362
  if (!r.ok) showErr('未開始のゴールは編集を使ってください'); await refresh();
7347
7363
  }); };
7348
7364
  for (const b of el.querySelectorAll('[data-fsedit]')) b.onclick = (e) => { e.stopPropagation();
7349
7365
  const g = state.goals.find((x) => x.id === Number(b.dataset.fsedit));
7350
7366
  fsInlineInput(b.closest('.trow'), g?.text ?? '', 'Edit the goal…', async (text) => {
7351
- 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; }
7352
7369
  if (!r.ok) showErr('Cannot edit a running/finished goal — reply to append instead'); await refresh();
7353
7370
  }); };
7354
7371
  for (const b of el.querySelectorAll('[data-fsdel]')) b.onclick = async (e) => { e.stopPropagation();
7355
7372
  const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
7356
7373
  if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
7357
- 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; }
7358
7376
  if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh(); };
7359
7377
  }
7360
7378
  async function fsCreateGoal(text, pending){
@@ -7472,7 +7490,7 @@ async function createProject(){
7472
7490
  if (!r.ok) throw new Error();
7473
7491
  const body = await r.json();
7474
7492
  state.projects = body.projects ?? [...state.projects, body.project].filter(Boolean);
7475
- state.active = body.project.id;
7493
+ setActiveProject(body.project.id);
7476
7494
  document.body.classList.remove('projectsopen');
7477
7495
  render();
7478
7496
  } catch { showErr('New project failed.'); }
@@ -7504,7 +7522,7 @@ async function deleteProject(p){
7504
7522
  const body = await r.json().catch(() => ({}));
7505
7523
  if (!r.ok) throw new Error(body.error || 'Delete project failed.');
7506
7524
  state.projects = body.projects ?? state.projects.filter((x) => x.id !== p.id);
7507
- 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);
7508
7526
  document.body.classList.remove('projectsopen');
7509
7527
  render();
7510
7528
  } catch (err) {
@@ -7539,21 +7557,24 @@ $('fsProjects').addEventListener('dblclick', (e) => {
7539
7557
  async function fsTaskRename(span, goalId){
7540
7558
  if (!goalId) return;
7541
7559
  inlineFsRename(span, async (v) => {
7542
- 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; }
7543
7562
  if (!r.ok) showErr('Cannot rename a running/finished goal — reply to append instead');
7544
7563
  await refresh();
7545
7564
  });
7546
7565
  }
7547
7566
  async function fsTaskArchive(goalId){
7548
7567
  if (!goalId) return;
7549
- 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; }
7550
7570
  if (!r.ok) showErr('Archive failed.'); else await refresh();
7551
7571
  }
7552
7572
  async function fsTaskDelete(goalId){
7553
7573
  if (!goalId) return;
7554
7574
  const preview = state.goals.find((x) => x.id === goalId)?.text || '';
7555
7575
  if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
7556
- 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; }
7557
7578
  if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh();
7558
7579
  }
7559
7580
  $('fsTodoSec').addEventListener('contextmenu', (e) => {
@@ -7587,7 +7608,7 @@ function fsSwitcherOpen(){
7587
7608
  function fsSwitcherHi(){ [...fsCtx.querySelectorAll('button')].forEach((b, i) => b.classList.toggle('hi', i === fsCtxSel)); }
7588
7609
  function fsSelectProjByIndex(idx){
7589
7610
  const rows = [...document.querySelectorAll('#fsProjects .fsproj')]; if (idx < 0 || idx >= rows.length) return;
7590
- state.active = rows[idx].dataset.fsproj; render();
7611
+ setActiveProject(rows[idx].dataset.fsproj); render();
7591
7612
  }
7592
7613
  document.addEventListener('keydown', (e) => {
7593
7614
  if (fsCtx.classList.contains('open') && fsCtxSwitcher) {
@@ -8327,7 +8348,7 @@ async function refresh(){
8327
8348
  state.workflowColumns = s.workflowColumns ?? {};
8328
8349
  state.reviewDefinitions = s.reviewDefinitions ?? {};
8329
8350
  checkUiVersion(s.uiVersion);
8330
- 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);
8331
8352
  render();
8332
8353
  } catch { /* server briefly down; SSE reconnect will retry */ }
8333
8354
  }
@@ -8412,7 +8433,7 @@ function connectSSE(){
8412
8433
  else if (m.ev === 'review-definition') state.reviewDefinitions[m.projectId] = m.reviewDefinition;
8413
8434
  else if (m.ev === 'projects') {
8414
8435
  state.projects = m.projects ?? state.projects;
8415
- 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);
8416
8437
  }
8417
8438
  else if (m.ev === 'ping') { checkUiVersion(m.uiVersion); return; }
8418
8439
  else if (m.ev === 'auth') { renderAuthBanner(m.auth); return; } // selected worker's sign-in preflight result
@@ -8448,7 +8469,7 @@ function bootDisconnected(info){
8448
8469
  applyTheme(); applyCust();
8449
8470
  // seed a minimal board so it renders like the real empty state
8450
8471
  state.projects = [{ id: '__onboard', name: 'Your project' }];
8451
- state.active = '__onboard';
8472
+ setActiveProject('__onboard');
8452
8473
  state.billing = { licensed: false, email };
8453
8474
  try { render(); } catch (e) { console.warn('disconnected render', e); }
8454
8475
  const heroSpan = document.querySelector('#fsClawdHero .hstat');
@@ -8664,15 +8685,6 @@ async function postOutboxItem(item){
8664
8685
  outbox.land(item.oid, goal.id);
8665
8686
  upsert(state.goals, goal);
8666
8687
  if (recentlySignedOut && !state.billing?.licensed) { recentlySignedOut = false; showSignInNudge(); }
8667
- // Pattern A (H23): this send became a real To-Do — mark the most recent
8668
- // unmarked user turn "→ Added to To Do #NN" in the feed. Skip a needsInput
8669
- // goal (it's still asking; shown as the question card at the feed bottom).
8670
- if (goal.status !== 'needsInput') {
8671
- const turns = state.chatTurns[item.projectId] || [];
8672
- for (let k = turns.length - 1; k >= 0; k--) {
8673
- if (turns[k].role === 'you' && turns[k].markGoalId == null) { turns[k].markGoalId = goal.id; break; }
8674
- }
8675
- }
8676
8688
  } catch {
8677
8689
  outbox.mark(item.oid, 'unsent');
8678
8690
  } finally {
@@ -8887,16 +8899,13 @@ async function send(){
8887
8899
  projectId: state.active, text, pr: 'auto', model, effort, agent: state.connectedApp,
8888
8900
  mode, skill: skill || null,
8889
8901
  workspaceMode: workspaceMode(),
8890
- // Keep the old wire contract too: only direct sends worktree:false.
8891
- worktree: worktreeOn() ? undefined : false,
8892
8902
  pending: later, review,
8893
8903
  images: attach.map((a) => a.path), imageUrls: attach.map((a) => a.url),
8894
8904
  status: 'sending', ts: Date.now(),
8895
8905
  };
8896
8906
  outbox.add(item); // saved locally BEFORE any network call
8897
8907
  // Pattern A (H23): the user's own turn enters the center feed immediately, so
8898
- // the composer reads like a conversation. If this send becomes a To-Do, a
8899
- // .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.
8900
8909
  (state.chatTurns[state.active] ||= []).push({ role: 'you', text, ts: Date.now() });
8901
8910
  state.attach = [];
8902
8911
  // mode/skill/later/review are per-message → clear after send (model stays sticky).
@@ -9025,9 +9034,12 @@ function renderSummaryPera(){
9025
9034
  body.querySelector('[data-peradetail]') && (body.querySelector('[data-peradetail]').onclick = () => { state.peraDetailOpen = !state.peraDetailOpen; renderSummaryPera(); });
9026
9035
  }
9027
9036
  async function peraAct(ids, kind){
9028
- for (const id of String(ids).split(',')) {
9029
- await fetch(withKey(`/api/goals/${id}/${kind}`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: kind === 'dismiss' ? JSON.stringify({ text: '' }) : undefined });
9030
- }
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; }
9031
9043
  if (kind === 'approve') celebrateApprove(); // confetti from the ペライチ too
9032
9044
  state.peraSpot = 0;
9033
9045
  await refresh();
@@ -9145,6 +9157,7 @@ function saRowFromDigest(r, gs, ts){
9145
9157
  goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
9146
9158
  did, siblings, risk: g?.risk ?? null,
9147
9159
  verificationInfraError: g?.verificationInfraError ?? r.verificationInfraError ?? null,
9160
+ prNote: g?.prNote ?? null,
9148
9161
  note: g?.retest?.passed ? g.retest.note : null,
9149
9162
  pr: r.pr,
9150
9163
  // P0b (Masa 2026-07-21): the AI's own TodoWrite breakdown for the "Steps" section
@@ -9593,7 +9606,8 @@ function saItemHtml(it, i){
9593
9606
  const fDel = it.metaDel ?? (hasGitDiff ? `−${d.del}` : clientDiff ? `−${cDel}` : null);
9594
9607
  const chips = [];
9595
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>`);
9596
- 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>`);
9597
9611
  if (testsLabel) chips.push(`<span class="mx"><span class="mk">Tests</span> <b class="ok">${esc(testsLabel)}</b></span>`);
9598
9612
  // Files: hide-empty — only with changed files AND totals (a bare-count chip reads odd).
9599
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>`);
@@ -9609,7 +9623,7 @@ function saItemHtml(it, i){
9609
9623
  if (it.recording) chips.push(it.recordingUrl
9610
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>`
9611
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>`);
9612
- 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';
9613
9627
  const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
9614
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>` : '';
9615
9629
  // §1.5-3: with Open it moved into the body, a card with no PR / tokens / tests has no
@@ -9726,6 +9740,15 @@ function saItemHtml(it, i){
9726
9740
  const riskHtml = risk?.shouldPause
9727
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>`
9728
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
+ : '';
9729
9752
  // ── Activity (spec §3): ONE Linear-docs pulldown. The "Activity" heading is plain; the
9730
9753
  // chevron sits on the FIRST line (the opening "You …" row); tapping it opens the rest
9731
9754
  // below, offset-indented. The review Discussion folds in at the bottom of the body. The
@@ -9817,6 +9840,7 @@ function saItemHtml(it, i){
9817
9840
  ${S('Proof', proofInner)}
9818
9841
  ${S('What changed', changedBody)}
9819
9842
  ${riskHtml}
9843
+ ${prNoteHtml}
9820
9844
  ${conversationSec}
9821
9845
  ${activitySec}
9822
9846
  ${S('Workflow', saWorkflowHtml(it))}
@@ -10072,13 +10096,32 @@ function saMoveFeedbackOutOfReview(it, body){
10072
10096
  if (task?.id) upsert(state.tasks, task);
10073
10097
  const at = SA.items.findIndex((row) => row.goalId === it.goalId);
10074
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);
10075
10109
  if (at >= 0) SA.items.splice(at, 1);
10076
10110
  render();
10077
10111
  if (SA.solo === it.goalId || !SA.items.length) { saRender(); saClose(); return; }
10078
10112
  SA.cur = next?.goalId ?? SA.items[0]?.goalId ?? null;
10079
- SA.focusGid = SA.cur;
10080
- SA.focusScrolled = false;
10081
- 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
+ }
10082
10125
  }
10083
10126
  function saCardEl(goalId){ return document.querySelector(`#seeall .sa-item[data-gid="${goalId}"]`); }
10084
10127
  // Flip one card to its "Working on it" head without redrawing anything else (決定2).
@@ -10262,7 +10305,8 @@ $('seeall').addEventListener('click', async (e) => {
10262
10305
  // SA.items[i] lookup below.
10263
10306
  if (k === 'foldopen') return openReviewChecklist(Number(v));
10264
10307
  if (k === 'foldundo') {
10265
- 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; }
10266
10310
  if (r && r.ok) await refresh(); else showErr('Undo failed.');
10267
10311
  return;
10268
10312
  }
@@ -10275,7 +10319,8 @@ $('seeall').addEventListener('click', async (e) => {
10275
10319
  if (k === 'reverify') {
10276
10320
  b.disabled = true;
10277
10321
  b.textContent = '再検証中…';
10278
- 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; }
10279
10324
  if (r.ok) await refresh(); else showErr('再検証に失敗');
10280
10325
  return;
10281
10326
  }
@@ -10460,7 +10505,6 @@ function renderCtxBar(){
10460
10505
  const grp = $('ctxbrgrp');
10461
10506
  if (grp) grp.style.display = branch ? '' : 'none';
10462
10507
  const bn = $('ctxbranchname'); if (bn) bn.textContent = branch;
10463
- renderWorktreeChip();
10464
10508
  const menu = $('ctxdirmenu'); if (!menu) return;
10465
10509
  const cur = dir;
10466
10510
  const opts = (state.repos ?? [])
@@ -10472,31 +10516,12 @@ function renderCtxBar(){
10472
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>`;
10473
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>`;
10474
10518
  }
10475
- // Workspace mode. isolated worktree is the default clean copy; direct workspace inherits
10476
- // and edits the current folder. Sticky per project, like model and effort, because it is
10477
- // a working habit rather than a per-message thought.
10478
- function worktreeKey(){ return `sel:worktree:${state.active}`; }
10479
- function worktreeOn(){ return localStorage.getItem(worktreeKey()) !== '0'; }
10480
- function workspaceMode(){ return worktreeOn() ? 'isolated-worktree' : 'direct-workspace'; }
10481
- function goalWorkspaceMode(g){ return (g?.workspaceMode === 'direct-workspace' || g?.worktree === false) ? 'direct-workspace' : 'isolated-worktree'; }
10482
- function workspaceModeText(g){ return goalWorkspaceMode(g) === 'direct-workspace' ? 'direct workspace' : 'isolated worktree'; }
10483
- function renderWorktreeChip(){
10484
- const el = $('ctxwt'); if (!el) return;
10485
- const isolated = worktreeOn();
10486
- const proj = state.projects.find((p) => p.id === state.active);
10487
- el.setAttribute('aria-checked', isolated ? 'true' : 'false');
10488
- const label = $('ctxwtlabel');
10489
- if (label) label.textContent = isolated ? 'isolated worktree' : 'direct workspace';
10490
- el.title = isolated
10491
- ? 'isolated worktree: default clean copy; this folder is not edited.'
10492
- : 'direct workspace: inherits the current state and edits this folder.';
10493
- const warn = $('ctxworkwarn');
10494
- if (warn) {
10495
- const dirty = !isolated && !!proj?.dirty;
10496
- warn.hidden = !dirty;
10497
- warn.textContent = dirty ? `Dirty workspace warning: direct workspace will edit ${folderName(proj?.dir) || 'this folder'} as it is now.` : '';
10498
- }
10499
- }
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'; }
10500
10525
  // Asked for once, and only when the menu is first opened — the same "only when you look
10501
10526
  // at it" rule the implementation log and the conversation follow.
10502
10527
  async function loadRepos(){
@@ -10557,11 +10582,6 @@ $('ctxdirbtn').onclick = (e) => {
10557
10582
  menu.classList.toggle('show');
10558
10583
  if (opening) { renderCtxBar(); loadRepos(); } // no-op once boot's fetch has landed
10559
10584
  };
10560
- $('ctxwt').onclick = (e) => {
10561
- e.stopPropagation();
10562
- localStorage.setItem(worktreeKey(), worktreeOn() ? '0' : '1');
10563
- renderWorktreeChip();
10564
- };
10565
10585
  $('ctxdirmenu').addEventListener('click', (e) => {
10566
10586
  e.stopPropagation();
10567
10587
  if (e.target.closest('#ctxopenfolder')) { openFolderNative(); return; }
@@ -11710,6 +11730,7 @@ function buildGearPop(){
11710
11730
  if (!(await confirmDelete({ title: 'Log out of Google?', body: 'You can sign back in anytime.', confirmLabel: 'Log out', confirmDanger: false }))) return;
11711
11731
  signOutBtn.disabled = true;
11712
11732
  try { await fetch(withKey('/api/signout'), { method: 'POST' }); recentlySignedOut = true; }
11733
+ catch {}
11713
11734
  finally { await refresh(); if ($('gearPop')) buildGearPop(); }
11714
11735
  };
11715
11736
  const rulesBtn = pop.querySelector('[data-grules]');
package/engine/lib.mjs CHANGED
@@ -655,9 +655,25 @@ export function estimateTokenOptimization({ usage = null, weightedTokens = null,
655
655
  // layer did to save tokens, and it is the opposite: the model is the user's
656
656
  // own pick from the composer, and we deliberately never override it
657
657
  // (Masa 2026-07-22). Every remaining line is an action this layer took.
658
+
659
+ // Masa 2026-07-26: a positive `baseline` percent is a comparison against past
660
+ // goals, not proof any technique was applied THIS run — a small goal that
661
+ // followed a run of large ones scores a big "saved" number purely from the
662
+ // size mix, with nothing the user actually did behind it. Only call it
663
+ // "saved" when a real technique signal fired this run (cache reuse, a fresh
664
+ // handoff, /compact, /clear); otherwise it is a true fact (this run was
665
+ // smaller/larger than usual) but not a causal claim, so we relabel it
666
+ // `baseline-untied` and the renderer must phrase it as a comparison, not a
667
+ // "you saved X% by doing Y".
668
+ const techniqueDriven = cacheRatio > 0 || usedHandoff || sessionMode === 'cold-handoff' || usedCompact || sessionMode === 'compact' || usedClear;
669
+ if (basis === 'baseline' && !techniqueDriven) basis = 'baseline-untied';
670
+
658
671
  if (basis === 'baseline' && percent > 0) {
659
672
  techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
660
673
  }
674
+ if (basis === 'baseline-untied') {
675
+ techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
676
+ }
661
677
  if (!techniques.length) techniques.push(`${Math.round(raw / 1000)}k tokens this run (no cache reuse yet)`);
662
678
 
663
679
  return { rawTokens: raw, weightedTokens: weighted, savedTokens, percent, basis, techniques: [...new Set(techniques)] };
@@ -2254,6 +2270,21 @@ export function isNothingVerifiable({ changedFiles = [], hasPassCondition = fals
2254
2270
  return hasPassCondition && changedFiles.length === 0 && !hasAnyProof;
2255
2271
  }
2256
2272
 
2273
+ // goal-751 (2026-07-26): a wantsPR goal is an explicit ask for a PR, so a worker
2274
+ // that ends with zero changed files and no proof did not deliver what was asked —
2275
+ // most often a lane-conflict report ("空き次第自動再開します") that cannot actually
2276
+ // happen, since a `claude -p` worker is a one-shot batch run with no way to resume
2277
+ // itself later. taskStatusAfterVerify still reads a clean exit (code===0, no
2278
+ // verdict) as 'done', so this reached Review looking exactly like a normal
2279
+ // completed PR. Same shape as isNothingVerifiable above but keyed on wantsPR
2280
+ // instead of passCondition (a wantsPR goal has no passCondition of its own — the
2281
+ // PR ask itself is the promise). Advisory only, like proofNote/verificationInfra
2282
+ // Error below: the caller sets a note, it never blocks (Failedを作らない方針, see
2283
+ // docs/HANDOFF-2026-07-25-verify-gate-default-off.md).
2284
+ export function isNothingVerifiableForPR({ wantsPR = false, changedFiles = [], hasAnyProof = false } = {}) {
2285
+ return Boolean(wantsPR) && changedFiles.length === 0 && !hasAnyProof;
2286
+ }
2287
+
2257
2288
  // チェックリストUIで要件が全件Approveされたら goal を review→done へ進める。
2258
2289
  // 以前は「PRありgoalはマージが唯一の完了トリガー」として弾いていたが、それだと
2259
2290
  // 承認しても done が永続化されず次のrefreshで review に戻る不具合になっていた
@@ -2542,10 +2573,10 @@ export function hasGitChanges(porcelainStatus) {
2542
2573
  return Boolean(String(porcelainStatus ?? '').trim());
2543
2574
  }
2544
2575
 
2545
- export function resolveWorkspaceMode(input) {
2546
- if (input === 'direct-workspace' || input === 'direct') return 'direct-workspace';
2547
- if (input === 'isolated-worktree' || input === 'worktree' || input === true) return 'isolated-worktree';
2548
- if (input === false) return 'direct-workspace';
2576
+ // Direct workspace is retired (2026-07-26): two accidents where a stray client-side
2577
+ // toggle edited a shared checkout directly. This always returns isolated-worktree
2578
+ // regardless of what a client sends, so a stale/tampered client can't force direct.
2579
+ export function resolveWorkspaceMode(_input) {
2549
2580
  return 'isolated-worktree';
2550
2581
  }
2551
2582
 
@@ -3151,6 +3182,13 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3151
3182
  // (research, summaries, answers) simply skip this — the worker decides,
3152
3183
  // never us.
3153
3184
  `- 人が実際に動かして結果を見られる依頼なら、最後にプロジェクト直下へ ${RUN_DECL_FILE} を書く: {"cmd": "<起動コマンド>", "url": "<開くURL>", "note": "<何が見えるか1行>", "check": "<人に何を見て判断してほしいか1行>", "title": "<この作業のチケット名。対象が分かる程度に説明的で動作を含む短い名前。例「看板モードの追加」「ヘッダのズレの修正」。依頼文をそのまま写さない>"}。Manager はこれを使って人に「動かして見る」と「何を見ればいいか」を出す。動かして見せるものが無い依頼(調査・要約・質問への回答など)では書かなくてよい。`,
3185
+ // `file` (goal #744):起動不要な静的成果物(比較HTML、レポート等)専用。この
3186
+ // クラスの依頼は cmd/url を使う理由がなく、worker が唯一持っている手(file://
3187
+ // URL)は http(s) しか許さないバリデーションに毎回弾かれ、しかも弾かれた理由が
3188
+ // 「JSONが壊れている」という誤診断メッセージだったため、同じ file:// を書き直
3189
+ // して再度失敗するループが実際に2回連続で起きた(goal #744)。ここで file を
3190
+ // 明示し、file:// URLを書く選択肢自体を塞ぐ。
3191
+ `- 起動不要な静的ファイル(HTML比較ページ・レポート等、npm run devのようなコマンドを必要としないもの)を見せたい場合は、cmd/url でなく "file": "<プロジェクト内の相対パス>" を使う。例: {"file": "docs/design/options.html", "title": "3案の比較"}。file:// URLは絶対に書かない(urlはhttp(s)以外だと宣言ごと弾かれる)。`,
3154
3192
  // Root-cause prevention (goal #733, 2026-07-26): the worker Wrote this file,
3155
3193
  // then Edit'd it, and the edit broke the JSON mid-edit — Manager silently
3156
3194
  // dropped the whole declaration (fixed separately: it now surfaces an honest
@@ -3186,6 +3224,22 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3186
3224
  // the request is a change-something request, and most requests are not.
3187
3225
  agentName === 'codex' ? '' : `- 同じ画面の「変える前」と「変えた後」を撮ったなら、${RUN_DECL_FILE} に \`"compare": ["<前の画像>.png", "<後の画像>.png"]\` と書いてよい(その2枚が同じ場所の前後だと分かるのは撮った本人だけ)。人には並べて、違う部分に印を付けて見せる。3案を並べた等、前後の関係が無い画像には書かない。`,
3188
3226
  `- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
3227
+ // Root-cause fix (Masa 2026-07-26, goal #727 review): a worker fixed a real bug
3228
+ // (the To-Do lane's Doing ring had zero animation) but its report also claimed
3229
+ // the project-rail dot was "static" before — it actually already had a working
3230
+ // pulse animation (fsrundot), just not a spin. The worker never re-read the
3231
+ // pre-change code to check its own diagnosis; it narrated a plausible-sounding
3232
+ // "before" state instead of a verified one, and nothing downstream caught it.
3233
+ '- 「〜は動いていなかった/静止していた」のように直す前の状態を断定する場合は、その断定をする前に修正前の該当コード(該当CSS/関数)を実際に読んで確認すること。読まずに推測で「動いていない」と書かない。読んで確認した箇所は、報告に該当コードを短く引用すること。',
3234
+ // goal #751 (2026-07-26): you are a one-shot batch process — once this run exits,
3235
+ // nothing resumes you. If something outside your control blocks you (a lane/file
3236
+ // lock held by another agent, a missing permission, etc.), do NOT report that you
3237
+ // will "resume automatically once it frees up" or similar — that is not something
3238
+ // you or anything in this run can do. Say plainly that you made no code changes and
3239
+ // why, so the goal reads as unfinished/blocked rather than as a completed PR. A
3240
+ // human or the CTO will re-dispatch the work (e.g. via manager_reply) once the
3241
+ // blocker clears.
3242
+ '- あなたは1回きりのバッチ実行であり、このプロセスが終了した後に自動で再開することはない。レーン/ファイルの競合など自分では解けない理由で着手できない場合、「空き次第自動で再開します」のような、この実行の外では実現できない約束を書かないこと。何をどう変更できなかったか(コード変更0件であること)を正直に書き、ゴールが完了したPRではなく未完了/ブロックとして扱われるように終えること。再開は人間かCTOが改めて指示する。',
3189
3243
  ].filter(Boolean).join('\n');
3190
3244
  }
3191
3245
 
@@ -3203,6 +3257,37 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3203
3257
  // button that opens the wrong thing is the fabricated-proof bug all over again.
3204
3258
  export const RUN_DECL_FILE = '.manager-run.json';
3205
3259
 
3260
+ // A declaration that parsed as JSON but breaks a rule (bad url scheme, an
3261
+ // unsafe `file` path) is NOT the same failure as unparseable JSON — the first
3262
+ // is a worker mistake with a specific, sayable reason; the second is a broken
3263
+ // file. Conflating them (goal #744) sent the worker back a message that only
3264
+ // describes JSON corruption ("was not usable JSON"), so a worker whose actual
3265
+ // mistake was a file:// url read that as "my JSON broke" and rewrote the exact
3266
+ // same file:// url a second time. This return shape lets the caller tell them
3267
+ // apart: `null` stays "nothing usable to report" (unparseable / genuinely
3268
+ // empty), an `{ error, message }` object carries a reason a human — or the
3269
+ // worker reading its own feedback — can act on.
3270
+ function runDeclError(error, message) {
3271
+ return { error, message };
3272
+ }
3273
+
3274
+ // `file` (goal #744, this PR): a static, already-built artifact — an HTML
3275
+ // comparison page, a report — that needs no process and has no http(s) URL of
3276
+ // its own. Declared as a path relative to the project so the Manager can copy
3277
+ // it out of the worktree (same pattern as SHOT_DIR) and serve it from a URL
3278
+ // that survives after the worktree is gone. Only a syntactic check lives here
3279
+ // (no absolute path, no ".." segment, no backslash/drive letter, no NUL) —
3280
+ // server.mjs still re-resolves against the worktree root before it reads
3281
+ // anything, because a syntax check alone doesn't rule out a symlink.
3282
+ export function isSafeRunDeclFilePath(p) {
3283
+ if (typeof p !== 'string' || !p) return false;
3284
+ if (p.includes('\0')) return false;
3285
+ if (p.startsWith('/') || p.startsWith('\\')) return false;
3286
+ if (/^[a-zA-Z]:[\\/]/.test(p)) return false; // Windows drive letter
3287
+ const norm = p.replace(/\\/g, '/');
3288
+ return !norm.split('/').some((seg) => seg === '..');
3289
+ }
3290
+
3206
3291
  export function parseRunDeclaration(text) {
3207
3292
  let raw;
3208
3293
  try { raw = JSON.parse(String(text ?? '')); } catch { return null; }
@@ -3210,6 +3295,7 @@ export function parseRunDeclaration(text) {
3210
3295
  const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
3211
3296
  const cmd = str(raw.cmd, 500);
3212
3297
  const url = str(raw.url, 500);
3298
+ const file = str(raw.file, 500);
3213
3299
  // `compare` (Masa 2026-07-22): which TWO of the worker's own screenshots are
3214
3300
  // the same view before and after. Only the agent that did the work knows that
3215
3301
  // two images relate — a request for three design options has no "after" at
@@ -3219,9 +3305,16 @@ export function parseRunDeclaration(text) {
3219
3305
  const compare = parseComparePair(raw.compare);
3220
3306
  const title = str(raw.title, 60);
3221
3307
  // Nothing to start, nothing to open, nothing named, and no pair to show = nothing to look at.
3222
- if (!cmd && !url && !compare && !title) return null;
3223
- // Only http(s): the button opens this in the user's browser.
3224
- if (url && !/^https?:\/\//i.test(url)) return null;
3308
+ if (!cmd && !url && !file && !compare && !title) return null;
3309
+ // Only http(s): the button opens this in the user's browser. A file:// (or
3310
+ // any other scheme) is a worker mistake with a specific fix — declare `file`
3311
+ // instead — not a JSON parse failure, so it gets its own reason.
3312
+ if (url && !/^https?:\/\//i.test(url)) {
3313
+ return runDeclError('bad-url-scheme', "url must start with http:// or https:// — a file:// path can't be opened from a remote browser session; declare an artifact file instead of a file:// url");
3314
+ }
3315
+ if (file && !isSafeRunDeclFilePath(file)) {
3316
+ return runDeclError('bad-file-path', 'file must be a path inside the project — no leading /, no .. segments');
3317
+ }
3225
3318
  // `check` (Masa 2026-07-22): what to look at, in the working AI's own words. It used
3226
3319
  // to come from the intake AI's plan — this layer inventing a pass condition for a
3227
3320
  // request it only read. The AI that did the work states it here instead, in the same
@@ -3232,7 +3325,7 @@ export function parseRunDeclaration(text) {
3232
3325
  // the user's own sentence, repeated back at them in the one place that is
3233
3326
  // supposed to say what the work WAS. The agent that just did it can name it in
3234
3327
  // one line, and it is the only party that knows what actually got done.
3235
- return { cmd, url, note: str(raw.note, 300), check: str(raw.check, 300), compare, title };
3328
+ return { cmd, url, file, note: str(raw.note, 300), check: str(raw.check, 300), compare, title };
3236
3329
  }
3237
3330
 
3238
3331
  // Exactly two shot file names, in [before, after] order. Anything else is
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, isCommandOnPath } from './lib.mjs';
24
- import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession } from './lib.mjs';
24
+ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession } from './lib.mjs';
25
25
  import { createSerialQueue } from './lib.mjs';
26
26
  import { isPrClosed } from './lib.mjs';
27
27
  import { collectProjectRules } from './lib.mjs';
@@ -2406,6 +2406,17 @@ function readRunDeclaration(task, workDir) {
2406
2406
  try { raw = readFileSync(file, 'utf8'); } catch { return; }
2407
2407
  try { unlinkSync(file); } catch { /* best effort — never fail a task over cleanup */ }
2408
2408
  const run = parseRunDeclaration(raw);
2409
+ // A declaration that parsed as JSON but broke a rule (bad url scheme, unsafe
2410
+ // file path) is a worker mistake with a specific, sayable fix — NOT the same
2411
+ // failure as unparseable JSON. Goal #744: conflating the two sent the worker
2412
+ // "was not usable JSON" for a file:// url, so it "fixed" the JSON (which was
2413
+ // never broken) and wrote the exact same file:// url a second time. `run` is
2414
+ // `{ error, message }` here, distinct from the `null` case below.
2415
+ if (run && run.error) {
2416
+ task.runDeclError = { reason: run.error, message: run.message, snippet: String(raw ?? '').slice(0, 200) };
2417
+ sendAct(task, `run declaration ignored — ${run.message}`);
2418
+ return;
2419
+ }
2409
2420
  if (!run) {
2410
2421
  // The worker DID try to declare something (the file exists) but it came out
2411
2422
  // unusable — e.g. a Write followed by an Edit that broke the JSON mid-edit
@@ -2417,17 +2428,62 @@ function readRunDeclaration(task, workDir) {
2417
2428
  // actually written (raw content is deleted above and never otherwise
2418
2429
  // recoverable) so both the activity log and the Review card can say WHY,
2419
2430
  // instead of the human having to guess or dig through file loss.
2420
- task.runDeclError = { snippet: String(raw ?? '').slice(0, 200) };
2431
+ task.runDeclError = { reason: 'invalid-json', snippet: String(raw ?? '').slice(0, 200) };
2421
2432
  sendAct(task, `run declaration ignored — ${RUN_DECL_FILE} was not usable JSON: ${task.runDeclError.snippet.slice(0, 80)}`);
2422
2433
  return;
2423
2434
  }
2424
2435
  task.run = { ...run, dir: workDir };
2425
- const how = run.cmd ?? run.url;
2436
+ collectRunDeclFileArtifact(task, workDir);
2437
+ const how = task.run.cmd ?? task.run.url;
2426
2438
  if (how) sendProcessAct(task, `Worker declared how to see the result: ${how}`);
2427
2439
  if (run.title) sendProcessAct(task, `Worker named the work: ${run.title}`);
2428
2440
  if (run.compare) sendProcessAct(task, `Worker declared a before/after pair: ${run.compare.join(' → ')}`);
2429
2441
  }
2430
2442
 
2443
+ // `file` (goal #744): a static, already-built artifact that needs no process
2444
+ // and has no http(s) URL of its own — an HTML comparison page, a report. Same
2445
+ // pattern as collectWorkerShots: copy it out of the worktree (which is about
2446
+ // to be diffed/discarded) into logDir/uploads and give it a URL that survives.
2447
+ // parseRunDeclaration already checked the path is syntactically inside the
2448
+ // project (no leading /, no ..); this re-resolves against the worktree root
2449
+ // before reading anything, because a syntax check alone doesn't rule out a
2450
+ // symlink pointing outside it.
2451
+ function collectRunDeclFileArtifact(task, workDir) {
2452
+ const rel = task.run?.file;
2453
+ if (!rel) return;
2454
+ const root = resolve(workDir) + '/';
2455
+ const abs = resolve(workDir, rel);
2456
+ if (!(abs + '/').startsWith(root) && abs !== root.slice(0, -1)) {
2457
+ task.runDeclError = { reason: 'file-outside-project', message: `file "${rel}" resolves outside the project`, snippet: rel };
2458
+ sendAct(task, `run declaration ignored — ${task.runDeclError.message}.`);
2459
+ task.run = { ...task.run, file: null };
2460
+ return;
2461
+ }
2462
+ let data;
2463
+ try { data = readFileSync(abs); } catch (err) {
2464
+ task.runDeclError = { reason: 'file-unreadable', message: `file "${rel}" could not be read: ${String(err?.code ?? err).slice(0, 80)}`, snippet: rel };
2465
+ sendAct(task, `run declaration's file "${rel}" could not be read: ${String(err?.code ?? err).slice(0, 80)}`);
2466
+ task.run = { ...task.run, file: null };
2467
+ return;
2468
+ }
2469
+ const outDir = join(logDir, 'uploads');
2470
+ mkdirSync(outDir, { recursive: true });
2471
+ const safe = basename(rel).replace(/[^\w.-]/g, '_').slice(-80);
2472
+ const outFile = `${Date.now()}-artifact-${task.id}-${safe}`;
2473
+ try {
2474
+ writeFileSync(join(outDir, outFile), data);
2475
+ } catch (err) {
2476
+ task.runDeclError = { reason: 'file-attach-failed', message: `file "${rel}" could not be attached: ${String(err?.code ?? err).slice(0, 80)}`, snippet: rel };
2477
+ sendAct(task, `run declaration's file "${rel}" could not be attached: ${String(err?.code ?? err).slice(0, 80)}`);
2478
+ task.run = { ...task.run, file: null };
2479
+ return;
2480
+ }
2481
+ // No process to start for a static file — the Review card renders this as a
2482
+ // plain link (like a PR link), not the "Start it" button.
2483
+ task.run = { ...task.run, url: `/upload/${outFile}`, cmd: null };
2484
+ sendProcessAct(task, `Worker attached a file to open: ${rel}`);
2485
+ }
2486
+
2431
2487
  // A direct/shared workspace can still contain a declaration left by an older
2432
2488
  // run (for example when that run was interrupted before readRunDeclaration).
2433
2489
  // Remove it before this task starts so only a file created by THIS worker can
@@ -3532,6 +3588,18 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
3532
3588
  hasPassCondition: siblings.some((t) => t.passCondition),
3533
3589
  hasAnyProof: siblings.some((t) => t.proof),
3534
3590
  });
3591
+ // goal-751: wantsPR but the worker changed nothing and proved nothing (typically a
3592
+ // lane-conflict report promising an automatic resume that a one-shot `claude -p`
3593
+ // run can never deliver). Advisory only — see isNothingVerifiableForPR in lib.mjs.
3594
+ const prNothingVerifiable = isNothingVerifiableForPR({
3595
+ wantsPR: goal.wantsPR,
3596
+ changedFiles,
3597
+ hasAnyProof: siblings.some((t) => t.proof),
3598
+ });
3599
+ goal.prNote = prNothingVerifiable
3600
+ ? 'workerはコード変更をせずに終了しました(PRを求めていたのに0ファイル変更)。実装は行われていない可能性があります。'
3601
+ : null;
3602
+ if (goal.prNote) goalAct(`PR advisory (not blocking): ${goal.prNote}`);
3535
3603
  if (testsFailing) {
3536
3604
  const failure = recordGoalFailure(goal, {
3537
3605
  kind: 'test',
@@ -3608,7 +3676,7 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
3608
3676
  const preNote = goal.testResult?.preExistingOnly
3609
3677
  ? ` (${goal.testResult.preExisting?.length ?? 0} pre-existing test failure(s) ignored as unrelated to this change)`
3610
3678
  : '';
3611
- goalAct(`Verification gate passed; moving goal to ${goal.status}.${preNote}${goal.proofNote ? ` [proof advisory: ${goal.proofNote}]` : ''}`);
3679
+ goalAct(`Verification gate passed; moving goal to ${goal.status}.${preNote}${goal.proofNote ? ` [proof advisory: ${goal.proofNote}]` : ''}${goal.prNote ? ` [PR advisory: ${goal.prNote}]` : ''}`);
3612
3680
  }
3613
3681
  }
3614
3682
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.72",
3
+ "version": "0.10.73",
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": {