@galda/cli 0.10.50 → 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 +122 -2
- 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}
|
|
@@ -2966,6 +2977,9 @@
|
|
|
2966
2977
|
reply exists (progressive disclosure — DESIGN-RULES §1.5-3, "存在
|
|
2967
2978
|
しないものの器を先に見せない"). -->
|
|
2968
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>
|
|
2969
2983
|
<div class="composer">
|
|
2970
2984
|
<div class="attachbar" id="attachbar" style="display:none"></div>
|
|
2971
2985
|
<!-- Slash-command palette (Masa決定 2026-07-07: chips→slash). Floats
|
|
@@ -4260,6 +4274,7 @@ function mergeGoalTimeline(goals, externalActivity, projectId){
|
|
|
4260
4274
|
// (.gsummary)が出ていればそこを、まだなければゴールメッセージ全体の末尾を狙う。
|
|
4261
4275
|
function goToGoalInChat(goalId){
|
|
4262
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)
|
|
4263
4278
|
renderStream();
|
|
4264
4279
|
const goalEl = document.querySelector(`#stream [data-goalmsg="${goalId}"]`);
|
|
4265
4280
|
if (!goalEl) return;
|
|
@@ -6153,6 +6168,7 @@ $('gdRevisionInput').addEventListener('keydown', (e) => {
|
|
|
6153
6168
|
function render(){
|
|
6154
6169
|
renderProjects(); renderStream(); renderQueue(); renderTasks(); renderActPanel(); wireWfEditButtons();
|
|
6155
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
|
|
6156
6172
|
renderFlagship();
|
|
6157
6173
|
if (state.reviewOpenGoal != null) renderReviewChecklist();
|
|
6158
6174
|
if (state.goalDetailOpen != null) renderGoalDetail();
|
|
@@ -6881,6 +6897,16 @@ function renderFsTodo(){
|
|
|
6881
6897
|
const resumedGoalIds = new Set(pgoals.filter((g) => g.status === 'running' && g.startedAt).map((g) => g.id));
|
|
6882
6898
|
const doingGoals = pgoals.filter((g) => g.status === 'running' && g.startedAt
|
|
6883
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)));
|
|
6884
6910
|
const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
|
|
6885
6911
|
// LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
|
|
6886
6912
|
// PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
|
|
@@ -6939,6 +6965,10 @@ function renderFsTodo(){
|
|
|
6939
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>
|
|
6940
6966
|
<div class="errline"><div class="msg"><span class="x int">?</span>${tt(g.question?.text || 'A question is waiting on you', 180)}</div>
|
|
6941
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('');
|
|
6942
6972
|
const NCAP = 6;
|
|
6943
6973
|
const upShown = fsUI.todoMore ? queued : queued.slice(0, NCAP);
|
|
6944
6974
|
const seenGoals = new Set();
|
|
@@ -6964,7 +6994,7 @@ function renderFsTodo(){
|
|
|
6964
6994
|
// queued.length specifically, since that suffix describes the Next-up sublist's own sort,
|
|
6965
6995
|
// not the lane total.
|
|
6966
6996
|
const needsCount = failed.filter((t) => !resumedGoalIds.has(t.goalId)).length + blocked.length + needsInput.length;
|
|
6967
|
-
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;
|
|
6968
6998
|
// Free-tier pending counter (H22): on the free plan the header shows the wall
|
|
6969
6999
|
// — pending goals out of the 5-slot cap — reddening at the cap and gone once
|
|
6970
7000
|
// paid. Reads the SAME numbers the gate enforces (state.billing, never a local
|
|
@@ -6981,7 +7011,7 @@ function renderFsTodo(){
|
|
|
6981
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}` : ''}
|
|
6982
7012
|
${needsRows ? `<span class="tlcap needs"${(doingRows || paused) ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
|
|
6983
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('')}` : ''}
|
|
6984
|
-
${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}` : ''}
|
|
6985
7015
|
${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
|
|
6986
7016
|
${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
|
|
6987
7017
|
<button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
|
|
@@ -8392,6 +8422,46 @@ function resolveComposer(raw){
|
|
|
8392
8422
|
review: !!(msg.review || parsed.review),
|
|
8393
8423
|
};
|
|
8394
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
|
+
}
|
|
8395
8465
|
async function send(){
|
|
8396
8466
|
const { text, model, effort, mode, skill, later, review } = resolveComposer($('input').value);
|
|
8397
8467
|
if (!text) return;
|
|
@@ -8402,6 +8472,54 @@ async function send(){
|
|
|
8402
8472
|
const now = Date.now();
|
|
8403
8473
|
if (text === _lastSend.text && now - _lastSend.ts < 1500) return;
|
|
8404
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
|
+
|
|
8405
8523
|
const attach = state.attach.slice();
|
|
8406
8524
|
const item = {
|
|
8407
8525
|
oid: `o${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
@@ -10254,6 +10372,8 @@ $('filein').addEventListener('change', () => {
|
|
|
10254
10372
|
for (const f of $('filein').files) uploadFile(f);
|
|
10255
10373
|
$('filein').value = '';
|
|
10256
10374
|
});
|
|
10375
|
+
// Feature A: ✕ on the "Replying to #NNN" chip detaches → back to new-goal mode.
|
|
10376
|
+
$('replyBindX').onclick = () => { state.selectedGoal = null; render(); };
|
|
10257
10377
|
const IS_MOBILE = () => window.matchMedia('(max-width: 760px)').matches;
|
|
10258
10378
|
// Phones are chat-first: the Tasks sheet starts FOLDED so the first view is the
|
|
10259
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