@galda/cli 0.10.83 → 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;
@@ -5368,7 +5437,10 @@ function renderReviewChecklist(){
5368
5437
  // own test result — so a reviewer can judge at a glance, before scrolling.
5369
5438
  const grs = goal.reviewSummary ?? {};
5370
5439
  const gtr = goal.testResult;
5371
- const testLine = gtr?.ran ? `<span class="tbadge ${gtr.ok ? 'ok' : 'no'}">tests ${gtr.passed}✓${gtr.failed ? ` / ${gtr.failed}✗` : ''}</span>` : '';
5440
+ const testPreflightFailed = gtr?.preflight && !gtr.preflight.ok;
5441
+ const testLine = gtr?.ran ? (testPreflightFailed
5442
+ ? `<span class="tbadge">tests need environment setup</span>`
5443
+ : `<span class="tbadge ${gtr.ok ? 'ok' : 'no'}">tests ${gtr.passed}✓${gtr.failed ? ` / ${gtr.failed}✗` : ''}</span>`) : '';
5372
5444
  // #377-7 (Masa, recurring): the base UI is ALWAYS English — Japanese is a
5373
5445
  // per-project CONTENT customization, never chrome. So the fixed system labels
5374
5446
  // below stay English regardless of reviewDefinition.language; only the
@@ -5398,7 +5470,7 @@ function renderReviewChecklist(){
5398
5470
  vchips.push(`<span class="vchip none">${esc(workspaceModeText(goal))}</span>`);
5399
5471
  if (goal.workspaceWarning) vchips.push(`<span class="vchip human high">⚠ ${esc(goal.workspaceWarning.slice(0, 80))}</span>`);
5400
5472
  vchips.push(gtr?.ran
5401
- ? (gtr.ok ? `<span class="vchip ok">✓ ${VF.pass}</span>` : `<span class="vchip no">✗ ${VF.fail}</span>`)
5473
+ ? (testPreflightFailed ? `<span class="vchip human">! tests need setup</span>` : gtr.ok ? `<span class="vchip ok">✓ ${VF.pass}</span>` : `<span class="vchip no">✗ ${VF.fail}</span>`)
5402
5474
  : `<span class="vchip none">— ${VF.untested}</span>`);
5403
5475
  vchips.push(hasProof ? `<span class="vchip ok">✓ ${VF.proof}</span>` : `<span class="vchip none">— ${VF.noproof}</span>`);
5404
5476
  if (risk?.shouldPause) vchips.push(`<span class="vchip human high">⚠ ${VF.human}</span>`);
@@ -6031,9 +6103,11 @@ async function sendReviewDismiss(text){
6031
6103
  if (body.task) upsert(state.tasks, body.task);
6032
6104
  $('reviewFixInput').value = '';
6033
6105
  reviewAttach = []; renderReviewAttach();
6034
- // A question changed nothing, so closing the card would hide the answer that is
6035
- // on its way. Only a reply that actually moved the goal closes it.
6036
- 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);
6037
6111
  render();
6038
6112
  } catch { showErr('Dismiss failed.'); }
6039
6113
  }
