@galda/cli 0.10.103 → 0.10.105

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
  はそのまま詳細一覧として残す。 */
@@ -2232,6 +2235,19 @@
2232
2235
  /* Changed/Tests aligns with the request TEXT, not the number/chevron — same 18px
2233
2236
  the fold body below uses, so both sit under the same column (Masa 2026-07-29). */
2234
2237
  .redesign .rqit .rqev{font-size:12px;color:var(--ink3);margin:5px 0 0;padding-left:18px}
2238
+ /* Canonical requirement coverage: one compact ledger, not three competing
2239
+ interpretations. Status is text + a small rule/mark (no badge surface),
2240
+ and Manager checks stay visually distinct from worker claims. */
2241
+ .redesign .rqcov{display:flex;flex-direction:column;margin-top:2px}
2242
+ .redesign .rqcov-row{padding:11px 0;border-top:1px solid var(--hair)}
2243
+ .redesign .rqcov-row:first-child{padding-top:0;border-top:0}
2244
+ .redesign .rqcov-main{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:14px;align-items:baseline}
2245
+ .redesign .rqcov-text{font-size:13.5px;line-height:1.55;color:var(--ink)}
2246
+ .redesign .rqcov-state{font:600 10px/1.4 var(--mono);letter-spacing:.02em;color:var(--ink3);white-space:nowrap}
2247
+ .redesign .rqcov-state.verified{color:var(--green)}
2248
+ .redesign .rqcov-state.failed{color:var(--danger)}
2249
+ .redesign .rqcov-detail{margin-top:5px;font-size:11.5px;line-height:1.55;color:var(--ink3)}
2250
+ .redesign .rqcov-detail b{font-weight:600;color:var(--ink2)}
2235
2251
  /* A long ask folds with the SAME .lacc pulldown Activity/Conversation use — chevron on
2236
2252
  the peek line, body is the genuine rest (no duplicate, no trailing "…": Masa
2237
2253
  2026-07-29 flagged both the vertical anchor line and the "…" as unnecessary — the
@@ -3278,6 +3294,7 @@
3278
3294
  <div class="revactions">
3279
3295
  <button type="button" class="reqbtn approve" id="reviewApprove">Approve goal</button>
3280
3296
  <button type="button" class="reqbtn dismiss" id="reviewDismiss">Dismiss</button>
3297
+ <button type="button" class="reqbtn dismiss" id="reviewArchive" style="display:none">Archive</button>
3281
3298
  <span style="flex:1"></span>
3282
3299
  <button class="mbtn ghost" id="reviewClose">Close</button>
3283
3300
  </div>
@@ -3686,6 +3703,18 @@ function retryCopy(projectId){
3686
3703
 
3687
3704
  function esc(s){ const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
3688
3705
 
3706
+ // A PR closed on GitHub without merging is not a success — render it as
3707
+ // "Closed" (not the open/green treatment) instead of the usual "PR #N ↗".
3708
+ // `cls` is the base chip class (prbig/pr/ppr/sa-delivery); `.closed` is added
3709
+ // alongside it so each surface can style the muted state itself.
3710
+ function prChipHtml(pr, prClosed, cls){
3711
+ if (!pr) return '';
3712
+ const prNo = esc((pr.match(/pull\/(\d+)/) ?? [, ''])[1]);
3713
+ return prClosed
3714
+ ? `<a class="${cls} closed" href="${esc(pr)}" target="_blank">PR #${prNo} — Closed</a>`
3715
+ : `<a class="${cls}" href="${esc(pr)}" target="_blank">PR #${prNo} ↗</a>`;
3716
+ }
3717
+
3689
3718
  // Mirrors engine/lib.mjs dndDropsAfter()/reorderByDrop() — kept in sync
3690
3719
  // manually since the browser script isn't a module and can't import
3691
3720
  // server-side code.
@@ -3803,6 +3832,7 @@ function buildReviewDigest({ goals, tasks } = {}){
3803
3832
  reviewNumber: goalReviewNumber({ goal: g, tasks }),
3804
3833
  checkLine: plan ? `PLAN — approve to execute: ${checkLine}`.slice(0, 90) : checkLine,
3805
3834
  pr: g.pr ?? null,
3835
+ prClosed: g.prClosed ?? false,
3806
3836
  testResult: g.testResult ?? null,
3807
3837
  verificationInfraError: g.verificationInfraError ?? null,
3808
3838
  proof: proofTask?.proof ?? null,
@@ -4808,7 +4838,7 @@ function renderTasks(){
4808
4838
  // the work went is logged inside the opened detail's "Also carries" section
4809
4839
  // (see saItemHtml's carriesBody) instead — the list row stays just the ask.
4810
4840
  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>
4841
+ <div class="cond">${prChipHtml(g.pr, g.prClosed, 'prbig')}</div>
4812
4842
  <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
4843
  <div class="revquick">
4814
4844
  <button type="button" class="qbtn ok" data-qapprove="${g.id}" title="Approve">${ICONS.check}</button>
@@ -5453,14 +5483,18 @@ function renderReviewChecklist(){
5453
5483
  ? `REVIEW ${rn ?? '–'} / ${rlist.length || 1}`
5454
5484
  : String(goal.status ?? '').toUpperCase();
5455
5485
  $('reviewCount').innerHTML = countLabel
5456
- + (goal.pr ? ` · <a class="prbig" href="${esc(goal.pr)}" target="_blank">PR #${esc((goal.pr.match(/pull\/(\d+)/) ?? [,''])[1])} ↗</a>` : '');
5486
+ + (goal.pr ? ` · ${prChipHtml(goal.pr, goal.prClosed, 'prbig')}` : '');
5457
5487
  // task: a goal opened from Attention (failed/interrupted) isn't reviewable —
5458
5488
  // hide Approve/Dismiss goal entirely there (they read as nonsensical on a
5459
5489
  // retry item), leaving just the report + Close. Per-requirement Approve/Dismiss
5460
5490
  // are gated the same way in the card render below (canReview).
5461
5491
  const canReview = goal.status === 'review';
5462
5492
  $('reviewApprove').style.display = 'none'; // Feedback: drop the duplicate goal-level Approve — per-requirement Approve is enough
5463
- $('reviewDismiss').style.display = canReview ? '' : 'none';
5493
+ // The PR was closed on GitHub without merging — this isn't a rework case (Dismiss
5494
+ // sends it back for another attempt), it's already closed. Offer Archive instead
5495
+ // so the person can park it themselves rather than being sent through rework.
5496
+ $('reviewDismiss').style.display = canReview && !goal.prClosed ? '' : 'none';
5497
+ $('reviewArchive').style.display = canReview && goal.prClosed ? '' : 'none';
5464
5498
  $('reviewApprove').disabled = !canReview;
5465
5499
  // A folded goal opens here read-only (Masa 2026-07-23, [Open] from "Also carries"): nothing
5466
5500
  // to approve, so don't show the approve-each-requirement intro — say honestly where its
@@ -5494,7 +5528,7 @@ function renderReviewChecklist(){
5494
5528
  const lang = reviewLangFor(state.active);
5495
5529
  // Action-framed so the first view IS "what you need to do" — read only this.
5496
5530
  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>` : '';
5531
+ const prLink = prChipHtml(goal.pr, goal.prClosed, 'prbig');
5498
5532
  // goal 427 §4: change-risk — classifyChangeRisk (engine/lib.mjs) already
5499
5533
  // decided this deterministically (no LLM) at goal-finish time; here we only
5500
5534
  // render it. High = "never auto-approve" (the goal was already forced to
@@ -5698,9 +5732,13 @@ function celebrateApprove(){
5698
5732
  }
5699
5733
 
5700
5734
  // 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.
5735
+ // person is looking at, then drop just that completed review from the ledger
5736
+ // the dialog itself stays open (task-939: closing it used to kick a person who
5737
+ // was mid-scroll through several review cards back out every time one PR
5738
+ // merged). saSync() rebuilds SA.items from live goal state (a 'done' goal drops
5739
+ // out of saLedgerRows on its own) and saRender()'s scroll-anchor logic keeps
5740
+ // whatever card the person was reading in view instead of jumping to the top.
5741
+ // Only close automatically once nothing is left to review.
5704
5742
  function finishOpenReview(goal){
5705
5743
  const ids = SA.items.flatMap((it) => it.ids || [it.goalId]);
5706
5744
  if (!SA.open || !ids.includes(goal.id)) { celebrateApprove(); return; }
@@ -5722,7 +5760,12 @@ function finishOpenReview(goal){
5722
5760
  celebrateApprove();
5723
5761
  }
5724
5762
  const delay = reduce ? 350 : 950;
5725
- setTimeout(() => { layer.remove(); saClose(); render(); }, delay);
5763
+ setTimeout(() => {
5764
+ layer.remove();
5765
+ saSync(); // drops the now-done goal from SA.items and re-anchors the scroll in place
5766
+ if (SA.open && saPending().length === 0 && !SA.items.some((it) => it.st === 3)) saClose();
5767
+ render();
5768
+ }, delay);
5726
5769
  }
5727
5770
  // PR-less review approval: use the same high-signal green completion mark as
5728
5771
  // the merged-PR path, but do not close or reflow the review surface. The canvas
@@ -6186,6 +6229,22 @@ async function sendReviewReject(){
6186
6229
  } catch { showErr('Dismiss failed.'); }
6187
6230
  }
6188
6231
  $('reviewDismiss').onclick = () => sendReviewReject();
6232
+ // The PR closed on GitHub without merging — Archive parks the goal (same /archive
6233
+ // endpoint as the Ledger's Archive) instead of Dismiss's rework flow, since there
6234
+ // is nothing left to rework once the PR itself is closed.
6235
+ async function sendReviewArchive(){
6236
+ const goal = state.goals.find((g) => g.id === state.reviewOpenGoal);
6237
+ if (!goal) return;
6238
+ try {
6239
+ const r = await fetch(withKey(`/api/goals/${goal.id}/archive`), { method: 'POST' });
6240
+ if (!r.ok) throw new Error();
6241
+ const body = await r.json();
6242
+ upsert(state.goals, body.goal ?? body);
6243
+ closeReviewChecklist();
6244
+ render();
6245
+ } catch { showErr('Archive failed.'); }
6246
+ }
6247
+ $('reviewArchive').onclick = () => sendReviewArchive();
6189
6248
  $('reviewFixSend').onclick = () => { const t = $('reviewFixInput').value.trim(); if (t || reviewAttach.length) sendReviewDismiss(t); };
6190
6249
  $('reviewFixInput').addEventListener('keydown', (e) => {
6191
6250
  if (e.key === 'Enter' && !e.isComposing) { const t = $('reviewFixInput').value.trim(); if (t || reviewAttach.length) sendReviewDismiss(t); }
@@ -9252,7 +9311,8 @@ function peraData(){
9252
9311
  });
9253
9312
  }
9254
9313
  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>` : '';
9314
+ if (!r.pr) return '';
9315
+ return prChipHtml(r.pr, r.prClosed, 'ppr').replace('<a ', '<a onclick="event.stopPropagation()" ');
9256
9316
  }
9257
9317
  function peraThumb(r, cls = ''){
9258
9318
  return r.proofImg && r.proofTaskId != null
@@ -9426,6 +9486,33 @@ function latestGoalRunForOutcome(goalId, tasks, outcome){
9426
9486
  note: prev.run?.note ? `${note} ${prev.run.note}` : note,
9427
9487
  };
9428
9488
  }
9489
+ function canonicalRequirementCoverage(goal, tasks){
9490
+ const requirements = Array.isArray(goal?.requirements) ? goal.requirements.filter((r) => r?.id && r?.text) : [];
9491
+ if (!requirements.length) {
9492
+ return (tasks || []).filter((t) => t.goalId === goal?.id && (t.detail || t.title) && (!t.reply || !t.replyFrom || t.replyFrom === 'you'))
9493
+ .sort((a, b) => (a.num ?? 0) - (b.num ?? 0))
9494
+ .map((t) => ({ id: `legacy-task-${t.id}`, text: String((t.reply ? t.detail : t.title) ?? t.title ?? '').replace(/\s+/g, ' ').trim(), coverage: 'unverified', legacy: true }));
9495
+ }
9496
+ const attemptOrder = new Map((goal.attempts || []).map((attempt, index) => [attempt.id, index]));
9497
+ const evidence = Array.isArray(goal.requirementEvidence) ? goal.requirementEvidence : [];
9498
+ return requirements.map((requirement) => {
9499
+ const rows = evidence.filter((row) => row?.requirementId === requirement.id)
9500
+ .sort((a, b) => (attemptOrder.get(a.attemptId) ?? -1) - (attemptOrder.get(b.attemptId) ?? -1));
9501
+ const latestAttemptId = rows.at(-1)?.attemptId;
9502
+ const latestRows = latestAttemptId ? rows.filter((row) => row.attemptId === latestAttemptId) : [];
9503
+ const statuses = latestRows.map((row) => row.coverage);
9504
+ const coverage = statuses.includes('failed') ? 'failed'
9505
+ : statuses.includes('verified') ? 'verified'
9506
+ : statuses.length && statuses.every((status) => status === 'not-applicable') ? 'not-applicable'
9507
+ : 'unverified';
9508
+ const claims = [...new Set(latestRows.map((row) => row.claim).filter(Boolean))];
9509
+ const workerEvidence = [...new Set(latestRows.map((row) => row.workerEvidence || row.evidence).filter(Boolean))];
9510
+ const checks = latestRows.flatMap((row) => Array.isArray(row.checks) ? row.checks : [])
9511
+ .filter((check) => Array.isArray(check.requirementIds) && check.requirementIds.includes(requirement.id));
9512
+ const checkFacts = [...new Set(checks.map((check) => `${check.kind}: ${check.status}`))];
9513
+ return { id: requirement.id, text: requirement.text, coverage, claims, workerEvidence, checkFacts, attemptId: latestAttemptId ?? null };
9514
+ });
9515
+ }
9429
9516
  // One digest-row → one See-all card item. Extracted from saRows (2026-07-24) so a single
9430
9517
  // "Needs you" goal can be opened as the SAME review card (saLedgerRows solo mode) — the card's
9431
9518
  // whole shape (Request/Result/Proof/Activity, empty sections auto-hidden) is reused verbatim.
@@ -9475,15 +9562,13 @@ function saRowFromDigest(r, gs, ts){
9475
9562
  // simply omit it, so the UI remains backward compatible during rollout.
9476
9563
  attempt: g?.reviewAttempt ?? null,
9477
9564
  unmappedChangedFiles: outcome?.unmappedChangedFiles ?? [],
9478
- requirementVerification: outcome?.requirementVerification ?? null,
9479
- 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) ?? t.title ?? '').replace(/\s+/g, ' ').trim(), taskStatus: t.status, evidenceStatus: t.requirementEvidence?.requirements?.[0]?.status ?? null })),
9480
- requirementEvidence: outcome?.requirementEvidence?.requirements ?? [],
9481
- 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) ?? t.title ?? '').replace(/\s+/g, ' ').trim(), status: t.status })),
9565
+ requirementCoverage: canonicalRequirementCoverage(g, ts),
9482
9566
  did, siblings, risk: g?.risk ?? null,
9483
9567
  verificationInfraError: g?.verificationInfraError ?? r.verificationInfraError ?? null,
9484
9568
  prNote: g?.prNote ?? null,
9485
9569
  note: g?.retest?.passed ? g.retest.note : null,
9486
9570
  pr: r.pr,
9571
+ prClosed: g?.prClosed ?? r.prClosed ?? false,
9487
9572
  // P0b (Masa 2026-07-21): the AI's own TodoWrite breakdown for the "Steps" section
9488
9573
  // below — the primary goal's own non-reply task, frozen at completion. Distinct
9489
9574
  // from `did` above (task.result prose) and from Activity (raw tool-call log).
@@ -9590,7 +9675,7 @@ function saSoloDigestRow(g, ts){
9590
9675
  const checkLine = (g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').trim().slice(0, 66);
9591
9676
  return {
9592
9677
  goalIds: [g.id], reviewNumber: goalReviewNumber({ goal: g, tasks: ts }), checkLine,
9593
- pr: g.pr ?? null, testResult: g.testResult ?? null,
9678
+ pr: g.pr ?? null, prClosed: g.prClosed ?? false, testResult: g.testResult ?? null,
9594
9679
  verificationInfraError: g.verificationInfraError ?? null,
9595
9680
  proof: proofTask?.proof ?? null, proofTaskId: proofTask?.id ?? null, plan: false, planText: null,
9596
9681
  };
@@ -10033,31 +10118,18 @@ function saItemHtml(it, i){
10033
10118
  const reqSec = reqStackRows
10034
10119
  ? `<section class="rsec rreq" data-reqmsgs="${gk}"><div class="rsec-h">Request</div><div class="rqlist">${reqStackRows}</div></section>`
10035
10120
  : '';
10036
- const reqVerify = it.requirementVerification;
10037
- const reqVerifyParts = [
10038
- reqVerify?.proof?.verified ? 'Independent proof recorded' : reqVerify?.proof ? 'Proof recorded' : '',
10039
- reqVerify?.run?.url ? `Open it: ${reqVerify.run.url}` : '',
10040
- Array.isArray(reqVerify?.changedFiles) && reqVerify.changedFiles.length ? `Files: ${reqVerify.changedFiles.join(' · ')}` : '',
10041
- ].filter(Boolean);
10042
- const reqVerifySec = reqVerifyParts.length ? `<section class="rsec"><div class="rsec-h">Requirement verification</div><p class="rbody v">${esc(reqVerifyParts.join(' · '))}</p></section>` : '';
10043
10121
  const coverage = (it.requirementCoverage || []).filter((r) => r.text);
10044
- // For one requirement copied from Request, its status is useful but the full
10045
- // sentence is not. Multiple or narrowed requirements retain their text.
10046
- const coverageIsSingleRequest = coverage.length === 1
10047
- && String(coverage[0].text).replace(/\s+/g, ' ').trim() === requestText;
10048
10122
  const coverageRows = coverage.map((r) => {
10049
- const status = r.evidenceStatus || r.taskStatus;
10050
- const tone = status === 'addressed' || status === 'done' ? 'ok' : status === 'partial' ? '' : 'dim';
10051
- return `<p class="rbody v"><b class="${tone}">${esc(status)}</b>${coverageIsSingleRequest ? '' : ` · ${esc(r.text)}`}</p>`;
10052
- }).join('');
10053
- const coverageSec = coverageRows ? `<section class="rsec"><div class="rsec-h">Requirement coverage</div>${coverageRows}</section>` : '';
10054
- // Worker-declared evidence is displayed verbatim as an auditable label, not
10055
- // promoted into a Manager verdict. Empty means the worker did not provide it.
10056
- const evidenceRows = (it.requirementEvidence || []).map((entry) => {
10057
- const tone = entry.status === 'addressed' ? 'ok' : entry.status === 'partial' ? '' : 'dim';
10058
- return `<p class="rbody v"><b class="${tone}">${esc(entry.status)}</b> · ${esc(entry.evidence || entry.id)}</p>`;
10123
+ const labels = { verified: 'Verified', failed: 'Failed', 'not-applicable': 'Not applicable', unverified: 'Not verified' };
10124
+ const details = [
10125
+ r.legacy ? 'Legacy item no canonical evidence was recorded' : '',
10126
+ r.checkFacts?.length ? `Manager: ${r.checkFacts.join(' · ')}` : '',
10127
+ r.claims?.length ? `Worker claim: ${r.claims.join(', ')}` : '',
10128
+ r.workerEvidence?.length ? r.workerEvidence.join(' · ') : '',
10129
+ ].filter(Boolean);
10130
+ return `<div class="rqcov-row" data-requirement-id="${esc(r.id)}"><div class="rqcov-main"><div class="rqcov-text">${esc(r.text)}</div><span class="rqcov-state ${esc(r.coverage)}">${esc(labels[r.coverage] || labels.unverified)}</span></div>${details.length ? `<div class="rqcov-detail">${details.map((detail, index) => index === 0 ? esc(detail) : `<br>${esc(detail)}`).join('')}</div>` : ''}</div>`;
10059
10131
  }).join('');
10060
- const evidenceSec = evidenceRows ? `<section class="rsec"><div class="rsec-h">Requirement evidence</div>${evidenceRows}</section>` : '';
10132
+ const coverageSec = coverageRows ? `<section class="rsec"><div class="rsec-h">Requirement coverage</div><div class="rqcov">${coverageRows}</div></section>` : '';
10061
10133
  // ── Open it (Masa 2026-07-22): a plain section in the card's own typography — the same
10062
10134
  // heading + body shape as Request and Result, not a chip in another vocabulary. The
10063
10135
  // address the worker declared IS the control (press it → the process starts on demand →
@@ -10296,9 +10368,8 @@ function saItemHtml(it, i){
10296
10368
  : attn
10297
10369
  ? `<span class="rst ${attnInt ? 'int' : 'err'}"><span class="rdot"></span>${attnLabel}</span>`
10298
10370
  : '<span class="rst"><span class="rdot"></span>In review</span>';
10299
- const prNumber = (it.pr?.match(/\/pull\/(\d+)/) ?? [, ''])[1];
10300
10371
  const delivery = it.pr
10301
- ? `<a class="sa-delivery" href="${esc(it.pr)}" target="_blank" rel="noopener">PR #${esc(prNumber)} ↗</a>`
10372
+ ? prChipHtml(it.pr, it.prClosed, 'sa-delivery').replace('<a ', '<a rel="noopener" ')
10302
10373
  : '<span class="sa-delivery">No pull request</span>';
10303
10374
  return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}${working ? ' working' : ''}" data-gid="${it.goalId}">
10304
10375
  <div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span>${rst}</div>
@@ -10308,8 +10379,6 @@ function saItemHtml(it, i){
10308
10379
  ${S('Open it', openBody + infraBody)}
10309
10380
  ${S('What to check', checkBody)}
10310
10381
  ${coverageSec}
10311
- ${evidenceSec}
10312
- ${reqVerifySec}
10313
10382
  ${S('Run state', interruptedBody)}
10314
10383
  ${S('PR', prRepairBody)}
10315
10384
  ${S('Also carries', carriesBody)}
@@ -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.103",
3
+ "version": "0.10.105",
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": {