@galda/cli 0.10.36 → 0.10.37

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
@@ -192,6 +192,18 @@
192
192
  .qico:disabled{opacity:.25;cursor:default}
193
193
  .qico:disabled:hover{color:var(--ink3);background:transparent}
194
194
  .qico svg{width:14px;height:14px;stroke:currentColor;stroke-width:1.8;fill:none;stroke-linecap:round;stroke-linejoin:round}
195
+ /* Connection status line (offline) — inline color element, NEVER a painted
196
+ surface (DESIGN-RULES §1.5-8). Lives as the top row of the queue tray. The
197
+ .creconn button reuses the .errbtn idiom but re-declares its base here: #queue
198
+ sits in #composerWrap, OUTSIDE #fsRoot, so `#fsRoot .errbtn` never reaches it. */
199
+ .connline{display:flex;align-items:center;gap:8px;padding:6px 10px 6px 8px;font-family:var(--mono);font-size:10.5px;line-height:1.4}
200
+ .connline .cdot{width:6px;height:6px;border-radius:50%;flex:0 0 auto;background:var(--danger);animation:connpulse 1.6s ease-in-out infinite}
201
+ .connline .cw{font-weight:600;color:var(--danger)}
202
+ .connline .cmsg{color:var(--ink2)}
203
+ .connline .creconn{margin-left:auto;border:0;background:transparent;color:var(--ink2);font-family:var(--mono);font-size:10px;letter-spacing:.02em;font-weight:600;line-height:1.15;padding:2.5px 10px;border-radius:7px;box-shadow:inset 0 0 0 1px var(--hair2);cursor:pointer}
204
+ .connline .creconn:hover{background:var(--hover);color:var(--ink)}
205
+ @keyframes connpulse{0%,100%{opacity:1}50%{opacity:.35}}
206
+ @media (prefers-reduced-motion:reduce){.connline .cdot{animation:none}}
195
207
  .qedit{width:100%;box-sizing:border-box;background:var(--paper);border:1px solid var(--hair2);border-radius:9px;color:var(--ink);
196
208
  font:inherit;font-size:13px;line-height:1.5;padding:7px 10px;outline:none;min-height:56px;resize:vertical;margin:4px 0}
197
209
  .qbtns{display:flex;align-items:center;gap:6px;justify-content:flex-end;padding-bottom:4px}
@@ -201,6 +213,7 @@
201
213
 
202
214
  .errbar{position:absolute;left:50%;bottom:96px;transform:translateX(-50%);z-index:40;background:var(--errbg);backdrop-filter:blur(14px);
203
215
  border:1px solid rgba(var(--danger-rgb),.4);color:var(--danger);font-size:12.5px;border-radius:10px;padding:8px 14px;display:none}
216
+ .errbar.ok{border-color:rgba(var(--green-rgb),.45);color:var(--green)} /* 07-20c: neutral/success toast (PR merged) */
204
217
 
205
218
  /* composer */
206
219
  .composer-wrap{flex:0 0 auto;padding:8px 22px 18px}
@@ -400,6 +413,7 @@
400
413
  .chip2{font-family:var(--mono);font-size:9.5px;letter-spacing:.05em;padding:2px 7px;border-radius:6px;border:1px solid var(--hair2);color:var(--ink3);flex:0 0 auto}
401
414
  .chip2.doing{color:var(--ink);border-color:rgba(var(--blue-rgb),.4)}
402
415
  .chip2.review{color:var(--amber);border-color:rgba(var(--amber-rgb),.4)}
416
+ .chip2.rejected{color:var(--danger);border-color:rgba(var(--danger-rgb),.35)}
403
417
  .goalx{scroll-margin-top:24px;border-radius:12px;transition:background-color .3s}
404
418
  .goalx.selected{background-color:rgba(var(--amber-rgb),.12)}
405
419
  .prmini{font-family:var(--mono);font-size:9.5px;color:var(--green);text-decoration:none;border:1px solid rgba(var(--green-rgb),.35);border-radius:6px;padding:2px 7px;flex:0 0 auto;background:transparent;cursor:pointer}
@@ -666,6 +680,10 @@
666
680
  .delmodal{width:380px}
667
681
  .delpreview{border:1px solid var(--hair2);border-radius:10px;padding:10px 12px;background:var(--paper);
668
682
  color:var(--ink);font-size:12.5px;line-height:1.5;max-height:120px;overflow:auto;word-break:break-word}
683
+ /* 07-20c: borderless preview for the merge-confirm dialog — must NOT read as an editable field */
684
+ .delpreview.plain{border:none;background:none;padding:2px 0;max-height:none}
685
+ .delpreview.plain .pvtitle{font-weight:600;color:var(--ink);font-size:14px}
686
+ .delpreview.plain .pvpr{color:var(--ink2);font-size:11px;font-family:var(--mono);margin-top:3px}
669
687
  /* moment ③ New-project paywall dialog (H22). Same narrow .modal as .delmodal,
670
688
  but a one-action layout (Masa 2026-07-17 確定 __dlg(2)): Upgrade full-width,
671
689
  "Not now" a quiet underlined link centered below it. */
@@ -1936,6 +1954,9 @@
1936
1954
  /* To Do timeline: order via tone + labels, no axis line (§1.5-4) */
1937
1955
  #fsRoot .tl{position:relative}
1938
1956
  #fsRoot .tlcap{display:block;font-family:var(--mono);font-size:8.5px;letter-spacing:.1em;color:var(--ink3);text-transform:uppercase;padding:13px 0 5px 0} /* left 8->0: sit the DOING/Next up/Later labels on the same spine as the section headings (Masa 2026-07-18) */
1957
+ /* 07-20c: Rejected reads as a real section heading (like Review), not the tiny mono lane captions */
1958
+ #fsRoot .tlcap.rejcap{display:flex;align-items:baseline;gap:8px;font-family:inherit;font-size:13px;letter-spacing:0;text-transform:none;color:var(--ink);padding-top:16px}
1959
+ #fsRoot .tlcap.rejcap .num{font-size:10px;color:var(--ink3)}
1939
1960
  #fsRoot .tlcap.needs{color:var(--amber)} /* "Needs you" caption tinted to the attention accent, matching the board's Needs-you column (Masa 2026-07-19). Colour on the label only — the rows below carry their own in-row colour per §1.5-8. */
1940
1961
  #fsRoot .trow{position:relative;padding:6px 6px 6px 0;border-radius:8px;font-size:12.5px;color:var(--ink);display:flex;align-items:center;gap:9px} /* left 8->0: row dot lines up with the Review/To Do headings' left edge, not 8px inside it (Masa 2026-07-18) */
1941
1962
  #fsRoot .trow .tt{color:color-mix(in srgb,var(--ink) 84%,var(--panel))} /* §13.2 soften To Do row name (not pure black); left chat feed keeps --ink */
@@ -1951,6 +1972,7 @@
1951
1972
  #fsRoot .trow .stword.int{color:var(--amber)}
1952
1973
  #fsRoot .trow .st.ok{border-color:transparent;background:rgba(var(--green-rgb),.12);position:relative}
1953
1974
  #fsRoot .trow .st.ok::after{content:"✓";position:absolute;inset:0;display:grid;place-items:center;font-size:8px;color:var(--green)}
1975
+ #fsRoot .trow .st.rej{border-color:rgba(var(--danger-rgb),.5)} /* 07-20c: rejected — a quiet danger dot (settled, not alarming) */
1954
1976
  #fsRoot .trow .tt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1955
1977
  #fsRoot .trow .num{font-size:10px;color:var(--ink3);flex:0 0 auto}
1956
1978
  #fsRoot .trow .pct{font-size:10.5px;color:var(--green);flex:0 0 auto;font-family:var(--mono);font-variant-numeric:tabular-nums}
@@ -2812,7 +2834,7 @@
2812
2834
  <div class="reqlist" id="reviewList"></div>
2813
2835
  <div class="revactions">
2814
2836
  <button type="button" class="reqbtn approve" id="reviewApprove">Approve goal</button>
2815
- <button type="button" class="reqbtn dismiss" id="reviewDismiss">Dismiss goal</button>
2837
+ <button type="button" class="reqbtn dismiss" id="reviewDismiss">Reject</button>
2816
2838
  <span style="flex:1"></span>
2817
2839
  <button class="mbtn ghost" id="reviewClose">Close</button>
2818
2840
  </div>
@@ -2882,7 +2904,7 @@
2882
2904
  </div>
2883
2905
  <div class="sa-act">
2884
2906
  <button type="button" class="sa-app" id="gdApprove"><svg viewBox="0 0 24 24"><path d="M5 12.5l4.2 4.2L19 7"/></svg>Approve</button>
2885
- <button type="button" class="sa-dis" id="gdDismiss">Dismiss</button>
2907
+ <button type="button" class="sa-dis" id="gdDismiss">Reject</button>
2886
2908
  <div class="sa-chatwrap">
2887
2909
  <input type="text" class="sa-chat" id="gdRevisionInput" placeholder="Tell the worker what to try next…">
2888
2910
  <button type="button" class="sa-send" id="gdSend" title="Send" aria-label="Send"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
@@ -3000,6 +3022,7 @@ function restoreFocusedAnswerInput(container, attr, saved){
3000
3022
  const STATUS_LABEL = {
3001
3023
  sending: 'Sending…', stacked: 'Queued', planning: 'Planning…', queued: 'To do', running: 'Working…', needsInput: 'Needs input',
3002
3024
  review: 'In review', done: 'Done', failed: 'Failed', partial: 'Partly failed', interrupted: 'Interrupted', skipped: 'Skipped',
3025
+ rejected: 'Rejected',
3003
3026
  };
3004
3027
  // Mirrors engine/lib.mjs goalChip() — kept in sync manually since this
3005
3028
  // inline script can't import server-side code. `columns` (a project's
@@ -3018,6 +3041,7 @@ const GOAL_CHIP = (s, columns, startedAt) => s === 'stacked' || s === 'sending'
3018
3041
  s === 'done' ? [pickColumnLabel(columns, 'done', 'DONE'), ''] :
3019
3042
  s === 'retesting' ? ['RE-TESTING', 'doing'] :
3020
3043
  s === 'reverted' ? ['REVERTED', ''] :
3044
+ s === 'rejected' ? ['REJECTED', 'rejected'] :
3021
3045
  s === 'folded' ? ['FOLDED', ''] :
3022
3046
  s === 'blocked' ? ['BLOCKED', 'blocked'] : s === 'needsInput' ? ['NEEDS INPUT', 'blocked'] : ['FAIL', ''];
3023
3047
  const FINISHED = (s) => ['done', 'partial', 'failed', 'interrupted'].includes(s);
@@ -3321,11 +3345,13 @@ function buildReviewDigest({ goals, tasks } = {}){
3321
3345
  }
3322
3346
  return rows;
3323
3347
  }
3324
- // Review title: mirrors the label convention used throughout (reviewSummary.changed
3325
- // plan[0] text) pulled into one place so the review card's own title and its
3326
- // sibling-goal titles (H23 split note) stay consistent.
3348
+ // Review title: the same goal title the list view's goal header shows
3349
+ // (goalHeadText = g.text ?? g.plan[0]) so a card in "See all" and the same goal
3350
+ // in the list read identically (Masa 2026-07-19). Text-first; reviewSummary.changed
3351
+ // stays only as a last resort for a goal with no text. Pulled into one place so the
3352
+ // review card's own title and its sibling-goal titles (H23 split note) stay consistent.
3327
3353
  function saReviewTitle(g){
3328
- return String(g?.reviewSummary?.changed || g?.plan?.[0] || g?.text || '').replace(/\s+/g, ' ').trim().slice(0, 90);
3354
+ return String(g?.text || g?.plan?.[0] || g?.reviewSummary?.changed || '').replace(/\s+/g, ' ').trim().slice(0, 90);
3329
3355
  }
3330
3356
  // H23 (2026-07-18) evidence-kind detection. Mirrors engine/lib.mjs isUiChange() /
3331
3357
  // hasTestRelevantChanges() — kept in sync manually since the browser script isn't a
@@ -3955,21 +3981,35 @@ function renderQueue(){
3955
3981
  const stacked = state.goals.filter((g) => g.projectId === state.active && g.status === 'stacked')
3956
3982
  .sort((a, b) => (b.prio ?? 0) - (a.prio ?? 0) || a.id - b.id);
3957
3983
  const planning = state.goals.filter((g) => g.projectId === state.active && ['sending', 'planning'].includes(g.status));
3958
- if (!ob.length && !stacked.length && !planning.length) { q.classList.remove('show'); q.innerHTML = ''; return; }
3984
+ // Offline connection line: connOffline is the grace-debounced (~800ms) SSE-down
3985
+ // flag from the reconnect loop. When offline we always show the tray (even with
3986
+ // no queued items) so the red status line + Reconnect is visible above the composer.
3987
+ const offline = connOffline;
3988
+ if (!ob.length && !stacked.length && !planning.length && !offline) { q.classList.remove('show'); q.innerHTML = ''; return; }
3959
3989
  q.classList.add('show');
3990
+ // mirror connectionBannerState({online}) from engine/lib.mjs (a browser static file
3991
+ // can't import the module) — offline ⇒ red inline line + Reconnect atop the tray.
3992
+ const connHtml = offline
3993
+ ? '<div class="connline"><span class="cdot"></span><span class="cw">Galda is offline</span><span class="cmsg">— reconnecting…</span><button class="errbtn creconn" data-reconnect>Reconnect</button></div>'
3994
+ : '';
3960
3995
  // Optimistic outbox items (saved to localStorage BEFORE the network call) render
3961
3996
  // first — instant, offline-safe, never lost. 'unsent' rows are tap-to-resend.
3962
3997
  const obRows = ob.map((o) => {
3963
3998
  const unsent = o.status === 'unsent';
3964
3999
  const lead = unsent ? '<span class="qdot"></span>' : '<span class="spinner" style="width:9px;height:9px;flex:0 0 9px"></span>';
3965
- return `<div class="qrow"${unsent ? ` data-oresend="${o.oid}"` : ''}>${lead}<span class="qtxt">${esc((o.text ?? '').replace(/\s+/g, ' '))}</span><span class="meta">${unsent ? 'Unsent tap to resend' : 'Sending…'}</span></div>`;
4000
+ // mirror unsentMetaLabel({online}) from engine/lib.mjsoffline resend is a lie
4001
+ // (recovery is the connline's Reconnect), so drop tap-to-resend and swap the label.
4002
+ const resendable = unsent && !offline;
4003
+ const metaTxt = unsent ? (offline ? 'Waiting for connection' : 'Unsent — tap to resend') : 'Sending…';
4004
+ return `<div class="qrow"${resendable ? ` data-oresend="${o.oid}"` : ''}>${lead}<span class="qtxt">${esc((o.text ?? '').replace(/\s+/g, ' '))}</span><span class="meta">${metaTxt}</span></div>`;
3966
4005
  }).join('');
3967
4006
  const planningRows = planning.map((g) =>
3968
4007
  `<div class="qrow"><span class="spinner" style="width:9px;height:9px;flex:0 0 9px"></span><span class="qtxt">${esc((g.text ?? '').replace(/\s+/g, ' '))}</span><span class="meta">breaking down → To do…</span></div>`).join('');
3969
4008
  // Flagship: qtray label is the quiet mono "Queued · N" (flagship que1); other
3970
4009
  // layouts keep the explanatory sentence.
3971
4010
  const qn = ob.length + stacked.length + planning.length;
3972
- q.innerHTML = `<div class="qhead">${state.layout === 'flagship' ? `Queued · ${qn}` : `${qn} queued · goals become To dos in ~30s`}</div>` + obRows + planningRows + stacked.map((g) => {
4011
+ const headHtml = qn > 0 ? `<div class="qhead">${state.layout === 'flagship' ? `Queued · ${qn}` : `${qn} queued · goals become To dos in ~30s`}</div>` : '';
4012
+ q.innerHTML = connHtml + headHtml + obRows + planningRows + stacked.map((g) => {
3973
4013
  if (state.editing === g.id) {
3974
4014
  return `<textarea class="qedit" id="qedit-${g.id}">${esc(g.text)}</textarea>
3975
4015
  <div class="qbtns">
@@ -3987,6 +4027,7 @@ function renderQueue(){
3987
4027
  <button class="qico del qd" data-id="${g.id}" title="Delete">${ICONS.trash}</button></div>`;
3988
4028
  }).join('');
3989
4029
  for (const el of q.querySelectorAll('[data-oresend]')) el.onclick = () => { const item = outbox.load().find((x) => x.oid === el.dataset.oresend); if (item) postOutboxItem(item); };
4030
+ for (const el of q.querySelectorAll('[data-reconnect]')) el.onclick = () => forceReconnect();
3990
4031
  const qrows = [...q.querySelectorAll('.qrow[data-goal]')];
3991
4032
  for (const el of qrows) {
3992
4033
  el.addEventListener('dragstart', (e) => { state.dragGoal = Number(el.dataset.goal); el.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
@@ -4313,15 +4354,23 @@ function renderTasks(){
4313
4354
  for (const b of $('tasklist').querySelectorAll('[data-qapprove]')) {
4314
4355
  b.onclick = async (e) => { e.stopPropagation();
4315
4356
  const bx = b.getBoundingClientRect(); // capture before await/re-render — burst FROM the ✓ button
4316
- let r; for (const id of b.dataset.qapprove.split(',')) r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
4317
- if (r && r.ok) confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2);
4318
- qrefresh(r, 'Approve failed.'); };
4357
+ let ok = true, mergeGoal = null;
4358
+ for (const id of b.dataset.qapprove.split(',')) {
4359
+ const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
4360
+ if (!r.ok){ ok = false; break; }
4361
+ const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
4362
+ }
4363
+ if (!ok){ showErr('Approve failed.'); return; }
4364
+ await refresh();
4365
+ if (mergeGoal){ confirmMerge(mergeGoal); return; } // merge instead of confetti
4366
+ confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2); };
4319
4367
  }
4320
4368
  for (const b of $('tasklist').querySelectorAll('[data-qdismiss]')) {
4369
+ // 07-20c: the inline ✕ is a pure Reject — park the goal in the Rejected column (/reject),
4370
+ // no auto-rework. Rework is asked for via chat.
4321
4371
  b.onclick = async (e) => { e.stopPropagation();
4322
- let r; for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/dismiss`), { method: 'POST',
4323
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: '' }) });
4324
- qrefresh(r, 'Dismiss failed.'); };
4372
+ let r; for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
4373
+ qrefresh(r, 'Reject failed.'); };
4325
4374
  }
4326
4375
  // Archive: retire an unfinished goal cleanly (no rework spawned). The escape
4327
4376
  // hatch for half-finished (partial/blocked) items so Attention stays drainable.
@@ -5069,7 +5118,39 @@ async function setRequirementStatus(goalId, taskId, status){
5069
5118
  // backend has never heard of, e.g. an injected verify fixture) and then
5070
5119
  // persisted server-side; a real goal's SSE 'goal' event reconciles it right
5071
5120
  // after, a no-op since the value already matches.
5121
+ // 07-20c: a review goal that HAS a PR is finished by MERGING the PR, not by approving in
5122
+ // the app (that was the "approved but PR still open" zombie). Reuses the confirmDelete modal
5123
+ // as a merge-confirm: primary action opens the PR; the goal stays in review and
5124
+ // syncGoalMerges flips it to done once GitHub reports the merge.
5125
+ async function confirmMerge(goal){
5126
+ const prNo = goal && goal.pr ? (String(goal.pr).match(/pull\/(\d+)/) ?? [, ''])[1] : '';
5127
+ const open = await confirmDelete({
5128
+ title: 'Merge the Pull Request to finish',
5129
+ body: 'This goal has a PR. Approving here does not merge it — open the PR and merge it, and it moves to Done automatically.',
5130
+ previewHtml: `<div class="pvtitle">${esc(goal?.text || '')}</div>${prNo ? `<div class="pvpr">PR #${prNo} ↗</div>` : ''}`,
5131
+ previewPlain: true,
5132
+ confirmLabel: 'Open PR', cancelLabel: 'Close', confirmDanger: false,
5133
+ });
5134
+ if (open && goal && goal.pr) window.open(goal.pr, '_blank', 'noopener');
5135
+ }
5136
+ // Shared approve-response handler for the simple approve surfaces (inline ✓, review-card ✓,
5137
+ // See-all). Returns true if the goal actually advanced (done), false if it needs a merge.
5138
+ function handleApproveJson(json, goal){
5139
+ if (json && json.needsMerge){ if (json.goal) upsert(state.goals, json.goal); confirmMerge(json.goal || goal); return false; }
5140
+ if (json) upsert(state.goals, json);
5141
+ return true;
5142
+ }
5072
5143
  async function advanceGoalAfterAllApproved(goal){
5144
+ // 07-20c: a PR goal is not finished here — route it to the merge-confirm dialog and keep
5145
+ // it in review (no optimistic done, no celebration until the PR is actually merged).
5146
+ if (goal.pr){
5147
+ try {
5148
+ const r = await fetch(withKey(`/api/goals/${goal.id}/advance-review`), { method: 'POST' });
5149
+ if (r.ok){ const j = await r.json(); if (j.goal) upsert(state.goals, j.goal); if (j.needsMerge) confirmMerge(j.goal || goal); }
5150
+ } catch { /* offline — leave as review */ }
5151
+ renderReviewChecklist(); render();
5152
+ return;
5153
+ }
5073
5154
  const fly = captureReviewFly(goal.id);
5074
5155
  goal.status = 'done';
5075
5156
  renderReviewChecklist(); render();
@@ -5139,8 +5220,16 @@ $('reviewApprove').onclick = async () => {
5139
5220
  try {
5140
5221
  const r = await fetch(withKey(`/api/goals/${goal.id}/approve`), { method: 'POST' });
5141
5222
  if (!r.ok) throw new Error();
5223
+ const j = await r.json();
5224
+ if (j.needsMerge){ // 07-20c: PR goal — send to the merge dialog, no celebration/advance
5225
+ if (j.goal) upsert(state.goals, j.goal);
5226
+ renderReviewChecklist();
5227
+ $('reviewApprove').disabled = false;
5228
+ confirmMerge(j.goal || goal);
5229
+ return;
5230
+ }
5142
5231
  const fly = captureReviewFly(goal.id);
5143
- upsert(state.goals, await r.json());
5232
+ upsert(state.goals, j);
5144
5233
  renderReviewChecklist();
5145
5234
  const dm = $('reviewDoneMsg');
5146
5235
  if (dm){ dm.classList.remove('pop'); void dm.offsetWidth; dm.classList.add('pop'); }
@@ -5189,7 +5278,21 @@ async function sendReviewDismiss(text){
5189
5278
  render();
5190
5279
  } catch { showErr('Dismiss failed.'); }
5191
5280
  }
5192
- $('reviewDismiss').onclick = () => sendReviewDismiss('');
5281
+ // 07-20c: the review dialog's Reject button parks the goal in the Rejected column (/reject),
5282
+ // no auto-rework. Typing feedback into the box below and pressing Send still reworks (/dismiss).
5283
+ async function sendReviewReject(){
5284
+ const goal = state.goals.find((g) => g.id === state.reviewOpenGoal);
5285
+ if (!goal) return;
5286
+ try {
5287
+ const r = await fetch(withKey(`/api/goals/${goal.id}/reject`), { method: 'POST' });
5288
+ if (!r.ok) throw new Error();
5289
+ const body = await r.json();
5290
+ upsert(state.goals, body.goal);
5291
+ closeReviewChecklist();
5292
+ render();
5293
+ } catch { showErr('Reject failed.'); }
5294
+ }
5295
+ $('reviewDismiss').onclick = () => sendReviewReject();
5193
5296
  $('reviewFixSend').onclick = () => { const t = $('reviewFixInput').value.trim(); if (t || reviewAttach.length) sendReviewDismiss(t); };
5194
5297
  $('reviewFixInput').addEventListener('keydown', (e) => {
5195
5298
  if (e.key === 'Enter') { const t = $('reviewFixInput').value.trim(); if (t || reviewAttach.length) sendReviewDismiss(t); }
@@ -5370,12 +5473,26 @@ $('gdApprove').onclick = async () => {
5370
5473
  try {
5371
5474
  const r = await fetch(withKey(`/api/goals/${goalId}/approve`), { method: 'POST' });
5372
5475
  if (!r.ok) throw new Error();
5373
- upsert(state.goals, await r.json());
5476
+ handleApproveJson(await r.json(), state.goals.find((g) => g.id === goalId)); // 07-20c: PR goal → merge dialog
5374
5477
  closeGoalDetail();
5375
5478
  render();
5376
5479
  } catch { showErr('Approve failed.'); }
5377
5480
  $('gdApprove').disabled = false;
5378
5481
  };
5482
+ // 07-20c: the goal-detail Reject button parks the goal in the Rejected column (/reject).
5483
+ // Revision text typed into gdSend still goes to /dismiss (rework) via sendDismiss below.
5484
+ async function sendReject(goalId){
5485
+ $('gdApprove').disabled = true; $('gdDismiss').disabled = true; $('gdSend').disabled = true;
5486
+ try {
5487
+ const r = await fetch(withKey(`/api/goals/${goalId}/reject`), { method: 'POST' });
5488
+ if (!r.ok) throw new Error();
5489
+ const body = await r.json();
5490
+ upsert(state.goals, body.goal);
5491
+ closeGoalDetail();
5492
+ render();
5493
+ } catch { showErr('Reject failed.'); }
5494
+ $('gdApprove').disabled = false; $('gdDismiss').disabled = false; $('gdSend').disabled = false;
5495
+ }
5379
5496
  async function sendDismiss(goalId, text){
5380
5497
  $('gdApprove').disabled = true; $('gdDismiss').disabled = true; $('gdSend').disabled = true;
5381
5498
  try {
@@ -5416,7 +5533,7 @@ async function discardGoal(goalId){
5416
5533
  } catch { showErr('Discard failed.'); }
5417
5534
  $('gdSend').disabled = false; $('gdDiscard').disabled = false;
5418
5535
  }
5419
- $('gdDismiss').onclick = () => { const goalId = state.goalDetailOpen; if (goalId != null) sendDismiss(goalId, ''); };
5536
+ $('gdDismiss').onclick = () => { const goalId = state.goalDetailOpen; if (goalId != null) sendReject(goalId); };
5420
5537
  $('gdDiscard').onclick = () => { const goalId = state.goalDetailOpen; if (goalId != null) discardGoal(goalId); };
5421
5538
  $('gdSend').onclick = () => {
5422
5539
  const goalId = state.goalDetailOpen;
@@ -5460,7 +5577,7 @@ const fsUI = {
5460
5577
  rv: [1, 2, 3].includes(Number(localStorage.getItem('fsRv'))) ? Number(localStorage.getItem('fsRv')) : 3, // review view: 3 thumbnails (default) / 1 list / 2 compact
5461
5578
  band: Number(localStorage.getItem('fsBand') ?? 0), // theme-6 band choice
5462
5579
  revMore: false, // "+N more" fold on the review list (§1.5-8)
5463
- todoFold: false, doneOpen: false, todoMore: false,
5580
+ todoFold: false, doneOpen: false, rejOpen: false, todoMore: false,
5464
5581
  todoView: localStorage.getItem('fsTodoView') === 'board' ? 'board' : 'list', // To Do: list (default) / kanban board (Masa 2026-07-16)
5465
5582
  logOpen: new Set(), logFocus: null, // "View log" targets
5466
5583
  repsOpen: new Set(), // reply-group folds
@@ -5469,6 +5586,7 @@ const FS_ICONS = {
5469
5586
  reply: '<svg viewBox="0 0 24 24"><path d="M10 16l-5-4 5-4"/><path d="M5 12h9a4 4 0 0 1 4 4v2"/></svg>',
5470
5587
  edit: '<svg viewBox="0 0 24 24"><path d="M4 20h4L18.5 9.5a2.1 2.1 0 0 0-3-3L5 17v3z"/><path d="M14 7l3 3"/></svg>',
5471
5588
  del: '<svg viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>',
5589
+ reopen: '<svg viewBox="0 0 24 24"><g transform="translate(24,0) scale(-1,1)"><polyline points="3 5 3 10 8 10"/><path d="M5.6 15.4A8 8 0 1 0 6 8.3L3 10.8"/></g></svg>', /* 07-20c: rotate arrow (mirrored → arrowhead top-right) = reopen/restore */
5472
5590
  };
5473
5591
  // per-project count icons (HANDOFF-v45 §10): To Do = rounded square, Review = Scan
5474
5592
  // (magnifier) — both stroked, NEUTRAL ink (never green); shape is the only distinction.
@@ -5984,14 +6102,19 @@ function renderFsReview(){
5984
6102
  const cnote = carried.length ? `<span class="fnote">${foldLangFor(g0) === 'ja'
5985
6103
  ? `+ ${carried.map((x) => `#${x.id}`).join(', ')} の仕事もここに入っています`
5986
6104
  : `also carries ${carried.map((x) => `#${x.id}`).join(', ')}`}</span>` : '';
5987
- return `<div class="rcard${img ? '' : ' noimg'}" data-fsrev="${r.goalIds[0]}">${img}<div class="rc"><div class="t">${rid}${esc(r.checkLine)}</div>${rnote}${cnote}${pr}</div>
6105
+ // Title = the goal's own title (saReviewTitle → text-first), same as the See-all
6106
+ // Ledger and the To Do list, so a review card and its list row read identically
6107
+ // (Masa 2026-07-20: this card showed reviewSummary.check "…が出ない" while the list
6108
+ // showed the request text). Plan-reviews keep their "PLAN — approve to execute:"
6109
+ // checkLine, which is the actionable label there, not a title mismatch.
6110
+ return `<div class="rcard${img ? '' : ' noimg'}" data-fsrev="${r.goalIds[0]}">${img}<div class="rc"><div class="t">${rid}${esc(r.plan ? r.checkLine : saReviewTitle(g0))}</div>${rnote}${cnote}${pr}</div>
5988
6111
  <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>`;
5989
6112
  };
5990
6113
  // A folded goal is settled, not failed: no ✓/✕ (the thing to approve lives on the goal
5991
6114
  // it was folded into), no red, no painted surface (§1.5-8) — just its own number, what
5992
6115
  // it asked for, and where the work went. Tapping it opens that goal (§1.5-9 wording).
5993
6116
  const foldCard = (g) => {
5994
- const line = String(g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').slice(0, 60);
6117
+ const line = saReviewTitle(g).slice(0, 60); // title-first, same as the review card above
5995
6118
  const mergedNote = foldLangFor(g) === 'ja' ? `→ #${esc(String(g.foldedInto))} にまとめました` : `merged into #${esc(String(g.foldedInto))}`;
5996
6119
  return `<div class="rcard noimg fold" data-fsfold="${g.id}">
5997
6120
  <div class="rc"><div class="t"><span class="rid gno" data-n="${g.id}">#${g.id}</span>${esc(line)}</div>
@@ -6046,12 +6169,19 @@ function renderFsReview(){
6046
6169
  else openGoalDetail(target.id);
6047
6170
  };
6048
6171
  for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
6049
- let r; for (const id of b.dataset.fsapprove.split(',')) r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
6050
- if (r && r.ok) { celebrateApprove(); await refresh(); } else showErr('Approve failed.'); };
6172
+ let ok = true, mergeGoal = null;
6173
+ for (const id of b.dataset.fsapprove.split(',')) {
6174
+ const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
6175
+ if (!r.ok){ ok = false; break; }
6176
+ const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
6177
+ }
6178
+ if (!ok){ showErr('Approve failed.'); return; }
6179
+ await refresh();
6180
+ if (mergeGoal) confirmMerge(mergeGoal); else celebrateApprove(); };
6051
6181
  for (const b of el.querySelectorAll('[data-fsdismiss]')) b.onclick = async (e) => { e.stopPropagation();
6052
- let r; for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/dismiss`), { method: 'POST',
6053
- headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: '' }) });
6054
- if (r && r.ok) await refresh(); else showErr('Dismiss failed.'); };
6182
+ // 07-20c: the review-card is a pure Reject Rejected column (/reject), no auto-rework.
6183
+ let r; for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
6184
+ if (r && r.ok) await refresh(); else showErr('Reject failed.'); };
6055
6185
  }
6056
6186
  // Shared To Do bucketing (list rows AND the kanban board read the same groups —
6057
6187
  // single source of truth, see renderFsBoard below). Pure of the DOM: state in, groups out.
@@ -6070,13 +6200,14 @@ function fsBucketTodo(){
6070
6200
  queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
6071
6201
  doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num),
6072
6202
  later: pgoals.filter((g) => g.status === 'pending'),
6203
+ rejected: pgoals.filter((g) => g.status === 'rejected'), // 07-20c: Reject parks goals here (own fold, like Done)
6073
6204
  };
6074
6205
  }
6075
6206
  // To Do (below Review): Doing rows with bar + mono %, Next up in queue order,
6076
6207
  // error as a small in-row colour element (§1.5-8), quiet Done fold.
6077
6208
  function renderFsTodo(){
6078
6209
  const el = $('fsTodoSec'); if (!el) return;
6079
- const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later } = fsBucketTodo();
6210
+ const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later, rejected } = fsBucketTodo();
6080
6211
  const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // list版 Doing トグルの状態
6081
6212
  // 一時停止したゴール(blocked kind:'paused')は Doing に「Paused」1行で留める(Masa 2026-07-19)。
6082
6213
  // Needs you 側(failed/blocked)へは出さず、その走行/中断タスク行も抑止して二重行を作らない。
@@ -6177,7 +6308,8 @@ function renderFsTodo(){
6177
6308
  <div class="tl">
6178
6309
  ${(doingRows || paused) ? `<span class="tlcap tlcap-doing" style="padding-top:0">Doing<button class="tlcap-pause" data-fspause="1" aria-pressed="${paused ? 'true' : 'false'}" title="${paused ? 'Resume — 再開' : 'Pause — 一時停止'}">${paused ? '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>' : '<svg viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>'}</button></span>${doingRows}` : ''}
6179
6310
  ${needsRows ? `<span class="tlcap needs"${(doingRows || paused) ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
6180
- ${upRows ? `<span class="tlcap"${(doingRows || paused) || needsRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
6311
+ ${rejected.length ? `<span class="tlcap rejcap"${(doingRows || paused) || needsRows ? '' : ' style="padding-top:0"'}>Rejected<span class="num">${rejected.length}</span></span>${rejected.map((g) => `<div class="trow up rej"><span class="st rej"></span>${noSpan(g.id)}<span class="tt">${tt(g.text, 70)}</span><span class="acts"><button class="ab" data-fsreopen="${g.id}" title="Reopen — back to Review">${FS_ICONS.reopen}</button><button class="ab" data-fsrejdel="${g.id}" title="Delete — discard (closes the PR)">${FS_ICONS.del}</button></span></div>`).join('')}` : ''}
6312
+ ${upRows ? `<span class="tlcap"${(doingRows || paused) || needsRows || rejected.length ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
6181
6313
  ${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
6182
6314
  ${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
6183
6315
  <button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
@@ -6190,6 +6322,16 @@ function renderFsTodo(){
6190
6322
  if (qadd) qadd.onclick = () => $('input')?.focus();
6191
6323
  const dl = el.querySelector('#fsDoneLine');
6192
6324
  if (dl) dl.onclick = () => { fsUI.doneOpen = !fsUI.doneOpen; dl.classList.toggle('open', fsUI.doneOpen); el.querySelector('#fsDonelist').hidden = !fsUI.doneOpen; };
6325
+ for (const b of el.querySelectorAll('[data-fsreopen]')) b.onclick = async (e) => { e.stopPropagation();
6326
+ const r = await fetch(withKey(`/api/goals/${b.dataset.fsreopen}/reopen`), { method: 'POST' });
6327
+ if (!r.ok) showErr('Reopen failed.'); else await refresh(); };
6328
+ for (const b of el.querySelectorAll('[data-fsrejdel]')) b.onclick = async (e) => { e.stopPropagation();
6329
+ const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
6330
+ // Delete on a rejected goal = revert: it closes the PR + drops the worktree, so a rejected
6331
+ // goal that had a PR doesn't leave an orphan PR behind (DELETE would 409 — rejected isn't deletable).
6332
+ if (!(await confirmDelete({ title: 'Discard this rejected goal?', preview }))) return;
6333
+ const r = await fetch(withKey(`/api/goals/${b.dataset.fsrejdel}/revert`), { method: 'POST' });
6334
+ if (!r.ok) showErr('Delete failed.'); else await refresh(); };
6193
6335
  const more = el.querySelector('#fsTodoMore');
6194
6336
  if (more) more.onclick = () => { fsUI.todoMore = !fsUI.todoMore; renderFsTodo(); };
6195
6337
  for (const b of el.querySelectorAll('[data-fsreps]')) b.onclick = () => {
@@ -6512,7 +6654,20 @@ function renderClawdHero(){
6512
6654
  $('fsRailTg').innerHTML = tgSvg('left', !root.classList.contains('railfold'));
6513
6655
  $('fsLaneTg').innerHTML = tgSvg('right', !root.classList.contains('lanefold'));
6514
6656
  };
6515
- $('fsRailTg').onclick = () => { root.classList.toggle('railfold'); tgUpdate(); fsGearAlignX(); }; // §1.5-7: fold
6657
+ $('fsRailTg').onclick = () => { // §1.5-7: fold
6658
+ root.classList.toggle('railfold'); tgUpdate();
6659
+ // Re-align the gear only AFTER the rail's fold animation settles. The old
6660
+ // synchronous fsGearAlignX() measured a half-collapsed rail mid-transition and
6661
+ // left the gear at a stale offset once the width finished moving — "たたむと歯車が
6662
+ // ズレる" (#474/#548, Masa 2026-07-19). Clear any stale offset now, re-measure on
6663
+ // transitionend (flex-basis), with a timeout fallback for reduced-motion / .noanim.
6664
+ const btn = $('fsMoreBtn'); if (btn) btn.style.transform = '';
6665
+ const rail = root.querySelector('.fsrail');
6666
+ let aligned = false;
6667
+ const align = () => { if (aligned) return; aligned = true; fsGearAlignX(); };
6668
+ if (rail) rail.addEventListener('transitionend', function te(e){ if (e.propertyName !== 'flex-basis') return; rail.removeEventListener('transitionend', te); align(); });
6669
+ setTimeout(align, 340);
6670
+ };
6516
6671
  $('fsLaneTg').onclick = () => { root.classList.toggle('lanefold'); tgUpdate(); };
6517
6672
  // List/Board toggle — wired once (static, unlike the row it used to live inside which
6518
6673
  // rebuilds on every renderFsTodo()); syncFsTvSeg() paints .on and is called from renderFsLane.
@@ -6756,12 +6911,22 @@ function clearBillingBlocked(){
6756
6911
  }
6757
6912
  function showErr(msg){
6758
6913
  const bar = $('errbar');
6759
- bar.classList.remove('billing'); // shed the paywall styling if it was last used for that
6914
+ bar.classList.remove('billing', 'ok'); // shed the paywall/success styling if it was last used for that
6760
6915
  billingBlockedActive = false;
6761
6916
  bar.textContent = msg; bar.style.display = 'block';
6762
6917
  clearTimeout(errTimer);
6763
6918
  errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; }, 6000);
6764
6919
  }
6920
+ // 07-20c: a neutral/success toast (green), reusing the #errbar surface. Used when a PR merge
6921
+ // lands back in the app (review→done via syncGoalMerges).
6922
+ function showToast(msg){
6923
+ const bar = $('errbar');
6924
+ bar.classList.remove('billing'); bar.classList.add('ok');
6925
+ billingBlockedActive = false;
6926
+ bar.textContent = msg; bar.style.display = 'block';
6927
+ clearTimeout(errTimer);
6928
+ errTimer = setTimeout(() => { bar.classList.remove('ok'); bar.style.display = 'none'; }, 4000);
6929
+ }
6765
6930
 
6766
6931
  // Free-limit copy (H22, Masa 2026-07-17 確定 · ja+en). The sentence is content,
6767
6932
  // so it follows the user's language; the mono label and buttons stay English
@@ -7109,15 +7274,55 @@ function scheduleActRender(){ if (_actT) { _actP = true; return; } renderActPane
7109
7274
  // (connection refused = fatal, no auto-retry), so we own the reconnect loop.
7110
7275
  let esRetryMs = 1000;
7111
7276
  let sseUp = false; // live engine link — the honest signal behind the MCP tab's status dot (v46)
7277
+ // Connection status line (queue tray): grace-debounce the offline state ~800ms so
7278
+ // sub-second SSE blips don't flash a red line. connOffline is the debounced flag
7279
+ // renderQueue() reads; connSetOnline/Offline flip it around real sseUp changes.
7280
+ let connOffline = false, _connGraceT = null, _sseReconnectT = null, _es = null, _healthT = null;
7281
+ function connSetOnline(){ if (_connGraceT) { clearTimeout(_connGraceT); _connGraceT = null; } if (connOffline) { connOffline = false; renderQueue(); } }
7282
+ function connSetOffline(){ if (_connGraceT || connOffline) return; _connGraceT = setTimeout(() => { _connGraceT = null; connOffline = true; renderQueue(); }, 800); }
7283
+ // Reconnect button: drop any pending backoff timer and reopen the EventSource now.
7284
+ function forceReconnect(){ if (_sseReconnectT) { clearTimeout(_sseReconnectT); _sseReconnectT = null; } try { _es && _es.close(); } catch { /* already closed */ } esRetryMs = 1000; connectSSE(); }
7285
+ // Robust liveness backstop that does NOT depend on EventSource onerror firing.
7286
+ // The browser throttles background tabs, so onerror can be delayed or never fire and
7287
+ // sseUp goes stale-true — that is why a dropped link showed no red line until the tab
7288
+ // was refreshed. A cheap HEAD probe (visible tabs only; also fired on tab-focus) flips
7289
+ // the connline within seconds of the user viewing a dead link, and forces a fresh SSE
7290
+ // so recovery (onopen -> clear + flush) still happens when onerror never fired.
7291
+ async function checkHealth(){
7292
+ if (document.hidden) return;
7293
+ let ok = false;
7294
+ try {
7295
+ const c = new AbortController(); const to = setTimeout(() => c.abort(), 3000);
7296
+ const r = await fetch(withKey('/'), { method: 'HEAD', cache: 'no-store', signal: c.signal });
7297
+ clearTimeout(to); ok = r.ok;
7298
+ } catch { ok = false; }
7299
+ if (!ok) {
7300
+ connSetOffline(); // grace-debounced ~800ms
7301
+ if (!_sseReconnectT) { try { _es && _es.close(); } catch {} sseUp = false; esRetryMs = Math.min(esRetryMs, 4000); _sseReconnectT = setTimeout(connectSSE, esRetryMs); }
7302
+ } else if (connOffline && !_sseReconnectT) {
7303
+ forceReconnect(); // server reachable again but the line is still up → reconnect to clear + flush
7304
+ }
7305
+ }
7306
+ function startHealthPoll(){ clearInterval(_healthT); _healthT = setInterval(checkHealth, 4000); }
7112
7307
  function connectSSE(){
7113
- const es = new EventSource(withKey('/api/events'));
7114
- es.onopen = () => { esRetryMs = 1000; sseUp = true; console.log('[manager] sse connected'); refresh(); flushOutbox();
7308
+ _sseReconnectT = null; // this attempt supersedes any scheduled retry
7309
+ const es = _es = new EventSource(withKey('/api/events'));
7310
+ es.onopen = () => { esRetryMs = 1000; sseUp = true; connSetOnline(); console.log('[manager] sse connected'); refresh(); flushOutbox();
7115
7311
  if ($('gearPop').classList.contains('open') && custTab === 'mcp') buildGearPop(); };
7116
7312
  es.onmessage = (e) => {
7117
7313
  const m = JSON.parse(e.data);
7118
7314
  if (m.ev === 'goal') {
7119
- const line = goalLogLine(m.goal, state.goals.find((g) => g.id === m.goal.id));
7315
+ const prev = state.goals.find((g) => g.id === m.goal.id);
7316
+ const line = goalLogLine(m.goal, prev);
7120
7317
  if (line) state.logEntries = pushCapped(state.logEntries, { text: line, ts: Date.now() });
7318
+ // 07-20c: a PR goal reaches 'done' ONLY via a real merge (syncGoalMerges) — so a
7319
+ // review→done flip on a goal that has a PR means the user just merged it on GitHub and
7320
+ // came back. Celebrate (confetti) + a "merged" toast so the merge is unmistakable.
7321
+ if (prev && prev.status === 'review' && m.goal.status === 'done' && m.goal.pr) {
7322
+ try { celebrateApprove(); } catch {}
7323
+ const prNo = (String(m.goal.pr).match(/pull\/(\d+)/) ?? [, ''])[1];
7324
+ showToast(`PR #${prNo} merged — “${(m.goal.text || '').replace(/\s+/g, ' ').slice(0, 48)}” is Done`);
7325
+ }
7121
7326
  upsert(state.goals, m.goal);
7122
7327
  }
7123
7328
  else if (m.ev === 'goal-deleted') state.goals = state.goals.filter((g) => g.id !== m.id);
@@ -7145,13 +7350,19 @@ function connectSSE(){
7145
7350
  scheduleRender();
7146
7351
  };
7147
7352
  es.onerror = () => {
7148
- if (es.readyState === EventSource.CLOSED) {
7149
- es.close();
7150
- if (sseUp) { sseUp = false; if ($('gearPop').classList.contains('open') && custTab === 'mcp') buildGearPop(); }
7151
- console.log(`[manager] sse closed reconnecting in ${esRetryMs}ms`);
7152
- setTimeout(connectSSE, esRetryMs);
7153
- esRetryMs = Math.min(esRetryMs * 2, 15000);
7154
- }
7353
+ // Native EventSource sits silently in CONNECTING and auto-retries on a dropped
7354
+ // or refused connection — readyState only reaches CLOSED on a non-2xx response.
7355
+ // Gating on CLOSED therefore missed every REAL relay/network drop (verified
7356
+ // 2026-07-20: CDP-offline AND killing the server both left the old gate un-fired,
7357
+ // so the red line never showed). Treat ANY error as the live link going down:
7358
+ // stop the native retry (close), flag offline (grace-debounced), own the backoff.
7359
+ if (_sseReconnectT) return; // a reconnect is already scheduled — don't stack
7360
+ try { es.close(); } catch { /* already closed */ }
7361
+ if (sseUp) { sseUp = false; if ($('gearPop').classList.contains('open') && custTab === 'mcp') buildGearPop(); }
7362
+ connSetOffline(); // arm the ~800ms grace timer before the red connline shows
7363
+ console.log(`[manager] sse down (readyState=${es.readyState}) — reconnecting in ${esRetryMs}ms`);
7364
+ _sseReconnectT = setTimeout(connectSSE, esRetryMs);
7365
+ esRetryMs = Math.min(esRetryMs * 2, 15000);
7155
7366
  };
7156
7367
  }
7157
7368
 
@@ -7302,7 +7513,8 @@ async function boot(){
7302
7513
  await refresh();
7303
7514
  flushOutbox();
7304
7515
  connectSSE();
7305
- document.addEventListener('visibilitychange', () => { if (!document.hidden) { refresh(); flushOutbox(); } });
7516
+ startHealthPoll();
7517
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) { refresh(); flushOutbox(); checkHealth(); } });
7306
7518
  // belt and suspenders for the never-lose guarantee: even if every event
7307
7519
  // path misfires, unsent memos are retried on a slow heartbeat
7308
7520
  setInterval(() => { if (outbox.load().some((o) => o.status === 'unsent')) flushOutbox(); }, 15000);
@@ -7809,7 +8021,7 @@ function saEvidenceHtml(kind, factRow, img, filePaths, diffText, gtr, it){
7809
8021
  return `<div class="lg-ev"><div class="lg-evhd">Evidence</div><div class="row">${factRow}</div>${img}</div>`;
7810
8022
  }
7811
8023
  function saItemHtml(it, i){
7812
- const done = { 1: ['ok', '✓', 'Approved'], 2: ['no', '↩', 'Dismissed — re-testing ×3'], 3: ['no', '↩', 'Feedback sent — returned to To Do'], 4: ['ar', '—', 'Archived'], 5: ['no', '↩', 'Reverted — change undone'] }[it.st];
8024
+ const done = { 1: ['ok', '✓', 'Approved'], 2: ['no', '↩', 'Rejected'], 3: ['no', '↩', 'Feedback sent — returned to To Do'], 4: ['ar', '—', 'Archived'], 5: ['no', '↩', 'Reverted — change undone'] }[it.st];
7813
8025
  if (done) {
7814
8026
  // Revert deliberately has NO undo: the PR is closed and its branch deleted —
7815
8027
  // an "UNDO" here would be a lie (HANDOFF-v45 §5.3 honesty over symmetry).
@@ -7878,7 +8090,7 @@ function saItemHtml(it, i){
7878
8090
  <div class="lg-ctx">${contextHtml}<div class="lg-fact"><span class="k">Done when</span><span class="v">${esc(it.check)}</span></div></div>
7879
8091
  ${evidenceHtml}
7880
8092
  ${riskHtml}
7881
- <div class="sa-act"><button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Dismiss</button><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" placeholder="What's wrong? Send back to Claude Code…"><button class="sa-send" data-saact="send:${i}" title="Send back"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
8093
+ <div class="sa-act"><button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Reject</button><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" placeholder="What's wrong? Send back to Claude Code…"><button class="sa-send" data-saact="send:${i}" title="Send back"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
7882
8094
  </div>`;
7883
8095
  }
7884
8096
  function saRender(){
@@ -7889,7 +8101,7 @@ function saRender(){
7889
8101
  const c = `${pending.length} ready · ${green === pending.length ? 'all green' : `${pending.length - green} failing`} · ${proofN} with proof`;
7890
8102
  const curIdx = pending.findIndex((x) => x.goalId === SA.cur);
7891
8103
  const posLabel = pending.length && curIdx >= 0 ? `${curIdx + 1} / ${pending.length}` : '';
7892
- S.innerHTML = `<div class="sa-panel"><div class="sa-hd"><span class="t">Review</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">↑↓ MOVE · A APPROVE · D DISMISS · ESC CLOSE</span><button class="sa-x" data-saact="close:0">✕</button></div>
8104
+ S.innerHTML = `<div class="sa-panel"><div class="sa-hd"><span class="t">Review</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">↑↓ MOVE · A APPROVE · D REJECT · ESC CLOSE</span><button class="sa-x" data-saact="close:0">✕</button></div>
7893
8105
  <div class="sa-body">${SA.items.length ? SA.items.map((it, i) => saItemHtml(it, i)).join('') : '<div class="sa-empty">Nothing waiting for review.</div>'}</div></div>`;
7894
8106
  // Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
7895
8107
  // track what's open per goal+section in SA.detOpen (persists across re-renders)
@@ -8031,7 +8243,7 @@ async function saAct(it, st, path, body){
8031
8243
  saSync();
8032
8244
  return true;
8033
8245
  }
8034
- const SA_UNDO = { 1: 'revert-review', 2: 'retest-cancel', 3: 'undismiss', 4: 'unarchive' };
8246
+ const SA_UNDO = { 1: 'revert-review', 2: 'reopen', 3: 'undismiss', 4: 'unarchive' }; // 07-20c: st 2 = Reject, undo = reopen
8035
8247
  $('seeall').addEventListener('mousedown', (e) => { if (e.target === $('seeall')) saClose(); }); // no-dim dialog: outside = close
8036
8248
  $('seeall').addEventListener('click', async (e) => {
8037
8249
  const b = e.target.closest('[data-saact]');
@@ -8045,8 +8257,14 @@ $('seeall').addEventListener('click', async (e) => {
8045
8257
  if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
8046
8258
  if (k === 'runlocal') { $('seeall').querySelector(`#runlocal${i}`)?.classList.toggle('open'); return; }
8047
8259
  // every verdict auto-advances the focus to the next pending card (tempo).
8048
- if (k === 'app') { const bx = b.getBoundingClientRect(); confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2); return saAct(it, 1, 'approve').then((ok) => { if (ok) saAdvanceFocus(it.goalId); }); }
8049
- if (k === 'dis') return saAct(it, 2, 'retest').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
8260
+ if (k === 'app') { const bx = b.getBoundingClientRect(); return saAct(it, 1, 'approve').then((ok) => {
8261
+ if (!ok) return;
8262
+ // 07-20c: if the goal has a PR it stays in review (needsMerge) — open the merge dialog
8263
+ // instead of celebrating/advancing; saCall already upserted the still-'review' goal.
8264
+ const g = state.goals.find((x) => x.id === it.goalId);
8265
+ if (g && g.status === 'review' && g.pr) { confirmMerge(g); return; }
8266
+ confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2); saAdvanceFocus(it.goalId); }); }
8267
+ if (k === 'dis') return saAct(it, 2, 'reject').then((ok) => { if (ok) saAdvanceFocus(it.goalId); }); // 07-20c: Reject → Rejected column
8050
8268
  if (k === 'arch') return saAct(it, 4, 'archive').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
8051
8269
  if (k === 'rev') return saAct(it, 5, 'revert').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
8052
8270
  if (k === 'send') {
@@ -8090,11 +8308,16 @@ document.addEventListener('keydown', (e) => {
8090
8308
  e.preventDefault();
8091
8309
  const gid = it.goalId;
8092
8310
  if (e.key === 'a' || e.key === 'A') {
8093
- const el = $('seeall').querySelector(`.sa-item[data-gid="${gid}"] .sa-app`);
8094
- const box = el?.getBoundingClientRect();
8095
- confettiBurst(box ? box.left + box.width / 2 : innerWidth / 2, box ? box.top + box.height / 2 : innerHeight / 3);
8096
- saAct(it, 1, 'approve').then((ok) => { if (ok) saAdvanceFocus(gid); });
8097
- } else saAct(it, 2, 'retest').then((ok) => { if (ok) saAdvanceFocus(gid); });
8311
+ saAct(it, 1, 'approve').then((ok) => {
8312
+ if (!ok) return;
8313
+ const g = state.goals.find((x) => x.id === gid);
8314
+ if (g && g.status === 'review' && g.pr) { confirmMerge(g); return; } // 07-20c: PR goal → merge dialog
8315
+ const el = $('seeall').querySelector(`.sa-item[data-gid="${gid}"] .sa-app`);
8316
+ const box = el?.getBoundingClientRect();
8317
+ confettiBurst(box ? box.left + box.width / 2 : innerWidth / 2, box ? box.top + box.height / 2 : innerHeight / 3);
8318
+ saAdvanceFocus(gid);
8319
+ });
8320
+ } else saAct(it, 2, 'reject').then((ok) => { if (ok) saAdvanceFocus(gid); }); // 07-20c: D = Reject
8098
8321
  });
8099
8322
  // 紙吹雪 (Approveの気持ちよさ=機能要件・PRD §5.1) — flagship confetti port:
8100
8323
  // 6 full-spectrum colours, ~90 small rects, gravity, gone in 1.4s.
@@ -8403,7 +8626,7 @@ let _delConfirmResolve = null;
8403
8626
  // Discard positioned left); dismissing the dialog must still land on the non-destructive choice
8404
8627
  // regardless of which label happens to sit in which slot.
8405
8628
  let _delDismissValue = false;
8406
- function confirmDelete({ title, body, preview, confirmLabel, cancelLabel, dismissValue = false, confirmDanger = true, cancelDanger = false } = {}){
8629
+ function confirmDelete({ title, body, preview, previewHtml, previewPlain = false, confirmLabel, cancelLabel, dismissValue = false, confirmDanger = true, cancelDanger = false } = {}){
8407
8630
  $('delModalTitle').textContent = title || 'Delete this goal?';
8408
8631
  $('delModalBody').textContent = body || "This can't be undone.";
8409
8632
  $('delConfirmBtn').textContent = confirmLabel || 'Delete';
@@ -8415,7 +8638,10 @@ function confirmDelete({ title, body, preview, confirmLabel, cancelLabel, dismis
8415
8638
  $('delConfirmBtn').className = 'mbtn' + (confirmDanger ? ' danger' : ' primary');
8416
8639
  $('delCancelBtn').className = 'mbtn' + (cancelDanger ? ' danger' : ' ghost');
8417
8640
  const pv = $('delPreviewTitle');
8418
- if (preview) { pv.textContent = preview; pv.style.display = ''; } else { pv.textContent = ''; pv.style.display = 'none'; }
8641
+ pv.classList.toggle('plain', !!previewPlain);
8642
+ if (previewHtml) { pv.innerHTML = previewHtml; pv.style.display = ''; }
8643
+ else if (preview) { pv.textContent = preview; pv.style.display = ''; }
8644
+ else { pv.textContent = ''; pv.style.display = 'none'; }
8419
8645
  _delDismissValue = dismissValue;
8420
8646
  $('deleteConfirmOverlay').classList.add('show');
8421
8647
  return new Promise((resolve) => { _delConfirmResolve = resolve; });
@@ -9152,7 +9378,6 @@ function gearSetHTML(){
9152
9378
  // to anonymous free users too. Sign-in is an extra (sync across devices), not a
9153
9379
  // gate to see your own usage or upgrade.
9154
9380
  const freeUsage = `<div class="srow"><div class="sl">Plan</div><span class="bx-sval">Free</span></div>
9155
- <div class="srow"><div class="sl">To Dos<span class="sd">pending, this install</span></div><span class="bx-sval bx-num">${u.pending ?? 0} / ${lim.pending ?? 5}</span></div>
9156
9381
  <div class="srow"><div class="sl">Lifetime</div><span class="bx-sval bx-num">${u.cumulative ?? 0} / ${lim.cumulative ?? 20}</span></div>
9157
9382
  <button class="bx-cta bx-up-set" data-gupgrade="1"><span class="bx-spin"></span>Upgrade${priceTag(bill.price)}</button>`;
9158
9383
  const billingRow = bill.plan === 'paid'
package/engine/lib.mjs CHANGED
@@ -877,6 +877,42 @@ export function buildGoalPrBody({ lang, goal, goalTasks, files, owner, branch, p
877
877
  ].join('\n');
878
878
  }
879
879
 
880
+ // summarizeForReview's prompt + reply parser, kept pure/here so engine/test can
881
+ // assert (a) the JSON asks for a work-title, (b) the title guidance names
882
+ // 修正/追加/改善/調査 so an "追加したい" request never becomes "…の修正", and (c) a
883
+ // failed/missing reply falls back to goal.text (no regression) — all without an
884
+ // LLM call. The app's saReviewTitle reads reviewSummary.title first (PR-B).
885
+ export function buildReviewSummaryPrompt({ lang, goalText, changed, reports }) {
886
+ return [
887
+ `あるゴールの実装が完了しました。レビューする人が一目で判断できるよう、やさしい${lang === 'ja' ? '日本語' : lang}で書いてください。`,
888
+ '専門用語・長い説明は禁止。小学生でも分かる短い言い方。JSONのみ出力(前後に文章を書かない):',
889
+ '{"title":"依頼を短い作業タイトルに言い換え(体言止め可)","check":"何を確認すれば良いか(1行)","changed":"何を変えたか(1行)"}',
890
+ 'titleは依頼文の意図を取り違えないこと。修正/追加/改善/調査を区別する。'
891
+ + '例:「Xの不具合」→「Xの修正」/「Yを追加したい」→「Yの追加」/「Zを速くしたい」→「Zの改善」/「なぜWか調べて」→「Wの調査」。',
892
+ '',
893
+ `ゴール: ${(goalText ?? '').slice(0, 300)}`,
894
+ `変更ファイル: ${(changed ?? []).join(', ') || '(なし)'}`,
895
+ `ワーカー報告:\n${(reports ?? '').slice(0, 1500)}`,
896
+ ].join('\n');
897
+ }
898
+
899
+ // Parse the model's JSON into {title, check, changed}, per-field fallback when a
900
+ // key is missing/blank. `fallback` carries the text-derived defaults
901
+ // (title/changed = goal.text) so a goal whose summary call failed still shows
902
+ // its own request text. Never throws — a broken/absent JSON yields the fallback.
903
+ export function parseReviewSummary(raw, fallback = {}) {
904
+ const clean = (v, n) => String(v ?? '').replace(/\s+/g, ' ').slice(0, n);
905
+ const m = String(raw ?? '').match(/\{[\s\S]*\}/);
906
+ if (!m) return { ...fallback };
907
+ let o;
908
+ try { o = JSON.parse(m[0]); } catch { return { ...fallback }; }
909
+ return {
910
+ title: clean(o.title, 60) || fallback.title,
911
+ check: clean(o.check, 120),
912
+ changed: clean(o.changed, 120) || fallback.changed,
913
+ };
914
+ }
915
+
880
916
  // A finished task's changed files look like a UI edit: app/ (the Manager's
881
917
  // own UI dir, or an equivalent frontend root) plus common frontend source
882
918
  // extensions. Pure/deterministic — no LLM — so it can gate proof capture
@@ -1139,6 +1175,7 @@ export function goalGroupStatus(goalStatus, startedAt) {
1139
1175
  if (goalStatus === 'folded') return 'review'; // PRD §6.5: rendered inside the Review section, not Done
1140
1176
  if (goalStatus === 'blocked') return 'attn'; // §4E: blocked is Attention, not Done
1141
1177
  if (goalStatus === 'needsInput') return 'todo'; // smart intake: awaiting an answer
1178
+ if (goalStatus === 'rejected') return 'rejected'; // 07-20c: Reject を溜める独立欄(Done ではない)
1142
1179
  return 'done'; // includes 'reverted' (terminal)
1143
1180
  }
1144
1181
 
@@ -1495,6 +1532,8 @@ export function isNothingVerifiable({ changedFiles = [], hasPassCondition = fals
1495
1532
  // 人がアプリ側から閉じる経路。
1496
1533
  export function advanceReviewGoal(goal) {
1497
1534
  if (goal.status !== 'review') return { ok: false, error: 'goal is not in review' };
1535
+ // 07-20c: PR ありゴールは Approve だけで done にしない(merge=採用が唯一の完了トリガー)。
1536
+ if (goal && goal.pr) return { ok: true, needsMerge: true, status: 'review' };
1498
1537
  return { ok: true, status: 'done' };
1499
1538
  }
1500
1539
 
@@ -1508,20 +1547,39 @@ export function revertReviewGoal(goal) {
1508
1547
  // レビュー詳細パネル(task 70)のApprove/Dismissボタンの判定。advanceReviewGoal(task 62,
1509
1548
  // チェックリスト全件Approveの自動進行)とは別に、人間がレビュー画面から単発で下す判断
1510
1549
  // を扱う——PRの有無を問わず、レビュー中のgoalだけが対象。
1511
- // Approveはそのままdoneへ。goalGroupStatus/goalChipのdone分岐(review以外は全部doneと
1512
- // 読む)と揃えているので、doneに寄せて問題ない。
1513
- export function approveGoal({ goalStatus }) {
1550
+ // Approve は PR の有無で分岐する(07-20c)。PR 無しは従来どおり即 done。PR ありは
1551
+ // Approve だけで done にせず needsMerge を返す——呼び出し側が「PR をマージして完了」
1552
+ // ダイアログを出して PR へ飛ばし、マージ後に syncGoalMerges が review→done を自動確定
1553
+ // する。これで「アプリで Approve 済みだが PR は開いたまま」のゾンビを構造的に消す
1554
+ // (merge=採用は人の操作、の鉄則を守る)。
1555
+ export function approveGoal({ goalStatus, hasPr = false }) {
1514
1556
  if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
1557
+ if (hasPr) return { ok: true, needsMerge: true, status: 'review' };
1515
1558
  return { ok: true, status: 'done' };
1516
1559
  }
1517
1560
 
1518
- // Dismiss(差し戻し)は 'running' に戻す——goalGroupStatus/goalChipは 'running'
1519
- // startedAt済みなら'doing'バケットに読むので、これでレビュー中断→作業中の表示に戻る。
1561
+ // Dismiss(差し戻し)は 'running' に戻す——チャット(/reply /dismiss にテキスト)での
1562
+ // 「作り直し」依頼の受け皿。goalGroupStatus は startedAt 済みなら 'doing' に読む。
1520
1563
  export function dismissGoal({ goalStatus }) {
1521
1564
  if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
1522
1565
  return { ok: true, status: 'running' };
1523
1566
  }
1524
1567
 
1568
+ // Reject(07-20c・Dismiss とは別物)は自動再作業をせず、Rejected 欄に溜める 'rejected'
1569
+ // へ park する。作り直しはチャット(/reply)で依頼する。Rejected からは reopenGoal で
1570
+ // review へ戻せ、Delete(=revert)で PR ごと破棄できる。/dismiss(rework)とは別の
1571
+ // /reject エンドポイントが担うので、既存のチャット rework 契約を壊さない。
1572
+ export function rejectGoal({ goalStatus }) {
1573
+ if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
1574
+ return { ok: true, status: 'rejected' };
1575
+ }
1576
+
1577
+ // Rejected 欄の『Reopen』——rejected を review に戻す(rejectGoal の逆操作)。
1578
+ export function reopenGoal({ goalStatus }) {
1579
+ if (goalStatus !== 'rejected') return { ok: false, error: 'goal is not rejected' };
1580
+ return { ok: true, status: 'review' };
1581
+ }
1582
+
1525
1583
  // ---- MODE (Auto / Plan) ----------------------------------------------------
1526
1584
  // A goal runs in one of two modes. 'auto' = the default implement→verify→review
1527
1585
  // flow (worker edits files with acceptEdits). 'plan' = the worker runs under
@@ -2461,6 +2519,8 @@ export function goalLogLine(goal, prevGoal) {
2461
2519
  const label = String(goal?.plan?.[0] ?? goal?.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 60);
2462
2520
  if (!prevGoal) return goal?.status === 'pending' ? `Goal shelved to Pending: "${label}"` : null;
2463
2521
  if (prevGoal.status === 'review' && goal.status === 'done') return `Approved: "${label}"`;
2522
+ if (prevGoal.status === 'review' && goal.status === 'rejected') return `Rejected: "${label}"`;
2523
+ if (prevGoal.status === 'rejected' && goal.status === 'review') return `Reopened: "${label}"`;
2464
2524
  if (prevGoal.status === 'review' && goal.status === 'running') return `Dismissed: "${label}" — back to work`;
2465
2525
  if (prevGoal.status !== 'running' && goal.status === 'running' && Array.isArray(goal.plan) && goal.plan.length) {
2466
2526
  return `Goal decomposed into ${goal.plan.length} task(s) → To do: "${label}"`;
@@ -2564,8 +2624,10 @@ export function computeRetestOutcome(results = [], total = 3) {
2564
2624
  // terminal 'reverted' status (distinct from 'skipped'/archived). Decision only —
2565
2625
  // the destructive gh/git calls live behind the server's small wrapper.
2566
2626
  export function revertGoal({ goalStatus }) {
2567
- if (!['review', 'done', 'blocked', 'partial'].includes(goalStatus)) {
2568
- return { ok: false, error: 'only a finished (review/done/blocked/partial) goal can be reverted' };
2627
+ // 07-20c: 'rejected' も対象に含める——Rejected 欄の Delete は revert 経由で PR を閉じ
2628
+ // worktree を落とす(PR を残したまま消すと孤児 PR =別のゾンビになるため)。
2629
+ if (!['review', 'done', 'blocked', 'partial', 'rejected'].includes(goalStatus)) {
2630
+ return { ok: false, error: 'only a finished (review/done/blocked/partial/rejected) goal can be reverted' };
2569
2631
  }
2570
2632
  return { ok: true, status: 'reverted' };
2571
2633
  }
@@ -2903,8 +2965,11 @@ export function nextResumeDelay(attempt, { baseMs = 5 * 60 * 1000, maxMs = 60 *
2903
2965
  // tested — sharing a module across that boundary would couple two things
2904
2966
  // that fail and deploy on different schedules. Keep the two copies in sync by
2905
2967
  // hand; both have their own test coverage against the same free-tier numbers.
2968
+ // maxPendingGoals (the 5 concurrent "queued to-do" cap) was REMOVED 2026-07-20
2969
+ // (Masa): the free tier no longer caps how many to-dos run at once, and the UI
2970
+ // drops the "x / 5" counter. The lifetime (20) and project (1) caps remain the
2971
+ // monetization wall.
2906
2972
  export const FREE_TIER_LIMITS = Object.freeze({
2907
- maxPendingGoals: 5,
2908
2973
  maxCumulativeGoals: 20,
2909
2974
  maxProjects: 1,
2910
2975
  });
@@ -2921,12 +2986,10 @@ export const QUEUED_GOAL_STATUSES = Object.freeze([
2921
2986
  ]);
2922
2987
 
2923
2988
  export function checkFreeTierLimit(usage) {
2924
- const pendingCount = Number(usage?.pendingCount) || 0;
2925
2989
  const cumulativeCount = Number(usage?.cumulativeCount) || 0;
2926
2990
  const projectCount = Number(usage?.projectCount) || 0;
2927
- if (pendingCount > FREE_TIER_LIMITS.maxPendingGoals) {
2928
- return { blocked: true, reason: 'pending-goal-limit', limit: FREE_TIER_LIMITS.maxPendingGoals, value: pendingCount };
2929
- }
2991
+ // Concurrent "pending" to-dos are no longer capped (Masa 2026-07-20) — only
2992
+ // the lifetime total and the project count gate the free tier.
2930
2993
  if (cumulativeCount > FREE_TIER_LIMITS.maxCumulativeGoals) {
2931
2994
  return { blocked: true, reason: 'cumulative-goal-limit', limit: FREE_TIER_LIMITS.maxCumulativeGoals, value: cumulativeCount };
2932
2995
  }
@@ -3509,3 +3572,15 @@ export function parseClaimResponse(obj) {
3509
3572
  if (obj.token) return { state: 'ready', token: String(obj.token), email: obj.email != null ? String(obj.email) : undefined };
3510
3573
  return { state: 'pending' };
3511
3574
  }
3575
+
3576
+ // --- connection status line (queue tray) -------------------------------------
3577
+ // Pure source of truth for the offline/reconnecting line that renders as the top
3578
+ // row of the queued tray above the composer. app/index.html is a browser static
3579
+ // file that can't import this module, so it MIRRORS these two tiny functions
3580
+ // inline (see renderQueue()); keep the logic identical on both sides.
3581
+ export function connectionBannerState({ online }) {
3582
+ return online ? { kind: 'ok' } : { kind: 'offline', reconnect: true };
3583
+ }
3584
+ export function unsentMetaLabel({ online }) {
3585
+ return online ? 'Unsent — tap to resend' : 'Waiting for connection';
3586
+ }
package/engine/server.mjs CHANGED
@@ -21,7 +21,7 @@ import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
24
- import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot } from './lib.mjs';
24
+ import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot } from './lib.mjs';
25
25
  import { openPR } from './pr.mjs';
26
26
  import { runVerification, exerciseUi } from './verify.mjs';
27
27
 
@@ -2604,21 +2604,19 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
2604
2604
  const lang = reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : (reviewDefinition.language || 'ja');
2605
2605
  const changed = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].slice(0, 20);
2606
2606
  const reports = siblings.filter((t) => !t.reply).map((t) => `- ${t.title}: ${(t.result ?? '').replace(/\s+/g, ' ').slice(0, 300)}`).join('\n');
2607
- const fallback = { check: '', changed: (goal.plan?.[0] ?? goal.text ?? '').replace(/\s+/g, ' ').slice(0, 80) };
2607
+ // fallback = current behavior: title/changed derived from the goal's own text,
2608
+ // so a goal with no summary (AI failed or nothing to summarize) still shows a
2609
+ // meaningful title/changed and never a blank or mis-phrased "…の修正".
2610
+ const fallback = {
2611
+ title: (goal.text ?? '').replace(/\s+/g, ' ').slice(0, 60),
2612
+ check: '',
2613
+ changed: (goal.plan?.[0] ?? goal.text ?? '').replace(/\s+/g, ' ').slice(0, 80),
2614
+ };
2608
2615
  if (!changed.length && !reports) return fallback;
2609
- const prompt = [
2610
- `あるゴールの実装が完了しました。レビューする人が一目で判断できるよう、やさしい${lang === 'ja' ? '日本語' : lang}で2行だけ書いてください。`,
2611
- '専門用語・長い説明は禁止。小学生でも分かる短い言い方。JSONのみ出力(前後に文章を書かない):',
2612
- '{"check":"何を確認すれば良いか(1行)","changed":"何を変えたか(1行)"}',
2613
- '',
2614
- `ゴール: ${(goal.text ?? '').slice(0, 300)}`,
2615
- `変更ファイル: ${changed.join(', ') || '(なし)'}`,
2616
- `ワーカー報告:\n${reports.slice(0, 1500)}`,
2617
- ].join('\n');
2616
+ const prompt = buildReviewSummaryPrompt({ lang, goalText: goal.text, changed, reports });
2618
2617
  try {
2619
2618
  const r = await runUtility({ prompt, cwd: dir, tools: 'Read' });
2620
- const m = r.result.match(/\{[\s\S]*\}/);
2621
- if (m) { const o = JSON.parse(m[0]); return { check: String(o.check ?? '').replace(/\s+/g, ' ').slice(0, 120), changed: String(o.changed ?? '').replace(/\s+/g, ' ').slice(0, 120) || fallback.changed }; }
2619
+ return parseReviewSummary(r.result, fallback);
2622
2620
  } catch { /* fall through */ }
2623
2621
  return fallback;
2624
2622
  }
@@ -3177,8 +3175,8 @@ const server = createServer(async (req, res) => {
3177
3175
  email: licenseState.email,
3178
3176
  isPaying: Boolean(licenseState.isPaying),
3179
3177
  plan: licenseState.isPaying ? 'paid' : 'free',
3180
- usage: { pending: freeUsage.pendingCount, cumulative: freeUsage.cumulativeCount },
3181
- limits: { pending: FREE_TIER_LIMITS.maxPendingGoals, cumulative: FREE_TIER_LIMITS.maxCumulativeGoals },
3178
+ usage: { cumulative: freeUsage.cumulativeCount },
3179
+ limits: { cumulative: FREE_TIER_LIMITS.maxCumulativeGoals },
3182
3180
  price: await getBillingPrice(),
3183
3181
  siteUrl: BILLING_API_URL || null,
3184
3182
  };
@@ -3786,6 +3784,9 @@ const server = createServer(async (req, res) => {
3786
3784
  if (!requireGoalOwnership(req, res, goal)) return;
3787
3785
  const result = advanceReviewGoal(goal);
3788
3786
  if (!result.ok) return json(res, 409, { error: result.error });
3787
+ // 07-20c: PR ありは done にせず「PR をマージして完了」を促す。status は review の
3788
+ // まま。マージ後に syncGoalMerges が done を確定する。
3789
+ if (result.needsMerge) return json(res, 200, { goal, needsMerge: true, pr: goal.pr });
3789
3790
  goal.status = result.status;
3790
3791
  saveGoal(goal);
3791
3792
  return json(res, 200, goal);
@@ -4047,8 +4048,12 @@ const server = createServer(async (req, res) => {
4047
4048
  pump(goal.projectId);
4048
4049
  return json(res, 200, goal);
4049
4050
  }
4050
- const result = approveGoal({ goalStatus: goal.status });
4051
+ const result = approveGoal({ goalStatus: goal.status, hasPr: !!goal.pr });
4051
4052
  if (!result.ok) return json(res, 409, { error: result.error });
4053
+ // 07-20c: PR ありは Approve だけで done にしない。status は review のまま返し、
4054
+ // クライアントは「PR をマージして完了してください」ダイアログ+ Open PR を出す。
4055
+ // マージされたら syncGoalMerges が review→done を自動確定する。
4056
+ if (result.needsMerge) return json(res, 200, { goal, needsMerge: true, pr: goal.pr });
4052
4057
  goal.status = result.status;
4053
4058
  saveGoal(goal);
4054
4059
  return json(res, 200, goal);
@@ -4228,6 +4233,46 @@ const server = createServer(async (req, res) => {
4228
4233
  return;
4229
4234
  }
4230
4235
 
4236
+ // 07-20c: Reject — park a review goal in the Rejected column. No auto-rework
4237
+ // (rework is asked for via chat /reply or /dismiss). Kept as a SEPARATE
4238
+ // endpoint from /dismiss so the existing chat-rework contract is untouched.
4239
+ // An optional text is kept as a rejection note; no worker is spawned.
4240
+ const rejectMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/reject$/);
4241
+ if (rejectMatch && req.method === 'POST') {
4242
+ const goal = goals.find((g) => g.id === Number(rejectMatch[1]));
4243
+ if (!goal) return json(res, 404, { error: 'goal not found' });
4244
+ if (!requireGoalOwnership(req, res, goal)) return;
4245
+ const result = rejectGoal({ goalStatus: goal.status });
4246
+ if (!result.ok) return json(res, 409, { error: result.error });
4247
+ let body = '';
4248
+ req.on('data', (d) => { body += d; });
4249
+ req.on('end', () => {
4250
+ let text = '';
4251
+ try { text = String(JSON.parse(body || '{}').text ?? '').trim(); } catch {}
4252
+ goal.status = result.status;
4253
+ if (text) goal.rejectNote = text;
4254
+ goal.rejectedAt = new Date().toISOString();
4255
+ saveGoal(goal);
4256
+ json(res, 200, { goal });
4257
+ });
4258
+ return;
4259
+ }
4260
+
4261
+ // 07-20c: Rejected 欄の『Reopen』——rejected を review へ戻す(Reject の取り消し)。
4262
+ // Delete(破棄)は既存の /revert(PR を閉じ worktree を落とす)を使う。
4263
+ const reopenMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/reopen$/);
4264
+ if (reopenMatch && req.method === 'POST') {
4265
+ const goal = goals.find((g) => g.id === Number(reopenMatch[1]));
4266
+ if (!goal) return json(res, 404, { error: 'goal not found' });
4267
+ if (!requireGoalOwnership(req, res, goal)) return;
4268
+ const result = reopenGoal({ goalStatus: goal.status });
4269
+ if (!result.ok) return json(res, 409, { error: result.error });
4270
+ goal.status = result.status;
4271
+ goal.rejectNote = null; goal.rejectedAt = null;
4272
+ saveGoal(goal);
4273
+ return json(res, 200, goal);
4274
+ }
4275
+
4231
4276
  // image attachments: upload (base64 JSON) + serve back for thumbnails
4232
4277
  if (url.pathname === '/api/upload' && req.method === 'POST') {
4233
4278
  let body = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.36",
3
+ "version": "0.10.37",
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": {