@galda/cli 0.10.38 → 0.10.40

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
@@ -534,6 +534,96 @@ export function parseStreamEvents(line) {
534
534
  return out;
535
535
  }
536
536
 
537
+ // ---- CODEX BRIDGE ----------------------------------------------------------
538
+ // The same three wires the product is built out of — the board (the agent's own
539
+ // to-dos), the implementation log (what it actually did), and the thread it can
540
+ // be talked to again in — read from `codex exec --json` instead of `claude -p
541
+ // --output-format stream-json`. Everything above this line stays agent-agnostic:
542
+ // the bridge's whole job is to speak Codex's event vocabulary into the one this
543
+ // engine already has, so nothing downstream branches on which agent ran.
544
+ //
545
+ // The shapes below are transcribed from a real `codex exec --json` run
546
+ // (codex-cli 0.142.4) — see engine/test/codex-bridge.test.mjs, whose fixtures
547
+ // are that run's stdout verbatim rather than a schema we imagined.
548
+ export function parseCodexTodos(items) {
549
+ return (Array.isArray(items) ? items : [])
550
+ .map((i) => ({ content: String(i?.text ?? '').trim(), status: i?.completed ? 'completed' : 'pending' }))
551
+ .filter((t) => t.content);
552
+ }
553
+
554
+ // Codex reports usage with its own key names AND its own arithmetic: its
555
+ // `input_tokens` INCLUDES the cached ones, where claude reports cache reads as a
556
+ // separate bucket that input_tokens excludes. Adding both as-is counted the
557
+ // cached tokens twice, so the ledger showed a Codex run as far more expensive
558
+ // than it was (measured: ~92k of a ~108k turn was cache). Subtract, so a number
559
+ // from either agent means the same thing.
560
+ export function parseCodexUsage(usage) {
561
+ if (!usage) return null;
562
+ const total = usage.input_tokens ?? usage.inputTokens ?? 0;
563
+ const cached = usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0;
564
+ return {
565
+ input_tokens: Math.max(0, total - cached),
566
+ output_tokens: usage.output_tokens ?? usage.outputTokens ?? 0,
567
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
568
+ cache_read_input_tokens: cached,
569
+ };
570
+ }
571
+
572
+ // Parse one line of `codex exec --json` output into the same event vocabulary
573
+ // parseStreamEvents returns ('session' | 'todos' | 'activity' | 'message' |
574
+ // 'usage'). 'message' rather than 'result' because Codex emits an agent_message
575
+ // per narration step, not once at the end — the caller keeps the last one.
576
+ export function parseCodexEvents(line) {
577
+ let ev;
578
+ try { ev = JSON.parse(line); } catch { return []; }
579
+ if (!ev || typeof ev !== 'object') return [];
580
+ if (ev.type === 'thread.started' && ev.thread_id) return [{ kind: 'session', id: String(ev.thread_id) }];
581
+ if (ev.type === 'turn.completed') return [{ kind: 'usage', usage: parseCodexUsage(ev.usage) }];
582
+ if (ev.type === 'error' && ev.message) return [{ kind: 'error', text: String(ev.message) }];
583
+ if (!['item.started', 'item.updated', 'item.completed'].includes(ev.type)) return [];
584
+ const item = ev.item ?? {};
585
+ if (item.type === 'todo_list') {
586
+ const todos = parseCodexTodos(item.items);
587
+ return todos.length ? [{ kind: 'todos', todos }] : [];
588
+ }
589
+ if (item.type === 'agent_message' && ev.type === 'item.completed' && item.text) {
590
+ return [{ kind: 'message', text: String(item.text) }];
591
+ }
592
+ // Commands are announced when they start (the log should move while the
593
+ // worker works, not after) and only mentioned again when they fail.
594
+ if (item.type === 'command_execution' && item.command) {
595
+ if (ev.type === 'item.started') return [{ kind: 'activity', text: `Run ${String(item.command)}`.slice(0, 140) }];
596
+ if (ev.type === 'item.completed' && item.exit_code) {
597
+ return [{ kind: 'activity', text: `Run ${String(item.command)} (exit ${item.exit_code})`.slice(0, 140) }];
598
+ }
599
+ return [];
600
+ }
601
+ // File edits are the bulk of what a worker does and were entirely missing from
602
+ // the log on Codex — the run read as "a few commands and then, somehow, a PR".
603
+ if (item.type === 'file_change' && ev.type === 'item.completed') {
604
+ const verb = { add: 'Write', delete: 'Delete' };
605
+ return (Array.isArray(item.changes) ? item.changes : [])
606
+ .filter((c) => c?.path)
607
+ .slice(0, 8)
608
+ .map((c) => ({ kind: 'activity', text: `${verb[c.kind] ?? 'Edit'} ${String(c.path)}`.slice(0, 140) }));
609
+ }
610
+ if (item.type === 'error' && item.message && ev.type === 'item.completed') {
611
+ return [{ kind: 'error', text: String(item.message) }];
612
+ }
613
+ return [];
614
+ }
615
+
616
+ // Argument list for one `codex exec` run. `resume` continues an existing thread
617
+ // (the id from a previous run's thread.started) — that subcommand takes neither
618
+ // --cd nor --sandbox, so the working directory comes from the spawn's cwd and
619
+ // the sandbox from -c sandbox_mode. Verified against codex-cli 0.142.4.
620
+ export function buildCodexArgs({ model, effort, sandbox, cwd, resume } = {}) {
621
+ const common = ['--json', '-m', model, '-c', `model_reasoning_effort="${effort}"`, '--skip-git-repo-check'];
622
+ return resume
623
+ ? ['exec', 'resume', String(resume), ...common, '-c', `sandbox_mode="${sandbox}"`, '-']
624
+ : ['exec', ...common, '--sandbox', sandbox, '--cd', cwd, '-'];
625
+ }
626
+
537
627
  // Parse the planner's reply into { entry, tasks }. Accepts either a JSON
538
628
  // object {entry, tasks:[...]} or a bare JSON array anywhere in the text;
539
629
  // falls back to a single task made from the raw request. Each task may carry
@@ -590,6 +680,40 @@ export function isEngineeringPhaseTitle(title) {
590
680
  return ENGINEERING_PHASE_RE.test(String(title ?? ''));
591
681
  }
592
682
 
683
+ // ---- title = the user's REQUEST, not a completion report -------------------
684
+ // Masa (goal #474): the planner echoed a worker's finished-work sentence as the
685
+ // title —「アニメーションが終わってから歯車の位置を計算するように直した」— instead of
686
+ // naming what the user asked for. A title should read like the ask ("…のバグ修正"),
687
+ // never like a past-tense report of HOW it was implemented. Detect that shape so
688
+ // we can fall back to the user's own words. NARROW on purpose: only past-tense
689
+ // completion phrasing (…した/…ました, "…ように直/修/変") — a genuine request title
690
+ // never ends that way.
691
+ const IMPL_REPORT_RE = /(直し|修正し|変更し|追加し|対応し|実装し|計算する|するように|完了し)(た|ました)?/;
692
+ const PAST_DONE_RE = /(直した|修正した|変更した|追加した|対応した|実装した|しました|ました|するように直|するように修正|するように変更)/;
693
+ export function isImplementationReportTitle(title) {
694
+ const s = String(title ?? '').trim();
695
+ if (!s) return false;
696
+ // Report shape = past-tense completion OR "…するように<動詞>" (describes the how).
697
+ return PAST_DONE_RE.test(s) || (/するように/.test(s) && IMPL_REPORT_RE.test(s));
698
+ }
699
+
700
+ // Derive a short, user-worded title from the raw request text. Strips the polite
701
+ // request tail (…して/してください/直して/お願いします 等) so the title reads as the
702
+ // thing itself (「…バグ修正して」→「…バグ修正」) — the user's words, not a rephrase.
703
+ export function titleFromRequest(goalText, max = 60) {
704
+ let s = String(goalText ?? '').replace(/\s+/g, ' ').trim();
705
+ if (!s) return '';
706
+ // Drop a trailing polite/imperative request tail so the noun-phrase remains.
707
+ // Order matters: strip the courtesy suffix first, then the verb 直す ("…を直して"
708
+ // is a verb — drop the whole thing), then the する light verb ("…修正して" keeps
709
+ // the noun 修正, drops only して).
710
+ s = s.replace(/[\s、。,.,.!!??]*(?:お願いします|おねがいします|お願い|please)[。.!!]*$/iu, '')
711
+ .replace(/[\s、。,.,.!!??]*(?:を)?直して(?:ください|下さい|くれ|ほしい|欲しい)?[。.!!]*$/u, '')
712
+ .replace(/[\s、。,.,.!!??]*して(?:ください|下さい|くれ|ほしい|欲しい)?[。.!!]*$/u, '')
713
+ .trim();
714
+ return s.slice(0, max);
715
+ }
716
+
593
717
  // Reduce a planner's task list to USER-TOPIC granularity — the deterministic
594
718
  // backstop for when the LLM over-decomposes despite the prompt. Two moves, both
595
719
  // conservative so a distinct ask is never silently dropped:
@@ -603,7 +727,13 @@ export function isEngineeringPhaseTitle(title) {
603
727
  // worker still owns the full engineering breakdown internally.
604
728
  export function refinePlanTasks(tasks, { goalText, maxTasks = 4 } = {}) {
605
729
  const list = (tasks ?? []).filter((t) => t && String(t.title ?? '').trim());
606
- if (list.length <= 1) return list.slice(0, maxTasks);
730
+ // A title that reads as a completion report ("…するように直した") is replaced with
731
+ // the user's own request wording. Only safe when there's a single topic — with
732
+ // multiple distinct deliverables the goal text can't stand in for any one title.
733
+ const userTitled = (arr) => arr.length === 1 && isImplementationReportTitle(arr[0].title) && titleFromRequest(goalText)
734
+ ? [{ ...arr[0], title: titleFromRequest(goalText) }]
735
+ : arr;
736
+ if (list.length <= 1) return userTitled(list.slice(0, maxTasks));
607
737
  const real = list.filter((t) => !isEngineeringPhaseTitle(t.title));
608
738
  if (!real.length) {
609
739
  const title = String(goalText ?? list[0].title).replace(/\s+/g, ' ').trim().slice(0, 120);
@@ -611,7 +741,7 @@ export function refinePlanTasks(tasks, { goalText, maxTasks = 4 } = {}) {
611
741
  const passCondition = list.find((t) => t.passCondition)?.passCondition ?? null;
612
742
  return [{ title, detail, passCondition }];
613
743
  }
614
- return real.slice(0, maxTasks);
744
+ return userTitled(real.slice(0, maxTasks));
615
745
  }
616
746
 
617
747
  // ---- dedupe / merge overlapping goals --------------------------------------
@@ -870,8 +1000,30 @@ export function buildGoalPrBody({ lang, goal, goalTasks, files, owner, branch, p
870
1000
  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
1001
  `Manager for AI が実装: ゴールを${goalTasks.length}個のタスクに分解し、Claude Code worker が順に実行(既存サブスク・追加API無し・タスク毎にテストを作成し実行)。${proofItemsCount ? '成功条件つきタスクは headless ブラウザで独立検証済み。' : ''}`),
872
1002
  '',
1003
+ // How the reviewer sees the thing for themselves, in the worker's own
1004
+ // words. Above the reports because "run it and look" beats reading about
1005
+ // it — and because for a request with nothing to run this section is simply
1006
+ // absent rather than filled with a staged screenshot.
1007
+ ...(() => {
1008
+ const runs = goalTasks.filter((t) => t.run && (t.run.cmd || t.run.url));
1009
+ if (!runs.length) return [];
1010
+ return [
1011
+ '',
1012
+ L('## See it running', '## 動かして見る'),
1013
+ '',
1014
+ ...runs.flatMap((t) => [
1015
+ t.run.note ? `${t.run.note}` : '',
1016
+ t.run.cmd ? '```\n' + t.run.cmd + '\n```' : '',
1017
+ t.run.url ? `→ ${t.run.url}` : '',
1018
+ ].filter(Boolean)),
1019
+ ];
1020
+ })(),
1021
+ '',
873
1022
  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)}`),
1023
+ // 800 chars used to cut the worker's report mid-sentence. For a request
1024
+ // that produces no runnable thing (research, a summary, an answer) that
1025
+ // report IS the deliverable, so truncating it threw away the result.
1026
+ ...goalTasks.map((t) => `\n### ${t.num} ${t.title} — ${t.status} (${t.secs}s)\n${(t.result ?? '').slice(0, 8000)}`),
875
1027
  '',
876
1028
  L(`Changed files: ${files.map((f) => `\`${f}\``).join(' ')}`, `変更ファイル: ${files.map((f) => `\`${f}\``).join(' ')}`),
877
1029
  ].join('\n');
@@ -913,28 +1065,6 @@ export function parseReviewSummary(raw, fallback = {}) {
913
1065
  };
914
1066
  }
915
1067
 
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
1068
  const DOC_ONLY_RE = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/.*\.(md|mdx|txt)$|\.mdx?$|\.txt$/i;
939
1069
  export function hasTestRelevantChanges(changedFiles) {
940
1070
  const files = (changedFiles ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
@@ -1009,17 +1139,25 @@ export function classifyVerifyGate({
1009
1139
  return { blocked, kind, proofAdvisory };
1010
1140
  }
1011
1141
 
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);
1142
+ // Serializes async work so at most one item runs at a time. The goal-verify
1143
+ // pipeline uses this to stop two goals' `npm test` runs (each spins up real
1144
+ // headless Chrome + real servers) from ever overlapping running two full
1145
+ // suites at once was the actual cause of goal #552/#487 each blocking on a
1146
+ // DIFFERENT flaky test on every retry (2026-07-21 postmortem): the requested
1147
+ // feature was already correct, but the shared machine's CPU/ports/tmp-dirs
1148
+ // couldn't support two concurrent suites, so the baseline-diff gate never
1149
+ // saw the same failure twice in a row and never converged to "no new
1150
+ // failures". Queued work still runs even if an earlier item throws/rejects —
1151
+ // one bad run must not wedge every goal behind it forever.
1152
+ export function createSerialQueue() {
1153
+ let tail = Promise.resolve();
1154
+ return {
1155
+ run(fn) {
1156
+ const started = tail.then(fn, fn);
1157
+ tail = started.then(() => undefined, () => undefined);
1158
+ return started;
1159
+ },
1160
+ };
1023
1161
  }
1024
1162
 
1025
1163
  // Task 56: a project's review definition can carry a free-text `description`
@@ -1179,6 +1317,20 @@ export function goalGroupStatus(goalStatus, startedAt) {
1179
1317
  return 'done'; // includes 'reverted' (terminal)
1180
1318
  }
1181
1319
 
1320
+ // PRD §6.5 → Masa 2026-07-21: a folded goal no longer takes its own row in the Review
1321
+ // column. The standalone "→ #12 にまとめました" rows read as clutter ("まとめましたは
1322
+ // review欄にいらない"); the merge belongs inside the carrier review's detail, as a log of
1323
+ // what was folded into it. This selects the folded goals for a given carrier review (a
1324
+ // review can group several goalIds under one PR, so match against the whole set). Pure
1325
+ // so the browser mirror in app/index.html (non-module, can't import this) and the test
1326
+ // agree on exactly which goals a carrier review logs.
1327
+ export function foldedIntoReview(goals, targetGoalIds) {
1328
+ const ids = new Set((targetGoalIds ?? []).map(Number));
1329
+ return (goals ?? []).filter(
1330
+ (g) => g && g.status === 'folded' && g.foldedInto != null && ids.has(Number(g.foldedInto)),
1331
+ );
1332
+ }
1333
+
1182
1334
  // Label + CSS class for the small status chip shown next to a goal in the
1183
1335
  // goal list and goal detail thread — mirrored in app/index.html's GOAL_CHIP
1184
1336
  // since that inline script can't import this module.
@@ -2083,7 +2235,268 @@ const WORKER_BASH = [
2083
2235
  'cat', 'ls', 'head', 'tail', 'wc', 'find', 'grep', 'rg',
2084
2236
  'sed', 'awk', 'sort', 'uniq', 'diff', 'echo', 'which', 'env', 'pwd', 'date',
2085
2237
  ].map((c) => `Bash(${c}:*)`).join(',');
2086
- export const WORKER_TOOLS = `Edit,Write,Read,Glob,Grep,Skill,TodoWrite,${WORKER_BASH}`;
2238
+ // WebFetch/WebSearch: a plain Claude Code session has both, and a worker that
2239
+ // cannot open a page or look anything up fails a whole class of requests that
2240
+ // work locally ("この記事のとおりに直して", "現行の仕様を確認して"). They are
2241
+ // read-only, so they widen what the worker can SEE, not what it can change.
2242
+ export const WORKER_TOOLS = `Edit,Write,Read,Glob,Grep,Skill,TodoWrite,WebFetch,WebSearch,${WORKER_BASH}`;
2243
+
2244
+ // ---- goal → single task (demoted planner) ----------------------------------
2245
+ // P0b (Masa 2026-07-21): the planner no longer decomposes the request into
2246
+ // tasks. The request reaches the worker verbatim as ONE task; the worker
2247
+ // (Claude Code / Codex) decides its own breakdown and emits it as its own
2248
+ // TodoWrite, which the board surfaces. One card = one request.
2249
+
2250
+ // A short, single-line display label for the task, derived from the request
2251
+ // itself (no LLM rewrite). First non-empty line, trimmed and truncated.
2252
+ export function goalTaskTitle(text, max = 80) {
2253
+ const firstLine = String(text ?? '').split('\n').map((s) => s.trim()).find(Boolean) ?? '';
2254
+ return firstLine.length > max ? firstLine.slice(0, max - 1).trimEnd() + '…' : firstLine;
2255
+ }
2256
+
2257
+ // Build the single task that carries a goal's request verbatim to the worker.
2258
+ // `id`, `num`, `createdAt` are supplied by the caller (stateful/impure bits);
2259
+ // everything else is derived from the goal so this stays pure & testable.
2260
+ // `passCondition` rides along only when the user wrote an explicit one.
2261
+ export function buildGoalTask(goal, { id, num, passCondition = null, createdAt }) {
2262
+ return {
2263
+ id, num, goalId: goal.id, projectId: goal.projectId,
2264
+ title: goalTaskTitle(goal.text), detail: goal.text, passCondition,
2265
+ model: goal.model, effort: goal.effort, agent: goal.agent,
2266
+ mode: goal.mode ?? 'auto', skill: goal.skill ?? null,
2267
+ status: 'queued', createdAt, priority: '中',
2268
+ result: null, changedFiles: [], secs: null, activity: [], proof: null, usage: null,
2269
+ };
2270
+ }
2271
+
2272
+ // ---- worker prompt ---------------------------------------------------------
2273
+ // The instruction the worker (Claude Code / Codex) actually reads. Pure:
2274
+ // (task, goal, lastFailure, agentName) -> string, so it is unit-testable.
2275
+ // `agentName` is the resolved worker agent id ('codex' | 'claude-code'); the
2276
+ // caller resolves it (server keeps the WORKER_AGENTS table).
2277
+ //
2278
+ // P0a "un-lobotomy" (Masa 2026-07-21, goal #496): the harness used to (a) trim
2279
+ // the user's real request to 500 chars and bury it as "part of the overall
2280
+ // goal", and (b) inject suppressive RULES ("keep changes minimal", "make the
2281
+ // smallest safe change instead of expanding scope", "検証しすぎない…すぐ終える").
2282
+ // Together they turned "design 3 patterns as an artifact" into one minimal
2283
+ // tweak. North star: we never re-judge the request — the user's verbatim words
2284
+ // are the source of truth and the worker delivers exactly what they ask for.
2285
+ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code') {
2286
+ // Optional skill the user attached to this goal. Empirically (scratch worker,
2287
+ // haiku, custom fixture skill) the reliably-firing form is an explicit Skill-tool
2288
+ // instruction as the FIRST line; a bare "/name" first line did not consistently
2289
+ // trigger the skill in `claude -p`. See commit body for the run evidence.
2290
+ const skill = task?.skill ?? goal?.skill;
2291
+ const displayName = agentName === 'codex' ? 'Codex' : 'Claude Code';
2292
+ // Report back to the human in the SAME language they wrote the goal in (Masa
2293
+ // 2026-07-15: "撃った言語で返す"). JP characters → Japanese, otherwise English.
2294
+ const replyLang = detectRequestLanguage(goal?.text || task?.title || '') === 'ja' ? '日本語' : 'English';
2295
+ return [
2296
+ 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.` : '',
2297
+ `You are a ${displayName} worker managed by "Manager for AI". Do the request below inside this project directory.`,
2298
+ goal?.priorFailureMemory?.length ? `\nMANAGER FAILURE MEMORY:\n${latestFailurePolicy(goal.priorFailureMemory)?.text}\nApply this prevention before starting.` : '',
2299
+ '',
2300
+ // The user's own words, verbatim and in full, are the source of truth.
2301
+ // TASK/DETAIL below is a paraphrase from the planner — when they differ,
2302
+ // follow the user's words.
2303
+ goal?.text ? `USER'S REQUEST (verbatim — the source of truth; deliver exactly this):\n${goal.text}` : '',
2304
+ '',
2305
+ `TASK ${task.num}: ${task.title}`,
2306
+ task.detail ? `DETAIL: ${task.detail}` : '',
2307
+ goal?.images?.length ? `ATTACHED IMAGES (absolute paths, use the Read tool to view them): ${goal.images.join(' ')}` : '',
2308
+ task.passCondition ? `\nEXPLICIT PASS CONDITION (verified externally by a headless browser after you finish — NOT by you): ${task.passCondition}` : '',
2309
+ lastFailure ? `\nPREVIOUS ATTEMPT FAILED EXTERNAL VERIFICATION: ${lastFailure}\nYour previous changes are on disk. Diagnose and fix.` : '',
2310
+ '',
2311
+ 'RULES:',
2312
+ // The board is a window onto the agent's own plan — it holds nothing this
2313
+ // layer invented. That only works if the agent keeps a plan where we can see
2314
+ // it. Claude Code reaches for TodoWrite on its own; Codex has the same list
2315
+ // but skips it on anything small (measured 2026-07-22: gpt-5.4-mini emitted
2316
+ // no todo_list even when the REQUEST said "in three steps" — it does emit one
2317
+ // when told to track the work in its to-do list). So ask, once, for every
2318
+ // agent — the instruction is about how to work, never about what the request
2319
+ // is, so no request-type branching enters here.
2320
+ '- 作業の手順は最初に自分の Todo リスト(Claude Code なら TodoWrite、Codex なら todo list)に書き出し、進むたびに更新すること。人はこの Todo を見て進み具合を知る。',
2321
+ '- Edit only files inside this directory and this project. Stay on this request; do not wander into unrelated work.',
2322
+ // Deliverable fidelity (replaces the old "make the smallest safe change
2323
+ // instead of expanding scope"): the request, not our fear of scope, sets
2324
+ // the size of the work.
2325
+ '- 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.',
2326
+ '- Keep the work bounded to the request: inspect only files needed; avoid broad repository scans unless they are necessary.',
2327
+ "- You may use the user's installed skills (Skill tool, e.g. /galda-doc) when one clearly fits the request.",
2328
+ // Token-frugality + the 10-min run cap: the worker used to run the WHOLE suite
2329
+ // (npm test) repeatedly, which the Manager ALSO runs at close-out — double work
2330
+ // that regularly overran the cap (→ a SIGTERM 'interrupted'). Write the test,
2331
+ // run only the RELEVANT file once, and leave the full-suite + verification to
2332
+ // the Manager. (Masa: worker が自前でテスト全回し→止まる、が最大の無駄。)
2333
+ '- 変更に対応するテストを書く。確認は該当テストファイル1つだけを1回実行する(例: node --test path/to/only-this.test.mjs)。プロジェクト全体の npm test を繰り返し回さない — 全体テストと最終検証は Manager 側が実行する。テストが適用できない変更は理由と手動確認手順を書く。',
2334
+ // Anti-wasteful-re-verify only — NOT a licence to do less. Once it works,
2335
+ // stop re-checking; but the deliverables above must still be produced in full.
2336
+ '- 動くと確認できたら十分(無駄な再検証でログを漁り続けない)。ただし依頼された成果物は削らず出し切ること。',
2337
+ '- Do not commit (git is handled by the Manager).',
2338
+ // How the human actually looks at the result. We never guess the run
2339
+ // command ourselves — guessing means branching on project type (Next.js vs
2340
+ // Python vs …), and that is the "smart PM" behaviour this layer must not
2341
+ // have. The only AI in the pipeline is the one that just did the work, so
2342
+ // it is the one that says how to run it. Requests with nothing to run
2343
+ // (research, summaries, answers) simply skip this — the worker decides,
2344
+ // never us.
2345
+ `- 人が実際に動かして結果を見られる依頼なら、最後にプロジェクト直下へ ${RUN_DECL_FILE} を書く: {"cmd": "<起動コマンド>", "url": "<開くURL>", "note": "<何が見えるか1行>", "check": "<人に何を見て判断してほしいか1行>"}。Manager はこれを使って人に「動かして見る」と「何を見ればいいか」を出す。動かして見せるものが無い依頼(調査・要約・質問への回答など)では書かなくてよい。`,
2346
+ // Links are rendered as links. The product cannot tell a real one from a
2347
+ // decorative one, so the only place this can be held is here.
2348
+ `- 自分が作っていない場所へのリンクを報告に書かない(存在しないURLや「詳しくはここ」のような飾りのリンクは、そのままリンクとして人に表示される)。`,
2349
+ // The camera. We say it exists and where the pictures go; we never say when
2350
+ // to use it — that is the worker's call, like the run declaration above.
2351
+ // Telling it "screenshot UI changes" would be this layer deciding what kind
2352
+ // of request it is, which is the behaviour that produced off-target proof.
2353
+ // ...but only to an agent that can actually take one. Codex runs its shell
2354
+ // inside a sandbox that Chrome cannot start under (measured 2026-07-22 on
2355
+ // codex-cli 0.142.4: the camera fails under `workspace-write` and works only
2356
+ // with the sandbox off — opening network access does not help). Offering it
2357
+ // anyway is not a harmless extra line: the Codex worker tried four times,
2358
+ // then reported the failure instead of the work. Do not ask an agent for
2359
+ // something this machine has proven it cannot do (Masa 2026-07-22).
2360
+ agentName === 'codex' ? '' : `- 画面・見た目のあるものを作ったなら、自分でスクリーンショットを撮って見せてよい: \`node ${SHOT_CLI} <URL または HTMLファイル> ${SHOT_DIR}/<名前>.png [--full] [--wait-for <CSSセレクタ>]\`(headless Chrome・前面に出ない)。${SHOT_DIR}/ に置いた画像は Manager が回収して人に見せる(diff には入らない)。撮ったものは Read で自分でも見返せる。`,
2361
+ `- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
2362
+ ].filter(Boolean).join('\n');
2363
+ }
2364
+
2365
+ // ---- RUN DECLARATION -------------------------------------------------------
2366
+ // The worker tells us how a human can run and look at what it built, by writing
2367
+ // RUN_DECL_FILE. This is the whole of P1's "動かして見られる状態": the evidence
2368
+ // is the real thing running, not a screenshot we staged.
2369
+ //
2370
+ // Why a file and not a line in the worker's report: writing a file is a tool
2371
+ // call the agent makes deliberately, where a report format is a wording habit
2372
+ // that drifts. And why the worker at all — the layer below (this one) must not
2373
+ // classify the request or the project to decide how to start it.
2374
+ //
2375
+ // Anything we cannot validate is dropped: no "run it" button is honest, a
2376
+ // button that opens the wrong thing is the fabricated-proof bug all over again.
2377
+ export const RUN_DECL_FILE = '.manager-run.json';
2378
+
2379
+ export function parseRunDeclaration(text) {
2380
+ let raw;
2381
+ try { raw = JSON.parse(String(text ?? '')); } catch { return null; }
2382
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
2383
+ const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
2384
+ const cmd = str(raw.cmd, 500);
2385
+ const url = str(raw.url, 500);
2386
+ // Nothing to start and nothing to open = nothing to look at.
2387
+ if (!cmd && !url) return null;
2388
+ // Only http(s): the button opens this in the user's browser.
2389
+ if (url && !/^https?:\/\//i.test(url)) return null;
2390
+ // `check` (Masa 2026-07-22): what to look at, in the working AI's own words. It used
2391
+ // to come from the intake AI's plan — this layer inventing a pass condition for a
2392
+ // request it only read. The AI that did the work states it here instead, in the same
2393
+ // file it already writes, and the review card shows that sentence verbatim.
2394
+ return { cmd, url, note: str(raw.note, 300), check: str(raw.check, 300) };
2395
+ }
2396
+
2397
+ // ---- SHOTS: the worker's own camera ----------------------------------------
2398
+ // The worker could build a screen and had no way to show it: WORKER_TOOLS has
2399
+ // no browser, so "見せて" could only ever come back as prose. That is one half
2400
+ // of the off-target-evidence problem (#496) — the other half was us staging the
2401
+ // picture ourselves, which is exactly what must not come back.
2402
+ //
2403
+ // So: hand the worker a camera and nothing else. `engine/shot.mjs` takes a URL
2404
+ // or a local file and writes a PNG in headless Chrome. This layer never decides
2405
+ // WHEN to shoot or WHAT to shoot — the worker that just did the work does, the
2406
+ // same way it already decides the run declaration above. No request-type
2407
+ // branching lives here.
2408
+ //
2409
+ // Shots land in SHOT_DIR inside the worktree and the Manager moves them out
2410
+ // before the diff is computed: they are a message to the Manager, not part of
2411
+ // the deliverable, so they must never reach the PR (same rule as RUN_DECL_FILE).
2412
+ export const SHOT_DIR = '.manager-shots';
2413
+
2414
+ // Absolute path to the camera, resolved from this file so it is correct in a
2415
+ // repo checkout and in an installed package alike (the worker runs in the
2416
+ // customer's worktree, which knows nothing about where the Manager lives).
2417
+ export const SHOT_CLI = (() => {
2418
+ const p = decodeURIComponent(new URL('shot.mjs', import.meta.url).pathname);
2419
+ return /^\/[A-Za-z]:/.test(p) ? p.slice(1) : p;
2420
+ })();
2421
+
2422
+ // Where Chrome might be. Env first so a customer with a non-standard install
2423
+ // (or a Linux box) can point at it; the rest are the default locations per
2424
+ // platform. Pure — the caller supplies `exists`, so this is testable.
2425
+ export function chromeCandidates(env = {}) {
2426
+ return [
2427
+ env.CHROME_PATH,
2428
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
2429
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
2430
+ '/usr/bin/google-chrome',
2431
+ '/usr/bin/chromium-browser',
2432
+ '/usr/bin/chromium',
2433
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
2434
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
2435
+ ].filter(Boolean);
2436
+ }
2437
+ export function pickChromePath(env = {}, exists = () => false) {
2438
+ return chromeCandidates(env).find((c) => exists(c)) ?? null;
2439
+ }
2440
+
2441
+ // `node <manager>/engine/shot.mjs <url|file> <out.png> [--width N] [--height N]
2442
+ // [--full] [--wait MS] [--wait-for SELECTOR]`
2443
+ // Returns { error } instead of throwing, so the CLI can print one clear line.
2444
+ export function parseShotArgs(argv = []) {
2445
+ const rest = [];
2446
+ const opt = { width: 1440, height: 900, full: false, wait: 0, waitFor: null };
2447
+ const num = (v, lo, hi) => {
2448
+ const n = Number(v);
2449
+ return Number.isFinite(n) && n >= lo && n <= hi ? Math.round(n) : null;
2450
+ };
2451
+ for (let i = 0; i < argv.length; i++) {
2452
+ const a = String(argv[i]);
2453
+ if (a === '--full') { opt.full = true; continue; }
2454
+ if (a === '--width' || a === '--height') {
2455
+ const n = num(argv[++i], 100, 4000);
2456
+ if (n == null) return { error: `${a} needs a number between 100 and 4000` };
2457
+ opt[a.slice(2)] = n; continue;
2458
+ }
2459
+ if (a === '--wait') {
2460
+ const n = num(argv[++i], 0, 30000);
2461
+ if (n == null) return { error: '--wait needs milliseconds between 0 and 30000' };
2462
+ opt.wait = n; continue;
2463
+ }
2464
+ if (a === '--wait-for') {
2465
+ const sel = String(argv[++i] ?? '').trim();
2466
+ if (!sel) return { error: '--wait-for needs a CSS selector' };
2467
+ opt.waitFor = sel; continue;
2468
+ }
2469
+ if (a.startsWith('--')) return { error: `unknown option ${a}` };
2470
+ rest.push(a);
2471
+ }
2472
+ const [target, out] = rest;
2473
+ if (!target || !out) return { error: 'usage: node engine/shot.mjs <url|file> <out.png> [--width N] [--height N] [--full] [--wait MS] [--wait-for SELECTOR]' };
2474
+ if (!/\.png$/i.test(out)) return { error: 'output must end in .png' };
2475
+ return { target, out, ...opt };
2476
+ }
2477
+
2478
+ // http(s) passes through; anything else is a local path → file:// URL.
2479
+ // `resolvePath` is injected so this stays pure and testable.
2480
+ export function shotTargetUrl(target, resolvePath = (p) => p) {
2481
+ const t = String(target ?? '').trim();
2482
+ if (!t) return null;
2483
+ if (/^(https?|file):\/\//i.test(t)) return t;
2484
+ const abs = String(resolvePath(t)).replace(/\\/g, '/');
2485
+ const withSlash = abs.startsWith('/') ? abs : `/${abs}`;
2486
+ return `file://${withSlash.split('/').map((seg, i) => (i === 0 ? seg : encodeURIComponent(seg))).join('/')}`;
2487
+ }
2488
+
2489
+ // What the Manager takes out of SHOT_DIR: images only, no subpaths or dotfiles
2490
+ // (they become filenames in the Manager's own store), capped so a runaway loop
2491
+ // cannot bloat the record.
2492
+ export const SHOT_MAX = 20;
2493
+ export function collectShotNames(names = []) {
2494
+ return names
2495
+ .filter((n) => typeof n === 'string' && /\.(png|jpe?g|gif|webp)$/i.test(n))
2496
+ .filter((n) => !n.includes('/') && !n.includes('\\') && !n.startsWith('.'))
2497
+ .sort()
2498
+ .slice(0, SHOT_MAX);
2499
+ }
2087
2500
 
2088
2501
  // ---- SKILL picker ----------------------------------------------------------
2089
2502
  // Parse the YAML frontmatter of a SKILL.md / command .md and pull out `name`