@galda/cli 0.10.42 → 0.10.43

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
@@ -318,6 +318,12 @@
318
318
  .ctxmenu .ctxmsg{padding:2px 5px 3px;font-size:11px;color:var(--ink3)}
319
319
  .ctxmenu .ctxmsg:empty{display:none}
320
320
  .ctxmenu .ctxmsg.bad{color:var(--danger)}
321
+ /* "Open folder…" — the Claude-Code entry that opens the OS folder chooser on a
322
+ local session. A single-line row (the repo rows above are 2-line name+path),
323
+ hairline-separated from the detected list, matching .appopt grammar. */
324
+ .ctxmenu .appopt.ctxopen{flex-direction:row;align-items:center;gap:7px;border-top:1px solid var(--hair);margin-top:4px;padding-top:8px}
325
+ .ctxmenu .appopt.ctxopen svg{width:14px;height:14px;flex:0 0 14px;stroke:currentColor;stroke-width:1.7;fill:none;stroke-linecap:round;stroke-linejoin:round}
326
+ .ctxmenu .appopt.ctxopen .nm{color:var(--ink);font-size:12.5px;line-height:1.3}
321
327
  .appopt{display:flex;align-items:center;gap:7px;height:28px;padding:0 8px;border:0;border-radius:6px;background:transparent;
322
328
  color:var(--ink2);font:inherit;font-size:12px;cursor:pointer;text-align:left;width:100%}
323
329
  .appopt:hover{background:rgba(var(--tint),.06);color:var(--ink)}
@@ -1018,29 +1024,41 @@
1018
1024
  /* Goal review detail panel (task 72): tapping any Review-column goal item
1019
1025
  (whether or not it has a PR) opens this instead of the chat panel —
1020
1026
  Requirement / What was done / Proof, then Approve/Dismiss + revision chat. */
1021
- .gdoverlay .modal{width:680px;max-height:min(86vh,760px);display:flex;flex-direction:column;position:relative;overflow:hidden}
1022
- .gdclosex{position:absolute;right:14px;top:12px}
1023
- .gdscroll{flex:1;overflow:auto;min-height:0;padding-right:2px}
1024
- .gdsec{margin-top:16px}
1025
- .gdsec:first-of-type{margin-top:4px}
1026
- .gdlabel{font-family:var(--mono);font-size:9.5px;letter-spacing:.06em;color:var(--ink3);text-transform:uppercase;margin-bottom:7px}
1027
- .gdrequirement{font-size:13.5px;line-height:1.65;color:var(--ink);white-space:pre-wrap}
1028
- .gdbrief{display:none;margin:12px 0 2px;border:1px solid var(--hair);border-radius:10px;background:var(--surface2);padding:12px 13px}
1027
+ /* View-log / Attention modal — transcribes the Review card's redesign grammar
1028
+ (REVIEW-CARD-SPEC / .redesign): panel container, 40px Linear-docs gutters,
1029
+ sentence-case UI section heads, 14px --ink body prose, canvas log box. Same
1030
+ IDs/classes/markup as before — ONLY the look changes. Behaviour is unchanged
1031
+ (Approve stays hidden for failed; failure brief / Archive / send-back shown).
1032
+ All tokens (--panel/--hair/--ink*/--canvas/--ui/--mono) so it adapts per theme. */
1033
+ .gdoverlay .modal{width:min(680px,94%);max-height:min(86vh,760px);display:flex;flex-direction:column;position:relative;overflow:hidden;
1034
+ background:var(--panel);border:0;border-radius:18px;padding:0;
1035
+ box-shadow:0 18px 54px -18px rgba(15,17,40,.30),0 0 0 1px var(--hair);
1036
+ backdrop-filter:blur(30px) saturate(1.4);-webkit-backdrop-filter:blur(30px) saturate(1.4)}
1037
+ .gdclosex{position:absolute;right:18px;top:16px;z-index:2}
1038
+ .gdoverlay #gdTitle{margin:0;padding:28px 40px 0;font-family:var(--ui);font-size:18px;font-weight:680;letter-spacing:-.018em;line-height:1.3;color:var(--ink)}
1039
+ .gdscroll{flex:1;overflow:auto;min-height:0;padding:0 34px 4px 40px}
1040
+ .gdsec{margin-top:24px}
1041
+ .gdsec:first-of-type{margin-top:20px}
1042
+ /* section head = Linear-docs sentence-case (markup text already "Requirement"/"What
1043
+ was done"/"Proof"/"Log"), matching .redesign .rsec-h — not the old mono eyebrow. */
1044
+ .gdlabel{font-family:var(--ui);font-size:15px;font-weight:680;letter-spacing:-.012em;line-height:1.3;color:var(--ink);text-transform:none;margin-bottom:9px}
1045
+ .gdrequirement{font-size:14px;line-height:1.62;letter-spacing:-.012em;color:var(--ink);max-width:70ch;white-space:pre-wrap}
1046
+ .gdbrief{display:none;margin:16px 0 2px;border:0;border-radius:10px;background:color-mix(in srgb,var(--ink) 4%,transparent);padding:12px 14px}
1029
1047
  .gdbrief.show{display:block}