@@ -6419,13 +6493,23 @@ function renderFsRail(){
6419
6493
  const pgoals = state.goals.filter((g) => g.projectId === p.id);
6420
6494
  // running dot (HANDOFF-v45 §11): a REAL running task, independent of selection —
6421
6495
  // .on already means "selected", .run means "actually doing work right now".
6422
- 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'));
6423
6503
  const td = ptasks.filter((t) => ['running', 'queued'].includes(t.status)).length;
6424
6504
  const rev = buildReviewDigest({ goals: pgoals, tasks: ptasks }).length;
6425
6505
  const pcount = `<span class="pcount" title="${rev} review${rev === 1 ? '' : 's'} ready · ${td} to-do">`
6426
6506
  + `<span class="pc-item rev${rev ? '' : ' zero'}">${FS_ICO_SCAN}${rev}</span>`
6427
6507
  + `<span class="pc-item td">${FS_ICO_TD}${td}</span></span>`;
6428
- 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>`;
6429
6513
  }).join('')
6430
6514
  + `<button class="fsprojadd" id="fsProjAdd" title="New project"><span class="pa-plus"></span><span class="pa-label">New project</span></button>`;
6431
6515
  // Skip the DOM swap when the rail is unchanged: re-creating .fsproj.run would restart the
@@ -6628,7 +6712,12 @@ function renderFsFeed(){
6628
6712
  if (!out && hasWork) {
6629
6713
  const laterN = pgoals.filter((g) => g.status === 'pending').length;
6630
6714
  const queuedN = ptasks.filter((t) => t.status === 'queued').length + pgoals.filter((g) => ['planning', 'stacked'].includes(g.status)).length;
6631
- 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';
6632
6721
  out = `<div class="cc-c">⎿ ${esc(msg)}</div>`;
6633
6722
  }
6634
6723
  $('fsRoot').classList.toggle('clawdhero', !hasWork);
@@ -6666,6 +6755,13 @@ async function fsAnswerQuestion(goalId, answer){
6666
6755
  catch { showErr('Failed to send answer'); return; }
6667
6756
  if (!r.ok) showErr('Failed to send answer'); else await refresh();
6668
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
+ }
6669
6765
  async function fsRetryTask(id){
6670
6766
  const r = await fetch(withKey(`/api/tasks/${id}/retry`), { method: 'POST' });
6671
6767
  if (!r.ok) showErr('Retry failed.'); else await refresh();
@@ -6741,6 +6837,7 @@ function fsBoardBucket(goal, tasks){
6741
6837
  const s = goal?.status;
6742
6838
  if (s === 'pending') return 'later'; // shelved by the composer's "Later"
6743
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
6744
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へ移動しない)
6745
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)
6746
6843
  if (s === 'review' || s === 'folded') return 'review'; // folded (PR filed for review) reads as Review, not Doing — goalGroupStatus parity (Masa 2026-07-21)
@@ -6892,7 +6989,7 @@ function renderFsBoard(){
6892
6989
  col('To Do', 'var(--ink3)', bucket.todo.length, bucket.todo.map(goalCard).join(''), 'todo')
6893
6990
  + col('Doing', 'var(--green)', bucket.doing.length, bucket.doing.map(goalCard).join(''), 'doing')
6894
6991
  + (bucket.needs.length ? col('Needs you', '#ffca16', bucket.needs.length, bucket.needs.map(goalCard).join(''), 'needs') : '')
6895
- + col('Review', '#8390f2', reviewDigest.length, reviewDigest.map(reviewCard).join(''), 'review')
6992
+ + col('Review', 'var(--review)', reviewDigest.length, reviewDigest.map(reviewCard).join(''), 'review')
6896
6993
  + col('Done', 'var(--ink3)', bucket.done.length, bucket.done.slice(0, 40).map(goalCard).join(''), 'done')
6897
6994
  + col('Later', 'var(--ink3)', bucket.later.length, bucket.later.map(goalCard).join(''), 'later');
6898
6995
  for (const b of el.querySelectorAll('[data-fsnew]')) b.onclick = () => $('input')?.focus(); // every + New / header + focuses the composer
@@ -6921,7 +7018,7 @@ function togglePauseActive(){
6921
7018
  // width. They announce themselves beside the List/Board toggle instead — pure read of the same
6922
7019
  // buckets the board just rendered (no server call). Colour on the dot only; a column already
6923
7020
  // in view sinks. Only the columns with items are drawn — zero waiting shows nothing.
6924
- const FS_WAIT = [['needs', 'Needs you', '#ffca16'], ['review', 'Review', '#8390f2']];
7021
+ const FS_WAIT = [['needs', 'Needs you', '#ffca16'], ['review', 'Review', 'var(--review)']];
6925
7022
  function fsWaitPlace(){
6926
7023
  const bar = $('fsWait'), seg = $('fsTvSeg'), lane = $('fsLane');
6927
7024
  if (!bar || !seg || !lane) return;
@@ -6994,6 +7091,7 @@ function renderFsReview(){
6994
7091
  // retest ×3 note (v45 §5.3): the goal came back green from a Ledger Dismiss
6995
7092
  const g0 = pgoals.find((g) => g.id === r.goalIds[0]);
6996
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>` : '';
6997
7095
  const infraNote = r.verificationInfraError?.detail
6998
7096
  ? `<span class="rnote">検証基盤エラー · <button type="button" class="retrybtn" data-goalreverify="${r.goalIds[0]}">再検証</button></span>`
6999
7097
  : '';
@@ -7008,7 +7106,7 @@ function renderFsReview(){
7008
7106
  // (Masa 2026-07-20: this card showed reviewSummary.check "…が出ない" while the list
7009
7107
  // showed the request text). Plan-reviews keep their "PLAN — approve to execute:"
7010
7108
  // checkLine, which is the actionable label there, not a title mismatch.
7011
- 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>
7012
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>`;
7013
7111
  };
7014
7112
  // V3-C (Masa決定 2026-07-07): no collapsed strip — it duplicated the sm2 meter right
@@ -7076,6 +7174,7 @@ function fsBucketTodo(){
7076
7174
  failed: list.filter((t) => ['failed', 'interrupted'].includes(t.status)),
7077
7175
  blocked: pgoals.filter((g) => g.status === 'blocked'),
7078
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'),
7079
7178
  reviewGoals: pgoals.filter((g) => g.status === 'review'),
7080
7179
  queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
7081
7180
  // A reply task's completion is already visible inline in the center-lane timeline
@@ -7183,7 +7282,7 @@ function errorButtonsHtml(meta, { taskId = null, goalId = null, interrupted = fa
7183
7282
  // error as a small in-row colour element (§1.5-8), quiet Done fold.
7184
7283
  function renderFsTodo(){
7185
7284
  const el = $('fsTodoSec'); if (!el) return;
7186
- 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();
7187
7286
  const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // list版 Doing トグルの状態
7188
7287
  // 一時停止したゴール(blocked kind:'paused')は Doing に「Paused」1行で留める(Masa 2026-07-19)。
7189
7288
  // Needs you 側(failed/blocked)へは出さず、その走行/中断タスク行も抑止して二重行を作らない。
@@ -7273,7 +7372,10 @@ function renderFsTodo(){
7273
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('')
7274
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>
7275
7374
  <div class="errline"><div class="msg"><span class="x int">?</span>${tt(g.question?.text || 'A question is waiting on you', 180)}</div>
7276
- <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('');
7277
7379
  // Parked over-cap replies ride at the TOP of To Do (above the ordinary Next-up
7278
7380
  // queue), as neutral "Queued" rows — the same goal-row shape as a resumed Doing
7279
7381
  // goal but without the green "Working on it", because no worker is running yet.
@@ -7300,7 +7402,7 @@ function renderFsTodo(){
7300
7402
  // Keep executable work and deliberately-shelved Later work visibly distinct.
7301
7403
  // "To Do 6" while all six were in Later made an idle queue look broken: Later
7302
7404
  // never auto-starts by design. The header now tells the operational truth.
7303
- 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;
7304
7406
  const doingCount = running.length + finalizingGoals.length + pausedGoalIds.size;
7305
7407
  const nextCount = parkedReplyGoals.length + queued.length;
7306
7408
  // Free-tier pending counter (H22): on the free plan the header shows the wall
@@ -7371,6 +7473,14 @@ function renderFsTodo(){
7371
7473
  for (const b of el.querySelectorAll('[data-fsprretry]')) b.onclick = (e) => { e.stopPropagation(); fsRetryPrGoal(b); };
7372
7474
  for (const b of el.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
7373
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
+ };
7374
7484
  for (const b of el.querySelectorAll('[data-fsreverify]')) b.onclick = async () => {
7375
7485
  b.disabled = true; b.textContent = 'Starting…';
7376
7486
  let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreverify}/reverify`), { method: 'POST' }); }
@@ -8068,14 +8178,15 @@ function showBillingBlocked(blocked){
8068
8178
 
8069
8179
  const AUTH_COPY = {
8070
8180
  ja: {
8071
- connectTitle: '接続が必要です', connectBody: 'Claude Codeとの接続が切れています。ボタンから接続してください。', connect: 'Claude Codeを接続', retry: '接続を確認',
8181
+ connectTitle: '接続が必要です', connectBody: 'Claude Codeとの接続が切れています。ボタンから接続してください。', connect: 'Claude Codeを接続', retry: '接続を確認', checking: '確認中…',
8072
8182
  limitTitle: 'Claudeは利用上限です', limitBody: '作業は安全に待機しています。すぐ続ける場合は、待機中のタスクをCodexへ切り替えられます。', limitWait: '作業は安全に待機しています。Claudeが再び使えるようになると自動で再開します。', codex: 'Codexで続ける',
8073
8183
  },
8074
8184
  en: {
8075
- 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…',
8076
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',
8077
8187
  },
8078
8188
  };
8189
+ let authConnectionOpened = false;
8079
8190
  function renderAuthBanner(auth){
8080
8191
  const bar = $('errbar');
8081
8192
  state.auth = auth ?? state.auth;
@@ -8089,7 +8200,7 @@ function renderAuthBanner(auth){
8089
8200
  + `<div class="bx-q">${limited ? (canUseCodex ? copy.limitBody : copy.limitWait) : copy.connectBody}</div></div>`
8090
8201
  + `<div class="bx-act">${limited && canUseCodex
8091
8202
  ? `<button class="bx-cta" id="bxUseCodex">${copy.codex}</button>`
8092
- : 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>`;
8093
8204
  bar.style.display = 'block';
8094
8205
  clearTimeout(errTimer); // this banner persists — no auto-hide
8095
8206
  const codex = bar.querySelector('#bxUseCodex');
@@ -8105,30 +8216,39 @@ function renderAuthBanner(auth){
8105
8216
  } catch (e) { showErr(e.message || 'Could not switch to Codex.'); }
8106
8217
  finally { codex.disabled = false; }
8107
8218
  };
8108
- const connect = bar.querySelector('#bxConnectClaude');
8109
- if (connect) connect.onclick = async () => {
8110
- connect.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;
8111
8226
  try {
8112
- const r = await fetch(withKey('/api/worker-auth/connect'), { method: 'POST' });
8113
- if (!r.ok) throw new Error();
8114
- showToast(copy === AUTH_COPY.ja ? '接続画面を開きました。完了後に「接続を確認」を押してください。' : 'Connection opened. When finished, choose “Check connection”.');
8115
- } catch { showErr(copy === AUTH_COPY.ja ? '接続画面を開けませんでした。' : 'Could not open the connection.'); }
8116
- finally { connect.disabled = false; }
8117
- };
8118
- const retry = bar.querySelector('#bxRetryClaude');
8119
- if (retry) retry.onclick = async () => {
8120
- retry.disabled = true;
8121
- try {
8122
- 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' });
8123
8229
  const body = await r.json().catch(() => ({}));
8124
8230
  if (!r.ok) throw new Error();
8125
- renderAuthBanner(body.auth);
8126
- if (body.auth?.ok) showToast(copy === AUTH_COPY.ja ? '接続できました。' : 'Connected.');
8127
- } catch { showErr(copy === AUTH_COPY.ja ? 'まだ接続できていません。' : 'Not connected yet.'); }
8128
- 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
+ }
8129
8248
  };
8130
8249
  } else if (authDown) {
8131
8250
  authDown = false;
8251
+ authConnectionOpened = false;
8132
8252
  bar.classList.remove('billing');
8133
8253
  bar.style.display = 'none';
8134
8254
  bar.innerHTML = '';
@@ -8855,6 +8975,10 @@ const REPLY_PLAIN = new Set(['running', 'partial', 'failed', 'interrupted', 'blo
8855
8975
  function replyBinding(){
8856
8976
  const id = state.selectedGoal;
8857
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;
8858
8982
  const g = state.goals.find((x) => x.id === id);
8859
8983
  if (!g || g.projectId !== state.active) return null; // stale after a project switch → new-goal mode
8860
8984
  // needsInput has NO worker session yet (planGoal() sets it before any task/worker
@@ -8929,10 +9053,9 @@ async function send(){
8929
9053
  const body = await replyToGoal(bind.goal.id, fullText, '#input');
8930
9054
  if (body) {
8931
9055
  upsert(state.goals, body.goal); if (body.task) upsert(state.tasks, body.task);
8932
- // A Review reply is a one-shot handoff. Keeping the composer bound
8933
- // afterwards adds a redundant “Replying to #N” banner and makes the
8934
- // next, unrelated instruction easy to send to the old goal by mistake.
8935
- 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);
8936
9059
  render();
8937
9060
  }
8938
9061
  } else {
@@ -9218,6 +9341,14 @@ function saRowFromDigest(r, gs, ts){
9218
9341
  title: saReviewTitle(g),
9219
9342
  check: String(g?.reviewSummary?.check || r.checkLine || '').replace(/\s+/g, ' ').trim(),
9220
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 })),
9221
9352
  did, siblings, risk: g?.risk ?? null,
9222
9353
  verificationInfraError: g?.verificationInfraError ?? r.verificationInfraError ?? null,
9223
9354
  prNote: g?.prNote ?? null,
@@ -9723,6 +9854,44 @@ function saItemHtml(it, i){
9723
9854
  const reqSec = !it.goalText ? '' : reqFold
9724
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>`
9725
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>` : '';
9726
9895
  // ── Open it (Masa 2026-07-22): a plain section in the card's own typography — the same
9727
9896
  // heading + body shape as Request and Result, not a chip in another vocabulary. The
9728
9897
  // address the worker declared IS the control (press it → the process starts on demand →
@@ -9840,7 +10009,11 @@ function saItemHtml(it, i){
9840
10009
  const diffHtml = it.ghRows
9841
10010
  ? saGhDiffRowsHtml(it.ghRows, it.diffTotal || 0, it.pr || '', gk)
9842
10011
  : hasGitDiff ? saGhDiffHtml(d.diff, it.pr, gk) : clientDiff ? saGhDiffHtml(clientDiff, it.pr, gk) : '';
9843
- 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);
9844
10017
  // ── sensitive-area note (real: classifyChangeRisk forced the pause) ──
9845
10018
  const risk = it.risk;
9846
10019
  const riskHtml = risk?.shouldPause
@@ -9925,8 +10098,18 @@ function saItemHtml(it, i){
9925
10098
  // interrupted goal is amber+resumable, everything else stopped is a red error.
9926
10099
  const attnInt = gstatus === 'interrupted' || Boolean(it.interruptedTask);
9927
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';
9928
10111
  const rst = working
9929
- ? '<span class="rst working"><span class="rdot"></span>Working on it</span>'
10112
+ ? `<span class="rst working"><span class="rdot"></span>${liveWorkLabel}</span>`
9930
10113
  : attn
9931
10114
  ? `<span class="rst ${attnInt ? 'int' : 'err'}"><span class="rdot"></span>${attnLabel}</span>`
9932
10115
  : '<span class="rst"><span class="rdot"></span>In review</span>';
@@ -9935,6 +10118,11 @@ function saItemHtml(it, i){
9935
10118
  <h2 class="rtitle">${esc(it.title)}</h2>
9936
10119
  ${meta}
9937
10120
  ${reqSec}
10121
+ ${attemptSec}
10122
+ ${coverageSec}
10123
+ ${evidenceSec}
10124
+ ${reqVerifySec}
10125
+ ${userCoverageSec}
9938
10126
  ${S('Open it', openBody)}
9939
10127
  ${S('Run state', interruptedBody)}
9940
10128
  ${S('PR', prRepairBody)}
@@ -10095,7 +10283,22 @@ function saRender(){
10095
10283
  function saSync(){
10096
10284
  if (!SA.open) return;
10097
10285
  const ae = document.activeElement;
10098
- 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
+ }
10099
10302
  const rows = saLedgerRows();
10100
10303
  for (const row of rows) {
10101
10304
  const ex = SA.items.find((x) => x.goalId === row.goalId);
@@ -10193,42 +10396,24 @@ async function saReplyCall(it, text, outcome){
10193
10396
  if (!r?.ok) throw new Error('reply failed');
10194
10397
  return r.json().catch(() => null);
10195
10398
  }
10196
- // Once feedback becomes rework, remove it from Review immediately and repaint the board
10197
- // from the response. A queued reply appears in To Do; the next worker event promotes it to
10198
- // Doing. Questions/unsure replies keep status=review and never come through this function.
10199
- 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){
10200
10403
  const goal = body?.goal;
10201
10404
  const task = body?.task;
10202
10405
  if (goal?.id) upsert(state.goals, goal);
10203
10406
  if (task?.id) upsert(state.tasks, task);
10204
- const at = SA.items.findIndex((row) => row.goalId === it.goalId);
10205
- const next = at >= 0 ? (SA.items[at + 1] || SA.items[at - 1]) : null;
10206
- // Was the reviewer actually looking at the card that just left Review (vs having already
10207
- // scrolled past it to read another one)? If it's fully above the visible scroll area, the
10208
- // reviewer is reading something else — snapping the view to the next card's top would yank
10209
- // it out from under them (Masa 2026-07-26: replying to the card above and having the one
10210
- // you're reading jump to the top was reported as stressful).
10211
- const bodyEl = document.querySelector('#seeall .sa-body');
10212
- const cardEl = saCardEl(it.goalId);
10213
- const priorScrollTop = bodyEl?.scrollTop ?? 0;
10214
- const removedHeight = cardEl ? cardEl.getBoundingClientRect().height : 0;
10215
- const cardAboveViewport = !!(cardEl && bodyEl && cardEl.getBoundingClientRect().bottom <= bodyEl.getBoundingClientRect().top + 1);
10216
- 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);
10217
10415
  render();
10218
- if (SA.solo === it.goalId || !SA.items.length) { saRender(); saClose(); return; }
10219
- SA.cur = next?.goalId ?? SA.items[0]?.goalId ?? null;
10220
- if (cardAboveViewport) {
10221
- // Keep reading where we were: compensate for the removed card's height instead of
10222
- // forcing a scroll-to-top of whatever is now "current".
10223
- SA.focusGid = null;
10224
- saRender();
10225
- const newBody = document.querySelector('#seeall .sa-body');
10226
- if (newBody) newBody.scrollTop = Math.max(0, priorScrollTop - removedHeight);
10227
- } else {
10228
- SA.focusGid = SA.cur;
10229
- SA.focusScrolled = false;
10230
- saRender();
10231
- }
10416
+ saPaintCard(it.goalId);
10232
10417
  }
10233
10418
  function saCardEl(goalId){ return document.querySelector(`#seeall .sa-item[data-gid="${goalId}"]`); }
10234
10419
  // Flip one card to its "Working on it" head without redrawing anything else (決定2).
@@ -10237,7 +10422,13 @@ function saFlipWorking(goalId){
10237
10422
  if (!card) return;
10238
10423
  card.classList.add('working');
10239
10424
  const rst = card.querySelector('.rhead .rst');
10240
- 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}`; }
10241
10432
  }
10242
10433
  // Repaint one card's reply thread IN PLACE from its client state — no saRender, so nothing
10243
10434
  // above the box moves. Header is flipped to "Working on it" iff the card is in that state,
@@ -10331,7 +10522,7 @@ async function saSendReply(it, i, text){
10331
10522
  // A change request has left Review. The response is the source of truth for whether it
10332
10523
  // is queued (To Do) or already starting (Doing); reveal that transition immediately.
10333
10524
  if (body?.goal && body.goal.status !== 'review') {
10334
- saMoveFeedbackOutOfReview(it, body);
10525
+ saKeepFeedbackLive(it, body);
10335
10526
  return;
10336
10527
  }
10337
10528
  // split keeps the original goal in Review; only the newly-created ticket moves away.
@@ -11054,7 +11245,7 @@ $('filein').addEventListener('change', () => {
11054
11245
  $('filein').value = '';
11055
11246
  });
11056
11247
  // Feature A: ✕ on the "Replying to #NNN" chip detaches → back to new-goal mode.
11057
- $('replyBindX').onclick = () => { state.selectedGoal = null; render(); };
11248
+ $('replyBindX').onclick = () => { state.composerDetachedGoal = state.selectedGoal; render(); };
11058
11249
  const IS_MOBILE = () => window.matchMedia('(max-width: 760px)').matches;
11059
11250
  // Phones are chat-first: the Tasks sheet starts FOLDED so the first view is the
11060
11251
  // conversation + composer (the sheet is an 88vw overlay — default-open buried
@@ -11696,7 +11887,7 @@ function gearHistHTML(){
11696
11887
  };
11697
11888
  // History = everything you've sent, newest first (not only settled goals —
11698
11889
  // that felt empty). Each row gets a status glyph; unknown states fall back.
11699
- 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', '?'] };
11700
11891
  const evs = (state.goals || [])
11701
11892
  .filter((g) => g.projectId === state.active)
11702
11893
  .map((g) => ({ g, ts: goalTs(g) })).sort((a, b) => b.ts - a.ts);
@@ -11747,15 +11938,23 @@ function gearSetHTML(){
11747
11938
  ? acct + `<div class="srow"><div class="sl">Plan</div><span class="bx-pdot"><i></i>Paid</span></div>
11748
11939
  <div class="srow"><div class="sl">Billing<span class="sd">manage or cancel in Stripe</span></div><button class="bx-cchip" data-gportal="1">Manage</button></div>`
11749
11940
  : acct + freeUsage;
11941
+ const st = state.stability;
11942
+ const stability = st ? `<div class="srow"><div class="sl">Verification health<span class="sd">Last 24h · implementation ${st.implementationFailures} · environment ${st.verificationInfrastructure} · inconclusive ${st.timeoutsOrInconclusive} · worker holds ${st.workerHolds}</span></div><button class="optchip" data-gstability="1">Refresh</button></div>` : `<div class="srow"><div class="sl">Verification health<span class="sd">Loading…</span></div></div>`;
11750
11943
  return `
11751
11944
  <div class="srow"><div class="sl">Language<span class="sd">Review checklist language for this project</span></div>
11752
11945
  <button class="optchip${lang === 'auto' ? ' on' : ''}" data-gslang="auto">Auto</button>
11753
11946
  <button class="optchip${lang === 'en' ? ' on' : ''}" data-gslang="en">EN</button>
11754
11947
  <button class="optchip${lang === 'ja' ? ' on' : ''}" data-gslang="ja">JA</button></div>
11755
11948
  ${billingRow}
11949
+ ${stability}
11756
11950
  ${gearRulesHTML()}
11757
11951
  <div class="srow dis"><div class="sl">Data folder<span class="sd">Runs are stored in the engine's chat-runs folder on the host</span></div></div>`;
11758
11952
  }
11953
+ async function loadStability(){
11954
+ try { const r = await fetch(withKey('/api/stability')); state.stability = r.ok ? await r.json() : null; }
11955
+ catch { state.stability = null; }
11956
+ if ($('gearPop') && custTab === 'settings') buildGearPop();
11957
+ }
11759
11958
  // ONE-CONVERSATION §1.5a: the project's working rules — the file(s) the connected
11760
11959
  // AI actually reads (Claude Code → CLAUDE.md, Codex → AGENTS.md). Read-only: Galda
11761
11960
  // does NOT own these rules, it only surfaces them (editing is done in an editor or
@@ -11844,7 +12043,10 @@ function buildGearPop(){
11844
12043
  };
11845
12044
  const rulesBtn = pop.querySelector('[data-grules]');
11846
12045
  if (rulesBtn) rulesBtn.onclick = () => { state.rulesOpen = !state.rulesOpen; buildGearPop(); };
12046
+ const stabilityBtn = pop.querySelector('[data-gstability]');
12047
+ if (stabilityBtn) stabilityBtn.onclick = () => { state.stability = null; buildGearPop(); loadStability(); };
11847
12048
  loadProjectRules();
12049
+ if (!state.stability) loadStability();
11848
12050
  return;
11849
12051
  }
11850
12052
  if (custTab === 'mcp') {