@galda/cli 0.10.82 → 0.10.84

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
@@ -5368,7 +5368,10 @@ function renderReviewChecklist(){
5368
5368
  // own test result — so a reviewer can judge at a glance, before scrolling.
5369
5369
  const grs = goal.reviewSummary ?? {};
5370
5370
  const gtr = goal.testResult;
5371
- const testLine = gtr?.ran ? `<span class="tbadge ${gtr.ok ? 'ok' : 'no'}">tests ${gtr.passed}✓${gtr.failed ? ` / ${gtr.failed}✗` : ''}</span>` : '';
5371
+ const testPreflightFailed = gtr?.preflight && !gtr.preflight.ok;
5372
+ const testLine = gtr?.ran ? (testPreflightFailed
5373
+ ? `<span class="tbadge">tests need environment setup</span>`
5374
+ : `<span class="tbadge ${gtr.ok ? 'ok' : 'no'}">tests ${gtr.passed}✓${gtr.failed ? ` / ${gtr.failed}✗` : ''}</span>`) : '';
5372
5375
  // #377-7 (Masa, recurring): the base UI is ALWAYS English — Japanese is a
5373
5376
  // per-project CONTENT customization, never chrome. So the fixed system labels
5374
5377
  // below stay English regardless of reviewDefinition.language; only the
@@ -5398,7 +5401,7 @@ function renderReviewChecklist(){
5398
5401
  vchips.push(`<span class="vchip none">${esc(workspaceModeText(goal))}</span>`);
5399
5402
  if (goal.workspaceWarning) vchips.push(`<span class="vchip human high">⚠ ${esc(goal.workspaceWarning.slice(0, 80))}</span>`);
5400
5403
  vchips.push(gtr?.ran
5401
- ? (gtr.ok ? `<span class="vchip ok">✓ ${VF.pass}</span>` : `<span class="vchip no">✗ ${VF.fail}</span>`)
5404
+ ? (testPreflightFailed ? `<span class="vchip human">! tests need setup</span>` : gtr.ok ? `<span class="vchip ok">✓ ${VF.pass}</span>` : `<span class="vchip no">✗ ${VF.fail}</span>`)
5402
5405
  : `<span class="vchip none">— ${VF.untested}</span>`);
5403
5406
  vchips.push(hasProof ? `<span class="vchip ok">✓ ${VF.proof}</span>` : `<span class="vchip none">— ${VF.noproof}</span>`);
5404
5407
  if (risk?.shouldPause) vchips.push(`<span class="vchip human high">⚠ ${VF.human}</span>`);
@@ -11747,15 +11750,23 @@ function gearSetHTML(){
11747
11750
  ? acct + `<div class="srow"><div class="sl">Plan</div><span class="bx-pdot"><i></i>Paid</span></div>
11748
11751
  <div class="srow"><div class="sl">Billing<span class="sd">manage or cancel in Stripe</span></div><button class="bx-cchip" data-gportal="1">Manage</button></div>`
11749
11752
  : acct + freeUsage;
11753
+ const st = state.stability;
11754
+ const stability = st ? `<div class="srow"><div class="sl">Verification health<span class="sd">Last 24h · implementation ${st.implementationFailures} · environment ${st.verificationInfrastructure} · inconclusive ${st.timeoutsOrInconclusive} · worker holds ${st.workerHolds}</span></div><button class="optchip" data-gstability="1">Refresh</button></div>` : `<div class="srow"><div class="sl">Verification health<span class="sd">Loading…</span></div></div>`;
11750
11755
  return `
11751
11756
  <div class="srow"><div class="sl">Language<span class="sd">Review checklist language for this project</span></div>
11752
11757
  <button class="optchip${lang === 'auto' ? ' on' : ''}" data-gslang="auto">Auto</button>
11753
11758
  <button class="optchip${lang === 'en' ? ' on' : ''}" data-gslang="en">EN</button>
11754
11759
  <button class="optchip${lang === 'ja' ? ' on' : ''}" data-gslang="ja">JA</button></div>
11755
11760
  ${billingRow}
11761
+ ${stability}
11756
11762
  ${gearRulesHTML()}
11757
11763
  <div class="srow dis"><div class="sl">Data folder<span class="sd">Runs are stored in the engine's chat-runs folder on the host</span></div></div>`;
11758
11764
  }
11765
+ async function loadStability(){
11766
+ try { const r = await fetch(withKey('/api/stability')); state.stability = r.ok ? await r.json() : null; }
11767
+ catch { state.stability = null; }
11768
+ if ($('gearPop') && custTab === 'settings') buildGearPop();
11769
+ }
11759
11770
  // ONE-CONVERSATION §1.5a: the project's working rules — the file(s) the connected
11760
11771
  // AI actually reads (Claude Code → CLAUDE.md, Codex → AGENTS.md). Read-only: Galda
11761
11772
  // does NOT own these rules, it only surfaces them (editing is done in an editor or
@@ -11844,7 +11855,10 @@ function buildGearPop(){
11844
11855
  };
11845
11856
  const rulesBtn = pop.querySelector('[data-grules]');
11846
11857
  if (rulesBtn) rulesBtn.onclick = () => { state.rulesOpen = !state.rulesOpen; buildGearPop(); };
11858
+ const stabilityBtn = pop.querySelector('[data-gstability]');
11859
+ if (stabilityBtn) stabilityBtn.onclick = () => { state.stability = null; buildGearPop(); loadStability(); };
11847
11860
  loadProjectRules();
11861
+ if (!state.stability) loadStability();
11848
11862
  return;
11849
11863
  }
11850
11864
  if (custTab === 'mcp') {
package/engine/lib.mjs CHANGED
@@ -1474,7 +1474,7 @@ export function buildGoalPrBody({ lang, goal, goalTasks, files, owner, branch, p
1474
1474
  // 修正/追加/改善/調査 so an "追加したい" request never becomes "…の修正", and (c) a
1475
1475
  // failed/missing reply falls back to goal.text (no regression) — all without an
1476
1476
  // LLM call. The app's saReviewTitle reads reviewSummary.title first (PR-B).
1477
- export function buildReviewSummaryPrompt({ lang, goalText, changed, reports }) {
1477
+ export function buildReviewSummaryPrompt({ lang, goalText, changed, reports, latestFeedback = '' }) {
1478
1478
  return [
1479
1479
  `あるゴールの実装が完了しました。レビューする人が一目で判断できるよう、やさしい${lang === 'ja' ? '日本語' : lang}で書いてください。`,
1480
1480
  '専門用語・長い説明は禁止。小学生でも分かる短い言い方。JSONのみ出力(前後に文章を書かない):',
@@ -1483,6 +1483,7 @@ export function buildReviewSummaryPrompt({ lang, goalText, changed, reports }) {
1483
1483
  + '例:「Xの不具合」→「Xの修正」/「Yを追加したい」→「Yの追加」/「Zを速くしたい」→「Zの改善」/「なぜWか調べて」→「Wの調査」。',
1484
1484
  '',
1485
1485
  `ゴール: ${(goalText ?? '').slice(0, 300)}`,
1486
+ latestFeedback ? `最新のユーザー返信(この再レビューで満たすべき条件。初回の結果ではなく、これへの対応をchanged/checkに書く):\n${String(latestFeedback).slice(0, 1000)}` : null,
1486
1487
  `変更ファイル: ${(changed ?? []).join(', ') || '(なし)'}`,
1487
1488
  `ワーカー報告:\n${(reports ?? '').slice(0, 1500)}`,
1488
1489
  ].join('\n');
@@ -1650,6 +1651,8 @@ export function countInfraFlakes(text = '') {
1650
1651
  /Navigation timeout of \d+\s*ms exceeded/g, // puppeteer page.goto
1651
1652
  /test timed out after \d+\s*ms/g, // node --test per-test timeout
1652
1653
  /\bTarget closed\b/g, // puppeteer: browser died mid-test
1654
+ /puppeteer-core is not installed next to the Manager/g, // worktree/node_modules missing
1655
+ /Cannot find module ['"]puppeteer-core['"]/g, // same dependency failure, alternate Node wording
1653
1656
  ];
1654
1657
  return pats.reduce((n, re) => n + (String(text ?? '').match(re) || []).length, 0);
1655
1658
  }
@@ -3120,6 +3123,12 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3120
3123
  // TASK/DETAIL below is a paraphrase from the planner — when they differ,
3121
3124
  // follow the user's words.
3122
3125
  goal?.text ? `USER'S REQUEST (verbatim — the source of truth; deliver exactly this):\n${goal.text}` : '',
3126
+ // A review reply is a new, explicit acceptance condition. It used to be
3127
+ // present only as TASK DETAIL below the inherited session context, which
3128
+ // made it easy for a resumed agent to keep describing the first attempt.
3129
+ // Put it next to the original request and make the precedence explicit:
3130
+ // the interface transports the user's words, it does not reinterpret them.
3131
+ task?.reply && task?.detail ? `CURRENT USER FOLLOW-UP (verbatim — these are the acceptance conditions for THIS re-review):\n${task.detail}` : '',
3123
3132
  runContext ? (typeof runContext === 'string' ? runContext : buildWorkerContextBlock(runContext)) : '',
3124
3133
  '',
3125
3134
  `TASK ${task.num}: ${task.title}`,
@@ -3144,6 +3153,7 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3144
3153
  // instead of expanding scope"): the request, not our fear of scope, sets
3145
3154
  // the size of the work.
3146
3155
  '- Deliver what the request actually asks for. If it asks for multiple options, N variations, a visual, an artifact, or a full exploration, produce ALL of them — do not reduce it to one, and do not cut it short to finish faster.',
3156
+ '- For a review follow-up, implement and report against CURRENT USER FOLLOW-UP above. Do not claim the first attempt as this attempt\'s result. Do not change shared theme tokens, seed/demo data, or unrelated indicators merely to make a manual check easier unless the user explicitly requested those changes; put test fixtures in test files only.',
3147
3157
  '- デザイン案・UI案・レイアウト案を求められた場合は、文章だけで完了にしない。画像を使わない案でも、比較して確認できるHTML等のartifactをプロジェクト内に作り、実際に開ける .manager-run.json を残す。',
3148
3158
  '- Keep the work bounded to the request: inspect only files needed; avoid broad repository scans unless they are necessary.',
3149
3159
  "- You may use the user's installed skills (Skill tool, e.g. /galda-doc) when one clearly fits the request.",
package/engine/server.mjs CHANGED
@@ -2982,12 +2982,37 @@ const testRunQueue = createSerialQueue();
2982
2982
  // Manager-run tests (never trust the worker's "all green" claim): run the
2983
2983
  // project's own `npm test` in the goal's worktree and parse node --test's
2984
2984
  // "ℹ pass N / ℹ fail N" (or TAP "# pass/# fail"). Returns {ran,passed,failed,ok}.
2985
- function runTestsOnce(dir) {
2985
+ function testNodePath(dir, dependencySourceDir = null) {
2986
+ // node_modules is intentionally absent from git worktrees. Reuse the base
2987
+ // checkout's installed dependencies before declaring every server/UI test a
2988
+ // product regression; verification must never silently depend on `npm install`.
2989
+ const sep = process.platform === 'win32' ? ';' : ':';
2990
+ const local = [join(dir, 'node_modules'), dependencySourceDir && join(dependencySourceDir, 'node_modules'), join(ROOT, 'node_modules')]
2991
+ .filter(Boolean).filter(existsSync);
2992
+ return [...new Set([...local, ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(sep) : [])])].join(sep);
2993
+ }
2994
+ function testEnvironmentPreflight(dir, dependencySourceDir = null) {
2995
+ const nodePath = testNodePath(dir, dependencySourceDir);
2996
+ const sources = nodePath ? nodePath.split(process.platform === 'win32' ? ';' : ':') : [];
2997
+ const hasPuppeteer = sources.some((p) => existsSync(join(p, 'puppeteer-core', 'package.json')));
2998
+ const npm = spawnSync('npm', ['--version'], { encoding: 'utf8' });
2999
+ const checks = [
3000
+ { id: 'node', ok: Boolean(process.execPath), detail: process.version },
3001
+ { id: 'npm', ok: npm.status === 0, detail: npm.status === 0 ? npm.stdout.trim() : 'npm command unavailable' },
3002
+ { id: 'dependencies', ok: sources.length > 0, detail: sources.length ? `${sources.length} node_modules source(s)` : 'node_modules not found' },
3003
+ { id: 'puppeteer', ok: hasPuppeteer, detail: hasPuppeteer ? 'puppeteer-core available' : 'puppeteer-core missing; run npm install in the project directory' },
3004
+ ];
3005
+ return { ok: checks.every((c) => c.ok), checks };
3006
+ }
3007
+ function runTestsOnce(dir, dependencySourceDir = null) {
2986
3008
  // The timeout is env-overridable so tests can force the killed-run path fast;
2987
3009
  // production keeps the 4-minute default.
2988
3010
  const runTimeout = Number(process.env.MANAGER_TEST_TIMEOUT_MS) || 240000;
2989
3011
  return testRunQueue.run(() => new Promise((res) => {
2990
- execFile('npm', ['test'], { cwd: dir, timeout: runTimeout, env: { ...process.env }, maxBuffer: 8 * 1024 * 1024 }, (e, out = '', err = '') => {
3012
+ const preflight = testEnvironmentPreflight(dir, dependencySourceDir);
3013
+ if (!preflight.ok) return res({ passed: 0, failed: 0, preflight, timedOut: false, realAssertion: false, infraOnly: true, infraFlakes: 0, detail: preflight.checks.filter((c) => !c.ok).map((c) => c.detail).join('\n'), failingNames: [] });
3014
+ const nodePath = testNodePath(dir, dependencySourceDir);
3015
+ execFile('npm', ['test'], { cwd: dir, timeout: runTimeout, env: { ...process.env, ...(nodePath ? { NODE_PATH: nodePath } : {}) }, maxBuffer: 8 * 1024 * 1024 }, (e, out = '', err = '') => {
2991
3016
  const text = `${out}\n${err}`;
2992
3017
  const n = (re) => { const m = text.match(re); return m ? Number(m[1]) : null; };
2993
3018
  const passed = n(/ℹ pass (\d+)/) ?? n(/# pass (\d+)/) ?? n(/(\d+) passing/) ?? 0;
@@ -3015,21 +3040,21 @@ function runTestsOnce(dir) {
3015
3040
  const timedOut = Boolean(e && (e.killed || e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT'));
3016
3041
  // Capture the failing test NAMES too (not just the count) so the gate can
3017
3042
  // diff them against the base's failures and block only on NEW ones.
3018
- res({ passed, failed, timedOut, realAssertion, infraOnly, infraFlakes, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
3043
+ res({ passed, failed, preflight, timedOut, realAssertion, infraOnly, infraFlakes, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
3019
3044
  });
3020
3045
  }));
3021
3046
  }
3022
- async function runProjectTests(dir) {
3047
+ async function runProjectTests(dir, dependencySourceDir = null) {
3023
3048
  let hasTest = false;
3024
3049
  try { hasTest = !!JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).scripts?.test; } catch { /* no package.json */ }
3025
3050
  if (!hasTest) return { ran: false };
3026
- let r = await runTestsOnce(dir);
3051
+ let r = await runTestsOnce(dir, dependencySourceDir);
3027
3052
  // Flake resistance: the integration suite spawns real servers; under load a
3028
3053
  // startup can time out. Re-run once and take the fewer failures — a real
3029
3054
  // failure fails both runs, a flake clears on the retry — so a good goal is
3030
3055
  // never blocked on a flaky test.
3031
3056
  if (r.failed > 0) {
3032
- const r2 = await runTestsOnce(dir);
3057
+ const r2 = await runTestsOnce(dir, dependencySourceDir);
3033
3058
  const kept = r2.failed <= r.failed ? r2 : r; // the run that saw fewer failures
3034
3059
  r = {
3035
3060
  passed: Math.max(r.passed, r2.passed),
@@ -3044,11 +3069,12 @@ async function runProjectTests(dir) {
3044
3069
  // the gate should see, so no good goal is blocked on a saturated boot.
3045
3070
  infraOnly: !!kept.infraOnly,
3046
3071
  infraFlakes: kept.infraFlakes ?? 0,
3072
+ preflight: kept.preflight ?? r.preflight ?? null,
3047
3073
  detail: kept.detail || r.detail || r2.detail || '',
3048
3074
  failingNames: kept.failingNames ?? r.failingNames ?? [],
3049
3075
  };
3050
3076
  }
3051
- return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, timedOut: !!r.timedOut, realAssertion: !!r.realAssertion, infraOnly: !!r.infraOnly, infraFlakes: r.infraFlakes ?? 0, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
3077
+ return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, timedOut: !!r.timedOut, realAssertion: !!r.realAssertion, infraOnly: !!r.infraOnly, infraFlakes: r.infraFlakes ?? 0, preflight: r.preflight ?? null, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
3052
3078
  }
3053
3079
 
3054
3080
  // Failing tests on the BASE (pre-change) checkout — the set the review gate
@@ -3144,7 +3170,7 @@ async function runRetestJob(goal, token) {
3144
3170
  } else {
3145
3171
  for (let i = 0; i < RETEST_RUNS; i++) {
3146
3172
  if (!live()) return; // cancelled (UNDO) / superseded / deleted → discard
3147
- const r = await runTestsOnce(wdir);
3173
+ const r = await runTestsOnce(wdir, project.dir);
3148
3174
  if (!live()) return; // cancelled while this run was in flight
3149
3175
  results.push({ ran: true, passed: r.passed, failed: r.failed });
3150
3176
  goal.retest = { state: 'running', runs: results.length, total: RETEST_RUNS, startedAt: goal.retest?.startedAt };
@@ -3231,7 +3257,11 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
3231
3257
  // An explicit ja/en on the project's review definition always overrides.
3232
3258
  const lang = reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : (reviewDefinition.language || 'ja');
3233
3259
  const changed = [...new Set(siblings.flatMap((t) => t.changedFiles ?? []))].slice(0, 20);
3234
- const reports = siblings.filter((t) => !t.reply).map((t) => `- ${t.title}: ${(t.result ?? '').replace(/\s+/g, ' ').slice(0, 300)}`).join('\n');
3260
+ // A reply is a new implementation attempt, not just conversation. Excluding
3261
+ // it here made the second Review render the first attempt's stale Result,
3262
+ // even when the worker had completed the user's latest feedback.
3263
+ const reports = siblings.map((t) => `${t.reply ? '[follow-up] ' : ''}- ${t.title}: ${(t.result ?? '').replace(/\s+/g, ' ').slice(0, 300)}`).join('\n');
3264
+ const latestFeedback = [...siblings].reverse().find((t) => t.reply && t.detail)?.detail ?? '';
3235
3265
  // The name of the work, from the agent that did it (task.run.title). It wins
3236
3266
  // over both the summarizer and the request text: the summarizer only ever read
3237
3267
  // the same request the user typed, and the request text is the sentence the
@@ -3245,7 +3275,7 @@ async function summarizeForReview(goal, siblings, dir, reviewDefinition) {
3245
3275
  changed: (goal.plan?.[0] ?? goal.text ?? '').replace(/\s+/g, ' ').slice(0, 80),
3246
3276
  };
3247
3277
  if (!changed.length && !reports) return fallback;
3248
- const prompt = buildReviewSummaryPrompt({ lang, goalText: goal.text, changed, reports });
3278
+ const prompt = buildReviewSummaryPrompt({ lang, goalText: goal.text, changed, reports, latestFeedback });
3249
3279
  try {
3250
3280
  const r = await runUtility({ prompt, cwd: dir, tools: 'Read' });
3251
3281
  const summary = parseReviewSummary(r.result, fallback);
@@ -3509,7 +3539,7 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
3509
3539
  const testGateOn = resolveTestGate({ reviewDefinition, env: process.env, force: forceTests });
3510
3540
  if (testGateOn && hasTestRelevantChanges(changedFiles)) {
3511
3541
  goalAct(`Running project tests in the goal worktree (${changedFiles.length} changed file${changedFiles.length === 1 ? '' : 's'}).`);
3512
- goal.testResult = await runProjectTests(wdir);
3542
+ goal.testResult = await runProjectTests(wdir, baseProject?.dir);
3513
3543
  goalAct(`Project tests finished: ${goal.testResult.failed ?? 0} failed, ${goal.testResult.passed ?? 0} passed.`);
3514
3544
  } else if (!testGateOn) {
3515
3545
  goal.testResult = { ran: false, skipped: true, skippedReason: 'test gate off (opt-in: requireTests / MANAGER_VERIFY_GATE=on)' };
@@ -3561,6 +3591,11 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
3561
3591
  if (goal.testResult.ran && goal.testResult.infraOnly && !goal.testResult.inconclusive) {
3562
3592
  goalAct(`Project tests: all ${goal.testResult.infraFlakes || goal.testResult.failed || ''} failure(s) were infra flakes (a real server could not boot / a wait timed out under machine load), not code regressions — not blocking. Re-run when the machine is idle to confirm.`.replace(/\s{2,}/g, ' '));
3563
3593
  }
3594
+ if (goal.testResult.ran && goal.testResult.preflight && !goal.testResult.preflight.ok) {
3595
+ const missing = goal.testResult.preflight.checks.filter((c) => !c.ok).map((c) => c.id).join(', ');
3596
+ goal.testResult.inconclusive = true;
3597
+ goalAct(`Project tests were not started: verification preflight failed (${missing}). This is an environment issue, not an implementation failure. Fix the listed dependency or tool, then re-run verification.`);
3598
+ }
3564
3599
  // Baseline diff (Masa 2026-07-19): block ONLY on failures THIS change
3565
3600
  // introduced. A test already red on the base — e.g. persist-failure on clean
3566
3601
  // origin/main — is pre-existing noise and must not knock a proof-verified
@@ -3932,6 +3967,23 @@ const server = createServer(async (req, res) => {
3932
3967
  return json(res, 200, { projects: projectsWithBranch, goals: visibleGoals, tasks: trimTaskActivityForState(visibleTasks), models: MODELS, agentModels: { 'claude-code': MODELS, codex: CODEX_MODELS }, agentEfforts: { 'claude-code': CLAUDE_EFFORTS, codex: CODEX_EFFORTS }, agents: AVAILABLE_AGENTS, connectCommand, queueOrder, externalActivity, workflowColumns: projectColumns, reviewDefinitions: projectReviewDefs, runningCounts, parallelLimits, uiVersion: uiVersion(), billing, auth: publicAuth() });
3933
3968
  }
3934
3969
 
3970
+ // Read-only stability snapshot: separates implementation failures from
3971
+ // verification infrastructure so operational trends can be monitored without
3972
+ // scraping human-facing cards. The browser dashboard can consume this later.
3973
+ if (url.pathname === '/api/stability' && req.method === 'GET') {
3974
+ const since = Date.now() - 24 * 60 * 60 * 1000;
3975
+ const recentGoals = goals.filter((g) => Date.parse(g.createdAt ?? '') >= since);
3976
+ const count = (fn) => recentGoals.filter(fn).length;
3977
+ return json(res, 200, {
3978
+ since: new Date(since).toISOString(), generatedAt: new Date().toISOString(), total: recentGoals.length,
3979
+ implementationFailures: count((g) => ['failed', 'partial'].includes(g.status) && !g.verificationInfraError),
3980
+ verificationInfrastructure: count((g) => Boolean(g.verificationInfraError)),
3981
+ timeoutsOrInconclusive: count((g) => Boolean(g.testResult?.inconclusive)),
3982
+ workerHolds: count((g) => ['rate-limit', 'budget'].includes(g.blocked?.kind)),
3983
+ readyForReview: count((g) => g.status === 'review'),
3984
+ });
3985
+ }
3986
+
3935
3987
  // Billing (docs/BILLING-LAUNCH-PLAN.md): activate the license key a user
3936
3988
  // copies from galda.app after paying. GET reports whether one is on file
3937
3989
  // (for the settings panel); POST verifies+persists a newly pasted token.
@@ -4185,6 +4237,16 @@ const server = createServer(async (req, res) => {
4185
4237
  for (const t of removedTasks) logAppend(JSON.stringify({ kind: 'task', id: t.id, deleted: true }));
4186
4238
  projects.splice(idx, 1);
4187
4239
  queues.delete(project.id);
4240
+ // A deleted project's goals must not outlive it: project ids are derived
4241
+ // from the name (uniqueProjectId), so a later "+ New project" click can
4242
+ // regenerate the exact same id and — without this purge — inherit the
4243
+ // old project's leftover goals as if they belonged to the brand-new one
4244
+ // (Masa report 2026-07-27: new project shows Review items on creation).
4245
+ for (const g of goals.filter((g) => g.projectId === project.id)) {
4246
+ goals = goals.filter((x) => x.id !== g.id);
4247
+ logAppend(JSON.stringify({ kind: 'goal', id: g.id, deleted: true }));
4248
+ sendGoalEvent({ ev: 'goal-deleted', id: g.id }, g);
4249
+ }
4188
4250
  saveProjects();
4189
4251
  send({ ev: 'projects', projects, deletedProjectId: project.id });
4190
4252
  return json(res, 200, { ok: true, projects, deletedProjectId: project.id });
@@ -5418,7 +5480,19 @@ async function answerGoalQuestion(goal, question) {
5418
5480
  if (upMatch) {
5419
5481
  const file = join(logDir, 'uploads', upMatch[1]);
5420
5482
  if (!existsSync(file)) { res.writeHead(404); return res.end('not found'); }
5421
- res.writeHead(200, { 'content-type': file.endsWith('.gif') ? 'image/gif' : file.endsWith('.jpg') || file.endsWith('.jpeg') ? 'image/jpeg' : 'image/png' });
5483
+ // Worker-declared Open it artifacts and image attachments share `/upload`.
5484
+ // Never guess that an unknown artifact is a PNG: it turns valid HTML, PDF,
5485
+ // or text into a broken-image page. Known preview formats get their real
5486
+ // browser type; an unknown file downloads safely instead of being misread.
5487
+ const ext = (file.match(/\.([a-z0-9]+)$/i)?.[1] || '').toLowerCase();
5488
+ const types = {
5489
+ html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
5490
+ css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8',
5491
+ json: 'application/json; charset=utf-8', txt: 'text/plain; charset=utf-8', md: 'text/markdown; charset=utf-8', csv: 'text/csv; charset=utf-8',
5492
+ svg: 'image/svg+xml', png: 'image/png', gif: 'image/gif', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', ico: 'image/x-icon',
5493
+ pdf: 'application/pdf', mp4: 'video/mp4', webm: 'video/webm', wasm: 'application/wasm',
5494
+ };
5495
+ res.writeHead(200, { 'content-type': types[ext] || 'application/octet-stream' });
5422
5496
  return res.end(readFileSync(file));
5423
5497
  }
5424
5498
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.82",
3
+ "version": "0.10.84",
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": {