@galda/cli 0.10.116 → 0.10.118
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 +3 -1
- package/app/index.html +29 -5
- package/engine/server.mjs +50 -10
- package/package.json +2 -2
package/CLAUDE.md
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
## テスト(Masa指示 2026-07-03・必須)
|
|
6
6
|
|
|
7
7
|
- `毎回、実装したものにはテストを書いて、実行する`。
|
|
8
|
-
- engine/ の変更 →
|
|
8
|
+
- engine/ の変更 → **`npm test`**(=`node --test --test-concurrency=1 --test-global-setup=engine/test/helpers/browser-setup.mjs 'engine/test/*.test.mjs'`)が緑になるまで完了と言わない。
|
|
9
|
+
- 🔴 **`--test-global-setup=…/browser-setup.mjs` を外さない**(2026-07-31 実測)。38ファイルがChromeを使う。付けると**スイート全体でChromeが1つ**になり、交互A/B実測で **825秒→231秒 / 416秒→261秒**、赤も **2件→1件**(ONで新しく壊れたテストはゼロ)。**外した時の実行時間は825秒と416秒で倍ぶれる**=「テストが遅い」ではなく「マシンの機嫌を測っている」状態に戻る。素の `node --test` を手打ちすると付き忘れるので `npm test` を使う。
|
|
10
|
+
- 🔴 **共有ブラウザを `close()` しない**。`getBrowser()` が返すのは**スイート全員のChrome**。閉じると以降の全ファイルが `connect ECONNREFUSED` で落ちる(2026-07-31 に実際にそうなっていて、この仕組みは実装済みなのに一度も動いていなかった)。終わりは必ず **`closeBrowser()`**=自分で起動した時だけ殺し、共有なら切断するだけ。自前で `puppeteer.connect` する場合も同じ(`later-list-new-ui.test.mjs` が手本)。
|
|
9
11
|
- 🔴 **`--test-concurrency=1` は必須**(Masa報告のカオス調査で発見・2026-07-16)。既定(CPUコア数)だと
|
|
10
12
|
58ファイルが同時に実サーバと Chrome を立てて**マシンを飽和させ、同じコードで赤が毎回入れ替わる**。
|
|
11
13
|
**「テスト緑」がコードの状態でなく運を報告している状態を放置しない**=規律の土台。
|
package/app/index.html
CHANGED
|
@@ -1598,6 +1598,7 @@
|
|
|
1598
1598
|
#fsRoot .cc-ask-lb{font-family:var(--mono);font-size:8.5px;letter-spacing:.11em;text-transform:uppercase;
|
|
1599
1599
|
color:var(--blue);margin-bottom:6px}
|
|
1600
1600
|
#fsRoot .cc-ask-q{font-size:12.5px;font-weight:500;color:var(--ink);line-height:1.35;margin-bottom:10px}
|
|
1601
|
+
#fsRoot .cc-ask-note{font-size:12px;color:var(--amber,#e0a03a);line-height:1.35;margin-bottom:6px}
|
|
1601
1602
|
#fsRoot .cc-ask .qchips{margin:0}
|
|
1602
1603
|
/* Free-text answer, always shown alongside chips (Masa 2026-07-08「最初から出そう」):
|
|
1603
1604
|
input + the shared rainbow send ring. Restores 質問カードの自由記述回答欄. */
|
|
@@ -3530,10 +3531,13 @@ function captureFocusedAnswerInput(container, attr){
|
|
|
3530
3531
|
if (!el || !container || !container.contains(el) || !el.hasAttribute(attr)) return null;
|
|
3531
3532
|
return { id: el.getAttribute(attr), value: el.value, start: el.selectionStart, end: el.selectionEnd };
|
|
3532
3533
|
}
|
|
3533
|
-
function restoreFocusedAnswerInput(container, attr, saved){
|
|
3534
|
+
function restoreFocusedAnswerInput(container, attr, saved, shouldRestoreValue){
|
|
3534
3535
|
if (!saved || !container) return;
|
|
3535
3536
|
const el = container.querySelector(`[${attr}="${saved.id}"]`);
|
|
3536
3537
|
if (!el) return;
|
|
3538
|
+
// Focus always comes back; the TEXT only when the caller still wants it there.
|
|
3539
|
+
const keep = typeof shouldRestoreValue === 'function' ? shouldRestoreValue(saved.id) : true;
|
|
3540
|
+
if (!keep) { el.value = ''; el.focus(); return; }
|
|
3537
3541
|
el.value = saved.value;
|
|
3538
3542
|
el.focus();
|
|
3539
3543
|
try { el.setSelectionRange(saved.start, saved.end); } catch { /* input type may not support it */ }
|
|
@@ -6699,8 +6703,19 @@ $('gdSend').onclick = () => {
|
|
|
6699
6703
|
const text = $('gdRevisionInput').value.trim();
|
|
6700
6704
|
if (goalId == null || !text) return;
|
|
6701
6705
|
const goal = state.goals.find((g) => g.id === goalId);
|
|
6702
|
-
if (goal?.status === 'review') sendDismiss(goalId, text);
|
|
6703
|
-
|
|
6706
|
+
if (goal?.status === 'review') { sendDismiss(goalId, text); return; }
|
|
6707
|
+
// A goal that is ASKING gets an ANSWER. This box says "Answer the question…" for
|
|
6708
|
+
// needsInput, and it used to post to /reply, where the server accepted it, returned 200
|
|
6709
|
+
// and changed nothing — the question stayed up and nobody was told (a first-time user
|
|
6710
|
+
// hit exactly this, 2026-07-31). /answer is the route that hands the words back to the
|
|
6711
|
+
// agent that asked.
|
|
6712
|
+
if (goal?.status === 'needsInput') {
|
|
6713
|
+
$('gdRevisionInput').value = '';
|
|
6714
|
+
closeGoalDetail();
|
|
6715
|
+
fsAnswerQuestion(goalId, text);
|
|
6716
|
+
return;
|
|
6717
|
+
}
|
|
6718
|
+
sendGoalInstruction(goalId, text);
|
|
6704
6719
|
};
|
|
6705
6720
|
// Feedback 8/9: Enter in the goal-detail revision box sends (like the button) —
|
|
6706
6721
|
// which is what moves the goal Review→To Do for rework. Was button-only before.
|
|
@@ -6985,9 +7000,15 @@ function renderFsFeed(){
|
|
|
6985
7000
|
// it deleted the GOAL, not the question, from an unlabelled card — and an
|
|
6986
7001
|
// irreversible action does not belong beside a text box in a feed you are
|
|
6987
7002
|
// skim-reading. Deleting still lives on the card and in the right lane.
|
|
7003
|
+
// The server marks an answer it could not use. Say it as its own line: it used to be
|
|
7004
|
+
// concatenated into the question text, so the card re-rendered looking identical and
|
|
7005
|
+
// read as "ignored" rather than "I could not use that" (2026-07-31).
|
|
7006
|
+
const notUsed = g.question.notUnderstood === true;
|
|
7007
|
+
const qText = notUsed ? String(g.question.text).replace(/^[^\n]*\n+/, '') : g.question.text;
|
|
6988
7008
|
askHtml += `<div class="cc-ask"><div class="cc-ask-lb">Question`
|
|
6989
7009
|
+ `<span class="cc-ask-of">#${esc(String(g.id))}${title ? ` ${esc(title)}` : ''}</span></div>`
|
|
6990
|
-
+ `<div class="cc-ask-
|
|
7010
|
+
+ (notUsed ? `<div class="cc-ask-note">I could not use that answer — give me a full path.</div>` : '')
|
|
7011
|
+
+ `<div class="cc-ask-q">${esc(qText)}</div>`
|
|
6991
7012
|
+ (chips ? `<div class="qchips">${chips}</div>` : '')
|
|
6992
7013
|
+ `<div class="answ-inline"><input type="text" data-fsansinput="${g.id}" placeholder="Type your answer…" aria-label="Type your answer">`
|
|
6993
7014
|
+ `<button type="button" class="sa-send" data-fsanssend="${g.id}" title="Send answer" aria-label="Send answer"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button></div></div>`;
|
|
@@ -7058,7 +7079,10 @@ function renderFsFeed(){
|
|
|
7058
7079
|
const savedFsAns = captureFocusedAnswerInput(feed, 'data-fsansinput');
|
|
7059
7080
|
feed.innerHTML = hasWork ? out : '';
|
|
7060
7081
|
if (nearBottom) sc.scrollTop = sc.scrollHeight;
|
|
7061
|
-
|
|
7082
|
+
// Not restored when the server just rejected it: putting the same words back is what
|
|
7083
|
+
// made a rejected answer look like nothing had happened at all.
|
|
7084
|
+
restoreFocusedAnswerInput(feed, 'data-fsansinput', savedFsAns, (id) =>
|
|
7085
|
+
!(state.goals.find((g) => String(g.id) === String(id))?.question?.notUnderstood === true));
|
|
7062
7086
|
window.__fsCineSync?.(); // Cinema dynamic rule: content growth may reach the band region
|
|
7063
7087
|
for (const b of feed.querySelectorAll('[data-fsretry]')) b.onclick = () => fsRetryTask(b.dataset.fsretry);
|
|
7064
7088
|
for (const b of feed.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
|
package/engine/server.mjs
CHANGED
|
@@ -975,7 +975,15 @@ const workerOpenedWith = new Set();
|
|
|
975
975
|
function sayFromWorker(goal, task, text) {
|
|
976
976
|
if (!goal || !task || workerOpenedWith.has(task.id)) return;
|
|
977
977
|
workerOpenedWith.add(task.id);
|
|
978
|
-
addGoalMessage(goal, { from: 'ai', via: task.reply ? 'thread' : 'composer', text });
|
|
978
|
+
const msg = addGoalMessage(goal, { from: 'ai', via: task.reply ? 'thread' : 'composer', text });
|
|
979
|
+
if (!msg) return;
|
|
980
|
+
// Say it out loud, now. The conversation on a goal is read on demand (a board
|
|
981
|
+
// with 100 cards must not drag 100 threads along), so a message written to it
|
|
982
|
+
// is invisible until someone opens that card — and the whole point of this one
|
|
983
|
+
// is that the person is sitting in front of the composer waiting to hear
|
|
984
|
+
// something. The text rides on the event: no fetch, and nothing for the client
|
|
985
|
+
// to work out about which messages it has already shown.
|
|
986
|
+
sendGoalEvent({ ev: 'say', goalId: goal.id, taskId: task.id, agent: task.agent ?? goal.agent, text: msg.text, at: msg.at }, goal);
|
|
979
987
|
}
|
|
980
988
|
|
|
981
989
|
function readGoalMessages(goalId) {
|
|
@@ -5491,15 +5499,7 @@ const server = createServer(async (req, res) => {
|
|
|
5491
5499
|
// A worker-authored question is a transport pause, not a new planning
|
|
5492
5500
|
// cycle. Preserve the worker session and pass the user's answer straight
|
|
5493
5501
|
// back to it; Galda must not reinterpret or rewrite the answer.
|
|
5494
|
-
if (goal.question?.kind === 'worker')
|
|
5495
|
-
goal.question = null;
|
|
5496
|
-
goal.blocked = null;
|
|
5497
|
-
goal.status = 'running';
|
|
5498
|
-
goal.startedAt = null;
|
|
5499
|
-
saveGoal(goal);
|
|
5500
|
-
const replyTask = queueReplyTask(goal, answer, { from: 'you', via: 'question' });
|
|
5501
|
-
return json(res, 200, { ...goal, replyTaskId: replyTask.id });
|
|
5502
|
-
}
|
|
5502
|
+
if (goal.question?.kind === 'worker') return json(res, 200, deliverWorkerAnswer(goal, answer));
|
|
5503
5503
|
// Bind this goal to a project rooted at `dir` and start planning. Shared by
|
|
5504
5504
|
// the "picked a repo" and "git-init'd a new folder" paths below.
|
|
5505
5505
|
const bindAndPlan = (dir) => {
|
|
@@ -5559,6 +5559,11 @@ const server = createServer(async (req, res) => {
|
|
|
5559
5559
|
goal.question = buildFolderQuestion(detectRepos(HOME), { lang });
|
|
5560
5560
|
}
|
|
5561
5561
|
goal.question.text = notUnderstoodNote(lang) + goal.question.text;
|
|
5562
|
+
// Machine-readable, because the note above is concatenated into the question text
|
|
5563
|
+
// and the card re-renders looking identical — which reads as "ignored", not as
|
|
5564
|
+
// "I could not use that" (measured 2026-07-31). The app clears the box and shows
|
|
5565
|
+
// this as its own line instead of relying on someone re-reading the paragraph.
|
|
5566
|
+
goal.question.notUnderstood = true;
|
|
5562
5567
|
saveGoal(goal);
|
|
5563
5568
|
return json(res, 200, goal);
|
|
5564
5569
|
}
|
|
@@ -5654,12 +5659,47 @@ const server = createServer(async (req, res) => {
|
|
|
5654
5659
|
return;
|
|
5655
5660
|
}
|
|
5656
5661
|
|
|
5662
|
+
// Hand a person's answer back to the worker that asked the question, unchanged. The
|
|
5663
|
+
// worker's session is preserved, so it can answer, ask again, or carry on — which is the
|
|
5664
|
+
// whole point: the question layer is Claude Code's / Codex's, not Galda's (Masa
|
|
5665
|
+
// 2026-07-31). Shared by /answer and /reply so that WHICH box the person typed into
|
|
5666
|
+
// cannot change whether their words arrive.
|
|
5667
|
+
function deliverWorkerAnswer(goal, answer) {
|
|
5668
|
+
goal.question = null;
|
|
5669
|
+
goal.blocked = null;
|
|
5670
|
+
goal.status = 'running';
|
|
5671
|
+
goal.startedAt = null;
|
|
5672
|
+
saveGoal(goal);
|
|
5673
|
+
const replyTask = queueReplyTask(goal, answer, { from: 'you', via: 'question' });
|
|
5674
|
+
return { ...goal, replyTaskId: replyTask.id };
|
|
5675
|
+
}
|
|
5657
5676
|
const replyMatch = url.pathname.match(/^\/api\/goals\/(\d+)\/reply$/);
|
|
5658
5677
|
if (replyMatch && req.method === 'POST') {
|
|
5659
5678
|
const goal = goals.find((g) => g.id === Number(replyMatch[1]));
|
|
5660
5679
|
if (!goal) return json(res, 404, { error: 'goal not found' });
|
|
5661
5680
|
if (!requireGoalOwnership(req, res, goal)) return;
|
|
5662
5681
|
if (goal.status === 'needsApproval') return json(res, 409, { error: 'goal is awaiting an approval decision — use /approval' });
|
|
5682
|
+
// A goal that is ASKING something used to accept a reply here, return 200, and change
|
|
5683
|
+
// nothing: the question stayed on screen, the goal stayed in needsInput, and the person
|
|
5684
|
+
// was told nothing (measured 2026-07-31 — a first-time user typed into a box labelled
|
|
5685
|
+
// "Answer the question…" and it silently went nowhere). Two honest outcomes instead:
|
|
5686
|
+
// • the worker asked → deliver it, exactly as /answer would. Which box someone typed
|
|
5687
|
+
// into must not decide whether their words reach the agent.
|
|
5688
|
+
// • Galda's own card → say so, so the UI can fail loudly instead of pretending.
|
|
5689
|
+
if (goal.status === 'needsInput') {
|
|
5690
|
+
if (goal.question?.kind === 'worker') {
|
|
5691
|
+
let body = '';
|
|
5692
|
+
req.on('data', (d) => { body += d; });
|
|
5693
|
+
req.on('end', () => {
|
|
5694
|
+
let text = '';
|
|
5695
|
+
try { text = String(JSON.parse(body || '{}').text ?? '').trim(); } catch {}
|
|
5696
|
+
if (!text) return json(res, 400, { error: 'text required' });
|
|
5697
|
+
return json(res, 200, deliverWorkerAnswer(goal, text));
|
|
5698
|
+
});
|
|
5699
|
+
return;
|
|
5700
|
+
}
|
|
5701
|
+
return json(res, 409, { error: 'goal is waiting on an answer — use /answer' });
|
|
5702
|
+
}
|
|
5663
5703
|
if (['stacked', 'planning'].includes(goal.status)) return json(res, 409, { error: 'goal not started yet — edit it instead' });
|
|
5664
5704
|
let body = '';
|
|
5665
5705
|
req.on('data', (d) => { body += d; });
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@galda/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.118",
|
|
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": {
|
|
7
7
|
"start": "node engine/server.mjs",
|
|
8
|
-
"test": "node --test --test-concurrency=1 'engine/test/*.test.mjs'"
|
|
8
|
+
"test": "node --test --test-concurrency=1 --test-global-setup=engine/test/helpers/browser-setup.mjs 'engine/test/*.test.mjs'"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"puppeteer-core": "^23.11.1",
|