@galda/cli 0.10.86 → 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
@@ -9859,6 +9869,11 @@ function saItemHtml(it, i){
9859
9869
  // verification visible together while prior attempts stay in Conversation.
9860
9870
  const attempt = it.attempt;
9861
9871
  const activeText = String(attempt?.activeRequirement?.text || '').replace(/\s+/g, ' ').trim();
9872
+ const requestText = String(it.goalText || '').replace(/\s+/g, ' ').trim();
9873
+ // A single requirement is commonly recorded verbatim from the Request. Do
9874
+ // not make the review read it back as a second section; keep this section for
9875
+ // a narrowed retry requirement or concrete run evidence instead.
9876
+ const reviewText = activeText && activeText !== requestText ? activeText : '';
9862
9877
  const attemptFiles = Array.isArray(attempt?.changedFiles) ? attempt.changedFiles.filter(Boolean) : [];
9863
9878
  const attemptTests = attempt?.verification?.tests;
9864
9879
  const attemptProof = attempt?.verification?.proof;
@@ -9867,8 +9882,8 @@ function saItemHtml(it, i){
9867
9882
  attemptTests?.ran ? `Tests: ${attemptTests.passed ?? 0} passed${attemptTests.failed ? ` / ${attemptTests.failed} failed` : ''}` : '',
9868
9883
  attemptProof?.pass === true ? 'Proof recorded' : '',
9869
9884
  ].filter(Boolean).join(' · ');
9870
- const attemptSec = activeText || attemptEvidence
9871
- ? `<section class="rsec"><div class="rsec-h">This review</div>${activeText ? `<p class="rbody rask">${esc(activeText)}</p>` : ''}${attemptEvidence ? `<p class="rbody v">${esc(attemptEvidence)}</p>` : ''}</section>`
9885
+ const attemptSec = reviewText || attemptEvidence
9886
+ ? `<section class="rsec"><div class="rsec-h">This review</div>${reviewText ? `<p class="rbody rask">${esc(reviewText)}</p>` : ''}${attemptEvidence ? `<p class="rbody v">${esc(attemptEvidence)}</p>` : ''}</section>`
9872
9887
  : '';
9873
9888
  const reqVerify = it.requirementVerification;
9874
9889
  const reqVerifyParts = [
@@ -9877,10 +9892,15 @@ function saItemHtml(it, i){
9877
9892
  Array.isArray(reqVerify?.changedFiles) && reqVerify.changedFiles.length ? `Files: ${reqVerify.changedFiles.join(' · ')}` : '',
9878
9893
  ].filter(Boolean);
9879
9894
  const reqVerifySec = reqVerifyParts.length ? `<section class="rsec"><div class="rsec-h">Requirement verification</div><p class="rbody v">${esc(reqVerifyParts.join(' · '))}</p></section>` : '';
9880
- const coverageRows = (it.requirementCoverage || []).filter((r) => r.text).map((r) => {
9895
+ const coverage = (it.requirementCoverage || []).filter((r) => r.text);
9896
+ // For one requirement copied from Request, its status is useful but the full
9897
+ // sentence is not. Multiple or narrowed requirements retain their text.
9898
+ const coverageIsSingleRequest = coverage.length === 1
9899
+ && String(coverage[0].text).replace(/\s+/g, ' ').trim() === requestText;
9900
+ const coverageRows = coverage.map((r) => {
9881
9901
  const status = r.evidenceStatus || r.taskStatus;
9882
9902
  const tone = status === 'addressed' || status === 'done' ? 'ok' : status === 'partial' ? '' : 'dim';
9883
- return `<p class="rbody v"><b class="${tone}">${esc(status)}</b> · ${esc(r.text)}</p>`;
9903
+ return `<p class="rbody v"><b class="${tone}">${esc(status)}</b>${coverageIsSingleRequest ? '' : ` · ${esc(r.text)}`}</p>`;
9884
9904
  }).join('');
9885
9905
  const coverageSec = coverageRows ? `<section class="rsec"><div class="rsec-h">Requirement coverage</div>${coverageRows}</section>` : '';
9886
9906
  // Worker-declared evidence is displayed verbatim as an auditable label, not
@@ -9890,8 +9910,6 @@ function saItemHtml(it, i){
9890
9910
  return `<p class="rbody v"><b class="${tone}">${esc(entry.status)}</b> · ${esc(entry.evidence || entry.id)}</p>`;
9891
9911
  }).join('');
9892
9912
  const evidenceSec = evidenceRows ? `<section class="rsec"><div class="rsec-h">Requirement evidence</div>${evidenceRows}</section>` : '';
9893
- const userCoverageRows = (it.requirementCoverage || []).filter((r) => r.text).map((r) => `<p class="rbody v"><b class="${r.status === 'done' ? 'ok' : 'dim'}">${esc(r.status)}</b> · ${esc(r.text)}</p>`).join('');
9894
- const userCoverageSec = userCoverageRows ? `<section class="rsec"><div class="rsec-h">User requirements</div>${userCoverageRows}</section>` : '';
9895
9913
  // ── Open it (Masa 2026-07-22): a plain section in the card's own typography — the same
9896
9914
  // heading + body shape as Request and Result, not a chip in another vocabulary. The
9897
9915
  // address the worker declared IS the control (press it → the process starts on demand →
@@ -10122,14 +10140,12 @@ function saItemHtml(it, i){
10122
10140
  ${coverageSec}
10123
10141
  ${evidenceSec}
10124
10142
  ${reqVerifySec}
10125
- ${userCoverageSec}
10126
- ${S('Open it', openBody)}
10143
+ ${S('Open it', openBody + infraBody)}
10127
10144
  ${S('Run state', interruptedBody)}
10128
10145
  ${S('PR', prRepairBody)}
10129
10146
  ${S('Also carries', carriesBody)}
10130
10147
  ${S('What to check', checkBody)}
10131
10148
  ${S('Result', reportBody)}
10132
- ${S(infra?.title || '検証基盤エラー', infraBody)}
10133
10149
  ${S('Screenshots', shotsBody)}
10134
10150
  ${S('Steps', stepsBody)}
10135
10151
  ${S('Proof', proofInner)}
@@ -10622,11 +10638,12 @@ $('seeall').addEventListener('click', async (e) => {
10622
10638
  // meta-row toggle (H23): tap the token chip to reveal the technique breakdown.
10623
10639
  if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
10624
10640
  if (k === 'reverify') {
10641
+ const copy = retryCopy(it.goal?.projectId || state.active);
10625
10642
  b.disabled = true;
10626
- b.textContent = '再検証中…';
10643
+ b.textContent = copy.retrying;
10627
10644
  let r; try { r = await fetch(withKey(`/api/goals/${it.goalId}/reverify`), { method: 'POST' }); }
10628
- catch { showErr('再検証に失敗'); b.disabled = false; return; }
10629
- 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; }
10630
10647
  return;
10631
10648
  }
10632
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);
@@ -5520,6 +5549,12 @@ async function answerGoalQuestion(goal, question) {
5520
5549
  addGoalMessage(goal, { from: 'you', via: 'review', text });
5521
5550
  goal.text = text.slice(0, 8000);
5522
5551
  }
5552
+ // A PR request added during Review changes the deliverable, not the
5553
+ // worker's permissions. Workers deliberately cannot git add/commit; the
5554
+ // Manager close-out below owns commit/push/PR creation. Keep this
5555
+ // monotonic: an explicit later PR request upgrades a No-PR/auto goal,
5556
+ // while ordinary feedback never silently downgrades an existing PR goal.
5557
+ if (result.spawnsWorker && resolveWantsPR('auto', text, false)) goal.wantsPR = true;
5523
5558
  goal.status = result.status;
5524
5559
  saveGoal(goal);
5525
5560
  // rescope はワーカーを起こさない——「いったん止めて考え直す」なので、走り出したら
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.86",
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": {