1030
- .gdbrief .brow{display:grid;grid-template-columns:120px 1fr;gap:12px;padding:8px 0;border-top:1px solid var(--hair)}
1048
+ .gdbrief .brow{display:grid;grid-template-columns:120px 1fr;gap:12px;padding:9px 0;border-top:1px solid var(--hair)}
1031
1049
  .gdbrief .brow:first-child{border-top:0;padding-top:0}
1032
- .gdbrief .bk{font-family:var(--mono);font-size:9.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink3)}
1033
- .gdbrief .bv{font-size:13px;line-height:1.55;color:var(--ink)}
1034
- .gdtask{margin-bottom:10px}
1050
+ .gdbrief .bk{font-family:var(--ui);font-size:12px;font-weight:600;letter-spacing:-.004em;text-transform:none;color:var(--ink3)}
1051
+ .gdbrief .bv{font-size:14px;line-height:1.6;letter-spacing:-.012em;color:var(--ink)}
1052
+ .gdtask{margin-bottom:12px}
1035
1053
  .gdtask:last-child{margin-bottom:0}
1036
- .gdtasktitle{font-size:12.5px;font-weight:600;color:var(--ink)}
1037
- .gdtaskresult{font-size:12.5px;color:var(--ink2);line-height:1.6;white-space:pre-wrap;margin-top:3px}
1038
- .gdlog{max-height:min(34vh,280px);overflow:auto;border:1px solid var(--hair);border-radius:9px;background:var(--surface2);padding:8px 10px;
1039
- font-family:var(--mono);font-size:10.5px;line-height:1.55;color:var(--ink2);white-space:pre-wrap}
1054
+ .gdtasktitle{font-size:14px;font-weight:600;letter-spacing:-.012em;color:var(--ink)}
1055
+ .gdtaskresult{font-size:14px;color:var(--ink2);line-height:1.62;letter-spacing:-.012em;white-space:pre-wrap;margin-top:4px;max-width:70ch}
1056
+ .gdlog{max-height:min(34vh,280px);overflow:auto;border:0;border-radius:9px;background:var(--canvas);padding:10px 12px;
1057
+ font-family:var(--mono);font-size:10.5px;line-height:1.75;color:var(--ink2);white-space:pre-wrap}
1040
1058
  .gdlogline{border-top:1px solid var(--hair);padding-top:6px;margin-top:6px}
1041
1059
  .gdlogline:first-child{border-top:0;padding-top:0;margin-top:0}
1042
- .gdempty{font-size:12px;color:var(--ink3)}
1043
- .gdoverlay .sa-act{border-top:1px solid var(--hair);padding-top:12px;margin-top:12px}
1060
+ .gdempty{font-size:13px;color:var(--ink3)}
1061
+ .gdoverlay .sa-act{border-top:1px solid var(--hair);padding:14px 40px 28px;margin-top:20px}
1044
1062
  .more{margin:6px auto 4px;padding:5px 18px;width:fit-content;display:block;text-align:center;color:var(--ink3);font-size:11.5px;
1045
1063
  border:1px solid var(--hair2);border-radius:999px;background:var(--glass);backdrop-filter:blur(10px);cursor:pointer}
1046
1064
  .more:hover{color:var(--ink)}
@@ -2176,6 +2194,13 @@
2176
2194
  .redesign .rask,.redesign .rcheck,.redesign .rcheck li,.redesign .rcheck1{color:var(--ink) !important}
