@galda/cli 0.10.15 → 0.10.17

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
@@ -610,7 +610,10 @@
610
610
  .confetti-piece{position:fixed;pointer-events:none;z-index:9999;will-change:transform,opacity}
611
611
 
612
612
  /* connect modal */
613
- .overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);backdrop-filter:blur(6px);z-index:60;display:none;place-items:center}
613
+ .overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);backdrop-filter:blur(6px);z-index:90;display:none;place-items:center}
614
+ /* z-index:90 (was 60): the customize-panel discard confirm (#deleteConfirmOverlay, shares this
615
+ class) can now fire while .gearpop (the customize panel, z-index:80) is open — it was
616
+ rendering BEHIND the panel it's confirming a discard for (Masa 2026-07-16, real screenshot). */
614
617
  .overlay.show{display:grid}
615
618
  .modal{width:640px;max-width:92vw;background:var(--surface);border:1px solid var(--hair2);border-radius:16px;padding:20px 22px;box-shadow:0 24px 70px rgba(0,0,0,.6)}
616
619
  .modal h3{margin:0 0 6px;font-size:15px;letter-spacing:-.01em}
@@ -619,6 +622,12 @@
619
622
  border-radius:9px;padding:11px 12px;white-space:pre-wrap;word-break:break-all;line-height:1.55}
620
623
  .modal .mrow{display:flex;gap:8px;margin-top:10px}
621
624
  .mbtn{height:30px;padding:0 14px;border-radius:9px;border:0;background:var(--iri);color:var(--on-accent);font-size:12.5px;font-weight:600;cursor:pointer}
625
+ /* the bare .mbtn fill is --iri, the AI-signature rainbow (logo/send-ring only per DESIGN-RULES
626
+ §5.5 "虹はAI要素だけ") — it must never be a generic button fill. .primary is the actual
627
+ neutral affirmative-action look, lifted from the customize panel's own existing Save button
628
+ (.gearpop .cfoot .cbtn.save below: same --ink/--invink tokens) rather than inventing a new
629
+ one (CDO decision, Masa 2026-07-16: "todo削除や他UIを参考にしてほしい"). */
630
+ .mbtn.primary{background:var(--ink);color:var(--invink)}
622
631
  .mbtn.ghost{background:transparent;border:1px solid var(--hair2);color:var(--ink2)}
623
632
  /* Soft destructive (DESIGN-RULES §7 決定ログ 2026-07-07深夜3: See allアクション色=Soft確定
624
633
  — 淡赤面+濃赤文字。redは面を塗らない小要素専用の原則に沿い、フル赤塗りは使わない). */
@@ -1074,8 +1083,10 @@
1074
1083
  the layout starts at the three cards. Frame = 14px all around (flagship .app{inset:14px}).
1075
1084
  Dev escape hatch: ?dev=1 (or localStorage devbar=1) adds body.devbar to show the bar. */
1076
1085
  body[data-layout="flagship"]:not(.devbar) .top{display:none}
1077
- body[data-layout="flagship"] #fsRoot{flex:1;min-height:0;display:flex;gap:12px;padding:14px;
1078
- --fscenterw:760px} /* single source of truth for the reading column .fsstream (feed) and
1086
+ body[data-layout="flagship"] #fsRoot{flex:1;min-height:0;display:flex;gap:12px;padding:14px;position:relative;
1087
+ --fscenterw:760px} /* position:relative so .railgrip (a direct child, kept outside .fsrail's
1088
+ overflow:hidden — see below) can be placed via left:calc(var(--railw)...) and never get clipped.
1089
+ single source of truth for the reading column — .fsstream (feed) and
1079
1090
  .compwrap (composer) both read this var so they can never drift apart (fix: composer was
1080
1091
  reported "narrow vs the text above" — both already used the same 760px, but as two separate
1081
1092
  literals; centralising removes the risk of future edits widening one without the other) */
@@ -1092,17 +1103,23 @@
1092
1103
  #fsRoot .fsrail{flex:0 0 var(--railw,168px);display:flex;flex-direction:column;padding:14px 10px;position:relative;transition:flex-basis .28s cubic-bezier(.4,0,.2,1);
1093
1104
  min-width:0;overflow:hidden} /* CDO reference: the rail is a narrow lane; long project names ellipsize inside it instead of widening the whole left side */
1094
1105
  #fsRoot .fsrail.noanim{transition:none}
1095
- #fsRoot .railgrip{position:absolute;right:0;top:0;bottom:0;width:12px;cursor:col-resize;z-index:5}
1096
- /* right:0 (was -15px): the rail has overflow:hidden for name-ellipsizing, which CLIPPED a grip
1097
- that poked out; and .fscenter covered that spot. Sitting on the rail's inner right edge keeps
1098
- it un-clipped, un-covered, and over the 10px right padding (no project-button overlap). */
1099
- /* right:-15px = -3px - 12px(#fsRoot gap). The grab area must land on the boundary the user
1100
- actually SEES: in Gradient the rail melts into the background (transparent, no box-shadow),
1101
- so the perceived edge is .fscenter's own left border (its box-shadow hairline), which sits
1102
- one flex gap (12px) to the right of the rail's own right edge. Was centred on the rail's
1103
- invisible edge; now centred on the visible center-panel edge, in every theme (Masa 2026-07-08). */
1106
+ /* .railgrip is a DIRECT CHILD OF #fsRoot (moved out of .fsrail in the markup below), not a
1107
+ descendant of the rail — .fsrail has overflow:hidden for name-ellipsizing, and any part of a
1108
+ child that pokes past an overflow:hidden ancestor's box is both unpainted AND un-hit-testable,
1109
+ so a grip nested inside the rail can never straddle the rail's own edge (only sit fully inside
1110
+ it, biased away from the visible seam Masa 2026-07-16). Positioned via left:calc(14px + var(--railw))
1111
+ so it tracks the live rail width automatically (no JS repositioning needed) and is centered
1112
+ exactly on the visible edge, mirroring .lanegrip's left:-3px/width:7px pattern on the other side.
1113
+ The leading 14px is #fsRoot's own left padding (line ~1085): left/top on an absolutely positioned
1114
+ child resolve against the PADDING edge of its containing block, not the content edge where the
1115
+ flex children (.fsrail etc.) actually start — omitting it left the whole grip sitting 14px too
1116
+ far left of the seam (caught from a real screenshot, Masa 2026-07-16 2nd pass). */
1117
+ #fsRoot .railgrip{position:absolute;top:0;bottom:0;left:calc(14px + var(--railw, 168px) - 4px);width:7px;cursor:col-resize;z-index:5}
1118
+ /* Gradient theme: the rail is transparent (no box-shadow edge of its own), so the seam the user
1119
+ actually SEES is .fscenter's own left edge, one flex gap (12px) further right (Masa 2026-07-08). */
1120
+ body[data-theme="gradient"] #fsRoot .railgrip{left:calc(14px + var(--railw, 168px) + 8px)}
1104
1121
  #fsRoot .railgrip::after{content:"";position:absolute;right:3px;top:50%;transform:translateY(-50%);width:2px;height:34px;border-radius:2px;background:var(--hair2);opacity:0;transition:opacity .15s}
1105
- #fsRoot .fsrail:hover .railgrip::after{opacity:1}
1122
+ #fsRoot .fsrail:hover ~ .railgrip::after,#fsRoot .railgrip:hover::after{opacity:1}
1106
1123
  #fsRoot.railfold .railgrip{display:none}
1107
1124
  #fsRoot .fslogo{display:flex;align-items:center;gap:9px;margin:2px 0 16px 6px;height:22px;flex:0 0 auto}
1108
1125
  #fsRoot .logoring{width:22px;height:22px;flex:0 0 22px;border-radius:50%;
@@ -1296,6 +1313,11 @@
1296
1313
  discard path. */
1297
1314
  #fsRoot .cc-ask .acts{position:absolute;top:8px;right:8px;display:flex;opacity:0;transition:opacity .15s}
1298
1315
  #fsRoot .cc-ask:hover .acts{opacity:1}
1316
+ /* Touch devices have no hover → the hover-only trash was permanently invisible
1317
+ and the「Needs your answer」card had no reachable discard path (Masa
1318
+ 2026-07-16: 右上のdeleteが押せない). Reveal it (dimmed, so it still reads as
1319
+ secondary per §1.5-10) whenever the pointer can't hover. */
1320
+ @media (hover: none){ #fsRoot .cc-ask .acts{opacity:.55} }
1299
1321
  #fsRoot .qchips{display:flex;gap:6px;flex-wrap:wrap}
1300
1322
  #fsRoot .achip{height:28px;padding:0 12px;border-radius:8px;border:1px solid var(--hair2);background:var(--panel);
1301
1323
  color:var(--ink2);font:inherit;font-size:12.5px;cursor:pointer;transition:border-color .15s,color .15s,background .15s}
@@ -1920,7 +1942,7 @@
1920
1942
  #fsClawdHero span{display:block;margin-top:5px;font:500 10px/1.4 var(--mono);letter-spacing:.08em;color:var(--ink3)}
1921
1943
  /* Welcome P1 (H17 item 4, flagship f9c75ad): grounded hop + clean-cut eyes on settle + our CSS confetti.
1922
1944
  Two pixel frames (neutral black-eyes / laughing), no arm-raise, no official confetti. reduce-motion=static. */
1923
- #fsClawdHero .cstage{position:relative;width:170px;height:132px;display:flex;align-items:flex-end;justify-content:center;margin:0 auto 2px}#fsClawdHero .cw{position:relative;width:150px;transform-origin:50% 100%;will-change:transform;animation:chHop 2.4s linear infinite}#fsClawdHero .cw img{position:absolute;left:0;bottom:0;width:100%;display:block;image-rendering:pixelated}#fsClawdHero .cw .chNeu{animation:chNeu 2.4s linear infinite}#fsClawdHero .cw .chLau{opacity:0;animation:chLau 2.4s linear infinite}#fsClawdHero .chConf{position:absolute;left:50%;top:34px;width:0;height:0;pointer-events:none;z-index:3}#fsClawdHero .chConf i{position:absolute;width:4px;height:7px;border-radius:1.5px;opacity:0;transform:translate(-50%,-50%);animation:chPop 2.4s cubic-bezier(.2,.7,.3,1) infinite}@keyframes chHop{0%,52%{transform:translateY(0) scale(1,1)}55%{transform:translateY(0) scale(1.08,.85)}58%{transform:translateY(-16px) scale(.95,1.14)}61%{transform:translateY(-27px) scale(.98,1.05)}64%{transform:translateY(-31px) scale(1,1)}67%{transform:translateY(-26px) scale(.98,1.05)}70%{transform:translateY(-14px) scale(.95,1.12)}73%{transform:translateY(0) scale(1.1,.82)}77%{transform:translateY(-4px) scale(.99,1.02)}80%,100%{transform:translateY(0) scale(1,1)}}@keyframes chLau{0%,53.8%{opacity:0}54%,85.8%{opacity:1}86%,100%{opacity:0}}@keyframes chNeu{0%,53.8%{opacity:1}54%,85.8%{opacity:0}86%,100%{opacity:1}}@keyframes chPop{0%,54%{opacity:0;transform:translate(0,6px) rotate(0) scale(.5)}60%{opacity:1;transform:translate(calc(var(--dx)*.5),calc(var(--dy)*.5)) rotate(calc(var(--rot)*.5)) scale(1)}72%{opacity:1;transform:translate(var(--dx),var(--dy)) rotate(var(--rot))}100%{opacity:0;transform:translate(calc(var(--dx)*1.1),calc(var(--dy) + 44px)) rotate(calc(var(--rot)*1.4)) scale(.9)}}@media (prefers-reduced-motion:reduce){#fsClawdHero .cw,#fsClawdHero .cw .chNeu,#fsClawdHero .cw .chLau{animation:none}#fsClawdHero .cw .chLau{opacity:0}#fsClawdHero .chConf{display:none}}
1945
+ #fsClawdHero .cstage{position:relative;left:-13px;width:170px;height:132px;display:flex;align-items:flex-end;justify-content:center;margin:0 auto 2px}#fsClawdHero .cw{position:relative;width:150px;transform-origin:50% 100%;will-change:transform;animation:chHop 2.4s linear infinite}#fsClawdHero .cw img{position:absolute;left:0;bottom:0;width:100%;display:block;image-rendering:pixelated}#fsClawdHero .cw .chNeu{animation:chNeu 2.4s linear infinite}#fsClawdHero .cw .chLau{opacity:0;animation:chLau 2.4s linear infinite}#fsClawdHero .chConf{position:absolute;left:50%;top:34px;width:0;height:0;pointer-events:none;z-index:3}#fsClawdHero .chConf i{position:absolute;width:4px;height:7px;border-radius:1.5px;opacity:0;transform:translate(-50%,-50%);animation:chPop 2.4s cubic-bezier(.2,.7,.3,1) infinite}@keyframes chHop{0%,52%{transform:translateY(0) scale(1,1)}55%{transform:translateY(0) scale(1.08,.85)}58%{transform:translateY(-16px) scale(.95,1.14)}61%{transform:translateY(-27px) scale(.98,1.05)}64%{transform:translateY(-31px) scale(1,1)}67%{transform:translateY(-26px) scale(.98,1.05)}70%{transform:translateY(-14px) scale(.95,1.12)}73%{transform:translateY(0) scale(1.1,.82)}77%{transform:translateY(-4px) scale(.99,1.02)}80%,100%{transform:translateY(0) scale(1,1)}}@keyframes chLau{0%,53.8%{opacity:0}54%,85.8%{opacity:1}86%,100%{opacity:0}}@keyframes chNeu{0%,53.8%{opacity:1}54%,85.8%{opacity:0}86%,100%{opacity:1}}@keyframes chPop{0%,54%{opacity:0;transform:translate(0,6px) rotate(0) scale(.5)}60%{opacity:1;transform:translate(calc(var(--dx)*.5),calc(var(--dy)*.5)) rotate(calc(var(--rot)*.5)) scale(1)}72%{opacity:1;transform:translate(var(--dx),var(--dy)) rotate(var(--rot))}100%{opacity:0;transform:translate(calc(var(--dx)*1.1),calc(var(--dy) + 44px)) rotate(calc(var(--rot)*1.4)) scale(.9)}}@media (prefers-reduced-motion:reduce){#fsClawdHero .cw,#fsClawdHero .cw .chNeu,#fsClawdHero .cw .chLau{animation:none}#fsClawdHero .cw .chLau{opacity:0}#fsClawdHero .chConf{display:none}}
1924
1946
  #fsRoot.clawdhero #fsClawdHero{display:block}
