@galda/cli 0.10.72 → 0.10.73
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 +210 -189
- package/engine/lib.mjs +101 -8
- package/engine/server.mjs +72 -4
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -213,7 +213,7 @@
|
|
|
213
213
|
|
|
214
214
|
.errbar{position:absolute;left:50%;bottom:96px;transform:translateX(-50%);z-index:40;background:var(--errbg);backdrop-filter:blur(14px);
|
|
215
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) */
|
|
216
|
+
.errbar.ok{background:rgba(var(--green-rgb),.12);border-color:rgba(var(--green-rgb),.45);color:var(--green)} /* 07-20c: neutral/success toast (PR merged) */
|
|
217
217
|
|
|
218
218
|
/* composer */
|
|
219
219
|
.composer-wrap{flex:0 0 auto;padding:8px 22px 18px}
|
|
@@ -296,21 +296,6 @@
|
|
|
296
296
|
.ctxseg:hover{color:var(--ink)} /* glyph colour only — no surface (§5.5) */
|
|
297
297
|
.ctxseg svg{width:13px;height:13px;flex:0 0 13px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
|
298
298
|
.ctxseg b{font-weight:600;font-size:11.5px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;max-width:300px}
|
|
299
|
-
.ctxdiv{width:1px;align-self:stretch;margin:5px 0;background:var(--hair);flex:0 0 1px}
|
|
300
|
-
/* workspace mode — isolated worktree is the default; direct workspace is explicit.
|
|
301
|
-
ON is --ink, never --green: in this product green means "it passed" (PR green,
|
|
302
|
-
tests green, Token green), and a settings checkbox borrowing it makes that green cheap.
|
|
303
|
-
The tick is knocked out in --panel (the surface it sits on), NOT --invink. */
|
|
304
|
-
.ctxwt{font-size:11.5px;color:var(--ink2)}
|
|
305
|
-
.ctxwt .ctxbox{width:11px;height:11px;flex:0 0 11px;border-radius:3px;border:1px solid var(--ink3);
|
|
306
|
-
display:inline-block;position:relative;transition:background .12s,border-color .12s}
|
|
307
|
-
.ctxwt[aria-checked="true"]{color:var(--ink2)}
|
|
308
|
-
.ctxwt[aria-checked="true"] .ctxbox{background:var(--ink);border-color:var(--ink)}
|
|
309
|
-
.ctxwt[aria-checked="true"] .ctxbox::after{content:"";position:absolute;left:3px;top:0;width:3px;height:7px;
|
|
310
|
-
border:solid var(--panel);border-width:0 1.6px 1.6px 0;transform:rotate(42deg)}
|
|
311
|
-
.ctxworkwarn{display:flex;align-items:center;gap:6px;margin:-3px 2px 6px;color:var(--amber);
|
|
312
|
-
font:500 11.5px/1.35 var(--ui)}
|
|
313
|
-
.ctxworkwarn[hidden]{display:none}
|
|
314
299
|
.ctxmenu{min-width:230px;max-width:420px}
|
|
315
300
|
/* The folder list is galda's own screen (Claude has none — it opens ONE folder; galda
|
|
316
301
|
moves between repos). The one thing that actually goes wrong is that names alone look
|
|
@@ -1507,15 +1492,6 @@
|
|
|
1507
1492
|
background:var(--ink);animation:ccblink 1.05s steps(1) infinite}
|
|
1508
1493
|
@keyframes ccblink{50%{opacity:0}}
|
|
1509
1494
|
@media (prefers-reduced-motion:reduce){#fsRoot .cc-caret{animation:none}}
|
|
1510
|
-
/* "→ Added to To Do #NN" — shown quietly in the flow when a turn became a task.
|
|
1511
|
-
Undo appears on hover only (§1.5-10). No filled surface. */
|
|
1512
|
-
#fsRoot .cc-mark{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px;color:var(--ink3);padding:1px 0}
|
|
1513
|
-
#fsRoot .cc-mark .cm-ar{color:var(--ink2);font-size:12.5px;line-height:1}
|
|
1514
|
-
#fsRoot .cc-mark .cm-t{color:var(--ink2)}
|
|
1515
|
-
#fsRoot .cc-mark .cm-no{color:var(--ink3);font-variant-numeric:tabular-nums}
|
|
1516
|
-
#fsRoot .cc-mark .cm-undo{margin-left:2px;padding:0;border:0;background:none;font:inherit;color:var(--ink3);cursor:pointer;opacity:0;transition:opacity .15s,color .15s}
|
|
1517
|
-
#fsRoot .cc-mark:hover .cm-undo{opacity:1}
|
|
1518
|
-
#fsRoot .cc-mark .cm-undo:hover{color:var(--blue)}
|
|
1519
1495
|
/* Claude Code CLI language (HANDOFF-v45 §12, flagship .cc-t port): gap:0 — the bullet's
|
|
1520
1496
|
own margin-right does the spacing, not a flex gap (keeps ⏺ tight against the tool name
|
|
1521
1497
|
the way the real CLI reads). */
|
|
@@ -2134,6 +2110,10 @@
|
|
|
2134
2110
|
/* body prose = the app's normal text: 14px / −.012em, matching .bubble (Masa 2026-07-20:
|
|
2135
2111
|
13px read thinner/lighter for Japanese than the surrounding app). §2. */
|
|
2136
2112
|
.redesign .rbody{font-size:14px;line-height:1.62;letter-spacing:-.012em;color:var(--ink);max-width:70ch;margin:0 !important}
|
|
2113
|
+
/* Request text is the human's own free-form message (goal #431): it can contain blank
|
|
2114
|
+
lines and numbered lists. esc() already neutralizes HTML, so pre-wrap just keeps the
|
|
2115
|
+
line breaks the person actually typed instead of HTML collapsing them into one line. */
|
|
2116
|
+
.redesign .rbody.rask{white-space:pre-wrap}
|
|
2137
2117
|
.redesign .rinfra{padding:10px 12px;border:1px solid rgba(var(--amber-rgb),.35);border-radius:8px;background:rgba(var(--amber-rgb),.06)}
|
|
2138
2118
|
.redesign .rinfra .rbody{color:var(--ink2)}
|
|
2139
2119
|
.redesign .rinfra .retrybtn{margin-top:8px;margin-left:0}
|
|
@@ -2982,13 +2962,8 @@
|
|
|
2982
2962
|
<div class="ctxchip ctxbrgrp" id="ctxbrgrp">
|
|
2983
2963
|
<span class="ctxseg ctxbranch" id="ctxbranch" title="Branch this folder is on">
|
|
2984
2964
|
<svg viewBox="0 0 24 24"><path d="M6 3v12M6 15a3 3 0 100 6 3 3 0 000-6zM18 9a3 3 0 100-6 3 3 0 000 6zM18 9v3a4 4 0 01-4 4H8"/></svg><span id="ctxbranchname"></span></span>
|
|
2985
|
-
<span class="ctxdiv"></span>
|
|
2986
|
-
<button type="button" class="ctxseg ctxwt" id="ctxwt" role="checkbox" aria-checked="true"
|
|
2987
|
-
title="isolated worktree: default clean copy. direct workspace: inherits and edits this folder.">
|
|
2988
|
-
<i class="ctxbox"></i><span id="ctxwtlabel">isolated worktree</span></button>
|
|
2989
2965
|
</div>
|
|
2990
2966
|
</div>
|
|
2991
|
-
<div class="ctxworkwarn" id="ctxworkwarn" hidden></div>
|
|
2992
2967
|
<!-- Conversation-first composer (SLICE 2, docs/HANDOFF-ONBOARDING-conversational.md):
|
|
2993
2968
|
a chat/help/settings message gets a quiet reply HERE instead of
|
|
2994
2969
|
becoming a To-Do. Reuses the .peraanswer surface; hidden until a
|
|
@@ -3375,8 +3350,20 @@ const savedTheme = (() => {
|
|
|
3375
3350
|
const state = { repos: null, repoHome: '', /* GET /api/repos — the folders a project can point at (Context Bar) */ projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: localStorage.getItem('sel:project') || 'default', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], auth: null, expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, runningCounts: {}, parallelLimits: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, boardskin: localStorage.getItem('boardskin') || 'auto', summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null, projectRules: {}, rulesOpen: false,
|
|
3376
3351
|
// per-message send overrides set via slash-commands (reset after each send,
|
|
3377
3352
|
// except model which is sticky in localStorage). palette = open command list.
|
|
3353
|
+
// Scoped per project (msgByProject) so a skill/mode picked in one project can't leak
|
|
3354
|
+
// into another one just switched to — state.msg is always the CURRENT project's slot;
|
|
3355
|
+
// switching projects must go through setActiveProject(), never a bare state.active = .
|
|
3378
3356
|
msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
|
|
3357
|
+
msgByProject: {},
|
|
3379
3358
|
stickyModel: localStorage.getItem('sel:model') || 'sonnet', stickyEffort: localStorage.getItem('sel:effort') || 'medium', palette: null };
|
|
3359
|
+
state.msgByProject[state.active] = state.msg;
|
|
3360
|
+
function emptyMsg(){ return { mode: null, model: null, effort: null, skill: null, later: false, review: false }; }
|
|
3361
|
+
function setActiveProject(id){
|
|
3362
|
+
if (id === state.active) return;
|
|
3363
|
+
state.msgByProject[state.active] = state.msg;
|
|
3364
|
+
state.active = id;
|
|
3365
|
+
state.msg = state.msgByProject[id] || (state.msgByProject[id] = emptyMsg());
|
|
3366
|
+
}
|
|
3380
3367
|
window.state = state; // top-level const isn't a window property; external checks probe window.state
|
|
3381
3368
|
const $ = (id) => document.getElementById(id);
|
|
3382
3369
|
|
|
@@ -3901,10 +3888,13 @@ function tokenBaselinePerRequirementLocal(history){
|
|
|
3901
3888
|
return samples.reduce((a, b) => a + b, 0) / samples.length;
|
|
3902
3889
|
}
|
|
3903
3890
|
// Mirrors engine/lib.mjs estimateTokenOptimization() — see the comment there
|
|
3904
|
-
// for why
|
|
3905
|
-
//
|
|
3906
|
-
//
|
|
3907
|
-
//
|
|
3891
|
+
// for why a positive `baseline` percent is only labeled "saved" (basis:
|
|
3892
|
+
// 'baseline') when a real technique fired this run (cache reuse, a fresh
|
|
3893
|
+
// handoff, /compact, /clear). Without one, the same comparison ships as
|
|
3894
|
+
// basis: 'baseline-untied' — a true fact (smaller/larger than usual) but
|
|
3895
|
+
// not a claim that anything the user did caused it (Masa 2026-07-26; a
|
|
3896
|
+
// small goal right after big ones scored a huge "saved" purely from the
|
|
3897
|
+
// size mix). With no history at all it's basis: 'no-baseline' (no number).
|
|
3908
3898
|
function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null, requirementCount = 1, history = [] } = {}){
|
|
3909
3899
|
const u = usage ?? {};
|
|
3910
3900
|
const raw = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
|
|
@@ -3913,15 +3903,11 @@ function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, c
|
|
|
3913
3903
|
const reqN = Math.max(1, Number(requirementCount) || 1);
|
|
3914
3904
|
const perReq = weighted / reqN;
|
|
3915
3905
|
const baseline = tokenBaselinePerRequirementLocal(history);
|
|
3916
|
-
let percent, basis, savedTokens;
|
|
3906
|
+
let percent = null, basis = 'no-baseline', savedTokens = null;
|
|
3917
3907
|
if (baseline != null) {
|
|
3918
3908
|
percent = Math.max(-100, Math.min(100, Math.round(((baseline - perReq) / baseline) * 100)));
|
|
3919
3909
|
basis = 'baseline';
|
|
3920
3910
|
savedTokens = Math.max(0, Math.round((baseline - perReq) * reqN));
|
|
3921
|
-
} else {
|
|
3922
|
-
savedTokens = Math.max(0, raw - weighted);
|
|
3923
|
-
percent = Math.round((savedTokens / raw) * 100);
|
|
3924
|
-
basis = 'cache-weight';
|
|
3925
3911
|
}
|
|
3926
3912
|
const techniques = [];
|
|
3927
3913
|
const cacheRatio = Math.round(((u.cache_read_input_tokens ?? 0) / raw) * 100);
|
|
@@ -3929,8 +3915,10 @@ function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, c
|
|
|
3929
3915
|
if (usedHandoff || sessionMode === 'cold-handoff') techniques.push('fresh handoff instead of a bloated session');
|
|
3930
3916
|
if (usedCompact || sessionMode === 'compact') techniques.push('/compact before continuing');
|
|
3931
3917
|
if (usedClear) techniques.push('/clear between unrelated goals');
|
|
3932
|
-
|
|
3918
|
+
const techniqueDriven = cacheRatio > 0 || usedHandoff || sessionMode === 'cold-handoff' || usedCompact || sessionMode === 'compact' || usedClear;
|
|
3919
|
+
if (basis === 'baseline' && !techniqueDriven) basis = 'baseline-untied';
|
|
3933
3920
|
if (basis === 'baseline' && percent > 0) techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
|
|
3921
|
+
if (basis === 'baseline-untied') techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
|
|
3934
3922
|
if (!techniques.length) techniques.push(`${Math.round(raw / 1000)}k tokens this run (no cache reuse yet)`);
|
|
3935
3923
|
return { rawTokens: raw, weightedTokens: weighted, savedTokens, percent, basis, techniques: [...new Set(techniques)] };
|
|
3936
3924
|
}
|
|
@@ -3938,6 +3926,10 @@ function tokenOptimizationHtml(opt){
|
|
|
3938
3926
|
if (!opt) return '';
|
|
3939
3927
|
const techniques = opt.techniques?.length ? opt.techniques.slice(0, 4).join(' / ') : 'this run';
|
|
3940
3928
|
const pct = Number(opt.percent);
|
|
3929
|
+
if (opt.basis === 'baseline-untied') {
|
|
3930
|
+
const dir = pct > 0 ? 'smaller' : 'larger';
|
|
3931
|
+
return `<div class="rvtokenopt">${esc(`Token usage: this run was ${dir} than your recent average (${techniques}) — no cache/compact/handoff technique behind it`)}</div>`;
|
|
3932
|
+
}
|
|
3941
3933
|
const cmp = opt.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
|
|
3942
3934
|
const text = pct > 0
|
|
3943
3935
|
? `Token optimized: saved about ${pct}% ${cmp} (${techniques})`
|
|
@@ -3949,7 +3941,7 @@ function renderProjects(){
|
|
|
3949
3941
|
const busy = state.tasks.filter((t) => t.projectId === p.id && ['running','queued'].includes(t.status)).length;
|
|
3950
3942
|
return `<button class="proj ${p.id === state.active ? 'active' : ''}" data-id="${p.id}">${esc(p.name)}${busy ? `<span class="n">${busy}</span>` : ''}</button>`;
|
|
3951
3943
|
}).join('');
|
|
3952
|
-
for (const b of document.querySelectorAll('.proj')) b.onclick = () => {
|
|
3944
|
+
for (const b of document.querySelectorAll('.proj')) b.onclick = () => { setActiveProject(b.dataset.id); render(); };
|
|
3953
3945
|
}
|
|
3954
3946
|
|
|
3955
3947
|
function proofMediaHtml(taskId, proof, altLabel){
|
|
@@ -4372,8 +4364,9 @@ function renderStream(){
|
|
|
4372
4364
|
inp.addEventListener('keydown', async (e) => {
|
|
4373
4365
|
if (e.key !== 'Enter' || e.isComposing || !inp.value.trim()) return;
|
|
4374
4366
|
const text = inp.value.trim(); inp.value = ''; inp.disabled = true;
|
|
4375
|
-
|
|
4376
|
-
headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text })) });
|
|
4367
|
+
let r; try { r = await fetch(withKey(`/api/goals/${inp.dataset.goal}/reply`), { method: 'POST',
|
|
4368
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text })) }); }
|
|
4369
|
+
catch { showErr('Reply failed — the goal may not have started yet.'); inp.disabled = false; return; }
|
|
4377
4370
|
if (!r.ok) showErr('Reply failed — the goal may not have started yet.');
|
|
4378
4371
|
inp.disabled = false;
|
|
4379
4372
|
});
|
|
@@ -4498,18 +4491,22 @@ function renderQueue(){
|
|
|
4498
4491
|
const text = document.getElementById(`qedit-${id}`).value.trim();
|
|
4499
4492
|
const pr = document.getElementById(`qedit-pr-${id}`).dataset.val;
|
|
4500
4493
|
if (text) {
|
|
4501
|
-
|
|
4502
|
-
|
|
4494
|
+
try {
|
|
4495
|
+
const r = await fetch(withKey(`/api/goals/${id}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, pr }) });
|
|
4496
|
+
if (r.ok) upsert(state.goals, await r.json()); else showErr('Save failed.');
|
|
4497
|
+
} catch { showErr('Save failed.'); }
|
|
4503
4498
|
}
|
|
4504
4499
|
state.editing = null; render();
|
|
4505
4500
|
};
|
|
4506
4501
|
for (const b of q.querySelectorAll('.qp')) b.onclick = async () => {
|
|
4507
|
-
|
|
4502
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.id}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ promote: true }) }); }
|
|
4503
|
+
catch { showErr('Too late — this goal already started.'); render(); return; }
|
|
4508
4504
|
if (r.ok) upsert(state.goals, await r.json()); else showErr('Too late — this goal already started.');
|
|
4509
4505
|
render();
|
|
4510
4506
|
};
|
|
4511
4507
|
for (const b of q.querySelectorAll('.qd')) b.onclick = async () => {
|
|
4512
|
-
|
|
4508
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.id}`), { method: 'DELETE' }); }
|
|
4509
|
+
catch { showErr('Too late — this goal already started.'); await refresh(); render(); return; }
|
|
4513
4510
|
if (r.ok) state.goals = state.goals.filter((g) => g.id !== Number(b.dataset.id));
|
|
4514
4511
|
else { showErr('Too late — this goal already started.'); await refresh(); }
|
|
4515
4512
|
render();
|
|
@@ -4724,15 +4721,19 @@ function renderTasks(){
|
|
|
4724
4721
|
const id = Number(el.dataset.task);
|
|
4725
4722
|
const rb = e.target.closest('[data-retrybtn]');
|
|
4726
4723
|
if (rb) {
|
|
4727
|
-
|
|
4728
|
-
|
|
4724
|
+
try {
|
|
4725
|
+
const r = await fetch(withKey(`/api/tasks/${rb.dataset.retrybtn}/retry`), { method: 'POST' });
|
|
4726
|
+
if (!r.ok) showErr('Retry failed.'); else await refresh();
|
|
4727
|
+
} catch { showErr('Retry failed.'); }
|
|
4729
4728
|
return;
|
|
4730
4729
|
}
|
|
4731
4730
|
const sb = e.target.closest('[data-skipbtn]');
|
|
4732
4731
|
if (sb) {
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4732
|
+
try {
|
|
4733
|
+
const r = await fetch(withKey(`/api/tasks/${sb.dataset.skipbtn}/skip`), { method: 'POST',
|
|
4734
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reason: 'Dismissed from Attention.' }) });
|
|
4735
|
+
if (!r.ok) showErr('Dismiss failed.'); else await refresh();
|
|
4736
|
+
} catch { showErr('Dismiss failed.'); }
|
|
4736
4737
|
return;
|
|
4737
4738
|
}
|
|
4738
4739
|
// Attention rows open the same detail dialog as Review (tap anywhere but
|
|
@@ -4776,11 +4777,13 @@ function renderTasks(){
|
|
|
4776
4777
|
b.onclick = async (e) => { e.stopPropagation();
|
|
4777
4778
|
const bx = b.getBoundingClientRect(); // capture before await/re-render — burst FROM the ✓ button
|
|
4778
4779
|
let ok = true, mergeGoal = null;
|
|
4779
|
-
|
|
4780
|
-
const
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4780
|
+
try {
|
|
4781
|
+
for (const id of b.dataset.qapprove.split(',')) {
|
|
4782
|
+
const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
|
|
4783
|
+
if (!r.ok){ ok = false; break; }
|
|
4784
|
+
const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
|
|
4785
|
+
}
|
|
4786
|
+
} catch { ok = false; }
|
|
4784
4787
|
if (!ok){ showErr('Approve failed.'); return; }
|
|
4785
4788
|
await refresh();
|
|
4786
4789
|
if (mergeGoal){ confirmMerge(mergeGoal); return; } // merge instead of confetti
|
|
@@ -4790,19 +4793,24 @@ function renderTasks(){
|
|
|
4790
4793
|
// 07-20c: the inline ✕ is a pure Reject — park the goal in the Rejected column (/reject),
|
|
4791
4794
|
// no auto-rework. Rework is asked for via chat.
|
|
4792
4795
|
b.onclick = async (e) => { e.stopPropagation();
|
|
4793
|
-
let r; for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
|
|
4796
|
+
let r; try { for (const id of b.dataset.qdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' }); }
|
|
4797
|
+
catch { showErr('Dismiss failed.'); return; }
|
|
4794
4798
|
qrefresh(r, 'Dismiss failed.'); };
|
|
4795
4799
|
}
|
|
4796
4800
|
// Archive: retire an unfinished goal cleanly (no rework spawned). The escape
|
|
4797
4801
|
// hatch for half-finished (partial/blocked) items so Attention stays drainable.
|
|
4798
4802
|
for (const b of $('tasklist').querySelectorAll('[data-archivegoal]')) {
|
|
4799
4803
|
b.onclick = async (e) => { e.stopPropagation();
|
|
4800
|
-
|
|
4804
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.archivegoal}/archive`), { method: 'POST' }); }
|
|
4805
|
+
catch { showErr('Archive failed.'); return; }
|
|
4806
|
+
qrefresh(r, 'Archive failed.'); };
|
|
4801
4807
|
}
|
|
4802
4808
|
// Manager-verified gate: re-run tests + re-capture proof for a blocked goal.
|
|
4803
4809
|
for (const b of $('tasklist').querySelectorAll('[data-goalreverify]')) {
|
|
4804
4810
|
b.onclick = async (e) => { e.stopPropagation(); b.disabled = true; b.textContent = '再検証中…';
|
|
4805
|
-
|
|
4811
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' }); }
|
|
4812
|
+
catch { showErr('再検証に失敗'); return; }
|
|
4813
|
+
qrefresh(r, '再検証に失敗'); };
|
|
4806
4814
|
}
|
|
4807
4815
|
for (const b of $('tasklist').querySelectorAll('[data-qreply]')) {
|
|
4808
4816
|
b.onclick = (e) => { e.stopPropagation();
|
|
@@ -4845,14 +4853,16 @@ function renderTasks(){
|
|
|
4845
4853
|
for (const b of $('tasklist').querySelectorAll('[data-pstart]')) {
|
|
4846
4854
|
b.onclick = async (e) => {
|
|
4847
4855
|
e.stopPropagation();
|
|
4848
|
-
|
|
4856
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.pstart}/activate`), { method: 'POST' }); }
|
|
4857
|
+
catch { showErr('Start failed.'); return; }
|
|
4849
4858
|
if (!r.ok) showErr('Start failed.'); else await refresh();
|
|
4850
4859
|
};
|
|
4851
4860
|
}
|
|
4852
4861
|
for (const b of $('tasklist').querySelectorAll('[data-pdel]')) {
|
|
4853
4862
|
b.onclick = async (e) => {
|
|
4854
4863
|
e.stopPropagation();
|
|
4855
|
-
|
|
4864
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.pdel}`), { method: 'DELETE' }); }
|
|
4865
|
+
catch { showErr('Delete failed.'); return; }
|
|
4856
4866
|
if (!r.ok) showErr('Delete failed.'); else await refresh();
|
|
4857
4867
|
};
|
|
4858
4868
|
}
|
|
@@ -4864,13 +4874,15 @@ function renderTasks(){
|
|
|
4864
4874
|
e.stopPropagation();
|
|
4865
4875
|
const preview = b.closest('.goalhead')?.querySelector('.gt')?.textContent || '';
|
|
4866
4876
|
if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
|
|
4867
|
-
|
|
4877
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.ghdel}`), { method: 'DELETE' }); }
|
|
4878
|
+
catch { showErr('Cannot delete a running/finished goal — reply to append instead'); return; }
|
|
4868
4879
|
if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh();
|
|
4869
4880
|
};
|
|
4870
4881
|
// Smart intake: answer a planner clarifying question (chip or free text) → re-plan.
|
|
4871
4882
|
const answerQ = async (id, answer) => {
|
|
4872
4883
|
if (!answer) return;
|
|
4873
|
-
|
|
4884
|
+
let r; try { r = await fetch(withKey(`/api/goals/${id}/answer`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) }); }
|
|
4885
|
+
catch { showErr('Failed to send answer'); return; }
|
|
4874
4886
|
if (!r.ok) showErr('Failed to send answer'); else await refresh();
|
|
4875
4887
|
};
|
|
4876
4888
|
for (const b of $('tasklist').querySelectorAll('[data-qanswer]')) b.onclick = (e) => { e.stopPropagation(); answerQ(Number(b.dataset.qanswer), b.dataset.answer); };
|
|
@@ -4967,7 +4979,8 @@ function openGoalheadReply(id, btn){
|
|
|
4967
4979
|
const snd = document.createElement('button'); snd.className = 'qbtn ok'; snd.textContent = '↑';
|
|
4968
4980
|
wrap.appendChild(inp); wrap.appendChild(snd); head.insertAdjacentElement('afterend', wrap); inp.focus();
|
|
4969
4981
|
const send = async () => { const t = inp.value.trim(); if (!t) return;
|
|
4970
|
-
|
|
4982
|
+
let r; try { r = await fetch(withKey(`/api/goals/${id}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text: t })) }); }
|
|
4983
|
+
catch { showErr('未開始のゴールは編集を使ってください'); return; }
|
|
4971
4984
|
if (!r.ok) showErr('未開始のゴールは編集を使ってください'); await refresh(); };
|
|
4972
4985
|
inp.onkeydown = (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); send(); } if (e.key === 'Escape') wrap.remove(); };
|
|
4973
4986
|
snd.onclick = send;
|
|
@@ -4990,9 +5003,11 @@ function wirePriority(){
|
|
|
4990
5003
|
b.onclick = async (e) => {
|
|
4991
5004
|
e.stopPropagation();
|
|
4992
5005
|
b.closest('.priomenu')?.classList.remove('show');
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
5006
|
+
try {
|
|
5007
|
+
const r = await fetch(withKey(`/api/tasks/${b.dataset.priotask}/priority`), { method: 'PUT',
|
|
5008
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ priority: b.dataset.val }) });
|
|
5009
|
+
if (!r.ok) showErr('優先度の変更に失敗しました。'); else await refresh();
|
|
5010
|
+
} catch { showErr('優先度の変更に失敗しました。'); }
|
|
4996
5011
|
};
|
|
4997
5012
|
}
|
|
4998
5013
|
}
|
|
@@ -5188,26 +5203,31 @@ $('wfHeaderEdit').onclick = (e) => { e.stopPropagation(); openWfEditor(); };
|
|
|
5188
5203
|
$('wfoverlay').addEventListener('click', (e) => { if (e.target === $('wfoverlay')) $('wfoverlay').classList.remove('show'); });
|
|
5189
5204
|
$('wfsave').onclick = async () => {
|
|
5190
5205
|
const columns = state.wfDraft.map((c) => ({ key: c.key, label: c.label.trim(), bucket: c.bucket }));
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5206
|
+
try {
|
|
5207
|
+
const r = await fetch(withKey(`/api/projects/${state.active}/columns`), { method: 'PUT',
|
|
5208
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ columns }) });
|
|
5209
|
+
const data = await r.json().catch(() => ({}));
|
|
5210
|
+
if (!r.ok) {
|
|
5211
|
+
$('wferr').textContent = data.error || 'Save failed.';
|
|
5212
|
+
$('wferr').style.display = 'block';
|
|
5213
|
+
return;
|
|
5214
|
+
}
|
|
5215
|
+
const rdr = await fetch(withKey(`/api/projects/${state.active}/review-definition`), { method: 'PUT',
|
|
5216
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reviewDefinition: state.rdDraft }) });
|
|
5217
|
+
const rdData = await rdr.json().catch(() => ({}));
|
|
5218
|
+
if (!rdr.ok) {
|
|
5219
|
+
$('wferr').textContent = rdData.error || 'Save failed.';
|
|
5220
|
+
$('wferr').style.display = 'block';
|
|
5221
|
+
return;
|
|
5222
|
+
}
|
|
5223
|
+
state.workflowColumns[state.active] = data.columns;
|
|
5224
|
+
state.reviewDefinitions[state.active] = rdData.reviewDefinition;
|
|
5225
|
+
$('wfoverlay').classList.remove('show');
|
|
5226
|
+
render();
|
|
5227
|
+
} catch {
|
|
5228
|
+
$('wferr').textContent = 'Save failed.';
|
|
5204
5229
|
$('wferr').style.display = 'block';
|
|
5205
|
-
return;
|
|
5206
5230
|
}
|
|
5207
|
-
state.workflowColumns[state.active] = data.columns;
|
|
5208
|
-
state.reviewDefinitions[state.active] = rdData.reviewDefinition;
|
|
5209
|
-
$('wfoverlay').classList.remove('show');
|
|
5210
|
-
render();
|
|
5211
5231
|
};
|
|
5212
5232
|
|
|
5213
5233
|
function wireWfEditButtons(){
|
|
@@ -5432,7 +5452,8 @@ function renderReviewChecklist(){
|
|
|
5432
5452
|
e.stopPropagation();
|
|
5433
5453
|
b.disabled = true;
|
|
5434
5454
|
b.textContent = '再検証中…';
|
|
5435
|
-
|
|
5455
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' }); }
|
|
5456
|
+
catch { showErr('再検証に失敗'); b.disabled = false; return; }
|
|
5436
5457
|
if (r.ok) await refresh(); else showErr('再検証に失敗');
|
|
5437
5458
|
};
|
|
5438
5459
|
}
|
|
@@ -5444,9 +5465,10 @@ function renderReviewChecklist(){
|
|
|
5444
5465
|
if (!text) return;
|
|
5445
5466
|
const t = reqs.find((r) => r.id === taskId);
|
|
5446
5467
|
b.disabled = true; input.disabled = true;
|
|
5447
|
-
|
|
5468
|
+
let r; try { r = await fetch(withKey(`/api/goals/${goalId}/reply`), { method: 'POST',
|
|
5448
5469
|
headers: { 'content-type': 'application/json' },
|
|
5449
|
-
body: JSON.stringify(replyWorkerPayload({ text: `[Fix request for requirement #${t?.num ?? ''} "${t?.title ?? ''}"] ${text}` })) });
|
|
5470
|
+
body: JSON.stringify(replyWorkerPayload({ text: `[Fix request for requirement #${t?.num ?? ''} "${t?.title ?? ''}"] ${text}` })) }); }
|
|
5471
|
+
catch { showErr('Failed to send the fix request.'); b.disabled = false; input.disabled = false; return; }
|
|
5450
5472
|
if (r.ok) { input.value = ''; input.placeholder = 'Sent — the worker will pick it up'; }
|
|
5451
5473
|
else showErr('Failed to send the fix request.');
|
|
5452
5474
|
b.disabled = false; input.disabled = false;
|
|
@@ -6367,7 +6389,7 @@ function renderFsRail(){
|
|
|
6367
6389
|
el.innerHTML = html;
|
|
6368
6390
|
for (const b of el.querySelectorAll('[data-fsproj]')) b.onclick = (e) => {
|
|
6369
6391
|
if (e.target.closest('.pname.editing') || b.querySelector('.pname.editing')) return;
|
|
6370
|
-
|
|
6392
|
+
setActiveProject(b.dataset.fsproj); document.body.classList.remove('projectsopen'); render();
|
|
6371
6393
|
};
|
|
6372
6394
|
const add = $('fsProjAdd'); if (add) add.onclick = createProject;
|
|
6373
6395
|
}
|
|
@@ -6538,11 +6560,6 @@ function renderFsFeed(){
|
|
|
6538
6560
|
let t = '';
|
|
6539
6561
|
if (turn.role === 'you') {
|
|
6540
6562
|
t += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
|
|
6541
|
-
if (turn.markGoalId != null && !turn.undone) {
|
|
6542
|
-
const mg = state.goals.find((x) => x.id === turn.markGoalId);
|
|
6543
|
-
const n = mg ? goalReviewNumber({ goal: mg, tasks: state.tasks }) : turn.markGoalId;
|
|
6544
|
-
t += `<div class="cc-mark"><span class="cm-ar">→</span><span class="cm-t">Added to To Do</span><span class="cm-no">#${n}</span><button class="cm-undo" type="button" data-ccundo="${turn.markGoalId}">Undo</button></div>`;
|
|
6545
|
-
}
|
|
6546
6563
|
} else { // Claude Code reply — speaker line first (You → speaker → reply), then the text
|
|
6547
6564
|
const app = connectedAppMeta(turn.agent);
|
|
6548
6565
|
t += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
|
|
@@ -6595,24 +6612,12 @@ function renderFsFeed(){
|
|
|
6595
6612
|
// Delete a "needs your answer" card outright (restores 「Needs your answer」カードを削除
|
|
6596
6613
|
// できるように — a question you don't want to answer had no discard path in flagship).
|
|
6597
6614
|
for (const b of feed.querySelectorAll('[data-fsanswerdel]')) b.onclick = (e) => { e.stopPropagation(); fsTaskDelete(Number(b.dataset.fsanswerdel)); };
|
|
6598
|
-
// Pattern A (H23): Undo a "→ Added to To Do" marker — cancel the created goal
|
|
6599
|
-
// and drop the mark. A just-created (planning) goal is deletable; if it already
|
|
6600
|
-
// started the server returns non-OK (canDeleteGoal) and we say so quietly.
|
|
6601
|
-
for (const b of feed.querySelectorAll('[data-ccundo]')) b.onclick = async (e) => {
|
|
6602
|
-
e.stopPropagation();
|
|
6603
|
-
const gid = Number(b.dataset.ccundo);
|
|
6604
|
-
const r = await fetch(withKey(`/api/goals/${gid}`), { method: 'DELETE' });
|
|
6605
|
-
if (!r.ok) { showErr('Could not undo — the task may already be running'); return; }
|
|
6606
|
-
const turn = (state.chatTurns[state.active] || []).find((t) => t.markGoalId === gid);
|
|
6607
|
-
if (turn) turn.undone = true;
|
|
6608
|
-
state.goals = state.goals.filter((g) => g.id !== gid);
|
|
6609
|
-
render();
|
|
6610
|
-
};
|
|
6611
6615
|
}
|
|
6612
6616
|
async function fsAnswerQuestion(goalId, answer){
|
|
6613
6617
|
if (!answer) return;
|
|
6614
|
-
|
|
6615
|
-
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) });
|
|
6618
|
+
let r; try { r = await fetch(withKey(`/api/goals/${goalId}/answer`), { method: 'POST',
|
|
6619
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer }) }); }
|
|
6620
|
+
catch { showErr('Failed to send answer'); return; }
|
|
6616
6621
|
if (!r.ok) showErr('Failed to send answer'); else await refresh();
|
|
6617
6622
|
}
|
|
6618
6623
|
async function fsRetryTask(id){
|
|
@@ -6989,17 +6994,20 @@ function renderFsReview(){
|
|
|
6989
6994
|
// "Also carries" section (saItemHtml + the seeall handler).
|
|
6990
6995
|
for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
|
|
6991
6996
|
let ok = true, mergeGoal = null;
|
|
6992
|
-
|
|
6993
|
-
const
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
+
try {
|
|
6998
|
+
for (const id of b.dataset.fsapprove.split(',')) {
|
|
6999
|
+
const r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
|
|
7000
|
+
if (!r.ok){ ok = false; break; }
|
|
7001
|
+
const j = await r.json(); if (j && j.needsMerge) mergeGoal = j.goal; // 07-20c: PR goal
|
|
7002
|
+
}
|
|
7003
|
+
} catch { ok = false; }
|
|
6997
7004
|
if (!ok){ showErr('Approve failed.'); return; }
|
|
6998
7005
|
await refresh();
|
|
6999
7006
|
if (mergeGoal) confirmMerge(mergeGoal); else celebrateApprove(); };
|
|
7000
7007
|
for (const b of el.querySelectorAll('[data-fsdismiss]')) b.onclick = async (e) => { e.stopPropagation();
|
|
7001
7008
|
// 07-20c: the review-card ✕ is a pure Reject → Rejected column (/reject), no auto-rework.
|
|
7002
|
-
let r; for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' });
|
|
7009
|
+
let r; try { for (const id of b.dataset.fsdismiss.split(',')) r = await fetch(withKey(`/api/goals/${id}/reject`), { method: 'POST' }); }
|
|
7010
|
+
catch { showErr('Dismiss failed.'); return; }
|
|
7003
7011
|
if (r && r.ok) await refresh(); else showErr('Dismiss failed.'); };
|
|
7004
7012
|
}
|
|
7005
7013
|
// Shared To Do bucketing (list rows AND the kanban board read the same groups —
|
|
@@ -7290,14 +7298,16 @@ function renderFsTodo(){
|
|
|
7290
7298
|
if (Number.isFinite(id)) fsOpenGoalDetail(id);
|
|
7291
7299
|
};
|
|
7292
7300
|
for (const b of el.querySelectorAll('[data-fsreopen]')) b.onclick = async (e) => { e.stopPropagation();
|
|
7293
|
-
|
|
7301
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreopen}/reopen`), { method: 'POST' }); }
|
|
7302
|
+
catch { showErr('Reopen failed.'); return; }
|
|
7294
7303
|
if (!r.ok) showErr('Reopen failed.'); else await refresh(); };
|
|
7295
7304
|
for (const b of el.querySelectorAll('[data-fsrejdel]')) b.onclick = async (e) => { e.stopPropagation();
|
|
7296
7305
|
const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
|
|
7297
7306
|
// Delete on a rejected goal = revert: it closes the PR + drops the worktree, so a rejected
|
|
7298
7307
|
// goal that had a PR doesn't leave an orphan PR behind (DELETE would 409 — rejected isn't deletable).
|
|
7299
7308
|
if (!(await confirmDelete({ title: 'Discard this rejected goal?', preview }))) return;
|
|
7300
|
-
|
|
7309
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsrejdel}/revert`), { method: 'POST' }); }
|
|
7310
|
+
catch { showErr('Delete failed.'); return; }
|
|
7301
7311
|
if (!r.ok) showErr('Delete failed.'); else await refresh(); };
|
|
7302
7312
|
const more = el.querySelector('#fsTodoMore');
|
|
7303
7313
|
if (more) more.onclick = () => { fsUI.todoMore = !fsUI.todoMore; renderFsTodo(); };
|
|
@@ -7316,45 +7326,53 @@ function renderFsTodo(){
|
|
|
7316
7326
|
for (const b of el.querySelectorAll('[data-fsglog]')) b.onclick = () => toggleGoalDetail(Number(b.dataset.fsglog));
|
|
7317
7327
|
for (const b of el.querySelectorAll('[data-fsreverify]')) b.onclick = async () => {
|
|
7318
7328
|
b.disabled = true; b.textContent = 'Starting…';
|
|
7319
|
-
|
|
7329
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreverify}/reverify`), { method: 'POST' }); }
|
|
7330
|
+
catch { showErr(document.documentElement.lang === 'ja' ? 'Retryに失敗しました' : 'Retry failed.'); b.disabled = false; b.textContent = 'Retry'; return; }
|
|
7320
7331
|
if (!r.ok) { showErr(document.documentElement.lang === 'ja' ? 'Retryに失敗しました' : 'Retry failed.'); b.disabled = false; b.textContent = 'Retry'; }
|
|
7321
7332
|
else await refresh();
|
|
7322
7333
|
};
|
|
7323
7334
|
for (const b of el.querySelectorAll('[data-fsarchive]')) b.onclick = async () => {
|
|
7324
|
-
|
|
7335
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsarchive}/archive`), { method: 'POST' }); }
|
|
7336
|
+
catch { showErr('Archive failed.'); return; }
|
|
7325
7337
|
if (!r.ok) showErr('Archive failed.'); else await refresh();
|
|
7326
7338
|
};
|
|
7327
7339
|
// LATER row actions (PRD §8.2): Start queues the shelved goal, Delete discards it.
|
|
7328
7340
|
for (const b of el.querySelectorAll('[data-fspstart]')) b.onclick = async (e) => { e.stopPropagation();
|
|
7329
|
-
|
|
7341
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fspstart}/activate`), { method: 'POST' }); }
|
|
7342
|
+
catch { showErr('Start failed.'); return; }
|
|
7330
7343
|
if (!r.ok) showErr('Start failed.'); else await refresh(); };
|
|
7331
7344
|
const startNextLater = el.querySelector('[data-fsstartnextlater]');
|
|
7332
7345
|
if (startNextLater) startNextLater.onclick = async (e) => { e.stopPropagation();
|
|
7333
7346
|
startNextLater.disabled = true;
|
|
7334
|
-
|
|
7347
|
+
let r; try { r = await fetch(withKey(`/api/goals/${startNextLater.dataset.fsstartnextlater}/activate`), { method: 'POST' }); }
|
|
7348
|
+
catch { showErr(document.documentElement.lang === 'ja' ? '開始できませんでした' : 'Could not start the next item.'); startNextLater.disabled = false; return; }
|
|
7335
7349
|
if (!r.ok) { showErr(document.documentElement.lang === 'ja' ? '開始できませんでした' : 'Could not start the next item.'); startNextLater.disabled = false; }
|
|
7336
7350
|
else await refresh(); };
|
|
7337
7351
|
for (const b of el.querySelectorAll('[data-fspdel]')) b.onclick = async (e) => { e.stopPropagation();
|
|
7338
7352
|
const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
|
|
7339
7353
|
if (!(await confirmDelete({ title: 'Delete this shelved goal?', preview }))) return;
|
|
7340
|
-
|
|
7354
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fspdel}`), { method: 'DELETE' }); }
|
|
7355
|
+
catch { showErr('Delete failed.'); return; }
|
|
7341
7356
|
if (!r.ok) showErr('Delete failed.'); else await refresh(); };
|
|
7342
7357
|
// hover-toolbar actions → same endpoints as the SL1 goalhead toolbar
|
|
7343
7358
|
for (const b of el.querySelectorAll('[data-fsreply]')) b.onclick = (e) => { e.stopPropagation();
|
|
7344
7359
|
fsInlineInput(b.closest('.trow'), '', 'Reply to this goal…', async (text) => {
|
|
7345
|
-
|
|
7360
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsreply}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyWorkerPayload({ text })) }); }
|
|
7361
|
+
catch { showErr('未開始のゴールは編集を使ってください'); return; }
|
|
7346
7362
|
if (!r.ok) showErr('未開始のゴールは編集を使ってください'); await refresh();
|
|
7347
7363
|
}); };
|
|
7348
7364
|
for (const b of el.querySelectorAll('[data-fsedit]')) b.onclick = (e) => { e.stopPropagation();
|
|
7349
7365
|
const g = state.goals.find((x) => x.id === Number(b.dataset.fsedit));
|
|
7350
7366
|
fsInlineInput(b.closest('.trow'), g?.text ?? '', 'Edit the goal…', async (text) => {
|
|
7351
|
-
|
|
7367
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsedit}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) }); }
|
|
7368
|
+
catch { showErr('Cannot edit a running/finished goal — reply to append instead'); return; }
|
|
7352
7369
|
if (!r.ok) showErr('Cannot edit a running/finished goal — reply to append instead'); await refresh();
|
|
7353
7370
|
}); };
|
|
7354
7371
|
for (const b of el.querySelectorAll('[data-fsdel]')) b.onclick = async (e) => { e.stopPropagation();
|
|
7355
7372
|
const preview = b.closest('.trow')?.querySelector('.tt')?.textContent || '';
|
|
7356
7373
|
if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
|
|
7357
|
-
|
|
7374
|
+
let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.fsdel}`), { method: 'DELETE' }); }
|
|
7375
|
+
catch { showErr('Cannot delete a running/finished goal — reply to append instead'); return; }
|
|
7358
7376
|
if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh(); };
|
|
7359
7377
|
}
|
|
7360
7378
|
async function fsCreateGoal(text, pending){
|
|
@@ -7472,7 +7490,7 @@ async function createProject(){
|
|
|
7472
7490
|
if (!r.ok) throw new Error();
|
|
7473
7491
|
const body = await r.json();
|
|
7474
7492
|
state.projects = body.projects ?? [...state.projects, body.project].filter(Boolean);
|
|
7475
|
-
|
|
7493
|
+
setActiveProject(body.project.id);
|
|
7476
7494
|
document.body.classList.remove('projectsopen');
|
|
7477
7495
|
render();
|
|
7478
7496
|
} catch { showErr('New project failed.'); }
|
|
@@ -7504,7 +7522,7 @@ async function deleteProject(p){
|
|
|
7504
7522
|
const body = await r.json().catch(() => ({}));
|
|
7505
7523
|
if (!r.ok) throw new Error(body.error || 'Delete project failed.');
|
|
7506
7524
|
state.projects = body.projects ?? state.projects.filter((x) => x.id !== p.id);
|
|
7507
|
-
if (state.active === p.id) state.
|
|
7525
|
+
if (state.active === p.id) setActiveProject(state.projects[0]?.id ?? state.active);
|
|
7508
7526
|
document.body.classList.remove('projectsopen');
|
|
7509
7527
|
render();
|
|
7510
7528
|
} catch (err) {
|
|
@@ -7539,21 +7557,24 @@ $('fsProjects').addEventListener('dblclick', (e) => {
|
|
|
7539
7557
|
async function fsTaskRename(span, goalId){
|
|
7540
7558
|
if (!goalId) return;
|
|
7541
7559
|
inlineFsRename(span, async (v) => {
|
|
7542
|
-
|
|
7560
|
+
let r; try { r = await fetch(withKey(`/api/goals/${goalId}`), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: v }) }); }
|
|
7561
|
+
catch { showErr('Cannot rename a running/finished goal — reply to append instead'); return; }
|
|
7543
7562
|
if (!r.ok) showErr('Cannot rename a running/finished goal — reply to append instead');
|
|
7544
7563
|
await refresh();
|
|
7545
7564
|
});
|
|
7546
7565
|
}
|
|
7547
7566
|
async function fsTaskArchive(goalId){
|
|
7548
7567
|
if (!goalId) return;
|
|
7549
|
-
|
|
7568
|
+
let r; try { r = await fetch(withKey(`/api/goals/${goalId}/archive`), { method: 'POST' }); }
|
|
7569
|
+
catch { showErr('Archive failed.'); return; }
|
|
7550
7570
|
if (!r.ok) showErr('Archive failed.'); else await refresh();
|
|
7551
7571
|
}
|
|
7552
7572
|
async function fsTaskDelete(goalId){
|
|
7553
7573
|
if (!goalId) return;
|
|
7554
7574
|
const preview = state.goals.find((x) => x.id === goalId)?.text || '';
|
|
7555
7575
|
if (!(await confirmDelete({ title: 'Delete this goal?', preview }))) return;
|
|
7556
|
-
|
|
7576
|
+
let r; try { r = await fetch(withKey(`/api/goals/${goalId}`), { method: 'DELETE' }); }
|
|
7577
|
+
catch { showErr('Cannot delete a running/finished goal — reply to append instead'); return; }
|
|
7557
7578
|
if (!r.ok) showErr('Cannot delete a running/finished goal — reply to append instead'); else await refresh();
|
|
7558
7579
|
}
|
|
7559
7580
|
$('fsTodoSec').addEventListener('contextmenu', (e) => {
|
|
@@ -7587,7 +7608,7 @@ function fsSwitcherOpen(){
|
|
|
7587
7608
|
function fsSwitcherHi(){ [...fsCtx.querySelectorAll('button')].forEach((b, i) => b.classList.toggle('hi', i === fsCtxSel)); }
|
|
7588
7609
|
function fsSelectProjByIndex(idx){
|
|
7589
7610
|
const rows = [...document.querySelectorAll('#fsProjects .fsproj')]; if (idx < 0 || idx >= rows.length) return;
|
|
7590
|
-
|
|
7611
|
+
setActiveProject(rows[idx].dataset.fsproj); render();
|
|
7591
7612
|
}
|
|
7592
7613
|
document.addEventListener('keydown', (e) => {
|
|
7593
7614
|
if (fsCtx.classList.contains('open') && fsCtxSwitcher) {
|
|
@@ -8327,7 +8348,7 @@ async function refresh(){
|
|
|
8327
8348
|
state.workflowColumns = s.workflowColumns ?? {};
|
|
8328
8349
|
state.reviewDefinitions = s.reviewDefinitions ?? {};
|
|
8329
8350
|
checkUiVersion(s.uiVersion);
|
|
8330
|
-
if (!state.projects.some((p) => p.id === state.active)) state.
|
|
8351
|
+
if (!state.projects.some((p) => p.id === state.active)) setActiveProject(state.projects[0]?.id);
|
|
8331
8352
|
render();
|
|
8332
8353
|
} catch { /* server briefly down; SSE reconnect will retry */ }
|
|
8333
8354
|
}
|
|
@@ -8412,7 +8433,7 @@ function connectSSE(){
|
|
|
8412
8433
|
else if (m.ev === 'review-definition') state.reviewDefinitions[m.projectId] = m.reviewDefinition;
|
|
8413
8434
|
else if (m.ev === 'projects') {
|
|
8414
8435
|
state.projects = m.projects ?? state.projects;
|
|
8415
|
-
if (!state.projects.some((p) => p.id === state.active)) state.
|
|
8436
|
+
if (!state.projects.some((p) => p.id === state.active)) setActiveProject(state.projects[0]?.id);
|
|
8416
8437
|
}
|
|
8417
8438
|
else if (m.ev === 'ping') { checkUiVersion(m.uiVersion); return; }
|
|
8418
8439
|
else if (m.ev === 'auth') { renderAuthBanner(m.auth); return; } // selected worker's sign-in preflight result
|
|
@@ -8448,7 +8469,7 @@ function bootDisconnected(info){
|
|
|
8448
8469
|
applyTheme(); applyCust();
|
|
8449
8470
|
// seed a minimal board so it renders like the real empty state
|
|
8450
8471
|
state.projects = [{ id: '__onboard', name: 'Your project' }];
|
|
8451
|
-
|
|
8472
|
+
setActiveProject('__onboard');
|
|
8452
8473
|
state.billing = { licensed: false, email };
|
|
8453
8474
|
try { render(); } catch (e) { console.warn('disconnected render', e); }
|
|
8454
8475
|
const heroSpan = document.querySelector('#fsClawdHero .hstat');
|
|
@@ -8664,15 +8685,6 @@ async function postOutboxItem(item){
|
|
|
8664
8685
|
outbox.land(item.oid, goal.id);
|
|
8665
8686
|
upsert(state.goals, goal);
|
|
8666
8687
|
if (recentlySignedOut && !state.billing?.licensed) { recentlySignedOut = false; showSignInNudge(); }
|
|
8667
|
-
// Pattern A (H23): this send became a real To-Do — mark the most recent
|
|
8668
|
-
// unmarked user turn "→ Added to To Do #NN" in the feed. Skip a needsInput
|
|
8669
|
-
// goal (it's still asking; shown as the question card at the feed bottom).
|
|
8670
|
-
if (goal.status !== 'needsInput') {
|
|
8671
|
-
const turns = state.chatTurns[item.projectId] || [];
|
|
8672
|
-
for (let k = turns.length - 1; k >= 0; k--) {
|
|
8673
|
-
if (turns[k].role === 'you' && turns[k].markGoalId == null) { turns[k].markGoalId = goal.id; break; }
|
|
8674
|
-
}
|
|
8675
|
-
}
|
|
8676
8688
|
} catch {
|
|
8677
8689
|
outbox.mark(item.oid, 'unsent');
|
|
8678
8690
|
} finally {
|
|
@@ -8887,16 +8899,13 @@ async function send(){
|
|
|
8887
8899
|
projectId: state.active, text, pr: 'auto', model, effort, agent: state.connectedApp,
|
|
8888
8900
|
mode, skill: skill || null,
|
|
8889
8901
|
workspaceMode: workspaceMode(),
|
|
8890
|
-
// Keep the old wire contract too: only direct sends worktree:false.
|
|
8891
|
-
worktree: worktreeOn() ? undefined : false,
|
|
8892
8902
|
pending: later, review,
|
|
8893
8903
|
images: attach.map((a) => a.path), imageUrls: attach.map((a) => a.url),
|
|
8894
8904
|
status: 'sending', ts: Date.now(),
|
|
8895
8905
|
};
|
|
8896
8906
|
outbox.add(item); // saved locally BEFORE any network call
|
|
8897
8907
|
// Pattern A (H23): the user's own turn enters the center feed immediately, so
|
|
8898
|
-
// the composer reads like a conversation
|
|
8899
|
-
// .cc-mark is attached to this turn later; if it's chat, a reply turn follows.
|
|
8908
|
+
// the composer reads like a conversation; if it's chat, a reply turn follows.
|
|
8900
8909
|
(state.chatTurns[state.active] ||= []).push({ role: 'you', text, ts: Date.now() });
|
|
8901
8910
|
state.attach = [];
|
|
8902
8911
|
// mode/skill/later/review are per-message → clear after send (model stays sticky).
|
|
@@ -9025,9 +9034,12 @@ function renderSummaryPera(){
|
|
|
9025
9034
|
body.querySelector('[data-peradetail]') && (body.querySelector('[data-peradetail]').onclick = () => { state.peraDetailOpen = !state.peraDetailOpen; renderSummaryPera(); });
|
|
9026
9035
|
}
|
|
9027
9036
|
async function peraAct(ids, kind){
|
|
9028
|
-
|
|
9029
|
-
|
|
9030
|
-
|
|
9037
|
+
try {
|
|
9038
|
+
for (const id of String(ids).split(',')) {
|
|
9039
|
+
const r = await fetch(withKey(`/api/goals/${id}/${kind}`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: kind === 'dismiss' ? JSON.stringify({ text: '' }) : undefined });
|
|
9040
|
+
if (!r.ok) throw new Error();
|
|
9041
|
+
}
|
|
9042
|
+
} catch { showErr(kind === 'approve' ? 'Approve failed.' : 'Dismiss failed.'); return; }
|
|
9031
9043
|
if (kind === 'approve') celebrateApprove(); // confetti from the ペライチ too
|
|
9032
9044
|
state.peraSpot = 0;
|
|
9033
9045
|
await refresh();
|
|
@@ -9145,6 +9157,7 @@ function saRowFromDigest(r, gs, ts){
|
|
|
9145
9157
|
goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
|
|
9146
9158
|
did, siblings, risk: g?.risk ?? null,
|
|
9147
9159
|
verificationInfraError: g?.verificationInfraError ?? r.verificationInfraError ?? null,
|
|
9160
|
+
prNote: g?.prNote ?? null,
|
|
9148
9161
|
note: g?.retest?.passed ? g.retest.note : null,
|
|
9149
9162
|
pr: r.pr,
|
|
9150
9163
|
// P0b (Masa 2026-07-21): the AI's own TodoWrite breakdown for the "Steps" section
|
|
@@ -9593,7 +9606,8 @@ function saItemHtml(it, i){
|
|
|
9593
9606
|
const fDel = it.metaDel ?? (hasGitDiff ? `−${d.del}` : clientDiff ? `−${cDel}` : null);
|
|
9594
9607
|
const chips = [];
|
|
9595
9608
|
if (it.pr) chips.push(`<a class="mx mlink pr" href="${esc(it.pr)}" target="_blank" rel="noopener" title="Open pull request"><span class="mk">PR</span> <b>#${esc(prNum)}</b> <span class="mmut">Open</span>${arw}</a>`);
|
|
9596
|
-
|
|
9609
|
+
const tokUntied = it.tokenOpt?.basis === 'baseline-untied';
|
|
9610
|
+
if (it.tokenOpt && tokPct > 0) chips.push(`<button type="button" class="mx mtok" data-saact="tok:${i}"><span class="mk">Token</span> <b>${tokPct}%</b> <span class="mmut">${tokUntied ? 'smaller' : 'saved'}</span> <i class="tokchevron">›</i></button>`);
|
|
9597
9611
|
if (testsLabel) chips.push(`<span class="mx"><span class="mk">Tests</span> <b class="ok">${esc(testsLabel)}</b></span>`);
|
|
9598
9612
|
// Files: hide-empty — only with changed files AND totals (a bare-count chip reads odd).
|
|
9599
9613
|
if (nf && fAdd) chips.push(`<span class="mx"><span class="mk">Files</span> <b>${nf}</b> <span class="mmut">· <i class="a">${esc(fAdd)}</i> <i class="d">${esc(fDel)}</i></span></span>`);
|
|
@@ -9609,7 +9623,7 @@ function saItemHtml(it, i){
|
|
|
9609
9623
|
if (it.recording) chips.push(it.recordingUrl
|
|
9610
9624
|
? `<a class="mx mlink" href="${esc(it.recordingUrl)}" target="_blank" rel="noopener" title="Watch the recording"><span class="mk">Verify</span> <b>recording</b>${arw}</a>`
|
|
9611
9625
|
: `<a class="mx mlink" data-saact="rec:${i}" style="cursor:pointer" title="Watch the recording"><span class="mk">Verify</span> <b>recording</b>${arw}</a>`);
|
|
9612
|
-
const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
|
|
9626
|
+
const tokCmp = tokUntied ? 'vs your recent average (no technique behind it)' : it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
|
|
9613
9627
|
const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
|
|
9614
9628
|
const metaDetail = (it.tokenOpt && tokPct > 0) ? `<div class="rmeta-detail" id="tokdetail${i}"><span class="k">How this run used tokens</span><span class="v">${esc(tokCmp)}${tokTech ? ` · ${esc(tokTech)}` : ''}</span></div>` : '';
|
|
9615
9629
|
// §1.5-3: with Open it moved into the body, a card with no PR / tokens / tests has no
|
|
@@ -9726,6 +9740,15 @@ function saItemHtml(it, i){
|
|
|
9726
9740
|
const riskHtml = risk?.shouldPause
|
|
9727
9741
|
? `<div class="lg-risk"><svg viewBox="0 0 24 24"><path d="M12 8v5M12 16.5v.5"/><path d="M10.3 3.9 2.4 18a1.9 1.9 0 0 0 1.7 2.9h15.8a1.9 1.9 0 0 0 1.7-2.9L13.7 3.9a1.9 1.9 0 0 0-3.4 0z"/></svg>Needs your review — touches a sensitive area${risk.categories?.length ? `: ${esc(risk.categories.map((c) => c.label).join(', '))}` : ''}</div>`
|
|
9728
9742
|
: '';
|
|
9743
|
+
// ── PR-requested-but-no-code note (goal #751, Masa 2026-07-26): a wantsPR goal whose
|
|
9744
|
+
// worker ended with zero changed files and no proof — most often a lane-conflict
|
|
9745
|
+
// report ("空き次第自動再開します") that could not actually resume, since a `claude -p`
|
|
9746
|
+
// run is one-shot. Advisory only (isNothingVerifiableForPR never blocks — Failedを作ら
|
|
9747
|
+
// ない方針を尊重), so the reviewer sees it and decides instead of it silently reading
|
|
9748
|
+
// as a normal completed PR review. ──
|
|
9749
|
+
const prNoteHtml = it.prNote
|
|
9750
|
+
? `<div class="lg-risk"><svg viewBox="0 0 24 24"><path d="M12 8v5M12 16.5v.5"/><path d="M10.3 3.9 2.4 18a1.9 1.9 0 0 0 1.7 2.9h15.8a1.9 1.9 0 0 0 1.7-2.9L13.7 3.9a1.9 1.9 0 0 0-3.4 0z"/></svg>${esc(it.prNote)}</div>`
|
|
9751
|
+
: '';
|
|
9729
9752
|
// ── Activity (spec §3): ONE Linear-docs pulldown. The "Activity" heading is plain; the
|
|
9730
9753
|
// chevron sits on the FIRST line (the opening "You …" row); tapping it opens the rest
|
|
9731
9754
|
// below, offset-indented. The review Discussion folds in at the bottom of the body. The
|
|
@@ -9817,6 +9840,7 @@ function saItemHtml(it, i){
|
|
|
9817
9840
|
${S('Proof', proofInner)}
|
|
9818
9841
|
${S('What changed', changedBody)}
|
|
9819
9842
|
${riskHtml}
|
|
9843
|
+
${prNoteHtml}
|
|
9820
9844
|
${conversationSec}
|
|
9821
9845
|
${activitySec}
|
|
9822
9846
|
${S('Workflow', saWorkflowHtml(it))}
|
|
@@ -10072,13 +10096,32 @@ function saMoveFeedbackOutOfReview(it, body){
|
|
|
10072
10096
|
if (task?.id) upsert(state.tasks, task);
|
|
10073
10097
|
const at = SA.items.findIndex((row) => row.goalId === it.goalId);
|
|
10074
10098
|
const next = at >= 0 ? (SA.items[at + 1] || SA.items[at - 1]) : null;
|
|
10099
|
+
// Was the reviewer actually looking at the card that just left Review (vs having already
|
|
10100
|
+
// scrolled past it to read another one)? If it's fully above the visible scroll area, the
|
|
10101
|
+
// reviewer is reading something else — snapping the view to the next card's top would yank
|
|
10102
|
+
// it out from under them (Masa 2026-07-26: replying to the card above and having the one
|
|
10103
|
+
// you're reading jump to the top was reported as stressful).
|
|
10104
|
+
const bodyEl = document.querySelector('#seeall .sa-body');
|
|
10105
|
+
const cardEl = saCardEl(it.goalId);
|
|
10106
|
+
const priorScrollTop = bodyEl?.scrollTop ?? 0;
|
|
10107
|
+
const removedHeight = cardEl ? cardEl.getBoundingClientRect().height : 0;
|
|
10108
|
+
const cardAboveViewport = !!(cardEl && bodyEl && cardEl.getBoundingClientRect().bottom <= bodyEl.getBoundingClientRect().top + 1);
|
|
10075
10109
|
if (at >= 0) SA.items.splice(at, 1);
|
|
10076
10110
|
render();
|
|
10077
10111
|
if (SA.solo === it.goalId || !SA.items.length) { saRender(); saClose(); return; }
|
|
10078
10112
|
SA.cur = next?.goalId ?? SA.items[0]?.goalId ?? null;
|
|
10079
|
-
|
|
10080
|
-
|
|
10081
|
-
|
|
10113
|
+
if (cardAboveViewport) {
|
|
10114
|
+
// Keep reading where we were: compensate for the removed card's height instead of
|
|
10115
|
+
// forcing a scroll-to-top of whatever is now "current".
|
|
10116
|
+
SA.focusGid = null;
|
|
10117
|
+
saRender();
|
|
10118
|
+
const newBody = document.querySelector('#seeall .sa-body');
|
|
10119
|
+
if (newBody) newBody.scrollTop = Math.max(0, priorScrollTop - removedHeight);
|
|
10120
|
+
} else {
|
|
10121
|
+
SA.focusGid = SA.cur;
|
|
10122
|
+
SA.focusScrolled = false;
|
|
10123
|
+
saRender();
|
|
10124
|
+
}
|
|
10082
10125
|
}
|
|
10083
10126
|
function saCardEl(goalId){ return document.querySelector(`#seeall .sa-item[data-gid="${goalId}"]`); }
|
|
10084
10127
|
// Flip one card to its "Working on it" head without redrawing anything else (決定2).
|
|
@@ -10262,7 +10305,8 @@ $('seeall').addEventListener('click', async (e) => {
|
|
|
10262
10305
|
// SA.items[i] lookup below.
|
|
10263
10306
|
if (k === 'foldopen') return openReviewChecklist(Number(v));
|
|
10264
10307
|
if (k === 'foldundo') {
|
|
10265
|
-
|
|
10308
|
+
let r; try { r = await fetch(withKey(`/api/goals/${v}/unfold`), { method: 'POST' }); }
|
|
10309
|
+
catch { showErr('Undo failed.'); return; }
|
|
10266
10310
|
if (r && r.ok) await refresh(); else showErr('Undo failed.');
|
|
10267
10311
|
return;
|
|
10268
10312
|
}
|
|
@@ -10275,7 +10319,8 @@ $('seeall').addEventListener('click', async (e) => {
|
|
|
10275
10319
|
if (k === 'reverify') {
|
|
10276
10320
|
b.disabled = true;
|
|
10277
10321
|
b.textContent = '再検証中…';
|
|
10278
|
-
|
|
10322
|
+
let r; try { r = await fetch(withKey(`/api/goals/${it.goalId}/reverify`), { method: 'POST' }); }
|
|
10323
|
+
catch { showErr('再検証に失敗'); b.disabled = false; return; }
|
|
10279
10324
|
if (r.ok) await refresh(); else showErr('再検証に失敗');
|
|
10280
10325
|
return;
|
|
10281
10326
|
}
|
|
@@ -10460,7 +10505,6 @@ function renderCtxBar(){
|
|
|
10460
10505
|
const grp = $('ctxbrgrp');
|
|
10461
10506
|
if (grp) grp.style.display = branch ? '' : 'none';
|
|
10462
10507
|
const bn = $('ctxbranchname'); if (bn) bn.textContent = branch;
|
|
10463
|
-
renderWorktreeChip();
|
|
10464
10508
|
const menu = $('ctxdirmenu'); if (!menu) return;
|
|
10465
10509
|
const cur = dir;
|
|
10466
10510
|
const opts = (state.repos ?? [])
|
|
@@ -10472,31 +10516,12 @@ function renderCtxBar(){
|
|
|
10472
10516
|
const openRow = `<button type="button" class="appopt ctxopen" id="ctxopenfolder" title="Open the OS folder chooser"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><path d="M12 11v6M9 14h6"/></svg><span class="nm">Open folder…</span></button>`;
|
|
10473
10517
|
menu.innerHTML = `${opts}${openRow}<div class="ctxpath"><input type="text" id="ctxdirin" placeholder="~/path/to/repo" aria-label="Folder path" spellcheck="false"><button type="button" class="appopt" id="ctxdiruse" style="width:auto">Use</button></div><div class="ctxmsg" id="ctxdirmsg"></div>`;
|
|
10474
10518
|
}
|
|
10475
|
-
// Workspace mode
|
|
10476
|
-
//
|
|
10477
|
-
// a
|
|
10478
|
-
function
|
|
10479
|
-
function
|
|
10480
|
-
function
|
|
10481
|
-
function goalWorkspaceMode(g){ return (g?.workspaceMode === 'direct-workspace' || g?.worktree === false) ? 'direct-workspace' : 'isolated-worktree'; }
|
|
10482
|
-
function workspaceModeText(g){ return goalWorkspaceMode(g) === 'direct-workspace' ? 'direct workspace' : 'isolated worktree'; }
|
|
10483
|
-
function renderWorktreeChip(){
|
|
10484
|
-
const el = $('ctxwt'); if (!el) return;
|
|
10485
|
-
const isolated = worktreeOn();
|
|
10486
|
-
const proj = state.projects.find((p) => p.id === state.active);
|
|
10487
|
-
el.setAttribute('aria-checked', isolated ? 'true' : 'false');
|
|
10488
|
-
const label = $('ctxwtlabel');
|
|
10489
|
-
if (label) label.textContent = isolated ? 'isolated worktree' : 'direct workspace';
|
|
10490
|
-
el.title = isolated
|
|
10491
|
-
? 'isolated worktree: default clean copy; this folder is not edited.'
|
|
10492
|
-
: 'direct workspace: inherits the current state and edits this folder.';
|
|
10493
|
-
const warn = $('ctxworkwarn');
|
|
10494
|
-
if (warn) {
|
|
10495
|
-
const dirty = !isolated && !!proj?.dirty;
|
|
10496
|
-
warn.hidden = !dirty;
|
|
10497
|
-
warn.textContent = dirty ? `Dirty workspace warning: direct workspace will edit ${folderName(proj?.dir) || 'this folder'} as it is now.` : '';
|
|
10498
|
-
}
|
|
10499
|
-
}
|
|
10519
|
+
// Workspace mode is fixed to the isolated worktree (clean separate checkout). The
|
|
10520
|
+
// direct-workspace escape hatch was removed after two accidents where a stray click
|
|
10521
|
+
// edited a shared checkout directly; there was no real upside to keep it around for.
|
|
10522
|
+
function workspaceMode(){ return 'isolated-worktree'; }
|
|
10523
|
+
function goalWorkspaceMode(){ return 'isolated-worktree'; }
|
|
10524
|
+
function workspaceModeText(){ return 'isolated worktree'; }
|
|
10500
10525
|
// Asked for once, and only when the menu is first opened — the same "only when you look
|
|
10501
10526
|
// at it" rule the implementation log and the conversation follow.
|
|
10502
10527
|
async function loadRepos(){
|
|
@@ -10557,11 +10582,6 @@ $('ctxdirbtn').onclick = (e) => {
|
|
|
10557
10582
|
menu.classList.toggle('show');
|
|
10558
10583
|
if (opening) { renderCtxBar(); loadRepos(); } // no-op once boot's fetch has landed
|
|
10559
10584
|
};
|
|
10560
|
-
$('ctxwt').onclick = (e) => {
|
|
10561
|
-
e.stopPropagation();
|
|
10562
|
-
localStorage.setItem(worktreeKey(), worktreeOn() ? '0' : '1');
|
|
10563
|
-
renderWorktreeChip();
|
|
10564
|
-
};
|
|
10565
10585
|
$('ctxdirmenu').addEventListener('click', (e) => {
|
|
10566
10586
|
e.stopPropagation();
|
|
10567
10587
|
if (e.target.closest('#ctxopenfolder')) { openFolderNative(); return; }
|
|
@@ -11710,6 +11730,7 @@ function buildGearPop(){
|
|
|
11710
11730
|
if (!(await confirmDelete({ title: 'Log out of Google?', body: 'You can sign back in anytime.', confirmLabel: 'Log out', confirmDanger: false }))) return;
|
|
11711
11731
|
signOutBtn.disabled = true;
|
|
11712
11732
|
try { await fetch(withKey('/api/signout'), { method: 'POST' }); recentlySignedOut = true; }
|
|
11733
|
+
catch {}
|
|
11713
11734
|
finally { await refresh(); if ($('gearPop')) buildGearPop(); }
|
|
11714
11735
|
};
|
|
11715
11736
|
const rulesBtn = pop.querySelector('[data-grules]');
|
package/engine/lib.mjs
CHANGED
|
@@ -655,9 +655,25 @@ export function estimateTokenOptimization({ usage = null, weightedTokens = null,
|
|
|
655
655
|
// layer did to save tokens, and it is the opposite: the model is the user's
|
|
656
656
|
// own pick from the composer, and we deliberately never override it
|
|
657
657
|
// (Masa 2026-07-22). Every remaining line is an action this layer took.
|
|
658
|
+
|
|
659
|
+
// Masa 2026-07-26: a positive `baseline` percent is a comparison against past
|
|
660
|
+
// goals, not proof any technique was applied THIS run — a small goal that
|
|
661
|
+
// followed a run of large ones scores a big "saved" number purely from the
|
|
662
|
+
// size mix, with nothing the user actually did behind it. Only call it
|
|
663
|
+
// "saved" when a real technique signal fired this run (cache reuse, a fresh
|
|
664
|
+
// handoff, /compact, /clear); otherwise it is a true fact (this run was
|
|
665
|
+
// smaller/larger than usual) but not a causal claim, so we relabel it
|
|
666
|
+
// `baseline-untied` and the renderer must phrase it as a comparison, not a
|
|
667
|
+
// "you saved X% by doing Y".
|
|
668
|
+
const techniqueDriven = cacheRatio > 0 || usedHandoff || sessionMode === 'cold-handoff' || usedCompact || sessionMode === 'compact' || usedClear;
|
|
669
|
+
if (basis === 'baseline' && !techniqueDriven) basis = 'baseline-untied';
|
|
670
|
+
|
|
658
671
|
if (basis === 'baseline' && percent > 0) {
|
|
659
672
|
techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
|
|
660
673
|
}
|
|
674
|
+
if (basis === 'baseline-untied') {
|
|
675
|
+
techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
|
|
676
|
+
}
|
|
661
677
|
if (!techniques.length) techniques.push(`${Math.round(raw / 1000)}k tokens this run (no cache reuse yet)`);
|
|
662
678
|
|
|
663
679
|
return { rawTokens: raw, weightedTokens: weighted, savedTokens, percent, basis, techniques: [...new Set(techniques)] };
|
|
@@ -2254,6 +2270,21 @@ export function isNothingVerifiable({ changedFiles = [], hasPassCondition = fals
|
|
|
2254
2270
|
return hasPassCondition && changedFiles.length === 0 && !hasAnyProof;
|
|
2255
2271
|
}
|
|
2256
2272
|
|
|
2273
|
+
// goal-751 (2026-07-26): a wantsPR goal is an explicit ask for a PR, so a worker
|
|
2274
|
+
// that ends with zero changed files and no proof did not deliver what was asked —
|
|
2275
|
+
// most often a lane-conflict report ("空き次第自動再開します") that cannot actually
|
|
2276
|
+
// happen, since a `claude -p` worker is a one-shot batch run with no way to resume
|
|
2277
|
+
// itself later. taskStatusAfterVerify still reads a clean exit (code===0, no
|
|
2278
|
+
// verdict) as 'done', so this reached Review looking exactly like a normal
|
|
2279
|
+
// completed PR. Same shape as isNothingVerifiable above but keyed on wantsPR
|
|
2280
|
+
// instead of passCondition (a wantsPR goal has no passCondition of its own — the
|
|
2281
|
+
// PR ask itself is the promise). Advisory only, like proofNote/verificationInfra
|
|
2282
|
+
// Error below: the caller sets a note, it never blocks (Failedを作らない方針, see
|
|
2283
|
+
// docs/HANDOFF-2026-07-25-verify-gate-default-off.md).
|
|
2284
|
+
export function isNothingVerifiableForPR({ wantsPR = false, changedFiles = [], hasAnyProof = false } = {}) {
|
|
2285
|
+
return Boolean(wantsPR) && changedFiles.length === 0 && !hasAnyProof;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2257
2288
|
// チェックリストUIで要件が全件Approveされたら goal を review→done へ進める。
|
|
2258
2289
|
// 以前は「PRありgoalはマージが唯一の完了トリガー」として弾いていたが、それだと
|
|
2259
2290
|
// 承認しても done が永続化されず次のrefreshで review に戻る不具合になっていた
|
|
@@ -2542,10 +2573,10 @@ export function hasGitChanges(porcelainStatus) {
|
|
|
2542
2573
|
return Boolean(String(porcelainStatus ?? '').trim());
|
|
2543
2574
|
}
|
|
2544
2575
|
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2576
|
+
// Direct workspace is retired (2026-07-26): two accidents where a stray client-side
|
|
2577
|
+
// toggle edited a shared checkout directly. This always returns isolated-worktree
|
|
2578
|
+
// regardless of what a client sends, so a stale/tampered client can't force direct.
|
|
2579
|
+
export function resolveWorkspaceMode(_input) {
|
|
2549
2580
|
return 'isolated-worktree';
|
|
2550
2581
|
}
|
|
2551
2582
|
|
|
@@ -3151,6 +3182,13 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
|
|
|
3151
3182
|
// (research, summaries, answers) simply skip this — the worker decides,
|
|
3152
3183
|
// never us.
|
|
3153
3184
|
`- 人が実際に動かして結果を見られる依頼なら、最後にプロジェクト直下へ ${RUN_DECL_FILE} を書く: {"cmd": "<起動コマンド>", "url": "<開くURL>", "note": "<何が見えるか1行>", "check": "<人に何を見て判断してほしいか1行>", "title": "<この作業のチケット名。対象が分かる程度に説明的で動作を含む短い名前。例「看板モードの追加」「ヘッダのズレの修正」。依頼文をそのまま写さない>"}。Manager はこれを使って人に「動かして見る」と「何を見ればいいか」を出す。動かして見せるものが無い依頼(調査・要約・質問への回答など)では書かなくてよい。`,
|
|
3185
|
+
// `file` (goal #744):起動不要な静的成果物(比較HTML、レポート等)専用。この
|
|
3186
|
+
// クラスの依頼は cmd/url を使う理由がなく、worker が唯一持っている手(file://
|
|
3187
|
+
// URL)は http(s) しか許さないバリデーションに毎回弾かれ、しかも弾かれた理由が
|
|
3188
|
+
// 「JSONが壊れている」という誤診断メッセージだったため、同じ file:// を書き直
|
|
3189
|
+
// して再度失敗するループが実際に2回連続で起きた(goal #744)。ここで file を
|
|
3190
|
+
// 明示し、file:// URLを書く選択肢自体を塞ぐ。
|
|
3191
|
+
`- 起動不要な静的ファイル(HTML比較ページ・レポート等、npm run devのようなコマンドを必要としないもの)を見せたい場合は、cmd/url でなく "file": "<プロジェクト内の相対パス>" を使う。例: {"file": "docs/design/options.html", "title": "3案の比較"}。file:// URLは絶対に書かない(urlはhttp(s)以外だと宣言ごと弾かれる)。`,
|
|
3154
3192
|
// Root-cause prevention (goal #733, 2026-07-26): the worker Wrote this file,
|
|
3155
3193
|
// then Edit'd it, and the edit broke the JSON mid-edit — Manager silently
|
|
3156
3194
|
// dropped the whole declaration (fixed separately: it now surfaces an honest
|
|
@@ -3186,6 +3224,22 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
|
|
|
3186
3224
|
// the request is a change-something request, and most requests are not.
|
|
3187
3225
|
agentName === 'codex' ? '' : `- 同じ画面の「変える前」と「変えた後」を撮ったなら、${RUN_DECL_FILE} に \`"compare": ["<前の画像>.png", "<後の画像>.png"]\` と書いてよい(その2枚が同じ場所の前後だと分かるのは撮った本人だけ)。人には並べて、違う部分に印を付けて見せる。3案を並べた等、前後の関係が無い画像には書かない。`,
|
|
3188
3226
|
`- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
|
|
3227
|
+
// Root-cause fix (Masa 2026-07-26, goal #727 review): a worker fixed a real bug
|
|
3228
|
+
// (the To-Do lane's Doing ring had zero animation) but its report also claimed
|
|
3229
|
+
// the project-rail dot was "static" before — it actually already had a working
|
|
3230
|
+
// pulse animation (fsrundot), just not a spin. The worker never re-read the
|
|
3231
|
+
// pre-change code to check its own diagnosis; it narrated a plausible-sounding
|
|
3232
|
+
// "before" state instead of a verified one, and nothing downstream caught it.
|
|
3233
|
+
'- 「〜は動いていなかった/静止していた」のように直す前の状態を断定する場合は、その断定をする前に修正前の該当コード(該当CSS/関数)を実際に読んで確認すること。読まずに推測で「動いていない」と書かない。読んで確認した箇所は、報告に該当コードを短く引用すること。',
|
|
3234
|
+
// goal #751 (2026-07-26): you are a one-shot batch process — once this run exits,
|
|
3235
|
+
// nothing resumes you. If something outside your control blocks you (a lane/file
|
|
3236
|
+
// lock held by another agent, a missing permission, etc.), do NOT report that you
|
|
3237
|
+
// will "resume automatically once it frees up" or similar — that is not something
|
|
3238
|
+
// you or anything in this run can do. Say plainly that you made no code changes and
|
|
3239
|
+
// why, so the goal reads as unfinished/blocked rather than as a completed PR. A
|
|
3240
|
+
// human or the CTO will re-dispatch the work (e.g. via manager_reply) once the
|
|
3241
|
+
// blocker clears.
|
|
3242
|
+
'- あなたは1回きりのバッチ実行であり、このプロセスが終了した後に自動で再開することはない。レーン/ファイルの競合など自分では解けない理由で着手できない場合、「空き次第自動で再開します」のような、この実行の外では実現できない約束を書かないこと。何をどう変更できなかったか(コード変更0件であること)を正直に書き、ゴールが完了したPRではなく未完了/ブロックとして扱われるように終えること。再開は人間かCTOが改めて指示する。',
|
|
3189
3243
|
].filter(Boolean).join('\n');
|
|
3190
3244
|
}
|
|
3191
3245
|
|
|
@@ -3203,6 +3257,37 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
|
|
|
3203
3257
|
// button that opens the wrong thing is the fabricated-proof bug all over again.
|
|
3204
3258
|
export const RUN_DECL_FILE = '.manager-run.json';
|
|
3205
3259
|
|
|
3260
|
+
// A declaration that parsed as JSON but breaks a rule (bad url scheme, an
|
|
3261
|
+
// unsafe `file` path) is NOT the same failure as unparseable JSON — the first
|
|
3262
|
+
// is a worker mistake with a specific, sayable reason; the second is a broken
|
|
3263
|
+
// file. Conflating them (goal #744) sent the worker back a message that only
|
|
3264
|
+
// describes JSON corruption ("was not usable JSON"), so a worker whose actual
|
|
3265
|
+
// mistake was a file:// url read that as "my JSON broke" and rewrote the exact
|
|
3266
|
+
// same file:// url a second time. This return shape lets the caller tell them
|
|
3267
|
+
// apart: `null` stays "nothing usable to report" (unparseable / genuinely
|
|
3268
|
+
// empty), an `{ error, message }` object carries a reason a human — or the
|
|
3269
|
+
// worker reading its own feedback — can act on.
|
|
3270
|
+
function runDeclError(error, message) {
|
|
3271
|
+
return { error, message };
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
// `file` (goal #744, this PR): a static, already-built artifact — an HTML
|
|
3275
|
+
// comparison page, a report — that needs no process and has no http(s) URL of
|
|
3276
|
+
// its own. Declared as a path relative to the project so the Manager can copy
|
|
3277
|
+
// it out of the worktree (same pattern as SHOT_DIR) and serve it from a URL
|
|
3278
|
+
// that survives after the worktree is gone. Only a syntactic check lives here
|
|
3279
|
+
// (no absolute path, no ".." segment, no backslash/drive letter, no NUL) —
|
|
3280
|
+
// server.mjs still re-resolves against the worktree root before it reads
|
|
3281
|
+
// anything, because a syntax check alone doesn't rule out a symlink.
|
|
3282
|
+
export function isSafeRunDeclFilePath(p) {
|
|
3283
|
+
if (typeof p !== 'string' || !p) return false;
|
|
3284
|
+
if (p.includes('\0')) return false;
|
|
3285
|
+
if (p.startsWith('/') || p.startsWith('\\')) return false;
|
|
3286
|
+
if (/^[a-zA-Z]:[\\/]/.test(p)) return false; // Windows drive letter
|
|
3287
|
+
const norm = p.replace(/\\/g, '/');
|
|
3288
|
+
return !norm.split('/').some((seg) => seg === '..');
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3206
3291
|
export function parseRunDeclaration(text) {
|
|
3207
3292
|
let raw;
|
|
3208
3293
|
try { raw = JSON.parse(String(text ?? '')); } catch { return null; }
|
|
@@ -3210,6 +3295,7 @@ export function parseRunDeclaration(text) {
|
|
|
3210
3295
|
const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
|
|
3211
3296
|
const cmd = str(raw.cmd, 500);
|
|
3212
3297
|
const url = str(raw.url, 500);
|
|
3298
|
+
const file = str(raw.file, 500);
|
|
3213
3299
|
// `compare` (Masa 2026-07-22): which TWO of the worker's own screenshots are
|
|
3214
3300
|
// the same view before and after. Only the agent that did the work knows that
|
|
3215
3301
|
// two images relate — a request for three design options has no "after" at
|
|
@@ -3219,9 +3305,16 @@ export function parseRunDeclaration(text) {
|
|
|
3219
3305
|
const compare = parseComparePair(raw.compare);
|
|
3220
3306
|
const title = str(raw.title, 60);
|
|
3221
3307
|
// Nothing to start, nothing to open, nothing named, and no pair to show = nothing to look at.
|
|
3222
|
-
if (!cmd && !url && !compare && !title) return null;
|
|
3223
|
-
// Only http(s): the button opens this in the user's browser.
|
|
3224
|
-
|
|
3308
|
+
if (!cmd && !url && !file && !compare && !title) return null;
|
|
3309
|
+
// Only http(s): the button opens this in the user's browser. A file:// (or
|
|
3310
|
+
// any other scheme) is a worker mistake with a specific fix — declare `file`
|
|
3311
|
+
// instead — not a JSON parse failure, so it gets its own reason.
|
|
3312
|
+
if (url && !/^https?:\/\//i.test(url)) {
|
|
3313
|
+
return runDeclError('bad-url-scheme', "url must start with http:// or https:// — a file:// path can't be opened from a remote browser session; declare an artifact file instead of a file:// url");
|
|
3314
|
+
}
|
|
3315
|
+
if (file && !isSafeRunDeclFilePath(file)) {
|
|
3316
|
+
return runDeclError('bad-file-path', 'file must be a path inside the project — no leading /, no .. segments');
|
|
3317
|
+
}
|
|
3225
3318
|
// `check` (Masa 2026-07-22): what to look at, in the working AI's own words. It used
|
|
3226
3319
|
// to come from the intake AI's plan — this layer inventing a pass condition for a
|
|
3227
3320
|
// request it only read. The AI that did the work states it here instead, in the same
|
|
@@ -3232,7 +3325,7 @@ export function parseRunDeclaration(text) {
|
|
|
3232
3325
|
// the user's own sentence, repeated back at them in the one place that is
|
|
3233
3326
|
// supposed to say what the work WAS. The agent that just did it can name it in
|
|
3234
3327
|
// one line, and it is the only party that knows what actually got done.
|
|
3235
|
-
return { cmd, url, note: str(raw.note, 300), check: str(raw.check, 300), compare, title };
|
|
3328
|
+
return { cmd, url, file, note: str(raw.note, 300), check: str(raw.check, 300), compare, title };
|
|
3236
3329
|
}
|
|
3237
3330
|
|
|
3238
3331
|
// Exactly two shot file names, in [before, after] order. Anything else is
|
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, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath } from './lib.mjs';
|
|
24
|
-
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, 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, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession } from './lib.mjs';
|
|
24
|
+
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, 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, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession } from './lib.mjs';
|
|
25
25
|
import { createSerialQueue } from './lib.mjs';
|
|
26
26
|
import { isPrClosed } from './lib.mjs';
|
|
27
27
|
import { collectProjectRules } from './lib.mjs';
|
|
@@ -2406,6 +2406,17 @@ function readRunDeclaration(task, workDir) {
|
|
|
2406
2406
|
try { raw = readFileSync(file, 'utf8'); } catch { return; }
|
|
2407
2407
|
try { unlinkSync(file); } catch { /* best effort — never fail a task over cleanup */ }
|
|
2408
2408
|
const run = parseRunDeclaration(raw);
|
|
2409
|
+
// A declaration that parsed as JSON but broke a rule (bad url scheme, unsafe
|
|
2410
|
+
// file path) is a worker mistake with a specific, sayable fix — NOT the same
|
|
2411
|
+
// failure as unparseable JSON. Goal #744: conflating the two sent the worker
|
|
2412
|
+
// "was not usable JSON" for a file:// url, so it "fixed" the JSON (which was
|
|
2413
|
+
// never broken) and wrote the exact same file:// url a second time. `run` is
|
|
2414
|
+
// `{ error, message }` here, distinct from the `null` case below.
|
|
2415
|
+
if (run && run.error) {
|
|
2416
|
+
task.runDeclError = { reason: run.error, message: run.message, snippet: String(raw ?? '').slice(0, 200) };
|
|
2417
|
+
sendAct(task, `run declaration ignored — ${run.message}`);
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2409
2420
|
if (!run) {
|
|
2410
2421
|
// The worker DID try to declare something (the file exists) but it came out
|
|
2411
2422
|
// unusable — e.g. a Write followed by an Edit that broke the JSON mid-edit
|
|
@@ -2417,17 +2428,62 @@ function readRunDeclaration(task, workDir) {
|
|
|
2417
2428
|
// actually written (raw content is deleted above and never otherwise
|
|
2418
2429
|
// recoverable) so both the activity log and the Review card can say WHY,
|
|
2419
2430
|
// instead of the human having to guess or dig through file loss.
|
|
2420
|
-
task.runDeclError = { snippet: String(raw ?? '').slice(0, 200) };
|
|
2431
|
+
task.runDeclError = { reason: 'invalid-json', snippet: String(raw ?? '').slice(0, 200) };
|
|
2421
2432
|
sendAct(task, `run declaration ignored — ${RUN_DECL_FILE} was not usable JSON: ${task.runDeclError.snippet.slice(0, 80)}`);
|
|
2422
2433
|
return;
|
|
2423
2434
|
}
|
|
2424
2435
|
task.run = { ...run, dir: workDir };
|
|
2425
|
-
|
|
2436
|
+
collectRunDeclFileArtifact(task, workDir);
|
|
2437
|
+
const how = task.run.cmd ?? task.run.url;
|
|
2426
2438
|
if (how) sendProcessAct(task, `Worker declared how to see the result: ${how}`);
|
|
2427
2439
|
if (run.title) sendProcessAct(task, `Worker named the work: ${run.title}`);
|
|
2428
2440
|
if (run.compare) sendProcessAct(task, `Worker declared a before/after pair: ${run.compare.join(' → ')}`);
|
|
2429
2441
|
}
|
|
2430
2442
|
|
|
2443
|
+
// `file` (goal #744): a static, already-built artifact that needs no process
|
|
2444
|
+
// and has no http(s) URL of its own — an HTML comparison page, a report. Same
|
|
2445
|
+
// pattern as collectWorkerShots: copy it out of the worktree (which is about
|
|
2446
|
+
// to be diffed/discarded) into logDir/uploads and give it a URL that survives.
|
|
2447
|
+
// parseRunDeclaration already checked the path is syntactically inside the
|
|
2448
|
+
// project (no leading /, no ..); this re-resolves against the worktree root
|
|
2449
|
+
// before reading anything, because a syntax check alone doesn't rule out a
|
|
2450
|
+
// symlink pointing outside it.
|
|
2451
|
+
function collectRunDeclFileArtifact(task, workDir) {
|
|
2452
|
+
const rel = task.run?.file;
|
|
2453
|
+
if (!rel) return;
|
|
2454
|
+
const root = resolve(workDir) + '/';
|
|
2455
|
+
const abs = resolve(workDir, rel);
|
|
2456
|
+
if (!(abs + '/').startsWith(root) && abs !== root.slice(0, -1)) {
|
|
2457
|
+
task.runDeclError = { reason: 'file-outside-project', message: `file "${rel}" resolves outside the project`, snippet: rel };
|
|
2458
|
+
sendAct(task, `run declaration ignored — ${task.runDeclError.message}.`);
|
|
2459
|
+
task.run = { ...task.run, file: null };
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
let data;
|
|
2463
|
+
try { data = readFileSync(abs); } catch (err) {
|
|
2464
|
+
task.runDeclError = { reason: 'file-unreadable', message: `file "${rel}" could not be read: ${String(err?.code ?? err).slice(0, 80)}`, snippet: rel };
|
|
2465
|
+
sendAct(task, `run declaration's file "${rel}" could not be read: ${String(err?.code ?? err).slice(0, 80)}`);
|
|
2466
|
+
task.run = { ...task.run, file: null };
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
const outDir = join(logDir, 'uploads');
|
|
2470
|
+
mkdirSync(outDir, { recursive: true });
|
|
2471
|
+
const safe = basename(rel).replace(/[^\w.-]/g, '_').slice(-80);
|
|
2472
|
+
const outFile = `${Date.now()}-artifact-${task.id}-${safe}`;
|
|
2473
|
+
try {
|
|
2474
|
+
writeFileSync(join(outDir, outFile), data);
|
|
2475
|
+
} catch (err) {
|
|
2476
|
+
task.runDeclError = { reason: 'file-attach-failed', message: `file "${rel}" could not be attached: ${String(err?.code ?? err).slice(0, 80)}`, snippet: rel };
|
|
2477
|
+
sendAct(task, `run declaration's file "${rel}" could not be attached: ${String(err?.code ?? err).slice(0, 80)}`);
|
|
2478
|
+
task.run = { ...task.run, file: null };
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
// No process to start for a static file — the Review card renders this as a
|
|
2482
|
+
// plain link (like a PR link), not the "Start it" button.
|
|
2483
|
+
task.run = { ...task.run, url: `/upload/${outFile}`, cmd: null };
|
|
2484
|
+
sendProcessAct(task, `Worker attached a file to open: ${rel}`);
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2431
2487
|
// A direct/shared workspace can still contain a declaration left by an older
|
|
2432
2488
|
// run (for example when that run was interrupted before readRunDeclaration).
|
|
2433
2489
|
// Remove it before this task starts so only a file created by THIS worker can
|
|
@@ -3532,6 +3588,18 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
3532
3588
|
hasPassCondition: siblings.some((t) => t.passCondition),
|
|
3533
3589
|
hasAnyProof: siblings.some((t) => t.proof),
|
|
3534
3590
|
});
|
|
3591
|
+
// goal-751: wantsPR but the worker changed nothing and proved nothing (typically a
|
|
3592
|
+
// lane-conflict report promising an automatic resume that a one-shot `claude -p`
|
|
3593
|
+
// run can never deliver). Advisory only — see isNothingVerifiableForPR in lib.mjs.
|
|
3594
|
+
const prNothingVerifiable = isNothingVerifiableForPR({
|
|
3595
|
+
wantsPR: goal.wantsPR,
|
|
3596
|
+
changedFiles,
|
|
3597
|
+
hasAnyProof: siblings.some((t) => t.proof),
|
|
3598
|
+
});
|
|
3599
|
+
goal.prNote = prNothingVerifiable
|
|
3600
|
+
? 'workerはコード変更をせずに終了しました(PRを求めていたのに0ファイル変更)。実装は行われていない可能性があります。'
|
|
3601
|
+
: null;
|
|
3602
|
+
if (goal.prNote) goalAct(`PR advisory (not blocking): ${goal.prNote}`);
|
|
3535
3603
|
if (testsFailing) {
|
|
3536
3604
|
const failure = recordGoalFailure(goal, {
|
|
3537
3605
|
kind: 'test',
|
|
@@ -3608,7 +3676,7 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
3608
3676
|
const preNote = goal.testResult?.preExistingOnly
|
|
3609
3677
|
? ` (${goal.testResult.preExisting?.length ?? 0} pre-existing test failure(s) ignored as unrelated to this change)`
|
|
3610
3678
|
: '';
|
|
3611
|
-
goalAct(`Verification gate passed; moving goal to ${goal.status}.${preNote}${goal.proofNote ? ` [proof advisory: ${goal.proofNote}]` : ''}`);
|
|
3679
|
+
goalAct(`Verification gate passed; moving goal to ${goal.status}.${preNote}${goal.proofNote ? ` [proof advisory: ${goal.proofNote}]` : ''}${goal.prNote ? ` [PR advisory: ${goal.prNote}]` : ''}`);
|
|
3612
3680
|
}
|
|
3613
3681
|
}
|
|
3614
3682
|
|
package/package.json
CHANGED