@galda/cli 0.10.72 → 0.10.74

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
@@ -655,9 +655,25 @@ export function estimateTokenOptimization({ usage = null, weightedTokens = null,
655
655
  // layer did to save tokens, and it is the opposite: the model is the user's
656
656
  // own pick from the composer, and we deliberately never override it
657
657
  // (Masa 2026-07-22). Every remaining line is an action this layer took.
658
+
659
+ // Masa 2026-07-26: a positive `baseline` percent is a comparison against past
660
+ // goals, not proof any technique was applied THIS run — a small goal that
661
+ // followed a run of large ones scores a big "saved" number purely from the
662
+ // size mix, with nothing the user actually did behind it. Only call it
663
+ // "saved" when a real technique signal fired this run (cache reuse, a fresh
664
+ // handoff, /compact, /clear); otherwise it is a true fact (this run was
665
+ // smaller/larger than usual) but not a causal claim, so we relabel it
666
+ // `baseline-untied` and the renderer must phrase it as a comparison, not a
667
+ // "you saved X% by doing Y".
668
+ const techniqueDriven = cacheRatio > 0 || usedHandoff || sessionMode === 'cold-handoff' || usedCompact || sessionMode === 'compact' || usedClear;
669
+ if (basis === 'baseline' && !techniqueDriven) basis = 'baseline-untied';
670
+
658
671
  if (basis === 'baseline' && percent > 0) {
659
672
  techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
660
673
  }
674
+ if (basis === 'baseline-untied') {
675
+ techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
676
+ }
661
677
  if (!techniques.length) techniques.push(`${Math.round(raw / 1000)}k tokens this run (no cache reuse yet)`);
662
678
 
663
679
  return { rawTokens: raw, weightedTokens: weighted, savedTokens, percent, basis, techniques: [...new Set(techniques)] };
@@ -1261,6 +1277,38 @@ export function taskStatusAfterVerify({ verdict, code, timedOut }) {
1261
1277
  return code === 0 ? 'done' : 'failed';
1262
1278
  }
1263
1279
 
1280
+ // task 450 (goal #783, task 448): a worker can exit 0 with changedFiles: []
1281
+ // and a result that reads as a thought cut off mid-sentence (e.g. "I'll wait
1282
+ // for this background full-suite run to complete rather than poll.") — no
1283
+ // work happened, but taskStatusAfterVerify (decided from `code` alone, before
1284
+ // changedFiles even exists) already called it 'done'. This is the same
1285
+ // principle the product already applies to verification (don't trust
1286
+ // self-report, judge by evidence) applied to task completion itself.
1287
+ // Deliberately narrow, per the same 0-file-legitimate-report carve-out as
1288
+ // isNothingVerifiable above: a real investigation/report task also has zero
1289
+ // changed files, but its result reads as a normal finished sentence, so only
1290
+ // text that also LOOKS cut off trips this.
1291
+ export function looksIncomplete(text) {
1292
+ const t = String(text ?? '').trim();
1293
+ if (!t) return true;
1294
+ if (t.length >= 200) return false;
1295
+ // A short blurb ending mid-word/mid-clause (no sentence-final punctuation
1296
+ // in any of ja/en) reads as truncated, not as a deliberate one-liner.
1297
+ if (!/[.!?。!?」))]$/.test(t)) return true;
1298
+ // goal-783/task-448's actual fragment ("I'll wait for this background
1299
+ // full-suite run to complete rather than poll.") IS a grammatically
1300
+ // complete sentence, so the check above misses it — but it's a one-shot
1301
+ // batch worker narrating a future intent ("I'll…"/"let me…"), never a
1302
+ // report of something actually done. That opener is itself the signal.
1303
+ return /^(i'll|i will|i'm going to|i am going to|let me)\b/i.test(t);
1304
+ }
1305
+
1306
+ export function isSuspiciouslyIncompleteDone({ status, changedFiles = [], result = '', hasVerdict = false } = {}) {
1307
+ if (status !== 'done' || hasVerdict) return false;
1308
+ if ((changedFiles ?? []).length > 0) return false;
1309
+ return looksIncomplete(result);
1310
+ }
1311
+
1264
1312
  // A task counts as complete for GOAL status when it cleanly finished
1265
1313
  // (done/skipped) OR it was force-stopped (interrupted) but its change is STRONGLY
1266
1314
  // verified — an independent passCondition verification PASSED (proof.verified &&
