@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/app/index.html CHANGED
@@ -853,20 +853,15 @@
853
853
  .reqdesc .mdsp{height:6px}
854
854
  .reqdesc code,.gdtaskresult code{font-family:var(--mono);font-size:11px;background:rgba(var(--tint),.07);padding:1px 4px;border-radius:4px}
855
855
  .reqdesc strong{color:var(--ink);font-weight:700}
856
+ /* an image the AI pasted into its own report: shown full-width at its own aspect,
857
+ hairline edge only (§5.5 縁は1px以下のヘアライン), never a fixed-ratio crop box */
858
+ .mdimg{display:block;max-width:100%;height:auto;margin:8px 0;border-radius:8px;border:1px solid var(--hair)}
856
859
  .reqcond{font-family:var(--mono);font-size:11px;color:var(--ink3);margin-top:4px}
857
860
  .reqvideo{margin:10px 0}
858
861
  .reqnovideo{font-size:12px;color:var(--ink3);border:1px dashed var(--hair2);border-radius:10px;padding:16px;text-align:center;margin:10px 0}
859
862
  .beforeafter{display:grid;grid-template-columns:1fr 1fr;gap:8px}
860
863
  .ba-col{min-width:0}
861
864
  .balabel{display:block;font-family:var(--mono);font-size:9px;letter-spacing:.06em;color:var(--ink3);margin-bottom:2px}
862
- /* diff box + zoomed inset (HANDOFF-review-card-diffbox-2026-07-18) — only emitted
863
- by beforeAfterBoxedHtml(), so these never touch the plain pair above or the
864
- older per-task renderReviewChecklist before/after (out of scope, untouched). */
865
- .ba-imgwrap{position:relative;margin-top:8px}
866
- .ba-imgwrap .proofimg{width:100%;height:auto;max-height:none;object-fit:initial;margin-top:0;display:block}
867
- .ba-box{position:absolute;border:1.5px solid var(--danger);border-radius:2px;pointer-events:none}
868
- .ba-zoomhd{margin-top:14px}
869
- .lg-ev .ba-zoomimg{display:block;width:100%;height:auto;max-height:none;object-fit:initial;margin-top:8px;border-radius:9px;border:1px solid var(--hair2)}
870
865
  .reqbtns{display:flex;gap:8px;margin-top:4px}
871
866
  .reqbtn{height:30px;padding:0 14px;border-radius:9px;border:1px solid var(--hair2);background:transparent;
872
867
  color:var(--ink2);font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;transition:.15s}
@@ -1620,6 +1615,14 @@
1620
1615
  .lbc.is-quiet .lbc-t{color:var(--ink2)} /* Done / Later / To Do: visible, not shouting */
1621
1616
  .lbc-w{font:600 8.5px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase;margin-left:auto}
1622
1617
  .lbc-w.bad{color:var(--danger)} .lbc-w.intr{color:#ffca16}
1618
+ /* P0b: the AI's own To-do breakdown, scaled down to the card's density (§1.5-8 fold, not
1619
+ scroll — capped + "+N more" text rather than an inner scrollbar). Reuses the .todo/.box/
1620
+ .tdtext vocabulary from the chat feed (todosHtml) so the ✓/▸/○ language stays one system. */
1621
+ .lbc-todos{display:flex;flex-direction:column;gap:3px}
1622
+ .lbc-todos .todo{font-size:10.5px;line-height:1.4;gap:5px}
1623
+ .lbc-todos .todo .box{width:11px;height:11px;font-size:9px;line-height:11px;margin-top:1px}
1624
+ .lbc-todos.prep{font-size:10.5px;color:var(--ink3);font-style:italic}
1625
+ .lbc-more{font:500 9.5px/1 var(--mono);color:var(--ink3)}
1623
1626
  .lbc-meter{display:flex;align-items:center;gap:7px}
1624
1627
  .lbc-meter .bar{flex:1;height:2px;border-radius:2px;background:color-mix(in srgb,var(--ink) 11%,transparent);overflow:hidden}
1625
1628
  .lbc-meter .bar i{display:block;height:100%;border-radius:2px;background:var(--green);transition:width .3s}
@@ -1712,33 +1715,12 @@
1712
1715
  /* review row number — #NN PREFIX on the title (HANDOFF-v45 §11), same form as the To Do
1713
1716
  rows (PRD §5: numbering matches the sidebar) */
1714
1717
  #fsRoot .rcard .rid{font-size:10.5px;font-weight:600;color:var(--ink3);margin:0 5px 0 0;display:inline-block;font-variant-numeric:tabular-nums}
1715
- /* PRD §6.5 fold note on the carrier ("also carries #13") and on the folded card
1716
- ("merged into #12"). Deliberately NOT the .pr treatment: no tinted ground, no
1717
- badge (§1.5-9), no red folding is not a failure. Mono + --ink3 = the same quiet
1718
- meta tone the rest of the lane uses (§5.5「品は罫線とトーン差・monoで出す」). */
1719
- /* display:block, not inline-block: it shares .rc with the PR pill, which is itself
1720
- inline-block side by side the pill sat on top of the last "#NN" and ate it. */
1721
- #fsRoot .rcard .fnote{font-family:var(--mono);font-size:9.5px;color:var(--ink3);margin-top:3px;display:block}
1722
- /* In rv3 the meta sits on the near-black photo scrim whatever the theme, where --ink3
1723
- is effectively invisible (§5.5「見えるが騒がない」— near-invisible is a fail). Match
1724
- .rid's white-on-scrim treatment. The folded card opts out of the scrim (rule above),
1725
- so it keeps --ink3 on the panel where that tone is correct. */
1726
- #fsRoot[data-rv="3"] .rcard:not(.fold) .fnote{color:rgba(255,255,255,.66);text-shadow:0 1px 3px rgba(0,0,0,.55)}
1727
- /* A folded card is settled, not failed: title sinks to the settled tone, and it
1728
- keeps no ✓/✕ because the thing to approve lives on the goal it was folded into. */
1729
- #fsRoot .rcard.fold .t{color:var(--ink2)}
1730
- /* A folded card is a pointer, not a review item: no proof to show, nothing to approve.
1731
- So it opts OUT of the rv3 thumbnail treatment (whose .rc is absolutely positioned
1732
- over an 84px photo ground) and stays the same quiet line in every view — the form
1733
- Masa drew. Giving it a fake dark thumbnail would claim it has something to look at. */
1734
- /* .fold.noimg, not .fold: the placeholder-ground rule below is `.rcard.noimg`, which
1735
- ties this on specificity and would win on order — a folded card would keep the fake
1736
- dark thumbnail ground. Matching both classes settles it regardless of order. */
1737
- #fsRoot[data-rv="3"] .rcard.fold.noimg{display:flex;position:static;align-items:center;gap:10px;
1738
- padding:9px 10px;min-height:0;border:1px solid var(--hair);border-radius:11px;background:none;box-shadow:none}
1739
- #fsRoot[data-rv="3"] .rcard.fold.noimg .rc{position:static;padding:0;background:none}
1740
- #fsRoot[data-rv="3"] .rcard.fold.noimg .t{color:var(--ink2);font-size:12.5px;text-shadow:none}
1741
- #fsRoot[data-rv="3"] .rcard.fold.noimg .rid{color:var(--ink3)}
1718
+ /* PRD §6.5 Masa 2026-07-21: the fold note ("also carries #13" on the carrier card,
1719
+ "merged into #12" on the folded card) no longer renders in the Review list at all
1720
+ it read as the same report-in-the-list clutter the feature was meant to avoid. Where
1721
+ folded work went is now logged in the opened detail's "Also carries" section only, so
1722
+ the .fnote / .rcard.fold* rules that used to style these list-level notes are gone
1723
+ with them (dead CSS orphaned the moment nothing emits those classes any more). */
1742
1724
  /* reference numbers everywhere else (HANDOFF-v45 §11): a leading #NN prefix on every
1743
1725
  To Do item's name — same goal = same number (goalReviewNumber), distinct from the
1744
1726
  PR's own #id shown separately. */
@@ -2010,6 +1992,17 @@
2010
1992
  .redesign .rmeta-detail.open{display:block}
2011
1993
  .redesign .rmeta-detail .k{font:600 8px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink3);margin-bottom:5px;display:block}
2012
1994
  .redesign .rmeta-detail .v{font:500 11.5px/1.6 var(--ui);color:var(--ink2)}
1995
+ /* Open it section: the address is the control. Mono so it reads as an address, plain
1996
+ text weight so it doesn't shout, and hover moves the GLYPH colour only — never a
1997
+ filled surface (§5.5 アイコンボタンのhoverは面を塗らない). */
1998
+ .redesign .ropen .olink{display:inline-flex;align-items:baseline;gap:6px;padding:0;border:0;background:none;cursor:pointer;
1999
+ font:400 13.5px/1.62 var(--mono);color:var(--ink);letter-spacing:-.004em;text-align:left;overflow-wrap:anywhere;transition:color .14s}
2000
+ .redesign .ropen .olink:hover{color:var(--green)}
2001
+ .redesign .ropen .olink[disabled]{color:var(--ink3);cursor:default}
2002
+ .redesign .ropen .olink .ar{font-size:.85em;color:var(--ink3)}
2003
+ /* the note is body prose like every other section's — same 14px / −.012em / --ink as
2004
+ .rbody (Masa 2026-07-22: it was a shade lighter and read as a different kind of text) */
2005
+ .redesign .ropen .v{margin:4px 0 0 !important}
2013
2006
  /* sections — Linear-docs bold sentence-case headings + high-contrast body.
2014
2007
  margins are !important: the app's pre-existing .lg / .sa-item rules load first and
2015
2008
  otherwise tie/beat these on specificity, flattening the Linear-docs rhythm. */
@@ -2019,6 +2012,22 @@
2019
2012
  13px read thinner/lighter for Japanese than the surrounding app). §2. */
2020
2013
  .redesign .rbody{font-size:14px;line-height:1.62;letter-spacing:-.012em;color:var(--ink);max-width:70ch;margin:0 !important}
2021
2014
  .redesign .rdesc,.redesign .rrisk{color:var(--ink2)}
2015
+ /* Result (P1, Masa 2026-07-21): the AI's own report, rendered as written. Same 14px
2016
+ body rhythm as .rbody — it is prose the reviewer reads, not a log. Headings/lists/
2017
+ code inside it are the report's own structure, so they get the section's type scale
2018
+ rather than the smaller .reqdesc one used in the older checklist dialog. */
2019
+ .redesign .rreport{font-size:14px;line-height:1.62;letter-spacing:-.012em;color:var(--ink);max-width:70ch;overflow-wrap:anywhere}
2020
+ .redesign .rreport .mdh{font-weight:680;color:var(--ink);margin:14px 0 4px}
2021
+ .redesign .rreport .mdh:first-child{margin-top:0}
2022
+ .redesign .rreport .mdul{margin:2px 0;padding-left:20px}
2023
+ .redesign .rreport .mdul li{margin:2px 0}
2024
+ .redesign .rreport .mdsp{height:8px}
2025
+ .redesign .rreport code{font-family:var(--mono);font-size:12px;background:rgba(var(--tint),.07);padding:1px 4px;border-radius:4px}
2026
+ .redesign .rreport strong{color:var(--ink);font-weight:700}
2027
+ .redesign .rreport a{color:var(--ink);text-decoration:underline;text-underline-offset:2px}
2028
+ /* "Open it" is waiting on a real process to boot (up to ~15s), so the chip says so
2029
+ rather than looking dead. Dim + no pointer, no spinner — §5.5 薄く・主張しない. */
2030
+ .redesign .mx.mwait{opacity:.55;pointer-events:none}
2022
2031
  .redesign .rfiles{margin:12px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink2);line-height:1.75}
2023
2032
  .redesign .rfiles .a{color:#3fd07a;font-style:normal}.redesign .rfiles .d{color:#ff7b72;font-style:normal}
2024
2033
  /* Request accordion (spec §3) — chevron › rotate 90, body indented, no left rule */
@@ -3361,16 +3370,6 @@ function reviewCardConfigFor(projectId){ return { ...DEFAULT_REVIEW_DEFINITION.r
3361
3370
  // goal's own request language, same as the planner/worker; ja/en pin the
3362
3371
  // project to one language regardless of what a given goal was typed in.
3363
3372
  function reviewLangFor(projectId){ const l = reviewDefinitionFor(projectId).language; return (l === 'en' || l === 'ja') ? l : 'auto'; }
3364
- // PRD §6.5 fold note language (Masa 2026-07-17: "ユーザーの言語に合わせて" — not
3365
- // hardcoded English). Mirrors the server's own 'auto' resolution exactly
3366
- // (reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : ...,
3367
- // engine/server.mjs) so this note reads in the same language as the review
3368
- // headline/PR body for the same goal: the project's fixed override wins if set,
3369
- // otherwise match the language the goal's own request was written in.
3370
- function foldLangFor(goal){
3371
- const l = reviewLangFor(state.active);
3372
- return (l === 'ja' || l === 'en') ? l : (/[぀-ヿ㐀-鿿]/.test(goal?.text || '') ? 'ja' : 'en');
3373
- }
3374
3373
 
3375
3374
  function esc(s){ const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
3376
3375
 
@@ -3522,30 +3521,28 @@ function saReviewTitle(g){
3522
3521
  // reviewSummary.title, so they fall back to the request text unchanged (no regression).
3523
3522
  return String(g?.reviewSummary?.title || g?.text || g?.plan?.[0] || g?.reviewSummary?.changed || '').replace(/\s+/g, ' ').trim().slice(0, 90);
3524
3523
  }
3525
- // H23 (2026-07-18) evidence-kind detection. Mirrors engine/lib.mjs isUiChange() /
3526
- // hasTestRelevantChanges() kept in sync manually since the browser script isn't a
3527
- // module. flagship's demo data carries an explicit `kind` field
3528
- // ('image'|'logic'|'docs'|'missing'); real goals/tasks have no such field, so the
3529
- // review card derives one from real signals it already has (a captured proof image,
3530
- // whether changed files look like UI source, whether they're all docs-only). This
3531
- // is a HEURISTIC, not a stored fact on the goal flagged as a judgment call in the
3532
- // H23 implementation PR for a human to correct if it misclassifies real data.
3533
- const UI_TOUCH_RE_LOCAL = /^app\/|\.(?:html|css|tsx|jsx|vue|svelte)$/i;
3534
- function isUiChangeLocal(files){ return (files ?? []).some((f) => UI_TOUCH_RE_LOCAL.test(String(f ?? ''))); }
3524
+ // H23 (2026-07-18) evidence-kind detection. flagship's demo data carries an explicit
3525
+ // `kind` field; real goals/tasks have no such field, so the review card derives one
3526
+ // from real signals it already has (a captured proof image, whether the changed files
3527
+ // are all docs-only).
3528
+ //
3529
+ // P1 (Masa 2026-07-21): the 'missing' kind is gone, and with it the app-local copy of
3530
+ // isUiChange(). It said "this touched the UI, so a screenshot SHOULD exist" a rule
3531
+ // from the era when we photographed the work ourselves. That capture never ran outside
3532
+ // this one repo, so the only thing the rule ever produced was an apology on every UI
3533
+ // goal. We don't take the photo any more, so there is nothing to apologise for.
3535
3534
  const DOC_ONLY_RE_LOCAL = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/.*\.(md|mdx|txt)$|\.mdx?$|\.txt$/i;
3536
3535
  function hasTestRelevantChangesLocal(files){
3537
3536
  const fs = (files ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
3538
3537
  if (!fs.length) return true;
3539
3538
  return fs.some((f) => !DOC_ONLY_RE_LOCAL.test(f));
3540
3539
  }
3541
- // Priority: image (a real capture exists) > missing (UI touched, should have a
3542
- // capture per PRD §4(C) but doesn't) > docs (every changed file is docs-only) >
3540
+ // Priority: image (a real capture exists) > docs (every changed file is docs-only) >
3543
3541
  // logic (verified only by tests, no UI). Falls back to 'image' (today's generic
3544
3542
  // fact-row + no image) when no signal is conclusive — never claims a stronger kind
3545
3543
  // than the data actually supports.
3546
3544
  function saEvidenceKind(filePaths, hasProofImg, testResult){
3547
3545
  if (hasProofImg) return 'image';
3548
- if (isUiChangeLocal(filePaths)) return 'missing';
3549
3546
  if (filePaths.length && !hasTestRelevantChangesLocal(filePaths)) return 'docs';
3550
3547
  if (testResult?.ran) return 'logic';
3551
3548
  return 'image';
@@ -3596,11 +3593,28 @@ function requirementSummaryLine(task){
3596
3593
  // Worker reports are written in Markdown (## headings, **bold**, - lists, `code`).
3597
3594
  // The review cards showed them raw, so "## " and "**" leaked as literal text.
3598
3595
  // Render the common subset to HTML (escaping first) so they read cleanly.
3599
- function mdLite(src){
3596
+ // P1 (Masa 2026-07-21): the AI's report IS the deliverable, so an image it pasted
3597
+ // (`![alt](url)`) has to render as that image — this is the whole "paste it and it
3598
+ // shows up" wiring. Only src values we can vouch for are allowed through: http(s),
3599
+ // a same-origin path, or an inline data:image. Anything else (javascript:, data:text
3600
+ // /html, …) renders as the literal text it was, never as an <img src>.
3601
+ const MD_IMG_SRC_OK = /^(?:https?:\/\/|\/(?!\/)|data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,)/i;
3602
+ // `images: false` renders a pasted image as nothing at all. Masa 2026-07-22, after
3603
+ // seeing one in a real card: the review card is not a gallery — the way to see the work
3604
+ // is Open it, and a lone blurry still of "one of the three" is worse than no still. The
3605
+ // renderer keeps the capability (chat and the older checklist still use images); the
3606
+ // review card is simply not the place.
3607
+ function mdLite(src, { images = true } = {}){
3600
3608
  const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
3601
3609
  const inline = (s) => esc(s)
3602
3610
  .replace(/`([^`]+)`/g, '<code>$1</code>')
3603
3611
  .replace(/\*\*([^*]+?)\*\*/g, '<strong>$1</strong>')
3612
+ // images before links — ![alt](url) would otherwise match the link rule below
3613
+ // and render as a bare "!" plus an <a>.
3614
+ .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, url) => (!images ? ''
3615
+ : MD_IMG_SRC_OK.test(url)
3616
+ ? `<img class="mdimg" src="${url.replace(/"/g, '&quot;')}" alt="${alt}" loading="lazy">`
3617
+ : m))
3604
3618
  // Feedback: make links tappable — markdown [text](url) and bare URLs (e.g. a
3605
3619
  // "open the ReadMe/PROOF.md" link in the worker report).
3606
3620
  .replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
@@ -3740,106 +3754,15 @@ function proofMediaHtml(taskId, proof, altLabel){
3740
3754
  }
3741
3755
  return imgTag;
3742
3756
  }
3743
- // reviewCard.beforeAfter: side-by-side comparison when a "before" capture
3744
- // exists. Proof today never carries one (only a single post-change
3745
- // snapshot/run is captured) so this falls back to the normal single-shot
3746
- // view rather than showing an empty/broken "Before" pane.
3747
- function beforeAfterMediaHtml(taskId, proof, altLabel){
3748
- if (!proof) return '';
3749
- const beforeImg = proof.beforePng ?? proof.beforeGif;
3750
- if (!beforeImg) return proofMediaHtml(taskId, proof, altLabel);
3751
- const beforeTag = `<img class="proofimg" src="${withKey(`/proof/${taskId}/${beforeImg}`)}" alt="before: ${altLabel}" loading="lazy">`;
3752
- return `<div class="beforeafter">
3753
- <div class="ba-col"><span class="balabel">Before</span>${beforeTag}</div>
3754
- <div class="ba-col"><span class="balabel">After</span>${proofMediaHtml(taskId, proof, altLabel)}</div>
3755
- </div>`;
3756
- }
3757
- // Diff box + zoomed inset (HANDOFF-review-card-diffbox-2026-07-18): on top of the
3758
- // plain before/after pair above, isolate the region that actually changed and draw
3759
- // a red box over it on both images, plus a cropped/enlarged row below. Computed
3760
- // client-side from the two PNGs via <canvas> pixel comparison at render time — not
3761
- // baked in at capture time — so it works retroactively on every proof pair already
3762
- // on disk with no new server-side image dependency (see the handoff for the full
3763
- // rationale). Reference algorithm, verified against a real before/after pair
3764
- // (composer icon swap, 640x200): correctly isolated a 57x52px box centered on the
3765
- // changed icon — 0.13% image coverage, no false positives elsewhere in the frame.
3766
- function computeDiffBox(beforeImg, afterImg, { threshold = 24, minCoverage = 0.0005, maxCoverage = 0.7, pad = 0.03 } = {}) {
3767
- const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
3768
- if (w !== afterImg.naturalWidth || h !== afterImg.naturalHeight) return null; // different-shaped images: don't guess
3769
- const c1 = document.createElement('canvas'); c1.width = w; c1.height = h;
3770
- const c2 = document.createElement('canvas'); c2.width = w; c2.height = h;
3771
- c1.getContext('2d').drawImage(beforeImg, 0, 0, w, h);
3772
- c2.getContext('2d').drawImage(afterImg, 0, 0, w, h);
3773
- const d1 = c1.getContext('2d').getImageData(0, 0, w, h).data;
3774
- const d2 = c2.getContext('2d').getImageData(0, 0, w, h).data;
3775
- let minX = w, minY = h, maxX = -1, maxY = -1, diffPx = 0;
3776
- for (let y = 0; y < h; y++) {
3777
- for (let x = 0; x < w; x++) {
3778
- const i = (y * w + x) * 4;
3779
- const d = Math.abs(d1[i] - d2[i]) + Math.abs(d1[i + 1] - d2[i + 1]) + Math.abs(d1[i + 2] - d2[i + 2]);
3780
- if (d > threshold) {
3781
- diffPx++;
3782
- if (x < minX) minX = x; if (x > maxX) maxX = x;
3783
- if (y < minY) minY = y; if (y > maxY) maxY = y;
3784
- }
3785
- }
3786
- }
3787
- const total = w * h;
3788
- if (maxX < 0 || diffPx / total < minCoverage) return null; // no real difference — don't box a no-op
3789
- if (diffPx / total > maxCoverage) return null; // diff too diffuse to be "a spot" — a full redesign shouldn't get a red box either
3790
- const padPx = Math.round(Math.max(w, h) * pad);
3791
- minX = Math.max(0, minX - padPx); minY = Math.max(0, minY - padPx);
3792
- maxX = Math.min(w, maxX + padPx); maxY = Math.min(h, maxY + padPx);
3793
- return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
3794
- }
3795
- function baLoadImage(src){
3796
- return new Promise((resolve, reject) => {
3797
- const im = new Image();
3798
- im.onload = () => resolve(im);
3799
- im.onerror = () => reject(new Error(`image failed to load: ${src}`));
3800
- im.src = src;
3801
- });
3802
- }
3803
- // Crop {x,y,w,h} out of a decoded image and scale it up into a small inset — same
3804
- // aspect ratio as the box, never stretched. Magnification is capped at 6x so a
3805
- // near-single-pixel box doesn't blow up into mush.
3806
- function baZoomDataUrl(img, box, maxW = 420, maxH = 200){
3807
- const scale = Math.min(maxW / box.w, maxH / box.h, 6);
3808
- const outW = Math.max(1, Math.round(box.w * scale));
3809
- const outH = Math.max(1, Math.round(box.h * scale));
3810
- const c = document.createElement('canvas'); c.width = outW; c.height = outH;
3811
- c.getContext('2d').drawImage(img, box.x, box.y, box.w, box.h, 0, 0, outW, outH);
3812
- return c.toDataURL('image/png');
3813
- }
3814
- // Async: loads the before/after PNGs, runs computeDiffBox(), and — only when a real
3815
- // box is found — renders the before/after pair with a red box overlay on each image
3816
- // (percent-positioned so it tracks the image's displayed size at any viewport width)
3817
- // plus a "Zoomed on the boxed area" row below. Returns null on every fallback
3818
- // condition (no PNG pair, load failure, no clean box) so the caller keeps showing
3819
- // the plain beforeAfterMediaHtml() pair unchanged — never a guessed or empty box
3820
- // (HANDOFF-review-card-diffbox-2026-07-18). Only kind==='image' Ledger evidence
3821
- // (saEvidenceHtml) calls this; the older per-task renderReviewChecklist before/after
3822
- // is explicitly out of scope for this handoff and stays on beforeAfterMediaHtml.
3823
- async function beforeAfterBoxedHtml(taskId, proof, altLabel){
3824
- const beforeSrc = proof?.beforePng, afterSrc = proof?.png;
3825
- if (!beforeSrc || !afterSrc) return null; // the box needs two real stills, not a GIF frame
3826
- let beforeImg, afterImg;
3827
- try {
3828
- [beforeImg, afterImg] = await Promise.all([
3829
- baLoadImage(withKey(`/proof/${taskId}/${beforeSrc}`)),
3830
- baLoadImage(withKey(`/proof/${taskId}/${afterSrc}`)),
3831
- ]);
3832
- } catch { return null; } // a missing/broken file is a fallback, not a crash
3833
- const box = computeDiffBox(beforeImg, afterImg);
3834
- if (!box) return null;
3835
- const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
3836
- const boxStyle = `left:${(box.x / w * 100).toFixed(3)}%;top:${(box.y / h * 100).toFixed(3)}%;width:${(box.w / w * 100).toFixed(3)}%;height:${(box.h / h * 100).toFixed(3)}%`;
3837
- const frame = (img, label) => `<div class="ba-col"><span class="balabel">${label}</span><div class="ba-imgwrap"><img class="proofimg" src="${img.src}" alt="${label.toLowerCase()}: ${altLabel}" loading="lazy"><div class="ba-box" style="${boxStyle}"></div></div></div>`;
3838
- const zoomCol = (dataUrl, label) => `<div class="ba-col"><img class="ba-zoomimg" src="${dataUrl}" alt="zoomed ${label.toLowerCase()}: ${altLabel}"></div>`;
3839
- return `<div class="beforeafter">${frame(beforeImg, 'Before')}${frame(afterImg, 'After')}</div>
3840
- <div class="lg-evhd ba-zoomhd">Zoomed on the boxed area</div>
3841
- <div class="beforeafter ba-zoomrow">${zoomCol(baZoomDataUrl(beforeImg, box), 'before')}${zoomCol(baZoomDataUrl(afterImg, box), 'after')}</div>`;
3842
- }
3757
+ // (P1, Masa 2026-07-21) beforeAfterMediaHtml() was deleted here. The pair was staged by
3758
+ // us, not by the work: the "before" shot came from the same photo-staging pass that P1
3759
+ // removed on the engine side (#228), and in practice it never existed at all, so every
3760
+ // card fell through to the single shot anyway. Callers now ask for that shot directly.
3761
+ // (P1, Masa 2026-07-21) The before/after diff-box machinery (computeDiffBox,
3762
+ // baLoadImage, baZoomDataUrl, beforeAfterBoxedHtml) was deleted here. It pixel-diffed a
3763
+ // "before" capture against an "after" one to draw a red box around what changed — real
3764
+ // craft, built on a pair that the engine no longer stages (#228) and that never existed
3765
+ // outside this repo anyway. With no before shot there is nothing to compare.
3843
3766
  // swap the still for the real video only on demand, one at a time
3844
3767
  document.addEventListener('click', (e) => {
3845
3768
  const w = e.target.closest('.proofwrap');
@@ -3904,15 +3827,28 @@ function progressMeta(t){
3904
3827
  // checked + struck through, the in-progress one is highlighted, pending ones
3905
3828
  // are dimmed. Empty list → nothing (workers without todos look unchanged).
3906
3829
  const TODO_MARK = { completed: '✓', in_progress: '▸', pending: '○' };
3907
- function todosHtml(t){
3908
- const list = state.todos[t.id] ?? [];
3909
- if (!list.length) return '';
3910
- const rows = list.map((d) => {
3830
+ // P0b (Masa 2026-07-21, planner demoted): the AI's own TodoWrite list is now the
3831
+ // only breakdown a goal has (1 goal = 1 task, no invented sub-tasks). `state.todos`
3832
+ // mirrors it live via the 'todos' SSE event; once the task finishes, the server
3833
+ // freezes the same shape into `task.todosFinal` (engine/server.mjs) since the live
3834
+ // field gets stripped off finished tasks. This is the one place that resolves
3835
+ // "which list to show" — live wins while it has content, else the frozen one.
3836
+ function todoSourceFor(t){
3837
+ const live = state.todos[t?.id];
3838
+ if (Array.isArray(live) && live.length) return live;
3839
+ return Array.isArray(t?.todosFinal) ? t.todosFinal : [];
3840
+ }
3841
+ function todoRowsHtml(list){
3842
+ return list.map((d) => {
3911
3843
  const st = ['completed', 'in_progress', 'pending'].includes(d.status) ? d.status : 'pending';
3912
3844
  const label = st === 'in_progress' && d.activeForm ? d.activeForm : d.content;
3913
3845
  return `<div class="todo ${st}"><span class="box">${TODO_MARK[st]}</span><span class="tdtext">${esc(label ?? '')}</span></div>`;
3914
3846
  }).join('');
3915
- return `<div class="todos"><div class="tdhead">To-do</div>${rows}</div>`;
3847
+ }
3848
+ function todosHtml(t){
3849
+ const list = todoSourceFor(t);
3850
+ if (!list.length) return '';
3851
+ return `<div class="todos"><div class="tdhead">To-do</div>${todoRowsHtml(list)}</div>`;
3916
3852
  }
3917
3853
 
3918
3854
  function taskBlock(t){
@@ -4386,7 +4322,6 @@ function renderTasks(){
4386
4322
  const digestRows = buildReviewDigest({ goals: pgoals, tasks: list });
4387
4323
  const digestCount = digestRows.length;
4388
4324
  const dGreen = digestRows.filter((r) => !r.testResult || r.testResult.ok !== false).length;
4389
- const dProof = digestRows.filter((r) => r.proof).length;
4390
4325
  const dPct = digestCount ? Math.round((dGreen / digestCount) * 100) : 0;
4391
4326
  const dBlocked = pgoals.filter((g) => g.status === 'blocked').length; // §10: reflect real blocked count, don't hardcode
4392
4327
  // CDO review 2026-07-08 §6: dGreen/digestCount already exclude blocked goals (they
@@ -4404,7 +4339,7 @@ function renderTasks(){
4404
4339
  ? `<div class="s5sum" id="summaryEntry" title="Open the review summary (ペライチ)">
4405
4340
  <span class="s5exp"><svg width="13" height="13" viewBox="0 0 24 24"><path d="M15 4h5v5M20 4l-7 7M9 20H4v-5M4 20l7-7"/></svg></span>
4406
4341
  <span class="s5ring" style="background:conic-gradient(var(--green) 0 ${dPct}%, var(--hair2) ${dPct}% 100%)"><span class="s5in">${dGreen}/${digestCount}</span></span>
4407
- <span class="s5tx"><span class="s5hl">${dAllClear ? 'Ready to review' : `${dGreen} of ${digestCount} ready`}</span><span class="s5sub">${dAllClear ? 'all green' : dGreen === digestCount ? 'blocked needs attention' : 'some failing'} · ${dProof} with proof · ${dBlocked ? `${dBlocked} blocked` : 'none blocked'}</span></span>
4342
+ <span class="s5tx"><span class="s5hl">${dAllClear ? 'Ready to review' : `${dGreen} of ${digestCount} ready`}</span><span class="s5sub">${dAllClear ? 'all green' : dGreen === digestCount ? 'blocked needs attention' : 'some failing'} · ${dBlocked ? `${dBlocked} blocked` : 'none blocked'}</span></span>
4408
4343
  </div>`
4409
4344
  : '';
4410
4345
  const REVIEW_LIMIT = 5;
@@ -4418,16 +4353,12 @@ function renderTasks(){
4418
4353
  // Feedback 3: the row carries only the check-line + PR link. Tests/proof are
4419
4354
  // internal gate checks (they still block), NOT list clutter; the Review number
4420
4355
  // is redundant with the title; tapping the row opens the detail (no button).
4421
- // PRD §6.5: goals that landed on the same files were folded onto this one, so
4422
- // they ride this PR instead of opening a second one editing the same lines.
4423
- // Whoever opens this review must know it answers more than one request.
4424
- // Only rendered when it actually happened (§1.5 art.3 no empty container).
4425
- const carried = pgoals.filter((x) => x.foldedInto === g.id);
4426
- const carryNote = carried.length ? (foldLangFor(g) === 'ja'
4427
- ? `+ ${carried.map((x) => `#${x.id}`).join(', ')} の仕事もここに入っています`
4428
- : `also carries ${carried.map((x) => `#${x.id}`).join(', ')}`) : '';
4356
+ // PRD §6.5 Masa 2026-07-21: goals folded onto this one are NOT announced here
4357
+ // any more ("+ #901 の仕事もここに入っています" read as the same report-in-the-list
4358
+ // clutter as the old "→ #12 にまとめました" row Masa: review欄にいらない). Where
4359
+ // the work went is logged inside the opened detail's "Also carries" section
4360
+ // (see saItemHtml's carriesBody) instead the list row stays just the ask.
4429
4361
  return `<div class="task revrow" data-goal="${g.id}"><div class="ic pending"></div><div class="tx"><div class="name">${esc(checkLine)} ${goalSourceBadge(g.source)}</div>
4430
- ${carryNote ? `<div class="cond carries">${carryNote}</div>` : ''}
4431
4362
  <div class="cond">${g.pr ? `<a class="prbig" href="${esc(g.pr)}" target="_blank">PR #${esc((g.pr.match(/pull\/(\d+)/) ?? [,''])[1])} ↗</a>` : ''}</div>
4432
4363
  <div class="qreplyrow" data-qreplyrow="${g.id}"${state.qreplyOpen.has(g.id) ? '' : ' hidden'}><input type="text" data-qreplyinput="${g.id}" placeholder="直したい点を書いて送信(Todoに戻して再作業)"><button type="button" class="qreplysend" data-qreplysend="${g.id}" aria-label="Send">${ICONS.up}</button></div></div>
4433
4364
  <div class="revquick">
@@ -4437,16 +4368,10 @@ function renderTasks(){
4437
4368
  </div></div>`;
4438
4369
  }).join('')
4439
4370
  + (reviewGoalsAll.length > REVIEW_LIMIT ? `<div class="more" data-reviewmore="1">${state.showAllReview ? 'show less' : `more · +${reviewGoalsAll.length - REVIEW_LIMIT}`}</div>` : '');
4440
- // PRD §3.5: a folded goal is never deleted deleting it just moves the question
4441
- // from "why is my finished work stuck" to "where did #13 go". It keeps its row and
4442
- // says where its work went. Not a failure: no red, no painted surface (§1.5 art.8);
4443
- // no ✓/✕ either, because the thing to approve lives on the goal it was folded into.
4444
- const foldedRows = pgoals.filter((g) => g.status === 'folded').map((g) => {
4445
- const checkLine = (g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').slice(0, 66);
4446
- const mergedNote = foldLangFor(g) === 'ja' ? `→ #${esc(String(g.foldedInto))} にまとめました` : `merged into #${esc(String(g.foldedInto))}`;
4447
- return `<div class="task revrow foldrow" data-goal="${g.id}"><div class="ic pending"></div><div class="tx"><div class="name">${esc(checkLine)} ${goalSourceBadge(g.source)}</div>
4448
- <div class="cond">${mergedNote}</div></div></div>`;
4449
- }).join('');
4371
+ // PRD §3.5 → Masa 2026-07-21: a folded goal no longer gets its own "→ #12 にまとめました"
4372
+ // row in the Review column (Masa: review欄にいらない・わかりにくい). It is never deleted
4373
+ // where its work went is now logged inside the carrier review's detail ("Also carries",
4374
+ // see saItemHtml), so the column stays clean while the trail is still one tap away.
4450
4375
  const doneRows = grouped(list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num), (t) => row(t));
4451
4376
  // Pending = deliberately shelved goals (e.g. "npm公開はPhase 2"): never
4452
4377
  // planned or queued until Start is pressed. Parked below To do on purpose.
@@ -4458,7 +4383,7 @@ function renderTasks(){
4458
4383
  const savedQAns = captureFocusedAnswerInput($('tasklist'), 'data-qansinput');
4459
4384
  $('tasklist').innerHTML =
4460
4385
  sec('Attention', attention, 'attn')
4461
- + sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review + foldedRows, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
4386
+ + sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
4462
4387
  + sec(pickColumnLabel(cols, 'doing', 'Doing'), doing)
4463
4388
  + sec(pickColumnLabel(cols, 'todo', 'To do'), todo, 'todosec')
4464
4389
  + sec('Pending', pendingRows, 'dim')
@@ -4910,9 +4835,6 @@ function renderRdEditor(){
4910
4835
  <label class="rdrow"><input type="checkbox" id="rdCardScreenshots"${state.rdDraft.reviewCard.screenshots ? ' checked' : ''}>
4911
4836
  <span><span class="rdlabel">スクショ / Screenshots</span>
4912
4837
  <span class="rdhint">レビューカードに証跡(スクショ/GIF)を表示する。</span></span></label>
4913
- <label class="rdrow"><input type="checkbox" id="rdCardBeforeAfter"${state.rdDraft.reviewCard.beforeAfter ? ' checked' : ''}>
4914
- <span><span class="rdlabel">Before/After</span>
4915
- <span class="rdhint">変更前後を並べて表示する(変更前キャプチャが無い場合は通常表示になります)。</span></span></label>
4916
4838
 
4917
4839
  <div class="rdsub">出力言語 / Output language</div>
4918
4840
  <label class="rdrow"><select id="rdLanguage">
@@ -4933,7 +4855,6 @@ function renderRdEditor(){
4933
4855
  $('rdPrScreenshots').addEventListener('change', (e) => { state.rdDraft.prSections.screenshots = e.target.checked; });
4934
4856
  $('rdPrRisk').addEventListener('change', (e) => { state.rdDraft.prSections.risk = e.target.checked; });
4935
4857
  $('rdCardScreenshots').addEventListener('change', (e) => { state.rdDraft.reviewCard.screenshots = e.target.checked; });
4936
- $('rdCardBeforeAfter').addEventListener('change', (e) => { state.rdDraft.reviewCard.beforeAfter = e.target.checked; });
4937
4858
  $('rdLanguage').addEventListener('change', (e) => { state.rdDraft.language = e.target.value; });
4938
4859
  }
4939
4860
  function renderWfEditor(){
@@ -5127,10 +5048,8 @@ function renderReviewChecklist(){
5127
5048
  $('reviewDoneMsg').classList.toggle('show', goal.status === 'done');
5128
5049
  // task 97: doneカードの『元に戻す』——押し間違いのApproveを取り消し、reviewへ戻す。
5129
5050
  $('reviewUndoBtn').onclick = () => revertDoneGoal(goal);
5130
- // reviewCard.screenshots off → the media block (and its "No capture yet"
5131
- // placeholder) is skipped entirely, not just emptied. reviewCard.beforeAfter
5132
- // on → try the before/after comparison (falls back to the single shot when
5133
- // there's no "before" capture — see beforeAfterMediaHtml).
5051
+ // reviewCard.screenshots off → the media block is skipped entirely, not just
5052
+ // emptied. (The Before/After switch is gone with the staged pair it controlled — P1.)
5134
5053
  const rc = reviewCardConfigFor(state.active);
5135
5054
  $('reviewList').innerHTML = reqs.map((t, i) => {
5136
5055
  const st = approvals[t.id];
@@ -5141,13 +5060,14 @@ function renderReviewChecklist(){
5141
5060
  <div class="reqhead"><span class="tick">✓</span><span class="reqnum">${t.num ?? i + 1}</span><span class="reqtitle">${esc(t.title ?? '')}</span><span class="apvd">Approved · tap to reopen</span></div>
5142
5061
  </div>`;
5143
5062
  }
5144
- const mediaInner = rc.beforeAfter ? beforeAfterMediaHtml(t.id, t.proof, esc(t.title ?? '')) : proofMediaHtml(t.id, t.proof, esc(t.title ?? ''));
5145
- const media = mediaInner || '<div class="reqnovideo">No capture yet</div>';
5063
+ // P1: one shot when a promised check actually captured one, otherwise nothing at
5064
+ // all "No capture yet" was us apologising for a photo we no longer go and take.
5065
+ const media = proofMediaHtml(t.id, t.proof, esc(t.title ?? ''));
5146
5066
  // task 156/234 + 160: show the WORKER'S REPORT (what it actually did/found,
5147
5067
  // incl. any test-only verification it describes) — that's what you read to
5148
5068
  // decide. Falls back to the instruction if the worker left no result.
5149
5069
  const report = String(t.result ?? '').replace(/^検証(PASS|FAIL)[^\n]*\n+/, '').replace(/\s+$/, '').trim();
5150
- const desc = report ? `<div class="reqdesc"><span class="reqreportlbl">Worker report</span>${mdLite(report.slice(0, 1600))}</div>`
5070
+ const desc = report ? `<div class="reqdesc"><span class="reqreportlbl">Worker report</span>${mdLite(report)}</div>`
5151
5071
  : t.detail ? `<div class="reqdesc">${mdLite(t.detail.slice(0, 600))}</div>` : '';
5152
5072
  const usage = formatUsageCompact(t.usage);
5153
5073
  const usageBadge = usage ? `<span class="tbadge usage">${usage} tokens</span>` : '';
@@ -6093,6 +6013,11 @@ function renderFsBoard(){
6093
6013
  const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // Doing列トグルの状態
6094
6014
  const { pgoals, list } = fsBucketTodo();
6095
6015
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 60));
6016
+ // Card copy language — same rule as reviewLangFor(state.active) === 'ja', inlined
6017
+ // (not calling reviewLangFor itself) because engine/test/board-goal-level.test.mjs
6018
+ // evals this function body standalone off a minimal `state` stub with no
6019
+ // state.reviewDefinitions; reviewLangFor would throw there.
6020
+ const boardJa = state.reviewDefinitions?.[state.active]?.language === 'ja';
6096
6021
  const goalNo = new Map();
6097
6022
  for (const g of pgoals) { const n = goalReviewNumber({ goal: g, tasks: list }); if (n != null) goalNo.set(g.id, n); }
6098
6023
  // + New in EVERY column, matching the flagship Artifact: a hover-reveal "+" in the header
@@ -6107,6 +6032,35 @@ function renderFsBoard(){
6107
6032
  // status, so the card never repeats it. Stopped/failed carry NO bar (a dead bar would claim
6108
6033
  // the goal is moving) — a coloured word (Failed/Interrupted) says it instead.
6109
6034
  const goalTasks = (g) => list.filter((t) => t.goalId === g.id && !t.reply);
6035
+ // P0b (Masa 2026-07-21): the card's breakdown is the AI's OWN TodoWrite list — never
6036
+ // the planner's invented sub-tasks (there aren't any anymore; 1 goal = 1 task). Live
6037
+ // list wins while it has content, else the frozen task.todosFinal (mirrors the
6038
+ // module-level todoSourceFor() used by the chat feed's todosHtml — duplicated inline
6039
+ // rather than called, same reason as boardJa above: this whole function is evaluated
6040
+ // standalone by the board-goal-level test harness, which only stubs `state`/`esc`).
6041
+ const todoListFor = (t) => {
6042
+ const live = state.todos?.[t?.id];
6043
+ if (Array.isArray(live) && live.length) return live;
6044
+ return Array.isArray(t?.todosFinal) ? t.todosFinal : [];
6045
+ };
6046
+ // Capped + folded rather than scrolled (§1.5-8). While the task is actively running
6047
+ // but hasn't reached its first TodoWrite yet, the list is genuinely empty — show one
6048
+ // small "preparing" line instead of an empty container (§1.5-3), never a bare gap.
6049
+ const TODO_CARD_CAP = 5, TODO_MARK_BOARD = { completed: '✓', in_progress: '▸', pending: '○' };
6050
+ const goalCardTodosHtml = (t) => {
6051
+ if (!t) return '';
6052
+ const list_ = todoListFor(t);
6053
+ if (!list_.length) return t.status === 'running' ? `<div class="lbc-todos prep">${boardJa ? '準備中…' : 'Preparing…'}</div>` : '';
6054
+ const shown = list_.slice(0, TODO_CARD_CAP);
6055
+ const restN = list_.length - shown.length;
6056
+ const rows = shown.map((d) => {
6057
+ const st = ['completed', 'in_progress', 'pending'].includes(d.status) ? d.status : 'pending';
6058
+ const label = st === 'in_progress' && d.activeForm ? d.activeForm : d.content;
6059
+ return `<div class="todo ${st}"><span class="box">${TODO_MARK_BOARD[st]}</span><span class="tdtext">${esc(label ?? '')}</span></div>`;
6060
+ }).join('');
6061
+ const more = restN > 0 ? `<div class="lbc-more">+${restN} more</div>` : '';
6062
+ return `<div class="lbc-todos">${rows}${more}</div>`;
6063
+ };
6110
6064
  const goalCard = (g) => {
6111
6065
  const bkt = fsBoardBucket(g, list);
6112
6066
  // errored is status-based now (blocked/failed/partial/interrupted live in the Needs-you column,
@@ -6131,13 +6085,21 @@ function renderFsBoard(){
6131
6085
  + (word ? `<span class="lbc-w ${amber ? 'intr' : 'bad'}">${word}</span>` : '')
6132
6086
  + '</div>'
6133
6087
  + `<div class="lbc-t">${tt(g.text || g.plan?.[0], 90)}</div>`;
6088
+ // The goal's one task (post-demotion there's exactly one non-reply task; [0]
6089
+ // is just defensive against any pre-migration leftovers with more than one).
6090
+ const task = goalTasks(g)[0];
6091
+ // Todos: not shown for To Do / Later — nothing has actually started yet there, so
6092
+ // there is nothing honest to fold open (quiet stays quiet). Doing/Needs/Review/Done
6093
+ // all show the AI's own breakdown — that's what makes it survive past "running".
6094
+ if (task && bkt !== 'todo' && bkt !== 'later') h += goalCardTodosHtml(task);
6134
6095
  // n/m is Doing-only (Masa 2026-07-17): a fraction on a stopped/queued/done card is
6135
6096
  // residue, not progress — a card showing n/m reads unambiguously as "moving right now".
6136
- // done/total are the goal's real tasks, so it never invents a number.
6137
- if (healthyRun) {
6138
- const gt = goalTasks(g), total = gt.length;
6097
+ // P0b: done/total now reflect the AI's OWN todos (not the planner's invented tasks,
6098
+ // which under 1-goal=1-task degenerate to a useless 0/1 → 1/1).
6099
+ if (healthyRun && task) {
6100
+ const tl = todoListFor(task), total = tl.length;
6139
6101
  if (total) {
6140
- const done = gt.filter((t) => ['done', 'skipped'].includes(t.status)).length;
6102
+ const done = tl.filter((d) => d.status === 'completed').length;
6141
6103
  h += `<div class="lbc-meter"><span class="bar"><i style="width:${Math.round(done / total * 100)}%"></i></span><span class="frac">${done}/${total}</span></div>`;
6142
6104
  }
6143
6105
  }
@@ -6227,16 +6189,11 @@ function renderFsReview(){
6227
6189
  const pgoals = state.goals.filter((g) => g.projectId === state.active);
6228
6190
  const list = state.tasks.filter((t) => t.projectId === state.active);
6229
6191
  const rows = buildReviewDigest({ goals: pgoals, tasks: list });
6230
- // PRD §3.5/§6.5: folded goals still have a row they were never deleted, their work
6231
- // just went somewhere else. They must be able to hold this section up ALONE: the goal
6232
- // they were folded into flips back to 'running' while it re-runs their request, so
6233
- // there is a window where `rows` is empty and only the folded ones remain. Returning
6234
- // early there would make #13 vanish for the length of the re-run — exactly the "where
6235
- // did my finished work go" this feature exists to prevent.
6236
- const foldedGoals = pgoals.filter((g) => g.status === 'folded');
6237
- if (!rows.length && !foldedGoals.length) { el.innerHTML = ''; return; } // §1.5-3: no reviews → the UI itself does not exist
6192
+ // PRD §3.5/§6.5 → Masa 2026-07-21: folded goals no longer get their own card in the
6193
+ // Review column (Masa: review欄にいらない). They were never deleted where their work
6194
+ // went is now logged inside the carrier review's detail ("Also carries", see saItemHtml).
6195
+ if (!rows.length) { el.innerHTML = ''; return; } // §1.5-3: no reviews → the UI itself does not exist
6238
6196
  const green = rows.filter((r) => !r.testResult || r.testResult.ok !== false).length;
6239
- const proofN = rows.filter((r) => r.proof).length;
6240
6197
  const blockedN = pgoals.filter((g) => g.status === 'blocked').length;
6241
6198
  const pct = rows.length ? Math.round((green / rows.length) * 100) : 0;
6242
6199
  // CDO review 2026-07-08 §6: same fix as the s5sum digest above — green/rows.length
@@ -6244,7 +6201,7 @@ function renderFsReview(){
6244
6201
  // ratio was already correct; "all green" is the line that used to fire regardless
6245
6202
  // of blockedN, reading as "18/18 ready · all green · 1 blocked" (self-contradictory).
6246
6203
  const allClear = green === rows.length && !blockedN;
6247
- const sline = `${allClear ? 'all green' : green === rows.length ? 'blocked needs attention' : `${rows.length - green} failing`} · ${proofN} with proof · ${blockedN ? `${blockedN} blocked` : 'none blocked'}`;
6204
+ const sline = `${allClear ? 'all green' : green === rows.length ? 'blocked needs attention' : `${rows.length - green} failing`} · ${blockedN ? `${blockedN} blocked` : 'none blocked'}`;
6248
6205
  const RCAP = 3;
6249
6206
  const shown = fsUI.revMore ? rows : rows.slice(0, RCAP);
6250
6207
  const card = (r) => {
@@ -6264,31 +6221,18 @@ function renderFsReview(){
6264
6221
  // retest ×3 note (v45 §5.3): the goal came back green from a Ledger Dismiss
6265
6222
  const g0 = pgoals.find((g) => g.id === r.goalIds[0]);
6266
6223
  const rnote = g0?.retest?.passed ? `<span class="rnote">✓ ${esc(g0.retest.note)}</span>` : '';
6267
- // PRD §6.5: goals that landed on the same files ride this card's PR instead of
6268
- // opening a second one that edits the same lines. Whoever opens this review has
6269
- // to know it answers more than one request. Only when it happened (§1.5-3).
6270
- const carried = pgoals.filter((x) => r.goalIds.includes(x.foldedInto));
6271
- const cnote = carried.length ? `<span class="fnote">${foldLangFor(g0) === 'ja'
6272
- ? `+ ${carried.map((x) => `#${x.id}`).join(', ')} の仕事もここに入っています`
6273
- : `also carries ${carried.map((x) => `#${x.id}`).join(', ')}`}</span>` : '';
6224
+ // PRD §6.5 Masa 2026-07-21: goals folded onto this card's PR no longer get an
6225
+ // inline "+ #901 の仕事もここに入っています" note here same report-in-the-list
6226
+ // clutter the old standalone fold row was (Masa: review欄にいらない). It is logged
6227
+ // inside the opened detail's "Also carries" section instead (see saItemHtml).
6274
6228
  // Title = the goal's own title (saReviewTitle → text-first), same as the See-all
6275
6229
  // Ledger and the To Do list, so a review card and its list row read identically
6276
6230
  // (Masa 2026-07-20: this card showed reviewSummary.check "…が出ない" while the list
6277
6231
  // showed the request text). Plan-reviews keep their "PLAN — approve to execute:"
6278
6232
  // checkLine, which is the actionable label there, not a title mismatch.
6279
- return `<div class="rcard${img ? '' : ' noimg'}" data-fsrev="${r.goalIds[0]}">${img}<div class="rc"><div class="t">${rid}${esc(r.plan ? r.checkLine : saReviewTitle(g0))}</div>${rnote}${cnote}${pr}</div>
6233
+ return `<div class="rcard${img ? '' : ' noimg'}" data-fsrev="${r.goalIds[0]}">${img}<div class="rc"><div class="t">${rid}${esc(r.plan ? r.checkLine : saReviewTitle(g0))}</div>${rnote}${pr}</div>
6280
6234
  <div class="act"><button class="b" data-fsapprove="${ids}" title="Approve">✓</button><button class="b no" data-fsdismiss="${ids}" title="Send back">✕</button></div></div>`;
6281
6235
  };
6282
- // A folded goal is settled, not failed: no ✓/✕ (the thing to approve lives on the goal
6283
- // it was folded into), no red, no painted surface (§1.5-8) — just its own number, what
6284
- // it asked for, and where the work went. Tapping it opens that goal (§1.5-9 wording).
6285
- const foldCard = (g) => {
6286
- const line = saReviewTitle(g).slice(0, 60); // title-first, same as the review card above
6287
- const mergedNote = foldLangFor(g) === 'ja' ? `→ #${esc(String(g.foldedInto))} にまとめました` : `merged into #${esc(String(g.foldedInto))}`;
6288
- return `<div class="rcard noimg fold" data-fsfold="${g.id}">
6289
- <div class="rc"><div class="t"><span class="rid gno" data-n="${g.id}">#${g.id}</span>${esc(line)}</div>
6290
- <span class="fnote">${mergedNote}</span></div></div>`;
6291
- };
6292
6236
  // V3-C (Masa決定 2026-07-07): no collapsed strip — it duplicated the sm2 meter right
6293
6237
  // above. Reviews render directly: meter + Review header + cards (+N more fold, §1.5-8).
6294
6238
  // The meter counts what is reviewable, so it only exists when something is (§1.5-3):
@@ -6304,7 +6248,7 @@ function renderFsReview(){
6304
6248
  <button class="rvb${fsUI.rv === 1 ? ' on' : ''}" data-fsrv="1" title="List"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="M13 6h8M13 9h5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="M13 16h8M13 19h5"/></svg></button>
6305
6249
  <button class="rvb${fsUI.rv === 2 ? ' on' : ''}" data-fsrv="2" title="Compact"><svg viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg></button>
6306
6250
  </span></div>
6307
- <div class="revlist">${shown.map(card).join('')}${foldedGoals.map(foldCard).join('')}
6251
+ <div class="revlist">${shown.map(card).join('')}
6308
6252
  ${rows.length > RCAP ? `<div class="morefold" id="fsRevMore">${fsUI.revMore ? 'less' : `+${rows.length - RCAP} more`}<span class="g">›</span></div>` : ''}</div>`;
6309
6253
  // Mirror the ready-review count onto the mobile top-bar "Review N" opener (H23).
6310
6254
  const _hn = document.getElementById('fsHambN'); if (_hn) _hn.textContent = green > 0 ? String(green) : '';
@@ -6324,19 +6268,8 @@ function renderFsReview(){
6324
6268
  if (e.target.closest('a') || e.target.closest('.act')) return;
6325
6269
  openReviewChecklist(Number(c.dataset.fsrev));
6326
6270
  };
6327
- // A folded goal has nothing of its own to review its work is in the goal it was
6328
- // folded into, so the tap goes there. That goal is only in Review once its re-run
6329
- // finishes, so resolve from its live status instead of assuming (openReviewChecklist
6330
- // on a running goal would present an empty checklist).
6331
- for (const c of el.querySelectorAll('[data-fsfold]')) c.onclick = (e) => {
6332
- if (e.target.closest('a')) return;
6333
- const g = state.goals.find((x) => x.id === Number(c.dataset.fsfold));
6334
- const target = state.goals.find((x) => x.id === g?.foldedInto);
6335
- if (!target) return;
6336
- goToGoalInChat(target.id);
6337
- if (target.status === 'review') openReviewChecklist(target.id);
6338
- else openGoalDetail(target.id);
6339
- };
6271
+ // Folded goals no longer render their own card here (Masa 2026-07-21) where their
6272
+ // work went is logged inside the carrier review's detail ("Also carries"), not tapped.
6340
6273
  for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
6341
6274
  let ok = true, mergeGoal = null;
6342
6275
  for (const id of b.dataset.fsapprove.split(',')) {
@@ -7413,8 +7346,14 @@ async function refresh(){
7413
7346
  // back the already-accumulated activity for 'running' tasks.
7414
7347
  for (const t of state.tasks) { if (t.activity?.length) state.act[t.id] = t.activity; }
7415
7348
  // Same for the live to-do list: /api/state ships t.todos for running tasks
7416
- // (task 108/109) so a mid-run refresh restores the checklist immediately.
7417
- for (const t of state.tasks) { if (t.todos?.length) state.todos[t.id] = t.todos; }
7349
+ // (task 108/109) so a mid-run refresh restores the checklist immediately. A
7350
+ // FINISHED task has no live t.todos (trimTaskActivityForState strips it) but
7351
+ // keeps t.todosFinal — seed from that too (P0b), so a reload of a completed
7352
+ // goal still shows the AI's own breakdown instead of losing it silently.
7353
+ for (const t of state.tasks) {
7354
+ if (t.todos?.length) state.todos[t.id] = t.todos;
7355
+ else if (t.todosFinal?.length) state.todos[t.id] = t.todosFinal;
7356
+ }
7418
7357
  state.connectCommand = s.connectCommand ?? ''; state.models = s.models ?? ['sonnet']; state.agentModels = s.agentModels ?? null; state.agentEfforts = s.agentEfforts ?? null; state.agents = s.agents ?? ['claude-code', 'codex']; state.uiVersion = s.uiVersion;
7419
7358
  state.billing = s.billing ?? { licensed: false, email: null };
7420
7359
  renderAppSelect(); // picker follows the server's installed-agent set; may correct a stale connectedApp
@@ -8053,7 +7992,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
8053
7992
  // 4 archived / 5 reverted — session-local; the server round-trip is what
8054
7993
  // settles it (see saSync's "returned to review" reset).
8055
7994
  // ============================================================================
8056
- const SA = { open: false, items: [], diffs: {}, diffBoxHtml: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
7995
+ const SA = { open: false, items: [], diffs: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
8057
7996
  window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
8058
7997
  const SA_SVG = {
8059
7998
  ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
@@ -8086,8 +8025,13 @@ function saRows(){
8086
8025
  return { no: goalReviewNumber({ goal: sg, tasks: ts }), title: saReviewTitle(sg), cur: gid === r.goalIds[0] };
8087
8026
  })
8088
8027
  : null;
8028
+ // Masa 2026-07-21: goals folded onto this review no longer take their own Review-column
8029
+ // row ("→ #12 にまとめました" read as clutter). Instead this review's detail logs what
8030
+ // it carries. Mirrors engine/lib.mjs foldedIntoReview() (browser can't import it).
8031
+ const folded = gs.filter((x) => x.status === 'folded' && x.foldedInto != null && r.goalIds.includes(x.foldedInto))
8032
+ .map((x) => ({ id: x.id, title: saReviewTitle(x) }));
8089
8033
  return {
8090
- ids: r.goalIds, goalId: r.goalIds[0], goal: g,
8034
+ ids: r.goalIds, goalId: r.goalIds[0], goal: g, folded,
8091
8035
  no: r.reviewNumber,
8092
8036
  title: saReviewTitle(g),
8093
8037
  check: String(g?.reviewSummary?.check || r.checkLine || '').replace(/\s+/g, ' ').trim(),
@@ -8095,6 +8039,28 @@ function saRows(){
8095
8039
  did, siblings, risk: g?.risk ?? null,
8096
8040
  note: g?.retest?.passed ? g.retest.note : null,
8097
8041
  pr: r.pr,
8042
+ // P0b (Masa 2026-07-21): the AI's own TodoWrite breakdown for the "Steps" section
8043
+ // below — the primary goal's own non-reply task, frozen at completion. Distinct
8044
+ // from `did` above (task.result prose) and from Activity (raw tool-call log).
8045
+ todosFinal: (() => {
8046
+ const t = ts.find((x) => x.goalId === g?.id && !x.reply);
8047
+ return Array.isArray(t?.todosFinal) ? t.todosFinal : [];
8048
+ })(),
8049
+ // P1 (Masa 2026-07-21): the AI's own final report, verbatim and untrimmed — the
8050
+ // card renders it as Markdown and interprets nothing. This is the deliverable for
8051
+ // every ask that has no running thing to open ("research this", "explain that"),
8052
+ // and the explanation for every ask that does.
8053
+ report: (() => {
8054
+ const t = ts.find((x) => x.goalId === g?.id && !x.reply && x.result);
8055
+ return String(t?.result ?? '').replace(/^検証(PASS|FAIL)[^\n]*\n+/, '').trim();
8056
+ })(),
8057
+ // …and the worker's own "here's how you see it running" declaration (task.run,
8058
+ // written by the worker into .manager-run.json). Absent for asks with nothing to
8059
+ // run — then no button, which is the honest state, not a missing feature.
8060
+ run: (() => {
8061
+ const t = ts.find((x) => x.goalId === g?.id && !x.reply && x.run);
8062
+ return t ? { taskId: t.id, ...t.run } : null;
8063
+ })(),
8098
8064
  // Explicit review-summary display fields (spec §11: the card's shape follows the
8099
8065
  // task). A goal's reviewSummary may carry them directly (e.g. a conversational task
8100
8066
  // that reports an Answer + its Activity, or a ticket whose test count / diff totals
@@ -8111,10 +8077,6 @@ function saRows(){
8111
8077
  ghRows: Array.isArray(g?.reviewSummary?.ghdiff) ? g.reviewSummary.ghdiff : null,
8112
8078
  diffTotal: g?.reviewSummary?.diffTotal ?? null,
8113
8079
  proofTaskId: pt?.id ?? null, proofImg: pt?.proof?.png ?? pt?.proof?.gif ?? null,
8114
- // Ledger before/after (docs/design/HANDOFF-review-card-evidence-first-2026-07-18.md
8115
- // §3 kind==='image'): most tasks never have a "before" capture, so this is almost
8116
- // always null and beforeAfterMediaHtml() falls back to the single-shot view.
8117
- proofBefore: pt?.proof?.beforePng ?? pt?.proof?.beforeGif ?? null,
8118
8080
  // Recording (spec §5/§6): a proof task may carry a screen recording (proof.mp4)
8119
8081
  // or just a recording:true flag; the goal's reviewSummary can also name one.
8120
8082
  // Drives the neutral "Verify recording ↗" meta chip + the Proof video player.
@@ -8146,32 +8108,9 @@ function saRows(){
8146
8108
  };
8147
8109
  });
8148
8110
  }
8149
- // Meta row: PR link / token savings (real tap-to-open toggle) / Run locallyunder
8150
- // the title (H23 §1). Run-locally has no real per-task command yet this is the
8151
- // CDO's answer to "let me test this locally", left for CTO/engine to decide what it
8152
- // actually triggers (docs/design/HANDOFF-review-card-evidence-first-2026-07-18.md
8153
- // §5). Shown only for tasks with a real automated check to plausibly re-run
8154
- // (it.testResult.ran), and tapping it is honest about not being wired yet rather
8155
- // than pretending to execute anything — never a fake "running…" state.
8156
- function saMetaRowHtml(it, i){
8157
- const canRunLocal = Boolean(it.testResult?.ran);
8158
- if (!it.pr && !it.tokenOpt && !canRunLocal) {
8159
- return '<div class="lg-meta"><span class="none">No PR link / token-savings data on this task.</span></div>';
8160
- }
8161
- const chips = [];
8162
- if (it.pr) chips.push(`<a class="sa-prlink sm" href="${esc(it.pr)}" target="_blank" title="Open pull request">PR #${esc((it.pr.match(/pull\/(\d+)/) ?? [, ''])[1])} ↗</a>`);
8163
- if (it.tokenOpt) {
8164
- const pct = Number(it.tokenOpt.percent);
8165
- const label = pct > 0 ? `saved ${pct}% tokens` : 'token usage';
8166
- chips.push(`<button class="tokchip" type="button" data-saact="tok:${i}">${esc(label)} <i class="tokchevron">›</i></button>`);
8167
- }
8168
- if (canRunLocal) chips.push(`<button class="sa-runlocal" type="button" data-saact="runlocal:${i}" title="Run this locally"><svg viewBox="0 0 24 24"><path d="M6 4l14 8-14 8V4z"/></svg>Run locally</button>`);
8169
- const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
8170
- const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
8171
- const tokDetail = it.tokenOpt ? `<div class="tokdetail" id="tokdetail${i}"><span class="k">How this run used tokens</span><span class="v">${esc(tokCmp)} · ${esc(tokTech)}</span></div>` : '';
8172
- const runNote = canRunLocal ? `<div class="tokdetail" id="runlocal${i}"><span class="v">No local run command is wired for this task yet.</span></div>` : '';
8173
- return `<div class="lg-meta">${chips.join('')}</div>${tokDetail}${runNote}`;
8174
- }
8111
+ // (P1, Masa 2026-07-21) saMetaRowHtml() was deleted herethe pre-redesign meta row,
8112
+ // uncalled since the review-card redesign. Its "Run locally" chip only ever opened a
8113
+ // note saying no run command was wired; the card's Open it chip now starts the real one.
8175
8114
  // Context: closed by default, one-line verbatim-quote summary, chevron large and at
8176
8115
  // the far right (H23 §2). Quotes goal.text — the user's own words — never
8177
8116
  // task.detail/title (AI-authored; an earlier round of this design mistakenly quoted
@@ -8190,35 +8129,11 @@ function saContextHtml(it, gk){
8190
8129
  const open = SA.detOpen.has(`${gk}:ctx`) ? ' open' : '';
8191
8130
  return `<details class="lg-goal" data-detkey="${gk}:ctx"${open}><summary><span class="k">Context</span><span class="v">${summary}</span><i class="gtoggle">›</i></summary><div class="lg-context">${inner}</div></details>`;
8192
8131
  }
8193
- // Evidence hero: shape follows task kind, not one image-shaped template (H23 §3).
8194
- // Real data check: most finished tasks have no proof image at all (logic-only /
8195
- // docs-only / research changes verify without a screenshot) kind is derived, see
8196
- // saEvidenceKind() above.
8197
- function saEvidenceHtml(kind, factRow, img, filePaths, diffText, gtr, it){
8198
- if (kind === 'logic') {
8199
- const passed = gtr?.passed ?? 0, failed = gtr?.failed ?? 0, ok = gtr?.ok !== false;
8200
- const n = ok ? String(passed) : `${passed}/${passed + failed}`;
8201
- return `<div class="lg-ev"><div class="lg-evhd">Evidence — tests, no UI to show</div>
8202
- <div class="lg-herostat${ok ? '' : ' fail'}"><span class="n">${esc(n)}</span><span class="l">tests passed<br>${esc(filePaths.join(', '))}</span></div></div>`;
8203
- }
8204
- if (kind === 'docs') {
8205
- const lines = diffText ? diffText.split('\n').filter((l) => l.length) : [];
8206
- const diffHtml = lines.map((l) => {
8207
- const isAdd = l.startsWith('+');
8208
- return `<div class="ln${isAdd ? ' a' : ''}"><span class="gutter">+</span><span class="txt">${esc(l.slice(1) || l)}</span></div>`;
8209
- }).join('');
8210
- return `<div class="lg-ev"><div class="lg-evhd">Evidence — the diff, no test to run</div>
8211
- <div class="row"><span class="fls">${esc(filePaths.join(', '))} · docs-only, no test target</span></div>
8212
- ${diffHtml ? `<div class="lg-diffblock">${diffHtml}</div>` : ''}</div>`;
8213
- }
8214
- if (kind === 'missing') {
8215
- const note = it.check || 'Please verify this change manually — no automated capture exists for it.';
8216
- return `<div class="lg-ev"><div class="lg-missinghd"><svg viewBox="0 0 24 24"><path d="M12 8v5M12 16.5v.5"/><path d="M10.3 3.9 2.4 18a1.9 1.9 0 0 0 1.7 2.9h15.8a1.9 1.9 0 0 0 1.7-2.9L13.7 3.9a1.9 1.9 0 0 0-3.4 0z"/></svg>No capture — this touched the UI and should have one</div>
8217
- <div class="lg-manualnote">${esc(note)}</div></div>`;
8218
- }
8219
- // kind === 'image' (default)
8220
- return `<div class="lg-ev"><div class="lg-evhd">Evidence</div><div class="row">${factRow}</div>${img}</div>`;
8221
- }
8132
+ // (P1, Masa 2026-07-21) saEvidenceHtml() was deleted here. It rendered the four
8133
+ // "evidence hero" shapes for the pre-redesign card including the 'missing' one that
8134
+ // told the reviewer "No capture this touched the UI and should have one". Nothing has
8135
+ // called it since the review-card redesign, and its premise (we owe you a screenshot)
8136
+ // is exactly what P1 removes. The report the AI wrote, plus Open it, is the evidence.
8222
8137
  // Unified-diff → GitHub two-gutter model (REVIEW-CARD-SPEC §7 "GitHub diff = Bright"):
8223
8138
  // walk the @@ hunk headers to recover the old/new line numbers, emit one row per line
8224
8139
  // {o:oldNo|null, n:newNo|null, t:'add'|'del'|'ctx', c:text}. Word-level highlight is
@@ -8289,8 +8204,10 @@ function saVideoFigHtml(url){
8289
8204
  // No fabricated pipeline: an untested goal shows □ Tested, not a green ✓.
8290
8205
  function saWorkflowHtml(it){
8291
8206
  const ran = Boolean(it.testResult?.ran);
8292
- const hasProof = Boolean(it.proofImg && it.proofTaskId != null);
8293
- const steps = [['Implemented', 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['Proof captured', hasProof ? 'ok' : 'todo'], ['In review', 'now'], ['Merge', 'todo'], ['Deploy', 'todo']];
8207
+ // P1 (Masa 2026-07-21): "Proof captured □" is gone. We no longer go take photos of
8208
+ // the work, so an empty box there was scoring the goal against a step that isn't
8209
+ // part of the process any more — a permanent unchecked box nobody could ever check.
8210
+ const steps = [['Implemented', 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['In review', 'now'], ['Merge', 'todo'], ['Deploy', 'todo']];
8294
8211
  return '<div class="rwf">' + steps.map(([s, st]) => `<div class="wf-s ${st}"><span class="mk">${st === 'ok' ? '✓' : st === 'now' ? '→' : '□'}</span>${s}</div>`).join('') + '</div>';
8295
8212
  }
8296
8213
  function saItemHtml(it, i){
@@ -8304,15 +8221,9 @@ function saItemHtml(it, i){
8304
8221
  const d = SA.diffs[it.goalId];
8305
8222
  const gtr = it.testResult;
8306
8223
  const hasProofImg = it.proofImg && it.proofTaskId != null;
8307
- // Before/after (spec §6): reuse the same beforeafter/ba-col/balabel renderer as the
8308
- // per-task checklist (beforeAfterMediaHtml). it.proofBefore is null for the ~all
8309
- // tasks with no "before" capture, so it falls back to the single <img class="proofimg">.
8310
- // Diff-box + zoom inset is computed async after first paint (saEnhanceDiffBoxes);
8311
- // SA.diffBoxHtml caches it so the box survives every later saRender().
8312
- const boxedImg = SA.diffBoxHtml[it.goalId];
8313
- const img = hasProofImg
8314
- ? (typeof boxedImg === 'string' ? boxedImg : beforeAfterMediaHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, esc(it.title ?? '')))
8315
- : '';
8224
+ // Proof image (P1): the single capture from a check the AI itself promised and ran.
8225
+ // No pair, no red box those compared against a "before" shot we used to stage.
8226
+ const img = hasProofImg ? proofMediaHtml(it.proofTaskId, { png: it.proofImg }, esc(it.title ?? '')) : '';
8316
8227
  // Changed-file paths: d from the diff endpoint ({add,del,files:[{path,add,del}],diff}),
8317
8228
  // falling back to task.changedFiles when the server has no diff for this goal.
8318
8229
  // A usable git diff has actual files; the endpoint returns an empty {add:0,del:0,
@@ -8362,7 +8273,10 @@ function saItemHtml(it, i){
8362
8273
  // the demo, so it renders as a non-navigating chip; a real URL opens in a new tab.
8363
8274
  if (it.localUrl) { const lu = String(it.localUrl); const isUrl = /^https?:/.test(lu);
8364
8275
  chips.push(`<a class="mx mlink"${isUrl ? ` href="${esc(lu)}" target="_blank" rel="noopener"` : ' style="cursor:pointer"'} title="Open the running app"><span class="mk">Local</span> <b>${esc(lu.replace(/^https?:\/\//, ''))}</b>${arw}</a>`); }
8365
- else if (gtr?.ran) chips.push(`<button type="button" class="mx mlink" data-saact="runlocal:${i}" title="Run this locally"><span class="mk">Local</span> <b>run</b>${arw}</button>`);
8276
+ // (Open it is NOT a meta chip — Masa 2026-07-22. It was saying the same thing as the
8277
+ // Open it section below, in the PR chip's vocabulary, so the card spoke two languages
8278
+ // about one thing. It lives in the body now, in the same typography as every other
8279
+ // section.)
8366
8280
  // Verify = a screen recording of the check (spec §5): NEUTRAL. Opens the file when there
8367
8281
  // is a real URL, otherwise scrolls to the in-card player.
8368
8282
  if (it.recording) chips.push(it.recordingUrl
@@ -8371,23 +8285,54 @@ function saItemHtml(it, i){
8371
8285
  const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
8372
8286
  const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
8373
8287
  const metaDetail = (it.tokenOpt && tokPct > 0) ? `<div class="rmeta-detail" id="tokdetail${i}"><span class="k">How this run used tokens</span><span class="v">${esc(tokCmp)}${tokTech ? ` · ${esc(tokTech)}` : ''}</span></div>` : '';
8374
- const runNote = gtr?.ran ? `<div class="rmeta-detail" id="runlocal${i}"><span class="v">No local run command is wired for this task yet.</span></div>` : '';
8375
- const meta = `<div class="rmeta">${chips.join('')}</div>${metaDetail}${runNote}`;
8288
+ // §1.5-3: with Open it moved into the body, a card with no PR / tokens / tests has no
8289
+ // chips at all — render no meta row rather than an empty one holding open a gap.
8290
+ const meta = chips.length ? `<div class="rmeta">${chips.join('')}</div>${metaDetail}` : '';
8376
8291
  // ── Request (spec §4): a PLAIN section — the user's own short words (goal.text). Never
8377
8292
  // an accordion, never the implementation spec (that lives in Activity below). ──
8378
8293
  const reqSec = it.goalText
8379
8294
  ? `<section class="rsec rreq"><div class="rsec-h">Request</div><p class="rbody rask">${esc(it.goalText)}</p></section>`
8380
8295
  : '';
8296
+ // ── Open it (Masa 2026-07-22): a plain section in the card's own typography — the same
8297
+ // heading + body shape as Request and Result, not a chip in another vocabulary. The
8298
+ // address the worker declared IS the control (press it → the process starts on demand →
8299
+ // it opens); its own note sits under it, and that note is where the status and any
8300
+ // failure reason land after the press. Nothing declared → no section (§1.5-3). ──
8301
+ const openBody = it.run
8302
+ ? `<div class="ropen" id="runlocal${i}"><button type="button" class="olink" data-saact="preview:${i}" id="prevlink${i}">${esc(it.run.url || 'Start it')}<span class="ar">↗</span></button><p class="rbody v">${esc(it.run.note || it.run.cmd || 'Starts when you press it.')}</p></div>`
8303
+ : '';
8304
+ // ── Also carries (Masa 2026-07-21): goals folded onto this review are logged HERE, inside
8305
+ // the review's own content — not as loose "→ #12 にまとめました" rows in the Review column.
8306
+ // Chrome label stays English (§377-7); the request titles keep whatever language they were
8307
+ // written in. Only rendered when something was actually folded in (§1.5-3 no empty box). ──
8308
+ const carriesBody = it.folded?.length
8309
+ ? `<ul class="rcheck">${it.folded.map((f) => `<li>#${esc(String(f.id))} ${esc(f.title)}</li>`).join('')}</ul>`
8310
+ : '';
8381
8311
  // ── What to check (spec §2.5): a bulleted list when several, a line when one. ──
8382
8312
  // Only an explicit review-summary check renders here — never the goal text echoed back
8383
8313
  // (it.check falls back to goal.text via checkLine, which would fabricate a "What to
8384
8314
  // check" for a conversational task that has none). §0.5 no placeholder.
8385
- const checkArr = Array.isArray(rs.checkList) ? rs.checkList.filter(Boolean)
8315
+ // The working AI's own sentence wins (P1, Masa 2026-07-22): it declares what to look
8316
+ // at in the same file it declares how to run the thing. reviewSummary.check below is
8317
+ // the intake AI's invention and only fills in for goals that predate the declaration.
8318
+ const checkArr = it.run?.check ? [it.run.check]
8319
+ : Array.isArray(rs.checkList) ? rs.checkList.filter(Boolean)
8386
8320
  : Array.isArray(rs.check) ? rs.check.filter(Boolean)
8387
8321
  : (typeof rs.check === 'string' && rs.check.trim() ? [rs.check.trim()] : []);
8388
8322
  const checkBody = checkArr.length > 1
8389
8323
  ? `<ul class="rcheck">${checkArr.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
8390
8324
  : (checkArr.length ? `<p class="rbody rcheck1">${esc(checkArr[0])}</p>` : '');
8325
+ // ── Steps (P0b, Masa 2026-07-21): the AI's own TodoWrite breakdown — distinct from
8326
+ // "What to check" (the human's pass condition) and Activity (the raw tool-call log).
8327
+ // Reuses the same .todos/.todo vocabulary as the chat feed / board card so the ✓/▸/○
8328
+ // language is consistent everywhere it appears. Empty → no section (§1.5-3).
8329
+ const stepsBody = it.todosFinal?.length ? `<div class="todos">${todoRowsHtml(it.todosFinal)}</div>` : '';
8330
+ // ── Result (P1, Masa 2026-07-21): the AI's own report, rendered as written and read
8331
+ // whole (no 160-char squeeze, no summariser of ours in the way). This is the one
8332
+ // section that carries every kind of ask — a研究 report reads here as prose, a UI
8333
+ // change explains itself here, and an image the AI pasted shows up here as an image.
8334
+ // We interpret nothing; mdLite only turns Markdown into the shapes it already means. ──
8335
+ const reportBody = it.report ? `<div class="rreport">${mdLite(it.report, { images: false })}</div>` : '';
8391
8336
  // ── Proof (spec §6/§11): before/after (identical size) when a real image exists; a
8392
8337
  // run-log for logic-only tests; dropped entirely for a conversational task. ──
8393
8338
  let proofInner = '';
@@ -8400,7 +8345,10 @@ function saItemHtml(it, i){
8400
8345
  // peeks then "…+N 行 · GitHub で開く ↗" for a huge change (no GitHub link when there's no
8401
8346
  // PR). Structured recorded rows (ghRows) win over a git/client unified diff. Dropped
8402
8347
  // entirely for a conversational task. ──
8403
- const changedDescHtml = (it.changedDesc || rs.changed) ? `<p class="rbody rdesc">${esc(String(it.changedDesc || rs.changed).slice(0, 600))}</p>` : '';
8348
+ // No prose here (P1, Masa 2026-07-22): reviewSummary.changed was the intake AI writing
8349
+ // its own summary of the change, and on a real goal it came out as the request echoed
8350
+ // back. What changed is now only what actually changed — files and the diff.
8351
+ const changedDescHtml = '';
8404
8352
  const filesListHtml = it.metaFiles
8405
8353
  ? `<div class="rfiles">${it.metaFiles.map((f) => esc(f)).join('<br>')}</div>`
8406
8354
  : (d && d.files.length) ? `<div class="rfiles">${d.files.map((f) => `${esc(f.path)} <i class="a">+${f.add}</i> <i class="d">−${f.del}</i>`).join('<br>')}</div>`
@@ -8419,9 +8367,12 @@ function saItemHtml(it, i){
8419
8367
  // below, offset-indented. The review Discussion folds in at the bottom of the body. The
8420
8368
  // transcript is the recorded build (activity); real goals fall back to "what we did".
8421
8369
  // Persisted open-state via data-detkey so SSE re-renders don't collapse it. ──
8370
+ // The "what we did" fallback is the same report as Result above, cut to 160 chars —
8371
+ // showing both makes the card repeat itself in a worse form (P1). Only fall back when
8372
+ // there is no Result to read.
8422
8373
  const txArr = (it.activity && it.activity.length)
8423
8374
  ? it.activity.map((e) => saTxRowHtml(e))
8424
- : (it.did?.length ? it.did.map((s) => saTxRowHtml(['claude', s])) : []);
8375
+ : ((!reportBody && it.did?.length) ? it.did.map((s) => saTxRowHtml(['claude', s])) : []);
8425
8376
  const discArr = it.discussion || it.thread || null;
8426
8377
  const threadHtml = (discArr && discArr.length)
8427
8378
  ? discArr.map((m) => `<div class="rmsg${m[0] === 'you' ? ' you' : ''}"><div class="who">${m[0] === 'you' ? 'You' : 'Manager'}</div><div class="msg">${esc(m[1])}</div></div>`).join('')
@@ -8439,7 +8390,11 @@ function saItemHtml(it, i){
8439
8390
  <h2 class="rtitle">${esc(it.title)}</h2>
8440
8391
  ${meta}
8441
8392
  ${reqSec}
8393
+ ${S('Open it', openBody)}
8394
+ ${S('Also carries', carriesBody)}
8442
8395
  ${S('What to check', checkBody)}
8396
+ ${S('Result', reportBody)}
8397
+ ${S('Steps', stepsBody)}
8443
8398
  ${S('Answer', it.answer ? `<p class="rbody">${esc(it.answer)}</p>` : '')}
8444
8399
  ${S('Proof', proofInner)}
8445
8400
  ${S('What changed', changedBody)}
@@ -8462,8 +8417,7 @@ function saRender(){
8462
8417
  const S = $('seeall'); if (!S) return;
8463
8418
  const pending = SA.items.filter((x) => x.st === 0);
8464
8419
  const green = pending.filter((x) => !x.testResult || x.testResult.ok !== false).length;
8465
- const proofN = pending.filter((x) => x.proofImg).length;
8466
- const c = `${pending.length} ready · ${green === pending.length ? 'all green' : `${pending.length - green} failing`} · ${proofN} with proof`;
8420
+ const c = `${pending.length} ready · ${green === pending.length ? 'all green' : `${pending.length - green} failing`}`;
8467
8421
  const curIdx = pending.findIndex((x) => x.goalId === SA.cur);
8468
8422
  const posLabel = pending.length && curIdx >= 0 ? `${curIdx + 1} / ${pending.length}` : '';
8469
8423
  S.innerHTML = `<div class="sa-panel"><div class="sa-hd"><span class="t">Review</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">↑↓ MOVE · A APPROVE · D DISMISS · ESC CLOSE</span><button class="sa-x" data-saact="close:0">✕</button></div>
@@ -8528,25 +8482,6 @@ async function saFetchDiffs(){
8528
8482
  }));
8529
8483
  if (SA.open) saSync();
8530
8484
  }
8531
- // Diff-box + zoom inset (HANDOFF-review-card-diffbox-2026-07-18): unlike
8532
- // saFetchDiffs above (whose fetch settling triggers a full saSync()/saRender()), this
8533
- // patches each card's already-rendered .beforeafter node directly — a full re-render
8534
- // here would reset scroll, close every open <details>, and drop an in-progress reply
8535
- // keystroke, all just to draw a box around one screenshot. SA.diffBoxHtml is still
8536
- // cached per goal, so if a later SSE-driven saSync() does rebuild the card anyway,
8537
- // saItemHtml() picks the cached result back up with no flicker to the plain pair.
8538
- async function saEnhanceDiffBoxes(){
8539
- const targets = SA.items.filter((it) => it.proofImg && it.proofTaskId != null && it.proofBefore && SA.diffBoxHtml[it.goalId] === undefined);
8540
- await Promise.all(targets.map(async (it) => {
8541
- let html = null;
8542
- try { html = await beforeAfterBoxedHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, esc(it.title ?? '')); }
8543
- catch { html = null; } // a decode/pixel-diff failure is a fallback, never a crash
8544
- SA.diffBoxHtml[it.goalId] = html; // cache even on null — never re-attempt an already-answered "no box"
8545
- if (!html || !SA.open) return;
8546
- const host = document.querySelector(`.sa-item[data-gid="${it.goalId}"] .beforeafter`);
8547
- if (host) host.outerHTML = html; // html itself opens with the replacement .beforeafter
8548
- }));
8549
- }
8550
8485
  function saOpen(focusGoalId){
8551
8486
  SA.open = true;
8552
8487
  SA.items = saRows().map((r) => ({ ...r, st: 0 }));
@@ -8576,7 +8511,6 @@ function saOpen(focusGoalId){
8576
8511
  if (el) saScrollCardToTop(el);
8577
8512
  });
8578
8513
  saFetchDiffs();
8579
- saEnhanceDiffBoxes();
8580
8514
  }
8581
8515
  function saClose(){ SA.open = false; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
8582
8516
  // Continuous review (Masa 2026-07-08): move a persistent "current" card with
@@ -8635,10 +8569,32 @@ $('seeall').addEventListener('click', async (e) => {
8635
8569
  const it = SA.items[i];
8636
8570
  if (k === 'close') return saClose();
8637
8571
  if (!it) return;
8638
- // meta-row toggles (H23): tap the token chip to reveal the technique breakdown, tap
8639
- // Run-locally to reveal the honest "not wired yet" note — neither performs an action.
8572
+ // meta-row toggle (H23): tap the token chip to reveal the technique breakdown.
8640
8573
  if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
8641
- if (k === 'runlocal') { $('seeall').querySelector(`#runlocal${i}`)?.classList.toggle('open'); return; }
8574
+ // Open it (P1, Masa 2026-07-21): start the thing the worker said to run, then open it.
8575
+ // Booting a real process takes seconds, so the chip says "Starting…" while it waits —
8576
+ // and a failure lands in the detail line as a plain reason. It never turns the goal
8577
+ // red: failing to boot a preview says nothing about whether the work itself is good.
8578
+ if (k === 'preview') {
8579
+ if (!it.run?.taskId) return;
8580
+ const sec = $('seeall').querySelector(`#runlocal${i}`);
8581
+ const link = $('seeall').querySelector(`#prevlink${i}`);
8582
+ const v = sec?.querySelector('.v');
8583
+ if (link) link.disabled = true;
8584
+ if (v) v.textContent = 'Starting it…';
8585
+ let r = null, body = {};
8586
+ try { r = await fetch(`/api/tasks/${it.run.taskId}/preview`, { method: 'POST' }); body = await r.json().catch(() => ({})); }
8587
+ catch (e) { body = { error: String(e.message ?? e) }; }
8588
+ if (link) link.disabled = false;
8589
+ const noteText = it.run.note || it.run.cmd || 'Starts when you press it.';
8590
+ // A tab opened: that IS the feedback. Put the worker's own note back rather than
8591
+ // narrating what the reviewer just watched happen.
8592
+ if (r?.ok && body.url) { window.open(body.url, '_blank', 'noopener'); if (v) v.textContent = noteText; return; }
8593
+ // Started, but the worker declared no URL: its own note is where it said to look.
8594
+ if (r?.ok) { if (v) v.textContent = it.run.note || it.run.cmd || 'Started — nothing to open.'; return; }
8595
+ if (v) v.textContent = `Could not start it: ${body.error || `HTTP ${r?.status ?? 'error'}`}`;
8596
+ return;
8597
+ }
8642
8598
  // Verify chip (no external recording URL) → scroll the in-card video player into view.
8643
8599
  if (k === 'rec') { $('seeall').querySelector(`.sa-item[data-gid="${it.goalId}"] .rvid`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; }
8644
8600
  // every verdict auto-advances the focus to the next pending card (tempo).