@galda/cli 0.10.23 → 0.10.27

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/CLAUDE.md CHANGED
@@ -31,7 +31,18 @@
31
31
  ## 実装規律
32
32
 
33
33
  - 変更は最小限。周辺コードの流儀に合わせる。
34
- - `コミットしない`(git 操作は Manager 側の仕事。workerは編集のみ)。
34
+ - **git は「行為」でなく「場所」で縛る**(Masa決定 2026-07-17・再議論しない)。
35
+ **commit=保存 / PR=主張 / merge=採用(Masaだけ)**。旧文言 `コミットしない` は**保存と主張を混ぜていた**上に、
36
+ worker には `WORKER_TOOLS`(`engine/lib.mjs`)に git が1文字も無く**権限で既に不可**=
37
+ **縛りたくない相手にだけ効き、PRを出すのにコミットが必須の CTO/engineer だけを縛っていた**ので削除。
38
+ - 🔴 **共有チェックアウト `~/agent-manager` で git を触らない**。ここは Masa の作業場所で、
39
+ 全 worktree の親(実測135本)で、4401 の常駐 manager の起動元。HEAD が勝手に動く事故の出どころ
40
+ (2026-07-16 実発生)。`lane verify|pr|finish` が**ここで落ちる**。
41
+ - ✅ **自分のレーンの worktree(`~/am-wt-<topic>`)では、こまめにコミットする**。Masa の視界に入らない=
42
+ タダで、戻せる。Masa の言葉=「**あの時のあのバージョンがマジで失われて、もう一回作り直す**ことがある」。
43
+ 「捨てる前提の3方向」も自分の worktree なら好きなだけコミットしてよい(嫌なのは**Masaの場所に置かれること**)。
44
+ - **Masa の視界に入るのは PR から**。`lane verify` は**汚れたツリーで落ち**、`lane pr` は**検証したコミット以外を
45
+ 出荷させない**=「コミットせずに PR」「A を検証して B を出荷」は原理的に作れない。
35
46
  - 報告は日本語で短く:何を変えたか・どのファイルか・人間が確認すべき点。
36
47
 
37
48
  ## 並行開発(Masa指示 2026-07-16・必須)
package/app/index.html CHANGED
@@ -592,6 +592,10 @@
592
592
  .task .name{font-size:13px;color:var(--ink);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
593
593
  .task.done .name{color:var(--ink)}
594
594
  .task .cond{font-family:var(--mono);font-size:10.5px;color:var(--ink3);margin-top:3px}
595
+ /* PRD §6.5: a folded goal is settled, not failed — its title sinks to --ink2 like
596
+ any other settled row. No red, no painted surface, no badge (§1.5 art.8/art.9);
597
+ the pointer is the text itself, which reads as "the work is over there". */
598
+ .task.revrow.foldrow .name{color:var(--ink2)}
595
599
  .replyctx{color:var(--ink2)}
596
600
  .replyctx svg{width:11px;height:11px;vertical-align:-1px;stroke:currentColor;stroke-width:2;fill:none}
597
601
  .side-foot{flex:0 0 auto;padding:11px 14px;border-top:1px solid var(--hair);font-size:11.5px;color:var(--ink3);display:flex;align-items:center;gap:7px}
@@ -1438,6 +1442,33 @@
1438
1442
  /* review row number — #NN PREFIX on the title (HANDOFF-v45 §11), same form as the To Do
1439
1443
  rows (PRD §5: numbering matches the sidebar) */
1440
1444
  #fsRoot .rcard .rid{font-size:10.5px;font-weight:600;color:var(--ink3);margin:0 5px 0 0;display:inline-block;font-variant-numeric:tabular-nums}
1445
+ /* PRD §6.5 fold note — on the carrier ("also carries #13") and on the folded card
1446
+ ("merged into #12"). Deliberately NOT the .pr treatment: no tinted ground, no
1447
+ badge (§1.5-9), no red — folding is not a failure. Mono + --ink3 = the same quiet
1448
+ meta tone the rest of the lane uses (§5.5「品は罫線とトーン差・monoで出す」). */
1449
+ /* display:block, not inline-block: it shares .rc with the PR pill, which is itself
1450
+ inline-block — side by side the pill sat on top of the last "#NN" and ate it. */
1451
+ #fsRoot .rcard .fnote{font-family:var(--mono);font-size:9.5px;color:var(--ink3);margin-top:3px;display:block}
1452
+ /* In rv3 the meta sits on the near-black photo scrim whatever the theme, where --ink3
1453
+ is effectively invisible (§5.5「見えるが騒がない」— near-invisible is a fail). Match
1454
+ .rid's white-on-scrim treatment. The folded card opts out of the scrim (rule above),
1455
+ so it keeps --ink3 on the panel where that tone is correct. */
1456
+ #fsRoot[data-rv="3"] .rcard:not(.fold) .fnote{color:rgba(255,255,255,.66);text-shadow:0 1px 3px rgba(0,0,0,.55)}
1457
+ /* A folded card is settled, not failed: title sinks to the settled tone, and it
1458
+ keeps no ✓/✕ because the thing to approve lives on the goal it was folded into. */
1459
+ #fsRoot .rcard.fold .t{color:var(--ink2)}
1460
+ /* A folded card is a pointer, not a review item: no proof to show, nothing to approve.
1461
+ So it opts OUT of the rv3 thumbnail treatment (whose .rc is absolutely positioned
1462
+ over an 84px photo ground) and stays the same quiet line in every view — the form
1463
+ Masa drew. Giving it a fake dark thumbnail would claim it has something to look at. */
1464
+ /* .fold.noimg, not .fold: the placeholder-ground rule below is `.rcard.noimg`, which
1465
+ ties this on specificity and would win on order — a folded card would keep the fake
1466
+ dark thumbnail ground. Matching both classes settles it regardless of order. */
1467
+ #fsRoot[data-rv="3"] .rcard.fold.noimg{display:flex;position:static;align-items:center;gap:10px;
1468
+ padding:9px 10px;min-height:0;border:1px solid var(--hair);border-radius:11px;background:none;box-shadow:none}
1469
+ #fsRoot[data-rv="3"] .rcard.fold.noimg .rc{position:static;padding:0;background:none}
1470
+ #fsRoot[data-rv="3"] .rcard.fold.noimg .t{color:var(--ink2);font-size:12.5px;text-shadow:none}
1471
+ #fsRoot[data-rv="3"] .rcard.fold.noimg .rid{color:var(--ink3)}
1441
1472
  /* reference numbers everywhere else (HANDOFF-v45 §11): a leading #NN prefix on every
1442
1473
  To Do item's name — same goal = same number (goalReviewNumber), distinct from the
1443
1474
  PR's own #id shown separately. */
