@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 +114 -38
- package/engine/lib.mjs +100 -10
- package/engine/server.mjs +83 -41
- package/package.json +1 -1
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:
|
|
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} /*
|
|
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 .
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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 <=
|
|
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:
|
|
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 &&
|
|
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(
|
|
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">
|
|
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
|
|
5818
|
-
//
|
|
5819
|
-
//
|
|
5820
|
-
//
|
|
5821
|
-
//
|
|
5822
|
-
//
|
|
5823
|
-
//
|
|
5824
|
-
//
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
.
|
|
5830
|
-
|
|
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
|
-
|
|
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(
|
|
6754
|
-
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('deleteConfirmOverlay').classList.contains('show')) closeDeleteConfirm(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
717
|
+
L('# Proof', '# 動作確認'),
|
|
714
718
|
``,
|
|
715
|
-
|
|
719
|
+
`${L('Goal', 'ゴール')}: ${String(goalText ?? '').replace(/\s+/g, ' ').slice(0, 300)}`,
|
|
716
720
|
``,
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
727
|
+
`${L('Pass condition', '成功条件')}: ${it.passCondition}`,
|
|
724
728
|
``,
|
|
725
|
-
it.beforeShotName ?
|
|
726
|
-
it.beforeShotName ?
|
|
729
|
+
it.beforeShotName ? `${L('Before', '変更前')}:\n\n\n` : '',
|
|
730
|
+
it.beforeShotName ? `${L('After', '変更後')}:\n` : '',
|
|
727
731
|
it.gifName ? `\n` : '',
|
|
728
732
|
it.shotName ? `\n` : '',
|
|
729
|
-
it.videoName ?
|
|
730
|
-
|
|
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
|
-
|
|
2232
|
-
|
|
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
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
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.
|
|
3093
|
-
//
|
|
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
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
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
|
-
//
|
|
3114
|
-
goal.question = buildFolderQuestion(detectRepos(HOME), { lang
|
|
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