@galda/cli 0.10.71 → 0.10.73

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)] };
@@ -2254,6 +2270,21 @@ export function isNothingVerifiable({ changedFiles = [], hasPassCondition = fals
2254
2270
  return hasPassCondition && changedFiles.length === 0 && !hasAnyProof;
2255
2271
  }
2256
2272
 
2273
+ // goal-751 (2026-07-26): a wantsPR goal is an explicit ask for a PR, so a worker
2274
+ // that ends with zero changed files and no proof did not deliver what was asked —
2275
+ // most often a lane-conflict report ("空き次第自動再開します") that cannot actually
2276
+ // happen, since a `claude -p` worker is a one-shot batch run with no way to resume
2277
+ // itself later. taskStatusAfterVerify still reads a clean exit (code===0, no
2278
+ // verdict) as 'done', so this reached Review looking exactly like a normal
2279
+ // completed PR. Same shape as isNothingVerifiable above but keyed on wantsPR
2280
+ // instead of passCondition (a wantsPR goal has no passCondition of its own — the
2281
+ // PR ask itself is the promise). Advisory only, like proofNote/verificationInfra
2282
+ // Error below: the caller sets a note, it never blocks (Failedを作らない方針, see
2283
+ // docs/HANDOFF-2026-07-25-verify-gate-default-off.md).
2284
+ export function isNothingVerifiableForPR({ wantsPR = false, changedFiles = [], hasAnyProof = false } = {}) {
2285
+ return Boolean(wantsPR) && changedFiles.length === 0 && !hasAnyProof;
2286
+ }
2287
+
2257
2288
  // チェックリストUIで要件が全件Approveされたら goal を review→done へ進める。
2258
2289
  // 以前は「PRありgoalはマージが唯一の完了トリガー」として弾いていたが、それだと
2259
2290
  // 承認しても done が永続化されず次のrefreshで review に戻る不具合になっていた
@@ -2542,10 +2573,10 @@ export function hasGitChanges(porcelainStatus) {
2542
2573
  return Boolean(String(porcelainStatus ?? '').trim());
2543
2574
  }
2544
2575
 
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';
2576
+ // Direct workspace is retired (2026-07-26): two accidents where a stray client-side
2577
+ // toggle edited a shared checkout directly. This always returns isolated-worktree
2578
+ // regardless of what a client sends, so a stale/tampered client can't force direct.
2579
+ export function resolveWorkspaceMode(_input) {
2549
2580
  return 'isolated-worktree';
2550
2581
  }
2551
2582
 
@@ -3151,8 +3182,28 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3151
3182
  // (research, summaries, answers) simply skip this — the worker decides,
3152
3183
  // never us.
3153
3184
  `- 人が実際に動かして結果を見られる依頼なら、最後にプロジェクト直下へ ${RUN_DECL_FILE} を書く: {"cmd": "<起動コマンド>", "url": "<開くURL>", "note": "<何が見えるか1行>", "check": "<人に何を見て判断してほしいか1行>", "title": "<この作業のチケット名。対象が分かる程度に説明的で動作を含む短い名前。例「看板モードの追加」「ヘッダのズレの修正」。依頼文をそのまま写さない>"}。Manager はこれを使って人に「動かして見る」と「何を見ればいいか」を出す。動かして見せるものが無い依頼(調査・要約・質問への回答など)では書かなくてよい。`,
3185
+ // `file` (goal #744):起動不要な静的成果物(比較HTML、レポート等)専用。この
3186
+ // クラスの依頼は cmd/url を使う理由がなく、worker が唯一持っている手(file://
3187
+ // URL)は http(s) しか許さないバリデーションに毎回弾かれ、しかも弾かれた理由が
3188
+ // 「JSONが壊れている」という誤診断メッセージだったため、同じ file:// を書き直
3189
+ // して再度失敗するループが実際に2回連続で起きた(goal #744)。ここで file を
3190
+ // 明示し、file:// URLを書く選択肢自体を塞ぐ。
3191
+ `- 起動不要な静的ファイル(HTML比較ページ・レポート等、npm run devのようなコマンドを必要としないもの)を見せたい場合は、cmd/url でなく "file": "<プロジェクト内の相対パス>" を使う。例: {"file": "docs/design/options.html", "title": "3案の比較"}。file:// URLは絶対に書かない(urlはhttp(s)以外だと宣言ごと弾かれる)。`,
3192
+ // Root-cause prevention (goal #733, 2026-07-26): the worker Wrote this file,
3193
+ // then Edit'd it, and the edit broke the JSON mid-edit — Manager silently
3194
+ // dropped the whole declaration (fixed separately: it now surfaces an honest
3195
+ // note instead of vanishing). But the honest note is a recovery, not a fix —
3196
+ // the real fix is stopping the corruption before it happens. Edit requires an
3197
+ // exact byte-for-byte match against the file's current content; a small slip
3198
+ // (a stray character, a stale in-memory copy) breaks the JSON with no error
3199
+ // the worker would notice, because it happens in the tool call, not in
3200
+ // anything it reads back. Write overwrites the whole file atomically — there
3201
+ // is no "current content" to mismatch against — so telling the worker to
3202
+ // always fully overwrite rather than partially edit removes the failure mode
3203
+ // structurally, not just its symptom.
3204
+ `- ${RUN_DECL_FILE} は毎回 Write で全文を書き直すこと。Edit で一部だけ直さない(既存内容と一致しないと壊れたJSONになり、Managerに握りつぶされて人に何も表示されない)。`,
3154
3205
  `- Markdown・設計文書・調査メモなど文章そのものが成果物なら、${RUN_DECL_FILE} に別のHTMLや過去のURLを書かない。最終回答に成果物の本文も貼り、保存先のMarkdownリンクを添えること。「ファイルに書きました」だけで終わらない。`,
3155
- `- 「ローカルで見れない」というフィードバックには、ターミナル操作や npx の再実行を案内して終わらないこと。${RUN_DECL_FILE} を実際に開ける内容へ更新し、可能なら自分でURLへ接続確認してから完了すること。`,
3206
+ `- 「ローカルで見れない」というフィードバックには、ターミナル操作や npx の再実行を案内して終わらないこと。${RUN_DECL_FILE} を Write で全文書き直して実際に開ける内容へ更新し、可能なら自分でURLへ接続確認してから完了すること。`,
3156
3207
  // Links are rendered as links. The product cannot tell a real one from a
3157
3208
  // decorative one, so the only place this can be held is here.
3158
3209
  `- 自分が作っていない場所へのリンクを報告に書かない(存在しないURLや「詳しくはここ」のような飾りのリンクは、そのままリンクとして人に表示される)。`,
@@ -3173,6 +3224,22 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3173
3224
  // the request is a change-something request, and most requests are not.
3174
3225
  agentName === 'codex' ? '' : `- 同じ画面の「変える前」と「変えた後」を撮ったなら、${RUN_DECL_FILE} に \`"compare": ["<前の画像>.png", "<後の画像>.png"]\` と書いてよい(その2枚が同じ場所の前後だと分かるのは撮った本人だけ)。人には並べて、違う部分に印を付けて見せる。3案を並べた等、前後の関係が無い画像には書かない。`,
3175
3226
  `- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
3227
+ // Root-cause fix (Masa 2026-07-26, goal #727 review): a worker fixed a real bug
3228
+ // (the To-Do lane's Doing ring had zero animation) but its report also claimed
3229
+ // the project-rail dot was "static" before — it actually already had a working
3230
+ // pulse animation (fsrundot), just not a spin. The worker never re-read the
3231
+ // pre-change code to check its own diagnosis; it narrated a plausible-sounding
3232
+ // "before" state instead of a verified one, and nothing downstream caught it.
3233
+ '- 「〜は動いていなかった/静止していた」のように直す前の状態を断定する場合は、その断定をする前に修正前の該当コード(該当CSS/関数)を実際に読んで確認すること。読まずに推測で「動いていない」と書かない。読んで確認した箇所は、報告に該当コードを短く引用すること。',
3234
+ // goal #751 (2026-07-26): you are a one-shot batch process — once this run exits,
3235
+ // nothing resumes you. If something outside your control blocks you (a lane/file
3236
+ // lock held by another agent, a missing permission, etc.), do NOT report that you
3237
+ // will "resume automatically once it frees up" or similar — that is not something
3238
+ // you or anything in this run can do. Say plainly that you made no code changes and
3239
+ // why, so the goal reads as unfinished/blocked rather than as a completed PR. A
3240
+ // human or the CTO will re-dispatch the work (e.g. via manager_reply) once the
3241
+ // blocker clears.
3242
+ '- あなたは1回きりのバッチ実行であり、このプロセスが終了した後に自動で再開することはない。レーン/ファイルの競合など自分では解けない理由で着手できない場合、「空き次第自動で再開します」のような、この実行の外では実現できない約束を書かないこと。何をどう変更できなかったか(コード変更0件であること)を正直に書き、ゴールが完了したPRではなく未完了/ブロックとして扱われるように終えること。再開は人間かCTOが改めて指示する。',
3176
3243
  ].filter(Boolean).join('\n');
3177
3244
  }
3178
3245
 
@@ -3190,6 +3257,37 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code',
3190
3257
  // button that opens the wrong thing is the fabricated-proof bug all over again.
3191
3258
  export const RUN_DECL_FILE = '.manager-run.json';
3192
3259
 
3260
+ // A declaration that parsed as JSON but breaks a rule (bad url scheme, an
3261
+ // unsafe `file` path) is NOT the same failure as unparseable JSON — the first
3262
+ // is a worker mistake with a specific, sayable reason; the second is a broken
3263
+ // file. Conflating them (goal #744) sent the worker back a message that only
3264
+ // describes JSON corruption ("was not usable JSON"), so a worker whose actual
3265
+ // mistake was a file:// url read that as "my JSON broke" and rewrote the exact
3266
+ // same file:// url a second time. This return shape lets the caller tell them
3267
+ // apart: `null` stays "nothing usable to report" (unparseable / genuinely
3268
+ // empty), an `{ error, message }` object carries a reason a human — or the
3269
+ // worker reading its own feedback — can act on.
3270
+ function runDeclError(error, message) {
3271
+ return { error, message };
3272
+ }
3273
+
3274
+ // `file` (goal #744, this PR): a static, already-built artifact — an HTML
3275
+ // comparison page, a report — that needs no process and has no http(s) URL of
3276
+ // its own. Declared as a path relative to the project so the Manager can copy
3277
+ // it out of the worktree (same pattern as SHOT_DIR) and serve it from a URL
3278
+ // that survives after the worktree is gone. Only a syntactic check lives here
3279
+ // (no absolute path, no ".." segment, no backslash/drive letter, no NUL) —
3280
+ // server.mjs still re-resolves against the worktree root before it reads
3281
+ // anything, because a syntax check alone doesn't rule out a symlink.
3282
+ export function isSafeRunDeclFilePath(p) {
3283
+ if (typeof p !== 'string' || !p) return false;
3284
+ if (p.includes('\0')) return false;
3285
+ if (p.startsWith('/') || p.startsWith('\\')) return false;
3286
+ if (/^[a-zA-Z]:[\\/]/.test(p)) return false; // Windows drive letter
3287
+ const norm = p.replace(/\\/g, '/');
3288
+ return !norm.split('/').some((seg) => seg === '..');
3289
+ }
3290
+
3193
3291
  export function parseRunDeclaration(text) {
3194
3292
  let raw;
3195
3293
  try { raw = JSON.parse(String(text ?? '')); } catch { return null; }
@@ -3197,6 +3295,7 @@ export function parseRunDeclaration(text) {
3197
3295
  const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
3198
3296
  const cmd = str(raw.cmd, 500);
3199
3297
  const url = str(raw.url, 500);
3298
+ const file = str(raw.file, 500);
3200
3299
  // `compare` (Masa 2026-07-22): which TWO of the worker's own screenshots are
3201
3300
  // the same view before and after. Only the agent that did the work knows that
3202
3301
  // two images relate — a request for three design options has no "after" at
@@ -3206,9 +3305,16 @@ export function parseRunDeclaration(text) {
3206
3305
  const compare = parseComparePair(raw.compare);
3207
3306
  const title = str(raw.title, 60);
3208
3307
  // Nothing to start, nothing to open, nothing named, and no pair to show = nothing to look at.
3209
- if (!cmd && !url && !compare && !title) return null;
3210
- // Only http(s): the button opens this in the user's browser.
3211
- if (url && !/^https?:\/\//i.test(url)) return null;
3308
+ if (!cmd && !url && !file && !compare && !title) return null;
3309
+ // Only http(s): the button opens this in the user's browser. A file:// (or
3310
+ // any other scheme) is a worker mistake with a specific fix — declare `file`
3311
+ // instead — not a JSON parse failure, so it gets its own reason.
3312
+ if (url && !/^https?:\/\//i.test(url)) {
3313
+ 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");
3314
+ }
3315
+ if (file && !isSafeRunDeclFilePath(file)) {
3316
+ return runDeclError('bad-file-path', 'file must be a path inside the project — no leading /, no .. segments');
3317
+ }
3212
3318
  // `check` (Masa 2026-07-22): what to look at, in the working AI's own words. It used
3213
3319
  // to come from the intake AI's plan — this layer inventing a pass condition for a
3214
3320
  // request it only read. The AI that did the work states it here instead, in the same
@@ -3219,7 +3325,7 @@ export function parseRunDeclaration(text) {
3219
3325
  // the user's own sentence, repeated back at them in the one place that is
3220
3326
  // supposed to say what the work WAS. The agent that just did it can name it in
3221
3327
  // one line, and it is the only party that knows what actually got done.
3222
- return { cmd, url, note: str(raw.note, 300), check: str(raw.check, 300), compare, title };
3328
+ return { cmd, url, file, note: str(raw.note, 300), check: str(raw.check, 300), compare, title };
3223
3329
  }
3224
3330
 
3225
3331
  // 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, 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,17 +2406,84 @@ 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
- sendAct(task, `run declaration ignored ${RUN_DECL_FILE} was not usable JSON`);
2421
+ // The worker DID try to declare something (the file exists) but it came out
2422
+ // unusable — e.g. a Write followed by an Edit that broke the JSON mid-edit
2423
+ // (goal #733, 2026-07-26: Masa saw a working artifact with no "Open it" and
2424
+ // no explanation at all — the Review card just silently had no section,
2425
+ // indistinguishable from "nothing to run"). Silence was already the honest
2426
+ // answer for a request with nothing to run; it stops being honest the moment
2427
+ // a declaration was attempted and swallowed. Keep a short snippet of what was
2428
+ // actually written (raw content is deleted above and never otherwise
2429
+ // recoverable) so both the activity log and the Review card can say WHY,
2430
+ // instead of the human having to guess or dig through file loss.
2431
+ task.runDeclError = { reason: 'invalid-json', snippet: String(raw ?? '').slice(0, 200) };
2432
+ sendAct(task, `run declaration ignored — ${RUN_DECL_FILE} was not usable JSON: ${task.runDeclError.snippet.slice(0, 80)}`);
2411
2433
  return;
2412
2434
  }
2413
2435
  task.run = { ...run, dir: workDir };
2414
- const how = run.cmd ?? run.url;
2436
+ collectRunDeclFileArtifact(task, workDir);
2437
+ const how = task.run.cmd ?? task.run.url;
2415
2438
  if (how) sendProcessAct(task, `Worker declared how to see the result: ${how}`);
2416
2439
  if (run.title) sendProcessAct(task, `Worker named the work: ${run.title}`);
2417
2440
  if (run.compare) sendProcessAct(task, `Worker declared a before/after pair: ${run.compare.join(' → ')}`);
2418
2441
  }
2419
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
+
2420
2487
  // A direct/shared workspace can still contain a declaration left by an older
2421
2488
  // run (for example when that run was interrupted before readRunDeclaration).
2422
2489
  // Remove it before this task starts so only a file created by THIS worker can
@@ -3521,6 +3588,18 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
3521
3588
  hasPassCondition: siblings.some((t) => t.passCondition),
3522
3589
  hasAnyProof: siblings.some((t) => t.proof),
3523
3590
  });
3591
+ // goal-751: wantsPR but the worker changed nothing and proved nothing (typically a
3592
+ // lane-conflict report promising an automatic resume that a one-shot `claude -p`
3593
+ // run can never deliver). Advisory only — see isNothingVerifiableForPR in lib.mjs.
3594
+ const prNothingVerifiable = isNothingVerifiableForPR({
3595
+ wantsPR: goal.wantsPR,
3596
+ changedFiles,
3597
+ hasAnyProof: siblings.some((t) => t.proof),
3598
+ });
3599
+ goal.prNote = prNothingVerifiable
3600
+ ? 'workerはコード変更をせずに終了しました(PRを求めていたのに0ファイル変更)。実装は行われていない可能性があります。'
3601
+ : null;
3602
+ if (goal.prNote) goalAct(`PR advisory (not blocking): ${goal.prNote}`);
3524
3603
  if (testsFailing) {
3525
3604
  const failure = recordGoalFailure(goal, {
3526
3605
  kind: 'test',
@@ -3597,7 +3676,7 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
3597
3676
  const preNote = goal.testResult?.preExistingOnly
3598
3677
  ? ` (${goal.testResult.preExisting?.length ?? 0} pre-existing test failure(s) ignored as unrelated to this change)`
3599
3678
  : '';
3600
- goalAct(`Verification gate passed; moving goal to ${goal.status}.${preNote}${goal.proofNote ? ` [proof advisory: ${goal.proofNote}]` : ''}`);
3679
+ goalAct(`Verification gate passed; moving goal to ${goal.status}.${preNote}${goal.proofNote ? ` [proof advisory: ${goal.proofNote}]` : ''}${goal.prNote ? ` [PR advisory: ${goal.prNote}]` : ''}`);
3601
3680
  }
3602
3681
  }
3603
3682
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.71",
3
+ "version": "0.10.73",
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": {