@galda/cli 0.10.14 → 0.10.16

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%;
@@ -2175,7 +2192,6 @@
2175
2192
  <div id="fsRoot" data-rv="3">
2176
2193
  <div class="fscard fsrail">
2177
2194
  <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
2195
  <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
2196
  <div class="fslogo"><span class="logoring"></span><span class="logoname">Galda</span></div>
2181
2197
  <!-- gear = RIGHT END of the Projects row (HANDOFF-v45 §2, flagship .lbrow port):
@@ -2193,6 +2209,10 @@
2193
2209
  <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
2210
  <div id="fsProjects"></div>
2195
2211
  </div>
2212
+ <!-- .railgrip lives here (sibling of .fsrail, not a child — see the CSS comment above)
2213
+ so it can straddle the rail's visible edge without being clipped by .fsrail's
2214
+ overflow:hidden (Masa 2026-07-16). -->
2215
+ <div class="railgrip" id="fsRgrip" title="Drag to resize"></div>
2196
2216
  <div class="fscard fscenter">
2197
2217
  <div id="fsBand"></div>
2198
2218
  <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 +2294,7 @@
2274
2294
  <div class="mrow">
2275
2295
  <span style="flex:1"></span>
2276
2296
  <button class="mbtn ghost" id="wfcancel">Cancel</button>
2277
- <button class="mbtn" id="wfsave">Save</button>
2297
+ <button class="mbtn primary" id="wfsave">Save</button>
2278
2298
  </div>
2279
2299
  <div class="wferr" id="wferr"></div>
2280
2300
  </div>
@@ -5437,10 +5457,15 @@ function renderClawdHero(){
5437
5457
  const defaultLaneW = 372, defaultRailW = 108, minReadableCenterW = 640;
5438
5458
  const canRestoreFlagshipWidths = (railW, laneW) =>
5439
5459
  window.innerWidth - 28 - 24 - railW - laneW >= minReadableCenterW;
5460
+ // rail's own live max: however wide it can go without crushing the center column below
5461
+ // minReadableCenterW, given however wide the lane currently is — no more flat 280px ceiling
5462
+ // that stopped rightward drags well short of the window edge (Masa 2026-07-16: "もっと広げ
5463
+ // られるようにできる?"). Floors at 72 (the min) on a narrow/laned-out window.
5464
+ const railMax = () => Math.max(72, window.innerWidth - 28 - 24 - lane.getBoundingClientRect().width - minReadableCenterW);
5440
5465
  const saved = Number(localStorage.getItem('fsLaneW'));
5441
5466
  const savedRail = Number(localStorage.getItem('fsRailW'));
5442
5467
  const restoreLane = saved >= 280 && saved <= 540;
5443
- const restoreRail = savedRail >= 72 && savedRail <= 280;
5468
+ const restoreRail = savedRail >= 72 && savedRail <= railMax();
5444
5469
  if (restoreLane && canRestoreFlagshipWidths(restoreRail ? savedRail : defaultRailW, saved)) {
5445
5470
  root.style.setProperty('--lanew', `${saved}px`);
5446
5471
  }
@@ -5456,16 +5481,17 @@ function renderClawdHero(){
5456
5481
  drag = false; lane.classList.remove('noanim');
5457
5482
  localStorage.setItem('fsLaneW', String(parseInt(root.style.getPropertyValue('--lanew')) || 372));
5458
5483
  });
5459
- // rail width drag: 72144px, persisted (flagship .railgrip port, v36 JS; grip hides when folded)
5484
+ // rail width drag: 72px railMax() (freely widens, only bounded by keeping the center column
5485
+ // readable), persisted (flagship .railgrip port, v36 JS; grip hides when folded)
5460
5486
  const rail = root.querySelector('.fsrail'), rgrip = $('fsRgrip');
