@galda/cli 0.10.69 → 0.10.70

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
@@ -297,9 +297,8 @@
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
299
  .ctxdiv{width:1px;align-self:stretch;margin:5px 0;background:var(--hair);flex:0 0 1px}
300
- /* worktreeMasa 2026-07-23: 「claude code が外れるだけなのでそれでいきたい。機能も
301
- claude code と同じにしたい」。OFF just loses its tick and sinks; no amber, no note, no
302
- dialog. ON is --ink, never --green: in this product green means "it passed" (PR green,
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,
303
302
  tests green, Token green), and a settings checkbox borrowing it makes that green cheap.
304
303
  The tick is knocked out in --panel (the surface it sits on), NOT --invink. */
305
304
  .ctxwt{font-size:11.5px;color:var(--ink2)}
@@ -309,6 +308,9 @@
309
308
  .ctxwt[aria-checked="true"] .ctxbox{background:var(--ink);border-color:var(--ink)}
310
309
  .ctxwt[aria-checked="true"] .ctxbox::after{content:"";position:absolute;left:3px;top:0;width:3px;height:7px;
311
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}
312
314
  .ctxmenu{min-width:230px;max-width:420px}
313
315
  /* The folder list is galda's own screen (Claude has none — it opens ONE folder; galda
314
316
  moves between repos). The one thing that actually goes wrong is that names alone look
@@ -568,6 +570,8 @@
568
570
  .blockreason{color:var(--amber)}
569
571
  .rvcheck2{font-size:15px;font-weight:600;color:var(--ink);line-height:1.5;margin-bottom:4px}
570
572
  .rvchanged{font-size:12.5px;color:var(--ink2);line-height:1.5;margin-bottom:6px}
573
+ .rvinfra{font-size:12.5px;color:var(--ink2);line-height:1.5;margin:8px 0 6px;padding:8px 10px;border:1px solid rgba(var(--amber-rgb),.35);border-radius:8px;background:rgba(var(--amber-rgb),.06)}
574
+ .rvinfra .retrybtn{margin-left:8px}
571
575
  .rvtokenopt{font-family:var(--mono);font-size:10.5px;font-weight:700;color:var(--green);line-height:1.45;margin:2px 0 7px}
572
576
  .rvlbl{display:inline-block;font-family:var(--mono);font-size:9px;letter-spacing:.06em;color:var(--ink3);background:var(--hair2);border-radius:4px;padding:1px 6px;margin-right:7px;vertical-align:1px}
573
577
  /* goal 427 §4: change-risk section on the review card. §5.5/§1.5-8: an
@@ -721,9 +725,19 @@
721
725
  .review-fly .name.flystrike{text-decoration:line-through;text-decoration-color:var(--green);
722
726
  opacity:.7;transition:opacity .25s ease}
723
727
 
724
- /* task 121: Approve確定の瞬間に一度だけ弾ける紙吹雪。位置/色/回転はJSで都度
725
- ランダムに設定するので、ここは土台だけ。 */
728
+ /* Approve celebration: bright Figma-like confetti plus a short, explicit
729
+ completion state inside the Review dialog before it closes. */
726
730
  .confetti-piece{position:fixed;pointer-events:none;z-index:9999;will-change:transform,opacity}
731
+ .sa-finish{position:fixed;z-index:10000;display:grid;place-items:center;pointer-events:none;overflow:hidden;border-radius:16px;
732
+ background:color-mix(in srgb,var(--canvas) 20%,transparent);backdrop-filter:blur(2px);animation:saFinishFade .9s ease both}
733
+ .sa-finish-mark{width:104px;height:104px;border-radius:50%;display:grid;place-items:center;background:#18c77a;
734
+ box-shadow:0 18px 52px rgba(24,199,122,.34);animation:saFinishPop .72s cubic-bezier(.2,1.35,.35,1) both}
735
+ .sa-finish-mark svg{width:56px;height:56px;fill:none;stroke:#fff;stroke-width:3.4;stroke-linecap:round;stroke-linejoin:round}
736
+ .sa-finish-mark path{stroke-dasharray:70;stroke-dashoffset:70;animation:saFinishDraw .42s ease .18s forwards}
737
+ @keyframes saFinishPop{0%{transform:scale(.45);opacity:0}62%{transform:scale(1.1);opacity:1}100%{transform:scale(1)}}
738
+ @keyframes saFinishDraw{to{stroke-dashoffset:0}}
739
+ @keyframes saFinishFade{0%,72%{opacity:1}100%{opacity:0}}
740
+ @media (prefers-reduced-motion:reduce){.sa-finish,.sa-finish-mark,.sa-finish-mark path{animation:none}.sa-finish{background:transparent}}
727
741
 
728
742
  /* connect modal */
729
743
  /* A confirm dialog (deleteConfirmOverlay etc.) must always sit above other
@@ -1758,10 +1772,9 @@
1758
1772
  /* ---- + New (CDO decision 5, Masa 2026-07-17): a quiet add-row where + sits in the ● column
1759
1773
  and New in the text column — aligned to the row's own grid, not a box shoved right. The
1760
1774
  shared .paplus glyph is 13px (the same span as the ● dot), with the stroke re-centred inside
1761
- that box so the GLYPH lines up, not the box (Masa aesthetic: align glyphs, not boxes). Click
1762
- = focus the composer (#input) — the one real create path, no invented row / server call. On
1763
- the board it lives ONLY in the To Do column: a new goal is a new to-do; you don't "create"
1764
- into Done/Review/Doing. ---- */
1775
+ that box so the GLYPH lines up, not the box (Masa aesthetic: align glyphs, not boxes). The
1776
+ bottom List row focuses the composer; the Later row below the Later caption posts pending:true
1777
+ directly so it shelves work without spawning a worker. ---- */
1765
1778
  #fsRoot .paplus{width:13px;height:13px;flex:0 0 13px;position:relative;display:inline-block}
1766
1779
  #fsRoot .paplus::before,#fsRoot .paplus::after{content:"";position:absolute;background:currentColor;border-radius:1px}
1767
1780
  #fsRoot .paplus::before{left:5.85px;top:2px;width:1.3px;height:9px}
@@ -1769,6 +1782,10 @@
1769
1782
  /* list rail add-row — padding-left:8px puts + on the .trow ● (8px pad + 13px ● + 9px gap grid) */
1770
1783
  #fsRoot .qadd{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:9px;width:100%;padding:6px 4px 6px 8px;border-radius:8px;color:var(--ink3);font-size:11.5px;cursor:pointer;opacity:.45;transition:opacity .15s,color .15s}
1771
1784
  #fsRoot .qadd:hover{opacity:1;color:var(--ink)}
1785
+ #fsRoot .lateridle{display:flex;align-items:center;gap:9px;margin:5px 0 2px;padding:9px 10px;border:1px solid var(--hair);border-radius:8px;color:var(--ink2);font-size:10.5px;line-height:1.45}
1786
+ #fsRoot .lateridle .msg{flex:1;min-width:0}
1787
+ #fsRoot .lateridle button{height:26px;padding:0 10px;border:1px solid var(--hair2);border-radius:7px;background:var(--panel);color:var(--ink);font:600 10px/1 var(--mono);cursor:pointer;white-space:nowrap}
1788
+ #fsRoot .lateridle button:hover{border-color:var(--ink3)}
1772
1789
  /* board To Do column add-row — 2px body pad + 10px card pad = 12px lines + up with the card's #NN */
1773
1790
  #fsRoot .fsbadd{margin:5px 2px 0;padding:6px 10px;border-radius:7px;border:0;background:none;color:var(--ink3);font:400 11.5px/1 var(--ui);text-align:left;cursor:pointer;display:flex;align-items:center;gap:9px;opacity:.45;transition:opacity .15s,color .15s}
1774
1791
  #fsRoot .fsbadd:hover{opacity:1;color:var(--ink2)}
@@ -1802,7 +1819,7 @@
1802
1819
  #fsRoot .sm2 .m-bar{height:3px;border-radius:2px;background:var(--hair);margin:8px 0 6px;overflow:hidden}
1803
1820
  #fsRoot .sm2 .m-bar i{display:block;height:100%;background:var(--green)}
1804
1821
  #fsRoot .sm2 .s{font-size:10.5px;color:var(--ink3)}
1805
- /* Review header + 3 view buttons (rv3 thumbnails default / rv1 list / rv2 compact) */
1822
+ /* Review header. Review cards use one stable, screenshot-free row design. */
1806
1823
  #fsRoot .revhd{display:flex;align-items:baseline;gap:8px;margin-bottom:11px}
1807
1824
  #fsRoot .revhd[hidden]{display:none}
1808
1825
  #fsRoot .revhd .hd{font-size:13.5px;letter-spacing:-.02em}
@@ -1816,28 +1833,30 @@
1816
1833
  it duplicated the sm2 meter directly above. Reviews render as meter + header + cards;
1817
1834
  zero reviews render nothing at all (§1.5-3). */
1818
1835
  #fsRoot .revlist{display:block}
1819
- /* rcard base (rv1 list) */
1820
- #fsRoot .rcard{margin-top:9px;border:1px solid var(--hair);border-radius:11px;padding:9px 10px;display:flex;gap:10px;align-items:center;cursor:pointer}
1821
- #fsRoot .rcard img{width:56px;height:38px;border-radius:6px;object-fit:cover;box-shadow:0 0 0 1px var(--hair);background:var(--thumbbg);flex:0 0 56px}
1836
+ /* Approved B Status dot: borderless, title-only Review rows. Details and proof
1837
+ stay in the Review dialog, where they can be inspected without bloating the board. */
1838
+ #fsRoot .rcard{position:relative;min-height:31px;margin-top:1px;border:0;border-radius:0;padding:5px 4px 5px 15px;display:flex;gap:7px;align-items:center;background:transparent;box-shadow:none;cursor:pointer}
1839
+ #fsRoot .rcard::before{content:"";position:absolute;left:4px;top:50%;width:5px;height:5px;border-radius:50%;background:var(--green);transform:translateY(-50%)}
1840
+ #fsRoot .rcard img{display:none}
1822
1841
  #fsRoot .rcard .rc{flex:1;min-width:0}
1823
- #fsRoot .rcard .t{font-size:12.5px;font-weight:500;line-height:1.35;color:color-mix(in srgb,var(--ink) 84%,var(--panel))} /* §13.2 soften imageless review heading; rv3 photo-overlay stays #fff (more specific rule below) */
1842
+ #fsRoot .rcard .t{font-size:11.5px;font-weight:500;line-height:1.35;color:color-mix(in srgb,var(--ink) 84%,var(--panel));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1824
1843
  #fsRoot .rcard .pr{font-family:var(--mono);font-size:9.5px;color:var(--green);background:rgba(var(--green-rgb),.12);border-radius:5px;padding:2px 7px;font-weight:600;margin-top:3px;display:inline-block;text-decoration:none}
1844
+ #fsRoot .rcard .rstat{font-family:var(--mono);font-size:9.5px;color:var(--ink3);letter-spacing:.04em;text-transform:uppercase;margin-top:3px;display:inline-block}
1845
+ #fsRoot .rcard .rstat.conflict{color:var(--amber)}
1846
+ #fsRoot .rcard .pr,#fsRoot .rcard .rstat,#fsRoot .rcard .rnote{display:none}
1847
+ #fsRoot .rcard.foldrow{border-style:dashed;background:transparent}
1848
+ #fsRoot .rcard.foldrow .rstat{color:var(--ink2)}
1825
1849
  /* review row number — #NN PREFIX on the title (HANDOFF-v45 §11), same form as the To Do
1826
1850
  rows (PRD §5: numbering matches the sidebar) */
1827
1851
  #fsRoot .rcard .rid{font-size:10.5px;font-weight:600;color:var(--ink3);margin:0 5px 0 0;display:inline-block;font-variant-numeric:tabular-nums}
1828
- /* PRD §6.5 Masa 2026-07-21: the fold note ("also carries #13" on the carrier card,
1829
- "merged into #12" on the folded card) no longer renders in the Review list at all —
1830
- it read as the same report-in-the-list clutter the feature was meant to avoid. Where
1831
- folded work went is now logged in the opened detail's "Also carries" section only, so
1832
- the .fnote / .rcard.fold* rules that used to style these list-level notes are gone
1833
- with them (dead CSS orphaned the moment nothing emits those classes any more). */
1852
+ /* Folded work must stay visible in List view: it is a settled pointer, not a vanished task. */
1834
1853
  /* reference numbers everywhere else (HANDOFF-v45 §11): a leading #NN prefix on every
1835
1854
  To Do item's name — same goal = same number (goalReviewNumber), distinct from the
1836
1855
  PR's own #id shown separately. */
1837
1856
  #fsRoot .trow .gno{font-size:10.5px;font-weight:600;color:var(--ink3);margin-right:5px;flex:0 0 auto;font-variant-numeric:tabular-nums}
1838
1857
  #fsRoot .rcard .act{display:flex;gap:5px;opacity:0;transition:opacity .15s} /* hover-only ✓/✕ (§1.5-10) */
1839
1858
  #fsRoot .rcard:hover .act{opacity:1}
1840
- #fsRoot .rcard .b{width:24px;height:24px;border-radius:7px;border:1px solid var(--hair2);background:transparent;display:grid;place-items:center;cursor:pointer;color:var(--green);font-size:11px;font:inherit}
1859
+ #fsRoot .rcard .b{width:21px;height:21px;border-radius:7px;border:1px solid var(--hair2);background:transparent;display:grid;place-items:center;cursor:pointer;color:var(--green);font-size:11px;font:inherit}
1841
1860
  #fsRoot .rcard .b.no{color:var(--danger)}
1842
1861
  #fsRoot .rcard .b:hover{background:var(--hover)}
1843
1862
  /* rv2 compact one-line rows */
@@ -2141,6 +2160,9 @@
2141
2160
  /* body prose = the app's normal text: 14px / −.012em, matching .bubble (Masa 2026-07-20:
2142
2161
  13px read thinner/lighter for Japanese than the surrounding app). §2. */
2143
2162
  .redesign .rbody{font-size:14px;line-height:1.62;letter-spacing:-.012em;color:var(--ink);max-width:70ch;margin:0 !important}
2163
+ .redesign .rinfra{padding:10px 12px;border:1px solid rgba(var(--amber-rgb),.35);border-radius:8px;background:rgba(var(--amber-rgb),.06)}
2164
+ .redesign .rinfra .rbody{color:var(--ink2)}
2165
+ .redesign .rinfra .retrybtn{margin-top:8px;margin-left:0}
2144
2166
  .redesign .rdesc,.redesign .rrisk{color:var(--ink2)}
2145
2167
  /* Result (P1, Masa 2026-07-21): the AI's own report, rendered as written. Same 14px
2146
2168
  body rhythm as .rbody — it is prose the reviewer reads, not a log. Headings/lists/
@@ -2330,6 +2352,8 @@
2330
2352
  /* Doing-failure state word (HANDOFF-v45 §11): colour TEXT only, no badge/pill surface —
2331
2353
  Failed = red, Interrupted = amber. */
2332
2354
  #fsRoot .trow .stword{font-size:10.5px;font-weight:600;color:var(--danger);flex:0 0 auto}
2355
+ #fsRoot .trow .stword.run{color:var(--green)}
2356
+ #fsRoot .trow .stword.wait{color:var(--ink3)}
2333
2357
  #fsRoot .trow .stword.int{color:var(--amber)}
2334
2358
  #fsRoot .trow .st.ok{border-color:transparent;background:rgba(var(--green-rgb),.12);position:relative}
2335
2359
  #fsRoot .trow .st.ok::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;font-size:8px;color:var(--green)}
@@ -2986,10 +3010,11 @@
2986
3010
  <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>
2987
3011
  <span class="ctxdiv"></span>
2988
3012
  <button type="button" class="ctxseg ctxwt" id="ctxwt" role="checkbox" aria-checked="true"
2989
- title="On: each goal works in its own copy, so this folder is never touched. Off: the work happens right here.">
2990
- <i class="ctxbox"></i>worktree</button>
3013
+ title="isolated worktree: default clean copy. direct workspace: inherits and edits this folder.">
3014
+ <i class="ctxbox"></i><span id="ctxwtlabel">isolated worktree</span></button>
2991
3015
  </div>
2992
3016
  </div>
3017
+ <div class="ctxworkwarn" id="ctxworkwarn" hidden></div>
2993
3018
  <!-- Conversation-first composer (SLICE 2, docs/HANDOFF-ONBOARDING-conversational.md):
2994
3019
  a chat/help/settings message gets a quiet reply HERE instead of
2995
3020
  becoming a To-Do. Reuses the .peraanswer surface; hidden until a
@@ -3081,7 +3106,7 @@
3081
3106
  Structure ported from docs/design/flagship/flagship.html: rail | Implementing | lane.
3082
3107
  The composer (#composerWrap: queue + composer + send/prism ring) is ADOPTED into
3083
3108
  #fsCompwrap by positionLayout() — same nodes, same handlers, input-focus fix intact. -->
3084
- <div id="fsRoot" data-rv="3">
3109
+ <div id="fsRoot" data-rv="1">
3085
3110
  <div class="fscard fsrail">
3086
3111
  <button class="railtg" id="fsRailTg" title="Fold the rail"></button><!-- panel icon filled by tgUpdate (v45 §4) -->
3087
3112
  <button class="fsprojbtn" id="fsProjBtn" title="Switch project"><span class="pname" id="fsProjBtnName"></span><svg viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></button>
@@ -3373,7 +3398,7 @@ const savedTheme = (() => {
3373
3398
  if (v !== s) localStorage.setItem('theme', v);
3374
3399
  return v;
3375
3400
  })();
3376
- 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, 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,
3401
+ 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,
3377
3402
  // per-message send overrides set via slash-commands (reset after each send,
3378
3403
  // except model which is sticky in localStorage). palette = open command list.
3379
3404
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
@@ -3704,6 +3729,7 @@ function buildReviewDigest({ goals, tasks } = {}){
3704
3729
  checkLine: plan ? `PLAN — approve to execute: ${checkLine}`.slice(0, 90) : checkLine,
3705
3730
  pr: g.pr ?? null,
3706
3731
  testResult: g.testResult ?? null,
3732
+ verificationInfraError: g.verificationInfraError ?? null,
3707
3733
  proof: proofTask?.proof ?? null,
3708
3734
  proofTaskId: proofTask?.id ?? null,
3709
3735
  plan,
@@ -3715,6 +3741,7 @@ function buildReviewDigest({ goals, tasks } = {}){
3715
3741
  if (row.reviewNumber != null && (existing.reviewNumber == null || row.reviewNumber < existing.reviewNumber)) existing.reviewNumber = row.reviewNumber;
3716
3742
  if (existing.proof == null) { existing.proof = row.proof; existing.proofTaskId = row.proofTaskId; }
3717
3743
  if (existing.testResult == null) existing.testResult = row.testResult;
3744
+ if (existing.verificationInfraError == null) existing.verificationInfraError = row.verificationInfraError;
3718
3745
  continue;
3719
3746
  }
3720
3747
  if (row.pr) byPr.set(row.pr, row);
@@ -4247,7 +4274,11 @@ function goalRow(g){
4247
4274
  const summary = GOAL_COMPLETE_STATUSES.includes(g.status) && g.summary
4248
4275
  ? `<div class="gsummary"><div class="gslabel"><span class="dot"></span>Manager結論サマリ</div>${esc(g.summary)}</div>` : '';
4249
4276
  const pr = g.pr ? `<a class="prlink" href="${esc(g.pr)}" target="_blank">${g.status === 'review' ? 'In review — open the Pull Request' : 'Open the Pull Request'}</a>`
4250
- : g.prError ? `<div class="prfail">PR failed: ${esc(g.prError)}</div>` : '';
4277
+ : g.prError ? (() => {
4278
+ const meta = (() => { const raw = g.prErrorClass || inferErrorClass(`PR step failed: ${g.prError}`, g.agent); const C = FAILURE_COPY[failureLang()]; return { ...raw, title: C.infra.title, body: C.infra[raw.reason] || C.infra.pr, copy: C }; })();
4279
+ const repair = g.prRepair ? `<div class="prrepair"><b>${esc(g.prRepair.title || 'Repair')}</b><ul>${(g.prRepair.steps || []).map((s) => `<li>${esc(s)}</li>`).join('')}</ul>${g.prRepair.dirtyFiles?.length ? `<div class="dim">dirty: ${g.prRepair.dirtyFiles.map(esc).join(' · ')}</div>` : ''}</div>` : '';
4280
+ return `<div class="prfail"><b>${esc(meta.title)}</b> — ${esc(meta.body)} <span class="dim">${esc(g.prError)}</span>${repair}<div class="acts2">${errorButtonsHtml(meta, { goalId: g.id, agent: g.agent, pr: true })}</div></div>`;
4281
+ })() : '';
4251
4282
  // task 96: PRなしのreviewゴールは、チャット内で要約+個別Approve+チェックリスト導線を出す。
4252
4283
  const reviewBox = g.status === 'review' && !g.pr ? reviewActionsHtml(g) : '';
4253
4284
  const active = replies.some((r) => ['running', 'queued'].includes(r.status));
@@ -4354,6 +4385,9 @@ function renderStream(){
4354
4385
  for (const b of $('stream').querySelectorAll('.reqopen')) {
4355
4386
  b.onclick = () => openReviewChecklist(Number(b.dataset.goal));
4356
4387
  }
4388
+ for (const b of $('stream').querySelectorAll('[data-fsprretry]')) {
4389
+ b.onclick = () => fsRetryPrGoal(b);
4390
+ }
4357
4391
  // task 96: chat-side per-requirement Approve — same flow as the checklist
4358
4392
  // (setRequirementStatus toggles the approval and auto-advances the goal when
4359
4393
  // every requirement is approved, then render() redraws this thread).
@@ -4422,6 +4456,17 @@ function renderQueue(){
4422
4456
  // layouts keep the explanatory sentence.
4423
4457
  const qn = ob.length + stacked.length + planning.length;
4424
4458
  const headHtml = qn > 0 ? `<div class="qhead">${state.layout === 'flagship' ? `Queued · ${qn}` : `${qn} queued · goals become To dos in ~30s`}</div>` : '';
4459
+ const stackedMeta = (g) => {
4460
+ const project = state.projects.find((p) => p.id === g.projectId);
4461
+ if (project?.paused) return 'Paused · press play to start';
4462
+ const activeIds = [...new Set([
4463
+ ...state.tasks.filter((t) => t.projectId === g.projectId && t.status === 'running' && t.goalId != null).map((t) => t.goalId),
4464
+ ...state.goals.filter((x) => x.projectId === g.projectId && x.status === 'planning').map((x) => x.id),
4465
+ ])].filter((id) => id !== g.id);
4466
+ const cap = state.parallelLimits?.project ?? 2;
4467
+ if (activeIds.length >= cap) return `Waiting for parallel slot · active ${activeIds.map((id) => `#${id}`).join(', ')} · starts when one reaches Review/Done · Run next / switch Agent`;
4468
+ return `Next up · starts automatically · Agent: ${esc(g.agent || state.connectedApp || 'worker')}`;
4469
+ };
4425
4470
  q.innerHTML = connHtml + headHtml + obRows + planningRows + stacked.map((g) => {
4426
4471
  if (state.editing === g.id) {
4427
4472
  return `<textarea class="qedit" id="qedit-${g.id}">${esc(g.text)}</textarea>
@@ -4434,7 +4479,7 @@ function renderQueue(){
4434
4479
  <button class="gbtn qsave" data-id="${g.id}">Save</button><button class="gbtn qcancel">Cancel</button>
4435
4480
  </div>`;
4436
4481
  }
4437
- return `<div class="qrow" draggable="true" data-goal="${g.id}"><span class="qdot"></span><span class="qtxt">${esc(g.text.replace(/\s+/g, ' '))}</span>
4482
+ return `<div class="qrow" draggable="true" data-goal="${g.id}"><span class="qdot"></span><span class="qtxt">${esc(g.text.replace(/\s+/g, ' '))}</span><span class="meta">${stackedMeta(g)}</span>
4438
4483
  <button class="qico qe" data-id="${g.id}" title="Edit">${ICONS.pencil}</button>
4439
4484
  <button class="qico qp" data-id="${g.id}" title="Run next">${ICONS.up}</button>
4440
4485
  <button class="qico del qd" data-id="${g.id}" title="Delete">${ICONS.trash}</button></div>`;
@@ -5324,6 +5369,8 @@ function renderReviewChecklist(){
5324
5369
  const hasProof = state.tasks.some((t) => t.goalId === goal.id && (t.proof?.png || t.proof?.gif));
5325
5370
  const VF = { pass: 'tests pass', fail: 'tests fail', untested: 'not tested', proof: 'proof', noproof: 'no proof', human: 'needs you' }; // #377-7: chrome is English
5326
5371
  const vchips = [];
5372
+ vchips.push(`<span class="vchip none">${esc(workspaceModeText(goal))}</span>`);
5373
+ if (goal.workspaceWarning) vchips.push(`<span class="vchip human high">⚠ ${esc(goal.workspaceWarning.slice(0, 80))}</span>`);
5327
5374
  vchips.push(gtr?.ran
5328
5375
  ? (gtr.ok ? `<span class="vchip ok">✓ ${VF.pass}</span>` : `<span class="vchip no">✗ ${VF.fail}</span>`)
5329
5376
  : `<span class="vchip none">— ${VF.untested}</span>`);
@@ -5335,10 +5382,15 @@ function renderReviewChecklist(){
5335
5382
  const failureHtml = fm?.whatHappened
5336
5383
  ? `<div class="rvchanged"><span class="rvlbl">Failure memory</span>${esc(fm.whatHappened)} ${fm.nextPolicy ? `<span class="muted">${esc(fm.nextPolicy)}</span>` : ''}</div>`
5337
5384
  : '';
5385
+ const infra = goal.verificationInfraError;
5386
+ const infraHtml = infra?.detail
5387
+ ? `<div class="rvinfra"><span class="rvlbl">${esc(infra.title || '検証基盤エラー')}</span>${esc(infra.detail)} <button type="button" class="retrybtn" data-goalreverify="${goal.id}">再検証</button></div>`
5388
+ : '';
5338
5389
  $('reviewSummary').innerHTML =
5339
5390
  vfyHtml
5340
5391
  + (grs.check ? `<div class="rvcheck2"><span class="rvlbl">${L.check}</span>${esc(grs.check)}</div>` : '')
5341
5392
  + (grs.changed ? `<div class="rvchanged"><span class="rvlbl">${L.changed}</span>${esc(grs.changed)}</div>` : '')
5393
+ + infraHtml
5342
5394
  + failureHtml
5343
5395
  + optHtml
5344
5396
  + `<div class="rvhead">${testLine}${esc(sum.headline)} · ${esc(sum.detail)}${prLink ? ` · ${prLink}` : ''}</div>`
@@ -5401,6 +5453,15 @@ function renderReviewChecklist(){
5401
5453
  for (const c of $('reviewList').querySelectorAll('[data-reopen]')) {
5402
5454
  c.onclick = () => setRequirementStatus(goalId, Number(c.dataset.reopen), 'approved'); // same status toggles it off
5403
5455
  }
5456
+ for (const b of $('reviewSummary').querySelectorAll('[data-goalreverify]')) {
5457
+ b.onclick = async (e) => {
5458
+ e.stopPropagation();
5459
+ b.disabled = true;
5460
+ b.textContent = '再検証中…';
5461
+ const r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' });
5462
+ if (r.ok) await refresh(); else showErr('再検証に失敗');
5463
+ };
5464
+ }
5404
5465
  for (const b of $('reviewList').querySelectorAll('.reqsend')) {
5405
5466
  b.onclick = async () => {
5406
5467
  const taskId = Number(b.dataset.task);
@@ -5456,15 +5517,15 @@ function playReviewFly(capture){
5456
5517
  }
5457
5518
  // task 121: 承認が確定した瞬間に一度だけ紙吹雪を弾けさせる共通フック。両方の
5458
5519
  // 承認経路(reviewApprove.onclick / advanceGoalAfterAllApproved)から呼ぶ。
5459
- // --iri(虹グラデーション)はAI要素専用の配色なので使わず、成功色の--green/
5460
- // --amberと中立色の--ink系だけに絞った数色にとどめる。
5461
- const CONFETTI_COLORS = ['var(--green)', 'var(--amber)', 'var(--ink)', 'var(--ink2)'];
5520
+ // Figma's playful primary family: vivid enough to read as a reward on both
5521
+ // light and dark themes while the green check remains the success anchor.
5522
+ const CONFETTI_COLORS = ['#F24E1E', '#FF7262', '#A259FF', '#1ABCFE', '#0ACF83', '#FFD166'];
5462
5523
  function celebrateApprove(){
5463
5524
  const dm = $('reviewDoneMsg');
5464
5525
  const rect = dm && dm.offsetParent ? dm.getBoundingClientRect() : null;
5465
5526
  const originX = rect ? rect.left + Math.min(28, rect.width / 2) : window.innerWidth / 2;
5466
5527
  const originY = rect ? rect.top + rect.height / 2 : window.innerHeight * 0.3;
5467
- for (let i = 0; i < 16; i++){
5528
+ for (let i = 0; i < 42; i++){
5468
5529
  const el = document.createElement('div');
5469
5530
  el.className = 'confetti-piece';
5470
5531
  const size = 6 + Math.random() * 6;
@@ -5478,7 +5539,7 @@ function celebrateApprove(){
5478
5539
  el.style.transition = 'transform .9s cubic-bezier(.15,.7,.3,1), opacity .9s ease .25s';
5479
5540
  document.body.appendChild(el);
5480
5541
  const angle = Math.random() * Math.PI * 2;
5481
- const dist = 60 + Math.random() * 120;
5542
+ const dist = 90 + Math.random() * 190;
5482
5543
  const dx = Math.cos(angle) * dist;
5483
5544
  const dy = Math.sin(angle) * dist * 0.6 + 90; // outward burst, then settle downward like falling confetti
5484
5545
  const rot = (Math.random() < 0.5 ? -1 : 1) * (360 + Math.random() * 360);
@@ -5489,6 +5550,34 @@ function celebrateApprove(){
5489
5550
  setTimeout(() => el.remove(), 1000);
5490
5551
  }
5491
5552
  }
5553
+
5554
+ // A merged PR is the terminal Review moment. Show the result in the dialog the
5555
+ // person is looking at, then remove that completed review instead of leaving a
5556
+ // stale modal behind. The state update arrives in the same SSE event, so render()
5557
+ // after close also removes the card from the Review lane.
5558
+ function finishOpenReview(goal){
5559
+ const ids = SA.items.flatMap((it) => it.ids || [it.goalId]);
5560
+ if (!SA.open || !ids.includes(goal.id)) { celebrateApprove(); return; }
5561
+ const layer = document.createElement('div');
5562
+ layer.className = 'sa-finish';
5563
+ layer.setAttribute('role', 'status');
5564
+ layer.setAttribute('aria-label', 'Review complete');
5565
+ layer.innerHTML = '<div class="sa-finish-mark"><svg viewBox="0 0 64 64" aria-hidden="true"><path d="M17 33l10 10 21-23"/></svg></div>';
5566
+ const panel = $('seeall').querySelector('.sa-panel')?.getBoundingClientRect();
5567
+ const bounds = panel ?? { left: 0, top: 0, width: innerWidth, height: innerHeight };
5568
+ Object.assign(layer.style, { left: `${bounds.left}px`, top: `${bounds.top}px`, width: `${bounds.width}px`, height: `${bounds.height}px` });
5569
+ // Keep the completion layer outside #seeall: the same SSE schedules a normal
5570
+ // render, which rebuilds the dialog while this short animation is playing.
5571
+ document.body.appendChild(layer);
5572
+ const mark = layer.querySelector('.sa-finish-mark').getBoundingClientRect();
5573
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
5574
+ if (!reduce) {
5575
+ confettiBurst(mark.left + mark.width / 2, mark.top + mark.height / 2);
5576
+ celebrateApprove();
5577
+ }
5578
+ const delay = reduce ? 350 : 950;
5579
+ setTimeout(() => { layer.remove(); saClose(); render(); }, delay);
5580
+ }
5492
5581
  async function setRequirementStatus(goalId, taskId, status){
5493
5582
  const approvals = (state.reviewApprovals[goalId] ??= {});
5494
5583
  approvals[taskId] = approvals[taskId] === status ? undefined : status; // click again to undo
@@ -6235,7 +6324,7 @@ function render(){
6235
6324
  // queueOrder); actions call the SAME endpoints (approve/dismiss/retry/reply…).
6236
6325
  // ============================================================================
6237
6326
  const fsUI = {
6238
- rv: [1, 2, 3].includes(Number(localStorage.getItem('fsRv'))) ? Number(localStorage.getItem('fsRv')) : 3, // review view: 3 thumbnails (default) / 1 list / 2 compact
6327
+ rv: 1, // approved screenshot-free B Status dot design; Review has one stable view
6239
6328
  band: Number(localStorage.getItem('fsBand') ?? 0), // theme-6 band choice
6240
6329
  revMore: false, // "+N more" fold on the review list (§1.5-8)
6241
6330
  todoFold: false, doneOpen: false, rejOpen: false, todoMore: false,
@@ -6340,9 +6429,6 @@ function fsActiveContext(){
6340
6429
  const focus = pinnedValid ? pinned : running[0] ?? closing[0] ?? failed[failed.length - 1] ?? null;
6341
6430
  return { tasks, running, failed, focus, goal: state.goals.find((g) => g.id === focus?.goalId) ?? null };
6342
6431
  }
6343
- // Per-project last non-empty center feed — the whiteout guard (see below): keeps
6344
- // what the user was looking at when a render pass momentarily produces no lines.
6345
- const _fsLastFeed = {};
6346
6432
  // H-CENTERLANE decision 1: pure merge-sort of timed HTML blocks (the focused task's
6347
6433
  // activity block + each chat turn) so the center lane renders in the order things
6348
6434
  // actually happened. Stable sort (spec-guaranteed since ES2019) keeps same-ts blocks
@@ -6416,13 +6502,14 @@ function renderFsFeed(){
6416
6502
  // it's continuing, so the ⎿ read as floating/orphaned. Now an independent state line:
6417
6503
  // colored icon + word only, no continuation glyph.
6418
6504
  const isInt = focus.status === 'interrupted';
6419
- const word = isInt ? 'Interrupted' : 'Failed', icon = isInt ? '⏸' : '✗', label = isInt ? 'Resume' : 'Retry';
6505
+ const word = isInt ? 'Interrupted' : 'Failed', icon = isInt ? '⏸' : '✗';
6506
+ const meta = taskErrorMeta(focus, goal);
6420
6507
  const reason = String(focus.result || '').trim();
6421
6508
  // no separate reason text (or it's just a restatement of the status itself) → don't
6422
6509
  // print "Interrupted — Interrupted"; the word + icon alone already say it
6423
6510
  const reasonSuffix = reason && reason.toLowerCase() !== word.toLowerCase() ? ` — ${esc(reason.replace(/\s+/g, ' ').slice(0, 140))}` : '';
6424
- html += `<div class="cc-err${isInt ? ' int' : ''}"><div class="msg"><span class="x${isInt ? ' int' : ''}">${icon} ${esc(word)}</span>${reasonSuffix}</div>
6425
- <div class="acts2"><button class="errbtn" data-fsretry="${focus.id}">${label}</button><button class="errbtn" data-fslog="${focus.id}">View log</button></div></div>`;
6511
+ html += `<div class="cc-err${isInt ? ' int' : ''}"><div class="msg"><span class="x${isInt ? ' int' : ''}">${icon} ${esc(word)}</span> <b>${esc(meta.title)}</b> — ${esc(meta.body)}${reasonSuffix}</div>
6512
+ <div class="acts2">${errorButtonsHtml(meta, { taskId: focus.id, goalId: focus.goalId, interrupted: isInt, agent: focus.agent || goal?.agent })}</div></div>`;
6426
6513
  }
6427
6514
  // AI question — while current it lives in the chat feed (cc-ask, §6.5 dynamic rule / q2).
6428
6515
  // Free-text answer row + delete (trash) restore lost behaviors from the pre-flagship
@@ -6452,15 +6539,9 @@ function renderFsFeed(){
6452
6539
  const app = connectedAppMeta(focus?.agent ?? goal?.agent);
6453
6540
  html = `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span>${meta ? `<span class="cs-d">${esc(meta)}${focus?.status === 'running' ? ' · worktree' : ''}</span>` : ''}</div>` + html;
6454
6541
  }
6455
- // BUG (whiteout / reset-to-loading): the Clawd HERO ("Claude Code / WAITING FOR
6456
- // GOALS") is the honest "this project has no work at all" first-run state it
6457
- // must NOT appear as a side effect of a transient empty render (SSE reconnect,
6458
- // a goal finishing before the next starts, a project switch, a refresh() that
6459
- // briefly reloads state). Gate it PURELY on whether the project has any
6460
- // goal/task; connection blips never change that, so the giant hero can no
6461
- // longer flash in and wipe what the user was looking at. While work exists but
6462
- // this pass produced no lines, keep the last known feed (or a calm one-line
6463
- // status) instead of blanking to the hero.
6542
+ // The center must describe current state. Keeping the previous task's activity
6543
+ // after it settled made an idle board look 77% active while the server had zero
6544
+ // workers. Empty live output now gets an explicit idle explanation instead.
6464
6545
  // Pattern A (H23): the conversation turns — the user's own messages, Claude
6465
6546
  // Code's replies (typed out with a caret on the streaming frontier), and
6466
6547
  // "→ Added to To Do #NN" markers — rendered from state.chatTurns so the
@@ -6500,8 +6581,12 @@ function renderFsFeed(){
6500
6581
  // end (H18/H19 — a pending question must never be lost mid-scroll).
6501
6582
  const timelineBlocks = html ? [{ ts: focusTs, html }, ...chatBlocks] : chatBlocks;
6502
6583
  let out = fsMergeTimelineBlocks(timelineBlocks) + askHtml;
6503
- if (out) _fsLastFeed[state.active] = out;
6504
- else if (hasWork) out = _fsLastFeed[state.active] || `<div class="cc-c">⎿ ${esc(running.length ? 'working…' : 'no active task')}</div>`;
6584
+ if (!out && hasWork) {
6585
+ const laterN = pgoals.filter((g) => g.status === 'pending').length;
6586
+ const queuedN = ptasks.filter((t) => t.status === 'queued').length + pgoals.filter((g) => ['planning', 'stacked'].includes(g.status)).length;
6587
+ const msg = running.length ? 'working…' : queuedN ? `${queuedN} queued · waiting to start` : laterN ? `no active task · ${laterN} saved in Later` : 'no active task';
6588
+ out = `<div class="cc-c">⎿ ${esc(msg)}</div>`;
6589
+ }
6505
6590
  $('fsRoot').classList.toggle('clawdhero', !hasWork);
6506
6591
  const sc = feed.parentNode;
6507
6592
  const nearBottom = sc.scrollHeight - sc.clientHeight - sc.scrollTop <= 80;
@@ -6512,6 +6597,9 @@ function renderFsFeed(){
6512
6597
  window.__fsCineSync?.(); // Cinema dynamic rule: content growth may reach the band region
6513
6598
  for (const b of feed.querySelectorAll('[data-fsretry]')) b.onclick = () => fsRetryTask(b.dataset.fsretry);
6514
6599
  for (const b of feed.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
6600
+ for (const b of feed.querySelectorAll('[data-fscodex]')) b.onclick = () => fsRunWithCodexGoal(b);
6601
+ for (const b of feed.querySelectorAll('[data-fsconnectworker]')) b.onclick = () => fsConnectWorker(b);
6602
+ for (const b of feed.querySelectorAll('[data-fscheckworker]')) b.onclick = () => fsCheckWorker(b);
6515
6603
  for (const b of feed.querySelectorAll('[data-fsanswer]')) b.onclick = () => fsAnswerQuestion(b.dataset.fsanswer, b.dataset.a);
6516
6604
  // Free-text answer (restores 質問カードの自由記述回答欄 — lost in the flagship rewrite,
6517
6605
  // see docs/confirmed-behaviors.md): Enter or the ↑ send button answers with typed text.
@@ -6550,6 +6638,38 @@ async function fsRetryTask(id){
6550
6638
  const r = await fetch(withKey(`/api/tasks/${id}/retry`), { method: 'POST' });
6551
6639
  if (!r.ok) showErr('Retry failed.'); else await refresh();
6552
6640
  }
6641
+ async function fsRunWithCodexGoal(b){
6642
+ b.disabled = true; b.textContent = failureLang() === 'ja' ? '開始中…' : 'Starting…';
6643
+ const r = await fetch(withKey(`/api/goals/${b.dataset.fscodex}/run-with-codex`), { method: 'POST' });
6644
+ if (!r.ok) showErr((await r.json().catch(() => ({}))).error || 'Could not start with Codex.');
6645
+ await refresh();
6646
+ }
6647
+ async function fsConnectWorker(b){
6648
+ b.disabled = true;
6649
+ try {
6650
+ const r = await fetch(withKey('/api/worker-auth/connect'), { method: 'POST' });
6651
+ if (!r.ok) throw new Error();
6652
+ showToast(failureLang() === 'ja' ? '接続画面を開きました。完了後に接続確認してください。' : 'Connection opened. Check connection when finished.');
6653
+ } catch { showErr(failureLang() === 'ja' ? '接続画面を開けませんでした。' : 'Could not open the connection.'); }
6654
+ finally { b.disabled = false; }
6655
+ }
6656
+ async function fsCheckWorker(b){
6657
+ b.disabled = true;
6658
+ try {
6659
+ const r = await fetch(withKey('/api/worker-auth/retry'), { method: 'POST' });
6660
+ const body = await r.json().catch(() => ({}));
6661
+ if (!r.ok) throw new Error();
6662
+ renderAuthBanner(body.auth);
6663
+ if (body.auth?.ok) showToast(failureLang() === 'ja' ? '接続できました。' : 'Connected.');
6664
+ } catch { showErr(failureLang() === 'ja' ? 'まだ接続できていません。' : 'Not connected yet.'); }
6665
+ finally { b.disabled = false; }
6666
+ }
6667
+ async function fsRetryPrGoal(b){
6668
+ b.disabled = true; b.textContent = failureLang() === 'ja' ? '再試行中…' : 'Retrying…';
6669
+ const r = await fetch(withKey(`/api/goals/${b.dataset.fsprretry}/retry-pr`), { method: 'POST' });
6670
+ if (!r.ok) { showErr((await r.json().catch(() => ({}))).error || 'PR retry failed.'); b.disabled = false; b.textContent = FAILURE_COPY[failureLang()].actions.pr; }
6671
+ else await refresh();
6672
+ }
6553
6673
  function fsToggleLog(taskId){
6554
6674
  const task = state.tasks.find((t) => t.id === taskId);
6555
6675
  if (task?.goalId == null) return;
@@ -6813,12 +6933,8 @@ function renderFsReview(){
6813
6933
  const pgoals = state.goals.filter((g) => g.projectId === state.active);
6814
6934
  const list = state.tasks.filter((t) => t.projectId === state.active);
6815
6935
  const rows = buildReviewDigest({ goals: pgoals, tasks: list });
6816
- // PRD §6.5 Masa 2026-07-23: a goal folded onto another review's PR does NOT get its own
6817
- // row in the Review list (Masa, seeing it live: "ここにはいらない・逆にわかりにくい"). Where
6818
- // its work went — and the control to restore it — live on the carrier's side only, in the
6819
- // opened detail's "Also carries" section (see saItemHtml's carriesBody). The list stays
6820
- // just the reviews; folded goals are explained where they were merged.
6821
- if (!rows.length) { el.innerHTML = ''; return; } // §1.5-3: no reviews → the UI itself does not exist
6936
+ const foldedRows = pgoals.filter((g) => g.status === 'folded' && g.foldedInto != null);
6937
+ if (!rows.length && !foldedRows.length) { el.innerHTML = ''; return; } // §1.5-3: no reviews → the UI itself does not exist
6822
6938
  const green = rows.filter((r) => !r.testResult || r.testResult.ok !== false).length;
6823
6939
  const blockedN = pgoals.filter((g) => g.status === 'blocked').length;
6824
6940
  const pct = rows.length ? Math.round((green / rows.length) * 100) : 0;
@@ -6831,12 +6947,6 @@ function renderFsReview(){
6831
6947
  const RCAP = 3;
6832
6948
  const shown = fsUI.revMore ? rows : rows.slice(0, RCAP);
6833
6949
  const card = (r) => {
6834
- const imgFile = r.proof?.png ?? r.proof?.gif;
6835
- // onerror: a broken proof (404 / expired key) removes the <img> so the card's
6836
- // dark ground shows through cleanly instead of a broken-image icon + "proof" alt
6837
- // text. Card height is guaranteed by the base rule's min-height, not the img.
6838
- const img = imgFile && r.proofTaskId != null
6839
- ? `<img src="${withKey(`/proof/${r.proofTaskId}/${imgFile}`)}" alt="proof" loading="lazy" onerror="this.remove()">` : '';
6840
6950
  const pr = r.pr ? `<a class="pr" href="${esc(r.pr)}" target="_blank">PR #${esc((r.pr.match(/pull\/(\d+)/) ?? [, ''])[1])} ↗</a>` : '';
6841
6951
  // #NN PREFIX on the title (HANDOFF-v45 §11 — same form/number as the To Do rows;
6842
6952
  // goalReviewNumber = goal.id, so a goal keeps the same number in Review and in To
@@ -6847,6 +6957,11 @@ function renderFsReview(){
6847
6957
  // retest ×3 note (v45 §5.3): the goal came back green from a Ledger Dismiss
6848
6958
  const g0 = pgoals.find((g) => g.id === r.goalIds[0]);
6849
6959
  const rnote = g0?.retest?.passed ? `<span class="rnote">✓ ${esc(g0.retest.note)}</span>` : '';
6960
+ const infraNote = r.verificationInfraError?.detail
6961
+ ? `<span class="rnote">検証基盤エラー · <button type="button" class="retrybtn" data-goalreverify="${r.goalIds[0]}">再検証</button></span>`
6962
+ : '';
6963
+ const conflictNote = g0?.conflictsWith?.length
6964
+ ? `<span class="rstat conflict">Potential conflict with ${esc(g0.conflictsWith.map((id) => `#${id}`).join(', '))}</span>` : '<span class="rstat">In review</span>';
6850
6965
  // PRD §6.5 → Masa 2026-07-21: goals folded onto this card's PR no longer get an
6851
6966
  // inline "+ #901 の仕事もここに入っています" note here — same report-in-the-list
6852
6967
  // clutter the old standalone fold row was (Masa: review欄にいらない). It is logged
@@ -6856,9 +6971,16 @@ function renderFsReview(){
6856
6971
  // (Masa 2026-07-20: this card showed reviewSummary.check "…が出ない" while the list
6857
6972
  // showed the request text). Plan-reviews keep their "PLAN — approve to execute:"
6858
6973
  // checkLine, which is the actionable label there, not a title mismatch.
6859
- return `<div class="rcard${img ? '' : ' noimg'}" data-fsrev="${r.goalIds[0]}">${img}<div class="rc"><div class="t">${rid}${esc(r.plan ? r.checkLine : saReviewTitle(g0))}</div>${rnote}${pr}</div>
6974
+ return `<div class="rcard noimg" data-fsrev="${r.goalIds[0]}"><div class="rc"><div class="t">${rid}${esc(r.plan ? r.checkLine : saReviewTitle(g0))}</div>${rnote}${infraNote}${pr}${conflictNote}</div>
6860
6975
  <div class="act"><button class="b" data-fsapprove="${ids}" title="Approve">✓</button><button class="b no" data-fsdismiss="${ids}" title="Send back">✕</button></div></div>`;
6861
6976
  };
6977
+ const foldCard = (g) => {
6978
+ const target = pgoals.find((x) => x.id === g.foldedInto);
6979
+ const n = goalReviewNumber({ goal: g, tasks: list });
6980
+ const rid = n != null ? `<span class="rid gno" data-n="${n}">#${n}</span>` : '';
6981
+ const targetNo = target ? (goalReviewNumber({ goal: target, tasks: list }) ?? target.id) : g.foldedInto;
6982
+ return `<div class="rcard noimg foldrow" data-fsrev="${g.id}"><div class="rc"><div class="t">${rid}${esc(saReviewTitle(g))}</div><span class="rstat">Merged into #${esc(String(targetNo))}</span></div></div>`;
6983
+ };
6862
6984
  // V3-C (Masa決定 2026-07-07): no collapsed strip — it duplicated the sm2 meter right
6863
6985
  // above. Reviews render directly: meter + Review header + cards (+N more fold, §1.5-8).
6864
6986
  // The meter counts what is reviewable, so it only exists when something is (§1.5-3):
@@ -6868,13 +6990,8 @@ function renderFsReview(){
6868
6990
  <div class="m-top"><span class="num mt">${green} / ${rows.length}</span><span> ready</span><button class="detailbtn" id="fsSeeAll">See all</button></div>
6869
6991
  <div class="m-bar"><i style="width:${pct}%"></i></div>
6870
6992
  <div class="s">${esc(sline)}</div></div>` : ''}
6871
- <div class="revhd" id="fsRevHd"><span class="hd">Review</span><span class="rnum num">${green} ready</span>
6872
- <span class="rvbtns">
6873
- <button class="rvb${fsUI.rv === 3 ? ' on' : ''}" data-fsrv="3" title="Thumbnails"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 15l5-4 4 3 4-5 5 6"/></svg></button>
6874
- <button class="rvb${fsUI.rv === 1 ? ' on' : ''}" data-fsrv="1" title="List"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="M13 6h8M13 9h5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="M13 16h8M13 19h5"/></svg></button>
6875
- <button class="rvb${fsUI.rv === 2 ? ' on' : ''}" data-fsrv="2" title="Compact"><svg viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg></button>
6876
- </span></div>
6877
- <div class="revlist">${shown.map(card).join('')}
6993
+ <div class="revhd" id="fsRevHd"><span class="hd">Review</span><span class="rnum num">${green} ready</span></div>
6994
+ <div class="revlist">${shown.map(card).join('')}${foldedRows.map(foldCard).join('')}
6878
6995
  ${rows.length > RCAP ? `<div class="morefold" id="fsRevMore">${fsUI.revMore ? 'less' : `+${rows.length - RCAP} more`}<span class="g">›</span></div>` : ''}</div>`;
6879
6996
  // Mirror the ready-review count onto the mobile top-bar "Review N" opener (H23).
6880
6997
  const _hn = document.getElementById('fsHambN'); if (_hn) _hn.textContent = green > 0 ? String(green) : '';
@@ -6888,10 +7005,8 @@ function renderFsReview(){
6888
7005
  saOpen(); };
6889
7006
  const moreEl = el.querySelector('#fsRevMore');
6890
7007
  if (moreEl) moreEl.onclick = () => { fsUI.revMore = !fsUI.revMore; renderFsReview(); };
6891
- for (const b of el.querySelectorAll('[data-fsrv]')) b.onclick = (e) => { e.stopPropagation();
6892
- fsUI.rv = Number(b.dataset.fsrv); localStorage.setItem('fsRv', String(fsUI.rv)); $('fsRoot').dataset.rv = String(fsUI.rv); renderFsReview(); };
6893
7008
  for (const c of el.querySelectorAll('[data-fsrev]')) c.onclick = (e) => {
6894
- if (e.target.closest('a') || e.target.closest('.act')) return;
7009
+ if (e.target.closest('a') || e.target.closest('.act') || e.target.closest('[data-goalreverify]')) return;
6895
7010
  openReviewChecklist(Number(c.dataset.fsrev));
6896
7011
  };
6897
7012
  // Folded goals get no row here (Masa 2026-07-23) — they are shown, and restored, on the
@@ -6938,6 +7053,96 @@ function fsBucketTodo(){
6938
7053
  rejected: pgoals.filter((g) => g.status === 'rejected'), // 07-20c: Reject parks goals here (own fold, like Done)
6939
7054
  };
6940
7055
  }
7056
+ const FAILURE_COPY = {
7057
+ ja: {
7058
+ agent: { title: 'Agentエラー', body: 'Claude Code/Codex 側が作業中に止まりました。依頼内容や実装で詰まった可能性があります。' },
7059
+ infra: {
7060
+ title: 'Galda基盤エラー',
7061
+ timeout: 'Galda側の実行上限や待ち時間で止まりました。変更内容の失敗とは限りません。',
7062
+ auth: '接続や認証確認で止まりました。接続を直してから再開できます。',
7063
+ relay: 'relay接続で止まりました。接続状態を確認してから再試行できます。',
7064
+ chrome: 'Chrome/ブラウザ確認で止まりました。画面確認環境の問題で、実装失敗とは限りません。',
7065
+ 'test-runner': 'Galdaのtest runnerで止まりました。マシン負荷や起動待ちの可能性があります。',
7066
+ pr: 'PR作成で止まりました。GitHub/gh認証やpush状態を確認してから再試行できます。',
7067
+ 'worker-start': 'worker起動で止まりました。Agentコマンドや実行環境を確認してから再開できます。',
7068
+ unknown: 'Galdaの実行基盤で止まりました。ログを確認し、原因に応じて再試行できます。',
7069
+ },
7070
+ actions: {
7071
+ retry: '再試行', resume: '再開', connect: 'Connect', check: '接続確認',
7072
+ other: '別Agentで実行', log: 'ログ確認', tests: 'テスト再実行', pr: 'PR再試行',
7073
+ },
7074
+ },
7075
+ en: {
7076
+ agent: { title: 'Agent error', body: 'Claude Code/Codex stopped while doing the work. The agent may need a revised instruction or another run.' },
7077
+ infra: {
7078
+ title: 'Galda system issue',
7079
+ timeout: 'Galda stopped this at the run limit or a wait timed out. This is not necessarily a problem with the change.',
7080
+ auth: 'Connection or authentication failed. Reconnect, then continue.',
7081
+ relay: 'The relay connection failed. Check the connection, then retry.',
7082
+ chrome: 'Chrome/browser verification failed. The UI check environment stopped, not necessarily the implementation.',
7083
+ 'test-runner': 'Galda’s test runner stopped. Machine load or app startup timing may be the cause.',
7084
+ pr: 'PR creation failed. Check GitHub/gh auth or push state, then retry.',
7085
+ 'worker-start': 'The worker could not start. Check the agent command or runtime, then continue.',
7086
+ unknown: 'Galda’s execution layer stopped. Check the log and retry based on the cause.',
7087
+ },
7088
+ actions: {
7089
+ retry: 'Retry', resume: 'Resume', connect: 'Connect', check: 'Check connection',
7090
+ other: 'Run with another Agent', log: 'View log', tests: 'Re-run tests', pr: 'Retry PR',
7091
+ },
7092
+ },
7093
+ };
7094
+ function failureLang(){
7095
+ try { return billingLang(); } catch { return document.documentElement.lang === 'ja' ? 'ja' : 'en'; }
7096
+ }
7097
+ function inferErrorClass(text, agent){
7098
+ const low = String(text || '').toLowerCase();
7099
+ const infra = (reason, actionIds) => ({ owner: 'galda-infra', reason, actionIds });
7100
+ if (/run limit|実行上限|timed out|timeout|タイムアウト/.test(low)) return infra('timeout', ['retry', 'run-with-codex', 'view-log']);
7101
+ if (/spawn|enoent|eacces|command not found|worker tool exited early|作業ツールが途中で終了/.test(low)) return infra('worker-start', ['retry', 'run-with-codex', 'view-log']);
7102
+ if (/auth probe|preflight|not logged in|logged out|not authenticated|authentication|unauthorized|invalid api key|bad credentials|401\b|403\b|接続が必要|認証/.test(low)) return infra('auth', ['connect', 'check-connection', 'run-with-codex', 'view-log']);
7103
+ if (/relay|websocket|socket|econnreset|econnrefused|billing-api unreachable|cf access|cloudflare access/.test(low)) return infra('relay', ['retry', 'connect', 'view-log']);
7104
+ if (/chrome|chromium|puppeteer|target closed|browser|navigation timeout|waiting failed|screenshot|headless/.test(low)) return infra('chrome', ['retry', 'view-log']);
7105
+ if (/test runner|node --test|npm test|project tests|server exited \(code \d+\) before it was ready|did not report ready in time|test timed out after|tap|not ok \d+/.test(low)) return infra('test-runner', ['retry-tests', 'view-log']);
7106
+ if (/pr step failed|openpr|pull request|github|gh:|gh auth|git push|git fetch|git .*failed|pr作成|pr failed/.test(low)) return infra('pr', ['retry-pr', 'view-log']);
7107
+ return { owner: 'agent', reason: agent === 'codex' ? 'codex' : 'claude-code', actionIds: ['retry', 'run-with-codex', 'view-log'] };
7108
+ }
7109
+ function taskErrorMeta(t, goal){
7110
+ const meta = t.errorClass || inferErrorClass(t.result, t.agent || goal?.agent);
7111
+ const lang = failureLang(), C = FAILURE_COPY[lang];
7112
+ const isInfra = meta.owner === 'galda-infra';
7113
+ return {
7114
+ ...meta,
7115
+ title: isInfra ? C.infra.title : C.agent.title,
7116
+ body: isInfra ? (C.infra[meta.reason] || C.infra.unknown) : C.agent.body,
7117
+ copy: C,
7118
+ };
7119
+ }
7120
+ function goalBlockedErrorMeta(g){
7121
+ const raw = g.blocked?.errorClass || (g.blocked?.kind === 'test'
7122
+ ? { owner: 'galda-infra', reason: 'test-runner', actionIds: ['retry-tests', 'view-log'] }
7123
+ : inferErrorClass(g.blocked?.reason, g.agent));
7124
+ const lang = failureLang(), C = FAILURE_COPY[lang];
7125
+ const isInfra = raw.owner === 'galda-infra';
7126
+ return { ...raw, title: isInfra ? C.infra.title : C.agent.title, body: isInfra ? (C.infra[raw.reason] || C.infra.unknown) : C.agent.body, copy: C };
7127
+ }
7128
+ function errorButtonsHtml(meta, { taskId = null, goalId = null, interrupted = false, agent = null, pr = false } = {}){
7129
+ const C = meta.copy.actions;
7130
+ const canCodex = goalId != null && agent !== 'codex' && (state.agents || []).includes('codex');
7131
+ const out = [];
7132
+ for (const id of meta.actionIds || []) {
7133
+ if (id === 'retry' && taskId != null) out.push(`<button class="errbtn" data-fsretry="${taskId}">${interrupted ? C.resume : C.retry}</button>`);
7134
+ else if (id === 'run-with-codex' && canCodex) out.push(`<button class="errbtn" data-fscodex="${goalId}">${C.other}</button>`);
7135
+ else if (id === 'connect') out.push(`<button class="errbtn" data-fsconnectworker="1">${C.connect}</button>`);
7136
+ else if (id === 'check-connection') out.push(`<button class="errbtn" data-fscheckworker="1">${C.check}</button>`);
7137
+ else if (id === 'retry-tests' && goalId != null) out.push(`<button class="errbtn" data-fsreverify="${goalId}">${C.tests}</button>`);
7138
+ else if (id === 'retry-pr' && goalId != null) out.push(`<button class="errbtn" data-fsprretry="${goalId}">${C.pr}</button>`);
7139
+ else if (id === 'view-log' && taskId != null) out.push(`<button class="errbtn" data-fslog="${taskId}">${C.log}</button>`);
7140
+ else if (id === 'view-log' && goalId != null) out.push(`<button class="errbtn" data-fsglog="${goalId}">${C.log}</button>`);
7141
+ }
7142
+ if (!out.length && taskId != null) out.push(`<button class="errbtn" data-fsretry="${taskId}">${interrupted ? C.resume : C.retry}</button>`);
7143
+ if (pr && !out.some((h) => h.includes('data-fsprretry'))) out.unshift(`<button class="errbtn" data-fsprretry="${goalId}">${C.pr}</button>`);
7144
+ return out.join('');
7145
+ }
6941
7146
  // To Do (below Review): Doing rows with bar + mono %, Next up in queue order,
6942
7147
  // error as a small in-row colour element (§1.5-8), quiet Done fold.
6943
7148
  function renderFsTodo(){
@@ -6963,6 +7168,12 @@ function renderFsTodo(){
6963
7168
  && !pausedGoalIds.has(g.id) && !runningTaskGoalIds.has(g.id)
6964
7169
  && list.some((t) => t.goalId === g.id && t.reply && t.status === 'queued')
6965
7170
  && !list.some((t) => t.goalId === g.id && !t.reply && ['running', 'queued'].includes(t.status)));
7171
+ const finalizingGoals = pgoals.filter((g) => g.status === 'running'
7172
+ && !pausedGoalIds.has(g.id)
7173
+ && !running.some((t) => t.goalId === g.id)
7174
+ && !parkedReplyGoals.some((x) => x.id === g.id)
7175
+ && list.some((t) => t.goalId === g.id)
7176
+ && list.filter((t) => t.goalId === g.id).every((t) => ['done', 'skipped'].includes(t.status)));
6966
7177
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
6967
7178
  // LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
6968
7179
  // PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
@@ -6984,9 +7195,10 @@ function renderFsTodo(){
6984
7195
  running.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => {
6985
7196
  const goal = pgoals.find((g) => g.id === t.goalId);
6986
7197
  const title = t.reply ? (goal?.reviewSummary?.check || goal?.plan?.[0] || goal?.text || t.title) : t.title;
6987
- return `<div class="drow"><div class="trow now" data-fsgoalrow="${t.goalId}"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(title)}</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
7198
+ return `<div class="drow"><div class="trow now" data-fsgoalrow="${t.goalId}"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(title)}</span><span class="stword run">Running</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
6988
7199
  <div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`;
6989
7200
  }).join('')
7201
+ + finalizingGoals.map((g) => `<div class="drow"><div class="trow now" data-fsgoalrow="${g.id}"><span class="st run"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword wait">Finalizing</span>${fsActs(g.id)}</div></div>`).join('')
6990
7202
  // 一時停止したゴールは Doing に「Paused」1行(アンバー)で留める+Resume。走行/中断行は上と needsRows で抑止済み。
6991
7203
  + pgoals.filter((g) => pausedGoalIds.has(g.id)).map((g) => `<div class="drow"><div class="trow now" data-fsgoalrow="${g.id}"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword int">Paused</span><button class="ab txt" data-fspause="1" title="Resume — 再開">Resume</button></div></div>`).join('');
6992
7204
  // NEEDS YOU: stopped/errored work that used to fold into Doing, now under its own caption so
@@ -7006,19 +7218,23 @@ function renderFsTodo(){
7006
7218
  // exists (server-side retry = "resume if possible, else re-run"; there is no separate
7007
7219
  // resume endpoint to call).
7008
7220
  failed.filter((t) => !pausedGoalIds.has(t.goalId) && !resumedGoalIds.has(t.goalId)).map((t) => { const isInt = t.status === 'interrupted';
7009
- const word = isInt ? 'Interrupted' : 'Failed', icon = isInt ? '⏸' : '✗', label = isInt ? 'Resume' : 'Retry';
7221
+ const goal = pgoals.find((g) => g.id === t.goalId);
7222
+ const meta = taskErrorMeta(t, goal);
7223
+ const word = isInt ? 'Interrupted' : 'Failed', icon = isInt ? '⏸' : '✗';
7010
7224
  // don't restate the state word as the "reason" too (e.g. "Interrupted / Interrupted")
7011
7225
  // when the task has no distinct result text — the coloured word already said it, so
7012
7226
  // the reason line only prints when there's real detail to add.
7013
7227
  const reason = String(t.result || '').trim();
7014
7228
  const reasonLine = reason && reason.toLowerCase() !== word.toLowerCase()
7015
- ? `<div class="msg"><span class="x ${isInt ? 'int' : ''}">${icon}</span>${tt(reason, 180)}</div>` : '';
7229
+ ? `<div class="msg"><span class="x ${isInt ? 'int' : ''}">${icon}</span><b>${esc(meta.title)}</b> — ${esc(meta.body)} <span class="dim">${tt(reason, 160)}</span></div>`
7230
+ : `<div class="msg"><span class="x ${isInt ? 'int' : ''}">${icon}</span><b>${esc(meta.title)}</b> — ${esc(meta.body)}</div>`;
7016
7231
  return `<div class="drow err${isInt ? ' int' : ''}"><div class="trow now" data-fsgoalrow="${t.goalId}"><span class="st run ${isInt ? 'int' : 'bad'}"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="stword ${isInt ? 'int' : ''}">${word}</span>${fsActs(t.goalId)}</div>
7017
7232
  <div class="errline">${reasonLine}
7018
- <div class="acts2"><button class="errbtn" data-fsretry="${t.id}">${label}</button><button class="errbtn" data-fslog="${t.id}">View log</button></div></div></div>`; }).join('')
7019
- + blocked.map((g) => g.blocked?.kind === 'paused' ? '' : `<div class="drow err"><div class="trow now" data-fsgoalrow="${g.id}"><span class="st run bad"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword">Failed</span></div>
7020
- <div class="errline"><div class="msg"><span class="x">✗</span>${tt(g.blocked?.reason ?? 'blocked', 180)}</div>
7021
- <div class="acts2"><button class="errbtn" data-fsreverify="${g.id}">Retry</button><button class="errbtn" data-fsglog="${g.id}">View log</button><button class="errbtn" data-fsarchive="${g.id}">Archive</button></div></div></div>`).join('')
7233
+ <div class="acts2">${errorButtonsHtml(meta, { taskId: t.id, goalId: t.goalId, interrupted: isInt, agent: t.agent || goal?.agent })}</div></div></div>`; }).join('')
7234
+ + blocked.map((g) => { if (g.blocked?.kind === 'paused') return ''; const meta = goalBlockedErrorMeta(g);
7235
+ return `<div class="drow err"><div class="trow now" data-fsgoalrow="${g.id}"><span class="st run bad"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword">Failed</span></div>
7236
+ <div class="errline"><div class="msg"><span class="x">✗</span><b>${esc(meta.title)}</b> ${esc(meta.body)} <span class="dim">${tt(g.blocked?.reason ?? 'blocked', 160)}</span></div>
7237
+ <div class="acts2">${errorButtonsHtml(meta, { goalId: g.id, agent: g.agent })}<button class="errbtn" data-fsarchive="${g.id}">Archive</button></div></div></div>`; }).join('')
7022
7238
  + needsInput.map((g) => `<div class="drow err int"><div class="trow now" data-fsgoalrow="${g.id}"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.text || g.plan?.[0], 60)}</span><span class="stword int">Needs input</span></div>
7023
7239
  <div class="errline"><div class="msg"><span class="x int">?</span>${tt(g.question?.text || 'A question is waiting on you', 180)}</div>
7024
7240
  <div class="acts2"><button class="errbtn" data-fsglog="${g.id}">Answer</button></div></div></div>`).join('');
@@ -7045,16 +7261,16 @@ function renderFsTodo(){
7045
7261
  }
7046
7262
  return `<div class="trow up${i >= 2 ? ' dim' : ''}" data-goalid="${t.goalId ?? ''}" data-taskid="${t.id}"${t.goalId != null ? ` data-fsgoalrow="${t.goalId}"` : ''}><span class="st"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span>${codexAction(t.goalId, t.agent)}${fsActs(t.goalId)}</div>${grp}`;
7047
7263
  }).join('');
7048
- // CDO review 2026-07-08 §7: this header count used to be queued.length alone (the
7049
- // "Next up" pool only) so a project sitting in Doing/Later with an empty Next-up
7050
- // queue showed "To Do 0" directly above visible Doing/Later rows. The header labels
7051
- // the whole lane ("To Do"), so it now counts everything the lane actually renders
7052
- // below it: Doing (running) + Needs you (failed/interrupted tasks + stopped/needsInput
7053
- // goals) + Next up (queued) + Later (pending goals). "· next-up order" stays keyed off
7054
- // queued.length specifically, since that suffix describes the Next-up sublist's own sort,
7055
- // not the lane total.
7264
+ // Keep executable work and deliberately-shelved Later work visibly distinct.
7265
+ // "To Do 6" while all six were in Later made an idle queue look broken: Later
7266
+ // never auto-starts by design. The header now tells the operational truth.
7056
7267
  const needsCount = failed.filter((t) => !resumedGoalIds.has(t.goalId)).length + blocked.length + needsInput.length;
7057
- const todoTotal = running.length + parkedReplyGoals.length + needsCount + queued.length + later.length;
7268
+ const doingCount = running.length + finalizingGoals.length + pausedGoalIds.size;
7269
+ const nextCount = parkedReplyGoals.length + queued.length;
7270
+ const hasPipelineWork = doingCount > 0 || nextCount > 0 || pgoals.some((g) => ['planning', 'stacked'].includes(g.status));
7271
+ const laterIdle = later.length && !hasPipelineWork
7272
+ ? `<div class="lateridle"><span class="msg">${document.documentElement.lang === 'ja' ? `実行待ちはありません。Laterの${later.length}件は自動開始されません。` : `Nothing is queued. ${later.length} Later item${later.length === 1 ? '' : 's'} will not start automatically.`}</span><button data-fsstartnextlater="${later[0].id}">${document.documentElement.lang === 'ja' ? '次を開始' : 'Start next'}</button></div>`
7273
+ : '';
7058
7274
  // Free-tier pending counter (H22): on the free plan the header shows the wall
7059
7275
  // — pending goals out of the 5-slot cap — reddening at the cap and gone once
7060
7276
  // paid. Reads the SAME numbers the gate enforces (state.billing, never a local
@@ -7065,7 +7281,7 @@ function renderFsTodo(){
7065
7281
  const _showFc = _bill.plan !== 'paid' && Number.isFinite(_fp) && Number.isFinite(_fl);
7066
7282
  const _todoNum = _showFc
7067
7283
  ? `<span class="fcount fstdlabel${_fp >= _fl ? ' cap' : ''}">${_fp}<span class="fc-l">/${_fl}</span></span>`
7068
- : `<span class="num fstdlabel">${todoTotal}${queued.length ? ' · next-up order' : ''}</span>`;
7284
+ : `<span class="num fstdlabel">${nextCount} next · ${doingCount} doing${later.length ? ` · ${later.length} later` : ''}</span>`;
7069
7285
  el.innerHTML = `<div class="lhd sechd" id="fsTodoHd"><span class="hd fstdlabel">To Do</span>${_todoNum}<span class="fold fstdlabel">▾</span></div>
7070
7286
  <div class="tl">
7071
7287
  ${(doingRows || paused) ? `<span class="tlcap tlcap-doing" style="padding-top:0">Doing<button class="tlcap-pause" data-fspause="1" aria-pressed="${paused ? 'true' : 'false'}" title="${paused ? 'Resume — 再開' : 'Pause — 一時停止'}">${paused ? '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>' : '<svg viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>'}</button></span>${doingRows}` : ''}
@@ -7073,8 +7289,11 @@ function renderFsTodo(){
7073
7289
  ${rejected.length ? `<span class="tlcap rejcap"${(doingRows || paused) || needsRows ? '' : ' style="padding-top:0"'}>Dismissed<span class="num">${rejected.length}</span></span>${rejected.map((g) => `<div class="trow up rej" data-fsgoalrow="${g.id}"><span class="st rej"></span>${noSpan(g.id)}<span class="tt">${tt(g.text, 70)}</span><span class="acts"><button class="ab" data-fsreopen="${g.id}" title="Reopen — back to Review">${FS_ICONS.reopen}</button><button class="ab" data-fsrejdel="${g.id}" title="Delete — discard (closes the PR)">${FS_ICONS.del}</button></span></div>`).join('')}` : ''}
7074
7290
  ${(parkedRows || upRows) ? `<span class="tlcap"${(doingRows || paused) || needsRows || rejected.length ? '' : ' style="padding-top:0"'}>Next up</span>${parkedRows}${upRows}` : ''}
7075
7291
  ${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
7076
- ${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
7077
7292
  <button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
7293
+ <span class="tlcap">Later</span>
7294
+ <button class="qadd lateradd" data-fslaternew="1" title="New Later goal"><span class="paplus"></span>New</button>
7295
+ ${laterIdle}
7296
+ ${laterRows}
7078
7297
  </div>
7079
7298
  ${doneAll.length ? `<div class="doneline${fsUI.doneOpen ? ' open' : ''}" id="fsDoneLine">✓ Done<span class="num">${doneAll.length}</span><span class="fold">▾</span></div>
7080
7299
  <div id="fsDonelist"${fsUI.doneOpen ? '' : ' hidden'}>${doneAll.map((t) => `<div class="trow up dim done"${t.goalId != null ? ` data-fsgoalrow="${t.goalId}"` : ''}><span class="st ok"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span></div>`).join('')}</div>` : ''}`;
@@ -7082,6 +7301,11 @@ function renderFsTodo(){
7082
7301
  $('fsTodoHd').onclick = () => { fsUI.todoFold = !fsUI.todoFold; el.classList.toggle('folded', fsUI.todoFold); };
7083
7302
  const qadd = el.querySelector('.qadd[data-fsnew]'); // + New → focus the composer (the real create path)
7084
7303
  if (qadd) qadd.onclick = () => $('input')?.focus();
7304
+ const laterAdd = el.querySelector('.qadd[data-fslaternew]');
7305
+ if (laterAdd) laterAdd.onclick = (e) => {
7306
+ e.stopPropagation();
7307
+ fsInlineInput(laterAdd, '', 'Add to Later…', fsCreatePendingGoal);
7308
+ };
7085
7309
  const dl = el.querySelector('#fsDoneLine');
7086
7310
  if (dl) dl.onclick = () => { fsUI.doneOpen = !fsUI.doneOpen; dl.classList.toggle('open', fsUI.doneOpen); el.querySelector('#fsDonelist').hidden = !fsUI.doneOpen; };
7087
7311
  for (const r of el.querySelectorAll('[data-fsgoalrow]')) r.onclick = (e) => {
@@ -7108,12 +7332,10 @@ function renderFsTodo(){
7108
7332
  };
7109
7333
  for (const b of el.querySelectorAll('[data-fspause]')) b.onclick = togglePauseActive; // list版 Doing 一時停止/再生(+Paused行のResume)
7110
7334
  for (const b of el.querySelectorAll('[data-fsretry]')) b.onclick = () => fsRetryTask(b.dataset.fsretry);
7111
- for (const b of el.querySelectorAll('[data-fscodex]')) b.onclick = async (e) => { e.stopPropagation();
7112
- b.disabled = true; b.textContent = 'Starting…';
7113
- const r = await fetch(withKey(`/api/goals/${b.dataset.fscodex}/run-with-codex`), { method: 'POST' });
7114
- if (!r.ok) showErr((await r.json().catch(() => ({}))).error || 'Could not start with Codex.');
7115
- await refresh();
7116
- };
7335
+ for (const b of el.querySelectorAll('[data-fscodex]')) b.onclick = (e) => { e.stopPropagation(); fsRunWithCodexGoal(b); };
7336
+ for (const b of el.querySelectorAll('[data-fsconnectworker]')) b.onclick = (e) => { e.stopPropagation(); fsConnectWorker(b); };
7337
+ for (const b of el.querySelectorAll('[data-fscheckworker]')) b.onclick = (e) => { e.stopPropagation(); fsCheckWorker(b); };
7338
+ for (const b of el.querySelectorAll('[data-fsprretry]')) b.onclick = (e) => { e.stopPropagation(); fsRetryPrGoal(b); };
7117
7339
  for (const b of el.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
7118
7340
  for (const b of el.querySelectorAll('[data-fsglog]')) b.onclick = () => toggleGoalDetail(Number(b.dataset.fsglog));
7119
7341
  for (const b of el.querySelectorAll('[data-fsreverify]')) b.onclick = async () => {
@@ -7130,6 +7352,12 @@ function renderFsTodo(){
7130
7352
  for (const b of el.querySelectorAll('[data-fspstart]')) b.onclick = async (e) => { e.stopPropagation();
7131
7353
  const r = await fetch(withKey(`/api/goals/${b.dataset.fspstart}/activate`), { method: 'POST' });
7132
7354
  if (!r.ok) showErr('Start failed.'); else await refresh(); };
7355
+ const startNextLater = el.querySelector('[data-fsstartnextlater]');
7356
+ if (startNextLater) startNextLater.onclick = async (e) => { e.stopPropagation();
7357
+ startNextLater.disabled = true;
7358
+ const r = await fetch(withKey(`/api/goals/${startNextLater.dataset.fsstartnextlater}/activate`), { method: 'POST' });
7359
+ if (!r.ok) { showErr(document.documentElement.lang === 'ja' ? '開始できませんでした' : 'Could not start the next item.'); startNextLater.disabled = false; }
7360
+ else await refresh(); };
7133
7361
  for (const b of el.querySelectorAll('[data-fspdel]')) b.onclick = async (e) => { e.stopPropagation();
7134
7362
  const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
7135
7363
  if (!(await confirmDelete({ title: 'Delete this shelved goal?', preview }))) return;
@@ -7153,6 +7381,20 @@ function renderFsTodo(){
7153
7381
  const r = await fetch(withKey(`/api/goals/${b.dataset.fsdel}`), { method: 'DELETE' });
7154
7382
  if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh(); };
7155
7383
  }
7384
+ async function fsCreatePendingGoal(text){
7385
+ const r = await fetch(withKey('/api/tasks'), { method: 'POST', headers: { 'content-type': 'application/json' },
7386
+ body: JSON.stringify({ projectId: state.active, text, pr: 'auto', model: state.stickyModel, effort: state.stickyEffort, agent: state.connectedApp, mode: 'auto', pending: true }) });
7387
+ if (r.status === 402) {
7388
+ const data = await r.json().catch(() => ({}));
7389
+ showBillingBlocked(data.blocked);
7390
+ return;
7391
+ }
7392
+ if (!r.ok) { showErr('Later add failed.'); return; }
7393
+ clearBillingBlocked();
7394
+ const goal = await r.json();
7395
+ if (goal?.id) upsert(state.goals, goal);
7396
+ render();
7397
+ }
7156
7398
  function fsInlineInput(afterEl, value, placeholder, onSubmit){
7157
7399
  if (!afterEl || afterEl.nextElementSibling?.classList.contains('fsinline')) return;
7158
7400
  const wrap = document.createElement('div'); wrap.className = 'fsinline';
@@ -8074,6 +8316,7 @@ async function refresh(){
8074
8316
  renderModelSelect();
8075
8317
  renderEffortSelect();
8076
8318
  state.queueOrder = s.queueOrder ?? {}; state.externalActivity = s.externalActivity ?? [];
8319
+ state.runningCounts = s.runningCounts ?? {}; state.parallelLimits = s.parallelLimits ?? null;
8077
8320
  state.workflowColumns = s.workflowColumns ?? {};
8078
8321
  state.reviewDefinitions = s.reviewDefinitions ?? {};
8079
8322
  checkUiVersion(s.uiVersion);
@@ -8138,7 +8381,7 @@ function connectSSE(){
8138
8381
  // review→done flip on a goal that has a PR means the user just merged it on GitHub and
8139
8382
  // came back. Celebrate (confetti) + a "merged" toast so the merge is unmistakable.
8140
8383
  if (prev && prev.status === 'review' && m.goal.status === 'done' && m.goal.pr) {
8141
- try { celebrateApprove(); } catch {}
8384
+ try { finishOpenReview(m.goal); } catch { try { celebrateApprove(); } catch {} }
8142
8385
  const prNo = (String(m.goal.pr).match(/pull\/(\d+)/) ?? [, ''])[1];
8143
8386
  showToast(`PR #${prNo} merged — “${(m.goal.text || '').replace(/\s+/g, ' ').slice(0, 48)}” is Done`);
8144
8387
  }
@@ -8376,7 +8619,7 @@ async function postOutboxItem(item){
8376
8619
  posting.add(item.oid);
8377
8620
  try {
8378
8621
  const r = await fetch(withKey('/api/tasks'), { method: 'POST', headers: { 'content-type': 'application/json' },
8379
- body: JSON.stringify({ projectId: item.projectId, text: item.text, pr: item.pr, model: item.model, effort: item.effort, agent: item.agent, mode: item.mode, skill: item.skill, worktree: item.worktree, images: item.images, pending: item.pending, review: item.review }) });
8622
+ body: JSON.stringify({ projectId: item.projectId, text: item.text, pr: item.pr, model: item.model, effort: item.effort, agent: item.agent, mode: item.mode, skill: item.skill, workspaceMode: item.workspaceMode, worktree: item.worktree, images: item.images, pending: item.pending, review: item.review }) });
8380
8623
  if (r.status === 402) {
8381
8624
  const data = await r.json().catch(() => ({}));
8382
8625
  outbox.mark(item.oid, 'unsent'); // same status as any other failed send — the existing 15s auto-flush retries it (and starts succeeding the moment a license activates), the existing queue resend affordance also works
@@ -8636,8 +8879,8 @@ async function send(){
8636
8879
  oid: `o${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
8637
8880
  projectId: state.active, text, pr: 'auto', model, effort, agent: state.connectedApp,
8638
8881
  mode, skill: skill || null,
8639
- // Only the OFF case travels: the engine stores an explicit false and leaves every
8640
- // other goal meaning exactly what it meant (engine #268).
8882
+ workspaceMode: workspaceMode(),
8883
+ // Keep the old wire contract too: only direct sends worktree:false.
8641
8884
  worktree: worktreeOn() ? undefined : false,
8642
8885
  pending: later, review,
8643
8886
  images: attach.map((a) => a.path), imageUrls: attach.map((a) => a.url),
@@ -8833,6 +9076,27 @@ function latestGoalOutcomeTask(goalId, tasks){
8833
9076
  .sort((a, b) => Date.parse(b.finishedAt || b.startedAt || b.createdAt || 0)
8834
9077
  - Date.parse(a.finishedAt || a.startedAt || a.createdAt || 0))[0] || null;
8835
9078
  }
9079
+ function taskAttemptId(t){ return t?.attemptId || (t?.id != null ? `task-${t.id}` : null); }
9080
+ function taskSortTime(t){ return Date.parse(t?.finishedAt || t?.startedAt || t?.createdAt || 0) || 0; }
9081
+ function latestGoalRunForOutcome(goalId, tasks, outcome){
9082
+ if (outcome?.run) return { taskId: outcome.id, attemptId: taskAttemptId(outcome), inherited: false, ...outcome.run };
9083
+ const runs = (tasks || []).filter((t) => t.goalId === goalId && t.run)
9084
+ .sort((a, b) => taskSortTime(b) - taskSortTime(a));
9085
+ if (!runs.length) return null;
9086
+ const cutoff = outcome ? taskSortTime(outcome) : Infinity;
9087
+ const prev = runs.find((t) => t.id !== outcome?.id && taskSortTime(t) <= cutoff) || (!outcome ? runs[0] : null);
9088
+ if (!prev) return null;
9089
+ const note = '前回のpreviewを継承しています: 最新replyは新しいpreviewを宣言しませんでした。 / Inherited previous preview: the latest reply did not declare a new preview.';
9090
+ return {
9091
+ taskId: prev.id,
9092
+ attemptId: taskAttemptId(outcome) || taskAttemptId(prev),
9093
+ inherited: true,
9094
+ inheritedFromTaskId: prev.id,
9095
+ inheritedForTaskId: outcome?.id ?? null,
9096
+ ...prev.run,
9097
+ note: prev.run?.note ? `${note} ${prev.run.note}` : note,
9098
+ };
9099
+ }
8836
9100
  // One digest-row → one See-all card item. Extracted from saRows (2026-07-24) so a single
8837
9101
  // "Needs you" goal can be opened as the SAME review card (saLedgerRows solo mode) — the card's
8838
9102
  // whole shape (Request/Result/Proof/Activity, empty sections auto-hidden) is reused verbatim.
@@ -8873,6 +9137,7 @@ function saRowFromDigest(r, gs, ts){
8873
9137
  check: String(g?.reviewSummary?.check || r.checkLine || '').replace(/\s+/g, ' ').trim(),
8874
9138
  goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
8875
9139
  did, siblings, risk: g?.risk ?? null,
9140
+ verificationInfraError: g?.verificationInfraError ?? r.verificationInfraError ?? null,
8876
9141
  note: g?.retest?.passed ? g.retest.note : null,
8877
9142
  pr: r.pr,
8878
9143
  // P0b (Masa 2026-07-21): the AI's own TodoWrite breakdown for the "Steps" section
@@ -8918,8 +9183,7 @@ function saRowFromDigest(r, gs, ts){
8918
9183
  // written by the worker into .manager-run.json). Absent for asks with nothing to
8919
9184
  // run — then no button, which is the honest state, not a missing feature.
8920
9185
  run: (() => {
8921
- const t = [...ts].reverse().find((x) => x.goalId === g?.id && x.run);
8922
- return t ? { taskId: t.id, ...t.run } : null;
9186
+ return latestGoalRunForOutcome(g?.id, ts, outcome);
8923
9187
  })(),
8924
9188
  // Explicit review-summary display fields (spec §11: the card's shape follows the
8925
9189
  // task). A goal's reviewSummary may carry them directly (e.g. a conversational task
@@ -8974,6 +9238,7 @@ function saSoloDigestRow(g, ts){
8974
9238
  return {
8975
9239
  goalIds: [g.id], reviewNumber: goalReviewNumber({ goal: g, tasks: ts }), checkLine,
8976
9240
  pr: g.pr ?? null, testResult: g.testResult ?? null,
9241
+ verificationInfraError: g.verificationInfraError ?? null,
8977
9242
  proof: proofTask?.proof ?? null, proofTaskId: proofTask?.id ?? null, plan: false, planText: null,
8978
9243
  };
8979
9244
  }
@@ -9349,6 +9614,9 @@ function saItemHtml(it, i){
9349
9614
  const openBody = (it.run?.cmd || it.run?.url)
9350
9615
  ? `<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>`
9351
9616
  : '';
9617
+ const prRepairBody = it.goal?.prError
9618
+ ? `<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>`
9619
+ : '';
9352
9620
  // ── Also carries (Masa 2026-07-21 → refined 2026-07-23): goals folded onto this review are
9353
9621
  // logged HERE, inside the review's own content — NOT as loose rows in the Review column
9354
9622
  // (Masa: "ここにはいらない・逆にわかりにくい"). This is the ONE place a folded goal is shown,
@@ -9383,6 +9651,10 @@ function saItemHtml(it, i){
9383
9651
  // change explains itself here, and an image the AI pasted shows up here as an image.
9384
9652
  // We interpret nothing; mdLite only turns Markdown into the shapes it already means. ──
9385
9653
  const reportBody = it.report ? `<div class="rreport">${mdLite(it.report, { images: false })}</div>` : '';
9654
+ const infra = it.verificationInfraError;
9655
+ const infraBody = infra?.detail
9656
+ ? `<div class="rinfra"><p class="rbody">${esc(infra.detail)}</p><button type="button" class="retrybtn" data-saact="reverify:${i}">再検証</button></div>`
9657
+ : '';
9386
9658
  // ── Screenshots (P2, Masa 2026-07-22): the pictures the AI took of its own result.
9387
9659
  // Full width, at their own aspect — a four-variant comparison sheet is unreadable
9388
9660
  // shrunk into a thumbnail, and that is the shape the AI actually produces. The file
@@ -9508,9 +9780,11 @@ function saItemHtml(it, i){
9508
9780
  ${meta}
9509
9781
  ${reqSec}
9510
9782
  ${S('Open it', openBody)}
9783
+ ${S('PR', prRepairBody)}
9511
9784
  ${S('Also carries', carriesBody)}
9512
9785
  ${S('What to check', checkBody)}
9513
9786
  ${S('Result', reportBody)}
9787
+ ${S(infra?.title || '検証基盤エラー', infraBody)}
9514
9788
  ${S('Screenshots', shotsBody)}
9515
9789
  ${S('Steps', stepsBody)}
9516
9790
  ${S('Proof', proofInner)}
@@ -9967,8 +10241,17 @@ $('seeall').addEventListener('click', async (e) => {
9967
10241
  }
9968
10242
  const it = SA.items[i];
9969
10243
  if (!it) return;
10244
+ if (k === 'retrypr') return fsRetryPrGoal({ disabled: false, textContent: '', dataset: { fsprretry: String(it.goalId) } });
10245
+ if (k === 'glog') return toggleGoalDetail(it.goalId);
9970
10246
  // meta-row toggle (H23): tap the token chip to reveal the technique breakdown.
9971
10247
  if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
10248
+ if (k === 'reverify') {
10249
+ b.disabled = true;
10250
+ b.textContent = '再検証中…';
10251
+ const r = await fetch(withKey(`/api/goals/${it.goalId}/reverify`), { method: 'POST' });
10252
+ if (r.ok) await refresh(); else showErr('再検証に失敗');
10253
+ return;
10254
+ }
9972
10255
  // Open it (P1, Masa 2026-07-21): start the thing the worker said to run, then open it.
9973
10256
  // Booting a real process takes seconds, so the chip says "Starting…" while it waits —
9974
10257
  // and a failure lands in the detail line as a plain reason. It never turns the goal
@@ -10162,16 +10445,30 @@ function renderCtxBar(){
10162
10445
  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>`;
10163
10446
  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>`;
10164
10447
  }
10165
- // The worktree checkbox (engine #268). ON — the default, and what every goal has always
10166
- // done gives each goal its own copy of the repo, so a run cannot leave this folder
10167
- // half-edited. OFF sends worktree:false and the work happens right here, which is what
10168
- // someone means when they want to watch it happen. Sticky per project, like model and
10169
- // effort, because it is a working habit rather than a per-message thought.
10448
+ // Workspace mode. isolated worktree is the default clean copy; direct workspace inherits
10449
+ // and edits the current folder. Sticky per project, like model and effort, because it is
10450
+ // a working habit rather than a per-message thought.
10170
10451
  function worktreeKey(){ return `sel:worktree:${state.active}`; }
10171
10452
  function worktreeOn(){ return localStorage.getItem(worktreeKey()) !== '0'; }
10453
+ function workspaceMode(){ return worktreeOn() ? 'isolated-worktree' : 'direct-workspace'; }
10454
+ function goalWorkspaceMode(g){ return (g?.workspaceMode === 'direct-workspace' || g?.worktree === false) ? 'direct-workspace' : 'isolated-worktree'; }
10455
+ function workspaceModeText(g){ return goalWorkspaceMode(g) === 'direct-workspace' ? 'direct workspace' : 'isolated worktree'; }
10172
10456
  function renderWorktreeChip(){
10173
10457
  const el = $('ctxwt'); if (!el) return;
10174
- el.setAttribute('aria-checked', worktreeOn() ? 'true' : 'false');
10458
+ const isolated = worktreeOn();
10459
+ const proj = state.projects.find((p) => p.id === state.active);
10460
+ el.setAttribute('aria-checked', isolated ? 'true' : 'false');
10461
+ const label = $('ctxwtlabel');
10462
+ if (label) label.textContent = isolated ? 'isolated worktree' : 'direct workspace';
10463
+ el.title = isolated
10464
+ ? 'isolated worktree: default clean copy; this folder is not edited.'
10465
+ : 'direct workspace: inherits the current state and edits this folder.';
10466
+ const warn = $('ctxworkwarn');
10467
+ if (warn) {
10468
+ const dirty = !isolated && !!proj?.dirty;
10469
+ warn.hidden = !dirty;
10470
+ warn.textContent = dirty ? `Dirty workspace warning: direct workspace will edit ${folderName(proj?.dir) || 'this folder'} as it is now.` : '';
10471
+ }
10175
10472
  }
10176
10473
  // Asked for once, and only when the menu is first opened — the same "only when you look
10177
10474
  // at it" rule the implementation log and the conversation follow.