@@ -2545,11 +2576,18 @@ const STATUS_LABEL = {
2545
2576
  // chip too; omitting it reproduces the original hardcoded labels. `startedAt`
2546
2577
  // (goal.startedAt) disambiguates 'running': planning finishes with every
2547
2578
  // task still 'queued', so it reads QUEUED until startedAt is set, then DOING.
2579
+ // Mirrors engine/lib.mjs goalChip() — this inline script can't import that
2580
+ // module. Had drifted out of sync (missing 'retesting'/'reverted'/'folded',
2581
+ // all silently falling to FAIL) until Masa caught a reverted goal reading as
2582
+ // FAIL (2026-07-17); keep this in lockstep with goalChip() by hand.
2548
2583
  const GOAL_CHIP = (s, columns, startedAt) => s === 'stacked' || s === 'sending' ? [pickColumnLabel(columns, 'todo', 'QUEUED'), ''] :
2549
2584
  s === 'running' && !startedAt ? [pickColumnLabel(columns, 'todo', 'QUEUED'), ''] :
2550
2585
  ['planning', 'running'].includes(s) ? [pickColumnLabel(columns, 'doing', 'DOING'), 'doing'] :
2551
2586
  s === 'review' ? [pickColumnLabel(columns, 'review', 'REVIEW'), 'review'] :
2552
2587
  s === 'done' ? [pickColumnLabel(columns, 'done', 'DONE'), ''] :
2588
+ s === 'retesting' ? ['RE-TESTING', 'doing'] :
2589
+ s === 'reverted' ? ['REVERTED', ''] :
2590
+ s === 'folded' ? ['FOLDED', ''] :
2553
2591
  s === 'blocked' ? ['BLOCKED', 'blocked'] : s === 'needsInput' ? ['NEEDS INPUT', 'blocked'] : ['FAIL', ''];
2554
2592
  const FINISHED = (s) => ['done', 'partial', 'failed', 'interrupted'].includes(s);
2555
2593
  const ICONS = {
@@ -2703,6 +2741,16 @@ function reviewCardConfigFor(projectId){ return { ...DEFAULT_REVIEW_DEFINITION.r
2703
2741
  // goal's own request language, same as the planner/worker; ja/en pin the
2704
2742
  // project to one language regardless of what a given goal was typed in.
2705
2743
  function reviewLangFor(projectId){ const l = reviewDefinitionFor(projectId).language; return (l === 'en' || l === 'ja') ? l : 'auto'; }
2744
+ // PRD §6.5 fold note language (Masa 2026-07-17: "ユーザーの言語に合わせて" — not
2745
+ // hardcoded English). Mirrors the server's own 'auto' resolution exactly
2746
+ // (reviewDefinition.language === 'auto' ? detectRequestLanguage(goal.text) : ...,
2747
+ // engine/server.mjs) so this note reads in the same language as the review
2748
+ // headline/PR body for the same goal: the project's fixed override wins if set,
2749
+ // otherwise match the language the goal's own request was written in.
2750
+ function foldLangFor(goal){
2751
+ const l = reviewLangFor(state.active);
2752
+ return (l === 'ja' || l === 'en') ? l : (/[぀-ヿ㐀-鿿]/.test(goal?.text || '') ? 'ja' : 'en');
2753
+ }
2706
2754
 
2707
2755
  function esc(s){ const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
2708
2756
 
@@ -3609,7 +3657,16 @@ function renderTasks(){
3609
3657
  // Feedback 3: the row carries only the check-line + PR link. Tests/proof are
3610
3658
  // internal gate checks (they still block), NOT list clutter; the Review number
3611
3659
  // is redundant with the title; tapping the row opens the detail (no button).
3660
+ // PRD §6.5: goals that landed on the same files were folded onto this one, so
3661
+ // they ride this PR instead of opening a second one editing the same lines.
3662
+ // Whoever opens this review must know it answers more than one request.
3663
+ // Only rendered when it actually happened (§1.5 art.3 — no empty container).
3664
+ const carried = pgoals.filter((x) => x.foldedInto === g.id);
3665
+ const carryNote = carried.length ? (foldLangFor(g) === 'ja'
3666
+ ? `+ ${carried.map((x) => `#${x.id}`).join(', ')} の仕事もここに入っています`
3667
+ : `also carries ${carried.map((x) => `#${x.id}`).join(', ')}`) : '';
3612
3668
  return `<div class="task revrow" data-goal="${g.id}"><div class="ic pending"></div><div class="tx"><div class="name">${esc(checkLine)} ${goalSourceBadge(g.source)}</div>
3669
+ ${carryNote ? `<div class="cond carries">${carryNote}</div>` : ''}
3613
3670
  <div class="cond">${g.pr ? `<a class="prbig" href="${esc(g.pr)}" target="_blank">PR #${esc((g.pr.match(/pull\/(\d+)/) ?? [,''])[1])} ↗</a>` : ''}</div>
3614
3671
  <div class="qreplyrow" data-qreplyrow="${g.id}"${state.qreplyOpen.has(g.id) ? '' : ' hidden'}><input type="text" data-qreplyinput="${g.id}" placeholder="直したい点を書いて送信(Todoに戻して再作業)"><button type="button" class="qreplysend" data-qreplysend="${g.id}" aria-label="Send">${ICONS.up}</button></div></div>
3615
3672
  <div class="revquick">
@@ -3619,6 +3676,16 @@ function renderTasks(){
3619
3676
  </div></div>`;
3620
3677
  }).join('')
3621
3678
  + (reviewGoalsAll.length > REVIEW_LIMIT ? `<div class="more" data-reviewmore="1">${state.showAllReview ? 'show less' : `more · +${reviewGoalsAll.length - REVIEW_LIMIT}`}</div>` : '');
3679
+ // PRD §3.5: a folded goal is never deleted — deleting it just moves the question
3680
+ // from "why is my finished work stuck" to "where did #13 go". It keeps its row and
3681
+ // says where its work went. Not a failure: no red, no painted surface (§1.5 art.8);
3682
+ // no ✓/✕ either, because the thing to approve lives on the goal it was folded into.
3683
+ const foldedRows = pgoals.filter((g) => g.status === 'folded').map((g) => {
3684
+ const checkLine = (g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').slice(0, 66);
3685
+ const mergedNote = foldLangFor(g) === 'ja' ? `→ #${esc(String(g.foldedInto))} にまとめました` : `merged into #${esc(String(g.foldedInto))}`;
3686
+ return `<div class="task revrow foldrow" data-goal="${g.id}"><div class="ic pending"></div><div class="tx"><div class="name">${esc(checkLine)} ${goalSourceBadge(g.source)}</div>
3687
+ <div class="cond">${mergedNote}</div></div></div>`;
3688
+ }).join('');
3622
3689
  const doneRows = grouped(list.filter((t) => ['done', 'skipped'].includes(t.status)).sort((a, b) => b.num - a.num), (t) => row(t));
3623
3690
  // Pending = deliberately shelved goals (e.g. "npm公開はPhase 2"): never
3624
3691
  // planned or queued until Start is pressed. Parked below To do on purpose.
@@ -3630,7 +3697,7 @@ function renderTasks(){
3630
3697
  const savedQAns = captureFocusedAnswerInput($('tasklist'), 'data-qansinput');
3631
3698
  $('tasklist').innerHTML =
3632
3699
  sec('Attention', attention, 'attn')
3633
- + sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
3700
+ + sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review + foldedRows, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
3634
3701
  + sec(pickColumnLabel(cols, 'doing', 'Doing'), doing)
3635
3702
  + sec(pickColumnLabel(cols, 'todo', 'To do'), todo, 'todosec')
3636
3703
  + sec('Pending', pendingRows, 'dim')
@@ -3673,9 +3740,16 @@ function renderTasks(){
3673
3740
  || e.target.closest('.revquick') || e.target.closest('.qreplyrow')) return;
3674
3741
  const goalId = Number(el.dataset.goal);
3675
3742
  const goal = state.goals.find((g) => g.id === goalId);
3676
- goToGoalInChat(goalId);
3677
- if (goal?.status === 'review') openReviewChecklist(goalId);
3678
- else openGoalDetail(goalId);
3743
+ // A folded goal has nothing of its own to open — its work is in the goal it
3744
+ // was folded into. Send the click there. The target briefly goes back to
3745
+ // 'running' while it re-runs this goal's request, so resolve what to open
3746
+ // from the target's own status rather than assuming it is still in Review.
3747
+ const opened = goal?.status === 'folded' && goal.foldedInto != null
3748
+ ? state.goals.find((x) => x.id === goal.foldedInto) ?? goal
3749
+ : goal;
3750
+ goToGoalInChat(opened.id);
3751
+ if (opened.status === 'review') openReviewChecklist(opened.id);
3752
+ else openGoalDetail(opened.id);
3679
3753
  });
3680
3754
  }
3681
3755
  for (const b of $('tasklist').querySelectorAll('.reqopen')) {
@@ -5140,11 +5214,18 @@ function renderFsReview(){
5140
5214
  const pgoals = state.goals.filter((g) => g.projectId === state.active);
5141
5215
  const list = state.tasks.filter((t) => t.projectId === state.active);
5142
5216
  const rows = buildReviewDigest({ goals: pgoals, tasks: list });
5143
- if (!rows.length) { el.innerHTML = ''; return; } // §1.5-3: no reviews the UI itself does not exist
5217
+ // PRD §3.5/§6.5: folded goals still have a row they were never deleted, their work
5218
+ // just went somewhere else. They must be able to hold this section up ALONE: the goal
5219
+ // they were folded into flips back to 'running' while it re-runs their request, so
5220
+ // there is a window where `rows` is empty and only the folded ones remain. Returning
5221
+ // early there would make #13 vanish for the length of the re-run — exactly the "where
5222
+ // did my finished work go" this feature exists to prevent.
5223
+ const foldedGoals = pgoals.filter((g) => g.status === 'folded');
5224
+ if (!rows.length && !foldedGoals.length) { el.innerHTML = ''; return; } // §1.5-3: no reviews → the UI itself does not exist
5144
5225
  const green = rows.filter((r) => !r.testResult || r.testResult.ok !== false).length;
5145
5226
  const proofN = rows.filter((r) => r.proof).length;
5146
5227
  const blockedN = pgoals.filter((g) => g.status === 'blocked').length;
5147
- const pct = Math.round((green / rows.length) * 100);
5228
+ const pct = rows.length ? Math.round((green / rows.length) * 100) : 0;
5148
5229
  // CDO review 2026-07-08 §6: same fix as the s5sum digest above — green/rows.length
5149
5230
  // already exclude blocked goals (different status, never enters `rows`), so the
5150
5231
  // ratio was already correct; "all green" is the line that used to fire regardless
@@ -5170,26 +5251,49 @@ function renderFsReview(){
5170
5251
  // retest ×3 note (v45 §5.3): the goal came back green from a Ledger Dismiss
5171
5252
  const g0 = pgoals.find((g) => g.id === r.goalIds[0]);
5172
5253
  const rnote = g0?.retest?.passed ? `<span class="rnote">✓ ${esc(g0.retest.note)}</span>` : '';
5173
- return `<div class="rcard${img ? '' : ' noimg'}" data-fsrev="${r.goalIds[0]}">${img}<div class="rc"><div class="t">${rid}${esc(r.checkLine)}</div>${rnote}${pr}</div>
5254
+ // PRD §6.5: goals that landed on the same files ride this card's PR instead of
5255
+ // opening a second one that edits the same lines. Whoever opens this review has
5256
+ // to know it answers more than one request. Only when it happened (§1.5-3).
5257
+ const carried = pgoals.filter((x) => r.goalIds.includes(x.foldedInto));
5258
+ const cnote = carried.length ? `<span class="fnote">${foldLangFor(g0) === 'ja'
5259
+ ? `+ ${carried.map((x) => `#${x.id}`).join(', ')} の仕事もここに入っています`
5260
+ : `also carries ${carried.map((x) => `#${x.id}`).join(', ')}`}</span>` : '';
5261
+ return `<div class="rcard${img ? '' : ' noimg'}" data-fsrev="${r.goalIds[0]}">${img}<div class="rc"><div class="t">${rid}${esc(r.checkLine)}</div>${rnote}${cnote}${pr}</div>
5174
5262
  <div class="act"><button class="b" data-fsapprove="${ids}" title="Approve">✓</button><button class="b no" data-fsdismiss="${ids}" title="Send back">✕</button></div></div>`;
5175
5263
  };
5264
+ // A folded goal is settled, not failed: no ✓/✕ (the thing to approve lives on the goal
5265
+ // it was folded into), no red, no painted surface (§1.5-8) — just its own number, what
5266
+ // it asked for, and where the work went. Tapping it opens that goal (§1.5-9 wording).
5267
+ const foldCard = (g) => {
5268
+ const line = String(g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').slice(0, 60);
5269
+ const mergedNote = foldLangFor(g) === 'ja' ? `→ #${esc(String(g.foldedInto))} にまとめました` : `merged into #${esc(String(g.foldedInto))}`;
5270
+ return `<div class="rcard noimg fold" data-fsfold="${g.id}">
5271
+ <div class="rc"><div class="t"><span class="rid gno" data-n="${g.id}">#${g.id}</span>${esc(line)}</div>
5272
+ <span class="fnote">${mergedNote}</span></div></div>`;
5273
+ };
5176
5274
  // V3-C (Masa決定 2026-07-07): no collapsed strip — it duplicated the sm2 meter right
5177
5275
  // above. Reviews render directly: meter + Review header + cards (+N more fold, §1.5-8).
5178
- el.innerHTML = `<div class="sm2">
5276
+ // The meter counts what is reviewable, so it only exists when something is (§1.5-3):
5277
+ // during the re-run window the section can be folded-rows-only, and "0 / 0 ready" is
5278
+ // a claim about nothing.
5279
+ el.innerHTML = `${rows.length ? `<div class="sm2">
5179
5280
  <div class="m-top"><span class="num mt">${green} / ${rows.length}</span><span> ready</span><button class="detailbtn" id="fsSeeAll">See all</button></div>
5180
5281
  <div class="m-bar"><i style="width:${pct}%"></i></div>
5181
- <div class="s">${esc(sline)}</div></div>
5282
+ <div class="s">${esc(sline)}</div></div>` : ''}
5182
5283
  <div class="revhd" id="fsRevHd"><span class="hd">Review</span><span class="rnum num">${green} ready</span>
5183
5284
  <span class="rvbtns">
5184
5285
  <button class="rvb${fsUI.rv === 3 ? ' on' : ''}" data-fsrv="3" title="Thumbnails"><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>
5185
5286
  <button class="rvb${fsUI.rv === 1 ? ' on' : ''}" data-fsrv="1" title="List"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="M13 6h8M13 9h5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="M13 16h8M13 19h5"/></svg></button>
5186
5287
  <button class="rvb${fsUI.rv === 2 ? ' on' : ''}" data-fsrv="2" title="Compact"><svg viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg></button>
5187
5288
  </span></div>
5188
- <div class="revlist">${shown.map(card).join('')}
5289
+ <div class="revlist">${shown.map(card).join('')}${foldedGoals.map(foldCard).join('')}
5189
5290
  ${rows.length > RCAP ? `<div class="morefold" id="fsRevMore">${fsUI.revMore ? 'less' : `+${rows.length - RCAP} more`}<span class="g">›</span></div>` : ''}</div>`;
5190
5291
  // See all = the Ledger dialog (HANDOFF-v45 §5, replacing the ペライチ);
5191
5292
  // Cmd/Ctrl+click = standalone full-page view in a new tab (#seeall route).
5192
- $('fsSeeAll').onclick = (e) => { e.stopPropagation();
5293
+ // guarded: the meter (and with it See all) is absent while the section holds only
5294
+ // folded rows — see the §1.5-3 note above.
5295
+ const seeAll = $('fsSeeAll');
5296
+ if (seeAll) seeAll.onclick = (e) => { e.stopPropagation();
5193
5297
  if (e.metaKey || e.ctrlKey) { window.open(saStandaloneUrl(), '_blank'); return; }
5194
5298
  saOpen(); };
5195
5299
  const moreEl = el.querySelector('#fsRevMore');
@@ -5200,6 +5304,19 @@ function renderFsReview(){
5200
5304
  if (e.target.closest('a') || e.target.closest('.act')) return;
5201
5305
  openReviewChecklist(Number(c.dataset.fsrev));
5202
5306
  };
5307
+ // A folded goal has nothing of its own to review — its work is in the goal it was
5308
+ // folded into, so the tap goes there. That goal is only in Review once its re-run
5309
+ // finishes, so resolve from its live status instead of assuming (openReviewChecklist
5310
+ // on a running goal would present an empty checklist).
5311
+ for (const c of el.querySelectorAll('[data-fsfold]')) c.onclick = (e) => {
5312
+ if (e.target.closest('a')) return;
5313
+ const g = state.goals.find((x) => x.id === Number(c.dataset.fsfold));
5314
+ const target = state.goals.find((x) => x.id === g?.foldedInto);
5315
+ if (!target) return;
5316
+ goToGoalInChat(target.id);
5317
+ if (target.status === 'review') openReviewChecklist(target.id);
5318
+ else openGoalDetail(target.id);
5319
+ };
5203
5320
  for (const b of el.querySelectorAll('[data-fsapprove]')) b.onclick = async (e) => { e.stopPropagation();
5204
5321
  let r; for (const id of b.dataset.fsapprove.split(',')) r = await fetch(withKey(`/api/goals/${id}/approve`), { method: 'POST' });
5205
5322
  if (r && r.ok) { celebrateApprove(); await refresh(); } else showErr('Approve failed.'); };
@@ -6061,12 +6178,13 @@ function bootDisconnected(info){
6061
6178
  #connectGate .cg-switch a{color:var(--blue,#8fb0ff);text-decoration:none}
6062
6179
  /* A long wait (15s+) almost always means an account mismatch, not a slow
6063
6180
  first connect (Masa 2026-07-17: kept re-running npx, kept landing back
6064
- here — the always-on caption below was too easy to miss). Escalate to
6065
- the existing warning tone (--amber, same as failed-step/review chips)
6066
- color only, no filled box, per the hover/attention convention used
6067
- everywhere else in this app. */
6068
- #connectGate .cg-switch.escalated{color:var(--amber);font-weight:600}
6069
- #connectGate .cg-switch.escalated a{color:var(--amber);text-decoration:underline}
6181
+ here — the always-on caption below was too easy to miss). At this point
6182
+ the board is unusable until the user acts, so this reads as an error
6183
+ state, not a caution --danger, same tone as failed steps/review chips
6184
+ (Masa 2026-07-17: escalated hint should be red, not amber) — color only,
6185
+ no filled box, per the hover/attention convention used everywhere else. */
6186
+ #connectGate .cg-switch.escalated{color:var(--danger);font-weight:600}
6187
+ #connectGate .cg-switch.escalated a{color:var(--danger);text-decoration:underline}
6070
6188
  #connectGate .cg-x{position:absolute;top:14px;right:16px;width:28px;height:28px;border:0;background:transparent;color:var(--ink3,#6b6862);font-size:20px;line-height:1;cursor:pointer}
6071
6189
  #connectGate .cg-wait{display:inline-flex;align-items:center;gap:7px;font:500 10.5px/1 var(--mono,monospace);letter-spacing:.08em;color:var(--ink3,#6b6862);margin-top:16px}
6072
6190
  #connectGate .cg-wait i{width:6px;height:6px;border-radius:50%;background:var(--green,#3DDC97);animation:cgpulse 1.8s ease-in-out infinite}
@@ -6088,7 +6206,7 @@ function bootDisconnected(info){
6088
6206
  + '<a href="https://openai.com/codex/" target="_blank" rel="noreferrer">Codex</a>, then run the same command '
6089
6207
  + '— Galda uses whichever agent you have.</p>'
6090
6208
  + '<p class="cg-who">Signed in as <b></b></p>'
6091
- + '<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>'
6209
+ + '<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 Google account →</a></p>'
6092
6210
  + '<div class="cg-wait"><i></i>WAITING FOR THIS DEVICE</div></div>';
6093
6211
  document.body.appendChild(ov);
6094
6212
  ov.querySelector('.cg-who b').textContent = email || 'your account';
@@ -6131,7 +6249,7 @@ function bootDisconnected(info){
6131
6249
  const CG_ESCALATE_MS = 15000;
6132
6250
  const escalateTimer = (email && cgSwitch) ? setTimeout(() => {
6133
6251
  cgSwitch.classList.add('escalated');
6134
- cgSwitch.innerHTML = `Still waiting? Galda may already be running on this computer under a <b>different account</b> than <b>${email.replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]))}</b>. Run <code>npx @galda/cli --signin</code> in that terminal and pick this account. Or <a href="/logout">switch this browser →</a>`;
6252
+ cgSwitch.innerHTML = `Still waiting? Galda may already be running on this computer under a <b>different account</b> than <b>${email.replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]))}</b>. Run <code>npx @galda/cli --signin</code> in that terminal and pick this account. Or <a href="/logout">switch Google account →</a>`;
6135
6253
  }, CG_ESCALATE_MS) : null;
6136
6254
  try {
6137
6255
  const es = new EventSource('/presence');
package/engine/lib.mjs CHANGED
@@ -1069,6 +1069,7 @@ export function goalGroupStatus(goalStatus, startedAt) {
1069
1069
  if (goalStatus === 'planning' || goalStatus === 'running') return 'doing';
1070
1070
  if (goalStatus === 'review') return 'review';
1071
1071
  if (goalStatus === 'retesting') return 'doing'; // Ledger Dismiss → engine re-testing ×3 (v45 §5.3)
1072
+ if (goalStatus === 'folded') return 'review'; // PRD §6.5: rendered inside the Review section, not Done
1072
1073
  if (goalStatus === 'blocked') return 'attn'; // §4E: blocked is Attention, not Done
1073
1074
  if (goalStatus === 'needsInput') return 'todo'; // smart intake: awaiting an answer
1074
1075
  return 'done'; // includes 'reverted' (terminal)
@@ -1101,6 +1102,7 @@ export function goalChip(goalStatus, columns, startedAt) {
1101
1102
  if (goalStatus === 'done') return [pickColumnLabel(columns, 'done', 'DONE'), ''];
1102
1103
  if (goalStatus === 'retesting') return ['RE-TESTING', 'doing']; // Ledger Dismiss → retest ×3 (v45 §5.3)
1103
1104
  if (goalStatus === 'reverted') return ['REVERTED', '']; // Ledger Revert: terminal, distinct from archived
1105
+ if (goalStatus === 'folded') return ['FOLDED', '']; // PRD §6.5: settled, not failed — no red/badge class, matches 'done'/'reverted'
1104
1106
  if (goalStatus === 'blocked') return ['BLOCKED', 'blocked']; // §4E: distinct state (reason + reverify), not FAIL/Done
1105
1107
  if (goalStatus === 'needsInput') return ['確認待ち', 'blocked']; // smart intake: awaiting an answer
1106
1108
  return ['FAIL', ''];
@@ -1595,6 +1597,44 @@ export function detectConflicts(goal, otherGoals) {
1595
1597
  .map((g) => g.id);
1596
1598
  }
1597
1599
 
1600
+ // ---- the review gate (PRD §6.5) --------------------------------------------
1601
+ // Detection alone was never the point: a flag that says "this conflicts with
1602
+ // #38" still leaves the human to untangle two PRs that edit the same lines.
1603
+ // PRD §6.5 requires that PRs sitting in Review never conflict WITH EACH OTHER,
1604
+ // so work that lands on the same paths must land on the SAME PR.
1605
+ //
1606
+ // This is the last of the three gates (queue / doing / review) and the only one
1607
+ // that knows the truth: by the time a goal finishes, its changed files are
1608
+ // measured, not guessed. So the promise rests here, and the earlier gates are
1609
+ // free to be wrong — being wrong costs a re-run, never a conflict.
1610
+ //
1611
+ // Why the caller re-runs the folded goal's intent inside the target's worktree
1612
+ // instead of replaying its diff: engine/pr.mjs COPIES changed files into the PR
1613
+ // worktree (cp -R) rather than merging them, so aiming this goal's PR at the
1614
+ // target's branch would silently overwrite the target's version of any shared
1615
+ // file. Re-running lets the worker SEE the target's changes and build on them,
1616
+ // which means there is never a conflict to resolve in the first place.
1617
+ //
1618
+ // `candidates` are same-project goals already awaiting review (each
1619
+ // `{ id, changedFiles }`). Returns the id to fold into, or null to open a
1620
+ // normal PR.
1621
+ //
1622
+ // Deliberately folds ONLY when exactly one review goal overlaps. With two or
1623
+ // more, folding is not merely insufficient, it is harmful: pulling goal B into
1624
+ // A's PR drags B's paths along, so A's PR starts colliding with C's where it
1625
+ // did not before. That cluster wants one shared PR, which we cannot build by
1626
+ // bolting onto an already-open one — so we leave those flagged (the old
1627
+ // behaviour) rather than make the pile worse. Known remainder, named in the
1628
+ // handoff, not silently swallowed.
1629
+ export function pickFoldTarget(goal, candidates) {
1630
+ const files = goal?.changedFiles ?? [];
1631
+ if (!files.length) return null;
1632
+ const overlapping = (candidates ?? []).filter(
1633
+ (g) => g && g.id !== goal.id && goalsConflict(files, g.changedFiles),
1634
+ );
1635
+ return overlapping.length === 1 ? overlapping[0].id : null;
1636
+ }
1637
+
1598
1638
  // Which of `commits` (newest-first, as returned by parseGitLog) are new
1599
1639
  // since we last synced up to `lastSeenHash`? null means nothing has been
1600
1640
  // seen yet, so every commit is new. If lastSeenHash isn't found in the
package/engine/server.mjs CHANGED
@@ -21,7 +21,7 @@ import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText } from './lib.mjs';
24
- import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision } 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, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision } from './lib.mjs';
25
25
  import { openPR } from './pr.mjs';
26
26
  import { runVerification, exerciseUi } from './verify.mjs';
27
27
 
@@ -2478,7 +2478,55 @@ async function finishGoalIfComplete(goal, project, activityTask = null) {
2478
2478
  // network needed) — this goal's changed files are already final at this
2479
2479
  // point (all its tasks are done), so its conflictsWith is accurate even if
2480
2480
  // PR creation itself fails (e.g. no `gh` auth) or is still no-op'd (no code).
2481
- updateGoalConflicts(goal);
2481
+ const conflicts = updateGoalConflicts(goal);
2482
+ // PRD §6.5 — the review gate. conflictsWith above only DESCRIBES the
2483
+ // collision; this acts on it. A goal that landed on the same paths as one
2484
+ // already awaiting review must not open a SECOND PR: both would edit the same
2485
+ // lines and fight when they land, which is exactly the "10 goals, 10
2486
+ // conflicting PRs" morning §6.5 exists to kill. Same-place work goes onto the
2487
+ // same PR, so what sits in Review never conflicts with itself.
2488
+ //
2489
+ // This is the last of the three gates (queue / doing / review) and the only
2490
+ // one that carries the promise: the files compared here are measured, not
2491
+ // predicted. That is what lets the earlier, cheaper gates guess — a wrong
2492
+ // guess costs the re-run below, never a conflict.
2493
+ //
2494
+ // We re-run this goal's intent inside the target's worktree rather than
2495
+ // replaying its diff, because openPR COPIES changed files into the PR
2496
+ // worktree instead of merging them: aiming this goal at the target's branch
2497
+ // would silently overwrite the target's version of any shared file. Re-running
2498
+ // lets the worker SEE the target's changes and build on them, so there is
2499
+ // never a conflict to resolve. It costs one extra worker pass — the price of
2500
+ // gates 1/2 having missed, paid in tokens instead of in the user's morning.
2501
+ //
2502
+ // Same owner only: folding across owners would splice one user's work onto
2503
+ // another's PR and echo its text back — the same leak the intake-side fold
2504
+ // guards against with a visibility check.
2505
+ const foldTarget = conflicts.length
2506
+ ? goals.find((g) => g.id === pickFoldTarget(
2507
+ { id: goal.id, changedFiles: goalChangedFileUnion(goal.id) },
2508
+ goals
2509
+ .filter((c) => c.id !== goal.id
2510
+ && c.projectId === goal.projectId
2511
+ && c.status === 'review'
2512
+ && (c.owner ?? 'owner') === (goal.owner ?? 'owner'))
2513
+ .map((c) => ({ id: c.id, changedFiles: goalChangedFileUnion(c.id) })),
2514
+ ))
2515
+ : null;
2516
+ if (foldTarget) {
2517
+ goalAct(`Same files as goal ${foldTarget.id} — folding onto its PR instead of opening a second one.`);
2518
+ // The target re-opens: it has new work to do, and createGoalPR's rework path
2519
+ // will push the result onto the PR branch it already owns.
2520
+ foldTarget.status = 'running';
2521
+ queueReplyTask(foldTarget, goal.text.slice(0, 8000));
2522
+ saveGoal(foldTarget);
2523
+ // The folded goal keeps its row (PRD §3.5: a submission is never dropped) —
2524
+ // it becomes a pointer at the goal that now carries its work.
2525
+ goal.status = 'folded';
2526
+ goal.foldedInto = foldTarget.id;
2527
+ saveGoal(goal);
2528
+ return;
2529
+ }
2482
2530
  // Computed BEFORE createGoalPR (not inside verifyGate, which used to run
2483
2531
  // after) so the PR body's "確認ポイント" headline is available the first
2484
2532
  // time the PR is opened, not only on the review card (Masa 2026-07-16).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.23",
3
+ "version": "0.10.27",
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": {
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "license": "UNLICENSED",
15
15
  "bin": {
16
+ "cli": "bin/manager-for-ai.mjs",
16
17
  "galda": "bin/manager-for-ai.mjs",
17
18
  "galda-mcp": "engine/mcp.mjs"
18
19
  },