5461
- if (restoreRail && savedRail <= 144 && canRestoreFlagshipWidths(savedRail, restoreLane ? saved : defaultLaneW)) {
5487
+ if (restoreRail && canRestoreFlagshipWidths(savedRail, restoreLane ? saved : defaultLaneW)) {
5462
5488
  root.style.setProperty('--railw', `${savedRail}px`);
5463
5489
  }
5464
5490
  let rsx = 0, rsw = 0, rdrag = false;
5465
5491
  rgrip.addEventListener('mousedown', (e) => { rdrag = true; rsx = e.clientX; rsw = rail.getBoundingClientRect().width; rail.classList.add('noanim'); e.preventDefault(); });
5466
5492
  document.addEventListener('mousemove', (e) => {
5467
5493
  if (!rdrag) return;
5468
- const w = Math.max(72, Math.min(144, rsw + (e.clientX - rsx)));
5494
+ const w = Math.max(72, Math.min(railMax(), rsw + (e.clientX - rsx)));
5469
5495
  root.style.setProperty('--railw', `${w}px`);
5470
5496
  });
5471
5497
  document.addEventListener('mouseup', () => {
@@ -5792,7 +5818,7 @@ function bootDisconnected(info){
5792
5818
  + '<p>Galda runs on your own machine. Run this where you use Claude Code or Codex — this board comes alive the moment it connects, no refresh needed:</p>'
5793
5819
  + '<div class="cg-cmd"><code>npx @galda/cli</code><button class="cg-copy">Copy</button></div>'
5794
5820
  + '<p class="cg-who">Signed in as <b></b></p>'
5795
- + '<p class="cg-switch">Wrong account? <a href="/logout">Switch account →</a></p>'
5821
+ + '<p class="cg-switch">Already running Galda under a different account? Run <code>npx @galda/cli --signin</code> and pick this one. Or <a href="/logout">switch this browser →</a></p>'
5796
5822
  + '<div class="cg-wait"><i></i>WAITING FOR THIS DEVICE</div></div>';
5797
5823
  document.body.appendChild(ov);
5798
5824
  ov.querySelector('.cg-who b').textContent = email || 'your account';
@@ -5814,20 +5840,24 @@ function bootDisconnected(info){
5814
5840
  const cw = document.getElementById('composerWrap');
5815
5841
  if (cw) cw.addEventListener('mousedown', (e) => { if (e.target.closest('#appsel, #modelsel, #effortsel')) return; gate(e); });
5816
5842
 
5817
- // Come alive automatically: while this device's engine is offline the relay
5818
- // 503s every engine-backed path (only /, /theme.css, /fonts.css are served);
5819
- // the instant the agent connects, the same path is proxied to the live engine
5820
- // (200). So we probe an engine-only endpoint by STATUS and reload when it stops
5821
- // 503-ing. We must NOT decide this by fetching `/` and grepping its HTML for a
5822
- // sentinel: this very poll's source contains the string '__GALDA_DISCONNECTED',
5823
- // so it is baked into every proxied copy of the page — the match is ALWAYS
5824
- // "found", the reload never fires, and the tab stays stuck on the connect
5825
- // screen forever ("npx said done but nothing happens"). (fix: status, not body)
5826
- setInterval(() => {
5827
- fetch('/api/state', { cache: 'no-store', credentials: 'same-origin' })
5828
- .then((r) => { if (r.status !== 503) location.reload(); })
5829
- .catch(() => {});
5830
- }, 4000);
5843
+ // Come alive automatically WITHOUT polling-and-reloading (Masa 2026-07-16).
5844
+ // The relay pushes a 'connected' event over SSE the instant THIS account's agent
5845
+ // is STABLY online (debounced on the relay side), and we reload exactly once on
5846
+ // that edge. Event-driven + debounced makes a reload loop structurally
5847
+ // impossible: a flapping agent never reaches the stable-connect edge, so it
5848
+ // shows "reconnecting" instead of triggering reloads. This replaces the old
5849
+ // `setInterval` + `if (status !== 503) reload` poll, which amplified any agent
5850
+ // flap into an infinite reload (the ~10-failure "無限ループ").
5851
+ const cgWait = ov.querySelector('.cg-wait');
5852
+ try {
5853
+ const es = new EventSource('/presence');
5854
+ es.addEventListener('presence', (e) => {
5855
+ let d; try { d = JSON.parse(e.data); } catch { return; }
5856
+ if (d.state === 'connected') { es.close(); location.reload(); } // one reload, only on a stable connect
5857
+ else if (cgWait) cgWait.innerHTML = '<i></i>WAITING FOR THIS DEVICE';
5858
+ });
5859
+ window.addEventListener('beforeunload', () => { try { es.close(); } catch {} });
5860
+ } catch { /* no EventSource (ancient browser) → stay on the gate; never loops */ }
5831
5861
  }
5832
5862
 
5833
5863
  async function boot(){
@@ -6734,11 +6764,27 @@ for (const b of $('langSeg').querySelectorAll('button')) b.onclick = () => saveL
6734
6764
  // true (Delete) or false (Cancel/backdrop-click/Escape) — every caller `await`s it, never
6735
6765
  // deletes on native confirm() any more.
6736
6766
  let _delConfirmResolve = null;
6737
- function confirmDelete({ title, body, preview } = {}){
6767
+ // dismissValue: what backdrop-click/Escape resolve to. Defaults to false (= the cancelLabel
6768
+ // button's own value) for every existing delete-confirmation caller, where that's the safe/no-op
6769
+ // outcome. custCancel() (Discard/Save on the customize panel) overrides this to true/"save",
6770
+ // because there Discard — not Save — is the LEFT/cancelLabel-slot button (Masa 2026-07-16: wants
6771
+ // Discard positioned left); dismissing the dialog must still land on the non-destructive choice
6772
+ // regardless of which label happens to sit in which slot.
6773
+ let _delDismissValue = false;
6774
+ function confirmDelete({ title, body, preview, confirmLabel, cancelLabel, dismissValue = false, confirmDanger = true, cancelDanger = false } = {}){
6738
6775
  $('delModalTitle').textContent = title || 'Delete this goal?';
6739
6776
  $('delModalBody').textContent = body || "This can't be undone.";
6777
+ $('delConfirmBtn').textContent = confirmLabel || 'Delete';
6778
+ $('delCancelBtn').textContent = cancelLabel || 'Cancel';
6779
+ // confirmLabel is danger-red by default (every existing caller is a real delete); cancelLabel
6780
+ // is plain ghost by default. custCancel() flips both — Discard (danger-red) sits in the
6781
+ // cancelLabel slot, Save (plain) in the confirmLabel slot — since Discard is the one Masa
6782
+ // wants positioned left, not the one that happens to be "the button confirmDelete() calls confirm".
6783
+ $('delConfirmBtn').className = 'mbtn' + (confirmDanger ? ' danger' : ' primary');
6784
+ $('delCancelBtn').className = 'mbtn' + (cancelDanger ? ' danger' : ' ghost');
6740
6785
  const pv = $('delPreviewTitle');
6741
6786
  if (preview) { pv.textContent = preview; pv.style.display = ''; } else { pv.textContent = ''; pv.style.display = 'none'; }
6787
+ _delDismissValue = dismissValue;
6742
6788
  $('deleteConfirmOverlay').classList.add('show');
6743
6789
  return new Promise((resolve) => { _delConfirmResolve = resolve; });
6744
6790
  }
@@ -6750,8 +6796,8 @@ function closeDeleteConfirm(result){
6750
6796
  }
6751
6797
  $('delCancelBtn').onclick = () => closeDeleteConfirm(false);
6752
6798
  $('delConfirmBtn').onclick = () => closeDeleteConfirm(true);
6753
- $('deleteConfirmOverlay').addEventListener('click', (e) => { if (e.target === $('deleteConfirmOverlay')) closeDeleteConfirm(false); });
6754
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('deleteConfirmOverlay').classList.contains('show')) closeDeleteConfirm(false); });
6799
+ $('deleteConfirmOverlay').addEventListener('click', (e) => { if (e.target === $('deleteConfirmOverlay')) closeDeleteConfirm(_delDismissValue); });
6800
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('deleteConfirmOverlay').classList.contains('show')) closeDeleteConfirm(_delDismissValue); });
6755
6801
  $('input').addEventListener('keydown', (e) => {
6756
6802
  // Palette navigation takes priority while it is open (Claude-Code keys).
6757
6803
  if (state.palette && !e.isComposing) {
@@ -7668,7 +7714,37 @@ function custSave(){
7668
7714
  set('tbAnim', cust.tban); set('tbColor', cust.tbc); // logoPal no longer persisted — logo is code-fixed (v45 §3)
7669
7715
  custSnap = null; $('gearPop').classList.remove('open');
7670
7716
  }
7671
- function custCancel(){
7717
+ function custHasUnsavedChanges(){
7718
+ if (!custSnap) return false;
7719
+ if (state.theme !== custSnap.theme || fsUI.band !== custSnap.band) return true;
7720
+ return JSON.stringify(cust) !== JSON.stringify(custSnap.cust);
7721
+ }
7722
+ // Discarding used to happen instantly on any outside tap / Esc / gear re-click — one careless
7723
+ // click outside the panel silently threw away everything just picked (Masa 2026-07-16: "外枠
7724
+ // タップしたら一瞬で元に戻るのがストレスだから"). Now it only reverts after confirmation, reusing
7725
+ // the same Slack-style modal as goal delete (confirmDelete), copy in the project's own review
7726
+ // language (reviewLangFor) since this is the one UI dialog whose audience is the user, not a
7727
+ // worker's report. _custCancelling guards re-entrancy: the confirm modal's own Escape/backdrop
7728
+ // handlers can re-trigger this same Escape/outside-tap listener while it's already awaiting.
7729
+ let _custCancelling = false;
7730
+ async function custCancel(){
7731
+ if (_custCancelling) return;
7732
+ if (custHasUnsavedChanges()) {
7733
+ _custCancelling = true;
7734
+ const ja = reviewLangFor(state.active) === 'ja';
7735
+ // Discard sits LEFT (Masa 2026-07-16, 2nd pass — mirrors the macOS "unsaved changes"
7736
+ // layout). Because Discard, not Save, occupies the cancelLabel slot this time, backdrop
7737
+ // click/Escape must be told explicitly to resolve as Save (dismissValue: true) — the shared
7738
+ // default (false = whatever's in the cancelLabel slot) would otherwise silently discard on
7739
+ // an accidental Escape/outside-click on the CONFIRM DIALOG ITSELF, the exact class of bug
7740
+ // this whole feature exists to prevent.
7741
+ const saveChosen = await confirmDelete(ja
7742
+ ? { title: '変更を保存しますか?', body: '配色・テーマの変更が保存されていません。', confirmLabel: '保存', cancelLabel: '破棄', confirmDanger: false, cancelDanger: true, dismissValue: true }
7743
+ : { title: 'Save your changes?', body: "Your color and theme customizations haven't been saved.", confirmLabel: 'Save', cancelLabel: 'Discard', confirmDanger: false, cancelDanger: true, dismissValue: true });
7744
+ _custCancelling = false;
7745
+ if (saveChosen) { custSave(); return; }
7746
+ // else: Discard was explicitly clicked (backdrop/Escape can no longer produce this) — revert below.
7747
+ }
7672
7748
  if (custSnap) { Object.assign(cust, custSnap.cust); state.theme = custSnap.theme; fsUI.band = custSnap.band; custSnap = null; custApplyAll(); }
7673
7749
  $('gearPop').classList.remove('open');
7674
7750
  }
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
@@ -2485,6 +2525,56 @@ export function resolveFolderAnswer(answer, folders = []) {
2485
2525
  return null;
2486
2526
  }
2487
2527
 
2528
+ // Onboarding funnel, part 2: the user picked "somewhere else…" and typed a path
2529
+ // that ISN'T already a git repo. Before, the server just re-asked the same
2530
+ // folder question forever (a repo-less / vibe-coder user could never get past
2531
+ // setup). This decides what to offer instead. All filesystem probes (does it
2532
+ // exist / is it a directory / is it already a repo) are done by the CALLER and
2533
+ // passed in, so this stays pure/testable. Returns:
2534
+ // { action: 'use' } already a repo → bind as-is
2535
+ // { action: 'init', create: false } existing non-repo dir → offer `git init` here
2536
+ // { action: 'init', create: true } path doesn't exist yet → offer to create it + `git init`
2537
+ // { action: 'reject', reason } HOME (never a work root) or a path that is a file → re-ask
2538
+ export function classifyFolderTarget({ dir, homeDir, exists, isDirectory, isRepo } = {}) {
2539
+ if (!dir) return { action: 'reject', reason: 'empty' };
2540
+ const norm = (p) => String(p).replace(/\/+$/, '');
2541
+ if (homeDir && norm(dir) === norm(homeDir)) return { action: 'reject', reason: 'home' };
2542
+ if (isRepo) return { action: 'use' };
2543
+ if (!exists) return { action: 'init', create: true };
2544
+ if (!isDirectory) return { action: 'reject', reason: 'not-a-dir' };
2545
+ return { action: 'init', create: false };
2546
+ }
2547
+
2548
+ // Build the confirm card shown before we `git init` a non-repo folder. Reuses
2549
+ // the same needsInput card shape ({text, options:string[]}) but tags it
2550
+ // kind:'folder-init' so the /answer handler runs the init (instead of another
2551
+ // folder rebind). `create` = the folder doesn't exist yet and we'll make it.
2552
+ // The chosen dir + create flag ride along on the question so the handler is
2553
+ // authoritative about WHAT it's initializing (never re-derived from free text).
2554
+ export function buildFolderInitConfirm(dir, { lang = 'ja', create = false, homeDir = '' } = {}) {
2555
+ const label = folderLabel(dir, homeDir);
2556
+ const yes = lang === 'en'
2557
+ ? (create ? 'Create it and start a new project here' : 'Start a new project here')
2558
+ : (create ? '作成して、ここで新規プロジェクトを始める' : 'ここで新規プロジェクトを始める');
2559
+ const no = lang === 'en' ? 'Pick another folder…' : '別の場所を選ぶ…';
2560
+ const text = lang === 'en'
2561
+ ? `“${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.`
2562
+ : `「${label}」はまだGitリポジトリではありません。${create ? '作成して、' : ''}ここで新しいプロジェクトを始めますか?(git init・既存のファイルには手を触れません)`;
2563
+ return { text, options: [yes, no], kind: 'folder-init', lang, dir, create, yes, no };
2564
+ }
2565
+
2566
+ // Interpret the confirm-card answer → true (do the init) / false (decline →
2567
+ // re-ask the folder question). Match the exact "yes" option we offered, or a
2568
+ // plain affirmative word; anything else (incl. the "pick another folder" option)
2569
+ // declines. Defaults to decline on empty/unknown so we never init by accident.
2570
+ export function isFolderInitConfirmed(answer, question = {}) {
2571
+ const a = String(answer ?? '').trim();
2572
+ if (!a) return false;
2573
+ if (question.yes && a === question.yes) return true;
2574
+ if (question.no && a === question.no) return false;
2575
+ return /^(yes|y|はい|ok|する|始める)$/i.test(a);
2576
+ }
2577
+
2488
2578
  // Analytics identity (A+): a signed-in install scopes usage events to the
2489
2579
  // ACCOUNT (its email, which the billing Worker hashes into the same account_id
2490
2580
  // 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 } from './lib.mjs';
25
25
  import { openPR } from './pr.mjs';
26
26
  import { runVerification, exerciseUi } from './verify.mjs';
27
27
 
@@ -2160,6 +2160,11 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
2160
2160
  // point (all its tasks are done), so its conflictsWith is accurate even if
2161
2161
  // PR creation itself fails (e.g. no `gh` auth) or is still no-op'd (no code).
2162
2162
  updateGoalConflicts(goal);
2163
+ // Computed BEFORE createGoalPR (not inside verifyGate, which used to run
2164
+ // after) so the PR body's "確認ポイント" headline is available the first
2165
+ // time the PR is opened, not only on the review card (Masa 2026-07-16).
2166
+ goalAct('Writing the review summary from the worker report and diff.');
2167
+ goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
2163
2168
  goalAct('Checking changed files and preparing the PR branch if code changed.');
2164
2169
  await createGoalPR(goal, prProject, siblings, reviewDefinition, activityTask);
2165
2170
  // goal 427 §4: classify the change's risk from the SAME changed-files/diff
@@ -2228,8 +2233,13 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2228
2233
  goal.testResult = { ran: false, skipped: true, skippedReason: changedFiles.length ? 'documentation-only change' : 'no changed files' };
2229
2234
  goalAct(`Skipped project tests: ${goal.testResult.skippedReason}.`);
2230
2235
  }
2231
- goalAct('Writing the review summary from the worker report and diff.');
2232
- goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
2236
+ // Normally already set by finishGoalIfComplete before the PR was opened
2237
+ // (so the PR body can include it too); only fall back here for the
2238
+ // standalone /reverify path or any older goal that never got one.
2239
+ if (!goal.reviewSummary) {
2240
+ goalAct('Writing the review summary from the worker report and diff.');
2241
+ goal.reviewSummary = await summarizeForReview(goal, siblings, wdir, reviewDefinition);
2242
+ }
2233
2243
  const uiTasks = siblings.filter((t) => isUiChange(t.changedFiles));
2234
2244
  // Only require/capture proof for projects we can actually boot & screenshot
2235
2245
  // (Manager-shaped). A generic web repo that edits a stylesheet can't be
@@ -2371,26 +2381,12 @@ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT
2371
2381
  // (Masa: 「ユーザーの言語で」).
2372
2382
  const lang = reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : (reviewDefinition.language || 'ja');
2373
2383
  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');
2384
+ const bodyFor = (branch) => buildGoalPrBody({
2385
+ lang, goal, goalTasks, files, owner, branch, proofRel,
2386
+ proofItemsCount: proofItems.length,
2387
+ reviewDescription: reviewDefinition.description,
2388
+ reviewSummary: goal.reviewSummary,
2389
+ });
2394
2390
  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
2391
  const { url, branch } = openPR({
2396
2392
  repoDir: project.dir, slug: `goal-${goal.id}`,
@@ -2401,7 +2397,7 @@ async function createGoalPR(goal, project, goalTasks, reviewDefinition = DEFAULT
2401
2397
  extraCopies,
2402
2398
  extraWrites: proofItems.length ? [{
2403
2399
  destRel: `${proofRel}/PROOF.md`,
2404
- content: buildGoalProofMd({ goalText: goal.text, items: proofItems }),
2400
+ content: buildGoalProofMd({ goalText: goal.text, items: proofItems, lang }),
2405
2401
  }] : [],
2406
2402
  ...(isRework ? { existingBranch: goal.prBranch, existingPrUrl: goal.pr } : {}),
2407
2403
  });
@@ -3087,34 +3083,80 @@ const server = createServer(async (req, res) => {
3087
3083
  let answer = '';
3088
3084
  try { answer = String(JSON.parse(body || '{}').answer ?? '').trim(); } catch {}
3089
3085
  if (!answer) return json(res, 400, { error: 'answer required' });
3086
+ // Bind this goal to a project rooted at `dir` and start planning. Shared by
3087
+ // the "picked a repo" and "git-init'd a new folder" paths below.
3088
+ const bindAndPlan = (dir) => {
3089
+ const existing = projects.find((p) => p.dir === dir);
3090
+ let proj = existing;
3091
+ if (!proj) {
3092
+ proj = { id: uniqueProjectId(basename(dir)), name: basename(dir), dir };
3093
+ projects.push(proj); saveProjects();
3094
+ }
3095
+ goal.projectId = proj.id;
3096
+ goal.question = null;
3097
+ goal.clarified = true;
3098
+ goal.status = 'planning';
3099
+ saveGoal(goal);
3100
+ planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
3101
+ return json(res, 200, goal);
3102
+ };
3090
3103
  // Folder-choice answer (onboarding funnel): rebind the goal to the chosen
3091
3104
  // 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.
3105
+ // clarification. If the picked path is already a repo, bind it. If it's a
3106
+ // non-repo dir (or a path that doesn't exist yet), DON'T loop re-asking
3107
+ // offer to start a new project there via `git init` (the repo-less /
3108
+ // vibe-coder onboarding path). HOME / a file → re-ask.
3094
3109
  if (goal.question?.kind === 'folder') {
3095
3110
  const resolved = resolveFolderAnswer(answer, goal.question.folders || []);
3096
3111
  let dir = resolved?.dir || answer;
3097
3112
  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';
3113
+ const lang = goal.question.lang || 'ja';
3114
+ const cls = classifyFolderTarget({
3115
+ dir, homeDir: HOME,
3116
+ exists: existsSync(dir),
3117
+ isDirectory: (() => { try { return statSync(dir).isDirectory(); } catch { return false; } })(),
3118
+ isRepo: isGitRepo(dir),
3119
+ });
3120
+ if (cls.action === 'use') return bindAndPlan(dir);
3121
+ if (cls.action === 'init') {
3122
+ // Non-repo folder → confirm before we create a repo (non-destructive,
3123
+ // but a repo-less user should see what we're about to do).
3124
+ goal.question = buildFolderInitConfirm(dir, { lang, create: cls.create, homeDir: HOME });
3109
3125
  saveGoal(goal);
3110
- planGoal(goal).catch((e) => { goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal); });
3111
3126
  return json(res, 200, goal);
3112
3127
  }
3113
- // not a usable repo keep asking (don't run a no-op)
3114
- goal.question = buildFolderQuestion(detectRepos(HOME), { lang: goal.question.lang || 'ja' });
3128
+ // reject (HOME or a file)re-ask with a fresh detected list
3129
+ goal.question = buildFolderQuestion(detectRepos(HOME), { lang });
3115
3130
  saveGoal(goal);
3116
3131
  return json(res, 200, goal);
3117
3132
  }
3133
+ // Confirm card for "start a new project here (git init)?". Yes → run the
3134
+ // init (guarded: create the dir if needed, NEVER touch existing files,
3135
+ // NEVER re-init an existing repo), then bind + plan like a picked repo.
3136
+ // Anything else → back to the folder list.
3137
+ if (goal.question?.kind === 'folder-init') {
3138
+ const q = goal.question;
3139
+ const lang = q.lang || 'ja';
3140
+ if (!isFolderInitConfirmed(answer, q)) {
3141
+ goal.question = buildFolderQuestion(detectRepos(HOME), { lang });
3142
+ saveGoal(goal);
3143
+ return json(res, 200, goal);
3144
+ }
3145
+ const dir = q.dir;
3146
+ try {
3147
+ if (dir === HOME) throw new Error('refusing to init HOME as a project');
3148
+ if (q.create && !existsSync(dir)) mkdirSync(dir, { recursive: true });
3149
+ if (!statSync(dir).isDirectory()) throw new Error('target is not a directory');
3150
+ if (!isGitRepo(dir)) gitSync(dir, ['init', '--quiet']); // adds .git only; leaves files as-is
3151
+ } catch (e) {
3152
+ // couldn't init → don't run a no-op; surface why and re-ask
3153
+ goal.question = buildFolderQuestion(detectRepos(HOME), { lang });
3154
+ goal.prError = `git init failed: ${String(e?.message || e).slice(0, 200)}`;
3155
+ saveGoal(goal);
3156
+ return json(res, 200, goal);
3157
+ }
3158
+ return bindAndPlan(dir);
3159
+ }
3118
3160
  goal.text = `${goal.text}\n[確認: ${goal.question?.text ?? ''} → ${answer}]`;
3119
3161
  goal.clarified = true; // answered once → planGoal must never re-ask (persisted via saveGoal)
3120
3162
  goal.question = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.14",
3
+ "version": "0.10.16",
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": {