@galda/cli 0.10.87 → 0.10.88

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
@@ -3633,10 +3633,7 @@ function reviewCardConfigFor(projectId){ return { ...DEFAULT_REVIEW_DEFINITION.r
3633
3633
  // project to one language regardless of what a given goal was typed in.
3634
3634
  function reviewLangFor(projectId){ const l = reviewDefinitionFor(projectId).language; return (l === 'en' || l === 'ja') ? l : 'auto'; }
3635
3635
  function retryCopy(projectId){
3636
- const ja = reviewLangFor(projectId) === 'ja';
3637
- return ja
3638
- ? { infra: '検証基盤エラー', retry: '再検証', retrying: '再検証中…', failed: '再検証に失敗' }
3639
- : { infra: 'Verification infrastructure error', retry: 'Retry verification', retrying: 'Retrying…', failed: 'Verification retry failed' };
3636
+ return { infra: 'Verification infrastructure error', retry: 'Retry verification', retrying: 'Retrying…', failed: 'Verification retry failed' };
3640
3637
  }
3641
3638
 
3642
3639
  function esc(s){ const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
@@ -4891,7 +4888,8 @@ function renderTasks(){
4891
4888
  for (const b of $('tasklist').querySelectorAll('[data-goalreverify]')) {
4892
4889
  b.onclick = async (e) => { e.stopPropagation(); const copy = retryCopy(state.active); b.disabled = true; b.textContent = copy.retrying;
4893
4890
  let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' }); }
4894
- catch { showErr(copy.failed); return; }
4891
+ catch { showErr(copy.failed); b.disabled = false; b.textContent = copy.retry; return; }
4892
+ if (!r.ok) { showErr(copy.failed); b.disabled = false; b.textContent = copy.retry; return; }
4895
4893
  qrefresh(r, copy.failed); };
4896
4894
  }
4897
4895
  for (const b of $('tasklist').querySelectorAll('[data-qreply]')) {
@@ -5557,8 +5555,8 @@ function renderReviewChecklist(){
5557
5555
  b.disabled = true;
5558
5556
  b.textContent = retryCopy(goal.projectId).retrying;
5559
5557
  let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' }); }
5560
- catch { showErr(retryCopy(goal.projectId).failed); b.disabled = false; return; }
5561
- if (r.ok) await refresh(); else showErr(retryCopy(goal.projectId).failed);
5558
+ catch { showErr(retryCopy(goal.projectId).failed); b.disabled = false; b.textContent = retryCopy(goal.projectId).retry; return; }
5559
+ if (r.ok) await refresh(); else { showErr(retryCopy(goal.projectId).failed); b.disabled = false; b.textContent = retryCopy(goal.projectId).retry; }
5562
5560
  };
5563
5561
  }
5564
5562
  for (const b of $('reviewList').querySelectorAll('.reqsend')) {
@@ -8530,7 +8528,7 @@ let sseUp = false; // live engine link — the honest signal behind the MCP tab'
8530
8528
  // Connection status line (queue tray): grace-debounce the offline state ~800ms so
8531
8529
  // sub-second SSE blips don't flash a red line. connOffline is the debounced flag
8532
8530
  // renderQueue() reads; connSetOnline/Offline flip it around real sseUp changes.
8533
- let connOffline = false, _connGraceT = null, _sseReconnectT = null, _es = null, _healthT = null;
8531
+ let connOffline = false, _connGraceT = null, _sseReconnectT = null, _es = null, _healthT = null, _healthFailStreak = 0;
8534
8532
  function connSetOnline(){ if (_connGraceT) { clearTimeout(_connGraceT); _connGraceT = null; } if (connOffline) { connOffline = false; renderQueue(); } }
8535
8533
  function connSetOffline(){ if (_connGraceT || connOffline) return; _connGraceT = setTimeout(() => { _connGraceT = null; connOffline = true; renderQueue(); }, 800); }
8536
8534
  // Reconnect button: drop any pending backoff timer and reopen the EventSource now.
@@ -8545,15 +8543,23 @@ async function checkHealth(){
8545
8543
  if (document.hidden) return;
8546
8544
  let ok = false;
8547
8545
  try {
8548
- const c = new AbortController(); const to = setTimeout(() => c.abort(), 3000);
8546
+ // Mobile browsers and DevTools device emulation can briefly starve this low-priority
8547
+ // probe even while the already-open SSE and POST path are healthy. Do not turn one
8548
+ // transient probe into a user-visible outage.
8549
+ const c = new AbortController(); const to = setTimeout(() => c.abort(), 5000);
8549
8550
  const r = await fetch(withKey('/'), { method: 'HEAD', cache: 'no-store', signal: c.signal });
8550
8551
  clearTimeout(to); ok = r.ok;
8551
8552
  } catch { ok = false; }
8552
8553
  if (!ok) {
8554
+ _healthFailStreak++;
8555
+ if (_healthFailStreak < 2) return;
8553
8556
  connSetOffline(); // grace-debounced ~800ms
8554
8557
  if (!_sseReconnectT) { try { _es && _es.close(); } catch {} sseUp = false; esRetryMs = Math.min(esRetryMs, 4000); _sseReconnectT = setTimeout(connectSSE, esRetryMs); }
8555
- } else if (connOffline && !_sseReconnectT) {
8558
+ } else {
8559
+ _healthFailStreak = 0;
8560
+ if (connOffline && !_sseReconnectT) {
8556
8561
  forceReconnect(); // server reachable again but the line is still up → reconnect to clear + flush
8562
+ }
8557
8563
  }
8558
8564
  }
8559
8565
  function startHealthPoll(){ clearInterval(_healthT); _healthT = setInterval(checkHealth, 4000); }
@@ -8773,6 +8779,10 @@ async function boot(){
8773
8779
  flushOutbox();
8774
8780
  connectSSE();
8775
8781
  startHealthPoll();
8782
+ // A Wi-Fi ⇄ cellular switch can leave EventSource waiting for its normal retry
8783
+ // budget. The browser has already confirmed connectivity here, so reconnect now
8784
+ // instead of making a mobile send wait for the next health poll/backoff cycle.
8785
+ window.addEventListener('online', () => { _healthFailStreak = 0; forceReconnect(); });
8776
8786
  document.addEventListener('visibilitychange', () => { if (!document.hidden) { refresh(); flushOutbox(); checkHealth(); } });
8777
8787
  // belt and suspenders for the never-lose guarantee: even if every event
8778
8788
  // path misfires, unsent memos are retried on a slow heartbeat
@@ -10130,13 +10140,12 @@ function saItemHtml(it, i){
10130
10140
  ${coverageSec}
10131
10141
  ${evidenceSec}
10132
10142
  ${reqVerifySec}
10133
- ${S('Open it', openBody)}
10143
+ ${S('Open it', openBody + infraBody)}
10134
10144
  ${S('Run state', interruptedBody)}
10135
10145
  ${S('PR', prRepairBody)}
10136
10146
  ${S('Also carries', carriesBody)}
10137
10147
  ${S('What to check', checkBody)}
10138
10148
  ${S('Result', reportBody)}
10139
- ${S(infra?.title || '検証基盤エラー', infraBody)}
10140
10149
  ${S('Screenshots', shotsBody)}
10141
10150
  ${S('Steps', stepsBody)}
10142
10151
  ${S('Proof', proofInner)}
@@ -10629,11 +10638,12 @@ $('seeall').addEventListener('click', async (e) => {
10629
10638
  // meta-row toggle (H23): tap the token chip to reveal the technique breakdown.
10630
10639
  if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
10631
10640
  if (k === 'reverify') {
10641
+ const copy = retryCopy(it.goal?.projectId || state.active);
10632
10642
  b.disabled = true;
10633
- b.textContent = '再検証中…';
10643
+ b.textContent = copy.retrying;
10634
10644
  let r; try { r = await fetch(withKey(`/api/goals/${it.goalId}/reverify`), { method: 'POST' }); }
10635
- catch { showErr('再検証に失敗'); b.disabled = false; return; }
10636
- if (r.ok) await refresh(); else showErr('再検証に失敗');
10645
+ catch { showErr(copy.failed); b.disabled = false; b.textContent = copy.retry; return; }
10646
+ if (r.ok) await refresh(); else { showErr(copy.failed); b.disabled = false; b.textContent = copy.retry; }
10637
10647
  return;
10638
10648
  }
10639
10649
  // Open it (P1, Masa 2026-07-21): start the thing the worker said to run, then open it.
@@ -0,0 +1,19 @@
1
+ const CHANGE_REQUEST_RE = /(?:修正|直して|解決して|改善して|一致させ|統一して|実装して|追加して|削除して|変更して|作って|fix\b|solve\b|resolve\b|implement\b|change\b|make\b)/i;
2
+ const REFUSAL_RESULT_RE = /(?:直せません|修正できません|解決できません|実現できません|不可能です|cannot\s+(?:be\s+)?(?:fix(?:ed)?|solv(?:e|ed)|implement(?:ed)?|done)|can't\s+(?:fix|solve|implement|do)|unable\s+to\s+(?:fix|solve|implement))/i;
3
+
4
+ export const COMPLETION_CONTRACT_PROMPT = [
5
+ 'MANAGER COMPLETION CONTRACT:',
6
+ '- The latest explicit user instruction can revise an older product decision or code comment. Treat old comments as context, not proof that the requested change is impossible. If the change has consequences, explain them and implement the latest request; only a real safety, permission, legal, or external-system constraint is a blocker.',
7
+ '- A test must assert the observable outcome the user requested. Never replace the request with a tooltip, explanation, or a test that permanently preserves the opposite behavior.',
8
+ '- Do not report a change request as complete with “cannot fix”, “直せません”, or equivalent. If a real external blocker remains, state the concrete blocker and leave the work explicitly unfinished instead of presenting it for Review as completed.',
9
+ ].join('\n');
10
+
11
+ export function completionContractIssue({ request = '', followUp = '', result = '', status = '' } = {}) {
12
+ if (status !== 'done') return null;
13
+ const requested = `${request}\n${followUp}`;
14
+ if (!CHANGE_REQUEST_RE.test(requested) || !REFUSAL_RESULT_RE.test(String(result))) return null;
15
+ return {
16
+ kind: 'unresolved-refusal',
17
+ reason: '変更・解決依頼に対して「対応不能」で終了しており、要求した結果が実現されていません。',
18
+ };
19
+ }
package/engine/server.mjs CHANGED
@@ -26,6 +26,7 @@ import { createSerialQueue } from './lib.mjs';
26
26
  import { parseRequirementEvidence, unmappedChangedFiles } from './lib.mjs';
27
27
  import { isPrClosed } from './lib.mjs';
28
28
  import { collectProjectRules } from './lib.mjs';
29
+ import { COMPLETION_CONTRACT_PROMPT, completionContractIssue } from './completion-contract.mjs';
29
30
  import { FALLBACK_CODEX_MODELS, loadCodexModels } from './codex-models.mjs';
30
31
  import { CLAUDE_MODELS } from './claude-models.mjs';
31
32
  import { classifyWorkerPreflight, shouldProbeClaudeAuth, workerCanStart } from './worker-availability.mjs';
@@ -2378,8 +2379,13 @@ async function startEphemeralApp(projectDir) {
2378
2379
  const home = join(logDir, `ephemeral-${Date.now()}`);
2379
2380
  mkdirSync(home, { recursive: true });
2380
2381
  seedEphemeralHistory(home, projectDir);
2382
+ // Managed worktrees intentionally do not copy node_modules. An ephemeral
2383
+ // preview is still this Manager package, so let it resolve the already
2384
+ // installed runtime dependencies from the parent rather than crashing before
2385
+ // its readiness endpoint can answer (for example `puppeteer-core`).
2386
+ const nodePath = testNodePath(projectDir);
2381
2387
  const child = spawn('node', [join(projectDir, 'engine', 'server.mjs')], {
2382
- env: { ...process.env, MANAGER_PORT: String(port), MANAGER_HOME: home, MANAGER_OPEN_BROWSER: '0', MANAGER_SEED_RUNNING_FIXTURE: '1' },
2388
+ env: { ...process.env, NODE_PATH: nodePath, MANAGER_PORT: String(port), MANAGER_HOME: home, MANAGER_OPEN_BROWSER: '0', MANAGER_SEED_RUNNING_FIXTURE: '1' },
2383
2389
  cwd: projectDir, stdio: 'ignore',
2384
2390
  });
2385
2391
  ephemeralChildren.add(child);
@@ -2721,6 +2727,7 @@ async function runTask(task) {
2721
2727
  const prompt = [
2722
2728
  managerHandoffPrompt(goal, plan),
2723
2729
  workerPrompt(task, goal, null, runAgent, runContext),
2730
+ COMPLETION_CONTRACT_PROMPT,
2724
2731
  '(User follow-up on the goal you worked on. Apply it to the same goal and deliver the requested result.)',
2725
2732
  ].filter(Boolean).join('\n\n');
2726
2733
  const callT0 = Date.now();
@@ -2765,6 +2772,17 @@ async function runTask(task) {
2765
2772
  task.unmappedChangedFiles = unmappedChangedFiles(task.changedFiles, task.requirementEvidence);
2766
2773
  task.requirementVerification = { proof: task.proof ?? null, run: task.run ?? null, changedFiles: task.changedFiles };
2767
2774
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this follow-up.`);
2775
+ const completionIssue = completionContractIssue({
2776
+ request: goal?.text,
2777
+ followUp: task.detail,
2778
+ result: task.result,
2779
+ status: task.status,
2780
+ });
2781
+ if (completionIssue) {
2782
+ task.status = 'interrupted';
2783
+ task.result = `[未解決: ${completionIssue.reason}]\n\n${task.result}`.slice(0, RESULT_MAX);
2784
+ sendProcessAct(task, 'Completion contract rejected an unresolved refusal — kept out of Review for retry.');
2785
+ }
2768
2786
  task.finishedAt = new Date().toISOString();
2769
2787
  task.resultAttemptId = task.attemptId;
2770
2788
  saveTask(task);
@@ -2799,7 +2817,7 @@ async function runTask(task) {
2799
2817
  const runEffort = task.effort ?? goal?.effort;
2800
2818
  const resumeSession = shouldFreshSessionForPlan(plan) ? undefined : (goalSessionFor(goal, runAgent) ?? undefined);
2801
2819
  const runContext = workerRunContext({ goal, task, agent: runAgent, model: runModel, effort: runEffort, permissionMode, cwd: workDir, resumeSession, plan });
2802
- const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, null, runAgent, runContext)].filter(Boolean).join('\n\n');
2820
+ const prompt = [managerHandoffPrompt(goal, plan), workerPrompt(task, goal, null, runAgent, runContext), COMPLETION_CONTRACT_PROMPT].filter(Boolean).join('\n\n');
2803
2821
  sendWorkerContextAct(task, runContext);
2804
2822
  sendProcessAct(task, `Starting ${runAgent === 'codex' ? 'Codex' : 'Claude Code'} worker (attempt ${attempt}/${maxAttempts}).`);
2805
2823
  let r = await runWorkerAgent({
@@ -2833,7 +2851,7 @@ async function runTask(task) {
2833
2851
  sendWorkerContextAct(task, freshContext);
2834
2852
  r = await runWorkerAgent({
2835
2853
  agent: runAgent,
2836
- prompt: workerPrompt(task, goal, null, runAgent, freshContext), cwd: workDir, tools: WORKER_TOOLS,
2854
+ prompt: [workerPrompt(task, goal, null, runAgent, freshContext), COMPLETION_CONTRACT_PROMPT].join('\n\n'), cwd: workDir, tools: WORKER_TOOLS,
2837
2855
  permissionMode,
2838
2856
  model: runModel, effort: runEffort, onEvent: (line) => sendAct(task, line),
2839
2857
  images: goal?.images,
@@ -2984,6 +3002,17 @@ async function runTask(task) {
2984
3002
  task.result = `[未完了の疑い: 変更ファイル0件かつ応答が途中で切れています] ${task.result}`.slice(0, RESULT_MAX);
2985
3003
  sendProcessAct(task, 'Result looked incomplete (0 changed files, cut-off text) — marked interrupted instead of done for human review.');
2986
3004
  }
3005
+ const completionIssue = completionContractIssue({
3006
+ request: goal?.text,
3007
+ followUp: task.reply ? task.detail : '',
3008
+ result: task.result,
3009
+ status: task.status,
3010
+ });
3011
+ if (completionIssue) {
3012
+ task.status = 'interrupted';
3013
+ task.result = `[未解決: ${completionIssue.reason}]\n\n${task.result}`.slice(0, RESULT_MAX);
3014
+ sendProcessAct(task, 'Completion contract rejected an unresolved refusal — kept out of Review for retry.');
3015
+ }
2987
3016
  task.finishedAt = new Date().toISOString();
2988
3017
  task.resultAttemptId = task.attemptId;
2989
3018
  saveTask(task);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.87",
3
+ "version": "0.10.88",
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": {