@galda/cli 0.10.84 → 0.10.85

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
@@ -4,6 +4,24 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Galda</title>
7
+ <script>
8
+ // app/index.html is a server-backed SPA, not a standalone artifact. Rendering the
9
+ // product shell from file:// makes missing theme/API/SSE data look like an old UI.
10
+ if (location.protocol === 'file:') {
11
+ document.documentElement.classList.add('galda-invalid-file-preview');
12
+ document.title = 'Galda — invalid preview URL';
13
+ }
14
+ </script>
15
+ <style>
16
+ #galdaInvalidFilePreview{display:none}
17
+ html.galda-invalid-file-preview body{margin:0;background:#0a0a0b;color:#e9e7e2;font-family:system-ui,sans-serif}
18
+ html.galda-invalid-file-preview body>*{display:none!important}
19
+ html.galda-invalid-file-preview body>#galdaInvalidFilePreview{display:grid!important;min-height:100vh;place-items:center;padding:32px}
20
+ .galda-invalid-file-card{max-width:620px;border:1px solid rgba(255,255,255,.12);border-radius:16px;padding:28px;background:#101014}
21
+ .galda-invalid-file-card h1{margin:0 0 12px;font-size:22px;letter-spacing:-.02em}
22
+ .galda-invalid-file-card p{margin:8px 0;color:#a3a097;line-height:1.6}
23
+ .galda-invalid-file-card code{color:#e9e7e2;font-family:ui-monospace,monospace}
24
+ </style>
7
25
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%233B6BFF'/%3E%3Cstop offset='1' stop-color='%230E2AA8'/%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='16' cy='16' r='11' fill='none' stroke='url(%23g)' stroke-width='5'/%3E%3C/svg%3E">
8
26
  <!-- brand fonts FINAL (v45 §1): the 3 adopted faces self-host in /fonts.css — the old
9
27
  Google Fonts Inter/JetBrains request was removed (offline-safe, no stray families) -->
@@ -463,6 +481,11 @@
463
481
  .qansrow{display:flex;gap:7px;align-items:center;margin-top:8px}
464
482
  .qansrow input{flex:1;background:var(--glass);border:1px solid var(--hair2);border-radius:8px;color:var(--ink);font-size:12.5px;padding:6px 10px;outline:none}
465
483
  .qsub{font-size:10.5px;color:var(--ink3);margin-top:7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
484
+ .approvalflow .qscope{margin-top:7px;font-size:11px;color:var(--ink2);line-height:1.45;white-space:pre-wrap}
485
+ .approvalflow .qactions{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
486
+ .approvalflow .qaction{border:1px solid rgba(var(--blue-rgb),.5);border-radius:8px;padding:6px 12px;background:transparent;color:var(--blue);font-size:11.5px;cursor:pointer}
487
+ .approvalflow .qaction.primary{background:var(--blue);color:white;border-color:var(--blue)}
488
+ .approvalflow .qaction.cancel{color:var(--ink3);border-color:var(--hair2)}
466
489
  /* The one line under a reply box: what the AI asked back, or the answer it gave.
467
490
  Same part as .qsub, but an answer is a sentence — it must wrap, not ellipsis. */
468
491
  /* The reply box's own short thread (CDO HANDOFF-REPLY-CONVERSATION §2/§5). It uses
@@ -1368,13 +1391,19 @@
1368
1391
  #fsRoot .fsproj.on{background:var(--hover);color:var(--ink)}
1369
1392
  #fsRoot .fsproj i{width:4px;height:4px;border-radius:50%;background:var(--ink3);flex:0 0 4px;position:relative;top:1.5px} /* top:1.5px = optical nudge so the dot/running-ring centre sits on the name's x-height, not the line-box centre (Masa 2026-07-24, rail=D). Same shift for idle dot + running ring keeps the column aligned. */
1370
1393
  #fsRoot .fsproj.on i{background:var(--green)}
1394
+ /* Review-blue comparison B: Aurora Blue #5CC4FF at 78%. This is a dedicated
1395
+ Review token; the app's ordinary blue controls must not inherit it. */
1396
+ #fsRoot .fsproj.rev i{background:rgba(var(--review-rgb),.78);box-shadow:0 0 3px rgba(var(--review-rgb),.24)}
1371
1397
  /* running project = solid saturated green dot with a breathing glow — never dims to
1372
1398
  grey, independent of selection (HANDOFF-v45 §11, flagship .proj.run port) */
1373
1399
  /* running project = a spinning broken green ring (matches the Doing marker · flagship #316).
1374
1400
  Was a pulsing dot (fsrundot). Centre WITHOUT transform (spin's rotate would override a centring
1375
1401
  translate → drift bottom-right) via top/left:50% + negative-half margin; concentric with the 4px dot. */
1376
1402
  #fsRoot .fsproj.run i{background:none;box-shadow:none;position:relative}
1377
- #fsRoot .fsproj.run i::after{content:"";position:absolute;top:50%;left:50%;width:7px;height:7px;margin:-3.5px 0 0 -3.5px;box-sizing:border-box;border-radius:50%;border:1.3px solid #22c55e;border-top-color:transparent;animation:spin 1.8s linear infinite}
1403
+ /* Review-blue comparison baseline: the Doing cue is a 7px / 1.3px broken
1404
+ ring, while idle and Review remain 4px dots. */
1405
+ #fsRoot .fsproj.run i::after{content:"";position:absolute;top:50%;left:50%;width:7px;height:7px;margin:-3.5px 0 0 -3.5px;box-sizing:border-box;border-radius:50%;border:1.3px solid var(--green);border-top-color:transparent;animation:spin 1.8s linear infinite}
1406
+ #fsRoot .fsproj.rev i{background:rgba(var(--review-rgb),.78);box-shadow:0 0 3px rgba(var(--review-rgb),.24)}
1378
1407
  @keyframes fsrundot{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--green) 50%,transparent)}50%{box-shadow:0 0 5px 1.5px color-mix(in srgb,var(--green) 35%,transparent)}}
1379
1408
  @media (prefers-reduced-motion:reduce){#fsRoot .fsproj.run i::after{animation:none}}
1380
1409
  #fsRoot .fsproj .pname{min-width:0;overflow:hidden;text-overflow:ellipsis}
@@ -1719,10 +1748,9 @@
1719
1748
  .lbc-n{font:600 10px/1 var(--mono);color:var(--ink3);font-variant-numeric:tabular-nums;letter-spacing:.02em}
1720
1749
  .lbc-dot{width:6px;height:6px;border-radius:50%;flex:0 0 6px;background:var(--ink3)}
1721
1750
  .lbc-dot.run{background:var(--green);animation:lbcpulse 2.4s ease-in-out infinite}
1722
- /* Status dot palette (Masa 2026-07-18): Needs you = amber #ffca16, Review = steel #8390f2,
1723
- Doing = green (kept), Done/To Do/Later = grey, Failed = red. Fixed hues (like flagship's
1724
- board), chosen to sit together as one family anchored on the green. */
1725
- .lbc-dot.rev{background:#8390f2}
1751
+ /* Status dot palette: Needs you = amber, Review = dedicated Aurora Blue,
1752
+ Doing = green, Done/To Do/Later = grey, Failed = red. */
1753
+ .lbc-dot.rev{background:rgba(var(--review-rgb),.78)}
1726
1754
  .lbc-dot.bad{background:var(--danger)}
1727
1755
  .lbc-dot.intr{background:#ffca16}
1728
1756
  .lbc-dot.ok{background:var(--ink3);opacity:.6}
@@ -1918,7 +1946,7 @@
1918
1946
  .lg-hd .no{flex:0 0 auto;font:600 11px/1 var(--mono);color:var(--ink3);font-variant-numeric:tabular-nums}
1919
1947
  .lg-hd .t{flex:1;font:600 15px/1.3 var(--ui);letter-spacing:-.014em}
1920
1948
  .lg-state{flex:0 0 auto;font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase} /* state = colour text only (§5.5 no badge) */
1921
- .lg-state.review{color:var(--blue)}
1949
+ .lg-state.review{color:var(--review)}
1922
1950
  /* meta row: PR link / token savings (real tap-to-open toggle) / Run locally — a
1923
1951
  compact row directly under the title (H23 §1, pattern 1 won over a plain text
1924
1952
  line and title-row-right-aligned chips). */
@@ -2428,6 +2456,7 @@
2428
2456
  specificity and greys the running dot out on the Gradient wallpaper rail — this
2429
2457
  exception restores solid green so "running" never dims to grey. */
2430
2458
  body[data-theme="gradient"] #fsRoot .fsproj.run i{background:none}
2459
+ body[data-theme="gradient"] #fsRoot .fsproj.rev i{background:rgba(var(--review-rgb),.78)}
2431
2460
  body[data-theme="gradient"] #fsRoot .fsproj .num,body[data-theme="gradient"] #fsRoot .fsproj .pcount{color:rgba(20,23,31,.6)}
2432
2461
  body[data-theme="gradient"] #fsRoot .fsproj:not(.on):hover{background:rgba(255,255,255,.34)}
2433
2462
  body[data-theme="gradient"] #fsRoot .fsproj.on{background:rgba(255,255,255,.5);color:var(--ink)}
@@ -2565,6 +2594,7 @@
2565
2594
  body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj i{background:rgba(255,255,255,.6)}
2566
2595
  /* same GOTCHA exception as the light Gradient rail, dark variant (HANDOFF-v45 §11) */
2567
2596
  body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj.run i{background:none}
2597
+ body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj.rev i{background:rgba(var(--review-rgb),.78)}
2568
2598
  body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj .num,body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj .pcount{color:rgba(255,255,255,.6)}
2569
2599
  body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj.on{background:rgba(255,255,255,.15);color:#fff}
2570
2600
  body[data-theme="gradient"][data-arcdark="1"] #fsRoot .fsproj:not(.on):hover{background:rgba(255,255,255,.1)}
@@ -2928,6 +2958,13 @@
2928
2958
  </style>
2929
2959
  </head>
2930
2960
  <body>
2961
+ <main id="galdaInvalidFilePreview" aria-live="assertive">
2962
+ <section class="galda-invalid-file-card">
2963
+ <h1>This is not the running Galda app</h1>
2964
+ <p><code>app/index.html</code> was opened with <code>file://</code>. This source file cannot load the theme, live state, Review data, or realtime updates.</p>
2965
+ <p>Start Galda from the target worktree and open the authenticated <code>http://localhost:&lt;port&gt;/?key=&lt;key&gt;</code> URL printed in the terminal.</p>
2966
+ </section>
2967
+ </main>
2931
2968
  <div id="themebg"></div>
2932
2969
  <input type="file" id="bg3File" accept="image/*" style="display:none"><!-- Banff BACKDROP free upload (v3 §6.5) -->
2933
2970
  <canvas id="glassgl"></canvas>
@@ -3371,7 +3408,7 @@ const savedTheme = (() => {
3371
3408
  if (v !== s) localStorage.setItem('theme', v);
3372
3409
  return v;
3373
3410
  })();
3374
- 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,
3411
+ 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, composerDetachedGoal: 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,
3375
3412
  // per-message send overrides set via slash-commands (reset after each send,
3376
3413
  // except model which is sticky in localStorage). palette = open command list.
3377
3414
  // Scoped per project (msgByProject) so a skill/mode picked in one project can't leak
@@ -3414,7 +3451,7 @@ function restoreFocusedAnswerInput(container, attr, saved){
3414
3451
  }
3415
3452
 
3416
3453
  const STATUS_LABEL = {
3417
- sending: 'Sending…', stacked: 'Queued', planning: 'Planning…', queued: 'To do', running: 'Working…', needsInput: 'Needs input',
3454
+ sending: 'Sending…', stacked: 'Queued', planning: 'Planning…', queued: 'To do', running: 'Working…', needsInput: 'Needs input', needsApproval: 'Needs you',
3418
3455
  review: 'In review', done: 'Done', failed: 'Failed', partial: 'Partly failed', interrupted: 'Interrupted', skipped: 'Skipped',
3419
3456
  rejected: 'Dismissed',
3420
3457
  };
@@ -3437,7 +3474,7 @@ const GOAL_CHIP = (s, columns, startedAt) => s === 'stacked' || s === 'sending'
3437
3474
  s === 'reverted' ? ['REVERTED', ''] :
3438
3475
  s === 'rejected' ? ['DISMISSED', 'rejected'] :
3439
3476
  s === 'folded' ? ['FOLDED', ''] :
3440
- s === 'blocked' ? ['BLOCKED', 'blocked'] : s === 'needsInput' ? ['NEEDS INPUT', 'blocked'] : ['FAIL', ''];
3477
+ s === 'blocked' ? ['BLOCKED', 'blocked'] : s === 'needsInput' ? ['NEEDS INPUT', 'blocked'] : s === 'needsApproval' ? ['NEEDS YOU', 'blocked'] : ['FAIL', ''];
3441
3478
  const FINISHED = (s) => ['done', 'partial', 'failed', 'interrupted'].includes(s);
3442
3479
  const ICONS = {
3443
3480
  pencil: '<svg viewBox="0 0 24 24"><path d="M4 20l1-4L16.5 4.5a2.1 2.1 0 013 3L8 19l-4 1z"/></svg>',
@@ -3725,6 +3762,7 @@ function buildReviewDigest({ goals, tasks } = {}){
3725
3762
  verificationInfraError: g.verificationInfraError ?? null,
3726
3763
  proof: proofTask?.proof ?? null,
3727
3764
  proofTaskId: proofTask?.id ?? null,
3765
+ incompleteRequirementCount: goalTasks.reduce((n, t) => n + (t.requirementEvidence?.requirements ?? []).filter((v) => ['partial', 'not-addressed'].includes(v.status)).length, 0),
3728
3766
  plan,
3729
3767
  planText: plan ? (g.planText ?? '') : null,
3730
3768
  };
@@ -3735,6 +3773,7 @@ function buildReviewDigest({ goals, tasks } = {}){
3735
3773
  if (existing.proof == null) { existing.proof = row.proof; existing.proofTaskId = row.proofTaskId; }
3736
3774
  if (existing.testResult == null) existing.testResult = row.testResult;
3737
3775
  if (existing.verificationInfraError == null) existing.verificationInfraError = row.verificationInfraError;
3776
+ existing.incompleteRequirementCount += row.incompleteRequirementCount;
3738
3777
  continue;
3739
3778
  }
3740
3779
  if (row.pr) byPr.set(row.pr, row);
@@ -4320,8 +4359,9 @@ function mergeGoalTimeline(goals, externalActivity, projectId){
4320
4359
  // レビュー画面(checklist/detail overlay)からチャットへ戻った時、そのゴールの
4321
4360
  // 末尾がすぐ見える位置へスクロールする(task 86)。ゴールが完了していて結論サマリ
4322
4361
  // (.gsummary)が出ていればそこを、まだなければゴールメッセージ全体の末尾を狙う。
4323
- function goToGoalInChat(goalId){
4362
+ function goToGoalInChat(goalId, bindComposer = true){
4324
4363
  state.selectedGoal = goalId;
4364
+ if (bindComposer) state.composerDetachedGoal = null;
4325
4365
  renderReplyBind(); // Feature A: the composer's "Replying to #NNN" chip follows the focused goal — renderStream() alone skips it (only render() calls it)
4326
4366
  renderStream();
4327
4367
  const goalEl = document.querySelector(`#stream [data-goalmsg="${goalId}"]`);
@@ -4663,8 +4703,17 @@ function renderTasks(){
4663
4703
  <div class="qansrow"><input type="text" data-qansinput="${g.id}" placeholder="Type your answer…"><button type="button" class="qbtn ok" data-qanssend="${g.id}" aria-label="Send">↑</button></div>
4664
4704
  <div class="qsub">${esc((g.text ?? '').replace(/\s+/g, ' ').slice(0, 54))}</div></div>`;
4665
4705
  }).join('');
4666
- const todo = questionCards
4667
- + grouped(todoShown, (t, i) => (i === 0 && !questionCards ? '<div class="upnextlbl">UP NEXT</div>' : '') + row(t, { drag: 1, idx: i }))
4706
+ const approvalCards = pgoals.filter((g) => g.status === 'needsApproval' && g.approvalRequest).map((g) => {
4707
+ const a = g.approvalRequest;
4708
+ const scope = (a.scope || []).map((x) => `• ${esc(x)}`).join('\n');
4709
+ return `<div class="qflow approvalflow" data-approvalgoal="${g.id}"><div class="qh">◆ Needs your approval</div><div class="qq">${esc(a.question || g.text)}</div>
4710
+ ${scope ? `<div class="qscope">${scope}</div>` : ''}
4711
+ <div class="qsub">${esc(a.action || 'continue')}</div>
4712
+ <div class="qactions"><button type="button" class="qaction primary" data-approval="proceed" data-goal="${g.id}">Proceed</button><button type="button" class="qaction" data-approvaledit="${g.id}">Edit scope</button><button type="button" class="qaction cancel" data-approval="cancel" data-goal="${g.id}">Cancel</button></div>
4713
+ <div class="qansrow" data-approvaleditrow="${g.id}" hidden><input type="text" data-approvalinput="${g.id}" placeholder="Describe the allowed scope…"><button type="button" class="qbtn ok" data-approval="edit" data-goal="${g.id}" aria-label="Apply and proceed">↑</button></div></div>`;
4714
+ }).join('');
4715
+ const needsYou = approvalCards + questionCards;
4716
+ const todo = grouped(todoShown, (t, i) => (i === 0 ? '<div class="upnextlbl">UP NEXT</div>' : '') + row(t, { drag: 1, idx: i }))
4668
4717
  + (todoAll.length > TODO_LIMIT ? `<div class="more" data-todomore="1">${state.showAllTodo ? 'show less' : `more · +${todoAll.length - TODO_LIMIT}`}</div>` : '');
4669
4718
  // Reviews are numbered by their own original TODO number (task 118), not
4670
4719
  // by position in this list — that number matches the dialog header
@@ -4738,7 +4787,8 @@ function renderTasks(){
4738
4787
  <button type="button" class="pdel" data-pdel="${g.id}" title="Delete">✕</button></div>`).join('');
4739
4788
  const savedQAns = captureFocusedAnswerInput($('tasklist'), 'data-qansinput');
4740
4789
  $('tasklist').innerHTML =
4741
- sec('Attention', attention, 'attn')
4790
+ sec('Needs you', needsYou, 'needsyou')
4791
+ + sec('Attention', attention, 'attn')
4742
4792
  + sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
4743
4793
  + sec(pickColumnLabel(cols, 'doing', 'Doing'), doing)
4744
4794
  + sec(pickColumnLabel(cols, 'todo', 'To do'), todo, 'todosec')
@@ -4920,6 +4970,25 @@ function renderTasks(){
4920
4970
  for (const b of $('tasklist').querySelectorAll('[data-qanswer]')) b.onclick = (e) => { e.stopPropagation(); answerQ(Number(b.dataset.qanswer), b.dataset.answer); };
4921
4971
  for (const b of $('tasklist').querySelectorAll('[data-qanssend]')) b.onclick = (e) => { e.stopPropagation(); const inp = $('tasklist').querySelector(`[data-qansinput="${b.dataset.qanssend}"]`); answerQ(Number(b.dataset.qanssend), inp && inp.value.trim()); };
4922
4972
  for (const inp of $('tasklist').querySelectorAll('[data-qansinput]')) inp.onkeydown = (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); answerQ(Number(inp.dataset.qansinput), inp.value.trim()); } };
4973
+ for (const b of $('tasklist').querySelectorAll('[data-approvaledit]')) b.onclick = (e) => {
4974
+ e.stopPropagation();
4975
+ const row = $('tasklist').querySelector(`[data-approvaleditrow="${b.dataset.approvaledit}"]`);
4976
+ if (row) { row.hidden = !row.hidden; if (!row.hidden) row.querySelector('input')?.focus(); }
4977
+ };
4978
+ const decideApproval = async (goalId, decision, instruction = '') => {
4979
+ const r = await fetch(withKey(`/api/goals/${goalId}/approval`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision, instruction }) });
4980
+ if (!r.ok) showErr((await r.json().catch(() => null))?.error || 'Approval decision failed'); else await refresh();
4981
+ };
4982
+ for (const b of $('tasklist').querySelectorAll('[data-approval]')) b.onclick = (e) => {
4983
+ e.stopPropagation();
4984
+ const instruction = b.dataset.approval === 'edit' ? ($('tasklist').querySelector(`[data-approvalinput="${b.dataset.goal}"]`)?.value.trim() || '') : '';
4985
+ if (b.dataset.approval === 'edit' && !instruction) return;
4986
+ decideApproval(Number(b.dataset.goal), b.dataset.approval, instruction);
4987
+ };
4988
+ for (const inp of $('tasklist').querySelectorAll('[data-approvalinput]')) inp.onkeydown = (e) => {
4989
+ if (e.key !== 'Enter' || e.isComposing || !inp.value.trim()) return;
4990
+ e.preventDefault(); decideApproval(Number(inp.dataset.approvalinput), 'edit', inp.value.trim());
4991
+ };
4923
4992
  // B mode: tap an "未送信" outbox row to resend it.
4924
4993
  for (const el of $('tasklist').querySelectorAll('[data-oresend]')) el.onclick = () => { const item = outbox.load().find((x) => x.oid === el.dataset.oresend); if (item) postOutboxItem(item); };
4925
4994
  const moreEl = $('tasklist').querySelector('[data-todomore]');
@@ -5318,7 +5387,7 @@ function closeReviewChecklist(){
5318
5387
  state.reviewOpenGoal = null;
5319
5388
  // task 86: レビュー画面からチャットへ戻る瞬間に、そのゴールの末尾(完了して
5320
5389
  // いれば結論サマリ)がすぐ見える位置へスクロールする。
5321
- if (goalId != null) goToGoalInChat(goalId);
5390
+ if (goalId != null) goToGoalInChat(goalId, false);
5322
5391
  }
5323
5392
  function renderReviewChecklist(){
5324
5393
  const goalId = state.reviewOpenGoal;
@@ -6034,9 +6103,11 @@ async function sendReviewDismiss(text){
6034
6103
  if (body.task) upsert(state.tasks, body.task);
6035
6104
  $('reviewFixInput').value = '';
6036
6105
  reviewAttach = []; renderReviewAttach();
6037
- // A question changed nothing, so closing the card would hide the answer that is
6038
- // on its way. Only a reply that actually moved the goal closes it.
6039
- if (body.outcome !== 'answer') closeReviewChecklist();
6106
+ // Keep the centre conversation focused on this work, but detach only the
6107
+ // composer. Review is a live progress surface and stays open until Close.
6108
+ state.selectedGoal = goal.id;
6109
+ state.composerDetachedGoal = goal.id;
6110
+ state.openThreads.add(goal.id);
6040
6111
  render();
6041
6112
  } catch { showErr('Dismiss failed.'); }
6042
6113
  }
@@ -6422,13 +6493,23 @@ function renderFsRail(){
6422
6493
  const pgoals = state.goals.filter((g) => g.projectId === p.id);
6423
6494
  // running dot (HANDOFF-v45 §11): a REAL running task, independent of selection —
6424
6495
  // .on already means "selected", .run means "actually doing work right now".
6425
- const running = ptasks.some((t) => t.status === 'running');
6496
+ // Was ptasks.some(status==='running') only missed goals sitting in the board's
6497
+ // 'doing' bucket before their first task exists yet (e.g. still 'planning'), so a
6498
+ // project the To Do/Kanban view showed as Doing kept a static dot in the rail
6499
+ // (Masa report 2026-07-27: "全部doingなのに緑のアイコンが回ってない"). Match the
6500
+ // same bucket the board/list already use, minus Paused (intentionally static).
6501
+ const running = pgoals.some((g) => fsBoardBucket(g, ptasks) === 'doing'
6502
+ && !(g.status === 'blocked' && g.blocked?.kind === 'paused'));
6426
6503
  const td = ptasks.filter((t) => ['running', 'queued'].includes(t.status)).length;
6427
6504
  const rev = buildReviewDigest({ goals: pgoals, tasks: ptasks }).length;
6428
6505
  const pcount = `<span class="pcount" title="${rev} review${rev === 1 ? '' : 's'} ready · ${td} to-do">`
6429
6506
  + `<span class="pc-item rev${rev ? '' : ' zero'}">${FS_ICO_SCAN}${rev}</span>`
6430
6507
  + `<span class="pc-item td">${FS_ICO_TD}${td}</span></span>`;
6431
- return `<button class="fsproj${p.id === state.active ? ' on' : ''}${running ? ' run' : ''}" data-fsproj="${p.id}"><i></i><span class="pname">${esc(p.name)}</span>${pcount}</button>`;
6508
+ // The left dot itself, not just the count beside it, should read "review waiting"
6509
+ // (Masa 2026-07-27: "青で表示されるのは左の◯もね") — reuses the same Review blue
6510
+ // as pc-item.rev/.lbc-dot.rev. Running (spinning) wins when both are true.
6511
+ const revCls = !running && rev ? ' rev' : '';
6512
+ return `<button class="fsproj${p.id === state.active ? ' on' : ''}${running ? ' run' : ''}${revCls}" data-fsproj="${p.id}"><i></i><span class="pname">${esc(p.name)}</span>${pcount}</button>`;
6432
6513
  }).join('')
6433
6514
  + `<button class="fsprojadd" id="fsProjAdd" title="New project"><span class="pa-plus"></span><span class="pa-label">New project</span></button>`;
6434
6515
  // Skip the DOM swap when the rail is unchanged: re-creating .fsproj.run would restart the
@@ -6631,7 +6712,12 @@ function renderFsFeed(){
6631
6712
  if (!out && hasWork) {
6632
6713
  const laterN = pgoals.filter((g) => g.status === 'pending').length;
6633
6714
  const queuedN = ptasks.filter((t) => t.status === 'queued').length + pgoals.filter((g) => ['planning', 'stacked'].includes(g.status)).length;
6634
- const msg = running.length ? 'working…' : queuedN ? `${queuedN} queued · waiting to start` : laterN ? `no active task · ${laterN} saved in Later` : 'no active task';
6715
+ const selectedQueued = [...ptasks].reverse().find((t) => t.status === 'queued' && t.goalId === state.selectedGoal)
6716
+ ?? [...ptasks].reverse().find((t) => t.status === 'queued');
6717
+ const queuedText = selectedQueued
6718
+ ? `Queued · ${String(selectedQueued.title ?? '').replace(/\s+/g, ' ').slice(0, 90)} · waiting to start`
6719
+ : `${queuedN} queued · waiting to start`;
6720
+ const msg = running.length ? 'working…' : queuedN ? queuedText : laterN ? `no active task · ${laterN} saved in Later` : 'no active task';
6635
6721
  out = `<div class="cc-c">⎿ ${esc(msg)}</div>`;
6636
6722
  }
6637
6723
  $('fsRoot').classList.toggle('clawdhero', !hasWork);
@@ -6669,6 +6755,13 @@ async function fsAnswerQuestion(goalId, answer){
6669
6755
  catch { showErr('Failed to send answer'); return; }
6670
6756
  if (!r.ok) showErr('Failed to send answer'); else await refresh();
6671
6757
  }
6758
+ async function submitApprovalDecision(goalId, decision, instruction = ''){
6759
+ if (decision === 'edit' && !String(instruction).trim()) return;
6760
+ let r;
6761
+ try { r = await fetch(withKey(`/api/goals/${goalId}/approval`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision, instruction }) }); }
6762
+ catch { showErr('Approval decision failed'); return; }
6763
+ if (!r.ok) showErr((await r.json().catch(() => null))?.error || 'Approval decision failed'); else await refresh();
6764
+ }
6672
6765
  async function fsRetryTask(id){
6673
6766
  const r = await fetch(withKey(`/api/tasks/${id}/retry`), { method: 'POST' });
6674
6767
  if (!r.ok) showErr('Retry failed.'); else await refresh();
@@ -6744,6 +6837,7 @@ function fsBoardBucket(goal, tasks){
6744
6837
  const s = goal?.status;
6745
6838
  if (s === 'pending') return 'later'; // shelved by the composer's "Later"
6746
6839
  if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
6840
+ if (s === 'needsApproval') return 'needs'; // an explicit go/no-go is waiting; never Review
6747
6841
  if (s === 'blocked' && goal?.blocked?.kind === 'paused') return 'doing'; // paused by user — stays in Doing, shown amber "Paused" (Masa 2026-07-19: 1カードのまま黄色に・Needs youへ移動しない)
6748
6842
  if (['blocked', 'failed', 'partial', 'interrupted'].includes(s)) return 'needs'; // stopped/errored — needs YOUR action (retry), like the flagship board which puts .drow.err in Needs you. Not "Doing" — Doing is only what's actively moving (Masa 2026-07-18)
6749
6843
  if (s === 'review' || s === 'folded') return 'review'; // folded (PR filed for review) reads as Review, not Doing — goalGroupStatus parity (Masa 2026-07-21)
@@ -6895,7 +6989,7 @@ function renderFsBoard(){
6895
6989
  col('To Do', 'var(--ink3)', bucket.todo.length, bucket.todo.map(goalCard).join(''), 'todo')
6896
6990
  + col('Doing', 'var(--green)', bucket.doing.length, bucket.doing.map(goalCard).join(''), 'doing')
6897
6991
  + (bucket.needs.length ? col('Needs you', '#ffca16', bucket.needs.length, bucket.needs.map(goalCard).join(''), 'needs') : '')
6898
- + col('Review', '#8390f2', reviewDigest.length, reviewDigest.map(reviewCard).join(''), 'review')
6992
+ + col('Review', 'var(--review)', reviewDigest.length, reviewDigest.map(reviewCard).join(''), 'review')
6899
6993
  + col('Done', 'var(--ink3)', bucket.done.length, bucket.done.slice(0, 40).map(goalCard).join(''), 'done')
6900
6994
  + col('Later', 'var(--ink3)', bucket.later.length, bucket.later.map(goalCard).join(''), 'later');
6901
6995
  for (const b of el.querySelectorAll('[data-fsnew]')) b.onclick = () => $('input')?.focus(); // every + New / header + focuses the composer
@@ -6924,7 +7018,7 @@ function togglePauseActive(){
6924
7018
  // width. They announce themselves beside the List/Board toggle instead — pure read of the same
6925
7019
  // buckets the board just rendered (no server call). Colour on the dot only; a column already
6926
7020
  // in view sinks. Only the columns with items are drawn — zero waiting shows nothing.
6927
- const FS_WAIT = [['needs', 'Needs you', '#ffca16'], ['review', 'Review', '#8390f2']];
7021
+ const FS_WAIT = [['needs', 'Needs you', '#ffca16'], ['review', 'Review', 'var(--review)']];
6928
7022
  function fsWaitPlace(){
6929
7023
  const bar = $('fsWait'), seg = $('fsTvSeg'), lane = $('fsLane');
6930
7024
  if (!bar || !seg || !lane) return;
@@ -6997,6 +7091,7 @@ function renderFsReview(){
6997
7091
  // retest ×3 note (v45 §5.3): the goal came back green from a Ledger Dismiss
6998
7092
  const g0 = pgoals.find((g) => g.id === r.goalIds[0]);
6999
7093
  const rnote = g0?.retest?.passed ? `<span class="rnote">✓ ${esc(g0.retest.note)}</span>` : '';
7094
+ const incompleteNote = r.incompleteRequirementCount ? `<span class="rnote">${r.incompleteRequirementCount} requirement${r.incompleteRequirementCount === 1 ? '' : 's'} need review</span>` : '';
7000
7095
  const infraNote = r.verificationInfraError?.detail
7001
7096
  ? `<span class="rnote">検証基盤エラー · <button type="button" class="retrybtn" data-goalreverify="${r.goalIds[0]}">再検証</button></span>`
7002
7097
  : '';
@@ -7011,7 +7106,7 @@ function renderFsReview(){
7011
7106
  // (Masa 2026-07-20: this card showed reviewSummary.check "…が出ない" while the list
7012
7107
  // showed the request text). Plan-reviews keep their "PLAN — approve to execute:"
7013
7108
  // checkLine, which is the actionable label there, not a title mismatch.
7014
- 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>
7109
+ 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}${incompleteNote}${infraNote}${pr}${conflictNote}</div>
7015
7110
  <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>`;
7016
7111
  };
7017
7112
  // V3-C (Masa決定 2026-07-07): no collapsed strip — it duplicated the sm2 meter right
@@ -7079,6 +7174,7 @@ function fsBucketTodo(){
7079
7174
  failed: list.filter((t) => ['failed', 'interrupted'].includes(t.status)),
7080
7175
  blocked: pgoals.filter((g) => g.status === 'blocked'),
7081
7176
  needsInput: pgoals.filter((g) => g.status === 'needsInput'), // a clarifying question is waiting on the user (board's "Needs you")
7177
+ needsApproval: pgoals.filter((g) => g.status === 'needsApproval'),
7082
7178
  reviewGoals: pgoals.filter((g) => g.status === 'review'),
7083
7179
  queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
7084
7180
  // A reply task's completion is already visible inline in the center-lane timeline
@@ -7186,7 +7282,7 @@ function errorButtonsHtml(meta, { taskId = null, goalId = null, interrupted = fa
7186
7282
  // error as a small in-row colour element (§1.5-8), quiet Done fold.
7187
7283
  function renderFsTodo(){
7188
7284
  const el = $('fsTodoSec'); if (!el) return;
7189
- const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later, rejected } = fsBucketTodo();
7285
+ const { pgoals, list, running, failed, blocked, needsInput, needsApproval, queued, doneAll, later, rejected } = fsBucketTodo();
7190
7286
  const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // list版 Doing トグルの状態
7191
7287
  // 一時停止したゴール(blocked kind:'paused')は Doing に「Paused」1行で留める(Masa 2026-07-19)。
7192
7288
  // Needs you 側(failed/blocked)へは出さず、その走行/中断タスク行も抑止して二重行を作らない。
@@ -7276,7 +7372,10 @@ function renderFsTodo(){
7276
7372
  <div class="acts2">${errorButtonsHtml(meta, { goalId: g.id, agent: g.agent })}<button class="errbtn" data-fsarchive="${g.id}">Archive</button></div></div></div>`; }).join('')
7277
7373
  + 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>
7278
7374
  <div class="errline"><div class="msg"><span class="x int">?</span>${tt(g.question?.text || 'A question is waiting on you', 180)}</div>
7279
- <div class="acts2"><button class="errbtn" data-fsglog="${g.id}">Answer</button></div></div></div>`).join('');
7375
+ <div class="acts2"><button class="errbtn" data-fsglog="${g.id}">Answer</button></div></div></div>`).join('')
7376
+ + needsApproval.map((g) => { const a = g.approvalRequest || {}; const scope = (a.scope || []).join(' · '); return `<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(a.question || g.text, 80)}</span><span class="stword int">Approval</span></div>
7377
+ <div class="errline"><div class="msg"><span class="x int">?</span>${tt(scope || a.action || 'Permission required', 180)}</div>
7378
+ <div class="acts2"><button class="errbtn" data-fsapproval="proceed" data-goal="${g.id}">Proceed</button><button class="errbtn" data-fsapprovaledit="${g.id}">Edit scope</button><button class="errbtn" data-fsapproval="cancel" data-goal="${g.id}">Cancel</button></div></div></div>`; }).join('');
7280
7379
  // Parked over-cap replies ride at the TOP of To Do (above the ordinary Next-up
7281
7380
  // queue), as neutral "Queued" rows — the same goal-row shape as a resumed Doing
7282
7381
  // goal but without the green "Working on it", because no worker is running yet.
@@ -7303,7 +7402,7 @@ function renderFsTodo(){
7303
7402
  // Keep executable work and deliberately-shelved Later work visibly distinct.
7304
7403
  // "To Do 6" while all six were in Later made an idle queue look broken: Later
7305
7404
  // never auto-starts by design. The header now tells the operational truth.
7306
- const needsCount = failed.filter((t) => !resumedGoalIds.has(t.goalId)).length + blocked.length + needsInput.length;
7405
+ const needsCount = failed.filter((t) => !resumedGoalIds.has(t.goalId)).length + blocked.length + needsInput.length + needsApproval.length;
7307
7406
  const doingCount = running.length + finalizingGoals.length + pausedGoalIds.size;
7308
7407
  const nextCount = parkedReplyGoals.length + queued.length;
7309
7408
  // Free-tier pending counter (H22): on the free plan the header shows the wall
@@ -7374,6 +7473,14 @@ function renderFsTodo(){
7374
7473
  for (const b of el.querySelectorAll('[data-fsprretry]')) b.onclick = (e) => { e.stopPropagation(); fsRetryPrGoal(b); };
7375
7474
  for (const b of el.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
7376
7475
  for (const b of el.querySelectorAll('[data-fsglog]')) b.onclick = () => toggleGoalDetail(Number(b.dataset.fsglog));
7476
+ for (const b of el.querySelectorAll('[data-fsapproval]')) b.onclick = async (e) => {
7477
+ e.stopPropagation(); await submitApprovalDecision(Number(b.dataset.goal), b.dataset.fsapproval);
7478
+ };
7479
+ for (const b of el.querySelectorAll('[data-fsapprovaledit]')) b.onclick = (e) => {
7480
+ e.stopPropagation(); fsInlineInput(b.closest('.drow'), '', 'Describe the allowed scope…', async (instruction) => {
7481
+ await submitApprovalDecision(Number(b.dataset.fsapprovaledit), 'edit', instruction);
7482
+ });
7483
+ };
7377
7484
  for (const b of el.querySelectorAll('[data-fsreverify]')) b.onclick = async () => {
7378
7485
  b.disabled = true; b.textContent = 'Starting…';
7379
7486
  let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreverify}/reverify`), { method: 'POST' }); }
@@ -8071,14 +8178,15 @@ function showBillingBlocked(blocked){
8071
8178
 
8072
8179
  const AUTH_COPY = {
8073
8180
  ja: {
8074
- connectTitle: '接続が必要です', connectBody: 'Claude Codeとの接続が切れています。ボタンから接続してください。', connect: 'Claude Codeを接続', retry: '接続を確認',
8181
+ connectTitle: '接続が必要です', connectBody: 'Claude Codeとの接続が切れています。ボタンから接続してください。', connect: 'Claude Codeを接続', retry: '接続を確認', checking: '確認中…',
8075
8182
  limitTitle: 'Claudeは利用上限です', limitBody: '作業は安全に待機しています。すぐ続ける場合は、待機中のタスクをCodexへ切り替えられます。', limitWait: '作業は安全に待機しています。Claudeが再び使えるようになると自動で再開します。', codex: 'Codexで続ける',
8076
8183
  },
8077
8184
  en: {
8078
- connectTitle: 'Connect Claude Code', connectBody: 'Claude Code is disconnected. Use the button to reconnect and continue.', connect: 'Connect Claude Code', retry: 'Check connection',
8185
+ connectTitle: 'Connect Claude Code', connectBody: 'Claude Code is disconnected. Use the button to reconnect and continue.', connect: 'Connect Claude Code', retry: 'Check connection', checking: 'Checking…',
8079
8186
  limitTitle: 'Claude limit reached', limitBody: 'Your work is safely waiting. Switch the waiting tasks to Codex if you want to continue now.', limitWait: 'Your work is safely waiting and will resume automatically when Claude is available.', codex: 'Continue with Codex',
8080
8187
  },
8081
8188
  };
8189
+ let authConnectionOpened = false;
8082
8190
  function renderAuthBanner(auth){
8083
8191
  const bar = $('errbar');
8084
8192
  state.auth = auth ?? state.auth;
@@ -8092,7 +8200,7 @@ function renderAuthBanner(auth){
8092
8200
  + `<div class="bx-q">${limited ? (canUseCodex ? copy.limitBody : copy.limitWait) : copy.connectBody}</div></div>`
8093
8201
  + `<div class="bx-act">${limited && canUseCodex
8094
8202
  ? `<button class="bx-cta" id="bxUseCodex">${copy.codex}</button>`
8095
- : limited ? '' : `<button class="bx-cta" id="bxConnectClaude">${copy.connect}</button><button class="bx-cta" id="bxRetryClaude">${copy.retry}</button>`}</div></div>`;
8203
+ : limited ? '' : `<button class="bx-cta" id="bxClaudeAuthAction" data-auth-phase="${authConnectionOpened ? 'verify' : 'connect'}"><span class="bx-spin"></span><span data-auth-label>${authConnectionOpened ? copy.retry : copy.connect}</span></button>`}</div></div>`;
8096
8204
  bar.style.display = 'block';
8097
8205
  clearTimeout(errTimer); // this banner persists — no auto-hide
8098
8206
  const codex = bar.querySelector('#bxUseCodex');
@@ -8108,30 +8216,39 @@ function renderAuthBanner(auth){
8108
8216
  } catch (e) { showErr(e.message || 'Could not switch to Codex.'); }
8109
8217
  finally { codex.disabled = false; }
8110
8218
  };
8111
- const connect = bar.querySelector('#bxConnectClaude');
8112
- if (connect) connect.onclick = async () => {
8113
- connect.disabled = true;
8114
- try {
8115
- const r = await fetch(withKey('/api/worker-auth/connect'), { method: 'POST' });
8116
- if (!r.ok) throw new Error();
8117
- showToast(copy === AUTH_COPY.ja ? '接続画面を開きました。完了後に「接続を確認」を押してください。' : 'Connection opened. When finished, choose “Check connection”.');
8118
- } catch { showErr(copy === AUTH_COPY.ja ? '接続画面を開けませんでした。' : 'Could not open the connection.'); }
8119
- finally { connect.disabled = false; }
8120
- };
8121
- const retry = bar.querySelector('#bxRetryClaude');
8122
- if (retry) retry.onclick = async () => {
8123
- retry.disabled = true;
8219
+ const authAction = bar.querySelector('#bxClaudeAuthAction');
8220
+ if (authAction) authAction.onclick = async () => {
8221
+ const phase = authAction.dataset.authPhase;
8222
+ authAction.disabled = true;
8223
+ authAction.classList.add('loading');
8224
+ const label = authAction.querySelector('[data-auth-label]');
8225
+ if (label) label.textContent = phase === 'verify' ? copy.checking : copy.connect;
8124
8226
  try {
8125
- const r = await fetch(withKey('/api/worker-auth/retry'), { method: 'POST' });
8227
+ const endpoint = phase === 'verify' ? '/api/worker-auth/retry' : '/api/worker-auth/connect';
8228
+ const r = await fetch(withKey(endpoint), { method: 'POST' });
8126
8229
  const body = await r.json().catch(() => ({}));
8127
8230
  if (!r.ok) throw new Error();
8128
- renderAuthBanner(body.auth);
8129
- if (body.auth?.ok) showToast(copy === AUTH_COPY.ja ? '接続できました。' : 'Connected.');
8130
- } catch { showErr(copy === AUTH_COPY.ja ? 'まだ接続できていません。' : 'Not connected yet.'); }
8131
- finally { retry.disabled = false; }
8231
+ if (phase === 'connect') {
8232
+ authConnectionOpened = true;
8233
+ renderAuthBanner(state.auth);
8234
+ showToast(copy === AUTH_COPY.ja ? '接続画面を開きました。完了後に「接続を確認」を押してください。' : 'Connection opened. When finished, choose “Check connection”.');
8235
+ } else {
8236
+ renderAuthBanner(body.auth);
8237
+ if (body.auth?.ok) showToast(copy === AUTH_COPY.ja ? '接続できました。' : 'Connected.');
8238
+ }
8239
+ } catch {
8240
+ showErr(phase === 'verify'
8241
+ ? (copy === AUTH_COPY.ja ? 'まだ接続できていません。' : 'Not connected yet.')
8242
+ : (copy === AUTH_COPY.ja ? '接続画面を開けませんでした。' : 'Could not open the connection.'));
8243
+ } finally {
8244
+ authAction.disabled = false;
8245
+ authAction.classList.remove('loading');
8246
+ if (label) label.textContent = phase === 'verify' ? copy.retry : copy.connect;
8247
+ }
8132
8248
  };
8133
8249
  } else if (authDown) {
8134
8250
  authDown = false;
8251
+ authConnectionOpened = false;
8135
8252
  bar.classList.remove('billing');
8136
8253
  bar.style.display = 'none';
8137
8254
  bar.innerHTML = '';
@@ -8858,6 +8975,10 @@ const REPLY_PLAIN = new Set(['running', 'partial', 'failed', 'interrupted', 'blo
8858
8975
  function replyBinding(){
8859
8976
  const id = state.selectedGoal;
8860
8977
  if (id == null) return null;
8978
+ // Focus and reply-target are separate concepts. A Review reply keeps this goal
8979
+ // selected so its live work remains in the centre feed, while the composer goes
8980
+ // back to new-goal mode and shows no stale “Replying to” chip.
8981
+ if (state.composerDetachedGoal === id) return null;
8861
8982
  const g = state.goals.find((x) => x.id === id);
8862
8983
  if (!g || g.projectId !== state.active) return null; // stale after a project switch → new-goal mode
8863
8984
  // needsInput has NO worker session yet (planGoal() sets it before any task/worker
@@ -8932,10 +9053,9 @@ async function send(){
8932
9053
  const body = await replyToGoal(bind.goal.id, fullText, '#input');
8933
9054
  if (body) {
8934
9055
  upsert(state.goals, body.goal); if (body.task) upsert(state.tasks, body.task);
8935
- // A Review reply is a one-shot handoff. Keeping the composer bound
8936
- // afterwards adds a redundant “Replying to #N” banner and makes the
8937
- // next, unrelated instruction easy to send to the old goal by mistake.
8938
- state.selectedGoal = null;
9056
+ // Keep the goal selected for the centre feed; detach only the composer.
9057
+ state.composerDetachedGoal = bind.goal.id;
9058
+ state.openThreads.add(bind.goal.id);
8939
9059
  render();
8940
9060
  }
8941
9061
  } else {
@@ -9221,6 +9341,14 @@ function saRowFromDigest(r, gs, ts){
9221
9341
  title: saReviewTitle(g),
9222
9342
  check: String(g?.reviewSummary?.check || r.checkLine || '').replace(/\s+/g, ' ').trim(),
9223
9343
  goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
9344
+ // Server supplies this fact-only ledger for new reviews. Older reviews
9345
+ // simply omit it, so the UI remains backward compatible during rollout.
9346
+ attempt: g?.reviewAttempt ?? null,
9347
+ unmappedChangedFiles: outcome?.unmappedChangedFiles ?? [],
9348
+ requirementVerification: outcome?.requirementVerification ?? null,
9349
+ requirementCoverage: ts.filter((t) => t.goalId === g?.id && (t.detail || t.title)).sort((a, b) => (a.num ?? 0) - (b.num ?? 0)).map((t) => ({ text: String(t.reply ? t.detail : t.title).replace(/\s+/g, ' ').trim(), taskStatus: t.status, evidenceStatus: t.requirementEvidence?.requirements?.[0]?.status ?? null })),
9350
+ requirementEvidence: outcome?.requirementEvidence?.requirements ?? [],
9351
+ requirementCoverage: ts.filter((t) => t.goalId === g?.id && (t.detail || t.title) && (!t.reply || !t.replyFrom || t.replyFrom === 'you')).sort((a, b) => (a.num ?? 0) - (b.num ?? 0)).map((t) => ({ text: String(t.reply ? t.detail : t.title).replace(/\s+/g, ' ').trim(), status: t.status })),
9224
9352
  did, siblings, risk: g?.risk ?? null,
9225
9353
  verificationInfraError: g?.verificationInfraError ?? r.verificationInfraError ?? null,
9226
9354
  prNote: g?.prNote ?? null,
@@ -9726,6 +9854,44 @@ function saItemHtml(it, i){
9726
9854
  const reqSec = !it.goalText ? '' : reqFold
9727
9855
  ? `<section class="rsec rreq"><div class="rsec-h">Request</div><details class="lacc" data-detkey="${gk}:req"${reqOpen ? ' open' : ''}><summary><i class="ltog">›</i><p class="rbody rask lfirst">${esc(reqPeek)}</p></summary><div class="lbody"><p class="rbody rask">${esc(it.goalText)}</p></div></details></section>`
9728
9856
  : `<section class="rsec rreq"><div class="rsec-h">Request</div><p class="rbody rask">${esc(it.goalText)}</p></section>`;
9857
+ // The active attempt is intentionally a compact fact row, not a second
9858
+ // interpretation of the request. It makes the latest reply, files and
9859
+ // verification visible together while prior attempts stay in Conversation.
9860
+ const attempt = it.attempt;
9861
+ const activeText = String(attempt?.activeRequirement?.text || '').replace(/\s+/g, ' ').trim();
9862
+ const attemptFiles = Array.isArray(attempt?.changedFiles) ? attempt.changedFiles.filter(Boolean) : [];
9863
+ const attemptTests = attempt?.verification?.tests;
9864
+ const attemptProof = attempt?.verification?.proof;
9865
+ const attemptEvidence = [
9866
+ attemptFiles.length ? `Changed: ${attemptFiles.join(' · ')}` : '',
9867
+ attemptTests?.ran ? `Tests: ${attemptTests.passed ?? 0} passed${attemptTests.failed ? ` / ${attemptTests.failed} failed` : ''}` : '',
9868
+ attemptProof?.pass === true ? 'Proof recorded' : '',
9869
+ ].filter(Boolean).join(' · ');
9870
+ const attemptSec = activeText || attemptEvidence
9871
+ ? `<section class="rsec"><div class="rsec-h">This review</div>${activeText ? `<p class="rbody rask">${esc(activeText)}</p>` : ''}${attemptEvidence ? `<p class="rbody v">${esc(attemptEvidence)}</p>` : ''}</section>`
9872
+ : '';
9873
+ const reqVerify = it.requirementVerification;
9874
+ const reqVerifyParts = [
9875
+ reqVerify?.proof?.verified ? 'Independent proof recorded' : reqVerify?.proof ? 'Proof recorded' : '',
9876
+ reqVerify?.run?.url ? `Open it: ${reqVerify.run.url}` : '',
9877
+ Array.isArray(reqVerify?.changedFiles) && reqVerify.changedFiles.length ? `Files: ${reqVerify.changedFiles.join(' · ')}` : '',
9878
+ ].filter(Boolean);
9879
+ const reqVerifySec = reqVerifyParts.length ? `<section class="rsec"><div class="rsec-h">Requirement verification</div><p class="rbody v">${esc(reqVerifyParts.join(' · '))}</p></section>` : '';
9880
+ const coverageRows = (it.requirementCoverage || []).filter((r) => r.text).map((r) => {
9881
+ const status = r.evidenceStatus || r.taskStatus;
9882
+ const tone = status === 'addressed' || status === 'done' ? 'ok' : status === 'partial' ? '' : 'dim';
9883
+ return `<p class="rbody v"><b class="${tone}">${esc(status)}</b> · ${esc(r.text)}</p>`;
9884
+ }).join('');
9885
+ const coverageSec = coverageRows ? `<section class="rsec"><div class="rsec-h">Requirement coverage</div>${coverageRows}</section>` : '';
9886
+ // Worker-declared evidence is displayed verbatim as an auditable label, not
9887
+ // promoted into a Manager verdict. Empty means the worker did not provide it.
9888
+ const evidenceRows = (it.requirementEvidence || []).map((entry) => {
9889
+ const tone = entry.status === 'addressed' ? 'ok' : entry.status === 'partial' ? '' : 'dim';
9890
+ return `<p class="rbody v"><b class="${tone}">${esc(entry.status)}</b> · ${esc(entry.evidence || entry.id)}</p>`;
9891
+ }).join('');
9892
+ const evidenceSec = evidenceRows ? `<section class="rsec"><div class="rsec-h">Requirement evidence</div>${evidenceRows}</section>` : '';
9893
+ const userCoverageRows = (it.requirementCoverage || []).filter((r) => r.text).map((r) => `<p class="rbody v"><b class="${r.status === 'done' ? 'ok' : 'dim'}">${esc(r.status)}</b> · ${esc(r.text)}</p>`).join('');
9894
+ const userCoverageSec = userCoverageRows ? `<section class="rsec"><div class="rsec-h">User requirements</div>${userCoverageRows}</section>` : '';
9729
9895
  // ── Open it (Masa 2026-07-22): a plain section in the card's own typography — the same
9730
9896
  // heading + body shape as Request and Result, not a chip in another vocabulary. The
9731
9897
  // address the worker declared IS the control (press it → the process starts on demand →
@@ -9843,7 +10009,11 @@ function saItemHtml(it, i){
9843
10009
  const diffHtml = it.ghRows
9844
10010
  ? saGhDiffRowsHtml(it.ghRows, it.diffTotal || 0, it.pr || '', gk)
9845
10011
  : hasGitDiff ? saGhDiffHtml(d.diff, it.pr, gk) : clientDiff ? saGhDiffHtml(clientDiff, it.pr, gk) : '';
9846
- const changedBody = isConv ? '' : (changedDescHtml + filesListHtml + diffHtml);
10012
+ const unmapped = Array.isArray(it.unmappedChangedFiles) ? it.unmappedChangedFiles.filter(Boolean) : [];
10013
+ // "Unmapped" is a fact: these measured changes were not named in worker
10014
+ // evidence. It is intentionally not labelled unrelated or wrong.
10015
+ const unmappedHtml = unmapped.length ? `<p class="rbody v">Not mapped to a requirement: ${esc(unmapped.join(' · '))}</p>` : '';
10016
+ const changedBody = isConv ? '' : (changedDescHtml + filesListHtml + unmappedHtml + diffHtml);
9847
10017
  // ── sensitive-area note (real: classifyChangeRisk forced the pause) ──
9848
10018
  const risk = it.risk;
9849
10019
  const riskHtml = risk?.shouldPause
@@ -9928,8 +10098,18 @@ function saItemHtml(it, i){
9928
10098
  // interrupted goal is amber+resumable, everything else stopped is a red error.
9929
10099
  const attnInt = gstatus === 'interrupted' || Boolean(it.interruptedTask);
9930
10100
  const attnLabel = attnInt ? 'Interrupted — resume available' : gstatus === 'blocked' ? 'Blocked' : 'Failed';
10101
+ const liveReply = [...state.tasks].reverse().find((t) => t.goalId === it.goalId && t.reply);
10102
+ const liveWorkLabel = liveReply?.status === 'queued'
10103
+ ? 'Queued · To Do'
10104
+ : liveReply?.status === 'running'
10105
+ ? 'Working on it · Doing'
10106
+ : it.goal?.status === 'review'
10107
+ ? 'Ready for review'
10108
+ : it.goal?.status === 'failed' || it.goal?.status === 'partial'
10109
+ ? 'Needs attention'
10110
+ : 'Working on it';
9931
10111
  const rst = working
9932
- ? '<span class="rst working"><span class="rdot"></span>Working on it</span>'
10112
+ ? `<span class="rst working"><span class="rdot"></span>${liveWorkLabel}</span>`
9933
10113
  : attn
9934
10114
  ? `<span class="rst ${attnInt ? 'int' : 'err'}"><span class="rdot"></span>${attnLabel}</span>`
9935
10115
  : '<span class="rst"><span class="rdot"></span>In review</span>';
@@ -9938,6 +10118,11 @@ function saItemHtml(it, i){
9938
10118
  <h2 class="rtitle">${esc(it.title)}</h2>
9939
10119
  ${meta}
9940
10120
  ${reqSec}
10121
+ ${attemptSec}
10122
+ ${coverageSec}
10123
+ ${evidenceSec}
10124
+ ${reqVerifySec}
10125
+ ${userCoverageSec}
9941
10126
  ${S('Open it', openBody)}
9942
10127
  ${S('Run state', interruptedBody)}
9943
10128
  ${S('PR', prRepairBody)}
@@ -10098,7 +10283,22 @@ function saRender(){
10098
10283
  function saSync(){
10099
10284
  if (!SA.open) return;
10100
10285
  const ae = document.activeElement;
10101
- if (ae && $('seeall').contains(ae) && ae.tagName === 'INPUT') return;
10286
+ // Do not erase text while the reviewer is typing. Once Send clears the box,
10287
+ // focus alone must not freeze To Do / Doing / Review status updates.
10288
+ if (ae && $('seeall').contains(ae) && ae.tagName === 'INPUT' && ae.value) return;
10289
+ // Working cards are intentionally absent from the Review ledger rows after the
10290
+ // server moves them to To Do / Doing. Keep their held dialog snapshot wired to
10291
+ // live goal/task state so the status changes in place instead of disappearing.
10292
+ for (const ex of SA.items.filter((x) => x.st === 3)) {
10293
+ const liveGoal = state.goals.find((g) => g.id === ex.goalId);
10294
+ if (liveGoal) ex.goal = liveGoal;
10295
+ const activeReply = state.tasks.some((t) => t.goalId === ex.goalId && t.reply && ['queued', 'running'].includes(t.status));
10296
+ if (liveGoal?.status === 'review' && !activeReply) {
10297
+ ex.st = 0;
10298
+ ex.rstatus = null;
10299
+ ex.inflight = false;
10300
+ }
10301
+ }
10102
10302
  const rows = saLedgerRows();
10103
10303
  for (const row of rows) {
10104
10304
  const ex = SA.items.find((x) => x.goalId === row.goalId);
@@ -10196,42 +10396,24 @@ async function saReplyCall(it, text, outcome){
10196
10396
  if (!r?.ok) throw new Error('reply failed');
10197
10397
  return r.json().catch(() => null);
10198
10398
  }
10199
- // Once feedback becomes rework, remove it from Review immediately and repaint the board
10200
- // from the response. A queued reply appears in To Do; the next worker event promotes it to
10201
- // Doing. Questions/unsure replies keep status=review and never come through this function.
10202
- function saMoveFeedbackOutOfReview(it, body){
10399
+ // Once feedback becomes rework, keep the card as a live progress surface until the
10400
+ // reviewer closes it. The lane behind the dialog still moves immediately to To Do / Doing,
10401
+ // and the centre conversation follows the same goal; only the Review card snapshot is held.
10402
+ function saKeepFeedbackLive(it, body){
10203
10403
  const goal = body?.goal;
10204
10404
  const task = body?.task;
10205
10405
  if (goal?.id) upsert(state.goals, goal);
10206
10406
  if (task?.id) upsert(state.tasks, task);
10207
- const at = SA.items.findIndex((row) => row.goalId === it.goalId);
10208
- const next = at >= 0 ? (SA.items[at + 1] || SA.items[at - 1]) : null;
10209
- // Was the reviewer actually looking at the card that just left Review (vs having already
10210
- // scrolled past it to read another one)? If it's fully above the visible scroll area, the
10211
- // reviewer is reading something else — snapping the view to the next card's top would yank
10212
- // it out from under them (Masa 2026-07-26: replying to the card above and having the one
10213
- // you're reading jump to the top was reported as stressful).
10214
- const bodyEl = document.querySelector('#seeall .sa-body');
10215
- const cardEl = saCardEl(it.goalId);
10216
- const priorScrollTop = bodyEl?.scrollTop ?? 0;
10217
- const removedHeight = cardEl ? cardEl.getBoundingClientRect().height : 0;
10218
- const cardAboveViewport = !!(cardEl && bodyEl && cardEl.getBoundingClientRect().bottom <= bodyEl.getBoundingClientRect().top + 1);
10219
- if (at >= 0) SA.items.splice(at, 1);
10407
+ it.goal = goal ?? state.goals.find((g) => g.id === it.goalId) ?? it.goal;
10408
+ it.st = 3;
10409
+ it.inflight = false;
10410
+ it.rstatus = 'working';
10411
+ SA.cur = it.goalId;
10412
+ state.selectedGoal = it.goalId;
10413
+ state.composerDetachedGoal = it.goalId;
10414
+ state.openThreads.add(it.goalId);
10220
10415
  render();
10221
- if (SA.solo === it.goalId || !SA.items.length) { saRender(); saClose(); return; }
10222
- SA.cur = next?.goalId ?? SA.items[0]?.goalId ?? null;
10223
- if (cardAboveViewport) {
10224
- // Keep reading where we were: compensate for the removed card's height instead of
10225
- // forcing a scroll-to-top of whatever is now "current".
10226
- SA.focusGid = null;
10227
- saRender();
10228
- const newBody = document.querySelector('#seeall .sa-body');
10229
- if (newBody) newBody.scrollTop = Math.max(0, priorScrollTop - removedHeight);
10230
- } else {
10231
- SA.focusGid = SA.cur;
10232
- SA.focusScrolled = false;
10233
- saRender();
10234
- }
10416
+ saPaintCard(it.goalId);
10235
10417
  }
10236
10418
  function saCardEl(goalId){ return document.querySelector(`#seeall .sa-item[data-gid="${goalId}"]`); }
10237
10419
  // Flip one card to its "Working on it" head without redrawing anything else (決定2).
@@ -10240,7 +10422,13 @@ function saFlipWorking(goalId){
10240
10422
  if (!card) return;
10241
10423
  card.classList.add('working');
10242
10424
  const rst = card.querySelector('.rhead .rst');
10243
- if (rst) { rst.className = 'rst working'; rst.innerHTML = '<span class="rdot"></span>Working on it'; }
10425
+ const it = SA.items.find((x) => x.goalId === goalId);
10426
+ const liveReply = [...state.tasks].reverse().find((t) => t.goalId === goalId && t.reply);
10427
+ const label = liveReply?.status === 'queued' ? 'Queued · To Do'
10428
+ : liveReply?.status === 'running' ? 'Working on it · Doing'
10429
+ : it?.goal?.status === 'review' ? 'Ready for review'
10430
+ : 'Working on it';
10431
+ if (rst) { rst.className = 'rst working'; rst.innerHTML = `<span class="rdot"></span>${label}`; }
10244
10432
  }
10245
10433
  // Repaint one card's reply thread IN PLACE from its client state — no saRender, so nothing
10246
10434
  // above the box moves. Header is flipped to "Working on it" iff the card is in that state,
@@ -10334,7 +10522,7 @@ async function saSendReply(it, i, text){
10334
10522
  // A change request has left Review. The response is the source of truth for whether it
10335
10523
  // is queued (To Do) or already starting (Doing); reveal that transition immediately.
10336
10524
  if (body?.goal && body.goal.status !== 'review') {
10337
- saMoveFeedbackOutOfReview(it, body);
10525
+ saKeepFeedbackLive(it, body);
10338
10526
  return;
10339
10527
  }
10340
10528
  // split keeps the original goal in Review; only the newly-created ticket moves away.
@@ -11057,7 +11245,7 @@ $('filein').addEventListener('change', () => {
11057
11245
  $('filein').value = '';
11058
11246
  });
11059
11247
  // Feature A: ✕ on the "Replying to #NNN" chip detaches → back to new-goal mode.
11060
- $('replyBindX').onclick = () => { state.selectedGoal = null; render(); };
11248
+ $('replyBindX').onclick = () => { state.composerDetachedGoal = state.selectedGoal; render(); };
11061
11249
  const IS_MOBILE = () => window.matchMedia('(max-width: 760px)').matches;
11062
11250
  // Phones are chat-first: the Tasks sheet starts FOLDED so the first view is the
11063
11251
  // conversation + composer (the sheet is an 88vw overlay — default-open buried
@@ -11699,7 +11887,7 @@ function gearHistHTML(){
11699
11887
  };
11700
11888
  // History = everything you've sent, newest first (not only settled goals —
11701
11889
  // that felt empty). Each row gets a status glyph; unknown states fall back.
11702
- const MARK = { done: ['ok', '✓'], reverted: ['no', '↩'], skipped: ['ar', '—'], failed: ['no', '✕'], interrupted: ['ar', '⏸'], review: ['rv', '●'], running: ['run', '…'], planning: ['run', '…'], stacked: ['ar', '·'], needsInput: ['rv', '?'] };
11890
+ const MARK = { done: ['ok', '✓'], reverted: ['no', '↩'], skipped: ['ar', '—'], failed: ['no', '✕'], interrupted: ['ar', '⏸'], review: ['rv', '●'], running: ['run', '…'], planning: ['run', '…'], stacked: ['ar', '·'], needsInput: ['rv', '?'], needsApproval: ['rv', '?'] };
11703
11891
  const evs = (state.goals || [])
11704
11892
  .filter((g) => g.projectId === state.active)
11705
11893
  .map((g) => ({ g, ts: goalTs(g) })).sort((a, b) => b.ts - a.ts);