2177
2195
  .redesign ul.rcheck{margin:2px 0 0;padding:0 0 0 18px;font-size:14px;line-height:1.7;letter-spacing:-.012em}
2178
2196
  .redesign ul.rcheck li{margin-bottom:6px}
2197
+ /* Also carries — each folded goal, with Open / Undo (restore). The controls sit at the
2198
+ row end; muted mono buttons, glyph-coloured hover only (§5.5), no filled surface. */
2199
+ .redesign ul.rcarries li{display:flex;align-items:center;gap:10px}
2200
+ .redesign ul.rcarries .cft{min-width:0;overflow:hidden;text-overflow:ellipsis}
2201
+ .redesign ul.rcarries .cfa{margin-left:auto;display:inline-flex;gap:5px;flex:0 0 auto}
2202
+ .redesign ul.rcarries .cfb{height:22px;padding:0 9px;border:1px solid var(--hair2);border-radius:7px;background:transparent;color:var(--ink2);font-family:var(--mono);font-size:10px;cursor:pointer}
2203
+ .redesign ul.rcarries .cfb:hover{background:var(--hover);color:var(--ink)}
2179
2204
  /* Activity (spec §3) = ONE Linear-docs pulldown. Heading plain; chevron on the FIRST
2180
2205
  line; tapping opens the rest below, offset-indented (no left rule); Discussion folds
2181
2206
  in at the bottom of the opened body. */
@@ -3118,7 +3143,7 @@
3118
3143
  <div class="modal reviewmodal">
3119
3144
  <div class="revcount" id="reviewCount">REVIEW</div>
3120
3145
  <h3 id="reviewGoalTitle">Review</h3>
3121
- <p>Review each requirement with its capture and approve it individually — approved ones fold away. Or approve the whole goal below to send it straight to Done; the next review pops in automatically. Tap outside to close.</p>
3146
+ <p id="reviewIntro">Review each requirement with its capture and approve it individually — approved ones fold away. Or approve the whole goal below to send it straight to Done; the next review pops in automatically. Tap outside to close.</p>
3122
3147
  <div class="rvtoast" id="reviewPrevToast" style="display:none"></div>
3123
3148
  <div class="revsummary" id="reviewSummary">0 / 0 approved</div>
3124
3149
  <div class="revdone" id="reviewDoneMsg">
