@galda/cli 0.10.38 → 0.10.39

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
@@ -590,6 +590,40 @@ export function isEngineeringPhaseTitle(title) {
590
590
  return ENGINEERING_PHASE_RE.test(String(title ?? ''));
591
591
  }
592
592
 
593
+ // ---- title = the user's REQUEST, not a completion report -------------------
594
+ // Masa (goal #474): the planner echoed a worker's finished-work sentence as the
595
+ // title —「アニメーションが終わってから歯車の位置を計算するように直した」— instead of
596
+ // naming what the user asked for. A title should read like the ask ("…のバグ修正"),
597
+ // never like a past-tense report of HOW it was implemented. Detect that shape so
598
+ // we can fall back to the user's own words. NARROW on purpose: only past-tense
599
+ // completion phrasing (…した/…ました, "…ように直/修/変") — a genuine request title
600
+ // never ends that way.
601
+ const IMPL_REPORT_RE = /(直し|修正し|変更し|追加し|対応し|実装し|計算する|するように|完了し)(た|ました)?/;
602
+ const PAST_DONE_RE = /(直した|修正した|変更した|追加した|対応した|実装した|しました|ました|するように直|するように修正|するように変更)/;
603
+ export function isImplementationReportTitle(title) {
604
+ const s = String(title ?? '').trim();
605
+ if (!s) return false;
606
+ // Report shape = past-tense completion OR "…するように<動詞>" (describes the how).
607
+ return PAST_DONE_RE.test(s) || (/するように/.test(s) && IMPL_REPORT_RE.test(s));
608
+ }
609
+
610
+ // Derive a short, user-worded title from the raw request text. Strips the polite
611
+ // request tail (…して/してください/直して/お願いします 等) so the title reads as the
612
+ // thing itself (「…バグ修正して」→「…バグ修正」) — the user's words, not a rephrase.
613
+ export function titleFromRequest(goalText, max = 60) {
614
+ let s = String(goalText ?? '').replace(/\s+/g, ' ').trim();
615
+ if (!s) return '';
616
+ // Drop a trailing polite/imperative request tail so the noun-phrase remains.
617
+ // Order matters: strip the courtesy suffix first, then the verb 直す ("…を直して"
618
+ // is a verb — drop the whole thing), then the する light verb ("…修正して" keeps
619
+ // the noun 修正, drops only して).
620
+ s = s.replace(/[\s、。,.,.!!??]*(?:お願いします|おねがいします|お願い|please)[。.!!]*$/iu, '')
621
+ .replace(/[\s、。,.,.!!??]*(?:を)?直して(?:ください|下さい|くれ|ほしい|欲しい)?[。.!!]*$/u, '')
622
+ .replace(/[\s、。,.,.!!??]*して(?:ください|下さい|くれ|ほしい|欲しい)?[。.!!]*$/u, '')
623
+ .trim();
624
+ return s.slice(0, max);
625
+ }
626
+
593
627
  // Reduce a planner's task list to USER-TOPIC granularity — the deterministic
594
628
  // backstop for when the LLM over-decomposes despite the prompt. Two moves, both
595
629
  // conservative so a distinct ask is never silently dropped:
@@ -603,7 +637,13 @@ export function isEngineeringPhaseTitle(title) {
603
637
  // worker still owns the full engineering breakdown internally.
604
638
  export function refinePlanTasks(tasks, { goalText, maxTasks = 4 } = {}) {
605
639
  const list = (tasks ?? []).filter((t) => t && String(t.title ?? '').trim());
606
- if (list.length <= 1) return list.slice(0, maxTasks);
640
+ // A title that reads as a completion report ("…するように直した") is replaced with
641
+ // the user's own request wording. Only safe when there's a single topic — with
642
+ // multiple distinct deliverables the goal text can't stand in for any one title.
643
+ const userTitled = (arr) => arr.length === 1 && isImplementationReportTitle(arr[0].title) && titleFromRequest(goalText)
644
+ ? [{ ...arr[0], title: titleFromRequest(goalText) }]
645
+ : arr;
646
+ if (list.length <= 1) return userTitled(list.slice(0, maxTasks));
607
647
  const real = list.filter((t) => !isEngineeringPhaseTitle(t.title));
608
648
  if (!real.length) {
609
649
  const title = String(goalText ?? list[0].title).replace(/\s+/g, ' ').trim().slice(0, 120);
@@ -611,7 +651,7 @@ export function refinePlanTasks(tasks, { goalText, maxTasks = 4 } = {}) {
611
651
  const passCondition = list.find((t) => t.passCondition)?.passCondition ?? null;
612
652
  return [{ title, detail, passCondition }];
613
653
  }
614
- return real.slice(0, maxTasks);
654
+ return userTitled(real.slice(0, maxTasks));
615
655
  }
616
656
 
617
657
  // ---- dedupe / merge overlapping goals --------------------------------------
@@ -870,8 +910,30 @@ export function buildGoalPrBody({ lang, goal, goalTasks, files, owner, branch, p
870
910
  L(`Implemented by Manager for AI: the goal was decomposed into ${goalTasks.length} task(s), each executed by a Claude Code worker (flat-rate, no extra API), tests written and run per task.${proofItemsCount ? ' Tasks with a pass condition were verified independently in a headless browser.' : ''}`,
871
911
  `Manager for AI が実装: ゴールを${goalTasks.length}個のタスクに分解し、Claude Code worker が順に実行(既存サブスク・追加API無し・タスク毎にテストを作成し実行)。${proofItemsCount ? '成功条件つきタスクは headless ブラウザで独立検証済み。' : ''}`),
872
912
  '',
913
+ // How the reviewer sees the thing for themselves, in the worker's own
914
+ // words. Above the reports because "run it and look" beats reading about
915
+ // it — and because for a request with nothing to run this section is simply
916
+ // absent rather than filled with a staged screenshot.
917
+ ...(() => {
918
+ const runs = goalTasks.filter((t) => t.run && (t.run.cmd || t.run.url));
919
+ if (!runs.length) return [];
920
+ return [
921
+ '',
922
+ L('## See it running', '## 動かして見る'),
923
+ '',
924
+ ...runs.flatMap((t) => [
925
+ t.run.note ? `${t.run.note}` : '',
926
+ t.run.cmd ? '```\n' + t.run.cmd + '\n```' : '',
927
+ t.run.url ? `→ ${t.run.url}` : '',
928
+ ].filter(Boolean)),
929
+ ];
930
+ })(),
931
+ '',
873
932
  L('## Tasks and worker reports', '## タスクとworker報告'),
874
- ...goalTasks.map((t) => `\n### ${t.num} ${t.title} ${t.status} (${t.secs}s)\n${(t.result ?? '').slice(0, 800)}`),
933
+ // 800 chars used to cut the worker's report mid-sentence. For a request
934
+ // that produces no runnable thing (research, a summary, an answer) that
935
+ // report IS the deliverable, so truncating it threw away the result.
936
+ ...goalTasks.map((t) => `\n### ${t.num} ${t.title} — ${t.status} (${t.secs}s)\n${(t.result ?? '').slice(0, 8000)}`),
875
937
  '',
876
938
  L(`Changed files: ${files.map((f) => `\`${f}\``).join(' ')}`, `変更ファイル: ${files.map((f) => `\`${f}\``).join(' ')}`),
877
939
  ].join('\n');
@@ -913,28 +975,6 @@ export function parseReviewSummary(raw, fallback = {}) {
913
975
  };
914
976
  }
915
977
 
916
- // A finished task's changed files look like a UI edit: app/ (the Manager's
917
- // own UI dir, or an equivalent frontend root) plus common frontend source
918
- // extensions. Pure/deterministic — no LLM — so it can gate proof capture
919
- // for EVERY UI-touching goal, not just ones that happened to get a
920
- // passCondition (Masa dogfood: "見た目の調整" goals had no capture at all).
921
- const UI_TOUCH_RE = /^app\/|\.(?:html|css|tsx|jsx|vue|svelte)$/i;
922
- export function isUiChange(changedFiles) {
923
- return (changedFiles ?? []).some((f) => UI_TOUCH_RE.test(String(f ?? '')));
924
- }
925
-
926
- // Whether a finished UI-touching task/goal needs a zero-LLM proof capture
927
- // (screenshot + short clip of the ephemeral branch instance): only when the
928
- // project can actually be booted & screenshotted (canCapture), nothing has
929
- // captured proof yet (hasProof), and the change really looks like UI
930
- // (isUiChange). This is the ADDED path for goals with no explicit
931
- // passCondition — the existing passCondition-based verify loop (runVerification
932
- // in verify.mjs) still runs and still asserts pass/fail; this only decides
933
- // whether to ALSO take a capture-only snapshot when that loop didn't run.
934
- export function shouldCaptureProof({ changedFiles, hasProof, canCapture }) {
935
- return Boolean(canCapture) && !hasProof && isUiChange(changedFiles);
936
- }
937
-
938
978
  const DOC_ONLY_RE = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/.*\.(md|mdx|txt)$|\.mdx?$|\.txt$/i;
939
979
  export function hasTestRelevantChanges(changedFiles) {
940
980
  const files = (changedFiles ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
@@ -1009,17 +1049,25 @@ export function classifyVerifyGate({
1009
1049
  return { blocked, kind, proofAdvisory };
1010
1050
  }
1011
1051
 
1012
- // Whether to ALSO capture a BASELINE ("before") shot for a before/after
1013
- // comparison. Only worth attempting when the baseline is a genuinely
1014
- // different checkout of the code than the one just captured as "after" — a
1015
- // goal whose worktree fell back to the shared project dir (no isolated
1016
- // worktree available) would otherwise "capture" the SAME already-changed
1017
- // code twice and label it before/after, which is not a comparison, it's a
1018
- // fabrication. Best-effort beyond that: gated separately by whether the
1019
- // baseline dir can itself be snapshotted (canCaptureBaseline).
1020
- export function shouldCaptureBaseline({ baselineDir, afterDir, canCaptureBaseline }) {
1021
- if (!baselineDir || !afterDir || baselineDir === afterDir) return false;
1022
- return Boolean(canCaptureBaseline);
1052
+ // Serializes async work so at most one item runs at a time. The goal-verify
1053
+ // pipeline uses this to stop two goals' `npm test` runs (each spins up real
1054
+ // headless Chrome + real servers) from ever overlapping running two full
1055
+ // suites at once was the actual cause of goal #552/#487 each blocking on a
1056
+ // DIFFERENT flaky test on every retry (2026-07-21 postmortem): the requested
1057
+ // feature was already correct, but the shared machine's CPU/ports/tmp-dirs
1058
+ // couldn't support two concurrent suites, so the baseline-diff gate never
1059
+ // saw the same failure twice in a row and never converged to "no new
1060
+ // failures". Queued work still runs even if an earlier item throws/rejects —
1061
+ // one bad run must not wedge every goal behind it forever.
1062
+ export function createSerialQueue() {
1063
+ let tail = Promise.resolve();
1064
+ return {
1065
+ run(fn) {
1066
+ const started = tail.then(fn, fn);
1067
+ tail = started.then(() => undefined, () => undefined);
1068
+ return started;
1069
+ },
1070
+ };
1023
1071
  }
1024
1072
 
1025
1073
  // Task 56: a project's review definition can carry a free-text `description`
@@ -1179,6 +1227,20 @@ export function goalGroupStatus(goalStatus, startedAt) {
1179
1227
  return 'done'; // includes 'reverted' (terminal)
1180
1228
  }
1181
1229
 
1230
+ // PRD §6.5 → Masa 2026-07-21: a folded goal no longer takes its own row in the Review
1231
+ // column. The standalone "→ #12 にまとめました" rows read as clutter ("まとめましたは
1232
+ // review欄にいらない"); the merge belongs inside the carrier review's detail, as a log of
1233
+ // what was folded into it. This selects the folded goals for a given carrier review (a
1234
+ // review can group several goalIds under one PR, so match against the whole set). Pure
1235
+ // so the browser mirror in app/index.html (non-module, can't import this) and the test
1236
+ // agree on exactly which goals a carrier review logs.
1237
+ export function foldedIntoReview(goals, targetGoalIds) {
1238
+ const ids = new Set((targetGoalIds ?? []).map(Number));
1239
+ return (goals ?? []).filter(
1240
+ (g) => g && g.status === 'folded' && g.foldedInto != null && ids.has(Number(g.foldedInto)),
1241
+ );
1242
+ }
1243
+
1182
1244
  // Label + CSS class for the small status chip shown next to a goal in the
1183
1245
  // goal list and goal detail thread — mirrored in app/index.html's GOAL_CHIP
1184
1246
  // since that inline script can't import this module.
@@ -2083,7 +2145,252 @@ const WORKER_BASH = [
2083
2145
  'cat', 'ls', 'head', 'tail', 'wc', 'find', 'grep', 'rg',
2084
2146
  'sed', 'awk', 'sort', 'uniq', 'diff', 'echo', 'which', 'env', 'pwd', 'date',
2085
2147
  ].map((c) => `Bash(${c}:*)`).join(',');
2086
- export const WORKER_TOOLS = `Edit,Write,Read,Glob,Grep,Skill,TodoWrite,${WORKER_BASH}`;
2148
+ // WebFetch/WebSearch: a plain Claude Code session has both, and a worker that
2149
+ // cannot open a page or look anything up fails a whole class of requests that
2150
+ // work locally ("この記事のとおりに直して", "現行の仕様を確認して"). They are
2151
+ // read-only, so they widen what the worker can SEE, not what it can change.
2152
+ export const WORKER_TOOLS = `Edit,Write,Read,Glob,Grep,Skill,TodoWrite,WebFetch,WebSearch,${WORKER_BASH}`;
2153
+
2154
+ // ---- goal → single task (demoted planner) ----------------------------------
2155
+ // P0b (Masa 2026-07-21): the planner no longer decomposes the request into
2156
+ // tasks. The request reaches the worker verbatim as ONE task; the worker
2157
+ // (Claude Code / Codex) decides its own breakdown and emits it as its own
2158
+ // TodoWrite, which the board surfaces. One card = one request.
2159
+
2160
+ // A short, single-line display label for the task, derived from the request
2161
+ // itself (no LLM rewrite). First non-empty line, trimmed and truncated.
2162
+ export function goalTaskTitle(text, max = 80) {
2163
+ const firstLine = String(text ?? '').split('\n').map((s) => s.trim()).find(Boolean) ?? '';
2164
+ return firstLine.length > max ? firstLine.slice(0, max - 1).trimEnd() + '…' : firstLine;
2165
+ }
2166
+
2167
+ // Build the single task that carries a goal's request verbatim to the worker.
2168
+ // `id`, `num`, `createdAt` are supplied by the caller (stateful/impure bits);
2169
+ // everything else is derived from the goal so this stays pure & testable.
2170
+ // `passCondition` rides along only when the user wrote an explicit one.
2171
+ export function buildGoalTask(goal, { id, num, passCondition = null, createdAt }) {
2172
+ return {
2173
+ id, num, goalId: goal.id, projectId: goal.projectId,
2174
+ title: goalTaskTitle(goal.text), detail: goal.text, passCondition,
2175
+ model: goal.model, effort: goal.effort, agent: goal.agent,
2176
+ mode: goal.mode ?? 'auto', skill: goal.skill ?? null,
2177
+ status: 'queued', createdAt, priority: '中',
2178
+ result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
2179
+ };
2180
+ }
2181
+
2182
+ // ---- worker prompt ---------------------------------------------------------
2183
+ // The instruction the worker (Claude Code / Codex) actually reads. Pure:
2184
+ // (task, goal, lastFailure, agentName) -> string, so it is unit-testable.
2185
+ // `agentName` is the resolved worker agent id ('codex' | 'claude-code'); the
2186
+ // caller resolves it (server keeps the WORKER_AGENTS table).
2187
+ //
2188
+ // P0a "un-lobotomy" (Masa 2026-07-21, goal #496): the harness used to (a) trim
2189
+ // the user's real request to 500 chars and bury it as "part of the overall
2190
+ // goal", and (b) inject suppressive RULES ("keep changes minimal", "make the
2191
+ // smallest safe change instead of expanding scope", "検証しすぎない…すぐ終える").
2192
+ // Together they turned "design 3 patterns as an artifact" into one minimal
2193
+ // tweak. North star: we never re-judge the request — the user's verbatim words
2194
+ // are the source of truth and the worker delivers exactly what they ask for.
2195
+ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code') {
2196
+ // Optional skill the user attached to this goal. Empirically (scratch worker,
2197
+ // haiku, custom fixture skill) the reliably-firing form is an explicit Skill-tool
2198
+ // instruction as the FIRST line; a bare "/name" first line did not consistently
2199
+ // trigger the skill in `claude -p`. See commit body for the run evidence.
2200
+ const skill = task?.skill ?? goal?.skill;
2201
+ const displayName = agentName === 'codex' ? 'Codex' : 'Claude Code';
2202
+ // Report back to the human in the SAME language they wrote the goal in (Masa
2203
+ // 2026-07-15: "撃った言語で返す"). JP characters → Japanese, otherwise English.
2204
+ const replyLang = detectRequestLanguage(goal?.text || task?.title || '') === 'ja' ? '日本語' : 'English';
2205
+ return [
2206
+ skill ? `FIRST, use the /${skill} skill via the Skill tool (invoke Skill with the "${skill}" skill) before doing anything else. Then carry out the task below.` : '',
2207
+ `You are a ${displayName} worker managed by "Manager for AI". Do the request below inside this project directory.`,
2208
+ goal?.priorFailureMemory?.length ? `\nMANAGER FAILURE MEMORY:\n${latestFailurePolicy(goal.priorFailureMemory)?.text}\nApply this prevention before starting.` : '',
2209
+ '',
2210
+ // The user's own words, verbatim and in full, are the source of truth.
2211
+ // TASK/DETAIL below is a paraphrase from the planner — when they differ,
2212
+ // follow the user's words.
2213
+ goal?.text ? `USER'S REQUEST (verbatim — the source of truth; deliver exactly this):\n${goal.text}` : '',
2214
+ '',
2215
+ `TASK ${task.num}: ${task.title}`,
2216
+ task.detail ? `DETAIL: ${task.detail}` : '',
2217
+ goal?.images?.length ? `ATTACHED IMAGES (absolute paths, use the Read tool to view them): ${goal.images.join(' ')}` : '',
2218
+ task.passCondition ? `\nEXPLICIT PASS CONDITION (verified externally by a headless browser after you finish — NOT by you): ${task.passCondition}` : '',
2219
+ lastFailure ? `\nPREVIOUS ATTEMPT FAILED EXTERNAL VERIFICATION: ${lastFailure}\nYour previous changes are on disk. Diagnose and fix.` : '',
2220
+ '',
2221
+ 'RULES:',
2222
+ '- Edit only files inside this directory and this project. Stay on this request; do not wander into unrelated work.',
2223
+ // Deliverable fidelity (replaces the old "make the smallest safe change
2224
+ // instead of expanding scope"): the request, not our fear of scope, sets
2225
+ // the size of the work.
2226
+ '- 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.',
2227
+ '- Keep the work bounded to the request: inspect only files needed; avoid broad repository scans unless they are necessary.',
2228
+ "- You may use the user's installed skills (Skill tool, e.g. /galda-doc) when one clearly fits the request.",
2229
+ // Token-frugality + the 10-min run cap: the worker used to run the WHOLE suite
2230
+ // (npm test) repeatedly, which the Manager ALSO runs at close-out — double work
2231
+ // that regularly overran the cap (→ a SIGTERM 'interrupted'). Write the test,
2232
+ // run only the RELEVANT file once, and leave the full-suite + verification to
2233
+ // the Manager. (Masa: worker が自前でテスト全回し→止まる、が最大の無駄。)
2234
+ '- 変更に対応するテストを書く。確認は該当テストファイル1つだけを1回実行する(例: node --test path/to/only-this.test.mjs)。プロジェクト全体の npm test を繰り返し回さない — 全体テストと最終検証は Manager 側が実行する。テストが適用できない変更は理由と手動確認手順を書く。',
2235
+ // Anti-wasteful-re-verify only — NOT a licence to do less. Once it works,
2236
+ // stop re-checking; but the deliverables above must still be produced in full.
2237
+ '- 動くと確認できたら十分(無駄な再検証でログを漁り続けない)。ただし依頼された成果物は削らず出し切ること。',
2238
+ '- Do not commit (git is handled by the Manager).',
2239
+ // How the human actually looks at the result. We never guess the run
2240
+ // command ourselves — guessing means branching on project type (Next.js vs
2241
+ // Python vs …), and that is the "smart PM" behaviour this layer must not
2242
+ // have. The only AI in the pipeline is the one that just did the work, so
2243
+ // it is the one that says how to run it. Requests with nothing to run
2244
+ // (research, summaries, answers) simply skip this — the worker decides,
2245
+ // never us.
2246
+ `- 人が実際に動かして結果を見られる依頼なら、最後にプロジェクト直下へ ${RUN_DECL_FILE} を書く: {"cmd": "<起動コマンド>", "url": "<開くURL>", "note": "<何が見えるか1行>", "check": "<人に何を見て判断してほしいか1行>"}。Manager はこれを使って人に「動かして見る」と「何を見ればいいか」を出す。動かして見せるものが無い依頼(調査・要約・質問への回答など)では書かなくてよい。`,
2247
+ // Links are rendered as links. The product cannot tell a real one from a
2248
+ // decorative one, so the only place this can be held is here.
2249
+ `- 自分が作っていない場所へのリンクを報告に書かない(存在しないURLや「詳しくはここ」のような飾りのリンクは、そのままリンクとして人に表示される)。`,
2250
+ // The camera. We say it exists and where the pictures go; we never say when
2251
+ // to use it — that is the worker's call, like the run declaration above.
2252
+ // Telling it "screenshot UI changes" would be this layer deciding what kind
2253
+ // of request it is, which is the behaviour that produced off-target proof.
2254
+ `- 画面・見た目のあるものを作ったなら、自分でスクリーンショットを撮って見せてよい: \`node ${SHOT_CLI} <URL または HTMLファイル> ${SHOT_DIR}/<名前>.png [--full] [--wait-for <CSSセレクタ>]\`(headless Chrome・前面に出ない)。${SHOT_DIR}/ に置いた画像は Manager が回収して人に見せる(diff には入らない)。撮ったものは Read で自分でも見返せる。`,
2255
+ `- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
2256
+ ].filter(Boolean).join('\n');
2257
+ }
2258
+
2259
+ // ---- RUN DECLARATION -------------------------------------------------------
2260
+ // The worker tells us how a human can run and look at what it built, by writing
2261
+ // RUN_DECL_FILE. This is the whole of P1's "動かして見られる状態": the evidence
2262
+ // is the real thing running, not a screenshot we staged.
2263
+ //
2264
+ // Why a file and not a line in the worker's report: writing a file is a tool
2265
+ // call the agent makes deliberately, where a report format is a wording habit
2266
+ // that drifts. And why the worker at all — the layer below (this one) must not
2267
+ // classify the request or the project to decide how to start it.
2268
+ //
2269
+ // Anything we cannot validate is dropped: no "run it" button is honest, a
2270
+ // button that opens the wrong thing is the fabricated-proof bug all over again.
2271
+ export const RUN_DECL_FILE = '.manager-run.json';
2272
+
2273
+ export function parseRunDeclaration(text) {
2274
+ let raw;
2275
+ try { raw = JSON.parse(String(text ?? '')); } catch { return null; }
2276
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
2277
+ const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
2278
+ const cmd = str(raw.cmd, 500);
2279
+ const url = str(raw.url, 500);
2280
+ // Nothing to start and nothing to open = nothing to look at.
2281
+ if (!cmd && !url) return null;
2282
+ // Only http(s): the button opens this in the user's browser.
2283
+ if (url && !/^https?:\/\//i.test(url)) return null;
2284
+ // `check` (Masa 2026-07-22): what to look at, in the working AI's own words. It used
2285
+ // to come from the intake AI's plan — this layer inventing a pass condition for a
2286
+ // request it only read. The AI that did the work states it here instead, in the same
2287
+ // file it already writes, and the review card shows that sentence verbatim.
2288
+ return { cmd, url, note: str(raw.note, 300), check: str(raw.check, 300) };
2289
+ }
2290
+
2291
+ // ---- SHOTS: the worker's own camera ----------------------------------------
2292
+ // The worker could build a screen and had no way to show it: WORKER_TOOLS has
2293
+ // no browser, so "見せて" could only ever come back as prose. That is one half
2294
+ // of the off-target-evidence problem (#496) — the other half was us staging the
2295
+ // picture ourselves, which is exactly what must not come back.
2296
+ //
2297
+ // So: hand the worker a camera and nothing else. `engine/shot.mjs` takes a URL
2298
+ // or a local file and writes a PNG in headless Chrome. This layer never decides
2299
+ // WHEN to shoot or WHAT to shoot — the worker that just did the work does, the
2300
+ // same way it already decides the run declaration above. No request-type
2301
+ // branching lives here.
2302
+ //
2303
+ // Shots land in SHOT_DIR inside the worktree and the Manager moves them out
2304
+ // before the diff is computed: they are a message to the Manager, not part of
2305
+ // the deliverable, so they must never reach the PR (same rule as RUN_DECL_FILE).
2306
+ export const SHOT_DIR = '.manager-shots';
2307
+
2308
+ // Absolute path to the camera, resolved from this file so it is correct in a
2309
+ // repo checkout and in an installed package alike (the worker runs in the
2310
+ // customer's worktree, which knows nothing about where the Manager lives).
2311
+ export const SHOT_CLI = (() => {
2312
+ const p = decodeURIComponent(new URL('shot.mjs', import.meta.url).pathname);
2313
+ return /^\/[A-Za-z]:/.test(p) ? p.slice(1) : p;
2314
+ })();
2315
+
2316
+ // Where Chrome might be. Env first so a customer with a non-standard install
2317
+ // (or a Linux box) can point at it; the rest are the default locations per
2318
+ // platform. Pure — the caller supplies `exists`, so this is testable.
2319
+ export function chromeCandidates(env = {}) {
2320
+ return [
2321
+ env.CHROME_PATH,
2322
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
2323
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
2324
+ '/usr/bin/google-chrome',
2325
+ '/usr/bin/chromium-browser',
2326
+ '/usr/bin/chromium',
2327
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
2328
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
2329
+ ].filter(Boolean);
2330
+ }
2331
+ export function pickChromePath(env = {}, exists = () => false) {
2332
+ return chromeCandidates(env).find((c) => exists(c)) ?? null;
2333
+ }
2334
+
2335
+ // `node <manager>/engine/shot.mjs <url|file> <out.png> [--width N] [--height N]
2336
+ // [--full] [--wait MS] [--wait-for SELECTOR]`
2337
+ // Returns { error } instead of throwing, so the CLI can print one clear line.
2338
+ export function parseShotArgs(argv = []) {
2339
+ const rest = [];
2340
+ const opt = { width: 1440, height: 900, full: false, wait: 0, waitFor: null };
2341
+ const num = (v, lo, hi) => {
2342
+ const n = Number(v);
2343
+ return Number.isFinite(n) && n >= lo && n <= hi ? Math.round(n) : null;
2344
+ };
2345
+ for (let i = 0; i < argv.length; i++) {
2346
+ const a = String(argv[i]);
2347
+ if (a === '--full') { opt.full = true; continue; }
2348
+ if (a === '--width' || a === '--height') {
2349
+ const n = num(argv[++i], 100, 4000);
2350
+ if (n == null) return { error: `${a} needs a number between 100 and 4000` };
2351
+ opt[a.slice(2)] = n; continue;
2352
+ }
2353
+ if (a === '--wait') {
2354
+ const n = num(argv[++i], 0, 30000);
2355
+ if (n == null) return { error: '--wait needs milliseconds between 0 and 30000' };
2356
+ opt.wait = n; continue;
2357
+ }
2358
+ if (a === '--wait-for') {
2359
+ const sel = String(argv[++i] ?? '').trim();
2360
+ if (!sel) return { error: '--wait-for needs a CSS selector' };
2361
+ opt.waitFor = sel; continue;
2362
+ }
2363
+ if (a.startsWith('--')) return { error: `unknown option ${a}` };
2364
+ rest.push(a);
2365
+ }
2366
+ const [target, out] = rest;
2367
+ if (!target || !out) return { error: 'usage: node engine/shot.mjs <url|file> <out.png> [--width N] [--height N] [--full] [--wait MS] [--wait-for SELECTOR]' };
2368
+ if (!/\.png$/i.test(out)) return { error: 'output must end in .png' };
2369
+ return { target, out, ...opt };
2370
+ }
2371
+
2372
+ // http(s) passes through; anything else is a local path → file:// URL.
2373
+ // `resolvePath` is injected so this stays pure and testable.
2374
+ export function shotTargetUrl(target, resolvePath = (p) => p) {
2375
+ const t = String(target ?? '').trim();
2376
+ if (!t) return null;
2377
+ if (/^(https?|file):\/\//i.test(t)) return t;
2378
+ const abs = String(resolvePath(t)).replace(/\\/g, '/');
2379
+ const withSlash = abs.startsWith('/') ? abs : `/${abs}`;
2380
+ return `file://${withSlash.split('/').map((seg, i) => (i === 0 ? seg : encodeURIComponent(seg))).join('/')}`;
2381
+ }
2382
+
2383
+ // What the Manager takes out of SHOT_DIR: images only, no subpaths or dotfiles
2384
+ // (they become filenames in the Manager's own store), capped so a runaway loop
2385
+ // cannot bloat the record.
2386
+ export const SHOT_MAX = 20;
2387
+ export function collectShotNames(names = []) {
2388
+ return names
2389
+ .filter((n) => typeof n === 'string' && /\.(png|jpe?g|gif|webp)$/i.test(n))
2390
+ .filter((n) => !n.includes('/') && !n.includes('\\') && !n.startsWith('.'))
2391
+ .sort()
2392
+ .slice(0, SHOT_MAX);
2393
+ }
2087
2394
 
2088
2395
  // ---- SKILL picker ----------------------------------------------------------
2089
2396
  // Parse the YAML frontmatter of a SKILL.md / command .md and pull out `name`