@galda/cli 0.10.102 → 0.10.104

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
@@ -613,6 +613,9 @@
613
613
  .qreplysend{width:30px;height:30px;flex:0 0 30px;display:grid;place-items:center;border:0;border-radius:50%;background:var(--iri);color:var(--on-accent);cursor:pointer;padding:0}
614
614
  .qreplysend svg{width:15px;height:15px;stroke:currentColor;stroke-width:2;fill:none}
615
615
  .prbig{font-family:var(--mono);font-size:10.5px;color:var(--green);text-decoration:none;border:1px solid rgba(var(--green-rgb),.4);background:rgba(var(--green-rgb),.12);border-radius:7px;padding:3px 10px;font-weight:600}
616
+ /* A PR closed on GitHub without merging is not a success — mute it off the green
617
+ "open" treatment (Masa: closed should read as closed, not celebrated). */
618
+ .prbig.closed,.ppr.closed,a.sa-delivery.closed{color:var(--ink2)!important;border-color:var(--hair2);background:transparent}
616
619
  /* task 228: レビューダイジェスト — Reviewセクション最上部の一枚まとめ。行=確認1行
617
620
  +proofサムネ(小)+テストバッジ+PRリンク+その場でApprove/Reject。下の個別行(revrow)
618
621
  はそのまま詳細一覧として残す。 */
@@ -1923,9 +1926,9 @@
1923
1926
  box-shadow:inset 0 0 0 1px rgba(var(--green-rgb),.30);font:600 12px/1 var(--ui);cursor:pointer;display:inline-flex;align-items:center;gap:7px}
1924
1927
  .sa-app:hover{box-shadow:inset 0 0 0 1px var(--green);background:rgba(var(--green-rgb),.12)}
1925
1928
  .sa-app svg{width:12px;height:12px;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}
1926
- .sa-approve-wrap{display:flex;flex-direction:column;align-items:center;gap:5px;flex:0 0 auto}
1929
+ .sa-approve-wrap{display:flex;flex-direction:column;align-items:flex-start;gap:5px;flex:0 0 auto}
1927
1930
  .sa-approve-wrap + .sa-dis{align-self:flex-start}
1928
- .sa-delivery{font:500 9.5px/1.15 var(--mono);color:var(--ink3);text-decoration:none;white-space:nowrap}
1931
+ .sa-delivery{font:500 9.5px/1.15 var(--mono);color:var(--ink3);text-decoration:none;white-space:nowrap;margin-left:16px}
1929
1932
  a.sa-delivery{color:var(--green)}
1930
1933
  a.sa-delivery:hover{text-decoration:underline}
1931
1934
  .sa-dis{height:32px;padding:0 13px;border:0;border-radius:9px;background:color-mix(in srgb,var(--danger) 8%,var(--panel));
@@ -3278,6 +3281,7 @@
3278
3281
  <div class="revactions">
3279
3282
  <button type="button" class="reqbtn approve" id="reviewApprove">Approve goal</button>
3280
3283
  <button type="button" class="reqbtn dismiss" id="reviewDismiss">Dismiss</button>
3284
+ <button type="button" class="reqbtn dismiss" id="reviewArchive" style="display:none">Archive</button>
3281
3285
  <span style="flex:1"></span>
3282
3286
  <button class="mbtn ghost" id="reviewClose">Close</button>
3283
3287
  </div>
@@ -3686,6 +3690,18 @@ function retryCopy(projectId){
3686
3690
 
3687
3691
  function esc(s){ const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
3688
3692
 
3693
+ // A PR closed on GitHub without merging is not a success — render it as
3694
+ // "Closed" (not the open/green treatment) instead of the usual "PR #N ↗".
3695
+ // `cls` is the base chip class (prbig/pr/ppr/sa-delivery); `.closed` is added
3696
+ // alongside it so each surface can style the muted state itself.
3697
+ function prChipHtml(pr, prClosed, cls){
3698
+ if (!pr) return '';
3699
+ const prNo = esc((pr.match(/pull\/(\d+)/) ?? [, ''])[1]);
3700
+ return prClosed
3701
+ ? `<a class="${cls} closed" href="${esc(pr)}" target="_blank">PR #${prNo} — Closed</a>`
3702
+ : `<a class="${cls}" href="${esc(pr)}" target="_blank">PR #${prNo} ↗</a>`;
3703
+ }
3704
+
3689
3705
  // Mirrors engine/lib.mjs dndDropsAfter()/reorderByDrop() — kept in sync
3690
3706
  // manually since the browser script isn't a module and can't import
3691
3707
  // server-side code.
@@ -3803,6 +3819,7 @@ function buildReviewDigest({ goals, tasks } = {}){
3803
3819
  reviewNumber: goalReviewNumber({ goal: g, tasks }),
3804
3820
  checkLine: plan ? `PLAN — approve to execute: ${checkLine}`.slice(0, 90) : checkLine,
3805
3821
  pr: g.pr ?? null,
3822
+ prClosed: g.prClosed ?? false,
3806
3823
  testResult: g.testResult ?? null,
3807
3824
  verificationInfraError: g.verificationInfraError ?? null,
3808
3825
  proof: proofTask?.proof ?? null,
@@ -4808,7 +4825,7 @@ function renderTasks(){
4808
4825
  // the work went is logged inside the opened detail's "Also carries" section
4809
4826
  // (see saItemHtml's carriesBody) instead — the list row stays just the ask.
4810
4827
  return `<div class="task revrow" data-goal="${g.id}"><div class="ic pending"></div><div class="tx"><div class="name">${esc(checkLine)} ${goalSourceBadge(g.source)}</div>
4811
- <div class="cond">${g.pr ? `<a class="prbig" href="${esc(g.pr)}" target="_blank">PR #${esc((g.pr.match(/pull\/(\d+)/) ?? [,''])[1])} ↗</a>` : ''}</div>
4828
+ <div class="cond">${prChipHtml(g.pr, g.prClosed, 'prbig')}</div>
4812
4829
  <div class="qreplyrow" data-qreplyrow="${g.id}"${state.qreplyOpen.has(g.id) ? '' : ' hidden'}><input type="text" data-qreplyinput="${g.id}" placeholder="直したい点を書いて送信(Todoに戻して再作業)"><button type="button" class="qreplysend" data-qreplysend="${g.id}" aria-label="Send">${ICONS.up}</button></div></div>
4813
4830
  <div class="revquick">
4814
4831
  <button type="button" class="qbtn ok" data-qapprove="${g.id}" title="Approve">${ICONS.check}</button>
@@ -5453,14 +5470,18 @@ function renderReviewChecklist(){
5453
5470
  ? `REVIEW ${rn ?? '–'} / ${rlist.length || 1}`
5454
5471
  : String(goal.status ?? '').toUpperCase();
5455
5472
  $('reviewCount').innerHTML = countLabel
5456
- + (goal.pr ? ` · <a class="prbig" href="${esc(goal.pr)}" target="_blank">PR #${esc((goal.pr.match(/pull\/(\d+)/) ?? [,''])[1])} ↗</a>` : '');
5473
+ + (goal.pr ? ` · ${prChipHtml(goal.pr, goal.prClosed, 'prbig')}` : '');
5457
5474
  // task: a goal opened from Attention (failed/interrupted) isn't reviewable —
5458
5475
  // hide Approve/Dismiss goal entirely there (they read as nonsensical on a
5459
5476
  // retry item), leaving just the report + Close. Per-requirement Approve/Dismiss
5460
5477
  // are gated the same way in the card render below (canReview).
5461
5478
  const canReview = goal.status === 'review';
5462
5479
  $('reviewApprove').style.display = 'none'; // Feedback: drop the duplicate goal-level Approve — per-requirement Approve is enough
5463
- $('reviewDismiss').style.display = canReview ? '' : 'none';
5480
+ // The PR was closed on GitHub without merging — this isn't a rework case (Dismiss
5481
+ // sends it back for another attempt), it's already closed. Offer Archive instead
5482
+ // so the person can park it themselves rather than being sent through rework.
5483
+ $('reviewDismiss').style.display = canReview && !goal.prClosed ? '' : 'none';
5484
+ $('reviewArchive').style.display = canReview && goal.prClosed ? '' : 'none';
5464
5485
  $('reviewApprove').disabled = !canReview;
5465
5486
  // A folded goal opens here read-only (Masa 2026-07-23, [Open] from "Also carries"): nothing
5466
5487
  // to approve, so don't show the approve-each-requirement intro — say honestly where its
@@ -5494,7 +5515,7 @@ function renderReviewChecklist(){
5494
5515
  const lang = reviewLangFor(state.active);
5495
5516
  // Action-framed so the first view IS "what you need to do" — read only this.
5496
5517
  const L = { check: 'What you need to do', changed: 'What was done' };
5497
- const prLink = goal.pr ? `<a class="prbig" href="${esc(goal.pr)}" target="_blank">PR #${esc((goal.pr.match(/pull\/(\d+)/) ?? [, ''])[1])} ↗</a>` : '';
5518
+ const prLink = prChipHtml(goal.pr, goal.prClosed, 'prbig');
5498
5519
  // goal 427 §4: change-risk — classifyChangeRisk (engine/lib.mjs) already
5499
5520
  // decided this deterministically (no LLM) at goal-finish time; here we only
5500
5521
  // render it. High = "never auto-approve" (the goal was already forced to
@@ -5698,9 +5719,13 @@ function celebrateApprove(){
5698
5719
  }
5699
5720
 
5700
5721
  // A merged PR is the terminal Review moment. Show the result in the dialog the
5701
- // person is looking at, then remove that completed review instead of leaving a
5702
- // stale modal behind. The state update arrives in the same SSE event, so render()
5703
- // after close also removes the card from the Review lane.
5722
+ // person is looking at, then drop just that completed review from the ledger
5723
+ // the dialog itself stays open (task-939: closing it used to kick a person who
5724
+ // was mid-scroll through several review cards back out every time one PR
5725
+ // merged). saSync() rebuilds SA.items from live goal state (a 'done' goal drops
5726
+ // out of saLedgerRows on its own) and saRender()'s scroll-anchor logic keeps
5727
+ // whatever card the person was reading in view instead of jumping to the top.
5728
+ // Only close automatically once nothing is left to review.
5704
5729
  function finishOpenReview(goal){
5705
5730
  const ids = SA.items.flatMap((it) => it.ids || [it.goalId]);
5706
5731
  if (!SA.open || !ids.includes(goal.id)) { celebrateApprove(); return; }
@@ -5722,7 +5747,12 @@ function finishOpenReview(goal){
5722
5747
  celebrateApprove();
5723
5748
  }
5724
5749
  const delay = reduce ? 350 : 950;
5725
- setTimeout(() => { layer.remove(); saClose(); render(); }, delay);
5750
+ setTimeout(() => {
5751
+ layer.remove();
5752
+ saSync(); // drops the now-done goal from SA.items and re-anchors the scroll in place
5753
+ if (SA.open && saPending().length === 0 && !SA.items.some((it) => it.st === 3)) saClose();
5754
+ render();
5755
+ }, delay);
5726
5756
  }
5727
5757
  // PR-less review approval: use the same high-signal green completion mark as
5728
5758
  // the merged-PR path, but do not close or reflow the review surface. The canvas
@@ -6186,6 +6216,22 @@ async function sendReviewReject(){
6186
6216
  } catch { showErr('Dismiss failed.'); }
6187
6217
  }
6188
6218
  $('reviewDismiss').onclick = () => sendReviewReject();
6219
+ // The PR closed on GitHub without merging — Archive parks the goal (same /archive
6220
+ // endpoint as the Ledger's Archive) instead of Dismiss's rework flow, since there
6221
+ // is nothing left to rework once the PR itself is closed.
6222
+ async function sendReviewArchive(){
6223
+ const goal = state.goals.find((g) => g.id === state.reviewOpenGoal);
6224
+ if (!goal) return;
6225
+ try {
6226
+ const r = await fetch(withKey(`/api/goals/${goal.id}/archive`), { method: 'POST' });
6227
+ if (!r.ok) throw new Error();
6228
+ const body = await r.json();
6229
+ upsert(state.goals, body.goal ?? body);
6230
+ closeReviewChecklist();
6231
+ render();
6232
+ } catch { showErr('Archive failed.'); }
6233
+ }
6234
+ $('reviewArchive').onclick = () => sendReviewArchive();
6189
6235
  $('reviewFixSend').onclick = () => { const t = $('reviewFixInput').value.trim(); if (t || reviewAttach.length) sendReviewDismiss(t); };
6190
6236
  $('reviewFixInput').addEventListener('keydown', (e) => {
6191
6237
  if (e.key === 'Enter' && !e.isComposing) { const t = $('reviewFixInput').value.trim(); if (t || reviewAttach.length) sendReviewDismiss(t); }
@@ -9252,7 +9298,8 @@ function peraData(){
9252
9298
  });
9253
9299
  }
9254
9300
  function peraPrLink(r){
9255
- return r.pr ? `<a class="ppr" href="${esc(r.pr)}" target="_blank" onclick="event.stopPropagation()">PR #${esc((r.pr.match(/pull\/(\d+)/) ?? [, ''])[1])} ↗</a>` : '';
9301
+ if (!r.pr) return '';
9302
+ return prChipHtml(r.pr, r.prClosed, 'ppr').replace('<a ', '<a onclick="event.stopPropagation()" ');
9256
9303
  }
9257
9304
  function peraThumb(r, cls = ''){
9258
9305
  return r.proofImg && r.proofTaskId != null
@@ -9484,6 +9531,7 @@ function saRowFromDigest(r, gs, ts){
9484
9531
  prNote: g?.prNote ?? null,
9485
9532
  note: g?.retest?.passed ? g.retest.note : null,
9486
9533
  pr: r.pr,
9534
+ prClosed: g?.prClosed ?? r.prClosed ?? false,
9487
9535
  // P0b (Masa 2026-07-21): the AI's own TodoWrite breakdown for the "Steps" section
9488
9536
  // below — the primary goal's own non-reply task, frozen at completion. Distinct
9489
9537
  // from `did` above (task.result prose) and from Activity (raw tool-call log).
@@ -9590,7 +9638,7 @@ function saSoloDigestRow(g, ts){
9590
9638
  const checkLine = (g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').trim().slice(0, 66);
9591
9639
  return {
9592
9640
  goalIds: [g.id], reviewNumber: goalReviewNumber({ goal: g, tasks: ts }), checkLine,
9593
- pr: g.pr ?? null, testResult: g.testResult ?? null,
9641
+ pr: g.pr ?? null, prClosed: g.prClosed ?? false, testResult: g.testResult ?? null,
9594
9642
  verificationInfraError: g.verificationInfraError ?? null,
9595
9643
  proof: proofTask?.proof ?? null, proofTaskId: proofTask?.id ?? null, plan: false, planText: null,
9596
9644
  };
@@ -10296,9 +10344,8 @@ function saItemHtml(it, i){
10296
10344
  : attn
10297
10345
  ? `<span class="rst ${attnInt ? 'int' : 'err'}"><span class="rdot"></span>${attnLabel}</span>`
10298
10346
  : '<span class="rst"><span class="rdot"></span>In review</span>';
10299
- const prNumber = (it.pr?.match(/\/pull\/(\d+)/) ?? [, ''])[1];
10300
10347
  const delivery = it.pr
10301
- ? `<a class="sa-delivery" href="${esc(it.pr)}" target="_blank" rel="noopener">PR #${esc(prNumber)} ↗</a>`
10348
+ ? prChipHtml(it.pr, it.prClosed, 'sa-delivery').replace('<a ', '<a rel="noopener" ')
10302
10349
  : '<span class="sa-delivery">No pull request</span>';
10303
10350
  return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}${working ? ' working' : ''}" data-gid="${it.goalId}">
10304
10351
  <div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span>${rst}</div>
@@ -3,19 +3,94 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
- <title>Review completion preview</title>
6
+ <title>Review completion preview — dialog stays open</title>
7
7
  <style>
8
8
  *{box-sizing:border-box}body{margin:0;background:#f4f5f7;color:#202124;font:14px Inter,ui-sans-serif,system-ui,sans-serif;min-height:100vh;display:grid;place-items:center}
9
- .stage{width:min(900px,94vw);height:min(720px,88vh);position:relative}.dialog{position:absolute;inset:0;background:#fff;border:1px solid #d9dce2;border-radius:16px;box-shadow:0 24px 70px rgba(22,25,31,.18);overflow:hidden;transition:opacity .2s,transform .2s}.dialog.closed{opacity:0;transform:scale(.985);pointer-events:none}.head{height:58px;border-bottom:1px solid #e7e8eb;display:flex;align-items:center;padding:0 28px;font-weight:700}.content{padding:32px 40px}.label{font:11px ui-monospace,SFMono-Regular,monospace;color:#737780;letter-spacing:.08em;text-transform:uppercase}.title{font-size:24px;margin:9px 0 26px}.line{height:12px;border-radius:3px;background:#eceef1;margin:12px 0}.line.w1{width:82%}.line.w2{width:64%}.line.w3{width:73%}.actions{position:absolute;left:40px;right:40px;bottom:30px;border-top:1px solid #e7e8eb;padding-top:20px;display:flex;gap:10px}.btn{height:38px;border-radius:9px;border:1px solid #d9dce2;background:#fff;padding:0 16px}.btn.ok{background:#202124;color:#fff;border-color:#202124}.replay{position:fixed;right:24px;bottom:24px;height:40px;padding:0 18px;border:0;border-radius:9px;background:#202124;color:#fff;font-weight:650;cursor:pointer}.finish{position:absolute;inset:0;z-index:3;display:grid;place-items:center;overflow:hidden;border-radius:16px;background:rgba(245,247,250,.78);backdrop-filter:blur(3px);animation:fade .95s ease both}.mark{width:104px;height:104px;border-radius:50%;display:grid;place-items:center;background:#18c77a;box-shadow:0 18px 52px rgba(24,199,122,.34);animation:pop .72s cubic-bezier(.2,1.35,.35,1) both}.mark svg{width:56px;height:56px;fill:none;stroke:#fff;stroke-width:3.4;stroke-linecap:round;stroke-linejoin:round}.mark path{stroke-dasharray:70;stroke-dashoffset:70;animation:draw .42s ease .18s forwards}.piece{position:fixed;z-index:4;pointer-events:none;will-change:transform,opacity}@keyframes pop{0%{transform:scale(.45);opacity:0}62%{transform:scale(1.1);opacity:1}100%{transform:scale(1)}}@keyframes draw{to{stroke-dashoffset:0}}@keyframes fade{0%,72%{opacity:1}100%{opacity:0}}@media(prefers-reduced-motion:reduce){.finish,.mark,.mark path{animation:none}.finish{background:transparent}}
9
+ .stage{width:min(900px,94vw);height:min(720px,88vh);position:relative}
10
+ .dialog{position:absolute;inset:0;background:#fff;border:1px solid #d9dce2;border-radius:16px;box-shadow:0 24px 70px rgba(22,25,31,.18);overflow:hidden;transition:opacity .2s,transform .2s;display:flex;flex-direction:column}
11
+ .dialog.closed{opacity:0;transform:scale(.985);pointer-events:none}
12
+ .head{height:58px;border-bottom:1px solid #e7e8eb;display:flex;align-items:center;padding:0 28px;font-weight:700;flex:none}
13
+ .head .c{margin-left:10px;font-weight:400;color:#737780;font-size:12px}
14
+ .body{flex:1;overflow:auto;padding:18px 28px}
15
+ .card{border:1px solid #e7e8eb;border-radius:12px;padding:20px 22px;margin-bottom:16px;position:relative;transition:opacity .25s,transform .25s}
16
+ .card.gone{opacity:0;transform:scale(.97);pointer-events:none;height:0;margin:0;padding:0;border:0;overflow:hidden}
17
+ .label{font:11px ui-monospace,SFMono-Regular,monospace;color:#737780;letter-spacing:.08em;text-transform:uppercase}
18
+ .title{font-size:18px;margin:9px 0 16px}
19
+ .line{height:11px;border-radius:3px;background:#eceef1;margin:10px 0}
20
+ .line.w1{width:82%}.line.w2{width:64%}.line.w3{width:73%}
21
+ .actions{margin-top:16px;display:flex;gap:10px}
22
+ .btn{height:34px;border-radius:9px;border:1px solid #d9dce2;background:#fff;padding:0 14px;cursor:pointer}
23
+ .btn.ok{background:#202124;color:#fff;border-color:#202124}
24
+ .replay{position:fixed;right:24px;bottom:24px;height:40px;padding:0 18px;border:0;border-radius:9px;background:#202124;color:#fff;font-weight:650;cursor:pointer}
25
+ .note{position:fixed;left:24px;bottom:24px;max-width:420px;font-size:12.5px;color:#4a4e57;background:#fff;border:1px solid #e7e8eb;border-radius:10px;padding:12px 14px}
26
+ .finish{position:absolute;inset:0;z-index:3;display:grid;place-items:center;overflow:hidden;border-radius:16px;background:rgba(245,247,250,.78);backdrop-filter:blur(3px);animation:fade .95s ease both}
27
+ .mark{width:88px;height:88px;border-radius:50%;display:grid;place-items:center;background:#18c77a;box-shadow:0 18px 52px rgba(24,199,122,.34);animation:pop .72s cubic-bezier(.2,1.35,.35,1) both}
28
+ .mark svg{width:48px;height:48px;fill:none;stroke:#fff;stroke-width:3.4;stroke-linecap:round;stroke-linejoin:round}
29
+ .mark path{stroke-dasharray:70;stroke-dashoffset:70;animation:draw .42s ease .18s forwards}
30
+ .piece{position:fixed;z-index:4;pointer-events:none;will-change:transform,opacity}
31
+ @keyframes pop{0%{transform:scale(.45);opacity:0}62%{transform:scale(1.1);opacity:1}100%{transform:scale(1)}}
32
+ @keyframes draw{to{stroke-dashoffset:0}}
33
+ @keyframes fade{0%,72%{opacity:1}100%{opacity:0}}
34
+ @media(prefers-reduced-motion:reduce){.finish,.mark,.mark path{animation:none}.finish{background:transparent}}
10
35
  </style>
11
36
  </head>
12
37
  <body>
13
- <main class="stage"><section class="dialog" id="dialog"><div class="head">In review</div><div class="content"><div class="label">Request</div><h1 class="title">Improve the approval completion moment</h1><div class="label">What changed</div><div class="line w1"></div><div class="line w2"></div><div class="line w3"></div><div class="actions"><button class="btn ok">Approve</button><button class="btn">Dismiss</button></div></div></section></main>
14
- <button class="replay" id="replay">Replay</button>
38
+ <main class="stage">
39
+ <section class="dialog" id="dialog">
40
+ <div class="head"><span>Review</span><span class="c" id="hdCount">3 ready</span></div>
41
+ <div class="body" id="body">
42
+ <div class="card" id="card-1" data-gid="1">
43
+ <div class="label">Request #1</div>
44
+ <h1 class="title">Fix the header spacing</h1>
45
+ <div class="line w1"></div><div class="line w2"></div><div class="line w3"></div>
46
+ <div class="actions"><button class="btn ok" data-merge="1">Merge PR</button><button class="btn">Dismiss</button></div>
47
+ </div>
48
+ <div class="card" id="card-2" data-gid="2">
49
+ <div class="label">Request #2</div>
50
+ <h1 class="title">Add empty-state illustration</h1>
51
+ <div class="line w1"></div><div class="line w2"></div><div class="line w3"></div>
52
+ <div class="actions"><button class="btn ok" data-merge="2">Merge PR</button><button class="btn">Dismiss</button></div>
53
+ </div>
54
+ <div class="card" id="card-3" data-gid="3">
55
+ <div class="label">Request #3</div>
56
+ <h1 class="title">Improve the approval completion moment</h1>
57
+ <div class="line w1"></div><div class="line w2"></div><div class="line w3"></div>
58
+ <div class="actions"><button class="btn ok" data-merge="3">Merge PR</button><button class="btn">Dismiss</button></div>
59
+ </div>
60
+ </div>
61
+ </section>
62
+ </main>
63
+ <button class="replay" id="replay">Reset all 3</button>
64
+ <div class="note">task-939: merging one PR (click "Merge PR" on any card) plays the completion
65
+ animation, then only that card is removed — the Review dialog stays open so you can keep
66
+ reading the other cards, instead of the whole dialog closing on you.</div>
15
67
  <script>
16
68
  const colors=['#F24E1E','#FF7262','#A259FF','#1ABCFE','#0ACF83','#FFD166'];
17
69
  function burst(cx,cy){if(matchMedia('(prefers-reduced-motion: reduce)').matches)return;for(let i=0;i<42;i++){const p=document.createElement('i');p.className='piece';const s=6+Math.random()*6;p.style.cssText=`left:${cx}px;top:${cy}px;width:${s}px;height:${s}px;background:${colors[i%colors.length]};border-radius:${Math.random()<.5?'50%':'2px'};transition:transform .9s cubic-bezier(.15,.7,.3,1),opacity .9s ease .25s`;document.body.appendChild(p);const a=Math.random()*Math.PI*2,d=90+Math.random()*190,dx=Math.cos(a)*d,dy=Math.sin(a)*d*.6+90,rot=(Math.random()<.5?-1:1)*(360+Math.random()*360);requestAnimationFrame(()=>{p.style.transform=`translate(${dx}px,${dy}px) rotate(${rot}deg)`;p.style.opacity='0'});setTimeout(()=>p.remove(),1000)}}
18
- function play(){document.querySelectorAll('.finish,.piece').forEach(x=>x.remove());const d=document.getElementById('dialog');d.classList.remove('closed');const f=document.createElement('div');f.className='finish';f.innerHTML='<div class="mark"><svg viewBox="0 0 64 64"><path d="M17 33l10 10 21-23"/></svg></div>';document.querySelector('.stage').appendChild(f);const r=f.querySelector('.mark').getBoundingClientRect();burst(r.left+r.width/2,r.top+r.height/2);setTimeout(()=>{f.remove();d.classList.add('closed')},matchMedia('(prefers-reduced-motion: reduce)').matches?350:950)}
19
- document.getElementById('replay').onclick=play;setTimeout(play,350);
70
+ function updateCount(){const left=document.querySelectorAll('.card:not(.gone)').length;document.getElementById('hdCount').textContent=left?`${left} ready`:'Nothing waiting for review';}
71
+ function mergeCard(gid){
72
+ const card=document.getElementById('card-'+gid);
73
+ if(!card||card.classList.contains('gone'))return;
74
+ const r=card.getBoundingClientRect();
75
+ const f=document.createElement('div');f.className='finish';f.style.cssText=`position:fixed;left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px;border-radius:12px;`;
76
+ f.innerHTML='<div class="mark"><svg viewBox="0 0 64 64"><path d="M17 33l10 10 21-23"/></svg></div>';
77
+ document.body.appendChild(f);
78
+ const mr=f.querySelector('.mark').getBoundingClientRect();
79
+ burst(mr.left+mr.width/2,mr.top+mr.height/2);
80
+ const delay=matchMedia('(prefers-reduced-motion: reduce)').matches?350:950;
81
+ setTimeout(()=>{
82
+ f.remove();
83
+ card.classList.add('gone'); // drop just this card — the dialog itself never closes here
84
+ updateCount();
85
+ },delay);
86
+ }
87
+ document.getElementById('body').addEventListener('click',(e)=>{
88
+ const gid=e.target?.dataset?.merge; if(gid) mergeCard(gid);
89
+ });
90
+ document.getElementById('replay').onclick=()=>{
91
+ document.querySelectorAll('.finish,.piece').forEach(x=>x.remove());
92
+ document.querySelectorAll('.card').forEach(c=>c.classList.remove('gone'));
93
+ updateCount();
94
+ };
20
95
  </script>
21
96
  </body></html>
package/engine/lib.mjs CHANGED
@@ -2118,7 +2118,7 @@ export function buildReviewDigest({ goals, tasks } = {}) {
2118
2118
  pr: g.pr ?? null,
2119
2119
  testResult: g.testResult ?? null,
2120
2120
  verificationInfraError: g.verificationInfraError ?? null,
2121
- proof: goalTasks.find((t) => t.proof)?.proof ?? null,
2121
+ proof: latestProofTask(goalTasks)?.proof ?? null,
2122
2122
  // Worker-declared partial/not-addressed requirements are not a failed
2123
2123
  // verdict. Count them so a human does not miss them in the compact list.
2124
2124
  incompleteRequirementCount: goalTasks.reduce((n, t) => n + (t.requirementEvidence?.requirements ?? []).filter((r) => ['partial', 'not-addressed'].includes(r.status)).length, 0),
@@ -2587,13 +2587,14 @@ export function isPrApproved({ reviewDecision } = {}) {
2587
2587
  return reviewDecision === 'APPROVED';
2588
2588
  }
2589
2589
 
2590
- // A 'review' goal whose PR merged OR closed is done; every other goal status
2591
- // (including an approved but still-open PR) passes through unchanged this
2592
- // never regresses a goal out of 'review' on its own.
2593
- export function reviewToDoneStatus({ goalStatus, merged, closed }) {
2594
- // Merge and close both end the GitHub review lifecycle. Approval alone does
2595
- // not: an approved but still-open PR remains actionable in Review.
2596
- return goalStatus === 'review' && (merged || closed) ? 'done' : goalStatus;
2590
+ // A 'review' goal whose PR merged is done. A PR closed WITHOUT merging is not
2591
+ // a success it stays in 'review' (the caller marks goal.prClosed so the UI
2592
+ // can show "Closed", skip the done celebration, and let the person archive it
2593
+ // themselves) rather than being auto-celebrated as if it had shipped.
2594
+ // Every other goal status (including an approved but still-open PR) passes
2595
+ // through unchanged this never regresses a goal out of 'review' on its own.
2596
+ export function reviewToDoneStatus({ goalStatus, merged }) {
2597
+ return goalStatus === 'review' && merged ? 'done' : goalStatus;
2597
2598
  }
2598
2599
 
2599
2600
  // Build the proof PR body (EN + JA, no emoji) and the PROOF.md committed to
@@ -3893,6 +3894,19 @@ export function buildGoalSummary({ goalText, tasks } = {}) {
3893
3894
  return lines.join('\n');
3894
3895
  }
3895
3896
 
3897
+ // The proof a reviewer is being asked to look at is the NEWEST one. A reply re-runs
3898
+ // the work and takes its own shot, so the goal accumulates several. This used to be
3899
+ // `tasks.find((t) => t.proof)`, which returns the OLDEST — the digest then carried
3900
+ // the first attempt's screenshot underneath the latest attempt's words, since the
3901
+ // outcome is picked by time (latestGoalOutcomeTask). Same fix as app/index.html's
3902
+ // latestProofTask, which this is mirrored by.
3903
+ export function latestProofTask(tasks = []) {
3904
+ return (tasks ?? [])
3905
+ .filter((t) => t?.proof)
3906
+ .sort((a, b) => Date.parse(b.finishedAt || b.startedAt || b.createdAt || 0)
3907
+ - Date.parse(a.finishedAt || a.startedAt || a.createdAt || 0))[0] ?? null;
3908
+ }
3909
+
3896
3910
  // The Review card represents the goal's latest completed worker outcome. A reply is
3897
3911
  // a real worker run and may supersede an earlier skipped/failed requirement.
3898
3912
  export function latestGoalOutcomeTask(tasks = []) {
package/engine/server.mjs CHANGED
@@ -1662,13 +1662,20 @@ async function syncGoalMerges() {
1662
1662
  try { data = JSON.parse(raw); } catch { return; }
1663
1663
  const merged = isPrMerged(data);
1664
1664
  const closed = isPrClosed(data);
1665
- const newStatus = reviewToDoneStatus({ goalStatus: g.status, merged, closed, approved: isPrApproved(data) });
1666
- if (newStatus !== g.status) {
1667
- g.status = newStatus; saveGoal(g);
1665
+ const newStatus = reviewToDoneStatus({ goalStatus: g.status, merged });
1666
+ let changed = false;
1667
+ if (newStatus !== g.status) { g.status = newStatus; changed = true; }
1668
+ // A closed-without-merge PR is not a success — no done celebration. It stays
1669
+ // 'review' with prClosed set so the review dialog / PR links show "Closed"
1670
+ // (not the open/green treatment) and the person can Archive it themselves
1671
+ // instead of an automatic "down" done animation firing on their behalf.
1672
+ if (closed && !merged && !g.prClosed) { g.prClosed = true; changed = true; }
1673
+ if (changed) {
1674
+ saveGoal(g);
1668
1675
  // §3/§7: a merged PR is truly terminal (no rework possible) — reclaim the
1669
1676
  // per-goal worktree so .manager-wt/goal-<id> doesn't leak forever. (Manual
1670
1677
  // Approve stays reworkable via revert, so we don't clean those up here.)
1671
- if (newStatus === 'done' && (merged || closed)) {
1678
+ if (merged || closed) {
1672
1679
  const project = projects.find((p) => p.id === g.projectId);
1673
1680
  if (project) removeGoalWorkDir(g, project);
1674
1681
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.102",
3
+ "version": "0.10.104",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {