@galda/cli 0.10.39 → 0.10.41

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
@@ -227,7 +227,13 @@ export function buildExecutionPlan({ projectId = null, text = '', usage = null,
227
227
  session.mode === 'compact' ? 'compact handoff' : null,
228
228
  session.mode === 'cold-handoff' ? 'fresh handoff' : null,
229
229
  session.mode === 'cold' ? '/clear equivalent' : null,
230
- workerPolicy.reason ? 'worker policy' : null,
230
+ // 'worker policy' used to be listed here and it never was one: the effort
231
+ // chooseWorkerPolicy picks is not applied to the worker (it only reaches the
232
+ // handoff prompt as a sentence), and Masa ruled on 2026-07-22 that it must
233
+ // NOT start being applied — guessing effort from keywords in the request is
234
+ // the request-classification this layer is not allowed to do. A technique
235
+ // list that names something we did not do is what makes the saved figure
236
+ // untrustworthy, so it is gone rather than kept as a label.
231
237
  ].filter(Boolean);
232
238
  return {
233
239
  projectGroupId: projectId,
@@ -301,15 +307,20 @@ export function estimateTokenOptimization({ usage = null, weightedTokens = null,
301
307
  const perReq = weighted / reqN;
302
308
  const baseline = tokenBaselinePerRequirement(history);
303
309
 
304
- let percent, basis, savedTokens;
310
+ // A percentage is only claimed against this user's own recent runs. The old
311
+ // fallback — with no history, score the run against its own cache-weighting —
312
+ // produced the number the PRD itself called out as a perverse incentive: the
313
+ // MORE context a run dragged along, the bigger its cache read, and the better
314
+ // the "saving" looked. That made the first runs of every new install, the
315
+ // ones with no history, the ones that bragged most (Masa 2026-07-22). Now
316
+ // there is simply no percentage until there is something real to compare to;
317
+ // the tokens and the techniques below still ship, so the card shows facts
318
+ // instead of a flattering ratio.
319
+ let percent = null, basis = 'no-baseline', savedTokens = null;
305
320
  if (baseline != null) {
306
321
  percent = Math.max(-100, Math.min(100, Math.round(((baseline - perReq) / baseline) * 100)));
307
322
  basis = 'baseline';
308
323
  savedTokens = Math.max(0, Math.round((baseline - perReq) * reqN));
309
- } else {
310
- savedTokens = Math.max(0, raw - weighted);
311
- percent = Math.round((savedTokens / raw) * 100);
312
- basis = 'cache-weight';
313
324
  }
314
325
 
315
326
  // "why": always at least one concrete, data-derived sentence — never a
@@ -320,7 +331,10 @@ export function estimateTokenOptimization({ usage = null, weightedTokens = null,
320
331
  if (usedHandoff || sessionMode === 'cold-handoff') techniques.push('fresh handoff instead of a bloated session');
321
332
  if (usedCompact || sessionMode === 'compact') techniques.push('/compact before continuing');
322
333
  if (usedClear) techniques.push('/clear between unrelated goals');
323
- if (agentModel) techniques.push(`${agentModel} for this run`);
334
+ // The model is NOT listed any more. Naming it here read as something this
335
+ // layer did to save tokens, and it is the opposite: the model is the user's
336
+ // own pick from the composer, and we deliberately never override it
337
+ // (Masa 2026-07-22). Every remaining line is an action this layer took.
324
338
  if (basis === 'baseline' && percent > 0) {
325
339
  techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
326
340
  }
@@ -534,6 +548,96 @@ export function parseStreamEvents(line) {
534
548
  return out;
535
549
  }
536
550
 
551
+ // ---- CODEX BRIDGE ----------------------------------------------------------
552
+ // The same three wires the product is built out of — the board (the agent's own
553
+ // to-dos), the implementation log (what it actually did), and the thread it can
554
+ // be talked to again in — read from `codex exec --json` instead of `claude -p
555
+ // --output-format stream-json`. Everything above this line stays agent-agnostic:
556
+ // the bridge's whole job is to speak Codex's event vocabulary into the one this
557
+ // engine already has, so nothing downstream branches on which agent ran.
558
+ //
559
+ // The shapes below are transcribed from a real `codex exec --json` run
560
+ // (codex-cli 0.142.4) — see engine/test/codex-bridge.test.mjs, whose fixtures
561
+ // are that run's stdout verbatim rather than a schema we imagined.
562
+ export function parseCodexTodos(items) {
563
+ return (Array.isArray(items) ? items : [])
564
+ .map((i) => ({ content: String(i?.text ?? '').trim(), status: i?.completed ? 'completed' : 'pending' }))
565
+ .filter((t) => t.content);
566
+ }
567
+
568
+ // Codex reports usage with its own key names AND its own arithmetic: its
569
+ // `input_tokens` INCLUDES the cached ones, where claude reports cache reads as a
570
+ // separate bucket that input_tokens excludes. Adding both as-is counted the
571
+ // cached tokens twice, so the ledger showed a Codex run as far more expensive
572
+ // than it was (measured: ~92k of a ~108k turn was cache). Subtract, so a number
573
+ // from either agent means the same thing.
574
+ export function parseCodexUsage(usage) {
575
+ if (!usage) return null;
576
+ const total = usage.input_tokens ?? usage.inputTokens ?? 0;
577
+ const cached = usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0;
578
+ return {
579
+ input_tokens: Math.max(0, total - cached),
580
+ output_tokens: usage.output_tokens ?? usage.outputTokens ?? 0,
581
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
582
+ cache_read_input_tokens: cached,
583
+ };
584
+ }
585
+
586
+ // Parse one line of `codex exec --json` output into the same event vocabulary
587
+ // parseStreamEvents returns ('session' | 'todos' | 'activity' | 'message' |
588
+ // 'usage'). 'message' rather than 'result' because Codex emits an agent_message
589
+ // per narration step, not once at the end — the caller keeps the last one.
590
+ export function parseCodexEvents(line) {
591
+ let ev;
592
+ try { ev = JSON.parse(line); } catch { return []; }
593
+ if (!ev || typeof ev !== 'object') return [];
594
+ if (ev.type === 'thread.started' && ev.thread_id) return [{ kind: 'session', id: String(ev.thread_id) }];
595
+ if (ev.type === 'turn.completed') return [{ kind: 'usage', usage: parseCodexUsage(ev.usage) }];
596
+ if (ev.type === 'error' && ev.message) return [{ kind: 'error', text: String(ev.message) }];
597
+ if (!['item.started', 'item.updated', 'item.completed'].includes(ev.type)) return [];
598
+ const item = ev.item ?? {};
599
+ if (item.type === 'todo_list') {
600
+ const todos = parseCodexTodos(item.items);
601
+ return todos.length ? [{ kind: 'todos', todos }] : [];
602
+ }
603
+ if (item.type === 'agent_message' && ev.type === 'item.completed' && item.text) {
604
+ return [{ kind: 'message', text: String(item.text) }];
605
+ }
606
+ // Commands are announced when they start (the log should move while the
607
+ // worker works, not after) and only mentioned again when they fail.
608
+ if (item.type === 'command_execution' && item.command) {
609
+ if (ev.type === 'item.started') return [{ kind: 'activity', text: `Run ${String(item.command)}`.slice(0, 140) }];
610
+ if (ev.type === 'item.completed' && item.exit_code) {
611
+ return [{ kind: 'activity', text: `Run ${String(item.command)} (exit ${item.exit_code})`.slice(0, 140) }];
612
+ }
613
+ return [];
614
+ }
615
+ // File edits are the bulk of what a worker does and were entirely missing from
616
+ // the log on Codex — the run read as "a few commands and then, somehow, a PR".
617
+ if (item.type === 'file_change' && ev.type === 'item.completed') {
618
+ const verb = { add: 'Write', delete: 'Delete' };
619
+ return (Array.isArray(item.changes) ? item.changes : [])
620
+ .filter((c) => c?.path)
621
+ .slice(0, 8)
622
+ .map((c) => ({ kind: 'activity', text: `${verb[c.kind] ?? 'Edit'} ${String(c.path)}`.slice(0, 140) }));
623
+ }
624
+ if (item.type === 'error' && item.message && ev.type === 'item.completed') {
625
+ return [{ kind: 'error', text: String(item.message) }];
626
+ }
627
+ return [];
628
+ }
629
+
630
+ // Argument list for one `codex exec` run. `resume` continues an existing thread
631
+ // (the id from a previous run's thread.started) — that subcommand takes neither
632
+ // --cd nor --sandbox, so the working directory comes from the spawn's cwd and
633
+ // the sandbox from -c sandbox_mode. Verified against codex-cli 0.142.4.
634
+ export function buildCodexArgs({ model, effort, sandbox, cwd, resume } = {}) {
635
+ const common = ['--json', '-m', model, '-c', `model_reasoning_effort="${effort}"`, '--skip-git-repo-check'];
636
+ return resume
637
+ ? ['exec', 'resume', String(resume), ...common, '-c', `sandbox_mode="${sandbox}"`, '-']
638
+ : ['exec', ...common, '--sandbox', sandbox, '--cd', cwd, '-'];
639
+ }
640
+
537
641
  // Parse the planner's reply into { entry, tasks }. Accepts either a JSON
538
642
  // object {entry, tasks:[...]} or a bare JSON array anywhere in the text;
539
643
  // falls back to a single task made from the raw request. Each task may carry
@@ -654,65 +758,51 @@ export function refinePlanTasks(tasks, { goalText, maxTasks = 4 } = {}) {
654
758
  return userTitled(real.slice(0, maxTasks));
655
759
  }
656
760
 
657
- // ---- dedupe / merge overlapping goals --------------------------------------
658
- // Masa (dogfood): 「被ったやつも自分でちゃんと理解してまとめてほしい」。When a
659
- // new goal near-duplicates an existing active one, fold it in as a follow-up
660
- // instead of piling up a redundant parallel goal.
761
+ // ---- dedupe / merge overlapping goals — REMOVED 2026-07-22 ----------------
762
+ // normalizeGoalText / goalTextSimilarity / findOverlappingGoal lived here: a
763
+ // word-overlap score that folded a new request into an in-flight goal whenever
764
+ // the two texts scored >= 0.72 alike. Deleted with its call site
765
+ // (docs/design/ONE-CONVERSATION.md §1.2) — it was our layer deciding "this is
766
+ // probably the same thing" on the user's behalf. See parseGoalAddress below:
767
+ // the person names the ticket, or the request becomes its own.
768
+
769
+ // ---- the conversation on a goal (docs/design/ONE-CONVERSATION.md §1.1) -----
770
+ // 「1本の会話・複数の入口」の入れ物。今日までの連続性は Claude Code 側の
771
+ // sessionId だけに預けてあり、上書きされたり resume に失敗すると我々の側には
772
+ // 何も残らなかった。ゴール自身がやりとりを持てば、セッションが飛んでも人には
773
+ // 見える。
661
774
  //
662
- // Normalize goal text for overlap comparison: lowercase, collapse whitespace
663
- // (incl. full-width space), strip punctuation — so "PRDを作って!" and
664
- // "prd を 作って" compare alike.
665
- export function normalizeGoalText(text) {
666
- return String(text ?? '')
667
- .toLowerCase()
668
- .replace(/[\s ]+/g, ' ')
669
- .replace(/[、。,.,.!!??「」『』()()\[\]{}"'`~・…—\-_/\\:;]/g, '')
670
- .replace(/\s+/g, ' ')
671
- .trim();
775
+ // 原則(Masa確定 2026-07-22):
776
+ // - 要約しない・分類しない・間引かない。言われた言葉をそのまま全件。
777
+ // - 1発言の長さにだけ上限(極端な貼り付けで記録が壊れないように)。
778
+ // - /api/state には載せない。開いた時だけ取りに行く(実装ログ #234/#238 と同じ形)。
779
+ export const MESSAGE_TEXT_LIMIT = 8000;
780
+ export const MESSAGE_FROM = ['you', 'ai', 'manager'];
781
+ export const MESSAGE_VIA = ['composer', 'review', 'thread'];
782
+
783
+ // 1発言を正規化する。text が空なら null(空の発言は記録しない=無いものを
784
+ // 残さない)。from/via が未知なら記録側の都合で捨てず、既定に寄せる。
785
+ export function buildGoalMessage({ from, via, text, at } = {}) {
786
+ const body = String(text ?? '').trim();
787
+ if (!body) return null;
788
+ return {
789
+ at: at || new Date().toISOString(),
790
+ from: MESSAGE_FROM.includes(from) ? from : 'manager',
791
+ via: MESSAGE_VIA.includes(via) ? via : 'composer',
792
+ text: body.slice(0, MESSAGE_TEXT_LIMIT),
793
+ };
672
794
  }
673
795
 
674
- // Similarity 0..1 between two goal texts. Combines word tokens with CJK
675
- // bigrams (Japanese has no spaces, so word-splitting alone can't compare
676
- // 日本語同士) into a Jaccard overlap; identical normalized text short-circuits
677
- // to 1. Pure and symmetric.
678
- export function goalTextSimilarity(a, b) {
679
- const na = normalizeGoalText(a), nb = normalizeGoalText(b);
680
- if (!na || !nb) return 0;
681
- if (na === nb) return 1;
682
- const toks = (s) => {
683
- // Latin/alphanumeric WORDS (≥2 chars) + CJK BIGRAMS. Bigrams matter because
684
- // Japanese has no spaces — splitting on ' ' alone would make the whole
685
- // spaceless string one giant token and never compare 日本語同士.
686
- const set = new Set((s.match(/[a-z0-9]{2,}/g) ?? []));
687
- const cjk = s.replace(/[^぀-ヿ一-鿿]/g, '');
688
- for (let i = 0; i < cjk.length - 1; i++) set.add(cjk.slice(i, i + 2));
689
- return set;
690
- };
691
- const sa = toks(na), sb = toks(nb);
692
- if (!sa.size || !sb.size) return 0;
693
- let inter = 0;
694
- for (const t of sa) if (sb.has(t)) inter++;
695
- return inter / (sa.size + sb.size - inter);
696
- }
697
-
698
- // Find an existing goal a new goal's text substantially overlaps (near
699
- // duplicate), so the caller can fold it in as a thread follow-up rather than
700
- // create a redundant parallel goal. `existingGoals` is expected to be already
701
- // filtered by the caller to real merge candidates (same project, active,
702
- // session-bearing). Returns { goal, score } only on HIGH confidence
703
- // (threshold 0.72); when unsure, null → the caller creates the goal, so a
704
- // genuinely distinct ask is never silently swallowed. Very short texts
705
- // (< 6 normalized chars) never match — too little signal to be sure.
706
- export const GOAL_OVERLAP_THRESHOLD = 0.72;
707
- export function findOverlappingGoal(text, existingGoals, { threshold = GOAL_OVERLAP_THRESHOLD } = {}) {
708
- if (normalizeGoalText(text).length < 6) return null;
709
- let best = null, bestScore = 0;
710
- for (const g of existingGoals ?? []) {
711
- if (normalizeGoalText(g?.text).length < 6) continue;
712
- const score = goalTextSimilarity(text, g?.text);
713
- if (score > bestScore) { bestScore = score; best = g; }
714
- }
715
- return best && bestScore >= threshold ? { goal: best, score: bestScore } : null;
796
+ // JSONL 1 発言。壊れた行は黙って捨てる(1行の破損で会話全部を失わない)。
797
+ export function serializeGoalMessage(msg) { return JSON.stringify(msg); }
798
+ export function parseGoalMessages(raw) {
799
+ return String(raw ?? '').split('\n').map((line) => {
800
+ if (!line.trim()) return null;
801
+ try {
802
+ const m = JSON.parse(line);
803
+ return (m && typeof m.text === 'string') ? m : null;
804
+ } catch { return null; }
805
+ }).filter(Boolean);
716
806
  }
717
807
 
718
808
  // ---- which goal states allow EDIT / DELETE from the board ------------------
@@ -793,6 +883,24 @@ export function isForcedStop(code, timedOut) {
793
883
  return Boolean(timedOut) || code === 143 || code === 130;
794
884
  }
795
885
 
886
+ // What a task's status is once the worker has stopped and any check has run.
887
+ //
888
+ // The one thing that changed (Masa 2026-07-22): a check that RAN AND FAILED no
889
+ // longer scores the task 'failed', and (at the call site) no longer sends the
890
+ // worker back for another attempt. Both were how a wrong check turned into real
891
+ // damage — a run measured on 2026-07-22 failed a check that was looking at a
892
+ // file the worker never made, and the rework it triggered spent >100k tokens
893
+ // pushing the work toward that wrong target. A check we authored can be wrong
894
+ // about the work; the work cannot be wrong about itself. So a failing check is
895
+ // now what an inconclusive one already was: a note for the human in review,
896
+ // never a verdict that manufactures a failure. A PASS still wins outright — it
897
+ // is evidence, and evidence beats an exit code.
898
+ export function taskStatusAfterVerify({ verdict, code, timedOut }) {
899
+ if (verdict?.pass) return 'done';
900
+ if (isForcedStop(code, timedOut)) return 'interrupted';
901
+ return code === 0 ? 'done' : 'failed';
902
+ }
903
+
796
904
  // A task counts as complete for GOAL status when it cleanly finished
797
905
  // (done/skipped) OR it was force-stopped (interrupted) but its change is STRONGLY
798
906
  // verified — an independent passCondition verification PASSED (proof.verified &&
@@ -1314,18 +1422,18 @@ export function groupTasksByGoalOrdered(tasks, goals) {
1314
1422
  return order.map((key) => ({ goal: key == null ? null : byId.get(key), tasks: map.get(key) }));
1315
1423
  }
1316
1424
 
1317
- // A goal's review number must stay stable while sibling reviews get
1318
- // Approved and drop out of the list — an index-based "R1/R2/R3" reflows
1319
- // every time one is removed (task 118: "R2をApproveした後にまた別のがR2に
1320
- // なる"). Use the goal's own original TODO number instead: the lowest
1321
- // task.num among the tasks that belong to it (its first-decomposed task).
1322
- export function goalReviewNumber({ goal, tasks } = {}) {
1323
- if (goal == null) return null;
1324
- const nums = (tasks ?? [])
1325
- .filter((t) => t.goalId === goal.id)
1326
- .map((t) => t.num)
1327
- .filter((n) => typeof n === 'number' && Number.isFinite(n));
1328
- return nums.length ? Math.min(...nums) : null;
1425
+ // A goal's number must stay stable while sibling reviews get Approved and drop out of
1426
+ // the list — an index-based "R1/R2/R3" reflows every time one is removed (task 118:
1427
+ // "R2をApproveした後にまた別のがR2になる"). It is the goal's OWN id: unique, permanent,
1428
+ // and the number every screen already prints (app/index.html's own goalReviewNumber has
1429
+ // returned goal.id since the CDO review 2026-07-08 §1).
1430
+ //
1431
+ // This one used to return the goal's lowest task.num instead — so the same ticket was
1432
+ // called #4 by the engine's own digests and #5 by every screen. Measured on the resident
1433
+ // instance 2026-07-22: of 214 goals the two numbers agreed exactly ONCE and differed 184
1434
+ // times. One card, one request, one number (galda-one-card-one-request).
1435
+ export function goalReviewNumber({ goal } = {}) {
1436
+ return goal == null ? null : goal.id;
1329
1437
  }
1330
1438
 
1331
1439
  // Review digest (task 227): one row per review-pending goal, collapsed to a
@@ -1620,11 +1728,114 @@ export function approveGoal({ goalStatus, hasPr = false }) {
1620
1728
  return { ok: true, status: 'done' };
1621
1729
  }
1622
1730
 
1623
- // Dismiss(差し戻し)は 'running' に戻す——チャット(/reply や /dismiss にテキスト)での
1624
- // 「作り直し」依頼の受け皿。goalGroupStatus は startedAt 済みなら 'doing' に読む。
1625
- export function dismissGoal({ goalStatus }) {
1731
+ // 返事の行き先(docs/design/ONE-CONVERSATION.md §1.3)
1732
+ //
1733
+ // Dismiss は長いあいだ一本道だった——何を言っても必ず 'running'(=すぐ作り直し)。
1734
+ // だが人が返す言葉は3種類ある: 「このまま直して」「いったん止めて考え直す」「それは別件」。
1735
+ // 一本道だと後ろ2つが表現できず、考え直したいだけの時にもワーカーが走り出す。
1736
+ //
1737
+ // continue … 今までと同じ。Doing に戻して同じ会話を resume する(既定)
1738
+ // rescope … 止めて考え直す。To Do の下(pending)へ park し、ワーカーは起こさない。
1739
+ // 呼び出し側がゴールの文章そのものを新しい言葉に差し替える
1740
+ // ——チケットの文章が書き換わるのはここだけ
1741
+ // split … 別件。このゴールには触らず、呼び出し側が新しいゴールを作る
1742
+ // answer … 聞いてるだけ。何も動かさない。Review のまま、コードも触らせず、
1743
+ // 作業した AI 本人が読み取り専用で答えるだけ(会話に1発言積む)
1744
+ //
1745
+ // なぜ rescope が 'pending' か: 「走らせずに一覧に戻す」意味を持つ状態はこれだけ。
1746
+ // 'stacked' は空きが出ると自動で走り出す(server の pump)ので「考え直す」にならない。
1747
+ //
1748
+ // なぜ answer が要るか (PRD §5.6・Masa確定 2026-07-22): 返事は中身に関わらず全部
1749
+ // 「直して」に落ちていた——「なんでこうしたの?」と聞いただけでゴールが Doing に
1750
+ // 戻り、ワーカーが起き、コードが変わり始める。聞いただけで作り直しが始まるのは
1751
+ // 壊れている。answer は goalStatus をそのまま返す=1ビットも動かさない。
1752
+ export const REPLY_OUTCOMES = ['continue', 'rescope', 'split', 'answer'];
1753
+ export function dismissGoal({ goalStatus, outcome = 'continue' } = {}) {
1626
1754
  if (goalStatus !== 'review') return { ok: false, error: 'goal is not in review' };
1627
- return { ok: true, status: 'running' };
1755
+ const which = REPLY_OUTCOMES.includes(outcome) ? outcome : 'continue';
1756
+ if (which === 'rescope') return { ok: true, outcome: which, status: 'pending', spawnsWorker: false, rewritesText: true };
1757
+ if (which === 'split') return { ok: true, outcome: which, status: goalStatus, spawnsWorker: false, splits: true };
1758
+ if (which === 'answer') return { ok: true, outcome: which, status: goalStatus, spawnsWorker: false, answers: true };
1759
+ return { ok: true, outcome: 'continue', status: 'running', spawnsWorker: true };
1760
+ }
1761
+
1762
+ // 誰が決めるか=**AI本人** (docs/design/ONE-CONVERSATION.md §1.4)
1763
+ //
1764
+ // 返事が来た時、それが「このまま直して」なのか「考え直したい」なのか「別件」なのか
1765
+ // 「ただ聞いただけ」なのかを、**我々の層で当てない**。キーワードでも類似度でもなく、
1766
+ // 人の言葉を1回だけ AI に渡して、返ってきた答えのとおりに配線する。0.72 の自動合流を
1767
+ // 消したのと同じ理由——この層が人の意図を推測し始めると、外れた時に黙って別のことを
1768
+ // する(今日そこを1つずつ潰してきた)。
1769
+ //
1770
+ // 迷った時だけ人に聞く: AI が unsure と答えたら**ゴールに触らず**選択肢を返す。人は
1771
+ // モードを普段選ばない。迷った時だけ選ぶ。
1772
+ export const REPLY_INTENTS = [...REPLY_OUTCOMES, 'unsure'];
1773
+ export function buildReplyIntentPrompt({ goalText, reply } = {}) {
1774
+ return [
1775
+ 'You route replies in Agent Manager: a person delegated a coding task, an AI did it, and it came back for review. The person has now replied.',
1776
+ 'Decide what the reply ASKS FOR — exactly one of:',
1777
+ '- "continue": fix or change the work that was just done (the usual case).',
1778
+ '- "rescope": stop and rethink; the request itself should now say something different.',
1779
+ '- "split": a separate piece of work that should not touch this one.',
1780
+ '- "answer": a question about the work. Nothing should be built or changed — they want to know something.',
1781
+ '- "unsure": you genuinely cannot tell. Prefer this over guessing: the person will be shown the choices.',
1782
+ 'Judge the reply on its own words. Do not treat a question mark as proof of "answer", or an imperative as proof of "continue" — "why is this red? make it blue" asks for a change.',
1783
+ 'Output ONLY one line of raw JSON: {"outcome":"continue|rescope|split|answer|unsure"}. No markdown, no code fence, no other text.',
1784
+ '',
1785
+ 'The request that was worked on:',
1786
+ String(goalText ?? '').slice(0, 2000),
1787
+ '',
1788
+ 'Their reply:',
1789
+ String(reply ?? '').slice(0, 4000),
1790
+ ].join('\n');
1791
+ }
1792
+
1793
+ // Tolerant parser — never throws. It returns TWO things, and the difference
1794
+ // between them is the whole point:
1795
+ //
1796
+ // read: true … the AI answered, and we understood it. 'unsure' here means the
1797
+ // AI genuinely could not tell → ask the person (§1.4).
1798
+ // read: false … we could not get an answer at all (no agent installed, offline,
1799
+ // rate-limited, garbled output). The AI never said anything, so
1800
+ // there is nobody to have been unsure.
1801
+ //
1802
+ // Treating those two the same is a real bug, found by an existing test on
1803
+ // 2026-07-22: on a machine with no agent, a reply stopped doing ANYTHING — it put
1804
+ // the four choices on screen and waited. But if the agent cannot be reached, no
1805
+ // answer can be written either; the only useful thing a reply can still do is
1806
+ // what it always did — go back as rework, and run when the agent returns.
1807
+ // So the caller falls back to 'continue' when read is false. That is not a guess
1808
+ // about what the person meant: it is the documented default this endpoint has
1809
+ // always had when no outcome is given.
1810
+ export function parseReplyIntent(raw) {
1811
+ const unread = { outcome: 'unsure', read: false };
1812
+ if (!raw || typeof raw !== 'string') return unread;
1813
+ const m = raw.match(/\{[\s\S]*\}/);
1814
+ if (!m) return unread;
1815
+ let obj;
1816
+ try { obj = JSON.parse(m[0]); } catch { return unread; }
1817
+ if (!REPLY_INTENTS.includes(obj?.outcome)) return unread; // answered, but not in our words
1818
+ return { outcome: obj.outcome, read: true };
1819
+ }
1820
+
1821
+ // 宛先(docs/design/ONE-CONVERSATION.md §1.2)
1822
+ //
1823
+ // チャットで「#12 の件だけど」と書けたら、そのチケット宛て。**決定論で、我々の推測は
1824
+ // ゼロ**。これが要るのは、今まで「同じ話かどうか」を我々の層が文字列の似かた(0.72)で
1825
+ // 当てずっぽうに決めていたから——当たれば黙って合流し、外れれば別のカードが生えた。
1826
+ // 人が番号を書けるなら、推測する理由が無い。
1827
+ //
1828
+ // 最初の `#N` だけを見る。番号が実在しなければ宛先なしとして扱う(=ふつうに新しい
1829
+ // チケットになる)ので、`#` を含むだけの文章がどこかへ黙って吸い込まれることはない。
1830
+ // 本文は削らない: 言われた言葉をそのまま作業AIへ渡す(P0 の「原文100%」と同じ約束)。
1831
+ export function parseGoalAddress(text) {
1832
+ // `(?![\w-])` =「番号の直後に英数字やハイフンが続くなら、それは番号ではない」:
1833
+ // `#1f6f4a`(色) や `#12-3` を宛先と読み違えない。日本語は \w に入らないので
1834
+ // 「#12の件だけど」はちゃんと宛先になる。
1835
+ const m = /(?:^|[\s((「【])#(\d+)(?![\w-])/.exec(String(text ?? ''));
1836
+ if (!m) return null;
1837
+ const goalId = Number(m[1]);
1838
+ return Number.isSafeInteger(goalId) && goalId > 0 ? { goalId } : null;
1628
1839
  }
1629
1840
 
1630
1841
  // Reject(07-20c・Dismiss とは別物)は自動再作業をせず、Rejected 欄に溜める 'rejected'
@@ -2219,6 +2430,15 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code')
2219
2430
  lastFailure ? `\nPREVIOUS ATTEMPT FAILED EXTERNAL VERIFICATION: ${lastFailure}\nYour previous changes are on disk. Diagnose and fix.` : '',
2220
2431
  '',
2221
2432
  'RULES:',
2433
+ // The board is a window onto the agent's own plan — it holds nothing this
2434
+ // layer invented. That only works if the agent keeps a plan where we can see
2435
+ // it. Claude Code reaches for TodoWrite on its own; Codex has the same list
2436
+ // but skips it on anything small (measured 2026-07-22: gpt-5.4-mini emitted
2437
+ // no todo_list even when the REQUEST said "in three steps" — it does emit one
2438
+ // when told to track the work in its to-do list). So ask, once, for every
2439
+ // agent — the instruction is about how to work, never about what the request
2440
+ // is, so no request-type branching enters here.
2441
+ '- 作業の手順は最初に自分の Todo リスト(Claude Code なら TodoWrite、Codex なら todo list)に書き出し、進むたびに更新すること。人はこの Todo を見て進み具合を知る。',
2222
2442
  '- Edit only files inside this directory and this project. Stay on this request; do not wander into unrelated work.',
2223
2443
  // Deliverable fidelity (replaces the old "make the smallest safe change
2224
2444
  // instead of expanding scope"): the request, not our fear of scope, sets
@@ -2243,7 +2463,7 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code')
2243
2463
  // it is the one that says how to run it. Requests with nothing to run
2244
2464
  // (research, summaries, answers) simply skip this — the worker decides,
2245
2465
  // never us.
2246
- `- 人が実際に動かして結果を見られる依頼なら、最後にプロジェクト直下へ ${RUN_DECL_FILE} を書く: {"cmd": "<起動コマンド>", "url": "<開くURL>", "note": "<何が見えるか1行>", "check": "<人に何を見て判断してほしいか1行>"}。Manager はこれを使って人に「動かして見る」と「何を見ればいいか」を出す。動かして見せるものが無い依頼(調査・要約・質問への回答など)では書かなくてよい。`,
2466
+ `- 人が実際に動かして結果を見られる依頼なら、最後にプロジェクト直下へ ${RUN_DECL_FILE} を書く: {"cmd": "<起動コマンド>", "url": "<開くURL>", "note": "<何が見えるか1行>", "check": "<人に何を見て判断してほしいか1行>", "title": "<この作業のチケット名。対象が分かる程度に説明的で動作を含む短い名前。例「看板モードの追加」「ヘッダのズレの修正」。依頼文をそのまま写さない>"}。Manager はこれを使って人に「動かして見る」と「何を見ればいいか」を出す。動かして見せるものが無い依頼(調査・要約・質問への回答など)では書かなくてよい。`,
2247
2467
  // Links are rendered as links. The product cannot tell a real one from a
2248
2468
  // decorative one, so the only place this can be held is here.
2249
2469
  `- 自分が作っていない場所へのリンクを報告に書かない(存在しないURLや「詳しくはここ」のような飾りのリンクは、そのままリンクとして人に表示される)。`,
@@ -2251,7 +2471,18 @@ export function workerPrompt(task, goal, lastFailure, agentName = 'claude-code')
2251
2471
  // to use it — that is the worker's call, like the run declaration above.
2252
2472
  // Telling it "screenshot UI changes" would be this layer deciding what kind
2253
2473
  // 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 で自分でも見返せる。`,
2474
+ // ...but only to an agent that can actually take one. Codex runs its shell
2475
+ // inside a sandbox that Chrome cannot start under (measured 2026-07-22 on
2476
+ // codex-cli 0.142.4: the camera fails under `workspace-write` and works only
2477
+ // with the sandbox off — opening network access does not help). Offering it
2478
+ // anyway is not a harmless extra line: the Codex worker tried four times,
2479
+ // then reported the failure instead of the work. Do not ask an agent for
2480
+ // something this machine has proven it cannot do (Masa 2026-07-22).
2481
+ agentName === 'codex' ? '' : `- 画面・見た目のあるものを作ったなら、自分でスクリーンショットを撮って見せてよい: \`node ${SHOT_CLI} <URL または HTMLファイル> ${SHOT_DIR}/<名前>.png [--full] [--wait-for <CSSセレクタ>] [--element <CSSセレクタ>] [--clip x,y,幅,高さ]\`(headless Chrome・前面に出ない)。変えた場所が小さいなら --element / --clip でそこだけ撮ってよい(ページ全体を撮って「どこが違うか探して」にしない)。${SHOT_DIR}/ に置いた画像は Manager が回収して人に見せる(diff には入らない)。撮ったものは Read で自分でも見返せる。`,
2482
+ // The pair, declared by the only party that can know it. Deliberately not
2483
+ // "take a before shot before you start": that would be this layer deciding
2484
+ // the request is a change-something request, and most requests are not.
2485
+ agentName === 'codex' ? '' : `- 同じ画面の「変える前」と「変えた後」を撮ったなら、${RUN_DECL_FILE} に \`"compare": ["<前の画像>.png", "<後の画像>.png"]\` と書いてよい(その2枚が同じ場所の前後だと分かるのは撮った本人だけ)。人には並べて、違う部分に印を付けて見せる。3案を並べた等、前後の関係が無い画像には書かない。`,
2255
2486
  `- 最後に${replyLang}で2〜4行:何を変えたか・どのファイルか・テスト結果・人間が確認すべき点。`,
2256
2487
  ].filter(Boolean).join('\n');
2257
2488
  }
@@ -2277,15 +2508,42 @@ export function parseRunDeclaration(text) {
2277
2508
  const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
2278
2509
  const cmd = str(raw.cmd, 500);
2279
2510
  const url = str(raw.url, 500);
2280
- // Nothing to start and nothing to open = nothing to look at.
2281
- if (!cmd && !url) return null;
2511
+ // `compare` (Masa 2026-07-22): which TWO of the worker's own screenshots are
2512
+ // the same view before and after. Only the agent that did the work knows that
2513
+ // two images relate — a request for three design options has no "after" at
2514
+ // all — so this layer must never infer it. (The app briefly guessed the pair
2515
+ // from file names containing "before"/"after": the same mistake as the intake
2516
+ // AI writing a pass condition, and it is not repeated here.)
2517
+ const compare = parseComparePair(raw.compare);
2518
+ const title = str(raw.title, 60);
2519
+ // Nothing to start, nothing to open, nothing named, and no pair to show = nothing to look at.
2520
+ if (!cmd && !url && !compare && !title) return null;
2282
2521
  // Only http(s): the button opens this in the user's browser.
2283
2522
  if (url && !/^https?:\/\//i.test(url)) return null;
2284
2523
  // `check` (Masa 2026-07-22): what to look at, in the working AI's own words. It used
2285
2524
  // to come from the intake AI's plan — this layer inventing a pass condition for a
2286
2525
  // request it only read. The AI that did the work states it here instead, in the same
2287
2526
  // 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) };
2527
+ // `title` (Masa 2026-07-22): the ticket name, from the AI that did the work.
2528
+ // Until now the name came from a summarizer LLM down here, and when that call
2529
+ // returned nothing the card fell back to the raw request — so the "title" was
2530
+ // the user's own sentence, repeated back at them in the one place that is
2531
+ // supposed to say what the work WAS. The agent that just did it can name it in
2532
+ // one line, and it is the only party that knows what actually got done.
2533
+ return { cmd, url, note: str(raw.note, 300), check: str(raw.check, 300), compare, title };
2534
+ }
2535
+
2536
+ // Exactly two shot file names, in [before, after] order. Anything else is
2537
+ // dropped rather than half-accepted: the card draws a red box over the region
2538
+ // that differs between the two, and a box drawn over the wrong pair is the
2539
+ // fabricated-evidence bug again. Names only — a path would let a declaration
2540
+ // point outside the shots the Manager collected.
2541
+ export function parseComparePair(value) {
2542
+ if (!Array.isArray(value) || value.length !== 2) return null;
2543
+ const names = value.map((v) => (typeof v === 'string' ? v.trim() : '')).map((n) => n.replace(/^.*[/\\]/, ''));
2544
+ if (names.some((n) => !n || n.length > 120)) return null;
2545
+ if (names[0] === names[1]) return null; // a picture compared with itself shows nothing
2546
+ return names;
2289
2547
  }
2290
2548
 
2291
2549
  // ---- SHOTS: the worker's own camera ----------------------------------------
@@ -2333,11 +2591,11 @@ export function pickChromePath(env = {}, exists = () => false) {
2333
2591
  }
2334
2592
 
2335
2593
  // `node <manager>/engine/shot.mjs <url|file> <out.png> [--width N] [--height N]
2336
- // [--full] [--wait MS] [--wait-for SELECTOR]`
2594
+ // [--full] [--wait MS] [--wait-for SELECTOR] [--element SELECTOR] [--clip x,y,w,h]`
2337
2595
  // Returns { error } instead of throwing, so the CLI can print one clear line.
2338
2596
  export function parseShotArgs(argv = []) {
2339
2597
  const rest = [];
2340
- const opt = { width: 1440, height: 900, full: false, wait: 0, waitFor: null };
2598
+ const opt = { width: 1440, height: 900, full: false, wait: 0, waitFor: null, element: null, clip: null };
2341
2599
  const num = (v, lo, hi) => {
2342
2600
  const n = Number(v);
2343
2601
  return Number.isFinite(n) && n >= lo && n <= hi ? Math.round(n) : null;
@@ -2360,12 +2618,33 @@ export function parseShotArgs(argv = []) {
2360
2618
  if (!sel) return { error: '--wait-for needs a CSS selector' };
2361
2619
  opt.waitFor = sel; continue;
2362
2620
  }
2621
+ // Region (Masa 2026-07-22). A full-page picture of a one-component change
2622
+ // hands the reviewer a page and asks them to find the difference — and the
2623
+ // agent that made the change is the only one who knows where to point. Two
2624
+ // ways in: a selector when the worker has one, coordinates when it doesn't.
2625
+ if (a === '--element') {
2626
+ const sel = String(argv[++i] ?? '').trim();
2627
+ if (!sel) return { error: '--element needs a CSS selector' };
2628
+ opt.element = sel; continue;
2629
+ }
2630
+ if (a === '--clip') {
2631
+ const parts = String(argv[++i] ?? '').split(',').map((s) => s.trim());
2632
+ if (parts.length !== 4) return { error: '--clip needs x,y,width,height (e.g. --clip 0,0,800,400)' };
2633
+ const [x, y, width, height] = parts.map((v, idx) => num(v, idx < 2 ? 0 : 1, 20000));
2634
+ if ([x, y, width, height].some((n) => n == null)) return { error: '--clip needs four numbers: x,y,width,height' };
2635
+ opt.clip = { x, y, width, height }; continue;
2636
+ }
2363
2637
  if (a.startsWith('--')) return { error: `unknown option ${a}` };
2364
2638
  rest.push(a);
2365
2639
  }
2366
2640
  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]' };
2641
+ if (!target || !out) return { error: 'usage: node engine/shot.mjs <url|file> <out.png> [--width N] [--height N] [--full] [--wait MS] [--wait-for SELECTOR] [--element SELECTOR] [--clip x,y,w,h]' };
2368
2642
  if (!/\.png$/i.test(out)) return { error: 'output must end in .png' };
2643
+ // One region, not two — a selector and a rectangle disagree the moment the
2644
+ // page reflows, and silently preferring one would make the picture a guess.
2645
+ if (opt.element && opt.clip) return { error: 'use --element or --clip, not both' };
2646
+ // A region IS the framing, so --full has nothing left to mean.
2647
+ if (opt.full && (opt.element || opt.clip)) return { error: '--full cannot be combined with --element/--clip' };
2369
2648
  return { target, out, ...opt };
2370
2649
  }
2371
2650
 
@@ -3411,6 +3690,15 @@ export function needsProjectFolder({ dir, homeDir, isRepo } = {}) {
3411
3690
  return !isRepo;
3412
3691
  }
3413
3692
 
3693
+ // The repo's own name: the last segment of its path. The composer chip prints this and
3694
+ // the folder menu prints it again on every row, so it lives here as one function — two
3695
+ // copies of "take the last segment" is how a chip and its own menu start disagreeing
3696
+ // (CDO handoff 2026-07-23 §A2). Trailing slashes are the caller's typo, not a segment.
3697
+ export function folderName(dir) {
3698
+ const d = String(dir ?? '').replace(/\/+$/, '');
3699
+ return d ? d.split('/').pop() : '';
3700
+ }
3701
+
3414
3702
  // A short, ≤60-char display label for a repo dir, shown home-relative (~/…) so
3415
3703
  // the folder-choice options read cleanly on the board card.
3416
3704
  export function folderLabel(dir, homeDir = '') {