@galda/cli 0.10.34 → 0.10.35
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 +430 -101
- package/bin/manager-for-ai.mjs +12 -2
- package/engine/lib.mjs +103 -1
- package/engine/relay-supervisor.mjs +113 -0
- package/engine/server.mjs +151 -37
- package/engine/verify.mjs +9 -2
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -452,6 +452,18 @@
|
|
|
452
452
|
.vchip.none{color:var(--ink3);border-color:var(--hair2)}
|
|
453
453
|
.vchip.human{color:var(--amber);border-color:rgba(var(--amber-rgb),.5);font-weight:700}
|
|
454
454
|
.vchip.human.high{color:var(--danger);border-color:rgba(var(--danger-rgb),.5)}
|
|
455
|
+
/* H23 (2026-07-18) evidence-first review card: a fact stops looking like a button —
|
|
456
|
+
glyph carries the state colour, text stays plain ink, no border/pill (border is
|
|
457
|
+
reserved for the PR link, the card's only clickable pill). Scoped to .lg-ev so it
|
|
458
|
+
only affects the Ledger review card; other .vchip call sites are unchanged/out of
|
|
459
|
+
scope for this pass. Ported from flagship.html .lg-ev .vf (~L401-407). */
|
|
460
|
+
.lg-ev .vf{display:inline-flex;align-items:center;gap:5px;color:var(--ink2)}
|
|
461
|
+
.lg-ev .vf .g{font-style:normal}
|
|
462
|
+
.lg-ev .vf.ok .g{color:var(--green)}
|
|
463
|
+
.lg-ev .vf.no .g{color:var(--danger)}
|
|
464
|
+
.lg-ev .vf.none{color:var(--ink3)}
|
|
465
|
+
.lg-ev .vf.human .g{color:var(--amber)}
|
|
466
|
+
.lg-ev .vf.human.high .g{color:var(--danger)}
|
|
455
467
|
.pbadge{font-family:var(--mono);font-size:9.5px;padding:1px 6px;border-radius:5px;margin-right:6px;color:var(--iri);border:1px solid rgba(var(--blue-rgb),.35)}
|
|
456
468
|
.blockreason{color:var(--amber)}
|
|
457
469
|
.rvcheck2{font-size:15px;font-weight:600;color:var(--ink);line-height:1.5;margin-bottom:4px}
|
|
@@ -829,6 +841,14 @@
|
|
|
829
841
|
.beforeafter{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
|
830
842
|
.ba-col{min-width:0}
|
|
831
843
|
.balabel{display:block;font-family:var(--mono);font-size:9px;letter-spacing:.06em;color:var(--ink3);margin-bottom:2px}
|
|
844
|
+
/* diff box + zoomed inset (HANDOFF-review-card-diffbox-2026-07-18) — only emitted
|
|
845
|
+
by beforeAfterBoxedHtml(), so these never touch the plain pair above or the
|
|
846
|
+
older per-task renderReviewChecklist before/after (out of scope, untouched). */
|
|
847
|
+
.ba-imgwrap{position:relative;margin-top:8px}
|
|
848
|
+
.ba-imgwrap .proofimg{width:100%;height:auto;max-height:none;object-fit:initial;margin-top:0;display:block}
|
|
849
|
+
.ba-box{position:absolute;border:1.5px solid var(--danger);border-radius:2px;pointer-events:none}
|
|
850
|
+
.ba-zoomhd{margin-top:14px}
|
|
851
|
+
.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)}
|
|
832
852
|
.reqbtns{display:flex;gap:8px;margin-top:4px}
|
|
833
853
|
.reqbtn{height:30px;padding:0 14px;border-radius:9px;border:1px solid var(--hair2);background:transparent;
|
|
834
854
|
color:var(--ink2);font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;transition:.15s}
|
|
@@ -1781,40 +1801,95 @@
|
|
|
1781
1801
|
.sa-ic{width:30px;height:30px;border:0;border-radius:8px;background:transparent;color:var(--ink3);display:grid;place-items:center;cursor:pointer;flex:0 0 30px}
|
|
1782
1802
|
.sa-ic:hover{color:var(--ink);background:transparent} /* icon hover = glyph colour only (§5.5) */
|
|
1783
1803
|
.sa-ic svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
|
1784
|
-
/* ──
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1804
|
+
/* ── Review card, evidence-first (H23 2026-07-18 full spec, superseding H22/H06):
|
|
1805
|
+
Evidence sits right under the title/meta row, Context is a closed-by-default
|
|
1806
|
+
<details> with a one-line quote summary, and Evidence itself branches by task
|
|
1807
|
+
kind (image/logic/docs/missing). Context / Done when / Evidence all sit on ONE
|
|
1808
|
+
flush surface now — hairlines only, no more Evidence-only canvas box (§5.5 "面の
|
|
1809
|
+
処理はパネル全体に均一"). Ported from docs/design/flagship/flagship.html
|
|
1810
|
+
(~L340-434), flagship --red/--green-bg substituted for the app's real tokens
|
|
1811
|
+
(--danger, rgba(var(--green-rgb),.x)). Old Plan/Review-thread/Changed-files&diff
|
|
1812
|
+
folds (.lg-det/.lg-plan/.lg-thread/.lg-diffwrap) are retired in favour of the
|
|
1813
|
+
What-we-did/What-to-look-at bullet lists inside Context. */
|
|
1814
|
+
.lg-hd{display:flex;align-items:baseline;gap:10px;margin-bottom:2px}
|
|
1789
1815
|
.lg-hd .no{flex:0 0 auto;font:600 11px/1 var(--mono);color:var(--ink3);font-variant-numeric:tabular-nums}
|
|
1790
1816
|
.lg-hd .t{flex:1;font:600 15px/1.3 var(--ui);letter-spacing:-.014em}
|
|
1791
1817
|
.lg-state{flex:0 0 auto;font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase} /* state = colour text only (§5.5 no badge) */
|
|
1792
1818
|
.lg-state.review{color:var(--blue)}
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
.lg-
|
|
1819
|
+
/* meta row: PR link / token savings (real tap-to-open toggle) / Run locally — a
|
|
1820
|
+
compact row directly under the title (H23 §1, pattern 1 won over a plain text
|
|
1821
|
+
line and title-row-right-aligned chips). */
|
|
1822
|
+
.lg-meta{display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin:9px 0 13px}
|
|
1823
|
+
.lg-meta .none{font:500 10.5px/1.5 var(--mono);color:var(--ink3);font-style:italic}
|
|
1824
|
+
.sa-prlink.sm{font-size:9.5px;padding:4px 7px}
|
|
1825
|
+
.sa-runlocal{display:inline-flex;align-items:center;gap:5px;font:600 10px/1 var(--mono);letter-spacing:.03em;
|
|
1826
|
+
color:var(--blue);background:transparent;border:1px solid color-mix(in srgb,var(--blue) 35%,transparent);
|
|
1827
|
+
border-radius:7px;padding:5px 9px 5px 8px;cursor:pointer}
|
|
1828
|
+
.sa-runlocal:hover{border-color:var(--blue)}
|
|
1829
|
+
.sa-runlocal svg{width:11px;height:11px;fill:currentColor;stroke:none}
|
|
1830
|
+
.tokchip{display:inline-flex;align-items:center;gap:4px;font:600 9.5px/1 var(--mono);letter-spacing:.02em;color:var(--green);
|
|
1831
|
+
background:rgba(var(--green-rgb),.12);border:0;border-radius:7px;padding:5px 9px;box-shadow:inset 0 0 0 1px rgba(var(--green-rgb),.22);
|
|
1832
|
+
cursor:pointer}
|
|
1833
|
+
.tokchip:hover{box-shadow:inset 0 0 0 1px var(--green)}
|
|
1834
|
+
.tokchevron{font-style:normal;font-size:11px;line-height:1;transition:transform .2s;opacity:.75}
|
|
1835
|
+
.tokchip.open .tokchevron{transform:rotate(90deg)}
|
|
1836
|
+
.tokdetail{display:none;margin:0 0 13px;padding:9px 11px;background:var(--canvas);border-radius:8px}
|
|
1837
|
+
.tokdetail.open{display:block}
|
|
1838
|
+
.tokdetail .k{font:600 8px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ink3);margin-bottom:5px;display:block}
|
|
1839
|
+
.tokdetail .v{font:500 11px/1.6 var(--mono);color:var(--ink2)}
|
|
1840
|
+
/* Context: closed by default, one-line quote summary, chevron large + far right (H23 §2) */
|
|
1841
|
+
.lg-ctx{display:flex;flex-direction:column;gap:10px;margin-bottom:14px;font-size:12.5px;line-height:1.5}
|
|
1842
|
+
.lg-fact{display:grid;grid-template-columns:66px 1fr;gap:7px 12px}
|
|
1843
|
+
.lg-fact .k{font:600 8.5px/1.8 var(--mono);letter-spacing:.1em;color:var(--ink3);text-transform:uppercase}
|
|
1844
|
+
.lg-fact .v{color:var(--ink)}
|
|
1845
|
+
.lg-goal{border:0}
|
|
1846
|
+
.lg-goal>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:6px;color:var(--ink3);min-width:0}
|
|
1847
|
+
.lg-goal>summary::-webkit-details-marker{display:none}
|
|
1848
|
+
.lg-goal>summary .k{font:600 8.5px/1.8 var(--mono);letter-spacing:.1em;color:var(--ink3);text-transform:uppercase;flex:0 0 auto}
|
|
1849
|
+
.lg-goal>summary .v{color:var(--ink);font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto}
|
|
1850
|
+
.lg-goal>summary .gtoggle{font-style:normal;font-size:19px;line-height:1;transition:transform .2s;flex:0 0 auto;
|
|
1851
|
+
margin-left:auto;color:var(--ink3);padding-left:10px}
|
|
1852
|
+
.lg-goal:hover>summary{color:var(--ink2)}
|
|
1853
|
+
.lg-goal:hover>summary .gtoggle{color:var(--ink)}
|
|
1854
|
+
.lg-goal[open]>summary .gtoggle{transform:rotate(90deg)}
|
|
1855
|
+
.lg-context{margin-top:10px;padding-top:10px;border-top:1px solid var(--hair)}
|
|
1856
|
+
/* fanned-out goal: list every sibling task/goal so a reviewer never wonders whether
|
|
1857
|
+
the AI silently dropped part of a long ask (H23 §2/§4). */
|
|
1858
|
+
.lg-splitnote{background:var(--canvas);border-radius:8px;padding:10px 12px;margin-bottom:13px}
|
|
1859
|
+
.lg-splitnote .hd{font:600 8.5px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);margin-bottom:7px}
|
|
1860
|
+
.lg-splitnote ul{margin:0;padding:0;list-style:none;font-size:11.5px;line-height:1.8;color:var(--ink3)}
|
|
1861
|
+
.lg-splitnote li.cur{color:var(--ink)}
|
|
1862
|
+
.lg-splitnote li.cur b{color:var(--green);font-weight:600}
|
|
1863
|
+
.lg-notfound{font-size:11.5px;line-height:1.65;color:var(--ink3);font-style:italic;margin-bottom:13px}
|
|
1864
|
+
.lg-bullethd{font:600 8px/1 var(--mono);letter-spacing:.1em;color:var(--ink3);text-transform:uppercase;margin:13px 0 6px}
|
|
1865
|
+
.lg-bullets{margin:0;padding:0 0 0 16px;font-size:12px;line-height:1.65;color:var(--ink2)}
|
|
1866
|
+
.lg-bullets li{margin-bottom:5px}
|
|
1867
|
+
/* Evidence: same flush surface as Context/Done when — hairline only, no boxed canvas
|
|
1868
|
+
(H23 §5.5 correction: "コンテキストとダウンウェインとエビデンスが全部違うレイヤー") */
|
|
1869
|
+
.lg-ev{border-top:1px solid var(--hair);padding-top:13px;margin-top:2px}
|
|
1797
1870
|
.lg-ev .lg-evhd{font:600 8.5px/1 var(--mono);letter-spacing:.13em;color:var(--ink3);text-transform:uppercase;margin-bottom:10px}
|
|
1798
1871
|
.lg-ev .row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 15px;font:500 11.5px/1.2 var(--mono);font-variant-numeric:tabular-nums}
|
|
1799
1872
|
.lg-ev .a{color:var(--green);font-style:normal} .lg-ev .d{color:var(--danger);font-style:normal}
|
|
1800
1873
|
.lg-ev .fls{color:var(--ink3)}
|
|
1801
1874
|
.lg-ev img{display:block;width:100%;max-height:190px;object-fit:cover;object-position:top;border-radius:9px;margin-top:11px;cursor:zoom-in}
|
|
1802
|
-
/*
|
|
1803
|
-
.lg-
|
|
1804
|
-
.lg-
|
|
1805
|
-
.lg-
|
|
1806
|
-
.lg-
|
|
1807
|
-
|
|
1808
|
-
.lg-
|
|
1809
|
-
|
|
1810
|
-
.lg-
|
|
1811
|
-
.lg-
|
|
1812
|
-
.lg-
|
|
1813
|
-
.lg-
|
|
1814
|
-
.lg-
|
|
1815
|
-
|
|
1816
|
-
.lg-
|
|
1817
|
-
|
|
1875
|
+
/* logic-only hero: no UI to show, so the number IS the evidence */
|
|
1876
|
+
.lg-herostat{display:flex;align-items:baseline;gap:10px;margin:4px 0 12px}
|
|
1877
|
+
.lg-herostat .n{font:700 30px/1 var(--mono);color:var(--green);font-variant-numeric:tabular-nums}
|
|
1878
|
+
.lg-herostat.fail .n{color:var(--danger)}
|
|
1879
|
+
.lg-herostat .l{font:500 11px/1.4 var(--ui);color:var(--ink3)}
|
|
1880
|
+
/* docs-only hero: GitHub-style diff — added lines get a green band + "+" gutter */
|
|
1881
|
+
.lg-diffblock{background:var(--canvas);border-radius:7px;padding:7px 0;font:400 11.5px/1.8 var(--mono);
|
|
1882
|
+
color:var(--ink2);margin-top:6px;overflow-x:auto}
|
|
1883
|
+
.lg-diffblock .ln{display:flex;padding:0 11px}
|
|
1884
|
+
.lg-diffblock .ln.a{background:rgba(var(--green-rgb),.14)}
|
|
1885
|
+
.lg-diffblock .gutter{flex:0 0 14px;color:var(--green);font-weight:700;user-select:none}
|
|
1886
|
+
.lg-diffblock .ln:not(.a) .gutter{color:transparent}
|
|
1887
|
+
.lg-diffblock .ln.a .txt{color:var(--green)}
|
|
1888
|
+
/* missing-capture hero: an honest small amber note, never a fabricated image */
|
|
1889
|
+
.lg-missinghd{display:flex;align-items:center;gap:8px;font:600 9px/1 var(--mono);letter-spacing:.1em;
|
|
1890
|
+
text-transform:uppercase;color:var(--amber);margin-bottom:9px}
|
|
1891
|
+
.lg-missinghd svg{width:14px;height:14px;flex:0 0 14px;fill:none;stroke:currentColor;stroke-width:2}
|
|
1892
|
+
.lg-manualnote{font-size:12px;line-height:1.7;color:var(--ink2);background:var(--canvas);border-radius:8px;padding:11px 13px}
|
|
1818
1893
|
/* risk/conflict = small inline amber, never a filled panel (§1.5-8 don't paint the surface) */
|
|
1819
1894
|
.lg-risk{display:flex;align-items:center;gap:8px;color:var(--amber);font-size:12px;line-height:1.4;padding:9px 2px;border-top:1px solid var(--hair)}
|
|
1820
1895
|
.lg-risk svg{width:14px;height:14px;flex:0 0 14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
@@ -1847,6 +1922,7 @@
|
|
|
1847
1922
|
/* To Do timeline: order via tone + labels, no axis line (§1.5-4) */
|
|
1848
1923
|
#fsRoot .tl{position:relative}
|
|
1849
1924
|
#fsRoot .tlcap{display:block;font-family:var(--mono);font-size:8.5px;letter-spacing:.1em;color:var(--ink3);text-transform:uppercase;padding:13px 0 5px 0} /* left 8->0: sit the DOING/Next up/Later labels on the same spine as the section headings (Masa 2026-07-18) */
|
|
1925
|
+
#fsRoot .tlcap.needs{color:var(--amber)} /* "Needs you" caption tinted to the attention accent, matching the board's Needs-you column (Masa 2026-07-19). Colour on the label only — the rows below carry their own in-row colour per §1.5-8. */
|
|
1850
1926
|
#fsRoot .trow{position:relative;padding:6px 6px 6px 0;border-radius:8px;font-size:12.5px;color:var(--ink);display:flex;align-items:center;gap:9px} /* left 8->0: row dot lines up with the Review/To Do headings' left edge, not 8px inside it (Masa 2026-07-18) */
|
|
1851
1927
|
#fsRoot .trow .tt{color:color-mix(in srgb,var(--ink) 84%,var(--panel))} /* §13.2 soften To Do row name (not pure black); left chat feed keeps --ink */
|
|
1852
1928
|
#fsRoot .trow:hover{background:var(--hover-s)}
|
|
@@ -3231,6 +3307,40 @@ function buildReviewDigest({ goals, tasks } = {}){
|
|
|
3231
3307
|
}
|
|
3232
3308
|
return rows;
|
|
3233
3309
|
}
|
|
3310
|
+
// Review title: mirrors the label convention used throughout (reviewSummary.changed
|
|
3311
|
+
// → plan[0] → text) — pulled into one place so the review card's own title and its
|
|
3312
|
+
// sibling-goal titles (H23 split note) stay consistent.
|
|
3313
|
+
function saReviewTitle(g){
|
|
3314
|
+
return String(g?.reviewSummary?.changed || g?.plan?.[0] || g?.text || '').replace(/\s+/g, ' ').trim().slice(0, 90);
|
|
3315
|
+
}
|
|
3316
|
+
// H23 (2026-07-18) evidence-kind detection. Mirrors engine/lib.mjs isUiChange() /
|
|
3317
|
+
// hasTestRelevantChanges() — kept in sync manually since the browser script isn't a
|
|
3318
|
+
// module. flagship's demo data carries an explicit `kind` field
|
|
3319
|
+
// ('image'|'logic'|'docs'|'missing'); real goals/tasks have no such field, so the
|
|
3320
|
+
// review card derives one from real signals it already has (a captured proof image,
|
|
3321
|
+
// whether changed files look like UI source, whether they're all docs-only). This
|
|
3322
|
+
// is a HEURISTIC, not a stored fact on the goal — flagged as a judgment call in the
|
|
3323
|
+
// H23 implementation PR for a human to correct if it misclassifies real data.
|
|
3324
|
+
const UI_TOUCH_RE_LOCAL = /^app\/|\.(?:html|css|tsx|jsx|vue|svelte)$/i;
|
|
3325
|
+
function isUiChangeLocal(files){ return (files ?? []).some((f) => UI_TOUCH_RE_LOCAL.test(String(f ?? ''))); }
|
|
3326
|
+
const DOC_ONLY_RE_LOCAL = /(^|\/)(README|START-HERE|CHANGELOG|LICENSE)(\.[\w.-]+)?$|(^|\/)docs\/.*\.(md|mdx|txt)$|\.mdx?$|\.txt$/i;
|
|
3327
|
+
function hasTestRelevantChangesLocal(files){
|
|
3328
|
+
const fs = (files ?? []).map((f) => String(f ?? '').trim()).filter(Boolean);
|
|
3329
|
+
if (!fs.length) return true;
|
|
3330
|
+
return fs.some((f) => !DOC_ONLY_RE_LOCAL.test(f));
|
|
3331
|
+
}
|
|
3332
|
+
// Priority: image (a real capture exists) > missing (UI touched, should have a
|
|
3333
|
+
// capture per PRD §4(C) but doesn't) > docs (every changed file is docs-only) >
|
|
3334
|
+
// logic (verified only by tests, no UI). Falls back to 'image' (today's generic
|
|
3335
|
+
// fact-row + no image) when no signal is conclusive — never claims a stronger kind
|
|
3336
|
+
// than the data actually supports.
|
|
3337
|
+
function saEvidenceKind(filePaths, hasProofImg, testResult){
|
|
3338
|
+
if (hasProofImg) return 'image';
|
|
3339
|
+
if (isUiChangeLocal(filePaths)) return 'missing';
|
|
3340
|
+
if (filePaths.length && !hasTestRelevantChangesLocal(filePaths)) return 'docs';
|
|
3341
|
+
if (testResult?.ran) return 'logic';
|
|
3342
|
+
return 'image';
|
|
3343
|
+
}
|
|
3234
3344
|
// Mirrors engine/lib.mjs buildReviewSummary()/requirementSummaryLine() (task 96)
|
|
3235
3345
|
// — kept in sync manually since the browser script isn't a module. Used by both
|
|
3236
3346
|
// the review checklist top summary and the chat-side review导线.
|
|
@@ -3435,6 +3545,92 @@ function beforeAfterMediaHtml(taskId, proof, altLabel){
|
|
|
3435
3545
|
<div class="ba-col"><span class="balabel">After</span>${proofMediaHtml(taskId, proof, altLabel)}</div>
|
|
3436
3546
|
</div>`;
|
|
3437
3547
|
}
|
|
3548
|
+
// Diff box + zoomed inset (HANDOFF-review-card-diffbox-2026-07-18): on top of the
|
|
3549
|
+
// plain before/after pair above, isolate the region that actually changed and draw
|
|
3550
|
+
// a red box over it on both images, plus a cropped/enlarged row below. Computed
|
|
3551
|
+
// client-side from the two PNGs via <canvas> pixel comparison at render time — not
|
|
3552
|
+
// baked in at capture time — so it works retroactively on every proof pair already
|
|
3553
|
+
// on disk with no new server-side image dependency (see the handoff for the full
|
|
3554
|
+
// rationale). Reference algorithm, verified against a real before/after pair
|
|
3555
|
+
// (composer icon swap, 640x200): correctly isolated a 57x52px box centered on the
|
|
3556
|
+
// changed icon — 0.13% image coverage, no false positives elsewhere in the frame.
|
|
3557
|
+
function computeDiffBox(beforeImg, afterImg, { threshold = 24, minCoverage = 0.0005, maxCoverage = 0.7, pad = 0.03 } = {}) {
|
|
3558
|
+
const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
|
|
3559
|
+
if (w !== afterImg.naturalWidth || h !== afterImg.naturalHeight) return null; // different-shaped images: don't guess
|
|
3560
|
+
const c1 = document.createElement('canvas'); c1.width = w; c1.height = h;
|
|
3561
|
+
const c2 = document.createElement('canvas'); c2.width = w; c2.height = h;
|
|
3562
|
+
c1.getContext('2d').drawImage(beforeImg, 0, 0, w, h);
|
|
3563
|
+
c2.getContext('2d').drawImage(afterImg, 0, 0, w, h);
|
|
3564
|
+
const d1 = c1.getContext('2d').getImageData(0, 0, w, h).data;
|
|
3565
|
+
const d2 = c2.getContext('2d').getImageData(0, 0, w, h).data;
|
|
3566
|
+
let minX = w, minY = h, maxX = -1, maxY = -1, diffPx = 0;
|
|
3567
|
+
for (let y = 0; y < h; y++) {
|
|
3568
|
+
for (let x = 0; x < w; x++) {
|
|
3569
|
+
const i = (y * w + x) * 4;
|
|
3570
|
+
const d = Math.abs(d1[i] - d2[i]) + Math.abs(d1[i + 1] - d2[i + 1]) + Math.abs(d1[i + 2] - d2[i + 2]);
|
|
3571
|
+
if (d > threshold) {
|
|
3572
|
+
diffPx++;
|
|
3573
|
+
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
|
3574
|
+
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
const total = w * h;
|
|
3579
|
+
if (maxX < 0 || diffPx / total < minCoverage) return null; // no real difference — don't box a no-op
|
|
3580
|
+
if (diffPx / total > maxCoverage) return null; // diff too diffuse to be "a spot" — a full redesign shouldn't get a red box either
|
|
3581
|
+
const padPx = Math.round(Math.max(w, h) * pad);
|
|
3582
|
+
minX = Math.max(0, minX - padPx); minY = Math.max(0, minY - padPx);
|
|
3583
|
+
maxX = Math.min(w, maxX + padPx); maxY = Math.min(h, maxY + padPx);
|
|
3584
|
+
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
3585
|
+
}
|
|
3586
|
+
function baLoadImage(src){
|
|
3587
|
+
return new Promise((resolve, reject) => {
|
|
3588
|
+
const im = new Image();
|
|
3589
|
+
im.onload = () => resolve(im);
|
|
3590
|
+
im.onerror = () => reject(new Error(`image failed to load: ${src}`));
|
|
3591
|
+
im.src = src;
|
|
3592
|
+
});
|
|
3593
|
+
}
|
|
3594
|
+
// Crop {x,y,w,h} out of a decoded image and scale it up into a small inset — same
|
|
3595
|
+
// aspect ratio as the box, never stretched. Magnification is capped at 6x so a
|
|
3596
|
+
// near-single-pixel box doesn't blow up into mush.
|
|
3597
|
+
function baZoomDataUrl(img, box, maxW = 420, maxH = 200){
|
|
3598
|
+
const scale = Math.min(maxW / box.w, maxH / box.h, 6);
|
|
3599
|
+
const outW = Math.max(1, Math.round(box.w * scale));
|
|
3600
|
+
const outH = Math.max(1, Math.round(box.h * scale));
|
|
3601
|
+
const c = document.createElement('canvas'); c.width = outW; c.height = outH;
|
|
3602
|
+
c.getContext('2d').drawImage(img, box.x, box.y, box.w, box.h, 0, 0, outW, outH);
|
|
3603
|
+
return c.toDataURL('image/png');
|
|
3604
|
+
}
|
|
3605
|
+
// Async: loads the before/after PNGs, runs computeDiffBox(), and — only when a real
|
|
3606
|
+
// box is found — renders the before/after pair with a red box overlay on each image
|
|
3607
|
+
// (percent-positioned so it tracks the image's displayed size at any viewport width)
|
|
3608
|
+
// plus a "Zoomed on the boxed area" row below. Returns null on every fallback
|
|
3609
|
+
// condition (no PNG pair, load failure, no clean box) so the caller keeps showing
|
|
3610
|
+
// the plain beforeAfterMediaHtml() pair unchanged — never a guessed or empty box
|
|
3611
|
+
// (HANDOFF-review-card-diffbox-2026-07-18). Only kind==='image' Ledger evidence
|
|
3612
|
+
// (saEvidenceHtml) calls this; the older per-task renderReviewChecklist before/after
|
|
3613
|
+
// is explicitly out of scope for this handoff and stays on beforeAfterMediaHtml.
|
|
3614
|
+
async function beforeAfterBoxedHtml(taskId, proof, altLabel){
|
|
3615
|
+
const beforeSrc = proof?.beforePng, afterSrc = proof?.png;
|
|
3616
|
+
if (!beforeSrc || !afterSrc) return null; // the box needs two real stills, not a GIF frame
|
|
3617
|
+
let beforeImg, afterImg;
|
|
3618
|
+
try {
|
|
3619
|
+
[beforeImg, afterImg] = await Promise.all([
|
|
3620
|
+
baLoadImage(withKey(`/proof/${taskId}/${beforeSrc}`)),
|
|
3621
|
+
baLoadImage(withKey(`/proof/${taskId}/${afterSrc}`)),
|
|
3622
|
+
]);
|
|
3623
|
+
} catch { return null; } // a missing/broken file is a fallback, not a crash
|
|
3624
|
+
const box = computeDiffBox(beforeImg, afterImg);
|
|
3625
|
+
if (!box) return null;
|
|
3626
|
+
const w = beforeImg.naturalWidth, h = beforeImg.naturalHeight;
|
|
3627
|
+
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)}%`;
|
|
3628
|
+
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>`;
|
|
3629
|
+
const zoomCol = (dataUrl, label) => `<div class="ba-col"><img class="ba-zoomimg" src="${dataUrl}" alt="zoomed ${label.toLowerCase()}: ${altLabel}"></div>`;
|
|
3630
|
+
return `<div class="beforeafter">${frame(beforeImg, 'Before')}${frame(afterImg, 'After')}</div>
|
|
3631
|
+
<div class="lg-evhd ba-zoomhd">Zoomed on the boxed area</div>
|
|
3632
|
+
<div class="beforeafter ba-zoomrow">${zoomCol(baZoomDataUrl(beforeImg, box), 'before')}${zoomCol(baZoomDataUrl(afterImg, box), 'after')}</div>`;
|
|
3633
|
+
}
|
|
3438
3634
|
// swap the still for the real video only on demand, one at a time
|
|
3439
3635
|
document.addEventListener('click', (e) => {
|
|
3440
3636
|
const w = e.target.closest('.proofwrap');
|
|
@@ -5554,19 +5750,27 @@ function renderFsLane(){
|
|
|
5554
5750
|
renderFsReview(); renderFsTodo();
|
|
5555
5751
|
if (fsUI.todoView === 'board') renderFsBoard();
|
|
5556
5752
|
}
|
|
5557
|
-
// Which board column a GOAL belongs in. Follows goalChip()'s rule so the board and
|
|
5558
|
-
//
|
|
5559
|
-
//
|
|
5560
|
-
|
|
5753
|
+
// Which board column a GOAL belongs in. Follows goalChip()'s rule so the board and the
|
|
5754
|
+
// status chips can never drift; the two columns the chips have no word for (Later / Needs
|
|
5755
|
+
// you) are spelled out here. Takes the goal's TASKS so it can tell a genuinely in-flight
|
|
5756
|
+
// goal from one whose status got stuck 'running' after every task already finished — that
|
|
5757
|
+
// stale case is what piled old goals into Doing (Masa 2026-07-19). Pure: goal + tasks in.
|
|
5758
|
+
function fsBoardBucket(goal, tasks){
|
|
5561
5759
|
const s = goal?.status;
|
|
5562
5760
|
if (s === 'pending') return 'later'; // shelved by the composer's "Later"
|
|
5563
5761
|
if (s === 'needsInput') return 'needs'; // a clarifying question is waiting on the user
|
|
5564
5762
|
if (['blocked', 'failed', 'partial', 'interrupted'].includes(s)) return 'needs'; // stopped/errored — needs YOUR action (retry), like the flagship board which puts .drow.err in Needs you. Not "Doing" — Doing is only what's actively moving (Masa 2026-07-18)
|
|
5565
5763
|
if (s === 'review') return 'review';
|
|
5566
|
-
if (s === 'done' || s === 'reverted') return 'done';
|
|
5764
|
+
if (s === 'done' || s === 'reverted' || s === 'skipped') return 'done'; // terminal; skipped is finished, not in flight (it used to fall through to the doing default)
|
|
5567
5765
|
if (s === 'stacked' || s === 'sending') return 'todo';
|
|
5568
|
-
if (s === 'running' && !goal
|
|
5569
|
-
|
|
5766
|
+
if (s === 'running' && !goal?.startedAt) return 'todo'; // goalChip: planned, nothing started yet
|
|
5767
|
+
// Otherwise the goal is in flight (planning / retesting / running-and-started). BUT a
|
|
5768
|
+
// goal whose status stuck 'running' after every task already finished is NOT moving —
|
|
5769
|
+
// that is exactly what piled stale goals into Doing (Masa 2026-07-19, "Doing に11件").
|
|
5770
|
+
// If it has tasks and they're all terminal, it belongs in Done, not Doing.
|
|
5771
|
+
const mine = (tasks ?? []).filter((t) => t.goalId === goal?.id && !t.reply);
|
|
5772
|
+
if (mine.length && mine.every((t) => ['done', 'skipped'].includes(t.status))) return 'done';
|
|
5773
|
+
return 'doing'; // planning / retesting / actively in flight
|
|
5570
5774
|
}
|
|
5571
5775
|
// Kanban board — GOAL-level: one card per goal, never per task.
|
|
5572
5776
|
//
|
|
@@ -5601,7 +5805,7 @@ function renderFsBoard(){
|
|
|
5601
5805
|
// the goal is moving) — a coloured word (Failed/Interrupted) says it instead.
|
|
5602
5806
|
const goalTasks = (g) => list.filter((t) => t.goalId === g.id && !t.reply);
|
|
5603
5807
|
const goalCard = (g) => {
|
|
5604
|
-
const bkt = fsBoardBucket(g);
|
|
5808
|
+
const bkt = fsBoardBucket(g, list);
|
|
5605
5809
|
// errored is status-based now (blocked/failed/partial/interrupted live in the Needs-you column,
|
|
5606
5810
|
// not Doing). Doing holds only what's actively moving, so bkt==='doing' ⇒ healthy in flight.
|
|
5607
5811
|
const errored = ['blocked', 'failed', 'partial', 'interrupted'].includes(g.status);
|
|
@@ -5636,7 +5840,7 @@ function renderFsBoard(){
|
|
|
5636
5840
|
};
|
|
5637
5841
|
const no = (g) => goalNo.get(g.id) ?? Infinity;
|
|
5638
5842
|
const bucket = { todo: [], doing: [], needs: [], review: [], done: [], later: [] };
|
|
5639
|
-
for (const g of pgoals) bucket[fsBoardBucket(g)].push(g);
|
|
5843
|
+
for (const g of pgoals) bucket[fsBoardBucket(g, list)].push(g);
|
|
5640
5844
|
for (const k of Object.keys(bucket)) bucket[k].sort((a, b) => k === 'done' ? no(b) - no(a) : no(a) - no(b));
|
|
5641
5845
|
// To Do / Doing / Needs you / Review / Done / Later — the original design's columns
|
|
5642
5846
|
// (Masa 2026-07-16). "Needs you" = a clarifying question is waiting on the user —
|
|
@@ -5840,7 +6044,7 @@ function fsBucketTodo(){
|
|
|
5840
6044
|
// error as a small in-row colour element (§1.5-8), quiet Done fold.
|
|
5841
6045
|
function renderFsTodo(){
|
|
5842
6046
|
const el = $('fsTodoSec'); if (!el) return;
|
|
5843
|
-
const { pgoals, list, running, failed, blocked, queued, doneAll, later } = fsBucketTodo();
|
|
6047
|
+
const { pgoals, list, running, failed, blocked, needsInput, queued, doneAll, later } = fsBucketTodo();
|
|
5844
6048
|
const tt = (s, n) => esc(String(s ?? '').replace(/\s+/g, ' ').slice(0, n ?? 70));
|
|
5845
6049
|
// LATER = deliberately shelved goals (composer "Later" send → server status 'pending';
|
|
5846
6050
|
// PRD §8.2 top gap — they were invisible in flagship). Staged disclosure: the cap
|
|
@@ -5855,16 +6059,29 @@ function renderFsTodo(){
|
|
|
5855
6059
|
const goalNo = new Map();
|
|
5856
6060
|
for (const g of pgoals) { const n = goalReviewNumber({ goal: g, tasks: list }); if (n != null) goalNo.set(g.id, n); }
|
|
5857
6061
|
const noSpan = (goalId) => { const n = goalId != null ? goalNo.get(goalId) : null; return n != null ? `<span class="gno" data-n="${n}">#${n}</span>` : ''; };
|
|
6062
|
+
// DOING = only what is actively moving (running task rows). Stopped/errored work moved
|
|
6063
|
+
// out to its own "Needs you" caption below (Masa 2026-07-19) — folding it into Doing read
|
|
6064
|
+
// as "still in flight".
|
|
5858
6065
|
const doingRows =
|
|
5859
6066
|
running.map((t) => `<div class="drow"><div class="trow now"><span class="st run"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span><span class="pct num">${progressFor(t)}%</span>${fsActs(t.goalId)}</div>
|
|
5860
|
-
<div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('')
|
|
6067
|
+
<div class="tbar"><i style="width:${progressFor(t)}%"></i></div></div>`).join('');
|
|
6068
|
+
// NEEDS YOU: stopped/errored work that used to fold into Doing, now under its own caption so
|
|
6069
|
+
// it no longer reads as "still in flight" (Masa 2026-07-19). Three kinds share it, each
|
|
6070
|
+
// keeping the affordances it already had — nothing about the failed-task and blocked-goal
|
|
6071
|
+
// rows changed except the caption they sit under:
|
|
6072
|
+
// • a failed/interrupted TASK — the rich Retry/Resume row;
|
|
6073
|
+
// • a BLOCKED goal — the Retry/Archive goal row (a blocked goal can ALSO have a stopped
|
|
6074
|
+
// task; both rows show, as before — they carry different, goal- vs task-level actions);
|
|
6075
|
+
// • a needsInput goal — new here: a clarifying question waiting on you (Answer opens it,
|
|
6076
|
+
// where the chat feed's question card takes the actual answer).
|
|
6077
|
+
const needsRows =
|
|
5861
6078
|
// Doing-failure UI (HANDOFF-v45 §11, revises v45 §6): a stopped task shows NO progress
|
|
5862
6079
|
// bar (a dead bar pushed Retry down) — state is a COLOURED TEXT WORD only, no badge.
|
|
5863
6080
|
// Interrupted (amber, ⏸, "Resume") is distinguished from Failed (red, ✗, "Retry") —
|
|
5864
6081
|
// both still call the same /retry endpoint, which already resumes the session when one
|
|
5865
6082
|
// exists (server-side retry = "resume if possible, else re-run"; there is no separate
|
|
5866
6083
|
// resume endpoint to call).
|
|
5867
|
-
|
|
6084
|
+
failed.map((t) => { const isInt = t.status === 'interrupted';
|
|
5868
6085
|
const word = isInt ? 'Interrupted' : 'Failed', icon = isInt ? '⏸' : '✗', label = isInt ? 'Resume' : 'Retry';
|
|
5869
6086
|
// don't restate the state word as the "reason" too (e.g. "Interrupted / Interrupted")
|
|
5870
6087
|
// when the task has no distinct result text — the coloured word already said it, so
|
|
@@ -5877,7 +6094,10 @@ function renderFsTodo(){
|
|
|
5877
6094
|
<div class="acts2"><button class="errbtn" data-fsretry="${t.id}">${label}</button><button class="errbtn" data-fslog="${t.id}">View log</button></div></div></div>`; }).join('')
|
|
5878
6095
|
+ blocked.map((g) => `<div class="drow err"><div class="trow now"><span class="st run bad"></span>${noSpan(g.id)}<span class="tt">${tt(g.reviewSummary?.check || g.plan?.[0] || g.text, 60)}</span><span class="stword">Failed</span></div>
|
|
5879
6096
|
<div class="errline"><div class="msg"><span class="x">✗</span>${tt(g.blocked?.reason ?? 'blocked', 180)}</div>
|
|
5880
|
-
<div class="acts2"><button class="errbtn" data-fsreverify="${g.id}">Retry</button><button class="errbtn" data-fsglog="${g.id}">View log</button><button class="errbtn" data-fsarchive="${g.id}">Archive</button></div></div></div>`).join('')
|
|
6097
|
+
<div class="acts2"><button class="errbtn" data-fsreverify="${g.id}">Retry</button><button class="errbtn" data-fsglog="${g.id}">View log</button><button class="errbtn" data-fsarchive="${g.id}">Archive</button></div></div></div>`).join('')
|
|
6098
|
+
+ needsInput.map((g) => `<div class="drow err int"><div class="trow now"><span class="st run int"></span>${noSpan(g.id)}<span class="tt">${tt(g.text || g.plan?.[0], 60)}</span><span class="stword int">Needs input</span></div>
|
|
6099
|
+
<div class="errline"><div class="msg"><span class="x int">?</span>${tt(g.question?.text || 'A question is waiting on you', 180)}</div>
|
|
6100
|
+
<div class="acts2"><button class="errbtn" data-fsglog="${g.id}">Answer</button></div></div></div>`).join('');
|
|
5881
6101
|
const NCAP = 6;
|
|
5882
6102
|
const upShown = fsUI.todoMore ? queued : queued.slice(0, NCAP);
|
|
5883
6103
|
const seenGoals = new Set();
|
|
@@ -5898,11 +6118,12 @@ function renderFsTodo(){
|
|
|
5898
6118
|
// "Next up" pool only) — so a project sitting in Doing/Later with an empty Next-up
|
|
5899
6119
|
// queue showed "To Do 0" directly above visible Doing/Later rows. The header labels
|
|
5900
6120
|
// the whole lane ("To Do"), so it now counts everything the lane actually renders
|
|
5901
|
-
// below it: Doing (running + failed/interrupted
|
|
5902
|
-
// (queued) + Later (pending goals). "· next-up order" stays keyed off
|
|
5903
|
-
// specifically, since that suffix describes the Next-up sublist's own sort,
|
|
5904
|
-
// lane total.
|
|
5905
|
-
const
|
|
6121
|
+
// below it: Doing (running) + Needs you (failed/interrupted tasks + stopped/needsInput
|
|
6122
|
+
// goals) + Next up (queued) + Later (pending goals). "· next-up order" stays keyed off
|
|
6123
|
+
// queued.length specifically, since that suffix describes the Next-up sublist's own sort,
|
|
6124
|
+
// not the lane total.
|
|
6125
|
+
const needsCount = failed.length + blocked.length + needsInput.length;
|
|
6126
|
+
const todoTotal = running.length + needsCount + queued.length + later.length;
|
|
5906
6127
|
// Free-tier pending counter (H22): on the free plan the header shows the wall
|
|
5907
6128
|
// — pending goals out of the 5-slot cap — reddening at the cap and gone once
|
|
5908
6129
|
// paid. Reads the SAME numbers the gate enforces (state.billing, never a local
|
|
@@ -5917,7 +6138,8 @@ function renderFsTodo(){
|
|
|
5917
6138
|
el.innerHTML = `<div class="lhd sechd" id="fsTodoHd"><span class="hd fstdlabel">To Do</span>${_todoNum}<span class="fold fstdlabel">▾</span></div>
|
|
5918
6139
|
<div class="tl">
|
|
5919
6140
|
${doingRows ? `<span class="tlcap" style="padding-top:0">Doing</span>${doingRows}` : ''}
|
|
5920
|
-
${
|
|
6141
|
+
${needsRows ? `<span class="tlcap needs"${doingRows ? '' : ' style="padding-top:0"'}>Needs you</span>${needsRows}` : ''}
|
|
6142
|
+
${upRows ? `<span class="tlcap"${doingRows || needsRows ? '' : ' style="padding-top:0"'}>Next up</span>${upRows}` : ''}
|
|
5921
6143
|
${queued.length > NCAP ? `<div class="morefold" id="fsTodoMore">${fsUI.todoMore ? 'less' : `+${queued.length - NCAP} more`}<span class="g">›</span></div>` : ''}
|
|
5922
6144
|
${laterRows ? `<span class="tlcap">Later</span>${laterRows}` : ''}
|
|
5923
6145
|
<button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
|
|
@@ -7411,7 +7633,7 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
|
|
|
7411
7633
|
// 4 archived / 5 reverted — session-local; the server round-trip is what
|
|
7412
7634
|
// settles it (see saSync's "returned to review" reset).
|
|
7413
7635
|
// ============================================================================
|
|
7414
|
-
const SA = { open: false, items: [], diffs: {} , detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
|
|
7636
|
+
const SA = { open: false, items: [], diffs: {}, diffBoxHtml: {}, detOpen: new Set(), focusGid: null, focusScrolled: false, focusTimer: null, cur: null };
|
|
7415
7637
|
window.SA = SA; // top-level const isn't a window property; external checks probe window.SA (same convention as window.state above)
|
|
7416
7638
|
const SA_SVG = {
|
|
7417
7639
|
ok: '<svg viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
|
|
@@ -7424,27 +7646,40 @@ function saRows(){
|
|
|
7424
7646
|
return buildReviewDigest({ goals: gs, tasks: ts }).map((r) => {
|
|
7425
7647
|
const g = gs.find((x) => x.id === r.goalIds[0]);
|
|
7426
7648
|
const pt = ts.find((t) => r.goalIds.includes(t.goalId) && t.proof && (t.proof.png || t.proof.gif));
|
|
7427
|
-
//
|
|
7428
|
-
//
|
|
7429
|
-
//
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
|
-
.
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
|
|
7649
|
+
// "What we did" (H23 §2, replaces the old Plan/Review-thread folds): one bullet
|
|
7650
|
+
// per finished requirement task's own report; falls back to the goal's plan
|
|
7651
|
+
// steps when nothing has finished yet (e.g. a plan awaiting approval — the plan
|
|
7652
|
+
// IS "what we're going to do" in that case, which is the honest content to show).
|
|
7653
|
+
const doneTasks = ts.filter((t) => r.goalIds.includes(t.goalId) && !t.reply && t.result)
|
|
7654
|
+
.sort((a, b) => (a.num ?? 0) - (b.num ?? 0));
|
|
7655
|
+
const did = doneTasks.length
|
|
7656
|
+
? doneTasks.map((t) => String(t.result).replace(/\s+/g, ' ').trim().slice(0, 160))
|
|
7657
|
+
: (Array.isArray(g?.plan) ? g.plan.filter(Boolean).map((s) => String(s).replace(/\s+/g, ' ').trim()) : []);
|
|
7658
|
+
// Sibling goals (H23 §2/§4 "did the AI drop part of what I asked"): buildReviewDigest
|
|
7659
|
+
// already collapses goals that share one PR into a single row (goalIds.length > 1)
|
|
7660
|
+
// — the real-data analog of a long ask fanning out into several tracked pieces.
|
|
7661
|
+
// The row's primary goal (goalIds[0], whose text/title drive this card) is marked
|
|
7662
|
+
// "cur"; the rest are listed so nothing reads as silently dropped.
|
|
7663
|
+
const siblings = r.goalIds.length > 1
|
|
7664
|
+
? r.goalIds.map((gid) => {
|
|
7665
|
+
const sg = gs.find((x) => x.id === gid);
|
|
7666
|
+
return { no: goalReviewNumber({ goal: sg, tasks: ts }), title: saReviewTitle(sg), cur: gid === r.goalIds[0] };
|
|
7667
|
+
})
|
|
7668
|
+
: null;
|
|
7437
7669
|
return {
|
|
7438
7670
|
ids: r.goalIds, goalId: r.goalIds[0], goal: g,
|
|
7439
7671
|
no: r.reviewNumber,
|
|
7440
|
-
title:
|
|
7672
|
+
title: saReviewTitle(g),
|
|
7441
7673
|
check: String(g?.reviewSummary?.check || r.checkLine || '').replace(/\s+/g, ' ').trim(),
|
|
7442
7674
|
goalText: String(g?.text ?? '').replace(/\s+/g, ' ').trim(),
|
|
7443
|
-
|
|
7444
|
-
thread, risk: g?.risk ?? null,
|
|
7675
|
+
did, siblings, risk: g?.risk ?? null,
|
|
7445
7676
|
note: g?.retest?.passed ? g.retest.note : null,
|
|
7446
7677
|
pr: r.pr, testResult: r.testResult,
|
|
7447
7678
|
proofTaskId: pt?.id ?? null, proofImg: pt?.proof?.png ?? pt?.proof?.gif ?? null,
|
|
7679
|
+
// Ledger before/after (docs/design/HANDOFF-review-card-evidence-first-2026-07-18.md
|
|
7680
|
+
// §3 kind==='image'): most tasks never have a "before" capture, so this is almost
|
|
7681
|
+
// always null and beforeAfterMediaHtml() falls back to the single-shot view.
|
|
7682
|
+
proofBefore: pt?.proof?.beforePng ?? pt?.proof?.beforeGif ?? null,
|
|
7448
7683
|
// Token-frugality is the product's whole pitch — surface it here too, not
|
|
7449
7684
|
// just in the per-goal review modal. buildReviewSummary computes the same
|
|
7450
7685
|
// estimate; reuse its logic directly off the goal's runUsage/runTokens.
|
|
@@ -7461,6 +7696,79 @@ function saRows(){
|
|
|
7461
7696
|
};
|
|
7462
7697
|
});
|
|
7463
7698
|
}
|
|
7699
|
+
// Meta row: PR link / token savings (real tap-to-open toggle) / Run locally — under
|
|
7700
|
+
// the title (H23 §1). Run-locally has no real per-task command yet — this is the
|
|
7701
|
+
// CDO's answer to "let me test this locally", left for CTO/engine to decide what it
|
|
7702
|
+
// actually triggers (docs/design/HANDOFF-review-card-evidence-first-2026-07-18.md
|
|
7703
|
+
// §5). Shown only for tasks with a real automated check to plausibly re-run
|
|
7704
|
+
// (it.testResult.ran), and tapping it is honest about not being wired yet rather
|
|
7705
|
+
// than pretending to execute anything — never a fake "running…" state.
|
|
7706
|
+
function saMetaRowHtml(it, i){
|
|
7707
|
+
const canRunLocal = Boolean(it.testResult?.ran);
|
|
7708
|
+
if (!it.pr && !it.tokenOpt && !canRunLocal) {
|
|
7709
|
+
return '<div class="lg-meta"><span class="none">No PR link / token-savings data on this task.</span></div>';
|
|
7710
|
+
}
|
|
7711
|
+
const chips = [];
|
|
7712
|
+
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>`);
|
|
7713
|
+
if (it.tokenOpt) {
|
|
7714
|
+
const pct = Number(it.tokenOpt.percent);
|
|
7715
|
+
const label = pct > 0 ? `saved ${pct}% tokens` : 'token usage';
|
|
7716
|
+
chips.push(`<button class="tokchip" type="button" data-saact="tok:${i}">${esc(label)} <i class="tokchevron">›</i></button>`);
|
|
7717
|
+
}
|
|
7718
|
+
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>`);
|
|
7719
|
+
const tokCmp = it.tokenOpt?.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
|
|
7720
|
+
const tokTech = (it.tokenOpt?.techniques ?? []).slice(0, 4).join(' / ');
|
|
7721
|
+
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>` : '';
|
|
7722
|
+
const runNote = canRunLocal ? `<div class="tokdetail" id="runlocal${i}"><span class="v">No local run command is wired for this task yet.</span></div>` : '';
|
|
7723
|
+
return `<div class="lg-meta">${chips.join('')}</div>${tokDetail}${runNote}`;
|
|
7724
|
+
}
|
|
7725
|
+
// Context: closed by default, one-line verbatim-quote summary, chevron large and at
|
|
7726
|
+
// the far right (H23 §2). Quotes goal.text — the user's own words — never
|
|
7727
|
+
// task.detail/title (AI-authored; an earlier round of this design mistakenly quoted
|
|
7728
|
+
// the AI's own read on the request as "your ask" and it wasn't recognizable).
|
|
7729
|
+
function saContextHtml(it, gk){
|
|
7730
|
+
const goalTxt = it.goalText;
|
|
7731
|
+
const summary = goalTxt
|
|
7732
|
+
? `"${esc(goalTxt.length > 60 ? `${goalTxt.slice(0, 60)}…` : goalTxt)}"`
|
|
7733
|
+
: 'No verbatim ask on record for this goal.';
|
|
7734
|
+
const split = it.siblings
|
|
7735
|
+
? `<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>`
|
|
7736
|
+
: '';
|
|
7737
|
+
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>` : '';
|
|
7738
|
+
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>` : '';
|
|
7739
|
+
const inner = (split || did || look) ? `${split}${did}${look}` : '<div class="lg-notfound">Nothing else on record for this goal.</div>';
|
|
7740
|
+
const open = SA.detOpen.has(`${gk}:ctx`) ? ' open' : '';
|
|
7741
|
+
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>`;
|
|
7742
|
+
}
|
|
7743
|
+
// Evidence hero: shape follows task kind, not one image-shaped template (H23 §3).
|
|
7744
|
+
// Real data check: most finished tasks have no proof image at all (logic-only /
|
|
7745
|
+
// docs-only / research changes verify without a screenshot) — kind is derived, see
|
|
7746
|
+
// saEvidenceKind() above.
|
|
7747
|
+
function saEvidenceHtml(kind, factRow, img, filePaths, diffText, gtr, it){
|
|
7748
|
+
if (kind === 'logic') {
|
|
7749
|
+
const passed = gtr?.passed ?? 0, failed = gtr?.failed ?? 0, ok = gtr?.ok !== false;
|
|
7750
|
+
const n = ok ? String(passed) : `${passed}/${passed + failed}`;
|
|
7751
|
+
return `<div class="lg-ev"><div class="lg-evhd">Evidence — tests, no UI to show</div>
|
|
7752
|
+
<div class="lg-herostat${ok ? '' : ' fail'}"><span class="n">${esc(n)}</span><span class="l">tests passed<br>${esc(filePaths.join(', '))}</span></div></div>`;
|
|
7753
|
+
}
|
|
7754
|
+
if (kind === 'docs') {
|
|
7755
|
+
const lines = diffText ? diffText.split('\n').filter((l) => l.length) : [];
|
|
7756
|
+
const diffHtml = lines.map((l) => {
|
|
7757
|
+
const isAdd = l.startsWith('+');
|
|
7758
|
+
return `<div class="ln${isAdd ? ' a' : ''}"><span class="gutter">+</span><span class="txt">${esc(l.slice(1) || l)}</span></div>`;
|
|
7759
|
+
}).join('');
|
|
7760
|
+
return `<div class="lg-ev"><div class="lg-evhd">Evidence — the diff, no test to run</div>
|
|
7761
|
+
<div class="row"><span class="fls">${esc(filePaths.join(', '))} · docs-only, no test target</span></div>
|
|
7762
|
+
${diffHtml ? `<div class="lg-diffblock">${diffHtml}</div>` : ''}</div>`;
|
|
7763
|
+
}
|
|
7764
|
+
if (kind === 'missing') {
|
|
7765
|
+
const note = it.check || 'Please verify this change manually — no automated capture exists for it.';
|
|
7766
|
+
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>
|
|
7767
|
+
<div class="lg-manualnote">${esc(note)}</div></div>`;
|
|
7768
|
+
}
|
|
7769
|
+
// kind === 'image' (default)
|
|
7770
|
+
return `<div class="lg-ev"><div class="lg-evhd">Evidence</div><div class="row">${factRow}</div>${img}</div>`;
|
|
7771
|
+
}
|
|
7464
7772
|
function saItemHtml(it, i){
|
|
7465
7773
|
const done = { 1: ['ok', '✓', 'Approved'], 2: ['no', '↩', 'Dismissed — re-testing ×3'], 3: ['no', '↩', 'Feedback sent — returned to To Do'], 4: ['ar', '—', 'Archived'], 5: ['no', '↩', 'Reverted — change undone'] }[it.st];
|
|
7466
7774
|
if (done) {
|
|
@@ -7473,46 +7781,50 @@ function saItemHtml(it, i){
|
|
|
7473
7781
|
// Honest evidence chips (mirrors the vchip tested/proof/needs-you logic in
|
|
7474
7782
|
// renderReviewChecklist ~L3742-3752): absence of a signal is shown as a muted
|
|
7475
7783
|
// chip, never silently dropped — an untested/proofless goal must never LOOK verified.
|
|
7784
|
+
// H23: facts stop looking like buttons — glyph carries the state colour, text
|
|
7785
|
+
// stays plain ink, no border/pill (border is reserved for the PR link).
|
|
7476
7786
|
const gtr = it.testResult;
|
|
7477
7787
|
const testsChip = gtr?.ran
|
|
7478
|
-
? (gtr.ok ? `<span class="
|
|
7479
|
-
: '<span class="
|
|
7788
|
+
? (gtr.ok ? `<span class="vf ok"><i class="g">✓</i>${gtr.passed} passed</span>` : `<span class="vf no"><i class="g">✗</i>${gtr.passed}/${gtr.passed + (gtr.failed || 0)} passed</span>`)
|
|
7789
|
+
: '<span class="vf none">— not tested</span>';
|
|
7480
7790
|
const hasProofImg = it.proofImg && it.proofTaskId != null;
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
|
|
7484
|
-
//
|
|
7485
|
-
//
|
|
7486
|
-
//
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
|
|
7490
|
-
|
|
7491
|
-
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7791
|
+
// Before/after (H23 spec §3 kind==='image'): reuse the same beforeafter/ba-col/
|
|
7792
|
+
// balabel renderer as the older per-task checklist (beforeAfterMediaHtml, ~L3281).
|
|
7793
|
+
// it.proofBefore is null for the ~all tasks with no "before" capture, so this
|
|
7794
|
+
// falls back to the identical single <img class="proofimg"> markup it replaced.
|
|
7795
|
+
// Diff-box + zoom inset (HANDOFF-review-card-diffbox-2026-07-18) is computed async
|
|
7796
|
+
// after the card first paints (saEnhanceDiffBoxes, below saRender). SA.diffBoxHtml
|
|
7797
|
+
// holds the result — a string once a box is found, null once ruled out, undefined
|
|
7798
|
+
// until then — so it survives every later saRender() the same way SA.diffs already
|
|
7799
|
+
// does for the changed-files diff.
|
|
7800
|
+
const boxedImg = SA.diffBoxHtml[it.goalId];
|
|
7801
|
+
const img = hasProofImg
|
|
7802
|
+
? (typeof boxedImg === 'string' ? boxedImg : beforeAfterMediaHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, 'proof'))
|
|
7803
|
+
: '';
|
|
7804
|
+
// Changed-file paths feed both the evidence-kind heuristic and the docs-kind diff —
|
|
7805
|
+
// same source the old "Changed files & diff" fold used (d from the diff endpoint,
|
|
7806
|
+
// falling back to task.changedFiles when the server has no diff for this goal).
|
|
7807
|
+
let filePaths = [], nf = 0;
|
|
7808
|
+
if (d) { filePaths = d.files.map((f) => f.path); nf = filePaths.length; }
|
|
7809
|
+
else if (d === null) {
|
|
7810
|
+
filePaths = [...new Set((state.tasks || []).filter((t) => it.ids.includes(t.goalId)).flatMap((t) => t.changedFiles ?? []))];
|
|
7811
|
+
nf = filePaths.length;
|
|
7498
7812
|
}
|
|
7499
|
-
const
|
|
7813
|
+
const kind = saEvidenceKind(filePaths, hasProofImg, gtr);
|
|
7814
|
+
const factRow = [
|
|
7500
7815
|
testsChip,
|
|
7501
|
-
it.note ? `<span class="
|
|
7502
|
-
pm,
|
|
7816
|
+
it.note ? `<span class="vf ok"><i class="g">✓</i>${esc(it.note)}</span>` : '',
|
|
7503
7817
|
`<span class="fls">${nf} file${nf === 1 ? '' : 's'}</span>`,
|
|
7504
|
-
|
|
7505
|
-
hasProofImg ? '' : '<span class="vchip none">— no proof</span>',
|
|
7818
|
+
hasProofImg ? '' : '<span class="vf none">— no proof</span>',
|
|
7506
7819
|
].filter(Boolean).join('');
|
|
7507
7820
|
const gk = it.goalId;
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
const
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
const
|
|
7515
|
-
const diffHtml = `<details class="lg-det" data-detkey="${gk}:diff"${SA.detOpen.has(`${gk}:diff`) ? ' open' : ''}><summary>Changed files & diff<span class="c">${diffCount}</span></summary><div class="lg-diffwrap"><div class="desc">${desc}</div>${files}${diffBlock}${miss}</div></details>`;
|
|
7821
|
+
// "What to look at" (H23 §2): the changed files themselves — a real, non-fabricated
|
|
7822
|
+
// pointer for the reviewer's eyes, same file list that used to live in the retired
|
|
7823
|
+
// "Changed files & diff" fold.
|
|
7824
|
+
const look = filePaths.slice(0, 8);
|
|
7825
|
+
if (filePaths.length > 8) look.push(`+${filePaths.length - 8} more`);
|
|
7826
|
+
const contextHtml = saContextHtml({ goalText: it.goalText, siblings: it.siblings, did: it.did, look }, gk);
|
|
7827
|
+
const evidenceHtml = saEvidenceHtml(kind, factRow, img, filePaths, d?.diff ?? null, gtr, it);
|
|
7516
7828
|
// shouldPause = classifyChangeRisk (engine/lib.mjs) already forced this goal to
|
|
7517
7829
|
// stop in review for exactly this reason — surface it; a non-blocking low-risk
|
|
7518
7830
|
// read isn't worth a permanent row on every card (goal 427 §4 already covers it
|
|
@@ -7523,16 +7835,9 @@ function saItemHtml(it, i){
|
|
|
7523
7835
|
: '';
|
|
7524
7836
|
return `<div class="sa-item lg${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
|
|
7525
7837
|
<div class="lg-hd"><span class="no">${it.no != null ? `#${it.no}` : ''}</span><span class="t">${esc(it.title)}</span><span class="lg-state review">Review</span></div>
|
|
7526
|
-
|
|
7527
|
-
<div class="lg-
|
|
7528
|
-
|
|
7529
|
-
<div class="row">${evRow}</div>
|
|
7530
|
-
${tokenOptimizationHtml(it.tokenOpt)}
|
|
7531
|
-
${img}
|
|
7532
|
-
</div>
|
|
7533
|
-
${planHtml}
|
|
7534
|
-
${threadHtml}
|
|
7535
|
-
${diffHtml}
|
|
7838
|
+
${saMetaRowHtml(it, i)}
|
|
7839
|
+
<div class="lg-ctx">${contextHtml}<div class="lg-fact"><span class="k">Done when</span><span class="v">${esc(it.check)}</span></div></div>
|
|
7840
|
+
${evidenceHtml}
|
|
7536
7841
|
${riskHtml}
|
|
7537
7842
|
<div class="sa-act"><button class="sa-app" data-saact="app:${i}">${SA_SVG.ok}Approve</button><button class="sa-dis" data-saact="dis:${i}">Dismiss</button><div class="sa-chatwrap"><input class="sa-chat" data-i="${i}" placeholder="What's wrong? Send back to Claude Code…"><button class="sa-send" data-saact="send:${i}" title="Send back"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div><button class="sa-ic" title="Archive" data-saact="arch:${i}">${SA_SVG.arch}</button><button class="sa-ic" title="Revert" data-saact="rev:${i}">${SA_SVG.trash}</button></div>
|
|
7538
7843
|
</div>`;
|
|
@@ -7550,7 +7855,7 @@ function saRender(){
|
|
|
7550
7855
|
// Native <details> would collapse on every rebuild (SSE tick, approve, etc.) —
|
|
7551
7856
|
// track what's open per goal+section in SA.detOpen (persists across re-renders)
|
|
7552
7857
|
// and wire each one's toggle to keep that set current.
|
|
7553
|
-
for (const det of S.querySelectorAll('.lg-
|
|
7858
|
+
for (const det of S.querySelectorAll('.lg-goal[data-detkey]')) {
|
|
7554
7859
|
det.addEventListener('toggle', () => {
|
|
7555
7860
|
const k = det.dataset.detkey;
|
|
7556
7861
|
if (det.open) SA.detOpen.add(k); else SA.detOpen.delete(k);
|
|
@@ -7597,6 +7902,25 @@ async function saFetchDiffs(){
|
|
|
7597
7902
|
}));
|
|
7598
7903
|
if (SA.open) saSync();
|
|
7599
7904
|
}
|
|
7905
|
+
// Diff-box + zoom inset (HANDOFF-review-card-diffbox-2026-07-18): unlike
|
|
7906
|
+
// saFetchDiffs above (whose fetch settling triggers a full saSync()/saRender()), this
|
|
7907
|
+
// patches each card's already-rendered .beforeafter node directly — a full re-render
|
|
7908
|
+
// here would reset scroll, close every open <details>, and drop an in-progress reply
|
|
7909
|
+
// keystroke, all just to draw a box around one screenshot. SA.diffBoxHtml is still
|
|
7910
|
+
// cached per goal, so if a later SSE-driven saSync() does rebuild the card anyway,
|
|
7911
|
+
// saItemHtml() picks the cached result back up with no flicker to the plain pair.
|
|
7912
|
+
async function saEnhanceDiffBoxes(){
|
|
7913
|
+
const targets = SA.items.filter((it) => it.proofImg && it.proofTaskId != null && it.proofBefore && SA.diffBoxHtml[it.goalId] === undefined);
|
|
7914
|
+
await Promise.all(targets.map(async (it) => {
|
|
7915
|
+
let html = null;
|
|
7916
|
+
try { html = await beforeAfterBoxedHtml(it.proofTaskId, { png: it.proofImg, beforePng: it.proofBefore }, esc(it.title ?? '')); }
|
|
7917
|
+
catch { html = null; } // a decode/pixel-diff failure is a fallback, never a crash
|
|
7918
|
+
SA.diffBoxHtml[it.goalId] = html; // cache even on null — never re-attempt an already-answered "no box"
|
|
7919
|
+
if (!html || !SA.open) return;
|
|
7920
|
+
const host = document.querySelector(`.sa-item[data-gid="${it.goalId}"] .beforeafter`);
|
|
7921
|
+
if (host) host.outerHTML = html; // html itself opens with the replacement .beforeafter
|
|
7922
|
+
}));
|
|
7923
|
+
}
|
|
7600
7924
|
function saOpen(focusGoalId){
|
|
7601
7925
|
SA.open = true;
|
|
7602
7926
|
SA.items = saRows().map((r) => ({ ...r, st: 0 }));
|
|
@@ -7618,6 +7942,7 @@ function saOpen(focusGoalId){
|
|
|
7618
7942
|
saRender();
|
|
7619
7943
|
$('seeall').classList.add('open');
|
|
7620
7944
|
saFetchDiffs();
|
|
7945
|
+
saEnhanceDiffBoxes();
|
|
7621
7946
|
}
|
|
7622
7947
|
function saClose(){ SA.open = false; SA.focusGid = null; if (SA.focusTimer) { clearTimeout(SA.focusTimer); SA.focusTimer = null; } $('seeall').classList.remove('open'); }
|
|
7623
7948
|
// Continuous review (Masa 2026-07-08): move a persistent "current" card with
|
|
@@ -7676,6 +8001,10 @@ $('seeall').addEventListener('click', async (e) => {
|
|
|
7676
8001
|
const it = SA.items[i];
|
|
7677
8002
|
if (k === 'close') return saClose();
|
|
7678
8003
|
if (!it) return;
|
|
8004
|
+
// meta-row toggles (H23): tap the token chip to reveal the technique breakdown, tap
|
|
8005
|
+
// Run-locally to reveal the honest "not wired yet" note — neither performs an action.
|
|
8006
|
+
if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
|
|
8007
|
+
if (k === 'runlocal') { $('seeall').querySelector(`#runlocal${i}`)?.classList.toggle('open'); return; }
|
|
7679
8008
|
// every verdict auto-advances the focus to the next pending card (tempo).
|
|
7680
8009
|
if (k === 'app') { const bx = b.getBoundingClientRect(); confettiBurst(bx.left + bx.width / 2, bx.top + bx.height / 2); return saAct(it, 1, 'approve').then((ok) => { if (ok) saAdvanceFocus(it.goalId); }); }
|
|
7681
8010
|
if (k === 'dis') return saAct(it, 2, 'retest').then((ok) => { if (ok) saAdvanceFocus(it.goalId); });
|