@galda/cli 0.10.49 → 0.10.51
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 +197 -21
- package/engine/lib.mjs +22 -0
- package/engine/server.mjs +44 -6
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -276,6 +276,17 @@
|
|
|
276
276
|
.gearpop{}, so out here it was invalid at computed-value time and fell back to
|
|
277
277
|
transparent — measured rgba(0,0,0,0). The chips were never filled to begin with. */
|
|
278
278
|
.ctxbar{display:flex;align-items:center;gap:6px;padding:0 2px 7px;background:none;border:0}
|
|
279
|
+
/* Feature A: "Replying to #NNN ✕" chip — neutral hairline pill above the composer
|
|
280
|
+
(real tokens only → theme-adaptive; the ✕ hover moves glyph colour only, §5.5). */
|
|
281
|
+
.replybind{display:flex;align-items:center;gap:6px;max-width:var(--fscenterw,760px);width:100%;margin:0 auto 6px;padding:0 2px}
|
|
282
|
+
.replybind[hidden]{display:none}
|
|
283
|
+
.replybind .rb-seg{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;
|
|
284
|
+
border:1px solid var(--hair);background:var(--panel);font:500 12px/1 var(--ui);color:var(--ink2)}
|
|
285
|
+
.replybind .rb-seg b{color:var(--ink);font-weight:600;font-family:var(--mono);font-variant-numeric:tabular-nums}
|
|
286
|
+
.replybind .rb-x{display:grid;place-items:center;width:24px;height:24px;border:0;border-radius:7px;background:transparent;
|
|
287
|
+
color:var(--ink3);cursor:pointer;transition:color .12s;flex:0 0 24px}
|
|
288
|
+
.replybind .rb-x:hover{color:var(--ink)}
|
|
289
|
+
.replybind .rb-x svg{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
279
290
|
.ctxchip{display:inline-flex;align-items:center;height:26px;padding:0;gap:0;border-radius:8px;
|
|
280
291
|
border:1px solid var(--hair);background:transparent;position:relative;min-width:0}
|
|
281
292
|
.ctxsel{position:relative}
|
|
@@ -472,7 +483,17 @@
|
|
|
472
483
|
scoped here, since every other .qsub stays a one-liner. */
|
|
473
484
|
.replynote{white-space:normal;overflow:visible;margin-top:14px;padding-top:12px;border-top:1px solid var(--hair)}
|
|
474
485
|
.replynote .qchips{margin-top:6px}
|
|
475
|
-
.
|
|
486
|
+
/* The .txr You/Claude rows are styled by `.redesign .txr` — which the See-all card
|
|
487
|
+
(.sa-item.redesign) provides, but the goal-detail overlay and review dialog do NOT.
|
|
488
|
+
There the same note rendered as unstyled run-in black text ("Youこんにちは" with no
|
|
489
|
+
gap/weight — Masa 2026-07-24 "弾"). Scope the row layout to .replynote too so the
|
|
490
|
+
reply thread reads the same wherever its box lives. */
|
|
491
|
+
.replynote .txr{display:flex;gap:12px;padding:5px 0;font-size:13px;line-height:1.55;align-items:baseline}
|
|
492
|
+
.replynote .txr .w{flex:0 0 46px;font-size:11px;color:var(--ink3)}
|
|
493
|
+
.replynote .txr.you .w,.replynote .txr.cl .w{color:var(--ink);font-weight:600}
|
|
494
|
+
.replynote .txr .m{flex:1;min-width:0;color:var(--ink2)}
|
|
495
|
+
.replynote .txr.cl .m{color:var(--ink)}
|
|
496
|
+
.replynote .txr.note .m{font-family:var(--mono);font-size:12px;color:var(--ink3)}
|
|
476
497
|
.ob-unsent{color:rgb(var(--danger-rgb));cursor:pointer}
|
|
477
498
|
[data-oresend]{cursor:pointer}
|
|
478
499
|
.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}
|
|
@@ -2283,7 +2304,7 @@
|
|
|
2283
2304
|
#fsRoot .trow .st{width:13px;height:13px;border-radius:50%;border:1.5px solid var(--hair2);flex:0 0 13px;position:relative;top:1px} /* top:1px = optical nudge so the whole status column (To Do/Doing/Failed/Done) centres on the row text, not the line-box (Masa 2026-07-24, board=C) */
|
|
2284
2305
|
/* Doing marker = a broken green ring that spins (pattern B · flagship #316). Running only —
|
|
2285
2306
|
.bad (failed) / .int (paused) stay static: no dead motion (DESIGN-RULES §5.5). Reuses @keyframes spin. */
|
|
2286
|
-
#fsRoot .trow .st.run{border-color:var(--green);border-top-color:transparent;background:none;box-shadow:0 0 6px color-mix(in srgb,var(--green) 40%,transparent);animation:spin 1.8s linear infinite}
|
|
2307
|
+
#fsRoot .trow .st.run{border-color:var(--green);border-top-color:transparent;background:none;box-shadow:0 0 6px color-mix(in srgb,var(--green) 40%,transparent);animation:spin 1.8s linear infinite;animation-delay:var(--spd,0ms)} /* --spd = wall-clock phase (set in render()) so a re-created board marker doesn't restart the spin at 0deg */
|
|
2287
2308
|
#fsRoot .trow .st.run.bad{border-color:var(--danger);background:radial-gradient(circle,rgba(var(--danger-rgb),.42),transparent 70%);animation:none;box-shadow:none}
|
|
2288
2309
|
#fsRoot .trow .st.run.int{border-color:var(--amber);background:radial-gradient(circle,rgba(var(--amber-rgb),.4),transparent 70%);animation:none;box-shadow:none}
|
|
2289
2310
|
@media (prefers-reduced-motion:reduce){#fsRoot .trow .st.run{animation:none}}
|
|
@@ -2956,6 +2977,9 @@
|
|
|
2956
2977
|
reply exists (progressive disclosure — DESIGN-RULES §1.5-3, "存在
|
|
2957
2978
|
しないものの器を先に見せない"). -->
|
|
2958
2979
|
<div class="peraanswer" id="chatReply" hidden></div>
|
|
2980
|
+
<!-- Feature A: when the composer is bound to the focused goal, this chip shows
|
|
2981
|
+
which conversation you're replying to; ✕ detaches back to new-goal mode. -->
|
|
2982
|
+
<div class="replybind" id="replyBind" hidden><span class="rb-seg"><span id="replyBindLabel">Replying to</span> <b id="replyBindNum"></b></span><button type="button" class="rb-x" id="replyBindX" title="New goal instead" aria-label="Detach"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
|
|
2959
2983
|
<div class="composer">
|
|
2960
2984
|
<div class="attachbar" id="attachbar" style="display:none"></div>
|
|
2961
2985
|
<!-- Slash-command palette (Masa決定 2026-07-07: chips→slash). Floats
|
|
@@ -4250,6 +4274,7 @@ function mergeGoalTimeline(goals, externalActivity, projectId){
|
|
|
4250
4274
|
// (.gsummary)が出ていればそこを、まだなければゴールメッセージ全体の末尾を狙う。
|
|
4251
4275
|
function goToGoalInChat(goalId){
|
|
4252
4276
|
state.selectedGoal = goalId;
|
|
4277
|
+
renderReplyBind(); // Feature A: the composer's "Replying to #NNN" chip follows the focused goal — renderStream() alone skips it (only render() calls it)
|
|
4253
4278
|
renderStream();
|
|
4254
4279
|
const goalEl = document.querySelector(`#stream [data-goalmsg="${goalId}"]`);
|
|
4255
4280
|
if (!goalEl) return;
|
|
@@ -6143,6 +6168,7 @@ $('gdRevisionInput').addEventListener('keydown', (e) => {
|
|
|
6143
6168
|
function render(){
|
|
6144
6169
|
renderProjects(); renderStream(); renderQueue(); renderTasks(); renderActPanel(); wireWfEditButtons();
|
|
6145
6170
|
renderCtxBar(); // the folder above the composer follows the active project
|
|
6171
|
+
renderReplyBind(); // Feature A: show/hide the "Replying to #NNN" chip for the focused goal
|
|
6146
6172
|
renderFlagship();
|
|
6147
6173
|
if (state.reviewOpenGoal != null) renderReviewChecklist();
|
|
6148
6174
|
if (state.goalDetailOpen != null) renderGoalDetail();
|
|
@@ -6195,6 +6221,11 @@ function renderFlagship(){
|
|
|
6195
6221
|
if (state.layout !== 'flagship') return;
|
|
6196
6222
|
const root = $('fsRoot'); if (!root) return;
|
|
6197
6223
|
root.dataset.rv = String(fsUI.rv);
|
|
6224
|
+
// Wall-clock phase for the spinning markers: any ring re-created this render (the board
|
|
6225
|
+
// re-renders often on progress %) starts at the angle a continuous one would be at now,
|
|
6226
|
+
// instead of snapping back to 0deg — no "jump". The rail ring is guarded separately and
|
|
6227
|
+
// doesn't read --spd, so changing it here can't disturb the persistent rail ring. (Masa 2026-07-24)
|
|
6228
|
+
root.style.setProperty('--spd', (-(Date.now() % 1800)) + 'ms');
|
|
6198
6229
|
renderFsRail(); renderFsFeed(); renderFsLane();
|
|
6199
6230
|
}
|
|
6200
6231
|
function renderFsRail(){
|
|
@@ -6269,6 +6300,14 @@ function fsActiveContext(){
|
|
|
6269
6300
|
// Per-project last non-empty center feed — the whiteout guard (see below): keeps
|
|
6270
6301
|
// what the user was looking at when a render pass momentarily produces no lines.
|
|
6271
6302
|
const _fsLastFeed = {};
|
|
6303
|
+
// H-CENTERLANE decision 1: pure merge-sort of timed HTML blocks (the focused task's
|
|
6304
|
+
// activity block + each chat turn) so the center lane renders in the order things
|
|
6305
|
+
// actually happened. Stable sort (spec-guaranteed since ES2019) keeps same-ts blocks
|
|
6306
|
+
// in their original relative order. Pure/no DOM — covered directly in
|
|
6307
|
+
// engine/test/center-lane-timeline.test.mjs.
|
|
6308
|
+
function fsMergeTimelineBlocks(blocks){
|
|
6309
|
+
return [...blocks].sort((a, b) => a.ts - b.ts).map((b) => b.html).join('');
|
|
6310
|
+
}
|
|
6272
6311
|
function renderFsFeed(){
|
|
6273
6312
|
if (state.layout !== 'flagship') return;
|
|
6274
6313
|
const feed = $('fsFeed'); if (!feed) return;
|
|
@@ -6313,7 +6352,12 @@ function renderFsFeed(){
|
|
|
6313
6352
|
} else { nd.hidden = true; nd.innerHTML = ''; }
|
|
6314
6353
|
}
|
|
6315
6354
|
let html = '';
|
|
6355
|
+
// H-CENTERLANE decision 1: the focused task's activity block is anchored at its
|
|
6356
|
+
// startedAt so it can be merge-sorted against chat turns below (state.act carries
|
|
6357
|
+
// no per-line timestamp, so the whole block — not each line — is the sort unit).
|
|
6358
|
+
let focusTs = 0;
|
|
6316
6359
|
if (focus) {
|
|
6360
|
+
focusTs = focus.startedAt ? Date.parse(focus.startedAt) : 0;
|
|
6317
6361
|
const lines = state.act[focus.id] ?? [];
|
|
6318
6362
|
const lineLimit = fsUI.logOpen.has(focus.id) || focus.status === 'running' || ['running', 'partial'].includes(goal?.status) ? 1000 : 80;
|
|
6319
6363
|
html += lines.slice(-lineLimit).map(fsFeedRow).join('');
|
|
@@ -6380,29 +6424,39 @@ function renderFsFeed(){
|
|
|
6380
6424
|
// whole-feed re-render (SSE act events) can't drop them. Order per turn:
|
|
6381
6425
|
// You → (mark, if it became a task) and, for a reply, speaker line → reply.
|
|
6382
6426
|
const turns = (state.chatTurns[state.active] || []).slice(-40);
|
|
6383
|
-
|
|
6427
|
+
// H-CENTERLANE decision 1: each turn becomes its own timed block (ts, html) so it
|
|
6428
|
+
// can be merge-sorted against the focused task's activity block below, instead of
|
|
6429
|
+
// always rendering as one chat block stapled after the task feed regardless of
|
|
6430
|
+
// when either actually happened.
|
|
6431
|
+
const chatBlocks = [];
|
|
6384
6432
|
for (const turn of turns) {
|
|
6433
|
+
let t = '';
|
|
6385
6434
|
if (turn.role === 'you') {
|
|
6386
|
-
|
|
6435
|
+
t += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
|
|
6387
6436
|
if (turn.markGoalId != null && !turn.undone) {
|
|
6388
6437
|
const mg = state.goals.find((x) => x.id === turn.markGoalId);
|
|
6389
6438
|
const n = mg ? goalReviewNumber({ goal: mg, tasks: state.tasks }) : turn.markGoalId;
|
|
6390
|
-
|
|
6439
|
+
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>`;
|
|
6391
6440
|
}
|
|
6392
6441
|
} else { // Claude Code reply — speaker line first (You → speaker → reply), then the text
|
|
6393
6442
|
const app = connectedAppMeta(turn.agent);
|
|
6394
|
-
|
|
6443
|
+
t += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
|
|
6395
6444
|
const shown = esc((turn.text || '').slice(0, turn.revealed || 0));
|
|
6396
|
-
|
|
6445
|
+
t += turn.done
|
|
6397
6446
|
? `<div class="cc-say">${shown}</div>`
|
|
6398
6447
|
: `<div class="cc-say"><span id="ccTyping">${shown}</span><span class="cc-caret"></span></div>`;
|
|
6399
6448
|
}
|
|
6449
|
+
chatBlocks.push({ ts: turn.ts ?? 0, html: t });
|
|
6400
6450
|
}
|
|
6401
6451
|
const pgoals = state.goals.filter((g) => g.projectId === state.active);
|
|
6402
6452
|
const ptasks = state.tasks.filter((t) => t.projectId === state.active);
|
|
6403
6453
|
const hasWork = pgoals.length > 0 || ptasks.length > 0 || turns.length > 0;
|
|
6404
|
-
//
|
|
6405
|
-
|
|
6454
|
+
// H-CENTERLANE decision 1: interleave the focused task's activity block with the
|
|
6455
|
+
// chat turns in the order they actually happened (stable sort keeps same-ts items
|
|
6456
|
+
// in their original relative order); the question card(s) stay pinned at the very
|
|
6457
|
+
// end (H18/H19 — a pending question must never be lost mid-scroll).
|
|
6458
|
+
const timelineBlocks = html ? [{ ts: focusTs, html }, ...chatBlocks] : chatBlocks;
|
|
6459
|
+
let out = fsMergeTimelineBlocks(timelineBlocks) + askHtml;
|
|
6406
6460
|
if (out) _fsLastFeed[state.active] = out;
|
|
6407
6461
|
else if (hasWork) out = _fsLastFeed[state.active] || `<div class="cc-c">⎿ ${esc(running.length ? 'working…' : 'no active task')}</div>`;
|
|
6408
6462
|
$('fsRoot').classList.toggle('clawdhero', !hasWork);
|
|
@@ -6819,7 +6873,10 @@ function fsBucketTodo(){
|
|
|
6819
6873
|
needsInput: pgoals.filter((g) => g.status === 'needsInput'), // a clarifying question is waiting on the user (board's "Needs you")
|
|
6820
6874
|
reviewGoals: pgoals.filter((g) => g.status === 'review'),
|
|
6821
6875
|
queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
|
|
6822
|
-
|
|
6876
|
+
// A reply task's completion is already visible inline in the center-lane timeline
|
|
6877
|
+
// (H-CENTERLANE decision 3) — showing it again as an independent Done row is the
|
|
6878
|
+
// same event twice. `queued` already guards `!t.reply` above; mirror it here.
|
|
6879
|
+
doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status) && !closedGoalIds.has(t.goalId) && !t.reply).sort((a, b) => b.num - a.num),
|
|
6823
6880
|
later: pgoals.filter((g) => g.status === 'pending'),
|
|
6824
6881
|
rejected: pgoals.filter((g) => g.status === 'rejected'), // 07-20c: Reject parks goals here (own fold, like Done)
|
|
6825
6882
|
};
|
|
@@ -6840,6 +6897,16 @@ function renderFsTodo(){
|
|
|
6840
6897
|
const resumedGoalIds = new Set(pgoals.filter((g) => g.status === 'running' && g.startedAt).map((g) => g.id));
|
|
6841
6898
|
const doingGoals = pgoals.filter((g) => g.status === 'running' && g.startedAt
|
|
6842
6899
|
&& !runningTaskGoalIds.has(g.id) && !pausedGoalIds.has(g.id));
|
|
6900
|
+
// A reply that was OVER the parallel cap (engine PR #342): the goal is 'running'
|
|
6901
|
+
// but startedAt is cleared, and its only pending work is a QUEUED reply task —
|
|
6902
|
+
// which Doing needs startedAt for, and Next-up excludes (!t.reply). With no
|
|
6903
|
+
// bucket it vanished. Surface these "parked" goals at the TOP of To Do ("最上部",
|
|
6904
|
+
// Masa 2026-07-24) as Queued rows — not "Working on it" (nothing runs yet). They
|
|
6905
|
+
// auto-promote to Doing the moment a slot frees and the engine sets startedAt.
|
|
6906
|
+
const parkedReplyGoals = pgoals.filter((g) => g.status === 'running' && !g.startedAt
|
|
6907
|
+
&& !pausedGoalIds.has(g.id) && !runningTaskGoalIds.has(g.id)
|
|
6908
|
+
&& list.some((t) => t.goalId === g.id && t.reply && t.status === 'queued')
|
|
6909
|
+
&& !list.some((t) => t.goalId === g.id && !t.reply && ['running', 'queued'].includes(t.status)));
|
|
6843
6910
|
const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
|
|
6844
6911
|
// LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
|
|
6845
6912
|
// PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
|
|
@@ -6898,6 +6965,10 @@ function renderFsTodo(){
|
|
|
6898
6965
|
+ needsInput.map((g) => `<div class="drow err int"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.text || g.plan?.[0], 60)}</span><span class="stword int">Needs input</span></div>
|
|
6899
6966
|
<div class="errline"><div class="msg"><span class="x int">?</span>${tt(g.question?.text || 'A question is waiting on you', 180)}</div>
|
|
6900
6967
|
<div class="acts2"><button class="errbtn" data-fsglog="${g.id}">Answer</button></div></div></div>`).join('');
|
|
6968
|
+
// Parked over-cap replies ride at the TOP of To Do (above the ordinary Next-up
|
|
6969
|
+
// queue), as neutral "Queued" rows — the same goal-row shape as a resumed Doing
|
|
6970
|
+
// goal but without the green "Working on it", because no worker is running yet.
|
|
6971
|
+
const parkedRows = parkedReplyGoals.map((g) => `<div class="trow up" data-goalid="${g.id}"><span class="st"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword" style="color:var(--ink3)">Queued</span>${fsActs(g.id)}</div>`).join('');
|
|
6901
6972
|
const NCAP = 6;
|
|
6902
6973
|
const upShown = fsUI.todoMore ? queued : queued.slice(0, NCAP);
|
|
6903
6974
|
const seenGoals = new Set();
|
|
@@ -6923,7 +6994,7 @@ function renderFsTodo(){
|
|
|
6923
6994
|
// queued.length specifically, since that suffix describes the Next-up sublist's own sort,
|
|
6924
6995
|
// not the lane total.
|
|
6925
6996
|
const needsCount = failed.filter((t) => !resumedGoalIds.has(t.goalId)).length + blocked.length + needsInput.length;
|
|
6926
|
-
const todoTotal = running.length + doingGoals.length + needsCount + queued.length + later.length;
|
|
6997
|
+
const todoTotal = running.length + doingGoals.length + parkedReplyGoals.length + needsCount + queued.length + later.length;
|
|
6927
6998
|
// Free-tier pending counter (H22): on the free plan the header shows the wall
|
|
6928
6999
|
// — pending goals out of the 5-slot cap — reddening at the cap and gone once
|
|
6929
7000
|
// paid. Reads the SAME numbers the gate enforces (state.billing, never a local
|
|
@@ -6940,13 +7011,13 @@ function renderFsTodo(){
|
|
|
6940
7011
|
${(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}` : ''}
|
|
6941
7012
|
${needsRows ? `<span class="tlcap needs"${(doingRows || paused) ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
|
|
6942
7013
|
${rejected.length ? `<span class="tlcap rejcap"${(doingRows || paused) || needsRows ? '' : ' style="padding-top:0"'}>Dismissed<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('')}` : ''}
|
|
6943
|
-
${upRows ? `<span class="tlcap"${(doingRows || paused) || needsRows || rejected.length ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
|
|
7014
|
+
${(parkedRows || upRows) ? `<span class="tlcap"${(doingRows || paused) || needsRows || rejected.length ? '' : ' style="padding-top:0"'}>Next up</span>${parkedRows}${upRows}` : ''}
|
|
6944
7015
|
${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
|
|
6945
7016
|
${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
|
|
6946
7017
|
<button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
|
|
6947
7018
|
</div>
|
|
6948
7019
|
${doneAll.length ? `<div class="doneline${fsUI.doneOpen ? ' open' : ''}" id="fsDoneLine">✓ Done<span class="num">${doneAll.length}</span><span class="fold">▾</span></div>
|
|
6949
|
-
<div id="fsDonelist"${fsUI.doneOpen ? '' : ' hidden'}>${doneAll.
|
|
7020
|
+
<div id="fsDonelist"${fsUI.doneOpen ? '' : ' hidden'}>${doneAll.map((t) => `<div class="trow up dim done"><span class="st ok"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span></div>`).join('')}</div>` : ''}`;
|
|
6950
7021
|
el.classList.toggle('folded', !!fsUI.todoFold);
|
|
6951
7022
|
$('fsTodoHd').onclick = () => { fsUI.todoFold = !fsUI.todoFold; el.classList.toggle('folded', fsUI.todoFold); };
|
|
6952
7023
|
const qadd = el.querySelector('.qadd[data-fsnew]'); // + New → focus the composer (the real create path)
|
|
@@ -8218,7 +8289,7 @@ async function postOutboxItem(item){
|
|
|
8218
8289
|
// .peraanswer bar. Never land()/upsert(): a kind:'chat' response has no id.
|
|
8219
8290
|
outbox.remove(item.oid);
|
|
8220
8291
|
if (state.layout === 'flagship') {
|
|
8221
|
-
const turn = { role: 'clawd', text: String(goal.reply || ''), revealed: 0, done: false, agent: item.agent };
|
|
8292
|
+
const turn = { role: 'clawd', text: String(goal.reply || ''), revealed: 0, done: false, agent: item.agent, ts: Date.now() };
|
|
8222
8293
|
(state.chatTurns[item.projectId] ||= []).push(turn);
|
|
8223
8294
|
renderFsFeed();
|
|
8224
8295
|
typeChatTurn(turn);
|
|
@@ -8351,6 +8422,46 @@ function resolveComposer(raw){
|
|
|
8351
8422
|
review: !!(msg.review || parsed.review),
|
|
8352
8423
|
};
|
|
8353
8424
|
}
|
|
8425
|
+
// Feature A (ONE-CONVERSATION §1.2/§1.4 "1本の会話"): the composer CONTINUES the
|
|
8426
|
+
// goal you're focused on (state.selectedGoal) instead of always making a new goal
|
|
8427
|
+
// — like Claude Code, where you keep talking to the same session. It binds only
|
|
8428
|
+
// when that goal is in the ACTIVE project AND in a repliable status: a review goal
|
|
8429
|
+
// routes to the AI-decide reply (continue/rescope/split/answer), an in-flight or
|
|
8430
|
+
// finished-rework goal takes a plain follow-up. Anything else (done, needsInput,
|
|
8431
|
+
// planning/stacked, or nothing selected) → new-goal mode, exactly as before.
|
|
8432
|
+
const REPLY_AUTO = new Set(['review']);
|
|
8433
|
+
const REPLY_PLAIN = new Set(['running', 'partial', 'failed', 'interrupted', 'blocked']);
|
|
8434
|
+
function replyBinding(){
|
|
8435
|
+
const id = state.selectedGoal;
|
|
8436
|
+
if (id == null) return null;
|
|
8437
|
+
const g = state.goals.find((x) => x.id === id);
|
|
8438
|
+
if (!g || g.projectId !== state.active) return null; // stale after a project switch → new-goal mode
|
|
8439
|
+
// needsInput has NO worker session yet (planGoal() sets it before any task/worker
|
|
8440
|
+
// exists — see engine/server.mjs) — there's no "conversation" to resume via /reply
|
|
8441
|
+
// (that endpoint 409s on needsInput; engine/lib.mjs:3010). Binding here routes to
|
|
8442
|
+
// the SAME /answer endpoint the question card's own free-text box already uses
|
|
8443
|
+
// (fsAnswerQuestion), so the composer becomes a second way to answer, not a new path.
|
|
8444
|
+
if (g.status === 'needsInput' && g.question) return { goal: g, mode: 'answer' };
|
|
8445
|
+
if (REPLY_AUTO.has(g.status)) return { goal: g, mode: 'auto' };
|
|
8446
|
+
if (REPLY_PLAIN.has(g.status)) return { goal: g, mode: 'plain' };
|
|
8447
|
+
return null;
|
|
8448
|
+
}
|
|
8449
|
+
// The visible, reversible "Replying to #NNN ✕" chip above the composer — never
|
|
8450
|
+
// silent (shown whenever bound), always detachable (✕ clears the binding).
|
|
8451
|
+
function renderReplyBind(){
|
|
8452
|
+
const el = $('replyBind'); if (!el) return;
|
|
8453
|
+
const b = replyBinding();
|
|
8454
|
+
el.hidden = !b;
|
|
8455
|
+
const inp = $('input');
|
|
8456
|
+
if (b) {
|
|
8457
|
+
const n = goalReviewNumber({ goal: b.goal, tasks: state.tasks }) ?? b.goal.id;
|
|
8458
|
+
const numEl = $('replyBindNum'); if (numEl) numEl.textContent = '#' + n;
|
|
8459
|
+
const lblEl = $('replyBindLabel'); if (lblEl) lblEl.textContent = b.mode === 'answer' ? 'Answering' : 'Replying to';
|
|
8460
|
+
if (inp) inp.placeholder = b.mode === 'answer' ? `Answering #${n}…` : `Reply to #${n}…`;
|
|
8461
|
+
} else if (inp) {
|
|
8462
|
+
inp.placeholder = 'Describe a goal… ( / for commands )';
|
|
8463
|
+
}
|
|
8464
|
+
}
|
|
8354
8465
|
async function send(){
|
|
8355
8466
|
const { text, model, effort, mode, skill, later, review } = resolveComposer($('input').value);
|
|
8356
8467
|
if (!text) return;
|
|
@@ -8361,6 +8472,54 @@ async function send(){
|
|
|
8361
8472
|
const now = Date.now();
|
|
8362
8473
|
if (text === _lastSend.text && now - _lastSend.ts < 1500) return;
|
|
8363
8474
|
_lastSend = { text, ts: now };
|
|
8475
|
+
|
|
8476
|
+
// Feature A: bound to the focused goal → this is a REPLY, not a new goal.
|
|
8477
|
+
// /later and /review are explicit "make a new ticket" intents → detach + fall through.
|
|
8478
|
+
const bind = (later || review) ? null : replyBinding();
|
|
8479
|
+
if (bind) {
|
|
8480
|
+
const batt = state.attach.slice();
|
|
8481
|
+
const fullText = batt.length
|
|
8482
|
+
? `${text}${text ? '\n\n' : ''}添付画像(workerはこのパスをReadで確認できます): ${batt.map((a) => a.path).join(', ')}`
|
|
8483
|
+
: text;
|
|
8484
|
+
// same housekeeping as a normal send: the words appear in the feed at once.
|
|
8485
|
+
(state.chatTurns[state.active] ||= []).push({ role: 'you', text, ts: Date.now() });
|
|
8486
|
+
state.attach = [];
|
|
8487
|
+
state.msg = { mode: null, model: null, effort: null, skill: null, later: false, review: false };
|
|
8488
|
+
renderMsgHint();
|
|
8489
|
+
$('input').value = ''; $('input').style.height = 'auto';
|
|
8490
|
+
clearChatReply(); closePalette(); localStorage.removeItem('draft');
|
|
8491
|
+
renderAttach(); render();
|
|
8492
|
+
try {
|
|
8493
|
+
if (bind.mode === 'answer') {
|
|
8494
|
+
// needsInput → same /answer endpoint the question card's own free-text box
|
|
8495
|
+
// uses (fsAnswerQuestion). Plain `text`, not `fullText`: /answer may read the
|
|
8496
|
+
// reply as a literal folder path/choice (routeQuestionAnswer), and the
|
|
8497
|
+
// attachment note appended below would break that matching.
|
|
8498
|
+
const r = await fetch(withKey(`/api/goals/${bind.goal.id}/answer`), { method: 'POST',
|
|
8499
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer: text }) });
|
|
8500
|
+
if (!r.ok) throw new Error();
|
|
8501
|
+
const goal = await r.json();
|
|
8502
|
+
if (goal?.id) upsert(state.goals, goal);
|
|
8503
|
+
render();
|
|
8504
|
+
} else if (bind.mode === 'auto') {
|
|
8505
|
+
// review goal → the AI decides continue/rescope/split/answer (same path as the
|
|
8506
|
+
// review card's own reply box), with the note anchored under the composer.
|
|
8507
|
+
const body = await replyToGoal(bind.goal.id, fullText, '#input');
|
|
8508
|
+
if (body) { upsert(state.goals, body.goal); if (body.task) upsert(state.tasks, body.task); render(); }
|
|
8509
|
+
} else {
|
|
8510
|
+
// running/partial/failed/interrupted/blocked → a plain follow-up on the same goal.
|
|
8511
|
+
const r = await fetch(withKey(`/api/goals/${bind.goal.id}/reply`), { method: 'POST',
|
|
8512
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: fullText }) });
|
|
8513
|
+
if (!r.ok) throw new Error();
|
|
8514
|
+
const g = await r.json(); const goal = g.goal ?? g;
|
|
8515
|
+
if (goal?.id) upsert(state.goals, goal);
|
|
8516
|
+
if (g.task) upsert(state.tasks, g.task);
|
|
8517
|
+
render();
|
|
8518
|
+
}
|
|
8519
|
+
} catch { showErr('Reply failed.'); }
|
|
8520
|
+
return; // never touches the outbox / POST /api/tasks
|
|
8521
|
+
}
|
|
8522
|
+
|
|
8364
8523
|
const attach = state.attach.slice();
|
|
8365
8524
|
const item = {
|
|
8366
8525
|
oid: `o${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
@@ -9995,7 +10154,7 @@ async function argItems(cmd){
|
|
|
9995
10154
|
// Read the raw input → decide what the palette shows (null = closed).
|
|
9996
10155
|
function paletteState(val){
|
|
9997
10156
|
if (val[0] !== '/') return null;
|
|
9998
|
-
const m = val.match(/^\/(\w*)(\s)?([\s\S]*)$/);
|
|
10157
|
+
const m = val.match(/^\/([\w-]*)(\s)?([\s\S]*)$/); // allow '-' so hyphenated skill names filter (english-practice)
|
|
9999
10158
|
if (!m) return null;
|
|
10000
10159
|
const word = m[1], hadSpace = m[2] != null, rest = m[3];
|
|
10001
10160
|
if (!hadSpace) return { level: 'top', query: word }; // still typing the command name
|
|
@@ -10018,11 +10177,18 @@ function renderPalette(){
|
|
|
10018
10177
|
if (!p || !el) return;
|
|
10019
10178
|
const head = p.level === 'top' ? 'Commands' : '/' + p.cmd;
|
|
10020
10179
|
if (!p.items.length) { el.innerHTML = `<div class="sphead">${esc(head)}</div><div class="empty">No match</div>`; el.classList.add('show'); return; }
|
|
10021
|
-
|
|
10022
|
-
|
|
10023
|
-
|
|
10024
|
-
|
|
10025
|
-
|
|
10180
|
+
let html = '', lastGroup = null;
|
|
10181
|
+
p.items.forEach((it, i) => {
|
|
10182
|
+
// group headers: top-level splits Commands vs Skills; arg-level shows the "/cmd" head once.
|
|
10183
|
+
if (p.level === 'top') {
|
|
10184
|
+
const group = it.skill ? 'Skills' : 'Commands';
|
|
10185
|
+
if (group !== lastGroup) { html += `<div class="sphead">${group}</div>`; lastGroup = group; }
|
|
10186
|
+
} else if (i === 0) { html += `<div class="sphead">${esc('/' + p.cmd)}</div>`; }
|
|
10187
|
+
const name = p.level === 'top' ? (it.skill ? '/' + it.skill : '/' + it.cmd + (it.arg ? ' …' : '')) : it.v;
|
|
10188
|
+
const desc = p.level === 'top' ? (it.desc || '') : (it.d || '');
|
|
10189
|
+
html += `<button type="button" class="spopt${i === p.sel ? ' on' : ''}" data-i="${i}" role="option"><span class="spn">${esc(name)}</span><span class="spd">${esc(desc)}</span></button>`;
|
|
10190
|
+
});
|
|
10191
|
+
el.innerHTML = html;
|
|
10026
10192
|
el.classList.add('show');
|
|
10027
10193
|
for (const b of el.querySelectorAll('[data-i]')) b.onmousedown = (e) => { e.preventDefault(); state.palette.sel = Number(b.dataset.i); acceptPalette(); };
|
|
10028
10194
|
}
|
|
@@ -10034,6 +10200,13 @@ async function refreshPalette(){
|
|
|
10034
10200
|
if (info.level === 'top') {
|
|
10035
10201
|
const q = info.query.toLowerCase();
|
|
10036
10202
|
items = SLASH_CMDS.filter((s) => !q || s.cmd.startsWith(q));
|
|
10203
|
+
// Once the user types after "/", also offer their registered skills (type-to-filter, so a
|
|
10204
|
+
// bare "/" stays a short command list). Selecting one runs the goal with that skill first.
|
|
10205
|
+
if (q) {
|
|
10206
|
+
const skills = (await loadSkills()).filter((s) => s.name.toLowerCase().includes(q));
|
|
10207
|
+
if ($('input').value !== val) return; // input changed while awaiting skills
|
|
10208
|
+
items = items.concat(skills.map((s) => ({ skill: s.name, desc: `${s.kind || 'skill'}${s.description ? ' · ' + s.description : ''}`.trim() })));
|
|
10209
|
+
}
|
|
10037
10210
|
} else {
|
|
10038
10211
|
const all = await argItems(info.cmd);
|
|
10039
10212
|
if ($('input').value !== val) return; // input changed while awaiting skills
|
|
@@ -10054,7 +10227,8 @@ function acceptPalette(){
|
|
|
10054
10227
|
const p = state.palette; if (!p) return false;
|
|
10055
10228
|
const item = p.items[p.sel]; if (!item) return true;
|
|
10056
10229
|
if (p.level === 'top') {
|
|
10057
|
-
if (item.
|
|
10230
|
+
if (item.skill) { applySlash('skill', item.skill); $('input').value = $('input').value.replace(/^\s*\/\S*\s*/, ''); closePalette(); } // hyphen-safe strip of the "/skillname" token
|
|
10231
|
+
else if (item.arg) { $('input').value = '/' + item.cmd + ' '; refreshPalette(); }
|
|
10058
10232
|
else { applySlash(item.cmd, null); $('input').value = stripLeadingCmd($('input').value); closePalette(); }
|
|
10059
10233
|
} else {
|
|
10060
10234
|
applySlash(p.cmd, item.v);
|
|
@@ -10198,6 +10372,8 @@ $('filein').addEventListener('change', () => {
|
|
|
10198
10372
|
for (const f of $('filein').files) uploadFile(f);
|
|
10199
10373
|
$('filein').value = '';
|
|
10200
10374
|
});
|
|
10375
|
+
// Feature A: ✕ on the "Replying to #NNN" chip detaches → back to new-goal mode.
|
|
10376
|
+
$('replyBindX').onclick = () => { state.selectedGoal = null; render(); };
|
|
10201
10377
|
const IS_MOBILE = () => window.matchMedia('(max-width: 760px)').matches;
|
|
10202
10378
|
// Phones are chat-first: the Tasks sheet starts FOLDED so the first view is the
|
|
10203
10379
|
// conversation + composer (the sheet is an 88vw overlay — default-open buried
|
package/engine/lib.mjs
CHANGED
|
@@ -1609,6 +1609,8 @@ export function resolveGoalSource(input) {
|
|
|
1609
1609
|
export const DEFAULT_REVIEW_DEFINITION = {
|
|
1610
1610
|
requirePR: true,
|
|
1611
1611
|
requireVerifyPass: false,
|
|
1612
|
+
// OFF by default (Masa 2026-07-25). Opt-in per project; see resolveTestGate.
|
|
1613
|
+
requireTests: false,
|
|
1612
1614
|
description: '',
|
|
1613
1615
|
defaultWantsPR: false,
|
|
1614
1616
|
language: 'auto',
|
|
@@ -1616,6 +1618,25 @@ export const DEFAULT_REVIEW_DEFINITION = {
|
|
|
1616
1618
|
reviewCard: { screenshots: true, beforeAfter: false },
|
|
1617
1619
|
};
|
|
1618
1620
|
|
|
1621
|
+
// Should Galda run its OWN project test suite as a blocking gate? OFF by default
|
|
1622
|
+
// (Masa 2026-07-25). Galda is a thin interface over Claude Code: the worker
|
|
1623
|
+
// already runs the repo's own tests per its CLAUDE.md and reports proof, so
|
|
1624
|
+
// Galda running the full suite a SECOND time only manufactured false
|
|
1625
|
+
// "テスト失敗 N件" Failures under machine saturation (audit 2026-07-24 — the same
|
|
1626
|
+
// code produced 93→94 / 5→4 varying red counts). With the gate off we never run
|
|
1627
|
+
// those tests and so never block/rework on them; the worker's report + proof is
|
|
1628
|
+
// what the human reviews. A project (or Masa's own instance) opts INTO a hard,
|
|
1629
|
+
// CI-style blocking gate via reviewDefinition.requireTests or MANAGER_VERIFY_GATE=on.
|
|
1630
|
+
// `force` is the explicit human "再検証 / Retry" action — a deliberate, per-goal
|
|
1631
|
+
// request that always runs the tests regardless of the default.
|
|
1632
|
+
export function resolveTestGate({ reviewDefinition = DEFAULT_REVIEW_DEFINITION, env = {}, force = false } = {}) {
|
|
1633
|
+
if (force) return true;
|
|
1634
|
+
const v = String(env.MANAGER_VERIFY_GATE ?? '').trim().toLowerCase();
|
|
1635
|
+
if (v === 'on' || v === '1' || v === 'true') return true;
|
|
1636
|
+
if (v === 'off' || v === '0' || v === 'false') return false;
|
|
1637
|
+
return !!(reviewDefinition && reviewDefinition.requireTests);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1619
1640
|
// Detects whether text was written in Japanese (kana/kanji present) or not, so
|
|
1620
1641
|
// generated replies can match the language the request was written in
|
|
1621
1642
|
// ("test" → English, "テスト" → 日本語) — worker close-out summaries, and (via
|
|
@@ -1730,6 +1751,7 @@ export function validateReviewDefinition(input) {
|
|
|
1730
1751
|
definition: {
|
|
1731
1752
|
requirePR: input.requirePR !== undefined ? !!input.requirePR : DEFAULT_REVIEW_DEFINITION.requirePR,
|
|
1732
1753
|
requireVerifyPass: input.requireVerifyPass !== undefined ? !!input.requireVerifyPass : DEFAULT_REVIEW_DEFINITION.requireVerifyPass,
|
|
1754
|
+
requireTests: input.requireTests !== undefined ? !!input.requireTests : DEFAULT_REVIEW_DEFINITION.requireTests,
|
|
1733
1755
|
description: input.description !== undefined ? String(input.description).slice(0, 500) : DEFAULT_REVIEW_DEFINITION.description,
|
|
1734
1756
|
defaultWantsPR: input.defaultWantsPR !== undefined ? !!input.defaultWantsPR : DEFAULT_REVIEW_DEFINITION.defaultWantsPR,
|
|
1735
1757
|
language: (input.language === 'ja' || input.language === 'en' || input.language === 'auto') ? input.language : DEFAULT_REVIEW_DEFINITION.language,
|
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 } from './lib.mjs';
|
|
24
|
-
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, taskStatusAfterVerify, 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, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, 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, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages } from './lib.mjs';
|
|
24
|
+
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, taskStatusAfterVerify, 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, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, 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, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages } from './lib.mjs';
|
|
25
25
|
import { createSerialQueue } from './lib.mjs';
|
|
26
26
|
import { collectProjectRules } from './lib.mjs';
|
|
27
27
|
import { openPR } from './pr.mjs';
|
|
@@ -1952,7 +1952,26 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
|
|
|
1952
1952
|
};
|
|
1953
1953
|
tasks.push(task);
|
|
1954
1954
|
saveTask(task);
|
|
1955
|
-
queues.get(goal.projectId)
|
|
1955
|
+
const q = queues.get(goal.projectId);
|
|
1956
|
+
q.waiting.push(task);
|
|
1957
|
+
// "空き分は即Doing・あふれ分はTo Do" (Masa 2026-07-24). A re-queued reply used to
|
|
1958
|
+
// keep the goal's stale startedAt from its first run, so the card read DOING even
|
|
1959
|
+
// when no worker could start: reply to N in-review cards with a parallel cap of 2
|
|
1960
|
+
// and all N said "Working on it" while only 2 actually ran. Decide startedAt the
|
|
1961
|
+
// same way pump() will on the next tick — if THIS task is within the runnable set
|
|
1962
|
+
// for the currently-free slots it starts now, so keep it DOING; otherwise it waits
|
|
1963
|
+
// in To Do (QUEUED) until a slot frees, where runTask() sets startedAt and it
|
|
1964
|
+
// auto-promotes to DOING. Only for a goal with no live worker of its own: a
|
|
1965
|
+
// follow-up reply to an already-running goal must stay DOING (its clock stands).
|
|
1966
|
+
if (goal.status === 'running' && ![...q.running].some((t) => t.goalId === goal.id)) {
|
|
1967
|
+
const slow = slowModeSettings();
|
|
1968
|
+
const projectCap = slow.enabled ? Math.min(PROJECT_MAX_PARALLEL, slow.maxParallel) : PROJECT_MAX_PARALLEL;
|
|
1969
|
+
const slots = Math.min(projectCap - q.running.size, GLOBAL_MAX_PARALLEL - globalRunningCount());
|
|
1970
|
+
const runningGoalIds = [...q.running].map((t) => t.goalId);
|
|
1971
|
+
const startsNow = nextRunnableTasks(q.waiting, runningGoalIds, slow.enabled ? 1 : slots).some((t) => t.id === task.id);
|
|
1972
|
+
goal.startedAt = startsNow ? new Date().toISOString() : null;
|
|
1973
|
+
saveGoal(goal);
|
|
1974
|
+
}
|
|
1956
1975
|
// Spawn the worker on the NEXT event-loop turn, not synchronously here. runTask() runs
|
|
1957
1976
|
// straight through to its first await — and that stretch includes goalWorkDir()'s
|
|
1958
1977
|
// `git worktree add` (measured 1.78s on a checkout with ~135 worktrees). Callers like
|
|
@@ -2234,7 +2253,14 @@ async function runTask(task) {
|
|
|
2234
2253
|
const project = projects.find((p) => p.id === task.projectId);
|
|
2235
2254
|
const goal = goals.find((g) => g.id === task.goalId);
|
|
2236
2255
|
task.status = 'running'; task.startedAt = new Date().toISOString();
|
|
2237
|
-
|
|
2256
|
+
// The goal's "started" clock is set by the first task that actually runs — the
|
|
2257
|
+
// signal the board uses to read a 'running' goal as DOING rather than QUEUED
|
|
2258
|
+
// (app GOAL_CHIP). A reply task counts too now: an over-cap re-queued reply
|
|
2259
|
+
// parks the goal in To Do with startedAt cleared (see queueReplyTask), and this
|
|
2260
|
+
// is where it flips to DOING the moment a worker slot frees and it starts. The
|
|
2261
|
+
// `!goal.startedAt` guard still protects an already-running goal (a follow-up
|
|
2262
|
+
// reply while its worker is live never resets the clock).
|
|
2263
|
+
if (goal && !goal.startedAt) { goal.startedAt = task.startedAt; saveGoal(goal); }
|
|
2238
2264
|
saveTask(task);
|
|
2239
2265
|
sendProcessAct(task, `Working on "${task.title}"${task.reply ? ' as a follow-up' : ''}.`);
|
|
2240
2266
|
|
|
@@ -2992,7 +3018,7 @@ function computeGoalRisk(goal, project, siblings) {
|
|
|
2992
3018
|
// UI change — capturing it (3 tries) if missing. Any failure → 'blocked' (shown
|
|
2993
3019
|
// in Attention with the reason); otherwise → review/done via nextGoalStatus.
|
|
2994
3020
|
// Nothing reaches Review that a human can't judge.
|
|
2995
|
-
async function verifyGate(goal, prProject, siblings, baseProject, activityTask = null) {
|
|
3021
|
+
async function verifyGate(goal, prProject, siblings, baseProject, activityTask = null, { forceTests = false } = {}) {
|
|
2996
3022
|
const goalAct = (line) => { if (activityTask) sendProcessAct(activityTask, line); };
|
|
2997
3023
|
const wdir = prProject.dir;
|
|
2998
3024
|
const reviewDefinition = getReviewDefinition(goal.projectId);
|
|
@@ -3000,10 +3026,20 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
|
|
|
3000
3026
|
const allDone = siblings.every(taskCountsAsComplete);
|
|
3001
3027
|
const allVerified = judged.length > 0 && judged.every((t) => t.proof?.pass === true);
|
|
3002
3028
|
const changedFiles = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].filter((f) => f && !SCRATCH_FILE_RE.test(f));
|
|
3003
|
-
|
|
3029
|
+
// Galda's own test gate is OFF by default (Masa 2026-07-25) — the worker already
|
|
3030
|
+
// ran the repo's own tests per its CLAUDE.md, and running the full suite again
|
|
3031
|
+
// here only manufactured false Failures under machine saturation. Opt in per
|
|
3032
|
+
// project (requireTests) or via MANAGER_VERIFY_GATE=on; the human "再検証/Retry"
|
|
3033
|
+
// path forces it on for that one goal. When off, testResult.ran stays false, so
|
|
3034
|
+
// testsFailing below is false and the goal never blocks or auto-reworks on tests.
|
|
3035
|
+
const testGateOn = resolveTestGate({ reviewDefinition, env: process.env, force: forceTests });
|
|
3036
|
+
if (testGateOn && hasTestRelevantChanges(changedFiles)) {
|
|
3004
3037
|
goalAct(`Running project tests in the goal worktree (${changedFiles.length} changed file${changedFiles.length === 1 ? '' : 's'}).`);
|
|
3005
3038
|
goal.testResult = await runProjectTests(wdir);
|
|
3006
3039
|
goalAct(`Project tests finished: ${goal.testResult.failed ?? 0} failed, ${goal.testResult.passed ?? 0} passed.`);
|
|
3040
|
+
} else if (!testGateOn) {
|
|
3041
|
+
goal.testResult = { ran: false, skipped: true, skippedReason: 'test gate off (opt-in: requireTests / MANAGER_VERIFY_GATE=on)' };
|
|
3042
|
+
goalAct("Skipped Galda's test gate (off by default): the worker runs the repo's own tests per its CLAUDE.md — enable a blocking gate with requireTests / MANAGER_VERIFY_GATE=on.");
|
|
3007
3043
|
} else {
|
|
3008
3044
|
goal.testResult = { ran: false, skipped: true, skippedReason: changedFiles.length ? 'documentation-only change' : 'no changed files' };
|
|
3009
3045
|
goalAct(`Skipped project tests: ${goal.testResult.skippedReason}.`);
|
|
@@ -4410,7 +4446,9 @@ const server = createServer(async (req, res) => {
|
|
|
4410
4446
|
if (!project) return json(res, 404, { error: 'project not found' });
|
|
4411
4447
|
const siblings = tasks.filter((t) => t.goalId === goal.id);
|
|
4412
4448
|
const wdir = (goalWorkDirs.get(goal.id) && existsSync(goalWorkDirs.get(goal.id))) ? goalWorkDirs.get(goal.id) : project.dir;
|
|
4413
|
-
|
|
4449
|
+
// 再検証 is the explicit human "run the tests now" — force the gate on for this
|
|
4450
|
+
// one goal even when the default (automatic) gate is off.
|
|
4451
|
+
await verifyGate(goal, { ...project, dir: wdir }, siblings, project, null, { forceTests: true });
|
|
4414
4452
|
saveGoal(goal);
|
|
4415
4453
|
return json(res, 200, goal);
|
|
4416
4454
|
}
|
package/package.json
CHANGED