@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 +281 -56
- package/engine/lib.mjs +87 -12
- package/engine/server.mjs +61 -16
- package/package.json +1 -1
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">
|
|
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">
|
|
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:
|
|
3325
|
-
//
|
|
3326
|
-
//
|
|
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?.
|
|
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
|
-
|
|
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
|
-
|
|
4000
|
+
// mirror unsentMetaLabel({online}) from engine/lib.mjs — offline 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
|
-
|
|
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
|
|
4317
|
-
|
|
4318
|
-
|
|
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}/
|
|
4323
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
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
|
|
6050
|
-
|
|
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
|
-
|
|
6053
|
-
|
|
6054
|
-
if (r && r.ok) await refresh(); else showErr('
|
|
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
|
-
${
|
|
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 = () => {
|
|
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
|
-
|
|
7114
|
-
es
|
|
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
|
|
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
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
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
|
-
|
|
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', '↩', '
|
|
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}">
|
|
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
|
|
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: '
|
|
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();
|
|
8049
|
-
|
|
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
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
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
|
-
|
|
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
|
|
1512
|
-
//
|
|
1513
|
-
|
|
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'
|
|
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
|
-
|
|
2568
|
-
|
|
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
|
-
|
|
2928
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
3181
|
-
limits: {
|
|
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