@galda/cli 0.10.35 → 0.10.37

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/engine/lib.mjs CHANGED
@@ -877,6 +877,42 @@ export function buildGoalPrBody({ lang, goal, goalTasks, files, owner, branch, p
877
877
  ].join('\n');
878
878
  }
879
879
 
880
+ // summarizeForReview's prompt + reply parser, kept pure/here so engine/test can
881
+ // assert (a) the JSON asks for a work-title, (b) the title guidance names
882
+ // 修正/追加/改善/調査 so an "追加したい" request never becomes "…の修正", and (c) a
883
+ // failed/missing reply falls back to goal.text (no regression) — all without an
884
+ // LLM call. The app's saReviewTitle reads reviewSummary.title first (PR-B).
885
+ export function buildReviewSummaryPrompt({ lang, goalText, changed, reports }) {
886
+ return [
887
+ `あるゴールの実装が完了しました。レビューする人が一目で判断できるよう、やさしい${lang === 'ja' ? '日本語' : lang}で書いてください。`,
888
+ '専門用語・長い説明は禁止。小学生でも分かる短い言い方。JSONのみ出力(前後に文章を書かない):',
889
+ '{"title":"依頼を短い作業タイトルに言い換え(体言止め可)","check":"何を確認すれば良いか(1行)","changed":"何を変えたか(1行)"}',
890
+ 'titleは依頼文の意図を取り違えないこと。修正/追加/改善/調査を区別する。'
891
+ + '例:「Xの不具合」→「Xの修正」/「Yを追加したい」→「Yの追加」/「Zを速くしたい」→「Zの改善」/「なぜWか調べて」→「Wの調査」。',
892
+ '',
893
+ `ゴール: ${(goalText ?? '').slice(0, 300)}`,
894
+ `変更ファイル: ${(changed ?? []).join(', ') || '(なし)'}`,
895
+ `ワーカー報告:\n${(reports ?? '').slice(0, 1500)}`,
896
+ ].join('\n');
897
+ }
898
+
899
+ // Parse the model's JSON into {title, check, changed}, per-field fallback when a
900
+ // key is missing/blank. `fallback` carries the text-derived defaults
901
+ // (title/changed = goal.text) so a goal whose summary call failed still shows
902
+ // its own request text. Never throws — a broken/absent JSON yields the fallback.
903
+ export function parseReviewSummary(raw, fallback = {}) {
904
+ const clean = (v, n) => String(v ?? '').replace(/\s+/g, ' ').slice(0, n);
905
+ const m = String(raw ?? '').match(/\{[\s\S]*\}/);
906
+ if (!m) return { ...fallback };
907
+ let o;
908
+ try { o = JSON.parse(m[0]); } catch { return { ...fallback }; }
909
+ return {
910
+ title: clean(o.title, 60) || fallback.title,
911
+ check: clean(o.check, 120),
912
+ changed: clean(o.changed, 120) || fallback.changed,
913
+ };
914
+ }
915
+
880
916
  // A finished task's changed files look like a UI edit: app/ (the Manager's
881
917
  // own UI dir, or an equivalent frontend root) plus common frontend source
882
918
  // extensions. Pure/deterministic — no LLM — so it can gate proof capture
@@ -1139,6 +1175,7 @@ export function goalGroupStatus(goalStatus, startedAt) {
1139
1175
  if (goalStatus === 'folded') return 'review'; // PRD §6.5: rendered inside the Review section, not Done
1140
1176
  if (goalStatus === 'blocked') return 'attn'; // §4E: blocked is Attention, not Done
1141
1177
  if (goalStatus === 'needsInput') return 'todo'; // smart intake: awaiting an answer
1178
+ if (goalStatus === 'rejected') return 'rejected'; // 07-20c: Reject を溜める独立欄(Done ではない)
1142
1179
  return 'done'; // includes 'reverted' (terminal)
1143
1180
  }
1144
1181
 
@@ -1375,6 +1412,22 @@ export function classifyComposerIntentHeuristic(text) {
1375
1412
  return 'unsure'; // both or neither → let the LLM decide
1376
1413
  }
1377
1414
 
1415
+ // Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). A DETERMINISTIC,
1416
+ // conservative match — no LLM, no cost — that only fires when the WHOLE message
1417
+ // is a short, command-like pause/resume order. Anchored ^…$ with only polite
1418
+ // suffixes allowed after the keyword, plus a length cap, so a described request
1419
+ // that merely contains the word ("止めてほしいバグを直して", "start building the
1420
+ // login") is never mistaken for a control command. Returns 'pause'|'resume'|null.
1421
+ const PAUSE_INTENT_RE = /^(一時停止|いったん停止|一旦停止|停止|止めて|とめて|ストップ|作業を?止めて|全部止めて|全部停止|pause|stop)(?:\s*(して|してください|ください|でお願いします?|お願いします?|please|now))?[\s。.!!、,]*$/i;
1422
+ const RESUME_INTENT_RE = /^(再開|再生|再スタート|続けて|続きから|続行|resume|restart|start|play|go)(?:\s*(して|してください|ください|お願いします?|please|now))?[\s。.!!、,]*$/i;
1423
+ export function detectPauseIntent(text) {
1424
+ const t = (text || '').trim();
1425
+ if (!t || t.length > 24) return null; // a control command is short; long text is a real request
1426
+ if (PAUSE_INTENT_RE.test(t)) return 'pause';
1427
+ if (RESUME_INTENT_RE.test(t)) return 'resume';
1428
+ return null;
1429
+ }
1430
+
1378
1431
  // The one prompt that BOTH classifies and (for chat) writes the reply in the
1379
1432
  // requester's language — one Haiku round-trip, not two. The model returns a
1380
1433
  // single line of JSON: {"intent":"chat|task","reply":"…"}.