@@ -2254,6 +2302,21 @@ export function isNothingVerifiable({ changedFiles = [], hasPassCondition = fals
2254
2302
  return hasPassCondition && changedFiles.length === 0 && !hasAnyProof;
2255
2303
  }
2256
2304
 
2305
+ // goal-751 (2026-07-26): a wantsPR goal is an explicit ask for a PR, so a worker
2306
+ // that ends with zero changed files and no proof did not deliver what was asked —
2307
+ // most often a lane-conflict report ("空き次第自動再開します") that cannot actually
2308
+ // happen, since a `claude -p` worker is a one-shot batch run with no way to resume
2309
+ // itself later. taskStatusAfterVerify still reads a clean exit (code===0, no
2310
+ // verdict) as 'done', so this reached Review looking exactly like a normal
2311
+ // completed PR. Same shape as isNothingVerifiable above but keyed on wantsPR
2312
+ // instead of passCondition (a wantsPR goal has no passCondition of its own — the
2313
+ // PR ask itself is the promise). Advisory only, like proofNote/verificationInfra
2314
+ // Error below: the caller sets a note, it never blocks (Failedを作らない方針, see
2315
+ // docs/HANDOFF-2026-07-25-verify-gate-default-off.md).
2316
+ export function isNothingVerifiableForPR({ wantsPR = false, changedFiles = [], hasAnyProof = false } = {}) {
2317
+ return Boolean(wantsPR) && changedFiles.length === 0 && !hasAnyProof;
2318
+ }
2319
+
2257
2320
  // チェックリストUIで要件が全件Approveされたら goal を review→done へ進める。
2258
2321
  // 以前は「PRありgoalはマージが唯一の完了トリガー」として弾いていたが、それだと
2259
2322
  // 承認しても done が永続化されず次のrefreshで review に戻る不具合になっていた
@@ -2542,10 +2605,10 @@ export function hasGitChanges(porcelainStatus) {
2542
2605
  return Boolean(String(porcelainStatus ?? '').trim());
2543
2606
  }
2544
2607
 
2545
- export function resolveWorkspaceMode(input) {
2546
- if (input === 'direct-workspace' || input === 'direct') return 'direct-workspace';
2547
- if (input === 'isolated-worktree' || input === 'worktree' || input === true) return 'isolated-worktree';
2548
- if (input === false) return 'direct-workspace';
2608
+ // Direct workspace is retired (2026-07-26): two accidents where a stray client-side
2609
+ // toggle edited a shared checkout directly. This always returns isolated-worktree
2610
+ // regardless of what a client sends, so a stale/tampered client can't force direct.
2611
+ export function resolveWorkspaceMode(_input) {
2549
2612
  return 'isolated-worktree';
2550
2613
  }
2551
2614
 
@@ -3151,6 +3214,13 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3151
3214
  // (research, summaries, answers) simply skip this — the worker decides,
3152
3215
  // never us.
3153
3216
  `- 人が実際に動かして結果を見られる依頼なら、最後にプロジェクト直下へ ${RUN_DECL_FILE} を書く: {"cmd": "<起動コマンド>", "url": "<開くURL>", "note": "<何が見えるか1行>", "check": "<人に何を見て判断してほしいか1行>", "title": "<この作業のチケット名。対象が分かる程度に説明的で動作を含む短い名前。例「看板モードの追加」「ヘッダのズレの修正」。依頼文をそのまま写さない>"}。Manager はこれを使って人に「動かして見る」と「何を見ればいいか」を出す。動かして見せるものが無い依頼(調査・要約・質問への回答など)では書かなくてよい。`,
3217
+ // `file` (goal #744):起動不要な静的成果物(比較HTML、レポート等)専用。この
3218
+ // クラスの依頼は cmd/url を使う理由がなく、worker が唯一持っている手(file://
3219
+ // URL)は http(s) しか許さないバリデーションに毎回弾かれ、しかも弾かれた理由が
3220
+ // 「JSONが壊れている」という誤診断メッセージだったため、同じ file:// を書き直
3221
+ // して再度失敗するループが実際に2回連続で起きた(goal #744)。ここで file を
3222
+ // 明示し、file:// URLを書く選択肢自体を塞ぐ。
3223
+ `- 起動不要な静的ファイル(HTML比較ページ・レポート等、npm run devのようなコマンドを必要としないもの)を見せたい場合は、cmd/url でなく "file": "<プロジェクト内の相対パス>" を使う。例: {"file": "docs/design/options.html", "title": "3案の比較"}。file:// URLは絶対に書かない(urlはhttp(s)以外だと宣言ごと弾かれる)。`,
3154
3224
  // Root-cause prevention (goal #733, 2026-07-26): the worker Wrote this file,
3155
3225
  // then Edit'd it, and the edit broke the JSON mid-edit — Manager silently
3156
3226
  // dropped the whole declaration (fixed separately: it now surfaces an honest
@@ -3186,6 +3256,22 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3186
3256
  // the request is a change-something request, and most requests are not.
3187
3257
  agentName === 'codex' ? '' : `- 同じ画面の「変える前」と「変えた後」を撮ったなら、${RUN_DECL_FILE} に \`"compare": ["<前の画像>.png", "<後の画像>.png"]\` と書いてよい(その2枚が同じ場所の前後だと分かるのは撮った本人だけ)。人には並べて、違う部分に印を付けて見せる。3案を並べた等、前後の関係が無い画像には書かない。`,
3188
3258
  `- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
3259
+ // Root-cause fix (Masa 2026-07-26, goal #727 review): a worker fixed a real bug
3260
+ // (the To-Do lane's Doing ring had zero animation) but its report also claimed
3261
+ // the project-rail dot was "static" before — it actually already had a working
3262
+ // pulse animation (fsrundot), just not a spin. The worker never re-read the
3263
+ // pre-change code to check its own diagnosis; it narrated a plausible-sounding
3264
+ // "before" state instead of a verified one, and nothing downstream caught it.
3265
+ '- 「〜は動いていなかった/静止していた」のように直す前の状態を断定する場合は、その断定をする前に修正前の該当コード(該当CSS/関数)を実際に読んで確認すること。読まずに推測で「動いていない」と書かない。読んで確認した箇所は、報告に該当コードを短く引用すること。',
3266
+ // goal #751 (2026-07-26): you are a one-shot batch process — once this run exits,
3267
+ // nothing resumes you. If something outside your control blocks you (a lane/file
3268
+ // lock held by another agent, a missing permission, etc.), do NOT report that you
3269
+ // will "resume automatically once it frees up" or similar — that is not something
3270
+ // you or anything in this run can do. Say plainly that you made no code changes and
3271
+ // why, so the goal reads as unfinished/blocked rather than as a completed PR. A
3272
+ // human or the CTO will re-dispatch the work (e.g. via manager_reply) once the
3273
+ // blocker clears.
3274
+ '- あなたは1回きりのバッチ実行であり、このプロセスが終了した後に自動で再開することはない。レーン/ファイルの競合など自分では解けない理由で着手できない場合、「空き次第自動で再開します」のような、この実行の外では実現できない約束を書かないこと。何をどう変更できなかったか(コード変更0件であること)を正直に書き、ゴールが完了したPRではなく未完了/ブロックとして扱われるように終えること。再開は人間かCTOが改めて指示する。',
3189
3275
  ].filter(Boolean).join('\n');
3190
3276
  }
3191
3277
 
@@ -3203,6 +3289,37 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3203
3289
  // button that opens the wrong thing is the fabricated-proof bug all over again.
3204
3290
  export const RUN_DECL_FILE = '.manager-run.json';
3205
3291
 
3292
+ // A declaration that parsed as JSON but breaks a rule (bad url scheme, an
3293
+ // unsafe `file` path) is NOT the same failure as unparseable JSON — the first
3294
+ // is a worker mistake with a specific, sayable reason; the second is a broken
3295
+ // file. Conflating them (goal #744) sent the worker back a message that only
3296
+ // describes JSON corruption ("was not usable JSON"), so a worker whose actual
3297
+ // mistake was a file:// url read that as "my JSON broke" and rewrote the exact
3298
+ // same file:// url a second time. This return shape lets the caller tell them
3299
+ // apart: `null` stays "nothing usable to report" (unparseable / genuinely
3300
+ // empty), an `{ error, message }` object carries a reason a human — or the
3301
+ // worker reading its own feedback — can act on.
3302
+ function runDeclError(error, message) {
3303
+ return { error, message };
3304
+ }
3305
+
3306
+ // `file` (goal #744, this PR): a static, already-built artifact — an HTML
3307
+ // comparison page, a report — that needs no process and has no http(s) URL of
3308
+ // its own. Declared as a path relative to the project so the Manager can copy
3309
+ // it out of the worktree (same pattern as SHOT_DIR) and serve it from a URL
3310
+ // that survives after the worktree is gone. Only a syntactic check lives here
3311
+ // (no absolute path, no ".." segment, no backslash/drive letter, no NUL) —
3312
+ // server.mjs still re-resolves against the worktree root before it reads
3313
+ // anything, because a syntax check alone doesn't rule out a symlink.
3314
+ export function isSafeRunDeclFilePath(p) {
3315
+ if (typeof p !== 'string' || !p) return false;
3316
+ if (p.includes('\0')) return false;
3317
+ if (p.startsWith('/') || p.startsWith('\\')) return false;
3318
+ if (/^[a-zA-Z]:[\\/]/.test(p)) return false; // Windows drive letter
3319
+ const norm = p.replace(/\\/g, '/');
3320
+ return !norm.split('/').some((seg) => seg === '..');
3321
+ }
3322
+
3206
3323
  export function parseRunDeclaration(text) {
3207
3324
  let raw;
3208
3325
  try { raw = JSON.parse(String(text ?? '')); } catch { return null; }
@@ -3210,6 +3327,7 @@ export function parseRunDeclaration(text) {
3210
3327
  const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
3211
3328
  const cmd = str(raw.cmd, 500);
3212
3329
  const url = str(raw.url, 500);
3330
+ const file = str(raw.file, 500);
3213
3331
  // `compare` (Masa 2026-07-22): which TWO of the worker's own screenshots are
3214
3332
  // the same view before and after. Only the agent that did the work knows that
3215
3333
  // two images relate — a request for three design options has no "after" at
@@ -3219,9 +3337,16 @@ export function parseRunDeclaration(text) {
3219
3337
  const compare = parseComparePair(raw.compare);
3220
3338
  const title = str(raw.title, 60);
3221
3339
  // Nothing to start, nothing to open, nothing named, and no pair to show = nothing to look at.
3222
- if (!cmd && !url && !compare && !title) return null;
3223
- // Only http(s): the button opens this in the user's browser.
3224
- if (url && !/^https?:\/\//i.test(url)) return null;
3340
+ if (!cmd && !url && !file && !compare && !title) return null;
3341
+ // Only http(s): the button opens this in the user's browser. A file:// (or
3342
+ // any other scheme) is a worker mistake with a specific fix — declare `file`
3343
+ // instead — not a JSON parse failure, so it gets its own reason.
3344
+ if (url && !/^https?:\/\//i.test(url)) {
3345
+ return runDeclError('bad-url-scheme', "url must start with http:// or https:// — a file:// path can't be opened from a remote browser session; declare an artifact file instead of a file:// url");
3346
+ }
3347
+ if (file && !isSafeRunDeclFilePath(file)) {
3348
+ return runDeclError('bad-file-path', 'file must be a path inside the project — no leading /, no .. segments');
3349
+ }
3225
3350
  // `check` (Masa 2026-07-22): what to look at, in the working AI's own words. It used
3226
3351
  // to come from the intake AI's plan — this layer inventing a pass condition for a
3227
3352
  // request it only read. The AI that did the work states it here instead, in the same
@@ -3232,7 +3357,7 @@ export function parseRunDeclaration(text) {
3232
3357
  // the user's own sentence, repeated back at them in the one place that is
3233
3358
  // supposed to say what the work WAS. The agent that just did it can name it in
3234
3359
  // one line, and it is the only party that knows what actually got done.
3235
- return { cmd, url, note: str(raw.note, 300), check: str(raw.check, 300), compare, title };
3360
+ return { cmd, url, file, note: str(raw.note, 300), check: str(raw.check, 300), compare, title };
3236
3361
  }
3237
3362
 
3238
3363
  // Exactly two shot file names, in [before, after] order. Anything else is
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, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse, isCommandOnPath } from './lib.mjs';
24
- import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, 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, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession } from './lib.mjs';
24
+ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, 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, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession } from './lib.mjs';
25
25
  import { createSerialQueue } from './lib.mjs';
26
26
  import { isPrClosed } from './lib.mjs';
27
27
  import { collectProjectRules } from './lib.mjs';
@@ -2406,6 +2406,17 @@ function readRunDeclaration(task, workDir) {
2406
2406
  try { raw = readFileSync(file, 'utf8'); } catch { return; }
2407
2407
  try { unlinkSync(file); } catch { /* best effort — never fail a task over cleanup */ }
2408
2408
  const run = parseRunDeclaration(raw);
2409
+ // A declaration that parsed as JSON but broke a rule (bad url scheme, unsafe
2410
+ // file path) is a worker mistake with a specific, sayable fix — NOT the same
2411
+ // failure as unparseable JSON. Goal #744: conflating the two sent the worker
2412
+ // "was not usable JSON" for a file:// url, so it "fixed" the JSON (which was
2413
+ // never broken) and wrote the exact same file:// url a second time. `run` is
2414
+ // `{ error, message }` here, distinct from the `null` case below.
2415
+ if (run && run.error) {
2416
+ task.runDeclError = { reason: run.error, message: run.message, snippet: String(raw ?? '').slice(0, 200) };
2417
+ sendAct(task, `run declaration ignored — ${run.message}`);
2418
+ return;
2419
+ }
2409
2420
  if (!run) {
2410
2421
  // The worker DID try to declare something (the file exists) but it came out
2411
2422
  // unusable — e.g. a Write followed by an Edit that broke the JSON mid-edit
@@ -2417,17 +2428,62 @@ function readRunDeclaration(task, workDir) {
2417
2428
  // actually written (raw content is deleted above and never otherwise
2418
2429
  // recoverable) so both the activity log and the Review card can say WHY,
2419
2430
  // instead of the human having to guess or dig through file loss.
2420
- task.runDeclError = { snippet: String(raw ?? '').slice(0, 200) };
2431
+ task.runDeclError = { reason: 'invalid-json', snippet: String(raw ?? '').slice(0, 200) };
2421
2432
  sendAct(task, `run declaration ignored — ${RUN_DECL_FILE} was not usable JSON: ${task.runDeclError.snippet.slice(0, 80)}`);
2422
2433
  return;
2423
2434
  }
2424
2435
  task.run = { ...run, dir: workDir };
2425
- const how = run.cmd ?? run.url;
2436
+ collectRunDeclFileArtifact(task, workDir);
2437
+ const how = task.run.cmd ?? task.run.url;
2426
2438
  if (how) sendProcessAct(task, `Worker declared how to see the result: ${how}`);
2427
2439
  if (run.title) sendProcessAct(task, `Worker named the work: ${run.title}`);
2428
2440
  if (run.compare) sendProcessAct(task, `Worker declared a before/after pair: ${run.compare.join(' → ')}`);
2429
2441
  }
2430
2442
 
2443
+ // `file` (goal #744): a static, already-built artifact that needs no process
2444
+ // and has no http(s) URL of its own — an HTML comparison page, a report. Same
2445
+ // pattern as collectWorkerShots: copy it out of the worktree (which is about
2446
+ // to be diffed/discarded) into logDir/uploads and give it a URL that survives.
2447
+ // parseRunDeclaration already checked the path is syntactically inside the
2448
+ // project (no leading /, no ..); this re-resolves against the worktree root
2449
+ // before reading anything, because a syntax check alone doesn't rule out a
2450
+ // symlink pointing outside it.
2451
+ function collectRunDeclFileArtifact(task, workDir) {
2452
+ const rel = task.run?.file;
2453
+ if (!rel) return;
2454
+ const root = resolve(workDir) + '/';
2455
+ const abs = resolve(workDir, rel);
2456
+ if (!(abs + '/').startsWith(root) && abs !== root.slice(0, -1)) {
2457
+ task.runDeclError = { reason: 'file-outside-project', message: `file "${rel}" resolves outside the project`, snippet: rel };
2458
+ sendAct(task, `run declaration ignored — ${task.runDeclError.message}.`);
2459
+ task.run = { ...task.run, file: null };
2460
+ return;
2461
+ }
2462
+ let data;
2463
+ try { data = readFileSync(abs); } catch (err) {
2464
+ task.runDeclError = { reason: 'file-unreadable', message: `file "${rel}" could not be read: ${String(err?.code ?? err).slice(0, 80)}`, snippet: rel };
2465
+ sendAct(task, `run declaration's file "${rel}" could not be read: ${String(err?.code ?? err).slice(0, 80)}`);
2466
+ task.run = { ...task.run, file: null };
2467
+ return;
2468
+ }
2469
+ const outDir = join(logDir, 'uploads');
2470
+ mkdirSync(outDir, { recursive: true });
2471
+ const safe = basename(rel).replace(/[^\w.-]/g, '_').slice(-80);
2472
+ const outFile = `${Date.now()}-artifact-${task.id}-${safe}`;
2473
+ try {
2474
+ writeFileSync(join(outDir, outFile), data);
2475
+ } catch (err) {
2476
+ task.runDeclError = { reason: 'file-attach-failed', message: `file "${rel}" could not be attached: ${String(err?.code ?? err).slice(0, 80)}`, snippet: rel };
2477
+ sendAct(task, `run declaration's file "${rel}" could not be attached: ${String(err?.code ?? err).slice(0, 80)}`);
2478
+ task.run = { ...task.run, file: null };
2479
+ return;
2480
+ }
2481
+ // No process to start for a static file — the Review card renders this as a
2482
+ // plain link (like a PR link), not the "Start it" button.
2483
+ task.run = { ...task.run, url: `/upload/${outFile}`, cmd: null };
2484
+ sendProcessAct(task, `Worker attached a file to open: ${rel}`);
2485
+ }
2486
+
2431
2487
  // A direct/shared workspace can still contain a declaration left by an older