1925
1947
  /* Non-Claude agent (e.g. Codex) hero: no Clawd mascot (that's a Claude Code brand mark,
1926
1948
  DESIGN-RULES v3 §6.5) — a plain glyph badge stands in so the hero still names the
@@ -2090,6 +2112,12 @@
2090
2112
  <div class="errbar" id="errbar"></div>
2091
2113
  <div class="composer-wrap" id="composerWrap">
2092
2114
  <div class="queue" id="queue"></div>
2115
+ <!-- Conversation-first composer (SLICE 2, docs/HANDOFF-ONBOARDING-conversational.md):
2116
+ a chat/help/settings message gets a quiet reply HERE instead of
2117
+ becoming a To-Do. Reuses the .peraanswer surface; hidden until a
2118
+ reply exists (progressive disclosure — DESIGN-RULES §1.5-3, "存在
2119
+ しないものの器を先に見せない"). -->
2120
+ <div class="peraanswer" id="chatReply" hidden></div>
2093
2121
  <div class="composer">
2094
2122
  <div class="attachbar" id="attachbar" style="display:none"></div>
2095
2123
  <!-- Slash-command palette (Masa決定 2026-07-07: chips→slash). Floats
@@ -2175,7 +2203,6 @@
2175
2203
  <div id="fsRoot" data-rv="3">
2176
2204
  <div class="fscard fsrail">
2177
2205
  <button class="railtg" id="fsRailTg" title="Fold the rail"></button><!-- panel icon filled by tgUpdate (v45 §4) -->
2178
- <div class="railgrip" id="fsRgrip" title="Drag to resize"></div>
2179
2206
  <button class="fsprojbtn" id="fsProjBtn" title="Switch project"><span class="pname" id="fsProjBtnName"></span><svg viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></button>
2180
2207
  <div class="fslogo"><span class="logoring"></span><span class="logoname">Galda</span></div>
2181
2208
  <!-- gear = RIGHT END of the Projects row (HANDOFF-v45 §2, flagship .lbrow port):
@@ -2193,6 +2220,10 @@
2193
2220
  <button class="fshamb" id="fsHamburger" title="Open To Do and Review"><svg viewBox="0 0 24 24"><path d="M5 7h14M5 12h14M5 17h14"/></svg></button>
2194
2221
  <div id="fsProjects"></div>
2195
2222
  </div>
2223
+ <!-- .railgrip lives here (sibling of .fsrail, not a child — see the CSS comment above)
2224
+ so it can straddle the rail's visible edge without being clipped by .fsrail's
2225
+ overflow:hidden (Masa 2026-07-16). -->
2226
+ <div class="railgrip" id="fsRgrip" title="Drag to resize"></div>
2196
2227
  <div class="fscard fscenter">
2197
2228
  <div id="fsBand"></div>
2198
2229
  <button id="fsBandBtn" title="Change the band image"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 15l5-4 4 3 4-5 5 6"/></svg></button>
@@ -2274,7 +2305,7 @@
2274
2305
  <div class="mrow">
2275
2306
  <span style="flex:1"></span>
2276
2307
  <button class="mbtn ghost" id="wfcancel">Cancel</button>
2277
- <button class="mbtn" id="wfsave">Save</button>
2308
+ <button class="mbtn primary" id="wfsave">Save</button>
2278
2309
  </div>
2279
2310
  <div class="wferr" id="wferr"></div>
2280
2311
  </div>
@@ -5347,10 +5378,15 @@ function applyFsBand(){
5347
5378
  const CLAWD_HERO_IMGS = '<img class="chNeu" src="data:image/png;base64,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" alt=""><img class="chLau" src="data:image/png;base64,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" alt=""></div></div>';
5348
5379
  function clawdHeroHTML(agent){
5349
5380
  const app = connectedAppMeta(agent);
5381
+ // Codex: wordmark + status only — no Clawd mascot (that's a Claude Code brand
5382
+ // mark, §6.5) and no glyph badge either (Masa 2026-07-16: 「codexの時は文字だけでよい」).
5350
5383
  const stage = agent === 'codex'
5351
- ? `<div class="cstage"><div class="chBadge">${app.icon}</div></div>`
5384
+ ? ''
5352
5385
  : `<div class="cstage"><div class="chConf"></div><div class="cw">${CLAWD_HERO_IMGS}`;
5353
- return stage + `<b>${esc(app.label)}</b><span>CONNECTED · WAITING FOR GOALS</span>`;
5386
+ // .hstat: the status line is the ONLY element renderClawdHero()/bootDisconnected()
5387
+ // may rewrite — never target it by bare `span` (the codex badge used to carry an
5388
+ // .appglyph <span>, so querySelector('span') clobbered the glyph with the status).
5389
+ return stage + `<b>${esc(app.label)}</b><span class="hstat">CONNECTED · WAITING FOR GOALS</span>`;
5354
5390
  }
5355
5391
  function spawnHeroConfetti(){
5356
5392
  const conf = document.querySelector('#fsClawdHero .chConf'); if (!conf) return;
@@ -5371,9 +5407,9 @@ function spawnHeroConfetti(){
5371
5407
  }
5372
5408
  function renderClawdHero(){
5373
5409
  const el = $('fsClawdHero'); if (!el) return;
5374
- const prevStatus = el.querySelector('span')?.textContent;
5410
+ const prevStatus = el.querySelector('.hstat')?.textContent;
5375
5411
  el.innerHTML = clawdHeroHTML(state.connectedApp);
5376
- if (prevStatus) { const s = el.querySelector('span'); if (s) s.textContent = prevStatus; }
5412
+ if (prevStatus) { const s = el.querySelector('.hstat'); if (s) s.textContent = prevStatus; }
5377
5413
  if (state.connectedApp !== 'codex') spawnHeroConfetti(); // Welcome P1 confetti (H17 item 4)
5378
5414
  }
5379
5415
  // one-time static wiring for the flagship shell (folds, lane resize, rail menu, band picker)
@@ -5437,10 +5473,15 @@ function renderClawdHero(){
5437
5473
  const defaultLaneW = 372, defaultRailW = 108, minReadableCenterW = 640;
5438
5474
  const canRestoreFlagshipWidths = (railW, laneW) =>
5439
5475
  window.innerWidth - 28 - 24 - railW - laneW >= minReadableCenterW;
5476
+ // rail's own live max: however wide it can go without crushing the center column below
5477
+ // minReadableCenterW, given however wide the lane currently is — no more flat 280px ceiling
5478
+ // that stopped rightward drags well short of the window edge (Masa 2026-07-16: "もっと広げ
5479
+ // られるようにできる?"). Floors at 72 (the min) on a narrow/laned-out window.
5480
+ const railMax = () => Math.max(72, window.innerWidth - 28 - 24 - lane.getBoundingClientRect().width - minReadableCenterW);
5440
5481
  const saved = Number(localStorage.getItem('fsLaneW'));
5441
5482
  const savedRail = Number(localStorage.getItem('fsRailW'));
5442
5483
  const restoreLane = saved >= 280 && saved <= 540;
5443
- const restoreRail = savedRail >= 72 && savedRail <= 280;
5484
+ const restoreRail = savedRail >= 72 && savedRail <= railMax();
5444
5485
  if (restoreLane && canRestoreFlagshipWidths(restoreRail ? savedRail : defaultRailW, saved)) {
5445
5486
  root.style.setProperty('--lanew', `${saved}px`);
5446
5487
  }
@@ -5456,16 +5497,17 @@ function renderClawdHero(){
5456
5497
  drag = false; lane.classList.remove('noanim');
5457
5498
  localStorage.setItem('fsLaneW', String(parseInt(root.style.getPropertyValue('--lanew')) || 372));
5458
5499
  });
5459
- // rail width drag: 72144px, persisted (flagship .railgrip port, v36 JS; grip hides when folded)
5500
+ // rail width drag: 72px railMax() (freely widens, only bounded by keeping the center column
5501
+ // readable), persisted (flagship .railgrip port, v36 JS; grip hides when folded)
5460
5502
  const rail = root.querySelector('.fsrail'), rgrip = $('fsRgrip');
5461
- if (restoreRail && savedRail <= 144 && canRestoreFlagshipWidths(savedRail, restoreLane ? saved : defaultLaneW)) {
5503
+ if (restoreRail && canRestoreFlagshipWidths(savedRail, restoreLane ? saved : defaultLaneW)) {
5462
5504
  root.style.setProperty('--railw', `${savedRail}px`);
5463
5505
  }
5464
5506
  let rsx = 0, rsw = 0, rdrag = false;
5465
5507
  rgrip.addEventListener('mousedown', (e) => { rdrag = true; rsx = e.clientX; rsw = rail.getBoundingClientRect().width; rail.classList.add('noanim'); e.preventDefault(); });
5466
5508
  document.addEventListener('mousemove', (e) => {
5467
5509
  if (!rdrag) return;
5468
- const w = Math.max(72, Math.min(144, rsw + (e.clientX - rsx)));
5510
+ const w = Math.max(72, Math.min(railMax(), rsw + (e.clientX - rsx)));
5469
5511
  root.style.setProperty('--railw', `${w}px`);
5470
5512
  });
5471
5513
  document.addEventListener('mouseup', () => {
@@ -5756,7 +5798,7 @@ function bootDisconnected(info){
5756
5798
  state.active = '__onboard';
5757
5799
  state.billing = { licensed: false, email };
5758
5800
  try { render(); } catch (e) { console.warn('disconnected render', e); }
5759
- const heroSpan = document.querySelector('#fsClawdHero span');
5801
+ const heroSpan = document.querySelector('#fsClawdHero .hstat');
5760
5802
  if (heroSpan) heroSpan.textContent = 'CONNECT YOUR MACHINE TO BEGIN';
5761
5803
 
5762
5804
  const style = document.createElement('style');
@@ -5827,7 +5869,17 @@ function bootDisconnected(info){
5827
5869
  const es = new EventSource('/presence');
5828
5870
  es.addEventListener('presence', (e) => {
5829
5871
  let d; try { d = JSON.parse(e.data); } catch { return; }
5830
- if (d.state === 'connected') { es.close(); location.reload(); } // one reload, only on a stable connect
5872
+ if (d.state === 'connected') {
5873
+ // Confirm the engine is REALLY reachable before reloading. A stale/spurious
5874
+ // 'connected' presence otherwise reloads us into a white flash that lands
5875
+ // right back on this same connect screen — the welcome animation plays, then
5876
+ // 一瞬真っ白 (Masa 2026-07-16). Reuse the proven 6d71c32 contract: the relay
5877
+ // 503s every engine-backed path while offline and 200s once proxied, so
5878
+ // status !== 503 flips exactly at a genuine connect.
5879
+ fetch('/api/state', { cache: 'no-store', credentials: 'same-origin' })
5880
+ .then((r) => { if (r.status !== 503) { es.close(); location.reload(); } })
5881
+ .catch(() => {}); // engine unreachable → not connected yet; keep waiting
5882
+ }
5831
5883
  else if (cgWait) cgWait.innerHTML = '<i></i>WAITING FOR THIS DEVICE';
5832
5884
  });
5833
5885
  window.addEventListener('beforeunload', () => { try { es.close(); } catch {} });
@@ -5891,6 +5943,19 @@ async function postOutboxItem(item){
5891
5943
  }
5892
5944
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
5893
5945
  const goal = await r.json();
5946
+ if (goal.kind === 'chat') {
5947
+ // Conversation-first composer (SLICE 2): this was a chat/help/settings
5948
+ // turn, not a To-Do. Retire the optimistic outbox item — a chat turn is
5949
+ // ephemeral by design, so dropping it does NOT weaken the never-lose
5950
+ // guarantee (which protects real tasks/goals). Show the reply quietly
5951
+ // above the input. We must NOT reach land()/upsert(): a kind:'chat'
5952
+ // response carries no goal id, so upserting it would wedge a ghost
5953
+ // "Sending…" row in the queue forever and pollute state.goals.
5954
+ outbox.remove(item.oid);
5955
+ showChatReply(goal.reply);
5956
+ render();
5957
+ return; // finally still runs posting.delete(); skip land()/upsert()
5958
+ }
5894
5959
  goal.imageUrls = item.imageUrls;
5895
5960
  outbox.land(item.oid, goal.id);
5896
5961
  upsert(state.goals, goal);
@@ -5902,6 +5967,17 @@ async function postOutboxItem(item){
5902
5967
  render();
5903
5968
  }
5904
5969
 
5970
+ // SLICE 2: render / clear the conversation-first composer reply (reuses the
5971
+ // .peraanswer surface). Quiet, transient — cleared the moment the user sends
5972
+ // the next message so a stale reply never lingers above a new turn.
5973
+ function showChatReply(text){
5974
+ const el = $('chatReply'); if (!el) return;
5975
+ const t = (text || '').trim();
5976
+ if (!t) { el.hidden = true; el.textContent = ''; return; }
5977
+ el.className = 'peraanswer'; el.textContent = t; el.hidden = false;
5978
+ }
5979
+ function clearChatReply(){ const el = $('chatReply'); if (el) { el.hidden = true; el.textContent = ''; } }
5980
+
5905
5981
  async function flushOutbox(){
5906
5982
  // Only (re)send items that still need it — never re-POST 'landed' memos that
5907
5983
  // are merely waiting for reconcile() (that was the double-send path).
@@ -5978,6 +6054,7 @@ async function send(){
5978
6054
  renderMsgHint();
5979
6055
 
5980
6056
  $('input').value = ''; $('input').style.height = 'auto';
6057
+ clearChatReply(); // a new turn starts — drop any prior conversational reply
5981
6058
  closePalette();
5982
6059
  localStorage.removeItem('draft');
5983
6060
  renderAttach(); render();
@@ -6738,11 +6815,27 @@ for (const b of $('langSeg').querySelectorAll('button')) b.onclick = () => saveL
6738
6815
  // true (Delete) or false (Cancel/backdrop-click/Escape) — every caller `await`s it, never
6739
6816
  // deletes on native confirm() any more.
6740
6817
  let _delConfirmResolve = null;
6741
- function confirmDelete({ title, body, preview } = {}){
6818
+ // dismissValue: what backdrop-click/Escape resolve to. Defaults to false (= the cancelLabel
6819
+ // button's own value) for every existing delete-confirmation caller, where that's the safe/no-op
6820
+ // outcome. custCancel() (Discard/Save on the customize panel) overrides this to true/"save",
6821
+ // because there Discard — not Save — is the LEFT/cancelLabel-slot button (Masa 2026-07-16: wants
6822
+ // Discard positioned left); dismissing the dialog must still land on the non-destructive choice
6823
+ // regardless of which label happens to sit in which slot.
6824
+ let _delDismissValue = false;
6825
+ function confirmDelete({ title, body, preview, confirmLabel, cancelLabel, dismissValue = false, confirmDanger = true, cancelDanger = false } = {}){
6742
6826
  $('delModalTitle').textContent = title || 'Delete this goal?';
6743
6827
  $('delModalBody').textContent = body || "This can't be undone.";
6828
+ $('delConfirmBtn').textContent = confirmLabel || 'Delete';
6829
+ $('delCancelBtn').textContent = cancelLabel || 'Cancel';
6830
+ // confirmLabel is danger-red by default (every existing caller is a real delete); cancelLabel
6831
+ // is plain ghost by default. custCancel() flips both — Discard (danger-red) sits in the
6832
+ // cancelLabel slot, Save (plain) in the confirmLabel slot — since Discard is the one Masa
6833
+ // wants positioned left, not the one that happens to be "the button confirmDelete() calls confirm".
6834
+ $('delConfirmBtn').className = 'mbtn' + (confirmDanger ? ' danger' : ' primary');
6835
+ $('delCancelBtn').className = 'mbtn' + (cancelDanger ? ' danger' : ' ghost');
6744
6836
  const pv = $('delPreviewTitle');
6745
6837
  if (preview) { pv.textContent = preview; pv.style.display = ''; } else { pv.textContent = ''; pv.style.display = 'none'; }
6838
+ _delDismissValue = dismissValue;
6746
6839
  $('deleteConfirmOverlay').classList.add('show');
6747
6840
  return new Promise((resolve) => { _delConfirmResolve = resolve; });
6748
6841
  }
@@ -6754,8 +6847,8 @@ function closeDeleteConfirm(result){
6754
6847
  }
6755
6848
  $('delCancelBtn').onclick = () => closeDeleteConfirm(false);
6756
6849
  $('delConfirmBtn').onclick = () => closeDeleteConfirm(true);
6757
- $('deleteConfirmOverlay').addEventListener('click', (e) => { if (e.target === $('deleteConfirmOverlay')) closeDeleteConfirm(false); });
6758
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('deleteConfirmOverlay').classList.contains('show')) closeDeleteConfirm(false); });
6850
+ $('deleteConfirmOverlay').addEventListener('click', (e) => { if (e.target === $('deleteConfirmOverlay')) closeDeleteConfirm(_delDismissValue); });
6851
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('deleteConfirmOverlay').classList.contains('show')) closeDeleteConfirm(_delDismissValue); });
6759
6852
  $('input').addEventListener('keydown', (e) => {
6760
6853
  // Palette navigation takes priority while it is open (Claude-Code keys).
6761
6854
  if (state.palette && !e.isComposing) {
@@ -7672,7 +7765,37 @@ function custSave(){
7672
7765
  set('tbAnim', cust.tban); set('tbColor', cust.tbc); // logoPal no longer persisted — logo is code-fixed (v45 §3)
7673
7766
  custSnap = null; $('gearPop').classList.remove('open');
7674
7767
  }
7675
- function custCancel(){
7768
+ function custHasUnsavedChanges(){
7769
+ if (!custSnap) return false;
7770
+ if (state.theme !== custSnap.theme || fsUI.band !== custSnap.band) return true;
7771
+ return JSON.stringify(cust) !== JSON.stringify(custSnap.cust);
7772
+ }
7773
+ // Discarding used to happen instantly on any outside tap / Esc / gear re-click — one careless
7774
+ // click outside the panel silently threw away everything just picked (Masa 2026-07-16: "外枠
7775
+ // タップしたら一瞬で元に戻るのがストレスだから"). Now it only reverts after confirmation, reusing
7776
+ // the same Slack-style modal as goal delete (confirmDelete), copy in the project's own review
7777
+ // language (reviewLangFor) since this is the one UI dialog whose audience is the user, not a
7778
+ // worker's report. _custCancelling guards re-entrancy: the confirm modal's own Escape/backdrop
7779
+ // handlers can re-trigger this same Escape/outside-tap listener while it's already awaiting.
7780
+ let _custCancelling = false;
7781
+ async function custCancel(){
7782
+ if (_custCancelling) return;
7783
+ if (custHasUnsavedChanges()) {
7784
+ _custCancelling = true;
7785
+ const ja = reviewLangFor(state.active) === 'ja';
7786
+ // Discard sits LEFT (Masa 2026-07-16, 2nd pass — mirrors the macOS "unsaved changes"
7787
+ // layout). Because Discard, not Save, occupies the cancelLabel slot this time, backdrop
7788
+ // click/Escape must be told explicitly to resolve as Save (dismissValue: true) — the shared
7789
+ // default (false = whatever's in the cancelLabel slot) would otherwise silently discard on
7790
+ // an accidental Escape/outside-click on the CONFIRM DIALOG ITSELF, the exact class of bug
7791
+ // this whole feature exists to prevent.
7792
+ const saveChosen = await confirmDelete(ja
7793
+ ? { title: '変更を保存しますか?', body: '配色・テーマの変更が保存されていません。', confirmLabel: '保存', cancelLabel: '破棄', confirmDanger: false, cancelDanger: true, dismissValue: true }
7794
+ : { title: 'Save your changes?', body: "Your color and theme customizations haven't been saved.", confirmLabel: 'Save', cancelLabel: 'Discard', confirmDanger: false, cancelDanger: true, dismissValue: true });
7795
+ _custCancelling = false;
7796
+ if (saveChosen) { custSave(); return; }
7797
+ // else: Discard was explicitly clicked (backdrop/Escape can no longer produce this) — revert below.
7798
+ }
7676
7799
  if (custSnap) { Object.assign(cust, custSnap.cust); state.theme = custSnap.theme; fsUI.band = custSnap.band; custSnap = null; custApplyAll(); }
7677
7800
  $('gearPop').classList.remove('open');
7678
7801
  }
package/engine/lib.mjs CHANGED
@@ -708,31 +708,71 @@ export function trimTaskActivityForState(tasks) {
708
708
  }
709
709
 
710
710
  // PROOF.md for a goal PR: one section per verified task, GIF auto-plays.
711
- export function buildGoalProofMd({ goalText, items }) {
711
+ // `lang` picks a single language (matches the goal's own request language,
712
+ // same detection as the PR body) instead of the old EN+JA bilingual dump —
713
+ // a proof doc that mixes both isn't "in the requester's language".
714
+ export function buildGoalProofMd({ goalText, items, lang = 'ja' }) {
715
+ const L = (en, ja) => (lang === 'en' ? en : ja);
712
716
  return [
713
- `# Proof / 動作確認`,
717
+ L('# Proof', '# 動作確認'),
714
718
  ``,
715
- `Goal / ゴール: ${String(goalText ?? '').replace(/\s+/g, ' ').slice(0, 300)}`,
719
+ `${L('Goal', 'ゴール')}: ${String(goalText ?? '').replace(/\s+/g, ' ').slice(0, 300)}`,
716
720
  ``,
717
- `Each task below was verified independently by Manager for AI in a headless browser (deterministic check, no LLM at verification time).`,
718
- `以下の各タスクは Manager for AI が headless ブラウザで独立検証しました(検証はdeterministic・検証時にLLMは使いません)。`,
721
+ L('Each task below was verified independently by Manager for AI in a headless browser (deterministic check, no LLM at verification time).',
722
+ '以下の各タスクは Manager for AI が headless ブラウザで独立検証しました(検証はdeterministic・検証時にLLMは使いません)。'),
719
723
  ``,
720
724
  ...items.flatMap((it) => [
721
725
  `## ${it.num} ${it.title}`,
722
726
  ``,
723
- `Pass condition / 成功条件: ${it.passCondition}`,
727
+ `${L('Pass condition', '成功条件')}: ${it.passCondition}`,
724
728
  ``,
725
- it.beforeShotName ? `Before / 変更前:\n\n![before](./${it.beforeShotName})\n` : '',
726
- it.beforeShotName ? `After / 変更後:\n` : '',
729
+ it.beforeShotName ? `${L('Before', '変更前')}:\n\n![before](./${it.beforeShotName})\n` : '',
730
+ it.beforeShotName ? `${L('After', '変更後')}:\n` : '',
727
731
  it.gifName ? `![demo](./${it.gifName})\n` : '',
728
732
  it.shotName ? `![proof](./${it.shotName})\n` : '',
729
- it.videoName ? `Full-quality video / 高画質mp4: [${it.videoName}](./${it.videoName})\n` : '',
730
- `Result / 結果: ${it.detail}`,
733
+ it.videoName ? `${L('Full-quality video', '高画質mp4')}: [${it.videoName}](./${it.videoName})\n` : '',
734
+ `${L('Result', '結果')}: ${it.detail}`,
731
735
  ``,
732
736
  ]),
733
737
  ].filter((l) => l !== '').join('\n');
734
738
  }
735
739
 
740
+ // PR body for a finished goal (Masa 2026-07-16: "1時間後に見ても一目で分かる"):
741
+ // the plain-language "what to check / what changed" headline (already
742
+ // generated per-goal by summarizeForReview, same request-language `lang`)
743
+ // goes FIRST, above proof/goal/task detail, so a reviewer returning later
744
+ // doesn't have to re-read the raw worker log to know what to look at.
745
+ export function buildGoalPrBody({ lang, goal, goalTasks, files, owner, branch, proofRel, proofItemsCount, reviewDescription, reviewSummary }) {
746
+ const L = (en, ja) => (lang === 'en' ? en : ja);
747
+ return [
748
+ ...((reviewSummary?.check || reviewSummary?.changed) ? [
749
+ L('## Check first', '## 確認ポイント'),
750
+ '',
751
+ ...(reviewSummary.check ? [L(`Check: ${reviewSummary.check}`, `確認: ${reviewSummary.check}`)] : []),
752
+ ...(reviewSummary.changed ? [L(`Changed: ${reviewSummary.changed}`, `変更: ${reviewSummary.changed}`)] : []),
753
+ '',
754
+ ] : []),
755
+ ...(proofItemsCount && owner ? [
756
+ L('## Proof — one tap, plays in place', '## 動作確認 — 1タップでその場で再生'),
757
+ '',
758
+ `https://github.com/${owner}/blob/${branch}/${proofRel}/PROOF.md`,
759
+ '',
760
+ ] : []),
761
+ L('## Goal', '## ゴール'),
762
+ '',
763
+ goal.text.slice(0, 1500),
764
+ '',
765
+ ...buildReviewRuleSection(reviewDescription),
766
+ L(`Implemented by Manager for AI: the goal was decomposed into ${goalTasks.length} task(s), each executed by a Claude Code worker (flat-rate, no extra API), tests written and run per task.${proofItemsCount ? ' Tasks with a pass condition were verified independently in a headless browser.' : ''}`,
767
+ `Manager for AI が実装: ゴールを${goalTasks.length}個のタスクに分解し、Claude Code worker が順に実行(既存サブスク・追加API無し・タスク毎にテストを作成し実行)。${proofItemsCount ? '成功条件つきタスクは headless ブラウザで独立検証済み。' : ''}`),
768
+ '',
769
+ L('## Tasks and worker reports', '## タスクとworker報告'),
770
+ ...goalTasks.map((t) => `\n### ${t.num} ${t.title} — ${t.status} (${t.secs}s)\n${(t.result ?? '').slice(0, 800)}`),
771
+ '',
772
+ L(`Changed files: ${files.map((f) => `\`${f}\``).join(' ')}`, `変更ファイル: ${files.map((f) => `\`${f}\``).join(' ')}`),
773
+ ].join('\n');
774
+ }
775
+
736
776
  // A finished task's changed files look like a UI edit: app/ (the Manager's
737
777
  // own UI dir, or an equivalent frontend root) plus common frontend source
738
778
  // extensions. Pure/deterministic — no LLM — so it can gate proof capture
@@ -1130,6 +1170,72 @@ export function detectRequestLanguage(text) {
1130
1170
  return /[぀-ヿ㐀-鿿]/.test(text || '') ? 'ja' : 'en';
1131
1171
  }
1132
1172
 
1173
+ // SLICE 2 intent routing (docs/HANDOFF-ONBOARDING-conversational.md): the single
1174
+ // composer is conversation-first — a chat/help/settings message ("what can you
1175
+ // do?", "reply in English", a greeting) must get a conversational reply, NOT a
1176
+ // To-Do; only implementation requests become goals. These are the pure,
1177
+ // testable pieces; the server wires them into POST /api/tasks.
1178
+ //
1179
+ // A dependency-free heuristic that short-circuits only the OBVIOUS cases so most
1180
+ // sends skip the LLM classifier:
1181
+ // 'task' — a clear implementation request (imperative verb / code token) and
1182
+ // no chat signal → skip the LLM, go straight to goal creation.
1183
+ // 'chat' — a clear help/settings/greeting message and no task signal.
1184
+ // 'unsure' — ambiguous (both signals, or neither) → defer to the LLM.
1185
+ // It is deliberately conservative about declaring 'task' (the only branch that
1186
+ // skips the LLM): a message carrying BOTH a task verb and a help/settings signal
1187
+ // is 'unsure', not 'task', so a help question phrased with "add"/"fix" isn't
1188
+ // silently turned into a goal.
1189
+ const INTENT_TASK_RE = /\b(fix|add|implement|create|build|refactor|remove|delete|rename|migrate|debug|integrate|revert|deploy)\b|実装|直し|直す|なおして|修正|作って|作成|追加|変更|削除|対応して|リファクタ|置き換え|バグ|不具合/i;
1190
+ const INTENT_CODE_RE = /\.(?:js|mjs|cjs|ts|tsx|jsx|css|scss|html|py|go|rs|java|rb|php|json|md|yml|yaml|sh|sql)\b|`[^`]+`|\b(?:function|const|class|import)\b|#\d+/;
1191
+ const INTENT_CHAT_META_RE = /何ができ|なにができ|何が出来|使い方|どう使|どうやって使|使える\?|説明して|教えて(?:ください|くれ|ほしい)?\s*[??]?$|about this (?:tool|app)|what (?:can|do|are|is|does) (?:you|it|this|i)|what'?s this|what can this|how (?:do|does|can) (?:i|this|you|it)|how to use|who are you|^\s*help\s*[??]?$|そもそも|初めて/i;
1192
+ const INTENT_CHAT_SETTINGS_RE = /英語で|日本語で|(?:reply|respond|answer|talk|speak|write)\s+(?:back\s+)?in\s+\w|語で(?:返|答|話|書)|言語/i;
1193
+ const INTENT_GREETING_RE = /^\s*(?:hi|hey|hello|yo|sup|thanks|thank you|thx|good (?:morning|evening|afternoon))\b[\s.!?]*$|^\s*(?:こんにち(?:は|わ)|こんばん(?:は|わ)|おはよう|はじめまして|やあ|ありがと(?:う)?|よろしく)[\s。!?!?]*$/i;
1194
+
1195
+ export function classifyComposerIntentHeuristic(text) {
1196
+ const t = (text || '').trim();
1197
+ if (!t) return 'chat';
1198
+ const hasTask = INTENT_TASK_RE.test(t) || INTENT_CODE_RE.test(t);
1199
+ const hasChat = INTENT_CHAT_META_RE.test(t) || INTENT_CHAT_SETTINGS_RE.test(t) || INTENT_GREETING_RE.test(t);
1200
+ if (hasTask && !hasChat) return 'task'; // pure implementation request → skip LLM
1201
+ if (hasChat && !hasTask) return 'chat'; // pure help/settings/greeting
1202
+ return 'unsure'; // both or neither → let the LLM decide
1203
+ }
1204
+
1205
+ // The one prompt that BOTH classifies and (for chat) writes the reply in the
1206
+ // requester's language — one Haiku round-trip, not two. The model returns a
1207
+ // single line of JSON: {"intent":"chat|task","reply":"…"}.
1208
+ export function buildIntentPrompt(text, lang) {
1209
+ const language = lang === 'ja' ? '日本語' : 'English';
1210
+ return [
1211
+ 'You are the intent router for Agent Manager: a tool where a user delegates a coding task in plain language, an AI implements it, and it comes back as a reviewed pull request with proof.',
1212
+ 'Classify the user message below into exactly one intent:',
1213
+ '- "task": a request to implement, change, fix, build, or remove something in code (even if vague, e.g. "add dark mode").',
1214
+ '- "chat": anything that is NOT an implementation request — questions about what this tool can do or how to use it, greetings, thanks, small talk, or a settings/preference request (e.g. "reply in English").',
1215
+ 'When in doubt between the two, prefer "task".',
1216
+ `If intent is "chat", also write "reply": a short, warm, helpful answer in ${language} (at most 4 sentences). If the user asks what this can do or how to start, briefly explain: describe the change you want in plain language, and it will ask which folder/repo to work in, implement it, and return a PR with proof. If intent is "task", set "reply" to "".`,
1217
+ 'Output ONLY one line of raw JSON with keys "intent" and "reply". No markdown, no code fence, no extra text.',
1218
+ '',
1219
+ 'User message:',
1220
+ String(text || '').slice(0, 4000),
1221
+ ].join('\n');
1222
+ }
1223
+
1224
+ // Tolerant parser for the router's reply — never throws. Extracts the first JSON
1225
+ // object; on any failure it fails safe to 'task' (so a garbled classification
1226
+ // can never silently swallow a real implementation request). Reply is clamped.
1227
+ export function parseIntentResponse(raw) {
1228
+ const fallback = { intent: 'task', reply: '' };
1229
+ if (!raw || typeof raw !== 'string') return fallback;
1230
+ const m = raw.match(/\{[\s\S]*\}/);
1231
+ if (!m) return fallback;
1232
+ let obj;
1233
+ try { obj = JSON.parse(m[0]); } catch { return fallback; }
1234
+ const intent = obj && obj.intent === 'chat' ? 'chat' : 'task';
1235
+ const reply = intent === 'chat' && typeof obj.reply === 'string' ? obj.reply.trim().slice(0, 2000) : '';
1236
+ return { intent, reply };
1237
+ }
1238
+
1133
1239
  // Normalize a nested boolean-flag object (prSections/reviewCard) key by key,
1134
1240
  // so a partial payload — or one missing the whole sub-object — can't
1135
1241
  // silently disable flags it never mentioned; each key falls back to its own
@@ -2485,6 +2591,56 @@ export function resolveFolderAnswer(answer, folders = []) {
2485
2591
  return null;
2486
2592
  }
2487
2593
 
2594
+ // Onboarding funnel, part 2: the user picked "somewhere else…" and typed a path
2595
+ // that ISN'T already a git repo. Before, the server just re-asked the same
2596
+ // folder question forever (a repo-less / vibe-coder user could never get past
2597
+ // setup). This decides what to offer instead. All filesystem probes (does it
2598
+ // exist / is it a directory / is it already a repo) are done by the CALLER and
2599
+ // passed in, so this stays pure/testable. Returns:
2600
+ // { action: 'use' } already a repo → bind as-is
2601
+ // { action: 'init', create: false } existing non-repo dir → offer `git init` here
2602
+ // { action: 'init', create: true } path doesn't exist yet → offer to create it + `git init`
2603
+ // { action: 'reject', reason } HOME (never a work root) or a path that is a file → re-ask
2604
+ export function classifyFolderTarget({ dir, homeDir, exists, isDirectory, isRepo } = {}) {
2605
+ if (!dir) return { action: 'reject', reason: 'empty' };
2606
+ const norm = (p) => String(p).replace(/\/+$/, '');
2607
+ if (homeDir && norm(dir) === norm(homeDir)) return { action: 'reject', reason: 'home' };
2608
+ if (isRepo) return { action: 'use' };
2609
+ if (!exists) return { action: 'init', create: true };
2610
+ if (!isDirectory) return { action: 'reject', reason: 'not-a-dir' };
2611
+ return { action: 'init', create: false };
2612
+ }
2613
+
2614
+ // Build the confirm card shown before we `git init` a non-repo folder. Reuses
2615
+ // the same needsInput card shape ({text, options:string[]}) but tags it
2616
+ // kind:'folder-init' so the /answer handler runs the init (instead of another
2617
+ // folder rebind). `create` = the folder doesn't exist yet and we'll make it.
2618
+ // The chosen dir + create flag ride along on the question so the handler is
2619
+ // authoritative about WHAT it's initializing (never re-derived from free text).
2620
+ export function buildFolderInitConfirm(dir, { lang = 'ja', create = false, homeDir = '' } = {}) {
2621
+ const label = folderLabel(dir, homeDir);
2622
+ const yes = lang === 'en'
2623
+ ? (create ? 'Create it and start a new project here' : 'Start a new project here')
2624
+ : (create ? '作成して、ここで新規プロジェクトを始める' : 'ここで新規プロジェクトを始める');
2625
+ const no = lang === 'en' ? 'Pick another folder…' : '別の場所を選ぶ…';
2626
+ const text = lang === 'en'
2627
+ ? `“${label}” isn’t a Git repository yet. ${create ? 'Create it and start' : 'Start'} a new project here (git init)? Your existing files won’t be touched.`
2628
+ : `「${label}」はまだGitリポジトリではありません。${create ? '作成して、' : ''}ここで新しいプロジェクトを始めますか?(git init・既存のファイルには手を触れません)`;
2629
+ return { text, options: [yes, no], kind: 'folder-init', lang, dir, create, yes, no };
2630
+ }
2631
+
2632
+ // Interpret the confirm-card answer → true (do the init) / false (decline →
2633
+ // re-ask the folder question). Match the exact "yes" option we offered, or a
2634
+ // plain affirmative word; anything else (incl. the "pick another folder" option)
2635
+ // declines. Defaults to decline on empty/unknown so we never init by accident.
2636
+ export function isFolderInitConfirmed(answer, question = {}) {
2637
+ const a = String(answer ?? '').trim();
2638
+ if (!a) return false;
2639
+ if (question.yes && a === question.yes) return true;
2640
+ if (question.no && a === question.no) return false;
2641
+ return /^(yes|y|はい|ok|する|始める)$/i.test(a);
2642
+ }
2643
+
2488
2644
  // Analytics identity (A+): a signed-in install scopes usage events to the
2489
2645
  // ACCOUNT (its email, which the billing Worker hashes into the same account_id
2490
2646
  // the accounts table carries) so per-user app activity joins WITHOUT a cookie;
package/engine/server.mjs CHANGED
@@ -20,8 +20,8 @@ import { resolve, dirname, join, basename } from 'node:path';
20
20
  import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
- import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer } from './lib.mjs';
24
- import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable } from './lib.mjs';
23
+ import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed } from './lib.mjs';
24
+ import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse } from './lib.mjs';
25
25
  import { openPR } from './pr.mjs';
26
26
  import { runVerification, exerciseUi } from './verify.mjs';
27
27
 
@@ -582,7 +582,19 @@ function sendGoalEvent(obj, goal) {
582
582
  if (goalVisibleTo(goal, identity, MANAGER_OWNER_EMAIL)) res.write(data);
583
583
  }
584
584
  }
585
- function saveGoal(g) { appendFileSync(logFile, JSON.stringify({ ...g, kind: 'goal' }) + '\n'); sendGoalEvent({ ev: 'goal', goal: g }, g); }
585
+ // Durable append to the restart-safe log. A failing disk (ENOSPC when the
586
+ // volume is full, EACCES/EROFS) must NEVER freeze the board: without this guard
587
+ // an appendFileSync throw inside a handler leaves the HTTP request hanging (no
588
+ // response) AND skips the SSE broadcast that follows, so a delete/answer looks
589
+ // like it did nothing — the question card stays stuck forever (Masa 2026-07-16).
590
+ // We swallow the write error (logged), and let the in-memory mutation + SSE
591
+ // proceed regardless. Durability is best-effort here; a live UI wins over a
592
+ // perfectly-replayable log on a disk that can't be written to anyway.
593
+ function logAppend(line) {
594
+ try { appendFileSync(logFile, line + '\n'); return true; }
595
+ catch (e) { console.error(`[manager] log write failed (${e?.code || e}) — state not persisted this write, UI still updated`); return false; }
596
+ }
597
+ function saveGoal(g) { logAppend(JSON.stringify({ ...g, kind: 'goal' })); sendGoalEvent({ ev: 'goal', goal: g }, g); }
586
598
  // Persist the orphan-goal reclassification computed right after replayQueueLog
587
599
  // above (deferred to here because saveGoal/send/sseClients don't exist yet at
588
600
  // that point in the module) — writes it to the restart-safe log so it settles
@@ -594,7 +606,7 @@ for (const fix of orphanFixes) {
594
606
  }
595
607
  function saveTask(t) {
596
608
  const { activity, todos, ...persist } = t; // activity + todos are ephemeral (live only)
597
- appendFileSync(logFile, JSON.stringify({ ...persist, kind: 'task' }) + '\n');
609
+ logAppend(JSON.stringify({ ...persist, kind: 'task' }));
598
610
  const goal = goals.find((g) => g.id === t.goalId);
599
611
  if (goal) sendGoalEvent({ ev: 'task', task: persist }, goal); else send({ ev: 'task', task: persist });
600
612
  emitTaskAnalytics(t);
@@ -2160,6 +2172,11 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
2160
2172
  // point (all its tasks are done), so its conflictsWith is accurate even if
2161
2173
  // PR creation itself fails (e.g. no `gh` auth) or is still no-op'd (no code).
2162
2174
  updateGoalConflicts(goal);
2175
+ // Computed BEFORE createGoalPR (not inside verifyGate, which used to run
2176
+ // after) so the PR body's "確認ポイント" headline is available the first
2177
+ // time the PR is opened, not only on the review card (Masa 2026-07-16).
2178
+ goalAct('Writing the review summary from the worker report and diff.');
2179
+ goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
2163
2180
  goalAct('Checking changed files and preparing the PR branch if code changed.');
2164
2181
  await createGoalPR(goal, prProject, siblings, reviewDefinition, activityTask);
2165
2182
  // goal 427 §4: classify the change's risk from the SAME changed-files/diff
@@ -2228,8 +2245,13 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2228
2245
  goal.testResult = { ran: false, skipped: true, skippedReason: changedFiles.length ? 'documentation-only change' : 'no changed files' };
2229
2246
  goalAct(`Skipped project tests: ${goal.testResult.skippedReason}.`);
2230
2247
  }
2231
- goalAct('Writing the review summary from the worker report and diff.');
2232
- goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
2248
+ // Normally already set by finishGoalIfComplete before the PR was opened
2249
+ // (so the PR body can include it too); only fall back here for the
2250
+ // standalone /reverify path or any older goal that never got one.
2251
+ if (!goal.reviewSummary) {
2252
+ goalAct('Writing the review summary from the worker report and diff.');
2253
+ goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
2254
+ }
2233
2255
  const uiTasks = siblings.filter((t) => isUiChange(t.changedFiles));
2234
2256
  // Only require/capture proof for projects we can actually boot & screenshot
2235
2257
  // (Manager-shaped). A generic web repo that edits a stylesheet can't be
@@ -2371,26 +2393,12 @@ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT
2371
2393
  // (Masa: 「ユーザーの言語で」).
2372
2394
  const lang = reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : (reviewDefinition.language || 'ja');
2373
2395
  const L = (en, ja) => (lang === 'en' ? en : ja);
2374
- const bodyFor = (branch) => [
2375
- ...(proofItems.length && owner ? [
2376
- L('## Proof — one tap, plays in place', '## 動作確認 — 1タップでその場で再生'),
2377
- '',
2378
- `https://github.com/${owner}/blob/${branch}/${proofRel}/PROOF.md`,
2379
- '',
2380
- ] : []),
2381
- L('## Goal', '## ゴール'),
2382
- '',
2383
- goal.text.slice(0, 1500),
2384
- '',
2385
- ...buildReviewRuleSection(reviewDefinition.description),
2386
- L(`Implemented by Manager for AI: the goal was decomposed into ${goalTasks.length} task(s), each executed by a Claude Code worker (flat-rate, no extra API), tests written and run per task.${proofItems.length ? ' Tasks with a pass condition were verified independently in a headless browser.' : ''}`,
2387
- `Manager for AI が実装: ゴールを${goalTasks.length}個のタスクに分解し、Claude Code worker が順に実行(既存サブスク・追加API無し・タスク毎にテストを作成し実行)。${proofItems.length ? '成功条件つきタスクは headless ブラウザで独立検証済み。' : ''}`),
2388
- '',
2389
- L('## Tasks and worker reports', '## タスクとworker報告'),
2390
- ...goalTasks.map((t) => `\n### ${t.num} ${t.title} — ${t.status} (${t.secs}s)\n${(t.result ?? '').slice(0, 800)}`),
2391
- '',
2392
- L(`Changed files: ${files.map((f) => `\`${f}\``).join(' ')}`, `変更ファイル: ${files.map((f) => `\`${f}\``).join(' ')}`),
2393
- ].join('\n');
2396
+ const bodyFor = (branch) => buildGoalPrBody({
2397
+ lang, goal, goalTasks, files, owner, branch, proofRel,
2398
+ proofItemsCount: proofItems.length,
2399
+ reviewDescription: reviewDefinition.description,
2400
+ reviewSummary: goal.reviewSummary,
2401
+ });
2394
2402
  const title = `Manager for AI: ${isRework ? L('rework — ', '再作業 — ') : ''}${goal.plan?.[0] ?? goal.text.slice(0, 60)}${goal.plan?.length > 1 ? L(` +${goal.plan.length - 1} more`, ` 他${goal.plan.length - 1}件`) : ''}`;
2395
2403
  const { url, branch } = openPR({
2396
2404
  repoDir: project.dir, slug: `goal-${goal.id}`,
@@ -2401,7 +2409,7 @@ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT
2401
2409
  extraCopies,
2402
2410
  extraWrites: proofItems.length ? [{
2403
2411
  destRel: `${proofRel}/PROOF.md`,
2404
- content: buildGoalProofMd({ goalText: goal.text, items: proofItems }),
2412
+ content: buildGoalProofMd({ goalText: goal.text, items: proofItems, lang }),
2405
2413
  }] : [],
2406
2414
  ...(isRework ? { existingBranch: goal.prBranch, existingPrUrl: goal.pr } : {}),
2407
2415
  });
@@ -2705,6 +2713,32 @@ const server = createServer(async (req, res) => {
2705
2713
  const { projectId, text, pr, images, model, effort, source, pending, review, note, mode, skill, agent } = JSON.parse(body);
2706
2714
  const project = projects.find((p) => p.id === projectId);
2707
2715
  if (!project || !text?.trim()) return json(res, 400, { error: 'projectId and text required' });
2716
+ // Onboarding (docs/HANDOFF-ONBOARDING-conversational.md, SLICE 2): the
2717
+ // composer is conversation-first. A chat/help/settings message ("what can
2718
+ // you do?", "reply in English", a greeting) gets a conversational reply
2719
+ // and must NOT become a To-Do. Classified BEFORE dedupe/billing/goal
2720
+ // creation, so a chat turn never folds into a goal, never counts against
2721
+ // the free-tier cap, and never persists a task. /later (pending) and
2722
+ // /review are explicit user intent → skip classification. A cheap
2723
+ // heuristic short-circuits obvious implementation requests (no LLM);
2724
+ // anything else gets one Haiku round-trip that both classifies and writes
2725
+ // the reply. LLM failure falls back to 'task' — never drop a real
2726
+ // implementation ask (the folder guard below still prevents no-ops).
2727
+ if (!pending && !review && classifyComposerIntentHeuristic(text.trim()) === 'chat') {
2728
+ // Only a HIGH-confidence chat/help/settings message reaches the LLM —
2729
+ // to write the reply, and to let the model veto back to 'task' if the
2730
+ // heuristic over-fired. Everything else (clear tasks AND ambiguous
2731
+ // 'unsure') goes straight to goal creation: bias to task keeps the
2732
+ // common path LLM-free (no latency/tokens on real work) and the folder
2733
+ // guard below still prevents no-ops. LLM failure → treat as task and
2734
+ // fall through, so a real implementation ask is never dropped.
2735
+ const intentLang = detectRequestLanguage(text);
2736
+ try {
2737
+ const r = await runClaude({ prompt: buildIntentPrompt(text.trim(), intentLang), cwd: ROOT, tools: 'Read', model: 'haiku' });
2738
+ const parsed = parseIntentResponse(r.result);
2739
+ if (parsed.intent === 'chat') return json(res, 200, { kind: 'chat', reply: parsed.reply.slice(0, 2000), lang: intentLang });
2740
+ } catch { /* LLM unavailable → fall through to goal creation */ }
2741
+ }
2708
2742
  // Dedupe (Masa: 被ったやつもまとめて): if this near-duplicates a goal
2709
2743
  // already in flight (running/review with a live worker session), fold it
2710
2744
  // in as a thread follow-up (context kept via session resume) instead of
@@ -2854,7 +2888,7 @@ const server = createServer(async (req, res) => {
2854
2888
  const q = queues.get(goal.projectId);
2855
2889
  if (q) q.waiting = q.waiting.filter((t) => t.goalId !== goal.id);
2856
2890
  goals = goals.filter((g) => g.id !== goal.id);
2857
- appendFileSync(logFile, JSON.stringify({ kind: 'goal', id: goal.id, deleted: true }) + '\n');
2891
+ logAppend(JSON.stringify({ kind: 'goal', id: goal.id, deleted: true }));
2858
2892
  sendGoalEvent({ ev: 'goal-deleted', id: goal.id }, goal);
2859
2893
  startNextStacked(goal.projectId);
2860
2894
  pump(goal.projectId);
@@ -3087,34 +3121,80 @@ const server = createServer(async (req, res) => {
3087
3121
  let answer = '';
3088
3122
  try { answer = String(JSON.parse(body || '{}').answer ?? '').trim(); } catch {}
3089
3123
  if (!answer) return json(res, 400, { error: 'answer required' });
3124
+ // Bind this goal to a project rooted at `dir` and start planning. Shared by
3125
+ // the "picked a repo" and "git-init'd a new folder" paths below.
3126
+ const bindAndPlan = (dir) => {
3127
+ const existing = projects.find((p) => p.dir === dir);
3128
+ let proj = existing;
3129
+ if (!proj) {
3130
+ proj = { id: uniqueProjectId(basename(dir)), name: basename(dir), dir };
3131
+ projects.push(proj); saveProjects();
3132
+ }
3133
+ goal.projectId = proj.id;
3134
+ goal.question = null;
3135
+ goal.clarified = true;
3136
+ goal.status = 'planning';
3137
+ saveGoal(goal);
3138
+ planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
3139
+ return json(res, 200, goal);
3140
+ };
3090
3141
  // Folder-choice answer (onboarding funnel): rebind the goal to the chosen
3091
3142
  // repo rather than appending to goal.text — a folder isn't a task
3092
- // clarification. Validate it's a real repo (and not HOME); if not, re-ask
3093
- // with a fresh detected list so we never fall through to a no-op run.
3143
+ // clarification. If the picked path is already a repo, bind it. If it's a
3144
+ // non-repo dir (or a path that doesn't exist yet), DON'T loop re-asking
3145
+ // offer to start a new project there via `git init` (the repo-less /
3146
+ // vibe-coder onboarding path). HOME / a file → re-ask.
3094
3147
  if (goal.question?.kind === 'folder') {
3095
3148
  const resolved = resolveFolderAnswer(answer, goal.question.folders || []);
3096
3149
  let dir = resolved?.dir || answer;
3097
3150
  if (dir.startsWith('~')) dir = join(HOME, dir.slice(1).replace(/^\/+/, ''));
3098
- if (dir !== HOME && isGitRepo(dir)) {
3099
- const existing = projects.find((p) => p.dir === dir);
3100
- let proj = existing;
3101
- if (!proj) {
3102
- proj = { id: uniqueProjectId(basename(dir)), name: basename(dir), dir };
3103
- projects.push(proj); saveProjects();
3104
- }
3105
- goal.projectId = proj.id;
3106
- goal.question = null;
3107
- goal.clarified = true;
3108
- goal.status = 'planning';
3151
+ const lang = goal.question.lang || 'ja';
3152
+ const cls = classifyFolderTarget({
3153
+ dir, homeDir: HOME,
3154
+ exists: existsSync(dir),
3155
+ isDirectory: (() => { try { return statSync(dir).isDirectory(); } catch { return false; } })(),
3156
+ isRepo: isGitRepo(dir),
3157
+ });
3158
+ if (cls.action === 'use') return bindAndPlan(dir);
3159
+ if (cls.action === 'init') {
3160
+ // Non-repo folder → confirm before we create a repo (non-destructive,
3161
+ // but a repo-less user should see what we're about to do).
3162
+ goal.question = buildFolderInitConfirm(dir, { lang, create: cls.create, homeDir: HOME });
3109
3163
  saveGoal(goal);
3110
- planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
3111
3164
  return json(res, 200, goal);
3112
3165
  }
3113
- // not a usable repo keep asking (don't run a no-op)
3114
- goal.question = buildFolderQuestion(detectRepos(HOME), { lang: goal.question.lang || 'ja' });
3166
+ // reject (HOME or a file)re-ask with a fresh detected list
3167
+ goal.question = buildFolderQuestion(detectRepos(HOME), { lang });
3115
3168
  saveGoal(goal);
3116
3169
  return json(res, 200, goal);
3117
3170
  }
3171
+ // Confirm card for "start a new project here (git init)?". Yes → run the
3172
+ // init (guarded: create the dir if needed, NEVER touch existing files,
3173
+ // NEVER re-init an existing repo), then bind + plan like a picked repo.
3174
+ // Anything else → back to the folder list.
3175
+ if (goal.question?.kind === 'folder-init') {
3176
+ const q = goal.question;
3177
+ const lang = q.lang || 'ja';
3178
+ if (!isFolderInitConfirmed(answer, q)) {
3179
+ goal.question = buildFolderQuestion(detectRepos(HOME), { lang });
3180
+ saveGoal(goal);
3181
+ return json(res, 200, goal);
3182
+ }
3183
+ const dir = q.dir;
3184
+ try {
3185
+ if (dir === HOME) throw new Error('refusing to init HOME as a project');
3186
+ if (q.create && !existsSync(dir)) mkdirSync(dir, { recursive: true });
3187
+ if (!statSync(dir).isDirectory()) throw new Error('target is not a directory');
3188
+ if (!isGitRepo(dir)) gitSync(dir, ['init', '--quiet']); // adds .git only; leaves files as-is
3189
+ } catch (e) {
3190
+ // couldn't init → don't run a no-op; surface why and re-ask
3191
+ goal.question = buildFolderQuestion(detectRepos(HOME), { lang });
3192
+ goal.prError = `git init failed: ${String(e?.message || e).slice(0, 200)}`;
3193
+ saveGoal(goal);
3194
+ return json(res, 200, goal);
3195
+ }
3196
+ return bindAndPlan(dir);
3197
+ }
3118
3198
  goal.text = `${goal.text}\n[確認: ${goal.question?.text ?? ''} → ${answer}]`;
3119
3199
  goal.clarified = true; // answered once → planGoal must never re-ask (persisted via saveGoal)
3120
3200
  goal.question = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.15",
3
+ "version": "0.10.17",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to your Claude Code, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {