@galda/cli 0.10.50 → 0.10.52

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}
@@ -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
@@ -0,0 +1,13 @@
1
+ // Claude Code accepts these stable aliases and resolves them to the current
2
+ // model versions for the active account and provider.
3
+ export const CLAUDE_MODELS = Object.freeze([
4
+ 'sonnet',
5
+ 'fable',
6
+ 'best',
7
+ 'opus',
8
+ 'haiku',
9
+ 'default',
10
+ 'sonnet[1m]',
11
+ 'opus[1m]',
12
+ 'opusplan',
13
+ ]);
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ export const FALLBACK_CODEX_MODELS = [
6
+ 'gpt-5.6-sol',
7
+ 'gpt-5.6-terra',
8
+ 'gpt-5.6-luna',
9
+ 'gpt-5.5',
10
+ 'gpt-5.4',
11
+ 'gpt-5.4-mini',
12
+ 'gpt-5.3-codex-spark',
13
+ ];
14
+
15
+ export function parseCodexModelCache(raw, fallback = FALLBACK_CODEX_MODELS) {
16
+ try {
17
+ const parsed = JSON.parse(String(raw));
18
+ const models = Array.isArray(parsed?.models)
19
+ ? parsed.models
20
+ .filter((model) => model?.visibility === 'list' && typeof model.slug === 'string')
21
+ .map((model) => model.slug.trim())
22
+ .filter(Boolean)
23
+ : [];
24
+ return models.length ? [...new Set(models)] : fallback.slice();
25
+ } catch {
26
+ return fallback.slice();
27
+ }
28
+ }
29
+
30
+ // Codex refreshes this cache itself. Reading it gives Galda the account's real
31
+ // picker catalog without launching Codex, making a network request, or running
32
+ // an installer. Older CLIs and first-run installs simply use the fallback.
33
+ export function loadCodexModels({ cacheFile, readFile = readFileSync } = {}) {
34
+ const file = cacheFile
35
+ || process.env.MANAGER_CODEX_MODELS_FILE
36
+ || join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'models_cache.json');
37
+ try {
38
+ return parseCodexModelCache(readFile(file, 'utf8'));
39
+ } catch {
40
+ return FALLBACK_CODEX_MODELS.slice();
41
+ }
42
+ }
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
@@ -20,10 +20,12 @@ import { resolve, dirname, join, basename } from 'node:path';
20
20
  import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
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';
23
+ import { needsProjectFolder, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath } from './lib.mjs';
24
+ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, 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
+ import { FALLBACK_CODEX_MODELS, loadCodexModels } from './codex-models.mjs';
28
+ import { CLAUDE_MODELS } from './claude-models.mjs';
27
29
  import { openPR } from './pr.mjs';
28
30
  import { runVerification, exerciseUi } from './verify.mjs';
29
31
 
@@ -71,8 +73,8 @@ const WORKER_TIMEOUT_MS = Number(process.env.MANAGER_WORKER_TIMEOUT_MS) || 15 *
71
73
  // review card showed a truncated report and called it evidence. Keep a cap —
72
74
  // this lands in persisted state — but one that fits a real answer.
73
75
  const RESULT_MAX = 20000;
74
- const MODELS = ['sonnet', 'opus', 'haiku'];
75
- const CODEX_MODELS = ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex-spark'];
76
+ const MODELS = CLAUDE_MODELS;
77
+ let CODEX_MODELS = FALLBACK_CODEX_MODELS.slice();
76
78
  const WORKER_AGENTS = ['claude-code', 'codex'];
77
79
  const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
78
80
  const CODEX_EFFORTS = ['low', 'medium', 'high', 'xhigh'];
@@ -647,14 +649,25 @@ let _syncDirtyAgain = false; // a save landed mid-push → coalesce into
647
649
  // Which worker agent CLIs are installed on this machine — the launcher
648
650
  // (bin/manager-for-ai.mjs) detects them (codex via a PATH lookup, never executed)
649
651
  // and passes the set here. Galda offers only these, so a Codex-only machine gets
650
- // a working board and a claude-only one never shows Codex. Defaults to Claude
651
- // Code when unset (tests / running the server directly).
652
- const envAgents = (process.env.MANAGER_AVAILABLE_AGENTS || 'claude-code')
652
+ // a working board and a claude-only one never shows Codex.
653
+ // The packaged launcher sets MANAGER_AVAILABLE_AGENTS after its safe CLI probe.
654
+ // Direct starts (launchd, `npm start`, development) bypass that launcher, so do
655
+ // the same non-executing PATH lookup here instead of silently becoming
656
+ // Claude-only. Never invoke an agent, npx, or an installer during detection.
657
+ const configuredAgents = process.env.MANAGER_AVAILABLE_AGENTS;
658
+ const detectedAgents = configuredAgents === undefined
659
+ ? [
660
+ isCommandOnPath('claude', { pathEnv: process.env.PATH || '', sep: process.platform === 'win32' ? ';' : ':', pathExt: process.env.PATHEXT || '' }) && 'claude-code',
661
+ isCommandOnPath('codex', { pathEnv: process.env.PATH || '', sep: process.platform === 'win32' ? ';' : ':', pathExt: process.env.PATHEXT || '' }) && 'codex',
662
+ ].filter(Boolean).join(',')
663
+ : configuredAgents;
664
+ const envAgents = (detectedAgents || 'claude-code')
653
665
  .split(',').map((a) => a.trim()).filter(Boolean);
654
666
  const AVAILABLE_AGENTS = resolveConnectedAgents({
655
667
  claude: envAgents.includes('claude-code'),
656
668
  codex: envAgents.includes('codex'),
657
669
  });
670
+ if (AVAILABLE_AGENTS.includes('codex')) CODEX_MODELS = loadCodexModels();
658
671
 
659
672
  // ---- state ---------------------------------------------------------------
660
673
  // Restarts must not lose pending work: tasks that never started stay
@@ -1952,7 +1965,26 @@ function queueReplyTask(goal, text, { from = 'manager', via = 'composer' } = {})
1952
1965
  };
1953
1966
  tasks.push(task);
1954
1967
  saveTask(task);
1955
- queues.get(goal.projectId).waiting.push(task);
1968
+ const q = queues.get(goal.projectId);
1969
+ q.waiting.push(task);
1970
+ // "空き分は即Doing・あふれ分はTo Do" (Masa 2026-07-24). A re-queued reply used to
1971
+ // keep the goal's stale startedAt from its first run, so the card read DOING even
1972
+ // when no worker could start: reply to N in-review cards with a parallel cap of 2
1973
+ // and all N said "Working on it" while only 2 actually ran. Decide startedAt the
1974
+ // same way pump() will on the next tick — if THIS task is within the runnable set
1975
+ // for the currently-free slots it starts now, so keep it DOING; otherwise it waits
1976
+ // in To Do (QUEUED) until a slot frees, where runTask() sets startedAt and it
1977
+ // auto-promotes to DOING. Only for a goal with no live worker of its own: a
1978
+ // follow-up reply to an already-running goal must stay DOING (its clock stands).
1979
+ if (goal.status === 'running' && ![...q.running].some((t) => t.goalId === goal.id)) {
1980
+ const slow = slowModeSettings();
1981
+ const projectCap = slow.enabled ? Math.min(PROJECT_MAX_PARALLEL, slow.maxParallel) : PROJECT_MAX_PARALLEL;
1982
+ const slots = Math.min(projectCap - q.running.size, GLOBAL_MAX_PARALLEL - globalRunningCount());
1983
+ const runningGoalIds = [...q.running].map((t) => t.goalId);
1984
+ const startsNow = nextRunnableTasks(q.waiting, runningGoalIds, slow.enabled ? 1 : slots).some((t) => t.id === task.id);
1985
+ goal.startedAt = startsNow ? new Date().toISOString() : null;
1986
+ saveGoal(goal);
1987
+ }
1956
1988
  // Spawn the worker on the NEXT event-loop turn, not synchronously here. runTask() runs
1957
1989
  // straight through to its first await — and that stretch includes goalWorkDir()'s
1958
1990
  // `git worktree add` (measured 1.78s on a checkout with ~135 worktrees). Callers like
@@ -2234,7 +2266,14 @@ async function runTask(task) {
2234
2266
  const project = projects.find((p) => p.id === task.projectId);
2235
2267
  const goal = goals.find((g) => g.id === task.goalId);
2236
2268
  task.status = 'running'; task.startedAt = new Date().toISOString();
2237
- if (goal && !goal.startedAt && !task.reply) { goal.startedAt = task.startedAt; saveGoal(goal); }
2269
+ // The goal's "started" clock is set by the first task that actually runs — the
2270
+ // signal the board uses to read a 'running' goal as DOING rather than QUEUED
2271
+ // (app GOAL_CHIP). A reply task counts too now: an over-cap re-queued reply
2272
+ // parks the goal in To Do with startedAt cleared (see queueReplyTask), and this
2273
+ // is where it flips to DOING the moment a worker slot frees and it starts. The
2274
+ // `!goal.startedAt` guard still protects an already-running goal (a follow-up
2275
+ // reply while its worker is live never resets the clock).
2276
+ if (goal && !goal.startedAt) { goal.startedAt = task.startedAt; saveGoal(goal); }
2238
2277
  saveTask(task);
2239
2278
  sendProcessAct(task, `Working on "${task.title}"${task.reply ? ' as a follow-up' : ''}.`);
2240
2279
 
@@ -2992,7 +3031,7 @@ function computeGoalRisk(goal, project, siblings) {
2992
3031
  // UI change — capturing it (3 tries) if missing. Any failure → 'blocked' (shown
2993
3032
  // in Attention with the reason); otherwise → review/done via nextGoalStatus.
2994
3033
  // Nothing reaches Review that a human can't judge.
2995
- async function verifyGate(goal, prProject, siblings, baseProject, activityTask = null) {
3034
+ async function verifyGate(goal, prProject, siblings, baseProject, activityTask = null, { forceTests = false } = {}) {
2996
3035
  const goalAct = (line) => { if (activityTask) sendProcessAct(activityTask, line); };
2997
3036
  const wdir = prProject.dir;
2998
3037
  const reviewDefinition = getReviewDefinition(goal.projectId);
@@ -3000,10 +3039,20 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
3000
3039
  const allDone = siblings.every(taskCountsAsComplete);
3001
3040
  const allVerified = judged.length > 0 && judged.every((t) => t.proof?.pass === true);
3002
3041
  const changedFiles = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].filter((f) => f && !SCRATCH_FILE_RE.test(f));
3003
- if (hasTestRelevantChanges(changedFiles)) {
3042
+ // Galda's own test gate is OFF by default (Masa 2026-07-25) — the worker already
3043
+ // ran the repo's own tests per its CLAUDE.md, and running the full suite again
3044
+ // here only manufactured false Failures under machine saturation. Opt in per
3045
+ // project (requireTests) or via MANAGER_VERIFY_GATE=on; the human "再検証/Retry"
3046
+ // path forces it on for that one goal. When off, testResult.ran stays false, so
3047
+ // testsFailing below is false and the goal never blocks or auto-reworks on tests.
3048
+ const testGateOn = resolveTestGate({ reviewDefinition, env: process.env, force: forceTests });
3049
+ if (testGateOn && hasTestRelevantChanges(changedFiles)) {
3004
3050
  goalAct(`Running project tests in the goal worktree (${changedFiles.length} changed file${changedFiles.length === 1 ? '' : 's'}).`);
3005
3051
  goal.testResult = await runProjectTests(wdir);
3006
3052
  goalAct(`Project tests finished: ${goal.testResult.failed ?? 0} failed, ${goal.testResult.passed ?? 0} passed.`);
3053
+ } else if (!testGateOn) {
3054
+ goal.testResult = { ran: false, skipped: true, skippedReason: 'test gate off (opt-in: requireTests / MANAGER_VERIFY_GATE=on)' };
3055
+ 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
3056
  } else {
3008
3057
  goal.testResult = { ran: false, skipped: true, skippedReason: changedFiles.length ? 'documentation-only change' : 'no changed files' };
3009
3058
  goalAct(`Skipped project tests: ${goal.testResult.skippedReason}.`);
@@ -4410,7 +4459,9 @@ const server = createServer(async (req, res) => {
4410
4459
  if (!project) return json(res, 404, { error: 'project not found' });
4411
4460
  const siblings = tasks.filter((t) => t.goalId === goal.id);
4412
4461
  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);
4462
+ // 再検証 is the explicit human "run the tests now" — force the gate on for this
4463
+ // one goal even when the default (automatic) gate is off.
4464
+ await verifyGate(goal, { ...project, dir: wdir }, siblings, project, null, { forceTests: true });
4414
4465
  saveGoal(goal);
4415
4466
  return json(res, 200, goal);
4416
4467
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.50",
3
+ "version": "0.10.52",
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": {