2432
2488
  // run (for example when that run was interrupted before readRunDeclaration).
2433
2489
  // Remove it before this task starts so only a file created by THIS worker can
@@ -2865,6 +2921,19 @@ async function runTask(task) {
2865
2921
  const after = await gitChanges(workDir);
2866
2922
  task.changedFiles = after.filter((l) => !before.includes(l)).map((l) => l.slice(3));
2867
2923
  sendProcessAct(task, `Detected ${task.changedFiles.length} changed file${task.changedFiles.length === 1 ? '' : 's'} from this task.`);
2924
+ // task 450: taskStatusAfterVerify decided 'done' from `code` alone, before
2925
+ // changedFiles existed to check against. Don't trust a clean exit code by
2926
+ // itself — a worker that changed nothing and left a cut-off-looking result
2927
+ // (goal #783 task 448: "I'll wait for this background full-suite run to
2928
+ // complete rather than poll.") did not actually finish. Downgrade to
2929
+ // 'interrupted' (retryable, surfaces in Attention) instead of silently
2930
+ // shipping it as done. Never touches a real 0-file report/investigation
2931
+ // task, whose result reads as a normal finished sentence.
2932
+ if (isSuspiciouslyIncompleteDone({ status: task.status, changedFiles: task.changedFiles, result: task.result, hasVerdict: Boolean(verdict) })) {
2933
+ task.status = 'interrupted';
2934
+ task.result = `[未完了の疑い: 変更ファイル0件かつ応答が途中で切れています] ${task.result}`.slice(0, RESULT_MAX);
2935
+ sendProcessAct(task, 'Result looked incomplete (0 changed files, cut-off text) — marked interrupted instead of done for human review.');
2936
+ }
2868
2937
  task.finishedAt = new Date().toISOString();
2869
2938
  task.resultAttemptId = task.attemptId;
2870
2939
  saveTask(task);
@@ -3532,6 +3601,18 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
3532
3601
  hasPassCondition: siblings.some((t) => t.passCondition),
3533
3602
  hasAnyProof: siblings.some((t) => t.proof),
3534
3603
  });