@@ -1479,6 +1532,8 @@ export function isNothingVerifiable({ changedFiles = [], hasPassCondition = fals
1479
1532
  // 人がアプリ側から閉じる経路。
1480
1533
  export function advanceReviewGoal(goal) {
1481
1534
  if (goal.status !== 'review') return { ok: false, error: 'goal is not in review' };
1535
+ // 07-20c: PR ありゴールは Approve だけで done にしない(merge=採用が唯一の完了トリガー)。
1536
+ if (goal && goal.pr) return { ok: true, needsMerge: true, status: 'review' };
1482
1537
  return { ok: true, status: 'done' };
1483
1538
  }
1484
1539
 
@@ -1492,20 +1547,39 @@ export function revertReviewGoal(goal) {
1492
1547
  // レビュー詳細パネル(task 70)のApprove/Dismissボタンの判定。advanceReviewGoal(task 62,
1493
1548
  // チェックリスト全件Approveの自動進行)とは別に、人間がレビュー画面から単発で下す判断
1494
1549
  // を扱う——PRの有無を問わず、レビュー中のgoalだけが対象。
1495
- // Approveはそのままdoneへ。goalGroupStatus/goalChipのdone分岐(review以外は全部doneと
1496
- // 読む)と揃えているので、doneに寄せて問題ない。
1497
- export function approveGoal({ goalStatus }) {
1550
+ // Approve は PR の有無で分岐する(07-20c)。PR 無しは従来どおり即 done。PR ありは
1551
+ // Approve だけで done にせず needsMerge を返す——呼び出し側が「PR をマージして完了」
1552
+ // ダイアログを出して PR へ飛ばし、マージ後に syncGoalMerges が review→done を自動確定
1553
+ // する。これで「アプリで Approve 済みだが PR は開いたまま」のゾンビを構造的に消す
1554
+ // (merge=採用は人の操作、の鉄則を守る)。
1555
+ export function approveGoal({ goalStatus, hasPr = false }) {
1498
1556
  if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
1557
+ if (hasPr) return { ok: true, needsMerge: true, status: 'review' };
1499
1558
  return { ok: true, status: 'done' };
1500
1559
  }
1501
1560
 
1502
- // Dismiss(差し戻し)は 'running' に戻す——goalGroupStatus/goalChipは 'running'
1503
- // startedAt済みなら'doing'バケットに読むので、これでレビュー中断→作業中の表示に戻る。
1561
+ // Dismiss(差し戻し)は 'running' に戻す——チャット(/reply /dismiss にテキスト)での
1562
+ // 「作り直し」依頼の受け皿。goalGroupStatus は startedAt 済みなら 'doing' に読む。
1504
1563
  export function dismissGoal({ goalStatus }) {
1505
1564
  if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
1506
1565
  return { ok: true, status: 'running' };
1507
1566
  }
1508
1567
 
1568
+ // Reject(07-20c・Dismiss とは別物)は自動再作業をせず、Rejected 欄に溜める 'rejected'
1569
+ // へ park する。作り直しはチャット(/reply)で依頼する。Rejected からは reopenGoal で
1570
+ // review へ戻せ、Delete(=revert)で PR ごと破棄できる。/dismiss(rework)とは別の
1571
+ // /reject エンドポイントが担うので、既存のチャット rework 契約を壊さない。
1572
+ export function rejectGoal({ goalStatus }) {
1573
+ if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
1574
+ return { ok: true, status: 'rejected' };
1575
+ }
1576
+
1577
+ // Rejected 欄の『Reopen』——rejected を review に戻す(rejectGoal の逆操作)。
1578
+ export function reopenGoal({ goalStatus }) {
1579
+ if (goalStatus !== 'rejected') return { ok: false, error: 'goal is not rejected' };
1580
+ return { ok: true, status: 'review' };
1581
+ }
1582
+
1509
1583
  // ---- MODE (Auto / Plan) ----------------------------------------------------
1510
1584
  // A goal runs in one of two modes. 'auto' = the default implement→verify→review
1511
1585
  // flow (worker edits files with acceptEdits). 'plan' = the worker runs under
@@ -2445,6 +2519,8 @@ export function goalLogLine(goal, prevGoal) {
2445
2519
  const label = String(goal?.plan?.[0] ?? goal?.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 60);
2446
2520
  if (!prevGoal) return goal?.status === 'pending' ? `Goal shelved to Pending: "${label}"` : null;
2447
2521
  if (prevGoal.status === 'review' && goal.status === 'done') return `Approved: "${label}"`;
2522
+ if (prevGoal.status === 'review' && goal.status === 'rejected') return `Rejected: "${label}"`;
2523
+ if (prevGoal.status === 'rejected' && goal.status === 'review') return `Reopened: "${label}"`;
2448
2524
  if (prevGoal.status === 'review' && goal.status === 'running') return `Dismissed: "${label}" — back to work`;
2449
2525
  if (prevGoal.status !== 'running' && goal.status === 'running' && Array.isArray(goal.plan) && goal.plan.length) {
2450
2526
  return `Goal decomposed into ${goal.plan.length} task(s) → To do: "${label}"`;
@@ -2548,8 +2624,10 @@ export function computeRetestOutcome(results = [], total = 3) {
2548
2624
  // terminal 'reverted' status (distinct from 'skipped'/archived). Decision only —
2549
2625
  // the destructive gh/git calls live behind the server's small wrapper.
2550
2626
  export function revertGoal({ goalStatus }) {
2551
- if (!['review', 'done', 'blocked', 'partial'].includes(goalStatus)) {
2552
- return { ok: false, error: 'only a finished (review/done/blocked/partial) goal can be reverted' };
2627
+ // 07-20c: 'rejected' も対象に含める——Rejected 欄の Delete は revert 経由で PR を閉じ
2628
+ // worktree を落とす(PR を残したまま消すと孤児 PR =別のゾンビになるため)。
2629
+ if (!['review', 'done', 'blocked', 'partial', 'rejected'].includes(goalStatus)) {
2630
+ return { ok: false, error: 'only a finished (review/done/blocked/partial/rejected) goal can be reverted' };
2553
2631
  }
2554
2632
  return { ok: true, status: 'reverted' };
2555
2633
  }
@@ -2887,8 +2965,11 @@ export function nextResumeDelay(attempt, { baseMs = 5 * 60 * 1000, maxMs = 60 *
2887
2965
  // tested — sharing a module across that boundary would couple two things
2888
2966
  // that fail and deploy on different schedules. Keep the two copies in sync by
2889
2967
  // hand; both have their own test coverage against the same free-tier numbers.
2968
+ // maxPendingGoals (the 5 concurrent "queued to-do" cap) was REMOVED 2026-07-20
2969
+ // (Masa): the free tier no longer caps how many to-dos run at once, and the UI
2970
+ // drops the "x / 5" counter. The lifetime (20) and project (1) caps remain the
2971
+ // monetization wall.
2890
2972
  export const FREE_TIER_LIMITS = Object.freeze({
2891
- maxPendingGoals: 5,
2892
2973
  maxCumulativeGoals: 20,
2893
2974
  maxProjects: 1,
2894
2975
  });
@@ -2905,12 +2986,10 @@ export const QUEUED_GOAL_STATUSES = Object.freeze([
2905
2986
  ]);
2906
2987
 
2907
2988
  export function checkFreeTierLimit(usage) {
2908
- const pendingCount = Number(usage?.pendingCount) || 0;
2909
2989
  const cumulativeCount = Number(usage?.cumulativeCount) || 0;
2910
2990
  const projectCount = Number(usage?.projectCount) || 0;
2911
- if (pendingCount > FREE_TIER_LIMITS.maxPendingGoals) {
2912
- return { blocked: true, reason: 'pending-goal-limit', limit: FREE_TIER_LIMITS.maxPendingGoals, value: pendingCount };
2913
- }
2991
+ // Concurrent "pending" to-dos are no longer capped (Masa 2026-07-20) — only
2992
+ // the lifetime total and the project count gate the free tier.
2914
2993
  if (cumulativeCount > FREE_TIER_LIMITS.maxCumulativeGoals) {
2915
2994
  return { blocked: true, reason: 'cumulative-goal-limit', limit: FREE_TIER_LIMITS.maxCumulativeGoals, value: cumulativeCount };
2916
2995
  }
@@ -3493,3 +3572,15 @@ export function parseClaimResponse(obj) {
3493
3572
  if (obj.token) return { state: 'ready', token: String(obj.token), email: obj.email != null ? String(obj.email) : undefined };
3494
3573
  return { state: 'pending' };
3495
3574
  }
3575
+
3576
+ // --- connection status line (queue tray) -------------------------------------
3577
+ // Pure source of truth for the offline/reconnecting line that renders as the top
3578
+ // row of the queued tray above the composer. app/index.html is a browser static
3579
+ // file that can't import this module, so it MIRRORS these two tiny functions
3580
+ // inline (see renderQueue()); keep the logic identical on both sides.
3581
+ export function connectionBannerState({ online }) {
3582
+ return online ? { kind: 'ok' } : { kind: 'offline', reconnect: true };
3583
+ }
3584
+ export function unsentMetaLabel({ online }) {
3585
+ return online ? 'Unsent — tap to resend' : 'Waiting for connection';
3586
+ }
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, 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, 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) ----------------
@@ -2508,21 +2604,19 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
2508
2604
  const lang = reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : (reviewDefinition.language || 'ja');
2509
2605
  const changed = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].slice(0, 20);
2510
2606
  const reports = siblings.filter((t) => !t.reply).map((t) => `- ${t.title}: ${(t.result ?? '').replace(/\s+/g, ' ').slice(0, 300)}`).join('\n');
2511
- const fallback = { check: '', changed: (goal.plan?.[0] ?? goal.text ?? '').replace(/\s+/g, ' ').slice(0, 80) };
2607
+ // fallback = current behavior: title/changed derived from the goal's own text,
2608
+ // so a goal with no summary (AI failed or nothing to summarize) still shows a
2609
+ // meaningful title/changed and never a blank or mis-phrased "…の修正".
2610
+ const fallback = {
2611
+ title: (goal.text ?? '').replace(/\s+/g, ' ').slice(0, 60),
2612
+ check: '',
2613
+ changed: (goal.plan?.[0] ?? goal.text ?? '').replace(/\s+/g, ' ').slice(0, 80),
2614
+ };
2512
2615
  if (!changed.length && !reports) return fallback;
2513
- const prompt = [
2514
- `あるゴールの実装が完了しました。レビューする人が一目で判断できるよう、やさしい${lang === 'ja' ? '日本語' : lang}で2行だけ書いてください。`,
2515
- '専門用語・長い説明は禁止。小学生でも分かる短い言い方。JSONのみ出力(前後に文章を書かない):',
2516
- '{"check":"何を確認すれば良いか(1行)","changed":"何を変えたか(1行)"}',
2517
- '',
2518
- `ゴール: ${(goal.text ?? '').slice(0, 300)}`,
2519
- `変更ファイル: ${changed.join(', ') || '(なし)'}`,
2520
- `ワーカー報告:\n${reports.slice(0, 1500)}`,
2521
- ].join('\n');
2616
+ const prompt = buildReviewSummaryPrompt({ lang, goalText: goal.text, changed, reports });
2522
2617
  try {
2523
2618
  const r = await runUtility({ prompt, cwd: dir, tools: 'Read' });
2524
- const m = r.result.match(/\{[\s\S]*\}/);
2525
- if (m) { const o = JSON.parse(m[0]); return { check: String(o.check ?? '').replace(/\s+/g, ' ').slice(0, 120), changed: String(o.changed ?? '').replace(/\s+/g, ' ').slice(0, 120) || fallback.changed }; }
2619
+ return parseReviewSummary(r.result, fallback);
2526
2620
  } catch { /* fall through */ }
2527
2621
  return fallback;
2528
2622
  }
@@ -3081,8 +3175,8 @@ const server = createServer(async (req, res) => {
3081
3175
  email: licenseState.email,
3082
3176
  isPaying: Boolean(licenseState.isPaying),
3083
3177
  plan: licenseState.isPaying ? 'paid' : 'free',
3084
- usage: { pending: freeUsage.pendingCount, cumulative: freeUsage.cumulativeCount },
3085
- limits: { pending: FREE_TIER_LIMITS.maxPendingGoals, cumulative: FREE_TIER_LIMITS.maxCumulativeGoals },
3178
+ usage: { cumulative: freeUsage.cumulativeCount },
3179
+ limits: { cumulative: FREE_TIER_LIMITS.maxCumulativeGoals },
3086
3180
  price: await getBillingPrice(),
3087
3181
  siteUrl: BILLING_API_URL || null,
3088
3182
  };
@@ -3272,6 +3366,18 @@ const server = createServer(async (req, res) => {
3272
3366
  return;
3273
3367
  }
3274
3368
 
3369
+ // Per-project play/pause (Masa: Doing列の一時停止/再生・チャットからも同じ). POST
3370
+ // .../pause stops in-flight Doing work + holds the queue; .../resume continues
3371
+ // from where it left off. Broadcasts the projects change so every client's
3372
+ // toggle updates. Idempotent: pausing an already-paused project is a 200 no-op.
3373
+ const pauseMatch = url.pathname.match(/^\/api\/projects\/([\w-]+)\/(pause|resume)$/);
3374
+ if (pauseMatch && req.method === 'POST') {
3375
+ const project = projects.find((p) => p.id === pauseMatch[1]);
3376
+ if (!project) return json(res, 404, { error: 'project not found' });
3377
+ if (pauseMatch[2] === 'pause') pauseProject(project.id); else resumeProject(project.id);
3378
+ return json(res, 200, { project, projects, paused: !!project.paused });
3379
+ }
3380
+
3275
3381
  // proof artifacts: /proof/<taskId>/<file> (gif/png/mp4 from that task's proof dir)
3276
3382
  const proofMatch = url.pathname.match(/^\/proof\/(\d+)\/([\w.-]+)$/);
3277
3383
  if (proofMatch) {
@@ -3301,6 +3407,21 @@ const server = createServer(async (req, res) => {
3301
3407
  const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent } = JSON.parse(body);
3302
3408
  const project = projects.find((p) => p.id === projectId);
3303
3409
  if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
3410
+ // Chat-driven play/pause (Masa: "チャットで再生一時停止はおせる"). Deterministic
3411
+ // (no LLM), checked BEFORE the intent router below: a short command-like
3412
+ // "止めて"/"再開" toggles THIS project's pause state and returns a chat reply
3413
+ // — it never becomes a To-Do. /later and /review skip this like the router.
3414
+ if (!pending && !review) {
3415
+ const pauseIntent = detectPauseIntent(text.trim());
3416
+ if (pauseIntent === 'pause') {
3417
+ pauseProject(projectId);
3418
+ return json(res, 200, { kind: 'chat', reply: `${project.name} を一時停止しました。動いていた作業を止め、新しいToDoも始めません。再生(▶)で続きから再開します。` });
3419
+ }
3420
+ if (pauseIntent === 'resume') {
3421
+ resumeProject(projectId);
3422
+ return json(res, 200, { kind: 'chat', reply: `${project.name} を再開しました。止めた作業を続きから進めます。` });
3423
+ }
3424
+ }
3304
3425
  // Onboarding (docs/HANDOFF-ONBOARDING-conversational.md, SLICE 2): the
3305
3426
  // composer is conversation-first. A chat/help/settings message ("what can
3306
3427
  // you do?", "reply in English", a greeting) gets a conversational reply
@@ -3663,6 +3784,9 @@ const server = createServer(async (req, res) => {
3663
3784
  if (!requireGoalOwnership(req, res, goal)) return;
3664
3785
  const result = advanceReviewGoal(goal);
3665
3786
  if (!result.ok) return json(res, 409, { error: result.error });
3787
+ // 07-20c: PR ありは done にせず「PR をマージして完了」を促す。status は review の
3788
+ // まま。マージ後に syncGoalMerges が done を確定する。
3789
+ if (result.needsMerge) return json(res, 200, { goal, needsMerge: true, pr: goal.pr });
3666
3790
  goal.status = result.status;
3667
3791
  saveGoal(goal);
3668
3792
  return json(res, 200, goal);
@@ -3924,8 +4048,12 @@ const server = createServer(async (req, res) => {
3924
4048
  pump(goal.projectId);
3925
4049
  return json(res, 200, goal);
3926
4050
  }
3927
- const result = approveGoal({ goalStatus: goal.status });
4051
+ const result = approveGoal({ goalStatus: goal.status, hasPr: !!goal.pr });
3928
4052
  if (!result.ok) return json(res, 409, { error: result.error });
4053
+ // 07-20c: PR ありは Approve だけで done にしない。status は review のまま返し、
4054
+ // クライアントは「PR をマージして完了してください」ダイアログ+ Open PR を出す。
4055
+ // マージされたら syncGoalMerges が review→done を自動確定する。
4056
+ if (result.needsMerge) return json(res, 200, { goal, needsMerge: true, pr: goal.pr });
3929
4057
  goal.status = result.status;
3930
4058
  saveGoal(goal);
3931
4059
  return json(res, 200, goal);
@@ -4105,6 +4233,46 @@ const server = createServer(async (req, res) => {
4105
4233
  return;
4106
4234
  }
4107
4235
 
4236
+ // 07-20c: Reject — park a review goal in the Rejected column. No auto-rework
4237
+ // (rework is asked for via chat /reply or /dismiss). Kept as a SEPARATE
4238
+ // endpoint from /dismiss so the existing chat-rework contract is untouched.
4239
+ // An optional text is kept as a rejection note; no worker is spawned.
4240
+ const rejectMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/reject$/);
4241
+ if (rejectMatch && req.method === 'POST') {
4242
+ const goal = goals.find((g) => g.id === Number(rejectMatch[1]));
4243
+ if (!goal) return json(res, 404, { error: 'goal not found' });
4244
+ if (!requireGoalOwnership(req, res, goal)) return;
4245
+ const result = rejectGoal({ goalStatus: goal.status });
4246
+ if (!result.ok) return json(res, 409, { error: result.error });
4247
+ let body = '';
4248
+ req.on('data', (d) => { body += d; });
4249
+ req.on('end', () => {
4250
+ let text = '';
4251
+ try { text = String(JSON.parse(body || '{}').text ?? '').trim(); } catch {}
4252
+ goal.status = result.status;
4253
+ if (text) goal.rejectNote = text;
4254
+ goal.rejectedAt = new Date().toISOString();
4255
+ saveGoal(goal);
4256
+ json(res, 200, { goal });
4257
+ });
4258
+ return;
4259
+ }
4260
+
4261
+ // 07-20c: Rejected 欄の『Reopen』——rejected を review へ戻す(Reject の取り消し)。
4262
+ // Delete(破棄)は既存の /revert(PR を閉じ worktree を落とす)を使う。
4263
+ const reopenMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/reopen$/);
4264
+ if (reopenMatch && req.method === 'POST') {
4265
+ const goal = goals.find((g) => g.id === Number(reopenMatch[1]));
4266
+ if (!goal) return json(res, 404, { error: 'goal not found' });
4267
+ if (!requireGoalOwnership(req, res, goal)) return;
4268
+ const result = reopenGoal({ goalStatus: goal.status });
4269
+ if (!result.ok) return json(res, 409, { error: result.error });
4270
+ goal.status = result.status;
4271
+ goal.rejectNote = null; goal.rejectedAt = null;
4272
+ saveGoal(goal);
4273
+ return json(res, 200, goal);
4274
+ }
4275
+
4108
4276
  // image attachments: upload (base64 JSON) + serve back for thumbnails
4109
4277
  if (url.pathname === '/api/upload' && req.method === 'POST') {
4110
4278
  let body = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.35",
3
+ "version": "0.10.37",
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": {