@galda/cli 0.10.87 → 0.10.89

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/CLAUDE.md CHANGED
@@ -105,6 +105,8 @@
105
105
  - Worker/API へは**必ず所有ドメイン `https://galda.app`(custom domain)経由**で参照する。`*.workers.dev` サブドメインを製品コードに焼き込まない(`MANAGER_BILLING_API_URL` 等の既定値は `https://galda.app`)。
106
106
  - **Cloudflare/wrangler にデプロイする前に `wrangler whoami` でデプロイ先アカウントを確認**し、Galda/Kodo 所有であること・workers.dev サブドメイン名に客先名や別プロジェクト名が出ていないことを確かめる。怪しければ止めて Masa に確認(本番デプロイは Masa 明示許可制)。
107
107
  - **リリースは4面**(① npm ② Fly ③ Worker ④ Masaの常駐インスタンス `~/manager-for-ai`:4400)。①だけ出して④を忘れると「直したのに直ってない」+relay相互kickループ再発の実例あり(2026-07-16/17)。全文=`docs/RELEASE-WORKFLOW.md`の「本番は1つじゃない」表。
108
+ - **常駐Managerを直接 `kill`/`launchctl kickstart -k` しない**(2026-07-28、実行中3 workerを同時中断した実例)。再起動は対象checkoutで
109
+ `node tools/safe-restart-manager.mjs` を使う。このコマンドはDoing/Finalizingが1件でもあれば拒否する。拒否時は完了を待って再実行し、Agent切替で回避しない。
108
110
 
109
111
  ## relay接続(`app.galda.app`)を伴う作業の掟(Masa明言 2026-07-17・全レーン必読)
110
112
 
package/app/index.html CHANGED
@@ -1558,6 +1558,14 @@
1558
1558
  line-height:1.15;padding:2.5px 10px;border-radius:7px;box-shadow:inset 0 0 0 1px var(--hair2)}
1559
1559
  #fsRoot .acts2 .errbtn:first-child{color:var(--ink2)}
1560
1560
  #fsRoot .errbtn:hover{background:var(--hover);color:var(--ink)}
1561
+ /* The review/attention dialog is rendered in #seeall, outside #fsRoot.
1562
+ Keep its recovery actions on the same designed component instead of
1563
+ falling back to the browser's unstyled native button. */
1564
+ #seeall .acts2{display:flex;gap:6px;margin-top:3px;align-items:center}
1565
+ #seeall .errbtn{border:0;background:transparent;color:var(--ink3);font-family:var(--mono);font-size:10px;letter-spacing:.02em;cursor:pointer;
1566
+ line-height:1.15;padding:2.5px 10px;border-radius:7px;box-shadow:inset 0 0 0 1px var(--hair2)}
1567
+ #seeall .acts2 .errbtn:first-child{color:var(--ink2)}
1568
+ #seeall .errbtn:hover{background:var(--hover);color:var(--ink)}
1561
1569
  /* AI question in the feed (q2 — §6.5: while current it lives in the chat feed).
1562
1570
  H18 / CDO HANDOFF-question-ui-2026-07-13: QUESTION card = hairline frame +
1563
1571
  2px --blue left rule + mono label + option chips + an always-on free-text
@@ -3633,10 +3641,7 @@ function reviewCardConfigFor(projectId){ return { ...DEFAULT_REVIEW_DEFINITION.r
3633
3641
  // project to one language regardless of what a given goal was typed in.
3634
3642
  function reviewLangFor(projectId){ const l = reviewDefinitionFor(projectId).language; return (l === 'en' || l === 'ja') ? l : 'auto'; }
3635
3643
  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' };
3644
+ return { infra: 'Verification infrastructure error', retry: 'Retry verification', retrying: 'Retrying…', failed: 'Verification retry failed' };
3640
3645
  }
3641
3646
 
3642
3647
  function esc(s){ const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
@@ -4891,7 +4896,8 @@ function renderTasks(){
4891
4896
  for (const b of $('tasklist').querySelectorAll('[data-goalreverify]')) {
4892
4897
  b.onclick = async (e) => { e.stopPropagation(); const copy = retryCopy(state.active); b.disabled = true; b.textContent = copy.retrying;
4893
4898
  let r; try { r = await fetch(withKey(`/api/goals/${b.dataset.goalreverify}/reverify`), { method: 'POST' }); }
4894
- catch { showErr(copy.failed); return; }
4899
+ catch { showErr(copy.failed); b.disabled = false; b.textContent = copy.retry; return; }
4900
+ if (!r.ok) { showErr(copy.failed); b.disabled = false; b.textContent = copy.retry; return; }
4895
4901
  qrefresh(r, copy.failed); };
4896
4902
  }
4897
4903
  for (const b of $('tasklist').querySelectorAll('[data-qreply]')) {
@@ -5557,8 +5563,8 @@ function renderReviewChecklist(){
5557
5563
  b.disabled = true;
5558
5564
  b.textContent = retryCopy(goal.projectId).retrying;
5559
5565
  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);
5566
+ catch { showErr(retryCopy(goal.projectId).failed); b.disabled = false; b.textContent = retryCopy(goal.projectId).retry; return; }
5567
+ if (r.ok) await refresh(); else { showErr(retryCopy(goal.projectId).failed); b.disabled = false; b.textContent = retryCopy(goal.projectId).retry; }
5562
5568
  };
5563
5569
  }
5564
5570
  for (const b of $('reviewList').querySelectorAll('.reqsend')) {
@@ -7191,6 +7197,7 @@ const FAILURE_COPY = {
7191
7197
  infra: {
7192
7198
  title: 'Galda基盤エラー',
7193
7199
  timeout: 'Galda側の実行上限や待ち時間で止まりました。変更内容の失敗とは限りません。',
7200
+ interrupted: 'ローカルの実行が中断されました。変更内容やAgentの失敗とは限らないため、同じ作業を再開できます。',
7194
7201
  auth: '接続や認証確認で止まりました。接続を直してから再開できます。',
7195
7202
  relay: 'relay接続で止まりました。接続状態を確認してから再試行できます。',
7196
7203
  chrome: 'Chrome/ブラウザ確認で止まりました。画面確認環境の問題で、実装失敗とは限りません。',
@@ -7200,8 +7207,8 @@ const FAILURE_COPY = {
7200
7207
  unknown: 'Galdaの実行基盤で止まりました。ログを確認し、原因に応じて再試行できます。',
7201
7208
  },
7202
7209
  actions: {
7203
- retry: '再試行', resume: '再開', connect: 'Connect', check: '接続確認',
7204
- other: 'Agentで実行', log: 'ログ確認', tests: 'テスト再実行', pr: 'PR再試行',
7210
+ retry: 'Retry', resume: 'Resume', connect: 'Connect', check: 'Check connection',
7211
+ other: 'Try another Agent', log: 'View log', tests: 'Re-run tests', pr: 'Retry PR',
7205
7212
  },
7206
7213
  },
7207
7214
  en: {
@@ -7209,6 +7216,7 @@ const FAILURE_COPY = {
7209
7216
  infra: {
7210
7217
  title: 'Galda system issue',
7211
7218
  timeout: 'Galda stopped this at the run limit or a wait timed out. This is not necessarily a problem with the change.',
7219
+ interrupted: 'The local run was interrupted. This does not necessarily reflect a problem with the change or agent; resume the same work.',
7212
7220
  auth: 'Connection or authentication failed. Reconnect, then continue.',
7213
7221
  relay: 'The relay connection failed. Check the connection, then retry.',
7214
7222
  chrome: 'Chrome/browser verification failed. The UI check environment stopped, not necessarily the implementation.',
@@ -7229,17 +7237,23 @@ function failureLang(){
7229
7237
  function inferErrorClass(text, agent){
7230
7238
  const low = String(text || '').toLowerCase();
7231
7239
  const infra = (reason, actionIds) => ({ owner: 'galda-infra', reason, actionIds });
7232
- if (/run limit|実行上限|timed out|timeout|タイムアウト/.test(low)) return infra('timeout', ['retry', 'run-with-codex', 'view-log']);
7233
- if (/spawn|enoent|eacces|command not found|worker tool exited early|作業ツールが途中で終了/.test(low)) return infra('worker-start', ['retry', 'run-with-codex', 'view-log']);
7234
- if (/auth probe|preflight|not logged in|logged out|not authenticated|authentication|unauthorized|invalid api key|bad credentials|401\b|403\b|接続が必要|認証/.test(low)) return infra('auth', ['connect', 'check-connection', 'run-with-codex', 'view-log']);
7240
+ if (/run limit|実行上限|timed out|timeout|タイムアウト/.test(low)) return infra('timeout', ['retry', 'view-log']);
7241
+ if (/worker was interrupted|作業が途中で中断|exit 143|sigterm|sigint/.test(low)) return infra('interrupted', ['retry', 'view-log']);
7242
+ if (/spawn|enoent|eacces|command not found|worker tool exited early|作業ツールが途中で終了/.test(low)) return infra('worker-start', ['retry', 'view-log']);
7243
+ if (/auth probe|preflight|not logged in|logged out|not authenticated|authentication|unauthorized|invalid api key|bad credentials|401\b|403\b|接続が必要|認証/.test(low)) return infra('auth', ['connect', 'check-connection', 'view-log']);
7235
7244
  if (/relay|websocket|socket|econnreset|econnrefused|billing-api unreachable|cf access|cloudflare access/.test(low)) return infra('relay', ['retry', 'connect', 'view-log']);
7236
7245
  if (/chrome|chromium|puppeteer|target closed|browser|navigation timeout|waiting failed|screenshot|headless/.test(low)) return infra('chrome', ['retry', 'view-log']);
7237
7246
  if (/test runner|node --test|npm test|project tests|server exited \(code \d+\) before it was ready|did not report ready in time|test timed out after|tap|not ok \d+/.test(low)) return infra('test-runner', ['retry-tests', 'view-log']);
7238
7247
  if (/pr step failed|openpr|pull request|github|gh:|gh auth|git push|git fetch|git .*failed|pr作成|pr failed/.test(low)) return infra('pr', ['retry-pr', 'view-log']);
7239
- return { owner: 'agent', reason: agent === 'codex' ? 'codex' : 'claude-code', actionIds: ['retry', 'run-with-codex', 'view-log'] };
7248
+ return { owner: 'agent', reason: agent === 'codex' ? 'codex' : 'claude-code', actionIds: ['retry', 'view-log'] };
7240
7249
  }
7241
7250
  function taskErrorMeta(t, goal){
7242
- const meta = t.errorClass || inferErrorClass(t.result, t.agent || goal?.agent);
7251
+ // Reclassify interrupted tasks from their concrete exit text instead of
7252
+ // trusting stale persisted metadata. Older records labelled an external
7253
+ // SIGTERM/restart as an Agent failure and permanently carried a Codex switch.
7254
+ const inferred = inferErrorClass(t.result, t.agent || goal?.agent);
7255
+ const raw = t.status === 'interrupted' ? inferred : (t.errorClass || inferred);
7256
+ const meta = { ...raw, actionIds: (raw.actionIds || []).filter((id) => !['run-with-codex', 'run-with-claude'].includes(id)) };
7243
7257
  const lang = failureLang(), C = FAILURE_COPY[lang];
7244
7258
  const isInfra = meta.owner === 'galda-infra';
7245
7259
  return {
@@ -7253,20 +7267,16 @@ function goalBlockedErrorMeta(g){
7253
7267
  const raw = g.blocked?.errorClass || (g.blocked?.kind === 'test'
7254
7268
  ? { owner: 'galda-infra', reason: 'test-runner', actionIds: ['retry-tests', 'view-log'] }
7255
7269
  : inferErrorClass(g.blocked?.reason, g.agent));
7270
+ const meta = { ...raw, actionIds: (raw.actionIds || []).filter((id) => !['run-with-codex', 'run-with-claude'].includes(id)) };
7256
7271
  const lang = failureLang(), C = FAILURE_COPY[lang];
7257
- const isInfra = raw.owner === 'galda-infra';
7258
- return { ...raw, title: isInfra ? C.infra.title : C.agent.title, body: isInfra ? (C.infra[raw.reason] || C.infra.unknown) : C.agent.body, copy: C };
7272
+ const isInfra = meta.owner === 'galda-infra';
7273
+ return { ...meta, title: isInfra ? C.infra.title : C.agent.title, body: isInfra ? (C.infra[meta.reason] || C.infra.unknown) : C.agent.body, copy: C };
7259
7274
  }
7260
7275
  function errorButtonsHtml(meta, { taskId = null, goalId = null, interrupted = false, agent = null, pr = false } = {}){
7261
7276
  const C = meta.copy.actions;
7262
- const canCodex = goalId != null && agent !== 'codex' && (state.agents || []).includes('codex');
7263
7277
  const out = [];
7264
7278
  for (const id of meta.actionIds || []) {
7265
7279
  if (id === 'retry' && taskId != null) out.push(`<button class="errbtn" data-fsretry="${taskId}">${interrupted ? C.resume : C.retry}</button>`);
7266
- // This keeps the existing agent-switch endpoint, but says what it really does
7267
- // for an interrupted run: continue the saved goal with another agent rather
7268
- // than imply a brand-new, contextless task will be created.
7269
- else if (id === 'run-with-codex' && canCodex) out.push(`<button class="errbtn" data-fscodex="${goalId}">${interrupted ? (failureLang() === 'ja' ? 'Codexで再開' : 'Resume with Codex') : C.other}</button>`);
7270
7280
  else if (id === 'connect') out.push(`<button class="errbtn" data-fsconnectworker="1">${C.connect}</button>`);
7271
7281
  else if (id === 'check-connection') out.push(`<button class="errbtn" data-fscheckworker="1">${C.check}</button>`);
7272
7282
  else if (id === 'retry-tests' && goalId != null) out.push(`<button class="errbtn" data-fsreverify="${goalId}">${C.tests}</button>`);
@@ -8530,7 +8540,7 @@ let sseUp = false; // live engine link — the honest signal behind the MCP tab'
8530
8540
  // Connection status line (queue tray): grace-debounce the offline state ~800ms so
8531
8541
  // sub-second SSE blips don't flash a red line. connOffline is the debounced flag
8532
8542
  // renderQueue() reads; connSetOnline/Offline flip it around real sseUp changes.
8533
- let connOffline = false, _connGraceT = null, _sseReconnectT = null, _es = null, _healthT = null;
8543
+ let connOffline = false, _connGraceT = null, _sseReconnectT = null, _es = null, _healthT = null, _healthFailStreak = 0;
8534
8544
  function connSetOnline(){ if (_connGraceT) { clearTimeout(_connGraceT); _connGraceT = null; } if (connOffline) { connOffline = false; renderQueue(); } }
8535
8545
  function connSetOffline(){ if (_connGraceT || connOffline) return; _connGraceT = setTimeout(() => { _connGraceT = null; connOffline = true; renderQueue(); }, 800); }
8536
8546
  // Reconnect button: drop any pending backoff timer and reopen the EventSource now.
@@ -8545,15 +8555,23 @@ async function checkHealth(){
8545
8555
  if (document.hidden) return;
8546
8556
  let ok = false;
8547
8557
  try {
8548
- const c = new AbortController(); const to = setTimeout(() => c.abort(), 3000);
8558
+ // Mobile browsers and DevTools device emulation can briefly starve this low-priority
8559
+ // probe even while the already-open SSE and POST path are healthy. Do not turn one
8560
+ // transient probe into a user-visible outage.
8561
+ const c = new AbortController(); const to = setTimeout(() => c.abort(), 5000);
8549
8562
  const r = await fetch(withKey('/'), { method: 'HEAD', cache: 'no-store', signal: c.signal });
8550
8563
  clearTimeout(to); ok = r.ok;
8551
8564
  } catch { ok = false; }
8552
8565
  if (!ok) {
8566
+ _healthFailStreak++;
8567
+ if (_healthFailStreak < 2) return;
8553
8568
  connSetOffline(); // grace-debounced ~800ms
8554
8569
  if (!_sseReconnectT) { try { _es && _es.close(); } catch {} sseUp = false; esRetryMs = Math.min(esRetryMs, 4000); _sseReconnectT = setTimeout(connectSSE, esRetryMs); }
8555
- } else if (connOffline && !_sseReconnectT) {
8570
+ } else {
8571
+ _healthFailStreak = 0;
8572
+ if (connOffline && !_sseReconnectT) {
8556
8573
  forceReconnect(); // server reachable again but the line is still up → reconnect to clear + flush
8574
+ }
8557
8575
  }
8558
8576
  }
8559
8577
  function startHealthPoll(){ clearInterval(_healthT); _healthT = setInterval(checkHealth, 4000); }
@@ -8773,6 +8791,10 @@ async function boot(){
8773
8791
  flushOutbox();
8774
8792
  connectSSE();
8775
8793
  startHealthPoll();
8794
+ // A Wi-Fi ⇄ cellular switch can leave EventSource waiting for its normal retry
8795
+ // budget. The browser has already confirmed connectivity here, so reconnect now
8796
+ // instead of making a mobile send wait for the next health poll/backoff cycle.
8797
+ window.addEventListener('online', () => { _healthFailStreak = 0; forceReconnect(); });
8776
8798
  document.addEventListener('visibilitychange', () => { if (!document.hidden) { refresh(); flushOutbox(); checkHealth(); } });
8777
8799
  // belt and suspenders for the never-lose guarantee: even if every event
8778
8800
  // path misfires, unsent memos are retried on a slow heartbeat
@@ -10130,13 +10152,12 @@ function saItemHtml(it, i){
10130
10152
  ${coverageSec}
10131
10153
  ${evidenceSec}
10132
10154
  ${reqVerifySec}
10133
- ${S('Open it', openBody)}
10155
+ ${S('Open it', openBody + infraBody)}
10134
10156
  ${S('Run state', interruptedBody)}
10135
10157
  ${S('PR', prRepairBody)}
10136
10158
  ${S('Also carries', carriesBody)}
10137
10159
  ${S('What to check', checkBody)}
10138
10160
  ${S('Result', reportBody)}
10139
- ${S(infra?.title || '検証基盤エラー', infraBody)}
10140
10161
  ${S('Screenshots', shotsBody)}
10141
10162
  ${S('Steps', stepsBody)}
10142
10163
  ${S('Proof', proofInner)}
@@ -10629,11 +10650,12 @@ $('seeall').addEventListener('click', async (e) => {
10629
10650
  // meta-row toggle (H23): tap the token chip to reveal the technique breakdown.
10630
10651
  if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
10631
10652
  if (k === 'reverify') {
10653
+ const copy = retryCopy(it.goal?.projectId || state.active);
10632
10654
  b.disabled = true;
10633
- b.textContent = '再検証中…';
10655
+ b.textContent = copy.retrying;
10634
10656
  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('再検証に失敗');
10657
+ catch { showErr(copy.failed); b.disabled = false; b.textContent = copy.retry; return; }
10658
+ if (r.ok) await refresh(); else { showErr(copy.failed); b.disabled = false; b.textContent = copy.retry; }
10637
10659
  return;
10638
10660
  }
10639
10661
  // 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/lib.mjs CHANGED
@@ -1258,13 +1258,20 @@ export function classifyWorkerFailure({ agent = 'claude-code', result = '', err
1258
1258
  actionIds,
1259
1259
  });
1260
1260
  if (timedOut || /run limit|実行上限|timed out|timeout|タイムアウト/.test(low)) {
1261
- return infra('timeout', ['retry', 'run-with-codex', 'view-log']);
1261
+ return infra('timeout', ['retry', 'view-log']);
1262
+ }
1263
+ // SIGTERM/SIGINT means the local worker was stopped from outside the task
1264
+ // (for example while the local Manager was restarting). It says nothing
1265
+ // about either the requested change or the chosen agent, so never turn this
1266
+ // recovery path into a suggestion to switch agents.
1267
+ if (code === 143 || code === 130 || /worker was interrupted|作業が途中で中断|exit 143|sigterm|sigint/.test(low)) {
1268
+ return infra('interrupted', ['retry', 'view-log']);
1262
1269
  }
1263
1270
  if (/spawn|enoent|eacces|command not found|executable|worker tool exited early|作業ツールが途中で終了/.test(low)) {
1264
- return infra('worker-start', ['retry', 'run-with-codex', 'view-log']);
1271
+ return infra('worker-start', ['retry', 'view-log']);
1265
1272
  }
1266
1273
  if (/auth probe|preflight|not logged in|logged out|not authenticated|authentication|unauthorized|invalid api key|bad credentials|401\b|403\b|接続が必要|認証/.test(low)) {
1267
- return infra('auth', ['connect', 'check-connection', 'run-with-codex', 'view-log']);
1274
+ return infra('auth', ['connect', 'check-connection', 'view-log']);
1268
1275
  }
1269
1276
  if (/relay|websocket|socket|econnreset|econnrefused|billing-api unreachable|cf access|cloudflare access/.test(low)) {
1270
1277
  return infra('relay', ['retry', 'connect', 'view-log']);
@@ -1281,7 +1288,7 @@ export function classifyWorkerFailure({ agent = 'claude-code', result = '', err
1281
1288
  return {
1282
1289
  owner: 'agent',
1283
1290
  reason: agent === 'codex' ? 'codex' : 'claude-code',
1284
- actionIds: ['retry', agent === 'claude-code' ? 'run-with-codex' : 'run-with-claude', 'view-log'],
1291
+ actionIds: ['retry', 'view-log'],
1285
1292
  };
1286
1293
  }
1287
1294
 
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.89",
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": {