@@ -5180,6 +5205,18 @@ function renderReviewChecklist(){
5180
5205
  $('reviewApprove').style.display = 'none'; // Feedback: drop the duplicate goal-level Approve — per-requirement Approve is enough
5181
5206
  $('reviewDismiss').style.display = canReview ? '' : 'none';
5182
5207
  $('reviewApprove').disabled = !canReview;
5208
+ // A folded goal opens here read-only (Masa 2026-07-23, [Open] from "Also carries"): nothing
5209
+ // to approve, so don't show the approve-each-requirement intro — say honestly where its
5210
+ // work went. Set it in BOTH branches (this overlay is reused across goals, so a stale
5211
+ // folded-intro must not linger on the next review goal).
5212
+ const introEl = $('reviewIntro');
5213
+ if (introEl) {
5214
+ introEl.textContent = canReview
5215
+ ? 'Review each requirement with its capture and approve it individually — approved ones fold away. Or approve the whole goal below to send it straight to Done; the next review pops in automatically. Tap outside to close.'
5216
+ : goal.status === 'folded'
5217
+ ? "This request's work was merged into another review's PR, so there is nothing to approve here — its work is shown below. Undo the merge from “Also carries” to restore it as its own review."
5218
+ : 'Review the details below. Tap outside to close.';
5219
+ }
5183
5220
  renderPrevToast(goalId);
5184
5221
  // task 96: 件数だけでなく「このゴールで何をやったか・何件完了か」を要約表示。
5185
5222
  const sum = buildReviewSummary({ goal, tasks: reqs, approvedN });
@@ -6547,9 +6584,11 @@ function renderFsReview(){
6547
6584
  const pgoals = state.goals.filter((g) => g.projectId === state.active);
6548
6585
  const list = state.tasks.filter((t) => t.projectId === state.active);
6549
6586
  const rows = buildReviewDigest({ goals: pgoals, tasks: list });
6550
- // PRD §3.5/§6.5 → Masa 2026-07-21: folded goals no longer get their own card in the
6551
- // Review column (Masa: review欄にいらない). They were never deleted — where their work
6552
- // went is now logged inside the carrier review's detail ("Also carries", see saItemHtml).
6587
+ // PRD §6.5 → Masa 2026-07-23: a goal folded onto another review's PR does NOT get its own
6588
+ // row in the Review list (Masa, seeing it live: "ここにはいらない・逆にわかりにくい"). Where
6589
+ // its work went and the control to restore it — live on the carrier's side only, in the
6590
+ // opened detail's "Also carries" section (see saItemHtml's carriesBody). The list stays
6591
+ // just the reviews; folded goals are explained where they were merged.
6553
6592
  if (!rows.length) { el.innerHTML = ''; return; } // §1.5-3: no reviews → the UI itself does not exist
6554
6593
  const green = rows.filter((r) => !r.testResult || r.testResult.ok !== false).length;
6555
6594
  const blockedN = pgoals.filter((g) => g.status === 'blocked').length;
@@ -6626,8 +6665,8 @@ function renderFsReview(){
6626
6665
  if (e.target.closest('a') || e.target.closest('.act')) return;
6627
6666
  openReviewChecklist(Number(c.dataset.fsrev));
6628
6667
  };
6629
- // Folded goals no longer render their own card here (Masa 2026-07-21) — where their
6630
- // work went is logged inside the carrier review's detail ("Also carries"), not tapped.
6668
+ // Folded goals get no row here (Masa 2026-07-23) — they are shown, and restored, on the
6669
+ // carrier's side in the See-all "Also carries" section (saItemHtml + the seeall handler).
6631
6670
  for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
6632
6671
  let ok = true, mergeGoal = null;
6633
6672
  for (const id of b.dataset.fsapprove.split(',')) {
@@ -8644,6 +8683,13 @@ const MSG_VIA = { composer: 'chat', review: 'review', thread: 'note' };
8644
8683
  function saMsgRowHtml(m, prevVia){
8645
8684
  const who = MSG_WHO[m?.from] ?? 'AI';
8646
8685
  const cls = m?.from === 'you' ? 'you' : m?.from === 'manager' ? 'mgr' : 'cl';
8686
+ // A folded sibling's work was merged onto this goal (engine stamps from:manager/via:fold,
8687
+ // Masa 2026-07-23). Label it "merged in" so its text does not read as the Manager typing
8688
+ // a fresh request — the old you/via:composer stamp looked like a phantom re-submission.
8689
+ // Which card it came from is listed on the carrier's "Also carries" section.
8690
+ if (m?.via === 'fold') {
8691
+ return `<div class="txr mgr"><span class="w">Manager</span><span class="m">${esc(String(m?.text ?? ''))}</span><span class="cvia">merged in</span></div>`;
8692
+ }
8647
8693
  // Manager rows carry no entrance: `who` already says this line is automatic.
8648
8694
  const via = m?.from === 'manager' ? '' : (MSG_VIA[m?.via] ?? '');
8649
8695
  const show = via && via !== prevVia;
@@ -8824,12 +8870,14 @@ function saItemHtml(it, i){
8824
8870
  const openBody = (it.run?.cmd || it.run?.url)
8825
8871
  ? `<div class="ropen" id="runlocal${i}"><button type="button" class="olink" data-saact="preview:${i}" id="prevlink${i}">${esc(it.run.url || 'Start it')}<span class="ar">↗</span></button><p class="rbody v">${esc(it.run.note || it.run.cmd || 'Starts when you press it.')}</p></div>`
8826
8872
  : '';
8827
- // ── Also carries (Masa 2026-07-21): goals folded onto this review are logged HERE, inside
8828
- // the review's own content — not as loose "→ #12 にまとめました" rows in the Review column.
8829
- // Chrome label stays English (§377-7); the request titles keep whatever language they were
8830
- // written in. Only rendered when something was actually folded in (§1.5-3 no empty box). ──
8873
+ // ── Also carries (Masa 2026-07-21 → refined 2026-07-23): goals folded onto this review are
8874
+ // logged HERE, inside the review's own content — NOT as loose rows in the Review column
8875
+ // (Masa: "ここにはいらない・逆にわかりにくい"). This is the ONE place a folded goal is shown,
8876
+ // and where it is restored: each item gets [Open] (the Review dialog) and [Undo] (POST /unfold
8877
+ // — pull it back out as its own review + PR). Chrome label stays English (§4); the request
8878
+ // titles keep their language. Only rendered when something was folded in (§1.5-3 no empty box). ──
8831
8879
  const carriesBody = it.folded?.length
8832
- ? `<ul class="rcheck">${it.folded.map((f) => `<li>#${esc(String(f.id))} ${esc(f.title)}</li>`).join('')}</ul>`
8880
+ ? `<ul class="rcheck rcarries">${it.folded.map((f) => `<li><span class="cft">#${esc(String(f.id))} ${esc(f.title)}</span><span class="cfa"><button class="cfb" data-saact="foldopen:${f.id}">Open</button><button class="cfb" data-saact="foldundo:${f.id}" title="Undo the merge — restore this as its own review">Undo</button></span></li>`).join('')}</ul>`
8833
8881
  : '';
8834
8882
  // ── What to check (spec §2.5): a bulleted list when several, a line when one. ──
8835
8883
  // Only an explicit review-summary check renders here — never the goal text echoed back
@@ -9256,8 +9304,19 @@ $('seeall').addEventListener('click', async (e) => {
9256
9304
  const b = e.target.closest('[data-saact]');
9257
9305
  if (!b) return;
9258
9306
  const [k, v] = b.dataset.saact.split(':'); const i = Number(v);
9259
- const it = SA.items[i];
9260
9307
  if (k === 'close') return saClose();
9308
+ // Also carries actions carry a GOAL id (not an item index): open the folded goal in the
9309
+ // Review dialog (Masa 2026-07-23 — same review-styled UI, not the plain goal detail; the
9310
+ // #reviewOverlay sits above #seeall, and a folded goal shows read-only there since
9311
+ // canReview=false hides Approve/Dismiss), or restore it via /unfold. Handled before the
9312
+ // SA.items[i] lookup below.
9313
+ if (k === 'foldopen') return openReviewChecklist(Number(v));
9314
+ if (k === 'foldundo') {
9315
+ const r = await fetch(withKey(`/api/goals/${v}/unfold`), { method: 'POST' });
9316
+ if (r && r.ok) await refresh(); else showErr('Undo failed.');
9317
+ return;
9318
+ }
9319
+ const it = SA.items[i];
9261
9320
  if (!it) return;
9262
9321
  // meta-row toggle (H23): tap the token chip to reveal the technique breakdown.
9263
9322
  if (k === 'tok') { b.classList.toggle('open'); $('seeall').querySelector(`#tokdetail${i}`)?.classList.toggle('open'); return; }
@@ -9439,7 +9498,8 @@ function renderCtxBar(){
9439
9498
  // LRM, so the rtl clipping trims the HEAD of the path without reversing the text
9440
9499
  + `<span class="pt">\u200e${esc(folderShort(r.dir))}</span></button>`)
9441
9500
  .join('');
9442
- menu.innerHTML = `${opts}<div class="ctxpath"><input type="text" id="ctxdirin" placeholder="~/path/to/repo" aria-label="Folder path" spellcheck="false"><button type="button" class="appopt" id="ctxdiruse" style="width:auto">Use</button></div><div class="ctxmsg" id="ctxdirmsg"></div>`;
9501
+ const openRow = `<button type="button" class="appopt ctxopen" id="ctxopenfolder" title="Open the OS folder chooser"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><path d="M12 11v6M9 14h6"/></svg><span class="nm">Open folder…</span></button>`;
9502
+ menu.innerHTML = `${opts}${openRow}<div class="ctxpath"><input type="text" id="ctxdirin" placeholder="~/path/to/repo" aria-label="Folder path" spellcheck="false"><button type="button" class="appopt" id="ctxdiruse" style="width:auto">Use</button></div><div class="ctxmsg" id="ctxdirmsg"></div>`;
9443
9503
  }
9444
9504
  // The worktree checkbox (engine #268). ON — the default, and what every goal has always
9445
9505
  // done — gives each goal its own copy of the repo, so a run cannot leave this folder
@@ -9485,6 +9545,26 @@ async function setProjectFolder(dir){
9485
9545
  render();
9486
9546
  } catch { say('Could not reach the engine.', true); }
9487
9547
  }
9548
+ // "Open folder…" the Claude-Code way. On a LOCAL session the engine opens the
9549
+ // operator's OWN native OS folder chooser (POST /api/choose-folder — engine PR)
9550
+ // and we point this project at the picked path. A remote/relay session can't
9551
+ // (the dialog would open on the server, not the person's screen), so it falls
9552
+ // back to the detected repos + typed path already in this menu.
9553
+ function isLocalSession(){ return ['localhost', '127.0.0.1'].includes(location.hostname); }
9554
+ async function openFolderNative(){
9555
+ const msg = $('ctxdirmsg');
9556
+ const say = (t, bad) => { if (msg) { msg.textContent = t; msg.classList.toggle('bad', Boolean(bad)); } };
9557
+ if (!isLocalSession()) { $('ctxdirin')?.focus(); say('Remote session — pick a repo above or type a path.'); return; }
9558
+ say('Opening the folder chooser…');
9559
+ try {
9560
+ const r = await fetch(withKey('/api/choose-folder'), { method: 'POST' });
9561
+ const body = await r.json().catch(() => ({}));
9562
+ if (body.path) { setProjectFolder(body.path); return; } // reuse: PUT the project's dir, close, render
9563
+ if (body.cancelled) { say(''); return; }
9564
+ if (body.unsupported) { say('This engine can’t open a folder dialog — pick a repo above or type a path.'); return; }
9565
+ say(body.error || 'Could not open the folder chooser.', true);
9566
+ } catch { say('Could not reach the engine.', true); }
9567
+ }
9488
9568
  $('ctxdirbtn').onclick = (e) => {
9489
9569
  e.stopPropagation();
9490
9570
  const menu = $('ctxdirmenu');
@@ -9499,6 +9579,7 @@ $('ctxwt').onclick = (e) => {
9499
9579
  };
9500
9580
  $('ctxdirmenu').addEventListener('click', (e) => {
9501
9581
  e.stopPropagation();
9582
+ if (e.target.closest('#ctxopenfolder')) { openFolderNative(); return; }
9502
9583
  const opt = e.target.closest('[data-ctxdir]');
9503
9584
  if (opt) { setProjectFolder(opt.dataset.ctxdir); return; }
9504
9585
  if (e.target.closest('#ctxdiruse')) setProjectFolder($('ctxdirin')?.value ?? '');
package/engine/lib.mjs CHANGED
@@ -778,7 +778,10 @@ export function refinePlanTasks(tasks, { goalText, maxTasks = 4 } = {}) {
778
778
  // - /api/state には載せない。開いた時だけ取りに行く(実装ログ #234/#238 と同じ形)。
779
779
  export const MESSAGE_TEXT_LIMIT = 8000;
780
780
  export const MESSAGE_FROM = ['you', 'ai', 'manager'];
781
- export const MESSAGE_VIA = ['composer', 'review', 'thread'];
781
+ // 'fold' = the review gate merged a sibling goal's work onto this one; the app
782
+ // renders it as a merge notice ("🔀 統合"), not as a human utterance (Masa
783
+ // 2026-07-23 — the old you/composer stamp looked like a phantom re-submission).
784
+ export const MESSAGE_VIA = ['composer', 'review', 'thread', 'fold'];
782
785
 
783
786
  // 1発言を正規化する。text が空なら null(空の発言は記録しない=無いものを
784
787
  // 残さない)。from/via が未知なら記録側の都合で捨てず、既定に寄せる。
@@ -3751,6 +3754,31 @@ export function folderLabel(dir, homeDir = '') {
3751
3754
  return d.length <= 60 ? d : '…' + d.slice(-59);
3752
3755
  }
3753
3756
 
3757
+ // The osascript program that opens the operator's native macOS folder chooser
3758
+ // and prints the picked folder's POSIX path. Kept here (not inline in the route)
3759
+ // so the exact script — and its quote-escaping — is one testable thing. `choose
3760
+ // folder` is a Standard Additions command; `POSIX path of` turns the returned
3761
+ // alias into an absolute path string.
3762
+ export function chooseFolderScript(prompt = 'Choose a project folder') {
3763
+ const safe = String(prompt).replace(/["\\]/g, ' ');
3764
+ return `POSIX path of (choose folder with prompt "${safe}")`;
3765
+ }
3766
+
3767
+ // Turn the result of running `osascript -e chooseFolderScript()` into a small
3768
+ // verdict the /api/choose-folder route returns. macOS prints the POSIX path
3769
+ // (with a trailing slash) on stdout; pressing Cancel exits non-zero with
3770
+ // "User canceled." on stderr. Pure, so the route's contract is unit-tested
3771
+ // without ever opening a GUI dialog.
3772
+ // { path } → a folder was chosen (trailing slash stripped; '/' preserved)
3773
+ // { cancelled } → the user dismissed the dialog
3774
+ // { error } → osascript failed for some other reason
3775
+ export function parseChosenFolder({ code = 0, stdout = '', stderr = '' } = {}) {
3776
+ const out = String(stdout).trim();
3777
+ if (Number(code) === 0 && out) return { path: out.replace(/\/+$/, '') || '/' };
3778
+ if (/user cancell?ed/i.test(String(stderr))) return { cancelled: true };
3779
+ return { error: String(stderr).trim() || 'choose folder failed' };
3780
+ }
3781
+
3754
3782
  // Build the "which folder should I work in?" clarifying question from detected
3755
3783
  // repos. Reuses the existing needsInput card ({text, options:string[]}), and
3756
3784
  // adds kind:'folder' + folders[] + lang so the /answer handler rebinds the
package/engine/server.mjs CHANGED
@@ -20,7 +20,7 @@ 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, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
23
+ import { needsProjectFolder, folderLabel, chooseFolderScript, parseChosenFolder, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
24
24
  import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, 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, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, isInconclusiveTestRun, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages } from './lib.mjs';
25
25
  import { createSerialQueue } from './lib.mjs';
26
26
  import { collectProjectRules } from './lib.mjs';
@@ -2839,7 +2839,12 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
2839
2839
  // Same owner only: folding across owners would splice one user's work onto
2840
2840
  // another's PR and echo its text back — the same leak the intake-side fold
2841
2841
  // guards against with a visibility check.
2842
- const foldTarget = conflicts.length
2842
+ // `noFold` = the human hit 「戻す」 on a folded card (POST /unfold): re-run
2843
+ // close-out but DON'T fold back into the same target it was just pulled out of
2844
+ // (that would be an instant loop, since the target still sits in review with
2845
+ // the overlapping files). The card then opens its own PR and both stay in
2846
+ // Review, flagged via conflictsWith — the visible collision Masa asked for.
2847
+ const foldTarget = (conflicts.length && !goal.noFold)
2843
2848
  ? goals.find((g) => g.id === pickFoldTarget(
2844
2849
  { id: goal.id, changedFiles: goalChangedFileUnion(goal.id) },
2845
2850
  goals
@@ -2855,10 +2860,16 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
2855
2860
  // The target re-opens: it has new work to do, and createGoalPR's rework path
2856
2861
  // will push the result onto the PR branch it already owns.
2857
2862
  foldTarget.status = 'running';
2858
- queueReplyTask(foldTarget, goal.text.slice(0, 8000), { from: 'you', via: 'composer' });
2863
+ // from:'manager' via:'fold' (NOT you/composer): this is the engine merging a
2864
+ // sibling's work, not the human typing. The old you/composer stamp made the
2865
+ // folded goal's text look like a phantom re-submission by the user in the
2866
+ // target's thread (Masa 2026-07-23). The app renders via:'fold' as a merge
2867
+ // notice; the folded card itself carries the "→ #N 統合済み" pointer.
2868
+ queueReplyTask(foldTarget, goal.text.slice(0, 8000), { from: 'manager', via: 'fold' });
2859
2869
  saveGoal(foldTarget);
2860
2870
  // The folded goal keeps its row (PRD §3.5: a submission is never dropped) —
2861
- // it becomes a pointer at the goal that now carries its work.
2871
+ // it becomes a pointer at the goal that now carries its work. The app renders
2872
+ // it as a visible "→ #N 統合済み [開く][戻す]" card so it never vanishes.
2862
2873
  goal.status = 'folded';
2863
2874
  goal.foldedInto = foldTarget.id;
2864
2875
  saveGoal(goal);
@@ -3435,6 +3446,27 @@ const server = createServer(async (req, res) => {
3435
3446
  return json(res, 200, { repos: detectRepos(HOME), home: HOME });
3436
3447
  }
3437
3448
 
3449
+ // Open the operator's OWN native OS folder chooser and hand back the absolute
3450
+ // path — the honest "Open folder…" for a LOCAL session, matching Claude Code.
3451
+ // The dialog opens on the machine running the engine, so the browser only calls
3452
+ // this when it is local (same host); a remote/relay UI uses /api/repos instead
3453
+ // (otherwise the dialog would open on the server, not the person's screen). It
3454
+ // returns only the chosen path — nothing is scanned, listed, or invented.
3455
+ // { path, label } | { cancelled: true } | { unsupported: true } | { error }
3456
+ // MANAGER_NO_NATIVE_DIALOG (and any non-macOS host) short-circuits to
3457
+ // {unsupported} so tests/CI/headless never block on a GUI.
3458
+ if (url.pathname === '/api/choose-folder' && req.method === 'POST') {
3459
+ if (process.platform !== 'darwin' || process.env.MANAGER_NO_NATIVE_DIALOG) {
3460
+ return json(res, 200, { unsupported: true });
3461
+ }
3462
+ execFile('osascript', ['-e', chooseFolderScript()], { timeout: 120000 }, (err, stdout, stderr) => {
3463
+ const verdict = parseChosenFolder({ code: err ? (err.code ?? 1) : 0, stdout, stderr });
3464
+ if (verdict.path) verdict.label = folderLabel(verdict.path, HOME);
3465
+ json(res, 200, verdict);
3466
+ });
3467
+ return;
3468
+ }
3469
+
3438
3470
  // The project's working rules (ONE-CONVERSATION §1.5a): the settings panel
3439
3471
  // shows a read-only view of whichever of CLAUDE.md / AGENTS.md exist in the
3440
3472
  // project dir — the instructions the connected AI actually reads. The browser
@@ -4011,6 +4043,31 @@ const server = createServer(async (req, res) => {
4011
4043
  return json(res, 200, goal);
4012
4044
  }
4013
4045
 
4046
+ // 「戻す」on a folded card (Masa 2026-07-23 — 畳むけど見える・戻せる): pull a
4047
+ // folded goal back out of the target it was merged into and give it its own
4048
+ // PR. We flag noFold so finishGoalIfComplete's review gate does NOT fold it
4049
+ // straight back into the same target (which still sits in review with the
4050
+ // overlapping files); the re-run opens a separate PR and both cards stay in
4051
+ // Review, flagged via conflictsWith. Non-destructive to the target: whatever
4052
+ // work already merged into it stays — this only restores the folded card.
4053
+ const unfoldMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/unfold$/);
4054
+ if (unfoldMatch && req.method === 'POST') {
4055
+ const goal = goals.find((g) => g.id === Number(unfoldMatch[1]));
4056
+ if (!goal) return json(res, 404, { error: 'goal not found' });
4057
+ if (!requireGoalOwnership(req, res, goal)) return;
4058
+ if (goal.status !== 'folded') return json(res, 409, { error: 'goal is not folded' });
4059
+ const project = projects.find((p) => p.id === goal.projectId);
4060
+ if (!project) return json(res, 404, { error: 'project not found' });
4061
+ goal.foldedInto = null;
4062
+ goal.noFold = true;
4063
+ goal.status = 'running'; // let finishGoalIfComplete re-run close-out (its tasks are all done → opens its own PR)
4064
+ saveGoal(goal);
4065
+ finishGoalIfComplete(goal, project).catch((e) => {
4066
+ goal.status = 'failed'; goal.prError = String(e).slice(0, 300); saveGoal(goal);
4067
+ });
4068
+ return json(res, 200, goal);
4069
+ }
4070
+
4014
4071
  // Pending (deliberately shelved) goals: activate moves one into the normal
4015
4072
  // breakdown-first flow; pend shelves a stacked goal.
4016
4073
  const activateMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/activate$/);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.42",
3
+ "version": "0.10.43",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {