@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 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
- .replynote .txr.note .m{color:var(--ink3)}
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
- let chatHtml = '';
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
- chatHtml += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
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
- chatHtml += `<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>`;
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
- chatHtml += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
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
- chatHtml += turn.done
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
- // task feed (with its own speaker line) conversation turns → question (bottom)
6405
- let out = html + chatHtml + askHtml;
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
- doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status) && !closedGoalIds.has(t.goalId)).sort((a, b) => b.num - a.num),
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.slice(0, 3).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>` : ''}`;
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
- el.innerHTML = `<div class="sphead">${esc(head)}</div>` + p.items.map((it, i) => {
10022
- const name = p.level === 'top' ? '/' + it.cmd + (it.arg ? ' …' : '') : it.v;
10023
- const desc = p.level === 'top' ? it.desc : (it.d || '');
10024
- return `<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>`;
10025
- }).join('');
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.arg) { $('input').value = '/' + item.cmd + ' '; refreshPalette(); }
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).waiting.push(task);
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
- if (goal && !goal.startedAt && !task.reply) { goal.startedAt = task.startedAt; saveGoal(goal); }
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
- if (hasTestRelevantChanges(changedFiles)) {
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
- await verifyGate(goal, { ...project, dir: wdir }, siblings, project);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.49",
3
+ "version": "0.10.51",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {