@galda/cli 0.10.35 → 0.10.36

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
@@ -1546,6 +1546,20 @@
1546
1546
  .fsbcol:hover .fsbcadd{opacity:.7}
1547
1547
  .fsbcadd:hover{opacity:1;color:var(--ink);background:var(--hover)}
1548
1548
  .fsbcadd svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round}
1549
+ /* Doing列の一時停止/再生トグル (Masa 2026-07-18・Spotify式). Running=hoverで出る一時停止
1550
+ ❚❚ / paused=常時表示の再生 ▶ (停止状態が読める+再開できる). hoverは面を塗らずグリフ
1551
+ 色のみ (DESIGN-RULES §5.5). 中立グレー: 緑/赤はstatusに読めるので使わない. board版=col
1552
+ ヘッダの Doing ラベル隣, list版=下の .tlcap-pause が #fsTodoSec の DOING 見出し隣. */
1553
+ .fsbcpause{width:20px;height:20px;display:grid;place-items:center;border:0;background:none;color:var(--ink3);cursor:pointer;border-radius:5px;opacity:0;transition:opacity .12s,color .12s}
1554
+ .fsbcol:hover .fsbcpause{opacity:.7}
1555
+ .fsbcpause:hover{opacity:1;color:var(--ink)}
1556
+ .fsbcpause[aria-pressed="true"]{opacity:1;color:var(--ink)}
1557
+ .fsbcpause svg{width:15px;height:15px;fill:currentColor;stroke:none}
1558
+ .tlcap-pause{display:inline-grid;place-items:center;width:15px;height:15px;vertical-align:-3px;margin-left:6px;border:0;background:none;color:var(--ink3);cursor:pointer;border-radius:4px;opacity:0;transition:opacity .12s,color .12s}
1559
+ #fsTodoSec:hover .tlcap-pause{opacity:.7}
1560
+ .tlcap-pause:hover{opacity:1;color:var(--ink)}
1561
+ .tlcap-pause[aria-pressed="true"]{opacity:1;color:var(--ink)}
1562
+ .tlcap-pause svg{width:11px;height:11px;fill:currentColor;stroke:none}
1549
1563
  .fsbbody{flex:1;min-height:12px;overflow-y:auto;padding:1px 2px 6px;display:flex;flex-direction:column;gap:5px}
1550
1564
  .fsbbody::-webkit-scrollbar{width:7px}.fsbbody::-webkit-scrollbar-thumb{background:var(--hair);border-radius:6px}
1551
1565
  /* Board card = Depth (CDO, Masa 2026-07-17): a card is SCANNED, not read — #NN + a
@@ -5759,6 +5773,7 @@ function fsBoardBucket(goal, tasks){
5759
5773
  const s = goal?.status;
5760
5774
  if (s === 'pending') return 'later'; // shelved by the composer's "Later"
5761
5775
  if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
5776
+ if (s === 'blocked' && goal?.blocked?.kind === 'paused') return 'doing'; // paused by user — stays in Doing, shown amber "Paused" (Masa 2026-07-19: 1カードのまま黄色に・Needs youへ移動しない)
5762
5777
  if (['blocked', 'failed', 'partial', 'interrupted'].includes(s)) return 'needs'; // stopped/errored — needs YOUR action (retry), like the flagship board which puts .drow.err in Needs you. Not "Doing" — Doing is only what's actively moving (Masa 2026-07-18)
5763
5778
  if (s === 'review') return 'review';
5764
5779
  if (s === 'done' || s === 'reverted' || s === 'skipped') return 'done'; // terminal; skipped is finished, not in flight (it used to fall through to the doing default)
@@ -5788,6 +5803,7 @@ function fsBoardBucket(goal, tasks){
5788
5803
  // above (proof thumbnails, PR links, approve/dismiss) — the board doesn't duplicate it.
5789
5804
  function renderFsBoard(){
5790
5805
  const el = $('fsBoard'); if (!el) return;
5806
+ const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // Doing列トグルの状態
5791
5807
  const { pgoals, list } = fsBucketTodo();
5792
5808
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 60));
5793
5809
  const goalNo = new Map();
@@ -5798,7 +5814,7 @@ function renderFsBoard(){
5798
5814
  // flagship mock; a new goal enters via the composer). Earlier this was To-Do-only, which read
5799
5815
  // as "position is off" against the Artifact (Masa 2026-07-18).
5800
5816
  const col = (name, stg, count, html, stage) =>
5801
- `<section class="fsbcol" data-stage="${stage}" style="--stg:${stg}"><div class="fsbcolhd"><span class="fsbdot"></span><span class="fsbcolnm">${name}</span><span class="fsbcnt">${count}</span><button class="fsbcadd" data-fsnew="1" title="New goal"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button></div><div class="fsbbody">${html || '<div class="fsbempty">—</div>'}</div><button class="fsbadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button></section>`;
5817
+ `<section class="fsbcol" data-stage="${stage}" style="--stg:${stg}"><div class="fsbcolhd"><span class="fsbdot"></span><span class="fsbcolnm">${name}</span>${stage === 'doing' ? `<button class="fsbcpause" 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 class="fsbcnt">${count}</span><button class="fsbcadd" data-fsnew="1" title="New goal"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button></div><div class="fsbbody">${html || '<div class="fsbempty">—</div>'}</div><button class="fsbadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button></section>`;
5802
5818
  // Depth card (CDO, Masa 2026-07-17): #NN + title, colour on the dot only, and a continuous
5803
5819
  // stroke + n/m ONLY while the goal is actually in flight. The column already names the
5804
5820
  // status, so the card never repeats it. Stopped/failed carry NO bar (a dead bar would claim
@@ -5810,9 +5826,11 @@ function renderFsBoard(){
5810
5826
  // not Doing). Doing holds only what's actively moving, so bkt==='doing' ⇒ healthy in flight.
5811
5827
  const errored = ['blocked', 'failed', 'partial', 'interrupted'].includes(g.status);
5812
5828
  const intr = g.status === 'interrupted';
5813
- const healthyRun = bkt === 'doing';
5829
+ const isPaused = g.status === 'blocked' && g.blocked?.kind === 'paused'; // 一時停止=エラーでなくアンバー扱い
5830
+ const amber = intr || isPaused;
5831
+ const healthyRun = bkt === 'doing' && !errored; // paused(=errored) goals now sit in 'doing' too but carry no progress bar
5814
5832
  let dot = '', word = '';
5815
- if (errored) { dot = intr ? 'intr' : 'bad'; word = intr ? 'Interrupted' : 'Failed'; } // shown in Needs you, with the word
5833
+ if (errored) { dot = amber ? 'intr' : 'bad'; word = isPaused ? 'Paused' : intr ? 'Interrupted' : 'Failed'; } // shown in Needs you, with the word
5816
5834
  else if (bkt === 'review') dot = 'rev';
5817
5835
  else if (bkt === 'done') dot = 'ok';
5818
5836
  else if (bkt === 'needs') dot = 'intr'; // your turn — a clarifying question is waiting
@@ -5823,7 +5841,7 @@ function renderFsBoard(){
5823
5841
  let h = '<div class="lbc-hd">'
5824
5842
  + (dot ? `<span class="lbc-dot ${dot}"></span>` : '')
5825
5843
  + (n != null ? `<span class="lbc-n">#${n}</span>` : '')
5826
- + (word ? `<span class="lbc-w ${intr ? 'intr' : 'bad'}">${word}</span>` : '')
5844
+ + (word ? `<span class="lbc-w ${amber ? 'intr' : 'bad'}">${word}</span>` : '')
5827
5845
  + '</div>'
5828
5846
  + `<div class="lbc-t">${tt(g.text || g.plan?.[0], 90)}</div>`;
5829
5847
  // n/m is Doing-only (Masa 2026-07-17): a fraction on a stopped/queued/done card is
@@ -5854,8 +5872,22 @@ function renderFsBoard(){
5854
5872
  + col('Done', 'var(--ink3)', bucket.done.length, bucket.done.slice(0, 40).map(goalCard).join(''), 'done')
5855
5873
  + col('Later', 'var(--ink3)', bucket.later.length, bucket.later.map(goalCard).join(''), 'later');
5856
5874
  for (const b of el.querySelectorAll('[data-fsnew]')) b.onclick = () => $('input')?.focus(); // every + New / header + focuses the composer
5875
+ for (const b of el.querySelectorAll('[data-fspause]')) b.onclick = togglePauseActive; // Doing列の一時停止/再生
5857
5876
  renderFsWait(bucket);
5858
5877
  }
5878
+ // Doing列の一時停止/再生 (Masa 2026-07-18・Spotify式トグル). 楽観的にローカルの
5879
+ // project.paused を反転して即描画→POST. サーバは pause/resume 後に {ev:'projects'}
5880
+ // を broadcast するので、確定状態はSSEハンドラが state.projects を上書きして一致
5881
+ // させる. POST失敗時だけ元に戻す. board/list 両方の [data-fspause] が共有する。
5882
+ function togglePauseActive(){
5883
+ const p = state.projects.find((x) => x.id === state.active);
5884
+ if (!p) return;
5885
+ const next = !p.paused;
5886
+ p.paused = next; render();
5887
+ fetch(withKey(`/api/projects/${state.active}/${next ? 'pause' : 'resume'}`), { method: 'POST', headers: { 'content-type': 'application/json' } })
5888
+ .then((r) => { if (!r.ok) throw new Error(`pause toggle ${r.status}`); })
5889
+ .catch(() => { p.paused = !next; render(); });
5890
+ }
5859
5891
  // Waiting badge (CDO, Masa 2026-07-17 decision 2): the board keeps work-flow column order, so
5860
5892
  // the "your turn" columns (Needs you, Review) can sit off the right edge at a narrow lane
5861
5893
  // width. They announce themselves beside the List/Board toggle instead — pure read of the same
@@ -6045,6 +6077,10 @@ function fsBucketTodo(){
6045
6077
  function renderFsTodo(){
6046
6078
  const el = $('fsTodoSec'); if (!el) return;
6047
6079
  const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later } = fsBucketTodo();
6080
+ const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // list版 Doing トグルの状態
6081
+ // 一時停止したゴール(blocked kind:'paused')は Doing に「Paused」1行で留める(Masa 2026-07-19)。
6082
+ // Needs you 側(failed/blocked)へは出さず、その走行/中断タスク行も抑止して二重行を作らない。
6083
+ const pausedGoalIds = new Set(pgoals.filter((g) => g.status === 'blocked' && g.blocked?.kind === 'paused').map((g) => g.id));
6048
6084
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
6049
6085
  // LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
6050
6086
  // PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
@@ -6063,8 +6099,10 @@ function renderFsTodo(){
6063
6099
  // out to its own "Needs you" caption below (Masa 2026-07-19) — folding it into Doing read
6064
6100
  // as "still in flight".
6065
6101
  const doingRows =
6066
- running.map((t) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
6067
- <div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('');
6102
+ running.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
6103
+ <div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('')
6104
+ // 一時停止したゴールは Doing に「Paused」1行(アンバー)で留める+Resume。走行/中断行は上と needsRows で抑止済み。
6105
+ + pgoals.filter((g) => pausedGoalIds.has(g.id)).map((g) => `<div class="drow"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword int">Paused</span><button class="ab txt" data-fspause="1" title="Resume — 再開">Resume</button></div></div>`).join('');
6068
6106
  // NEEDS YOU: stopped/errored work that used to fold into Doing, now under its own caption so
6069
6107
  // it no longer reads as "still in flight" (Masa 2026-07-19). Three kinds share it, each
6070
6108
  // keeping the affordances it already had — nothing about the failed-task and blocked-goal
@@ -6081,7 +6119,7 @@ function renderFsTodo(){
6081
6119
  // both still call the same /retry endpoint, which already resumes the session when one
6082
6120
  // exists (server-side retry = "resume if possible, else re-run"; there is no separate
6083
6121
  // resume endpoint to call).
6084
- failed.map((t) => { const isInt = t.status === 'interrupted';
6122
+ failed.filter((t) => !pausedGoalIds.has(t.goalId)).map((t) => { const isInt = t.status === 'interrupted';
6085
6123
  const word = isInt ? 'Interrupted' : 'Failed', icon = isInt ? '⏸' : '✗', label = isInt ? 'Resume' : 'Retry';
6086
6124
  // don't restate the state word as the "reason" too (e.g. "Interrupted / Interrupted")
6087
6125
  // when the task has no distinct result text — the coloured word already said it, so
@@ -6092,7 +6130,7 @@ function renderFsTodo(){
6092
6130
  return `<div class="drow err${isInt ? ' int' : ''}"><div class="trow now"><span class="st run ${isInt ? 'int' : 'bad'}"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="stword ${isInt ? 'int' : ''}">${word}</span>${fsActs(t.goalId)}</div>
6093
6131
  <div class="errline">${reasonLine}
6094
6132
  <div class="acts2"><button class="errbtn" data-fsretry="${t.id}">${label}</button><button class="errbtn" data-fslog="${t.id}">View log</button></div></div></div>`; }).join('')
6095
- + blocked.map((g) => `<div class="drow err"><div class="trow now"><span class="st run bad"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword">Failed</span></div>
6133
+ + blocked.map((g) => g.blocked?.kind === 'paused' ? '' : `<div class="drow err"><div class="trow now"><span class="st run bad"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword">Failed</span></div>
6096
6134
  <div class="errline"><div class="msg"><span class="x">✗</span>${tt(g.blocked?.reason ?? 'blocked', 180)}</div>
6097
6135
  <div class="acts2"><button class="errbtn" data-fsreverify="${g.id}">Retry</button><button class="errbtn" data-fsglog="${g.id}">View log</button><button class="errbtn" data-fsarchive="${g.id}">Archive</button></div></div></div>`).join('')
6098
6136
  + 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>
@@ -6137,9 +6175,9 @@ function renderFsTodo(){
6137
6175
  : `<span class="num fstdlabel">${todoTotal}${queued.length ? ' · next-up order' : ''}</span>`;
6138
6176
  el.innerHTML = `<div class="lhd sechd" id="fsTodoHd"><span class="hd fstdlabel">To Do</span>${_todoNum}<span class="fold fstdlabel">▾</span></div>
6139
6177
  <div class="tl">
6140
- ${doingRows ? `<span class="tlcap" style="padding-top:0">Doing</span>${doingRows}` : ''}
6141
- ${needsRows ? `<span class="tlcap needs"${doingRows ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
6142
- ${upRows ? `<span class="tlcap"${doingRows || needsRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
6178
+ ${(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}` : ''}
6179
+ ${needsRows ? `<span class="tlcap needs"${(doingRows || paused) ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
6180
+ ${upRows ? `<span class="tlcap"${(doingRows || paused) || needsRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
6143
6181
  ${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
6144
6182
  ${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
6145
6183
  <button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
@@ -6159,6 +6197,7 @@ function renderFsTodo(){
6159
6197
  fsUI.repsOpen.has(id) ? fsUI.repsOpen.delete(id) : fsUI.repsOpen.add(id);
6160
6198
  b.closest('.grp').classList.toggle('open');
6161
6199
  };
6200
+ for (const b of el.querySelectorAll('[data-fspause]')) b.onclick = togglePauseActive; // list版 Doing 一時停止/再生(+Paused行のResume)
6162
6201
  for (const b of el.querySelectorAll('[data-fsretry]')) b.onclick = () => fsRetryTask(b.dataset.fsretry);
6163
6202
  for (const b of el.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
6164
6203
  for (const b of el.querySelectorAll('[data-fsglog]')) b.onclick = () => toggleGoalDetail(Number(b.dataset.fsglog));
package/engine/lib.mjs CHANGED
@@ -1375,6 +1375,22 @@ export function classifyComposerIntentHeuristic(text) {
1375
1375
  return 'unsure'; // both or neither → let the LLM decide
1376
1376
  }
1377
1377
 
1378
+ // Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). A DETERMINISTIC,
1379
+ // conservative match — no LLM, no cost — that only fires when the WHOLE message
1380
+ // is a short, command-like pause/resume order. Anchored ^…$ with only polite
1381
+ // suffixes allowed after the keyword, plus a length cap, so a described request
1382
+ // that merely contains the word ("止めてほしいバグを直して", "start building the
1383
+ // login") is never mistaken for a control command. Returns 'pause'|'resume'|null.
1384
+ const PAUSE_INTENT_RE = /^(一時停止|いったん停止|一旦停止|停止|止めて|とめて|ストップ|作業を?止めて|全部止めて|全部停止|pause|stop)(?:\s*(して|してください|ください|でお願いします?|お願いします?|please|now))?[\s。.!!、,]*$/i;
1385
+ const RESUME_INTENT_RE = /^(再開|再生|再スタート|続けて|続きから|続行|resume|restart|start|play|go)(?:\s*(して|してください|ください|お願いします?|please|now))?[\s。.!!、,]*$/i;
1386
+ export function detectPauseIntent(text) {
1387
+ const t = (text || '').trim();
1388
+ if (!t || t.length > 24) return null; // a control command is short; long text is a real request
1389
+ if (PAUSE_INTENT_RE.test(t)) return 'pause';
1390
+ if (RESUME_INTENT_RE.test(t)) return 'resume';
1391
+ return null;
1392
+ }
1393
+
1378
1394
  // The one prompt that BOTH classifies and (for chat) writes the reply in the
1379
1395
  // requester's language — one Haiku round-trip, not two. The model returns a
1380
1396
  // single line of JSON: {"intent":"chat|task","reply":"…"}.
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, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
24
- import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, 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, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot } from './lib.mjs';
24
+ import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, 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, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot } from './lib.mjs';
25
25
  import { openPR } from './pr.mjs';
26
26
  import { runVerification, exerciseUi } from './verify.mjs';
27
27
 
@@ -657,11 +657,17 @@ if (process.env.MANAGER_SEED_RUNNING_FIXTURE === '1') {
657
657
  const goalId = nextId++, taskId = nextId++;
658
658
  goals.push({
659
659
  id: goalId, projectId: 'default', text: '[verify fixture] 実行中ゴール', status: 'running',
660
+ // startedAt is what a REAL running goal carries (server.mjs sets it when the
661
+ // first task starts — see runTask). Without it the board bucketed this as
662
+ // To Do while the list showed it under Doing (task-level) — the two views
663
+ // disagreed on a malformed fixture. Set it so the seed matches reality:
664
+ // both views place a running goal in Doing (Masa 2026-07-19).
665
+ startedAt: at,
660
666
  wantsPR: false, model: 'sonnet', images: [], plan: ['fixture task'], pr: undefined, createdAt: at,
661
667
  });
662
668
  tasks.push({
663
669
  id: taskId, num: 1, goalId, projectId: 'default', title: 'fixture task', detail: '', passCondition: '',
664
- model: 'sonnet', status: 'running', createdAt: at, result: null, changedFiles: [], secs: null, proof: null, usage: null,
670
+ model: 'sonnet', status: 'running', createdAt: at, startedAt: at, result: null, changedFiles: [], secs: null, proof: null, usage: null,
665
671
  activity: [
666
672
  'Read app/index.html', 'Grep taskBlock', 'Edit app/index.html', 'Read engine/lib.mjs',
667
673
  'Bash node --test engine/test/*.test.mjs', '✓ 12 tests passed', 'Edit engine/server.mjs',
@@ -1302,12 +1308,22 @@ function globalRunningCount() {
1302
1308
  return n;
1303
1309
  }
1304
1310
 
1311
+ // Per-project play/pause (Masa: Doing列の一時停止/再生). A paused project neither
1312
+ // starts new stacked goals nor pumps queued tasks — the two choke points below
1313
+ // (startNextStacked / pump) read this, so EVERY caller of them is covered by one
1314
+ // guard each. The flag lives on the project object (projects.json), so it
1315
+ // survives a restart; nothing auto-resumes a paused project.
1316
+ function isProjectPaused(projectId) {
1317
+ return !!projects.find((p) => p.id === projectId)?.paused;
1318
+ }
1319
+
1305
1320
  // Goals stack up per project: while one goal is being planned or executed,
1306
1321
  // newer goals wait as 'stacked' (editable / deletable until picked up).
1307
1322
  function goalActive(projectId) {
1308
1323
  return goals.some((g) => g.projectId === projectId && ['planning', 'running'].includes(g.status));
1309
1324
  }
1310
1325
  function startNextStacked(projectId) {
1326
+ if (isProjectPaused(projectId)) return; // paused → don't begin planning queued goals
1311
1327
  if (goalActive(projectId)) return;
1312
1328
  const next = goals.filter((g) => g.projectId === projectId && g.status === 'stacked')
1313
1329
  .sort((a, b) => (b.prio ?? 0) - (a.prio ?? 0) || a.id - b.id)[0];
@@ -1496,6 +1512,9 @@ function pump(projectId) {
1496
1512
  // to sign `claude` in and relaunch). Never spawn a worker that can only fail
1497
1513
  // with "Not logged in". Cleared automatically once a probe succeeds.
1498
1514
  if (!workerAuth.ok) return;
1515
+ // Project paused by the user (Doing列の一時停止 / chat) → hold this project's
1516
+ // queue exactly like the auth hold above. Resume flips the flag and pumps.
1517
+ if (isProjectPaused(projectId)) return;
1499
1518
  const q = queues.get(projectId);
1500
1519
  // sortQueueByPriority is a stable sort: it only ever moves a task ahead of
1501
1520
  // a lower-priority one, never disturbs relative order within the same
@@ -1729,6 +1748,48 @@ function rateLimitResumeSweep() {
1729
1748
  const rateLimitSweepTimer = setInterval(rateLimitResumeSweep, RATE_LIMIT_SWEEP_MS);
1730
1749
  rateLimitSweepTimer.unref?.();
1731
1750
 
1751
+ // Project play/pause (Masa: Doing列の一時停止ボタン + チャット). Pause STOPS the
1752
+ // project's in-flight Doing work and prevents new ToDo from starting; Resume
1753
+ // continues from where it left off. Modelled on the rate-limit pause above (a
1754
+ // SIGTERM'd worker becomes an 'interrupted' task with its sessionId preserved,
1755
+ // so runTask's retry/resume path re-continues the same conversation) — the only
1756
+ // differences are: it's user-driven (no resumeAt / auto-resume), and it flips
1757
+ // the persisted project.paused flag that gates pump()/startNextStacked().
1758
+ //
1759
+ // Race note: setting goal.status='blocked' HERE (synchronously) BEFORE the
1760
+ // SIGTERM'd worker's async exit runs means finishGoalIfComplete later sees a
1761
+ // non-'running'/'partial' goal and bails at its status guard without clobbering
1762
+ // the paused state — see finishGoalIfComplete's early return.
1763
+ function pauseProject(projectId) {
1764
+ const project = projects.find((p) => p.id === projectId);
1765
+ if (!project || project.paused) return false;
1766
+ project.paused = true;
1767
+ saveProjects();
1768
+ for (const g of goals.filter((g) => g.projectId === projectId && g.status === 'running')) {
1769
+ killGoalWorkers(g.id); // SIGTERM → task becomes 'interrupted' (resumable), not cancelled
1770
+ g.status = 'blocked';
1771
+ g.blocked = { kind: 'paused', reason: 'paused by user — resume to continue' };
1772
+ saveGoal(g);
1773
+ }
1774
+ send({ ev: 'projects', projects });
1775
+ return true;
1776
+ }
1777
+ function resumeProject(projectId) {
1778
+ const project = projects.find((p) => p.id === projectId);
1779
+ if (!project || !project.paused) return false;
1780
+ project.paused = false;
1781
+ saveProjects();
1782
+ // Re-queue each paused goal's interrupted tasks and flip it back to running
1783
+ // (reuses the proven rate-limit resume: continues from the preserved session).
1784
+ for (const g of goals.filter((g) => g.projectId === projectId && g.status === 'blocked' && g.blocked?.kind === 'paused')) {
1785
+ resumeRateLimitedGoal(g);
1786
+ }
1787
+ startNextStacked(projectId);
1788
+ pump(projectId);
1789
+ send({ ev: 'projects', projects });
1790
+ return true;
1791
+ }
1792
+
1732
1793
  // Slack風スレッド返信タスクの生成(/api/goals/:id/reply と /api/goals/:id/dismiss で共有)。
1733
1794
  // runTask()のtask.reply分岐がgoal.sessionIdをresumeに渡して同じ会話を続ける。
1734
1795
  function queueReplyTask(goal, text) {
@@ -2365,31 +2426,66 @@ async function runProjectTests(dir) {
2365
2426
  // Failing tests on the BASE (pre-change) checkout — the set the review gate
2366
2427
  // subtracts so a goal is blocked only on failures IT introduced, never on a
2367
2428
  // test that was already red (e.g. persist-failure on clean origin/main). Lazy
2368
- // + cached by the base's HEAD sha in MANAGER_HOME: computed only when a goal's
2369
- // own run is red, then reused across goals sharing the same base (main moves a
2370
- // few times a day, so this is a handful of full runs a day, not one per goal).
2429
+ // + cached by the base-ref sha in MANAGER_HOME: computed only when a goal's own
2430
+ // run is red, then reused across goals sharing the same base (main moves a few
2431
+ // times a day, so this is a handful of full runs a day, not one per goal).
2371
2432
  // Any failure to compute → [] = "unknown", and the caller falls back to the
2372
2433
  // old block-on-any-red behaviour (fail closed).
2434
+ //
2435
+ // 🔴 The base MUST be the ref the goal worktree was branched from —
2436
+ // origin/<default> (goalWorkDir: `git worktree add … origin/<br>`), NOT the
2437
+ // registry dir's working HEAD. Those diverge in the wild: a dogfood checkout
2438
+ // left on an old branch, or uncommitted local work. Measuring the baseline on a
2439
+ // stale HEAD runs a suite where today's red tests don't exist yet → an EMPTY
2440
+ // failing set → the gate falls closed and blocks EVERY goal on a red that was
2441
+ // actually pre-existing. (Masa 2026-07-19: the registry dir sat 612 commits
2442
+ // behind origin/main, so every goal Failed on "テスト失敗 1件" the baseline
2443
+ // should have excused.) So resolve origin/<default> and run the base suite in a
2444
+ // throwaway detached worktree of that exact commit — nested under
2445
+ // repoRoot/.manager-wt so node's upward node_modules resolution still finds the
2446
+ // repo's deps (the same trick goal worktrees rely on), and so we never mutate
2447
+ // the registry checkout (prod procs read it; it may be on any branch).
2373
2448
  const BASELINE_CACHE = join(DATA_DIR, 'test-baseline.json');
2449
+ const baselineInFlight = new Map(); // sha -> Promise<string[]> — dedupe concurrent goal closes
2374
2450
  async function getBaselineFailures(baseProject) {
2375
2451
  const dir = baseProject?.dir;
2376
2452
  if (!dir) return [];
2377
- let sha = '';
2378
- try {
2379
- const r = spawnSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
2380
- if (r.status === 0) sha = String(r.stdout || '').trim();
2381
- } catch { return []; }
2453
+ let repoRoot;
2454
+ try { repoRoot = gitSync(dir, ['rev-parse', '--show-toplevel']); } catch { return []; }
2455
+ const br = repoDefaultBranch(dir);
2456
+ try { gitSync(repoRoot, ['fetch', 'origin', br, '--quiet']); } catch { /* offline — use whatever origin/<br> we already have */ }
2457
+ // The base ref the goal worktree is cut from: origin/<br>, else the local
2458
+ // branch tip, else the working HEAD (offline single-checkout fallback).
2459
+ const sha = (gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${br}`])
2460
+ || gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', br])
2461
+ || gitOut(repoRoot, ['rev-parse', '--verify', '--quiet', 'HEAD']) || '').trim();
2382
2462
  if (!sha) return [];
2383
2463
  try {
2384
2464
  const cached = JSON.parse(readFileSync(BASELINE_CACHE, 'utf8'));
2385
2465
  if (cached && cached.sha === sha && Array.isArray(cached.failingNames)) return cached.failingNames;
2386
2466
  } catch { /* no cache yet */ }
2387
- let base;
2388
- try { base = await runProjectTests(dir); } catch { return []; }
2389
- if (!base?.ran) return [];
2390
- const failingNames = base.failingNames ?? [];
2391
- try { writeFileSync(BASELINE_CACHE, JSON.stringify({ sha, failingNames, at: new Date().toISOString() })); } catch { /* best effort */ }
2392
- return failingNames;
2467
+ if (baselineInFlight.has(sha)) return baselineInFlight.get(sha);
2468
+ const job = (async () => {
2469
+ const wt = join(repoRoot, '.manager-wt', `baseline-${sha.slice(0, 12)}`);
2470
+ let base = null;
2471
+ try {
2472
+ try { gitSync(repoRoot, ['worktree', 'remove', '--force', wt]); } catch { /* nothing to clean */ }
2473
+ gitSync(repoRoot, ['worktree', 'add', '--detach', wt, sha]);
2474
+ base = await runProjectTests(wt);
2475
+ } catch {
2476
+ // Worktree couldn't be made (odd repo / offline no-remote) — fall back to
2477
+ // the registry dir in place = the pre-fix behaviour. Never worse.
2478
+ try { base = await runProjectTests(dir); } catch { base = null; }
2479
+ } finally {
2480
+ try { gitSync(repoRoot, ['worktree', 'remove', '--force', wt]); } catch { /* best effort */ }
2481
+ }
2482
+ if (!base?.ran) return [];
2483
+ const failingNames = base.failingNames ?? [];
2484
+ try { writeFileSync(BASELINE_CACHE, JSON.stringify({ sha, failingNames, at: new Date().toISOString() })); } catch { /* best effort */ }
2485
+ return failingNames;
2486
+ })();
2487
+ baselineInFlight.set(sha, job);
2488
+ try { return await job; } finally { baselineInFlight.delete(sha); }
2393
2489
  }
2394
2490
 
2395
2491
  // ---- See all Ledger engine jobs (HANDOFF-v45 §5 / PRD §5.1) ----------------
@@ -3272,6 +3368,18 @@ const server = createServer(async (req, res) => {
3272
3368
  return;
3273
3369
  }
3274
3370
 
3371
+ // Per-project play/pause (Masa: Doing列の一時停止/再生・チャットからも同じ). POST
3372
+ // .../pause stops in-flight Doing work + holds the queue; .../resume continues
3373
+ // from where it left off. Broadcasts the projects change so every client's
3374
+ // toggle updates. Idempotent: pausing an already-paused project is a 200 no-op.
3375
+ const pauseMatch = url.pathname.match(/^\/api\/projects\/([\w-]+)\/(pause|resume)$/);
3376
+ if (pauseMatch && req.method === 'POST') {
3377
+ const project = projects.find((p) => p.id === pauseMatch[1]);
3378
+ if (!project) return json(res, 404, { error: 'project not found' });
3379
+ if (pauseMatch[2] === 'pause') pauseProject(project.id); else resumeProject(project.id);
3380
+ return json(res, 200, { project, projects, paused: !!project.paused });
3381
+ }
3382
+
3275
3383
  // proof artifacts: /proof/<taskId>/<file> (gif/png/mp4 from that task's proof dir)
3276
3384
  const proofMatch = url.pathname.match(/^\/proof\/(\d+)\/([\w.-]+)$/);
3277
3385
  if (proofMatch) {
@@ -3301,6 +3409,21 @@ const server = createServer(async (req, res) => {
3301
3409
  const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent } = JSON.parse(body);
3302
3410
  const project = projects.find((p) => p.id === projectId);
3303
3411
  if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
3412
+ // Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
3413
+ // (no LLM), checked BEFORE the intent router below: a short command-like
3414
+ // "止めて"/"再開" toggles THIS project's pause state and returns a chat reply
3415
+ // — it never becomes a To-Do. /later and /review skip this like the router.
3416
+ if (!pending && !review) {
3417
+ const pauseIntent = detectPauseIntent(text.trim());
3418
+ if (pauseIntent === 'pause') {
3419
+ pauseProject(projectId);
3420
+ return json(res, 200, { kind: 'chat', reply: `${project.name} を一時停止しました。動いていた作業を止め、新しいToDoも始めません。再生(▶)で続きから再開します。` });
3421
+ }
3422
+ if (pauseIntent === 'resume') {
3423
+ resumeProject(projectId);
3424
+ return json(res, 200, { kind: 'chat', reply: `${project.name} を再開しました。止めた作業を続きから進めます。` });
3425
+ }
3426
+ }
3304
3427
  // Onboarding (docs/HANDOFF-ONBOARDING-conversational.md, SLICE 2): the
3305
3428
  // composer is conversation-first. A chat/help/settings message ("what can
3306
3429
  // you do?", "reply in English", a greeting) gets a conversational reply
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.35",
3
+ "version": "0.10.36",
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": {