3604
+ // goal-751: wantsPR but the worker changed nothing and proved nothing (typically a
3605
+ // lane-conflict report promising an automatic resume that a one-shot `claude -p`
3606
+ // run can never deliver). Advisory only — see isNothingVerifiableForPR in lib.mjs.
3607
+ const prNothingVerifiable = isNothingVerifiableForPR({
3608
+ wantsPR: goal.wantsPR,
3609
+ changedFiles,
3610
+ hasAnyProof: siblings.some((t) => t.proof),
3611
+ });
3612
+ goal.prNote = prNothingVerifiable
3613
+ ? 'workerはコード変更をせずに終了しました(PRを求めていたのに0ファイル変更)。実装は行われていない可能性があります。'
3614
+ : null;
3615
+ if (goal.prNote) goalAct(`PR advisory (not blocking): ${goal.prNote}`);
3535
3616
  if (testsFailing) {
3536
3617
  const failure = recordGoalFailure(goal, {
3537
3618
  kind: 'test',
@@ -3608,7 +3689,7 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
3608
3689
  const preNote = goal.testResult?.preExistingOnly
3609
3690
  ? ` (${goal.testResult.preExisting?.length ?? 0} pre-existing test failure(s) ignored as unrelated to this change)`
3610
3691
  : '';
3611
- goalAct(`Verification gate passed; moving goal to ${goal.status}.${preNote}${goal.proofNote ? ` [proof advisory: ${goal.proofNote}]` : ''}`);
3692
+ goalAct(`Verification gate passed; moving goal to ${goal.status}.${preNote}${goal.proofNote ? ` [proof advisory: ${goal.proofNote}]` : ''}${goal.prNote ? ` [PR advisory: ${goal.prNote}]` : ''}`);
3612
3693
  }
3613
3694
  }
3614
3695
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.72",
3
+ "version": "0.10.74",
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": {