@galda/cli 0.10.38 → 0.10.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,36 @@
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}
2031
+ /* the AI's own screenshots: full width, own aspect, hairline edge (§5.5 縁は1px以下).
2032
+ No grid, no crop — one shot is often a comparison sheet and dies when shrunk. */
2033
+ .redesign .rshot{margin:0 0 16px}
2034
+ .redesign .rshot:last-child{margin-bottom:0}
2035
+ .redesign .rshot img{display:block;width:100%;height:auto;border-radius:10px;border:1px solid var(--hair)}
2036
+ .redesign .rshot figcaption{margin-top:6px;font-family:var(--mono);font-size:11px;color:var(--ink3)}
2037
+ /* before/after pair + the red box over what actually changed (pixel-diffed here from
2038
+ the worker's own two shots — no box appears unless the pixels really differ) */
2039
+ .redesign .beforeafter .proofimg{width:100%;height:auto;max-height:none;object-fit:initial;display:block;border-radius:8px;border:1px solid var(--hair)}
2040
+ .redesign .ba-imgwrap{position:relative}
2041
+ .redesign .ba-imgwrap .proofimg{margin-top:0}
2042
+ .redesign .ba-box{position:absolute;border:1.5px solid var(--danger);border-radius:2px;pointer-events:none}
2043
+ .redesign .ba-zoomhd{margin:14px 0 8px;font-family:var(--mono);font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:var(--ink3)}
2044
+ .redesign .ba-zoomimg{display:block;width:100%;height:auto;border-radius:8px;border:1px solid var(--hair)}
2022
2045
  .redesign .rfiles{margin:12px 0 0;font-family:var(--mono);font-size:12px;color:var(--ink2);line-height:1.75}
2023
2046
  .redesign .rfiles .a{color:#3fd07a;font-style:normal}.redesign .rfiles .d{color:#ff7b72;font-style:normal}
2024
2047
  /* Request accordion (spec §3) — chevron › rotate 90, body indented, no left rule */
@@ -2091,22 +2114,25 @@
2091
2114
  in at the bottom of the opened body. */
2092
2115
  .redesign .ract{margin-top:24px !important}
2093
2116
  .redesign .ract>.rsec-h{margin-bottom:10px !important}
2094
- .redesign .lacc>summary{list-style:none;cursor:pointer;display:flex;gap:9px;align-items:baseline}
2117
+ .redesign .lacc>summary{list-style:none;cursor:pointer;display:flex;gap:14px;align-items:baseline}
2095
2118
  .redesign .lacc>summary::-webkit-details-marker{display:none}
2096
- .redesign .lacc .ltog{flex:0 0 auto;color:var(--ink3);font-style:normal;line-height:1;transition:transform .14s}
2119
+ .redesign .lacc .ltog{flex:0 0 auto;color:var(--ink2);font-style:normal;font-size:1.15em;line-height:1.25;transition:transform .14s}
2097
2120
  .redesign .lacc[open]>summary .ltog{transform:rotate(90deg)}
2098
2121
  .redesign .lacc .lfirst{flex:1;min-width:0}
2099
2122
  .redesign .lacc .lbody{margin-top:12px;padding-left:18px}
2100
2123
  /* transcript rows — Linear-clean, borderless (You / tool mono / res green / Claude) */
2101
2124
  .redesign .rtx{border:0;background:none}
2102
- .redesign .txr{display:flex;gap:12px;padding:5px 0;font-size:13px;line-height:1.55}
2103
- .redesign .txr .w{flex:0 0 46px;font-size:11px;color:var(--ink3);padding-top:1px}
2125
+ .redesign .txr{display:flex;gap:12px;padding:5px 0;font-size:13px;line-height:1.55;align-items:baseline}
2126
+ .redesign .txr .w{flex:0 0 46px;font-size:11px;color:var(--ink3)}
2104
2127
  .redesign .txr.you .w,.redesign .txr.cl .w{color:var(--ink);font-weight:600}
2105
2128
  .redesign .txr .m{flex:1;min-width:0;color:var(--ink2)}
2106
2129
  .redesign .txr.cl .m{color:var(--ink)}
2107
2130
  .redesign .txr.tool .m{color:var(--ink2);font-family:var(--mono);font-size:12px}
2108
2131
  .redesign .txr.tool .m b{color:var(--ink);font-weight:600}
2109
2132
  .redesign .txr.res .m{font-family:var(--mono);font-size:12px;color:var(--green)}
2133
+ /* Manager's own progress notes inside the implementation log: commentary, not results.
2134
+ Green would read as "passed" (§5.5 UIクロームは薄く・優しく・主張しない). */
2135
+ .redesign .txr.note .m{font-family:var(--mono);font-size:12px;color:var(--ink3)}
2110
2136
  /* Discussion (review back-and-forth) folded at the bottom of Activity's body */
2111
2137
  .redesign .lbody .rthread{display:flex;flex-direction:column;gap:16px;margin:0}
2112
2138
  .redesign .lbody .rmsg .who{font-size:12px;font-weight:600;color:var(--ink);margin-bottom:4px}
@@ -3361,16 +3387,6 @@ function reviewCardConfigFor(projectId){ return { ...DEFAULT_REVIEW_DEFINITION.r
3361
3387
  // goal's own request language, same as the planner/worker; ja/en pin the
3362
3388
  // project to one language regardless of what a given goal was typed in.
3363
3389
  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
3390
 
3375
3391
  function esc(s){ const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
3376
3392
 
@@ -3522,30 +3538,28 @@ function saReviewTitle(g){
3522
3538
  // reviewSummary.title, so they fall back to the request text unchanged (no regression).
3523
3539
  return String(g?.reviewSummary?.title || g?.text || g?.plan?.[0] || g?.reviewSummary?.changed || '').replace(/\s+/g, ' ').trim().slice(0, 90);
3524
3540
  }
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 ?? ''))); }
3541
+ // H23 (2026-07-18) evidence-kind detection. flagship's demo data carries an explicit
3542
+ // `kind` field; real goals/tasks have no such field, so the review card derives one
3543
+ // from real signals it already has (a captured proof image, whether the changed files
3544
+ // are all docs-only).
3545
+ //
3546
+ // P1 (Masa 2026-07-21): the 'missing' kind is gone, and with it the app-local copy of
3547
+ // isUiChange(). It said "this touched the UI, so a screenshot SHOULD exist" a rule
3548
+ // from the era when we photographed the work ourselves. That capture never ran outside
3549
+ // this one repo, so the only thing the rule ever produced was an apology on every UI
3550
+ // goal. We don't take the photo any more, so there is nothing to apologise for.
3535
3551
  const DOC_ONLY_RE_LOCAL = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/.*\.(md|mdx|txt)$|\.mdx?$|\.txt$/i;
3536
3552
  function hasTestRelevantChangesLocal(files){
3537
3553
  const fs = (files ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
3538
3554
  if (!fs.length) return true;
3539
3555
  return fs.some((f) => !DOC_ONLY_RE_LOCAL.test(f));
3540
3556
  }
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) >
3557
+ // Priority: image (a real capture exists) > docs (every changed file is docs-only) >
3543
3558
  // logic (verified only by tests, no UI). Falls back to 'image' (today's generic
3544
3559
  // fact-row + no image) when no signal is conclusive — never claims a stronger kind
3545
3560
  // than the data actually supports.
3546
3561
  function saEvidenceKind(filePaths, hasProofImg, testResult){
3547
3562
  if (hasProofImg) return 'image';
3548
- if (isUiChangeLocal(filePaths)) return 'missing';
3549
3563
  if (filePaths.length && !hasTestRelevantChangesLocal(filePaths)) return 'docs';
3550
3564
  if (testResult?.ran) return 'logic';
3551
3565
  return 'image';
@@ -3596,11 +3610,28 @@ function requirementSummaryLine(task){
3596
3610
  // Worker reports are written in Markdown (## headings, **bold**, - lists, `code`).
3597
3611
  // The review cards showed them raw, so "## " and "**" leaked as literal text.
3598
3612
  // Render the common subset to HTML (escaping first) so they read cleanly.
3599
- function mdLite(src){
3613
+ // P1 (Masa 2026-07-21): the AI's report IS the deliverable, so an image it pasted
3614
+ // (`![alt](url)`) has to render as that image — this is the whole "paste it and it
3615
+ // shows up" wiring. Only src values we can vouch for are allowed through: http(s),
3616
+ // a same-origin path, or an inline data:image. Anything else (javascript:, data:text
3617
+ // /html, …) renders as the literal text it was, never as an <img src>.
3618
+ const MD_IMG_SRC_OK = /^(?:https?:\/\/|\/(?!\/)|data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,)/i;
3619
+ // `images: false` renders a pasted image as nothing at all. Masa 2026-07-22, after
3620
+ // seeing one in a real card: the review card is not a gallery — the way to see the work
3621
+ // is Open it, and a lone blurry still of "one of the three" is worse than no still. The
3622
+ // renderer keeps the capability (chat and the older checklist still use images); the
3623
+ // review card is simply not the place.
3624
+ function mdLite(src, { images = true } = {}){
3600
3625
  const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
3601
3626
  const inline = (s) => esc(s)
3602
3627
  .replace(/`([^`]+)`/g, '<code>$1</code>')
3603
3628
  .replace(/\*\*([^*]+?)\*\*/g, '<strong>$1</strong>')
3629
+ // images before links — ![alt](url) would otherwise match the link rule below
3630
+ // and render as a bare "!" plus an <a>.
3631
+ .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, url) => (!images ? ''
3632
+ : MD_IMG_SRC_OK.test(url)
3633
+ ? `<img class="mdimg" src="${url.replace(/"/g, '&quot;')}" alt="${alt}" loading="lazy">`
3634
+ : m))
3604
3635
  // Feedback: make links tappable — markdown [text](url) and bare URLs (e.g. a
3605
3636
  // "open the ReadMe/PROOF.md" link in the worker report).
3606
3637
  .replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
@@ -3740,30 +3771,22 @@ function proofMediaHtml(taskId, proof, altLabel){
3740
3771
  }
3741
3772
  return imgTag;
3742
3773
  }
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 } = {}) {
3774
+ // (P1, Masa 2026-07-21) beforeAfterMediaHtml() was deleted here. The pair was staged by
3775
+ // us, not by the work: the "before" shot came from the same photo-staging pass that P1
3776
+ // removed on the engine side (#228), and in practice it never existed at all, so every
3777
+ // card fell through to the single shot anyway. Callers now ask for that shot directly.
3778
+ // Red box (restored 2026-07-22 on Masa's ask). This machinery was deleted in #230 with
3779
+ // the staged before/after pair, because the 'before' was a photo WE took of a screen
3780
+ // that had nothing to do with the change. It comes back on a different footing: the two
3781
+ // images are both the worker's own shots, taken by the agent that did the work, and the
3782
+ // box is computed here from those two PNGs by pixel comparison — nothing is invented, a
3783
+ // box only appears when the pixels really differ in one region.
3784
+ // minCoverage lowered 2026-07-22: the old 0.05% floor was tuned on a 640x200 proof pair.
3785
+ // A real worker shot is a whole 900-1440px page, where a genuine change (a dot going
3786
+ // green, a label swapping) is well under that floor and was being thrown away as noise.
3787
+ // 30 pixels differing by more than `threshold` is a change someone made on purpose;
3788
+ // compression noise does not clear a 24-point RGB delta.
3789
+ function computeDiffBox(beforeImg, afterImg, { threshold = 24, minPixels = 30, maxCoverage = 0.7, pad = 0.03 } = {}) {
3767
3790
  const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
3768
3791
  if (w !== afterImg.naturalWidth || h !== afterImg.naturalHeight) return null; // different-shaped images: don't guess
3769
3792
  const c1 = document.createElement('canvas'); c1.width = w; c1.height = h;
@@ -3785,7 +3808,7 @@ function computeDiffBox(beforeImg, afterImg, { threshold = 24, minCoverage = 0.0
3785
3808
  }
3786
3809
  }
3787
3810
  const total = w * h;
3788
- if (maxX < 0 || diffPx / total < minCoverage) return null; // no real difference — don't box a no-op
3811
+ if (maxX < 0 || diffPx < minPixels) return null; // no real difference — don't box a no-op
3789
3812
  if (diffPx / total > maxCoverage) return null; // diff too diffuse to be "a spot" — a full redesign shouldn't get a red box either
3790
3813
  const padPx = Math.round(Math.max(w, h) * pad);
3791
3814
  minX = Math.max(0, minX - padPx); minY = Math.max(0, minY - padPx);
@@ -3811,34 +3834,45 @@ function baZoomDataUrl(img, box, maxW = 420, maxH = 200){
3811
3834
  c.getContext('2d').drawImage(img, box.x, box.y, box.w, box.h, 0, 0, outW, outH);
3812
3835
  return c.toDataURL('image/png');
3813
3836
  }
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);
3837
+ // A before/after pair exists only when the AI SAYS two of its shots are the same view
3838
+ // before and after (task.run.compare = [beforeName, afterName]). Masa 2026-07-22: an
3839
+ // earlier version of this guessed the pair from file names containing "before"/"after",
3840
+ // which is this layer inventing a relationship it cannot know the same mistake as the
3841
+ // intake AI writing the pass condition. A request for three options has no "after"; only
3842
+ // the agent that did the work knows which images relate, so only it can say so.
3843
+ //
3844
+ // Nothing declares `compare` yet (the engine keeps cmd/url/note/check), so no card draws
3845
+ // a box today. The machinery below stays wired: the day the worker can declare a pair,
3846
+ // it lights up with no further app change.
3847
+ function saShotPair(shots, declared){
3848
+ const list = shots ?? [];
3849
+ const pair = Array.isArray(declared) ? declared : null;
3850
+ if (!pair || pair.length !== 2) return null;
3851
+ const find = (n) => list.find((s) => s.name === n || String(s.url || '').endsWith(String(n)));
3852
+ const before = find(pair[0]), after = find(pair[1]);
3853
+ return before && after && before !== after ? { before, after } : null;
3854
+ }
3855
+ function saPlainPairHtml(pair){
3856
+ const col = (sh, label) => `<div class="ba-col"><span class="balabel">${label}</span><img class="proofimg" src="${esc(sh.url)}" alt="${esc(label)}" loading="lazy"></div>`;
3857
+ return `<div class="beforeafter">${col(pair.before, 'Before')}${col(pair.after, 'After')}</div>`;
3858
+ }
3859
+ // Renders the pair with the changed region boxed on both, plus a cropped enlargement.
3860
+ // Returns null on every fallback (not two images, load failure, no clean box, different
3861
+ // sizes) so the caller keeps showing the plain shots — never a guessed box.
3862
+ async function shotsBoxedHtml(before, after){
3863
+ if (!before?.url || !after?.url) return null;
3864
+ let bImg, aImg;
3865
+ try { [bImg, aImg] = await Promise.all([baLoadImage(before.url), baLoadImage(after.url)]); }
3866
+ catch { return null; }
3867
+ const box = computeDiffBox(bImg, aImg);
3834
3868
  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>`;
3869
+ const w = bImg.naturalWidth, h = bImg.naturalHeight;
3870
+ const st = `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)}%`;
3871
+ 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()}" loading="lazy"><div class="ba-box" style="${st}"></div></div></div>`;
3872
+ const zoom = (dataUrl, label) => `<div class="ba-col"><img class="ba-zoomimg" src="${dataUrl}" alt="zoomed ${label}"></div>`;
3873
+ return `<div class="beforeafter">${frame(bImg, 'Before')}${frame(aImg, 'After')}</div>`
3874
+ + `<div class="ba-zoomhd">Zoomed on the boxed area</div>`
3875
+ + `<div class="beforeafter ba-zoomrow">${zoom(baZoomDataUrl(bImg, box), 'before')}${zoom(baZoomDataUrl(aImg, box), 'after')}</div>`;
3842
3876
  }
3843
3877
  // swap the still for the real video only on demand, one at a time
3844
3878
  document.addEventListener('click', (e) => {
@@ -3904,15 +3938,28 @@ function progressMeta(t){
3904
3938
  // checked + struck through, the in-progress one is highlighted, pending ones
3905
3939
  // are dimmed. Empty list → nothing (workers without todos look unchanged).
3906
3940
  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) => {
3941
+ // P0b (Masa 2026-07-21, planner demoted): the AI's own TodoWrite list is now the
3942
+ // only breakdown a goal has (1 goal = 1 task, no invented sub-tasks). `state.todos`
3943
+ // mirrors it live via the 'todos' SSE event; once the task finishes, the server
3944
+ // freezes the same shape into `task.todosFinal` (engine/server.mjs) since the live
3945
+ // field gets stripped off finished tasks. This is the one place that resolves
3946
+ // "which list to show" — live wins while it has content, else the frozen one.
3947
+ function todoSourceFor(t){
3948
+ const live = state.todos[t?.id];
3949
+ if (Array.isArray(live) && live.length) return live;
3950
+ return Array.isArray(t?.todosFinal) ? t.todosFinal : [];
3951
+ }
3952
+ function todoRowsHtml(list){
3953
+ return list.map((d) => {
3911
3954
  const st = ['completed', 'in_progress', 'pending'].includes(d.status) ? d.status : 'pending';
3912
3955
  const label = st === 'in_progress' && d.activeForm ? d.activeForm : d.content;
3913
3956
  return `<div class="todo ${st}"><span class="box">${TODO_MARK[st]}</span><span class="tdtext">${esc(label ?? '')}</span></div>`;
3914
3957
  }).join('');
3915
- return `<div class="todos"><div class="tdhead">To-do</div>${rows}</div>`;
3958
+ }
3959
+ function todosHtml(t){
3960
+ const list = todoSourceFor(t);
3961
+ if (!list.length) return '';
3962
+ return `<div class="todos"><div class="tdhead">To-do</div>${todoRowsHtml(list)}</div>`;
3916
3963
  }
3917
3964
 
3918
3965
  function taskBlock(t){
@@ -4386,7 +4433,6 @@ function renderTasks(){
4386
4433
  const digestRows = buildReviewDigest({ goals: pgoals, tasks: list });
4387
4434
  const digestCount = digestRows.length;
4388
4435
  const dGreen = digestRows.filter((r) => !r.testResult || r.testResult.ok !== false).length;
4389
- const dProof = digestRows.filter((r) => r.proof).length;
4390
4436
  const dPct = digestCount ? Math.round((dGreen / digestCount) * 100) : 0;
4391
4437
  const dBlocked = pgoals.filter((g) => g.status === 'blocked').length; // §10: reflect real blocked count, don't hardcode
4392
4438
  // CDO review 2026-07-08 §6: dGreen/digestCount already exclude blocked goals (they
@@ -4404,7 +4450,7 @@ function renderTasks(){
4404
4450
  ? `<div class="s5sum" id="summaryEntry" title="Open the review summary (ペライチ)">
4405
4451
  <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
4452
  <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>
4453
+ <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
4454
  </div>`
4409
4455
  : '';
4410
4456
  const REVIEW_LIMIT = 5;
@@ -4418,16 +4464,12 @@ function renderTasks(){
4418
4464
  // Feedback 3: the row carries only the check-line + PR link. Tests/proof are
4419
4465
  // internal gate checks (they still block), NOT list clutter; the Review number
4420
4466
  // 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(', ')}`) : '';
4467
+ // PRD §6.5 Masa 2026-07-21: goals folded onto this one are NOT announced here
4468
+ // any more ("+ #901 の仕事もここに入っています" read as the same report-in-the-list
4469
+ // clutter as the old "→ #12 にまとめました" row Masa: review欄にいらない). Where
4470
+ // the work went is logged inside the opened detail's "Also carries" section
4471
+ // (see saItemHtml's carriesBody) instead the list row stays just the ask.
4429
4472
  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
4473
  <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
4474
  <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
4475
  <div class="revquick">
@@ -4437,16 +4479,10 @@ function renderTasks(){
4437
4479
  </div></div>`;
4438
4480
  }).join('')
4439
4481
  + (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('');
4482
+ // PRD §3.5 → Masa 2026-07-21: a folded goal no longer gets its own "→ #12 にまとめました"
4483
+ // row in the Review column (Masa: review欄にいらない・わかりにくい). It is never deleted
4484
+ // where its work went is now logged inside the carrier review's detail ("Also carries",
4485
+ // see saItemHtml), so the column stays clean while the trail is still one tap away.
4450
4486
  const doneRows = grouped(list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num), (t) => row(t));
4451
4487
  // Pending = deliberately shelved goals (e.g. "npm公開はPhase 2"): never
4452
4488
  // planned or queued until Start is pressed. Parked below To do on purpose.
@@ -4458,7 +4494,7 @@ function renderTasks(){
4458
4494
  const savedQAns = captureFocusedAnswerInput($('tasklist'), 'data-qansinput');
4459
4495
  $('tasklist').innerHTML =
4460
4496
  sec('Attention', attention, 'attn')
4461
- + sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review + foldedRows, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
4497
+ + sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
4462
4498
  + sec(pickColumnLabel(cols, 'doing', 'Doing'), doing)
4463
4499
  + sec(pickColumnLabel(cols, 'todo', 'To do'), todo, 'todosec')
4464
4500
  + sec('Pending', pendingRows, 'dim')
@@ -4910,9 +4946,6 @@ function renderRdEditor(){
4910
4946
  <label class="rdrow"><input type="checkbox" id="rdCardScreenshots"${state.rdDraft.reviewCard.screenshots ? ' checked' : ''}>
4911
4947
  <span><span class="rdlabel">スクショ / Screenshots</span>
4912
4948
  <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
4949
 
4917
4950
  <div class="rdsub">出力言語 / Output language</div>
4918
4951
  <label class="rdrow"><select id="rdLanguage">
@@ -4933,7 +4966,6 @@ function renderRdEditor(){
4933
4966
  $('rdPrScreenshots').addEventListener('change', (e) => { state.rdDraft.prSections.screenshots = e.target.checked; });
4934
4967
  $('rdPrRisk').addEventListener('change', (e) => { state.rdDraft.prSections.risk = e.target.checked; });
4935
4968
  $('rdCardScreenshots').addEventListener('change', (e) => { state.rdDraft.reviewCard.screenshots = e.target.checked; });
4936
- $('rdCardBeforeAfter').addEventListener('change', (e) => { state.rdDraft.reviewCard.beforeAfter = e.target.checked; });
4937
4969
  $('rdLanguage').addEventListener('change', (e) => { state.rdDraft.language = e.target.value; });
4938
4970
  }
4939
4971
  function renderWfEditor(){
@@ -5127,10 +5159,8 @@ function renderReviewChecklist(){
5127
5159
  $('reviewDoneMsg').classList.toggle('show', goal.status === 'done');
5128
5160
  // task 97: doneカードの『元に戻す』——押し間違いのApproveを取り消し、reviewへ戻す。
5129
5161
  $('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).
5162
+ // reviewCard.screenshots off → the media block is skipped entirely, not just
5163
+ // emptied. (The Before/After switch is gone with the staged pair it controlled — P1.)
5134
5164
  const rc = reviewCardConfigFor(state.active);
5135
5165
  $('reviewList').innerHTML = reqs.map((t, i) => {
5136
5166
  const st = approvals[t.id];
@@ -5141,13 +5171,14 @@ function renderReviewChecklist(){
5141
5171
  <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
5172
  </div>`;
5143
5173
  }
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>';
5174
+ // P1: one shot when a promised check actually captured one, otherwise nothing at
5175
+ // all "No capture yet" was us apologising for a photo we no longer go and take.
5176
+ const media = proofMediaHtml(t.id, t.proof, esc(t.title ?? ''));
5146
5177
  // task 156/234 + 160: show the WORKER'S REPORT (what it actually did/found,
5147
5178
  // incl. any test-only verification it describes) — that's what you read to
5148
5179
  // decide. Falls back to the instruction if the worker left no result.
5149
5180
  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>`
5181
+ const desc = report ? `<div class="reqdesc"><span class="reqreportlbl">Worker report</span>${mdLite(report)}</div>`
5151
5182
  : t.detail ? `<div class="reqdesc">${mdLite(t.detail.slice(0, 600))}</div>` : '';
5152
5183
  const usage = formatUsageCompact(t.usage);
5153
5184
  const usageBadge = usage ? `<span class="tbadge usage">${usage} tokens</span>` : '';
@@ -6093,6 +6124,11 @@ function renderFsBoard(){
6093
6124
  const paused = !!state.projects.find((p) => p.id === state.active)?.paused; // Doing列トグルの状態
6094
6125
  const { pgoals, list } = fsBucketTodo();
6095
6126
  const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 60));
6127
+ // Card copy language — same rule as reviewLangFor(state.active) === 'ja', inlined
6128
+ // (not calling reviewLangFor itself) because engine/test/board-goal-level.test.mjs
6129
+ // evals this function body standalone off a minimal `state` stub with no
6130
+ // state.reviewDefinitions; reviewLangFor would throw there.
6131
+ const boardJa = state.reviewDefinitions?.[state.active]?.language === 'ja';
6096
6132
  const goalNo = new Map();
6097
6133
  for (const g of pgoals) { const n = goalReviewNumber({ goal: g, tasks: list }); if (n != null) goalNo.set(g.id, n); }
6098
6134
  // + New in EVERY column, matching the flagship Artifact: a hover-reveal "+" in the header
@@ -6107,6 +6143,35 @@ function renderFsBoard(){
6107
6143
  // status, so the card never repeats it. Stopped/failed carry NO bar (a dead bar would claim
6108
6144
  // the goal is moving) — a coloured word (Failed/Interrupted) says it instead.
6109
6145
  const goalTasks = (g) => list.filter((t) => t.goalId === g.id && !t.reply);
6146
+ // P0b (Masa 2026-07-21): the card's breakdown is the AI's OWN TodoWrite list — never
6147
+ // the planner's invented sub-tasks (there aren't any anymore; 1 goal = 1 task). Live
6148
+ // list wins while it has content, else the frozen task.todosFinal (mirrors the
6149
+ // module-level todoSourceFor() used by the chat feed's todosHtml — duplicated inline
6150
+ // rather than called, same reason as boardJa above: this whole function is evaluated
6151
+ // standalone by the board-goal-level test harness, which only stubs `state`/`esc`).
6152
+ const todoListFor = (t) => {
6153
+ const live = state.todos?.[t?.id];
6154
+ if (Array.isArray(live) && live.length) return live;
6155
+ return Array.isArray(t?.todosFinal) ? t.todosFinal : [];
6156
+ };
6157
+ // Capped + folded rather than scrolled (§1.5-8). While the task is actively running
6158
+ // but hasn't reached its first TodoWrite yet, the list is genuinely empty — show one
6159
+ // small "preparing" line instead of an empty container (§1.5-3), never a bare gap.
6160
+ const TODO_CARD_CAP = 5, TODO_MARK_BOARD = { completed: '✓', in_progress: '▸', pending: '○' };
6161
+ const goalCardTodosHtml = (t) => {
6162
+ if (!t) return '';
6163
+ const list_ = todoListFor(t);
6164
+ if (!list_.length) return t.status === 'running' ? `<div class="lbc-todos prep">${boardJa ? '準備中…' : 'Preparing…'}</div>` : '';
6165
+ const shown = list_.slice(0, TODO_CARD_CAP);
6166
+ const restN = list_.length - shown.length;
6167
+ const rows = shown.map((d) => {
6168
+ const st = ['completed', 'in_progress', 'pending'].includes(d.status) ? d.status : 'pending';
6169
+ const label = st === 'in_progress' && d.activeForm ? d.activeForm : d.content;
6170
+ return `<div class="todo ${st}"><span class="box">${TODO_MARK_BOARD[st]}</span><span class="tdtext">${esc(label ?? '')}</span></div>`;
6171
+ }).join('');
6172
+ const more = restN > 0 ? `<div class="lbc-more">+${restN} more</div>` : '';
6173
+ return `<div class="lbc-todos">${rows}${more}</div>`;
6174
+ };
6110
6175
  const goalCard = (g) => {
6111
6176
  const bkt = fsBoardBucket(g, list);
6112
6177
  // errored is status-based now (blocked/failed/partial/interrupted live in the Needs-you column,
@@ -6131,13 +6196,21 @@ function renderFsBoard(){
6131
6196
  + (word ? `<span class="lbc-w ${amber ? 'intr' : 'bad'}">${word}</span>` : '')
6132
6197
  + '</div>'
6133
6198
  + `<div class="lbc-t">${tt(g.text || g.plan?.[0], 90)}</div>`;
6199
+ // The goal's one task (post-demotion there's exactly one non-reply task; [0]
6200
+ // is just defensive against any pre-migration leftovers with more than one).
6201
+ const task = goalTasks(g)[0];
6202
+ // Todos: not shown for To Do / Later — nothing has actually started yet there, so
6203
+ // there is nothing honest to fold open (quiet stays quiet). Doing/Needs/Review/Done
6204
+ // all show the AI's own breakdown — that's what makes it survive past "running".
6205
+ if (task && bkt !== 'todo' && bkt !== 'later') h += goalCardTodosHtml(task);
6134
6206
  // n/m is Doing-only (Masa 2026-07-17): a fraction on a stopped/queued/done card is
6135
6207
  // 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;
6208
+ // P0b: done/total now reflect the AI's OWN todos (not the planner's invented tasks,
6209
+ // which under 1-goal=1-task degenerate to a useless 0/1 → 1/1).
6210
+ if (healthyRun && task) {
6211
+ const tl = todoListFor(task), total = tl.length;
6139
6212
  if (total) {
6140
- const done = gt.filter((t) => ['done', 'skipped'].includes(t.status)).length;
6213
+ const done = tl.filter((d) => d.status === 'completed').length;
6141
6214
  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
6215
  }
6143
6216
  }
@@ -6227,16 +6300,11 @@ function renderFsReview(){
6227
6300
  const pgoals = state.goals.filter((g) => g.projectId === state.active);
6228
6301
  const list = state.tasks.filter((t) => t.projectId === state.active);
6229
6302
  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
6303
+ // PRD §3.5/§6.5 → Masa 2026-07-21: folded goals no longer get their own card in the
6304
+ // Review column (Masa: review欄にいらない). They were never deleted where their work
6305
+ // went is now logged inside the carrier review's detail ("Also carries", see saItemHtml).
6306
+ if (!rows.length) { el.innerHTML = ''; return; } // §1.5-3: no reviews → the UI itself does not exist
6238
6307
  const green = rows.filter((r) => !r.testResult || r.testResult.ok !== false).length;
6239
- const proofN = rows.filter((r) => r.proof).length;
6240
6308
  const blockedN = pgoals.filter((g) => g.status === 'blocked').length;
6241
6309
  const pct = rows.length ? Math.round((green / rows.length) * 100) : 0;
6242
6310
  // CDO review 2026-07-08 §6: same fix as the s5sum digest above — green/rows.length
@@ -6244,7 +6312,7 @@ function renderFsReview(){
6244
6312
  // ratio was already correct; "all green" is the line that used to fire regardless
6245
6313
  // of blockedN, reading as "18/18 ready · all green · 1 blocked" (self-contradictory).
6246
6314
  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'}`;
6315
+ const sline = `${allClear ? 'all green' : green === rows.length ? 'blocked needs attention' : `${rows.length - green} failing`} · ${blockedN ? `${blockedN} blocked` : 'none blocked'}`;
6248
6316
  const RCAP = 3;
6249
6317
  const shown = fsUI.revMore ? rows : rows.slice(0, RCAP);
6250
6318
  const card = (r) => {
@@ -6264,31 +6332,18 @@ function renderFsReview(){
6264
6332
  // retest ×3 note (v45 §5.3): the goal came back green from a Ledger Dismiss
6265
6333
  const g0 = pgoals.find((g) => g.id === r.goalIds[0]);
6266
6334
  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>` : '';
6335
+ // PRD §6.5 Masa 2026-07-21: goals folded onto this card's PR no longer get an
6336
+ // inline "+ #901 の仕事もここに入っています" note here same report-in-the-list
6337
+ // clutter the old standalone fold row was (Masa: review欄にいらない). It is logged
6338
+ // inside the opened detail's "Also carries" section instead (see saItemHtml).
6274
6339
  // Title = the goal's own title (saReviewTitle → text-first), same as the See-all
6275
6340
  // Ledger and the To Do list, so a review card and its list row read identically
6276
6341
  // (Masa 2026-07-20: this card showed reviewSummary.check "…が出ない" while the list
6277
6342
  // showed the request text). Plan-reviews keep their "PLAN — approve to execute:"
6278
6343
  // 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>
6344
+ 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
6345
  <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
6346
  };
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
6347
  // V3-C (Masa決定 2026-07-07): no collapsed strip — it duplicated the sm2 meter right
6293
6348
  // above. Reviews render directly: meter + Review header + cards (+N more fold, §1.5-8).
6294
6349
  // The meter counts what is reviewable, so it only exists when something is (§1.5-3):
@@ -6304,7 +6359,7 @@ function renderFsReview(){
6304
6359
  <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
6360
  <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
6361
  </span></div>
6307
- <div class="revlist">${shown.map(card).join('')}${foldedGoals.map(foldCard).join('')}
6362
+ <div class="revlist">${shown.map(card).join('')}
6308
6363
  ${rows.length > RCAP ? `<div class="morefold" id="fsRevMore">${fsUI.revMore ? 'less' : `+${rows.length - RCAP} more`}<span class="g">›</span></div>` : ''}</div>`;
6309
6364
  // Mirror the ready-review count onto the mobile top-bar "Review N" opener (H23).
6310
6365
  const _hn = document.getElementById('fsHambN'); if (_hn) _hn.textContent = green > 0 ? String(green) : '';
@@ -6324,19 +6379,8 @@ function renderFsReview(){
6324
6379
  if (e.target.closest('a') || e.target.closest('.act')) return;
6325
6380
  openReviewChecklist(Number(c.dataset.fsrev));
6326
6381
  };
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
- };
6382
+ // Folded goals no longer render their own card here (Masa 2026-07-21) where their
6383
+ // work went is logged inside the carrier review's detail ("Also carries"), not tapped.
6340
6384
  for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
6341
6385
  let ok = true, mergeGoal = null;
6342
6386
  for (const id of b.dataset.fsapprove.split(',')) {
@@ -7413,8 +7457,14 @@ async function refresh(){
7413
7457
  // back the already-accumulated activity for 'running' tasks.
7414
7458
  for (const t of state.tasks) { if (t.activity?.length) state.act[t.id] = t.activity; }
7415
7459
  // 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; }
7460
+ // (task 108/109) so a mid-run refresh restores the checklist immediately. A
7461
+ // FINISHED task has no live t.todos (trimTaskActivityForState strips it) but
7462
+ // keeps t.todosFinal — seed from that too (P0b), so a reload of a completed
7463
+ // goal still shows the AI's own breakdown instead of losing it silently.
7464
+ for (const t of state.tasks) {
7465
+ if (t.todos?.length) state.todos[t.id] = t.todos;
7466
+ else if (t.todosFinal?.length) state.todos[t.id] = t.todosFinal;
7467
+ }
7418
7468
  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
7469
  state.billing = s.billing ?? { licensed: false, email: null };
7420
7470
  renderAppSelect(); // picker follows the server's installed-agent set; may correct a stale connectedApp
@@ -8053,7 +8103,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
8053
8103
  // 4 archived / 5 reverted — session-local; the server round-trip is what
8054
8104
  // settles it (see saSync's "returned to review" reset).
8055
8105
  // ============================================================================
8056
- const SA = { open: false, items: [], diffs: {}, diffBoxHtml: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
8106
+ const SA = { open: false, items: [], diffs: {}, actLog: {}, shotBox: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
8057
8107
  window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
8058
8108
  const SA_SVG = {
8059
8109
  ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
@@ -8086,8 +8136,13 @@ function saRows(){
8086
8136
  return { no: goalReviewNumber({ goal: sg, tasks: ts }), title: saReviewTitle(sg), cur: gid === r.goalIds[0] };
8087
8137
  })
8088
8138
  : null;
8139
+ // Masa 2026-07-21: goals folded onto this review no longer take their own Review-column
8140
+ // row ("→ #12 にまとめました" read as clutter). Instead this review's detail logs what
8141
+ // it carries. Mirrors engine/lib.mjs foldedIntoReview() (browser can't import it).
8142
+ const folded = gs.filter((x) => x.status === 'folded' && x.foldedInto != null && r.goalIds.includes(x.foldedInto))
8143
+ .map((x) => ({ id: x.id, title: saReviewTitle(x) }));
8089
8144
  return {
8090
- ids: r.goalIds, goalId: r.goalIds[0], goal: g,
8145
+ ids: r.goalIds, goalId: r.goalIds[0], goal: g, folded,
8091
8146
  no: r.reviewNumber,
8092
8147
  title: saReviewTitle(g),
8093
8148
  check: String(g?.reviewSummary?.check || r.checkLine || '').replace(/\s+/g, ' ').trim(),
@@ -8095,6 +8150,47 @@ function saRows(){
8095
8150
  did, siblings, risk: g?.risk ?? null,
8096
8151
  note: g?.retest?.passed ? g.retest.note : null,
8097
8152
  pr: r.pr,
8153
+ // P0b (Masa 2026-07-21): the AI's own TodoWrite breakdown for the "Steps" section
8154
+ // below — the primary goal's own non-reply task, frozen at completion. Distinct
8155
+ // from `did` above (task.result prose) and from Activity (raw tool-call log).
8156
+ todosFinal: (() => {
8157
+ const t = ts.find((x) => x.goalId === g?.id && !x.reply);
8158
+ return Array.isArray(t?.todosFinal) ? t.todosFinal : [];
8159
+ })(),
8160
+ // Screenshots the worker took of what it built (#229). Measured 2026-07-22 on real
8161
+ // runs: it takes 1-3 depending on the ask — three variants as three shots, or four
8162
+ // variants as ONE comparison sheet. A single shot is not a lazy shot, so none of
8163
+ // these get thumbnailed down.
8164
+ //
8165
+ // Folded goals count: a goal folded into this one keeps its own tasks, and those
8166
+ // tasks carry shots. Reading only the primary goal's tasks makes real deliverables
8167
+ // vanish from the screen (measured: #4 folded into #3, its images disappeared).
8168
+ shots: (() => {
8169
+ const ids = [...r.goalIds, ...folded.map((f) => f.id)];
8170
+ return ts.filter((t) => ids.includes(t.goalId)).flatMap((t) => t.shots ?? []);
8171
+ })(),
8172
+ // The task whose implementation log the engine froze at completion (P1.5). The
8173
+ // lines are NOT in state — 1000 of them per task would bloat every page load — so
8174
+ // this is just the id to ask for when the reviewer opens Activity.
8175
+ logTaskId: (() => {
8176
+ const t = ts.find((x) => x.goalId === g?.id && !x.reply && x.activityLog);
8177
+ return t?.id ?? null;
8178
+ })(),
8179
+ // P1 (Masa 2026-07-21): the AI's own final report, verbatim and untrimmed — the
8180
+ // card renders it as Markdown and interprets nothing. This is the deliverable for
8181
+ // every ask that has no running thing to open ("research this", "explain that"),
8182
+ // and the explanation for every ask that does.
8183
+ report: (() => {
8184
+ const t = ts.find((x) => x.goalId === g?.id && !x.reply && x.result);
8185
+ return String(t?.result ?? '').replace(/^検証(PASS|FAIL)[^\n]*\n+/, '').trim();
8186
+ })(),
8187
+ // …and the worker's own "here's how you see it running" declaration (task.run,
8188
+ // written by the worker into .manager-run.json). Absent for asks with nothing to
8189
+ // run — then no button, which is the honest state, not a missing feature.
8190
+ run: (() => {
8191
+ const t = ts.find((x) => x.goalId === g?.id && !x.reply && x.run);
8192
+ return t ? { taskId: t.id, ...t.run } : null;
8193
+ })(),
8098
8194
  // Explicit review-summary display fields (spec §11: the card's shape follows the
8099
8195
  // task). A goal's reviewSummary may carry them directly (e.g. a conversational task
8100
8196
  // that reports an Answer + its Activity, or a ticket whose test count / diff totals
@@ -8102,19 +8198,13 @@ function saRows(){
8102
8198
  testResult: (typeof (g?.reviewSummary?.tests) === 'string')
8103
8199
  ? { ran: true, ok: true, passed: parseInt(g.reviewSummary.tests, 10) || 0, failed: 0, label: g.reviewSummary.tests }
8104
8200
  : r.testResult,
8105
- answer: g?.reviewSummary?.answer ?? null,
8106
8201
  activity: Array.isArray(g?.reviewSummary?.activity) ? g.reviewSummary.activity : null,
8107
- discussion: Array.isArray(g?.reviewSummary?.discussion) ? g.reviewSummary.discussion : null,
8108
8202
  changedDesc: g?.reviewSummary?.changed ?? null,
8109
8203
  metaFiles: Array.isArray(g?.reviewSummary?.files) ? g.reviewSummary.files : null,
8110
8204
  metaAdd: g?.reviewSummary?.a ?? null, metaDel: g?.reviewSummary?.d ?? null,
8111
8205
  ghRows: Array.isArray(g?.reviewSummary?.ghdiff) ? g.reviewSummary.ghdiff : null,
8112
8206
  diffTotal: g?.reviewSummary?.diffTotal ?? null,
8113
8207
  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
8208
  // Recording (spec §5/§6): a proof task may carry a screen recording (proof.mp4)
8119
8209
  // or just a recording:true flag; the goal's reviewSummary can also name one.
8120
8210
  // Drives the neutral "Verify recording ↗" meta chip + the Proof video player.
@@ -8123,7 +8213,6 @@ function saRows(){
8123
8213
  recordingUrl: pt?.proof?.mp4 ? withKey(`/proof/${pt.id}/${pt.proof.mp4}`) : (typeof g?.reviewSummary?.recording === 'string' ? g.reviewSummary.recording : null),
8124
8214
  // Local URL (spec §5): a running localhost the reviewer can open ("Local localhost:4809 ↗").
8125
8215
  // Falls back to the existing "Local run ↗" (runlocal) button when absent.
8126
- localUrl: g?.reviewSummary?.localUrl ?? null,
8127
8216
  // Client-side diff fallback: the git-backed /diff endpoint is null for seeded/
8128
8217
  // offline goals, so "What changed" never renders. A unified-diff string stored on
8129
8218
  // the goal (or any of its tasks) lets saGhDiffHtml render locally with no server.
@@ -8146,85 +8235,33 @@ function saRows(){
8146
8235
  };
8147
8236
  });
8148
8237
  }
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
- }
8175
- // Context: closed by default, one-line verbatim-quote summary, chevron large and at
8176
- // the far right (H23 §2). Quotes goal.text — the user's own words — never
8177
- // task.detail/title (AI-authored; an earlier round of this design mistakenly quoted
8178
- // the AI's own read on the request as "your ask" and it wasn't recognizable).
8179
- function saContextHtml(it, gk){
8180
- const goalTxt = it.goalText;
8181
- const summary = goalTxt
8182
- ? `"${esc(goalTxt.length > 60 ? `${goalTxt.slice(0, 60)}…` : goalTxt)}"`
8183
- : 'No verbatim ask on record for this goal.';
8184
- const split = it.siblings
8185
- ? `<div class="lg-splitnote"><div class="hd">Split into ${it.siblings.length} goals — nothing else was dropped</div><ul>${it.siblings.map((s) => `<li class="${s.cur ? 'cur' : ''}">${s.no != null ? `#${esc(s.no)} ` : ''}${esc(s.title)}${s.cur ? ' <b>(this one)</b>' : ''}</li>`).join('')}</ul></div>`
8186
- : '';
8187
- const did = it.did?.length ? `<div class="lg-bullethd">What we did</div><ul class="lg-bullets">${it.did.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '';
8188
- const look = it.look?.length ? `<div class="lg-bullethd">What to look at</div><ul class="lg-bullets">${it.look.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '';
8189
- const inner = (split || did || look) ? `${split}${did}${look}` : '<div class="lg-notfound">Nothing else on record for this goal.</div>';
8190
- const open = SA.detOpen.has(`${gk}:ctx`) ? ' open' : '';
8191
- 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
- }
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
- }
8238
+ // (P1, Masa 2026-07-21) saMetaRowHtml() was deleted herethe pre-redesign meta row,
8239
+ // uncalled since the review-card redesign. Its "Run locally" chip only ever opened a
8240
+ // note saying no run command was wired; the card's Open it chip now starts the real one.
8241
+ // (saContextHtml() was deleted here — Masa 2026-07-22. The pre-redesign Context fold:
8242
+ // verbatim quote, 'What we did' / 'What to look at' bullets, sibling split note. Nothing
8243
+ // has called it since the review-card redesign; Request and Result carry that ground now.)
8222
8244
  // Unified-diff → GitHub two-gutter model (REVIEW-CARD-SPEC §7 "GitHub diff = Bright"):
8223
8245
  // walk the @@ hunk headers to recover the old/new line numbers, emit one row per line
8224
8246
  // {o:oldNo|null, n:newNo|null, t:'add'|'del'|'ctx', c:text}. Word-level highlight is
8225
8247
  // NOT fabricated from a raw diff (the product never invents evidence) — the Bright
8226
8248
  // line+gutter tint carries the change and .gh-hi simply stays absent. Pure, so it is
8227
8249
  // extracted and unit-tested in engine/test/review-card-diff.test.mjs.
8250
+ // How many body rows the diff really has, ignoring the display cap. A big change used
8251
+ // to be cut at 80 rows with nothing said (SPEC §7 asks for "…+N 行 · GitHub で開く ↗"
8252
+ // precisely so a reviewer is never shown a slice that looks like the whole thing).
8253
+ // Same skip rules as saUnifiedToGh below — headers and @@ markers are not rows.
8254
+ function saDiffRowCount(diff){
8255
+ let n = 0;
8256
+ for (const raw of String(diff || '').split('\n')) {
8257
+ if (raw === '') continue;
8258
+ if (/^(diff --git|index |--- |\+\+\+ |new file|deleted file|old mode|new mode|similarity|rename |Binary )/.test(raw)) continue;
8259
+ if (/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(raw)) continue;
8260
+ if (raw.startsWith('\\')) continue;
8261
+ n++;
8262
+ }
8263
+ return n;
8264
+ }
8228
8265
  function saUnifiedToGh(diff, max){
8229
8266
  const cap = max || 80; const out = []; let o = 0, n = 0;
8230
8267
  for (const raw of String(diff || '').split('\n')) {
@@ -8240,10 +8277,13 @@ function saUnifiedToGh(diff, max){
8240
8277
  }
8241
8278
  return out;
8242
8279
  }
8243
- function saGhDiffHtml(diff){
8280
+ function saGhDiffHtml(diff, pr){
8244
8281
  const rows = saUnifiedToGh(diff);
8245
8282
  if (!rows.length) return '';
8246
- return saGhDiffRowsHtml(rows);
8283
+ // Say what was left out. Before this, a 5000-line change rendered its first 80 rows
8284
+ // and stopped — a quiet lie, and the one thing SPEC §7 names as forbidden.
8285
+ const more = Math.max(0, saDiffRowCount(diff) - rows.length);
8286
+ return saGhDiffRowsHtml(rows, more, pr || '');
8247
8287
  }
8248
8288
  // One Activity transcript row (spec §3): You / tool (mono, bold name) / res (green) /
8249
8289
  // Claude. Mirrors the flagship artifact's tx mapping so the pulldown pixel-matches.
@@ -8253,6 +8293,18 @@ function saTxRowHtml(e){
8253
8293
  if (e[0] === 'user') return `<div class="txr you"><span class="w">You</span><span class="m">${esc(e[1])}</span></div>`;
8254
8294
  return `<div class="txr cl"><span class="w">Claude</span><span class="m">${esc(e[1])}</span></div>`;
8255
8295
  }
8296
+ // One line of the frozen implementation log. The worker's own tool calls come through
8297
+ // as "Read app/index.html" / "Bash npm test"; the Manager's own progress notes are
8298
+ // prefixed with ✎ by sendProcessAct() and read as commentary, so they take the quieter
8299
+ // row. Nothing is parsed beyond that split — the line is shown as it was written.
8300
+ function saLogRowHtml(line){
8301
+ const s = String(line ?? '');
8302
+ if (s.startsWith('✎')) return `<div class="txr note"><span class="w"></span><span class="m">${esc(s.replace(/^✎\s*/, ''))}</span></div>`;
8303
+ const m = s.match(/^([A-Z][A-Za-z]*)\s+([\s\S]*)$/);
8304
+ return m
8305
+ ? `<div class="txr tool"><span class="w">tool</span><span class="m"><b>${esc(m[1])}</b> ${esc(m[2])}</span></div>`
8306
+ : `<div class="txr cl"><span class="w"></span><span class="m">${esc(s)}</span></div>`;
8307
+ }
8256
8308
  // Structured GitHub diff (REVIEW-CARD-SPEC §7): renders {o,n,t,c,h} rows with a
8257
8309
  // word-level highlight (h) and, for a huge change, a "…+N 行 · GitHub で開く ↗"
8258
8310
  // footer (only linking to GitHub when a PR exists). Mirrors the flagship artifact's
@@ -8289,8 +8341,18 @@ function saVideoFigHtml(url){
8289
8341
  // No fabricated pipeline: an untested goal shows □ Tested, not a green ✓.
8290
8342
  function saWorkflowHtml(it){
8291
8343
  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']];
8344
+ // P1 (Masa 2026-07-21): "Proof captured □" is gone. We no longer go take photos of
8345
+ // the work, so an empty box there was scoring the goal against a step that isn't
8346
+ // part of the process any more — a permanent unchecked box nobody could ever check.
8347
+ // "Deploy" is gone too (Masa 2026-07-22): nothing in the product observes a deploy,
8348
+ // so that box could never be ticked — the same permanent-□ problem as Proof captured.
8349
+ // Merge stays because it IS observed: the PR poller flips the goal to done on merge.
8350
+ //
8351
+ // (PRD §5.5-A/15 asks for the USER'S OWN pipeline — PRD→design→implement→check→PR→
8352
+ // merge→deploy, varying per person. There is no place to record such a pipeline yet,
8353
+ // so inventing one here would be this layer guessing someone's process. Left as a
8354
+ // decision, not faked.)
8355
+ const steps = [['Implemented', 'ok'], ['Tested', ran ? 'ok' : 'todo'], ['In review', 'now'], ['Merge', 'todo']];
8294
8356
  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
8357
  }
8296
8358
  function saItemHtml(it, i){
@@ -8304,15 +8366,9 @@ function saItemHtml(it, i){
8304
8366
  const d = SA.diffs[it.goalId];
8305
8367
  const gtr = it.testResult;
8306
8368
  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
- : '';
8369
+ // Proof image (P1): the single capture from a check the AI itself promised and ran.
8370
+ // No pair, no red box those compared against a "before" shot we used to stage.
8371
+ const img = hasProofImg ? proofMediaHtml(it.proofTaskId, { png: it.proofImg }, esc(it.title ?? '')) : '';
8316
8372
  // Changed-file paths: d from the diff endpoint ({add,del,files:[{path,add,del}],diff}),
8317
8373
  // falling back to task.changedFiles when the server has no diff for this goal.
8318
8374
  // A usable git diff has actual files; the endpoint returns an empty {add:0,del:0,
@@ -8339,9 +8395,9 @@ function saItemHtml(it, i){
8339
8395
  const kind = saEvidenceKind(filePaths, hasProofImg, gtr);
8340
8396
  const gk = it.goalId;
8341
8397
  const rs = it.goal?.reviewSummary || {};
8342
- // Conversational task (no code change): drops Proof + What changed, shows an Answer
8343
- // instead (spec §11); its Activity IS the conversation.
8344
- const isConv = Boolean(it.answer);
8398
+ // A conversational task (no code change) has no Proof / What changed to show. Its
8399
+ // Result IS the answer (P1) the old separate Answer section is gone.
8400
+ const isConv = !it.report && !filePaths.length;
8345
8401
  // ── meta (spec §5) — fully data-driven / hide-empty: each chip renders only when its
8346
8402
  // data exists (no PR → no PR chip · no tests → no Tests · no file totals → no Files ·
8347
8403
  // no recording → no Verify). PR = bold green, no underline (a real <a href>). ──
@@ -8358,11 +8414,13 @@ function saItemHtml(it, i){
8358
8414
  if (testsLabel) chips.push(`<span class="mx"><span class="mk">Tests</span> <b class="ok">${esc(testsLabel)}</b></span>`);
8359
8415
  // Files: hide-empty — only with changed files AND totals (a bare-count chip reads odd).
8360
8416
  if (nf && fAdd) chips.push(`<span class="mx"><span class="mk">Files</span> <b>${nf}</b> <span class="mmut">· <i class="a">${esc(fAdd)}</i> <i class="d">${esc(fDel)}</i></span></span>`);
8361
- // Local: an open localhost URL (neutral, not green). A bare host:port isn't navigable in
8362
- // the demo, so it renders as a non-navigating chip; a real URL opens in a new tab.
8363
- if (it.localUrl) { const lu = String(it.localUrl); const isUrl = /^https?:/.test(lu);
8364
- 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>`);
8417
+ // (The Local chip was deleted here Masa 2026-07-22. It read reviewSummary.localUrl,
8418
+ // a key parseReviewSummary never emits, so it only ever appeared in demo data; and the
8419
+ // real thing it promised is the Open it section.)
8420
+ // (Open it is NOT a meta chip Masa 2026-07-22. It was saying the same thing as the
8421
+ // Open it section below, in the PR chip's vocabulary, so the card spoke two languages
8422
+ // about one thing. It lives in the body now, in the same typography as every other
8423
+ // section.)
8366
8424
  // Verify = a screen recording of the check (spec §5): NEUTRAL. Opens the file when there
8367
8425
  // is a real URL, otherwise scrolls to the in-card player.
8368
8426
  if (it.recording) chips.push(it.recordingUrl
@@ -8371,23 +8429,69 @@ function saItemHtml(it, i){
8371
8429
  const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
8372
8430
  const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
8373
8431
  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}`;
8432
+ // §1.5-3: with Open it moved into the body, a card with no PR / tokens / tests has no
8433
+ // chips at all — render no meta row rather than an empty one holding open a gap.
8434
+ const meta = chips.length ? `<div class="rmeta">${chips.join('')}</div>${metaDetail}` : '';
8376
8435
  // ── Request (spec §4): a PLAIN section — the user's own short words (goal.text). Never
8377
8436
  // an accordion, never the implementation spec (that lives in Activity below). ──
8378
8437
  const reqSec = it.goalText
8379
8438
  ? `<section class="rsec rreq"><div class="rsec-h">Request</div><p class="rbody rask">${esc(it.goalText)}</p></section>`
8380
8439
  : '';
8440
+ // ── Open it (Masa 2026-07-22): a plain section in the card's own typography — the same
8441
+ // heading + body shape as Request and Result, not a chip in another vocabulary. The
8442
+ // address the worker declared IS the control (press it → the process starts on demand →
8443
+ // it opens); its own note sits under it, and that note is where the status and any
8444
+ // failure reason land after the press. Nothing declared → no section (§1.5-3). ──
8445
+ const openBody = it.run
8446
+ ? `<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>`
8447
+ : '';
8448
+ // ── Also carries (Masa 2026-07-21): goals folded onto this review are logged HERE, inside
8449
+ // the review's own content — not as loose "→ #12 にまとめました" rows in the Review column.
8450
+ // Chrome label stays English (§377-7); the request titles keep whatever language they were
8451
+ // written in. Only rendered when something was actually folded in (§1.5-3 no empty box). ──
8452
+ const carriesBody = it.folded?.length
8453
+ ? `<ul class="rcheck">${it.folded.map((f) => `<li>#${esc(String(f.id))} ${esc(f.title)}</li>`).join('')}</ul>`
8454
+ : '';
8381
8455
  // ── What to check (spec §2.5): a bulleted list when several, a line when one. ──
8382
8456
  // Only an explicit review-summary check renders here — never the goal text echoed back
8383
8457
  // (it.check falls back to goal.text via checkLine, which would fabricate a "What to
8384
8458
  // check" for a conversational task that has none). §0.5 no placeholder.
8385
- const checkArr = Array.isArray(rs.checkList) ? rs.checkList.filter(Boolean)
8459
+ // The working AI's own sentence wins (P1, Masa 2026-07-22): it declares what to look
8460
+ // at in the same file it declares how to run the thing. reviewSummary.check below is
8461
+ // the intake AI's invention and only fills in for goals that predate the declaration.
8462
+ const checkArr = it.run?.check ? [it.run.check]
8463
+ : Array.isArray(rs.checkList) ? rs.checkList.filter(Boolean)
8386
8464
  : Array.isArray(rs.check) ? rs.check.filter(Boolean)
8387
8465
  : (typeof rs.check === 'string' && rs.check.trim() ? [rs.check.trim()] : []);
8388
8466
  const checkBody = checkArr.length > 1
8389
8467
  ? `<ul class="rcheck">${checkArr.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
8390
8468
  : (checkArr.length ? `<p class="rbody rcheck1">${esc(checkArr[0])}</p>` : '');
8469
+ // ── Steps (P0b, Masa 2026-07-21): the AI's own TodoWrite breakdown — distinct from
8470
+ // "What to check" (the human's pass condition) and Activity (the raw tool-call log).
8471
+ // Reuses the same .todos/.todo vocabulary as the chat feed / board card so the ✓/▸/○
8472
+ // language is consistent everywhere it appears. Empty → no section (§1.5-3).
8473
+ const stepsBody = it.todosFinal?.length ? `<div class="todos">${todoRowsHtml(it.todosFinal)}</div>` : '';
8474
+ // ── Result (P1, Masa 2026-07-21): the AI's own report, rendered as written and read
8475
+ // whole (no 160-char squeeze, no summariser of ours in the way). This is the one
8476
+ // section that carries every kind of ask — a研究 report reads here as prose, a UI
8477
+ // change explains itself here, and an image the AI pasted shows up here as an image.
8478
+ // We interpret nothing; mdLite only turns Markdown into the shapes it already means. ──
8479
+ const reportBody = it.report ? `<div class="rreport">${mdLite(it.report, { images: false })}</div>` : '';
8480
+ // ── Screenshots (P2, Masa 2026-07-22): the pictures the AI took of its own result.
8481
+ // Full width, at their own aspect — a four-variant comparison sheet is unreadable
8482
+ // shrunk into a thumbnail, and that is the shape the AI actually produces. The file
8483
+ // name it chose is the caption (it names them variant-1-linear.png / compare.png), so
8484
+ // it labels its own work; we add nothing. ──
8485
+ // A before/after pair is two shots the worker named as such — it decides whether the
8486
+ // change had a "before" worth shooting, we only recognise the naming. Anything else is
8487
+ // shown as the plain list.
8488
+ const shotPair = saShotPair(it.shots, it.run?.compare);
8489
+ const boxed = shotPair ? SA.shotBox[it.goalId] : null;
8490
+ const shotsBody = shotPair
8491
+ ? `<div class="rshotpair" data-shotpair="${it.goalId}">${typeof boxed === 'string' ? boxed : saPlainPairHtml(shotPair)}</div>`
8492
+ : it.shots?.length
8493
+ ? it.shots.map((sh) => `<figure class="rshot"><img src="${esc(sh.url)}" alt="${esc(sh.name || 'screenshot')}" loading="lazy"><figcaption>${esc(String(sh.name || '').replace(/\.[a-z]+$/i, ''))}</figcaption></figure>`).join('')
8494
+ : '';
8391
8495
  // ── Proof (spec §6/§11): before/after (identical size) when a real image exists; a
8392
8496
  // run-log for logic-only tests; dropped entirely for a conversational task. ──
8393
8497
  let proofInner = '';
@@ -8400,14 +8504,17 @@ function saItemHtml(it, i){
8400
8504
  // peeks then "…+N 行 · GitHub で開く ↗" for a huge change (no GitHub link when there's no
8401
8505
  // PR). Structured recorded rows (ghRows) win over a git/client unified diff. Dropped
8402
8506
  // 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>` : '';
8507
+ // No prose here (P1, Masa 2026-07-22): reviewSummary.changed was the intake AI writing
8508
+ // its own summary of the change, and on a real goal it came out as the request echoed
8509
+ // back. What changed is now only what actually changed — files and the diff.
8510
+ const changedDescHtml = '';
8404
8511
  const filesListHtml = it.metaFiles
8405
8512
  ? `<div class="rfiles">${it.metaFiles.map((f) => esc(f)).join('<br>')}</div>`
8406
8513
  : (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>`
8407
8514
  : (clientDiff && filePaths.length) ? `<div class="rfiles">${filePaths.map((p) => esc(p)).join('<br>')}</div>` : '';
8408
8515
  const diffHtml = it.ghRows
8409
8516
  ? saGhDiffRowsHtml(it.ghRows, it.diffTotal || 0, it.pr || '')
8410
- : hasGitDiff ? saGhDiffHtml(d.diff) : clientDiff ? saGhDiffHtml(clientDiff) : '';
8517
+ : hasGitDiff ? saGhDiffHtml(d.diff, it.pr) : clientDiff ? saGhDiffHtml(clientDiff, it.pr) : '';
8411
8518
  const changedBody = isConv ? '' : (changedDescHtml + filesListHtml + diffHtml);
8412
8519
  // ── sensitive-area note (real: classifyChangeRisk forced the pause) ──
8413
8520
  const risk = it.risk;
@@ -8419,31 +8526,50 @@ function saItemHtml(it, i){
8419
8526
  // below, offset-indented. The review Discussion folds in at the bottom of the body. The
8420
8527
  // transcript is the recorded build (activity); real goals fall back to "what we did".
8421
8528
  // Persisted open-state via data-detkey so SSE re-renders don't collapse it. ──
8529
+ // The "what we did" fallback is the same report as Result above, cut to 160 chars —
8530
+ // showing both makes the card repeat itself in a worse form (P1). Only fall back when
8531
+ // there is no Result to read.
8422
8532
  const txArr = (it.activity && it.activity.length)
8423
8533
  ? it.activity.map((e) => saTxRowHtml(e))
8424
- : (it.did?.length ? it.did.map((s) => saTxRowHtml(['claude', s])) : []);
8425
- const discArr = it.discussion || it.thread || null;
8426
- const threadHtml = (discArr && discArr.length)
8427
- ? 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('')
8428
- : '';
8534
+ : ((!reportBody && it.did?.length) ? it.did.map((s) => saTxRowHtml(['claude', s])) : []);
8535
+ // (Discussion was deleted here — Masa 2026-07-22. It read reviewSummary.discussion,
8536
+ // which parseReviewSummary never emits, so it never rendered on a real goal; replies
8537
+ // already live in the card's own reply box.)
8538
+ const threadHtml = '';
8539
+ // ── Activity = the implementation log (P1.5, Masa 2026-07-22): what the AI actually
8540
+ // did, read AFTER it finished. Fetched only when the section is opened — a task's log
8541
+ // runs to ~500KB and most cards are never expanded — so the closed state is one line
8542
+ // and the lines arrive on the first open (same "only when asked" shape as Open it).
8543
+ // Once fetched they live in SA.actLog so an SSE rebuild doesn't wipe them.
8544
+ const logLines = it.logTaskId != null ? SA.actLog[it.logTaskId] : null;
8545
+ const logHtml = Array.isArray(logLines)
8546
+ ? (logLines.length
8547
+ ? logLines.map((l) => saLogRowHtml(l)).join('')
8548
+ : '<div class="txr res"><span class="w"></span><span class="m">Nothing was logged for this one.</span></div>')
8549
+ : '<div class="txr res"><span class="w"></span><span class="m">Loading…</span></div>';
8429
8550
  const actOpen = SA.detOpen.has(`${gk}:act`) ? ' open' : '';
8430
8551
  const txFirst = txArr[0] || '<div class="txr"><span class="w"></span><span class="m">Activity</span></div>';
8431
8552
  const txRest = txArr.slice(1).join('');
8432
8553
  const discInner = threadHtml ? `<div class="rsub">Discussion</div><div class="rthread">${threadHtml}</div>` : '';
8433
8554
  const activitySec = (txArr.length || threadHtml)
8434
8555
  ? `<section class="rsec ract"><div class="rsec-h">Activity</div><details class="lacc" data-detkey="${gk}:act"${actOpen}><summary><i class="ltog">›</i><div class="rtx lfirst">${txFirst}</div></summary><div class="lbody">${txRest ? `<div class="rtx">${txRest}</div>` : ''}${discInner}</div></details></section>`
8435
- : '';
8556
+ : it.logTaskId != null
8557
+ ? `<section class="rsec ract"><div class="rsec-h">Activity</div><details class="lacc" data-detkey="${gk}:act" data-actlog="${it.logTaskId}"${actOpen}><summary><i class="ltog">›</i><div class="rtx lfirst">${saTxRowHtml(['user', it.goalText || it.title])}</div></summary><div class="lbody"><div class="rtx" data-actbody="${it.logTaskId}">${logHtml}</div>${discInner}</div></details></section>`
8558
+ : '';
8436
8559
  const S = (label, body) => body ? `<section class="rsec"><div class="rsec-h">${label}</div>${body}</section>` : '';
8437
8560
  return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
8438
8561
  <div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span><span class="rst"><span class="rdot"></span>In review</span></div>
8439
8562
  <h2 class="rtitle">${esc(it.title)}</h2>
8440
8563
  ${meta}
8441
8564
  ${reqSec}
8565
+ ${S('Open it', openBody)}
8566
+ ${S('Also carries', carriesBody)}
8442
8567
  ${S('What to check', checkBody)}
8443
- ${S('Answer', it.answer ? `<p class="rbody">${esc(it.answer)}</p>` : '')}
8568
+ ${S('Result', reportBody)}
8569
+ ${S('Screenshots', shotsBody)}
8570
+ ${S('Steps', stepsBody)}
8444
8571
  ${S('Proof', proofInner)}
8445
8572
  ${S('What changed', changedBody)}
8446
- ${S('Not done', it.risk?.summary ? `<p class="rbody rrisk">${esc(it.risk.summary)}</p>` : '')}
8447
8573
  ${riskHtml}
8448
8574
  ${activitySec}
8449
8575
  ${S('Workflow', saWorkflowHtml(it))}
@@ -8458,12 +8584,45 @@ function saScrollCardToTop(el){
8458
8584
  if (!body || !el) return;
8459
8585
  body.scrollTop += el.getBoundingClientRect().top - body.getBoundingClientRect().top;
8460
8586
  }
8587
+ // Fetch one task's frozen implementation log and paint it into the open card. Cached in
8588
+ // SA.actLog (including an empty result) so a card can be opened and closed without
8589
+ // asking twice. A failure writes an honest line rather than an empty box — the log
8590
+ // either exists or it doesn't, and we say which.
8591
+ async function saLoadActivityLog(taskId){
8592
+ if (!Number.isFinite(taskId) || SA.actLog[taskId] !== undefined) return;
8593
+ SA.actLog[taskId] = null; // in flight — keeps a second toggle from re-fetching
8594
+ let lines = [];
8595
+ try {
8596
+ const r = await fetch(withKey(`/api/tasks/${taskId}/activity`));
8597
+ lines = r.ok ? ((await r.json()).lines ?? []) : [];
8598
+ } catch { lines = []; }
8599
+ SA.actLog[taskId] = lines;
8600
+ const body = document.querySelector(`#seeall [data-actbody="${taskId}"]`);
8601
+ if (!body) return;
8602
+ body.innerHTML = lines.length
8603
+ ? lines.map((l) => saLogRowHtml(l)).join('')
8604
+ : '<div class="txr res"><span class="w"></span><span class="m">Nothing was logged for this one.</span></div>';
8605
+ }
8606
+ // After first paint, compute the red box for any card showing a before/after pair and
8607
+ // patch that card's node directly — a full re-render here would close every open
8608
+ // pulldown just to draw a box. Cached per goal (null included) so it runs once.
8609
+ async function saEnhanceShotBoxes(){
8610
+ const targets = SA.items.filter((it) => saShotPair(it.shots, it.run?.compare) && SA.shotBox[it.goalId] === undefined);
8611
+ await Promise.all(targets.map(async (it) => {
8612
+ const pair = saShotPair(it.shots, it.run?.compare);
8613
+ let html = null;
8614
+ try { html = await shotsBoxedHtml(pair.before, pair.after); } catch { html = null; }
8615
+ SA.shotBox[it.goalId] = html;
8616
+ if (!html || !SA.open) return;
8617
+ const host = document.querySelector(`#seeall [data-shotpair="${it.goalId}"]`);
8618
+ if (host) host.innerHTML = html;
8619
+ }));
8620
+ }
8461
8621
  function saRender(){
8462
8622
  const S = $('seeall'); if (!S) return;
8463
8623
  const pending = SA.items.filter((x) => x.st === 0);
8464
8624
  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`;
8625
+ const c = `${pending.length} ready · ${green === pending.length ? 'all green' : `${pending.length - green} failing`}`;
8467
8626
  const curIdx = pending.findIndex((x) => x.goalId === SA.cur);
8468
8627
  const posLabel = pending.length && curIdx >= 0 ? `${curIdx + 1} / ${pending.length}` : '';
8469
8628
  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>
@@ -8475,7 +8634,14 @@ function saRender(){
8475
8634
  det.addEventListener('toggle', () => {
8476
8635
  const k = det.dataset.detkey;
8477
8636
  if (det.open) SA.detOpen.add(k); else SA.detOpen.delete(k);
8637
+ // Opening Activity is what fetches the implementation log (Masa 2026-07-22:
8638
+ // "開いた時だけ取りに行く"). Closed cards cost nothing; a reopened one is served
8639
+ // from SA.actLog.
8640
+ if (det.open && det.dataset.actlog) saLoadActivityLog(Number(det.dataset.actlog));
8478
8641
  });
8642
+ // A card re-rendered while its Activity was already open (SSE tick) keeps the log
8643
+ // it had — the fetch below no-ops once SA.actLog holds the answer.
8644
+ if (det.open && det.dataset.actlog) saLoadActivityLog(Number(det.dataset.actlog));
8479
8645
  }
8480
8646
  // "ないものは載せない" (Masa): a Proof <img> whose file is missing/broken must never
8481
8647
  // show a broken-image placeholder. proofImg is only a stored filename, so a file that
@@ -8528,25 +8694,6 @@ async function saFetchDiffs(){
8528
8694
  }));
8529
8695
  if (SA.open) saSync();
8530
8696
  }
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
8697
  function saOpen(focusGoalId){
8551
8698
  SA.open = true;
8552
8699
  SA.items = saRows().map((r) => ({ ...r, st: 0 }));
@@ -8576,7 +8723,7 @@ function saOpen(focusGoalId){
8576
8723
  if (el) saScrollCardToTop(el);
8577
8724
  });
8578
8725
  saFetchDiffs();
8579
- saEnhanceDiffBoxes();
8726
+ saEnhanceShotBoxes();
8580
8727
  }
8581
8728
  function saClose(){ SA.open = false; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
8582
8729
  // Continuous review (Masa 2026-07-08): move a persistent "current" card with
@@ -8635,10 +8782,32 @@ $('seeall').addEventListener('click', async (e) => {
8635
8782
  const it = SA.items[i];
8636
8783
  if (k === 'close') return saClose();
8637
8784
  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.
8785
+ // meta-row toggle (H23): tap the token chip to reveal the technique breakdown.
8640
8786
  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; }
8787
+ // Open it (P1, Masa 2026-07-21): start the thing the worker said to run, then open it.
8788
+ // Booting a real process takes seconds, so the chip says "Starting…" while it waits —
8789
+ // and a failure lands in the detail line as a plain reason. It never turns the goal
8790
+ // red: failing to boot a preview says nothing about whether the work itself is good.
8791
+ if (k === 'preview') {
8792
+ if (!it.run?.taskId) return;
8793
+ const sec = $('seeall').querySelector(`#runlocal${i}`);
8794
+ const link = $('seeall').querySelector(`#prevlink${i}`);
8795
+ const v = sec?.querySelector('.v');
8796
+ if (link) link.disabled = true;
8797
+ if (v) v.textContent = 'Starting it…';
8798
+ let r = null, body = {};
8799
+ try { r = await fetch(`/api/tasks/${it.run.taskId}/preview`, { method: 'POST' }); body = await r.json().catch(() => ({})); }
8800
+ catch (e) { body = { error: String(e.message ?? e) }; }
8801
+ if (link) link.disabled = false;
8802
+ const noteText = it.run.note || it.run.cmd || 'Starts when you press it.';
8803
+ // A tab opened: that IS the feedback. Put the worker's own note back rather than
8804
+ // narrating what the reviewer just watched happen.
8805
+ if (r?.ok && body.url) { window.open(body.url, '_blank', 'noopener'); if (v) v.textContent = noteText; return; }
8806
+ // Started, but the worker declared no URL: its own note is where it said to look.
8807
+ if (r?.ok) { if (v) v.textContent = it.run.note || it.run.cmd || 'Started — nothing to open.'; return; }
8808
+ if (v) v.textContent = `Could not start it: ${body.error || `HTTP ${r?.status ?? 'error'}`}`;
8809
+ return;
8810
+ }
8642
8811
  // Verify chip (no external recording URL) → scroll the in-card video player into view.
8643
8812
  if (k === 'rec') { $('seeall').querySelector(`.sa-item[data-gid="${it.goalId}"] .rvid`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; }
8644
8813
  // every verdict auto-advances the focus to the next pending card (tempo).